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.
package/src/acp.mjs ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * acp.mjs — `thincoder acp` entry: expose the thincoder agent over the
3
+ * Agent Client Protocol (schema v1) on stdio, so ACP clients (Zed, JetBrains
4
+ * AI Chat, Paseo) can drive sessions directly.
5
+ *
6
+ * M1 scope (see docs/design/ACP-CLIENT.md §9):
7
+ * initialize / authenticate / session/new / session/prompt / session/cancel / session/close
8
+ * M2: tools + request_permission + fs reverse-RPC. M3: session load/resume/list/delete + config options.
9
+ *
10
+ * Auth: reuse the terminal config (~/.thincoder/config.json) — a resolvable
11
+ * provider API key means "configured". No account system; `logout` is absent.
12
+ *
13
+ * `isConfigured` / `createSession` are injectable for tests.
14
+ */
15
+ import { readFileSync } from "node:fs"
16
+ import { resolve } from "node:path"
17
+ import { loadConfig } from "./config.mjs"
18
+ import { assembleAgent } from "./cli/make-agent.mjs"
19
+ import { createAcpServer, ACP_ERRORS } from "./acp/transport.mjs"
20
+ import { createAcpSession } from "./acp/session.mjs"
21
+ import { replayHistory } from "./acp/bridge.mjs"
22
+ import { listSlots, applySession, deleteSlot, sessionPath, normalizeCwd, isLegacyTransient } from "./session.mjs"
23
+
24
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
25
+
26
+ /** Load a specific slot file (not the active one) — session/load by id.
27
+ * Same validation as loadSession: version 1/2, cwd match, legacy-transient
28
+ * filtering (pre-filtering slot files must not leak machine lines into replay). */
29
+ function loadSlotFile(cwd, slot) {
30
+ const path = `${sessionPath(cwd)}.${slot}`
31
+ try {
32
+ const data = JSON.parse(readFileSync(path, "utf8"))
33
+ if (data?.version !== 1 && data?.version !== 2) return null
34
+ if (!Array.isArray(data.history)) return null
35
+ if (data.cwd && normalizeCwd(data.cwd).toLowerCase() !== normalizeCwd(cwd).toLowerCase()) return null
36
+ data.history = data.history.filter((m) => !isLegacyTransient(m))
37
+ return data
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Apply a session-level config option to the agent instance (memory only —
45
+ * the session's own runtime state, last-write-wins; not persisted to config.json).
46
+ * Returns true when the configId is known.
47
+ */
48
+ function applyConfigOption(agent, configId, value) {
49
+ switch (configId) {
50
+ case "model": {
51
+ if (typeof value !== "string" || !value.trim()) return false
52
+ if (!agent.provider) return false // nothing to configure
53
+ // Split on the FIRST colon only — model names may contain colons.
54
+ const ci = value.indexOf(":")
55
+ const provider = ci >= 0 ? value.slice(0, ci) : null
56
+ const model = (ci >= 0 ? value.slice(ci + 1) : value).trim()
57
+ if (provider && provider !== agent.provider.name) agent.provider.name = provider
58
+ agent.provider.model = model
59
+ return true
60
+ }
61
+ case "thinking": {
62
+ if (typeof value !== "boolean" || !agent.provider) return false
63
+ agent.provider.thinking = value ? { type: "enabled" } : { type: "disabled" }
64
+ return true
65
+ }
66
+ case "mode": {
67
+ if (value !== "plan" && value !== "normal") return false
68
+ agent.planMode = value === "plan"
69
+ return true
70
+ }
71
+ default:
72
+ return false
73
+ }
74
+ }
75
+
76
+ /** Config is "configured" when the active provider has a resolvable API key (env fallback included). */
77
+ export function defaultIsConfigured() {
78
+ try {
79
+ return !!loadConfig().provider?.apiKey?.trim()
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+
85
+ /** M1/M2 session factory: one agent per session, built from the process cwd (single-cwd model).
86
+ * `id` is the ACP session id — it is baked into the callbacks at construction time
87
+ * (buildAcpCallbacks closure), so it must be known BEFORE createAcpSession runs.
88
+ * `request` is the transport's reverse-RPC channel (permissions + fs routing). */
89
+ export async function defaultCreateSession({ id, notify, request, log }) {
90
+ const agent = await assembleAgent()
91
+ return createAcpSession({ id, agent, notify, request, log })
92
+ }
93
+
94
+ /**
95
+ * Build the ACP method handlers. Returns { handlers, sessions, notifyRef } —
96
+ * notifyRef.current is set by runAcpServer once the transport exists; sessions
97
+ * are created lazily (session/new), by which time the reference is live.
98
+ * @param {{ version?: string, notify?: (method, params) => void, log?: (s: string) => void,
99
+ * isConfigured?: () => boolean, createSession?: (ctx) => Promise<object> }} deps
100
+ */
101
+ export function buildAcpHandlers({
102
+ version = VERSION,
103
+ notify = () => {},
104
+ log = () => {},
105
+ isConfigured = defaultIsConfigured,
106
+ createSession = defaultCreateSession,
107
+ cwd = () => process.cwd(),
108
+ }) {
109
+ const getCwd = cwd
110
+ const notifyRef = { current: notify }
111
+ const requestRef = { current: async () => { throw new Error("no request channel") } }
112
+ const sessions = new Map()
113
+ let nextId = 1
114
+ let authenticated = false
115
+ const findSession = (params) => {
116
+ const s = sessions.get(String(params?.sessionId))
117
+ if (!s) return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `unknown session ${params?.sessionId}` } }
118
+ return { session: s }
119
+ }
120
+
121
+ return {
122
+ handlers: {
123
+ initialize: () => ({
124
+ protocolVersion: 1,
125
+ agentInfo: { name: "thincoder", version },
126
+ authMethods: ["terminal"],
127
+ capabilities: { fs: { read: true, write: true }, terminal: false },
128
+ }),
129
+
130
+ authenticate: () => {
131
+ if (!isConfigured()) return { error: ACP_ERRORS.AUTH_REQUIRED }
132
+ authenticated = true
133
+ return { authenticated: true }
134
+ },
135
+
136
+ "session/new": async (params) => {
137
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
138
+ if (params.cwd !== undefined && typeof params.cwd !== "string") {
139
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "cwd must be a string" } }
140
+ }
141
+ // Normalized comparison: resolve() collapses trailing slashes, and
142
+ // normalizeCwd().toLowerCase() makes the check case-insensitive on
143
+ // Windows (drive letter + path — a client sending "c:\users\…" vs
144
+ // process.cwd() "C:\Users\…" must match). The ternary guards against
145
+ // resolve(undefined) — it would coerce undefined to the literal
146
+ // "undefined" and resolve a nonsense path. Note: `requested` never
147
+ // feeds any path operation — the agent always runs in getCwd() — so
148
+ // a case-insensitive match on case-sensitive platforms is harmless.
149
+ const norm = (p) => normalizeCwd(p).toLowerCase()
150
+ const requested = params.cwd ? resolve(params.cwd) : getCwd()
151
+ if (norm(requested) !== norm(getCwd())) {
152
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `v1: cwd must equal the process working directory (${getCwd()})` } }
153
+ }
154
+ if (params.mcpServers?.length) {
155
+ log(`[acp] MCP forwarding is M2 scope — ignoring ${params.mcpServers.length} server(s)`)
156
+ }
157
+ try {
158
+ const id = String(nextId++)
159
+ const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
160
+ // `id` is immutable after construction (baked into the callbacks) — never reassign.
161
+ sessions.set(id, session)
162
+ return { id, configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
163
+ } catch (e) {
164
+ return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to create session: ${e.message}` } }
165
+ }
166
+ },
167
+
168
+ "session/prompt": async (params) => {
169
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
170
+ const found = findSession(params)
171
+ if (found.error) return found
172
+ const blocks = Array.isArray(params?.content) ? params.content : []
173
+ const text = blocks.find((b) => b?.type === "text")?.text ?? ""
174
+ if (!text) return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "prompt requires a text content block" } }
175
+ try {
176
+ await found.session.run(text)
177
+ return { stopReason: "end_turn" }
178
+ } catch (e) {
179
+ // Cancelled/interrupted turns are not errors on the wire.
180
+ if (e?.name === "AbortError" || e?.code === "ABORT_ERR") return { stopReason: "cancelled" }
181
+ return { error: { code: ACP_ERRORS.INTERNAL.code, message: e?.message ?? String(e) } }
182
+ }
183
+ },
184
+
185
+ "session/cancel": (params) => {
186
+ const found = findSession(params)
187
+ if (found.error) return found
188
+ found.session.cancel()
189
+ return {}
190
+ },
191
+
192
+ "session/close": (params) => {
193
+ const found = findSession(params)
194
+ if (!found.error) {
195
+ // Abort any in-flight turn first — the client is gone, the agent must
196
+ // stop consuming LLM tokens and emitting notifications.
197
+ found.session.cancel()
198
+ sessions.delete(String(params.sessionId))
199
+ log(`session ${params.sessionId} closed by client`)
200
+ }
201
+ return {}
202
+ },
203
+
204
+ // ─── M3: persisted slots (thincoder session archive) + config options ───
205
+
206
+ "session/list": () => {
207
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
208
+ const slots = listSlots(getCwd())
209
+ return {
210
+ sessions: slots.map((s) => ({
211
+ id: String(s.slot),
212
+ cwd: getCwd(), // single-cwd model (design §4.5)
213
+ updatedAt: s.updatedAt ?? 0,
214
+ title: s.title ?? "",
215
+ messageCount: s.messageCount ?? 0,
216
+ })),
217
+ }
218
+ },
219
+
220
+ "session/load": async (params) => {
221
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
222
+ const slot = Number(params.sessionId)
223
+ if (!Number.isInteger(slot) || slot < 1) {
224
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
225
+ }
226
+ const data = loadSlotFile(getCwd(), slot)
227
+ if (!data) {
228
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found (corrupt or deleted)` } }
229
+ }
230
+ try {
231
+ const id = String(nextId++)
232
+ const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
233
+ applySession(session.agent, data)
234
+ sessions.set(id, session)
235
+ // Replay the human line (role → chunk mapping, design §4.5) so the
236
+ // client renders the restored conversation.
237
+ replayHistory({ sessionId: id, notify: notifyRef.current, history: data.history, log })
238
+ log(`session ${slot} loaded as session ${id} (${data.history?.length ?? 0} messages replayed)`)
239
+ return { id, cwd: getCwd(), configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
240
+ } catch (e) {
241
+ return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to load session ${slot}: ${e.message}` } }
242
+ }
243
+ },
244
+
245
+ "session/resume": async (params) => {
246
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
247
+ const slot = Number(params.sessionId)
248
+ if (!Number.isInteger(slot) || slot < 1) {
249
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
250
+ }
251
+ const data = loadSlotFile(getCwd(), slot)
252
+ if (!data) {
253
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found (corrupt or deleted)` } }
254
+ }
255
+ try {
256
+ const id = String(nextId++)
257
+ const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
258
+ applySession(session.agent, data)
259
+ sessions.set(id, session)
260
+ // resume: no history replay — the client keeps its own rendering.
261
+ log(`session ${slot} resumed as session ${id} (no replay)`)
262
+ return { id, cwd: getCwd(), configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
263
+ } catch (e) {
264
+ return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to resume session ${slot}: ${e.message}` } }
265
+ }
266
+ },
267
+
268
+ "session/delete": (params) => {
269
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
270
+ const slot = Number(params.sessionId)
271
+ if (!Number.isInteger(slot) || slot < 1) {
272
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
273
+ }
274
+ // Only the persisted archive is removed; an active in-memory session
275
+ // with the same id keeps running (design §4.5).
276
+ if (!deleteSlot(getCwd(), slot)) {
277
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found` } }
278
+ }
279
+ log(`session ${slot} archive deleted`)
280
+ return {}
281
+ },
282
+
283
+ "session/set_config_option": (params) => {
284
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
285
+ const found = findSession(params)
286
+ if (found.error) return found
287
+ const { configId, value } = params
288
+ if (!configId || value === undefined) {
289
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "set_config_option requires configId and value" } }
290
+ }
291
+ const applied = applyConfigOption(found.session.agent, configId, value)
292
+ if (!applied) {
293
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `unknown configId: ${configId}` } }
294
+ }
295
+ // Last-write-wins on the internal state; notify the client of the change.
296
+ notifyRef.current("session/update", {
297
+ sessionId: String(params.sessionId),
298
+ update: { sessionUpdate: "config_option_update", configId, value },
299
+ })
300
+ return {}
301
+ },
302
+
303
+ "session/set_mode": (params) => {
304
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
305
+ const found = findSession(params)
306
+ if (found.error) return found
307
+ if (params.mode !== "plan" && params.mode !== "normal") {
308
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "mode must be plan or normal" } }
309
+ }
310
+ found.session.agent.planMode = params.mode === "plan"
311
+ notifyRef.current("session/update", {
312
+ sessionId: String(params.sessionId),
313
+ update: { sessionUpdate: "current_mode_update", mode: params.mode },
314
+ })
315
+ return {}
316
+ },
317
+ },
318
+ sessions,
319
+ notifyRef,
320
+ requestRef,
321
+ }
322
+ }
323
+
324
+ /** `thincoder acp` — start the server and block until the client closes the pipe. */
325
+ export async function runAcpServer() {
326
+ const log = (...a) => process.stderr.write(a.join(" ") + "\n")
327
+ // Build handlers first, then wire the transport — no window where requests
328
+ // hit an empty handler map. notifyRef/requestRef become live with the server.
329
+ const built = buildAcpHandlers({ log })
330
+ const server = createAcpServer(built.handlers, { log })
331
+ built.notifyRef.current = server.notify
332
+ built.requestRef.current = server.request
333
+ log(`[acp] thincoder ${VERSION} — ACP v1 over stdio, waiting for initialize`)
334
+ server.start()
335
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * citations.mjs — host-verified citation checking (decision d698434).
3
+ * Extracted from advisor/run.mjs (file-size split). The evidence rule becomes
4
+ * a host fact: every `file:line: content` reference in a review is mechanically
5
+ * checked against the CURRENT disk state; mismatches mark the finding
6
+ * unverified and cannot support a push-back.
7
+ */
8
+ import { readFileSync, realpathSync } from "node:fs"
9
+ import { resolve, sep } from "node:path"
10
+
11
+ // `file:line: content` citations — the file group is narrowed to source/config
12
+ // extensions so URLs (`example.com:8080: …`) don't become false-positive
13
+ // citations that fail as "file unreadable" in the verification report.
14
+ // Single-letter extensions (c/h) are kept — false positives (e.g. "a.c:1: x")
15
+ // are rare and only add a failed-citation line to the report; the report is
16
+ // advisory for the parent agent, never a crash path.
17
+ const CITATION_RE = /([\w./\\-]+\.(?:mjs|cjs|js|ts|jsx|tsx|mts|cts|py|rs|go|c|h|cpp|hpp|java|rb|php|sh|bash|json|md|markdown|mdx|yaml|yml|toml|css|html)):(\d+):\s*([^`\n]{4,})/g
18
+
19
+ /** Extract `file:line: content` citations from a review text. */
20
+ export function extractCitations(text) {
21
+ const out = []
22
+ for (const m of text.matchAll(CITATION_RE)) {
23
+ out.push({ file: m[1], line: Number(m[2]), content: m[3].trim() })
24
+ }
25
+ return out
26
+ }
27
+
28
+ /**
29
+ * Mechanically verify citations against the CURRENT file state: read the file,
30
+ * take the exact line, check it CONTAINS the quoted content. Reports
31
+ * N/M matched + the mismatches. Unverified citations cannot support a
32
+ * push-back — the evidence rule becomes a host fact, not a prompt wish.
33
+ */
34
+ export function verifyCitations(text, cwd) {
35
+ const citations = extractCitations(text)
36
+ const matched = []
37
+ const failed = []
38
+ const root = resolve(cwd) + sep
39
+ for (const c of citations) {
40
+ try {
41
+ // Path confinement: citation paths are LLM-generated — never trust them.
42
+ // A hallucinated "../config.json" would otherwise read (and leak via the
43
+ // report) files outside the project, including API-key configs.
44
+ // realpathSync resolves symlinks too — a link inside the project that
45
+ // points outside must not pass the prefix check.
46
+ const resolved = realpathSync(resolve(cwd, c.file))
47
+ if (!resolved.startsWith(root)) {
48
+ failed.push({ ...c, reason: "path traversal" })
49
+ continue
50
+ }
51
+ const line = readFileSync(resolved, "utf8").split("\n")[c.line - 1] ?? ""
52
+ if (line.includes(c.content)) matched.push(c)
53
+ else failed.push(c)
54
+ } catch {
55
+ failed.push({ ...c, reason: "file unreadable" })
56
+ }
57
+ }
58
+ return { total: citations.length, matched, failed }
59
+ }
60
+
61
+ /** Append the verification report to the review text (visible to the parent agent). */
62
+ export function appendCitationReport(text, cwd) {
63
+ const { total, matched, failed } = verifyCitations(text, cwd)
64
+ if (total === 0) return text // no citations — nothing to verify
65
+ const lines = [
66
+ "",
67
+ "---",
68
+ `[host-verified] ${matched.length}/${total} citations match current file state.`,
69
+ ]
70
+ if (failed.length > 0) {
71
+ lines.push("Citations that do NOT match the current file state (treat their claims as unverified):")
72
+ for (const f of failed.slice(0, 10)) {
73
+ lines.push(`- ${f.file}:${f.line}: ${f.content.slice(0, 80)}${f.reason ? ` (${f.reason})` : ""}`)
74
+ }
75
+ }
76
+ return text + lines.join("\n")
77
+ }
@@ -6,6 +6,8 @@ import { join } from "node:path"
6
6
 
7
7
  export const ADVISOR_MD_PATH = ".thincoder/advisor.md"
8
8
  export const ADVISOR_TABLE_HEADER = "| # | File | Severity | Issue | Suggestion |"
9
+ // Design-review table header (advisor-design.md round 1): | # | Category | Severity | Issue | Suggestion |
10
+ const DESIGN_TABLE_HEADER = "| # | Category | Severity | Issue | Suggestion |"
9
11
  const CONVERGENCE_TABLE_HEADER = "| # | Orig# | File | Severity | Status | Notes |"
10
12
  const AGENT_RESPONSE_HEADER = "| # | Action | Detail |"
11
13
  export const LEGACY_ADVISOR_HEADER = "| # | 文件 | 严重程度 | 问题描述 | 建议修复 |"
@@ -25,11 +27,19 @@ const DEFAULT_CRITERIA = `Review the code changes, focusing on:
25
27
  * Returns null when: no advisor call, empty output, or the last review is
26
28
  * all-clear (nothing to follow up on).
27
29
  */
30
+ // All-clear phrases the prompts instruct the advisor to use on a clean review.
31
+ // Used ONLY by extractPriorIssueTable (issue-table verdict — a phrase-free
32
+ // issue table with rows is never all-clear). The round-reset guard no longer
33
+ // depends on model output at all: prepareAdvisorMessages decides by the
34
+ // deterministic _mutatedThisRun flag (user decision 2026-08-05). "no new
35
+ // issues" is DELIBERATELY absent — verification-table outputs (round 2+)
36
+ // commonly conclude with it (round 3+ instructions even SAY "do not look for
37
+ // new issues"); treating it as all-clear would reset the convergence budget
38
+ // after every round-2 review (the observed "always round 2" bug: prior → null
39
+ // → _advisorRound reset → 1→2→1…).
40
+ const ALL_CLEAR_PHRASES = ["no 🔴", "all clear", "全部通过", "review passed", "no issues found", "everything is fine"]
41
+
28
42
  export function extractPriorIssueTable(history) {
29
- // allClear: exact phrases the prompts instruct the advisor to use on a clean review.
30
- // NOTE: "已修复" (Fixed) is NOT here — it's a per-row Status value in convergence tables,
31
- // and a mixed table must continue convergence even if some rows are Fixed.
32
- const allClear = ["no 🔴", "all clear", "全部通过", "review passed", "no issues found", "no new issues"]
33
43
  // Negative signals: a table listing SOME items as unfixed is NOT all-clear.
34
44
  // Applied when the message carries a Status column (English or Chinese convergence
35
45
  // format) — "failed"/"❌" in a round-1 Issue description must NOT trigger it.
@@ -44,6 +54,7 @@ export function extractPriorIssueTable(history) {
44
54
  // header constants' own source code (e.g. history.mjs), producing a phantom
45
55
  // "prior issue table" and re-opening convergence rounds against stale data.
46
56
  if (!lineHasHeader(m.content, ADVISOR_TABLE_HEADER)
57
+ && !lineHasHeader(m.content, DESIGN_TABLE_HEADER)
47
58
  && !lineHasHeader(m.content, CONVERGENCE_TABLE_HEADER)
48
59
  && !lineHasHeader(m.content, LEGACY_ADVISOR_HEADER)) continue
49
60
  const text = m.content
@@ -52,8 +63,16 @@ export function extractPriorIssueTable(history) {
52
63
  // column) — Chinese legacy tables lack it, but they are issue tables, not convergence.
53
64
  const hasStatusColumn = lineHasHeader(text, CONVERGENCE_TABLE_HEADER)
54
65
  || /Status|状态/.test(text.slice(0, text.indexOf("\n") + 1))
55
- if (hasStatusColumn && partiallyFixedRe.test(lower)) return { text, sinceIdx: i }
56
- if (allClear.some((s) => lower.includes(s))) return null
66
+ if (hasStatusColumn) {
67
+ // Convergence/verification table: pass/fail is row-level — free-text
68
+ // phrases like "no new issues found" / "review passed" are partial or
69
+ // boilerplate statements in verification outputs. Some row unfixed →
70
+ // convergence continues; every row fixed → all clear (fresh cycle).
71
+ if (partiallyFixedRe.test(lower)) return { text, sinceIdx: i }
72
+ return null
73
+ }
74
+ // Issue table (round 1 / design): phrase-based all-clear detection.
75
+ if (ALL_CLEAR_PHRASES.some((s) => lower.includes(s))) return null
57
76
  // sinceIdx = the advisor call's own index; extractAgentResponseTable skips it (role !== assistant)
58
77
  return { text, sinceIdx: i }
59
78
  }
@@ -4,30 +4,31 @@
4
4
  * (.thincoder/advisor.md). System prompts live in advisor.mjs / prompts/.
5
5
  */
6
6
  import { readFileSync } from "node:fs"
7
- import { resolve } from "node:path"
7
+ import { resolve, join, relative } from "node:path"
8
8
  import { findReviewRepos, collectRepoSnapshots, collectChangedFiles } from "./repos.mjs"
9
9
  import { loadAdvisorMd, extractConversationBackground, extractAgentResponseTable, extractPriorIssueTable } from "./history.mjs"
10
10
 
11
11
  /**
12
12
  * Build the user message for an advisor review session.
13
13
  * @param {Object} agent — the parent agent
14
- * @param {Object|null} [_prior] — prior issue table
14
+ * @param {Object|null} [prior] — prior issue table
15
15
  * @param {string} [reviewType] — "design" or "code" (default)
16
16
  * @param {string|null} [designToken] — token injected into the design-review prompt; the advisor echoes it only on approval
17
17
  * @param {string[]|null} [documents] — design review only: explicit list of doc paths to review (requirements + design + referenced docs).
18
18
  * When set, the review input is built from this list ONLY — no git-diff change-set collection.
19
19
  * When absent, the legacy git-diff-based scope is kept (backward compatible).
20
+ * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review (deduped; shown under Review Scope)
20
21
  * @returns {string} the user message
21
22
  */
22
- export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken = null, documents = null, paths = null) {
23
- const prior = _prior ?? extractPriorIssueTable(agent.history)
23
+ export function buildAdvisorUserMessage(agent, prior, reviewType, designToken = null, documents = null, paths = null) {
24
+ const p = prior ?? extractPriorIssueTable(agent.history)
24
25
 
25
26
  const parts = []
26
27
  const docList = Array.isArray(documents) ? documents.filter((d) => typeof d === "string" && d.trim()) : []
27
- const pathList = Array.isArray(paths) ? paths.filter((p) => typeof p === "string" && p.trim()) : []
28
+ const pathList = Array.isArray(paths) ? [...new Set(paths.filter((p) => typeof p === "string" && p.trim()))] : []
28
29
 
29
30
  // Design review: simplified message — focus on the design doc, not code
30
- if (reviewType === "design") {
31
+ if (reviewType === "design" && (agent._advisorRound || 0) === 0) {
31
32
  const repos = findReviewRepos(agent)
32
33
  parts.push("## Design Review")
33
34
  if (docList.length > 0) {
@@ -95,25 +96,35 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
95
96
  return parts.join("\n")
96
97
  }
97
98
 
98
- // Convergence data (round 2+). Design reviews returned above only code reviews reach here.
99
- if (prior && (agent._advisorRound || 0) > 0) {
100
- const response = extractAgentResponseTable(agent.history, prior.sinceIdx)
101
- || "(Agent did not provide a response table re-evaluate each issue)"
99
+ // Convergence data (round 2+). LEGACY COMPATIBILITY PATH: the normal advisor
100
+ // flow routes convergence rounds through buildAdvisorFollowUp (fresh session,
101
+ // decision d698434); this block only fires for direct external callers of
102
+ // buildAdvisorUserMessage with a prior table. Kept to avoid breaking those.
103
+ // Same rule as buildAdvisorFollowUp: prior table IS injected (decision
104
+ // 2026-08-05, reversed) — it is the only complete verification list.
105
+ if (p && (agent._advisorRound || 0) > 0) {
106
+ const scopeFiles = resolveScopeFiles(agent, paths)
107
+ const response = extractAgentResponseTable(agent.history, p.sinceIdx)
108
+ || (scopeFiles?.length
109
+ ? "(Agent did not provide a response table — perform a fresh review of: " + scopeFiles.slice(0, 10).join(", ") + ")"
110
+ : "(Agent did not provide a response table — perform a fresh review of the files named in the system prompt context)")
102
111
  const round = (agent._advisorRound || 0) + 1
103
112
  const label = round === 2 ? "Verify Prior Table + Flag New Issues" : "Strict Verification"
104
113
  parts.push(`## Round ${round} — ${label}`)
105
114
  parts.push("")
106
- parts.push("## Prior Issue Table")
107
- parts.push(prior.text)
115
+ parts.push("## Prior Issue Table (verify every item)")
116
+ parts.push(p.text)
108
117
  parts.push("")
109
- parts.push("## Agent Response")
118
+ parts.push("## Agent Response (fix claims — reference only)")
110
119
  parts.push(response)
111
120
  parts.push("")
112
121
  parts.push("---")
113
122
  parts.push("")
114
123
  }
115
124
 
116
- parts.push("## Review Scope")
125
+ if (pathList.length > 0 || docList.length > 0) {
126
+ parts.push("## Review Scope")
127
+ }
117
128
  if (pathList.length > 0) {
118
129
  parts.push("Review these code files/directories — read them in full for context:")
119
130
  parts.push("")
@@ -159,18 +170,15 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
159
170
  }
160
171
 
161
172
  // Instructions — round-aware: re-reviews skip convention discovery entirely
162
- const isReReview = prior && (agent._advisorRound || 0) > 0
173
+ const isReReview = p && (agent._advisorRound || 0) > 0
163
174
  parts.push("## Instructions")
164
- parts.push("1. IMPORTANT: in the diff, `-` lines are REMOVED content (no longer in the file), `+` lines are ADDED. The prior issue table (if any) is HISTORY — always verify current file state with `read` before judging an item.")
175
+ parts.push("1. IMPORTANT: the review scope lists the files under review — always verify current file state with `read` before judging. Never decide based on earlier snapshots alone.")
165
176
  if (isReReview) {
166
- parts.push("2. STALE-CONTEXT WARNING: any diff or file content embedded in earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state. Never quote a `-` line from an earlier diff as if it were live code.")
167
- parts.push("3. Verify the prior issue table against the CURRENT FILE STATE — use `read`, never `git diff` alone. Fixes may already be committed: an empty `git diff` does NOT mean nothing changed. `git log -3` shows recent commits.")
168
- parts.push("4. `read` the files in the Review Scope in full — ALWAYS, regardless of what `git diff` shows. Batch reads/greps in a single reply.")
169
- parts.push("5. Evidence rule: every 'Unfixed'/'New' finding 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. Findings without a fresh quoted line are treated as unverified and will not be accepted.")
170
- parts.push("6. Produce your verification table. Do not re-read content you already have.")
177
+ const round = (agent._advisorRound || 0) + 1
178
+ parts.push(...buildConvergenceInstructions(round, pathList))
171
179
  } else {
172
180
  parts.push("2. Read `AGENTS.md` / design docs only if they exist (check once; do not re-probe with multiple patterns).")
173
- parts.push("3. `read` changed files for full context beyond the diff. Batch independent reads/greps in a single reply instead of one call per round-trip.")
181
+ parts.push("3. `read` the files in the Review Scope in full they define exactly what to inspect. Batch independent reads/greps in a single reply instead of one call per round-trip.")
174
182
  parts.push("4. Use `grep` or `lsp` to trace callers, imports, and dependencies — only where the diff leaves genuine doubt.")
175
183
  parts.push("5. Produce your review table based on the review criteria above. Do not re-read content you already have.")
176
184
  parts.push("6. You may also flag other issues: crashes, data loss, logic errors — anything obvious. This is the convergence protocol: round 1 is the full review, later rounds only re-verify.")
@@ -180,3 +188,49 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
180
188
 
181
189
  return parts.join("\n")
182
190
  }
191
+
192
+ /**
193
+ * Resolve the review surface for the convergence fallback: explicit `paths`
194
+ * win; otherwise the runtime mutation record (_touchedFiles, ABSOLUTE) is
195
+ * normalized to cwd-relative so the fallback list matches the relative-path
196
+ * norm the reviewer sees everywhere else. Paths outside cwd are relativized
197
+ * with path.relative — never a mixed absolute/relative list.
198
+ */
199
+ export function resolveScopeFiles(agent, paths) {
200
+ const normalize = (p) => {
201
+ const abs = p.startsWith(agent.cwd) ? p : join(agent.cwd, p)
202
+ return relative(agent.cwd, abs)
203
+ }
204
+ if (Array.isArray(paths)) return [...new Set(paths.map(normalize))]
205
+ if (agent._touchedFiles?.length) {
206
+ return [...new Set(agent._touchedFiles.map(normalize))]
207
+ }
208
+ return null
209
+ }
210
+
211
+ /**
212
+ * Shared convergence-round instructions — single source for BOTH paths
213
+ * (buildAdvisorUserMessage's legacy convergence block and
214
+ * buildAdvisorFollowUp), so the wording cannot diverge.
215
+ * Round 2 may flag obvious new issues; round 3+ is strict verification.
216
+ * @param {number} round — convergence round number (2+)
217
+ * @param {string[]|null} scopeFiles — optional file list for the no-response fallback
218
+ * @returns {string[]} the numbered instruction lines (callers spread them)
219
+ */
220
+ export function buildConvergenceInstructions(round, scopeFiles = null) {
221
+ const fileList = scopeFiles?.length
222
+ ? ` The review surface is: ${scopeFiles.slice(0, 10).join(", ")}.`
223
+ : ""
224
+ return [
225
+ `1. IMPORTANT: verify EVERY item of the prior issue table against the CURRENT FILE STATE with \`read\` — never decide based on earlier snapshots alone.${fileList}`,
226
+ "2. STALE-CONTEXT WARNING: any diff or file content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.",
227
+ "3. You have no git tool; git output in earlier messages is historical and untrustworthy (committed fixes never show in a diff).",
228
+ "4. `read` the files named in the prior table (or the review surface above) in full — ALWAYS. Batch reads/greps in a single reply.",
229
+ "5. Evidence rule: every 'Unfixed'/'New' finding 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 be stale or fabricated. Findings without a fresh quoted line are treated as unverified and will not be accepted.",
230
+ "6. Produce your verification table. Do not re-read content you already have.",
231
+ round === 2
232
+ ? "7. You may flag obvious NEW issues introduced by the fixes (crashes, data loss, logic errors — not style)."
233
+ : "7. Do NOT look for new issues.",
234
+ ]
235
+ }
236
+