iterate-plugin 2.10.0 → 2.12.0
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 +42 -2
- package/README.zh-CN.md +40 -2
- package/dist/approval-gate.js +92 -0
- package/dist/config-loader.js +18 -3
- package/dist/config-write.js +7 -4
- package/dist/evidence.js +67 -1
- package/dist/git-scope.js +35 -6
- package/dist/index.js +15 -5
- package/dist/live.js +155 -0
- package/dist/meta-review.js +19 -5
- package/dist/method-scope.js +5 -1
- package/dist/paths.js +4 -0
- package/dist/review-scope.js +12 -8
- package/dist/review.js +76 -24
- package/dist/session-hooks.js +89 -0
- package/dist/skill-prompt.js +101 -19
- package/dist/tools/checkpoint.js +10 -3
- package/dist/tools/context.js +16 -4
- package/dist/tools/decision-log.js +29 -9
- package/dist/tools/fix.js +120 -3
- package/dist/tools/prune.js +16 -9
- package/dist/tools/review.js +4 -1
- package/dist/tools/transcript.js +324 -0
- package/dist/tools/triage.js +9 -6
- package/dist/tools/validate.js +5 -2
- package/dist/transcript.js +421 -0
- package/lib/client.js +966 -80
- package/lib/parse.js +302 -17
- package/package.json +1 -1
- package/src/approval-gate.ts +119 -0
- package/src/client/index.ts +807 -62
- package/src/config-loader.ts +16 -2
- package/src/config-write.ts +6 -4
- package/src/evidence.ts +69 -1
- package/src/git-scope.ts +34 -6
- package/src/index.ts +17 -6
- package/src/live.ts +185 -0
- package/src/meta-review.ts +24 -10
- package/src/method-scope.ts +5 -1
- package/src/paths.ts +5 -0
- package/src/review-scope.ts +11 -7
- package/src/review.ts +82 -25
- package/src/session-hooks.ts +90 -0
- package/src/skill-prompt.ts +101 -19
- package/src/tools/checkpoint.ts +10 -3
- package/src/tools/context.ts +14 -3
- package/src/tools/decision-log.ts +27 -10
- package/src/tools/fix.ts +114 -3
- package/src/tools/prune.ts +14 -11
- package/src/tools/review.ts +5 -2
- package/src/tools/transcript.ts +334 -0
- package/src/tools/triage.ts +9 -6
- package/src/tools/validate.ts +5 -2
- package/src/transcript.ts +475 -0
- package/src/types.ts +129 -0
package/dist/skill-prompt.js
CHANGED
|
@@ -22,6 +22,7 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
22
22
|
- \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint left on disk means the previous run was interrupted and can be resumed)
|
|
23
23
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
24
24
|
- \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
|
|
25
|
+
- \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
|
|
25
26
|
|
|
26
27
|
### When to use
|
|
27
28
|
When the user asks to review or iterate on the project (e.g. "review this project", "iterate on error handling", "check the codebase for issues", "dry-run review", "反复审查"), run an iterate **workflow** by calling the \`workflow\` tool.
|
|
@@ -77,6 +78,14 @@ const knownIntentional = (plan.knownIntentional || []) // config personalizati
|
|
|
77
78
|
let known = [] // cumulative DEDUPED findings fed back to reviewers
|
|
78
79
|
const rounds = [] // raw per-round findings
|
|
79
80
|
|
|
81
|
+
phase('transcript')
|
|
82
|
+
// Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
|
|
83
|
+
const transRead = await agent(
|
|
84
|
+
'Call iterate_transcript({operation:"read"}) and return {nudge:<transcript.nudge ? transcript.nudge.text : null>}.',
|
|
85
|
+
Object.assign({ label: 'transcript:read' }, backend)
|
|
86
|
+
)
|
|
87
|
+
const steering = transRead && typeof transRead.nudge === 'string' && transRead.nudge ? transRead.nudge : null
|
|
88
|
+
|
|
80
89
|
phase('review')
|
|
81
90
|
for (let r = 1; r <= maxRounds; r++) {
|
|
82
91
|
log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
|
|
@@ -88,14 +97,29 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
88
97
|
const nudge = retries > 0
|
|
89
98
|
? '\\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).'
|
|
90
99
|
: ''
|
|
91
|
-
const raw = await parallel(dims.map(dim => () =>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
const raw = await parallel(dims.map(dim => () => {
|
|
101
|
+
// Pass the plan's full per-dimension reviewerPrompt (goal, COVERAGE RULE
|
|
102
|
+
// with the assigned file inventory, EVIDENCE RULE, output language) and
|
|
103
|
+
// append the round-specific context — the reviewers must receive the
|
|
104
|
+
// file inventory or the coverage machinery has nothing to enforce.
|
|
105
|
+
const meta = plan.dimensions.find(x => x.id === dim)
|
|
106
|
+
const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
|
|
107
|
+
? meta.reviewerPrompt
|
|
108
|
+
: 'Review dimension "' + dim + '".'
|
|
109
|
+
const extra =
|
|
110
|
+
(steering ? '\\n STEERING — read this first: ' + steering : '') +
|
|
111
|
+
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
112
|
+
'\\n Already-known findings (do NOT re-report): ' +
|
|
113
|
+
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
114
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
115
|
+
}))
|
|
116
|
+
const thisRound = {
|
|
117
|
+
round: r,
|
|
118
|
+
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
119
|
+
// readFiles are threaded through so the aggregate/meta-review coverage
|
|
120
|
+
// gate can compare self-reported reads against the assigned inventory.
|
|
121
|
+
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
122
|
+
}
|
|
99
123
|
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
100
124
|
// Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
|
|
101
125
|
agg = await agent(
|
|
@@ -142,6 +166,13 @@ const metaRes = await agent(
|
|
|
142
166
|
const finalReport = metaRes && metaRes.finalReport ? metaRes.finalReport : null
|
|
143
167
|
const metaAudit = finalReport && finalReport.metaReview ? finalReport.metaReview : null
|
|
144
168
|
|
|
169
|
+
// Persist the run's observatory transcript (reviewer threads, trend, findings)
|
|
170
|
+
// so the client observatory panel reflects this review. Writes ONLY .iterate/transcript.json.
|
|
171
|
+
await agent(
|
|
172
|
+
'Call iterate_transcript({operation:"capture", mode:"dry-run", goal:' + JSON.stringify(report.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + report.convergence.totalRounds + ', findingsByRound:' + JSON.stringify(report.convergence.findingsByRound || []) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
|
|
173
|
+
Object.assign({ label: 'transcript:capture' }, backend)
|
|
174
|
+
)
|
|
175
|
+
|
|
145
176
|
return {
|
|
146
177
|
mode: 'dry-run',
|
|
147
178
|
goal: report.goal,
|
|
@@ -166,7 +197,7 @@ Key rules for dry-run:
|
|
|
166
197
|
- Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
|
|
167
198
|
- The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
|
|
168
199
|
- **Meta-review**: after building the report, audit it with \`iterate_review({operation:"meta-review"})\` for internal consistency (counts, severity buckets, dimension sums, sort order, convergence math). The meta-review ALSO runs the hard code-evidence gate (default on): every finding's file/line is validated against real files on disk, so any fabricated location surfaces as a critical \`EVIDENCE_VIOLATION\` and flips the verdict to \`needs_revision\`. The \`finalReport.verdict\` is \`approved\` only when the report passes every check AND every finding anchors to real, read code; otherwise \`needs_revision\`. Surface the final report and its verdict as the closing deliverable.
|
|
169
|
-
- Only a single \`report\` entry may be appended to the decision log; nothing else is written.
|
|
200
|
+
- Only a single \`report\` entry may be appended to the decision log; nothing else is written to source files. The final \`iterate_transcript capture\` writes ONLY the observatory file (\`.iterate/transcript.json\`) so the client panel reflects the run — it is not a source-code write.
|
|
170
201
|
|
|
171
202
|
### Normal-mode workflow (autonomous closed loop)
|
|
172
203
|
Set \`args.mode = "normal"\`. Loop: resume → plan → parallel review ×N → atomic fixes via \`iterate_fix\` → validate → rollback on failure → checkpoint → loop → auto-stop when zero findings remain.
|
|
@@ -224,10 +255,23 @@ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? che
|
|
|
224
255
|
let converged = false
|
|
225
256
|
let abortedByValidation = false
|
|
226
257
|
let failedCommands = []
|
|
258
|
+
const fixRecords = [] // observatory fix records collected round by round
|
|
259
|
+
|
|
260
|
+
// Read any steering nudge intended for this run's reviewers.
|
|
261
|
+
const transRead = await agent(
|
|
262
|
+
'Call iterate_transcript({operation:"read"}) and return {nudge:<transcript.nudge ? transcript.nudge.text : null>}.',
|
|
263
|
+
Object.assign({ label: 'transcript:read' }, backend)
|
|
264
|
+
)
|
|
265
|
+
const steering = transRead && typeof transRead.nudge === 'string' && transRead.nudge ? transRead.nudge : null
|
|
227
266
|
|
|
228
267
|
phase('loop')
|
|
229
268
|
for (let r = startRound; r <= maxRounds; r++) {
|
|
230
269
|
log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
|
|
270
|
+
// Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
|
|
271
|
+
await agent(
|
|
272
|
+
'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
|
|
273
|
+
Object.assign({ label: 'log:start:r' + r }, backend)
|
|
274
|
+
)
|
|
231
275
|
let agg = null
|
|
232
276
|
let schemaInvalid = false
|
|
233
277
|
let retries = 0
|
|
@@ -236,13 +280,25 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
236
280
|
const nudge = retries > 0
|
|
237
281
|
? '\\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).'
|
|
238
282
|
: ''
|
|
239
|
-
const raw = await parallel(dims.map(dim => () =>
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
283
|
+
const raw = await parallel(dims.map(dim => () => {
|
|
284
|
+
// Pass the plan's full per-dimension reviewerPrompt (COVERAGE RULE with
|
|
285
|
+
// the assigned file inventory, EVIDENCE RULE, output language) plus the
|
|
286
|
+
// round-specific context.
|
|
287
|
+
const meta = plan.dimensions.find(x => x.id === dim)
|
|
288
|
+
const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
|
|
289
|
+
? meta.reviewerPrompt
|
|
290
|
+
: 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed).'
|
|
291
|
+
const extra =
|
|
292
|
+
(steering ? '\\n STEERING — read this first: ' + steering : '') +
|
|
293
|
+
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
294
|
+
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
295
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
296
|
+
}))
|
|
297
|
+
const thisRound = {
|
|
298
|
+
round: r,
|
|
299
|
+
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
300
|
+
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
301
|
+
}
|
|
246
302
|
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
247
303
|
|
|
248
304
|
// Deterministic dedupe / known_intentional filter / severity sort for this round.
|
|
@@ -278,12 +334,14 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
278
334
|
'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
|
|
279
335
|
'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
|
|
280
336
|
'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
|
|
281
|
-
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff
|
|
282
|
-
'
|
|
337
|
+
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff and ' +
|
|
338
|
+
'read its line statistics (lines added/removed). ' +
|
|
339
|
+
'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
|
|
340
|
+
'(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
|
|
283
341
|
Object.assign({ label: 'fix:' + file, phase: 'fix', schema: {
|
|
284
342
|
type: 'object', additionalProperties: false,
|
|
285
343
|
properties: {
|
|
286
|
-
fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
|
|
344
|
+
fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' }, file: { type: 'string' }, linesAdded: { type: 'integer' }, linesRemoved: { type: 'integer' } }, required: ['id', 'ok'] } }
|
|
287
345
|
},
|
|
288
346
|
required: ['fixes'] } }, backend)
|
|
289
347
|
)))
|
|
@@ -291,6 +349,17 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
291
349
|
if (res && Array.isArray(res.fixes)) {
|
|
292
350
|
for (const fx of res.fixes) {
|
|
293
351
|
if (fx && fx.ok === true) { fixedCount += 1; roundFixIds.push(fx.id) }
|
|
352
|
+
// Collect fix records for the observatory transcript (defensive defaults).
|
|
353
|
+
const fixFileKeys = Object.keys(byFile)
|
|
354
|
+
fixRecords.push({
|
|
355
|
+
id: fx && typeof fx.id === 'string' ? fx.id : '',
|
|
356
|
+
file: fx && typeof fx.file === 'string' ? fx.file : (fixFileKeys.length === 1 ? fixFileKeys[0] : ''),
|
|
357
|
+
round: r,
|
|
358
|
+
summary: '',
|
|
359
|
+
linesAdded: fx && typeof fx.linesAdded === 'number' ? fx.linesAdded : 0,
|
|
360
|
+
linesRemoved: fx && typeof fx.linesRemoved === 'number' ? fx.linesRemoved : 0,
|
|
361
|
+
success: !!(fx && fx.ok === true),
|
|
362
|
+
})
|
|
294
363
|
}
|
|
295
364
|
}
|
|
296
365
|
}
|
|
@@ -373,6 +442,19 @@ if (!abortedByValidation) {
|
|
|
373
442
|
{ label: 'checkpoint:clear' }
|
|
374
443
|
)
|
|
375
444
|
}
|
|
445
|
+
// Persist the run's observatory transcript (threads, trend, fixes, checkpoint)
|
|
446
|
+
// so the client observatory panel reflects the run. Writes ONLY .iterate/transcript.json.
|
|
447
|
+
const obsCheckpoint = abortedByValidation ? null : {
|
|
448
|
+
mode: 'normal',
|
|
449
|
+
round: rounds.length,
|
|
450
|
+
maxRounds: maxRounds,
|
|
451
|
+
fixedCount: fixedCount,
|
|
452
|
+
resumeCount: effectiveResumeCount,
|
|
453
|
+
}
|
|
454
|
+
await agent(
|
|
455
|
+
'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)) + ', 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"}.',
|
|
456
|
+
{ label: 'transcript:capture' }
|
|
457
|
+
)
|
|
376
458
|
return {
|
|
377
459
|
mode: 'normal',
|
|
378
460
|
goal: plan.goal,
|
package/dist/tools/checkpoint.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Checkpoint layout: `.iterate/checkpoint.json`.
|
|
10
10
|
*/
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
13
13
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
14
14
|
import { checkpointPath, iterateDir } from "../paths.js";
|
|
@@ -89,7 +89,9 @@ export function computeStatus(input) {
|
|
|
89
89
|
totalRounds,
|
|
90
90
|
fixedCount,
|
|
91
91
|
architecturalCount,
|
|
92
|
-
|
|
92
|
+
// A checkpoint may predate the `findings` field (or be hand-edited) — a
|
|
93
|
+
// missing findings must degrade to 0, never throw.
|
|
94
|
+
findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
|
|
93
95
|
totalDecisionLogEntries: entries.length,
|
|
94
96
|
hasCheckpoint: checkpoint !== null,
|
|
95
97
|
// A checkpoint left on disk means the previous run was interrupted before
|
|
@@ -187,7 +189,12 @@ export function registerCheckpointTool(ctx) {
|
|
|
187
189
|
};
|
|
188
190
|
try {
|
|
189
191
|
mkdirSync(iterateDir(projectRoot), { recursive: true });
|
|
190
|
-
|
|
192
|
+
// Atomic write (temp + rename): a crash mid-write must not corrupt
|
|
193
|
+
// the checkpoint and silently lose the interruption state.
|
|
194
|
+
const cpPath = checkpointPath(projectRoot);
|
|
195
|
+
const tmpPath = `${cpPath}.tmp-${Date.now()}`;
|
|
196
|
+
writeFileSync(tmpPath, JSON.stringify(checkpoint, null, 2), 'utf-8');
|
|
197
|
+
renameSync(tmpPath, cpPath);
|
|
191
198
|
}
|
|
192
199
|
catch (err) {
|
|
193
200
|
return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` };
|
package/dist/tools/context.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { join, dirname, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
@@ -241,7 +241,8 @@ export function registerContextTool(ctx) {
|
|
|
241
241
|
return { found: false, error: resolved.reason, searched: [] };
|
|
242
242
|
}
|
|
243
243
|
const projectRoot = resolved.root;
|
|
244
|
-
|
|
244
|
+
// Guard: `files` must be a comma-separated string (model-controlled).
|
|
245
|
+
const requested = (typeof args.files === 'string' ? args.files : '')
|
|
245
246
|
.split(',')
|
|
246
247
|
.map((s) => s.trim().toLowerCase())
|
|
247
248
|
.filter(Boolean);
|
|
@@ -262,8 +263,19 @@ export function registerContextTool(ctx) {
|
|
|
262
263
|
// are all supported.
|
|
263
264
|
const skillRoot = findSkillRoot(PLUGIN_SRC_DIR);
|
|
264
265
|
const candidates = [];
|
|
265
|
-
|
|
266
|
-
|
|
266
|
+
// skillDir is a model-controlled path; only honor it when it is an
|
|
267
|
+
// existing directory (resolve it first) — otherwise fall through to
|
|
268
|
+
// the auto-detected root / project root.
|
|
269
|
+
if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
|
|
270
|
+
try {
|
|
271
|
+
const dir = resolve(args.skillDir);
|
|
272
|
+
if (existsSync(dir) && statSync(dir).isDirectory())
|
|
273
|
+
candidates.push(dir);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// unreadable/invalid skillDir — skip it
|
|
277
|
+
}
|
|
278
|
+
}
|
|
267
279
|
if (skillRoot)
|
|
268
280
|
candidates.push(skillRoot);
|
|
269
281
|
candidates.push(projectRoot);
|
|
@@ -45,12 +45,20 @@ function logPath(projectRoot) {
|
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
47
|
* Append one entry to the decision log (JSONL format).
|
|
48
|
-
* Returns the entry count after appending.
|
|
48
|
+
* Returns the entry count after appending. Never throws — a disk failure is
|
|
49
|
+
* surfaced through `error` so callers (fix/prune) can report the audit-trail
|
|
50
|
+
* miss without failing the mutation they already performed.
|
|
49
51
|
*/
|
|
50
52
|
export function appendDecisionEntry(projectRoot, entry) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
let filePath;
|
|
54
|
+
try {
|
|
55
|
+
filePath = logPath(projectRoot);
|
|
56
|
+
const line = JSON.stringify(entry) + '\n';
|
|
57
|
+
appendFileSync(filePath, line, 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return { count: -1, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` };
|
|
61
|
+
}
|
|
54
62
|
// Count entries
|
|
55
63
|
let count = 0;
|
|
56
64
|
try {
|
|
@@ -64,21 +72,33 @@ export function appendDecisionEntry(projectRoot, entry) {
|
|
|
64
72
|
}
|
|
65
73
|
/**
|
|
66
74
|
* Read all entries from the decision log.
|
|
75
|
+
* A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
|
|
76
|
+
* one bad line must never empty the whole history for every reader.
|
|
67
77
|
*/
|
|
68
78
|
export function readDecisionEntries(projectRoot) {
|
|
69
79
|
const filePath = join(projectRoot, LOG_DIR, LOG_FILE);
|
|
70
80
|
if (!existsSync(filePath))
|
|
71
81
|
return [];
|
|
82
|
+
let content;
|
|
72
83
|
try {
|
|
73
|
-
|
|
74
|
-
return content
|
|
75
|
-
.split('\n')
|
|
76
|
-
.filter((l) => l.trim().length > 0)
|
|
77
|
-
.map((l) => JSON.parse(l));
|
|
84
|
+
content = readFileSync(filePath, 'utf-8');
|
|
78
85
|
}
|
|
79
86
|
catch {
|
|
80
87
|
return [];
|
|
81
88
|
}
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const line of content.split('\n')) {
|
|
91
|
+
const trimmed = line.trim();
|
|
92
|
+
if (trimmed.length === 0)
|
|
93
|
+
continue;
|
|
94
|
+
try {
|
|
95
|
+
out.push(JSON.parse(trimmed));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// skip the corrupt line, keep the rest
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
82
102
|
}
|
|
83
103
|
/**
|
|
84
104
|
* Register the `iterate_decision_log` tool.
|
package/dist/tools/fix.js
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* - Backups are written before any write, so a failure never destroys data.
|
|
17
17
|
* - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
|
|
18
18
|
*/
|
|
19
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
-
import { join } from 'node:path';
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join, sep } from 'node:path';
|
|
21
21
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
22
22
|
import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
|
|
23
23
|
import { countTouchedMethods } from "../method-scope.js";
|
|
@@ -117,6 +117,9 @@ export function readRegistry(projectRoot) {
|
|
|
117
117
|
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
118
118
|
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds))
|
|
119
119
|
return emptyRegistry();
|
|
120
|
+
// Defensive: a hand-edited or partially-written registry may contain a
|
|
121
|
+
// round without a `records` array — normalize instead of crashing readers.
|
|
122
|
+
parsed.rounds = parsed.rounds.filter((r) => r && typeof r === 'object' && Array.isArray(r.records));
|
|
120
123
|
return parsed;
|
|
121
124
|
}
|
|
122
125
|
catch {
|
|
@@ -191,9 +194,64 @@ export function resolveProjectFile(projectRoot, file) {
|
|
|
191
194
|
if (resolved === projectRoot || !resolved.startsWith(projectRoot + '/') && !resolved.startsWith(projectRoot + '\\')) {
|
|
192
195
|
return { ok: false, reason: 'file resolves outside the project root' };
|
|
193
196
|
}
|
|
197
|
+
// Symlink containment: the lexical prefix check above does not resolve
|
|
198
|
+
// symlinks. If the target exists, verify its REAL path stays inside the REAL
|
|
199
|
+
// project root so a symlinked directory/file inside the repo can never route
|
|
200
|
+
// a fix (write/rollback/diff) outside the project.
|
|
201
|
+
if (existsSync(resolved)) {
|
|
202
|
+
let rootReal;
|
|
203
|
+
let real;
|
|
204
|
+
try {
|
|
205
|
+
rootReal = realpathSync(projectRoot);
|
|
206
|
+
real = realpathSync(resolved);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return { ok: false, reason: 'failed to resolve real path for containment check' };
|
|
210
|
+
}
|
|
211
|
+
const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep;
|
|
212
|
+
if (real !== rootReal && !real.startsWith(rootPrefix)) {
|
|
213
|
+
return { ok: false, reason: 'file resolves outside the project root (symlink escape)' };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
194
216
|
return { ok: true, resolved };
|
|
195
217
|
}
|
|
196
218
|
// ─── Shared execute helpers ──────────────────────────────────────────────────
|
|
219
|
+
/**
|
|
220
|
+
* Minimal glob matcher for personalization.protected_paths.
|
|
221
|
+
* Supports `*` (any run of chars within one segment) and `**` (any chars,
|
|
222
|
+
* including separators). All other characters are literal. Pure, unit-testable.
|
|
223
|
+
*/
|
|
224
|
+
export function globMatch(path, pattern) {
|
|
225
|
+
if (typeof path !== 'string' || typeof pattern !== 'string')
|
|
226
|
+
return false;
|
|
227
|
+
// Escape regex specials except our two wildcards.
|
|
228
|
+
let re = '';
|
|
229
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
230
|
+
const ch = pattern[i];
|
|
231
|
+
if (ch === '*') {
|
|
232
|
+
const isDouble = pattern[i + 1] === '*';
|
|
233
|
+
if (isDouble) {
|
|
234
|
+
re += '[\\s\\S]*';
|
|
235
|
+
i++;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
re += '[^/\\\\]*';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else if ('.[]{}()+-^$|?'.includes(ch)) {
|
|
242
|
+
re += '\\' + ch;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
re += ch;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
return new RegExp('^' + re + '$').test(path);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
197
255
|
/** Read the current content of a file under the project root. */
|
|
198
256
|
function readProjectFile(projectRoot, file) {
|
|
199
257
|
const resolved = resolveProjectFile(projectRoot, file);
|
|
@@ -304,6 +362,28 @@ export function registerFixTool(ctx) {
|
|
|
304
362
|
if (typeof finding.dimension !== 'string' || finding.dimension.trim().length === 0) {
|
|
305
363
|
return { ok: false, error: 'finding.dimension must be a non-empty string' };
|
|
306
364
|
}
|
|
365
|
+
// The finding must reference the file being fixed — the fix id and the
|
|
366
|
+
// rollback/diff target are derived from finding.file, so a mismatch
|
|
367
|
+
// would back up/restore the WRONG file.
|
|
368
|
+
if (finding.file !== file) {
|
|
369
|
+
return { ok: false, error: `finding.file ("${finding.file}") must match the file being fixed ("${file}")` };
|
|
370
|
+
}
|
|
371
|
+
// Full finding validation, mirroring the review schema: malformed
|
|
372
|
+
// findings would produce lossy registry/log entries and a degraded id.
|
|
373
|
+
const SEVERITY_SET = new Set(['critical', 'high', 'medium', 'low']);
|
|
374
|
+
if (!SEVERITY_SET.has(finding.severity)) {
|
|
375
|
+
return { ok: false, error: 'finding.severity must be one of critical/high/medium/low' };
|
|
376
|
+
}
|
|
377
|
+
if (typeof finding.summary !== 'string' || finding.summary.trim().length === 0) {
|
|
378
|
+
return { ok: false, error: 'finding.summary must be a non-empty string' };
|
|
379
|
+
}
|
|
380
|
+
if (typeof finding.is_atomic !== 'boolean') {
|
|
381
|
+
return { ok: false, error: 'finding.is_atomic must be a boolean' };
|
|
382
|
+
}
|
|
383
|
+
if (finding.line !== undefined && finding.line !== null &&
|
|
384
|
+
(typeof finding.line !== 'number' || !Number.isInteger(finding.line) || finding.line < 0)) {
|
|
385
|
+
return { ok: false, error: 'finding.line must be a non-negative integer (0 = whole-file)' };
|
|
386
|
+
}
|
|
307
387
|
const current = readProjectFile(projectRoot, file);
|
|
308
388
|
if (!current.ok)
|
|
309
389
|
return { ok: false, error: current.reason };
|
|
@@ -332,6 +412,27 @@ export function registerFixTool(ctx) {
|
|
|
332
412
|
const target = resolveProjectFile(projectRoot, file);
|
|
333
413
|
if (!target.ok)
|
|
334
414
|
return { ok: false, error: target.reason };
|
|
415
|
+
// Personalization guards (SKILL.md Phase 2): protected_paths veto the
|
|
416
|
+
// fix outright; forbidden_fixes veto fix approaches appearing in the
|
|
417
|
+
// new content. Both are security-relevant, so they are enforced here
|
|
418
|
+
// in the tool, not left to the model.
|
|
419
|
+
const pers = config.personalization;
|
|
420
|
+
const protectedPaths = Array.isArray(pers?.protected_paths)
|
|
421
|
+
? pers.protected_paths.filter((p) => typeof p === 'string' && p.length > 0)
|
|
422
|
+
: [];
|
|
423
|
+
for (const pattern of protectedPaths) {
|
|
424
|
+
if (globMatch(file, pattern)) {
|
|
425
|
+
return { ok: false, error: `skipped: ${file} matches protected path "${pattern}" (personalization.protected_paths forbids modifying it)` };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const forbiddenFixes = Array.isArray(pers?.forbidden_fixes)
|
|
429
|
+
? pers.forbidden_fixes.filter((f) => typeof f === 'string' && f.length > 0)
|
|
430
|
+
: [];
|
|
431
|
+
for (const forbidden of forbiddenFixes) {
|
|
432
|
+
if (args.content.includes(forbidden)) {
|
|
433
|
+
return { ok: false, error: `fix uses a forbidden approach: "${forbidden}" appears in the new content (personalization.forbidden_fixes)` };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
335
436
|
const timestamp = new Date().toISOString();
|
|
336
437
|
const backupPath = fixBackupPath(projectRoot, id, timestamp);
|
|
337
438
|
try {
|
|
@@ -363,7 +464,20 @@ export function registerFixTool(ctx) {
|
|
|
363
464
|
writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8');
|
|
364
465
|
}
|
|
365
466
|
catch (err) {
|
|
366
|
-
|
|
467
|
+
// Registry write failed → the file was already modified but no record
|
|
468
|
+
// exists, so a later rollback/diff could never see it and a retry would
|
|
469
|
+
// back up the already-fixed content as "original". Restore the file
|
|
470
|
+
// from the backup to leave the tree exactly as it was.
|
|
471
|
+
try {
|
|
472
|
+
copyFileSync(backupPath, target.resolved);
|
|
473
|
+
}
|
|
474
|
+
catch (restoreErr) {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
error: `failed to write fix registry: ${String(err)}; additionally failed to restore ${file} from backup: ${String(restoreErr)}`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` };
|
|
367
481
|
}
|
|
368
482
|
appendDecisionEntry(projectRoot, {
|
|
369
483
|
timestamp,
|
|
@@ -467,6 +581,9 @@ export function registerDiffTool(ctx) {
|
|
|
467
581
|
if (existing) {
|
|
468
582
|
existing.linesAdded += r.linesAdded;
|
|
469
583
|
existing.linesRemoved += r.linesRemoved;
|
|
584
|
+
// Recompute the summary from the summed counts so a multi-fix
|
|
585
|
+
// file's text does not contradict its accumulated numbers.
|
|
586
|
+
existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`;
|
|
470
587
|
}
|
|
471
588
|
else {
|
|
472
589
|
files.push({
|
package/dist/tools/prune.js
CHANGED
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
* - dryRun=true by default — the caller must explicitly opt into deletion.
|
|
18
18
|
* - Each deletion is logged to the decision log (when not dry-run).
|
|
19
19
|
*/
|
|
20
|
-
import { existsSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { existsSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
23
23
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
24
24
|
import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
|
|
25
|
-
import { readRegistry,
|
|
25
|
+
import { readRegistry, recomputeRoundCounts } from "./fix.js";
|
|
26
26
|
import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from "../paths.js";
|
|
27
27
|
/** Default retention for decision-log entries (in days). */
|
|
28
28
|
const DEFAULT_RETAIN_DAYS = 30;
|
|
@@ -101,13 +101,17 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
101
101
|
trimmedEmptyRounds: 0,
|
|
102
102
|
errors: [],
|
|
103
103
|
};
|
|
104
|
-
// 1. Rewrite the decision log, keeping only recent entries.
|
|
104
|
+
// 1. Rewrite the decision log, keeping only recent entries. Atomic
|
|
105
|
+
// (temp + rename) so a crash mid-write can never truncate the log.
|
|
105
106
|
try {
|
|
106
107
|
const entries = readDecisionEntries(projectRoot);
|
|
107
108
|
const kept = entries.filter((e) => e.timestamp >= cutoff);
|
|
108
109
|
result.deletedLogEntries = entries.length - kept.length;
|
|
109
110
|
if (result.deletedLogEntries > 0) {
|
|
110
|
-
|
|
111
|
+
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
|
|
112
|
+
const tmpPath = `${logPath}.tmp-${Date.now()}`;
|
|
113
|
+
writeFileSync(tmpPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
|
|
114
|
+
renameSync(tmpPath, logPath);
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
117
|
catch (err) {
|
|
@@ -138,11 +142,14 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
138
142
|
if (report.emptyRounds.length > 0) {
|
|
139
143
|
try {
|
|
140
144
|
let registry = readRegistry(projectRoot);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
const emptyRoundNos = new Set(report.emptyRounds);
|
|
146
|
+
// Drop whole empty rounds (records.length === 0) instead of only
|
|
147
|
+
// removing their records — an empty round has no records to remove, so
|
|
148
|
+
// the old loop was a no-op that still reported trimmedEmptyRounds.
|
|
149
|
+
registry = {
|
|
150
|
+
...registry,
|
|
151
|
+
rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
|
|
152
|
+
};
|
|
146
153
|
registry = recomputeRoundCounts(registry);
|
|
147
154
|
writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(registry, null, 2), 'utf-8');
|
|
148
155
|
result.trimmedEmptyRounds = report.emptyRounds.length;
|
package/dist/tools/review.js
CHANGED
|
@@ -145,7 +145,10 @@ export function registerReviewTool(ctx) {
|
|
|
145
145
|
.map((r) => {
|
|
146
146
|
const rr = r;
|
|
147
147
|
const findings = Array.isArray(rr?.findings) ? rr.findings : [];
|
|
148
|
-
|
|
148
|
+
const readFiles = Array.isArray(rr?.readFiles)
|
|
149
|
+
? rr.readFiles.filter((f) => typeof f === 'string')
|
|
150
|
+
: [];
|
|
151
|
+
return { round: typeof rr?.round === 'number' ? rr.round : 0, findings, readFiles };
|
|
149
152
|
})
|
|
150
153
|
.filter((r) => r.round > 0);
|
|
151
154
|
if (rounds.length === 0) {
|