iterate-plugin 2.11.0 → 2.12.1

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.
@@ -56,6 +56,10 @@ export function defaultConfig(): IterateConfig {
56
56
  coverage_validation: true,
57
57
  scope_chunk_size: 25,
58
58
  },
59
+ observatory: {
60
+ capture: true,
61
+ approval: 'ask',
62
+ },
59
63
  }
60
64
  }
61
65
 
package/src/index.ts CHANGED
@@ -2,13 +2,15 @@
2
2
  * iterate-plugin — dsh plugin for the iterate autonomous closed-loop workflow
3
3
  *
4
4
  * Architecture:
5
- * - The plugin registers 13 tools (config, validate, decision-log, context, review,
6
- * triage, fix, diff, rollback, checkpoint, status, history, prune)
5
+ * - The plugin registers 14 tools (config, validate, decision-log, context, review,
6
+ * triage, fix, diff, rollback, checkpoint, status, history, prune, transcript)
7
7
  * - The plugin injects a system prompt section teaching the iterate workflow pattern
8
8
  * - The model (prompted by the skill) writes a workflow script using dsh's `workflow` tool
9
9
  * - The workflow script uses `agent()` / `parallel()` / `phase()` / `log()` to orchestrate
10
- * - Subagents use the 13 tools to do real work (read config, run validation, log decisions,
11
- * review, triage, apply/rollback/fixing, checkpoint, status, history, prune)
10
+ * - Subagents use the 14 tools to do real work (read config, run validation, log decisions,
11
+ * review, triage, apply/rollback/fixing, checkpoint, status, history, prune, transcript)
12
+ * - A `tools/pre-execute` hook gates destructive iterate calls behind human approval
13
+ * (F8 observatory approval policy: ask / deny / allow).
12
14
  *
13
15
  * Tool invocation model:
14
16
  * - Workflow script CANNOT call tools directly (sandboxed vm, no Node API)
@@ -34,13 +36,16 @@ import { registerFixTool, registerDiffTool, registerRollbackTool } from './tools
34
36
  import { registerCheckpointTool, registerStatusTool } from './tools/checkpoint.ts'
35
37
  import { registerHistoryTool } from './tools/history.ts'
36
38
  import { registerPruneTool } from './tools/prune.ts'
39
+ import { registerTranscriptTool } from './tools/transcript.ts'
40
+ import { registerSessionHooks } from './session-hooks.ts'
41
+ import { registerLiveCapture } from './live.ts'
37
42
  import { ITERATE_SKILL_PROMPT } from './skill-prompt.ts'
38
43
 
39
44
  export const name = 'iterate-plugin'
40
- export const inject = ['tools', 'systemPrompt']
45
+ export const inject = ['tools', 'systemPrompt'] as const
41
46
 
42
47
  export function apply(ctx: Context): void {
43
- // 1. Register the 13 tools
48
+ // 1. Register the 14 tools
44
49
  registerConfigTool(ctx)
45
50
  registerValidateTool(ctx)
46
51
  registerDecisionLogTool(ctx)
@@ -54,6 +59,12 @@ export function apply(ctx: Context): void {
54
59
  registerStatusTool(ctx)
55
60
  registerHistoryTool(ctx)
56
61
  registerPruneTool(ctx)
62
+ registerTranscriptTool(ctx)
63
+
64
+ // 2. Wire the observatory approval gate onto dsh's tools/pre-execute waterfall,
65
+ // and the live reviewer-activity feed onto tools/result.
66
+ registerSessionHooks(ctx)
67
+ registerLiveCapture(ctx)
57
68
 
58
69
  // 2. Inject the iterate skill prompt as a system prompt section
59
70
  // This teaches the model how to write iterate workflow scripts using the tools.
package/src/live.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * src/live.ts — live reviewer-activity feed for the iterate observatory (F1 live).
3
+ *
4
+ * Watches `tools/result` and, for tool calls we can attribute to a project root
5
+ * (the caller agent's session cwd), appends one line to an append-only NDJSON
6
+ * file `.iterate/transcript-live.ndjson`. The `iterate_transcript` tool then
7
+ * mixes the most recent entries into its `read` / `capture` results so the
8
+ * client observatory shows what reviewers are doing in near-real-time (which
9
+ * files they read, which fixes/rollbacks/diffs land, where the run is).
10
+ *
11
+ * Why project-scoped (not per-thread):
12
+ * Tool executions carry the calling agent's session cwd but NOT the workflow
13
+ * sub-agent's `dimension` / `round` label, so we cannot reliably attribute a
14
+ * read to a specific reviewer thread without inventing data. We therefore
15
+ * record honest project-level activity and never fabricate an attribution.
16
+ * Per-thread narration stays the job of the final `iterate_transcript capture`.
17
+ *
18
+ * Safety:
19
+ * - Read-only observer: never mutates source files; writes only the NDJSON
20
+ * live file under `.iterate/`.
21
+ * - The live file is byte-capped (rewrite to last N lines when it grows too
22
+ * large) so it can never grow unbounded.
23
+ * - Any capture failure is swallowed (fire-and-forget) so it can never block
24
+ * or crash a tool call.
25
+ */
26
+
27
+ import { mkdir, readFile, writeFile, stat, appendFile, rename } from 'node:fs/promises'
28
+ import { existsSync } from 'node:fs'
29
+ import { join } from 'node:path'
30
+ import type { Context } from '@deepseek-ai/cordis'
31
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
32
+ import { resolveProjectRoot } from './config-loader.ts'
33
+
34
+ /** Keep at most this many live activity entries. */
35
+ export const LIVE_MAX_ENTRIES = 300
36
+ /** Rewrite the live file when its byte size exceeds this threshold. */
37
+ export const LIVE_MAX_BYTES = 64 * 1024
38
+
39
+ /** One live activity record. */
40
+ export interface LiveActivityEntry {
41
+ /** ISO 8601 timestamp of the tool result. */
42
+ ts: string
43
+ /** Coarse category used by the client for coloring/grouping. */
44
+ type:
45
+ | 'read'
46
+ | 'fix'
47
+ | 'rollback'
48
+ | 'diff'
49
+ | 'review'
50
+ | 'triage'
51
+ | 'checkpoint'
52
+ | 'validate'
53
+ | 'log'
54
+ | 'prune'
55
+ | 'info'
56
+ /** The tool that produced the activity. */
57
+ tool: string
58
+ /**
59
+ * The affected target: a source file path (relative to project root) for
60
+ * read/fix/rollback/diff, else a summary string (e.g. the review operation).
61
+ */
62
+ target: string
63
+ }
64
+
65
+ /** File path of the live NDJSON feed for a project root. */
66
+ export function liveFilePath(projectRoot: string): string {
67
+ return join(projectRoot, '.iterate', 'transcript-live.ndjson')
68
+ }
69
+
70
+ /** Resolve the project root a tool execution belongs to, if any. */
71
+ function projectRootOf(exec: ToolExecution): string | null {
72
+ const cwd = exec.agent?.session?.header?.cwd
73
+ if (!cwd) return null
74
+ const resolved = resolveProjectRoot(undefined, cwd)
75
+ return resolved.ok ? resolved.root : null
76
+ }
77
+
78
+ /** Classify a settled tool call into a live activity entry, or null to skip. */
79
+ export function classifyTool(
80
+ name: string,
81
+ args: unknown,
82
+ projectRoot: string,
83
+ ): LiveActivityEntry | null {
84
+ // `read_file` is the dsh-native file reader reviewers use to inspect code.
85
+ if (name === 'read_file') {
86
+ const file =
87
+ args && typeof args === 'object' && typeof (args as Record<string, unknown>).path === 'string'
88
+ ? (args as Record<string, unknown>).path as string
89
+ : ''
90
+ return file ? { ts: new Date().toISOString(), type: 'read', tool: name, target: file } : null
91
+ }
92
+
93
+ // The iterate plugin's own tools — surface what the workflow is doing live.
94
+ const records: Record<string, LiveActivityEntry['type']> = {
95
+ iterate_fix: 'fix',
96
+ iterate_rollback: 'rollback',
97
+ iterate_diff: 'diff',
98
+ iterate_review: 'review',
99
+ iterate_triage: 'triage',
100
+ iterate_checkpoint: 'checkpoint',
101
+ iterate_validate: 'validate',
102
+ iterate_decision_log: 'log',
103
+ iterate_history: 'info',
104
+ iterate_prune: 'prune',
105
+ iterate_transcript: 'log',
106
+ iterate_status: 'info',
107
+ iterate_config: 'info',
108
+ iterate_context: 'info',
109
+ }
110
+ const type = records[name]
111
+ if (!type) return null
112
+
113
+ let target = ''
114
+ if (args && typeof args === 'object') {
115
+ const a = args as Record<string, unknown>
116
+ if (typeof a.file === 'string' && a.file) target = a.file
117
+ else if (typeof a.path === 'string' && a.path) target = a.path
118
+ else if (typeof a.operation === 'string' && a.operation) target = a.operation
119
+ else if (name === 'iterate_rollback' && typeof a.id === 'string' && a.id) {
120
+ target = `fix ${a.id}`
121
+ }
122
+ }
123
+ if (!target) target = name
124
+ return { ts: new Date().toISOString(), type, tool: name, target }
125
+ }
126
+
127
+ /** Append one activity record to the project's live feed (byte-capped). */
128
+ export async function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
129
+ const file = liveFilePath(projectRoot)
130
+ const line = JSON.stringify(entry) + '\n'
131
+ await mkdir(join(projectRoot, '.iterate'), { recursive: true })
132
+ // Amortized O(1): only read+rewrite when the file has grown past the cap.
133
+ try {
134
+ const st = await stat(file).catch(() => null)
135
+ if (st && st.size > LIVE_MAX_BYTES) {
136
+ const raw = await readFile(file, 'utf-8')
137
+ const lines = raw.split('\n').filter(Boolean)
138
+ const tail = lines.slice(-LIVE_MAX_ENTRIES)
139
+ const tmp = `${file}.trim.tmp`
140
+ await writeFile(tmp, tail.join('\n') + '\n', 'utf-8')
141
+ await rename(tmp, file)
142
+ }
143
+ await appendFile(file, line, 'utf-8')
144
+ } catch {
145
+ // Fire-and-forget: never let live capture break a tool call.
146
+ }
147
+ }
148
+
149
+ /** Read the live feed (newest first), capped at the last LIVE_MAX_ENTRIES. */
150
+ export async function readLive(projectRoot: string): Promise<LiveActivityEntry[]> {
151
+ const file = liveFilePath(projectRoot)
152
+ if (!existsSync(file)) return []
153
+ try {
154
+ const raw = await readFile(file, 'utf-8')
155
+ const entries: LiveActivityEntry[] = []
156
+ for (const line of raw.split('\n')) {
157
+ if (!line.trim()) continue
158
+ try {
159
+ const parsed = JSON.parse(line) as LiveActivityEntry
160
+ if (parsed && typeof parsed.ts === 'string' && typeof parsed.type === 'string') {
161
+ entries.push(parsed)
162
+ }
163
+ } catch {
164
+ // skip malformed lines
165
+ }
166
+ }
167
+ return entries.slice(-LIVE_MAX_ENTRIES).reverse()
168
+ } catch {
169
+ return []
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Register a `tools/result` observer that captures reviewer activity into the
175
+ * project's live feed. Fire-and-forget; failures are swallowed.
176
+ */
177
+ export function registerLiveCapture(ctx: Context): void {
178
+ ctx.on('tools/result', (exec: ToolExecution) => {
179
+ const root = projectRootOf(exec)
180
+ if (!root) return
181
+ const entry = classifyTool(exec.name, exec.arguments, root)
182
+ if (!entry) return
183
+ void appendLive(root, entry)
184
+ })
185
+ }
package/src/paths.ts CHANGED
@@ -36,3 +36,8 @@ export function fixBackupPath(projectRoot: string, id: string, timestamp: string
36
36
  export function checkpointPath(projectRoot: string): string {
37
37
  return join(iterateDir(projectRoot), 'checkpoint.json')
38
38
  }
39
+
40
+ /** Runtime-observatory transcript file (JSON). */
41
+ export function transcriptPath(projectRoot: string): string {
42
+ return join(iterateDir(projectRoot), 'transcript.json')
43
+ }
@@ -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')
@@ -99,6 +108,7 @@ for (let r = 1; r <= maxRounds; r++) {
99
108
  ? meta.reviewerPrompt
100
109
  : 'Review dimension "' + dim + '".'
101
110
  const extra =
111
+ (steering ? '\\n STEERING — read this first: ' + steering : '') +
102
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) + '.' : '') +
103
113
  '\\n Already-known findings (do NOT re-report): ' +
104
114
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
@@ -157,6 +167,13 @@ const metaRes = await agent(
157
167
  const finalReport = metaRes && metaRes.finalReport ? metaRes.finalReport : null
158
168
  const metaAudit = finalReport && finalReport.metaReview ? finalReport.metaReview : null
159
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
+
160
177
  return {
161
178
  mode: 'dry-run',
162
179
  goal: report.goal,
@@ -181,7 +198,7 @@ Key rules for dry-run:
181
198
  - Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
182
199
  - The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
183
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.
184
- - 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.
185
202
 
186
203
  ### Normal-mode workflow (autonomous closed loop)
187
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.
@@ -239,6 +256,14 @@ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? che
239
256
  let converged = false
240
257
  let abortedByValidation = false
241
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
242
267
 
243
268
  phase('loop')
244
269
  for (let r = startRound; r <= maxRounds; r++) {
@@ -265,6 +290,7 @@ for (let r = startRound; r <= maxRounds; r++) {
265
290
  ? meta.reviewerPrompt
266
291
  : 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed).'
267
292
  const extra =
293
+ (steering ? '\\n STEERING — read this first: ' + steering : '') +
268
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) + '.' : '') +
269
295
  '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
270
296
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
@@ -309,12 +335,14 @@ for (let r = startRound; r <= maxRounds; r++) {
309
335
  'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
310
336
  'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
311
337
  'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
312
- 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff. ' +
313
- '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).',
314
342
  Object.assign({ label: 'fix:' + file, phase: 'fix', schema: {
315
343
  type: 'object', additionalProperties: false,
316
344
  properties: {
317
- 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'] } }
318
346
  },
319
347
  required: ['fixes'] } }, backend)
320
348
  )))
@@ -322,6 +350,17 @@ for (let r = startRound; r <= maxRounds; r++) {
322
350
  if (res && Array.isArray(res.fixes)) {
323
351
  for (const fx of res.fixes) {
324
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
+ })
325
364
  }
326
365
  }
327
366
  }
@@ -404,6 +443,19 @@ if (!abortedByValidation) {
404
443
  { label: 'checkpoint:clear' }
405
444
  )
406
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
+ )
407
459
  return {
408
460
  mode: 'normal',
409
461
  goal: plan.goal,