thincoder 0.12.10 → 0.12.12

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