iterate-plugin 3.7.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 +37 -5
- package/README.zh-CN.md +23 -3
- package/dist/file-lock.js +75 -14
- package/dist/git-scope.js +22 -2
- package/dist/skill-prompt.js +2 -2
- package/dist/tools/checkpoint.js +24 -17
- package/dist/tools/config.js +42 -33
- package/dist/tools/decision-log.js +13 -93
- package/dist/tools/transcript.js +74 -44
- package/dist/tools/triage.js +52 -45
- package/dist/transcript.js +2 -2
- package/lib/client.js +34 -10
- package/lib/parse.js +29 -6
- package/package.json +31 -13
- package/src/client/index.ts +22 -4
- package/src/file-lock.ts +84 -14
- package/src/git-scope.ts +21 -2
- package/src/skill-prompt.ts +2 -2
- package/src/tools/checkpoint.ts +23 -16
- package/src/tools/config.ts +40 -31
- package/src/tools/decision-log.ts +12 -80
- package/src/tools/transcript.ts +72 -43
- package/src/tools/triage.ts +47 -40
- package/src/transcript.ts +2 -2
- package/src/types.ts +1 -1
package/README.md
CHANGED
|
@@ -342,11 +342,15 @@ All runtime state lives under `.iterate/` at the project root (can be excluded v
|
|
|
342
342
|
`0.1.2-rc.1`, `0.1.3-alpha.1`, `0.1.5-alpha.1`, `0.1.5-alpha.2`,
|
|
343
343
|
`0.1.5-rc.1`, `0.1.5-rc.2`, `0.1.6-alpha.1`, `0.1.6-alpha.2`,
|
|
344
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`,
|
|
345
|
-
`0.2.0-rc.2`, `0.2.1-alpha.1`, `0.2.1-alpha.2
|
|
346
|
-
`0.2.1-alpha.
|
|
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`).
|
|
347
348
|
The plugin is built against `@deepseek-ai/dsh-tools` /
|
|
348
|
-
`@deepseek-ai/dsh-util-values` `0.2.1-alpha.
|
|
349
|
+
`@deepseek-ai/dsh-util-values` `0.2.1-alpha.4` and uses only public contracts
|
|
349
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`).
|
|
350
354
|
- **Disposable-Profile evidence (real, `dsh` CLI `0.1.1-rc.1`)**: on a temp
|
|
351
355
|
`$DSH_HOME`, `dsh plugin --profile <p> add <this-repo>` installed the bundle in
|
|
352
356
|
~449 ms; `dsh --profile <p> --dump-config` composed the `iterate-plugin` bundle
|
|
@@ -384,8 +388,36 @@ following capabilities; elevated capability means a DSH Profile install stays
|
|
|
384
388
|
|
|
385
389
|
### Dependencies
|
|
386
390
|
|
|
387
|
-
Runtime
|
|
388
|
-
`@deepseek-ai/
|
|
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`).
|
|
389
421
|
|
|
390
422
|
#### Upstream adoption (v3.7.0 → dsh `0.2.1-alpha.2`)
|
|
391
423
|
|
package/README.zh-CN.md
CHANGED
|
@@ -334,7 +334,7 @@ validation:
|
|
|
334
334
|
### Node.js / DSH 兼容范围
|
|
335
335
|
|
|
336
336
|
- **Node.js**:`>=20`(`package.json` 的 `engines.node`)。
|
|
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`(共
|
|
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 版本)。
|
|
338
338
|
- **一次性 Profile 证据(真实,`dsh` CLI `0.1.1-rc.1`)**:在临时 `$DSH_HOME` 下
|
|
339
339
|
`dsh plugin --profile <p> add <本仓库>` 约 449ms 完成安装;`dsh --profile <p>
|
|
340
340
|
--dump-config` 正确合成 `iterate-plugin` bundle patch;`dsh plugin --profile <p>
|
|
@@ -364,8 +364,28 @@ validation:
|
|
|
364
364
|
|
|
365
365
|
### 依赖
|
|
366
366
|
|
|
367
|
-
|
|
368
|
-
`@deepseek-ai/dsh-util-values
|
|
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`)。
|
|
369
389
|
|
|
370
390
|
#### 上游采纳记录(v3.7.0 → dsh `0.2.1-alpha.2`)
|
|
371
391
|
|
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
|
-
|
|
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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* created or the wait times out — callers then proceed
|
|
68
|
-
* historical best-effort behavior, rather than failing
|
|
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
|
|
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:
|
|
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,9 +155,20 @@ export function acquireProjectLock(projectRoot, name, options = {}) {
|
|
|
135
155
|
catch {
|
|
136
156
|
// best-effort
|
|
137
157
|
}
|
|
138
|
-
return makeLockRelease(lockPath);
|
|
158
|
+
return { acquired: true, release: makeLockRelease(lockPath, ino) };
|
|
139
159
|
}
|
|
140
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
|
+
}
|
|
141
172
|
/**
|
|
142
173
|
* Build the release function for a lock file this process just acquired.
|
|
143
174
|
*
|
|
@@ -151,7 +182,25 @@ export function acquireProjectLock(projectRoot, name, options = {}) {
|
|
|
151
182
|
*
|
|
152
183
|
* Always callable, idempotent, and never throws.
|
|
153
184
|
*/
|
|
154
|
-
|
|
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) {
|
|
155
204
|
let released = false;
|
|
156
205
|
return () => {
|
|
157
206
|
if (released)
|
|
@@ -169,6 +218,18 @@ export function makeLockRelease(lockPath) {
|
|
|
169
218
|
// it is no longer ours to delete.
|
|
170
219
|
if (raw !== '' && Number(raw) !== process.pid)
|
|
171
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) {
|
|
225
|
+
try {
|
|
226
|
+
if (statSync(lockPath).ino !== ino)
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return; // vanished mid-release — nothing of ours left
|
|
231
|
+
}
|
|
232
|
+
}
|
|
172
233
|
try {
|
|
173
234
|
unlinkSync(lockPath);
|
|
174
235
|
}
|
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 {
|
|
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
|
-
|
|
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/skill-prompt.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/dist/tools/checkpoint.js
CHANGED
|
@@ -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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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));
|
package/dist/tools/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from "../config-loader.js";
|
|
5
5
|
import { applyConfigUpdates, readRawConfig, validateConfigUpdates, writeConfigFile, } from "../config-write.js";
|
|
6
|
+
import { withProjectLock } from "../file-lock.js";
|
|
6
7
|
/**
|
|
7
8
|
* Register the `iterate_config` tool.
|
|
8
9
|
* Reads and returns the iterate.config.yaml configuration, or writes a
|
|
@@ -105,42 +106,50 @@ export function registerConfigTool(ctx) {
|
|
|
105
106
|
if (updateErrors.length > 0) {
|
|
106
107
|
return { operation: 'write', ok: false, found: false, errors: updateErrors };
|
|
107
108
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
109
|
+
// Serialize the read→merge→validate→write cycle against any other
|
|
110
|
+
// config/triage writer in a sibling process: without the lock, two
|
|
111
|
+
// sessions on the same project each read a stale base and the second
|
|
112
|
+
// merge silently discards the first's update (lost update). The same
|
|
113
|
+
// lock name is shared with `iterate_triage apply` so the two writers
|
|
114
|
+
// are mutually exclusive.
|
|
115
|
+
return withProjectLock(projectRoot, 'config-write', () => {
|
|
116
|
+
let base;
|
|
117
|
+
try {
|
|
118
|
+
// Partial updates must be mergeable on a FRESH project (no config
|
|
119
|
+
// file yet): otherwise `{max_rounds: 5}` alone fails schema
|
|
120
|
+
// validation with a misleading "missing goal". Merge against the
|
|
121
|
+
// built-in defaults when there is no file to read, so the written
|
|
122
|
+
// config captures the effective config (defaults + update) and the
|
|
123
|
+
// documented "write a partial update" contract works out of the box.
|
|
124
|
+
base = existsSync(join(projectRoot, 'iterate.config.yaml'))
|
|
125
|
+
? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
|
|
126
|
+
: defaultConfig();
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` };
|
|
130
|
+
}
|
|
131
|
+
const merged = applyConfigUpdates(base, updates ?? {});
|
|
132
|
+
const schemaErrors = validateConfig(merged);
|
|
133
|
+
if (schemaErrors.length > 0) {
|
|
134
|
+
return {
|
|
135
|
+
operation: 'write',
|
|
136
|
+
ok: false,
|
|
137
|
+
found: false,
|
|
138
|
+
errors: schemaErrors.map((e) => `missing/required field: ${e}`),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const result = writeConfigFile(projectRoot, merged);
|
|
142
|
+
if (!result.ok)
|
|
143
|
+
return { operation: 'write', ok: false, found: false, error: result.error };
|
|
144
|
+
const { config } = loadEffectiveConfig(projectRoot);
|
|
126
145
|
return {
|
|
127
146
|
operation: 'write',
|
|
128
|
-
ok:
|
|
129
|
-
found:
|
|
130
|
-
|
|
147
|
+
ok: true,
|
|
148
|
+
found: true,
|
|
149
|
+
backupPath: result.backupPath ?? null,
|
|
150
|
+
config: config,
|
|
131
151
|
};
|
|
132
|
-
}
|
|
133
|
-
const result = writeConfigFile(projectRoot, merged);
|
|
134
|
-
if (!result.ok)
|
|
135
|
-
return { operation: 'write', ok: false, found: false, error: result.error };
|
|
136
|
-
const { config } = loadEffectiveConfig(projectRoot);
|
|
137
|
-
return {
|
|
138
|
-
operation: 'write',
|
|
139
|
-
ok: true,
|
|
140
|
-
found: true,
|
|
141
|
-
backupPath: result.backupPath ?? null,
|
|
142
|
-
config: config,
|
|
143
|
-
};
|
|
152
|
+
});
|
|
144
153
|
}
|
|
145
154
|
// ── Read operations (original behavior) ────────────────────────────
|
|
146
155
|
const { config, source } = loadEffectiveConfig(projectRoot);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { appendFileSync, readFileSync, mkdirSync, existsSync,
|
|
1
|
+
import { appendFileSync, readFileSync, mkdirSync, existsSync, statSync, } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
5
|
-
import {
|
|
5
|
+
import { acquireProjectLockChecked } from "../file-lock.js";
|
|
6
6
|
const LOG_DIR = '.iterate';
|
|
7
7
|
const LOG_FILE = 'decision-log.jsonl';
|
|
8
8
|
// ─── Cross-process log mutex ────────────────────────────────────────────────
|
|
@@ -19,34 +19,15 @@ const LOG_FILE = 'decision-log.jsonl';
|
|
|
19
19
|
// be lost to a wedged append), never to a crash. The prune REWRITE is the
|
|
20
20
|
// opposite — it renames the log and therefore refuses to run when the lock
|
|
21
21
|
// cannot be taken (see `rewriteDecisionLogKeepingRecent` in prune.ts).
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const LOCK_STALE_MS = 10000;
|
|
25
|
-
const LOCK_POLL_MS = 25;
|
|
26
|
-
/** True when a pid refers to a live process (or we lack permission to tell). */
|
|
27
|
-
function processAlive(pid) {
|
|
28
|
-
if (!Number.isInteger(pid) || pid <= 0)
|
|
29
|
-
return false;
|
|
30
|
-
try {
|
|
31
|
-
process.kill(pid, 0);
|
|
32
|
-
return true;
|
|
33
|
-
}
|
|
34
|
-
catch (err) {
|
|
35
|
-
return err.code === 'EPERM';
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
/** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
|
|
39
|
-
function sleepSync(ms) {
|
|
40
|
-
const sab = new Int32Array(new SharedArrayBuffer(4));
|
|
41
|
-
Atomics.wait(sab, 0, 0, ms);
|
|
42
|
-
}
|
|
22
|
+
/** Lock name shared with the prune rewrite (→ `.iterate/.decision-log.lock`). */
|
|
23
|
+
const LOG_LOCK_NAME = 'decision-log';
|
|
43
24
|
/**
|
|
44
25
|
* Acquire the decision-log lock for `projectRoot`.
|
|
45
26
|
* Returns a release function (always callable; a no-op when the lock could
|
|
46
27
|
* not be taken — never wedge an append behind a vanished holder).
|
|
47
28
|
*
|
|
48
|
-
* `options.waitMs` / `options.staleMs` override the defaults (tests use
|
|
49
|
-
* waits so a live-holder timeout is exercised in milliseconds, not 5s).
|
|
29
|
+
* `options.waitMs` / `options.staleMs` override the shared defaults (tests use
|
|
30
|
+
* short waits so a live-holder timeout is exercised in milliseconds, not 5s).
|
|
50
31
|
*/
|
|
51
32
|
export function acquireLogLock(projectRoot, options = {}) {
|
|
52
33
|
return acquireLogLockChecked(projectRoot, options).release;
|
|
@@ -57,76 +38,15 @@ export function acquireLogLock(projectRoot, options = {}) {
|
|
|
57
38
|
* audit line must not be lost to a wedged append), but the rewrite renames the
|
|
58
39
|
* log under any unlocked concurrent appender — it must refuse to run without
|
|
59
40
|
* the mutex rather than silently dropping an audit line.
|
|
41
|
+
*
|
|
42
|
+
* Delegates to the shared `acquireProjectLockChecked` with lock name
|
|
43
|
+
* `decision-log`: the decision-log mutex IS that generic project lock. This
|
|
44
|
+
* module used to hand-roll an identical copy of the acquire/steal/wait logic,
|
|
45
|
+
* and the two stale/wait parameter sets could drift apart — one implementation,
|
|
46
|
+
* one set of defaults, one ownership-checked release.
|
|
60
47
|
*/
|
|
61
48
|
export function acquireLogLockChecked(projectRoot, options = {}) {
|
|
62
|
-
|
|
63
|
-
? options.waitMs
|
|
64
|
-
: LOCK_WAIT_MS;
|
|
65
|
-
const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
|
|
66
|
-
? options.staleMs
|
|
67
|
-
: LOCK_STALE_MS;
|
|
68
|
-
const dir = join(projectRoot, LOG_DIR);
|
|
69
|
-
if (!existsSync(dir)) {
|
|
70
|
-
try {
|
|
71
|
-
mkdirSync(dir, { recursive: true });
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
return { acquired: false, release: () => { } };
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
const lockPath = join(dir, LOCK_FILE);
|
|
78
|
-
const start = Date.now();
|
|
79
|
-
for (;;) {
|
|
80
|
-
let fd = null;
|
|
81
|
-
try {
|
|
82
|
-
fd = openSync(lockPath, 'wx');
|
|
83
|
-
}
|
|
84
|
-
catch (err) {
|
|
85
|
-
if (err.code !== 'EEXIST')
|
|
86
|
-
return { acquired: false, release: () => { } };
|
|
87
|
-
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
88
|
-
let stale = false;
|
|
89
|
-
try {
|
|
90
|
-
const st = statSync(lockPath);
|
|
91
|
-
if (Date.now() - st.mtimeMs > staleMs)
|
|
92
|
-
stale = true;
|
|
93
|
-
else {
|
|
94
|
-
const pid = Number(readFileSync(lockPath, 'utf-8'));
|
|
95
|
-
if (!processAlive(pid))
|
|
96
|
-
stale = true;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
stale = true; // unreadable/vanishing lock → retry as stale
|
|
101
|
-
}
|
|
102
|
-
if (stale) {
|
|
103
|
-
try {
|
|
104
|
-
unlinkSync(lockPath);
|
|
105
|
-
}
|
|
106
|
-
catch { /* another holder stole it */ }
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (Date.now() - start > waitMs)
|
|
110
|
-
return { acquired: false, release: () => { } };
|
|
111
|
-
sleepSync(LOCK_POLL_MS);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
// Owned the lock: stamp the holder pid, then release once on return.
|
|
115
|
-
// The release is ownership-checked (see `makeLockRelease`): a lock stolen
|
|
116
|
-
// from this process after its owner went stale must not be unlinked by us.
|
|
117
|
-
try {
|
|
118
|
-
writeSync(fd, String(process.pid));
|
|
119
|
-
}
|
|
120
|
-
catch { /* pid stamp is advisory */ }
|
|
121
|
-
try {
|
|
122
|
-
closeSync(fd);
|
|
123
|
-
}
|
|
124
|
-
catch { /* best-effort */ }
|
|
125
|
-
return {
|
|
126
|
-
acquired: true,
|
|
127
|
-
release: makeLockRelease(lockPath),
|
|
128
|
-
};
|
|
129
|
-
}
|
|
49
|
+
return acquireProjectLockChecked(projectRoot, LOG_LOCK_NAME, options);
|
|
130
50
|
}
|
|
131
51
|
// ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
|
|
132
52
|
/**
|