thincoder 0.12.11 → 0.12.13

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.
@@ -1,27 +1,38 @@
1
1
  /**
2
2
  * advisor/run.mjs — advisor execution: tool loop, provider resolution, and the review entry point.
3
- * Message building lives in advisor.mjs; git collection in advisor/repos.mjs.
3
+ * Message building lives in advisor.mjs.
4
4
  */
5
5
  import { chat } from "../provider/core.mjs"
6
- import { findProvider } from "../config.mjs"
6
+ import { findProvider, specForModel } from "../config.mjs"
7
7
  import { toOpenAISchema } from "../tools/index.mjs"
8
8
  import { prepareAdvisorMessages } from "../advisor.mjs"
9
- import { extractPriorIssueTable } from "../advisor/history.mjs"
9
+ import { appendCitationReport } from "./citations.mjs"
10
10
 
11
11
  const MAX_ADVISOR_TURNS = 100
12
12
  // Mechanical convergence cap: the protocol assumes up to 5 rounds suffice
13
13
  // (full review, verify+fix cycles, strict verification). A 6th call means the
14
14
  // model is looping — refuse it instead of burning tokens on a review that cannot
15
- // converge. Design reviews are exempt (each call resets the round).
16
- // NOTE: prompts/advisor-round{1,2,3}.md advertise a 30-round BUDGET the
17
- // prompt-level efficiency target, distinct from this 100-round mechanical hard
18
- // cap (loop guard). Keep both in sync when either changes.
15
+ // converge. Code AND design reviews share the 5-round budget (each advances
16
+ // _advisorRound in agent.mjs; the cap no longer exempts design).
19
17
  export const MAX_ADVISOR_ROUNDS = 5
20
18
 
19
+ // NOTE: prompts/advisor-round{1,2,3}.md encourage the model to finish within
20
+ // ~30 tool turns — a prompt-level efficiency target, DISTINCT from the
21
+ // 100-turn mechanical hard cap (MAX_ADVISOR_TURNS above; pure runaway-loop
22
+ // guard). They serve different purposes; do NOT synchronize them.
23
+
24
+ // The live "[thinking…]" wait indicator shares its exact text with the TUI
25
+ // cleanup regex (agent-turn.mjs strips it before flushing to history) — keep
26
+ // them in lockstep.
27
+ export const ADVISOR_THINKING_PLACEHOLDER = "\n[thinking…]\n"
28
+
21
29
  // Context window limits
22
- const MAX_CONTEXT_TOKENS = 120_000 // 预留 headroom,避免 OOM
23
- const TOOL_TIMEOUT_MS = 30_000 // 单个工具 30
24
- const REVIEW_TIMEOUT_MS = 300_000 // 整个审查 5 分钟
30
+ const MAX_CONTEXT_TOKENS = 120_000 // Reserve headroom to avoid OOM
31
+ const TOOL_TIMEOUT_MS = 30_000 // single tool timeout
32
+ const REVIEW_TIMEOUT_MS = 300_000 // whole review timeout
33
+ const MAX_RESULT_CHARS = 12_000 // tool result truncation (line-aware)
34
+ const MAX_UNFIXED_DISPLAY = 10 // unfixed issues shown in the cap message
35
+ const MAX_KEY_FILES_IN_COMPACTION = 5 // files named in the compaction summary
25
36
 
26
37
  /** Estimate token count from messages (rough: 1 token ≈ 4 chars) */
27
38
  function estimateTokens(messages) {
@@ -32,61 +43,87 @@ function estimateTokens(messages) {
32
43
  }, 0)
33
44
  }
34
45
 
35
- /** Compact early messages when context grows too large */
36
- async function compactMessages(messages, provider) {
37
- // Keep: system prompt, last 10 assistant+tool pairs, user message
38
- if (messages.length <= 20) return messages
39
-
46
+ /** Compact early messages when context grows too large — LOCAL trimming only
47
+ * (no LLM summarization). MUTATES in place (splice) so the caller's array
48
+ * reference stays valid a reassignment would leave the caller's logging
49
+ * (tool-call count, token estimate) reading a stale array. */
50
+ function compactMessages(messages) {
51
+ // Keep: system prompt, last 20 messages (≈ 10 assistant+tool exchanges),
52
+ // user message — the rest is summarized.
53
+ if (messages.length <= 20) return
54
+
40
55
  const system = messages[0]
41
56
  const recent = messages.slice(-20)
42
57
  const old = messages.slice(1, -20)
43
-
44
- // Summarize old messages
45
- const summary = `Earlier exploration: ${old.length} tool calls completed. Key files examined: ${
46
- old
47
- .filter((m) => m.role === "tool")
48
- .map((m) => m.content?.split("\n")[0]?.slice(0, 50))
49
- .filter(Boolean)
50
- .slice(0, 5)
51
- .join(", ")
52
- }`
53
-
54
- return [system, { role: "user", content: `[Context compacted] ${summary}` }, ...recent]
58
+
59
+ // Count actual tool messages (old.length counts user/assistant rows too)
60
+ const toolCount = old.filter((m) => m.role === "tool").length
61
+ const keyFiles = old
62
+ .filter((m) => m.role === "tool")
63
+ .map((m) => m.content?.split("\n")[0]?.slice(0, 50)) // first line of tool results typically names the file that was read/grepped
64
+ .filter(Boolean)
65
+ .slice(0, MAX_KEY_FILES_IN_COMPACTION)
66
+ const filesPart = keyFiles.length > 0 ? ` Key files examined: ${keyFiles.join(", ")}` : ""
67
+ const summary = `Earlier exploration: ${toolCount} tool calls completed.${filesPart}`
68
+
69
+ messages.splice(0, messages.length,
70
+ system,
71
+ { role: "user", content: `[Context compacted] ${summary}` },
72
+ ...recent)
55
73
  }
56
74
 
57
- const { gitTool, readTool, globTool, grepTool, lsTool } = await import("../tools/index.mjs")
75
+ const { readTool, globTool, grepTool, lsTool } = await import("../tools/index.mjs")
58
76
  const { lspTool } = await import("../tools/lsp.mjs")
59
- const { codeModeTool: codeSearchTool } = await import("../tools/codemode.mjs")
77
+ const { codeSearchTool } = await import("../memory/code-sync.mjs")
60
78
 
61
- /** Restricted git tool: diff / status / log only.
62
- * Checkpoint create/rewind are blockedthe advisor must not mutate state. */
63
- const advisorGitTool = {
64
- ...gitTool,
65
- readonly: true,
66
- async execute(args, ctx) {
67
- if (args.action === "checkpoint") {
68
- if (args.checkpointAction === "create" || args.checkpointAction === "rewind") {
69
- return "Error: checkpoint create/rewind is disabled in advisor mode. Use diff/status/log only."
70
- }
71
- }
72
- return gitTool.execute(args, ctx)
73
- },
79
+ /**
80
+ * Advisor tool setZERO git, read-only ONLY, every round. The change surface
81
+ * comes from the review scope (paths / _touchedFiles injected by the caller),
82
+ * never from git: git output misled reviews (committed fixes never show in
83
+ * `git diff HEAD`, so "no changes" was read as "not fixed") and the user
84
+ * mandate is full decoupling (7d49a52 + d3be613). The reviewer reads files
85
+ * and searches code; it never touches git and never writes.
86
+ * No round parameter the set is constant across all rounds.
87
+ * @param {Object} agent only used for the code index (agent.memory); the
88
+ * semantic code_search tool needs it. Without a memory, the set is 5 tools.
89
+ */
90
+ function advisorToolsFor(agent) {
91
+ const search = agent?.memory ? codeSearchTool(agent.memory) : null
92
+ const tools = search
93
+ ? [readTool, globTool, grepTool, lsTool, lspTool, search]
94
+ : [readTool, globTool, grepTool, lsTool, lspTool]
95
+ return { schemas: tools.map(toOpenAISchema), byName: new Map(tools.map((t) => [t.name, t])) }
74
96
  }
75
-
76
- const ADVISOR_TOOLS = [readTool, globTool, grepTool, lsTool, advisorGitTool, lspTool, codeSearchTool]
77
- const ADVISOR_TOOL_SCHEMAS = ADVISOR_TOOLS.map(toOpenAISchema)
78
- const ADVISOR_TOOL_BY_NAME = new Map(ADVISOR_TOOLS.map((t) => [t.name, t]))
97
+ // Test seam: the tool set is pure (agent.memory → code_search inclusion).
98
+ export { advisorToolsFor as _advisorToolsFor }
79
99
 
80
100
  /** Compact one-line summary of tool args for panel progress lines.
81
101
  * Picks the most identifying field; falls back to truncated JSON. */
82
102
  function summarizeToolArgs(args) {
83
- // e.g. "git diff HEAD", "read src/x.mjs" — action first when present
103
+ // e.g. "read src/x.mjs", "grep foo src/", "ls docs" — action first when present
84
104
  const parts = [args.action, args.path ?? args.pattern ?? args.command].filter((v) => v != null)
85
105
  let s = parts.length > 0 ? parts.map(String).join(" ") : JSON.stringify(args)
86
106
  s = s.replace(/\s+/g, " ").trim()
87
107
  return s.length > 80 ? s.slice(0, 79) + "…" : s
88
108
  }
89
109
 
110
+ /**
111
+ * Render the ordered review timeline — thinking / tool progress / final text
112
+ * interleaved EXACTLY as emitted, so the persisted record shows the review
113
+ * process at its real positions. A summary appended at the end would lose the
114
+ * order (the user-visible "no tool calls in the advisor record" gap). The
115
+ * live "[thinking…]" placeholder is stripped (wait indicator, not content).
116
+ */
117
+ function renderTimeline(timeline, tail = "") {
118
+ const body = timeline
119
+ .map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, "").trim())
120
+ .filter(Boolean)
121
+ .join("\n\n")
122
+ return [body, tail].filter(Boolean).join("\n\n")
123
+ }
124
+ // Test seam (mirrors _advisorToolsFor).
125
+ export { renderTimeline as _renderTimeline }
126
+
90
127
  /**
91
128
  * Run the advisor's tool loop: chat → execute tools → repeat.
92
129
  * Stops when the model produces text without tool calls.
@@ -97,50 +134,85 @@ function summarizeToolArgs(args) {
97
134
  */
98
135
  async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd) {
99
136
  // Kind-tagged wrappers: the TUI panel colors reasoning / answer / tool progress differently.
100
- const emit = (kind) => (onOutput ? (text) => onOutput({ kind, text }) : undefined)
137
+ // Every chunk is ALSO recorded into an ordered timeline the persisted record
138
+ // must show the review process (thinking ↔ tool progress ↔ final text) at its
139
+ // real positions, not a summary appended at the end. Same-kind consecutive
140
+ // chunks merge (token streams); kind flips start a new entry.
141
+ const timeline = []
142
+ const record = (kind, text) => {
143
+ const last = timeline.at(-1)
144
+ if (last && last.kind === kind) last.text += text
145
+ else timeline.push({ kind, text })
146
+ }
147
+ const emit = (kind) => (text) => { record(kind, text); onOutput?.({ kind, text }) }
101
148
  const onThink = emit("think")
102
149
  const onText = emit("text")
150
+ const onTool = emit("tool")
151
+ const { schemas: toolSchemas, byName: toolByName } = advisorToolsFor(agent)
103
152
  let turns = 0
104
153
  const startTime = Date.now()
105
154
 
106
155
  while (true) {
107
156
  // Interrupted (Ctrl+I) — stop immediately instead of spinning a fresh uncancellable signal
108
- if (signal?.aborted) return "Advisor: interrupted."
157
+ if (signal?.aborted) return renderTimeline(timeline, "Advisor: interrupted.")
109
158
 
110
159
  // Check review timeout (5 minutes)
111
160
  if (Date.now() - startTime > REVIEW_TIMEOUT_MS) {
112
- return `Advisor: review timeout after ${Math.round(REVIEW_TIMEOUT_MS / 1000)}s. Partial results may be available. Try again with a narrower scope.`
161
+ return renderTimeline(timeline, `Advisor: review timeout after ${Math.round(REVIEW_TIMEOUT_MS / 1000)}s. Partial results may be available. Try again with a narrower scope.`)
113
162
  }
114
163
 
115
164
  if (++turns > MAX_ADVISOR_TURNS) {
116
- return "Advisor: stopped after " + MAX_ADVISOR_TURNS + " tool rounds — the review appears to be looping. You may retry with a narrower scope."
165
+ return renderTimeline(timeline, "Advisor: stopped after " + MAX_ADVISOR_TURNS + " tool rounds — the review appears to be looping. You may retry with a narrower scope.")
117
166
  }
118
167
 
119
168
  // Check context window and compact if needed
120
169
  const currentTokens = estimateTokens(messages)
121
170
  if (currentTokens > MAX_CONTEXT_TOKENS * 0.8) {
122
- onOutput?.({ kind: "text", text: `\n[Context compacted: ${currentTokens} tokens → reducing to fit window]\n` })
123
- messages = await compactMessages(messages, provider)
171
+ onText(`\n[Context compacted: ${currentTokens} tokens → reducing to fit window]\n`)
172
+ compactMessages(messages)
124
173
  if (estimateTokens(messages) > MAX_CONTEXT_TOKENS) {
125
- return `Advisor: context window limit reached (${currentTokens} tokens). Review incomplete too many tool calls. Try a narrower scope.`
174
+ // Report the POST-compaction countthe pre-compaction currentTokens
175
+ // is stale by the time compaction has run.
176
+ return renderTimeline(timeline, `Advisor: context window limit reached (${estimateTokens(messages)} tokens). Review incomplete — too many tool calls. Try a narrower scope.`)
126
177
  }
127
178
  }
128
179
 
180
+ // LLM generation silence: the reasoning phase produces no SSE bytes for
181
+ // seconds to tens of seconds (server-side prefill on large contexts, per
182
+ // tool-round LLM return). A placeholder keeps the panel visibly working.
183
+ // kind "think" (NOT "text"): the placeholder must land in the SAME buffer
184
+ // and position as the upcoming reasoning — a "text"-kind placeholder
185
+ // rendered BELOW the think block, and the reasoning stream appeared ABOVE
186
+ // it ("the stream runs back to the front"). Same buffer = same spot; the
187
+ // reasoning continues right where the placeholder sits.
188
+ onOutput?.({ kind: "think", text: ADVISOR_THINKING_PLACEHOLDER })
189
+
129
190
  const response = await chat(provider, {
130
191
  messages,
131
- tools: ADVISOR_TOOL_SCHEMAS,
132
- signal: (signal && !signal.aborted) ? signal : null,
192
+ tools: toolSchemas,
193
+ // Pass the signal UNCONDITIONALLY: a signal aborted between the check
194
+ // above and here must still cancel the fetch. core.mjs composes
195
+ // AbortSignal.any([signal, timeout]) — an already-aborted signal makes
196
+ // the request fail immediately instead of ignoring the interrupt.
197
+ signal: signal ?? null,
133
198
  onToken: onText,
134
199
  onReasoning: onThink,
135
200
  })
136
201
 
137
- // No tool calls — this is the final review text
202
+ // No tool calls — this is the final review text. The final answer was
203
+ // already streamed into the timeline via onText; fall back to
204
+ // response.content only if nothing was recorded.
138
205
  if (!response.toolCalls?.length) {
139
- if (!response.content?.trim()) return "Advisor: (empty response — review was inconclusive)"
140
- return response.content.trim()
206
+ if (!response.content?.trim()) return renderTimeline(timeline) || "Advisor: (empty response — review was inconclusive)"
207
+ return renderTimeline(timeline) || response.content.trim()
141
208
  }
142
209
 
143
- // Push assistant message with tool calls
210
+ // Push assistant message with tool calls. reasoning_content ECHO is
211
+ // mandatory for reasoningEcho:"required" providers (deepseek/kimi): the
212
+ // server stops returning reasoning_content on later rounds when the
213
+ // tool-call assistant history lacks it — the observed "reasoning stops
214
+ // after the first tool call, returns only at the final answer" symptom.
215
+ // Mirrors the main agent's push (agent.mjs).
144
216
  messages.push({
145
217
  role: "assistant",
146
218
  content: response.content || null,
@@ -148,11 +220,14 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
148
220
  id: tc.id, type: "function",
149
221
  function: { name: tc.name, arguments: tc.arguments },
150
222
  })),
223
+ ...(response.reasoning && specForModel(provider.model).reasoningEcho === "required"
224
+ ? { reasoning_content: response.reasoning }
225
+ : {}),
151
226
  })
152
227
 
153
228
  // Execute each tool call
154
229
  for (const tc of response.toolCalls) {
155
- const tool = ADVISOR_TOOL_BY_NAME.get(tc.name)
230
+ const tool = toolByName.get(tc.name)
156
231
  let args = {}
157
232
  let parseError = null
158
233
  try {
@@ -167,10 +242,10 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
167
242
  continue
168
243
  }
169
244
 
170
- onOutput?.({ kind: "tool", text: `\n→ ${tc.name} ${summarizeToolArgs(args)}\n` })
245
+ onTool(`\n→ ${tc.name} ${summarizeToolArgs(args)}\n`)
171
246
  let result
172
247
  if (!tool) {
173
- result = `Error: unknown tool "${tc.name}". Available: ${[...ADVISOR_TOOL_BY_NAME.keys()].join(", ")}`
248
+ result = `Error: unknown tool "${tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
174
249
  } else {
175
250
  // Execute with timeout (clear the timer when the tool wins the race —
176
251
  // otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
@@ -179,13 +254,17 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
179
254
  const timeoutPromise = new Promise((_, reject) => {
180
255
  timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
181
256
  })
257
+ let toolPromise
182
258
  try {
183
- result = await Promise.race([
184
- tool.execute(args, { cwd, agent, onOutput, signal }),
185
- timeoutPromise,
186
- ])
259
+ toolPromise = tool.execute(args, { cwd, agent, onOutput, signal })
260
+ result = await Promise.race([toolPromise, timeoutPromise])
187
261
  } finally {
188
262
  clearTimeout(timeoutId)
263
+ // Timeout won → toolPromise is still pending; a later rejection
264
+ // would surface as an unhandled rejection. The race already
265
+ // consumed the result/error in the normal path, so this no-op
266
+ // catch only fires for the abandoned-tool case.
267
+ toolPromise?.catch(() => {})
189
268
  }
190
269
  } catch (e) {
191
270
  const errorType = e.message.includes("timeout") ? "timeout"
@@ -198,7 +277,6 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
198
277
  if (typeof result !== "string") result = JSON.stringify(result)
199
278
 
200
279
  // Line-aware truncation: preserve line integrity
201
- const MAX_RESULT_CHARS = 12_000
202
280
  if (result.length > MAX_RESULT_CHARS) {
203
281
  const lines = result.split("\n")
204
282
  let truncated = ""
@@ -217,7 +295,7 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
217
295
  result = (
218
296
  truncated +
219
297
  `\n… (truncated: ${remainingLines} more lines, ${result.length} chars total)\n` +
220
- `To see more content, use: read(path, offset=${keptLines}, limit=200)`
298
+ `To see more content, use: read(path, offset=${keptLines + 1}, limit=200)`
221
299
  )
222
300
  }
223
301
 
@@ -227,13 +305,13 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
227
305
  }
228
306
 
229
307
  /** Resolve the advisor's provider: cfg.provider/model when set, otherwise the main agent's provider */
230
- function resolveAdvisorProvider(agent) {
308
+ export function resolveAdvisorProvider(agent) {
231
309
  const cfg = agent.config?.advisor
232
310
  if (cfg?.provider) {
233
311
  try {
234
312
  const provider = findProvider(agent.providers ?? [agent.provider], cfg.provider)
235
313
  const result = cfg.model ? { ...provider, model: cfg.model } : { ...provider }
236
- if (cfg.thinking === null) result.thinking = undefined // explicitly off
314
+ if (cfg.thinking === null || cfg.thinking === false) result.thinking = undefined // explicitly off
237
315
  else if (cfg.thinking !== undefined) result.thinking = cfg.thinking
238
316
  if (cfg.reasoningEffort !== undefined) result.reasoningEffort = cfg.reasoningEffort
239
317
  return result
@@ -244,24 +322,35 @@ function resolveAdvisorProvider(agent) {
244
322
  }
245
323
  const provider = { ...agent.provider }
246
324
  if (cfg?.model) provider.model = cfg.model
247
- if (cfg?.thinking === null) provider.thinking = undefined // explicitly off
325
+ // thinking off: null AND false both mean "explicitly off" — a raw `false`
326
+ // value is invalid for providers that expect undefined or an object.
327
+ if (cfg?.thinking === null || cfg?.thinking === false) provider.thinking = undefined
248
328
  else if (cfg?.thinking !== undefined) provider.thinking = cfg.thinking
249
329
  if (cfg?.reasoningEffort !== undefined) provider.reasoningEffort = cfg.reasoningEffort
250
330
  return provider
251
331
  }
252
332
 
253
333
  /**
254
- * Extract unfixed issues from prior review text
334
+ * Extract unfixed issues from prior review text (for the cap message).
335
+ * Input: an advisor review markdown table (`| # | … |` rows). A row counts as
336
+ * unfixed unless its line carries a resolved-status word (fixed/resolved/done/
337
+ * addressed/corrected, ✓/✔). Returns at most MAX_UNFIXED_DISPLAY plain
338
+ * (pipe-stripped) row strings.
255
339
  */
256
340
  function extractUnfixedIssues(priorText) {
257
341
  if (!priorText) return []
258
342
  const lines = priorText.split("\n")
343
+ // Resolved-status words: fixed/resolved/done/addressed/corrected (+ ✓/✔).
344
+ // \b prevents "unfixed"/"prefixed" from matching "fixed".
345
+ const resolvedRe = /\b(?:fixed|resolved|done|addressed|corrected)\b|✓|✔/i
259
346
  return lines
260
347
  .filter((line) => /\|\s*\d+\s*\|/.test(line)) // 匹配表格行
261
- .filter((line) => !/fixed|resolved|done|✓|✔/i.test(line))
262
- .map((line) => line.replace(/\|/g, "").trim())
348
+ .filter((line) => !resolvedRe.test(line))
349
+ // Strip only the leading/trailing table pipes — inner pipes (escaped or
350
+ // in-cell content) stay intact instead of garbling the cap message.
351
+ .map((line) => line.trim().replace(/^\|/, "").replace(/\|$/, "").trim())
263
352
  .filter(Boolean)
264
- .slice(0, 10) // 最多显示 10 个
353
+ .slice(0, MAX_UNFIXED_DISPLAY)
265
354
  }
266
355
 
267
356
  /**
@@ -282,11 +371,14 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
282
371
 
283
372
  // Mechanical convergence cap — refuse further reviews once the protocol has run
284
373
  // its rounds. _advisorRound counts completed advisor calls (incremented by the
285
- // agent after each one), so >= MAX_ADVISOR_ROUNDS blocks the next call.
286
- if (reviewType !== "design" && (agent._advisorRound || 0) >= MAX_ADVISOR_ROUNDS) {
287
- // 提取未解决的问题,给出更具体的指导
288
- const prior = extractPriorIssueTable(agent.history)
289
- const unfixed = prior ? extractUnfixedIssues(prior.text) : []
374
+ // agent after each one — code AND design reviews alike), so >= MAX_ADVISOR_ROUNDS
375
+ // blocks the next call. 5 rounds max; after that the review is never pushed back
376
+ // (the caller decides: accept, manual re-check, or /new to reset).
377
+ if ((agent._advisorRound || 0) >= MAX_ADVISOR_ROUNDS) {
378
+ // Summarize unresolved items from the last review output for guidance
379
+ // (line-level status-word scan — no table-header parsing, decision 2026-08-08).
380
+ const prior = agent._lastAdvisorOutput
381
+ const unfixed = prior ? extractUnfixedIssues(prior) : []
290
382
 
291
383
  let message = `Advisor: convergence cap reached after ${MAX_ADVISOR_ROUNDS} rounds.\n`
292
384
  if (unfixed.length > 0) {
@@ -307,7 +399,30 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
307
399
 
308
400
  try {
309
401
  const result = await runAdvisorToolLoop(provider, messages, onOutput, signal, agent, advisorCwd)
310
-
402
+
403
+ // Host-verified citations (decision d698434): mechanically check every
404
+ // `file:line: content` reference in the review against the CURRENT file
405
+ // state. LLMs cannot self-enforce the evidence rule — the model may quote
406
+ // the prior table instead of re-reading (three consecutive false reports
407
+ // cited pre-fix line content). Unverified citations must not support a
408
+ // push-back; the parent agent sees the verification report.
409
+ let final = result
410
+ if (!result.trimStart().startsWith("Advisor:")) {
411
+ final = appendCitationReport(result, advisorCwd)
412
+ // Success path: keep the FULL review output for convergence rounds —
413
+ // round 2+ injects this verbatim and the model understands it (decision
414
+ // 2026-08-08: prior-table hard parsing removed; no phrase/header matching).
415
+ // Guard: only store outputs that actually carry a review — a markdown
416
+ // table row (`| a | b | c |`) or substantial prose (>200 chars). An
417
+ // empty or tool-progress-only reply must not become the "prior review"
418
+ // of round 2+.
419
+ const trimmed = final.trim()
420
+ const looksLikeReview = /\|.*\|.*\|/.test(trimmed) || trimmed.length >= 200
421
+ if (looksLikeReview) {
422
+ agent._lastAdvisorOutput = final
423
+ }
424
+ }
425
+
311
426
  // Log review statistics for observability
312
427
  const elapsed = Math.round((Date.now() - startTime) / 1000)
313
428
  const toolCallCount = messages.filter((m) => m.role === "tool").length
@@ -316,18 +431,7 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
316
431
  kind: "text",
317
432
  text: `\n[advisor] Review completed: ${elapsed}s, ${toolCallCount} tool calls, ~${Math.round(tokensUsed / 1000)}k tokens\n`,
318
433
  })
319
-
320
- // Only persist the session on success — timeout/interrupt/empty results
321
- // would poison the next review call (the conversation is truncated mid-review,
322
- // and the model picks up from a broken state, burning more rounds).
323
- if (!result.trimStart().startsWith("Advisor:")) {
324
- // Assign only for fresh sessions; re-assignment of the same reference on
325
- // continued sessions is a no-op but communicating intent matters.
326
- agent._advisorSession = reviewType === "design" ? null : messages
327
- } else {
328
- agent._advisorSession = null
329
- }
330
- return result
434
+ return final
331
435
  } catch (e) {
332
436
  if (e.name === "AbortError" && signal?.reason?.interrupt) throw e
333
437
 
@@ -346,7 +450,9 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
346
450
  ? "Reduce the scope (fewer files/paths) or use a model with larger context window."
347
451
  : "You may retry or proceed to verify manually."
348
452
 
349
- agent._advisorSession = null // failed review: don't keep a half-built conversation
350
453
  return `Advisor: review failed (${errorType}) — ${e.message || "unknown error"}. ${retryAdvice}`
351
454
  }
352
455
  }
456
+ // Host-verified citations — moved to citations.mjs (kept re-exported here for
457
+ // import compatibility: tests and callers import from run.mjs).
458
+ export { extractCitations, verifyCitations, appendCitationReport } from "./citations.mjs"