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
@@ -7,9 +7,12 @@ import {
7
7
  buildReviewReport,
8
8
  sanitizeRounds,
9
9
  validateRoundsSchema,
10
+ MAX_REVIEW_ROUNDS_CAP,
10
11
  } from '../review.ts'
11
12
  import { buildFinalReviewReport, metaReviewReport } from '../meta-review.ts'
12
- import { evidenceToPlain, verifyFindings } from '../evidence.ts'
13
+ import { evidenceToPlain, resolveWithin, verifyFindings } from '../evidence.ts'
14
+ import { readKnownIntentional } from './triage.ts'
15
+ import { asNumber, asRecord, parseRenderedJson } from './present.ts'
13
16
  import {
14
17
  collectScopeFiles,
15
18
  computeCoverage,
@@ -22,6 +25,63 @@ import type { CoverageResult } from '../review-scope.ts'
22
25
  /** Default round cap when neither the arg nor config provides one. */
23
26
  const DEFAULT_MAX_REVIEW_ROUNDS = 3
24
27
 
28
+ /**
29
+ * Resolve the effective round cap at the TOOL boundary so `plan` and
30
+ * `aggregate` can never disagree on it (the plan advertises a cap the
31
+ * aggregate must then honor).
32
+ *
33
+ * Precedence: a FINITE caller value → a FINITE config value → the default (3).
34
+ * A non-finite / non-numeric caller value does NOT collapse to 1 — it falls
35
+ * through to the config value (the old doc comment claimed otherwise, which
36
+ * the implementation never did). Only a finite `≤ 0` value collapses to 1,
37
+ * the smallest sane cap.
38
+ *
39
+ * The resolved value is then clamped to `[1, MAX_REVIEW_ROUNDS_CAP]`, so an
40
+ * absurd caller value (1e15) can no longer leak an unbounded cap into the
41
+ * report's `maxReviewRounds` field (`buildReviewReport` echoes its input and
42
+ * `aggregateRounds` sizes `findingsByRound` from it). Upper clamp mirrors
43
+ * `clampMaxReviewRounds` in src/review.ts.
44
+ */
45
+ export function resolveMaxReviewRounds(argsMax: unknown, configMax: unknown): number {
46
+ const raw =
47
+ typeof argsMax === 'number' && Number.isFinite(argsMax)
48
+ ? argsMax
49
+ : typeof configMax === 'number' && Number.isFinite(configMax)
50
+ ? configMax
51
+ : DEFAULT_MAX_REVIEW_ROUNDS
52
+ return Math.min(MAX_REVIEW_ROUNDS_CAP, Math.max(1, Math.floor(raw)))
53
+ }
54
+
55
+ /**
56
+ * Build the READ set backing the meta-review evidence verdict from the
57
+ * report's self-reported `readFiles` (aggregated across rounds; falls back to
58
+ * the per-round lists for older reports that predate the flat field).
59
+ *
60
+ * Entries are resolved the SAME way `verifyFinding` resolves a finding's
61
+ * `file` (lexical, root-relative) so the Set membership check matches, and
62
+ * entries escaping the project root are dropped. Returns `undefined` when
63
+ * nothing was read — an absent read set keeps `readVerified` "not checkable"
64
+ * (null ratio) instead of a misleading `0.0`.
65
+ */
66
+ function buildReadSet(projectRoot: string, report: ReviewReport): Set<string> | undefined {
67
+ const raw: unknown[] = []
68
+ if (Array.isArray(report.readFiles)) {
69
+ raw.push(...report.readFiles)
70
+ } else if (Array.isArray(report.rounds)) {
71
+ for (const r of report.rounds) {
72
+ const files = (r as { readFiles?: unknown } | null | undefined)?.readFiles
73
+ if (Array.isArray(files)) raw.push(...files)
74
+ }
75
+ }
76
+ const set = new Set<string>()
77
+ for (const p of raw) {
78
+ if (typeof p !== 'string' || p === '') continue
79
+ const abs = resolveWithin(projectRoot, p)
80
+ if (abs !== null) set.add(abs)
81
+ }
82
+ return set.size > 0 ? set : undefined
83
+ }
84
+
25
85
  /**
26
86
  * Register the `iterate_review` tool.
27
87
  *
@@ -35,10 +95,72 @@ const DEFAULT_MAX_REVIEW_ROUNDS = 3
35
95
  * severity sort, and convergence stats; returns a ReviewReport.
36
96
  * Purely computational — NEVER touches the filesystem.
37
97
  */
98
+ /**
99
+ * Card headline for an `aggregate` result: what the report found and where
100
+ * convergence stands (#12). Defensive — returns `undefined` whenever the
101
+ * payload is not the shape this tool renders, so the card is declined rather
102
+ * than guessed at.
103
+ */
104
+ function aggregateHeadline(value: Record<string, unknown>): string | undefined {
105
+ const report = asRecord(value.report)
106
+ const summary = report ? asRecord(report.summary) : undefined
107
+ const total = summary ? asNumber(summary.totalFindings) : undefined
108
+ if (total === undefined || !summary) return undefined
109
+ const buckets = (['critical', 'high', 'medium', 'low'] as const)
110
+ .map((k) => ({ label: k, n: asNumber(summary[k]) ?? 0 }))
111
+ .filter((b) => b.n > 0)
112
+ .map((b) => `${b.n} ${b.label}`)
113
+ const convergence = report ? asRecord(report.convergence) : undefined
114
+ // A mid-run aggregate legitimately has converged:false — say so instead of
115
+ // implying the run is over.
116
+ const status = convergence && convergence.converged === true ? 'converged' : 'not converged yet'
117
+ return (
118
+ `Review report: ${total} finding${total === 1 ? '' : 's'}` +
119
+ (buckets.length > 0 ? ` (${buckets.join(', ')})` : '') +
120
+ ` — ${status}`
121
+ )
122
+ }
123
+
124
+ /**
125
+ * Card headline for a `meta-review` result: the verdict plus what the audit
126
+ * checked (#12). Same defensive contract as {@link aggregateHeadline}.
127
+ */
128
+ function metaReviewHeadline(value: Record<string, unknown>): string | undefined {
129
+ const finalReport = asRecord(value.finalReport)
130
+ if (!finalReport) return undefined
131
+ const verdict = typeof finalReport.verdict === 'string' && finalReport.verdict
132
+ ? finalReport.verdict
133
+ : undefined
134
+ if (!verdict) return undefined
135
+ const meta = asRecord(finalReport.metaReview)
136
+ const issues = meta && Array.isArray(meta.issues) ? meta.issues.length : undefined
137
+ const checks = meta ? asNumber(meta.checksRun) : undefined
138
+ const bits = [
139
+ issues !== undefined ? `${issues} issue${issues === 1 ? '' : 's'}` : '',
140
+ checks !== undefined ? `${checks} check${checks === 1 ? '' : 's'} run` : '',
141
+ ].filter(Boolean)
142
+ return `Meta-review: ${verdict}` + (bits.length > 0 ? ` (${bits.join(', ')})` : '')
143
+ }
144
+
38
145
  export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
39
146
  ctx.tools.register(
40
147
  defineTool({
41
148
  name: 'iterate_review',
149
+ // Result card (#12): aggregate/meta-review results are large raw-JSON
150
+ // bodies — give them a readable headline (finding counts + convergence /
151
+ // verdict) instead of an unsummarized blob. Only these two operations
152
+ // get a card; `plan` keeps the default. Pure: parsed from the rendered
153
+ // result only (replay-safe); a shape miss declines the card.
154
+ presentResult: (args, result) => {
155
+ if (result.isError) return undefined
156
+ const a = args as { operation?: unknown }
157
+ const op = a.operation
158
+ if (op !== 'aggregate' && op !== 'meta-review') return undefined
159
+ const value = parseRenderedJson(result)
160
+ if (!value) return undefined
161
+ const title = op === 'aggregate' ? aggregateHeadline(value) : metaReviewHeadline(value)
162
+ return title ? { card: 'generic', title } : undefined
163
+ },
42
164
  description:
43
165
  'Deterministic review engine for the iterate workflow. ' +
44
166
  'Use `plan` to generate the review plan (dimensions, reviewer prompts, findings schema, round cap) ' +
@@ -155,7 +277,7 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
155
277
  const mode = args.mode ?? 'dry-run'
156
278
 
157
279
  if (args.operation === 'plan') {
158
- const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS
280
+ const maxReviewRounds = resolveMaxReviewRounds(args.maxReviewRounds, config.max_rounds)
159
281
  const knownIntentional = (config.personalization as { known_intentional?: KnownIntentional[] } | undefined)
160
282
  ?.known_intentional
161
283
  // changed-only scope: resolve the changed-file set against
@@ -200,7 +322,12 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
200
322
  : []
201
323
  return { round: typeof rr?.round === 'number' ? rr.round : 0, findings, readFiles }
202
324
  })
203
- .filter((r: ReviewRound) => r.round > 0)
325
+ // Boundary guard: convergence is keyed on `round - 1`, so a
326
+ // fractional/zero/NaN round (`1.5`, `NaN` — all passable through
327
+ // the `json` argument) must never reach the deterministic core.
328
+ // Mirrors the core's own `Number.isInteger(round) && round >= 1`
329
+ // filter; a non-integer round is simply "not a round" here.
330
+ .filter((r: ReviewRound) => Number.isInteger(r.round) && r.round >= 1)
204
331
 
205
332
  if (rounds.length === 0) {
206
333
  return {
@@ -210,7 +337,7 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
210
337
  }
211
338
  }
212
339
 
213
- const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS
340
+ const maxReviewRounds = resolveMaxReviewRounds(args.maxReviewRounds, config.max_rounds)
214
341
  const goal = args.goal ?? config.goal ?? ''
215
342
  const dimensions = config.dimensions ?? []
216
343
 
@@ -232,7 +359,18 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
232
359
  dimensions,
233
360
  maxReviewRounds,
234
361
  rounds: cleanRounds,
235
- knownIntentional: args.knownIntentional as KnownIntentional[] | undefined,
362
+ // `type:'json'` passes schema validation for ANY JSON value, so a
363
+ // bare string/object used to reach filterKnownIntentional →
364
+ // `known.some` TypeError that escaped execute. Every sibling json
365
+ // arg is guarded the same way: a non-array means "no known
366
+ // entries" (identical to omitting it). The element shape reuses
367
+ // triage's guard — the same one behind the config-sourced list —
368
+ // so config-provided and arg-provided rows stay interchangeable.
369
+ knownIntentional: Array.isArray(args.knownIntentional)
370
+ ? readKnownIntentional({
371
+ personalization: { known_intentional: args.knownIntentional },
372
+ })
373
+ : undefined,
236
374
  fixedCount: typeof args.fixedCount === 'number' ? args.fixedCount : undefined,
237
375
  })
238
376
  return {
@@ -258,7 +396,18 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
258
396
  // verdict. Disable via config `reviewer.evidence_validation: false`.
259
397
  const evidenceEnabled = config.reviewer?.evidence_validation !== false
260
398
  const findings: ReviewFinding[] = Array.isArray(source.findings) ? source.findings : []
261
- const evidence = evidenceEnabled ? verifyFindings(projectRoot, findings) : null
399
+ // READ set for the evidence verdict: `readVerified`/`readVerifiedRatio`
400
+ // are only computable when the audit knows which files reviewers
401
+ // actually opened, yet the tool never supplied one — so the skill
402
+ // prompt's "every finding anchors to real, READ code" was
403
+ // unverifiable (always null). The report already aggregates every
404
+ // round's self-reported `readFiles`; wiring it through evidence.ts's
405
+ // existing optional `readSet` needs no core change. An empty read
406
+ // list keeps the "not checkable" (null) semantics instead of 0.0.
407
+ const readSet = buildReadSet(projectRoot, source)
408
+ const evidence = evidenceEnabled
409
+ ? verifyFindings(projectRoot, findings, readSet ? { readSet } : {})
410
+ : null
262
411
  // Prompt-informative coverage: compare the reviewer's self-reported
263
412
  // reads against the assigned scope inventory (never flips the
264
413
  // 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.
@@ -29,7 +31,8 @@ import {
29
31
  resolveProjectRootForExec,
30
32
  } from '../config-loader.ts'
31
33
  import { transcriptPath } from '../paths.ts'
32
- import { ReviewTranscriptBuilder } from '../transcript.ts'
34
+ import { ReviewTranscriptBuilder, MAX_TIMELINE, MAX_ROUNDS, normalizeManifestBounds } from '../transcript.ts'
35
+ import { readDecisionLogDetailed } from './decision-log.ts'
33
36
  import { readLive } from '../live.ts'
34
37
  import type {
35
38
  TranscriptManifest,
@@ -106,6 +109,47 @@ function normalizeFix(input: unknown): TranscriptFix | null {
106
109
  }
107
110
  }
108
111
 
112
+ /**
113
+ * Structural gate for a parsed `.iterate/transcript.json` payload.
114
+ *
115
+ * `JSON.parse` accepts `null`, `[]`, `{}` … — parseable values that are NOT
116
+ * manifests; the read path used to return them as `found: true`. Only a
117
+ * payload carrying the builder's field layout is accepted here (the ROOT
118
+ * gate); row-level bounds and values are then re-normalized on the way out by
119
+ * {@link normalizeManifestBounds}, so an older but still renderable manifest
120
+ * is shown instead of blanked — and a hostile one is bounded instead of
121
+ * passed through.
122
+ *
123
+ * Deliberately does NOT rehydrate through the builder for normalization:
124
+ * `rehydrateBuilder(...).serialize()` drops fields the builder cannot rebuild
125
+ * (notably `phases`), so a plain read would silently rewrite history.
126
+ */
127
+ function isManifestShaped(value: unknown): value is TranscriptManifest {
128
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
129
+ const m = value as Record<string, unknown>
130
+ if (typeof m.version !== 'number' || typeof m.project !== 'string' || typeof m.active !== 'boolean') {
131
+ return false
132
+ }
133
+ if (
134
+ !Array.isArray(m.rounds) ||
135
+ !Array.isArray(m.convergence) ||
136
+ !Array.isArray(m.findings) ||
137
+ !Array.isArray(m.fixes) ||
138
+ !Array.isArray(m.timeline)
139
+ ) {
140
+ return false
141
+ }
142
+ // Rows must be objects (round rows with a numeric index): a manifest whose
143
+ // rows are `null`/`"x"` is structurally broken, not merely old.
144
+ return (
145
+ m.rounds.every((r) => !!r && typeof r === 'object' && typeof (r as { round?: unknown }).round === 'number') &&
146
+ m.convergence.every((n) => typeof n === 'number') &&
147
+ m.findings.every((f) => !!f && typeof f === 'object') &&
148
+ m.fixes.every((f) => !!f && typeof f === 'object') &&
149
+ m.timeline.every((e) => !!e && typeof e === 'object')
150
+ )
151
+ }
152
+
109
153
  /** Register the `iterate_transcript` tool. */
110
154
  export function registerTranscriptTool(ctx: {
111
155
  tools: { register: (def: ReturnType<typeof defineTool>) => void }
@@ -120,10 +164,13 @@ export function registerTranscriptTool(ctx: {
120
164
  'Runtime-observatory transcript for the iterate workflow. ' +
121
165
  '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
122
166
  'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
123
- 'written for the next round). ' +
124
- '`capture` builds a fresh transcript from the review `rounds` + `report` and persists it ' +
125
- '(call once after the final aggregate so the UI reflects the run). ' +
126
- '`nudge` sets (text) or clears (text:null) steering text the next round\'s reviewers read. ' +
167
+ 'written for the next round) — or, when the file is missing/malformed, a structured ' +
168
+ 'empty view (`found:false` plus a blank manifest). ' +
169
+ '`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
170
+ 'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
171
+ 'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
172
+ 'can be carried on capture via `validations` ({round, command, exitCode, allowed, rejectReason?}). ' +
173
+ '`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
127
174
  'Purely local and deterministic — never touches source files.',
128
175
  parameters: {
129
176
  operation: {
@@ -159,9 +206,27 @@ export function registerTranscriptTool(ctx: {
159
206
  type: 'json',
160
207
  description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
161
208
  },
209
+ validations: {
210
+ type: 'json',
211
+ description:
212
+ 'For `capture`: per-round validation outcomes [{round, command, exitCode:number|null, allowed:boolean, rejectReason?}] — ' +
213
+ 'the exact commands run by iterate_validate with their exit codes. Malformed rows are dropped, ' +
214
+ 'out-of-range rounds clamped, and the list capped (newest kept). Pass every round\'s validate results so ' +
215
+ 'the run console can show which commands ran and whether they passed.',
216
+ },
162
217
  refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
163
218
  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).' },
164
- text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
219
+ // `string | null` (NOT plain `type: 'string'`): dsh validates arguments
220
+ // against the compiled schema BEFORE execute, and the docs — plus the
221
+ // client's clear button — advertise `{"operation":"nudge","text":null}`.
222
+ // Under `type: 'string'` that call always failed validation, so the
223
+ // advertised clear flow could never run. Probed against the installed
224
+ // dsh-tools validator: `oneOf[string,null]` accepts null + string and
225
+ // still rejects numbers (`type:'json'` would have accepted those too).
226
+ text: {
227
+ oneOf: [{ type: 'string' }, { type: 'null' }],
228
+ description: 'For `nudge`: steering text to set. `null` (or an empty/whitespace-only string) clears the stored nudge.',
229
+ },
165
230
  path: { type: 'string', description: 'Project root directory (default: current working directory).' },
166
231
  },
167
232
 
@@ -175,6 +240,11 @@ export function registerTranscriptTool(ctx: {
175
240
  transcript: { type: 'json' },
176
241
  live: { type: 'json', description: 'Recent live reviewer-activity entries (newest first).' },
177
242
  updated: { type: 'boolean' },
243
+ skipped: {
244
+ type: 'boolean',
245
+ description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
246
+ },
247
+ reason: { type: 'string', description: 'For a skipped `capture`: why persistence was skipped.' },
178
248
  error: { type: 'string' },
179
249
  },
180
250
  },
@@ -191,33 +261,53 @@ export function registerTranscriptTool(ctx: {
191
261
 
192
262
  if (args.operation === 'read') {
193
263
  const live = await readLive(projectRoot)
194
- if (!existsSync(file)) {
195
- return {
196
- operation: 'read',
197
- found: false,
198
- live: live as unknown as JsonValue,
199
- transcript: new ReviewTranscriptBuilder({
200
- project: projectRoot,
201
- approval,
202
- }).serialize() as unknown as JsonValue,
203
- }
204
- }
264
+ // Every "no usable manifest" outcome (missing file, unparseable JSON,
265
+ // parseable-but-wrong-shape JSON) returns the SAME structured
266
+ // not-found view: `found:false` plus an empty manifest. The client's
267
+ // F1–F7 tabs read `transcript.<field>` unconditionally, so the old
268
+ // bare `{found:false, error}` parse-error branch blanked the panel.
269
+ const emptyView = (): {
270
+ operation: 'read'
271
+ found: false
272
+ live: JsonValue
273
+ transcript: JsonValue
274
+ } => ({
275
+ operation: 'read',
276
+ found: false,
277
+ live: live as unknown as JsonValue,
278
+ transcript: new ReviewTranscriptBuilder({
279
+ project: projectRoot,
280
+ approval,
281
+ }).serialize() as unknown as JsonValue,
282
+ })
283
+ if (!existsSync(file)) return emptyView()
284
+ let parsed: unknown
205
285
  try {
206
- const raw = await readFile(file, 'utf-8')
207
- const parsed = JSON.parse(raw) as unknown as TranscriptManifest
208
- return {
209
- operation: 'read',
210
- found: true,
211
- live: live as unknown as JsonValue,
212
- transcript: parsed as unknown as JsonValue,
213
- }
286
+ parsed = JSON.parse(await readFile(file, 'utf-8'))
214
287
  } catch (err) {
215
288
  return {
216
- operation: 'read',
217
- found: false,
289
+ ...emptyView(),
218
290
  error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
219
291
  }
220
292
  }
293
+ if (!isManifestShaped(parsed)) {
294
+ // `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
295
+ // are parseable but carry no manifest. Surfacing them as
296
+ // `found:true` made the observatory render junk panels.
297
+ return {
298
+ ...emptyView(),
299
+ error: 'Transcript file is not a valid manifest; returning an empty view.',
300
+ }
301
+ }
302
+ return {
303
+ operation: 'read',
304
+ found: true,
305
+ live: live as unknown as JsonValue,
306
+ // Defensive normalization (bounded rows + sanitized field values)
307
+ // so a hand-edited/hostile manifest is never handed to the client
308
+ // raw — same normalizers/caps the builder applies at capture time.
309
+ transcript: normalizeManifestBounds(parsed) as unknown as JsonValue,
310
+ }
221
311
  }
222
312
 
223
313
  if (args.operation === 'nudge') {
@@ -267,6 +357,18 @@ export function registerTranscriptTool(ctx: {
267
357
  }
268
358
 
269
359
  // capture
360
+ // `observatory.capture: false` opts the project out of transcript
361
+ // persistence entirely. Only an EXPLICIT false skips: an unreadable or
362
+ // missing config falls back to the defaults (capture on), so a broken
363
+ // config can never silently blind the observatory.
364
+ if (config.observatory?.capture === false) {
365
+ return {
366
+ operation: 'capture',
367
+ updated: false,
368
+ skipped: true,
369
+ reason: 'observatory.capture is disabled',
370
+ }
371
+ }
270
372
  const mode = args.mode === 'normal' ? 'normal' : 'dry-run'
271
373
  const taskMode = args.taskMode === 'code' || args.taskMode === 'iterate' ? args.taskMode : undefined
272
374
  const goal = typeof args.goal === 'string' ? args.goal : ''
@@ -301,14 +403,43 @@ export function registerTranscriptTool(ctx: {
301
403
  for (const [dim, list] of byDim) builder.reviewerSnapshot(dim, list, readFiles)
302
404
  }
303
405
 
304
- // Convergence series from the report (position per round).
305
- for (let i = 0; i < convergence.length; i += 1) {
406
+ // Convergence series from the report (position per round). The rehydrate
407
+ // path already refuses negative/non-finite counts; capture must not be
408
+ // the asymmetric back door that persists a hostile `-3` into the F2
409
+ // trend (a non-number entry was already skipped; `Number.isFinite`
410
+ // additionally rejects NaN, which `typeof NaN === 'number'` admits).
411
+ // Truncate at MAX_ROUNDS on the FEED side: `snapshotConvergence` clamps
412
+ // an over-cap round to the last slot, so feeding 1000+ entries would
413
+ // silently FOLD every extra round into slot 1000 (one value winning).
414
+ for (let i = 0; i < Math.min(convergence.length, MAX_ROUNDS); i += 1) {
306
415
  const n = convergence[i]
307
- if (typeof n === 'number') builder.snapshotConvergence(i + 1, n)
416
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0) builder.snapshotConvergence(i + 1, n)
308
417
  }
309
418
  const roundsExecuted =
310
419
  typeof args.roundsExecuted === 'number' ? Math.floor(args.roundsExecuted) : rounds.length
311
- if (roundsExecuted > 0) builder.roundStart(roundsExecuted, maxRounds)
420
+ // Advance the round MARKER only — never `roundStart`, which would
421
+ // pre-create phantom EMPTY round rows for rounds we captured no data
422
+ // for (a resumed run reports `roundsExecuted` above the rounds with
423
+ // captured findings). advanceRound also refuses to rewind below the
424
+ // last captured round.
425
+ if (roundsExecuted > 0) builder.advanceRound(roundsExecuted, maxRounds)
426
+
427
+ // F7 timeline: the decision log is the source of truth for what the run
428
+ // decided, and the tool docs promise a captured `timeline`. Without
429
+ // folding it in, `manifest.timeline` was ALWAYS empty on capture (the
430
+ // builder's decision() ran only during nudge rehydration), so the
431
+ // client's F7 tab rendered nothing even for a run with a full audit
432
+ // trail. Feed only the builder's cap worth — the builder evicts oldest
433
+ // first anyway, and slicing first saves a clone per dropped entry.
434
+ // Fail-safe: an unreadable/corrupt log must never break the capture —
435
+ // the transcript is a UI projection, the log stays the primary record.
436
+ try {
437
+ const { entries } = readDecisionLogDetailed(projectRoot)
438
+ for (const e of entries.slice(-MAX_TIMELINE)) builder.decision(e)
439
+ } catch {
440
+ // readDecisionLogDetailed already skips corrupt lines; this catch
441
+ // covers an unexpected reader failure (unreadable dir, EACCES …).
442
+ }
312
443
 
313
444
  builder.recordCheckpoint(normalizeCheckpoint(args.checkpoint))
314
445
  if (Array.isArray(args.fixes)) {
@@ -317,6 +448,14 @@ export function registerTranscriptTool(ctx: {
317
448
  if (record) builder.fix(record)
318
449
  }
319
450
  }
451
+ // Validation outcomes (contract with the client run console):
452
+ // [{round, command, exitCode, allowed, rejectReason?}]. The builder
453
+ // normalizes every row defensively (bad rows dropped, rounds clamped,
454
+ // capped newest-wins), so a hostile/malformed payload can neither
455
+ // crash the capture nor persist junk.
456
+ if (Array.isArray(args.validations)) {
457
+ for (const v of args.validations) builder.validation(v)
458
+ }
320
459
  // End state: an explicit stoppedReason wins; otherwise derive it from
321
460
  // the trend. A run that settled closes as "converged"; a run that did
322
461
  // real work but never trended to 0 closes as "max_rounds_reached";
@@ -377,12 +516,15 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
377
516
  })
378
517
  }
379
518
  }
380
- for (let idx = 0; idx < (manifest.convergence ?? []).length; idx += 1) {
519
+ for (let idx = 0; idx < Math.min((manifest.convergence ?? []).length, MAX_ROUNDS); idx += 1) {
381
520
  const n = manifest.convergence[idx]
382
521
  if (typeof n === 'number' && n >= 0) builder.snapshotConvergence(idx + 1, n)
383
522
  }
384
523
  if (manifest.checkpoint) builder.recordCheckpoint(manifest.checkpoint)
385
524
  if (Array.isArray(manifest.fixes)) for (const fx of manifest.fixes) builder.fix(fx as TranscriptFix)
525
+ // Validation rows survive a nudge-edit re-persist (same defensive
526
+ // normalization as capture — a hand-edited manifest is re-clamped here).
527
+ if (Array.isArray(manifest.validations)) for (const v of manifest.validations) builder.validation(v)
386
528
  if (Array.isArray(manifest.timeline)) for (const e of manifest.timeline) builder.decision(e)
387
529
  builder.setNudge(manifest.nudge?.text ?? null)
388
530
  if (!manifest.active) builder.finish(manifest.stoppedReason ?? undefined)