thincoder 0.12.11 → 0.12.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/bin/thincoder.mjs +11 -1
- package/package.json +1 -1
- package/src/acp/bridge.mjs +229 -0
- package/src/acp/session.mjs +46 -0
- package/src/acp/transport.mjs +155 -0
- package/src/acp.mjs +335 -0
- package/src/advisor/citations.mjs +77 -0
- package/src/advisor/convergence.mjs +80 -0
- package/src/advisor/history.mjs +20 -55
- package/src/advisor/messages.mjs +182 -36
- package/src/advisor/repos.mjs +40 -0
- package/src/advisor/run.mjs +201 -95
- package/src/advisor.mjs +192 -99
- package/src/agent/completion.mjs +10 -10
- package/src/agent/dispatch.mjs +14 -0
- package/src/agent-tools/advisor.mjs +11 -10
- package/src/agent-tools/subagent.mjs +26 -8
- package/src/agent.mjs +25 -32
- package/src/config.mjs +13 -5
- package/src/context.mjs +32 -9
- package/src/prompts/advisor-round1.md +9 -3
- package/src/prompts/advisor-round2.md +10 -8
- package/src/prompts/advisor-round3.md +14 -14
- package/src/prompts/discipline.md +1 -1
- package/src/session.mjs +19 -0
- package/src/tools/file.mjs +30 -0
- package/src/tools/insert_after.md +1 -0
- package/src/tools/patch.mjs +4 -0
- package/src/tools/shared.mjs +68 -57
- package/src/tui/agent-turn.mjs +73 -121
- package/src/tui/index.mjs +1 -1
- package/src/tui/markdown.mjs +26 -8
- package/src/tui/render-conversation.mjs +90 -39
- package/src/tui/tool-summaries.mjs +113 -0
package/src/agent.mjs
CHANGED
|
@@ -13,7 +13,6 @@ import { executeToolCalls } from "./agent/dispatch.mjs"
|
|
|
13
13
|
import { prepareRun } from "./agent/setup.mjs"
|
|
14
14
|
import { injectPostTurn, STALL_WINDOW_SIZE, STALL_THRESHOLD, GOAL_BUDGET_WARN_RATIO } from "./agent/post-turn.mjs"
|
|
15
15
|
import { handleCompletion } from "./agent/completion.mjs"
|
|
16
|
-
import { isDocFile } from "./advisor/repos.mjs"
|
|
17
16
|
import {
|
|
18
17
|
escapeXml, tryCanonicalize, repairHistory, listWorkDir,
|
|
19
18
|
readonlyToolNames, collectGitContext, loadProjectInstructions,
|
|
@@ -56,23 +55,8 @@ export const ENG_ON_REMINDER =
|
|
|
56
55
|
"subagents only. Advisor calls are NOT per-turn-mandatory — call only at " +
|
|
57
56
|
"flow nodes or when the user asks.]"
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* (docs/, *.md, LICENSE…) must NOT trigger the advisor/verify guards — the
|
|
62
|
-
* design phase edits docs/ and must not be pushed to a code review.
|
|
63
|
-
* Mutations without a known path (tools outside FILE_MUTATORS) are treated as
|
|
64
|
-
* code — cannot tell, so guard conservatively.
|
|
65
|
-
* Product-code semantics match isProductCode: anything under src/ (incl.
|
|
66
|
-
* src/prompts/*.md) is code; anything else that isn't a doc file is code.
|
|
67
|
-
* NOTE: _touchedFiles stores ABSOLUTE paths (join(cwd, p)), so the src/ check
|
|
68
|
-
* matches a path component (works for "src/..." and "D:\...\src\..." alike),
|
|
69
|
-
* not a bare ^src prefix — the literal ^src[\\/] form would be dead code here.
|
|
70
|
-
*/
|
|
71
|
-
export function hasCodeMutations(agent) {
|
|
72
|
-
const files = agent._touchedFiles ?? []
|
|
73
|
-
if (files.length === 0) return agent._mutatedThisRun
|
|
74
|
-
return files.some((p) => /(?:^|[\\/])src[\\/]/.test(p) || !isDocFile(p))
|
|
75
|
-
}
|
|
58
|
+
// Re-exported for API compatibility (single source of truth: advisor/repos.mjs)
|
|
59
|
+
export { hasCodeMutations } from "./advisor/repos.mjs"
|
|
76
60
|
|
|
77
61
|
/** Engineering-mode status injection — one reminder when engineering mode is ON. */
|
|
78
62
|
function injectEngineeringReminder(agent) {
|
|
@@ -101,6 +85,7 @@ export function createAgent({
|
|
|
101
85
|
_engDesignReviewed: false, // eng-coder: design review gate passed (hard gate in dispatch.mjs)
|
|
102
86
|
_engDesignToken: null, // issued by advisor(type="design"); required to spawn eng-coder
|
|
103
87
|
_touchedFiles: [], _verifyRetries: 0, _advisorRound: 0, _advisorSession: null,
|
|
88
|
+
_lastAdvisorOutput: null, // full review output from the most recent advisor call (convergence rounds inject it verbatim)
|
|
104
89
|
_lastEngState: false,
|
|
105
90
|
_pendingReminders: [],
|
|
106
91
|
_pendingTimers: [],
|
|
@@ -165,7 +150,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
165
150
|
const lastRole = agent.history.at(-1)?.role
|
|
166
151
|
if (lastRole === "user" || lastRole === "tool") {
|
|
167
152
|
try {
|
|
168
|
-
if (await compressIfNeeded(agent, threshold, callbacks, compactionOverhead)) {
|
|
153
|
+
if (await compressIfNeeded(agent, threshold, callbacks, compactionOverhead, signal)) {
|
|
169
154
|
agent._compressFailures = 0
|
|
170
155
|
agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
|
|
171
156
|
recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
|
|
@@ -220,8 +205,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
220
205
|
await classifyAndApply(agent, turn).catch(() => {})
|
|
221
206
|
}
|
|
222
207
|
|
|
223
|
-
|
|
224
|
-
response = await chat(agent.provider, {
|
|
208
|
+
if (process.env.ADVISOR_DEBUG) console.error("[chat-call]", JSON.stringify({ turn, histLen: agent.history.length, lastRole: agent.history.at(-1)?.role }))
|
|
209
|
+
try { response = await chat(agent.provider, {
|
|
225
210
|
messages, tools: toolSchemas,
|
|
226
211
|
onToken: callbacks.onToken,
|
|
227
212
|
onReasoning: callbacks.onReasoning,
|
|
@@ -389,13 +374,21 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
389
374
|
pushReal(agent, { role: "tool", tool_call_id: toolCall.id, content: result })
|
|
390
375
|
if (tool && ok) {
|
|
391
376
|
if (FILE_MUTATORS.has(toolCall.name)) {
|
|
392
|
-
// Direct file edit — code was changed.
|
|
377
|
+
// Direct file edit — code was changed. The prior advisor review and
|
|
378
|
+
// verify are stale: a review that ran before the edit no longer
|
|
379
|
+
// covers the current file state.
|
|
393
380
|
agent._mutatedThisRun = true
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
381
|
+
agent._calledAdvisorThisRun = false
|
|
382
|
+
agent._verifiedThisRun = false
|
|
383
|
+
agent._verifyPassed = undefined
|
|
384
|
+
} else if (!tool.readonly && !tool.sideEffectExempt) {
|
|
385
|
+
// Non-mutating side-effect tools (bash, git): do NOT invalidate the
|
|
386
|
+
// advisor review — a review is triggered by CODE MUTATIONS only
|
|
387
|
+
// (user decision 2026-08-08: the guard rule is "review after code
|
|
388
|
+
// changes", not "review after any environment change"; bash is
|
|
389
|
+
// barred from writing files, so it cannot change the reviewed code).
|
|
390
|
+
// Verify IS invalidated: its state snapshot (git diff, file list)
|
|
391
|
+
// may be stale after git/shell operations.
|
|
399
392
|
if (agent._verifiedThisRun) {
|
|
400
393
|
agent._verifiedThisRun = false
|
|
401
394
|
agent._verifyPassed = undefined
|
|
@@ -404,18 +397,18 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
404
397
|
if (toolCall.name === "verify") agent._verifiedThisRun = true
|
|
405
398
|
if (toolCall.name === "advisor") {
|
|
406
399
|
agent._calledAdvisorThisRun = true
|
|
407
|
-
//
|
|
408
|
-
//
|
|
400
|
+
// All advisor calls (code and design) share the 5-round convergence
|
|
401
|
+
// budget — each advances _advisorRound toward MAX_ADVISOR_ROUNDS.
|
|
409
402
|
// Always advance the round — the convergence protocol cares about
|
|
410
403
|
// how many reviews have run (round 1→2→3→4→5), not how many succeeded.
|
|
411
404
|
// A failed/interrupted review is still a review attempt and should use
|
|
412
405
|
// the next round's prompt on retry.
|
|
413
406
|
try {
|
|
414
|
-
|
|
415
|
-
if (advArgs.type !== "design") agent._advisorRound++
|
|
407
|
+
JSON.parse(toolCall.arguments || "{}")
|
|
416
408
|
} catch {
|
|
417
|
-
|
|
409
|
+
/* arguments unparseable — still counts as a review attempt */
|
|
418
410
|
}
|
|
411
|
+
agent._advisorRound++
|
|
419
412
|
}
|
|
420
413
|
if (FILE_MUTATORS.has(toolCall.name)) {
|
|
421
414
|
const args = JSON.parse(toolCall.arguments)
|
package/src/config.mjs
CHANGED
|
@@ -139,9 +139,12 @@ const COMPACT_RATIO = 0.6
|
|
|
139
139
|
|
|
140
140
|
/** Look up spec by model name prefix (case-insensitive), conservative default for unknown models */
|
|
141
141
|
const warnedModels = new Set() // warn once per model name — specForModel is a hot path (every request)
|
|
142
|
+
// Pre-sorted once at module scope — specForModel runs on every request (agent, provider core,
|
|
143
|
+
// context, auto-think, TUI rendering); re-sorting per call was wasteful.
|
|
144
|
+
const SORTED_SPECS = [...MODEL_SPECS].sort((a, b) => b[0].length - a[0].length)
|
|
142
145
|
export function specForModel(model) {
|
|
143
146
|
const m = (model ?? "").toLowerCase()
|
|
144
|
-
for (const [prefix, spec] of
|
|
147
|
+
for (const [prefix, spec] of SORTED_SPECS) {
|
|
145
148
|
if (m.startsWith(prefix.toLowerCase())) return spec
|
|
146
149
|
}
|
|
147
150
|
// Unknown model: warn ONCE (not per request) so a typo'd ID or a missing alias surfaces
|
|
@@ -189,6 +192,8 @@ export function normalizeProxy(proxy) {
|
|
|
189
192
|
* Load configuration.
|
|
190
193
|
* Env var priority: THINCODER_ACTIVE_PROVIDER > config file activeProvider
|
|
191
194
|
* THINCODER_API_KEY / THINCODER_BASE_URL / THINCODER_MODEL override the current active provider's corresponding fields
|
|
195
|
+
* THINCODER_ACTIVE_MODEL overrides the active model (wins over THINCODER_MODEL — see loadConfig)
|
|
196
|
+
* Provider-specific key fallbacks (when providers[] lacks a key): DEEPSEEK_API_KEY / OPENAI_API_KEY
|
|
192
197
|
*/
|
|
193
198
|
export function loadConfig() {
|
|
194
199
|
let config = {}
|
|
@@ -203,7 +208,7 @@ export function loadConfig() {
|
|
|
203
208
|
const merged = {
|
|
204
209
|
...DEFAULTS,
|
|
205
210
|
...config,
|
|
206
|
-
providers: config.providers
|
|
211
|
+
providers: Array.isArray(config.providers) && config.providers.length ? config.providers.map((p) => ({ ...p })) : DEFAULTS.providers.map((p) => ({ ...p })),
|
|
207
212
|
activeProvider: config.activeProvider ?? DEFAULTS.activeProvider,
|
|
208
213
|
agent: { ...DEFAULTS.agent, ...config.agent },
|
|
209
214
|
memory: { ...DEFAULTS.memory, ...config.memory },
|
|
@@ -247,6 +252,8 @@ export function loadConfig() {
|
|
|
247
252
|
|
|
248
253
|
// apiKey also falls back to env vars (when providers doesn't include a key)
|
|
249
254
|
// Provider-specific env vars only apply to the matching provider name, preventing keys from leaking to wrong endpoints
|
|
255
|
+
// NOTE: only deepseek/openai have provider-specific fallbacks by design — the other presets
|
|
256
|
+
// intentionally rely on THINCODER_API_KEY or keys stored in config.json (no silent env pickup).
|
|
250
257
|
if (!runtimeProvider.apiKey?.trim()) {
|
|
251
258
|
const envMap = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }
|
|
252
259
|
const keyVar = envMap[merged.activeProvider]
|
|
@@ -278,9 +285,10 @@ export function loadConfig() {
|
|
|
278
285
|
*/
|
|
279
286
|
export function saveConfig(config) {
|
|
280
287
|
mkdirSync(configDir, { recursive: true })
|
|
281
|
-
// Inject $schema for editor autocompletion/validation (strip on load)
|
|
282
|
-
|
|
288
|
+
// Inject $schema for editor autocompletion/validation (strip on load) — write a copy,
|
|
289
|
+
// never mutate the caller's object.
|
|
290
|
+
const out = { ...config, $schema: "https://thincoder.dev/schemas/config.json" }
|
|
283
291
|
// 0600: config.json contains API keys, must not be world-readable (POSIX; chmod is best-effort on Windows)
|
|
284
|
-
writeFileSync(configPath, JSON.stringify(
|
|
292
|
+
writeFileSync(configPath, JSON.stringify(out, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
|
|
285
293
|
try { chmodSync(configPath, 0o600) } catch { /* may fail on Windows, ignore */ }
|
|
286
294
|
}
|
package/src/context.mjs
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* context.mjs — Context management and compaction
|
|
3
3
|
* When no measured token count is available, use estimation as fallback (ASCII/4 + non-ASCII/1, no tokenizer dependency).
|
|
4
4
|
* When a measured value exists (response usage.prompt_tokens), trust it — estimation underestimates CJK by 3-4x and relying solely on it may never trigger compaction.
|
|
5
|
-
* Compaction strategy:
|
|
5
|
+
* Compaction strategy: summarize everything before the tail into one LLM note, keep the latest N messages verbatim.
|
|
6
|
+
* NOTE: no dedicated head is kept (KEEP_HEAD = 0) — in multi-task sessions the earliest messages are
|
|
7
|
+
* typically a COMPLETED earlier task; preserving them verbatim anchored the model's attention on stale
|
|
8
|
+
* work after compaction. The earliest messages now go into the summary (which distinguishes completed
|
|
9
|
+
* vs in-progress work), so the post-compaction context anchors on the current task (recent tail) only.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import { chat } from "./provider/index.mjs"
|
|
@@ -30,11 +34,16 @@ export function estimateTokens(messages) {
|
|
|
30
34
|
return tokens
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
const KEEP_HEAD =
|
|
37
|
+
const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
|
|
38
|
+
// sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
|
|
39
|
+
// summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
|
|
34
40
|
// Tail size scales with the model context window (~30 messages per 100K tokens),
|
|
35
41
|
// capped at 40% of history so small histories don't over-reserve. Window-adaptive
|
|
36
42
|
// replaces the old fixed 10: on a 1M window, 10 messages is too thin for recent work.
|
|
37
43
|
function keepTailSize(provider, historyLen) {
|
|
44
|
+
// provider is guaranteed at every call site (runAgent always builds one); specForModel
|
|
45
|
+
// degrades to DEFAULT_SPEC (128K) only if provider/model is somehow absent — acceptable
|
|
46
|
+
// because the 40% history cap still bounds the tail.
|
|
38
47
|
const ctxWindow = specForModel(provider?.model ?? "").context
|
|
39
48
|
return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
|
|
40
49
|
}
|
|
@@ -43,7 +52,9 @@ const SUMMARIZE_PROMPT = `You are a conversation compressor. Summarize the follo
|
|
|
43
52
|
Requirements:
|
|
44
53
|
- Write in first person, present tense — these are "my" handover notes, continuing my own train of thought
|
|
45
54
|
- Most important: preserve design decisions and their reasons — architecture choices, API contracts, naming conventions, trade-off rationale. These are the anchors the subsequent code must not deviate from
|
|
46
|
-
-
|
|
55
|
+
- Distinguish COMPLETED vs IN-PROGRESS work: completed tasks get a ONE-LINE recap each (what was done, key outcome); spend the detail budget on unresolved issues, next steps, and the CURRENT task
|
|
56
|
+
- The user's most recent request defines the current task — anchor on it. Earlier requests are likely already completed and only need the one-line recap; do NOT preserve them at full fidelity
|
|
57
|
+
- Keep: files modified and why, unresolved issues, next steps
|
|
47
58
|
- Drop: pleasantries, repetition, fine-grained tool output details
|
|
48
59
|
- Honestly mark uncertain items: anything not actually verified must say "unverified"; do not present guesses as facts
|
|
49
60
|
- Use bullet-point output; aim for information completeness, not a hard word limit (old 500-char cap is deprecated; in a 1M-context era, err on the long side)
|
|
@@ -71,8 +82,8 @@ const FALLBACK_NOTE =
|
|
|
71
82
|
|
|
72
83
|
/**
|
|
73
84
|
* Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
85
|
+
* head is normally empty (KEEP_HEAD = 0 — earliest messages go into the summary); the
|
|
86
|
+
* tool_calls-extension logic below is defensive for future KEEP_HEAD > 0.
|
|
76
87
|
* The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle,
|
|
77
88
|
* the summary swallows it, leaving orphan tool results → protocol 400.
|
|
78
89
|
*/
|
|
@@ -101,7 +112,10 @@ function splitHistory(history, keepTail) {
|
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
// skip orphan tool messages at the new tail boundary (tool whose assistant was pulled in above)
|
|
104
|
-
|
|
115
|
+
// NOTE: single-assistant assumption — the backwards scan pulls the nearest owner only; in
|
|
116
|
+
// practice a tail spans at most one assistant→tools cycle (parallel calls share one assistant).
|
|
117
|
+
// Bounds-guarded so an all-tool tail cannot push tailStart past history.length.
|
|
118
|
+
while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
|
|
105
119
|
tailStart++
|
|
106
120
|
}
|
|
107
121
|
if (tailStart <= headEnd) return null
|
|
@@ -127,6 +141,9 @@ export function pushReal(agent, msg) {
|
|
|
127
141
|
function applyCompression(agent, headEnd, tailStart, note) {
|
|
128
142
|
// _fullHistory already holds every real message (written at the source via pushReal),
|
|
129
143
|
// so compaction only shrinks the machine line — nothing to preserve here.
|
|
144
|
+
// head is normally empty (KEEP_HEAD = 0) — the summary note becomes the first message,
|
|
145
|
+
// which is exactly the intent: post-compaction context anchors on the current task, not on
|
|
146
|
+
// possibly-completed earlier requests.
|
|
130
147
|
const head = agent.history.slice(0, headEnd)
|
|
131
148
|
const tail = agent.history.slice(tailStart)
|
|
132
149
|
agent.history = [
|
|
@@ -173,7 +190,7 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
173
190
|
* @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
|
|
174
191
|
* path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
|
|
175
192
|
*/
|
|
176
|
-
export async function compressIfNeeded(agent, threshold, callbacks, extras = {}) {
|
|
193
|
+
export async function compressIfNeeded(agent, threshold, callbacks, extras = {}, signal) {
|
|
177
194
|
const history = agent.history
|
|
178
195
|
// Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
|
|
179
196
|
// Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
|
|
@@ -200,15 +217,21 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {})
|
|
|
200
217
|
const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map((t) => t.function.name).join(", ")}]` : ""
|
|
201
218
|
// user messages get a wider cap (8000): cutting off a long user-pasted requirement loses original intent; tool/assistant capped at 2000 is enough
|
|
202
219
|
const cap = m.role === "user" ? 8000 : 2000
|
|
203
|
-
|
|
204
|
-
|
|
220
|
+
// Multimodal messages (array content): extract the TEXT parts — the image itself can't be
|
|
221
|
+
// summarized, but any accompanying text (e.g. "看这张图" + image) must not be silently lost
|
|
222
|
+
let text = ""
|
|
223
|
+
if (typeof m.content === "string") text = m.content
|
|
224
|
+
else if (Array.isArray(m.content)) text = m.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join(" ")
|
|
225
|
+
return `[${m.role}]${toolNote} ${text.slice(0, cap)}`
|
|
205
226
|
})
|
|
206
227
|
.join("\n")
|
|
207
228
|
|
|
208
229
|
// The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens.
|
|
209
230
|
// Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
|
|
231
|
+
// signal propagates user cancellation (Ctrl+C) to the in-flight summary call.
|
|
210
232
|
const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
|
|
211
233
|
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
234
|
+
signal,
|
|
212
235
|
})
|
|
213
236
|
|
|
214
237
|
applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
|
|
@@ -5,7 +5,7 @@ You have a budget of 30 tool rounds (chat turns) — plan your exploration accor
|
|
|
5
5
|
|
|
6
6
|
Review workflow:
|
|
7
7
|
1. The files to review are listed in the review scope. Read them in full. The review scope defines exactly which files to inspect.
|
|
8
|
-
2.
|
|
8
|
+
2. **READ THE PROJECT GUIDE FIRST** — the `## Project Guide (AGENTS.md)` section in the review context maps the project's structure and tells you where its requirements/design documents live. Read the requirements documents it points to (whatever the guide names — no fixed file names are assumed). **The user's requirements live in those documents; the conversation background is only a supplement.** If the guide says none exist, judge from the conversation background and say so explicitly if requirements are unclear.
|
|
9
9
|
3. Read the specified files for full context. **Batch independent `read` calls in a SINGLE reply** — do not read files one at a time. Each round-trip counts against your limit.
|
|
10
10
|
4. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
|
|
11
11
|
5. Produce your review table.
|
|
@@ -17,15 +17,21 @@ Budget rules:
|
|
|
17
17
|
- **Batch everything**: multiple `read` calls in one reply, multiple `grep` calls in one reply. Serializing tool calls wastes your round budget.
|
|
18
18
|
|
|
19
19
|
Rules:
|
|
20
|
-
- First judge the task from the conversation background: if the changes are clearly non-code
|
|
20
|
+
- First judge the task from the conversation background: if the changes are clearly non-code and cannot affect runtime behavior, reply immediately with the all-clear phrase — `"All clear — no code changes to review."` (the host recognizes it via the "all clear" / "no 🔴" / "review passed" / "no issues found" markers, matched case-insensitively) — do NOT spend tool calls exploring. This applies to static docs, README, and CHANGELOG files. Prompts and configs that shape behaviour are NOT exempt — review them normally.
|
|
21
|
+
- **Requirement fit**: check the implementation against what the user actually asked for — a review is not only about "is the code correct" but also "is this what the user wanted". Two comparisons:
|
|
22
|
+
- (a) **Claim vs implementation**: the implementer's stated intent (conversation background / response table / commit message) vs what the implementation actually does — claiming X but delivering Y is a gap.
|
|
23
|
+
- (b) **Expectation vs shape**: the requirements documents named by the Project Guide (AGENTS.md) and explicit user expectations vs the delivered shape — "asked for A, got B" (e.g. "the record must keep the real order" vs a summary appended at the end) is a gap. **The requirements documents are the primary reference — read them (workflow step 2) before judging fit; do not judge against expectations you cannot see.**
|
|
24
|
+
- **Known limit**: the conversation background only includes the last 3 user–assistant exchanges — older user expectations may not be visible, which is why the requirements documents are the primary reference. (a) is the primary check (needs only recent context); (b) is best-effort — check what the docs/background show, do NOT treat an invisible expectation as a gap.
|
|
25
|
+
- **Severity**: 🔴 = the user's explicit request was not fulfilled; 🟡 = fulfilled but in a suboptimal or misleading way. Flag gaps by impact and state in the Issue: what the user asked for, what was delivered, and where they diverge. Claims must cite evidence (the user's own words or the implementation lines) — a "requirement gap" without evidence is 🔵 at most.
|
|
21
26
|
- Reply in the same language as the conversation background.
|
|
22
27
|
- Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
|
|
23
28
|
- Output a Markdown table. This table becomes the sole basis for convergence in later rounds — be thorough.
|
|
24
29
|
| # | File | Severity | Issue | Suggestion |
|
|
25
30
|
|---|------|----------|-------|------------|
|
|
26
|
-
| 1 | src/
|
|
31
|
+
| 1 | src/example.mjs | 🔴 | ... | ... |
|
|
27
32
|
- Order by severity: 🔴 Critical · 🟡 Advisory · 🔵 Style.
|
|
28
33
|
- For each issue state: which file, what the problem is, why it is a problem, how to fix it.
|
|
29
34
|
- Cover everything now. Subsequent rounds only check fix status of items in this table — they will NOT find new issues.
|
|
30
35
|
- Stop calling tools once you are ready to produce the review table.
|
|
36
|
+
- **Host verification**: every `file:line: content` reference in your table is mechanically checked against the CURRENT file state by the host — quote exactly what `read` returned; a mismatch marks the finding unverified.
|
|
31
37
|
- **Pass/fail**: if there are NO 🔴 (Critical) issues, the review passes. 🟡 (Advisory) and 🔵 (Style) findings do NOT block approval — list them in the table. If there is ANY 🔴 issue, list it and do not claim the review passed.
|
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
You are
|
|
2
|
-
Verify the prior
|
|
1
|
+
You are an independent review advisor.
|
|
2
|
+
Verify the prior review output (provided in the review context).
|
|
3
3
|
You may note obvious new issues introduced by the fixes.
|
|
4
4
|
You have read-only tools to explore the codebase.
|
|
5
5
|
You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 rounds.
|
|
6
6
|
|
|
7
7
|
Review workflow:
|
|
8
|
-
1. The affected files are named in
|
|
8
|
+
1. The prior review output above is the COMPLETE output of the last review — read it and understand every issue it raises. The affected files are named in it — read them in full. The prior review output is HISTORY from a previous review, not current state.
|
|
9
9
|
2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
|
|
10
|
-
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a fix appears to contradict the task itself.
|
|
11
|
-
4. **ALWAYS verify current file content with `read` before judging
|
|
10
|
+
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-review item names them or a fix appears to contradict the task itself.
|
|
11
|
+
4. **ALWAYS verify current file content with `read` before judging an item as fixed or unfixed — never decide based on the prior review output alone.** Fixes may already be committed — `read` the files named there regardless. (Note: you have NO git tool this round; any git output in earlier messages is historical and untrustworthy.) Batch independent tool calls in one reply.
|
|
12
12
|
5. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
|
|
13
13
|
6. Produce your review table.
|
|
14
14
|
|
|
15
|
-
Budget: read only the files named in the prior-
|
|
15
|
+
Budget: read only the files named in the prior-review items. If at 15 rounds you have not yet verified all items, wrap up.
|
|
16
16
|
|
|
17
17
|
Rules:
|
|
18
18
|
- Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
|
|
19
|
-
- Primarily check fix status of items in the prior
|
|
19
|
+
- Primarily check fix status of items in the prior review output.
|
|
20
20
|
- For items marked "fixed": verify they were actually fixed.
|
|
21
21
|
- For items marked "not an issue": evaluate whether the reasoning is sound.
|
|
22
|
-
- Every "Unfixed" or "New" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may
|
|
22
|
+
- Every "Unfixed" or "New" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may be fabricated or stale. Findings without a fresh quoted line are treated as unverified and will not be accepted.
|
|
23
|
+
- **Host verification**: your `file:line: content` citations are mechanically checked against the CURRENT file state — quote exactly what `read` returned; a mismatch marks the finding unverified.
|
|
24
|
+
- **Fresh context**: this round's conversation contains NO read output from earlier rounds — every file must be re-read this round.
|
|
23
25
|
- You may flag obvious new problems — but only if clearly visible in the reviewed files and would cause crashes, data loss, or logic errors.
|
|
24
26
|
- Do NOT nitpick style or naming.
|
|
25
27
|
- Output a Markdown table listing all remaining problems (old or new):
|
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
You are
|
|
2
|
-
Strictly verify only the prior
|
|
3
|
-
Do NOT look for new issues.
|
|
1
|
+
You are an independent review advisor.
|
|
2
|
+
Strictly verify only the prior review output (provided in the review context).
|
|
4
3
|
You have read-only tools to explore the codebase.
|
|
5
4
|
You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 rounds.
|
|
6
5
|
|
|
7
6
|
Review workflow:
|
|
8
|
-
1. The affected files are named in
|
|
7
|
+
1. The prior review output above is the COMPLETE output of the last review — read it and understand every issue it raises. The affected files are named in it — read them in full. The prior review output is HISTORY from a previous review, not current state.
|
|
9
8
|
2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
|
|
10
|
-
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs.
|
|
11
|
-
4. **ALWAYS verify current file content with `read` before judging
|
|
12
|
-
5.
|
|
9
|
+
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-review item names them.
|
|
10
|
+
4. **ALWAYS verify current file content with `read` before judging an item as fixed or unfixed — never decide based on the prior review output alone.** Fixes may already be committed — `read` the files named there regardless. (Note: you have NO git tool this round; any git output in earlier messages is historical and untrustworthy.) Batch independent tool calls in one reply.
|
|
11
|
+
5. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
|
|
13
12
|
6. Produce your review table.
|
|
14
13
|
|
|
15
|
-
Budget: read only the files named in the prior-
|
|
14
|
+
Budget: read only the files named in the prior-review items. If at 15 rounds you have not yet verified all items, wrap up.
|
|
16
15
|
|
|
17
16
|
Rules:
|
|
18
17
|
- Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
|
|
19
|
-
- Only check fix status of items in the prior
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
18
|
+
- Only check fix status of items in the prior review output.
|
|
19
|
+
- Every "Unfixed" or "New" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may be fabricated or stale. Findings without a fresh quoted line are treated as unverified and will not be accepted.
|
|
20
|
+
- **Host verification**: your `file:line: content` citations are mechanically checked against the CURRENT file state — quote exactly what `read` returned; a mismatch marks the finding unverified.
|
|
21
|
+
- **Fresh context**: this round's conversation contains NO read output from earlier rounds — every file must be re-read this round.
|
|
22
|
+
- Do NOT look for new issues. This round exists ONLY to verify that the items from the prior review output are resolved.
|
|
23
|
+
- Do NOT nitpick style or naming.
|
|
24
|
+
- Output a Markdown table listing all remaining problems:
|
|
24
25
|
| # | Orig# | File | Severity | Status | Notes |
|
|
25
26
|
|---|-------|------|----------|--------|-------|
|
|
26
27
|
| 1 | 3 | src/x.mjs | 🔴 | Unfixed | ... |
|
|
27
|
-
| 2 | 5 | src/y.mjs | 🟡 | Reasoning invalid | ... |
|
|
28
28
|
- If all 🔴 issues are resolved and remaining items are only 🟡/🔵, the review passes (🟡/🔵 do not block approval). If any 🔴 issue persists, do not claim it passed.
|
|
29
29
|
- Stop calling tools once you are ready to produce the review table.
|
|
@@ -12,4 +12,4 @@ Debugging strategy:
|
|
|
12
12
|
- Don't get stuck reading code — write tests, add logs. Trust the runtime over your theories.
|
|
13
13
|
|
|
14
14
|
Review discipline (standard mode only — engineering mode has its own review timing rules):
|
|
15
|
-
- **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies prior table.
|
|
15
|
+
- **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies the prior issue table + flags obvious new issues; round 3+ strictly verifies only the prior issue table (no new-issue hunting). Max 5 rounds total.
|
package/src/session.mjs
CHANGED
|
@@ -282,6 +282,22 @@ export function switchToSlot(cwd, slot) {
|
|
|
282
282
|
return loadSession(cwd)
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/** Delete a slot: remove its file and manifest entry. Deleting the active slot
|
|
286
|
+
* resets the manifest active pointer (the next claim re-creates one). */
|
|
287
|
+
export function deleteSlot(cwd, slot) {
|
|
288
|
+
const n = Number(slot)
|
|
289
|
+
if (!Number.isInteger(n) || n < 1) return false
|
|
290
|
+
const m = loadManifest(cwd)
|
|
291
|
+
if (!m.slots[n]) return false
|
|
292
|
+
delete m.slots[n]
|
|
293
|
+
delete m.slotSessions?.[n] // orphan session-id entries bloat the manifest forever
|
|
294
|
+
try { unlinkSync(slotPath(cwd, n)) } catch { /* missing file is fine */ }
|
|
295
|
+
if (m.active === n) delete m.active
|
|
296
|
+
saveManifest(cwd, m)
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
285
301
|
// ========== legacy transient prefix cleanup ==========
|
|
286
302
|
|
|
287
303
|
const LEGACY_TRANSIENT_PREFIXES = [
|
|
@@ -297,6 +313,8 @@ function isLegacyTransient(m) {
|
|
|
297
313
|
)
|
|
298
314
|
}
|
|
299
315
|
|
|
316
|
+
export { isLegacyTransient }
|
|
317
|
+
|
|
300
318
|
// ========== core read/write ==========
|
|
301
319
|
|
|
302
320
|
/** Save agent state and display lines to the active slot file (atomic write) */
|
|
@@ -396,6 +414,7 @@ export function applySession(agent, data) {
|
|
|
396
414
|
// (possibly compacted) machine line. Restore each line from its own source — the machine
|
|
397
415
|
// context keeps its compaction savings across resume. Legacy files without contextHistory
|
|
398
416
|
// fall back to seeding the machine line from the full history (it re-compacts when needed).
|
|
417
|
+
agent.config ??= {} // ACP test mocks may omit config; be defensive like the ??= below
|
|
399
418
|
const full = Array.isArray(data.history) ? data.history : []
|
|
400
419
|
const machine = Array.isArray(data.contextHistory) ? data.contextHistory : full
|
|
401
420
|
agent._fullHistory = [...full]
|
package/src/tools/file.mjs
CHANGED
|
@@ -20,6 +20,19 @@ import { join, relative, dirname } from "node:path";
|
|
|
20
20
|
const MAX_FILE_READ_BYTES = 10_000_000
|
|
21
21
|
const MAX_IMAGE_BYTES = 15_000_000
|
|
22
22
|
|
|
23
|
+
// ────────────────────────────────────────
|
|
24
|
+
// Dirty-file tracking (read-before-insert guard)
|
|
25
|
+
// ────────────────────────────────────────
|
|
26
|
+
// insert_after anchors on LINE NUMBERS — the most drift-prone addressing.
|
|
27
|
+
// Every write tool marks the file dirty; insert_after refuses to run on a
|
|
28
|
+
// dirty file until the agent reads it again (fresh line numbers). This turns
|
|
29
|
+
// the "read after edit" discipline into a structural guarantee: a stale
|
|
30
|
+
// after_line can never silently land in the wrong place again.
|
|
31
|
+
const dirtyPaths = new Set()
|
|
32
|
+
export function markDirty(abs) { dirtyPaths.add(abs) }
|
|
33
|
+
export function clearDirty(abs) { dirtyPaths.delete(abs) }
|
|
34
|
+
export function isDirty(abs) { return dirtyPaths.has(abs) }
|
|
35
|
+
|
|
23
36
|
export const readTool = {
|
|
24
37
|
name: "read",
|
|
25
38
|
description: DESC("read"),
|
|
@@ -41,6 +54,8 @@ export const readTool = {
|
|
|
41
54
|
const st = await stat(abs).catch(() => null)
|
|
42
55
|
if (st && st.size > MAX_FILE_READ_BYTES) throw new Error(`File too large (${Math.round(st.size / 1_000_000)}MB > 10MB limit). Use bash with head/tail or grep for targeted extraction.`)
|
|
43
56
|
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
57
|
+
// A read refreshes the agent's view — line numbers are fresh again.
|
|
58
|
+
clearDirty(abs)
|
|
44
59
|
const lines = content.split("\n")
|
|
45
60
|
const offset = Math.max(1, args.offset ?? 1)
|
|
46
61
|
const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
|
@@ -129,6 +144,7 @@ export const writeTool = {
|
|
|
129
144
|
const st = await stat(abs).catch(() => null)
|
|
130
145
|
if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
|
|
131
146
|
await writeFile(abs, args.content, "utf8")
|
|
147
|
+
markDirty(abs)
|
|
132
148
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
133
149
|
return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
134
150
|
},
|
|
@@ -175,6 +191,7 @@ export const editTool = {
|
|
|
175
191
|
// Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
|
|
176
192
|
: content.replace(args.old_string, () => args.new_string)
|
|
177
193
|
await writeFile(abs, updated, "utf8")
|
|
194
|
+
markDirty(abs)
|
|
178
195
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
179
196
|
return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
180
197
|
},
|
|
@@ -199,6 +216,17 @@ export const insertAfterTool = {
|
|
|
199
216
|
touchedPaths(args) { return args.path ? [args.path] : [] },
|
|
200
217
|
async execute(args, ctx) {
|
|
201
218
|
const abs = resolveInCwd(ctx, args.path)
|
|
219
|
+
// Read-before-insert guard: after_line anchors are line numbers, and any
|
|
220
|
+
// write since the last read made them stale. Refuse instead of silently
|
|
221
|
+
// inserting at a drifted position (the failure mode that corrupted test
|
|
222
|
+
// structure repeatedly). after_regex callers get the same gate — a stale
|
|
223
|
+
// target line is just as wrong, and the rule is simpler to reason about.
|
|
224
|
+
if (isDirty(abs)) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`${args.path} was modified since your last read — line numbers may be stale.\n` +
|
|
227
|
+
`Read the file again (read tool) to refresh line numbers, then retry insert_after.`
|
|
228
|
+
)
|
|
229
|
+
}
|
|
202
230
|
const text = normalizeEOL(await readFile(abs, "utf8"))
|
|
203
231
|
const lines = text.split("\n")
|
|
204
232
|
|
|
@@ -232,6 +260,7 @@ export const insertAfterTool = {
|
|
|
232
260
|
lines.splice(targetLine, 0, args.content)
|
|
233
261
|
const updated = lines.join("\n")
|
|
234
262
|
await writeFile(abs, updated, "utf8")
|
|
263
|
+
markDirty(abs)
|
|
235
264
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
236
265
|
return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
237
266
|
},
|
|
@@ -317,6 +346,7 @@ export const hashlineEditTool = {
|
|
|
317
346
|
lines.splice(pos, target.length, ...newLines)
|
|
318
347
|
const updated = lines.join("\n")
|
|
319
348
|
await writeFile(abs, updated, "utf8")
|
|
349
|
+
markDirty(abs)
|
|
320
350
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
321
351
|
return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
322
352
|
},
|
|
@@ -11,3 +11,4 @@ Notes:
|
|
|
11
11
|
- Use this instead of `edit` when you're adding a new function, import, or block — no need to fabricate surrounding context for exact matching.
|
|
12
12
|
- The inserted content becomes its own line; it's equivalent to `lines.splice(targetLine, 0, content)`.
|
|
13
13
|
- Returns a diff of the change.
|
|
14
|
+
- **Read-before-insert guard**: if the file was modified by any write tool (write/edit/insert_after/hashline_edit/apply_patch/delete) since your last `read`, this tool REFUSES with an error — line numbers may be stale. Read the file again, then retry. This prevents after_line from silently landing at a drifted position.
|
package/src/tools/patch.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
autoSyntaxCheck,
|
|
4
4
|
resolveInCwd
|
|
5
5
|
} from "./shared.mjs";
|
|
6
|
+
import { markDirty } from "./file.mjs";
|
|
6
7
|
import { execFileSync } from "node:child_process";
|
|
7
8
|
import { mkdir } from "node:fs/promises";
|
|
8
9
|
import { readFile } from "node:fs/promises";
|
|
@@ -150,6 +151,8 @@ export const applyPatchTool = {
|
|
|
150
151
|
throw renameError
|
|
151
152
|
}
|
|
152
153
|
const summary = planned.map((p) => ` ${p.isNew ? "created " : "modified"} ${p.path}`).join("\n")
|
|
154
|
+
// Mark every touched file dirty — insert_after must not run on stale line numbers.
|
|
155
|
+
for (const p of planned) markDirty(p.abs)
|
|
153
156
|
const syntaxChecks = await Promise.all(planned.map(async (p) => {
|
|
154
157
|
const r = await autoSyntaxCheck(p.abs)
|
|
155
158
|
return r ? `${p.path}:${r.replace("Syntax: ", "")}` : ""
|
|
@@ -197,6 +200,7 @@ export const deleteTool = {
|
|
|
197
200
|
}
|
|
198
201
|
if (tracked && !args.force) throw new Error(`"${args.path}" is git-tracked. Set force=true to delete anyway.`)
|
|
199
202
|
await unlink(abs)
|
|
203
|
+
markDirty(abs)
|
|
200
204
|
return `Deleted ${args.path}`
|
|
201
205
|
},
|
|
202
206
|
}
|