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.
@@ -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
- let base;
109
- try {
110
- // Partial updates must be mergeable on a FRESH project (no config
111
- // file yet): otherwise `{max_rounds: 5}` alone fails schema
112
- // validation with a misleading "missing goal". Merge against the
113
- // built-in defaults when there is no file to read, so the written
114
- // config captures the effective config (defaults + update) and the
115
- // documented "write a partial update" contract works out of the box.
116
- base = existsSync(join(projectRoot, 'iterate.config.yaml'))
117
- ? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
118
- : defaultConfig();
119
- }
120
- catch (err) {
121
- return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` };
122
- }
123
- const merged = applyConfigUpdates(base, updates ?? {});
124
- const schemaErrors = validateConfig(merged);
125
- if (schemaErrors.length > 0) {
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: false,
129
- found: false,
130
- errors: schemaErrors.map((e) => `missing/required field: ${e}`),
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, openSync, writeSync, closeSync, unlinkSync, statSync, } from 'node:fs';
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
- const LOCK_FILE = '.decision-log.lock';
22
- const LOCK_WAIT_MS = 5000;
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 short
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
- const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
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
  /**
@@ -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 : 'normal',
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: { type: 'string', 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).' },
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
- let manifest = null;
292
- if (existsSync(file)) {
293
- try {
294
- const parsed = JSON.parse(await readFile(file, 'utf-8'));
295
- manifest = parsed;
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
- catch {
298
- manifest = null;
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
- // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
302
- // or a non-object) must never crash the nudge — degrade to a fresh
303
- // builder so the steering text still lands. The fallback must keep
304
- // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
305
- // stray malformed field can never silently rewrite a dry-run run
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
- catch {
321
- builder = new ReviewTranscriptBuilder(fallbackOpts);
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
- else {
325
- builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval });
326
- }
327
- builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null);
328
- const persisted = await persistChecked(file, builder.serialize());
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
@@ -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
- let config;
231
- try {
232
- config = readConfigFile(configPath);
233
- }
234
- catch (err) {
235
- // The file exists but is malformed — refuse to overwrite user data.
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
- copyFileSync(configPath, backupPath);
238
+ config = readConfigFile(configPath);
246
239
  }
247
240
  catch (err) {
248
- return {
249
- ok: false,
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
- const yamlText = yaml.dump(nextConfig, { noRefs: true });
255
- try {
256
- writeTextAtomic(configPath, yamlText);
257
- }
258
- catch (err) {
259
- // Rollback: restore the backup, or REMOVE the file we just created when
260
- // there was no prior config — an empty file left behind would poison all
261
- // future config reads (empty YAML is not a valid mapping).
262
- let rollbackError = '';
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
- if (backupPath)
265
- copyFileSync(backupPath, configPath);
266
- else if (existsSync(configPath))
267
- rmSync(configPath, { force: true });
262
+ writeTextAtomic(configPath, yamlText);
268
263
  }
269
- catch (rbErr) {
270
- rollbackError = `; rollback also failed: ${String(rbErr)}`;
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
- return {
273
- ok: false,
274
- error: `Failed to write config: ${String(err)}${rollbackError}`,
275
- };
276
- }
277
- // Success: bound the accumulation of timestamped backups so a long-lived
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.
@@ -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 : 'normal',
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 : 'normal',
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
+ }