@thincoder/core 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/advisor/citations.mjs +40 -4
  3. package/advisor/compaction.mjs +21 -2
  4. package/advisor/convergence.mjs +1 -1
  5. package/advisor/history.mjs +8 -6
  6. package/advisor/loop.mjs +29 -27
  7. package/advisor/messages.mjs +5 -4
  8. package/advisor/run.mjs +5 -1
  9. package/advisor/timeline.mjs +26 -0
  10. package/advisor.mjs +4 -2
  11. package/agent/completion.mjs +3 -1
  12. package/agent/digest-account.mjs +188 -0
  13. package/agent/dispatch-run.mjs +29 -12
  14. package/agent/family-tools.mjs +11 -11
  15. package/agent/helpers.mjs +8 -0
  16. package/agent/record-results.mjs +10 -1
  17. package/agent/run-stages.mjs +27 -4
  18. package/agent/run-start.mjs +49 -3
  19. package/agent/spawn-child.mjs +2 -1
  20. package/agent/subagent-event.mjs +71 -0
  21. package/agent/suspension.mjs +60 -12
  22. package/agent/turn-loop.mjs +31 -2
  23. package/agent/write-gate.mjs +13 -25
  24. package/agent-tools/advisor-async.mjs +25 -7
  25. package/agent-tools/advisor-settle.mjs +7 -9
  26. package/agent-tools/advisor.mjs +34 -33
  27. package/agent-tools/async-settle.mjs +29 -5
  28. package/agent-tools/batch-lifecycle.mjs +29 -7
  29. package/agent-tools/batch-paths.mjs +22 -2
  30. package/agent-tools/batch-read.mjs +36 -0
  31. package/agent-tools/batch-skeleton.mjs +59 -33
  32. package/agent-tools/batch.mjs +16 -6
  33. package/agent-tools/design-token.mjs +76 -16
  34. package/agent-tools/escalate-async.mjs +4 -2
  35. package/agent-tools/review-facts.mjs +143 -16
  36. package/agent-tools/subagent-actions-query.mjs +13 -6
  37. package/agent-tools/subagent-async.mjs +16 -4
  38. package/agent-tools/subagent-panel.mjs +121 -37
  39. package/agent-tools/subagent-spawn.mjs +1 -1
  40. package/agent-tools/subagent.mjs +5 -3
  41. package/agent-tools/verify.mjs +5 -0
  42. package/agent-tools/zero-write-watch.mjs +77 -0
  43. package/agent.mjs +1 -1
  44. package/auto-think.mjs +26 -2
  45. package/config-presets.mjs +8 -0
  46. package/config.mjs +30 -0
  47. package/conventions.mjs +26 -6
  48. package/declaration.mjs +26 -2
  49. package/embedding.mjs +35 -3
  50. package/escape.mjs +1 -1
  51. package/i18n.mjs +5 -0
  52. package/ledger-cmd.mjs +13 -21
  53. package/ledger-db.mjs +34 -1
  54. package/ledger-migrate.mjs +4 -3
  55. package/ledger-read.mjs +94 -0
  56. package/ledger-surface.mjs +15 -46
  57. package/ledger-tools.mjs +148 -85
  58. package/ledger-variant-notice.mjs +47 -0
  59. package/ledger.mjs +38 -64
  60. package/manifest-schema.mjs +19 -7
  61. package/manifest.mjs +32 -11
  62. package/mcp/transport-http.mjs +52 -4
  63. package/mcp/transport-stdio.mjs +33 -8
  64. package/mcp.mjs +13 -5
  65. package/memory/code-search.mjs +5 -3
  66. package/memory/core.mjs +6 -4
  67. package/memory/docs.mjs +5 -3
  68. package/memory/schema.mjs +2 -0
  69. package/package.json +1 -1
  70. package/prompts/advisor-design.md +3 -2
  71. package/prompts/advisor-round1.md +1 -0
  72. package/prompts/advisor-round2.md +8 -3
  73. package/prompts/advisor-round3.md +7 -2
  74. package/prompts/common.md +2 -2
  75. package/prompts/discipline-normal.md +2 -1
  76. package/prompts/persona-engineering.md +7 -4
  77. package/provider/anthropic.mjs +14 -2
  78. package/provider/core.mjs +10 -4
  79. package/provider/errors.mjs +23 -0
  80. package/provider/google.mjs +31 -4
  81. package/provider/normalize.mjs +57 -0
  82. package/provider/responses-request.mjs +3 -1
  83. package/provider/responses.mjs +38 -4
  84. package/provider/sse.mjs +45 -13
  85. package/proxy.mjs +13 -3
  86. package/session.mjs +13 -2
  87. package/shell-identity.mjs +107 -0
  88. package/stream-destroy.mjs +62 -0
  89. package/tool-docs/advisor.md +1 -1
  90. package/tool-docs/bash.md +1 -1
  91. package/tool-docs/batch.md +1 -1
  92. package/tool-docs/delete.md +7 -5
  93. package/tool-docs/ledger.md +6 -0
  94. package/tool-docs/subagent.md +10 -0
  95. package/tool-docs/verify.md +1 -1
  96. package/tools/bash.mjs +9 -4
  97. package/tools/edit-batch.mjs +7 -2
  98. package/tools/index.mjs +1 -4
  99. package/tools/patch.mjs +8 -2
  100. package/tools/write-path.mjs +6 -4
  101. package/undo-stack.mjs +23 -9
  102. package/tool-docs/ledger_add.md +0 -1
  103. package/tool-docs/ledger_close.md +0 -1
  104. package/tool-docs/ledger_count.md +0 -1
  105. package/tool-docs/ledger_query.md +0 -1
  106. package/tool-docs/ledger_update.md +0 -1
@@ -9,6 +9,11 @@
9
9
  * computePanelBlocks(ctx.state)(ctx.state = agent._tuiState——TUI 装配处接线——单账本);
10
10
  * 未挂载(headless/VSC/子代理)→ 现算返 null → 降级/报不可用照旧
11
11
  * (T-P5 语义零变)。门控语义零动(F-4)——仅数据源从镜像换现算。
12
+ * **子代理面板批(2026-10-04 · `docs/desktop/design/PANEL-READBACK.md`)——回读源链**:数据源不再单源现算——
13
+ * `readPanelSource(ctx)` = `ctx.readout()`(桌面渲染面实况快照——上报缓存读面)优先 ⇒ `ctx.state` 现算 ⇒
14
+ * 二者皆空报不可用;视图面快照非空 ⇒ `{ source:"renderer", asOf, ageMs, panel[] }`(awaitingDigest 条读时
15
+ * 交叉核池/pending 标 `digested`——与 CLI 面同判据);freeze 门控两源同判(桌面块 `awaitingDigest`/`region` 为
16
+ * 独立字段),键统一规范化(`sub:` 前缀剥除——**有意放宽且两端同宽**;发射键 = 规范化键,relay 文法不吃 `:`)。
12
17
  * subagent-actions.mjs 尾部 re-export executePanelAction 保 subagent.mjs 既有 import 面。
13
18
  */
14
19
  import { getAsyncPool } from "./async-settle.mjs"
@@ -41,67 +46,129 @@ function consultChildUndigested(agent, key) {
41
46
  return false
42
47
  }
43
48
 
49
+ /**
50
+ * 回读源链(子代理面板批 · 2026-10-04 · `PANEL-READBACK.md` §2.1/§2.3):渲染面快照
51
+ * (`ctx.readout()`——桌面实况回读上报;`{ blocks, receivedAt }`,块形 = 判别六键 + `role`/`id` 随行)优先
52
+ * ∥ CLI 现算(`computePanelBlocks(ctx.state)`——F-3 单账本)∥ 二者皆空 ⇒ `null`(拒因照旧)。
53
+ * 返回 `{ blocks, readout }`:`readout` 非空 = 桌面快照源(`awaitingDigest` / `region` 为块独立字段)。
54
+ */
55
+ function readPanelSource(ctx) {
56
+ const snapshot = typeof ctx.readout === "function" ? ctx.readout() : null
57
+ if (snapshot && Array.isArray(snapshot.blocks)) return { blocks: snapshot.blocks, readout: snapshot }
58
+ const snap = computePanelBlocks(ctx.state)
59
+ return Array.isArray(snap) ? { blocks: snap, readout: null } : null
60
+ }
61
+
62
+ /** 键归一(**有意放宽且两端同宽**——§2.3):`sub:` 前缀剥除后比对(桌面块键 `sub:role#id` ∥ CLI 块键
63
+ * `role#id` 两写法皆接受);命中后比对与发射全用规范化键(relay 前缀文法 `^\w-+#\d+/` 不吃 `:`)。 */
64
+ function normalizePanelKey(key) {
65
+ const k = String(key ?? "")
66
+ return k.startsWith("sub:") ? k.slice("sub:".length) : k
67
+ }
68
+
69
+ /** 两源同判——等待消化:CLI 块 = 现算 status 位;桌面块 = 判别键 `awaitingDigest`。 */
70
+ function awaitingOf(src, block) {
71
+ return src.readout ? block.awaitingDigest === true : block.status === "awaitingDigest"
72
+ }
73
+
74
+ /** 活态标签(live 列表 ∥ 拒因文案共用):桌面块把 awaiting 态折进 status 位(与 CLI 现算同形)。 */
75
+ function liveStatusOf(src, block) {
76
+ return awaitingOf(src, block) ? "awaitingDigest" : String(block.status ?? "unknown")
77
+ }
78
+
79
+ /** 桌面回读条目(视图面——判别六键 + `role`/`id` 随行;awaitingDigest 条读时交叉核池/pending 标 `digested`
80
+ * —— 判据与 CLI 面同源;consult 子块按消费判据——键经归一后入判)。 */
81
+ function readoutPanelEntry(agent, block) {
82
+ const key = normalizePanelKey(block.key)
83
+ const out = {
84
+ key: block.key,
85
+ role: block.role ?? null,
86
+ id: block.id ?? null,
87
+ status: block.status ?? null,
88
+ frozen: block.frozen === true,
89
+ awaitingDigest: block.awaitingDigest === true,
90
+ region: block.region === "flow" ? "flow" : "activity",
91
+ dom: block.dom === true,
92
+ }
93
+ if (out.awaitingDigest) {
94
+ out.digested = consultChildKey(key)
95
+ ? !consultChildUndigested(agent, key)
96
+ : !blockKeyIn(agent._pendingAsyncResults, key)
97
+ && !blockKeyIn(getAsyncPool(agent, "subagent"), key) && !blockKeyIn(getAsyncPool(agent, "advisor"), key)
98
+ }
99
+ return out
100
+ }
101
+
44
102
  /**
45
103
  * AGENT-LOOP-SUBAGENT.md §6.7.2 D-P3 冻结门控(安全):仅允许冻结 awaitingDigest 且池(_asyncSubagents/
46
104
  * _asyncAdvisors——经 getAsyncPool accessor——D1)无对应运行条目 + pending 单容器
47
105
  * (_pendingAsyncResults——D2)无对应条目的块(= 已消化驻留块——报告已入模型上下文
48
106
  * ——pending 已消费——状态滞后——补发冻结不破坏任何顺序)。
107
+ * - 数据源 = **回读源链**(子代理面板批 · 2026-10-04):`ctx.readout`(桌面渲染面快照——块判别键
108
+ * `awaitingDigest`/`region` 独立字段)优先 ∥ `ctx.state` 现算;命中后比对与发射全用**规范化键**
109
+ * (`sub:` 前缀剥除——有意放宽且两端同宽,§2.3)。
49
110
  * - pending 仍有对应(报告未达模型)→ 拒绝(提前回收破坏消化顺序——T-P3)
50
111
  * - 不存在的 key / 仍 running / done 的块 → 拒绝(T-P4——running 块 settle 时自冻)
51
- * - 无面板(现算返 null)→ 拒绝(headless/VS Code——freeze 不可用——T-P5)
112
+ * - 无面板(两源皆空)→ 拒绝(headless/VS Code——freeze 不可用——T-P5)
113
+ * - 已入流(桌面快照 `region:"flow"`——已归档)→ 追加拒因(无卡可收;CLI 源无该字段 ⇒ 零改)
52
114
  * - consult 子块(#748):会话在跑 ∥ 会话条目在 pending(`childIds` 携该号)→ 拒绝(同判据——与回收 ∥ 补发同源)
53
- * 错误信息明确(模型可解释 + 自助修正)。返回 { ok:true } 或 { err }。
115
+ * 错误信息明确(模型可解释 + 自助修正)。返回 `{ ok:true, renderer }` 或 `{ err }`。
54
116
  */
55
117
  function panelFreezeGate(ctx, key) {
56
- // F-3(2026-09-10):镜像退役——经 ctx.state 读时现算(CLI-ACTIVITY-DEBLOAT)。
57
- // 门控语义零动(F-4):awaitingDigest 限定/池归属查/pending 查逐字保留——仅数据源换现算。
58
- const snap = computePanelBlocks(ctx.state)
59
- if (!Array.isArray(snap)) {
118
+ const src = readPanelSource(ctx)
119
+ if (src === null) {
60
120
  return { err: "panel unavailable — no CLI TUI panel in this session (headless / VS Code / subagent contexts — freeze unavailable; panel is CLI-TUI-only, AC-P4)" }
61
121
  }
62
- const block = snap.find((b) => b.key === key)
122
+ const want = normalizePanelKey(key)
123
+ const block = src.blocks.find((b) => normalizePanelKey(b.key) === want)
63
124
  if (!block) {
64
- const live = snap.map((b) => `${b.key}(${b.status})`).join(", ")
125
+ const live = src.blocks.map((b) => `${b.key}(${liveStatusOf(src, b)})`).join(", ")
65
126
  return { err: `unknown panel block key: ${key} — the live panel holds: ${live || "(no blocks)"}` }
66
127
  }
67
- if (block.status !== "awaitingDigest") {
68
- if (block.status === "done") {
69
- return { err: `block ${key} is already done — nothing to freeze; it was (or is about to be) reclaimed into the conversation by the freeze sweep at the turn end / settle (only digested-stuck awaitingDigest blocks need a manual freeze)` }
128
+ // 已入流 = 已归档(桌面快照判别键)——无卡可收(CLI 源无 region 字段 ⇒ 零改)。
129
+ if (src.readout && block.region === "flow") {
130
+ return { err: `block ${want} is already in the conversation flow (region "flow") — it has been archived; there is nothing left to freeze` }
131
+ }
132
+ if (!awaitingOf(src, block)) {
133
+ const status = String(block.status ?? "unknown")
134
+ if (status === "done") {
135
+ return { err: `block ${want} is already done — nothing to freeze; it was (or is about to be) reclaimed into the conversation by the freeze sweep at the turn end / settle (only digested-stuck awaitingDigest blocks need a manual freeze)` }
70
136
  }
71
- if (block.status === "running") {
72
- return { err: `block ${key} is still running — freeze only reclaims awaitingDigest blocks whose report is already digested; a running block freezes on its own settle (or stop it with action:'cancel' if it is a background async child)` }
137
+ if (status === "running") {
138
+ return { err: `block ${want} is still running — freeze only reclaims awaitingDigest blocks whose report is already digested; a running block freezes on its own settle (or stop it with action:'cancel' if it is a background async child)` }
73
139
  }
74
- return { err: `block ${key} is in state ${block.status} — freeze only reclaims awaitingDigest blocks whose report is already digested` }
140
+ return { err: `block ${want} is in state ${status} — freeze only reclaims awaitingDigest blocks whose report is already digested` }
75
141
  }
76
- if (blockKeyIn(getAsyncPool(ctx.agent, "subagent"), key) || blockKeyIn(getAsyncPool(ctx.agent, "advisor"), key)) {
77
- return { err: `block ${key} still has a live pool entry — it is NOT a digested-stuck block (freeze refused; status action shows the pool)` }
142
+ if (blockKeyIn(getAsyncPool(ctx.agent, "subagent"), want) || blockKeyIn(getAsyncPool(ctx.agent, "advisor"), want)) {
143
+ return { err: `block ${want} still has a live pool entry — it is NOT a digested-stuck block (freeze refused; status action shows the pool)` }
78
144
  }
79
145
  // ASYNC-RESULT-CONTAINER.md D2:pending 单容器(四族统一——原 escalate/consult
80
146
  // 独立族退役)。#748:consult 子块键不经裸键比对(会话条目键 `consult#<sessionId>` 与
81
147
  // 子块键可数字撞键)——改判消费判据(会话在跑 ∥ 会话条目在 pending——与回收 ∥ 补发同源)。
82
- if (!consultChildKey(key) && blockKeyIn(ctx.agent._pendingAsyncResults, key)) {
83
- return { err: `block ${key} is still genuinely awaiting digestion — its report is still pending and has NOT reached the model yet; freezing now would break the digestion order (wait for the digest run, which reclaims it automatically)` }
148
+ if (!consultChildKey(want) && blockKeyIn(ctx.agent._pendingAsyncResults, want)) {
149
+ return { err: `block ${want} is still genuinely awaiting digestion — its report is still pending and has NOT reached the model yet; freezing now would break the digestion order (wait for the digest run, which reclaims it automatically)` }
84
150
  }
85
- if (consultChildUndigested(ctx.agent, key)) {
86
- return { err: `block ${key} belongs to a consultation whose digest has not been consumed — the session is still running, or its session entry is still pending (freeze refused; the block is reclaimed at the consumption window)` }
151
+ if (consultChildUndigested(ctx.agent, want)) {
152
+ return { err: `block ${want} belongs to a consultation whose digest has not been consumed — the session is still running, or its session entry is still pending (freeze refused; the block is reclaimed at the consumption window)` }
87
153
  }
88
- return { ok: true }
154
+ return { ok: true, renderer: src.readout !== null }
89
155
  }
90
156
 
91
157
  /**
92
158
  * AGENT-LOOP-SUBAGENT.md §6.7.2 subagent action:"panel"(D-P2——readonly 视图面 + 门控干预面——单动作双参,
93
159
  * freeze 优先):
94
- * - view(缺省——返回面板块列表):ctx.state(= agent._tuiState——CLI TUI 装配)经
95
- * computePanelBlocks **读时现算**(F-3 单账本——与用户所见一致)。awaitingDigest 条目
96
- * **读时交叉**
97
- * _pendingAsyncResults/池 标注 digested(round1 #3——digested:true
98
- * = 报告已消化但块仍驻留——异常块——freeze 候选;模型可定位解释 UI 怪相)。
99
- * - freeze:key(D-P3 门控通过 → 发 key + "/" + ⟦ev⟧done 哨兵字面 token——
100
- * onToken——TUI routeSubToken 冻结回收——落位复用 sub._freezeAt settle 锚点
101
- * splice,无锚点尾推兜底——AGENT-LOOP-ASYNC-POOL.md §6.8 同口径——round1 #2)。
102
- * 无 TUI state(headless/VS Code——D-P2 round1 #1:webview 无 state.subTasks 对应物——
103
- * 7.2.3.2 #8 先例)→ view 恒降级池视图(双池经 getAsyncPool + pending 单容器
104
- * 合成)+ no panel 注;freeze 报不可用。CLI-only 完整能力(AC-P4)。
160
+ * - view(缺省——返回面板块列表):**回读源链**(子代理面板批 · 2026-10-04):渲染面快照非空 ⇒
161
+ * `{ source:"renderer", asOf, ageMs, panel[] }`(判别六键 + role/id 随行;awaitingDigest 条读时交叉
162
+ * 核池/pending 标 `digested`)∥ 快照空 ⇒ 现链回落——ctx.state(= agent._tuiState——CLI TUI 装配)经
163
+ * computePanelBlocks **读时现算**(F-3 单账本——与用户所见一致),awaitingDigest 条目**读时交叉**
164
+ * _pendingAsyncResults/池 标注 digested(round1 #3——digested:true = 报告已消化但块仍驻留——异常块——
165
+ * freeze 候选;模型可定位解释 UI 怪相)。
166
+ * - freeze:key(D-P3 门控通过 → 发 key + "/" + ⟦ev⟧done 哨兵字面 token——onToken——TUI routeSubToken
167
+ * 冻结回收 ∥ 桌面经桥 relay ⇒ 渲染面归档机入流 + record——同字面、零新消费者;发射 = 规范化键。
168
+ * 落位复用 sub._freezeAt settle 锚点 splice,无锚点尾推兜底——AGENT-LOOP-ASYNC-POOL.md §6.8 同口径——round1 #2)。
169
+ * 两源皆空(headless/VS Code——D-P2 round1 #1:webview 无 state.subTasks 对应物——7.2.3.2 #8 先例)
170
+ * → view 恒降级池视图(双池经 getAsyncPool + pending 单容器合成)+ no panel 注;freeze 报不可用。
171
+ * CLI-only 完整能力(AC-P4)。
105
172
  */
106
173
  export function executePanelAction(args, ctx) {
107
174
  const agent = ctx.agent
@@ -115,17 +182,22 @@ export function executePanelAction(args, ctx) {
115
182
  }
116
183
  const gate = panelFreezeGate(ctx, freezeKey)
117
184
  if (gate.err) return JSON.stringify({ status: "error", error: gate.err })
185
+ const panelKey = normalizePanelKey(freezeKey)
118
186
  if (!ctx.callbacks?.onToken) {
119
- return JSON.stringify({ status: "error", error: `panel present but no token relay in this context — the freeze of ${freezeKey} cannot reach the TUI` })
187
+ return JSON.stringify({ status: "error", error: `panel present but no token relay in this context — the freeze of ${panelKey} cannot reach the TUI` })
120
188
  }
121
189
  // 门控通过 → 发 done 冻结事件(settle 同机制字面格式——TUI routeSubToken done
122
190
  // 分支冻结回收——落位 _freezeAt settle 锚点 splice;无锚点(旧会话残留)时
123
191
  // freezeSubTaskLines 尾推兜底——注明两种落位,模型不被误导(advisor 🟡1)。
124
- ctx.callbacks.onToken(`${freezeKey}/⟦ev⟧done\x1e0\x1e0\x1edone\x1e`)
192
+ // 桌面(回读源):同字面经桥 relay 分流 ⇒ 渲染面归档机(归档入流 + record)——零新消费者;
193
+ // 发射键 = 规范化键(`sub:` 前缀不入 relay 文法——§2.3)。
194
+ ctx.callbacks.onToken(`${panelKey}/⟦ev⟧done\x1e0\x1e0\x1edone\x1e`)
125
195
  return JSON.stringify({
126
- key: freezeKey,
196
+ key: panelKey,
127
197
  status: "frozen",
128
- note: "done freeze event issued — the TUI reclaimed the block into the conversation (spliced at its settle anchor when one is recorded, else appended at the current stream end — same-rule position)",
198
+ note: gate.renderer
199
+ ? "done freeze event issued — the desktop renderer reclaimed the block into the conversation flow (archived there, record appended)"
200
+ : "done freeze event issued — the TUI reclaimed the block into the conversation (spliced at its settle anchor when one is recorded, else appended at the current stream end — same-rule position)",
129
201
  })
130
202
  }
131
203
  // ── view 面(缺省——readonly)──
@@ -133,6 +205,17 @@ export function executePanelAction(args, ctx) {
133
205
  if (args?.view === false) {
134
206
  return JSON.stringify({ status: "error", error: "panel has nothing to do — view:false with no freeze key; pass freeze:'role#N' to reclaim a digested-stuck block, or omit view (defaults to true)" })
135
207
  }
208
+ // 回读源链(子代理面板批 · 2026-10-04 · `PANEL-READBACK.md` §2.1):渲染面快照非空 ⇒ 实况回读面
209
+ // (判别六键 + role/id 随行 + awaitingDigest 读时交叉;asOf/ageMs 携龄——不设截断,卡留块状态恒定)。
210
+ const readout = typeof ctx.readout === "function" ? ctx.readout() : null
211
+ if (readout && Array.isArray(readout.blocks)) {
212
+ return JSON.stringify({
213
+ source: "renderer",
214
+ asOf: readout.receivedAt ?? null,
215
+ ageMs: typeof readout.receivedAt === "number" ? Math.max(0, Date.now() - readout.receivedAt) : null,
216
+ panel: readout.blocks.map((b) => readoutPanelEntry(agent, b)),
217
+ })
218
+ }
136
219
  const snap = computePanelBlocks(ctx.state)
137
220
  if (!Array.isArray(snap)) {
138
221
  // F-P3 降级:无 TUI state(headless/VS Code/子代理上下文——CLI TUI-only 完整能力)→
@@ -174,7 +257,8 @@ export function executePanelAction(args, ctx) {
174
257
  if (b.status === "running") {
175
258
  out.elapsedSec = b.startedAt ? Math.max(0, Math.floor((Date.now() - b.startedAt) / 1000)) : 0
176
259
  } else if (b.status === "awaitingDigest") {
177
- // 读时交叉(round1 #3):pending/池均无对应 = 报告已消化(注入即从两者移除)——
260
+ // 读时交叉(round1 #3):pending/池均无对应 = 报告已消化(fallback 面注入即从两者移除;
261
+ // 会话面注入后不随即移除——留容器至销账 / 升级,移除 = `accountDigestRound` 的 splice;§6.31)——
178
262
  // 块驻留 = 状态滞后——digested:true(freeze 候选——模型可定位异常块)。
179
263
  // ASYNC-RESULT-CONTAINER.md D2:pending 单容器参与比对(四族统一)。
180
264
  // #748:consult 子块按消费判据(会话在跑 ∥ 会话条目在 pending ⇒ 未消化)——与门控同源。
@@ -243,7 +243,7 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
243
243
  if (role === "eng-coder" || role === "eng-designer") {
244
244
  const given = typeof args.batchDoc === "string" ? args.batchDoc.trim() : ""
245
245
  const refusal = (suffix = "") => new Error(
246
- `batchDoc is required for role='${role}' — pass the batch record path (docs/batches/<batch>-<topic>.md); spawn refused without it.` + suffix)
246
+ `batchDoc is required for role='${role}' — pass the batch record path (e.g. <repo>/docs/batches/<batch>-<topic>.md; relative paths resolve against the session cwd first, then candidate project roots — or absolute); spawn refused without it.` + suffix)
247
247
  if (!given) throw refusal()
248
248
  batchDocAbs = resolveBatchReadPath(parent.cwd ?? process.cwd(), given)
249
249
  if (!batchDocAbs) throw refusal(" (given path is not a readable file)")
@@ -132,14 +132,14 @@ export const subagentTool = {
132
132
  properties: {
133
133
  action: { type: "string", enum: ["spawn", "status", "escalate", "cancel", "panel", "consume-design", "observe", "send"], description: "Which action — spawn (default) / status / observe / send / escalate / cancel / panel / consume-design; semantics per action in the tool description." },
134
134
  view: { type: "boolean", description: "action:'panel' only: true (default) = the live panel blocks as the user sees them; false without freeze = error. Mutually exclusive with freeze (freeze wins)." },
135
- freeze: { type: "string", description: "action:'panel' only: block key of a digested-stuck block — reclaims it into the conversation; refused for running/done/unknown blocks or a still-pending report; requires the CLI TUI panel mirror." },
135
+ freeze: { type: "string", description: "action:'panel' only: block key of a digested-stuck block — reclaims it into the conversation; refused for running/done/unknown blocks or a still-pending report; works on the CLI TUI panel and on the desktop renderer panel (live readback of the blocks as the user sees them)." },
136
136
  task: { type: "string", description: "Required for action:'spawn' (the self-contained task brief) and action:'escalate' (goal, constraints, entry files, acceptance criteria)." },
137
137
  context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation); action:'spawn' only." },
138
138
  role: { type: "string", enum: ["explore", "plan", "coder", "eng-coder", "eng-designer"], description: "The sub-agent role — see the role matrix in the tool description. Exact spelling required. action:'spawn' only (escalate spawns its own expert internally)." },
139
139
  model: { type: "string", description: "action:'spawn': provider/model override ('provider:model', a provider, or a model name on the parent's provider) — defaults config.agent.subagentModels[role] → config.agent.subagentModel → the parent's provider; \"default\" inherits. action:'escalate': a consult candidate (default = the first)." },
140
140
  designToken: { type: "string", description: "Required when role='eng-coder': the token from advisor(type='design') after the review passed — without it, eng-coder cannot modify files." },
141
141
  designId: { type: "string", description: "role='eng-coder': the designId echoed with the approved token — required to pick between concurrent designs (optional for one). action:'consume-design': the slot to close out (required to pick between several; the gate refuses to guess)." },
142
- batchDoc: { type: "string", description: "REQUIRED for role='eng-coder'/'eng-designer': the batch record path (docs/batches/<batch>-<topic>.md) — the task book this spawn implements/writes. Refused when absent or when the path does not resolve to a readable file; the content is never validated." },
142
+ batchDoc: { type: "string", description: "REQUIRED for role='eng-coder'/'eng-designer': the batch record path (e.g. <repo>/docs/batches/<batch>-<topic>.md; relative paths resolve against the session cwd first, then candidate project roots — or absolute) — the task book this spawn implements/writes. Refused when absent or when the path does not resolve to a readable file; the content is never validated." },
143
143
  round: { type: "string", enum: ROUND_VALUES, description: "REQUIRED for role='eng-coder'/'eng-designer': initial (first round for this design) or fix (a correction round reusing the same designId+designToken; docs FIRST). No default — missing = refused." },
144
144
  async: { type: "boolean", description: "action:'spawn'/'escalate': default at depth 0 = true (async; the report arrives automatically); depth>0 always sync. async:false forces the blocking flight." },
145
145
  files: { type: "array", items: { type: "string" }, description: "action:'spawn' only: the file write-domain this task declares — file-level paths only (cwd-relative or absolute; directories rejected — they bypass the conflict detector). Overlapping-file tasks serialize automatically. Omit = no conflict detection." },
@@ -188,7 +188,9 @@ export const subagentTool = {
188
188
  // CLI-ACTIVITY-DEBLOAT F-3(2026-09-10)接线:executePanelAction 经 ctx.state
189
189
  // (= agent._tuiState——startTUI 反向挂载)读时现算面板块(computePanelBlocks)——
190
190
  // 手工面板镜像已退役。headless/VSC 无挂载 → 现算返 null → 降级照旧。
191
- if (action === "panel") return executePanelAction(args, { ...ctx, state: ctx.agent?._tuiState })
191
+ // 子代理面板批(2026-10-04):另携 ctx.readout(= agent._panelReadout——桌面渲染面实况回读
192
+ // 上报缓存读面;agent-host 装配挂)——核内读源链 readout → state → 降级(PANEL-READBACK.md §2.1)。
193
+ if (action === "panel") return executePanelAction(args, { ...ctx, state: ctx.agent?._tuiState, readout: ctx.agent?._panelReadout })
192
194
  // SUBAGENT-OBSERVE-SEND:observe = readonly 查询(同 status——digest/planMode 放行);
193
195
  // send = 控制类豁免(同 cancel——父回合内显式调用即授权)。深度门在各自执行器内。
194
196
  if (action === "observe") return executeObserveAction(args, ctx)
@@ -87,6 +87,7 @@ export const verifyTool = {
87
87
  status: { type: "string", enum: ["passed", "failed", "skipped"], description: "The verification outcome you declare: 'passed' → allowed; 'failed' → blocked; 'skipped' → allowed but requires a summary reason." },
88
88
  command: { type: "string", description: "Optional: the verification command you ran (self-reported)." },
89
89
  summary: { type: "string", description: "Optional: the result. Required when status='skipped' — a concrete reason (e.g. \"project has no automated tests — verified by manual review\")." },
90
+ basis: { type: "string", description: "Optional: what the conclusion stands on — a judgment source, a read file:line coordinate, or an explicit \"unverified\" (declaration-only: no format is enforced or machine-checked). A passed/skipped declaration without it gets one advisory hint line in the report — never blocking, never changing the gate." },
90
91
  },
91
92
  required: ["status"],
92
93
  },
@@ -239,12 +240,16 @@ export const verifyTool = {
239
240
  lines.push("Verification declared passed.")
240
241
  if (v.command) lines.push(` command: ${v.command}`)
241
242
  if (v.summary) lines.push(` summary: ${v.summary}`)
243
+ if (v.basis) lines.push(` basis: ${v.basis}`)
244
+ else lines.push(" basis: not declared (advisory — does not affect the gate) — note what the conclusion stands on: a judgment source, a read file:line, or an explicit \"unverified\".")
242
245
  ctx.agent._verifyPassed = true
243
246
  } else if (status === "skipped") {
244
247
  const reason = (v.summary ?? "").trim()
245
248
  if (reason) {
246
249
  lines.push("")
247
250
  lines.push(`Verification skipped with reason: ${reason}`)
251
+ if (v.basis) lines.push(` basis: ${v.basis}`)
252
+ else lines.push(" basis: not declared (advisory — does not affect the gate) — note what the conclusion stands on: a judgment source, a read file:line, or an explicit \"unverified\".")
248
253
  ctx.agent._verifyPassed = true
249
254
  } else {
250
255
  lines.push("")
@@ -0,0 +1,77 @@
1
+ /**
2
+ * zero-write-watch.mjs — 零落笔看门狗叶(批 subagent-zero-write-watchdog · 台账 #934)。
3
+ * 设计权威 = `docs/core/design/AGENT-LOOP-UPSTREAM.md` §6.32;需求 = `docs/core/requirements/AGENT-LOOP.md` §4.16。
4
+ *
5
+ * 两族(§6.32.2):子代理族 = eng-coder / eng-designer **连续** N 轮零文件写(采集点邻单点 =
6
+ * `agent/turn-loop.mjs` 轮顶——streak 更新与越阈判定同点);评审族 = 异步评审 **连续** N 轮零评审
7
+ * 文本产出(`advisor/loop.mjs` 循环局部计数 + 可选钩子 `seams.onStallRound`)。
8
+ *
9
+ * 送达 = 复用 §6.27 上行通道(`pushChildUpstream`——note 类:同队列同消费点、零唤醒、零新容器);
10
+ * **只推提醒**——不自动杀 / 不自动转向 / 不代父侧动作(§6.32.10-1)。
11
+ * 防轰炸 = 闩 + 写后重臂(子代理族:`_zeroWriteAlerted`;评审族:单次运行闩——§6.32.3)。
12
+ * 文案单源 = 本档两族文本函数(§6.32.4 逐字——父侧定稿;`from` + 轮数为唯一变量)。
13
+ * 零依赖叶:静态 import 只取 `parent-channel.mjs` 的 `pushChildUpstream`(不引池 / 端面 / 循环)。
14
+ */
15
+ import { pushChildUpstream } from "./parent-channel.mjs"
16
+
17
+ /** 阈值单源(连续轮数——两族共用;§6.32.2 阈值固定 50,不做配置键)。 */
18
+ export const ZERO_WRITE_ALERT_ROUNDS = 50
19
+
20
+ /** 子代理族触面(用户点名三角色中的两位——有文件写面;explore / plan / consult / 普通 coder / depth-0 不设)。 */
21
+ const WATCH_ROLES = new Set(["eng-coder", "eng-designer"])
22
+
23
+ /** 子代理族文案(§6.32.4 逐字——父侧定稿;`from` = `_upstream.label`,形 `role#id`)。 */
24
+ export function zeroWriteAlertText(from, rounds) {
25
+ return `[zero-write watchdog] ${from}: ${rounds} consecutive rounds with no file write — take a look (subagent action:'status' for the live view).`
26
+ }
27
+
28
+ /** 评审族文案(§6.32.4 逐字——父侧定稿;`from` = `advisor#<entry id>`)。 */
29
+ export function advisorAlertText(from, rounds) {
30
+ return `[zero-write watchdog] ${from}: ${rounds} consecutive rounds with no review output — take a look (subagent action:'status' / 'cancel').`
31
+ }
32
+
33
+ /**
34
+ * 子代理族判定 + 推送(§6.32.6)——门 = `_role` ∈ 触面 ∧ `_upstream.parent` 在场 ∧
35
+ * `_zeroWriteStreak` **为整数** ≥ 阈值(缺字段 / 非整数 ⇒ 不判——不冒充 `0`)∧ 未闩;
36
+ * 命中 ⇒ `pushChildUpstream({parent, from: _upstream.label, kind: "note", message})` + 置闩。
37
+ * **恒零抛**(兜底捕获兑现——自动源无模型可报错;失败面静默跳过)。
38
+ * @param {object} child 子 agent(eng-coder / eng-designer 形态;`_upstream` 由 spawn 站点装配)。
39
+ * @returns {boolean} 是否命中推送(测试面)。
40
+ */
41
+ export function maybeZeroWriteAlert(child) {
42
+ try {
43
+ if (!WATCH_ROLES.has(child?._role)) return false
44
+ const upstream = child._upstream
45
+ if (!upstream?.parent) return false
46
+ const streak = child._zeroWriteStreak
47
+ if (!Number.isInteger(streak) || streak < ZERO_WRITE_ALERT_ROUNDS) return false
48
+ if (child._zeroWriteAlerted) return false
49
+ pushChildUpstream({
50
+ parent: upstream.parent,
51
+ from: upstream.label,
52
+ kind: "note",
53
+ message: zeroWriteAlertText(upstream.label, streak),
54
+ })
55
+ child._zeroWriteAlerted = true
56
+ return true
57
+ } catch {
58
+ return false // 恒零抛兑现(§6.32.6——失败面静默跳过)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * 评审族推送(组合文案 + push——呼点 = 评审环路钩子 `seams.onStallRound`,单次运行至多一次)。
64
+ * **恒零抛**(实现面兜底捕获兑现——抛错不得上抛为评审 failed 面;§6.32.6)。
65
+ * @param {object} parent 父 agent(接收方——`pushChildUpstream` 载体吸收同款)。
66
+ * @param {string} from 来源标签(`advisor#<entry id>`)。
67
+ * @param {number} rounds 连续无产出轮数。
68
+ * @returns {boolean} 是否推送成功(测试面)。
69
+ */
70
+ export function pushAdvisorAlert(parent, from, rounds) {
71
+ try {
72
+ pushChildUpstream({ parent, from, kind: "note", message: advisorAlertText(from, rounds) })
73
+ return true
74
+ } catch {
75
+ return false
76
+ }
77
+ }
package/agent.mjs CHANGED
@@ -100,6 +100,6 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
100
100
  // 2026-09-05 实践轮:回合收尾(consult 清理/async 池分流/guard 继承——原 425-462
101
101
  // 段 + collectSettledAsync 466-494 整体迁 agent/run-stages.mjs finalizeAgentTurn——
102
102
  // CLI 对位 VS run-stages——finally 只剩一行调用 + 骨架注释)。
103
- await finalizeAgentTurn(agent, { signal, autoTurn, suspDriven, thrownError, depth })
103
+ await finalizeAgentTurn(agent, { signal, autoTurn, upstreamTurn, timerTurn, suspDriven, thrownError, depth })
104
104
  }
105
105
  }
package/auto-think.mjs CHANGED
@@ -16,6 +16,8 @@
16
16
  */
17
17
  import { chat } from "./provider/core.mjs"
18
18
  import { specForModel } from "./config.mjs"
19
+ // #860(AGENT-LOOP.md §6.1 ∥ §7 D-AL26):off 形族的单一实现源(写面取形 + 有效 off 路径判据)
20
+ import { thinkOffPath, thinkOffShape } from "./think-off.mjs"
19
21
 
20
22
  const CLASSIFY_PROMPT = `Classify this coding task's difficulty: low, medium, or high.
21
23
 
@@ -31,6 +33,10 @@ const EFFORT_MAP = {
31
33
  high: ["max", "max", "xhigh", "max"],
32
34
  }
33
35
 
36
+ /** #860(D-AL26):不可关思考的跳过警示 ∕ 分类失败——进程级一次性(按 model ∕ 错误签名去重,防逐轮刷屏)。 */
37
+ const warnedThinkGuard = new Set()
38
+ const warnedClassifyFailure = new Set()
39
+
34
40
  /**
35
41
  * Build classifier input from history: the latest real user message (reminders and
36
42
  * interrupt injections excluded), plus the previous user message as context when the
@@ -67,13 +73,25 @@ export async function classifyAndApply(agent, turn) {
67
73
  const validEfforts = spec.reasoningEffortEnum
68
74
  if (!validEfforts) return null // Model doesn't support reasoning effort
69
75
 
76
+ // #860 守卫(D-AL26):无法关思考的模型(thinkAlwaysOn ∕ 枚举无 none)⇒ 分类调用必然失败
77
+ //(思考吃光小预算 ∕ 400)——不调用;一次可见警示(按 model 去重),语义回退 null。
78
+ if (!thinkOffPath(spec)) {
79
+ if (!warnedThinkGuard.has(agent.provider.model)) {
80
+ warnedThinkGuard.add(agent.provider.model)
81
+ console.warn(`[auto-think] model "${agent.provider.model}" cannot disable thinking — difficulty classification skipped (reasoning effort unchanged)`)
82
+ }
83
+ return null
84
+ }
85
+
70
86
  const prompt = buildClassifierInput(agent.history)
71
87
  if (prompt == null) return null
72
88
 
73
89
  // Classification call: use same provider, minimal tokens, no tools, no streaming
74
90
  let level
75
91
  try {
76
- const classifierProvider = { ...agent.provider, maxTokens: 10 }
92
+ // #860:关思考形(族别 off 形)+ 小预算 32(原 10 token 被思考吃光)+ reasoningEffort 清空
93
+ //(防父档外溢;effort 族 off 形由载荷门补发 reasoning_effort:"none")。
94
+ const classifierProvider = { ...agent.provider, maxTokens: 32, thinking: thinkOffShape(spec), reasoningEffort: null }
77
95
  const response = await chat(classifierProvider, {
78
96
  messages: [
79
97
  { role: "system", content: CLASSIFY_PROMPT },
@@ -101,7 +119,13 @@ export async function classifyAndApply(agent, turn) {
101
119
  else if (word.startsWith("medium") || word.startsWith("med")) level = "medium"
102
120
  else if (word.startsWith("high")) level = "high"
103
121
  else return null // Unparseable
104
- } catch {
122
+ } catch (e) {
123
+ // #860(D-AL26):失败一次可见(按错误签名进程级去重——不再静默);回退 null 语义不变。
124
+ const sig = e?.message ?? String(e)
125
+ if (!warnedClassifyFailure.has(sig)) {
126
+ warnedClassifyFailure.add(sig)
127
+ console.warn(`[auto-think] classification failed: ${sig}`)
128
+ }
105
129
  return null // Classification failure → fall back to current setting
106
130
  }
107
131
 
@@ -42,6 +42,14 @@ export const PROVIDER_PRESETS = {
42
42
  siliconflow: { baseURL: "https://api.siliconflow.cn/v1", model: "deepseek-ai/DeepSeek-V3", maxTokens: 32_000, desc: "SiliconFlow (硅基流动)" },
43
43
  openrouter: { baseURL: "https://openrouter.ai/api/v1", model: "anthropic/claude-sonnet-4", maxTokens: 32_000, desc: "OpenRouter" },
44
44
  groq: { baseURL: "https://api.groq.com/openai/v1", model: "llama-3.3-70b-versatile", maxTokens: 32_000, desc: "Groq" },
45
+ // OpenCode Go 订阅网关(opencode.ai/zen 取 sk- key ∥ 无 OAuth)——官方档(go.mdx)双协议混装 ⇒ 拆双预设
46
+ // (PROVIDER.md §6.11):本键 = OpenAI 兼容 `/chat/completions` 侧;`opencode-go-anthropic` = Anthropic
47
+ // `/messages` 侧(同 key——模型按侧选)。字段集 = 最小面:`thinking` / `reasoningEffort` 不设 = 不发
48
+ // (D-13 同口径);`maxTokens` 仅 anthropic 侧设 `65536`(该 transport `max_tokens` 必发——不设即落规格
49
+ // 行值 131072,超渠道口径)。**待验(无 key——未实拉 `/models` ∕ 载荷未实测;接入后按实拉复核)**;
50
+ // `x-opencode-session` 头 = 可选遥测——**不发**(亦不动 `providers[].headers` 面)。
51
+ "opencode-go": { baseURL: "https://opencode.ai/zen/go/v1", model: "glm-5.2", desc: "OpenCode Go subscription (sk- key from opencode.ai/zen; OpenAI-compatible models — MiniMax/Qwen via opencode-go-anthropic)" },
52
+ "opencode-go-anthropic": { baseURL: "https://opencode.ai/zen/go/v1", model: "qwen3.7-max", format: "anthropic", maxTokens: 65536, desc: "OpenCode Go — Anthropic Messages side (same sk- key as opencode-go; MiniMax/Qwen models)" },
45
53
  }
46
54
 
47
55
  /** Build the stored provider entry from a preset — strip the display field, keep the rest
package/config.mjs CHANGED
@@ -188,6 +188,31 @@ function warnConsultModelsFiltered(dropped, path) {
188
188
  `\n Fix: clean agent.consultModels in ${path} or use /config → consult/escalate pool menu.`)
189
189
  }
190
190
 
191
+ /** #861(AGENT-LOOP-SUBAGENT.md §6.7.1):subagent 模型覆盖加载期清洗——单源纯函数(核 loadConfig ∥ VSC 端壳 raw 读点同消费);非法形态不 throw(`subagentModel` 非法 ⇒ null、`subagentModels` 非对象 ⇒ {}、值非法 ⇒ 剔除该键)。 */
192
+ export function sanitizeSubagentModel(v) {
193
+ const ok = typeof v === "string" && v.trim()
194
+ const dropped = ok || v == null ? [] : [`agent.subagentModel must be a non-empty string ("provider:model" | provider | model) — got ${Array.isArray(v) ? "array" : typeof v} — ignored (inherits the parent provider)`]
195
+ return { value: ok ? v : null, dropped }
196
+ }
197
+ export function sanitizeSubagentModels(v) {
198
+ if (v == null) return { value: {}, dropped: [] }
199
+ if (typeof v !== "object" || Array.isArray(v)) return { value: {}, dropped: [`agent.subagentModels must be an object mapping role → model override — got ${Array.isArray(v) ? "array" : typeof v} — ignored`] }
200
+ const value = {}, dropped = []
201
+ for (const [role, val] of Object.entries(v)) {
202
+ if (typeof val === "string" && val.trim()) { value[role] = val; continue }
203
+ dropped.push(`agent.subagentModels.${role} must be a non-empty string ("provider:model" | provider | model) — got ${Array.isArray(val) ? "array" : typeof val} — dropped`)
204
+ }
205
+ return { value, dropped }
206
+ }
207
+
208
+ let warnedSubagentModels = false // #861:清洗警告——进程级一次性(同 consultModels 先例)
209
+ function warnSubagentModelsFiltered(dropped, path) {
210
+ if (warnedSubagentModels || dropped.length === 0) return
211
+ warnedSubagentModels = true
212
+ console.warn(`[config] agent.subagentModel/subagentModels: ${dropped.length} invalid entr${dropped.length === 1 ? "y ignored" : "ies ignored"} (filtered — startup continues; no crash):\n` +
213
+ `${dropped.map((d) => ` - ${d}`).join("\n")}\n Fix: clean it in ${path} — "provider:model" | provider | model.`)
214
+ }
215
+
191
216
  /**
192
217
  * Find provider by name in providers[].
193
218
  * Throws if name is non-empty but not found — a typo in activeProvider silently falling to the first provider would use the wrong key on the wrong endpoint.
@@ -311,6 +336,11 @@ export function loadConfig() {
311
336
  merged.agent.consultModels = cmClean.keep
312
337
  if (cmClean.dropped.length) merged.agent.consultModelsFiltered = cmClean.dropped
313
338
 
339
+ const smClean = sanitizeSubagentModel(merged.agent.subagentModel), smsClean = sanitizeSubagentModels(merged.agent.subagentModels) // #861(§6.7.1):加载期清洗——非法形态不采纳 + 一次性警告(不 throw——同 F-4 范式)
340
+ warnSubagentModelsFiltered([...smClean.dropped, ...smsClean.dropped], path)
341
+ merged.agent.subagentModel = smClean.value
342
+ merged.agent.subagentModels = smsClean.value
343
+
314
344
  // Backward compatibility: promote root-level config fields to agent sub-object
315
345
  if (config.verifyGuard !== undefined) {
316
346
  merged.agent.verifyGuard = config.verifyGuard
package/conventions.mjs CHANGED
@@ -26,9 +26,10 @@
26
26
  * code — inside a declared code segment (default: the path segment `src`), or the
27
27
  * fallback; doc — documentation extension outside any code segment and not
28
28
  * scratch; temp — tmp-* name or .tmp/.temp extension; aux — a default
29
- * auxiliary-path segment sequence (test / tests / scripts / .thincoder/tmp —
30
- * F9) that matched no code segment and neither doc nor temp.
31
- * Precedence: code segment → temp → doc → aux → fallback code.
29
+ * auxiliary-path segment sequence (test / tests / scripts / .thincoder/tmp — F9)
30
+ * that matched no code segment and neither doc nor temp; state — a project state
31
+ * file (`PROJECT-MANIFEST.json` basename ∥ `.thincoder/conventions.json` pair — #941).
32
+ * Precedence: code segment → temp → doc → aux → state → fallback code.
32
33
  *
33
34
  * Segment matching runs on the PROJECT-ROOT-RELATIVE FACE of a path (D17 · 2026-09-28): the
34
35
  * machine layout ABOVE the project root never participates. An absolute path outside the
@@ -128,18 +129,32 @@ function hasAuxSegment(p, conv) {
128
129
  return hasSegmentSequence(p, DEFAULT_AUX_PATHS, conv?.root, false)
129
130
  }
130
131
 
131
- /** "code" | "doc" | "temp" | "aux" — the single classification decision.
132
+ /** Project state-file markers (data — #941 · PORTABILITY D19): manifest basename (any layer) ∥
133
+ * retired-carrier pair; single sources: `MANIFEST_REL` ∥ `RETIRED_REL_PATH`. */
134
+ const STATE_BASENAME = "project-manifest.json"
135
+ const STATE_PAIR = ".thincoder/conventions.json"
136
+
137
+ /** True when `s` names a project state file (basename any layer ∥ segment pair; case-insensitive — no face, D17). */
138
+ function isStateFile(s) {
139
+ const segs = segmentsOf(s)
140
+ if (segs.length > 0 && lowerSeg(segs[segs.length - 1]) === STATE_BASENAME) return true
141
+ return hasSequenceIn(segs, [STATE_PAIR])
142
+ }
143
+
144
+ /** "code" | "doc" | "temp" | "aux" | "state" — the single classification decision.
132
145
  * Precedence: code segment first (src/** stays product code even when the name
133
146
  * looks scratch — the pre-existing unconditional-src rule), then temp, then a
134
147
  * documentation extension, then an auxiliary-path segment sequence (test / tests
135
- * / scripts / .thincoder/tmp — F9), else code (anything not doc/temp/aux is product
136
- * code). Both segment checks run on the project-root-relative face (D17 — `faceOf`). */
148
+ * / scripts / .thincoder/tmp — F9), then a project state file (#941), else code
149
+ * (anything not doc/temp/aux/state is product code). Segment checks face-bound (D17);
150
+ * state matches by name marker. */
137
151
  export function classifyPath(p, conv) {
138
152
  const s = String(p ?? "")
139
153
  if (hasCodeSegment(s, conv)) return "code"
140
154
  if (TEMP_FILE.test(s)) return "temp"
141
155
  if (DOC_FILE.test(s)) return "doc"
142
156
  if (hasAuxSegment(s, conv)) return "aux"
157
+ if (isStateFile(s)) return "state"
143
158
  return "code"
144
159
  }
145
160
 
@@ -165,6 +180,11 @@ export function isAuxPath(p, conv) {
165
180
  return classifyPath(p, conv) === "aux"
166
181
  }
167
182
 
183
+ /** True when the path is a project state file (`classifyPath === "state"` — #941; derived form). */
184
+ export function isStatePath(p, conv) {
185
+ return classifyPath(p, conv) === "state"
186
+ }
187
+
168
188
  // ─────────────────────────────────────────────────────────────────────────────
169
189
  // 迁出面(2026-10-01 拆分批 · #755 ∥ #786):声明装载面外提 `declaration.mjs`——本档经
170
190
  // 转口保名(消费面 / 批内件 import 面零改;`DEFAULT_DECLARATION` 另供本档分类裁判缺省取用)。
package/declaration.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  * name / shape / defaults = schema 面 `docs/core/design/MANIFEST.md` §2.2)。迁出块逐字;
6
6
  * 原档 `conventions.mjs` 经 `export { … } from` 转口保名(消费面 / 批内件 import 面零改)。
7
7
  */
8
- import { existsSync } from "node:fs"
9
- import { dirname, isAbsolute, join, relative, resolve } from "node:path"
8
+ import { existsSync, statSync } from "node:fs"
9
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
10
10
  import { DEFAULT_MANIFEST, manifestFilePath, readManifest } from "./manifest.mjs"
11
11
  import { logEvent } from "./log.mjs"
12
12
 
@@ -140,6 +140,30 @@ export function declaredPublicRoots(cwd) {
140
140
  return out
141
141
  }
142
142
 
143
+ /** 引用解析单点(§6.1 口径 4/5——写门 ∥ 迁移旗同消费,不得二写):① 仓根解析 `resolve(base, part)`
144
+ * 为存在的档 ⇒ 命中;② 声明源前缀解析序:首段命中声明公共仓目录名(消费既有 `declaredPublicRoots`
145
+ * ——同名多仓 ⇒ 声明序首者)⇒ 于该声明根解析余段;未声明 ∥ 声明根缺位 ∥ 目标档缺 ⇒ `{ ok: false, abs }`。
146
+ * @param {string} base 仓根基准(写门口径 2 同一子表达式)
147
+ * @param {string} part task_book 文件部分(首个 `§` 前子串,trim——调用面已判缺位)
148
+ * @returns {{ok: boolean, abs: string}}
149
+ */
150
+ export function resolveDeclaredRef(base, part) {
151
+ const abs = resolve(base, part)
152
+ if (isFile(abs)) return { ok: true, abs }
153
+ const segs = String(part).replace(/\\/g, "/").split("/").filter(Boolean)
154
+ for (const root of declaredPublicRoots(base)) {
155
+ if (segs[0] !== basename(root)) continue
156
+ if (segs.length > 1) {
157
+ const hit = resolve(root, segs.slice(1).join("/"))
158
+ if (isFile(hit)) return { ok: true, abs: hit }
159
+ }
160
+ break
161
+ }
162
+ return { ok: false, abs }
163
+ }
164
+
165
+ const isFile = (p) => { try { return statSync(p).isFile() } catch { return false } }
166
+
143
167
  const _cache = new Map()
144
168
 
145
169
  /** Drop the per-data-file-path cache (test seam — declarations are read once per path). */