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.
package/src/advisor.mjs CHANGED
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * advisor.mjs — advisor system-prompt selection, follow-up building, session assembly.
3
3
  * User-message building lives in advisor/messages.mjs; execution (tool loop, provider
4
- * resolution, review entry) in advisor/run.mjs; git discovery/collection in
5
- * advisor/repos.mjs (design-review diffs only code-review follow-ups deliberately
6
- * inject NO git information); history extraction in advisor/history.mjs.
4
+ * resolution, review entry) in advisor/run.mjs; history extraction in advisor/history.mjs.
5
+ * repos.mjs still hosts the doc-file classifier (isDocFile) used by mutation tracking.
7
6
  *
8
7
  * The advisor runs as a read-only exploration sub-agent with tools
9
- * (read, glob, grep, ls, git, lsp, code_search). Round 1 discovers changes
10
- * via git diff and reads files for context; convergence rounds (2+) verify
11
- * fix status with `read` only no git output is injected or trusted.
8
+ * (read, glob, grep, ls, lsp, code_search) ZERO git, every round. The change
9
+ * surface comes from the review scope (paths / _touchedFiles), never from git;
10
+ * verification is `read`-only with quoted-line evidence (7d49a52, d3be613).
12
11
  *
13
12
  * Config:
14
13
  * { advisor: { enabled: true, provider: "deepseek", model: "deepseek-chat" } }
@@ -16,9 +15,16 @@
16
15
  *
17
16
  * Convergence protocol:
18
17
  * Round 1: full review → produces a numbered issue table.
19
- * Agent responds with a response table per issue.
20
- * Round 2: semi-convergence — verifies table + can flag obvious new issues.
18
+ * Agent responds with a response table per issue (fix claims).
19
+ * Round 2: semi-convergence — verifies the prior table + can flag obvious new issues.
21
20
  * Round 3+: strict convergence — only checks the prior issue table.
21
+ * The prior issue table IS injected into rounds 2+ (decision 2026-08-05,
22
+ * reversed) — it is the ONLY complete verification list: the agent response
23
+ * table covers only issues the agent chose to answer, so skipped issues would
24
+ * silently escape convergence without it. The fix-claim table travels as a
25
+ * focus reference only. Restatement risk is handled mechanically:
26
+ * host-verified citations reject references that do not match the current
27
+ * disk state, and fresh sessions exclude old read data.
22
28
  * Each round replaces the system prompt (ROUND1 → ROUND2 → ROUND3) so the
23
29
  * round-1 full-scope mandate can't bleed into later rounds, plus a mechanical
24
30
  * cap (MAX_ADVISOR_ROUNDS in run.mjs) refuses a 6th review call outright.
@@ -26,23 +32,23 @@
26
32
  * file:line evidence for any unfixed/new finding — see docs/design/ADVISOR-CONVERGENCE.md.
27
33
  *
28
34
  * Session memory (agent._advisorSession):
29
- * All advisor calls within one run share a single conversation — round 2+
30
- * just appends a follow-up (agent response + refreshed diff + round rules),
31
- * so the advisor keeps its exploration context instead of re-discovering
32
- * everything. The session is discarded when the run ends (runAgent resets it);
33
- * the next task starts a fresh advisor session. After an app restart the
34
- * in-memory session is gone falls back to a fresh session seeded from the
35
- * issue/response tables in the main history.
35
+ * RETAINED for initialization compatibility but NEVER read (decision d698434):
36
+ * every review round builds a fresh [system, user] session round 2+ must not
37
+ * reuse round 1's messages, because the old read outputs are the anchoring
38
+ * source of re-review false reports and a token sink. Convergence data (prior
39
+ * issue table + agent response table) travels via buildAdvisorFollowUp.
40
+ * The field is reset by runAgent; the write sites are harmless leftovers.
36
41
  *
37
42
  * Project customisation: .thincoder/advisor.md in the project root.
38
43
  */
39
44
  import { readFileSync } from "node:fs"
40
45
  import { join, dirname } from "node:path"
41
46
  import { fileURLToPath } from "node:url"
42
- import { extractPriorIssueTable, extractAgentResponseTable } from "./advisor/history.mjs"
43
- import { buildAdvisorUserMessage } from "./advisor/messages.mjs"
47
+ import { extractAgentResponseTable } from "./advisor/history.mjs"
48
+ import { buildAdvisorUserMessage, resolveScopeFiles } from "./advisor/messages.mjs"
49
+ import { buildConvergenceBody } from "./advisor/convergence.mjs"
44
50
  // Re-export for run.mjs and tests (keeps their imports from "../advisor.mjs" stable)
45
- export { ADVISOR_MD_PATH, extractPriorIssueTable, extractAgentResponseTable, extractConversationBackground } from "./advisor/history.mjs"
51
+ export { ADVISOR_MD_PATH, extractAgentResponseTable, extractConversationBackground } from "./advisor/history.mjs"
46
52
  export { buildAdvisorUserMessage } from "./advisor/messages.mjs"
47
53
 
48
54
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -51,15 +57,29 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
51
57
  // Prompt files — loaded at module init
52
58
  // ────────────────────────────────────────
53
59
 
54
- const ADVISOR_ROUND1 = readFileSync(join(__dirname, "prompts", "advisor-round1.md"), "utf8")
60
+ function loadPrompt(file, name) {
61
+ try {
62
+ return readFileSync(join(__dirname, "prompts", file), "utf8")
63
+ } catch {
64
+ throw new Error(`${name} missing from the installation (prompts/${file}) — reinstall thincoder or restore the file`)
65
+ }
66
+ }
67
+
68
+ const ADVISOR_ROUND1 = loadPrompt("advisor-round1.md", "advisor-round1.md")
55
69
  // ROUND2/3 are used whenever a convergence round (round 2+) is being built:
56
70
  // in-run session continuation replaces the system prompt with them, and a
57
71
  // rebuilt fresh session (e.g. after a failed review) also selects them via
58
72
  // buildAdvisorSystemPrompt when _advisorRound > 0.
59
- const ADVISOR_ROUND2 = readFileSync(join(__dirname, "prompts", "advisor-round2.md"), "utf8")
60
- const ADVISOR_ROUND3 = readFileSync(join(__dirname, "prompts", "advisor-round3.md"), "utf8")
73
+ const ADVISOR_ROUND2 = loadPrompt("advisor-round2.md", "advisor-round2.md")
74
+ const ADVISOR_ROUND3 = loadPrompt("advisor-round3.md", "advisor-round3.md")
75
+ // Fallback when advisor-design.md is missing — keep in sync with the real
76
+ // file (table format + workflow steps).
77
+ const ADVISOR_DESIGN_FALLBACK = `You are an independent design reviewer for an engineering-mode project. Review the design document in the changes below. Evaluate: completeness, feasibility, clarity, scope, acceptance criteria. Read METHODOLOGY.md if provided. Produce a review table with | # | Category | Severity | Issue | Suggestion | format.`
61
78
  let ADVISOR_DESIGN = ""
62
- try { ADVISOR_DESIGN = readFileSync(join(__dirname, "prompts", "advisor-design.md"), "utf8") } catch { /* design review unavailable */ }
79
+ // Design review is OPTIONAL (engineering mode only) silent fallback to the
80
+ // in-code constant is intentional, unlike the mandatory round prompts which
81
+ // must exist for every review (loadPrompt throws a descriptive error there).
82
+ try { ADVISOR_DESIGN = readFileSync(join(__dirname, "prompts", "advisor-design.md"), "utf8") } catch { /* fallback below */ }
63
83
 
64
84
  // ────────────────────────────────────────
65
85
  // System prompt building
@@ -68,15 +88,28 @@ try { ADVISOR_DESIGN = readFileSync(join(__dirname, "prompts", "advisor-design.m
68
88
  /**
69
89
  * Build the system prompt for an advisor review session.
70
90
  * @param {Object} agent — the parent agent
71
- * @param {Object|null} [_prior] — prior issue table (from extractPriorIssueTable)
91
+ * @param {Object|null} [prior] — prior review output (full text; decision 2026-08-08)
72
92
  * @param {string} [reviewType] — "design" for design review, undefined/"code" for code review
73
93
  * @returns {string} the system prompt
74
94
  */
75
- export function buildAdvisorSystemPrompt(agent, _prior, reviewType) {
76
- // Design review: dedicated prompt, no convergence rounds
77
- if (reviewType === "design") return ADVISOR_DESIGN || `You are an independent design reviewer for an engineering-mode project. Review the design document in the changes below. Evaluate: completeness, feasibility, clarity, scope, acceptance criteria. Read METHODOLOGY.md if provided. Produce a review table with | # | Category | Severity | Issue | Suggestion | format.`
78
- const prior = _prior ?? extractPriorIssueTable(agent.history)
79
- if (!prior || (agent._advisorRound || 0) === 0) return ADVISOR_ROUND1
95
+ export function buildAdvisorSystemPrompt(agent, prior, reviewType) {
96
+ // Round decision is DETERMINISTIC (decision 2026-08-08): _advisorRound > 0
97
+ // with a stored review output means convergence (round 2+); 0 means round 1.
98
+ // No prior-table parsing, no all-clear phrase matching — the round counter
99
+ // and the stored output are the only inputs. A restarted process has
100
+ // _advisorRound 0 → conservative full re-review.
101
+ const hasPrior = (agent._advisorRound || 0) > 0 && (prior ?? agent._lastAdvisorOutput)
102
+ // Design review: round 1 uses the dedicated design-review prompt (full scope +
103
+ // approval token); rounds 2+ converge like code reviews (verify agent fix claims).
104
+ if (reviewType === "design") {
105
+ if (!hasPrior) {
106
+ return ADVISOR_DESIGN || ADVISOR_DESIGN_FALLBACK
107
+ }
108
+ const round = (agent._advisorRound || 0) + 1
109
+ if (round === 2) return ADVISOR_ROUND2
110
+ return ADVISOR_ROUND3
111
+ }
112
+ if (!hasPrior) return ADVISOR_ROUND1
80
113
  const round = (agent._advisorRound || 0) + 1
81
114
  if (round === 2) return ADVISOR_ROUND2
82
115
  return ADVISOR_ROUND3
@@ -92,93 +125,153 @@ export function buildAdvisorSystemPrompt(agent, _prior, reviewType) {
92
125
  * Deliberately NO git information injected (no diff snapshot, no git context):
93
126
  * git output misled re-reviews — committed fixes never show in `git diff HEAD`,
94
127
  * so the model read "no changes" as "no fixes". Verification is `read`-only.
128
+ * NOTE: the caller (prepareAdvisorMessages) applies escapeLiteralEscapes to
129
+ * the return value — direct callers must do the same (the prior table and
130
+ * agent response can quote literal "\x"/"\u" sequences).
131
+ * @param {Object} agent — the parent agent (history used for the response table)
132
+ * @param {Object|null} prior — prior issue table (extracted from history when null)
133
+ * @param {string[]|null} [scopeFiles] — review surface for the no-response fallback (cwd-relative)
134
+ * @returns {string} the follow-up user message — or a plain "System reminder: …"
135
+ * fresh-review fallback (NO brackets — some OpenAI-compatible servers parse
136
+ * '['-prefixed content as structured data / expand escapes) when no prior
137
+ * review exists at all (caller misuse; the response-table extraction would
138
+ * otherwise scan history from index 0 and could match an unrelated stale table)
95
139
  */
96
- export function buildAdvisorFollowUp(agent, _prior) {
97
- const prior = _prior ?? extractPriorIssueTable(agent.history)
98
- const response = extractAgentResponseTable(agent.history, prior?.sinceIdx ?? 0)
99
- || "(Agent did not provide a response table re-evaluate each issue)"
140
+ export function buildAdvisorFollowUp(agent, prior, scopeFiles = null) {
141
+ // Convergence follow-up REQUIRES a prior review record the full output of
142
+ // the last review, injected VERBATIM (decision 2026-08-08: the model
143
+ // understands the review output; no table/header/phrase parsing). The caller
144
+ // usually passes it; fall back to the stored agent._lastAdvisorOutput.
145
+ const p = prior ?? agent._lastAdvisorOutput
146
+ if (!p) {
147
+ // Plain "System reminder:" prefix (no brackets) — same convention as the
148
+ // round-1 path (some OpenAI-compatible servers parse '['-prefixed content
149
+ // as structured data / expand escapes; see prepareAdvisorMessages).
150
+ return "System reminder: convergence follow-up requested without a prior review — perform a fresh full review."
151
+ }
152
+ // Convergence semantics require round >= 2 (round 1 is the full review, not
153
+ // verification). A direct caller with _advisorRound 0 would otherwise get a
154
+ // meaningless "Round 1 — Strict Verification".
155
+ if ((agent._advisorRound || 0) < 1) {
156
+ return "System reminder: convergence follow-up requested at round 1 — a full review is already in progress; no prior verification exists yet."
157
+ }
158
+ const noResponseFallback = scopeFiles?.length
159
+ ? "(Agent did not provide a response table — perform a fresh review of: " + scopeFiles.slice(0, 10).join(", ") + ")"
160
+ : "(Agent did not provide a response table — perform a fresh full review; the review surface is unknown, ask the user for the file list)"
161
+ const response = extractAgentResponseTable(agent.history) || noResponseFallback
100
162
  const round = (agent._advisorRound || 0) + 1
101
- const label = round === 2 ? "Verify Prior Table + Flag New Issues" : "Strict Verification"
163
+ return buildConvergenceBody(p, response, round, scopeFiles)
164
+ }
102
165
 
103
- const parts = [
104
- `## Round ${round}${label}`,
105
- "",
106
- `[System reminder: this is round ${round} of the convergence protocol. The system prompt for this round has already narrowed the review scope — follow it: ${round === 2 ? "verify the prior table and flag only obvious new issues introduced by the fixes" : "strictly verify only the prior table — do NOT look for new issues"}.]`,
107
- "",
108
- "## Prior Issue Table",
109
- prior?.text ?? "(no prior table review from scratch)",
110
- "",
111
- "## Agent Response",
112
- response,
113
- "",
114
- "## Instructions",
115
- round === 2
116
- ? "Verify each item in the prior table. Flag any obvious NEW issues introduced by the fixes (crashes, data loss, logic errors — not style). Produce a verification table."
117
- : "Strictly verify ONLY the items in the prior table against the CURRENT FILE STATE (use `read` an empty diff does not mean the fixes are absent). Do NOT look for new issues.",
118
- "",
119
- "IMPORTANT: the prior issue table is HISTORY always verify current file state with `read` before judging an item as fixed or unfixed.",
120
- // Round-aware evidence rule: "New" entries only exist in round 2 (round 3+ forbids them).
121
- `STALE-CONTEXT WARNING: only fresh \`read\` results describe the current state — never judge from earlier snapshots or from \`git diff\` (committed fixes never show in \`git diff HEAD\`). Read the files to verify. Any "Unfixed" entry${round === 2 ? ' (and any "New" entry)' : ""} MUST quote the exact line content from THIS round's \`read\` output (e.g. \`run.mjs:180: timeoutId = setTimeout(...)\`); line numbers alone are NOT evidence (they may come from the stale prior table). Uncited findings are unverified and will be ignored.`,
122
- "",
123
- "Do NOT re-read AGENTS.md / design docs. Verify fix status with `read` only do not rely on git output: a clean working tree does not mean fixes are absent (they may be committed).",
124
- "",
125
- ]
126
- return parts.join("\n")
166
+ /**
167
+ * Resolve the review surface for the convergence fallback moved to
168
+ * messages.mjs so the legacy path shares it (see there).
169
+ */
170
+
171
+ /**
172
+ * Neutralize literal backslash escape sequences ("\x", "\u") that some
173
+ * OpenAI-compatible servers interpret inside message content ("unexpected end
174
+ * of hex escape" 400 — observed 2026-08-06 when the conversation background
175
+ * quoted "\x" literals). Only sequences that would be INVALID when expanded
176
+ * are doubled ("\\x" → literal "\x" after server expansion); well-formed
177
+ * "\xNN" / "\uNNNN" pass through untouched (they expand to a byte/codepoint).
178
+ */
179
+ export function escapeLiteralEscapes(text) {
180
+ // (?<!\\) only a SINGLE backslash counts ("\\x" already doubles the
181
+ // escape and must pass through untouched); lookbehind is fine on Node 24.
182
+ // Known limitation (documented, accepted): an ODD backslash run of 3+ (e.g.
183
+ // "\\\x") leaves the trailing "\x" un-doubled vanishingly rare in real
184
+ // conversation text, and the sequence is still valid JSON either way.
185
+ // The lookahead treats "\x" followed by AT LEAST 2 hex as valid (servers
186
+ // expand only the first two: "\x1b3" ESC + "3"); only truncated runs
187
+ // ("\x" + <2 hex) are doubled.
188
+ text = String(text ?? "")
189
+ return text
190
+ .replace(/(?<!\\)\\(x)(?![0-9a-fA-F]{2})/g, "\\\\$1")
191
+ .replace(/(?<!\\)\\(u)(?![0-9a-fA-F]{4})/g, "\\\\$1")
127
192
  }
128
193
 
194
+
129
195
  /**
130
- * Build or continue the advisor conversation for this run.
131
- * First call in a run: fresh [system, user] session. Later calls: append a
132
- * follow-up to the existing session so the advisor keeps its context.
133
- * After an app restart (session lost), starts a fresh round-1 full review —
134
- * exploration context is gone, so prior tables from history are not injected.
135
- * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review
196
+ * Build the advisor conversation for this run.
197
+ * EVERY call builds a fresh [system, user] session (decision d698434) no
198
+ * session reuse across rounds: round 1 = full scope (ROUND1 prompt), rounds
199
+ * 2+ = convergence (ROUND2/ROUND3 prompt + fix-claims follow-up).
200
+ * @param {Object} agent the parent agent
136
201
  * @param {string} [reviewType] — "design" or "code" (default)
137
202
  * @param {string|null} [designToken] — design-review approval token (design only)
138
203
  * @param {string[]|null} [documents] — design review only: explicit list of doc paths to review (passed through to buildAdvisorUserMessage)
204
+ * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review
139
205
  */
140
- export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null) {
141
- const prior = extractPriorIssueTable(agent.history)
142
- // Design review: always fresh session, no convergence
143
- if (reviewType === "design") {
206
+ export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null, priorParam = null) {
207
+ // Deterministic convergence state (decision 2026-08-08): round 2+ requires
208
+ // _advisorRound > 0 AND a stored prior review output. No history parsing.
209
+ // priorParam (direct callers) wins over the stored output — same derivation
210
+ // as buildAdvisorSystemPrompt (single source of truth for round semantics).
211
+ const prior = (agent._advisorRound || 0) > 0 ? (priorParam ?? agent._lastAdvisorOutput) : null
212
+
213
+ // Design review round 1: the dedicated full-scope review with the approval
214
+ // token (an independent gate — it runs even when a prior review exists, e.g.
215
+ // after a failed design review). Fresh session.
216
+ if (reviewType === "design" && (agent._advisorRound || 0) === 0) {
144
217
  return [
145
218
  { role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
146
- { role: "user", content: buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths) },
219
+ { role: "user", content: escapeLiteralEscapes(buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)) },
147
220
  ]
148
221
  }
149
- let session = agent._advisorSession
150
- if (session) {
151
- // Session exists but no prior table (last review was all-clear or none)
152
- // a follow-up "Verify Prior Table" would be meaningless; start a fresh full review
153
- if (!prior) {
154
- agent._advisorSession = null
155
- // Only reset the round counter on a truly fresh start (no prior reviews at all).
156
- // If _advisorRound > 0, there WAS a prior review it just passed (all-clear).
157
- if (!agent._advisorRound) agent._advisorRound = 0
158
- } else {
159
- // Convergence rounds (2+): replace the system prompt so the round-1
160
- // "full-scope review" mandate cannot override the follow-up's narrowed scope.
161
- // Without this the model re-runs a full review every round, finds new issues
162
- // each time, and the protocol never converges. buildAdvisorSystemPrompt
163
- // returns ROUND2 for round 2 and ROUND3 for round 3+.
164
- session[0] = { role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) }
165
- session.push({ role: "user", content: buildAdvisorFollowUp(agent, prior) })
166
- return session
222
+
223
+ // Every round is a FRESH session (decision d698434): round 2+ must NOT reuse
224
+ // round 1's messages the old read outputs are the top anchoring source of
225
+ // re-review false reports (the model quoted pre-fix file content instead of
226
+ // re-reading) and a token sink. The agent response table (fix claims) is
227
+ // injected through buildAdvisorFollowUp instead; the system prompt carries
228
+ // the round (ROUND2/ROUND3) via buildAdvisorSystemPrompt.
229
+ // No prior review output: reset ONLY when this run made no code changes (user
230
+ // decision 2026-08-05: any loop that modified code must NOT reset — the
231
+ // advisor guard WILL push back, so the convergence round must keep advancing
232
+ // toward the cap; a run with no mutations has no push-back risk and a reset
233
+ // is safe). Deterministic runtime state (`_mutatedThisRun`) decides never
234
+ // model output (phrases/table headers drift; three rounds of false reports
235
+ // proved it). Either way the message is a fresh full review (no prior output
236
+ // exists without a completed review) only the round counter differs.
237
+ if (!prior || (agent._advisorRound || 0) === 0) {
238
+ if (!(agent._mutatedThisRun ?? false)) {
239
+ // New review cycle (first review, all-clear, or no code changes): reset
240
+ // the round so the cycle gets its own 5-round budget.
241
+ agent._advisorRound = 0
167
242
  }
243
+ // Mutations exist → KEEP the round (cap keeps advancing through retries).
244
+ const user = buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)
245
+ return [
246
+ { role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
247
+ {
248
+ role: "user",
249
+ // NOTE (2026-08-06): the leading prefix is a PLAIN "System reminder:",
250
+ // NOT "[System reminder: ...]" — some OpenAI-compatible servers try to
251
+ // parse content that STARTS with '[' as structured content (or expand
252
+ // escape sequences in it). A literal "\x" inside the conversation
253
+ // background (e.g. the parent agent quoting escape sequences) then
254
+ // fails server-side as "unexpected end of hex escape" → 400. Plain
255
+ // prefix keeps the review message a plain string everywhere.
256
+ // The whole content also passes through escapeLiteralEscapes (below)
257
+ // so literal "\x"/"\u" quoted by the parent agent can never form an
258
+ // invalid escape when the server expands them.
259
+ content: escapeLiteralEscapes(`System reminder: no prior issue table is being carried into this review (first review, app restart, or session clear) — start with a fresh full review.\n\n${user}`),
260
+ },
261
+ ]
168
262
  }
169
- session = [
263
+
264
+ // Convergence rounds (2+): fresh [system(ROUND2/3), user(prior table + fix
265
+ // claims)]. buildAdvisorFollowUp carries BOTH the prior issue table (the
266
+ // only complete verification list — decision 2026-08-05, reversed) and the
267
+ // agent's fix-claim table (focus reference). buildAdvisorSystemPrompt
268
+ // selects ROUND2 for round 2, ROUND3 for rounds 3+ — a failed review retry
269
+ // keeps _advisorRound so the convergence prompt matches the attempt count.
270
+ // scopeFiles gives the fallback (agent gave no response table) a concrete
271
+ // review surface.
272
+ const scopeFiles = resolveScopeFiles(agent, paths)
273
+ return [
170
274
  { role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
171
- { role: "user", content: buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths) },
275
+ { role: "user", content: escapeLiteralEscapes(buildAdvisorFollowUp(agent, prior, scopeFiles)) },
172
276
  ]
173
- // Fresh session. Only reset round if this is truly the first review.
174
- // If _advisorRound > 0, there was a prior review that passed (all-clear).
175
- if (!agent._advisorRound) agent._advisorRound = 0
176
- if (!prior) {
177
- // Tell the advisor why no prior issue table is present
178
- session[1] = {
179
- role: "user",
180
- content: `[System reminder: no prior issue table is being carried into this review (first review, app restart, or session clear) — start with a fresh full review.]\n\n${session[1].content}`,
181
- }
182
- }
183
- return session
184
277
  }
@@ -4,15 +4,9 @@
4
4
  * Checks: pending tasks, verify guard, advisor guard.
5
5
  * Returns { action: 'continue' | 'done', content?, guardPushbacks, honestReminderInjected, advisorPushbacks }
6
6
  */
7
- import { isDocFile } from "../advisor/repos.mjs"
7
+ import { hasCodeMutations } from "../advisor/repos.mjs"
8
8
  import { pushReal } from "../context.mjs"
9
-
10
- /** True when this run mutated at least one CODE file. Mirrors agent.mjs:hasCodeMutations. */
11
- function hasCodeMutations(agent) {
12
- const files = agent._touchedFiles ?? []
13
- if (files.length === 0) return agent._mutatedThisRun
14
- return files.some((p) => /(?:^|[\\/])src[\\/]/.test(p) || !isDocFile(p))
15
- }
9
+ import { MAX_ADVISOR_ROUNDS } from "../advisor/run.mjs"
16
10
 
17
11
  const MAX_VERIFY_PUSHBACKS = 2
18
12
  const MAX_VERIFY_RETRIES = 3
@@ -117,12 +111,18 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
117
111
  }
118
112
 
119
113
  // --- advisor guard: review of mutated files before completion ---
120
- // OPT-IN via advisor.enabled + guard!==false, and NEVER in engineering mode.
114
+ // Active by default when advisor.enabled is set (opt-out via guard: false),
115
+ // and NEVER in engineering mode.
121
116
  const cfg = agent.config?.advisor
122
117
  const advisorReview = cfg?.enabled && cfg?.guard !== false
123
118
  if (depth === 0 && advisorReview && !agent.config?.agent?.engineering) {
119
+ // Cap sync: beyond MAX_ADVISOR_ROUNDS the advisor tool refuses to review
120
+ // (run.mjs convergence cap) — pushing back further would loop forever
121
+ // (fix → pushback → cap-refused call → fix …). The cap message from the
122
+ // last accepted review stands; the user decides manually.
124
123
  if (agent._mutatedThisRun && !agent._calledAdvisorThisRun && hasCodeMutations(agent)
125
- && advisorPushbacks < MAX_ADVISOR_PUSHBACKS) {
124
+ && advisorPushbacks < MAX_ADVISOR_PUSHBACKS
125
+ && (agent._advisorRound || 0) < MAX_ADVISOR_ROUNDS) {
126
126
  advisorPushbacks++
127
127
  pushReal(agent, { role: "assistant", content: response.content })
128
128
  agent.history.push({
@@ -152,6 +152,15 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
152
152
  if (!item.tool?.readonly && item.args) {
153
153
  snapshotForUndo(agent, item.toolCall.name, item.args, agent.cwd)
154
154
  }
155
+ // M2 ACP: route fs tools through the client (IDE buffer / diff review).
156
+ // toolRouter returns { handled: true, result } to short-circuit execution.
157
+ if (callbacks.toolRouter) {
158
+ const routed = await callbacks.toolRouter(item.toolCall.name, item.args)
159
+ if (routed?.handled) {
160
+ callbacks.onToolResult?.(item.toolCall.name, routed.result)
161
+ return { ...item, result: routed.result, ok: true }
162
+ }
163
+ }
155
164
  const rawResult = await item.tool.execute(item.args, {
156
165
  cwd: agent.cwd,
157
166
  agent,
@@ -172,6 +181,11 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
172
181
  } catch (error) {
173
182
  // Persist to ~/.thincoder/tool-errors/ for post-mortem; only pass message to the model (stack traces confuse LLMs and may leak paths)
174
183
  logToolError(item.toolCall.name, item.args, error)
184
+ // User interrupt (Ctrl+C / Ctrl+I) must propagate, not become a tool error:
185
+ // swallowing it here would make the parent keep looping while the user
186
+ // asked to stop — worst case with subagents, where the child runs its
187
+ // whole turn budget and the interrupt appears to do nothing.
188
+ if (signal?.aborted) throw error
175
189
  runHooks("PostToolUseFailure", { agent, toolName: item.toolCall.name, toolArgs: item.args, error }).catch(() => {})
176
190
  // Build contextual error: tool name + key args so the model can reason about what went wrong
177
191
  const ctxParts = []
@@ -81,8 +81,8 @@ export const advisorTool = {
81
81
  "Use type='code' (default) to review code changes after implementation — pass paths=[...] to specify which files or directories to review, or documents=[...] for acceptance criteria context. " +
82
82
  "The advisor is an independent read-only sub-agent that explores the codebase, " +
83
83
  "reads files, and traces callers via grep/lsp. " +
84
- "For code review: round 1 does a full review, round 2 verifies the prior table, " +
85
- "round 3+ strictly checks only the prior table — convergence, not divergence. " +
84
+ "For code review: round 1 does a full review, round 2 verifies the agent's fix claims, " +
85
+ "round 3+ strictly checks only the fix claims — convergence, not divergence. " +
86
86
  "For design review: single-pass review against methodology and requirements. " +
87
87
  "Review criteria come from .thincoder/advisor.md (if present) or sensible defaults. " +
88
88
  "After the review, you MUST produce a response table (see discipline rules for format). " +
@@ -94,7 +94,7 @@ export const advisorTool = {
94
94
  paths: {
95
95
  type: "array",
96
96
  items: { type: "string" },
97
- description: "Code files or directories to review (for code review). Required unless documents is provided. The advisor reviews git diff filtered to these paths.",
97
+ description: "Code files or directories to review (for code review). Required unless documents is provided. The advisor reads the files/directories listed here — it has no git tool and never inspects diffs.",
98
98
  },
99
99
  documents: {
100
100
  type: "array",
@@ -110,11 +110,13 @@ export const advisorTool = {
110
110
  const agent = ctx.agent
111
111
  const reviewType = args.type || "code"
112
112
  const documents = args.documents || null
113
- const paths = args.paths || null
113
+ // Scope fallback: the runtime mutation record (zero git) covers guard-triggered
114
+ // reviews where the model did not pass explicit paths.
115
+ const paths = args.paths || (agent._touchedFiles?.length ? [...agent._touchedFiles] : null)
114
116
 
115
117
  // Code review must have a scope — no implicit fallback.
116
118
  if (reviewType !== "design" && !paths && !documents) {
117
- return "Advisor: no review scope specified. Provide paths (files/directories to review) or documents (acceptance criteria — code diff is still used)."
119
+ return "Advisor: no review scope specified. Provide paths (files/directories to review) or documents (acceptance criteria context)."
118
120
  }
119
121
 
120
122
  // Design review: validate that documents are in docs/ or are recognized doc files
@@ -129,11 +131,10 @@ export const advisorTool = {
129
131
  }
130
132
  }
131
133
 
132
- // Design review: always starts from round 1 (no convergence)
133
- if (reviewType === "design") {
134
- agent._advisorRound = 0
135
- agent._advisorSession = null
136
- }
134
+ // Design review: NO round reset design reviews share the 5-round convergence
135
+ // budget with code reviews (round advances in agent.mjs; cap in run.mjs).
136
+ // Session reset also removed: design rounds 2+ continue the advisor session
137
+ // like code reviews (fix claims + round-aware prompts).
137
138
 
138
139
  // Generate the design token BEFORE the review and inject it into the advisor's prompt.
139
140
  // The advisor (LLM) decides pass/fail itself and echoes the token only on approval —
@@ -180,7 +180,7 @@ export const subagentTool = {
180
180
  ? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args)
181
181
  : null,
182
182
  }
183
- const childRunOpts = { depth: (ctx.depth ?? 0) + 1, maxTurns: ctx.agent?.config?.agent?.subagentTurns ?? DEFAULT_SUBAGENT_TURNS }
183
+ const childRunOpts = buildChildRunOpts(ctx)
184
184
  let report = await runAgent(child, input, childOpts, childRunOpts)
185
185
 
186
186
  // Report too short = incomplete handoff: send back for expansion once (inspired by kimi-code's summaryPolicy: min 200 chars, retry 1 time).
@@ -203,6 +203,20 @@ export const subagentTool = {
203
203
  },
204
204
  }
205
205
 
206
+ /**
207
+ * Child agent run options — the parent's abort signal MUST propagate to the
208
+ * child: without it, Ctrl+C aborts the parent's controller but the child keeps
209
+ * running its full turn budget (up to subagentTurns) while the parent awaits —
210
+ * the interrupt appears to do nothing.
211
+ */
212
+ export function buildChildRunOpts(ctx) {
213
+ return {
214
+ depth: (ctx.depth ?? 0) + 1,
215
+ maxTurns: ctx.agent?.config?.agent?.subagentTurns ?? DEFAULT_SUBAGENT_TURNS,
216
+ signal: ctx.signal ?? null,
217
+ }
218
+ }
219
+
206
220
  /**
207
221
  * Merge an eng-coder child's mutations into the parent agent's bookkeeping.
208
222
  * The parent must stay aware of delegated file changes: `_touchedFiles` enables
@@ -210,13 +224,20 @@ export const subagentTool = {
210
224
  * pushback for review. Prior verify/advisor state is invalidated because it
211
225
  * judged an older state.
212
226
  *
213
- * `_advisorRound` is reset to 0: merged code is new code that deserves a fresh
214
- * convergence budget. Mirrors the design-review reset semantics.
227
+ * `_advisorRound` is NOT reset: merged code enters the CURRENT convergence
228
+ * cycle. Resetting here would break the review→fix→re-review loop (the parent
229
+ * reviews, spawns an eng-coder to fix, merges, reviews again — every merge
230
+ * would restart at round 1 and the 5-round cap could never be reached).
231
+ * `_calledAdvisorThisRun` IS cleared so the merged code triggers a fresh
232
+ * advisor call (the guard demands review of new mutations).
215
233
  *
216
234
  * Returns true when mutations were merged (kept for future caller checks).
217
235
  */
218
236
  export function mergeChildMutations(parent, child) {
219
- if (!child._mutatedThisRun) return false
237
+ // A child claiming mutations without any touched file is a misbehaving
238
+ // child (or a bookkeeping bug) — do not propagate an empty mutation claim
239
+ // to the parent's guard state.
240
+ if (!child._mutatedThisRun || !(child._touchedFiles?.length)) return false
220
241
  parent._mutatedThisRun = true
221
242
  for (const abs of child._touchedFiles ?? []) {
222
243
  if (!parent._touchedFiles.includes(abs)) parent._touchedFiles.push(abs)
@@ -226,10 +247,7 @@ export function mergeChildMutations(parent, child) {
226
247
  parent._verifiedThisRun = false
227
248
  parent._verifyPassed = undefined
228
249
  }
229
- // Fresh code fresh convergence budget + stale session cleanup.
230
- // _advisorRound reset ensures new code gets a full round-1 review;
231
- // _advisorSession prevents cross-contamination between reviews.
232
- parent._advisorRound = 0
250
+ // Stale session cleanup only the round counter survives (see above).
233
251
  parent._advisorSession = null
234
252
  return true
235
253
  }