Explorar el Código

feat(workspace-changes): summarize file-tool edits outside a git repository

A working directory outside any repository, or a Host without git, no
longer loses the card: the recorder skips the snapshots and builds the
summary from the hunks the file tools persisted, with the working
directory as the workspace, so shell edits are the only gap. The
sidebar e2e scopes its chip clicks to the prose mention now that the
card offers the same open, and the present-svg and permission-policy
scenarios gain the card their write calls earn.
creatixchu hace 1 semana
padre
commit
c48f4203d9

+ 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: 9e5ca9689e2af3a650f596bdd8da980396ddb4d4
-2026-09-11-turn-changed-files-card.zh.md: 4af94d62eb434ee244a80f63290c256d0960c826
+2026-09-11-turn-changed-files-card.md: b8c6ad42cb7baab26cdc9d8bcda2b4ec672afe12
+2026-09-11-turn-changed-files-card.zh.md: 4687b74a18c00b2e5b30cef42ceefb54c912c2a0

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

@@ -14,7 +14,7 @@ The Host [workspace-changes](../../../../packages/deliverables/workspace-changes
 
 The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. Both write into a temporary object directory owned by the Session while the repository's object store is attached as a read-only alternate, so the user's repository gains no objects; keeping every captured byte outside the workspace follows the workspace change journal POC (#2973) and costs nothing measurable because the stat cache lives in the index. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, objects, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it.
 
-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.
+Git is the default executable on `PATH`; no environment plugin is consulted. Outside any repository, or without git, no snapshot is taken and the summary lists the file-tool edits alone, with the working directory as the workspace, so the card still appears but misses shell edits. 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, 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.
 
@@ -32,7 +32,7 @@ The log deliberately carries nothing but the turn number. Summaries, snapshot tr
 
 **A git tag or `stash create` per turn** leaves refs in the user's repository or omits untracked files; a tree written through a private index does neither.
 
-**A pure-JavaScript git or a bundled binary for hosts without git** adds megabytes and a platform matrix for users who mostly run without the card; the card is simply absent until git exists.
+**A pure-JavaScript git or a bundled binary for hosts without git** adds megabytes and a platform matrix for users who mostly run without the card; until git exists the card lists file-tool edits only.
 
 **Recording the pre-edit file content at first touch** would make hunk counts exact and enable full-file diffs for uncovered files, but requires the file tools to hand their pre-read content to the recorder; the persisted hunks already carry the before and after text of each edit for that purpose.
 
@@ -42,7 +42,7 @@ The log deliberately carries nothing but the turn number. Summaries, snapshot tr
 
 **An environment-provider seam for locating git** was raised by the team but not settled; the plugin uses `PATH` and keeps its lookup in one place.
 
-**A shadow repository for working directories outside any repository** — a git directory under the Harness home with the work tree pointing at the working directory — would give those users the card without adding a `.git`, but a shadow repository has no `.gitignore`, and a configured exclude list cannot reliably keep build outputs, caches, and dependency trees out of every project layout. It is deferred until that exclude policy is settled; the recorder already treats the repository as an input, so adding the tier changes only where the snapshot goes.
+**A shadow repository for working directories outside any repository** — a git directory under the Harness home with the work tree pointing at the working directory — would add shell edits to those users' card without adding a `.git`, but a shadow repository has no `.gitignore`, and a configured exclude list cannot reliably keep build outputs, caches, and dependency trees out of every project layout. It is deferred until that exclude policy is settled; the recorder already treats the repository as an input, so adding the tier changes only where the snapshot goes.
 
 ## Consequences
 

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

@@ -14,7 +14,7 @@ Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-chan
 
 记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两者都写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 挂接,因此用户仓库不会多出任何对象;把捕获的每个字节都放在工作区外学自工作区变更日志 POC(#2973),而且因为 stat 缓存在 index 里,它对耗时没有可测量的影响。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、对象、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。
 
-git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。只有位于 git 仓库内的工作目录会被记录;不在任何仓库内或没有 git 时,插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。
+git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。不在任何仓库内或没有 git 时,不做快照,摘要只列文件工具的编辑,并以工作目录作为工作区,因此卡片仍会出现,但缺少 shell 的改动。嵌套仓库和 submodule 是 gitlink,不会深入。
 
 快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,结果没有持久化 hunk 时取调用参数,新建文件的 `write` 和 `str_replace_editor` 的每种修改都是这种情况;不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。
 
@@ -32,7 +32,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。
 
 **每轮打一个 git tag 或 `stash create`**会在用户仓库里留下 ref,或者遗漏未跟踪文件;通过私有 index 写出的树两者都不会发生。
 
-**为没有 git 的主机引入纯 JavaScript 的 git 或捆绑二进制**会为大多数不使用卡片的用户增加数 MB 体积和一套平台矩阵;在 git 出现之前卡片直接不显示
+**为没有 git 的主机引入纯 JavaScript 的 git 或捆绑二进制**会为大多数不使用卡片的用户增加数 MB 体积和一套平台矩阵;在 git 出现之前卡片只列文件工具的编辑
 
 **在首次触碰时记录编辑前的文件内容**能让 hunk 计数精确并为未覆盖文件提供整文件 diff,但要求文件工具把预先读取的内容交给记录器;持久化的 hunk 已经为此带上了每次编辑的前后文本。
 
@@ -42,7 +42,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。
 
 **用于定位 git 的环境提供者接缝**由团队提出但尚未定案;插件使用 `PATH` 并把查找收敛在一处。
 
-**为不在任何仓库内的工作目录建影子仓库**,即把 git 目录放在 Harness home 下、工作树指向工作目录,可以让这些用户在不多出 `.git` 的情况下看到卡片,但影子仓库没有 `.gitignore`,一份配置的排除列表无法在所有项目布局里可靠地挡住构建产物、缓存和依赖目录。它暂缓到排除策略定下来为止;记录器已经把仓库当作输入,加这一档只改变快照写到哪里。
+**为不在任何仓库内的工作目录建影子仓库**,即把 git 目录放在 Harness home 下、工作树指向工作目录,可以让这些用户在不多出 `.git` 的情况下也看到 shell 改动,但影子仓库没有 `.gitignore`,一份配置的排除列表无法在所有项目布局里可靠地挡住构建产物、缓存和依赖目录。它暂缓到排除策略定下来为止;记录器已经把仓库当作输入,加这一档只改变快照写到哪里。
 
 ## Consequences
 

+ 2 - 2
apps/web/tests/present-svg.e2e.ts

@@ -106,8 +106,8 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
     await card.waitFor({ state: 'visible' })
     expect(await card.count()).toBe(1)
     expect(await page.getByText('产物', { exact: true }).count()).toBe(0)
-    // The scaffold workspace is not a git repository, so no changed-files card accompanies the delivery.
-    expect(await page.locator('[data-changed-files]').count()).toBe(0)
+    // The scaffold workspace is not a git repository, so the changed-files card lists the written SVG from the write call alone.
+    expect(await page.locator('[data-changed-files]').count()).toBe(1)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   })

+ 20 - 10
apps/web/tests/sidebar-right.e2e.ts

@@ -186,6 +186,15 @@ async function setPanelWidth(page: Page, target: number): Promise<void> {
 }
 
 /** Tab titles inside one container, in strip order. */
+/**
+ * The closing prose's inline-code mention of the seeded file. The changed-files
+ * card below the prose offers the same open under the same accessible name, so
+ * the first match in document order is the prose's.
+ */
+function proseChip(root: Page): Locator {
+  return root.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).first()
+}
+
 async function tabTitles(root: Locator): Promise<string[]> {
   return await root.locator('[data-dockkit-tab-title]').allInnerTexts()
 }
@@ -734,7 +743,7 @@ describe('web e2e: shipped right Sidebar', () => {
       // The product's own entry point: the closing prose's file mention. It
       // reaches the Sidebar through openFile → ctx.sidebarRight.openResource, and the
       // text type claims the address.
-      const chip = page.getByRole('button', { name: `Open ${SAMPLE_NAME} in sidebar` })
+      const chip = proseChip(page)
       await chip.click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
 
@@ -756,11 +765,12 @@ describe('web e2e: shipped right Sidebar', () => {
       await shot(page, '06-produced-chip-to-preview')
 
       // The directory scenario's V1 behaviour, asserted in the shipped product:
-      // there is no folder affordance at all. `openFile('.')` would name a
-      // directory, which a text preview correctly refuses, and the native opener
-      // it used to reach is gone — so the row offers nothing rather than a
-      // button that always fails.
-      expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
+      // the prose mention offers no folder affordance. `openFile('.')` would
+      // name a directory, which a text preview correctly refuses. The only
+      // folder action on the page is the changed-files card's header, and only
+      // when the Host has a desktop.
+      const folders = page.getByRole('button', { name: /folder/i })
+      expect(await folders.count()).toBe(await page.locator('[data-changed-files]').getByRole('button', { name: /folder/i }).count())
 
       // Split, then dock-drag: the kit's gestures drive the store's actions.
       await panes.first().locator('[data-dockkit-split-button]').click()
@@ -819,7 +829,7 @@ describe('web e2e: shipped right Sidebar', () => {
         const column = fx.locator('[data-rightbar-col]')
         await ensureExpanded(fx, column)
         await width(column)
-        await fx.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+        await proseChip(fx).click()
         await column.locator('[data-textpreview-state="text"]').waitFor({ timeout: 15_000 })
         const wrap = column.locator('[data-textpreview-tool="wrap"]')
         expect(await wrap.getAttribute('aria-pressed')).toBe('true')
@@ -888,7 +898,7 @@ describe('web e2e: shipped right Sidebar', () => {
       //    leave it standing, since a pane emptied by a move is dropped.
       const first = panes.first()
       const strip = first.locator('[data-dockkit-strip]')
-      await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+      await proseChip(page).click()
       await expect.poll(async () => await tabTitles(first)).toEqual(['Files', SAMPLE_NAME])
       const order = await tabTitles(first)
       // The insertion index is measured against chip midpoints, not strip width.
@@ -962,7 +972,7 @@ describe('web e2e: shipped right Sidebar', () => {
       const column = await resetSidebar(page)
       const panes = column.locator('[data-dockkit-pane]')
       expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1)
-      await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+      await proseChip(page).click()
       await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME])
       await panes.first().locator('[data-dockkit-split-button]').click()
       await expect.poll(async () => await panes.count()).toBe(2)
@@ -998,7 +1008,7 @@ describe('web e2e: shipped right Sidebar', () => {
       // sample file, close the guide (an ordinary close with two tabs), then
       // close the file: the column collapses in the same gesture, and the
       // settle rule reseeds the current default, so reopening shows Start.
-      await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+      await proseChip(page).click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
       await column.locator('[data-dockkit-tab]').first().hover()
       await column.locator('[data-dockkit-tab-close]').first().click()

+ 2 - 2
docs/subsystems/deliverables.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 docs/subsystems/deliverables.md
-deliverables.md: 22b51978fddc44bc40083bcbd501aec19a380937
-deliverables.zh.md: 67774eb4ba8c91e030ef20e228557276ff73d48b
+deliverables.md: 6542d402a21e064f5b4d2f6223b0a22534fe7fd3
+deliverables.zh.md: f2b4490a66ee0f068af33ae00839b1ebb23e9ea8

+ 1 - 1
docs/subsystems/deliverables.md

@@ -2,7 +2,7 @@
 
 English | [中文](deliverables.zh.md)
 
-What a turn hands to the user, owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, recorded in a log-only Session event, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end, announced by a log-only event and served by a Host service while the Session lives. Only clients read them; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md).
+What a turn hands to the user, owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, recorded in a log-only Session event, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end (or, outside a repository, from the file tools' persisted hunks alone), announced by a log-only event and served by a Host service while the Session lives. Only clients read them; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md).
 
 Sources: [`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts), [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts)
 

+ 1 - 1
docs/subsystems/deliverables.zh.md

@@ -2,7 +2,7 @@
 
 [English](deliverables.md) | 中文
 
-记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有:模型通过 `present` 工具声明的文件,记在一个只写日志的 Session 事件里;这一轮改动的文件,由轮开始和轮结束时的 git 工作树快照对比得出,用一个只写日志的事件宣告,并在 Session 存活期间由 Host 服务提供。它们只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。
+记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有:模型通过 `present` 工具声明的文件,记在一个只写日志的 Session 事件里;这一轮改动的文件,由轮开始和轮结束时的 git 工作树快照对比得出(不在仓库内时只由文件工具持久化的 hunk 得出),用一个只写日志的事件宣告,并在 Session 存活期间由 Host 服务提供。它们只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。
 
 源码:[`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts)、[`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts)
 

+ 2 - 2
packages/client/ui-deliverables/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/client/ui-deliverables/README.md
-README.md: 340a4c8a0dc6e3d99e71973b5ecfe13560716422
-README.zh.md: 4decf7f247fe33c5b8d4ed92f2f05925e3897df0
+README.md: 5875fb131b094cf2e9bee3ec110bbd8bcdfba8e7
+README.zh.md: e3327a881cd15d13c6171a40b2cb6a537147098e

+ 1 - 1
packages/client/ui-deliverables/README.md

@@ -25,7 +25,7 @@ This package renders the changed-files card a finished turn ends with — the fi
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../deliverables/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a served summary — a workspace outside any git repository, no git on the Host, the plugin composed out, or a Host restarted since the turn ran — the card is absent and only deliveries and prose links remain.
+Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../deliverables/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a served summary — a turn that changed no file, the plugin composed out, or a Host restarted since the turn ran — the card is absent and only deliveries and prose links remain; outside a git repository the summary lists file-tool edits only.
 
 <a id="explicit-deliveries"></a>
 ### Explicit deliveries

+ 1 - 1
packages/client/ui-deliverables/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../deliverables/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有可提供的摘要时——工作区不在任何 git 仓库内、Host 上没有 git、该插件被组合出去,或该轮之后 Host 重启过——卡片不出现,只保留交付卡片与正文链接。
+与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../deliverables/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有可提供的摘要时——本轮没有改动任何文件、该插件被组合出去,或该轮之后 Host 重启过——卡片不出现,只保留交付卡片与正文链接;工作区不在 git 仓库内时摘要只列文件工具的编辑
 
 <a id="explicit-deliveries"></a>
 ### 显式交付

+ 2 - 2
packages/deliverables/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/deliverables/workspace-changes/README.md
-README.md: cf0bc63290f3e4820e21c966642ed7c28c441cc3
-README.zh.md: 3d551c79f62c3ab42bbaf0fc3f1467b783563676
+README.md: 82106aa6c7fa7b812a228fc80f693d9fbee1ab5b
+README.zh.md: 31503458242cf42a93483b2a81537058516719b5

+ 3 - 3
packages/deliverables/workspace-changes/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This plugin summarizes which files each top-level turn changed, with per-file added and deleted line counts. It snapshots the working tree with git when a turn starts and when it ends, diffs the two snapshots, and adds the file-tool edits that git does not cover. The Session log receives only a `workspace/changes` event naming the turn; the summary itself stays on the Host, served through the `workspaceChanges` service until the Session is disposed. Only a working directory inside a git repository is recorded. The Web changed-files card renders the served summary; the model never sees it.
+This plugin summarizes which files each top-level turn changed, with per-file added and deleted line counts: git snapshots of the working tree at turn start and turn end are diffed, and the file-tool edits git does not cover are added. Outside a git repository, or without git, the summary lists the file-tool edits only. The Session log receives one `workspace/changes` event naming the turn; the summary stays on the Host, served through the `workspaceChanges` service until the Session is disposed. The Web changed-files card renders it; the model never sees it.
 
 ## Table of Contents
 
@@ -39,7 +39,7 @@ 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 summary; `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 temporary object directory owned by the Session, 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. Session disposal removes the directory. 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 with a working directory and no subagent origin is recorded; subagent Sessions are not. Snapshots are written through a private index into a temporary object directory owned by the Session, 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. Session disposal removes the directory. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. A working directory outside any git repository takes no snapshots. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — no repository is located either, and the plugin logs that once. Either way the summary lists the file-tool edits alone, as described next, with the working directory as the workspace; shell edits are absent.
 
 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.
 
@@ -87,7 +87,7 @@ Nothing here enters a model request, so provider cache reuse is unaffected.
 - 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.
+- A working directory outside any git repository lists file-tool edits only, so shell edits are missing from its 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.
 - Windows paths keep native separators in `path`; `display` is always slash-separated.
 

+ 3 - 3
packages/deliverables/workspace-changes/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本插件汇总每个顶层轮次改动了哪些文件、每个文件的增删行数。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。Session 日志只收到一条写明轮号的 `workspace/changes` 事件;摘要本身留在 Host 上,通过 `workspaceChanges` 服务提供,直到 Session 释放。只有位于 git 仓库内的工作目录会被记录。Web 的改动文件卡片渲染提供的摘要;模型看不到它。
+本插件汇总每个顶层轮次改动了哪些文件、每个文件的增删行数:比较轮次开始和结束时的 git 工作树快照,再补上 git 覆盖不到的文件工具编辑。不在 git 仓库内或没有 git 时,摘要只列文件工具的编辑。Session 日志只收到一条写明轮号的 `workspace/changes` 事件;摘要留在 Host 上,通过 `workspaceChanges` 服务提供,直到 Session 释放。Web 的改动文件卡片渲染它;模型看不到它。
 
 ## 目录
 
@@ -39,7 +39,7 @@ kind: "package-reference"
 | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 |
 | `maxFiles` | `500` | 单份摘要携带的最大文件数;`total` 仍报告完整数量 |
 
-工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。Session 释放时删除该目录。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志
+工作目录且不是子代理来源的 Session 都会被记录;子代理 Session 不记录。快照通过私有 index 写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。Session 释放时删除该目录。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。不在任何 git 仓库内的工作目录不做快照。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——同样定位不到仓库,插件记录一次日志。两种情况下摘要都只列下文所述的文件工具编辑,并以工作目录作为工作区;shell 的改动不会出现
 
 文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入,结果没有持久化 hunk 时则取调用自身的参数,也就是新建文件的 `write` 和 `str_replace_editor` 的每一种修改:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。
 
@@ -87,7 +87,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_
 - 有两个 git 功能在快照期间仍会写入仓库自己的 git 目录:`core.splitIndex` 会写 `sharedindex.*` 文件,git-lfs 会对改动文件运行 clean 过滤器并把对象存到 `.git/lfs` 下。
 - 需要 git 2.13 或更高版本以支持 `rev-parse --absolute-git-dir`;不支持的仓库格式或其他 git 失败会带着警告放弃本轮,而不是被当成普通目录。
 - 用户在轮次进行中自己做的编辑会被算到该轮。
-- 不在任何 git 仓库内的工作目录没有卡片;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。
+- 不在任何 git 仓库内的工作目录只列文件工具的编辑,卡片里因此没有 shell 改动;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。
 - 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。
 - Windows 路径在 `path` 中保留原生分隔符;`display` 始终用斜杠分隔。
 

+ 3 - 3
packages/deliverables/workspace-changes/src/index.ts

@@ -3,8 +3,8 @@
  * snapshots taken at turn start and turn end plus the hunks file tools persist
  * for paths git does not cover. Each summary is announced by a `workspace/changes`
  * Session event that carries only the turn number and is served through the
- * `workspaceChanges` service until the Session is disposed. Only a working
- * directory inside a git repository is recorded.
+ * `workspaceChanges` service until the Session is disposed. Outside a git
+ * repository, or without git, the summary lists file-tool edits only.
  */
 import { homedir, tmpdir } from 'node:os'
 import type { Context } from '@deepseek-ai/cordis'
@@ -103,7 +103,7 @@ export function apply(ctx: Context, config: Config): void {
   const gitRunner = (): Promise<GitRunner | null> => {
     runner ??= resolveGit(ctx, lifetime.signal).then((executable) => {
       if (executable === null) {
-        ctx.logger.info('workspace-changes: git is unavailable; turn file changes are not recorded')
+        ctx.logger.info('workspace-changes: git is unavailable; only file-tool edits are summarized')
         return null
       }
       return new GitRunner(ctx.subprocess, executable, { timeoutMs: config.timeoutMs, outputMaxBytes: config.outputMaxBytes })

+ 51 - 34
packages/deliverables/workspace-changes/src/recorder.ts

@@ -21,23 +21,29 @@ export interface RecorderEnvironment {
   warn: (message: string) => void
 }
 
-/** Everything a located repository needs, resolved once per Session. */
-interface Located {
-  git: GitRunner
-  workspace: GitWorkspace
+/** Canonical paths every comparison and display uses, resolved once per Session. */
+interface Paths {
   /** Canonical working directory; git reports symlink-resolved paths, so every comparison uses that form. */
   cwd: string
   /** Canonical home directory abbreviated as `~` in display paths. */
   home: string
-  /** Temporary roots whose files never enter a summary. */
+  /** Temporary roots whose files outside the workspace never enter a summary. */
   temporaryRoots: readonly string[]
 }
 
-interface Baseline extends Located { tree: string }
+/** The repository enclosing the working directory and the runner that snapshots it. */
+interface Repository {
+  git: GitRunner
+  workspace: GitWorkspace
+}
+
+/** The turn-start snapshot of a located repository. */
+interface Baseline extends Repository { tree: 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
+  /** Set once the turn-start snapshot exists; a turn without one summarizes file-tool hunks only. */
   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>
@@ -60,14 +66,17 @@ function freshState(turn: number): TurnState {
  * summary this recorder keeps. Snapshot objects live in a temporary directory
  * owned by the recorder; disposal removes it together with the summaries.
  * Tool execution waits for pending work so a snapshot never races a mutation.
- * A working directory outside any repository records nothing.
+ * A working directory outside any repository, or a Host without git, gets no
+ * snapshot; its summary lists the files the file tools changed.
  */
 export class TurnRecorder {
   private chain: Promise<void> = Promise.resolve()
   /** The open turn; before the first `turn/start` it is an empty placeholder no event can match. */
   private state = freshState(0)
+  /** Canonical paths, resolved by the first turn. */
+  private paths: Paths | undefined
   /** The located repository, reused across turns once found; null keeps retrying each turn. */
-  private located: Located | null = null
+  private repository: Repository | null = null
   /** Temporary directory holding this Session's snapshot objects, created with the first located repository. */
   private objectsDir: string | undefined
   /** Summaries by the sequence of the event that announced them. */
@@ -88,10 +97,11 @@ export class TurnRecorder {
     const state = freshState(turn)
     this.state = state
     void this.enqueue(async (signal) => {
-      const located = await this.locate(signal)
-      if (located === null) return
-      const tree = await snapshotTree(located.git, located.workspace, signal)
-      state.baseline = { ...located, tree }
+      this.paths ??= { cwd: await realpath(this.cwd), home: await canonicalPath(homedir()), temporaryRoots: await temporaryRoots() }
+      const repository = await this.locate(this.paths.cwd, signal)
+      if (repository === null) return
+      const tree = await snapshotTree(repository.git, repository.workspace, signal)
+      state.baseline = { ...repository, tree }
     })
   }
 
@@ -186,46 +196,53 @@ export class TurnRecorder {
     if (!this.lifetime.signal.aborted) this.env.warn(`workspace-changes: ${String(error)}`)
   }
 
-  /** The repository for this Session together with the canonical paths every comparison uses, located once. */
-  private async locate(signal: AbortSignal): Promise<Located | null> {
-    if (this.located !== null) return this.located
+  /** The repository enclosing the working directory, located once; null keeps retrying each turn. */
+  private async locate(cwd: string, signal: AbortSignal): Promise<Repository | null> {
+    if (this.repository !== null) return this.repository
     const git = await this.env.git
     if (git === null) return null
-    const cwd = await realpath(this.cwd)
     const workspace = await locateGitWorkspace(git, cwd, async () => {
       this.objectsDir ??= await mkdtemp(join(this.env.tempRoot, 'dsh-workspace-changes-objects-'))
       return this.objectsDir
     }, signal)
     if (workspace === null) return null
-    this.located = { git, workspace, cwd, home: await canonicalPath(homedir()), temporaryRoots: await temporaryRoots() }
-    return this.located
+    this.repository = { git, workspace }
+    return this.repository
   }
 
   private async record(state: TurnState, signal: AbortSignal): Promise<void> {
-    if (state.baseline === null || state.lastToolResultSeq < 0) return
+    const paths = this.paths
+    if (paths === undefined || state.lastToolResultSeq < 0) return
     state.attemptedAfterSeq = state.lastToolResultSeq
-    const baseline = state.baseline
-    const { git, workspace, tree: before, cwd } = baseline
-    const root = workspace.root
-    const after = await snapshotTree(git, workspace, signal)
+    const { baseline } = state
+    // Without a snapshot the working directory itself bounds the workspace.
+    const root = baseline?.workspace.root ?? paths.cwd
     const files = new Map<string, WorkspaceChangedFile>()
-    for (const entry of await diffTrees(git, workspace, before, after, signal)) {
-      const absolute = resolve(root, entry.path)
-      files.set(absolute, changedFile(baseline, absolute, entry))
+    if (baseline !== null) {
+      const after = await snapshotTree(baseline.git, baseline.workspace, signal)
+      for (const entry of await diffTrees(baseline.git, baseline.workspace, baseline.tree, after, signal)) {
+        const absolute = resolve(root, entry.path)
+        files.set(absolute, changedFile(paths, root, absolute, entry))
+      }
     }
     // File-tool hunks by canonical absolute path, for the files snapshots do not cover.
     const hunks = new Map<string, FileDiff[]>()
     for (const [path, list] of state.hunks) {
-      const absolute = await canonicalPath(resolve(cwd, path))
+      const absolute = await canonicalPath(resolve(paths.cwd, path))
       if (!files.has(absolute)) hunks.set(absolute, [...hunks.get(absolute) ?? [], ...list])
     }
     const workTreePath = (absolute: string): string => toPosix(relative(root, absolute))
-    const inRepository = [...hunks.keys()].filter(absolute => isInside(root, absolute))
-    const ignored = await ignoredPaths(git, workspace, inRepository.map(workTreePath), signal)
+    const inWorkspace = [...hunks.keys()].filter(absolute => isInside(root, absolute))
+    // A snapshot covers every workspace file except the ignored ones; without one, every file-tool edit counts.
+    const uncoveredInWorkspace = baseline === null
+      ? new Set(inWorkspace.map(workTreePath))
+      : await ignoredPaths(baseline.git, baseline.workspace, inWorkspace.map(workTreePath), signal)
     for (const [absolute, list] of hunks) {
-      // Inside the repository only ignored files are uncovered; outside it, scratch files under a temporary root stay out.
-      const uncovered = isInside(root, absolute) ? ignored.has(workTreePath(absolute)) : !isTemporaryPath(absolute, baseline.temporaryRoots)
-      if (uncovered) files.set(absolute, changedFile(baseline, absolute, { ...hunkLineCounts(list), binary: false }))
+      // Outside the workspace, scratch files under a temporary root stay out.
+      const uncovered = isInside(root, absolute)
+        ? uncoveredInWorkspace.has(workTreePath(absolute))
+        : !isTemporaryPath(absolute, paths.temporaryRoots)
+      if (uncovered) files.set(absolute, changedFile(paths, root, absolute, { ...hunkLineCounts(list), binary: false }))
     }
     const sorted = [...files.values()].sort(compareDisplay)
     // An empty list after an earlier in-turn record supersedes that record.
@@ -237,11 +254,11 @@ export class TurnRecorder {
 }
 
 function changedFile(
-  { cwd, workspace, home }: Baseline, absolute: string, counts: { added: number; deleted: number; binary: boolean },
+  { cwd, home }: Paths, root: string, absolute: string, counts: { added: number; deleted: number; binary: boolean },
 ): WorkspaceChangedFile {
   return {
     path: durablePathOf(absolute, cwd),
-    display: displayPathOf(absolute, cwd, workspace.root, home),
+    display: displayPathOf(absolute, cwd, root, home),
     added: counts.added,
     deleted: counts.deleted,
     ...counts.binary ? { binary: true as const } : {},

+ 23 - 5
packages/deliverables/workspace-changes/tests/plugin.spec.ts

@@ -231,9 +231,12 @@ describe('workspace-changes in a repository', () => {
 })
 
 describe('workspace-changes without a repository', () => {
-  it('records nothing for a working directory outside any git repository', async () => {
+  it('summarizes file-tool edits only for a working directory outside any git repository', async () => {
     const cwd = await scratchDir('dsh-workspace-changes-plain-', cleanups)
     await writeFile(join(cwd, 'existing.txt'), 'before\n')
+    await writeFile(join(cwd, 'shell.txt'), 'shell\n')
+    const outside = await mkdtemp(join(homedir(), '.dsh-workspace-changes-plain-'))
+    cleanups.push(() => rm(outside, { recursive: true, force: true }))
     const { ctx } = await boot()
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
     const session = ctx.sessions.create(SessionId('plain'), { meta: { cwd } })
@@ -243,9 +246,23 @@ describe('workspace-changes without a repository', () => {
     toolCall(session, 1, 'write', { file_path: 'existing.txt', content: 'after\nmore\n' }, {
       meta: { diffs: [{ path: 'existing.txt', oldText: 'before', newText: 'after\nmore' }] },
     })
+    toolCall(session, 1, 'edit', { file_path: 'existing.txt', old_string: 'more', new_string: 'more\nagain' }, {
+      meta: { diffs: [{ path: 'existing.txt', oldText: 'more', newText: 'more\nagain' }] },
+    })
+    // Shell edits and scratch files under a temporary root stay out; a file elsewhere outside the workspace counts.
+    await writeFile(join(cwd, 'shell.txt'), 'shell\nedited\n')
+    toolCall(session, 1, 'bash', { command: 'x' })
+    toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, { meta: { diffs: [] } })
+    toolCall(session, 1, 'str_replace_editor', { command: 'create', path: join(outside, 'note.txt'), file_text: 'one\ntwo\n' })
     endTurn(session, 1)
     await settle(ctx, session)
-    expect(changes(ctx, session)).toEqual([])
+    expect(changes(ctx, session)).toEqual([{
+      turn: 1, cwd, total: 2,
+      files: [
+        { path: 'existing.txt', display: 'existing.txt', added: 4, deleted: 2 },
+        { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 2, deleted: 0 },
+      ],
+    }])
     expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toEqual([])
     await expect(stat(join(cwd, '.git'))).rejects.toThrow()
   })
@@ -301,8 +318,8 @@ describe('workspace-changes without a repository', () => {
 })
 
 describe('workspace-changes without git', () => {
-  it('records nothing and reports the absence once', async () => {
-    const cwd = await scratchDir('dsh-workspace-changes-nogit-', cleanups)
+  it('summarizes file-tool edits only, even inside a repository, and reports the absence once', async () => {
+    const cwd = await repository()
     const { ctx } = await boot()
     vi.spyOn(ctx.subprocess, 'resolveExecutable').mockRejectedValue(new Error('git: not found'))
     const info = vi.spyOn(ctx.logger, 'info').mockImplementation(() => undefined)
@@ -312,10 +329,11 @@ describe('workspace-changes without git', () => {
       await settle(ctx, session)
       await writeFile(join(cwd, `${turn}.txt`), 'x\n')
       toolCall(session, turn, 'bash', { command: 'x' })
+      if (turn === 2) toolCall(session, turn, 'write', { file_path: 'w.txt', content: 'w\n' }, { meta: { diffs: [] } })
       endTurn(session, turn)
       await settle(ctx, session)
     }
-    expect(changes(ctx, session)).toEqual([])
+    expect(changes(ctx, session)).toEqual([{ turn: 2, cwd, total: 1, files: [{ path: 'w.txt', display: 'w.txt', added: 1, deleted: 0 }] }])
     expect(info).toHaveBeenCalledTimes(1)
     expect(info.mock.calls[0]![0]).toContain('git is unavailable')
   })

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
snapshots/web/permission-policy-context/session.v3.jsonl


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
snapshots/web/present-svg/session.v3.jsonl


+ 4 - 0
snapshots/web/present-svg/ui.expected.md

@@ -49,6 +49,10 @@
     - strong: 总线连接
     - text: :CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向
 - paragraph: 下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。
+- text: 已编辑 1 个文件 +83 -0
+- list:
+  - listitem:
+    - button "在侧边栏打开 von-neumann.svg": von-neumann.svg +83 -0
 - text: 此主机没有可用的桌面,无法使用外部程序打开文件或文件夹;文件仍可在侧边栏预览
 - button "在侧边栏预览 von-neumann.svg"
 - text: von-neumann.svg 冯诺依曼架构示意图 SVG

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio