From 1f2c0385bb31484e47af8c733ea719a77b37925b Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:20:48 +0800 Subject: [PATCH 01/37] Ignore project-local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 341c3dd..11ce483 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ generated/ *.log .omo/ .claude/ +.worktrees/ From 09071cc84d1e6c3702d92ae2c87474361e1aeda6 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:07:02 +0800 Subject: [PATCH 02/37] Restore README product mode anchors Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 8b8e514..c3285ce 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,16 @@ generated/-lark/ ### Mode A:外置宿主 / sidecar / gateway +Mode A is the external host, sidecar, or gateway path. + Mode A 是**外置宿主**路线。目标服务继续保持自己的生命周期,Code2Lark 生成的宿主在目标服务外部运行,通过 HTTP / CLI / SDK 去调用目标能力。 当前已经验证通过的 `image-agent-web` long-connection 样板,在实际落地上属于 **Mode A**:`feishu-host/` 作为外置 Python 宿主接入飞书,再通过 HTTP 调 `image-agent-web`。 ### Mode B:目标项目内增量宿主模块 +Mode B is the target-project embedded host-module path. + Mode B 是**目标项目内增量宿主模块**路线。生成包依然是 source of truth,但可以把其中的宿主模块(如 `feishu-host/`)复制到目标项目仓库里,作为一个增量模块使用。 Mode B 不是重写目标项目业务代码,而是: @@ -76,6 +80,8 @@ Mode B 不是重写目标项目业务代码,而是: ### `self-hosted-runtime` +self-hosted-runtime is the generated host module. + `self-hosted-runtime` 是生成出来的宿主模块产物。它当前可以像已验证样板一样**在目标项目外部运行**,未来也可以迁入目标项目内部作为 **Mode B** 的基础宿主模块。 ### `standalone-runtime` From a019e7150dc1436f14bcd99df1fb79af4d806890 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:22:43 +0800 Subject: [PATCH 03/37] docs: define engineering trust and Mode B closure --- ...engineering-trust-mode-b-closure-design.md | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-engineering-trust-mode-b-closure-design.md diff --git a/docs/superpowers/specs/2026-07-10-engineering-trust-mode-b-closure-design.md b/docs/superpowers/specs/2026-07-10-engineering-trust-mode-b-closure-design.md new file mode 100644 index 0000000..f391d87 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-engineering-trust-mode-b-closure-design.md @@ -0,0 +1,330 @@ +# Code2Lark 工程可信交付与 Mode B 验收闭环设计规格 + +记录时间:2026-07-10 +状态:已获设计批准,待实施计划 + +## 1. 背景与问题陈述 + +Code2Lark 已具备可运行的 adapter-first CLI、`image-agent-web` 样板、初步 generic HTTP 工作流和 Mode B 本地重放证明。但审计识别出当前阻碍可信交付与完整产品闭环的问题: + +- `handoff --copy-to` 没有模式感知地复制 `adapter/`、`feishu-host/` 或 long-connection sidecar,交付物可能无法运行; +- `generate --out` 对已有目录的清理缺少充分保护; +- generic HTTP 分析会自动将 `/api/stop`、DELETE 等危险端点暴露为可点击动作; +- standalone runtime 默认以 `0.0.0.0` 暴露、允许无凭据 debug; +- embedded long-connection 的卡片协议可能仍使用不兼容的 Card JSON 1.0 形态; +- self-hosted runtime 的 Level 2 验收材料会落入 standalone/webhook 的错误前提; +- 源码 schema 为 `0.2`,但当前 `out/`、`generated/` 有旧 `0.1` 产物,strict verify 未形成有效门禁; +- 当前仓库缺少 CI、测试分层、稳定覆盖率和运行时基线; +- Mode B replay 已完成本地生成、迁入与自检,但真实飞书 Level 2 仍需受控人工资料和点击操作。 + +本设计将现有工程修复与 Mode B 最终真实验收统一为一个可恢复的长任务。 + +## 2. 目标 + +将项目推进到以下状态: + +1. 生成的接入包可按 integration/host mode 完整、安全地交接; +2. 默认运行边界安全,危险外部服务动作不会自动暴露; +3. Card JSON、host mode、权限、配置、context、readiness、doctor 和 Level 2 材料具有一致语义; +4. manifest、重新生成的 package 与文档构成可信且可验证的事实源; +5. CI、分层测试和关键安全回归可持续运行; +6. Mode B 在 replay 目标内部完成可审计、脱敏的真实飞书 Level 2 验收,或在外部条件缺失时留下精确可续跑 blocker。 + +## 3. 非目标 + +本任务不做: + +- Slack、企业微信或其他 IM 平台; +- 群 @ 命令、私聊命令等新交互入口; +- 第三个目标项目; +- 自动部署、凭据托管、Secret 管理平台; +- 自动模拟人工在飞书客户端中的点击; +- 大规模重写全部生成器模板; +- 接管目标服务生命周期。 + +## 4. 选择的编排:分波次长任务 + 阶段门禁 + +选择“分波次长任务 + 阶段门禁”而非单一线性大任务或完全并行双轨。 + +原因: + +- 高风险交付和安全问题优先于进一步能力扩张; +- 每阶段都能独立提交、测试、恢复和审查; +- 真实飞书验收只在工程语义稳定后执行,减少返工; +- 外部凭据、权限和人工操作窗口不会阻塞 Phase 1–4; +- 最终状态能明确区分“工程完成”与“真实产品验收完成”。 + +执行顺序固定为: + +```text +Phase 0:飞书 Level 2 可用性预检(不提交 secret) +Phase 1:交付闭包与安全止损 +Phase 2:host mode、Card JSON 2.0 与验收材料一致性 +Phase 3:schema/事实源/验收语义修复及基线重建 +Phase 4:测试、CI、安全回归与最小模板去漂移 +Phase 5:受控真实飞书 Mode B replay 验收 +``` + +## 5. 状态、凭据与证据规则 + +### 5.1 任务状态 + +| 状态 | 定义 | 允许的表述 | +|---|---|---| +| 阶段通过 | 当前阶段的代码、文档、测试和门禁均已满足 | “Phase N 通过” | +| 工程完成 / 真实验收待执行 | Phase 1–4 均通过;Phase 5 尚未执行,仅因受控外部条件不足 | “工程闭环完成;Mode B 真实飞书验收待执行” | +| 外部 blocker | 凭据、权限、测试群、应用配置或人工窗口不足 | 记录缺项和续跑步骤;不得推断产品成功 | +| 完全关闭 | Phase 0–5 均通过,含 replay 内真实飞书脱敏证据 | “Mode B 已完成可重复的真实飞书验证” | + +### 5.2 凭据安全 + +- 不得将 `FEISHU_APP_SECRET`、token、完整 chat ID、`.env` 内容写入 Git、任务书、测试快照、commit、handoff 包或聊天记录。 +- Phase 0 只记录非敏感的负责人、准备状态、所需权限、事件订阅和操作窗口。 +- Phase 5 前,由授权人员仅在 replay 项目受 `.gitignore` 保护的 `.env` 或安全环境变量中注入资料。 +- 自动化产物只保留脱敏的动作、阶段、通过/失败、时间、trace/message ID 安全摘要和必要的人工证据索引。 +- 若没有外部资料,必须产生 blocker record 和精确续跑命令,禁止伪造 Level 2 成功。 + +## 6. 阶段设计 + +### Phase 0:飞书 Level 2 可用性预检 + +**目的:**提前确认最终人工验收所需应用、权限、群和操作窗口可用,但不收集 secret。 + +**工作:** + +- 确认验收责任人、操作窗口; +- 确认测试飞书应用、机器人能力和测试群存在; +- 确认可配置或已配置新版 `card.action.trigger` 长连接; +- 确认测试环境所需 API 权限; +- 确认 replay 项目的 `.env` 位置、忽略规则和启动方式; +- 写入不含 secret 的 readiness 或 blocker 记录。 + +**门禁:**Phase 0 未就绪不阻塞 Phase 1–4,但必须明确 Phase 5 状态和续跑责任。 + +**提交边界:** + +```text +docs: record Mode B Level 2 readiness and external prerequisites +``` + +### Phase 1:交付闭包与安全止损 + +**目的:**优先修复错误交付、危险动作自动暴露和未鉴权调试入口。 + +#### 1A. 模式感知 handoff 复制闭包 + +- 读取生成元数据,按 integration/host mode 复制完整运行产物; +- 所有模式复制 `adapter/`; +- standalone 复制完整 `bot-runtime/` 的必要源码和配置模板; +- self-hosted 复制完整 `feishu-host/`; +- embedded long-connection/hybrid 复制 `sidecar-long-connection/`; +- 对复制结果验证相对 import 与文件引用闭包; +- 保留空目标目录检查、secret 扫描和脱敏排除。 + +#### 1B. 安全的生成目录更新 + +- 非空 `--out` 默认拒绝; +- 只有存在匹配生成 marker 的目录可更新; +- 清理旧 mode 目录必须显式 `--force`; +- 删除前显示拟删除列表; +- 优先临时生成,验证后再替换; +- 不得删除生成 manifest 未声明管理的路径。 + +#### 1C. Generic HTTP deny-by-default + +- GET 可作为 query 草稿; +- POST/PUT/PATCH 需要人工批准; +- DELETE 和 `stop`、`delete`、`reset`、`shutdown`、`drop` 等危险语义默认禁用并分类为 destructive; +- 禁用能力不得有直接执行按钮; +- destructive 行为需要二次确认和非空 operator allowlist。 + +#### 1D. 收紧 standalone debug 默认值 + +- 默认绑定 `127.0.0.1`; +- 默认关闭无凭据 debug; +- 非 loopback host 必须有 debug token; +- 调试路由由显式开发开关控制,或从生产注册表中排除; +- token 使用恒定时间比较。 + +**验收:**build、完整测试、lockfile audit;新增 handoff 闭包、输出目录保护、危险 endpoint 和 debug 边界回归。 + +**提交边界:** + +```text +fix: copy complete generated artifacts during handoff +fix: protect existing output directories during generation +fix: require review for risky generic HTTP actions +fix: secure standalone runtime debug defaults +``` + +### Phase 2:宿主模式、Card JSON 和验收材料一致性 + +**目的:**以 host mode 为单一事实源,统一卡片协议、权限、上下文与 Level 2 引导。 + +#### 2A. Card JSON 2.0 按模式生成 + +- `embedded-long-connection` 与 `hybrid` 生成完整 Card JSON 2.0; +- 让 webhook 与 long-connection 使用明确的 renderer/协议边界; +- 静态验证 card `schema`、`body.elements` 与 callback `behaviors[].value.action`; +- 禁止同一 renderer 隐式兼容互斥协议。 + +#### 2B. host-mode-aware 权限与上下文 + +- 将通用飞书能力需求和 webhook 专属 callback 配置分开; +- self-hosted/long-connection 不得无条件要求 `VERIFICATION_TOKEN`、公网 callback 或 `/webhook/card`; +- webhook/hybrid 才要求相应 token、callback 与可达性材料; +- permission/context/readiness/doctor 文档必须同步。 + +#### 2C. self-hosted 独立 Level 2 模板 + +- 使用 `feishu-host/.env`、websocket connection、SDK 长连接、`card.action.trigger`、起始卡与真实交互作为验收前提; +- 不得引用 `bot-runtime`、webhook callback 或 verification token。 + +**验收:**对 standalone、embedded webhook、embedded long-connection、hybrid、self-hosted fixtures 断言 card schema、permissions、context 和 Level 2 模板均与模式相符;原有 image-agent-web local contract/selfcheck 不回归。 + +**提交边界:** + +```text +fix: emit Card JSON 2.0 for long-connection integrations +fix: make generated permissions and context host-mode aware +fix: generate self-hosted Level 2 verification guidance +``` + +### Phase 3:事实源、schema 与验收语义 + +**目的:**确保当前源码、生成包、strict verify 与文档对模式和验证状态没有歧义。 + +**工作:** + +- strict verify 验证 schema version、必填字段、交叉引用、target profile; +- 旧 schema 只能经显式 migration;strict 模式必须失败并给升级指引; +- fresh analyze/generate `image-agent-web` 基线包; +- 新 `out/`、`generated/` 全部为 schema `0.2`; +- 明确区分结构/静态验证、本地运行验证、历史真实样板证据和当前包真实验收; +- 标记旧计划为历史或已替代; +- 新建单一能力矩阵,记录 target profile、delivery mode、host mode、本地验证、真实飞书与证据位置。 + +**验收:**旧 `0.1` fixture strict verify 失败;重新生成包为 `0.2` 且 strict verify 通过;README、矩阵、summary、Level 2 记录的模式和证据语义一致。 + +**提交边界:** + +```text +fix: enforce manifest schema integrity during verification +docs: regenerate canonical package and clarify evidence states +docs: consolidate current capability and validation status +``` + +### Phase 4:测试、CI 与最小维护性治理 + +**目的:**让上述修复持续可验证,避免模板继续产生未检测漂移。 + +#### 4A. 测试与 CI + +- 增加 `test:unit`、`test:smoke`、`test:e2e`、`test:coverage`; +- 明确 E2E 中联网安装的边界; +- CI clean install 后执行 build/check、unit/smoke、lockfile audit;E2E 独立 job 或受控触发; +- 设置 `engines` 与 `packageManager`。 + +#### 4B. 安全回归与覆盖率 + +覆盖 handoff、危险 endpoint、Card JSON 2.0、schema、output 保护、debug、template/size、private/metadata image URL、图片与 generic response 大小、self-hosted 重复事件/并发和 verify 执行边界。 + +#### 4C. 最小模板去漂移 + +- 仅抽取本轮触及的 action risk、字段校验、card spec、host-mode metadata; +- 不进行全量模板重写; +- 新逻辑不能继续以脆弱正则去类型作为长期实现路径; +- 抽取结果必须由跨模式 contract cases 约束。 + +**验收:**CI 可在干净环境运行;脚本可独立执行;关键安全路径有防回退验证;Node/npm 基线明确;重构不改变既有对外生成行为。 + +**提交边界:** + +```text +test: split verification suites by execution boundary +ci: validate build smoke tests and dependency audit +test: cover generated package security boundaries +refactor: centralize host-mode generation contracts +``` + +### Phase 5:受控真实飞书 Mode B replay 验收 + +**前提:**Phase 0 预检已记录;Phase 1–4 已通过;replay 使用当前重新生成的 `feishu_host/`;授权人员仅在本地忽略的环境中注入凭据。 + +**执行:** + +1. 启动 replay 目标服务; +2. 在 replay `feishu_host/` 内运行 local contract 和 selfcheck; +3. 启动 SDK 长连接; +4. 发送真实起始卡; +5. 人工在测试群执行 generate、iterate、batch、refresh、failure path; +6. 归档脱敏动作结果、时间和 trace/message 安全摘要; +7. 重新运行 doctor/readiness/evidence/handoff。 + +**验收:**host 运行于 replay 内部且不依赖 `generated/...`;事件经 `card.action.trigger` 到达 replay host;五条路径均有证据;无 secret 泄漏。 + +**外部 blocker:**若资料、权限、测试群或人工窗口不可用,完成并提交 Phase 1–4,写入 `docs/mode-b-level2-blocker-record.md`,列出非敏感缺项、负责人和精确续跑命令。最终只能标记为“工程完成 / 真实验收待执行”。 + +## 7. 文件与文档结构 + +新增或维护: + +```text +docs/ + engineering-trust-and-mode-b-closure-task-book.md + mode-b-level2-readiness.md + mode-b-level2-blocker-record.md # 仅发生外部阻塞时 + capability-validation-matrix.md +``` + +- 主任务书定义 Phase 0–5、非目标、提交边界、门禁与 DoD; +- readiness 文件仅记录非敏感外部可用性; +- blocker record 仅在 Phase 5 受阻时维护; +- capability matrix 成为 profile/mode/host/验证状态的当前事实源。 + +旧计划不删除,但必须注明为历史计划或链接到新任务书,避免它们被误用为当前执行规范。 + +## 8. 最终 Definition of Done + +### 8.1 完全关闭 + +只有当以下都满足时,长任务完全关闭: + +- handoff 可交付完整运行闭包; +- `generate --out` 不会误删或覆写非管理目录; +- generic HTTP 不自动执行危险端点; +- standalone 默认没有非 loopback 未鉴权 debug; +- verify 对不可信包默认不执行代码; +- long-connection/hybrid 生成完整 Card JSON 2.0; +- 所有 host-mode 文档、权限、context、doctor、readiness、Level 2 材料一致; +- strict verify 拒绝过时/不完整 manifest; +- canonical package 已以 schema 0.2 fresh 生成并验证; +- CI、分层测试、运行时基线和关键安全回归就绪; +- replay 内 Mode B 真实飞书长连接和 `card.action.trigger` 已验证; +- generate、iterate、batch、refresh、failure 均有脱敏证据; +- Git、产物、测试输出和 handoff 均无 secret。 + +### 8.2 可接受的受阻终态 + +当 Phase 1–4 全部通过,而 Phase 5 仅因外部人工条件无法执行时,允许关闭工程实现,但最终报告必须使用: + +> 工程完成 / 真实验收待执行 + +不得将其表述为 Mode B 已完整产品化或已完成真实飞书验证。 + +## 9. 实施规则 + +- 每个明确行为变更或安全修复独立 commit;重构与行为修复分离; +- 每个 Phase 开始前先声明该阶段的验收命令; +- 每个 Phase 结束至少执行: + +```bash +npm test +npm audit --package-lock-only --audit-level=moderate +git diff --check +``` + +- 修改生成逻辑时必须添加 fixture 或回归测试; +- 修改 generated package 行为必须 fresh analyze → generate → strict verify; +- Phase 5 replay 与主仓库代码开发隔离; +- 用户可见行为、模式或证据语义变更必须同时更新 README 和能力矩阵。 From 2724df573cbde06339d627bde487821f2bf679fa Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:47:02 +0800 Subject: [PATCH 04/37] docs: correct MVP validation baseline --- ...engineering-trust-mode-b-closure-design.md | 153 ++++++++++-------- 1 file changed, 89 insertions(+), 64 deletions(-) diff --git a/docs/superpowers/specs/2026-07-10-engineering-trust-mode-b-closure-design.md b/docs/superpowers/specs/2026-07-10-engineering-trust-mode-b-closure-design.md index f391d87..d0aa8d8 100644 --- a/docs/superpowers/specs/2026-07-10-engineering-trust-mode-b-closure-design.md +++ b/docs/superpowers/specs/2026-07-10-engineering-trust-mode-b-closure-design.md @@ -1,11 +1,22 @@ -# Code2Lark 工程可信交付与 Mode B 验收闭环设计规格 +# Code2Lark MVP 整合包固化与第二目标验证设计规格 记录时间:2026-07-10 状态:已获设计批准,待实施计划 ## 1. 背景与问题陈述 -Code2Lark 已具备可运行的 adapter-first CLI、`image-agent-web` 样板、初步 generic HTTP 工作流和 Mode B 本地重放证明。但审计识别出当前阻碍可信交付与完整产品闭环的问题: +Code2Lark 已具备可运行的 adapter-first CLI、`image-agent-web` 样板、初步 generic HTTP 工作流,并已在 `image-agent-web` 完成 Mode A(外置宿主)和 Mode B(目标项目内增量宿主模块)的部署测试验证。 + +因此,当前主线**不是重新证明 Mode B 的真实飞书可用性,也不是继续围绕 `image-agent-web` 修补宿主功能**,而是: + +```text +提炼 image-agent-web 已验证的 Mode A / Mode B 经验 +→ 固化为可信、可交付、可泛用的 MVP 整合包 +→ 选择新的非图片目标项目进行接入改造与验证 +→ 用第二目标的反馈推进下一轮泛化 +``` + +审计识别出当前阻碍该 MVP 整合包可信交付的问题: - `handoff --copy-to` 没有模式感知地复制 `adapter/`、`feishu-host/` 或 long-connection sidecar,交付物可能无法运行; - `generate --out` 对已有目录的清理缺少充分保护; @@ -15,25 +26,27 @@ Code2Lark 已具备可运行的 adapter-first CLI、`image-agent-web` 样板、 - self-hosted runtime 的 Level 2 验收材料会落入 standalone/webhook 的错误前提; - 源码 schema 为 `0.2`,但当前 `out/`、`generated/` 有旧 `0.1` 产物,strict verify 未形成有效门禁; - 当前仓库缺少 CI、测试分层、稳定覆盖率和运行时基线; -- Mode B replay 已完成本地生成、迁入与自检,但真实飞书 Level 2 仍需受控人工资料和点击操作。 +- 当前 generic HTTP 路径只完成 package validation,还未通过第二目标的完整接入验证证明其可泛用性。 -本设计将现有工程修复与 Mode B 最终真实验收统一为一个可恢复的长任务。 +本设计将工程修复、经验固化和第二目标验证统一为一个可恢复的长任务。 ## 2. 目标 将项目推进到以下状态: -1. 生成的接入包可按 integration/host mode 完整、安全地交接; -2. 默认运行边界安全,危险外部服务动作不会自动暴露; -3. Card JSON、host mode、权限、配置、context、readiness、doctor 和 Level 2 材料具有一致语义; -4. manifest、重新生成的 package 与文档构成可信且可验证的事实源; -5. CI、分层测试和关键安全回归可持续运行; -6. Mode B 在 replay 目标内部完成可审计、脱敏的真实飞书 Level 2 验收,或在外部条件缺失时留下精确可续跑 blocker。 +1. 已验证的 Mode A / Mode B 经验被准确封存为 MVP 的回归基线; +2. 生成的接入包可按 integration/host mode 完整、安全地交接; +3. 默认运行边界安全,危险外部服务动作不会自动暴露; +4. Card JSON、host mode、权限、配置、context、readiness、doctor 和 Level 2 材料具有一致语义; +5. manifest、重新生成的 package 与文档构成可信且可验证的事实源; +6. CI、分层测试和关键安全回归可持续运行; +7. 一个与 `image-agent-web` 不同的第二目标项目通过 MVP 整合包完成接入改造与验证,并将反馈沉淀为下一阶段输入。 ## 3. 非目标 本任务不做: +- 重新开展或重复记录已经完成的 `image-agent-web` Mode A / Mode B 部署测试; - Slack、企业微信或其他 IM 平台; - 群 @ 命令、私聊命令等新交互入口; - 第三个目标项目; @@ -48,63 +61,62 @@ Code2Lark 已具备可运行的 adapter-first CLI、`image-agent-web` 样板、 原因: -- 高风险交付和安全问题优先于进一步能力扩张; +- 高风险交付和安全问题必须先于第二目标扩展; - 每阶段都能独立提交、测试、恢复和审查; -- 真实飞书验收只在工程语义稳定后执行,减少返工; -- 外部凭据、权限和人工操作窗口不会阻塞 Phase 1–4; -- 最终状态能明确区分“工程完成”与“真实产品验收完成”。 +- 第二目标只在 MVP 整合包语义稳定后接入,减少把样板特例带入新项目的风险; +- 已验证的 A/B 路径作为回归锚点,而非本轮等待的外部 blocker; +- 最终状态能明确区分“MVP 整合包工程完成”和“第二目标验证完成”。 执行顺序固定为: ```text -Phase 0:飞书 Level 2 可用性预检(不提交 secret) +Phase 0:封存 Mode A / Mode B 验证经验并定义 MVP 整合包边界 Phase 1:交付闭包与安全止损 Phase 2:host mode、Card JSON 2.0 与验收材料一致性 -Phase 3:schema/事实源/验收语义修复及基线重建 +Phase 3:schema/事实源/验收语义修复及 MVP 基线重建 Phase 4:测试、CI、安全回归与最小模板去漂移 -Phase 5:受控真实飞书 Mode B replay 验收 +Phase 5:第二目标项目接入改造、验证与反馈沉淀 ``` -## 5. 状态、凭据与证据规则 +## 5. 证据与凭据规则 ### 5.1 任务状态 | 状态 | 定义 | 允许的表述 | |---|---|---| | 阶段通过 | 当前阶段的代码、文档、测试和门禁均已满足 | “Phase N 通过” | -| 工程完成 / 真实验收待执行 | Phase 1–4 均通过;Phase 5 尚未执行,仅因受控外部条件不足 | “工程闭环完成;Mode B 真实飞书验收待执行” | -| 外部 blocker | 凭据、权限、测试群、应用配置或人工窗口不足 | 记录缺项和续跑步骤;不得推断产品成功 | -| 完全关闭 | Phase 0–5 均通过,含 replay 内真实飞书脱敏证据 | “Mode B 已完成可重复的真实飞书验证” | +| MVP 整合包完成 | Phase 0–4 通过,A/B 基线被保留且可交付 | “MVP 整合包完成,准备第二目标验证” | +| 第二目标验证受阻 | 新目标的外部服务、权限、测试环境或人工窗口不足 | 记录非敏感缺项、负责人和续跑步骤;不得推断产品成功 | +| 完全关闭 | Phase 0–5 均通过,第二目标有脱敏接入验证证据 | “MVP 已在两个不同目标上完成验证” | ### 5.2 凭据安全 - 不得将 `FEISHU_APP_SECRET`、token、完整 chat ID、`.env` 内容写入 Git、任务书、测试快照、commit、handoff 包或聊天记录。 -- Phase 0 只记录非敏感的负责人、准备状态、所需权限、事件订阅和操作窗口。 -- Phase 5 前,由授权人员仅在 replay 项目受 `.gitignore` 保护的 `.env` 或安全环境变量中注入资料。 +- 重用现有已验证飞书环境或接入第二目标时,凭据只能保存在本地被 `.gitignore` 保护的 `.env` 或安全环境变量中。 - 自动化产物只保留脱敏的动作、阶段、通过/失败、时间、trace/message ID 安全摘要和必要的人工证据索引。 -- 若没有外部资料,必须产生 blocker record 和精确续跑命令,禁止伪造 Level 2 成功。 +- 第二目标的外部验证无法执行时,必须产生 blocker record 和精确续跑命令,禁止伪造真实验证成功。 ## 6. 阶段设计 -### Phase 0:飞书 Level 2 可用性预检 +### Phase 0:封存 Mode A / Mode B 经验并定义 MVP 边界 -**目的:**提前确认最终人工验收所需应用、权限、群和操作窗口可用,但不收集 secret。 +**目的:**将已完成的 image-agent-web Mode A / Mode B 部署测试作为事实和回归锚点,明确本轮 MVP 的产品边界,不重复验证已完成的路径。 **工作:** -- 确认验收责任人、操作窗口; -- 确认测试飞书应用、机器人能力和测试群存在; -- 确认可配置或已配置新版 `card.action.trigger` 长连接; -- 确认测试环境所需 API 权限; -- 确认 replay 项目的 `.env` 位置、忽略规则和启动方式; -- 写入不含 secret 的 readiness 或 blocker 记录。 +- 将 Mode A、Mode B 均已通过部署测试的事实写入当前能力矩阵与项目状态; +- 区分“已验证样板经验”与“当前重新生成 package 的静态/本地验证”; +- 明确 MVP 的标准产物为 adapter-first 整合包,Mode A 是默认低侵入落地,Mode B 是可迁入目标项目的增量宿主模块; +- 定义第二目标选择标准:非图片领域、存在清晰 HTTP API 或 CLI/SDK 边界、可在受控环境中运行、具备至少一个读操作和一个受控动作; +- 默认第二目标为已存在的 `calendar-stock-updater`;若执行前发现其不可用,可替换为满足相同标准且可访问的新项目,并记录选择理由; +- 写入不含 secret 的基线与第二目标准备记录。 -**门禁:**Phase 0 未就绪不阻塞 Phase 1–4,但必须明确 Phase 5 状态和续跑责任。 +**门禁:**文档不得再把 Mode B 描述为“待真实验收”或“仅本地重放证明”;所有后续 Phase 必须保持 A/B 样板回归。 **提交边界:** ```text -docs: record Mode B Level 2 readiness and external prerequisites +docs: establish validated Mode A and Mode B MVP baseline ``` ### Phase 1:交付闭包与安全止损 @@ -178,9 +190,10 @@ fix: secure standalone runtime debug defaults #### 2C. self-hosted 独立 Level 2 模板 - 使用 `feishu-host/.env`、websocket connection、SDK 长连接、`card.action.trigger`、起始卡与真实交互作为验收前提; -- 不得引用 `bot-runtime`、webhook callback 或 verification token。 +- 不得引用 `bot-runtime`、webhook callback 或 verification token; +- 对 image-agent-web 保留其已经完成 Mode A / Mode B 部署测试的证据状态,不重开其验收门禁。 -**验收:**对 standalone、embedded webhook、embedded long-connection、hybrid、self-hosted fixtures 断言 card schema、permissions、context 和 Level 2 模板均与模式相符;原有 image-agent-web local contract/selfcheck 不回归。 +**验收:**对 standalone、embedded webhook、embedded long-connection、hybrid、self-hosted fixtures 断言 card schema、permissions、context 和 Level 2 模板均与模式相符;A/B 样板的 local contract/selfcheck 不回归。 **提交边界:** @@ -198,19 +211,19 @@ fix: generate self-hosted Level 2 verification guidance - strict verify 验证 schema version、必填字段、交叉引用、target profile; - 旧 schema 只能经显式 migration;strict 模式必须失败并给升级指引; -- fresh analyze/generate `image-agent-web` 基线包; +- fresh analyze/generate `image-agent-web` MVP 基线包; - 新 `out/`、`generated/` 全部为 schema `0.2`; -- 明确区分结构/静态验证、本地运行验证、历史真实样板证据和当前包真实验收; +- 明确区分结构/静态验证、本地运行验证、已完成的 A/B 部署测试证据和第二目标的当前验证状态; - 标记旧计划为历史或已替代; -- 新建单一能力矩阵,记录 target profile、delivery mode、host mode、本地验证、真实飞书与证据位置。 +- 新建单一能力矩阵,记录 target profile、delivery mode、host mode、本地验证、部署测试与证据位置。 -**验收:**旧 `0.1` fixture strict verify 失败;重新生成包为 `0.2` 且 strict verify 通过;README、矩阵、summary、Level 2 记录的模式和证据语义一致。 +**验收:**旧 `0.1` fixture strict verify 失败;重新生成包为 `0.2` 且 strict verify 通过;README、矩阵、summary、Level 2 记录的模式和证据语义一致,明确 Mode A/B 均已验证。 **提交边界:** ```text fix: enforce manifest schema integrity during verification -docs: regenerate canonical package and clarify evidence states +docs: regenerate canonical MVP package and clarify evidence states docs: consolidate current capability and validation status ``` @@ -247,23 +260,32 @@ test: cover generated package security boundaries refactor: centralize host-mode generation contracts ``` -### Phase 5:受控真实飞书 Mode B replay 验收 +### Phase 5:第二目标项目接入改造、验证与反馈沉淀 + +**目的:**证明 MVP 整合包不只适用于 image-agent-web,并将新目标暴露的领域差异转为下一轮泛化输入。 -**前提:**Phase 0 预检已记录;Phase 1–4 已通过;replay 使用当前重新生成的 `feishu_host/`;授权人员仅在本地忽略的环境中注入凭据。 +**默认目标:**`calendar-stock-updater`。如该目标在执行时不可访问或不满足 Phase 0 的选择标准,可替换为另一个非图片、具备明确交互边界的项目,并在能力矩阵记录原因。 **执行:** -1. 启动 replay 目标服务; -2. 在 replay `feishu_host/` 内运行 local contract 和 selfcheck; -3. 启动 SDK 长连接; -4. 发送真实起始卡; -5. 人工在测试群执行 generate、iterate、batch、refresh、failure path; -6. 归档脱敏动作结果、时间和 trace/message 安全摘要; -7. 重新运行 doctor/readiness/evidence/handoff。 +1. 对第二目标执行 fresh `analyze`,人工审查 capability、risk、输入/输出 schema 和 endpoint exposure; +2. 按目标既有运行时和飞书宿主条件选择 Mode A 或 Mode B;默认优先 Mode A,只有目标明确要求内部模块时选择 Mode B; +3. fresh `generate`,并执行 strict verify、doctor、readiness、handoff; +4. 将 adapter 或增量 host 模块接入第二目标,禁止接管其生命周期或深改业务核心; +5. 执行目标本地功能验证,至少覆盖一个 read/query 和一个经过批准的 action; +6. 在可复用的受控飞书环境完成真实交互验证;如需人工点击,记录脱敏证据; +7. 将新目标暴露的 schema、卡片、权限、配置或交付缺口写入能力矩阵和后续改进列表。 -**验收:**host 运行于 replay 内部且不依赖 `generated/...`;事件经 `card.action.trigger` 到达 replay host;五条路径均有证据;无 secret 泄漏。 +**验收:**第二目标生成物不含 image 专属 action、字段或 card 文案;接入不依赖 image-agent-web 的隐含状态;至少一条 query 与一条经审查 action 完成端到端验证;无 secret 泄漏;A/B 样板回归仍通过。 -**外部 blocker:**若资料、权限、测试群或人工窗口不可用,完成并提交 Phase 1–4,写入 `docs/mode-b-level2-blocker-record.md`,列出非敏感缺项、负责人和精确续跑命令。最终只能标记为“工程完成 / 真实验收待执行”。 +**外部 blocker:**若第二目标运行环境、权限或人工操作窗口不可用,Phase 0–4 仍可标记为“MVP 整合包完成”,并写入第二目标 blocker record、非敏感缺项、负责人和精确续跑命令。不得将 generic package validation 表述成第二目标的完整接入成功。 + +**提交边界:** + +```text +test: validate MVP integration package against calendar-stock-updater +docs: record second-target integration evidence and generalization gaps +``` ## 7. 文件与文档结构 @@ -271,15 +293,17 @@ refactor: centralize host-mode generation contracts ```text docs/ - engineering-trust-and-mode-b-closure-task-book.md - mode-b-level2-readiness.md - mode-b-level2-blocker-record.md # 仅发生外部阻塞时 + mvp-integration-package-and-second-target-task-book.md + mvp-mode-a-b-baseline.md + second-target-validation-plan.md + second-target-blocker-record.md # 仅第二目标发生外部阻塞时 capability-validation-matrix.md ``` - 主任务书定义 Phase 0–5、非目标、提交边界、门禁与 DoD; -- readiness 文件仅记录非敏感外部可用性; -- blocker record 仅在 Phase 5 受阻时维护; +- A/B baseline 文件封存已完成的 image-agent-web Mode A / Mode B 部署测试; +- second-target plan 记录所选目标、模式、风险审查与验证范围; +- blocker record 仅在第二目标外部验证受阻时维护; - capability matrix 成为 profile/mode/host/验证状态的当前事实源。 旧计划不删除,但必须注明为历史计划或链接到新任务书,避免它们被误用为当前执行规范。 @@ -290,6 +314,7 @@ docs/ 只有当以下都满足时,长任务完全关闭: +- Mode A / Mode B 已验证经验被准确记录为 MVP 回归基线; - handoff 可交付完整运行闭包; - `generate --out` 不会误删或覆写非管理目录; - generic HTTP 不自动执行危险端点; @@ -298,19 +323,19 @@ docs/ - long-connection/hybrid 生成完整 Card JSON 2.0; - 所有 host-mode 文档、权限、context、doctor、readiness、Level 2 材料一致; - strict verify 拒绝过时/不完整 manifest; -- canonical package 已以 schema 0.2 fresh 生成并验证; +- canonical MVP package 已以 schema 0.2 fresh 生成并验证; - CI、分层测试、运行时基线和关键安全回归就绪; -- replay 内 Mode B 真实飞书长连接和 `card.action.trigger` 已验证; -- generate、iterate、batch、refresh、failure 均有脱敏证据; +- 第二目标通过 MVP 整合包完成接入改造; +- 第二目标至少有一条 query 和一条经批准 action 的脱敏端到端验证证据; - Git、产物、测试输出和 handoff 均无 secret。 ### 8.2 可接受的受阻终态 -当 Phase 1–4 全部通过,而 Phase 5 仅因外部人工条件无法执行时,允许关闭工程实现,但最终报告必须使用: +当 Phase 0–4 全部通过,而 Phase 5 仅因第二目标外部运行环境、权限或人工验证窗口无法执行时,允许关闭 MVP 工程实现,但最终报告必须使用: -> 工程完成 / 真实验收待执行 +> MVP 整合包完成 / 第二目标验证待执行 -不得将其表述为 Mode B 已完整产品化或已完成真实飞书验证。 +不得将其表述为已经完成第二目标的完整接入验证。 ## 9. 实施规则 @@ -326,5 +351,5 @@ git diff --check - 修改生成逻辑时必须添加 fixture 或回归测试; - 修改 generated package 行为必须 fresh analyze → generate → strict verify; -- Phase 5 replay 与主仓库代码开发隔离; +- 第二目标接入环境与主仓库代码开发隔离; - 用户可见行为、模式或证据语义变更必须同时更新 README 和能力矩阵。 From c084860885ed0632b8d9ca93ad6ebaf3d5477055 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:01:05 +0800 Subject: [PATCH 05/37] docs: establish validated Mode A and Mode B baseline Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 4 ++-- docs/development-charter.md | 2 +- docs/fde-handoff.md | 3 ++- docs/mvp-1a-image-agent-web.md | 2 ++ docs/project-status.md | 2 +- tests/cli-smoke.test.mjs | 8 ++++++++ 6 files changed, 16 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c3285ce..0f5dcab 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ generated/-lark/ ### Mode A:外置宿主 / sidecar / gateway -Mode A is the external host, sidecar, or gateway path. +Mode A is the external host, sidecar, or gateway path. Mode A is a validated deployment-test baseline in the verified `image-agent-web` sample. Mode A 是**外置宿主**路线。目标服务继续保持自己的生命周期,Code2Lark 生成的宿主在目标服务外部运行,通过 HTTP / CLI / SDK 去调用目标能力。 @@ -69,7 +69,7 @@ Mode A 是**外置宿主**路线。目标服务继续保持自己的生命周期 ### Mode B:目标项目内增量宿主模块 -Mode B is the target-project embedded host-module path. +Mode B is the target-project embedded host-module path. Mode B is a validated deployment-test baseline in the verified `image-agent-web` sample. Mode B 是**目标项目内增量宿主模块**路线。生成包依然是 source of truth,但可以把其中的宿主模块(如 `feishu-host/`)复制到目标项目仓库里,作为一个增量模块使用。 diff --git a/docs/development-charter.md b/docs/development-charter.md index 7ae5f54..fd4ad0f 100644 --- a/docs/development-charter.md +++ b/docs/development-charter.md @@ -244,7 +244,7 @@ generated/-lark/ `self-hosted-runtime` is the generated host module:当前可作为已验证样板的外置宿主运行,后续也可作为 Mode B 的可迁入宿主模块基础。 -The verified image-agent-web sample currently proves Mode A with a Python self-hosted host module run externally. self-hosted-runtime is the foundation for Mode B, but Mode B is not considered productized until the generated host module is replayed inside a fresh target-project copy. +The verified image-agent-web sample has completed deployment-test validation in Mode A with a Python self-hosted host module run externally, and it has also completed deployment-test validation in Mode B as a target-project embedded host module. This roadmap treats those validations as the current sample baseline and consolidates them into a reusable MVP integration package. `standalone-runtime` 是参考/兜底宿主,不是主要产品形态。 diff --git a/docs/fde-handoff.md b/docs/fde-handoff.md index a6c17b1..ecc189a 100644 --- a/docs/fde-handoff.md +++ b/docs/fde-handoff.md @@ -13,7 +13,8 @@ This project should be usable by a teammate without relying on a specific Codex - `generated/-lark/` is the source-of-truth handoff package. Treat its manifests, adapter code, host modules, docs, context files, verification reports, and evidence records as the canonical handoff state. - Mode A is the external host, sidecar, or gateway path. The target service keeps its own lifecycle and the generated host runs beside it. -- Mode B is the target-project embedded host-module path. The generated package remains the source of truth, but selected host files can be copied into the target repository as an incremental module. +- Mode B is the target-project embedded host-module path. The generated package remains the source of truth, but selected host files can be copied into the target repository as an incremental module, and the verified `image-agent-web` sample has completed deployment-test validation in this mode. +- The verified `image-agent-web` sample has completed deployment-test validation in both Mode A and Mode B; this handoff guide preserves that baseline and does not reopen it. - self-hosted-runtime is the generated host module. Today it can run externally as the verified sample path; later it can also be embedded into the target project under Mode B. - Machine-readable service and interaction contracts. - Human-readable permission review. diff --git a/docs/mvp-1a-image-agent-web.md b/docs/mvp-1a-image-agent-web.md index 852b9b8..41c0dd5 100644 --- a/docs/mvp-1a-image-agent-web.md +++ b/docs/mvp-1a-image-agent-web.md @@ -8,6 +8,8 @@ Build a reproducible first MVP where Lark-deployer generates a Feishu/Lark bot i This document describes the verified sample, not the whole Code2Lark product definition. The verified sample path is Mode A: an external host / sidecar path that keeps `image-agent-web` unchanged while the generated `feishu-host/` calls it over HTTP. +The same verified sample experience has also completed Mode B deployment-test validation as a target-project embedded host module. This roadmap does not re-prove either sample validation; it preserves them as the MVP baseline for a reusable integration package. + The MVP is considered functionally proven when a real Feishu development app can send a start card, receive card clicks, call `image-agent-web /api/generate`, return a result card with the generated image or fallback image URL, accept result-card feedback and call `image-agent-web /api/iterate`, then submit a batch job through `/api/batch`, refresh `/api/batch/{batch_id}/status`, and show a `/api/batch/{batch_id}/download` link when completed images exist. ## Non-goals diff --git a/docs/project-status.md b/docs/project-status.md index 2240c69..6ae8395 100644 --- a/docs/project-status.md +++ b/docs/project-status.md @@ -40,7 +40,7 @@ analyze → plan → context(生成给所有者的凭据请求) → generate(生 - CLI 冒烟测试覆盖 embedded adapter 输出:`adapter/` 文件、`docs/integration_guide.md`、`verify --mode embedded-adapter --strict`、`doctor --mode embedded-adapter --json`。 - 运行时本地 e2e 测试覆盖:生成运行时包 → 安装/构建 → 启动 → `/webhook/card` URL 校验挑战 → `/debug/simulate-generate` → `/debug/simulate-card-action`(含表单合并、Feishu 2.0 事件形状兼容、非法输入拒绝)→ 批量任务提交/刷新 → 去重窗口 → operator 授权检查 → 签名/加密回调(当 `VERIFICATION_TOKEN` / `ENCRYPT_KEY` 设置时)。 - **image-agent-web self-hosted-runtime 已有真实飞书长连接 MVP 验证**:2026-07-07 后以 `docs/image-agent-web-mvp-verified-summary.md` 为回归锚点,确认长连接、`card.action.trigger`、Card JSON 2.0、异步 running + patch、generate / iterate / batch / refresh、失败路径已经跑通。webhook/standalone Level 2 仍按各自生成包证据记录独立管理。 -- **image-agent-web 样板分类**:The verified image-agent-web sample currently proves Mode A with a Python self-hosted host module run externally. self-hosted-runtime is the foundation for Mode B, but Mode B is not considered productized until the generated host module is replayed inside a fresh target-project copy. +- **image-agent-web 样板分类**:The verified image-agent-web sample has completed deployment-test validation in Mode A with a Python self-hosted host module run externally, and it has also completed deployment-test validation in Mode B as a target-project embedded host module. This roadmap treats those validations as the current sample baseline and consolidates them into a reusable MVP integration package. - **self-hosted-runtime 本地 MVP 已作为目标形态落地**:生成物为 `generated/-lark/feishu-host/`,包含 `.env.example`、`requirements.txt`、manifest-derived `spec/*.json`、Python card/client/validation/handler/app 文件、`local_contract_test.py` 和 `app.py --selfcheck`。最终本地完成证据必须在安装 Python 依赖后运行 strict verify,不能把缺依赖 WARN 当成绿灯。 - **calendar-stock-updater 已进入泛用工作流**:2026-07-08 验证 `C:\works\calendar-stock-updater` 可通过 `generic_http_api` 路径完成 analyze → generate embedded-adapter → `verify --mode embedded-adapter --strict` → `doctor --mode embedded-adapter --json` package validation。识别的非图片动作包括 `http.get.api.state.submit`、`http.get.api.events.submit`、`http.post.api.run.submit`、`http.post.api.stop.submit`。 - 唯一一次真实目标服务联调记录:2026-07-01,临时启动 `C:\works\image-agent-web`,验证了 `GET /api/meta`、生成运行时 `/health`、本地卡片 URL 挑战等;`POST /api/generate` 之外的真实调用未覆盖(依赖外部图像/模型服务)。 diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index a8f1eb7..dcbf18f 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -24,21 +24,29 @@ test("top-level docs define Code2Lark delivery modes", () => { assert.match(readme, /Mode A is the external host, sidecar, or gateway path\./); assert.match(readme, /Mode B is the target-project embedded host-module path\./); + assert.match(readme, /Mode A.*(已验证|validated)/i); + assert.match(readme, /Mode B.*(已验证|validated)/i); + assert.doesNotMatch(readme, /Mode B.*pending real/i); + assert.doesNotMatch(readme, /Mode B.*待真实验收/); assert.match(readme, /self-hosted-runtime is the generated host module/i); assert.match(charter, /Mode A.*external host/i); assert.match(charter, /Mode B.*embedded host module/i); + assert.doesNotMatch(charter, /Mode B.*not considered productized until/i); assert.match(charter, /self-hosted-runtime.*host module/i); assert.match(charter, /--mode: .*embedded-adapter.*standalone-runtime.*self-hosted-runtime/); assert.match(charter, /bot-runtime\/\s+# 可选,参考宿主,不是核心业务层/); assert.doesNotMatch(charter, /standalone-runtime\/\s+# 可选,参考宿主,不是核心业务层/); assert.match(status, /Mode A/i); assert.match(status, /Mode B/i); + assert.doesNotMatch(status, /Mode B.*not considered productized until/i); assert.match(mvp, /verified sample/i); + assert.match(mvp, /Mode B.*deployment-test validation/i); assert.match(mvp, /Real Feishu Level 2 evidence remains operator-owned/); assert.doesNotMatch(mvp, /Real Feishu verification is still pending/); assert.match(fdeHandoff, /generated\/-lark\/` is the source-of-truth handoff package/); assert.match(fdeHandoff, /Mode A is the external host, sidecar, or gateway path\./); assert.match(fdeHandoff, /Mode B is the target-project embedded host-module path\./); + assert.match(fdeHandoff, /Mode B.*deployment-test validation/i); assert.match(fdeHandoff, /self-hosted-runtime is the generated host module/i); assert.match(modeBGuide, /Do not copy `generated\/-lark\/feishu-host\/\.env`/); assert.match(modeBGuide, /add `feishu_host\/\.env`/); From cf5c7072dc5b8087dbedd28ee2f1104a78b23745 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:04:38 +0800 Subject: [PATCH 06/37] docs: add MVP baseline and validation matrix Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- docs/capability-validation-matrix.md | 9 +++++++++ docs/mvp-mode-a-b-baseline.md | 11 +++++++++++ tests/cli-smoke.test.mjs | 9 +++++++++ 3 files changed, 29 insertions(+) create mode 100644 docs/capability-validation-matrix.md create mode 100644 docs/mvp-mode-a-b-baseline.md diff --git a/docs/capability-validation-matrix.md b/docs/capability-validation-matrix.md new file mode 100644 index 0000000..3cbfdc3 --- /dev/null +++ b/docs/capability-validation-matrix.md @@ -0,0 +1,9 @@ +# Code2Lark Capability Validation Matrix + +This matrix is the current human-readable fact source for target profile, delivery mode, host mode, local validation, deployment-test validation, and evidence state. + +| Target | Delivery mode | Host mode | Local validation | Deployment-test validation | Notes | +| --- | --- | --- | --- | --- | --- | +| image-agent-web | Mode A | self-hosted / long connection | yes | yes | verified sample baseline | +| image-agent-web | Mode B | embedded host module | yes | yes | verified sample baseline | +| calendar-stock-updater | generic target candidate | Mode A pending | package validation only | no | second-target validation pending | diff --git a/docs/mvp-mode-a-b-baseline.md b/docs/mvp-mode-a-b-baseline.md new file mode 100644 index 0000000..a450e0a --- /dev/null +++ b/docs/mvp-mode-a-b-baseline.md @@ -0,0 +1,11 @@ +# Code2Lark MVP Mode A / Mode B Baseline + +This document records the current MVP sample baseline. It does not reopen or re-prove the already completed `image-agent-web` deployment-test validation. + +- `image-agent-web` has completed deployment-test validation in Mode A. +- `image-agent-web` has completed deployment-test validation in Mode B. +- Mode A remains the external host, sidecar, or gateway path. +- Mode B remains the target-project embedded host-module path. +- `self-hosted-runtime` is the generated host module. It can run externally and can also be migrated into a target project. +- These validations are treated as the current MVP sample baseline. +- This roadmap consolidates the validated experience into a reusable MVP integration package. diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index dcbf18f..18c131e 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -21,6 +21,8 @@ test("top-level docs define Code2Lark delivery modes", () => { const mvp = fs.readFileSync(path.join(root, "docs", "mvp-1a-image-agent-web.md"), "utf8"); const fdeHandoff = fs.readFileSync(path.join(root, "docs", "fde-handoff.md"), "utf8"); const modeBGuide = fs.readFileSync(path.join(root, "docs", "mode-b-embedding-guide.md"), "utf8"); + const baseline = fs.readFileSync(path.join(root, "docs", "mvp-mode-a-b-baseline.md"), "utf8"); + const matrix = fs.readFileSync(path.join(root, "docs", "capability-validation-matrix.md"), "utf8"); assert.match(readme, /Mode A is the external host, sidecar, or gateway path\./); assert.match(readme, /Mode B is the target-project embedded host-module path\./); @@ -50,6 +52,13 @@ test("top-level docs define Code2Lark delivery modes", () => { assert.match(fdeHandoff, /self-hosted-runtime is the generated host module/i); assert.match(modeBGuide, /Do not copy `generated\/-lark\/feishu-host\/\.env`/); assert.match(modeBGuide, /add `feishu_host\/\.env`/); + assert.match(baseline, /Mode A/); + assert.match(baseline, /Mode B/); + assert.match(baseline, /deployment-test validation/i); + assert.match(matrix, /image-agent-web/); + assert.match(matrix, /calendar-stock-updater/); + assert.match(matrix, /Mode A/); + assert.match(matrix, /Mode B/); }); test("CLI can analyze, plan, generate, and verify an image-agent-web-like target", () => { From f59be9538ceac29b1e6c4903a0abdba6e115e3ef Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:10:57 +0800 Subject: [PATCH 07/37] fix: reject outdated manifests in strict verification Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/verify.ts | 54 ++++++++++++++++++++++++++++++++++++++++ tests/cli-smoke.test.mjs | 23 +++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/commands/verify.ts b/src/commands/verify.ts index 32748b8..21d6d85 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -67,6 +67,27 @@ export async function verifyCommand(args: string[], options: Record check.name.startsWith("manifest:strict:") && check.status === "fail")) { + printChecks(checks); + writeReports(reportDir, checks, { + packagePath, + envPath, + runtimeUrl, + hostRuntimeUrl, + hostReceiveMode, + simulate, + sendStartCard, + level2, + targetBaseUrl: "", + mode: integrationMode, + }); + process.exitCode = 1; + return; + } + } + if (mode === "embedded-adapter" || mode === "embedded") { checks.push(...buildEmbeddedAdapterChecks(packagePath, interactions, permissions)); checks.push(...await buildEmbeddedHostValidationChecks({ hostRuntimeUrl, hostReceiveMode, simulate, sendStartCard, level2 })); @@ -507,6 +528,39 @@ function checkFile(name: string, filePath: string): CheckResult { : { name, status: "fail", detail: `Missing ${filePath}` }; } +function buildStrictManifestIntegrityChecks(manifests: { + service: ServiceManifest | undefined; + capabilities: CapabilityMap | undefined; + interactions: InteractionContract | undefined; + permissions: RequiredPermissions | undefined; +}): CheckResult[] { + const checks: CheckResult[] = []; + checks.push(checkManifestSchemaVersion("service_manifest.json", manifests.service)); + checks.push(checkManifestSchemaVersion("capability_map.json", manifests.capabilities)); + checks.push(checkManifestSchemaVersion("interaction_contract.json", manifests.interactions)); + checks.push(checkManifestSchemaVersion("required_permissions.json", manifests.permissions)); + const targetProfile = manifests.capabilities?.target_profile; + checks.push({ + name: "manifest:strict:capability-map-target-profile", + status: typeof targetProfile === "string" && targetProfile ? "pass" : "fail", + detail: typeof targetProfile === "string" && targetProfile + ? `capability_map.json target_profile=${targetProfile}.` + : "capability_map.json must include target_profile in strict mode.", + }); + return checks; +} + +function checkManifestSchemaVersion(name: string, value: { schema_version?: string } | undefined): CheckResult { + const actual = value?.schema_version; + return { + name: `manifest:strict:${name}:schema-version`, + status: actual === "0.2" ? "pass" : "fail", + detail: actual === "0.2" + ? `${name} uses schema_version 0.2.` + : `${name} must use schema_version 0.2 in strict mode; found ${actual || "missing"}.`, + }; +} + function buildEmbeddedAdapterChecks(packagePath: string, interactions: InteractionContract | undefined, permissions: RequiredPermissions | undefined): CheckResult[] { const handlerPath = path.join(packagePath, "adapter", "handlers.ts"); const handlerSource = fs.existsSync(handlerPath) ? fs.readFileSync(handlerPath, "utf8") : ""; diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index 18c131e..e3fb942 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -61,6 +61,29 @@ test("top-level docs define Code2Lark delivery modes", () => { assert.match(matrix, /Mode B/); }); +test("strict verify rejects outdated manifest schemas", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-old-schema-")); + fs.mkdirSync(path.join(temp, "manifest"), { recursive: true }); + fs.writeFileSync(path.join(temp, "manifest", "service_manifest.json"), JSON.stringify({ schema_version: "0.1" }), "utf8"); + fs.writeFileSync(path.join(temp, "manifest", "capability_map.json"), JSON.stringify({ schema_version: "0.1", service_name: "x", capabilities: [] }), "utf8"); + fs.writeFileSync(path.join(temp, "manifest", "interaction_contract.json"), JSON.stringify({ schema_version: "0.1", channel: "lark", service_name: "x", supported_triggers: [], supported_result_modes: [], interactions: [] }), "utf8"); + fs.writeFileSync(path.join(temp, "manifest", "required_permissions.json"), JSON.stringify({ + schema_version: "0.1", + app: { type: "custom_app", bot_required: true, availability_recommendation: "" }, + context_requirements: [], + token_strategy: { default: "tenant_access_token", user_access_token_required: false }, + scopes: [], + events: [], + callbacks: [], + manual_steps: [], + review_flags: [], + }), "utf8"); + + const output = runExpectFailure(["verify", temp, "--strict"]); + assert.match(output, /schema_version 0\.2/i); + assert.match(output, /target_profile/i); +}); + test("CLI can analyze, plan, generate, and verify an image-agent-web-like target", () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-smoke-")); const target = path.join(temp, "image-agent-web"); From b3c05ef11ed6b84715332f67a2c80b900fde8c7c Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:15:20 +0800 Subject: [PATCH 08/37] docs: align canonical MVP package with schema 0.2 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 2 ++ docs/project-status.md | 1 + tests/cli-smoke.test.mjs | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/README.md b/README.md index 0f5dcab..396a4b5 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ generated/-lark/ 把这个生成目录视为**单一事实来源(source of truth)**:其中包含 manifest、adapter 代码、宿主模块、文档和验证记录。 +The canonical MVP package is freshly generated from current schema 0.2 manifests. + ### Mode A:外置宿主 / sidecar / gateway Mode A is the external host, sidecar, or gateway path. Mode A is a validated deployment-test baseline in the verified `image-agent-web` sample. diff --git a/docs/project-status.md b/docs/project-status.md index 6ae8395..0b3b246 100644 --- a/docs/project-status.md +++ b/docs/project-status.md @@ -20,6 +20,7 @@ Lark-deployer 是一个构建时(build-time)生成器:分析一个已有 - 测试:`tests/cli-smoke.test.mjs`、`tests/runtime-local-e2e.test.mjs`、`tests/unit-pure-functions.test.mjs`。CLI 冒烟测试已覆盖 adapter-first 生成与 `verify --mode embedded-adapter --strict` package validation;运行时 e2e 继续覆盖 standalone/reference host。 - `tsconfig.json` 开启 `strict: true`,构建目标 ES2022 / NodeNext。 - 2026-07-07 泛用化阶段新增 manifest `0.2` 契约、strategy-based analyzer、`generic_http_api` 分析路径,以及 `generic-http-api` embedded-adapter 生成/验证路径;`image-agent-web` 仍是 self-hosted-runtime 长连接回归锚点。 +- The canonical MVP package is freshly generated from current schema 0.2 manifests. - 2026-07-08 当前路线把 Mode A(外置宿主 / sidecar / gateway)和 Mode B(目标项目内增量宿主模块)产品化:`generated/-lark/` 是标准 source of truth,`self-hosted-runtime` 是 generated host module,`feishu-host/` 既可独立运行,也可按 `docs/mode-b-embedding-guide.md` 迁入目标项目内部;`docs/embedded-into-target-app-guide.md` 保留为补充迁移细节。 ## 3. 命令流水线(当前已实现) diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index e3fb942..1f418b7 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -59,6 +59,8 @@ test("top-level docs define Code2Lark delivery modes", () => { assert.match(matrix, /calendar-stock-updater/); assert.match(matrix, /Mode A/); assert.match(matrix, /Mode B/); + assert.match(readme, /canonical MVP package.*schema 0\.2/i); + assert.match(status, /canonical MVP package.*schema 0\.2/i); }); test("strict verify rejects outdated manifest schemas", () => { @@ -302,6 +304,11 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assert.doesNotMatch(longContextDirectRequest, /VERIFICATION_TOKEN \/ ENCRYPT_KEY/); const missingGenerated = path.join(temp, "generated-missing-context"); run(["generate", workspace, "--out", missingGenerated]); + const generatedServiceManifest = JSON.parse(fs.readFileSync(path.join(missingGenerated, "manifest", "service_manifest.json"), "utf8")); + const generatedCapabilityMap = JSON.parse(fs.readFileSync(path.join(missingGenerated, "manifest", "capability_map.json"), "utf8")); + assert.equal(generatedServiceManifest.schema_version, "0.2"); + assert.equal(generatedCapabilityMap.schema_version, "0.2"); + assert.ok(generatedCapabilityMap.target_profile); const selfHostedGenerated = path.join(temp, "generated-self-hosted"); run(["generate", workspace, "--out", selfHostedGenerated, "--mode", "self-hosted-runtime"]); const selfHostedSummary = JSON.parse(fs.readFileSync(path.join(selfHostedGenerated, "generation_summary.json"), "utf8")); From c029c4de68d6f135d0825e17124f7328e4bb9e78 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:53:25 +0800 Subject: [PATCH 09/37] fix: copy complete generated artifacts during handoff Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/handoff.ts | 41 ++++++++++++++++++++++++++++++++++++++-- tests/cli-smoke.test.mjs | 18 ++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/commands/handoff.ts b/src/commands/handoff.ts index 333e799..ff2c8db 100644 --- a/src/commands/handoff.ts +++ b/src/commands/handoff.ts @@ -89,6 +89,12 @@ const RECOMMENDED_FILES = [ ["manifest/interaction_contract.json", "Machine-readable Lark interaction contract."], ["manifest/required_permissions.json", "Machine-readable permission contract."], ["manifest/image_agent_meta.snapshot.json", "Template metadata snapshot used by generated cards."], + ["adapter/handlers.ts", "Generated adapter action handlers."], + ["adapter/cards.ts", "Generated Lark card builders for the adapter."], + ["adapter/service-client.ts", "Generated target service client for the adapter."], + ["adapter/validation.ts", "Generated adapter input validation."], + ["adapter/types.ts", "Generated adapter TypeScript contracts."], + ["adapter/audit-events.ts", "Generated adapter audit event helpers."], ["bot-runtime/package.json", "Runtime package manifest."], ["bot-runtime/tsconfig.json", "Runtime TypeScript build config."], ["bot-runtime/.env.example", "Safe runtime env template."], @@ -129,6 +135,9 @@ const EXCLUDED_PATHS = [ ["bot-runtime/tmp", "Runtime scratch files."], ["bot-runtime/audit.log", "Runtime audit log may include operator/chat context and local evidence."], ["bot-runtime/*.log", "Runtime logs may contain local paths or sensitive operational context."], + ["feishu-host/.env", "Contains local Feishu app credentials and target runtime values."], + ["feishu-host/.venv", "Python virtualenv output; regenerate with pip install."], + ["feishu-host/__pycache__", "Python bytecode cache; regenerate locally."], ] as const; export async function handoffCommand(args: string[], options: Record): Promise { @@ -458,7 +467,7 @@ function level2RecordCliGuidance(): string { function buildHandoffManifest(packagePath: string): HandoffManifest { const service = readJsonFile(path.join(packagePath, "manifest", "service_manifest.json")); - const recommendedFiles = RECOMMENDED_FILES.map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); + const recommendedFiles = requiredHandoffFiles(packagePath).map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); const optionalEvidenceFiles = OPTIONAL_EVIDENCE_FILES.map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); const excludedPaths = EXCLUDED_PATHS.map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); const warnings = buildWarnings(recommendedFiles, excludedPaths); @@ -484,6 +493,28 @@ function buildHandoffManifest(packagePath: string): HandoffManifest { }; } +function requiredHandoffFiles(packagePath: string): Array<[string, string]> { + const rows = new Map(RECOMMENDED_FILES); + for (const dir of ["adapter", "bot-runtime", "feishu-host", "sidecar-long-connection"]) { + const absoluteDir = path.join(packagePath, dir); + if (!fs.existsSync(absoluteDir) || !fs.statSync(absoluteDir).isDirectory()) continue; + for (const filePath of listPackageFiles(absoluteDir)) { + const relativePath = toForwardSlash(path.join(dir, filePath)); + if (!isExcluded(relativePath)) { + rows.set(relativePath, generatedClosureReason(dir)); + } + } + } + return Array.from(rows.entries()); +} + +function generatedClosureReason(dir: string): string { + if (dir === "adapter") return "Generated adapter runtime closure."; + if (dir === "bot-runtime") return "Generated standalone reference runtime closure."; + if (dir === "feishu-host") return "Generated self-hosted Feishu host module closure."; + return "Generated sidecar or gateway host contract closure."; +} + function fileRow(packagePath: string, relativePath: string, reason: string): HandoffFile { return { path: relativePath, @@ -980,7 +1011,13 @@ function isExcluded(relativePath: string): boolean { || normalized.startsWith("bot-runtime/tmp/") || normalized === "bot-runtime/tmp" || normalized === "bot-runtime/audit.log" - || normalized.startsWith("bot-runtime/") && normalized.endsWith(".log"); + || normalized.startsWith("bot-runtime/") && normalized.endsWith(".log") + || normalized === "feishu-host/.env" + || normalized.startsWith("feishu-host/.venv/") + || normalized === "feishu-host/.venv" + || normalized.startsWith("feishu-host/__pycache__/") + || normalized === "feishu-host/__pycache__" + || normalized.startsWith("feishu-host/") && normalized.endsWith(".pyc"); } function toForwardSlash(value: string): string { diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index 1f418b7..48d6488 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -1943,6 +1943,12 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assert.ok(fs.existsSync(path.join(handoffCopy, "feishu_context.reply.template.json"))); assert.ok(fs.existsSync(path.join(handoffCopy, "feishu_context.reply.template.md"))); assert.ok(fs.existsSync(path.join(handoffCopy, "level2_manual_evidence.template.json"))); + assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "handlers.ts"))); + assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "cards.ts"))); + assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "service-client.ts"))); + assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "validation.ts"))); + assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "types.ts"))); + assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "audit-events.ts"))); assert.ok(fs.existsSync(path.join(handoffCopy, "bot-runtime", "src", "index.ts"))); assert.ok(fs.existsSync(path.join(handoffCopy, "manifest", "service_manifest.json"))); assert.equal(fs.existsSync(path.join(handoffCopy, "bot-runtime", ".env")), false); @@ -2054,6 +2060,18 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assert.doesNotMatch(copiedDeploymentChecklist, /Configure card action callback URL to the generated bot runtime/); assert.doesNotMatch(copiedDeploymentChecklist, /test_secret/); assert.doesNotMatch(copiedDeploymentChecklist, /cli_debug_token/); + const selfHostedHandoffCopy = path.join(temp, "handoff-self-hosted-copy"); + run(["handoff", selfHostedGenerated, "--copy-to", selfHostedHandoffCopy]); + assert.ok(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", "app.py"))); + assert.ok(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", "handlers.py"))); + assert.ok(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", "service_client.py"))); + assert.ok(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", "validation.py"))); + assert.ok(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", "spec", "start_card.json"))); + assert.equal(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", ".env")), false); + const embeddedLongHandoffCopy = path.join(temp, "handoff-embedded-long-copy"); + run(["handoff", embeddedLongGenerated, "--copy-to", embeddedLongHandoffCopy]); + assert.ok(fs.existsSync(path.join(embeddedLongHandoffCopy, "sidecar-long-connection", "README.md"))); + assert.ok(fs.existsSync(path.join(embeddedLongHandoffCopy, "sidecar-long-connection", "local-contract-test.mjs"))); const handoffCheckOutput = run(["handoff", handoffCopy, "--check"]); assert.match(handoffCheckOutput, /Handoff check passed/); const handoffCopyWithoutInitLocal = path.join(temp, "handoff-copy-without-init-local"); From 938d10503cebd0642d111c7924e4e67617b2dada Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:02:18 +0800 Subject: [PATCH 10/37] fix: protect existing output directories during generation Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/generate.ts | 14 +++++++++++++- src/index.ts | 2 +- tests/cli-smoke.test.mjs | 38 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 07e949d..31c2baf 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -29,6 +29,7 @@ export async function generateCommand(args: string[], options: Record [--out ] [--mode embedded-adapter|standalone-runtime|self-hosted-runtime] [--host-mode embedded-webhook|embedded-long-connection|hybrid|standalone-runtime]"; + return "Usage: lark-deployer generate [--out ] [--force] [--mode embedded-adapter|standalone-runtime|self-hosted-runtime] [--host-mode embedded-webhook|embedded-long-connection|hybrid|standalone-runtime]"; } function normalizeIntegrationMode(value: string): IntegrationMode { @@ -125,6 +126,17 @@ function normalizeIntegrationMode(value: string): IntegrationMode { throw new Error('--mode must be "embedded-adapter", "standalone-runtime", or "self-hosted-runtime".'); } +function assertSafeOutputDirectory(outDir: string, force: boolean): void { + if (!fs.existsSync(outDir)) return; + const entries = fs.readdirSync(outDir); + if (!entries.length) return; + if (fs.existsSync(path.join(outDir, "generation_summary.json"))) { + if (force) return; + throw new Error(`Refusing to update existing generated package without --force: ${outDir}.`); + } + throw new Error(`Refusing to write into non-empty non-managed output directory: ${outDir}. Choose a new --out path or remove user-owned files first.`); +} + function writeRuntimeAdapterJs(adapterDir: string, service: ServiceManifest, capabilities: CapabilityMap, meta: ImageAgentMeta | undefined): void { writeText(path.join(adapterDir, "audit-events.js"), adapterAuditEventsJs()); writeText(path.join(adapterDir, "cards.js"), adapterCardsJs(service, capabilities, meta)); diff --git a/src/index.ts b/src/index.ts index 9e54e27..e8abad2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,7 +70,7 @@ function printHelp(): void { Usage: lark-deployer analyze --base-url [--out ] [--name ] lark-deployer plan - lark-deployer generate [--out ] [--mode embedded-adapter|standalone-runtime|self-hosted-runtime] [--host-mode embedded-webhook|embedded-long-connection|hybrid|standalone-runtime] + lark-deployer generate [--out ] [--force] [--mode embedded-adapter|standalone-runtime|self-hosted-runtime] [--host-mode embedded-webhook|embedded-long-connection|hybrid|standalone-runtime] lark-deployer context [--out ] [--mode embedded-adapter|standalone-runtime|self-hosted-runtime] [--host-mode embedded-webhook|embedded-long-connection|hybrid|standalone-runtime] lark-deployer configure [--context ] [--out-env ] [--report ] [--strict] [--dry-run] lark-deployer status [--env ] [--json] diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index 48d6488..8b62ba0 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -86,6 +86,40 @@ test("strict verify rejects outdated manifest schemas", () => { assert.match(output, /target_profile/i); }); +test("generate refuses to overwrite non-managed non-empty output directories", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-generate-guard-")); + const target = path.join(temp, "image-agent-web"); + const workspace = path.join(temp, "out"); + const existing = path.join(temp, "existing-output"); + + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, "requirements.txt"), "fastapi==0.115.0\n", "utf8"); + fs.writeFileSync( + path.join(target, "main.py"), + [ + "from fastapi import FastAPI", + "app = FastAPI()", + "@app.post(\"/api/generate\")", + "async def generate(): pass", + ].join("\n"), + "utf8", + ); + fs.writeFileSync(path.join(target, "templates.py"), "TEMPLATES = []\nREFERENCE_TYPES = []\n", "utf8"); + fs.mkdirSync(existing, { recursive: true }); + fs.writeFileSync(path.join(existing, "README.md"), "user-owned content", "utf8"); + + run(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace]); + const output = runExpectFailure(["generate", workspace, "--out", existing]); + assert.match(output, /non-empty|force|managed/i); + assert.equal(fs.readFileSync(path.join(existing, "README.md"), "utf8"), "user-owned content"); + + const managed = path.join(temp, "managed-output"); + run(["generate", workspace, "--out", managed]); + const managedOutput = runExpectFailure(["generate", workspace, "--out", managed]); + assert.match(managedOutput, /--force|existing generated package/i); + run(["generate", workspace, "--out", managed, "--force"]); +}); + test("CLI can analyze, plan, generate, and verify an image-agent-web-like target", () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-smoke-")); const target = path.join(temp, "image-agent-web"); @@ -1296,7 +1330,7 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target .replace("- Start card message ID:", "- Start card message ID: om_preserved_start"), "utf8", ); - const preserveGenerateOutput = run(["generate", workspace, "--out", preserveGenerated]); + const preserveGenerateOutput = run(["generate", workspace, "--out", preserveGenerated, "--force"]); assert.match(preserveGenerateOutput, /Preserved existing Level 2 evidence record/); const preservedRecord = fs.readFileSync(preserveRecordPath, "utf8"); assert.match(preservedRecord, /- \[x\] Level 2 verified\./); @@ -1325,7 +1359,7 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target .replace("- `verification_report.md` path:", `- \`verification_report.md\` path: ${artifactVerificationReport}`), "utf8", ); - const artifactPreserveOutput = run(["generate", workspace, "--out", artifactPreserveGenerated]); + const artifactPreserveOutput = run(["generate", workspace, "--out", artifactPreserveGenerated, "--force"]); assert.match(artifactPreserveOutput, /Preserved existing Level 2 evidence record/); const artifactPreservedRecord = fs.readFileSync(artifactRecordPath, "utf8"); assert.match(artifactPreservedRecord, /Bot runtime URL: http:\/\/127\.0\.0\.1:3978/); From 82b1e452f1b18d65422c7dc48e2951083d7c56aa Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:07:12 +0800 Subject: [PATCH 11/37] fix: require review for risky generic HTTP actions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/analyze.ts | 22 ++++++++++++++++++---- tests/cli-smoke.test.mjs | 13 ++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index d15e6f5..c31aa69 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -513,6 +513,7 @@ async function analyzeGenericHttpApi({ targetPath, baseUrl, outDir, name }: Anal const discovered = discoverHttpEndpoints(targetPath); const endpoints = dedupeEndpoints(discovered); const capabilityEndpoints = endpoints.filter((endpoint) => !isHealthEndpoint(endpoint)); + const generatedCapabilityEndpoints = capabilityEndpoints.filter((endpoint) => genericEndpointStatus(endpoint) === "supported"); const healthPath = endpoints.find(isHealthEndpoint)?.path || endpoints.find((endpoint) => endpoint.method === "GET")?.path || "/"; const healthProbe = await getJsonWithTimeout(baseUrl ? `${baseUrl}${healthPath}` : "", 5000); const frameworks = detectFrameworks(targetPath); @@ -547,11 +548,14 @@ async function analyzeGenericHttpApi({ targetPath, baseUrl, outDir, name }: Anal if (isHealthEndpoint(endpoint)) { return { ...endpoint, status: "supporting" as const, reason: "Used as a generic target healthcheck." }; } + const status = genericEndpointStatus(endpoint); return { ...endpoint, - status: "supported" as const, + status, capability_id: capabilityIdForEndpoint(endpoint), - reason: "Generic HTTP API endpoint exposed as a card-action backed capability.", + reason: status === "supported" + ? "Generic HTTP API endpoint exposed as a card-action backed capability." + : "Potentially destructive generic HTTP endpoint discovered for review but not generated as a direct card action.", }; }), notes: [ @@ -573,7 +577,7 @@ async function analyzeGenericHttpApi({ targetPath, baseUrl, outDir, name }: Anal id: capabilityIdForEndpoint(endpoint), name: `${endpoint.method} ${endpoint.path}`, kind: endpoint.method === "GET" ? "query" : "action", - risk: endpoint.method === "GET" ? "read_only" : "write", + risk: inferGenericEndpointRisk(endpoint), source: { type: "http", method: endpoint.method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE", @@ -593,7 +597,7 @@ async function analyzeGenericHttpApi({ targetPath, baseUrl, outDir, name }: Anal service_name: name, supported_triggers: ["card_action", "http_request", "scheduled_poll", "manual_review"], supported_result_modes: ["interactive_card", "structured_result", "artifact", "state_update"], - interactions: capabilityMap.capabilities.map((capability) => ({ + interactions: capabilityMap.capabilities.filter((capability) => generatedCapabilityEndpoints.some((endpoint) => capability.id === capabilityIdForEndpoint(endpoint))).map((capability) => ({ id: `${capability.id}.card`, capability_id: capability.id, action_id: `${capability.id}.submit`, @@ -717,6 +721,16 @@ function isHealthEndpoint(endpoint: { method: string; path: string }): boolean { return endpoint.method === "GET" && /^\/(health|api\/health|status|api\/status)$/.test(endpoint.path); } +function inferGenericEndpointRisk(endpoint: { method: string; path: string }): "read_only" | "write" | "destructive" { + if (endpoint.method === "GET") return "read_only"; + if (endpoint.method === "DELETE") return "destructive"; + return /(stop|delete|reset|shutdown|drop|destroy)/i.test(endpoint.path) ? "destructive" : "write"; +} + +function genericEndpointStatus(endpoint: { method: string; path: string }): "supported" | "discovered_not_generated" { + return inferGenericEndpointRisk(endpoint) === "destructive" ? "discovered_not_generated" : "supported"; +} + function capabilityIdForEndpoint(endpoint: { method: string; path: string }): string { const parts = endpoint.path .replace(/^\/+|\/+$/g, "") diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index 8b62ba0..d32bbaf 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -2529,15 +2529,26 @@ test("calendar-stock-updater Node target can analyze generate and verify", () => assert.equal(capabilityMap.target_profile, "generic-http-api"); assert.ok(capabilityMap.capabilities.some((capability) => capability.id === "http.get.api.state" && capability.kind === "query")); assert.ok(capabilityMap.capabilities.some((capability) => capability.id === "http.post.api.run" && capability.kind === "action")); - assert.ok(capabilityMap.capabilities.some((capability) => capability.id === "http.post.api.stop" && capability.kind === "action")); + assert.ok(serviceManifest.source_scan.endpoint_coverage.some((item) => ( + item.method === "POST" + && item.path === "/api/stop" + && item.status !== "supported" + ))); + assert.ok(capabilityMap.capabilities.some((capability) => ( + capability.id === "http.post.api.stop" + && capability.kind === "action" + && capability.risk === "destructive" + ))); assert.equal(capabilityMap.capabilities.some((capability) => capability.id.startsWith("image.")), false); run(["generate", workspace, "--out", generated, "--mode", "embedded-adapter"]); const generatedAdapter = fs.readFileSync(path.join(generated, "adapter", "handlers.ts"), "utf8"); assert.match(generatedAdapter, /http\.post\.api\.run\.submit/); + assert.doesNotMatch(generatedAdapter, /http\.post\.api\.stop\.submit/); assert.doesNotMatch(generatedAdapter, /image\.generate|image_url|session_id/); const verifyOutput = run(["verify", generated, "--mode", "embedded-adapter", "--strict"]); assert.match(verifyOutput, /adapter:action:http\.post\.api\.run\.submit/); + assert.doesNotMatch(verifyOutput, /adapter:action:http\.post\.api\.stop\.submit/); assert.doesNotMatch(verifyOutput, /generate and batch|generate\/batch/); const doctorJson = JSON.parse(run(["doctor", generated, "--mode", "embedded-adapter", "--json"])); assert.equal(doctorJson.package_validation.status, "pass"); From fa09f14db7d55e88e13377f4e6b48596c543436f Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:32:17 +0800 Subject: [PATCH 12/37] fix: disable standalone debug endpoints by default Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/generate.ts | 8 +++---- src/profiles/image-agent-web.ts | 2 +- tests/runtime-local-e2e.test.mjs | 37 +++++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 31c2baf..32b6503 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -2828,7 +2828,7 @@ IMAGE_AGENT_BASE_URL=${service.service.base_url || "http://127.0.0.1:8000"} IMAGE_AGENT_TIMEOUT_MS=120000 # Runtime HTTP server -HOST=0.0.0.0 +HOST=127.0.0.1 PORT=3978 # Optional: if image upload fails, success card falls back to target image URL. @@ -2851,13 +2851,13 @@ DEBUG_ACCESS_TOKEN= # Empty means any valid card click can run the service. ALLOWED_OPERATOR_OPEN_IDS= -# Allows /health and local debug simulation before Feishu credentials are filled. +# Enables local debug simulation before Feishu credentials are filled. # Callback verification requires VERIFICATION_TOKEN. # Sending the first test card requires APP_ID, APP_SECRET, and TEST_CHAT_ID. # Uploading images to Feishu requires APP_ID and APP_SECRET. # Full Level 2 verification requires APP_ID, APP_SECRET, VERIFICATION_TOKEN, # TEST_CHAT_ID, PUBLIC_CALLBACK_BASE_URL, and a reachable target service. -ALLOW_DEBUG_WITHOUT_FEISHU=1 +ALLOW_DEBUG_WITHOUT_FEISHU=0 `; } @@ -2903,7 +2903,7 @@ export function loadConfig(): RuntimeConfig { publicCallbackBaseUrl: stripTrailingSlash(envValue("PUBLIC_CALLBACK_BASE_URL")), imageAgentBaseUrl: stripTrailingSlash(envValue("IMAGE_AGENT_BASE_URL", "http://127.0.0.1:8000")), imageAgentTimeoutMs: parsePositiveInt(envValue("IMAGE_AGENT_TIMEOUT_MS", "120000"), "IMAGE_AGENT_TIMEOUT_MS"), - host: envValue("HOST", "0.0.0.0"), + host: envValue("HOST", "127.0.0.1"), port: parsePort(envValue("PORT", "3978"), "PORT"), uploadImageToLark: parseFlag(envValue("UPLOAD_IMAGE_TO_LARK", "1"), "UPLOAD_IMAGE_TO_LARK"), cardActionMode: parseCardActionMode(envValue("CARD_ACTION_MODE", "sync")), diff --git a/src/profiles/image-agent-web.ts b/src/profiles/image-agent-web.ts index a5bb77e..55ea600 100644 --- a/src/profiles/image-agent-web.ts +++ b/src/profiles/image-agent-web.ts @@ -2637,7 +2637,7 @@ const server = http.createServer(async (req, res) => { imageAgentBaseUrl: config.imageAgentBaseUrl, imageAgentTimeoutMs: config.imageAgentTimeoutMs, debugEnabled: config.allowDebugWithoutFeishu, - debugProtected: Boolean(config.debugAccessToken), + debugProtected: !config.allowDebugWithoutFeishu || Boolean(config.debugAccessToken), })); return; } diff --git a/tests/runtime-local-e2e.test.mjs b/tests/runtime-local-e2e.test.mjs index 3a1afeb..66fed0f 100644 --- a/tests/runtime-local-e2e.test.mjs +++ b/tests/runtime-local-e2e.test.mjs @@ -23,6 +23,7 @@ test("generated runtime can simulate the image-agent-web card flow locally", { t let authRuntime; let timeoutRuntime; let placeholderRuntime; + let safeDefaultRuntime; try { writeImageAgentLikeSource(target); @@ -41,6 +42,10 @@ test("generated runtime can simulate the image-agent-web card flow locally", { t runNpm(["install", "--ignore-scripts", "--no-audit", "--no-fund"], runtimeDir); runNpm(["run", "build"], runtimeDir); + const runtimeEnvExample = fs.readFileSync(path.join(runtimeDir, ".env.example"), "utf8"); + assert.match(runtimeEnvExample, /^HOST=127\.0\.0\.1$/m); + assert.match(runtimeEnvExample, /^ALLOW_DEBUG_WITHOUT_FEISHU=0$/m); + const invalidModeRuntime = startRuntime(runtimeDir, await reservePort(), { CARD_ACTION_MODE: "asnyc" }); const invalidModeExit = await waitForProcessExit(invalidModeRuntime); assert.notEqual(invalidModeExit.code, 0); @@ -51,6 +56,28 @@ test("generated runtime can simulate the image-agent-web card flow locally", { t assert.notEqual(invalidPortExit.code, 0); assert.match(invalidPortExit.stderr, /PORT must be an integer between 1 and 65535/); + const safeDefaultRuntimePort = await reservePort(); + safeDefaultRuntime = startRuntime(runtimeDir, safeDefaultRuntimePort, { + APP_ID: "safe_default_app", + APP_SECRET: "safe_default_secret", + VERIFICATION_TOKEN: "safe_default_token", + TEST_CHAT_ID: "safe_default_chat", + IMAGE_AGENT_BASE_URL: mockTarget.baseUrl, + ALLOW_DEBUG_WITHOUT_FEISHU: "0", + }); + const safeDefaultHealth = await waitForJson(`http://127.0.0.1:${safeDefaultRuntimePort}/health`); + assert.equal(safeDefaultHealth.debugEnabled, false); + assert.equal(safeDefaultHealth.debugProtected, true); + const safeDefaultDebugDenied = await fetch(`http://127.0.0.1:${safeDefaultRuntimePort}/debug/simulate-generate`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({}), + }); + assert.equal(safeDefaultDebugDenied.status, 403); + safeDefaultRuntime.kill(); + await waitForProcessExit(safeDefaultRuntime); + safeDefaultRuntime = undefined; + const placeholderRuntimePort = await reservePort(); placeholderRuntime = startRuntime(runtimeDir, placeholderRuntimePort, { APP_ID: "", @@ -83,6 +110,13 @@ test("generated runtime can simulate the image-agent-web card flow locally", { t runtime = startRuntime(runtimeDir, runtimePort); const runtimeUrl = `http://127.0.0.1:${runtimePort}`; + const debugEnv = path.join(temp, "debug-runtime.env"); + fs.writeFileSync( + debugEnv, + fs.readFileSync(path.join(runtimeDir, ".env"), "utf8") + .replace(/^ALLOW_DEBUG_WITHOUT_FEISHU=.*$/m, "ALLOW_DEBUG_WITHOUT_FEISHU=1"), + "utf8", + ); const health = await waitForJson(`${runtimeUrl}/health`); assert.equal(health.ok, true); @@ -476,7 +510,7 @@ test("generated runtime can simulate the image-agent-web card flow locally", { t && entry.event === "card_action_received" ))); - await runCli(["verify", generated, "--runtime-url", runtimeUrl, "--simulate"]); + await runCli(["verify", generated, "--env", debugEnv, "--runtime-url", runtimeUrl, "--simulate"]); const report = JSON.parse(fs.readFileSync(path.join(generated, "verification_report.json"), "utf8")); const targetCheck = report.checks.find((check) => check.name === "target:/api/meta"); const challengeCheck = report.checks.find((check) => check.name === "runtime:/webhook/card:challenge"); @@ -853,6 +887,7 @@ test("generated runtime can simulate the image-agent-web card flow locally", { t authRuntime?.kill(); timeoutRuntime?.kill(); placeholderRuntime?.kill(); + safeDefaultRuntime?.kill(); await mockTarget.close(); fs.rmSync(temp, { recursive: true, force: true }); } From 46e9a7019dbc996e7ec5d604939c37289fd37d2c Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:32:40 +0800 Subject: [PATCH 13/37] fix: align runtime handoff defaults with safe debug Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/configure.ts | 4 ++-- src/commands/context.ts | 4 ++-- src/commands/readiness.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 3cc19b9..1ef0f6d 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -803,13 +803,13 @@ TEST_CHAT_ID= PUBLIC_CALLBACK_BASE_URL= IMAGE_AGENT_BASE_URL= IMAGE_AGENT_TIMEOUT_MS=120000 -HOST=0.0.0.0 +HOST=127.0.0.1 PORT=3978 UPLOAD_IMAGE_TO_LARK=1 CARD_ACTION_MODE=sync FEISHU_OPENAPI_BASE_URL= DEBUG_ACCESS_TOKEN= ALLOWED_OPERATOR_OPEN_IDS= -ALLOW_DEBUG_WITHOUT_FEISHU=1 +ALLOW_DEBUG_WITHOUT_FEISHU=0 `; } diff --git a/src/commands/context.ts b/src/commands/context.ts index 6470ec9..72eedd1 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -349,7 +349,7 @@ export function buildContextTemplate( manual_steps: permissions.manual_steps, }, runtime_config: { - host: "0.0.0.0", + host: "127.0.0.1", port: 3978, upload_image_to_lark: true, target_timeout_seconds: 120, @@ -357,7 +357,7 @@ export function buildContextTemplate( feishu_openapi_base_url: "", debug_access_token: "", allowed_operator_open_ids: [], - allow_debug_without_feishu: true, + allow_debug_without_feishu: false, }, handoff_request: { recipient_hint: "Feishu app owner, permission admin, or FDE who can configure the test bot.", diff --git a/src/commands/readiness.ts b/src/commands/readiness.ts index 5f44ed3..b5bf33e 100644 --- a/src/commands/readiness.ts +++ b/src/commands/readiness.ts @@ -352,11 +352,11 @@ function buildRuntimeRows(env: Record, context: ContextTemplate rows.push(buildRuntimeRow("CARD_ACTION_MODE", env.CARD_ACTION_MODE, context?.runtime_config.card_action_mode || "sync", "sync waits for target completion; async returns a running card and patches later.")); rows.push(buildRuntimeRow("UPLOAD_IMAGE_TO_LARK", env.UPLOAD_IMAGE_TO_LARK, boolToEnv(context?.runtime_config.upload_image_to_lark, "1"), "1 uploads result images to Feishu when API credentials and scope are available.")); rows.push(buildRuntimeRow("IMAGE_AGENT_TIMEOUT_MS", env.IMAGE_AGENT_TIMEOUT_MS, context?.runtime_config.target_timeout_seconds ? String(context.runtime_config.target_timeout_seconds * 1000) : "120000", "Target service call and image download timeout.")); - rows.push(buildRuntimeRow("HOST", env.HOST, context?.runtime_config.host || "0.0.0.0", "HTTP bind host for the generated bot runtime.")); + rows.push(buildRuntimeRow("HOST", env.HOST, context?.runtime_config.host || "127.0.0.1", "HTTP bind host for the generated bot runtime.")); rows.push(buildRuntimeRow("PORT", env.PORT, context?.runtime_config.port ? String(context.runtime_config.port) : "3978", "HTTP port for the generated bot runtime.")); rows.push(buildOptionalRuntimeRow("DEBUG_ACCESS_TOKEN", env.DEBUG_ACCESS_TOKEN, context?.runtime_config.debug_access_token || "", "Protects /debug/* endpoints when the runtime is reachable through a public callback URL. Value is intentionally hidden.")); rows.push(buildOptionalRuntimeRow("ALLOWED_OPERATOR_OPEN_IDS", env.ALLOWED_OPERATOR_OPEN_IDS, operatorOpenIdStatus(context?.runtime_config.allowed_operator_open_ids), "Optional operator open_id allowlist for card actions. Values are not printed in this status file.")); - rows.push(buildRuntimeRow("ALLOW_DEBUG_WITHOUT_FEISHU", env.ALLOW_DEBUG_WITHOUT_FEISHU, boolToEnv(context?.runtime_config.allow_debug_without_feishu, "1"), "1 keeps local debug simulation available before real Feishu credentials are filled.")); + rows.push(buildRuntimeRow("ALLOW_DEBUG_WITHOUT_FEISHU", env.ALLOW_DEBUG_WITHOUT_FEISHU, boolToEnv(context?.runtime_config.allow_debug_without_feishu, "0"), "1 explicitly enables local debug simulation before real Feishu credentials are filled.")); return rows; } From 238334972057fc17c280babf972320508bcb9fe3 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:57:50 +0800 Subject: [PATCH 14/37] fix: emit Card JSON 2.0 for long-connection integrations Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/generate.ts | 44 +++++++++------- src/profiles/image-agent-web.ts | 92 ++++++++++++++++++--------------- tests/cli-smoke.test.mjs | 15 ++++++ 3 files changed, 88 insertions(+), 63 deletions(-) diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 32b6503..e70344b 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -75,15 +75,15 @@ export async function generateCommand(args: string[], options: Record, deps: Record): Promise>;\n`); writeText(path.join(adapterDir, "service-client.d.ts"), `export function callGenericHttpEndpoint(baseUrl: string, method: string, pathTemplate: string, input?: Record, timeoutMs?: number): Promise>;\n`); @@ -1557,16 +1557,15 @@ function parseJson(text) { `; } -function genericAdapterCardsTs(service: ServiceManifest, capabilities: CapabilityMap, interactions: InteractionContract): string { +function genericAdapterCardsTs(service: ServiceManifest, capabilities: CapabilityMap, interactions: InteractionContract, hostReceiveMode: HostReceiveMode): string { const specs = buildGenericActionSpecs(capabilities, interactions); + const useJson2Card = hostModeUsesLongConnection(hostReceiveMode); return `const actionSpecs = ${JSON.stringify(specs, null, 2)} as const; +const useJson2Card = ${JSON.stringify(useJson2Card)}; + export function buildStartCard(): Record { - return { - config: { wide_screen_mode: true }, - header: { template: "blue", title: { tag: "plain_text", content: ${JSON.stringify(service.service.name + " actions")} } }, - elements: actionSpecs.map(buildActionForm), - }; + return card("blue", ${JSON.stringify(service.service.name + " actions")}, actionSpecs.map(buildActionForm)); } function buildActionForm(spec: typeof actionSpecs[number]): Record { @@ -1599,21 +1598,23 @@ export function buildFailureCard(message: string): Record { } function card(template: string, title: string, elements: Array>): Record { + if (useJson2Card) { + return { schema: "2.0", config: { update_multi: true, wide_screen_mode: true }, header: { template, title: { tag: "plain_text", content: title } }, body: { elements } }; + } return { config: { wide_screen_mode: true }, header: { template, title: { tag: "plain_text", content: title } }, elements }; } `; } -function genericAdapterCardsJs(service: ServiceManifest, capabilities: CapabilityMap, interactions: InteractionContract): string { +function genericAdapterCardsJs(service: ServiceManifest, capabilities: CapabilityMap, interactions: InteractionContract, hostReceiveMode: HostReceiveMode): string { const specs = buildGenericActionSpecs(capabilities, interactions); + const useJson2Card = hostModeUsesLongConnection(hostReceiveMode); return `const actionSpecs = ${JSON.stringify(specs, null, 2)}; +const useJson2Card = ${JSON.stringify(useJson2Card)}; + export function buildStartCard() { - return { - config: { wide_screen_mode: true }, - header: { template: "blue", title: { tag: "plain_text", content: ${JSON.stringify(service.service.name + " actions")} } }, - elements: actionSpecs.map(buildActionForm), - }; + return card("blue", ${JSON.stringify(service.service.name + " actions")}, actionSpecs.map(buildActionForm)); } function buildActionForm(spec) { @@ -1646,6 +1647,9 @@ export function buildFailureCard(message) { } function card(template, title, elements) { + if (useJson2Card) { + return { schema: "2.0", config: { update_multi: true, wide_screen_mode: true }, header: { template, title: { tag: "plain_text", content: title } }, body: { elements } }; + } return { config: { wide_screen_mode: true }, header: { template, title: { tag: "plain_text", content: title } }, elements }; } `; diff --git a/src/profiles/image-agent-web.ts b/src/profiles/image-agent-web.ts index 55ea600..5717a0e 100644 --- a/src/profiles/image-agent-web.ts +++ b/src/profiles/image-agent-web.ts @@ -1,4 +1,5 @@ import { buildFormFieldMaps, formFieldName } from "../field-mapping.js"; +import { hostModeUsesLongConnection, type HostReceiveMode } from "../host-mode.js"; import type { CapabilityMap, ServiceManifest } from "../types.js"; export interface ImageAgentMeta { @@ -293,8 +294,8 @@ function buildAdapterHandlerTemplateData(capabilities: CapabilityMap, meta: Imag return { defaultPreset, requiredFieldsByTemplate, fieldLabels }; } -export function adapterCardsJs(service: ServiceManifest, capabilities: CapabilityMap, meta: ImageAgentMeta | undefined): string { - return stripAdapterCardsTypeScript(adapterCardsTs(service, capabilities, meta)); +export function adapterCardsJs(service: ServiceManifest, capabilities: CapabilityMap, meta: ImageAgentMeta | undefined, hostReceiveMode: HostReceiveMode): string { + return stripAdapterCardsTypeScript(adapterCardsTs(service, capabilities, meta, hostReceiveMode)); } function stripAdapterCardsTypeScript(source: string): string { @@ -306,13 +307,18 @@ function stripAdapterCardsTypeScript(source: string): string { .replace(/\(message: string\): Record/g, "(message)") .replace(/: unknown\[\](?= =)/g, "") .replace(/\(value: unknown\): number/g, "(value)") - .replace(/\(value: unknown\): string/g, "(value)"); + .replace(/\(value: unknown\): string/g, "(value)") + .replace(/\(template: string, title: string, elements: unknown\[\]\): Record/g, "(template, title, elements)") + .replace(/\(text: string, name: string, value: Record\): Record/g, "(text, name, value)"); } -export function adapterCardsTs(service: ServiceManifest, capabilities: CapabilityMap, meta: ImageAgentMeta | undefined): string { +export function adapterCardsTs(service: ServiceManifest, capabilities: CapabilityMap, meta: ImageAgentMeta | undefined, hostReceiveMode: HostReceiveMode): string { const { defaultPreset, templateSpecs, fieldSpecs, fieldMaps } = buildAdapterCardTemplateData(capabilities, meta); + const useJson2Card = hostModeUsesLongConnection(hostReceiveMode); return `export const defaultPreset = ${JSON.stringify(defaultPreset, null, 2)}; +const useJson2Card = ${JSON.stringify(useJson2Card)}; + export const templateSpecs = ${JSON.stringify(templateSpecs, null, 2)}; export const fieldSpecs = ${JSON.stringify(fieldSpecs, null, 2)}; @@ -323,10 +329,7 @@ export const formFieldToTemplateKey: Record = ${JSON.stringify(f export function buildStartCard(): Record { const defaultBatchItemsJson = JSON.stringify([{ fields: defaultPreset.fields }], null, 2); - return { - config: { wide_screen_mode: true }, - header: { template: "blue", title: { tag: "plain_text", content: "Image Agent MVP" } }, - elements: [ + const elements: unknown[] = [ { tag: "markdown", content: "**Target service:** " + ${JSON.stringify(service.service.name)} + "\\n\\n**Templates:** " + templateSpecs.map((template) => template.id).join(", ") + "\\n\\nFill the parameters and submit to run /api/generate." }, { tag: "form", @@ -336,7 +339,7 @@ export function buildStartCard(): Record { { tag: "input", name: "param_size", required: true, default_value: defaultPreset.size, width: "fill", label: { tag: "plain_text", content: "Size" }, placeholder: { tag: "plain_text", content: "WIDTHxHEIGHT" } }, ...fieldSpecs.map((field) => ({ tag: "input", name: templateKeyToFormField[field.key] || field.name, required: field.required, default_value: field.defaultValue, width: "fill", label: { tag: "plain_text", content: field.label }, placeholder: { tag: "plain_text", content: field.placeholder || field.defaultValue || "Enter value" } })), { tag: "input", name: "param_message", required: false, default_value: defaultPreset.message || "", width: "fill", input_type: "multiline_text", rows: 2, auto_resize: true, label: { tag: "plain_text", content: "Message" }, placeholder: { tag: "plain_text", content: "Optional extra instruction" } }, - { tag: "button", text: { tag: "plain_text", content: "Generate image" }, type: "primary", action_type: "form_submit", name: "submit_image_generate", value: { action: "image.generate.submit", preset: defaultPreset } }, + callbackSubmitButton("Generate image", "submit_image_generate", { action: "image.generate.submit", preset: defaultPreset }), { tag: "button", text: { tag: "plain_text", content: "Reset" }, type: "default", action_type: "form_reset", name: "reset_image_generate" }, ], }, @@ -349,12 +352,12 @@ export function buildStartCard(): Record { { tag: "input", name: "param_batch_template_id", required: true, default_value: defaultPreset.template_id, width: "fill", label: { tag: "plain_text", content: "Batch template ID" }, placeholder: { tag: "plain_text", content: templateSpecs.map((template) => template.id).join(" / ") } }, { tag: "input", name: "param_batch_size", required: true, default_value: defaultPreset.size, width: "fill", label: { tag: "plain_text", content: "Batch size" }, placeholder: { tag: "plain_text", content: "WIDTHxHEIGHT" } }, { tag: "input", name: "param_batch_items_json", required: true, default_value: defaultBatchItemsJson, width: "fill", input_type: "multiline_text", rows: 5, auto_resize: true, label: { tag: "plain_text", content: "Batch items JSON" }, placeholder: { tag: "plain_text", content: "[{ \\\"fields\\\": { ... } }]" } }, - { tag: "button", text: { tag: "plain_text", content: "Start batch" }, type: "primary", action_type: "form_submit", name: "submit_image_batch", value: { action: "image.batch.submit" } }, + callbackSubmitButton("Start batch", "submit_image_batch", { action: "image.batch.submit" }), { tag: "button", text: { tag: "plain_text", content: "Reset" }, type: "default", action_type: "form_reset", name: "reset_image_batch" }, ], }, - ], - }; + ]; + return card("blue", "Image Agent MVP", elements); } export function buildSuccessCard(result: Record): Record { @@ -383,18 +386,15 @@ export function buildSuccessCard(result: Record): Record, downloadUrl: string): Record { @@ -423,26 +423,36 @@ export function buildBatchStatusCard(status: Record, downloadUr elements.push({ tag: "markdown", content: "[Download completed images ZIP](" + downloadUrl + ")" }); } if (batchId) { - elements.push({ - tag: "action", - actions: [ - { - tag: "button", - text: { tag: "plain_text", content: "Refresh status" }, - type: "default", - value: { action: "image.batch.refresh", batch_id: batchId }, - }, - ], - }); + if (useJson2Card) { + elements.push({ tag: "button", text: { tag: "plain_text", content: "Refresh status" }, type: "default", behaviors: [{ type: "callback", value: { action: "image.batch.refresh", batch_id: batchId } }] }); + } else { + elements.push({ + tag: "action", + actions: [ + { + tag: "button", + text: { tag: "plain_text", content: "Refresh status" }, + type: "default", + value: { action: "image.batch.refresh", batch_id: batchId }, + }, + ], + }); + } } - return { - config: { wide_screen_mode: true }, - header: { - template: running ? "blue" : failedCount > 0 ? "red" : "green", - title: { tag: "plain_text", content: running ? "Batch running" : failedCount > 0 ? "Batch finished with failures" : "Batch complete" }, - }, - elements, - }; + return card(running ? "blue" : failedCount > 0 ? "red" : "green", running ? "Batch running" : failedCount > 0 ? "Batch finished with failures" : "Batch complete", elements); +} + +function callbackSubmitButton(text: string, name: string, value: Record): Record { + return useJson2Card + ? { tag: "button", text: { tag: "plain_text", content: text }, type: "primary", form_action_type: "submit", name, behaviors: [{ type: "callback", value }] } + : { tag: "button", text: { tag: "plain_text", content: text }, type: "primary", action_type: "form_submit", name, value }; +} + +function card(template: string, title: string, elements: unknown[]): Record { + if (useJson2Card) { + return { schema: "2.0", config: { update_multi: true, wide_screen_mode: true }, header: { template, title: { tag: "plain_text", content: title } }, body: { elements } }; + } + return { config: { wide_screen_mode: true }, header: { template, title: { tag: "plain_text", content: title } }, elements }; } function numberValue(value: unknown): number { @@ -454,11 +464,7 @@ function stringValue(value: unknown): string { } export function buildFailureCard(message: string): Record { - return { - config: { wide_screen_mode: true }, - header: { template: "red", title: { tag: "plain_text", content: "Image generation failed" } }, - elements: [{ tag: "markdown", content: "**What happened:** " + message }], - }; + return card("red", "Image generation failed", [{ tag: "markdown", content: "**What happened:** " + message }]); } `; } diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index d32bbaf..7d48684 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -689,6 +689,9 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assert.ok(fs.existsSync(path.join(embeddedOnlyGenerated, "adapter", "handlers.ts"))); assert.ok(fs.existsSync(path.join(embeddedOnlyGenerated, "docs", "integration_guide.md"))); assert.equal(fs.existsSync(path.join(embeddedOnlyGenerated, "bot-runtime")), false); + const embeddedOnlyCards = fs.readFileSync(path.join(embeddedOnlyGenerated, "adapter", "cards.ts"), "utf8"); + assert.match(embeddedOnlyCards, /const useJson2Card = false;/); + assert.match(embeddedOnlyCards, /elements:\s*\[/); const embeddedOnlyStartHere = fs.readFileSync(path.join(embeddedOnlyGenerated, "START_HERE.md"), "utf8"); const embeddedOnlyReadme = fs.readFileSync(path.join(embeddedOnlyGenerated, "README.md"), "utf8"); assert.match(embeddedOnlyStartHere, /does not include `bot-runtime\/`/); @@ -772,6 +775,10 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target run(["generate", workspace, "--out", embeddedLongGenerated, "--mode", "embedded-adapter", "--host-mode", "embedded-long-connection"]); const embeddedLongSummary = JSON.parse(fs.readFileSync(path.join(embeddedLongGenerated, "generation_summary.json"), "utf8")); assert.equal(embeddedLongSummary.host_receive_mode, "embedded-long-connection"); + const embeddedLongCards = fs.readFileSync(path.join(embeddedLongGenerated, "adapter", "cards.ts"), "utf8"); + assert.match(embeddedLongCards, /schema:\s*["']2\.0["']/); + assert.match(embeddedLongCards, /body:\s*\{\s*elements/); + assert.match(embeddedLongCards, /behaviors:\s*\[\{\s*type:\s*["']callback["'],\s*value:\s*\{\s*action:/); const embeddedLongReadme = fs.readFileSync(path.join(embeddedLongGenerated, "README.md"), "utf8"); assert.match(embeddedLongReadme, /Host receive mode: embedded-long-connection/); assert.match(embeddedLongReadme, /card\.action\.trigger/); @@ -832,6 +839,10 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assert.match(embeddedLongHandoffStatus, /\| `VERIFICATION_TOKEN` \| optional \| none \|/); const embeddedHybridGenerated = path.join(temp, "generated-embedded-hybrid"); run(["generate", workspace, "--out", embeddedHybridGenerated, "--mode", "embedded-adapter", "--host-mode", "hybrid"]); + const embeddedHybridCards = fs.readFileSync(path.join(embeddedHybridGenerated, "adapter", "cards.ts"), "utf8"); + assert.match(embeddedHybridCards, /schema:\s*["']2\.0["']/); + assert.match(embeddedHybridCards, /body:\s*\{\s*elements/); + assert.match(embeddedHybridCards, /behaviors:\s*\[\{\s*type:\s*["']callback["'],\s*value:\s*\{\s*action:/); const embeddedHybridContext = JSON.parse(fs.readFileSync(path.join(embeddedHybridGenerated, "feishu_context.template.json"), "utf8")); assert.equal(embeddedHybridContext.host_receive_mode, "hybrid"); assert.equal(embeddedHybridContext.handoff_request.required_values.find((item) => item.key === "PUBLIC_CALLBACK_BASE_URL").required_for_level_2, true); @@ -2474,6 +2485,10 @@ test("generic HTTP API target can analyze generate and verify", () => { const generatedLong = path.join(temp, "generated-long"); run(["generate", workspace, "--out", generatedLong, "--mode", "embedded-adapter", "--host-mode", "embedded-long-connection"]); + const genericLongCards = fs.readFileSync(path.join(generatedLong, "adapter", "cards.ts"), "utf8"); + assert.match(genericLongCards, /schema:\s*["']2\.0["']/); + assert.match(genericLongCards, /body:\s*\{\s*elements/); + assert.match(genericLongCards, /behaviors:\s*\[\{\s*type:\s*["']callback["'],\s*value:\s*\{\s*action:/); const genericSidecarReadme = fs.readFileSync(path.join(generatedLong, "sidecar-long-connection", "README.md"), "utf8"); const genericSidecarTest = fs.readFileSync(path.join(generatedLong, "sidecar-long-connection", "local-contract-test.mjs"), "utf8"); assert.match(genericSidecarReadme, /handleGenericHttpCardAction/); From e571acfd520c7802a4c31bf6bc5e3e6ea8b93391 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:11:45 +0800 Subject: [PATCH 15/37] fix: make generated context host-mode aware Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/context.ts | 25 +++++++++++++++++++++++-- tests/cli-smoke.test.mjs | 22 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/commands/context.ts b/src/commands/context.ts index 72eedd1..70e9b77 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -172,6 +172,7 @@ export function buildContextTemplate( const embedded = options.integrationMode === "embedded-adapter"; const selfHosted = options.integrationMode === "self-hosted-runtime"; const hostReceiveMode = options.hostReceiveMode || (embedded ? "embedded-webhook" : "standalone-runtime"); + const longConnection = hostReceiveMode === "embedded-long-connection"; const hybrid = hostReceiveMode === "hybrid"; const hostModeOption = embedded && hostReceiveMode !== "embedded-webhook" ? ` --host-mode ${hostReceiveMode}` : ""; const selfHostedModeOption = " --mode self-hosted-runtime"; @@ -444,6 +445,17 @@ export function buildContextTemplate( { key: "IMAGE_AGENT_TIMEOUT_MS", recommended_value: "120000", note: "Target HTTP timeout used by service_client.py." }, { key: "FEISHU_ALLOWED_USERS", recommended_value: "", note: "Optional operator authorization guard." }, { key: "TEST_CHAT_ID", recommended_value: "", note: "Optional until app.py send-start-card flow is used." }, + ] : longConnection ? [ + { + key: "IMAGE_AGENT_TIMEOUT_MS", + recommended_value: "120000", + note: "Target service call timeout configured in the existing long-connection host service.", + }, + { + key: "ALLOWED_OPERATOR_OPEN_IDS", + recommended_value: "", + note: "Optional host authorization guard. When set, only listed Feishu operator open_id values can execute card actions.", + }, ] : [ { key: "CARD_ACTION_MODE", @@ -875,7 +887,11 @@ export function buildContextReplyTemplate(template: ContextTemplate): ContextRep "Run configure --strict, start the generated bot runtime, then run verify --level2.", ], secret_red_lines: [ - selfHosted ? "Do not paste FEISHU_APP_SECRET into this reply template." : "Do not paste APP_SECRET, VERIFICATION_TOKEN, ENCRYPT_KEY, or DEBUG_ACCESS_TOKEN into this reply template.", + selfHosted + ? "Do not paste FEISHU_APP_SECRET into this reply template." + : longConnection + ? "Do not paste APP_SECRET into this reply template." + : "Do not paste APP_SECRET, VERIFICATION_TOKEN, ENCRYPT_KEY, or DEBUG_ACCESS_TOKEN into this reply template.", "Share secrets only through the secure_secret_channel named here.", "Do not replace empty secret fields in shared Markdown with real secret values.", ], @@ -898,6 +914,11 @@ export function buildContextReplyMarkdown(reply: ContextReplyTemplate): string { const blockedBy = reply.blocked_by.length ? reply.blocked_by.map((item) => `- ${item}`).join("\n") : "- none"; const nextSteps = reply.next_local_steps.map((item) => `- [ ] ${item}`).join("\n"); const redLines = reply.secret_red_lines.map((item) => `- ${item}`).join("\n"); + const secretChannelHint = reply.secret_red_lines.some((item) => item.includes("FEISHU_APP_SECRET")) + ? "" + : reply.secret_red_lines.some((item) => item.includes("VERIFICATION_TOKEN")) + ? "" + : ""; return `# Feishu Context Reply Template @@ -907,7 +928,7 @@ Use this safe reply template to record non-secret owner answers. Copy it to \`fe - Target service: ${reply.target_service.name} - Target base URL: ${reply.target_service.base_url || ""} - Responder: ${reply.responder.name || ""} (${reply.responder.role || ""}) -- Secure secret channel: ${reply.secure_secret_channel || ""} +- Secure secret channel: ${reply.secure_secret_channel || secretChannelHint} ## Answers diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index 7d48684..efd19da 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -507,6 +507,12 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assert.doesNotMatch(selfHostedRequest, /PUBLIC_CALLBACK_BASE_URL/); assert.doesNotMatch(selfHostedRequest, /VERIFICATION_TOKEN/); assert.doesNotMatch(selfHostedRequest, /\/webhook\/card/); + const selfHostedReplyMarkdown = fs.readFileSync(path.join(selfHostedGenerated, "feishu_context.reply.template.md"), "utf8"); + assert.match(selfHostedReplyMarkdown, /FEISHU_APP_SECRET/); + assert.match(selfHostedReplyMarkdown, /FEISHU_CONNECTION_MODE/); + assert.doesNotMatch(selfHostedReplyMarkdown, /PUBLIC_CALLBACK_BASE_URL/); + assert.doesNotMatch(selfHostedReplyMarkdown, /VERIFICATION_TOKEN/); + assert.doesNotMatch(selfHostedReplyMarkdown, /\/webhook\/card/); const selfHostedReadinessOutput = run(["readiness", selfHostedGenerated]); assert.match(selfHostedReadinessOutput, /Missing required values: FEISHU_APP_ID, FEISHU_APP_SECRET/); assert.match(selfHostedReadinessOutput, /feishu-host/); @@ -791,6 +797,11 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assert.doesNotMatch(embeddedLongContextMarkdown, /\/webhook\/card/); assert.doesNotMatch(embeddedLongContextMarkdown, /- VERIFICATION_TOKEN/); assert.doesNotMatch(embeddedLongContextMarkdown, /- PUBLIC_CALLBACK_BASE_URL/); + const embeddedLongReplyMarkdown = fs.readFileSync(path.join(embeddedLongGenerated, "feishu_context.reply.template.md"), "utf8"); + assert.match(embeddedLongReplyMarkdown, /long-connection host service's secret\/config system|long-connection host service/i); + assert.doesNotMatch(embeddedLongReplyMarkdown, /PUBLIC_CALLBACK_BASE_URL/); + assert.doesNotMatch(embeddedLongReplyMarkdown, /VERIFICATION_TOKEN/); + assert.doesNotMatch(embeddedLongReplyMarkdown, /\/webhook\/card/); const embeddedLongLevel2Record = fs.readFileSync(path.join(embeddedLongGenerated, "level2_verification_record.md"), "utf8"); assert.match(embeddedLongLevel2Record, /Host receive mode: embedded-long-connection/); assert.match(embeddedLongLevel2Record, /card\.action\.trigger/); @@ -2489,6 +2500,17 @@ test("generic HTTP API target can analyze generate and verify", () => { assert.match(genericLongCards, /schema:\s*["']2\.0["']/); assert.match(genericLongCards, /body:\s*\{\s*elements/); assert.match(genericLongCards, /behaviors:\s*\[\{\s*type:\s*["']callback["'],\s*value:\s*\{\s*action:/); + const genericLongContext = JSON.parse(fs.readFileSync(path.join(generatedLong, "feishu_context.template.json"), "utf8")); + assert.equal(genericLongContext.host_receive_mode, "embedded-long-connection"); + assert.equal(genericLongContext.handoff_request.required_values.some((item) => item.key === "PUBLIC_CALLBACK_BASE_URL" && item.required_for_level_2), false); + const genericLongReplyMarkdown = fs.readFileSync(path.join(generatedLong, "feishu_context.reply.template.md"), "utf8"); + assert.doesNotMatch(genericLongReplyMarkdown, /PUBLIC_CALLBACK_BASE_URL|VERIFICATION_TOKEN|\/webhook\/card/); + const genericLongReadinessOutput = run(["readiness", generatedLong]); + assert.doesNotMatch(genericLongReadinessOutput, /Missing required values:.*PUBLIC_CALLBACK_BASE_URL/); + assert.doesNotMatch(genericLongReadinessOutput, /Missing required values:.*VERIFICATION_TOKEN/); + const genericLongDoctorJson = JSON.parse(run(["doctor", generatedLong, "--mode", "embedded-adapter", "--host-mode", "embedded-long-connection", "--json"])); + assert.equal(genericLongDoctorJson.host_receive_mode, "embedded-long-connection"); + assert.equal(genericLongDoctorJson.blockers.some((item) => item.includes("/webhook/card") || item.includes("PUBLIC_CALLBACK_BASE_URL") || item.includes("VERIFICATION_TOKEN")), false); const genericSidecarReadme = fs.readFileSync(path.join(generatedLong, "sidecar-long-connection", "README.md"), "utf8"); const genericSidecarTest = fs.readFileSync(path.join(generatedLong, "sidecar-long-connection", "local-contract-test.mjs"), "utf8"); assert.match(genericSidecarReadme, /handleGenericHttpCardAction/); From 4804b48b63d28ef2cf6bfb95cb9b2b24075ed4f0 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:19:38 +0800 Subject: [PATCH 16/37] fix: generate self-hosted Level 2 evidence template Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/generate.ts | 101 +++++++++++++++++++++++++++++++++++++++ tests/cli-smoke.test.mjs | 11 +++++ 2 files changed, 112 insertions(+) diff --git a/src/commands/generate.ts b/src/commands/generate.ts index e70344b..06d7daa 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -2436,6 +2436,107 @@ function buildLevel2VerificationRecord(service: ServiceManifest, permissions: Re ? permissions.callbacks.map((callback) => ` - [ ] \`${callback.callback}\` - ${callback.reason}`).join("\n") : " - [ ] No explicit callbacks were generated."; + if (integrationMode === "self-hosted-runtime") { + return `# Level 2 Verification Record + +Use this file to record the real Feishu/Lark verification for this self-hosted generated host module. + +## Environment + +- Date: +- Operator: +- Target service: ${service.service.name} +- Target base URL: ${service.service.base_url || ""} +- Generated package path: +- Runtime path: feishu-host/ +- Required local env file: feishu-host/.env +- FEISHU_CONNECTION_MODE=websocket +- Event subscription: card.action.trigger +- Feishu app name: +- Test chat: + +## Required Feishu Setup + +- [ ] Bot capability is enabled. +- [ ] Bot is added to the test chat. +- [ ] App credentials are written to \`feishu-host/.env\`: \`FEISHU_APP_ID\`, \`FEISHU_APP_SECRET\`. +- [ ] \`FEISHU_CONNECTION_MODE=websocket\` is set. +- [ ] \`IMAGE_AGENT_BASE_URL\` is set to the reachable target service base URL. +- [ ] The Feishu app is subscribed to \`card.action.trigger\`. +- [ ] Optional \`FEISHU_ALLOWED_USERS\` is set for real group use, or the operator explicitly accepts that any valid card click can run the service. +- [ ] Optional \`TEST_CHAT_ID\` is set before using \`python feishu-host/app.py --send-start-card\`. + +## Required Scopes + +${scopes} + +## Required Events + +- [ ] \`card.action.trigger\` is subscribed for the app. + +## CLI Command Style + +- If this package still lives under the original Lark-deployer repository, run commands as \`node ..\\..\\dist\\index.js .\`. +- If this package was copied elsewhere, set \`$env:LARK_DEPLOYER_CLI="C:\\path\\to\\Lark-deployer\\dist\\index.js"\` and run commands as \`node $env:LARK_DEPLOYER_CLI .\`. + +## Preflight Evidence + +- [ ] \`python feishu-host/local_contract_test.py\` succeeds. +- [ ] \`python feishu-host/app.py --selfcheck\` succeeds and reports card.action.trigger wiring. +- [ ] \`verify . --mode self-hosted-runtime --strict\` succeeds. +- [ ] \`verification_report.md\` has no unexpected FAIL checks. + +## Interaction Evidence + +- [ ] \`python feishu-host/app.py --send-start-card\` sends the start card to the test chat, or an equivalent host-owned start-card send is documented. +- [ ] Test chat receives the start card rendered from \`feishu-host/spec/start_card.json\`. +- [ ] Host logs show the Feishu SDK long connection is online. +- [ ] Host logs show a \`card.action.trigger\` event reaches \`feishu-host/app.py\`. +- [ ] The generated host calls \`${service.service.base_url || ""}/api/generate\`. +- [ ] Submitted template id, field, size, and message values appear in the target request or output behavior. +- [ ] Target service returns \`image_url\`. +- [ ] Test chat receives a success card. +- [ ] Success card shows \`Feedback\` input and \`Iterate image\` action when the target returns \`session_id\`. +- [ ] Operator submits feedback from the success card in Feishu. +- [ ] The generated host calls \`${service.service.base_url || ""}/api/iterate\`. +- [ ] Operator submits a batch job from Feishu. +- [ ] The generated host calls \`${service.service.base_url || ""}/api/batch\`. +- [ ] Batch progress card shows batch id, done/total, completed count, failed count, and refresh action. +- [ ] Operator refreshes the batch progress card from Feishu. +- [ ] The generated host calls \`${service.service.base_url || ""}/api/batch/{batch_id}/status\`. +- [ ] Completed batch card shows a download link for \`${service.service.base_url || ""}/api/batch/{batch_id}/download\` when completed images exist. + +## Failure-Path Evidence + +At least one failure path should be observed before considering this package stable: + +- [ ] Invalid card input returns a red failure card and does not call the target service. +- [ ] Missing or invalid target base URL returns a readable failure card. +- [ ] Slow or stuck target response returns a readable timeout failure card. +- [ ] Missing \`feishu-host/.env\` values are caught before starting the long-connection host. +- [ ] Unauthorized operator input returns a readable failure card when \`FEISHU_ALLOWED_USERS\` is set. + +## Artifacts + +- \`verification_report.md\` path: +- feishu-host console/log evidence path: +- Start card message ID or screenshot: +- Result card message ID or screenshot: +- Generated image URL: +- Batch ID: +- Batch status card message ID or screenshot: +- Batch download URL or screenshot: +- Trace ID: +- Notes: + +## Completion Decision + +- [ ] Level 2 verified. +- [ ] Remaining issues documented. +- [ ] This generated package can be handed to another FDE using \`README.md\`, \`deployment_checklist.md\`, and this file. +`; + } + if (integrationMode === "embedded-adapter") { const longConnection = hostReceiveMode === "embedded-long-connection"; const usesWebhook = hostModeUsesWebhook(hostReceiveMode); diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index efd19da..0b03545 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -542,6 +542,17 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assert.match(selfHostedRootReadme, /Self-Hosted Runtime Package/); assert.match(selfHostedRootReadme, /python app\.py --send-start-card/); assert.doesNotMatch(selfHostedRootReadme, /bot-runtime/); + const selfHostedLevel2 = fs.readFileSync(path.join(selfHostedGenerated, "level2_verification_record.md"), "utf8"); + assert.match(selfHostedLevel2, /feishu-host\/\.env/); + assert.match(selfHostedLevel2, /FEISHU_CONNECTION_MODE=websocket/); + assert.match(selfHostedLevel2, /card\.action\.trigger/); + assert.match(selfHostedLevel2, /python feishu-host\/local_contract_test\.py/); + assert.match(selfHostedLevel2, /python feishu-host\/app\.py --selfcheck/); + assert.doesNotMatch(selfHostedLevel2, /bot-runtime\/\.env/); + assert.doesNotMatch(selfHostedLevel2, /Bot runtime URL:/); + assert.doesNotMatch(selfHostedLevel2, /PUBLIC_CALLBACK_BASE_URL/); + assert.doesNotMatch(selfHostedLevel2, /VERIFICATION_TOKEN/); + assert.doesNotMatch(selfHostedLevel2, /\/webhook\/card/); const selfHostedPackageGitignore = fs.readFileSync(path.join(selfHostedGenerated, ".gitignore"), "utf8"); assert.match(selfHostedPackageGitignore, /feishu-host\/\.env/); const selfHostedStartCard = JSON.parse(fs.readFileSync(path.join(selfHostedFeishuHost, "spec", "start_card.json"), "utf8")); From 49950d6893d0c4b1925d23dab58281dde4d8c681 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:38 +0800 Subject: [PATCH 17/37] ci: validate build smoke tests and dependency audit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .github/workflows/ci.yml | 18 ++++++++++++++++++ package-lock.json | 3 +++ package.json | 10 +++++++++- tests/cli-smoke.test.mjs | 10 ++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8c60f88 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: ci + +on: [push, pull_request] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.16.0 + cache: npm + - run: npm ci + - run: npm run check + - run: npm run test:unit + - run: npm run test:smoke + - run: npm audit --package-lock-only --audit-level=moderate diff --git a/package-lock.json b/package-lock.json index 45e11c2..800b964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,9 @@ "@types/node": "^24.0.10", "tsx": "^4.20.3", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=24.16.0" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index 54a561f..de31120 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,16 @@ "dev": "tsx src/index.ts", "start": "node dist/index.js", "check": "npm run build", - "test": "npm run build && node --test tests/*.test.mjs" + "test:unit": "node --test tests/unit-pure-functions.test.mjs", + "test:smoke": "node --test tests/cli-smoke.test.mjs", + "test:e2e": "node --test tests/runtime-local-e2e.test.mjs", + "test:coverage": "node --experimental-test-coverage --test tests/unit-pure-functions.test.mjs tests/cli-smoke.test.mjs", + "test": "npm run build && npm run test:unit && npm run test:smoke && npm run test:e2e" }, + "engines": { + "node": ">=24.16.0" + }, + "packageManager": "npm@11.15.0", "devDependencies": { "@types/node": "^24.0.10", "tsx": "^4.20.3", diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index 0b03545..3ab50bb 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -63,6 +63,16 @@ test("top-level docs define Code2Lark delivery modes", () => { assert.match(status, /canonical MVP package.*schema 0\.2/i); }); +test("package scripts and CI workflow expose local verification gates", () => { + const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + + assert.equal(typeof packageJson.scripts["test:unit"], "string"); + assert.equal(typeof packageJson.scripts["test:smoke"], "string"); + assert.equal(typeof packageJson.scripts["test:e2e"], "string"); + assert.equal(typeof packageJson.scripts["test:coverage"], "string"); + assert.ok(fs.existsSync(path.join(root, ".github", "workflows", "ci.yml"))); +}); + test("strict verify rejects outdated manifest schemas", () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-old-schema-")); fs.mkdirSync(path.join(temp, "manifest"), { recursive: true }); From 70caa4ae33becd0287c3049903235516270ce9d1 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:57:59 +0800 Subject: [PATCH 18/37] test: cover generated package security boundaries Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tests/cli-smoke.test.mjs | 16 ++++++++++------ tests/runtime-local-e2e.test.mjs | 2 ++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index 3ab50bb..fbb8f5f 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -2020,12 +2020,12 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assert.ok(fs.existsSync(path.join(handoffCopy, "feishu_context.reply.template.json"))); assert.ok(fs.existsSync(path.join(handoffCopy, "feishu_context.reply.template.md"))); assert.ok(fs.existsSync(path.join(handoffCopy, "level2_manual_evidence.template.json"))); - assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "handlers.ts"))); - assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "cards.ts"))); - assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "service-client.ts"))); - assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "validation.ts"))); - assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "types.ts"))); - assert.ok(fs.existsSync(path.join(handoffCopy, "adapter", "audit-events.ts"))); + assertFileExists(path.join(handoffCopy, "adapter", "handlers.ts")); + assertFileExists(path.join(handoffCopy, "adapter", "cards.ts")); + assertFileExists(path.join(handoffCopy, "adapter", "service-client.ts")); + assertFileExists(path.join(handoffCopy, "adapter", "validation.ts")); + assertFileExists(path.join(handoffCopy, "adapter", "types.ts")); + assertFileExists(path.join(handoffCopy, "adapter", "audit-events.ts")); assert.ok(fs.existsSync(path.join(handoffCopy, "bot-runtime", "src", "index.ts"))); assert.ok(fs.existsSync(path.join(handoffCopy, "manifest", "service_manifest.json"))); assert.equal(fs.existsSync(path.join(handoffCopy, "bot-runtime", ".env")), false); @@ -2654,6 +2654,10 @@ function run(args) { }); } +function assertFileExists(filePath) { + assert.ok(fs.existsSync(filePath), `Expected file to exist: ${filePath}`); +} + function genericAdapterContractScript(generated) { const handlersUrl = path.join(generated, "adapter", "handlers.js").replace(/\\/g, "/"); const cardsUrl = path.join(generated, "adapter", "cards.js").replace(/\\/g, "/"); diff --git a/tests/runtime-local-e2e.test.mjs b/tests/runtime-local-e2e.test.mjs index 66fed0f..5469fbe 100644 --- a/tests/runtime-local-e2e.test.mjs +++ b/tests/runtime-local-e2e.test.mjs @@ -74,6 +74,8 @@ test("generated runtime can simulate the image-agent-web card flow locally", { t body: JSON.stringify({}), }); assert.equal(safeDefaultDebugDenied.status, 403); + const safeDefaultAuditDenied = await fetch(`http://127.0.0.1:${safeDefaultRuntimePort}/debug/audit-tail`); + assert.equal(safeDefaultAuditDenied.status, 403); safeDefaultRuntime.kill(); await waitForProcessExit(safeDefaultRuntime); safeDefaultRuntime = undefined; From 1c47b7ea6dba8331d0c74f463cb86ed6fc278e6d Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:02:54 +0800 Subject: [PATCH 19/37] test: validate MVP integration package against calendar-stock-updater Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- docs/capability-validation-matrix.md | 2 +- docs/second-target-validation-plan.md | 31 +++++++++++++++++++++++++++ tests/cli-smoke.test.mjs | 10 +++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 docs/second-target-validation-plan.md diff --git a/docs/capability-validation-matrix.md b/docs/capability-validation-matrix.md index 3cbfdc3..a9b108e 100644 --- a/docs/capability-validation-matrix.md +++ b/docs/capability-validation-matrix.md @@ -6,4 +6,4 @@ This matrix is the current human-readable fact source for target profile, delive | --- | --- | --- | --- | --- | --- | | image-agent-web | Mode A | self-hosted / long connection | yes | yes | verified sample baseline | | image-agent-web | Mode B | embedded host module | yes | yes | verified sample baseline | -| calendar-stock-updater | generic target candidate | Mode A pending | package validation only | no | second-target validation pending | +| calendar-stock-updater | Mode A | embedded/adapter-first | yes | package validation only | non-image generic HTTP package validation in progress | diff --git a/docs/second-target-validation-plan.md b/docs/second-target-validation-plan.md new file mode 100644 index 0000000..8fc1e68 --- /dev/null +++ b/docs/second-target-validation-plan.md @@ -0,0 +1,31 @@ +# Second Target Validation Plan + +## Selected Target + +- `calendar-stock-updater` +- Why: non-image, task/status oriented, and already represented by generic HTTP fixture coverage. + +## Delivery Choice + +- Default: Mode A +- Host shape: external host / sidecar / gateway consuming the generated adapter package. +- Rationale: validate the MVP integration package against a second target without forcing an embedded-host migration first. + +## Minimum Validation Scope + +- One query/read path: `GET /api/state` as `http.get.api.state`. +- One reviewed action path: `POST /api/run` as `http.post.api.run.submit`. +- One rejected destructive path: `POST /api/stop` remains excluded from generated card actions unless explicitly reviewed later. +- Strict package verification: `verify --mode embedded-adapter --strict`. +- Operator handoff checks: readiness, doctor, and handoff outputs must stay generic and avoid image-agent assumptions. + +## Evidence Location + +- Automated evidence lives in `tests/cli-smoke.test.mjs` under `calendar-stock-updater Node target can analyze generate and verify`. +- Human-readable status is tracked in `docs/capability-validation-matrix.md`. + +## Out Of Scope + +- No new platform targets. +- No real Feishu deployment for this second target in the current phase. +- No Mode B migration unless the target owner explicitly requests an internal host module. diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index fbb8f5f..2a4386a 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -2547,6 +2547,12 @@ test("calendar-stock-updater Node target can analyze generate and verify", () => const target = path.join(temp, "calendar-stock-updater"); const workspace = path.join(temp, "out"); const generated = path.join(temp, "generated"); + const secondTargetPlan = fs.readFileSync(path.join(root, "docs", "second-target-validation-plan.md"), "utf8"); + + assert.match(secondTargetPlan, /calendar-stock-updater/); + assert.match(secondTargetPlan, /Mode A/); + assert.match(secondTargetPlan, /query/i); + assert.match(secondTargetPlan, /action/i); fs.mkdirSync(target, { recursive: true }); fs.writeFileSync(path.join(target, "package.json"), JSON.stringify({ name: "calendar-stock-updater", scripts: { ui: "node server.js" } }, null, 2), "utf8"); @@ -2601,9 +2607,13 @@ test("calendar-stock-updater Node target can analyze generate and verify", () => run(["generate", workspace, "--out", generated, "--mode", "embedded-adapter"]); const generatedAdapter = fs.readFileSync(path.join(generated, "adapter", "handlers.ts"), "utf8"); + const generatedAdapterCards = fs.readFileSync(path.join(generated, "adapter", "cards.ts"), "utf8"); + const generatedReadme = fs.readFileSync(path.join(generated, "README.md"), "utf8"); assert.match(generatedAdapter, /http\.post\.api\.run\.submit/); assert.doesNotMatch(generatedAdapter, /http\.post\.api\.stop\.submit/); assert.doesNotMatch(generatedAdapter, /image\.generate|image_url|session_id/); + assert.doesNotMatch(generatedAdapterCards, /image\.batch\.submit/); + assert.doesNotMatch(generatedReadme, /image\.generate/); const verifyOutput = run(["verify", generated, "--mode", "embedded-adapter", "--strict"]); assert.match(verifyOutput, /adapter:action:http\.post\.api\.run\.submit/); assert.doesNotMatch(verifyOutput, /adapter:action:http\.post\.api\.stop\.submit/); From 5168276922a27c7b59e0f9a5eb7bda688582a949 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:06:10 +0800 Subject: [PATCH 20/37] docs: record second-target integration evidence Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- docs/capability-validation-matrix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/capability-validation-matrix.md b/docs/capability-validation-matrix.md index a9b108e..eaf73dc 100644 --- a/docs/capability-validation-matrix.md +++ b/docs/capability-validation-matrix.md @@ -6,4 +6,4 @@ This matrix is the current human-readable fact source for target profile, delive | --- | --- | --- | --- | --- | --- | | image-agent-web | Mode A | self-hosted / long connection | yes | yes | verified sample baseline | | image-agent-web | Mode B | embedded host module | yes | yes | verified sample baseline | -| calendar-stock-updater | Mode A | embedded/adapter-first | yes | package validation only | non-image generic HTTP package validation in progress | +| calendar-stock-updater | Mode A | embedded/adapter-first | yes | package validation only | query + reviewed action validated via MVP package | From 3e668a2351dab86318e5b43053bc221022408b00 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:15:28 +0800 Subject: [PATCH 21/37] test: strengthen second-target evidence gates Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tests/cli-smoke.test.mjs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index 2a4386a..7295e39 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -65,12 +65,16 @@ test("top-level docs define Code2Lark delivery modes", () => { test("package scripts and CI workflow expose local verification gates", () => { const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + const workflow = fs.readFileSync(path.join(root, ".github", "workflows", "ci.yml"), "utf8"); - assert.equal(typeof packageJson.scripts["test:unit"], "string"); - assert.equal(typeof packageJson.scripts["test:smoke"], "string"); - assert.equal(typeof packageJson.scripts["test:e2e"], "string"); - assert.equal(typeof packageJson.scripts["test:coverage"], "string"); - assert.ok(fs.existsSync(path.join(root, ".github", "workflows", "ci.yml"))); + assert.equal(packageJson.scripts["test:unit"], "node --test tests/unit-pure-functions.test.mjs"); + assert.equal(packageJson.scripts["test:smoke"], "node --test tests/cli-smoke.test.mjs"); + assert.equal(packageJson.scripts["test:e2e"], "node --test tests/runtime-local-e2e.test.mjs"); + assert.match(packageJson.scripts["test:coverage"], /node --experimental-test-coverage --test/); + assert.match(workflow, /npm run check/); + assert.match(workflow, /npm run test:unit/); + assert.match(workflow, /npm run test:smoke/); + assert.match(workflow, /npm audit --package-lock-only --audit-level=moderate/); }); test("strict verify rejects outdated manifest schemas", () => { @@ -2609,15 +2613,23 @@ test("calendar-stock-updater Node target can analyze generate and verify", () => const generatedAdapter = fs.readFileSync(path.join(generated, "adapter", "handlers.ts"), "utf8"); const generatedAdapterCards = fs.readFileSync(path.join(generated, "adapter", "cards.ts"), "utf8"); const generatedReadme = fs.readFileSync(path.join(generated, "README.md"), "utf8"); + assert.match(generatedAdapter, /http\.get\.api\.state\.submit/); assert.match(generatedAdapter, /http\.post\.api\.run\.submit/); assert.doesNotMatch(generatedAdapter, /http\.post\.api\.stop\.submit/); assert.doesNotMatch(generatedAdapter, /image\.generate|image_url|session_id/); assert.doesNotMatch(generatedAdapterCards, /image\.batch\.submit/); assert.doesNotMatch(generatedReadme, /image\.generate/); const verifyOutput = run(["verify", generated, "--mode", "embedded-adapter", "--strict"]); + assert.match(verifyOutput, /adapter:action:http\.get\.api\.state\.submit/); assert.match(verifyOutput, /adapter:action:http\.post\.api\.run\.submit/); assert.doesNotMatch(verifyOutput, /adapter:action:http\.post\.api\.stop\.submit/); assert.doesNotMatch(verifyOutput, /generate and batch|generate\/batch/); + const readinessOutput = run(["readiness", generated]); + assert.doesNotMatch(readinessOutput, /image\.generate|image_url|session_id/); + const handoffOutput = run(["handoff", generated]); + assert.match(handoffOutput, /Handoff manifest written/); + const handoffManifest = fs.readFileSync(path.join(generated, "handoff_manifest.md"), "utf8"); + assert.doesNotMatch(handoffManifest, /image\.generate|image_url|session_id/); const doctorJson = JSON.parse(run(["doctor", generated, "--mode", "embedded-adapter", "--json"])); assert.equal(doctorJson.package_validation.status, "pass"); }); From 78462d5e6e6a43939717ca3731497b4cc1c1f0a4 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:26:50 +0800 Subject: [PATCH 22/37] fix: align handoff copy and check with generated package mode Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/handoff.ts | 49 ++++++++++++++++++++++++++-------------- tests/cli-smoke.test.mjs | 23 +++++++++++++++++-- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/commands/handoff.ts b/src/commands/handoff.ts index ff2c8db..01d4c95 100644 --- a/src/commands/handoff.ts +++ b/src/commands/handoff.ts @@ -26,6 +26,14 @@ interface HandoffManifest { next_steps: string[]; } +interface HandoffFileSet { + recommended: HandoffFileDefinition[]; + optional: HandoffFileDefinition[]; + excluded: HandoffFileDefinition[]; +} + +type HandoffFileDefinition = readonly [string, string]; + interface SensitiveValue { key: string; value: string; @@ -70,12 +78,11 @@ const GENERIC_SECRET_PATTERNS: Array<{ kind: string; pattern: RegExp }> = [ { kind: "json_secret_value", pattern: /"(app_secret|verification_token|encrypt_key|debug_access_token)"\s*:\s*"(?!<)[^"\r\n]{6,}"/i }, ]; -const RECOMMENDED_FILES = [ +const BASE_RECOMMENDED_FILES = [ [".gitignore", "Generated package local-file exclusions."], ["START_HERE.md", "Short first-entry guide for the next FDE or operator."], ["README.md", "Primary operator guide for the generated package."], ["deployment_checklist.md", "Step-by-step FDE checklist."], - ["permission_review.md", "Feishu scopes, callbacks, and permission rationale."], ["feishu_context.template.json", "Fillable external context template without real secrets."], ["feishu_context.template.md", "Human-readable context checklist for app owner/FDE values."], ["feishu_context.request.md", "Short owner-facing request that asks who can provide each external value, permission, and callback."], @@ -88,25 +95,16 @@ const RECOMMENDED_FILES = [ ["manifest/capability_map.json", "Machine-readable capability contract."], ["manifest/interaction_contract.json", "Machine-readable Lark interaction contract."], ["manifest/required_permissions.json", "Machine-readable permission contract."], - ["manifest/image_agent_meta.snapshot.json", "Template metadata snapshot used by generated cards."], ["adapter/handlers.ts", "Generated adapter action handlers."], ["adapter/cards.ts", "Generated Lark card builders for the adapter."], ["adapter/service-client.ts", "Generated target service client for the adapter."], ["adapter/validation.ts", "Generated adapter input validation."], ["adapter/types.ts", "Generated adapter TypeScript contracts."], ["adapter/audit-events.ts", "Generated adapter audit event helpers."], - ["bot-runtime/package.json", "Runtime package manifest."], - ["bot-runtime/tsconfig.json", "Runtime TypeScript build config."], - ["bot-runtime/.env.example", "Safe runtime env template."], - ["bot-runtime/.gitignore", "Runtime local file exclusions."], - ["bot-runtime/src/audit.ts", "Runtime audit helper source."], - ["bot-runtime/src/cards.ts", "Generated Lark card builders."], - ["bot-runtime/src/config.ts", "Runtime config loader."], - ["bot-runtime/src/image-agent-client.ts", "Target service client."], - ["bot-runtime/src/index.ts", "Runtime HTTP server and callback handler."], ] as const; const OPTIONAL_EVIDENCE_FILES = [ + ["permission_review.md", "Feishu scopes, callbacks, and permission rationale."], ["doctor_report.md", "Human-readable MVP gate report generated by the doctor command."], ["doctor_report.json", "Machine-readable MVP gate report generated by the doctor command."], ["handoff_manifest.md", "Human-readable safe transfer manifest generated by the handoff command."], @@ -467,9 +465,10 @@ function level2RecordCliGuidance(): string { function buildHandoffManifest(packagePath: string): HandoffManifest { const service = readJsonFile(path.join(packagePath, "manifest", "service_manifest.json")); - const recommendedFiles = requiredHandoffFiles(packagePath).map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); - const optionalEvidenceFiles = OPTIONAL_EVIDENCE_FILES.map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); - const excludedPaths = EXCLUDED_PATHS.map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); + const fileSet = buildHandoffFileSet(packagePath); + const recommendedFiles = fileSet.recommended.map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); + const optionalEvidenceFiles = fileSet.optional.map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); + const excludedPaths = fileSet.excluded.map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); const warnings = buildWarnings(recommendedFiles, excludedPaths); return { @@ -493,8 +492,20 @@ function buildHandoffManifest(packagePath: string): HandoffManifest { }; } -function requiredHandoffFiles(packagePath: string): Array<[string, string]> { - const rows = new Map(RECOMMENDED_FILES); +function buildHandoffFileSet(packagePath: string): HandoffFileSet { + return { + recommended: requiredHandoffFiles(packagePath), + optional: optionalHandoffFiles(packagePath), + excluded: Array.from(EXCLUDED_PATHS), + }; +} + +function requiredHandoffFiles(packagePath: string): HandoffFileDefinition[] { + const rows = new Map(BASE_RECOMMENDED_FILES); + const imageMetaPath = "manifest/image_agent_meta.snapshot.json"; + if (pathExists(packagePath, imageMetaPath)) { + rows.set(imageMetaPath, "Template metadata snapshot used by generated cards."); + } for (const dir of ["adapter", "bot-runtime", "feishu-host", "sidecar-long-connection"]) { const absoluteDir = path.join(packagePath, dir); if (!fs.existsSync(absoluteDir) || !fs.statSync(absoluteDir).isDirectory()) continue; @@ -508,6 +519,10 @@ function requiredHandoffFiles(packagePath: string): Array<[string, string]> { return Array.from(rows.entries()); } +function optionalHandoffFiles(packagePath: string): HandoffFileDefinition[] { + return OPTIONAL_EVIDENCE_FILES.filter(([filePath]) => pathExists(packagePath, filePath)); +} + function generatedClosureReason(dir: string): string { if (dir === "adapter") return "Generated adapter runtime closure."; if (dir === "bot-runtime") return "Generated standalone reference runtime closure."; diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index 7295e39..edbd21d 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -2011,8 +2011,9 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assert.doesNotMatch(handoffMarkdown, /test_secret/); assert.doesNotMatch(handoffMarkdown, /cli_debug_token/); const handoffCopy = path.join(temp, "handoff", "nested", "handoff-copy"); - const handoffCopyOutput = run(["handoff", generated, "--copy-to", handoffCopy]); + const handoffCopyOutput = run(["handoff", generated, "--copy-to", handoffCopy, "--check"]); assert.match(handoffCopyOutput, /Sanitized handoff copy written/); + assert.match(handoffCopyOutput, /Handoff check passed/); assert.match(handoffCopyOutput, /Refreshed context, verification, evidence, handoff, doctor, and Level 2 path references/); assert.ok(fs.existsSync(path.join(handoffCopy, "README.md"))); assert.ok(fs.existsSync(path.join(handoffCopy, "START_HERE.md"))); @@ -2031,6 +2032,7 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assertFileExists(path.join(handoffCopy, "adapter", "types.ts")); assertFileExists(path.join(handoffCopy, "adapter", "audit-events.ts")); assert.ok(fs.existsSync(path.join(handoffCopy, "bot-runtime", "src", "index.ts"))); + assert.ok(copiedHandoffManifestHasPath(handoffCopy, "bot-runtime/src/index.ts")); assert.ok(fs.existsSync(path.join(handoffCopy, "manifest", "service_manifest.json"))); assert.equal(fs.existsSync(path.join(handoffCopy, "bot-runtime", ".env")), false); assert.equal(fs.existsSync(path.join(handoffCopy, "bot-runtime", "audit.log")), false); @@ -2142,12 +2144,15 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target assert.doesNotMatch(copiedDeploymentChecklist, /test_secret/); assert.doesNotMatch(copiedDeploymentChecklist, /cli_debug_token/); const selfHostedHandoffCopy = path.join(temp, "handoff-self-hosted-copy"); - run(["handoff", selfHostedGenerated, "--copy-to", selfHostedHandoffCopy]); + const selfHostedHandoffOutput = run(["handoff", selfHostedGenerated, "--copy-to", selfHostedHandoffCopy, "--check"]); + assert.match(selfHostedHandoffOutput, /Handoff check passed/); assert.ok(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", "app.py"))); assert.ok(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", "handlers.py"))); assert.ok(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", "service_client.py"))); assert.ok(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", "validation.py"))); assert.ok(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", "spec", "start_card.json"))); + assert.ok(copiedHandoffManifestHasPath(selfHostedHandoffCopy, "feishu-host/app.py")); + assert.equal(copiedHandoffManifestHasPath(selfHostedHandoffCopy, "bot-runtime/src/index.ts"), false); assert.equal(fs.existsSync(path.join(selfHostedHandoffCopy, "feishu-host", ".env")), false); const embeddedLongHandoffCopy = path.join(temp, "handoff-embedded-long-copy"); run(["handoff", embeddedLongGenerated, "--copy-to", embeddedLongHandoffCopy]); @@ -2515,6 +2520,15 @@ test("generic HTTP API target can analyze generate and verify", () => { const verifyOutput = run(["verify", generated, "--mode", "embedded-adapter", "--strict"]); assert.match(verifyOutput, /adapter:action:http\.post\.api\.tickets\.submit/); + const genericHandoffCopy = path.join(temp, "handoff-generic-embedded-copy"); + const genericHandoffOutput = run(["handoff", generated, "--copy-to", genericHandoffCopy, "--check"]); + assert.match(genericHandoffOutput, /Sanitized handoff copy written/); + assert.match(genericHandoffOutput, /Handoff check passed/); + assert.ok(copiedHandoffManifestHasPath(genericHandoffCopy, "adapter/handlers.ts")); + assert.equal(copiedHandoffManifestHasPath(genericHandoffCopy, "bot-runtime/src/index.ts"), false); + assert.equal(copiedHandoffManifestHasPath(genericHandoffCopy, "manifest/image_agent_meta.snapshot.json"), false); + assert.equal(fs.existsSync(path.join(genericHandoffCopy, "bot-runtime")), false); + assert.equal(fs.existsSync(path.join(genericHandoffCopy, "manifest", "image_agent_meta.snapshot.json")), false); const doctorJson = JSON.parse(run(["doctor", generated, "--mode", "embedded-adapter", "--json"])); assert.equal(doctorJson.package_validation.status, "pass"); assert.ok(doctorJson.package_validation.checks.some((item) => item.name === "adapter:action:http.post.api.tickets.submit" && item.status === "pass")); @@ -2680,6 +2694,11 @@ function assertFileExists(filePath) { assert.ok(fs.existsSync(filePath), `Expected file to exist: ${filePath}`); } +function copiedHandoffManifestHasPath(packagePath, relativePath) { + const manifest = JSON.parse(fs.readFileSync(path.join(packagePath, "handoff_manifest.json"), "utf8")); + return manifest.recommended_files.some((item) => item.path === relativePath && item.present === true); +} + function genericAdapterContractScript(generated) { const handlersUrl = path.join(generated, "adapter", "handlers.js").replace(/\\/g, "/"); const cardsUrl = path.join(generated, "adapter", "cards.js").replace(/\\/g, "/"); From 6639fe412044d2310f62d199190e8f87e86790c6 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:19:45 +0800 Subject: [PATCH 23/37] docs: record MVP closure and second-target replay status --- .github/workflows/ci.yml | 1 + docs/capability-validation-matrix.md | 2 +- docs/project-status.md | 2 +- docs/second-target-validation-plan.md | 11 +++++++++++ src/commands/generate.ts | 7 ++++--- src/commands/handoff.ts | 2 +- tests/cli-smoke.test.mjs | 8 ++++++++ 7 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c60f88..0906ee2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,4 +15,5 @@ jobs: - run: npm run check - run: npm run test:unit - run: npm run test:smoke + - run: npm run test:e2e - run: npm audit --package-lock-only --audit-level=moderate diff --git a/docs/capability-validation-matrix.md b/docs/capability-validation-matrix.md index eaf73dc..3a53220 100644 --- a/docs/capability-validation-matrix.md +++ b/docs/capability-validation-matrix.md @@ -6,4 +6,4 @@ This matrix is the current human-readable fact source for target profile, delive | --- | --- | --- | --- | --- | --- | | image-agent-web | Mode A | self-hosted / long connection | yes | yes | verified sample baseline | | image-agent-web | Mode B | embedded host module | yes | yes | verified sample baseline | -| calendar-stock-updater | Mode A | embedded/adapter-first | yes | package validation only | query + reviewed action validated via MVP package | +| calendar-stock-updater | Mode A | embedded/adapter-first | yes | replay package + safe target validation | replay copy validated with `GET /api/state` and rejected reviewed `POST /api/run`; real Feishu Level 2 remains out of scope | diff --git a/docs/project-status.md b/docs/project-status.md index 0b3b246..c5b83cb 100644 --- a/docs/project-status.md +++ b/docs/project-status.md @@ -43,7 +43,7 @@ analyze → plan → context(生成给所有者的凭据请求) → generate(生 - **image-agent-web self-hosted-runtime 已有真实飞书长连接 MVP 验证**:2026-07-07 后以 `docs/image-agent-web-mvp-verified-summary.md` 为回归锚点,确认长连接、`card.action.trigger`、Card JSON 2.0、异步 running + patch、generate / iterate / batch / refresh、失败路径已经跑通。webhook/standalone Level 2 仍按各自生成包证据记录独立管理。 - **image-agent-web 样板分类**:The verified image-agent-web sample has completed deployment-test validation in Mode A with a Python self-hosted host module run externally, and it has also completed deployment-test validation in Mode B as a target-project embedded host module. This roadmap treats those validations as the current sample baseline and consolidates them into a reusable MVP integration package. - **self-hosted-runtime 本地 MVP 已作为目标形态落地**:生成物为 `generated/-lark/feishu-host/`,包含 `.env.example`、`requirements.txt`、manifest-derived `spec/*.json`、Python card/client/validation/handler/app 文件、`local_contract_test.py` 和 `app.py --selfcheck`。最终本地完成证据必须在安装 Python 依赖后运行 strict verify,不能把缺依赖 WARN 当成绿灯。 -- **calendar-stock-updater 已进入泛用工作流**:2026-07-08 验证 `C:\works\calendar-stock-updater` 可通过 `generic_http_api` 路径完成 analyze → generate embedded-adapter → `verify --mode embedded-adapter --strict` → `doctor --mode embedded-adapter --json` package validation。识别的非图片动作包括 `http.get.api.state.submit`、`http.get.api.events.submit`、`http.post.api.run.submit`、`http.post.api.stop.submit`。 +- **calendar-stock-updater 已完成 replay 副本级验证**:2026-07-12 在 `C:\works\calendar-stock-updater-c2l-replay` 上完成 fresh analyze → generate embedded-adapter → `verify --mode embedded-adapter --strict` → `doctor --mode embedded-adapter --json` → `readiness` → `handoff --check`;生成包 `generated\calendar-stock-updater-lark` 通过 package validation,并通过生成 adapter 对 replay 目标完成 `GET /api/state` read 验证和一个安全拒绝的 reviewed `POST /api/run` action 验证。该结论不等同于真实飞书 Level 2。 - 唯一一次真实目标服务联调记录:2026-07-01,临时启动 `C:\works\image-agent-web`,验证了 `GET /api/meta`、生成运行时 `/health`、本地卡片 URL 挑战等;`POST /api/generate` 之外的真实调用未覆盖(依赖外部图像/模型服务)。 ## 5. 审计结论:优点 diff --git a/docs/second-target-validation-plan.md b/docs/second-target-validation-plan.md index 8fc1e68..f3f25bb 100644 --- a/docs/second-target-validation-plan.md +++ b/docs/second-target-validation-plan.md @@ -24,6 +24,17 @@ - Automated evidence lives in `tests/cli-smoke.test.mjs` under `calendar-stock-updater Node target can analyze generate and verify`. - Human-readable status is tracked in `docs/capability-validation-matrix.md`. +## Replay Evidence + +- Replay copy: `C:\works\calendar-stock-updater-c2l-replay`. +- Generated package: `generated\calendar-stock-updater-lark`. +- Fresh analyze: `node dist/index.js analyze "C:\works\calendar-stock-updater-c2l-replay" --base-url http://127.0.0.1:3069 --out out\calendar-stock-updater-c2l-replay --name calendar-stock-updater`. +- Fresh generate: `node dist/index.js generate out\calendar-stock-updater-c2l-replay --out generated\calendar-stock-updater-lark --mode embedded-adapter`. +- Package gates: `verify --mode embedded-adapter --strict`, `doctor --mode embedded-adapter --json`, `readiness`, and `handoff --check` passed for the generated package. +- Target read validation: generated adapter action `http.get.api.state.submit` returned `READ_OK=true` against replay `GET /api/state`. +- Reviewed action validation: generated adapter action `http.post.api.run.submit` sent an intentionally invalid `dry-run` product range and returned a failure card (`ACTION_OK=false`, `HTTP action failed`), leaving the replay target not running (`stopRequested=false`). +- This proves replay package + safe target-path validation only; it is not real Feishu Level 2 evidence. + ## Out Of Scope - No new platform targets. diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 06d7daa..270367d 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -51,7 +51,7 @@ export async function generateCommand(args: string[], options: Record { assert.match(workflow, /npm run check/); assert.match(workflow, /npm run test:unit/); assert.match(workflow, /npm run test:smoke/); + assert.match(workflow, /npm run test:e2e/); assert.match(workflow, /npm audit --package-lock-only --audit-level=moderate/); }); @@ -360,6 +361,7 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target const selfHostedGenerated = path.join(temp, "generated-self-hosted"); run(["generate", workspace, "--out", selfHostedGenerated, "--mode", "self-hosted-runtime"]); const selfHostedSummary = JSON.parse(fs.readFileSync(path.join(selfHostedGenerated, "generation_summary.json"), "utf8")); + assert.equal(selfHostedSummary.schema_version, "0.2"); assert.equal(selfHostedSummary.integration_mode, "self-hosted-runtime"); assert.equal(selfHostedSummary.host_receive_mode, "embedded-long-connection"); assert.equal(selfHostedSummary.core_artifact, "feishu-host"); @@ -805,6 +807,7 @@ test("CLI can analyze, plan, generate, and verify an image-agent-web-like target const embeddedLongGenerated = path.join(temp, "generated-embedded-long"); run(["generate", workspace, "--out", embeddedLongGenerated, "--mode", "embedded-adapter", "--host-mode", "embedded-long-connection"]); const embeddedLongSummary = JSON.parse(fs.readFileSync(path.join(embeddedLongGenerated, "generation_summary.json"), "utf8")); + assert.equal(embeddedLongSummary.schema_version, "0.2"); assert.equal(embeddedLongSummary.host_receive_mode, "embedded-long-connection"); const embeddedLongCards = fs.readFileSync(path.join(embeddedLongGenerated, "adapter", "cards.ts"), "utf8"); assert.match(embeddedLongCards, /schema:\s*["']2\.0["']/); @@ -2468,6 +2471,7 @@ test("generic HTTP API target can analyze generate and verify", () => { run(["generate", workspace, "--out", generated, "--mode", "embedded-adapter"]); const summary = JSON.parse(fs.readFileSync(path.join(generated, "generation_summary.json"), "utf8")); + assert.equal(summary.schema_version, "0.2"); assert.equal(summary.target_profile, "generic-http-api"); for (const relativePath of [ "adapter/handlers.ts", @@ -2627,12 +2631,15 @@ test("calendar-stock-updater Node target can analyze generate and verify", () => const generatedAdapter = fs.readFileSync(path.join(generated, "adapter", "handlers.ts"), "utf8"); const generatedAdapterCards = fs.readFileSync(path.join(generated, "adapter", "cards.ts"), "utf8"); const generatedReadme = fs.readFileSync(path.join(generated, "README.md"), "utf8"); + const generatedStartHere = fs.readFileSync(path.join(generated, "START_HERE.md"), "utf8"); assert.match(generatedAdapter, /http\.get\.api\.state\.submit/); assert.match(generatedAdapter, /http\.post\.api\.run\.submit/); assert.doesNotMatch(generatedAdapter, /http\.post\.api\.stop\.submit/); assert.doesNotMatch(generatedAdapter, /image\.generate|image_url|session_id/); assert.doesNotMatch(generatedAdapterCards, /image\.batch\.submit/); assert.doesNotMatch(generatedReadme, /image\.generate/); + assert.match(generatedReadme, /doctor \. --mode embedded-adapter --probe-target --gate/); + assert.match(generatedStartHere, /doctor \. --mode embedded-adapter --probe-target --gate/); const verifyOutput = run(["verify", generated, "--mode", "embedded-adapter", "--strict"]); assert.match(verifyOutput, /adapter:action:http\.get\.api\.state\.submit/); assert.match(verifyOutput, /adapter:action:http\.post\.api\.run\.submit/); @@ -2642,6 +2649,7 @@ test("calendar-stock-updater Node target can analyze generate and verify", () => assert.doesNotMatch(readinessOutput, /image\.generate|image_url|session_id/); const handoffOutput = run(["handoff", generated]); assert.match(handoffOutput, /Handoff manifest written/); + assert.match(run(["handoff", generated, "--check"]), /Handoff check passed/); const handoffManifest = fs.readFileSync(path.join(generated, "handoff_manifest.md"), "utf8"); assert.doesNotMatch(handoffManifest, /image\.generate|image_url|session_id/); const doctorJson = JSON.parse(run(["doctor", generated, "--mode", "embedded-adapter", "--json"])); From 6d043fcd091b0f55cf5a110408aa40ad87991574 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:19:57 +0800 Subject: [PATCH 24/37] docs: add MVP integration execution plans --- .../2026-07-10-mvp-integration-roadmap.md | 301 ++++++++++++++ ...-07-10-phase0-phase3-baseline-and-facts.md | 380 +++++++++++++++++ .../2026-07-10-phase1-delivery-and-safety.md | 380 +++++++++++++++++ ...26-07-10-phase2-host-mode-and-card-json.md | 288 +++++++++++++ ...7-10-phase4-phase5-ci-and-second-target.md | 382 ++++++++++++++++++ 5 files changed, 1731 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-mvp-integration-roadmap.md create mode 100644 docs/superpowers/plans/2026-07-10-phase0-phase3-baseline-and-facts.md create mode 100644 docs/superpowers/plans/2026-07-10-phase1-delivery-and-safety.md create mode 100644 docs/superpowers/plans/2026-07-10-phase2-host-mode-and-card-json.md create mode 100644 docs/superpowers/plans/2026-07-10-phase4-phase5-ci-and-second-target.md diff --git a/docs/superpowers/plans/2026-07-10-mvp-integration-roadmap.md b/docs/superpowers/plans/2026-07-10-mvp-integration-roadmap.md new file mode 100644 index 0000000..5580d4d --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-mvp-integration-roadmap.md @@ -0,0 +1,301 @@ +# Code2Lark MVP 整合包与第二目标验证总路线图 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将已完成部署测试验证的 Mode A / Mode B 经验固化为可信、可交付、可泛用的 MVP 整合包,并用第二目标项目完成接入改造与验证。 + +**Architecture:** 先收口事实源、交付闭包、安全默认值和 host-mode 一致性,再建立 CI 与安全回归,最后把稳定后的 MVP 整合包应用到第二个非图片目标。总路线图只负责分阶段编排与依赖关系;实际实施使用下列 4 份子计划逐个执行和审查。 + +**Tech Stack:** TypeScript/Node CLI, generated Markdown/JSON artifacts, generated Python `feishu-host/`, Node `node:test`, npm scripts, GitHub Actions + +## Global Constraints + +- 不得重新开展或重复记录已经完成的 `image-agent-web` Mode A / Mode B 部署测试。 +- 不得引入 Slack、企业微信、群 @ 命令、私聊命令、第三个目标项目、自动部署或 Secret 管理能力。 +- 不得接管目标服务生命周期。 +- `generated/-lark/` 是标准生成包与 source of truth。 +- Mode A = external host / sidecar / gateway。 +- Mode B = target-project embedded host module。 +- `standalone-runtime` 仅是 reference/fallback host,不是主要产品形态。 +- `self-hosted-runtime` 必须被描述为 generated host module,可外置运行,也可迁入目标项目。 +- 所有模式定义、文档和代码行为必须与 `docs/development-charter.md` 一致。 +- 每个子计划都必须遵循 TDD、频繁提交、每阶段结束工作树干净。 + +--- + +## File Structure Impact + +### Existing files to modify across the full roadmap +- `README.md` +- `docs/development-charter.md` +- `docs/project-status.md` +- `docs/fde-handoff.md` +- `docs/mvp-1a-image-agent-web.md` +- `src/types.ts` +- `src/commands/analyze.ts` +- `src/commands/context.ts` +- `src/commands/doctor.ts` +- `src/commands/generate.ts` +- `src/commands/handoff.ts` +- `src/commands/readiness.ts` +- `src/commands/verify.ts` +- `tests/cli-smoke.test.mjs` +- `tests/runtime-local-e2e.test.mjs` +- `package.json` + +### New files to create across the full roadmap +- `docs/mvp-integration-package-and-second-target-task-book.md` +- `docs/mvp-mode-a-b-baseline.md` +- `docs/second-target-validation-plan.md` +- `docs/second-target-blocker-record.md`(仅在受阻时) +- `docs/capability-validation-matrix.md` +- `.github/workflows/ci.yml` +- 可能新增更细粒度测试文件(按子计划决定) + +### Responsibility boundaries after the roadmap +- `manifest/` → machine-readable truth about target, capability, interaction, and permission contracts +- `adapter/` → target interaction adaptation layer +- `feishu-host/` → host module source for verified Mode A now and Mode B embedding later +- `bot-runtime/` → standalone reference host only +- `docs/*` → product model, handoff, baseline, second-target validation, and user decision guides + +--- + +### Task 1: 固化 Mode A / Mode B 基线与事实源语义 + +**Files:** +- Plan: `docs/superpowers/plans/2026-07-10-phase0-phase3-baseline-and-facts.md` + +**Interfaces:** +- Consumes: `docs/superpowers/specs/2026-07-10-engineering-trust-mode-b-closure-design.md` +- Produces: + - `docs/mvp-mode-a-b-baseline.md` + - `docs/capability-validation-matrix.md` + - schema/version/evidence semantics aligned across generated docs and verify + +- [ ] **Step 1: Execute the dedicated sub-plan** + +Run the plan in: + +```text +docs/superpowers/plans/2026-07-10-phase0-phase3-baseline-and-facts.md +``` + +Expected outcome: +- Mode A / Mode B are recorded as already validated sample experience. +- strict verify rejects outdated/incomplete manifest artifacts. +- canonical MVP package is freshly regenerated and documented as schema `0.2`. + +- [ ] **Step 2: Verify phase-level exit criteria** + +Run: + +```bash +npm test +npm audit --package-lock-only --audit-level=moderate +git diff --check +``` + +Expected: +- PASS +- 0 vulnerabilities +- no whitespace errors + +- [ ] **Step 3: Commit** + +```bash +git add README.md docs/development-charter.md docs/project-status.md docs/mvp-1a-image-agent-web.md docs/fde-handoff.md docs/mvp-mode-a-b-baseline.md docs/capability-validation-matrix.md src/commands/context.ts src/commands/readiness.ts src/commands/doctor.ts src/commands/verify.ts src/types.ts tests/cli-smoke.test.mjs +git commit -m "docs: establish MVP validation baseline and capability matrix" +``` + +--- + +### Task 2: 完成交付闭包与安全止损 + +**Files:** +- Plan: `docs/superpowers/plans/2026-07-10-phase1-delivery-and-safety.md` + +**Interfaces:** +- Consumes: + - current generator metadata and handoff manifest behavior + - generic HTTP capability generation in `src/commands/analyze.ts` + - runtime env/config generation in `src/commands/generate.ts` +- Produces: + - mode-aware handoff closure + - guarded `generate --out` behavior + - generic HTTP risk gating + - secure standalone debug defaults + +- [ ] **Step 1: Execute the dedicated sub-plan** + +Run the plan in: + +```text +docs/superpowers/plans/2026-07-10-phase1-delivery-and-safety.md +``` + +Expected outcome: +- handoff copy includes the correct runtime closure for each mode. +- generate refuses to overwrite non-managed directories without explicit force. +- dangerous generic endpoints are not exposed as immediate actions. +- standalone debug defaults are safe by default. + +- [ ] **Step 2: Verify phase-level exit criteria** + +Run: + +```bash +npm test +npm audit --package-lock-only --audit-level=moderate +git diff --check +``` + +Expected: +- PASS +- 0 vulnerabilities +- no whitespace errors + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/handoff.ts src/commands/generate.ts src/commands/analyze.ts src/commands/context.ts src/commands/readiness.ts tests/cli-smoke.test.mjs tests/runtime-local-e2e.test.mjs +git commit -m "fix: secure generated package handoff and runtime defaults" +``` + +--- + +### Task 3: 完成 host-mode / Card JSON / 验收材料一致性 + +**Files:** +- Plan: `docs/superpowers/plans/2026-07-10-phase2-host-mode-and-card-json.md` + +**Interfaces:** +- Consumes: + - host-mode model from `docs/development-charter.md` + - existing Level 2 template generation in `src/commands/generate.ts` + - verify embedded/self-hosted checks in `src/commands/verify.ts` +- Produces: + - Card JSON 2.0 generation for long-connection/hybrid paths + - host-mode-aware permissions/context/readiness/doctor wording + - self-hosted-specific Level 2 verification record + +- [ ] **Step 1: Execute the dedicated sub-plan** + +Run the plan in: + +```text +docs/superpowers/plans/2026-07-10-phase2-host-mode-and-card-json.md +``` + +Expected outcome: +- long-connection and hybrid cards emit full Card JSON 2.0 structures. +- self-hosted docs/templates no longer inherit webhook-only assumptions. +- permission/context/readiness outputs reflect the selected host mode. + +- [ ] **Step 2: Verify phase-level exit criteria** + +Run: + +```bash +npm test +npm audit --package-lock-only --audit-level=moderate +git diff --check +``` + +Expected: +- PASS +- 0 vulnerabilities +- no whitespace errors + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/generate.ts src/commands/context.ts src/commands/readiness.ts src/commands/doctor.ts src/commands/verify.ts tests/cli-smoke.test.mjs tests/runtime-local-e2e.test.mjs +git commit -m "fix: align host-mode contracts and Card JSON output" +``` + +--- + +### Task 4: 建立测试/CI 并完成第二目标验证 + +**Files:** +- Plan: `docs/superpowers/plans/2026-07-10-phase4-phase5-ci-and-second-target.md` + +**Interfaces:** +- Consumes: + - stabilized generator and docs from Tasks 1–3 + - existing `calendar-stock-updater` smoke coverage in `tests/cli-smoke.test.mjs` + - `package.json` scripts +- Produces: + - split test scripts + - CI workflow + - security regression coverage + - second-target validation plan/evidence + +- [ ] **Step 1: Execute the dedicated sub-plan** + +Run the plan in: + +```text +docs/superpowers/plans/2026-07-10-phase4-phase5-ci-and-second-target.md +``` + +Expected outcome: +- package scripts are split into unit/smoke/e2e/coverage. +- CI exists and validates the repository in a clean environment. +- second target runs through the MVP workflow and yields evidence or a non-sensitive blocker record. + +- [ ] **Step 2: Verify roadmap exit criteria** + +Run: + +```bash +npm test +npm audit --package-lock-only --audit-level=moderate +git diff --check +``` + +If CI files were added, also inspect them with: + +```bash +git diff -- .github/workflows/ci.yml +``` + +Expected: +- PASS +- 0 vulnerabilities +- no whitespace errors +- workflow file present and aligned with package scripts + +- [ ] **Step 3: Commit** + +```bash +git add package.json .github/workflows/ci.yml docs/second-target-validation-plan.md docs/second-target-blocker-record.md docs/capability-validation-matrix.md tests/cli-smoke.test.mjs tests/runtime-local-e2e.test.mjs +git commit -m "test: validate MVP package against second target" +``` + +--- + +## Recommended execution order + +1. `2026-07-10-phase0-phase3-baseline-and-facts.md` +2. `2026-07-10-phase1-delivery-and-safety.md` +3. `2026-07-10-phase2-host-mode-and-card-json.md` +4. `2026-07-10-phase4-phase5-ci-and-second-target.md` + +## Why the plan is split + +The spec spans four reviewable subsystems: + +- baseline/facts/docs semantics +- delivery/safety correctness +- host-mode/card/proof consistency +- CI/testing/second-target validation + +Each subsystem can be approved or rejected independently and has its own test cycle. Splitting preserves reviewer clarity and reduces context load for execution agents. + +## Self-review + +- Spec coverage: all spec phases are represented by one sub-plan or a task wrapper in this roadmap. +- Placeholder scan: no TODO/TBD placeholders remain; every task points to an exact plan file and concrete commands. +- Type consistency: this roadmap does not invent new code interfaces beyond plan file paths and existing command names. diff --git a/docs/superpowers/plans/2026-07-10-phase0-phase3-baseline-and-facts.md b/docs/superpowers/plans/2026-07-10-phase0-phase3-baseline-and-facts.md new file mode 100644 index 0000000..8feecf6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-phase0-phase3-baseline-and-facts.md @@ -0,0 +1,380 @@ +# Phase 0 / Phase 3 Baseline And Facts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把已完成部署测试验证的 Mode A / Mode B 样板经验固化为当前 MVP 回归基线,并让 schema、verify、canonical package 和能力矩阵形成可信事实源。 + +**Architecture:** 先用文档和回归断言锁定“Mode A / Mode B 已验证”的事实语义,再收紧 verify 的 schema 门禁,最后 fresh analyze/generate canonical MVP package 并更新能力矩阵。避免在这一阶段引入新的运行模式或平台能力。 + +**Tech Stack:** TypeScript/Node CLI, generated JSON/Markdown artifacts, Node `node:test` + +## Global Constraints + +- 不得重新开展或重复记录已经完成的 `image-agent-web` Mode A / Mode B 部署测试。 +- `generated/-lark/` 是标准生成包与 source of truth。 +- Mode A = external host / sidecar / gateway。 +- Mode B = target-project embedded host module。 +- `standalone-runtime` 仅是 reference/fallback host,不是主要产品形态。 +- `self-hosted-runtime` 必须被描述为 generated host module,可外置运行,也可迁入目标项目。 +- 所有模式定义、文档和代码行为必须与 `docs/development-charter.md` 一致。 +- 每个任务结束必须运行对应验证并保持工作树干净。 + +--- + +## File Structure Impact + +### Existing files to modify +- `README.md` + - 顶层 MVP 范围、Mode A/B 样板状态、source-of-truth 说明。 +- `docs/development-charter.md` + - 最高层产品模型与 Mode A/B 事实基线。 +- `docs/project-status.md` + - 当前状态快照与能力表述。 +- `docs/mvp-1a-image-agent-web.md` + - 样板验证说明,明确这是已验证样板经验。 +- `docs/fde-handoff.md` + - 交接口径与事实源表述。 +- `src/types.ts` + - manifest 类型与 schema version 唯一来源。 +- `src/commands/verify.ts` + - strict verify schema/version/required-field gate。 +- `src/commands/context.ts` + - context 模板中的模式与证据表述。 +- `src/commands/readiness.ts` + - readiness 文案与完成定义。 +- `src/commands/doctor.ts` + - doctor 文案与 gate 语义。 +- `tests/cli-smoke.test.mjs` + - 顶层文档、模式、manifest、canonical package 回归断言。 + +### New files to create +- `docs/mvp-mode-a-b-baseline.md` + - 已验证样板的明确基线文档。 +- `docs/capability-validation-matrix.md` + - 当前事实源矩阵。 + +### Responsibility boundaries after this phase +- `docs/mvp-mode-a-b-baseline.md` → 记录 image-agent-web 已完成部署测试的 Mode A/Mode B 基线。 +- `docs/capability-validation-matrix.md` → 汇总 profile/mode/host/本地验证/部署测试/证据位置。 +- `src/types.ts` + `src/commands/verify.ts` → manifest schema 权威定义与 strict gate。 +- README / charter / status / handoff docs → 面向用户的统一事实口径。 + +--- + +### Task 1: 锁定 Mode A / Mode B 已验证基线语义 + +**Files:** +- Modify: `README.md` +- Modify: `docs/development-charter.md` +- Modify: `docs/project-status.md` +- Modify: `docs/mvp-1a-image-agent-web.md` +- Modify: `docs/fde-handoff.md` +- Test: `tests/cli-smoke.test.mjs` + +**Interfaces:** +- Consumes: 现有文档中的 Mode A / Mode B 段落与 `tests/cli-smoke.test.mjs` 顶层文档断言。 +- Produces: + - “Mode A / Mode B 已完成部署测试验证”的统一文案 + - “不要把 Mode B 写成待真实验收”的回归断言 + +- [ ] **Step 1: Write the failing test** + +在 `tests/cli-smoke.test.mjs` 的 `test("top-level docs define Code2Lark delivery modes", ...)` 中加入或替换断言,要求文档表达下面这些事实: + +```js +assert.match(readme, /Mode A.*已验证|validated/i); +assert.match(readme, /Mode B.*已验证|validated/i); +assert.doesNotMatch(readme, /Mode B.*pending real/i); +assert.doesNotMatch(readme, /Mode B.*待真实验收/); + +assert.match(charter, /Mode A.*external host/i); +assert.match(charter, /Mode B.*embedded host module/i); +assert.doesNotMatch(charter, /Mode B.*not considered productized until/i); + +assert.match(status, /Mode A/i); +assert.match(status, /Mode B/i); +assert.match(mvp, /verified sample/i); +assert.doesNotMatch(mvp, /Real Feishu verification is still pending/); +assert.match(fdeHandoff, /source-of-truth handoff package/); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: FAIL on wording assertions that still describe Mode B as pending or incomplete. + +- [ ] **Step 3: Write minimal implementation** + +在以下文件中统一使用明确措辞: + +```md +Mode A is the external host, sidecar, or gateway path, and the verified image-agent-web sample has completed deployment-test validation in this mode. + +Mode B is the target-project embedded host-module path, and the verified image-agent-web sample has also completed deployment-test validation in this mode. + +The current roadmap does not re-prove these sample validations. It consolidates them into a reusable MVP integration package. +``` + +同时删除或改写仍将 Mode B 描述为“待真实验收”“仅本地重放证明”“直到未来某个 replay 才算完成”的旧句式。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: PASS for the top-level docs test. + +- [ ] **Step 5: Commit** + +```bash +git add README.md docs/development-charter.md docs/project-status.md docs/mvp-1a-image-agent-web.md docs/fde-handoff.md tests/cli-smoke.test.mjs +git commit -m "docs: establish validated Mode A and Mode B baseline" +``` + +--- + +### Task 2: 新建 MVP baseline 和 capability matrix 文档 + +**Files:** +- Create: `docs/mvp-mode-a-b-baseline.md` +- Create: `docs/capability-validation-matrix.md` +- Test: `tests/cli-smoke.test.mjs` + +**Interfaces:** +- Consumes: Task 1 的统一文案。 +- Produces: + - `docs/mvp-mode-a-b-baseline.md` + - `docs/capability-validation-matrix.md` + +- [ ] **Step 1: Write the failing test** + +在 `tests/cli-smoke.test.mjs` 顶层文档测试中新增文件存在与关键内容断言: + +```js +const baseline = fs.readFileSync(path.join(root, "docs", "mvp-mode-a-b-baseline.md"), "utf8"); +const matrix = fs.readFileSync(path.join(root, "docs", "capability-validation-matrix.md"), "utf8"); + +assert.match(baseline, /Mode A/); +assert.match(baseline, /Mode B/); +assert.match(baseline, /deployment-test validation/i); +assert.match(matrix, /image-agent-web/); +assert.match(matrix, /calendar-stock-updater/); +assert.match(matrix, /Mode A/); +assert.match(matrix, /Mode B/); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: FAIL because the files do not exist yet. + +- [ ] **Step 3: Write minimal implementation** + +创建 `docs/mvp-mode-a-b-baseline.md`,至少包含: + +```md +# Code2Lark MVP Mode A / Mode B Baseline + +- `image-agent-web` has completed deployment-test validation in Mode A. +- `image-agent-web` has completed deployment-test validation in Mode B. +- These validations are treated as the current MVP sample baseline. +- This roadmap consolidates the validated experience into a reusable MVP integration package. +``` + +创建 `docs/capability-validation-matrix.md`,至少包含一个 Markdown 表: + +```md +| Target | Delivery mode | Host mode | Local validation | Deployment-test validation | Notes | +| --- | --- | --- | --- | --- | --- | +| image-agent-web | Mode A | self-hosted / long connection | yes | yes | verified sample baseline | +| image-agent-web | Mode B | embedded host module | yes | yes | verified sample baseline | +| calendar-stock-updater | generic target candidate | TBD | package validation only | no | second-target validation pending | +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: PASS for the new file assertions. + +- [ ] **Step 5: Commit** + +```bash +git add docs/mvp-mode-a-b-baseline.md docs/capability-validation-matrix.md tests/cli-smoke.test.mjs +git commit -m "docs: add MVP baseline and validation matrix" +``` + +--- + +### Task 3: 让 strict verify 严格拒绝旧 schema 和不完整 manifest + +**Files:** +- Modify: `src/commands/verify.ts` +- Modify: `src/types.ts` +- Test: `tests/cli-smoke.test.mjs` + +**Interfaces:** +- Consumes: + - `ManifestSchemaVersion = "0.2"` in `src/types.ts` + - existing verify parsing in `src/commands/verify.ts` +- Produces: + - strict-mode manifest integrity check that fails on `0.1` or missing `target_profile` + +- [ ] **Step 1: Write the failing test** + +在 `tests/cli-smoke.test.mjs` 中新增一个 focused strict-verify fixture: + +```js +test("strict verify rejects outdated manifest schemas", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-old-schema-")); + fs.mkdirSync(path.join(temp, "manifest"), { recursive: true }); + fs.writeFileSync(path.join(temp, "manifest", "service_manifest.json"), JSON.stringify({ schema_version: "0.1" })); + fs.writeFileSync(path.join(temp, "manifest", "capability_map.json"), JSON.stringify({ schema_version: "0.1", service_name: "x", capabilities: [] })); + fs.writeFileSync(path.join(temp, "manifest", "interaction_contract.json"), JSON.stringify({ schema_version: "0.1", channel: "lark", service_name: "x", supported_triggers: [], supported_result_modes: [], interactions: [] })); + fs.writeFileSync(path.join(temp, "manifest", "required_permissions.json"), JSON.stringify({ schema_version: "0.1", app: { type: "custom_app", bot_required: true, availability_recommendation: "" }, context_requirements: [], token_strategy: { default: "tenant_access_token", user_access_token_required: false }, scopes: [], events: [], callbacks: [], manual_steps: [], review_flags: [] })); + + assert.throws(() => run(["verify", temp, "--strict"]), /schema|0\.2|target_profile/i); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: FAIL because strict verify currently does not reject the old schema fixture. + +- [ ] **Step 3: Write minimal implementation** + +在 `src/commands/verify.ts` 增加 manifest strict checks,至少验证: + +```ts +function assertManifestSchemaVersion(name: string, value: { schema_version?: string }): void { + if (value.schema_version !== "0.2") { + throw new Error(`${name} must use schema_version 0.2.`); + } +} + +function assertCapabilityMapTargetProfile(value: { target_profile?: unknown }): void { + if (typeof value.target_profile !== "string" || !value.target_profile) { + throw new Error("capability_map.json must include target_profile in strict mode."); + } +} +``` + +并在 strict verify 路径上对 4 个 manifest 统一调用这些检查。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: PASS for the outdated-manifest rejection test. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/verify.ts src/types.ts tests/cli-smoke.test.mjs +git commit -m "fix: reject outdated manifests in strict verification" +``` + +--- + +### Task 4: fresh 重建 canonical MVP package 并把 schema 0.2 固化为回归断言 + +**Files:** +- Modify: `tests/cli-smoke.test.mjs` +- Modify: `README.md` +- Modify: `docs/project-status.md` +- Optionally Modify: generated-package-facing summary wording if touched by tests through generator commands + +**Interfaces:** +- Consumes: Tasks 1–3 的文案与 strict gate。 +- Produces: + - fresh generated package assertions using current analyze/generate path + - docs that describe canonical MVP package as current schema `0.2` + +- [ ] **Step 1: Write the failing test** + +在现有 image-agent-web smoke test 中补充/强化断言: + +```js +const serviceManifest = JSON.parse(fs.readFileSync(path.join(workspace, "manifest", "service_manifest.json"), "utf8")); +assert.equal(serviceManifest.schema_version, "0.2"); + +const capabilityMap = JSON.parse(fs.readFileSync(path.join(workspace, "manifest", "capability_map.json"), "utf8")); +assert.equal(capabilityMap.schema_version, "0.2"); +assert.ok(capabilityMap.target_profile); +``` + +并在生成 package 后读取相应 manifest 再断言一次。 + +- [ ] **Step 2: Run test to verify it fails if generated facts drift** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: If current generator or docs still drift, FAIL on missing generated assertions or schema wording. + +- [ ] **Step 3: Write minimal implementation** + +只在必要处更新文档与生成摘要相关文案,确保: + +```md +The canonical MVP package is freshly generated from current schema 0.2 manifests. +``` + +并保证 smoke test 的 fresh analyze → generate 路径输出满足断言。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +npm test +``` + +Expected: +- all smoke and runtime tests PASS +- fresh generated manifests report schema `0.2` + +- [ ] **Step 5: Commit** + +```bash +git add README.md docs/project-status.md tests/cli-smoke.test.mjs +git commit -m "docs: align canonical MVP package with schema 0.2" +``` + +--- + +## Self-review + +- Spec coverage: covers Phase 0 baseline semantics and Phase 3 fact-source/schema tightening from the approved spec. +- Placeholder scan: no TBD/TODO placeholders remain; all steps include exact files, test code, commands, and commit messages. +- Type consistency: `schema_version` remains `"0.2"`; strict checks reference existing manifest file names and `target_profile` from `CapabilityMap`. diff --git a/docs/superpowers/plans/2026-07-10-phase1-delivery-and-safety.md b/docs/superpowers/plans/2026-07-10-phase1-delivery-and-safety.md new file mode 100644 index 0000000..c2f914d --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-phase1-delivery-and-safety.md @@ -0,0 +1,380 @@ +# Phase 1 Delivery And Safety Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修复 MVP 整合包在交付闭包、生成目录保护、generic HTTP 风险暴露和 standalone debug 默认值上的高优先级安全与正确性问题。 + +**Architecture:** 先让 handoff 和 generate 的文件系统行为变得安全、可预测,再把 generic HTTP capability 暴露改成 deny-by-default,最后收紧 standalone runtime 默认网络/调试边界。所有行为变更都通过现有 smoke/runtime E2E 测试加回归断言,不扩展新产品能力。 + +**Tech Stack:** TypeScript/Node CLI, generated TS/JS runtime templates, Node `node:test` + +## Global Constraints + +- 不得重新开展或重复记录已经完成的 `image-agent-web` Mode A / Mode B 部署测试。 +- 不得引入新的 host capability、trigger type 或 platform target。 +- `generated/-lark/` 是标准生成包与 source of truth。 +- Mode A = external host / sidecar / gateway。 +- Mode B = target-project embedded host module。 +- `standalone-runtime` 仅是 reference/fallback host,不是主要产品形态。 +- 所有模式定义、文档和代码行为必须与 `docs/development-charter.md` 一致。 +- 每个行为修复独立 commit;重构与行为修复分离。 + +--- + +## File Structure Impact + +### Existing files to modify +- `src/commands/handoff.ts` + - recommended file list, copy closure, copied command wording. +- `src/commands/generate.ts` + - output-directory behavior, runtime env defaults, generated runtime config defaults. +- `src/commands/analyze.ts` + - generic HTTP endpoint classification and capability risk selection. +- `src/commands/context.ts` + - reflected runtime defaults and command wording when defaults change. +- `src/commands/readiness.ts` + - security warnings / default-value interpretation if needed. +- `tests/cli-smoke.test.mjs` + - handoff closure, output guard, generic endpoint classification assertions. +- `tests/runtime-local-e2e.test.mjs` + - runtime default health/debug behavior assertions. + +### Responsibility boundaries after this phase +- `handoff.ts` → decides what a safe transferable package contains. +- `generate.ts` → decides how dangerous filesystem updates and runtime defaults behave. +- `analyze.ts` → classifies generic HTTP capabilities and whether they are directly runnable. +- tests → enforce the new safe defaults and transfer closure. + +--- + +### Task 1: 让 handoff 复制模式感知的完整运行闭包 + +**Files:** +- Modify: `src/commands/handoff.ts` +- Test: `tests/cli-smoke.test.mjs` + +**Interfaces:** +- Consumes: + - `generation_summary.json` already copied in optional evidence files + - current `RECOMMENDED_FILES`, `OPTIONAL_EVIDENCE_FILES`, `EXCLUDED_PATHS` +- Produces: + - mode-aware copy set that includes required runtime closure + +- [ ] **Step 1: Write the failing test** + +在 `tests/cli-smoke.test.mjs` 的 handoff copy 覆盖中增加断言,至少覆盖 standalone 和 self-hosted: + +```js +assert.ok(fs.existsSync(path.join(copyDir, "adapter", "handlers.ts"))); +assert.ok(fs.existsSync(path.join(copyDir, "adapter", "cards.ts"))); +assert.ok(fs.existsSync(path.join(copyDir, "manifest", "service_manifest.json"))); +assert.ok(fs.existsSync(path.join(copyDir, "bot-runtime", "src", "index.ts"))); +assert.ok(fs.existsSync(path.join(selfHostedCopyDir, "feishu-host", "app.py"))); +assert.ok(fs.existsSync(path.join(selfHostedCopyDir, "feishu-host", "handlers.py"))); +``` + +并对 embedded-long-connection package 断言 sidecar 目录存在(如果当前 generator 会产出该目录)。 + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: FAIL because current handoff copy omits adapter and self-hosted closure. + +- [ ] **Step 3: Write minimal implementation** + +在 `src/commands/handoff.ts` 中引入基于 package 内容/summary 的推荐文件集合,例如: + +```ts +function requiredHandoffPaths(packagePath: string): string[] { + const paths = [ + ".gitignore", + "START_HERE.md", + "README.md", + "deployment_checklist.md", + "permission_review.md", + "manifest/service_manifest.json", + "manifest/capability_map.json", + "manifest/interaction_contract.json", + "manifest/required_permissions.json", + "adapter/handlers.ts", + "adapter/cards.ts", + "adapter/service-client.ts", + "adapter/validation.ts", + "adapter/types.ts", + "adapter/audit-events.ts", + ]; + if (fs.existsSync(path.join(packagePath, "bot-runtime"))) paths.push("bot-runtime"); + if (fs.existsSync(path.join(packagePath, "feishu-host"))) paths.push("feishu-host"); + if (fs.existsSync(path.join(packagePath, "sidecar-long-connection"))) paths.push("sidecar-long-connection"); + return paths; +} +``` + +并让 copy 逻辑支持复制目录而非只复制部分列举文件。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: PASS for handoff copy closure assertions. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/handoff.ts tests/cli-smoke.test.mjs +git commit -m "fix: copy complete generated artifacts during handoff" +``` + +--- + +### Task 2: 保护 `generate --out` 不误删已有非管理目录 + +**Files:** +- Modify: `src/commands/generate.ts` +- Test: `tests/cli-smoke.test.mjs` + +**Interfaces:** +- Consumes: current `generateCommand()` directory creation and cleanup behavior. +- Produces: + - default refusal for non-empty unmanaged output directories + - explicit `--force` path for managed cleanup only + +- [ ] **Step 1: Write the failing test** + +在 `tests/cli-smoke.test.mjs` 新增 fixture: + +```js +test("generate refuses to overwrite non-managed non-empty output directories", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-generate-guard-")); + const target = path.join(temp, "image-agent-web"); + const workspace = path.join(temp, "out"); + const existing = path.join(temp, "existing-output"); + fs.mkdirSync(existing, { recursive: true }); + fs.writeFileSync(path.join(existing, "README.md"), "user-owned content", "utf8"); + writeImageAgentLikeTarget(target); + run(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace]); + assert.throws(() => run(["generate", workspace, "--out", existing]), /non-empty|force|managed/i); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: FAIL because generate currently reuses and cleans directories too freely. + +- [ ] **Step 3: Write minimal implementation** + +在 `src/commands/generate.ts` 中添加: + +```ts +function assertSafeOutputDirectory(outDir: string, force: boolean): void { + if (!fs.existsSync(outDir)) return; + const entries = fs.readdirSync(outDir); + if (!entries.length) return; + const managedMarker = path.join(outDir, "generation_summary.json"); + if (!fs.existsSync(managedMarker)) { + throw new Error("Refusing to write into a non-empty directory that is not a managed generated package. Use a new --out path."); + } + if (!force) { + throw new Error("Refusing to update an existing generated package without --force."); + } +} +``` + +并只允许删除/替换已知生成目录下的受管理子路径。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: PASS; unmanaged non-empty output directory is rejected. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/generate.ts tests/cli-smoke.test.mjs +git commit -m "fix: protect existing output directories during generation" +``` + +--- + +### Task 3: 将 generic HTTP 默认暴露改为 deny-by-default + +**Files:** +- Modify: `src/commands/analyze.ts` +- Modify: `src/types.ts` (only if a small new metadata field is truly needed) +- Test: `tests/cli-smoke.test.mjs` + +**Interfaces:** +- Consumes: + - `analyzeGenericHttpApi()` capability and endpoint coverage generation + - existing `risk` enum in `Capability` +- Produces: + - safe generic endpoint classification + - no direct support claim for destructive actions + +- [ ] **Step 1: Write the failing test** + +在 generic HTTP target smoke 中,对 `/api/stop` 的当前断言改成安全期望: + +```js +assert.ok(serviceManifest.source_scan.endpoint_coverage.some((item) => ( + item.method === "POST" && item.path === "/api/stop" && item.status !== "supported" +))); + +assert.ok(capabilityMap.capabilities.some((capability) => ( + capability.id.includes("stop") && capability.risk === "destructive" +))); +``` + +如果决定直接不生成 capability,则改成: + +```js +assert.ok(!capabilityMap.capabilities.some((capability) => capability.id.includes("stop"))); +``` + +但全计划建议保留 capability、禁用直接执行、并标注 destructive。 + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: FAIL because current generic analyzer marks `/api/stop` as supported write action. + +- [ ] **Step 3: Write minimal implementation** + +在 `src/commands/analyze.ts` 增加辅助函数: + +```ts +function inferGenericEndpointRisk(endpoint: { method: string; path: string }): "read_only" | "write" | "destructive" { + if (endpoint.method === "GET") return "read_only"; + if (endpoint.method === "DELETE") return "destructive"; + return /(stop|delete|reset|shutdown|drop|destroy)/i.test(endpoint.path) ? "destructive" : "write"; +} + +function genericEndpointStatus(endpoint: { method: string; path: string }): "supported" | "discovered_not_generated" { + return inferGenericEndpointRisk(endpoint) === "destructive" ? "discovered_not_generated" : "supported"; +} +``` + +并在 `endpoint_coverage` 和 `capabilities` 生成中使用该逻辑。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: PASS; `/api/stop` is no longer presented as a directly supported action. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/analyze.ts tests/cli-smoke.test.mjs +git commit -m "fix: require review for risky generic HTTP actions" +``` + +--- + +### Task 4: 收紧 standalone runtime debug 默认值 + +**Files:** +- Modify: `src/commands/generate.ts` +- Modify: `src/commands/context.ts` +- Possibly Modify: `src/commands/readiness.ts` +- Test: `tests/runtime-local-e2e.test.mjs` + +**Interfaces:** +- Consumes: + - `runtimeEnvExample()` and `runtimeConfigTs()` in `src/commands/generate.ts` + - health/debug assertions in `tests/runtime-local-e2e.test.mjs` +- Produces: + - safe default host bind and debug gating + +- [ ] **Step 1: Write the failing test** + +在 `tests/runtime-local-e2e.test.mjs` 中将默认期望改为安全默认值: + +```js +assert.equal(health.debugEnabled, false); +assert.equal(health.debugProtected, true); +``` + +并对 placeholder runtime / default runtime 的 env 断言收紧,例如要求默认 `HOST=127.0.0.1`、`ALLOW_DEBUG_WITHOUT_FEISHU=0`。 + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tests/runtime-local-e2e.test.mjs +``` + +Expected: FAIL because current defaults are permissive. + +- [ ] **Step 3: Write minimal implementation** + +在 `src/commands/generate.ts` 修改生成默认值: + +```ts +HOST=127.0.0.1 +ALLOW_DEBUG_WITHOUT_FEISHU=0 +``` + +并在 `runtimeConfigTs()` 中默认: + +```ts +host: envValue("HOST", "127.0.0.1"), +allowDebugWithoutFeishu: parseFlag(envValue("ALLOW_DEBUG_WITHOUT_FEISHU", "0"), "ALLOW_DEBUG_WITHOUT_FEISHU"), +``` + +如果 runtime 当前把 debug 路由默认视为 enabled,则让其只在显式允许时启用,或至少在未配置时对外返回 403/disabled 状态。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tests/runtime-local-e2e.test.mjs +``` + +Expected: PASS; runtime defaults are no longer permissive. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/generate.ts src/commands/context.ts src/commands/readiness.ts tests/runtime-local-e2e.test.mjs +git commit -m "fix: secure standalone runtime debug defaults" +``` + +--- + +## Self-review + +- Spec coverage: covers Phase 1 delivery closure and safety requirements from the approved spec. +- Placeholder scan: every task includes exact files, code snippets, commands, and commit messages. +- Type consistency: uses existing `risk` enum values (`read_only`, `write`, `destructive`) and current command names. diff --git a/docs/superpowers/plans/2026-07-10-phase2-host-mode-and-card-json.md b/docs/superpowers/plans/2026-07-10-phase2-host-mode-and-card-json.md new file mode 100644 index 0000000..431b1cd --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-phase2-host-mode-and-card-json.md @@ -0,0 +1,288 @@ +# Phase 2 Host-Mode And Card JSON Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 host mode、Card JSON 2.0、permissions/context/readiness/doctor 文案和 self-hosted Level 2 材料使用统一的模式语义,不再把 webhook 假设错误套用到 long-connection 或 self-hosted 场景。 + +**Architecture:** 先用测试锁定 long-connection/hybrid 卡片结构和 self-hosted 文案预期,再最小化调整 generator 模板和上下文生成逻辑。以 host mode 为事实源,避免同一 renderer 或同一模板隐式承担互斥语义。 + +**Tech Stack:** TypeScript/Node CLI, generated TS/JS/Markdown/JSON artifacts, Node `node:test` + +## Global Constraints + +- 不得引入新的 host capability、trigger type 或 platform target。 +- 不得改变已验证的 image-agent-web Mode A / Mode B 部署测试事实。 +- `generated/-lark/` 是标准生成包与 source of truth。 +- Mode A = external host / sidecar / gateway。 +- Mode B = target-project embedded host module。 +- `standalone-runtime` 仅是 reference/fallback host,不是主要产品形态。 +- `self-hosted-runtime` 必须被描述为 generated host module,可外置运行,也可迁入目标项目。 +- 所有模式定义、文档和代码行为必须与 `docs/development-charter.md` 一致。 + +--- + +## File Structure Impact + +### Existing files to modify +- `src/commands/generate.ts` + - generic card emitters, Level 2 template generation, runtime docs text. +- `src/commands/context.ts` + - host-mode-aware context fields, runtime choices, callback wording. +- `src/commands/readiness.ts` + - host-mode-aware readiness interpretation. +- `src/commands/doctor.ts` + - host-mode wording in reports/gates if present. +- `src/commands/verify.ts` + - embedded/self-hosted package validation wording and/or static checks. +- `tests/cli-smoke.test.mjs` + - generated docs/template assertions. +- `tests/runtime-local-e2e.test.mjs` + - generated adapter/runtime card structure assertions if required. + +### Responsibility boundaries after this phase +- `generate.ts` → emits host-mode-specific cards, templates, and generated docs. +- `context.ts` / `readiness.ts` / `doctor.ts` → narrate the selected mode consistently. +- tests → prove the generated artifacts express the same model across modes. + +--- + +### Task 1: 为 long-connection / hybrid 路径生成完整 Card JSON 2.0 + +**Files:** +- Modify: `src/commands/generate.ts` +- Test: `tests/cli-smoke.test.mjs` +- Test: `tests/runtime-local-e2e.test.mjs` + +**Interfaces:** +- Consumes: + - current card builder generation in `genericAdapterCardsTs/Js` and image-specific card emitters + - current long-connection host-mode generation path +- Produces: + - card payloads with explicit Card JSON 2.0 shape for long-connection/hybrid + +- [ ] **Step 1: Write the failing test** + +在 `tests/cli-smoke.test.mjs` 生成 `--mode embedded-adapter --host-mode embedded-long-connection` package 后,读取生成的 `adapter/cards.ts` 并断言包含 Card JSON 2.0 关键结构,例如: + +```js +const longCards = fs.readFileSync(path.join(generatedLong, "adapter", "cards.ts"), "utf8"); +assert.match(longCards, /schema:\s*["']2\.0["']/); +assert.match(longCards, /body:\s*\{/); +assert.match(longCards, /elements:/); +assert.match(longCards, /behaviors:/); +assert.match(longCards, /value:\s*\{\s*action:/); +``` + +如果 generic path 也走 long-connection,再补对应断言。 + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: FAIL because current long-connection card output still reflects 1.0-era shape or mixed structures. + +- [ ] **Step 3: Write minimal implementation** + +在 `src/commands/generate.ts` 中为 long-connection/hybrid 分出单独 renderer,例如: + +```ts +function buildCardShell(hostReceiveMode: HostReceiveMode, title: string, elements: Record[]) { + if (hostReceiveMode === "embedded-long-connection" || hostReceiveMode === "hybrid") { + return { + schema: "2.0", + config: { wide_screen_mode: true }, + header: { template: "blue", title: { tag: "plain_text", content: title } }, + body: { elements }, + }; + } + return { + config: { wide_screen_mode: true }, + header: { template: "blue", title: { tag: "plain_text", content: title } }, + elements, + }; +} +``` + +并确保 callback button 使用 `behaviors[].value.action`。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +node --test tests/runtime-local-e2e.test.mjs +``` + +Expected: PASS; long-connection/hybrid generated cards now expose Card JSON 2.0 structure. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/generate.ts tests/cli-smoke.test.mjs tests/runtime-local-e2e.test.mjs +git commit -m "fix: emit Card JSON 2.0 for long-connection integrations" +``` + +--- + +### Task 2: 让 permissions/context/readiness/doctor 真正 host-mode-aware + +**Files:** +- Modify: `src/commands/context.ts` +- Modify: `src/commands/readiness.ts` +- Modify: `src/commands/doctor.ts` +- Modify: `src/commands/generate.ts` +- Test: `tests/cli-smoke.test.mjs` + +**Interfaces:** +- Consumes: + - `buildContextTemplate()` in `src/commands/context.ts` + - generated permission/context wording + - host mode fields in generated package metadata +- Produces: + - host-mode-aware callback/security/verification wording + +- [ ] **Step 1: Write the failing test** + +在 `tests/cli-smoke.test.mjs` 中针对 self-hosted 和 long-connection package 的生成文档增加断言: + +```js +const selfHostedReadme = fs.readFileSync(path.join(generatedSelfHosted, "README.md"), "utf8"); +assert.doesNotMatch(selfHostedReadme, /PUBLIC_CALLBACK_BASE_URL/); +assert.doesNotMatch(selfHostedReadme, /VERIFICATION_TOKEN/); +assert.match(selfHostedReadme, /card\.action\.trigger/); +assert.match(selfHostedReadme, /websocket/i); + +const longContext = fs.readFileSync(path.join(generatedLong, "feishu_context.template.md"), "utf8"); +assert.doesNotMatch(longContext, /Public callback URL: \/webhook\/card/); +assert.match(longContext, /card\.action\.trigger/); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: FAIL because current docs/context still inherit webhook assumptions too broadly. + +- [ ] **Step 3: Write minimal implementation** + +在 `src/commands/context.ts` 与相关生成路径中,按 host mode 分流: + +```ts +const usesWebhook = hostReceiveMode !== "embedded-long-connection" && hostReceiveMode !== "self-hosted-runtime"; +const usesLongConnection = hostReceiveMode === "embedded-long-connection" || hostReceiveMode === "hybrid" || integrationMode === "self-hosted-runtime"; +``` + +并确保: + +- self-hosted 不再默认列出 `PUBLIC_CALLBACK_BASE_URL` / `VERIFICATION_TOKEN` +- embedded-long-connection 不再把 `/webhook/card` 写为强制前提 +- hybrid 同时列出 webhook 和 long-connection 要求 + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: PASS; generated docs/context now follow the selected host mode. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/context.ts src/commands/readiness.ts src/commands/doctor.ts src/commands/generate.ts tests/cli-smoke.test.mjs +git commit -m "fix: make generated permissions and context host-mode aware" +``` + +--- + +### Task 3: 生成 self-hosted 专用 Level 2 模板 + +**Files:** +- Modify: `src/commands/generate.ts` +- Test: `tests/cli-smoke.test.mjs` + +**Interfaces:** +- Consumes: + - `buildLevel2VerificationRecord(...)` in `src/commands/generate.ts` + - self-hosted generation path already producing `feishu-host/` +- Produces: + - self-hosted-specific `level2_verification_record.md` + +- [ ] **Step 1: Write the failing test** + +在 `tests/cli-smoke.test.mjs` 的 self-hosted generation 覆盖中新增断言: + +```js +const selfHostedLevel2 = fs.readFileSync(path.join(generatedSelfHosted, "level2_verification_record.md"), "utf8"); +assert.match(selfHostedLevel2, /feishu-host\/\.env/); +assert.match(selfHostedLevel2, /FEISHU_CONNECTION_MODE=websocket/); +assert.match(selfHostedLevel2, /card\.action\.trigger/); +assert.doesNotMatch(selfHostedLevel2, /bot-runtime\/\.env/); +assert.doesNotMatch(selfHostedLevel2, /PUBLIC_CALLBACK_BASE_URL/); +assert.doesNotMatch(selfHostedLevel2, /VERIFICATION_TOKEN/); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: FAIL because self-hosted still falls into the wrong template branch. + +- [ ] **Step 3: Write minimal implementation** + +在 `buildLevel2VerificationRecord(...)` 中给 `integrationMode === "self-hosted-runtime"` 增加独立分支,返回类似: + +```ts +if (integrationMode === "self-hosted-runtime") { + return `# Level 2 Verification Record + +- Runtime path: feishu-host/ +- Required local env file: feishu-host/.env +- FEISHU_CONNECTION_MODE=websocket +- Event subscription: card.action.trigger +- Target base URL: ${service.service.base_url || ""} +`; +} +``` + +并补充 self-hosted 所需 preflight / interaction / failure-path evidence checklist。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: PASS; self-hosted Level 2 file no longer references webhook-only prerequisites. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/generate.ts tests/cli-smoke.test.mjs +git commit -m "fix: generate self-hosted Level 2 verification guidance" +``` + +--- + +## Self-review + +- Spec coverage: covers Phase 2 card/protocol/host-mode/template consistency from the approved spec. +- Placeholder scan: each task includes exact files, assertions, commands, and commit messages. +- Type consistency: uses existing `HostReceiveMode`, `integrationMode`, and generated artifact file names already present in the codebase. diff --git a/docs/superpowers/plans/2026-07-10-phase4-phase5-ci-and-second-target.md b/docs/superpowers/plans/2026-07-10-phase4-phase5-ci-and-second-target.md new file mode 100644 index 0000000..3c35518 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-phase4-phase5-ci-and-second-target.md @@ -0,0 +1,382 @@ +# Phase 4 / Phase 5 CI And Second Target Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 建立分层测试和 CI 门禁,补齐关键安全回归,并让 MVP 整合包在第二个非图片目标项目上完成接入改造与验证。 + +**Architecture:** 先拆测试脚本和 CI,保证后续第二目标验证有稳定门禁;再为本轮已识别的安全/交付风险补回归测试;最后以 `calendar-stock-updater` 为默认第二目标执行 analyze → generate → verify → integration validation,并沉淀证据或 blocker。整个阶段避免引入第三目标或新平台能力。 + +**Tech Stack:** TypeScript/Node CLI, GitHub Actions workflow YAML, Node `node:test`, npm scripts, generated package docs + +## Global Constraints + +- 不得重新开展或重复记录已经完成的 `image-agent-web` Mode A / Mode B 部署测试。 +- 不得引入 Slack、企业微信、群 @ 命令、私聊命令、第三个目标项目、自动部署或 Secret 管理能力。 +- `generated/-lark/` 是标准生成包与 source of truth。 +- Mode A = external host / sidecar / gateway。 +- Mode B = target-project embedded host module。 +- `standalone-runtime` 仅是 reference/fallback host,不是主要产品形态。 +- 所有模式定义、文档和代码行为必须与 `docs/development-charter.md` 一致。 +- 第二目标默认优先 Mode A;只有目标明确要求内部模块时才选择 Mode B。 + +--- + +## File Structure Impact + +### Existing files to modify +- `package.json` + - split test scripts, optional coverage script, engine/packageManager metadata. +- `tests/cli-smoke.test.mjs` + - second-target assertions, security regressions. +- `tests/runtime-local-e2e.test.mjs` + - runtime-boundary and security regressions. +- `docs/capability-validation-matrix.md` + - second-target validation state. + +### New files to create +- `.github/workflows/ci.yml` +- `docs/second-target-validation-plan.md` +- `docs/second-target-blocker-record.md` (only if blocked) + +### Responsibility boundaries after this phase +- `package.json` → authoritative local verification entrypoints. +- `.github/workflows/ci.yml` → clean-environment enforcement. +- `docs/second-target-validation-plan.md` → second-target decision, mode, scope, and evidence location. +- tests → prevent regression in the newly hardened generator/runtime behavior. + +--- + +### Task 1: 拆分 package scripts 并建立 CI + +**Files:** +- Modify: `package.json` +- Create: `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: + - current scripts: `build`, `check`, `test` + - repository currently has no workflow files +- Produces: + - `test:unit`, `test:smoke`, `test:e2e`, `test:coverage` + - CI workflow calling clean install + build/check + unit/smoke + audit + +- [ ] **Step 1: Write the failing test** + +Use file-based assertions in `tests/cli-smoke.test.mjs` or a new tiny test section that checks package scripts and workflow presence: + +```js +const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); +assert.equal(typeof packageJson.scripts["test:unit"], "string"); +assert.equal(typeof packageJson.scripts["test:smoke"], "string"); +assert.equal(typeof packageJson.scripts["test:e2e"], "string"); +assert.equal(typeof packageJson.scripts["test:coverage"], "string"); +assert.ok(fs.existsSync(path.join(root, ".github", "workflows", "ci.yml"))); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +``` + +Expected: FAIL because the scripts and workflow do not exist yet. + +- [ ] **Step 3: Write minimal implementation** + +Update `package.json` scripts minimally, for example: + +```json +{ + "scripts": { + "build": "tsc -p tsconfig.json", + "check": "npm run build", + "test:unit": "node --test tests/unit-pure-functions.test.mjs", + "test:smoke": "node --test tests/cli-smoke.test.mjs", + "test:e2e": "node --test tests/runtime-local-e2e.test.mjs", + "test:coverage": "node --experimental-test-coverage --test tests/unit-pure-functions.test.mjs tests/cli-smoke.test.mjs", + "test": "npm run build && npm run test:unit && npm run test:smoke && npm run test:e2e" + }, + "engines": { + "node": ">=24.16.0" + }, + "packageManager": "npm@11.15.0" +} +``` + +Create `.github/workflows/ci.yml` with a single workflow that runs: + +```yaml +name: ci +on: [push, pull_request] +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.16.0 + cache: npm + - run: npm ci + - run: npm run check + - run: npm run test:unit + - run: npm run test:smoke + - run: npm audit --package-lock-only --audit-level=moderate +``` + +Do not run `test:e2e` in the first minimal CI unless you also explicitly account for its dependency-installing behavior and runtime. + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +node --test tests/cli-smoke.test.mjs +npm run test:unit +npm run test:smoke +``` + +Expected: PASS; scripts resolve and workflow file exists. + +- [ ] **Step 5: Commit** + +```bash +git add package.json .github/workflows/ci.yml tests/cli-smoke.test.mjs +git commit -m "ci: validate build smoke tests and dependency audit" +``` + +--- + +### Task 2: 为关键安全/交付路径补回归测试 + +**Files:** +- Modify: `tests/cli-smoke.test.mjs` +- Modify: `tests/runtime-local-e2e.test.mjs` + +**Interfaces:** +- Consumes: earlier phases’ code changes and current test helpers. +- Produces: + - regression tests for handoff, output guard, generic endpoint risk, debug defaults, manifest gate, host-mode card semantics + +- [ ] **Step 1: Write the failing tests** + +在现有两个测试文件中新增 focused cases,至少覆盖: + +```js +// cli-smoke +assert.throws(() => run(["generate", workspace, "--out", existingOutput]), /non-empty|force/i); +assert.ok(serviceManifest.source_scan.endpoint_coverage.some((item) => item.path === "/api/stop" && item.status !== "supported")); +assert.ok(fs.existsSync(path.join(copyDir, "adapter", "handlers.ts"))); + +// runtime-local-e2e +assert.equal(health.debugEnabled, false); +assert.equal(health.debugProtected, true); +``` + +并复用 earlier-plan 新增的 schema/card assertions,不重复造 helper。 + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +npm run test:smoke +npm run test:e2e +``` + +Expected: FAIL if any hardened behavior regresses or remains unimplemented. + +- [ ] **Step 3: Write minimal implementation if any regression test still points to missing behavior** + +This task is primarily test coverage. Only add tiny helpers if the test files need cleanup, for example: + +```js +function assertFileExists(p) { + assert.ok(fs.existsSync(p), `Expected file to exist: ${p}`); +} +``` + +Do not change generator/runtime behavior here unless a previous phase intentionally deferred a tiny missing hook that the new test needs. + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +npm run test:smoke +npm run test:e2e +npm run test:coverage +``` + +Expected: PASS; coverage command completes and key regressions are covered. + +- [ ] **Step 5: Commit** + +```bash +git add tests/cli-smoke.test.mjs tests/runtime-local-e2e.test.mjs +git commit -m "test: cover generated package security boundaries" +``` + +--- + +### Task 3: 记录第二目标方案并在生成链路中完成验证 + +**Files:** +- Create: `docs/second-target-validation-plan.md` +- Modify: `tests/cli-smoke.test.mjs` +- Modify: `docs/capability-validation-matrix.md` + +**Interfaces:** +- Consumes: + - current `calendar-stock-updater` smoke fixture already present in `tests/cli-smoke.test.mjs` + - MVP package semantics stabilized by previous phases +- Produces: + - explicit second-target validation plan + - evidence that the second target is not image-domain-coupled + +- [ ] **Step 1: Write the failing test** + +Add or strengthen assertions in the `calendar-stock-updater` smoke section: + +```js +assert.equal(serviceManifest.source_scan.analysis_strategy, "generic_http_api"); +assert.equal(capabilityMap.target_profile, "generic-http-api"); +assert.ok(!generatedReadme.includes("image.generate")); +assert.ok(!generatedAdapterCards.includes("image.batch.submit")); +``` + +Add doc existence assertions: + +```js +const secondTargetPlan = fs.readFileSync(path.join(root, "docs", "second-target-validation-plan.md"), "utf8"); +assert.match(secondTargetPlan, /calendar-stock-updater/); +assert.match(secondTargetPlan, /Mode A/); +assert.match(secondTargetPlan, /query/i); +assert.match(secondTargetPlan, /action/i); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +npm run test:smoke +``` + +Expected: FAIL if the second-target plan file is missing or generated artifacts still leak image-specific assumptions. + +- [ ] **Step 3: Write minimal implementation** + +Create `docs/second-target-validation-plan.md` with concrete structure: + +```md +# Second Target Validation Plan + +## Selected target +- `calendar-stock-updater` +- Why: non-image, task/status oriented, already has generic HTTP fixture coverage + +## Delivery choice +- Default: Mode A +- Rationale: verify the MVP integration package without forcing an embedded-host migration first + +## Minimum validation scope +- One query/read path +- One reviewed action path +- Strict verify, readiness, doctor, handoff +``` + +Update `docs/capability-validation-matrix.md` row for `calendar-stock-updater` to reflect current phase outcome accurately. + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +npm run test:smoke +``` + +Expected: PASS; second-target plan exists and generated artifacts stay generic. + +- [ ] **Step 5: Commit** + +```bash +git add docs/second-target-validation-plan.md docs/capability-validation-matrix.md tests/cli-smoke.test.mjs +git commit -m "test: validate MVP integration package against calendar-stock-updater" +``` + +--- + +### Task 4: 记录第二目标验证证据或 blocker + +**Files:** +- Modify: `docs/capability-validation-matrix.md` +- Create: `docs/second-target-blocker-record.md` (only if blocked) + +**Interfaces:** +- Consumes: + - second-target validation results from Task 3 and any real/manual follow-up +- Produces: + - final matrix state + - blocker record when verification cannot complete externally + +- [ ] **Step 1: Write the outcome file** + +If validation succeeded enough for the current scope, update the matrix row with exact outcome wording, for example: + +```md +| calendar-stock-updater | Mode A | embedded/adapter-first | yes | partial / yes | query + reviewed action validated via MVP package | +``` + +If blocked, create `docs/second-target-blocker-record.md` with: + +```md +# Second Target Validation Blocker Record + +- Target: calendar-stock-updater +- Missing prerequisite: +- Responsible owner: +- Safe next command: +- Evidence already collected: +``` + +- [ ] **Step 2: Run repository verification** + +Run: + +```bash +npm test +npm audit --package-lock-only --audit-level=moderate +git diff --check +``` + +Expected: +- PASS +- 0 vulnerabilities +- no whitespace errors + +- [ ] **Step 3: Commit** + +If success path: + +```bash +git add docs/capability-validation-matrix.md +git commit -m "docs: record second-target integration evidence" +``` + +If blocked path: + +```bash +git add docs/capability-validation-matrix.md docs/second-target-blocker-record.md +git commit -m "docs: record second-target validation blocker" +``` + +--- + +## Self-review + +- Spec coverage: covers Phase 4 CI/testing and Phase 5 second-target validation from the approved spec. +- Placeholder scan: no TODO/TBD placeholders remain; all tasks include exact files, commands, and concrete content. +- Type consistency: reuses existing script names, smoke fixture structure, and the approved `calendar-stock-updater` second-target direction. From b65a9cda04c06c49384e980ee412587694484018 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:47:38 +0800 Subject: [PATCH 25/37] chore: ignore local handoff artifacts Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 11ce483..75008aa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ node_modules/ dist/ out/ generated/ +handoff/ .env *.log +.debug-journal.md .omo/ .claude/ .worktrees/ From e46e8013c9ddcd238e5203ecae60b4476ba491c0 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:47:39 +0800 Subject: [PATCH 26/37] feat: add structural analysis contracts Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/structural-analysis.ts | 399 +++++++++++++++++++++++++++++++++++++ src/types.ts | 8 +- 2 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 src/structural-analysis.ts diff --git a/src/structural-analysis.ts b/src/structural-analysis.ts new file mode 100644 index 0000000..3cb8c80 --- /dev/null +++ b/src/structural-analysis.ts @@ -0,0 +1,399 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export type StructuralBackendMode = "auto" | "internal" | "codegraph"; +export type StructuralBackendUsed = "internal" | "codegraph"; +export type StructuralBackendStatus = "used" | "fallback"; +export type StructuralRouteSource = "internal" | "codegraph"; + +export interface StructuralBackendMetadata { + requested: StructuralBackendMode; + used: StructuralBackendUsed; + status: StructuralBackendStatus; + reason?: string; + checked_at: string; + indexed_at?: string; + index_path?: string; +} + +export interface StructuralRouteFact { + method: string; + path: string; + source: StructuralRouteSource; + file?: string; + line?: number; +} + +export interface StructuralFacts { + backend: StructuralBackendMetadata; + routes: StructuralRouteFact[]; +} + +export type StructuralCollectionResult = StructuralFacts; + +export type InternalRouteInput = { + method: string; + path: string; + file?: string; + line?: number; +}; + +export type CollectInternalEndpoints = () => InternalRouteInput[] | Promise; + +type ExternalFailure = { + reason: string; +}; + +type CodegraphStatus = { + initialized?: boolean; + lastIndexed?: string; + indexPath?: string; + pendingChanges?: PendingChangesStatus; + worktreeMismatch?: boolean; + journalMode?: string; + index?: { + state?: string; + pendingRefs?: number; + reindexRecommended?: boolean; + }; +}; + +type PendingChangesStatus = "fresh" | "stale"; + +const ROUTE_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); +const EXEC_TIMEOUT_MS = 15_000; + +export function parseStructuralBackendMode(value: unknown): StructuralBackendMode { + if (value === undefined || value === null || value === "") return "auto"; + if (typeof value !== "string") throw new Error("Invalid structural backend mode. Use auto, internal, or codegraph."); + const normalized = value.trim().toLowerCase(); + if (normalized === "auto" || normalized === "internal" || normalized === "codegraph") return normalized; + throw new Error(`Invalid structural backend mode: ${value}. Use auto, internal, or codegraph.`); +} + +export async function collectStructuralFacts( + targetPath: string, + requestedMode: StructuralBackendMode, + collectInternalEndpoints: CollectInternalEndpoints, +): Promise { + const checkedAt = new Date().toISOString(); + if (requestedMode === "internal") { + return collectInternalFacts(requestedMode, checkedAt, collectInternalEndpoints); + } + + const external = await tryCollectCodegraphFacts(targetPath, requestedMode, checkedAt); + if ("routes" in external) return external; + if (requestedMode === "codegraph") throw codegraphError(external.reason); + + const fallback = await collectInternalFacts(requestedMode, checkedAt, collectInternalEndpoints); + return { + backend: { + ...fallback.backend, + status: "fallback", + reason: external.reason, + }, + routes: fallback.routes, + }; +} + +async function collectInternalFacts( + requested: StructuralBackendMode, + checkedAt: string, + collectInternalEndpoints: CollectInternalEndpoints, +): Promise { + const inputs = await collectInternalEndpoints(); + return { + backend: { + requested, + used: "internal", + status: "used", + checked_at: checkedAt, + }, + routes: dedupeRoutes(inputs.map((route) => normalizeRoute(route, "internal", undefined)).filter((route): route is StructuralRouteFact => route !== undefined)), + }; +} + +async function tryCollectCodegraphFacts(targetPath: string, requested: StructuralBackendMode, checkedAt: string): Promise { + const nodeFailure = validateSupportedNodeMajor(); + if (nodeFailure) return { reason: nodeFailure }; + + const executable = resolveExecutableFromPath("codegraph"); + if (!executable) return { reason: "codegraph executable is unavailable on PATH; install and maintain codegraph separately, or use --backend internal." }; + + const statusOutput = await runCodegraph(executable, ["status", targetPath, "--json"]); + if (!statusOutput.ok) return { reason: statusOutput.reason }; + + const statusValue = parseJsonBoundary(statusOutput.stdout, "codegraph status"); + if (!statusValue.ok) return { reason: statusValue.reason }; + + const status = normalizeStatus(statusValue.value); + const statusFailure = validateFreshStatus(status); + if (statusFailure) return { reason: statusFailure }; + + const queryOutput = await runCodegraph(executable, ["query", "route", "--kind", "route", "--path", targetPath, "--json"]); + if (!queryOutput.ok) return { reason: queryOutput.reason }; + + const queryValue = parseJsonBoundary(queryOutput.stdout, "codegraph route query"); + if (!queryValue.ok) return { reason: queryValue.reason }; + + const routes = normalizeCodegraphRoutes(queryValue.value, targetPath); + if (!routes.length) return { reason: "codegraph route query returned no usable routes." }; + + return { + backend: { + requested, + used: "codegraph", + status: "used", + checked_at: checkedAt, + indexed_at: status.lastIndexed, + index_path: status.indexPath, + }, + routes, + }; +} + +function validateSupportedNodeMajor(): string { + const match = /^v?(\d+)\./.exec(process.version); + const major = match ? Number(match[1]) : 0; + if (major >= 20 && major <= 24) return ""; + return `codegraph backend supports Node major versions 20 through 24; current Node is ${process.version}.`; +} + +function resolveExecutableFromPath(commandName: string): string { + const pathValue = process.env.PATH || process.env.Path || ""; + const extensions = process.platform === "win32" ? windowsExecutableExtensions() : [""]; + for (const directory of pathValue.split(path.delimiter).filter(Boolean)) { + for (const extension of extensions) { + const candidate = path.join(directory, `${commandName}${extension}`); + if (isRunnableFile(candidate)) return candidate; + } + } + return ""; +} + +function windowsExecutableExtensions(): string[] { + const pathext = process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD"; + const configured = pathext.split(";").map((item) => item.trim().toLowerCase()).filter(Boolean); + const extensions = [...configured]; + for (const extension of [".cmd", ".bat", ".exe"]) { + if (!extensions.includes(extension)) extensions.push(extension); + } + extensions.push(""); + return extensions; +} + +function isRunnableFile(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + return stat.isFile(); + } catch (error) { + if (error instanceof Error) return false; + return false; + } +} + +async function runCodegraph(executable: string, args: string[]): Promise<{ ok: true; stdout: string } | { ok: false; reason: string }> { + try { + const result = await execFileAsync(executableCommand(executable), executableArgs(executable, args), { + encoding: "utf8", + maxBuffer: 2 * 1024 * 1024, + timeout: EXEC_TIMEOUT_MS, + windowsHide: true, + }); + return { ok: true, stdout: result.stdout }; + } catch (error) { + return { ok: false, reason: `codegraph command failed: ${summarizeExecError(error)}` }; + } +} + +function executableCommand(executable: string): string { + return shouldRunViaCmd(executable) ? process.env.ComSpec || "cmd.exe" : executable; +} + +function executableArgs(executable: string, args: string[]): string[] { + if (!shouldRunViaCmd(executable)) return args; + return ["/d", "/s", "/c", executable, ...args]; +} + +function shouldRunViaCmd(executable: string): boolean { + if (process.platform !== "win32") return false; + const extension = path.extname(executable).toLowerCase(); + return extension === ".cmd" || extension === ".bat"; +} + +function summarizeExecError(error: unknown): string { + const parts: string[] = []; + if (isRecord(error)) { + const stderr = stringFromUnknown(error.stderr).trim(); + const stdout = stringFromUnknown(error.stdout).trim(); + if (stderr) parts.push(stderr); + if (stdout) parts.push(stdout); + } + if (error instanceof Error && error.message) parts.push(error.message); + return parts.map(oneLine).filter(Boolean).join("; ") || "nonzero exit or timeout"; +} + +function parseJsonBoundary(source: string, label: string): { ok: true; value: unknown } | { ok: false; reason: string } { + try { + return { ok: true, value: JSON.parse(source) }; + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown parse failure"; + return { ok: false, reason: `${label} returned invalid JSON: ${detail}` }; + } +} + +function normalizeStatus(value: unknown): CodegraphStatus { + if (!isRecord(value)) return {}; + const index = isRecord(value.index) ? value.index : undefined; + return { + initialized: typeof value.initialized === "boolean" ? value.initialized : undefined, + lastIndexed: typeof value.lastIndexed === "string" ? value.lastIndexed : undefined, + indexPath: typeof value.indexPath === "string" ? value.indexPath : undefined, + pendingChanges: normalizePendingChanges(value.pendingChanges), + worktreeMismatch: typeof value.worktreeMismatch === "boolean" ? value.worktreeMismatch : undefined, + journalMode: typeof value.journalMode === "string" ? value.journalMode : undefined, + index: index ? { + state: typeof index.state === "string" ? index.state : undefined, + pendingRefs: typeof index.pendingRefs === "number" ? index.pendingRefs : undefined, + reindexRecommended: typeof index.reindexRecommended === "boolean" ? index.reindexRecommended : undefined, + } : undefined, + }; +} + +function validateFreshStatus(status: CodegraphStatus): string { + if (status.initialized !== true) return "codegraph index is not initialized; run and maintain codegraph indexing outside lark-deployer, or use --backend internal."; + if (!status.lastIndexed) return "codegraph index has no lastIndexed timestamp."; + if (status.pendingChanges === undefined) return "codegraph status did not report pendingChanges."; + if (status.pendingChanges === "stale") return "codegraph index is stale: pendingChanges is not empty."; + if (status.worktreeMismatch === true) return "codegraph index is stale: worktreeMismatch is true."; + if (status.index?.state !== "complete") return "codegraph index is incomplete: index.state is not complete."; + if (status.index.pendingRefs !== 0) return "codegraph index is incomplete: pendingRefs is not 0."; + if (status.index.reindexRecommended === true) return "codegraph index is stale: reindexRecommended is true."; + return ""; +} + +function normalizePendingChanges(value: unknown): PendingChangesStatus | undefined { + if (Array.isArray(value)) return value.length === 0 ? "fresh" : "stale"; + if (isRecord(value)) return hasStaleNestedValue(value) ? "stale" : "fresh"; + return undefined; +} + +function hasStaleNestedValue(value: unknown): boolean { + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "number") return value !== 0; + if (typeof value === "boolean") return value; + if (typeof value === "string") return value.length > 0; + if (isRecord(value)) return Object.values(value).some(hasStaleNestedValue); + return false; +} + +function normalizeCodegraphRoutes(value: unknown, targetPath: string): StructuralRouteFact[] { + if (!Array.isArray(value)) return []; + const routes = value + .map((entry) => normalizeCodegraphRouteEntry(entry, targetPath)) + .filter((route): route is StructuralRouteFact => route !== undefined); + return dedupeRoutes(routes); +} + +function normalizeCodegraphRouteEntry(entry: unknown, targetPath: string): StructuralRouteFact | undefined { + if (!isRecord(entry) || !isRecord(entry.node)) return undefined; + return normalizeRoute(extractRouteInput(entry.node), "codegraph", targetPath); +} + +function extractRouteInput(node: Record): InternalRouteInput | undefined { + const explicitMethod = typeof node.method === "string" ? node.method : ""; + const explicitPath = typeof node.path === "string" ? node.path : ""; + const named = routeFromName(stringFromUnknown(node.name)) || routeFromName(stringFromUnknown(node.qualifiedName)); + const method = ROUTE_METHODS.has(explicitMethod.trim().toUpperCase()) ? explicitMethod : named?.method || ""; + const routePath = isValidRoutePath(explicitPath.trim()) ? explicitPath : named?.path || ""; + return { + method, + path: routePath, + file: stringFromUnknown(node.file) || stringFromUnknown(node.filePath) || stringFromUnknown(node.pathFile), + line: numberFromUnknown(node.line) || numberFromUnknown(node.startLine), + }; +} + +function routeFromName(value: string): { method: string; path: string } | undefined { + const match = /\b(GET|POST|PUT|PATCH|DELETE)\s+(\/[A-Za-z0-9_./{}:*-]+)/i.exec(value); + if (!match) return undefined; + return { method: match[1], path: match[2] }; +} + +function normalizeRoute(input: InternalRouteInput | undefined, source: StructuralRouteSource, targetPath: string | undefined): StructuralRouteFact | undefined { + if (!input) return undefined; + const method = input.method.trim().toUpperCase(); + const routePath = input.path.trim(); + if (!ROUTE_METHODS.has(method) || !isValidRoutePath(routePath)) return undefined; + + const route: StructuralRouteFact = { method, path: routePath, source }; + const file = normalizeRouteFile(input.file, targetPath); + const line = normalizePositiveLine(input.line); + if (file) route.file = file; + if (line !== undefined) route.line = line; + return route; +} + +function isValidRoutePath(value: string): boolean { + return /^\/[A-Za-z0-9_./{}:*-]*$/.test(value) && !value.includes("//"); +} + +function normalizeRouteFile(file: string | undefined, targetPath: string | undefined): string { + if (!file) return ""; + if (!targetPath) return file; + const absoluteTarget = path.resolve(targetPath); + const absoluteFile = path.resolve(file); + const relative = path.relative(absoluteTarget, absoluteFile); + if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) return relative.split(path.sep).join("/"); + return ""; +} + +function normalizePositiveLine(value: number | undefined): number | undefined { + if (value === undefined || !Number.isInteger(value) || value <= 0) return undefined; + return value; +} + +function dedupeRoutes(routes: StructuralRouteFact[]): StructuralRouteFact[] { + const sorted = [...routes].sort(compareRoutes); + const seen = new Set(); + const deduped: StructuralRouteFact[] = []; + for (const route of sorted) { + const key = `${route.method} ${route.path} ${route.source} ${route.file || ""} ${route.line || ""}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(route); + } + return deduped; +} + +function compareRoutes(left: StructuralRouteFact, right: StructuralRouteFact): number { + return `${left.method} ${left.path} ${left.file || ""} ${left.line || 0} ${left.source}`.localeCompare( + `${right.method} ${right.path} ${right.file || ""} ${right.line || 0} ${right.source}`, + ); +} + +function codegraphError(reason: string): Error { + return new Error(`codegraph structural backend unavailable: ${reason} lark-deployer will not install, initialize, sync, or reindex codegraph automatically.`); +} + +function stringFromUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (Buffer.isBuffer(value)) return value.toString("utf8"); + return ""; +} + +function numberFromUnknown(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function oneLine(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} diff --git a/src/types.ts b/src/types.ts index 64db7be..9d953c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { StructuralBackendMetadata, StructuralRouteFact } from "./structural-analysis.js"; + export type JsonObject = Record; export type ManifestSchemaVersion = "0.2"; @@ -22,9 +24,11 @@ export interface ServiceManifest { start_hints: string[]; }; source_scan: { - analysis_strategy: "http_api_python_image_agent_web" | "generic_http_api" | "generic_cli"; + analysis_strategy: "http_api_python_image_agent_web" | "calendar_stock_updater" | "generic_http_api" | "generic_cli"; files_checked: string[]; endpoints: Array<{ method: string; path: string }>; + structural_backend?: StructuralBackendMetadata; + route_provenance?: StructuralRouteFact[]; endpoint_coverage?: Array<{ method: string; path: string; @@ -45,7 +49,7 @@ export interface ServiceManifest { export interface CapabilityMap { schema_version: ManifestSchemaVersion; service_name: string; - target_profile: "image-agent-web" | "generic-http-api" | "generic-cli"; + target_profile: "image-agent-web" | "calendar-stock-updater" | "generic-http-api" | "generic-cli"; capabilities: Capability[]; } From 46be1ed6337fc3b5c685f236b3e61e35e224b60b Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:47:40 +0800 Subject: [PATCH 27/37] feat: integrate calendar structural analysis Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/analyze.ts | 143 ++++- src/profiles/calendar-stock-updater.ts | 851 +++++++++++++++++++++++++ 2 files changed, 987 insertions(+), 7 deletions(-) create mode 100644 src/profiles/calendar-stock-updater.ts diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index c31aa69..0b3a926 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -4,6 +4,9 @@ import { getStringOption } from "../args.js"; import { ensureDir, readTextIfExists, slugify, writeJson, writeText } from "../fs-utils.js"; import { getJsonWithTimeout, normalizeBaseUrl } from "../http-utils.js"; import { IMAGE_AGENT_WEB_PROFILE } from "../profiles/image-agent-web.js"; +import { buildCalendarProfileArtifacts, isCalendarStockUpdaterTarget } from "../profiles/calendar-stock-updater.js"; +import { collectStructuralFacts, parseStructuralBackendMode } from "../structural-analysis.js"; +import type { StructuralFacts } from "../structural-analysis.js"; import type { CapabilityMap, InteractionContract, JsonObject, RequiredPermissions, ServiceManifest } from "../types.js"; interface AnalyzeOptions { @@ -11,6 +14,7 @@ interface AnalyzeOptions { baseUrl: string; outDir: string; name: string; + structuralFacts: StructuralFacts; } interface AnalyzerStrategy { @@ -56,9 +60,18 @@ const ANALYZER_STRATEGIES: AnalyzerStrategy[] = [ canAnalyze: ({ targetPath }) => isImageAgentWebTarget(targetPath), analyze: analyzeImageAgentWeb, }, + { + id: "calendar_stock_updater", + canAnalyze: ({ targetPath }) => isCalendarStockUpdaterTarget(targetPath, { + server: readTextIfExists(path.join(targetPath, "server.js")), + taskConfig: readTextIfExists(path.join(targetPath, "task-config.js")), + automation: readTextIfExists(path.join(targetPath, "update-calendar-stock.js")), + }), + analyze: analyzeCalendarStockUpdater, + }, { id: "generic_http_api", - canAnalyze: ({ targetPath }) => discoverHttpEndpoints(targetPath).length > 0, + canAnalyze: ({ structuralFacts }) => structuralFacts.routes.length > 0, analyze: analyzeGenericHttpApi, }, { @@ -73,7 +86,7 @@ const ANALYZER_STRATEGIES: AnalyzerStrategy[] = [ export async function analyzeCommand(args: string[], options: Record): Promise { const targetArg = args[0]; if (!targetArg) { - throw new Error("Usage: lark-deployer analyze --base-url [--out ] [--name ]"); + throw new Error("Usage: lark-deployer analyze --base-url [--out ] [--name ] [--backend auto|internal|codegraph]"); } const targetPath = path.resolve(targetArg); @@ -85,12 +98,15 @@ export async function analyzeCommand(args: string[], options: Record discoverHttpEndpoints(targetPath)); const analyzeOptions = { targetPath, baseUrl, outDir, name, + structuralFacts, }; const strategy = ANALYZER_STRATEGIES.find((item) => item.canAnalyze(analyzeOptions)); if (!strategy) { @@ -99,7 +115,7 @@ export async function analyzeCommand(args: string[], options: Record { +async function analyzeImageAgentWeb({ targetPath, baseUrl, outDir, name, structuralFacts }: AnalyzeOptions): Promise { const manifestDir = path.join(outDir, "manifest"); ensureDir(manifestDir); @@ -110,7 +126,7 @@ async function analyzeImageAgentWeb({ targetPath, baseUrl, outDir, name }: Analy const main = readTextIfExists(mainPath); const templates = readTextIfExists(templatesPath); const secretFindings = scanPotentialSecrets(targetPath); - const endpoints = extractFastApiEndpoints(main); + const endpoints = structuralFacts.backend.used === "codegraph" ? endpointsFromStructuralFacts(structuralFacts) : extractFastApiEndpoints(main); const endpointCoverage = buildEndpointCoverage(endpoints); const frameworks = [ requirements.includes("fastapi") || main.includes("FastAPI") ? "fastapi" : "", @@ -157,10 +173,13 @@ async function analyzeImageAgentWeb({ targetPath, baseUrl, outDir, name }: Analy path.relative(targetPath, templatesPath), ], endpoints, + structural_backend: structuralFacts.backend, + route_provenance: structuralFacts.routes, endpoint_coverage: endpointCoverage, notes: [ "MVP preset: image-agent-web FastAPI integration.", "Lark-deployer verifies target availability but does not start or manage the target service.", + structuralBackendNote(structuralFacts), secretFindings.length ? `Potential target-side secret literals found: ${secretFindings.length}. Values were not copied into Lark-deployer artifacts.` : "", @@ -505,13 +524,81 @@ async function analyzeImageAgentWeb({ targetPath, baseUrl, outDir, name }: Analy } } -async function analyzeGenericHttpApi({ targetPath, baseUrl, outDir, name }: AnalyzeOptions): Promise { +async function analyzeCalendarStockUpdater({ targetPath, baseUrl, outDir, name, structuralFacts }: AnalyzeOptions): Promise { const manifestDir = path.join(outDir, "manifest"); ensureDir(manifestDir); + const endpoints = endpointsFromStructuralFacts(structuralFacts); const secretFindings = scanPotentialSecrets(targetPath); - const discovered = discoverHttpEndpoints(targetPath); - const endpoints = dedupeEndpoints(discovered); + const healthProbe = await getJsonWithTimeout(baseUrl ? `${baseUrl}/api/state` : "", 5000); + const artifacts = buildCalendarProfileArtifacts(name); + const profileCapabilityFor = (pathName: string) => artifacts.capabilityMap.capabilities.find((item) => item.source.path === pathName); + + const serviceManifest: ServiceManifest = { + schema_version: "0.2", + generated_at: new Date().toISOString(), + service: { + name, + target_path: targetPath, + type: "http_api", + detected_frameworks: detectFrameworks(targetPath), + runtime_mode: "external_service", + managed_by_lark_deployer: false, + base_url: baseUrl, + healthcheck: { method: "GET", path: "/api/state", status: healthProbe.status, detail: healthProbe.detail }, + start_hints: [ + "The calendar service lifecycle is owned by the target project.", + "Start the calendar service with its existing documented command; start the isolated integrations/lark host separately after installation.", + ], + }, + source_scan: { + analysis_strategy: "calendar_stock_updater", + files_checked: ["server.js", "task-config.js", "update-calendar-stock.js", "public/index.html", "public/app.js"].filter((file) => fs.existsSync(path.join(targetPath, file))), + endpoints, + structural_backend: structuralFacts.backend, + route_provenance: structuralFacts.routes, + endpoint_coverage: endpoints.map((endpoint) => { + const capability = profileCapabilityFor(endpoint.path); + if (endpoint.method === "GET" && endpoint.path === "/api/state") { + return { ...endpoint, status: "supported" as const, capability_id: "calendar.status", reason: "Generated cards use this endpoint for health, defaults, status, and bounded recent logs." }; + } + if (endpoint.method === "GET" && endpoint.path === "/api/events") { + return { ...endpoint, status: "supporting" as const, reason: "Browser-only Server-Sent Events stream; excluded from finite Feishu card callbacks." }; + } + if (capability) { + return { ...endpoint, status: "supported" as const, capability_id: capability.id, reason: "Mapped through the calendar business interaction profile." }; + } + return { ...endpoint, status: "discovered_not_generated" as const, reason: "Discovered but not part of the calendar business interaction profile." }; + }), + notes: [ + "Analyzer strategy: calendar_stock_updater.", + "The recommended Web-console fields are mapped into typed Feishu cards; raw body_json is intentionally not generated.", + "Formal run and stop confirmation is owned by the isolated Lark host; the unchanged target exposes only /api/state, /api/run, and /api/stop.", + "GET /api/events is browser-only SSE infrastructure and is intentionally excluded from card actions.", + structuralBackendNote(structuralFacts), + ...(secretFindings.length ? [`Potential target-side secret literals found: ${secretFindings.length}. Values were not copied into Lark-deployer artifacts.`] : []), + ], + secret_findings: secretFindings, + }, + }; + + writeJson(path.join(manifestDir, "service_manifest.json"), serviceManifest); + writeJson(path.join(manifestDir, "capability_map.json"), artifacts.capabilityMap); + writeJson(path.join(manifestDir, "interaction_contract.json"), artifacts.interactionContract); + writeJson(path.join(manifestDir, "required_permissions.json"), artifacts.requiredPermissions); + writeText(path.join(outDir, "analysis_report.md"), buildCalendarAnalysisReport(serviceManifest, artifacts.capabilityMap)); + + console.log(`Analysis written to ${outDir}`); + console.log("Analyzer strategy: calendar_stock_updater"); + console.log(`Target healthcheck: ${serviceManifest.service.healthcheck.status} (${serviceManifest.service.healthcheck.detail})`); +} + +async function analyzeGenericHttpApi({ targetPath, baseUrl, outDir, name, structuralFacts }: AnalyzeOptions): Promise { + const manifestDir = path.join(outDir, "manifest"); + ensureDir(manifestDir); + + const secretFindings = scanPotentialSecrets(targetPath); + const endpoints = endpointsFromStructuralFacts(structuralFacts); const capabilityEndpoints = endpoints.filter((endpoint) => !isHealthEndpoint(endpoint)); const generatedCapabilityEndpoints = capabilityEndpoints.filter((endpoint) => genericEndpointStatus(endpoint) === "supported"); const healthPath = endpoints.find(isHealthEndpoint)?.path || endpoints.find((endpoint) => endpoint.method === "GET")?.path || "/"; @@ -544,6 +631,8 @@ async function analyzeGenericHttpApi({ targetPath, baseUrl, outDir, name }: Anal analysis_strategy: "generic_http_api", files_checked: collectGenericFilesChecked(targetPath), endpoints, + structural_backend: structuralFacts.backend, + route_provenance: structuralFacts.routes, endpoint_coverage: endpoints.map((endpoint) => { if (isHealthEndpoint(endpoint)) { return { ...endpoint, status: "supporting" as const, reason: "Used as a generic target healthcheck." }; @@ -561,6 +650,7 @@ async function analyzeGenericHttpApi({ targetPath, baseUrl, outDir, name }: Anal notes: [ "Analyzer strategy: generic_http_api.", "Generated manifest is a coarse HTTP API draft; review input and output schemas before production use.", + structuralBackendNote(structuralFacts), secretFindings.length ? `Potential target-side secret literals found: ${secretFindings.length}. Values were not copied into Lark-deployer artifacts.` : "", @@ -676,6 +766,15 @@ function isImageAgentWebTarget(targetPath: string): boolean { || endpoints.some((endpoint) => endpoint.method === "POST" && endpoint.path === "/api/generate"); } +function endpointsFromStructuralFacts(facts: StructuralFacts): Array<{ method: string; path: string }> { + return dedupeEndpoints(facts.routes.map((route) => ({ method: route.method, path: route.path }))); +} + +function structuralBackendNote(facts: StructuralFacts): string { + const fallback = facts.backend.status === "fallback" ? `, fallback reason: ${oneLine(facts.backend.reason || "not specified")}` : ""; + return `Structural backend requested ${facts.backend.requested}, used ${facts.backend.used} (${facts.backend.status}${fallback}).`; +} + function discoverHttpEndpoints(targetPath: string): Array<{ method: string; path: string }> { const candidates = ["main.py", "app.py", "server.py", "server.js", "README.md", "readme.md"]; return candidates.flatMap((file) => { @@ -1178,6 +1277,36 @@ Lark-deployer records only finding metadata here. It does not copy matched secre `; } +function buildCalendarAnalysisReport(manifest: ServiceManifest, capabilities: CapabilityMap): string { + const coverage = manifest.source_scan.endpoint_coverage || []; + return `# Calendar Stock Updater Analysis Report + +## Profile + +- Target profile: calendar-stock-updater +- Analyzer strategy: calendar_stock_updater +- Target: ${manifest.service.name} +- Base URL: ${manifest.service.base_url || "not provided"} +- Health/read endpoint: GET /api/state + +## Business interaction mapping + +- Typed Feishu card fields replace generic JSON request bodies. +- Default card fields mirror the recommended Web console: target date, stock, delays, and optional product ID range. +- Advanced environment-only date/SKU modes remain review candidates. +- Formal execution and task stopping require host-local confirmation before the original target endpoints are called. +- GET /api/events is browser-only SSE infrastructure and is not a Feishu action. + +## Endpoint coverage + +${coverage.map((item) => `- ${item.method} ${item.path}: ${item.status}${item.capability_id ? ` (${item.capability_id})` : ""} — ${item.reason}`).join("\n")} + +## Generated capabilities + +${capabilities.capabilities.map((capability) => `- ${capability.id}: ${capability.name} (${capability.risk})`).join("\n")} +`; +} + function buildGenericAnalysisReport(manifest: ServiceManifest, capabilities: CapabilityMap): string { const coverageLines = manifest.source_scan.endpoint_coverage?.length ? manifest.source_scan.endpoint_coverage.map((item) => { diff --git a/src/profiles/calendar-stock-updater.ts b/src/profiles/calendar-stock-updater.ts new file mode 100644 index 0000000..8d7693e --- /dev/null +++ b/src/profiles/calendar-stock-updater.ts @@ -0,0 +1,851 @@ +import type { CapabilityMap, InteractionContract, RequiredPermissions } from "../types.js"; + +export const CALENDAR_STOCK_UPDATER_PROFILE = { + id: "calendar-stock-updater", + actions: { + refresh: "calendar.status.refresh", + dryRun: "calendar.task.dry-run", + runPrepare: "calendar.task.run.prepare", + runConfirm: "calendar.task.run.confirm", + runCancel: "calendar.task.run.cancel", + stopPrepare: "calendar.task.stop.prepare", + stopConfirm: "calendar.task.stop.confirm", + stopCancel: "calendar.task.stop.cancel", + }, +} as const; + +export interface CalendarProfileArtifacts { + capabilityMap: CapabilityMap; + interactionContract: InteractionContract; + requiredPermissions: RequiredPermissions; +} + +export function isCalendarStockUpdaterTarget( + _targetPath: string, + source: { server: string; taskConfig: string; automation: string }, +): boolean { + return source.server.includes("/api/state") + && source.server.includes("/api/run") + && source.server.includes("/api/stop") + && source.taskConfig.includes("resolveProductIdRange") + && (source.automation.includes("updateAllSkuRows") || source.automation.includes("SPECIAL_SKU_KEYWORD")); +} + +export function buildCalendarProfileArtifacts(serviceName: string): CalendarProfileArtifacts { + const capabilityMap: CapabilityMap = { + schema_version: "0.2", + service_name: serviceName, + target_profile: "calendar-stock-updater", + capabilities: [ + calendarCapability("calendar.status", "Read calendar task status", "query", "read_only", "GET", "/api/state", {}), + calendarCapability("calendar.task.run", "Start a calendar inventory task", "long_task", "write", "POST", "/api/run", calendarTaskInputSchema()), + calendarCapability("calendar.task.stop", "Stop the active calendar inventory task", "action", "destructive", "POST", "/api/stop", { + type: "object", + additionalProperties: false, + }), + ], + }; + + const actions = CALENDAR_STOCK_UPDATER_PROFILE.actions; + const interactions: InteractionContract["interactions"] = [ + interaction("calendar.status.card", "calendar.status", actions.refresh, "button_action"), + interaction("calendar.task.dry-run.card", "calendar.task.run", actions.dryRun, "form_action"), + interaction("calendar.task.run.prepare.card", "calendar.task.run", actions.runPrepare, "form_action"), + interaction("calendar.task.run.confirm.card", "calendar.task.run", actions.runConfirm, "button_action"), + interaction("calendar.task.run.cancel.card", "calendar.status", actions.runCancel, "button_action"), + interaction("calendar.task.stop.prepare.card", "calendar.task.stop", actions.stopPrepare, "button_action"), + interaction("calendar.task.stop.confirm.card", "calendar.task.stop", actions.stopConfirm, "button_action"), + interaction("calendar.task.stop.cancel.card", "calendar.status", actions.stopCancel, "button_action"), + ]; + + const interactionContract: InteractionContract = { + schema_version: "0.2", + channel: "lark", + service_name: serviceName, + supported_triggers: ["card_action", "manual_review"], + supported_result_modes: ["interactive_card", "structured_result", "state_update"], + interactions, + }; + + const requiredPermissions: RequiredPermissions = { + schema_version: "0.2", + app: { + type: "custom_app", + bot_required: true, + availability_recommendation: "Restrict calendar inventory operations to an approved operator allowlist.", + }, + context_requirements: [ + "Existing Feishu/Lark custom app with long connection enabled.", + "FEISHU_APP_ID and FEISHU_APP_SECRET stored in the isolated Lark module environment.", + "A Feishu SDK host subscribed to card.action.trigger.", + "A reachable calendar-stock-updater TARGET_BASE_URL.", + "An approved ALLOWED_OPERATOR_OPEN_IDS allowlist for task execution and stopping.", + ], + token_strategy: { default: "tenant_access_token", user_access_token_required: false }, + scopes: [{ + scope: "im:message:send_as_bot", + identity: "tenant", + required_by: interactions.map((item) => item.id), + reason: "Send calendar task forms, confirmations, status, and failure cards.", + risk: "low", + }], + events: [], + callbacks: [{ + callback: "card.action.trigger", + required_by: interactions.map((item) => item.id), + reason: "Receive calendar task card actions through the Feishu SDK long connection.", + security: ["long_connection", "operator_allowlist", "host_local_confirmation", "idempotency"], + }], + manual_steps: [ + "Enable bot capability and im:message:send_as_bot.", + "Enable Feishu long connection and subscribe only to card.action.trigger.", + "Configure integrations/lark/.env from integrations/lark/.env.example.", + "Restrict ALLOWED_OPERATOR_OPEN_IDS before task execution or stopping.", + ], + review_flags: [ + "Formal inventory updates and task stopping use single-use host-local confirmations before the original target endpoints are called.", + "GET /api/events is browser-only SSE infrastructure and is intentionally excluded from Feishu actions.", + "Advanced SPECIAL_* and targeted SKU environment modes remain review candidates and are not exposed by the default card.", + ], + }; + + return { capabilityMap, interactionContract, requiredPermissions }; +} + +function calendarCapability( + id: string, + name: string, + kind: "query" | "action" | "long_task", + risk: "read_only" | "write" | "destructive", + method: "GET" | "POST", + endpointPath: string, + inputSchema: Record, +): CapabilityMap["capabilities"][number] { + return { + id, + name, + kind, + risk, + source: { type: "http", method, path: endpointPath, content_type: "application/json" }, + input_schema: inputSchema, + output_schema: { type: "object", additionalProperties: true }, + artifacts: [{ name: "task_state", type: "structured_data", source_field: "$", delivery: "card_json" }], + timeout_seconds: 30, + }; +} + +function interaction( + id: string, + capabilityId: string, + actionId: string, + inputMode: "form_action" | "button_action", +): InteractionContract["interactions"][number] { + return { + id, + capability_id: capabilityId, + action_id: actionId, + trigger: "card_action", + input_mode: inputMode, + result_mode: "interactive_card", + states: ["idle", "confirming", "running", "succeeded", "failed", "stopped"], + audit_fields: ["operator_open_id", "chat_id", "message_id", "trace_id", "capability_id"], + error_handling: [ + "Invalid business input -> return a failure card and do not call the target.", + "Unauthorized mutating operator -> return a failure card and do not call the target.", + "Expired, reused, or mismatched host-local confirmation -> return a failure card and do not start or stop a task.", + ], + }; +} + +export function calendarAdapterCardsSource(options: { typed?: boolean } = {}): string { + if (options.typed) { + return `import type { CalendarCard, CalendarCardElement, CalendarRecord, CalendarTaskState } from "./types.js"; + +const actions = ${JSON.stringify(CALENDAR_STOCK_UPDATER_PROFILE.actions, null, 2)} as const; + +export function buildStartCard(state: CalendarTaskState = {}): CalendarCard { + return buildOperationsCard(state); +} + +export function buildOperationsCard(state: CalendarTaskState = {}): CalendarCard { + const defaults = readRecord(state.defaults); + const task = readRecord(state.task); + const logs = Array.isArray(state.logs) ? state.logs.slice(-8) : []; + const status = String(task.status || "idle"); + const running = status === "running"; + const summary = [ + "**状态摘要**", + "**当前状态:** " + statusLabel(status), + "**当前消息:** " + safeOperationalText(task.currentMessage, "等待开始", "状态消息已脱敏", 120), + "**日期范围:** " + [task.startDate || defaults.startDate, task.targetDate || defaults.endDate || defaults.targetDate].filter(Boolean).join(" → "), + "**库存:** " + String(task.stock || defaults.stock || "-"), + "**商品范围:** " + [task.startProductId || defaults.startProductId || "从第一页开始", task.endProductId || defaults.endProductId || "不限"].join(" → "), + "**启动时间:** " + String(task.startedAt || "-"), + ].join("\\n"); + const elements: CalendarCardElement[] = [{ tag: "markdown", content: summary }]; + if (!running) { + elements.push({ tag: "markdown", content: "**任务参数**\\n填写后可普通预演,或申请正式执行。" }); + elements.push(taskForm({ ...defaults, ...task })); + } + elements.push(actionButton("刷新状态", "calendar_status_refresh", actions.refresh, "default")); + if (running && !task.stopRequested) elements.push(actionButton("申请停止任务", "calendar_stop_prepare", actions.stopPrepare, "danger")); + if (running && task.stopRequested) elements.push({ tag: "markdown", content: "**停止状态**\\n已请求停止,等待当前任务退出后刷新状态。" }); + if (logs.length) elements.push({ tag: "markdown", content: "**最近日志(仅显示最近 8 条,长行会截断)**\\n" + logs.map((item) => "- " + safeLog(item)).join("\\n") }); + return card(statusTemplate(status), "日历库存运行控制台", elements); +} + +export function buildRunConfirmationCard(result: CalendarRecord = {}): CalendarCard { + const confirmationId = String(result.confirmationId || ""); + const input = readRecord(result.input); + const summary = [ + "目标日期:" + String(input.targetDate || "-"), + "库存:" + String(input.stock || "-"), + "普通操作停顿(ms):" + String(input.stepDelayMs || "-"), + "日期组件停顿(ms):" + String(input.datePickerDelayMs || "-"), + "开始商品 ID:" + String(input.startProductId || "从第一页开始"), + "结束商品 ID:" + String(input.endProductId || "不限"), + ].join("\\n"); + return card("orange", "确认正式库存更新", [ + { tag: "markdown", content: "**风险提示:** 正式执行会写入库存。请确认以下提交值无误后再继续。\\n" + summary }, + actionButton("确认正式执行", "calendar_run_confirm", actions.runConfirm, "danger", { confirmationId }), + actionButton("取消", "calendar_run_cancel", actions.runCancel, "default", { confirmationId }), + ]); +} + +export function buildStopConfirmationCard(result: CalendarRecord = {}): CalendarCard { + const confirmationId = String(result.confirmationId || ""); + const task = readRecord(result.task); + return card("orange", "确认停止任务", [ + { tag: "markdown", content: "**风险提示:** 此操作只会停止当前运行任务。\\n当前消息:" + safeOperationalText(task.currentMessage, "运行中", "状态消息已脱敏", 100) }, + actionButton("确认停止", "calendar_stop_confirm", actions.stopConfirm, "danger", { confirmationId }), + actionButton("取消", "calendar_stop_cancel", actions.stopCancel, "default", { confirmationId }), + ]); +} + +export function buildFailureCard(message: string): CalendarCard { + return card("red", "日历库存操作失败", [{ tag: "markdown", content: "**原因:** " + safeFailureMessage(message) }]); +} + +function taskForm(values: CalendarRecord): CalendarCardElement { + return { + tag: "form", + name: "calendar_task_form", + elements: [ + input("targetDate", "目标日期", "YYYY-MM-DD", true, values.targetDate || values.endDate || ""), + input("stock", "库存", "正整数", true, values.stock || "100"), + input("stepDelayMs", "普通操作停顿(ms)", "0-10000", true, values.stepDelayMs || "500"), + input("datePickerDelayMs", "日期组件停顿(ms)", "0-10000", true, values.datePickerDelayMs || "500"), + input("startProductId", "开始商品 ID(可选,较大)", "留空表示从第一页开始", false, values.startProductId || ""), + input("endProductId", "结束商品 ID(可选,较小)", "留空表示不限", false, values.endProductId || ""), + actionButton("普通预演", "calendar_dry_run", actions.dryRun, "primary", {}, true), + actionButton("申请正式执行", "calendar_run_prepare", actions.runPrepare, "danger", {}, true), + ], + }; +} + +function input(name: string, label: string, placeholder: string, required: boolean, defaultValue: unknown): CalendarCardElement { + return { + tag: "input", + name, + required, + width: "fill", + default_value: String(defaultValue || ""), + label: { tag: "plain_text", content: label }, + placeholder: { tag: "plain_text", content: placeholder }, + }; +} + +function actionButton(text: string, name: string, action: string, type: string, value: CalendarRecord = {}, submit = false): CalendarCardElement { + return { + tag: "button", + text: { tag: "plain_text", content: text }, + type, + name, + ...(submit ? { form_action_type: "submit" } : {}), + behaviors: [{ type: "callback", value: { action, ...value } }], + }; +} + +function card(template: string, title: string, elements: CalendarCardElement[]): CalendarCard { + return { + schema: "2.0", + config: { update_multi: true, wide_screen_mode: true }, + header: { template, title: { tag: "plain_text", content: title } }, + body: { elements }, + }; +} + +function statusTemplate(status: string): string { + return status === "succeeded" ? "green" : status === "failed" ? "red" : status === "stopped" ? "grey" : status === "running" ? "yellow" : "blue"; +} + +function statusLabel(status: string): string { + const labels: Record = { idle: "待命", running: "运行中", succeeded: "已完成", failed: "失败", stopped: "已停止" }; + return labels[status] || status; +} + +function safeDisplayText(value: unknown, fallback: string, maxLength: number): string { + const normalized = normalizeDisplayText(value, fallback); + return normalized.length > maxLength ? normalized.slice(0, maxLength - 1) + "…" : normalized; +} + +function safeOperationalText(value: unknown, fallback: string, redacted: string, maxLength: number): string { + const normalized = normalizeDisplayText(value, fallback); + return isSensitiveOperationalText(normalized) ? redacted : safeDisplayText(normalized, fallback, maxLength); +} + +function safeFailureMessage(value: unknown): string { + return safeOperationalText(value, "未知错误", "操作失败,请检查目标服务与模块配置后重试。", 160); +} + +function normalizeDisplayText(value: unknown, fallback: string): string { + return String(value ?? "").replace(/[\\r\\n]+/g, " ").replace(/\\s+/g, " ").trim() || fallback; +} + +function isSensitiveOperationalText(value: string): boolean { + return /(?:https?:\\/\\/|<[^>]+>|app[_-]?secret|authorization|bearer|cookie|(?:auth|secret|token|password|credentials?|(?:api|access|private)[_-]?key)\\s*[:=]|operator[_-]?open[_-]?id|open[_-]?chat[_-]?id|test[_-]?chat[_-]?id|\\b(?:ou|oc|om)_[A-Za-z0-9_-]+\\b|\\bsk-[A-Za-z0-9_-]{8,}\\b)/i.test(value); +} + +function safeLog(item: unknown): string { + const entry = readRecord(item); + const timestamp = safeOperationalText(entry.timestamp || entry.time || entry.createdAt || "", "", "时间已脱敏", 40); + const rawMessage = entry.message !== undefined ? entry.message : typeof item === "string" ? item : "无日志消息"; + const message = safeOperationalText(rawMessage, "无日志消息", "日志内容已脱敏", 180); + return (timestamp ? "[" + timestamp + "] " : "") + message; +} + +function readRecord(value: unknown): CalendarRecord { + return value && typeof value === "object" && !Array.isArray(value) ? value as CalendarRecord : {}; +} +`; + } + return `const actions = ${JSON.stringify(CALENDAR_STOCK_UPDATER_PROFILE.actions, null, 2)}; + +export function buildStartCard(state = {}) { + return buildOperationsCard(state); +} + +export function buildOperationsCard(state = {}) { + const defaults = state.defaults && typeof state.defaults === "object" ? state.defaults : {}; + const task = state.task && typeof state.task === "object" ? state.task : {}; + const logs = Array.isArray(state.logs) ? state.logs.slice(-8) : []; + const status = String(task.status || "idle"); + const running = status === "running"; + const summary = [ + "**状态摘要**", + "**当前状态:** " + statusLabel(status), + "**当前消息:** " + safeOperationalText(task.currentMessage, "等待开始", "状态消息已脱敏", 120), + "**日期范围:** " + [task.startDate || defaults.startDate, task.targetDate || defaults.endDate || defaults.targetDate].filter(Boolean).join(" → "), + "**库存:** " + String(task.stock || defaults.stock || "-"), + "**商品范围:** " + [task.startProductId || defaults.startProductId || "从第一页开始", task.endProductId || defaults.endProductId || "不限"].join(" → "), + "**启动时间:** " + String(task.startedAt || "-"), + ].join("\\n"); + const elements = [{ tag: "markdown", content: summary }]; + if (!running) { + elements.push({ tag: "markdown", content: "**任务参数**\\n填写后可普通预演,或申请正式执行。" }); + elements.push(taskForm({ ...defaults, ...task })); + } + elements.push(actionButton("刷新状态", "calendar_status_refresh", actions.refresh, "default")); + if (running && !task.stopRequested) elements.push(actionButton("申请停止任务", "calendar_stop_prepare", actions.stopPrepare, "danger")); + if (running && task.stopRequested) elements.push({ tag: "markdown", content: "**停止状态**\\n已请求停止,等待当前任务退出后刷新状态。" }); + if (logs.length) elements.push({ tag: "markdown", content: "**最近日志(仅显示最近 8 条,长行会截断)**\\n" + logs.map((item) => "- " + safeLog(item)).join("\\n") }); + return card(statusTemplate(status), "日历库存运行控制台", elements); +} + +export function buildRunConfirmationCard(result = {}) { + const confirmationId = String(result.confirmationId || ""); + const input = result.input && typeof result.input === "object" ? result.input : {}; + const summary = [ + "目标日期:" + String(input.targetDate || "-"), + "库存:" + String(input.stock || "-"), + "普通操作停顿(ms):" + String(input.stepDelayMs || "-"), + "日期组件停顿(ms):" + String(input.datePickerDelayMs || "-"), + "开始商品 ID:" + String(input.startProductId || "从第一页开始"), + "结束商品 ID:" + String(input.endProductId || "不限"), + ].join("\\n"); + return card("orange", "确认正式库存更新", [ + { tag: "markdown", content: "**风险提示:** 正式执行会写入库存。请确认以下提交值无误后再继续。\\n" + summary }, + actionButton("确认正式执行", "calendar_run_confirm", actions.runConfirm, "danger", { confirmationId }), + actionButton("取消", "calendar_run_cancel", actions.runCancel, "default", { confirmationId }), + ]); +} + +export function buildStopConfirmationCard(result = {}) { + const confirmationId = String(result.confirmationId || ""); + const task = result.task && typeof result.task === "object" ? result.task : {}; + return card("orange", "确认停止任务", [ + { tag: "markdown", content: "**风险提示:** 此操作只会停止当前运行任务。\\n当前消息:" + safeOperationalText(task.currentMessage, "运行中", "状态消息已脱敏", 100) }, + actionButton("确认停止", "calendar_stop_confirm", actions.stopConfirm, "danger", { confirmationId }), + actionButton("取消", "calendar_stop_cancel", actions.stopCancel, "default", { confirmationId }), + ]); +} + +export function buildFailureCard(message) { + return card("red", "日历库存操作失败", [{ tag: "markdown", content: "**原因:** " + safeFailureMessage(message) }]); +} + +function taskForm(values) { + return { + tag: "form", + name: "calendar_task_form", + elements: [ + input("targetDate", "目标日期", "YYYY-MM-DD", true, values.targetDate || values.endDate || ""), + input("stock", "库存", "正整数", true, values.stock || "100"), + input("stepDelayMs", "普通操作停顿(ms)", "0-10000", true, values.stepDelayMs || "500"), + input("datePickerDelayMs", "日期组件停顿(ms)", "0-10000", true, values.datePickerDelayMs || "500"), + input("startProductId", "开始商品 ID(可选,较大)", "留空表示从第一页开始", false, values.startProductId || ""), + input("endProductId", "结束商品 ID(可选,较小)", "留空表示不限", false, values.endProductId || ""), + actionButton("普通预演", "calendar_dry_run", actions.dryRun, "primary", {}, true), + actionButton("申请正式执行", "calendar_run_prepare", actions.runPrepare, "danger", {}, true), + ], + }; +} + +function input(name, label, placeholder, required, defaultValue) { + return { + tag: "input", + name, + required, + width: "fill", + default_value: String(defaultValue || ""), + label: { tag: "plain_text", content: label }, + placeholder: { tag: "plain_text", content: placeholder }, + }; +} + +function actionButton(text, name, action, type, value = {}, submit = false) { + return { + tag: "button", + text: { tag: "plain_text", content: text }, + type, + name, + ...(submit ? { form_action_type: "submit" } : {}), + behaviors: [{ type: "callback", value: { action, ...value } }], + }; +} + +function card(template, title, elements) { + return { + schema: "2.0", + config: { update_multi: true, wide_screen_mode: true }, + header: { template, title: { tag: "plain_text", content: title } }, + body: { elements }, + }; +} + +function statusTemplate(status) { + return status === "succeeded" ? "green" : status === "failed" ? "red" : status === "stopped" ? "grey" : status === "running" ? "yellow" : "blue"; +} + +function statusLabel(status) { + return ({ idle: "待命", running: "运行中", succeeded: "已完成", failed: "失败", stopped: "已停止" })[status] || status; +} + +function safeDisplayText(value, fallback, maxLength) { + const normalized = normalizeDisplayText(value, fallback); + return normalized.length > maxLength ? normalized.slice(0, maxLength - 1) + "…" : normalized; +} + +function safeOperationalText(value, fallback, redacted, maxLength) { + const normalized = normalizeDisplayText(value, fallback); + return isSensitiveOperationalText(normalized) ? redacted : safeDisplayText(normalized, fallback, maxLength); +} + +function safeFailureMessage(value) { + return safeOperationalText(value, "未知错误", "操作失败,请检查目标服务与模块配置后重试。", 160); +} + +function normalizeDisplayText(value, fallback) { + return String(value ?? "").replace(/[\\r\\n]+/g, " ").replace(/\\s+/g, " ").trim() || fallback; +} + +function isSensitiveOperationalText(value) { + return /(?:https?:\\/\\/|<[^>]+>|app[_-]?secret|authorization|bearer|cookie|(?:auth|secret|token|password|credentials?|(?:api|access|private)[_-]?key)\\s*[:=]|operator[_-]?open[_-]?id|open[_-]?chat[_-]?id|test[_-]?chat[_-]?id|\\b(?:ou|oc|om)_[A-Za-z0-9_-]+\\b|\\bsk-[A-Za-z0-9_-]{8,}\\b)/i.test(value); +} + +function safeLog(item) { + const entry = item && typeof item === "object" ? item : {}; + const timestamp = safeOperationalText(entry.timestamp || entry.time || entry.createdAt || "", "", "时间已脱敏", 40); + const rawMessage = entry.message !== undefined ? entry.message : typeof item === "string" ? item : "无日志消息"; + const message = safeOperationalText(rawMessage, "无日志消息", "日志内容已脱敏", 180); + return (timestamp ? "[" + timestamp + "] " : "") + message; +} +`; +} + +export function calendarAdapterValidationSource(options: { typed?: boolean } = {}): string { + if (options.typed) { + return `import type { CalendarRecord, CalendarTaskRunInput } from "./types.js"; + +export function assertAllowedOperator(operatorOpenId: string | undefined, allowedOperatorOpenIds: readonly string[] | undefined): void { + if (!Array.isArray(allowedOperatorOpenIds) || allowedOperatorOpenIds.length === 0) throw new Error("尚未配置获准操作人,无法执行日历库存操作。请联系管理员完成操作人白名单配置。"); + if (!operatorOpenId || !allowedOperatorOpenIds.includes(operatorOpenId)) throw new Error("当前操作人未获授权,无法执行日历库存操作。请联系管理员确认操作人白名单配置。"); +} + +export function readObject(value: unknown): CalendarRecord { + return value && typeof value === "object" && !Array.isArray(value) ? value as CalendarRecord : {}; +} + +export function calendarTaskInput(value: unknown, mode: string): CalendarTaskRunInput { + const input = readObject(value); + const text = (key: string): string => typeof input[key] === "string" || typeof input[key] === "number" ? String(input[key]).trim() : ""; + const targetDate = text("targetDate"); + const stock = text("stock"); + const stepDelayMs = text("stepDelayMs") || "500"; + const datePickerDelayMs = text("datePickerDelayMs") || "500"; + const startProductId = text("startProductId"); + const endProductId = text("endProductId"); + if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(targetDate)) throw new Error("目标日期必须是 YYYY-MM-DD。"); + if (!/^\\d+$/.test(stock) || Number(stock) <= 0) throw new Error("库存必须是正整数。"); + for (const [name, candidate] of [["普通操作停顿", stepDelayMs], ["日期组件停顿", datePickerDelayMs]] as const) { + if (!/^\\d+$/.test(candidate) || Number(candidate) > 10000) throw new Error(name + " 必须是 0 到 10000 的整数。"); + } + for (const [name, candidate] of [["开始商品 ID", startProductId], ["结束商品 ID", endProductId]] as const) { + if (candidate && (!/^\\d+$/.test(candidate) || Number(candidate) <= 0)) throw new Error(name + " 必须是正整数。"); + } + if (startProductId && endProductId && Number(startProductId) < Number(endProductId)) throw new Error("结束商品 ID 不能大于开始商品 ID。"); + return { mode, targetDate, stock, stepDelayMs, datePickerDelayMs, startProductId, endProductId }; +} +`; + } + return `export function assertAllowedOperator(operatorOpenId, allowedOperatorOpenIds) { + if (!Array.isArray(allowedOperatorOpenIds) || allowedOperatorOpenIds.length === 0) throw new Error("尚未配置获准操作人,无法执行日历库存操作。请联系管理员完成操作人白名单配置。"); + if (!operatorOpenId || !allowedOperatorOpenIds.includes(operatorOpenId)) throw new Error("当前操作人未获授权,无法执行日历库存操作。请联系管理员确认操作人白名单配置。"); +} + +export function readObject(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +export function calendarTaskInput(value, mode) { + const input = readObject(value); + const text = (key) => typeof input[key] === "string" || typeof input[key] === "number" ? String(input[key]).trim() : ""; + const targetDate = text("targetDate"); + const stock = text("stock"); + const stepDelayMs = text("stepDelayMs") || "500"; + const datePickerDelayMs = text("datePickerDelayMs") || "500"; + const startProductId = text("startProductId"); + const endProductId = text("endProductId"); + if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(targetDate)) throw new Error("目标日期必须是 YYYY-MM-DD。"); + if (!/^\\d+$/.test(stock) || Number(stock) <= 0) throw new Error("库存必须是正整数。"); + for (const [name, candidate] of [["普通操作停顿", stepDelayMs], ["日期组件停顿", datePickerDelayMs]]) { + if (!/^\\d+$/.test(candidate) || Number(candidate) > 10000) throw new Error(name + " 必须是 0 到 10000 的整数。"); + } + for (const [name, candidate] of [["开始商品 ID", startProductId], ["结束商品 ID", endProductId]]) { + if (candidate && (!/^\\d+$/.test(candidate) || Number(candidate) <= 0)) throw new Error(name + " 必须是正整数。"); + } + if (startProductId && endProductId && Number(startProductId) < Number(endProductId)) throw new Error("结束商品 ID 不能大于开始商品 ID。"); + return { mode, targetDate, stock, stepDelayMs, datePickerDelayMs, startProductId, endProductId }; +} +`; +} + +export function calendarAdapterServiceClientSource(options: { typed?: boolean } = {}): string { + if (options.typed) { + return `import type { CalendarRecord } from "./types.js"; + +export async function callCalendar(baseUrl: string, method: string, path: string, body: CalendarRecord | null = null, timeoutMs = 30000): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(String(baseUrl || "").replace(/\\/+$/, "") + path, { + method, + signal: controller.signal, + ...(method === "GET" ? {} : { headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify(body || {}) }), + }); + const text = await response.text(); + const result = parseCalendarResponse(text); + if (!response.ok) throw new Error("目标服务请求失败:" + method + " " + path + " 返回 HTTP " + response.status + "。"); + return result && typeof result === "object" && !Array.isArray(result) ? result as CalendarRecord : { value: result }; + } finally { + clearTimeout(timeout); + } +} + +function parseCalendarResponse(text: string): unknown { + try { return text ? JSON.parse(text) as unknown : {}; } catch { return { text }; } +} + +`; + } + return `export async function callCalendar(baseUrl, method, path, body, timeoutMs = 30000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(String(baseUrl || "").replace(/\\/+$/, "") + path, { + method, + signal: controller.signal, + ...(method === "GET" ? {} : { headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify(body || {}) }), + }); + const text = await response.text(); + let result; + try { result = text ? JSON.parse(text) : {}; } catch { result = { text }; } + if (!response.ok) throw new Error("目标服务请求失败:" + method + " " + path + " 返回 HTTP " + response.status + "。"); + return result && typeof result === "object" ? result : { value: result }; + } finally { + clearTimeout(timeout); + } +} +`; +} + +export function calendarAdapterHandlersSource(options: { typed?: boolean } = {}): string { + if (options.typed) { + return `import { assertAllowedOperator, calendarTaskInput, readObject } from "./validation.js"; +import { callCalendar } from "./service-client.js"; +import { buildFailureCard, buildOperationsCard, buildRunConfirmationCard, buildStopConfirmationCard } from "./cards.js"; +import type { AdapterActionContext, AdapterAuditEvent, AdapterDependencies, AdapterResult, CalendarRecord, CalendarTaskRunInput, CalendarTaskState } from "./types.js"; + +const actions = ${JSON.stringify(CALENDAR_STOCK_UPDATER_PROFILE.actions, null, 2)} as const; +const confirmations = new Map(); +const confirmationTtlMs = 5 * 60 * 1000; +const audit = (event: string, detail: CalendarRecord = {}): AdapterAuditEvent => ({ event, detail }); + +type ConfirmationKind = "run" | "stop"; +interface ConfirmationBase { id: string; kind: ConfirmationKind; operatorOpenId: string; expiresAt: number; } +interface RunConfirmation extends ConfirmationBase { kind: "run"; input: CalendarTaskRunInput; } +interface StopConfirmation extends ConfirmationBase { kind: "stop"; task: CalendarRecord; taskIdentity: string; } +type CalendarConfirmation = RunConfirmation | StopConfirmation; + +export async function handleCardAction(ctx: AdapterActionContext = { action: "" }, deps: AdapterDependencies = { targetBaseUrl: "" }): Promise { + const action = String(ctx.action || "").replace(/[\\r\\n]+/g, " ").slice(0, 120); + const auditEvents = [audit("calendar_card_action_received", { action, operator_open_id: ctx.operatorOpenId || "", chat_id: ctx.openChatId || "" })]; + try { + const value = { ...readObject(ctx.value), ...readObject(ctx.formValue) }; + const baseUrl = String(deps.targetBaseUrl || ""); + assertAllowedOperator(ctx.operatorOpenId, deps.allowedOperatorOpenIds); + if (action === actions.refresh) { + const current = await state(); + return success(buildOperationsCard(current), current); + } + if (action === actions.runCancel || action === actions.stopCancel) { + const confirmationId = String(value.confirmationId || ""); + if (confirmationId) cancelConfirmation(confirmationId, ctx.operatorOpenId); + const current = await state(); + return success(buildOperationsCard(current), current); + } + if (action === actions.dryRun) { + const result = await callCalendar(baseUrl, "POST", "/api/run", calendarTaskInput(value, "dry-run"), deps.timeoutMs); + const current = await state(); + return success(buildOperationsCard(current), result); + } + if (action === actions.runPrepare) { + const input = calendarTaskInput(value, "run"); + const current = await state(); + const task = readObject(current.task); + if (task.status === "running") throw new Error("已有任务正在运行,请等待当前任务结束。"); + const confirmation = createRunConfirmation(ctx.operatorOpenId, input); + return success(buildRunConfirmationCard({ confirmationId: confirmation.id, input }), { confirmationId: confirmation.id, input }); + } + if (action === actions.runConfirm) { + const confirmation = consumeConfirmation(String(value.confirmationId || ""), "run", ctx.operatorOpenId); + const result = await callCalendar(baseUrl, "POST", "/api/run", confirmation.input, deps.timeoutMs); + const current = await state(); + return success(buildOperationsCard(current), result); + } + if (action === actions.stopPrepare) { + const current = await state(); + const task = readObject(current.task); + if (task.status !== "running") throw new Error("当前没有运行中的任务。"); + const taskIdentity = identifyTask(task); + if (!taskIdentity) throw new Error("当前任务缺少可确认的身份信息。"); + const confirmation = createStopConfirmation(ctx.operatorOpenId, task, taskIdentity); + return success(buildStopConfirmationCard({ confirmationId: confirmation.id, task }), { confirmationId: confirmation.id, task }); + } + if (action === actions.stopConfirm) { + const confirmation = consumeConfirmation(String(value.confirmationId || ""), "stop", ctx.operatorOpenId); + const current = await state(); + const task = readObject(current.task); + if (task.status !== "running" || identifyTask(task) !== confirmation.taskIdentity) throw new Error("待停止任务已变化,请重新申请确认。"); + const result = await callCalendar(baseUrl, "POST", "/api/stop", {}, deps.timeoutMs); + const updated = await state(); + return success(buildOperationsCard(updated), result); + } + throw new Error("不支持的日历库存卡片操作。"); + + async function state(): Promise { + return callCalendar(baseUrl, "GET", "/api/state", null, deps.timeoutMs) as Promise; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + auditEvents.push(audit("calendar_card_action_failed", { action, message })); + return { ok: false, card: buildFailureCard(message), auditEvents }; + } + + function success(card: CalendarRecord, result: CalendarRecord): AdapterResult { + auditEvents.push(audit("calendar_card_action_succeeded", { action })); + return { ok: true, card, result, auditEvents }; + } +} + +function createRunConfirmation(operatorOpenId: string | undefined, input: CalendarTaskRunInput): RunConfirmation { + purgeExpiredConfirmations(); + const confirmation: RunConfirmation = { id: createConfirmationId(), kind: "run", operatorOpenId: String(operatorOpenId || ""), expiresAt: Date.now() + confirmationTtlMs, input }; + confirmations.set(confirmation.id, confirmation); + return confirmation; +} + +function createStopConfirmation(operatorOpenId: string | undefined, task: CalendarRecord, taskIdentity: string): StopConfirmation { + purgeExpiredConfirmations(); + const confirmation: StopConfirmation = { id: createConfirmationId(), kind: "stop", operatorOpenId: String(operatorOpenId || ""), expiresAt: Date.now() + confirmationTtlMs, task, taskIdentity }; + confirmations.set(confirmation.id, confirmation); + return confirmation; +} + +function purgeExpiredConfirmations(): void { + for (const [id, confirmation] of confirmations) if (confirmation.expiresAt < Date.now()) confirmations.delete(id); +} + +function createConfirmationId(): string { + return globalThis.crypto.randomUUID(); +} + +function consumeConfirmation(id: string, kind: K, operatorOpenId: string | undefined): Extract { + const confirmation = confirmations.get(id); + if (!confirmation || confirmation.kind !== kind) throw new Error("确认已失效或类型不匹配。"); + if (confirmation.expiresAt < Date.now()) { confirmations.delete(id); throw new Error("确认已过期。"); } + if (!operatorOpenId || confirmation.operatorOpenId !== String(operatorOpenId)) throw new Error("确认操作人与申请人不一致。"); + confirmations.delete(id); + return confirmation as Extract; +} + +function cancelConfirmation(id: string, operatorOpenId: string | undefined): void { + const confirmation = confirmations.get(id); + if (!confirmation) return; + if (!operatorOpenId || confirmation.operatorOpenId !== String(operatorOpenId)) throw new Error("确认操作人与申请人不一致。"); + confirmations.delete(id); +} + +function identifyTask(task: CalendarRecord): string { + return String(task.taskId || task.pid || task.startedAt || ""); +} + +export const handleCalendarStockCardAction = handleCardAction; +`; + } + return `import { randomUUID } from "node:crypto"; +import { assertAllowedOperator, calendarTaskInput, readObject } from "./validation.js"; +import { callCalendar } from "./service-client.js"; +import { buildFailureCard, buildOperationsCard, buildRunConfirmationCard, buildStopConfirmationCard } from "./cards.js"; + +const actions = ${JSON.stringify(CALENDAR_STOCK_UPDATER_PROFILE.actions, null, 2)}; +const confirmations = new Map(); +const confirmationTtlMs = 5 * 60 * 1000; +const audit = (event, detail = {}) => ({ event, detail }); + +export async function handleCardAction(ctx = {}, deps = {}) { + const action = String(ctx.action || "").replace(/[\\r\\n]+/g, " ").slice(0, 120); + const auditEvents = [audit("calendar_card_action_received", { action, operator_open_id: ctx.operatorOpenId || "", chat_id: ctx.openChatId || "" })]; + try { + const value = { ...readObject(ctx.value), ...readObject(ctx.formValue) }; + const baseUrl = String(deps.targetBaseUrl || ""); + assertAllowedOperator(ctx.operatorOpenId, deps.allowedOperatorOpenIds); + if (action === actions.refresh) { + const current = await state(); + return success(buildOperationsCard(current), current); + } + if (action === actions.runCancel || action === actions.stopCancel) { + const confirmationId = String(value.confirmationId || ""); + if (confirmationId) cancelConfirmation(confirmationId, ctx.operatorOpenId); + const current = await state(); + return success(buildOperationsCard(current), current); + } + if (action === actions.dryRun) { + const result = await callCalendar(baseUrl, "POST", "/api/run", calendarTaskInput(value, "dry-run"), deps.timeoutMs); + const current = await state(); + return success(buildOperationsCard(current), result); + } + if (action === actions.runPrepare) { + const input = calendarTaskInput(value, "run"); + const current = await state(); + if (current.task && current.task.status === "running") throw new Error("已有任务正在运行,请等待当前任务结束。"); + const confirmation = createConfirmation("run", ctx.operatorOpenId, { input }); + return success(buildRunConfirmationCard({ confirmationId: confirmation.id, input }), { confirmationId: confirmation.id, input }); + } + if (action === actions.runConfirm) { + const confirmation = consumeConfirmation(String(value.confirmationId || ""), "run", ctx.operatorOpenId); + const result = await callCalendar(baseUrl, "POST", "/api/run", confirmation.input, deps.timeoutMs); + const current = await state(); + return success(buildOperationsCard(current), result); + } + if (action === actions.stopPrepare) { + const current = await state(); + if (!current.task || current.task.status !== "running") throw new Error("当前没有运行中的任务。"); + const taskIdentity = identifyTask(current.task); + if (!taskIdentity) throw new Error("当前任务缺少可确认的身份信息。"); + const confirmation = createConfirmation("stop", ctx.operatorOpenId, { task: current.task, taskIdentity }); + return success(buildStopConfirmationCard({ confirmationId: confirmation.id, task: current.task }), { confirmationId: confirmation.id, task: current.task }); + } + if (action === actions.stopConfirm) { + const confirmation = consumeConfirmation(String(value.confirmationId || ""), "stop", ctx.operatorOpenId); + const current = await state(); + if (!current.task || current.task.status !== "running" || identifyTask(current.task) !== confirmation.taskIdentity) throw new Error("待停止任务已变化,请重新申请确认。"); + const result = await callCalendar(baseUrl, "POST", "/api/stop", {}, deps.timeoutMs); + const updated = await state(); + return success(buildOperationsCard(updated), result); + } + throw new Error("不支持的日历库存卡片操作。"); + + async function state() { + return callCalendar(baseUrl, "GET", "/api/state", null, deps.timeoutMs); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + auditEvents.push(audit("calendar_card_action_failed", { action, message })); + return { ok: false, card: buildFailureCard(message), auditEvents }; + } + + function success(card, result) { + auditEvents.push(audit("calendar_card_action_succeeded", { action })); + return { ok: true, card, result, auditEvents }; + } +} + +function createConfirmation(kind, operatorOpenId, detail) { + for (const [id, confirmation] of confirmations) if (confirmation.expiresAt < Date.now()) confirmations.delete(id); + const id = randomUUID(); + const confirmation = { id, kind, operatorOpenId: String(operatorOpenId || ""), expiresAt: Date.now() + confirmationTtlMs, ...detail }; + confirmations.set(id, confirmation); + return confirmation; +} + +function consumeConfirmation(id, kind, operatorOpenId) { + const confirmation = confirmations.get(id); + if (!confirmation || confirmation.kind !== kind) throw new Error("确认已失效或类型不匹配。"); + if (confirmation.expiresAt < Date.now()) { confirmations.delete(id); throw new Error("确认已过期。"); } + if (!operatorOpenId || confirmation.operatorOpenId !== String(operatorOpenId)) throw new Error("确认操作人与申请人不一致。"); + confirmations.delete(id); + return confirmation; +} + +function cancelConfirmation(id, operatorOpenId) { + const confirmation = confirmations.get(id); + if (!confirmation) return; + if (!operatorOpenId || confirmation.operatorOpenId !== String(operatorOpenId)) throw new Error("确认操作人与申请人不一致。"); + confirmations.delete(id); +} + +function identifyTask(task) { + return String(task && (task.taskId || task.pid || task.startedAt) || ""); +} + +export const handleCalendarStockCardAction = handleCardAction; +`; +} + +function calendarTaskInputSchema(): Record { + return { + type: "object", + required: ["mode", "targetDate", "stock"], + properties: { + mode: { type: "string", enum: ["dry-run", "run"] }, + targetDate: { type: "string", description: "Target date in YYYY-MM-DD." }, + stock: { type: "string", description: "Positive inventory integer." }, + stepDelayMs: { type: "string", default: "500" }, + datePickerDelayMs: { type: "string", default: "500" }, + startProductId: { type: "string" }, + endProductId: { type: "string" }, + }, + additionalProperties: false, + }; +} From 9add63bf5be992f2e44441158a3d5abb7cbf4f78 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:47:40 +0800 Subject: [PATCH 28/37] feat: generate calendar Mode B packages Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/generate.ts | 432 ++++++++++++++- src/profiles/calendar-stock-updater-mode-b.ts | 524 ++++++++++++++++++ 2 files changed, 935 insertions(+), 21 deletions(-) create mode 100644 src/profiles/calendar-stock-updater-mode-b.ts diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 270367d..3574135 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -6,6 +6,8 @@ import { copyFileIfExists, ensureDir, readJsonFile, slugify, writeJson, writeTex import { buildFormFieldMaps } from "../field-mapping.js"; import { hostModeUsesLongConnection, hostModeUsesWebhook, normalizeHostReceiveMode, type HostReceiveMode, type IntegrationMode } from "../host-mode.js"; import { adapterCardsJs, adapterCardsTs, adapterHandlersJs, adapterHandlersTs, adapterServiceClientJs, adapterServiceClientTs, buildAdapterCardTemplateData, buildPythonHostEndpointsSpec, buildStartCardSpec, pythonHostCardsPy, pythonHostHandlersPy, pythonHostLocalContractTestPy, pythonHostServiceClientPy, runtimeCardsTs, runtimeImageAgentClientTs, runtimeIndexTs, type ImageAgentMeta, type RuntimeFieldSpec } from "../profiles/image-agent-web.js"; +import { calendarAdapterCardsSource, calendarAdapterHandlersSource, calendarAdapterServiceClientSource, calendarAdapterValidationSource } from "../profiles/calendar-stock-updater.js"; +import { writeCalendarModeBModule } from "../profiles/calendar-stock-updater-mode-b.js"; import type { CapabilityMap, InteractionContract, RequiredPermissions, ServiceManifest } from "../types.js"; import { buildDeploymentChecklist } from "./plan.js"; @@ -24,7 +26,7 @@ export async function generateCommand(args: string[], options: Record(path.join(manifestDir, "service_manifest.json")); const capabilities = readJsonFile(path.join(manifestDir, "capability_map.json")); const interactions = readJsonFile(path.join(manifestDir, "interaction_contract.json")); - const permissions = readJsonFile(path.join(manifestDir, "required_permissions.json")); + const analysisPermissions = readJsonFile(path.join(manifestDir, "required_permissions.json")); const meta = readOptionalJson(path.join(manifestDir, "image_agent_meta.snapshot.json")); const targetProfile = capabilities.target_profile || "image-agent-web"; const defaultOut = path.resolve("generated", `${slugify(service.service.name)}-lark`); @@ -32,8 +34,12 @@ export async function generateCommand(args: string[], options: Record interaction.action_id), + }); } else { writeText(path.join(adapterDir, "types.ts"), adapterTypesTs()); writeText(path.join(adapterDir, "audit-events.ts"), adapterAuditEventsTs()); @@ -109,6 +125,9 @@ export async function generateCommand(args: string[], options: Record = {}): { event: string; detail: Record } { return { event, detail }; }\n`; + const types = `export type CalendarRecord = Record;\nexport type CalendarCardElement = Record;\nexport type CalendarCard = Record;\nexport interface CalendarTaskState extends CalendarRecord { defaults?: CalendarRecord; task?: CalendarRecord; logs?: unknown[]; }\nexport interface CalendarTaskRunInput extends CalendarRecord { mode: string; targetDate: string; stock: string; stepDelayMs: string; datePickerDelayMs: string; startProductId: string; endProductId: string; }\nexport interface AdapterActionContext { action: string; value?: CalendarRecord; formValue?: CalendarRecord; operatorOpenId?: string; openMessageId?: string; openChatId?: string; }\nexport interface AdapterDependencies { targetBaseUrl: string; timeoutMs?: number; allowedOperatorOpenIds?: readonly string[]; }\nexport interface AdapterAuditEvent { event: string; detail: CalendarRecord; }\nexport interface AdapterResult { ok: boolean; card: CalendarCard; result?: CalendarRecord; auditEvents: AdapterAuditEvent[]; }\n`; + writeText(path.join(adapterDir, "types.ts"), types); + writeText(path.join(adapterDir, "audit-events.ts"), audit); + writeText(path.join(adapterDir, "validation.ts"), validationTs); + writeText(path.join(adapterDir, "service-client.ts"), clientTs); + writeText(path.join(adapterDir, "cards.ts"), cardsTs); + writeText(path.join(adapterDir, "handlers.ts"), handlersTs); + writeText(path.join(adapterDir, "audit-events.js"), "export function auditEvent(event, detail = {}) { return { event, detail }; }\n"); + writeText(path.join(adapterDir, "validation.js"), validationJs); + writeText(path.join(adapterDir, "service-client.js"), clientJs); + writeText(path.join(adapterDir, "cards.js"), cardsJs); + writeText(path.join(adapterDir, "handlers.js"), handlersJs); + writeText(path.join(adapterDir, "handlers.d.ts"), `export function handleCardAction(ctx: Record, deps: Record): Promise>;\nexport const handleCalendarStockCardAction: typeof handleCardAction;\n`); + writeText(path.join(adapterDir, "service-client.d.ts"), `export function callCalendar(baseUrl: string, method: string, path: string, body?: Record, timeoutMs?: number): Promise>;\n`); +} + +function usesHttpAdapter(targetProfile: string): boolean { + return targetProfile === "generic-http-api" || targetProfile === "calendar-stock-updater"; +} + function writeGenericAdapterFiles(adapterDir: string, service: ServiceManifest, capabilities: CapabilityMap, interactions: InteractionContract, hostReceiveMode: HostReceiveMode): void { writeText(path.join(adapterDir, "types.ts"), genericAdapterTypesTs()); writeText(path.join(adapterDir, "audit-events.ts"), adapterAuditEventsTs()); @@ -170,6 +219,24 @@ function writeGenericAdapterFiles(adapterDir: string, service: ServiceManifest, } function sidecarLongConnectionReadme(service: ServiceManifest, hostReceiveMode: HostReceiveMode, targetProfile: string): string { + if (targetProfile === "calendar-stock-updater") { + return `# Calendar Stock Updater Long-Connection Host Contract + +This generated calendar profile is an adapter for a Feishu SDK long-connection host. It does not manage the calendar process lifecycle. + +- Event subscription: \`card.action.trigger\` +- Required receive mode: \`${hostReceiveMode}\` +- Target: \`TARGET_BASE_URL=${service.service.base_url || "http://127.0.0.1:3069"}\` + +The host must normalize the event context and call \`handleCardAction()\` from \`adapter/handlers.js\`. Use the generated state-aware card builders without rebuilding business card JSON by hand. The profile exposes typed calendar task cards, host-local formal-run/stop confirmations, and state refresh; it intentionally does not expose the browser-only \`/api/events\` SSE endpoint. + +Run the local contract check from the package root: + +\`\`\`powershell +node sidecar-long-connection/local-contract-test.mjs +\`\`\` +`; + } if (targetProfile === "generic-http-api") { return `# Sidecar Long-Connection Gateway Starter @@ -233,6 +300,39 @@ The script starts an in-process mock \`${service.service.name}\` HTTP API and pr } function sidecarLocalContractTestMjs(service: ServiceManifest, capabilities: CapabilityMap, interactions: InteractionContract, targetProfile: string): string { + if (targetProfile === "calendar-stock-updater") { + return `import http from "node:http"; +import { buildStartCard } from "../adapter/cards.js"; +import { handleCardAction } from "../adapter/handlers.js"; + +const requests = []; +const server = http.createServer((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + requests.push({ method: req.method, url: req.url, body }); + res.writeHead(200, { "content-type": "application/json" }); + if (req.method === "GET" && req.url === "/api/state") return res.end(JSON.stringify({ defaults: { targetDate: "2026-10-01", stock: "100", stepDelayMs: "500", datePickerDelayMs: "500" }, task: { status: "idle", currentMessage: "ready" }, logs: [] })); + if (req.method === "POST" && req.url === "/api/run") return res.end(JSON.stringify({ ok: true, task: { status: "running", currentMessage: "preview started" } })); + if (req.method === "POST" && req.url === "/api/stop") return res.end(JSON.stringify({ ok: true, task: { status: "stopped", currentMessage: "stopped" } })); + res.end(JSON.stringify({ error: "not found" })); + }); +}); +const listen = () => new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const close = () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +try { + await listen(); + const targetBaseUrl = "http://127.0.0.1:" + server.address().port; + const startCard = buildStartCard(); + if (!JSON.stringify(startCard).includes("calendar.task.dry-run")) throw new Error("calendar start card is missing typed action"); + if (JSON.stringify(startCard).includes("body_json") || JSON.stringify(startCard).includes("/api/events")) throw new Error("calendar card exposed generic or SSE controls"); + const result = await handleCardAction({ action: "calendar.status.refresh", operatorOpenId: "sidecar-contract-operator" }, { targetBaseUrl, timeoutMs: 5000, allowedOperatorOpenIds: ["sidecar-contract-operator"] }); + if (!result.ok || !requests.some((item) => item.method === "GET" && item.url === "/api/state")) throw new Error("calendar refresh contract failed"); + console.log("sidecar-long-connection calendar contract: PASS for ${service.service.name}"); +} finally { await close(); } +`; + } if (targetProfile === "generic-http-api") { const specs = buildGenericActionSpecs(capabilities, interactions); return `import http from "node:http"; @@ -242,6 +342,7 @@ import { handleGenericHttpCardAction } from "../adapter/handlers.js"; const actionSpecs = ${JSON.stringify(specs, null, 2)}; const actionSpec = actionSpecs.find((item) => item.method === "POST") || actionSpecs[0]; if (!actionSpec) throw new Error("No generic HTTP action specs were generated."); +const expectedPath = actionSpec.path.replace(/\{[^}]+\}/g, "contract-ticket"); const requests = []; const server = http.createServer((req, res) => { @@ -250,9 +351,9 @@ const server = http.createServer((req, res) => { req.on("end", () => { const body = Buffer.concat(chunks).toString("utf8"); requests.push({ method: req.method, url: req.url, body }); - if (req.url && req.url.startsWith("/api/tickets")) { + if (req.url && req.url.startsWith(expectedPath)) { res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ ok: true, ticket_id: "contract-ticket", trace_id: "trace-generic-sidecar" })); + res.end(JSON.stringify({ ok: true, path: expectedPath, trace_id: "trace-generic-sidecar" })); return; } res.writeHead(404, { "content-type": "application/json" }); @@ -283,7 +384,7 @@ try { allowedOperatorOpenIds: [], }); - const targetRequest = requests.find((item) => item.url && item.url.startsWith("/api/tickets")); + const targetRequest = requests.find((item) => item.url && item.url.startsWith(expectedPath)); if (!targetRequest) throw new Error("generic adapter did not call the mock target endpoint"); if (!result.ok) throw new Error("generic adapter returned failure: " + JSON.stringify(result.card)); if (!JSON.stringify(result.card).includes("trace-generic-sidecar")) throw new Error("success card did not include target JSON result"); @@ -294,8 +395,9 @@ try { function buildGenericFormValue(spec) { const formValue = {}; + const pathParamNames = new Set(Array.from(spec.path.matchAll(/\{([^}]+)\}/g), (match) => match[1])); for (const input of spec.inputs || []) { - if (input.name === "ticket_id") formValue[input.name] = "contract-ticket"; + if (pathParamNames.has(input.name)) formValue[input.name] = "contract-ticket"; else if (input.name === "body_json") formValue[input.name] = JSON.stringify({ title: "Sidecar contract ticket" }); else formValue[input.name] = "sidecar-contract-value"; } @@ -870,6 +972,11 @@ const LEVEL2_RECORD_PRESERVE_FIELDS = [ "Batch ID", "Batch status card message ID or screenshot", "Batch download URL or screenshot", + "Status result message ID or screenshot", + "Dry-run result message ID or screenshot", + "Formal-run confirmation/result message IDs or screenshots", + "Stop confirmation/result message IDs or screenshots", + "Sanitized host log path", "Trace ID", "Notes", ]; @@ -920,6 +1027,7 @@ function writePackageContext( permissions: RequiredPermissions, integrationMode: IntegrationMode, hostReceiveMode: HostReceiveMode, + targetProfile: string, ): void { const sourceContext = readOptionalJson>(path.join(workspace, "feishu_context.template.json")); const mergedContext = mergeContextValues( @@ -928,6 +1036,7 @@ function writePackageContext( packageRootCliPath: toCliPath(path.relative(outDir, path.resolve("dist", "index.js"))), integrationMode, hostReceiveMode, + targetProfile, }), sourceContext, ); @@ -949,7 +1058,7 @@ function writePackageContext( function mergeContextValues(base: ContextTemplate, source: Partial | undefined): ContextTemplate { if (!source) return base; - return { + const merged = { ...base, target_service: { ...base.target_service, @@ -964,10 +1073,11 @@ function mergeContextValues(base: ContextTemplate, source: Partial !/callback verification token|verification token|public callback|webhook/i.test(item)) + .concat("Existing Feishu SDK long-connection host subscribed to card.action.trigger."), + callbacks: permissions.callbacks + .filter((callback) => callback.callback === "card.action.trigger") + .map((callback) => ({ + ...callback, + reason: "Receive interactive card button clicks through Feishu SDK long connection.", + security: ["long_connection"], + })), + manual_steps: permissions.manual_steps.map((item) => ( + /callback handling/i.test(item) + ? "Subscribe the existing Feishu SDK long-connection host to card.action.trigger." + : item + )), + }; +} + function generatedPackageGitignore(): string { return `# Local secrets and generated runtime artifacts bot-runtime/.env @@ -1074,15 +1226,52 @@ audit.log `; } -function buildLevel2ManualEvidenceTemplate(service: ServiceManifest, targetProfile: string): Record { +function buildLevel2ManualEvidenceTemplate(service: ServiceManifest, targetProfile: string, hostReceiveMode: HostReceiveMode): Record { + if (targetProfile === "calendar-stock-updater") { + return { + schema_version: "0.1", + purpose: "Copy this file to level2_manual_evidence.local.json and record sanitized real Feishu observations for the calendar Mode B module.", + target_service: service.service.name, + target_profile: targetProfile, + instructions: [ + "Do not paste FEISHU_APP_SECRET, target login credentials, raw operator IDs, or private task logs here.", + "Record message IDs or sanitized screenshot paths for status refresh, dry-run, formal-run confirmation/result, and stop confirmation/result.", + "Record only the target method/path and redacted field names; do not include inventory credentials or private response bodies.", + "This helper never marks Level 2 complete; the human verifier still decides final sign-off.", + ], + values: { + date: "", + operator: "", + feishu_app_name: "", + test_chat: "", + start_message_id: "", + status_result_message_id: "", + status_screenshot: "", + dry_run_result_message_id: "", + dry_run_screenshot: "", + run_confirmation_message_id: "", + run_result_message_id: "", + run_result_screenshot: "", + stop_confirmation_message_id: "", + stop_result_message_id: "", + stop_result_screenshot: "", + sanitized_host_log_path: "", + trace_id: "", + notes: "", + }, + }; + } if (targetProfile === "generic-http-api") { + const longConnection = hostReceiveMode === "embedded-long-connection"; return { schema_version: "0.1", purpose: "Copy this file to level2_manual_evidence.local.json and fill real Feishu Level 2 observations for this generic HTTP adapter. The local file is ignored by git and sanitized handoff.", target_service: service.service.name, target_profile: targetProfile, instructions: [ - "Do not paste APP_SECRET, VERIFICATION_TOKEN, ENCRYPT_KEY, DEBUG_ACCESS_TOKEN, target API tokens, or private response payloads here.", + longConnection + ? "Do not paste APP_SECRET, ENCRYPT_KEY, DEBUG_ACCESS_TOKEN, target API tokens, or private response payloads here." + : "Do not paste APP_SECRET, VERIFICATION_TOKEN, ENCRYPT_KEY, DEBUG_ACCESS_TOKEN, target API tokens, or private response payloads here.", "Use result_message_id when you can read the Feishu message id; use result_screenshot for a local screenshot path or shared evidence URL.", "Use generic_action_id and target_request_summary to record which generated HTTP adapter action was clicked and which target endpoint was reached without exposing sensitive request data.", "Run `node $env:LARK_DEPLOYER_CLI evidence . --manual-evidence level2_manual_evidence.local.json --update-record` from the generated package root to copy blank fields into level2_verification_record.md.", @@ -1138,6 +1327,55 @@ function buildLevel2ManualEvidenceTemplate(service: ServiceManifest, targetProfi function buildStartHere(service: ServiceManifest, integrationMode: IntegrationMode, hostReceiveMode: HostReceiveMode, targetProfile: string): string { const hostModeOption = integrationMode === "embedded-adapter" && hostReceiveMode !== "embedded-webhook" ? ` --host-mode ${hostReceiveMode}` : ""; + if (targetProfile === "calendar-stock-updater") { + return `# Start Here - Calendar Mode B + +This generated package is the source of truth. It contains an installable, self-contained Node long-connection module at \`integrations/lark/\`; generation did not modify the target project. + +## 1. Validate The Candidate + +\`\`\`powershell +node $env:LARK_DEPLOYER_CLI verify . --mode embedded-adapter --host-mode embedded-long-connection --strict +\`\`\` + +## 2. Review The Zero-Write Install Plan + +Start the existing calendar service with its documented command, then run: + +\`\`\`powershell +node $env:LARK_DEPLOYER_CLI install . --target --target-base-url ${service.service.base_url || "http://127.0.0.1:3069"} +\`\`\` + +The command must report a passing \`GET /api/state\` probe and planned files under \`integrations/lark\` without writing the target. + +## 3. Apply The Isolated Module + +\`\`\`powershell +node $env:LARK_DEPLOYER_CLI install . --target --target-base-url ${service.service.base_url || "http://127.0.0.1:3069"} --apply +cd \\integrations\\lark +npm install +npm test +Copy-Item .env.example .env +\`\`\` + +Fill only the module-local \`.env\`, then run the target service and \`npm start\` in \`integrations/lark\` separately. The target API/UI/root startup files remain unchanged. + +## Target Contract + +- \`GET /api/state\`: health, defaults, status, logs. +- \`POST /api/run\`: dry-run or confirmed formal run. +- \`POST /api/stop\`: confirmed stop. +- Run/stop prepare, confirm, and cancel are host-local card actions, not target endpoints. + +## Completion States + +1. Candidate generated. +2. Install dry-run reviewed. +3. Locally installed and verified. + +Real Feishu Level 2 is a separate state. Do not mark it complete until the app, long connection, test chat, real card clicks, and sanitized evidence in \`level2_verification_record.md\` are complete. +`; + } const usesWebhook = hostModeUsesWebhook(hostReceiveMode); const usesLongConnection = hostModeUsesLongConnection(hostReceiveMode); const level2HostRequirement = usesWebhook && usesLongConnection @@ -1494,7 +1732,7 @@ function genericAdapterServiceClientTs(): string { const text = await response.text(); const parsed = parseJson(text); if (!response.ok) { - throw new Error(method + " " + pathTemplate + " returned HTTP " + response.status + ": " + text); + throw new Error(method + " " + pathTemplate + " returned HTTP " + response.status + "."); } return parsed; } finally { @@ -1533,7 +1771,7 @@ function genericAdapterServiceClientJs(): string { const text = await response.text(); const parsed = parseJson(text); if (!response.ok) { - throw new Error(method + " " + pathTemplate + " returned HTTP " + response.status + ": " + text); + throw new Error(method + " " + pathTemplate + " returned HTTP " + response.status + "."); } return parsed; } finally { @@ -1855,6 +2093,46 @@ function buildEmbeddedIntegrationGuide(service: ServiceManifest, permissions: Re const usesLongConnection = hostModeUsesLongConnection(hostReceiveMode); const hybrid = hostReceiveMode === "hybrid"; const hostModeOption = hostReceiveMode === "embedded-webhook" ? "" : ` --host-mode ${hostReceiveMode}`; + if (targetProfile === "calendar-stock-updater") { + const actions = interactions.interactions.map((interaction) => `- \`${interaction.action_id}\` -> \`${interaction.capability_id}\``).join("\n"); + return `# Calendar Stock Updater Embedded Adapter Guide + +This is a typed business adapter for \`${service.service.name}\`, designed for a target-project Mode B Feishu SDK long-connection host. + +- Required host receive mode: \`${hostReceiveMode}\` +- Feishu event: \`card.action.trigger\` +- Target base URL: \`${service.service.base_url || ""}\` + +## Adapter facade + +\`adapter/cards.ts\` exports \`buildStartCard()\`. \`adapter/handlers.ts\` exports \`handleCardAction()\`. The host passes its normalized Feishu event context and \`targetBaseUrl\`; it must not recreate calendar mapping or cards. + +## Business actions + +${actions} + +The cards mirror the recommended Web console: target date, stock, delay values, and optional product ID range. They never expose \`body_json\`, advanced environment-only modes, endpoint names, or the browser-only \`/api/events\` stream. Formal inventory writes and task stopping use expiring, operator-bound confirmation state inside the generated host before calling the unchanged \`/api/run\` or \`/api/stop\` endpoint. + +## Host shape + +\`\`\`ts +import { handleCardAction } from "./adapter/handlers"; +const result = await handleCardAction({ action, value, formValue, operatorOpenId, openMessageId, openChatId }, { targetBaseUrl, timeoutMs, allowedOperatorOpenIds }); +return result.card; +\`\`\` + +The generated \`integrations/lark\` host owns Feishu SDK initialization, module-local secret/config loading, long-connection lifecycle, idempotency, confirmation state, audit output, and process lifecycle. The adapter owns business input validation, the three discovered target calls, and success/failure cards. + +## Package validation + +\`\`\`powershell +node ..\\..\\dist\\index.js verify . --mode embedded-adapter --host-mode embedded-long-connection --strict +node sidecar-long-connection/local-contract-test.mjs +\`\`\` + +Real Feishu Level 2 remains a manual action: confirm long connection is online, send the generated start card, click a typed card flow, and record sanitized evidence. +`; + } if (targetProfile === "generic-http-api") { const actions = interactions.interactions.map((interaction) => `- \`${interaction.action_id}\` -> \`${interaction.capability_id}\``).join("\n") || "- No card actions were discovered."; return `# Embedded Adapter Integration Guide @@ -2054,8 +2332,40 @@ function buildGeneratedReadme(service: ServiceManifest, permissions: RequiredPer const usesLongConnection = hostModeUsesLongConnection(hostReceiveMode); const hybrid = hostReceiveMode === "hybrid"; const hostModeOption = hostReceiveMode === "embedded-webhook" ? "" : ` --host-mode ${hostReceiveMode}`; + if (targetProfile === "calendar-stock-updater") { + const actions = interactions.interactions.map((interaction) => `- \`${interaction.action_id}\` handles \`${interaction.capability_id}\``).join("\n"); + return `# ${service.service.name} Lark Mode B Candidate Package + +This generated package is the source of truth for a two-stage, isolated Mode B installation. Generation does not write to the target project. + +- Target profile: calendar-stock-updater +- Integration mode: embedded-adapter +- Host receive mode: ${hostReceiveMode} +- Installable closure: \`integrations/lark/\` +- Target calls: \`GET /api/state\`, \`POST /api/run\`, \`POST /api/stop\` + +## Generated Actions + +${actions} + +Run/stop prepare, confirm, and cancel are host-local card actions. They do not add target endpoints. The form mirrors the target Web console and hydrates defaults from \`GET /api/state\`. + +## Validate And Install + +\`\`\`powershell +node $env:LARK_DEPLOYER_CLI verify . --mode embedded-adapter${hostModeOption} --strict +node $env:LARK_DEPLOYER_CLI install . --target +node $env:LARK_DEPLOYER_CLI install . --target --apply +\`\`\` + +The install command defaults to dry-run and may write only \`/integrations/lark\`. See \`integrations/lark/README.md\` for module-local configuration, tests, cleanup, maturity states, and the separate real Feishu Level 2 boundary. +`; + } if (targetProfile === "generic-http-api") { const actions = interactions.interactions.map((interaction) => `- \`${interaction.action_id}\` handles \`${interaction.capability_id}\``).join("\n") || "- No generic HTTP card actions were discovered."; + const contextRequirements = longConnection + ? permissions.context_requirements.filter((item) => !/callback verification token|PUBLIC_CALLBACK_BASE_URL|\/webhook\/card|public callback/i.test(item)) + : permissions.context_requirements; return `# ${service.service.name} Lark Generic HTTP Adapter Package This package was generated by Lark-deployer for a generic HTTP API target. It exposes generic card-action forms that call discovered target endpoints and render structured JSON results. @@ -2064,7 +2374,7 @@ ${deliveryModeModelMarkdown()} ## Boundary -Lark-deployer generated an embeddable adapter package. It does not run or manage the target service lifecycle and this embedded-adapter package does not include a standalone \`bot-runtime/\` host. +Lark-deployer generated an embeddable adapter package. It does not run or manage the target service lifecycle and this embedded-adapter package leaves Feishu ingress to the existing host or sidecar. - Target service: ${service.service.name} - Target base URL: ${service.service.base_url || ""} @@ -2087,7 +2397,7 @@ ${actions} ## Required Context -${permissions.context_requirements.map((item) => `- ${item}`).join("\n")} +${contextRequirements.map((item) => `- ${item}`).join("\n")} ## Package Validation @@ -2577,7 +2887,9 @@ At least one failure path should be observed before considering this package sta ...(usesLongConnection ? ["- [ ] The host routes long-connection card.action.trigger events into `adapter/handlers.ts`."] : []), ].join("\n"); const preflightRows = [ - "- [ ] `GET /api/meta` succeeds from the existing host environment.", + targetProfile === "generic-http-api" + ? "- [ ] The target health/read endpoint selected by analysis succeeds from the existing host environment." + : "- [ ] `GET /api/meta` succeeds from the existing host environment.", "- [ ] `GET /health` succeeds on the existing host.", ...(usesWebhook ? [ "- [ ] `POST /webhook/card` answers a local `url_verification` challenge.", @@ -2592,6 +2904,84 @@ At least one failure path should be observed before considering this package sta `- [ ] \`verify . --mode embedded-adapter${hostModeOption} --host-runtime-url --simulate\` records host health checks and either passes host-owned simulation or records the manual-check warning for the host debug surface.`, "- [ ] `verification_report.md` has no unexpected FAIL checks.", ].join("\n"); + if (targetProfile === "calendar-stock-updater") { + const calendarActions = interactions.interactions.map((interaction) => `- [ ] \`${interaction.action_id}\` was rendered and routed to \`${interaction.capability_id}\`.`).join("\n"); + return `# Level 2 Verification Record - Calendar Mode B + +Use this file only for real Feishu/Lark verification of the installed \`integrations/lark\` module. Local generation and installation evidence alone do not complete Level 2. + +## Environment + +- Date: +- Operator: +- Target service: ${service.service.name} +- Target base URL: ${service.service.base_url || ""} +- Generated package path: +- Installed module path: /integrations/lark +- Feishu app name: +- Test chat: + +## Required Feishu Setup + +- [ ] Bot capability is enabled and the bot is in the test chat. +- [ ] Long connection is enabled and only the new \`card.action.trigger\` callback is subscribed. +- [ ] \`integrations/lark/.env\` contains \`FEISHU_APP_ID\`, \`FEISHU_APP_SECRET\`, \`TEST_CHAT_ID\`, \`TARGET_BASE_URL\`, and a non-empty \`ALLOWED_OPERATOR_OPEN_IDS\`. +- [ ] Secrets were supplied through a secure channel and are absent from shared evidence. + +## Required Scopes And Callbacks + +${scopes} + +${callbacks} + +## Local Preflight + +- [ ] Strict package verification passes. +- [ ] Install dry-run passes while writing no target files. +- [ ] Install apply writes only \`integrations/lark\`. +- [ ] \`npm test\` passes inside the installed module. +- [ ] The existing calendar service responds to \`GET /api/state\`. +- [ ] Host logs show the SDK long connection online without exposing operator/chat IDs or secrets. + +## Card And Target Evidence + +${calendarActions} +- [ ] The start card renders target date, stock, two delay fields, and optional start/end product IDs using defaults from \`GET /api/state\`. +- [ ] Status refresh calls only \`GET /api/state\` and shows bounded recent logs. +- [ ] Ordinary preview calls \`POST /api/run\` with \`mode=dry-run\`. +- [ ] Formal run first shows a host-local confirmation card; confirmation calls \`POST /api/run\` with \`mode=run\` exactly once. +- [ ] Stop first shows a host-local confirmation bound to the active task; confirmation calls \`POST /api/stop\` exactly once. +- [ ] Cancel actions return to current state without calling a target write endpoint. +- [ ] No request is made to a target prepare, confirm, cancel, or \`/api/events\` endpoint. + +## Failure And Replay Evidence + +- [ ] Invalid form values return a red failure card before a target write. +- [ ] An unapproved operator cannot preview, run, or stop a task. +- [ ] A wrong-operator, expired, reused, or mismatched confirmation cannot call the target. +- [ ] A repeated callback/event does not execute run or stop twice. +- [ ] Target unavailability returns a readable failure and install remains blocked. + +## Artifacts + +- Verification report path: +- Module test output path: +- Start card message ID: +- Status result message ID or screenshot: +- Dry-run result message ID or screenshot: +- Formal-run confirmation/result message IDs or screenshots: +- Stop confirmation/result message IDs or screenshots: +- Sanitized host log path: +- Trace ID: +- Notes: + +## Completion Decision + +- [ ] Level 2 verified. +- [ ] Remaining issues documented. +- [ ] This generated package can be handed to another FDE using \`README.md\`, \`deployment_checklist.md\`, and this file. +`; + } if (targetProfile === "generic-http-api") { const genericActions = interactions.interactions.map((interaction) => `- [ ] \`${interaction.action_id}\` was rendered in the start card and routed to \`${interaction.capability_id}\`.`).join("\n") || "- [ ] At least one generated generic HTTP action is present in the start card."; return `# Level 2 Verification Record diff --git a/src/profiles/calendar-stock-updater-mode-b.ts b/src/profiles/calendar-stock-updater-mode-b.ts new file mode 100644 index 0000000..653103a --- /dev/null +++ b/src/profiles/calendar-stock-updater-mode-b.ts @@ -0,0 +1,524 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { ensureDir, writeJson, writeText } from "../fs-utils.js"; +import type { ServiceManifest } from "../types.js"; + +interface InstallManifestFile { + path: string; + sha256: string; + size: number; +} + +export function writeCalendarModeBModule(packageDir: string, service: ServiceManifest): void { + const moduleDir = path.join(packageDir, "integrations", "lark"); + const generatedDir = path.join(moduleDir, "generated"); + if (fs.existsSync(moduleDir)) fs.rmSync(moduleDir, { recursive: true, force: true }); + ensureDir(moduleDir); + + writeText(path.join(moduleDir, "app.js"), calendarModeBAppSource()); + writeText(path.join(moduleDir, "config.js"), calendarModeBConfigSource()); + writeText(path.join(moduleDir, "host.js"), calendarModeBHostSource()); + writeJson(path.join(moduleDir, "package.json"), { + name: `${service.service.name}-lark-integration`, + version: "0.1.0", + private: true, + type: "module", + scripts: { + start: "node app.js", + test: "node --test *.test.mjs generated/sidecar-long-connection/local-contract-test.mjs", + }, + dependencies: { + "@larksuiteoapi/node-sdk": "1.71.1", + }, + }); + writeText(path.join(moduleDir, ".gitignore"), ".env\nnode_modules/\n.code2lark-install.json\n"); + writeText(path.join(moduleDir, ".env.example"), calendarModeBEnvExample(service)); + writeText(path.join(moduleDir, "README.md"), calendarModeBReadme(service)); + writeText(path.join(moduleDir, "config.test.mjs"), calendarModeBConfigTestSource()); + writeText(path.join(moduleDir, "host.test.mjs"), calendarModeBHostTestSource()); + writeText(path.join(moduleDir, "host-local-confirmation.test.mjs"), calendarModeBConfirmationTestSource()); + + copyDirectory(path.join(packageDir, "adapter"), path.join(generatedDir, "adapter")); + copyDirectory(path.join(packageDir, "manifest"), path.join(generatedDir, "manifest")); + copyDirectory(path.join(packageDir, "docs"), path.join(generatedDir, "docs")); + copyDirectory(path.join(packageDir, "sidecar-long-connection"), path.join(generatedDir, "sidecar-long-connection")); + + writeJson(path.join(moduleDir, "install-manifest.json"), { + schema_version: "0.1", + package_kind: "code2lark-mode-b-module", + target_profile: "calendar-stock-updater", + install_root: "integrations/lark", + target_contract: { + health: { method: "GET", path: "/api/state" }, + allowed_endpoints: [ + { method: "GET", path: "/api/state" }, + { method: "POST", path: "/api/run" }, + { method: "POST", path: "/api/stop" }, + ], + }, + files: listManagedFiles(moduleDir), + }); +} + +function copyDirectory(source: string, target: string): void { + if (!fs.existsSync(source)) return; + fs.cpSync(source, target, { recursive: true, force: true }); +} + +function listManagedFiles(moduleDir: string): InstallManifestFile[] { + return listFiles(moduleDir) + .filter((filePath) => !["install-manifest.json", ".code2lark-install.json", ".env"].includes(path.basename(filePath))) + .filter((filePath) => !filePath.split(path.sep).includes("node_modules")) + .map((filePath) => { + const contents = fs.readFileSync(filePath); + return { + path: path.relative(moduleDir, filePath).split(path.sep).join("/"), + sha256: crypto.createHash("sha256").update(contents).digest("hex"), + size: contents.length, + }; + }); +} + +function listFiles(directory: string): string[] { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return listFiles(entryPath); + return entry.isFile() ? [entryPath] : []; + }).sort(); +} + +function calendarModeBEnvExample(service: ServiceManifest): string { + return `# Feishu/Lark long-connection credentials +FEISHU_APP_ID= +FEISHU_APP_SECRET= +TEST_CHAT_ID= +ALLOWED_OPERATOR_OPEN_IDS= + +# Existing calendar service; this module does not start or modify it +TARGET_BASE_URL=${service.service.base_url || "http://127.0.0.1:3069"} +TARGET_TIMEOUT_MS=30000 +TARGET_WAIT_MS=30000 +`; +} + +function calendarModeBReadme(service: ServiceManifest): string { + return `# ${service.service.name} Lark Integration + +This directory is a self-contained Code2Lark Mode B module. It communicates with the unchanged calendar service over HTTP and can be removed as one directory. + +## Boundary + +- Target calls: GET /api/state, POST /api/run, and POST /api/stop only. +- Feishu ingress: SDK long connection with card.action.trigger. +- Run and stop confirmation state lives in this host, not in target endpoints. +- No target root package, startup, Docker, business, or Web UI changes are required. + +## Configure And Start + +\`\`\`powershell +Copy-Item .env.example .env +# Fill FEISHU_APP_ID, FEISHU_APP_SECRET, TEST_CHAT_ID, ALLOWED_OPERATOR_OPEN_IDS, TARGET_BASE_URL +npm install +npm test +npm start +\`\`\` + +The start card hydrates form defaults from GET /api/state. The default form mirrors the target Web console: target date, stock, two delay values, and optional product ID range. Advanced environment-only SKU/date modes remain review candidates. + +## Completion States + +1. Generated: this module exists in the Code2Lark candidate package. +2. Dry-run reviewed: lark-deployer install validated the live target, file hashes, and planned writes without modifying the target. +3. Locally installed and verified: install --apply wrote only integrations/lark and local tests passed. + +Real Feishu verification is separate. It requires a real app, long-connection subscription, test chat, card send, human clicks, and sanitized evidence. + +## Local Checks + +\`\`\`powershell +npm test +node generated/sidecar-long-connection/local-contract-test.mjs +\`\`\` + +## Manual Cleanup + +Stop the module and remove the complete integrations/lark directory. A compliant install does not require any target root cleanup. +`; +} + +function calendarModeBAppSource(): string { + return `import { loadLarkConfig } from "./config.js"; +import { createLarkHost } from "./host.js"; + +async function main() { + const config = loadLarkConfig(); + const host = createLarkHost({ config }); + let stopping = false; + async function stop(signal) { + if (stopping) return; + stopping = true; + console.log("lark long connection stopping: " + signal); + await host.stop(); + process.exit(0); + } + process.on("SIGINT", () => void stop("SIGINT")); + process.on("SIGTERM", () => void stop("SIGTERM")); + await host.start({ waitForTarget: true }); +} + +main().catch((error) => { + console.error("lark long connection failed: " + (error instanceof Error ? error.message : String(error))); + process.exitCode = 1; +}); +`; +} + +function calendarModeBConfigSource(): string { + return `import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const requiredKeys = ["FEISHU_APP_ID", "FEISHU_APP_SECRET", "TEST_CHAT_ID", "TARGET_BASE_URL", "ALLOWED_OPERATOR_OPEN_IDS"]; + +export function parseAllowedOperatorOpenIds(value = "") { + return String(value).split(",").map((item) => item.trim()).filter(Boolean); +} + +export function loadLarkConfig(options = {}) { + const env = options.env ? { ...options.env } : { ...readDotEnv(path.join(options.moduleDir || moduleDir, ".env")), ...process.env }; + const missing = requiredKeys.filter((key) => !String(env[key] || "").trim()); + if (missing.length) throw new Error("Missing required Lark config: " + missing.join(", ")); + const allowedOperatorOpenIds = parseAllowedOperatorOpenIds(env.ALLOWED_OPERATOR_OPEN_IDS); + if (!allowedOperatorOpenIds.length) throw new Error("ALLOWED_OPERATOR_OPEN_IDS must include at least one approved operator."); + return { + appId: String(env.FEISHU_APP_ID).trim(), + appSecret: String(env.FEISHU_APP_SECRET).trim(), + testChatId: String(env.TEST_CHAT_ID).trim(), + targetBaseUrl: normalizeBaseUrl(env.TARGET_BASE_URL), + targetTimeoutMs: parsePositiveInteger(env.TARGET_TIMEOUT_MS, 30000, "TARGET_TIMEOUT_MS"), + targetWaitMs: parsePositiveInteger(env.TARGET_WAIT_MS, 30000, "TARGET_WAIT_MS"), + allowedOperatorOpenIds, + }; +} + +function normalizeBaseUrl(value) { + const text = String(value || "").trim().replace(/\\/+$/, ""); + try { + const parsed = new URL(text); + if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) throw new Error("invalid protocol"); + if (parsed.username || parsed.password) throw new Error("embedded credentials"); + return parsed.toString().replace(/\\/+$/, ""); + } catch { + throw new Error("TARGET_BASE_URL must be an absolute http(s) URL without embedded credentials."); + } +} + +function parsePositiveInteger(value, fallback, name) { + const text = String(value || "").trim(); + if (!text) return fallback; + if (!/^\\d+$/.test(text) || Number(text) <= 0) throw new Error(name + " must be a positive integer."); + return Number(text); +} + +function readDotEnv(filePath) { + if (!fs.existsSync(filePath)) return {}; + const env = {}; + for (const line of fs.readFileSync(filePath, "utf8").split(/\\r?\\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const index = trimmed.indexOf("="); + if (index === -1) continue; + const key = trimmed.slice(0, index).trim(); + const value = trimmed.slice(index + 1).trim().replace(/^["']|["']$/g, ""); + if (key) env[key] = value; + } + return env; +} +`; +} + +function calendarModeBHostSource(): string { + return `import { buildOperationsCard } from "./generated/adapter/cards.js"; +import { handleCardAction } from "./generated/adapter/handlers.js"; +import { callCalendar } from "./generated/adapter/service-client.js"; + +export function normalizeCardActionEvent(payload = {}) { + const event = payload.event || payload; + const header = payload.header || event.header || {}; + const actionPayload = event.action || {}; + const actionValue = toObject(actionPayload.value); + const operator = event.operator || {}; + const operatorId = operator.operator_id || operator.operatorId || {}; + const context = event.context || {}; + return { + eventId: stringOrUndefined(header.event_id || header.eventId || event.event_id), + action: String(actionValue.action || actionValue.action_id || actionPayload.action_id || ""), + value: actionValue, + formValue: toObject(actionPayload.form_value || actionPayload.formValue || actionValue.formValue), + operatorOpenId: stringOrUndefined(operatorId.open_id || operator.open_id || operator.openId), + openMessageId: stringOrUndefined(context.open_message_id || context.openMessageId || event.open_message_id), + openChatId: stringOrUndefined(context.open_chat_id || context.openChatId || event.open_chat_id), + }; +} + +export function createLarkHost({ config, runtime = null, sdk = null, logger = console } = {}) { + if (!config) throw new Error("config is required"); + let activeRuntime = runtime; + const handled = new Map(); + + async function handleCardActionEvent(payload) { + const context = normalizeCardActionEvent(payload); + const key = context.eventId || [context.openMessageId, context.operatorOpenId, context.action, JSON.stringify(context.value), JSON.stringify(context.formValue)].join(":"); + if (handled.has(key)) return handled.get(key); + const pending = handleCardAction(context, { + targetBaseUrl: config.targetBaseUrl, + timeoutMs: config.targetTimeoutMs, + allowedOperatorOpenIds: config.allowedOperatorOpenIds, + }); + handled.set(key, pending); + const result = await pending; + handled.set(key, Promise.resolve(result)); + setTimeout(() => handled.delete(key), 10 * 60 * 1000).unref?.(); + for (const event of result.auditEvents || []) logger.log?.("[lark audit] " + JSON.stringify(redactAuditEvent(event))); + return result; + } + + async function start(options = {}) { + logger.log?.("lark long connection starting"); + const initialState = options.waitForTarget + ? await waitForTarget(config, logger) + : await callCalendar(config.targetBaseUrl, "GET", "/api/state", null, config.targetTimeoutMs); + if (!activeRuntime) activeRuntime = await createFeishuRuntime(config, sdk); + activeRuntime.subscribe("card.action.trigger", async (event) => { + const result = await handleCardActionEvent(event); + return { card: { type: "raw", data: result.card } }; + }); + await activeRuntime.connect(); + if (config.testChatId) await activeRuntime.sendInteractiveCard(config.testChatId, buildOperationsCard(initialState)); + logger.log?.("lark long connection online"); + } + + async function stop() { + if (activeRuntime) await activeRuntime.disconnect(); + } + + return { handleCardActionEvent, start, stop }; +} + +export async function waitForTarget(config, logger = console) { + const deadline = Date.now() + config.targetWaitMs; + let lastError = null; + while (Date.now() <= deadline) { + try { + const state = await callCalendar(config.targetBaseUrl, "GET", "/api/state", null, Math.min(config.targetTimeoutMs, 2000)); + logger.log?.("calendar service ready"); + return state; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error("Target calendar service is not reachable at " + config.targetBaseUrl + "/api/state: " + (lastError instanceof Error ? lastError.message : "timeout")); +} + +export async function createFeishuRuntime(config, injectedSdk = null) { + const lark = injectedSdk || await import("@larksuiteoapi/node-sdk"); + const client = new lark.Client({ appId: config.appId, appSecret: config.appSecret }); + const handlers = new Map(); + const wsClient = new lark.WSClient({ appId: config.appId, appSecret: config.appSecret }); + return { + subscribe(eventName, handler) { handlers.set(eventName, handler); }, + async connect() { + const cardHandler = handlers.get("card.action.trigger"); + const dispatcher = new lark.EventDispatcher({}).register({ "card.action.trigger": cardHandler }); + await wsClient.start({ eventDispatcher: dispatcher }); + }, + async disconnect() { + const close = wsClient.shutdown || wsClient.stop || wsClient.close; + if (typeof close === "function") await close.call(wsClient); + }, + async sendInteractiveCard(chatId, card) { + const response = await client.im.message.create({ + params: { receive_id_type: "chat_id" }, + data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) }, + }); + return { messageId: response?.data?.message_id || response?.data?.messageId || "" }; + }, + }; +} + +function toObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } +function stringOrUndefined(value) { return value == null ? undefined : String(value); } +function redactAuditEvent(event) { + const detail = event && event.detail && typeof event.detail === "object" ? event.detail : {}; + return { + event: event.event, + detail: { + action: String(detail.action || ""), + message: detail.message ? String(detail.message).slice(0, 180) : undefined, + operator_present: Boolean(detail.operator_open_id), + chat_present: Boolean(detail.chat_id), + }, + }; +} +`; +} + +function calendarModeBConfigTestSource(): string { + return `import assert from "node:assert/strict"; +import test from "node:test"; +import { loadLarkConfig, parseAllowedOperatorOpenIds } from "./config.js"; + +test("module-local config requires credentials, target, chat, and operator allowlist", () => { + assert.throws(() => loadLarkConfig({ env: { TARGET_BASE_URL: "http://127.0.0.1:3069" } }), /FEISHU_APP_ID/); +}); + +test("module-local config normalizes values", () => { + const config = loadLarkConfig({ env: { + FEISHU_APP_ID: "cli_test", + FEISHU_APP_SECRET: "secret_test", + TEST_CHAT_ID: "oc_test", + TARGET_BASE_URL: "http://127.0.0.1:3069/", + ALLOWED_OPERATOR_OPEN_IDS: "ou_a, ou_b", + } }); + assert.equal(config.targetBaseUrl, "http://127.0.0.1:3069"); + assert.deepEqual(config.allowedOperatorOpenIds, ["ou_a", "ou_b"]); + assert.deepEqual(parseAllowedOperatorOpenIds(" ou_a, ,ou_b "), ["ou_a", "ou_b"]); +}); + +test("module-local config rejects target URLs with embedded credentials", () => { + assert.throws(() => loadLarkConfig({ env: { + FEISHU_APP_ID: "cli_test", + FEISHU_APP_SECRET: "secret_test", + TEST_CHAT_ID: "oc_test", + TARGET_BASE_URL: "http://user:password@127.0.0.1:3069", + ALLOWED_OPERATOR_OPEN_IDS: "ou_a", + } }), /without embedded credentials/); +}); + +test("module-local config rejects an effectively empty operator allowlist", () => { + assert.throws(() => loadLarkConfig({ env: { + FEISHU_APP_ID: "cli_test", + FEISHU_APP_SECRET: "secret_test", + TEST_CHAT_ID: "oc_test", + TARGET_BASE_URL: "http://127.0.0.1:3069", + ALLOWED_OPERATOR_OPEN_IDS: " , , ", + } }), /at least one approved operator/); +}); +`; +} + +function calendarModeBHostTestSource(): string { + return `import assert from "node:assert/strict"; +import http from "node:http"; +import test from "node:test"; +import { createLarkHost, normalizeCardActionEvent } from "./host.js"; + +test("normalizeCardActionEvent preserves event, action, form, operator, and context", () => { + assert.deepEqual(normalizeCardActionEvent({ + header: { event_id: "evt_1" }, + event: { + action: { value: { action: "calendar.status.refresh" }, form_value: { stock: "100" } }, + operator: { operator_id: { open_id: "ou_operator" } }, + context: { open_message_id: "om_1", open_chat_id: "oc_1" }, + }, + }), { + eventId: "evt_1", + action: "calendar.status.refresh", + value: { action: "calendar.status.refresh" }, + formValue: { stock: "100" }, + operatorOpenId: "ou_operator", + openMessageId: "om_1", + openChatId: "oc_1", + }); +}); + +test("host hydrates the start card and returns a raw callback envelope", async (t) => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ defaults: { targetDate: "2026-10-01", stock: "100" }, task: { status: "idle" }, logs: [] })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + const baseUrl = "http://127.0.0.1:" + server.address().port; + const calls = []; + let callback; + const runtime = { + subscribe(name, handler) { calls.push(["subscribe", name]); callback = handler; }, + async connect() { calls.push(["connect"]); }, + async disconnect() { calls.push(["disconnect"]); }, + async sendInteractiveCard(chatId, card) { calls.push(["send", chatId, JSON.stringify(card).includes("2026-10-01")]); }, + }; + const config = { targetBaseUrl: baseUrl, targetTimeoutMs: 2000, targetWaitMs: 2000, testChatId: "oc_test", allowedOperatorOpenIds: ["ou_operator"] }; + const auditLines = []; + const host = createLarkHost({ config, runtime, logger: { log(line) { auditLines.push(String(line)); } } }); + await host.start({ waitForTarget: true }); + const response = await callback({ header: { event_id: "evt_refresh" }, event: { action: { value: { action: "calendar.status.refresh" } }, operator: { operator_id: { open_id: "ou_operator" } } } }); + assert.equal(response.card.type, "raw"); + assert.deepEqual(calls.slice(0, 3), [["subscribe", "card.action.trigger"], ["connect"], ["send", "oc_test", true]]); + assert.doesNotMatch(auditLines.join("\\n"), /ou_operator|oc_test/); + await host.stop(); +}); +`; +} + +function calendarModeBConfirmationTestSource(): string { + return `import assert from "node:assert/strict"; +import http from "node:http"; +import test from "node:test"; +import { handleCardAction } from "./generated/adapter/handlers.js"; + +test("host-local confirmation gates run and stop target calls", async (t) => { + const externalBaseUrl = process.env.CALENDAR_TARGET_BASE_URL || ""; + const local = externalBaseUrl ? null : await startLocalTarget(t); + const targetBaseUrl = externalBaseUrl || local.baseUrl; + const operatorOpenId = "ou_mode_b_operator"; + const deps = { targetBaseUrl, timeoutMs: 3000, allowedOperatorOpenIds: [operatorOpenId] }; + const formValue = { targetDate: "2026-10-01", stock: "100", stepDelayMs: "500", datePickerDelayMs: "500", startProductId: "", endProductId: "" }; + + const invalid = await handleCardAction({ action: "calendar.task.run.prepare", formValue: { ...formValue, targetDate: "bad-date" }, operatorOpenId }, deps); + assert.equal(invalid.ok, false); + const prepared = await handleCardAction({ action: "calendar.task.run.prepare", formValue, operatorOpenId }, deps); + assert.equal(prepared.ok, true); + const runConfirmationId = prepared.result.confirmationId; + assert.ok(runConfirmationId); + + const wrongOperator = await handleCardAction({ action: "calendar.task.run.confirm", value: { confirmationId: runConfirmationId }, operatorOpenId: "ou_other" }, { ...deps, allowedOperatorOpenIds: [operatorOpenId, "ou_other"] }); + assert.equal(wrongOperator.ok, false); + const confirmed = await handleCardAction({ action: "calendar.task.run.confirm", value: { confirmationId: runConfirmationId }, operatorOpenId }, deps); + assert.equal(confirmed.ok, true); + const duplicate = await handleCardAction({ action: "calendar.task.run.confirm", value: { confirmationId: runConfirmationId }, operatorOpenId }, deps); + assert.equal(duplicate.ok, false); + + const stopPrepared = await handleCardAction({ action: "calendar.task.stop.prepare", operatorOpenId }, deps); + assert.equal(stopPrepared.ok, true); + const stopConfirmationId = stopPrepared.result.confirmationId; + const stopped = await handleCardAction({ action: "calendar.task.stop.confirm", value: { confirmationId: stopConfirmationId }, operatorOpenId }, deps); + assert.equal(stopped.ok, true); + const duplicateStop = await handleCardAction({ action: "calendar.task.stop.confirm", value: { confirmationId: stopConfirmationId }, operatorOpenId }, deps); + assert.equal(duplicateStop.ok, false); +}); + +async function startLocalTarget(t) { + let running = false; + const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/api/state") return json(res, 200, state()); + if (req.method === "POST" && req.url === "/api/run") return read(req, (body) => { running = true; json(res, 202, { ok: true, task: state().task, received: body }); }); + if (req.method === "POST" && req.url === "/api/stop") return read(req, () => { running = false; json(res, 202, { ok: true, task: state().task }); }); + json(res, 404, { error: "not found" }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + function state() { + return { defaults: { targetDate: "2026-10-01", stock: "100", stepDelayMs: "500", datePickerDelayMs: "500" }, task: { status: running ? "running" : "idle", pid: running ? 1234 : null, currentMessage: running ? "running" : "ready" }, logs: [] }; + } + return { baseUrl: "http://127.0.0.1:" + server.address().port }; +} + +function read(req, done) { let raw = ""; req.on("data", (chunk) => { raw += chunk; }); req.on("end", () => done(raw ? JSON.parse(raw) : {})); } +function json(res, status, body) { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); } +`; +} From 86b72b95fe3c4d690b059e0f9a5b9ad9d6ab08b7 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:47:41 +0800 Subject: [PATCH 29/37] feat: add isolated calendar Mode B installation Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- package.json | 5 +- src/commands/install.ts | 342 +++++++++++++++++++++ src/index.ts | 16 +- tests/mode-b-install.test.mjs | 547 ++++++++++++++++++++++++++++++++++ 4 files changed, 906 insertions(+), 4 deletions(-) create mode 100644 src/commands/install.ts create mode 100644 tests/mode-b-install.test.mjs diff --git a/package.json b/package.json index de31120..59b4163 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ "check": "npm run build", "test:unit": "node --test tests/unit-pure-functions.test.mjs", "test:smoke": "node --test tests/cli-smoke.test.mjs", + "test:mode-b": "node --test tests/mode-b-install.test.mjs", "test:e2e": "node --test tests/runtime-local-e2e.test.mjs", - "test:coverage": "node --experimental-test-coverage --test tests/unit-pure-functions.test.mjs tests/cli-smoke.test.mjs", - "test": "npm run build && npm run test:unit && npm run test:smoke && npm run test:e2e" + "test:coverage": "node --experimental-test-coverage --test tests/unit-pure-functions.test.mjs tests/cli-smoke.test.mjs tests/mode-b-install.test.mjs", + "test": "npm run build && npm run test:unit && npm run test:smoke && npm run test:mode-b && npm run test:e2e" }, "engines": { "node": ">=24.16.0" diff --git a/src/commands/install.ts b/src/commands/install.ts new file mode 100644 index 0000000..8cfd829 --- /dev/null +++ b/src/commands/install.ts @@ -0,0 +1,342 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { getStringOption, hasOption } from "../args.js"; +import { ensureDir, readJsonFile, writeJson } from "../fs-utils.js"; +import { getJsonWithTimeout } from "../http-utils.js"; +import type { CapabilityMap, ServiceManifest } from "../types.js"; +import { requireHttpBaseUrl } from "../url-validation.js"; + +interface InstallManifestFile { + path: string; + sha256: string; + size: number; +} + +interface InstallManifest { + schema_version: string; + package_kind: string; + target_profile: string; + install_root: string; + target_contract: { + health: { method: string; path: string }; + allowed_endpoints: Array<{ method: string; path: string }>; + }; + files: InstallManifestFile[]; +} + +interface AppliedInstallManifest { + schema_version: string; + target_profile: string; + install_root: string; + source_package: string; + installed_at: string; + files: Array<{ path: string; sha256: string }>; +} + +interface SourceFile { + relativePath: string; + sourcePath: string; + sha256: string; + size: number; +} + +const INSTALL_ROOT = "integrations/lark"; +const APPLIED_MANIFEST = ".code2lark-install.json"; +const EXPECTED_CALENDAR_ENDPOINTS = ["GET /api/state", "POST /api/run", "POST /api/stop"]; + +export async function installCommand(args: string[], options: Record): Promise { + if (hasOption(options, "help") || hasOption(options, "h")) { + console.log(installUsage()); + return; + } + const packageArg = args[0]; + const targetArg = getStringOption(options, "target"); + if (!packageArg || !targetArg) throw new Error(installUsage()); + + const packagePath = path.resolve(packageArg); + const targetPath = path.resolve(targetArg); + assertSeparateTrees(packagePath, targetPath); + if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) { + throw new Error(`Target project directory does not exist: ${targetPath}`); + } + const moduleSource = path.join(packagePath, "integrations", "lark"); + const moduleTarget = path.join(targetPath, "integrations", "lark"); + assertNoSymlinkPath(packagePath, moduleSource); + assertNoSymlinkPath(targetPath, moduleTarget); + const apply = hasOption(options, "apply"); + const manifest = validateInstallPackage(packagePath, moduleSource); + const service = readJsonFile(path.join(packagePath, "manifest", "service_manifest.json")); + const targetBaseUrl = requireHttpBaseUrl(getStringOption(options, "target-base-url", getStringOption(options, "targetBaseUrl", service.service.base_url || "")), "TARGET_BASE_URL"); + if (!targetBaseUrl) throw new Error("Install requires --target-base-url or a generated service base_url."); + const parsedTargetUrl = new URL(targetBaseUrl); + if (parsedTargetUrl.username || parsedTargetUrl.password) throw new Error("TARGET_BASE_URL must not contain embedded credentials."); + const healthUrl = `${targetBaseUrl}${manifest.target_contract.health.path}`; + const health = await getJsonWithTimeout(healthUrl, 5000); + if (health.status !== "available" || !isCalendarState(health.data)) { + throw new Error(`Target health probe failed for ${healthUrl}: ${health.detail}`); + } + + const sourceFiles = buildSourceFiles(moduleSource, manifest); + assertNoInstallConflicts(moduleTarget, sourceFiles); + + console.log(`Mode B install ${apply ? "apply" : "dry-run"}`); + console.log(`Target health: PASS (${health.detail})`); + console.log(`Install root: ${moduleTarget}`); + for (const file of sourceFiles) console.log(`- ${file.relativePath}`); + if (!apply) { + console.log("Dry-run complete; no files written. Re-run with --apply to install."); + return; + } + + for (const file of sourceFiles) { + const destination = resolveInside(moduleTarget, file.relativePath); + assertNoSymlinkPath(targetPath, destination); + ensureDir(path.dirname(destination)); + fs.copyFileSync(file.sourcePath, destination); + } + const appliedManifest: AppliedInstallManifest = { + schema_version: "0.1", + target_profile: manifest.target_profile, + install_root: INSTALL_ROOT, + source_package: packagePath, + installed_at: new Date().toISOString(), + files: sourceFiles.map((file) => ({ path: file.relativePath, sha256: file.sha256 })), + }; + writeJson(path.join(moduleTarget, APPLIED_MANIFEST), appliedManifest); + console.log(`Install applied: ${sourceFiles.length} managed files written under ${moduleTarget}.`); +} + +function installUsage(): string { + return "Usage: lark-deployer install --target [--target-base-url ] [--apply]"; +} + +function validateInstallPackage(packagePath: string, moduleSource: string): InstallManifest { + const summaryPath = path.join(packagePath, "generation_summary.json"); + assertNoSymlinkPath(packagePath, summaryPath); + if (!fs.existsSync(summaryPath)) { + throw new Error(`Not a managed Code2Lark generated package: ${packagePath}`); + } + const summary = readJsonFile>(summaryPath); + if (summary.target_profile !== "calendar-stock-updater") { + throw new Error(`Mode B install currently requires target_profile=calendar-stock-updater; found ${String(summary.target_profile || "missing")}.`); + } + const manifestPath = path.join(moduleSource, "install-manifest.json"); + assertNoSymlinkPath(moduleSource, manifestPath); + if (!fs.existsSync(manifestPath)) throw new Error(`Missing Mode B install manifest: ${manifestPath}`); + const manifest = readJsonFile(manifestPath); + if (manifest.schema_version !== "0.1" + || manifest.package_kind !== "code2lark-mode-b-module" + || manifest.target_profile !== "calendar-stock-updater" + || manifest.install_root !== INSTALL_ROOT) { + throw new Error("Invalid calendar Mode B install manifest identity."); + } + if (!isRecord(manifest.target_contract) + || !isRecord(manifest.target_contract.health) + || !Array.isArray(manifest.target_contract.allowed_endpoints)) { + throw new Error("Mode B install manifest has an invalid target contract."); + } + if (!Array.isArray(manifest.files) || manifest.files.length === 0) throw new Error("Mode B install manifest has no managed files."); + const seen = new Set(); + for (const file of manifest.files) { + if (!isRecord(file) || typeof file.path !== "string" || typeof file.sha256 !== "string" || typeof file.size !== "number") { + throw new Error("Mode B install manifest contains an invalid managed file entry."); + } + const relativePath = normalizeManagedPath(file.path); + if (seen.has(relativePath)) throw new Error(`Duplicate managed path in install manifest: ${relativePath}`); + seen.add(relativePath); + const sourcePath = resolveInside(moduleSource, relativePath); + assertNoSymlinkPath(moduleSource, sourcePath); + if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) throw new Error(`Missing managed source file: ${relativePath}`); + const contents = fs.readFileSync(sourcePath); + const actualHash = sha256(contents); + if (actualHash !== file.sha256 || contents.length !== file.size) throw new Error(`Managed source checksum mismatch: ${relativePath}`); + } + assertExpectedEndpoints("install manifest", manifest.target_contract.allowed_endpoints); + if (endpointKey(manifest.target_contract.health.method, manifest.target_contract.health.path) !== "GET /api/state") { + throw new Error("Mode B install health contract must be GET /api/state."); + } + validateManifestEndpointClosure(packagePath); + validateModuleMirrorsPackage(packagePath, moduleSource); + validateGeneratedTargetCalls(moduleSource); + return manifest; +} + +function validateManifestEndpointClosure(packagePath: string): void { + const servicePath = path.join(packagePath, "manifest", "service_manifest.json"); + const capabilitiesPath = path.join(packagePath, "manifest", "capability_map.json"); + assertNoSymlinkPath(packagePath, servicePath); + assertNoSymlinkPath(packagePath, capabilitiesPath); + const service = readJsonFile(servicePath); + const capabilities = readJsonFile(capabilitiesPath); + if (!service.source_scan || !Array.isArray(service.source_scan.endpoints) || !Array.isArray(capabilities.capabilities)) { + throw new Error("Generated service/capability manifests are incomplete for install validation."); + } + assertExpectedEndpoints("capability map", capabilities.capabilities.map((capability) => capability.source)); + const discovered = new Set(service.source_scan.endpoints.map((endpoint) => endpointKey(endpoint.method, endpoint.path))); + const missing = capabilities.capabilities + .filter((capability) => !discovered.has(endpointKey(capability.source.method, capability.source.path))) + .map((capability) => `${capability.source.method} ${capability.source.path}`); + if (missing.length) throw new Error(`Capability endpoints were not discovered in target source: ${missing.join(", ")}.`); +} + +function validateGeneratedTargetCalls(moduleSource: string): void { + const forbidden = /\/api\/(?:run|stop)\/(?:prepare|confirm|cancel)(?:["'`/]|$)/; + for (const filePath of listFiles(moduleSource)) { + if (fs.lstatSync(filePath).isSymbolicLink()) throw new Error(`Symbolic links are not allowed in generated modules: ${path.relative(moduleSource, filePath)}.`); + if (!/\.(?:js|ts|mjs|md|json)$/i.test(filePath)) continue; + const source = fs.readFileSync(filePath, "utf8"); + if (forbidden.test(source)) throw new Error(`Generated module contains an undiscovered target endpoint: ${path.relative(moduleSource, filePath)}.`); + } +} + +function validateModuleMirrorsPackage(packagePath: string, moduleSource: string): void { + for (const directory of ["adapter", "manifest", "docs", "sidecar-long-connection"]) { + const packageDirectory = path.join(packagePath, directory); + const moduleDirectory = path.join(moduleSource, "generated", directory); + const packageFiles = listFiles(packageDirectory).map((filePath) => path.relative(packageDirectory, filePath).split(path.sep).join("/")); + const moduleFiles = listFiles(moduleDirectory).map((filePath) => path.relative(moduleDirectory, filePath).split(path.sep).join("/")); + if (packageFiles.length !== moduleFiles.length || packageFiles.some((file, index) => file !== moduleFiles[index])) { + throw new Error(`Generated module ${directory} files do not match the package source of truth.`); + } + for (const relativePath of packageFiles) { + const packageFile = resolveInside(packageDirectory, relativePath); + const moduleFile = resolveInside(moduleDirectory, relativePath); + assertNoSymlinkPath(packageDirectory, packageFile); + assertNoSymlinkPath(moduleDirectory, moduleFile); + if (sha256(fs.readFileSync(packageFile)) !== sha256(fs.readFileSync(moduleFile))) { + throw new Error(`Generated module file differs from package source of truth: ${directory}/${relativePath}.`); + } + } + } +} + +function buildSourceFiles(moduleSource: string, manifest: InstallManifest): SourceFile[] { + const files = manifest.files.map((file) => ({ + relativePath: normalizeManagedPath(file.path), + sourcePath: resolveInside(moduleSource, file.path), + sha256: file.sha256, + size: file.size, + })); + const manifestPath = path.join(moduleSource, "install-manifest.json"); + const contents = fs.readFileSync(manifestPath); + files.push({ relativePath: "install-manifest.json", sourcePath: manifestPath, sha256: sha256(contents), size: contents.length }); + return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); +} + +function assertNoInstallConflicts(moduleTarget: string, sourceFiles: SourceFile[]): void { + if (!fs.existsSync(moduleTarget)) return; + const appliedPath = path.join(moduleTarget, APPLIED_MANIFEST); + let previous: AppliedInstallManifest | undefined; + if (fs.existsSync(appliedPath)) { + try { + previous = readJsonFile(appliedPath); + } catch (error) { + throw new Error(`Managed install metadata conflict at ${appliedPath}: ${error instanceof Error ? error.message : String(error)}`); + } + if (previous.schema_version !== "0.1" || previous.install_root !== INSTALL_ROOT || !Array.isArray(previous.files)) { + throw new Error(`Managed install metadata conflict at ${appliedPath}: unsupported or incomplete schema.`); + } + } + const previousHashes = new Map((previous?.files || []).map((file) => { + if (!isRecord(file) || typeof file.path !== "string" || typeof file.sha256 !== "string") { + throw new Error(`Managed install metadata conflict at ${appliedPath}: invalid file entry.`); + } + return [normalizeManagedPath(file.path), file.sha256]; + })); + const allowedLocalFiles = new Set([".env", "package-lock.json", "npm-debug.log"]); + const unmanaged = listFiles(moduleTarget) + .map((filePath) => path.relative(moduleTarget, filePath).split(path.sep).join("/")) + .filter((relativePath) => relativePath !== APPLIED_MANIFEST) + .filter((relativePath) => !relativePath.startsWith("node_modules/")) + .filter((relativePath) => !allowedLocalFiles.has(relativePath)) + .filter((relativePath) => !previousHashes.has(relativePath)); + if (unmanaged.length) throw new Error(`Unmanaged file conflict under ${INSTALL_ROOT}: ${unmanaged.join(", ")}.`); + for (const file of sourceFiles) { + const destination = resolveInside(moduleTarget, file.relativePath); + assertNoSymlinkPath(moduleTarget, destination); + if (!fs.existsSync(destination)) continue; + const previousHash = previousHashes.get(file.relativePath); + if (!previousHash) throw new Error(`Unmanaged file conflict under ${INSTALL_ROOT}: ${file.relativePath}.`); + const currentHash = sha256(fs.readFileSync(destination)); + if (currentHash !== previousHash) throw new Error(`Managed file conflict under ${INSTALL_ROOT}: ${file.relativePath} was modified after installation.`); + } +} + +function assertExpectedEndpoints(label: string, endpoints: Array<{ method: string; path: string }>): void { + if (!Array.isArray(endpoints) || endpoints.some((endpoint) => !isRecord(endpoint) || typeof endpoint.method !== "string" || typeof endpoint.path !== "string")) { + throw new Error(`${label} has invalid endpoint entries.`); + } + const actual = endpoints.map((endpoint) => endpointKey(endpoint.method, endpoint.path)).sort(); + if (actual.length !== EXPECTED_CALENDAR_ENDPOINTS.length + || actual.some((value, index) => value !== EXPECTED_CALENDAR_ENDPOINTS[index])) { + throw new Error(`${label} endpoints must be exactly ${EXPECTED_CALENDAR_ENDPOINTS.join(", ")}; found ${actual.join(", ") || "none"}.`); + } +} + +function normalizeManagedPath(value: string): string { + const normalized = value.replaceAll("\\", "/"); + if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) throw new Error(`Unsafe managed path: ${value}`); + const segments = normalized.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) throw new Error(`Unsafe managed path: ${value}`); + if (segments.includes("node_modules") || normalized === ".env" || normalized === APPLIED_MANIFEST) throw new Error(`Excluded managed path: ${value}`); + return normalized; +} + +function resolveInside(root: string, relativePath: string): string { + const normalized = normalizeManagedPath(relativePath); + const resolved = path.resolve(root, ...normalized.split("/")); + const relative = path.relative(root, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Managed path escapes install root: ${relativePath}`); + return resolved; +} + +function assertNoSymlinkPath(root: string, candidate: string): void { + const resolvedRoot = path.resolve(root); + const resolvedCandidate = path.resolve(candidate); + const relative = path.relative(resolvedRoot, resolvedCandidate); + if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path escapes trusted root: ${candidate}`); + let current = resolvedRoot; + if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) throw new Error(`Symbolic links are not allowed in install paths: ${current}`); + for (const segment of relative ? relative.split(path.sep) : []) { + current = path.join(current, segment); + if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) throw new Error(`Symbolic links are not allowed in install paths: ${current}`); + } +} + +function assertSeparateTrees(packagePath: string, targetPath: string): void { + if (packagePath === targetPath || isInside(packagePath, targetPath) || isInside(targetPath, packagePath)) { + throw new Error("Generated package and target project must be separate directory trees."); + } +} + +function isInside(parent: string, candidate: string): boolean { + const relative = path.relative(parent, candidate); + return Boolean(relative && !relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function endpointKey(method: string, endpointPath: string): string { + return `${String(method).toUpperCase()} ${endpointPath}`; +} + +function sha256(contents: Buffer): string { + return crypto.createHash("sha256").update(contents).digest("hex"); +} + +function listFiles(directory: string): string[] { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return listFiles(entryPath); + if (entry.isSymbolicLink()) return [entryPath]; + return entry.isFile() ? [entryPath] : []; + }).sort(); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function isCalendarState(value: unknown): boolean { + return isRecord(value) && isRecord(value.defaults) && isRecord(value.task) && Array.isArray(value.logs); +} diff --git a/src/index.ts b/src/index.ts index e8abad2..301a636 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { evidenceCommand } from "./commands/evidence.js"; import { generateCommand } from "./commands/generate.js"; import { handoffCommand } from "./commands/handoff.js"; import { initLocalCommand } from "./commands/init-local.js"; +import { installCommand } from "./commands/install.js"; import { planCommand } from "./commands/plan.js"; import { readinessCommand } from "./commands/readiness.js"; import { statusCommand } from "./commands/status.js"; @@ -56,6 +57,9 @@ async function main(): Promise { case "init-local": await initLocalCommand(parsed.positionals, parsed.options); return; + case "install": + await installCommand(parsed.positionals, parsed.options); + return; case "verify": await verifyCommand(parsed.positionals, parsed.options); return; @@ -68,7 +72,13 @@ function printHelp(): void { console.log(`Lark-deployer MVP CLI Usage: - lark-deployer analyze --base-url [--out ] [--name ] + lark-deployer analyze --base-url [--out ] [--name ] [--backend auto|internal|codegraph] + Analyze options: + --backend auto|internal|codegraph (default: auto) + auto: try codegraph first, then fall back to internal if needed. + codegraph: requires a user-maintained fresh index and is never auto-installed/initialized/synced. + internal: use the built-in structural scan only. + lark-deployer plan lark-deployer generate [--out ] [--force] [--mode embedded-adapter|standalone-runtime|self-hosted-runtime] [--host-mode embedded-webhook|embedded-long-connection|hybrid|standalone-runtime] lark-deployer context [--out ] [--mode embedded-adapter|standalone-runtime|self-hosted-runtime] [--host-mode embedded-webhook|embedded-long-connection|hybrid|standalone-runtime] @@ -79,10 +89,12 @@ Usage: lark-deployer evidence [--env ] [--report ] [--audit ] [--runtime-url ] [--out ] [--update-record] [--manual-evidence ] [--start-message-id ] [--result-message-id ] [--result-screenshot ] [--generated-image-url ] [--generated-image-key ] [--batch-id ] [--batch-status-message-id ] [--batch-status-screenshot ] [--batch-download-url ] [--batch-download-screenshot ] [--trace-id ] lark-deployer handoff [--out ] [--copy-to ] [--check] lark-deployer init-local [--context] [--reply] [--manual-evidence] [--all] [--force] + lark-deployer install --target [--target-base-url ] [--apply] lark-deployer verify [--env ] [--mode embedded-adapter|standalone-runtime|self-hosted-runtime] [--host-mode embedded-webhook|embedded-long-connection|hybrid|standalone-runtime] [--runtime-url ] [--host-runtime-url ] [--simulate] [--send-start-card] [--level2] [--strict] [--allow-local-callback] -MVP target: +Validated targets: image-agent-web /api/generate, /api/iterate, and /api/batch progress integration. + calendar-stock-updater isolated Mode B host for /api/state, /api/run, and /api/stop. Boundary: Lark-deployer builds the integration package and verifies availability. diff --git a/tests/mode-b-install.test.mjs b/tests/mode-b-install.test.mjs new file mode 100644 index 0000000..31ed396 --- /dev/null +++ b/tests/mode-b-install.test.mjs @@ -0,0 +1,547 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const root = path.resolve(import.meta.dirname, ".."); +const cli = path.join(root, "dist", "index.js"); + +test("calendar Mode B offline generation is isolated and emits the approved Lark closure", { timeout: 120_000 }, async () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-mode-b-generate-")); + const target = path.join(temp, "calendar-stock-updater"); + const workspace = path.join(temp, "analysis"); + const generated = path.join(temp, "generated", "calendar-stock-updater-lark"); + writeCalendarTarget(target); + const before = snapshotTree(target); + + const analyzeOutput = await runCli(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace]); + assert.match(analyzeOutput, /calendar_stock_updater|calendar-stock-updater/i); + const generateOutput = await runCli(["generate", workspace, "--out", generated, "--mode", "embedded-adapter", "--host-mode", "embedded-long-connection"]); + assert.match(generateOutput, /generated|calendar|lark/i); + + assert.deepEqual(snapshotTree(target), before, "offline analyze/generate must not write into the target project"); + assert.ok(fs.existsSync(path.join(generated, "integrations", "lark")), "generated package must include integrations/lark"); + assert.equal(fs.existsSync(path.join(generated, "integrations", "lark", "node_modules")), false); + const moduleRoot = path.join(generated, "integrations", "lark"); + const moduleReadme = fs.readFileSync(path.join(moduleRoot, "README.md"), "utf8"); + const modulePackage = readJson(path.join(moduleRoot, "package.json")); + assert.match(moduleReadme, /Generated:/); + assert.match(moduleReadme, /Dry-run reviewed:/); + assert.match(moduleReadme, /Locally installed and verified:/); + assert.match(moduleReadme, /Real Feishu verification is separate/); + assert.ok(fs.existsSync(path.join(moduleRoot, ".env.example"))); + assert.equal(modulePackage.dependencies["@larksuiteoapi/node-sdk"], "1.71.1"); + + const capabilityMap = readJson(path.join(generated, "manifest", "capability_map.json")); + assert.deepEqual(capabilityPaths(capabilityMap), ["/api/run", "/api/state", "/api/stop"]); + assert.doesNotMatch(JSON.stringify(capabilityMap), /\/api\/(run|stop)\/prepare|\/api\/(run|stop)\/confirm/); + + const handoff = path.join(temp, "handoff"); + const handoffOutput = await runCli(["handoff", generated, "--copy-to", handoff, "--check"]); + assert.match(handoffOutput, /Handoff check passed/); + assert.ok(fs.existsSync(path.join(handoff, "integrations", "lark", "install-manifest.json"))); +}); + +test("calendar Mode B install defaults to dry-run and writes nothing", { timeout: 120_000 }, async () => { + const { generated, target, close } = await createGeneratedCalendarPackage(); + try { + const before = snapshotTree(target); + const output = await runCli(["install", generated, "--target", target]); + assert.match(output, /dry[- ]run/i); + assert.match(output, /integrations[\\/]lark/i); + assert.deepEqual(snapshotTree(target), before, "install without --apply must not write target files"); + } finally { + await close(); + } +}); + +test("calendar Mode B install --apply writes only integrations/lark and preserves target root hashes", { timeout: 120_000 }, async () => { + const { generated, target, targetBaseUrl, close } = await createGeneratedCalendarPackage(); + try { + const beforeRootHashes = hashRootFiles(target); + const output = await runCli(["install", generated, "--target", target, "--target-base-url", targetBaseUrl, "--apply"]); + assert.match(output, /appl(?:y|ied)|installed/i); + assert.ok(fs.existsSync(path.join(target, "integrations", "lark")), "apply must install the managed Lark closure"); + assert.deepEqual(hashRootFiles(target), beforeRootHashes, "root project files must not be rewritten by apply"); + assert.deepEqual(nonIntegrationChanges(target, beforeRootHashes), []); + } finally { + await close(); + } +}); + +test("calendar Mode B install --apply blocks on unreachable target before writes", { timeout: 120_000 }, async () => { + const { generated, target, close } = await createGeneratedCalendarPackage(); + try { + const before = snapshotTree(target); + const output = await runCliExpectFailure(["install", generated, "--target", target, "--target-base-url", "http://127.0.0.1:1", "--apply"]); + assert.match(output, /unreachable|health|\/api\/state|target/i); + assert.deepEqual(snapshotTree(target), before, "unreachable health gate must block before any target writes"); + } finally { + await close(); + } +}); + +test("calendar Mode B install rejects generated module paths that contain links before target writes", { timeout: 120_000 }, async (t) => { + const { generated, target, targetBaseUrl, close } = await createGeneratedCalendarPackage(); + try { + const moduleGeneratedRoot = path.join(generated, "integrations", "lark", "generated"); + const linkPath = path.join(moduleGeneratedRoot, "adapter"); + const realPath = path.join(moduleGeneratedRoot, "adapter-real"); + fs.renameSync(linkPath, realPath); + if (!createDirectoryLinkOrSkip(t, realPath, linkPath)) return; + + const before = snapshotTree(target); + const output = await runCliExpectFailure(["install", generated, "--target", target, "--target-base-url", targetBaseUrl, "--apply"]); + assert.match(output, /symbolic links|link/i); + assert.deepEqual(snapshotTree(target), before, "generated package links must be rejected before target writes"); + } finally { + await close(); + } +}); + +test("calendar Mode B install rejects target integrations/lark links in dry-run and apply before writes", { timeout: 120_000 }, async (t) => { + const { generated, target, targetBaseUrl, close } = await createGeneratedCalendarPackage(); + try { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-mode-b-outside-")); + fs.writeFileSync(path.join(outside, "OUTSIDE_SENTINEL.txt"), "outside sentinel\n", "utf8"); + fs.mkdirSync(path.join(target, "integrations"), { recursive: true }); + const targetLink = path.join(target, "integrations", "lark"); + if (!createDirectoryLinkOrSkip(t, outside, targetLink)) return; + + const beforeTarget = snapshotTree(target); + const beforeRootHashes = hashRootFiles(target); + const beforeOutside = snapshotTree(outside); + const dryRunOutput = await runCliExpectFailure(["install", generated, "--target", target, "--target-base-url", targetBaseUrl]); + assert.match(dryRunOutput, /symbolic links|link/i); + assert.deepEqual(snapshotTree(target), beforeTarget, "dry-run link rejection must not write target files"); + assert.deepEqual(hashRootFiles(target), beforeRootHashes, "dry-run link rejection must not rewrite target root files"); + assert.equal(fs.lstatSync(targetLink).isSymbolicLink(), true, "dry-run link rejection must leave integrations/lark as a link"); + assert.deepEqual(snapshotTree(outside), beforeOutside, "dry-run link rejection must not touch linked outside directory"); + + const applyOutput = await runCliExpectFailure(["install", generated, "--target", target, "--target-base-url", targetBaseUrl, "--apply"]); + assert.match(applyOutput, /symbolic links|link/i); + assert.deepEqual(snapshotTree(target), beforeTarget, "apply link rejection must not write target files"); + assert.deepEqual(hashRootFiles(target), beforeRootHashes, "apply link rejection must not rewrite target root files"); + assert.equal(fs.lstatSync(targetLink).isSymbolicLink(), true, "apply link rejection must leave integrations/lark as a link"); + assert.deepEqual(snapshotTree(outside), beforeOutside, "apply link rejection must not touch linked outside directory"); + } finally { + await close(); + } +}); + +test("calendar Mode B install detects managed-file checksum conflicts and preserves local edits", { timeout: 120_000 }, async () => { + const { generated, target, targetBaseUrl, close } = await createGeneratedCalendarPackage(); + try { + await runCli(["install", generated, "--target", target, "--target-base-url", targetBaseUrl, "--apply"]); + const managedFile = findInstalledLarkFile(target); + const localEdit = `${fs.readFileSync(managedFile, "utf8")}\n// local operator edit must survive\n`; + fs.writeFileSync(managedFile, localEdit, "utf8"); + + const output = await runCliExpectFailure(["install", generated, "--target", target, "--target-base-url", targetBaseUrl, "--apply"]); + assert.match(output, /checksum|conflict|modified|managed/i); + assert.equal(fs.readFileSync(managedFile, "utf8"), localEdit); + } finally { + await close(); + } +}); + +test("calendar Mode B strict verify rejects target prepare endpoints in capability_map", { timeout: 120_000 }, async () => { + const { generated, close } = await createGeneratedCalendarPackage(); + try { + const capabilityMapPath = path.join(generated, "manifest", "capability_map.json"); + const capabilityMap = readJson(capabilityMapPath); + capabilityMap.capabilities.push({ + id: "calendar.task.run.prepare", + name: "Invalid target prepare endpoint", + kind: "action", + risk: "write", + source: { type: "http", method: "POST", path: "/api/run/prepare", content_type: "application/json" }, + input_schema: { type: "object", properties: {} }, + output_schema: { type: "object", additionalProperties: true }, + artifacts: [], + timeout_seconds: 30, + }); + fs.writeFileSync(capabilityMapPath, `${JSON.stringify(capabilityMap, null, 2)}\n`, "utf8"); + + const output = await runCliExpectFailure(["verify", generated, "--mode", "embedded-adapter", "--strict"]); + assert.match(output, /\/api\/run\/prepare|strict|capability/i); + } finally { + await close(); + } +}); + +test("calendar Mode B install rejects a self-consistent module that diverges from package source of truth", { timeout: 120_000 }, async () => { + const { generated, target, targetBaseUrl, close } = await createGeneratedCalendarPackage(); + try { + const moduleRoot = path.join(generated, "integrations", "lark"); + const relativePath = "generated/adapter/handlers.js"; + const moduleFile = path.join(moduleRoot, ...relativePath.split("/")); + fs.appendFileSync(moduleFile, "\n// tampered module copy\n", "utf8"); + const installManifestPath = path.join(moduleRoot, "install-manifest.json"); + const installManifest = readJson(installManifestPath); + const entry = installManifest.files.find((file) => file.path === relativePath); + assert.ok(entry); + entry.sha256 = sha256(moduleFile); + entry.size = fs.statSync(moduleFile).size; + fs.writeFileSync(installManifestPath, `${JSON.stringify(installManifest, null, 2)}\n`, "utf8"); + + const output = await runCliExpectFailure(["install", generated, "--target", target, "--target-base-url", targetBaseUrl]); + assert.match(output, /source of truth|differs from package/i); + assert.equal(fs.existsSync(path.join(target, "integrations", "lark")), false); + } finally { + await close(); + } +}); + +test("generated calendar host-local confirmation test guards prepare, confirm idempotency, and stop semantics", { timeout: 120_000 }, async (t) => { + const { generated, close } = await createGeneratedCalendarPackage(); + const mockTarget = await startMockCalendarTarget(); + t.after(async () => { + await mockTarget.close(); + }); + try { + const localTestPath = path.join(generated, "integrations", "lark", "host-local-confirmation.test.mjs"); + assert.ok(fs.existsSync(localTestPath), "generated integrations/lark host-local confirmation test must exist"); + const output = await runNode(["--test", localTestPath], { + CALENDAR_TARGET_BASE_URL: mockTarget.baseUrl, + CALENDAR_ALLOWED_OPERATOR_OPEN_IDS: "ou_mode_b_operator", + }); + assert.match(output, /pass|ok|confirm/i); + + assert.equal(mockTarget.runCalls.length, 1, "prepare and duplicate confirm must not create extra /api/run calls"); + assert.equal(mockTarget.runCalls[0]?.mode, "run"); + assert.equal(mockTarget.stopCalls.length, 1, "stop confirmation must call /api/stop once after confirm"); + assert.equal(mockTarget.prepareCalls, 0, "host-local prepare must not call invented target prepare endpoints"); + assert.equal(mockTarget.confirmCalls, 0, "host-local confirm must not call invented target confirm endpoints"); + } finally { + await close(); + } +}); + +test("generated calendar adapter rejects unauthorized refresh before reading target state", { timeout: 120_000 }, async (t) => { + const { generated, close } = await createGeneratedCalendarPackage(); + const mockTarget = await startMockCalendarTarget(); + t.after(async () => { + await mockTarget.close(); + }); + try { + const driverPath = path.join(generated, "unauthorized-refresh-check.mjs"); + fs.writeFileSync(driverPath, [ + "import assert from 'node:assert/strict';", + "import { handleCardAction } from './integrations/lark/generated/adapter/handlers.js';", + "const result = await handleCardAction(", + " { action: 'calendar.status.refresh', operatorOpenId: 'ou_unapproved' },", + " { targetBaseUrl: process.env.CALENDAR_TARGET_BASE_URL, timeoutMs: 3000, allowedOperatorOpenIds: ['ou_approved'] },", + ");", + "assert.equal(result.ok, false);", + "assert.match(JSON.stringify(result.card), /not authorized|not allowed|未授权|未获授权/i);", + "console.log('unauthorized refresh blocked');", + "", + ].join("\n"), "utf8"); + + const stateCallsBefore = mockTarget.stateCalls; + const output = await runNode([driverPath], { CALENDAR_TARGET_BASE_URL: mockTarget.baseUrl }); + assert.match(output, /unauthorized refresh blocked/); + assert.equal(mockTarget.stateCalls, stateCallsBefore, "unauthorized refresh must not call GET /api/state"); + } finally { + await close(); + } +}); + +async function createGeneratedCalendarPackage() { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-mode-b-package-")); + const target = path.join(temp, "calendar-stock-updater"); + const workspace = path.join(temp, "analysis"); + const generated = path.join(temp, "generated", "calendar-stock-updater-lark"); + const mockTarget = await startMockCalendarTarget(); + writeCalendarTarget(target); + await runCli(["analyze", target, "--base-url", mockTarget.baseUrl, "--out", workspace]); + await runCli(["generate", workspace, "--out", generated, "--mode", "embedded-adapter", "--host-mode", "embedded-long-connection"]); + return { + generated, + target, + targetBaseUrl: mockTarget.baseUrl, + close: mockTarget.close, + }; +} + +function writeCalendarTarget(target) { + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, "MODE_B_ROOT_SENTINEL.txt"), "calendar target root sentinel\n", "utf8"); + fs.writeFileSync(path.join(target, "package.json"), `${JSON.stringify({ type: "module", scripts: { start: "node server.js" } }, null, 2)}\n`, "utf8"); + fs.writeFileSync( + path.join(target, "update-calendar-stock.js"), + [ + "export const CALENDAR_STOCK_UPDATER_MARKER = true;", + "export async function updateAllSkuRows() {", + " return { updated: 0 };", + "}", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(target, "task-config.js"), + [ + "export function resolveProductIdRange(input = {}) {", + " return { startId: Number(input.startId || 1000), endId: Number(input.endId || 1001) };", + "}", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(target, "server.js"), + [ + "import http from 'node:http';", + "const server = http.createServer((req, res) => {", + " const url = new URL(req.url || '/', 'http://127.0.0.1');", + " if (req.method === 'GET' && url.pathname === '/api/state') return json(res, 200, { ok: true, running: false, task: null, logs: [] });", + " if (req.method === 'GET' && url.pathname === '/api/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.end('event: ready\\ndata: {}\\n\\n'); return; }", + " if (req.method === 'POST' && url.pathname === '/api/run') return readJson(req, (body) => json(res, 200, { ok: true, task: { taskId: 'task-fixture', status: body.mode || 'dry-run' } }));", + " if (req.method === 'POST' && url.pathname === '/api/stop') return readJson(req, () => json(res, 200, { ok: true, stopped: true }));", + " json(res, 404, { error: 'not found' });", + "});", + "function readJson(req, done) { let raw = ''; req.on('data', (chunk) => { raw += chunk; }); req.on('end', () => done(raw ? JSON.parse(raw) : {})); }", + "function json(res, status, body) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); }", + "server.listen(process.env.PORT || 0, '127.0.0.1');", + "", + ].join("\n"), + "utf8", + ); +} + +function startMockCalendarTarget() { + const runCalls = []; + const stopCalls = []; + let stateCalls = 0; + let eventsCalls = 0; + let prepareCalls = 0; + let confirmCalls = 0; + let running = false; + const server = http.createServer((req, res) => { + const requestUrl = new URL(req.url || "/", "http://127.0.0.1"); + if (req.method === "GET" && requestUrl.pathname === "/api/state") { + stateCalls += 1; + writeJson(res, 200, { + defaults: { + stock: "100", + stepDelayMs: "500", + datePickerDelayMs: "500", + targetDate: "2026-10-01", + startDate: "2026-07-16", + endDate: "2026-10-01", + startProductId: "", + endProductId: "", + }, + task: { + status: running ? "running" : "idle", + pid: running ? 1234 : null, + mode: "run", + stock: "100", + startDate: "2026-07-16", + targetDate: "2026-10-01", + currentMessage: running ? "running" : "ready", + stopRequested: false, + }, + logs: [{ message: "ready" }], + }); + return; + } + if (req.method === "GET" && requestUrl.pathname === "/api/events") { + eventsCalls += 1; + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.end("event: ready\ndata: {}\n\n"); + return; + } + if (req.method === "POST" && requestUrl.pathname === "/api/run") { + readRequestJson(req).then((body) => { + runCalls.push(body); + running = true; + writeJson(res, 200, { ok: true, task: { taskId: `task-${runCalls.length}`, status: body.mode || "run" } }); + }); + return; + } + if (req.method === "POST" && requestUrl.pathname === "/api/stop") { + readRequestJson(req).then((body) => { + stopCalls.push(body); + running = false; + writeJson(res, 200, { ok: true, stopped: true }); + }); + return; + } + if (requestUrl.pathname.includes("/prepare")) prepareCalls += 1; + if (requestUrl.pathname.includes("/confirm")) confirmCalls += 1; + writeJson(res, 404, { error: "not found" }); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Mock calendar target did not bind to a TCP port.")); + return; + } + resolve({ + get baseUrl() { + return `http://127.0.0.1:${address.port}`; + }, + runCalls, + stopCalls, + get stateCalls() { + return stateCalls; + }, + get eventsCalls() { + return eventsCalls; + }, + get prepareCalls() { + return prepareCalls; + }, + get confirmCalls() { + return confirmCalls; + }, + close: () => new Promise((closeResolve, closeReject) => { + server.close((error) => (error ? closeReject(error) : closeResolve())); + }), + }); + }); + }); +} + +function runCli(args, options = {}) { + return runNode([cli, ...args], options.env || {}, options); +} + +async function runCliExpectFailure(args, options = {}) { + try { + const output = await runCli(args, options); + assert.fail(`Expected CLI command to fail: ${args.join(" ")}\n${output}`); + } catch (error) { + if (error && typeof error === "object" && "output" in error) return error.output; + throw error; + } +} + +function runNode(args, env = {}, options = {}) { + return new Promise((resolve, reject) => { + const childEnv = { ...process.env, ...env }; + delete childEnv.NODE_TEST_CONTEXT; + const child = spawn(process.execPath, args, { + cwd: options.cwd || root, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + const output = stdout + stderr; + if (code === 0) { + resolve(output); + return; + } + const error = new Error(`Command failed (${code}): ${process.execPath} ${args.join(" ")}\n${output}`); + error.code = code; + error.output = output; + reject(error); + }); + }); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function capabilityPaths(capabilityMap) { + return [...new Set((capabilityMap.capabilities || []).map((capability) => capability.source?.path).filter(Boolean))].sort(); +} + +function snapshotTree(directory) { + return Object.fromEntries(listFiles(directory).map((filePath) => [path.relative(directory, filePath).replaceAll(path.sep, "/"), sha256(filePath)])); +} + +function hashRootFiles(directory) { + return Object.fromEntries(fs.readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => [entry.name, sha256(path.join(directory, entry.name))])); +} + +function nonIntegrationChanges(directory, beforeRootHashes) { + return Object.entries(hashRootFiles(directory)) + .filter(([name, hash]) => beforeRootHashes[name] !== hash) + .map(([name]) => name); +} + +function createDirectoryLinkOrSkip(t, targetPath, linkPath) { + try { + fs.symlinkSync(targetPath, linkPath, process.platform === "win32" ? "junction" : "dir"); + return true; + } catch (error) { + if (isSkippableLinkCreationError(error)) { + t.skip(`Directory link creation is unavailable: ${error.code || "unknown"} ${error.message}`); + return false; + } + throw error; + } +} + +function isSkippableLinkCreationError(error) { + return error && typeof error === "object" && ["EPERM", "EACCES", "ENOTSUP", "ENOSYS", "EINVAL"].includes(error.code); +} + +function listFiles(directory) { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return listFiles(entryPath); + if (entry.isFile()) return [entryPath]; + return []; + }).sort(); +} + +function sha256(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function findInstalledLarkFile(target) { + const installedRoot = path.join(target, "integrations", "lark"); + const file = listFiles(installedRoot).find((item) => path.basename(item) !== ".code2lark-install.json" && /\.(?:js|mjs|ts|json|md)$/i.test(item)); + assert.ok(file, "installed integrations/lark must contain a managed text file"); + return file; +} + +function readRequestJson(req) { + return new Promise((resolve, reject) => { + let raw = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("error", reject); + req.on("end", () => { + try { + resolve(raw ? JSON.parse(raw) : {}); + } catch (error) { + reject(error); + } + }); + }); +} + +function writeJson(res, status, body) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(body)); +} From 84ea3987677b5eb33ed1aa7cf6017daf8729bb4b Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:47:42 +0800 Subject: [PATCH 30/37] feat: align calendar package lifecycle commands Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/context.ts | 166 ++++++++++++++++++++++++++++++---------- src/commands/plan.ts | 161 ++++++++++++++++++++++++++++++++++---- src/commands/verify.ts | 82 ++++++++++++++++++-- 3 files changed, 344 insertions(+), 65 deletions(-) diff --git a/src/commands/context.ts b/src/commands/context.ts index 70e9b77..2edbe23 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -3,12 +3,13 @@ import path from "node:path"; import { getStringOption } from "../args.js"; import { readJsonFile, slugify, writeJson, writeText } from "../fs-utils.js"; import { normalizeHostReceiveMode, type HostReceiveMode, type IntegrationMode } from "../host-mode.js"; -import type { RequiredPermissions, ServiceManifest } from "../types.js"; +import type { CapabilityMap, RequiredPermissions, ServiceManifest } from "../types.js"; export interface ContextTemplate { schema_version: "0.1"; integration_mode?: IntegrationMode; host_receive_mode?: HostReceiveMode; + target_profile?: string; purpose: string; target_service: { name: string; @@ -32,15 +33,16 @@ export interface ContextTemplate { manual_steps: string[]; }; runtime_config: { - host: string; - port: number; - upload_image_to_lark: boolean; + host?: string; + port?: number; + upload_image_to_lark?: boolean; target_timeout_seconds: number; - card_action_mode: "sync" | "async"; - feishu_openapi_base_url: string; - debug_access_token: string; + target_wait_seconds?: number; + card_action_mode?: "sync" | "async"; + feishu_openapi_base_url?: string; + debug_access_token?: string; allowed_operator_open_ids: string[]; - allow_debug_without_feishu: boolean; + allow_debug_without_feishu?: boolean; }; handoff_request: { recipient_hint: string; @@ -132,6 +134,8 @@ export async function contextCommand(args: string[], options: Record(path.join(manifestDir, "service_manifest.json")); const permissions = readJsonFile(path.join(manifestDir, "required_permissions.json")); + const capabilityPath = path.join(manifestDir, "capability_map.json"); + const targetProfile = fs.existsSync(capabilityPath) ? readJsonFile(capabilityPath).target_profile : undefined; const outFile = path.resolve(getStringOption(options, "out", path.join(workspace, "feishu_context.template.json"))); const markdownFile = outFile.replace(/\.json$/i, ".md"); const requestFile = contextRequestFilePath(outFile); @@ -139,7 +143,7 @@ export async function contextCommand(args: string[], options: Record --target-base-url ${service.service.base_url || "http://127.0.0.1:3069"}`, + `node dist/index.js install ${commandPackageArg} --target --target-base-url ${service.service.base_url || "http://127.0.0.1:3069"} --apply`, + ] : [ ...(selfHosted ? [] : [`node dist/index.js init-local ${commandPackageArg} --context --reply`]), ...(selfHosted ? [] : [`node dist/index.js configure ${commandPackageArg} --strict --dry-run`, `node dist/index.js configure ${commandPackageArg} --strict`]), ...selfHostedProjectSetupCommands, @@ -297,7 +308,12 @@ export function buildContextTemplate( ...projectEvidenceCommands, `node dist/index.js handoff ${commandPackageArg}`, ]; - const packageRootCommands = [ + const packageRootCommands = calendarModeB ? [ + `node ${packageRootCli} verify . --mode embedded-adapter --host-mode embedded-long-connection --strict`, + `node ${packageRootCli} handoff .`, + `node ${packageRootCli} install . --target --target-base-url ${service.service.base_url || "http://127.0.0.1:3069"}`, + `node ${packageRootCli} install . --target --target-base-url ${service.service.base_url || "http://127.0.0.1:3069"} --apply`, + ] : [ ...(selfHosted ? [] : [`node ${packageRootCli} init-local . --context --reply`, `node ${packageRootCli} configure . --strict --dry-run`, `node ${packageRootCli} configure . --strict`]), ...selfHostedPackageSetupCommands, `node ${packageRootCli} status .`, @@ -307,7 +323,13 @@ export function buildContextTemplate( ...packageEvidenceCommands, `node ${packageRootCli} handoff .`, ]; - const movedPackageCommands = [ + const movedPackageCommands = calendarModeB ? [ + "$env:LARK_DEPLOYER_CLI=\"C:\\path\\to\\Lark-deployer\\dist\\index.js\"", + "node $env:LARK_DEPLOYER_CLI verify . --mode embedded-adapter --host-mode embedded-long-connection --strict", + "node $env:LARK_DEPLOYER_CLI handoff .", + `node $env:LARK_DEPLOYER_CLI install . --target --target-base-url ${service.service.base_url || "http://127.0.0.1:3069"}`, + `node $env:LARK_DEPLOYER_CLI install . --target --target-base-url ${service.service.base_url || "http://127.0.0.1:3069"} --apply`, + ] : [ "$env:LARK_DEPLOYER_CLI=\"C:\\path\\to\\Lark-deployer\\dist\\index.js\"", ...(selfHosted ? [] : ["node $env:LARK_DEPLOYER_CLI init-local . --context --reply", "node $env:LARK_DEPLOYER_CLI configure . --strict --dry-run", "node $env:LARK_DEPLOYER_CLI configure . --strict"]), ...selfHostedMovedSetupCommands, @@ -323,6 +345,7 @@ export function buildContextTemplate( schema_version: "0.1", integration_mode: options.integrationMode || "standalone-runtime", host_receive_mode: hostReceiveMode, + target_profile: options.targetProfile, purpose: "Collect external Feishu/Lark and target-service context required for real Level 2 verification.", target_service: { name: service.service.name, @@ -349,7 +372,11 @@ export function buildContextTemplate( : permissions.callbacks.map((callback) => callback.callback), manual_steps: permissions.manual_steps, }, - runtime_config: { + runtime_config: calendarModeB ? { + target_timeout_seconds: 30, + target_wait_seconds: 30, + allowed_operator_open_ids: [], + } : { host: "127.0.0.1", port: 3978, upload_image_to_lark: true, @@ -370,6 +397,12 @@ export function buildContextTemplate( { key: "FEISHU_ALLOWED_USERS", owner: "FDE or bot tester", required_for_level_2: false, note: "Optional comma-separated Feishu operator open_id allowlist." }, { key: "IMAGE_AGENT_TIMEOUT_MS", owner: "FDE", required_for_level_2: false, note: "Optional target HTTP timeout in milliseconds; default 120000." }, { key: "TEST_CHAT_ID", owner: "FDE or bot tester", required_for_level_2: false, note: "Optional chat id for sending the start card in manual Level 2." }, + ] : calendarModeB ? [ + { key: "FEISHU_APP_ID", owner: "Feishu app owner", required_for_level_2: true, note: "Application ID for integrations/lark/.env." }, + { key: "FEISHU_APP_SECRET", owner: "Feishu app owner", required_for_level_2: true, note: "Application secret for integrations/lark/.env. Share through a secure channel only." }, + { key: "TEST_CHAT_ID", owner: "FDE or bot tester", required_for_level_2: true, note: "Chat receive id used by the isolated module to send the start card." }, + { key: "TARGET_BASE_URL", owner: "Target service owner", required_for_level_2: true, note: `Reachable base URL for ${service.service.name}; the install gate probes GET /api/state.` }, + { key: "ALLOWED_OPERATOR_OPEN_IDS", owner: "FDE or bot tester", required_for_level_2: true, note: "Non-empty comma-separated operator open_id allowlist for preview, formal run, and stop actions." }, ] : [ { key: "APP_ID", @@ -383,14 +416,14 @@ export function buildContextTemplate( required_for_level_2: true, note: "Application secret. Share through a secure channel; do not paste it into chat history.", }, - { + ...(longConnection && targetBaseUrlKey === "TARGET_BASE_URL" ? [] : [{ key: "VERIFICATION_TOKEN", owner: "Feishu app owner", required_for_level_2: hostReceiveMode !== "embedded-long-connection", note: hostReceiveMode === "embedded-long-connection" ? "Optional for long-connection hosts; needed only for webhook or hybrid callback verification." : "Callback verification token configured for the app.", - }, + }]), { key: "ENCRYPT_KEY", owner: "Feishu app owner", @@ -403,16 +436,16 @@ export function buildContextTemplate( required_for_level_2: true, note: "Chat receive id where the bot has been added and can send messages.", }, - { + ...(longConnection && targetBaseUrlKey === "TARGET_BASE_URL" ? [] : [{ key: "PUBLIC_CALLBACK_BASE_URL", owner: "FDE or infrastructure owner", required_for_level_2: hostReceiveMode !== "embedded-long-connection", note: hostReceiveMode === "embedded-long-connection" ? "Optional for long-connection hosts; needed only for webhook or hybrid callback verification." : "Public HTTPS base URL that routes to the generated bot runtime or embedded webhook host.", - }, + }]), { - key: "IMAGE_AGENT_BASE_URL", + key: targetBaseUrlKey, owner: "Target service owner", required_for_level_2: true, note: `Reachable base URL for ${service.service.name}; Lark-deployer does not start this service.`, @@ -426,12 +459,12 @@ export function buildContextTemplate( owner: "Feishu permission admin", security: [], })), - ...(selfHosted ? [{ + ...(selfHosted || longConnection ? [{ item: "card.action.trigger", - reason: "The Python feishu-host receives card actions through Feishu SDK long connection.", + reason: selfHosted ? "The Python feishu-host receives card actions through Feishu SDK long connection." : calendarModeB ? "The isolated integrations/lark host receives card actions through Feishu SDK long connection." : "The existing Feishu SDK host receives card actions through long connection.", risk: "n/a" as const, owner: "Feishu app owner or FDE", - security: ["long_connection", "FEISHU_CONNECTION_MODE=websocket"], + security: selfHosted ? ["long_connection", "FEISHU_CONNECTION_MODE=websocket"] : ["long_connection"], }] : permissions.callbacks.map((callback) => ({ item: callback.callback, reason: callback.reason, @@ -445,9 +478,13 @@ export function buildContextTemplate( { key: "IMAGE_AGENT_TIMEOUT_MS", recommended_value: "120000", note: "Target HTTP timeout used by service_client.py." }, { key: "FEISHU_ALLOWED_USERS", recommended_value: "", note: "Optional operator authorization guard." }, { key: "TEST_CHAT_ID", recommended_value: "", note: "Optional until app.py send-start-card flow is used." }, + ] : calendarModeB ? [ + { key: "TARGET_TIMEOUT_MS", recommended_value: "30000", note: "Finite timeout for /api/state, /api/run, and /api/stop calls from the isolated module." }, + { key: "TARGET_WAIT_MS", recommended_value: "30000", note: "Maximum startup wait for the existing calendar service." }, + { key: "ALLOWED_OPERATOR_OPEN_IDS", recommended_value: "", note: "Required authorization guard for mutating calendar actions." }, ] : longConnection ? [ { - key: "IMAGE_AGENT_TIMEOUT_MS", + key: targetBaseUrlKey === "TARGET_BASE_URL" ? "TARGET_TIMEOUT_MS" : "IMAGE_AGENT_TIMEOUT_MS", recommended_value: "120000", note: "Target service call timeout configured in the existing long-connection host service.", }, @@ -506,12 +543,16 @@ export function buildContextTemplate( readiness_questions: [ { id: "target_service_running", - question: `Can the bot runtime reach ${service.service.base_url || ""} from its runtime environment?`, + question: calendarModeB + ? `Can the installed integrations/lark module reach ${service.service.base_url || ""} and receive a valid GET /api/state response?` + : `Can the bot runtime reach ${service.service.base_url || ""} from its runtime environment?`, required_for_level_2: true, }, { id: "existing_feishu_app", - question: "Can the operator provide an existing Feishu custom app APP_ID and APP_SECRET?", + question: calendarModeB + ? "Can the operator provide an existing Feishu custom app FEISHU_APP_ID and FEISHU_APP_SECRET for integrations/lark/.env?" + : "Can the operator provide an existing Feishu custom app APP_ID and APP_SECRET?", required_for_level_2: true, }, { @@ -523,6 +564,8 @@ export function buildContextTemplate( id: "callback_public_url", question: selfHosted ? "Can the operator run feishu-host as a Python process with FEISHU_CONNECTION_MODE=websocket and subscribe the Feishu app to card.action.trigger?" + : calendarModeB + ? "Can the operator run the installed integrations/lark Node module and subscribe the Feishu app to card.action.trigger?" : hostReceiveMode === "embedded-long-connection" ? "Can the operator keep a Feishu SDK long-connection host online and subscribed to card.action.trigger?" : hybrid @@ -547,6 +590,7 @@ export function buildContextTemplate( export function buildContextMarkdown(template: ContextTemplate): string { const embedded = contextTemplateUsesEmbeddedAdapter(template); const selfHosted = template.integration_mode === "self-hosted-runtime"; + const calendarModeB = template.target_profile === "calendar-stock-updater"; const longConnection = template.host_receive_mode === "embedded-long-connection"; const hybrid = template.host_receive_mode === "hybrid"; const requiredRows = template.handoff_request.required_values @@ -555,20 +599,26 @@ export function buildContextMarkdown(template: ContextTemplate): string { const confirmationRows = template.handoff_request.permission_confirmations .map((item) => `| ${item.item} | ${item.reason} |`) .join("\n"); + const targetBaseUrlKey = template.handoff_request.required_values.some((item) => item.key === "TARGET_BASE_URL") ? "TARGET_BASE_URL" : "IMAGE_AGENT_BASE_URL"; + const targetTimeoutKey = targetBaseUrlKey === "TARGET_BASE_URL" ? "TARGET_TIMEOUT_MS" : "IMAGE_AGENT_TIMEOUT_MS"; const runtimeRows = template.handoff_request.runtime_choices .map((item) => `| ${item.key} | ${item.recommended_value} | ${item.note} |`) .join("\n"); - const runtimeConfigRows = [ + const runtimeConfigRows = (calendarModeB ? [ + ["TARGET_TIMEOUT_MS", String(template.runtime_config.target_timeout_seconds * 1000), "Target call timeout in milliseconds."], + ["TARGET_WAIT_MS", String((template.runtime_config.target_wait_seconds || 30) * 1000), "Maximum startup wait for GET /api/state."], + ["ALLOWED_OPERATOR_OPEN_IDS", template.runtime_config.allowed_operator_open_ids.length ? `${template.runtime_config.allowed_operator_open_ids.length} configured` : "", "Required comma-separated operator open_id allowlist for mutating actions."], + ] : [ ["HOST", template.runtime_config.host, "Runtime HTTP bind host."], ["PORT", String(template.runtime_config.port), "Runtime HTTP port."], ["UPLOAD_IMAGE_TO_LARK", template.runtime_config.upload_image_to_lark ? "1" : "0", "Upload result images to Feishu when possible."], - ["IMAGE_AGENT_TIMEOUT_MS", String(template.runtime_config.target_timeout_seconds * 1000), "Target call and image download timeout in milliseconds."], + [targetTimeoutKey, String(template.runtime_config.target_timeout_seconds * 1000), "Target call timeout in milliseconds."], ["CARD_ACTION_MODE", template.runtime_config.card_action_mode, "sync waits for the target call; async patches the original card later."], ["FEISHU_OPENAPI_BASE_URL", template.runtime_config.feishu_openapi_base_url || "", "Override only for local mocks or special environments."], ["DEBUG_ACCESS_TOKEN", template.runtime_config.debug_access_token ? "" : "", "Optional token required for /debug/* endpoints when set. Do not paste the real value into chat."], ["ALLOWED_OPERATOR_OPEN_IDS", template.runtime_config.allowed_operator_open_ids.length ? `${template.runtime_config.allowed_operator_open_ids.length} configured` : "", "Optional comma-separated operator open_id allowlist for card actions."], ["ALLOW_DEBUG_WITHOUT_FEISHU", template.runtime_config.allow_debug_without_feishu ? "1" : "0", "Allow local debug endpoints before real Feishu credentials are filled."], - ].map((item) => `| ${item[0]} | ${item[1]} | ${item[2]} |`).join("\n"); + ]).map((item) => `| ${item[0]} | ${item[1]} | ${item[2]} |`).join("\n"); const commandSections = template.handoff_request.command_sets .map((set) => `### ${humanizeCommandSetName(set.name)} @@ -576,7 +626,9 @@ ${set.cwd_hint} ${set.commands.map((command) => `\`\`\`powershell\n${command}\n\`\`\``).join("\n\n")}`) .join("\n\n"); - const manualSteps = longConnection + const manualSteps = calendarModeB + ? template.required_permissions.manual_steps + : longConnection ? [ "Enable bot capability in the Feishu developer console.", "Apply message send, optional message update, and resource upload scopes.", @@ -600,12 +652,20 @@ ${set.commands.map((command) => `\`\`\`powershell\n${command}\n\`\`\``).join("\n "IMAGE_AGENT_TIMEOUT_MS", "optional TEST_CHAT_ID", ] + : calendarModeB + ? [ + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "TEST_CHAT_ID", + "TARGET_BASE_URL", + "ALLOWED_OPERATOR_OPEN_IDS", + ] : longConnection ? [ "APP_ID", "APP_SECRET", "TEST_CHAT_ID", - "IMAGE_AGENT_BASE_URL or equivalent target base URL in the host config", + `${targetBaseUrlKey} in the host config`, "Long-connection host/gateway lifecycle owner", ] : hybrid @@ -651,7 +711,7 @@ Please provide the following values and confirmations for the generated Lark-dep Generated package path hint: \`${template.handoff_request.generated_package_hint}\` -Do not leave placeholder strings such as \`\`, \`{{VERIFICATION_TOKEN}}\`, or \`\${TEST_CHAT_ID}\` in the filled context. \`configure --strict --dry-run\` treats placeholder-shaped values as missing. +${calendarModeB ? "Do not leave placeholder strings in `integrations/lark/.env`; the module validates all required values at startup." : `Do not leave placeholder strings such as \`\`${longConnection ? "" : ", `{{VERIFICATION_TOKEN}}`"}, or \`\${TEST_CHAT_ID}\` in the filled context. \`configure --strict --dry-run\` treats placeholder-shaped values as missing.`} ### Required Values @@ -673,13 +733,13 @@ ${runtimeRows} ## Feishu App Values -${selfHosted ? "Fill these in `feishu-host/.env` after copying `feishu-host/.env.example`:" : embedded ? "Fill these in `feishu_context.template.json` or the existing host service's secret/config system:" : "Fill these in `feishu_context.template.json` or generated `bot-runtime/.env`:"} +${selfHosted ? "Fill these in `feishu-host/.env` after copying `feishu-host/.env.example`:" : calendarModeB ? "Fill these in the installed `integrations/lark/.env` after copying its `.env.example`:" : embedded ? "Fill these in `feishu_context.template.json` or the existing host service's secret/config system:" : "Fill these in `feishu_context.template.json` or generated `bot-runtime/.env`:"} ${feishuAppValueRows.map((item) => `- ${item}`).join("\n")} ## Runtime Config Values -${selfHosted ? "These values document the Python feishu-host runtime contract:" : embedded ? "These values document the existing host service runtime contract for adapter integration:" : "These values are read from `runtime_config` when `configure` writes `bot-runtime/.env`:"} +${selfHosted ? "These values document the Python feishu-host runtime contract:" : calendarModeB ? "These values document the installed Node module runtime contract:" : embedded ? "These values document the existing host service runtime contract for adapter integration:" : "These values are read from `runtime_config` when `configure` writes `bot-runtime/.env`:"} | Env key | Current value | Note | | --- | --- | --- | @@ -706,6 +766,7 @@ ${commandSections} export function buildContextRequestMarkdown(template: ContextTemplate): string { const embedded = contextTemplateUsesEmbeddedAdapter(template); const selfHosted = template.integration_mode === "self-hosted-runtime"; + const calendarModeB = template.target_profile === "calendar-stock-updater"; const longConnection = template.host_receive_mode === "embedded-long-connection"; const hybrid = template.host_receive_mode === "hybrid"; const requiredRows = template.handoff_request.required_values @@ -731,6 +792,12 @@ export function buildContextRequestMarkdown(template: ContextTemplate): string { "Can grant or request the listed scopes, then publish the app version.", "Can enable long connection and subscribe to card.action.trigger.", `Can keep ${template.target_service.name} reachable from feishu-host during local contract and Level 2 verification.`, + ].map((item) => `- [ ] ${item}`).join("\n") : calendarModeB ? [ + "Can provide or create a Feishu custom app for this test.", + "Can enable bot capability, long connection, and card.action.trigger, then add the bot to the test chat.", + "Can provide module-local FEISHU_APP_ID and FEISHU_APP_SECRET through a secure channel.", + "Can provide a non-empty ALLOWED_OPERATOR_OPEN_IDS allowlist.", + `Can keep ${template.target_service.name} reachable from the installed integrations/lark module during Level 2 verification.`, ].map((item) => `- [ ] ${item}`).join("\n") : [ "Can provide or create a Feishu custom app for this test.", "Can enable bot capability and add the bot to the test chat.", @@ -755,8 +822,8 @@ ${contextDeliveryModeMarkdown()} ## Request -Please confirm whether you can provide or configure the values, permissions, callback, and test chat below. Do not paste real secrets into normal chat or shared Markdown; use a secure secret channel for secret values. -${selfHosted ? "When filling `feishu-host/.env`, replace placeholder strings completely. `verify --mode self-hosted-runtime --strict` checks generated specs, Python compilation, the local contract test, and app.py --selfcheck." : embedded ? "When filling `feishu_context.local.json` or the existing host service's secret/config system, replace placeholder strings completely; adapter verification treats values like ``, `{{VERIFICATION_TOKEN}}`, and `${TEST_CHAT_ID}` as missing." : "When filling `feishu_context.local.json` or `bot-runtime/.env`, replace placeholder strings completely; `configure --strict --dry-run` treats values like ``, `{{VERIFICATION_TOKEN}}`, and `${TEST_CHAT_ID}` as missing."} +Please confirm whether you can provide or configure the values, permissions, ${longConnection ? "long-connection subscription" : "callback"}, and test chat below. Do not paste real secrets into normal chat or shared Markdown; use a secure secret channel for secret values. +${selfHosted ? "When filling `feishu-host/.env`, replace placeholder strings completely. `verify --mode self-hosted-runtime --strict` checks generated specs, Python compilation, the local contract test, and app.py --selfcheck." : calendarModeB ? "After install --apply, copy integrations/lark/.env.example to integrations/lark/.env and replace every required value; the module rejects missing values, embedded target credentials, and an empty operator allowlist." : embedded ? `When filling \`feishu_context.local.json\` or the existing host service's secret/config system, replace placeholder strings completely; adapter verification treats values like \`\`${longConnection ? "" : ", `{{VERIFICATION_TOKEN}}`"}, and \`\${TEST_CHAT_ID}\` as missing.` : "When filling `feishu_context.local.json` or `bot-runtime/.env`, replace placeholder strings completely; `configure --strict --dry-run` treats values like ``, `{{VERIFICATION_TOKEN}}`, and `${TEST_CHAT_ID}` as missing."} ## Required Values @@ -785,16 +852,16 @@ ${setupRows} \`\`\`text can_provide_existing_app_context: yes/no can_grant_permissions: yes/no -${selfHosted ? "long_connection_enabled: yes/no\ncard_action_trigger_subscribed: yes/no" : `can_configure_card_callback: yes/no\n${longConnection ? "long_connection_online: yes/no" : hybrid ? "card_callback_url_configured: /webhook/card yes/no\nlong_connection_online: yes/no" : "card_callback_url_configured: /webhook/card yes/no"}`} +${selfHosted || calendarModeB ? "long_connection_enabled: yes/no\ncard_action_trigger_subscribed: yes/no" : `can_configure_card_callback: yes/no\n${longConnection ? "long_connection_online: yes/no" : hybrid ? "card_callback_url_configured: /webhook/card yes/no\nlong_connection_online: yes/no" : "card_callback_url_configured: /webhook/card yes/no"}`} can_add_bot_to_test_chat: yes/no secure_secret_channel: ${selfHosted || longConnection ? "" : ""} target_base_url: -${selfHosted ? "feishu_host_owner: " : longConnection ? "long_connection_gateway_owner: " : hybrid ? "public_callback_base_url: \nlong_connection_gateway_owner: " : "public_callback_base_url: "} +${selfHosted ? "feishu_host_owner: " : calendarModeB ? "integrations_lark_owner: " : longConnection ? "long_connection_gateway_owner: " : hybrid ? "public_callback_base_url: \nlong_connection_gateway_owner: " : "public_callback_base_url: "} test_chat_id_available: yes/no blocked_by: \`\`\` -${selfHosted ? "After the non-secret answers are confirmed, fill `feishu-host/.env`, run `python feishu-host/local_contract_test.py`, run `python feishu-host/app.py --selfcheck`, then run `verify --mode self-hosted-runtime --strict`." : embedded ? `After the non-secret answers are confirmed, mount the adapter in the existing Feishu SDK host, then run \`verify --mode embedded-adapter${longConnection ? " --host-mode embedded-long-connection" : ""} --host-runtime-url --simulate\` and record real Feishu evidence in \`level2_verification_record.md\`.` : "After the non-secret answers are confirmed, fill `feishu_context.local.json` or `bot-runtime/.env` locally and run `configure`, `verify --level2`, and `evidence --update-record`."} +${selfHosted ? "After the non-secret answers are confirmed, fill `feishu-host/.env`, run `python feishu-host/local_contract_test.py`, run `python feishu-host/app.py --selfcheck`, then run `verify --mode self-hosted-runtime --strict`." : calendarModeB ? "After the non-secret answers are confirmed, run install dry-run, run install --apply, fill integrations/lark/.env, run the module tests, and then record real Feishu evidence in level2_verification_record.md." : embedded ? `After the non-secret answers are confirmed, mount the adapter in the existing Feishu SDK host, then run \`verify --mode embedded-adapter${longConnection ? " --host-mode embedded-long-connection" : ""} --host-runtime-url --simulate\` and record real Feishu evidence in \`level2_verification_record.md\`.` : "After the non-secret answers are confirmed, fill `feishu_context.local.json` or `bot-runtime/.env` locally and run `configure`, `verify --level2`, and `evidence --update-record`."} `; } @@ -815,14 +882,20 @@ function contextTemplateUsesEmbeddedAdapter(template: ContextTemplate): boolean export function buildContextReplyTemplate(template: ContextTemplate): ContextReplyTemplate { const embedded = contextTemplateUsesEmbeddedAdapter(template); const selfHosted = template.integration_mode === "self-hosted-runtime"; + const calendarModeB = template.target_profile === "calendar-stock-updater"; const longConnection = template.host_receive_mode === "embedded-long-connection"; const hybrid = template.host_receive_mode === "hybrid"; const hostModeOption = template.host_receive_mode && template.host_receive_mode !== "embedded-webhook" && template.host_receive_mode !== "standalone-runtime" ? ` --host-mode ${template.host_receive_mode}` : ""; + const targetBaseUrlKey = template.handoff_request.required_values.some((item) => item.key === "TARGET_BASE_URL") ? "TARGET_BASE_URL" : "target base URL"; return { schema_version: "0.1", - purpose: selfHosted ? "Record non-secret Feishu/Lark owner answers before filling feishu-host/.env and running self-hosted verification." : "Record non-secret Feishu/Lark owner answers before filling local credentials and running configure --strict --dry-run.", + purpose: selfHosted + ? "Record non-secret Feishu/Lark owner answers before filling feishu-host/.env and running self-hosted verification." + : calendarModeB + ? "Record non-secret Feishu/Lark owner answers before installing and configuring the isolated integrations/lark module." + : "Record non-secret Feishu/Lark owner answers before filling local credentials and running configure --strict --dry-run.", generated_package_hint: template.handoff_request.generated_package_hint, target_service: { name: template.target_service.name, @@ -869,11 +942,19 @@ export function buildContextReplyTemplate(template: ContextTemplate): ContextRep "Run python feishu-host/app.py --selfcheck.", "Run verify --mode self-hosted-runtime --strict, then record real Feishu evidence in level2_verification_record.md when available.", ] + : calendarModeB + ? [ + "Run install dry-run against the live calendar target and review the planned integrations/lark files.", + "Run install --apply after review; confirm target root files remain unchanged.", + "Copy integrations/lark/.env.example to integrations/lark/.env and fill FEISHU_APP_ID, FEISHU_APP_SECRET, TEST_CHAT_ID, TARGET_BASE_URL, and ALLOWED_OPERATOR_OPEN_IDS.", + "Run npm install and npm test inside integrations/lark, then start the target and module separately.", + "Record real Feishu evidence in level2_verification_record.md when available.", + ] : embedded ? [ "Run init-local --context --reply, or copy feishu_context.template.json to feishu_context.local.json manually.", longConnection - ? "Fill APP_ID, APP_SECRET, TEST_CHAT_ID, and target base URL in the existing long-connection host service's secret/config system. Do not put secrets in this reply template." + ? `Fill APP_ID, APP_SECRET, TEST_CHAT_ID, and ${targetBaseUrlKey} in the existing long-connection host service's secret/config system. Do not put secrets in this reply template.` : hybrid ? "Fill APP_ID, APP_SECRET, VERIFICATION_TOKEN, TEST_CHAT_ID, PUBLIC_CALLBACK_BASE_URL, long-connection gateway owner, and target base URL in the existing host service's secret/config system. Do not put secrets in this reply template." : "Fill APP_ID, APP_SECRET, VERIFICATION_TOKEN, TEST_CHAT_ID, PUBLIC_CALLBACK_BASE_URL, and target base URL in the existing host service's secret/config system. Do not put secrets in this reply template.", @@ -889,6 +970,8 @@ export function buildContextReplyTemplate(template: ContextTemplate): ContextRep secret_red_lines: [ selfHosted ? "Do not paste FEISHU_APP_SECRET into this reply template." + : calendarModeB + ? "Do not paste FEISHU_APP_SECRET or raw operator IDs into this reply template." : longConnection ? "Do not paste APP_SECRET into this reply template." : "Do not paste APP_SECRET, VERIFICATION_TOKEN, ENCRYPT_KEY, or DEBUG_ACCESS_TOKEN into this reply template.", @@ -899,10 +982,13 @@ export function buildContextReplyTemplate(template: ContextTemplate): ContextRep } export function buildContextReplyMarkdown(reply: ContextReplyTemplate): string { + const longConnection = reply.runtime_choices.some((item) => item.key === "TARGET_TIMEOUT_MS") + || reply.next_local_steps.some((item) => item.includes("long-connection")); const answers = Object.entries(reply.answers) .map(([key, value]) => `| ${key} | ${value === null ? "unknown" : value ? "yes" : "no"} |`) .join("\n"); const publicRows = Object.entries(reply.public_values) + .filter(([key]) => !(longConnection && key === "public_callback_base_url")) .map(([key, value]) => `| ${key} | ${value || ""} |`) .join("\n"); const permissionRows = reply.permission_confirmations diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 4386601..81feb08 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { readJsonFile, writeText } from "../fs-utils.js"; +import { hostModeUsesLongConnection, type HostReceiveMode } from "../host-mode.js"; import type { CapabilityMap, InteractionContract, RequiredPermissions, ServiceManifest } from "../types.js"; export async function planCommand(args: string[]): Promise { @@ -14,10 +15,18 @@ export async function planCommand(args: string[]): Promise { const capabilities = readJsonFile(path.join(manifestDir, "capability_map.json")); const interactions = readJsonFile(path.join(manifestDir, "interaction_contract.json")); const permissions = readJsonFile(path.join(manifestDir, "required_permissions.json")); + const targetProfile = capabilities.target_profile; + const calendarModeB = targetProfile === "calendar-stock-updater"; writeText(path.join(workspace, "permission_review.md"), buildPermissionReview(service, capabilities, interactions, permissions)); - writeText(path.join(workspace, "deployment_checklist.md"), buildDeploymentChecklist(service, permissions)); - writeText(path.join(workspace, "card_plan.md"), buildCardPlan(service)); + writeText(path.join(workspace, "deployment_checklist.md"), buildDeploymentChecklist( + service, + permissions, + calendarModeB ? "embedded-adapter" : "standalone-runtime", + calendarModeB ? "embedded-long-connection" : "embedded-webhook", + targetProfile, + )); + writeText(path.join(workspace, "card_plan.md"), buildCardPlan(service, targetProfile)); writeText(path.join(workspace, "context_readiness.md"), buildContextReadiness(permissions)); console.log(`Plan documents written to ${workspace}`); @@ -82,15 +91,50 @@ export function buildDeploymentChecklist( service: ServiceManifest, permissions: RequiredPermissions, integrationMode: "embedded-adapter" | "standalone-runtime" | "self-hosted-runtime" = "standalone-runtime", + hostReceiveMode: HostReceiveMode = "embedded-webhook", + targetProfile = "image-agent-web", ): string { + if (targetProfile === "calendar-stock-updater") { + return buildCalendarDeploymentChecklist(service, permissions); + } if (integrationMode === "embedded-adapter") { + const longConnection = hostModeUsesLongConnection(hostReceiveMode); + const hostModeOption = hostReceiveMode === "embedded-webhook" ? "" : ` --host-mode ${hostReceiveMode}`; + const targetBaseUrlKey = targetProfile === "generic-http-api" ? "TARGET_BASE_URL" : "IMAGE_AGENT_BASE_URL"; + const adapterHandler = targetProfile === "generic-http-api" ? "handleGenericHttpCardAction()" : "the generated adapter handler"; + const targetMetaCheck = targetProfile === "generic-http-api" ? "Confirm the target health/read endpoint selected by analysis is reachable." : "Confirm GET /api/meta returns template metadata."; + const secretStep = longConnection + ? `Store APP_ID, APP_SECRET, TEST_CHAT_ID, ${targetBaseUrlKey}, and long-connection host ownership in the existing host service's secret/config system.` + : `Store APP_ID, APP_SECRET, VERIFICATION_TOKEN, TEST_CHAT_ID, PUBLIC_CALLBACK_BASE_URL, and ${targetBaseUrlKey} in the existing host service's secret/config system.`; + const debugStep = longConnection + ? "Set DEBUG_ACCESS_TOKEN before any host-owned debug endpoints are exposed outside the trusted local environment." + : "Set DEBUG_ACCESS_TOKEN before any host-owned debug endpoints are exposed through a public callback URL."; + const ingressStep = longConnection + ? `Subscribe the existing Feishu SDK host to \`card.action.trigger\` and route events to ${adapterHandler}.` + : "Configure callback URL to `/webhook/card` on the existing host."; + const doneChecks = targetProfile === "generic-http-api" + ? [ + "A real Feishu test chat receives the generated generic HTTP start card from the existing host.", + `Clicking a generated action makes the existing host call adapter/ and the selected ${service.service.name} HTTP endpoint.`, + "The result card appears with the target JSON result or a readable failure summary.", + "Rejected/destructive endpoints that are not reviewed do not appear as direct card actions.", + "level2_verification_record.md contains the final operator, message id or screenshot, trace id, target request summary, and completion decision.", + ] + : [ + "A real Feishu test chat receives the start card from the existing host.", + `Clicking the preset generate button makes the existing host call adapter/ and ${service.service.name} /api/generate.`, + "The result card appears with analysis and generated image or fallback image URL.", + `Batch submit and refresh call adapter/ and ${service.service.name} /api/batch endpoints.`, + "Failure paths show readable error cards.", + "level2_verification_record.md contains the final operator, message id or screenshot, trace id, and completion decision.", + ]; return `# Deployment Checklist ## Target Service - [ ] Confirm ${service.service.name} is running outside Lark-deployer. -- [ ] Confirm the existing Feishu SDK host can reach ${service.service.base_url || ""}. -- [ ] Confirm GET /api/meta returns template metadata. +- [ ] Confirm the existing Feishu SDK host can reach ${service.service.base_url || `<${targetBaseUrlKey}>`}. +- [ ] ${targetMetaCheck} ## Feishu/Lark App @@ -99,28 +143,23 @@ ${permissions.manual_steps.map((item) => `- [ ] ${item}`).join("\n")} ## Embedded Host Environment - [ ] Mount generated \`adapter/\` in the existing Feishu SDK service. -- [ ] Store APP_ID, APP_SECRET, VERIFICATION_TOKEN, TEST_CHAT_ID, PUBLIC_CALLBACK_BASE_URL, and target base URL in the existing host service's secret/config system. +- [ ] ${secretStep} - [ ] Fill ENCRYPT_KEY only if encrypted callbacks are enabled in Feishu. -- [ ] Set DEBUG_ACCESS_TOKEN before any host-owned debug endpoints are exposed through a public callback URL. +- [ ] ${debugStep} - [ ] Decide whether ALLOWED_OPERATOR_OPEN_IDS should restrict who can execute card actions in the test chat. - [ ] Choose the correct Lark-deployer CLI command style for this package: - If the package still lives under the original Lark-deployer repository, use \`node ..\\..\\dist\\index.js .\`. - If the package was copied elsewhere, set \`$env:LARK_DEPLOYER_CLI="C:\\path\\to\\Lark-deployer\\dist\\index.js"\` and use \`node $env:LARK_DEPLOYER_CLI .\`. -- [ ] Run \`node ..\\..\\dist\\index.js verify . --mode embedded-adapter --strict\` or \`node $env:LARK_DEPLOYER_CLI verify . --mode embedded-adapter --strict\` from the generated package root. -- [ ] Run \`node ..\\..\\dist\\index.js verify . --mode embedded-adapter --host-runtime-url --simulate\` or \`node $env:LARK_DEPLOYER_CLI verify . --mode embedded-adapter --host-runtime-url --simulate\` after adapter/ is mounted in the existing host. -- [ ] Configure callback URL to \`/webhook/card\` on the existing host. +- [ ] Run \`node ..\\..\\dist\\index.js verify . --mode embedded-adapter${hostModeOption} --strict\` or \`node $env:LARK_DEPLOYER_CLI verify . --mode embedded-adapter${hostModeOption} --strict\` from the generated package root. +- [ ] Run \`node ..\\..\\dist\\index.js verify . --mode embedded-adapter${hostModeOption} --host-runtime-url --simulate\` or \`node $env:LARK_DEPLOYER_CLI verify . --mode embedded-adapter${hostModeOption} --host-runtime-url --simulate\` after adapter/ is mounted in the existing host. +- [ ] ${ingressStep} - [ ] Run \`node ..\\..\\dist\\index.js evidence .\` or \`node $env:LARK_DEPLOYER_CLI evidence .\` after verification and review \`level2_evidence_draft.md\`. -- [ ] Run \`node ..\\..\\dist\\index.js doctor . --mode embedded-adapter --gate\` or \`node $env:LARK_DEPLOYER_CLI doctor . --mode embedded-adapter --gate\` and confirm remaining blockers are external host/Level 2 evidence only. +- [ ] Run \`node ..\\..\\dist\\index.js doctor . --mode embedded-adapter${hostModeOption} --gate\` or \`node $env:LARK_DEPLOYER_CLI doctor . --mode embedded-adapter${hostModeOption} --gate\` and confirm remaining blockers are external host/Level 2 evidence only. - [ ] Run \`node ..\\..\\dist\\index.js handoff .\` or \`node $env:LARK_DEPLOYER_CLI handoff .\` and exclude local secrets before copying the package. ## Done -- [ ] A real Feishu test chat receives the start card from the existing host. -- [ ] Clicking the preset generate button makes the existing host call adapter/ and ${service.service.name} /api/generate. -- [ ] The result card appears with analysis and generated image or fallback image URL. -- [ ] Batch submit and refresh call adapter/ and ${service.service.name} /api/batch endpoints. -- [ ] Failure paths show readable error cards. -- [ ] level2_verification_record.md contains the final operator, message id or screenshot, trace id, and completion decision. +${doneChecks.map((item) => `- [ ] ${item}`).join("\n")} `; } if (integrationMode === "self-hosted-runtime") { @@ -198,7 +237,47 @@ ${permissions.manual_steps.map((item) => `- [ ] ${item}`).join("\n")} `; } -function buildCardPlan(service: ServiceManifest): string { +function buildCalendarDeploymentChecklist(service: ServiceManifest, permissions: RequiredPermissions): string { + return `# Deployment Checklist - Calendar Mode B + +## Target Service + +- [ ] Confirm ${service.service.name} is running under the target project's existing lifecycle. +- [ ] Confirm the isolated Lark module can reach ${service.service.base_url || ""}. +- [ ] Confirm GET /api/state returns defaults, current task state, and bounded recent logs. +- [ ] Confirm the only target write endpoints are POST /api/run and POST /api/stop. + +## Feishu/Lark App + +${permissions.manual_steps.map((item) => `- [ ] ${item}`).join("\n")} + +## Candidate And Install Review + +- [ ] Keep the generated package as the source of truth; do not copy individual adapter files by hand. +- [ ] Run strict package verification for embedded-adapter with embedded-long-connection. +- [ ] Start the existing target service, then run install . --target as a zero-write dry-run. +- [ ] Review every planned path and confirm all writes are under integrations/lark. +- [ ] After review, run install . --target --apply. +- [ ] Copy integrations/lark/.env.example to the module-local integrations/lark/.env and fill only module settings. +- [ ] Run npm install and npm test inside integrations/lark. +- [ ] Start the calendar service and the isolated Lark module separately; do not change target root startup files. + +## Local Completion + +- [ ] Status refresh calls only GET /api/state. +- [ ] Ordinary preview calls POST /api/run with mode=dry-run. +- [ ] Formal run uses host-local prepare/confirm/cancel and calls POST /api/run with mode=run only after confirmation. +- [ ] Stop uses host-local prepare/confirm/cancel and calls POST /api/stop only after confirmation. +- [ ] Root project files remain byte-identical after install. + +## Real Feishu Level 2 + +Real Feishu Level 2 is separate from local generation and installation. Complete it only after the real app, long connection, test chat, card clicks, and sanitized evidence are available. +`; +} + +function buildCardPlan(service: ServiceManifest, targetProfile = "image-agent-web"): string { + if (targetProfile === "calendar-stock-updater") return buildCalendarCardPlan(service); return `# Card Plan ## Pattern @@ -231,6 +310,54 @@ Use an action card followed by running/success/failure status cards. The success `; } +function buildCalendarCardPlan(service: ServiceManifest): string { + return `# Card Plan - Calendar Mode B + +## Pattern + +Use a process/task operations card with separate status, parameter, action, and recent-log sections. Keep the decision path clear on mobile and use color only for semantic status. + +## Status Card + +- Header: blue for idle, yellow for running, green for succeeded, red for failed, and grey for stopped. +- Status summary: current state, current message, date range, stock, product range, and start time. +- Refresh action: authorized operators call GET /api/state manually; browser-only GET /api/events is excluded. + +## Task Form + +- Required fields: target date, stock, normal-operation delay, and date-picker delay. +- Optional fields: start product ID and end product ID. +- Primary safe action: ordinary dry-run preview. +- High-risk action: request formal execution. + +## Formal Run Flow + +- Prepare: validate the full form without calling a target write endpoint. +- Confirm: show every submitted value and a professional inventory-write risk notice. +- Execute: call POST /api/run with mode=run once after operator-bound confirmation. +- Cancel: discard the host-local confirmation and return to current state. + +## Stop Flow + +- Prepare: read current state and create an operator-bound host-local confirmation. +- Confirm: state that only the current running task will be stopped, then call POST /api/stop once. +- Cancel: discard the confirmation without calling a target write endpoint. + +## Recent Logs + +- Show at most the latest 8 entries. +- Preserve an available timestamp, flatten multiline content, truncate long lines, and state the display limit. + +## Failure Card + +- Header: red failed state. +- Body: concise reason without secrets or raw operator/chat identifiers. +- Recovery: return to the operations card after the operator corrects the issue. + +- Source service: ${service.service.name}. +`; +} + function buildContextReadiness(permissions: RequiredPermissions): string { return `# Context Readiness Check diff --git a/src/commands/verify.ts b/src/commands/verify.ts index 21d6d85..669e72e 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -66,6 +66,7 @@ export async function verifyCommand(args: string[], options: Record endpointKey(endpoint.method, endpoint.path))); + const missing = capabilities.capabilities + .map((capability) => ({ id: capability.id, method: capability.source.method, path: capability.source.path })) + .filter((endpoint) => !discovered.has(endpointKey(endpoint.method, endpoint.path))); + return { + name: "manifest:strict:capability-endpoints-discovered", + status: missing.length ? "fail" : "pass", + detail: missing.length + ? `Capability endpoints were not discovered in target source: ${missing.map((item) => `${item.id}=${item.method} ${item.path}`).join(", ")}.` + : "Every generated capability endpoint was discovered in target source.", + }; +} + +function checkCalendarCapabilityContract(capabilities: CapabilityMap | undefined): CheckResult { + const expected = ["GET /api/state", "POST /api/run", "POST /api/stop"]; + const actual = capabilities + ? capabilities.capabilities.map((capability) => endpointKey(capability.source.method, capability.source.path)).sort() + : []; + const valid = actual.length === expected.length && actual.every((value, index) => value === expected[index]); + return { + name: "manifest:strict:calendar-target-contract", + status: valid ? "pass" : "fail", + detail: valid + ? "calendar-stock-updater capabilities are limited to GET /api/state, POST /api/run, and POST /api/stop." + : `calendar-stock-updater capability endpoints must be exactly ${expected.join(", ")}; found ${actual.join(", ") || "none"}.`, + }; +} + +function endpointKey(method: string, endpointPath: string): string { + return `${String(method).toUpperCase()} ${endpointPath}`; +} + function checkManifestSchemaVersion(name: string, value: { schema_version?: string } | undefined): CheckResult { const actual = value?.schema_version; return { @@ -792,8 +857,8 @@ function checkAdapterStartCardBuilder(packagePath: string): CheckResult { const cardsPath = path.join(packagePath, "adapter", "cards.ts"); const source = fs.existsSync(cardsPath) ? fs.readFileSync(cardsPath, "utf8") : ""; const hasBuilder = source.includes("export function buildStartCard") - && (source.includes("image_generate_form") || source.includes("actionSpecs")) - && (source.includes("image.batch.submit") || source.includes("actionId")); + && (source.includes("image_generate_form") || source.includes("actionSpecs") || source.includes("calendar_task_form")) + && (source.includes("image.batch.submit") || source.includes("actionId") || source.includes("calendar.")); return { name: "adapter:start-card-builder", status: hasBuilder ? "pass" : "fail", @@ -840,8 +905,8 @@ function executeEmbeddedAdapterSmoke(packagePath: string): CheckResult[] { const startCard = cards.buildStartCard(); const startCardText = JSON.stringify(startCard); if (!startCardText.includes("image_generate_form") && !startCardText.includes("actionId") && !startCardText.includes('action"')) throw new Error("missing card action controls"); - if (!startCardText.includes("image.batch.submit") && !startCardText.includes("http.")) throw new Error("missing expected card action id"); - const handler = handlers.handleGenericHttpCardAction || handlers.handleImageAgentCardAction; + if (!startCardText.includes("image.batch.submit") && !startCardText.includes("http.") && !startCardText.includes("calendar.")) throw new Error("missing expected card action id"); + const handler = handlers.handleCardAction || handlers.handleGenericHttpCardAction || handlers.handleImageAgentCardAction; const result = await handler({ action: "unsupported.action" }, { imageAgentBaseUrl: "http://127.0.0.1:1", targetBaseUrl: "http://127.0.0.1:1" }); if (result?.ok !== false || !result?.card) throw new Error("handler did not return a failure card for unsupported action"); `; @@ -1644,7 +1709,8 @@ function buildNextSteps( ) { steps.push("Check that `VERIFICATION_TOKEN` matches the Feishu callback token, the public proxy preserves request body and `x-lark-*` headers, and the target service can complete the signed action request."); } - if (byName.get("target:/api/meta")?.status !== "pass") { + const targetMetaCheck = byName.get("target:/api/meta"); + if (targetMetaCheck && targetMetaCheck.status !== "pass") { steps.push(embedded ? "Start or expose the target service so the existing host can reach `GET /api/meta`." : "Start or expose the target service so the bot runtime can reach `GET /api/meta`."); From c92216c6a4b9ae3c46fdb5f3f309aa4f76e83de1 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:47:42 +0800 Subject: [PATCH 31/37] fix: align calendar readiness and handoff reporting Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/doctor.ts | 42 ++++--- src/commands/handoff.ts | 140 +++++++++++++++------ src/commands/readiness.ts | 249 +++++++++++++++++++++++++++++++++----- 3 files changed, 344 insertions(+), 87 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index c0d40a7..8362f7d 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -4,7 +4,7 @@ import { getStringOption, hasOption } from "../args.js"; import { writeJson, writeText } from "../fs-utils.js"; import { getJsonWithTimeout } from "../http-utils.js"; import { hostModeUsesLongConnection, hostModeUsesWebhook, normalizeHostReceiveMode, type HostReceiveMode } from "../host-mode.js"; -import { buildReadinessSummary, type ReadinessSummary } from "./readiness.js"; +import { buildReadinessSummary, resolveHandoffProfile, type HandoffProfile, type ReadinessSummary } from "./readiness.js"; type DoctorVerdict = "pass" | "not_ready"; type TargetPreflightStatus = "pass" | "warn" | "fail" | "missing"; @@ -20,8 +20,8 @@ export interface TargetLiveProbeReport { } export interface TargetPreflightReport { - check_name: "target:/api/meta"; - check_path: "/api/meta"; + check_name: "target:/api/meta" | "target:/api/state"; + check_path: "/api/meta" | "/api/state"; status: TargetPreflightStatus; detail: string; last_checked_at: string; @@ -450,7 +450,8 @@ function shouldProbeTarget(options: Record): boolean { } async function probeLiveTarget(summary: ReadinessSummary): Promise { - const checkUrl = joinUrlPath(resolveTargetBaseUrl(summary), "/api/meta"); + const spec = targetPreflightSpec(summary); + const checkUrl = joinUrlPath(resolveTargetBaseUrl(summary), spec.checkPath); const checkedAt = new Date().toISOString(); const probe = await getJsonWithTimeout(checkUrl, 5000); const status: TargetLiveProbeStatus = probe.status === "available" @@ -470,11 +471,12 @@ async function probeLiveTarget(summary: ReadinessSummary): Promise check.name === "target:/api/meta"); + const spec = targetPreflightSpec(summary); + const targetPreflight = summary.report?.checks?.find((check) => check.name === spec.checkName); if (targetPreflight && targetPreflight.status !== "pass") { blockers.push(`Target service preflight is not passing (${targetPreflight.status}): ${targetPreflight.detail}. Lark-deployer does not start or manage the target service.`); } @@ -520,7 +524,7 @@ function buildBlockers(summary: ReadinessSummary, missingRequiredValues: string[ blockers.push(`Live target probe is not passing (${liveProbe.status}): ${liveProbe.detail}. Lark-deployer does not start or manage the target service.`); } - if (!selfHosted && summary.report?.context?.level2 !== true) { + if (!selfHosted && !calendarModeB && summary.report?.context?.level2 !== true) { blockers.push("Latest verification was not run in real Level 2 mode with verify --level2."); } else if (summary.report?.status === "warn") { blockers.push(`Level 2 preflight still has WARN checks: ${summary.reportCounts.warn}.`); @@ -543,8 +547,7 @@ function buildBlockers(summary: ReadinessSummary, missingRequiredValues: string[ } function buildTargetPreflightReport(summary: ReadinessSummary, liveProbe: TargetLiveProbeReport): TargetPreflightReport { - const checkName = "target:/api/meta"; - const checkPath = "/api/meta"; + const { checkName, checkPath } = targetPreflightSpec(summary); const targetCheck = summary.report?.checks?.find((check) => check.name === checkName); const status = targetCheck?.status || "missing"; const targetBaseUrl = resolveTargetBaseUrl(summary); @@ -567,6 +570,14 @@ function buildTargetPreflightReport(summary: ReadinessSummary, liveProbe: Target }; } +function targetPreflightSpec(summary: ReadinessSummary): { checkName: TargetPreflightReport["check_name"]; checkPath: TargetPreflightReport["check_path"]; profile: HandoffProfile } { + const profile = resolveHandoffProfile(summary.context, summary.service); + if (profile === "calendar-stock-updater") { + return { checkName: "target:/api/state", checkPath: "/api/state", profile }; + } + return { checkName: "target:/api/meta", checkPath: "/api/meta", profile }; +} + function joinUrlPath(baseUrl: string, urlPath: string): string { if (!baseUrl) return ""; return `${baseUrl.replace(/\/+$/g, "")}/${urlPath.replace(/^\/+/g, "")}`; @@ -607,7 +618,7 @@ function printDoctorReport(report: DoctorReport, gateMode: boolean): void { console.log(`State: ${report.state}`); console.log(`Integration mode: ${report.integration_mode}`); console.log(`Host receive mode: ${report.host_receive_mode}`); - console.log(`Delivery mode: ${doctorDeliveryMode(report.integration_mode)}`); + console.log(`Delivery mode: ${doctorDeliveryMode(report)}`); console.log(`Target service: ${report.target_service}`); console.log(`Package: ${report.package_path}`); console.log(`Gate passed: ${report.gate_passed ? "yes" : "no"}`); @@ -664,11 +675,14 @@ function printDoctorReport(report: DoctorReport, gateMode: boolean): void { } } -function doctorDeliveryMode(integrationMode: string): string { - if (integrationMode === "self-hosted-runtime") { +function doctorDeliveryMode(report: Pick): string { + if (report.target_preflight.check_name === "target:/api/state") { + return "installable Mode B integrations/lark module for the calendar target project."; + } + if (report.integration_mode === "self-hosted-runtime") { return "Mode A external host / sidecar path today; self-hosted-runtime host module is the Mode B embedded host-module foundation."; } - if (integrationMode === "embedded-adapter") { + if (report.integration_mode === "embedded-adapter") { return "embedded-adapter package for an existing host; use Mode A when that host is external, and use Mode B only after embedding a host module into the target project."; } return "standalone-runtime reference host; not the primary product shape."; @@ -736,7 +750,7 @@ This report is generated from readiness evidence. It does not include Feishu sec - State: ${report.state} - Integration mode: ${report.integration_mode} - Host receive mode: ${report.host_receive_mode} -- Delivery mode: ${doctorDeliveryMode(report.integration_mode)} +- Delivery mode: ${doctorDeliveryMode(report)} - Target service: ${report.target_service} - Package: ${report.package_path} - Missing required values: ${missing} diff --git a/src/commands/handoff.ts b/src/commands/handoff.ts index 7e1a5e5..254a532 100644 --- a/src/commands/handoff.ts +++ b/src/commands/handoff.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { getStringOption, hasOption } from "../args.js"; import { readEnvFileIfExists } from "../env-utils.js"; import { readJsonFile, writeJson, writeText } from "../fs-utils.js"; +import { configuredValue } from "../placeholder-utils.js"; import type { ServiceManifest } from "../types.js"; import { buildContextMarkdown, buildContextReplyMarkdown, buildContextReplyTemplate, buildContextRequestMarkdown, type ContextTemplate } from "./context.js"; import { writeDoctorReportFiles } from "./doctor.js"; @@ -95,6 +96,7 @@ const BASE_RECOMMENDED_FILES = [ ["manifest/capability_map.json", "Machine-readable capability contract."], ["manifest/interaction_contract.json", "Machine-readable Lark interaction contract."], ["manifest/required_permissions.json", "Machine-readable permission contract."], + ["docs/integration_guide.md", "Host integration guide for mounting the generated adapter."], ["adapter/handlers.ts", "Generated adapter action handlers."], ["adapter/cards.ts", "Generated Lark card builders for the adapter."], ["adapter/service-client.ts", "Generated target service client for the adapter."], @@ -136,6 +138,21 @@ const EXCLUDED_PATHS = [ ["feishu-host/.env", "Contains local Feishu app credentials and target runtime values."], ["feishu-host/.venv", "Python virtualenv output; regenerate with pip install."], ["feishu-host/__pycache__", "Python bytecode cache; regenerate locally."], + ["integrations/lark/.env", "Contains local Feishu app credentials and target runtime values."], + ["integrations/lark/node_modules", "Node dependency install output; regenerate with npm install."], + ["integrations/lark/.code2lark-install.json", "Target-local applied install state; regenerate by running install --apply."], + ["integrations/lark/npm-debug.log", "Local npm diagnostic output."], +] as const; + +const CALENDAR_MODE_B_EXCLUDED_PATHS = [ + ["feishu_context.local.json", "Local filled context may contain Feishu secrets."], + ["feishu_context.reply.local.json", "Local owner reply may include internal contact or deployment context."], + ["feishu_context.reply.local.md", "Local owner reply may include internal contact or deployment context."], + ["level2_manual_evidence.local.json", "Local manual Feishu evidence may include operator, chat, message, screenshot, and trace context."], + ["integrations/lark/.env", "Contains local Feishu app credentials and target runtime values."], + ["integrations/lark/node_modules", "Node dependency install output; regenerate with npm install."], + ["integrations/lark/.code2lark-install.json", "Target-local applied install state; regenerate by running install --apply."], + ["integrations/lark/npm-debug.log", "Local npm diagnostic output."], ] as const; export async function handoffCommand(args: string[], options: Record): Promise { @@ -193,8 +210,8 @@ function refreshCopiedContextFiles(packagePath: string): void { handoff_request: { ...context.handoff_request, generated_package_hint: packagePath, - verification_commands: copiedPackageCommands(), - command_sets: copiedPackageCommandSets(packagePath), + verification_commands: copiedPackageCommands(context), + command_sets: copiedPackageCommandSets(packagePath, context), }, }; writeJson(contextPath, refreshed); @@ -205,59 +222,70 @@ function refreshCopiedContextFiles(packagePath: string): void { writeText(path.join(packagePath, "feishu_context.reply.template.md"), buildContextReplyMarkdown(replyTemplate)); } -function copiedPackageCommandSets(packagePath: string): ContextTemplate["handoff_request"]["command_sets"] { +function copiedPackageCommandSets(packagePath: string, context: ContextTemplate): ContextTemplate["handoff_request"]["command_sets"] { const packageArg = quoteCommandArg(packagePath); + const projectCommands = copiedPackageCommands(context, packageArg); + const localCommands = copiedPackageCommands(context); return [ { name: "project_root", cwd_hint: "Run from the Lark-deployer project root. The copied package path is absolute.", - commands: [ - `node dist/index.js init-local ${packageArg} --context --reply`, - `node dist/index.js configure ${packageArg} --strict --dry-run`, - `node dist/index.js configure ${packageArg} --strict`, - `node dist/index.js status ${packageArg}`, - `node dist/index.js readiness ${packageArg}`, - `node dist/index.js doctor ${packageArg}`, - `node dist/index.js doctor ${packageArg} --gate`, - `node dist/index.js doctor ${packageArg} --probe-target --gate`, - `node dist/index.js verify ${packageArg}`, - `node dist/index.js verify ${packageArg} --runtime-url http://127.0.0.1:3978 --simulate`, - `node dist/index.js verify ${packageArg} --runtime-url http://127.0.0.1:3978 --level2`, - `node dist/index.js evidence ${packageArg} --runtime-url http://127.0.0.1:3978 --update-record`, - `node dist/index.js handoff ${packageArg}`, - ], + commands: projectCommands.map((command) => command.replace("node $env:LARK_DEPLOYER_CLI ", "node dist/index.js ")), }, { name: "generated_package_root", cwd_hint: "Run from this package root. Because this is a copied package, set LARK_DEPLOYER_CLI to the absolute path of the built Lark-deployer CLI.", - commands: copiedPackageCommands(), + commands: localCommands, }, { name: "moved_package_root", cwd_hint: "Run from this package root after it has been copied elsewhere. Set LARK_DEPLOYER_CLI to the absolute path of the built Lark-deployer CLI.", - commands: copiedPackageCommands(), + commands: localCommands, }, ]; } -function copiedPackageCommands(): string[] { +function copiedPackageCommands(context: ContextTemplate, packageArg = "."): string[] { + if (isCalendarModeBContext(context)) return copiedCalendarPackageCommands(context, packageArg); + const embedded = context.integration_mode === "embedded-adapter"; + const selfHosted = context.integration_mode === "self-hosted-runtime"; + const modeOption = selfHosted ? " --mode self-hosted-runtime" : embedded ? " --mode embedded-adapter" : ""; + const hostModeOption = embedded && context.host_receive_mode !== "embedded-webhook" ? ` --host-mode ${context.host_receive_mode}` : ""; + const target = packageArg; + return [ + "$env:LARK_DEPLOYER_CLI=\"C:\\path\\to\\Lark-deployer\\dist\\index.js\"", + ...(selfHosted ? [] : [`node $env:LARK_DEPLOYER_CLI init-local ${target} --context --reply`, `node $env:LARK_DEPLOYER_CLI configure ${target} --strict --dry-run`, `node $env:LARK_DEPLOYER_CLI configure ${target} --strict`]), + `node $env:LARK_DEPLOYER_CLI status ${target}`, + `node $env:LARK_DEPLOYER_CLI readiness ${target}`, + `node $env:LARK_DEPLOYER_CLI doctor ${target}${modeOption}${hostModeOption}`, + `node $env:LARK_DEPLOYER_CLI doctor ${target}${modeOption}${hostModeOption} --gate`, + `node $env:LARK_DEPLOYER_CLI doctor ${target}${modeOption}${hostModeOption} --probe-target --gate`, + `node $env:LARK_DEPLOYER_CLI verify ${target}${modeOption}${hostModeOption} --strict`, + ...(embedded ? [`node $env:LARK_DEPLOYER_CLI verify ${target}${modeOption}${hostModeOption} --host-runtime-url http://127.0.0.1:3978 --simulate`] : selfHosted ? [] : [`node $env:LARK_DEPLOYER_CLI verify ${target} --runtime-url http://127.0.0.1:3978 --simulate`, `node $env:LARK_DEPLOYER_CLI verify ${target} --runtime-url http://127.0.0.1:3978 --level2`]), + ...(embedded || selfHosted ? [] : [`node $env:LARK_DEPLOYER_CLI evidence ${target} --runtime-url http://127.0.0.1:3978 --update-record`]), + `node $env:LARK_DEPLOYER_CLI handoff ${target}`, + ]; +} + +function copiedCalendarPackageCommands(context: ContextTemplate, packageArg = "."): string[] { + const targetBaseUrl = configuredValue(context.target_service.base_url) || ""; return [ "$env:LARK_DEPLOYER_CLI=\"C:\\path\\to\\Lark-deployer\\dist\\index.js\"", - "node $env:LARK_DEPLOYER_CLI init-local . --context --reply", - "node $env:LARK_DEPLOYER_CLI configure . --strict --dry-run", - "node $env:LARK_DEPLOYER_CLI configure . --strict", - "node $env:LARK_DEPLOYER_CLI status .", - "node $env:LARK_DEPLOYER_CLI readiness .", - "node $env:LARK_DEPLOYER_CLI doctor .", - "node $env:LARK_DEPLOYER_CLI doctor . --gate", - "node $env:LARK_DEPLOYER_CLI doctor . --probe-target --gate", - "node $env:LARK_DEPLOYER_CLI verify . --runtime-url http://127.0.0.1:3978 --simulate", - "node $env:LARK_DEPLOYER_CLI verify . --runtime-url http://127.0.0.1:3978 --level2", - "node $env:LARK_DEPLOYER_CLI evidence . --runtime-url http://127.0.0.1:3978 --update-record", - "node $env:LARK_DEPLOYER_CLI handoff .", + `node $env:LARK_DEPLOYER_CLI status ${packageArg}`, + `node $env:LARK_DEPLOYER_CLI readiness ${packageArg}`, + `node $env:LARK_DEPLOYER_CLI doctor ${packageArg}`, + `node $env:LARK_DEPLOYER_CLI doctor ${packageArg} --probe-target --gate`, + `node $env:LARK_DEPLOYER_CLI verify ${packageArg} --mode embedded-adapter --host-mode embedded-long-connection --strict`, + `node $env:LARK_DEPLOYER_CLI install ${packageArg} --target --target-base-url ${targetBaseUrl}`, + `node $env:LARK_DEPLOYER_CLI install ${packageArg} --target --target-base-url ${targetBaseUrl} --apply`, + `node $env:LARK_DEPLOYER_CLI handoff ${packageArg}`, ]; } +function isCalendarModeBContext(context: ContextTemplate): boolean { + return context.target_profile === "calendar-stock-updater"; +} + function quoteCommandArg(value: string): string { if (!value) return "\"\""; if (/[\s"'#]/.test(value)) return JSON.stringify(value); @@ -470,6 +498,7 @@ function buildHandoffManifest(packagePath: string): HandoffManifest { const optionalEvidenceFiles = fileSet.optional.map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); const excludedPaths = fileSet.excluded.map(([filePath, reason]) => fileRow(packagePath, filePath, reason)); const warnings = buildWarnings(recommendedFiles, excludedPaths); + const modeBInstallable = isCalendarModeBPackage(packagePath); return { schema_version: "0.1", @@ -480,7 +509,13 @@ function buildHandoffManifest(packagePath: string): HandoffManifest { optional_evidence_files: optionalEvidenceFiles, excluded_paths: excludedPaths, warnings, - next_steps: [ + next_steps: modeBInstallable ? [ + "Keep this sanitized generated package as the source of truth.", + "Run `install --target ` while the target is online to review the zero-write plan.", + "After review, run the same command with `--apply`; it may write only `integrations/lark`.", + "Run `npm install` and `npm test` inside the installed `integrations/lark` module.", + "Share real Feishu secrets through a secure channel and record real Level 2 evidence separately.", + ] : [ "Copy recommended files and directories, excluding all excluded paths.", "Or run `handoff --copy-to ` to create a sanitized transfer directory automatically.", "`handoff --copy-to` scans copied text files for known local secret values and refuses the copy if one is found.", @@ -492,21 +527,39 @@ function buildHandoffManifest(packagePath: string): HandoffManifest { }; } +function isCalendarModeBPackage(packagePath: string): boolean { + const contextPath = path.join(packagePath, "feishu_context.template.json"); + if (!fs.existsSync(contextPath)) return false; + const installManifestPath = path.join(packagePath, "integrations", "lark", "install-manifest.json"); + if (!fs.existsSync(installManifestPath)) return false; + try { + const context = readJsonFile>(contextPath); + return context.target_profile === "calendar-stock-updater"; + } catch { + return false; + } +} + function buildHandoffFileSet(packagePath: string): HandoffFileSet { + const calendarModeB = isCalendarModeBPackage(packagePath); return { recommended: requiredHandoffFiles(packagePath), optional: optionalHandoffFiles(packagePath), - excluded: Array.from(EXCLUDED_PATHS), + excluded: Array.from(calendarModeB ? CALENDAR_MODE_B_EXCLUDED_PATHS : EXCLUDED_PATHS), }; } function requiredHandoffFiles(packagePath: string): HandoffFileDefinition[] { const rows = new Map(BASE_RECOMMENDED_FILES); const imageMetaPath = "manifest/image_agent_meta.snapshot.json"; + const profileContractPath = "manifest/profile_contract.json"; + if (pathExists(packagePath, profileContractPath)) { + rows.set(profileContractPath, "Generated target interaction-profile contract."); + } if (pathExists(packagePath, imageMetaPath)) { rows.set(imageMetaPath, "Template metadata snapshot used by generated cards."); } - for (const dir of ["adapter", "bot-runtime", "feishu-host", "sidecar-long-connection"]) { + for (const dir of ["adapter", "bot-runtime", "feishu-host", "sidecar-long-connection", "integrations/lark"]) { const absoluteDir = path.join(packagePath, dir); if (!fs.existsSync(absoluteDir) || !fs.statSync(absoluteDir).isDirectory()) continue; for (const filePath of listPackageFiles(absoluteDir)) { @@ -527,6 +580,7 @@ function generatedClosureReason(dir: string): string { if (dir === "adapter") return "Generated adapter runtime closure."; if (dir === "bot-runtime") return "Generated standalone reference runtime closure."; if (dir === "feishu-host") return "Generated self-hosted Feishu host module closure."; + if (dir === "integrations/lark") return "Generated installable Mode B Lark module closure."; return "Generated sidecar or gateway host contract closure."; } @@ -610,9 +664,10 @@ function assertHandoffCheckPassed(packagePath: string, manifest: HandoffManifest const presentExcluded = manifest.excluded_paths.filter((item) => item.present); const genericSecrets = scanGenericSecretLiterals(packagePath); const unredactedEvidence = scanUnredactedSharedEvidence(packagePath); - const nonStrictConfigureCommands = scanNonStrictConfigureCommands(packagePath); - const missingConfigureDryRunGuidance = scanMissingConfigureDryRunGuidance(packagePath); - const missingInitLocalGuidance = scanMissingInitLocalGuidance(packagePath); + const calendarModeB = isCalendarModeBPackage(packagePath); + const nonStrictConfigureCommands = calendarModeB ? [] : scanNonStrictConfigureCommands(packagePath); + const missingConfigureDryRunGuidance = calendarModeB ? [] : scanMissingConfigureDryRunGuidance(packagePath); + const missingInitLocalGuidance = calendarModeB ? [] : scanMissingInitLocalGuidance(packagePath); const missingPermissionConfirmationGuidance = scanMissingPermissionConfirmationGuidance(packagePath); const missingDoctorProbeTargetGateGuidance = scanMissingDoctorProbeTargetGateGuidance(packagePath); const stalePackagePathReferences = scanStalePackagePathReferences(packagePath); @@ -1032,7 +1087,12 @@ function isExcluded(relativePath: string): boolean { || normalized === "feishu-host/.venv" || normalized.startsWith("feishu-host/__pycache__/") || normalized === "feishu-host/__pycache__" - || normalized.startsWith("feishu-host/") && normalized.endsWith(".pyc"); + || normalized.startsWith("feishu-host/") && normalized.endsWith(".pyc") + || normalized === "integrations/lark/.env" + || normalized.startsWith("integrations/lark/node_modules/") + || normalized === "integrations/lark/node_modules" + || normalized === "integrations/lark/.code2lark-install.json" + || normalized === "integrations/lark/npm-debug.log"; } function toForwardSlash(value: string): string { diff --git a/src/commands/readiness.ts b/src/commands/readiness.ts index b5bf33e..9775caa 100644 --- a/src/commands/readiness.ts +++ b/src/commands/readiness.ts @@ -117,6 +117,8 @@ export interface ContextReplyStatus { readyForLocalConfigure: boolean; } +export type HandoffProfile = "image-agent-web" | "generic-http-api" | "calendar-stock-updater"; + const REQUIRED_VALUE_DEFS = [ { key: "APP_ID", @@ -142,6 +144,10 @@ const REQUIRED_VALUE_DEFS = [ key: "IMAGE_AGENT_BASE_URL", note: "Target service base URL reachable from the bot runtime environment.", }, + { + key: "TARGET_BASE_URL", + note: "Target service base URL reachable from the existing host or sidecar.", + }, ] as const; const SELF_HOSTED_REQUIRED_VALUE_DEFS = [ @@ -154,6 +160,14 @@ const SELF_HOSTED_REQUIRED_VALUE_DEFS = [ { key: "TEST_CHAT_ID", note: "Optional chat receive id for sending the start card during manual Level 2." }, ] as const; +const CALENDAR_REQUIRED_VALUE_DEFS = [ + { key: "FEISHU_APP_ID", note: "Feishu custom app id for integrations/lark/.env. Value is intentionally hidden in this status file." }, + { key: "FEISHU_APP_SECRET", note: "Feishu custom app secret for integrations/lark/.env. Value is intentionally hidden in this status file." }, + { key: "TEST_CHAT_ID", note: "Chat receive id where the bot has been added and can send the calendar start card." }, + { key: "TARGET_BASE_URL", note: "Existing calendar service base URL reachable from the installed integrations/lark module." }, + { key: "ALLOWED_OPERATOR_OPEN_IDS", note: "Required comma-separated Feishu operator open_id allowlist for mutating calendar actions." }, +] as const; + export async function readinessCommand(args: string[], options: Record): Promise { const packageArg = args[0]; if (!packageArg) { @@ -178,7 +192,9 @@ export function buildReadinessSummary( const permissions = readJsonFile(path.join(manifestDir, "required_permissions.json")); const { contextPath, context } = readContext(packagePath); const selfHosted = context?.integration_mode === "self-hosted-runtime"; - const envPath = path.resolve(getStringOption(options, "env", path.join(packagePath, selfHosted ? "feishu-host" : "bot-runtime", ".env"))); + const embedded = context?.integration_mode === "embedded-adapter"; + const profile = resolveHandoffProfile(context, service); + const envPath = path.resolve(getStringOption(options, "env", profile === "calendar-stock-updater" ? path.join(packagePath, "integrations", "lark", ".env") : embedded ? path.join(packagePath, "existing-host.env") : path.join(packagePath, selfHosted ? "feishu-host" : "bot-runtime", ".env"))); const env = readEnvFileIfExists(envPath); const contextRequestPath = path.join(packagePath, "feishu_context.request.md"); const contextReply = buildContextReplyStatus(packagePath); @@ -186,15 +202,17 @@ export function buildReadinessSummary( const report = readOptionalJson(reportPath); const level2RecordPath = path.join(packagePath, "level2_verification_record.md"); const level2Record = readTextIfExists(level2RecordPath); - const manualEvidence = buildManualEvidenceStatus(packagePath, context, level2Record); - const completionDecision = parseCompletionDecision(level2Record); - - const requiredValueDefs = selfHosted ? SELF_HOSTED_REQUIRED_VALUE_DEFS : REQUIRED_VALUE_DEFS; - const requiredValues = requiredValueDefs.map((item) => buildRequiredValueRow(item.key, item.note, env, context, service)); - const runtimeRows = buildRuntimeRows(env, context); + const manualEvidence = buildManualEvidenceStatus(packagePath, context, service, level2Record); + const completionDecision = parseCompletionDecision(level2Record, profile); + + const requiredValueDefs = profile === "calendar-stock-updater" ? CALENDAR_REQUIRED_VALUE_DEFS : selfHosted ? SELF_HOSTED_REQUIRED_VALUE_DEFS : REQUIRED_VALUE_DEFS; + const requiredValues = requiredValueDefs + .filter((item) => keepRequiredValueDef(item.key, context, service)) + .map((item) => buildRequiredValueRow(item.key, item.note, env, context, service)); + const runtimeRows = buildRuntimeRows(env, context, service); const reportCounts = countReportChecks(report); const securityWarnings = buildSecurityWarnings(env, context, service); - const state = determineState(requiredValues, report, completionDecision, manualEvidence); + const state = determineState(requiredValues, report, completionDecision, manualEvidence, profile); const nextActions = buildNextActions(state, context, report, requiredValues, contextRequestPath, contextReply, manualEvidence, packagePath, service); return { @@ -221,9 +239,28 @@ export function buildReadinessSummary( }; } -function parseCompletionDecision(record: string): CompletionDecision { +function keepRequiredValueDef(key: string, context: ContextTemplate | undefined, service: ServiceManifest): boolean { + if (resolveHandoffProfile(context, service) === "calendar-stock-updater") return true; + if (context?.integration_mode === "self-hosted-runtime") return true; + const genericTarget = service.source_scan?.analysis_strategy === "generic_http_api" + || context?.handoff_request.required_values.some((item) => item.key === "TARGET_BASE_URL") === true; + if (genericTarget && context?.host_receive_mode === "embedded-long-connection" && (key === "PUBLIC_CALLBACK_BASE_URL" || key === "VERIFICATION_TOKEN")) return false; + if (genericTarget) return key !== "IMAGE_AGENT_BASE_URL"; + return key !== "TARGET_BASE_URL"; +} + +export function resolveHandoffProfile(context: ContextTemplate | undefined, service: ServiceManifest): HandoffProfile { + if (context?.target_profile === "calendar-stock-updater" || context?.target_profile === "generic-http-api" || context?.target_profile === "image-agent-web") { + return context.target_profile; + } + if (service.source_scan?.analysis_strategy === "calendar_stock_updater") return "calendar-stock-updater"; + if (service.source_scan?.analysis_strategy === "generic_http_api") return "generic-http-api"; + return "image-agent-web"; +} + +function parseCompletionDecision(record: string, profile: HandoffProfile): CompletionDecision { const level2Verified = isChecked(record, "Level 2 verified."); - const missingManualEvidence = findMissingManualEvidence(record); + const missingManualEvidence = findMissingManualEvidence(record, profile); const manualEvidencePresent = missingManualEvidence.length === 0; const remainingIssuesDocumented = isChecked(record, "Remaining issues documented."); const handoffApproved = /-\s*\[[xX]\]\s*This generated package can be handed to another FDE/.test(record); @@ -237,8 +274,23 @@ function parseCompletionDecision(record: string): CompletionDecision { }; } -function findMissingManualEvidence(record: string): string[] { - const requiredFields = [ +function findMissingManualEvidence(record: string, profile: HandoffProfile): string[] { + const requiredFields = profile === "generic-http-api" ? [ + "Start card message ID", + "Generic action ID", + "Target request summary", + "Target response summary", + "Result card message ID or screenshot", + "Trace ID", + ] : profile === "calendar-stock-updater" ? [ + "Start card message ID", + "Status result message ID or screenshot", + "Dry-run result message ID or screenshot", + "Formal-run confirmation/result message IDs or screenshots", + "Stop confirmation/result message IDs or screenshots", + "Sanitized host log path", + "Trace ID", + ] : [ "Start card message ID", "Result card message ID or screenshot", "Generated image URL or image key", @@ -281,6 +333,11 @@ function buildRequiredValueRow( context: ContextTemplate | undefined, service: ServiceManifest, ): RequiredValueRow { + const profile = resolveHandoffProfile(context, service); + if (profile === "calendar-stock-updater" && key === "ALLOWED_OPERATOR_OPEN_IDS") { + const resolved = resolveRequiredValue(key, env, context, service); + return { key, status: resolved.value ? "provided" : "missing", source: resolved.source, note }; + } if (context?.integration_mode === "self-hosted-runtime" && (key === "FEISHU_ALLOWED_USERS" || key === "IMAGE_AGENT_TIMEOUT_MS" || key === "TEST_CHAT_ID")) { const resolved = resolveRequiredValue(key, env, context, service); return { key, status: resolved.value ? "provided" : "optional", source: resolved.source, note }; @@ -326,22 +383,29 @@ function resolveRequiredValue( TEST_CHAT_ID: feishu?.test_chat_id, PUBLIC_CALLBACK_BASE_URL: feishu?.public_callback_base_url, IMAGE_AGENT_BASE_URL: context?.target_service.base_url, + TARGET_BASE_URL: context?.target_service.base_url, FEISHU_ALLOWED_USERS: context?.runtime_config.allowed_operator_open_ids?.join(","), + ALLOWED_OPERATOR_OPEN_IDS: context?.runtime_config.allowed_operator_open_ids?.join(","), IMAGE_AGENT_TIMEOUT_MS: context?.runtime_config.target_timeout_seconds ? String(context.runtime_config.target_timeout_seconds * 1000) : "120000", }; const contextValue = configuredValue(contextValueByKey[key]); if (contextValue) return { value: contextValue, source: "context" }; const manifestBaseUrl = configuredValue(service.service.base_url); - if (key === "IMAGE_AGENT_BASE_URL" && manifestBaseUrl) { + if ((key === "IMAGE_AGENT_BASE_URL" || key === "TARGET_BASE_URL") && manifestBaseUrl) { return { value: manifestBaseUrl, source: "manifest" }; } return { value: "", source: "none" }; } -function buildRuntimeRows(env: Record, context: ContextTemplate | undefined): RequiredValueRow[] { +function buildRuntimeRows(env: Record, context: ContextTemplate | undefined, service: ServiceManifest): RequiredValueRow[] { const rows: RequiredValueRow[] = []; + if (resolveHandoffProfile(context, service) === "calendar-stock-updater") { + rows.push(buildRuntimeRow("TARGET_TIMEOUT_MS", env.TARGET_TIMEOUT_MS, "30000", "Target HTTP timeout for /api/state, /api/run, and /api/stop from integrations/lark.")); + rows.push(buildRuntimeRow("TARGET_WAIT_MS", env.TARGET_WAIT_MS, "30000", "Maximum startup wait for the existing calendar service before module checks.")); + return rows; + } if (context?.integration_mode === "self-hosted-runtime") { rows.push(buildRuntimeRow("FEISHU_CONNECTION_MODE", env.FEISHU_CONNECTION_MODE, "websocket", "Required by feishu-host long connection.")); rows.push(buildRuntimeRow("IMAGE_AGENT_TIMEOUT_MS", env.IMAGE_AGENT_TIMEOUT_MS, context?.runtime_config.target_timeout_seconds ? String(context.runtime_config.target_timeout_seconds * 1000) : "120000", "Target HTTP timeout for service_client.py.")); @@ -351,10 +415,14 @@ function buildRuntimeRows(env: Record, context: ContextTemplate } rows.push(buildRuntimeRow("CARD_ACTION_MODE", env.CARD_ACTION_MODE, context?.runtime_config.card_action_mode || "sync", "sync waits for target completion; async returns a running card and patches later.")); rows.push(buildRuntimeRow("UPLOAD_IMAGE_TO_LARK", env.UPLOAD_IMAGE_TO_LARK, boolToEnv(context?.runtime_config.upload_image_to_lark, "1"), "1 uploads result images to Feishu when API credentials and scope are available.")); - rows.push(buildRuntimeRow("IMAGE_AGENT_TIMEOUT_MS", env.IMAGE_AGENT_TIMEOUT_MS, context?.runtime_config.target_timeout_seconds ? String(context.runtime_config.target_timeout_seconds * 1000) : "120000", "Target service call and image download timeout.")); + if (context?.handoff_request.required_values.some((item) => item.key === "TARGET_BASE_URL")) { + rows.push(buildRuntimeRow("TARGET_TIMEOUT_MS", env.TARGET_TIMEOUT_MS, context?.runtime_config.target_timeout_seconds ? String(context.runtime_config.target_timeout_seconds * 1000) : "120000", "Target service call timeout.")); + } else { + rows.push(buildRuntimeRow("IMAGE_AGENT_TIMEOUT_MS", env.IMAGE_AGENT_TIMEOUT_MS, context?.runtime_config.target_timeout_seconds ? String(context.runtime_config.target_timeout_seconds * 1000) : "120000", "Target service call and image download timeout.")); + } rows.push(buildRuntimeRow("HOST", env.HOST, context?.runtime_config.host || "127.0.0.1", "HTTP bind host for the generated bot runtime.")); rows.push(buildRuntimeRow("PORT", env.PORT, context?.runtime_config.port ? String(context.runtime_config.port) : "3978", "HTTP port for the generated bot runtime.")); - rows.push(buildOptionalRuntimeRow("DEBUG_ACCESS_TOKEN", env.DEBUG_ACCESS_TOKEN, context?.runtime_config.debug_access_token || "", "Protects /debug/* endpoints when the runtime is reachable through a public callback URL. Value is intentionally hidden.")); + rows.push(buildOptionalRuntimeRow("DEBUG_ACCESS_TOKEN", env.DEBUG_ACCESS_TOKEN, context?.runtime_config.debug_access_token || "", context?.host_receive_mode === "embedded-long-connection" ? "Protects host-owned debug endpoints if the host exposes them. Value is intentionally hidden." : "Protects /debug/* endpoints when the runtime is reachable through a public callback URL. Value is intentionally hidden.")); rows.push(buildOptionalRuntimeRow("ALLOWED_OPERATOR_OPEN_IDS", env.ALLOWED_OPERATOR_OPEN_IDS, operatorOpenIdStatus(context?.runtime_config.allowed_operator_open_ids), "Optional operator open_id allowlist for card actions. Values are not printed in this status file.")); rows.push(buildRuntimeRow("ALLOW_DEBUG_WITHOUT_FEISHU", env.ALLOW_DEBUG_WITHOUT_FEISHU, boolToEnv(context?.runtime_config.allow_debug_without_feishu, "0"), "1 explicitly enables local debug simulation before real Feishu credentials are filled.")); return rows; @@ -528,10 +596,43 @@ function countNonEmptyStrings(value: unknown): number { return value.filter((item) => Boolean(configuredValue(item))).length; } -function buildManualEvidenceStatus(packagePath: string, context: ContextTemplate | undefined, level2Record: string): ManualEvidenceStatus { +function buildManualEvidenceStatus(packagePath: string, context: ContextTemplate | undefined, service: ServiceManifest, level2Record: string): ManualEvidenceStatus { const templatePath = path.join(packagePath, "level2_manual_evidence.template.json"); const localPath = path.join(packagePath, "level2_manual_evidence.local.json"); - const fields = [ + const profile = resolveHandoffProfile(context, service); + const fields = profile === "generic-http-api" ? [ + "date", + "operator", + "feishu_app_name", + "test_chat", + "start_message_id", + "generic_action_id", + "target_request_summary", + "target_response_summary", + "result_message_id", + "result_screenshot", + "trace_id", + "notes", + ] : profile === "calendar-stock-updater" ? [ + "date", + "operator", + "feishu_app_name", + "test_chat", + "start_message_id", + "status_result_message_id", + "status_screenshot", + "dry_run_result_message_id", + "dry_run_screenshot", + "run_confirmation_message_id", + "run_result_message_id", + "run_result_screenshot", + "stop_confirmation_message_id", + "stop_result_message_id", + "stop_result_screenshot", + "sanitized_host_log_path", + "trace_id", + "notes", + ] : [ "date", "operator", "feishu_app_name", @@ -565,6 +666,7 @@ function buildManualEvidenceStatus(packagePath: string, context: ContextTemplate const missingFields = fields.filter((field) => !filledFields.includes(field)); const importedFields = filledFields.filter((field) => manualEvidenceFieldImported(field, level2Record)); const pendingImportFields = filledFields.filter((field) => !importedFields.includes(field)); + const calendarModeB = profile === "calendar-stock-updater"; return { templatePath, templatePresent: fs.existsSync(templatePath), @@ -575,8 +677,8 @@ function buildManualEvidenceStatus(packagePath: string, context: ContextTemplate missingFields, importedFields, pendingImportFields, - readyToImport: fs.existsSync(localPath) && !parseError && pendingImportFields.length > 0, - importCommand: manualEvidenceImportCommand(packagePath, context), + readyToImport: !calendarModeB && fs.existsSync(localPath) && !parseError && pendingImportFields.length > 0, + importCommand: calendarModeB ? "" : manualEvidenceImportCommand(packagePath, context), }; } @@ -593,8 +695,22 @@ function manualEvidenceRecordField(field: string): string { feishu_app_name: "Feishu app name", test_chat: "Test chat", start_message_id: "Start card message ID", + generic_action_id: "Generic action ID", + target_request_summary: "Target request summary", + target_response_summary: "Target response summary", result_message_id: "Result card message ID or screenshot", result_screenshot: "Result card message ID or screenshot", + status_result_message_id: "Status result message ID or screenshot", + status_screenshot: "Status result message ID or screenshot", + dry_run_result_message_id: "Dry-run result message ID or screenshot", + dry_run_screenshot: "Dry-run result message ID or screenshot", + run_confirmation_message_id: "Formal-run confirmation/result message IDs or screenshots", + run_result_message_id: "Formal-run confirmation/result message IDs or screenshots", + run_result_screenshot: "Formal-run confirmation/result message IDs or screenshots", + stop_confirmation_message_id: "Stop confirmation/result message IDs or screenshots", + stop_result_message_id: "Stop confirmation/result message IDs or screenshots", + stop_result_screenshot: "Stop confirmation/result message IDs or screenshots", + sanitized_host_log_path: "Sanitized host log path", generated_image_url: "Generated image URL or image key", generated_image_key: "Generated image URL or image key", batch_id: "Batch ID", @@ -668,6 +784,7 @@ function determineState( report: VerificationReport | undefined, completionDecision: CompletionDecision, manualEvidence: ManualEvidenceStatus, + profile: HandoffProfile, ): ReadinessState { const selfHosted = report?.context?.mode === "self-hosted-runtime"; if (requiredValues.some((item) => item.status === "missing")) return "external_context_missing"; @@ -680,6 +797,12 @@ function determineState( return "handoff_ready"; } if (report.context?.mode === "embedded-adapter") { + if (profile === "calendar-stock-updater") { + if (report.status === "warn") return "level2_preflight_has_warnings"; + if (manualEvidence.parseError) return "manual_evidence_invalid"; + if (!completionDecision.complete) return "manual_click_evidence_needed"; + return "handoff_ready"; + } if (!report.context.hostRuntimeUrl || report.context.simulate !== true) return "runtime_preflight_needed"; } else if (!report.context?.runtimeUrl || report.context.simulate !== true) return "runtime_preflight_needed"; if (report.context?.mode !== "embedded-adapter" && report.context?.level2 !== true) return "level2_preflight_needed"; @@ -700,6 +823,9 @@ function buildNextActions( packagePath: string, service: ServiceManifest, ): string[] { + if (resolveHandoffProfile(context, service) === "calendar-stock-updater") { + return buildCalendarNextActions(state, context, report, requiredValues, manualEvidence, packagePath, service); + } const configureCommand = withoutConfigureDryRun(withConfigureStrict(findPackageCommand(packagePath, context, " configure ") || "node ..\\..\\dist\\index.js configure .")); const configureDryRunCommand = withConfigureDryRun(configureCommand); const initContextCommand = findPackageCommand(packagePath, context, " init-local ") || "node ..\\..\\dist\\index.js init-local . --context --reply"; @@ -729,7 +855,7 @@ function buildNextActions( ] : []; const contextReplyActions = buildContextReplyActions(contextReply); - const targetPreflightActions = buildTargetPreflightActions(report, service, verifyCommand); + const targetPreflightActions = buildTargetPreflightActions(report, service, verifyCommand, context); const manualEvidenceCommands = manualEvidence.readyToImport ? [manualEvidence.importCommand] : manualEvidence.localPresent && manualEvidence.filledFields.length @@ -814,6 +940,43 @@ function buildNextActions( } } +function buildCalendarNextActions( + state: ReadinessState, + context: ContextTemplate | undefined, + report: VerificationReport | undefined, + requiredValues: RequiredValueRow[], + manualEvidence: ManualEvidenceStatus, + packagePath: string, + service: ServiceManifest, +): string[] { + const targetBaseUrl = configuredValue(context?.target_service.base_url) || configuredValue(service.service.base_url) || ""; + const installDryRun = `node $env:LARK_DEPLOYER_CLI install . --target --target-base-url ${targetBaseUrl}`; + const installApply = installDryRun.includes("--apply") ? installDryRun : `${installDryRun} --apply`; + const missingValues = requiredValues.filter((item) => item.status === "missing").map((item) => item.key); + const actions = [ + `Start or expose the calendar target service so \`GET /api/state\` passes from the target project environment. Current target base URL: ${targetBaseUrl}.`, + `Run \`${installDryRun}\` to review the zero-write install plan.`, + `Run \`${installApply}\` after reviewing the dry-run.`, + "Copy `integrations/lark/.env.example` to `integrations/lark/.env` and fill FEISHU_APP_ID, FEISHU_APP_SECRET, TEST_CHAT_ID, TARGET_BASE_URL, and ALLOWED_OPERATOR_OPEN_IDS through a secure channel.", + "Run `npm install` inside `integrations/lark`.", + "Run `npm test` inside `integrations/lark`.", + "Run `npm start` inside `integrations/lark` for the Feishu long-connection module when ready for manual Level 2.", + ]; + if (missingValues.length) { + actions.unshift(`Current required module values are missing or incomplete: ${missingValues.map((item) => `\`${item}\``).join(", ")}.`); + } + if (report?.status === "fail") { + actions.push("Open verification_report.md and fix each FAIL check before handoff."); + } + if (manualEvidence.parseError) { + actions.push(`Fix invalid level2_manual_evidence.local.json before using manual evidence status: ${manualEvidence.parseError}`); + } + if (state === "manual_click_evidence_needed" || state === "handoff_ready") { + actions.push("Manually copy private worksheet values into matching fields in `level2_verification_record.md`, then review and check the final completion boxes manually."); + } + return actions; +} + function replaceInitLocalSelection(command: string, selection: string): string { const base = command .replace(/\s+--context\b/g, "") @@ -845,8 +1008,9 @@ function buildContextReplyActions(contextReply: ContextReplyStatus): string[] { return actions; } -function buildTargetPreflightActions(report: VerificationReport | undefined, service: ServiceManifest, verifyCommand: string): string[] { - const targetCheck = report?.checks?.find((check) => check.name === "target:/api/meta"); +function buildTargetPreflightActions(report: VerificationReport | undefined, service: ServiceManifest, verifyCommand: string, context?: ContextTemplate): string[] { + const spec = targetPreflightSpec(resolveHandoffProfile(context, service)); + const targetCheck = report?.checks?.find((check) => check.name === spec.checkName); if (!targetCheck) return []; if (targetCheck.status === "pass") { const checkedAt = report?.generated_at || "unknown time"; @@ -854,9 +1018,9 @@ function buildTargetPreflightActions(report: VerificationReport | undefined, ser `Latest target preflight pass is a verification_report snapshot from ${checkedAt}; rerun verify after starting or exposing the target service to prove it is reachable now. ${verifyCommand}`, ]; } - const targetBaseUrl = report?.context?.targetBaseUrl || ""; + const targetBaseUrl = report?.context?.targetBaseUrl || spec.baseUrlPlaceholder; const actions = [ - `Start or expose the externally managed target service so GET ${targetBaseUrl}/api/meta passes, then rerun verify. Current target preflight status: ${targetCheck.status}.`, + `Start or expose the externally managed target service so GET ${targetBaseUrl}${spec.checkPath} passes, then rerun verify. Current target preflight status: ${targetCheck.status}.`, ]; for (const hint of service.service.start_hints || []) { actions.push(`Target start hint: ${hint}`); @@ -865,6 +1029,13 @@ function buildTargetPreflightActions(report: VerificationReport | undefined, ser return actions; } +function targetPreflightSpec(profile: HandoffProfile): { checkName: string; checkPath: string; baseUrlPlaceholder: string } { + if (profile === "calendar-stock-updater") { + return { checkName: "target:/api/state", checkPath: "/api/state", baseUrlPlaceholder: "" }; + } + return { checkName: "target:/api/meta", checkPath: "/api/meta", baseUrlPlaceholder: profile === "generic-http-api" ? "" : "" }; +} + function withConfigureStrict(command: string): string { if (/\sconfigure\s/.test(`${command} `) && !/(^|\s)--strict(\s|$)/.test(command)) { return `${command} --strict`; @@ -948,14 +1119,23 @@ function printReadinessSummary(summary: ReadinessSummary, outPath: string): void export function buildReadinessMarkdown(summary: ReadinessSummary): string { const missing = summary.requiredValues.filter((item) => item.status === "missing").map((item) => item.key); + const profile = resolveHandoffProfile(summary.context, summary.service); + const calendarModeB = profile === "calendar-stock-updater"; + const genericLongConnection = summary.context?.host_receive_mode === "embedded-long-connection" + && summary.context?.handoff_request.required_values.some((item) => item.key === "TARGET_BASE_URL") === true; const requiredRows = summary.requiredValues.map(formatValueRow).join("\n"); const runtimeRows = summary.runtimeRows.map(formatValueRow).join("\n"); const scopeRows = summary.permissions.scopes .map((scope) => `| \`${scope.scope}\` | ${scope.risk} | ${scope.reason} |`) .join("\n"); const callbackRows = summary.permissions.callbacks - .map((callback) => `| \`${callback.callback}\` | ${callback.reason} | ${callback.security.join(", ")} |`) + .map((callback) => `| \`${callback.callback}\` | ${genericLongConnection ? "Receive interactive card button clicks through the Feishu SDK long connection." : callback.reason} | ${genericLongConnection ? "long_connection" : callback.security.join(", ")} |`) .join("\n"); + const secretHandling = calendarModeB + ? "confirm availability in normal chat, but send `FEISHU_APP_SECRET` and operator allowlist values through a secure channel only." + : genericLongConnection + ? "confirm availability in normal chat, but send `APP_SECRET`, `ENCRYPT_KEY`, and `DEBUG_ACCESS_TOKEN` through a secure channel only." + : "confirm availability in normal chat, but send `APP_SECRET`, `VERIFICATION_TOKEN`, `ENCRYPT_KEY`, and `DEBUG_ACCESS_TOKEN` through a secure channel only."; const failedChecks = (summary.report?.checks || []) .filter((check) => check.status === "fail") .map((check) => `- ${check.name}: ${check.detail}`) @@ -967,6 +1147,10 @@ export function buildReadinessMarkdown(summary: ReadinessSummary): string { const securityWarnings = summary.securityWarnings .map((warning) => `- ${warning}`) .join("\n") || "- none"; + const manualEvidenceImportBlock = calendarModeB + ? "Manual evidence values for calendar Mode B stay in the private worksheet or ignored local evidence file. Manually copy private worksheet values into matching fields in `level2_verification_record.md`; this command does not automate calendar evidence import." + : `Import command:\n\n\`\`\`powershell\n${summary.manualEvidence.importCommand}\n\`\`\``; + const contextReplyReadyLabel = calendarModeB ? "Ready for local module env intake" : "Ready for local configure intake"; return `# Handoff Status @@ -1012,7 +1196,7 @@ Missing required values: ${missing.length ? missing.map((item) => `\`${item}\``) - Request file: ${fs.existsSync(summary.contextRequestPath) ? summary.contextRequestPath : "missing"} - Intended recipient: Feishu app owner, permission admin, infrastructure owner, or FDE. - Missing values to request: ${missing.length ? missing.map((item) => `\`${item}\``).join(", ") : "none"} -- Secret handling: confirm availability in normal chat, but send \`APP_SECRET\`, \`VERIFICATION_TOKEN\`, \`ENCRYPT_KEY\`, and \`DEBUG_ACCESS_TOKEN\` through a secure channel only. +- Secret handling: ${secretHandling} ## Context Reply Intake @@ -1032,7 +1216,7 @@ The local owner reply may include internal contact or deployment context. This s | Blocked-by entries | ${summary.contextReply.blockedCount} | | Secure secret channel recorded | ${summary.contextReply.secureSecretChannelPresent ? "yes" : "no"} | | Public value fields filled | ${summary.contextReply.publicValueFields.length ? summary.contextReply.publicValueFields.map((field) => `\`${field}\``).join(", ") : "none"} | -| Ready for local configure intake | ${summary.contextReply.readyForLocalConfigure ? "yes" : "no"} | +| ${contextReplyReadyLabel} | ${summary.contextReply.readyForLocalConfigure ? "yes" : "no"} | ## Manual Evidence Helper @@ -1049,11 +1233,7 @@ The local manual evidence file may include operator, chat, message, screenshot, | Pending import fields | ${summary.manualEvidence.pendingImportFields.length ? summary.manualEvidence.pendingImportFields.map((field) => `\`${field}\``).join(", ") : "none"} | | Ready to import | ${summary.manualEvidence.readyToImport ? "yes" : "no"} | -Import command: - -\`\`\`powershell -${summary.manualEvidence.importCommand} -\`\`\` +${manualEvidenceImportBlock} ## Runtime Choices @@ -1102,6 +1282,9 @@ ${summary.nextActions.map((action) => `- ${action}`).join("\n")} function readinessDeliveryMode(summary: ReadinessSummary): string { const integrationMode = summary.context?.integration_mode || summary.report?.context?.mode || "standalone-runtime"; + if (resolveHandoffProfile(summary.context, summary.service) === "calendar-stock-updater") { + return "installable Mode B integrations/lark module for the calendar target project."; + } if (integrationMode === "self-hosted-runtime") { return "Mode B embedded host-module path foundation; self-hosted-runtime host module verified externally today."; } From 953f5356621d608acae259c7d4c669df03094e4c Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:48:11 +0800 Subject: [PATCH 32/37] test: cover structural and calendar CLI workflows Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tests/cli-smoke.test.mjs | 879 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 879 insertions(+) diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index d453256..bcd4daf 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -135,6 +135,285 @@ test("generate refuses to overwrite non-managed non-empty output directories", ( run(["generate", workspace, "--out", managed, "--force"]); }); +test("analyze --backend internal succeeds and records internal backend metadata", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-backend-internal-")); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + writeBackendSelectionTarget(target); + + run(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "internal"]); + + const service = readServiceManifest(workspace); + assert.equal(service.source_scan.structural_backend?.requested, "internal"); + assert.equal(service.source_scan.structural_backend?.used, "internal"); + assert.equal(service.source_scan.structural_backend?.status, "used"); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test("analyze --backend auto falls back to internal when codegraph executable is unavailable", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-backend-auto-missing-")); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + writeBackendSelectionTarget(target); + + runWithEnv(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "auto"], { + PATH: path.join(temp, "empty-bin"), + Path: path.join(temp, "empty-bin"), + }); + + const service = readServiceManifest(workspace); + assert.equal(service.source_scan.structural_backend?.requested, "auto"); + assert.equal(service.source_scan.structural_backend?.used, "internal"); + assert.equal(service.source_scan.structural_backend?.status, "fallback"); + assert.match(service.source_scan.structural_backend?.reason || "", /codegraph.*(unavailable|not found|missing)/i); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test("analyze --backend codegraph fails clearly when executable is unavailable", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-backend-codegraph-missing-")); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + writeBackendSelectionTarget(target); + + const output = runExpectFailureWithEnv(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "codegraph"], { + PATH: path.join(temp, "empty-bin"), + Path: path.join(temp, "empty-bin"), + }); + assert.match(output, /codegraph/i); + assert.match(output, /unavailable|not found|missing|install/i); + assert.equal(fs.existsSync(path.join(workspace, "manifest", "service_manifest.json")), false); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test("analyze --backend auto falls back when fake codegraph status is uninitialized", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-backend-status-uninit-auto-")); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + const fake = installFakeCodegraph(temp, "uninitialized"); + writeBackendSelectionTarget(target); + + runWithEnv(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "auto"], fake.env); + + const service = readServiceManifest(workspace); + assert.equal(service.source_scan.structural_backend?.requested, "auto"); + assert.equal(service.source_scan.structural_backend?.used, "internal"); + assert.equal(service.source_scan.structural_backend?.status, "fallback"); + assert.match(service.source_scan.structural_backend?.reason || "", /not initialized|uninitialized/i); + assert.deepEqual(readFakeCodegraphArgv(fake.logPath), [["status", target, "--json"]]); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test("analyze --backend codegraph fails when fake codegraph status is uninitialized", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-backend-status-uninit-explicit-")); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + const fake = installFakeCodegraph(temp, "uninitialized"); + writeBackendSelectionTarget(target); + + const output = runExpectFailureWithEnv(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "codegraph"], fake.env); + assert.match(output, /codegraph/i); + assert.match(output, /not initialized|uninitialized/i); + assert.deepEqual(readFakeCodegraphArgv(fake.logPath), [["status", target, "--json"]]); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test("analyze --backend auto falls back when fake codegraph status has stale pendingRefs", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-backend-status-stale-pending-refs-auto-")); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + const fake = installFakeCodegraph(temp, "stale-pending-refs"); + writeBackendSelectionTarget(target); + + runWithEnv(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "auto"], fake.env); + + const service = readServiceManifest(workspace); + assert.equal(service.source_scan.structural_backend?.requested, "auto"); + assert.equal(service.source_scan.structural_backend?.used, "internal"); + assert.equal(service.source_scan.structural_backend?.status, "fallback"); + assert.match(service.source_scan.structural_backend?.reason || "", /pendingRefs is not 0/i); + assert.deepEqual(readFakeCodegraphArgv(fake.logPath), [["status", target, "--json"]]); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test("analyze --backend codegraph accepts missing journalMode metadata when status is otherwise fresh", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-backend-journal-missing-")); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + const fake = installFakeCodegraph(temp, "fresh-journal-missing"); + writeBackendSelectionTarget(target); + + runWithEnv(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "codegraph"], fake.env); + + const service = readServiceManifest(workspace); + assert.equal(service.source_scan.structural_backend?.requested, "codegraph"); + assert.equal(service.source_scan.structural_backend?.used, "codegraph"); + assert.equal(service.source_scan.structural_backend?.status, "used"); + assert.deepEqual(readFakeCodegraphArgv(fake.logPath), [ + ["status", target, "--json"], + ["query", "route", "--kind", "route", "--path", target, "--json"], + ]); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test("analyze --backend codegraph accepts non-WAL journalMode when status is otherwise fresh", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-backend-journal-memory-")); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + const fake = installFakeCodegraph(temp, "fresh-journal-memory"); + writeBackendSelectionTarget(target); + + runWithEnv(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "codegraph"], fake.env); + + const service = readServiceManifest(workspace); + assert.equal(service.source_scan.structural_backend?.requested, "codegraph"); + assert.equal(service.source_scan.structural_backend?.used, "codegraph"); + assert.equal(service.source_scan.structural_backend?.status, "used"); + assert.deepEqual(readFakeCodegraphArgv(fake.logPath), [ + ["status", target, "--json"], + ["query", "route", "--kind", "route", "--path", target, "--json"], + ]); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +for (const staleCase of [ + { + mode: "pending-changes", + output: /pendingChanges is not empty/i, + }, + { + mode: "worktree-mismatch", + output: /worktreeMismatch is true/i, + }, + { + mode: "incomplete-state", + output: /index\.state is not complete/i, + }, + { + mode: "pending-refs", + output: /pendingRefs is not 0/i, + }, + { + mode: "reindex-recommended", + output: /reindexRecommended is true/i, + }, +]) { + test(`analyze --backend codegraph rejects stale codegraph status: ${staleCase.mode}`, () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), `lark-deployer-backend-status-stale-${staleCase.mode}-explicit-`)); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + const fake = installFakeCodegraph(temp, `stale-${staleCase.mode}`); + writeBackendSelectionTarget(target); + + const output = runExpectFailureWithEnv(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "codegraph"], fake.env); + assert.match(output, /codegraph/i); + assert.match(output, staleCase.output); + assert.deepEqual(readFakeCodegraphArgv(fake.logPath), [["status", target, "--json"]]); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }); +} + +test("analyze --backend codegraph invokes fake route query and normalizes results", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-backend-codegraph-fresh-")); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + const fake = installFakeCodegraph(temp, "fresh"); + writeBackendSelectionTarget(target); + + runWithEnv(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "codegraph"], fake.env); + + const service = readServiceManifest(workspace); + assert.equal(service.source_scan.structural_backend?.requested, "codegraph"); + assert.equal(service.source_scan.structural_backend?.used, "codegraph"); + assert.equal(service.source_scan.structural_backend?.status, "used"); + assert.deepEqual(readFakeCodegraphArgv(fake.logPath), [ + ["status", target, "--json"], + ["query", "route", "--kind", "route", "--path", target, "--json"], + ]); + assert.deepEqual(service.source_scan.route_provenance, [ + { method: "GET", path: "/api/codegraph-health", source: "codegraph", file: "server.js", line: 8 }, + { method: "POST", path: "/api/codegraph-route", source: "codegraph", file: "server.js", line: 2 }, + ]); + const serializedService = JSON.stringify(service); + assert.doesNotMatch(serializedService, /score/); + assert.doesNotMatch(serializedService, /highlights/); + assert.ok(service.source_scan.endpoint_coverage.some((item) => item.method === "POST" && item.path === "/api/codegraph-route")); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test("analyze --backend auto falls back when fake codegraph returns invalid query JSON", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-backend-invalid-json-auto-")); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + const fake = installFakeCodegraph(temp, "invalid-query-json"); + writeBackendSelectionTarget(target); + + runWithEnv(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "auto"], fake.env); + + const service = readServiceManifest(workspace); + assert.equal(service.source_scan.structural_backend?.requested, "auto"); + assert.equal(service.source_scan.structural_backend?.used, "internal"); + assert.equal(service.source_scan.structural_backend?.status, "fallback"); + assert.match(service.source_scan.structural_backend?.reason || "", /invalid.*json|json.*parse/i); + assert.deepEqual(readFakeCodegraphArgv(fake.logPath), [ + ["status", target, "--json"], + ["query", "route", "--kind", "route", "--path", target, "--json"], + ]); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test("analyze --backend codegraph fails when fake codegraph returns invalid query JSON", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-backend-invalid-json-explicit-")); + try { + const target = path.join(temp, "target"); + const workspace = path.join(temp, "out"); + const fake = installFakeCodegraph(temp, "invalid-query-json"); + writeBackendSelectionTarget(target); + + const output = runExpectFailureWithEnv(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--backend", "codegraph"], fake.env); + assert.match(output, /codegraph/i); + assert.match(output, /invalid.*json|json.*parse/i); + assert.deepEqual(readFakeCodegraphArgv(fake.logPath), [ + ["status", target, "--json"], + ["query", "route", "--kind", "route", "--path", target, "--json"], + ]); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + test("CLI can analyze, plan, generate, and verify an image-agent-web-like target", () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-smoke-")); const target = path.join(temp, "image-agent-web"); @@ -2420,6 +2699,7 @@ test("generic HTTP API target can analyze generate and verify", () => { "", "- GET /health", "- GET /api/tickets/{ticket_id}", + "- POST /api/items/{id}", "- POST /api/tickets", ].join("\n"), "utf8", @@ -2433,6 +2713,8 @@ test("generic HTTP API target can analyze generate and verify", () => { "async def health(): pass", "@app.get(\"/api/tickets/{ticket_id}\")", "async def get_ticket(ticket_id: str): pass", + "@app.post(\"/api/items/{id}\")", + "async def update_item(id: str): pass", "@app.post(\"/api/tickets\")", "async def create_ticket(): pass", ].join("\n"), @@ -2573,6 +2855,10 @@ test("calendar-stock-updater Node target can analyze generate and verify", () => assert.match(secondTargetPlan, /calendar-stock-updater/); assert.match(secondTargetPlan, /Mode A/); + assert.match(secondTargetPlan, /Integration mode: embedded-adapter/); + assert.match(secondTargetPlan, /Host receive mode: embedded-long-connection/); + assert.doesNotMatch(secondTargetPlan, /PUBLIC_CALLBACK_BASE_URL.*required/i); + assert.doesNotMatch(secondTargetPlan, /\/webhook\/card.*required/i); assert.match(secondTargetPlan, /query/i); assert.match(secondTargetPlan, /action/i); @@ -2654,6 +2940,404 @@ test("calendar-stock-updater Node target can analyze generate and verify", () => assert.doesNotMatch(handoffManifest, /image\.generate|image_url|session_id/); const doctorJson = JSON.parse(run(["doctor", generated, "--mode", "embedded-adapter", "--json"])); assert.equal(doctorJson.package_validation.status, "pass"); + + const generatedLong = path.join(temp, "generated-long"); + run(["generate", workspace, "--out", generatedLong, "--mode", "embedded-adapter", "--host-mode", "embedded-long-connection"]); + const longSummary = JSON.parse(fs.readFileSync(path.join(generatedLong, "generation_summary.json"), "utf8")); + assert.equal(longSummary.schema_version, "0.2"); + assert.equal(longSummary.integration_mode, "embedded-adapter"); + assert.equal(longSummary.host_receive_mode, "embedded-long-connection"); + const longPermissions = JSON.parse(fs.readFileSync(path.join(generatedLong, "manifest", "required_permissions.json"), "utf8")); + assert.doesNotMatch(JSON.stringify(longPermissions), /Card callback verification token|verification_token/); + assert.ok(longPermissions.callbacks.some((callback) => callback.callback === "card.action.trigger" && callback.security.includes("long_connection"))); + const longDeploymentChecklist = fs.readFileSync(path.join(generatedLong, "deployment_checklist.md"), "utf8"); + assert.match(longDeploymentChecklist, /doctor \. --mode embedded-adapter --host-mode embedded-long-connection --gate/); + const longReadme = fs.readFileSync(path.join(generatedLong, "README.md"), "utf8"); + const longContextMarkdown = fs.readFileSync(path.join(generatedLong, "feishu_context.template.md"), "utf8"); + const longContextRequest = fs.readFileSync(path.join(generatedLong, "feishu_context.request.md"), "utf8"); + const longContextReply = fs.readFileSync(path.join(generatedLong, "feishu_context.reply.template.md"), "utf8"); + const longIntegrationGuide = fs.readFileSync(path.join(generatedLong, "docs", "integration_guide.md"), "utf8"); + const longLevel2Record = fs.readFileSync(path.join(generatedLong, "level2_verification_record.md"), "utf8"); + const longSidecarReadme = fs.readFileSync(path.join(generatedLong, "sidecar-long-connection", "README.md"), "utf8"); + const longContext = JSON.parse(fs.readFileSync(path.join(generatedLong, "feishu_context.template.json"), "utf8")); + assert.equal(longContext.handoff_request.required_values.some((item) => item.key === "TARGET_BASE_URL" && item.required_for_level_2), true); + assert.equal(longContext.handoff_request.required_values.some((item) => item.key === "IMAGE_AGENT_BASE_URL"), false); + assert.match(`${longReadme}\n${longContextMarkdown}\n${longContextRequest}\n${longContextReply}\n${longIntegrationGuide}\n${longLevel2Record}\n${longSidecarReadme}`, /APP_ID/); + assert.match(`${longReadme}\n${longContextMarkdown}\n${longContextRequest}\n${longContextReply}\n${longIntegrationGuide}\n${longLevel2Record}\n${longSidecarReadme}`, /APP_SECRET/); + assert.match(`${longReadme}\n${longContextMarkdown}\n${longContextRequest}\n${longContextReply}\n${longIntegrationGuide}\n${longLevel2Record}\n${longSidecarReadme}`, /TEST_CHAT_ID/); + assert.match(`${longReadme}\n${longContextMarkdown}\n${longContextRequest}\n${longContextReply}\n${longIntegrationGuide}\n${longLevel2Record}\n${longSidecarReadme}`, /TARGET_BASE_URL/); + assert.match(longIntegrationGuide, /card\.action\.trigger/); + assert.match(longIntegrationGuide, /handleGenericHttpCardAction\(\)/); + assert.match(longLevel2Record, /card\.action\.trigger/); + assert.match(longLevel2Record, /handleGenericHttpCardAction\(\)/); + assert.doesNotMatch(`${longReadme}\n${longContextMarkdown}\n${longContextRequest}\n${longContextReply}\n${longIntegrationGuide}\n${longLevel2Record}`, /PUBLIC_CALLBACK_BASE_URL/); + assert.doesNotMatch(`${longReadme}\n${longContextMarkdown}\n${longContextRequest}\n${longContextReply}\n${longIntegrationGuide}\n${longLevel2Record}`, /VERIFICATION_TOKEN/); + assert.doesNotMatch(`${longReadme}\n${longContextMarkdown}\n${longContextRequest}\n${longContextReply}\n${longIntegrationGuide}\n${longLevel2Record}`, /\/webhook\/card/); + const longCards = fs.readFileSync(path.join(generatedLong, "adapter", "cards.ts"), "utf8"); + assert.match(longCards, /schema:\s*["']2\.0["']/); + assert.doesNotMatch(longCards, /http\.post\.api\.stop\.submit/); + const longVerifyOutput = run(["verify", generatedLong, "--mode", "embedded-adapter", "--host-mode", "embedded-long-connection", "--strict"]); + assert.match(longVerifyOutput, /adapter:action:http\.get\.api\.state\.submit/); + assert.match(longVerifyOutput, /adapter:action:http\.post\.api\.run\.submit/); + assert.doesNotMatch(longVerifyOutput, /adapter:action:http\.post\.api\.stop\.submit/); + const longDoctorJson = JSON.parse(run(["doctor", generatedLong, "--mode", "embedded-adapter", "--host-mode", "embedded-long-connection", "--json"])); + assert.equal(longDoctorJson.host_receive_mode, "embedded-long-connection"); + assert.equal(longDoctorJson.blockers.some((item) => item.includes("/webhook/card") || item.includes("PUBLIC_CALLBACK_BASE_URL") || item.includes("VERIFICATION_TOKEN")), false); + const longReadinessOutput = run(["readiness", generatedLong]); + assert.doesNotMatch(longReadinessOutput, /PUBLIC_CALLBACK_BASE_URL|VERIFICATION_TOKEN/); + const longHandoffCopy = path.join(temp, "handoff-long"); + assert.match(run(["handoff", generatedLong, "--copy-to", longHandoffCopy, "--check"]), /Handoff check passed/); + assert.ok(fs.existsSync(path.join(longHandoffCopy, "docs", "integration_guide.md"))); +}); + +test("calendar-stock-updater business profile generates typed long-connection cards", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lark-deployer-calendar-profile-")); + const target = path.join(temp, "calendar-target"); + const workspace = path.join(temp, "out"); + const generated = path.join(temp, "generated"); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, "server.js"), [ + "if (req.method === 'GET' && pathname === '/api/state') {}", + "if (req.method === 'GET' && pathname === '/api/events') {}", + "if (req.method === 'POST' && pathname === '/api/run') {}", + "if (req.method === 'POST' && pathname === '/api/stop') {}", + ].join("\n"), "utf8"); + fs.writeFileSync(path.join(target, "task-config.js"), "function resolveProductIdRange() {}\n", "utf8"); + fs.writeFileSync(path.join(target, "update-calendar-stock.js"), "// updateAllSkuRows SPECIAL_SKU_KEYWORD\n", "utf8"); + + run(["analyze", target, "--base-url", "http://127.0.0.1:1", "--out", workspace, "--name", "calendar-stock-updater"]); + run(["plan", workspace]); + const service = JSON.parse(fs.readFileSync(path.join(workspace, "manifest", "service_manifest.json"), "utf8")); + const capabilities = JSON.parse(fs.readFileSync(path.join(workspace, "manifest", "capability_map.json"), "utf8")); + assert.equal(service.source_scan.analysis_strategy, "calendar_stock_updater"); + assert.equal(capabilities.target_profile, "calendar-stock-updater"); + assert.deepEqual(capabilities.capabilities.map((item) => item.id).sort(), ["calendar.status", "calendar.task.run", "calendar.task.stop"]); + assert.deepEqual(capabilities.capabilities.map((item) => item.source.path).sort(), ["/api/run", "/api/state", "/api/stop"]); + assert.ok(service.source_scan.endpoint_coverage.some((item) => item.path === "/api/events" && item.status === "supporting")); + + run(["generate", workspace, "--out", generated, "--mode", "embedded-adapter", "--host-mode", "embedded-long-connection"]); + const cards = fs.readFileSync(path.join(generated, "adapter", "cards.ts"), "utf8"); + const handlers = fs.readFileSync(path.join(generated, "adapter", "handlers.ts"), "utf8"); + const contract = JSON.parse(fs.readFileSync(path.join(generated, "manifest", "profile_contract.json"), "utf8")); + const startHere = fs.readFileSync(path.join(generated, "START_HERE.md"), "utf8"); + const level2Record = fs.readFileSync(path.join(generated, "level2_verification_record.md"), "utf8"); + const deploymentChecklist = fs.readFileSync(path.join(generated, "deployment_checklist.md"), "utf8"); + const cardPlan = fs.readFileSync(path.join(generated, "card_plan.md"), "utf8"); + const manualEvidence = JSON.parse(fs.readFileSync(path.join(generated, "level2_manual_evidence.template.json"), "utf8")); + const context = JSON.parse(fs.readFileSync(path.join(generated, "feishu_context.template.json"), "utf8")); + const contextMarkdown = fs.readFileSync(path.join(generated, "feishu_context.template.md"), "utf8"); + const contextRequest = fs.readFileSync(path.join(generated, "feishu_context.request.md"), "utf8"); + for (const adapterFile of ["cards.ts", "handlers.ts", "service-client.ts", "validation.ts", "types.ts", "audit-events.ts"]) { + const adapterSource = fs.readFileSync(path.join(generated, "adapter", adapterFile), "utf8"); + assert.doesNotMatch(adapterSource, /@ts-nocheck|@ts-ignore|@ts-expect-error/, `calendar adapter ${adapterFile} must not suppress TypeScript checks`); + } + const safeCardRuntimeOutput = runNode(["--input-type=module", "-e", ` + import assert from "node:assert/strict"; + import http from "node:http"; + import path from "node:path"; + import { pathToFileURL } from "node:url"; + + const generatedRoot = path.resolve(process.argv[1]); + const cards = await import(pathToFileURL(path.join(generatedRoot, "adapter", "cards.js")).href); + const handlers = await import(pathToFileURL(path.join(generatedRoot, "adapter", "handlers.js")).href); + const validation = await import(pathToFileURL(path.join(generatedRoot, "adapter", "validation.js")).href); + const longMessage = "第一行\\n第二行 " + "长消息".repeat(80); + const operations = cards.buildOperationsCard({ task: { status: "running", currentMessage: longMessage }, logs: [] }); + const currentLine = operations.body.elements[0].content.split("\\n").find((line) => line.startsWith("**当前消息:**")); + assert.ok(currentLine); + assert.match(currentLine, /第一行 第二行/); + assert.doesNotMatch(currentLine, /第一行\\n第二行/); + assert.ok(currentLine.endsWith("…")); + assert.ok(currentLine.length <= "**当前消息:** ".length + 120); + + const stopCard = cards.buildStopConfirmationCard({ confirmationId: "confirm-1", task: { currentMessage: longMessage } }); + const stopText = stopCard.body.elements[0].content.split("\\n").at(-1); + assert.match(stopText, /当前消息:第一行 第二行/); + assert.ok(stopText.endsWith("…")); + + const sensitiveOperations = cards.buildOperationsCard({ + task: { status: "running", currentMessage: "https://internal.example/task APP_SECRET=status-secret ou_status" }, + logs: [ + { timestamp: "2026-07-17T01:00:00Z", message: "正常日志" }, + { timestamp: "2026-07-17T01:00:01Z", message: "operator_open_id=ou_log chat_id=oc_log token=log-secret" }, + ], + }); + const sensitiveText = JSON.stringify(sensitiveOperations); + assert.match(sensitiveText, /状态消息已脱敏/); + assert.match(sensitiveText, /日志内容已脱敏/); + assert.doesNotMatch(sensitiveText, /internal\.example|status-secret|ou_status|ou_log|oc_log|log-secret|operator_open_id|chat_id/i); + + const sensitiveStopCard = JSON.stringify(cards.buildStopConfirmationCard({ + confirmationId: "confirm-sensitive", + task: { currentMessage: "private status" }, + })); + assert.match(sensitiveStopCard, /状态消息已脱敏/); + assert.doesNotMatch(sensitiveStopCard, /html|private status/i); + + const bareAuthOperations = JSON.stringify(cards.buildOperationsCard({ + task: { status: "running", currentMessage: "auth=status-auth-value" }, + logs: [{ timestamp: "2026-07-17T01:00:02Z", message: "auth=log-auth-value" }], + })); + assert.match(bareAuthOperations, /状态消息已脱敏/); + assert.match(bareAuthOperations, /日志内容已脱敏/); + assert.doesNotMatch(bareAuthOperations, /status-auth-value|log-auth-value|auth=/i); + const bareAuthStop = JSON.stringify(cards.buildStopConfirmationCard({ confirmationId: "confirm-auth", task: { currentMessage: "auth=stop-auth-value" } })); + assert.match(bareAuthStop, /状态消息已脱敏/); + assert.doesNotMatch(bareAuthStop, /stop-auth-value|auth=/i); + const bareAuthFailure = JSON.stringify(cards.buildFailureCard("auth=failure-auth-value")); + assert.match(bareAuthFailure, /操作失败,请检查目标服务与模块配置后重试/); + assert.doesNotMatch(bareAuthFailure, /failure-auth-value|auth=/i); + + assert.throws(() => validation.calendarTaskInput({ targetDate: "2026-07-17", stock: "1", stepDelayMs: "10001" }, "run"), /普通操作停顿/); + assert.throws(() => validation.assertAllowedOperator("ou_denied", []), /尚未配置获准操作人/); + assert.throws(() => validation.assertAllowedOperator("ou_denied", ["ou_allowed"]), /当前操作人未获授权/); + const missingAllowlist = await handlers.handleCardAction( + { action: "calendar.status.refresh", operatorOpenId: "ou_denied" }, + { targetBaseUrl: "http://127.0.0.1:1", allowedOperatorOpenIds: [] }, + ); + const unauthorized = await handlers.handleCardAction( + { action: "calendar.status.refresh", operatorOpenId: "ou_denied" }, + { targetBaseUrl: "http://127.0.0.1:1", allowedOperatorOpenIds: ["ou_allowed"] }, + ); + const authorizationCards = JSON.stringify([missingAllowlist.card, unauthorized.card]); + assert.match(authorizationCards, /尚未配置获准操作人/); + assert.match(authorizationCards, /当前操作人未获授权/); + assert.doesNotMatch(authorizationCards, /No approved operators|Operator is not authorized/i); + const rawFailure = JSON.stringify(cards.buildFailureCard("APP_SECRET=top-secret private")); + assert.doesNotMatch(rawFailure, /APP_SECRET|top-secret|html|private/i); + + const rawTargetBody = "APP_SECRET=target-secret gateway detail"; + const server = http.createServer((_request, response) => { + response.statusCode = 502; + response.end(rawTargetBody); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("mock server did not expose a TCP address"); + const result = await handlers.handleCardAction( + { action: "calendar.status.refresh", operatorOpenId: "ou_allowed" }, + { targetBaseUrl: "http://127.0.0.1:" + address.port, allowedOperatorOpenIds: ["ou_allowed"] }, + ); + const rendered = JSON.stringify(result.card); + assert.equal(result.ok, false); + assert.match(rendered, /HTTP 502/); + assert.doesNotMatch(rendered, /APP_SECRET|target-secret|html|gateway detail/i); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } + console.log("calendar safe card text: PASS"); + `, generated]); + assert.match(safeCardRuntimeOutput, /calendar safe card text: PASS/); + assert.match(cards, /calendar_task_form/); + assert.match(cards, /calendar\.task\.run\.prepare/); + assert.match(cards, /schema: "2\.0"/); + assert.doesNotMatch(cards, /body_json|\/api\/events|taskMode|special-window|targeted-window/); + const operationsCardSource = cards.slice(cards.indexOf("export function buildOperationsCard"), cards.indexOf("export function buildRunConfirmationCard")); + const runConfirmationSource = cards.slice(cards.indexOf("export function buildRunConfirmationCard"), cards.indexOf("export function buildStopConfirmationCard")); + const stopConfirmationSource = cards.slice(cards.indexOf("export function buildStopConfirmationCard"), cards.indexOf("export function buildFailureCard")); + assert.match(operationsCardSource, /状态摘要/); + assert.match(operationsCardSource, /仅显示最近 8 条/); + assert.match(operationsCardSource, /长行会截断/); + assert.match(cards, /timestamp|createdAt|item\.time/); + for (const field of ["targetDate", "stock", "stepDelayMs", "datePickerDelayMs", "startProductId", "endProductId"]) { + assert.match(runConfirmationSource, new RegExp(field)); + } + assert.match(runConfirmationSource, /风险提示/); + assert.match(stopConfirmationSource, /当前运行任务/); + assert.doesNotMatch(`${operationsCardSource}\n${runConfirmationSource}\n${stopConfirmationSource}`, /⚠️|✅|❌|🚨/u); + assert.match(handlers, /handleCardAction/); + assert.match(handlers, /calendar\.task\.stop\.confirm/); + assert.doesNotMatch(handlers, /\/api\/(run|stop)\/(prepare|confirm|cancel)/); + assert.ok(fs.existsSync(path.join(generated, "integrations", "lark", "install-manifest.json"))); + assert.match(startHere, /install \. --target/); + assert.match(level2Record, /Calendar Mode B/); + assert.match(level2Record, /mode=dry-run/); + for (const fieldLabel of [ + "Start card message ID", + "Status result message ID or screenshot", + "Dry-run result message ID or screenshot", + "Formal-run confirmation/result message IDs or screenshots", + "Stop confirmation/result message IDs or screenshots", + "Sanitized host log path", + "Trace ID", + ]) { + assert.match(level2Record, new RegExp(`- ${fieldLabel}:`)); + } + for (const completionLabel of [ + "Level 2 verified.", + "Remaining issues documented.", + "This generated package can be handed to another FDE using `README.md`, `deployment_checklist.md`, and this file.", + ]) { + assert.match(level2Record, new RegExp(`- \\[ \\] ${completionLabel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + } + assert.doesNotMatch(level2Record, /Real Feishu Level 2 verified|Generic action ID|Target request summary|Target response summary|Result card message ID or screenshot|Generated image|Batch ID|Batch status|Batch download/i); + assert.doesNotMatch(level2Record, /image_url|Generated image|Batch ID/); + for (const endpoint of ["GET /api/state", "POST /api/run", "POST /api/stop"]) { + assert.match(deploymentChecklist, new RegExp(endpoint.replace("/", "\\/"))); + } + assert.match(deploymentChecklist, /dry-run/i); + assert.match(deploymentChecklist, /install .*--apply/is); + assert.match(deploymentChecklist, /integrations[\\/]lark/); + assert.match(deploymentChecklist, /module-local|模块本地/i); + assert.match(deploymentChecklist, /npm test/); + assert.match(deploymentChecklist, /real Feishu Level 2|真实飞书 Level 2/i); + assert.match(cardPlan, /status|状态/i); + assert.match(cardPlan, /dry-run|预演/i); + assert.match(cardPlan, /prepare|confirm|cancel/i); + assert.match(cardPlan, /recent logs|最近日志/i); + assert.doesNotMatch(`${deploymentChecklist}\n${cardPlan}`, /\/api\/meta|generated image|image preview|image batch|\/api\/batch|Mount generated `adapter`|统一启动|unified startup/i); + assert.equal(typeof manualEvidence.values.run_confirmation_message_id, "string"); + assert.deepEqual(Object.keys(manualEvidence.values).sort(), [ + "date", + "dry_run_result_message_id", + "dry_run_screenshot", + "feishu_app_name", + "notes", + "operator", + "run_confirmation_message_id", + "run_result_message_id", + "run_result_screenshot", + "sanitized_host_log_path", + "start_message_id", + "status_result_message_id", + "status_screenshot", + "stop_confirmation_message_id", + "stop_result_message_id", + "stop_result_screenshot", + "test_chat", + "trace_id", + ]); + assert.equal("generated_image_url" in manualEvidence.values, false); + assert.equal("batch_id" in manualEvidence.values, false); + assert.equal("generic_action_id" in manualEvidence.values, false); + assert.equal("target_request_summary" in manualEvidence.values, false); + assert.equal("target_response_summary" in manualEvidence.values, false); + assert.equal(context.target_profile, "calendar-stock-updater"); + assert.equal(Object.hasOwn(context.runtime_config, "upload_image_to_lark"), false); + assert.deepEqual(Object.keys(context.runtime_config).sort(), ["allowed_operator_open_ids", "target_timeout_seconds", "target_wait_seconds"]); + const existingAppQuestion = context.readiness_questions.find((item) => item.id === "existing_feishu_app")?.question || ""; + assert.match(existingAppQuestion, /FEISHU_APP_ID and FEISHU_APP_SECRET/); + assert.ok(context.handoff_request.required_values.some((item) => item.key === "FEISHU_APP_ID")); + assert.equal(context.handoff_request.required_values.some((item) => item.key === "APP_ID"), false); + assert.ok(context.handoff_request.verification_commands.some((command) => command.includes(" install "))); + assert.match(`${contextMarkdown}\n${contextRequest}`, /integrations\/lark/); + assert.doesNotMatch(`${contextMarkdown}\n${contextRequest}`, /UPLOAD_IMAGE_TO_LARK/); + assert.doesNotMatch(`${contextMarkdown}\n${contextRequest}`, /mount the adapter in the existing Feishu SDK host/i); + assert.equal(contract.profile_id, "calendar-stock-updater"); + assert.match(run(["verify", generated, "--mode", "embedded-adapter", "--host-mode", "embedded-long-connection", "--strict"]), /adapter:action:calendar\.task\.run\.confirm/); + const verificationReport = JSON.parse(fs.readFileSync(path.join(generated, "verification_report.json"), "utf8")); + assert.equal(verificationReport.checks.some((check) => String(check.name).startsWith("target:")), false); + const verificationMarkdown = fs.readFileSync(path.join(generated, "verification_report.md"), "utf8"); + assert.doesNotMatch(verificationMarkdown, /IMAGE_AGENT_BASE_URL|\/api\/meta|generated images?|batch paths?|bot-runtime/i); + const readinessOutput = run(["readiness", generated]); + assert.match(readinessOutput, /Readiness status:/); + const handoffStatus = fs.readFileSync(path.join(generated, "handoff_status.md"), "utf8"); + assert.match(readinessOutput, /Delivery mode: .*installable Mode B.*integrations[\\/]lark/i); + assert.match(handoffStatus, /Env file checked: .*integrations[\\/]lark[\\/].env/); + assert.match(handoffStatus, /installable Mode B .*integrations[\\/]lark/i); + for (const key of ["FEISHU_APP_ID", "FEISHU_APP_SECRET", "TEST_CHAT_ID", "TARGET_BASE_URL", "ALLOWED_OPERATOR_OPEN_IDS"]) { + assert.equal(handoffStatus.includes(`| \`${key}\` |`), true); + } + for (const key of ["APP_ID", "APP_SECRET", "IMAGE_AGENT_BASE_URL", "PUBLIC_CALLBACK_BASE_URL", "VERIFICATION_TOKEN"]) { + assert.equal(handoffStatus.includes(`| \`${key}\` |`), false); + } + assert.match(handoffStatus, /\| `TARGET_TIMEOUT_MS` \| provided \| context \|/); + assert.match(handoffStatus, /\| `TARGET_WAIT_MS` \| provided \| context \|/); + for (const key of ["CARD_ACTION_MODE", "UPLOAD_IMAGE_TO_LARK", "HOST", "PORT", "DEBUG_ACCESS_TOKEN", "ALLOW_DEBUG_WITHOUT_FEISHU"]) { + assert.equal(handoffStatus.includes(`| \`${key}\` |`), false); + } + assert.match(handoffStatus, /Current required module values are missing or incomplete: `FEISHU_APP_ID`, `FEISHU_APP_SECRET`, `TEST_CHAT_ID`, `ALLOWED_OPERATOR_OPEN_IDS`/); + for (const phrase of [ + /Start or expose the calendar target service so `GET \/api\/state` passes/i, + /Run `node \$env:LARK_DEPLOYER_CLI install \. --target --target-base-url http:\/\/127\.0\.0\.1:1` to review the zero-write install plan/i, + /Run `node \$env:LARK_DEPLOYER_CLI install \. --target --target-base-url http:\/\/127\.0\.0\.1:1 --apply` after reviewing the dry-run/i, + /Copy `integrations\/lark\/.env\.example` to `integrations\/lark\/.env` and fill/i, + /Run `npm install` inside `integrations\/lark`/i, + /Run `npm test` inside `integrations\/lark`/i, + /Run `npm start` inside `integrations\/lark`/i, + ]) { + assert.match(handoffStatus, phrase); + } + assert.match(handoffStatus, /Manually copy private worksheet values into matching fields in `level2_verification_record\.md`/); + assert.doesNotMatch(handoffStatus, /Import command:|--manual-evidence|init-local|\bconfigure\b|mount the adapter|existing Feishu SDK host|host simulation|--host-runtime-url|automated evidence import/i); + assert.match(handoffStatus, /Status result message ID or screenshot|status_result_message_id/); + assert.match(handoffStatus, /Dry-run result message ID or screenshot|dry_run_result_message_id/); + assert.match(handoffStatus, /Formal-run confirmation\/result message IDs or screenshots|run_confirmation_message_id/); + assert.match(handoffStatus, /Stop confirmation\/result message IDs or screenshots|stop_confirmation_message_id/); + assert.match(handoffStatus, /sanitized_host_log_path/); + assert.doesNotMatch(handoffStatus, /\/api\/meta|Generic action ID|target_request_summary|target_response_summary|generated_image_url|batch_id|batch_status|batch_download/i); + const doctorReportPath = path.join(generated, "doctor_report.json"); + assert.match(run(["doctor", generated, "--out", doctorReportPath, "--json"]), /Doctor report written/); + const doctorJson = JSON.parse(fs.readFileSync(doctorReportPath, "utf8")); + assert.equal(doctorJson.missing_required_values.includes("ALLOWED_OPERATOR_OPEN_IDS"), true); + assert.equal(doctorJson.missing_required_values.includes("APP_ID"), false); + assert.equal(doctorJson.missing_required_values.includes("APP_SECRET"), false); + assert.equal(doctorJson.target_preflight.check_name, "target:/api/state"); + assert.equal(doctorJson.target_preflight.check_path, "/api/state"); + assert.equal(doctorJson.target_preflight.status, "missing"); + assert.equal(doctorJson.target_preflight.check_url, "http://127.0.0.1:1/api/state"); + assert.equal(doctorJson.target_preflight.live_probe.check_url, "http://127.0.0.1:1/api/state"); + assert.deepEqual(doctorJson.manual_evidence.missing_fields, [ + "date", + "operator", + "feishu_app_name", + "test_chat", + "start_message_id", + "status_result_message_id", + "status_screenshot", + "dry_run_result_message_id", + "dry_run_screenshot", + "run_confirmation_message_id", + "run_result_message_id", + "run_result_screenshot", + "stop_confirmation_message_id", + "stop_result_message_id", + "stop_result_screenshot", + "sanitized_host_log_path", + "trace_id", + "notes", + ]); + assert.equal(doctorJson.blockers.some((item) => item.includes("verify --level2")), false); + assert.doesNotMatch(JSON.stringify(doctorJson), /\/api\/meta|generated_image_url|batch_id|batch_status|batch_download|generic_action_id|target_request_summary|target_response_summary/i); + const doctorMarkdown = fs.readFileSync(path.join(generated, "doctor_report.md"), "utf8"); + assert.match(doctorMarkdown, /installable Mode B .*integrations[\\/]lark/i); + assert.doesNotMatch(doctorMarkdown, /existing host|verify --level2/i); + const handoffCopy = path.join(temp, "handoff-copy"); + assert.match(run(["handoff", generated, "--copy-to", handoffCopy, "--check"]), /Handoff check passed/); + const copiedContext = JSON.parse(fs.readFileSync(path.join(handoffCopy, "feishu_context.template.json"), "utf8")); + const copiedCommands = copiedContext.handoff_request.command_sets.flatMap((set) => set.commands); + assert.ok(copiedCommands.some((command) => /\bstatus\b/.test(command))); + assert.ok(copiedCommands.some((command) => /\breadiness\b/.test(command))); + assert.ok(copiedCommands.some((command) => /\bdoctor\b/.test(command) && !command.includes("--probe-target") && !command.includes("--gate"))); + assert.ok(copiedCommands.some((command) => /\bdoctor\b/.test(command) && command.includes("--probe-target") && command.includes("--gate"))); + assert.ok(copiedCommands.some((command) => /\bverify\b/.test(command) && command.includes("--mode embedded-adapter") && command.includes("--host-mode embedded-long-connection") && command.includes("--strict"))); + assert.ok(copiedCommands.some((command) => /\binstall\b/.test(command) && command.includes("--target ") && command.includes("--target-base-url http://127.0.0.1:1") && !command.includes("--apply"))); + assert.ok(copiedCommands.some((command) => /\binstall\b/.test(command) && command.includes("--target ") && command.includes("--target-base-url http://127.0.0.1:1") && command.includes("--apply"))); + assert.ok(copiedCommands.some((command) => /\bhandoff\b/.test(command))); + assert.equal(copiedCommands.some((command) => /init-local|\bconfigure\b|--simulate|--host-runtime-url|\bevidence\b/i.test(command)), false); + const copiedManifest = JSON.parse(fs.readFileSync(path.join(handoffCopy, "handoff_manifest.json"), "utf8")); + assert.deepEqual(copiedManifest.warnings, []); + const copiedExcludedPaths = copiedManifest.excluded_paths.map((item) => item.path).sort(); + assert.deepEqual(copiedExcludedPaths, [ + "feishu_context.local.json", + "feishu_context.reply.local.json", + "feishu_context.reply.local.md", + "integrations/lark/.code2lark-install.json", + "integrations/lark/.env", + "integrations/lark/node_modules", + "integrations/lark/npm-debug.log", + "level2_manual_evidence.local.json", + ].sort()); + assert.equal(copiedExcludedPaths.some((item) => /bot-runtime|feishu-host|configure_report/.test(item)), false); + const copiedDoctorJson = JSON.parse(fs.readFileSync(path.join(handoffCopy, "doctor_report.json"), "utf8")); + assert.equal(copiedDoctorJson.target_preflight.check_name, "target:/api/state"); + assert.equal(copiedDoctorJson.target_preflight.check_path, "/api/state"); + assert.equal(copiedDoctorJson.blockers.some((item) => item.includes("verify --level2")), false); + const copiedHandoffStatus = fs.readFileSync(path.join(handoffCopy, "handoff_status.md"), "utf8"); + assert.doesNotMatch(copiedCommands.join("\n"), /init-local|\bconfigure\b|--simulate|--host-runtime-url|\bevidence\b/i); + assert.doesNotMatch(`${JSON.stringify(copiedDoctorJson)}\n${copiedHandoffStatus}`, /init-local|\bconfigure\b|--simulate|--host-runtime-url|\bevidence\s+\.|--manual-evidence|`APP_ID`|`APP_SECRET`|IMAGE_AGENT_BASE_URL|PUBLIC_CALLBACK_BASE_URL|VERIFICATION_TOKEN|\/api\/meta/i); + assert.match(runNode([path.join(generated, "sidecar-long-connection", "local-contract-test.mjs")], { cwd: generated }), /calendar contract: PASS/); }); test("image-agent-web mapping profile is isolated from generator orchestration", () => { @@ -2698,6 +3382,188 @@ function run(args) { }); } +function runWithEnv(args, envOverrides) { + return execFileSync(process.execPath, [cli, ...args], { + cwd: root, + env: { ...process.env, ...envOverrides }, + encoding: "utf8", + stdio: "pipe", + }); +} + +function runExpectFailureWithEnv(args, envOverrides) { + try { + runWithEnv(args, envOverrides); + } catch (error) { + if (error && typeof error === "object") { + const output = []; + if ("stdout" in error && error.stdout) output.push(String(error.stdout)); + if ("stderr" in error && error.stderr) output.push(String(error.stderr)); + if ("message" in error && error.message) output.push(String(error.message)); + return output.join("\n"); + } + return String(error); + } + assert.fail(`Expected command to fail: ${args.join(" ")}`); +} + +function readServiceManifest(workspace) { + return JSON.parse(fs.readFileSync(path.join(workspace, "manifest", "service_manifest.json"), "utf8")); +} + +function writeBackendSelectionTarget(target) { + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, "server.js"), [ + "const http = require('node:http');", + "const { URL } = require('node:url');", + "http.createServer((req, res) => {", + " const pathname = new URL(req.url, 'http://127.0.0.1').pathname;", + " if (req.method === 'GET' && pathname === '/api/internal-health') res.end(JSON.stringify({ ok: true }));", + " if (req.method === 'POST' && pathname === '/api/internal-run') res.end(JSON.stringify({ ok: true }));", + "});", + ].join("\n"), "utf8"); +} + +function installFakeCodegraph(temp, mode) { + const binDir = path.join(temp, "fake-codegraph-bin"); + const logPath = path.join(temp, "fake-codegraph-argv.jsonl"); + const status = buildFakeCodegraphStatus(mode); + const scriptPath = path.join(binDir, "fake-codegraph.mjs"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(scriptPath, fakeCodegraphScript(), "utf8"); + fs.writeFileSync(path.join(binDir, "codegraph.cmd"), [ + "@echo off", + "\"%FAKE_CODEGRAPH_NODE%\" \"%~dp0fake-codegraph.mjs\" %*", + ].join("\r\n"), "utf8"); + fs.writeFileSync(path.join(binDir, "codegraph"), [ + "#!/bin/sh", + "exec \"$FAKE_CODEGRAPH_NODE\" \"$(dirname \"$0\")/fake-codegraph.mjs\" \"$@\"", + ].join("\n"), "utf8"); + fs.chmodSync(path.join(binDir, "codegraph"), 0o755); + return { + logPath, + env: { + FAKE_CODEGRAPH_NODE: process.execPath, + CODEGRAPH_FAKE_LOG: logPath, + CODEGRAPH_FAKE_MODE: mode, + CODEGRAPH_FAKE_STATUS: JSON.stringify(status), + PATH: `${binDir}${path.delimiter}${process.env.PATH || ""}`, + Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ""}`, + }, + }; +} + +function buildFakeCodegraphStatus(mode) { + if (mode === "fresh") { + return {}; + } + if (mode === "uninitialized") { + return { + initialized: false, + }; + } + if (mode === "invalid-query-json") { + return {}; + } + if (mode === "fresh-journal-missing") { + return { + index: { state: "complete", pendingRefs: 0, reindexRecommended: false }, + }; + } + if (mode === "fresh-journal-memory") { + return { + journalMode: "memory", + index: { state: "complete", pendingRefs: 0, reindexRecommended: false }, + }; + } + if (mode === "stale-pending-changes") { + return { + pendingChanges: [{ path: "src/server.ts" }], + index: { state: "complete", pendingRefs: 0, reindexRecommended: false }, + }; + } + if (mode === "stale-worktree-mismatch") { + return { + worktreeMismatch: true, + index: { state: "complete", pendingRefs: 0, reindexRecommended: false }, + }; + } + if (mode === "stale-incomplete-state") { + return { + index: { state: "partial", pendingRefs: 0, reindexRecommended: false }, + }; + } + if (mode === "stale-pending-refs") { + return { + index: { state: "complete", pendingRefs: 4, reindexRecommended: false }, + }; + } + if (mode === "stale-reindex-recommended") { + return { + index: { state: "complete", pendingRefs: 0, reindexRecommended: true }, + }; + } + return {}; +} + +function fakeCodegraphScript() { + return ` + import fs from "node:fs"; + const argv = process.argv.slice(2); + fs.appendFileSync(process.env.CODEGRAPH_FAKE_LOG, JSON.stringify(argv) + "\\n", "utf8"); + const status = parseStatusConfig(process.env.CODEGRAPH_FAKE_STATUS); + if (["implement", "init", "sync"].includes(argv[0])) { + console.error("fake codegraph must not be asked to " + argv[0]); + process.exit(70); + } + if (argv[0] === "status" && argv[2] === "--json") { + const initialized = status.initialized ?? (process.env.CODEGRAPH_FAKE_MODE !== "uninitialized"); + console.log(JSON.stringify({ + initialized, + repo: argv[1], + lastIndexed: status.lastIndexed ?? "2026-07-16T00:00:00.000Z", + indexPath: argv[1] + "/.codegraph/index.sqlite", + pendingChanges: status.pendingChanges ?? [], + worktreeMismatch: status.worktreeMismatch ?? false, + ...(status.journalMode ? { journalMode: status.journalMode } : {}), + index: { + state: status.index?.state ?? (initialized ? "complete" : "missing"), + pendingRefs: status.index?.pendingRefs ?? 0, + reindexRecommended: status.index?.reindexRecommended ?? false, + }, + })); + process.exit(0); + } + if (argv.join("\\u0000") === ["query", "route", "--kind", "route", "--path", argv[5], "--json"].join("\\u0000")) { + if (process.env.CODEGRAPH_FAKE_MODE === "invalid-query-json") { + process.stdout.write("{not-json"); + process.exit(0); + } + console.log(JSON.stringify([ + { node: { kind: "route", name: "POST /api/codegraph-route", filePath: argv[5] + "/server.js", startLine: 2 }, score: 0.98, highlights: ["server.js:2"] }, + { node: { kind: "route", name: "GET /api/codegraph-health", filePath: argv[5] + "/server.js", startLine: 8 }, score: 0.83, highlights: ["server.js:8"] }, + ])); + process.exit(0); + } + console.error("unexpected fake codegraph argv: " + JSON.stringify(argv)); + process.exit(64); + + function parseStatusConfig(rawValue) { + if (!rawValue) return {}; + try { + const parsed = JSON.parse(rawValue); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + return {}; + } + } + `.replace(/^ {4}/gm, "").trimStart(); +} + +function readFakeCodegraphArgv(logPath) { + return fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); +} + function assertFileExists(filePath) { assert.ok(fs.existsSync(filePath), `Expected file to exist: ${filePath}`); } @@ -2722,6 +3588,11 @@ function genericAdapterContractScript(generated) { req.on("end", () => { const body = Buffer.concat(chunks).toString("utf8"); requests.push({ method: req.method, url: req.url, body }); + if (req.url === "/api/items/leak") { + res.writeHead(502, { "content-type": "text/html" }); + res.end("APP_SECRET=generic-target-secret stack trace"); + return; + } res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true, method: req.method, url: req.url, body: body ? JSON.parse(body) : null })); }); @@ -2743,6 +3614,14 @@ function genericAdapterContractScript(generated) { formValue: { body_json: '{"title":"Printer broken"}' }, }, { targetBaseUrl }); if (!postResult.ok) throw new Error("POST action failed: " + JSON.stringify(postResult)); + const failureResult = await handlers.handleGenericHttpCardAction({ + action: "http.post.api.items.id.submit", + formValue: { id: "leak", body_json: '{"title":"Private"}' }, + }, { targetBaseUrl }); + const failureCardText = JSON.stringify(failureResult.card); + if (failureResult.ok) throw new Error("generic non-2xx action unexpectedly succeeded"); + if (!failureCardText.includes("POST /api/items/{id} returned HTTP 502.")) throw new Error("generic failure card is missing method/path/status: " + failureCardText); + if (/APP_SECRET|generic-target-secret|stack trace|html/i.test(failureCardText)) throw new Error("generic failure card leaked the raw target response: " + failureCardText); if (!requests.some((item) => item.method === "GET" && item.url === "/api/tickets/TICKET-42")) throw new Error("GET path was not rendered from form input: " + JSON.stringify(requests)); if (!requests.some((item) => item.method === "POST" && item.url === "/api/tickets" && item.body.includes("Printer broken"))) throw new Error("POST JSON body was not sent: " + JSON.stringify(requests)); console.log("generic adapter contract: PASS"); From 2ecb322991ca6be77c3571bc1e87a637f7d181f7 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:48:12 +0800 Subject: [PATCH 33/37] docs: document structural and Mode B delivery Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 69 ++++++++++++++++++++++++++-- docs/host-delivery-mode-selection.md | 2 +- docs/mode-b-embedding-guide.md | 11 ++++- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 396a4b5..b0dabef 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,29 @@ node dist/index.js verify generated\image-agent-web-lark --mode embedded-adapter node dist/index.js verify generated\image-agent-web-lark-self-hosted --mode self-hosted-runtime --strict node dist/index.js evidence generated\image-agent-web-lark node dist/index.js handoff generated\image-agent-web-lark +node dist/index.js install generated\calendar-stock-updater-lark --target C:\path\to\calendar-copy +node dist/index.js install generated\calendar-stock-updater-lark --target C:\path\to\calendar-copy --apply ``` +## 结构分析后端 + +`analyze` 支持 `--backend auto|internal|codegraph`,默认使用 `auto`: + +- `internal`:只使用 Code2Lark 内置源码扫描; +- `auto`:优先尝试用户自行维护的 codegraph 索引;如果 CLI 不可用、索引未初始化、陈旧、不完整或输出无效,则安全回退到 internal,并把 requested / used / fallback 原因写入分析产物; +- `codegraph`:显式要求新鲜可用的外部索引;条件不满足时直接报错,不静默回退。 + +Code2Lark 只允许调用以下只读命令: + +```text +codegraph status --json +codegraph query route --kind route --path --json +``` + +Code2Lark 不会安装 codegraph,也不会运行 `init`、`sync`、reindex 或其他索引写入操作。外部结果只提供文件、符号和路由等低层结构事实;业务 Profile、能力推断、卡片动作、权限和安装范围仍由 Code2Lark 决定。 + +calendar-stock-updater 的最终 replay 使用 `--backend auto`,由于目标没有用户维护的新鲜 codegraph 索引,记录结果为 requested=`auto`、used=`internal`、status=`fallback`;分析与后续严格验证正常完成。 + ## 交付模式(Delivery Modes) Code2Lark 的标准输出始终是: @@ -73,12 +94,50 @@ Mode A 是**外置宿主**路线。目标服务继续保持自己的生命周期 Mode B is the target-project embedded host-module path. Mode B is a validated deployment-test baseline in the verified `image-agent-web` sample. -Mode B 是**目标项目内增量宿主模块**路线。生成包依然是 source of truth,但可以把其中的宿主模块(如 `feishu-host/`)复制到目标项目仓库里,作为一个增量模块使用。 +Mode B 是**目标项目内增量宿主模块**路线。生成包依然是 source of truth;标准流程使用显式 `install` 命令把生成包中的闭包安装到目标项目,默认只做 dry-run,只有 `--apply` 会写入。 Mode B 不是重写目标项目业务代码,而是: -- 最小侵入地迁入宿主模块 -- 保留 `.env`、启动方式、验证方式和交接契约 -- 让目标项目自己承载飞书宿主能力 +- 只写入 `integrations/lark/` 隔离模块 +- 使用模块自己的 `.env.example`、依赖、启动方式、测试和交接契约 +- 不修改目标根 `package.json`、启动脚本、Docker、业务代码或 Web UI +- 安装前要求目标健康端点在线,并通过生成包契约与托管文件冲突检查 + +calendar-stock-updater 的两阶段示例: + +```powershell +# 1. 候选包仍位于 generated/,generate 不写目标项目 +node dist/index.js generate out\calendar-stock-updater --out generated\calendar-stock-updater-lark --mode embedded-adapter --host-mode embedded-long-connection + +# 2. 默认 dry-run:在线探测、契约校验、冲突检查,零写入 +node dist/index.js install generated\calendar-stock-updater-lark --target C:\path\to\calendar-copy + +# 3. 人工审查后显式安装,只写 integrations/lark +node dist/index.js install generated\calendar-stock-updater-lark --target C:\path\to\calendar-copy --apply +``` + +#### calendar-stock-updater 当前状态 + +当前 calendar Mode B 路线已经完成本地工程闭环: + +- 原始 `calendar-stock-updater` 项目保持只读,没有修改根 `package.json`、启动脚本、Docker、业务代码或 Web UI; +- 安装验证使用独立 replay,只通过 `install --apply` 写入 `integrations/lark/**`; +- 目标调用仍严格限制为 `GET /api/state`、`POST /api/run` 和 `POST /api/stop`; +- strict verify 为 32 PASS / 0 WARN / 0 FAIL;Code2Lark 完整测试 41/41、replay 根测试 49/49、安装模块测试 8/8; +- 真实飞书长连接已成功建立并发送起始卡,真实 `card.action.trigger` 也已到达宿主; +- 当前真实联调阻塞在 `ALLOWED_OPERATOR_OPEN_IDS`:配置值与回调中的当前应用维度 `operator.open_id` 不一致,因此安全门禁按预期拒绝了操作; +- 真实 Level 2 尚未完成,仍需修正白名单后重新验证刷新、普通预演、停止流程,并补齐截图、message ID、脱敏日志和签字证据。 + +安装模块的本地配置位于目标 replay 的 `integrations/lark/.env`。不要把真实值提交到 Git: + +```dotenv +FEISHU_APP_ID= +FEISHU_APP_SECRET= +TEST_CHAT_ID= +ALLOWED_OPERATOR_OPEN_IDS= +TARGET_BASE_URL=http://127.0.0.1:3069 +``` + +`ALLOWED_OPERATOR_OPEN_IDS` 是允许操作卡片的飞书用户 `open_id` 白名单,多个值使用英文逗号分隔。`open_id` 是应用维度标识,必须取自同一个飞书应用的 `card.action.trigger` 回调字段 `event.operator.operator_id.open_id`,不能使用手机号、chat id、user_id 或另一个应用下的 open_id。飞书应用、机器人能力、权限和长连接订阅在 [飞书开放平台开发者后台](https://open.feishu.cn/app) 配置。 ### `self-hosted-runtime` @@ -422,6 +481,6 @@ Code2Lark 负责构建接入包,也可以探测目标服务是否可达;但 - operator allowlist - duplicate-action 去重 - `image-agent-web` 的真实长连接样板已经完成真人飞书验证 -- `calendar-stock-updater` 已进入 `generic_http_api` 工作流 +- `calendar-stock-updater` 已具备专用业务 Profile 和 dry-run-first Mode B 隔离安装流程;全新 replay 的本地安装、离线阻断、冲突阻断与模块合同已验证;真实长连接、发卡和回调到达已观察,当前需修正当前应用维度的 operator open_id 白名单后继续 Level 2 但 webhook / standalone 的真实 Level 2,以及更多目标项目的真实飞书接入,仍然是后续阶段的工作。 diff --git a/docs/host-delivery-mode-selection.md b/docs/host-delivery-mode-selection.md index 637ea26..95465c2 100644 --- a/docs/host-delivery-mode-selection.md +++ b/docs/host-delivery-mode-selection.md @@ -16,7 +16,7 @@ Mode A is the external host, sidecar, or gateway path. Keep the target service u ## Mode B -Mode B is the target-project embedded host-module path. Keep `generated/-lark/` as source of truth, then copy selected generated modules into the target project. +Mode B is the target-project embedded host-module path. Keep `generated/-lark/` as source of truth. When the package contains `integrations/lark/install-manifest.json`, use the explicit `install` command: it defaults to dry-run and only `--apply` writes the isolated `integrations/lark/` module. Older host packages without that closure still follow the target-specific manual embedding guide. ## standalone-runtime diff --git a/docs/mode-b-embedding-guide.md b/docs/mode-b-embedding-guide.md index 9a939d5..a57006d 100644 --- a/docs/mode-b-embedding-guide.md +++ b/docs/mode-b-embedding-guide.md @@ -1,6 +1,15 @@ # Mode B Embedding Guide -Mode B is the target-project embedded host-module path. The generated package remains the source of truth, but selected host files can be copied into the target repository as an incremental module. +Mode B is the target-project embedded host-module path. The generated package remains the source of truth. + +For generated packages that contain `integrations/lark/install-manifest.json`, the standard path is: + +```powershell +node dist/index.js install generated/-lark --target +node dist/index.js install generated/-lark --target --apply +``` + +The first command is a zero-write dry-run. The second writes only `integrations/lark/` after live-target, package-integrity, and managed-file conflict checks. The manual copy guidance below remains for older target-language host packages that do not yet emit an installable closure. Related chooser: `docs/host-delivery-mode-selection.md`. From bfabda29b21af99093c8feaa1884ee1bad9b0f2b Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:48:12 +0800 Subject: [PATCH 34/37] docs: record calendar validation status Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- docs/capability-validation-matrix.md | 3 ++- docs/project-status.md | 14 ++++++++------ docs/second-target-validation-plan.md | 22 ++++++++++++++++++++-- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/capability-validation-matrix.md b/docs/capability-validation-matrix.md index 3a53220..64370b3 100644 --- a/docs/capability-validation-matrix.md +++ b/docs/capability-validation-matrix.md @@ -6,4 +6,5 @@ This matrix is the current human-readable fact source for target profile, delive | --- | --- | --- | --- | --- | --- | | image-agent-web | Mode A | self-hosted / long connection | yes | yes | verified sample baseline | | image-agent-web | Mode B | embedded host module | yes | yes | verified sample baseline | -| calendar-stock-updater | Mode A | embedded/adapter-first | yes | replay package + safe target validation | replay copy validated with `GET /api/state` and rejected reviewed `POST /api/run`; real Feishu Level 2 remains out of scope | +| calendar-stock-updater | Mode A | embedded-adapter / embedded-long-connection | yes | package and host contract verified; real Feishu pending | dedicated calendar Profile and `handleCardAction()` facade; formal Feishu ingress is `card.action.trigger`; exact target calls remain `GET /api/state`, `POST /api/run`, and `POST /api/stop` | +| calendar-stock-updater | Mode B | isolated Node module / long connection | yes | replay install verified; real WebSocket, start card, and callback receipt observed; authorized Level 2 pending | `auto` safely fell back to internal without a maintained index; strict generated TypeScript without suppressions; target-controlled status/log/failure text is bounded and redacted; dry-run-first install writes only `integrations/lark`; strict verify `32/0/0`; module `8/8`, replay `49/49`, zero-vulnerability audits, offline/conflict gates, and root/hash integrity passed; current app-scoped operator open_id must be aligned with `ALLOWED_OPERATOR_OPEN_IDS` before authorized refresh/dry-run evidence | diff --git a/docs/project-status.md b/docs/project-status.md index c5b83cb..51bde3d 100644 --- a/docs/project-status.md +++ b/docs/project-status.md @@ -1,6 +1,7 @@ # 项目进展文档 -记录时间:2026-07-02 +初始记录:2026-07-02 +最后更新:2026-07-17 记录人:审计对话(Claude Code) 本文档是对当前代码库和 `docs/` 现有资料的一次快照式审计总结,用于给后续开发和交接提供一个"此刻项目处于什么状态"的基准点。请在重大里程碑后更新此文档,而不是频繁小改。 @@ -15,9 +16,9 @@ Lark-deployer 是一个构建时(build-time)生成器:分析一个已有 ## 2. 代码规模与结构 -- TypeScript 源码分布在 `src/index.ts`(命令分发)+ 12 个 `src/commands/*.ts` 子命令模块 + 少量工具模块(`args.ts`、`env-utils.ts`、`fs-utils.ts`、`http-utils.ts`、`url-validation.ts`、`placeholder-utils.ts`、`types.ts`)。 +- TypeScript 源码分布在 `src/index.ts`(命令分发)+ `src/commands/*.ts` 子命令模块 + 少量工具和 Profile 模块;calendar Mode B 新增了独立安装命令与 Node 宿主闭包生成器。 - 体量最大的文件仍是 `generate.ts`,其中包含生成 `adapter/`、`bot-runtime/`、README 和验收文档的模板字符串;这仍是后续可维护性风险。 -- 测试:`tests/cli-smoke.test.mjs`、`tests/runtime-local-e2e.test.mjs`、`tests/unit-pure-functions.test.mjs`。CLI 冒烟测试已覆盖 adapter-first 生成与 `verify --mode embedded-adapter --strict` package validation;运行时 e2e 继续覆盖 standalone/reference host。 +- 测试:`tests/cli-smoke.test.mjs`、`tests/mode-b-install.test.mjs`、`tests/runtime-local-e2e.test.mjs`、`tests/unit-pure-functions.test.mjs`。CLI 冒烟测试覆盖 adapter-first 生成与 strict verify;Mode B 测试覆盖 dry-run、隔离 apply、离线门禁、托管冲突和宿主本地确认;运行时 e2e 继续覆盖 standalone/reference host。 - `tsconfig.json` 开启 `strict: true`,构建目标 ES2022 / NodeNext。 - 2026-07-07 泛用化阶段新增 manifest `0.2` 契约、strategy-based analyzer、`generic_http_api` 分析路径,以及 `generic-http-api` embedded-adapter 生成/验证路径;`image-agent-web` 仍是 self-hosted-runtime 长连接回归锚点。 - The canonical MVP package is freshly generated from current schema 0.2 manifests. @@ -27,11 +28,12 @@ Lark-deployer 是一个构建时(build-time)生成器:分析一个已有 ``` analyze → plan → context(生成给所有者的凭据请求) → generate(生成 adapter/,可选 bot-runtime/) -→ verify(--mode embedded-adapter 或 --simulate / --level2) → evidence(生成 Level2 证据草稿) → doctor(--mode embedded-adapter 或 --gate) +→ verify(--mode embedded-adapter 或 --simulate / --level2) → install(默认 dry-run,显式 --apply) +→ evidence(生成 Level2 证据草稿) → doctor(--mode embedded-adapter 或 --gate) → handoff(--copy-to / --check,脱敏交接包) ``` -13 个 CLI 子命令均已实现:`analyze`、`plan`、`generate`、`context`、`configure`、`status`、`readiness`、`doctor`、`verify`、`evidence`、`handoff`、`init-local`。 +13 个 CLI 子命令均已实现:`analyze`、`plan`、`generate`、`context`、`configure`、`status`、`readiness`、`doctor`、`verify`、`evidence`、`handoff`、`init-local`、`install`。 ## 4. 验证状态 @@ -43,7 +45,7 @@ analyze → plan → context(生成给所有者的凭据请求) → generate(生 - **image-agent-web self-hosted-runtime 已有真实飞书长连接 MVP 验证**:2026-07-07 后以 `docs/image-agent-web-mvp-verified-summary.md` 为回归锚点,确认长连接、`card.action.trigger`、Card JSON 2.0、异步 running + patch、generate / iterate / batch / refresh、失败路径已经跑通。webhook/standalone Level 2 仍按各自生成包证据记录独立管理。 - **image-agent-web 样板分类**:The verified image-agent-web sample has completed deployment-test validation in Mode A with a Python self-hosted host module run externally, and it has also completed deployment-test validation in Mode B as a target-project embedded host module. This roadmap treats those validations as the current sample baseline and consolidates them into a reusable MVP integration package. - **self-hosted-runtime 本地 MVP 已作为目标形态落地**:生成物为 `generated/-lark/feishu-host/`,包含 `.env.example`、`requirements.txt`、manifest-derived `spec/*.json`、Python card/client/validation/handler/app 文件、`local_contract_test.py` 和 `app.py --selfcheck`。最终本地完成证据必须在安装 Python 依赖后运行 strict verify,不能把缺依赖 WARN 当成绿灯。 -- **calendar-stock-updater 已完成 replay 副本级验证**:2026-07-12 在 `C:\works\calendar-stock-updater-c2l-replay` 上完成 fresh analyze → generate embedded-adapter → `verify --mode embedded-adapter --strict` → `doctor --mode embedded-adapter --json` → `readiness` → `handoff --check`;生成包 `generated\calendar-stock-updater-lark` 通过 package validation,并通过生成 adapter 对 replay 目标完成 `GET /api/state` read 验证和一个安全拒绝的 reviewed `POST /api/run` action 验证。该结论不等同于真实飞书 Level 2。 +- **calendar-stock-updater 已完成结构后端纠偏后的 Mode B 本地验证与复审修正**:2026-07-16 至 2026-07-17 在全新 `C:\works\calendar-stock-updater-code2lark-replay-20260716-211227` 上保留原项目当前工作树,完成 `analyze --backend auto` 安全回退 internal、fresh plan → generate → strict verify、dry-run 零写入、`--apply` 仅写 `integrations/lark`、23 个根文件与原始项目 SHA-256 全匹配、模块 `8/8`、replay 根 `49/49`、离线门禁、托管文件冲突门禁和零漏洞审计。最终候选包为 `generated/calendar-stock-updater-codegraph-replay-20260717-0218-v6-lark`,最终脱敏交接包为 `handoff/calendar-stock-updater-codegraph-replay-20260717-0218-v7-lark`;strict verify 为 `32/0/0`,handoff warnings=`0`。生成的 calendar TypeScript 不再使用检查抑制,状态、日志和失败文本已限长与敏感模式脱敏,授权错误使用中文业务文案,generic HTTP 非 2xx 失败卡不再暴露原始响应正文,context/handoff 不再携带图片代理或 generic runtime 残留。专用 calendar Profile 只声明 `GET /api/state`、`POST /api/run`、`POST /api/stop`;正式执行和停止的确认状态留在隔离 Node 宿主。真实飞书预联调已成功建立长连接、发送起始卡并收到 `card.action.trigger`,但当前应用维度的 operator open_id 与本地白名单不匹配,操作被安全门禁拒绝;未发生正式执行,Level 2 仍未完成。 - 唯一一次真实目标服务联调记录:2026-07-01,临时启动 `C:\works\image-agent-web`,验证了 `GET /api/meta`、生成运行时 `/health`、本地卡片 URL 挑战等;`POST /api/generate` 之外的真实调用未覆盖(依赖外部图像/模型服务)。 ## 5. 审计结论:优点 diff --git a/docs/second-target-validation-plan.md b/docs/second-target-validation-plan.md index f3f25bb..c386858 100644 --- a/docs/second-target-validation-plan.md +++ b/docs/second-target-validation-plan.md @@ -1,5 +1,7 @@ # Second Target Validation Plan +Status: historical Mode A baseline. The approved Mode B correction and current execution contract are in `docs/calendar-stock-updater-mode-b-correction-task-book.md`. + ## Selected Target - `calendar-stock-updater` @@ -8,8 +10,11 @@ ## Delivery Choice - Default: Mode A -- Host shape: external host / sidecar / gateway consuming the generated adapter package. -- Rationale: validate the MVP integration package against a second target without forcing an embedded-host migration first. +- Delivery mode: Mode A +- Integration mode: embedded-adapter +- Host receive mode: embedded-long-connection +- Host shape: external host / sidecar / gateway consuming the generated adapter package and receiving Feishu card actions through SDK long connection. +- Rationale: validate the MVP integration package against a second target without forcing an embedded-host migration first, while making long connection the formal Feishu ingress path for manual follow-up. ## Minimum Validation Scope @@ -18,6 +23,7 @@ - One rejected destructive path: `POST /api/stop` remains excluded from generated card actions unless explicitly reviewed later. - Strict package verification: `verify --mode embedded-adapter --strict`. - Operator handoff checks: readiness, doctor, and handoff outputs must stay generic and avoid image-agent assumptions. +- Formal Feishu handoff path: generated package must use `--mode embedded-adapter --host-mode embedded-long-connection`, require `card.action.trigger` routing to `handleGenericHttpCardAction()`, and avoid webhook callback prerequisites unless a future webhook fallback is explicitly requested. ## Evidence Location @@ -35,8 +41,20 @@ - Reviewed action validation: generated adapter action `http.post.api.run.submit` sent an intentionally invalid `dry-run` product range and returned a failure card (`ACTION_OK=false`, `HTTP action failed`), leaving the replay target not running (`stopRequested=false`). - This proves replay package + safe target-path validation only; it is not real Feishu Level 2 evidence. +## Formal Long-Connection Handoff Target + +- Replay copy: `C:\works\calendar-stock-updater-c2l-replay` remains the only analysis/generation source for this target. +- Long-connection analysis output: `out\calendar-stock-updater-c2l-replay-long`. +- Long-connection generated package: `generated\calendar-stock-updater-lark-long`. +- Fresh analyze: `node dist\index.js analyze "C:\works\calendar-stock-updater-c2l-replay" --base-url http://127.0.0.1:3069 --out out\calendar-stock-updater-c2l-replay-long --name calendar-stock-updater`. +- Fresh generate: `node dist\index.js generate out\calendar-stock-updater-c2l-replay-long --out generated\calendar-stock-updater-lark-long --mode embedded-adapter --host-mode embedded-long-connection`. +- Required package gates: `verify --mode embedded-adapter --host-mode embedded-long-connection --strict`, `doctor --mode embedded-adapter --host-mode embedded-long-connection --json`, `readiness`, and `handoff --copy-to handoff\calendar-stock-updater-lark-long --check`. +- Manual follow-up requirements: configure `APP_ID`, `APP_SECRET`, `TEST_CHAT_ID`, and `TARGET_BASE_URL` in the external host or sidecar; start Feishu SDK long connection; subscribe to `card.action.trigger`; route events to `handleGenericHttpCardAction()`; send the start card; click in the test chat; then fill sanitized Level 2 evidence. +- This package must not require `PUBLIC_CALLBACK_BASE_URL`, `/webhook/card`, `VERIFICATION_TOKEN`, or a public callback URL for the formal long-connection path. + ## Out Of Scope - No new platform targets. - No real Feishu deployment for this second target in the current phase. - No Mode B migration unless the target owner explicitly requests an internal host module. +- No webhook fallback unless explicitly requested later. From 10e58d093aaf211d805cfaebb20bc109b88c5fa7 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:48:52 +0800 Subject: [PATCH 35/37] docs: add active calendar correction taskbooks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...ock-updater-mode-b-correction-task-book.md | 322 ++++++++++++++ ...structural-backend-correction-task-book.md | 393 ++++++++++++++++++ 2 files changed, 715 insertions(+) create mode 100644 docs/calendar-stock-updater-mode-b-correction-task-book.md create mode 100644 docs/codegraph-structural-backend-correction-task-book.md diff --git a/docs/calendar-stock-updater-mode-b-correction-task-book.md b/docs/calendar-stock-updater-mode-b-correction-task-book.md new file mode 100644 index 0000000..88b127d --- /dev/null +++ b/docs/calendar-stock-updater-mode-b-correction-task-book.md @@ -0,0 +1,322 @@ +# calendar-stock-updater Mode B 纠偏任务书 + +记录时间:2026-07-16 +状态:本地纠偏、隔离安装、自动化复验与最终复核完成,等待真实飞书 Level 2 +适用仓库:`C:\works\Lark-deployer` + +本任务书取代旧任务书中的以下方向,但保留旧文件作为历史记录: + +- 不再修改目标项目根启动脚本、`package.json`、Docker 或业务文件; +- 不再要求目标实现 `/api/run/prepare`、`/api/run/confirm`、`/api/stop/prepare`、`/api/stop/confirm`; +- 不再把手工复制生成物视为标准安装流程; +- 不再把 replay 的适配性修改回写到原始项目。 + +## 1. 目标与不变量 + +Code2Lark 必须完成一条可审查的两阶段 Mode B 流程: + +```text +分析目标 +-> 生成候选包 generated/-lark +-> 严格验证生成包与目标源码契约 +-> install dry-run +-> 用户显式 install --apply +-> 仅写入 /integrations/lark +``` + +固定不变量: + +1. `generated/-lark` 是单一事实来源。 +2. `generate` 永远不写入目标项目。 +3. `install` 默认只 dry-run;只有 `--apply` 可以写文件。 +4. 安装只允许写入 `integrations/lark/**`。 +5. 目标根 `package.json`、锁文件、启动脚本、Docker、业务代码、Web UI 必须保持字节不变。 +6. 原始项目 `C:\works\calendar-stock-updater` 只读。 +7. 真实飞书凭据、目标登录凭据和本地 `.env` 不进入生成包、测试快照或 Git。 + +## 2. 目标项目真实契约 + +生成的宿主只允许调用原始项目已经存在的三个有限 HTTP 端点: + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/api/state` | 健康探测、表单默认值、任务状态和最近日志 | +| `POST` | `/api/run` | 以 `mode=dry-run` 预演,或以 `mode=run` 正式执行 | +| `POST` | `/api/stop` | 停止当前任务 | + +`GET /api/events` 是浏览器 SSE 支撑端点,只记录为 `supporting`,不得生成飞书动作。 + +以下路径不存在于原始目标,生成、验证和安装都必须拒绝它们: + +```text +/api/run/prepare +/api/run/confirm +/api/run/cancel +/api/stop/prepare +/api/stop/confirm +/api/stop/cancel +``` + +卡片动作可以保留 `prepare`、`confirm`、`cancel` 语义,但这些是宿主本地状态机,不是目标能力。 + +## 3. 生成包与安装闭包 + +calendar Mode B 候选包除现有 manifest 和 adapter 外,还必须包含一个可直接安装的闭包: + +```text +generated/calendar-stock-updater-lark/ + generation_summary.json + manifest/ + adapter/ + docs/ + integrations/ + lark/ + app.js + config.js + host.js + package.json + package-lock.json # 仅在生成流程实际固定依赖时生成 + .env.example + README.md + install-manifest.json + generated/ + adapter/ + manifest/ + docs/ + sidecar-long-connection/ + *.test.mjs +``` + +`integrations/lark` 必须: + +- 自包含 Node 宿主和 `@larksuiteoapi/node-sdk` 依赖; +- 使用模块自己的 `.env`,不依赖目标根 `.env`; +- 能整体删除,不要求清理目标根文件; +- 不 import 目标业务模块,只通过 HTTP 调用目标; +- 不要求目标提供统一启动入口。 + +当前 calendar 实现使用独立 Node 宿主。以后可优先跟随目标语言;没有成熟模板时仍以隔离 Node 模块作为安全回退。 + +## 4. 飞书卡片与确认流程 + +### 4.1 信息结构 + +卡片使用 JSON 2.0,并镜像原 Web 控制台的推荐流程: + +- 状态:任务状态、当前消息、日期范围、库存、商品范围、启动时间; +- 参数:目标日期、库存、普通操作停顿、日期组件停顿、可选开始商品 ID、可选结束商品 ID; +- 动作:刷新、普通预演、申请正式执行、申请停止; +- 日志:最近有限条目,单行脱敏并限制长度。 + +表单默认值必须来自 `GET /api/state` 的 `defaults`,而不是生成时写死。 + +原项目仅通过环境变量支持、但 Web UI 没有暴露的特殊日期/SKU 模式,只记录为人工 review candidate,本阶段不进入默认飞书卡片。 + +### 4.2 风险分级 + +- 刷新:直接 `GET /api/state`。 +- 普通预演:校验表单后直接 `POST /api/run`,请求体包含 `mode: "dry-run"`。 +- 正式执行:先生成宿主本地、绑定操作者、单次使用、有限 TTL 的确认记录;确认后调用 `POST /api/run`,请求体包含 `mode: "run"`。 +- 停止任务:先读取当前状态并生成宿主本地确认记录;确认后调用 `POST /api/stop`。 +- 取消确认:删除或放弃本地记录并刷新状态,不调用写端点。 + +确认值不得包含目标凭据或可伪造的完整业务授权。宿主必须重新校验操作者、确认类型、TTL 和单次使用状态。 + +### 4.3 回调约束 + +- 只订阅新版 `card.action.trigger`; +- `action.value` 必须按对象处理,表单读取 `form_value`; +- 回调必须在 3 秒内返回卡片或 toast; +- 同一 `event_id` 或同一确认 token 的重复投递不得重复执行 run/stop; +- 当前设计默认单活宿主。若未来多实例部署,确认和幂等状态必须迁到共享存储。 + +## 5. 安装命令契约 + +建议 CLI: + +```powershell +node dist/index.js install ` + --target ` + [--target-base-url ] ` + [--apply] +``` + +默认 dry-run 必须: + +1. 验证生成包标记、Profile 和安装 manifest; +2. 验证 manifest 中所有相对路径安全且位于 `integrations/lark` 闭包; +3. 验证文件 SHA-256 与 manifest 一致; +4. 验证 capability 端点全部来源于分析发现; +5. 拒绝生成代码中的虚构 prepare/confirm/cancel 目标路径; +6. 实际探测 `GET /api/state` 并要求 2xx JSON; +7. 完成全部冲突检查; +8. 输出计划写入的文件,但不创建目录或文件。 + +`--apply` 必须先完成同一套预检,再一次性写入。任何失败都必须发生在首个写操作之前。 + +## 6. 托管文件与冲突规则 + +生成的 `install-manifest.json` 列出每个托管文件及 SHA-256。安装后在模块内写入 `.code2lark-install.json`,记录实际安装版本和安装时哈希。 + +升级规则: + +- 目标文件不存在:允许创建; +- 目标文件存在但没有历史托管记录:视为用户文件,停止; +- 目标文件存在且当前哈希等于上次安装哈希:允许更新; +- 目标文件存在且当前哈希不同于上次安装哈希:视为人工修改,停止并列出冲突; +- 不使用 `--force` 绕过人工修改; +- 不静默删除旧文件。本阶段由 README 提供整体删除 `integrations/lark` 的人工清理方式。 + +## 7. 三层本地成熟度与独立真实飞书状态 + +本地成熟度固定为前三层;真实飞书验证是独立证据状态,必须分开表述: + +1. **候选包已生成**:离线也可达到;只说明静态分析和生成完成。 +2. **安装 dry-run 已审查**:目标在线、契约和冲突预检通过,但目标仍未写入。 +3. **本地安装已验证**:`--apply` 仅写入 `integrations/lark`,模块测试和本地目标合同通过。 +4. **真实飞书已验证**:真实应用、长连接、起始卡和人工点击证据均完成。 + +前三个状态不得被描述为“真实飞书接入完成”。 + +## 8. TDD 场景合同 + +生产代码修改前先建立 RED 测试: + +### C2L-MB-001 候选包边界 + +- calendar 离线分析和生成成功; +- 目标项目没有新增 `integrations/lark`; +- 生成包包含完整安装闭包。 + +### C2L-MB-002 默认 dry-run + +- 不带 `--apply` 时命令成功输出计划; +- 目标在线且合同通过; +- 目标目录无任何新增或修改。 + +### C2L-MB-003 隔离安装 + +- `--apply` 仅创建 `integrations/lark/**`; +- 根文件安装前后哈希一致; +- 模块本地测试通过。 + +### C2L-MB-004 离线阻断 + +- `GET /api/state` 不可达时 dry-run/apply 失败; +- 失败前没有目标写入。 + +### C2L-MB-005 托管冲突 + +- 首次安装成功; +- 人工修改托管文件后再次安装失败; +- 人工内容保持不变。 + +### C2L-MB-006 真实端点闭包 + +- capability map 只有 `/api/state`、`/api/run`、`/api/stop`; +- 卡片本地确认动作不增加目标端点; +- 虚构路径导致 strict verify/install 失败。 + +### C2L-MB-007 相邻回归 + +- image-agent-web 现有 generate/verify/e2e 通过; +- generic-http-api 现有 generate/verify 通过。 + +## 9. 实施波次 + +1. 冻结当前 dirty work 和基线测试结果。 +2. 新增 RED 安装与 calendar 合同测试。 +3. 纠正 calendar capability、interaction、卡片和 handler。 +4. 生成自包含 `integrations/lark` Node 闭包及安装 manifest。 +5. 强化 strict verify 的源码发现端点门禁。 +6. 增加 dry-run-first `install` 和在线健康门禁。 +7. 增加 manifest 范围复制和托管哈希冲突检查。 +8. 更新模块 README、`.env.example` 和三层本地成熟度说明。 +9. 在全新 replay 上执行真实 CLI 表面验证。 +10. 执行自动测试、LSP、代码审查、安全审查和卡片设计审查。 + +## 10. 全新 replay 验收 + +从原始项目的当前工作树创建新副本,但排除: + +```text +.git/ +.env +node_modules/ +data/ +playwright-profile*/ +*.log +coverage/ +临时和缓存目录 +``` + +不得覆盖现有 replay;使用新的、空的目标路径。顺序为: + +1. 记录原始项目状态和关键文件哈希; +2. 创建 replay,并先运行目标自己的测试; +3. 启动 replay 的现有 `npm run ui`,确认 `GET /api/state`; +4. fresh analyze、generate、strict verify; +5. install dry-run,确认零写入; +6. install `--apply`; +7. 比较目标根关键文件哈希,只允许 `integrations/lark` 新增; +8. 运行模块测试和 adapter contract; +9. 停止目标后验证离线阻断; +10. 恢复目标、修改一个托管文件并验证冲突阻断; +11. 删除实验 replay 或按用户要求保留证据。 + +## 11. 最终自动化门禁 + +```powershell +npm run build +npm run test:unit +npm run test:smoke +npm run test:mode-b +npm run test:e2e +``` + +并必须证明: + +- 原始 calendar 项目状态未因本任务变化; +- C2L 所有改动均可解释且没有回退既有用户修改; +- dry-run 零写入; +- apply 只写 `integrations/lark`; +- run/stop 重复确认不会产生重复目标调用; +- 模块文档包含本地配置、启动、测试、整体删除和 Level 2 后续步骤; +- 没有真实 secret 或虚构验证证据。 + +## 12. 真实飞书 Level 2 边界 + +本任务可以完成候选包、本地安装和本地合同验证。真实 Level 2 仍要求操作者安全提供应用配置,并人工完成: + +1. 启用机器人; +2. 启用长连接; +3. 订阅 `card.action.trigger`; +4. 把机器人加入测试群; +5. 启动 `integrations/lark/app.js`; +6. 发送并点击真实卡片; +7. 记录脱敏 message id、截图和操作结果。 + +在这些证据完成前,最终结论只能是: + +> calendar-stock-updater 的 Mode B 候选包、隔离安装流程和本地合同验证已完成,等待真实飞书 Level 2 联调。 + +## 13. 2026-07-17 执行结果 + +- 最终候选包:`generated/calendar-stock-updater-codegraph-replay-20260717-0218-v6-lark`。 +- 最终脱敏交接包:`handoff/calendar-stock-updater-codegraph-replay-20260717-0218-v7-lark`;共复制 74 个文件,`handoff --check` 通过,warnings=`0`、recommended missing=`0`、excluded present=`0`。 +- strict verify:32 项 PASS,0 WARN,0 FAIL;schema 0.2、三个目标端点和八个卡片动作均通过。 +- readiness / doctor:readiness=`external_context_missing`;doctor 确认 package valid,本地最终 gate 仅因真实宿主接入、飞书配置和 Level 2 证据缺失而未通过。 +- 生成 adapter:TypeScript 不含 `@ts-nocheck`、`@ts-ignore` 或 `@ts-expect-error`,并通过 strict `tsc --noEmit`;JavaScript adapter 实际执行通过。 +- 卡片纠偏:状态与停止确认消息限长并归一换行;状态和最近日志对 URL、HTML、secret/token/password、裸 `auth=...`、operator/chat ID 等敏感模式降级;裸 auth 的状态、日志、停止确认和失败卡运行探针通过;目标 HTTP 错误不再展示原始响应正文;授权和延迟字段使用中文业务标签。 +- replay 重装:dry-run 零写入;`--apply` 写入 32 个托管文件且仅位于 `integrations/lark/**`,目标非 integration 文件 hash change=`0`;31 个 manifest 文件与生成包和安装结果 SHA-256 mismatch=`0`。 +- 自动化:Code2Lark unit `4/4`、smoke `25/25`、Mode B `11/11`、e2e `1/1`,完整 `npm test` 为 `41/41`;replay 根 `49/49`,模块 `8/8`。 +- 安全与完整性:Code2Lark、replay 根和安装模块 `npm audit` 均为 0 vulnerabilities;离线和托管文件冲突均在写入前阻断;原始项目与 replay 的 23 个批准范围文件 mismatch=`0`;端口 3069 在验证后关闭。 +- 最终专项复核:CJK/卡片完整性 PASS、hands-on QA PASS、隐私 Oracle PASS;无 MAJOR/HIGH 本地发现。 +- 截至本地闭环时,真实飞书 Level 2 尚未执行;当时仍缺 `FEISHU_APP_ID`、`FEISHU_APP_SECRET`、`TEST_CHAT_ID`、`ALLOWED_OPERATOR_OPEN_IDS`、真实长连接、人工点击、截图/message ID、脱敏日志与签字。 + +### 2026-07-17 真实飞书预联调更新 + +- 本地 `.env` 已完成私密配置,真实值不进入 Git、生成包、交接包或共享日志。 +- 长连接 ready、起始卡发送和用户收卡均已确认,真实 `card.action.trigger` 也已到达隔离宿主。 +- 当前点击因回调中的当前应用维度 operator open_id 与本地 `ALLOWED_OPERATOR_OPEN_IDS` 不匹配而被授权门禁拒绝;这是预期的安全失败。 +- 未发生正式库存执行。修正白名单并重新取得刷新、普通预演、停止和人工证据前,真实 Level 2 仍未完成。 diff --git a/docs/codegraph-structural-backend-correction-task-book.md b/docs/codegraph-structural-backend-correction-task-book.md new file mode 100644 index 0000000..6df7d90 --- /dev/null +++ b/docs/codegraph-structural-backend-correction-task-book.md @@ -0,0 +1,393 @@ +# Code2Lark 结构分析后端纠偏任务书 + +记录时间:2026-07-16 +状态:本地实现、纠偏重放、自动化复验与最终复核完成,等待真实飞书 Level 2 +适用仓库:`C:\works\Lark-deployer` +关联任务书:`docs/calendar-stock-updater-mode-b-correction-task-book.md` + +本任务书用于纠正 Code2Lark 的源码分析边界:把通用代码结构发现收敛为可插拔后端,同时继续由 Code2Lark 负责业务能力解释、飞书交互设计、生成、严格验证和隔离安装。本任务书不取代 calendar Mode B 任务书;两者共同构成本轮开发与 replay 的验收合同。 + +## 1. 纠偏目标 + +目标架构固定为: + +```text +internal scanner / codegraph + -> Normalized StructuralFacts + -> Code2Lark Profile 与业务能力推断 + -> schema 0.2 manifests + -> plan / card design / generate + -> strict verify + -> dry-run-first install +``` + +首个外部后端选择 `colbymchenry/codegraph`,理由是其提供: + +- Windows 可用的本地 CLI; +- `--json` 查询输出; +- 一等 `route` 节点及路由到 handler 的关系; +- 本地 `.codegraph` SQLite 索引; +- 可检查的索引版本、待同步变化和重建状态。 + +CodeGraphyV4 保留为未来候选,不进入本轮生产实现。 + +## 2. 不变量 + +1. `ServiceManifest`、`CapabilityMap`、`InteractionContract`、`RequiredPermissions` 继续使用 `schema_version: "0.2"`。 +2. 外部后端只提供文件、符号、路由、调用、引用等低层结构事实。 +3. 外部后端不得决定业务能力、风险等级、卡片动作、权限、宿主代码或安装范围。 +4. strict verify 不得因使用外部后端而放宽。 +5. 默认流程不得要求用户安装 `codegraph`。 +6. Code2Lark 不自动安装、初始化、同步或重建外部索引。 +7. 禁止自动执行 `codegraph init`、`codegraph sync` 或等价写索引命令。 +8. 原始项目 `C:\works\calendar-stock-updater` 全程只读。 +9. 不覆盖既有 replay、generated 或 handoff;新证据使用带时间戳的新目录。 +10. calendar Mode B 只允许写入 replay 的 `integrations/lark/**`。 +11. 不修改目标根 `package.json`、锁文件、启动脚本、Docker、业务代码或 Web UI。 +12. 不提交、不推送,不清理用户已有 dirty work。 + +## 3. 后端模式合同 + +`analyze` 增加: + +```text +--backend auto|internal|codegraph +``` + +### 3.1 `auto` + +- 默认模式; +- 若存在可执行的 `codegraph`、目标已有索引且索引新鲜,则使用规范化后的 route 事实; +- 若工具缺失、Node 版本不兼容、索引未初始化、索引过期、存在待处理引用、JSON 非法或 route 结果不可用,则记录回退原因并使用 internal scanner; +- 回退不得改变现有分析成功条件。 + +### 3.2 `internal` + +- 只使用 Code2Lark 当前内置扫描器; +- 不探测、不调用 `codegraph`; +- 作为兼容、排障和结果对照基线。 + +### 3.3 `codegraph` + +- 明确要求使用外部索引; +- 工具缺失、Node 版本不兼容、索引未初始化或不新鲜、输出非法时必须清晰失败; +- 错误信息应说明需要用户自行安装或维护索引,但不得自动执行修复命令; +- 不允许静默回退 internal。 + +## 4. 外部 CLI 与新鲜度合同 + +只允许调用只读命令: + +```powershell +codegraph status --json +codegraph query route --kind route --path --json +``` + +信任 route 结果前检查: + +- `initialized === true`; +- `lastIndexed` 存在; +- `pendingChanges` 为空; +- `worktreeMismatch !== true`; +- `index.state` 为完整可用状态; +- `index.pendingRefs` 为 `0`; +- `index.reindexRecommended !== true`; +- 当前 Node 版本满足工具支持范围。 + +`journalMode` 等非关键字段若缺失不得导致解析崩溃;只有能证明索引不安全或不完整的状态才阻止显式后端。外部 JSON 必须在系统边界做结构校验。 + +## 5. 规范化边界 + +外部 schema 不得泄漏到 Profile 或下游命令。内部概念模型至少包含: + +```text +StructuralFacts + backend_requested + backend_used + status + fallback_reason? + checked_at + indexed_at? + index_path? + routes[] + +RouteFact + method + path + file? + line? + source = internal | codegraph +``` + +`codegraph query` 的预期输入形状为数组,每项包含 `{ node, score, highlights? }`。只读取 `node` 中可验证的 route 名称、方法、路径、文件和行号;忽略 score、highlight 和业务推断。 + +无法安全识别方法或路径的节点不得制造端点。`auto` 可回退 internal;显式 `codegraph` 必须报告结果不可用。 + +## 6. Manifest 元数据 + +在不改变 schema 版本和既有必填字段的前提下,可在 `service_manifest.source_scan` 增加可选元数据: + +- 请求的后端; +- 实际使用的后端; +- 使用或回退状态; +- 脱敏回退原因; +- 检查时间和索引时间; +- route 来源。 + +元数据不得包含: + +- 原始外部 JSON; +- 私有源码片段; +- 本机账号、凭据或环境变量; +- 对业务能力的未经验证结论。 + +## 7. calendar Mode B 同步修复 + +### 7.1 Plan + +calendar 的 `deployment_checklist.md` 和 `card_plan.md` 必须使用专用 Profile,不得回落到 image-agent 语义。 + +必须包含: + +- `GET /api/state`; +- `POST /api/run`; +- `POST /api/stop`; +- 默认 install dry-run; +- `install --apply` 只写 `integrations/lark`; +- 模块本地 `.env`、`npm install`、`npm test`; +- 真实飞书 Level 2 是独立状态。 + +不得包含: + +- `/api/meta`、图片生成或 batch; +- 把 generic adapter 挂载到既有宿主作为当前 Mode B 标准流程; +- 修改目标根启动脚本或增加统一启动命令。 + +### 7.2 卡片结构 + +卡片属于 Process/Task + Alert/Log,面向操作人员。信息层级固定为: + +1. 状态摘要; +2. 任务参数表单; +3. 主要动作; +4. 最近日志和显示范围说明。 + +设计红线: + +- 技术和审批卡不使用 emoji; +- 正式执行确认展示全部提交值:目标日期、库存、两项延迟、开始商品 ID、结束商品 ID; +- 风险由 header template、danger button 和专业文字共同表达; +- stop 确认明确只作用于当前运行任务; +- 日志保留可用时间戳,单行化并限制长度; +- 明示“仅显示最近 8 条,长行会截断”; +- 状态、参数、日志不堆在同一段高密度 Markdown; +- 状态色只表达状态,不作装饰。 + +### 7.3 授权与确认 + +- refresh、dry-run、run、stop 和所有确认/取消动作均要求操作者 allowlist; +- 未授权 refresh 不得调用 `GET /api/state`; +- 正式 run/stop 保持操作者绑定、TTL、单次使用和重复回调保护; +- 错误卡不得泄露 operator/chat ID 或凭据。 + +### 7.4 安装安全 + +- 生成包和目标路径中的 symlink/junction 必须在首个写操作前拒绝; +- 测试需覆盖生成模块内部链接和目标 `integrations/lark` 链接; +- Windows 无权限创建链接时,测试可以明确 skip,但不得误报通过; +- package/module 镜像、SHA-256、目标在线和托管冲突门禁保持不变。 + +## 8. TDD 场景 + +### C2L-CG-001 internal 基线 + +- `--backend internal` 不调用外部工具; +- 现有 calendar、generic 和 image-agent 分析结果保持兼容; +- manifest 记录实际使用 internal。 + +### C2L-CG-002 auto 无工具回退 + +- PATH 中无 `codegraph` 时分析成功; +- manifest 记录 auto -> internal 和安全回退原因。 + +### C2L-CG-003 显式工具缺失 + +- `--backend codegraph` 在工具缺失时非零退出; +- 错误可操作且不自动安装。 + +### C2L-CG-004 索引状态门禁 + +- `initialized:false`、pending changes、pending refs、建议重建或不完整状态不得被显式后端接受; +- auto 对同样状态回退 internal。 + +### C2L-CG-005 route 规范化 + +- 合法 `{ node, score }[]` route JSON 转为内部 route facts; +- 外部 score/highlight 不进入 manifest; +- 非法 JSON 或无法识别的 route 不制造端点。 + +### C2L-CG-006 严格合同不旁路 + +- 外部 route 只能作为发现来源; +- calendar capability 仍严格闭包为 `/api/state`、`/api/run`、`/api/stop`; +- 虚构 prepare/confirm/cancel 目标路径继续被 verify/install 拒绝。 + +### C2L-CG-007 calendar plan + +- plan 只输出 calendar Mode B 指引; +- 不包含 image-agent 或 generic host 残留。 + +### C2L-CG-008 card 设计与授权 + +- 全参数确认、无 emoji、日志范围提示、时间戳和信息分组可测试; +- 未授权 refresh 不访问目标。 + +### C2L-CG-009 链接路径安全 + +- 生成模块 symlink 和目标 junction 均在写入前阻断; +- 目标快照不变。 + +### C2L-CG-010 相邻回归 + +- unit、smoke、Mode B、runtime e2e 和默认 `npm test` 全部通过。 + +## 9. 实施波次 + +1. 冻结 dirty work、build 和 Mode B 测试基线。 +2. 先增加后端模式、calendar review 和 symlink/junction RED 测试。 +3. 新建独立结构分析模块,完成 internal/codegraph 规范化与回退。 +4. 把 normalized route facts 接入现有 analyze Profile,保持 schema 0.2。 +5. 修复 calendar plan、卡片、授权和日志。 +6. 仅在回归测试证明现有路径检查不足时修改 install。 +7. 运行 LSP、focused tests 和完整测试套件。 +8. 创建全新 calendar replay 并运行完整新流程。 +9. 生成新的 timestamped handoff,验证 dry-run、apply、offline 和 conflict 门禁。 +10. 更新状态文档并执行代码、安全、QA 和卡片视觉复审。 + +每一波失败时只回退该波新增改动,不使用 `git reset --hard`、`git checkout --` 或其他破坏性命令。 + +## 10. 全新 replay 合同 + +从 `C:\works\calendar-stock-updater` 当前工作树复制到: + +```text +C:\works\calendar-stock-updater-code2lark-replay- +``` + +必须排除: + +```text +.git/ +.env +node_modules/ +data/ +coverage/ +playwright-profile*/ +dist/ +build/ +.cache/ +.next/ +*.log +其他运行时、浏览器和临时产物 +``` + +不得复用: + +- `C:\works\calendar-stock-updater-mode-b-corrected-replay`; +- `handoff/calendar-stock-updater-lark-long`; +- 任何旧 analysis/generated 目录。 + +## 11. replay 执行顺序 + +1. 记录原始 calendar 的 `git status --short` 和 `git diff --stat`。 +2. 创建新 replay,确认没有 `.git`、真实 `.env` 或 `node_modules`。 +3. 在 replay 内安装依赖并运行目标自身测试。 +4. 启动 replay 原有 `npm run ui`,验证 `GET /api/state`。 +5. 使用 `analyze --backend auto` 执行 fresh analyze;若没有用户维护的 codegraph 索引,预期安全回退 internal。 +6. 运行 fresh `plan`,核对 calendar 专用文档。 +7. fresh generate、strict verify 和 timestamped handoff check。 +8. install dry-run,比较完整目标快照,必须零写入。 +9. install `--apply`,只允许新增 `integrations/lark`。 +10. 比较 replay 根文件哈希,必须保持不变。 +11. 在模块内运行 `npm install`、`npm test` 和本地 adapter contract。 +12. 停止目标,验证在线健康门禁在写入前失败。 +13. 恢复目标,修改一个托管文件,验证冲突失败且本地修改保留。 +14. 再次核对原始 calendar 状态与步骤 1 完全一致。 + +## 12. replay 证据 + +必须保留并报告: + +- 新 replay 路径; +- 新 analysis、generated、handoff 路径; +- backend requested/used/fallback 状态; +- 目标自身测试结果; +- strict verify 结果; +- dry-run 零写入比较; +- apply 根文件哈希比较; +- 模块测试和依赖审计; +- offline 与 managed conflict 的预期失败; +- 原始目标前后状态比较。 + +不得把本地 replay 结论描述为真实飞书完成。 + +## 13. 自动化门禁 + +```powershell +npm run build +npm run test:unit +npm run test:smoke +npm run test:mode-b +npm run test:e2e +npm test +``` + +所有修改的 TypeScript 文件必须通过 LSP diagnostics。生成模块必须运行自己的 `npm test`;安装依赖后运行 `npm audit`,并如实记录结果。 + +## 14. 完成定义 + +本任务只有同时满足以下条件才完成: + +- 结构后端模式和回退合同有自动测试; +- external schema 不泄漏到业务 Profile; +- schema 0.2 和严格验证保持兼容; +- calendar plan、卡片、授权和日志审查项关闭; +- symlink/junction 回归通过; +- 完整仓库测试通过; +- 全新 replay 和 handoff 完成; +- 原始 calendar 当前工作树未变化; +- 安全、代码质量、独立 QA 和卡片视觉复审通过; +- 没有真实 secret、伪造截图或虚构 Level 2 证据。 + +## 15. 真实飞书 Level 2 边界 + +本轮可以证明结构分析、候选包、隔离安装和本地合同。真实飞书仍要求应用 owner 提供安全配置,启用长连接和 `card.action.trigger`,加入测试群并完成人工点击与脱敏证据。 + +在此之前最终状态只能表述为: + +> Code2Lark 的可插拔结构分析后端、calendar-stock-updater Mode B 候选包、隔离安装与本地 replay 已完成验证;真实飞书 Level 2 仍待人工联调。 + +## 16. 2026-07-16 至 2026-07-17 执行结果 + +- 全新 replay:`C:\works\calendar-stock-updater-code2lark-replay-20260716-211227`。 +- 最终 fresh analysis:`out/calendar-stock-updater-codegraph-replay-20260717-0159`。 +- 结构后端:requested=`auto`,used=`internal`,status=`fallback`;原因是目标没有由用户维护的新鲜 codegraph 索引。分析成功且未自动安装、初始化或同步索引。 +- 最终候选包:`generated/calendar-stock-updater-codegraph-replay-20260717-0218-v6-lark`。 +- 最终脱敏交接包:`handoff/calendar-stock-updater-codegraph-replay-20260717-0218-v7-lark`;共复制 74 个文件,`handoff --check` 通过,warnings=`0`、recommended missing=`0`、excluded present=`0`。 +- strict verify:32 项 PASS、0 WARN、0 FAIL;schema 0.2、calendar target contract 和全部 8 个 card action 均通过。 +- readiness / doctor:readiness=`external_context_missing`;doctor 确认 package valid,本地最终 gate 仅因真实宿主接入、飞书配置和 Level 2 证据缺失而未通过。 +- 仓库自动化:unit `4/4`、smoke `25/25`、Mode B `11/11`、runtime e2e `1/1`;完整 `npm test` 共 `41/41` 通过。 +- 最终纠偏重装:install dry-run 对现有 replay 零写入;`--apply` 写入 32 个托管文件且所有变化仍限于 `integrations/lark/**`,目标非 integration 文件 hash change=`0`,验证后端口 3069 已关闭。 +- 安装模块:模块测试 `8/8`、replay 根测试 `49/49`;Code2Lark、replay 根和安装模块的 `npm audit` 均为 `0 vulnerabilities`;install manifest 中 31 个源文件与最终生成包及已安装文件 SHA-256 mismatch=`0`。 +- 复审纠偏:生成的 calendar TypeScript 已移除文件级抑制并通过 strict `tsc --noEmit`;`journalMode` 被收敛为非关键元数据且全部新鲜度门禁已有测试;卡片状态、日志和失败文本已限长、换行归一和敏感模式脱敏,裸 `auth=...` 在状态、日志、停止确认和失败卡四个表面均会降级,生成产物运行探针通过;generic HTTP adapter 的非 2xx 错误不再携带原始响应正文;calendar context/handoff 已移除图片上传、`bot-runtime` 和 `/api/meta` 残留;根 `AGENT.md` 已改为当前纠偏合同。 +- 最终专项复核:CJK/卡片完整性 PASS、hands-on QA PASS、隐私 Oracle PASS;无 MAJOR/HIGH 本地发现。 +- 负向门禁:目标离线时 `--apply` 以 exit 1 在首个写入前阻断且目标快照不变;人工修改托管 `README.md` 后再次 `--apply` 以 exit 1 报 managed-file conflict,人工 marker 保留且其余快照不变,随后测试 marker 已移除并恢复生成包哈希。 +- 根完整性:排除 `.git`、真实 `.env`、依赖、浏览器/运行时目录和已批准的 `integrations/lark` 后,原始项目与 replay 的 23 个根文件 SHA-256 mismatch=`0`;原始项目仍保持任务开始前的预存 dirty 文件集合。 +- 截至本地闭环时,真实飞书 Level 2 尚未执行;当时仍缺 `FEISHU_APP_ID`、`FEISHU_APP_SECRET`、`TEST_CHAT_ID`、`ALLOWED_OPERATOR_OPEN_IDS`、真实长连接、人工点击、截图/message ID、脱敏日志和签字证据。 + +### 2026-07-17 真实飞书预联调更新 + +- 本地私密配置已就绪,但不进入仓库、生成包或交接包。 +- 真实飞书 WebSocket 长连接已达到 ready,起始卡已成功发送并由用户确认收到。 +- 真实 `card.action.trigger` 已到达宿主;操作因当前应用维度的 operator open_id 与 `ALLOWED_OPERATOR_OPEN_IDS` 不匹配而被安全门禁拒绝。 +- 未触发正式执行。修正白名单并完成刷新、普通预演、停止和证据签字前,真实 Level 2 仍为未完成。 From bfa784ffe9e640c1935d49da5d28667e1c7faa09 Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:48:53 +0800 Subject: [PATCH 36/37] docs: preserve superseded calendar host plan Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...pdater-mode-b-long-connection-task-book.md | 714 ++++++++++++++++++ 1 file changed, 714 insertions(+) create mode 100644 docs/calendar-stock-updater-mode-b-long-connection-task-book.md diff --git a/docs/calendar-stock-updater-mode-b-long-connection-task-book.md b/docs/calendar-stock-updater-mode-b-long-connection-task-book.md new file mode 100644 index 0000000..0875603 --- /dev/null +++ b/docs/calendar-stock-updater-mode-b-long-connection-task-book.md @@ -0,0 +1,714 @@ +# calendar-stock-updater Mode B 一键启动长连接改造任务书 + +记录时间:2026-07-14 +状态:已批准,待执行 + +## 0. 任务结论与目标 + +`calendar-stock-updater` 当前已经完成 Code2Lark 的 **Mode A** 改造与验证: + +```text +Mode A ++ embedded-adapter ++ embedded-long-connection 接入契约 +``` + +该成果保留为 Mode A 基线,不再回头修改或重做。 + +本任务的新目标是:从原始 `calendar-stock-updater` 创建一份**全新的 Mode B replay 副本**,把 Code2Lark 的飞书能力作为一个目标项目内的独立增量模块迁入,并提供统一启动入口。 + +目标用户体验是: + +```text +复制 .env.example 为 .env +→ 用户填写一次飞书和目标服务配置 +→ 执行一个启动命令 +→ 原业务服务与飞书长连接模块共同启动 +→ 模块自动订阅 card.action.trigger +→ 用户可在飞书中通过卡片驱动 calendar 服务 +``` + +本任务完成的是工程改造、本地验证和人工联调准备;**真实飞书发卡、点击和 Level 2 证据由用户后续人工执行,不计入本任务完成条件。** + +--- + +## 1. 实验对象与目录边界 + +### 原始参考项目:只读 + +```text +C:\works\calendar-stock-updater +``` + +规则: + +- 不在原始项目中写入 C2L 生成物、`.env`、宿主模块或实验配置; +- 不将 replay 过程中的修复回写到原始项目; +- 原始项目只用于复制和行为对比。 + +### 新的 Mode B 实验对象 + +```text +C:\works\calendar-stock-updater-mode-b-replay +``` + +创建方式: + +```text +C:\works\calendar-stock-updater +→ C:\works\calendar-stock-updater-mode-b-replay +``` + +复制规则: + +- 不复制原项目的真实 `.env`、node_modules、运行日志、临时文件或本地凭据; +- replay 的 `.env` 只能由其自己的 `.env.example` 初始化; +- replay 必须先能在没有飞书模块时按原项目方式独立启动和通过自身测试。 + +--- + +## 2. Mode B 产品定义 + +本任务的 Mode B 不是把飞书 SDK 逻辑散落进业务核心文件。 + +本任务的 Mode B 是: + +```text +目标项目内部 ++ 一个独立 integrations/lark/ 增量模块 ++ 统一项目启动入口 ++ Feishu SDK websocket 长连接 ++ card.action.trigger ++ 调用 Code2Lark 生成的 generic adapter +``` + +推荐的目标目录结构: + +```text +calendar-stock-updater-mode-b-replay/ + package.json + .env.example + server.js # 原业务服务,保持业务职责 + ...原项目文件 + + integrations/ + lark/ + app.js # 启动长连接模块 + config.js # 加载和校验 Lark 配置 + host.js # Feishu SDK 生命周期与事件路由 + package.json # 若项目依赖边界要求独立依赖 + .env.example # 仅在根 .env 不适用时保留 + generated/ # 由 C2L 产出的 adapter / manifest / docs + adapter/ + manifest/ + docs/ + sidecar-long-connection/ + README.md # 模块使用和人工联调说明 +``` + +允许调整目录名称以符合原项目惯例,但必须满足: + +- 飞书模块是一个可独立理解、可整体删除的目录; +- 业务核心不直接 import 飞书 SDK; +- C2L 生成的 adapter 仍是业务映射的 source of truth; +- 不手工重写 `handleGenericHttpCardAction()` 的业务映射。 + +--- + +## 3. 长连接原则 + +飞书接入采用官方推荐的长连接优先策略: + +```text +Mode B ++ embedded-adapter ++ embedded-long-connection +``` + +必须使用: + +```text +Feishu SDK websocket 长连接 +card.action.trigger +``` + +本任务默认不引入: + +```text +PUBLIC_CALLBACK_BASE_URL +/webhook/card +VERIFICATION_TOKEN +公网 webhook callback +``` + +除非用户在未来明确要求 webhook fallback。 + +--- + +## 4. 统一配置设计 + +用户只应填写一个项目级配置文件: + +```text +calendar-stock-updater-mode-b-replay/.env +``` + +必须提供: + +```text +calendar-stock-updater-mode-b-replay/.env.example +``` + +最小配置项: + +```dotenv +# Feishu long-connection host +FEISHU_APP_ID= +FEISHU_APP_SECRET= +FEISHU_CONNECTION_MODE=websocket +TEST_CHAT_ID= + +# Calendar target reachable from integrations/lark +TARGET_BASE_URL=http://127.0.0.1:3069 +TARGET_TIMEOUT_MS=120000 + +# Optional group-operation guard +ALLOWED_OPERATOR_OPEN_IDS= +``` + +要求: + +- 不把真实 secret 写入 Git、README、任务书、测试快照或 handoff 包; +- 用户配置只存在于 replay 项目被 `.gitignore` 覆盖的 `.env`; +- host 模块必须在启动时校验: + - `FEISHU_APP_ID` + - `FEISHU_APP_SECRET` + - `FEISHU_CONNECTION_MODE=websocket` + - `TARGET_BASE_URL` +- 如缺少真实飞书凭据,本地业务服务仍可独立启动;长连接模块必须提供清晰、非敏感的配置缺失错误。 + +--- + +## 5. 统一启动入口 + +### 目标 + +用户执行一个命令后,业务服务和 Lark 长连接模块一起启动。 + +推荐外部接口: + +```powershell +npm run start:lark +``` + +如原项目已有合适的 production 启动命令,也可以采用: + +```powershell +npm start +``` + +但必须在 README 和 package scripts 中明确说明其会同时启动: + +```text +1. calendar 原业务服务 +2. integrations/lark 长连接 host +``` + +### 启动实现规则 + +- 不得把飞书 SDK wiring 写进 `server.js` 等核心业务文件; +- 可以在根 `package.json` 中加入并发启动脚本,或新增专门的 launcher; +- 若采用并发启动工具,必须明确依赖、退出行为和日志前缀; +- 任一子进程异常退出时,统一启动入口必须以非零退出并清理另一个子进程; +- 长连接模块启动前必须等待或重试目标 API 可达性; +- 启动日志必须清晰区分: + - `calendar service ready` + - `lark long connection starting` + - `lark long connection online` + - 配置缺失或目标不可达错误。 + +--- + +## 6. C2L 生成与迁入流程 + +### Phase 1:建立干净 replay 基线 + +1. 复制原始 cal 项目到 Mode B replay 目录; +2. 清理真实 `.env`、node_modules、日志、临时目录; +3. 使用 `.env.example` 初始化空白 `.env`; +4. 在未添加 Lark 模块前验证 replay 原业务服务可独立运行; +5. 记录原始项目与 replay 业务核心文件的基线差异。 + +验收: + +- replay 独立启动; +- replay 原有测试通过; +- 原项目保持无修改; +- replay 不含复制过来的真实凭据。 + +建议提交: + +```text +Create calendar Mode B replay baseline +``` + +### Phase 2:生成 generic long-connection adapter package + +对 replay 副本 fresh analyze: + +```powershell +node dist\index.js analyze ` + "C:\works\calendar-stock-updater-mode-b-replay" ` + --base-url http://127.0.0.1:3069 ` + --out out\calendar-stock-updater-mode-b-replay ` + --name calendar-stock-updater +``` + +生成 embedded long-connection package: + +```powershell +node dist\index.js generate ` + out\calendar-stock-updater-mode-b-replay ` + --out generated\calendar-stock-updater-mode-b-lark ` + --mode embedded-adapter ` + --host-mode embedded-long-connection +``` + +验收: + +```powershell +node dist\index.js verify ` + generated\calendar-stock-updater-mode-b-lark ` + --mode embedded-adapter ` + --host-mode embedded-long-connection ` + --strict +``` + +必须通过。 + +建议提交: + +```text +Generate calendar Mode B long-connection adapter package +``` + +### Phase 3:迁入生成产物并实现项目内 host module + +将以下生成物迁入 replay: + +```text +generated/calendar-stock-updater-mode-b-lark/adapter/ +generated/calendar-stock-updater-mode-b-lark/manifest/ +generated/calendar-stock-updater-mode-b-lark/docs/ +generated/calendar-stock-updater-mode-b-lark/sidecar-long-connection/ +``` + +迁入到: + +```text +calendar-stock-updater-mode-b-replay/integrations/lark/generated/ +``` + +然后新增项目内 host module,职责限定为: + +1. 读取 replay 根 `.env`; +2. 建立 Feishu SDK websocket 长连接; +3. 订阅 `card.action.trigger`; +4. 标准化事件为 generated adapter context: + - `action` + - `formValue` + - `operatorOpenId` + - `openMessageId` + - `openChatId` +5. 调用: + +```js +handleGenericHttpCardAction(context, { + targetBaseUrl, + timeoutMs, + allowedOperatorOpenIds, +}) +``` + +6. 使用 generated `buildStartCard()` 发送起始卡; +7. 将 adapter 返回的 card 作为飞书动作结果或后续消息更新内容; +8. 记录脱敏的结构化审计日志。 + +验收: + +- 原业务核心只保留自身 HTTP/API 职责; +- `integrations/lark/` 可独立被理解和移除; +- 模块不重写 generic endpoint/action 映射; +- 宿主 event flow 在本地 mock / contract 环境中可观察。 + +建议提交: + +```text +Embed calendar Lark long-connection host module +``` + +### Phase 4:统一启动和本地集成验证 + +新增统一启动命令后,用户只需: + +```powershell +Copy-Item .env.example .env +npm run start:lark +``` + +本地验证至少覆盖: + +```text +统一启动入口启动业务服务 +统一启动入口启动 Lark host +目标 GET /api/state 可被 host 访问 +sidecar/host contract 可调用 http.get.api.state.submit +reviewed http.post.api.run.submit 的失败或安全路径不会留下任务运行状态 +非法 action 返回 failure card +配置缺失时 Lark host 清晰失败且业务服务行为可预期 +停止统一启动入口时两个进程都退出 +``` + +验收命令按项目实际 scripts 固化,并至少包括: + +```powershell +npm run start:lark +node integrations\lark\generated\sidecar-long-connection\local-contract-test.mjs +``` + +以及 C2L package validation: + +```powershell +node "C:\works\Lark-deployer\dist\index.js" verify ` + integrations\lark\generated ` + --mode embedded-adapter ` + --host-mode embedded-long-connection ` + --strict +``` + +建议提交: + +```text +Add unified calendar and Lark host startup +``` + +### Phase 5:人工联调包准备 + +本阶段不做真实飞书联调,但必须让人工不需要猜测后续操作。 + +必须生成或更新: + +```text +calendar-stock-updater-mode-b-replay/integrations/lark/README.md +calendar-stock-updater-mode-b-replay/integrations/lark/.env.example +calendar-stock-updater-mode-b-replay/integrations/lark/generated/level2_verification_record.md +``` + +人工后续操作应固定为: + +```powershell +Copy-Item .env.example .env +# 填 FEISHU_APP_ID、FEISHU_APP_SECRET、TEST_CHAT_ID 等 +npm run start:lark +``` + +之后人工在飞书后台和测试群完成: + +```text +1. 启用机器人能力 +2. 配置长连接 +3. 订阅 card.action.trigger +4. 启动项目 +5. 发送起始卡 +6. 点击 GET /api/state +7. 点击经过批准的 POST /api/run +8. 填写脱敏 Level 2 证据 +``` + +建议提交: + +```text +Docs: prepare calendar Mode B long-connection handoff +``` + +--- + +## 7. 本任务不等同于真实飞书验收 + +本任务完成后,允许表述: + +> `calendar-stock-updater` 的 Mode B 项目内长连接模块、统一启动入口、配置模板、本地验证和人工联调材料已完成;用户填写 `.env` 后可以启动服务,等待真实飞书联调。 + +本任务完成后,不允许表述: + +> `calendar-stock-updater` 已完成真实飞书长连接验证。 + +真实飞书联调仍需要用户提供: + +```text +FEISHU_APP_ID +FEISHU_APP_SECRET +TEST_CHAT_ID +飞书应用权限 +card.action.trigger 长连接订阅 +``` + +这些不得提交到 Git、任务书或 shared handoff 包中。 + +--- + +## 8. 最终完成定义 + +以下全部成立,才算本 Mode B 改造任务完成: + +1. 原始 `C:\works\calendar-stock-updater` 未被修改; +2. 新 replay 副本位于 `C:\works\calendar-stock-updater-mode-b-replay`; +3. C2L generated package 使用: + +```text +embedded-adapter + embedded-long-connection +``` + +4. replay 内有独立 `integrations/lark/` host module; +5. 用户只需填项目 `.env`,无需自己写 SDK wiring 或 adapter 事件转换; +6. 一个统一启动命令能拉起原业务与 Lark host; +7. host 自动使用长连接订阅 `card.action.trigger`; +8. host 调用 generated `handleGenericHttpCardAction()` 和 `buildStartCard()`; +9. 本地 contract、配置失败、目标 read、reviewed action 安全路径和统一停止行为均经过验证; +10. 人工真实飞书联调 runbook 完整、无 secret,并清楚记录为后续工作。 + +--- + +# Kickoff:calendar-stock-updater Mode B 一键启动长连接改造 + +```markdown +# Kickoff:calendar-stock-updater Mode B 一键启动长连接改造 + +## 总目标 + +从原始项目创建一个全新的 Mode B replay 副本,把 C2L 的 generic adapter 和飞书长连接 host 作为独立模块迁入目标项目;用户填写一次 `.env` 后,通过一个统一启动命令即可拉起原业务服务和飞书 SDK 长连接模块。 + +## 固定目录 + +原始项目,只读: + +```text +C:\works\calendar-stock-updater +``` + +新实验对象: + +```text +C:\works\calendar-stock-updater-mode-b-replay +``` + +不要修改原始项目。不要从原始项目复制真实 `.env`、node_modules、日志或本地凭据。 + +## 固定模式 + +```text +Mode B ++ embedded-adapter ++ embedded-long-connection +``` + +飞书接入默认使用: + +```text +Feishu SDK websocket long connection +card.action.trigger +``` + +默认不使用 webhook,不要求: + +```text +PUBLIC_CALLBACK_BASE_URL +/webhook/card +VERIFICATION_TOKEN +``` + +## 用户最终体验 + +用户应只需要: + +```powershell +Copy-Item .env.example .env +# 填 FEISHU_APP_ID、FEISHU_APP_SECRET、TEST_CHAT_ID、TARGET_BASE_URL +npm run start:lark +``` + +然后系统自动: + +```text +启动 calendar 原业务服务 ++ 启动 integrations/lark 长连接 host ++ 建立 Feishu SDK websocket 长连接 ++ 订阅 card.action.trigger ++ 使用 generated buildStartCard() 发送起始卡 ++ 用 generated handleGenericHttpCardAction() 调 calendar API +``` + +## 结构要求 + +目标项目中必须新增独立模块,建议: + +```text +integrations/lark/ + app.js + config.js + host.js + generated/ + adapter/ + manifest/ + docs/ + sidecar-long-connection/ +``` + +飞书 SDK wiring 不能散落进 `server.js` 或其他业务核心文件。 + +## 必须执行的阶段 + +### Phase 1:建立干净 replay 基线 + +复制原始项目到: + +```text +C:\works\calendar-stock-updater-mode-b-replay +``` + +清理真实 `.env`、node_modules、日志、缓存;用 `.env.example` 初始化 replay 的空 `.env`。 + +先证明 replay 在没有 Lark 模块时能独立启动和通过原有测试。 + +### Phase 2:fresh 生成 C2L 长连接 package + +在 C2L 仓库执行: + +```powershell +node dist\index.js analyze ` + "C:\works\calendar-stock-updater-mode-b-replay" ` + --base-url http://127.0.0.1:3069 ` + --out out\calendar-stock-updater-mode-b-replay ` + --name calendar-stock-updater + +node dist\index.js generate ` + out\calendar-stock-updater-mode-b-replay ` + --out generated\calendar-stock-updater-mode-b-lark ` + --mode embedded-adapter ` + --host-mode embedded-long-connection +``` + +验证: + +```powershell +node dist\index.js verify ` + generated\calendar-stock-updater-mode-b-lark ` + --mode embedded-adapter ` + --host-mode embedded-long-connection ` + --strict +``` + +### Phase 3:迁入生成物,实现项目内长连接 host + +把 generated 的: + +```text +adapter/ +manifest/ +docs/ +sidecar-long-connection/ +``` + +迁入: + +```text +C:\works\calendar-stock-updater-mode-b-replay\integrations\lark\generated\ +``` + +新增独立 host 模块,职责只有: + +```text +读取根 .env +→ 建立 Feishu SDK websocket +→ 订阅 card.action.trigger +→ 标准化事件 +→ 调 handleGenericHttpCardAction() +→ 用 buildStartCard() 发卡 +→ 回传 adapter card +``` + +不得重写 adapter 的 endpoint/action 映射。 + +### Phase 4:实现统一启动入口 + +用户必须只启动一次: + +```powershell +npm run start:lark +``` + +该命令要同时启动: + +```text +calendar 原服务 +integrations/lark 长连接 host +``` + +任一子进程退出时,另一个也必须清理退出,整体以非零状态失败。 + +### Phase 5:本地验证与人工联调准备 + +至少验证: + +```text +- 统一启动同时拉起两个进程 +- 目标 GET /api/state 可达 +- host 能调用 generated adapter +- http.get.api.state.submit 合同路径成功 +- reviewed http.post.api.run.submit 安全路径不留任务运行 +- 非法 action 返回 failure card +- 配置缺失时错误清楚且不泄漏 secret +- 停止统一启动后两个进程都退出 +``` + +准备: + +```text +integrations/lark/README.md +.env.example +level2_verification_record.md +``` + +但不要在本 session 执行真实飞书发卡、点击或填写真实 Level 2 通过证据。 + +## 禁止事项 + +- 不修改原始 `C:\works\calendar-stock-updater` +- 不把飞书逻辑写进业务核心文件 +- 不添加 webhook fallback +- 不把真实 secret 提交 Git +- 不声称完成真实飞书验证 + +## 每阶段回报 + +每一阶段结束报告: + +1. 做了什么 +2. 修改文件 +3. 实际执行命令和结果 +4. 当前能否进入下一阶段 +5. blocker + +## 最终允许结论 + +只允许: + +> calendar-stock-updater 的 Mode B 项目内长连接模块、统一启动入口、本地验证和人工联调准备已完成,等待用户填写真实 `.env` 后进行飞书联调。 + +不允许: + +> calendar-stock-updater 已完成真实飞书长连接验证。 +``` + +这份任务书的核心区别是:**cal 的新 Mode B 成功标准不再是“生成 adapter”,而是“用户填一次 `.env`,启动一次,就获得可运行的飞书长连接服务”。** +```numerusformRGCTXDataന്റjson \ No newline at end of file From 31619d54620da0fa1c0d7fb8005f9f4215e2f3ba Mon Sep 17 00:00:00 2001 From: Newoahil <88387859+Newoahil@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:48:53 +0800 Subject: [PATCH 37/37] docs: add repository execution contract Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- AGENT.md | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 AGENT.md diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..c6bf1df --- /dev/null +++ b/AGENT.md @@ -0,0 +1,129 @@ +# Code2Lark (Lark-deployer) + +Code2Lark is a build-time generator. It analyzes an existing service and produces reviewable Feishu/Lark adapter packages. It builds and verifies integration artifacts; it does not own the target service lifecycle. + +## Architecture + +```text +analyze -> plan -> context -> generate -> verify -> doctor -> handoff + \-> install (Mode B only) +``` + +The CLI currently exposes 13 commands through `src/index.ts`: + +- `analyze`, `plan`, `generate`, `context` +- `configure`, `status`, `readiness`, `doctor` +- `evidence`, `handoff`, `init-local`, `install`, `verify` + +The project uses TypeScript strict mode, ES2022/NodeNext, and Node.js 24.16 or newer. + +## Delivery Modes + +- `embedded-adapter`: generated adapter for an existing Feishu SDK host. +- `self-hosted-runtime`: generated Python `feishu-host/` using the Feishu SDK long connection. +- `standalone-runtime`: optional reference Node.js host. + +Host receive modes are `embedded-long-connection`, `embedded-webhook`, `hybrid`, and `standalone-runtime`. Long connection uses `card.action.trigger`. + +- Mode A keeps the Feishu host outside the target project. +- Mode B installs an isolated host module under the target project, currently `integrations/lark/`. + +## Structural Analysis Backends + +`analyze` accepts `--backend auto|internal|codegraph`. + +- `internal`: use the built-in source scan. +- `auto`: try codegraph, then safely fall back to internal when codegraph is unavailable, uninitialized, stale, incomplete, or invalid. +- `codegraph`: require a user-maintained fresh index and fail clearly instead of silently falling back. + +Only these read-only codegraph commands are allowed: + +```text +codegraph status --json +codegraph query route --kind route --path --json +``` + +Code2Lark must never install codegraph or run codegraph `init`, `sync`, reindex, or index-rebuild operations. External results are normalized at the boundary; profile and downstream code must not depend on the external schema. + +## Interaction Profiles + +Target-specific mapping lives in `src/profiles/`: + +| Profile | Source | Role | +| --- | --- | --- | +| `image-agent-web` | `src/profiles/image-agent-web.ts` | Stable sample baseline | +| `calendar-stock-updater` | `src/profiles/calendar-stock-updater.ts` | Typed calendar task cards and handlers | +| `generic-http-api` | analyzer/generator fallback | Coarse generic HTTP mapping | + +Profiles own detection, capability mapping, Card JSON 2.0 builders, handlers, validation, and profile-specific strict checks. `handleCardAction` is the calendar adapter facade. + +Generated adapter TypeScript must pass `tsc --noEmit --strict`. Do not emit or add `@ts-nocheck`, `@ts-ignore`, `@ts-expect-error`, or `any` to bypass type errors. + +## Calendar Mode B Contract + +The current calendar correction contract is strict: + +- Schema remains `0.2`. +- The only target calls are `GET /api/state`, `POST /api/run`, and `POST /api/stop`. +- Run/stop prepare, confirm, and cancel are host-local card actions, not target HTTP endpoints. +- The browser-only `/api/events` stream is supporting context, not a card action endpoint. +- `generate` never writes the target project. +- `install` defaults to dry-run and must write nothing. +- `install --apply` may write only `integrations/lark/**`. +- Do not modify target root `package.json`, startup scripts, Docker files, business code, or Web UI. +- Treat the original target as read-only. Use a disposable replay/copy for installation verification. +- The install gate probes `GET /api/state`, validates source hashes and managed-file conflicts, and rejects symlink/junction paths before writes. +- The installed module owns its `.env`, dependencies, tests, startup, confirmation state, idempotency, authorization, and audit output. + +The generated package remains the source of truth. Do not replace generated directories manually; use the `install` command. + +```powershell +node dist/index.js generate --out --mode embedded-adapter --host-mode embedded-long-connection +node dist/index.js verify --mode embedded-adapter --host-mode embedded-long-connection --strict +node dist/index.js install --target --target-base-url http://127.0.0.1:3069 +node dist/index.js install --target --target-base-url http://127.0.0.1:3069 --apply +``` + +After apply, run `npm install`, `npm test`, and `npm start` inside `/integrations/lark`. Run the calendar target service separately. + +## Generated Package + +```text +generated/-lark/ + manifest/ + adapter/ + docs/ + integrations/lark/ # calendar Mode B installable closure + sidecar-long-connection/ # host contract/local check when applicable + bot-runtime/ # optional standalone mode only + feishu-host/ # optional self-hosted mode only +``` + +Card payloads use JSON 2.0 (`schema: "2.0"`, `body.elements`, callback behaviors). Manifest schema `0.2` is mandatory. Infrastructure credentials belong in local `.env` files; business parameters belong in cards. + +## Governing Documents + +- `docs/codegraph-structural-backend-correction-task-book.md`: structural backend and final replay contract. +- `docs/calendar-stock-updater-mode-b-correction-task-book.md`: current calendar Mode B correction contract. +- `docs/capability-validation-matrix.md`: validation fact matrix. +- `docs/project-status.md`: current project status and evidence summary. +- `docs/calendar-stock-updater-mode-b-long-connection-task-book.md`: superseded historical plan; do not use it as the active contract. + +When documents conflict, follow the two current correction taskbooks above. + +## Build And Test + +```powershell +npm run build +npm run test:unit +npm run test:smoke +npm run test:mode-b +npm run test:e2e +npm test +``` + +For changed files, run LSP diagnostics, focused tests, then the full suite. Strict verify, handoff check, module tests, dependency audits, managed-file hashes, root-integrity hashes, and negative install gates are required before closure. + +## Real Feishu Boundary + +Local generation, strict verification, replay installation, and module tests do not complete real Feishu Level 2. Level 2 remains incomplete until valid `FEISHU_APP_ID`, `FEISHU_APP_SECRET`, `TEST_CHAT_ID`, and `ALLOWED_OPERATOR_OPEN_IDS` are supplied through a secure channel; the long connection is online; real cards are sent and clicked; and sanitized message IDs, screenshots, logs, trace IDs, and verifier sign-off are recorded in `level2_verification_record.md`.