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 +70 -7
- package/README.zh-CN.md +50 -6
- package/dist/config-loader.js +7 -1
- package/dist/file-lock.js +110 -21
- package/dist/git-scope.js +22 -2
- package/dist/index.js +7 -0
- package/dist/live.js +2 -1
- package/dist/session-hooks.js +6 -1
- 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 -99
- package/dist/tools/transcript.js +74 -44
- package/dist/tools/triage.js +52 -45
- package/dist/transcript.js +2 -2
- package/dist/working-directory.js +114 -0
- package/lib/client.js +1128 -468
- package/lib/parse.js +280 -62
- package/package.json +34 -11
- package/src/client/i18n.ts +517 -0
- package/src/client/index.ts +553 -344
- package/src/client/session-cache.ts +54 -0
- package/src/config-loader.ts +7 -1
- package/src/file-lock.ts +115 -18
- package/src/git-scope.ts +21 -2
- package/src/index.ts +7 -0
- package/src/live.ts +2 -1
- package/src/session-hooks.ts +6 -1
- 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 -82
- 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/src/working-directory.ts +126 -0
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,7 +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 { acquireProjectLockChecked } from "../file-lock.js";
|
|
5
6
|
const LOG_DIR = '.iterate';
|
|
6
7
|
const LOG_FILE = 'decision-log.jsonl';
|
|
7
8
|
// ─── Cross-process log mutex ────────────────────────────────────────────────
|
|
@@ -18,34 +19,15 @@ const LOG_FILE = 'decision-log.jsonl';
|
|
|
18
19
|
// be lost to a wedged append), never to a crash. The prune REWRITE is the
|
|
19
20
|
// opposite — it renames the log and therefore refuses to run when the lock
|
|
20
21
|
// cannot be taken (see `rewriteDecisionLogKeepingRecent` in prune.ts).
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const LOCK_STALE_MS = 10000;
|
|
24
|
-
const LOCK_POLL_MS = 25;
|
|
25
|
-
/** True when a pid refers to a live process (or we lack permission to tell). */
|
|
26
|
-
function processAlive(pid) {
|
|
27
|
-
if (!Number.isInteger(pid) || pid <= 0)
|
|
28
|
-
return false;
|
|
29
|
-
try {
|
|
30
|
-
process.kill(pid, 0);
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
33
|
-
catch (err) {
|
|
34
|
-
return err.code === 'EPERM';
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
|
|
38
|
-
function sleepSync(ms) {
|
|
39
|
-
const sab = new Int32Array(new SharedArrayBuffer(4));
|
|
40
|
-
Atomics.wait(sab, 0, 0, ms);
|
|
41
|
-
}
|
|
22
|
+
/** Lock name shared with the prune rewrite (→ `.iterate/.decision-log.lock`). */
|
|
23
|
+
const LOG_LOCK_NAME = 'decision-log';
|
|
42
24
|
/**
|
|
43
25
|
* Acquire the decision-log lock for `projectRoot`.
|
|
44
26
|
* Returns a release function (always callable; a no-op when the lock could
|
|
45
27
|
* not be taken — never wedge an append behind a vanished holder).
|
|
46
28
|
*
|
|
47
|
-
* `options.waitMs` / `options.staleMs` override the defaults (tests use
|
|
48
|
-
* 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).
|
|
49
31
|
*/
|
|
50
32
|
export function acquireLogLock(projectRoot, options = {}) {
|
|
51
33
|
return acquireLogLockChecked(projectRoot, options).release;
|
|
@@ -56,83 +38,15 @@ export function acquireLogLock(projectRoot, options = {}) {
|
|
|
56
38
|
* audit line must not be lost to a wedged append), but the rewrite renames the
|
|
57
39
|
* log under any unlocked concurrent appender — it must refuse to run without
|
|
58
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.
|
|
59
47
|
*/
|
|
60
48
|
export function acquireLogLockChecked(projectRoot, options = {}) {
|
|
61
|
-
|
|
62
|
-
? options.waitMs
|
|
63
|
-
: LOCK_WAIT_MS;
|
|
64
|
-
const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
|
|
65
|
-
? options.staleMs
|
|
66
|
-
: LOCK_STALE_MS;
|
|
67
|
-
const dir = join(projectRoot, LOG_DIR);
|
|
68
|
-
if (!existsSync(dir)) {
|
|
69
|
-
try {
|
|
70
|
-
mkdirSync(dir, { recursive: true });
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
return { acquired: false, release: () => { } };
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
const lockPath = join(dir, LOCK_FILE);
|
|
77
|
-
const start = Date.now();
|
|
78
|
-
for (;;) {
|
|
79
|
-
let fd = null;
|
|
80
|
-
try {
|
|
81
|
-
fd = openSync(lockPath, 'wx');
|
|
82
|
-
}
|
|
83
|
-
catch (err) {
|
|
84
|
-
if (err.code !== 'EEXIST')
|
|
85
|
-
return { acquired: false, release: () => { } };
|
|
86
|
-
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
87
|
-
let stale = false;
|
|
88
|
-
try {
|
|
89
|
-
const st = statSync(lockPath);
|
|
90
|
-
if (Date.now() - st.mtimeMs > staleMs)
|
|
91
|
-
stale = true;
|
|
92
|
-
else {
|
|
93
|
-
const pid = Number(readFileSync(lockPath, 'utf-8'));
|
|
94
|
-
if (!processAlive(pid))
|
|
95
|
-
stale = true;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
stale = true; // unreadable/vanishing lock → retry as stale
|
|
100
|
-
}
|
|
101
|
-
if (stale) {
|
|
102
|
-
try {
|
|
103
|
-
unlinkSync(lockPath);
|
|
104
|
-
}
|
|
105
|
-
catch { /* another holder stole it */ }
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (Date.now() - start > waitMs)
|
|
109
|
-
return { acquired: false, release: () => { } };
|
|
110
|
-
sleepSync(LOCK_POLL_MS);
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
// Owned the lock: stamp the holder pid, then release once on return.
|
|
114
|
-
try {
|
|
115
|
-
writeSync(fd, String(process.pid));
|
|
116
|
-
}
|
|
117
|
-
catch { /* pid stamp is advisory */ }
|
|
118
|
-
try {
|
|
119
|
-
closeSync(fd);
|
|
120
|
-
}
|
|
121
|
-
catch { /* best-effort */ }
|
|
122
|
-
let released = false;
|
|
123
|
-
return {
|
|
124
|
-
acquired: true,
|
|
125
|
-
release: () => {
|
|
126
|
-
if (released)
|
|
127
|
-
return;
|
|
128
|
-
released = true;
|
|
129
|
-
try {
|
|
130
|
-
unlinkSync(lockPath);
|
|
131
|
-
}
|
|
132
|
-
catch { /* already gone */ }
|
|
133
|
-
},
|
|
134
|
-
};
|
|
135
|
-
}
|
|
49
|
+
return acquireProjectLockChecked(projectRoot, LOG_LOCK_NAME, options);
|
|
136
50
|
}
|
|
137
51
|
// ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
|
|
138
52
|
/**
|
package/dist/tools/transcript.js
CHANGED
|
@@ -23,6 +23,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
23
23
|
import { mkdir, readFile } from 'node:fs/promises';
|
|
24
24
|
import { existsSync } from 'node:fs';
|
|
25
25
|
import { writeTextAtomicAsync } from "../atomic-fs.js";
|
|
26
|
+
import { withProjectLock } from "../file-lock.js";
|
|
26
27
|
import { dirname } from 'node:path';
|
|
27
28
|
import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loader.js";
|
|
28
29
|
import { transcriptPath } from "../paths.js";
|
|
@@ -73,7 +74,7 @@ function normalizeCheckpoint(input) {
|
|
|
73
74
|
if (round <= 0)
|
|
74
75
|
return null;
|
|
75
76
|
return {
|
|
76
|
-
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode :
|
|
77
|
+
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
|
|
77
78
|
round,
|
|
78
79
|
maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
|
|
79
80
|
fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
|
|
@@ -198,7 +199,23 @@ export function registerTranscriptTool(ctx) {
|
|
|
198
199
|
'the run console can show which commands ran and whether they passed.',
|
|
199
200
|
},
|
|
200
201
|
refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
|
|
201
|
-
stoppedReason: {
|
|
202
|
+
stoppedReason: {
|
|
203
|
+
type: 'string',
|
|
204
|
+
// Enum guard: the label renderer keys off this value, so a stray or
|
|
205
|
+
// prototype-chain string (e.g. "__proto__") must not enter the tool.
|
|
206
|
+
// The render path additionally uses Object.hasOwn (defense for
|
|
207
|
+
// hand-edited manifests that bypass this schema).
|
|
208
|
+
enum: [
|
|
209
|
+
'converged',
|
|
210
|
+
'max_rounds_reached',
|
|
211
|
+
'aborted_by_validation',
|
|
212
|
+
'aborted_by_config',
|
|
213
|
+
'schema_invalid',
|
|
214
|
+
'no_usable_reviewer_output',
|
|
215
|
+
'inconclusive',
|
|
216
|
+
],
|
|
217
|
+
description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).'
|
|
218
|
+
},
|
|
202
219
|
// `string | null` (NOT plain `type: 'string'`): dsh validates arguments
|
|
203
220
|
// against the compiled schema BEFORE execute, and the docs — plus the
|
|
204
221
|
// client's clear button — advertise `{"operation":"nudge","text":null}`.
|
|
@@ -288,52 +305,65 @@ export function registerTranscriptTool(ctx) {
|
|
|
288
305
|
};
|
|
289
306
|
}
|
|
290
307
|
if (args.operation === 'nudge') {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
308
|
+
// Read-modify-write across processes: a concurrent nudge in a sibling
|
|
309
|
+
// process would otherwise read the same manifest and write back over
|
|
310
|
+
// each other's steering text. Serialize on the shared transcript lock
|
|
311
|
+
// (same pattern as the config/triage writers). The body still awaits,
|
|
312
|
+
// so the lock is held across the read and write; a same-process
|
|
313
|
+
// re-entry degrades to unlocked (the advisory lock is per-pid), which
|
|
314
|
+
// is the documented best-effort fallback for a low-value projection.
|
|
315
|
+
return withProjectLock(projectRoot, 'transcript', async () => {
|
|
316
|
+
let manifest = null;
|
|
317
|
+
if (existsSync(file)) {
|
|
318
|
+
try {
|
|
319
|
+
const parsed = JSON.parse(await readFile(file, 'utf-8'));
|
|
320
|
+
manifest = parsed;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
manifest = null;
|
|
324
|
+
}
|
|
296
325
|
}
|
|
297
|
-
|
|
298
|
-
|
|
326
|
+
// Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
|
|
327
|
+
// or a non-object) must never crash the nudge — degrade to a fresh
|
|
328
|
+
// builder so the steering text still lands. The fallback must keep
|
|
329
|
+
// the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
|
|
330
|
+
// stray malformed field can never silently rewrite a dry-run run
|
|
331
|
+
// into a `normal` one.
|
|
332
|
+
const fallbackOpts = {
|
|
333
|
+
project: projectRoot,
|
|
334
|
+
mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null),
|
|
335
|
+
taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null),
|
|
336
|
+
approval,
|
|
337
|
+
goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
|
|
338
|
+
maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
|
|
339
|
+
};
|
|
340
|
+
let builder;
|
|
341
|
+
if (manifest) {
|
|
342
|
+
try {
|
|
343
|
+
builder = rehydrateBuilder(manifest, approval);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
builder = new ReviewTranscriptBuilder(fallbackOpts);
|
|
347
|
+
}
|
|
299
348
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
// into a `normal` one.
|
|
307
|
-
const fallbackOpts = {
|
|
308
|
-
project: projectRoot,
|
|
309
|
-
mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null),
|
|
310
|
-
taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null),
|
|
311
|
-
approval,
|
|
312
|
-
goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
|
|
313
|
-
maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
|
|
314
|
-
};
|
|
315
|
-
let builder;
|
|
316
|
-
if (manifest) {
|
|
317
|
-
try {
|
|
318
|
-
builder = rehydrateBuilder(manifest, approval);
|
|
349
|
+
else {
|
|
350
|
+
// No manifest yet: the run's mode is genuinely unknown at nudge
|
|
351
|
+
// time (capture sets it). Recording `mode: 'normal'` here would
|
|
352
|
+
// rewrite a dry-run run into a `normal` one in the status/badge
|
|
353
|
+
// until capture lands — `null` honestly says "not captured yet".
|
|
354
|
+
builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: null, approval });
|
|
319
355
|
}
|
|
320
|
-
|
|
321
|
-
|
|
356
|
+
builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null);
|
|
357
|
+
const persisted = await persistChecked(file, builder.serialize());
|
|
358
|
+
if (!persisted.ok) {
|
|
359
|
+
return { operation: 'nudge', updated: false, error: persisted.error };
|
|
322
360
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (!persisted.ok) {
|
|
330
|
-
return { operation: 'nudge', updated: false, error: persisted.error };
|
|
331
|
-
}
|
|
332
|
-
return {
|
|
333
|
-
operation: 'nudge',
|
|
334
|
-
updated: true,
|
|
335
|
-
transcript: builder.serialize(),
|
|
336
|
-
};
|
|
361
|
+
return {
|
|
362
|
+
operation: 'nudge',
|
|
363
|
+
updated: true,
|
|
364
|
+
transcript: builder.serialize(),
|
|
365
|
+
};
|
|
366
|
+
});
|
|
337
367
|
}
|
|
338
368
|
// capture
|
|
339
369
|
// `observatory.capture: false` opts the project out of transcript
|
package/dist/tools/triage.js
CHANGED
|
@@ -5,6 +5,7 @@ import yaml from 'js-yaml';
|
|
|
5
5
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
6
6
|
import { writeTextAtomic } from "../atomic-fs.js";
|
|
7
7
|
import { configBackupSuffix } from "../config-write.js";
|
|
8
|
+
import { withProjectLock } from "../file-lock.js";
|
|
8
9
|
import { WHOLE_FILE_LINE } from "../evidence.js";
|
|
9
10
|
const CONFIG_FILE = 'iterate.config.yaml';
|
|
10
11
|
/** Personalization key that holds the known-intentional list. */
|
|
@@ -227,58 +228,64 @@ function readConfigFile(configPath) {
|
|
|
227
228
|
/** Apply the triage entries: backup, merge, write, rollback on failure. */
|
|
228
229
|
function applyEntries(projectRoot, incoming) {
|
|
229
230
|
const configPath = join(projectRoot, CONFIG_FILE);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return { ok: false, error: `Failed to read config: ${String(err)}` };
|
|
237
|
-
}
|
|
238
|
-
const existing = readKnownIntentional(config);
|
|
239
|
-
const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming);
|
|
240
|
-
const nextConfig = buildConfigWithKnownIntentional(config, merged);
|
|
241
|
-
const hadFile = existsSync(configPath);
|
|
242
|
-
const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null;
|
|
243
|
-
if (backupPath) {
|
|
231
|
+
// Serialize against `iterate_config write` (SAME lock name) so a triage apply
|
|
232
|
+
// and a config write on one project cannot interleave their read-modify-write
|
|
233
|
+
// and silently drop each other's updates. The lock spans read → merge →
|
|
234
|
+
// backup → write → backup-prune so the whole cycle is atomic.
|
|
235
|
+
return withProjectLock(projectRoot, 'config-write', () => {
|
|
236
|
+
let config;
|
|
244
237
|
try {
|
|
245
|
-
|
|
238
|
+
config = readConfigFile(configPath);
|
|
246
239
|
}
|
|
247
240
|
catch (err) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
error: `Failed to create backup: ${String(err)}`,
|
|
251
|
-
};
|
|
241
|
+
// The file exists but is malformed — refuse to overwrite user data.
|
|
242
|
+
return { ok: false, error: `Failed to read config: ${String(err)}` };
|
|
252
243
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
244
|
+
const existing = readKnownIntentional(config);
|
|
245
|
+
const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming);
|
|
246
|
+
const nextConfig = buildConfigWithKnownIntentional(config, merged);
|
|
247
|
+
const hadFile = existsSync(configPath);
|
|
248
|
+
const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null;
|
|
249
|
+
if (backupPath) {
|
|
250
|
+
try {
|
|
251
|
+
copyFileSync(configPath, backupPath);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
error: `Failed to create backup: ${String(err)}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const yamlText = yaml.dump(nextConfig, { noRefs: true });
|
|
263
261
|
try {
|
|
264
|
-
|
|
265
|
-
copyFileSync(backupPath, configPath);
|
|
266
|
-
else if (existsSync(configPath))
|
|
267
|
-
rmSync(configPath, { force: true });
|
|
262
|
+
writeTextAtomic(configPath, yamlText);
|
|
268
263
|
}
|
|
269
|
-
catch (
|
|
270
|
-
|
|
264
|
+
catch (err) {
|
|
265
|
+
// Rollback: restore the backup, or REMOVE the file we just created when
|
|
266
|
+
// there was no prior config — an empty file left behind would poison all
|
|
267
|
+
// future config reads (empty YAML is not a valid mapping).
|
|
268
|
+
let rollbackError = '';
|
|
269
|
+
try {
|
|
270
|
+
if (backupPath)
|
|
271
|
+
copyFileSync(backupPath, configPath);
|
|
272
|
+
else if (existsSync(configPath))
|
|
273
|
+
rmSync(configPath, { force: true });
|
|
274
|
+
}
|
|
275
|
+
catch (rbErr) {
|
|
276
|
+
rollbackError = `; rollback also failed: ${String(rbErr)}`;
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
error: `Failed to write config: ${String(err)}${rollbackError}`,
|
|
281
|
+
};
|
|
271
282
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// project never collects an unbounded pile of config snapshots.
|
|
279
|
-
if (backupPath)
|
|
280
|
-
pruneOldConfigBackups(configPath);
|
|
281
|
-
return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath };
|
|
283
|
+
// Success: bound the accumulation of timestamped backups so a long-lived
|
|
284
|
+
// project never collects an unbounded pile of config snapshots.
|
|
285
|
+
if (backupPath)
|
|
286
|
+
pruneOldConfigBackups(configPath);
|
|
287
|
+
return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath };
|
|
288
|
+
});
|
|
282
289
|
}
|
|
283
290
|
/**
|
|
284
291
|
* Register the `iterate_triage` tool.
|
package/dist/transcript.js
CHANGED
|
@@ -501,7 +501,7 @@ export class ReviewTranscriptBuilder {
|
|
|
501
501
|
return;
|
|
502
502
|
}
|
|
503
503
|
this.checkpoint = {
|
|
504
|
-
mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode :
|
|
504
|
+
mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode : null,
|
|
505
505
|
round: typeof state.round === 'number' ? state.round : 0,
|
|
506
506
|
maxRounds: typeof state.maxRounds === 'number' ? state.maxRounds : 0,
|
|
507
507
|
fixedCount: typeof state.fixedCount === 'number' ? state.fixedCount : 0,
|
|
@@ -725,7 +725,7 @@ function normalizeCheckpointRow(input) {
|
|
|
725
725
|
if (round <= 0)
|
|
726
726
|
return null;
|
|
727
727
|
return {
|
|
728
|
-
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode :
|
|
728
|
+
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
|
|
729
729
|
round: clampRoundValue(c.round),
|
|
730
730
|
maxRounds: clampCountValue(c.maxRounds),
|
|
731
731
|
fixedCount: clampCountValue(c.fixedCount),
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/working-directory.ts — follow dsh's mutable session working directory.
|
|
3
|
+
*
|
|
4
|
+
* Upstream history:
|
|
5
|
+
* - through dsh 0.2.1-alpha.1 the session's working directory WAS the
|
|
6
|
+
* immutable `Session.header.cwd`, so reading `exec.agent.session.header.cwd`
|
|
7
|
+
* was authoritative;
|
|
8
|
+
* - dsh 0.2.1-alpha.2 added the `working_directory` tool and the
|
|
9
|
+
* `WorkingDirectoryService` (`@deepseek-ai/dsh-working-directory`), whose
|
|
10
|
+
* contract explicitly states "the immutable Session header retains the
|
|
11
|
+
* ORIGINAL project" while `working-directory/change` events record the
|
|
12
|
+
* current one. After a model/user switches directories mid-session,
|
|
13
|
+
* `header.cwd` is therefore stale BY DESIGN.
|
|
14
|
+
*
|
|
15
|
+
* The iterate plugin resolves the project root (where `.iterate/` lives, where
|
|
16
|
+
* fixes are written, and — critically — which workspace the F8 approval policy
|
|
17
|
+
* is read from) from the session cwd. Sticking to `header.cwd` on a host that
|
|
18
|
+
* supports directory switches would silently operate on the wrong project:
|
|
19
|
+
* reviews would anchor to the original checkout while the model edits files
|
|
20
|
+
* elsewhere, and a `deny` policy in the active workspace would be read from
|
|
21
|
+
* the original one.
|
|
22
|
+
*
|
|
23
|
+
* So the effective directory is resolved through the host's service when it
|
|
24
|
+
* exists, and degrades to `header.cwd` otherwise:
|
|
25
|
+
* - feature-detected (`ctx.get('workingDirectory')`) — hosts older than
|
|
26
|
+
* 0.2.1-alpha.2 simply have no such service and keep the old behavior;
|
|
27
|
+
* - `service.get(session)` is the service's synchronous, I/O-free read of
|
|
28
|
+
* the committed directory, so this stays safe on the approval path;
|
|
29
|
+
* - every failure mode (no service, service throws, hostile getters, a
|
|
30
|
+
* non-string result) falls back to `header.cwd`, never to `undefined`
|
|
31
|
+
* where a header cwd exists.
|
|
32
|
+
*
|
|
33
|
+
* The service object is fetched lazily on every call, not once at plugin
|
|
34
|
+
* apply: dsh may register the service after this plugin loads, and a disposed
|
|
35
|
+
* context must degrade rather than cache a dead handle.
|
|
36
|
+
*/
|
|
37
|
+
/** The plugin context, installed once by `apply()`; null on hosts without it. */
|
|
38
|
+
let installedContext = null;
|
|
39
|
+
/** True when `value` looks like a cordis context carrying `ctx.get`. */
|
|
40
|
+
function isContextLike(value) {
|
|
41
|
+
return (typeof value === 'object' &&
|
|
42
|
+
value !== null &&
|
|
43
|
+
typeof value.get === 'function');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Install the plugin context used to look up the `workingDirectory` service.
|
|
47
|
+
* Passing anything without a callable `get` clears the installation (tests and
|
|
48
|
+
* hosts that load the plugin into a degraded context).
|
|
49
|
+
*/
|
|
50
|
+
export function installWorkingDirectoryContext(ctx) {
|
|
51
|
+
installedContext = isContextLike(ctx) ? ctx : null;
|
|
52
|
+
}
|
|
53
|
+
/** Forget the installed context (test isolation / unload). */
|
|
54
|
+
export function resetWorkingDirectoryContext() {
|
|
55
|
+
installedContext = null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Read the host's committed working directory for `session`, if the host
|
|
59
|
+
* provides the service and it answers with a usable absolute path.
|
|
60
|
+
*
|
|
61
|
+
* Returns `undefined` for every degradation path so callers keep their
|
|
62
|
+
* previous `header.cwd` behavior.
|
|
63
|
+
*/
|
|
64
|
+
function serviceWorkingDirectory(session) {
|
|
65
|
+
if (session === undefined || session === null)
|
|
66
|
+
return undefined;
|
|
67
|
+
const ctx = installedContext;
|
|
68
|
+
if (!ctx)
|
|
69
|
+
return undefined;
|
|
70
|
+
try {
|
|
71
|
+
const service = ctx.get?.('workingDirectory');
|
|
72
|
+
if (!service || typeof service.get !== 'function')
|
|
73
|
+
return undefined;
|
|
74
|
+
const dir = service.get(session);
|
|
75
|
+
// A relative or empty directory would move the project root somewhere
|
|
76
|
+
// unspecified — refuse it and keep the header value instead.
|
|
77
|
+
if (typeof dir !== 'string' || dir.length === 0)
|
|
78
|
+
return undefined;
|
|
79
|
+
return dir;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Missing/disposed service, unpublished session, or a throwing getter —
|
|
83
|
+
// degrade to the historical source rather than failing the tool call.
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The effective working directory of a session: the host's committed
|
|
89
|
+
* directory when available, else the immutable session header cwd.
|
|
90
|
+
*
|
|
91
|
+
* Returns `undefined` only when neither source yields a usable path.
|
|
92
|
+
*/
|
|
93
|
+
export function effectiveSessionCwd(session, headerCwd) {
|
|
94
|
+
const fromService = serviceWorkingDirectory(session);
|
|
95
|
+
if (fromService !== undefined)
|
|
96
|
+
return fromService;
|
|
97
|
+
return typeof headerCwd === 'string' && headerCwd.length > 0 ? headerCwd : undefined;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Extract the effective cwd from a tool execution (`exec.agent.session`),
|
|
101
|
+
* reading every property defensively: a hostile/proxied exec must degrade to
|
|
102
|
+
* the fallback path, never throw out of an approval or capture hook.
|
|
103
|
+
*/
|
|
104
|
+
export function sessionCwdOf(exec) {
|
|
105
|
+
try {
|
|
106
|
+
const agent = exec?.agent;
|
|
107
|
+
const session = agent?.session;
|
|
108
|
+
const headerCwd = typeof agent?.session?.header?.cwd === 'string' ? agent.session.header.cwd : undefined;
|
|
109
|
+
return effectiveSessionCwd(session, headerCwd);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|