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
package/src/review.ts CHANGED
@@ -44,9 +44,11 @@ export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] {
44
44
  const rankB = SEVERITY_RANK[b.severity] ?? SEVERITY_RANK.low
45
45
  const bySeverity = rankA - rankB
46
46
  if (bySeverity !== 0) return bySeverity
47
- const byFile = a.file.localeCompare(b.file)
47
+ // Defensive coercion: `file`/`line` can be wrong-typed when schema
48
+ // validation is disabled — String()/Number() keep the comparator total.
49
+ const byFile = String(a.file ?? '').localeCompare(String(b.file ?? ''))
48
50
  if (byFile !== 0) return byFile
49
- return (a.line ?? 0) - (b.line ?? 0)
51
+ return (Number(a.line) || 0) - (Number(b.line) || 0)
50
52
  })
51
53
  }
52
54
 
@@ -58,9 +60,15 @@ export function normalizeSummary(summary: string): string {
58
60
  .replace(/[\s\n\t]+/g, ' ')
59
61
  }
60
62
 
61
- /** Dedupe key: same file + same dimension + similar summary. */
63
+ /**
64
+ * Dedupe key: same file + same dimension + similar summary + explicit line.
65
+ * Including the line keeps two genuine issues with identical wording at
66
+ * different locations from collapsing into one (the line is omitted only when
67
+ * neither side anchors one, i.e. whole-file findings).
68
+ */
62
69
  export function findingKey(f: ReviewFinding): string {
63
- return `${f.file}|${f.dimension}|${normalizeSummary(f.summary)}`
70
+ const line = typeof f.line === 'number' && f.line > 0 ? f.line : 0
71
+ return `${f.file}|${f.dimension}|${line}|${normalizeSummary(f.summary)}`
64
72
  }
65
73
 
66
74
  /**
@@ -129,12 +137,19 @@ export function aggregateRounds(
129
137
  const merged: ReviewFinding[] = []
130
138
 
131
139
  // Guard: round numbers are expected to be positive integers. Skip malformed
132
- // entries defensively rather than letting `firstRoundByKey` key on NaN/0.
140
+ // entries defensively rather than letting `firstRoundByKey` key on NaN/0 or
141
+ // crashing on null / non-array findings.
142
+ // Hard ceiling: round numbers are model-authored JSON; an absurd round (e.g.
143
+ // 1e9) would otherwise allocate an array of that size below (OOM). Round
144
+ // numbers above the configured cap are clamped to the cap.
133
145
  let maxRound = 0
146
+ const roundCap = Math.max(1, maxReviewRounds)
134
147
  for (const round of rounds) {
148
+ if (!round || typeof round !== 'object') continue
135
149
  if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1) continue
150
+ const findings = Array.isArray(round.findings) ? round.findings : []
136
151
  if (round.round > maxRound) maxRound = round.round
137
- for (const f of round.findings) {
152
+ for (const f of findings) {
138
153
  const key = findingKey(f)
139
154
  if (seen.has(key)) continue
140
155
  seen.add(key)
@@ -142,9 +157,11 @@ export function aggregateRounds(
142
157
  merged.push(f)
143
158
  }
144
159
  }
160
+ // Clamp the allocation bound so a hostile round number cannot OOM the tool.
161
+ const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2))
145
162
 
146
163
  const findingsByRound: number[] = []
147
- for (let r = 1; r <= maxRound; r++) {
164
+ for (let r = 1; r <= effectiveMax; r++) {
148
165
  let count = 0
149
166
  for (const key of firstRoundByKey.keys()) {
150
167
  if (firstRoundByKey.get(key) === r) count++
@@ -165,12 +182,18 @@ export function computeConvergence(
165
182
  const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds)
166
183
  const totalRounds = rounds.length
167
184
  // `findingsByRound` is indexed by the actual round number (round r → index
168
- // r-1), so convergence must read the LAST PRESENT round's count using its
169
- // reported round number — not `totalRounds - 1`, which is only valid for
170
- // contiguous 1..N round numbers.
171
- const lastRound = totalRounds > 0 ? rounds[totalRounds - 1]!.round : 0
172
- const lastRoundCount =
173
- lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
185
+ // r-1), sized to the highest present round (clamped). Convergence must read
186
+ // the HIGHEST PRESENT round's count — not the last array element (rounds
187
+ // may arrive unsorted) and not `totalRounds - 1` (only valid for contiguous
188
+ // 1..N). The count index is bounded by the array length aggregateRounds
189
+ // actually allocated.
190
+ let lastRound = 0
191
+ for (const round of rounds) {
192
+ if (!round || typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1) continue
193
+ if (round.round > lastRound) lastRound = round.round
194
+ }
195
+ const idx = Math.min(lastRound, findingsByRound.length) - 1
196
+ const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0
174
197
  const converged = totalRounds > 0 && lastRoundCount === 0
175
198
  return {
176
199
  totalRounds,
@@ -224,8 +247,12 @@ export function buildReviewReport(input: {
224
247
  }): ReviewReport {
225
248
  // 1. Filter known-intentional per round (before cross-round dedupe).
226
249
  const filteredRounds = input.rounds.map((r) => ({
227
- round: r.round,
228
- findings: filterKnownIntentional(r.findings, input.knownIntentional),
250
+ round: typeof r?.round === 'number' ? r.round : 0,
251
+ findings: filterKnownIntentional(
252
+ Array.isArray(r?.findings) ? r.findings : [],
253
+ input.knownIntentional,
254
+ ),
255
+ readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
229
256
  }))
230
257
 
231
258
  // 2. Cross-round dedupe + per-round "first seen" tracking.
@@ -262,6 +289,9 @@ export function buildReviewReport(input: {
262
289
  maxReviewRounds: input.maxReviewRounds,
263
290
  rounds: filteredRounds,
264
291
  findings: sorted,
292
+ // Aggregate of every round's self-reported reads, so the meta-review
293
+ // coverage gate can compare against the assigned inventory.
294
+ readFiles: ([] as string[]).concat(...filteredRounds.map((r) => r.readFiles ?? [])),
265
295
  convergence: {
266
296
  totalRounds: filteredRounds.length,
267
297
  findingsByRound,
@@ -470,8 +500,8 @@ export interface RoundSchemaValidation {
470
500
  */
471
501
  export function validateRoundsSchema(rounds: ReviewRound[]): RoundSchemaValidation[] {
472
502
  return rounds.map((r) => {
473
- const issues = validateFindingsSchema(r.findings)
474
- return { round: r.round, valid: issues.length === 0, issues }
503
+ const issues = validateFindingsSchema(Array.isArray(r?.findings) ? r.findings : [])
504
+ return { round: typeof r?.round === 'number' ? r.round : 0, valid: issues.length === 0, issues }
475
505
  })
476
506
  }
477
507
 
@@ -491,21 +521,27 @@ export function sanitizeRounds(
491
521
  schemaValidation: RoundSchemaValidation[] | null,
492
522
  ): ReviewRound[] {
493
523
  return rounds.map((r, i) => {
524
+ // Defensive: malformed rounds must never crash the deterministic core.
525
+ const findings = Array.isArray(r?.findings) ? r.findings : []
526
+ const roundNo = typeof r?.round === 'number' ? r.round : 0
527
+ const readFiles = Array.isArray(r?.readFiles) ? r.readFiles : []
494
528
  if (schemaValidation) {
495
529
  const issues = schemaValidation[i]?.issues ?? []
496
- if (issues.some((iss) => iss.index === -1)) return { round: r.round, findings: [] }
530
+ if (issues.some((iss) => iss.index === -1)) return { round: roundNo, findings: [], readFiles }
497
531
  const bad = new Set(issues.map((iss) => iss.index))
498
532
  return {
499
- round: r.round,
500
- findings: r.findings.filter((_, fi) => !bad.has(fi)),
533
+ round: roundNo,
534
+ findings: findings.filter((_, fi) => !bad.has(fi)),
535
+ readFiles,
501
536
  }
502
537
  }
503
538
  return {
504
- round: r.round,
505
- findings: r.findings.filter(
539
+ round: roundNo,
540
+ findings: findings.filter(
506
541
  (f): f is ReviewFinding =>
507
542
  Boolean(f) && typeof f === 'object' && !Array.isArray(f),
508
543
  ),
544
+ readFiles,
509
545
  }
510
546
  })
511
547
  }
@@ -539,6 +575,12 @@ export function reviewerTaskPrompt(input: {
539
575
  * `changedFiles`.
540
576
  */
541
577
  scopeFiles?: string[]
578
+ /**
579
+ * Per-dimension focus guidance (from personalization.dimension_focus, or the
580
+ * skill's dimension definitions). Appended to the reviewer prompt so the
581
+ * review concentrates on the areas the user cares about.
582
+ */
583
+ focus?: string
542
584
  }): string {
543
585
  const parts: string[] = []
544
586
  parts.push(
@@ -546,6 +588,9 @@ export function reviewerTaskPrompt(input: {
546
588
  `Goal: ${input.goal}`,
547
589
  `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
548
590
  )
591
+ if (input.focus) {
592
+ parts.push(`FOCUS: ${input.focus}`)
593
+ }
549
594
  if (input.scopeFiles && input.scopeFiles.length > 0) {
550
595
  parts.push(
551
596
  'COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
@@ -592,9 +637,9 @@ export function reviewerTaskPrompt(input: {
592
637
  parts.push(
593
638
  `Return a JSON object: {"findings": [...], "readFiles": [...]}.`,
594
639
  `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
595
- 'line (REQUIRED positive integer — the exact line you READ for an ' +
596
- 'anchored, line-targeted issue; use 0 for whole-file/module-level ' +
597
- 'issues), severity (critical/high/medium/low), summary (one line), ' +
640
+ 'line (optional; the exact line you READ for a line-targeted issue; ' +
641
+ '0 or omitted for whole-file/module-level issues), ' +
642
+ 'severity (critical/high/medium/low), summary (one line), ' +
598
643
  'failure_scenario (how/when it fails, backed by the code you actually ' +
599
644
  'read), suggested_fix (the concrete fix), ' +
600
645
  `is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`,
@@ -672,6 +717,17 @@ export function buildReviewPlan(input: {
672
717
  reviewerPrompt: string
673
718
  findingsSchema: Record<string, unknown>
674
719
  }[] = []
720
+ // personalization.dimension_focus: [{dimension, focus}] — appended to the
721
+ // matching dimension's reviewer prompt.
722
+ const focusMap = new Map<string, string>()
723
+ const pf = input.config.personalization as { dimension_focus?: { dimension?: string; focus?: string }[] } | undefined
724
+ if (pf && Array.isArray(pf.dimension_focus)) {
725
+ for (const entry of pf.dimension_focus) {
726
+ if (entry && typeof entry.dimension === 'string' && typeof entry.focus === 'string' && entry.focus) {
727
+ focusMap.set(entry.dimension, entry.focus)
728
+ }
729
+ }
730
+ }
675
731
  for (const d of dimensions) {
676
732
  batches.forEach((batch, index) => {
677
733
  const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`
@@ -687,6 +743,7 @@ export function buildReviewPlan(input: {
687
743
  maxLines,
688
744
  changedFiles: effectiveChangedOnly ? changedFiles : undefined,
689
745
  scopeFiles: batch,
746
+ focus: focusMap.get(d),
690
747
  }),
691
748
  findingsSchema: findingsSchema(),
692
749
  })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * src/session-hooks.ts — dsh pipeline hooks for the iterate observatory (F8).
3
+ *
4
+ * Wires the {@link decideApproval} policy gate to dsh's `tools/pre-execute`
5
+ * waterfall. This is the AUTHORITATIVE approval seam for destructive iterate
6
+ * tools (`iterate_fix` / `iterate_rollback` / `iterate_prune` with dryRun:false):
7
+ *
8
+ * - `allow` policy → the call runs.
9
+ * - `deny` policy → the call is refused (fail-closed), surfaced as an
10
+ * error to the model.
11
+ * - `ask` policy → return `{ kind: 'ask', reason }`; dsh's own
12
+ * scheduler routes it through the `approval` service
13
+ * (see `@deepseek-ai/dsh-user-approval`), which
14
+ * prompts the human and audits an approve/deny pair
15
+ * on the session.
16
+ *
17
+ * We deliberately do NOT also add `approved` flags inside the tool bodies:
18
+ * the pre-execute waterfall consumes the human decision before the tool runs,
19
+ * so a second tool-internal gate would double-ask. This one gate is enough and
20
+ * stays dsh-native.
21
+ *
22
+ * Safety properties:
23
+ * - Read-only tools and non-iterate tools are always allowed (the gate only
24
+ * inspects the three destructive iterate toolnames).
25
+ * - If the project root / observatory config cannot be resolved, the policy
26
+ * degrades to `ask` (fail-safe: destructive writes always require consent).
27
+ */
28
+
29
+ import { loadEffectiveConfig, resolveProjectRoot } from './config-loader.ts'
30
+ import { decideApproval, isDestructiveIterateTool } from './approval-gate.ts'
31
+ import type { Context } from '@deepseek-ai/cordis'
32
+ import type { ToolExecution, PreToolDecision } from '@deepseek-ai/dsh-tools'
33
+
34
+ /**
35
+ * Build the per-call approval decision for a tool execution.
36
+ * Returns a dsh `PreToolDecision` so the caller can short-circuit the caller.
37
+ */
38
+ export function gateDecision(exec: ToolExecution): PreToolDecision {
39
+ // Importing the decision, and only inspecting our own tools, keeps unrelated
40
+ // tooling untouched. Anything we cannot classify is allowed by default.
41
+ if (!isDestructiveIterateTool(exec.name)) return { kind: 'allow' }
42
+
43
+ // Resolve the project root (use the call's own `path` arg, else the agent's
44
+ // session cwd) to read the effective observatory policy.
45
+ const argPath = typeof exec.arguments === 'object' && exec.arguments && !Array.isArray(exec.arguments)
46
+ && typeof (exec.arguments as Record<string, unknown>).path === 'string'
47
+ ? (exec.arguments as Record<string, unknown>).path as string
48
+ : undefined
49
+ const sessionCwd = exec.agent?.session?.header?.cwd
50
+ const resolved = resolveProjectRoot(argPath, sessionCwd)
51
+ let policy: 'ask' | 'deny' | 'allow' = 'ask'
52
+ if (resolved.ok) {
53
+ const { config } = loadEffectiveConfig(resolved.root)
54
+ const p = config.observatory?.approval
55
+ if (p === 'deny') policy = 'deny'
56
+ else if (p === 'allow') policy = 'allow'
57
+ // anything else (including a corrupt/missing `ask`) → 'ask'
58
+ }
59
+
60
+ const decision = decideApproval(exec, policy)
61
+ if (decision.kind === 'deny') return { kind: 'deny', reason: decision.reason }
62
+ if (decision.kind === 'ask') return { kind: 'ask', reason: decision.reason }
63
+ return { kind: 'allow' }
64
+ }
65
+
66
+ /**
67
+ * Register the `tools/pre-execute` waterfall listener that applies the
68
+ * observatory approval gate to every destructive iterate tool call.
69
+ */
70
+ export function registerSessionHooks(ctx: Context): void {
71
+ ctx.on('tools/pre-execute', (exec: ToolExecution, next: () => Promise<PreToolDecision>) => {
72
+ // Never let a throwing gate break the pipeline — degrade to allow.
73
+ let decision: PreToolDecision
74
+ try {
75
+ decision = gateDecision(exec)
76
+ } catch {
77
+ return next()
78
+ }
79
+ if (decision.kind === 'ask') {
80
+ // Delegate the actual human-consent prompt + audit to dsh's approval
81
+ // service via the scheduler's `ask` path. `next()` here would short-circuit
82
+ // to allow, which would bypass consent — so return our ask decision.
83
+ return Promise.resolve(decision)
84
+ }
85
+ if (decision.kind === 'deny') {
86
+ return Promise.resolve(decision)
87
+ }
88
+ return next()
89
+ })
90
+ }
@@ -23,6 +23,7 @@ You have the iterate plugin installed, which registers these tools:
23
23
  - \`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)
24
24
  - \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
25
25
  - \`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.
26
+ - \`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.
26
27
 
27
28
  ### When to use
28
29
  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.
@@ -78,6 +79,14 @@ const knownIntentional = (plan.knownIntentional || []) // config personalizati
78
79
  let known = [] // cumulative DEDUPED findings fed back to reviewers
79
80
  const rounds = [] // raw per-round findings
80
81
 
82
+ phase('transcript')
83
+ // Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
84
+ const transRead = await agent(
85
+ 'Call iterate_transcript({operation:"read"}) and return {nudge:<transcript.nudge ? transcript.nudge.text : null>}.',
86
+ Object.assign({ label: 'transcript:read' }, backend)
87
+ )
88
+ const steering = transRead && typeof transRead.nudge === 'string' && transRead.nudge ? transRead.nudge : null
89
+
81
90
  phase('review')
82
91
  for (let r = 1; r <= maxRounds; r++) {
83
92
  log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
@@ -89,14 +98,29 @@ for (let r = 1; r <= maxRounds; r++) {
89
98
  const nudge = retries > 0
90
99
  ? '\\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).'
91
100
  : ''
92
- const raw = await parallel(dims.map(dim => () => agent(
93
- 'Review dimension "' + dim + '".' +
94
- (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) + '.' : '') +
95
- ' Already-known findings (do NOT re-report): ' +
96
- JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
97
- Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
98
- )))
99
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
101
+ const raw = await parallel(dims.map(dim => () => {
102
+ // Pass the plan's full per-dimension reviewerPrompt (goal, COVERAGE RULE
103
+ // with the assigned file inventory, EVIDENCE RULE, output language) and
104
+ // append the round-specific context the reviewers must receive the
105
+ // file inventory or the coverage machinery has nothing to enforce.
106
+ const meta = plan.dimensions.find(x => x.id === dim)
107
+ const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
108
+ ? meta.reviewerPrompt
109
+ : 'Review dimension "' + dim + '".'
110
+ const extra =
111
+ (steering ? '\\n STEERING — read this first: ' + steering : '') +
112
+ (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) + '.' : '') +
113
+ '\\n Already-known findings (do NOT re-report): ' +
114
+ JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
115
+ return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
116
+ }))
117
+ const thisRound = {
118
+ round: r,
119
+ findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
120
+ // readFiles are threaded through so the aggregate/meta-review coverage
121
+ // gate can compare self-reported reads against the assigned inventory.
122
+ readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
123
+ }
100
124
  if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
101
125
  // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
102
126
  agg = await agent(
@@ -143,6 +167,13 @@ const metaRes = await agent(
143
167
  const finalReport = metaRes && metaRes.finalReport ? metaRes.finalReport : null
144
168
  const metaAudit = finalReport && finalReport.metaReview ? finalReport.metaReview : null
145
169
 
170
+ // Persist the run's observatory transcript (reviewer threads, trend, findings)
171
+ // so the client observatory panel reflects this review. Writes ONLY .iterate/transcript.json.
172
+ await agent(
173
+ '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"}.',
174
+ Object.assign({ label: 'transcript:capture' }, backend)
175
+ )
176
+
146
177
  return {
147
178
  mode: 'dry-run',
148
179
  goal: report.goal,
@@ -167,7 +198,7 @@ Key rules for dry-run:
167
198
  - Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
168
199
  - The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
169
200
  - **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.
170
- - Only a single \`report\` entry may be appended to the decision log; nothing else is written.
201
+ - 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.
171
202
 
172
203
  ### Normal-mode workflow (autonomous closed loop)
173
204
  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.
@@ -225,10 +256,23 @@ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? che
225
256
  let converged = false
226
257
  let abortedByValidation = false
227
258
  let failedCommands = []
259
+ const fixRecords = [] // observatory fix records collected round by round
260
+
261
+ // Read any steering nudge intended for this run's reviewers.
262
+ const transRead = await agent(
263
+ 'Call iterate_transcript({operation:"read"}) and return {nudge:<transcript.nudge ? transcript.nudge.text : null>}.',
264
+ Object.assign({ label: 'transcript:read' }, backend)
265
+ )
266
+ const steering = transRead && typeof transRead.nudge === 'string' && transRead.nudge ? transRead.nudge : null
228
267
 
229
268
  phase('loop')
230
269
  for (let r = startRound; r <= maxRounds; r++) {
231
270
  log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
271
+ // Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
272
+ await agent(
273
+ 'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
274
+ Object.assign({ label: 'log:start:r' + r }, backend)
275
+ )
232
276
  let agg = null
233
277
  let schemaInvalid = false
234
278
  let retries = 0
@@ -237,13 +281,25 @@ for (let r = startRound; r <= maxRounds; r++) {
237
281
  const nudge = retries > 0
238
282
  ? '\\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).'
239
283
  : ''
240
- const raw = await parallel(dims.map(dim => () => agent(
241
- 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
242
- (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) + '.' : '') +
243
- 'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
244
- Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
245
- )))
246
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
284
+ const raw = await parallel(dims.map(dim => () => {
285
+ // Pass the plan's full per-dimension reviewerPrompt (COVERAGE RULE with
286
+ // the assigned file inventory, EVIDENCE RULE, output language) plus the
287
+ // round-specific context.
288
+ const meta = plan.dimensions.find(x => x.id === dim)
289
+ const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
290
+ ? meta.reviewerPrompt
291
+ : 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed).'
292
+ const extra =
293
+ (steering ? '\\n STEERING — read this first: ' + steering : '') +
294
+ (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) + '.' : '') +
295
+ '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
296
+ return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
297
+ }))
298
+ const thisRound = {
299
+ round: r,
300
+ findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
301
+ readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
302
+ }
247
303
  if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
248
304
 
249
305
  // Deterministic dedupe / known_intentional filter / severity sort for this round.
@@ -279,12 +335,14 @@ for (let r = startRound; r <= maxRounds; r++) {
279
335
  'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
280
336
  'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
281
337
  'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
282
- 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff. ' +
283
- 'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error} per iterate_fix call.',
338
+ 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff and ' +
339
+ 'read its line statistics (lines added/removed). ' +
340
+ 'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
341
+ '(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
284
342
  Object.assign({ label: 'fix:' + file, phase: 'fix', schema: {
285
343
  type: 'object', additionalProperties: false,
286
344
  properties: {
287
- fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
345
+ 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'] } }
288
346
  },
289
347
  required: ['fixes'] } }, backend)
290
348
  )))
@@ -292,6 +350,17 @@ for (let r = startRound; r <= maxRounds; r++) {
292
350
  if (res && Array.isArray(res.fixes)) {
293
351
  for (const fx of res.fixes) {
294
352
  if (fx && fx.ok === true) { fixedCount += 1; roundFixIds.push(fx.id) }
353
+ // Collect fix records for the observatory transcript (defensive defaults).
354
+ const fixFileKeys = Object.keys(byFile)
355
+ fixRecords.push({
356
+ id: fx && typeof fx.id === 'string' ? fx.id : '',
357
+ file: fx && typeof fx.file === 'string' ? fx.file : (fixFileKeys.length === 1 ? fixFileKeys[0] : ''),
358
+ round: r,
359
+ summary: '',
360
+ linesAdded: fx && typeof fx.linesAdded === 'number' ? fx.linesAdded : 0,
361
+ linesRemoved: fx && typeof fx.linesRemoved === 'number' ? fx.linesRemoved : 0,
362
+ success: !!(fx && fx.ok === true),
363
+ })
295
364
  }
296
365
  }
297
366
  }
@@ -374,6 +443,19 @@ if (!abortedByValidation) {
374
443
  { label: 'checkpoint:clear' }
375
444
  )
376
445
  }
446
+ // Persist the run's observatory transcript (threads, trend, fixes, checkpoint)
447
+ // so the client observatory panel reflects the run. Writes ONLY .iterate/transcript.json.
448
+ const obsCheckpoint = abortedByValidation ? null : {
449
+ mode: 'normal',
450
+ round: rounds.length,
451
+ maxRounds: maxRounds,
452
+ fixedCount: fixedCount,
453
+ resumeCount: effectiveResumeCount,
454
+ }
455
+ await agent(
456
+ '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"}.',
457
+ { label: 'transcript:capture' }
458
+ )
377
459
  return {
378
460
  mode: 'normal',
379
461
  goal: plan.goal,
@@ -9,7 +9,7 @@
9
9
  * Checkpoint layout: `.iterate/checkpoint.json`.
10
10
  */
11
11
 
12
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
13
13
  import { defineTool } from '@deepseek-ai/dsh-tools'
14
14
  import type { JsonValue } from '@deepseek-ai/dsh-session'
15
15
  import { resolveProjectRootForExec } from '../config-loader.ts'
@@ -107,7 +107,9 @@ export function computeStatus(input: {
107
107
  totalRounds,
108
108
  fixedCount,
109
109
  architecturalCount,
110
- findingsCount: checkpoint?.findings.length ?? 0,
110
+ // A checkpoint may predate the `findings` field (or be hand-edited) — a
111
+ // missing findings must degrade to 0, never throw.
112
+ findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
111
113
  totalDecisionLogEntries: entries.length,
112
114
  hasCheckpoint: checkpoint !== null,
113
115
  // A checkpoint left on disk means the previous run was interrupted before
@@ -209,7 +211,12 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
209
211
  }
210
212
  try {
211
213
  mkdirSync(iterateDir(projectRoot), { recursive: true })
212
- writeFileSync(checkpointPath(projectRoot), JSON.stringify(checkpoint, null, 2), 'utf-8')
214
+ // Atomic write (temp + rename): a crash mid-write must not corrupt
215
+ // the checkpoint and silently lose the interruption state.
216
+ const cpPath = checkpointPath(projectRoot)
217
+ const tmpPath = `${cpPath}.tmp-${Date.now()}`
218
+ writeFileSync(tmpPath, JSON.stringify(checkpoint, null, 2), 'utf-8')
219
+ renameSync(tmpPath, cpPath)
213
220
  } catch (err) {
214
221
  return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` }
215
222
  }
@@ -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'
@@ -263,7 +263,8 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
263
263
  return { found: false, error: resolved.reason, searched: [] }
264
264
  }
265
265
  const projectRoot = resolved.root
266
- const requested = (args.files ?? '')
266
+ // Guard: `files` must be a comma-separated string (model-controlled).
267
+ const requested = (typeof args.files === 'string' ? args.files : '')
267
268
  .split(',')
268
269
  .map((s) => s.trim().toLowerCase())
269
270
  .filter(Boolean)
@@ -295,7 +296,17 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
295
296
  // are all supported.
296
297
  const skillRoot = findSkillRoot(PLUGIN_SRC_DIR)
297
298
  const candidates: string[] = []
298
- if (args.skillDir) candidates.push(args.skillDir)
299
+ // skillDir is a model-controlled path; only honor it when it is an
300
+ // existing directory (resolve it first) — otherwise fall through to
301
+ // the auto-detected root / project root.
302
+ if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
303
+ try {
304
+ const dir = resolve(args.skillDir)
305
+ if (existsSync(dir) && statSync(dir).isDirectory()) candidates.push(dir)
306
+ } catch {
307
+ // unreadable/invalid skillDir — skip it
308
+ }
309
+ }
299
310
  if (skillRoot) candidates.push(skillRoot)
300
311
  candidates.push(projectRoot)
301
312
  result.searched = candidates
@@ -52,12 +52,19 @@ function logPath(projectRoot: string): string {
52
52
 
53
53
  /**
54
54
  * Append one entry to the decision log (JSONL format).
55
- * Returns the entry count after appending.
55
+ * Returns the entry count after appending. Never throws — a disk failure is
56
+ * surfaced through `error` so callers (fix/prune) can report the audit-trail
57
+ * miss without failing the mutation they already performed.
56
58
  */
57
- export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string } {
58
- const filePath = logPath(projectRoot)
59
- const line = JSON.stringify(entry) + '\n'
60
- appendFileSync(filePath, line, 'utf-8')
59
+ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string; error?: string } {
60
+ let filePath: string
61
+ try {
62
+ filePath = logPath(projectRoot)
63
+ const line = JSON.stringify(entry) + '\n'
64
+ appendFileSync(filePath, line, 'utf-8')
65
+ } catch (err) {
66
+ return { count: -1, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
67
+ }
61
68
  // Count entries
62
69
  let count = 0
63
70
  try {
@@ -71,19 +78,29 @@ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry
71
78
 
72
79
  /**
73
80
  * Read all entries from the decision log.
81
+ * A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
82
+ * one bad line must never empty the whole history for every reader.
74
83
  */
75
84
  export function readDecisionEntries(projectRoot: string): DecisionLogEntry[] {
76
85
  const filePath = join(projectRoot, LOG_DIR, LOG_FILE)
77
86
  if (!existsSync(filePath)) return []
87
+ let content: string
78
88
  try {
79
- const content = readFileSync(filePath, 'utf-8')
80
- return content
81
- .split('\n')
82
- .filter((l) => l.trim().length > 0)
83
- .map((l) => JSON.parse(l) as DecisionLogEntry)
89
+ content = readFileSync(filePath, 'utf-8')
84
90
  } catch {
85
91
  return []
86
92
  }
93
+ const out: DecisionLogEntry[] = []
94
+ for (const line of content.split('\n')) {
95
+ const trimmed = line.trim()
96
+ if (trimmed.length === 0) continue
97
+ try {
98
+ out.push(JSON.parse(trimmed) as DecisionLogEntry)
99
+ } catch {
100
+ // skip the corrupt line, keep the rest
101
+ }
102
+ }
103
+ return out
87
104
  }
88
105
 
89
106
  /**