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/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
|