thincoder 0.12.2 → 0.12.4

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.
Files changed (85) hide show
  1. package/README.md +29 -6
  2. package/package.json +3 -3
  3. package/src/advisor/history.mjs +112 -0
  4. package/src/advisor/messages.mjs +182 -0
  5. package/src/advisor/repos.mjs +133 -0
  6. package/src/advisor/run.mjs +346 -0
  7. package/src/advisor.mjs +109 -509
  8. package/src/agent/completion.mjs +134 -0
  9. package/src/agent/dispatch.mjs +54 -7
  10. package/src/agent/post-turn.mjs +70 -0
  11. package/src/agent/setup.mjs +95 -6
  12. package/src/agent-tools/advisor.mjs +159 -12
  13. package/src/agent-tools/eng.mjs +64 -0
  14. package/src/agent-tools/subagent.mjs +73 -3
  15. package/src/agent-tools/task.mjs +45 -6
  16. package/src/agent-tools/verify.mjs +18 -0
  17. package/src/agent-tools.mjs +1 -0
  18. package/src/agent.mjs +152 -161
  19. package/src/cli/make-agent.mjs +1 -0
  20. package/src/cli/setup-wizard.mjs +1 -0
  21. package/src/config.mjs +34 -4
  22. package/src/context.mjs +47 -13
  23. package/src/generate-title.mjs +44 -0
  24. package/src/prompts/advisor-design.md +43 -0
  25. package/src/prompts/advisor-round1.md +11 -4
  26. package/src/prompts/advisor-round2.md +12 -7
  27. package/src/prompts/advisor-round3.md +11 -6
  28. package/src/prompts/coder.md +9 -3
  29. package/src/prompts/discipline.md +12 -96
  30. package/src/prompts/eng-coder.md +34 -0
  31. package/src/prompts/engineering-sub.md +12 -0
  32. package/src/prompts/engineering.md +96 -0
  33. package/src/prompts/main.md +1 -1
  34. package/src/prompts/methodology-template.md +39 -0
  35. package/src/prompts/plan.md +2 -2
  36. package/src/prompts/system.md +43 -61
  37. package/src/provider/core.mjs +58 -2
  38. package/src/session.mjs +291 -94
  39. package/src/skills.mjs +48 -15
  40. package/src/tools/apply_patch.md +1 -1
  41. package/src/tools/checklist.mjs +4 -3
  42. package/src/tools/codemode.mjs +23 -11
  43. package/src/tools/delete.md +1 -0
  44. package/src/tools/edit.md +1 -1
  45. package/src/tools/execute.md +5 -0
  46. package/src/tools/file.mjs +4 -0
  47. package/src/tools/git.md +15 -0
  48. package/src/tools/git.mjs +1 -6
  49. package/src/tools/lint.md +8 -0
  50. package/src/tools/linter.mjs +1 -5
  51. package/src/tools/lsp.md +7 -0
  52. package/src/tools/lsp.mjs +8 -9
  53. package/src/tools/patch.mjs +1 -29
  54. package/src/tools/read_image.md +5 -1
  55. package/src/tools/system.mjs +1 -1
  56. package/src/tools/web.mjs +3 -3
  57. package/src/tui/agent-turn.mjs +184 -66
  58. package/src/tui/ansi.mjs +4 -0
  59. package/src/tui/clipboard.mjs +9 -0
  60. package/src/tui/cmd-config.mjs +14 -26
  61. package/src/tui/cmd-eng.mjs +44 -0
  62. package/src/tui/cmd-exit.mjs +1 -1
  63. package/src/tui/cmd-fold.mjs +3 -4
  64. package/src/tui/cmd-model.mjs +11 -6
  65. package/src/tui/cmd-new.mjs +5 -5
  66. package/src/tui/cmd-session.mjs +21 -11
  67. package/src/tui/cmd-think.mjs +1 -0
  68. package/src/tui/index.mjs +20 -9
  69. package/src/tui/key-handler.mjs +177 -9
  70. package/src/tui/layout.mjs +5 -5
  71. package/src/tui/markdown.mjs +52 -0
  72. package/src/tui/pickers.mjs +190 -45
  73. package/src/tui/render-conversation.mjs +54 -13
  74. package/src/tui/render-frame.mjs +39 -12
  75. package/src/tui/render-loop.mjs +2 -1
  76. package/src/tui/render.mjs +13 -7
  77. package/src/tui/slash-commands.mjs +11 -7
  78. package/src/tui/startup.mjs +4 -3
  79. package/src/tui/wizard.mjs +3 -0
  80. package/src/tools/checkpoint.md +0 -15
  81. package/src/tools/git_diff.md +0 -11
  82. package/src/tools/git_log.md +0 -10
  83. package/src/tools/git_status.md +0 -8
  84. package/src/tools/linter.md +0 -13
  85. package/src/tools/syntax_check.md +0 -10
package/src/advisor.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * advisor.mjs — Code review engine.
3
- * Called by the advisor tool (agent-tools/advisor.mjs) when the agent
4
- * explicitly requests a review at the end of a coding task.
2
+ * advisor.mjs — advisor system-prompt selection, follow-up building, session assembly.
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; history extraction in advisor/history.mjs.
5
6
  *
6
7
  * The advisor runs as a read-only exploration sub-agent with tools
7
8
  * (read, glob, grep, ls, git, lsp, code_search). It discovers changes
@@ -16,7 +17,11 @@
16
17
  * Agent responds with a response table per issue.
17
18
  * Round 2: semi-convergence — verifies table + can flag obvious new issues.
18
19
  * Round 3+: strict convergence — only checks the prior issue table.
19
- * No hard round cap the convergence protocol naturally limits divergence.
20
+ * Each round replaces the system prompt (ROUND1 ROUND2 → ROUND3) so the
21
+ * round-1 full-scope mandate can't bleed into later rounds, plus a mechanical
22
+ * cap (MAX_ADVISOR_ROUNDS in run.mjs) refuses a 6th review call outright.
23
+ * Rounds 2+ also declare all earlier diffs STALE and require read-verified
24
+ * file:line evidence for any unfixed/new finding — see docs/design/ADVISOR-CONVERGENCE.md.
20
25
  *
21
26
  * Session memory (agent._advisorSession):
22
27
  * All advisor calls within one run share a single conversation — round 2+
@@ -27,258 +32,49 @@
27
32
  * in-memory session is gone — falls back to a fresh session seeded from the
28
33
  * issue/response tables in the main history.
29
34
  *
30
- *
31
35
  * Project customisation: .thincoder/advisor.md in the project root.
32
36
  */
33
- import { chat } from "./provider/core.mjs"
34
- import { findProvider } from "./config.mjs"
35
- import { existsSync, readFileSync } from "node:fs"
37
+ import { readFileSync } from "node:fs"
36
38
  import { join, dirname } from "node:path"
37
39
  import { fileURLToPath } from "node:url"
38
- import { execFileSync } from "node:child_process"
39
- import { toOpenAISchema } from "./tools/index.mjs"
40
+ import { createHash } from "node:crypto"
41
+ import { findReviewRepos, collectRepoSnapshots } from "./advisor/repos.mjs"
42
+ import { extractPriorIssueTable, extractAgentResponseTable } from "./advisor/history.mjs"
43
+ import { buildAdvisorUserMessage } from "./advisor/messages.mjs"
44
+ // 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"
46
+ export { buildAdvisorUserMessage } from "./advisor/messages.mjs"
40
47
 
41
48
  const __dirname = dirname(fileURLToPath(import.meta.url))
42
49
 
43
- const ADVISOR_MD_PATH = ".thincoder/advisor.md"
44
- const GIT_TIMEOUT = 5_000
45
-
46
- const DEFAULT_CRITERIA = `Review the code changes, focusing on:
47
- 1. Correctness: logic errors, edge cases, off-by-one, incomplete modifications
48
- 2. Security: unhandled exceptions, null references, resource leaks, race conditions
49
- 3. Consistency: alignment with existing project patterns and conventions
50
- 4. Completeness: missing callers, imports, or follow-up changes
51
- 5. Maintainability: vague naming, missing comments, overly complex logic`
52
-
53
- // ────────────────────────────────────────
54
- // Advisor's read-only tool set
55
- // ────────────────────────────────────────
56
-
57
- /**
58
- * Restricted git tool: diff / status / log only.
59
- * Checkpoint create/rewind are blocked — the advisor must not mutate state.
60
- */
61
- const { gitTool, readTool, globTool, grepTool, lsTool } = await import("./tools/index.mjs")
62
- const { lspTool } = await import("./tools/lsp.mjs")
63
- const { codeModeTool: codeSearchTool } = await import("./tools/codemode.mjs")
64
-
65
- const advisorGitTool = {
66
- ...gitTool,
67
- readonly: true,
68
- async execute(args, ctx) {
69
- // Block checkpoint create/rewind — advisor is read-only
70
- if (args.action === "checkpoint") {
71
- if (args.checkpointAction === "create" || args.checkpointAction === "rewind") {
72
- return "Error: checkpoint create/rewind is disabled in advisor mode. Use diff/status/log only."
73
- }
74
- }
75
- return gitTool.execute(args, ctx)
76
- },
77
- }
78
-
79
- const ADVISOR_TOOLS = [readTool, globTool, grepTool, lsTool, advisorGitTool, lspTool, codeSearchTool]
80
- const ADVISOR_TOOL_SCHEMAS = ADVISOR_TOOLS.map(toOpenAISchema)
81
- const ADVISOR_TOOL_BY_NAME = new Map(ADVISOR_TOOLS.map((t) => [t.name, t]))
82
-
83
50
  // ────────────────────────────────────────
84
51
  // Prompt files — loaded at module init
85
52
  // ────────────────────────────────────────
86
53
 
87
54
  const ADVISOR_ROUND1 = readFileSync(join(__dirname, "prompts", "advisor-round1.md"), "utf8")
55
+ // ROUND2/3 are used whenever a convergence round (round 2+) is being built:
56
+ // in-run session continuation replaces the system prompt with them, and a
57
+ // rebuilt fresh session (e.g. after a failed review) also selects them via
58
+ // buildAdvisorSystemPrompt when _advisorRound > 0.
88
59
  const ADVISOR_ROUND2 = readFileSync(join(__dirname, "prompts", "advisor-round2.md"), "utf8")
89
60
  const ADVISOR_ROUND3 = readFileSync(join(__dirname, "prompts", "advisor-round3.md"), "utf8")
61
+ let ADVISOR_DESIGN = ""
62
+ try { ADVISOR_DESIGN = readFileSync(join(__dirname, "prompts", "advisor-design.md"), "utf8") } catch { /* design review unavailable */ }
90
63
 
91
64
  // ────────────────────────────────────────
92
- // History extraction — issue/response tables
93
- // ────────────────────────────────────────
94
-
95
- const ADVISOR_TABLE_HEADER = "| # | File | Severity | Issue | Suggestion |"
96
- const CONVERGENCE_TABLE_HEADER = "| # | Orig# | File | Severity | Status | Notes |"
97
- const AGENT_RESPONSE_HEADER = "| # | Action | Detail |"
98
- const LEGACY_ADVISOR_HEADER = "| # | 文件 | 严重程度 | 问题描述 | 建议修复 |"
99
- const LEGACY_CONVERGENCE_HEADER = "| # | 原# | 文件 | 严重程度 | 当前状态 | 说明 |"
100
- const LEGACY_RESPONSE_HEADER = "| # | 处理 | 详情 |"
101
- const ALL_CLEAR_PHRASES = [
102
- "No issues found",
103
- "All issues resolved",
104
- "review passed",
105
- "未发现问题",
106
- "所有问题已解决",
107
- "审查通过",
108
- ]
109
-
110
- export function extractPriorIssueTable(history) {
111
- for (let i = history.length - 1; i >= 0; i--) {
112
- const m = history[i]
113
- if (m.role !== "tool") continue
114
- const content = typeof m.content === "string" ? m.content : ""
115
- if (ALL_CLEAR_PHRASES.some((p) => content.includes(p))) return null
116
- if (content.includes(ADVISOR_TABLE_HEADER) || content.includes(CONVERGENCE_TABLE_HEADER) ||
117
- content.includes(LEGACY_ADVISOR_HEADER) || content.includes(LEGACY_CONVERGENCE_HEADER)) {
118
- const table = extractTableBlock(content)
119
- if (table) return { text: table, sinceIdx: i }
120
- return null
121
- }
122
- }
123
- return null
124
- }
125
-
126
- export function extractAgentResponseTable(history, sinceIdx) {
127
- for (let i = sinceIdx + 1; i < history.length; i++) {
128
- const m = history[i]
129
- if (m.role !== "assistant") continue
130
- const content = typeof m.content === "string" ? m.content : ""
131
- if (content.includes(AGENT_RESPONSE_HEADER) || content.includes(LEGACY_RESPONSE_HEADER)) {
132
- return extractTableBlock(content)
133
- }
134
- }
135
- return null
136
- }
137
-
138
- function extractTableBlock(text) {
139
- const lines = text.split("\n")
140
- let start = -1
141
- for (let i = 0; i < lines.length; i++) {
142
- if (lines[i].startsWith("|")) { start = i; break }
143
- }
144
- if (start < 0) return null
145
- let end = start
146
- for (let i = start + 1; i < lines.length; i++) {
147
- if (lines[i].startsWith("|")) end = i
148
- else break
149
- }
150
- return lines.slice(start, end + 1).join("\n")
151
- }
152
-
153
- // ────────────────────────────────────────
154
- // Review scope — repos to review
155
- // ────────────────────────────────────────
156
-
157
- /**
158
- * Find the git repository roots that contain the agent's touched files.
159
- * Falls back to cwd if no repos found.
160
- */
161
- function findReviewRepos(agent) {
162
- const touched = agent._touchedFiles ?? []
163
- const repos = []
164
-
165
- for (const abs of touched) {
166
- try {
167
- const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
168
- cwd: dirname(abs), encoding: "utf8", timeout: GIT_TIMEOUT,
169
- stdio: ["ignore", "pipe", "pipe"],
170
- }).trim()
171
- if (root && !repos.includes(root)) repos.push(root)
172
- } catch { /* not a git repo */ }
173
- }
174
-
175
- if (repos.length > 0) return repos
176
-
177
- // Fallback: cwd itself
178
- try {
179
- const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
180
- cwd: agent.cwd, encoding: "utf8", timeout: GIT_TIMEOUT,
181
- stdio: ["ignore", "pipe", "pipe"],
182
- }).trim()
183
- if (root) return [root]
184
- } catch { /* not a git repo */ }
185
-
186
- return []
187
- }
188
-
65
+ // System prompt building
189
66
  // ────────────────────────────────────────
190
67
 
191
- /** Cap per-repo embedded diff — generous (large-context models); advisor can fetch the rest via its git tool */
192
- const MAX_EMBEDDED_DIFF = 50_000
193
-
194
68
  /**
195
- * Collect git status + diff for each repo, embedded into the review context so
196
- * the advisor doesn't need to spend its first tool calls discovering changes.
69
+ * Build the system prompt for an advisor review session.
70
+ * @param {Object} agent the parent agent
71
+ * @param {Object|null} [_prior] — prior issue table (from extractPriorIssueTable)
72
+ * @param {string} [reviewType] — "design" for design review, undefined/"code" for code review
73
+ * @returns {string} the system prompt
197
74
  */
198
- function collectRepoSnapshots(repos, cwd) {
199
- const targets = repos.length > 0 ? repos : [cwd]
200
- const parts = []
201
- for (const repo of targets) {
202
- let status = "", diff = ""
203
- try {
204
- status = execFileSync("git", ["status", "--porcelain"], {
205
- cwd: repo, encoding: "utf8", timeout: GIT_TIMEOUT, stdio: ["ignore", "pipe", "pipe"],
206
- }).trim()
207
- diff = execFileSync("git", ["diff", "HEAD"], {
208
- cwd: repo, encoding: "utf8", timeout: GIT_TIMEOUT, stdio: ["ignore", "pipe", "pipe"],
209
- maxBuffer: 8 * 1024 * 1024,
210
- })
211
- } catch { continue /* not a git repo or git failed */ }
212
- if (!status && !diff.trim()) continue
213
- parts.push(`### ${repo}`)
214
- if (status) parts.push("```", status, "```")
215
- if (diff.trim()) {
216
- const truncated = diff.length > MAX_EMBEDDED_DIFF
217
- parts.push("```diff", truncated ? diff.slice(0, MAX_EMBEDDED_DIFF) : diff.trimEnd(), "```")
218
- if (truncated) parts.push(`(diff truncated at ${MAX_EMBEDDED_DIFF} chars — use the git tool to see the rest)`)
219
- }
220
- }
221
- return parts
222
- }
223
-
224
- export function loadAdvisorMd(cwd) {
225
- const path = join(cwd, ADVISOR_MD_PATH)
226
- if (!existsSync(path)) return DEFAULT_CRITERIA
227
- try {
228
- const content = readFileSync(path, "utf8").trim()
229
- return content || DEFAULT_CRITERIA
230
- } catch {
231
- return DEFAULT_CRITERIA
232
- }
233
- }
234
-
235
- const MAX_BACKGROUND_CHARS = 20_000
236
- const MAX_BG_USER_CHARS = 2000
237
- const MAX_BG_ASSISTANT_CHARS = 1500
238
-
239
- /**
240
- * Recent conversation context for the advisor: the last few user↔assistant
241
- * exchanges (default 3 user turns). The last user message alone often lacks
242
- * context ("把那个问题改一下" means nothing without the preceding turns) —
243
- * the advisor needs the background to judge whether the changes match intent.
244
- * Tool messages are skipped (noise); texts are truncated with generous caps
245
- * (models have large context windows — completeness beats frugality).
246
- */
247
- export function extractConversationBackground(history, maxTurns = 3) {
248
- const isNoise = (c) => c.startsWith("[System reminder:") || c.startsWith("[User interrupt:")
249
- const picked = []
250
- let userCount = 0
251
- for (let i = history.length - 1; i >= 0 && userCount < maxTurns; i--) {
252
- const m = history[i]
253
- if (m.role !== "user" && m.role !== "assistant") continue
254
- const content = typeof m.content === "string" ? m.content.trim() : ""
255
- if (!content || isNoise(content)) continue
256
- picked.unshift({ role: m.role === "user" ? "User" : "Assistant", text: content })
257
- if (m.role === "user") userCount++
258
- }
259
- if (picked.length === 0) return null
260
-
261
- const lines = picked.map((e) => {
262
- const cap = e.role === "User" ? MAX_BG_USER_CHARS : MAX_BG_ASSISTANT_CHARS
263
- const text = e.text.length > cap ? e.text.slice(0, cap) + "…" : e.text
264
- return `${e.role}: ${text}`
265
- })
266
- // Keep the most recent lines within the total budget
267
- const out = []
268
- let total = 0
269
- for (let i = lines.length - 1; i >= 0; i--) {
270
- if (out.length > 0 && total + lines[i].length > MAX_BACKGROUND_CHARS) break
271
- total += lines[i].length
272
- out.unshift(lines[i])
273
- }
274
- return out.join("\n")
275
- }
276
-
277
- // ────────────────────────────────────────
278
- // System prompt routing
279
- // ────────────────────────────────────────
280
-
281
- export function buildAdvisorSystemPrompt(agent, _prior) {
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.`
282
78
  const prior = _prior ?? extractPriorIssueTable(agent.history)
283
79
  if (!prior || (agent._advisorRound || 0) === 0) return ADVISOR_ROUND1
284
80
  const round = (agent._advisorRound || 0) + 1
@@ -287,130 +83,59 @@ export function buildAdvisorSystemPrompt(agent, _prior) {
287
83
  }
288
84
 
289
85
  // ────────────────────────────────────────
290
- // User message building
291
- // ────────────────────────────────────────
292
-
293
- export function buildAdvisorUserMessage(agent, _prior) {
294
- const prior = _prior ?? extractPriorIssueTable(agent.history)
295
-
296
- // Repos to review
297
- const repos = findReviewRepos(agent)
298
- const repoList = repos.length > 0
299
- ? repos.map((r, i) => `${i + 1}. ${r}`).join("\n")
300
- : `(no git repository — working directory: ${agent.cwd})`
301
-
302
- const parts = []
303
-
304
- // Convergence data (round 2+)
305
- if (prior && (agent._advisorRound || 0) > 0) {
306
- const response = extractAgentResponseTable(agent.history, prior.sinceIdx)
307
- || "(Agent did not provide a response table — re-evaluate each issue)"
308
- const round = (agent._advisorRound || 0) + 1
309
- const label = round === 2 ? "Verify Prior Table + Flag New Issues" : "Strict Verification"
310
- parts.push(`## Round ${round} — ${label}`)
311
- parts.push("")
312
- parts.push("## Prior Issue Table")
313
- parts.push(prior.text)
314
- parts.push("")
315
- parts.push("## Agent Response")
316
- parts.push(response)
317
- parts.push("")
318
- parts.push("---")
319
- parts.push("")
320
- }
321
-
322
- // Review scope
323
- parts.push("## Review Scope")
324
- parts.push(`Review the following git repositor${repos.length === 1 ? "y" : "ies"}:`)
325
- parts.push(repoList)
326
- parts.push("")
327
-
328
- // Pre-collected changes — saves the advisor from spending its first tool
329
- // calls on discovery (git status / git diff) every single round.
330
- const snapshots = collectRepoSnapshots(repos, agent.cwd)
331
- agent._advisorLastSnapshot = snapshots.join("\n") // dedup baseline for follow-up rounds
332
- if (snapshots.length > 0) {
333
- parts.push("## Current Changes (git status + git diff HEAD, pre-collected)")
334
- parts.push(...snapshots)
335
- parts.push("")
336
- }
337
-
338
- // Conversation background — recent user↔assistant exchanges for intent context
339
- const background = extractConversationBackground(agent.history)
340
- if (background) {
341
- parts.push("## Conversation Background (recent turns)")
342
- parts.push(background)
343
- parts.push("")
344
- }
345
-
346
- // Review criteria
347
- const criteria = loadAdvisorMd(agent.cwd)
348
- parts.push("## Review Criteria")
349
- parts.push(criteria)
350
- parts.push("")
351
-
352
- // Instructions — round-aware: re-reviews skip convention discovery entirely
353
- const isReReview = prior && (agent._advisorRound || 0) > 0
354
- parts.push("## Instructions")
355
- parts.push("1. The uncommitted changes are already provided above — do NOT re-run `git status` / `git diff` unless the embedded diff is marked truncated.")
356
- if (isReReview) {
357
- parts.push("2. Do NOT re-read AGENTS.md / design docs — conventions were established in round 1. Focus on verifying the prior issue table against the current diff.")
358
- parts.push("3. `read` only the files touched by the fixes. Batch independent reads/greps in a single reply.")
359
- parts.push("4. Produce your verification table. Do not re-read content you already have.")
360
- } else {
361
- parts.push("2. Read `AGENTS.md` / design docs only if they exist (check once; do not re-probe with multiple patterns).")
362
- 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.")
363
- parts.push("4. Use `grep` or `lsp` to trace callers, imports, and dependencies — only where the diff leaves genuine doubt.")
364
- parts.push("5. Produce your review table based on the review criteria above. Do not re-read content you already have.")
365
- }
366
- parts.push("Do NOT flag features that are valid under the project's stated platform requirements.")
367
-
368
- return parts.join("\n")
369
- }
370
-
371
- // ────────────────────────────────────────
372
- // Session continuity — one advisor conversation per run
86
+ // Follow-up building (round 2+)
373
87
  // ────────────────────────────────────────
374
88
 
375
89
  /**
376
- * Follow-up message for round 2+ in a continued advisor session.
377
- * The advisor already has full context (its exploration, its issue table) in
378
- * the conversation — the follow-up only carries what changed: the agent's
379
- * response table, the fresh diff snapshot, and this round's rules.
90
+ * Build a follow-up user message for round 2+ the agent's response table +
91
+ * the refreshed diff, without re-sending the full round-1 context.
380
92
  */
381
93
  export function buildAdvisorFollowUp(agent, _prior) {
382
94
  const prior = _prior ?? extractPriorIssueTable(agent.history)
383
- const round = (agent._advisorRound || 0) + 1
384
- const response = (prior ? extractAgentResponseTable(agent.history, prior.sinceIdx) : null)
95
+ const response = extractAgentResponseTable(agent.history, prior?.sinceIdx ?? 0)
385
96
  || "(Agent did not provide a response table — re-evaluate each issue)"
386
- const rules = round === 2
387
- ? "Verify each item in your prior issue table against the current changes. " +
388
- "You may flag obvious NEW issues introduced by the fixes — but only crashes, data loss, or logic errors clearly visible in the diff. Do not nitpick style."
389
- : "Strictly verify only your prior issue table against the current changes. Do NOT look for new issues."
97
+ const round = (agent._advisorRound || 0) + 1
98
+ const label = round === 2 ? "Verify Prior Table + Flag New Issues" : "Strict Verification"
390
99
 
391
100
  const parts = [
392
- `## Round ${round} — ${round === 2 ? "Verify Prior Table + Flag New Issues" : "Strict Verification"}`,
101
+ `## Round ${round} — ${label}`,
393
102
  "",
394
- rules,
103
+ `[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"}.]`,
395
104
  "",
396
- 'If every prior issue is resolved, say exactly: "All issues resolved — review passed."',
105
+ "## Prior Issue Table",
106
+ prior?.text ?? "(no prior table — review from scratch)",
397
107
  "",
398
- "Do NOT re-read AGENTS.md / design docs or re-run git status/diff (current changes are below) — you already have full context from previous rounds.",
399
- "",
400
- "## Agent Response to Your Review",
108
+ "## Agent Response",
401
109
  response,
402
110
  "",
111
+ "## Instructions",
112
+ round === 2
113
+ ? "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."
114
+ : "Strictly verify ONLY the items in the prior table against the current diff. Do NOT look for new issues.",
115
+ "",
116
+ "IMPORTANT: in any embedded diff, `-` lines are REMOVED content (no longer in the file), `+` lines are ADDED. The prior issue table is HISTORY — always verify current file state with `read` before judging an item as fixed or unfixed.",
117
+ // Round-aware evidence rule: "New" entries only exist in round 2 (round 3+ forbids them).
118
+ `STALE-CONTEXT WARNING: all diffs in earlier messages (including round 1) are historical snapshots — files have changed since. Only THIS message's "Current Changes" section and fresh \`read\` results are authoritative. Any "Unfixed" entry${round === 2 ? ' (and any "New" entry)' : ""} MUST cite read-verified evidence (file:line from a \`read\` of the current file); uncited findings are unverified and will be ignored.`,
119
+ "",
120
+ "Do NOT re-read AGENTS.md / design docs or re-run git status/diff (current changes are below) — you already have full context from previous rounds.",
121
+ "",
403
122
  ]
404
123
  const snapshots = collectRepoSnapshots(findReviewRepos(agent), agent.cwd)
405
124
  const snapshotText = snapshots.join("\n")
125
+ const snapshotHash = snapshotText ? createHash("sha1").update(snapshotText).digest("hex") : null
406
126
  // Skip re-pushing an identical diff (e.g. advisor re-run without any file changes) —
407
127
  // the previous snapshot is already in the conversation, duplicating it wastes tokens.
408
- if (snapshotText && snapshotText === agent._advisorLastSnapshot) {
409
- parts.push("## Current Changes", "(No changes since your previous review.)")
410
- } else if (snapshots.length > 0) {
128
+ if (snapshots.length === 0) {
129
+ parts.push("## Current Changes", "(No git repository or no changes detected.)")
130
+ } else if (snapshotHash && snapshotHash === agent._advisorLastSnapshotHash) {
131
+ // Hash match → the snapshot from the previous round is still current.
132
+ // Using hash instead of full-text comparison avoids keeping the entire diff
133
+ // string in memory and handles edge cases (e.g. file changed then reverted).
134
+ parts.push("## Current Changes", "(No changes since your previous review — the diff snapshot is identical, so the one from your last round remains valid for this round.)")
135
+ } else {
411
136
  parts.push("## Current Changes (git status + git diff HEAD, refreshed)", ...snapshots)
412
137
  }
413
- agent._advisorLastSnapshot = snapshotText
138
+ agent._advisorLastSnapshotHash = snapshotHash
414
139
  return parts.join("\n")
415
140
  }
416
141
 
@@ -418,183 +143,58 @@ export function buildAdvisorFollowUp(agent, _prior) {
418
143
  * Build or continue the advisor conversation for this run.
419
144
  * First call in a run: fresh [system, user] session. Later calls: append a
420
145
  * follow-up to the existing session so the advisor keeps its context.
421
- * After an app restart (session lost), falls back to a fresh session whose
422
- * system prompt is picked from history tables (round 2/3 style).
146
+ * After an app restart (session lost), starts a fresh round-1 full review —
147
+ * exploration context is gone, so prior tables from history are not injected.
148
+ * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review
149
+ * @param {string} [reviewType] — "design" or "code" (default)
150
+ * @param {string|null} [designToken] — design-review approval token (design only)
151
+ * @param {string[]|null} [documents] — design review only: explicit list of doc paths to review (passed through to buildAdvisorUserMessage)
423
152
  */
424
- export function prepareAdvisorMessages(agent) {
153
+ export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null) {
425
154
  const prior = extractPriorIssueTable(agent.history)
155
+ // Design review: always fresh session, no convergence
156
+ if (reviewType === "design") {
157
+ return [
158
+ { role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
159
+ { role: "user", content: buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths) },
160
+ ]
161
+ }
426
162
  let session = agent._advisorSession
427
163
  if (session) {
428
- session.push({ role: "user", content: buildAdvisorFollowUp(agent, prior) })
429
- return session
164
+ // Session exists but no prior table (last review was all-clear or none)
165
+ // a follow-up "Verify Prior Table" would be meaningless; start a fresh full review
166
+ if (!prior) {
167
+ agent._advisorSession = null
168
+ agent._advisorLastSnapshotHash = null
169
+ session = null
170
+ // Only reset the round counter on a truly fresh start (no prior reviews at all).
171
+ // If _advisorRound > 0, there WAS a prior review — it just passed (all-clear).
172
+ if (!agent._advisorRound) agent._advisorRound = 0
173
+ } else {
174
+ // Convergence rounds (2+): replace the system prompt so the round-1
175
+ // "full-scope review" mandate cannot override the follow-up's narrowed scope.
176
+ // Without this the model re-runs a full review every round, finds new issues
177
+ // each time, and the protocol never converges. buildAdvisorSystemPrompt
178
+ // returns ROUND2 for round 2 and ROUND3 for round 3+.
179
+ session[0] = { role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) }
180
+ session.push({ role: "user", content: buildAdvisorFollowUp(agent, prior) })
181
+ return session
182
+ }
430
183
  }
431
184
  session = [
432
- { role: "system", content: buildAdvisorSystemPrompt(agent, prior) },
433
- { role: "user", content: buildAdvisorUserMessage(agent, prior) },
185
+ { role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
186
+ { role: "user", content: buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths) },
434
187
  ]
435
- return session
436
- }
437
-
438
- // ────────────────────────────────────────
439
- // Advisor tool loop
440
- // ────────────────────────────────────────
441
-
442
- /**
443
- * Compact one-line summary of tool args for panel progress lines.
444
- * Picks the most identifying field; falls back to truncated JSON.
445
- */
446
- function summarizeToolArgs(args) {
447
- // e.g. "git diff HEAD", "read src/x.mjs" — action first when present
448
- const parts = [args.action, args.path ?? args.pattern ?? args.command].filter((v) => v != null)
449
- let s = parts.length > 0 ? parts.map(String).join(" ") : JSON.stringify(args)
450
- s = s.replace(/\s+/g, " ").trim()
451
- return s.length > 80 ? s.slice(0, 79) + "…" : s
452
- }
453
-
454
- /**
455
- * Run the advisor's tool loop: chat → execute tools → repeat.
456
- * Stops when the model produces text without tool calls.
457
- *
458
- * Progress lines (→ tool args) are emitted via onOutput between model bursts so
459
- * the panel keeps moving while the advisor explores — otherwise the panel sits
460
- * frozen through every tool-call phase and the review appears to have stalled.
461
- */
462
- async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd) {
463
- // Kind-tagged wrappers: the TUI panel colors reasoning / answer / tool progress differently.
464
- const emit = (kind) => (onOutput ? (text) => onOutput({ kind, text }) : undefined)
465
- const onThink = emit("think")
466
- const onText = emit("text")
467
- while (true) {
468
- const response = await chat(provider, {
469
- messages,
470
- tools: ADVISOR_TOOL_SCHEMAS,
471
- signal: (signal && !signal.aborted) ? signal : new AbortController().signal,
472
- onToken: onText,
473
- onReasoning: onThink,
474
- })
475
-
476
- // No tool calls — this is the final review text
477
- if (!response.toolCalls?.length) {
478
- if (!response.content?.trim()) return "Advisor: (empty response — review was inconclusive)"
479
- return response.content.trim()
480
- }
481
-
482
- // Push assistant message with tool calls
483
- messages.push({
484
- role: "assistant",
485
- content: response.content || null,
486
- tool_calls: response.toolCalls.map((tc) => ({
487
- id: tc.id, type: "function",
488
- function: { name: tc.name, arguments: tc.arguments },
489
- })),
490
- })
491
-
492
- // Execute each tool call
493
- for (const tc of response.toolCalls) {
494
- const tool = ADVISOR_TOOL_BY_NAME.get(tc.name)
495
- let args = {}
496
- try { args = JSON.parse(tc.arguments || "{}") } catch { /* summarized as raw JSON below */ }
497
- onOutput?.({ kind: "tool", text: `\n→ ${tc.name} ${summarizeToolArgs(args)}\n` })
498
- let result
499
- if (!tool) {
500
- result = `Error: unknown tool "${tc.name}". Available: ${[...ADVISOR_TOOL_BY_NAME.keys()].join(", ")}`
501
- } else {
502
- try {
503
- result = await tool.execute(args, {
504
- cwd,
505
- agent,
506
- onOutput,
507
- signal,
508
- })
509
- } catch (e) {
510
- result = `Error: ${e.message}`
511
- }
512
- }
513
- messages.push({ role: "tool", tool_call_id: tc.id, content: String(result) })
188
+ // Fresh session. Only reset round if this is truly the first review.
189
+ // If _advisorRound > 0, there was a prior review that passed (all-clear).
190
+ if (!agent._advisorRound) agent._advisorRound = 0
191
+ agent._advisorLastSnapshot = null
192
+ if (!prior) {
193
+ // Tell the advisor why no prior issue table is present
194
+ session[1] = {
195
+ role: "user",
196
+ 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}`,
514
197
  }
515
198
  }
516
- }
517
-
518
- // ────────────────────────────────────────
519
- // Main entry point
520
- // ────────────────────────────────────────
521
-
522
- export function resolveAdvisorProvider(agent) {
523
- const cfg = agent.config?.advisor
524
- if (cfg?.provider) {
525
- try {
526
- const provider = findProvider(agent.providers ?? [agent.provider], cfg.provider)
527
- const result = cfg.model ? { ...provider, model: cfg.model } : { ...provider }
528
- if (cfg.thinking === null) result.thinking = undefined // explicitly off
529
- else if (cfg.thinking !== undefined) result.thinking = cfg.thinking
530
- if (cfg.reasoningEffort !== undefined) result.reasoningEffort = cfg.reasoningEffort
531
- return result
532
- } catch {
533
- // Provider not found — fall back to main provider
534
- }
535
- }
536
- const provider = { ...agent.provider }
537
- if (cfg?.model) provider.model = cfg.model
538
- if (cfg?.thinking === null) provider.thinking = undefined // explicitly off
539
- else if (cfg?.thinking !== undefined) provider.thinking = cfg.thinking
540
- if (cfg?.reasoningEffort !== undefined) provider.reasoningEffort = cfg.reasoningEffort
541
- return provider
542
- }
543
-
544
- /**
545
- * Whether every changed file across the review repos is documentation-only.
546
- * Used to skip pointless code reviews for doc updates (README, docs/, LICENSE…).
547
- */
548
- function isDocOnlyChange(repos, cwd) {
549
- const DOC_FILE = /(?:^|[/\\])(?:LICENSE|NOTICE|CHANGELOG|AUTHORS)(?:\.\w+)?$|\.(?:md|markdown|mdx|txt|rst|adoc)$/i
550
- const targets = repos.length > 0 ? repos : [cwd]
551
- let sawChanges = false
552
- for (const repo of targets) {
553
- let status = ""
554
- try {
555
- status = execFileSync("git", ["status", "--porcelain"], {
556
- cwd: repo, encoding: "utf8", timeout: GIT_TIMEOUT, stdio: ["ignore", "pipe", "pipe"],
557
- }).trim()
558
- } catch { return false /* can't tell — let the advisor run */ }
559
- if (!status) continue
560
- sawChanges = true
561
- for (const line of status.split("\n")) {
562
- // porcelain: "XY path" or "XY old -> new" (rename)
563
- const filePath = line.slice(3).split(" -> ").pop().replace(/^"|"$/g, "")
564
- if (!DOC_FILE.test(filePath)) return false
565
- }
566
- }
567
- return sawChanges
568
- }
569
-
570
- export async function runAdvisorReview(agent, onOutput, signal) {
571
- const cfg = agent.config?.advisor
572
- if (!cfg?.enabled) return null
573
-
574
- const repos = findReviewRepos(agent)
575
- if ((agent._touchedFiles ?? []).length === 0) return null
576
-
577
- // Fast path: documentation-only changes need no code review — unless the project
578
- // customized review criteria (.thincoder/advisor.md may genuinely care about docs).
579
- if (!existsSync(join(agent.cwd, ADVISOR_MD_PATH)) && isDocOnlyChange(repos, agent.cwd)) {
580
- return "No issues found — documentation-only changes, code review skipped."
581
- }
582
-
583
- const provider = resolveAdvisorProvider(agent)
584
-
585
- // Set the advisor's cwd to the first repo (for tool context)
586
- const advisorCwd = repos.length > 0 ? repos[0] : agent.cwd
587
-
588
- const messages = prepareAdvisorMessages(agent)
589
-
590
- try {
591
- const result = await runAdvisorToolLoop(provider, messages, onOutput, signal, agent, advisorCwd)
592
- // Persist the conversation: the next advisor call in this run continues here
593
- // (reset by runAgent when the run ends — each task gets a fresh advisor session)
594
- agent._advisorSession = messages
595
- return result
596
- } catch (e) {
597
- if (e.name === "AbortError" && signal?.reason?.interrupt) throw e
598
- return `Advisor: review failed — ${e.message || "unknown error"}. You may retry or proceed to verify.`
599
- }
199
+ return session
600
200
  }