iterate-plugin 3.5.2 → 3.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/dist/session-hooks.js +48 -4
- package/dist/skill-prompt.js +63 -29
- package/dist/tools/checkpoint.js +39 -5
- package/dist/tools/config.js +11 -2
- package/dist/tools/context.js +5 -1
- package/dist/tools/defense-events.js +48 -9
- package/dist/tools/defense-store.js +63 -5
- package/dist/tools/experience-bank.js +14 -11
- package/dist/tools/experience-store.js +79 -14
- package/dist/tools/fix.js +30 -10
- package/dist/tools/history.js +5 -3
- package/dist/tools/prune.js +197 -29
- package/dist/tools/quality-gate.js +3 -0
- package/dist/tools/quality-store.js +8 -2
- package/dist/tools/review.js +17 -3
- package/dist/tools/transcript.js +72 -15
- package/dist/tools/triage.js +35 -2
- package/dist/transcript.js +30 -7
- package/lib/client.js +89 -21
- package/lib/parse.js +27 -2
- package/package.json +9 -7
- package/src/client/index.ts +53 -8
- package/src/session-hooks.ts +50 -4
- package/src/skill-prompt.ts +63 -29
- package/src/tools/checkpoint.ts +45 -6
- package/src/tools/config.ts +11 -2
- package/src/tools/context.ts +5 -1
- package/src/tools/defense-events.ts +50 -9
- package/src/tools/defense-store.ts +64 -8
- package/src/tools/experience-bank.ts +14 -11
- package/src/tools/experience-store.ts +88 -23
- package/src/tools/fix.ts +32 -12
- package/src/tools/history.ts +5 -3
- package/src/tools/prune.ts +204 -28
- package/src/tools/quality-gate.ts +4 -0
- package/src/tools/quality-store.ts +7 -2
- package/src/tools/review.ts +16 -3
- package/src/tools/transcript.ts +71 -14
- package/src/tools/triage.ts +36 -2
- package/src/transcript.ts +32 -7
- package/src/types.ts +2 -0
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)
|
|
@@ -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/checkpoint.ts
CHANGED
|
@@ -24,16 +24,41 @@ import type { DefenseEventType, IterationCheckpoint, IterationStatus, QualityGat
|
|
|
24
24
|
|
|
25
25
|
// ─── Pure helpers (exported for unit tests) ─────────────────────────────────
|
|
26
26
|
|
|
27
|
+
/** Max findings persisted in a checkpoint (a model-authored findings payload is
|
|
28
|
+
* bounded, mirroring MAX_FIX_CONTENT_CHARS for fix content). */
|
|
29
|
+
export const MAX_CHECKPOINT_FINDINGS = 1000
|
|
30
|
+
|
|
27
31
|
/** Read the current checkpoint from disk (missing/corrupt → null). */
|
|
28
32
|
export function readCheckpoint(projectRoot: string): IterationCheckpoint | null {
|
|
29
33
|
const file = checkpointPath(projectRoot)
|
|
30
34
|
if (!existsSync(file)) return null
|
|
31
35
|
try {
|
|
32
|
-
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as IterationCheckpoint
|
|
36
|
+
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial<IterationCheckpoint>
|
|
33
37
|
if (!parsed || typeof parsed !== 'object') return null
|
|
34
38
|
if (parsed.mode !== 'dry-run' && parsed.mode !== 'normal') return null
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
// Strict round gate: non-numeric (incl. NaN — `NaN <= 0` is false and
|
|
40
|
+
// `NaN < 0` also false) round values must not survive into the status.
|
|
41
|
+
const round =
|
|
42
|
+
typeof parsed.round === 'number' && Number.isFinite(parsed.round)
|
|
43
|
+
? Math.floor(parsed.round)
|
|
44
|
+
: null
|
|
45
|
+
if (round === null || round < 0) return null
|
|
46
|
+
const num = (v: unknown, fallback: number): number =>
|
|
47
|
+
typeof v === 'number' && Number.isFinite(v) ? v : fallback
|
|
48
|
+
// Normalize every field so a hand-edited/corrupt checkpoint (string
|
|
49
|
+
// maxRounds, missing fixedCount, …) can never leak a non-numeric value
|
|
50
|
+
// into the status output or crash a consumer.
|
|
51
|
+
return {
|
|
52
|
+
mode: parsed.mode,
|
|
53
|
+
round,
|
|
54
|
+
maxRounds: num(parsed.maxRounds, round),
|
|
55
|
+
fixedCount: num(parsed.fixedCount, 0),
|
|
56
|
+
architecturalCount: num(parsed.architecturalCount, 0),
|
|
57
|
+
resumeCount: num(parsed.resumeCount, 0),
|
|
58
|
+
findings: Array.isArray(parsed.findings) ? (parsed.findings as IterationCheckpoint['findings']) : [],
|
|
59
|
+
startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : new Date().toISOString(),
|
|
60
|
+
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
|
|
61
|
+
}
|
|
37
62
|
} catch {
|
|
38
63
|
return null
|
|
39
64
|
}
|
|
@@ -82,6 +107,17 @@ export function validateCheckpoint(input: {
|
|
|
82
107
|
) {
|
|
83
108
|
return 'resumeCount must be a non-negative integer'
|
|
84
109
|
}
|
|
110
|
+
// A round that claims to be past the configured cap is an inconsistent save —
|
|
111
|
+
// the status would render `Round X / Y` with X > Y, which is never legitimate
|
|
112
|
+
// for a resumed run (resume continues AT the checkpoint, it does not exceed
|
|
113
|
+
// the cap).
|
|
114
|
+
if (
|
|
115
|
+
typeof input.round === 'number' &&
|
|
116
|
+
typeof input.maxRounds === 'number' &&
|
|
117
|
+
input.round > input.maxRounds
|
|
118
|
+
) {
|
|
119
|
+
return 'round must not exceed maxRounds'
|
|
120
|
+
}
|
|
85
121
|
return null
|
|
86
122
|
}
|
|
87
123
|
|
|
@@ -118,8 +154,11 @@ export function computeStatus(input: {
|
|
|
118
154
|
}
|
|
119
155
|
|
|
120
156
|
const totalRounds = checkpoint?.maxRounds ?? currentRound
|
|
121
|
-
|
|
122
|
-
|
|
157
|
+
// Coerce the registry's per-round counts defensively: a hand-edited registry
|
|
158
|
+
// round missing fixedCount/failedCount would otherwise feed NaN into the
|
|
159
|
+
// status integer fields.
|
|
160
|
+
const registryFixed = registry.rounds.reduce((sum, r) => sum + (Number(r.fixedCount) || 0), 0)
|
|
161
|
+
const failedCount = registry.rounds.reduce((sum, r) => sum + (Number(r.failedCount) || 0), 0)
|
|
123
162
|
// When a checkpoint exists, its snapshot fields are authoritative for resume
|
|
124
163
|
// (fixedCount / architecturalCount / findings); otherwise derive from the
|
|
125
164
|
// live fix registry and decision log.
|
|
@@ -258,7 +297,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
258
297
|
fixedCount: args.fixedCount as number,
|
|
259
298
|
architecturalCount: args.architecturalCount as number,
|
|
260
299
|
resumeCount: (typeof args.resumeCount === 'number' ? args.resumeCount : 0),
|
|
261
|
-
findings: (Array.isArray(args.findings) ? args.findings : []) as unknown as IterationCheckpoint['findings'],
|
|
300
|
+
findings: (Array.isArray(args.findings) ? args.findings.slice(0, MAX_CHECKPOINT_FINDINGS) : []) as unknown as IterationCheckpoint['findings'],
|
|
262
301
|
startedAt: readCheckpoint(projectRoot)?.startedAt ?? new Date().toISOString(),
|
|
263
302
|
updatedAt: new Date().toISOString(),
|
|
264
303
|
}
|
package/src/tools/config.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
2
3
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
3
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
|
-
import { loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
5
|
+
import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
5
6
|
import {
|
|
6
7
|
applyConfigUpdates,
|
|
7
8
|
readRawConfig,
|
|
@@ -106,7 +107,15 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
106
107
|
}
|
|
107
108
|
let base: Record<string, unknown>
|
|
108
109
|
try {
|
|
109
|
-
|
|
110
|
+
// Partial updates must be mergeable on a FRESH project (no config
|
|
111
|
+
// file yet): otherwise `{max_rounds: 5}` alone fails schema
|
|
112
|
+
// validation with a misleading "missing goal". Merge against the
|
|
113
|
+
// built-in defaults when there is no file to read, so the written
|
|
114
|
+
// config captures the effective config (defaults + update) and the
|
|
115
|
+
// documented "write a partial update" contract works out of the box.
|
|
116
|
+
base = existsSync(join(projectRoot, 'iterate.config.yaml'))
|
|
117
|
+
? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
|
|
118
|
+
: (defaultConfig() as unknown as Record<string, unknown>)
|
|
110
119
|
} catch (err) {
|
|
111
120
|
return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` }
|
|
112
121
|
}
|
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
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
13
13
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
14
14
|
import { resolveProjectRootForExec, loadEffectiveConfig } from '../config-loader.ts'
|
|
15
|
-
import { readDefenseEvents, writeDefenseEvents, addDefenseEvent } from './defense-store.ts'
|
|
15
|
+
import { readDefenseEvents, writeDefenseEvents, addDefenseEvent, clearDefenseEvents } from './defense-store.ts'
|
|
16
16
|
import type { DefenseEvent, DefenseEventType } from '../types.ts'
|
|
17
17
|
|
|
18
18
|
const DEFAULT_LIMIT = 50
|
|
@@ -91,20 +91,26 @@ 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
|
-
'Query or
|
|
102
|
+
'Query, record, or clear defense events: precondition failures, rollbacks, invariant violations, ' +
|
|
99
103
|
'and assumption falsifications. ' +
|
|
100
104
|
'List/counts return events with descriptions, outcomes, and summary counts; ' +
|
|
101
|
-
'"record" persists a new event to .iterate/defense-events.json
|
|
105
|
+
'"record" persists a new event to .iterate/defense-events.json; ' +
|
|
106
|
+
'"clear" resets the persisted event stream (use it to start a fresh iteration without stale ' +
|
|
107
|
+
'defense data). ' +
|
|
102
108
|
'Use it to review defensive actions taken, or to log one when a defense fires.',
|
|
103
109
|
parameters: {
|
|
104
110
|
operation: {
|
|
105
111
|
type: 'string',
|
|
106
|
-
description: 'Operation: list (browse all), counts (summary by type), record (log a new event). Default: list.',
|
|
107
|
-
enum: ['list', 'counts', 'record'],
|
|
112
|
+
description: 'Operation: list (browse all), counts (summary by type), record (log a new event), clear (reset the stream). Default: list.',
|
|
113
|
+
enum: ['list', 'counts', 'record', 'clear'],
|
|
108
114
|
},
|
|
109
115
|
type: {
|
|
110
116
|
type: 'string',
|
|
@@ -162,6 +168,7 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
|
|
|
162
168
|
kind: { type: 'string' },
|
|
163
169
|
operation: { type: 'string' },
|
|
164
170
|
count: { type: 'integer' },
|
|
171
|
+
counted: { type: 'boolean', description: 'clear only: true when a persisted stream was removed.' },
|
|
165
172
|
events: { type: 'json' },
|
|
166
173
|
counts: { type: 'json' },
|
|
167
174
|
event: { type: 'json' },
|
|
@@ -174,6 +181,17 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
|
|
|
174
181
|
if (!value.ok) return [{ type: 'text', text: `defense events query failed: ${value.error}` }]
|
|
175
182
|
const language: 'zh' | 'en' = value.language === 'zh' ? 'zh' : 'en'
|
|
176
183
|
|
|
184
|
+
if (value.operation === 'clear') {
|
|
185
|
+
// Fresh stream: counts are all zero and the event list is empty.
|
|
186
|
+
return [{ type: 'text', text: [
|
|
187
|
+
value.counted === true
|
|
188
|
+
? 'Defense event stream cleared — a fresh iteration starts with an empty stream.'
|
|
189
|
+
: 'No persisted defense event stream to clear.',
|
|
190
|
+
'Defense Event Summary:',
|
|
191
|
+
...EVENT_TYPES.map((type) => ` ${labelFor(type, language)}: 0`),
|
|
192
|
+
].join('\n') }]
|
|
193
|
+
}
|
|
194
|
+
|
|
177
195
|
if (value.operation === 'counts' && value.counts) {
|
|
178
196
|
const counts = value.counts as Record<DefenseEventType, number>
|
|
179
197
|
const lines = [
|
|
@@ -220,12 +238,35 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
|
|
|
220
238
|
if (!resolved.ok) return { ok: false, kind: 'defense_events', error: resolved.reason }
|
|
221
239
|
const projectRoot = resolved.root
|
|
222
240
|
|
|
223
|
-
|
|
241
|
+
// A hand-edited `language` value outside zh/en must not leak out of the
|
|
242
|
+
// declared 'zh' | 'en' enum (labelFor would return undefined for it) —
|
|
243
|
+
// normalize any non-'zh' config value to 'en'.
|
|
244
|
+
const rawConfigLang = loadEffectiveConfig(projectRoot).config.language
|
|
245
|
+
const configLang: 'zh' | 'en' = rawConfigLang === 'zh' ? 'zh' : 'en'
|
|
224
246
|
const language: 'zh' | 'en' = args.language === 'zh' || args.language === 'en' ? args.language : configLang
|
|
225
247
|
|
|
226
248
|
const operation = typeof args.operation === 'string' ? args.operation : 'list'
|
|
227
249
|
const limit = clampLimit(args.limit as number | undefined)
|
|
228
250
|
|
|
251
|
+
if (operation === 'clear') {
|
|
252
|
+
// Reset the persisted event stream so a fresh iteration does not carry
|
|
253
|
+
// stale defensive data (mirrors iterate_quality_gate clear / clearQualityGate).
|
|
254
|
+
const result = clearDefenseEvents(projectRoot)
|
|
255
|
+
if (!result.ok) {
|
|
256
|
+
return { ok: false, kind: 'defense_events', operation: 'clear', error: result.error }
|
|
257
|
+
}
|
|
258
|
+
const empty = readDefenseEvents(projectRoot)
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
kind: 'defense_events',
|
|
262
|
+
operation: 'clear',
|
|
263
|
+
counted: result.existed,
|
|
264
|
+
language,
|
|
265
|
+
counts: empty.counts as unknown as JsonValue,
|
|
266
|
+
events: empty.events as unknown as JsonValue,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
229
270
|
if (operation === 'record') {
|
|
230
271
|
const errors = validateRecordInput(args)
|
|
231
272
|
if (errors.length > 0) {
|
|
@@ -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'
|
|
@@ -46,11 +47,45 @@ function emptyStream(): DefenseEventStream {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/** Valid severity values (kept in sync with DefenseEvent). */
|
|
51
|
+
const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Normalize one persisted event. Hand-edited files can carry events missing
|
|
55
|
+
* `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
|
|
56
|
+
* (list sort by timestamp, render label selection) must never crash or emit
|
|
57
|
+
* NaN for those. Returns null when the entry is not an object or has no usable
|
|
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.
|
|
62
|
+
*/
|
|
63
|
+
function normalizeEvent(raw: unknown, index: number): DefenseEvent | null {
|
|
64
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
65
|
+
const e = raw as Record<string, unknown>
|
|
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 ?? '')}`)}`
|
|
68
|
+
return {
|
|
69
|
+
id: typeof e.id === 'string' && e.id ? e.id : derivedId,
|
|
70
|
+
timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : new Date().toISOString(),
|
|
71
|
+
round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
|
|
72
|
+
type: e.type as DefenseEventType,
|
|
73
|
+
description: typeof e.description === 'string' ? e.description : '',
|
|
74
|
+
defense: typeof e.defense === 'string' ? e.defense : '',
|
|
75
|
+
outcome: typeof e.outcome === 'string' ? e.outcome : '',
|
|
76
|
+
...(typeof e.file === 'string' && e.file.length > 0 ? { file: e.file } : {}),
|
|
77
|
+
...(typeof e.line === 'number' && Number.isFinite(e.line) && e.line >= 0 ? { line: e.line } : {}),
|
|
78
|
+
severity: VALID_SEVERITIES.has(String(e.severity)) ? (e.severity as DefenseEvent['severity']) : 'low',
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
49
82
|
/**
|
|
50
83
|
* Read the defense events stream from disk.
|
|
51
84
|
* Normalizes the persisted stream so a hand-edited / partial file can never
|
|
52
85
|
* produce NaN counts: `counts` is recomputed from the events when missing or
|
|
53
|
-
* malformed,
|
|
86
|
+
* malformed, every type key is guaranteed present, and every surviving event is
|
|
87
|
+
* shape-normalized so consumers (timestamp sort, render label selection) cannot
|
|
88
|
+
* throw on missing fields.
|
|
54
89
|
*/
|
|
55
90
|
export function readDefenseEvents(projectRoot: string): DefenseEventStream {
|
|
56
91
|
const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE)
|
|
@@ -58,10 +93,9 @@ export function readDefenseEvents(projectRoot: string): DefenseEventStream {
|
|
|
58
93
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
59
94
|
const parsed = JSON.parse(content) as Partial<DefenseEventStream>
|
|
60
95
|
if (parsed && Array.isArray(parsed.events)) {
|
|
61
|
-
const events = parsed.events
|
|
62
|
-
(
|
|
63
|
-
|
|
64
|
-
)
|
|
96
|
+
const events = parsed.events
|
|
97
|
+
.map((raw, i) => normalizeEvent(raw, i))
|
|
98
|
+
.filter((e): e is DefenseEvent => e !== null)
|
|
65
99
|
const counts = computeCounts(events)
|
|
66
100
|
return {
|
|
67
101
|
events,
|
|
@@ -101,16 +135,38 @@ export function writeDefenseEvents(
|
|
|
101
135
|
return { ok: true }
|
|
102
136
|
}
|
|
103
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Clear the persisted defense-event stream (`.iterate/defense-events.json`).
|
|
140
|
+
* Mirrors `clearQualityGate` in quality-store.ts: a stale event stream from a
|
|
141
|
+
* previous iteration must be resettable before a fresh run. Returns whether a
|
|
142
|
+
* file existed and was removed, or a structured error when removal fails.
|
|
143
|
+
*/
|
|
144
|
+
export function clearDefenseEvents(
|
|
145
|
+
projectRoot: string,
|
|
146
|
+
): { ok: true; existed: boolean } | { ok: false; error: string } {
|
|
147
|
+
const filePath = path.join(iterateDir(projectRoot), DEFENSE_EVENTS_FILE)
|
|
148
|
+
const existed = fs.existsSync(filePath)
|
|
149
|
+
if (!existed) return { ok: true, existed: false }
|
|
150
|
+
try {
|
|
151
|
+
fs.rmSync(filePath, { force: true })
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return { ok: false, error: `unable to remove ${filePath}: ${String(err)}` }
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, existed: true }
|
|
156
|
+
}
|
|
157
|
+
|
|
104
158
|
/** Add a defense event to the stream. */
|
|
105
159
|
export function addDefenseEvent(
|
|
106
160
|
stream: DefenseEventStream,
|
|
107
161
|
event: Omit<DefenseEvent, 'id' | 'timestamp'>
|
|
108
162
|
): DefenseEventStream {
|
|
109
|
-
|
|
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.
|
|
110
166
|
const newEvent: DefenseEvent = {
|
|
111
|
-
id,
|
|
112
|
-
timestamp: new Date().toISOString(),
|
|
113
167
|
...event,
|
|
168
|
+
id: `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
169
|
+
timestamp: new Date().toISOString(),
|
|
114
170
|
}
|
|
115
171
|
|
|
116
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"). ' +
|
|
@@ -151,8 +154,8 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
151
154
|
`Dimension: ${entry.dimension}`,
|
|
152
155
|
`Description: ${entry.description}`,
|
|
153
156
|
`Fix: ${entry.verifiedFix}`,
|
|
154
|
-
`Files: ${entry.files.join(', ')}`,
|
|
155
|
-
`Tags: ${entry.tags.join(', ')}`,
|
|
157
|
+
`Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
|
|
158
|
+
`Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
|
|
156
159
|
].join('\n') }]
|
|
157
160
|
}
|
|
158
161
|
if (value.operation === 'remove') {
|
|
@@ -165,9 +168,9 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
165
168
|
`Pattern: ${entry.pattern}`,
|
|
166
169
|
`Description: ${entry.description}`,
|
|
167
170
|
`Fix: ${entry.verifiedFix}`,
|
|
168
|
-
`Files: ${entry.files.join(', ')}`,
|
|
171
|
+
`Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
|
|
169
172
|
`Hits: ${entry.hitCount}`,
|
|
170
|
-
`Tags: ${entry.tags.join(', ')}`,
|
|
173
|
+
`Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
|
|
171
174
|
].join('\n') }]
|
|
172
175
|
}
|
|
173
176
|
const entries = (value.entries as unknown as ExperienceEntry[] | undefined) ?? []
|
|
@@ -264,7 +267,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
264
267
|
operation: 'get',
|
|
265
268
|
count: 1,
|
|
266
269
|
entry: entry as unknown as JsonValue,
|
|
267
|
-
totalHits: bank.totalHits,
|
|
270
|
+
totalHits: bank.totalHits ?? 0,
|
|
268
271
|
}
|
|
269
272
|
}
|
|
270
273
|
|
|
@@ -280,7 +283,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
280
283
|
operation: 'search',
|
|
281
284
|
count: entries.length,
|
|
282
285
|
entries: entries as unknown as JsonValue,
|
|
283
|
-
totalHits: bank.totalHits,
|
|
286
|
+
totalHits: bank.totalHits ?? 0,
|
|
284
287
|
}
|
|
285
288
|
}
|
|
286
289
|
|
|
@@ -290,7 +293,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
290
293
|
entries = entries.filter((e) => e.dimension === args.dimension)
|
|
291
294
|
}
|
|
292
295
|
if (Array.isArray(args.tags) && args.tags.length > 0) {
|
|
293
|
-
entries = entries.filter((e) => args.tags!.every((t: string) => e.tags.includes(t)))
|
|
296
|
+
entries = entries.filter((e) => args.tags!.every((t: string) => Array.isArray(e.tags) && e.tags.includes(t)))
|
|
294
297
|
}
|
|
295
298
|
|
|
296
299
|
return {
|
|
@@ -299,7 +302,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
299
302
|
operation: 'list',
|
|
300
303
|
count: Math.min(entries.length, limit),
|
|
301
304
|
entries: entries.slice(0, limit) as unknown as JsonValue,
|
|
302
|
-
totalHits: bank.totalHits,
|
|
305
|
+
totalHits: bank.totalHits ?? 0,
|
|
303
306
|
}
|
|
304
307
|
},
|
|
305
308
|
}),
|