diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..1601bdc7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,56 @@ +name: Bug report +description: Report a reproducible issue +labels: [bug] +body: + - type: markdown + attributes: + value: | + Please provide reproducible details so the issue can be diagnosed quickly. + - type: textarea + id: summary + attributes: + label: Issue summary + description: What happened? + placeholder: Briefly describe the problem + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Describe how to reproduce it step by step + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected result + placeholder: What should have happened + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual result + placeholder: What actually happened + validations: + required: true + - type: input + id: version + attributes: + label: Version + placeholder: e.g. 0.9.0 / commit hash / build version + - type: textarea + id: env + attributes: + label: Environment + placeholder: OS, architecture, Electron version, etc. + - type: textarea + id: extra + attributes: + label: Additional context + placeholder: Logs, screenshots, recordings, or extra notes diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..b0873deb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Project README + url: https://github.com/Fioooooooo/FylloCode#readme + about: Check the README before opening a new issue diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..77d5ced7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,32 @@ +name: Feature request +description: Suggest a new feature or improvement +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Please describe the scenario and value, not just the conclusion. + - type: textarea + id: problem + attributes: + label: Problem to solve + placeholder: What pain point are you trying to address? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + placeholder: How do you want this to work? + validations: + required: true + - type: textarea + id: impact + attributes: + label: Expected impact + placeholder: What value would this change bring? + - type: textarea + id: extra + attributes: + label: Additional context + placeholder: Links, sketches, screenshots, or notes diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5079b3a7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,26 @@ +## Summary + + + +## Related Issue + + + +## What Changed + + + +## How Tested + + + +## Screenshots / Recordings + + + +## Checklist + +- [ ] `test` passed +- [ ] `lint` passed +- [ ] `typecheck` passed +- [ ] Documentation updated if needed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..a8e2d4cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Test, lint, and typecheck + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test + + - name: Run lint + run: pnpm lint + + - name: Run typecheck + run: pnpm typecheck 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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d8006c0e..47c585fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, adapted for the current stage of the project. -## [0.9.0] - 2026-05-13 +## [0.9.0] - 2026-05-20 -First structured pre-1.0 release. FylloCode has moved beyond the initial scaffold and now covers the main proposal, task, chat, workflow, and agent integration flows needed for MVP validation. +First stable `0.9.0` release. On top of the initial beta baseline, FylloCode now further completes multi-worktree orchestration, session-list interaction refinements, built-in specs workspace capabilities, and a set of product-level UX and reliability improvements needed for broader day-to-day use. ### Added @@ -17,6 +17,8 @@ First structured pre-1.0 release. FylloCode has moved beyond the initial scaffol - System reminder injection for new ACP sessions, including persistence and UI filtering - Built-in `fyllo-specs` MCP server for proposal, apply-change, archive-change, and explore workflows - Workflow editor and built-in workflow templates +- Multi-worktree foundation, including chat orchestration, archive orchestration, and proposal list worktree scanning +- Settings About panel for version visibility inside the desktop app ### Changed @@ -24,6 +26,12 @@ First structured pre-1.0 release. FylloCode has moved beyond the initial scaffol - Activity bar, welcome flow, and navigation structure refined around current product layout - ACP agent process lifecycle and shutdown behavior improved for desktop stability - Packaging and bundled resource path handling refined for app distribution +- Built-in `fyllo-specs` workspace upgraded to support the latest project workflow expectations +- Session list behavior refined toward a conversation-first interaction model +- Apply and archive prompt guardrails tightened, with `includeInstruction` handling made more explicit +- System reminder template assets moved to standalone text resources for easier maintenance +- Settings navigation width and chat status indicator styling refined +- `.worktrees` is now ignored in the repository to reduce local workspace noise ### Fixed @@ -31,8 +39,11 @@ First structured pre-1.0 release. FylloCode has moved beyond the initial scaffol - macOS ARM64 build fatal issues and Fyllo icon loading problems - Streaming pipeline consistency between chat and proposal execution flows - Test assertions around reminder persistence and apply-change fixture handling +- Chat submitted state is now preserved during `usage_update` events +- Chat state is reset correctly when creating a new session +- Documentation and test spec inconsistencies cleaned up ### Notes -- Version `0.9.0` marks the start of formal changelog tracking -- `1.0.0` is reserved for the point where MVP is fully validated and core product contracts are considered stable +- This release consolidates everything shipped across `0.9.0-beta.1` through `0.9.0-beta.3` into the first stable `0.9.0` +- `1.0.0` remains reserved for the point where MVP is fully validated and core product contracts are considered stable diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 48ba5a72..faa46563 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -4,9 +4,9 @@ 格式参考 Keep a Changelog,并结合当前项目阶段做了简化调整。 -## [0.9.0] - 2026-05-13 +## [0.9.0] - 2026-05-20 -这是第一版结构化维护的预发布版本。FylloCode 已经从初始脚手架阶段进入可用于 MVP 验证的产品阶段,核心能力已经覆盖 proposal、task、chat、workflow 和 agent integration 等主要流程。 +这是首个稳定的 `0.9.0` 正式版。在最初 beta 基线之上,FylloCode 进一步补全了多 worktree 编排、session list 交互收敛、内置 specs workspace 能力,以及一组面向日常使用的体验与稳定性改进。 ### 新增 @@ -17,6 +17,8 @@ - 新 ACP session 的 system reminder 注入能力,包括持久化与前端过滤展示 - 内置 `fyllo-specs` MCP server,支持 proposal、apply-change、archive-change 与 explore 工作流 - Workflow 编辑能力与内置 workflow 模板 +- 多 worktree 基础能力,包括 chat orchestration、archive orchestration 与 proposal 列表的 worktree 扫描 +- 设置页 About 面板,支持在应用内查看当前版本信息 ### 调整 @@ -24,6 +26,12 @@ - Activity Bar、欢迎页流程与导航结构围绕当前产品布局做了收敛 - ACP agent 进程生命周期与退出治理加强,提升桌面环境稳定性 - 打包产物与 bundled resources 的路径处理进一步统一 +- 内置 `fyllo-specs` workspace 升级,以匹配最新项目工作流要求 +- Session list 交互进一步收敛为以 conversation-first 为中心的模型 +- Apply 与 Archive prompt 的 guardrails 收紧,`includeInstruction` 的处理更加明确 +- system reminder 模板资源迁移为独立文本文件,便于维护 +- 设置页导航宽度与聊天状态指示器样式做了细化调整 +- 仓库开始忽略 `.worktrees`,减少本地工作区噪音 ### 修复 @@ -31,8 +39,11 @@ - macOS ARM64 构建致命错误与 Fyllo 图标加载异常 - Chat 与 Proposal 执行流之间的 streaming pipeline 一致性问题 - reminder 持久化与 apply-change fixture 相关测试断言问题 +- `usage_update` 事件期间提交态被错误清空的问题 +- 创建新 session 时 chat 状态未正确重置的问题 +- 部分文档与测试 spec 不一致的问题 ### 备注 -- `0.9.0` 标志着项目开始正式维护 changelog +- 该版本汇总了 `0.9.0-beta.1` 到 `0.9.0-beta.3` 期间的全部已发布能力,作为首个稳定 `0.9.0` 正式版对外发布 - `1.0.0` 将保留给 MVP 跑通且核心产品契约趋于稳定的阶段 diff --git a/README.md b/README.md index d6600d33..5195aba2 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ pnpm dev - [ ] Auto-update - [ ] i18n (English UI) - [ ] Auto build guidelines -- [ ] Git linked workspace for task apply +- [x] Git linked workspace for task apply - [ ] More ACP Agent control ## Built With diff --git a/README.zh-CN.md b/README.zh-CN.md index 1fac56da..1ca05329 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -131,7 +131,7 @@ pnpm dev - [ ] Auto-update - [ ] i18n (English UI) - [ ] Auto build guidelines -- [ ] Git linked workspace for task apply +- [x] Git linked workspace for task apply - [ ] More ACP Agent control ## 技术栈 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/__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/chat/system-reminder/archive.spec.ts b/electron/main/__tests__/services/chat/system-reminder/archive.spec.ts new file mode 100644 index 00000000..429bd26c --- /dev/null +++ b/electron/main/__tests__/services/chat/system-reminder/archive.spec.ts @@ -0,0 +1,104 @@ +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 workspace section with archive-change result handling", () => { + const reminder = renderSystemReminderTemplate( + archiveTemplate, + createContext({ + worktreePath: "/abs/.worktrees/foo", + }) + ); + + expect(reminder).toContain(""); + expect(reminder).toContain(""); + expect(reminder).toContain("Workspace Policy"); + expect(reminder).toContain("/abs/.worktrees/foo"); + expect(reminder).toContain("mcp__fyllo_specs__archive-change"); + expect(reminder).toContain("state.archive"); + expect(reminder).toContain("state.workspace"); + expect(reminder).toContain("bypasses the MCP workspace runtime"); + expect(reminder).not.toBeNull(); + expect(reminder!.indexOf("")).toBeLessThan(reminder!.indexOf("")); + expect(reminder).not.toContain("git -C /abs merge --ff-only"); + expect(reminder).not.toContain("git -C /abs worktree remove"); + expect(reminder).not.toContain("git -C /abs branch -d"); + }); + + it("renders an empty worktreePath as main workspace", () => { + const reminder = renderSystemReminderTemplate(archiveTemplate, createContext()); + + expect(reminder).toContain(""); + expect(reminder).toContain("the current workspace is the main workspace"); + }); + + 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 context", () => { + const reminder = renderSystemReminderTemplate( + archiveTemplate, + createContext({ + changeId: "foo_bar", + worktreePath: "/abs/.worktrees/foo_bar", + }) + ); + + expect(reminder).toContain("OpenSpec change `foo_bar`"); + expect(reminder).not.toContain("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/chat/system-reminder/resolve.spec.ts b/electron/main/__tests__/services/chat/system-reminder/resolve.spec.ts index 808a637b..35ed84f8 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,13 @@ 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("Workspace Policy"); + expect(reminder?.text).toContain("the current workspace is the main workspace"); + expect(reminder?.text).toContain("`/tmp/project`"); + expect(reminder?.text.indexOf("")).toBeLessThan( + reminder?.text.indexOf("") ?? 0 + ); }); it("returns null and logs a warning when a variable contains angle brackets", async () => { @@ -108,4 +115,24 @@ describe("resolveSystemReminder", () => { expect(reminder).toContain("/tmp/project"); expect(reminder).toContain("{{unknownField}}"); }); + + it("renders the chat workspace tool contract", 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("Workspace Policy"); + expect(reminder?.text).toContain("Let the tool choose and prepare the proposal workspace"); + expect(reminder?.text).toContain("state.workspace.path"); + expect(reminder?.text).toContain("mcp__fyllo_specs__create-proposal"); + expect(reminder?.text).toContain("bypasses the MCP workspace runtime"); + expect(reminder?.text).not.toContain("git worktree add"); + }); }); 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/__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/__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/domain/proposal/openspec-reader.ts b/electron/main/domain/proposal/openspec-reader.ts index 0fbb6c7d..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,79 +136,101 @@ 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 []; } } +export async function findProposalMetaById( + projectPath: string, + changeId: string +): Promise { + const proposals = await readProposalFiles(projectPath); + return proposals.find((proposal) => proposal.id === changeId) ?? null; +} + export async function resolveChangeDir( projectPath: string, changeId: string @@ -191,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/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/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..dea4a1fe 100644 --- a/electron/main/services/chat/system-reminder/templates/apply.txt +++ b/electron/main/services/chat/system-reminder/templates/apply.txt @@ -49,6 +49,19 @@ Your job in this stage is to turn an already-agreed proposal into code, one task - When all tasks are done, explicitly signal that the change is ready for the Archive stage. + +## Workspace Policy + +FylloCode has already selected and set the current workspace for this Apply stage. Do not infer a different workspace from the main project path or from prior Chat-stage paths. + +- If the rendered worktree path is empty, the current workspace is the main workspace: `{{mainProjectPath}}`. +- If the rendered worktree path is non-empty, the current workspace is the linked worktree: `{{worktreePath}}`. +- Use the current workspace as the source of truth when calling `mcp__fyllo_specs__apply-change` and when reading or editing implementation files. + - **Why**: the proposal artifacts and code changes may live in a linked worktree, and using the main project path can read stale artifacts or write implementation changes into the wrong workspace. +- Do not manually create, move, merge, remove, or delete git worktrees in the Apply stage unless the user explicitly asks you to do so. If manual workspace work is requested, state that it bypasses the MCP workspace runtime before proceeding. + - **Why**: workspace lifecycle normally belongs to the MCP tool runtime so failures can be returned as structured state instead of shell output that the agent must interpret. + + The following constraints MUST NOT be violated in the Apply stage. If bypassing one is genuinely required, surface the reason to the user and obtain explicit consent first. diff --git a/electron/main/services/chat/system-reminder/templates/archive.txt b/electron/main/services/chat/system-reminder/templates/archive.txt index cc1acbfa..a66d8b43 100644 --- a/electron/main/services/chat/system-reminder/templates/archive.txt +++ b/electron/main/services/chat/system-reminder/templates/archive.txt @@ -7,7 +7,7 @@ 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.** @@ -22,14 +22,14 @@ Your job is to keep the archive result, the spec state, and the commit history m 1. Inspect artifacts and tasks. Honestly report any incomplete items or warnings to the user. 2. If delta specs exist, prefer syncing them to the main spec. **Default recommendation is `Sync now`.** Only skip the sync when the user explicitly accepts the risk. 3. Archive only when there is no conflict. If the target archive path conflicts, stop and report — never force through. -4. After archive completes, make one commit covering the worktree diffs produced by this change. +4. Call `mcp__fyllo_specs__archive-change` with `confirm: true` and `commitMessage` so the tool archives and finalizes the workspace. ## Commit Rules - The commit subject (first line) MUST follow `type(scope): summary`. - Below the subject you may add a short bullet list summarizing the key actions of this archive / sync, e.g. `- synced specs`, `- archived change`. - `type`, `scope`, `summary`, and any optional bullets must accurately reflect what was archived and synced. No vague phrasing — the format and semantics must be precise and reviewable. -- Commit only files related to this change / archive. If the worktree contains unrelated diffs, do not sweep them in. +- The archive tool commits the workspace finalization. The commit message you pass must accurately describe this change. ## Behavioral Constraints @@ -38,18 +38,34 @@ Your job is to keep the archive result, the spec state, and the commit history m - Do not invoke the OpenSpec CLI directly or move archive files by hand. Archive actions go through the existing MCP / runtime flow. - **Why**: the runtime flow keeps state.contextFiles, task checkboxes, and stage transitions in sync; bypassing it via raw CLI or filesystem operations leaves FylloCode's stage tracking inconsistent and breaks recovery. - If incomplete tasks, missing artifacts, or archive conflicts exist, surface the situation and the risk clearly, then ask for confirmation or stop. Do not paper over it. -- After completing, summarize explicitly: archive location, whether the spec was synced, any warnings, whether the commit landed, and the commit message used. +- After completing, summarize explicitly: archive location, whether the spec was synced, any warnings, whether workspace finalization landed, and the commit message used. + +## Workspace Policy + +FylloCode has already selected and set the current workspace for this Archive stage. Do not infer a different workspace from the main project path or from prior Chat/Apply-stage paths. + +- If the rendered worktree path is empty, the current workspace is the main workspace: `{{mainProjectPath}}`. +- If the rendered worktree path is non-empty, the current workspace is the linked worktree: `{{worktreePath}}`. +- Use the current workspace as the source of truth when calling `mcp__fyllo_specs__archive-change`. + - **Why**: archive files, synced specs, and finalization state may live in a linked worktree, and using the main project path can archive the wrong change or finalize the wrong workspace. +- Treat `state.archive` as the source of truth for OpenSpec archive results, and `state.workspace` as the source of truth for workspace finalization results. + - **Why**: archive failures and workspace/git failures require different recovery paths; merging them into one status makes it easy to report or retry the wrong step. +- Do not manually run git commit, merge, worktree cleanup, or branch deletion in the Archive stage unless the user explicitly asks you to do so. If manual workspace recovery is requested, state that it bypasses the MCP workspace runtime before proceeding. + - **Why**: workspace lifecycle normally belongs to the MCP tool runtime so failures can be returned as structured state instead of shell output that the agent must interpret. + + 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 let `mcp__fyllo_specs__archive-change` perform archive and workspace finalization.** No shell reimplementation unless the user explicitly requests manual recovery after a tool failure. - **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 inspect `state.archive` and `state.workspace` separately** and report which part failed. +- **MUST NOT manually run git commit / merge / worktree cleanup commands** unless the user explicitly asks for manual recovery after a tool failure. - **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/chat/system-reminder/templates/chat.txt b/electron/main/services/chat/system-reminder/templates/chat.txt index 0cf66add..63635a5a 100644 --- a/electron/main/services/chat/system-reminder/templates/chat.txt +++ b/electron/main/services/chat/system-reminder/templates/chat.txt @@ -48,6 +48,21 @@ 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. + +## Workspace Policy + +After the user explicitly agrees to create a proposal, use `mcp__fyllo_specs__create-proposal` as the only proposal workspace entry point. + +- Let the tool choose and prepare the proposal workspace by default. +- If the user explicitly asks to work in the main workspace, pass that intent through the tool call instead of creating a workspace manually. +- After the tool returns, use `state.workspace.path` as the source of truth for reading and editing proposal artifacts. + - **Why**: the selected workspace may differ from the main project path, and deriving artifact paths from cwd or `targetPath` can write files into the wrong workspace. +- Do not manually create, move, merge, remove, or delete git worktrees in the Chat stage unless the user explicitly asks you to do so. If manual workspace work is requested, state that it bypasses the MCP workspace runtime before proceeding. + - **Why**: workspace lifecycle normally belongs to the MCP tool runtime so failures can be returned as structured state instead of shell output that the agent must interpret. + +Multiple changes may be proposed in the same chat session. If the user later refers to "the previous change" and multiple `state.workspace.path` values exist in the conversation, ask which change they mean before continuing. + + 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/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/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/frontend/src/__tests__/components/chat-container.spec.ts b/frontend/src/__tests__/components/chat-container.spec.ts index 57146fc7..eaa1b206 100644 --- a/frontend/src/__tests__/components/chat-container.spec.ts +++ b/frontend/src/__tests__/components/chat-container.spec.ts @@ -116,7 +116,7 @@ describe("ChatContainer", () => { "The stream disconnected unexpectedly" ); - const children = wrapper.find(".max-w-240")?.element.children; + const children = wrapper.get(".max-w-3xl").element.children; expect(children?.[0]?.getAttribute("data-test")).toBe("message-list"); expect(children?.[1]?.querySelector('[data-test="stream-error"]')).not.toBeNull(); }); diff --git a/frontend/src/__tests__/components/session-item.spec.ts b/frontend/src/__tests__/components/session-item.spec.ts index 0325caf7..d812d011 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,71 @@ 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(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"); + }); + + 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.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()).toContain("animate-pulse"); + expect(wrapper.find('[data-test="session-status"]').exists()).toBe(false); + }); }); diff --git a/frontend/src/components/chat/ChatContainer.vue b/frontend/src/components/chat/ChatContainer.vue index 66f91606..3d01b024 100644 --- a/frontend/src/components/chat/ChatContainer.vue +++ b/frontend/src/components/chat/ChatContainer.vue @@ -14,7 +14,7 @@ const { activeSession } = storeToRefs(useSessionStore()); diff --git a/frontend/src/components/chat/ChatPromptPanel.vue b/frontend/src/components/chat/ChatPromptPanel.vue index e712731f..89f99c70 100644 --- a/frontend/src/components/chat/ChatPromptPanel.vue +++ b/frontend/src/components/chat/ChatPromptPanel.vue @@ -52,48 +52,47 @@ const { + 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 582b774d..5e48c135 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(() => [ { @@ -75,37 +78,60 @@ async function handleDelete(): Promise {