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
|
@@ -3,16 +3,13 @@ import {
|
|
|
3
3
|
readFileSync,
|
|
4
4
|
mkdirSync,
|
|
5
5
|
existsSync,
|
|
6
|
-
openSync,
|
|
7
|
-
writeSync,
|
|
8
|
-
closeSync,
|
|
9
|
-
unlinkSync,
|
|
10
6
|
statSync,
|
|
11
7
|
} from 'node:fs'
|
|
12
8
|
import { join } from 'node:path'
|
|
13
9
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
14
10
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
15
11
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
12
|
+
import { acquireProjectLockChecked } from '../file-lock.ts'
|
|
16
13
|
import type { DecisionLogEntry } from '../types.ts'
|
|
17
14
|
|
|
18
15
|
const LOG_DIR = '.iterate'
|
|
@@ -33,35 +30,16 @@ const LOG_FILE = 'decision-log.jsonl'
|
|
|
33
30
|
// opposite — it renames the log and therefore refuses to run when the lock
|
|
34
31
|
// cannot be taken (see `rewriteDecisionLogKeepingRecent` in prune.ts).
|
|
35
32
|
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const LOCK_STALE_MS = 10000
|
|
39
|
-
const LOCK_POLL_MS = 25
|
|
40
|
-
|
|
41
|
-
/** True when a pid refers to a live process (or we lack permission to tell). */
|
|
42
|
-
function processAlive(pid: number): boolean {
|
|
43
|
-
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
44
|
-
try {
|
|
45
|
-
process.kill(pid, 0)
|
|
46
|
-
return true
|
|
47
|
-
} catch (err) {
|
|
48
|
-
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
|
|
53
|
-
function sleepSync(ms: number): void {
|
|
54
|
-
const sab = new Int32Array(new SharedArrayBuffer(4))
|
|
55
|
-
Atomics.wait(sab, 0, 0, ms)
|
|
56
|
-
}
|
|
33
|
+
/** Lock name shared with the prune rewrite (→ `.iterate/.decision-log.lock`). */
|
|
34
|
+
const LOG_LOCK_NAME = 'decision-log'
|
|
57
35
|
|
|
58
36
|
/**
|
|
59
37
|
* Acquire the decision-log lock for `projectRoot`.
|
|
60
38
|
* Returns a release function (always callable; a no-op when the lock could
|
|
61
39
|
* not be taken — never wedge an append behind a vanished holder).
|
|
62
40
|
*
|
|
63
|
-
* `options.waitMs` / `options.staleMs` override the defaults (tests use
|
|
64
|
-
* waits so a live-holder timeout is exercised in milliseconds, not 5s).
|
|
41
|
+
* `options.waitMs` / `options.staleMs` override the shared defaults (tests use
|
|
42
|
+
* short waits so a live-holder timeout is exercised in milliseconds, not 5s).
|
|
65
43
|
*/
|
|
66
44
|
export function acquireLogLock(
|
|
67
45
|
projectRoot: string,
|
|
@@ -76,66 +54,18 @@ export function acquireLogLock(
|
|
|
76
54
|
* audit line must not be lost to a wedged append), but the rewrite renames the
|
|
77
55
|
* log under any unlocked concurrent appender — it must refuse to run without
|
|
78
56
|
* the mutex rather than silently dropping an audit line.
|
|
57
|
+
*
|
|
58
|
+
* Delegates to the shared `acquireProjectLockChecked` with lock name
|
|
59
|
+
* `decision-log`: the decision-log mutex IS that generic project lock. This
|
|
60
|
+
* module used to hand-roll an identical copy of the acquire/steal/wait logic,
|
|
61
|
+
* and the two stale/wait parameter sets could drift apart — one implementation,
|
|
62
|
+
* one set of defaults, one ownership-checked release.
|
|
79
63
|
*/
|
|
80
64
|
export function acquireLogLockChecked(
|
|
81
65
|
projectRoot: string,
|
|
82
66
|
options: { waitMs?: number; staleMs?: number } = {},
|
|
83
67
|
): { acquired: boolean; release: () => void } {
|
|
84
|
-
|
|
85
|
-
? (options.waitMs as number)
|
|
86
|
-
: LOCK_WAIT_MS
|
|
87
|
-
const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
|
|
88
|
-
? (options.staleMs as number)
|
|
89
|
-
: LOCK_STALE_MS
|
|
90
|
-
const dir = join(projectRoot, LOG_DIR)
|
|
91
|
-
if (!existsSync(dir)) {
|
|
92
|
-
try {
|
|
93
|
-
mkdirSync(dir, { recursive: true })
|
|
94
|
-
} catch {
|
|
95
|
-
return { acquired: false, release: () => {} }
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
const lockPath = join(dir, LOCK_FILE)
|
|
99
|
-
const start = Date.now()
|
|
100
|
-
for (;;) {
|
|
101
|
-
let fd: number | null = null
|
|
102
|
-
try {
|
|
103
|
-
fd = openSync(lockPath, 'wx')
|
|
104
|
-
} catch (err) {
|
|
105
|
-
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return { acquired: false, release: () => {} }
|
|
106
|
-
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
107
|
-
let stale = false
|
|
108
|
-
try {
|
|
109
|
-
const st = statSync(lockPath)
|
|
110
|
-
if (Date.now() - st.mtimeMs > staleMs) stale = true
|
|
111
|
-
else {
|
|
112
|
-
const pid = Number(readFileSync(lockPath, 'utf-8'))
|
|
113
|
-
if (!processAlive(pid)) stale = true
|
|
114
|
-
}
|
|
115
|
-
} catch {
|
|
116
|
-
stale = true // unreadable/vanishing lock → retry as stale
|
|
117
|
-
}
|
|
118
|
-
if (stale) {
|
|
119
|
-
try { unlinkSync(lockPath) } catch { /* another holder stole it */ }
|
|
120
|
-
continue
|
|
121
|
-
}
|
|
122
|
-
if (Date.now() - start > waitMs) return { acquired: false, release: () => {} }
|
|
123
|
-
sleepSync(LOCK_POLL_MS)
|
|
124
|
-
continue
|
|
125
|
-
}
|
|
126
|
-
// Owned the lock: stamp the holder pid, then release once on return.
|
|
127
|
-
try { writeSync(fd, String(process.pid)) } catch { /* pid stamp is advisory */ }
|
|
128
|
-
try { closeSync(fd) } catch { /* best-effort */ }
|
|
129
|
-
let released = false
|
|
130
|
-
return {
|
|
131
|
-
acquired: true,
|
|
132
|
-
release: () => {
|
|
133
|
-
if (released) return
|
|
134
|
-
released = true
|
|
135
|
-
try { unlinkSync(lockPath) } catch { /* already gone */ }
|
|
136
|
-
},
|
|
137
|
-
}
|
|
138
|
-
}
|
|
68
|
+
return acquireProjectLockChecked(projectRoot, LOG_LOCK_NAME, options)
|
|
139
69
|
}
|
|
140
70
|
|
|
141
71
|
// ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
|
package/src/tools/transcript.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
24
24
|
import { mkdir, readFile } from 'node:fs/promises'
|
|
25
25
|
import { existsSync } from 'node:fs'
|
|
26
26
|
import { writeTextAtomicAsync } from '../atomic-fs.ts'
|
|
27
|
+
import { withProjectLock } from '../file-lock.ts'
|
|
27
28
|
import { dirname } from 'node:path'
|
|
28
29
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
29
30
|
import {
|
|
@@ -81,7 +82,7 @@ function normalizeCheckpoint(input: unknown): TranscriptManifest['checkpoint'] {
|
|
|
81
82
|
const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0
|
|
82
83
|
if (round <= 0) return null
|
|
83
84
|
return {
|
|
84
|
-
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode :
|
|
85
|
+
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
|
|
85
86
|
round,
|
|
86
87
|
maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
|
|
87
88
|
fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
|
|
@@ -215,7 +216,22 @@ export function registerTranscriptTool(ctx: {
|
|
|
215
216
|
'the run console can show which commands ran and whether they passed.',
|
|
216
217
|
},
|
|
217
218
|
refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
|
|
218
|
-
stoppedReason: {
|
|
219
|
+
stoppedReason: {
|
|
220
|
+
type: 'string',
|
|
221
|
+
// Enum guard: the label renderer keys off this value, so a stray or
|
|
222
|
+
// prototype-chain string (e.g. "__proto__") must not enter the tool.
|
|
223
|
+
// The render path additionally uses Object.hasOwn (defense for
|
|
224
|
+
// hand-edited manifests that bypass this schema).
|
|
225
|
+
enum: [
|
|
226
|
+
'converged',
|
|
227
|
+
'max_rounds_reached',
|
|
228
|
+
'aborted_by_validation',
|
|
229
|
+
'aborted_by_config',
|
|
230
|
+
'schema_invalid',
|
|
231
|
+
'no_usable_reviewer_output',
|
|
232
|
+
'inconclusive',
|
|
233
|
+
],
|
|
234
|
+
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).' },
|
|
219
235
|
// `string | null` (NOT plain `type: 'string'`): dsh validates arguments
|
|
220
236
|
// against the compiled schema BEFORE execute, and the docs — plus the
|
|
221
237
|
// client's clear button — advertise `{"operation":"nudge","text":null}`.
|
|
@@ -311,49 +327,62 @@ export function registerTranscriptTool(ctx: {
|
|
|
311
327
|
}
|
|
312
328
|
|
|
313
329
|
if (args.operation === 'nudge') {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
330
|
+
// Read-modify-write across processes: a concurrent nudge in a sibling
|
|
331
|
+
// process would otherwise read the same manifest and write back over
|
|
332
|
+
// each other's steering text. Serialize on the shared transcript lock
|
|
333
|
+
// (same pattern as the config/triage writers). The body still awaits,
|
|
334
|
+
// so the lock is held across the read and write; a same-process
|
|
335
|
+
// re-entry degrades to unlocked (the advisory lock is per-pid), which
|
|
336
|
+
// is the documented best-effort fallback for a low-value projection.
|
|
337
|
+
return withProjectLock(projectRoot, 'transcript', async () => {
|
|
338
|
+
let manifest: TranscriptManifest | null = null
|
|
339
|
+
if (existsSync(file)) {
|
|
340
|
+
try {
|
|
341
|
+
const parsed = JSON.parse(await readFile(file, 'utf-8')) as unknown as TranscriptManifest
|
|
342
|
+
manifest = parsed
|
|
343
|
+
} catch {
|
|
344
|
+
manifest = null
|
|
345
|
+
}
|
|
321
346
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
|
|
336
|
-
}
|
|
337
|
-
let builder: ReviewTranscriptBuilder
|
|
338
|
-
if (manifest) {
|
|
339
|
-
try {
|
|
340
|
-
builder = rehydrateBuilder(manifest, approval)
|
|
341
|
-
} catch {
|
|
342
|
-
builder = new ReviewTranscriptBuilder(fallbackOpts)
|
|
347
|
+
// Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
|
|
348
|
+
// or a non-object) must never crash the nudge — degrade to a fresh
|
|
349
|
+
// builder so the steering text still lands. The fallback must keep
|
|
350
|
+
// the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
|
|
351
|
+
// stray malformed field can never silently rewrite a dry-run run
|
|
352
|
+
// into a `normal` one.
|
|
353
|
+
const fallbackOpts = {
|
|
354
|
+
project: projectRoot,
|
|
355
|
+
mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null) as 'dry-run' | 'normal' | null,
|
|
356
|
+
taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null) as 'code' | 'iterate' | null,
|
|
357
|
+
approval,
|
|
358
|
+
goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
|
|
359
|
+
maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
|
|
343
360
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
361
|
+
let builder: ReviewTranscriptBuilder
|
|
362
|
+
if (manifest) {
|
|
363
|
+
try {
|
|
364
|
+
builder = rehydrateBuilder(manifest, approval)
|
|
365
|
+
} catch {
|
|
366
|
+
builder = new ReviewTranscriptBuilder(fallbackOpts)
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
// No manifest yet: the run's mode is genuinely unknown at nudge
|
|
370
|
+
// time (capture sets it). Recording `mode: 'normal'` here would
|
|
371
|
+
// rewrite a dry-run run into a `normal` one in the status/badge
|
|
372
|
+
// until capture lands — `null` honestly says "not captured yet".
|
|
373
|
+
builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: null, approval })
|
|
374
|
+
}
|
|
375
|
+
builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
|
|
376
|
+
const persisted = await persistChecked(file, builder.serialize())
|
|
377
|
+
if (!persisted.ok) {
|
|
378
|
+
return { operation: 'nudge', updated: false, error: persisted.error }
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
operation: 'nudge',
|
|
382
|
+
updated: true,
|
|
383
|
+
transcript: builder.serialize() as unknown as JsonValue,
|
|
384
|
+
}
|
|
385
|
+
})
|
|
357
386
|
}
|
|
358
387
|
|
|
359
388
|
// capture
|
package/src/tools/triage.ts
CHANGED
|
@@ -6,6 +6,7 @@ import yaml from 'js-yaml'
|
|
|
6
6
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
7
7
|
import { writeTextAtomic } from '../atomic-fs.ts'
|
|
8
8
|
import { configBackupSuffix } from '../config-write.ts'
|
|
9
|
+
import { withProjectLock } from '../file-lock.ts'
|
|
9
10
|
import { WHOLE_FILE_LINE } from '../evidence.ts'
|
|
10
11
|
import type { KnownIntentional } from '../types.ts'
|
|
11
12
|
|
|
@@ -268,56 +269,62 @@ function applyEntries(
|
|
|
268
269
|
error: string
|
|
269
270
|
} {
|
|
270
271
|
const configPath = join(projectRoot, CONFIG_FILE)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
272
|
+
// Serialize against `iterate_config write` (SAME lock name) so a triage apply
|
|
273
|
+
// and a config write on one project cannot interleave their read-modify-write
|
|
274
|
+
// and silently drop each other's updates. The lock spans read → merge →
|
|
275
|
+
// backup → write → backup-prune so the whole cycle is atomic.
|
|
276
|
+
return withProjectLock(projectRoot, 'config-write', () => {
|
|
277
|
+
let config: Record<string, unknown>
|
|
278
|
+
try {
|
|
279
|
+
config = readConfigFile(configPath)
|
|
280
|
+
} catch (err) {
|
|
281
|
+
// The file exists but is malformed — refuse to overwrite user data.
|
|
282
|
+
return { ok: false, error: `Failed to read config: ${String(err)}` }
|
|
283
|
+
}
|
|
284
|
+
const existing = readKnownIntentional(config)
|
|
285
|
+
const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming)
|
|
286
|
+
const nextConfig = buildConfigWithKnownIntentional(config, merged)
|
|
281
287
|
|
|
282
|
-
|
|
283
|
-
|
|
288
|
+
const hadFile = existsSync(configPath)
|
|
289
|
+
const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null
|
|
290
|
+
|
|
291
|
+
if (backupPath) {
|
|
292
|
+
try {
|
|
293
|
+
copyFileSync(configPath, backupPath)
|
|
294
|
+
} catch (err) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
error: `Failed to create backup: ${String(err)}`,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
284
301
|
|
|
285
|
-
|
|
302
|
+
const yamlText = yaml.dump(nextConfig, { noRefs: true })
|
|
286
303
|
try {
|
|
287
|
-
|
|
304
|
+
writeTextAtomic(configPath, yamlText)
|
|
288
305
|
} catch (err) {
|
|
306
|
+
// Rollback: restore the backup, or REMOVE the file we just created when
|
|
307
|
+
// there was no prior config — an empty file left behind would poison all
|
|
308
|
+
// future config reads (empty YAML is not a valid mapping).
|
|
309
|
+
let rollbackError = ''
|
|
310
|
+
try {
|
|
311
|
+
if (backupPath) copyFileSync(backupPath, configPath)
|
|
312
|
+
else if (existsSync(configPath)) rmSync(configPath, { force: true })
|
|
313
|
+
} catch (rbErr) {
|
|
314
|
+
rollbackError = `; rollback also failed: ${String(rbErr)}`
|
|
315
|
+
}
|
|
289
316
|
return {
|
|
290
317
|
ok: false,
|
|
291
|
-
error: `Failed to
|
|
318
|
+
error: `Failed to write config: ${String(err)}${rollbackError}`,
|
|
292
319
|
}
|
|
293
320
|
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const yamlText = yaml.dump(nextConfig, { noRefs: true })
|
|
297
|
-
try {
|
|
298
|
-
writeTextAtomic(configPath, yamlText)
|
|
299
|
-
} catch (err) {
|
|
300
|
-
// Rollback: restore the backup, or REMOVE the file we just created when
|
|
301
|
-
// there was no prior config — an empty file left behind would poison all
|
|
302
|
-
// future config reads (empty YAML is not a valid mapping).
|
|
303
|
-
let rollbackError = ''
|
|
304
|
-
try {
|
|
305
|
-
if (backupPath) copyFileSync(backupPath, configPath)
|
|
306
|
-
else if (existsSync(configPath)) rmSync(configPath, { force: true })
|
|
307
|
-
} catch (rbErr) {
|
|
308
|
-
rollbackError = `; rollback also failed: ${String(rbErr)}`
|
|
309
|
-
}
|
|
310
|
-
return {
|
|
311
|
-
ok: false,
|
|
312
|
-
error: `Failed to write config: ${String(err)}${rollbackError}`,
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
321
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
322
|
+
// Success: bound the accumulation of timestamped backups so a long-lived
|
|
323
|
+
// project never collects an unbounded pile of config snapshots.
|
|
324
|
+
if (backupPath) pruneOldConfigBackups(configPath)
|
|
319
325
|
|
|
320
|
-
|
|
326
|
+
return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath }
|
|
327
|
+
})
|
|
321
328
|
}
|
|
322
329
|
|
|
323
330
|
/**
|
package/src/transcript.ts
CHANGED
|
@@ -565,7 +565,7 @@ export class ReviewTranscriptBuilder {
|
|
|
565
565
|
return
|
|
566
566
|
}
|
|
567
567
|
this.checkpoint = {
|
|
568
|
-
mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode :
|
|
568
|
+
mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode : null,
|
|
569
569
|
round: typeof state.round === 'number' ? state.round : 0,
|
|
570
570
|
maxRounds: typeof state.maxRounds === 'number' ? state.maxRounds : 0,
|
|
571
571
|
fixedCount: typeof state.fixedCount === 'number' ? state.fixedCount : 0,
|
|
@@ -804,7 +804,7 @@ function normalizeCheckpointRow(input: unknown): TranscriptCheckpoint | null {
|
|
|
804
804
|
const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0
|
|
805
805
|
if (round <= 0) return null
|
|
806
806
|
return {
|
|
807
|
-
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode :
|
|
807
|
+
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
|
|
808
808
|
round: clampRoundValue(c.round),
|
|
809
809
|
maxRounds: clampCountValue(c.maxRounds),
|
|
810
810
|
fixedCount: clampCountValue(c.fixedCount),
|
package/src/types.ts
CHANGED
|
@@ -320,7 +320,7 @@ export interface TranscriptEntry {
|
|
|
320
320
|
|
|
321
321
|
/** Checkpoint summary surfaced for resume actions (F5). */
|
|
322
322
|
export interface TranscriptCheckpoint {
|
|
323
|
-
mode: 'dry-run' | 'normal'
|
|
323
|
+
mode: 'dry-run' | 'normal' | null
|
|
324
324
|
round: number
|
|
325
325
|
maxRounds: number
|
|
326
326
|
fixedCount: number
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
|
|
38
|
+
/** Minimal structural view of dsh's `WorkingDirectoryService` (0.2.1-alpha.2+). */
|
|
39
|
+
interface WorkingDirectoryServiceLike {
|
|
40
|
+
get(session: unknown): string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Minimal structural view of the cordis context surface this module uses. */
|
|
44
|
+
interface ContextLike {
|
|
45
|
+
get?(name: string, strict?: boolean): unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The plugin context, installed once by `apply()`; null on hosts without it. */
|
|
49
|
+
let installedContext: ContextLike | null = null
|
|
50
|
+
|
|
51
|
+
/** True when `value` looks like a cordis context carrying `ctx.get`. */
|
|
52
|
+
function isContextLike(value: unknown): value is ContextLike {
|
|
53
|
+
return (
|
|
54
|
+
typeof value === 'object' &&
|
|
55
|
+
value !== null &&
|
|
56
|
+
typeof (value as ContextLike).get === 'function'
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Install the plugin context used to look up the `workingDirectory` service.
|
|
62
|
+
* Passing anything without a callable `get` clears the installation (tests and
|
|
63
|
+
* hosts that load the plugin into a degraded context).
|
|
64
|
+
*/
|
|
65
|
+
export function installWorkingDirectoryContext(ctx: unknown): void {
|
|
66
|
+
installedContext = isContextLike(ctx) ? ctx : null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Forget the installed context (test isolation / unload). */
|
|
70
|
+
export function resetWorkingDirectoryContext(): void {
|
|
71
|
+
installedContext = null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the host's committed working directory for `session`, if the host
|
|
76
|
+
* provides the service and it answers with a usable absolute path.
|
|
77
|
+
*
|
|
78
|
+
* Returns `undefined` for every degradation path so callers keep their
|
|
79
|
+
* previous `header.cwd` behavior.
|
|
80
|
+
*/
|
|
81
|
+
function serviceWorkingDirectory(session: unknown): string | undefined {
|
|
82
|
+
if (session === undefined || session === null) return undefined
|
|
83
|
+
const ctx = installedContext
|
|
84
|
+
if (!ctx) return undefined
|
|
85
|
+
try {
|
|
86
|
+
const service = ctx.get?.('workingDirectory')
|
|
87
|
+
if (!service || typeof (service as WorkingDirectoryServiceLike).get !== 'function') return undefined
|
|
88
|
+
const dir = (service as WorkingDirectoryServiceLike).get(session)
|
|
89
|
+
// A relative or empty directory would move the project root somewhere
|
|
90
|
+
// unspecified — refuse it and keep the header value instead.
|
|
91
|
+
if (typeof dir !== 'string' || dir.length === 0) return undefined
|
|
92
|
+
return dir
|
|
93
|
+
} catch {
|
|
94
|
+
// Missing/disposed service, unpublished session, or a throwing getter —
|
|
95
|
+
// degrade to the historical source rather than failing the tool call.
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The effective working directory of a session: the host's committed
|
|
102
|
+
* directory when available, else the immutable session header cwd.
|
|
103
|
+
*
|
|
104
|
+
* Returns `undefined` only when neither source yields a usable path.
|
|
105
|
+
*/
|
|
106
|
+
export function effectiveSessionCwd(session: unknown, headerCwd?: string): string | undefined {
|
|
107
|
+
const fromService = serviceWorkingDirectory(session)
|
|
108
|
+
if (fromService !== undefined) return fromService
|
|
109
|
+
return typeof headerCwd === 'string' && headerCwd.length > 0 ? headerCwd : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Extract the effective cwd from a tool execution (`exec.agent.session`),
|
|
114
|
+
* reading every property defensively: a hostile/proxied exec must degrade to
|
|
115
|
+
* the fallback path, never throw out of an approval or capture hook.
|
|
116
|
+
*/
|
|
117
|
+
export function sessionCwdOf(exec: unknown): string | undefined {
|
|
118
|
+
try {
|
|
119
|
+
const agent = (exec as { agent?: { session?: { header?: { cwd?: unknown } } } } | null | undefined)?.agent
|
|
120
|
+
const session = agent?.session
|
|
121
|
+
const headerCwd = typeof agent?.session?.header?.cwd === 'string' ? agent.session.header.cwd : undefined
|
|
122
|
+
return effectiveSessionCwd(session, headerCwd)
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
}
|