iterate-plugin 2.11.0 → 2.12.1
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 +41 -1
- package/README.zh-CN.md +39 -1
- package/dist/approval-gate.js +92 -0
- package/dist/config-loader.js +4 -0
- package/dist/index.js +15 -5
- package/dist/live.js +155 -0
- package/dist/paths.js +4 -0
- package/dist/session-hooks.js +89 -0
- package/dist/skill-prompt.js +56 -4
- package/dist/tools/transcript.js +324 -0
- package/dist/transcript.js +421 -0
- package/lib/client.js +865 -0
- package/lib/parse.js +276 -0
- package/package.json +1 -1
- package/src/approval-gate.ts +119 -0
- package/src/client/index.ts +710 -6
- package/src/config-loader.ts +4 -0
- package/src/index.ts +17 -6
- package/src/live.ts +185 -0
- package/src/paths.ts +5 -0
- package/src/session-hooks.ts +90 -0
- package/src/skill-prompt.ts +56 -4
- package/src/tools/transcript.ts +334 -0
- package/src/transcript.ts +475 -0
- package/src/types.ts +117 -0
package/dist/skill-prompt.js
CHANGED
|
@@ -22,6 +22,7 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
22
22
|
- \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint left on disk means the previous run was interrupted and can be resumed)
|
|
23
23
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
24
24
|
- \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
|
|
25
|
+
- \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
|
|
25
26
|
|
|
26
27
|
### When to use
|
|
27
28
|
When the user asks to review or iterate on the project (e.g. "review this project", "iterate on error handling", "check the codebase for issues", "dry-run review", "反复审查"), run an iterate **workflow** by calling the \`workflow\` tool.
|
|
@@ -77,6 +78,14 @@ const knownIntentional = (plan.knownIntentional || []) // config personalizati
|
|
|
77
78
|
let known = [] // cumulative DEDUPED findings fed back to reviewers
|
|
78
79
|
const rounds = [] // raw per-round findings
|
|
79
80
|
|
|
81
|
+
phase('transcript')
|
|
82
|
+
// Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
|
|
83
|
+
const transRead = await agent(
|
|
84
|
+
'Call iterate_transcript({operation:"read"}) and return {nudge:<transcript.nudge ? transcript.nudge.text : null>}.',
|
|
85
|
+
Object.assign({ label: 'transcript:read' }, backend)
|
|
86
|
+
)
|
|
87
|
+
const steering = transRead && typeof transRead.nudge === 'string' && transRead.nudge ? transRead.nudge : null
|
|
88
|
+
|
|
80
89
|
phase('review')
|
|
81
90
|
for (let r = 1; r <= maxRounds; r++) {
|
|
82
91
|
log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
|
|
@@ -98,6 +107,7 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
98
107
|
? meta.reviewerPrompt
|
|
99
108
|
: 'Review dimension "' + dim + '".'
|
|
100
109
|
const extra =
|
|
110
|
+
(steering ? '\\n STEERING — read this first: ' + steering : '') +
|
|
101
111
|
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
102
112
|
'\\n Already-known findings (do NOT re-report): ' +
|
|
103
113
|
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
@@ -156,6 +166,13 @@ const metaRes = await agent(
|
|
|
156
166
|
const finalReport = metaRes && metaRes.finalReport ? metaRes.finalReport : null
|
|
157
167
|
const metaAudit = finalReport && finalReport.metaReview ? finalReport.metaReview : null
|
|
158
168
|
|
|
169
|
+
// Persist the run's observatory transcript (reviewer threads, trend, findings)
|
|
170
|
+
// so the client observatory panel reflects this review. Writes ONLY .iterate/transcript.json.
|
|
171
|
+
await agent(
|
|
172
|
+
'Call iterate_transcript({operation:"capture", mode:"dry-run", goal:' + JSON.stringify(report.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + report.convergence.totalRounds + ', findingsByRound:' + JSON.stringify(report.convergence.findingsByRound || []) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
|
|
173
|
+
Object.assign({ label: 'transcript:capture' }, backend)
|
|
174
|
+
)
|
|
175
|
+
|
|
159
176
|
return {
|
|
160
177
|
mode: 'dry-run',
|
|
161
178
|
goal: report.goal,
|
|
@@ -180,7 +197,7 @@ Key rules for dry-run:
|
|
|
180
197
|
- Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
|
|
181
198
|
- The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
|
|
182
199
|
- **Meta-review**: after building the report, audit it with \`iterate_review({operation:"meta-review"})\` for internal consistency (counts, severity buckets, dimension sums, sort order, convergence math). The meta-review ALSO runs the hard code-evidence gate (default on): every finding's file/line is validated against real files on disk, so any fabricated location surfaces as a critical \`EVIDENCE_VIOLATION\` and flips the verdict to \`needs_revision\`. The \`finalReport.verdict\` is \`approved\` only when the report passes every check AND every finding anchors to real, read code; otherwise \`needs_revision\`. Surface the final report and its verdict as the closing deliverable.
|
|
183
|
-
- Only a single \`report\` entry may be appended to the decision log; nothing else is written.
|
|
200
|
+
- Only a single \`report\` entry may be appended to the decision log; nothing else is written to source files. The final \`iterate_transcript capture\` writes ONLY the observatory file (\`.iterate/transcript.json\`) so the client panel reflects the run — it is not a source-code write.
|
|
184
201
|
|
|
185
202
|
### Normal-mode workflow (autonomous closed loop)
|
|
186
203
|
Set \`args.mode = "normal"\`. Loop: resume → plan → parallel review ×N → atomic fixes via \`iterate_fix\` → validate → rollback on failure → checkpoint → loop → auto-stop when zero findings remain.
|
|
@@ -238,6 +255,14 @@ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? che
|
|
|
238
255
|
let converged = false
|
|
239
256
|
let abortedByValidation = false
|
|
240
257
|
let failedCommands = []
|
|
258
|
+
const fixRecords = [] // observatory fix records collected round by round
|
|
259
|
+
|
|
260
|
+
// Read any steering nudge intended for this run's reviewers.
|
|
261
|
+
const transRead = await agent(
|
|
262
|
+
'Call iterate_transcript({operation:"read"}) and return {nudge:<transcript.nudge ? transcript.nudge.text : null>}.',
|
|
263
|
+
Object.assign({ label: 'transcript:read' }, backend)
|
|
264
|
+
)
|
|
265
|
+
const steering = transRead && typeof transRead.nudge === 'string' && transRead.nudge ? transRead.nudge : null
|
|
241
266
|
|
|
242
267
|
phase('loop')
|
|
243
268
|
for (let r = startRound; r <= maxRounds; r++) {
|
|
@@ -264,6 +289,7 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
264
289
|
? meta.reviewerPrompt
|
|
265
290
|
: 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed).'
|
|
266
291
|
const extra =
|
|
292
|
+
(steering ? '\\n STEERING — read this first: ' + steering : '') +
|
|
267
293
|
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
268
294
|
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
269
295
|
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
@@ -308,12 +334,14 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
308
334
|
'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
|
|
309
335
|
'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
|
|
310
336
|
'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
|
|
311
|
-
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff
|
|
312
|
-
'
|
|
337
|
+
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff and ' +
|
|
338
|
+
'read its line statistics (lines added/removed). ' +
|
|
339
|
+
'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
|
|
340
|
+
'(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
|
|
313
341
|
Object.assign({ label: 'fix:' + file, phase: 'fix', schema: {
|
|
314
342
|
type: 'object', additionalProperties: false,
|
|
315
343
|
properties: {
|
|
316
|
-
fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
|
|
344
|
+
fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' }, file: { type: 'string' }, linesAdded: { type: 'integer' }, linesRemoved: { type: 'integer' } }, required: ['id', 'ok'] } }
|
|
317
345
|
},
|
|
318
346
|
required: ['fixes'] } }, backend)
|
|
319
347
|
)))
|
|
@@ -321,6 +349,17 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
321
349
|
if (res && Array.isArray(res.fixes)) {
|
|
322
350
|
for (const fx of res.fixes) {
|
|
323
351
|
if (fx && fx.ok === true) { fixedCount += 1; roundFixIds.push(fx.id) }
|
|
352
|
+
// Collect fix records for the observatory transcript (defensive defaults).
|
|
353
|
+
const fixFileKeys = Object.keys(byFile)
|
|
354
|
+
fixRecords.push({
|
|
355
|
+
id: fx && typeof fx.id === 'string' ? fx.id : '',
|
|
356
|
+
file: fx && typeof fx.file === 'string' ? fx.file : (fixFileKeys.length === 1 ? fixFileKeys[0] : ''),
|
|
357
|
+
round: r,
|
|
358
|
+
summary: '',
|
|
359
|
+
linesAdded: fx && typeof fx.linesAdded === 'number' ? fx.linesAdded : 0,
|
|
360
|
+
linesRemoved: fx && typeof fx.linesRemoved === 'number' ? fx.linesRemoved : 0,
|
|
361
|
+
success: !!(fx && fx.ok === true),
|
|
362
|
+
})
|
|
324
363
|
}
|
|
325
364
|
}
|
|
326
365
|
}
|
|
@@ -403,6 +442,19 @@ if (!abortedByValidation) {
|
|
|
403
442
|
{ label: 'checkpoint:clear' }
|
|
404
443
|
)
|
|
405
444
|
}
|
|
445
|
+
// Persist the run's observatory transcript (threads, trend, fixes, checkpoint)
|
|
446
|
+
// so the client observatory panel reflects the run. Writes ONLY .iterate/transcript.json.
|
|
447
|
+
const obsCheckpoint = abortedByValidation ? null : {
|
|
448
|
+
mode: 'normal',
|
|
449
|
+
round: rounds.length,
|
|
450
|
+
maxRounds: maxRounds,
|
|
451
|
+
fixedCount: fixedCount,
|
|
452
|
+
resumeCount: effectiveResumeCount,
|
|
453
|
+
}
|
|
454
|
+
await agent(
|
|
455
|
+
'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + rounds.length + ', findingsByRound:' + JSON.stringify(rounds.map(rr => (rr.findings && rr.findings.length) ? rr.findings.length : 0)) + ', fixes:' + JSON.stringify(fixRecords) + ', checkpoint:' + JSON.stringify(obsCheckpoint) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
|
|
456
|
+
{ label: 'transcript:capture' }
|
|
457
|
+
)
|
|
406
458
|
return {
|
|
407
459
|
mode: 'normal',
|
|
408
460
|
goal: plan.goal,
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/transcript.ts — `iterate_transcript` tool.
|
|
3
|
+
*
|
|
4
|
+
* Exposes the runtime-observatory manifest to the model (and, via its persisted
|
|
5
|
+
* on-disk copy, to the client observatory panel). Purely local, deterministic,
|
|
6
|
+
* and safe:
|
|
7
|
+
*
|
|
8
|
+
* - `read` — return the persisted transcript manifest (or a structured
|
|
9
|
+
* "not found" empty view). Used each round by the workflow to
|
|
10
|
+
* pick up steering nudges, and polled by tool-reading agents.
|
|
11
|
+
* - `capture` — build a fresh transcript from the review `rounds` + `report`
|
|
12
|
+
* and persist it. Called by the canonical scripts after the
|
|
13
|
+
* final aggregate so the client always sees the latest run.
|
|
14
|
+
* - `nudge` — set (`text`) or clear (`text: null`) steering text persisted
|
|
15
|
+
* for the next round's reviewers to read.
|
|
16
|
+
*
|
|
17
|
+
* All writes are persisted to `.iterate/transcript.json` via an atomic
|
|
18
|
+
* tmp+rename so a crashed writer never leaves a corrupt manifest.
|
|
19
|
+
*/
|
|
20
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
21
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { dirname } from 'node:path';
|
|
24
|
+
import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loader.js";
|
|
25
|
+
import { transcriptPath } from "../paths.js";
|
|
26
|
+
import { ReviewTranscriptBuilder } from "../transcript.js";
|
|
27
|
+
import { readLive } from "../live.js";
|
|
28
|
+
/** Build per-dimension threads for one round from its (dimension-tagged) findings. */
|
|
29
|
+
function captureRound(builder, round) {
|
|
30
|
+
if (!round || typeof round !== 'object')
|
|
31
|
+
return;
|
|
32
|
+
const r = round;
|
|
33
|
+
const roundNo = typeof r.round === 'number' ? Math.floor(r.round) : 0;
|
|
34
|
+
if (roundNo <= 0)
|
|
35
|
+
return;
|
|
36
|
+
builder.roundStart(roundNo);
|
|
37
|
+
const findings = Array.isArray(r.findings) ? r.findings : [];
|
|
38
|
+
const readFiles = Array.isArray(r.readFiles) ? r.readFiles : [];
|
|
39
|
+
// Group the round's findings by dimension → one reviewer thread each.
|
|
40
|
+
const byDim = new Map();
|
|
41
|
+
for (const f of findings) {
|
|
42
|
+
if (!f || typeof f !== 'object')
|
|
43
|
+
continue;
|
|
44
|
+
const rec = f;
|
|
45
|
+
const dim = typeof rec.dimension === 'string' && rec.dimension ? rec.dimension : 'review';
|
|
46
|
+
const list = byDim.get(dim) ?? [];
|
|
47
|
+
list.push(f);
|
|
48
|
+
byDim.set(dim, list);
|
|
49
|
+
}
|
|
50
|
+
if (byDim.size === 0) {
|
|
51
|
+
builder.reviewerSnapshot('review', [], readFiles);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
for (const [dim, list] of byDim)
|
|
55
|
+
builder.reviewerSnapshot(dim, list, readFiles);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Normalize the checkpoint shape if present. */
|
|
59
|
+
function normalizeCheckpoint(input) {
|
|
60
|
+
if (!input || typeof input !== 'object')
|
|
61
|
+
return null;
|
|
62
|
+
const c = input;
|
|
63
|
+
const round = typeof c.round === 'number' ? c.round : 0;
|
|
64
|
+
if (round <= 0)
|
|
65
|
+
return null;
|
|
66
|
+
return {
|
|
67
|
+
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
|
|
68
|
+
round,
|
|
69
|
+
maxRounds: typeof c.maxRounds === 'number' ? c.maxRounds : 0,
|
|
70
|
+
fixedCount: typeof c.fixedCount === 'number' ? c.fixedCount : 0,
|
|
71
|
+
resumeCount: typeof c.resumeCount === 'number' ? c.resumeCount : 0,
|
|
72
|
+
updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : new Date().toISOString(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Normalize a fix record. */
|
|
76
|
+
function normalizeFix(input) {
|
|
77
|
+
if (!input || typeof input !== 'object')
|
|
78
|
+
return null;
|
|
79
|
+
const f = input;
|
|
80
|
+
const id = typeof f.id === 'string' ? f.id : '';
|
|
81
|
+
const file = typeof f.file === 'string' ? f.file : '';
|
|
82
|
+
if (!id || !file)
|
|
83
|
+
return null;
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
timestamp: typeof f.timestamp === 'string' ? f.timestamp : new Date().toISOString(),
|
|
87
|
+
round: typeof f.round === 'number' ? f.round : 0,
|
|
88
|
+
file,
|
|
89
|
+
summary: typeof f.summary === 'string' ? f.summary : '',
|
|
90
|
+
linesAdded: typeof f.linesAdded === 'number' ? f.linesAdded : 0,
|
|
91
|
+
linesRemoved: typeof f.linesRemoved === 'number' ? f.linesRemoved : 0,
|
|
92
|
+
success: f.success !== false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Register the `iterate_transcript` tool. */
|
|
96
|
+
export function registerTranscriptTool(ctx) {
|
|
97
|
+
ctx.tools.register(defineTool({
|
|
98
|
+
name: 'iterate_transcript',
|
|
99
|
+
description: 'Runtime-observatory transcript for the iterate workflow. ' +
|
|
100
|
+
'`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
|
|
101
|
+
'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
|
|
102
|
+
'written for the next round). ' +
|
|
103
|
+
'`capture` builds a fresh transcript from the review `rounds` + `report` and persists it ' +
|
|
104
|
+
'(call once after the final aggregate so the UI reflects the run). ' +
|
|
105
|
+
'`nudge` sets (text) or clears (text:null) steering text the next round\'s reviewers read. ' +
|
|
106
|
+
'Purely local and deterministic — never touches source files.',
|
|
107
|
+
parameters: {
|
|
108
|
+
operation: {
|
|
109
|
+
type: 'string',
|
|
110
|
+
required: true,
|
|
111
|
+
description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
|
|
112
|
+
enum: ['read', 'capture', 'nudge'],
|
|
113
|
+
},
|
|
114
|
+
rounds: {
|
|
115
|
+
type: 'json',
|
|
116
|
+
description: 'For `capture`: per-round findings, each [{round, findings:[{dimension,file,line?,severity,summary,…}], readFiles:[…]}].',
|
|
117
|
+
},
|
|
118
|
+
report: {
|
|
119
|
+
type: 'json',
|
|
120
|
+
description: 'For `capture`: the ReviewReport (convergence.findingsByRound used for the trend).',
|
|
121
|
+
},
|
|
122
|
+
mode: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: 'For `capture`: run mode ("dry-run" | "normal"). Default dry-run.',
|
|
125
|
+
enum: ['dry-run', 'normal'],
|
|
126
|
+
},
|
|
127
|
+
goal: { type: 'string', description: 'For `capture`: run goal.' },
|
|
128
|
+
maxRounds: { type: 'integer', description: 'For `capture`: round cap.' },
|
|
129
|
+
roundsExecuted: { type: 'integer', description: 'For `capture`: number of rounds actually executed.' },
|
|
130
|
+
findingsByRound: { type: 'json', description: 'For `capture`: the per-round new-findings count series (report.convergence.findingsByRound). Preferred over passing the whole report.' },
|
|
131
|
+
checkpoint: { type: 'json', description: 'For `capture`: checkpoint summary (optional).' },
|
|
132
|
+
fixes: {
|
|
133
|
+
type: 'json',
|
|
134
|
+
description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
|
|
135
|
+
},
|
|
136
|
+
refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
|
|
137
|
+
text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
|
|
138
|
+
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
139
|
+
},
|
|
140
|
+
output: {
|
|
141
|
+
schema: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
additionalProperties: false,
|
|
144
|
+
properties: {
|
|
145
|
+
operation: { type: 'string', required: true },
|
|
146
|
+
found: { type: 'boolean' },
|
|
147
|
+
transcript: { type: 'json' },
|
|
148
|
+
live: { type: 'json', description: 'Recent live reviewer-activity entries (newest first).' },
|
|
149
|
+
updated: { type: 'boolean' },
|
|
150
|
+
error: { type: 'string' },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
154
|
+
},
|
|
155
|
+
async execute(args, exec) {
|
|
156
|
+
const resolved = resolveProjectRootForExec(exec, args.path);
|
|
157
|
+
if (!resolved.ok)
|
|
158
|
+
return { operation: args.operation, error: resolved.reason };
|
|
159
|
+
const projectRoot = resolved.root;
|
|
160
|
+
const file = transcriptPath(projectRoot);
|
|
161
|
+
const { config } = loadEffectiveConfig(projectRoot);
|
|
162
|
+
const approval = config.observatory?.approval ?? 'ask';
|
|
163
|
+
if (args.operation === 'read') {
|
|
164
|
+
const live = await readLive(projectRoot);
|
|
165
|
+
if (!existsSync(file)) {
|
|
166
|
+
return {
|
|
167
|
+
operation: 'read',
|
|
168
|
+
found: false,
|
|
169
|
+
live: live,
|
|
170
|
+
transcript: new ReviewTranscriptBuilder({
|
|
171
|
+
project: projectRoot,
|
|
172
|
+
approval,
|
|
173
|
+
}).serialize(),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const raw = await readFile(file, 'utf-8');
|
|
178
|
+
const parsed = JSON.parse(raw);
|
|
179
|
+
return {
|
|
180
|
+
operation: 'read',
|
|
181
|
+
found: true,
|
|
182
|
+
live: live,
|
|
183
|
+
transcript: parsed,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
return {
|
|
188
|
+
operation: 'read',
|
|
189
|
+
found: false,
|
|
190
|
+
error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (args.operation === 'nudge') {
|
|
195
|
+
let manifest = null;
|
|
196
|
+
if (existsSync(file)) {
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(await readFile(file, 'utf-8'));
|
|
199
|
+
manifest = parsed;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
manifest = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const builder = manifest
|
|
206
|
+
? rehydrateBuilder(manifest, approval)
|
|
207
|
+
: new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval });
|
|
208
|
+
builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null);
|
|
209
|
+
await persist(file, builder.serialize());
|
|
210
|
+
return {
|
|
211
|
+
operation: 'nudge',
|
|
212
|
+
updated: true,
|
|
213
|
+
transcript: builder.serialize(),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
// capture
|
|
217
|
+
const mode = args.mode === 'normal' ? 'normal' : 'dry-run';
|
|
218
|
+
const goal = typeof args.goal === 'string' ? args.goal : '';
|
|
219
|
+
const maxRounds = typeof args.maxRounds === 'number' ? Math.floor(args.maxRounds) : 0;
|
|
220
|
+
const builder = new ReviewTranscriptBuilder({ project: projectRoot, mode, approval, goal, maxRounds });
|
|
221
|
+
const report = args.report;
|
|
222
|
+
const reportFindings = report && typeof report === 'object' && Array.isArray(report.findings)
|
|
223
|
+
? report.findings
|
|
224
|
+
: [];
|
|
225
|
+
const convergence = Array.isArray(args.findingsByRound) ? args.findingsByRound
|
|
226
|
+
: report && typeof report === 'object' && report.convergence
|
|
227
|
+
? report.convergence.findingsByRound ?? []
|
|
228
|
+
: [];
|
|
229
|
+
const rounds = Array.isArray(args.rounds) ? args.rounds : [];
|
|
230
|
+
for (const r of rounds)
|
|
231
|
+
captureRound(builder, r);
|
|
232
|
+
if (rounds.length === 0) {
|
|
233
|
+
// No pre-grouped rounds: fall back to the report's flattened findings.
|
|
234
|
+
const readFiles = Array.isArray(args.refReadFiles) ? args.refReadFiles : [];
|
|
235
|
+
const byDim = new Map();
|
|
236
|
+
for (const f of reportFindings) {
|
|
237
|
+
if (!f || typeof f !== 'object')
|
|
238
|
+
continue;
|
|
239
|
+
const rec = f;
|
|
240
|
+
const dim = typeof rec.dimension === 'string' && rec.dimension ? rec.dimension : 'review';
|
|
241
|
+
const list = byDim.get(dim) ?? [];
|
|
242
|
+
list.push(f);
|
|
243
|
+
byDim.set(dim, list);
|
|
244
|
+
}
|
|
245
|
+
for (const [dim, list] of byDim)
|
|
246
|
+
builder.reviewerSnapshot(dim, list, readFiles);
|
|
247
|
+
}
|
|
248
|
+
// Convergence series from the report (position per round).
|
|
249
|
+
for (let i = 0; i < convergence.length; i += 1) {
|
|
250
|
+
const n = convergence[i];
|
|
251
|
+
if (typeof n === 'number')
|
|
252
|
+
builder.snapshotConvergence(i + 1, n);
|
|
253
|
+
}
|
|
254
|
+
const roundsExecuted = typeof args.roundsExecuted === 'number' ? Math.floor(args.roundsExecuted) : rounds.length;
|
|
255
|
+
if (roundsExecuted > 0)
|
|
256
|
+
builder.roundStart(roundsExecuted, maxRounds);
|
|
257
|
+
builder.recordCheckpoint(normalizeCheckpoint(args.checkpoint));
|
|
258
|
+
if (Array.isArray(args.fixes)) {
|
|
259
|
+
for (const fx of args.fixes) {
|
|
260
|
+
const record = normalizeFix(fx);
|
|
261
|
+
if (record)
|
|
262
|
+
builder.fix(record);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Convergence "found nothing → settled" marker when the trend ends on 0.
|
|
266
|
+
const last = convergence[convergence.length - 1];
|
|
267
|
+
if (convergence.length > 0 && last === 0)
|
|
268
|
+
builder.finish();
|
|
269
|
+
await persist(file, builder.serialize());
|
|
270
|
+
const live = await readLive(projectRoot);
|
|
271
|
+
return {
|
|
272
|
+
operation: 'capture',
|
|
273
|
+
found: true,
|
|
274
|
+
updated: true,
|
|
275
|
+
live: live,
|
|
276
|
+
transcript: builder.serialize(),
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
/** Rebuild a builder from a persisted manifest so nudge edits preserve history. */
|
|
282
|
+
function rehydrateBuilder(manifest, approval) {
|
|
283
|
+
const builder = new ReviewTranscriptBuilder({
|
|
284
|
+
project: manifest.project,
|
|
285
|
+
mode: manifest.mode ?? null,
|
|
286
|
+
approval,
|
|
287
|
+
goal: manifest.goal,
|
|
288
|
+
maxRounds: manifest.maxRounds,
|
|
289
|
+
});
|
|
290
|
+
for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
|
|
291
|
+
builder.roundStart(r.round, manifest.maxRounds);
|
|
292
|
+
for (const t of Array.isArray(r.threads) ? r.threads : []) {
|
|
293
|
+
builder.reviewerStart(t.dimension || 'review', t.attempt || 1);
|
|
294
|
+
builder.reviewerMessage((t.messages ?? []).join('\n'));
|
|
295
|
+
builder.reviewerRead(t.readFiles ?? []);
|
|
296
|
+
for (const f of t.findings ?? [])
|
|
297
|
+
builder.reviewerFindings([f]);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (let idx = 0; idx < (manifest.convergence ?? []).length; idx += 1) {
|
|
301
|
+
const n = manifest.convergence[idx];
|
|
302
|
+
if (typeof n === 'number' && n >= 0)
|
|
303
|
+
builder.snapshotConvergence(idx + 1, n);
|
|
304
|
+
}
|
|
305
|
+
if (manifest.checkpoint)
|
|
306
|
+
builder.recordCheckpoint(manifest.checkpoint);
|
|
307
|
+
if (Array.isArray(manifest.fixes))
|
|
308
|
+
for (const fx of manifest.fixes)
|
|
309
|
+
builder.fix(fx);
|
|
310
|
+
if (Array.isArray(manifest.timeline))
|
|
311
|
+
for (const e of manifest.timeline)
|
|
312
|
+
builder.decision(e);
|
|
313
|
+
builder.setNudge(manifest.nudge?.text ?? null);
|
|
314
|
+
if (!manifest.active)
|
|
315
|
+
builder.finish();
|
|
316
|
+
return builder;
|
|
317
|
+
}
|
|
318
|
+
/** Atomically persist a manifest (tmp + rename) under `.iterate/`. */
|
|
319
|
+
async function persist(file, manifest) {
|
|
320
|
+
await mkdir(dirname(file), { recursive: true });
|
|
321
|
+
const tmp = `${file}.tmp`;
|
|
322
|
+
await writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf-8');
|
|
323
|
+
await rename(tmp, file);
|
|
324
|
+
}
|