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/src/review-scope.ts
CHANGED
|
@@ -72,8 +72,12 @@ function normalizePath(path: string): string {
|
|
|
72
72
|
for (const part of cleaned.split(SEP)) {
|
|
73
73
|
if (part === '' || part === '.') continue
|
|
74
74
|
if (part === '..') {
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// Mirrors Python `os.path.normpath`: a leading `..` is preserved and a
|
|
76
|
+
// second `..` is NEVER collapsed into a bare `/` away — otherwise a
|
|
77
|
+
// traversal like `../../evil` folds into the single segment `evil` and
|
|
78
|
+
// the escape becomes undetectable downstream.
|
|
79
|
+
if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
|
|
80
|
+
else parts.push(part)
|
|
77
81
|
continue
|
|
78
82
|
}
|
|
79
83
|
parts.push(part)
|
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
|
|
package/src/session-hooks.ts
CHANGED
|
@@ -7,12 +7,22 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - `allow` policy → the call runs.
|
|
9
9
|
* - `deny` policy → the call is refused (fail-closed), surfaced as an
|
|
10
|
-
* error to the model.
|
|
10
|
+
* error to the model. Since dsh 0.1.6-alpha.1 the
|
|
11
|
+
* denial carries a structured `info` (name
|
|
12
|
+
* `iterate-approval-gate`, code `APPROVAL_DENIED`,
|
|
13
|
+
* plus the human-readable reason) so durable
|
|
14
|
+
* projections can route it distinctly from a normal
|
|
15
|
+
* tool failure.
|
|
11
16
|
* - `ask` policy → return `{ kind: 'ask', reason }`; dsh's own
|
|
12
17
|
* scheduler routes it through the `approval` service
|
|
13
18
|
* (see `@deepseek-ai/dsh-user-approval`), which
|
|
14
19
|
* prompts the human and audits an approve/deny pair
|
|
15
20
|
* on the session.
|
|
21
|
+
* - canceled → if the caller aborted the invocation before
|
|
22
|
+
* dispatch, return `{ kind: 'cancel' }` (the
|
|
23
|
+
* canonical 0.1.6-alpha.1 cancellation result) so a
|
|
24
|
+
* destructive call never runs (or asks) for a dead
|
|
25
|
+
* request.
|
|
16
26
|
*
|
|
17
27
|
* We deliberately do NOT also add `approved` flags inside the tool bodies:
|
|
18
28
|
* the pre-execute waterfall consumes the human decision before the tool runs,
|
|
@@ -29,7 +39,15 @@
|
|
|
29
39
|
import { loadEffectiveConfig, resolveProjectRoot } from './config-loader.ts'
|
|
30
40
|
import { decideApproval, isDestructiveIterateTool } from './approval-gate.ts'
|
|
31
41
|
import type { Context } from '@deepseek-ai/cordis'
|
|
32
|
-
import type { ToolExecution, PreToolDecision } from '@deepseek-ai/dsh-tools'
|
|
42
|
+
import type { ToolErrorInfo, ToolExecution, PreToolDecision } from '@deepseek-ai/dsh-tools'
|
|
43
|
+
|
|
44
|
+
/** Structured error identity attached to policy denials (dsh 0.1.6-alpha.1:
|
|
45
|
+
* `deny.info` lets durable projections / retry / replay distinguish an
|
|
46
|
+
* approval-policy denial from a tool-thrown failure). */
|
|
47
|
+
const DENY_INFO: ToolErrorInfo = {
|
|
48
|
+
name: 'iterate-approval-gate',
|
|
49
|
+
code: 'APPROVAL_DENIED',
|
|
50
|
+
}
|
|
33
51
|
|
|
34
52
|
/**
|
|
35
53
|
* Build the per-call approval decision for a tool execution.
|
|
@@ -49,6 +67,20 @@ export function gateDecision(exec: ToolExecution): PreToolDecision {
|
|
|
49
67
|
}
|
|
50
68
|
if (!isDestructiveIterateTool(name)) return { kind: 'allow' }
|
|
51
69
|
|
|
70
|
+
// dsh 0.1.6-alpha.1: if the caller already aborted this invocation before
|
|
71
|
+
// dispatch, there is nothing to gate — select the canonical cancellation
|
|
72
|
+
// result (`cancel`) rather than prompting a human for a dead call or running
|
|
73
|
+
// `next()` (which would allow) on an aborted one.
|
|
74
|
+
let signal: Readonly<AbortSignal> | undefined
|
|
75
|
+
try {
|
|
76
|
+
signal = exec.signal
|
|
77
|
+
} catch {
|
|
78
|
+
signal = undefined
|
|
79
|
+
}
|
|
80
|
+
if (signal && typeof signal.aborted === 'boolean' && signal.aborted) {
|
|
81
|
+
return { kind: 'cancel' }
|
|
82
|
+
}
|
|
83
|
+
|
|
52
84
|
// Resolve the project root (use the call's own `path` arg, else the agent's
|
|
53
85
|
// session cwd) to read the effective observatory policy.
|
|
54
86
|
let argPath: string | undefined
|
|
@@ -74,7 +106,18 @@ export function gateDecision(exec: ToolExecution): PreToolDecision {
|
|
|
74
106
|
}
|
|
75
107
|
|
|
76
108
|
const decision = decideApproval(exec, policy)
|
|
77
|
-
if (decision.kind === 'deny')
|
|
109
|
+
if (decision.kind === 'deny') {
|
|
110
|
+
return {
|
|
111
|
+
kind: 'deny',
|
|
112
|
+
reason: decision.reason,
|
|
113
|
+
info: {
|
|
114
|
+
...DENY_INFO,
|
|
115
|
+
// ToolErrorInfo.reason (0.1.6-alpha.1): raw user-facing detail kept in
|
|
116
|
+
// durable state; the model-facing text still only sees `reason` above.
|
|
117
|
+
reason: decision.reason,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
78
121
|
if (decision.kind === 'ask') return { kind: 'ask', reason: decision.reason }
|
|
79
122
|
return { kind: 'allow' }
|
|
80
123
|
}
|
|
@@ -104,7 +147,10 @@ export function registerSessionHooks(ctx: Context): void {
|
|
|
104
147
|
// to allow, which would bypass consent — so return our ask decision.
|
|
105
148
|
return Promise.resolve(decision)
|
|
106
149
|
}
|
|
107
|
-
if (decision.kind === 'deny') {
|
|
150
|
+
if (decision.kind === 'deny' || decision.kind === 'cancel') {
|
|
151
|
+
// deny: refuse the call. cancel: the caller already aborted this
|
|
152
|
+
// invocation — selecting the canonical cancellation result instead of
|
|
153
|
+
// letting `next()` allow it or prompting consent for a dead request.
|
|
108
154
|
return Promise.resolve(decision)
|
|
109
155
|
}
|
|
110
156
|
return next()
|
package/src/skill-prompt.ts
CHANGED
|
@@ -95,10 +95,11 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
95
95
|
log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
|
|
96
96
|
let agg = null
|
|
97
97
|
let schemaInvalid = false
|
|
98
|
-
let
|
|
98
|
+
let attempts = 0
|
|
99
99
|
do {
|
|
100
|
+
attempts += 1
|
|
100
101
|
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
101
|
-
const nudge =
|
|
102
|
+
const nudge = attempts > 1
|
|
102
103
|
? '\\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).'
|
|
103
104
|
: ''
|
|
104
105
|
const raw = await parallel(dims.map(dim => () => {
|
|
@@ -135,11 +136,15 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
135
136
|
schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
|
|
136
137
|
? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
|
|
137
138
|
: false
|
|
138
|
-
if (schemaInvalid &&
|
|
139
|
-
|
|
140
|
-
log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
139
|
+
if (schemaInvalid && attempts < 3) {
|
|
140
|
+
log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
141
141
|
}
|
|
142
|
-
} while (schemaInvalid &&
|
|
142
|
+
} while (schemaInvalid && attempts < 3)
|
|
143
|
+
if (schemaInvalid) {
|
|
144
|
+
// Bounded exit: never loop forever. Surface the failure so the run's
|
|
145
|
+
// final summary can explain WHY the round was inconclusive.
|
|
146
|
+
log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
|
|
147
|
+
}
|
|
143
148
|
// Feed the DEDUPED + already-filtered set back (not raw findings) so the known
|
|
144
149
|
// list stays bounded and reviewers never see the same issue twice.
|
|
145
150
|
if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
@@ -270,6 +275,7 @@ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? che
|
|
|
270
275
|
let converged = false
|
|
271
276
|
let abortedByValidation = false
|
|
272
277
|
let failedCommands = []
|
|
278
|
+
let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
|
|
273
279
|
const fixRecords = [] // observatory fix records collected round by round
|
|
274
280
|
|
|
275
281
|
// Read any steering nudge intended for this run's reviewers.
|
|
@@ -289,10 +295,11 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
289
295
|
)
|
|
290
296
|
let agg = null
|
|
291
297
|
let schemaInvalid = false
|
|
292
|
-
let
|
|
298
|
+
let attempts = 0
|
|
293
299
|
do {
|
|
300
|
+
attempts += 1
|
|
294
301
|
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
295
|
-
const nudge =
|
|
302
|
+
const nudge = attempts > 1
|
|
296
303
|
? '\\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).'
|
|
297
304
|
: ''
|
|
298
305
|
const raw = await parallel(dims.map(dim => () => {
|
|
@@ -328,11 +335,15 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
328
335
|
schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
|
|
329
336
|
? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
|
|
330
337
|
: false
|
|
331
|
-
if (schemaInvalid &&
|
|
332
|
-
|
|
333
|
-
log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
338
|
+
if (schemaInvalid && attempts < 3) {
|
|
339
|
+
log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
334
340
|
}
|
|
335
|
-
} while (schemaInvalid &&
|
|
341
|
+
} while (schemaInvalid && attempts < 3)
|
|
342
|
+
if (schemaInvalid) {
|
|
343
|
+
// Bounded exit: never loop forever. Surface the failure so the run's
|
|
344
|
+
// final summary can explain WHY the round was inconclusive.
|
|
345
|
+
log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
|
|
346
|
+
}
|
|
336
347
|
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
337
348
|
const atomic = findings.filter(f => f.is_atomic === true)
|
|
338
349
|
const remaining = findings.filter(f => f.is_atomic !== true)
|
|
@@ -346,10 +357,10 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
346
357
|
const byFile = {}
|
|
347
358
|
atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
|
|
348
359
|
const fixRes = await parallel(Object.keys(byFile).map(file => () => agent(
|
|
349
|
-
'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
|
|
360
|
+
'Apply the fixes for ' + JSON.stringify(file) + ' using iterate_fix. For EACH finding in this list, ' +
|
|
350
361
|
'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
|
|
351
|
-
'iterate_fix({ file:
|
|
352
|
-
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file:
|
|
362
|
+
'iterate_fix({ file: ' + JSON.stringify(file) + ', content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
|
|
363
|
+
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: ' + JSON.stringify(file) + ' }) to verify the accumulated diff and ' +
|
|
353
364
|
'read its line statistics (lines added/removed). ' +
|
|
354
365
|
'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
|
|
355
366
|
'(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
|
|
@@ -387,18 +398,33 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
387
398
|
if (seenKeys.indexOf(key) < 0) { architectural.push(f); seenKeys.push(key) }
|
|
388
399
|
}
|
|
389
400
|
|
|
390
|
-
// Validate every configured command; on ANY failure roll back this
|
|
401
|
+
// Validate every configured command; on ANY *executed* failure roll back this
|
|
402
|
+
// round's fixes. iterate_validate returns \`allowed:false\` + \`rejectReason\`
|
|
403
|
+
// when a command is NOT in validation.commands (a config gap, NOT a code
|
|
404
|
+
// failure) — those abort the run WITHOUT rolling back (the code changes are
|
|
405
|
+
// fine; the trust list just needs fixing).
|
|
391
406
|
const valRes = await agent(
|
|
392
407
|
'Read iterate.config.yaml validation.commands, then call iterate_validate({ command: <cmd> }) for EACH configured command ' +
|
|
393
|
-
'(one tool call per command). Return all results as {command, exitCode} entries.',
|
|
408
|
+
'(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).',
|
|
394
409
|
Object.assign({ label: 'validate:r' + r, phase: 'validate', schema: {
|
|
395
410
|
type: 'object', additionalProperties: false,
|
|
396
411
|
properties: {
|
|
397
|
-
results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' } }, required: ['command', 'exitCode'] } }
|
|
412
|
+
results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' }, allowed: { type: 'boolean' }, rejectReason: { type: 'string' } }, required: ['command', 'exitCode'] } }
|
|
398
413
|
},
|
|
399
414
|
required: ['results'] } }, backend)
|
|
400
415
|
)
|
|
401
|
-
|
|
416
|
+
const valResults = (valRes && Array.isArray(valRes.results)) ? valRes.results : []
|
|
417
|
+
configErrors = valResults.filter(v => v.allowed === false).map(v => v.rejectReason || (v.command + ' was rejected'))
|
|
418
|
+
failedCommands = valResults.filter(v => !(v.allowed === false) && v.exitCode !== 0).map(v => v.command)
|
|
419
|
+
if (configErrors.length > 0) {
|
|
420
|
+
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)')
|
|
421
|
+
abortedByValidation = true
|
|
422
|
+
await agent(
|
|
423
|
+
'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{configErrors:' + JSON.stringify(configErrors) + ', rolledBack:0, reason:"command not in validation.commands"}})',
|
|
424
|
+
Object.assign({ label: 'log:configError:r' + r }, backend)
|
|
425
|
+
)
|
|
426
|
+
break
|
|
427
|
+
}
|
|
402
428
|
if (failedCommands.length > 0) {
|
|
403
429
|
log('round ' + r + ' validation FAILED on: ' + failedCommands.join(', ') + ' — rolling back this round')
|
|
404
430
|
abortedByValidation = true
|
|
@@ -459,17 +485,23 @@ if (!abortedByValidation) {
|
|
|
459
485
|
}
|
|
460
486
|
// Persist the run's observatory transcript (threads, trend, fixes, checkpoint)
|
|
461
487
|
// so the client observatory panel reflects the run. Writes ONLY .iterate/transcript.json.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
488
|
+
// The checkpoint survives on disk for aborted runs (that is how resumption
|
|
489
|
+
// works), so the transcript must mirror that reality — a null checkpoint here
|
|
490
|
+
// would hide the very thing F5 is meant to resume. Reflect what is on disk.
|
|
491
|
+
const obsCheckpoint = {
|
|
492
|
+
mode: 'normal',
|
|
493
|
+
round: rounds.length,
|
|
494
|
+
maxRounds: maxRounds,
|
|
495
|
+
fixedCount: fixedCount,
|
|
496
|
+
resumeCount: effectiveResumeCount,
|
|
497
|
+
}
|
|
498
|
+
const stopReason = abortedByValidation
|
|
499
|
+
? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
|
|
500
|
+
: (converged ? 'converged' : 'max_rounds_reached')
|
|
501
|
+
await agent(
|
|
502
|
+
'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
|
+
{ label: 'transcript:capture' }
|
|
504
|
+
)
|
|
473
505
|
return {
|
|
474
506
|
mode: 'normal',
|
|
475
507
|
goal: plan.goal,
|
|
@@ -478,6 +510,8 @@ return {
|
|
|
478
510
|
converged: converged,
|
|
479
511
|
abortedByValidation: abortedByValidation,
|
|
480
512
|
failedCommands: failedCommands,
|
|
513
|
+
configErrors: configErrors,
|
|
514
|
+
stoppedReason: stopReason,
|
|
481
515
|
findingsFixed: fixedCount,
|
|
482
516
|
remainingArchitecturalCount: architectural.length,
|
|
483
517
|
remainingArchitectural: architectural,
|
|
@@ -495,10 +529,10 @@ return {
|
|
|
495
529
|
Key rules for normal mode:
|
|
496
530
|
- 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.
|
|
497
531
|
- Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
|
|
498
|
-
- **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\`.
|
|
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, mark the round inconclusive (never report it as a clean convergence).
|
|
499
533
|
- 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.
|
|
500
534
|
- **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
|
|
501
|
-
- **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).
|
|
535
|
+
- **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.
|
|
502
536
|
- **Checkpoint after every round**; clear it only when the iteration completes cleanly.
|
|
503
537
|
- Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
|
|
504
538
|
- Every round, every rollback, and the final report go to the append-only decision log.
|
package/src/tools/context.ts
CHANGED
|
@@ -336,7 +336,11 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
336
336
|
// skillDir point at arbitrary filesystem locations (e.g. /etc)
|
|
337
337
|
// whose contents would be injected wholesale into the context.
|
|
338
338
|
if (isAllowedSkillDir(args.skillDir, [projectRoot, PLUGIN_SRC_DIR])) {
|
|
339
|
-
|
|
339
|
+
// Read from the REAL (symlink-resolved) path that was actually
|
|
340
|
+
// validated — never the raw path. Validating one path and then
|
|
341
|
+
// reading through a different (swappable) symlink would leave a
|
|
342
|
+
// check-vs-read TOCTOU gap; using the realpath here closes it.
|
|
343
|
+
candidates.push(realpathSync(resolve(args.skillDir)))
|
|
340
344
|
}
|
|
341
345
|
} catch {
|
|
342
346
|
// unreadable/invalid skillDir — skip it
|
|
@@ -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
|
/**
|
|
@@ -91,9 +91,13 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
|
|
|
91
91
|
ctx.tools.register(
|
|
92
92
|
defineTool({
|
|
93
93
|
name: 'iterate_defense_events',
|
|
94
|
-
// List/counts never write;
|
|
95
|
-
// join a parallel
|
|
96
|
-
|
|
94
|
+
// List/counts never write; `record` persists an event and `clear` removes
|
|
95
|
+
// the persisted stream, so only the read shapes may join a parallel
|
|
96
|
+
// dispatch group.
|
|
97
|
+
isConcurrencySafe: (args) => {
|
|
98
|
+
const op = (args as { operation?: unknown }).operation
|
|
99
|
+
return op !== 'record' && op !== 'clear'
|
|
100
|
+
},
|
|
97
101
|
description:
|
|
98
102
|
'Query, record, or clear defense events: precondition failures, rollbacks, invariant violations, ' +
|
|
99
103
|
'and assumption falsifications. ' +
|
|
@@ -9,6 +9,7 @@ import * as fs from 'node:fs'
|
|
|
9
9
|
import * as path from 'node:path'
|
|
10
10
|
import { iterateDir } from '../paths.ts'
|
|
11
11
|
import { writeJsonAtomic } from '../atomic-fs.ts'
|
|
12
|
+
import { hashString } from './fix.ts'
|
|
12
13
|
import type { DefenseEvent, DefenseEventStream, DefenseEventType } from '../types.ts'
|
|
13
14
|
|
|
14
15
|
const DEFENSE_EVENTS_FILE = 'defense-events.json'
|
|
@@ -54,14 +55,18 @@ const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medi
|
|
|
54
55
|
* `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
|
|
55
56
|
* (list sort by timestamp, render label selection) must never crash or emit
|
|
56
57
|
* NaN for those. Returns null when the entry is not an object or has no usable
|
|
57
|
-
* `type`; otherwise fills every required field with a safe default.
|
|
58
|
+
* `type`; otherwise fills every required field with a safe default. An event
|
|
59
|
+
* missing its `id` gets a DETERMINISTIC id derived from its content and
|
|
60
|
+
* position (never a random one) so a read→write cycle cannot silently churn
|
|
61
|
+
* ids on every mutation.
|
|
58
62
|
*/
|
|
59
|
-
function normalizeEvent(raw: unknown): DefenseEvent | null {
|
|
63
|
+
function normalizeEvent(raw: unknown, index: number): DefenseEvent | null {
|
|
60
64
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
61
65
|
const e = raw as Record<string, unknown>
|
|
62
66
|
if (typeof e.type !== 'string' || !VALID_EVENT_TYPES.has(e.type as DefenseEventType)) return null
|
|
67
|
+
const derivedId = `def-${hashString(`${index}|${String(e.timestamp ?? '')}|${e.type}|${String(e.description ?? '')}|${String(e.defense ?? '')}`)}`
|
|
63
68
|
return {
|
|
64
|
-
id: typeof e.id === 'string' && e.id ? e.id :
|
|
69
|
+
id: typeof e.id === 'string' && e.id ? e.id : derivedId,
|
|
65
70
|
timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : new Date().toISOString(),
|
|
66
71
|
round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
|
|
67
72
|
type: e.type as DefenseEventType,
|
|
@@ -89,7 +94,7 @@ export function readDefenseEvents(projectRoot: string): DefenseEventStream {
|
|
|
89
94
|
const parsed = JSON.parse(content) as Partial<DefenseEventStream>
|
|
90
95
|
if (parsed && Array.isArray(parsed.events)) {
|
|
91
96
|
const events = parsed.events
|
|
92
|
-
.map(normalizeEvent)
|
|
97
|
+
.map((raw, i) => normalizeEvent(raw, i))
|
|
93
98
|
.filter((e): e is DefenseEvent => e !== null)
|
|
94
99
|
const counts = computeCounts(events)
|
|
95
100
|
return {
|
|
@@ -155,11 +160,13 @@ export function addDefenseEvent(
|
|
|
155
160
|
stream: DefenseEventStream,
|
|
156
161
|
event: Omit<DefenseEvent, 'id' | 'timestamp'>
|
|
157
162
|
): DefenseEventStream {
|
|
158
|
-
|
|
163
|
+
// Spread the caller-shaped event FIRST so the store-generated `id` and
|
|
164
|
+
// `timestamp` always win — a caller-supplied id/timestamp must never
|
|
165
|
+
// override the stream's own identity fields.
|
|
159
166
|
const newEvent: DefenseEvent = {
|
|
160
|
-
id,
|
|
161
|
-
timestamp: new Date().toISOString(),
|
|
162
167
|
...event,
|
|
168
|
+
id: `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
169
|
+
timestamp: new Date().toISOString(),
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
// Always recompute from the events array instead of mutating a possibly
|
|
@@ -72,9 +72,12 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
72
72
|
ctx.tools.register(
|
|
73
73
|
defineTool({
|
|
74
74
|
name: 'iterate_experience',
|
|
75
|
-
// List/search/get never write;
|
|
76
|
-
// join a parallel dispatch group.
|
|
77
|
-
isConcurrencySafe: (args) =>
|
|
75
|
+
// List/search/get never write; `add` upserts the bank and `remove`
|
|
76
|
+
// rewrites it → only the read shapes may join a parallel dispatch group.
|
|
77
|
+
isConcurrencySafe: (args) => {
|
|
78
|
+
const op = (args as { operation?: unknown }).operation
|
|
79
|
+
return op !== 'add' && op !== 'remove'
|
|
80
|
+
},
|
|
78
81
|
description:
|
|
79
82
|
'Query or extend the experience bank: browse/search historical fixes and patterns, ' +
|
|
80
83
|
'or record a new verified fix (operation:"add"). ' +
|