Просмотр исходного кода

fix(workspace-changes): count argument-derived hunks, isolate per-turn state, and harden git handling

Review round fixes: a write that creates a file persists no hunks and
str_replace_editor persists none at all, so mutation calls now contribute
hunks from their arguments when the result carries none; each turn owns a
state object so a record still running for an interrupted turn cannot be
reset by the next turn; git add accepts exit code 1 from --ignore-errors;
rev-parse failures other than a missing repository fail loud; only a missing
index or store is tolerated; the temporary-root exclusion uses the repository
root; a row without a verified Host path previews in the Sidebar. The
recorded scenario now creates an ignored file.
creatixchu 2 недель назад
Родитель
Сommit
8ea92ce9b5
24 измененных файлов с 385 добавлено и 163 удалено
  1. 2 2
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml
  2. 2 2
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md
  3. 2 2
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md
  4. 10 6
      apps/web/tests/changed-files-turn.e2e.ts
  5. 4 2
      packages/client/ui-deliverables/src/client/ChangedFiles.tsx
  6. 5 1
      packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
  7. 2 2
      packages/fs/README.i18n.yaml
  8. 1 1
      packages/fs/README.md
  9. 1 1
      packages/fs/README.zh.md
  10. 2 2
      packages/fs/workspace-changes/README.i18n.yaml
  11. 6 3
      packages/fs/workspace-changes/README.md
  12. 6 3
      packages/fs/workspace-changes/README.zh.md
  13. 22 10
      packages/fs/workspace-changes/src/git.ts
  14. 2 1
      packages/fs/workspace-changes/src/index.ts
  15. 55 1
      packages/fs/workspace-changes/src/numstat.ts
  16. 77 42
      packages/fs/workspace-changes/src/recorder.ts
  17. 35 1
      packages/fs/workspace-changes/tests/git.spec.ts
  18. 28 1
      packages/fs/workspace-changes/tests/numstat.spec.ts
  19. 51 25
      packages/fs/workspace-changes/tests/plugin.spec.ts
  20. 3 4
      snapshots/web/changed-files-turn/session.v3.jsonl
  21. 67 48
      snapshots/web/changed-files-turn/ui.expected.md
  22. 1 0
      snapshots/web/changed-files-turn/workspace.expected/.gitignore
  23. 1 0
      snapshots/web/changed-files-turn/workspace.expected/app.local
  24. 0 3
      snapshots/web/changed-files-turn/workspace.expected/src/util.ts

+ 2 - 2
.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md
-2026-09-11-turn-changed-files-card.md: a14e062d7801de089a7644ae335d246a206fa655
-2026-09-11-turn-changed-files-card.zh.md: 0f69397b58543eeee79e5f5d57eb08aa31618494
+2026-09-11-turn-changed-files-card.md: e15273cf1685381295fd3d2e2ac40d5380ffe85b
+2026-09-11-turn-changed-files-card.zh.md: da79843a48256d40472fcdec4b03f77cd0c2acfe

+ 2 - 2
.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md

@@ -16,7 +16,7 @@ The recorder snapshots the working tree with git at turn start and turn end: `ad
 
 Git is the default executable on `PATH`; no environment plugin is consulted. Only a working directory inside a git repository is recorded; outside any repository, or without git, the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into.
 
-Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, so no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation.
+Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, or from the call's arguments when a result persists none, as a `write` that creates a file and every `str_replace_editor` mutation do; no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation.
 
 The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. With a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and each row opens its file in the default application; without one, rows preview in the right Sidebar. The event carries both snapshot tree ids for a later full-file comparison.
 
@@ -44,4 +44,4 @@ Every turn with tool results costs two snapshots and one diff on the Host, and w
 
 The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card only when their workspace is a git repository, which one dedicated scenario seeds. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries.
 
-Focused tests cover repository and shadow tiers with real git, coverage classification, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenarios replay the recorder end to end.
+Focused tests cover repositories with real git, directories outside any repository, coverage classification with the metadata the tools really persist, an interrupted turn overlapped by the next, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenario replays the recorder end to end, including a created file the repository ignores.

+ 2 - 2
.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md

@@ -16,7 +16,7 @@ Host 侧的 [workspace-changes](../../../../packages/fs/workspace-changes/README
 
 git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。只有位于 git 仓库内的工作目录会被记录;不在任何仓库内或没有 git 时,插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。
 
-快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,因此不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。
+快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,结果没有持久化 hunk 时取调用参数,新建文件的 `write` 和 `str_replace_editor` 的每种修改都是这种情况;不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。
 
 列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——每一行用默认应用打开该文件;没有桌面时行改为在右侧 Sidebar 中预览。事件携带两个快照 tree id,供将来做整文件对比。
 
@@ -44,4 +44,4 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。
 
 只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只有在工作区是 git 仓库时才新增该事件与卡片,一个专门的场景负责种入这样的仓库。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。
 
-聚焦测试用真实 git 覆盖仓库与影子两档、覆盖范围分类、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器。
+聚焦测试用真实 git 覆盖仓库、不在任何仓库内的目录、按工具真实持久化的元数据做覆盖范围分类、被下一轮压上的中断轮次、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器,其中包括一个被仓库忽略的新建文件。

+ 10 - 6
apps/web/tests/changed-files-turn.e2e.ts

@@ -18,13 +18,14 @@ import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
 const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url))
 const FIXTURE = join(DIR, 'session.v3.jsonl')
 const MODE = webSnapshotMode()
-const PROMPT = '不用先查看目录,直接做三件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,然后用 bash 在 notes.txt 末尾追加一行 done。'
+const PROMPT = '不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。'
 
-/** Seed a committed repository so the turn's own edits are the only difference between its snapshots. */
+/** Seed a committed repository so the turn's own edits are the only difference between its snapshots; `*.local` stays ignored. */
 async function seedRepository(cwd: string): Promise<void> {
   await mkdir(cwd, { recursive: true })
   await writeFile(join(cwd, 'intro.md'), '# 示例项目\n\n一个用于演示的仓库。\n')
   await writeFile(join(cwd, 'notes.txt'), 'start\n')
+  await writeFile(join(cwd, '.gitignore'), '*.local\n')
   const git = (...args: string[]) => execFileSync('git', ['-c', 'user.email=seed@example.com', '-c', 'user.name=seed', '-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'ignore' })
   git('init', '-q', '-b', 'main')
   git('add', '-A')
@@ -93,16 +94,19 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
     const summary = summaries.at(-1)
     expect(summary, 'the turn must record its changed files').toBeDefined()
     if (summary === undefined) throw new Error('no changed-files summary')
-    expect(summary.data.files.map(file => file.display)).toEqual(['intro.md', 'notes.txt', 'src/util.ts'])
-    expect(summary.data.total).toBe(3)
+    // app.local is ignored by the repository, so its counts come from the write call rather than git.
+    expect(summary.data.files.map(file => file.display)).toEqual(['app.local', 'intro.md', 'notes.txt', 'src/util.ts'])
+    expect(summary.data.total).toBe(4)
     for (const file of summary.data.files) expect(file.added).toBeGreaterThan(0)
-    expect(summary.data.files[1]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 })
+    expect(summary.data.files[0]).toMatchObject({ path: 'app.local', added: 1, deleted: 0 })
+    expect(summary.data.files[2]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 })
     expect(await readFile(join(cwd, 'notes.txt'), 'utf8')).toBe('start\ndone\n')
 
     const card = page.locator('[data-changed-files]')
     await card.waitFor({ state: 'visible' })
-    expect(await card.getByText('已编辑 3 个文件', { exact: true }).count()).toBe(1)
+    expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1)
     expect(await card.getByRole('listitem').count()).toBe(3)
+    expect(await card.getByRole('button', { name: '展开全部 4 个改动文件' }).count()).toBe(1)
     // Without a Host desktop the header is a label and rows preview in the Sidebar.
     expect(await card.getByRole('button', { name: '打开改动文件所在的文件夹' }).count()).toBe(0)
     expect(await card.getByRole('button', { name: '在侧边栏打开 notes.txt' }).count()).toBe(1)

+ 4 - 2
packages/client/ui-deliverables/src/client/ChangedFiles.tsx

@@ -85,11 +85,13 @@ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, op
       {rows.map((file, index) => {
         const phase = phases[changedFileUrl(sessionId, changes.seq, index)]
         const status = rowStatus(phase)
+        // A file without a verified Host path falls back to the Sidebar preview the status names.
+        const opensNatively = native && phase !== 'nativeUnavailable'
         return <li key={file.display}>
           <button type="button" className={css.row} title={resolveWorkspacePath(cwd, file.path)}
-            aria-label={t(native ? 'changes.openFile' : 'presented.previewButton', { name: file.display })}
+            aria-label={t(opensNatively ? 'changes.openFile' : 'presented.previewButton', { name: file.display })}
             disabled={phase === 'opening'}
-            onClick={() => { if (native) onOpen(index); else openFile(file.path) }}>
+            onClick={() => { if (opensNatively) onOpen(index); else openFile(file.path) }}>
             <span className={css.path}>{file.display}</span>
             <span className={css.counts} role={status === undefined ? undefined : 'status'} data-error={status?.error ? true : undefined}>
               {status !== undefined ? t(status.key)

+ 5 - 1
packages/client/ui-deliverables/tests/deliverables.client.spec.tsx

@@ -529,12 +529,16 @@ describe('ChangedFiles card', () => {
       '/api/changes.open?sessionId=child-session&seq=5&index=2': 'nativeUnavailable',
       '/api/changes.open?sessionId=child-session&seq=5': 'opened',
     })
-    const { view } = renderCard(controller)
+    const { view, openFile, props } = renderCard(controller)
     expect(view.getByText(en['presented.opening'])).toBeTruthy()
     expect((view.getByRole('button', { name: 'Open config/design-token in default app' }) as HTMLButtonElement).disabled).toBe(true)
     expect(view.getByText(en['presented.error']).closest('[data-error]')).toBeTruthy()
     expect(view.getByText(en['presented.nativeUnavailable'])).toBeTruthy()
     expect(view.getByText(en['changes.folderOpened'])).toBeTruthy()
+    // A row without a verified Host path previews in the Sidebar instead of retrying the native open.
+    fireEvent.click(view.getByRole('button', { name: 'Open config/launch-plan.yaml in sidebar' }))
+    expect(openFile).toHaveBeenCalledWith('config/launch-plan.yaml')
+    expect(props.openChanged).not.toHaveBeenCalled()
     view.unmount()
     controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'opening', '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opened' })
     const { view: pending } = renderCard(controller)

+ 2 - 2
packages/fs/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/README.md
-README.md: d6e79d69187ac853bf26cbe037f277b848f21d3d
-README.zh.md: 9c6f2717e3255c90e0512081f192ceb16457f171
+README.md: 079dc476d5f0dbd9faa918bf7411f1be98b095c0
+README.zh.md: 99fe136f21050ae5073ee12b0e648fc1661f93c1

+ 1 - 1
packages/fs/README.md

@@ -22,7 +22,7 @@ The `fs/` group gives agents durable, policy-governed access to files: the `ctx.
 <a id="packages"></a>
 ## Packages
 
-Eight packages play the filesystem roles; the subsystem reference owns the exhaustive contracts and the error taxonomy.
+Nine packages play the filesystem roles; the subsystem reference owns the exhaustive contracts and the error taxonomy.
 
 | Package | Role | ctx key |
 |---|---|---|

+ 1 - 1
packages/fs/README.zh.md

@@ -22,7 +22,7 @@ kind: "package-group"
 <a id="packages"></a>
 ## 包
 
-八个包承担文件系统角色;子系统参考文档完整收录各项约定与错误分类体系。
+九个包承担文件系统角色;子系统参考文档完整收录各项约定与错误分类体系。
 
 | 包 | 职责 | ctx 键 |
 |---|---|---|

+ 2 - 2
packages/fs/workspace-changes/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/workspace-changes/README.md
-README.md: a6eff33162095afea9064af69f8685d946c3eab3
-README.zh.md: f8ac055711e0c888c3d5b79d2ce3c28d4fc90f32
+README.md: eadde42f30d3909bdce75641f0c73c7f4ec1fa2c
+README.zh.md: a5a49ea5214e01325e4fcfcd01713048c1fada90

+ 6 - 3
packages/fs/workspace-changes/README.md

@@ -41,9 +41,9 @@ The shipped Web bundle mounts this plugin. Mount it in any composition with the
 | `outputMaxBytes` | `8388608` | Bytes of git output retained per command; a larger diff listing abandons the record |
 | `maxFiles` | `500` | Maximum files carried by one event; `total` still reports the complete count |
 
-Every Session whose working directory lies inside a git repository and that has no subagent origin is recorded; subagent Sessions and working directories outside any repository are not. Snapshots are written through a private index into a private object store under the Harness home, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. A store that outgrows `objectStoreMaxBytes` is discarded before the next snapshot and starts empty. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — the plugin records nothing and logs that once.
+Every Session whose working directory lies inside a git repository and that has no subagent origin is recorded; subagent Sessions and working directories outside any repository are not. Snapshots are written through a private index into a private object store under the Harness home, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. One store serves every Session of a repository; a Session checks its size when it first locates the repository and discards a store over `objectStoreMaxBytes`, which starts empty. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — the plugin records nothing and logs that once.
 
-Files the file tools changed but the snapshots do not cover are added from the hunks those tools persist with their results: files matching an ignore pattern and files outside the repository. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the working directory. Line counts for these files sum over the recorded hunks, so repeated edits to one file in a turn can count a line more than once. Changes made through shell commands outside the snapshot coverage are not recorded.
+Files the file tools changed but the snapshots do not cover are added from the hunks those tools persist with their results, or from the call's own arguments when the result persists none — a `write` that creates a file and every `str_replace_editor` mutation: files matching an ignore pattern and files outside the repository. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the repository. Line counts for these files sum over the recorded hunks, so repeated edits to one file in a turn can count a line more than once. Changes made through shell commands outside the snapshot coverage are not recorded.
 
 Each file carries a durable `path` — relative to the working directory inside it, absolute elsewhere — and a `display` path used for ordering and labels: the relative path, a `../` path for repository files above the working directory, a `~` path under the home directory, otherwise the absolute path. Files sort by `display` in code-unit order, which lists parent and absolute paths before the working directory's own files. The event also carries the two snapshot tree ids.
 
@@ -55,7 +55,7 @@ Each file carries a durable `path` — relative to the working directory inside
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository and its object store, then `add --all` into a temporary index seeded from the repository's index and `write-tree` produce the tree id. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the private store and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/result` events collect the file-tool hunks. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record, which covers aborted, failed, and steered turns; an empty list after an earlier record supersedes it. The repository's index is only read.
+One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository and its object store once per Session, then `add --all --ignore-errors` into a temporary index seeded from the repository's index and `write-tree` produce the tree id; an unreadable file is skipped and reported through git's exit code 1, which the snapshot accepts. Each turn holds its own state object, so a record still running for an interrupted turn keeps that turn's files when the next turn starts. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the private store and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/call` events keep each mutation call's argument-derived hunks and `tool/result` events collect the persisted ones, preferring the latter. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record attempt, which covers aborted, failed, and steered turns without repeating a failed attempt; an empty list after an earlier record supersedes it. The repository's index is only read.
 
 Git runs through the `subprocess` capability with a scrubbed environment, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work.
 
@@ -86,6 +86,9 @@ Nothing here enters a model request, so provider cache reuse is unaffected.
 <a id="known-limitations-and-deferred-work"></a>
 
 - Snapshot trees survive only until their private store exceeds `objectStoreMaxBytes` and is discarded; the recorded counts survive, the trees needed for a later full-file comparison do not.
+- The store is shared by every Session of one repository. A Session that discards it while another Session is snapshotting makes that other turn warn and record nothing; the next turn starts afresh.
+- Two git features still write into the repository's own git directory during a snapshot: `core.splitIndex` writes `sharedindex.*` files, and git-lfs runs its clean filter on changed files and stores their objects under `.git/lfs`.
+- git 2.13 or later is required for `rev-parse --absolute-git-dir`; an unsupported repository format or another git failure abandons the turn with a warning rather than being treated as a plain directory.
 - Edits the user makes during a turn are attributed to that turn.
 - A working directory outside any git repository has no card; a shadow repository under the Harness home is deferred until its exclude rules can replace a missing `.gitignore` reliably.
 - Hunk-based counts for files outside snapshot coverage are sums over edits, not a first-to-last diff, and cover file tools only.

+ 6 - 3
packages/fs/workspace-changes/README.zh.md

@@ -41,9 +41,9 @@ kind: "package-reference"
 | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 |
 | `maxFiles` | `500` | 单个事件携带的最大文件数;`total` 仍报告完整数量 |
 
-工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入 Harness home 下的私有对象库,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。对象库超过 `objectStoreMaxBytes` 时在下次快照前被丢弃,从空开始。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。
+工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入 Harness home 下的私有对象库,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。一个仓库的所有 Session 共用一个对象库;Session 在首次定位仓库时检查其大小,超过 `objectStoreMaxBytes` 的对象库被丢弃,从空开始。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。
 
-文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于工作目录内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。
+文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入,结果没有持久化 hunk 时则取调用自身的参数,也就是新建文件的 `write` 和 `str_replace_editor` 的每一种修改:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。
 
 每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。事件还携带两次快照的 tree id。
 
@@ -55,7 +55,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库及其对象库,然后以仓库 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向私有对象库、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/result` 事件收集文件工具的 hunk。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次;早先记录之后的空列表会取代它。仓库的 index 只读取。
+每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库及其对象库,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向私有对象库、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/call` 事件保留每个修改调用由参数推出的 hunk,`tool/result` 事件收集持久化的 hunk,后者优先。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。
 
 git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作。
 
@@ -86,6 +86,9 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_
 <a id="known-limitations-and-deferred-work"></a>
 
 - 快照树只保留到其私有对象库超过 `objectStoreMaxBytes` 被丢弃为止;记录的计数保留,而将来做整文件对比所需的树不会保留。
+- 对象库由一个仓库的所有 Session 共用。一个 Session 在另一个 Session 快照途中丢弃它,会让那一轮告警并且不记录;下一轮重新开始。
+- 有两个 git 功能在快照期间仍会写入仓库自己的 git 目录:`core.splitIndex` 会写 `sharedindex.*` 文件,git-lfs 会对改动文件运行 clean 过滤器并把对象存到 `.git/lfs` 下。
+- 需要 git 2.13 或更高版本以支持 `rev-parse --absolute-git-dir`;不支持的仓库格式或其他 git 失败会带着警告放弃本轮,而不是被当成普通目录。
 - 用户在轮次进行中自己做的编辑会被算到该轮。
 - 不在任何 git 仓库内的工作目录没有卡片;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。
 - 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。

+ 22 - 10
packages/fs/workspace-changes/src/git.ts

@@ -2,7 +2,7 @@
 import { createHash } from 'node:crypto'
 import { copyFile, mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { join, resolve } from 'node:path'
 import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
 import { parseNumstat, type NumstatEntry } from './numstat.ts'
 
@@ -109,10 +109,18 @@ export interface ObjectStoreOptions {
   maxBytes: number
 }
 
+/** Whether a filesystem error names a missing path. */
+function isMissing(error: unknown): boolean {
+  return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'ENOENT'
+}
+
 /** Total size of the regular files under a directory; zero when it does not exist. */
 async function directoryBytes(directory: string): Promise<number> {
   let total = 0
-  const entries = await readdir(directory, { recursive: true, withFileTypes: true }).catch(() => [])
+  const entries = await readdir(directory, { recursive: true, withFileTypes: true }).catch((error: unknown) => {
+    if (isMissing(error)) return []
+    throw error
+  })
   for (const entry of entries) {
     if (entry.isFile()) total += (await stat(join(entry.parentPath, entry.name))).size
   }
@@ -123,7 +131,8 @@ async function directoryBytes(directory: string): Promise<number> {
  * Locate the repository enclosing a working directory and prepare its snapshot
  * object store. The repository's own object store is attached read-only as an
  * alternate, so snapshots read committed content from it and write nothing
- * into it; a store over its byte bound is discarded and starts empty.
+ * into it; a store over its byte bound is discarded and starts empty. A
+ * directory outside any repository yields null; any other git failure throws.
  * @param git - command runner.
  * @param cwd - absolute Session working directory.
  * @param store - snapshot object store placement and bound.
@@ -133,11 +142,10 @@ async function directoryBytes(directory: string): Promise<number> {
 export async function locateGitWorkspace(
   git: GitRunner, cwd: string, store: ObjectStoreOptions, signal: AbortSignal,
 ): Promise<GitWorkspace | null> {
-  const found = await git.run([
-    'rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-path', 'objects',
-  ], { cwd, signal })
-  if (found.exitCode !== 0) return null
-  const [root, gitDir, repositoryObjects] = found.stdout.split('\n') as [string, string, string]
+  const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-path', 'objects'], { cwd, signal })
+  if (found.exitCode === 128 && /not a git repository/i.test(found.stderr)) return null
+  const lines = ok(found, 'git rev-parse').stdout.split('\n').map(line => resolve(cwd, line))
+  const [root, gitDir, repositoryObjects] = lines as [string, string, string]
   const objectsDir = join(store.home, createHash('sha256').update(gitDir).digest('hex').slice(0, 16))
   if (await directoryBytes(objectsDir) > store.maxBytes) await rm(objectsDir, { recursive: true, force: true })
   await mkdir(objectsDir, { recursive: true })
@@ -160,9 +168,13 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign
   try {
     const index = join(scratch, 'index')
     // A repository without an index yet (fresh `git init`) starts from scratch.
-    await copyFile(join(workspace.gitDir, 'index'), index).then(() => undefined, () => undefined)
+    await copyFile(join(workspace.gitDir, 'index'), index).catch((error: unknown) => {
+      if (!isMissing(error)) throw error
+    })
     const env = { ...workspace.env, GIT_INDEX_FILE: index }
-    ok(await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal }), `git add in ${workspace.root}`)
+    // `--ignore-errors` skips unreadable files and reports them through exit code 1; the index is still complete.
+    const added = await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal })
+    if (added.exitCode !== 1) ok(added, `git add in ${workspace.root}`)
     return ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim()
   } finally {
     await rm(scratch, { recursive: true, force: true })

+ 2 - 1
packages/fs/workspace-changes/src/index.ts

@@ -129,7 +129,8 @@ export function apply(ctx: Context, config: Config): void {
       if (cwd !== undefined) recorderFor(session, cwd).start(event.data.turn)
       return
     }
-    if (event.type === 'tool/result') recorders.get(session)?.observe(event)
+    if (event.type === 'tool/call') recorders.get(session)?.observeCall(event)
+    else if (event.type === 'tool/result') recorders.get(session)?.observe(event)
     else if (event.type === 'turn/end') recorders.get(session)?.end(event.data.turn)
   })
   ctx.on('session/disposed', (session) => {

+ 55 - 1
packages/fs/workspace-changes/src/numstat.ts

@@ -64,7 +64,7 @@ export function hunkLineCounts(diffs: readonly FileDiff[]): { added: number; del
 /**
  * Narrow a tool result's opaque `meta` to the file-tool hunk list.
  * @param meta - persisted result metadata.
- * @returns the hunks, or undefined when the metadata carries none.
+ * @returns the hunks, or undefined when the metadata carries none; `write` persists an empty list for a created file.
  */
 export function fileDiffsOf(meta: unknown): FileDiff[] | undefined {
   if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
@@ -79,3 +79,57 @@ export function fileDiffsOf(meta: unknown): FileDiff[] | undefined {
   }
   return out
 }
+
+/** Non-blank string argument, or undefined. */
+function text(value: unknown): string | undefined {
+  return typeof value === 'string' && value.trim() !== '' ? value : undefined
+}
+
+/**
+ * Hunks a first-party mutation call implies from its own arguments, for
+ * results that persist no hunks: `write` creates, and every
+ * `str_replace_editor` mutation. Malformed or non-mutating calls yield null.
+ * @param name - wire tool name.
+ * @param argumentsRaw - model-produced JSON arguments.
+ * @returns the implied hunks, or null.
+ */
+export function argumentHunks(name: string, argumentsRaw: string): FileDiff[] | null {
+  let args: unknown
+  try {
+    args = JSON.parse(argumentsRaw) as unknown
+  } catch {
+    return null
+  }
+  if (typeof args !== 'object' || args === null || Array.isArray(args)) return null
+  const record = args as Record<string, unknown>
+  switch (name) {
+    case 'write': {
+      const path = text(record.file_path)
+      return path !== undefined && typeof record.content === 'string' ? [{ path, oldText: null, newText: record.content }] : null
+    }
+    case 'edit': {
+      const path = text(record.file_path)
+      return path !== undefined && typeof record.old_string === 'string' && record.old_string !== '' && typeof record.new_string === 'string'
+        ? [{ path, oldText: record.old_string, newText: record.new_string }]
+        : null
+    }
+    case 'str_replace_editor': {
+      const path = text(record.path)
+      if (path === undefined) return null
+      switch (record.command) {
+        case 'create':
+          return typeof record.file_text === 'string' ? [{ path, oldText: null, newText: record.file_text }] : null
+        case 'str_replace':
+          return typeof record.old_str === 'string' && record.old_str !== '' && (record.new_str === undefined || typeof record.new_str === 'string')
+            ? [{ path, oldText: record.old_str, newText: record.new_str ?? '' }]
+            : null
+        case 'insert':
+          return typeof record.new_str === 'string' ? [{ path, oldText: null, newText: record.new_str }] : null
+        default:
+          return null
+      }
+    }
+    default:
+      return null
+  }
+}

+ 77 - 42
packages/fs/workspace-changes/src/recorder.ts

@@ -4,7 +4,7 @@ import { relative } from 'node:path'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 import type { FileDiff } from '@deepseek-ai/dsh-tools'
 import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace, type ObjectStoreOptions } from './git.ts'
-import { fileDiffsOf, hunkLineCounts } from './numstat.ts'
+import { argumentHunks, fileDiffsOf, hunkLineCounts } from './numstat.ts'
 import { absolutePathOf, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, toPosix } from './paths.ts'
 import type { WorkspaceChangedFile } from './types.ts'
 
@@ -26,6 +26,25 @@ export interface RecorderEnvironment {
 
 interface Baseline { git: GitRunner; workspace: GitWorkspace; tree: string; cwd: string }
 
+/** Everything one turn accumulates; a new turn gets a new object so queued work for an older turn keeps its own. */
+interface TurnState {
+  readonly turn: number
+  baseline: Baseline | null
+  /** Hunks derived from each mutation call's arguments, or null for a call that changes no file. */
+  readonly calls: Map<string, FileDiff[] | null>
+  /** File-tool hunks by their model-facing path; canonicalized when the record is built. */
+  readonly hunks: Map<string, FileDiff[]>
+  lastToolResultSeq: number
+  /** Log length when the latest record attempt started; `end()` skips a turn already attempted after its last tool result. */
+  attemptedAfterSeq: number
+  /** Sequence of the latest appended event, or -1. */
+  recordedAfterSeq: number
+}
+
+function freshState(turn: number): TurnState {
+  return { turn, baseline: null, calls: new Map(), hunks: new Map(), lastToolResultSeq: -1, attemptedAfterSeq: -1, recordedAfterSeq: -1 }
+}
+
 /** Symlink-resolved path when the target exists, otherwise the lexical path. */
 async function canonicalPath(path: string): Promise<string> {
   try {
@@ -44,12 +63,10 @@ async function canonicalPath(path: string): Promise<string> {
  */
 export class TurnRecorder {
   private chain: Promise<void> = Promise.resolve()
-  private baseline: Baseline | null = null
-  private turn = 0
-  /** File-tool hunks by their model-facing path; canonicalized when the record is built. */
-  private hunks = new Map<string, FileDiff[]>()
-  private lastToolResultSeq = -1
-  private recordedAfterSeq = -1
+  /** The open turn; before the first `turn/start` it is an empty placeholder no event can match. */
+  private state = freshState(0)
+  /** The located repository, reused across turns once found; null keeps retrying each turn. */
+  private workspace: { git: GitRunner; cwd: string; workspace: GitWorkspace } | null = null
   private readonly lifetime = new AbortController()
 
   constructor(
@@ -59,40 +76,43 @@ export class TurnRecorder {
   ) {}
 
   /**
-   * Open a turn: reset per-turn state and queue the baseline snapshot.
+   * Open a turn with fresh per-turn state and queue its baseline snapshot.
    * @param turn - the turn number from `turn/start`.
    */
   start(turn: number): void {
-    this.turn = turn
-    this.baseline = null
-    this.hunks = new Map()
-    this.lastToolResultSeq = -1
-    this.recordedAfterSeq = -1
+    const state = freshState(turn)
+    this.state = state
     void this.enqueue(async (signal) => {
-      const git = await this.env.git
-      if (git === null) return
-      // git reports symlink-resolved paths; every comparison uses that form.
-      const cwd = await realpath(this.cwd)
-      const workspace = await locateGitWorkspace(git, cwd, this.env.objects, signal)
-      if (workspace === null) return
-      const tree = await snapshotTree(git, workspace, signal)
-      this.baseline = { git, workspace, tree, cwd }
+      const located = await this.locate(signal)
+      if (located === null) return
+      const tree = await snapshotTree(located.git, located.workspace, signal)
+      state.baseline = { ...located, tree }
     })
   }
 
   /**
-   * Remember a settled tool result and any file-tool hunks it carries.
+   * Remember a mutation call's arguments so a result without persisted hunks can still count its lines.
+   * @param event - the appended `tool/call` event.
+   */
+  observeCall(event: SessionEvent<'tool/call'>): void {
+    const state = this.state
+    if (event.data.turn !== state.turn) return
+    state.calls.set(String(event.data.callId), argumentHunks(event.data.name, event.data.arguments))
+  }
+
+  /**
+   * Remember a settled tool result and the hunks it carries, falling back to the call's arguments.
    * @param event - the appended `tool/result` event.
    */
   observe(event: SessionEvent<'tool/result'>): void {
-    if (event.data.turn !== this.turn) return
-    this.lastToolResultSeq = event.seq
+    const state = this.state
+    if (event.data.turn !== state.turn) return
+    state.lastToolResultSeq = event.seq
     if (event.data.message.content[0].isError === true) return
-    const diffs = fileDiffsOf(event.data.meta)
-    if (diffs === undefined) return
+    const diffs = fileDiffsOf(event.data.meta) ?? state.calls.get(String(event.data.message.source.callId)) ?? []
     for (const diff of diffs) {
-      const list = this.hunks.get(diff.path)
-      if (list === undefined) this.hunks.set(diff.path, [diff])
+      const list = state.hunks.get(diff.path)
+      if (list === undefined) state.hunks.set(diff.path, [diff])
       else list.push(diff)
     }
   }
@@ -103,17 +123,19 @@ export class TurnRecorder {
    * @returns after the event is appended or the attempt failed.
    */
   stopping(turn: number): Promise<void> {
-    if (turn !== this.turn) return Promise.resolve()
-    return this.enqueue(signal => this.record(signal))
+    const state = this.state
+    if (turn !== state.turn) return Promise.resolve()
+    return this.enqueue(signal => this.record(state, signal))
   }
 
   /**
-   * Record after `turn/end` when the in-turn record is missing or stale.
+   * Record after `turn/end` unless a record was already attempted after the turn's last tool result.
    * @param turn - the turn number from `turn/end`.
    */
   end(turn: number): void {
-    if (turn !== this.turn || this.recordedAfterSeq >= this.lastToolResultSeq) return
-    void this.enqueue(signal => this.record(signal))
+    const state = this.state
+    if (turn !== state.turn || state.attemptedAfterSeq >= state.lastToolResultSeq) return
+    void this.enqueue(signal => this.record(state, signal))
   }
 
   /** Resolves once every queued snapshot and record has settled. */
@@ -144,9 +166,22 @@ export class TurnRecorder {
     if (!this.lifetime.signal.aborted) this.env.warn(`workspace-changes: ${String(error)}`)
   }
 
-  private async record(signal: AbortSignal): Promise<void> {
-    if (this.baseline === null || this.lastToolResultSeq < 0) return
-    const { git, workspace, tree: before, cwd } = this.baseline
+  /** The repository for this Session, located once; git reports symlink-resolved paths, so every comparison uses that form. */
+  private async locate(signal: AbortSignal): Promise<{ git: GitRunner; cwd: string; workspace: GitWorkspace } | null> {
+    if (this.workspace !== null) return this.workspace
+    const git = await this.env.git
+    if (git === null) return null
+    const cwd = await realpath(this.cwd)
+    const workspace = await locateGitWorkspace(git, cwd, this.env.objects, signal)
+    if (workspace === null) return null
+    this.workspace = { git, cwd, workspace }
+    return this.workspace
+  }
+
+  private async record(state: TurnState, signal: AbortSignal): Promise<void> {
+    if (state.baseline === null || state.lastToolResultSeq < 0) return
+    state.attemptedAfterSeq = state.lastToolResultSeq
+    const { git, workspace, tree: before, cwd } = state.baseline
     const after = await snapshotTree(git, workspace, signal)
     const files = new Map<string, WorkspaceChangedFile>()
     for (const entry of await diffTrees(git, workspace, before, after, signal)) {
@@ -154,7 +189,7 @@ export class TurnRecorder {
       files.set(absolute, this.changedFile(absolute, cwd, workspace, entry))
     }
     const hunks = new Map<string, FileDiff[]>()
-    for (const [path, list] of this.hunks) {
+    for (const [path, list] of state.hunks) {
       const absolute = await canonicalPath(absolutePathOf(cwd, path))
       hunks.set(absolute, [...hunks.get(absolute) ?? [], ...list])
     }
@@ -162,8 +197,8 @@ export class TurnRecorder {
     const outside: string[] = []
     for (const absolute of hunks.keys()) {
       if (files.has(absolute)) continue
-      // The working directory is the user's workspace even when it lives under a temporary root.
-      if (!isInside(cwd, absolute) && isTemporaryPath(absolute, this.env.temporaryRoots)) continue
+      // The repository is the user's workspace even when it lives under a temporary root.
+      if (!isInside(workspace.root, absolute) && isTemporaryPath(absolute, this.env.temporaryRoots)) continue
       if (isInside(workspace.root, absolute)) inside.push(absolute)
       else outside.push(absolute)
     }
@@ -176,14 +211,14 @@ export class TurnRecorder {
     }
     const sorted = [...files.values()].sort(compareDisplay)
     // An empty list after an earlier in-turn record supersedes that record.
-    if (sorted.length === 0 && this.recordedAfterSeq < 0) return
+    if (sorted.length === 0 && state.recordedAfterSeq < 0) return
     const event = this.session.append('workspace/changes', {
-      turn: this.turn,
+      turn: state.turn,
       files: sorted.slice(0, this.env.maxFiles),
       total: sorted.length,
       snapshot: { before, after },
     })
-    this.recordedAfterSeq = event.seq
+    state.recordedAfterSeq = event.seq
   }
 
   private changedFile(

+ 35 - 1
packages/fs/workspace-changes/tests/git.spec.ts

@@ -1,5 +1,5 @@
 /** Git command bounds, snapshot recovery, and diff failure reporting. */
-import { realpath, writeFile } from 'node:fs/promises'
+import { chmod, readFile, realpath, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
@@ -87,6 +87,40 @@ describe('snapshots and diffs', () => {
 
 })
 
+describe('repository edge cases', () => {
+  it.skipIf(process.platform === 'win32')('snapshots past an unreadable file and refuses an unreadable index', async () => {
+    const cwd = await scratchDir('dsh-git-unreadable-', cleanups)
+    git(cwd, 'init', '-q', '-b', 'main')
+    await writeFile(join(cwd, 'ok.txt'), 'ok\n')
+    await writeFile(join(cwd, 'locked.txt'), 'locked\n')
+    await chmod(join(cwd, 'locked.txt'), 0o000)
+    cleanups.push(() => chmod(join(cwd, 'locked.txt'), 0o644))
+    const { git: runnerGit } = await runner()
+    const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }
+    const workspace = (await locateGitWorkspace(runnerGit, cwd, store, signal))!
+    expect(await snapshotTree(runnerGit, workspace, signal)).toMatch(/^[0-9a-f]{40,64}$/)
+    git(cwd, 'add', 'ok.txt')
+    await chmod(join(workspace.gitDir, 'index'), 0o000)
+    cleanups.push(() => chmod(join(workspace.gitDir, 'index'), 0o644))
+    await expect(snapshotTree(runnerGit, workspace, signal)).rejects.toThrow(/EACCES/)
+  })
+
+  it('reports a repository git cannot read instead of treating it as absent', async () => {
+    const cwd = await scratchDir('dsh-git-unsupported-', cleanups)
+    git(cwd, 'init', '-q')
+    const config = join(cwd, '.git', 'config')
+    const original = await readFile(config, 'utf8')
+    await writeFile(config, original.replace(/repositoryformatversion = \d+/, 'repositoryformatversion = 99'))
+    const { git: runnerGit } = await runner()
+    const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }
+    await expect(locateGitWorkspace(runnerGit, cwd, store, signal)).rejects.toThrow('git rev-parse failed')
+    await writeFile(config, original)
+    const blocked = join(cwd, 'store-file')
+    await writeFile(blocked, 'not a directory')
+    await expect(locateGitWorkspace(runnerGit, cwd, { home: blocked, maxBytes: 1 }, signal)).rejects.toThrow(/ENOTDIR/)
+  })
+})
+
 describe('TurnRecorder', () => {
   it('stays silent when disposed while git work is pending, and warns on failures otherwise', async () => {
     const cwd = await scratchDir('dsh-recorder-', cleanups)

+ 28 - 1
packages/fs/workspace-changes/tests/numstat.spec.ts

@@ -1,6 +1,6 @@
 /** Numstat parsing and hunk line counting. */
 import { describe, expect, it } from 'vitest'
-import { fileDiffsOf, hunkLineCounts, parseNumstat } from '../src/numstat.ts'
+import { argumentHunks, fileDiffsOf, hunkLineCounts, parseNumstat } from '../src/numstat.ts'
 
 describe('parseNumstat', () => {
   it('reads plain, binary, and rename records', () => {
@@ -44,3 +44,30 @@ describe('fileDiffsOf', () => {
     expect(fileDiffsOf({ diffs: [{ path: 'a', oldText: 'x', newText: 3 }] })).toBeUndefined()
   })
 })
+
+describe('argumentHunks', () => {
+  const call = (name: string, args: unknown) => argumentHunks(name, JSON.stringify(args))
+
+  it('derives hunks from write, edit, and editor mutations', () => {
+    expect(call('write', { file_path: 'a.txt', content: 'x\n' })).toEqual([{ path: 'a.txt', oldText: null, newText: 'x\n' }])
+    expect(call('edit', { file_path: 'a.txt', old_string: 'x', new_string: 'y' })).toEqual([{ path: 'a.txt', oldText: 'x', newText: 'y' }])
+    expect(call('str_replace_editor', { command: 'create', path: 'b.txt', file_text: 'b' })).toEqual([{ path: 'b.txt', oldText: null, newText: 'b' }])
+    expect(call('str_replace_editor', { command: 'str_replace', path: 'b.txt', old_str: 'b' })).toEqual([{ path: 'b.txt', oldText: 'b', newText: '' }])
+    expect(call('str_replace_editor', { command: 'str_replace', path: 'b.txt', old_str: 'b', new_str: 'c' })).toEqual([{ path: 'b.txt', oldText: 'b', newText: 'c' }])
+    expect(call('str_replace_editor', { command: 'insert', path: 'b.txt', insert_line: 1, new_str: 'i' })).toEqual([{ path: 'b.txt', oldText: null, newText: 'i' }])
+  })
+
+  it('yields null for reads, unknown tools, and malformed arguments', () => {
+    expect(argumentHunks('write', '{')).toBeNull()
+    expect(argumentHunks('write', '[]')).toBeNull()
+    expect(call('read', { file_path: 'a.txt' })).toBeNull()
+    expect(call('write', { file_path: ' ', content: 'x' })).toBeNull()
+    expect(call('write', { file_path: 'a.txt' })).toBeNull()
+    expect(call('edit', { file_path: 'a.txt', old_string: '', new_string: 'y' })).toBeNull()
+    expect(call('str_replace_editor', { command: 'view', path: 'b.txt' })).toBeNull()
+    expect(call('str_replace_editor', { command: 'create', path: '' })).toBeNull()
+    expect(call('str_replace_editor', { command: 'create', path: 'b.txt' })).toBeNull()
+    expect(call('str_replace_editor', { command: 'str_replace', path: 'b.txt', old_str: 'b', new_str: 1 })).toBeNull()
+    expect(call('str_replace_editor', { command: 'insert', path: 'b.txt', insert_line: 1 })).toBeNull()
+  })
+})

+ 51 - 25
packages/fs/workspace-changes/tests/plugin.spec.ts

@@ -63,14 +63,18 @@ describe('workspace-changes in a repository', () => {
     await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255))
     toolCall(session, 1, 'bash', { command: 'printf > files' })
     await writeFile(join(cwd, '.env'), 'A=1\nB=2\n')
-    toolCall(session, 1, 'write', { file_path: '.env' }, { meta: { diffs: [{ path: '.env', oldText: null, newText: 'A=1\n' }] } })
-    toolCall(session, 1, 'edit', { file_path: '.env' }, { meta: { diffs: [{ path: '.env', oldText: 'A=1\n', newText: 'A=1\nB=2\n' }] } })
-    toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt') }, {
-      meta: { diffs: [{ path: join(tmpdir(), 'scratch.txt'), oldText: null, newText: 'scratch\n' }] },
+    // A created file persists an empty hunk list; its arguments supply the content.
+    toolCall(session, 1, 'write', { file_path: '.env', content: 'A=1\n' }, { meta: { diffs: [] } })
+    toolCall(session, 1, 'edit', { file_path: '.env', old_string: 'A=1', new_string: 'A=1\nB=2' }, {
+      meta: { diffs: [{ path: '.env', oldText: 'A=1\n', newText: 'A=1\nB=2\n' }] },
+    })
+    toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, { meta: { diffs: [] } })
+    toolCall(session, 1, 'write', { file_path: 'failed.txt', content: 'x' }, { isError: true, meta: { diffs: [] } })
+    // The editor persists no hunks at all; its create arguments count, and the file may already be gone.
+    toolCall(session, 1, 'str_replace_editor', { command: 'create', path: '.env.gone', file_text: 'x' })
+    toolCall(session, 1, 'edit', { file_path: 'same.txt', old_string: 'same', new_string: 'same' }, {
+      meta: { diffs: [{ path: 'same.txt', oldText: 'same', newText: 'same' }] },
     })
-    toolCall(session, 1, 'write', { file_path: 'ignored-error' }, { isError: true, meta: { diffs: [{ path: 'failed.txt', oldText: null, newText: 'x' }] } })
-    toolCall(session, 1, 'write', { file_path: '.env.gone' }, { meta: { diffs: [{ path: '.env.gone', oldText: null, newText: 'x' }] } })
-    toolCall(session, 1, 'write', { file_path: 'same.txt' }, { meta: { diffs: [{ path: 'same.txt', oldText: 'same', newText: 'same' }] } })
     toolCall(session, 2, 'write', { file_path: 'other-turn' }, { meta: { diffs: [{ path: 'other.txt', oldText: null, newText: 'x' }] } })
     endTurn(session, 1)
     await settle(ctx, session)
@@ -99,28 +103,34 @@ describe('workspace-changes in a repository', () => {
     expect(objects.some(entry => /^[0-9a-f]{2}\/[0-9a-f]{38,}$/.test(entry))).toBe(true)
   })
 
-  it('discards a snapshot object store that outgrew its bound before the next baseline', async () => {
+  it('discards a snapshot object store that outgrew its bound when the next Session locates the repository', async () => {
     const cwd = await repository()
     const { ctx, dshHome } = await boot({ objectStoreMaxBytes: 1 })
-    const session = ctx.sessions.create(SessionId('bounded'), { meta: { cwd } })
-    startTurn(session, 1)
-    await settle(ctx, session)
+    const first = ctx.sessions.create(SessionId('bounded-1'), { meta: { cwd } })
+    startTurn(first, 1)
+    await settle(ctx, first)
     const [store] = await readdir(join(dshHome, 'workspace-changes'))
     const marker = join(dshHome, 'workspace-changes', store!, 'marker')
     await writeFile(marker, 'old store')
     await writeFile(join(cwd, 'n.txt'), 'n\n')
-    toolCall(session, 1, 'bash', { command: 'x' })
-    endTurn(session, 1)
-    await settle(ctx, session)
-    expect(changes(session)).toHaveLength(1)
-    startTurn(session, 2)
-    await settle(ctx, session)
+    toolCall(first, 1, 'bash', { command: 'x' })
+    endTurn(first, 1)
+    await settle(ctx, first)
+    expect(changes(first)).toHaveLength(1)
+    // The located repository is reused within a Session, so the bound is checked once per Session.
+    startTurn(first, 2)
+    await settle(ctx, first)
+    expect((await stat(marker)).isFile()).toBe(true)
+    endTurn(first, 2)
+    const second = ctx.sessions.create(SessionId('bounded-2'), { meta: { cwd } })
+    startTurn(second, 1)
+    await settle(ctx, second)
     await expect(stat(marker)).rejects.toThrow()
     await writeFile(join(cwd, 'm.txt'), 'm\n')
-    toolCall(session, 2, 'bash', { command: 'x' })
-    endTurn(session, 2)
-    await settle(ctx, session)
-    expect(changes(session).at(-1)!.files.map(file => file.display)).toEqual(['m.txt'])
+    toolCall(second, 1, 'bash', { command: 'x' })
+    endTurn(second, 1)
+    await settle(ctx, second)
+    expect(changes(second).at(-1)!.files.map(file => file.display)).toEqual(['m.txt'])
   })
 
   it('places files above the working directory and outside the repository by their display rule', async () => {
@@ -135,9 +145,7 @@ describe('workspace-changes in a repository', () => {
     await settle(ctx, session)
     await writeFile(join(root, 'a.txt'), 'changed\n')
     await writeFile(join(cwd, 'inner.txt'), 'inner\n')
-    toolCall(session, 1, 'write', { file_path: join(outside, 'note.txt') }, {
-      meta: { diffs: [{ path: join(outside, 'note.txt'), oldText: null, newText: 'one\ntwo\nthree\n' }] },
-    })
+    toolCall(session, 1, 'str_replace_editor', { command: 'insert', path: join(outside, 'note.txt'), insert_line: 0, new_str: 'one\ntwo\nthree\n' })
     endTurn(session, 1, 'blocked')
     await settle(ctx, session)
     const [recorded] = changes(session)
@@ -189,6 +197,24 @@ describe('workspace-changes in a repository', () => {
     expect(changes(session).filter(data => data.turn === 3)).toEqual([])
   })
 
+  it('keeps an interrupted turn’s record when the next turn starts before it settles', async () => {
+    const cwd = await repository()
+    const { ctx } = await boot()
+    const session = ctx.sessions.create(SessionId('interleaved'), { meta: { cwd } })
+    startTurn(session, 1)
+    await settle(ctx, session)
+    await writeFile(join(cwd, 'one.txt'), '1\n')
+    toolCall(session, 1, 'bash', { command: 'x' })
+    endTurn(session, 1, 'blocked')
+    startTurn(session, 2)
+    await settle(ctx, session)
+    await writeFile(join(cwd, 'two.txt'), '2\n')
+    toolCall(session, 2, 'bash', { command: 'x' })
+    endTurn(session, 2)
+    await settle(ctx, session)
+    expect(changes(session).map(data => [data.turn, data.files.map(file => file.display)])).toEqual([[1, ['one.txt']], [2, ['two.txt']]])
+  })
+
   it('caps the file list while reporting the complete count', async () => {
     const cwd = await repository()
     const { ctx } = await boot({ maxFiles: 2 })
@@ -242,7 +268,7 @@ describe('workspace-changes without a repository', () => {
     startTurn(session, 1)
     await settle(ctx, session)
     await writeFile(join(cwd, 'existing.txt'), 'after\nmore\n')
-    toolCall(session, 1, 'write', { file_path: 'existing.txt' }, {
+    toolCall(session, 1, 'write', { file_path: 'existing.txt', content: 'after\nmore\n' }, {
       meta: { diffs: [{ path: 'existing.txt', oldText: 'before', newText: 'after\nmore' }] },
     })
     endTurn(session, 1)

Разница между файлами не показана из-за своего большого размера
+ 3 - 4
snapshots/web/changed-files-turn/session.v3.jsonl


+ 67 - 48
snapshots/web/changed-files-turn/ui.expected.md

@@ -2,103 +2,117 @@
   - img
   - img
   - text: 系统提示词
-- text: 不用先查看目录,直接做三件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,然后用 bash 在 notes.txt 末尾追加一行 done。 {{clock}}
+- text: 不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。 {{clock}}
 - button "复制":
   - img
-- button "6 次工具调用 · 1 条消息" [expanded]:
-  - text: 6 次工具调用 · 1 条消息
+- button "7 次工具调用" [expanded]:
+  - text: 7 次工具调用
   - img
 - button "上下文注入 @deepseek-ai/dsh-system-prompt":
   - img
   - img
   - text: 上下文注入 @deepseek-ai/dsh-system-prompt
-- button "思考 The user wants three things without checking the directory first. Let me just do them. But the edit tool requires reading the file first per fs-observation-policy. The user said \"不用先查看目录\" (no need to check directory first) — that's about listing directory, not about reading file. The edit tool requires exact match of old_string. Hmm, I need to know the content of intro.md to edit. I could use edit with old_string \"示例项目\" — but the tool says read first unless just created/edited. Let me try reading intro.md quickly, that's fine.":
+- button "思考 The user wants four things done directly. But I need to read intro.md before editing (fs-observation-policy requires reading existing file first). The user says don't look at the directory first, but reading intro.md is needed for the edit tool. Let me just do it.":
   - img
   - img
-  - text: 思考 The user wants three things without checking the directory first. Let me just do them. But the edit tool requires reading the file first per fs-observation-policy. The user said "不用先查看目录" (no need to check directory first) — that's about listing directory, not about reading file. The edit tool requires exact match of old_string. Hmm, I need to know the content of intro.md to edit. I could use edit with old_string "示例项目" — but the tool says read first unless just created/edited. Let me try reading intro.md quickly, that's fine.
+  - text: 思考 The user wants four things done directly. But I need to read intro.md before editing (fs-observation-policy requires reading existing file first). The user says don't look at the directory first, but reading intro.md is needed for the edit tool. Let me just do it.
 - button "读取 intro.md":
   - img
   - img
   - text: 读取
   - button "intro.md"
-- button "写入 src/util.ts +6 -0":
+- button "Bash List files and show notes.txt":
   - img
   - img
-  - text: 写入
-  - button "src/util.ts"
-  - text: +6 -0
-- button "Bash Append done line to notes.txt":
+  - text: Bash List files and show notes.txt
+- button "思考 Now do all four actions. Edit intro.md, write src/util.ts, write app.local, bash append.":
   - img
   - img
-  - text: Bash Append done line to notes.txt
+  - text: 思考 Now do all four actions. Edit intro.md, write src/util.ts, write app.local, bash append.
 - button "编辑 intro.md +3 -3":
   - img
   - img
   - text: 编辑
   - button "intro.md"
   - text: +3 -3
-- button "思考 Verify the three results quickly.":
+- button "写入 src/util.ts +3 -0":
   - img
   - img
-  - text: 思考 Verify the three results quickly.
-- button "Bash Verify all three changes":
+  - text: 写入
+  - button "src/util.ts"
+  - text: +3 -0
+- button "写入 app.local +1 -0":
   - img
   - img
-  - text: Bash Verify all three changes
-- button "思考 Present the files as deliverables.":
+  - text: 写入
+  - button "app.local"
+  - text: +1 -0
+- button "Bash Append done to notes.txt":
   - img
   - img
-  - text: 思考 Present the files as deliverables.
-- paragraph: 三件事都已完成并验证:
+  - text: Bash Append done to notes.txt
+- 'button "思考 All four done. Present the files? The user asked to create files. I should call present for the deliverables. Files: intro.md, src/util.ts, app.local, notes.txt. All are outputs. Let me present them."':
+  - img
+  - img
+  - text: "思考 All four done. Present the files? The user asked to create files. I should call present for the deliverables. Files: intro.md, src/util.ts, app.local, notes.txt. All are outputs. Let me present them."
+- button "交付文件 已交付 intro.md, src/util.ts, app.local, notes.txt":
+  - img
+  - text: 交付文件 已交付 intro.md, src/util.ts, app.local, notes.txt
+- paragraph: 四件事全部完成:
 - list:
   - listitem:
     - strong:
-      - code: intro.md
-    - text: — 标题已从
+      - code:
+        - button "在侧边栏打开 intro.md": intro.md
+    - text: — 标题
     - code: "# 示例项目"
-    - text: 改为
+    - text: 已改为
     - code: "# 项目说明"
-    - text: (edit 工具需先读取文件以精确匹配,因此只读了这一个文件,未列目录)。
+    - text: (仅改了标题行,正文未动)。
   - listitem:
     - strong:
-      - code: src/util.ts
-    - text: (新建)— 导出带类型的
+      - code:
+        - button "在侧边栏打开 src/util.ts": src/util.ts
+    - text: — 新建,导出
     - code: "add(a: number, b: number): number"
-    - text: 函数。
+    - text: 。
   - listitem:
     - strong:
-      - code: notes.txt
-    - text: — 用 bash 以
-    - code: ">>"
-    - text: 在末尾追加了
-    - code: done
-    - text: (当前文件内容为
+      - code:
+        - button "在侧边栏打开 app.local": app.local
+    - text: — 新建,内容为一行
+    - code: mode=demo
+    - text: 。
+  - listitem:
+    - strong:
+      - code:
+        - button "在侧边栏打开 notes.txt": notes.txt
+    - text: — 用
+    - code: bash
+    - text: 追加成功,现有内容为
     - code: start
     - text: /
     - code: done
-    - text: 两行)。
-- button "交付文件 已交付 intro.md, src/util.ts, notes.txt":
-  - img
-  - text: 交付文件 已交付 intro.md, src/util.ts, notes.txt
+    - text: 两行。
 - paragraph:
-  - text: 改动文件:
+  - text: 说明:
   - code:
     - button "在侧边栏打开 intro.md": intro.md
-  - text: 、
-  - code:
-    - button "在侧边栏打开 src/util.ts": src/util.ts
-  - text: 、
+  - text: 的改动我按文件策略要求先读了一次原文件(只读该文件,未浏览目录),
   - code:
     - button "在侧边栏打开 notes.txt": notes.txt
-  - text: 。
-- text: 已编辑 3 个文件 +8 -1
+  - text: 的追加则完全走 bash。
+- text: 已编辑 4 个文件 +6 -1
 - list:
+  - listitem:
+    - button "在侧边栏打开 app.local": app.local +1 -0
   - listitem:
     - button "在侧边栏打开 intro.md": intro.md +1 -1
   - listitem:
     - button "在侧边栏打开 notes.txt": notes.txt +1 -0
-  - listitem:
-    - button "在侧边栏打开 src/util.ts": src/util.ts +6 -0
+- button "展开全部 4 个改动文件":
+  - text: 全部 4 个文件
+  - img
 - text: 此主机没有可用的桌面,无法打开文件或文件夹
 - button "在侧边栏预览 intro.md"
 - text: intro.md 标题已改为「项目说明」
@@ -106,12 +120,17 @@
 - button "intro.md 的更多文件操作" [disabled]:
   - img
 - button "在侧边栏预览 src/util.ts"
-- text: util.ts 新建,导出 add 函数
+- text: util.ts 新增 add 两数相加函数
 - button "在侧边栏打开 src/util.ts": 打开
 - button "src/util.ts 的更多文件操作" [disabled]:
   - img
+- button "在侧边栏预览 app.local"
+- text: app.local 新增配置文件,内容 mode=demo
+- button "在侧边栏打开 app.local": 打开
+- button "app.local 的更多文件操作" [disabled]:
+  - img
 - button "在侧边栏预览 notes.txt"
-- text: notes.txt 末尾已追加 done
+- text: notes.txt 末尾追加了 done 一行
 - button "在侧边栏打开 notes.txt": 打开
 - button "notes.txt 的更多文件操作" [disabled]:
   - img
@@ -123,9 +142,9 @@
   - img
 - button "在新对话中分支":
   - img
-- button "用量 45.9K tok":
+- button "用量 36.2K tok":
   - img
-  - text: 用量 45.9K tok
+  - text: 用量 36.2K tok
 - button "用时 {{duration}}":
   - img
   - text: 用时 {{duration}}

+ 1 - 0
snapshots/web/changed-files-turn/workspace.expected/.gitignore

@@ -0,0 +1 @@
+*.local

+ 1 - 0
snapshots/web/changed-files-turn/workspace.expected/app.local

@@ -0,0 +1 @@
+mode=demo

+ 0 - 3
snapshots/web/changed-files-turn/workspace.expected/src/util.ts

@@ -1,6 +1,3 @@
-/**
- * 两数相加
- */
 export function add(a: number, b: number): number {
   return a + b;
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов