iterate-plugin 3.6.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,7 +104,7 @@ Both modes share:
104
104
  - Validates after each round's fixes
105
105
  - Rolls back failed fixes, and can save / resume progress
106
106
 
107
- ### UI layer (build-free client slots, v3.6: 11 tabs)
107
+ ### UI layer (build-free client slots, v3.7: 11 tabs, bilingual)
108
108
 
109
109
  - **ConvergenceDashboard** (`conversation.input.dock`) — live round progress bar, severity stats, dimension badges, trend mini-chart above the input; normal mode also shows fix-count badges; plus a live workflow-phase chip (current phase + running/stopped) and a **task_mode indicator (code / iterate)**, now backed by the persisted transcript (`iterate_status` / `iterate_transcript` emit `taskMode`). While a run is active the dashboard switches to a running state (round + phase) instead of the static empty state, and the start buttons are hidden.
110
110
  - **ObservatoryPanel** (`conversation.input.dock`) — an **eleven-tab** runtime observatory below the input: **live activity stream** (default tab, type filter) + **F1** review threads (expand / collapse all), **F2** convergence trend (incl. the per-round comparison table: findings raised / fixed / severity mix / validation outcomes), **F3** finding locations (severity / dimension / search filter), **F4** fixes + rollback, **F5** checkpoint resume, **F6** run console, **F7** decision timeline (type / round filter + search), **F8** Quality Gate, **F9** Experience Bank, **F10** Defense Events. Number-key shortcuts: `1`–`9` jump to F1–F9, `0` to F10, `-` back to the live stream. **F8/F9/F10 are live** — they scan the session stream for the latest `iterate_quality_gate` / `iterate_experience` / `iterate_defense_events` results and render real data (PASS/FAIL chip + per-dimension score bars, experience entries with adopt buttons + client-side search, defense type-count chips + filtered event stream); when the session stream has none they distinguish "no data on disk" from "not pulled yet" and offer a **拉取磁盘快照 (pull disk snapshot)** instruction that has the model call the read-side tools and echo the `.iterate/` artifacts back into the stream. One-click JSON export of all observatory data — manifest + live + `qualityGate` / `experienceBank` / `defenseEvents` / `report` (download, copy fallback). **v3.5.3: reset actions surfaced** — F5 copies an `iterate_checkpoint clear` instruction, F8 an `iterate_quality_gate clear` instruction, F10 an `iterate_defense_events clear` instruction.
@@ -112,7 +112,8 @@ Both modes share:
112
112
  - **StatsCard** (`conversation.chat.turnTail`) — convergence stats, round history, trend chart and completion summary when no findings remain.
113
113
  - **iterate theme skin** (`theme.overrideTokens`) — a warm-amber 13-token `--dsw-*` override, light/dark modes, togglable in settings.
114
114
  - **ProgressCapsule** (`shell.overlay`) — popup notification on each round completion / convergence (incl. convergence confirm).
115
- - **SettingsPanel** (`settings.section`) — theme toggle, triage-persistence notes, config-management guide (covers `validation.commands` / `language` / `personalization.known_intentional` with an explicit "empty validation.commands = unprotected" warning; a field picker generates the matching `iterate_config write` instruction), runtime status overview (artifact layout + view/cleanup tool guide + disk-snapshot pull), one-click triage data reset.
115
+ - **SettingsPanel** (`settings.section`) — theme toggle, triage-persistence notes, config-management guide (covers `validation.commands` / `language` / `personalization.known_intentional` with an explicit "empty validation.commands = unprotected" warning; a field picker generates the matching `iterate_config write` instruction), **v3.7: a 回读当前配置 (read back current config) button that closes the write → read-back loop** — it copies an `iterate_config read` instruction and, once the model echoes the result into the session, shows the current value of the selected field, runtime status overview (artifact layout + view/cleanup tool guide + disk-snapshot pull), one-click triage data reset.
116
+ - **Bilingual UI (v3.7)** — every piece of UI chrome (panels, empty states, tabs, badges, settings, stop-reason and completion labels) ships in 简体中文 and English. Pick the language under **Settings → 界面语言 / Interface language**; the choice persists in `localStorage` (`iterate-ui-lang`) and otherwise auto-detects from `navigator.language` (a stored choice always wins, and auto-detection never writes a preference). **Model-facing copy deliberately stays Chinese** — the `build*Instruction` payloads and the copy-paste guides are contract text mirroring `SKILL.md`, so the two expanded guides carry a 「中文(粘贴给模型的契约文案)」 chip instead of looking like a missed translation.
116
117
 
117
118
  The UI layer is **defensive by design**: it degrades gracefully if any of `slots` / `theme` / `React` is unavailable — it never crashes the client.
118
119
 
@@ -125,7 +126,8 @@ Beyond the 17 registered tools (full reference further down), the plugin closes
125
126
  - **Breakpoint resume** — checkpoints saved at the start of each round; interrupted long iterations can resume
126
127
  - **History audit** — `iterate_history` reads the decision log (filtered by `type` / `since` / `round` / fixed `file` / `limit`, default latest 50, cap 200) plus a fix-registry summary (per-round fixed/failed counts, scoped by the same round/file filters) to audit run process and fix details
127
128
  - **Runtime cleanup** — `iterate_prune` removes stale decision-log entries, stale checkpoints, orphaned fix backups, empty rounds and stray temp files left by crashed atomic writes; dry-run by default (report-only), real cleanup requires `dryRun:false`, and every cleanup is logged
128
- - **Config read / write** — `iterate_config` supports validated, backed-up, rollback-capable partial writes
129
+ - **Config read / write** — `iterate_config` supports validated, backed-up, rollback-capable partial writes; since v3.7 the settings page can also **read the effective config back** (instruction + inline current value)
130
+ - **Session-aware project root (v3.7)** — on dsh ≥ `0.2.1-alpha.2` the plugin follows the session's *mutable* working directory through the host's `WorkingDirectoryService` instead of the immutable `Session.header.cwd`, so a mid-session directory switch redirects everything under `.iterate/` (config, decision log, checkpoints, fix backups, approval policy) to the workspace actually in use. The service is feature-detected with a `header.cwd` fallback on every failure mode, so older hosts behave exactly as before.
129
131
  - **v3.2 Experience Bank** — `iterate_experience` queries historical fixes and patterns with search / filter / adopt, and can persist new verified fixes (`add`) — re-adding the same pattern+dimension bumps its hit count instead of duplicating it
130
132
  - **v3.2 Quality Gate** — `iterate_quality_gate` reads quality gate status with per-dimension convergence rates and PASS/FAIL, and can recompute + persist a fresh certificate (`compute`) from this round's findings / validation results (real convergence from `findingsByRound`)
131
133
  - **v3.2 Defense Events** — `iterate_defense_events` queries defense events (precondition failures, rollbacks, invariant violations, assumption falsifications) and can `record` new ones; readable labels follow the project language (en / zh)
@@ -209,6 +211,8 @@ Press `1`–`9` to jump to F1–F9, `0` for F10, and `-` to return to the live s
209
211
 
210
212
  The settings section copies a config-edit guide that lists the supported `iterate.config.yaml` fields — including `validation.commands` (with an explicit warning that an empty list means every round runs unprotected) — and offers a **field picker**: choose a field, and the button generates the matching `iterate_config write` instruction (schema-validated, auto-backup, auto-rollback) for you to paste back.
211
213
 
214
+ **Reading back what actually landed** — the matching **回读当前配置 / Read back current config** button copies an `iterate_config` `{ operation: "read" }` instruction; when the model echoes the result into the session, the card shows the current value of the selected field right under the picker (arrays inline, e.g. `["npm test","npm run lint"]`). Three states are told apart honestly: *nothing read back yet*, *the value*, and *read back but this field is absent* (the built-in defaults are in play). If the clipboard refuses, the read instruction is revealed in the panel for manual copy — the button is never a dead end.
215
+
212
216
  ---
213
217
 
214
218
  ## 💬 Usage
@@ -338,10 +342,15 @@ All runtime state lives under `.iterate/` at the project root (can be excluded v
338
342
  `0.1.2-rc.1`, `0.1.3-alpha.1`, `0.1.5-alpha.1`, `0.1.5-alpha.2`,
339
343
  `0.1.5-rc.1`, `0.1.5-rc.2`, `0.1.6-alpha.1`, `0.1.6-alpha.2`,
340
344
  `0.1.7-alpha.1`, `0.1.7-alpha.2`, `0.1.7-rc.1`, `0.1.7-rc.2`, `0.2.0-rc.1`,
341
- `0.2.0-rc.2`, `0.2.1-alpha.1` (18 releases, `0.1.1-rc.1` … `0.2.1-alpha.1`).
345
+ `0.2.0-rc.2`, `0.2.1-alpha.1`, `0.2.1-alpha.2`, `0.2.1-alpha.3`,
346
+ `0.2.1-alpha.4` (21 releases, `0.1.1-rc.1` …
347
+ `0.2.1-alpha.4`).
342
348
  The plugin is built against `@deepseek-ai/dsh-tools` /
343
- `@deepseek-ai/dsh-util-values` `0.2.1-alpha.1` and uses only public contracts
349
+ `@deepseek-ai/dsh-util-values` `0.2.1-alpha.4` and uses only public contracts
344
350
  (tool registration, client slots + theme, bundle patch).
351
+ It also declares `engines.dsh` (`>=0.1.1-rc.1`) so the dsh ≥ 0.2.1-alpha.4
352
+ runtime evaluates compatibility from that single range (earlier runtimes fall
353
+ back to the `@deepseek-ai/dsh-*` peer versions listed in `package.json`).
345
354
  - **Disposable-Profile evidence (real, `dsh` CLI `0.1.1-rc.1`)**: on a temp
346
355
  `$DSH_HOME`, `dsh plugin --profile <p> add <this-repo>` installed the bundle in
347
356
  ~449 ms; `dsh --profile <p> --dump-config` composed the `iterate-plugin` bundle
@@ -379,8 +388,62 @@ following capabilities; elevated capability means a DSH Profile install stays
379
388
 
380
389
  ### Dependencies
381
390
 
382
- Runtime dependencies: `@deepseek-ai/cordis`, `@deepseek-ai/dsh-tools`,
383
- `@deepseek-ai/dsh-util-values`, `js-yaml` — all pinned exact versions. No
391
+ Runtime dependency: `js-yaml` (pinned exact version). The three DSH host
392
+ modules the plugin is built against — `@deepseek-ai/cordis`,
393
+ `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-util-values` — are declared as
394
+ **optional `peerDependencies`** so the host's own copies are used instead of a
395
+ second one bundled with the plugin (no duplicate module instances; `dsh`
396
+ supplies them). They stay pinned in `devDependencies` for local builds and
397
+ tests, in lockstep with the newest `dsh.compatibility.dshReleases` entry.
398
+
399
+ #### Upstream adoption (v3.8.0 → dsh `0.2.1-alpha.4`)
400
+
401
+ - **Adopted — the `engines.dsh` compatibility contract.** As of `0.2.1-alpha.4`
402
+ the dsh runtime decides a third-party plugin's compatibility **from
403
+ `engines.dsh` when it is declared** (a semver range, prereleases included),
404
+ ignoring `peerDependencies`; when it is absent the runtime falls back to
405
+ checking the exact `@deepseek-ai/dsh-*` peer versions. The plugin now declares
406
+ `engines.dsh: ">=0.1.1-rc.1"` — the same lower bound as the oldest
407
+ `dshReleases` entry — so a current runtime loads it without flagging the
408
+ exact-pinned peer list, while every host-specific capability stays
409
+ feature-detected (a missing `WorkingDirectoryService`, `ask.displayReason`,
410
+ `ctx.jobs`, etc. still degrade to the safe fallback). Earlier runtimes keep
411
+ using the `@deepseek-ai/dsh-*` peer versions, which are extended through
412
+ `0.2.1-alpha.4`.
413
+ - **`0.2.1-alpha.3` / `0.2.1-alpha.4` are otherwise host-level releases** — Web
414
+ sign-in, secret/token masking, minimal-mode image reading, `/compact`, plugin
415
+ hot-reload, custom plugin index sources, and per-model reasoning-level UI. None
416
+ expose a tool/agent/session API this plugin consumes (`defineTool`, `JsonValue`,
417
+ `exec.*`, `tools/pre-execute`, `presentCall`), so no further adoption is
418
+ required this cycle.
419
+ - All five `@deepseek-ai/*` packages are pinned to the exact `0.2.1-alpha.4`
420
+ (`@deepseek-ai/cordis` stays `4.0.5-alpha.2`).
421
+
422
+ #### Upstream adoption (v3.7.0 → dsh `0.2.1-alpha.2`)
423
+
424
+ - **Adopted — the mutable session working directory.** `src/working-directory.ts`
425
+ resolves the project root through `ctx.get('workingDirectory').get(session)`
426
+ when the host offers it (feature-detected; `header.cwd` fallback on a missing
427
+ service, a throwing service, hostile getters or a non-string result). Needed
428
+ because as of `0.2.1-alpha.2` the immutable header keeps the *original*
429
+ project by design, so reading it after a directory switch would anchor the
430
+ review to the wrong checkout.
431
+ - **Evaluated, not adopted:**
432
+ - *Session state-record APIs* (experimental plugins writing/reading their own
433
+ session records) — reaching into shell-owned record internals is fragile
434
+ across alpha releases, and iterate's cross-session state already lives in
435
+ `.iterate/`.
436
+ - *Shared `AGENTS.md` agents directory (`DSH_AGENTS_HOME`)* — a host-level
437
+ instruction concern; the plugin injects its own workflow prompt.
438
+ - *Experimental Git Worktrees plugin* — an upstream agent-workflow feature;
439
+ the plugin's own `git.use_worktree` config predates it and is unrelated
440
+ (per-round git hygiene for the iterate loop).
441
+ - *Removal of the `both` tool-presentation mode* — the plugin never set a
442
+ presentation mode (it renders `presentCall` cards), so nothing to migrate.
443
+ - *Installing the dsh CLI globally from the plugin* — disk cost for a plugin
444
+ that never shells out at runtime.
445
+ - All five `@deepseek-ai/*` packages are pinned to the exact `0.2.1-alpha.2`
446
+ (`@deepseek-ai/cordis` stays `4.0.5-alpha.1`). No
384
447
  install-time lifecycle scripts (`preinstall`/`install`/`postinstall`/`prepare`);
385
448
  only `prepublishOnly` (build) runs on publish.
386
449
 
package/README.zh-CN.md CHANGED
@@ -103,7 +103,7 @@ dsh plugin --profile web add iterate-plugin
103
103
  - 每轮修复后验证
104
104
  - 修复失败自动回滚
105
105
 
106
- ### UI 层(客户端免构建槽位,v3.6:11 个页签)
106
+ ### UI 层(客户端免构建槽位,v3.7:11 个页签,双语)
107
107
 
108
108
  - **收敛看板 `ConvergenceDashboard`**(`conversation.input.dock`)— 输入框上方实时显示轮次进度条、严重度统计、维度徽章、趋势迷你图;normal 模式另显示修复计数徽章;还有运行阶段芯片(当前工作流阶段 + 运行中/已结束)与 **task_mode 指示器(code / iterate)**。运行期间看板切换为"运行中"状态(显示轮次与阶段),并隐藏启动按钮。
109
109
  - **运行时观测台 `ObservatoryPanel`**(`conversation.input.dock`)— 输入框下方**十一个页签**运行时观测台:**实时活动流**(默认页,支持按活动类型筛选)+ **F1** 审查线程(全部展开/全部收起)、**F2** 收敛趋势(含跨轮对比表:每轮 findings / fixed / 严重度分布 / 验证结果)、**F3** 发现定位(按严重度/维度/关键词筛选)、**F4** 修复与回滚、**F5** 断点恢复、**F6** 运行控制台、**F7** 决策时间线(按类型/轮次筛选与关键词搜索)、**F8** 质量门禁、**F9** 经验银行、**F10** 防御事件。数字键快捷键:`1`–`9` 跳到 F1–F9,`0` 到 F10,`-` 回实时活动流。**F8/F9/F10 为实时数据**——扫描会话流中最近一次 `iterate_quality_gate` / `iterate_experience` / `iterate_defense_events` 结果并渲染真实数据(PASS/FAIL 徽章 + 分维度评分条、经验条目(含采纳按钮 + 客户端搜索)、防御类型计数芯片 + 筛选后的事件流);会话流中没有数据时区分"磁盘无数据"与"尚未拉取",并提供**拉取磁盘快照**指令(让模型调用只读工具、把 `.iterate/` 产物回显进会话流)。一键导出全部观测数据为 JSON——manifest + live + `qualityGate` / `experienceBank` / `defenseEvents` / `report`(优先下载,失败回退复制)。**v3.5.3 新增重置操作**:F5 复制 `iterate_checkpoint clear`、F8 复制 `iterate_quality_gate clear`、F10 复制 `iterate_defense_events clear` 指令。
@@ -111,7 +111,8 @@ dsh plugin --profile web add iterate-plugin
111
111
  - **收敛统计卡片 `StatsCard`**(`conversation.chat.turnTail`)— 无 findings 时显示收敛统计、历史轮次、趋势图、完成摘要。
112
112
  - **iterate 主题皮肤**(`theme.overrideTokens`)— 暖琥珀配色的 13 个 `--dsw-*` token 覆盖,明暗双模式,可在设置页开关。
113
113
  - **进度胶囊 `ProgressCapsule`**(`shell.overlay`)— 每轮完成 / 收敛时右下角弹出通知(含收敛确认)。
114
- - **iterate 设置区 `SettingsPanel`**(`settings.section`)— 主题开关、分诊持久化说明、配置管理指引(覆盖 `validation.commands` / `language` / `personalization.known_intentional`,并显式警告"validation.commands 为空 = 本轮不受任何测试保护";字段选择器可生成对应的 `iterate_config write` 指令)、运行时状态概览(产物布局 + 查看/清理工具指引 + 磁盘快照拉取)、一键清空分诊数据。
114
+ - **iterate 设置区 `SettingsPanel`**(`settings.section`)— 主题开关、分诊持久化说明、配置管理指引(覆盖 `validation.commands` / `language` / `personalization.known_intentional`,并显式警告"validation.commands 为空 = 本轮不受任何测试保护";字段选择器可生成对应的 `iterate_config write` 指令),**v3.7:新增「回读当前配置」补齐写入 → 回读闭环**——复制 `iterate_config read` 指令,模型把结果回显进会话后,卡片就地展示所选字段的当前值;运行时状态概览(产物布局 + 查看/清理工具指引 + 磁盘快照拉取)、一键清空分诊数据。
115
+ - **双语 UI(v3.7)**——所有界面文案(面板、空态、页签、徽标、设置、停止原因与完成汇总)均提供简体中文与 English。在**设置 → 界面语言 / Interface language**切换;选择持久化在 `localStorage`(`iterate-ui-lang`),否则按 `navigator.language` 自动探测(已保存的选择始终优先,且自动探测不会写入偏好)。**面向模型的文案有意保持中文**——`build*Instruction` 指令与可粘贴指引是与 `SKILL.md` 对齐的契约文本,因此两处展开的指引带「中文(粘贴给模型的契约文案)」标签,避免被误当成漏翻。
115
116
 
116
117
  UI 层为**防御式设计**:`slots` / `theme` / `React` 任一不可用时自动降级,不会崩溃客户端。
117
118
 
@@ -124,7 +125,8 @@ UI 层为**防御式设计**:`slots` / `theme` / `React` 任一不可用时自
124
125
  - **断点续跑** — 长迭代在每轮开头保存 checkpoint,中断后可恢复进度
125
126
  - **历史审计** — `iterate_history` 读取决策日志(可按 `type` / `since` / `round` / 修复文件 `file` / `limit` 过滤,默认取最新 50 条,上限 200 条)与修复注册表汇总(各轮 fixed/failed 计数,按同一 round/file 过滤收窄),审查运行过程与修复明细
126
127
  - **运行时清理** — `iterate_prune` 清理过期的决策日志条目、陈旧断点、孤儿修复备份、空轮次与原子写入崩溃残留的临时文件;默认 dry-run 只报告不删除,显式 `dryRun:false` 才真正清理,每次清理写入决策日志
127
- - **配置读写** — `iterate_config` 支持带校验、备份、回滚的局部写入
128
+ - **配置读写** — `iterate_config` 支持带校验、备份、回滚的局部写入;v3.7 起设置页还能**回读生效配置**(指令 + 就地展示当前值)
129
+ - **跟随会话的工作目录(v3.7)** — 在 dsh ≥ `0.2.1-alpha.2` 上,插件通过宿主的 `WorkingDirectoryService` 解析项目根,而不是不可变的 `Session.header.cwd`:会话中途切换目录后,`.iterate/` 下的一切(配置、决策日志、断点、修复备份、审批策略)都会跟着当前工作区走。该服务按特性探测,任何失败路径(服务缺失/抛错/敌意 getter/非字符串结果)都回退到 `header.cwd`,旧宿主行为完全不变。
128
130
  - **v3.2 经验银行** — `iterate_experience` 以检索 / 过滤 / 采纳查阅历史修复与模式,并可 `add` 持久化新的已验证修复——重复添加同一 pattern + dimension 时累加命中次数而非重复写入
129
131
  - **v3.2 质量门禁** — `iterate_quality_gate` 读取质量门禁状态(各维度收敛率 + PASS/FAIL),并可基于本轮 findings / 验证结果 `compute` 重新计算并持久化一份新的质量凭证(收敛率来自 `findingsByRound` 的真实收敛序列)
130
132
  - **v3.2 防御事件流** — `iterate_defense_events` 查询防御事件(前置条件失败、回滚、不变量违反、假设证伪),并可 `record` 记录新事件;可读标签跟随项目语言(en / zh)
@@ -208,6 +210,8 @@ pnpm add /path/to/iterate-skill/harness/iterate-plugin
208
210
 
209
211
  设置区复制一份配置编辑指引,列出支持的 `iterate.config.yaml` 字段——包括 `validation.commands`(并显式警告:留空意味着每一轮迭代都不受任何测试保护)——并提供**字段选择器**:选中字段后,按钮会生成对应的 `iterate_config write` 指令(schema 校验、自动备份、失败自动回滚)供你粘贴回会话。
210
212
 
213
+ **回读实际写入的结果**——配套的**「回读当前配置」**按钮复制 `iterate_config` 的 `{ operation: "read" }` 指令;模型把结果回显进会话后,卡片会在选择器下方展示所选字段的当前值(数组内联展示,如 `["npm test","npm run lint"]`)。三种状态被诚实区分:*尚未回读*、*当前值*、*已回读但没有该字段*(说明走的是内置默认值)。若剪贴板被拒绝,回读指令会在面板中展开供手动复制——按钮不会成为死路。
214
+
211
215
  ---
212
216
 
213
217
  ## 💬 使用
@@ -330,7 +334,7 @@ validation:
330
334
  ### Node.js / DSH 兼容范围
331
335
 
332
336
  - **Node.js**:`>=20`(`package.json` 的 `engines.node`)。
333
- - **DSH**:`package.json` 的 `dsh.compatibility.dshReleases` 字段是发布兼容性的**唯一真源**(本 README 逐字镜像该字段;两者需同步更新)。以下官方版本声明为 `compatible`:`0.1.1-rc.1`、`0.1.2-alpha.4`、`0.1.2-alpha.5`、`0.1.2-rc.1`、`0.1.3-alpha.1`、`0.1.5-alpha.1`、`0.1.5-alpha.2`、`0.1.5-rc.1`、`0.1.5-rc.2`、`0.1.6-alpha.1`、`0.1.6-alpha.2`、`0.1.7-alpha.1`、`0.1.7-alpha.2`、`0.1.7-rc.1`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.1`(共 18 个版本,`0.1.1-rc.1` … `0.2.1-alpha.1`)。插件基于 `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.2.1-alpha.1` 构建,只使用公开契约(工具注册、客户端 slots + theme、bundle patch)。
337
+ - **DSH**:`package.json` 的 `dsh.compatibility.dshReleases` 字段是发布兼容性的**唯一真源**(本 README 逐字镜像该字段;两者需同步更新)。以下官方版本声明为 `compatible`:`0.1.1-rc.1`、`0.1.2-alpha.4`、`0.1.2-alpha.5`、`0.1.2-rc.1`、`0.1.3-alpha.1`、`0.1.5-alpha.1`、`0.1.5-alpha.2`、`0.1.5-rc.1`、`0.1.5-rc.2`、`0.1.6-alpha.1`、`0.1.6-alpha.2`、`0.1.7-alpha.1`、`0.1.7-alpha.2`、`0.1.7-rc.1`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.1`、`0.2.1-alpha.2`、`0.2.1-alpha.3`、`0.2.1-alpha.4`(共 21 个版本,`0.1.1-rc.1` … `0.2.1-alpha.4`)。插件基于 `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.2.1-alpha.4` 构建,只使用公开契约(工具注册、客户端 slots + theme、bundle patch)。插件同时声明 `engines.dsh`(`>=0.1.1-rc.1`),使 dsh ≥ 0.2.1-alpha.4 运行时只依据该范围判断兼容性(更早的运行时仍回退校验 `package.json` 中列出的 `@deepseek-ai/dsh-*` peer 版本)。
334
338
  - **一次性 Profile 证据(真实,`dsh` CLI `0.1.1-rc.1`)**:在临时 `$DSH_HOME` 下
335
339
  `dsh plugin --profile <p> add <本仓库>` 约 449ms 完成安装;`dsh --profile <p>
336
340
  --dump-config` 正确合成 `iterate-plugin` bundle patch;`dsh plugin --profile <p>
@@ -360,8 +364,48 @@ validation:
360
364
 
361
365
  ### 依赖
362
366
 
363
- 运行时依赖:`@deepseek-ai/cordis`、`@deepseek-ai/dsh-tools`、
364
- `@deepseek-ai/dsh-util-values`、`js-yaml`——全部精确锁版。无安装期生命周期脚本
367
+ 运行时依赖:`js-yaml`(精确锁版)。本插件构建所依赖的三个 DSH 宿主模块——
368
+ `@deepseek-ai/cordis`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-util-values`
369
+ ——已声明为**可选 `peerDependencies`**,直接复用宿主自带的那一份,避免插件内
370
+ 再打包一份造成双模块实例(由 `dsh` 提供);同时在 `devDependencies` 中精确锁版,
371
+ 供本地构建与测试,并与 `dsh.compatibility.dshReleases` 的最新条目保持一致。
372
+
373
+ #### 上游采纳记录(v3.8.0 → dsh `0.2.1-alpha.4`)
374
+
375
+ - **已采纳——`engines.dsh` 兼容契约。** 自 `0.2.1-alpha.4` 起,dsh 运行时**在声明了
376
+ `engines.dsh` 时只依据该字段**(一个含预发布版本的 semver 范围)判断第三方插件兼容性,
377
+ 忽略 `peerDependencies`;未声明时才回退逐个校验精确的 `@deepseek-ai/dsh-*` peer 版本。
378
+ 插件现已声明 `engines.dsh: ">=0.1.1-rc.1"`(与最早的 `dshReleases` 条目同下界),
379
+ 使当前运行时加载时不再因精确锁版的 peer 列表被判不兼容;而所有宿主相关能力依旧
380
+ 特性探测(缺少 `WorkingDirectoryService`、`ask.displayReason`、`ctx.jobs` 等一律
381
+ 降级到安全回退)。更早的运行时仍走 `@deepseek-ai/dsh-*` peer 版本,已扩展至
382
+ `0.2.1-alpha.4`。
383
+ - **`0.2.1-alpha.3` / `0.2.1-alpha.4` 其余均为宿主级变更**——Web 登录、密钥/令牌脱敏、
384
+ 极简模式读图、`/compact`、插件热重载、自定义插件索引源、按模型配置推理等级 UI。
385
+ 都未暴露本插件消费的 tool/agent/session API(`defineTool`、`JsonValue`、`exec.*`、
386
+ `tools/pre-execute`、`presentCall`),本轮无需进一步采纳。
387
+ - 五个 `@deepseek-ai/*` 包全部精确锁到 `0.2.1-alpha.4`
388
+ (`@deepseek-ai/cordis` 保持 `4.0.5-alpha.2`)。
389
+
390
+ #### 上游采纳记录(v3.7.0 → dsh `0.2.1-alpha.2`)
391
+
392
+ - **已采纳——可变的会话工作目录。** `src/working-directory.ts` 通过
393
+ `ctx.get('workingDirectory').get(session)` 解析项目根(特性探测;服务缺失、
394
+ 抛错、敌意 getter 或非字符串结果一律回退 `header.cwd`)。必须采纳的原因:
395
+ 自 `0.2.1-alpha.2` 起,不可变的 header 按设计保留*原始*项目,切换目录后再读它
396
+ 会把评审锚定到错误的检出。
397
+ - **已评估、未采纳:**
398
+ - *Session 状态记录接口*(实验插件读写自己的会话记录)——依赖 shell 内部记录结构,
399
+ 跨 alpha 版本极脆弱;iterate 的跨会话状态本就存放在 `.iterate/`。
400
+ - *共享 agents 目录的 `AGENTS.md`(`DSH_AGENTS_HOME`)*——属于宿主的全局指令机制,
401
+ 插件注入的是自己的工作流提示词。
402
+ - *实验性 Git Worktrees 插件*——上游的 agent 工作流特性;插件自己的
403
+ `git.use_worktree` 配置早于它,且是另一回事(iterate 循环的逐轮 git 卫生)。
404
+ - *移除 `both` 混合展示模式*——插件从未设置展示模式(用的是 `presentCall` 卡片),
405
+ 无需迁移。
406
+ - *从插件全局安装 dsh CLI*——运行时从不 shell 调用,纯占磁盘。
407
+ - 五个 `@deepseek-ai/*` 包全部精确锁到 `0.2.1-alpha.2`
408
+ (`@deepseek-ai/cordis` 保持 `4.0.5-alpha.1`)。无安装期生命周期脚本
365
409
  (`preinstall`/`install`/`postinstall`/`prepare`);仅 `prepublishOnly`(构建)在发布时运行。
366
410
 
367
411
  ### 失败边界
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, dirname, join, resolve, sep } from 'node:path';
4
4
  import yaml from 'js-yaml';
5
+ import { sessionCwdOf } from "./working-directory.js";
5
6
  /**
6
7
  * Load and parse iterate.config.yaml from the project root.
7
8
  * Returns null if the file is missing or invalid.
@@ -701,9 +702,14 @@ export function resolveProjectRoot(input, sessionCwd) {
701
702
  /**
702
703
  * Thin adapter for tool `execute(args, exec)` bodies: pull the session cwd
703
704
  * from the DSH run context and hand it to {@link resolveProjectRoot}.
705
+ *
706
+ * The cwd source is the EFFECTIVE session directory (dsh 0.2.1-alpha.2's
707
+ * `workingDirectory` service when the host provides one, else the immutable
708
+ * `header.cwd` — see `./working-directory.ts`), so a session whose directory
709
+ * was switched mid-run still resolves the project it is actually editing.
704
710
  */
705
711
  export function resolveProjectRootForExec(exec, input) {
706
- return resolveProjectRoot(input, exec?.agent?.session?.header?.cwd);
712
+ return resolveProjectRoot(input, sessionCwdOf(exec));
707
713
  }
708
714
  /**
709
715
  * Resolve the default working directory for tools invoked without an explicit
package/dist/file-lock.js CHANGED
@@ -26,10 +26,17 @@
26
26
  * `withProjectLock` is therefore NOT reentrant and must not be nested for the
27
27
  * same (projectRoot, name).
28
28
  */
29
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync, } from 'node:fs';
29
+ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync, } from 'node:fs';
30
30
  import { join } from 'node:path';
31
31
  const LOCK_DIR = '.iterate';
32
- const DEFAULT_WAIT_MS = 5000;
32
+ // Bounded wait before degrading to "proceed unlocked". `acquireProjectLock`
33
+ // spins on `Atomics.wait`, which BLOCKS the single plugin thread for the whole
34
+ // budget — so a long default wedges unrelated tool calls and the UI heartbeat
35
+ // whenever a sibling process holds the lock. Healthy holders release in
36
+ // microseconds, so 1000ms is very generous; anything slower is treated as
37
+ // contention and degrades to the historical unlocked behavior rather than
38
+ // stalling the event loop for five seconds.
39
+ const DEFAULT_WAIT_MS = 1000;
33
40
  const DEFAULT_STALE_MS = 10000;
34
41
  const POLL_MS = 25;
35
42
  /** True when a pid refers to a live process (or we lack permission to tell). */
@@ -60,14 +67,17 @@ function lockFileName(name) {
60
67
  return `.${name}.lock`;
61
68
  }
62
69
  /**
63
- * Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`.
70
+ * Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`,
71
+ * reporting whether it was actually taken.
64
72
  *
65
- * Returns a release function: always callable, idempotent, never throws.
66
- * A no-op (but callable) release is returned when the lock directory cannot be
67
- * created or the wait times out — callers then proceed unlocked, which is the
68
- * historical best-effort behavior, rather than failing the operation.
73
+ * The returned release function is always callable, idempotent, and never
74
+ * throws. `acquired:false` (with a no-op release) is returned when the lock
75
+ * directory cannot be created or the wait times out — callers then proceed
76
+ * unlocked, which is the historical best-effort behavior, rather than failing
77
+ * the operation. Callers that MUST hold the mutex (e.g. a log rewrite that
78
+ * renames the file) branch on `acquired` and refuse to run unlocked.
69
79
  */
70
- export function acquireProjectLock(projectRoot, name, options = {}) {
80
+ export function acquireProjectLockChecked(projectRoot, name, options = {}) {
71
81
  const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
72
82
  ? options.waitMs
73
83
  : DEFAULT_WAIT_MS;
@@ -80,7 +90,7 @@ export function acquireProjectLock(projectRoot, name, options = {}) {
80
90
  mkdirSync(dir, { recursive: true });
81
91
  }
82
92
  catch {
83
- return () => { };
93
+ return { acquired: false, release: () => { } };
84
94
  }
85
95
  }
86
96
  const lockPath = join(dir, lockFileName(name));
@@ -92,7 +102,7 @@ export function acquireProjectLock(projectRoot, name, options = {}) {
92
102
  }
93
103
  catch (err) {
94
104
  if (err.code !== 'EEXIST')
95
- return () => { };
105
+ return { acquired: false, release: () => { } };
96
106
  // Lock exists — steal it when the owner is gone or the lock is ancient.
97
107
  let stale = false;
98
108
  try {
@@ -118,11 +128,21 @@ export function acquireProjectLock(projectRoot, name, options = {}) {
118
128
  continue;
119
129
  }
120
130
  if (Date.now() - start > waitMs)
121
- return () => { };
131
+ return { acquired: false, release: () => { } };
122
132
  sleepSync(POLL_MS);
123
133
  continue;
124
134
  }
125
- // Owned the lock: stamp the holder pid, then release once on return.
135
+ // Owned the lock: capture the created inode (for the ownership-checked
136
+ // release), stamp the holder pid, then release once on return.
137
+ let ino = 0;
138
+ try {
139
+ ino = fstatSync(fd).ino;
140
+ }
141
+ catch {
142
+ // inode unavailable on this filesystem — the release falls back to the
143
+ // pid-stamp check only.
144
+ ino = 0;
145
+ }
126
146
  try {
127
147
  writeSync(fd, String(process.pid));
128
148
  }
@@ -135,19 +155,88 @@ export function acquireProjectLock(projectRoot, name, options = {}) {
135
155
  catch {
136
156
  // best-effort
137
157
  }
138
- let released = false;
139
- return () => {
140
- if (released)
141
- return;
142
- released = true;
158
+ return { acquired: true, release: makeLockRelease(lockPath, ino) };
159
+ }
160
+ }
161
+ /**
162
+ * Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`.
163
+ *
164
+ * Returns a release function: always callable, idempotent, never throws.
165
+ * A no-op (but callable) release is returned when the lock directory cannot be
166
+ * created or the wait times out — callers then proceed unlocked, which is the
167
+ * historical best-effort behavior, rather than failing the operation.
168
+ */
169
+ export function acquireProjectLock(projectRoot, name, options = {}) {
170
+ return acquireProjectLockChecked(projectRoot, name, options).release;
171
+ }
172
+ /**
173
+ * Build the release function for a lock file this process just acquired.
174
+ *
175
+ * Advisory locks here are STOLEN when the recorded owner dies or the mtime
176
+ * goes ancient: the steal unlinks our file and creates a new one stamped with
177
+ * the STEALER's pid. An unguarded release would then unlink the new holder's
178
+ * lock — turning our release into a double-release that admits a third process
179
+ * into a "held" critical section. So the release re-reads the stamp and only
180
+ * unlinks a file that is still ours (same pid) or unstamped (the shape left
181
+ * when our own advisory pid write failed, or when the content is not a pid).
182
+ *
183
+ * Always callable, idempotent, and never throws.
184
+ */
185
+ /**
186
+ * Build the release function for a lock file this process just acquired.
187
+ *
188
+ * Advisory locks here are STOLEN when the recorded owner dies or the mtime
189
+ * goes ancient: the steal unlinks our file and creates a new one stamped with
190
+ * the STEALER's pid. An unguarded release would then unlink the new holder's
191
+ * lock — turning our release into a double-release that admits a third process
192
+ * into a "held" critical section. So the release re-reads the stamp and only
193
+ * unlinks a file that is still ours: same pid (or unstamped) AND, when we know
194
+ * the inode we created, the SAME inode still at the path. The inode check closes
195
+ * the read-then-unlink window where the stamp check alone could race a steal
196
+ * (our pid is alive, but the file was already replaced between the two calls).
197
+ *
198
+ * Always callable, idempotent, and never throws.
199
+ *
200
+ * @param lockPath - the lock file path.
201
+ * @param ino - the inode captured at acquisition (0 = unknown; skip the check).
202
+ */
203
+ export function makeLockRelease(lockPath, ino) {
204
+ let released = false;
205
+ return () => {
206
+ if (released)
207
+ return;
208
+ released = true;
209
+ let raw = '';
210
+ try {
211
+ raw = readFileSync(lockPath, 'utf-8').trim();
212
+ }
213
+ catch {
214
+ // Already gone (or unreadable) — nothing of ours left to remove.
215
+ return;
216
+ }
217
+ // A stamp naming another pid means the lock was stolen and reassigned:
218
+ // it is no longer ours to delete.
219
+ if (raw !== '' && Number(raw) !== process.pid)
220
+ return;
221
+ // The path must still refer to the file WE created. A stealer unlinked our
222
+ // file and created a fresh one; that replacement carries a different inode,
223
+ // so unlinking by path would delete the NEW holder's lock.
224
+ if (typeof ino === 'number' && ino !== 0) {
143
225
  try {
144
- unlinkSync(lockPath);
226
+ if (statSync(lockPath).ino !== ino)
227
+ return;
145
228
  }
146
229
  catch {
147
- // already gone
230
+ return; // vanished mid-release — nothing of ours left
148
231
  }
149
- };
150
- }
232
+ }
233
+ try {
234
+ unlinkSync(lockPath);
235
+ }
236
+ catch {
237
+ // already gone
238
+ }
239
+ };
151
240
  }
152
241
  /**
153
242
  * Run `fn` while holding the advisory lock named `name`.
package/dist/git-scope.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * git repo.
22
22
  */
23
23
  import { execFile } from 'node:child_process';
24
- import { existsSync, statSync } from 'node:fs';
24
+ import { lstatSync, realpathSync } from 'node:fs';
25
25
  import { join, sep } from 'node:path';
26
26
  /**
27
27
  * Parse `git diff --name-only -z` stdout into a list of relative paths.
@@ -112,6 +112,15 @@ export function parseChangedFiles(stdout) {
112
112
  * Keep only entries that resolve to an existing regular file under `root`.
113
113
  * Traversal-safe: rejects absolute paths and any relative path that would
114
114
  * escape `root` via `..` (resolved against the root before stat).
115
+ *
116
+ * Symlink handling: `statSync` (and `existsSync`) FOLLOW symlinks, so a git
117
+ * entry that is a link to a file OUTSIDE the project would pass a path-only
118
+ * containment check and enter the changed-only scope. Such a file is then
119
+ * rejected by the evidence gate (realpath out of bounds → `file_not_found`),
120
+ * so every finding on it would fail closed as `EVIDENCE_VIOLATION` forever —
121
+ * a permanent false positive for repos with dotfile or shared-config links.
122
+ * `lstatSync` distinguishes the link itself; a link whose REAL target escapes
123
+ * `root` is dropped here instead.
115
124
  */
116
125
  export function filterExistingFiles(root, files) {
117
126
  const out = [];
@@ -123,7 +132,18 @@ export function filterExistingFiles(root, files) {
123
132
  if (!candidate.startsWith(rootPrefix) && candidate !== root)
124
133
  continue;
125
134
  try {
126
- if (existsSync(candidate) && statSync(candidate).isFile())
135
+ const st = lstatSync(candidate);
136
+ if (st.isFile()) {
137
+ out.push(rel);
138
+ continue;
139
+ }
140
+ if (!st.isSymbolicLink())
141
+ continue;
142
+ // A link whose real target stays inside the project is a legitimate,
143
+ // reviewable file; one that escapes is excluded from scope (fail-closed
144
+ // at the source instead of as a permanent evidence violation).
145
+ const real = realpathSync(candidate);
146
+ if (real === root || real.startsWith(rootPrefix))
127
147
  out.push(rel);
128
148
  }
129
149
  catch {
package/dist/index.js CHANGED
@@ -43,6 +43,7 @@ import { registerQualityGateTool } from "./tools/quality-gate.js";
43
43
  import { registerDefenseEventsTool } from "./tools/defense-events.js";
44
44
  import { registerSessionHooks } from "./session-hooks.js";
45
45
  import { registerLiveCapture } from "./live.js";
46
+ import { installWorkingDirectoryContext } from "./working-directory.js";
46
47
  import { ITERATE_SKILL_PROMPT } from "./skill-prompt.js";
47
48
  export const name = 'iterate-plugin';
48
49
  export const inject = ['tools', 'systemPrompt'];
@@ -54,6 +55,12 @@ export function apply(ctx) {
54
55
  // running with NO `tools/pre-execute` approval gate — a fail-open hole
55
56
  // created by load order. Registering the gate first means a partial load
56
57
  // can only ever err on the side of MORE gating, never less.
58
+ //
59
+ // The working-directory context is installed first for the same reason:
60
+ // every cwd read below (gate policy, live capture, tool project roots)
61
+ // must see dsh 0.2.1-alpha.2+ directory switches, and installing it last
62
+ // would leave a partially-loaded plugin resolving the wrong workspace.
63
+ installWorkingDirectoryContext(ctx);
57
64
  registerSessionHooks(ctx);
58
65
  registerLiveCapture(ctx);
59
66
  // 1. Register the 17 tools (14 original + 3 v3.0). Each registration is
package/dist/live.js CHANGED
@@ -31,6 +31,7 @@ import { existsSync } from 'node:fs';
31
31
  import { writeTextAtomicAsync } from "./atomic-fs.js";
32
32
  import { join } from 'node:path';
33
33
  import { loadEffectiveConfig, resolveProjectRoot } from "./config-loader.js";
34
+ import { sessionCwdOf } from "./working-directory.js";
34
35
  /** Keep at most this many live activity entries. */
35
36
  export const LIVE_MAX_ENTRIES = 300;
36
37
  /** Rewrite the live file when its byte size exceeds this threshold. */
@@ -107,7 +108,7 @@ export function appendLive(projectRoot, entry) {
107
108
  }
108
109
  /** Resolve the project root a tool execution belongs to, if any. */
109
110
  function projectRootOf(exec) {
110
- const cwd = exec.agent?.session?.header?.cwd;
111
+ const cwd = sessionCwdOf(exec);
111
112
  if (!cwd)
112
113
  return null;
113
114
  const resolved = resolveProjectRoot(undefined, cwd);
@@ -54,6 +54,7 @@
54
54
  */
55
55
  import { loadEffectiveConfig, resolveProjectRoot } from "./config-loader.js";
56
56
  import { decideApproval, isDestructiveIterateTool } from "./approval-gate.js";
57
+ import { sessionCwdOf } from "./working-directory.js";
57
58
  /** Structured error identity attached to policy denials (dsh 0.1.6-alpha.1:
58
59
  * `deny.info` lets durable projections / retry / replay distinguish an
59
60
  * approval-policy denial from a tool-thrown failure). */
@@ -111,7 +112,11 @@ export function gateDecision(exec) {
111
112
  if (typeof p === 'string')
112
113
  argPath = p;
113
114
  }
114
- sessionCwd = exec?.agent?.session?.header?.cwd;
115
+ // Effective session directory (see ./working-directory.ts): a session whose
116
+ // directory was switched via dsh 0.2.1-alpha.2's `working_directory` tool
117
+ // must have its POLICY read from the workspace it is now in — `header.cwd`
118
+ // alone would read the original project's approval policy.
119
+ sessionCwd = sessionCwdOf(exec);
115
120
  }
116
121
  catch {
117
122
  // hostile/proxied exec — fall through with both undefined (defaults to ask)
@@ -134,7 +134,7 @@ for (let r = 1; r <= maxRounds; r++) {
134
134
  (attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
135
135
  '\\n Already-known findings (do NOT re-report): ' +
136
136
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
137
- return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
137
+ return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta?.findingsSchema }, backend))
138
138
  }))
139
139
  // A per-dimension reviewer subagent resolves \`null\` on child failure; the
140
140
  // aggregate below would report an all-failed round as an empty round, and
@@ -463,7 +463,7 @@ for (let r = startRound; r <= maxRounds; r++) {
463
463
  (steering ? '\\n STEERING — read this first: ' + steering : '') +
464
464
  (attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
465
465
  '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
466
- return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
466
+ return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta?.findingsSchema }, backend))
467
467
  }))
468
468
  // ALL reviewers returning null (child failures) means this round produced
469
469
  // no usable output — an empty aggregate must not read like a clean
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
12
12
  import { defineTool } from '@deepseek-ai/dsh-tools';
13
13
  import { resolveProjectRootForExec } from "../config-loader.js";
14
14
  import { writeJsonAtomic } from "../atomic-fs.js";
15
+ import { withProjectLock } from "../file-lock.js";
15
16
  import { checkpointPath, iterateDir, transcriptPath } from "../paths.js";
16
17
  import { readRegistry } from "./fix.js";
17
18
  import { readDecisionEntries } from "./decision-log.js";
@@ -289,23 +290,29 @@ export function registerCheckpointTool(ctx) {
289
290
  return { operation: 'load', ok: true, checkpoint: checkpoint };
290
291
  }
291
292
  if (args.operation === 'resume') {
292
- const current = readCheckpoint(projectRoot);
293
- if (!current) {
294
- return { operation: 'resume', ok: false, error: 'no checkpoint to resume — run `save` first' };
295
- }
296
- const resumed = {
297
- ...current,
298
- resumeCount: (current.resumeCount ?? 0) + 1,
299
- updatedAt: new Date().toISOString(),
300
- };
301
- try {
302
- mkdirSync(iterateDir(projectRoot), { recursive: true });
303
- writeJsonAtomic(checkpointPath(projectRoot), resumed);
304
- }
305
- catch (err) {
306
- return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` };
307
- }
308
- return { operation: 'resume', ok: true, checkpoint: resumed };
293
+ // Read-modify-write across processes: a concurrent resume in a
294
+ // sibling process would read the same resumeCount and write it back,
295
+ // losing one increment. Serialize on a checkpoint lock (the same
296
+ // advisory pattern the config/triage/transcript writers use).
297
+ return withProjectLock(projectRoot, 'checkpoint', () => {
298
+ const current = readCheckpoint(projectRoot);
299
+ if (!current) {
300
+ return { operation: 'resume', ok: false, error: 'no checkpoint to resume — run `save` first' };
301
+ }
302
+ const resumed = {
303
+ ...current,
304
+ resumeCount: (current.resumeCount ?? 0) + 1,
305
+ updatedAt: new Date().toISOString(),
306
+ };
307
+ try {
308
+ mkdirSync(iterateDir(projectRoot), { recursive: true });
309
+ writeJsonAtomic(checkpointPath(projectRoot), resumed);
310
+ }
311
+ catch (err) {
312
+ return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` };
313
+ }
314
+ return { operation: 'resume', ok: true, checkpoint: resumed };
315
+ });
309
316
  }
310
317
  if (args.operation === 'clear') {
311
318
  const existed = existsSync(checkpointPath(projectRoot));