thincoder 0.12.44 → 0.12.46
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/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/src/agent-tools/advisor.mjs +27 -23
- package/src/agent-tools/eng.mjs +9 -4
- package/src/agent.mjs +11 -6
- package/src/tools/checklist.md +4 -1
- package/src/tools/checklist.mjs +105 -46
- package/src/tools/file.mjs +48 -12
- package/src/tools/patch.mjs +16 -13
- package/src/tools/shared.mjs +101 -1
- package/src/tui/cmd-eng.mjs +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录 ThinCoder CLI 的发布历史。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),版本遵循[语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [0.12.46] — 2026-08-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **checklist 工具坐标系断裂**:`add` 返回任务 ID、`mark` 却只收列表位置 index,agent 拿 ID 定位不到条目、只能猜 index——误标无关条目(线上事故)。修复:`mark` 加 `id` 参数(优先于 index);auto-ID 按「最大根号+1」分配(含 `checklist-done.md` 双文件扫描,归档 ID 恒占位不复用);历史重复 `T[\d.]+:` 前缀读入即归一;标记父任务 done 时子任务非全 done 则拒绝(防静默丢弃子树),全 done 则递归归档整棵子树
|
|
10
|
+
- **子 agent/advisor 模型显示补录**(TUI.md 文档欠账,功能此前已实现)
|
|
11
|
+
|
|
12
|
+
## [0.12.45] — 2026-08-26
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **编辑工具 CRLF 行尾写回丢失**:`edit` / `apply_patch` / `hashline_edit` / `insert_after` 在 Windows CRLF 文件上写回全部被转成 LF(normalize 后直接落盘)——现按"首个换行符类型"检测原文件行尾并原样恢复,diff 不再整文件重写;`new_string`/`new_content` 含 CRLF 时先归一化再转换,杜绝 `\r\r\n`
|
|
17
|
+
- **`old_string not found` 黑盒报错**:失败时返回相似度最高的 top 3 候选行(行号+预览+LCS 相似度,阈值 0.5,多行 old_string 只对首行并标注 `old_string line 1:`)——从盲猜变导航
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`write` 行尾语义**:覆盖既有文件按原行尾恢复;新建文件默认 LF,同目录多数派为 CRLF 时跟随(≤20 文件嗅探)
|
|
22
|
+
- **`hashline_edit` 编码损坏探测**:文件含 U+FFFD(替换符)时结果追加警告(编码可能已损坏、哈希寻址可能不可靠),不阻断
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- 候选相似度 LCS 计算复用模块级 DP 缓冲(大文件失败路径不再有每行分配的 GC 压力)
|
|
27
|
+
|
|
5
28
|
## [0.12.44] — 2026-08-25
|
|
6
29
|
|
|
7
30
|
### Fixed
|
package/package.json
CHANGED
|
@@ -7,40 +7,40 @@ import { randomUUID, createHmac } from "node:crypto"
|
|
|
7
7
|
import { runAdvisorReview } from "../advisor/run.mjs"
|
|
8
8
|
import { isDocFile } from "../advisor/repos.mjs"
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const TOKEN_TTL_DEFAULT_MS = 7 * 24 * 3600 * 1000 // 7-day ceiling (v2 2026-08-25): multi-batch delivery must not re-review an unchanged design within a week; agent.engTokenTtlMs overrides
|
|
11
11
|
const TOKEN_SECRET = process.env.THINCODER_TOKEN_SECRET || "thincoder-default-secret"
|
|
12
12
|
|
|
13
|
+
/** Effective token TTL: config override with runtime validation (advisor timeoutMs precedent —
|
|
14
|
+
* invalid values fall back to the default, never silently disable the ceiling). */
|
|
15
|
+
function effectiveTokenTtlMs(agent) {
|
|
16
|
+
const cfg = agent?.config?.agent?.engTokenTtlMs
|
|
17
|
+
return (Number.isFinite(cfg) && cfg > 0) ? cfg : TOKEN_TTL_DEFAULT_MS
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
/** Generate a signed design token with expiration */
|
|
14
|
-
function generateDesignToken() {
|
|
21
|
+
function generateDesignToken(agent) {
|
|
15
22
|
const uuid = randomUUID()
|
|
16
|
-
const expiresAt = Date.now() +
|
|
23
|
+
const expiresAt = Date.now() + effectiveTokenTtlMs(agent)
|
|
17
24
|
const payload = `${uuid}:${expiresAt}`
|
|
18
25
|
const signature = createHmac("sha256", TOKEN_SECRET).update(payload).digest("hex").slice(0, 16)
|
|
19
26
|
return `${payload}:${signature}`
|
|
20
27
|
}
|
|
21
28
|
|
|
22
|
-
/** Validate design token:
|
|
23
|
-
*
|
|
24
|
-
|
|
29
|
+
/** Validate design token: format, expiration, signature — ALL fail-closed (v2 2026-08-25).
|
|
30
|
+
* The two legacy fail-open branches (parts!=3 → true, NaN expiry → true) were pass-through
|
|
31
|
+
* backdoors: any malformed string bypassed validation. Only exact signed tokens pass now.
|
|
32
|
+
* Format must be exactly uuid:expiresAt:hmacSig. */
|
|
25
33
|
export function validateDesignToken(token) {
|
|
26
34
|
if (!token || typeof token !== "string") return false
|
|
27
|
-
|
|
35
|
+
|
|
28
36
|
// New format: uuid:expiresAt:signature (3 parts separated by ':')
|
|
29
37
|
const parts = token.split(":")
|
|
30
|
-
if (parts.length !== 3)
|
|
31
|
-
|
|
32
|
-
return true
|
|
33
|
-
}
|
|
34
|
-
|
|
38
|
+
if (parts.length !== 3) return false // fail-closed (was: return true)
|
|
39
|
+
|
|
35
40
|
const [uuid, expiresAt, signature] = parts
|
|
36
41
|
const expTime = parseInt(expiresAt, 10)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (isNaN(expTime)) {
|
|
40
|
-
// Not a valid new format, treat as old format
|
|
41
|
-
return true
|
|
42
|
-
}
|
|
43
|
-
|
|
42
|
+
if (isNaN(expTime)) return false // fail-closed (was: return true)
|
|
43
|
+
|
|
44
44
|
// Check expiration
|
|
45
45
|
if (Date.now() > expTime) return false
|
|
46
46
|
|
|
@@ -130,7 +130,7 @@ export const advisorTool = {
|
|
|
130
130
|
// Generate the design token BEFORE the review and inject it into the advisor's prompt.
|
|
131
131
|
// The advisor (LLM) decides pass/fail itself and echoes the token only on approval —
|
|
132
132
|
// the gate is a mechanical string match, not fragile semantics parsing.
|
|
133
|
-
const designToken = reviewType === "design" ? generateDesignToken() : null
|
|
133
|
+
const designToken = reviewType === "design" ? generateDesignToken(agent) : null
|
|
134
134
|
const result = await runAdvisorReview(agent, reviewType, {
|
|
135
135
|
onOutput: ctx.onOutput,
|
|
136
136
|
signal: ctx.signal,
|
|
@@ -159,9 +159,13 @@ export const advisorTool = {
|
|
|
159
159
|
return `${cleanResult}\n\nApproved. Pass this exact token to eng-coder (designToken parameter): ${designToken}`
|
|
160
160
|
}
|
|
161
161
|
// Review failed (or advisor chose not to pass) → invalidate any previously-issued token.
|
|
162
|
-
//
|
|
163
|
-
// mode) —
|
|
164
|
-
|
|
162
|
+
// Guards (v2 2026-08-25): result === null means the review was SKIPPED (advisor disabled /
|
|
163
|
+
// not engineering mode) — must not revoke. An error reply (own "Advisor:" prefix — the
|
|
164
|
+
// error-return convention of runAdvisorReview) is a provider crash/timeout artifact, not
|
|
165
|
+
// a completed verdict — a network glitch must not revoke unrelated standing tokens.
|
|
166
|
+
// Only a COMPLETED review that did not pass revokes.
|
|
167
|
+
const isCompletedReview = result !== null && !result.startsWith("Advisor:")
|
|
168
|
+
if (isCompletedReview) agent._engDesignToken = null
|
|
165
169
|
// Strip every dead token occurrence from the raw output so the main agent can't grab an invalid one
|
|
166
170
|
if (result) {
|
|
167
171
|
const stripped = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
|
package/src/agent-tools/eng.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* In engineering mode the agent follows design-before-code methodology.
|
|
4
4
|
* Toggled here at session level; persisted by /eng.
|
|
5
5
|
*/
|
|
6
|
-
import { ENG_ON_REMINDER } from "../agent.mjs"
|
|
6
|
+
import { ENG_ON_REMINDER, ENG_OFF_REMINDER } from "../agent.mjs"
|
|
7
7
|
|
|
8
8
|
export const engTool = {
|
|
9
9
|
name: "eng",
|
|
@@ -27,8 +27,7 @@ export const engTool = {
|
|
|
27
27
|
ctx.agent._touchedFiles = [] // clear mutation tracking
|
|
28
28
|
ctx.agent._lastEngState = false
|
|
29
29
|
ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
|
|
30
|
-
ctx.agent._pendingReminders.push(
|
|
31
|
-
"[System reminder: engineering mode is now OFF — standard discipline applies. Changes go through the normal workflow.]")
|
|
30
|
+
ctx.agent._pendingReminders.push(ENG_OFF_REMINDER)
|
|
32
31
|
// 持久化工程模式状态到会话
|
|
33
32
|
if (ctx.persistState) {
|
|
34
33
|
await ctx.persistState({
|
|
@@ -42,8 +41,14 @@ export const engTool = {
|
|
|
42
41
|
return "Engineering mode exited. Standard discipline now applies. You may edit files directly."
|
|
43
42
|
}
|
|
44
43
|
if (args.action === "enter") {
|
|
44
|
+
// Idempotent enter (v2 2026-08-25): already in engineering mode → no-op. The old
|
|
45
|
+
// unconditional token clear killed standing design tokens on a redundant defensive
|
|
46
|
+
// eng(enter) — only a real off→on transition requires a fresh design review.
|
|
47
|
+
if (ctx.agent.config.agent.engineering) {
|
|
48
|
+
return "Engineering mode already active. Existing design tokens stay valid."
|
|
49
|
+
}
|
|
45
50
|
ctx.agent.config.agent.engineering = true
|
|
46
|
-
ctx.agent._engDesignToken = null //
|
|
51
|
+
ctx.agent._engDesignToken = null // off→on transition requires a fresh design review
|
|
47
52
|
ctx.agent._lastEngState = true
|
|
48
53
|
ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
|
|
49
54
|
ctx.agent._pendingReminders.push(ENG_ON_REMINDER)
|
package/src/agent.mjs
CHANGED
|
@@ -61,14 +61,19 @@ export const ENG_ON_REMINDER =
|
|
|
61
61
|
// Re-exported for API compatibility (single source of truth: advisor/repos.mjs)
|
|
62
62
|
export { hasCodeMutations } from "./advisor/repos.mjs"
|
|
63
63
|
|
|
64
|
-
/** Engineering
|
|
64
|
+
/** Engineering mode OFF reminder — shared with the eng tool and the injector. */
|
|
65
|
+
export const ENG_OFF_REMINDER =
|
|
66
|
+
"[System reminder: engineering mode is now OFF — standard discipline applies. " +
|
|
67
|
+
"Changes go through the normal workflow: you may edit files directly, advisor/verify " +
|
|
68
|
+
"guards apply per config.]"
|
|
69
|
+
|
|
70
|
+
/** Engineering-mode status injection — one reminder on EVERY transition (2026-08-25:
|
|
71
|
+
* OFF is announced too — the model must know the gates lifted; silence after /eng-off
|
|
72
|
+
* left it guessing. Covers TUI /eng, resume, and any path bypassing the eng tool.) */
|
|
65
73
|
function injectEngineeringReminder(agent) {
|
|
66
74
|
const eng = agent.config?.agent?.engineering ?? false
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// that it's in the default mode).
|
|
70
|
-
if (eng && !agent._lastEngState) {
|
|
71
|
-
agent.history.push({ role: "user", content: ENG_ON_REMINDER, transient: true })
|
|
75
|
+
if (eng !== agent._lastEngState) {
|
|
76
|
+
agent.history.push({ role: "user", content: eng ? ENG_ON_REMINDER : ENG_OFF_REMINDER, transient: true })
|
|
72
77
|
}
|
|
73
78
|
agent._lastEngState = eng
|
|
74
79
|
}
|
package/src/tools/checklist.md
CHANGED
|
@@ -4,7 +4,10 @@ Items support tree hierarchy via indentation (2 spaces per level) and auto-assig
|
|
|
4
4
|
|
|
5
5
|
Parameters:
|
|
6
6
|
- action: "add" | "mark" | "list"
|
|
7
|
+
- id: task ID to mark, e.g. "T3" (with "mark"; preferred — use the ID returned by `add`, or from `list`)
|
|
7
8
|
- item: text for new item (with "add")
|
|
8
|
-
- index: 1-based index (with "mark")
|
|
9
|
+
- index: 1-based index (with "mark"; fallback — use only when you have no ID)
|
|
9
10
|
- status: "pending" | "in_progress" | "done" (with "mark")
|
|
10
11
|
- parent: parent task ID for hierarchical tasks, e.g. "T1" (with "add")
|
|
12
|
+
|
|
13
|
+
Note: marking a parent "done" requires all its child tasks already done — otherwise it is rejected (complete the children before marking the parent done).
|
package/src/tools/checklist.mjs
CHANGED
|
@@ -31,42 +31,79 @@ function parse(filePath) {
|
|
|
31
31
|
const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
|
|
32
32
|
const text = m[3].trim()
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
text: idMatch ? text.slice(idMatch[0].length) : text,
|
|
43
|
-
children: [],
|
|
34
|
+
// Strip ALL leading "T[\d.]+:" tokens (historical dirty data can accumulate
|
|
35
|
+
// "T15: T15: T15:"); keep the first token as the ID and the rest as text.
|
|
36
|
+
let id = null
|
|
37
|
+
let bareText = text
|
|
38
|
+
let idTok
|
|
39
|
+
while ((idTok = bareText.match(/^(T[\d.]+):\s*/))) {
|
|
40
|
+
if (id == null) id = idTok[1]
|
|
41
|
+
bareText = bareText.slice(idTok[0].length)
|
|
44
42
|
}
|
|
43
|
+
const node = { id, index: flatIdx, depth, status, text: bareText, children: [] }
|
|
45
44
|
|
|
46
45
|
// Find parent by popping stack until we find a node at depth-1
|
|
47
46
|
while (stack.length > 1 && stack.at(-1).depth >= depth) stack.pop()
|
|
48
47
|
const parent = stack.at(-1)
|
|
49
48
|
parent.children.push(node)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
node.id = `T${rootIdx + 1}`
|
|
49
|
+
stack.push({ children: node.children, depth, id: node.id })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Assign stable IDs to lines that lacked an explicit one, exactly once.
|
|
53
|
+
// IDs are "max existing number + 1" (not position-based) so gaps left by
|
|
54
|
+
// archived items never collide, and persisted IDs never drift on re-read.
|
|
55
|
+
let assigned = false
|
|
56
|
+
function assignIds(nodes, parentId) {
|
|
57
|
+
for (const n of nodes) {
|
|
58
|
+
if (!n.id) {
|
|
59
|
+
n.id = parentId ? nextChildId(parentId, nodes) : nextRootId(nodes, doneRoots)
|
|
60
|
+
assigned = true
|
|
63
61
|
}
|
|
62
|
+
if (n.children?.length) assignIds(n.children, n.id)
|
|
64
63
|
}
|
|
65
|
-
stack.push({ children: node.children, depth, id: node.id })
|
|
66
64
|
}
|
|
65
|
+
// Root IDs archived to the done file also reserve numbers (mirrors the `add`
|
|
66
|
+
// path's double-file scan), so auto-assigned IDs never collide with them.
|
|
67
|
+
const doneRoots = readDoneRoots(join(dirname(filePath), DONE))
|
|
68
|
+
assignIds(items, null)
|
|
69
|
+
if (assigned) write(filePath, items)
|
|
70
|
+
|
|
67
71
|
return items
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
function readDoneRoots(doneFile) {
|
|
75
|
+
if (!existsSync(doneFile)) return []
|
|
76
|
+
const roots = []
|
|
77
|
+
for (const line of readFileSync(doneFile, "utf-8").split("\n")) {
|
|
78
|
+
const m = line.match(/^- \[.\] (T\d+): /)
|
|
79
|
+
if (m) roots.push({ id: m[1] })
|
|
80
|
+
}
|
|
81
|
+
return roots
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function nextRootId(items, doneItems) {
|
|
85
|
+
let max = 0
|
|
86
|
+
for (const list of [items, doneItems]) {
|
|
87
|
+
for (const c of list ?? []) {
|
|
88
|
+
const m = c.id?.match(/^T(\d+)$/)
|
|
89
|
+
if (m) max = Math.max(max, parseInt(m[1]))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return `T${max + 1}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function nextChildId(parentId, children) {
|
|
96
|
+
let max = 0
|
|
97
|
+
const prefix = `${parentId}.`
|
|
98
|
+
for (const c of children) {
|
|
99
|
+
if (c.id?.startsWith(prefix)) {
|
|
100
|
+
const suffix = c.id.slice(prefix.length)
|
|
101
|
+
if (/^\d+$/.test(suffix)) max = Math.max(max, parseInt(suffix))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return `${prefix}${max + 1}`
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
/** Write items back to file, preserving tree structure */
|
|
71
108
|
function write(filePath, items, _depth = 0) {
|
|
72
109
|
if (_depth === 0) mkdirSync(dirname(filePath), { recursive: true })
|
|
@@ -108,6 +145,26 @@ function flatten(items, out = []) {
|
|
|
108
145
|
return out
|
|
109
146
|
}
|
|
110
147
|
|
|
148
|
+
/** True if every descendant (children, grandchildren, …) is done. */
|
|
149
|
+
function allChildrenDone(node) {
|
|
150
|
+
for (const c of node.children ?? []) {
|
|
151
|
+
if (c.status !== "done" || !allChildrenDone(c)) return false
|
|
152
|
+
}
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Recursively clone a subtree for archiving, forcing every status to done. */
|
|
157
|
+
function archiveSubtree(node) {
|
|
158
|
+
return {
|
|
159
|
+
id: node.id,
|
|
160
|
+
index: 0,
|
|
161
|
+
depth: 0,
|
|
162
|
+
status: "done",
|
|
163
|
+
text: node.text,
|
|
164
|
+
children: (node.children ?? []).map(archiveSubtree),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
111
168
|
/** Parse pending items only (for context injection) */
|
|
112
169
|
export function pendingItems(cwd) {
|
|
113
170
|
const flat = flatten(parse(checklistPath(cwd)))
|
|
@@ -125,13 +182,17 @@ export const checklistTool = {
|
|
|
125
182
|
enum: ["add", "mark", "list"],
|
|
126
183
|
description: "add a new item / mark item status / list all items"
|
|
127
184
|
},
|
|
185
|
+
id: {
|
|
186
|
+
type: "string",
|
|
187
|
+
description: "Task ID to mark (preferred — use the ID returned by add, e.g. 'T3')"
|
|
188
|
+
},
|
|
128
189
|
item: {
|
|
129
190
|
type: "string",
|
|
130
191
|
description: "Item text (required for add)"
|
|
131
192
|
},
|
|
132
193
|
index: {
|
|
133
194
|
type: "number",
|
|
134
|
-
description: "1-based item index (
|
|
195
|
+
description: "1-based item index (fallback for mark, only when id is absent)"
|
|
135
196
|
},
|
|
136
197
|
status: {
|
|
137
198
|
type: "string",
|
|
@@ -161,48 +222,46 @@ export const checklistTool = {
|
|
|
161
222
|
parentId = found.item.id
|
|
162
223
|
}
|
|
163
224
|
|
|
164
|
-
|
|
165
|
-
let id
|
|
166
|
-
if (parentId) {
|
|
167
|
-
id = `${parentId}.${target.length + 1}`
|
|
168
|
-
} else {
|
|
169
|
-
let maxIdx = 0
|
|
170
|
-
for (const c of items) {
|
|
171
|
-
const m = c.id?.match(/^T(\d+)$/)
|
|
172
|
-
if (m) maxIdx = Math.max(maxIdx, parseInt(m[1]))
|
|
173
|
-
}
|
|
174
|
-
id = `T${maxIdx + 1}`
|
|
175
|
-
}
|
|
176
|
-
|
|
225
|
+
const id = parentId ? nextChildId(parentId, target) : nextRootId(items, parse(donePath(ctx.cwd)))
|
|
177
226
|
const node = { id, index: 0, depth: parentId ? 1 : 0, status: "pending", text: args.item, children: [] }
|
|
178
227
|
target.push(node)
|
|
179
228
|
write(checklistPath(ctx.cwd), items)
|
|
180
229
|
return `Added: [ ] ${id}: ${args.item}${parentId ? ` (under ${parentId})` : ""}`
|
|
181
230
|
}
|
|
182
231
|
case "mark": {
|
|
183
|
-
if (args.index == null) return "Error: 'index' is required for mark"
|
|
232
|
+
if (args.id == null && args.index == null) return "Error: 'id' or 'index' is required for mark"
|
|
184
233
|
const status = args.status
|
|
185
234
|
if (!status || !["pending", "in_progress", "done"].includes(status)) return "Error: 'status' is required (pending|in_progress|done)"
|
|
186
235
|
const cp = checklistPath(ctx.cwd)
|
|
187
236
|
const items = parse(cp)
|
|
188
|
-
|
|
189
|
-
if (args.
|
|
190
|
-
|
|
237
|
+
let item
|
|
238
|
+
if (args.id != null) {
|
|
239
|
+
const found = findById(items, args.id)
|
|
240
|
+
if (!found) return `Error: id '${args.id}' not found. Use 'list' to see all task IDs.`
|
|
241
|
+
item = found.item
|
|
242
|
+
} else {
|
|
243
|
+
const flat = flatten(items)
|
|
244
|
+
if (args.index < 1 || args.index > flat.length) return `Error: index ${args.index} out of range (1-${flat.length})`
|
|
245
|
+
item = flat[args.index - 1]
|
|
246
|
+
}
|
|
191
247
|
const old = item.status
|
|
192
248
|
if (old === status) return `Already ${status}: ${item.text}`
|
|
249
|
+
if (status === "done" && item.children?.length && !allChildrenDone(item)) {
|
|
250
|
+
return "Error: 父任务仍有未完成的子任务,先处理子任务再标父 done"
|
|
251
|
+
}
|
|
193
252
|
item.status = status
|
|
194
253
|
if (status === "done") {
|
|
195
|
-
// Move to done file
|
|
254
|
+
// Move the whole subtree to the done file (hierarchy preserved).
|
|
196
255
|
const dp = donePath(ctx.cwd)
|
|
197
256
|
const doneItems = parse(dp)
|
|
198
|
-
doneItems.push(
|
|
257
|
+
doneItems.push(archiveSubtree(item))
|
|
199
258
|
write(dp, doneItems)
|
|
200
|
-
// Remove from tree
|
|
259
|
+
// Remove the subtree from the tree.
|
|
201
260
|
const found = findById(items, item.id)
|
|
202
261
|
if (found) found.parent.splice(found.idx, 1)
|
|
203
262
|
}
|
|
204
263
|
write(cp, items)
|
|
205
|
-
return `Marked
|
|
264
|
+
return `Marked ${item.id} ${old} → ${status}`
|
|
206
265
|
}
|
|
207
266
|
case "list": {
|
|
208
267
|
const items = parse(checklistPath(ctx.cwd))
|
package/src/tools/file.mjs
CHANGED
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
resolveInCwd,
|
|
8
8
|
resolveExternal,
|
|
9
9
|
normalizeEOL,
|
|
10
|
+
detectFileEol,
|
|
11
|
+
joinWithEol,
|
|
12
|
+
majorityEol,
|
|
13
|
+
findCandidates,
|
|
14
|
+
FFFD_WARNING,
|
|
10
15
|
} from "./shared.mjs";
|
|
11
16
|
import { specForModel } from "../config.mjs";
|
|
12
17
|
import { createHash } from "node:crypto";
|
|
@@ -160,7 +165,12 @@ export const writeTool = {
|
|
|
160
165
|
await mkdir(dirname(abs), { recursive: true })
|
|
161
166
|
const st = await stat(abs).catch(() => null)
|
|
162
167
|
if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
|
|
163
|
-
|
|
168
|
+
// EOL semantics: overwriting an existing file restores ITS original EOL style (F1);
|
|
169
|
+
// a new file follows the directory's majority style, defaulting to LF (F2).
|
|
170
|
+
const prev = st ? await readFile(abs, "utf8").catch(() => null) : null
|
|
171
|
+
const eol = prev != null ? detectFileEol(prev) : majorityEol(dirname(abs))
|
|
172
|
+
const content = eol === "\r\n" ? normalizeEOL(args.content).replace(/\n/g, "\r\n") : args.content
|
|
173
|
+
await writeFile(abs, content, "utf8")
|
|
164
174
|
markDirty(abs)
|
|
165
175
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
166
176
|
return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
@@ -189,15 +199,28 @@ export const editTool = {
|
|
|
189
199
|
if (!args.old_string) {
|
|
190
200
|
throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
|
|
191
201
|
}
|
|
192
|
-
const
|
|
202
|
+
const raw = await readFile(abs, "utf8")
|
|
203
|
+
const content = normalizeEOL(raw)
|
|
193
204
|
const occurrences = content.split(args.old_string).length - 1
|
|
194
205
|
if (occurrences === 0) {
|
|
195
206
|
// Give clues to help the model locate: first-line preview + common causes
|
|
196
207
|
const preview = args.old_string.slice(0, 100).split("\n")[0]
|
|
208
|
+
// Similarity candidates (LCS, line-level, top 3, score ≥ 0.5) — turns the
|
|
209
|
+
// "not found" black box into a pointer at the most likely intended line.
|
|
210
|
+
// Multi-line old_string: only its first line is scored (marked accordingly).
|
|
211
|
+
const cands = findCandidates(content.split("\n"), args.old_string)
|
|
212
|
+
let candText = ""
|
|
213
|
+
if (cands.length > 0) {
|
|
214
|
+
const header = args.old_string.includes("\n")
|
|
215
|
+
? ` similar lines (old_string line 1: "${args.old_string.split("\n")[0].slice(0, 80)}"):`
|
|
216
|
+
: " similar lines:"
|
|
217
|
+
candText = "\n" + header + "\n" + cands.map((c) => ` L${c.line}: ${c.preview} (${Math.round(c.score * 100)}%)`).join("\n")
|
|
218
|
+
}
|
|
197
219
|
throw new Error(
|
|
198
220
|
`old_string not found in ${args.path}\n` +
|
|
199
221
|
` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
|
|
200
|
-
` hints: whitespace mismatch? file already changed? try reading the file first`
|
|
222
|
+
` hints: whitespace mismatch? file already changed? try reading the file first` +
|
|
223
|
+
candText
|
|
201
224
|
)
|
|
202
225
|
}
|
|
203
226
|
if (occurrences > 1 && !args.replace_all) {
|
|
@@ -207,7 +230,11 @@ export const editTool = {
|
|
|
207
230
|
? content.split(args.old_string).join(args.new_string)
|
|
208
231
|
// Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
|
|
209
232
|
: content.replace(args.old_string, () => args.new_string)
|
|
210
|
-
|
|
233
|
+
// Write back in the file's ORIGINAL EOL style (first-newline rule) — a CRLF
|
|
234
|
+
// file must not come back as LF (that rewrites every line in the diff).
|
|
235
|
+
// normalizeEOL first: new_string may carry \r\n (e.g. pasted from a raw CRLF
|
|
236
|
+
// read); without normalizing, split leaves stray \r and CRLF join makes \r\r\n.
|
|
237
|
+
await writeFile(abs, joinWithEol(normalizeEOL(updated).split("\n"), raw), "utf8")
|
|
211
238
|
markDirty(abs)
|
|
212
239
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
213
240
|
return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
@@ -244,7 +271,8 @@ export const insertAfterTool = {
|
|
|
244
271
|
`Read the file again (read tool) to refresh line numbers, then retry insert_after.`
|
|
245
272
|
)
|
|
246
273
|
}
|
|
247
|
-
const
|
|
274
|
+
const raw = await readFile(abs, "utf8") // original bytes — EOL detection needs the file's real line endings
|
|
275
|
+
const text = normalizeEOL(raw)
|
|
248
276
|
const lines = text.split("\n")
|
|
249
277
|
|
|
250
278
|
let targetLine
|
|
@@ -274,8 +302,10 @@ export const insertAfterTool = {
|
|
|
274
302
|
throw new Error("Either after_line or after_regex is required")
|
|
275
303
|
}
|
|
276
304
|
|
|
277
|
-
lines.splice(targetLine, 0, args.content)
|
|
278
|
-
|
|
305
|
+
lines.splice(targetLine, 0, normalizeEOL(args.content))
|
|
306
|
+
// Write back in the file's ORIGINAL EOL style (review R9#2: same bug class as
|
|
307
|
+
// edit — a CRLF file must not silently become LF here either).
|
|
308
|
+
const updated = joinWithEol(lines, raw)
|
|
279
309
|
await writeFile(abs, updated, "utf8")
|
|
280
310
|
markDirty(abs)
|
|
281
311
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
@@ -310,7 +340,11 @@ export const hashlineEditTool = {
|
|
|
310
340
|
async execute(args, ctx) {
|
|
311
341
|
const abs = resolveInCwd(ctx, args.path)
|
|
312
342
|
if (!args.old_hashes?.length) throw new Error("old_hashes must not be empty — read the file with hashes=true to get line hashes")
|
|
313
|
-
const
|
|
343
|
+
const raw = await readFile(abs, "utf8")
|
|
344
|
+
const content = normalizeEOL(raw)
|
|
345
|
+
// Encoding-corruption probe: U+FFFD means the file is not clean UTF-8 — hash
|
|
346
|
+
// addressing may be unreliable. Warn (never block).
|
|
347
|
+
const corrupted = content.includes("\uFFFD")
|
|
314
348
|
const lines = content.split("\n")
|
|
315
349
|
const fileHashes = lines.map((l) => hashLine(l))
|
|
316
350
|
const target = args.old_hashes
|
|
@@ -334,7 +368,8 @@ export const hashlineEditTool = {
|
|
|
334
368
|
const preview = target.join(" ")
|
|
335
369
|
throw new Error(
|
|
336
370
|
`Hash sequence not found in ${args.path}: ${preview}\n` +
|
|
337
|
-
`The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}`
|
|
371
|
+
`The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}` +
|
|
372
|
+
(corrupted ? `\n${FFFD_WARNING}` : "")
|
|
338
373
|
)
|
|
339
374
|
}
|
|
340
375
|
|
|
@@ -359,13 +394,14 @@ export const hashlineEditTool = {
|
|
|
359
394
|
|
|
360
395
|
const pos = matches[0]
|
|
361
396
|
// Replace: remove old lines, insert new lines at the same position
|
|
362
|
-
const newLines = args.new_content.split("\n")
|
|
397
|
+
const newLines = normalizeEOL(args.new_content).split("\n") // normalize: CRLF in new_content would join into \r\r\n
|
|
363
398
|
lines.splice(pos, target.length, ...newLines)
|
|
364
|
-
|
|
399
|
+
// Write back in the file's original EOL style (same rule as edit / apply_patch).
|
|
400
|
+
const updated = joinWithEol(lines, raw)
|
|
365
401
|
await writeFile(abs, updated, "utf8")
|
|
366
402
|
markDirty(abs)
|
|
367
403
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
368
|
-
return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
404
|
+
return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}${corrupted ? `\n${FFFD_WARNING}` : ""}`
|
|
369
405
|
},
|
|
370
406
|
}
|
|
371
407
|
|
package/src/tools/patch.mjs
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DESC,
|
|
3
3
|
autoSyntaxCheck,
|
|
4
|
-
resolveInCwd
|
|
4
|
+
resolveInCwd,
|
|
5
|
+
normalizeEOL,
|
|
6
|
+
detectFileEol,
|
|
7
|
+
majorityEol
|
|
5
8
|
} from "./shared.mjs";
|
|
6
9
|
import { markDirty } from "./file.mjs";
|
|
7
10
|
import { execFileSync } from "node:child_process";
|
|
8
|
-
import { mkdir } from "node:fs/promises";
|
|
9
|
-
import { readFile } from "node:fs/promises";
|
|
10
|
-
import { stat, lstat } from "node:fs/promises";
|
|
11
|
-
import { writeFile } from "node:fs/promises";
|
|
12
|
-
import { unlink } from "node:fs/promises";
|
|
11
|
+
import { mkdir, readFile, lstat, writeFile, unlink } from "node:fs/promises";
|
|
13
12
|
import { existsSync } from "node:fs";
|
|
14
|
-
import {
|
|
13
|
+
import { relative, dirname } from "node:path";
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* Parse a unified diff: returns [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
|
|
@@ -121,18 +120,22 @@ export const applyPatchTool = {
|
|
|
121
120
|
const abs = resolveInCwd(ctx, f.path)
|
|
122
121
|
if (f.isNew) {
|
|
123
122
|
if (existsSync(abs)) throw new Error(`Cannot create ${f.path}: file already exists`)
|
|
124
|
-
|
|
123
|
+
// New file: follow the directory's majority EOL style (default LF).
|
|
124
|
+
const eol = majorityEol(dirname(abs))
|
|
125
|
+
const content = f.hunks.flatMap((h) => h.ops.filter((o) => o.type === "+").map((o) => o.text)).join(eol) + eol
|
|
125
126
|
planned.push({ abs, path: f.path, content, isNew: true })
|
|
126
127
|
} else {
|
|
127
128
|
const original = await readFile(abs, "utf8").catch(() => { throw new Error(`File not found: ${f.path}`) })
|
|
128
|
-
const eol = original
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
const eol = detectFileEol(original)
|
|
130
|
+
// Apply hunks in the normalized LF domain, then write back joined with the
|
|
131
|
+
// file's ORIGINAL EOL style — join("\n") here used to rewrite CRLF files as LF.
|
|
132
|
+
const lines = normalizeEOL(original).split("\n")
|
|
133
|
+
applyHunks(lines, f.hunks, "\n", f.path)
|
|
134
|
+
planned.push({ abs, path: f.path, content: lines.join(eol), isNew: false })
|
|
132
135
|
}
|
|
133
136
|
}
|
|
134
137
|
// Multi-file write: write all to .tmp first, rename only after all succeed — failure cleans up written .tmp without affecting committed files
|
|
135
|
-
const { rename
|
|
138
|
+
const { rename } = await import("node:fs/promises") // unlink already statically imported
|
|
136
139
|
const written = []
|
|
137
140
|
try {
|
|
138
141
|
for (const p of planned) {
|
package/src/tools/shared.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { spawn, execFileSync, execFile } from "node:child_process"
|
|
7
|
-
import { readFileSync, existsSync, realpathSync } from "node:fs"
|
|
7
|
+
import { readFileSync, existsSync, realpathSync, readdirSync, statSync, openSync, readSync, closeSync } from "node:fs"
|
|
8
8
|
import { dirname, join, resolve, relative, isAbsolute, sep } from "node:path"
|
|
9
9
|
import { fileURLToPath } from "node:url"
|
|
10
10
|
|
|
@@ -55,6 +55,106 @@ export function normalizeEOL(text) {
|
|
|
55
55
|
return text.replace(/\r\n/g, "\n")
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** Detect a file's EOL style by the type of its FIRST newline: "\r\n" first →
|
|
59
|
+
* the whole file is written back as CRLF; a bare "\n" or no newline → LF.
|
|
60
|
+
* Never counts occurrences (mixed files follow the first line's style). */
|
|
61
|
+
export function detectFileEol(text) {
|
|
62
|
+
const i = text.indexOf("\n")
|
|
63
|
+
return i > 0 && text[i - 1] === "\r" ? "\r\n" : "\n"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Join lines with the EOL style detected from the original text (write-back restore). */
|
|
67
|
+
export function joinWithEol(lines, originalText) {
|
|
68
|
+
return lines.join(detectFileEol(originalText))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const MAJORITY_EOL_MAX_FILES = 20
|
|
72
|
+
const EOL_SNIFF_BYTES = 4096
|
|
73
|
+
|
|
74
|
+
/** Majority EOL style of a directory's existing files (≤20 files, first 4KB each).
|
|
75
|
+
* New files follow the directory's majority style; empty dir / tie / LF majority → "\n". */
|
|
76
|
+
export function majorityEol(dirPath) {
|
|
77
|
+
let names
|
|
78
|
+
try { names = readdirSync(dirPath) } catch { return "\n" }
|
|
79
|
+
let crlf = 0, lf = 0
|
|
80
|
+
for (const name of names) {
|
|
81
|
+
if (crlf + lf >= MAJORITY_EOL_MAX_FILES) break
|
|
82
|
+
try {
|
|
83
|
+
const p = join(dirPath, name)
|
|
84
|
+
if (!statSync(p).isFile()) continue
|
|
85
|
+
const fd = openSync(p, "r")
|
|
86
|
+
let head = ""
|
|
87
|
+
try {
|
|
88
|
+
const buf = Buffer.alloc(EOL_SNIFF_BYTES)
|
|
89
|
+
const n = readSync(fd, buf, 0, EOL_SNIFF_BYTES, 0)
|
|
90
|
+
head = buf.subarray(0, n).toString("utf8")
|
|
91
|
+
} finally {
|
|
92
|
+
closeSync(fd)
|
|
93
|
+
}
|
|
94
|
+
if (detectFileEol(head) === "\r\n") crlf++
|
|
95
|
+
else lf++
|
|
96
|
+
} catch { /* unreadable entry — skip */ }
|
|
97
|
+
}
|
|
98
|
+
return crlf > lf ? "\r\n" : "\n"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const CANDIDATE_MAX_LEN = 500
|
|
102
|
+
const CANDIDATE_PREVIEW_LEN = 80
|
|
103
|
+
|
|
104
|
+
/** Longest-common-substring length (rolling-row DP). Inputs are pre-truncated by the caller. */
|
|
105
|
+
function lcsLength(a, b) {
|
|
106
|
+
// Reused DP buffers (review R9#6): per-line allocation caused GC pressure on
|
|
107
|
+
// large files — hoist two module-level rows, grow to fit, swap by index.
|
|
108
|
+
const need = b.length + 1
|
|
109
|
+
if (_lcsBuf0.length < need) {
|
|
110
|
+
const size = Math.max(need, _lcsBuf0.length * 2)
|
|
111
|
+
_lcsBuf0 = new Uint16Array(size)
|
|
112
|
+
_lcsBuf1 = new Uint16Array(size)
|
|
113
|
+
}
|
|
114
|
+
let prev = _lcsBuf0, cur = _lcsBuf1
|
|
115
|
+
prev.fill(0, 0, need)
|
|
116
|
+
let best = 0
|
|
117
|
+
for (let i = 1; i <= a.length; i++) {
|
|
118
|
+
cur[0] = 0
|
|
119
|
+
const ca = a.charCodeAt(i - 1)
|
|
120
|
+
for (let j = 1; j < need; j++) {
|
|
121
|
+
if (ca === b.charCodeAt(j - 1)) {
|
|
122
|
+
const v = prev[j - 1] + 1
|
|
123
|
+
cur[j] = v
|
|
124
|
+
if (v > best) best = v
|
|
125
|
+
} else cur[j] = 0 // must reset — buffer is reused
|
|
126
|
+
}
|
|
127
|
+
const t = prev; prev = cur; cur = t
|
|
128
|
+
}
|
|
129
|
+
return best
|
|
130
|
+
}
|
|
131
|
+
let _lcsBuf0 = new Uint16Array(0), _lcsBuf1 = new Uint16Array(0)
|
|
132
|
+
/** Line-level similarity candidates for a failed edit: score = LCS(oldString, line) / max(len).
|
|
133
|
+
* Multi-line old_string matches on its FIRST line only (failures usually diverge there).
|
|
134
|
+
* Both sides are truncated to 500 chars before scoring so minified files can't blow the budget.
|
|
135
|
+
* Returns up to topN [{ line (1-based), preview, score }] with score >= threshold, best first. */
|
|
136
|
+
export function findCandidates(lines, oldString, topN = 3, threshold = 0.5) {
|
|
137
|
+
const needle = oldString.split("\n")[0].slice(0, CANDIDATE_MAX_LEN)
|
|
138
|
+
if (!needle) return []
|
|
139
|
+
const scored = []
|
|
140
|
+
for (let i = 0; i < lines.length; i++) {
|
|
141
|
+
const raw = lines[i]
|
|
142
|
+
if (!raw) continue
|
|
143
|
+
const line = raw.length > CANDIDATE_MAX_LEN ? raw.slice(0, CANDIDATE_MAX_LEN) : raw
|
|
144
|
+
const longer = Math.max(needle.length, line.length)
|
|
145
|
+
const shorter = Math.min(needle.length, line.length)
|
|
146
|
+
// LCS ≤ shorter side — a length ratio below the threshold can never reach it; skip the DP.
|
|
147
|
+
if (shorter / longer < threshold) continue
|
|
148
|
+
const score = lcsLength(needle, line) / longer
|
|
149
|
+
if (score >= threshold) scored.push({ line: i + 1, preview: raw.slice(0, CANDIDATE_PREVIEW_LEN), score })
|
|
150
|
+
}
|
|
151
|
+
scored.sort((a, b) => b.score - a.score || a.line - b.line)
|
|
152
|
+
return scored.slice(0, topN)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Appended to hashline_edit results when the file contains U+FFFD (encoding-corruption probe). */
|
|
156
|
+
export const FFFD_WARNING = "⚠ file contains U+FFFD (replacement char) — encoding may be corrupted; hash-based addressing may be unreliable. Consider fixing the file encoding first."
|
|
157
|
+
|
|
58
158
|
/** Convert to OpenAI tools parameter format */
|
|
59
159
|
export function toOpenAISchema(tool) {
|
|
60
160
|
return {
|
package/src/tui/cmd-eng.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"
|
|
|
7
7
|
import { ansi, C } from "./ansi.mjs"
|
|
8
8
|
|
|
9
9
|
const templateDir = join(fileURLToPath(import.meta.url), "..", "..", "prompts")
|
|
10
|
+
import { ENG_OFF_REMINDER } from "../agent.mjs"
|
|
10
11
|
|
|
11
12
|
export async function handleEngCommand(ctx) {
|
|
12
13
|
const { agent, pushLine, pushLabel, persistRaw, showPicker } = ctx
|
|
@@ -31,7 +32,14 @@ export async function handleEngCommand(ctx) {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
agent.config.agent.engineering = !agent.config.agent.engineering
|
|
34
|
-
if (!agent.config.agent.engineering)
|
|
35
|
+
if (!agent.config.agent.engineering) {
|
|
36
|
+
agent._engDesignToken = null // invalidate stale token
|
|
37
|
+
// OFF must reach the model too (2026-08-25): /auto pushes a reminder on toggle — the
|
|
38
|
+
// mode flip is invisible to the agent otherwise. (ON needs none here: the injector
|
|
39
|
+
// in agent.mjs already announces ON transitions on the next turn.)
|
|
40
|
+
agent._pendingReminders = agent._pendingReminders ?? []
|
|
41
|
+
agent._pendingReminders.push(ENG_OFF_REMINDER)
|
|
42
|
+
}
|
|
35
43
|
await persistRaw((raw) => {
|
|
36
44
|
raw.agent ??= {}
|
|
37
45
|
raw.agent.engineering = agent.config.agent.engineering
|