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.
Files changed (55) hide show
  1. package/README.md +42 -2
  2. package/README.zh-CN.md +40 -2
  3. package/dist/approval-gate.js +92 -0
  4. package/dist/config-loader.js +18 -3
  5. package/dist/config-write.js +7 -4
  6. package/dist/evidence.js +67 -1
  7. package/dist/git-scope.js +35 -6
  8. package/dist/index.js +15 -5
  9. package/dist/live.js +155 -0
  10. package/dist/meta-review.js +19 -5
  11. package/dist/method-scope.js +5 -1
  12. package/dist/paths.js +4 -0
  13. package/dist/review-scope.js +12 -8
  14. package/dist/review.js +76 -24
  15. package/dist/session-hooks.js +89 -0
  16. package/dist/skill-prompt.js +101 -19
  17. package/dist/tools/checkpoint.js +10 -3
  18. package/dist/tools/context.js +16 -4
  19. package/dist/tools/decision-log.js +29 -9
  20. package/dist/tools/fix.js +120 -3
  21. package/dist/tools/prune.js +16 -9
  22. package/dist/tools/review.js +4 -1
  23. package/dist/tools/transcript.js +324 -0
  24. package/dist/tools/triage.js +9 -6
  25. package/dist/tools/validate.js +5 -2
  26. package/dist/transcript.js +421 -0
  27. package/lib/client.js +966 -80
  28. package/lib/parse.js +302 -17
  29. package/package.json +1 -1
  30. package/src/approval-gate.ts +119 -0
  31. package/src/client/index.ts +807 -62
  32. package/src/config-loader.ts +16 -2
  33. package/src/config-write.ts +6 -4
  34. package/src/evidence.ts +69 -1
  35. package/src/git-scope.ts +34 -6
  36. package/src/index.ts +17 -6
  37. package/src/live.ts +185 -0
  38. package/src/meta-review.ts +24 -10
  39. package/src/method-scope.ts +5 -1
  40. package/src/paths.ts +5 -0
  41. package/src/review-scope.ts +11 -7
  42. package/src/review.ts +82 -25
  43. package/src/session-hooks.ts +90 -0
  44. package/src/skill-prompt.ts +101 -19
  45. package/src/tools/checkpoint.ts +10 -3
  46. package/src/tools/context.ts +14 -3
  47. package/src/tools/decision-log.ts +27 -10
  48. package/src/tools/fix.ts +114 -3
  49. package/src/tools/prune.ts +14 -11
  50. package/src/tools/review.ts +5 -2
  51. package/src/tools/transcript.ts +334 -0
  52. package/src/tools/triage.ts +9 -6
  53. package/src/tools/validate.ts +5 -2
  54. package/src/transcript.ts +475 -0
  55. package/src/types.ts +129 -0
@@ -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 => () => agent(
92
- 'Review dimension "' + dim + '".' +
93
- (attachments.length > 0 ? ' 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) + '.' : '') +
94
- ' Already-known findings (do NOT re-report): ' +
95
- JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
96
- Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
97
- )))
98
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
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 => () => agent(
240
- 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
241
- (attachments.length > 0 ? ' 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) + '.' : '') +
242
- 'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
243
- Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
244
- )))
245
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
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
- 'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error} per iterate_fix call.',
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,
@@ -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
- findingsCount: checkpoint?.findings.length ?? 0,
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
- writeFileSync(checkpointPath(projectRoot), JSON.stringify(checkpoint, null, 2), 'utf-8');
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)}` };
@@ -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
- const requested = (args.files ?? '')
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
- if (args.skillDir)
266
- candidates.push(args.skillDir);
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
- const filePath = logPath(projectRoot);
52
- const line = JSON.stringify(entry) + '\n';
53
- appendFileSync(filePath, line, 'utf-8');
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
- const content = readFileSync(filePath, 'utf-8');
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
- return { ok: false, error: `failed to write fix registry: ${String(err)}` };
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({
@@ -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, removeRecord, recomputeRoundCounts } from "./fix.js";
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
- writeFileSync(join(iterateDir(projectRoot), 'decision-log.jsonl'), kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
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
- for (const round of report.emptyRounds) {
142
- for (const rec of [...registry.rounds.find((r) => r.round === round)?.records ?? []]) {
143
- registry = removeRecord(registry, rec.id);
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;
@@ -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
- return { round: typeof rr?.round === 'number' ? rr.round : 0, findings };
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) {