iterate-plugin 3.5.3 → 3.5.5
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 +8 -3
- package/dist/config-write.js +50 -9
- package/dist/evidence.js +28 -1
- package/dist/git-scope.js +14 -5
- package/dist/jobs.js +15 -1
- package/dist/live.js +39 -22
- package/dist/meta-review.js +2 -2
- package/dist/review-scope.js +6 -2
- package/dist/review.js +7 -6
- package/dist/session-hooks.js +48 -4
- package/dist/skill-prompt.js +66 -32
- package/dist/tools/context.js +5 -1
- package/dist/tools/decision-log.js +142 -17
- package/dist/tools/defense-events.js +7 -3
- package/dist/tools/defense-store.js +14 -7
- package/dist/tools/experience-bank.js +6 -3
- package/dist/tools/experience-store.js +24 -12
- package/dist/tools/fix.js +24 -3
- package/dist/tools/prune.js +185 -17
- package/dist/tools/review.js +17 -3
- package/dist/tools/transcript.js +28 -6
- package/dist/tools/triage.js +38 -4
- package/dist/transcript.js +68 -34
- package/lib/client.js +41 -11
- package/lib/parse.js +30 -2
- package/package.json +10 -7
- package/src/atomic-fs.ts +20 -4
- package/src/client/index.ts +23 -3
- package/src/config-loader.ts +8 -3
- package/src/config-write.ts +59 -9
- package/src/evidence.ts +29 -1
- package/src/git-scope.ts +14 -3
- package/src/jobs.ts +15 -1
- package/src/live.ts +41 -22
- package/src/meta-review.ts +2 -2
- package/src/review-scope.ts +6 -2
- package/src/review.ts +7 -6
- package/src/session-hooks.ts +50 -4
- package/src/skill-prompt.ts +66 -32
- package/src/tools/context.ts +5 -1
- package/src/tools/decision-log.ts +136 -16
- package/src/tools/defense-events.ts +7 -3
- package/src/tools/defense-store.ts +14 -7
- package/src/tools/experience-bank.ts +6 -3
- package/src/tools/experience-store.ts +24 -12
- package/src/tools/fix.ts +25 -3
- package/src/tools/prune.ts +194 -17
- package/src/tools/review.ts +16 -3
- package/src/tools/transcript.ts +26 -5
- package/src/tools/triage.ts +39 -4
- package/src/transcript.ts +68 -31
- package/src/types.ts +2 -0
package/dist/skill-prompt.js
CHANGED
|
@@ -94,10 +94,11 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
94
94
|
log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
|
|
95
95
|
let agg = null
|
|
96
96
|
let schemaInvalid = false
|
|
97
|
-
let
|
|
97
|
+
let attempts = 0
|
|
98
98
|
do {
|
|
99
|
+
attempts += 1
|
|
99
100
|
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
100
|
-
const nudge =
|
|
101
|
+
const nudge = attempts > 1
|
|
101
102
|
? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
|
|
102
103
|
: ''
|
|
103
104
|
const raw = await parallel(dims.map(dim => () => {
|
|
@@ -134,11 +135,15 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
134
135
|
schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
|
|
135
136
|
? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
|
|
136
137
|
: false
|
|
137
|
-
if (schemaInvalid &&
|
|
138
|
-
|
|
139
|
-
log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
138
|
+
if (schemaInvalid && attempts < 3) {
|
|
139
|
+
log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
140
140
|
}
|
|
141
|
-
} while (schemaInvalid &&
|
|
141
|
+
} while (schemaInvalid && attempts < 3)
|
|
142
|
+
if (schemaInvalid) {
|
|
143
|
+
// Bounded exit: never loop forever. Surface the failure so the run's
|
|
144
|
+
// final summary can explain WHY the round was inconclusive.
|
|
145
|
+
log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
|
|
146
|
+
}
|
|
142
147
|
// Feed the DEDUPED + already-filtered set back (not raw findings) so the known
|
|
143
148
|
// list stays bounded and reviewers never see the same issue twice.
|
|
144
149
|
if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
@@ -269,6 +274,7 @@ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? che
|
|
|
269
274
|
let converged = false
|
|
270
275
|
let abortedByValidation = false
|
|
271
276
|
let failedCommands = []
|
|
277
|
+
let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
|
|
272
278
|
const fixRecords = [] // observatory fix records collected round by round
|
|
273
279
|
|
|
274
280
|
// Read any steering nudge intended for this run's reviewers.
|
|
@@ -288,10 +294,11 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
288
294
|
)
|
|
289
295
|
let agg = null
|
|
290
296
|
let schemaInvalid = false
|
|
291
|
-
let
|
|
297
|
+
let attempts = 0
|
|
292
298
|
do {
|
|
299
|
+
attempts += 1
|
|
293
300
|
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
294
|
-
const nudge =
|
|
301
|
+
const nudge = attempts > 1
|
|
295
302
|
? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
|
|
296
303
|
: ''
|
|
297
304
|
const raw = await parallel(dims.map(dim => () => {
|
|
@@ -327,11 +334,15 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
327
334
|
schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
|
|
328
335
|
? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
|
|
329
336
|
: false
|
|
330
|
-
if (schemaInvalid &&
|
|
331
|
-
|
|
332
|
-
log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
337
|
+
if (schemaInvalid && attempts < 3) {
|
|
338
|
+
log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
333
339
|
}
|
|
334
|
-
} while (schemaInvalid &&
|
|
340
|
+
} while (schemaInvalid && attempts < 3)
|
|
341
|
+
if (schemaInvalid) {
|
|
342
|
+
// Bounded exit: never loop forever. Surface the failure so the run's
|
|
343
|
+
// final summary can explain WHY the round was inconclusive.
|
|
344
|
+
log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
|
|
345
|
+
}
|
|
335
346
|
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
336
347
|
const atomic = findings.filter(f => f.is_atomic === true)
|
|
337
348
|
const remaining = findings.filter(f => f.is_atomic !== true)
|
|
@@ -345,10 +356,10 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
345
356
|
const byFile = {}
|
|
346
357
|
atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
|
|
347
358
|
const fixRes = await parallel(Object.keys(byFile).map(file => () => agent(
|
|
348
|
-
'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
|
|
359
|
+
'Apply the fixes for ' + JSON.stringify(file) + ' using iterate_fix. For EACH finding in this list, ' +
|
|
349
360
|
'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
|
|
350
|
-
'iterate_fix({ file:
|
|
351
|
-
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file:
|
|
361
|
+
'iterate_fix({ file: ' + JSON.stringify(file) + ', content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
|
|
362
|
+
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: ' + JSON.stringify(file) + ' }) to verify the accumulated diff and ' +
|
|
352
363
|
'read its line statistics (lines added/removed). ' +
|
|
353
364
|
'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
|
|
354
365
|
'(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
|
|
@@ -386,18 +397,33 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
386
397
|
if (seenKeys.indexOf(key) < 0) { architectural.push(f); seenKeys.push(key) }
|
|
387
398
|
}
|
|
388
399
|
|
|
389
|
-
// Validate every configured command; on ANY failure roll back this
|
|
400
|
+
// Validate every configured command; on ANY *executed* failure roll back this
|
|
401
|
+
// round's fixes. iterate_validate returns \`allowed:false\` + \`rejectReason\`
|
|
402
|
+
// when a command is NOT in validation.commands (a config gap, NOT a code
|
|
403
|
+
// failure) — those abort the run WITHOUT rolling back (the code changes are
|
|
404
|
+
// fine; the trust list just needs fixing).
|
|
390
405
|
const valRes = await agent(
|
|
391
406
|
'Read iterate.config.yaml validation.commands, then call iterate_validate({ command: <cmd> }) for EACH configured command ' +
|
|
392
|
-
'(one tool call per command). Return all results as {command, exitCode} entries.',
|
|
407
|
+
'(one tool call per command). Return all results as {command, exitCode, allowed, rejectReason} entries — include \`allowed\` and \`rejectReason\` exactly as returned by iterate_validate (allowed:false means the command is not in validation.commands).',
|
|
393
408
|
Object.assign({ label: 'validate:r' + r, phase: 'validate', schema: {
|
|
394
409
|
type: 'object', additionalProperties: false,
|
|
395
410
|
properties: {
|
|
396
|
-
results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' } }, required: ['command', 'exitCode'] } }
|
|
411
|
+
results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' }, allowed: { type: 'boolean' }, rejectReason: { type: 'string' } }, required: ['command', 'exitCode'] } }
|
|
397
412
|
},
|
|
398
413
|
required: ['results'] } }, backend)
|
|
399
414
|
)
|
|
400
|
-
|
|
415
|
+
const valResults = (valRes && Array.isArray(valRes.results)) ? valRes.results : []
|
|
416
|
+
configErrors = valResults.filter(v => v.allowed === false).map(v => v.rejectReason || (v.command + ' was rejected'))
|
|
417
|
+
failedCommands = valResults.filter(v => !(v.allowed === false) && v.exitCode !== 0).map(v => v.command)
|
|
418
|
+
if (configErrors.length > 0) {
|
|
419
|
+
log('round ' + r + ' validation CONFIG error (not in validation.commands): ' + configErrors.join(' | ') + ' — aborting WITHOUT rollback (code changes are kept; the command trust list needs updating)')
|
|
420
|
+
abortedByValidation = true
|
|
421
|
+
await agent(
|
|
422
|
+
'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{configErrors:' + JSON.stringify(configErrors) + ', rolledBack:0, reason:"command not in validation.commands"}})',
|
|
423
|
+
Object.assign({ label: 'log:configError:r' + r }, backend)
|
|
424
|
+
)
|
|
425
|
+
break
|
|
426
|
+
}
|
|
401
427
|
if (failedCommands.length > 0) {
|
|
402
428
|
log('round ' + r + ' validation FAILED on: ' + failedCommands.join(', ') + ' — rolling back this round')
|
|
403
429
|
abortedByValidation = true
|
|
@@ -458,17 +484,23 @@ if (!abortedByValidation) {
|
|
|
458
484
|
}
|
|
459
485
|
// Persist the run's observatory transcript (threads, trend, fixes, checkpoint)
|
|
460
486
|
// so the client observatory panel reflects the run. Writes ONLY .iterate/transcript.json.
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
487
|
+
// The checkpoint survives on disk for aborted runs (that is how resumption
|
|
488
|
+
// works), so the transcript must mirror that reality — a null checkpoint here
|
|
489
|
+
// would hide the very thing F5 is meant to resume. Reflect what is on disk.
|
|
490
|
+
const obsCheckpoint = {
|
|
491
|
+
mode: 'normal',
|
|
492
|
+
round: rounds.length,
|
|
493
|
+
maxRounds: maxRounds,
|
|
494
|
+
fixedCount: fixedCount,
|
|
495
|
+
resumeCount: effectiveResumeCount,
|
|
496
|
+
}
|
|
497
|
+
const stopReason = abortedByValidation
|
|
498
|
+
? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
|
|
499
|
+
: (converged ? 'converged' : 'max_rounds_reached')
|
|
500
|
+
await agent(
|
|
501
|
+
'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
|
+
{ label: 'transcript:capture' }
|
|
503
|
+
)
|
|
472
504
|
return {
|
|
473
505
|
mode: 'normal',
|
|
474
506
|
goal: plan.goal,
|
|
@@ -477,6 +509,8 @@ return {
|
|
|
477
509
|
converged: converged,
|
|
478
510
|
abortedByValidation: abortedByValidation,
|
|
479
511
|
failedCommands: failedCommands,
|
|
512
|
+
configErrors: configErrors,
|
|
513
|
+
stoppedReason: stopReason,
|
|
480
514
|
findingsFixed: fixedCount,
|
|
481
515
|
remainingArchitecturalCount: architectural.length,
|
|
482
516
|
remainingArchitectural: architectural,
|
|
@@ -494,10 +528,10 @@ return {
|
|
|
494
528
|
Key rules for normal mode:
|
|
495
529
|
- 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.
|
|
496
530
|
- Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
|
|
497
|
-
- **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`.
|
|
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, mark the round inconclusive (never report it as a clean convergence).
|
|
498
532
|
- 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.
|
|
499
533
|
- **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
|
|
500
|
-
- **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).
|
|
534
|
+
- **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.
|
|
501
535
|
- **Checkpoint after every round**; clear it only when the iteration completes cleanly.
|
|
502
536
|
- Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
|
|
503
537
|
- Every round, every rollback, and the final report go to the append-only decision log.
|
package/dist/tools/context.js
CHANGED
|
@@ -306,7 +306,11 @@ export function registerContextTool(ctx) {
|
|
|
306
306
|
// skillDir point at arbitrary filesystem locations (e.g. /etc)
|
|
307
307
|
// whose contents would be injected wholesale into the context.
|
|
308
308
|
if (isAllowedSkillDir(args.skillDir, [projectRoot, PLUGIN_SRC_DIR])) {
|
|
309
|
-
|
|
309
|
+
// Read from the REAL (symlink-resolved) path that was actually
|
|
310
|
+
// validated — never the raw path. Validating one path and then
|
|
311
|
+
// reading through a different (swappable) symlink would leave a
|
|
312
|
+
// check-vs-read TOCTOU gap; using the realpath here closes it.
|
|
313
|
+
candidates.push(realpathSync(resolve(args.skillDir)));
|
|
310
314
|
}
|
|
311
315
|
}
|
|
312
316
|
catch {
|
|
@@ -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.
|
|
@@ -72,9 +72,13 @@ function validateRecordInput(args) {
|
|
|
72
72
|
export function registerDefenseEventsTool(ctx) {
|
|
73
73
|
ctx.tools.register(defineTool({
|
|
74
74
|
name: 'iterate_defense_events',
|
|
75
|
-
// List/counts never write;
|
|
76
|
-
// join a parallel
|
|
77
|
-
|
|
75
|
+
// List/counts never write; `record` persists an event and `clear` removes
|
|
76
|
+
// the persisted stream, so only the read shapes may join a parallel
|
|
77
|
+
// dispatch group.
|
|
78
|
+
isConcurrencySafe: (args) => {
|
|
79
|
+
const op = args.operation;
|
|
80
|
+
return op !== 'record' && op !== 'clear';
|
|
81
|
+
},
|
|
78
82
|
description: 'Query, record, or clear defense events: precondition failures, rollbacks, invariant violations, ' +
|
|
79
83
|
'and assumption falsifications. ' +
|
|
80
84
|
'List/counts return events with descriptions, outcomes, and summary counts; ' +
|
|
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import { iterateDir } from "../paths.js";
|
|
10
10
|
import { writeJsonAtomic } from "../atomic-fs.js";
|
|
11
|
+
import { hashString } from "./fix.js";
|
|
11
12
|
const DEFENSE_EVENTS_FILE = 'defense-events.json';
|
|
12
13
|
/** Valid defense event types (must stay in sync with DefenseEventType). */
|
|
13
14
|
const VALID_EVENT_TYPES = new Set([
|
|
@@ -46,16 +47,20 @@ const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
|
|
|
46
47
|
* `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
|
|
47
48
|
* (list sort by timestamp, render label selection) must never crash or emit
|
|
48
49
|
* NaN for those. Returns null when the entry is not an object or has no usable
|
|
49
|
-
* `type`; otherwise fills every required field with a safe default.
|
|
50
|
+
* `type`; otherwise fills every required field with a safe default. An event
|
|
51
|
+
* missing its `id` gets a DETERMINISTIC id derived from its content and
|
|
52
|
+
* position (never a random one) so a read→write cycle cannot silently churn
|
|
53
|
+
* ids on every mutation.
|
|
50
54
|
*/
|
|
51
|
-
function normalizeEvent(raw) {
|
|
55
|
+
function normalizeEvent(raw, index) {
|
|
52
56
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
53
57
|
return null;
|
|
54
58
|
const e = raw;
|
|
55
59
|
if (typeof e.type !== 'string' || !VALID_EVENT_TYPES.has(e.type))
|
|
56
60
|
return null;
|
|
61
|
+
const derivedId = `def-${hashString(`${index}|${String(e.timestamp ?? '')}|${e.type}|${String(e.description ?? '')}|${String(e.defense ?? '')}`)}`;
|
|
57
62
|
return {
|
|
58
|
-
id: typeof e.id === 'string' && e.id ? e.id :
|
|
63
|
+
id: typeof e.id === 'string' && e.id ? e.id : derivedId,
|
|
59
64
|
timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : new Date().toISOString(),
|
|
60
65
|
round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
|
|
61
66
|
type: e.type,
|
|
@@ -82,7 +87,7 @@ export function readDefenseEvents(projectRoot) {
|
|
|
82
87
|
const parsed = JSON.parse(content);
|
|
83
88
|
if (parsed && Array.isArray(parsed.events)) {
|
|
84
89
|
const events = parsed.events
|
|
85
|
-
.map(normalizeEvent)
|
|
90
|
+
.map((raw, i) => normalizeEvent(raw, i))
|
|
86
91
|
.filter((e) => e !== null);
|
|
87
92
|
const counts = computeCounts(events);
|
|
88
93
|
return {
|
|
@@ -140,11 +145,13 @@ export function clearDefenseEvents(projectRoot) {
|
|
|
140
145
|
}
|
|
141
146
|
/** Add a defense event to the stream. */
|
|
142
147
|
export function addDefenseEvent(stream, event) {
|
|
143
|
-
|
|
148
|
+
// Spread the caller-shaped event FIRST so the store-generated `id` and
|
|
149
|
+
// `timestamp` always win — a caller-supplied id/timestamp must never
|
|
150
|
+
// override the stream's own identity fields.
|
|
144
151
|
const newEvent = {
|
|
145
|
-
id,
|
|
146
|
-
timestamp: new Date().toISOString(),
|
|
147
152
|
...event,
|
|
153
|
+
id: `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
154
|
+
timestamp: new Date().toISOString(),
|
|
148
155
|
};
|
|
149
156
|
// Always recompute from the events array instead of mutating a possibly
|
|
150
157
|
// stale/malformed persisted `counts` object — guarantees the stream counts
|
|
@@ -67,9 +67,12 @@ function normalizeExperienceInput(raw) {
|
|
|
67
67
|
export function registerExperienceBankTool(ctx) {
|
|
68
68
|
ctx.tools.register(defineTool({
|
|
69
69
|
name: 'iterate_experience',
|
|
70
|
-
// List/search/get never write;
|
|
71
|
-
// join a parallel dispatch group.
|
|
72
|
-
isConcurrencySafe: (args) =>
|
|
70
|
+
// List/search/get never write; `add` upserts the bank and `remove`
|
|
71
|
+
// rewrites it → only the read shapes may join a parallel dispatch group.
|
|
72
|
+
isConcurrencySafe: (args) => {
|
|
73
|
+
const op = args.operation;
|
|
74
|
+
return op !== 'add' && op !== 'remove';
|
|
75
|
+
},
|
|
73
76
|
description: 'Query or extend the experience bank: browse/search historical fixes and patterns, ' +
|
|
74
77
|
'or record a new verified fix (operation:"add"). ' +
|
|
75
78
|
'List/search/get return matching entries with hit counts, verified fixes, and related context. ' +
|
|
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import { iterateDir } from "../paths.js";
|
|
10
10
|
import { writeJsonAtomic } from "../atomic-fs.js";
|
|
11
|
+
import { hashString } from "./fix.js";
|
|
11
12
|
const EXPERIENCE_FILE = 'experience.json';
|
|
12
13
|
/** Default empty experience bank. */
|
|
13
14
|
function emptyBank() {
|
|
@@ -32,13 +33,16 @@ function stringArray(v) {
|
|
|
32
33
|
* throw or emit NaN. Returns null for non-object entries; every required field
|
|
33
34
|
* gets a safe default.
|
|
34
35
|
*/
|
|
35
|
-
function normalizeEntry(raw) {
|
|
36
|
+
function normalizeEntry(raw, index) {
|
|
36
37
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
37
38
|
return null;
|
|
38
39
|
const e = raw;
|
|
39
40
|
const pattern = typeof e.pattern === 'string' ? e.pattern : '';
|
|
40
41
|
const dimension = typeof e.dimension === 'string' ? e.dimension : '';
|
|
41
|
-
|
|
42
|
+
// Deterministic id (content + position based) so a read→write cycle never
|
|
43
|
+
// churns random ids on entries that were hand-edited without one.
|
|
44
|
+
const derivedId = `exp-${hashString(`${index}|${pattern}|${dimension}`)}`;
|
|
45
|
+
const id = typeof e.id === 'string' && e.id ? e.id : derivedId;
|
|
42
46
|
if (!pattern && !dimension)
|
|
43
47
|
return null;
|
|
44
48
|
const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0;
|
|
@@ -65,7 +69,7 @@ export function readExperienceBank(projectRoot) {
|
|
|
65
69
|
const parsed = JSON.parse(content);
|
|
66
70
|
if (parsed && Array.isArray(parsed.entries)) {
|
|
67
71
|
const entries = parsed.entries
|
|
68
|
-
.map(normalizeEntry)
|
|
72
|
+
.map((raw, i) => normalizeEntry(raw, i))
|
|
69
73
|
.filter((e) => e !== null);
|
|
70
74
|
return {
|
|
71
75
|
entries,
|
|
@@ -142,8 +146,11 @@ export function searchExperienceEntries(entries, query, opts = {}) {
|
|
|
142
146
|
/**
|
|
143
147
|
* Add or update an experience entry.
|
|
144
148
|
*
|
|
145
|
-
* An entry with an `id` that already exists
|
|
146
|
-
*
|
|
149
|
+
* An entry with an `id` that already exists is treated as an UPDATE + HIT:
|
|
150
|
+
* the caller-supplied fields replace the stored ones and the hitCount is
|
|
151
|
+
* incremented (lastHitAt refreshed) — this fulfills the documented
|
|
152
|
+
* "update a specific entry via add" contract. A NEW entry whose
|
|
153
|
+
* `pattern`+`dimension` pair matches an existing entry is treated as a HIT:
|
|
147
154
|
* the matching entry's hitCount is incremented (lastHitAt refreshed) so
|
|
148
155
|
* repeated encounters of the same pattern do not create duplicates. Otherwise
|
|
149
156
|
* a fresh entry is appended with hitCount 1. Never mutates the input bank.
|
|
@@ -157,11 +164,14 @@ export function upsertExperience(bank, entry) {
|
|
|
157
164
|
? bank.entries.find((e) => e.id === entry.id)
|
|
158
165
|
: bank.entries.find((e) => e.pattern === entry.pattern && e.dimension === entry.dimension);
|
|
159
166
|
if (existing) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
167
|
+
// Explicit-id updates REPLACE the editable fields (fulfilling the
|
|
168
|
+
// documented "update a specific entry via add" contract); a pattern+
|
|
169
|
+
// dimension HIT only bumps the hit metadata so a repeat encounter never
|
|
170
|
+
// overwrites the curated entry.
|
|
171
|
+
const byId = typeof entry.id === 'string' && entry.id.length > 0;
|
|
172
|
+
const updated = byId
|
|
173
|
+
? { ...existing, ...entry, id: existing.id, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
|
|
174
|
+
: { ...existing, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated };
|
|
165
175
|
return {
|
|
166
176
|
bank: {
|
|
167
177
|
...bank,
|
|
@@ -173,14 +183,16 @@ export function upsertExperience(bank, entry) {
|
|
|
173
183
|
entryId: existing.id,
|
|
174
184
|
};
|
|
175
185
|
}
|
|
176
|
-
// Add new entry
|
|
186
|
+
// Add new entry. Spread the caller input FIRST so the store-generated
|
|
187
|
+
// `id`/`timestamp`/`hitCount`/`lastHitAt` always win — a hostile or
|
|
188
|
+
// malformed input can never forge its own identity or hit count.
|
|
177
189
|
const id = entry.id || `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
178
190
|
const newEntry = {
|
|
191
|
+
...entry,
|
|
179
192
|
id,
|
|
180
193
|
timestamp: lastUpdated,
|
|
181
194
|
hitCount: 1,
|
|
182
195
|
lastHitAt: lastUpdated,
|
|
183
|
-
...entry,
|
|
184
196
|
};
|
|
185
197
|
return {
|
|
186
198
|
bank: {
|
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
|
}
|