thincoder 0.12.58 → 0.12.60

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 (158) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +3 -3
  3. package/bin/thincoder.mjs +88 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +135 -26
  6. package/src/advisor/messages.mjs +57 -4
  7. package/src/advisor/run.mjs +119 -79
  8. package/src/advisor.mjs +34 -7
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +182 -22
  11. package/src/agent/helpers.mjs +71 -4
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +107 -20
  16. package/src/agent/spawn-child.mjs +54 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +133 -109
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +154 -104
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +26 -30
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/goal.mjs +11 -1
  25. package/src/agent-tools/read-history.mjs +284 -0
  26. package/src/agent-tools/recent-changes.mjs +2 -1
  27. package/src/agent-tools/settings.mjs +152 -0
  28. package/src/agent-tools/skill.mjs +2 -1
  29. package/src/agent-tools/subagent-actions.mjs +470 -0
  30. package/src/agent-tools/subagent-async.mjs +382 -0
  31. package/src/agent-tools/subagent-panel.mjs +153 -0
  32. package/src/agent-tools/subagent-run.mjs +202 -0
  33. package/src/agent-tools/subagent-scheduler.mjs +343 -0
  34. package/src/agent-tools/subagent-spawn.mjs +406 -0
  35. package/src/agent-tools/subagent.mjs +203 -377
  36. package/src/agent-tools/task.mjs +4 -3
  37. package/src/agent-tools/timer.mjs +9 -4
  38. package/src/agent-tools/verify.mjs +198 -238
  39. package/src/agent-tools.mjs +1 -0
  40. package/src/agent.mjs +145 -242
  41. package/src/auto-think.mjs +14 -0
  42. package/src/cli/distill-command.mjs +10 -4
  43. package/src/cli/make-agent.mjs +4 -1
  44. package/src/cli/memory-command.mjs +2 -1
  45. package/src/cli/permission.mjs +8 -1
  46. package/src/cli/setup-wizard.mjs +17 -12
  47. package/src/config.mjs +61 -8
  48. package/src/context.mjs +81 -163
  49. package/src/crash-reports.mjs +123 -0
  50. package/src/distill.mjs +30 -12
  51. package/src/escape.mjs +6 -4
  52. package/src/explore-distill.mjs +155 -0
  53. package/src/log.mjs +195 -0
  54. package/src/memory/code-sync.mjs +2 -1
  55. package/src/memory/core.mjs +11 -72
  56. package/src/memory/delete.mjs +234 -0
  57. package/src/memory/docs.mjs +206 -87
  58. package/src/memory.mjs +3 -1
  59. package/src/model-specs.mjs +15 -1
  60. package/src/peer-domains.mjs +265 -0
  61. package/src/peer-instances.mjs +231 -0
  62. package/src/prompt-overlays.mjs +25 -0
  63. package/src/prompts/advisor-design.md +18 -39
  64. package/src/prompts/advisor-round1.md +20 -32
  65. package/src/prompts/advisor-round2.md +16 -16
  66. package/src/prompts/advisor-round3.md +16 -16
  67. package/src/prompts/coder.md +7 -28
  68. package/src/prompts/consult-base.md +4 -11
  69. package/src/prompts/discipline.md +31 -44
  70. package/src/prompts/eng-coder.md +9 -34
  71. package/src/prompts/engineering-sub.md +10 -8
  72. package/src/prompts/engineering.md +61 -264
  73. package/src/prompts/explore.md +4 -14
  74. package/src/prompts/main.md +18 -35
  75. package/src/prompts/methodology-template.md +32 -38
  76. package/src/prompts/plan.md +2 -9
  77. package/src/prompts/system.md +18 -35
  78. package/src/provider/core.mjs +62 -69
  79. package/src/provider/errors.mjs +76 -0
  80. package/src/provider/retry.mjs +8 -45
  81. package/src/session-gc.mjs +214 -0
  82. package/src/session-guard.mjs +47 -0
  83. package/src/session-rename.mjs +38 -0
  84. package/src/session-slots.mjs +181 -58
  85. package/src/session.mjs +48 -89
  86. package/src/token-ttl.mjs +273 -0
  87. package/src/tools/apply_patch.md +3 -1
  88. package/src/tools/bash.md +1 -1
  89. package/src/tools/checklist-sync.mjs +181 -0
  90. package/src/tools/checklist.mjs +52 -39
  91. package/src/tools/delete.md +1 -0
  92. package/src/tools/edit-batch.mjs +131 -44
  93. package/src/tools/edit-diff.mjs +348 -0
  94. package/src/tools/edit.md +20 -13
  95. package/src/tools/execute.md +7 -7
  96. package/src/tools/execute.mjs +55 -24
  97. package/src/tools/file.mjs +25 -70
  98. package/src/tools/file_ops.md +2 -1
  99. package/src/tools/get_current_time.md +3 -1
  100. package/src/tools/git.mjs +14 -6
  101. package/src/tools/glob-dialect.mjs +130 -0
  102. package/src/tools/glob.md +3 -3
  103. package/src/tools/grep.md +1 -1
  104. package/src/tools/hashline_edit.md +2 -0
  105. package/src/tools/index.mjs +3 -3
  106. package/src/tools/insert_after.md +2 -1
  107. package/src/tools/lint.md +2 -0
  108. package/src/tools/lsp.md +4 -1
  109. package/src/tools/ops.mjs +175 -3
  110. package/src/tools/patch.mjs +84 -13
  111. package/src/tools/question.md +5 -1
  112. package/src/tools/repomap.mjs +1 -1
  113. package/src/tools/shared.mjs +18 -25
  114. package/src/tools/system.mjs +50 -30
  115. package/src/tools/tree.md +2 -1
  116. package/src/tools/wait_for.md +22 -0
  117. package/src/tools/web.mjs +5 -3
  118. package/src/tools/websearch.md +2 -1
  119. package/src/tools/write.md +2 -0
  120. package/src/traces/trace-store.mjs +224 -0
  121. package/src/tui/agent-turn.mjs +179 -27
  122. package/src/tui/clipboard.mjs +15 -4
  123. package/src/tui/cmd-config.mjs +77 -16
  124. package/src/tui/cmd-eng.mjs +20 -16
  125. package/src/tui/cmd-extract.mjs +1 -1
  126. package/src/tui/cmd-mcp.mjs +17 -2
  127. package/src/tui/cmd-new.mjs +3 -2
  128. package/src/tui/cmd-session.mjs +19 -4
  129. package/src/tui/cmd-think.mjs +11 -11
  130. package/src/tui/cmd-upgrade.mjs +19 -4
  131. package/src/tui/config-helpers.mjs +28 -16
  132. package/src/tui/distill-cmd.mjs +1 -1
  133. package/src/tui/index.mjs +31 -96
  134. package/src/tui/interaction.mjs +13 -2
  135. package/src/tui/key-handler.mjs +105 -155
  136. package/src/tui/key-modes.mjs +215 -0
  137. package/src/tui/layout.mjs +22 -1
  138. package/src/tui/mouse.mjs +46 -0
  139. package/src/tui/pickers.mjs +51 -25
  140. package/src/tui/render-conversation.mjs +13 -161
  141. package/src/tui/render-frame.mjs +27 -10
  142. package/src/tui/render-loop.mjs +4 -1
  143. package/src/tui/render-segments.mjs +182 -0
  144. package/src/tui/startup.mjs +40 -0
  145. package/src/tui/subagent-blocks.mjs +272 -262
  146. package/src/tui/subagent-children.mjs +176 -0
  147. package/src/tui/subagent-freeze.mjs +172 -0
  148. package/src/tui/subagent-panel.mjs +125 -12
  149. package/src/tui/suspension-drive.mjs +351 -0
  150. package/src/tui/tool-args.mjs +10 -2
  151. package/src/tui/tool-display.mjs +142 -0
  152. package/src/tui/tool-events.mjs +127 -231
  153. package/src/tui/tui-lifecycle.mjs +29 -0
  154. package/src/tui/update-notice.mjs +76 -0
  155. package/src/tui/wizard.mjs +48 -12
  156. package/src/agent-tools/escalate.mjs +0 -179
  157. package/src/agent-tools/subagent-check.mjs +0 -107
  158. package/src/tools/exec-prelude.mjs +0 -84
@@ -105,6 +105,49 @@ function injectProjectGuide(agent, parts, scopeFiles = []) {
105
105
  return root // guide injected — requirement-fit criteria apply (truthy root)
106
106
  }
107
107
 
108
+ /**
109
+ * Build the mechanical review-object declaration block (AGENT-LOOP.md §18.8
110
+ * D-OA2 — English anchored form). Injected at the START of the review user
111
+ * message every round: round 1 (design + code), the legacy convergence path,
112
+ * and the round-2+ follow-up (see buildAdvisorFollowUp) — the reviewer must
113
+ * not re-derive "who is being reviewed / why" from the documents (T-OA2:
114
+ * every round stays anchored). Absent object → "" (legacy calls degrade to
115
+ * the current behavior — T-OA3).
116
+ * @param {Object|null} [object] — { type, target, status, reason, exclude }
117
+ * (strings; `exclude` may also be a list — joined with ", ")
118
+ * @returns {string} the declaration block (empty when no object)
119
+ */
120
+ export function buildObjectDeclarationBlock(object = null) {
121
+ if (!object || typeof object !== "object" || Array.isArray(object)) return ""
122
+ const field = (v) => (Array.isArray(v) ? v.join(", ") : v == null ? "" : String(v))
123
+ return [
124
+ "## Review-object declaration (mechanical — do not infer)",
125
+ `Review type: ${field(object.type)} | Target: ${field(object.target)} | Object state: ${field(object.status)} | Trigger: ${field(object.reason)}`,
126
+ `Excluded (not in this review): ${field(object.exclude)}`,
127
+ "Follow this declaration — do not infer the review target from the documents.",
128
+ "",
129
+ ].join("\n")
130
+ }
131
+
132
+ /**
133
+ * Approval-signal block for design reviews (round 1 and round 2+ — §24 D-24b:
134
+ * an async fix-round continuation must be able to re-approve, so the token is
135
+ * injected into EVERY design round; the reviewer echoes it only on a clean pass).
136
+ * §29.1 F2a (2026-09-07): BOTH values are injected — the token AND the designId
137
+ * (anchor sentence verbatim — Copy BOTH values). designId null (legacy direct
138
+ * callers without a resolved instance) degrades to the token-only form.
139
+ */
140
+ export function buildDesignApprovalBlock(designToken, designId) {
141
+ const echo = designId
142
+ ? `If — and ONLY if — your review finds NO 🔴 (Critical) issues, end your reply with this exact token: [DESIGN-TOKEN:${designToken}] and this exact designId: ${designId}. Copy BOTH values verbatim.`
143
+ : `If — and ONLY if — your review finds NO 🔴 (Critical) issues, end your reply with this exact token: [DESIGN-TOKEN:${designToken}]`
144
+ return [
145
+ "## Approval Signal",
146
+ echo,
147
+ "🟡 (Advisory) and 🔵 (Note) findings do NOT block approval — list them if present, but still include the token. If there are any 🔴 issues, do NOT include the token.",
148
+ ].join("\n")
149
+ }
150
+
108
151
  /**
109
152
  * Build the user message for an advisor review session.
110
153
  * @param {Object} agent — the parent agent
@@ -115,15 +158,27 @@ function injectProjectGuide(agent, parts, scopeFiles = []) {
115
158
  * When set, the review input is built from this list ONLY — no git-diff change-set collection.
116
159
  * When absent, the legacy git-diff-based scope is kept (backward compatible).
117
160
  * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review (deduped; shown under Review Scope)
161
+ * @param {Object|null} [object] — review-object declaration (§18.8 D-OA1/D-OA3):
162
+ * { type, target, status, reason, exclude } — mechanically injected at the
163
+ * start of the user message; absent → no injection (legacy calls unchanged).
164
+ * @param {string|null} [designId] — §29.1 F2a: injected next to the token in the
165
+ * Approval Signal (both values — the reviewer copies both verbatim); null →
166
+ * token-only degradation (legacy direct callers).
118
167
  * @returns {string} the user message
119
168
  */
120
- export function buildAdvisorUserMessage(agent, prior, reviewType, designToken = null, documents = null, paths = null) {
169
+ export function buildAdvisorUserMessage(agent, prior, reviewType, designToken = null, documents = null, paths = null, object = null, designId = null) {
121
170
  // prior = the full prior review output (string) when a convergence round is
122
171
  // being built (decision 2026-08-08 — verbatim injection, model understands it).
123
172
  // Deterministic: only _advisorRound > 0 with stored output counts.
124
173
  const p = prior ?? ((agent._advisorRound || 0) > 0 ? agent._lastAdvisorOutput : null)
125
174
 
126
175
  const parts = []
176
+ // Review-object declaration FIRST — D-OA1: at the start of the user message
177
+ // (after the system prompt, before the review content). Covers round 1
178
+ // design/code and the legacy convergence path; the round-2+ normal path
179
+ // prepends it in buildAdvisorFollowUp (T-OA2 — every round stays anchored).
180
+ const declaration = buildObjectDeclarationBlock(object)
181
+ if (declaration) parts.push(declaration)
127
182
  const docList = Array.isArray(documents) ? documents.filter((d) => typeof d === "string" && d.trim()) : []
128
183
  const pathList = Array.isArray(paths) ? [...new Set(paths.filter((p) => typeof p === "string" && p.trim()))] : []
129
184
 
@@ -213,9 +268,7 @@ export function buildAdvisorUserMessage(agent, prior, reviewType, designToken =
213
268
  parts.push("5. If you find issues, produce your review table with the format: | # | Category | Severity | Issue | Suggestion |. If the design passes, no table is needed.")
214
269
  if (designToken) {
215
270
  parts.push("")
216
- parts.push("## Approval Signal")
217
- parts.push(`If — and ONLY if — your review finds NO 🔴 (Critical) issues, end your reply with this exact token: [DESIGN-TOKEN:${designToken}]`)
218
- parts.push("🟡 (Advisory) and 🔵 (Note) findings do NOT block approval — list them if present, but still include the token. If there are any 🔴 issues, do NOT include the token.")
271
+ parts.push(buildDesignApprovalBlock(designToken, designId))
219
272
  }
220
273
  return parts.join("\n")
221
274
  }
@@ -10,11 +10,10 @@ import { appendCitationReport } from "./citations.mjs"
10
10
  import { describeToolArgs } from "../tui/tool-args.mjs"
11
11
 
12
12
  const MAX_ADVISOR_TURNS = 100
13
- // Mechanical convergence cap: the protocol assumes up to 5 rounds suffice
14
- // (full review, verify+fix cycles, strict verification). A 6th call means the
15
- // model is looping refuse it instead of burning tokens on a review that cannot
16
- // converge. Code AND design reviews share the 5-round budget (each advances
17
- // _advisorRound in agent.mjs; the cap no longer exempts design).
13
+ // Mechanical convergence cap: up to 5 rounds suffice; a 6th call means the model
14
+ // is looping refuse it instead of burning tokens. §24 D-24b (2026-09-06): PER
15
+ // REVIEW INSTANCE (agent._advisorRuns); CODE REVIEWS ONLY (2026-09-07 §8 ruling)
16
+ // design reviews are EXEMPT: their rounds keep advancing, the cap never refuses.
18
17
  export const MAX_ADVISOR_ROUNDS = 5
19
18
 
20
19
  // NOTE: prompts/advisor-round{1,2,3}.md encourage the model to finish within
@@ -120,6 +119,9 @@ function renderTimeline(timeline, tail = "") {
120
119
  }
121
120
  // Test seam (mirrors _advisorToolsFor).
122
121
  export { renderTimeline as _renderTimeline }
122
+ // Test seam (T-TS8/9): the tool loop itself — toolsOverride injects a mock tool
123
+ // set with controllable timing/errors (the real set comes from advisorToolsFor).
124
+ export { runAdvisorToolLoop as _runAdvisorToolLoop }
123
125
 
124
126
  /**
125
127
  * Run the advisor's tool loop: chat → execute tools → repeat.
@@ -129,7 +131,7 @@ export { renderTimeline as _renderTimeline }
129
131
  * the panel keeps moving while the advisor explores — otherwise the panel sits
130
132
  * frozen through every tool-call phase and the review appears to have stalled.
131
133
  */
132
- async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd) {
134
+ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd, toolsOverride = null) {
133
135
  // Kind-tagged wrappers: the TUI panel colors reasoning / answer / tool progress differently.
134
136
  // Every chunk is ALSO recorded into an ordered timeline — the persisted record
135
137
  // must show the review process (thinking ↔ tool progress ↔ final text) at its
@@ -145,7 +147,9 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
145
147
  const onThink = emit("think")
146
148
  const onText = emit("text")
147
149
  const onTool = emit("tool")
148
- const { schemas: toolSchemas, byName: toolByName } = advisorToolsFor(agent)
150
+ // toolsOverride = test seam (T-TS8/9): the real advisor tool set, or a mock
151
+ // set with controllable timing/errors.
152
+ const { schemas: toolSchemas, byName: toolByName } = toolsOverride ?? advisorToolsFor(agent)
149
153
  let turns = 0
150
154
  const startTime = Date.now()
151
155
 
@@ -198,13 +202,25 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
198
202
  signal: signal ?? null,
199
203
  onToken: onText,
200
204
  onReasoning: onThink,
205
+ // LOGGING(vscode advisor/run.mjs parity——按 stage 可 grep)
206
+ // §18.6 D-TR4:轨迹元数据增补——kind=advisor(评审独立于子代理——T-TR2);role
207
+ // 透出调用方角色(eng-coder 内嵌评审时为 "eng-coder");session/cwd 供轨迹对回;
208
+ // traces 开关沿 agent.config(D-TR6)。
209
+ logCtx: {
210
+ stage: "advisor",
211
+ role: agent?._role ?? null,
212
+ kind: "advisor",
213
+ session: agent?._sessionStart ?? null,
214
+ cwd,
215
+ traces: agent?.config?.traces?.enabled !== false,
216
+ },
201
217
  })
202
218
 
203
219
  // No tool calls — this is the final review text. The final answer was
204
220
  // already streamed into the timeline via onText; fall back to
205
221
  // response.content only if nothing was recorded.
206
222
  if (!response.toolCalls?.length) {
207
- if (!response.content?.trim()) return renderTimeline(timeline) || "Advisor: (empty response — review was inconclusive)"
223
+ if (!response.content?.trim()) return renderTimeline(timeline) || "Advisor: empty response — review was inconclusive"
208
224
  return renderTimeline(timeline) || response.content.trim()
209
225
  }
210
226
 
@@ -226,8 +242,19 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
226
242
  : {}),
227
243
  })
228
244
 
229
- // Execute each tool call
230
- for (const tc of response.toolCalls) {
245
+ // B1 (AGENT-LOOP.md §18.7 D-TS7): the SAME LLM reply's multiple read-only
246
+ // tool calls run in PARALLEL (Promise.all) — results are backfilled in
247
+ // toolCalls order (Promise.all preserves the input order → tool_call_id
248
+ // never mismatches); each tool's timeout/error is captured independently
249
+ // (the existing TOOL_TIMEOUT stays — one failing tool does not block the
250
+ // others); progress lines are emitted in toolCalls order. The read-only
251
+ // tool set has no side effects — no sequencing/serialization needed.
252
+ // Scope note (round1 review #10): B1 is ONLY in-loop tool parallelism — it
253
+ // does NOT solve the TODO "platform execution: advisor parallel calls are
254
+ // actually serial" mystery (docs/TODO.md — LOGGING evidence item), which
255
+ // concerns multiple advisor CALLS observed as serial, not one reply's
256
+ // tool calls.
257
+ const parsed = response.toolCalls.map((tc) => {
231
258
  const tool = toolByName.get(tc.name)
232
259
  let args = {}
233
260
  let parseError = null
@@ -236,63 +263,69 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
236
263
  } catch (e) {
237
264
  parseError = `Error: invalid JSON in tool arguments: ${e.message}\nRaw arguments: ${(tc.arguments || "").slice(0, 200)}`
238
265
  }
239
-
240
- // If parse failed, return error to model immediately
241
- if (parseError) {
242
- messages.push({ role: "tool", tool_call_id: tc.id, content: parseError })
243
- continue
244
- }
245
-
246
- const argsLine = describeToolArgs(tc.name, args)
247
- onTool(`\n→ ${tc.name}${argsLine ? " " + argsLine : ""}\n`)
248
- let result
249
- if (!tool) {
250
- result = `Error: unknown tool "${tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
251
- } else {
252
- // Execute with timeout (clear the timer when the tool wins the race —
253
- // otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
266
+ return { tc, tool, args, parseError }
267
+ })
268
+ // Progress lines first, in toolCalls order (emitted before the parallel
269
+ // run display order is independent of completion order).
270
+ for (const p of parsed) {
271
+ if (p.parseError) continue // parse-error tools get no progress line (legacy behavior)
272
+ const argsLine = describeToolArgs(p.tc.name, p.args)
273
+ onTool(`\n→ ${p.tc.name}${argsLine ? " " + argsLine : ""}\n`)
274
+ }
275
+ // Every tool runs CONCURRENTLY; each result/error lands in its own slot —
276
+ // Promise.all preserves input order, so index i always matches parsed[i].
277
+ const executed = await Promise.all(parsed.map(async (p) => {
278
+ // Parse failure → error to model immediately (no execution)
279
+ if (p.parseError) return p.parseError
280
+ if (!p.tool) return `Error: unknown tool "${p.tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
281
+ // Execute with timeout (clear the timer when the tool wins the race —
282
+ // otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
283
+ try {
284
+ let timeoutId
285
+ const timeoutPromise = new Promise((_, reject) => {
286
+ timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
287
+ })
288
+ let toolPromise
254
289
  try {
255
- let timeoutId
256
- const timeoutPromise = new Promise((_, reject) => {
257
- timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
258
- })
259
- let toolPromise
260
- try {
261
- toolPromise = tool.execute(args, { cwd, agent, onOutput, signal })
262
- result = await Promise.race([toolPromise, timeoutPromise])
263
- } finally {
264
- clearTimeout(timeoutId)
265
- // Timeout won → toolPromise is still pending; a later rejection
266
- // would surface as an unhandled rejection. The race already
267
- // consumed the result/error in the normal path, so this no-op
268
- // catch only fires for the abandoned-tool case.
269
- toolPromise?.catch(() => {})
270
- }
271
- } catch (e) {
272
- const errorType = e.message.includes("timeout") ? "timeout"
273
- : e.message.includes("ENOENT") ? "file_not_found"
274
- : e.message.includes("permission") ? "permission_denied"
275
- : "execution_error"
276
- result = `Error (${errorType}): ${e.message}`
290
+ toolPromise = p.tool.execute(p.args, { cwd, agent, onOutput, signal })
291
+ return await Promise.race([toolPromise, timeoutPromise])
292
+ } finally {
293
+ clearTimeout(timeoutId)
294
+ // Timeout won → toolPromise is still pending; a later rejection
295
+ // would surface as an unhandled rejection. The race already
296
+ // consumed the result/error in the normal path, so this no-op
297
+ // catch only fires for the abandoned-tool case.
298
+ toolPromise?.catch(() => {})
277
299
  }
300
+ } catch (e) {
301
+ const errorType = e.message.includes("timeout") ? "timeout"
302
+ : e.message.includes("ENOENT") ? "file_not_found"
303
+ : e.message.includes("permission") ? "permission_denied"
304
+ : "execution_error"
305
+ return `Error (${errorType}): ${e.message}`
278
306
  }
307
+ }))
308
+
309
+ // Backfill in toolCalls order (executed[i] ↔ parsed[i]); per-result
310
+ // non-string serialization + line-aware truncation stay per-tool.
311
+ for (let i = 0; i < parsed.length; i++) {
312
+ let result = executed[i]
279
313
  if (typeof result !== "string") result = JSON.stringify(result)
280
-
281
- // Line-aware truncation: preserve line integrity
314
+
282
315
  if (result.length > MAX_RESULT_CHARS) {
283
316
  const lines = result.split("\n")
284
317
  let truncated = ""
285
318
  let charCount = 0
286
319
  let keptLines = 0
287
-
288
- for (let i = 0; i < lines.length; i++) {
289
- const line = lines[i]
320
+
321
+ for (let j = 0; j < lines.length; j++) {
322
+ const line = lines[j]
290
323
  if (charCount + line.length + 1 > MAX_RESULT_CHARS) break
291
324
  truncated += line + "\n"
292
325
  charCount += line.length + 1
293
326
  keptLines++
294
327
  }
295
-
328
+
296
329
  const remainingLines = lines.length - keptLines
297
330
  result = (
298
331
  truncated +
@@ -300,8 +333,8 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
300
333
  `To see more content, use: read(path, offset=${keptLines + 1}, limit=200)`
301
334
  )
302
335
  }
303
-
304
- messages.push({ role: "tool", tool_call_id: tc.id, content: result })
336
+
337
+ messages.push({ role: "tool", tool_call_id: parsed[i].tc.id, content: result })
305
338
  }
306
339
  }
307
340
  }
@@ -354,13 +387,35 @@ function extractUnfixedIssues(priorText) {
354
387
  .filter(Boolean)
355
388
  .slice(0, MAX_UNFIXED_DISPLAY)
356
389
  }
390
+ /** Review-looking guard (async settle parity): a markdown table row or ≥200 chars of prose counts as a prior. */
391
+ export function looksLikeReviewOutput(text) {
392
+ const trimmed = String(text ?? "").trim()
393
+ return /\|.*\|.*\|/.test(trimmed) || trimmed.length >= 200
394
+ }
395
+ /** Cap message (shared by runAdvisorReview and the async pre-check — per-review refusal). */
396
+ export function buildCapMessage(agent) {
397
+ const prior = agent._lastAdvisorOutput
398
+ const unfixed = prior ? extractUnfixedIssues(prior) : []
399
+ let message = `Advisor: convergence cap reached after ${MAX_ADVISOR_ROUNDS} rounds.\n`
400
+ if (unfixed.length > 0) {
401
+ message += `\nUnresolved issues from prior rounds:\n${unfixed.map((i) => `- ${i}`).join("\n")}\n`
402
+ } else {
403
+ message += "\nAll prior issues appear resolved.\n"
404
+ }
405
+ message += "\nOptions:\n1. Accept current state and proceed\n2. Manually review specific concerns with read/grep\n3. Start a new session (/new) to reset the advisor"
406
+ return message
407
+ }
357
408
 
358
409
  /**
359
410
  * Run an advisor review. reviewType: "code" (default) or "design". Returns review text or null when skipped.
360
411
  * @param {string|null} [designToken] — injected into the design-review prompt; the advisor echoes it only on approval.
361
412
  * @param {string[]|null} [documents] — design review only: explicit list of doc paths to review; passed through to the message builder.
413
+ * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review.
414
+ * @param {Object|null} [object] — review-object declaration (§18.8 D-OA1/D-OA3): { type, target, status, reason, exclude }; absent → legacy behavior (no injection).
415
+ * @param {string|null} [designId] — §29.1 F2a: injected next to the design token in
416
+ * the Approval Signal (passed through to the message builders).
362
417
  */
363
- export async function runAdvisorReview(agent, reviewType, callbacks, designToken = null, documents = null, paths = null) {
418
+ export async function runAdvisorReview(agent, reviewType, callbacks, designToken = null, documents = null, paths = null, object = null, designId = null) {
364
419
  const onOutput = callbacks?.onOutput
365
420
  const signal = callbacks?.signal
366
421
  const startTime = Date.now()
@@ -369,33 +424,19 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
369
424
  // former advisor.enabled gate is removed — review capability has no off
370
425
  // switch; only the guard (completion pushback) is opt-in via advisor.guard.
371
426
 
372
- // Mechanical convergence cap — refuse further reviews once the protocol has run
373
- // its rounds. _advisorRound counts completed advisor calls (incremented by the
374
- // agent after each one code AND design reviews alike), so >= MAX_ADVISOR_ROUNDS
375
- // blocks the next call. 5 rounds max; after that the review is never pushed back
376
- // (the caller decides: accept, manual re-check, or /new to reset).
377
- if ((agent._advisorRound || 0) >= MAX_ADVISOR_ROUNDS) {
378
- // Summarize unresolved items from the last review output for guidance
379
- // (line-level status-word scan — no table-header parsing, decision 2026-08-08).
380
- const prior = agent._lastAdvisorOutput
381
- const unfixed = prior ? extractUnfixedIssues(prior) : []
382
-
383
- let message = `Advisor: convergence cap reached after ${MAX_ADVISOR_ROUNDS} rounds.\n`
384
- if (unfixed.length > 0) {
385
- message += `\nUnresolved issues from prior rounds:\n${unfixed.map((i) => `- ${i}`).join("\n")}\n`
386
- } else {
387
- message += "\nAll prior issues appear resolved.\n"
388
- }
389
- message += "\nOptions:\n1. Accept current state and proceed\n2. Manually review specific concerns with read/grep\n3. Start a new session (/new) to reset the advisor"
390
-
391
- return message
427
+ // Mechanical convergence cap — CODE REVIEWS ONLY (2026-09-07 §8 ruling: design
428
+ // reviews are exempt). _advisorRound is scoped to the current review instance
429
+ // (§24 D-24b ③), so >= MAX_ADVISOR_ROUNDS blocks the next call of THIS instance.
430
+ // 5 rounds max; after that the review is never pushed back.
431
+ if (reviewType !== "design" && (agent._advisorRound || 0) >= MAX_ADVISOR_ROUNDS) {
432
+ return buildCapMessage(agent)
392
433
  }
393
434
 
394
435
  const provider = resolveAdvisorProvider(agent)
395
436
  // Advisor always works in the agent's cwd — scope is defined by paths/documents.
396
437
  const advisorCwd = agent.cwd
397
438
 
398
- const messages = prepareAdvisorMessages(agent, reviewType, designToken, documents, paths)
439
+ const messages = prepareAdvisorMessages(agent, reviewType, designToken, documents, paths, null, object, designId)
399
440
 
400
441
  try {
401
442
  const result = await runAdvisorToolLoop(provider, messages, onOutput, signal, agent, advisorCwd)
@@ -416,8 +457,7 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
416
457
  // table row (`| a | b | c |`) or substantial prose (>200 chars). An
417
458
  // empty or tool-progress-only reply must not become the "prior review"
418
459
  // of round 2+.
419
- const trimmed = final.trim()
420
- const looksLikeReview = /\|.*\|.*\|/.test(trimmed) || trimmed.length >= 200
460
+ const looksLikeReview = looksLikeReviewOutput(final)
421
461
  if (looksLikeReview) {
422
462
  agent._lastAdvisorOutput = final
423
463
  }
package/src/advisor.mjs CHANGED
@@ -45,12 +45,13 @@ import { readFileSync } from "node:fs"
45
45
  import { join, dirname } from "node:path"
46
46
  import { fileURLToPath } from "node:url"
47
47
  import { extractAgentResponseTable } from "./advisor/history.mjs"
48
- import { buildAdvisorUserMessage, resolveScopeFiles } from "./advisor/messages.mjs"
48
+ import { buildAdvisorUserMessage, resolveScopeFiles, buildObjectDeclarationBlock, buildDesignApprovalBlock } from "./advisor/messages.mjs"
49
49
  import { buildConvergenceBody } from "./advisor/convergence.mjs"
50
50
  import { escapeLiteralEscapes } from "./escape.mjs"
51
51
  // Re-export for run.mjs and tests (keeps their imports from "../advisor.mjs" stable)
52
52
  export { ADVISOR_MD_PATH, extractAgentResponseTable, extractConversationBackground } from "./advisor/history.mjs"
53
53
  export { buildAdvisorUserMessage } from "./advisor/messages.mjs"
54
+ export { buildObjectDeclarationBlock, buildDesignApprovalBlock } from "./advisor/messages.mjs"
54
55
 
55
56
  const __dirname = dirname(fileURLToPath(import.meta.url))
56
57
 
@@ -135,13 +136,15 @@ export function buildAdvisorSystemPrompt(agent, prior, reviewType) {
135
136
  * @param {Object} agent — the parent agent (history used for the response table)
136
137
  * @param {Object|null} prior — prior issue table (extracted from history when null)
137
138
  * @param {string[]|null} [scopeFiles] — review surface for the no-response fallback (cwd-relative)
139
+ * @param {Object|null} [object] — review-object declaration (§18.8): mechanically
140
+ * prepended to the round-2+ follow-up so every round stays anchored (T-OA2).
138
141
  * @returns {string} the follow-up user message — or a plain "System reminder: …"
139
142
  * fresh-review fallback (NO brackets — some OpenAI-compatible servers parse
140
143
  * '['-prefixed content as structured data / expand escapes) when no prior
141
144
  * review exists at all (caller misuse; the response-table extraction would
142
145
  * otherwise scan history from index 0 and could match an unrelated stale table)
143
146
  */
144
- export function buildAdvisorFollowUp(agent, prior, scopeFiles = null) {
147
+ export function buildAdvisorFollowUp(agent, prior, scopeFiles = null, object = null) {
145
148
  // Convergence follow-up REQUIRES a prior review record — the full output of
146
149
  // the last review, injected VERBATIM (decision 2026-08-08: the model
147
150
  // understands the review output; no table/header/phrase parsing). The caller
@@ -164,7 +167,9 @@ export function buildAdvisorFollowUp(agent, prior, scopeFiles = null) {
164
167
  : "(Agent did not provide a response table — perform a fresh full review; the review surface is unknown, ask the user for the file list)"
165
168
  const response = extractAgentResponseTable(agent.history) || noResponseFallback
166
169
  const round = (agent._advisorRound || 0) + 1
167
- return buildConvergenceBody(p, response, round, scopeFiles)
170
+ // Review-object declaration FIRST (T-OA2 round 2+ stays anchored, no re-archaeology).
171
+ const declaration = buildObjectDeclarationBlock(object)
172
+ return (declaration ? declaration + "\n" : "") + buildConvergenceBody(p, response, round, scopeFiles)
168
173
  }
169
174
 
170
175
  /**
@@ -187,8 +192,13 @@ export { escapeLiteralEscapes }
187
192
  * @param {string|null} [designToken] — design-review approval token (design only)
188
193
  * @param {string[]|null} [documents] — design review only: explicit list of doc paths to review (passed through to buildAdvisorUserMessage)
189
194
  * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review
195
+ * @param {Object|null} [object] — review-object declaration (§18.8 D-OA1): passed through
196
+ * to the user-message builders; mechanically injected at the start of every review
197
+ * round (round 1 design/code + round 2+ follow-up). Absent → legacy behavior.
198
+ * @param {string|null} [designId] — §29.1 F2a: injected next to the design token in
199
+ * the Approval Signal (round 1 + round 2+ — both values, verbatim anchor).
190
200
  */
191
- export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null, priorParam = null) {
201
+ export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null, priorParam = null, object = null, designId = null) {
192
202
  // Deterministic convergence state (decision 2026-08-08): round 2+ requires
193
203
  // _advisorRound > 0 AND a stored prior review output. No history parsing.
194
204
  // priorParam (direct callers) wins over the stored output — same derivation
@@ -201,7 +211,7 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
201
211
  if (reviewType === "design" && (agent._advisorRound || 0) === 0) {
202
212
  return [
203
213
  { role: "system", content: withTime(buildAdvisorSystemPrompt(agent, prior, reviewType)) },
204
- { role: "user", content: escapeLiteralEscapes(buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)) },
214
+ { role: "user", content: escapeLiteralEscapes(buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths, object, designId)) },
205
215
  ]
206
216
  }
207
217
 
@@ -226,7 +236,7 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
226
236
  agent._advisorRound = 0
227
237
  }
228
238
  // Mutations exist → KEEP the round (cap keeps advancing through retries).
229
- const user = buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)
239
+ const user = buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths, object, designId)
230
240
  return [
231
241
  { role: "system", content: withTime(buildAdvisorSystemPrompt(agent, prior, reviewType)) },
232
242
  {
@@ -255,8 +265,25 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
255
265
  // scopeFiles gives the fallback (agent gave no response table) a concrete
256
266
  // review surface.
257
267
  const scopeFiles = resolveScopeFiles(agent, paths)
268
+ const followUp = buildAdvisorFollowUp(agent, prior, scopeFiles, object)
269
+ // §24 D-24b (design round 2+ — async fix-round continuations must be able to
270
+ // re-approve): re-anchor the review scope (the convergence follow-up carries no
271
+ // document list) and inject the round's design token with the approval signal.
272
+ if (reviewType === "design") {
273
+ const docList = Array.isArray(documents)
274
+ ? documents.filter((d) => typeof d === "string" && d.trim())
275
+ : []
276
+ const scopeBlock = docList.length > 0
277
+ ? `\n\n## Documents to Review\nThe documents below are the review scope. Review ONLY these files — do not scan git diff or read any other files.\n${docList.map((d) => `- ${d} — Read this file in full`).join("\n")}`
278
+ : ""
279
+ const tokenBlock = designToken ? `\n\n${buildDesignApprovalBlock(designToken, designId)}` : ""
280
+ return [
281
+ { role: "system", content: withTime(buildAdvisorSystemPrompt(agent, prior, reviewType)) },
282
+ { role: "user", content: escapeLiteralEscapes(followUp + scopeBlock + tokenBlock) },
283
+ ]
284
+ }
258
285
  return [
259
286
  { role: "system", content: withTime(buildAdvisorSystemPrompt(agent, prior, reviewType)) },
260
- { role: "user", content: escapeLiteralEscapes(buildAdvisorFollowUp(agent, prior, scopeFiles)) },
287
+ { role: "user", content: escapeLiteralEscapes(followUp) },
261
288
  ]
262
289
  }
@@ -7,6 +7,7 @@
7
7
  import { hasCodeMutations } from "../advisor/repos.mjs"
8
8
  import { pushReal } from "../context.mjs"
9
9
  import { MAX_ADVISOR_ROUNDS } from "../advisor/run.mjs"
10
+ import { advisorReviewPending, effectiveAdvisorRound } from "../agent-tools/advisor-async.mjs"
10
11
 
11
12
  const MAX_VERIFY_PUSHBACKS = 2
12
13
  const MAX_VERIFY_RETRIES = 3
@@ -77,18 +78,20 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
77
78
  pushReal(agent, { role: "assistant", content: response.content })
78
79
  agent.history.push({
79
80
  role: "user",
80
- content: "[System reminder: you modified files in this run but have not verified the changes. Before finishing: call the verify tool to run syntax checks and tests. If verify reports failures, fix them and run verify again. If verification is genuinely impossible here, say so explicitly in your reply.]",
81
+ content: "[System reminder: you modified files in this run but have not verified the changes. Before finishing: run the project's verification yourself (per its AGENTS.md test method), then call verify declaring the outcome via verification.status. verify mechanically gates on your declaration. If verification is genuinely impossible here, say so explicitly in your reply.]",
81
82
  })
82
83
  callbacks.onTurnEnd?.(agent, turn)
83
84
  return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }
84
85
  }
85
- // Verified but still failing → pushback to fix (up to MAX_VERIFY_RETRIES)
86
+ // Verified but not passed → pushback to fix/complete (up to MAX_VERIFY_RETRIES).
87
+ // _verifyPassed === false means the declaration was failed, skipped without a
88
+ // reason, or not declared — not necessarily that a test failed.
86
89
  if (agent._verifiedThisRun && agent._verifyPassed === false && agent._verifyRetries < MAX_VERIFY_RETRIES) {
87
90
  agent._verifyRetries++
88
91
  pushReal(agent, { role: "assistant", content: response.content })
89
92
  agent.history.push({
90
93
  role: "user",
91
- content: `[System reminder: verify reported test failures (retry ${agent._verifyRetries}/${MAX_VERIFY_RETRIES}). Review the failures, fix the issues, then run verify again. If you cannot fix after ${MAX_VERIFY_RETRIES} attempts, explain honestly what's blocking you.]`,
94
+ content: `[System reminder: (retry ${agent._verifyRetries}/${MAX_VERIFY_RETRIES}) verify was not passed — either your verification declared failed, was skipped without a reason, or was not declared. Fix or complete your verification, then call verify again declaring the outcome. If you cannot fix after ${MAX_VERIFY_RETRIES} attempts, explain honestly what's blocking you.]`,
92
95
  })
93
96
  callbacks.onTurnEnd?.(agent, turn)
94
97
  return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }
@@ -103,7 +106,7 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
103
106
  pushReal(agent, { role: "assistant", content: response.content })
104
107
  agent.history.push({
105
108
  role: "user",
106
- content: `[System reminder: ${MAX_VERIFY_RETRIES} verify attempts exhausted and tests are still failing. In your response to the user, you MUST state explicitly: (1) what tests are still failing, (2) what you tried, (3) what you believe the root cause is. Do not present this as complete the user needs to know the work is unfinished.]`,
109
+ content: `[System reminder: ${MAX_VERIFY_RETRIES} verify attempts exhausted. You have not passed verification. Either state explicitly that your verification could not be completed, or run verify again once it is. If your verification could not be completed, say so explicitly in your reply to the user — state what you tried and what you believe is blocking you, and do not present the work as complete; the user needs to know it is unfinished.]`,
107
110
  })
108
111
  callbacks.onTurnEnd?.(agent, turn)
109
112
  return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }
@@ -117,18 +120,21 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
117
120
  const cfg = agent.config?.advisor
118
121
  const advisorReview = cfg?.guard === true
119
122
  if (depth === 0 && advisorReview && !agent.config?.agent?.engineering) {
120
- // Cap sync: beyond MAX_ADVISOR_ROUNDS the advisor tool refuses to review
121
- // (run.mjs convergence cap) pushing back further would loop forever
122
- // (fix pushback cap-refused call fix ). The cap message from the
123
- // last accepted review stands; the user decides manually.
124
- if (agent._mutatedThisRun && !agent._calledAdvisorThisRun && hasCodeMutations(agent)
123
+ // §24 D-24b (T-24b4 — guard timing): an async review that is still in flight
124
+ // (or queued in the advisor pool) means the review was launched the guard
125
+ // does NOT push back while it is pending (未决不算未评审); once it settles
126
+ // non-stale it marks _calledAdvisorThisRun, and a STALE settle leaves the
127
+ // mark unset so the guard pushes back here again (fix #2 no silent skip).
128
+ const pending = advisorReviewPending(agent)
129
+ const rounds = effectiveAdvisorRound(agent)
130
+ if (!pending && agent._mutatedThisRun && !agent._calledAdvisorThisRun && hasCodeMutations(agent)
125
131
  && advisorPushbacks < MAX_ADVISOR_PUSHBACKS
126
- && (agent._advisorRound || 0) < MAX_ADVISOR_ROUNDS) {
132
+ && rounds < MAX_ADVISOR_ROUNDS) {
127
133
  advisorPushbacks++
128
134
  pushReal(agent, { role: "assistant", content: response.content })
129
135
  agent.history.push({
130
136
  role: "user",
131
- content: `[System reminder: you changed code in this run and MUST get an advisor review before finishing (round ${agent._advisorRound + 1}). Call the \`advisor\` tool now. This is required, not optional — do not skip it even if you believe the changes are trivial — the review will be quick either way. After the review, produce a response table for every issue found (see discipline rules for format).]`,
137
+ content: `[System reminder: you changed code in this run and MUST get an advisor review before finishing (round ${rounds + 1}). Call the \`advisor\` tool now. This is required, not optional — do not skip it even if you believe the changes are trivial — the review will be quick either way. After the review, produce a response table for every issue found (see discipline rules for format).]`,
132
138
  })
133
139
  callbacks.onTurnEnd?.(agent, turn)
134
140
  return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }