thincoder 0.12.45 → 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 CHANGED
@@ -2,6 +2,13 @@
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
+
5
12
  ## [0.12.45] — 2026-08-26
6
13
 
7
14
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.45",
3
+ "version": "0.12.46",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -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).
@@ -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
- // Extract explicit ID if present (e.g. "T1:", "T1.1:") strip it from the text
35
- // so write() doesn't re-prepend it (round-trip would otherwise accumulate "T1: T1: ...")
36
- const idMatch = text.match(/^(T[\d.]+):\s*/)
37
- const node = {
38
- id: idMatch ? idMatch[1] : null,
39
- index: flatIdx,
40
- depth,
41
- status,
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
- // Auto-assign ID if not explicit
51
- if (!node.id) {
52
- const siblingCount = parent.children.length
53
- const base = parent.id ? `${parent.id}` : "T"
54
- if (parent.id) {
55
- node.id = `${base}.${siblingCount}`
56
- } else {
57
- // Root level: T1, T2, T3...
58
- let rootIdx = 0
59
- for (const c of items) {
60
- if (c.id?.match(/^T\d+$/)) rootIdx = Math.max(rootIdx, parseInt(c.id.slice(1)))
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 (required for mark)"
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
- // Auto-assign ID
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
- const flat = flatten(items)
189
- if (args.index < 1 || args.index > flat.length) return `Error: index ${args.index} out of range (1-${flat.length})`
190
- const item = flat[args.index - 1]
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({ id: item.id, index: 0, depth: 0, status: "done", text: item.text, children: [] })
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 #${args.index} ${old} → ${status}: ${item.id}: ${item.text}`
264
+ return `Marked ${item.id} ${old} → ${status}`
206
265
  }
207
266
  case "list": {
208
267
  const items = parse(checklistPath(ctx.cwd))