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/dist/skill-prompt.js
CHANGED
|
@@ -19,7 +19,7 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
19
19
|
- \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
|
|
20
20
|
- \`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
|
|
21
21
|
- \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
|
|
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
|
|
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 on disk with no newer decision-log activity 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
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.
|
|
@@ -80,6 +80,10 @@ const maxRounds = plan.maxReviewRounds
|
|
|
80
80
|
const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
|
|
81
81
|
let known = [] // cumulative DEDUPED findings fed back to reviewers
|
|
82
82
|
const rounds = [] // raw per-round findings
|
|
83
|
+
// Tracks whether the LAST executed round produced usable reviewer output. A
|
|
84
|
+
// round where every reviewer subagent failed (or output stayed schema-invalid
|
|
85
|
+
// after retries) is INCONCLUSIVE — it must never make the run look converged.
|
|
86
|
+
let lastRoundOk = true
|
|
83
87
|
|
|
84
88
|
phase('transcript')
|
|
85
89
|
// Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
|
|
@@ -117,6 +121,12 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
117
121
|
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
118
122
|
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
119
123
|
}))
|
|
124
|
+
// A per-dimension reviewer subagent resolves \`null\` on child failure; the
|
|
125
|
+
// aggregate below would report an all-failed round as an empty round, and
|
|
126
|
+
// the convergence math would then read "0 new findings". An all-failed
|
|
127
|
+
// round is NOT a clean pass — it is inconclusive, so the convergence gate
|
|
128
|
+
// below refuses it before any "converged" signal fires.
|
|
129
|
+
const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
|
|
120
130
|
const thisRound = {
|
|
121
131
|
round: r,
|
|
122
132
|
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
@@ -144,23 +154,30 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
144
154
|
// final summary can explain WHY the round was inconclusive.
|
|
145
155
|
log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
|
|
146
156
|
}
|
|
157
|
+
const roundUnusable = schemaInvalid || !reviewersOk
|
|
158
|
+
if (roundUnusable) lastRoundOk = false
|
|
147
159
|
// Feed the DEDUPED + already-filtered set back (not raw findings) so the known
|
|
148
|
-
// list stays bounded and reviewers never see the same issue twice.
|
|
149
|
-
|
|
160
|
+
// list stays bounded and reviewers never see the same issue twice. Never feed
|
|
161
|
+
// an unusable round's empty result — it would erase the still-valid known list
|
|
162
|
+
// and let reviewers re-report everything next round.
|
|
163
|
+
if (!roundUnusable && agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
150
164
|
// A round counts as "converged" ONLY when the aggregate accepted it
|
|
151
|
-
// (schema-valid) AND
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
165
|
+
// (schema-valid) AND its reviewers actually returned output AND it genuinely
|
|
166
|
+
// found zero NEW findings. If reviewers all failed or their output stayed
|
|
167
|
+
// schema-invalid after retries, the round is INCONCLUSIVE — never report an
|
|
168
|
+
// empty-but-broken round as converged (an invalid/failed round would
|
|
169
|
+
// otherwise masquerade as a clean pass and stop the run early).
|
|
170
|
+
if (!roundUnusable && !schemaInvalid) {
|
|
156
171
|
const newCount =
|
|
157
172
|
agg && agg.report && agg.report.convergence
|
|
158
173
|
? agg.report.convergence.findingsByRound[r - 1]
|
|
159
174
|
: thisRound.findings.length
|
|
160
175
|
if (newCount === 0) {
|
|
161
176
|
log('round ' + r + ' found 0 new findings — converged')
|
|
177
|
+
lastRoundOk = true
|
|
162
178
|
break
|
|
163
179
|
}
|
|
180
|
+
lastRoundOk = true
|
|
164
181
|
}
|
|
165
182
|
}
|
|
166
183
|
|
|
@@ -196,8 +213,13 @@ return {
|
|
|
196
213
|
mode: 'dry-run',
|
|
197
214
|
goal: report.goal,
|
|
198
215
|
rounds: rounds.length,
|
|
199
|
-
|
|
200
|
-
|
|
216
|
+
// The aggregate's convergence flag is only trustworthy when the last round
|
|
217
|
+
// was usable — an all-failed/schema-invalid final round looks like "0 new"
|
|
218
|
+
// to the convergence math but is an inconclusive run, never a clean pass.
|
|
219
|
+
converged: report.convergence.converged && lastRoundOk,
|
|
220
|
+
stoppedReason: report.convergence.converged && !lastRoundOk
|
|
221
|
+
? 'inconclusive'
|
|
222
|
+
: report.convergence.stoppedReason,
|
|
201
223
|
findingsByRound: report.convergence.findingsByRound,
|
|
202
224
|
totalFindings: report.summary.totalFindings,
|
|
203
225
|
bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
|
|
@@ -259,7 +281,7 @@ const configRes = await agent(
|
|
|
259
281
|
const cfg = (configRes && configRes.config) ? configRes.config : null
|
|
260
282
|
const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
|
|
261
283
|
const planRes = await agent(
|
|
262
|
-
'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' +
|
|
284
|
+
'Call iterate_review({operation:"plan", mode:"normal"' + (args.maxRounds ? ', maxReviewRounds:' + args.maxRounds : '') + '}) and return the plan JSON.',
|
|
263
285
|
Object.assign({ label: 'review:plan' }, backend)
|
|
264
286
|
)
|
|
265
287
|
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
@@ -273,6 +295,7 @@ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? check
|
|
|
273
295
|
let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
|
|
274
296
|
let converged = false
|
|
275
297
|
let abortedByValidation = false
|
|
298
|
+
let schemaFailed = false // last round produced no usable reviewer output (schema-invalid after retries / all reviewers failed)
|
|
276
299
|
let failedCommands = []
|
|
277
300
|
let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
|
|
278
301
|
const fixRecords = [] // observatory fix records collected round by round
|
|
@@ -315,6 +338,10 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
315
338
|
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
316
339
|
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
317
340
|
}))
|
|
341
|
+
// ALL reviewers returning null (child failures) means this round produced
|
|
342
|
+
// no usable output — an empty aggregate must not read like a clean
|
|
343
|
+
// "nothing to fix" convergence. Gate the loop exit + convergence on it.
|
|
344
|
+
const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
|
|
318
345
|
const thisRound = {
|
|
319
346
|
round: r,
|
|
320
347
|
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
@@ -338,10 +365,19 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
338
365
|
log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
339
366
|
}
|
|
340
367
|
} while (schemaInvalid && attempts < 3)
|
|
341
|
-
if (schemaInvalid) {
|
|
342
|
-
// Bounded exit: never loop forever
|
|
343
|
-
//
|
|
344
|
-
|
|
368
|
+
if (schemaInvalid || !reviewersOk) {
|
|
369
|
+
// Bounded exit: never loop forever, and never let an all-failed/empty round
|
|
370
|
+
// read like a clean convergence. Stop the run WITHOUT applying fixes for
|
|
371
|
+
// this round, record the failure in the audit trail, and leave the
|
|
372
|
+
// checkpoint in place so a user/DM can resume or re-roll after fixing the
|
|
373
|
+
// reviewer configuration.
|
|
374
|
+
schemaFailed = true
|
|
375
|
+
log('round ' + r + ' produced NO usable reviewer output (schema-invalid after retries or all reviewers failed) — stopping run (NOT converged)')
|
|
376
|
+
await agent(
|
|
377
|
+
'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{reason:"' + (schemaInvalid ? 'schema_invalid' : 'no_usable_reviewer_output') + '", rolledBack:0}})',
|
|
378
|
+
Object.assign({ label: 'log:schemaFailed:r' + r }, backend)
|
|
379
|
+
)
|
|
380
|
+
break
|
|
345
381
|
}
|
|
346
382
|
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
347
383
|
const atomic = findings.filter(f => f.is_atomic === true)
|
|
@@ -356,10 +392,10 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
356
392
|
const byFile = {}
|
|
357
393
|
atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
|
|
358
394
|
const fixRes = await parallel(Object.keys(byFile).map(file => () => agent(
|
|
359
|
-
'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
|
|
395
|
+
'Apply the fixes for ' + JSON.stringify(file) + ' using iterate_fix. For EACH finding in this list, ' +
|
|
360
396
|
'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
|
|
361
|
-
'iterate_fix({ file:
|
|
362
|
-
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file:
|
|
397
|
+
'iterate_fix({ file: ' + JSON.stringify(file) + ', content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
|
|
398
|
+
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: ' + JSON.stringify(file) + ' }) to verify the accumulated diff and ' +
|
|
363
399
|
'read its line statistics (lines added/removed). ' +
|
|
364
400
|
'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
|
|
365
401
|
'(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
|
|
@@ -475,7 +511,7 @@ const statusRes = await agent(
|
|
|
475
511
|
{ label: 'status:final' }
|
|
476
512
|
)
|
|
477
513
|
const status = (statusRes && statusRes.ok) ? statusRes : null
|
|
478
|
-
if (!abortedByValidation) {
|
|
514
|
+
if (!abortedByValidation && !schemaFailed) {
|
|
479
515
|
// Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
|
|
480
516
|
await agent(
|
|
481
517
|
'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
|
|
@@ -494,9 +530,11 @@ if (!abortedByValidation) {
|
|
|
494
530
|
fixedCount: fixedCount,
|
|
495
531
|
resumeCount: effectiveResumeCount,
|
|
496
532
|
}
|
|
497
|
-
const stopReason =
|
|
498
|
-
?
|
|
499
|
-
:
|
|
533
|
+
const stopReason = schemaFailed
|
|
534
|
+
? 'schema_invalid'
|
|
535
|
+
: abortedByValidation
|
|
536
|
+
? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
|
|
537
|
+
: (converged ? 'converged' : 'max_rounds_reached')
|
|
500
538
|
await agent(
|
|
501
539
|
'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"}.',
|
|
502
540
|
{ label: 'transcript:capture' }
|
|
@@ -508,6 +546,7 @@ return {
|
|
|
508
546
|
maxRounds: maxRounds,
|
|
509
547
|
converged: converged,
|
|
510
548
|
abortedByValidation: abortedByValidation,
|
|
549
|
+
schemaFailed: schemaFailed,
|
|
511
550
|
failedCommands: failedCommands,
|
|
512
551
|
configErrors: configErrors,
|
|
513
552
|
stoppedReason: stopReason,
|
|
@@ -528,12 +567,12 @@ return {
|
|
|
528
567
|
Key rules for normal mode:
|
|
529
568
|
- 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.
|
|
530
569
|
- Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
|
|
531
|
-
- **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
|
|
570
|
+
- **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.
|
|
532
571
|
- 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.
|
|
533
572
|
- **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
|
|
534
573
|
- **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.
|
|
535
574
|
- **Checkpoint after every round**; clear it only when the iteration completes cleanly.
|
|
536
|
-
- Stop when a round produces nothing to fix (converged)
|
|
575
|
+
- 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"\`).
|
|
537
576
|
- Every round, every rollback, and the final report go to the append-only decision log.
|
|
538
577
|
- Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
|
|
539
578
|
|
package/dist/tools/checkpoint.js
CHANGED
|
@@ -148,9 +148,16 @@ export function computeStatus(input) {
|
|
|
148
148
|
findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
|
|
149
149
|
totalDecisionLogEntries: entries.length,
|
|
150
150
|
hasCheckpoint: checkpoint !== null,
|
|
151
|
-
// A checkpoint left on disk
|
|
152
|
-
//
|
|
153
|
-
|
|
151
|
+
// A checkpoint left on disk is the durable "the run did not finish" signal
|
|
152
|
+
// — EXCEPT when a decision-log entry is NEWER than the checkpoint: the
|
|
153
|
+
// workflow saves a checkpoint at the end of each round (right after that
|
|
154
|
+
// round's review_result log), so a live run proceeding past its last
|
|
155
|
+
// checkpoint would otherwise be flagged as "interrupted" for its whole
|
|
156
|
+
// duration. A fresh checkpoint (or no log at all) is genuinely leftover.
|
|
157
|
+
interrupted: checkpoint !== null &&
|
|
158
|
+
!(lastEntry != null &&
|
|
159
|
+
checkpoint.updatedAt &&
|
|
160
|
+
(Date.parse(checkpoint.updatedAt) || 0) >= (Date.parse(lastEntry.timestamp) || 0)),
|
|
154
161
|
resumeCount: checkpoint?.resumeCount ?? 0,
|
|
155
162
|
checkpoint,
|
|
156
163
|
lastUpdated,
|
|
@@ -315,7 +322,7 @@ export function registerStatusTool(ctx) {
|
|
|
315
322
|
findingsCount: { type: 'integer' },
|
|
316
323
|
totalDecisionLogEntries: { type: 'integer' },
|
|
317
324
|
hasCheckpoint: { type: 'boolean' },
|
|
318
|
-
interrupted: { type: 'boolean', description: 'True when a checkpoint exists
|
|
325
|
+
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.' },
|
|
319
326
|
resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
|
|
320
327
|
lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
321
328
|
qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
|
|
@@ -1,9 +1,127 @@
|
|
|
1
|
-
import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
1
|
+
import { appendFileSync, readFileSync, mkdirSync, existsSync, openSync, writeSync, closeSync, unlinkSync, statSync, } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
5
5
|
const LOG_DIR = '.iterate';
|
|
6
6
|
const LOG_FILE = 'decision-log.jsonl';
|
|
7
|
+
// ─── Cross-process log mutex ────────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// The prune rewrite (temp + atomic rename) can silently discard an audit line
|
|
10
|
+
// from a CONCURRENT appender whose fd was opened against the pre-rename inode:
|
|
11
|
+
// the appender writes to the unlinked inode, the post-rewrite re-read sees a
|
|
12
|
+
// "stable" file, and the fresh entry is lost. In-process safety is guaranteed
|
|
13
|
+
// by synchronous I/O, but a second plugin process appending to the same
|
|
14
|
+
// project realises the race. The log is serialized with a tiny advisory lock
|
|
15
|
+
// file (exclusive create, pid stamped, stale-stealable) so `append` and the
|
|
16
|
+
// prune rewrite mutually exclude across processes. Best-effort: contention
|
|
17
|
+
// timeouts degrade to "proceed unlocked" (the bounded retry loop in prune.ts
|
|
18
|
+
// stays as defense-in-depth), never to a crash.
|
|
19
|
+
const LOCK_FILE = '.decision-log.lock';
|
|
20
|
+
const LOCK_WAIT_MS = 5000;
|
|
21
|
+
const LOCK_STALE_MS = 10000;
|
|
22
|
+
const LOCK_POLL_MS = 25;
|
|
23
|
+
/** True when a pid refers to a live process (or we lack permission to tell). */
|
|
24
|
+
function processAlive(pid) {
|
|
25
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
26
|
+
return false;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return err.code === 'EPERM';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
|
|
36
|
+
function sleepSync(ms) {
|
|
37
|
+
const sab = new Int32Array(new SharedArrayBuffer(4));
|
|
38
|
+
Atomics.wait(sab, 0, 0, ms);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Acquire the decision-log lock for `projectRoot`.
|
|
42
|
+
* Returns a release function (always callable; a no-op when the lock could
|
|
43
|
+
* not be taken — never wedge an append behind a vanished holder).
|
|
44
|
+
*/
|
|
45
|
+
export function acquireLogLock(projectRoot) {
|
|
46
|
+
const dir = join(projectRoot, LOG_DIR);
|
|
47
|
+
if (!existsSync(dir)) {
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return () => { };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const lockPath = join(dir, LOCK_FILE);
|
|
56
|
+
const start = Date.now();
|
|
57
|
+
for (;;) {
|
|
58
|
+
let fd = null;
|
|
59
|
+
try {
|
|
60
|
+
fd = openSync(lockPath, 'wx');
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err.code !== 'EEXIST')
|
|
64
|
+
return () => { };
|
|
65
|
+
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
66
|
+
let stale = false;
|
|
67
|
+
try {
|
|
68
|
+
const st = statSync(lockPath);
|
|
69
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS)
|
|
70
|
+
stale = true;
|
|
71
|
+
else {
|
|
72
|
+
const pid = Number(readFileSync(lockPath, 'utf-8'));
|
|
73
|
+
if (!processAlive(pid))
|
|
74
|
+
stale = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
stale = true; // unreadable/vanishing lock → retry as stale
|
|
79
|
+
}
|
|
80
|
+
if (stale) {
|
|
81
|
+
try {
|
|
82
|
+
unlinkSync(lockPath);
|
|
83
|
+
}
|
|
84
|
+
catch { /* another holder stole it */ }
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (Date.now() - start > LOCK_WAIT_MS)
|
|
88
|
+
return () => { };
|
|
89
|
+
sleepSync(LOCK_POLL_MS);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Owned the lock: stamp the holder pid, then release once on return.
|
|
93
|
+
try {
|
|
94
|
+
writeSync(fd, String(process.pid));
|
|
95
|
+
}
|
|
96
|
+
catch { /* pid stamp is advisory */ }
|
|
97
|
+
try {
|
|
98
|
+
closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
catch { /* best-effort */ }
|
|
101
|
+
let released = false;
|
|
102
|
+
return () => {
|
|
103
|
+
if (released)
|
|
104
|
+
return;
|
|
105
|
+
released = true;
|
|
106
|
+
try {
|
|
107
|
+
unlinkSync(lockPath);
|
|
108
|
+
}
|
|
109
|
+
catch { /* already gone */ }
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
|
|
114
|
+
const entryCountCache = new Map();
|
|
115
|
+
/** Line-count the log file from scratch (entries = non-empty JSON lines). */
|
|
116
|
+
function recountEntries(filePath) {
|
|
117
|
+
try {
|
|
118
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
119
|
+
return content.split('\n').filter((l) => l.trim().length > 0).length;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
7
125
|
/** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
|
|
8
126
|
const VALID_ENTRY_TYPES = new Set([
|
|
9
127
|
'round_start',
|
|
@@ -50,25 +168,32 @@ function logPath(projectRoot) {
|
|
|
50
168
|
* miss without failing the mutation they already performed.
|
|
51
169
|
*/
|
|
52
170
|
export function appendDecisionEntry(projectRoot, entry) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const line = JSON.stringify(entry) + '\n';
|
|
57
|
-
appendFileSync(filePath, line, 'utf-8');
|
|
58
|
-
}
|
|
59
|
-
catch (err) {
|
|
60
|
-
return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` };
|
|
61
|
-
}
|
|
62
|
-
// Count entries
|
|
63
|
-
let count = 0;
|
|
171
|
+
// Serialize with the cross-process log lock so an append can never interleave
|
|
172
|
+
// with a prune rewrite (which renames the file under us).
|
|
173
|
+
const release = acquireLogLock(projectRoot);
|
|
64
174
|
try {
|
|
65
|
-
const
|
|
66
|
-
|
|
175
|
+
const filePath = logPath(projectRoot);
|
|
176
|
+
try {
|
|
177
|
+
const line = JSON.stringify(entry) + '\n';
|
|
178
|
+
const prevSize = existsSync(filePath) ? statSync(filePath).size : 0;
|
|
179
|
+
appendFileSync(filePath, line, 'utf-8');
|
|
180
|
+
// Count entries via the size/count cache: when nothing else changed the
|
|
181
|
+
// file since our last append (same byte size), count is just +1 — no full
|
|
182
|
+
// re-read. Falls back to a full re-count whenever the cache is stale.
|
|
183
|
+
const cached = entryCountCache.get(filePath);
|
|
184
|
+
const count = cached && cached.size === prevSize
|
|
185
|
+
? cached.count + 1
|
|
186
|
+
: recountEntries(filePath);
|
|
187
|
+
entryCountCache.set(filePath, { size: existsSync(filePath) ? statSync(filePath).size : prevSize, count });
|
|
188
|
+
return { count, path: filePath };
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` };
|
|
192
|
+
}
|
|
67
193
|
}
|
|
68
|
-
|
|
69
|
-
|
|
194
|
+
finally {
|
|
195
|
+
release();
|
|
70
196
|
}
|
|
71
|
-
return { count, path: filePath };
|
|
72
197
|
}
|
|
73
198
|
/**
|
|
74
199
|
* Read all entries from the decision log, plus the count of corrupt lines.
|
package/dist/tools/fix.js
CHANGED
|
@@ -364,6 +364,7 @@ export function registerFixTool(ctx) {
|
|
|
364
364
|
linesRemoved: { type: 'integer' },
|
|
365
365
|
diffSummary: { type: 'string' },
|
|
366
366
|
backupPath: { type: 'string' },
|
|
367
|
+
warning: { type: 'string' },
|
|
367
368
|
error: { type: 'string' },
|
|
368
369
|
},
|
|
369
370
|
},
|
|
@@ -451,6 +452,16 @@ export function registerFixTool(ctx) {
|
|
|
451
452
|
if (findFixRecord(registry, id)) {
|
|
452
453
|
return { ok: false, error: `finding already fixed this run (id: ${id})`, id };
|
|
453
454
|
}
|
|
455
|
+
// No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
|
|
456
|
+
// file unchanged) must never burn a backup, a write, or a registry/success
|
|
457
|
+
// record. Placed after the registry check so a re-sent fix of an id that
|
|
458
|
+
// was ALREADY fixed is still reported as "already fixed this run".
|
|
459
|
+
if (added === 0 && removed === 0) {
|
|
460
|
+
return {
|
|
461
|
+
ok: false,
|
|
462
|
+
error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
454
465
|
const target = resolveProjectFile(projectRoot, file);
|
|
455
466
|
if (!target.ok)
|
|
456
467
|
return { ok: false, error: target.reason };
|
|
@@ -521,12 +532,15 @@ export function registerFixTool(ctx) {
|
|
|
521
532
|
}
|
|
522
533
|
return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` };
|
|
523
534
|
}
|
|
524
|
-
appendDecisionEntry(projectRoot, {
|
|
535
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
525
536
|
timestamp,
|
|
526
537
|
round: args.round,
|
|
527
538
|
type: 'atomic_fix',
|
|
528
539
|
data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
|
|
529
540
|
});
|
|
541
|
+
// The fix itself succeeded, but a decision-log write failure would leave
|
|
542
|
+
// the audit trail incomplete — surface it (warning, not fatal) so the
|
|
543
|
+
// model/UI knows the record was not persisted.
|
|
530
544
|
return {
|
|
531
545
|
ok: true,
|
|
532
546
|
id,
|
|
@@ -536,6 +550,7 @@ export function registerFixTool(ctx) {
|
|
|
536
550
|
linesRemoved: removed,
|
|
537
551
|
diffSummary: record.diffSummary,
|
|
538
552
|
backupPath,
|
|
553
|
+
...(logRes.error ? { warning: logRes.error } : {}),
|
|
539
554
|
};
|
|
540
555
|
});
|
|
541
556
|
return result;
|
|
@@ -696,6 +711,7 @@ export function registerRollbackTool(ctx) {
|
|
|
696
711
|
ok: { type: 'boolean', required: true },
|
|
697
712
|
id: { type: 'string' },
|
|
698
713
|
file: { type: 'string' },
|
|
714
|
+
warning: { type: 'string' },
|
|
699
715
|
error: { type: 'string' },
|
|
700
716
|
},
|
|
701
717
|
},
|
|
@@ -736,7 +752,7 @@ export function registerRollbackTool(ctx) {
|
|
|
736
752
|
catch (err) {
|
|
737
753
|
return { ok: false, error: `failed to update fix registry: ${String(err)}` };
|
|
738
754
|
}
|
|
739
|
-
appendDecisionEntry(projectRoot, {
|
|
755
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
740
756
|
timestamp: new Date().toISOString(),
|
|
741
757
|
round: record.round,
|
|
742
758
|
type: 'revert',
|
|
@@ -747,7 +763,12 @@ export function registerRollbackTool(ctx) {
|
|
|
747
763
|
// a missing/corrupt transcript is left untouched and never breaks the
|
|
748
764
|
// rollback flow.
|
|
749
765
|
await markFixRolledBackInTranscript(projectRoot, id);
|
|
750
|
-
return {
|
|
766
|
+
return {
|
|
767
|
+
ok: true,
|
|
768
|
+
id,
|
|
769
|
+
file: record.finding.file,
|
|
770
|
+
...(logRes.error ? { warning: logRes.error } : {}),
|
|
771
|
+
};
|
|
751
772
|
},
|
|
752
773
|
}));
|
|
753
774
|
}
|
package/dist/tools/history.js
CHANGED
|
@@ -84,7 +84,7 @@ export function registerHistoryTool(ctx) {
|
|
|
84
84
|
},
|
|
85
85
|
limit: {
|
|
86
86
|
type: 'integer',
|
|
87
|
-
description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}).
|
|
87
|
+
description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
|
|
88
88
|
},
|
|
89
89
|
path: {
|
|
90
90
|
type: 'string',
|
package/dist/tools/prune.js
CHANGED
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
* - dryRun=true by default — the caller must explicitly opt into deletion.
|
|
24
24
|
* - Each deletion is logged to the decision log (when not dry-run).
|
|
25
25
|
*/
|
|
26
|
-
import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
26
|
+
import { existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'node:fs';
|
|
27
27
|
import { join } from 'node:path';
|
|
28
28
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
29
29
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
30
30
|
import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
|
|
31
|
-
import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
|
|
31
|
+
import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from "./decision-log.js";
|
|
32
32
|
import { readRegistry, recomputeRoundCounts } from "./fix.js";
|
|
33
33
|
import { readExperienceBank, writeExperienceBank } from "./experience-store.js";
|
|
34
34
|
import { readDefenseEvents, writeDefenseEvents } from "./defense-store.js";
|
|
@@ -143,8 +143,22 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
143
143
|
// 1. Decision-log entries older than retainDays.
|
|
144
144
|
const entries = readDecisionEntries(projectRoot);
|
|
145
145
|
const oldLogEntries = entries.filter((e) => e.timestamp < cutoff).length;
|
|
146
|
-
// 2. Checkpoint presence.
|
|
146
|
+
// 2. Checkpoint presence. A checkpoint is only STALE once it is older than
|
|
147
|
+
// retainDays — a fresh checkpoint means a run was interrupted recently and
|
|
148
|
+
// may still be resumed, so `hasCheckpoint` is kept as pure existence while
|
|
149
|
+
// `checkpointStale` decides whether a prune actually deletes it.
|
|
147
150
|
const hasCheckpoint = existsSync(checkpointPath(projectRoot));
|
|
151
|
+
let checkpointStale = false;
|
|
152
|
+
if (hasCheckpoint) {
|
|
153
|
+
try {
|
|
154
|
+
const st = statSync(checkpointPath(projectRoot));
|
|
155
|
+
if (st && st.mtimeMs < Date.parse(cutoff))
|
|
156
|
+
checkpointStale = true;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
checkpointStale = true; // unreadable mtime — treat as garbage / deletable
|
|
160
|
+
}
|
|
161
|
+
}
|
|
148
162
|
// 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
|
|
149
163
|
const registry = readRegistry(projectRoot);
|
|
150
164
|
const activeIds = new Set();
|
|
@@ -204,6 +218,7 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
204
218
|
return {
|
|
205
219
|
oldLogEntries,
|
|
206
220
|
hasCheckpoint,
|
|
221
|
+
checkpointStale,
|
|
207
222
|
staleBackups,
|
|
208
223
|
staleTemps,
|
|
209
224
|
emptyRounds,
|
|
@@ -234,26 +249,36 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
234
249
|
*/
|
|
235
250
|
function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
|
|
236
251
|
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
// Take the cross-process log lock for the ENTIRE loop: with the mutex held,
|
|
253
|
+
// no concurrent appender can slip a fresh line into the read-before-rewrite
|
|
254
|
+
// window, so the first attempt is nearly always the only one. (The retry
|
|
255
|
+
// loop stays as defense-in-depth for the lock-unavailable path.)
|
|
256
|
+
const release = acquireLogLock(projectRoot);
|
|
257
|
+
try {
|
|
258
|
+
for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
|
|
259
|
+
try {
|
|
260
|
+
const entries = readDecisionEntries(projectRoot);
|
|
261
|
+
const kept = entries.filter((e) => e.timestamp >= cutoff);
|
|
262
|
+
const deleted = entries.length - kept.length;
|
|
263
|
+
if (deleted === 0)
|
|
264
|
+
return { deleted: 0 };
|
|
265
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
266
|
+
// A concurrent appender (cross-process, lock-unavailable path) may have
|
|
267
|
+
// landed new lines after our read. If the log grew during the write
|
|
268
|
+
// window, re-read and prune again instead of accepting a lost audit trail.
|
|
269
|
+
const after = readDecisionEntries(projectRoot);
|
|
270
|
+
if (after.length <= kept.length)
|
|
271
|
+
return { deleted };
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` };
|
|
275
|
+
}
|
|
254
276
|
}
|
|
277
|
+
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
release();
|
|
255
281
|
}
|
|
256
|
-
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
|
|
257
282
|
}
|
|
258
283
|
/**
|
|
259
284
|
* Execute the prune: apply every cleanable item reported by `inspectPrune`.
|
|
@@ -282,8 +307,10 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
282
307
|
if (error)
|
|
283
308
|
result.errors.push(error);
|
|
284
309
|
}
|
|
285
|
-
// 2. Remove checkpoint
|
|
286
|
-
|
|
310
|
+
// 2. Remove checkpoint — ONLY when it is actually stale (older than
|
|
311
|
+
// retainDays). A fresh checkpoint is a live resume point for a recently
|
|
312
|
+
// interrupted run; pruning it would silently destroy the ability to resume.
|
|
313
|
+
if (report.checkpointStale) {
|
|
287
314
|
try {
|
|
288
315
|
rmSync(checkpointPath(projectRoot), { force: true });
|
|
289
316
|
result.deletedCheckpoint = true;
|
|
@@ -412,7 +439,7 @@ export function registerPruneTool(ctx) {
|
|
|
412
439
|
const lines = [
|
|
413
440
|
`[dry-run] prune report (retainDays=${value.retainDays}):`,
|
|
414
441
|
` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
|
|
415
|
-
` Checkpoint to delete: ${report?.
|
|
442
|
+
` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
|
|
416
443
|
` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
|
|
417
444
|
` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
|
|
418
445
|
` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
|
|
@@ -459,8 +486,10 @@ export function registerPruneTool(ctx) {
|
|
|
459
486
|
};
|
|
460
487
|
}
|
|
461
488
|
const result = executePrune(projectRoot, retainDays, report);
|
|
462
|
-
// Log the prune to the decision log.
|
|
463
|
-
|
|
489
|
+
// Log the prune to the decision log. The mutations already happened, so
|
|
490
|
+
// a failed log append is reported via `errors` — a silent audit miss
|
|
491
|
+
// (F2) must never pretend every deletion was recorded.
|
|
492
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
464
493
|
timestamp: new Date().toISOString(),
|
|
465
494
|
round: 0,
|
|
466
495
|
type: 'decision',
|
|
@@ -475,6 +504,8 @@ export function registerPruneTool(ctx) {
|
|
|
475
504
|
deletedDefenseEvents: result.deletedDefenseEvents,
|
|
476
505
|
},
|
|
477
506
|
});
|
|
507
|
+
if (logRes.error)
|
|
508
|
+
result.errors.push(logRes.error);
|
|
478
509
|
return {
|
|
479
510
|
ok: true,
|
|
480
511
|
dryRun: false,
|