iterate-plugin 3.7.0 → 3.9.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 +43 -8
- package/README.zh-CN.md +26 -5
- package/dist/file-lock.js +75 -14
- package/dist/git-scope.js +22 -2
- package/dist/index.js +9 -4
- package/dist/report-render.js +452 -0
- package/dist/session-record.js +246 -0
- package/dist/skill-prompt.js +4 -3
- 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/report.js +239 -0
- package/dist/tools/transcript.js +260 -53
- package/dist/tools/triage.js +52 -45
- package/dist/transcript.js +2 -2
- package/lib/client.js +486 -22
- package/lib/parse.js +29 -6
- package/package.json +38 -13
- package/src/client/i18n.ts +6 -0
- package/src/client/index.ts +66 -4
- package/src/file-lock.ts +84 -14
- package/src/git-scope.ts +21 -2
- package/src/index.ts +9 -4
- package/src/report-render.ts +578 -0
- package/src/session-record.ts +333 -0
- package/src/skill-prompt.ts +4 -3
- 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/report.ts +244 -0
- package/src/tools/transcript.ts +271 -52
- package/src/tools/triage.ts +47 -40
- package/src/transcript.ts +2 -2
- package/src/types.ts +1 -1
package/dist/tools/transcript.js
CHANGED
|
@@ -23,12 +23,83 @@ 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";
|
|
29
30
|
import { ReviewTranscriptBuilder, MAX_TIMELINE, MAX_ROUNDS, normalizeManifestBounds } from "../transcript.js";
|
|
30
31
|
import { readDecisionLogDetailed } from "./decision-log.js";
|
|
31
32
|
import { readLive } from "../live.js";
|
|
33
|
+
import { buildRunRecord, appendRunRecord, readRunRecords, sessionOf, } from "../session-record.js";
|
|
34
|
+
/** Severity tally over a manifest's (globally deduped) findings. */
|
|
35
|
+
function severityCounts(findings) {
|
|
36
|
+
const counts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 };
|
|
37
|
+
if (!Array.isArray(findings))
|
|
38
|
+
return counts;
|
|
39
|
+
for (const f of findings) {
|
|
40
|
+
if (!f || typeof f !== 'object')
|
|
41
|
+
continue;
|
|
42
|
+
const sev = f.severity;
|
|
43
|
+
counts.total++;
|
|
44
|
+
if (sev === 'critical')
|
|
45
|
+
counts.critical++;
|
|
46
|
+
else if (sev === 'high')
|
|
47
|
+
counts.high++;
|
|
48
|
+
else if (sev === 'medium')
|
|
49
|
+
counts.medium++;
|
|
50
|
+
else
|
|
51
|
+
counts.low++;
|
|
52
|
+
}
|
|
53
|
+
return counts;
|
|
54
|
+
}
|
|
55
|
+
/** Build the compact session-linked record from a finished manifest. */
|
|
56
|
+
function runRecordFromManifest(manifest, qualityStatus = null) {
|
|
57
|
+
return buildRunRecord({
|
|
58
|
+
project: manifest.project,
|
|
59
|
+
goal: manifest.goal,
|
|
60
|
+
mode: manifest.mode,
|
|
61
|
+
taskMode: manifest.taskMode,
|
|
62
|
+
round: manifest.round,
|
|
63
|
+
maxRounds: manifest.maxRounds,
|
|
64
|
+
active: manifest.active,
|
|
65
|
+
stoppedReason: manifest.stoppedReason,
|
|
66
|
+
convergence: manifest.convergence,
|
|
67
|
+
counts: severityCounts(manifest.findings),
|
|
68
|
+
fixedCount: Array.isArray(manifest.fixes) ? manifest.fixes.filter((f) => f.success !== false).length : 0,
|
|
69
|
+
qualityStatus,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Rebuild a (summary-only) transcript manifest from a session-linked record.
|
|
74
|
+
*
|
|
75
|
+
* Degraded by design: the record carries the run SUMMARY (mode / goal / round /
|
|
76
|
+
* max-rounds / end state / convergence series), not the per-finding detail, so
|
|
77
|
+
* the recovered manifest has an empty `findings`/`fixes`/`timeline`. It exists
|
|
78
|
+
* so the observatory shows the real run header + trend after `.iterate/` is
|
|
79
|
+
* lost, instead of a blank panel. Round rows stay empty because the record has
|
|
80
|
+
* no per-thread narration.
|
|
81
|
+
*/
|
|
82
|
+
export function recoverManifestFromRecord(record, fallbackProject, approval) {
|
|
83
|
+
const builder = new ReviewTranscriptBuilder({
|
|
84
|
+
project: record.project || fallbackProject,
|
|
85
|
+
mode: record.mode,
|
|
86
|
+
taskMode: record.taskMode,
|
|
87
|
+
approval,
|
|
88
|
+
goal: record.goal,
|
|
89
|
+
maxRounds: record.maxRounds,
|
|
90
|
+
});
|
|
91
|
+
const series = Array.isArray(record.convergence) ? record.convergence : [];
|
|
92
|
+
for (let i = 0; i < Math.min(series.length, MAX_ROUNDS); i += 1) {
|
|
93
|
+
const n = series[i];
|
|
94
|
+
if (typeof n === 'number' && Number.isFinite(n) && n >= 0)
|
|
95
|
+
builder.snapshotConvergence(i + 1, n);
|
|
96
|
+
}
|
|
97
|
+
if (record.round > 0)
|
|
98
|
+
builder.advanceRound(record.round, record.maxRounds);
|
|
99
|
+
if (!record.active)
|
|
100
|
+
builder.finish(record.stoppedReason ?? undefined);
|
|
101
|
+
return builder.serialize();
|
|
102
|
+
}
|
|
32
103
|
/** Build per-dimension threads for one round from its (dimension-tagged) findings. */
|
|
33
104
|
function captureRound(builder, round) {
|
|
34
105
|
if (!round || typeof round !== 'object')
|
|
@@ -73,7 +144,7 @@ function normalizeCheckpoint(input) {
|
|
|
73
144
|
if (round <= 0)
|
|
74
145
|
return null;
|
|
75
146
|
return {
|
|
76
|
-
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode :
|
|
147
|
+
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
|
|
77
148
|
round,
|
|
78
149
|
maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
|
|
79
150
|
fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
|
|
@@ -142,26 +213,36 @@ function isManifestShaped(value) {
|
|
|
142
213
|
export function registerTranscriptTool(ctx) {
|
|
143
214
|
ctx.tools.register(defineTool({
|
|
144
215
|
name: 'iterate_transcript',
|
|
145
|
-
// `read` never
|
|
146
|
-
// read
|
|
147
|
-
isConcurrencySafe: (args) =>
|
|
216
|
+
// `read`/`recall` never write; `capture`/`nudge` persist transcript state
|
|
217
|
+
// → only the read shapes join a parallel dispatch group.
|
|
218
|
+
isConcurrencySafe: (args) => {
|
|
219
|
+
const op = args.operation;
|
|
220
|
+
return op === 'read' || op === 'recall';
|
|
221
|
+
},
|
|
148
222
|
description: 'Runtime-observatory transcript for the iterate workflow. ' +
|
|
149
223
|
'`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
|
|
150
224
|
'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
|
|
151
225
|
'written for the next round) — or, when the file is missing/malformed, a structured ' +
|
|
152
|
-
'empty view (`found:false` plus a blank manifest). ' +
|
|
226
|
+
'empty view (`found:false` plus a blank manifest). When the on-disk file is gone but the ' +
|
|
227
|
+
'session carries a linked run record (dsh ≥ 0.2.1-rc.1), `read` RECOVERS a summary manifest ' +
|
|
228
|
+
'from that record and sets `recovered:true`. ' +
|
|
153
229
|
'`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
|
|
154
230
|
'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
|
|
155
231
|
'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
|
|
156
232
|
'can be carried on capture via `validations` ({round, command, exitCode, allowed, rejectReason?}). ' +
|
|
233
|
+
'On a successful capture the tool ALSO appends a compact run record onto the dsh session log ' +
|
|
234
|
+
'(best-effort; reported as `sessionRecord`) so the run survives `.iterate/` loss and travels with ' +
|
|
235
|
+
'the session. ' +
|
|
236
|
+
'`recall` lists the session-linked run records (newest first) — useful to audit a run after ' +
|
|
237
|
+
'restart or to confirm the write took. ' +
|
|
157
238
|
'`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
|
|
158
239
|
'Purely local and deterministic — never touches source files.',
|
|
159
240
|
parameters: {
|
|
160
241
|
operation: {
|
|
161
242
|
type: 'string',
|
|
162
243
|
required: true,
|
|
163
|
-
description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
|
|
164
|
-
enum: ['read', 'capture', 'nudge'],
|
|
244
|
+
description: '"read" to fetch the manifest, "capture" to persist one, "recall" to list session-linked run records, "nudge" to set steering text.',
|
|
245
|
+
enum: ['read', 'capture', 'recall', 'nudge'],
|
|
165
246
|
},
|
|
166
247
|
rounds: {
|
|
167
248
|
type: 'json',
|
|
@@ -198,7 +279,23 @@ export function registerTranscriptTool(ctx) {
|
|
|
198
279
|
'the run console can show which commands ran and whether they passed.',
|
|
199
280
|
},
|
|
200
281
|
refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
|
|
201
|
-
stoppedReason: {
|
|
282
|
+
stoppedReason: {
|
|
283
|
+
type: 'string',
|
|
284
|
+
// Enum guard: the label renderer keys off this value, so a stray or
|
|
285
|
+
// prototype-chain string (e.g. "__proto__") must not enter the tool.
|
|
286
|
+
// The render path additionally uses Object.hasOwn (defense for
|
|
287
|
+
// hand-edited manifests that bypass this schema).
|
|
288
|
+
enum: [
|
|
289
|
+
'converged',
|
|
290
|
+
'max_rounds_reached',
|
|
291
|
+
'aborted_by_validation',
|
|
292
|
+
'aborted_by_config',
|
|
293
|
+
'schema_invalid',
|
|
294
|
+
'no_usable_reviewer_output',
|
|
295
|
+
'inconclusive',
|
|
296
|
+
],
|
|
297
|
+
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).'
|
|
298
|
+
},
|
|
202
299
|
// `string | null` (NOT plain `type: 'string'`): dsh validates arguments
|
|
203
300
|
// against the compiled schema BEFORE execute, and the docs — plus the
|
|
204
301
|
// client's clear button — advertise `{"operation":"nudge","text":null}`.
|
|
@@ -227,6 +324,19 @@ export function registerTranscriptTool(ctx) {
|
|
|
227
324
|
description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
|
|
228
325
|
},
|
|
229
326
|
reason: { type: 'string', description: 'For a skipped `capture`: why persistence was skipped.' },
|
|
327
|
+
recovered: {
|
|
328
|
+
type: 'boolean',
|
|
329
|
+
description: 'For `read`: true when the manifest was rebuilt from a session-linked run record because the on-disk file was missing or unreadable.',
|
|
330
|
+
},
|
|
331
|
+
supported: {
|
|
332
|
+
type: 'boolean',
|
|
333
|
+
description: 'For `recall`: whether the host exposes the dsh session state-record API (dsh ≥ 0.2.1-rc.1).',
|
|
334
|
+
},
|
|
335
|
+
records: { type: 'json', description: 'For `recall`: session-linked run records, newest first.' },
|
|
336
|
+
sessionRecord: {
|
|
337
|
+
type: 'json',
|
|
338
|
+
description: 'For `capture`/`read`: the session-link outcome/position (`{linked, seq}` on capture, `{seq, updatedAt}` on a recovery).',
|
|
339
|
+
},
|
|
230
340
|
error: { type: 'string' },
|
|
231
341
|
},
|
|
232
342
|
},
|
|
@@ -256,13 +366,59 @@ export function registerTranscriptTool(ctx) {
|
|
|
256
366
|
approval,
|
|
257
367
|
}).serialize(),
|
|
258
368
|
});
|
|
259
|
-
|
|
369
|
+
// Recovery (dsh ≥ 0.2.1-rc.1): when the on-disk manifest is gone
|
|
370
|
+
// but the SESSION that produced the run carries a linked run record,
|
|
371
|
+
// rebuild a summary manifest so the observatory shows the real run
|
|
372
|
+
// header + trend instead of a blank panel. Best-effort: no session,
|
|
373
|
+
// no API, or no record all fall back to `emptyView()`.
|
|
374
|
+
const recover = async () => {
|
|
375
|
+
const session = sessionOf(exec);
|
|
376
|
+
if (!session)
|
|
377
|
+
return null;
|
|
378
|
+
const { supported, records } = await readRunRecords(session);
|
|
379
|
+
if (!supported || records.length === 0)
|
|
380
|
+
return null;
|
|
381
|
+
const latest = records[records.length - 1];
|
|
382
|
+
return {
|
|
383
|
+
transcript: normalizeManifestBounds(recoverManifestFromRecord(latest, projectRoot, approval)),
|
|
384
|
+
sessionRecord: {
|
|
385
|
+
seq: latest.seq,
|
|
386
|
+
time: latest.time,
|
|
387
|
+
updatedAt: latest.updatedAt,
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
};
|
|
391
|
+
if (!existsSync(file)) {
|
|
392
|
+
const recovered = await recover();
|
|
393
|
+
if (recovered) {
|
|
394
|
+
return {
|
|
395
|
+
operation: 'read',
|
|
396
|
+
found: true,
|
|
397
|
+
recovered: true,
|
|
398
|
+
live: live,
|
|
399
|
+
transcript: recovered.transcript,
|
|
400
|
+
sessionRecord: recovered.sessionRecord,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
260
403
|
return emptyView();
|
|
404
|
+
}
|
|
261
405
|
let parsed;
|
|
262
406
|
try {
|
|
263
407
|
parsed = JSON.parse(await readFile(file, 'utf-8'));
|
|
264
408
|
}
|
|
265
409
|
catch (err) {
|
|
410
|
+
const recovered = await recover();
|
|
411
|
+
if (recovered) {
|
|
412
|
+
return {
|
|
413
|
+
operation: 'read',
|
|
414
|
+
found: true,
|
|
415
|
+
recovered: true,
|
|
416
|
+
live: live,
|
|
417
|
+
transcript: recovered.transcript,
|
|
418
|
+
sessionRecord: recovered.sessionRecord,
|
|
419
|
+
error: `Recovered a summary from the session log; on-disk transcript was unreadable (${err instanceof Error ? err.message : String(err)}).`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
266
422
|
return {
|
|
267
423
|
...emptyView(),
|
|
268
424
|
error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -272,6 +428,18 @@ export function registerTranscriptTool(ctx) {
|
|
|
272
428
|
// `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
|
|
273
429
|
// are parseable but carry no manifest. Surfacing them as
|
|
274
430
|
// `found:true` made the observatory render junk panels.
|
|
431
|
+
const recovered = await recover();
|
|
432
|
+
if (recovered) {
|
|
433
|
+
return {
|
|
434
|
+
operation: 'read',
|
|
435
|
+
found: true,
|
|
436
|
+
recovered: true,
|
|
437
|
+
live: live,
|
|
438
|
+
transcript: recovered.transcript,
|
|
439
|
+
sessionRecord: recovered.sessionRecord,
|
|
440
|
+
error: 'Recovered a summary from the session log; on-disk file is not a valid manifest.',
|
|
441
|
+
};
|
|
442
|
+
}
|
|
275
443
|
return {
|
|
276
444
|
...emptyView(),
|
|
277
445
|
error: 'Transcript file is not a valid manifest; returning an empty view.',
|
|
@@ -287,53 +455,81 @@ export function registerTranscriptTool(ctx) {
|
|
|
287
455
|
transcript: normalizeManifestBounds(parsed),
|
|
288
456
|
};
|
|
289
457
|
}
|
|
458
|
+
if (args.operation === 'recall') {
|
|
459
|
+
// New capability: list the run records this plugin appended onto the
|
|
460
|
+
// dsh session log (dsh ≥ 0.2.1-rc.1). Read-only; degrades to
|
|
461
|
+
// `supported:false` on a host without the API.
|
|
462
|
+
const session = sessionOf(exec);
|
|
463
|
+
const { supported, records, error } = await readRunRecords(session);
|
|
464
|
+
const newestFirst = [...records].reverse();
|
|
465
|
+
return {
|
|
466
|
+
operation: 'recall',
|
|
467
|
+
found: newestFirst.length > 0,
|
|
468
|
+
supported,
|
|
469
|
+
records: newestFirst,
|
|
470
|
+
...(error ? { error } : {}),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
290
473
|
if (args.operation === 'nudge') {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
474
|
+
// Read-modify-write across processes: a concurrent nudge in a sibling
|
|
475
|
+
// process would otherwise read the same manifest and write back over
|
|
476
|
+
// each other's steering text. Serialize on the shared transcript lock
|
|
477
|
+
// (same pattern as the config/triage writers). The body still awaits,
|
|
478
|
+
// so the lock is held across the read and write; a same-process
|
|
479
|
+
// re-entry degrades to unlocked (the advisory lock is per-pid), which
|
|
480
|
+
// is the documented best-effort fallback for a low-value projection.
|
|
481
|
+
return withProjectLock(projectRoot, 'transcript', async () => {
|
|
482
|
+
let manifest = null;
|
|
483
|
+
if (existsSync(file)) {
|
|
484
|
+
try {
|
|
485
|
+
const parsed = JSON.parse(await readFile(file, 'utf-8'));
|
|
486
|
+
manifest = parsed;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
manifest = null;
|
|
490
|
+
}
|
|
296
491
|
}
|
|
297
|
-
|
|
298
|
-
|
|
492
|
+
// Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
|
|
493
|
+
// or a non-object) must never crash the nudge — degrade to a fresh
|
|
494
|
+
// builder so the steering text still lands. The fallback must keep
|
|
495
|
+
// the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
|
|
496
|
+
// stray malformed field can never silently rewrite a dry-run run
|
|
497
|
+
// into a `normal` one.
|
|
498
|
+
const fallbackOpts = {
|
|
499
|
+
project: projectRoot,
|
|
500
|
+
mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null),
|
|
501
|
+
taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null),
|
|
502
|
+
approval,
|
|
503
|
+
goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
|
|
504
|
+
maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
|
|
505
|
+
};
|
|
506
|
+
let builder;
|
|
507
|
+
if (manifest) {
|
|
508
|
+
try {
|
|
509
|
+
builder = rehydrateBuilder(manifest, approval);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
builder = new ReviewTranscriptBuilder(fallbackOpts);
|
|
513
|
+
}
|
|
299
514
|
}
|
|
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);
|
|
515
|
+
else {
|
|
516
|
+
// No manifest yet: the run's mode is genuinely unknown at nudge
|
|
517
|
+
// time (capture sets it). Recording `mode: 'normal'` here would
|
|
518
|
+
// rewrite a dry-run run into a `normal` one in the status/badge
|
|
519
|
+
// until capture lands — `null` honestly says "not captured yet".
|
|
520
|
+
builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: null, approval });
|
|
319
521
|
}
|
|
320
|
-
|
|
321
|
-
|
|
522
|
+
builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null);
|
|
523
|
+
const persisted = await persistChecked(file, builder.serialize());
|
|
524
|
+
if (!persisted.ok) {
|
|
525
|
+
return { operation: 'nudge', updated: false, error: persisted.error };
|
|
322
526
|
}
|
|
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
|
-
};
|
|
527
|
+
return {
|
|
528
|
+
operation: 'nudge',
|
|
529
|
+
updated: true,
|
|
530
|
+
transcript: builder.serialize(),
|
|
531
|
+
};
|
|
532
|
+
});
|
|
337
533
|
}
|
|
338
534
|
// capture
|
|
339
535
|
// `observatory.capture: false` opts the project out of transcript
|
|
@@ -450,17 +646,28 @@ export function registerTranscriptTool(ctx) {
|
|
|
450
646
|
builder.finish('converged');
|
|
451
647
|
else if (roundsExecuted > 0)
|
|
452
648
|
builder.finish('max_rounds_reached');
|
|
453
|
-
const
|
|
649
|
+
const manifest = builder.serialize();
|
|
650
|
+
const persisted = await persistChecked(file, manifest);
|
|
454
651
|
if (!persisted.ok) {
|
|
455
652
|
return { operation: 'capture', found: true, updated: false, error: persisted.error };
|
|
456
653
|
}
|
|
654
|
+
// Session link (dsh ≥ 0.2.1-rc.1): append a compact run record onto the
|
|
655
|
+
// session log so the run survives `.iterate/` loss and travels with the
|
|
656
|
+
// session. Best-effort — a link failure is reported, never fatal (the
|
|
657
|
+
// on-disk capture above already succeeded).
|
|
658
|
+
let sessionRecord;
|
|
659
|
+
const linkSession = await appendRunRecord(sessionOf(exec), runRecordFromManifest(manifest));
|
|
660
|
+
sessionRecord = (linkSession.ok
|
|
661
|
+
? { linked: true, seq: linkSession.seq }
|
|
662
|
+
: { linked: false, supported: linkSession.supported, reason: linkSession.error });
|
|
457
663
|
const live = await readLive(projectRoot);
|
|
458
664
|
return {
|
|
459
665
|
operation: 'capture',
|
|
460
666
|
found: true,
|
|
461
667
|
updated: true,
|
|
462
668
|
live: live,
|
|
463
|
-
transcript:
|
|
669
|
+
transcript: manifest,
|
|
670
|
+
sessionRecord,
|
|
464
671
|
};
|
|
465
672
|
},
|
|
466
673
|
}));
|
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),
|