Parcourir la source

feat(workspace-changes): record only git repositories and add the recorded card scenario

Drop the shadow repository tier: a working directory outside any git
repository records nothing and the card stays absent, because a configured
exclude list cannot replace a missing .gitignore reliably. Seed a committed
repository in a new keyless Web scenario so the recorded turn exercises the
recorder, and retire the produced-files row from the affected goldens.
creatixchu il y a 1 semaine
Parent
commit
0f8457a017
37 fichiers modifiés avec 398 ajouts et 225 suppressions
  1. 2 2
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml
  2. 4 2
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md
  3. 4 2
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md
  4. 118 0
      apps/web/tests/changed-files-turn.e2e.ts
  5. 5 0
      apps/web/tests/changed-files-turn.overlay.yml
  6. 6 10
      apps/web/tests/clickable-links-gallery.e2e.ts
  7. 0 8
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  8. 2 3
      apps/web/tests/present-svg.e2e.ts
  9. 2 2
      apps/web/tests/produced-file-mentions.e2e.ts
  10. 6 13
      apps/web/tests/scaffold.ts
  11. 1 0
      apps/web/tsconfig.json
  12. 2 2
      docs/config-catalog.i18n.yaml
  13. 2 6
      docs/config-catalog.md
  14. 2 6
      docs/config-catalog.zh.md
  15. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  16. 1 1
      packages/client/ui-deliverables/README.md
  17. 1 1
      packages/client/ui-deliverables/README.zh.md
  18. 2 2
      packages/fs/workspace-changes/README.i18n.yaml
  19. 7 8
      packages/fs/workspace-changes/README.md
  20. 7 8
      packages/fs/workspace-changes/README.zh.md
  21. 0 2
      packages/fs/workspace-changes/package.json
  22. 21 61
      packages/fs/workspace-changes/src/git.ts
  23. 5 19
      packages/fs/workspace-changes/src/index.ts
  24. 5 4
      packages/fs/workspace-changes/src/recorder.ts
  25. 8 18
      packages/fs/workspace-changes/tests/git.spec.ts
  26. 0 2
      packages/fs/workspace-changes/tests/loader-composition.spec.ts
  27. 15 33
      packages/fs/workspace-changes/tests/plugin.spec.ts
  28. 0 3
      packages/fs/workspace-changes/tsconfig.json
  29. 0 3
      pnpm-lock.yaml
  30. 14 0
      snapshots/web/changed-files-turn/session.v3.jsonl
  31. 9 0
      snapshots/web/changed-files-turn/snapshot.yml
  32. 133 0
      snapshots/web/changed-files-turn/ui.expected.md
  33. 3 0
      snapshots/web/changed-files-turn/workspace.expected/intro.md
  34. 2 0
      snapshots/web/changed-files-turn/workspace.expected/notes.txt
  35. 6 0
      snapshots/web/changed-files-turn/workspace.expected/src/util.ts
  36. 0 2
      snapshots/web/present-svg/ui.expected.md
  37. 1 0
      tsconfig.host.json

+ 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: f04e694853a89be75c642325d636c6a8fd496ddb
-2026-09-11-turn-changed-files-card.zh.md: 52474eaa4dc4b312dadb475c590e25146944c922
+2026-09-11-turn-changed-files-card.md: c477935fad1fa95ae0d60a67f31a89c38b020087
+2026-09-11-turn-changed-files-card.zh.md: 23ee760f18d6f9a6e9b540ef0e9bde452968a308

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

@@ -14,7 +14,7 @@ The Host [workspace-changes](../../../../packages/fs/workspace-changes/README.md
 
 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`. 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, 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. Three tiers cover the workspace: a working directory inside a repository uses that repository; a working directory outside any repository uses a shadow repository under the Harness home, with its git directory there and its work tree pointing at the working directory, so the user's directory gains no `.git`; without git the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into. Shadow repositories take their excludes from configuration because no `.gitignore` exists there.
+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.
 
@@ -36,10 +36,12 @@ The recorder appends inside the turn on `agent/turn-stopping` and again after `t
 
 **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.
+
 ## Consequences
 
 Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the repository's object store; unreferenced trees are pruned by the repository's own garbage collection, after which only the recorded counts remain. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff.
 
-The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries.
+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.

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

@@ -14,7 +14,7 @@ Host 侧的 [workspace-changes](../../../../packages/fs/workspace-changes/README
 
 记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。
 
-git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。工作区分三档:位于仓库内的工作目录使用该仓库;不在任何仓库内的工作目录使用 Harness home 下的影子仓库,其 git 目录在那里而工作树指向工作目录,用户目录里不会多出 `.git`;没有 git 时插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。影子仓库的排除规则来自配置,因为那里没有 `.gitignore`。
+git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。只有位于 git 仓库内的工作目录会被记录;不在任何仓库内或没有 git 时,插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。
 
 快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,因此不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。
 
@@ -36,10 +36,12 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。
 
 **用于定位 git 的环境提供者接缝**由团队提出但尚未定案;插件使用 `PATH` 并把查找收敛在一处。
 
+**为不在任何仓库内的工作目录建影子仓库**,即把 git 目录放在 Harness home 下、工作树指向工作目录,可以让这些用户在不多出 `.git` 的情况下看到卡片,但影子仓库没有 `.gitignore`,一份配置的排除列表无法在所有项目布局里可靠地挡住构建产物、缓存和依赖目录。它暂缓到排除策略定下来为止;记录器已经把仓库当作输入,加这一档只改变快照写到哪里。
+
 ## Consequences
 
 每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入仓库对象库;无引用的树由仓库自己的垃圾回收清理,之后只剩记录的计数。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。
 
-只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景新增该事件与卡片。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。
+只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只有在工作区是 git 仓库时才新增该事件与卡片,一个专门的场景负责种入这样的仓库。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。
 
 聚焦测试用真实 git 覆盖仓库与影子两档、覆盖范围分类、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器。

+ 118 - 0
apps/web/tests/changed-files-turn.e2e.ts

@@ -0,0 +1,118 @@
+/** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card. */
+import { execFileSync } from 'node:child_process'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type {} from '@deepseek-ai/dsh-workspace-changes'
+import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import {
+  assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden,
+  fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
+  webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+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。'
+
+/** Seed a committed repository so the turn's own edits are the only difference between its snapshots. */
+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')
+  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')
+  git('commit', '-q', '-m', 'seed')
+}
+
+describe('web e2e: a git workspace turn ends with its changed files', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  let cwd: string
+  let replayRoot: string | undefined
+
+  beforeAll(async () => {
+    let replayOverride: string | undefined
+    if (MODE !== 'record') {
+      replayRoot = await mkdtemp(join(tmpdir(), 'dsh-changed-files-turn-replay-'))
+      replayOverride = join(replayRoot, 'replay.override.json')
+      const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
+      // Recorded absolute paths must follow each isolated Session's working directory.
+      const cwdToken = '{{fromRequest:Your working directory is ([^\\n]+)\\.}}'
+      await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1)))
+    }
+    scaffold = await launchWebScaffold({
+      compareReplaySession: true,
+      extraOverlayPath: fileURLToPath(new URL('./changed-files-turn.overlay.yml', import.meta.url)),
+      ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }),
+    })
+    await seedRepository(join(scaffold.workspaceCwd, 'workspace'))
+    browser = await chromium.launch()
+    page = await browser.newPage({
+      viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai',
+    })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]')
+    await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
+  })
+
+  afterAll(async () => {
+    try {
+      await browser?.close()
+    } finally {
+      try {
+        await scaffold?.close()
+      } finally {
+        if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true })
+      }
+    }
+  })
+
+  it('records the edited, created, and shell-appended files with their line counts', async () => {
+    if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+    const settled = scaffold.whenTurnSettled()
+    const input = page.locator('[data-composer-input]').first()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    const sessionId = await settled
+    const session = scaffold.ctx.agents.get(sessionId)?.session
+    if (session?.header.cwd === undefined) throw new Error('changed-files Session has no workspace')
+    cwd = session.header.cwd
+    if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
+
+    const summaries = session.snapshotEvents().filter(event => event.type === 'workspace/changes')
+    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)
+    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(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.getByRole('listitem').count()).toBe(3)
+    // 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)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
+  it.skipIf(MODE === 'record')('replays the workspace and the Chinese conversation', async () => {
+    await assertFinalWorkspaceSnapshot(DIR, cwd, { ignoredRootEntries: ['.git'] })
+    const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
+  })
+})

+ 5 - 0
apps/web/tests/changed-files-turn.overlay.yml

@@ -0,0 +1,5 @@
+# This transcript includes the no-desktop state on every test platform, so the
+# card header stays a label and rows preview in the Sidebar.
+- id: session-controller
+  config:
+    nativeOpen: false

+ 6 - 10
apps/web/tests/clickable-links-gallery.e2e.ts

@@ -41,7 +41,7 @@ const UI_EXPECTED = fileURLToPath(new URL('./expected/clickable-links-gallery/ui
 // The golden holds the show-in-folder affordance; pin the native-opener
 // capability so headless Linux CI and desktop developer hosts expose the same
 // UI branch (same pin as produced-files.e2e.ts, whose overlay this shares).
-const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.meta.url))
+const OVERLAY = fileURLToPath(new URL('./changed-files.overlay.yml', import.meta.url))
 const MODE = webSnapshotMode()
 const SEED_ID = 'clickable-links-gallery-web-e2e'
 const DONE = 'LINK_GALLERY_DONE'
@@ -343,13 +343,12 @@ describe('web e2e: clickable links gallery', () => {
     expect(await markdown.locator(`img[src="${imageUrl}"]`).count()).toBe(1)
 
     // Produced files: one unique-basename mention links; the shared basename
-    // and the unwritten file stay inert code. Seven produced paths overflow
-    // the chip row; the failed write joins neither surface.
+    // and the unwritten file stay inert code. The seeded session carries no
+    // recorded change summary, so no changed-files card follows the prose.
     const mentions = markdown.locator('code button')
     expect(await mentions.count()).toBe(1)
     expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
-    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
-    expect(await page.locator('[class*="centerCol"] button[aria-label^="Open "]').count()).toBeGreaterThanOrEqual(5)
+    expect(await page.locator('[data-changed-files]').count()).toBe(0)
     expect(await page.locator('button[aria-label="Open c/broken.css"]').count()).toBe(0)
 
     // Tool rows: five writes, the edit, and the read carry the dotted file
@@ -406,13 +405,11 @@ describe('web e2e: clickable links gallery', () => {
     const styleOf = async (target: ReturnType<Page['locator']>, property: string): Promise<string> =>
       target.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), property)
     const guideLink = markdown.locator(`a[href="${GUIDE_URL}"]`).first()
-    const chip = page.locator('button[aria-label="Open site/report.html"]').first()
     for (const [name, link] of [
       ['markdown anchor', guideLink],
       ['file mention', mentions.first()],
       ['search source', sourceLink.first()],
       ['fetch url', page.locator(`a[href="${FETCH_URL}"]`).first()],
-      ['produced chip', chip],
     ] as const) {
       expect.soft(await styleOf(link, 'color'), `${name} color`).toBe(LINK_BLUE)
       expect.soft(await styleOf(link, 'font-weight'), `${name} weight`).toBe('500')
@@ -423,9 +420,8 @@ describe('web e2e: clickable links gallery', () => {
     expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline')
     expect(await styleOf(guideLink, 'text-decoration-style')).toBe('dotted')
     expect(await styleOf(guideLink, 'text-underline-offset')).toBe('3px')
-    await chip.hover()
-    expect(await styleOf(chip, 'text-decoration-style')).toBe('dotted')
-    expect(await styleOf(chip, 'background-color')).toBe('rgba(0, 0, 0, 0)')
+    await mentions.first().hover()
+    expect(await styleOf(mentions.first(), 'text-decoration-style')).toBe('dotted')
     // The excluded grey affordance: tool-row file links keep their own color.
     expect(await styleOf(page.locator('button[class*="fileLink"]').first(), 'color')).not.toBe(LINK_BLUE)
   }, 90_000)

+ 0 - 8
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -169,14 +169,6 @@
 - list:
   - listitem:
     - paragraph: Footnote references stay inert superscripts. ↩
-- text: Files changed
-- button "Open site/report.html": report.html
-- button "Open a/style.css": style.css
-- button "Open b/style.css": style.css
-- button "Open site/index.html": index.html
-- button "Open site/app.js": app.js
-- button "Open src/tokens.css": tokens.css
-- text: + 1 file
 - button "Copy":
   - img
 - button "Good response":

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

@@ -106,9 +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)
-    if (await page.locator('[data-produced-files-row]').count() > 0) {
-      expect(await page.getByText('本轮文件改动', { exact: true }).count()).toBe(1)
-    }
+    // 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)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   })

+ 2 - 2
apps/web/tests/produced-file-mentions.e2e.ts

@@ -151,8 +151,8 @@ describe('web e2e: inline-code mentions of produced files', () => {
     expect(await mentions.first().innerText()).toBe('report.html')
     expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html in sidebar')
     expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
-    // The turn still ends with its produced-files row (all three writes).
-    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
+    // Mentions resolve from the mutation calls alone; without a recorded summary no card follows.
+    expect(await page.locator('[data-changed-files]').count()).toBe(0)
 
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])

+ 6 - 13
apps/web/tests/scaffold.ts

@@ -38,6 +38,7 @@ import Group from '@deepseek-ai/cordis-plugin-group'
 import {
   captureExpectedWorkspaceSnapshot,
   captureWorkspaceSnapshot,
+  type CaptureWorkspaceSnapshotOptions,
   assertSessionFixtureVersion,
   formatSystemPromptSnapshot,
   formatToolSchemasSnapshot,
@@ -64,7 +65,6 @@ import {
   type Profile,
 } from '@deepseek-ai/dsh-app-boot'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
-import { DEFAULT_SHADOW_EXCLUDES } from '@deepseek-ai/dsh-workspace-changes'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type {
   LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, RetryPolicyConfig, StreamChunk,
@@ -128,13 +128,16 @@ export function webSnapshotMode(): WebSnapshotMode {
  * Compare a session-driven Web scenario's complete workspace with its committed independent expected state.
  * @param scenarioDir - Absolute recorded-session scenario directory.
  * @param workspaceRoot - Absolute cwd used by the controlled session.
+ * @param options - Root entries the scenario owns outside the expected state, such as a `.git` directory it initialized.
  */
-export async function assertFinalWorkspaceSnapshot(scenarioDir: string, workspaceRoot: string): Promise<void> {
+export async function assertFinalWorkspaceSnapshot(
+  scenarioDir: string, workspaceRoot: string, options: CaptureWorkspaceSnapshotOptions = {},
+): Promise<void> {
   const manifestPath = join(scenarioDir, 'snapshot.yml')
   const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath)
   expect(manifest.workspace?.final, `${manifest.scenario ?? scenarioDir}: mutating Web scenario declares workspace.final`)
     .toBe(true)
-  const actual = await captureWorkspaceSnapshot(workspaceRoot)
+  const actual = await captureWorkspaceSnapshot(workspaceRoot, options)
   const expected = await captureExpectedWorkspaceSnapshot(join(scenarioDir, 'workspace.expected'))
   expect(actual, `${manifest.scenario ?? scenarioDir}: complete final workspace`).toEqual(expected)
 }
@@ -606,16 +609,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       : [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
     { id: 'settings', config: { dshHome: harnessHome } },
     { id: 'credentials', config: { dshHome: harnessHome } },
-    // Turn change summaries snapshot the temp workspace into a shadow
-    // repository under the owned harness home; the scaffold's other in-workspace
-    // roots stay out of every snapshot so goldens depend only on scenario files.
-    {
-      id: 'workspace-changes',
-      config: {
-        dshHome: harnessHome,
-        shadowExcludes: [...DEFAULT_SHADOW_EXCLUDES, '.agents-home/', '.bundled-skills/', '.dsh-storages/'],
-      },
-    },
     // The shipped directory-picker row is the -auto chooser, which resolves
     // the interaction from the RUNNING host (display, SSH launch, bind). The
     // lane's goldens are interaction-specific (workspace-management drives

+ 1 - 0
apps/web/tsconfig.json

@@ -98,6 +98,7 @@
     "tests/sidebar-right.e2e.ts",
     "tests/startup-auto-selection.e2e.ts",
     "tests/changed-files.e2e.ts",
+    "tests/changed-files-turn.e2e.ts",
     "tests/produced-file-mentions.e2e.ts",
     "tests/goal-bar.e2e.ts",
     "tests/goal-command-presentation.e2e.ts",

+ 2 - 2
docs/config-catalog.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/config-catalog.md
-config-catalog.md: 8c1cf53597364af9dae4a679e87624ea358b6996
-config-catalog.zh.md: e4f21204ee2bb77d869a1e4645456d39cf6558b3
+config-catalog.md: e776a396ca0c18b02d9e91f0e979e102cf40aba4
+config-catalog.zh.md: 2eb933afd86b7c3358bb63daa7639b050fb68d1d

+ 2 - 6
docs/config-catalog.md

@@ -3454,22 +3454,18 @@ Source: [`packages/workflow/workflow-worker-thread/src/index.ts:32`](../packages
 Requires: `subprocess`
 
 ```ts config-catalog
-/** Snapshot bounds and shadow repository placement. Invalid values fail plugin load. */
+/** Snapshot bounds. Invalid values fail plugin load. */
 export interface Config {
-  /** Harness home that holds shadow repositories under `workspace-changes/`; defaults to `$DSH_HOME`, then `~/.dsh`. */
-  dshHome?: string
   /** Milliseconds one git command may run before the turn's record is abandoned. */
   timeoutMs: number
   /** Bytes of git output retained per command; a larger diff listing abandons the record. */
   outputMaxBytes: number
   /** Maximum files carried by one event; `total` still reports the complete count. */
   maxFiles: number
-  /** `info/exclude` patterns for shadow repositories. */
-  shadowExcludes: string[]
 }
 ```
 
-Source: [`packages/fs/workspace-changes/src/index.ts:35`](../packages/fs/workspace-changes/src/index.ts)
+Source: [`packages/fs/workspace-changes/src/index.ts:28`](../packages/fs/workspace-changes/src/index.ts)
 
 ## Loadable plugins with no config
 

+ 2 - 6
docs/config-catalog.zh.md

@@ -3456,22 +3456,18 @@ export interface Config {
 依赖: `subprocess`
 
 ```ts config-catalog
-/** Snapshot bounds and shadow repository placement. Invalid values fail plugin load. */
+/** Snapshot bounds. Invalid values fail plugin load. */
 export interface Config {
-  /** Harness home that holds shadow repositories under `workspace-changes/`; defaults to `$DSH_HOME`, then `~/.dsh`. */
-  dshHome?: string
   /** Milliseconds one git command may run before the turn's record is abandoned. */
   timeoutMs: number
   /** Bytes of git output retained per command; a larger diff listing abandons the record. */
   outputMaxBytes: number
   /** Maximum files carried by one event; `total` still reports the complete count. */
   maxFiles: number
-  /** `info/exclude` patterns for shadow repositories. */
-  shadowExcludes: string[]
 }
 ```
 
-来源: [`packages/fs/workspace-changes/src/index.ts:35`](../packages/fs/workspace-changes/src/index.ts)
+来源: [`packages/fs/workspace-changes/src/index.ts:28`](../packages/fs/workspace-changes/src/index.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: bb15db710decbc16a0974daf6bed555c3fb10815
-README.zh.md: a7db3227b5765eb303caf80e9337ee6b6d1354d7
+README.md: 2af21786f4e66b96cc72e907e0791b1e263755c1
+README.zh.md: 609a82070b0ce5424fab2b4a9fe396f9b487df29

+ 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](../../fs/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 recorded summary — no git on the Host, or the plugin composed out — the card is absent and only deliveries and prose links remain.
+Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../fs/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 recorded summary — a workspace outside any git repository, no git on the Host, or the plugin composed out — the card is absent and only deliveries and prose links remain.
 
 <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](../../fs/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有记录的摘要时——Host 上没有 git,或该插件被组合出去——卡片不出现,只保留交付卡片与正文链接。
+与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../fs/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有记录的摘要时——工作区不在任何 git 仓库内、Host 上没有 git,或该插件被组合出去——卡片不出现,只保留交付卡片与正文链接。
 
 <a id="explicit-deliveries"></a>
 ### 显式交付

+ 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: c5b83477ad986e8c4cecf403285ad8e1a7106fb7
-README.zh.md: 75d96e9a769412946eb788874564cc2f7b85bdf7
+README.md: 0c25dbacb039c32cc7cb7f3cb3ec57e251cc3de3
+README.zh.md: 1e400e422c87f9b9cd86c532ffdf1143daf92717

+ 7 - 8
packages/fs/workspace-changes/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Record each top-level turn's changed files from git working-tree snapshots as a durable workspace/changes Session event; configuration, repository tiers, and coverage rules."
+description: "Record each top-level turn's changed files from git working-tree snapshots as a durable workspace/changes Session event; configuration, repository requirement, and coverage rules."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This plugin records which files each top-level turn changed, with per-file added and deleted line counts, as a `workspace/changes` Session event. 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 Web changed-files card renders the event; the model never sees it.
+This plugin records which files each top-level turn changed, with per-file added and deleted line counts, as a `workspace/changes` Session event. 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. Only a working directory inside a git repository is recorded. The Web changed-files card renders the event; the model never sees it.
 
 ## Table of Contents
 
@@ -35,15 +35,13 @@ The shipped Web bundle mounts this plugin. Mount it in any composition with the
 
 | Field | Default | Meaning |
 |---|---|---|
-| `dshHome` | `$DSH_HOME`, then `~/.dsh` | Harness home whose `workspace-changes/` directory holds shadow repositories |
 | `timeoutMs` | `30000` | Milliseconds one git command may run before the turn's record is abandoned |
 | `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 |
-| `shadowExcludes` | `.git/`, `.dsh/`, `node_modules/`, build and cache directories | `info/exclude` patterns for shadow repositories |
 
-Every Session with a working directory and no subagent origin is recorded; subagent Sessions are not. A working directory inside a git repository snapshots into that repository's object store through a private index, so the repository's own index, work tree, and refs stay untouched and the user's earlier uncommitted changes never enter a summary. A working directory outside any repository snapshots into a shadow repository under the Harness home whose work tree is the working directory itself; the `shadowExcludes` patterns replace the missing `.gitignore`, and a Harness home inside the working directory is excluded automatically. 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 go into the repository's object store through a private index, so the repository's own index, work tree, and refs stay untouched and the user's earlier uncommitted changes never enter a summary. 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 or shadow work tree. 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: 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.
 
 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 +53,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 or initializes the shadow one, then `add --all` into a temporary index seeded from the persisted index and `write-tree` produce the tree id. 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. A shadow repository keeps its refreshed index for the next snapshot; a real repository's index is only read.
+One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository, then `add --all` into a temporary index seeded from the repository's index and `write-tree` produce the tree id. 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.
 
 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.
 
@@ -70,7 +68,7 @@ Git runs through the `subprocess` capability with a scrubbed environment, `GIT_T
 
 - [Web deliverables](../../client/ui-deliverables/README.md) — the changed-files card that renders this event and opens its files.
 - [Subprocess capability](../../subprocess/README.md) — the seam git runs through.
-- [Turn changed-files card decision](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — snapshot design, coverage rules, and rejected alternatives.
+- [Turn changed-files card decision](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — snapshot design, coverage rules, the deferred shadow repository, and rejected alternatives.
 
 <a id="model-experience"></a>
 ## Model Experience
@@ -87,6 +85,7 @@ Nothing here enters a model request, so provider cache reuse is unaffected.
 
 - Snapshot trees are unreferenced objects, so a repository's garbage collection removes them after its prune expiry; the recorded counts survive, the trees needed for a later full-file comparison do not.
 - 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.
 - Windows paths keep native separators in `path`; `display` is always slash-separated.
 

+ 7 - 8
packages/fs/workspace-changes/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "用 git 工作树快照把每个顶层轮次改动的文件记录为持久的 workspace/changes Session 事件;配置、仓库分档与覆盖规则。"
+description: "用 git 工作树快照把每个顶层轮次改动的文件记录为持久的 workspace/changes Session 事件;配置、仓库要求与覆盖规则。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本插件把每个顶层轮次改动了哪些文件、每个文件的增删行数记录为 `workspace/changes` Session 事件。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。Web 的改动文件卡片渲染该事件;模型看不到它。
+本插件把每个顶层轮次改动了哪些文件、每个文件的增删行数记录为 `workspace/changes` Session 事件。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。只有位于 git 仓库内的工作目录会被记录。Web 的改动文件卡片渲染该事件;模型看不到它。
 
 ## 目录
 
@@ -35,15 +35,13 @@ kind: "package-reference"
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
-| `dshHome` | `$DSH_HOME`,其次 `~/.dsh` | Harness home,其 `workspace-changes/` 目录存放影子仓库 |
 | `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 |
 | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 |
 | `maxFiles` | `500` | 单个事件携带的最大文件数;`total` 仍报告完整数量 |
-| `shadowExcludes` | `.git/`、`.dsh/`、`node_modules/`、构建与缓存目录 | 影子仓库的 `info/exclude` 模式 |
 
-每个有工作目录且不是子代理来源的 Session 都会被记录;子代理 Session 不记录。工作目录位于 git 仓库内时,快照通过私有 index 写入该仓库的对象库,仓库自己的 index、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。工作目录不在任何仓库内时,快照写入 Harness home 下的影子仓库,其工作树就是该工作目录;`shadowExcludes` 模式代替缺失的 `.gitignore`,位于工作目录内的 Harness home 会被自动排除。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。
+工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入仓库的对象库,仓库自己的 index、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。
 
-文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入:匹配忽略模式的文件,以及仓库或影子工作树之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于工作目录内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。
+文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于工作目录内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。
 
 每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。事件还携带两次快照的 tree id。
 
@@ -55,7 +53,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库或初始化影子仓库,然后以持久 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/result` 事件收集文件工具的 hunk。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次;早先记录之后的空列表会取代它。影子仓库保留刷新后的 index 供下次快照使用;真实仓库的 index 只读取。
+每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库,然后以仓库 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`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 释放与插件释放会中止排队的工作。
 
@@ -70,7 +68,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_
 
 - [Web 产出物](../../client/ui-deliverables/README.zh.md)——渲染该事件并打开其文件的改动文件卡片。
 - [子进程能力](../../subprocess/README.zh.md)——git 运行所经过的接缝。
-- [本轮改动文件卡片决策](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——快照设计、覆盖规则与被否决的备选方案。
+- [本轮改动文件卡片决策](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——快照设计、覆盖规则、暂缓的影子仓库与被否决的备选方案。
 
 <a id="model-experience"></a>
 ## 模型体验
@@ -87,6 +85,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_
 
 - 快照树是无引用对象,仓库的垃圾回收会在其过期期限后删除它们;记录的计数保留,而将来做整文件对比所需的树不会保留。
 - 用户在轮次进行中自己做的编辑会被算到该轮。
+- 不在任何 git 仓库内的工作目录没有卡片;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。
 - 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。
 - Windows 路径在 `path` 中保留原生分隔符;`display` 始终用斜杠分隔。
 

+ 0 - 2
packages/fs/workspace-changes/package.json

@@ -38,7 +38,6 @@
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
-    "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^"
@@ -48,7 +47,6 @@
     "@deepseek-ai/cordis-plugin-include": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
-    "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-subprocess-local": "workspace:^",

+ 21 - 61
packages/fs/workspace-changes/src/git.ts

@@ -1,11 +1,9 @@
 /** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */
-import { createHash } from 'node:crypto'
-import { copyFile, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
+import { copyFile, mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
-import { join, relative } from 'node:path'
+import { join } from 'node:path'
 import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
 import { parseNumstat, type NumstatEntry } from './numstat.ts'
-import { isInside, toPosix } from './paths.ts'
 
 /** Milliseconds a git child gets to exit after termination starts; a fixed lifecycle constant. */
 const TERMINATE_GRACE_MS = 2_000
@@ -90,64 +88,33 @@ function ok(result: GitRunResult, what: string): GitRunResult {
   return result
 }
 
-/**
- * The git repository a Session working directory snapshots into: the
- * enclosing repository when one exists, otherwise a shadow repository whose
- * git directory lives under the Harness home while its work tree is the
- * working directory itself.
- */
+/** The repository enclosing a Session working directory. */
 export interface GitWorkspace {
-  kind: 'repository' | 'shadow'
-  /** Work-tree root: the repository top level, or the working directory for a shadow. */
+  /** Repository top-level directory, the root every diff path is relative to. */
   root: string
   /** Absolute git directory holding the index and objects. */
   gitDir: string
-  /** Environment that addresses the repository for every command. */
-  env: Readonly<Record<string, string>>
-}
-
-/** Where and how shadow repositories are created. */
-export interface ShadowRepositoryOptions {
-  /** Directory that holds one shadow git directory per working directory. */
-  home: string
-  /** `info/exclude` patterns written on every snapshot. */
-  excludes: readonly string[]
 }
 
 /**
- * Locate the repository for a working directory, creating the shadow
- * repository when the directory is not inside one.
+ * Locate the repository enclosing a working directory.
  * @param git - command runner.
  * @param cwd - absolute Session working directory.
- * @param shadow - shadow repository placement and excludes.
  * @param signal - cancellation.
- * @returns the addressed repository.
+ * @returns the repository, or null when the directory is not inside one.
  */
-export async function locateGitWorkspace(
-  git: GitRunner, cwd: string, shadow: ShadowRepositoryOptions, signal: AbortSignal,
-): Promise<GitWorkspace> {
+export async function locateGitWorkspace(git: GitRunner, cwd: string, signal: AbortSignal): Promise<GitWorkspace | null> {
   const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd, signal })
-  if (found.exitCode === 0) {
-    const [root, gitDir] = found.stdout.split('\n') as [string, string]
-    return { kind: 'repository', root, gitDir, env: {} }
-  }
-  const gitDir = join(shadow.home, createHash('sha256').update(cwd).digest('hex').slice(0, 16))
-  const env = { GIT_DIR: gitDir, GIT_WORK_TREE: cwd }
-  await mkdir(gitDir, { recursive: true })
-  ok(await git.run(['init', '-q'], { cwd, env, signal }), `git init of shadow repository ${gitDir}`)
-  const excludes = [...shadow.excludes]
-  if (isInside(cwd, shadow.home)) excludes.push(`/${toPosix(relative(cwd, shadow.home))}/`)
-  await mkdir(join(gitDir, 'info'), { recursive: true })
-  await writeFile(join(gitDir, 'info', 'exclude'), `${excludes.join('\n')}\n`)
-  return { kind: 'shadow', root: cwd, gitDir, env }
+  if (found.exitCode !== 0) return null
+  const [root, gitDir] = found.stdout.split('\n') as [string, string]
+  return { root, gitDir }
 }
 
 /**
  * Write the complete work tree, including untracked and modified files but
- * not ignored ones, as a tree object through a private index. The
- * repository's own index seeds the stat cache and is never modified, and an
- * in-progress merge keeps its unmerged entries; a shadow repository keeps the
- * refreshed index for its next snapshot.
+ * not ignored ones, as a tree object through a private index seeded from the
+ * repository's index. That index, the work tree, and every ref stay unchanged;
+ * an in-progress merge keeps its unmerged entries.
  * @param git - command runner.
  * @param workspace - addressed repository.
  * @param signal - cancellation.
@@ -157,14 +124,11 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign
   const scratch = await mkdtemp(join(tmpdir(), 'dsh-workspace-changes-'))
   try {
     const index = join(scratch, 'index')
-    const persisted = join(workspace.gitDir, 'index')
-    // A missing persisted index (a fresh shadow repository) starts from scratch.
-    await copyFile(persisted, index).then(() => undefined, () => undefined)
-    const env = { ...workspace.env, GIT_INDEX_FILE: index }
+    // A repository without an index yet (fresh `git init`) starts from scratch.
+    await copyFile(join(workspace.gitDir, 'index'), index).then(() => undefined, () => undefined)
+    const env = { GIT_INDEX_FILE: index }
     ok(await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal }), `git add in ${workspace.root}`)
-    const tree = ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim()
-    if (workspace.kind === 'shadow') await rename(index, persisted)
-    return tree
+    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 })
   }
@@ -177,16 +141,14 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign
  * @param before - turn-start tree id.
  * @param after - turn-end tree id.
  * @param signal - cancellation.
- * @returns changed files relative to the work-tree root.
+ * @returns changed files relative to the repository root.
  * @throws when git fails or the output exceeded the cap.
  */
 export async function diffTrees(
   git: GitRunner, workspace: GitWorkspace, before: string, after: string, signal: AbortSignal,
 ): Promise<NumstatEntry[]> {
   if (before === after) return []
-  const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], {
-    cwd: workspace.root, env: workspace.env, signal,
-  }), 'git diff-tree')
+  const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], { cwd: workspace.root, signal }), 'git diff-tree')
   if (result.truncated) throw new Error('git diff-tree output exceeded the configured cap')
   return parseNumstat(result.stdout)
 }
@@ -197,7 +159,7 @@ export async function diffTrees(
  * counts as covered by snapshots.
  * @param git - command runner.
  * @param workspace - addressed repository.
- * @param paths - slash-separated paths relative to the work-tree root.
+ * @param paths - slash-separated paths relative to the repository root.
  * @param signal - cancellation.
  * @returns the ignored members of `paths`.
  */
@@ -205,9 +167,7 @@ export async function ignoredPaths(
   git: GitRunner, workspace: GitWorkspace, paths: readonly string[], signal: AbortSignal,
 ): Promise<Set<string>> {
   if (paths.length === 0) return new Set()
-  const result = await git.run(['check-ignore', '-z', '--stdin'], {
-    cwd: workspace.root, env: workspace.env, stdin: `${paths.join('\0')}\0`, signal,
-  })
+  const result = await git.run(['check-ignore', '-z', '--stdin'], { cwd: workspace.root, stdin: `${paths.join('\0')}\0`, signal })
   if (result.exitCode === 1) return new Set()
   return new Set(ok(result, 'git check-ignore').stdout.split('\0').filter(path => path !== ''))
 }

+ 5 - 19
packages/fs/workspace-changes/src/index.ts

@@ -2,13 +2,12 @@
  * Records the files each top-level turn changed as a durable `workspace/changes`
  * Session event, derived from git working-tree snapshots taken at turn start
  * and turn end plus the hunks file tools persist for paths git does not cover.
+ * Only a working directory inside a git repository is recorded.
  */
 import { realpathSync } from 'node:fs'
 import { homedir } from 'node:os'
-import { join } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import type {} from '@deepseek-ai/dsh-agent'
 import type { Session } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-subprocess'
@@ -25,33 +24,21 @@ export const name = 'workspace-changes'
 /** Services used to run git and observe turns. */
 export const inject = ['subprocess']
 
-/** Directories excluded from shadow repositories, where no `.gitignore` exists. */
-export const DEFAULT_SHADOW_EXCLUDES: readonly string[] = [
-  '.git/', '.dsh/', '.DS_Store', 'node_modules/', '.venv/', 'venv/', '__pycache__/', '.pytest_cache/', '.mypy_cache/',
-  'dist/', 'build/', 'out/', 'target/', '.cache/', '.next/', '.nuxt/', '.turbo/', 'coverage/',
-]
-
-/** Snapshot bounds and shadow repository placement. Invalid values fail plugin load. */
+/** Snapshot bounds. Invalid values fail plugin load. */
 export interface Config {
-  /** Harness home that holds shadow repositories under `workspace-changes/`; defaults to `$DSH_HOME`, then `~/.dsh`. */
-  dshHome?: string
   /** Milliseconds one git command may run before the turn's record is abandoned. */
   timeoutMs: number
   /** Bytes of git output retained per command; a larger diff listing abandons the record. */
   outputMaxBytes: number
   /** Maximum files carried by one event; `total` still reports the complete count. */
   maxFiles: number
-  /** `info/exclude` patterns for shadow repositories. */
-  shadowExcludes: string[]
 }
 
 /** Schemastery validation for {@link Config}. */
 export const Config: z<Config> = z.object({
-  dshHome: z.string(),
   timeoutMs: z.number().default(30_000),
   outputMaxBytes: z.number().default(8 * 1024 * 1024),
   maxFiles: z.number().default(500),
-  shadowExcludes: z.array(z.string()).default([...DEFAULT_SHADOW_EXCLUDES]),
 })
 
 function eligible(session: Session): string | undefined {
@@ -84,8 +71,8 @@ async function resolveGit(ctx: Context, signal: AbortSignal): Promise<string | n
 }
 
 /**
- * Observe top-level turns of every Session with a working directory and append
- * their change summaries.
+ * Observe top-level turns of every Session whose working directory lies in a
+ * git repository and append their change summaries.
  * @param ctx - host context with `subprocess`.
  * @param config - validated bounds and placement.
  */
@@ -100,7 +87,6 @@ export function apply(ctx: Context, config: Config): void {
     for (const recorder of recorders.values()) recorder.dispose()
     recorders.clear()
   })
-  const shadow = { home: join(resolveDshHome(config.dshHome), 'workspace-changes'), excludes: config.shadowExcludes }
   const roots = temporaryRoots()
   const home = realpathSync.native(homedir())
   let runner: Promise<GitRunner | null> | undefined
@@ -118,7 +104,7 @@ export function apply(ctx: Context, config: Config): void {
     let recorder = recorders.get(session)
     if (recorder === undefined) {
       recorder = new TurnRecorder(session, cwd, {
-        git: gitRunner(), shadow, home, temporaryRoots: roots, maxFiles: config.maxFiles,
+        git: gitRunner(), home, temporaryRoots: roots, maxFiles: config.maxFiles,
         warn: (message) => { ctx.logger.warn(message) },
       })
       recorders.set(session, recorder)

+ 5 - 4
packages/fs/workspace-changes/src/recorder.ts

@@ -3,7 +3,7 @@ import { realpath } from 'node:fs/promises'
 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 ShadowRepositoryOptions } from './git.ts'
+import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace } from './git.ts'
 import { fileDiffsOf, hunkLineCounts } from './numstat.ts'
 import { absolutePathOf, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, toPosix } from './paths.ts'
 import type { WorkspaceChangedFile } from './types.ts'
@@ -12,7 +12,6 @@ import type { WorkspaceChangedFile } from './types.ts'
 export interface RecorderEnvironment {
   /** Resolves to the runner, or null when git is unavailable and no turn records anything. */
   git: Promise<GitRunner | null>
-  shadow: ShadowRepositoryOptions
   /** Canonical absolute home directory abbreviated as `~` in display paths. */
   home: string
   /** Temporary roots whose files never enter a summary. */
@@ -38,7 +37,8 @@ async function canonicalPath(path: string): Promise<string> {
 /**
  * Serializes one Session's git work: the turn-start snapshot, the turn-end
  * snapshot with its diff, and the appended `workspace/changes` event. Tool
- * execution waits for pending work so a snapshot never races a mutation.
+ * execution waits for pending work so a snapshot never races a mutation. A
+ * working directory outside any repository records nothing.
  */
 export class TurnRecorder {
   private chain: Promise<void> = Promise.resolve()
@@ -68,7 +68,8 @@ export class TurnRecorder {
       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.shadow, signal)
+      const workspace = await locateGitWorkspace(git, cwd, signal)
+      if (workspace === null) return
       const tree = await snapshotTree(git, workspace, signal)
       this.baseline = { git, workspace, tree, cwd }
     })

+ 8 - 18
packages/fs/workspace-changes/tests/git.spec.ts

@@ -1,5 +1,5 @@
 /** Git command bounds, snapshot recovery, and diff failure reporting. */
-import { mkdir, writeFile } from 'node:fs/promises'
+import { realpath, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
@@ -56,9 +56,9 @@ describe('snapshots and diffs', () => {
     expect(() => git(cwd, 'merge', 'side')).toThrow()
     expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt')
     const { git: runnerGit } = await runner()
-    const workspace = await locateGitWorkspace(runnerGit, cwd, { home: join(cwd, 'unused'), excludes: [] }, signal)
-    expect(workspace.kind).toBe('repository')
-    const tree = await snapshotTree(runnerGit, workspace, signal)
+    const workspace = await locateGitWorkspace(runnerGit, cwd, signal)
+    expect(workspace?.root).toBe(await realpath(cwd))
+    const tree = await snapshotTree(runnerGit, workspace!, signal)
     expect(tree).toMatch(/^[0-9a-f]{40,64}$/)
     expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt')
   })
@@ -66,12 +66,13 @@ describe('snapshots and diffs', () => {
   it('fails loudly when the addressed repository cannot be written or diffed', async () => {
     const cwd = await scratchDir('dsh-git-broken-', cleanups)
     const { git: runnerGit } = await runner()
-    const broken = { kind: 'shadow' as const, root: cwd, gitDir: join(cwd, 'missing'), env: { GIT_DIR: join(cwd, 'missing'), GIT_WORK_TREE: cwd } }
+    expect(await locateGitWorkspace(runnerGit, cwd, signal)).toBeNull()
+    const broken = { root: cwd, gitDir: join(cwd, 'missing') }
     await expect(snapshotTree(runnerGit, broken, signal)).rejects.toThrow('git add in')
     await expect(ignoredPaths(runnerGit, broken, ['x'], signal)).rejects.toThrow('git check-ignore failed')
     expect(await ignoredPaths(runnerGit, broken, [], signal)).toEqual(new Set())
     git(cwd, 'init', '-q')
-    const workspace = await locateGitWorkspace(runnerGit, cwd, { home: join(cwd, 'unused'), excludes: [] }, signal)
+    const workspace = (await locateGitWorkspace(runnerGit, cwd, signal))!
     await expect(diffTrees(runnerGit, workspace, 'a'.repeat(40), 'b'.repeat(40), signal)).rejects.toThrow('git diff-tree failed')
     await writeFile(join(cwd, 'many.txt'), Array.from({ length: 50 }, (_, index) => `line ${index}`).join('\n'))
     const before = await snapshotTree(runnerGit, workspace, signal)
@@ -83,17 +84,6 @@ describe('snapshots and diffs', () => {
     expect((await diffTrees(runnerGit, workspace, before, after, signal)).length).toBe(21)
   })
 
-  it('refuses a shadow home whose git directory cannot be initialized', async () => {
-    const cwd = await scratchDir('dsh-git-shadow-broken-', cleanups)
-    const home = join(cwd, 'home')
-    const { git: runnerGit } = await runner()
-    const probe = await locateGitWorkspace(runnerGit, cwd, { home, excludes: ['x/'] }, signal)
-    expect(probe.kind).toBe('shadow')
-    await mkdir(join(probe.gitDir, 'objects', 'broken'), { recursive: true })
-    await writeFile(join(probe.gitDir, 'HEAD'), 'not a ref')
-    await writeFile(join(probe.gitDir, 'config'), '[core]\n\tbare = maybe\n')
-    await expect(locateGitWorkspace(runnerGit, cwd, { home, excludes: [] }, signal)).rejects.toThrow('git init of shadow repository')
-  })
 })
 
 describe('TurnRecorder', () => {
@@ -104,7 +94,7 @@ describe('TurnRecorder', () => {
     const warnings: string[] = []
     let release!: (runner: GitRunner | null) => void
     const gate = new Promise<GitRunner | null>((resolve) => { release = resolve })
-    const env = { git: gate, shadow: { home: join(cwd, 'home'), excludes: [] }, home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) } }
+    const env = { git: gate, home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) } }
     const disposed = new TurnRecorder(session, cwd, env)
     disposed.start(1)
     await new Promise(resolve => setTimeout(resolve, 5))

+ 0 - 2
packages/fs/workspace-changes/tests/loader-composition.spec.ts

@@ -34,8 +34,6 @@ describe('real Loader composition', () => {
       "- name: '@deepseek-ai/dsh-session'",
       "- name: '@deepseek-ai/dsh-subprocess-local'",
       "- name: '@deepseek-ai/dsh-workspace-changes'",
-      '  config:',
-      `    dshHome: ${JSON.stringify(join(root, 'home'))}`,
       '',
     ].join('\n'))
     context = new Context()

+ 15 - 33
packages/fs/workspace-changes/tests/plugin.spec.ts

@@ -22,9 +22,8 @@ async function boot(config: Partial<WorkspaceChanges.Config> = {}) {
   cleanups.push(() => ctx.fiber.dispose())
   await ctx.plugin(SessionStore)
   await ctx.plugin(LocalSubprocessRuntime)
-  const dshHome = config.dshHome ?? await scratchDir('dsh-workspace-changes-home-', cleanups)
-  const fiber = await ctx.plugin(WorkspaceChanges, { ...config, dshHome } as WorkspaceChanges.Config)
-  return { ctx, fiber, dshHome }
+  const fiber = await ctx.plugin(WorkspaceChanges, config as WorkspaceChanges.Config)
+  return { ctx, fiber }
 }
 
 async function repository(): Promise<string> {
@@ -170,10 +169,9 @@ describe('workspace-changes in a repository', () => {
   })
 
   it('warns and skips the turn when its git work fails', async () => {
-    const cwd = await scratchDir('dsh-workspace-changes-warn-', cleanups)
-    const blocker = join(cwd, 'blocker')
-    await writeFile(blocker, 'not a directory')
-    const { ctx } = await boot({ dshHome: blocker })
+    const cwd = await repository()
+    const { ctx } = await boot()
+    vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(join(cwd, 'missing-git'))
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
     const session = ctx.sessions.create(SessionId('warn'), { meta: { cwd } })
     startTurn(session, 1)
@@ -185,7 +183,7 @@ describe('workspace-changes in a repository', () => {
     expect(changes(session)).toEqual([])
     const own = warn.mock.calls.map(call => String(call[0])).filter(message => message.startsWith('workspace-changes:'))
     expect(own).toHaveLength(1)
-    expect(own[0]).toContain('ENOTDIR')
+    expect(own[0]).toContain('missing-git')
   })
 
   it('rejects non-positive bounds at load', async () => {
@@ -198,43 +196,27 @@ describe('workspace-changes in a repository', () => {
 })
 
 describe('workspace-changes without a repository', () => {
-  it('snapshots into a shadow repository under the Harness home and excludes that home', async () => {
+  it('records nothing for a working directory outside any git repository', async () => {
     const cwd = await scratchDir('dsh-workspace-changes-plain-', cleanups)
-    const dshHome = join(cwd, '.dsh')
     await writeFile(join(cwd, 'existing.txt'), 'before\n')
-    const { ctx } = await boot({ dshHome })
+    const { ctx } = await boot()
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
     const session = ctx.sessions.create(SessionId('plain'), { meta: { cwd } })
     startTurn(session, 1)
     await settle(ctx, session)
     await writeFile(join(cwd, 'existing.txt'), 'after\nmore\n')
-    await mkdir(join(cwd, 'node_modules'), { recursive: true })
-    await writeFile(join(cwd, 'node_modules', 'dep.js'), 'module\n')
-    await writeFile(join(dshHome, 'settings.yaml'), 'changed: true\n')
-    toolCall(session, 1, 'write', { file_path: 'node_modules/dep.js' }, {
-      meta: { diffs: [{ path: 'node_modules/dep.js', oldText: null, newText: 'module\n' }] },
+    toolCall(session, 1, 'write', { file_path: 'existing.txt' }, {
+      meta: { diffs: [{ path: 'existing.txt', oldText: 'before', newText: 'after\nmore' }] },
     })
     endTurn(session, 1)
     await settle(ctx, session)
-    const [first] = changes(session)
-    expect(first!.files).toEqual([
-      { path: 'existing.txt', display: 'existing.txt', added: 2, deleted: 1 },
-      { path: 'node_modules/dep.js', display: 'node_modules/dep.js', added: 1, deleted: 0 },
-    ])
-    const shadows = join(dshHome, 'workspace-changes')
-    expect((await stat(shadows)).isDirectory()).toBe(true)
+    expect(changes(session)).toEqual([])
+    expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toEqual([])
     await expect(stat(join(cwd, '.git'))).rejects.toThrow()
-
-    startTurn(session, 2)
-    await settle(ctx, session)
-    await writeFile(join(cwd, 'second.txt'), 's\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(['second.txt'])
   })
 
   it('drops a disposed session’s recorder and starts afresh on its next turn', async () => {
-    const cwd = await scratchDir('dsh-workspace-changes-disposed-', cleanups)
+    const cwd = await repository()
     const { ctx, fiber } = await boot()
     const session = ctx.sessions.create(SessionId('disposed'), { meta: { cwd } })
     startTurn(session, 1)
@@ -264,7 +246,7 @@ describe('workspace-changes without a repository', () => {
   })
 
   it('ignores subagent sessions and sessions without a working directory', async () => {
-    const cwd = await scratchDir('dsh-workspace-changes-skip-', cleanups)
+    const cwd = await repository()
     const { ctx } = await boot()
     const sessions = [
       ctx.sessions.create(SessionId('child'), { meta: { cwd, delegationDepth: 1 } }),

+ 0 - 3
packages/fs/workspace-changes/tsconfig.json

@@ -25,9 +25,6 @@
     },
     {
       "path": "../../subprocess/subprocess"
-    },
-    {
-      "path": "../../util/home-paths"
     }
   ]
 }

+ 0 - 3
pnpm-lock.yaml

@@ -6288,9 +6288,6 @@ importers:
       '@deepseek-ai/dsh-agent':
         specifier: workspace:^
         version: link:../../core/agent
-      '@deepseek-ai/dsh-home-paths':
-        specifier: workspace:^
-        version: link:../../util/home-paths
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm

Fichier diff supprimé car celui-ci est trop grand
+ 14 - 0
snapshots/web/changed-files-turn/session.v3.jsonl


+ 9 - 0
snapshots/web/changed-files-turn/snapshot.yml

@@ -0,0 +1,9 @@
+version: 1
+scenario: changed-files-turn
+profile: web
+composition: web-default
+recording: live
+header:
+  class: web-default
+workspace:
+  final: true

+ 133 - 0
snapshots/web/changed-files-turn/ui.expected.md

@@ -0,0 +1,133 @@
+- button "系统提示词":
+  - img
+  - img
+  - text: 系统提示词
+- text: 不用先查看目录,直接做三件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,然后用 bash 在 notes.txt 末尾追加一行 done。 {{clock}}
+- button "复制":
+  - img
+- button "6 次工具调用 · 1 条消息" [expanded]:
+  - text: 6 次工具调用 · 1 条消息
+  - 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.":
+  - 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.
+- button "读取 intro.md":
+  - img
+  - img
+  - text: 读取
+  - button "intro.md"
+- button "写入 src/util.ts +6 -0":
+  - img
+  - img
+  - text: 写入
+  - button "src/util.ts"
+  - text: +6 -0
+- button "Bash Append done line to notes.txt":
+  - img
+  - img
+  - text: Bash Append done line to notes.txt
+- button "编辑 intro.md +3 -3":
+  - img
+  - img
+  - text: 编辑
+  - button "intro.md"
+  - text: +3 -3
+- button "思考 Verify the three results quickly.":
+  - img
+  - img
+  - text: 思考 Verify the three results quickly.
+- button "Bash Verify all three changes":
+  - img
+  - img
+  - text: Bash Verify all three changes
+- button "思考 Present the files as deliverables.":
+  - img
+  - img
+  - text: 思考 Present the files as deliverables.
+- paragraph: 三件事都已完成并验证:
+- list:
+  - listitem:
+    - strong:
+      - code: intro.md
+    - text: — 标题已从
+    - code: "# 示例项目"
+    - text: 改为
+    - code: "# 项目说明"
+    - text: (edit 工具需先读取文件以精确匹配,因此只读了这一个文件,未列目录)。
+  - listitem:
+    - strong:
+      - code: src/util.ts
+    - text: (新建)— 导出带类型的
+    - code: "add(a: number, b: number): number"
+    - text: 函数。
+  - listitem:
+    - strong:
+      - code: notes.txt
+    - text: — 用 bash 以
+    - code: ">>"
+    - text: 在末尾追加了
+    - code: done
+    - text: (当前文件内容为
+    - code: start
+    - text: /
+    - code: done
+    - text: 两行)。
+- button "交付文件 已交付 intro.md, src/util.ts, notes.txt":
+  - img
+  - text: 交付文件 已交付 intro.md, src/util.ts, notes.txt
+- paragraph:
+  - text: 改动文件:
+  - code:
+    - button "在侧边栏打开 intro.md": intro.md
+  - text: 、
+  - code:
+    - button "在侧边栏打开 src/util.ts": src/util.ts
+  - text: 、
+  - code:
+    - button "在侧边栏打开 notes.txt": notes.txt
+  - text: 。
+- img
+- text: 已编辑 3 个文件 +8 -1
+- list:
+  - 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
+- text: 此主机没有可用的桌面,无法打开文件或文件夹
+- button "在侧边栏预览 intro.md"
+- text: intro.md 标题已改为「项目说明」
+- button "在侧边栏打开 intro.md": 打开
+- button "intro.md 的更多文件操作" [disabled]:
+  - img
+- button "在侧边栏预览 src/util.ts"
+- text: util.ts 新建,导出 add 函数
+- button "在侧边栏打开 src/util.ts": 打开
+- button "src/util.ts 的更多文件操作" [disabled]:
+  - img
+- button "在侧边栏预览 notes.txt"
+- text: notes.txt 末尾已追加 done
+- button "在侧边栏打开 notes.txt": 打开
+- button "notes.txt 的更多文件操作" [disabled]:
+  - img
+- button "复制":
+  - img
+- button "好的回答":
+  - img
+- button "有问题的回答":
+  - img
+- button "在新对话中分支":
+  - img
+- button "用量 45.9K tok":
+  - img
+  - text: 用量 45.9K tok
+- button "用时 {{duration}}":
+  - img
+  - text: 用时 {{duration}}
+- text: {{clock}}

+ 3 - 0
snapshots/web/changed-files-turn/workspace.expected/intro.md

@@ -0,0 +1,3 @@
+# 项目说明
+
+一个用于演示的仓库。

+ 2 - 0
snapshots/web/changed-files-turn/workspace.expected/notes.txt

@@ -0,0 +1,2 @@
+start
+done

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

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

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

@@ -49,8 +49,6 @@
     - strong: 总线连接
     - text: :CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向
 - paragraph: 下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。
-- text: 本轮文件改动
-- button "打开 {{cwd}}/workspace/von-neumann.svg": von-neumann.svg
 - text: 此主机没有可用的桌面,无法打开文件或文件夹
 - button "在侧边栏预览 von-neumann.svg"
 - text: von-neumann.svg 冯诺依曼架构示意图 SVG

+ 1 - 0
tsconfig.host.json

@@ -86,6 +86,7 @@
     "apps/web/tests/goal-command-presentation.e2e.ts",
     "apps/web/tests/startup-auto-selection.e2e.ts",
     "apps/web/tests/changed-files.e2e.ts",
+    "apps/web/tests/changed-files-turn.e2e.ts",
     "apps/web/tests/produced-file-mentions.e2e.ts",
     "apps/web/tests/subagent-conversation.e2e.ts",
     "apps/web/tests/subagent-interrupt.e2e.ts",

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff