iterate-plugin 3.5.6 → 3.6.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 (75) hide show
  1. package/README.md +64 -20
  2. package/README.zh-CN.md +53 -17
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +509 -34
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +165 -0
  9. package/dist/index.js +54 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +52 -13
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +55 -13
  17. package/dist/skill-prompt.js +417 -67
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +122 -27
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +156 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/lib/client.js +945 -181
  38. package/lib/parse.js +760 -57
  39. package/package.json +15 -12
  40. package/src/approval-gate.ts +160 -23
  41. package/src/atomic-fs.ts +152 -17
  42. package/src/client/index.ts +600 -129
  43. package/src/config-loader.ts +542 -35
  44. package/src/config-write.ts +108 -26
  45. package/src/evidence.ts +194 -92
  46. package/src/file-lock.ts +184 -0
  47. package/src/index.ts +53 -29
  48. package/src/jobs.ts +63 -12
  49. package/src/live.ts +48 -11
  50. package/src/meta-review.ts +151 -34
  51. package/src/method-scope.ts +31 -8
  52. package/src/review-scope.ts +33 -10
  53. package/src/review.ts +96 -17
  54. package/src/session-hooks.ts +54 -12
  55. package/src/skill-prompt.ts +417 -67
  56. package/src/tools/checkpoint.ts +81 -19
  57. package/src/tools/config.ts +44 -2
  58. package/src/tools/context.ts +25 -5
  59. package/src/tools/decision-log.ts +127 -22
  60. package/src/tools/defense-events.ts +87 -31
  61. package/src/tools/defense-store.ts +30 -3
  62. package/src/tools/experience-bank.ts +112 -44
  63. package/src/tools/experience-store.ts +54 -5
  64. package/src/tools/fix.ts +258 -20
  65. package/src/tools/history.ts +74 -15
  66. package/src/tools/present.ts +89 -0
  67. package/src/tools/prune.ts +160 -51
  68. package/src/tools/quality-gate.ts +57 -2
  69. package/src/tools/quality-store.ts +18 -1
  70. package/src/tools/review.ts +155 -6
  71. package/src/tools/transcript.ts +178 -36
  72. package/src/tools/triage.ts +70 -13
  73. package/src/tools/validate.ts +138 -32
  74. package/src/transcript.ts +279 -4
  75. package/src/types.ts +29 -1
@@ -59,6 +59,11 @@ export function readQualityGate(projectRoot) {
59
59
  })
60
60
  : [];
61
61
  return {
62
+ // Snapshot-level fallback timestamp: display-only metadata — the gate
63
+ // is a SINGLE snapshot file (overwritten wholesale), and nothing sorts
64
+ // or evicts by `timestamp`, so a missing/corrupt value degrading to
65
+ // "now" cannot affect retention or ordering. (Reaffirmed from the
66
+ // earlier review: comment only, behavior unchanged.)
62
67
  timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : emptySnapshot().timestamp,
63
68
  overallStatus: status,
64
69
  overallScore: num(parsed.overallScore),
@@ -160,6 +165,12 @@ export function computeQualityGate(opts) {
160
165
  dimensionStats[dim] = { count: 0, critical: 0, high: 0, medium: 0, low: 0 };
161
166
  }
162
167
  for (const finding of findings) {
168
+ // Findings in dimensions outside the gated list land here with no matching
169
+ // `dimensionStats` entry and are skipped — they never touch any dimension
170
+ // score (only a global critical still fails the gate). Scoring stays
171
+ // unchanged by contract; the tool layer surfaces the excluded dimensions
172
+ // as `warnings` so the numbers are never silently incomplete
173
+ // (see src/tools/quality-gate.ts).
163
174
  const stats = dimensionStats[finding.dimension];
164
175
  if (stats) {
165
176
  stats.count++;
@@ -179,7 +190,12 @@ export function computeQualityGate(opts) {
179
190
  // Score: 100 - (critical*30 + high*15 + medium*5 + low*1), capped at 0
180
191
  const penalty = stats.critical * 30 + stats.high * 15 + stats.medium * 5 + stats.low * 1;
181
192
  const score = Math.max(0, 100 - penalty);
182
- const status = score >= 80 ? 'pass' : score >= 50 ? 'warn' : 'fail';
193
+ // Arithmetic alone would grade a single critical as 100-30=70 → 'warn',
194
+ // while the snapshot's overall status is 'fail' (hasCritical below) — a
195
+ // certificate that contradicts itself ("WARN" on the dimension that made
196
+ // the gate fail). Any critical finding is a hard FAIL for that dimension,
197
+ // regardless of its score; non-critical dimensions keep the score bands.
198
+ const status = stats.critical > 0 ? 'fail' : score >= 80 ? 'pass' : score >= 50 ? 'warn' : 'fail';
183
199
  const series = findingsByRound?.[dim];
184
200
  const convergenceRate = convergenceRateFor(Array.isArray(series) ? series : undefined, stats.count);
185
201
  return {
@@ -1,13 +1,73 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
3
3
  import { runWithJob } from "../jobs.js";
4
- import { buildReviewPlan, buildReviewReport, sanitizeRounds, validateRoundsSchema, } from "../review.js";
4
+ import { buildReviewPlan, buildReviewReport, sanitizeRounds, validateRoundsSchema, MAX_REVIEW_ROUNDS_CAP, } from "../review.js";
5
5
  import { buildFinalReviewReport, metaReviewReport } from "../meta-review.js";
6
- import { evidenceToPlain, verifyFindings } from "../evidence.js";
6
+ import { evidenceToPlain, resolveWithin, verifyFindings } from "../evidence.js";
7
+ import { readKnownIntentional } from "./triage.js";
8
+ import { asNumber, asRecord, parseRenderedJson } from "./present.js";
7
9
  import { collectScopeFiles, computeCoverage, coverageToDict, } from "../review-scope.js";
8
10
  import { resolveChangedFiles } from "../git-scope.js";
9
11
  /** Default round cap when neither the arg nor config provides one. */
10
12
  const DEFAULT_MAX_REVIEW_ROUNDS = 3;
13
+ /**
14
+ * Resolve the effective round cap at the TOOL boundary so `plan` and
15
+ * `aggregate` can never disagree on it (the plan advertises a cap the
16
+ * aggregate must then honor).
17
+ *
18
+ * Precedence: a FINITE caller value → a FINITE config value → the default (3).
19
+ * A non-finite / non-numeric caller value does NOT collapse to 1 — it falls
20
+ * through to the config value (the old doc comment claimed otherwise, which
21
+ * the implementation never did). Only a finite `≤ 0` value collapses to 1,
22
+ * the smallest sane cap.
23
+ *
24
+ * The resolved value is then clamped to `[1, MAX_REVIEW_ROUNDS_CAP]`, so an
25
+ * absurd caller value (1e15) can no longer leak an unbounded cap into the
26
+ * report's `maxReviewRounds` field (`buildReviewReport` echoes its input and
27
+ * `aggregateRounds` sizes `findingsByRound` from it). Upper clamp mirrors
28
+ * `clampMaxReviewRounds` in src/review.ts.
29
+ */
30
+ export function resolveMaxReviewRounds(argsMax, configMax) {
31
+ const raw = typeof argsMax === 'number' && Number.isFinite(argsMax)
32
+ ? argsMax
33
+ : typeof configMax === 'number' && Number.isFinite(configMax)
34
+ ? configMax
35
+ : DEFAULT_MAX_REVIEW_ROUNDS;
36
+ return Math.min(MAX_REVIEW_ROUNDS_CAP, Math.max(1, Math.floor(raw)));
37
+ }
38
+ /**
39
+ * Build the READ set backing the meta-review evidence verdict from the
40
+ * report's self-reported `readFiles` (aggregated across rounds; falls back to
41
+ * the per-round lists for older reports that predate the flat field).
42
+ *
43
+ * Entries are resolved the SAME way `verifyFinding` resolves a finding's
44
+ * `file` (lexical, root-relative) so the Set membership check matches, and
45
+ * entries escaping the project root are dropped. Returns `undefined` when
46
+ * nothing was read — an absent read set keeps `readVerified` "not checkable"
47
+ * (null ratio) instead of a misleading `0.0`.
48
+ */
49
+ function buildReadSet(projectRoot, report) {
50
+ const raw = [];
51
+ if (Array.isArray(report.readFiles)) {
52
+ raw.push(...report.readFiles);
53
+ }
54
+ else if (Array.isArray(report.rounds)) {
55
+ for (const r of report.rounds) {
56
+ const files = r?.readFiles;
57
+ if (Array.isArray(files))
58
+ raw.push(...files);
59
+ }
60
+ }
61
+ const set = new Set();
62
+ for (const p of raw) {
63
+ if (typeof p !== 'string' || p === '')
64
+ continue;
65
+ const abs = resolveWithin(projectRoot, p);
66
+ if (abs !== null)
67
+ set.add(abs);
68
+ }
69
+ return set.size > 0 ? set : undefined;
70
+ }
11
71
  /**
12
72
  * Register the `iterate_review` tool.
13
73
  *
@@ -21,9 +81,73 @@ const DEFAULT_MAX_REVIEW_ROUNDS = 3;
21
81
  * severity sort, and convergence stats; returns a ReviewReport.
22
82
  * Purely computational — NEVER touches the filesystem.
23
83
  */
84
+ /**
85
+ * Card headline for an `aggregate` result: what the report found and where
86
+ * convergence stands (#12). Defensive — returns `undefined` whenever the
87
+ * payload is not the shape this tool renders, so the card is declined rather
88
+ * than guessed at.
89
+ */
90
+ function aggregateHeadline(value) {
91
+ const report = asRecord(value.report);
92
+ const summary = report ? asRecord(report.summary) : undefined;
93
+ const total = summary ? asNumber(summary.totalFindings) : undefined;
94
+ if (total === undefined || !summary)
95
+ return undefined;
96
+ const buckets = ['critical', 'high', 'medium', 'low']
97
+ .map((k) => ({ label: k, n: asNumber(summary[k]) ?? 0 }))
98
+ .filter((b) => b.n > 0)
99
+ .map((b) => `${b.n} ${b.label}`);
100
+ const convergence = report ? asRecord(report.convergence) : undefined;
101
+ // A mid-run aggregate legitimately has converged:false — say so instead of
102
+ // implying the run is over.
103
+ const status = convergence && convergence.converged === true ? 'converged' : 'not converged yet';
104
+ return (`Review report: ${total} finding${total === 1 ? '' : 's'}` +
105
+ (buckets.length > 0 ? ` (${buckets.join(', ')})` : '') +
106
+ ` — ${status}`);
107
+ }
108
+ /**
109
+ * Card headline for a `meta-review` result: the verdict plus what the audit
110
+ * checked (#12). Same defensive contract as {@link aggregateHeadline}.
111
+ */
112
+ function metaReviewHeadline(value) {
113
+ const finalReport = asRecord(value.finalReport);
114
+ if (!finalReport)
115
+ return undefined;
116
+ const verdict = typeof finalReport.verdict === 'string' && finalReport.verdict
117
+ ? finalReport.verdict
118
+ : undefined;
119
+ if (!verdict)
120
+ return undefined;
121
+ const meta = asRecord(finalReport.metaReview);
122
+ const issues = meta && Array.isArray(meta.issues) ? meta.issues.length : undefined;
123
+ const checks = meta ? asNumber(meta.checksRun) : undefined;
124
+ const bits = [
125
+ issues !== undefined ? `${issues} issue${issues === 1 ? '' : 's'}` : '',
126
+ checks !== undefined ? `${checks} check${checks === 1 ? '' : 's'} run` : '',
127
+ ].filter(Boolean);
128
+ return `Meta-review: ${verdict}` + (bits.length > 0 ? ` (${bits.join(', ')})` : '');
129
+ }
24
130
  export function registerReviewTool(ctx) {
25
131
  ctx.tools.register(defineTool({
26
132
  name: 'iterate_review',
133
+ // Result card (#12): aggregate/meta-review results are large raw-JSON
134
+ // bodies — give them a readable headline (finding counts + convergence /
135
+ // verdict) instead of an unsummarized blob. Only these two operations
136
+ // get a card; `plan` keeps the default. Pure: parsed from the rendered
137
+ // result only (replay-safe); a shape miss declines the card.
138
+ presentResult: (args, result) => {
139
+ if (result.isError)
140
+ return undefined;
141
+ const a = args;
142
+ const op = a.operation;
143
+ if (op !== 'aggregate' && op !== 'meta-review')
144
+ return undefined;
145
+ const value = parseRenderedJson(result);
146
+ if (!value)
147
+ return undefined;
148
+ const title = op === 'aggregate' ? aggregateHeadline(value) : metaReviewHeadline(value);
149
+ return title ? { card: 'generic', title } : undefined;
150
+ },
27
151
  description: 'Deterministic review engine for the iterate workflow. ' +
28
152
  'Use `plan` to generate the review plan (dimensions, reviewer prompts, findings schema, round cap) ' +
29
153
  'for normal or dry-run mode. Use `aggregate` to merge raw per-round findings into a deduped, ' +
@@ -128,7 +252,7 @@ export function registerReviewTool(ctx) {
128
252
  const { config } = loadEffectiveConfig(projectRoot);
129
253
  const mode = args.mode ?? 'dry-run';
130
254
  if (args.operation === 'plan') {
131
- const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
255
+ const maxReviewRounds = resolveMaxReviewRounds(args.maxReviewRounds, config.max_rounds);
132
256
  const knownIntentional = config.personalization
133
257
  ?.known_intentional;
134
258
  // changed-only scope: resolve the changed-file set against
@@ -169,7 +293,12 @@ export function registerReviewTool(ctx) {
169
293
  : [];
170
294
  return { round: typeof rr?.round === 'number' ? rr.round : 0, findings, readFiles };
171
295
  })
172
- .filter((r) => r.round > 0);
296
+ // Boundary guard: convergence is keyed on `round - 1`, so a
297
+ // fractional/zero/NaN round (`1.5`, `NaN` — all passable through
298
+ // the `json` argument) must never reach the deterministic core.
299
+ // Mirrors the core's own `Number.isInteger(round) && round >= 1`
300
+ // filter; a non-integer round is simply "not a round" here.
301
+ .filter((r) => Number.isInteger(r.round) && r.round >= 1);
173
302
  if (rounds.length === 0) {
174
303
  return {
175
304
  operation: 'aggregate',
@@ -177,7 +306,7 @@ export function registerReviewTool(ctx) {
177
306
  error: 'rounds must be a non-empty array of {round, findings}.',
178
307
  };
179
308
  }
180
- const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
309
+ const maxReviewRounds = resolveMaxReviewRounds(args.maxReviewRounds, config.max_rounds);
181
310
  const goal = args.goal ?? config.goal ?? '';
182
311
  const dimensions = config.dimensions ?? [];
183
312
  // Output schema validation gate (reviewer.output_schema_validation,
@@ -197,7 +326,18 @@ export function registerReviewTool(ctx) {
197
326
  dimensions,
198
327
  maxReviewRounds,
199
328
  rounds: cleanRounds,
200
- knownIntentional: args.knownIntentional,
329
+ // `type:'json'` passes schema validation for ANY JSON value, so a
330
+ // bare string/object used to reach filterKnownIntentional →
331
+ // `known.some` TypeError that escaped execute. Every sibling json
332
+ // arg is guarded the same way: a non-array means "no known
333
+ // entries" (identical to omitting it). The element shape reuses
334
+ // triage's guard — the same one behind the config-sourced list —
335
+ // so config-provided and arg-provided rows stay interchangeable.
336
+ knownIntentional: Array.isArray(args.knownIntentional)
337
+ ? readKnownIntentional({
338
+ personalization: { known_intentional: args.knownIntentional },
339
+ })
340
+ : undefined,
201
341
  fixedCount: typeof args.fixedCount === 'number' ? args.fixedCount : undefined,
202
342
  });
203
343
  return {
@@ -222,7 +362,18 @@ export function registerReviewTool(ctx) {
222
362
  // verdict. Disable via config `reviewer.evidence_validation: false`.
223
363
  const evidenceEnabled = config.reviewer?.evidence_validation !== false;
224
364
  const findings = Array.isArray(source.findings) ? source.findings : [];
225
- const evidence = evidenceEnabled ? verifyFindings(projectRoot, findings) : null;
365
+ // READ set for the evidence verdict: `readVerified`/`readVerifiedRatio`
366
+ // are only computable when the audit knows which files reviewers
367
+ // actually opened, yet the tool never supplied one — so the skill
368
+ // prompt's "every finding anchors to real, READ code" was
369
+ // unverifiable (always null). The report already aggregates every
370
+ // round's self-reported `readFiles`; wiring it through evidence.ts's
371
+ // existing optional `readSet` needs no core change. An empty read
372
+ // list keeps the "not checkable" (null) semantics instead of 0.0.
373
+ const readSet = buildReadSet(projectRoot, source);
374
+ const evidence = evidenceEnabled
375
+ ? verifyFindings(projectRoot, findings, readSet ? { readSet } : {})
376
+ : null;
226
377
  // Prompt-informative coverage: compare the reviewer's self-reported
227
378
  // reads against the assigned scope inventory (never flips the
228
379
  // verdict). Disable via config `reviewer.coverage_validation: false`.
@@ -9,10 +9,12 @@
9
9
  * "not found" empty view). Used each round by the workflow to
10
10
  * pick up steering nudges, and polled by tool-reading agents.
11
11
  * - `capture` — build a fresh transcript from the review `rounds` + `report`
12
- * and persist it. Called by the canonical scripts after the
13
- * final aggregate so the client always sees the latest run.
14
- * - `nudge` — set (`text`) or clear (`text: null`) steering text persisted
15
- * for the next round's reviewers to read.
12
+ * (plus the decision-log timeline) and persist it. Called by
13
+ * the canonical scripts after the final aggregate so the
14
+ * client always sees the latest run. Skipped entirely when
15
+ * `observatory.capture: false` is configured.
16
+ * - `nudge` — set (`text`) or clear (`text: null` / empty string) steering
17
+ * text persisted for the next round's reviewers to read.
16
18
  *
17
19
  * All writes are persisted to `.iterate/transcript.json` via an atomic
18
20
  * tmp+rename so a crashed writer never leaves a corrupt manifest.
@@ -24,7 +26,8 @@ import { writeTextAtomicAsync } from "../atomic-fs.js";
24
26
  import { dirname } from 'node:path';
25
27
  import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loader.js";
26
28
  import { transcriptPath } from "../paths.js";
27
- import { ReviewTranscriptBuilder } from "../transcript.js";
29
+ import { ReviewTranscriptBuilder, MAX_TIMELINE, MAX_ROUNDS, normalizeManifestBounds } from "../transcript.js";
30
+ import { readDecisionLogDetailed } from "./decision-log.js";
28
31
  import { readLive } from "../live.js";
29
32
  /** Build per-dimension threads for one round from its (dimension-tagged) findings. */
30
33
  function captureRound(builder, round) {
@@ -98,6 +101,43 @@ function normalizeFix(input) {
98
101
  success: f.success !== false,
99
102
  };
100
103
  }
104
+ /**
105
+ * Structural gate for a parsed `.iterate/transcript.json` payload.
106
+ *
107
+ * `JSON.parse` accepts `null`, `[]`, `{}` … — parseable values that are NOT
108
+ * manifests; the read path used to return them as `found: true`. Only a
109
+ * payload carrying the builder's field layout is accepted here (the ROOT
110
+ * gate); row-level bounds and values are then re-normalized on the way out by
111
+ * {@link normalizeManifestBounds}, so an older but still renderable manifest
112
+ * is shown instead of blanked — and a hostile one is bounded instead of
113
+ * passed through.
114
+ *
115
+ * Deliberately does NOT rehydrate through the builder for normalization:
116
+ * `rehydrateBuilder(...).serialize()` drops fields the builder cannot rebuild
117
+ * (notably `phases`), so a plain read would silently rewrite history.
118
+ */
119
+ function isManifestShaped(value) {
120
+ if (!value || typeof value !== 'object' || Array.isArray(value))
121
+ return false;
122
+ const m = value;
123
+ if (typeof m.version !== 'number' || typeof m.project !== 'string' || typeof m.active !== 'boolean') {
124
+ return false;
125
+ }
126
+ if (!Array.isArray(m.rounds) ||
127
+ !Array.isArray(m.convergence) ||
128
+ !Array.isArray(m.findings) ||
129
+ !Array.isArray(m.fixes) ||
130
+ !Array.isArray(m.timeline)) {
131
+ return false;
132
+ }
133
+ // Rows must be objects (round rows with a numeric index): a manifest whose
134
+ // rows are `null`/`"x"` is structurally broken, not merely old.
135
+ return (m.rounds.every((r) => !!r && typeof r === 'object' && typeof r.round === 'number') &&
136
+ m.convergence.every((n) => typeof n === 'number') &&
137
+ m.findings.every((f) => !!f && typeof f === 'object') &&
138
+ m.fixes.every((f) => !!f && typeof f === 'object') &&
139
+ m.timeline.every((e) => !!e && typeof e === 'object'));
140
+ }
101
141
  /** Register the `iterate_transcript` tool. */
102
142
  export function registerTranscriptTool(ctx) {
103
143
  ctx.tools.register(defineTool({
@@ -108,10 +148,13 @@ export function registerTranscriptTool(ctx) {
108
148
  description: 'Runtime-observatory transcript for the iterate workflow. ' +
109
149
  '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
110
150
  'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
111
- 'written for the next round). ' +
112
- '`capture` builds a fresh transcript from the review `rounds` + `report` and persists it ' +
113
- '(call once after the final aggregate so the UI reflects the run). ' +
114
- '`nudge` sets (text) or clears (text:null) steering text the next round\'s reviewers read. ' +
151
+ 'written for the next round) — or, when the file is missing/malformed, a structured ' +
152
+ 'empty view (`found:false` plus a blank manifest). ' +
153
+ '`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
154
+ 'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
155
+ 'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
156
+ 'can be carried on capture via `validations` ({round, command, exitCode, allowed, rejectReason?}). ' +
157
+ '`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
115
158
  'Purely local and deterministic — never touches source files.',
116
159
  parameters: {
117
160
  operation: {
@@ -147,9 +190,26 @@ export function registerTranscriptTool(ctx) {
147
190
  type: 'json',
148
191
  description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
149
192
  },
193
+ validations: {
194
+ type: 'json',
195
+ description: 'For `capture`: per-round validation outcomes [{round, command, exitCode:number|null, allowed:boolean, rejectReason?}] — ' +
196
+ 'the exact commands run by iterate_validate with their exit codes. Malformed rows are dropped, ' +
197
+ 'out-of-range rounds clamped, and the list capped (newest kept). Pass every round\'s validate results so ' +
198
+ 'the run console can show which commands ran and whether they passed.',
199
+ },
150
200
  refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
151
201
  stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
152
- text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
202
+ // `string | null` (NOT plain `type: 'string'`): dsh validates arguments
203
+ // against the compiled schema BEFORE execute, and the docs — plus the
204
+ // client's clear button — advertise `{"operation":"nudge","text":null}`.
205
+ // Under `type: 'string'` that call always failed validation, so the
206
+ // advertised clear flow could never run. Probed against the installed
207
+ // dsh-tools validator: `oneOf[string,null]` accepts null + string and
208
+ // still rejects numbers (`type:'json'` would have accepted those too).
209
+ text: {
210
+ oneOf: [{ type: 'string' }, { type: 'null' }],
211
+ description: 'For `nudge`: steering text to set. `null` (or an empty/whitespace-only string) clears the stored nudge.',
212
+ },
153
213
  path: { type: 'string', description: 'Project root directory (default: current working directory).' },
154
214
  },
155
215
  output: {
@@ -162,6 +222,11 @@ export function registerTranscriptTool(ctx) {
162
222
  transcript: { type: 'json' },
163
223
  live: { type: 'json', description: 'Recent live reviewer-activity entries (newest first).' },
164
224
  updated: { type: 'boolean' },
225
+ skipped: {
226
+ type: 'boolean',
227
+ description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
228
+ },
229
+ reason: { type: 'string', description: 'For a skipped `capture`: why persistence was skipped.' },
165
230
  error: { type: 'string' },
166
231
  },
167
232
  },
@@ -177,34 +242,50 @@ export function registerTranscriptTool(ctx) {
177
242
  const approval = config.observatory?.approval ?? 'ask';
178
243
  if (args.operation === 'read') {
179
244
  const live = await readLive(projectRoot);
180
- if (!existsSync(file)) {
181
- return {
182
- operation: 'read',
183
- found: false,
184
- live: live,
185
- transcript: new ReviewTranscriptBuilder({
186
- project: projectRoot,
187
- approval,
188
- }).serialize(),
189
- };
190
- }
245
+ // Every "no usable manifest" outcome (missing file, unparseable JSON,
246
+ // parseable-but-wrong-shape JSON) returns the SAME structured
247
+ // not-found view: `found:false` plus an empty manifest. The client's
248
+ // F1–F7 tabs read `transcript.<field>` unconditionally, so the old
249
+ // bare `{found:false, error}` parse-error branch blanked the panel.
250
+ const emptyView = () => ({
251
+ operation: 'read',
252
+ found: false,
253
+ live: live,
254
+ transcript: new ReviewTranscriptBuilder({
255
+ project: projectRoot,
256
+ approval,
257
+ }).serialize(),
258
+ });
259
+ if (!existsSync(file))
260
+ return emptyView();
261
+ let parsed;
191
262
  try {
192
- const raw = await readFile(file, 'utf-8');
193
- const parsed = JSON.parse(raw);
194
- return {
195
- operation: 'read',
196
- found: true,
197
- live: live,
198
- transcript: parsed,
199
- };
263
+ parsed = JSON.parse(await readFile(file, 'utf-8'));
200
264
  }
201
265
  catch (err) {
202
266
  return {
203
- operation: 'read',
204
- found: false,
267
+ ...emptyView(),
205
268
  error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
206
269
  };
207
270
  }
271
+ if (!isManifestShaped(parsed)) {
272
+ // `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
273
+ // are parseable but carry no manifest. Surfacing them as
274
+ // `found:true` made the observatory render junk panels.
275
+ return {
276
+ ...emptyView(),
277
+ error: 'Transcript file is not a valid manifest; returning an empty view.',
278
+ };
279
+ }
280
+ return {
281
+ operation: 'read',
282
+ found: true,
283
+ live: live,
284
+ // Defensive normalization (bounded rows + sanitized field values)
285
+ // so a hand-edited/hostile manifest is never handed to the client
286
+ // raw — same normalizers/caps the builder applies at capture time.
287
+ transcript: normalizeManifestBounds(parsed),
288
+ };
208
289
  }
209
290
  if (args.operation === 'nudge') {
210
291
  let manifest = null;
@@ -255,6 +336,18 @@ export function registerTranscriptTool(ctx) {
255
336
  };
256
337
  }
257
338
  // capture
339
+ // `observatory.capture: false` opts the project out of transcript
340
+ // persistence entirely. Only an EXPLICIT false skips: an unreadable or
341
+ // missing config falls back to the defaults (capture on), so a broken
342
+ // config can never silently blind the observatory.
343
+ if (config.observatory?.capture === false) {
344
+ return {
345
+ operation: 'capture',
346
+ updated: false,
347
+ skipped: true,
348
+ reason: 'observatory.capture is disabled',
349
+ };
350
+ }
258
351
  const mode = args.mode === 'normal' ? 'normal' : 'dry-run';
259
352
  const taskMode = args.taskMode === 'code' || args.taskMode === 'iterate' ? args.taskMode : undefined;
260
353
  const goal = typeof args.goal === 'string' ? args.goal : '';
@@ -287,15 +380,45 @@ export function registerTranscriptTool(ctx) {
287
380
  for (const [dim, list] of byDim)
288
381
  builder.reviewerSnapshot(dim, list, readFiles);
289
382
  }
290
- // Convergence series from the report (position per round).
291
- for (let i = 0; i < convergence.length; i += 1) {
383
+ // Convergence series from the report (position per round). The rehydrate
384
+ // path already refuses negative/non-finite counts; capture must not be
385
+ // the asymmetric back door that persists a hostile `-3` into the F2
386
+ // trend (a non-number entry was already skipped; `Number.isFinite`
387
+ // additionally rejects NaN, which `typeof NaN === 'number'` admits).
388
+ // Truncate at MAX_ROUNDS on the FEED side: `snapshotConvergence` clamps
389
+ // an over-cap round to the last slot, so feeding 1000+ entries would
390
+ // silently FOLD every extra round into slot 1000 (one value winning).
391
+ for (let i = 0; i < Math.min(convergence.length, MAX_ROUNDS); i += 1) {
292
392
  const n = convergence[i];
293
- if (typeof n === 'number')
393
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0)
294
394
  builder.snapshotConvergence(i + 1, n);
295
395
  }
296
396
  const roundsExecuted = typeof args.roundsExecuted === 'number' ? Math.floor(args.roundsExecuted) : rounds.length;
397
+ // Advance the round MARKER only — never `roundStart`, which would
398
+ // pre-create phantom EMPTY round rows for rounds we captured no data
399
+ // for (a resumed run reports `roundsExecuted` above the rounds with
400
+ // captured findings). advanceRound also refuses to rewind below the
401
+ // last captured round.
297
402
  if (roundsExecuted > 0)
298
- builder.roundStart(roundsExecuted, maxRounds);
403
+ builder.advanceRound(roundsExecuted, maxRounds);
404
+ // F7 timeline: the decision log is the source of truth for what the run
405
+ // decided, and the tool docs promise a captured `timeline`. Without
406
+ // folding it in, `manifest.timeline` was ALWAYS empty on capture (the
407
+ // builder's decision() ran only during nudge rehydration), so the
408
+ // client's F7 tab rendered nothing even for a run with a full audit
409
+ // trail. Feed only the builder's cap worth — the builder evicts oldest
410
+ // first anyway, and slicing first saves a clone per dropped entry.
411
+ // Fail-safe: an unreadable/corrupt log must never break the capture —
412
+ // the transcript is a UI projection, the log stays the primary record.
413
+ try {
414
+ const { entries } = readDecisionLogDetailed(projectRoot);
415
+ for (const e of entries.slice(-MAX_TIMELINE))
416
+ builder.decision(e);
417
+ }
418
+ catch {
419
+ // readDecisionLogDetailed already skips corrupt lines; this catch
420
+ // covers an unexpected reader failure (unreadable dir, EACCES …).
421
+ }
299
422
  builder.recordCheckpoint(normalizeCheckpoint(args.checkpoint));
300
423
  if (Array.isArray(args.fixes)) {
301
424
  for (const fx of args.fixes) {
@@ -304,6 +427,15 @@ export function registerTranscriptTool(ctx) {
304
427
  builder.fix(record);
305
428
  }
306
429
  }
430
+ // Validation outcomes (contract with the client run console):
431
+ // [{round, command, exitCode, allowed, rejectReason?}]. The builder
432
+ // normalizes every row defensively (bad rows dropped, rounds clamped,
433
+ // capped newest-wins), so a hostile/malformed payload can neither
434
+ // crash the capture nor persist junk.
435
+ if (Array.isArray(args.validations)) {
436
+ for (const v of args.validations)
437
+ builder.validation(v);
438
+ }
307
439
  // End state: an explicit stoppedReason wins; otherwise derive it from
308
440
  // the trend. A run that settled closes as "converged"; a run that did
309
441
  // real work but never trended to 0 closes as "max_rounds_reached";
@@ -364,7 +496,7 @@ function rehydrateBuilder(manifest, approval) {
364
496
  });
365
497
  }
366
498
  }
367
- for (let idx = 0; idx < (manifest.convergence ?? []).length; idx += 1) {
499
+ for (let idx = 0; idx < Math.min((manifest.convergence ?? []).length, MAX_ROUNDS); idx += 1) {
368
500
  const n = manifest.convergence[idx];
369
501
  if (typeof n === 'number' && n >= 0)
370
502
  builder.snapshotConvergence(idx + 1, n);
@@ -374,6 +506,11 @@ function rehydrateBuilder(manifest, approval) {
374
506
  if (Array.isArray(manifest.fixes))
375
507
  for (const fx of manifest.fixes)
376
508
  builder.fix(fx);
509
+ // Validation rows survive a nudge-edit re-persist (same defensive
510
+ // normalization as capture — a hand-edited manifest is re-clamped here).
511
+ if (Array.isArray(manifest.validations))
512
+ for (const v of manifest.validations)
513
+ builder.validation(v);
377
514
  if (Array.isArray(manifest.timeline))
378
515
  for (const e of manifest.timeline)
379
516
  builder.decision(e);