iterate-plugin 3.5.4 → 3.5.6
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/dist/atomic-fs.js +22 -4
- package/dist/config-loader.js +30 -5
- package/dist/config-write.js +78 -9
- package/dist/evidence.js +33 -2
- package/dist/git-scope.js +14 -5
- package/dist/jobs.js +15 -1
- package/dist/live.js +39 -22
- package/dist/meta-review.js +7 -3
- package/dist/review-scope.js +6 -2
- package/dist/review.js +34 -15
- package/dist/skill-prompt.js +63 -24
- package/dist/tools/checkpoint.js +11 -4
- package/dist/tools/decision-log.js +142 -17
- package/dist/tools/fix.js +24 -3
- package/dist/tools/history.js +1 -1
- package/dist/tools/prune.js +57 -26
- package/dist/tools/transcript.js +17 -6
- package/dist/tools/triage.js +3 -2
- package/dist/transcript.js +136 -34
- package/lib/client.js +67 -31
- package/lib/parse.js +34 -0
- package/package.json +12 -8
- package/src/atomic-fs.ts +20 -4
- package/src/client/index.ts +45 -24
- package/src/config-loader.ts +28 -5
- package/src/config-write.ts +91 -9
- package/src/evidence.ts +34 -2
- package/src/git-scope.ts +14 -3
- package/src/jobs.ts +15 -1
- package/src/live.ts +41 -22
- package/src/meta-review.ts +7 -3
- package/src/review-scope.ts +6 -2
- package/src/review.ts +47 -14
- package/src/skill-prompt.ts +63 -24
- package/src/tools/checkpoint.ts +12 -4
- package/src/tools/decision-log.ts +136 -16
- package/src/tools/fix.ts +25 -3
- package/src/tools/history.ts +1 -1
- package/src/tools/prune.ts +51 -23
- package/src/tools/transcript.ts +16 -5
- package/src/tools/triage.ts +3 -2
- package/src/transcript.ts +144 -31
- package/src/types.ts +9 -4
package/src/review.ts
CHANGED
|
@@ -39,17 +39,18 @@ export const SEVERITY_RANK: Record<ReviewFinding['severity'], number> = {
|
|
|
39
39
|
export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] {
|
|
40
40
|
return [...findings].sort((a, b) => {
|
|
41
41
|
// Guard against an out-of-spec severity string (e.g. from a model that
|
|
42
|
-
// bypassed the schema)
|
|
43
|
-
// the
|
|
44
|
-
|
|
45
|
-
const
|
|
42
|
+
// bypassed the schema) AND against a null list element (model-authored
|
|
43
|
+
// JSON): treat both as the least severe so NaN never enters the
|
|
44
|
+
// comparator and ordering stays deterministic.
|
|
45
|
+
const rankA = SEVERITY_RANK[a?.severity] ?? SEVERITY_RANK.low
|
|
46
|
+
const rankB = SEVERITY_RANK[b?.severity] ?? SEVERITY_RANK.low
|
|
46
47
|
const bySeverity = rankA - rankB
|
|
47
48
|
if (bySeverity !== 0) return bySeverity
|
|
48
49
|
// Defensive coercion: `file`/`line` can be wrong-typed when schema
|
|
49
50
|
// validation is disabled — String()/Number() keep the comparator total.
|
|
50
|
-
const byFile = String(a
|
|
51
|
+
const byFile = String(a?.file ?? '').localeCompare(String(b?.file ?? ''))
|
|
51
52
|
if (byFile !== 0) return byFile
|
|
52
|
-
return (Number(a
|
|
53
|
+
return (Number(a?.line) || 0) - (Number(b?.line) || 0)
|
|
53
54
|
})
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -161,13 +162,17 @@ export function aggregateRounds(
|
|
|
161
162
|
// Clamp the allocation bound so a hostile round number cannot OOM the tool.
|
|
162
163
|
const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2))
|
|
163
164
|
|
|
164
|
-
const findingsByRound: number[] =
|
|
165
|
-
for (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
165
|
+
const findingsByRound: number[] = new Array(effectiveMax).fill(0)
|
|
166
|
+
for (const key of firstRoundByKey.keys()) {
|
|
167
|
+
const firstRound = firstRoundByKey.get(key) ?? 1
|
|
168
|
+
// Fold rounds BEYOND the allocation bound into the highest slot instead of
|
|
169
|
+
// dropping them: a finding first seen in an over-cap round was previously
|
|
170
|
+
// invisible to `findingsByRound`, so a run whose final rounds exceeded the
|
|
171
|
+
// cap looked "converged" even though a real round found new issues. Folding
|
|
172
|
+
// keeps the counts truthful (the sum still equals totalFindings) while the
|
|
173
|
+
// memory bound stays fixed.
|
|
174
|
+
const idx = Math.min(firstRound, effectiveMax) - 1
|
|
175
|
+
findingsByRound[idx] = (findingsByRound[idx] || 0) + 1
|
|
171
176
|
}
|
|
172
177
|
|
|
173
178
|
return { findings: dedupeFindings(merged), findingsByRound, firstRoundByKey }
|
|
@@ -281,8 +286,13 @@ export function buildReviewReport(input: {
|
|
|
281
286
|
for (const r of filteredRounds) {
|
|
282
287
|
if (typeof r.round === 'number' && r.round > lastRound) lastRound = r.round
|
|
283
288
|
}
|
|
289
|
+
// `lastRound` can exceed the array length when over-cap rounds were FOLDED
|
|
290
|
+
// into the last slot (see aggregateRounds). Read the same clamped index
|
|
291
|
+
// computeConvergence uses, so a real over-cap round is never mis-read as 0
|
|
292
|
+
// new findings (which would falsely report "converged").
|
|
293
|
+
const idx = Math.min(lastRound, findingsByRound.length) - 1
|
|
284
294
|
const lastRoundCount =
|
|
285
|
-
lastRound > 0 ? (findingsByRound[
|
|
295
|
+
lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0
|
|
286
296
|
const converged = filteredRounds.length > 0 && lastRoundCount === 0
|
|
287
297
|
|
|
288
298
|
// Attach the normal-mode fix count to the summary (dry-run leaves it absent).
|
|
@@ -629,6 +639,12 @@ export function reviewerTaskPrompt(input: {
|
|
|
629
639
|
* every dimension reviewer inspects/considers them before judging.
|
|
630
640
|
*/
|
|
631
641
|
attachments?: ReviewAttachment[]
|
|
642
|
+
/**
|
|
643
|
+
* Configured reasoning effort ('low' | 'medium' | 'high') to apply to this
|
|
644
|
+
* review pass. Injected as a directive so the harness can honor it when
|
|
645
|
+
* running each reviewer subagent. Omitted → follow the provider default.
|
|
646
|
+
*/
|
|
647
|
+
effort?: 'low' | 'medium' | 'high'
|
|
632
648
|
}): string {
|
|
633
649
|
const parts: string[] = []
|
|
634
650
|
parts.push(
|
|
@@ -636,6 +652,9 @@ export function reviewerTaskPrompt(input: {
|
|
|
636
652
|
`Goal: ${input.goal}`,
|
|
637
653
|
`Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
|
|
638
654
|
)
|
|
655
|
+
if (input.effort) {
|
|
656
|
+
parts.push(`Reasoning effort for this review pass: ${input.effort}.`)
|
|
657
|
+
}
|
|
639
658
|
if (input.focus) {
|
|
640
659
|
parts.push(`FOCUS: ${input.focus}`)
|
|
641
660
|
}
|
|
@@ -743,6 +762,12 @@ export function buildReviewPlan(input: {
|
|
|
743
762
|
fallbackToFull: boolean
|
|
744
763
|
/** The attachments threaded into every reviewer prompt (empty when none). */
|
|
745
764
|
attachments: ReviewAttachment[]
|
|
765
|
+
/**
|
|
766
|
+
* Configured reasoning effort carried on the plan for the orchestrator to
|
|
767
|
+
* honor (null when unset → provider default). Also injected as a directive
|
|
768
|
+
* into every dimension's reviewer prompt.
|
|
769
|
+
*/
|
|
770
|
+
reasoningEffort: 'low' | 'medium' | 'high' | null
|
|
746
771
|
} {
|
|
747
772
|
// Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
|
|
748
773
|
// `review`/`atomic` missing) must degrade to sane defaults instead of
|
|
@@ -750,6 +775,12 @@ export function buildReviewPlan(input: {
|
|
|
750
775
|
const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English'
|
|
751
776
|
const goal = input.config.goal ?? ''
|
|
752
777
|
const configuredScope = input.config.review?.scope ?? 'full'
|
|
778
|
+
const reasoningEffort =
|
|
779
|
+
input.config.reasoning_effort === 'low' ||
|
|
780
|
+
input.config.reasoning_effort === 'medium' ||
|
|
781
|
+
input.config.reasoning_effort === 'high'
|
|
782
|
+
? input.config.reasoning_effort
|
|
783
|
+
: null
|
|
753
784
|
const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
|
|
754
785
|
const maxLines = input.config.atomic?.max_lines ?? 20
|
|
755
786
|
const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : []
|
|
@@ -815,6 +846,7 @@ export function buildReviewPlan(input: {
|
|
|
815
846
|
scopeFiles: batch,
|
|
816
847
|
focus: focusMap.get(d),
|
|
817
848
|
attachments,
|
|
849
|
+
effort: reasoningEffort ?? undefined,
|
|
818
850
|
}),
|
|
819
851
|
findingsSchema: findingsSchema(),
|
|
820
852
|
})
|
|
@@ -831,5 +863,6 @@ export function buildReviewPlan(input: {
|
|
|
831
863
|
changedFiles: effectiveChangedOnly ? changedFiles : [],
|
|
832
864
|
fallbackToFull,
|
|
833
865
|
attachments,
|
|
866
|
+
reasoningEffort,
|
|
834
867
|
}
|
|
835
868
|
}
|
package/src/skill-prompt.ts
CHANGED
|
@@ -20,7 +20,7 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
20
20
|
- \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
|
|
21
21
|
- \`iterate_rollback\` — revert a fix by id: restore the file from its backup, remove the fix from the registry, log a \`revert\` entry. Use when a round's validation fails
|
|
22
22
|
- \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
|
|
23
|
-
- \`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
|
|
23
|
+
- \`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 on disk with no newer decision-log activity means the previous run was interrupted and can be resumed)
|
|
24
24
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
25
25
|
- \`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.
|
|
26
26
|
- \`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.
|
|
@@ -81,6 +81,10 @@ const maxRounds = plan.maxReviewRounds
|
|
|
81
81
|
const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
|
|
82
82
|
let known = [] // cumulative DEDUPED findings fed back to reviewers
|
|
83
83
|
const rounds = [] // raw per-round findings
|
|
84
|
+
// Tracks whether the LAST executed round produced usable reviewer output. A
|
|
85
|
+
// round where every reviewer subagent failed (or output stayed schema-invalid
|
|
86
|
+
// after retries) is INCONCLUSIVE — it must never make the run look converged.
|
|
87
|
+
let lastRoundOk = true
|
|
84
88
|
|
|
85
89
|
phase('transcript')
|
|
86
90
|
// Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
|
|
@@ -118,6 +122,12 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
118
122
|
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
119
123
|
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
120
124
|
}))
|
|
125
|
+
// A per-dimension reviewer subagent resolves \`null\` on child failure; the
|
|
126
|
+
// aggregate below would report an all-failed round as an empty round, and
|
|
127
|
+
// the convergence math would then read "0 new findings". An all-failed
|
|
128
|
+
// round is NOT a clean pass — it is inconclusive, so the convergence gate
|
|
129
|
+
// below refuses it before any "converged" signal fires.
|
|
130
|
+
const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
|
|
121
131
|
const thisRound = {
|
|
122
132
|
round: r,
|
|
123
133
|
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
@@ -145,23 +155,30 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
145
155
|
// final summary can explain WHY the round was inconclusive.
|
|
146
156
|
log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
|
|
147
157
|
}
|
|
158
|
+
const roundUnusable = schemaInvalid || !reviewersOk
|
|
159
|
+
if (roundUnusable) lastRoundOk = false
|
|
148
160
|
// Feed the DEDUPED + already-filtered set back (not raw findings) so the known
|
|
149
|
-
// list stays bounded and reviewers never see the same issue twice.
|
|
150
|
-
|
|
161
|
+
// list stays bounded and reviewers never see the same issue twice. Never feed
|
|
162
|
+
// an unusable round's empty result — it would erase the still-valid known list
|
|
163
|
+
// and let reviewers re-report everything next round.
|
|
164
|
+
if (!roundUnusable && agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
151
165
|
// A round counts as "converged" ONLY when the aggregate accepted it
|
|
152
|
-
// (schema-valid) AND
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
166
|
+
// (schema-valid) AND its reviewers actually returned output AND it genuinely
|
|
167
|
+
// found zero NEW findings. If reviewers all failed or their output stayed
|
|
168
|
+
// schema-invalid after retries, the round is INCONCLUSIVE — never report an
|
|
169
|
+
// empty-but-broken round as converged (an invalid/failed round would
|
|
170
|
+
// otherwise masquerade as a clean pass and stop the run early).
|
|
171
|
+
if (!roundUnusable && !schemaInvalid) {
|
|
157
172
|
const newCount =
|
|
158
173
|
agg && agg.report && agg.report.convergence
|
|
159
174
|
? agg.report.convergence.findingsByRound[r - 1]
|
|
160
175
|
: thisRound.findings.length
|
|
161
176
|
if (newCount === 0) {
|
|
162
177
|
log('round ' + r + ' found 0 new findings — converged')
|
|
178
|
+
lastRoundOk = true
|
|
163
179
|
break
|
|
164
180
|
}
|
|
181
|
+
lastRoundOk = true
|
|
165
182
|
}
|
|
166
183
|
}
|
|
167
184
|
|
|
@@ -197,8 +214,13 @@ return {
|
|
|
197
214
|
mode: 'dry-run',
|
|
198
215
|
goal: report.goal,
|
|
199
216
|
rounds: rounds.length,
|
|
200
|
-
|
|
201
|
-
|
|
217
|
+
// The aggregate's convergence flag is only trustworthy when the last round
|
|
218
|
+
// was usable — an all-failed/schema-invalid final round looks like "0 new"
|
|
219
|
+
// to the convergence math but is an inconclusive run, never a clean pass.
|
|
220
|
+
converged: report.convergence.converged && lastRoundOk,
|
|
221
|
+
stoppedReason: report.convergence.converged && !lastRoundOk
|
|
222
|
+
? 'inconclusive'
|
|
223
|
+
: report.convergence.stoppedReason,
|
|
202
224
|
findingsByRound: report.convergence.findingsByRound,
|
|
203
225
|
totalFindings: report.summary.totalFindings,
|
|
204
226
|
bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
|
|
@@ -260,7 +282,7 @@ const configRes = await agent(
|
|
|
260
282
|
const cfg = (configRes && configRes.config) ? configRes.config : null
|
|
261
283
|
const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
|
|
262
284
|
const planRes = await agent(
|
|
263
|
-
'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' +
|
|
285
|
+
'Call iterate_review({operation:"plan", mode:"normal"' + (args.maxRounds ? ', maxReviewRounds:' + args.maxRounds : '') + '}) and return the plan JSON.',
|
|
264
286
|
Object.assign({ label: 'review:plan' }, backend)
|
|
265
287
|
)
|
|
266
288
|
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
@@ -274,6 +296,7 @@ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? check
|
|
|
274
296
|
let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
|
|
275
297
|
let converged = false
|
|
276
298
|
let abortedByValidation = false
|
|
299
|
+
let schemaFailed = false // last round produced no usable reviewer output (schema-invalid after retries / all reviewers failed)
|
|
277
300
|
let failedCommands = []
|
|
278
301
|
let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
|
|
279
302
|
const fixRecords = [] // observatory fix records collected round by round
|
|
@@ -316,6 +339,10 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
316
339
|
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
317
340
|
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
318
341
|
}))
|
|
342
|
+
// ALL reviewers returning null (child failures) means this round produced
|
|
343
|
+
// no usable output — an empty aggregate must not read like a clean
|
|
344
|
+
// "nothing to fix" convergence. Gate the loop exit + convergence on it.
|
|
345
|
+
const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
|
|
319
346
|
const thisRound = {
|
|
320
347
|
round: r,
|
|
321
348
|
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
@@ -339,10 +366,19 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
339
366
|
log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
340
367
|
}
|
|
341
368
|
} while (schemaInvalid && attempts < 3)
|
|
342
|
-
if (schemaInvalid) {
|
|
343
|
-
// Bounded exit: never loop forever
|
|
344
|
-
//
|
|
345
|
-
|
|
369
|
+
if (schemaInvalid || !reviewersOk) {
|
|
370
|
+
// Bounded exit: never loop forever, and never let an all-failed/empty round
|
|
371
|
+
// read like a clean convergence. Stop the run WITHOUT applying fixes for
|
|
372
|
+
// this round, record the failure in the audit trail, and leave the
|
|
373
|
+
// checkpoint in place so a user/DM can resume or re-roll after fixing the
|
|
374
|
+
// reviewer configuration.
|
|
375
|
+
schemaFailed = true
|
|
376
|
+
log('round ' + r + ' produced NO usable reviewer output (schema-invalid after retries or all reviewers failed) — stopping run (NOT converged)')
|
|
377
|
+
await agent(
|
|
378
|
+
'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{reason:"' + (schemaInvalid ? 'schema_invalid' : 'no_usable_reviewer_output') + '", rolledBack:0}})',
|
|
379
|
+
Object.assign({ label: 'log:schemaFailed:r' + r }, backend)
|
|
380
|
+
)
|
|
381
|
+
break
|
|
346
382
|
}
|
|
347
383
|
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
348
384
|
const atomic = findings.filter(f => f.is_atomic === true)
|
|
@@ -357,10 +393,10 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
357
393
|
const byFile = {}
|
|
358
394
|
atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
|
|
359
395
|
const fixRes = await parallel(Object.keys(byFile).map(file => () => agent(
|
|
360
|
-
'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
|
|
396
|
+
'Apply the fixes for ' + JSON.stringify(file) + ' using iterate_fix. For EACH finding in this list, ' +
|
|
361
397
|
'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
|
|
362
|
-
'iterate_fix({ file:
|
|
363
|
-
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file:
|
|
398
|
+
'iterate_fix({ file: ' + JSON.stringify(file) + ', content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
|
|
399
|
+
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: ' + JSON.stringify(file) + ' }) to verify the accumulated diff and ' +
|
|
364
400
|
'read its line statistics (lines added/removed). ' +
|
|
365
401
|
'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
|
|
366
402
|
'(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
|
|
@@ -476,7 +512,7 @@ const statusRes = await agent(
|
|
|
476
512
|
{ label: 'status:final' }
|
|
477
513
|
)
|
|
478
514
|
const status = (statusRes && statusRes.ok) ? statusRes : null
|
|
479
|
-
if (!abortedByValidation) {
|
|
515
|
+
if (!abortedByValidation && !schemaFailed) {
|
|
480
516
|
// Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
|
|
481
517
|
await agent(
|
|
482
518
|
'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
|
|
@@ -495,9 +531,11 @@ if (!abortedByValidation) {
|
|
|
495
531
|
fixedCount: fixedCount,
|
|
496
532
|
resumeCount: effectiveResumeCount,
|
|
497
533
|
}
|
|
498
|
-
const stopReason =
|
|
499
|
-
?
|
|
500
|
-
:
|
|
534
|
+
const stopReason = schemaFailed
|
|
535
|
+
? 'schema_invalid'
|
|
536
|
+
: abortedByValidation
|
|
537
|
+
? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
|
|
538
|
+
: (converged ? 'converged' : 'max_rounds_reached')
|
|
501
539
|
await agent(
|
|
502
540
|
'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)) + ', stoppedReason:"' + stopReason + '", 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"}.',
|
|
503
541
|
{ label: 'transcript:capture' }
|
|
@@ -509,6 +547,7 @@ return {
|
|
|
509
547
|
maxRounds: maxRounds,
|
|
510
548
|
converged: converged,
|
|
511
549
|
abortedByValidation: abortedByValidation,
|
|
550
|
+
schemaFailed: schemaFailed,
|
|
512
551
|
failedCommands: failedCommands,
|
|
513
552
|
configErrors: configErrors,
|
|
514
553
|
stoppedReason: stopReason,
|
|
@@ -529,12 +568,12 @@ return {
|
|
|
529
568
|
Key rules for normal mode:
|
|
530
569
|
- Fixers are the ONLY agents allowed to write files, and they must go through \`iterate_fix\` — never edit files directly. That is what gives every change a backup, a diff, and a rollback path. Reviewers read only. Architectural findings are reported, never auto-fixed.
|
|
531
570
|
- Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
|
|
532
|
-
- **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times (3 attempts total) when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`. If the output is still schema-invalid after the 3rd attempt
|
|
571
|
+
- **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times (3 attempts total) when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`. If the output is still schema-invalid after the 3rd attempt — or every reviewer subagent failed outright (no usable output at all) — the round is INCONCLUSIVE: \`break\` the loop, log a \`round_failed\` entry, set \`schemaFailed = true\`, and report \`stoppedReason:"schema_invalid"\`/\`"no_usable_reviewer_output"\`. Never report an inconclusive round as a clean convergence, and keep the checkpoint so the run can be resumed after the reviewer configuration is fixed.
|
|
533
572
|
- Apply atomic fixes **per file**: one fixer agent handles all findings for a given file serially (so the same file is never edited concurrently); different files are fixed in parallel.
|
|
534
573
|
- **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
|
|
535
574
|
- **Validate after every round** of fixes; on ANY validation failure, roll back the round's fixes via \`iterate_rollback\` and stop (the checkpoint is left in place so the run can be resumed). EXCEPTION: \`iterate_validate\` returning \`allowed:false\` means the command is simply not in \`validation.commands\` — a config gap, not broken code. In that case abort WITHOUT rolling back the round's fixes and surface the missing command + file so the user can fix the trust list.
|
|
536
575
|
- **Checkpoint after every round**; clear it only when the iteration completes cleanly.
|
|
537
|
-
- Stop when a round produces nothing to fix (converged)
|
|
576
|
+
- Stop when a round produces nothing to fix (converged), maxReviewRounds is reached, validation aborts the run, or a round yields no usable reviewer output (\`stoppedReason:"schema_invalid"\`).
|
|
538
577
|
- Every round, every rollback, and the final report go to the append-only decision log.
|
|
539
578
|
- Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
|
|
540
579
|
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -177,9 +177,17 @@ export function computeStatus(input: {
|
|
|
177
177
|
findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
|
|
178
178
|
totalDecisionLogEntries: entries.length,
|
|
179
179
|
hasCheckpoint: checkpoint !== null,
|
|
180
|
-
// A checkpoint left on disk
|
|
181
|
-
//
|
|
182
|
-
|
|
180
|
+
// A checkpoint left on disk is the durable "the run did not finish" signal
|
|
181
|
+
// — EXCEPT when a decision-log entry is NEWER than the checkpoint: the
|
|
182
|
+
// workflow saves a checkpoint at the end of each round (right after that
|
|
183
|
+
// round's review_result log), so a live run proceeding past its last
|
|
184
|
+
// checkpoint would otherwise be flagged as "interrupted" for its whole
|
|
185
|
+
// duration. A fresh checkpoint (or no log at all) is genuinely leftover.
|
|
186
|
+
interrupted:
|
|
187
|
+
checkpoint !== null &&
|
|
188
|
+
!(lastEntry != null &&
|
|
189
|
+
checkpoint.updatedAt &&
|
|
190
|
+
(Date.parse(checkpoint.updatedAt) || 0) >= (Date.parse(lastEntry.timestamp) || 0)),
|
|
183
191
|
resumeCount: checkpoint?.resumeCount ?? 0,
|
|
184
192
|
checkpoint,
|
|
185
193
|
lastUpdated,
|
|
@@ -354,7 +362,7 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
354
362
|
findingsCount: { type: 'integer' },
|
|
355
363
|
totalDecisionLogEntries: { type: 'integer' },
|
|
356
364
|
hasCheckpoint: { type: 'boolean' },
|
|
357
|
-
interrupted: { type: 'boolean', description: 'True when a checkpoint exists
|
|
365
|
+
interrupted: { type: 'boolean', description: 'True when a checkpoint exists and no decision-log entry is newer than it — the previous run left a checkpoint that nothing has resumed or progressed past.' },
|
|
358
366
|
resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
|
|
359
367
|
lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
360
368
|
qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
openSync,
|
|
7
|
+
writeSync,
|
|
8
|
+
closeSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
statSync,
|
|
11
|
+
} from 'node:fs'
|
|
2
12
|
import { join } from 'node:path'
|
|
3
13
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
14
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
@@ -8,6 +18,109 @@ import type { DecisionLogEntry } from '../types.ts'
|
|
|
8
18
|
const LOG_DIR = '.iterate'
|
|
9
19
|
const LOG_FILE = 'decision-log.jsonl'
|
|
10
20
|
|
|
21
|
+
// ─── Cross-process log mutex ────────────────────────────────────────────────
|
|
22
|
+
//
|
|
23
|
+
// The prune rewrite (temp + atomic rename) can silently discard an audit line
|
|
24
|
+
// from a CONCURRENT appender whose fd was opened against the pre-rename inode:
|
|
25
|
+
// the appender writes to the unlinked inode, the post-rewrite re-read sees a
|
|
26
|
+
// "stable" file, and the fresh entry is lost. In-process safety is guaranteed
|
|
27
|
+
// by synchronous I/O, but a second plugin process appending to the same
|
|
28
|
+
// project realises the race. The log is serialized with a tiny advisory lock
|
|
29
|
+
// file (exclusive create, pid stamped, stale-stealable) so `append` and the
|
|
30
|
+
// prune rewrite mutually exclude across processes. Best-effort: contention
|
|
31
|
+
// timeouts degrade to "proceed unlocked" (the bounded retry loop in prune.ts
|
|
32
|
+
// stays as defense-in-depth), never to a crash.
|
|
33
|
+
|
|
34
|
+
const LOCK_FILE = '.decision-log.lock'
|
|
35
|
+
const LOCK_WAIT_MS = 5000
|
|
36
|
+
const LOCK_STALE_MS = 10000
|
|
37
|
+
const LOCK_POLL_MS = 25
|
|
38
|
+
|
|
39
|
+
/** True when a pid refers to a live process (or we lack permission to tell). */
|
|
40
|
+
function processAlive(pid: number): boolean {
|
|
41
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0)
|
|
44
|
+
return true
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
|
|
51
|
+
function sleepSync(ms: number): void {
|
|
52
|
+
const sab = new Int32Array(new SharedArrayBuffer(4))
|
|
53
|
+
Atomics.wait(sab, 0, 0, ms)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Acquire the decision-log lock for `projectRoot`.
|
|
58
|
+
* Returns a release function (always callable; a no-op when the lock could
|
|
59
|
+
* not be taken — never wedge an append behind a vanished holder).
|
|
60
|
+
*/
|
|
61
|
+
export function acquireLogLock(projectRoot: string): () => void {
|
|
62
|
+
const dir = join(projectRoot, LOG_DIR)
|
|
63
|
+
if (!existsSync(dir)) {
|
|
64
|
+
try {
|
|
65
|
+
mkdirSync(dir, { recursive: true })
|
|
66
|
+
} catch {
|
|
67
|
+
return () => {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const lockPath = join(dir, LOCK_FILE)
|
|
71
|
+
const start = Date.now()
|
|
72
|
+
for (;;) {
|
|
73
|
+
let fd: number | null = null
|
|
74
|
+
try {
|
|
75
|
+
fd = openSync(lockPath, 'wx')
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return () => {}
|
|
78
|
+
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
79
|
+
let stale = false
|
|
80
|
+
try {
|
|
81
|
+
const st = statSync(lockPath)
|
|
82
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) stale = true
|
|
83
|
+
else {
|
|
84
|
+
const pid = Number(readFileSync(lockPath, 'utf-8'))
|
|
85
|
+
if (!processAlive(pid)) stale = true
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
stale = true // unreadable/vanishing lock → retry as stale
|
|
89
|
+
}
|
|
90
|
+
if (stale) {
|
|
91
|
+
try { unlinkSync(lockPath) } catch { /* another holder stole it */ }
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (Date.now() - start > LOCK_WAIT_MS) return () => {}
|
|
95
|
+
sleepSync(LOCK_POLL_MS)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
// Owned the lock: stamp the holder pid, then release once on return.
|
|
99
|
+
try { writeSync(fd, String(process.pid)) } catch { /* pid stamp is advisory */ }
|
|
100
|
+
try { closeSync(fd) } catch { /* best-effort */ }
|
|
101
|
+
let released = false
|
|
102
|
+
return () => {
|
|
103
|
+
if (released) return
|
|
104
|
+
released = true
|
|
105
|
+
try { unlinkSync(lockPath) } catch { /* already gone */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
|
|
111
|
+
|
|
112
|
+
const entryCountCache = new Map<string, { size: number; count: number }>()
|
|
113
|
+
|
|
114
|
+
/** Line-count the log file from scratch (entries = non-empty JSON lines). */
|
|
115
|
+
function recountEntries(filePath: string): number {
|
|
116
|
+
try {
|
|
117
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
118
|
+
return content.split('\n').filter((l) => l.trim().length > 0).length
|
|
119
|
+
} catch {
|
|
120
|
+
return 1
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
11
124
|
/** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
|
|
12
125
|
const VALID_ENTRY_TYPES = new Set<DecisionLogEntry['type']>([
|
|
13
126
|
'round_start',
|
|
@@ -57,23 +170,30 @@ function logPath(projectRoot: string): string {
|
|
|
57
170
|
* miss without failing the mutation they already performed.
|
|
58
171
|
*/
|
|
59
172
|
export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string; error?: string } {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const line = JSON.stringify(entry) + '\n'
|
|
64
|
-
appendFileSync(filePath, line, 'utf-8')
|
|
65
|
-
} catch (err) {
|
|
66
|
-
return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
|
|
67
|
-
}
|
|
68
|
-
// Count entries
|
|
69
|
-
let count = 0
|
|
173
|
+
// Serialize with the cross-process log lock so an append can never interleave
|
|
174
|
+
// with a prune rewrite (which renames the file under us).
|
|
175
|
+
const release = acquireLogLock(projectRoot)
|
|
70
176
|
try {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
177
|
+
const filePath = logPath(projectRoot)
|
|
178
|
+
try {
|
|
179
|
+
const line = JSON.stringify(entry) + '\n'
|
|
180
|
+
const prevSize = existsSync(filePath) ? statSync(filePath).size : 0
|
|
181
|
+
appendFileSync(filePath, line, 'utf-8')
|
|
182
|
+
// Count entries via the size/count cache: when nothing else changed the
|
|
183
|
+
// file since our last append (same byte size), count is just +1 — no full
|
|
184
|
+
// re-read. Falls back to a full re-count whenever the cache is stale.
|
|
185
|
+
const cached = entryCountCache.get(filePath)
|
|
186
|
+
const count = cached && cached.size === prevSize
|
|
187
|
+
? cached.count + 1
|
|
188
|
+
: recountEntries(filePath)
|
|
189
|
+
entryCountCache.set(filePath, { size: existsSync(filePath) ? statSync(filePath).size : prevSize, count })
|
|
190
|
+
return { count, path: filePath }
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
release()
|
|
75
196
|
}
|
|
76
|
-
return { count, path: filePath }
|
|
77
197
|
}
|
|
78
198
|
|
|
79
199
|
/**
|
package/src/tools/fix.ts
CHANGED
|
@@ -366,6 +366,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
366
366
|
linesRemoved: { type: 'integer' },
|
|
367
367
|
diffSummary: { type: 'string' },
|
|
368
368
|
backupPath: { type: 'string' },
|
|
369
|
+
warning: { type: 'string' },
|
|
369
370
|
error: { type: 'string' },
|
|
370
371
|
},
|
|
371
372
|
},
|
|
@@ -456,6 +457,17 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
456
457
|
return { ok: false, error: `finding already fixed this run (id: ${id})`, id }
|
|
457
458
|
}
|
|
458
459
|
|
|
460
|
+
// No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
|
|
461
|
+
// file unchanged) must never burn a backup, a write, or a registry/success
|
|
462
|
+
// record. Placed after the registry check so a re-sent fix of an id that
|
|
463
|
+
// was ALREADY fixed is still reported as "already fixed this run".
|
|
464
|
+
if (added === 0 && removed === 0) {
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
459
471
|
const target = resolveProjectFile(projectRoot, file)
|
|
460
472
|
if (!target.ok) return { ok: false, error: target.reason }
|
|
461
473
|
|
|
@@ -528,13 +540,16 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
528
540
|
return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` }
|
|
529
541
|
}
|
|
530
542
|
|
|
531
|
-
appendDecisionEntry(projectRoot, {
|
|
543
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
532
544
|
timestamp,
|
|
533
545
|
round: args.round,
|
|
534
546
|
type: 'atomic_fix',
|
|
535
547
|
data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
|
|
536
548
|
})
|
|
537
549
|
|
|
550
|
+
// The fix itself succeeded, but a decision-log write failure would leave
|
|
551
|
+
// the audit trail incomplete — surface it (warning, not fatal) so the
|
|
552
|
+
// model/UI knows the record was not persisted.
|
|
538
553
|
return {
|
|
539
554
|
ok: true,
|
|
540
555
|
id,
|
|
@@ -544,6 +559,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
544
559
|
linesRemoved: removed,
|
|
545
560
|
diffSummary: record.diffSummary,
|
|
546
561
|
backupPath,
|
|
562
|
+
...(logRes.error ? { warning: logRes.error } : {}),
|
|
547
563
|
}
|
|
548
564
|
})
|
|
549
565
|
return result
|
|
@@ -710,6 +726,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
710
726
|
ok: { type: 'boolean', required: true },
|
|
711
727
|
id: { type: 'string' },
|
|
712
728
|
file: { type: 'string' },
|
|
729
|
+
warning: { type: 'string' },
|
|
713
730
|
error: { type: 'string' },
|
|
714
731
|
},
|
|
715
732
|
},
|
|
@@ -749,7 +766,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
749
766
|
return { ok: false, error: `failed to update fix registry: ${String(err)}` }
|
|
750
767
|
}
|
|
751
768
|
|
|
752
|
-
appendDecisionEntry(projectRoot, {
|
|
769
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
753
770
|
timestamp: new Date().toISOString(),
|
|
754
771
|
round: record.round,
|
|
755
772
|
type: 'revert',
|
|
@@ -762,7 +779,12 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
762
779
|
// rollback flow.
|
|
763
780
|
await markFixRolledBackInTranscript(projectRoot, id)
|
|
764
781
|
|
|
765
|
-
return {
|
|
782
|
+
return {
|
|
783
|
+
ok: true,
|
|
784
|
+
id,
|
|
785
|
+
file: record.finding.file,
|
|
786
|
+
...(logRes.error ? { warning: logRes.error } : {}),
|
|
787
|
+
}
|
|
766
788
|
},
|
|
767
789
|
}),
|
|
768
790
|
)
|
package/src/tools/history.ts
CHANGED
|
@@ -102,7 +102,7 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
102
102
|
},
|
|
103
103
|
limit: {
|
|
104
104
|
type: 'integer',
|
|
105
|
-
description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}).
|
|
105
|
+
description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
|
|
106
106
|
},
|
|
107
107
|
path: {
|
|
108
108
|
type: 'string',
|