From 92d8dfdce23628452fb76eee1b4abc296fd278e3 Mon Sep 17 00:00:00 2001 From: Fioooooooo Date: Tue, 19 May 2026 18:11:53 +0800 Subject: [PATCH 01/19] chore: Bump version from 0.9.0-beta.1 to 0.9.0-beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 927aa13b..a3b44360 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "FylloCode", - "version": "0.9.0-beta.1", + "version": "0.9.0-beta.2", "description": "An Electron application with Vue and TypeScript", "main": "./out/main/index.js", "author": "Fio", From 2b4bd2fe611d05df8cfe28b7fd01ebcb45193809 Mon Sep 17 00:00:00 2001 From: Fio Date: Tue, 19 May 2026 18:13:46 +0800 Subject: [PATCH 02/19] chore(openspec): multi linked worktree openspec proposal --- .../.openspec.yaml | 3 + .../design.md | 237 +++++++++++++++++ .../proposal.md | 63 +++++ .../specs/proposal-archive-action/spec.md | 111 ++++++++ .../specs/system-reminder-injection/spec.md | 51 ++++ .../tasks.md | 80 ++++++ .../.openspec.yaml | 3 + .../add-chat-worktree-orchestration/design.md | 249 ++++++++++++++++++ .../proposal.md | 61 +++++ .../specs/system-reminder-injection/spec.md | 113 ++++++++ .../add-chat-worktree-orchestration/tasks.md | 65 +++++ .../.openspec.yaml | 3 + .../add-multi-worktree-foundation/design.md | 183 +++++++++++++ .../add-multi-worktree-foundation/proposal.md | 54 ++++ .../specs/fyllo-specs-mcp/spec.md | 215 +++++++++++++++ .../specs/proposal-apply-run/spec.md | 235 +++++++++++++++++ .../add-multi-worktree-foundation/tasks.md | 69 +++++ .../.openspec.yaml | 3 + .../add-proposal-list-worktree-scan/design.md | 193 ++++++++++++++ .../proposal.md | 51 ++++ .../specs/proposal-list/spec.md | 125 +++++++++ .../add-proposal-list-worktree-scan/tasks.md | 74 ++++++ 22 files changed, 2241 insertions(+) create mode 100644 openspec/changes/add-archive-worktree-orchestration/.openspec.yaml create mode 100644 openspec/changes/add-archive-worktree-orchestration/design.md create mode 100644 openspec/changes/add-archive-worktree-orchestration/proposal.md create mode 100644 openspec/changes/add-archive-worktree-orchestration/specs/proposal-archive-action/spec.md create mode 100644 openspec/changes/add-archive-worktree-orchestration/specs/system-reminder-injection/spec.md create mode 100644 openspec/changes/add-archive-worktree-orchestration/tasks.md create mode 100644 openspec/changes/add-chat-worktree-orchestration/.openspec.yaml create mode 100644 openspec/changes/add-chat-worktree-orchestration/design.md create mode 100644 openspec/changes/add-chat-worktree-orchestration/proposal.md create mode 100644 openspec/changes/add-chat-worktree-orchestration/specs/system-reminder-injection/spec.md create mode 100644 openspec/changes/add-chat-worktree-orchestration/tasks.md create mode 100644 openspec/changes/add-multi-worktree-foundation/.openspec.yaml create mode 100644 openspec/changes/add-multi-worktree-foundation/design.md create mode 100644 openspec/changes/add-multi-worktree-foundation/proposal.md create mode 100644 openspec/changes/add-multi-worktree-foundation/specs/fyllo-specs-mcp/spec.md create mode 100644 openspec/changes/add-multi-worktree-foundation/specs/proposal-apply-run/spec.md create mode 100644 openspec/changes/add-multi-worktree-foundation/tasks.md create mode 100644 openspec/changes/add-proposal-list-worktree-scan/.openspec.yaml create mode 100644 openspec/changes/add-proposal-list-worktree-scan/design.md create mode 100644 openspec/changes/add-proposal-list-worktree-scan/proposal.md create mode 100644 openspec/changes/add-proposal-list-worktree-scan/specs/proposal-list/spec.md create mode 100644 openspec/changes/add-proposal-list-worktree-scan/tasks.md diff --git a/openspec/changes/add-archive-worktree-orchestration/.openspec.yaml b/openspec/changes/add-archive-worktree-orchestration/.openspec.yaml new file mode 100644 index 00000000..65b9a4e8 --- /dev/null +++ b/openspec/changes/add-archive-worktree-orchestration/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-05-19T00:00:00.000Z +status: draft diff --git a/openspec/changes/add-archive-worktree-orchestration/design.md b/openspec/changes/add-archive-worktree-orchestration/design.md new file mode 100644 index 00000000..7fa627a3 --- /dev/null +++ b/openspec/changes/add-archive-worktree-orchestration/design.md @@ -0,0 +1,237 @@ +## Context + +P1(`add-multi-worktree-foundation`):主进程数据契约 + cwd fallback + MCP `targetPath` 必填。 +P2(`add-chat-worktree-orchestration`):chat 阶段编排 worktree add + 维护 .gitignore;OpenSpec change scaffold 落到 worktree 内。 +P3(`add-proposal-list-worktree-scan`):list 双源扫描 + ProposalMeta.worktreePath 写入 + 卡片标记;apply 触发后 ApplyRunMeta.worktreePath 自然写入;apply / archive ACP cwd 落到 worktree。 + +到 P3 结束时: + +- chat 在 worktree 创建 OpenSpec → list 显示 → apply 在 worktree 上跑业务代码。 +- archive 阶段 ACP cwd 也已是 worktree 绝对路径(来自 P1 的 `runMeta.worktreePath ?? projectPath`)。 +- archive system-reminder(`archive.txt`)当前已经规定了"sync 主 spec → archive → commit"三步顺序,但**没有说**怎么把 worktree 内的归档 commit 合回 main、也没说要清理 worktree。 + +结果是磁盘上残留越来越多的 `/.worktrees//` 目录与 `proposal/` 分支;P3 list 双源扫描虽然能识别这些 worktree,但它们的语义其实已经是"archived 但未清理"。 + +P4 通过修改 `archive.txt` system-reminder 与 `stage-prompts.ts` 的 archive prompt,让 agent 在 archive 阶段完成完整的 5 步收尾——**不引入任何主进程 git 调用、不改 MCP、不改 IPC 契约**,所有 git 命令都由 agent 用 Bash 执行。 + +**关键事实** + +- archive ACP session cwd 在 P1 后是 `runMeta.worktreePath ?? projectPath`(`proposal-apply.ts:330` 由 P1 task 5.3 改为该取值)。 +- `archive.txt` 现有正文已规定 commit 必须用 `type(scope): summary`,并要求"commit only files related to this change"。 +- `stage-prompts.ts:38` 当前 `proposal-archive` runner 是 `归档 ${changeId} 并提交代码`,与 spec 文档(`proposal-archive-action/spec.md` 描述的"加载 skill fyllo-archive-change...")不一致——spec drift。**P4 同时把 prompt 与 spec 对齐**,统一为最简 `归档 ${changeId}`,把"sync / commit / merge / worktree-cleanup"具体编排放到 `archive.txt` 与 `archive-change` tool_instruction 里。 +- 当前 archive 阶段 reminderContext(`proposal-apply.ts:329`)已含 `changeId`、`runId`,P1 添加的 `worktreePath` 字段也已透传。 +- P2 已让 `archive.txt` 模板支持 `{{worktreePath}}`、`{{mainProjectPath}}` 占位符(白名单变量已扩展)。 +- 非 git 项目 / 旧 ApplyRunMeta:`runMeta.worktreePath` 为 undefined,`archive.txt` 渲染时 `{{worktreePath}}` 为空字符串,模板需要在内文显式说明此情况下跳过 git 编排。 + +## Goals / Non-Goals + +**Goals** + +- archive 阶段的 agent 在 system-reminder 引导下,完成完整 5 步收尾:sync(已存在)→ archive-change(已存在)→ commit(已存在)→ merge --ff-only 进 main → worktree remove + branch -d。 +- archive 完成后 main 当前分支多一次 fast-forward merge commit 含归档移动;worktree 目录与 `proposal/` 分支被清理。 +- merge / remove / branch -d 任一步失败时 agent 把 stderr 复述给用户,**不**自动重试,不 force。 +- 非 git 项目(worktreePath 为空)archive 行为完全等价于 P3 之前;reminder 文本明确这一降级路径。 +- archive prompt 文案与 spec 对齐到最简 `归档 ${changeId}`;spec 文档与代码不再 drift。 + +**Non-Goals** + +- 不引入任何主进程 git 调用、不改 MCP `archive-change` 实现(仍仅做 OpenSpec 文件移动)。 +- 不引入"清理孤儿 worktree"UI 入口;用户用原生 git 命令处理失败兜底。 +- 不实现 archive 失败时的"幂等续跑"——agent 收到 stderr 即把状态交还用户。 +- 不把 commit message 拆出参数化模板:commit message 由 agent 在 chat-aware 上下文中生成,archive.txt 规定格式但不规定 message 文本。 +- 不修改 chat 阶段(P2)/ apply 阶段(P3)的任何编排。 + +## Decisions + +### D1:4 步 git 收尾全部由 agent 用 Bash 执行 + +**选择**:archive system-reminder 内文写明 4 步命令模板(commit → merge → worktree remove → branch -d),agent 用 Bash 工具逐条执行。MCP `archive-change` 仍只做 OpenSpec 文件移动,不引入任何 git 子进程。 + +**理由**: + +- 与 P2 的 worktree add 编排一致:MCP 不 owning git workflow,agent 用 Bash 是 git 操作的统一执行者。 +- merge 冲突 / remove 失败 / branch -d 失败的 fallback 都需要 agent 与用户对话决策,把 git 操作放在 MCP 内部反而切断了 agent 的接管路径。 +- 用户的 hooks / signing config 通过 agent 的 Bash 自然继承。 + +**否决**: + +- archive-change tool 内置 4 步 git 收尾——P1 设计阶段已明确否决(MCP = 文件级原子操作;多步带判断的工作流交给 agent)。 +- 主进程在 archive done 之后调 git——FylloCode 主进程当前完全不调 git,不在本次扩展范围内。 + +### D2:merge --ff-only,失败由 agent 接手 + +**选择**:reminder 文本硬规定 `git -C {{mainProjectPath}} merge --ff-only proposal/{{changeId}}`。失败时 agent 把 stderr 完整复述给用户,不自动 fall back 到普通 merge / rebase。 + +**理由**: + +- `--ff-only` 是最干净的常规路径——main 历史保持线性,不引入额外 merge commit。 +- 失败的最常见原因是 main 在 worktree 创建后被推进(团队协作场景)。这种情况要么 rebase 要么普通 merge,决策权应在用户而非 agent。 +- 不自动重试避免破坏用户工作树(pull --rebase 可能踩到本地未 commit 改动等边界)。 + +#### 选项对比 + +| merge 策略 | 优点 | 痛点 | +| --------------------- | --------------------------- | -------------------------------------------------------------------------------------------- | +| `--ff-only`(本设计) | main 历史线性;失败明确暴露 | 失败时需用户介入 | +| 默认 merge | 永远成功 | main 历史多 merge commit;archived 工作流的"工具元变更"不应制造 merge commit | +| `--squash` | 单 commit | 丢失 worktree 内中间 commit;多次 commit 就有用的工作流(apply 阶段多个业务 commit)会被压扁 | + +### D3:worktree remove 与 branch delete 顺序与失败处置 + +**选择**:`worktree remove ` 在前,`branch -d proposal/` 在后。两步都用普通命令(不用 `--force` / `-D`)。任一失败把 stderr 复述给用户。 + +**理由**: + +- worktree remove 失败的典型原因:编辑器锁文件、worktree 内有未 commit 改动(agent 应在 commit 步骤就处理掉,但万一没干净)。`--force` 会硬删导致用户看不到自己未保存的工作;reminder 让 agent 复述 stderr,用户手动 close 编辑器或 `--force` 决策更安全。 +- branch -d 失败原因:分支未完全合并(理论上 merge --ff-only 成功后不会发生;但若用户手动 reset main 后重跑,分支会"超前于 main")。`-D` 强删失去这个保护,让 agent 复述 stderr 即可。 + +### D4:archive prompt 精简,编排移到 system-reminder + tool_instruction + +**选择**:`stage-prompts.ts` 中 `proposal-archive` 改为 `归档 ${changeId}`。所有"sync / archive-change / commit / merge / worktree-cleanup"具体步骤都不再写在 prompt 里,全部交给: + +- **archive.txt system-reminder**:详细写整个 5 步顺序、命令模板、失败处置、commit message 格式(已存在的 `` `` 内容继续保留)。 +- **archive-change tool_instruction**:MCP 工具自身的"sync 主 spec → archive 文件移动 → 报告状态"约束。 + +**理由**: + +- 多处重复表述同一个工作流必然 drift。当前 spec 写的"加载 skill fyllo-archive-change..."与 stage-prompts 的 `归档 ${changeId} 并提交代码` 已经 drift 了,本次顺势对齐。 +- prompt 是用户消息,每次 archive 启动都会写入 archive.messages.jsonl,文本越短越便于人类回读。 +- system-reminder 是注入式 prompt,不会污染消息历史,承载长流程编排是它的职责。 + +### D5:非 git 项目 / 空 worktreePath 的降级写在 reminder 内文 + +**选择**:`archive.txt` 的 `` 段开头明确:"如果 `{{worktreePath}}` 为空,跳过本段全部 git 编排,仅按现有 `` 完成 archive-change + commit 即可"。 + +**理由**: + +- 现有 archive.txt 的 commit 步骤本来就在 worktreePath 不存在时也工作(cwd 是主仓库,commit 落到主仓库当前分支,与 P3 之前行为等价)。 +- agent 通过文本判断 `{{worktreePath}}` 是否为空,比通过条件分支模板(chat / apply 都没有此先例)更简单;模板渲染保持单一路径。 + +### D6:archive.txt 与 archive prompt 不引用 worktreePath 之外的 ApplyRunMeta 字段 + +**选择**:archive.txt `` 段只引用 `{{worktreePath}}`、`{{mainProjectPath}}`、`{{changeId}}` 三个占位符。`branchName` 不作为占位符——直接在 reminder 文本中以 `proposal/{{changeId}}` 字面量出现,因为 P2 已硬规定分支命名 `proposal/` 与 ` === `。 + +**理由**:变量越少,模板渲染越简单;分支名是命名约定的派生量,不是独立信息。 + +## Architecture + +### 5 步收尾时序 + +```mermaid +sequenceDiagram + participant Agent + participant MCP as archive-change MCP + participant WT as worktree
cwd + participant Main as main repo + + Agent->>WT: ① archive-change preview + WT-->>Agent: incompleteTasks / deltaSpecSummary + Agent->>WT: ② archive-change confirm:true + WT->>WT: openspec archive (文件移动) + WT-->>Agent: archiveRawOutput + Agent->>WT: ③ git add -A && git commit -m "..." + Agent->>Main: ④ git -C main merge --ff-only proposal/ + Main-->>Agent: stderr (若失败) + Agent->>Main: ⑤ git -C main worktree remove + Agent->>Main: ⑥ git -C main branch -d proposal/ + Main-->>Agent: stderr (若失败) +``` + +② 与 ③ 之间已被现有 archive.txt 的 `` 段约束(`sync → archive → commit`,commit message 格式 `type(scope): summary`)。④ ⑤ ⑥ 是 P4 新增的内文。 + +### archive.txt `` 段(设计文本) + +放在 `` 之后、`` 之前: + +``` + +本 archive run 的工作目录(cwd)是 `{{worktreePath}}`。 + +如果 `{{worktreePath}}` 为空字符串(典型场景:非 git 项目,或 ApplyRunMeta 是 P3 启用前创建),跳过本段全部 git 编排,仅按 `` 完成 archive-change 文件移动 + 业务代码 commit 即可。本 archive 不需要 merge / worktree remove / branch delete。 + +如果 `{{worktreePath}}` 非空,archive-change 完成 OpenSpec 文件移动后,按以下顺序完成 4 步 git 收尾: + +1. **commit OpenSpec 归档移动**(含归档目录新增 + 主 spec 同步): + `git -C {{worktreePath}} add -A && git -C {{worktreePath}} commit -m ""` + commit message 仍按 `` 与 `` 段已规定的 `type(scope): summary` 模板。`scope` 用 `openspec`,`summary` 简明描述本次归档(例 `archive {{changeId}}`);下方可选 bullet 列表概括 sync / archive 关键动作。 + +2. **fast-forward merge 进 main**: + `git -C {{mainProjectPath}} merge --ff-only proposal/{{changeId}}` + 失败(典型:"Not possible to fast-forward")时把 stderr 完整复述给用户,请用户决定 rebase 或普通 merge;不要自行 fall back,不要 force push。 + +3. **移除 worktree**: + `git -C {{mainProjectPath}} worktree remove {{worktreePath}}` + 失败(典型:编辑器锁定文件)时把 stderr 复述用户,让用户关闭编辑器后由用户自行 `git -C {{mainProjectPath}} worktree remove --force {{worktreePath}}`;不要自行加 `--force`。 + +4. **删除 worktree 分支**: + `git -C {{mainProjectPath}} branch -d proposal/{{changeId}}` + 失败(典型:分支未完全合并)时把 stderr 复述用户;不要自行 `-D` 强删。 + +任一步失败时,archive ACP session 不终止——agent 仍可在后续轮次中根据用户的进一步指示重试或调整。但**不**自动重试。 + +``` + +### archive.txt `` 段扩展 + +原 `` 中 `MUST follow the order: sync → archive → commit. No reordering, no skipping.` 改为: + +``` +- **MUST follow the order: sync → archive → commit → merge → worktree-cleanup.** No reordering, no skipping. Steps 4–5 only when `{{worktreePath}}` is non-empty. +- **MUST run merge as `git merge --ff-only`** and stop on failure (no force, no auto普通 merge fallback). +- **MUST clean up worktree only after merge succeeds.** worktree remove without successful merge would lose the archive commit. +- **MUST NOT use `worktree remove --force` / `branch -D`.** Failure stderr goes to the user; the user decides force. +``` + +其余 `` 既有 SHALL(commit subject 格式、commit only change-related files、archive-change 不能 bypass)保留。 + +### `stage-prompts.ts` 修改 + +```ts +// 当前 +"proposal-archive": ({ changeId }) => `归档 ${changeId} 并提交代码`, + +// P4 修改后 +"proposal-archive": ({ changeId }) => `归档 ${changeId}`, +``` + +archive prompt 不再编排"提交代码"——commit / merge / cleanup 都由 archive.txt 与 archive-change tool_instruction 共同保障。 + +## Risks / Trade-offs + +- **agent 编排 4 步 git 序列时漂移**(中高):reminder 是自然语言指令,agent 可能跳步、漏 `-C` 选项、把 worktreePath 与 mainProjectPath 混淆。 + - 缓解:reminder 文本写明每条命令的 cwd(用 `git -C` 而非 `cd`);`` 段加 SHALL 强制顺序;上线后在 FylloCode 自身仓库 dogfood 1-2 周。 + +- **merge --ff-only 失败的 UX**(中):用户首次遇到时可能困惑——为什么 archive 突然中断? + - 缓解:reminder 让 agent 把 stderr 完整复述并解释"main 在 worktree 创建后被推进,请决定 rebase 或普通 merge"。 + +- **worktree remove 在 IDE 打开时失败**(低):FylloCode 自身就是基于 worktree 文件展开 OpenSpec change 工作;archive 触发时 IDE 仍可能打开 worktree 内的某个文件。 + - 缓解:reminder 让 agent 复述 stderr 并提示用户关闭编辑器;用户用 `--force` 自行解决。 + +- **空 worktreePath 旁路被 agent 忽略**(低):agent 可能不读 reminder 第 1 段判断条件,仍尝试 `git merge --ff-only proposal/`(worktree 不存在 → 分支不存在 → merge 失败)。 + - 缓解:reminder 第 1 段把降级条件写在最显眼位置;P4 dogfood 时验证 agent 行为;空 worktree 场景的失败也只是 stderr 复述用户,不会破坏数据。 + +- **stage-prompts 修改让现有测试断言失败**(低):`__tests__/ipc/proposal-apply.spec.ts:79` 中 mock `buildArchiveStage`,需要确认 prompt 文案断言(如有)是否需要同步更新。 + - 缓解:tasks 显式列出测试更新步骤;变更小、回归风险可控。 + +- **spec 与文档历史一致性**(低):本次 spec 修改和现有 `proposal-archive-action/spec.md` 中"加载 skill fyllo-archive-change..."的旧描述对齐到代码事实;OpenSpec archive 后 spec 历史会保留这次变化轨迹。 + +## Migration Plan + +1. 修改 `electron/main/services/chat/system-reminder/templates/archive.txt`: + - 在 `` 后、`` 前插入 `` 段(设计文本)。 + - `` 段扩展 4 条新 SHALL(merge order / ff-only / cleanup-after-merge / no-force)。 +2. 修改 `electron/main/services/proposal/stage-prompts.ts`:`proposal-archive` runner 改为 `归档 ${changeId}`。 +3. 同步更新 `electron/main/__tests__/ipc/proposal-apply.spec.ts` 中如果有 archive prompt 文案断言,改为新文案。 +4. 在 `system-reminder/__tests__/`(P2 已建)追加用例: + - archive owner reminder 含 `` 段; + - `{{worktreePath}}` 非空时段内出现该字面量; + - `{{worktreePath}}` 为空时段内仍能渲染(占位符变空字符串)。 +5. dogfood:在 FylloCode 自身仓库走完整 chat → apply → archive 流程,验证 archive 完成后 main 多一次 ff-merge commit、`/.worktrees//` 不存在、`proposal/` 分支不存在。 +6. dogfood:跑一次旧 ApplyRunMeta(worktreePath 为空)的 archive,确认行为与改造前等价。 +7. dogfood:故意制造 main 推进场景(在 worktree 创建后给 main 加一个不相关 commit),触发 archive 时验证 agent 收到 merge --ff-only 的 stderr 并复述给用户、不强行 push。 + +**回滚**:把 archive.txt 的 `` 段删除、`` 段还原;`stage-prompts.ts` 的 `proposal-archive` 行还原为 `归档 ${changeId} 并提交代码`。spec 在 OpenSpec 中走反向 delta change 还原。 + +## Open Questions + +无。 diff --git a/openspec/changes/add-archive-worktree-orchestration/proposal.md b/openspec/changes/add-archive-worktree-orchestration/proposal.md new file mode 100644 index 00000000..7a1d7862 --- /dev/null +++ b/openspec/changes/add-archive-worktree-orchestration/proposal.md @@ -0,0 +1,63 @@ +## Why + +P2 让 chat 阶段在 `/.worktrees//` 下创建 worktree 与 OpenSpec change;P3 让 list 能扫到 worktree 来源的 change,apply 阶段 ACP cwd 自然落到 worktree。但 archive 阶段的清理路径(commit OpenSpec 归档移动、`merge --ff-only` 进 main、`worktree remove`、`branch -d`)目前没有任何编排——archive ACP session 虽然 cwd 已是 worktree(P1 通路),但当前 `archive.txt` system-reminder 与 stage-prompts 都没说怎么把 worktree 干净地合回 main 仓库。结果是:archive 完成后 worktree 长期残留在磁盘上,main 仓库看不到归档 commit,多 worktree 隔离反而留下更多孤儿目录。 + +P4 让 archive 阶段的 agent 在 `archive.txt` system-reminder 编排下完成 4 步 git 收尾,把 OpenSpec 归档结果 fast-forward 进 main,再清理 worktree——multi-worktree 工作流闭环。 + +## What Changes + +- **MODIFIED `archive.txt` system-reminder 模板**:在现有 `` / `` 段基础上新增 `` 段(位于 `` 之后、`` 之前)。段内明确 4 步收尾顺序与命令模板: + 1. 调 `mcp__fyllo_specs__archive-change` `confirm:true`,让 OpenSpec 归档移动落到 worktree 内的 `archive/-/`(MCP 仅做文件移动,不碰 git)。 + 2. agent 自己用 Bash 执行 `git -C {{worktreePath}} add . && git -C {{worktreePath}} commit -m ""`;commit message 按现有 `archive.txt` `` 段已规定的 `type(scope): summary` 模板(如 `chore(openspec): archive `),可结合 chat 上下文细化。 + 3. `git -C {{mainProjectPath}} merge --ff-only proposal/{{changeId}}`;非 fast-forward 失败时把 stderr 完整复述给用户,让用户决定 rebase 或普通 merge。 + 4. `git -C {{mainProjectPath}} worktree remove {{worktreePath}} && git -C {{mainProjectPath}} branch -d proposal/{{changeId}}`;remove 失败(典型:编辑器锁定文件)或 branch -d 失败(典型:未完全合并)时把 stderr 复述给用户。 +- **MODIFIED archive.txt `` 段**:在已有的 `MUST follow the order: sync → archive → commit` 基础上扩展为 `sync → archive → commit → merge → worktree-cleanup`;新增 `MUST run merge as --ff-only and stop on failure (no force, no普通 merge auto-fallback)`、`MUST clean up worktree only after merge succeeds`。 +- **MODIFIED archive prompt(`stage-prompts.ts` 中 `proposal-archive` runner)**:从 `归档 {changeId} 并提交代码` 精简为 `归档 {changeId}`。具体的 sync / archive / commit / merge / worktree-cleanup 编排全部交给 `archive.txt` system-reminder 与 `mcp__fyllo_specs__archive-change` 的 `tool_instruction`;prompt 不再重复编排(不重复表述就不会出现表述漂移)。 +- **MODIFIED `proposal-archive-action` spec**:删去原 spec 中"`archive prompt` 携带 sync 主 spec / commit message 模仿最近记录"那段具体文案要求;改为"prompt 仅指明归档目标,sync / commit / merge / worktree 收尾由 archive system-reminder 与 archive-change tool 共同保障"。新增 SHALL:archive 完成后 worktree 须被移除、`proposal/{{changeId}}` 分支须被删除;如 merge / remove 任一步失败,archive ACP session 须把 stderr 透传用户,**不**自动重试。 +- **MODIFIED `system-reminder-injection` spec**(轻微):`archive.txt` 模板正文 SHALL 含 `` 段,引用 `{{worktreePath}}`、`{{mainProjectPath}}`、`{{changeId}}` 占位符(`changeId` 在 archive 阶段对应 archive 操作的目标 change,与 reminderContext.changeId 一致)。 +- 不修改 MCP 工具实现:`archive-change` 的 confirm 阶段仍只做 OpenSpec 文件移动;不引入 git 子进程。 +- 不修改 `proposal-apply.ts` 的 archive handler:cwd / reminderContext / agentId 由 P1 已就绪的通路提供;只改 `stage-prompts.ts` 的一行 prompt。 +- 非 git 项目(`/.git` 不存在或 ApplyRunMeta.worktreePath 为 undefined):archive.txt 显式说明此情况下跳过 git 编排(merge / worktree remove / branch delete 全部 skip),仅做 archive-change + 现有 commit 步骤;这与 P3 之前的 archive 行为完全等价。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `proposal-archive-action`:archive prompt 精简;archive 完成后 worktree / branch 须被清理;merge --ff-only 失败由 agent 接手。 +- `system-reminder-injection`:`archive.txt` 模板正文新增 `` 段,引用 worktreePath / mainProjectPath / changeId 占位符。 + +## Impact + +**代码** + +- `electron/main/services/chat/system-reminder/templates/archive.txt`:新增 `` 段、`` 段扩展。 +- `electron/main/services/proposal/stage-prompts.ts`:`proposal-archive` runner 文案精简。 +- `electron/main/__tests__/ipc/proposal-apply.spec.ts`:archive prompt 文案断言同步更新(如有)。 +- `electron/main/services/chat/system-reminder/__tests__/`:archive 模板渲染单测覆盖 `worktreePath` 非空 / undefined 两种渲染。 + +**用户可见变化** + +- archive 完成后,worktree 目录被自动删除;main 仓库当前分支多一次 fast-forward merge commit,含归档移动。 +- 非 git 项目 / 旧 ApplyRunMeta(worktreePath undefined)archive 行为与改造前等价。 +- merge 冲突或 worktree 文件被占用时,agent 会把 stderr 复述给用户,archive ACP session 进入"等待用户处理"状态——不自动重试、不强行 push 通过。 + +**依赖** + +无新增依赖。所有 git 命令由 agent 用现有 Bash 工具执行。 + +**风险** + +- agent 编排 4 步 git 序列时漂移(中高):可能跳步、合并 commit、用错 cwd(worktreePath vs mainProjectPath)。缓解:archive.txt 文本写明每步命令模板与 cwd;上线后 dogfood 1-2 周。 +- merge --ff-only 失败的 fallback 不自动恢复(中):用户需手动决策 rebase / 普通 merge。缓解:reminder 文本明确把 stderr 复述给用户作为唯一兜底,不让 agent 自行重试。 +- worktree remove 失败导致后续 list 重影(低):用户编辑器仍打开 worktree 内文件时 `git worktree remove` 会失败。缓解:reminder 让 agent 复述 stderr 并提示用户关闭编辑器;用户手动执行 `git worktree remove --force` 或 P3 列表里看到孤儿条目自行处理。 +- 旧 ApplyRunMeta(worktreePath 为空)的 archive 流误触发 git 编排(低):reminder 文本第 1 步写明"`{{worktreePath}}` 为空时跳过 4 步收尾,沿用 archive-change + commit 经典流程"。 + +**回滚** + +- 把 `archive.txt` 中的 `` 段删除、`` 段恢复为原 5 条 SHALL;`stage-prompts.ts` 的 `proposal-archive` 行恢复为 `归档 ${changeId} 并提交代码`。 +- spec 在 OpenSpec 中走反向 delta change(unarchive 的等价),实际工程操作由人工还原。 +- worktreePath 字段、ACP cwd fallback、archive-change tool 行为均不变(P1/P3 的能力保留),回滚仅退化 archive 阶段的清理行为。 diff --git a/openspec/changes/add-archive-worktree-orchestration/specs/proposal-archive-action/spec.md b/openspec/changes/add-archive-worktree-orchestration/specs/proposal-archive-action/spec.md new file mode 100644 index 00000000..e6a421ef --- /dev/null +++ b/openspec/changes/add-archive-worktree-orchestration/specs/proposal-archive-action/spec.md @@ -0,0 +1,111 @@ +## MODIFIED Requirements + +### Requirement: Archive action resumes the completed apply session + +系统 SHALL 在触发归档时,复用已完成 apply stage 的 ACP session id,并使用 `proposal-archive` stage type 构造归档 prompt。归档 prompt SHALL 仅指明归档目标,不在 prompt 文本中重复编排具体步骤: + +`归档 {changeId}` + +具体的 sync 主 spec / archive-change 文件移动 / git commit / merge 进 main / worktree cleanup 等操作 SHALL 由 archive system-reminder 与 `archive-change` MCP tool 的 `tool_instruction` 共同保障。归档 prompt SHALL NOT 出现 "提交代码" / "merge" / "worktree" / "commit" 等编排关键词。 + +归档流程 SHALL 使用与 stage stream 相同的 MessagePort 流式传输方式。归档 ACP session 的 cwd SHALL 为 `runMeta.worktreePath ?? projectPath`(P1 通路)。 + +#### Scenario: Archive starts successfully + +- **WHEN** 用户触发归档且存在已完成的 apply run +- **THEN** main process 恢复最后一个 completed apply stage 的 ACP session +- **AND** 发送的 archive prompt 文本严格等于 `归档 {changeId}` +- **AND** prompt 文本不含 `提交代码` / `merge` / `worktree` / `commit` 字符串 +- **AND** ACP session cwd 等于 `runMeta.worktreePath ?? projectPath` +- **AND** renderer 收到 chunk、done 和 error 事件 + +#### Scenario: No completed apply run + +- **WHEN** 用户触发归档但没有可复用的 completed apply run +- **THEN** 系统返回错误(沿用现有 `APPLY_RUN_NOT_READY` 错误语义) + +## ADDED Requirements + +### Requirement: Archive completes 4-step git cleanup when worktreePath is non-empty + +archive 阶段完成 OpenSpec 文件归档移动与归档 commit 后,当 `runMeta.worktreePath` 非空时,agent SHALL 在 archive system-reminder 编排下用 Bash 执行以下 4 步 git 收尾: + +1. `git -C {{mainProjectPath}} merge --ff-only proposal/{{changeId}}` +2. `git -C {{mainProjectPath}} worktree remove {{worktreePath}}` +3. `git -C {{mainProjectPath}} branch -d proposal/{{changeId}}` + +(commit 步骤位于 archive-change 文件移动之后、merge 之前,已由 archive.txt 的 `` / `` 段约束。) + +merge 操作 SHALL 使用 `--ff-only`,SHALL NOT 自动 fall back 到普通 merge / rebase。worktree remove SHALL NOT 使用 `--force`。branch delete SHALL NOT 使用 `-D` 强删。 + +任一步失败时 archive ACP session SHALL 把 stderr 完整复述给用户,archive ACP session 不自动重试、不自动 fall back。后续动作(rebase / 普通 merge / `--force`)由用户决策并通过对话告知 agent。 + +archive ACP session 在 4 步全部成功后 SHALL: + +- 主仓库 `git -C {{mainProjectPath}} log` 当前分支头部含归档 commit; +- `git -C {{mainProjectPath}} worktree list` 不再含 `{{worktreePath}}`; +- `git -C {{mainProjectPath}} branch` 不再含 `proposal/{{changeId}}`。 + +#### Scenario: 全成路径 + +- **WHEN** archive ACP session 在 worktreePath 非空的 ApplyRunMeta 上启动 +- **AND** archive-change confirm:true 完成 OpenSpec 文件移动 +- **AND** agent 完成归档 commit +- **AND** main 自 worktree 创建后未被推进 +- **THEN** agent 执行 `git -C
merge --ff-only proposal/` 成功 +- **AND** agent 执行 `git -C
worktree remove ` 成功 +- **AND** agent 执行 `git -C
branch -d proposal/` 成功 +- **AND** 磁盘上 worktree 目录不存在 +- **AND** main 当前分支 HEAD 含归档 commit + +#### Scenario: merge 冲突 + +- **WHEN** main 在 worktree 创建后被推进 +- **AND** agent 执行 `git -C
merge --ff-only proposal/` +- **THEN** 命令失败,stderr 含 "Not possible to fast-forward" +- **AND** agent 把 stderr 完整复述给用户 +- **AND** agent 不自动尝试普通 merge 或 rebase +- **AND** agent 不执行后续 worktree remove / branch -d +- **AND** archive ACP session 等待用户进一步指示 + +#### Scenario: worktree remove 被锁定文件占用 + +- **WHEN** merge --ff-only 已成功 +- **AND** worktree 内某文件被外部进程锁定 +- **AND** agent 执行 `git -C
worktree remove ` +- **THEN** 命令失败,stderr 含 locking error +- **AND** agent 把 stderr 完整复述给用户 +- **AND** agent 不自动加 `--force` +- **AND** 后续 branch -d 不执行(因 worktree 仍占用分支) + +#### Scenario: branch delete 失败 + +- **WHEN** merge / worktree remove 已成功 +- **AND** agent 执行 `git -C
branch -d proposal/` +- **AND** 由于历史异常(如用户曾 reset main),git 报 "branch is not fully merged" +- **THEN** stderr 复述用户 +- **AND** agent 不自动 `-D` 强删 + +### Requirement: Archive skips git cleanup when worktreePath is empty + +archive 阶段在 `runMeta.worktreePath` 为空字符串或 `undefined` 时,SHALL 跳过 4 步 git 收尾,仅完成 archive-change 文件移动 + 归档 commit;行为完全等价于 multi-worktree 工作流引入前。 + +archive system-reminder 文本 SHALL 在 `` 段开头明确这一降级条件,让 agent 通过对 `{{worktreePath}}` 占位符渲染结果的判断(空字符串 vs 非空字符串)选择路径。 + +#### Scenario: 非 git 项目 archive + +- **WHEN** `/.git` 不存在 +- **AND** runMeta.worktreePath 为 `undefined` +- **AND** 用户触发 archive +- **THEN** archive ACP session cwd 等于 projectPath(P1 fallback) +- **AND** archive system-reminder 渲染后的 `{{worktreePath}}` 为空字符串 +- **AND** agent 完成 archive-change + commit 后**不**执行 merge / worktree remove / branch -d +- **AND** archive 行为与 multi-worktree 工作流引入前完全等价 + +#### Scenario: 旧 ApplyRunMeta archive + +- **WHEN** runMeta 为 P3 启用前持久化的 JSON(不含 worktreePath 字段) +- **AND** 用户触发 archive +- **THEN** runMeta.worktreePath 反序列化为 `undefined` +- **AND** archive 行为与多 worktree 工作流引入前完全等价 +- **AND** 不报错、不留遗留 worktree diff --git a/openspec/changes/add-archive-worktree-orchestration/specs/system-reminder-injection/spec.md b/openspec/changes/add-archive-worktree-orchestration/specs/system-reminder-injection/spec.md new file mode 100644 index 00000000..7f5e7215 --- /dev/null +++ b/openspec/changes/add-archive-worktree-orchestration/specs/system-reminder-injection/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: archive reminder 编排 worktree 4 步收尾 + +`archive.txt` system-reminder 模板正文 SHALL 包含一个 `` 段(位于 `` 闭合标签之后、`` 开始标签之前),段内 SHALL 明确以下内容: + +- 当 `{{worktreePath}}` 占位符渲染为空字符串时,agent 跳过 git 编排,仅完成 archive-change + 归档 commit。 +- 当 `{{worktreePath}}` 渲染为非空字符串时,archive-change 完成 OpenSpec 文件移动后,agent 用 Bash 执行 4 步 git 收尾: + 1. `git -C {{worktreePath}} add -A && git -C {{worktreePath}} commit -m ""`(commit message 仍按 `` / `` 段已规定的 `type(scope): summary` 模板,scope 用 `openspec`)。 + 2. `git -C {{mainProjectPath}} merge --ff-only proposal/{{changeId}}`;失败 stderr 复述用户、不自动 fall back。 + 3. `git -C {{mainProjectPath}} worktree remove {{worktreePath}}`;失败 stderr 复述用户、不加 `--force`。 + 4. `git -C {{mainProjectPath}} branch -d proposal/{{changeId}}`;失败 stderr 复述用户、不 `-D`。 + +`` 段 SHALL 引用以下三个白名单占位符(已由 P2 添加到 `ALLOWED_VARIABLES`):`{{worktreePath}}`、`{{mainProjectPath}}`、`{{changeId}}`。SHALL NOT 引用 `branchName` 等其他占位符——分支名 `proposal/{{changeId}}` 以字面量出现。 + +`` 段 SHALL 包含以下新增 SHALL 条款(与原有 SHALL 并列,原有内容保留): + +- 完整顺序为 `sync → archive → commit → merge → worktree-cleanup`,禁止重排或跳步;merge / cleanup 仅在 `{{worktreePath}}` 非空时执行。 +- merge 必须用 `git merge --ff-only`,失败时停止,禁止 force / 自动普通 merge fall back。 +- worktree cleanup 仅在 merge 成功后执行;merge 失败时禁止执行 worktree remove / branch delete。 +- 禁止 `worktree remove --force` / `branch -D`;失败 stderr 由用户决定 force。 + +#### Scenario: archive reminder 含 worktree 段 + +- **WHEN** 主进程为 archive owner 渲染 system-reminder 文本 +- **THEN** 文本含 `` 与 `` 闭合标签 +- **AND** 标签之间内文含 `git -C {{mainProjectPath}} merge --ff-only proposal/{{changeId}}` 模板(占位符渲染后的字面量) +- **AND** 标签之间内文含 `git -C {{mainProjectPath}} worktree remove` 与 `git -C {{mainProjectPath}} branch -d proposal/{{changeId}}` 模板 +- **AND** `` 段含 `merge --ff-only` 字符串 +- **AND** `` 段含禁止 `--force` / `-D` 强删的 SHALL 描述 + +#### Scenario: archive reminder worktreePath 为空时显式说明降级 + +- **WHEN** runMeta.worktreePath 为 `undefined` +- **THEN** `` 段开头明确说明 "若 `{{worktreePath}}` 为空字符串,跳过本段全部 git 编排" +- **AND** `{{worktreePath}}` 占位符在该段中被渲染为空字符串 +- **AND** agent 收到 reminder 后跳过 4 步 git 收尾 + +#### Scenario: archive prompt 文案精简 + +- **WHEN** main process 为 `proposal-archive` stage type 构造 prompt +- **THEN** prompt 文本严格等于 `归档 {changeId}` +- **AND** prompt 文本不含 "提交代码" / "merge" / "worktree" / "commit" 等编排关键词 +- **AND** worktree 收尾步骤完全由 archive system-reminder 与 archive-change tool_instruction 共同保障 + +#### Scenario: archive reminder 既有 SHALL 全部保留 + +- **WHEN** 主进程为 archive owner 渲染 system-reminder 文本 +- **THEN** 文本仍含 `` / `` / `` / `` 既有段 +- **AND** 既有 SHALL 全部保留(commit subject 格式 `type(scope): summary`、commit only change-related files、不能 bypass MCP 等) +- **AND** `` 段位于 `` 闭合后、`` 开始前 diff --git a/openspec/changes/add-archive-worktree-orchestration/tasks.md b/openspec/changes/add-archive-worktree-orchestration/tasks.md new file mode 100644 index 00000000..6a1ffd27 --- /dev/null +++ b/openspec/changes/add-archive-worktree-orchestration/tasks.md @@ -0,0 +1,80 @@ +## 1. archive prompt 文案精简 + +- [ ] 1.1 修改 `electron/main/services/proposal/stage-prompts.ts`:把 `stageRunners["proposal-archive"]` 的实现从 `({ changeId }) => \`归档 ${changeId} 并提交代码\``改为`({ changeId }) => \`归档 ${changeId}\``。 +- [ ] 1.2 检查并更新 `electron/main/__tests__/ipc/proposal-apply.spec.ts`(line 79 附近 mock `buildArchiveStage`):如果存在对 archive prompt 文本的断言,同步改为新文案;如果断言不涉及 prompt 文本,本步骤无需修改测试。 +- [ ] 1.3 在 `electron/main/services/proposal/__tests__/`(如不存在则建)创建/补充 `stage-prompts.spec.ts`,断言: + - `buildStagePrompt({ stage: { type: "proposal-archive" }, changeId: "foo", projectPath: "/x" })` 返回字符串严格等于 `归档 foo`。 + - 该字符串不含 `提交代码` / `merge` / `worktree` / `commit` 子串。 +- [ ] 1.4 验收:`pnpm typecheck` 通过;`pnpm test electron/main` 全过。 + +## 2. archive.txt 模板新增 worktree 段 + +- [ ] 2.1 修改 `electron/main/services/chat/system-reminder/templates/archive.txt`:在现有 `` 闭合标签之后、`` 开始标签之前,插入完整的 `` 段,内容严格按 design.md "archive.txt `` 段(设计文本)" 节写入。 +- [ ] 2.2 文本必须含三个占位符:`{{worktreePath}}`、`{{mainProjectPath}}`、`{{changeId}}`。分支名以 `proposal/{{changeId}}` 字面量出现,不引入 `branchName` 占位符。 +- [ ] 2.3 文本第 1 段必须明确:`{{worktreePath}}` 为空字符串时跳过整段 4 步 git 编排,仅按 `` 完成 archive-change + 业务代码 commit。 +- [ ] 2.4 文本必须依次写出: + - 1. commit OpenSpec 归档移动(`git -C {{worktreePath}} add -A && commit`) + - 2. merge --ff-only 进 main(`git -C {{mainProjectPath}} merge --ff-only proposal/{{changeId}}`) + - 3. worktree remove(`git -C {{mainProjectPath}} worktree remove {{worktreePath}}`) + - 4. branch delete(`git -C {{mainProjectPath}} branch -d proposal/{{changeId}}`) +- [ ] 2.5 每步必须明确"失败时把 stderr 完整复述给用户、不自动重试、不加 force"。 + +## 3. archive.txt 模板 critical 段扩展 + +- [ ] 3.1 修改 `archive.txt` 的 `` 段:把原有第一条 `MUST follow the order: sync → archive → commit. No reordering, no skipping.` 改为 `MUST follow the order: sync → archive → commit → merge → worktree-cleanup. No reordering, no skipping. Steps 4–5 only when `{{worktreePath}}` is non-empty.` +- [ ] 3.2 在 `` 段中追加以下新 SHALL 条款(保留原有所有其他 SHALL): + - `MUST run merge as `git merge --ff-only`` and stop on failure (no force, no auto普通 merge fallback).` + - `MUST clean up worktree only after merge succeeds.` worktree remove without successful merge would lose the archive commit.` + - `MUST NOT use `worktree remove --force`/`branch -D`.` Failure stderr goes to the user; the user decides force.` +- [ ] 3.3 验收:`` 段总条款数(既有 + 新增)= 既有 + 3;既有的 commit subject 格式 / commit only change-related files / 不能 bypass MCP / `archive-change` 必须传 `includeInstruction: true` 等条款全部保留。 + +## 4. 模板渲染单测扩展 + +- [ ] 4.1 在 P2 已建的 `electron/main/services/chat/system-reminder/providers/__tests__/`(或同路径下新建 archive-specific 测试文件)追加用例: + - 4.1.1 archive owner 渲染:传入 `worktreePath: "/abs/.worktrees/foo"`、`projectPath: "/abs"`、`changeId: "foo"` → 渲染后文本含 `` 段、含字面量 `/abs/.worktrees/foo` 与 `proposal/foo` 字符串。 + - 4.1.2 archive owner 渲染:传入 `worktreePath: undefined` → 渲染后 `{{worktreePath}}` 替换为空字符串;文本仍含 `` 段;段开头能找到"为空字符串时跳过 git 编排"的中文叙述。 + - 4.1.3 archive owner 渲染:`{{mainProjectPath}}` 与 `{{projectPath}}` 应渲染为同一字符串。 + - 4.1.4 archive owner 渲染:`changeId` 含特殊字符(如下划线)时占位符正常替换;`{{changeId}}` 与 `proposal/{{changeId}}` 都按规则渲染。 + - 4.1.5 任一字段含 `<` 或 `>` 字符时 `renderSystemReminderTemplate` 返回 `null`、`logger.warn` 被调用。 +- [ ] 4.2 验收:上述 5 条用例全过;`pnpm test electron/main/services/chat/system-reminder` 全过。 + +## 5. dogfood 与零回归验证 + +- [ ] 5.1 准备:FylloCode 自身仓库(git 项目)。前置 P1 / P2 / P3 都已 archive 落 `openspec/specs/`;本任务在 P3 完成基础上落地。 +- [ ] 5.2 全成路径 dogfood: + - 5.2.1 chat 阶段创建一个新 change(worktree 创建落 `.worktrees/`)。 + - 5.2.2 走完 apply(任意 stage)。 + - 5.2.3 触发 archive;从 logger 与 messages.jsonl 中确认: + - archive prompt 文本严格等于 `归档 `(不含"提交代码"等)。 + - archive system-reminder 含 `` 段;段内 `{{worktreePath}}` 渲染为绝对路径。 + - agent 依次执行 `archive-change confirm:true` → 在 worktree commit OpenSpec 归档移动 → `git -C
merge --ff-only proposal/` → `git -C
worktree remove ` → `git -C
branch -d proposal/`。 + - 5.2.4 archive 完成后磁盘验证: + - `/.worktrees//` 不存在。 + - `git -C
worktree list` 不含该 worktree。 + - `git -C
branch` 不含 `proposal/`。 + - main 当前分支 HEAD 含归档 commit(`git log -1` 可见),commit 内文件包含 OpenSpec 文件归档移动。 +- [ ] 5.3 merge 冲突 dogfood: + - 5.3.1 chat 阶段创建 worktree change,apply 完成。 + - 5.3.2 在 archive 触发**之前**,给 main 加一个不相关的 commit(例如 `git -C
commit --allow-empty -m "trigger conflict"`),让 main 推进到 worktree 创建之后。 + - 5.3.3 触发 archive。预期:agent 完成 archive-change + commit 后执行 merge --ff-only 失败,stderr 含 "Not possible to fast-forward";agent 把 stderr 完整复述给用户;agent **不**继续执行 worktree remove / branch delete;archive ACP session 等待用户。 + - 5.3.4 验证 worktree 与 branch 仍保留(用户可手动 rebase 或普通 merge 后再继续)。 +- [ ] 5.4 worktreePath 为空 dogfood(旧 ApplyRunMeta): + - 5.4.1 找一个 P3 启用之前的 ApplyRunMeta JSON(手工准备一份不含 worktreePath 字段的 run.json)。 + - 5.4.2 触发 archive。预期:archive ACP cwd === projectPath;agent 完成 archive-change + commit 后**不**执行 merge / worktree remove / branch delete;行为与多 worktree 工作流引入前完全等价。 +- [ ] 5.5 非 git 项目 dogfood: + - 5.5.1 在 FylloCode 创建一个 `template: "empty"` 项目,其中无 `.git`。 + - 5.5.2 走完一次 chat → apply → archive 流程;预期与 5.4 等价。 +- [ ] 5.6 `pnpm build` / `pnpm lint` / `pnpm typecheck` 全部通过;不引入新告警。 +- [ ] 5.7 验收:5.2 / 5.3 / 5.4 / 5.5 / 5.6 全部通过;记录 dogfood 结果到 commit/PR 描述。 + +## 6. 文档与实施顺序对齐 + +- [ ] 6.1 检查 `mcp-servers/fyllo-specs/src/prompts/archive-change.md`:当前 `tool_instruction` 的"sync 主 spec → archive 文件移动 → 报告状态"约束是否已经清晰。如不清晰,补一句"git commit / merge / worktree-cleanup 由 archive system-reminder 编排,不在本工具内执行",避免 agent 混淆 MCP 边界。 +- [ ] 6.2 验收:`mcp-servers/fyllo-specs/__tests__/prompts.test.ts` 仍通过(如断言 prompt 文本片段,则同步更新断言)。 + +## 7. 验收总闸 + +- [ ] 7.1 1.1 - 6.2 全部勾选完成。 +- [ ] 7.2 multi-worktree 工作流端到端闭环:chat → propose → 创建 worktree → apply 在 worktree → archive 完成后 worktree 自动清理 → main 多一次 fast-forward merge commit。 +- [ ] 7.3 旧 ApplyRunMeta / 非 git 项目行为完全等价于本能力引入前。 +- [ ] 7.4 OpenSpec change 状态从 `applying` 顺利变为 `archived`,proposal 详情页显示 archived,list 中(worktree 已删)该 change 不再显示 worktree 标记。 diff --git a/openspec/changes/add-chat-worktree-orchestration/.openspec.yaml b/openspec/changes/add-chat-worktree-orchestration/.openspec.yaml new file mode 100644 index 00000000..65b9a4e8 --- /dev/null +++ b/openspec/changes/add-chat-worktree-orchestration/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-05-19T00:00:00.000Z +status: draft diff --git a/openspec/changes/add-chat-worktree-orchestration/design.md b/openspec/changes/add-chat-worktree-orchestration/design.md new file mode 100644 index 00000000..3432457a --- /dev/null +++ b/openspec/changes/add-chat-worktree-orchestration/design.md @@ -0,0 +1,249 @@ +## Context + +P1(`add-multi-worktree-foundation`)已经把 `targetPath` 必填、cwd fallback、`SystemReminderContext.worktreePath?` 字段、ApplyRunMeta worktreePath 透传等基建落到位。但因为来源端(ProposalMeta.worktreePath)始终为 `undefined`,所有 worktreePath 字段在 P1 阶段实际都是空值,agent 的工作目录仍始终是主仓库。 + +P2 让 chat 阶段开始**真正创建并使用** linked worktree:用户同意 propose 后,chat ACP session 内的 agent 用 Bash 执行 `git worktree add`,把 OpenSpec change scaffold 直接写到 worktree 内部,再调 `mcp__fyllo_specs__create-proposal targetPath=` 让 MCP scaffold 落到 worktree 的 `openspec/changes//`。 + +P2 的边界: + +- **MCP 不变**:`create-proposal` 仍只做 OpenSpec 文件级原子操作 + `targetPath` 合法性校验。worktree add / .gitignore commit 全由 agent 用 Bash 完成。这是与"create-proposal 内置 git"方案的核心分野——已经在 P1 的设计讨论中拍板(MCP 永远不 owning git workflow)。 +- **chat session cwd 不变**:chat ACP session 仍绑定主仓库 projectPath;agent 通过 reminder 拿到 worktree 绝对路径,用绝对路径访问 worktree 内的 OpenSpec artifacts。chat 自身 cwd 与 worktree 路径分离是预期,不是缺陷。 +- **不修改 list / archive**:proposal 列表能否扫到 worktree 来源的 change 是 P3 的事;archive 时怎么 commit/merge/remove worktree 是 P4 的事。P2 只让 worktree **被产出**。 + +**关键事实** + +- chat.txt 和 apply.txt 模板由 `electron/main/services/chat/system-reminder/templates/*.txt?raw` 在 build 时打包进主进程,运行时通过 `renderSystemReminderTemplate(template, ctx)` 替换 `{{...}}` 占位符。 +- `ALLOWED_VARIABLES`(`providers/shared.ts:6`)当前只有 `["changeId", "stageIndex", "runId", "projectPath"]`。任何不在白名单的占位符保持字面量。 +- `apply.txt` 当前的 `` 段已用 `{{projectPath}}`、`{{changeId}}`、`{{stageIndex}}`、`{{runId}}`,但**不包含** `worktreePath` 或 `cwd` 信息。 +- chat.txt 当前的 `` 段也不引用 projectPath 或任何 cwd 信息(chat 阶段只面向"理解任务"的指令)。 +- ACP session 的 `cwd` 由调用方传入,`SystemReminderContext.cwd` 字段已是该 cwd 的副本。本设计**不修改** cwd 字段语义。 + +## Goals / Non-Goals + +**Goals** + +- chat 阶段的 agent 在用户同意 propose 后,能根据 reminder 编排:① 检查并维护主仓库 `.gitignore`;② `git worktree add .worktrees/ -b proposal/`;③ 用 worktree 绝对路径作为 `targetPath` 调 create-proposal。 +- apply 阶段的 agent 在 reminder 中清楚看到当前 stage cwd 是 linked worktree 还是主仓库(取决于 `runMeta.worktreePath` 是否非空)。 +- 模板变量白名单扩展,新增 `worktreePath` 与 `mainProjectPath`,沿用 P1 已经存在的 sanitize 流程(含尖括号则跳过注入)。 +- 同一 chat session 允许孵化多个 change,每个 change 占独立 worktree;agent 在用户表述模糊时主动确认目标。 +- non-git 项目(`/.git` 不存在)跳过 worktree 编排,与 P1 行为完全等价。 + +**Non-Goals** + +- 不修改 MCP `create-proposal` 工具(P1 已完成必填 `targetPath`;P2 不引入 worktree add 副作用进 MCP)。 +- 不修改 list / 卡片标记(P3)。 +- 不修改 archive 编排(P4)。 +- 不修改 chat ACP session 的 cwd(保持主仓库)。 +- 不引入新的 IPC 通道、`shared/` 类型、preload 暴露。 +- 不实现 worktree 创建失败的自动恢复(agent 把 stderr 复述给用户,由用户决策)。 + +## Decisions + +### D1:worktree 编排放进 chat.txt 模板,而不是新增 prompt 工具 + +**选择**:在 `chat.txt` 的 `` 段新增 `` 子段,把 worktree 编排步骤写成自然语言指令(含 Bash 命令模板)。agent 收到 reminder 后用现有的 Bash 工具执行命令。 + +**理由**: + +- 模板加段是最低侵入的方式:build 流程 / IPC 协议 / preload 表面全不动。 +- worktree 编排逻辑本质是"agent 在某个时机执行某些命令",与现有"chat 阶段如何工作"的指令同性质,归属在同一份 reminder 文本里语义自洽。 +- 新增 MCP 工具或 IPC 都会增加表面,且把"何时建 worktree"这种判断从 reminder 拆出来,反而让 agent 难以连贯执行(reminder 是 agent 的工作合约,跨工具拆分契约容易造成漂移)。 + +**否决方案**: + +- 在 `mcp__fyllo_specs__create-proposal` tool 里内置 git worktree add——已在 P1 讨论中拍板否决(MCP 不 owning git workflow,避免和用户 hooks/signing 冲突)。 +- 主进程在某 IPC handler 里 spawn git worktree add——FylloCode 主进程目前完全不调 git,引入此通路会突破现有架构边界。 + +### D2:worktree 路径与分支命名 + +**选择**:worktree 路径硬编码为 `/.worktrees/`;分支名硬编码为 `proposal/`。 + +**理由**: + +- `.worktrees/` 是放在主仓库内部的好处:用户用 `git status` 时一眼能看到哪些 worktree 存在;`.gitignore` 一行 `.worktrees/` 即可隔离。 +- 放主仓库**外**(`-worktrees/`)有写权限风险(用户对父目录可能没写权)且违背"工程文件留在仓库内"的直觉。 +- `` 作为唯一键,重名直接 `git worktree add` 失败,agent 让用户换名重试。 + +### D3:`.gitignore` 维护由 agent 用 Bash 完成 + +**选择**:reminder 中明确步骤——`grep -qxF .worktrees/ .gitignore || (echo .worktrees/ >> .gitignore && git add .gitignore && git commit -m "chore: ignore .worktrees")`。 + +**理由**: + +- 只在第一次为该 main repo 创建 worktree 时触发一次(之后 `grep -qxF` 命中即跳过)。 +- commit 失败时 stderr 透传给用户(commit signing required / pre-commit hook 拒绝),由用户手动处理。这与 D1 的"agent 主导,stderr 兜底"边界一致。 + +### D4:模板变量白名单扩展两个字段 + +**选择**:`ALLOWED_VARIABLES` 追加 `worktreePath` 与 `mainProjectPath`。 + +- `worktreePath`:取自 `SystemReminderContext.worktreePath`;undefined 时 `sanitizeValue` 已经返回 `""`(参见 `shared.ts:11`),模板侧需要明确"空字符串表示当前不在 worktree 内"的叙述。 +- `mainProjectPath`:取自 `SystemReminderContext.projectPath`,**与 `projectPath` 的值完全相同**,仅作为模板叙述用的别名(让"主仓库路径"这个概念在 worktree 引入后更清晰;若仍用 `{{projectPath}}` 在 worktree 段下叙述,"projectPath"在 chat 与 apply 两套语境的含义会让人混淆)。 + +#### 选项对比 + +| 方案 | 优点 | 痛点 | +| ------------------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------- | +| 加 worktreePath + mainProjectPath(本设计) | 模板叙述清晰;不破坏现有 projectPath 语义 | 多一个白名单变量,但语义明确 | +| 只加 worktreePath,复用 projectPath 表达"主仓库" | 白名单只多一项 | 在 worktree 编排段中"projectPath"既是主仓库又是 chat cwd,叙述容易绕 | +| 改名 projectPath → mainProjectPath(破坏现状) | 单一来源 | 已有模板大量引用 projectPath,破坏面大 | + +### D5:apply.txt 用 worktreePath 渲染,空值的叙述策略 + +**选择**:apply.txt 新增 `` 段渲染 `{{worktreePath}}`。当 worktreePath 为 undefined(旧 ApplyRunMeta 或 P3 未启用之前的 P2 阶段)时 `sanitizeValue` 返回 `""`,导致模板渲染出空字符串。模板内文叙述策略: + +``` + +本 stage 的工作目录是 `{{worktreePath}}`。空值代表当前在主仓库 `{{mainProjectPath}}` 上工作(即未启用 worktree 隔离)。 +... + +``` + +**理由**: + +- 不为 worktreePath 是否为空走两个分支模板(避免模板拷贝维护)。 +- 文本里把"空字符串"语义显式说出来,比让 agent 自己根据空值推断更稳。 + +### D6:chat 阶段 reminderContext.worktreePath 永远是 undefined + +**选择**:`chat.ts` IPC handler 构造 reminderContext 时 `worktreePath: undefined`。chat ACP session 自身 cwd 永远是主仓库,worktree 是 agent 在 chat 内创建的产物,不是 chat session 的属性。 + +**理由**: + +- 与"chat session cwd 不变"决策一致(D1 的整体定位)。 +- chat.txt 模板**不渲染** `{{worktreePath}}`——因为 chat 一次会话可能孵化多个 change,每个 change 一个 worktree,无法在 reminder 注入时确定单一 worktreePath。worktreePath 只在 apply.txt / archive.txt 这种 1 run = 1 worktree 的语境下才有渲染意义。 +- 因此 chat.txt 中的 worktree 编排段**只引用 `{{mainProjectPath}}`**,描述操作主仓库的命令模板;具体 worktreePath 在 agent 执行 `git worktree add` 后由 agent 自己记下并用作后续 `targetPath`。 + +### D7:chat.txt 编排同一 chat 多 change 的语义 + +**选择**:reminder 文本明确: + +- 用户每次同意 propose 时都重复整套 worktree 编排(不假设 chat 内只会孵化一个 change)。 +- 用户表述模糊(如"刚才那个 change")且对话历史中存在多个 change 时,agent 必须先反问目标 worktree。 +- chat 内对 worktree artifacts 的 Read/Edit 一律用绝对路径,因为 chat session cwd 是主仓库。 + +**理由**:根据用户在 P1 设计阶段的明确要求:"A1 可以,用户可能在同一个 session 里创建多个 proposal" + "如果用户未指明,agent 应该会主动去问"。 + +### D8:non-git 项目降级走文本判断 + +**选择**:reminder 文本中先让 agent 用 `git -C rev-parse --is-inside-work-tree` 自检;若失败(exit code 非 0 / stderr 含 "not a git repository"),跳过整个 worktree 编排,直接用 `targetPath: {{mainProjectPath}}` 调 create-proposal。 + +**理由**: + +- 主进程不需要新增"是否 git 项目"的字段;agent 自检更直接。 +- 与 P1 中 `validateTargetPath` 的 non-git 降级逻辑(`git worktree list` 失败时 fallback 到 main repo 自身合法)天然对齐。 + +## Architecture + +### chat.txt worktree 编排子段(自然语言文本) + +放在 `` 段末尾、`` 段之前。完整新增段落(确切文本由 tasks 步骤实施时按本设计落入模板): + +``` + +当用户同意提议(propose)后,按以下顺序为本次 change 创建独立的 git linked worktree: + +1. 自检主仓库是否为 git 仓库:`git -C {{mainProjectPath}} rev-parse --is-inside-work-tree`。失败(非 git 项目)则跳过本段,直接用 `targetPath: {{mainProjectPath}}` 调用 mcp__fyllo_specs__create-proposal。 + +2. 维护主仓库 .gitignore(仅首次需要): + `cd {{mainProjectPath}} && (grep -qxF .worktrees/ .gitignore 2>/dev/null || (echo .worktrees/ >> .gitignore && git add .gitignore && git commit -m "chore: ignore .worktrees"))` + commit 失败时(如 commit signing required、pre-commit hook 拒绝),把 stderr 完整复述给用户,让用户决定下一步;不要自行重试。 + +3. 创建 worktree: + `git -C {{mainProjectPath}} worktree add .worktrees/ -b proposal/` + 为 kebab-case 改动名(如 add-foo-bar)。命令成功后,worktree 绝对路径为 `{{mainProjectPath}}/.worktrees/`,记下作为后续 `targetPath`。 + 失败常见原因:worktree 已存在(changeName 重名)、HEAD 状态异常。把 stderr 完整复述给用户,请用户换名或处理后再继续。 + +4. 调 `mcp__fyllo_specs__create-proposal`,`targetPath` 必传 worktree 绝对路径(即第 3 步记下的 path),`changeName` 与目录名一致。本工具内部不会创建 worktree、不会改 git 状态,仅在传入路径下生成 OpenSpec change scaffold。 + +5. 同一 chat session 允许孵化多个 change,每个 change 各占独立 worktree;用户后续提到"刚才那个 change"且历史中存在多个 worktreePath 时,先反问目标。 + +6. 后续在 chat 内对该 change artifacts 的 Read / Edit 必须用 worktree 的绝对路径。chat session 自身 cwd 是主仓库 `{{mainProjectPath}}`,相对路径会落到主仓库。 + +``` + +### apply.txt worktree 子段(自然语言文本) + +放在现有 `` 段之后、`` 段之前: + +``` + +本 stage 的工作目录(cwd)是 `{{worktreePath}}`。若该字符串为空,表示当前 stage 的 cwd 是主仓库 `{{mainProjectPath}}`(旧 ApplyRunMeta 或 worktree 编排尚未启用的项目)。 + +业务代码改动产生的 commit 由你(agent)自己完成;archive 阶段不会替你 commit 业务代码。在准备进入 archive 之前,请确保 `git status` 工作区 clean(OpenSpec tasks.md 的勾选除外,那是工具进度)。 + +``` + +### 模板变量白名单扩展 + +`shared.ts` 修改后: + +```ts +const ALLOWED_VARIABLES = [ + "changeId", + "stageIndex", + "runId", + "projectPath", + "worktreePath", + "mainProjectPath", +] as const; + +function getVariableValue(ctx, field) { + switch (field) { + case "changeId": + return ctx.changeId; + case "stageIndex": + return ctx.stageIndex; + case "runId": + return ctx.runId; + case "projectPath": + return ctx.projectPath; + case "worktreePath": + return ctx.worktreePath; + case "mainProjectPath": + return ctx.projectPath; // alias + default: + return undefined; + } +} +``` + +### chat.ts IPC handler 不需要修改 + +`chat.ts` 当前调 `new AcpSession({ ... })` 时没有传 `reminderContext`(参见 `electron/main/services/chat/acp-session.ts:443`,AcpSession 内部对 `this.opts.reminderContext ?? {}` 做兜底 spread)。chat owner 的 reminder 解析仅依赖 `projectPath` / `cwd` / `fylloSessionId` / `agentId` 等核心字段,因此 P2 阶段 chat handler 完全不需要改动;chat.txt 的新 `` 段只引用 `{{mainProjectPath}}`,该值由 AcpSession 在 spread 前就已赋为 `args.projectPath`(即主仓库路径),渲染天然正确。 + +## Risks / Trade-offs + +- **agent 编排行为漂移**(中高):reminder 是自然语言指令,agent 可能跳步、合并 commit、用错路径。 + - 缓解:reminder 文本写明每一步的命令模板与失败处置;上线后在 FylloCode 自身仓库 dogfood 1-2 周观察实际行为。 + - 验证:上线后跑一次 chat → propose,确认 agent 真的执行了 `.gitignore` 维护、`worktree add`、并把 `targetPath` 设为 worktree 路径。 + +- **commit 在严格 git 环境失败**(中):commit signing / pre-commit hook 拒绝时 `.gitignore` commit 失败。 + - 缓解:reminder 中明确 stderr 透传给用户由用户处理;不要 agent 自行 retry。 + +- **`.worktrees/` 路径冲突**(低):用户已自定义其它工具用 `.worktrees/` 目录。 + - 缓解:`git worktree add` 在 `.worktrees/` 已存在时直接失败,stderr 含路径冲突信息;agent 复述给用户解决。 + +- **changeName 重名**(低):`.worktrees/` 已被 `git worktree add` 注册。 + - 缓解:reminder 让 agent 在 add 前用 `git worktree list` 自检;冲突时让用户换名。 + +- **空值 worktreePath 在 apply.txt 的叙述歧义**(低):旧 ApplyRunMeta 或 P3 启用之前的 P2 阶段,apply 渲染的 `{{worktreePath}}` 为空字符串。 + - 缓解:apply.txt 文本明确"空值代表主仓库 cwd"。 + +- **non-git 项目自检依赖 agent 行为**(低):agent 可能跳过 `git rev-parse --is-inside-work-tree` 自检直接 `worktree add` 失败。 + - 缓解:reminder 文本把自检步骤放在第 1 步且强调"失败则跳过"。 + +## Migration Plan + +1. 修改 `providers/shared.ts`:`ALLOWED_VARIABLES` 加 `worktreePath` / `mainProjectPath`;`getVariableValue` 处理这两个字段;单测覆盖含尖括号时跳过注入。 +2. 修改 `templates/chat.txt`:在 `` 段末尾追加 `` 子段(设计文本如上)。 +3. 修改 `templates/apply.txt`:在 `` 段后追加 `` 子段(设计文本如上)。 +4. 修改 `electron/main/ipc/chat.ts`:构造 reminderContext 时显式 `worktreePath: undefined`。 +5. 在 FylloCode 自身仓库 dogfood:跑一次 chat → propose 流程,确认 agent 行为符合预期;跑一次 apply 流程,确认 apply.txt 渲染含 worktreePath 段。 +6. 单测:`providers/shared.spec.ts`(如不存在则新建)覆盖新变量;模板正文不需要单测(dogfood 替代)。 + +**回滚**:把 chat.txt / apply.txt 的 `` 段删除;`shared.ts` 的两个新变量从白名单移除即可。MCP 不变、IPC 不变、字段类型不变。 + +## Open Questions + +无。 diff --git a/openspec/changes/add-chat-worktree-orchestration/proposal.md b/openspec/changes/add-chat-worktree-orchestration/proposal.md new file mode 100644 index 00000000..4d26f980 --- /dev/null +++ b/openspec/changes/add-chat-worktree-orchestration/proposal.md @@ -0,0 +1,61 @@ +## Why + +P1 已经为主进程数据通路与 MCP `targetPath` 入参打好基建。本次(P2)让 chat 阶段真正开始用 git linked worktree 隔离 OpenSpec proposal artifacts:当用户在 chat 同意 propose 后,agent 在 `chat.txt` system-reminder 编排下用 Bash 命令创建 `.worktrees//`,把 OpenSpec change 文件直接写到 worktree,避免 main 工作树长期堆积未 commit 的 proposal artifacts,也为 P3 的 list 双源扫描和 P4 的 archive 编排准备数据来源。 + +## What Changes + +- 修改 `chat.txt` system-reminder 模板正文:在 `` 段中新增 `` 子段,明确 chat 阶段用户同意 propose 后 agent 必须用 Bash 执行的 worktree 编排步骤—— + - 检查并维护主仓库 `.gitignore`:若不存在或不含 `.worktrees/` 行,先 `echo .worktrees/ >> .gitignore`,再 `git add .gitignore && git commit -m "chore: ignore .worktrees"`。 + - 执行 `git worktree add .worktrees/ -b proposal/`。 + - 调用 `mcp__fyllo_specs__create-proposal` 时把 `targetPath` 设为 worktree 的绝对路径(即 `/.worktrees/`)。 + - 同一 chat session 允许孵化多个 change:每次 propose 都重复以上步骤,每个 change 各占独立 worktree;用户表述模糊(如"刚才那个 change")且历史中存在多个 worktreePath 时,先确认目标。 + - 后续 chat 内对该 change artifacts 的 Read/Edit 必须使用 worktreePath 的绝对路径(chat session 自身 cwd 仍是主仓库)。 + - 失败处置(worktree add 失败、`.gitignore` commit 被 hook 拒绝、changeName 已存在)一律先把 stderr 复述给用户,由用户决定下一步。 +- 修改 `apply.txt` system-reminder 模板正文:新增 `` 子段,告知 agent 当前 stage cwd 即为 linked worktree(值由 `{{worktreePath}}` 占位符渲染;为空则提示当前在主仓库);业务代码改动产生的 commit 由 agent 在 archive 之前自己完成。 +- 修改 `electron/main/services/chat/system-reminder/providers/shared.ts` 的模板变量白名单:在 `ALLOWED_VARIABLES` 中追加 `worktreePath` 与 `mainProjectPath`,分别对应 `SystemReminderContext.worktreePath` 和 `SystemReminderContext.projectPath`(mainProjectPath 是 projectPath 的别名,让模板叙述更清晰)。 +- chat IPC handler `electron/main/ipc/chat.ts` **不需要改动**:chat owner 的 reminderContext 字段由 `AcpSession` 在 spread 前就已赋值(参见 `acp-session.ts:443`);chat.txt 中的 `{{mainProjectPath}}` 通过 `projectPath` 字段渲染。 +- **non-git 项目降级**:`chat.txt` 中显式说明 ——若用户当前项目不是 git 仓库(agent 用 `git -C rev-parse --is-inside-work-tree` 自检发现失败),跳过 worktree 编排,直接用 `targetPath: ` 调 create-proposal,与 P1 阶段行为一致。 +- 不修改任何 MCP tool 实现:`create-proposal` 在 P2 阶段仍只做 OpenSpec scaffold + targetPath 校验,git worktree add 完全由 agent 用 Bash 执行(边界与 P1 一致)。 +- 不引入 chat session 的 cwd 切换:chat ACP session cwd 永远是主仓库 projectPath。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `system-reminder-injection`: 模板变量白名单新增 `worktreePath` 与 `mainProjectPath`;`chat.txt` / `apply.txt` 模板正文新增 worktree 编排段落(行为契约层面:reminder 在 chat 阶段必须包含 worktree 编排指令,agent 在收到 reminder 后会用 Bash 执行 worktree add 与 .gitignore 维护)。 + +## Impact + +**代码** + +- `electron/main/services/chat/system-reminder/templates/chat.txt`:新增 `` 编排段 +- `electron/main/services/chat/system-reminder/templates/apply.txt`:新增 `` 段(通过 `{{worktreePath}}` 渲染) +- `electron/main/services/chat/system-reminder/providers/shared.ts`:`ALLOWED_VARIABLES` 列表追加 `worktreePath` / `mainProjectPath`;`getVariableValue` 处理这两个字段 +- `electron/main/services/chat/system-reminder/types.ts`:无需改动(P1 已加 `worktreePath?`) +- `electron/main/ipc/chat.ts`:构造 `reminderContext` 时透传 `projectPath`,新增显式 `worktreePath: undefined`(让契约清晰;行为不变) +- `mcp-servers/fyllo-specs/src/prompts/create-proposal.md`:在"Input"段说明 `targetPath` 在 git 项目下推荐使用 worktree 绝对路径(与 chat.txt 编排一致);non-git 项目仍传主仓库路径 +- 测试:`electron/main/services/chat/system-reminder/__tests__/`(如不存在则新建)覆盖 worktreePath 替换、mainProjectPath 替换、含尖括号时跳过注入 + +**用户可见变化** + +- 用户在 chat 阶段确认 propose 后,会观察到 agent 多出几次 Bash 调用(`git worktree add`、可能的 `.gitignore` 维护 + commit),且后续 OpenSpec artifacts 出现在 `/.worktrees//openspec/changes//` 而不是主仓库的 `openspec/changes/`。 +- 主仓库 `.gitignore` 在第一次 propose 时会自动多一条 `.worktrees/` 与一次 `chore: ignore .worktrees` commit 到 main 当前分支。 +- non-git 项目体验与 P1 一致:所有 OpenSpec 文件仍写在主仓库 `openspec/changes/`,不创建任何 worktree。 + +**依赖** + +无新增依赖。git 命令由用户系统的 git 二进制提供。 + +**风险** + +- agent 编排行为漂移(中高):reminder 是自然语言指令,agent 可能跳步、把 `.gitignore` 维护合进业务 commit、或忘记把 `targetPath` 改成 worktreePath。缓解:reminder 文本写明每一步的命令模板与失败处置;P2 上线后在 FylloCode 自身仓库 dogfood 1-2 周。 +- `.gitignore` commit 在签名/签出钩子严格的环境失败(中):用户配置了 commit signing required 或 pre-commit hook 拒绝时,`.gitignore` commit 失败。缓解:reminder 文本说明此时直接把 stderr 给用户,让用户手动处理。 +- changeName 重名(低):`.worktrees/` 已存在时 `git worktree add` 会失败。缓解:reminder 让 agent 先 `git worktree list` 检测已有 worktree;冲突时让用户换名后重试。 + +**回滚** + +把 `chat.txt` / `apply.txt` 中的 `` 段删除即可;MCP 层面无任何修改,行为完全回到 P1。 diff --git a/openspec/changes/add-chat-worktree-orchestration/specs/system-reminder-injection/spec.md b/openspec/changes/add-chat-worktree-orchestration/specs/system-reminder-injection/spec.md new file mode 100644 index 00000000..4ea4555d --- /dev/null +++ b/openspec/changes/add-chat-worktree-orchestration/specs/system-reminder-injection/spec.md @@ -0,0 +1,113 @@ +## ADDED Requirements + +### Requirement: chat reminder 编排 worktree 创建 + +`chat.txt` system-reminder 模板正文 SHALL 包含一个 `` 段(位于 `` 段尾、`` 段之前),明确以下编排步骤,用户每次同意 propose 时由 agent 用 Bash 执行: + +1. **non-git 项目自检**:`git -C {{mainProjectPath}} rev-parse --is-inside-work-tree`;失败时跳过 worktree 编排,直接以 `targetPath: {{mainProjectPath}}` 调 create-proposal。 +2. **维护主仓库 .gitignore**(仅首次需要):检查并追加 `.worktrees/` 行;追加后 `git add .gitignore && git commit -m "chore: ignore .worktrees"`;commit 失败时把 stderr 完整复述给用户,由用户决定下一步。 +3. **创建 worktree**:`git -C {{mainProjectPath}} worktree add .worktrees/ -b proposal/`;记录绝对路径 `{{mainProjectPath}}/.worktrees/`;失败(含 changeName 重名)把 stderr 完整复述给用户。 +4. **调 create-proposal**:`mcp__fyllo_specs__create-proposal targetPath=`,工具内部不创建 worktree、不修改 git 状态。 +5. **多 change 语义**:同一 chat session 允许孵化多个 change,每个 change 独立 worktree;用户表述模糊(例 "刚才那个 change")且历史中存在多个 worktreePath 时,agent 必须先反问目标。 +6. **路径口径**:chat session cwd 仍为主仓库;后续 chat 内对 worktree artifacts 的 Read / Edit 必须用绝对路径。 + +`` 段 SHALL 不出现 `git merge` / `git push` / `git worktree remove` / `git branch -d` 等任何 archive 阶段才会用到的命令——chat 阶段只负责创建 worktree,清理工作由 P4 的 archive 阶段编排。 + +`` 段 SHALL 不指示 agent 主动调用 `mcp__fyllo_specs__apply-change` 或 `mcp__fyllo_specs__archive-change`——chat 阶段的核心职责未变,仍然在 propose 后等待用户进入 Apply / Archive 阶段。 + +#### Scenario: chat reminder 包含 worktree 编排 + +- **WHEN** 主进程为 chat owner 渲染 system-reminder 文本 +- **THEN** 文本中包含 `` 与 `` 两个标签 +- **AND** 标签之间的内文包含 `git worktree add` 字符串 +- **AND** 标签之间的内文包含 `mcp__fyllo_specs__create-proposal` 与 `targetPath` 字符串 +- **AND** 标签之间的内文不包含 `git merge` / `git worktree remove` / `git branch -d` 字符串 + +#### Scenario: chat reminder 不修改既有 chat stage 行为约束 + +- **WHEN** 主进程为 chat owner 渲染 system-reminder 文本 +- **THEN** 文本仍含 `` / `` / `` / `` 段 +- **AND** `` 段中"MUST NOT modify code directly" / "MUST ask at most one question per turn" / "MUST obtain explicit user consent before calling create-proposal" 等既有 SHALL 全部保留 +- **AND** `` 段位于 `` 段尾、`` 段之前 + +### Requirement: apply reminder 暴露 worktreePath + +`apply.txt` system-reminder 模板正文 SHALL 包含一个 `` 段(位于 `` 段之后、`` 段之前),向 agent 暴露当前 stage 的工作目录: + +- 渲染 `{{worktreePath}}` 占位符(取自 `SystemReminderContext.worktreePath`,undefined 时自动渲染为空字符串)。 +- 文本中明确说明"空字符串表示当前 stage 的 cwd 是主仓库"。 +- 文本中明确说明"业务代码改动产生的 commit 由 agent 自己完成;archive 阶段不会替你 commit 业务代码"。 + +#### Scenario: apply reminder 含 worktreePath 占位符段 + +- **WHEN** 主进程为 apply owner 渲染 system-reminder 文本 +- **AND** ApplyRunMeta.worktreePath 为非空字符串 `/.worktrees/foo` +- **THEN** 渲染后的文本包含 `` 标签 +- **AND** 标签之间的内文包含字面量 `/.worktrees/foo`(`{{worktreePath}}` 已替换) + +#### Scenario: apply reminder worktreePath 为空时显式说明含义 + +- **WHEN** 主进程为 apply owner 渲染 system-reminder 文本 +- **AND** ApplyRunMeta.worktreePath 为 `undefined`(旧 ApplyRunMeta 或 P3 未启用) +- **THEN** 渲染后的文本仍包含 `` 标签 +- **AND** `{{worktreePath}}` 占位符渲染为空字符串 +- **AND** 标签之间的内文包含明确叙述:"空字符串"或"空值"代表主仓库 cwd + +#### Scenario: apply reminder 仍保留既有 apply stage 行为约束 + +- **WHEN** 主进程为 apply owner 渲染 system-reminder 文本 +- **THEN** 文本仍含 `` / `` / `` / `` 段 +- **AND** 现有 SHALL("MUST read state.contextFiles"、"MUST work one task at a time" 等)全部保留 + +## MODIFIED Requirements + +### Requirement: 模板变量白名单与 sanitize + +系统 SHALL 使用文本模板(每个 owner 一份 `.md` 或 `.txt` 文件)配合白名单变量插值生成 reminder 正文。允许的变量名 SHALL 限定为 `{{changeId}}`、`{{stageIndex}}`、`{{runId}}`、`{{projectPath}}`、`{{worktreePath}}`、`{{mainProjectPath}}`;其他 `{{...}}` 占位符保持字面量不替换。 + +`{{worktreePath}}` 与 `{{mainProjectPath}}` 的取值规则: + +- `{{worktreePath}}` 取自 `SystemReminderContext.worktreePath`;为 `undefined` 时按 sanitize 流程渲染为空字符串 `""`。 +- `{{mainProjectPath}}` 是 `SystemReminderContext.projectPath` 的别名(值完全等同),仅作为模板叙述的语义化变量名,方便在 worktree 编排段落中明确区分"主仓库路径"与"worktree 路径"。 + +若任一白名单变量的实际值包含 `<` 或 `>` 字符,provider SHALL 返回 `null`(跳过该 session 的 reminder 注入),并通过 `logger.warn` 记录。日志字段 SHALL 至少包含 `owner`、被拒字段名、`fylloSessionId`。 + +系统 SHALL 提供内部 util `wrapAsSystemReminder(body: string): string`,以 `\n{body}\n` 包裹正文。若 `body` 字面量已包含 `` 或 `` 字符串,`wrapAsSystemReminder` SHALL 抛 `Error`(开发期即暴露模板错误,不做静默 sanitize)。该 util 不对外导出给 IPC handler。 + +#### Scenario: 白名单变量被替换 + +- **WHEN** provider 读取模板,上下文提供 `changeId = "add-foo-bar"` +- **THEN** 模板中的 `{{changeId}}` 被替换为 `"add-foo-bar"` + +#### Scenario: 非白名单占位符保留字面量 + +- **WHEN** 模板中出现 `{{unknownField}}` +- **THEN** 渲染后仍为 `{{unknownField}}` 字符串 + +#### Scenario: worktreePath 占位符替换 + +- **WHEN** provider 读取模板,上下文 `worktreePath = "/.worktrees/foo"` +- **THEN** 模板中的 `{{worktreePath}}` 被替换为该字符串字面量 + +#### Scenario: worktreePath 为 undefined 时渲染空字符串 + +- **WHEN** provider 读取模板,上下文 `worktreePath` 为 `undefined` +- **THEN** 模板中的 `{{worktreePath}}` 被替换为空字符串 + +#### Scenario: mainProjectPath 是 projectPath 别名 + +- **WHEN** provider 读取模板,上下文 `projectPath = "/Users/foo/myapp"` +- **THEN** 模板中的 `{{mainProjectPath}}` 被替换为 `/Users/foo/myapp` +- **AND** 模板中同时存在的 `{{projectPath}}` 也被替换为相同字符串 + +#### Scenario: 变量值含尖括号时跳过注入 + +- **WHEN** 任一白名单变量(包含 `worktreePath` / `mainProjectPath`)的值含 `<` 或 `>` 字符 +- **THEN** provider 返回 `null` +- **AND** 写入 `logger.warn` 日志,至少包含 owner、被拒字段名、`fylloSessionId` + +#### Scenario: 模板正文已含 system-reminder 标签时抛错 + +- **WHEN** `wrapAsSystemReminder(body)` 被调用,`body` 字面量包含 `` 或 `` 字符串 +- **THEN** 抛 `Error` +- **AND** provider 将该异常冒泡(非 sanitize 能处理的 user 输入,是模板 bug) diff --git a/openspec/changes/add-chat-worktree-orchestration/tasks.md b/openspec/changes/add-chat-worktree-orchestration/tasks.md new file mode 100644 index 00000000..ff058fca --- /dev/null +++ b/openspec/changes/add-chat-worktree-orchestration/tasks.md @@ -0,0 +1,65 @@ +## 1. 模板变量白名单扩展 + +- [ ] 1.1 修改 `electron/main/services/chat/system-reminder/providers/shared.ts`:将 `ALLOWED_VARIABLES` 数组从 `["changeId", "stageIndex", "runId", "projectPath"]` 扩展为 `["changeId", "stageIndex", "runId", "projectPath", "worktreePath", "mainProjectPath"]`;同步更新 `AllowedVariable` 类型推导。 +- [ ] 1.2 在同一文件的 `getVariableValue` 函数中,为新增的两个字段补 case: + - `case "worktreePath": return ctx.worktreePath;` + - `case "mainProjectPath": return ctx.projectPath;`(别名,与 `projectPath` 取同一字段) +- [ ] 1.3 验收:`pnpm typecheck` 通过;`grep "ALLOWED_VARIABLES" providers/shared.ts` 输出含全部 6 个字段。 + +## 2. chat.txt 模板新增 worktree 编排段 + +- [ ] 2.1 修改 `electron/main/services/chat/system-reminder/templates/chat.txt`:在 `` 闭合标签之前、`` 开始标签之前,插入完整的 `` 段。段落文本严格按 design.md 第 "chat.txt worktree 编排子段" 节的设计文本写入(含 6 步编排:non-git 自检 / .gitignore 维护 / worktree add / 调 create-proposal / 多 change 语义 / 路径口径)。 +- [ ] 2.2 文本中所有 `{{mainProjectPath}}` 占位符必须严格使用此名称(不要写成 `{{projectPath}}`,避免与 `` 段中既有的 `projectPath` 叙述混淆);`` 用尖括号包裹的字面量保留(让 agent 知道这是占位符)。 +- [ ] 2.3 验收:手动启动 FylloCode(`pnpm dev`)后,新建 chat 触发首次 prompt,从主进程 logger 中确认 chat reminder 文本含 `` 段;用户在 chat 内确认 propose 后,agent 调用 Bash 执行 `git worktree add ...`,并把 `targetPath` 设为 `/.worktrees/` 调 `mcp__fyllo_specs__create-proposal`,工具返回成功。 + +## 3. apply.txt 模板新增 worktree 段 + +- [ ] 3.1 修改 `electron/main/services/chat/system-reminder/templates/apply.txt`:在 `` 闭合标签之后、`` 开始标签之前,插入完整的 `` 段。段落文本严格按 design.md 第 "apply.txt worktree 子段" 节的设计文本写入。 +- [ ] 3.2 文本必须含 `{{worktreePath}}` 与 `{{mainProjectPath}}` 两个占位符;明确说明"空字符串代表当前 stage 的 cwd 是主仓库";明确说明"业务代码改动产生的 commit 由 agent 自己完成"。 +- [ ] 3.3 验收:在 FylloCode 仓库本地,先用 P1 完成的 typecheck 通过的代码跑一次 stage stream(ApplyRunMeta.worktreePath 此时仍为 undefined),从主进程 logger 中确认 apply reminder 文本含 `` 段,且 `{{worktreePath}}` 渲染为空字符串。 + +## 4. 模板渲染单测扩展 + +- [ ] 4.1 在 `electron/main/services/chat/system-reminder/providers/__tests__/`(如不存在则新建目录)创建 `shared.spec.ts`,覆盖: + - 4.1.1 `worktreePath` 占位符:传入 `worktreePath: "/abs/.worktrees/foo"` → 渲染后字符串包含 `/abs/.worktrees/foo`。 + - 4.1.2 `worktreePath` 为 `undefined` → 渲染后 `{{worktreePath}}` 被替换为空字符串。 + - 4.1.3 `mainProjectPath` 占位符:传入 `projectPath: "/abs/myapp"` → `{{mainProjectPath}}` 渲染为 `/abs/myapp`,且与同模板内 `{{projectPath}}` 渲染结果完全一致。 + - 4.1.4 `worktreePath` 含 `<` 字符 → `renderSystemReminderTemplate` 返回 `null`;`logger.warn` 被调用且字段含 `worktreePath`、对应 `owner`、`fylloSessionId`。 + - 4.1.5 `mainProjectPath` 含 `>` 字符 → `renderSystemReminderTemplate` 返回 `null`;同样命中告警。 + - 4.1.6 非白名单占位符 `{{otherField}}` → 渲染后保持字面量 `{{otherField}}`。 +- [ ] 4.2 验收:`pnpm test electron/main/services/chat/system-reminder` 全过;新增的 shared.spec.ts 6 条用例全过。 + +## 5. 模板正文不会泄漏 system-reminder 标签 + +- [ ] 5.1 在新建的 `shared.spec.ts` 增加一条用例:模拟将 chat.txt 完整文本作为 `body` 传入 `wrapAsSystemReminder`,断言不抛错(即模板正文中**没有**字面量 `` 或 `` 字符串)。 +- [ ] 5.2 同理对 apply.txt 跑一次 `wrapAsSystemReminder` 不抛错的断言。 +- [ ] 5.3 验收:上述两条用例通过;如果未来有人在 chat.txt / apply.txt 中误写 ``,单测会立刻 fail。 + +## 6. dogfood 与零回归验证 + +- [ ] 6.1 在 FylloCode 自身仓库(git 项目)`pnpm dev` 启动开发环境;新建一个 chat session,让 agent 走完一次 propose 流程,确认: + - 6.1.1 主仓库 `.gitignore` 出现 `.worktrees/` 行(如原本不存在);该行通过一次 `chore: ignore .worktrees` commit 落到 main 当前分支。 + - 6.1.2 `/.worktrees//` 目录被创建;`git worktree list` 输出含该路径;分支名为 `proposal/`。 + - 6.1.3 OpenSpec change scaffold 出现在 worktree 内的 `openspec/changes//`,**不在**主仓库的 `openspec/changes/`。 + - 6.1.4 chat session 自身 cwd 仍是主仓库(agent Read 主仓库相对路径文件不报错)。 +- [ ] 6.2 在同一 chat session 内继续,让 agent 孵化第二个独立 change(不同 changeName);确认: + - 6.2.1 主仓库再多一个 worktree(路径 `/.worktrees/<另一个 changeName>/`)。 + - 6.2.2 之前的第一个 worktree 与 artifacts 完整保留。 + - 6.2.3 用户用模糊语言("刚才那个 change 加一段 design")追问时,agent 主动反问目标 worktree 而非自己猜。 +- [ ] 6.3 用一个非 git 项目(用户在 FylloCode 创建的 `template: "empty"` 项目),跑一次 chat → propose 流程;确认 agent 的 `git rev-parse --is-inside-work-tree` 自检失败后跳过 worktree 编排;create-proposal 调用使用 `targetPath: ` 即主仓库根;OpenSpec change 创建在 `/openspec/changes//`。 +- [ ] 6.4 触发一次 apply(在 P1 已完成的 ApplyRunMeta 字段下,worktreePath 仍为 undefined),从 logger 看 apply reminder 含 `` 段且 worktreePath 渲染为空——行为与 P1 一致,无回归。 +- [ ] 6.5 `pnpm build` 通过、`pnpm lint` 不引入新告警、`pnpm typecheck` 通过。 +- [ ] 6.6 验收:6.1–6.5 全部通过;新建一个 PR 或 commit 描述记录 dogfood 结果。 + +## 7. 文档与下游对齐 + +- [ ] 7.1 修改 `mcp-servers/fyllo-specs/src/prompts/create-proposal.md`:在文档"Input"段或开头注释中说明 `targetPath` 的推荐值—— + - git 项目:worktree 绝对路径(具体由 chat.txt system-reminder 引导 agent 创建并传入)。 + - non-git 项目:主仓库绝对路径(即 `FYLLO_PROJECT_PATH`)。 +- [ ] 7.2 验收:调整后 `pnpm test mcp-servers/fyllo-specs/__tests__/prompts.test.ts` 仍通过(如该测试断言文本片段,则同步更新断言)。 + +## 8. 验收总闸 + +- [ ] 8.1 1.1 - 7.2 全部勾选完成。 +- [ ] 8.2 OpenSpec change 状态从 `applying` 准备进入 archive;archive 阶段(P4)尚未实现,本次 archive 仍按当前 `proposal-archive-action` spec 行为执行(agent 自己 commit + 走完归档移动;不做 merge / worktree remove)。 +- [ ] 8.3 在 P3 / P4 落地之前,本次 archive **不会**自动清理 `.worktrees//` 目录——这部分留给 P4。本任务的 archive 步骤只负责让 OpenSpec 文件归档与 commit 落到 worktree 分支即可,worktree 目录保留在磁盘上属于预期。 diff --git a/openspec/changes/add-multi-worktree-foundation/.openspec.yaml b/openspec/changes/add-multi-worktree-foundation/.openspec.yaml new file mode 100644 index 00000000..65b9a4e8 --- /dev/null +++ b/openspec/changes/add-multi-worktree-foundation/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-05-19T00:00:00.000Z +status: draft diff --git a/openspec/changes/add-multi-worktree-foundation/design.md b/openspec/changes/add-multi-worktree-foundation/design.md new file mode 100644 index 00000000..b445e3fe --- /dev/null +++ b/openspec/changes/add-multi-worktree-foundation/design.md @@ -0,0 +1,183 @@ +## Context + +FylloCode 当前所有 ACP session(chat / apply / archive)都用 `cwd: projectPath` 启动(`electron/main/ipc/chat.ts:133`、`electron/main/ipc/proposal-apply.ts:131,334`),即主仓库根目录。OpenSpec change artifacts 通过 `fyllo-specs` MCP 落到 `/openspec/changes//`。fyllo-specs MCP 子进程通过 `bundled-mcp-servers.ts:33` 注入 `FYLLO_PROJECT_PATH=`,工具内部用 `resolveProjectRoot()` 拿到该值作为唯一 projectRoot 来源。 + +本次(P1)只为后续 multi-worktree 工作流(P2: chat 编排建 worktree、P3: list 双源扫描、P4: archive 编排收尾)打地基: + +- 主进程 ApplyRunMeta / ProposalMeta 增加 `worktreePath?` 字段(值始终为 `undefined`)。 +- ACP cwd 改为 `runMeta.worktreePath ?? projectPath` —— 字段为空时行为完全等价于现状。 +- MCP 工具入参 `targetPath` 必填,工具不再用 env fallback;P1 阶段 agent 仍传 `$FYLLO_PROJECT_PATH`(即主仓库),到 P2 切换为 worktreePath。 + +P1 不引入任何用户可见行为变化,目的是让 P2/P3/P4 能基于这套契约逐步增量上线,避免一次性大改。 + +**关键事实** + +- ACP session cwd 在 `connection.newSession({ cwd, mcpServers })` 时锁定,整个 session 不可改。 +- MCP 子进程通过 stdio 启动,父进程通过 env 注入上下文(参见 `bundled-mcp-servers.ts`)。 +- fyllo-specs 当前 4 个 tool 中 `explore` / `apply-change` / `archive-change` 入参不含 `targetPath`,`create-proposal` 同样没有;都靠 `resolveProjectRoot()` 读 `FYLLO_PROJECT_PATH`。 +- `ProposalMeta` / `ApplyRunMeta` 两个类型在 `shared/types/proposal.ts` 定义;当前没有 worktree 相关字段。 +- 现有 `loadApplyRunMeta` 反序列化使用 `JSON.parse`,对未知字段忽略;新增可选字段时旧文件加载不会出错。 + +## Goals / Non-Goals + +**Goals** + +- 主进程具备承载 worktreePath 的数据契约(ProposalMeta / ApplyRunMeta / SystemReminderContext)。 +- ACP cwd 取值具备 worktreePath fallback 路径,且当字段为空时行为完全等价于改造前。 +- fyllo-specs MCP 4 个 tool 入参 `targetPath` 必填,校验路径合法性,工具内部 projectRoot 取自该参数;FYLLO_PROJECT_PATH 仅用于合法性校验。 +- 单测覆盖关键分支:旧 run.json 兼容、targetPath 各类校验场景、cwd fallback。 + +**Non-Goals** + +- 不实现 git worktree 创建、移动、删除等任何 git 操作(P2/P4)。 +- 不修改 system-reminder 模板正文(chat.txt / apply.txt / archive.txt 的 worktree 编排段落留给 P2/P4)。 +- 不修改 `proposal:list` 扫描逻辑(双源扫描 + 卡片标记留给 P3)。 +- 不修改前端任何卡片显示(P3)。 +- 不引入新 capability spec —— `proposal-multi-worktree` 这份新 spec 留给 P2 提交。 + +## Decisions + +### D1:targetPath 设为必填,FYLLO_PROJECT_PATH 退为标识 + +**选择**:fyllo-specs MCP 4 个 tool 入参全部把 `targetPath: string` 设为必填;`FYLLO_PROJECT_PATH` env 仅用于:① 校验 targetPath 必须是该 main repo 的合法 worktree(通过 `git -C $FYLLO_PROJECT_PATH worktree list --porcelain` 解析);② 后续 P2/P4 阶段定位 main repo(如维护 .gitignore)。 + +**理由**: + +- MCP 没有"老调用方"——所有调用方就是 ACP agent,每次会话开始时通过 tool schema 看到当前形态;可选会让 agent 在缺省语义和显式传值之间犹豫,徒增不确定性。 +- 必填强制让 agent 时刻意识到"此次操作针对哪个工作目录",对多 change 同 chat 场景尤其重要(虽然 P1 阶段还无法发挥这个作用,但契约要先到位)。 +- 校验 `targetPath ∈ git -C $FYLLO_PROJECT_PATH worktree list` 防止 agent 把 artifacts 写到任意目录。 + +**P1 阶段的过渡形态**:agent 在 P1 上线后仍只看到主仓库一个 worktree,所以 `targetPath` 始终传 `$FYLLO_PROJECT_PATH`(main repo 自身在 `worktree list` 输出中也算一条)。P2 上线后 chat.txt 编排引导 agent 切换为 worktreePath。 + +### D2:cwd 用 nullish-coalescing fallback,不引入分支配置 + +**选择**:`proposal:stageStream` 与 `proposal:archive` 创建 `AcpSession` 时 cwd 取值改为 `runMeta.worktreePath ?? projectPath`。`projectPath` 字段(即主仓库根)保持原样传入,用于持久化目录计算与 reminderContext。 + +**理由**: + +- 单一表达式 + `??` 兜底,行为分支极简;P1 阶段 worktreePath 始终 undefined,等价于现状。 +- 不引入 feature flag 或独立配置项:worktreePath 字段值就是开关。 +- 不修改 `chat.ts` 的 cwd(chat session 永远绑定主仓库,这是 multi-worktree 工作流的整体决策;P1 不动 chat)。 + +### D3:reminderContext 增加 worktreePath(值可为 undefined) + +**选择**:`SystemReminderContext` 类型增加可选字段 `worktreePath?: string`;stage stream / archive handler 在构造 reminderContext 时把 `runMeta.worktreePath` 透传进来。P1 模板(apply.txt / archive.txt)正文不引用该字段(不渲染、不显示),仅做契约预留。 + +**理由**: + +- system-reminder 模板的字段消费由 P2/P4 完成;P1 只补字段。 +- 提前到位避免 P2/P4 上线时同时改两边导致 surface 增大。 + +### D4:ApplyRunMeta / ProposalMeta 字段为可选,向后兼容 + +**选择**:`ApplyRunMeta.worktreePath?` 与 `ProposalMeta.worktreePath?` 都为可选字段。 + +**反序列化兼容**:`loadApplyRunMeta` 当前直接 `JSON.parse` 已读 ApplyRunMeta,未知字段忽略;缺字段反序列化为 `undefined`。无需迁移脚本。 + +**序列化**:`JSON.stringify` 对 `undefined` 字段省略,旧 run.json 升级后写盘看不到 `worktreePath: null` 噪声。 + +#### 选项对比 + +| 方案 | 优点 | 痛点 | +| -------------------- | ----------------------------- | ---------------------------------------------- | +| 可选字段(本设计) | 旧文件零迁移;JSON 序列化干净 | 类型上多一层 `?:` | +| 必填默认空串 | 类型语义稍简单 | 旧文件加载需要兜底逻辑;空串与"主仓库"语义不同 | +| 引入新 v2 元数据文件 | 强类型版本控制 | 工程复杂度高于本次范围 | + +### D5:targetPath 校验通过 spawnSync 调起 git + +**选择**:在 `mcp-servers/fyllo-specs/src/utils/project-root.ts` 新增 `validateTargetPath(targetPath: string): { ok: boolean; rawOutput?: string; error?: string }`,内部使用 Node `child_process.spawnSync("git", ["-C", FYLLO_PROJECT_PATH, "worktree", "list", "--porcelain"])` 执行并解析输出。 + +**输出解析**:`worktree list --porcelain` 每条记录由空行分隔,每个 worktree 的第一行格式为 `worktree `。提取所有此类路径,比较 `path.resolve(targetPath)` 是否在集合中。 + +**non-git 项目兜底**:当 `/.git` 不存在时,spawn `git worktree list` 会以非 0 退出。`validateTargetPath` 在 spawn 失败时回退到"`targetPath === FYLLO_PROJECT_PATH` 即合法"的旧规则。这保证 non-git 项目(`template: "empty"`)行为完全不变。 + +#### 选项对比 + +| 方案 | 优点 | 痛点 | +| ----------------------------- | ----------------------------- | -------------------------------------- | +| spawnSync git(本设计) | 无新依赖;与系统 git 行为一致 | 子进程开销(每次 MCP 调用 1 次 spawn) | +| simple-git 库 | 高级 API | 新依赖;P1 阶段还用不到丰富 API | +| 直接读 `.git/worktrees/` 目录 | 无 spawn | 解析 git 内部数据格式不稳定 | + +`spawnSync` 开销在 MCP 工具调用层面忽略不计(agent 一次调用本来就走 stdio + JSON 解析)。 + +### D6:worktreePath 字段在 P1 阶段始终为 undefined + +**选择**:P1 落地后,`apply-run-service.ts` 创建 run 时虽然从 `ProposalMeta.worktreePath` 透传,但 `ProposalMeta` 自身的 worktreePath 在 P3(list 双源扫描)之前永远是 `undefined`,所以 ApplyRunMeta 实际写入也是 `undefined`。stage stream / archive 的 cwd fallback 始终命中 `projectPath`,行为等价于改造前。 + +**理由**:契约上字段就位,但来源端尚未启用,避免在 P3 完成前出现"半就位"的混乱状态。 + +## Architecture + +### 数据流 + +```mermaid +flowchart LR + P[ProposalMeta
worktreePath?] --> A["proposal:apply
透传 worktreePath"] + A --> R[ApplyRunMeta
worktreePath?] + R --> S["stage stream
cwd = worktreePath ?? projectPath"] + R --> AR["archive
cwd = worktreePath ?? projectPath"] + S --> SC[AcpSession
cwd] + AR --> AC[AcpSession
cwd] + S --> RC1[SystemReminderContext
worktreePath?] + AR --> RC2[SystemReminderContext
worktreePath?] +``` + +P1 阶段所有 `worktreePath?` 字段值均为 `undefined`;cwd fallback 命中 `projectPath`;模板不渲染 worktreePath。 + +### MCP tool 入参形态 + +```mermaid +flowchart LR + AG[ACP agent] -->|"changeName + targetPath"| TOOL[fyllo-specs tool] + TOOL -->|validateTargetPath| GIT["spawnSync
git worktree list"] + GIT -->|输出| PARSE[parse worktree paths] + PARSE -->|targetPath ∈ paths?| OK[ok] + PARSE -->|否| ERR[errors:
InvalidTargetPath
+ rawOutput] + OK --> RUN[runTool
projectRoot = targetPath] + ERR --> ABORT[abort,
no fs writes] +``` + +### 路径口径 + +| 概念 | P1 阶段值 | +| ------------------------- | --------------------------------------------------------------------------------- | +| `mainRepo` | `FYLLO_PROJECT_PATH` env,子进程注入 | +| `targetPath` | agent 传入;P1 阶段始终等于 `mainRepo` | +| `worktreePath` (类型字段) | 始终 `undefined` | +| ACP cwd | `runMeta.worktreePath ?? projectPath`,fallback 到 `projectPath`(即 `mainRepo`) | + +所有路径在比较前 `path.resolve` 规范化(剥离 trailing slash、解析符号链接)。 + +## Risks / Trade-offs + +- **MCP 工具入参 break**(中):`targetPath` 必填使工具描述与行为发生破坏性变化。 + - 缓解:唯一调用方是同进程 ACP agent;agent 在 P1 上线后第一次调工具时会读到新 schema 并按 schema 调用;不会出现外部"老调用方"残留问题。 + - 验证:FylloCode 启动后立即跑一次 chat → create-proposal → apply → archive 流程,确认 agent 正确传 `targetPath: `。 + +- **spawnSync git 在某些环境慢**(低):MCP 每次调工具会触发一次 git spawn。 + - 缓解:spawn 开销在 ACP 工具调用整体耗时中可忽略;不缓存(避免缓存与实际 worktree 状态错位的隐患)。 + +- **non-git 项目误判**(低):当 `/.git` 不存在,spawn 会非 0 退出。 + - 缓解:D5 已说明 fallback 规则——spawn 失败时退化为 `targetPath === FYLLO_PROJECT_PATH` 即合法,等价于现状。 + - 单测覆盖:`tools/__tests__/explore.spec.ts` 等加分支测 spawn 失败场景。 + +- **类型字段误用**(低):未来 P3 上线后,主进程代码可能忘记把 worktreePath 透传。 + - 缓解:本次单测在 `apply-run-service.ts` 加用例:从 ProposalMeta.worktreePath 透传到 ApplyRunMeta.worktreePath;以及 stage stream cwd 的 fallback 测试。 + +## Migration Plan + +1. **shared/types/proposal.ts**:增加 `worktreePath?: string` 到 ProposalMeta、ApplyRunMeta。typecheck 通过。 +2. **mcp-servers/fyllo-specs**:在 `utils/project-root.ts` 新增 `validateTargetPath`;4 个 tool 的 input schema 增加必填 `targetPath`;handler 内部 `projectRoot = path.resolve(input.targetPath)`,去掉 `resolveProjectRoot()` 的 fallback 调用。 +3. **electron/main/services/proposal/apply-run-service.ts**:创建 ApplyRunMeta 时透传 `proposalMeta.worktreePath`。 +4. **electron/main/ipc/proposal-apply.ts**:stage stream 与 archive 的 `cwd: projectPath` 改成 `cwd: runMeta.worktreePath ?? projectPath`;reminderContext 增加 `worktreePath: runMeta.worktreePath` 字段。 +5. **electron/main/services/chat/system-reminder/types.ts**:`SystemReminderContext.worktreePath?` 字段加入。 +6. 单测/集成测试覆盖:旧 run.json 加载、targetPath 各类校验场景、cwd fallback、ProposalMeta 序列化。 +7. dogfood:在 FylloCode 仓库本地跑 chat → create-proposal → apply → archive 一次,确认行为零回归。 + +**回滚**:把 `runMeta.worktreePath ?? projectPath` 改回 `projectPath`;MCP 工具 `targetPath` 改为可选并 fallback `resolveProjectRoot()`。代码物理改动可控。 + +## Open Questions + +无。所有决策在本设计内已锁定。 diff --git a/openspec/changes/add-multi-worktree-foundation/proposal.md b/openspec/changes/add-multi-worktree-foundation/proposal.md new file mode 100644 index 00000000..eb7b0ae2 --- /dev/null +++ b/openspec/changes/add-multi-worktree-foundation/proposal.md @@ -0,0 +1,54 @@ +## Why + +为了支持后续 multi-worktree 工作流(chat 阶段在 linked worktree 创建 proposal、apply/archive 在 linked worktree 执行),主进程的数据契约、ACP cwd 取值、MCP 工具入参都需要先具备承载 worktreePath 的能力。本次只做基建:所有新字段对外保持空值/默认值,行为完全等价于改造前;为后续 P2(chat 编排)/ P3(list 双源扫描)/ P4(archive 编排)打地基。 + +## What Changes + +- `ProposalMeta` 增加可选字段 `worktreePath?: string`(来源端暂时永远为 `undefined`,待 P3 启用扫描后才会写入)。 +- `ApplyRunMeta` 增加可选字段 `worktreePath?: string`;`proposal:apply` 创建 run 时从对应 `ProposalMeta.worktreePath` 透传;旧 run.json 缺字段时反序列化为 `undefined`。 +- `proposal:stageStream` / `proposal:archive` 创建 `AcpSession` 时 `cwd` 取 `runMeta.worktreePath ?? projectPath`;`projectPath` 字段不变(始终是主仓库路径)。 +- `SystemReminderContext` 增加可选 `worktreePath?: string` 字段,由 stage stream / archive handler 在构造 reminderContext 时传入(P1 阶段值始终为 `undefined`)。 +- **BREAKING**(仅对 MCP 内部调用方):fyllo-specs MCP 4 个 tool(`explore` / `create-proposal` / `apply-change` / `archive-change`)入参增加 `targetPath: string` 必填字段: + - 必须是绝对路径 + - 必须出现在 `git -C $FYLLO_PROJECT_PATH worktree list --porcelain` 输出中(main repo 自身合法) + - 工具内部以 `targetPath` 作为 projectRoot 计算 OpenSpec 路径,不再调用 `resolveProjectRoot()` + - 校验失败时返回 `state.errors` 包含原始 `worktree list` 输出,且 SHALL NOT 执行任何 fs 副作用 +- non-git 项目(`/.git` 不存在):`targetPath === ` 始终视为合法,工具行为等价于改造前;本能力不为 non-git 项目引入新概念。 +- `FYLLO_PROJECT_PATH` env 退化为"main repo 标识"——仅用于 worktree 合法性校验;不再作为 `targetPath` 缺省值。 +- 单测覆盖:`targetPath` 校验、cwd fallback、ApplyRunMeta 序列化兼容旧文件、ProposalMeta 序列化忽略 `undefined`。 + +## Capabilities + +### New Capabilities + +无。本次基建不引入新 capability;worktree 生命周期、命名、隔离语义都留给 P2/P3/P4。 + +### Modified Capabilities + +- `fyllo-specs-mcp`: 4 个 tool 入参追加 `targetPath: string` 必填,并把 projectRoot 来源从 `resolveProjectRoot()` 改为 `targetPath`;增加 `targetPath` 合法性校验;`FYLLO_PROJECT_PATH` 仅用于校验。 +- `proposal-apply-run`: ApplyRunMeta 增加 `worktreePath?`;apply 创建 run 时透传;stage stream / archive 的 ACP `cwd` 改为 `runMeta.worktreePath ?? projectPath`;reminderContext 携带 worktreePath。 + +## Impact + +**代码** + +- `mcp-servers/fyllo-specs/src/tools/{explore,create-proposal,apply-change,archive-change}.ts`:入参 schema、handler 内部 projectRoot 来源 +- `mcp-servers/fyllo-specs/src/utils/project-root.ts`:新增 `validateTargetPath(targetPath: string): { ok, rawOutput?, error? }` 工具 +- `shared/types/proposal.ts`:ProposalMeta / ApplyRunMeta 字段扩展 +- `electron/main/services/proposal/apply-run-service.ts`:创建 run 时透传 worktreePath(来源仍为 undefined) +- `electron/main/ipc/proposal-apply.ts`:stage stream(`cwd` 与 `reminderContext`)、archive(`cwd` 与 `reminderContext`)两处 +- `electron/main/services/chat/system-reminder/types.ts`:`SystemReminderContext.worktreePath?` +- `electron/main/domain/proposal/openspec-reader.ts`:写入 ProposalMeta 时字段保留 undefined(不修改扫描逻辑——扫描扩展属于 P3) + +**依赖** + +无新增依赖;仍只用 Node 内置 `child_process` 调起 `git -C $FYLLO_PROJECT_PATH worktree list --porcelain` 进行校验。 + +**风险** + +- MCP `targetPath` 必填属于破坏性入参变更;当前 fyllo-specs MCP 唯一调用方是同进程 ACP agent,不存在外部老调用方。验证:FylloCode 启动后立即跑一次 chat → create-proposal → apply-change → archive-change 流程,确认 agent 在工具描述更新后能正确传 `targetPath`(P1 默认值约定为传 `$FYLLO_PROJECT_PATH`,由 P2 改成 worktreePath)。 +- 旧 ApplyRunMeta JSON 文件缺 `worktreePath` 字段:解析时 `worktreePath` 为 `undefined`,cwd fallback 到 `projectPath`,行为等价于改造前。 + +**回滚** + +回滚成本低:把 `cwd: runMeta.worktreePath ?? projectPath` 改回 `cwd: projectPath`,删除字段即可;MCP 把 `targetPath` 改回可选并 fallback `resolveProjectRoot()`。 diff --git a/openspec/changes/add-multi-worktree-foundation/specs/fyllo-specs-mcp/spec.md b/openspec/changes/add-multi-worktree-foundation/specs/fyllo-specs-mcp/spec.md new file mode 100644 index 00000000..6b6af50d --- /dev/null +++ b/openspec/changes/add-multi-worktree-foundation/specs/fyllo-specs-mcp/spec.md @@ -0,0 +1,215 @@ +## ADDED Requirements + +### Requirement: 所有 tool 入参必填 targetPath 并校验合法性 + +`fyllo-specs` MCP 的 4 个 tool(`explore` / `create-proposal` / `apply-change` / `archive-change`)SHALL 全部把 `targetPath: string` 设为必填入参(zod schema 中无 `.optional()`、无 `.default(...)`)。 + +工具内部 SHALL 在执行任何 fs 副作用之前对 `targetPath` 进行合法性校验: + +1. **绝对路径**:`path.isAbsolute(targetPath)` 必须为 `true`。 +2. **是 main repo 的合法 worktree**:通过 `child_process.spawnSync("git", ["-C", FYLLO_PROJECT_PATH, "worktree", "list", "--porcelain"], { encoding: "utf8" })` 获取主仓库下所有已注册 worktree 的绝对路径集合(以 `worktree ` 开头的行后跟绝对路径);`path.resolve(targetPath)` 必须出现在该集合中(main repo 自身在 `worktree list` 输出中亦算一条,因此 `targetPath === FYLLO_PROJECT_PATH` 总是合法)。 +3. **non-git 项目兜底**:若 `git worktree list` 子进程退出码非 0(典型情况:`/.git` 不存在,即 `template: "empty"` 项目),SHALL 退化为"`path.resolve(targetPath) === path.resolve(FYLLO_PROJECT_PATH)` 即合法"的旧规则。 + +校验失败时 tool SHALL: + +- 不创建 change、不修改任何文件、不调用任何 git 子进程(除合法性校验本身的 `git worktree list`)。 +- 在 `state.errors` 中追加 `{ type: "InvalidTargetPath", message }` 条目,message 中携带原始 `git worktree list --porcelain` stdout 供 agent 诊断。 +- 仍然按 `runTool` 约定返回 `` + `` 双段文本。 + +工具内部 projectRoot SHALL 取自 `path.resolve(input.targetPath)`,不再从 `resolveProjectRoot()`(`FYLLO_PROJECT_PATH` env)兜底。 + +#### Scenario: 4 个 tool 都拦截缺省 targetPath + +- **WHEN** MCP client 调用 `explore` / `create-proposal` / `apply-change` / `archive-change` 任一,未传 `targetPath` +- **THEN** MCP SDK 在 zod schema 层拦截并返回 `isError: true` +- **AND** error code 等于 `InvalidParams` +- **AND** tool handler 不执行 + +#### Scenario: targetPath 为空字符串拦截 + +- **WHEN** MCP client 调用任一 tool 传入 `targetPath: ""` +- **THEN** zod schema 拦截(`.min(1)` 约束)并返回 `InvalidParams` +- **AND** tool handler 不执行 + +#### Scenario: targetPath 非绝对路径返回 InvalidTargetPath + +- **WHEN** MCP client 调用任一 tool 传入相对路径如 `targetPath: "./.worktrees/foo"` +- **THEN** 响应仍含 `` 与 `` 双段 +- **AND** `state.errors` 包含 `{ type: "InvalidTargetPath", message }` +- **AND** message 文本说明 targetPath 必须是绝对路径 +- **AND** 不调用 git 子进程 +- **AND** 不修改任何文件 + +#### Scenario: targetPath 不在 worktree list 中返回 InvalidTargetPath + +- **WHEN** 调用 `create-proposal` 传入 `targetPath: "/tmp/random-path"` +- **AND** `/.git` 存在 +- **AND** `/tmp/random-path` 不在 `git worktree list --porcelain` 输出中 +- **THEN** `state.errors` 包含 `{ type: "InvalidTargetPath", message }` +- **AND** message 中包含 `git worktree list --porcelain` 的原始 stdout +- **AND** 不创建 change、不修改任何文件 + +#### Scenario: targetPath 等于 FYLLO_PROJECT_PATH 视为合法 + +- **WHEN** 调用任一 tool 传入 `targetPath` 等于 `FYLLO_PROJECT_PATH`(path.resolve 后) +- **AND** `/.git` 存在 +- **THEN** 视为合法(main repo 自身在 worktree list 中也是一条记录) +- **AND** 进入正常 tool 处理逻辑 + +#### Scenario: non-git 项目降级合法 + +- **WHEN** `/.git` 不存在 +- **AND** 调用任一 tool 传入 `targetPath` 等于 `FYLLO_PROJECT_PATH` +- **THEN** `git worktree list` spawn 退出码非 0 +- **AND** 退化规则命中:"targetPath === FYLLO_PROJECT_PATH 即合法" +- **AND** 进入正常 tool 处理逻辑 + +#### Scenario: non-git 项目传入其他路径仍 InvalidTargetPath + +- **WHEN** `/.git` 不存在 +- **AND** 调用任一 tool 传入 `targetPath: "/tmp/elsewhere"` +- **THEN** `state.errors` 包含 `{ type: "InvalidTargetPath", message }` +- **AND** 不修改任何文件 + +#### Scenario: targetPath 路径规范化后比较 + +- **WHEN** 调用任一 tool 传入 `targetPath: + "/"`(含 trailing slash) +- **THEN** 校验通过(path.resolve 剥离 trailing slash 后等于 main repo) + +## MODIFIED Requirements + +### Requirement: explore tool 返回 state + +`explore` tool 接收参数 `{ changeName?: string, targetPath: string, includeInstruction?: boolean }`。`targetPath` 必填,校验规则参见「所有 tool 入参必填 targetPath 并校验合法性」Requirement。tool 内部 projectRoot SHALL 取自 `path.resolve(input.targetPath)`。 + +返回 state 至少包含: + +| 字段 | 类型 | 说明 | +| --------------- | ---------------------------- | ------------------------------------------------------------- | +| `projectRoot` | string | 等于 `path.resolve(input.targetPath)` | +| `schemaName` | string | 当前 `openspec/config.yaml` 的 schema(如 `spec-driven`) | +| `activeChanges` | `{ name, status, schema }[]` | `/openspec/changes/` 下非 archive 的 change 列表 | +| `currentChange` | object \| null | 若入参或上下文命中某 change,返回其 artifact 完成状态 | + +#### Scenario: 无入参列出 active changes + +- **WHEN** 调用 `explore` 不传 `changeName`、传入 `targetPath` 为 main repo +- **THEN** `state.activeChanges` 为当前 `/openspec/changes/*`(排除 `archive/`)的列表 +- **AND** `state.currentChange` 为 `null` +- **AND** `state.projectRoot === path.resolve(input.targetPath)` + +#### Scenario: 传入 changeName 命中已有 change + +- **WHEN** 调用 `explore` 传入存在的 `changeName`、传入合法 `targetPath` +- **THEN** `state.currentChange.artifacts` 列出该 change 各 artifact 的状态 +- **AND** state 中所有路径均基于 `path.resolve(input.targetPath)` + +### Requirement: create-proposal tool 返回 state + +`create-proposal` tool 接收参数 `{ name?: string, description?: string, targetPath: string, includeInstruction?: boolean }`。`targetPath` 必填,校验规则参见「所有 tool 入参必填 targetPath 并校验合法性」Requirement。tool 内部 projectRoot SHALL 取自 `path.resolve(input.targetPath)`。 + +当 `name` 提供且该 change 不存在时,tool SHALL 先调用 `openspec-runtime#createChange(projectRoot, name)` 创建目录并写入初始 `.openspec.yaml { schema, status: "creating" }`,再返回 state。本能力(P1)SHALL NOT 引入任何 git worktree 创建副作用。 + +返回 state 至少包含: + +| 字段 | 类型 | 说明 | +| --------------- | ------------------------------------------------------------------- | ---------------------------------------------- | +| `changeName` | string \| null | 当前目标 change;未指定且无法派生时为 null | +| `schemaName` | string | 如 `spec-driven` | +| `applyRequires` | `string[]` | schema 定义的 apply 前置 artifacts | +| `artifacts` | `{ id, status, outputPath, dependencies, template, instruction }[]` | 每个 artifact 的当前状态与创建所需的模板与指令 | +| `nextArtifact` | string \| null | 下一个应被创建的 artifact id | + +#### Scenario: 新建 change 目录与初始 yaml + +- **WHEN** 调用 `create-proposal` 传入不存在的 `name`、合法 `targetPath` +- **THEN** `/openspec/changes//` 被创建 +- **AND** `.openspec.yaml` 包含 `schema:` 与 `status: creating` +- **AND** 返回 state 中 `changeName === ` +- **AND** 不调用任何 git 子进程(除 targetPath 校验本身) + +#### Scenario: 对已有 change 返回当前 artifact 进度 + +- **WHEN** 调用 `create-proposal` 传入已存在的 `name`、合法 `targetPath` +- **THEN** 不再覆盖已有文件 +- **AND** 返回 state 中 `artifacts` 显示各 artifact 的真实状态(`done`/`ready`/`blocked`) +- **AND** `nextArtifact` 为依赖已满足且尚未 done 的第一个 artifact id(若全部 done,则为 null) + +### Requirement: apply-change tool 返回 state + +`apply-change` tool 接收参数 `{ changeName?: string, targetPath: string, includeInstruction?: boolean }`。`targetPath` 必填,校验规则参见「所有 tool 入参必填 targetPath 并校验合法性」Requirement。tool 内部 projectRoot SHALL 取自 `path.resolve(input.targetPath)`。 + +返回 state 至少包含: + +| 字段 | 类型 | 说明 | +| -------------- | ------------------------------------ | ------------------------------------------------------------------------------------- | +| `changeName` | string | 目标 change(若未传入,为上下文中唯一的活跃 change;否则需在 prompt 指引 agent 选择) | +| `schemaName` | string | 如 `spec-driven` | +| `applyState` | `"ready" \| "blocked" \| "all_done"` | apply 当前状态 | +| `contextFiles` | `Record` | artifact id → 绝对文件路径数组(基于 targetPath,供 agent Read) | +| `tasks` | `{ line, text, done }[]` | 解析自 `tasks.md` 的任务列表 | +| `progress` | `{ total, complete, remaining }` | 任务进度摘要 | + +tool 在 state 中一并更新 `/openspec/changes//.openspec.yaml` 的 `status: applying`(若原状态不是 `applying`)。 + +#### Scenario: contextFiles 路径基于 targetPath + +- **WHEN** 调用 `apply-change` 传入合法 `targetPath`、存在的 `changeName` +- **THEN** `state.contextFiles` 中所有路径以 `path.resolve(input.targetPath)` 开头 + +#### Scenario: 全部 artifacts 已 done 时返回 all_done + +- **WHEN** 调用 `apply-change` 指向一个 artifacts 全部 done 且 tasks 全部勾选的 change +- **THEN** `state.applyState === "all_done"` +- **AND** prompt 文本引导 agent 推荐 archive + +#### Scenario: 有 artifact 未 done 时返回 blocked + +- **WHEN** 调用 `apply-change` 指向仍有 artifact 处于 `ready` 或 `blocked` 的 change +- **THEN** `state.applyState === "blocked"` +- **AND** prompt 文本引导 agent 先补齐 artifact + +### Requirement: archive-change tool 返回 state 并执行归档动作 + +`archive-change` tool 接收参数 `{ changeName?: string, targetPath: string, confirm?: boolean, includeInstruction?: boolean }`。`targetPath` 必填,校验规则参见「所有 tool 入参必填 targetPath 并校验合法性」Requirement。tool 内部 projectRoot SHALL 取自 `path.resolve(input.targetPath)`。 + +默认(`confirm !== true`)SHALL 仅返回归档 preview 状态,不移动任何文件。本能力(P1)SHALL NOT 引入任何 git commit / merge / worktree remove / branch delete 副作用——`archive-change` 仅负责 OpenSpec 文件级归档(CLI 委托)。 + +返回 state 字段(同改造前): + +| 字段 | 类型 | 说明 | +| ------------------ | ---------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `changeName` | string | 目标 change | +| `artifactStatus` | 同 `apply-change.state.artifacts` 简化版 | 用于展示 incomplete 告警 | +| `incompleteTasks` | number | 未勾选 tasks 数量 | +| `deltaSpecSummary` | object \| null | delta specs 与主 specs 的差异摘要 | +| `archiveTarget` | string | 预计的归档目标路径(基于 targetPath)`/openspec/changes/archive/YYYY-MM-DD-` | +| `archiveRawOutput` | string \| null | `confirm: true` 时 `openspec archive` 的 stdout 原文,preview 时为 `null` | +| `conflicts` | `string[]` | 目标路径冲突(若有) | + +当 `confirm === true` 且 `conflicts` 为空时,tool SHALL 委托 `openspec archive --yes` CLI 命令执行归档,CLI cwd 为 `path.resolve(input.targetPath)`。 + +#### Scenario: 预览模式不修改磁盘 + +- **WHEN** 调用 `archive-change` 传入存在的 `changeName`、合法 `targetPath`、不传 `confirm` +- **THEN** 返回 state 中包含 `archiveTarget` 与 `deltaSpecSummary` +- **AND** 磁盘上该 change 目录位置不变 +- **AND** archiveTarget 路径以 `path.resolve(input.targetPath)` 开头 + +#### Scenario: 确认后执行归档 + +- **WHEN** 调用 `archive-change` 传入合法 `targetPath`、`confirm: true` +- **AND** 目标路径不冲突 +- **THEN** 调用 `openspec archive --yes` CLI 执行归档(cwd 为 targetPath) +- **AND** 原 `/openspec/changes//` 被移动至 `/openspec/changes/archive/YYYY-MM-DD-/` +- **AND** 该目录中 `.openspec.yaml` 的 `status` 字段为 `archived` +- **AND** 若 change 包含 delta specs,主 specs 目录(`/openspec/specs/`)被同步更新 +- **AND** tool 不调用任何 git 子进程(除 targetPath 校验本身) + +#### Scenario: 目标冲突时拒绝归档 + +- **WHEN** 调用 `archive-change` 传入合法 `targetPath`、`confirm: true`,目标路径已存在 +- **THEN** state `conflicts` 非空 +- **AND** 不执行任何移动 +- **AND** `state.errors` 为包含 `{ type: "Error", message: "Archive target exists: ..." }` 的非空数组 +- **AND** 响应 `isError` 为 `false` diff --git a/openspec/changes/add-multi-worktree-foundation/specs/proposal-apply-run/spec.md b/openspec/changes/add-multi-worktree-foundation/specs/proposal-apply-run/spec.md new file mode 100644 index 00000000..21410f11 --- /dev/null +++ b/openspec/changes/add-multi-worktree-foundation/specs/proposal-apply-run/spec.md @@ -0,0 +1,235 @@ +## ADDED Requirements + +### Requirement: ApplyRunMeta 携带 worktreePath + +`ApplyRunMeta` SHALL 增加可选字段 `worktreePath?: string`: + +- 类型定义位于 `shared/types/proposal.ts`,字段为 `worktreePath?: string`。 +- `proposal:apply` 创建新 run 时,从对应 `ProposalMeta.worktreePath` 透传该值;`ProposalMeta.worktreePath` 同期为可选字段,本能力(P1)阶段来源端始终写入 `undefined`,待 P3 启用 list 双源扫描后才会写入实际 worktree 绝对路径。 +- 字段值(如有)SHALL 为绝对路径,写入前 `path.resolve` 规范化。 +- 持久化到 `data/projects//apply-runs//run.json`。 +- 旧 run.json 缺该字段时,`loadApplyRunMeta` 反序列化为 `worktreePath: undefined`,主进程行为完全等价于本能力引入前。 +- `JSON.stringify` 默认对 `undefined` 字段省略,新写入 run.json 在该字段为空时不出现 `"worktreePath": null` 或 `"worktreePath": ""` 噪声。 + +#### Scenario: 创建 run 时透传 worktreePath(P1 阶段值为 undefined) + +- **WHEN** ProposalMeta.worktreePath 为 `undefined`(P1 阶段唯一情况) +- **AND** renderer 调用 `proposal:apply` 创建新 run +- **THEN** 持久化的 run.json 中 `worktreePath` 字段缺失(JSON.stringify 省略) +- **AND** 加载该 run.json 后 `runMeta.worktreePath` 为 `undefined` + +#### Scenario: 创建 run 时透传 worktreePath(worktreePath 非空场景) + +- **WHEN** ProposalMeta.worktreePath 为 `/.worktrees/foo` +- **AND** renderer 调用 `proposal:apply` 创建新 run +- **THEN** 持久化的 run.json 中 `worktreePath === /.worktrees/foo`(path.resolve 后字符串相等) + +#### Scenario: 旧 run.json 加载向后兼容 + +- **WHEN** 磁盘上已存在的 run.json 不含 `worktreePath` 字段 +- **AND** 主进程调用 `loadApplyRunMeta` +- **THEN** 返回的 `ApplyRunMeta.worktreePath === undefined` +- **AND** 不抛错 + +#### Scenario: 路径规范化 + +- **WHEN** ProposalMeta.worktreePath 为 `/.worktrees/foo/`(带 trailing slash) +- **AND** apply-run-service 写入 ApplyRunMeta +- **THEN** 持久化的 worktreePath 等于 `path.resolve(/.worktrees/foo/)`,不含 trailing slash + +## MODIFIED Requirements + +### Requirement: Stage 流式执行通过 MessagePort 传输 chunk + +系统 SHALL 在收到 `proposal:stageStream` IPC 时,main 进程根据 `stage.type` 构造 prompt,启动 `AcpSession`,通过 `MessageChannelMain` 将 `SessionEvent` chunk 推给 renderer。 + +prompt 构造规则(策略 Map,按 `stage.type` 分发): + +- `proposal-apply`:`加载 skill fyllo-apply-change,实现 {changeId}` +- 其他 type:抛出错误,code 为 `STAGE_TYPE_NOT_IMPLEMENTED` + +`agentId` SHALL 取自 `stages[stageIndex].agent`。若 `stage.agent` 为空(`undefined` / `null` / 空字符串),handler SHALL 抛 `ipcError(IpcErrorCodes.VALIDATION_ERROR, "stage.agent is required for stage ${stageIndex}")`,且 SHALL NOT 创建 `AcpSession`、SHALL NOT 写入任何 stage 文件。系统 SHALL NOT 维护 workflow / 主进程级 "默认 agentId" 兜底。 + +`AcpSession` 创建参数 SHALL 按以下规则取值: + +- **`cwd`**:`runMeta.worktreePath ?? projectPath`。即 ApplyRunMeta 含 worktreePath 时使用 worktree 绝对路径;缺失或为空字符串时 fallback 到主仓库 `projectPath`。 +- **`projectPath`**:始终为主仓库路径,不随 worktree 变化(用于持久化目录计算等)。 +- **`reminderContext`**:`{ changeId, stageIndex, runId, worktreePath: runMeta.worktreePath }`。`worktreePath` 字段允许为 `undefined`。 + +构造 `AcpSession` 时 SHALL 注入 `ApplyStageAcpSessionStore`(构造参数 `(projectPath, changeId, runId, stageIndex)`)。该 store 的实现: + +- `loadAcpSessionId()`:调用 `loadApplyRunMeta(projectPath, changeId)`,校验 `meta.runId === runId`,返回 `meta.stageAcpSessionIds[stageIndex] ?? null`;若 runId 不一致或 meta 缺失,返回 `null` 并 `logger.warn` 记录 +- `persistAcpSessionId(acpSessionId)`:通过 `updateRunMetaIfCurrent(projectPath, changeId, runId, ...)` 更新 `stageAcpSessionIds[stageIndex]` 与 `updatedAt` + +apply stage 流 SHALL NOT 读写 `data/projects//sessions/` 目录下的任何文件。 + +#### Scenario: 发起 stage stream + +- **WHEN** renderer 调用 `proposal:stageStream`,传入 `{ runId, stageIndex, projectId, changeId }` +- **AND** `stages[stageIndex].agent` 为非空字符串 +- **THEN** main 进程通过策略 Map 构造 prompt +- **AND** 创建 `AcpSession`(`sessionStore` 为 `ApplyStageAcpSessionStore`),通过 `MessageChannelMain` 将 port2 传给 renderer +- **AND** 等待 renderer 发送 `{ type: "ready" }` 后调用 `session.start(prompt)` +- **AND** `acpSessionId` 通过 `sessionStore.persistAcpSessionId` 写入 `run.json` 的 `stageAcpSessionIds[stageIndex]` +- **AND** 不写入 `sessions/run-{runId}-{stageIndex}.json` +- **AND** 不写入 `sessions/run-{runId}-{stageIndex}.messages.jsonl` + +#### Scenario: stage stream cwd 使用 worktreePath + +- **WHEN** ApplyRunMeta.worktreePath 为非空字符串(例 `/.worktrees/foo`) +- **AND** renderer 触发 `proposal:stageStream` +- **THEN** `AcpSession` 创建参数 `cwd === /.worktrees/foo` +- **AND** `AcpSession.opts.projectPath` 仍为主仓库路径(不变) + +#### Scenario: 旧 ApplyRunMeta 缺 worktreePath fallback + +- **WHEN** ApplyRunMeta.worktreePath 为 `undefined`(P1 阶段全部场景,或旧 run.json) +- **AND** renderer 触发 `proposal:stageStream` +- **THEN** `AcpSession` 创建参数 `cwd === projectPath` +- **AND** 行为完全等价于本能力引入前 + +#### Scenario: reminderContext 携带 worktreePath + +- **WHEN** `AcpSession` 创建并触发 system-reminder 注入流程 +- **THEN** `resolveSystemReminder` 接收的 ctx 含 `worktreePath` 字段(值可能为 `undefined`) +- **AND** ctx 同时包含 `changeId`、`stageIndex`、`runId` + +#### Scenario: Stage 缺少 agent 直接拒绝 + +- **WHEN** renderer 调用 `proposal:stageStream`,所选 stage 的 `agent` 字段为空 +- **THEN** handler 在创建 `AcpSession` 之前抛 `VALIDATION_ERROR`,错误 message 包含 stage 索引信息 +- **AND** 不向 `stage-{stageIndex}.messages.jsonl` 写入任何记录 +- **AND** 不调用 `sessionRegistry.register` +- **AND** 不创建 `ApplyStageAcpSessionStore` + +#### Scenario: 不支持的 stage type + +- **WHEN** `stages[stageIndex].type` 不在策略 Map 中 +- **THEN** port 发送 `{ type: "error", data: { code: "STAGE_TYPE_NOT_IMPLEMENTED", message: "..." } }` + +#### Scenario: 取消 stage stream + +- **WHEN** renderer 调用 `proposal:stageStream:cancel`, 传入 `{ runId }` +- **THEN** main 进程调用对应 `AcpSession.cancel()` +- **AND** 从活跃 session Map 中移除该 runId + +### Requirement: Archive 流独立落盘与状态持久化 + +系统 SHALL 在 `proposal:archive` handler 的 `onReady` 阶段: + +1. 校验 `runMeta.status === "done"`,并通过 `getCompletedApplyStageIndex(runMeta)` 找到最后一个完成的 stage 索引 `lastStageIndex` +2. 从 `runMeta.stages[lastStageIndex].agent` 取 `agentId`;若为空字符串、`null` 或 `undefined`,SHALL 抛 `ipcError(IpcErrorCodes.VALIDATION_ERROR, ...)`,SHALL NOT 创建 `AcpSession`、SHALL NOT 写任何 archive 文件 +3. 校验 `runMeta.stageAcpSessionIds[lastStageIndex]` 为非空字符串;若不满足,SHALL 抛 `ipcError(IpcErrorCodes.APPLY_SESSION_NOT_READY, ...)`。SHALL NOT 调用 `loadSessionMeta` 来获取该判据,SHALL NOT 调用 `loadSessionMeta` 来获取 `agentId` +4. 通过 `newArchiveFylloSessionId(runMeta.runId)` 生成 archive 阶段使用的 `fylloSessionId`,格式为 `${runId}-archive`。该 id SHALL NOT 复用任何 stage 的 `fylloSessionId` +5. 构造 `ArchiveRunMeta`,结构为 `{ runId: "archive-", changeId, status: "running", startedAt, updatedAt }`,通过 `saveArchiveRunMeta` 写入 `apply-runs//archive.json`。新建时 SHALL NOT 写入 `acpSessionId` 字段(`acpSessionId` 由 `ArchiveAcpSessionStore` 在 ACP newSession 成功后通过字段级更新写入) +6. 构造 archive 的 user message(`role: "user"`,`parts: [{ type: "text", text: prompt }]`),通过 `appendArchiveMessage` 写入 `apply-runs//archive.messages.jsonl`,并通过 sink 发送 `{ kind: "user_message", message }` chunk +7. 构造 `AcpSession` 时传入: + - `fylloSessionId`:第 4 步生成的 `${runId}-archive` + - `agentId`:第 2 步从 `runMeta.stages[lastStageIndex].agent` 取得 + - `owner`:`"archive"` + - **`cwd`**:`runMeta.worktreePath ?? projectPath` + - **`projectPath`**:始终为主仓库路径 + - `sessionStore`:`ArchiveAcpSessionStore` 实例(构造参数 `(projectPath, changeId)`) + - **`reminderContext`**:`{ changeId, runId: archiveRunId, worktreePath: runMeta.worktreePath }`。`worktreePath` 字段允许为 `undefined` + - `onReminderInjected` 钩子:调用 `prependReminderToLastUserMessage(, reminderPart)`,把 reminder text part pre-pend 到刚落盘的 user 消息 `parts` 首位;钩子 SHALL NOT 通过 sink 推送 `user_message` chunk +8. 使用 `MessageAssembler` 收集 assistant 事件;`done` 时 `flush()` → `appendArchiveMessage` → 更新 `archive.json` 的 `status` 为 `"done"` 与 `updatedAt` +9. 若 `AcpSession` emit `error`,更新 `archive.json` 的 `status` 为 `"error"` 后再通过 sink 发送错误 chunk + +archive 的持久化路径 SHALL 与 stage 完全解耦:不写入 `stage-*.messages.jsonl`,不修改 `run.json` 的 `stages` 数组,不读写 `sessions/` 目录下的任何文件。 + +`ArchiveAcpSessionStore` 在 `AcpSession.start` 期间被调用: + +- `loadAcpSessionId()`:调用 `loadArchiveRunMeta(projectPath, changeId)`,返回 `meta?.acpSessionId ?? null` +- `persistAcpSessionId(acpSessionId)`:调用 `updateArchiveRunAcpSessionId(projectPath, changeId, acpSessionId)`,进行字段级更新,仅修改 `acpSessionId` 与 `updatedAt`,不覆盖 `runId / status / startedAt` + +`ArchiveRunMeta` 的 `acpSessionId` 字段 SHALL 为可选 `string`;旧 archive.json 文件缺该字段时 SHALL 视为 `undefined`,`AcpSession` 据此走 `connection.newSession()` 路径并触发 reminder 注入。 + +**事件白名单(archive stream handler)**:`proposal:archive` 的事件判定 SHALL 将 `text_delta`、`reasoning_delta`、`tool_call_start`、`tool_call_update` 四类事件分派到 "assembler.apply(ev) + toMessageChunk(ev) + sink.sendChunk(chunk)" 组合。对 `available_commands_update` 事件 SHALL 显式忽略:不调用 assembler、不 sendChunk、不写磁盘、不修改 `archive.json`。其余事件(`session_info_update` / `done` / `error`)按既有逻辑处理。 + +#### Scenario: Archive 流启动时初始化 meta 与 user 消息 + +- **WHEN** `proposal:archive` handler 的 `onReady` 执行 +- **THEN** 主进程从 `runMeta.stages[lastStageIndex].agent` 读取 `agentId`,从 `runMeta.stageAcpSessionIds[lastStageIndex]` 读取 apply 就绪标记 +- **AND** 不调用 `loadSessionMeta` +- **AND** 通过 `newArchiveFylloSessionId(runMeta.runId)` 生成独立的 archive `fylloSessionId` +- **AND** 写入 `archive.json`(status: "running") +- **AND** 落盘 archive user message 到 `archive.messages.jsonl` +- **AND** 通过 sink 发送 `user_message` chunk +- **AND** 启动 `AcpSession`(owner 为 `"archive"`,sessionStore 为 `ArchiveAcpSessionStore`) + +#### Scenario: archive cwd 使用 worktreePath + +- **WHEN** ApplyRunMeta.worktreePath 为非空字符串 +- **AND** 用户触发 `proposal:archive` +- **THEN** `AcpSession` 创建参数 `cwd === ApplyRunMeta.worktreePath` +- **AND** `archive.json` 与 `archive.messages.jsonl` 仍写入 `data/projects//apply-runs//`(持久化路径不随 worktree 变化) + +#### Scenario: 旧 ApplyRunMeta archive fallback + +- **WHEN** ApplyRunMeta.worktreePath 为 `undefined`(P1 阶段全部场景) +- **AND** 用户触发 archive +- **THEN** `AcpSession` 创建参数 `cwd === projectPath` +- **AND** 行为完全等价于本能力引入前 + +#### Scenario: archive reminderContext 携带 worktreePath + +- **WHEN** archive 阶段 `AcpSession` 创建并触发 system-reminder 注入流程 +- **THEN** ctx 含 `worktreePath` 字段(值可能为 `undefined`) +- **AND** ctx 同时含 `changeId`、`runId` + +#### Scenario: Archive 启动校验 stage 是否就绪 + +- **WHEN** `proposal:archive` 被调用,但 `runMeta.stageAcpSessionIds[lastStageIndex]` 缺失或为空字符串 +- **THEN** 主进程抛 `ipcError(IpcErrorCodes.APPLY_SESSION_NOT_READY, ...)` +- **AND** 不创建 `AcpSession` +- **AND** 不调用 `loadSessionMeta` 进行兜底检查 + +#### Scenario: Archive 启动校验 stage agent 存在 + +- **WHEN** `proposal:archive` 被调用,但 `runMeta.stages[lastStageIndex].agent` 为空字符串、null 或 undefined +- **THEN** 主进程抛 `ipcError(IpcErrorCodes.VALIDATION_ERROR, ...)`,错误 message 包含 stage 索引信息 +- **AND** 不创建 `AcpSession` +- **AND** 不写入任何 archive 文件 + +#### Scenario: Archive 首次运行触发 reminder 注入 + +- **WHEN** archive `AcpSession.start` 调用 `sessionStore.loadAcpSessionId()` 返回 `null`(archive.json 缺 `acpSessionId`) +- **AND** 系统调用 `connection.newSession()` 成功返回 +- **AND** `resolveSystemReminder({ owner: "archive", ... })` 返回非 null 的 `TextUIPart` +- **THEN** 主进程调用 `await sessionStore.persistAcpSessionId(newAcpSessionId)`,archive.json 的 `acpSessionId` 被字段级更新 +- **AND** 在 `try/catch` 中 `await onReminderInjected(reminderPart)`;成功时磁盘上 `archive.messages.jsonl` 中最后一条 user 消息的 `parts` 首位被 prepend 为 reminder text part +- **AND** `connection.prompt()` 的 `prompt` 数组首位为 reminder part,次位为 user text block + +#### Scenario: Archive 不写 sessions 目录 + +- **WHEN** archive 流的整个生命周期内(启动、运行、done、error) +- **THEN** 主进程不创建、不修改 `data/projects//sessions/` 目录下的任何文件 +- **AND** 不调用 `loadSessionMeta` / `upsertSessionMeta` / `saveSessionMeta` + +#### Scenario: Archive 流透传 reasoning_delta + +- **WHEN** archive 执行中 `AcpSession` emit `reasoning_delta` 事件 +- **THEN** main 进程调用 `assembler.apply(ev)`(按 reasoning 轨道合并到当前 assistant message) +- **AND** 通过 sink 发送 `{ type: "chunk", data: { kind: "reasoning_delta", text } }` + +#### Scenario: Archive 流忽略 available_commands_update + +- **WHEN** archive 执行中 `AcpSession` emit `available_commands_update` 事件 +- **THEN** main 进程不调用 `assembler.apply(ev)` +- **AND** 不通过 sink 发送任何 chunk +- **AND** 不修改 `archive.json` +- **AND** 不写任何磁盘文件 + +#### Scenario: Archive 正常完成 + +- **WHEN** `AcpSession` emit `done` 事件 +- **THEN** 主进程调用 `assembler.flush()` 得到完整 assistant `UIMessage`(可能含 reasoning / text / dynamic-tool 各类 part) +- **AND** 通过 `appendArchiveMessage` 将该消息追加到 `archive.messages.jsonl` +- **AND** 更新 `archive.json` 的 `status` 为 `"done"`,更新 `updatedAt`,保留 `acpSessionId` +- **AND** 通过 sink 发送 `{ type: "done" }` + +#### Scenario: Archive 执行出错 + +- **WHEN** `AcpSession` emit `error` 事件 +- **THEN** 主进程更新 `archive.json` 的 `status` 为 `"error"`,更新 `updatedAt`,保留 `acpSessionId` +- **AND** 通过 sink 发送 `{ type: "error", data: { code, message } }` diff --git a/openspec/changes/add-multi-worktree-foundation/tasks.md b/openspec/changes/add-multi-worktree-foundation/tasks.md new file mode 100644 index 00000000..60253032 --- /dev/null +++ b/openspec/changes/add-multi-worktree-foundation/tasks.md @@ -0,0 +1,69 @@ +## 1. shared 类型扩展 + +- [ ] 1.1 在 `shared/types/proposal.ts` 的 `ProposalMeta` interface 中追加 `worktreePath?: string` 字段,紧跟 `date` 字段之后。 +- [ ] 1.2 在 `shared/types/proposal.ts` 的 `ApplyRunMeta` interface 中追加 `worktreePath?: string` 字段,紧跟 `updatedAt` 之后。 +- [ ] 1.3 验收:`pnpm typecheck` 通过;`shared/types/proposal.ts` 中两处 `worktreePath?: string` 字段存在。 + +## 2. fyllo-specs MCP:targetPath 必填校验 + +- [ ] 2.1 在 `mcp-servers/fyllo-specs/src/utils/project-root.ts` 内新增 `validateTargetPath(targetPath: string): { ok: boolean; resolved?: string; rawOutput?: string; error?: string }` 函数。实现: + - 入参先 `path.isAbsolute` 校验,非绝对返回 `{ ok: false, error: "targetPath must be an absolute path" }`。 + - `const resolved = path.resolve(targetPath)`。 + - 通过 `child_process.spawnSync("git", ["-C", process.env.FYLLO_PROJECT_PATH ?? "", "worktree", "list", "--porcelain"], { encoding: "utf8" })` 取 stdout。 + - spawn 退出码为 0 时:解析 stdout 中所有 `worktree ` 行,提取路径集合并 `path.resolve` 规范化;若 `resolved` 在集合中则返回 `{ ok: true, resolved }`,否则返回 `{ ok: false, rawOutput: stdout, error: "targetPath is not a registered git worktree" }`。 + - spawn 退出码非 0(典型 non-git 项目,无 `.git`):当 `resolved === path.resolve(process.env.FYLLO_PROJECT_PATH ?? "")` 时退化为合法,返回 `{ ok: true, resolved }`;否则 `{ ok: false, rawOutput: stderr, error: "targetPath must be the project root for non-git projects" }`。 +- [ ] 2.2 在 `mcp-servers/fyllo-specs/src/tools/explore.ts` 的 `exploreInputSchema` 中追加 `targetPath: z.string().min(1).describe("Absolute path to the project root or a registered git worktree.")`。在 handler 中:先用 `validateTargetPath(input.targetPath)` 校验;不通过时通过 `runTool` 抛 `Error` 让 `runTool` 内敛到 `state.errors`,error 类型用 `InvalidTargetPath`,message 包含 `result.error` 与 `result.rawOutput`(如有);通过则把 `projectRoot` 替换为 `result.resolved`,不再调 `resolveProjectRoot()`。 +- [ ] 2.3 在 `mcp-servers/fyllo-specs/src/tools/create-proposal.ts` 的 `createProposalInputSchema` 中追加 `targetPath` 字段(同 2.2)。handler 内部 `projectRoot = result.resolved`;保留现有 `createChange(projectRoot, name)` 流程。**禁止**任何 git worktree add 副作用——P1 阶段 create-proposal 不引入 worktree 创建。 +- [ ] 2.4 在 `mcp-servers/fyllo-specs/src/tools/apply-change.ts` 的 `applyChangeInputSchema` 中追加 `targetPath` 字段(同 2.2)。handler 内部 `projectRoot = result.resolved`;保留 `existsSync(changeDir(projectRoot, ...))` 与 `loadApplyState(projectRoot, ...)` 调用。 +- [ ] 2.5 在 `mcp-servers/fyllo-specs/src/tools/archive-change.ts` 的 `archiveChangeInputSchema` 中追加 `targetPath` 字段(同 2.2)。handler 内部 `projectRoot = result.resolved`。**禁止**任何 git commit/merge/worktree remove/branch delete 副作用——P1 阶段 archive-change 仅做 OpenSpec 文件归档。 +- [ ] 2.6 4 个 tool 的 prompt md 文件(`mcp-servers/fyllo-specs/src/prompts/{explore,create-proposal,apply-change,archive-change}.md`)开头的"Input"段都要更新:明确说明 `targetPath` 必填,必须是绝对路径,必须是 main repo 或已注册的 worktree;P1 阶段 agent 默认传 `$FYLLO_PROJECT_PATH`(即主仓库根)。 +- [ ] 2.7 在 `mcp-servers/fyllo-specs/__tests__/tools.test.ts` 增加测试用例: + - explore / create-proposal / apply-change / archive-change 任一调用缺省 `targetPath` → MCP SDK 拦截返回 `InvalidParams`。 + - 传入相对路径 → `state.errors[0].type === "InvalidTargetPath"`。 + - 传入不存在的绝对路径 → `state.errors[0].type === "InvalidTargetPath"`,message 含 `git worktree list --porcelain` 的输出片段。 + - 传入 `targetPath === FYLLO_PROJECT_PATH`(git 项目)→ 正常进入 handler。 + - non-git 项目(mock `/.git` 不存在)传入 `FYLLO_PROJECT_PATH` → 走降级逻辑,handler 正常执行;传入其他路径 → `InvalidTargetPath`。 + - 路径含 trailing slash → 校验通过(path.resolve 剥离)。 +- [ ] 2.8 验收:`pnpm test mcp-servers/fyllo-specs` 全过;4 个 tool 的 input schema 中 `targetPath` 为 zod required string;handler 内部不再出现 `resolveProjectRoot()` 调用(仅 `validateTargetPath` 内部使用 `process.env.FYLLO_PROJECT_PATH`)。 + +## 3. SystemReminderContext 扩展 + +- [ ] 3.1 在 `electron/main/services/chat/system-reminder/types.ts` 的 `SystemReminderContext` interface 中追加 `worktreePath?: string` 字段,紧跟 `runId?` 之后。 +- [ ] 3.2 验收:`pnpm typecheck` 通过;P1 阶段不修改任何 system-reminder template 文件(chat.txt / apply.txt / archive.txt)。 + +## 4. apply-run-service 透传 worktreePath + +- [ ] 4.1 在 `electron/main/services/proposal/apply-run-service.ts` 的 `createApplyRun` 函数内构造 `runMeta` 时,加载对应 ProposalMeta 并把 `proposalMeta.worktreePath` 透传到 `runMeta.worktreePath`。具体实现: + - 通过 `findProposalMetaById(projectPath, input.changeId)`(若不存在的辅助函数,则在 `electron/main/domain/proposal/openspec-reader.ts` 内补一个 `findProposalMetaById(projectPath, changeId)`,复用 `readProposalFiles` + `find`)拿到 ProposalMeta;P1 阶段该字段始终为 `undefined`。 + - `runMeta.worktreePath` 写入前用 `path.resolve(proposalMeta.worktreePath)` 规范化(仅在非 undefined 时)。 +- [ ] 4.2 在 `electron/main/services/proposal/__tests__/apply-run-service.spec.ts` 新建测试文件(如目录不存在则同时创建),覆盖: + - ProposalMeta.worktreePath 为 undefined 时,新写入的 run.json 不包含 `worktreePath` 键(JSON.stringify 省略)。 + - ProposalMeta.worktreePath 含 trailing slash 时,新写入的 run.json 中 worktreePath 已规范化(无 trailing slash)。 + - ProposalMeta.worktreePath 为绝对路径时,run.json 中字符串严格相等(path.resolve 后)。 +- [ ] 4.3 验收:`pnpm test electron/main/services/proposal` 全过;新写入的 run.json 在 P1 阶段实际不包含 worktreePath 字段(因 ProposalMeta.worktreePath 始终 undefined)。 + +## 5. proposal-apply.ts cwd 取值与 reminderContext + +- [ ] 5.1 在 `electron/main/ipc/proposal-apply.ts` 的 `proposal:stageStream` handler 中,把 `new AcpSession({ ..., cwd: projectPath, ... })` 的 `cwd` 改为 `cwd: runMeta.worktreePath ?? projectPath`。`projectPath` 字段保持不变(仍传主仓库 path)。 +- [ ] 5.2 在同一处把 `reminderContext` 的字段从 `{ changeId: form.changeId, stageIndex: form.stageIndex, runId: form.runId }` 改为 `{ changeId: form.changeId, stageIndex: form.stageIndex, runId: form.runId, worktreePath: runMeta.worktreePath }`。 +- [ ] 5.3 在 `proposal:archive` handler 中,找到 `new AcpSession(...)` 创建处,同样把 `cwd: projectPath` 改为 `cwd: runMeta.worktreePath ?? projectPath`,`reminderContext` 增加 `worktreePath: runMeta.worktreePath`。 +- [ ] 5.4 stage stream handler 当前在 `onReady` 里需要拿到 `runMeta`:检查现有逻辑是否已经 `loadApplyRunMeta` 了 runMeta;若未加载(早期实现仅用 form 字段构造 prompt),则在 `onReady` 早期阶段补一次 `const runMeta = await loadApplyRunMeta(projectPath, form.changeId)`,仅用其 worktreePath 字段;若 `runMeta` 为 null(异常状态)则按现有 cwd 行为 fallback 到 `projectPath`。 +- [ ] 5.5 验收:手工 dry-run(启动 FylloCode → 创建 git 项目 → 走一次完整 apply → archive 流程),确认旧 ApplyRunMeta 加载与新流程行为完全等价;`pnpm typecheck` 通过。 + +## 6. apply-run-store 序列化兼容性 + +- [ ] 6.1 在 `electron/main/infra/storage/apply-run-store.ts` 中确认 `loadApplyRunMeta` 实际是 `JSON.parse` + 类型断言,缺字段 `worktreePath` 时返回 `undefined`。无需改动;如有显式 schema 校验(如 zod),则在 schema 中追加 `.worktreePath: z.string().optional()`。 +- [ ] 6.2 确认 `saveApplyRunMeta` 实际为 `JSON.stringify(runMeta)`;当 `runMeta.worktreePath === undefined` 时键被自然省略。无需改动;若实现使用 `JSON.stringify(runMeta, null, 2)`,行为相同。 +- [ ] 6.3 在 `electron/main/infra/storage/__tests__/apply-run-store.spec.ts`(如不存在则新建)增加测试: + - 写入 ApplyRunMeta 不含 worktreePath → 读回 `worktreePath === undefined`。 + - 模拟磁盘上已存在的旧 run.json(不含 worktreePath 字段)→ 加载后 `worktreePath === undefined`。 + - 写入含绝对 worktreePath 的 ApplyRunMeta → 读回字符串严格相等。 +- [ ] 6.4 验收:`pnpm test` 中 apply-run-store 相关测试全过。 + +## 7. dogfood 与零回归验证 + +- [ ] 7.1 启动 FylloCode 开发环境(`pnpm dev`),打开任意 git 项目,触发一次 chat → create-proposal 调用,确认 fyllo-specs MCP 调用成功(agent 在新 schema 下传入 `targetPath: `,工具正常返回 state)。 +- [ ] 7.2 同一项目跑一次 apply(任意 stage)→ archive 流程,确认 stage stream 与 archive stream 正常完成;`run.json` / `archive.json` 落盘字段无 `worktreePath` 噪声。 +- [ ] 7.3 用旧版 FylloCode 已经留下的 ApplyRunMeta JSON(手工准备一份不含 worktreePath 的 run.json),加载后跑一次 archive,确认行为零回归。 +- [ ] 7.4 `pnpm build` 与 `pnpm lint` 通过(不引入新的告警)。 +- [ ] 7.5 验收:所有 7.1–7.4 检查通过;FylloCode 应用本身可用,所有 apply / archive 历史数据可正常加载。 diff --git a/openspec/changes/add-proposal-list-worktree-scan/.openspec.yaml b/openspec/changes/add-proposal-list-worktree-scan/.openspec.yaml new file mode 100644 index 00000000..65b9a4e8 --- /dev/null +++ b/openspec/changes/add-proposal-list-worktree-scan/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-05-19T00:00:00.000Z +status: draft diff --git a/openspec/changes/add-proposal-list-worktree-scan/design.md b/openspec/changes/add-proposal-list-worktree-scan/design.md new file mode 100644 index 00000000..ccba6d66 --- /dev/null +++ b/openspec/changes/add-proposal-list-worktree-scan/design.md @@ -0,0 +1,193 @@ +## Context + +P1 已经在 `shared/types/proposal.ts` 给 `ProposalMeta` 加了 `worktreePath?: string` 字段、在 `apply-run-service` 给 `ApplyRunMeta.worktreePath` 加了透传逻辑、在 `proposal-apply.ts` 给 stage stream / archive ACP session 加了 cwd fallback。但所有这些字段在 P1 阶段值都是 `undefined`,因为没有"来源端"——`readProposalFiles` 仍只扫主仓库的 `openspec/changes/`。 + +P2 让 chat 阶段的 agent 在 `/.worktrees//` 创建 worktree 并把 OpenSpec change 写在那里,这样磁盘上**确实**已经有 worktree 来源的 change,但列表/详情页都看不到。 + +P3 让 `readProposalFiles` 能扫到这些 worktree 来源的 change,给每条 meta 写上 `worktreePath`,激活 P1 已部署但未启用的整条数据通路:列表显示 → 详情页能 read → 点 apply → ApplyRunMeta.worktreePath 自动写入 → stage stream cwd 落到 worktree。 + +**关键事实** + +- `readProposalFiles(projectPath)` 当前实现:扫 `/openspec/changes/` 与 `/openspec/changes/archive/`;`resolveChangeDir` 同样只看这两处。 +- ProposalMeta 字段含 `id`、`title`、`status`、`why`、`totalTasks`、`doneTasks`、`hasDesign`、`date`、`worktreePath?`(最后一个由 P1 引入)。 +- 列表页 `frontend/src/pages/proposal/index.vue` 当前已用 `` 显示 status,可以在同一行右侧追加 worktree 标记。 +- 主仓库 `archive/-/` 与 worktree `/openspec/changes//` 在 changeId 上**不会冲突**,因为前者带日期前缀(`stripArchivePrefix` 用正则 `/^\d{4}-\d{2}-\d{2}-/` 识别)。 + +## Goals / Non-Goals + +**Goals** + +- `readProposalFiles` 同时扫主仓库与 `/.worktrees/*/openspec/changes/`;worktree 来源 meta 携带 `worktreePath`;同名 changeId 双源出现时 worktree 优先。 +- `resolveChangeDir` 在主仓库找不到时按 worktree 顺序探查;返回的目录路径用于下游 `proposal:read` 等 API 直接读取 worktree 内的 artifacts。 +- 列表卡片在 `worktreePath` 非空时展示视觉标记 + tooltip。 +- 该通路启用后,apply 创建 run 时 worktreePath 会自动从 ProposalMeta 透传到 ApplyRunMeta(P1 通路天然生效)。 + +**Non-Goals** + +- 不修改 MCP `create-proposal` 工具行为(worktree add 仍由 agent 在 P2 chat reminder 引导下完成)。 +- 不修改 archive 编排逻辑(P4)。 +- 不为 `/.worktrees/*/openspec/changes/archive/` 路径下的归档去扫描——archive 完成时 worktree 会被删除(P4 编排),所以这条路径上不会有持久 archived change。 +- 不引入"清理孤儿 worktree"的 UI 入口;列表里看到一个孤儿 worktree(已 archive 但 worktree 没被 remove)属于 P4 失败兜底场景,由用户用原生 git 命令处理。 +- 不改 ApplyRunMeta 持久化逻辑(P1 已就绪);不改 stage stream / archive ACP cwd 取值逻辑(P1 已就绪)。 + +## Decisions + +### D1:扫描顺序与目录构造 + +**选择**: + +```ts +async function readProposalFiles(projectPath: string): Promise { + const fromMain = await readActiveDir(join(projectPath, "openspec", "changes"), undefined); + const fromArchive = await readArchiveDir(join(projectPath, "openspec", "changes", "archive")); + const fromWorktrees = await readWorktreesActiveDirs(join(projectPath, ".worktrees")); + return dedupeWorktreePriority([...fromMain, ...fromArchive, ...fromWorktrees]); +} +``` + +具体: + +- `readActiveDir(dir, worktreePath)`:扫 `/*/`,每个子目录读 `.openspec.yaml`;返回的 ProposalMeta 把 `worktreePath` 设为传入参数。 +- `readArchiveDir(dir)`:扫 `/*/`,仅生产 status 为 `archived` 的 ProposalMeta(来源主仓库);`worktreePath: undefined`。 +- `readWorktreesActiveDirs(worktreesRoot)`:先 `fs.readdir(worktreesRoot)`(不存在则返空数组),对每个 worktree 子目录调 `readActiveDir(/openspec/changes, )`。**不扫 worktree 内的 archive 路径**——archive 完成时 worktree 被删除,那条路径上不会有持久数据。 + +**理由**:扫 worktree 的 active dir 已经覆盖所有 worktree 来源的 change;archive 时 worktree 由 P4 编排清理,不会有孤儿 archived 在 worktree 内。 + +### D2:去重规则——worktree 优先 + +**选择**:合并三段结果时用 `Map`,按"main active → main archive → worktree"顺序写入;后写入覆盖先写入。 + +**关键观察**: + +- main active 的 changeId 不带日期前缀(如 `foo`)。 +- main archive 的 changeId 带日期前缀(如 `2026-05-19-foo`)。 +- worktree 的 changeId 不带日期前缀(如 `foo`)。 + +所以**main active 与 worktree 的 changeId 重合**才会触发去重,main archive 不参与去重。 + +**冲突场景**: + +| 状态 | main active 是否有 `foo` | worktree `foo` 是否存在 | 结果 | +| ---------------------------------- | ------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| chat 刚 propose | ✗ | ✓ | 列表显示 worktree `foo` | +| archive 完成、worktree 还没 remove | ✗(已 merge 到 archive) | ✓(编排断在 worktree-remove 前) | 列表显示 worktree `foo`(status 已是 archived)+ main archive 的 `2026-05-19-foo`。changeId 不同,不冲突,两条都展示 | +| 真正发生冲突的异常状态 | ✓ | ✓ | 取 worktree 那条;main active 那条被覆盖 | + +第二种场景"用户在列表看到两条相关 change"虽然冗余,但都是真实状态,不应隐藏;用户能从 status / changeId 区分(前者 active 名 + worktree 标记 + status archived,后者带日期前缀 + 无 worktree 标记 + status archived)。 + +#### 选项对比 + +| 方案 | 优点 | 痛点 | +| ----------------------- | ---------------------------- | ----------------------------------------------------------- | +| worktree 优先(本设计) | 实时反映 worktree 内最新状态 | 异常状态下隐藏 main active 那一条;但异常状态本来就不该出现 | +| main 优先 | 主仓库为权威 | worktree 的 active 状态被屏蔽,与 P3 目标冲突 | +| 不去重,并列展示 | 显式 | 同名 changeId 在 UI 上视觉混乱 | + +### D3:`resolveChangeDir` 探查顺序 + +**选择**: + +``` +order: + 1. /openspec/changes/ + 2. /openspec/changes/archive/ + 3. /.worktrees/*/openspec/changes/ +找到第一个含 .openspec.yaml 的目录即返回。 +``` + +**理由**: + +- 1 优先于 3:保证 main active 与 worktree 同名时,下游接口默认拿到 main 那个;与 D2 列表去重的"worktree 优先"并不矛盾——`resolveChangeDir` 是被 `proposal:read changeId` 等单 change 操作调用,调用方传入 `changeId` 就是表达"我要看 main active 的"还是"worktree 的"已经在 changeId 不同时被表达。但实际上 D2 已经说明 main active 与 worktree 同名只在异常状态下出现;正常情况下 main active 与 worktree 不会有同名,因此优先级 1 vs 3 实际不冲突。 + +**例外**:详情页路由 `/proposal/` 当前不带 worktree 信息。如果同名异常状态出现,详情页打开的是 main active 那一份。这是已知边界,不在本能力范围内修补——异常状态本来就不该出现。 + +### D4:worktree 标记的 UI 形式 + +**选择**:在卡片右上角 status badge 旁边追加一个小图标 + 文案 "worktree",鼠标悬浮显示完整 worktreePath(用 `title=` 原生属性即可,不引入 Tooltip 组件以避免大改)。 + +```vue + + + worktree + +``` + +**理由**: + +- `i-lucide-git-branch` 在视觉上贴近 worktree 概念。 +- `title=` 是原生 HTML 属性,零依赖;hover 后用户能看到完整路径。 +- 文案 "worktree" 让用户理解这条 change 在哪一类目录里。 + +**否决**: + +- 单纯放 icon 不写文案——首次接触概念的用户看不出含义。 +- 用 UTooltip 包裹——@nuxt/ui 4 的 Tooltip 组件需要包一层 trigger,对单条卡片是过度封装。 + +### D5:扫描失败/路径不存在的鲁棒性 + +**选择**:每段扫描独立 try/catch,任一段失败都返回空数组而不终止整体。原 `readProposalFiles` 已有外层 try/catch(catch 全部异常返回 `[]`),P3 不收紧这个语义;新增的 worktree 扫描走同样的失败兜底。 + +**理由**:worktree 路径不存在(用户从来没建过 worktree)是常见情况;不应让 list 整体失败。 + +## Architecture + +### 扫描流程 + +```mermaid +flowchart TD + R[readProposalFiles
projectPath] --> M["扫主仓库
openspec/changes/*"] + R --> A["扫主仓库
openspec/changes/archive/*"] + R --> W["列出 .worktrees/*"] + W --> WL{worktrees 路径存在?} + WL -->|否| EMPTY[返空数组] + WL -->|是| WS["对每个 worktree
扫 openspec/changes/*"] + WS --> WC[worktree 来源 metas
带 worktreePath] + M --> MERGE[Map dedupe
main → archive → worktree] + A --> MERGE + WC --> MERGE + EMPTY --> MERGE + MERGE --> SORT[按 date 倒序] + SORT --> OUT[ProposalMeta[]] +``` + +### `resolveChangeDir` 探查 + +```mermaid +flowchart LR + R[resolveChangeDir
projectPath, changeId] --> M["main: openspec/changes/"] + M --> MOK{exists} + MOK -->|是| RET1[返回 main 路径] + MOK -->|否| AR["main: archive/"] + AR --> AOK{exists} + AOK -->|是| RET2[返回 archive 路径] + AOK -->|否| W["遍历 .worktrees/*
找第一个含 .openspec.yaml/"] + W --> WOK{found} + WOK -->|是| RET3[返回 worktree 路径] + WOK -->|否| RET4[null] +``` + +## Risks / Trade-offs + +- **同名去重的极端冲突**(低):main active 与 worktree 同时存在 `foo`(异常状态)时 worktree 覆盖 main active。详情页路由不带 worktree 信息,会打开 main active 那份。 + - 缓解:异常状态本来就不该出现;P4 archive 编排可靠后此场景几乎消失。 +- **worktree 数量过多导致扫描慢**(低):worktree 数量预期 < 10。 + - 缓解:不引入缓存,保持实现简单。 +- **archive 完成、worktree 未删时列表出现两行**(低):用户可能困惑。 + - 缓解:worktree 那条带 worktree 标记 + status archived;main archive 那条 changeId 含日期前缀。两条状态都是真实磁盘状态,不应隐藏。 + +## Migration Plan + +1. 修改 `electron/main/domain/proposal/openspec-reader.ts`:抽出 `readActiveDir` / `readArchiveDir` 内部辅助;新增 `readWorktreesActiveDirs`;更新 `readProposalFiles` 主流程;更新 `resolveChangeDir` 探查 worktree 路径。 +2. 在 `electron/main/domain/proposal/__tests__/`(如不存在则建)创建 `openspec-reader.spec.ts`,覆盖:worktrees 不存在 / 单 worktree 单 change / 多 worktree / 同名 changeId 去重以 worktree 为准 / archive 路径 changeId 含日期前缀不参与去重 / 路径规范化(trailing slash 等)。 +3. 修改 `frontend/src/pages/proposal/index.vue` 卡片模板,加 worktree 标记。 +4. dogfood:在 P2 完成后,跑一次 chat → propose 让 worktree 中产生一份 change;进入列表页确认显示 + 标记;进入详情页确认能正确读 worktree 内 artifacts;触发 apply 确认 ApplyRunMeta.worktreePath 写入。 + +**回滚**:移除 `readProposalFiles` 中 worktree 扫描分支;移除 `resolveChangeDir` 中 worktree 探查;前端卡片标记由 `v-if` 守卫,扫描回滚后字段为空,标记自然不显示。 + +## Open Questions + +无。 diff --git a/openspec/changes/add-proposal-list-worktree-scan/proposal.md b/openspec/changes/add-proposal-list-worktree-scan/proposal.md new file mode 100644 index 00000000..c41d7e33 --- /dev/null +++ b/openspec/changes/add-proposal-list-worktree-scan/proposal.md @@ -0,0 +1,51 @@ +## Why + +P2 让 chat 阶段的 agent 能在 `/.worktrees//` 下创建 OpenSpec change 与 artifacts。但当前 `proposal:list` 仅扫描主仓库 `/openspec/changes/`(参见 `electron/main/domain/proposal/openspec-reader.ts#readProposalFiles`),用户在 proposal 列表里**看不到**写入 worktree 的 change,自然无法点击 apply。本次(P3)让列表与详情页能看到 worktree 来源的 change,并把 worktreePath 自动透传给 ApplyRunMeta(这一步契约 P1 已就绪),让 apply / archive 的 ACP cwd 自然落到 worktree 上。 + +## What Changes + +- **MODIFIED `readProposalFiles(projectPath)`**:在扫主仓库 `/openspec/changes/` 之后,再扫 `/.worktrees/*/openspec/changes/`,把 worktree 来源的 ProposalMeta 也合并进结果。worktree 来源的 meta 设置 `worktreePath`,同名 changeId 出现时以 worktree 来源覆盖主仓库来源(worktree 优先)。主仓库 `archive/` 路径下的归档继续保留。 +- **MODIFIED `ProposalMeta` 序列化**:扫描时 `worktreePath` 字段在 worktree 来源的 meta 中写入绝对路径(`path.resolve` 规范化),主仓库来源保持 `undefined`。该字段是 P1 已经在 `shared/types/proposal.ts` 加上的可选字段。 +- **MODIFIED `resolveChangeDir(projectPath, changeId)`**:增加对 worktree 路径的探查——按主仓库 `openspec/changes/` → 主仓库 `archive/` → 各 worktree `openspec/changes/` 的顺序查找;找到第一个 `.openspec.yaml` 即返回。这是为了让 `proposal:read` 等下游接口在 P3 后能正确读到 worktree 内的 artifacts。 +- **MODIFIED proposal 列表页 UI**:proposal 卡片在 `worktreePath` 非空时显示 worktree 标记(badge / icon)。鼠标悬浮卡片时通过 tooltip / title 属性展示 `worktreePath` 的绝对路径字符串,方便用户排查。 +- **MODIFIED `proposal:apply` 创建 run**:当前 P1 的实现在 `apply-run-service.createApplyRun` 中已经有 worktreePath 透传位(来源是 ProposalMeta)。P3 落地后这条通路自然激活,因为 ProposalMeta 第一次有非空 worktreePath。本能力**不修改** apply-run-service 的代码——契约已就绪,行为通过 P3 的来源端启用而生效。 +- 单测:`openspec-reader.spec.ts` 增补 worktree 扫描分支(worktree 不存在 / 单 worktree / 多 worktree / 同名去重 / archive 路径不被去重)。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `proposal-list`: 列表扫描扩展为主仓库 + worktrees 双源;ProposalMeta 携带 worktreePath;卡片显示 worktree 标记。 + +## Impact + +**代码** + +- `electron/main/domain/proposal/openspec-reader.ts`:`readProposalFiles` 双源扫描;`resolveChangeDir` 加 worktree 探查路径 +- `frontend/src/pages/proposal/index.vue`:卡片增 worktree 标记 + tooltip +- `electron/main/domain/proposal/__tests__/openspec-reader.spec.ts`(如不存在则新建):扫描分支单测 + +**用户可见变化** + +- 列表里第一次能看到 worktree 来源的 change(在 P2 已经把 chat 阶段写到 worktree 之后)。 +- 卡片右上角多一个 worktree 视觉标记(小 icon + "worktree" 文案),鼠标悬浮显示 worktreePath。 +- 触发 apply:ApplyRunMeta.worktreePath 第一次会被写入 worktree 绝对路径,apply ACP cwd 自然落到 worktree——这是 P1 早已部署但未激活的通路。 +- 主仓库 `archive/` 下已归档的 change 仍正常显示,状态仍为 `archived`,与 worktree 来源去重不冲突(参见 design 中"去重规则")。 + +**依赖** + +无新增依赖。 + +**风险** + +- 双源扫描的 IO 成本(低):N 个 worktree 各自扫一次 `openspec/changes/`,但 worktree 数量预期 < 10,无需缓存。 +- 同名去重的边界(中):archive 完成的瞬间,worktree 内 OpenSpec 已移到 `archive/-/`、worktree 还没被 remove;此时主仓库已经 merge 进归档 commit。两边都看到 archived 状态,且 changeId 含日期前缀(例 `2026-05-19-foo`),与活跃 change(例 `foo`)不冲突。详情见 design 的 D2。 +- 卡片标记影响信息密度(低):badge 增加占用横向空间。卡片当前布局已能在 status badge 旁边再放一个标记(`flex shrink-0` 已在 markup 里);不需要重排版。 + +**回滚** + +把 `readProposalFiles` 中扫 `.worktrees/` 的循环 + 去重 Map 移除即可;ProposalMeta.worktreePath 字段保留(P1 已加);前端卡片标记由 `v-if="proposal.worktreePath"` 守卫,回滚扫描后字段为空,标记自然不显示。 diff --git a/openspec/changes/add-proposal-list-worktree-scan/specs/proposal-list/spec.md b/openspec/changes/add-proposal-list-worktree-scan/specs/proposal-list/spec.md new file mode 100644 index 00000000..83a7703c --- /dev/null +++ b/openspec/changes/add-proposal-list-worktree-scan/specs/proposal-list/spec.md @@ -0,0 +1,125 @@ +## ADDED Requirements + +### Requirement: Proposal list aggregates main repo and linked worktrees + +系统 SHALL 在生成 proposal 列表时,扫描以下三处目录并合并结果: + +1. 主仓库 `/openspec/changes/`(排除 `archive` 子目录)。 +2. 主仓库 `/openspec/changes/archive/`。 +3. 主仓库下每个 linked worktree 的 `/.worktrees//openspec/changes/`(worktree 内的 `archive` 子目录 SHALL NOT 被扫描)。 + +`/.worktrees/` 目录不存在时 SHALL 返回该段为空数组,整体 list SHALL NOT 报错。 + +#### Scenario: 仅主仓库 + +- **WHEN** `/.worktrees/` 不存在 +- **THEN** `proposal:list` 返回的 ProposalMeta 数组仅包含主仓库 `openspec/changes/*` 与 `openspec/changes/archive/*` +- **AND** 所有条目的 `worktreePath` 字段为 `undefined` + +#### Scenario: 主仓库与单个 worktree 共存 + +- **WHEN** 主仓库 `openspec/changes/foo/` 不存在 +- **AND** `/.worktrees/foo/openspec/changes/foo/.openspec.yaml` 存在 +- **THEN** list 包含 id 为 `foo` 的条目 +- **AND** 该条目 `worktreePath === path.resolve(/.worktrees/foo)` + +#### Scenario: 多个 worktree 各自一份 change + +- **WHEN** `/.worktrees/foo` 与 `/.worktrees/bar` 各含一份不同 change +- **THEN** list 同时包含 id 为 `foo` 与 `bar` 的两条 +- **AND** 各自 `worktreePath` 字段不同 +- **AND** 各自 `worktreePath` 均为 `path.resolve` 后的绝对路径 + +#### Scenario: worktree 内 archive 路径不被扫描 + +- **WHEN** `/.worktrees/foo/openspec/changes/archive/2026-05-19-xx/` 存在 +- **THEN** list 不包含 id 为 `2026-05-19-xx` 的来自该 worktree 的条目 +- **AND** (这条目录不应在正常工作流中出现,因为 P4 archive 编排在 archive 完成后会删除 worktree;本场景仅作鲁棒性约束) + +### Requirement: Proposal list deduplicates by changeId with worktree priority + +系统 SHALL 在合并主仓库 active / 主仓库 archive / worktree active 三段结果时执行去重: + +- 用 `Map` 收敛,按"主仓库 active → 主仓库 archive → worktree active"顺序写入;后写入 SHALL 覆盖先写入。 +- 主仓库 archive 的 changeId SHALL 含日期前缀(`/^\d{4}-\d{2}-\d{2}-/`),与活跃 change 不冲突,因此去重 Map 实际上只在主仓库 active 与 worktree active 同名时才会触发覆盖。 + +#### Scenario: 同名活跃 change 取 worktree 版本 + +- **WHEN** 主仓库 `openspec/changes/foo/.openspec.yaml` 与 `/.worktrees/foo/openspec/changes/foo/.openspec.yaml` 同时存在 +- **THEN** list 中 id 为 `foo` 的条目来自 worktree +- **AND** 该条目 `worktreePath` 非空 +- **AND** 主仓库 `foo` 那一份在最终结果中不出现 + +#### Scenario: archive 后短窗口期同时存在两条 + +- **WHEN** worktree 内 OpenSpec 已 archive 移动到 `archive/2026-05-19-foo/` +- **AND** worktree 尚未被 remove(archive 编排断在 worktree-remove 前) +- **AND** 主仓库已 merge 进归档 commit(`/openspec/changes/archive/2026-05-19-foo/.openspec.yaml` 存在) +- **THEN** list 同时包含来自主仓库 archive 的 `2026-05-19-foo` 与来自 worktree active 的 `foo` +- **AND** 两条 changeId 不同,不参与去重 +- **AND** worktree 来源那条状态为 active 名(`foo`),但 status 已是 `archived`,且 `worktreePath` 非空 + +### Requirement: ProposalMeta exposes worktreePath when sourced from worktree + +`ProposalMeta` 实例的 `worktreePath` 字段 SHALL 按以下规则赋值: + +- 来源主仓库 active:`worktreePath` 为 `undefined`。 +- 来源主仓库 archive:`worktreePath` 为 `undefined`。 +- 来源某 linked worktree:`worktreePath` 为 `path.resolve(/.worktrees/)`,绝对路径,无 trailing slash。 + +序列化(IPC / JSON.stringify)时 `worktreePath: undefined` 的字段 SHALL 被自然省略,不出现 `"worktreePath": null` 噪声。 + +#### Scenario: 主仓库来源不带 worktreePath + +- **WHEN** ProposalMeta 来源于主仓库 `openspec/changes/` +- **THEN** `worktreePath` 为 `undefined` +- **AND** JSON 序列化中该字段不出现 + +#### Scenario: 主仓库 archive 来源不带 worktreePath + +- **WHEN** ProposalMeta 来源于主仓库 `openspec/changes/archive/` +- **THEN** `worktreePath` 为 `undefined` + +#### Scenario: worktree 来源 worktreePath 规范化 + +- **WHEN** worktree 路径在文件系统上是 `/Users/foo/myapp/.worktrees/bar/`(含 trailing slash) +- **THEN** ProposalMeta.worktreePath === `/Users/foo/myapp/.worktrees/bar`(path.resolve 剥离 trailing slash 后) + +### Requirement: Proposal cards mark worktree-sourced changes + +前端 proposal 列表卡片组件 SHALL 在 `ProposalMeta.worktreePath` 非空时显示视觉标记,向用户暗示该 change 当前驻留在 linked worktree。标记的具体形式(badge / icon / 文案)由实现决定,但 SHALL: + +- 不遮挡现有的 status badge / Why 摘要 / 任务进度等核心信息。 +- 在卡片悬浮时(通过 `title=` 原生属性或 Tooltip 组件)展示 `worktreePath` 完整字符串供用户参考。 +- 仅在 `worktreePath` 非空时渲染(v-if 守卫);空时整段标记 DOM 不出现。 + +#### Scenario: 主仓库 change 无 worktree 标记 + +- **WHEN** 渲染 `worktreePath` 为 `undefined` 的 ProposalMeta +- **THEN** 卡片不显示 worktree 标记 DOM + +#### Scenario: worktree change 显示标记 + tooltip + +- **WHEN** 渲染 `worktreePath` 为 `/abs/.worktrees/foo` 的 ProposalMeta +- **THEN** 卡片显示 worktree 标记元素 +- **AND** 用户 hover / focus 标记元素时,能看到 `worktreePath` 完整字符串 +- **AND** 标记位置不与 status badge / Why 文本重叠 + +## MODIFIED Requirements + +### Requirement: Proposal list page displays overview statistics + +系统 SHALL 在列表页顶部展示三个统计数字:全部 proposal 数量、进行中(applying 状态)数量、已归档数量。 + +统计 SHALL 基于 `proposal:list` 返回的全部 ProposalMeta 计数,不区分 worktree 来源;worktree 来源的 active change 计入 "进行中",主仓库 archive 来源计入"已归档"。 + +#### Scenario: Statistics reflect current data + +- **WHEN** 用户进入 `/proposal` 页面 +- **THEN** 页面顶部显示全部、进行中、已归档三个统计数字 +- **AND** 数字与当前 project 的所有 proposal(含 worktree 来源)一致 + +#### Scenario: worktree applying change 计入进行中 + +- **WHEN** 列表中存在 worktree 来源、status 为 `applying` 的 ProposalMeta +- **THEN** "进行中"统计包含该条 diff --git a/openspec/changes/add-proposal-list-worktree-scan/tasks.md b/openspec/changes/add-proposal-list-worktree-scan/tasks.md new file mode 100644 index 00000000..2bf6eb1b --- /dev/null +++ b/openspec/changes/add-proposal-list-worktree-scan/tasks.md @@ -0,0 +1,74 @@ +## 1. openspec-reader 双源扫描 + +- [ ] 1.1 修改 `electron/main/domain/proposal/openspec-reader.ts`:把现有 `readProposalFiles(projectPath)` 中扫描 main active 与 main archive 的逻辑抽出为两个内部函数 `readActiveDir(dir, worktreePath?)` 与 `readArchiveDir(dir)`。两个函数返回 `ProposalMeta[]`。`readActiveDir` 接收 `worktreePath?: string` 参数,对每条产出的 meta 把该字段赋给 `worktreePath` 字段(`path.resolve` 规范化;为 `undefined` 时字段保持 undefined)。 +- [ ] 1.2 在 `openspec-reader.ts` 新增 `readWorktreesActiveDirs(worktreesRoot: string): Promise` 函数。实现: + - 调 `fs.readdir(worktreesRoot, { withFileTypes: true })`;目录不存在或失败时返回 `[]`。 + - 对每个 `entry.isDirectory()` 的子目录 `wt`,计算 `worktreePath = path.resolve(worktreesRoot, wt.name)`;调 `readActiveDir(join(worktreePath, "openspec", "changes"), worktreePath)` 收集 metas。 + - 不扫 `/openspec/changes/archive/`(worktree 内 archive 不在 P3 范围)。 + - 把所有 worktree 的 metas 拼接返回。 +- [ ] 1.3 重写 `readProposalFiles(projectPath)` 主流程: + ```ts + const baseChangesDir = join(projectPath, "openspec", "changes"); + const fromMain = await readActiveDir(baseChangesDir, undefined); + const fromArchive = await readArchiveDir(join(baseChangesDir, "archive")); + const fromWorktrees = await readWorktreesActiveDirs(join(projectPath, ".worktrees")); + const dedupe = new Map(); + for (const m of fromMain) dedupe.set(m.id, m); + for (const m of fromArchive) dedupe.set(m.id, m); + for (const m of fromWorktrees) dedupe.set(m.id, m); // 覆盖 main active 同名 + return Array.from(dedupe.values()).sort(byCreatedDesc); + ``` +- [ ] 1.4 修改 `resolveChangeDir(projectPath, changeId)`:在原有 main → archive 探查之后,新增 worktree 探查——`fs.readdir(/.worktrees, ...)` → 对每个 worktree 子目录检查 `/openspec/changes//.openspec.yaml` 是否存在,命中则返回该路径;全部 miss 时返回 `null`。worktrees 目录不存在时按现状返回 `null`。 +- [ ] 1.5 验收:`pnpm typecheck` 通过;导出的 `readProposalFiles` 与 `resolveChangeDir` 函数签名不变(向后兼容,所有调用方无需改动)。 + +## 2. 单测覆盖 + +- [ ] 2.1 在 `electron/main/domain/proposal/__tests__/openspec-reader.spec.ts`(如不存在则建)覆盖以下场景: + - 2.1.1 worktrees 目录不存在 → list 仅含 main 来源;不抛错。 + - 2.1.2 单个 worktree 单 change → list 含一条 worktreePath 非空的条目;字符串等于 `path.resolve(/.worktrees/)`。 + - 2.1.3 多个 worktree 各自一份 change → list 同时含两条 worktreePath 字段不同的条目。 + - 2.1.4 main active 与 worktree 同名 → list 中该 changeId 仅出现一次,且 worktreePath 非空(worktree 优先)。 + - 2.1.5 main archive 与 worktree active 同名(业务上不会发生但测试 archive 路径不参与去重):archive 那条 changeId 含日期前缀,worktree 那条 changeId 不含日期前缀,list 同时含两条。 + - 2.1.6 worktree 子目录中 `.openspec.yaml` 缺失 → 跳过该子目录,不抛错。 + - 2.1.7 worktree 路径含 trailing slash 的边界(mock fs 路径返回 `/abs/.worktrees/foo/` 时)→ ProposalMeta.worktreePath === `/abs/.worktrees/foo`。 +- [ ] 2.2 在同一测试文件添加 `resolveChangeDir` 测试: + - 2.2.1 main active 命中 → 返回 main 路径。 + - 2.2.2 main miss、main archive 命中 → 返回 archive 路径。 + - 2.2.3 main miss、archive miss、worktree 命中 → 返回 worktree 路径。 + - 2.2.4 三处都 miss → 返回 null。 +- [ ] 2.3 验收:`pnpm test electron/main/domain/proposal` 全过;新增用例覆盖 2.1.1 - 2.2.4。 + +## 3. 列表页卡片标记 + +- [ ] 3.1 修改 `frontend/src/pages/proposal/index.vue` 卡片模板:在 status badge 同行右侧(紧邻 `` 之后),追加 worktree 标记元素: + ```vue + + + worktree + + ``` +- [ ] 3.2 验收:`worktreePath` 非空的卡片右上角显示 git-branch icon + "worktree" 文案;hover 显示完整路径;`worktreePath` 为 undefined 的卡片不渲染该 DOM。 + +## 4. dogfood 与零回归验证 + +- [ ] 4.1 启动 FylloCode(`pnpm dev`)打开任一 git 项目;先用 P2 的 chat 编排创建一个 worktree change(流程:用户同意 propose → agent 跑 worktree add → 调 create-proposal 落 artifacts 到 `.worktrees/foo/`)。 +- [ ] 4.2 进入 `/proposal` 列表页,确认看到该 change: + - 4.2.1 卡片标题为 worktree 内的 change name 标题化。 + - 4.2.2 status badge 旁边出现 worktree 标记。 + - 4.2.3 hover 标记看到完整 worktree 绝对路径。 +- [ ] 4.3 点击该卡片进入详情页,确认能正确读到 proposal.md / design.md / specs / tasks 内容(来自 worktree 内的 artifacts)。 +- [ ] 4.4 在详情页触发一次 apply(任一 stage);从主进程 logger / `data/projects//apply-runs//run.json` 中确认 `worktreePath` 字段被写入(值等于 worktree 绝对路径);apply ACP session cwd 等于 worktree 绝对路径(可在 logger 中 grep "cwd")。 +- [ ] 4.5 用一个非 git 项目(`template: "empty"`)确认列表完全等价于改造前:worktree 标记不出现;apply / archive cwd 仍为主仓库;行为零回归。 +- [ ] 4.6 已经 archive 完成的旧 change(`archive/-`)仍在列表显示,状态为 archived;与可能并存的 worktree 来源 active change 不冲突(如果存在异常状态)。 +- [ ] 4.7 `pnpm build` / `pnpm lint` / `pnpm typecheck` 全部通过。 +- [ ] 4.8 验收:4.1–4.7 全部通过。 + +## 5. 文档与下游对齐 + +- [ ] 5.1 检查 `electron/main/services/proposal/apply-run-service.ts` 当前实现是否在 `createApplyRun` 时已经从 ProposalMeta 透传 worktreePath(P1 已完成此 task)。如果发现未透传,按 P1 task 4.1 的实现补齐——但 P1 已完成,本任务只校验。 +- [ ] 5.2 检查 `electron/main/ipc/proposal-apply.ts` stage stream 与 archive handler 的 cwd 是否使用 `runMeta.worktreePath ?? projectPath`(P1 已完成);reminderContext 是否含 worktreePath(P1 已完成)。本任务只校验。 +- [ ] 5.3 验收:5.1 / 5.2 校验通过;如果发现 P1 漏实施,回头补 P1 task,**不**在 P3 内补丁。 From 26c60814f07a14a1b57a4b87348edfdc072c0bad Mon Sep 17 00:00:00 2001 From: Fio Date: Tue, 19 May 2026 19:09:05 +0800 Subject: [PATCH 03/19] feat(worktree): wip! add-multi-worktree-foundation --- .../infra/storage/apply-run-store.spec.ts | 47 +++- .../main/__tests__/ipc/proposal-apply.spec.ts | 61 ++++ .../proposal/apply-run-service.spec.ts | 148 ++++++++++ .../main/domain/proposal/openspec-reader.ts | 8 + electron/main/ipc/proposal-apply.ts | 6 +- electron/main/services/chat/acp-session.ts | 1 + .../services/chat/system-reminder/types.ts | 1 + .../services/proposal/apply-run-service.ts | 10 +- .../fyllo-specs/__tests__/tools.test.ts | 264 +++++++++++++++--- .../fyllo-specs/src/prompts/apply-change.md | 5 +- .../fyllo-specs/src/prompts/archive-change.md | 4 +- .../src/prompts/create-proposal.md | 5 +- .../fyllo-specs/src/prompts/explore.md | 4 + .../fyllo-specs/src/tools/apply-change.ts | 17 +- .../fyllo-specs/src/tools/archive-change.ts | 17 +- .../fyllo-specs/src/tools/create-proposal.ts | 17 +- mcp-servers/fyllo-specs/src/tools/explore.ts | 17 +- .../fyllo-specs/src/utils/project-root.ts | 57 ++++ .../.openspec.yaml | 2 +- .../add-multi-worktree-foundation/tasks.md | 50 ++-- shared/types/proposal.ts | 2 + 21 files changed, 662 insertions(+), 81 deletions(-) create mode 100644 electron/main/__tests__/services/proposal/apply-run-service.spec.ts diff --git a/electron/main/__tests__/infra/storage/apply-run-store.spec.ts b/electron/main/__tests__/infra/storage/apply-run-store.spec.ts index fc7dbc58..56b3a845 100644 --- a/electron/main/__tests__/infra/storage/apply-run-store.spec.ts +++ b/electron/main/__tests__/infra/storage/apply-run-store.spec.ts @@ -1,4 +1,4 @@ -import { rmSync } from "fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import type { UIMessage } from "ai"; import type { MessageMeta } from "@shared/types/chat"; @@ -22,6 +22,7 @@ vi.mock("@main/infra/logger", () => ({ })); import { + applyRunDir, appendArchiveMessage, loadApplyRunMeta, loadArchiveMessages, @@ -158,4 +159,48 @@ describe("apply-run-store archive storage", () => { await expect(loadArchiveRunMeta("/tmp/project", "change-1")).resolves.toBeNull(); expect(loggerWarn).toHaveBeenCalledOnce(); }); + + it("loads saved apply run meta with worktreePath omitted as undefined", async () => { + await saveApplyRunMeta("/tmp/project", runMeta({ worktreePath: undefined })); + + const raw = readFileSync(`${applyRunDir("/tmp/project", "change-1")}/run.json`, "utf8"); + expect(raw).not.toContain("worktreePath"); + + const meta = await loadApplyRunMeta("/tmp/project", "change-1"); + expect(meta?.worktreePath).toBeUndefined(); + }); + + it("loads legacy apply run meta without a worktreePath field", async () => { + const dir = applyRunDir("/tmp/project", "change-1"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + `${dir}/run.json`, + JSON.stringify({ + runId: "run-1", + changeId: "change-1", + workflowId: "workflow-1", + stages: [], + currentStageIndex: 1, + stageAcpSessionIds: { 0: "acp-0" }, + status: "running", + startedAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + }), + "utf8" + ); + + const meta = await loadApplyRunMeta("/tmp/project", "change-1"); + expect(meta?.worktreePath).toBeUndefined(); + }); + + it("round-trips an absolute worktreePath for apply run meta", async () => { + await saveApplyRunMeta( + "/tmp/project", + runMeta({ worktreePath: "/tmp/project/.worktrees/change-1" }) + ); + + await expect(loadApplyRunMeta("/tmp/project", "change-1")).resolves.toEqual( + runMeta({ worktreePath: "/tmp/project/.worktrees/change-1" }) + ); + }); }); diff --git a/electron/main/__tests__/ipc/proposal-apply.spec.ts b/electron/main/__tests__/ipc/proposal-apply.spec.ts index 19bd00d3..b286388a 100644 --- a/electron/main/__tests__/ipc/proposal-apply.spec.ts +++ b/electron/main/__tests__/ipc/proposal-apply.spec.ts @@ -326,11 +326,13 @@ describe("registerProposalApplyHandlers", () => { expect(opts).toEqual( expect.objectContaining({ + cwd: "/tmp/project", owner: "apply", reminderContext: { changeId: "change-1", stageIndex: 0, runId: "run-1", + worktreePath: undefined, }, }) ); @@ -369,6 +371,33 @@ describe("registerProposalApplyHandlers", () => { expect(mocks.updateRunMetaIfCurrent).not.toHaveBeenCalled(); }); + it("uses apply run worktreePath as cwd when present", async () => { + mocks.loadApplyRunMeta.mockResolvedValueOnce({ + ...runMeta, + worktreePath: "/tmp/project/.worktrees/change-1", + }); + + handler(ProposalChannels.stageStream)( + { sender: { postMessage: vi.fn() } }, + { runId: "run-1", stageIndex: 0, projectId: "project-1", changeId: "change-1" } + ); + + const sink = { sendChunk: vi.fn(), sendDone: vi.fn(), sendError: vi.fn() }; + await mocks.onReady!(sink); + + const acpSessionMock = vi.mocked((await import("@main/services/chat/acp-session")).AcpSession); + const opts = acpSessionMock.mock.calls[0]?.[0] as AcpSessionOpts | undefined; + expect(opts).toEqual( + expect.objectContaining({ + cwd: "/tmp/project/.worktrees/change-1", + projectPath: "/tmp/project", + reminderContext: expect.objectContaining({ + worktreePath: "/tmp/project/.worktrees/change-1", + }), + }) + ); + }); + it("forwards stage reasoning_delta through assembler and sink", async () => { handler(ProposalChannels.stageStream)( { sender: { postMessage: vi.fn() } }, @@ -436,11 +465,13 @@ describe("registerProposalApplyHandlers", () => { expect(typedOpts).toEqual( expect.objectContaining({ + cwd: "/tmp/project", fylloSessionId: "run-1-archive", owner: "archive", reminderContext: expect.objectContaining({ changeId: "change-1", runId: expect.stringMatching(/^archive-/), + worktreePath: undefined, }), }) ); @@ -464,6 +495,36 @@ describe("registerProposalApplyHandlers", () => { ).toHaveLength(1); }); + it("uses apply run worktreePath for archive cwd when present", async () => { + mocks.loadApplyRunMeta.mockResolvedValueOnce({ + ...runMeta, + status: "done", + worktreePath: "/tmp/project/.worktrees/change-1", + }); + + handler(ProposalChannels.archive)( + { sender: { postMessage: vi.fn() } }, + { projectId: "project-1", changeId: "change-1" } + ); + + const sink = { sendChunk: vi.fn(), sendDone: vi.fn(), sendError: vi.fn() }; + await mocks.onReady!(sink); + + const calls = vi.mocked((await import("@main/services/chat/acp-session")).AcpSession).mock + .calls; + const [opts] = calls[calls.length - 1]; + const typedOpts = opts as AcpSessionOpts | undefined; + expect(typedOpts).toEqual( + expect.objectContaining({ + cwd: "/tmp/project/.worktrees/change-1", + projectPath: "/tmp/project", + reminderContext: expect.objectContaining({ + worktreePath: "/tmp/project/.worktrees/change-1", + }), + }) + ); + }); + it("rejects archive when the completed stage acpSessionId is missing", async () => { mocks.loadApplyRunMeta.mockResolvedValueOnce({ ...runMeta, diff --git a/electron/main/__tests__/services/proposal/apply-run-service.spec.ts b/electron/main/__tests__/services/proposal/apply-run-service.spec.ts new file mode 100644 index 00000000..1bde02cc --- /dev/null +++ b/electron/main/__tests__/services/proposal/apply-run-service.spec.ts @@ -0,0 +1,148 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { join, resolve } from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProposalMeta } from "@shared/types/proposal"; +import type { WorkflowTemplate } from "@shared/types/workflow"; + +const { tempRoot, mocks } = vi.hoisted(() => ({ + tempRoot: `/private/tmp/fyllocode-apply-run-service-${Math.random().toString(36).slice(2)}`, + mocks: { + findProposalMetaById: vi.fn(), + loadAllWorkflowTemplates: vi.fn(), + loadProject: vi.fn(), + newRunId: vi.fn(), + resolveChangeDir: vi.fn(), + }, +})); + +vi.mock("@main/infra/paths", () => ({ + getDataSubPath: vi.fn((subPath: string) => `${tempRoot}/${subPath}`), +})); + +vi.mock("@main/infra/storage/project-store", () => ({ + loadProject: mocks.loadProject, +})); + +vi.mock("@main/services/workflow/workflow-service", () => ({ + loadAllWorkflowTemplates: mocks.loadAllWorkflowTemplates, +})); + +vi.mock("@main/infra/ids", () => ({ + newRunId: mocks.newRunId, +})); + +vi.mock("@main/domain/proposal/openspec-reader", async () => { + const actual = await vi.importActual( + "@main/domain/proposal/openspec-reader" + ); + return { + ...actual, + findProposalMetaById: mocks.findProposalMetaById, + resolveChangeDir: mocks.resolveChangeDir, + }; +}); + +import { applyRunDir, loadApplyRunMeta } from "@main/infra/storage/apply-run-store"; +import { createApplyRun } from "@main/services/proposal/apply-run-service"; + +function workflowTemplate(): WorkflowTemplate { + return { + id: "workflow-1", + name: "Workflow", + source: "custom", + yaml: "name: Workflow", + stages: [{ id: "stage-1", name: "Apply", type: "proposal-apply", agent: "codex" }], + }; +} + +function proposalMeta(overrides: Partial = {}): ProposalMeta { + return { + id: "change-1", + title: "Change 1", + status: "draft", + why: "Why", + totalTasks: 1, + doneTasks: 0, + hasDesign: false, + date: "2026-05-19", + ...overrides, + }; +} + +function seedChange(projectPath: string, changeId: string): string { + const changeDir = join(projectPath, "openspec", "changes", changeId); + mkdirSync(changeDir, { recursive: true }); + writeFileSync(join(changeDir, ".openspec.yaml"), "schema: spec-driven\nstatus: draft\n", "utf8"); + return changeDir; +} + +describe("apply-run-service", () => { + const projectPath = `${tempRoot}/project`; + + beforeEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); + vi.clearAllMocks(); + mocks.loadProject.mockResolvedValue({ id: "project-1", path: projectPath }); + mocks.loadAllWorkflowTemplates.mockResolvedValue([workflowTemplate()]); + mocks.newRunId.mockReturnValue("run-1"); + mocks.resolveChangeDir.mockImplementation(async (_projectPath: string, changeId: string) => + join(projectPath, "openspec", "changes", changeId) + ); + }); + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("omits worktreePath from run.json when proposal meta does not provide it", async () => { + seedChange(projectPath, "change-1"); + mocks.findProposalMetaById.mockResolvedValue(proposalMeta({ worktreePath: undefined })); + + await createApplyRun({ + projectId: "project-1", + changeId: "change-1", + workflowId: "workflow-1", + }); + + const persisted = readFileSync(join(applyRunDir(projectPath, "change-1"), "run.json"), "utf8"); + expect(persisted).not.toContain("worktreePath"); + const runMeta = await loadApplyRunMeta(projectPath, "change-1"); + expect(runMeta?.changeId).toBe("change-1"); + expect(runMeta?.worktreePath).toBeUndefined(); + }); + + it("normalizes a trailing slash before persisting worktreePath", async () => { + seedChange(projectPath, "change-2"); + mocks.findProposalMetaById.mockResolvedValue( + proposalMeta({ id: "change-2", worktreePath: "/tmp/worktrees/foo/" }) + ); + + await createApplyRun({ + projectId: "project-1", + changeId: "change-2", + workflowId: "workflow-1", + }); + + await expect(loadApplyRunMeta(projectPath, "change-2")).resolves.toMatchObject({ + changeId: "change-2", + worktreePath: resolve("/tmp/worktrees/foo/"), + }); + }); + + it("persists an absolute worktreePath without changing its resolved value", async () => { + const worktreePath = resolve("/tmp/worktrees/bar"); + seedChange(projectPath, "change-3"); + mocks.findProposalMetaById.mockResolvedValue(proposalMeta({ id: "change-3", worktreePath })); + + await createApplyRun({ + projectId: "project-1", + changeId: "change-3", + workflowId: "workflow-1", + }); + + const persisted = JSON.parse( + readFileSync(join(applyRunDir(projectPath, "change-3"), "run.json"), "utf8") + ) as { worktreePath?: string }; + expect(persisted.worktreePath).toBe(worktreePath); + }); +}); diff --git a/electron/main/domain/proposal/openspec-reader.ts b/electron/main/domain/proposal/openspec-reader.ts index 0fbb6c7d..fcf554dc 100644 --- a/electron/main/domain/proposal/openspec-reader.ts +++ b/electron/main/domain/proposal/openspec-reader.ts @@ -178,6 +178,14 @@ export async function readProposalFiles(projectPath: string): Promise { + const proposals = await readProposalFiles(projectPath); + return proposals.find((proposal) => proposal.id === changeId) ?? null; +} + export async function resolveChangeDir( projectPath: string, changeId: string diff --git a/electron/main/ipc/proposal-apply.ts b/electron/main/ipc/proposal-apply.ts index 7082aacc..d620a2f2 100644 --- a/electron/main/ipc/proposal-apply.ts +++ b/electron/main/ipc/proposal-apply.ts @@ -129,13 +129,14 @@ export function registerProposalApplyHandlers(): void { fylloSessionId, agentId, projectPath, - cwd: projectPath, + cwd: runMeta.worktreePath ?? projectPath, owner: "apply", sessionStore, reminderContext: { changeId: form.changeId, stageIndex: form.stageIndex, runId: form.runId, + worktreePath: runMeta.worktreePath, }, onReminderInjected: async (reminderPart) => { await prependReminderToLastUserMessage( @@ -331,12 +332,13 @@ export function registerProposalApplyHandlers(): void { fylloSessionId, agentId, projectPath, - cwd: projectPath, + cwd: runMeta.worktreePath ?? projectPath, owner: "archive", sessionStore, reminderContext: { changeId: form.changeId, runId: archiveRunId, + worktreePath: runMeta.worktreePath, }, onReminderInjected: async (reminderPart) => { await prependReminderToLastUserMessage( diff --git a/electron/main/services/chat/acp-session.ts b/electron/main/services/chat/acp-session.ts index a070cf4e..5ff0f0dd 100644 --- a/electron/main/services/chat/acp-session.ts +++ b/electron/main/services/chat/acp-session.ts @@ -33,6 +33,7 @@ interface ReminderContext { changeId?: string; stageIndex?: number; runId?: string; + worktreePath?: string; } type PromptPart = { type: "text"; text: string }; diff --git a/electron/main/services/chat/system-reminder/types.ts b/electron/main/services/chat/system-reminder/types.ts index 92d1680e..6a95dde5 100644 --- a/electron/main/services/chat/system-reminder/types.ts +++ b/electron/main/services/chat/system-reminder/types.ts @@ -9,4 +9,5 @@ export interface SystemReminderContext { changeId?: string; stageIndex?: number; runId?: string; + worktreePath?: string; } diff --git a/electron/main/services/proposal/apply-run-service.ts b/electron/main/services/proposal/apply-run-service.ts index ece2159b..cc1b3b21 100644 --- a/electron/main/services/proposal/apply-run-service.ts +++ b/electron/main/services/proposal/apply-run-service.ts @@ -1,12 +1,16 @@ import { promises as fs } from "fs"; -import { join } from "path"; +import { join, resolve } from "path"; import { load, dump } from "js-yaml"; import type { ApplyRunMeta, ProposalStatus } from "@shared/types/proposal"; import type { WorkflowStage, WorkflowTemplate } from "@shared/types/workflow"; import { IpcErrorCodes } from "@shared/constants/error-codes"; import { loadProject } from "@main/infra/storage/project-store"; import { saveApplyRunMeta } from "@main/infra/storage/apply-run-store"; -import { resolveApplyRunChangeId, resolveChangeDir } from "@main/domain/proposal/openspec-reader"; +import { + findProposalMetaById, + resolveApplyRunChangeId, + resolveChangeDir, +} from "@main/domain/proposal/openspec-reader"; import { loadAllWorkflowTemplates } from "@main/services/workflow/workflow-service"; import { newRunId } from "@main/infra/ids"; import { ipcError } from "@main/ipc/_kit/errors"; @@ -79,6 +83,7 @@ export async function createApplyRun(input: { }): Promise<{ runId: string; stages: WorkflowStage[] }> { const projectPath = await resolveProjectPath(input.projectId); const template = await findWorkflowTemplate(input.projectId, input.workflowId); + const proposalMeta = await findProposalMetaById(projectPath, input.changeId); if (!template) { throw ipcError(IpcErrorCodes.WORKFLOW_NOT_FOUND, `Workflow not found: ${input.workflowId}`); } @@ -95,6 +100,7 @@ export async function createApplyRun(input: { status: "running", startedAt, updatedAt: startedAt, + worktreePath: proposalMeta?.worktreePath ? resolve(proposalMeta.worktreePath) : undefined, }; await saveApplyRunMeta(projectPath, runMeta); diff --git a/mcp-servers/fyllo-specs/__tests__/tools.test.ts b/mcp-servers/fyllo-specs/__tests__/tools.test.ts index 209d45ab..ba587789 100644 --- a/mcp-servers/fyllo-specs/__tests__/tools.test.ts +++ b/mcp-servers/fyllo-specs/__tests__/tools.test.ts @@ -1,11 +1,17 @@ -import { describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResultSchema, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { describe, expect, it, vi } from "vitest"; import { applyChangeTool } from "../src/tools/apply-change"; import { createProposalTool } from "../src/tools/create-proposal"; import { archiveChangeTool } from "../src/tools/archive-change"; import { exploreTool } from "../src/tools/explore"; +import { registerTools } from "../src/tools"; +import { gitChildProcess } from "../src/utils/project-root"; function parseState(text: string): Record { const match = text.match(/\n([\s\S]+?)\n<\/state>/); @@ -14,6 +20,25 @@ function parseState(text: string): Record { return JSON.parse(text); } +async function createToolClient(): Promise<{ + client: Client; + close: () => Promise; +}> { + const server = new McpServer({ name: "fyllo-specs-test", version: "1.0.0" }); + registerTools(server); + const client = new Client({ name: "fyllo-specs-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return { + client, + close: async () => { + await clientTransport.close(); + await serverTransport.close(); + await server.close(); + }, + }; +} + describe("tools", () => { const cliPath = join( process.cwd(), @@ -23,51 +48,211 @@ describe("tools", () => { "bin", "openspec.js" ); - const fixtureRoot = join( - process.cwd(), - "mcp-servers", - "fyllo-specs", - "__tests__", - "fixtures", - "openspec-sample" - ); + const repoRoot = process.cwd(); it("explore returns state", async () => { - const text = await exploreTool({}); - expect(text).toContain(""); - expect(text).toContain(""); + const prev = process.env.FYLLO_PROJECT_PATH; + process.env.FYLLO_PROJECT_PATH = repoRoot; + try { + const text = await exploreTool({ targetPath: repoRoot }); + expect(text).toContain(""); + expect(text).toContain(""); + } finally { + process.env.FYLLO_PROJECT_PATH = prev; + } + }); + + it("tools reject missing targetPath via MCP SDK validation", async () => { + const { client, close } = await createToolClient(); + try { + const exploreResult = await client.request( + { method: "tools/call", params: { name: "explore", arguments: {} } }, + CallToolResultSchema + ); + expect(exploreResult.isError).toBe(true); + expect(exploreResult.content[0].type).toBe("text"); + expect(exploreResult.content[0].text).toContain(String(ErrorCode.InvalidParams)); + expect(exploreResult.content[0].text).toContain("targetPath"); + + const createProposalResult = await client.request( + { + method: "tools/call", + params: { name: "create-proposal", arguments: { changeName: "sample-change" } }, + }, + CallToolResultSchema + ); + expect(createProposalResult.isError).toBe(true); + expect(createProposalResult.content[0].type).toBe("text"); + expect(createProposalResult.content[0].text).toContain(String(ErrorCode.InvalidParams)); + expect(createProposalResult.content[0].text).toContain("targetPath"); + + const applyChangeResult = await client.request( + { + method: "tools/call", + params: { name: "apply-change", arguments: { changeName: "sample-change" } }, + }, + CallToolResultSchema + ); + expect(applyChangeResult.isError).toBe(true); + expect(applyChangeResult.content[0].type).toBe("text"); + expect(applyChangeResult.content[0].text).toContain(String(ErrorCode.InvalidParams)); + expect(applyChangeResult.content[0].text).toContain("targetPath"); + + const archiveChangeResult = await client.request( + { + method: "tools/call", + params: { name: "archive-change", arguments: { changeName: "sample-change" } }, + }, + CallToolResultSchema + ); + expect(archiveChangeResult.isError).toBe(true); + expect(archiveChangeResult.content[0].type).toBe("text"); + expect(archiveChangeResult.content[0].text).toContain(String(ErrorCode.InvalidParams)); + expect(archiveChangeResult.content[0].text).toContain("targetPath"); + } finally { + await close(); + } + }); + + it("explore rejects relative targetPath without calling git", async () => { + const spawnSyncSpy = vi.spyOn(gitChildProcess, "spawnSync"); + try { + const text = await exploreTool({ targetPath: "./relative-path" }); + const state = parseState(text); + expect(state.errors).toBeInstanceOf(Array); + expect((state.errors as Array<{ type: string }>)[0].type).toBe("InvalidTargetPath"); + expect((state.errors as Array<{ message: string }>)[0].message).toContain( + "targetPath must be an absolute path" + ); + expect(spawnSyncSpy).not.toHaveBeenCalled(); + } finally { + spawnSyncSpy.mockRestore(); + } }); it("explore returns plain JSON when includeInstruction is false", async () => { - const text = await exploreTool({ includeInstruction: false }); - expect(text).not.toContain(""); - const state = JSON.parse(text); - expect(state).toHaveProperty("activeChanges"); + const prev = process.env.FYLLO_PROJECT_PATH; + process.env.FYLLO_PROJECT_PATH = repoRoot; + try { + const text = await exploreTool({ targetPath: repoRoot, includeInstruction: false }); + expect(text).not.toContain(""); + const state = JSON.parse(text); + expect(state).toHaveProperty("activeChanges"); + } finally { + process.env.FYLLO_PROJECT_PATH = prev; + } }); it("create-proposal returns error state for invalid input", async () => { - const text = await createProposalTool({ changeName: "bad name" }); - const state = parseState(text); - expect(state.errors).toBeInstanceOf(Array); - expect((state.errors as Array<{ message: string }>)[0].message).toContain("kebab-case"); + const prev = process.env.FYLLO_PROJECT_PATH; + process.env.FYLLO_PROJECT_PATH = repoRoot; + try { + const text = await createProposalTool({ changeName: "bad name", targetPath: repoRoot }); + const state = parseState(text); + expect(state.errors).toBeInstanceOf(Array); + expect((state.errors as Array<{ message: string }>)[0].message).toContain("kebab-case"); + } finally { + process.env.FYLLO_PROJECT_PATH = prev; + } }); it("create-proposal returns plain JSON error when includeInstruction is false", async () => { - const text = await createProposalTool({ changeName: "bad name", includeInstruction: false }); - expect(text).not.toContain(""); - const state = JSON.parse(text); - expect(state.errors).toBeInstanceOf(Array); - expect((state.errors as Array<{ message: string }>)[0].message).toContain("kebab-case"); + const prev = process.env.FYLLO_PROJECT_PATH; + process.env.FYLLO_PROJECT_PATH = repoRoot; + try { + const text = await createProposalTool({ + changeName: "bad name", + targetPath: repoRoot, + includeInstruction: false, + }); + expect(text).not.toContain(""); + const state = JSON.parse(text); + expect(state.errors).toBeInstanceOf(Array); + expect((state.errors as Array<{ message: string }>)[0].message).toContain("kebab-case"); + } finally { + process.env.FYLLO_PROJECT_PATH = prev; + } + }); + + it("create-proposal rejects unregistered absolute targetPath with git output", async () => { + const prev = process.env.FYLLO_PROJECT_PATH; + process.env.FYLLO_PROJECT_PATH = repoRoot; + try { + const text = await createProposalTool({ + changeName: "valid-change", + targetPath: "/tmp/random-path", + }); + const state = parseState(text); + expect(state.errors).toBeInstanceOf(Array); + expect((state.errors as Array<{ type: string }>)[0].type).toBe("InvalidTargetPath"); + expect((state.errors as Array<{ message: string }>)[0].message).toContain( + "targetPath is not a registered git worktree" + ); + expect((state.errors as Array<{ message: string }>)[0].message).toContain("worktree "); + } finally { + process.env.FYLLO_PROJECT_PATH = prev; + } + }); + + it("explore accepts the git project root targetPath", async () => { + const prev = process.env.FYLLO_PROJECT_PATH; + process.env.FYLLO_PROJECT_PATH = repoRoot; + try { + const text = await exploreTool({ targetPath: repoRoot, includeInstruction: false }); + const state = JSON.parse(text); + expect(state.projectRoot).toBe(repoRoot); + expect(state.activeChanges).toBeInstanceOf(Array); + } finally { + process.env.FYLLO_PROJECT_PATH = prev; + } + }); + + it("explore accepts targetPath with trailing slash", async () => { + const prev = process.env.FYLLO_PROJECT_PATH; + process.env.FYLLO_PROJECT_PATH = repoRoot; + try { + const text = await exploreTool({ targetPath: `${repoRoot}/`, includeInstruction: false }); + const state = JSON.parse(text); + expect(state.projectRoot).toBe(repoRoot); + } finally { + process.env.FYLLO_PROJECT_PATH = prev; + } + }); + + it("explore uses non-git fallback only for the project root", async () => { + const root = mkdtempSync(join(tmpdir(), "fyllo-open-spec-")); + mkdirSync(join(root, "openspec", "changes"), { recursive: true }); + writeFileSync(join(root, "openspec", "config.yaml"), "schema: spec-driven\n", "utf8"); + + const prev = process.env.FYLLO_PROJECT_PATH; + process.env.FYLLO_PROJECT_PATH = root; + try { + const okText = await exploreTool({ targetPath: root, includeInstruction: false }); + const okState = JSON.parse(okText); + expect(okState.projectRoot).toBe(root); + + const badText = await exploreTool({ targetPath: "/tmp/elsewhere" }); + const badState = parseState(badText); + expect((badState.errors as Array<{ type: string }>)[0].type).toBe("InvalidTargetPath"); + expect((badState.errors as Array<{ message: string }>)[0].message).toContain( + "targetPath must be the project root for non-git projects" + ); + } finally { + process.env.FYLLO_PROJECT_PATH = prev; + } }); it("apply-change returns ready for the active change", async () => { const prev = process.env.FYLLO_PROJECT_PATH; const prevCli = process.env.FYLLO_OPENSPEC_CLI_PATH; - process.env.FYLLO_PROJECT_PATH = fixtureRoot; + process.env.FYLLO_PROJECT_PATH = repoRoot; process.env.FYLLO_OPENSPEC_CLI_PATH = cliPath; try { - const text = await applyChangeTool({ changeName: "sample-change" }); - expect(text).toContain('"changeName": "sample-change"'); + const text = await applyChangeTool({ + changeName: "add-multi-worktree-foundation", + targetPath: repoRoot, + }); + expect(text).toContain('"changeName": "add-multi-worktree-foundation"'); expect(text).toContain('"applyState": "ready"'); } finally { process.env.FYLLO_PROJECT_PATH = prev; @@ -85,7 +270,7 @@ describe("tools", () => { process.env.FYLLO_PROJECT_PATH = root; process.env.FYLLO_OPENSPEC_CLI_PATH = cliPath; try { - const text = await applyChangeTool({ changeName: "missing-change" }); + const text = await applyChangeTool({ changeName: "missing-change", targetPath: root }); const state = parseState(text); expect(state.errors).toBeInstanceOf(Array); expect((state.errors as Array<{ message: string }>)[0].message).toContain("Change not found"); @@ -103,7 +288,7 @@ describe("tools", () => { const prev = process.env.FYLLO_PROJECT_PATH; process.env.FYLLO_PROJECT_PATH = root; try { - const text = await archiveChangeTool({ changeName: "missing-change" }); + const text = await archiveChangeTool({ changeName: "missing-change", targetPath: root }); const state = parseState(text); expect(state.errors).toBeInstanceOf(Array); expect((state.errors as Array<{ message: string }>)[0].message).toContain("Change not found"); @@ -134,7 +319,11 @@ describe("tools", () => { process.env.FYLLO_PROJECT_PATH = root; process.env.FYLLO_OPENSPEC_CLI_PATH = cliPath; try { - const text = await archiveChangeTool({ changeName: "test-archive", confirm: true }); + const text = await archiveChangeTool({ + changeName: "test-archive", + targetPath: root, + confirm: true, + }); const state = parseState(text); expect(state.errors).toBeUndefined(); expect(state.changeName).toBe("test-archive"); @@ -143,11 +332,8 @@ describe("tools", () => { expect(state.archiveTarget).toContain("test-archive"); expect(typeof state.archiveRawOutput).toBe("string"); expect((state.archiveRawOutput as string).length).toBeGreaterThan(0); - // Source directory should be gone expect(existsSync(changeDir)).toBe(false); - // Archive directory should exist expect(existsSync(state.archiveTarget as string)).toBe(true); - // tasks.md should be in archive expect(existsSync(join(state.archiveTarget as string, "tasks.md"))).toBe(true); } finally { process.env.FYLLO_PROJECT_PATH = prev; @@ -162,14 +348,12 @@ describe("tools", () => { mkdirSync(changeDir, { recursive: true }); mkdirSync(mainSpecDir, { recursive: true }); - // Existing main spec writeFileSync( join(mainSpecDir, "spec.md"), "# test-cap Specification\n\n## Purpose\nTest.\n\n## Requirements\n\n### Requirement: Existing\n\nSystem SHALL do the original thing.\n\n#### Scenario: Original scenario\n\n- **WHEN** something happens\n- **THEN** it works\n", "utf8" ); - // Delta spec in change const specChangeDir = join(changeDir, "specs", "test-cap"); mkdirSync(specChangeDir, { recursive: true }); writeFileSync( @@ -191,19 +375,21 @@ describe("tools", () => { process.env.FYLLO_PROJECT_PATH = root; process.env.FYLLO_OPENSPEC_CLI_PATH = cliPath; try { - const text = await archiveChangeTool({ changeName: "test-sync-spec", confirm: true }); + const text = await archiveChangeTool({ + changeName: "test-sync-spec", + targetPath: root, + confirm: true, + }); const state = parseState(text); expect(state.errors).toBeUndefined(); expect(state.changeName).toBe("test-sync-spec"); expect(typeof state.archiveRawOutput).toBe("string"); expect((state.archiveRawOutput as string).length).toBeGreaterThan(0); - // Main spec should be updated by openspec CLI const mainSpecContent = readFileSync(join(mainSpecDir, "spec.md"), "utf8"); expect(mainSpecContent).toContain("System SHALL do the updated thing."); expect(mainSpecContent).toContain("System SHALL support the new feature."); - // Change should be archived expect(existsSync(changeDir)).toBe(false); expect(existsSync(state.archiveTarget as string)).toBe(true); } finally { diff --git a/mcp-servers/fyllo-specs/src/prompts/apply-change.md b/mcp-servers/fyllo-specs/src/prompts/apply-change.md index d0743ac7..eaf93696 100644 --- a/mcp-servers/fyllo-specs/src/prompts/apply-change.md +++ b/mcp-servers/fyllo-specs/src/prompts/apply-change.md @@ -1,6 +1,9 @@ Implement tasks from an OpenSpec change using the provided `state`. -**Input**: `state.changeName` identifies the change being implemented. +**Input**: `targetPath` is required. It must be an absolute path to the main repo root or a +registered git worktree for that repo. In P1, the agent should default to passing +`$FYLLO_PROJECT_PATH` (the main repo root). `state.changeName` identifies the change being +implemented. **Steps** diff --git a/mcp-servers/fyllo-specs/src/prompts/archive-change.md b/mcp-servers/fyllo-specs/src/prompts/archive-change.md index 8b5b62d5..488ccb34 100644 --- a/mcp-servers/fyllo-specs/src/prompts/archive-change.md +++ b/mcp-servers/fyllo-specs/src/prompts/archive-change.md @@ -1,6 +1,8 @@ Archive a completed change using the provided `state`. -**Input**: `state.changeName` identifies the change to archive. +**Input**: `targetPath` is required. It must be an absolute path to the main repo root or a +registered git worktree for that repo. In P1, the agent should default to passing +`$FYLLO_PROJECT_PATH` (the main repo root). `state.changeName` identifies the change to archive. **Steps** diff --git a/mcp-servers/fyllo-specs/src/prompts/create-proposal.md b/mcp-servers/fyllo-specs/src/prompts/create-proposal.md index 41639f82..9ca5f44f 100644 --- a/mcp-servers/fyllo-specs/src/prompts/create-proposal.md +++ b/mcp-servers/fyllo-specs/src/prompts/create-proposal.md @@ -1,6 +1,9 @@ Create or inspect an OpenSpec change using the provided `state`. -**Input**: `state.changeName` is the kebab-case name for this change. +**Input**: `targetPath` is required. It must be an absolute path to the main repo root or a +registered git worktree for that repo. In P1, the agent should default to passing +`$FYLLO_PROJECT_PATH` (the main repo root). `state.changeName` is the kebab-case name for this +change. **Steps** diff --git a/mcp-servers/fyllo-specs/src/prompts/explore.md b/mcp-servers/fyllo-specs/src/prompts/explore.md index abcbfc2a..01dbf599 100644 --- a/mcp-servers/fyllo-specs/src/prompts/explore.md +++ b/mcp-servers/fyllo-specs/src/prompts/explore.md @@ -1,3 +1,7 @@ +**Input**: `targetPath` is required. It must be an absolute path to the main repo root or a +registered git worktree for that repo. In P1, the agent should default to passing +`$FYLLO_PROJECT_PATH` (the main repo root). + Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. **IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. diff --git a/mcp-servers/fyllo-specs/src/tools/apply-change.ts b/mcp-servers/fyllo-specs/src/tools/apply-change.ts index 38f91b00..d5dc8f37 100644 --- a/mcp-servers/fyllo-specs/src/tools/apply-change.ts +++ b/mcp-servers/fyllo-specs/src/tools/apply-change.ts @@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { existsSync } from "fs"; import { z } from "zod"; import { runTool } from "../utils/state"; -import { resolveProjectRoot } from "../utils/project-root"; +import { validateTargetPath } from "../utils/project-root"; import { changeDir } from "../openspec-runtime"; import { loadApplyState } from "../openspec-runtime/tasks"; @@ -12,6 +12,10 @@ const applyChangeInputSchema = z.object({ .describe( "Name of the change to implement. Use the explore tool first if multiple active changes exist and the target is not yet decided." ), + targetPath: z + .string() + .min(1) + .describe("Absolute path to the project root or a registered git worktree."), includeInstruction: z .boolean() .optional() @@ -25,7 +29,16 @@ export async function applyChangeTool( input: z.infer ): Promise { return runTool("apply-change", { includeInstruction: input.includeInstruction }, async () => { - const projectRoot = resolveProjectRoot(); + const result = validateTargetPath(input.targetPath); + if (!result.ok) { + const error = new Error( + result.rawOutput ? `${result.error}\n\n${result.rawOutput}` : result.error + ); + error.name = "InvalidTargetPath"; + throw error; + } + + const projectRoot = result.resolved; if (!existsSync(changeDir(projectRoot, input.changeName))) { throw new Error(`Change not found: ${input.changeName}`); } diff --git a/mcp-servers/fyllo-specs/src/tools/archive-change.ts b/mcp-servers/fyllo-specs/src/tools/archive-change.ts index d539f8dd..db29aed8 100644 --- a/mcp-servers/fyllo-specs/src/tools/archive-change.ts +++ b/mcp-servers/fyllo-specs/src/tools/archive-change.ts @@ -2,12 +2,16 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { runTool } from "../utils/state"; import { archiveChange, changeDir } from "../openspec-runtime"; -import { resolveProjectRoot } from "../utils/project-root"; +import { validateTargetPath } from "../utils/project-root"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; const archiveChangeInputSchema = z.object({ changeName: z.string().describe("Name of the change to archive."), + targetPath: z + .string() + .min(1) + .describe("Absolute path to the project root or a registered git worktree."), confirm: z .boolean() .optional() @@ -28,7 +32,16 @@ export async function archiveChangeTool( input: z.infer ): Promise { return runTool("archive-change", { includeInstruction: input.includeInstruction }, async () => { - const projectRoot = resolveProjectRoot(); + const validation = validateTargetPath(input.targetPath); + if (!validation.ok) { + const error = new Error( + validation.rawOutput ? `${validation.error}\n\n${validation.rawOutput}` : validation.error + ); + error.name = "InvalidTargetPath"; + throw error; + } + + const projectRoot = validation.resolved; const changeDirPath = changeDir(projectRoot, input.changeName); if (!existsSync(changeDirPath)) { throw new Error(`Change not found: ${input.changeName}`); diff --git a/mcp-servers/fyllo-specs/src/tools/create-proposal.ts b/mcp-servers/fyllo-specs/src/tools/create-proposal.ts index f054d5ff..e2e70efd 100644 --- a/mcp-servers/fyllo-specs/src/tools/create-proposal.ts +++ b/mcp-servers/fyllo-specs/src/tools/create-proposal.ts @@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { runTool } from "../utils/state"; import { createChange, computeStatus, getInstructions } from "../openspec-runtime"; -import { resolveProjectRoot } from "../utils/project-root"; +import { validateTargetPath } from "../utils/project-root"; const createProposalInputSchema = z.object({ changeName: z @@ -10,6 +10,10 @@ const createProposalInputSchema = z.object({ .describe( "Kebab-case name for the change (e.g. 'add-user-auth'). Derive this from the user's intent before calling — ask the user what they want to build first if it isn't already clear." ), + targetPath: z + .string() + .min(1) + .describe("Absolute path to the project root or a registered git worktree."), includeInstruction: z .boolean() .optional() @@ -23,7 +27,16 @@ export async function createProposalTool( input: z.infer ): Promise { return runTool("create-proposal", { includeInstruction: input.includeInstruction }, async () => { - const projectRoot = resolveProjectRoot(); + const result = validateTargetPath(input.targetPath); + if (!result.ok) { + const error = new Error( + result.rawOutput ? `${result.error}\n\n${result.rawOutput}` : result.error + ); + error.name = "InvalidTargetPath"; + throw error; + } + + const projectRoot = result.resolved; if (!/^[a-z0-9][a-z0-9-]*$/.test(input.changeName)) { throw new Error("changeName must be kebab-case"); } diff --git a/mcp-servers/fyllo-specs/src/tools/explore.ts b/mcp-servers/fyllo-specs/src/tools/explore.ts index 46efc86e..367397fc 100644 --- a/mcp-servers/fyllo-specs/src/tools/explore.ts +++ b/mcp-servers/fyllo-specs/src/tools/explore.ts @@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { runTool } from "../utils/state"; import { listChanges, computeStatus } from "../openspec-runtime"; -import { resolveProjectRoot } from "../utils/project-root"; +import { validateTargetPath } from "../utils/project-root"; const exploreInputSchema = z.object({ changeName: z @@ -11,6 +11,10 @@ const exploreInputSchema = z.object({ .describe( "Name of a specific change to inspect. Omit to get an overview of all active changes." ), + targetPath: z + .string() + .min(1) + .describe("Absolute path to the project root or a registered git worktree."), includeInstruction: z .boolean() .optional() @@ -22,7 +26,16 @@ const exploreInputSchema = z.object({ export async function exploreTool(input: z.infer): Promise { return runTool("explore", { includeInstruction: input.includeInstruction }, async () => { - const projectRoot = resolveProjectRoot(); + const result = validateTargetPath(input.targetPath); + if (!result.ok) { + const error = new Error( + result.rawOutput ? `${result.error}\n\n${result.rawOutput}` : result.error + ); + error.name = "InvalidTargetPath"; + throw error; + } + + const projectRoot = result.resolved; const activeChanges = await listChanges(projectRoot); const currentChange = input.changeName ? await computeStatus(projectRoot, input.changeName) diff --git a/mcp-servers/fyllo-specs/src/utils/project-root.ts b/mcp-servers/fyllo-specs/src/utils/project-root.ts index 21048fc5..080ce102 100644 --- a/mcp-servers/fyllo-specs/src/utils/project-root.ts +++ b/mcp-servers/fyllo-specs/src/utils/project-root.ts @@ -1,3 +1,60 @@ +import { spawnSync } from "child_process"; +import path from "path"; + +export interface TargetPathValidationResult { + ok: boolean; + resolved?: string; + rawOutput?: string; + error?: string; +} + export function resolveProjectRoot(): string { return process.env.FYLLO_PROJECT_PATH || process.cwd(); } + +export const gitChildProcess = { + spawnSync, +}; + +export function validateTargetPath(targetPath: string): TargetPathValidationResult { + if (!path.isAbsolute(targetPath)) { + return { ok: false, error: "targetPath must be an absolute path" }; + } + + const resolved = path.resolve(targetPath); + const projectRoot = path.resolve(process.env.FYLLO_PROJECT_PATH ?? ""); + const result = gitChildProcess.spawnSync( + "git", + ["-C", process.env.FYLLO_PROJECT_PATH ?? "", "worktree", "list", "--porcelain"], + { encoding: "utf8" } + ); + + if (result.status === 0) { + const worktreePaths = new Set( + (result.stdout ?? "") + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => path.resolve(line.slice("worktree ".length).trim())) + ); + + if (worktreePaths.has(resolved)) { + return { ok: true, resolved }; + } + + return { + ok: false, + rawOutput: result.stdout ?? "", + error: "targetPath is not a registered git worktree", + }; + } + + if (resolved === projectRoot) { + return { ok: true, resolved }; + } + + return { + ok: false, + rawOutput: result.stderr ?? result.error?.message ?? "", + error: "targetPath must be the project root for non-git projects", + }; +} diff --git a/openspec/changes/add-multi-worktree-foundation/.openspec.yaml b/openspec/changes/add-multi-worktree-foundation/.openspec.yaml index 65b9a4e8..e4ba8648 100644 --- a/openspec/changes/add-multi-worktree-foundation/.openspec.yaml +++ b/openspec/changes/add-multi-worktree-foundation/.openspec.yaml @@ -1,3 +1,3 @@ schema: spec-driven created: 2026-05-19T00:00:00.000Z -status: draft +status: applying diff --git a/openspec/changes/add-multi-worktree-foundation/tasks.md b/openspec/changes/add-multi-worktree-foundation/tasks.md index 60253032..811a49c5 100644 --- a/openspec/changes/add-multi-worktree-foundation/tasks.md +++ b/openspec/changes/add-multi-worktree-foundation/tasks.md @@ -1,69 +1,69 @@ ## 1. shared 类型扩展 -- [ ] 1.1 在 `shared/types/proposal.ts` 的 `ProposalMeta` interface 中追加 `worktreePath?: string` 字段,紧跟 `date` 字段之后。 -- [ ] 1.2 在 `shared/types/proposal.ts` 的 `ApplyRunMeta` interface 中追加 `worktreePath?: string` 字段,紧跟 `updatedAt` 之后。 -- [ ] 1.3 验收:`pnpm typecheck` 通过;`shared/types/proposal.ts` 中两处 `worktreePath?: string` 字段存在。 +- [x] 1.1 在 `shared/types/proposal.ts` 的 `ProposalMeta` interface 中追加 `worktreePath?: string` 字段,紧跟 `date` 字段之后。 +- [x] 1.2 在 `shared/types/proposal.ts` 的 `ApplyRunMeta` interface 中追加 `worktreePath?: string` 字段,紧跟 `updatedAt` 之后。 +- [x] 1.3 验收:`pnpm typecheck` 通过;`shared/types/proposal.ts` 中两处 `worktreePath?: string` 字段存在。 ## 2. fyllo-specs MCP:targetPath 必填校验 -- [ ] 2.1 在 `mcp-servers/fyllo-specs/src/utils/project-root.ts` 内新增 `validateTargetPath(targetPath: string): { ok: boolean; resolved?: string; rawOutput?: string; error?: string }` 函数。实现: +- [x] 2.1 在 `mcp-servers/fyllo-specs/src/utils/project-root.ts` 内新增 `validateTargetPath(targetPath: string): { ok: boolean; resolved?: string; rawOutput?: string; error?: string }` 函数。实现: - 入参先 `path.isAbsolute` 校验,非绝对返回 `{ ok: false, error: "targetPath must be an absolute path" }`。 - `const resolved = path.resolve(targetPath)`。 - 通过 `child_process.spawnSync("git", ["-C", process.env.FYLLO_PROJECT_PATH ?? "", "worktree", "list", "--porcelain"], { encoding: "utf8" })` 取 stdout。 - spawn 退出码为 0 时:解析 stdout 中所有 `worktree ` 行,提取路径集合并 `path.resolve` 规范化;若 `resolved` 在集合中则返回 `{ ok: true, resolved }`,否则返回 `{ ok: false, rawOutput: stdout, error: "targetPath is not a registered git worktree" }`。 - spawn 退出码非 0(典型 non-git 项目,无 `.git`):当 `resolved === path.resolve(process.env.FYLLO_PROJECT_PATH ?? "")` 时退化为合法,返回 `{ ok: true, resolved }`;否则 `{ ok: false, rawOutput: stderr, error: "targetPath must be the project root for non-git projects" }`。 -- [ ] 2.2 在 `mcp-servers/fyllo-specs/src/tools/explore.ts` 的 `exploreInputSchema` 中追加 `targetPath: z.string().min(1).describe("Absolute path to the project root or a registered git worktree.")`。在 handler 中:先用 `validateTargetPath(input.targetPath)` 校验;不通过时通过 `runTool` 抛 `Error` 让 `runTool` 内敛到 `state.errors`,error 类型用 `InvalidTargetPath`,message 包含 `result.error` 与 `result.rawOutput`(如有);通过则把 `projectRoot` 替换为 `result.resolved`,不再调 `resolveProjectRoot()`。 -- [ ] 2.3 在 `mcp-servers/fyllo-specs/src/tools/create-proposal.ts` 的 `createProposalInputSchema` 中追加 `targetPath` 字段(同 2.2)。handler 内部 `projectRoot = result.resolved`;保留现有 `createChange(projectRoot, name)` 流程。**禁止**任何 git worktree add 副作用——P1 阶段 create-proposal 不引入 worktree 创建。 -- [ ] 2.4 在 `mcp-servers/fyllo-specs/src/tools/apply-change.ts` 的 `applyChangeInputSchema` 中追加 `targetPath` 字段(同 2.2)。handler 内部 `projectRoot = result.resolved`;保留 `existsSync(changeDir(projectRoot, ...))` 与 `loadApplyState(projectRoot, ...)` 调用。 -- [ ] 2.5 在 `mcp-servers/fyllo-specs/src/tools/archive-change.ts` 的 `archiveChangeInputSchema` 中追加 `targetPath` 字段(同 2.2)。handler 内部 `projectRoot = result.resolved`。**禁止**任何 git commit/merge/worktree remove/branch delete 副作用——P1 阶段 archive-change 仅做 OpenSpec 文件归档。 -- [ ] 2.6 4 个 tool 的 prompt md 文件(`mcp-servers/fyllo-specs/src/prompts/{explore,create-proposal,apply-change,archive-change}.md`)开头的"Input"段都要更新:明确说明 `targetPath` 必填,必须是绝对路径,必须是 main repo 或已注册的 worktree;P1 阶段 agent 默认传 `$FYLLO_PROJECT_PATH`(即主仓库根)。 -- [ ] 2.7 在 `mcp-servers/fyllo-specs/__tests__/tools.test.ts` 增加测试用例: +- [x] 2.2 在 `mcp-servers/fyllo-specs/src/tools/explore.ts` 的 `exploreInputSchema` 中追加 `targetPath: z.string().min(1).describe("Absolute path to the project root or a registered git worktree.")`。在 handler 中:先用 `validateTargetPath(input.targetPath)` 校验;不通过时通过 `runTool` 抛 `Error` 让 `runTool` 内敛到 `state.errors`,error 类型用 `InvalidTargetPath`,message 包含 `result.error` 与 `result.rawOutput`(如有);通过则把 `projectRoot` 替换为 `result.resolved`,不再调 `resolveProjectRoot()`。 +- [x] 2.3 在 `mcp-servers/fyllo-specs/src/tools/create-proposal.ts` 的 `createProposalInputSchema` 中追加 `targetPath` 字段(同 2.2)。handler 内部 `projectRoot = result.resolved`;保留现有 `createChange(projectRoot, name)` 流程。**禁止**任何 git worktree add 副作用——P1 阶段 create-proposal 不引入 worktree 创建。 +- [x] 2.4 在 `mcp-servers/fyllo-specs/src/tools/apply-change.ts` 的 `applyChangeInputSchema` 中追加 `targetPath` 字段(同 2.2)。handler 内部 `projectRoot = result.resolved`;保留 `existsSync(changeDir(projectRoot, ...))` 与 `loadApplyState(projectRoot, ...)` 调用。 +- [x] 2.5 在 `mcp-servers/fyllo-specs/src/tools/archive-change.ts` 的 `archiveChangeInputSchema` 中追加 `targetPath` 字段(同 2.2)。handler 内部 `projectRoot = result.resolved`。**禁止**任何 git commit/merge/worktree remove/branch delete 副作用——P1 阶段 archive-change 仅做 OpenSpec 文件归档。 +- [x] 2.6 4 个 tool 的 prompt md 文件(`mcp-servers/fyllo-specs/src/prompts/{explore,create-proposal,apply-change,archive-change}.md`)开头的"Input"段都要更新:明确说明 `targetPath` 必填,必须是绝对路径,必须是 main repo 或已注册的 worktree;P1 阶段 agent 默认传 `$FYLLO_PROJECT_PATH`(即主仓库根)。 +- [x] 2.7 在 `mcp-servers/fyllo-specs/__tests__/tools.test.ts` 增加测试用例: - explore / create-proposal / apply-change / archive-change 任一调用缺省 `targetPath` → MCP SDK 拦截返回 `InvalidParams`。 - 传入相对路径 → `state.errors[0].type === "InvalidTargetPath"`。 - 传入不存在的绝对路径 → `state.errors[0].type === "InvalidTargetPath"`,message 含 `git worktree list --porcelain` 的输出片段。 - 传入 `targetPath === FYLLO_PROJECT_PATH`(git 项目)→ 正常进入 handler。 - non-git 项目(mock `/.git` 不存在)传入 `FYLLO_PROJECT_PATH` → 走降级逻辑,handler 正常执行;传入其他路径 → `InvalidTargetPath`。 - 路径含 trailing slash → 校验通过(path.resolve 剥离)。 -- [ ] 2.8 验收:`pnpm test mcp-servers/fyllo-specs` 全过;4 个 tool 的 input schema 中 `targetPath` 为 zod required string;handler 内部不再出现 `resolveProjectRoot()` 调用(仅 `validateTargetPath` 内部使用 `process.env.FYLLO_PROJECT_PATH`)。 +- [x] 2.8 验收:`pnpm test mcp-servers/fyllo-specs` 全过;4 个 tool 的 input schema 中 `targetPath` 为 zod required string;handler 内部不再出现 `resolveProjectRoot()` 调用(仅 `validateTargetPath` 内部使用 `process.env.FYLLO_PROJECT_PATH`)。 ## 3. SystemReminderContext 扩展 -- [ ] 3.1 在 `electron/main/services/chat/system-reminder/types.ts` 的 `SystemReminderContext` interface 中追加 `worktreePath?: string` 字段,紧跟 `runId?` 之后。 -- [ ] 3.2 验收:`pnpm typecheck` 通过;P1 阶段不修改任何 system-reminder template 文件(chat.txt / apply.txt / archive.txt)。 +- [x] 3.1 在 `electron/main/services/chat/system-reminder/types.ts` 的 `SystemReminderContext` interface 中追加 `worktreePath?: string` 字段,紧跟 `runId?` 之后。 +- [x] 3.2 验收:`pnpm typecheck` 通过;P1 阶段不修改任何 system-reminder template 文件(chat.txt / apply.txt / archive.txt)。 ## 4. apply-run-service 透传 worktreePath -- [ ] 4.1 在 `electron/main/services/proposal/apply-run-service.ts` 的 `createApplyRun` 函数内构造 `runMeta` 时,加载对应 ProposalMeta 并把 `proposalMeta.worktreePath` 透传到 `runMeta.worktreePath`。具体实现: +- [x] 4.1 在 `electron/main/services/proposal/apply-run-service.ts` 的 `createApplyRun` 函数内构造 `runMeta` 时,加载对应 ProposalMeta 并把 `proposalMeta.worktreePath` 透传到 `runMeta.worktreePath`。具体实现: - 通过 `findProposalMetaById(projectPath, input.changeId)`(若不存在的辅助函数,则在 `electron/main/domain/proposal/openspec-reader.ts` 内补一个 `findProposalMetaById(projectPath, changeId)`,复用 `readProposalFiles` + `find`)拿到 ProposalMeta;P1 阶段该字段始终为 `undefined`。 - `runMeta.worktreePath` 写入前用 `path.resolve(proposalMeta.worktreePath)` 规范化(仅在非 undefined 时)。 -- [ ] 4.2 在 `electron/main/services/proposal/__tests__/apply-run-service.spec.ts` 新建测试文件(如目录不存在则同时创建),覆盖: +- [x] 4.2 在 `electron/main/services/proposal/__tests__/apply-run-service.spec.ts` 新建测试文件(如目录不存在则同时创建),覆盖: - ProposalMeta.worktreePath 为 undefined 时,新写入的 run.json 不包含 `worktreePath` 键(JSON.stringify 省略)。 - ProposalMeta.worktreePath 含 trailing slash 时,新写入的 run.json 中 worktreePath 已规范化(无 trailing slash)。 - ProposalMeta.worktreePath 为绝对路径时,run.json 中字符串严格相等(path.resolve 后)。 -- [ ] 4.3 验收:`pnpm test electron/main/services/proposal` 全过;新写入的 run.json 在 P1 阶段实际不包含 worktreePath 字段(因 ProposalMeta.worktreePath 始终 undefined)。 +- [x] 4.3 验收:`pnpm test electron/main/services/proposal` 全过;新写入的 run.json 在 P1 阶段实际不包含 worktreePath 字段(因 ProposalMeta.worktreePath 始终 undefined)。 ## 5. proposal-apply.ts cwd 取值与 reminderContext -- [ ] 5.1 在 `electron/main/ipc/proposal-apply.ts` 的 `proposal:stageStream` handler 中,把 `new AcpSession({ ..., cwd: projectPath, ... })` 的 `cwd` 改为 `cwd: runMeta.worktreePath ?? projectPath`。`projectPath` 字段保持不变(仍传主仓库 path)。 -- [ ] 5.2 在同一处把 `reminderContext` 的字段从 `{ changeId: form.changeId, stageIndex: form.stageIndex, runId: form.runId }` 改为 `{ changeId: form.changeId, stageIndex: form.stageIndex, runId: form.runId, worktreePath: runMeta.worktreePath }`。 -- [ ] 5.3 在 `proposal:archive` handler 中,找到 `new AcpSession(...)` 创建处,同样把 `cwd: projectPath` 改为 `cwd: runMeta.worktreePath ?? projectPath`,`reminderContext` 增加 `worktreePath: runMeta.worktreePath`。 -- [ ] 5.4 stage stream handler 当前在 `onReady` 里需要拿到 `runMeta`:检查现有逻辑是否已经 `loadApplyRunMeta` 了 runMeta;若未加载(早期实现仅用 form 字段构造 prompt),则在 `onReady` 早期阶段补一次 `const runMeta = await loadApplyRunMeta(projectPath, form.changeId)`,仅用其 worktreePath 字段;若 `runMeta` 为 null(异常状态)则按现有 cwd 行为 fallback 到 `projectPath`。 +- [x] 5.1 在 `electron/main/ipc/proposal-apply.ts` 的 `proposal:stageStream` handler 中,把 `new AcpSession({ ..., cwd: projectPath, ... })` 的 `cwd` 改为 `cwd: runMeta.worktreePath ?? projectPath`。`projectPath` 字段保持不变(仍传主仓库 path)。 +- [x] 5.2 在同一处把 `reminderContext` 的字段从 `{ changeId: form.changeId, stageIndex: form.stageIndex, runId: form.runId }` 改为 `{ changeId: form.changeId, stageIndex: form.stageIndex, runId: form.runId, worktreePath: runMeta.worktreePath }`。 +- [x] 5.3 在 `proposal:archive` handler 中,找到 `new AcpSession(...)` 创建处,同样把 `cwd: projectPath` 改为 `cwd: runMeta.worktreePath ?? projectPath`,`reminderContext` 增加 `worktreePath: runMeta.worktreePath`。 +- [x] 5.4 stage stream handler 当前在 `onReady` 里需要拿到 `runMeta`:检查现有逻辑是否已经 `loadApplyRunMeta` 了 runMeta;若未加载(早期实现仅用 form 字段构造 prompt),则在 `onReady` 早期阶段补一次 `const runMeta = await loadApplyRunMeta(projectPath, form.changeId)`,仅用其 worktreePath 字段;若 `runMeta` 为 null(异常状态)则按现有 cwd 行为 fallback 到 `projectPath`。 - [ ] 5.5 验收:手工 dry-run(启动 FylloCode → 创建 git 项目 → 走一次完整 apply → archive 流程),确认旧 ApplyRunMeta 加载与新流程行为完全等价;`pnpm typecheck` 通过。 ## 6. apply-run-store 序列化兼容性 -- [ ] 6.1 在 `electron/main/infra/storage/apply-run-store.ts` 中确认 `loadApplyRunMeta` 实际是 `JSON.parse` + 类型断言,缺字段 `worktreePath` 时返回 `undefined`。无需改动;如有显式 schema 校验(如 zod),则在 schema 中追加 `.worktreePath: z.string().optional()`。 -- [ ] 6.2 确认 `saveApplyRunMeta` 实际为 `JSON.stringify(runMeta)`;当 `runMeta.worktreePath === undefined` 时键被自然省略。无需改动;若实现使用 `JSON.stringify(runMeta, null, 2)`,行为相同。 -- [ ] 6.3 在 `electron/main/infra/storage/__tests__/apply-run-store.spec.ts`(如不存在则新建)增加测试: +- [x] 6.1 在 `electron/main/infra/storage/apply-run-store.ts` 中确认 `loadApplyRunMeta` 实际是 `JSON.parse` + 类型断言,缺字段 `worktreePath` 时返回 `undefined`。无需改动;如有显式 schema 校验(如 zod),则在 schema 中追加 `.worktreePath: z.string().optional()`。 +- [x] 6.2 确认 `saveApplyRunMeta` 实际为 `JSON.stringify(runMeta)`;当 `runMeta.worktreePath === undefined` 时键被自然省略。无需改动;若实现使用 `JSON.stringify(runMeta, null, 2)`,行为相同。 +- [x] 6.3 在 `electron/main/infra/storage/__tests__/apply-run-store.spec.ts`(如不存在则新建)增加测试: - 写入 ApplyRunMeta 不含 worktreePath → 读回 `worktreePath === undefined`。 - 模拟磁盘上已存在的旧 run.json(不含 worktreePath 字段)→ 加载后 `worktreePath === undefined`。 - 写入含绝对 worktreePath 的 ApplyRunMeta → 读回字符串严格相等。 -- [ ] 6.4 验收:`pnpm test` 中 apply-run-store 相关测试全过。 +- [x] 6.4 验收:`pnpm test` 中 apply-run-store 相关测试全过。 ## 7. dogfood 与零回归验证 - [ ] 7.1 启动 FylloCode 开发环境(`pnpm dev`),打开任意 git 项目,触发一次 chat → create-proposal 调用,确认 fyllo-specs MCP 调用成功(agent 在新 schema 下传入 `targetPath: `,工具正常返回 state)。 - [ ] 7.2 同一项目跑一次 apply(任意 stage)→ archive 流程,确认 stage stream 与 archive stream 正常完成;`run.json` / `archive.json` 落盘字段无 `worktreePath` 噪声。 - [ ] 7.3 用旧版 FylloCode 已经留下的 ApplyRunMeta JSON(手工准备一份不含 worktreePath 的 run.json),加载后跑一次 archive,确认行为零回归。 -- [ ] 7.4 `pnpm build` 与 `pnpm lint` 通过(不引入新的告警)。 +- [x] 7.4 `pnpm build` 与 `pnpm lint` 通过(不引入新的告警)。 - [ ] 7.5 验收:所有 7.1–7.4 检查通过;FylloCode 应用本身可用,所有 apply / archive 历史数据可正常加载。 diff --git a/shared/types/proposal.ts b/shared/types/proposal.ts index 703e2c91..13b508ad 100644 --- a/shared/types/proposal.ts +++ b/shared/types/proposal.ts @@ -11,6 +11,7 @@ export interface ProposalMeta { doneTasks: number; hasDesign: boolean; date: string; + worktreePath?: string; } export interface ApplyRunMeta { @@ -23,6 +24,7 @@ export interface ApplyRunMeta { status: "running" | "done" | "error"; startedAt: string; updatedAt: string; + worktreePath?: string; } export interface ArchiveRunMeta { From 1adf8d556f94606fe4fb2a4024c7e7c4c11a3cc0 Mon Sep 17 00:00:00 2001 From: Fio Date: Tue, 19 May 2026 22:40:26 +0800 Subject: [PATCH 04/19] feat(worktree): wip! add-chat-worktree-orchestration --- .../chat/system-reminder/resolve.spec.ts | 19 +++ .../chat/system-reminder/shared.spec.ts | 110 ++++++++++++++++++ .../chat/system-reminder/providers/shared.ts | 13 ++- .../chat/system-reminder/templates/apply.txt | 6 + .../chat/system-reminder/templates/chat.txt | 21 ++++ .../src/prompts/create-proposal.md | 10 +- .../.openspec.yaml | 2 +- .../add-chat-worktree-orchestration/tasks.md | 30 ++--- 8 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 electron/main/__tests__/services/chat/system-reminder/shared.spec.ts diff --git a/electron/main/__tests__/services/chat/system-reminder/resolve.spec.ts b/electron/main/__tests__/services/chat/system-reminder/resolve.spec.ts index 808a637b..a7e20a1a 100644 --- a/electron/main/__tests__/services/chat/system-reminder/resolve.spec.ts +++ b/electron/main/__tests__/services/chat/system-reminder/resolve.spec.ts @@ -68,6 +68,9 @@ describe("resolveSystemReminder", () => { expect(reminder?.text.trim().endsWith("")).toBe(true); expect(reminder?.text).toContain("Stage index: 2"); expect(reminder?.text).toContain("Run id: run-1"); + expect(reminder?.text).toContain(""); + expect(reminder?.text).toContain("本 stage 的工作目录(cwd)是 ``。"); + expect(reminder?.text).toContain("主仓库 `/tmp/project`"); }); it("returns null and logs a warning when a variable contains angle brackets", async () => { @@ -108,4 +111,20 @@ describe("resolveSystemReminder", () => { expect(reminder).toContain("/tmp/project"); expect(reminder).toContain("{{unknownField}}"); }); + + it("renders the chat worktree instructions", async () => { + const { resolveSystemReminder } = await import("@main/services/chat/system-reminder"); + + const reminder = await resolveSystemReminder({ + owner: "chat", + projectPath: "/tmp/project", + cwd: "/tmp/project", + fylloSessionId: "session-1", + agentId: "claude-acp", + }); + + expect(reminder?.text).toContain(""); + expect(reminder?.text).toContain("git -C /tmp/project worktree add .worktrees/"); + expect(reminder?.text).toContain("targetPath: /tmp/project"); + }); }); diff --git a/electron/main/__tests__/services/chat/system-reminder/shared.spec.ts b/electron/main/__tests__/services/chat/system-reminder/shared.spec.ts new file mode 100644 index 00000000..3abd0d0e --- /dev/null +++ b/electron/main/__tests__/services/chat/system-reminder/shared.spec.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { wrapAsSystemReminder } from "@main/domain/chat/system-reminder-wrap"; +import type { SystemReminderContext } from "@main/services/chat/system-reminder/types"; +import { renderSystemReminderTemplate } from "@main/services/chat/system-reminder/providers/shared"; +import chatTemplate from "@main/services/chat/system-reminder/templates/chat.txt?raw"; +import applyTemplate from "@main/services/chat/system-reminder/templates/apply.txt?raw"; + +const logger = vi.hoisted(() => ({ + warn: vi.fn(), +})); + +vi.mock("@main/infra/logger", () => ({ + default: logger, +})); + +function createContext(overrides: Partial = {}): SystemReminderContext { + return { + owner: "apply", + projectPath: "/abs/project", + cwd: "/abs/project", + fylloSessionId: "session-1", + agentId: "claude-acp", + changeId: "change-1", + stageIndex: 0, + runId: "run-1", + ...overrides, + }; +} + +describe("renderSystemReminderTemplate", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders worktreePath placeholders", () => { + const reminder = renderSystemReminderTemplate("cwd={{worktreePath}}", { + ...createContext(), + worktreePath: "/abs/.worktrees/foo", + }); + + expect(reminder).toBe("cwd=/abs/.worktrees/foo"); + }); + + it("renders an empty string when worktreePath is undefined", () => { + const reminder = renderSystemReminderTemplate("cwd={{worktreePath}}.", createContext()); + + expect(reminder).toBe("cwd=."); + }); + + it("renders mainProjectPath as an alias of projectPath", () => { + const reminder = renderSystemReminderTemplate( + "main={{mainProjectPath}} project={{projectPath}}", + createContext({ + projectPath: "/abs/myapp", + }) + ); + + expect(reminder).toBe("main=/abs/myapp project=/abs/myapp"); + }); + + it("returns null and warns when worktreePath contains angle brackets", () => { + const reminder = renderSystemReminderTemplate("cwd={{worktreePath}}", { + ...createContext({ owner: "apply" }), + worktreePath: "/abs/", + }); + + expect(reminder).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + "[system-reminder] rejected reminder variable", + expect.objectContaining({ + owner: "apply", + field: "worktreePath", + fylloSessionId: "session-1", + }) + ); + }); + + it("returns null and warns when mainProjectPath contains angle brackets via projectPath", () => { + const reminder = renderSystemReminderTemplate("main={{mainProjectPath}}", { + ...createContext({ owner: "chat" }), + projectPath: "/abs/project>", + }); + + expect(reminder).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + "[system-reminder] rejected reminder variable", + expect.objectContaining({ + owner: "chat", + field: "projectPath", + fylloSessionId: "session-1", + }) + ); + }); + + it("preserves unknown placeholders as literals", () => { + const reminder = renderSystemReminderTemplate("{{otherField}}", createContext()); + + expect(reminder).toBe("{{otherField}}"); + }); +}); + +describe("system-reminder templates", () => { + it("allows chat.txt to be wrapped without nested wrapper tags", () => { + expect(() => wrapAsSystemReminder(chatTemplate)).not.toThrow(); + }); + + it("allows apply.txt to be wrapped without nested wrapper tags", () => { + expect(() => wrapAsSystemReminder(applyTemplate)).not.toThrow(); + }); +}); diff --git a/electron/main/services/chat/system-reminder/providers/shared.ts b/electron/main/services/chat/system-reminder/providers/shared.ts index 5942ffd5..81563915 100644 --- a/electron/main/services/chat/system-reminder/providers/shared.ts +++ b/electron/main/services/chat/system-reminder/providers/shared.ts @@ -2,7 +2,14 @@ import logger from "@main/infra/logger"; import type { SystemReminderContext } from "../types"; const VARIABLE_PATTERN = /\{\{([a-zA-Z0-9_]+)\}\}/g; -const ALLOWED_VARIABLES = ["changeId", "stageIndex", "runId", "projectPath"] as const; +const ALLOWED_VARIABLES = [ + "changeId", + "stageIndex", + "runId", + "projectPath", + "worktreePath", + "mainProjectPath", +] as const; const ALLOWED_VARIABLE_SET = new Set(ALLOWED_VARIABLES); type AllowedVariable = (typeof ALLOWED_VARIABLES)[number]; @@ -39,6 +46,10 @@ function getVariableValue( return ctx.runId; case "projectPath": return ctx.projectPath; + case "worktreePath": + return ctx.worktreePath; + case "mainProjectPath": + return ctx.projectPath; default: return undefined; } diff --git a/electron/main/services/chat/system-reminder/templates/apply.txt b/electron/main/services/chat/system-reminder/templates/apply.txt index ad7fbd7c..9ac8293e 100644 --- a/electron/main/services/chat/system-reminder/templates/apply.txt +++ b/electron/main/services/chat/system-reminder/templates/apply.txt @@ -10,6 +10,12 @@ You are currently in the **Apply stage** (the second of the three) for project ` Your job in this stage is to turn an already-agreed proposal into code, one task at a time, grounded in the existing artifacts and codebase. **You are not redesigning the change here.** + +本 stage 的工作目录(cwd)是 `{{worktreePath}}`。若该字符串为空,表示当前 stage 的 cwd 是主仓库 `{{mainProjectPath}}`(旧 ApplyRunMeta 或 worktree 编排尚未启用的项目)。 + +业务代码改动产生的 commit 由你(agent)自己完成;archive 阶段不会替你 commit 业务代码。在准备进入 archive 之前,请确保 `git status` 工作区 clean(OpenSpec tasks.md 的勾选除外,那是工具进度)。 + + ## Apply Stage Goals diff --git a/electron/main/services/chat/system-reminder/templates/chat.txt b/electron/main/services/chat/system-reminder/templates/chat.txt index 0cf66add..e230b816 100644 --- a/electron/main/services/chat/system-reminder/templates/chat.txt +++ b/electron/main/services/chat/system-reminder/templates/chat.txt @@ -48,6 +48,27 @@ You are currently in the **Chat stage** (the first of the three). Your job is to - When you cannot decisively answer that question after consulting specs, code, and existing changes, treat scope as unconverged and confirm with the user before deciding. + +当用户同意提议(propose)后,按以下顺序为本次 change 创建独立的 git linked worktree: + +1. 自检主仓库是否为 git 仓库:`git -C {{mainProjectPath}} rev-parse --is-inside-work-tree`。失败(非 git 项目)则跳过本段,直接用 `targetPath: {{mainProjectPath}}` 调用 mcp__fyllo_specs__create-proposal。 + +2. 维护主仓库 .gitignore(仅首次需要): + `cd {{mainProjectPath}} && (grep -qxF .worktrees/ .gitignore 2>/dev/null || (echo .worktrees/ >> .gitignore && git add .gitignore && git commit -m "chore: ignore .worktrees"))` + commit 失败时(如 commit signing required、pre-commit hook 拒绝),把 stderr 完整复述给用户,让用户决定下一步;不要自行重试。 + +3. 创建 worktree: + `git -C {{mainProjectPath}} worktree add .worktrees/ -b proposal/` + 为 kebab-case 改动名(如 add-foo-bar)。命令成功后,worktree 绝对路径为 `{{mainProjectPath}}/.worktrees/`,记下作为后续 `targetPath`。 + 失败常见原因:worktree 已存在(changeName 重名)、HEAD 状态异常。把 stderr 完整复述给用户,请用户换名或处理后再继续。 + +4. 调 `mcp__fyllo_specs__create-proposal`,`targetPath` 必传 worktree 绝对路径(即第 3 步记下的 path),`changeName` 与目录名一致。本工具内部不会创建 worktree、不会改 git 状态,仅在传入路径下生成 OpenSpec change scaffold。 + +5. 同一 chat session 允许孵化多个 change,每个 change 各占独立 worktree;用户后续提到“刚才那个 change”且历史中存在多个 worktreePath 时,先反问目标。 + +6. 后续在 chat 内对该 change artifacts 的 Read / Edit 必须用 worktree 的绝对路径。chat session 自身 cwd 是主仓库 `{{mainProjectPath}}`,相对路径会落到主仓库。 + + The following constraints MUST NOT be violated in the Chat stage. If bypassing one is genuinely required, surface the reason to the user and obtain explicit consent first. diff --git a/mcp-servers/fyllo-specs/src/prompts/create-proposal.md b/mcp-servers/fyllo-specs/src/prompts/create-proposal.md index 9ca5f44f..00f48d76 100644 --- a/mcp-servers/fyllo-specs/src/prompts/create-proposal.md +++ b/mcp-servers/fyllo-specs/src/prompts/create-proposal.md @@ -1,9 +1,13 @@ Create or inspect an OpenSpec change using the provided `state`. **Input**: `targetPath` is required. It must be an absolute path to the main repo root or a -registered git worktree for that repo. In P1, the agent should default to passing -`$FYLLO_PROJECT_PATH` (the main repo root). `state.changeName` is the kebab-case name for this -change. +registered git worktree for that repo. + +- For git projects, prefer the linked worktree absolute path prepared by the chat reminder flow + (for example `$FYLLO_PROJECT_PATH/.worktrees/`). +- For non-git projects, pass `$FYLLO_PROJECT_PATH` (the main repo root). + +`state.changeName` is the kebab-case name for this change. **Steps** diff --git a/openspec/changes/add-chat-worktree-orchestration/.openspec.yaml b/openspec/changes/add-chat-worktree-orchestration/.openspec.yaml index 65b9a4e8..e4ba8648 100644 --- a/openspec/changes/add-chat-worktree-orchestration/.openspec.yaml +++ b/openspec/changes/add-chat-worktree-orchestration/.openspec.yaml @@ -1,3 +1,3 @@ schema: spec-driven created: 2026-05-19T00:00:00.000Z -status: draft +status: applying diff --git a/openspec/changes/add-chat-worktree-orchestration/tasks.md b/openspec/changes/add-chat-worktree-orchestration/tasks.md index ff058fca..db6cb7d3 100644 --- a/openspec/changes/add-chat-worktree-orchestration/tasks.md +++ b/openspec/changes/add-chat-worktree-orchestration/tasks.md @@ -1,39 +1,39 @@ ## 1. 模板变量白名单扩展 -- [ ] 1.1 修改 `electron/main/services/chat/system-reminder/providers/shared.ts`:将 `ALLOWED_VARIABLES` 数组从 `["changeId", "stageIndex", "runId", "projectPath"]` 扩展为 `["changeId", "stageIndex", "runId", "projectPath", "worktreePath", "mainProjectPath"]`;同步更新 `AllowedVariable` 类型推导。 -- [ ] 1.2 在同一文件的 `getVariableValue` 函数中,为新增的两个字段补 case: +- [x] 1.1 修改 `electron/main/services/chat/system-reminder/providers/shared.ts`:将 `ALLOWED_VARIABLES` 数组从 `["changeId", "stageIndex", "runId", "projectPath"]` 扩展为 `["changeId", "stageIndex", "runId", "projectPath", "worktreePath", "mainProjectPath"]`;同步更新 `AllowedVariable` 类型推导。 +- [x] 1.2 在同一文件的 `getVariableValue` 函数中,为新增的两个字段补 case: - `case "worktreePath": return ctx.worktreePath;` - `case "mainProjectPath": return ctx.projectPath;`(别名,与 `projectPath` 取同一字段) -- [ ] 1.3 验收:`pnpm typecheck` 通过;`grep "ALLOWED_VARIABLES" providers/shared.ts` 输出含全部 6 个字段。 +- [x] 1.3 验收:`pnpm typecheck` 通过;`grep "ALLOWED_VARIABLES" providers/shared.ts` 输出含全部 6 个字段。 ## 2. chat.txt 模板新增 worktree 编排段 -- [ ] 2.1 修改 `electron/main/services/chat/system-reminder/templates/chat.txt`:在 `` 闭合标签之前、`` 开始标签之前,插入完整的 `` 段。段落文本严格按 design.md 第 "chat.txt worktree 编排子段" 节的设计文本写入(含 6 步编排:non-git 自检 / .gitignore 维护 / worktree add / 调 create-proposal / 多 change 语义 / 路径口径)。 -- [ ] 2.2 文本中所有 `{{mainProjectPath}}` 占位符必须严格使用此名称(不要写成 `{{projectPath}}`,避免与 `` 段中既有的 `projectPath` 叙述混淆);`` 用尖括号包裹的字面量保留(让 agent 知道这是占位符)。 +- [x] 2.1 修改 `electron/main/services/chat/system-reminder/templates/chat.txt`:在 `` 闭合标签之前、`` 开始标签之前,插入完整的 `` 段。段落文本严格按 design.md 第 "chat.txt worktree 编排子段" 节的设计文本写入(含 6 步编排:non-git 自检 / .gitignore 维护 / worktree add / 调 create-proposal / 多 change 语义 / 路径口径)。 +- [x] 2.2 文本中所有 `{{mainProjectPath}}` 占位符必须严格使用此名称(不要写成 `{{projectPath}}`,避免与 `` 段中既有的 `projectPath` 叙述混淆);`` 用尖括号包裹的字面量保留(让 agent 知道这是占位符)。 - [ ] 2.3 验收:手动启动 FylloCode(`pnpm dev`)后,新建 chat 触发首次 prompt,从主进程 logger 中确认 chat reminder 文本含 `` 段;用户在 chat 内确认 propose 后,agent 调用 Bash 执行 `git worktree add ...`,并把 `targetPath` 设为 `/.worktrees/` 调 `mcp__fyllo_specs__create-proposal`,工具返回成功。 ## 3. apply.txt 模板新增 worktree 段 -- [ ] 3.1 修改 `electron/main/services/chat/system-reminder/templates/apply.txt`:在 `` 闭合标签之后、`` 开始标签之前,插入完整的 `` 段。段落文本严格按 design.md 第 "apply.txt worktree 子段" 节的设计文本写入。 -- [ ] 3.2 文本必须含 `{{worktreePath}}` 与 `{{mainProjectPath}}` 两个占位符;明确说明"空字符串代表当前 stage 的 cwd 是主仓库";明确说明"业务代码改动产生的 commit 由 agent 自己完成"。 +- [x] 3.1 修改 `electron/main/services/chat/system-reminder/templates/apply.txt`:在 `` 闭合标签之后、`` 开始标签之前,插入完整的 `` 段。段落文本严格按 design.md 第 "apply.txt worktree 子段" 节的设计文本写入。 +- [x] 3.2 文本必须含 `{{worktreePath}}` 与 `{{mainProjectPath}}` 两个占位符;明确说明"空字符串代表当前 stage 的 cwd 是主仓库";明确说明"业务代码改动产生的 commit 由 agent 自己完成"。 - [ ] 3.3 验收:在 FylloCode 仓库本地,先用 P1 完成的 typecheck 通过的代码跑一次 stage stream(ApplyRunMeta.worktreePath 此时仍为 undefined),从主进程 logger 中确认 apply reminder 文本含 `` 段,且 `{{worktreePath}}` 渲染为空字符串。 ## 4. 模板渲染单测扩展 -- [ ] 4.1 在 `electron/main/services/chat/system-reminder/providers/__tests__/`(如不存在则新建目录)创建 `shared.spec.ts`,覆盖: +- [x] 4.1 在 `electron/main/__tests__/services/chat/system-reminder/` 创建 `shared.spec.ts`,覆盖: - 4.1.1 `worktreePath` 占位符:传入 `worktreePath: "/abs/.worktrees/foo"` → 渲染后字符串包含 `/abs/.worktrees/foo`。 - 4.1.2 `worktreePath` 为 `undefined` → 渲染后 `{{worktreePath}}` 被替换为空字符串。 - 4.1.3 `mainProjectPath` 占位符:传入 `projectPath: "/abs/myapp"` → `{{mainProjectPath}}` 渲染为 `/abs/myapp`,且与同模板内 `{{projectPath}}` 渲染结果完全一致。 - 4.1.4 `worktreePath` 含 `<` 字符 → `renderSystemReminderTemplate` 返回 `null`;`logger.warn` 被调用且字段含 `worktreePath`、对应 `owner`、`fylloSessionId`。 - 4.1.5 `mainProjectPath` 含 `>` 字符 → `renderSystemReminderTemplate` 返回 `null`;同样命中告警。 - 4.1.6 非白名单占位符 `{{otherField}}` → 渲染后保持字面量 `{{otherField}}`。 -- [ ] 4.2 验收:`pnpm test electron/main/services/chat/system-reminder` 全过;新增的 shared.spec.ts 6 条用例全过。 +- [x] 4.2 验收:`pnpm test electron/main/__tests__/services/chat/system-reminder` 全过;新增的 shared.spec.ts 6 条用例全过。 ## 5. 模板正文不会泄漏 system-reminder 标签 -- [ ] 5.1 在新建的 `shared.spec.ts` 增加一条用例:模拟将 chat.txt 完整文本作为 `body` 传入 `wrapAsSystemReminder`,断言不抛错(即模板正文中**没有**字面量 `` 或 `` 字符串)。 -- [ ] 5.2 同理对 apply.txt 跑一次 `wrapAsSystemReminder` 不抛错的断言。 -- [ ] 5.3 验收:上述两条用例通过;如果未来有人在 chat.txt / apply.txt 中误写 ``,单测会立刻 fail。 +- [x] 5.1 在新建的 `shared.spec.ts` 增加一条用例:模拟将 chat.txt 完整文本作为 `body` 传入 `wrapAsSystemReminder`,断言不抛错(即模板正文中**没有**字面量 `` 或 `` 字符串)。 +- [x] 5.2 同理对 apply.txt 跑一次 `wrapAsSystemReminder` 不抛错的断言。 +- [x] 5.3 验收:上述两条用例通过;如果未来有人在 chat.txt / apply.txt 中误写 ``,单测会立刻 fail。 ## 6. dogfood 与零回归验证 @@ -48,15 +48,15 @@ - 6.2.3 用户用模糊语言("刚才那个 change 加一段 design")追问时,agent 主动反问目标 worktree 而非自己猜。 - [ ] 6.3 用一个非 git 项目(用户在 FylloCode 创建的 `template: "empty"` 项目),跑一次 chat → propose 流程;确认 agent 的 `git rev-parse --is-inside-work-tree` 自检失败后跳过 worktree 编排;create-proposal 调用使用 `targetPath: ` 即主仓库根;OpenSpec change 创建在 `/openspec/changes//`。 - [ ] 6.4 触发一次 apply(在 P1 已完成的 ApplyRunMeta 字段下,worktreePath 仍为 undefined),从 logger 看 apply reminder 含 `` 段且 worktreePath 渲染为空——行为与 P1 一致,无回归。 -- [ ] 6.5 `pnpm build` 通过、`pnpm lint` 不引入新告警、`pnpm typecheck` 通过。 +- [x] 6.5 `pnpm build` 通过、`pnpm lint` 不引入新告警、`pnpm typecheck` 通过。 - [ ] 6.6 验收:6.1–6.5 全部通过;新建一个 PR 或 commit 描述记录 dogfood 结果。 ## 7. 文档与下游对齐 -- [ ] 7.1 修改 `mcp-servers/fyllo-specs/src/prompts/create-proposal.md`:在文档"Input"段或开头注释中说明 `targetPath` 的推荐值—— +- [x] 7.1 修改 `mcp-servers/fyllo-specs/src/prompts/create-proposal.md`:在文档"Input"段或开头注释中说明 `targetPath` 的推荐值—— - git 项目:worktree 绝对路径(具体由 chat.txt system-reminder 引导 agent 创建并传入)。 - non-git 项目:主仓库绝对路径(即 `FYLLO_PROJECT_PATH`)。 -- [ ] 7.2 验收:调整后 `pnpm test mcp-servers/fyllo-specs/__tests__/prompts.test.ts` 仍通过(如该测试断言文本片段,则同步更新断言)。 +- [x] 7.2 验收:调整后 `pnpm test mcp-servers/fyllo-specs/__tests__/prompts.test.ts` 仍通过(如该测试断言文本片段,则同步更新断言)。 ## 8. 验收总闸 From 5c54d2e23ac9c2c92cfefc10f64ad04a9dfb0c86 Mon Sep 17 00:00:00 2001 From: Fio Date: Tue, 19 May 2026 22:49:07 +0800 Subject: [PATCH 05/19] feat(worktree): wip! add-proposal-list-worktree-scan --- .../domain/proposal/openspec-reader.spec.ts | 294 ++++++++++++++++++ .../main/domain/proposal/openspec-reader.ts | 202 ++++++++---- frontend/src/pages/proposal/index.vue | 26 +- .../.openspec.yaml | 2 +- .../add-proposal-list-worktree-scan/tasks.md | 28 +- 5 files changed, 464 insertions(+), 88 deletions(-) diff --git a/electron/main/__tests__/domain/proposal/openspec-reader.spec.ts b/electron/main/__tests__/domain/proposal/openspec-reader.spec.ts index 8efb11bd..5434fbf7 100644 --- a/electron/main/__tests__/domain/proposal/openspec-reader.spec.ts +++ b/electron/main/__tests__/domain/proposal/openspec-reader.spec.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { promises as fs } from "fs"; +import { join, resolve } from "path"; import { countTasks, parseWhySummary, parseYamlCreated, parseYamlStatus, + readProposalFiles, resolveApplyRunChangeId, + resolveChangeDir, stripArchivePrefix, toTitleCase, } from "@main/domain/proposal/openspec-reader"; @@ -17,10 +20,51 @@ vi.mock("fs", async () => { promises: { ...actual.promises, readFile: vi.fn(), + readdir: vi.fn(), }, }; }); +type MockDirent = { + name: string; + isDirectory: () => boolean; +}; + +function dirent(name: string, isDirectory = true): MockDirent { + return { + name, + isDirectory: () => isDirectory, + }; +} + +function mockFsTree(input: { + directories?: Record; + files?: Record; +}): void { + const directories = input.directories ?? {}; + const files = input.files ?? {}; + + vi.mocked(fs.readdir).mockImplementation(async (targetPath: Parameters[0]) => { + const normalizedPath = String(targetPath); + const entries = directories[normalizedPath]; + if (!entries) { + throw new Error(`ENOENT: ${normalizedPath}`); + } + return entries as never; + }); + + vi.mocked(fs.readFile).mockImplementation( + async (targetPath: Parameters[0]) => { + const normalizedPath = String(targetPath); + const content = files[normalizedPath]; + if (content === undefined) { + throw new Error(`ENOENT: ${normalizedPath}`); + } + return content as never; + } + ); +} + describe("openspec-reader pure helpers", () => { it("stripArchivePrefix removes leading YYYY-MM-DD- only", () => { expect(stripArchivePrefix("2026-04-23-foo-bar")).toBe("foo-bar"); @@ -111,3 +155,253 @@ describe("resolveApplyRunChangeId", () => { ).resolves.toBe("proposal-archived-run-history"); }); }); + +describe("readProposalFiles", () => { + const projectPath = "/tmp/project"; + const baseChangesDir = join(projectPath, "openspec", "changes"); + const archiveDir = join(baseChangesDir, "archive"); + const worktreesDir = join(projectPath, ".worktrees"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns only main proposals when worktrees directory is absent", async () => { + const mainChangeDir = join(baseChangesDir, "main-change"); + const archivedChangeDir = join(archiveDir, "2026-05-19-old-change"); + + mockFsTree({ + directories: { + [baseChangesDir]: [dirent("main-change"), dirent("archive")], + [archiveDir]: [dirent("2026-05-19-old-change")], + }, + files: { + [join(mainChangeDir, ".openspec.yaml")]: "status: draft\ncreated: 2026-05-20\n", + [join(mainChangeDir, "proposal.md")]: "## Why\n\nMain reason\n", + [join(mainChangeDir, "tasks.md")]: "- [ ] todo\n", + [join(archivedChangeDir, ".openspec.yaml")]: "status: archived\ncreated: 2026-05-18\n", + [join(archivedChangeDir, "proposal.md")]: "## Why\n\nArchived reason\n", + [join(archivedChangeDir, "tasks.md")]: "- [x] done\n", + }, + }); + + await expect(readProposalFiles(projectPath)).resolves.toEqual([ + expect.objectContaining({ + id: "main-change", + worktreePath: undefined, + status: "draft", + }), + expect.objectContaining({ + id: "2026-05-19-old-change", + worktreePath: undefined, + status: "archived", + }), + ]); + }); + + it("includes a single worktree proposal with normalized worktreePath", async () => { + const worktreePath = resolve(worktreesDir, "foo"); + const worktreeChangeDir = join(worktreePath, "openspec", "changes", "foo"); + + mockFsTree({ + directories: { + [baseChangesDir]: [], + [archiveDir]: [], + [worktreesDir]: [dirent("foo")], + [join(worktreePath, "openspec", "changes")]: [dirent("foo")], + }, + files: { + [join(worktreeChangeDir, ".openspec.yaml")]: "status: creating\ncreated: 2026-05-20\n", + [join(worktreeChangeDir, "proposal.md")]: "## Why\n\nWorktree reason\n", + [join(worktreeChangeDir, "tasks.md")]: "- [ ] todo\n", + }, + }); + + await expect(readProposalFiles(projectPath)).resolves.toEqual([ + expect.objectContaining({ + id: "foo", + worktreePath, + status: "creating", + }), + ]); + }); + + it("includes multiple worktree proposals with different worktreePath values", async () => { + const fooWorktreePath = resolve(worktreesDir, "foo"); + const barWorktreePath = resolve(worktreesDir, "bar"); + + mockFsTree({ + directories: { + [baseChangesDir]: [], + [archiveDir]: [], + [worktreesDir]: [dirent("foo"), dirent("bar")], + [join(fooWorktreePath, "openspec", "changes")]: [dirent("change-foo")], + [join(barWorktreePath, "openspec", "changes")]: [dirent("change-bar")], + }, + files: { + [join(fooWorktreePath, "openspec", "changes", "change-foo", ".openspec.yaml")]: + "status: draft\ncreated: 2026-05-21\n", + [join(barWorktreePath, "openspec", "changes", "change-bar", ".openspec.yaml")]: + "status: applying\ncreated: 2026-05-20\n", + }, + }); + + await expect(readProposalFiles(projectPath)).resolves.toEqual([ + expect.objectContaining({ id: "change-foo", worktreePath: fooWorktreePath }), + expect.objectContaining({ id: "change-bar", worktreePath: barWorktreePath }), + ]); + }); + + it("deduplicates same active change id with worktree priority", async () => { + const mainChangeDir = join(baseChangesDir, "foo"); + const worktreePath = resolve(worktreesDir, "foo"); + const worktreeChangeDir = join(worktreePath, "openspec", "changes", "foo"); + + mockFsTree({ + directories: { + [baseChangesDir]: [dirent("foo")], + [archiveDir]: [], + [worktreesDir]: [dirent("foo")], + [join(worktreePath, "openspec", "changes")]: [dirent("foo")], + }, + files: { + [join(mainChangeDir, ".openspec.yaml")]: "status: draft\ncreated: 2026-05-20\n", + [join(mainChangeDir, "proposal.md")]: "## Why\n\nMain reason\n", + [join(worktreeChangeDir, ".openspec.yaml")]: "status: applying\ncreated: 2026-05-21\n", + [join(worktreeChangeDir, "proposal.md")]: "## Why\n\nWorktree reason\n", + }, + }); + + const proposals = await readProposalFiles(projectPath); + expect(proposals).toHaveLength(1); + expect(proposals[0]).toMatchObject({ + id: "foo", + status: "applying", + why: "Worktree reason", + worktreePath, + }); + }); + + it("keeps archive and worktree entries when archive id carries the date prefix", async () => { + const archivedChangeDir = join(archiveDir, "2026-05-19-foo"); + const worktreePath = resolve(worktreesDir, "foo"); + const worktreeArchiveDir = join(worktreePath, "openspec", "changes", "archive"); + const worktreeChangeDir = join(worktreePath, "openspec", "changes", "foo"); + + mockFsTree({ + directories: { + [baseChangesDir]: [], + [archiveDir]: [dirent("2026-05-19-foo")], + [worktreesDir]: [dirent("foo")], + [join(worktreePath, "openspec", "changes")]: [dirent("foo"), dirent("archive")], + [worktreeArchiveDir]: [dirent("2026-05-19-foo")], + }, + files: { + [join(archivedChangeDir, ".openspec.yaml")]: "status: archived\ncreated: 2026-05-19\n", + [join(worktreeChangeDir, ".openspec.yaml")]: "status: archived\ncreated: 2026-05-20\n", + }, + }); + + const proposals = await readProposalFiles(projectPath); + expect(proposals).toEqual([ + expect.objectContaining({ id: "foo", worktreePath }), + expect.objectContaining({ id: "2026-05-19-foo", worktreePath: undefined }), + ]); + }); + + it("skips worktree changes without .openspec.yaml", async () => { + const worktreePath = resolve(worktreesDir, "foo"); + + mockFsTree({ + directories: { + [baseChangesDir]: [], + [archiveDir]: [], + [worktreesDir]: [dirent("foo")], + [join(worktreePath, "openspec", "changes")]: [dirent("foo")], + }, + files: {}, + }); + + await expect(readProposalFiles(projectPath)).resolves.toEqual([]); + }); + + it("normalizes worktreePath when projectPath carries a trailing slash", async () => { + const projectPathWithSlash = "/tmp/project/"; + const baseChangesDirWithSlash = join(projectPathWithSlash, "openspec", "changes"); + const archiveDirWithSlash = join(baseChangesDirWithSlash, "archive"); + const worktreesDirWithSlash = join(projectPathWithSlash, ".worktrees"); + const worktreePath = resolve(worktreesDirWithSlash, "foo"); + + mockFsTree({ + directories: { + [baseChangesDirWithSlash]: [], + [archiveDirWithSlash]: [], + [worktreesDirWithSlash]: [dirent("foo")], + [join(worktreePath, "openspec", "changes")]: [dirent("foo")], + }, + files: { + [join(worktreePath, "openspec", "changes", "foo", ".openspec.yaml")]: + "status: draft\ncreated: 2026-05-20\n", + }, + }); + + const proposals = await readProposalFiles(projectPathWithSlash); + expect(proposals[0]?.worktreePath).toBe(resolve("/tmp/project/.worktrees/foo")); + }); +}); + +describe("resolveChangeDir", () => { + const projectPath = "/tmp/project"; + const rootDir = join(projectPath, "openspec", "changes", "foo"); + const archiveDir = join(projectPath, "openspec", "changes", "archive", "foo"); + const worktreesDir = join(projectPath, ".worktrees"); + const worktreeDir = join(projectPath, ".worktrees", "foo", "openspec", "changes", "foo"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the main active directory when present", async () => { + mockFsTree({ + files: { + [join(rootDir, ".openspec.yaml")]: "status: draft\n", + }, + }); + + await expect(resolveChangeDir(projectPath, "foo")).resolves.toBe(rootDir); + }); + + it("returns the archive directory when main active is missing", async () => { + mockFsTree({ + files: { + [join(archiveDir, ".openspec.yaml")]: "status: archived\n", + }, + }); + + await expect(resolveChangeDir(projectPath, "foo")).resolves.toBe(archiveDir); + }); + + it("returns the worktree directory when main and archive both miss", async () => { + mockFsTree({ + directories: { + [worktreesDir]: [dirent("foo")], + }, + files: { + [join(worktreeDir, ".openspec.yaml")]: "status: draft\n", + }, + }); + + await expect(resolveChangeDir(projectPath, "foo")).resolves.toBe(worktreeDir); + }); + + it("returns null when main, archive, and worktree all miss", async () => { + mockFsTree({ + directories: { + [worktreesDir]: [dirent("foo")], + }, + files: {}, + }); + + await expect(resolveChangeDir(projectPath, "foo")).resolves.toBeNull(); + }); +}); diff --git a/electron/main/domain/proposal/openspec-reader.ts b/electron/main/domain/proposal/openspec-reader.ts index fcf554dc..1a1fd27e 100644 --- a/electron/main/domain/proposal/openspec-reader.ts +++ b/electron/main/domain/proposal/openspec-reader.ts @@ -1,5 +1,5 @@ import { promises as fs } from "fs"; -import { basename, join } from "path"; +import { basename, join, resolve } from "path"; import type { ProposalMeta, ProposalStatus } from "@shared/types/proposal"; /** @@ -94,10 +94,41 @@ export async function readIfExists(targetPath: string): Promise { } } -export async function readProposalFiles(projectPath: string): Promise { - const baseChangesDir = join(projectPath, "openspec", "changes"); +async function readMetaFromDir( + changeDir: string, + entryName: string, + statusOverride?: ProposalStatus, + worktreePath?: string +): Promise { + const location = normalizeChangeId(entryName); + const yamlContent = await readIfExists(join(changeDir, ".openspec.yaml")); + if (!yamlContent) { + return null; + } + + const proposalContent = await readIfExists(join(changeDir, "proposal.md")); + const tasksContent = await readIfExists(join(changeDir, "tasks.md")); + const status = statusOverride ?? parseYamlStatus(yamlContent); + const date = parseYamlCreated(yamlContent); + const why = proposalContent ? parseWhySummary(proposalContent) : ""; + const taskCounts = tasksContent ? countTasks(tasksContent) : { totalTasks: 0, doneTasks: 0 }; + + return { + id: location.changeId, + title: toTitleCase(stripArchivePrefix(entryName)), + status: location.archived ? "archived" : status, + why, + totalTasks: taskCounts.totalTasks, + doneTasks: taskCounts.doneTasks, + hasDesign: Boolean(await readIfExists(join(changeDir, "design.md"))), + date, + worktreePath: worktreePath ? resolve(worktreePath) : undefined, + }; +} + +async function readActiveDir(dir: string, worktreePath?: string): Promise { try { - const entries = await fs.readdir(baseChangesDir, { withFileTypes: true }); + const entries = await fs.readdir(dir, { withFileTypes: true }); const metas: ProposalMeta[] = []; for (const entry of entries) { @@ -105,74 +136,88 @@ export async function readProposalFiles(projectPath: string): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const metas: ProposalMeta[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) { continue; } - const proposalContent = await readIfExists(join(changeDir, "proposal.md")); - const tasksContent = await readIfExists(join(changeDir, "tasks.md")); - const status = parseYamlStatus(yamlContent); - const date = parseYamlCreated(yamlContent); - const why = proposalContent ? parseWhySummary(proposalContent) : ""; - const taskCounts = tasksContent ? countTasks(tasksContent) : { totalTasks: 0, doneTasks: 0 }; - - metas.push({ - id: location.changeId, - title: toTitleCase(stripArchivePrefix(entry.name)), - status: location.archived ? "archived" : status, - why, - totalTasks: taskCounts.totalTasks, - doneTasks: taskCounts.doneTasks, - hasDesign: Boolean(await readIfExists(join(changeDir, "design.md"))), - date, - }); + const meta = await readMetaFromDir(join(dir, entry.name), entry.name, "archived"); + if (meta) { + metas.push(meta); + } } - const archiveDir = join(baseChangesDir, "archive"); - try { - const archiveEntries = await fs.readdir(archiveDir, { withFileTypes: true }); - for (const entry of archiveEntries) { - if (!entry.isDirectory()) { - continue; - } - - const location = normalizeChangeId(entry.name); - const changeDir = join(archiveDir, location.directory); - const yamlContent = await readIfExists(join(changeDir, ".openspec.yaml")); - if (!yamlContent) { - continue; - } - - const proposalContent = await readIfExists(join(changeDir, "proposal.md")); - const tasksContent = await readIfExists(join(changeDir, "tasks.md")); - const date = parseYamlCreated(yamlContent); - const taskCounts = tasksContent - ? countTasks(tasksContent) - : { totalTasks: 0, doneTasks: 0 }; - - metas.push({ - id: location.changeId, - title: toTitleCase(stripArchivePrefix(entry.name)), - status: "archived", - why: proposalContent ? parseWhySummary(proposalContent) : "", - totalTasks: taskCounts.totalTasks, - doneTasks: taskCounts.doneTasks, - hasDesign: Boolean(await readIfExists(join(changeDir, "design.md"))), - date, - }); - } - } catch { - // archive directory absent is fine. + return metas; + } catch { + return []; + } +} + +async function readWorktreesActiveDirs(worktreesRoot: string): Promise { + try { + const entries = await fs.readdir(worktreesRoot, { withFileTypes: true }); + const worktreeMetas = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const worktreePath = resolve(worktreesRoot, entry.name); + return readActiveDir(join(worktreePath, "openspec", "changes"), worktreePath); + }) + ); + + return worktreeMetas.flat(); + } catch { + return []; + } +} + +function byCreatedDesc(left: ProposalMeta, right: ProposalMeta): number { + const leftTime = new Date(left.date).getTime(); + const rightTime = new Date(right.date).getTime(); + return rightTime - leftTime; +} + +export async function readProposalFiles(projectPath: string): Promise { + const baseChangesDir = join(projectPath, "openspec", "changes"); + try { + const fromMain = await readActiveDir(baseChangesDir); + const fromArchive = await readArchiveDir(join(baseChangesDir, "archive")); + const fromWorktrees = await readWorktreesActiveDirs(join(projectPath, ".worktrees")); + const deduped = new Map(); + + for (const meta of fromMain) { + deduped.set(meta.id, meta); + } + for (const meta of fromArchive) { + deduped.set(meta.id, meta); + } + for (const meta of fromWorktrees) { + deduped.set(meta.id, meta); } - return metas.sort((left, right) => { - const leftTime = new Date(left.date).getTime(); - const rightTime = new Date(right.date).getTime(); - return rightTime - leftTime; - }); + return Array.from(deduped.values()).sort(byCreatedDesc); } catch { return []; } @@ -199,6 +244,33 @@ export async function resolveChangeDir( if (await readIfExists(join(archiveDir, ".openspec.yaml"))) { return archiveDir; } + + try { + const worktreeEntries = await fs.readdir(join(projectPath, ".worktrees"), { + withFileTypes: true, + }); + + for (const entry of worktreeEntries) { + if (!entry.isDirectory()) { + continue; + } + + const worktreeDir = join( + projectPath, + ".worktrees", + entry.name, + "openspec", + "changes", + changeId + ); + if (await readIfExists(join(worktreeDir, ".openspec.yaml"))) { + return worktreeDir; + } + } + } catch { + return null; + } + return null; } diff --git a/frontend/src/pages/proposal/index.vue b/frontend/src/pages/proposal/index.vue index d0dd1877..5b693d09 100644 --- a/frontend/src/pages/proposal/index.vue +++ b/frontend/src/pages/proposal/index.vue @@ -117,14 +117,24 @@ onMounted(() => {
{{ proposal.title }} - - {{ statusConfig[proposal.status].label }} - +
+ + {{ statusConfig[proposal.status].label }} + + + + worktree + +

{{ proposal.why }}

diff --git a/openspec/changes/add-proposal-list-worktree-scan/.openspec.yaml b/openspec/changes/add-proposal-list-worktree-scan/.openspec.yaml index 65b9a4e8..e4ba8648 100644 --- a/openspec/changes/add-proposal-list-worktree-scan/.openspec.yaml +++ b/openspec/changes/add-proposal-list-worktree-scan/.openspec.yaml @@ -1,3 +1,3 @@ schema: spec-driven created: 2026-05-19T00:00:00.000Z -status: draft +status: applying diff --git a/openspec/changes/add-proposal-list-worktree-scan/tasks.md b/openspec/changes/add-proposal-list-worktree-scan/tasks.md index 2bf6eb1b..97f595c8 100644 --- a/openspec/changes/add-proposal-list-worktree-scan/tasks.md +++ b/openspec/changes/add-proposal-list-worktree-scan/tasks.md @@ -1,12 +1,12 @@ ## 1. openspec-reader 双源扫描 -- [ ] 1.1 修改 `electron/main/domain/proposal/openspec-reader.ts`:把现有 `readProposalFiles(projectPath)` 中扫描 main active 与 main archive 的逻辑抽出为两个内部函数 `readActiveDir(dir, worktreePath?)` 与 `readArchiveDir(dir)`。两个函数返回 `ProposalMeta[]`。`readActiveDir` 接收 `worktreePath?: string` 参数,对每条产出的 meta 把该字段赋给 `worktreePath` 字段(`path.resolve` 规范化;为 `undefined` 时字段保持 undefined)。 -- [ ] 1.2 在 `openspec-reader.ts` 新增 `readWorktreesActiveDirs(worktreesRoot: string): Promise` 函数。实现: +- [x] 1.1 修改 `electron/main/domain/proposal/openspec-reader.ts`:把现有 `readProposalFiles(projectPath)` 中扫描 main active 与 main archive 的逻辑抽出为两个内部函数 `readActiveDir(dir, worktreePath?)` 与 `readArchiveDir(dir)`。两个函数返回 `ProposalMeta[]`。`readActiveDir` 接收 `worktreePath?: string` 参数,对每条产出的 meta 把该字段赋给 `worktreePath` 字段(`path.resolve` 规范化;为 `undefined` 时字段保持 undefined)。 +- [x] 1.2 在 `openspec-reader.ts` 新增 `readWorktreesActiveDirs(worktreesRoot: string): Promise` 函数。实现: - 调 `fs.readdir(worktreesRoot, { withFileTypes: true })`;目录不存在或失败时返回 `[]`。 - 对每个 `entry.isDirectory()` 的子目录 `wt`,计算 `worktreePath = path.resolve(worktreesRoot, wt.name)`;调 `readActiveDir(join(worktreePath, "openspec", "changes"), worktreePath)` 收集 metas。 - 不扫 `/openspec/changes/archive/`(worktree 内 archive 不在 P3 范围)。 - 把所有 worktree 的 metas 拼接返回。 -- [ ] 1.3 重写 `readProposalFiles(projectPath)` 主流程: +- [x] 1.3 重写 `readProposalFiles(projectPath)` 主流程: ```ts const baseChangesDir = join(projectPath, "openspec", "changes"); const fromMain = await readActiveDir(baseChangesDir, undefined); @@ -18,12 +18,12 @@ for (const m of fromWorktrees) dedupe.set(m.id, m); // 覆盖 main active 同名 return Array.from(dedupe.values()).sort(byCreatedDesc); ``` -- [ ] 1.4 修改 `resolveChangeDir(projectPath, changeId)`:在原有 main → archive 探查之后,新增 worktree 探查——`fs.readdir(/.worktrees, ...)` → 对每个 worktree 子目录检查 `/openspec/changes//.openspec.yaml` 是否存在,命中则返回该路径;全部 miss 时返回 `null`。worktrees 目录不存在时按现状返回 `null`。 -- [ ] 1.5 验收:`pnpm typecheck` 通过;导出的 `readProposalFiles` 与 `resolveChangeDir` 函数签名不变(向后兼容,所有调用方无需改动)。 +- [x] 1.4 修改 `resolveChangeDir(projectPath, changeId)`:在原有 main → archive 探查之后,新增 worktree 探查——`fs.readdir(/.worktrees, ...)` → 对每个 worktree 子目录检查 `/openspec/changes//.openspec.yaml` 是否存在,命中则返回该路径;全部 miss 时返回 `null`。worktrees 目录不存在时按现状返回 `null`。 +- [x] 1.5 验收:`pnpm typecheck` 通过;导出的 `readProposalFiles` 与 `resolveChangeDir` 函数签名不变(向后兼容,所有调用方无需改动)。 ## 2. 单测覆盖 -- [ ] 2.1 在 `electron/main/domain/proposal/__tests__/openspec-reader.spec.ts`(如不存在则建)覆盖以下场景: +- [x] 2.1 在 `electron/main/domain/proposal/__tests__/openspec-reader.spec.ts`(如不存在则建)覆盖以下场景: - 2.1.1 worktrees 目录不存在 → list 仅含 main 来源;不抛错。 - 2.1.2 单个 worktree 单 change → list 含一条 worktreePath 非空的条目;字符串等于 `path.resolve(/.worktrees/)`。 - 2.1.3 多个 worktree 各自一份 change → list 同时含两条 worktreePath 字段不同的条目。 @@ -31,16 +31,16 @@ - 2.1.5 main archive 与 worktree active 同名(业务上不会发生但测试 archive 路径不参与去重):archive 那条 changeId 含日期前缀,worktree 那条 changeId 不含日期前缀,list 同时含两条。 - 2.1.6 worktree 子目录中 `.openspec.yaml` 缺失 → 跳过该子目录,不抛错。 - 2.1.7 worktree 路径含 trailing slash 的边界(mock fs 路径返回 `/abs/.worktrees/foo/` 时)→ ProposalMeta.worktreePath === `/abs/.worktrees/foo`。 -- [ ] 2.2 在同一测试文件添加 `resolveChangeDir` 测试: +- [x] 2.2 在同一测试文件添加 `resolveChangeDir` 测试: - 2.2.1 main active 命中 → 返回 main 路径。 - 2.2.2 main miss、main archive 命中 → 返回 archive 路径。 - 2.2.3 main miss、archive miss、worktree 命中 → 返回 worktree 路径。 - 2.2.4 三处都 miss → 返回 null。 -- [ ] 2.3 验收:`pnpm test electron/main/domain/proposal` 全过;新增用例覆盖 2.1.1 - 2.2.4。 +- [x] 2.3 验收:`pnpm test electron/main/domain/proposal` 全过;新增用例覆盖 2.1.1 - 2.2.4。 ## 3. 列表页卡片标记 -- [ ] 3.1 修改 `frontend/src/pages/proposal/index.vue` 卡片模板:在 status badge 同行右侧(紧邻 `` 之后),追加 worktree 标记元素: +- [x] 3.1 修改 `frontend/src/pages/proposal/index.vue` 卡片模板:在 status badge 同行右侧(紧邻 `` 之后),追加 worktree 标记元素: ```vue worktree ``` -- [ ] 3.2 验收:`worktreePath` 非空的卡片右上角显示 git-branch icon + "worktree" 文案;hover 显示完整路径;`worktreePath` 为 undefined 的卡片不渲染该 DOM。 +- [x] 3.2 验收:`worktreePath` 非空的卡片右上角显示 git-branch icon + "worktree" 文案;hover 显示完整路径;`worktreePath` 为 undefined 的卡片不渲染该 DOM。 ## 4. dogfood 与零回归验证 @@ -64,11 +64,11 @@ - [ ] 4.4 在详情页触发一次 apply(任一 stage);从主进程 logger / `data/projects//apply-runs//run.json` 中确认 `worktreePath` 字段被写入(值等于 worktree 绝对路径);apply ACP session cwd 等于 worktree 绝对路径(可在 logger 中 grep "cwd")。 - [ ] 4.5 用一个非 git 项目(`template: "empty"`)确认列表完全等价于改造前:worktree 标记不出现;apply / archive cwd 仍为主仓库;行为零回归。 - [ ] 4.6 已经 archive 完成的旧 change(`archive/-`)仍在列表显示,状态为 archived;与可能并存的 worktree 来源 active change 不冲突(如果存在异常状态)。 -- [ ] 4.7 `pnpm build` / `pnpm lint` / `pnpm typecheck` 全部通过。 +- [x] 4.7 `pnpm build` / `pnpm lint` / `pnpm typecheck` 全部通过。 - [ ] 4.8 验收:4.1–4.7 全部通过。 ## 5. 文档与下游对齐 -- [ ] 5.1 检查 `electron/main/services/proposal/apply-run-service.ts` 当前实现是否在 `createApplyRun` 时已经从 ProposalMeta 透传 worktreePath(P1 已完成此 task)。如果发现未透传,按 P1 task 4.1 的实现补齐——但 P1 已完成,本任务只校验。 -- [ ] 5.2 检查 `electron/main/ipc/proposal-apply.ts` stage stream 与 archive handler 的 cwd 是否使用 `runMeta.worktreePath ?? projectPath`(P1 已完成);reminderContext 是否含 worktreePath(P1 已完成)。本任务只校验。 -- [ ] 5.3 验收:5.1 / 5.2 校验通过;如果发现 P1 漏实施,回头补 P1 task,**不**在 P3 内补丁。 +- [x] 5.1 检查 `electron/main/services/proposal/apply-run-service.ts` 当前实现是否在 `createApplyRun` 时已经从 ProposalMeta 透传 worktreePath(P1 已完成此 task)。如果发现未透传,按 P1 task 4.1 的实现补齐——但 P1 已完成,本任务只校验。 +- [x] 5.2 检查 `electron/main/ipc/proposal-apply.ts` stage stream 与 archive handler 的 cwd 是否使用 `runMeta.worktreePath ?? projectPath`(P1 已完成);reminderContext 是否含 worktreePath(P1 已完成)。本任务只校验。 +- [x] 5.3 验收:5.1 / 5.2 校验通过;如果发现 P1 漏实施,回头补 P1 task,**不**在 P3 内补丁。 From 3342501626637c8cfd3da2e4b2a5df655f090303 Mon Sep 17 00:00:00 2001 From: Fio Date: Tue, 19 May 2026 23:00:20 +0800 Subject: [PATCH 06/19] feat(worktree): wip! add-archive-worktree-orchestration --- .../chat/system-reminder/archive.spec.ts | 103 ++++++++++++++++++ .../services/proposal/stage-prompts.spec.ts | 22 ++++ .../system-reminder/templates/archive.txt | 33 +++++- .../main/services/proposal/stage-prompts.ts | 2 +- .../fyllo-specs/src/prompts/archive-change.md | 1 + .../.openspec.yaml | 2 +- .../tasks.md | 32 +++--- 7 files changed, 175 insertions(+), 20 deletions(-) create mode 100644 electron/main/__tests__/services/chat/system-reminder/archive.spec.ts create mode 100644 electron/main/__tests__/services/proposal/stage-prompts.spec.ts diff --git a/electron/main/__tests__/services/chat/system-reminder/archive.spec.ts b/electron/main/__tests__/services/chat/system-reminder/archive.spec.ts new file mode 100644 index 00000000..f2c6e74f --- /dev/null +++ b/electron/main/__tests__/services/chat/system-reminder/archive.spec.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SystemReminderContext } from "@main/services/chat/system-reminder/types"; +import { renderSystemReminderTemplate } from "@main/services/chat/system-reminder/providers/shared"; +import archiveTemplate from "@main/services/chat/system-reminder/templates/archive.txt?raw"; + +const logger = vi.hoisted(() => ({ + warn: vi.fn(), +})); + +vi.mock("@main/infra/logger", () => ({ + default: logger, +})); + +function createContext(overrides: Partial = {}): SystemReminderContext { + return { + owner: "archive", + projectPath: "/abs", + cwd: "/abs", + fylloSessionId: "archive-session-1", + agentId: "claude-acp", + changeId: "foo", + runId: "archive-run-1", + ...overrides, + }; +} + +describe("archive system-reminder template", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the worktree section with concrete worktree and branch values", () => { + const reminder = renderSystemReminderTemplate( + archiveTemplate, + createContext({ + worktreePath: "/abs/.worktrees/foo", + }) + ); + + expect(reminder).toContain(""); + expect(reminder).toContain(""); + expect(reminder).toContain("/abs/.worktrees/foo"); + expect(reminder).toContain("proposal/foo"); + expect(reminder).toContain("git -C /abs merge --ff-only proposal/foo"); + expect(reminder).toContain("git -C /abs worktree remove /abs/.worktrees/foo"); + expect(reminder).toContain("git -C /abs branch -d proposal/foo"); + expect(reminder).toContain("MUST run merge as `git merge --ff-only`"); + expect(reminder).toContain("MUST NOT use `worktree remove --force` / `branch -D`."); + }); + + it("renders an empty worktreePath and keeps the explicit downgrade instructions", () => { + const reminder = renderSystemReminderTemplate(archiveTemplate, createContext()); + + expect(reminder).toContain(""); + expect(reminder).toContain("如果 `` 为空字符串"); + expect(reminder).toContain("跳过本段全部 git 编排"); + expect(reminder).toContain("本 archive 不需要 merge / worktree remove / branch delete。"); + }); + + it("renders mainProjectPath as the same value as projectPath", () => { + const reminder = renderSystemReminderTemplate( + "main={{mainProjectPath}} project={{projectPath}}", + createContext({ + projectPath: "/abs/project", + }) + ); + + expect(reminder).toBe("main=/abs/project project=/abs/project"); + }); + + it("replaces changeId placeholders consistently for archive branches", () => { + const reminder = renderSystemReminderTemplate( + archiveTemplate, + createContext({ + changeId: "foo_bar", + worktreePath: "/abs/.worktrees/foo_bar", + }) + ); + + expect(reminder).toContain("archive foo_bar"); + expect(reminder).toContain("proposal/foo_bar"); + expect(reminder).toContain("git -C /abs branch -d proposal/foo_bar"); + }); + + it("returns null and warns when any field contains angle brackets", () => { + const reminder = renderSystemReminderTemplate( + archiveTemplate, + createContext({ + changeId: "foo", + }) + ); + + expect(reminder).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + "[system-reminder] rejected reminder variable", + expect.objectContaining({ + owner: "archive", + field: "changeId", + fylloSessionId: "archive-session-1", + }) + ); + }); +}); diff --git a/electron/main/__tests__/services/proposal/stage-prompts.spec.ts b/electron/main/__tests__/services/proposal/stage-prompts.spec.ts new file mode 100644 index 00000000..b89a7d4a --- /dev/null +++ b/electron/main/__tests__/services/proposal/stage-prompts.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { buildStagePrompt } from "@main/services/proposal/stage-prompts"; + +describe("buildStagePrompt", () => { + it("returns a minimal archive prompt without orchestration keywords", () => { + const prompt = buildStagePrompt({ + changeId: "foo", + projectPath: "/x", + stage: { + id: "archive", + name: "归档", + type: "proposal-archive", + }, + }); + + expect(prompt).toBe("归档 foo"); + expect(prompt).not.toContain("提交代码"); + expect(prompt).not.toContain("merge"); + expect(prompt).not.toContain("worktree"); + expect(prompt).not.toContain("commit"); + }); +}); diff --git a/electron/main/services/chat/system-reminder/templates/archive.txt b/electron/main/services/chat/system-reminder/templates/archive.txt index cc1acbfa..3e9eddeb 100644 --- a/electron/main/services/chat/system-reminder/templates/archive.txt +++ b/electron/main/services/chat/system-reminder/templates/archive.txt @@ -7,9 +7,35 @@ You are running inside FylloCode — a Desktop App that helps product and engine You are currently in the **Archive stage** (the third of the three) for project `{{projectPath}}`, finalizing OpenSpec change `{{changeId}}`. Archive run id: {{runId}}. -Your job is to keep the archive result, the spec state, and the commit history mutually consistent — sync first, archive next, commit last. **No step skipping.** +Your job is to keep the archive result, the spec state, and the commit history mutually consistent — sync first, archive next, then commit, merge, and clean up the worktree when required. **No step skipping.** + +本 archive run 的工作目录(cwd)是 `{{worktreePath}}`。 + +如果 `{{worktreePath}}` 为空字符串(典型场景:非 git 项目,或 ApplyRunMeta 是 P3 启用前创建),跳过本段全部 git 编排,仅按 `` 完成 archive-change 文件移动 + 业务代码 commit 即可。本 archive 不需要 merge / worktree remove / branch delete。 + +如果 `{{worktreePath}}` 非空,archive-change 完成 OpenSpec 文件移动后,按以下顺序完成 4 步 git 收尾: + +1. **commit OpenSpec 归档移动**(含归档目录新增 + 主 spec 同步): + `git -C {{worktreePath}} add -A && git -C {{worktreePath}} commit -m ""` + commit message 仍按 `` 与 `` 段已规定的 `type(scope): summary` 模板。`scope` 用 `openspec`,`summary` 简明描述本次归档(例 `archive {{changeId}}`);下方可选 bullet 列表概括 sync / archive 关键动作。失败时把 stderr 完整复述给用户,不自动重试。 + +2. **fast-forward merge 进 main**: + `git -C {{mainProjectPath}} merge --ff-only proposal/{{changeId}}` + 失败(典型:"Not possible to fast-forward")时把 stderr 完整复述给用户,请用户决定 rebase 或普通 merge;不要自行 fall back,不要 force,不自动重试。 + +3. **移除 worktree**: + `git -C {{mainProjectPath}} worktree remove {{worktreePath}}` + 失败(典型:编辑器锁定文件)时把 stderr 完整复述给用户,让用户关闭编辑器后由用户自行 `git -C {{mainProjectPath}} worktree remove --force {{worktreePath}}`;不要自行加 `--force`,不自动重试。 + +4. **删除 worktree 分支**: + `git -C {{mainProjectPath}} branch -d proposal/{{changeId}}` + 失败(典型:分支未完全合并)时把 stderr 完整复述给用户;不要自行 `-D` 强删,不自动重试。 + +任一步失败时,archive ACP session 不终止,agent 仍可在后续轮次中根据用户的进一步指示重试或调整。但**不**自动重试。 + + ## Archive Stage Goals @@ -44,12 +70,15 @@ Your job is to keep the archive result, the spec state, and the commit history m The following constraints MUST NOT be violated in the Archive stage. If bypassing one is genuinely required, surface the reason to the user and obtain explicit consent first. -- **MUST follow the order: sync → archive → commit.** No reordering, no skipping. +- **MUST follow the order: sync → archive → commit → merge → worktree-cleanup.** No reordering, no skipping. Steps 4–5 only when `{{worktreePath}}` is non-empty. - **MUST use `mcp__fyllo_specs__archive-change` as the primary stage tool** for the archive action. - **MUST default to `Sync now` for delta specs.** Skip sync only with explicit user acceptance of the risk. - **MUST stop on archive path conflicts.** Never force through. - **MUST commit only change-related files.** Do not bundle unrelated worktree diffs. - **MUST use `type(scope): summary` for the commit subject** and accurately describe the archive/sync actions. +- **MUST run merge as `git merge --ff-only`** and stop on failure (no force, no auto普通 merge fallback). +- **MUST clean up worktree only after merge succeeds.** worktree remove without successful merge would lose the archive commit. +- **MUST NOT use `worktree remove --force` / `branch -D`.** Failure stderr goes to the user; the user decides force. - **MUST NOT bypass the MCP / runtime flow** by calling the OpenSpec CLI directly or moving files manually. - **MUST report incomplete tasks, missing artifacts, or conflicts honestly** before proceeding or stopping. - **MUST call `archive-change` with `includeInstruction: true`** (or leave it unset) on the first invocation of this run. The returned `tool_instruction` defines the sync → archive → commit ordering, conflict handling, and reporting contract. Passing `false` returns only state JSON and discards that workflow. diff --git a/electron/main/services/proposal/stage-prompts.ts b/electron/main/services/proposal/stage-prompts.ts index c946fd93..bd034146 100644 --- a/electron/main/services/proposal/stage-prompts.ts +++ b/electron/main/services/proposal/stage-prompts.ts @@ -10,7 +10,7 @@ type StageRunner = (ctx: StageRunnerContext) => string; export const stageRunners: Partial> = { "proposal-apply": ({ changeId }) => `实现 ${changeId}`, - "proposal-archive": ({ changeId }) => `归档 ${changeId} 并提交代码`, + "proposal-archive": ({ changeId }) => `归档 ${changeId}`, "code-review": ({ stage }) => stage.prompt ?? `审查当前变更的正确性、可维护性与测试覆盖。`, "security-check": ({ stage }) => stage.prompt ?? `检查当前变更是否存在安全风险。`, "create-pr": ({ stage }) => stage.prompt ?? `创建 Pull Request。`, diff --git a/mcp-servers/fyllo-specs/src/prompts/archive-change.md b/mcp-servers/fyllo-specs/src/prompts/archive-change.md index 488ccb34..11153434 100644 --- a/mcp-servers/fyllo-specs/src/prompts/archive-change.md +++ b/mcp-servers/fyllo-specs/src/prompts/archive-change.md @@ -65,6 +65,7 @@ All artifacts complete. All tasks complete. **Guardrails** - Do not invoke the OpenSpec CLI or shell archive commands directly. Archive operations are handled by this MCP server via `confirm: true`. +- Git commit / merge / worktree-cleanup are orchestrated by the archive system-reminder, not by this tool. - Don't block archive on warnings — just inform and confirm - If `state.conflicts` is non-empty, do NOT proceed with `confirm: true` — report the conflict instead - If `state.archiveRawOutput` is available, prefer it over inference when describing the actual archive result diff --git a/openspec/changes/add-archive-worktree-orchestration/.openspec.yaml b/openspec/changes/add-archive-worktree-orchestration/.openspec.yaml index 65b9a4e8..e4ba8648 100644 --- a/openspec/changes/add-archive-worktree-orchestration/.openspec.yaml +++ b/openspec/changes/add-archive-worktree-orchestration/.openspec.yaml @@ -1,3 +1,3 @@ schema: spec-driven created: 2026-05-19T00:00:00.000Z -status: draft +status: applying diff --git a/openspec/changes/add-archive-worktree-orchestration/tasks.md b/openspec/changes/add-archive-worktree-orchestration/tasks.md index 6a1ffd27..cfe1ad21 100644 --- a/openspec/changes/add-archive-worktree-orchestration/tasks.md +++ b/openspec/changes/add-archive-worktree-orchestration/tasks.md @@ -1,42 +1,42 @@ ## 1. archive prompt 文案精简 -- [ ] 1.1 修改 `electron/main/services/proposal/stage-prompts.ts`:把 `stageRunners["proposal-archive"]` 的实现从 `({ changeId }) => \`归档 ${changeId} 并提交代码\``改为`({ changeId }) => \`归档 ${changeId}\``。 -- [ ] 1.2 检查并更新 `electron/main/__tests__/ipc/proposal-apply.spec.ts`(line 79 附近 mock `buildArchiveStage`):如果存在对 archive prompt 文本的断言,同步改为新文案;如果断言不涉及 prompt 文本,本步骤无需修改测试。 -- [ ] 1.3 在 `electron/main/services/proposal/__tests__/`(如不存在则建)创建/补充 `stage-prompts.spec.ts`,断言: +- [x] 1.1 修改 `electron/main/services/proposal/stage-prompts.ts`:把 `stageRunners["proposal-archive"]` 的实现从 `({ changeId }) => \`归档 ${changeId} 并提交代码\``改为`({ changeId }) => \`归档 ${changeId}\``。 +- [x] 1.2 检查并更新 `electron/main/__tests__/ipc/proposal-apply.spec.ts`(line 79 附近 mock `buildArchiveStage`):如果存在对 archive prompt 文本的断言,同步改为新文案;如果断言不涉及 prompt 文本,本步骤无需修改测试。 +- [x] 1.3 在 `electron/main/services/proposal/__tests__/`(如不存在则建)创建/补充 `stage-prompts.spec.ts`,断言: - `buildStagePrompt({ stage: { type: "proposal-archive" }, changeId: "foo", projectPath: "/x" })` 返回字符串严格等于 `归档 foo`。 - 该字符串不含 `提交代码` / `merge` / `worktree` / `commit` 子串。 -- [ ] 1.4 验收:`pnpm typecheck` 通过;`pnpm test electron/main` 全过。 +- [x] 1.4 验收:`pnpm typecheck` 通过;`pnpm test electron/main` 全过。 ## 2. archive.txt 模板新增 worktree 段 -- [ ] 2.1 修改 `electron/main/services/chat/system-reminder/templates/archive.txt`:在现有 `` 闭合标签之后、`` 开始标签之前,插入完整的 `` 段,内容严格按 design.md "archive.txt `` 段(设计文本)" 节写入。 -- [ ] 2.2 文本必须含三个占位符:`{{worktreePath}}`、`{{mainProjectPath}}`、`{{changeId}}`。分支名以 `proposal/{{changeId}}` 字面量出现,不引入 `branchName` 占位符。 -- [ ] 2.3 文本第 1 段必须明确:`{{worktreePath}}` 为空字符串时跳过整段 4 步 git 编排,仅按 `` 完成 archive-change + 业务代码 commit。 -- [ ] 2.4 文本必须依次写出: +- [x] 2.1 修改 `electron/main/services/chat/system-reminder/templates/archive.txt`:在现有 `` 闭合标签之后、`` 开始标签之前,插入完整的 `` 段,内容严格按 design.md "archive.txt `` 段(设计文本)" 节写入。 +- [x] 2.2 文本必须含三个占位符:`{{worktreePath}}`、`{{mainProjectPath}}`、`{{changeId}}`。分支名以 `proposal/{{changeId}}` 字面量出现,不引入 `branchName` 占位符。 +- [x] 2.3 文本第 1 段必须明确:`{{worktreePath}}` 为空字符串时跳过整段 4 步 git 编排,仅按 `` 完成 archive-change + 业务代码 commit。 +- [x] 2.4 文本必须依次写出: - 1. commit OpenSpec 归档移动(`git -C {{worktreePath}} add -A && commit`) - 2. merge --ff-only 进 main(`git -C {{mainProjectPath}} merge --ff-only proposal/{{changeId}}`) - 3. worktree remove(`git -C {{mainProjectPath}} worktree remove {{worktreePath}}`) - 4. branch delete(`git -C {{mainProjectPath}} branch -d proposal/{{changeId}}`) -- [ ] 2.5 每步必须明确"失败时把 stderr 完整复述给用户、不自动重试、不加 force"。 +- [x] 2.5 每步必须明确"失败时把 stderr 完整复述给用户、不自动重试、不加 force"。 ## 3. archive.txt 模板 critical 段扩展 -- [ ] 3.1 修改 `archive.txt` 的 `` 段:把原有第一条 `MUST follow the order: sync → archive → commit. No reordering, no skipping.` 改为 `MUST follow the order: sync → archive → commit → merge → worktree-cleanup. No reordering, no skipping. Steps 4–5 only when `{{worktreePath}}` is non-empty.` -- [ ] 3.2 在 `` 段中追加以下新 SHALL 条款(保留原有所有其他 SHALL): +- [x] 3.1 修改 `archive.txt` 的 `` 段:把原有第一条 `MUST follow the order: sync → archive → commit. No reordering, no skipping.` 改为 `MUST follow the order: sync → archive → commit → merge → worktree-cleanup. No reordering, no skipping. Steps 4–5 only when `{{worktreePath}}` is non-empty.` +- [x] 3.2 在 `` 段中追加以下新 SHALL 条款(保留原有所有其他 SHALL): - `MUST run merge as `git merge --ff-only`` and stop on failure (no force, no auto普通 merge fallback).` - `MUST clean up worktree only after merge succeeds.` worktree remove without successful merge would lose the archive commit.` - `MUST NOT use `worktree remove --force`/`branch -D`.` Failure stderr goes to the user; the user decides force.` -- [ ] 3.3 验收:`` 段总条款数(既有 + 新增)= 既有 + 3;既有的 commit subject 格式 / commit only change-related files / 不能 bypass MCP / `archive-change` 必须传 `includeInstruction: true` 等条款全部保留。 +- [x] 3.3 验收:`` 段总条款数(既有 + 新增)= 既有 + 3;既有的 commit subject 格式 / commit only change-related files / 不能 bypass MCP / `archive-change` 必须传 `includeInstruction: true` 等条款全部保留。 ## 4. 模板渲染单测扩展 -- [ ] 4.1 在 P2 已建的 `electron/main/services/chat/system-reminder/providers/__tests__/`(或同路径下新建 archive-specific 测试文件)追加用例: +- [x] 4.1 在 P2 已建的 `electron/main/services/chat/system-reminder/providers/__tests__/`(或同路径下新建 archive-specific 测试文件)追加用例: - 4.1.1 archive owner 渲染:传入 `worktreePath: "/abs/.worktrees/foo"`、`projectPath: "/abs"`、`changeId: "foo"` → 渲染后文本含 `` 段、含字面量 `/abs/.worktrees/foo` 与 `proposal/foo` 字符串。 - 4.1.2 archive owner 渲染:传入 `worktreePath: undefined` → 渲染后 `{{worktreePath}}` 替换为空字符串;文本仍含 `` 段;段开头能找到"为空字符串时跳过 git 编排"的中文叙述。 - 4.1.3 archive owner 渲染:`{{mainProjectPath}}` 与 `{{projectPath}}` 应渲染为同一字符串。 - 4.1.4 archive owner 渲染:`changeId` 含特殊字符(如下划线)时占位符正常替换;`{{changeId}}` 与 `proposal/{{changeId}}` 都按规则渲染。 - 4.1.5 任一字段含 `<` 或 `>` 字符时 `renderSystemReminderTemplate` 返回 `null`、`logger.warn` 被调用。 -- [ ] 4.2 验收:上述 5 条用例全过;`pnpm test electron/main/services/chat/system-reminder` 全过。 +- [x] 4.2 验收:上述 5 条用例全过;`pnpm test electron/main/services/chat/system-reminder` 全过。 ## 5. dogfood 与零回归验证 @@ -69,8 +69,8 @@ ## 6. 文档与实施顺序对齐 -- [ ] 6.1 检查 `mcp-servers/fyllo-specs/src/prompts/archive-change.md`:当前 `tool_instruction` 的"sync 主 spec → archive 文件移动 → 报告状态"约束是否已经清晰。如不清晰,补一句"git commit / merge / worktree-cleanup 由 archive system-reminder 编排,不在本工具内执行",避免 agent 混淆 MCP 边界。 -- [ ] 6.2 验收:`mcp-servers/fyllo-specs/__tests__/prompts.test.ts` 仍通过(如断言 prompt 文本片段,则同步更新断言)。 +- [x] 6.1 检查 `mcp-servers/fyllo-specs/src/prompts/archive-change.md`:当前 `tool_instruction` 的"sync 主 spec → archive 文件移动 → 报告状态"约束是否已经清晰。如不清晰,补一句"git commit / merge / worktree-cleanup 由 archive system-reminder 编排,不在本工具内执行",避免 agent 混淆 MCP 边界。 +- [x] 6.2 验收:`mcp-servers/fyllo-specs/__tests__/prompts.test.ts` 仍通过(如断言 prompt 文本片段,则同步更新断言)。 ## 7. 验收总闸 From fce33eaa100573c29360ef37dac717d42e34a184 Mon Sep 17 00:00:00 2001 From: Fio Date: Tue, 19 May 2026 23:32:54 +0800 Subject: [PATCH 07/19] chore(openspec): archive add-session-list-agent-icon - synced session-management spec - archived add-session-list-agent-icon --- .../__tests__/components/session-item.spec.ts | 52 +++++++++++++++ frontend/src/components/chat/SessionItem.vue | 32 +++++++--- .../.openspec.yaml | 3 + .../design.md | 63 +++++++++++++++++++ .../proposal.md | 26 ++++++++ .../specs/session-management/spec.md | 37 +++++++++++ .../tasks.md | 9 +++ openspec/specs/session-management/spec.md | 19 +++++- 8 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 openspec/changes/archive/2026-05-19-add-session-list-agent-icon/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-19-add-session-list-agent-icon/design.md create mode 100644 openspec/changes/archive/2026-05-19-add-session-list-agent-icon/proposal.md create mode 100644 openspec/changes/archive/2026-05-19-add-session-list-agent-icon/specs/session-management/spec.md create mode 100644 openspec/changes/archive/2026-05-19-add-session-list-agent-icon/tasks.md diff --git a/frontend/src/__tests__/components/session-item.spec.ts b/frontend/src/__tests__/components/session-item.spec.ts index 0325caf7..8270248b 100644 --- a/frontend/src/__tests__/components/session-item.spec.ts +++ b/frontend/src/__tests__/components/session-item.spec.ts @@ -11,6 +11,7 @@ const streamErrorRef = ref<{ code: string; message: string } | null>({ code: "stream_failed", message: "bad network", }); +const iconsRef = ref>({}); const selectSession = vi.fn(async (sessionId: string) => { activeSessionIdRef.value = sessionId; @@ -48,6 +49,14 @@ vi.mock("@renderer/stores", () => ({ }), })); +vi.mock("@renderer/stores/acp-agents", () => ({ + useAcpAgentsStore: () => ({ + get icons() { + return iconsRef.value; + }, + }), +})); + function makeSession(id: string): Session { return { id, @@ -69,6 +78,7 @@ describe("SessionItem", () => { activeSessionIdRef.value = "session-1"; chatStatusRef.value = "error"; streamErrorRef.value = { code: "stream_failed", message: "bad network" }; + iconsRef.value = {}; selectSession.mockClear(); renameSession.mockClear(); deleteSession.mockClear(); @@ -103,4 +113,46 @@ describe("SessionItem", () => { expect(streamErrorRef.value).toBeNull(); expect(activeSessionIdRef.value).toBe("session-2"); }); + + it("renders agent icon when the session agent has a matching icon", () => { + iconsRef.value = { + "claude-code": "data:image/png;base64,agent-icon", + }; + + const wrapper = mount(SessionItem, { + props: { + session: makeSession("session-2"), + }, + global: { + plugins: [createPinia()], + }, + }); + + const icon = wrapper.get('[data-test="session-agent-icon"]'); + expect(icon.attributes("src")).toBe("data:image/png;base64,agent-icon"); + expect(icon.attributes("alt")).toBe("claude-code icon"); + }); + + it("keeps a stable leading slot when the session agent icon is missing", () => { + const session = { + ...makeSession("session-3"), + agentId: "unknown-agent", + title: "Long session title", + turnCount: 4, + }; + + const wrapper = mount(SessionItem, { + props: { + session, + }, + global: { + plugins: [createPinia()], + }, + }); + + expect(wrapper.get('[data-test="session-agent-icon-slot"]').exists()).toBe(true); + expect(wrapper.find('[data-test="session-agent-icon"]').exists()).toBe(false); + expect(wrapper.text()).toContain("Long session title"); + expect(wrapper.text()).toContain("4 turns"); + }); }); diff --git a/frontend/src/components/chat/SessionItem.vue b/frontend/src/components/chat/SessionItem.vue index 582b774d..8da1a273 100644 --- a/frontend/src/components/chat/SessionItem.vue +++ b/frontend/src/components/chat/SessionItem.vue @@ -3,6 +3,7 @@ import { computed, toRef } from "vue"; import { useSessionStore } from "@renderer/stores/session"; import type { Session } from "@shared/types/chat"; import { useChatStore } from "@renderer/stores"; +import { useAcpAgentsStore } from "@renderer/stores/acp-agents"; const props = defineProps<{ session: Session; @@ -10,9 +11,11 @@ const props = defineProps<{ const sessionStore = useSessionStore(); const chatStore = useChatStore(); +const acpAgentsStore = useAcpAgentsStore(); const session = toRef(props, "session"); const active = computed(() => sessionStore.activeSessionId === session.value.id); +const agentIcon = computed(() => acpAgentsStore.icons[session.value.agentId] ?? null); const menuItems = computed(() => [ { @@ -94,14 +97,29 @@ async function handleDelete(): Promise { />
-
-
- {{ session.title }} +
+
+
-
- {{ formatTime(session.updatedAt) }} - · - {{ session.turnCount }} turns + +
+
+ {{ session.title }} +
+
+ {{ formatTime(session.updatedAt) }} + · + {{ session.turnCount }} turns +
diff --git a/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/.openspec.yaml b/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/.openspec.yaml new file mode 100644 index 00000000..e4ba8648 --- /dev/null +++ b/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-05-19T00:00:00.000Z +status: applying diff --git a/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/design.md b/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/design.md new file mode 100644 index 00000000..0d898d20 --- /dev/null +++ b/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/design.md @@ -0,0 +1,63 @@ +## Context + +当前 session 列表由 `frontend/src/components/chat/ChatSidebar.vue` 通过 `SessionItem.vue` 逐条渲染。`SessionItem.vue` 目前只消费 `session: Session`,展示运行状态点、标题、更新时间和轮次数。 + +ACP agent icon 的现有来源已经存在于 `useAcpAgentsStore.icons`。该 store 负责通过 `acpAgentsApi.getIcons()` 拉取并缓存 `Record`,聊天消息区的 `UIMessageList.vue` 已经复用这份 icon 数据来渲染 assistant avatar。这意味着“按 `session.agentId` 解析 icon”已有成熟数据源,不需要新增 IPC 或持久化字段。 + +本次变更只调整 Chat 侧边栏会话项的展示信息层级,不改变 session 排序、选择、重命名、删除或消息区 avatar 的现有行为。 + +## Goals / Non-Goals + +**Goals:** + +- 让用户在进入 session 前即可识别该会话对应的 agent。 +- 复用现有 ACP icon 数据源,不引入新的跨进程契约。 +- 在 icon 未命中或尚未加载完成时保持稳定布局,不影响会话列表操作。 + +**Non-Goals:** + +- 不移除聊天消息区内现有 assistant avatar 或 user avatar。 +- 不修改 session 数据模型、创建流程、agent 锁定逻辑或 session 排序规则。 +- 不在会话列表中新增 agent 名称文本,仅新增紧凑图标标识。 + +## Decisions + +### 1. 将 agent icon 放在 `SessionItem.vue` 内部渲染 + +原因: + +- `SessionItem.vue` 已经是单个 session 条目的展示边界,新增图标应和标题、状态点同属一个组件责任。 +- `session.agentId` 已经存在于 `Session` 模型中,组件本地即可完成“session -> icon”映射,无需让 `ChatSidebar.vue` 先做预处理。 + +备选方案: + +- 在 `ChatSidebar.vue` 预先把 `session` 映射为 view model 再传给 `SessionItem`。未采用,因为会引入额外中间层,但当前展示逻辑仍然简单。 + +### 2. 复用 `useAcpAgentsStore.icons`,不新增单独的会话列表图标加载逻辑 + +原因: + +- `UIMessageList.vue` 已经证明该 store 能提供 `agentId -> icon` 映射。 +- 复用现有 store 能保持 icon 缓存与更新行为一致,避免重复请求和双份状态。 + +备选方案: + +- 在 `SessionItem.vue` 或 `ChatSidebar.vue` 自行调用 API 拉取图标。未采用,因为这会绕过既有 store 分层并制造重复缓存。 + +### 3. icon 缺失时保留固定尺寸占位,而不是切换为文本或完全塌缩 + +原因: + +- `agent-registry-cache` spec 已允许部分 icon 不存在或延迟加载;列表不应因为 icon 状态不同而出现水平抖动。 +- 固定尺寸占位可以让标题起始列对齐,保证侧边栏扫描节奏稳定。 + +备选方案: + +- icon 缺失时直接不渲染任何前导区域。未采用,因为会导致部分 session 标题左边距不一致。 +- icon 缺失时显示 agentId 文本缩写。未采用,因为这会显著增加横向占用,背离本次“紧凑识别”的目标。 + +## Risks / Trade-offs + +- [图标尚未预热完成时出现空占位] → 通过固定尺寸占位和非阻塞加载避免布局跳动,图标加载完成后再无缝替换。 +- [会话项横向空间更紧] → 保持 icon 为小尺寸前导元素,并继续使用标题截断,避免压缩时间/turn 信息到不可读。 +- [测试 stub 未覆盖新增图像节点] → 在 renderer 组件测试中显式断言有/无 icon 的两种分支,避免只测交互不测展示。 diff --git a/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/proposal.md b/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/proposal.md new file mode 100644 index 00000000..19505f35 --- /dev/null +++ b/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/proposal.md @@ -0,0 +1,26 @@ +## Why + +当前 Chat 侧边栏的 session 列表只显示标题、时间戳、轮次数和状态点,用户在切换会话前无法直接识别每个 session 绑定的是哪个 ACP agent。随着同一项目下出现多个 agent 混用的 session,这会降低会话扫描与切换效率。 + +## What Changes + +- 在 Chat 侧边栏的每个 session 条目中新增 agent icon 展示,使用该 session 的 `agentId` 对应 ACP agent icon 作为会话级身份标识。 +- 将 session 列表的展示契约从“标题 + 时间戳 + 轮次数 + 状态指示器”扩展为“标题 + 时间戳 + 轮次数 + 状态指示器 + agent icon”。 +- 规定 icon 缺失时的降级行为,避免 registry/icon 加载延迟导致列表抖动或报错。 + +## Capabilities + +### New Capabilities + +- 无 + +### Modified Capabilities + +- `session-management`: 调整 Chat 左侧 session 列表条目的可见信息,新增会话绑定 agent 的图标标识及其缺失时的降级行为。 + +## Impact + +- 受影响前端组件:`frontend/src/components/chat/SessionItem.vue`、`frontend/src/components/chat/ChatSidebar.vue` +- 受影响前端 store / 数据源:`frontend/src/stores/acp-agents.ts` +- 受影响测试:`frontend/src/__tests__/components/session-item.spec.ts`,以及可能新增的 Chat 侧边栏组件测试 +- 不涉及新的 IPC、共享类型、磁盘存储格式或 session 数据模型变更 diff --git a/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/specs/session-management/spec.md b/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/specs/session-management/spec.md new file mode 100644 index 00000000..31c40d76 --- /dev/null +++ b/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/specs/session-management/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: Sessions 标签列出所有项目 session + +系统 SHALL 在左侧边栏的"Sessions"标签中显示 session 列表,按最新优先排序,最新 session 在顶部。session 列表 SHALL 从磁盘持久化存储中加载,而非使用 mock 数据。 + +每个 session 条目 SHALL 显示标题、时间戳、轮次数、状态指示器,以及与该 session `agentId` 对应的 ACP agent icon,用于在进入会话前识别该 session 绑定的 agent。 + +当 `useAcpAgentsStore.icons` 中存在该 `agentId` 对应 icon 时,session 条目 SHALL 渲染该 icon;当 `agentId` 未命中 icon、icon 尚未加载完成或 registry 不可用时,session 条目 SHALL 保留固定尺寸的前导占位,并继续正常显示其余会话信息,不得报错或导致条目布局塌缩。 + +#### Scenario: Session 列表已填充 + +- **WHEN** 项目存在已有 session +- **THEN** 列表显示每个 session 的标题、时间戳、轮次数、状态指示器和 agent icon + +#### Scenario: Session 标题截断 + +- **WHEN** session 标题超过一行 +- **THEN** 标题以省略号截断 + +#### Scenario: 切换项目时刷新 session 列表 + +- **WHEN** 用户切换到另一个项目 +- **THEN** session 列表清空并重新从磁盘加载该项目的 session 列表 + +#### Scenario: Session 的 agent icon 可解析时显示对应图标 + +- **WHEN** 某个 session 的 `agentId` 在 `useAcpAgentsStore.icons` 中存在对应 icon +- **THEN** 该 session 条目显示该 agent icon +- **AND** 图标作为会话级身份标识显示在条目主内容区域的前导位置 + +#### Scenario: Session 的 agent icon 不可解析时保持稳定占位 + +- **WHEN** 某个 session 的 `agentId` 在 `useAcpAgentsStore.icons` 中不存在对应 icon,或 icon 尚未加载完成 +- **THEN** 该 session 条目不抛错 +- **AND** 条目仍显示标题、时间戳、轮次数和状态指示器 +- **AND** agent icon 位置保留固定尺寸占位,不因缺失而改变条目整体对齐 diff --git a/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/tasks.md b/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/tasks.md new file mode 100644 index 00000000..bd7c2404 --- /dev/null +++ b/openspec/changes/archive/2026-05-19-add-session-list-agent-icon/tasks.md @@ -0,0 +1,9 @@ +## 1. Session 列表条目展示 + +- [x] 1.1 更新 `frontend/src/components/chat/SessionItem.vue`,接入 `useAcpAgentsStore` 并基于 `session.agentId` 解析 agent icon,给条目增加固定尺寸的前导 icon 区域,同时保留现有状态点、标题、时间和 turn 数展示与点击/菜单交互。 +- [x] 1.2 调整 `frontend/src/components/chat/SessionItem.vue` 的模板与样式类,确保 icon 命中时显示图片,未命中时保留空占位,且不会破坏标题截断、右侧菜单 hover 显示和选中态背景。 + +## 2. 验证与测试 + +- [x] 2.1 扩展 `frontend/src/__tests__/components/session-item.spec.ts`,覆盖至少三类场景:切换 session 仍会 reset 瞬时 chat 状态、存在 icon 时渲染对应 agent 图像、缺失 icon 时仍保留稳定前导区域且不影响文本信息渲染。 +- [x] 2.2 运行与本变更直接相关的 renderer 测试,至少包括 `pnpm vitest run frontend/src/__tests__/components/session-item.spec.ts`;若测试 stub 需要支持新增的图像/头像节点,在 `frontend/src/__tests__/setup.ts` 中补齐最小必要 stub,并记录验证结果。 diff --git a/openspec/specs/session-management/spec.md b/openspec/specs/session-management/spec.md index 6ab9e72a..c308cae2 100644 --- a/openspec/specs/session-management/spec.md +++ b/openspec/specs/session-management/spec.md @@ -10,10 +10,14 @@ Session 管理定义了 Chat 左侧边栏中 session 列表的展示、新建、 系统 SHALL 在左侧边栏的"Sessions"标签中显示 session 列表,按最新优先排序,最新 session 在顶部。session 列表 SHALL 从磁盘持久化存储中加载,而非使用 mock 数据。 +每个 session 条目 SHALL 显示标题、时间戳、轮次数、状态指示器,以及与该 session `agentId` 对应的 ACP agent icon,用于在进入会话前识别该 session 绑定的 agent。 + +当 `useAcpAgentsStore.icons` 中存在该 `agentId` 对应 icon 时,session 条目 SHALL 渲染该 icon;当 `agentId` 未命中 icon、icon 尚未加载完成或 registry 不可用时,session 条目 SHALL 保留固定尺寸的前导占位,并继续正常显示其余会话信息,不得报错或导致条目布局塌缩。 + #### Scenario: Session 列表已填充 - **WHEN** 项目存在已有 session -- **THEN** 列表显示每个 session 的标题、时间戳、轮次数和状态指示器 +- **THEN** 列表显示每个 session 的标题、时间戳、轮次数、状态指示器和 agent icon #### Scenario: Session 标题截断 @@ -25,6 +29,19 @@ Session 管理定义了 Chat 左侧边栏中 session 列表的展示、新建、 - **WHEN** 用户切换到另一个项目 - **THEN** session 列表清空并重新从磁盘加载该项目的 session 列表 +#### Scenario: Session 的 agent icon 可解析时显示对应图标 + +- **WHEN** 某个 session 的 `agentId` 在 `useAcpAgentsStore.icons` 中存在对应 icon +- **THEN** 该 session 条目显示该 agent icon +- **AND** 图标作为会话级身份标识显示在条目主内容区域的前导位置 + +#### Scenario: Session 的 agent icon 不可解析时保持稳定占位 + +- **WHEN** 某个 session 的 `agentId` 在 `useAcpAgentsStore.icons` 中不存在对应 icon,或 icon 尚未加载完成 +- **THEN** 该 session 条目不抛错 +- **AND** 条目仍显示标题、时间戳、轮次数和状态指示器 +- **AND** agent icon 位置保留固定尺寸占位,不因缺失而改变条目整体对齐 + ### Requirement: 新建 Session 按钮进入空白草稿态 系统 SHALL 在 Sessions 标签顶部提供"新建 Session"按钮。点击后,系统 SHALL 进入空白草稿态,而不是立即创建并持久化新的 session。 From 6ea476a0b1d88199ebffa97248a1f9e153d56bd3 Mon Sep 17 00:00:00 2001 From: Fio Date: Tue, 19 May 2026 23:36:45 +0800 Subject: [PATCH 08/19] chore: ignore .worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cc05aefe..36433bb5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ auto-imports.d.ts components.d.ts .eslintrc-auto-import.json *.tsbuildinfo +.worktrees/ From 525ef11ab96576a13332551eec51c4dd4d746471 Mon Sep 17 00:00:00 2001 From: Fio Date: Wed, 20 May 2026 10:53:07 +0800 Subject: [PATCH 09/19] fix: fix some doc and test spec --- .../chat/system-reminder/templates/apply.txt | 4 +--- .../chat/system-reminder/templates/archive.txt | 12 +++++------- .../src/__tests__/components/session-item.spec.ts | 2 +- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/electron/main/services/chat/system-reminder/templates/apply.txt b/electron/main/services/chat/system-reminder/templates/apply.txt index 9ac8293e..f9dab3fa 100644 --- a/electron/main/services/chat/system-reminder/templates/apply.txt +++ b/electron/main/services/chat/system-reminder/templates/apply.txt @@ -11,9 +11,7 @@ Your job in this stage is to turn an already-agreed proposal into code, one task -本 stage 的工作目录(cwd)是 `{{worktreePath}}`。若该字符串为空,表示当前 stage 的 cwd 是主仓库 `{{mainProjectPath}}`(旧 ApplyRunMeta 或 worktree 编排尚未启用的项目)。 - -业务代码改动产生的 commit 由你(agent)自己完成;archive 阶段不会替你 commit 业务代码。在准备进入 archive 之前,请确保 `git status` 工作区 clean(OpenSpec tasks.md 的勾选除外,那是工具进度)。 +本 stage 的工作目录(cwd)是 `{{worktreePath}}`。若该字符串为空,表示当前 stage 的 cwd 是主仓库 `{{mainProjectPath}}`。 diff --git a/electron/main/services/chat/system-reminder/templates/archive.txt b/electron/main/services/chat/system-reminder/templates/archive.txt index 3e9eddeb..17480090 100644 --- a/electron/main/services/chat/system-reminder/templates/archive.txt +++ b/electron/main/services/chat/system-reminder/templates/archive.txt @@ -13,17 +13,17 @@ Your job is to keep the archive result, the spec state, and the commit history m 本 archive run 的工作目录(cwd)是 `{{worktreePath}}`。 -如果 `{{worktreePath}}` 为空字符串(典型场景:非 git 项目,或 ApplyRunMeta 是 P3 启用前创建),跳过本段全部 git 编排,仅按 `` 完成 archive-change 文件移动 + 业务代码 commit 即可。本 archive 不需要 merge / worktree remove / branch delete。 +如果 `{{worktreePath}}` 为空字符串(典型场景:非 git 项目),跳过本段全部 git 编排,仅按 `` 完成 archive-change 文件移动 + 业务代码 commit 即可。本 archive 不需要 merge / worktree remove / branch delete。 -如果 `{{worktreePath}}` 非空,archive-change 完成 OpenSpec 文件移动后,按以下顺序完成 4 步 git 收尾: +如果 `{{worktreePath}}` 非空,执行以下命令时需要指定一个安全目录如`/private/tmp`(由于包含 worktree remove 操作,会导致命令默认cwd失效)。archive-change 完成 OpenSpec 文件移动后,按以下顺序完成 4 步 git 收尾: 1. **commit OpenSpec 归档移动**(含归档目录新增 + 主 spec 同步): `git -C {{worktreePath}} add -A && git -C {{worktreePath}} commit -m ""` - commit message 仍按 `` 与 `` 段已规定的 `type(scope): summary` 模板。`scope` 用 `openspec`,`summary` 简明描述本次归档(例 `archive {{changeId}}`);下方可选 bullet 列表概括 sync / archive 关键动作。失败时把 stderr 完整复述给用户,不自动重试。 + commit message 仍按 `` 与 `` 段已规定的 `type(scope): summary` 模板。失败时把 stderr 完整复述给用户。 2. **fast-forward merge 进 main**: `git -C {{mainProjectPath}} merge --ff-only proposal/{{changeId}}` - 失败(典型:"Not possible to fast-forward")时把 stderr 完整复述给用户,请用户决定 rebase 或普通 merge;不要自行 fall back,不要 force,不自动重试。 + 失败(典型:"Not possible to fast-forward")时把 stderr 完整复述给用户,请用户决定 rebase 或普通 merge;不要自行 fall back,不要 force。 3. **移除 worktree**: `git -C {{mainProjectPath}} worktree remove {{worktreePath}}` @@ -31,9 +31,7 @@ Your job is to keep the archive result, the spec state, and the commit history m 4. **删除 worktree 分支**: `git -C {{mainProjectPath}} branch -d proposal/{{changeId}}` - 失败(典型:分支未完全合并)时把 stderr 完整复述给用户;不要自行 `-D` 强删,不自动重试。 - -任一步失败时,archive ACP session 不终止,agent 仍可在后续轮次中根据用户的进一步指示重试或调整。但**不**自动重试。 + 失败(典型:分支未完全合并)时把 stderr 完整复述给用户;不要自行 `-D` 强删。 diff --git a/frontend/src/__tests__/components/session-item.spec.ts b/frontend/src/__tests__/components/session-item.spec.ts index 8270248b..d73aa25d 100644 --- a/frontend/src/__tests__/components/session-item.spec.ts +++ b/frontend/src/__tests__/components/session-item.spec.ts @@ -150,7 +150,7 @@ describe("SessionItem", () => { }, }); - expect(wrapper.get('[data-test="session-agent-icon-slot"]').exists()).toBe(true); + expect(wrapper.find('[data-test="session-agent-icon-slot"]').exists()).toBe(true); expect(wrapper.find('[data-test="session-agent-icon"]').exists()).toBe(false); expect(wrapper.text()).toContain("Long session title"); expect(wrapper.text()).toContain("4 turns"); From dc5e49e77a8b33018fe6b2bcd8ccd2037e0dca84 Mon Sep 17 00:00:00 2001 From: Fio Date: Wed, 20 May 2026 11:43:37 +0800 Subject: [PATCH 10/19] feat(session-management): archive conversation-first session list refinement - synced session-management spec - archived refine-session-list-conversation-first --- .../__tests__/components/session-item.spec.ts | 27 ++++++- frontend/src/components/chat/ChatSidebar.vue | 6 +- frontend/src/components/chat/SessionItem.vue | 68 ++++++++++-------- .../.openspec.yaml | 3 + .../design.md | 70 +++++++++++++++++++ .../proposal.md | 45 ++++++++++++ .../specs/session-management/spec.md | 52 ++++++++++++++ .../tasks.md | 27 +++++++ openspec/specs/session-management/spec.md | 17 ++++- 9 files changed, 281 insertions(+), 34 deletions(-) create mode 100644 openspec/changes/archive/2026-05-20-refine-session-list-conversation-first/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-20-refine-session-list-conversation-first/design.md create mode 100644 openspec/changes/archive/2026-05-20-refine-session-list-conversation-first/proposal.md create mode 100644 openspec/changes/archive/2026-05-20-refine-session-list-conversation-first/specs/session-management/spec.md create mode 100644 openspec/changes/archive/2026-05-20-refine-session-list-conversation-first/tasks.md diff --git a/frontend/src/__tests__/components/session-item.spec.ts b/frontend/src/__tests__/components/session-item.spec.ts index d73aa25d..c4173c8d 100644 --- a/frontend/src/__tests__/components/session-item.spec.ts +++ b/frontend/src/__tests__/components/session-item.spec.ts @@ -129,6 +129,7 @@ describe("SessionItem", () => { }); const icon = wrapper.get('[data-test="session-agent-icon"]'); + expect(wrapper.find('[data-test="session-media"]').exists()).toBe(true); expect(icon.attributes("src")).toBe("data:image/png;base64,agent-icon"); expect(icon.attributes("alt")).toBe("claude-code icon"); }); @@ -150,9 +151,33 @@ describe("SessionItem", () => { }, }); - expect(wrapper.find('[data-test="session-agent-icon-slot"]').exists()).toBe(true); + expect(wrapper.find('[data-test="session-media"]').exists()).toBe(true); expect(wrapper.find('[data-test="session-agent-icon"]').exists()).toBe(false); + expect(wrapper.find('[data-test="session-agent-icon-fallback"]').exists()).toBe(true); + expect(wrapper.get('[data-test="session-title"]').text()).toBe("Long session title"); + expect(wrapper.get('[data-test="session-meta"]').text()).toContain("4 turns"); expect(wrapper.text()).toContain("Long session title"); expect(wrapper.text()).toContain("4 turns"); }); + + it("keeps the running indicator inside the leading media area", () => { + const wrapper = mount(SessionItem, { + props: { + session: { + ...makeSession("session-4"), + status: "running", + }, + }, + global: { + plugins: [createPinia()], + }, + }); + + const media = wrapper.get('[data-test="session-media"]'); + const indicator = media.get('[data-test="session-running-indicator"]'); + + expect(media.classes().some((className) => className.includes("ring-success"))).toBe(false); + expect(indicator.classes()).not.toContain("animate-pulse"); + expect(wrapper.find('[data-test="session-status"]').exists()).toBe(false); + }); }); diff --git a/frontend/src/components/chat/ChatSidebar.vue b/frontend/src/components/chat/ChatSidebar.vue index c5dc1be9..bbb9f8b2 100644 --- a/frontend/src/components/chat/ChatSidebar.vue +++ b/frontend/src/components/chat/ChatSidebar.vue @@ -47,8 +47,10 @@ function handleCreateSession(): void {
-
- +
+
+ +
diff --git a/frontend/src/components/chat/SessionItem.vue b/frontend/src/components/chat/SessionItem.vue index 8da1a273..e670abdd 100644 --- a/frontend/src/components/chat/SessionItem.vue +++ b/frontend/src/components/chat/SessionItem.vue @@ -78,52 +78,60 @@ async function handleDelete(): Promise {