@thincoder/core 0.9.4 → 0.10.1

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 (221) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +1 -1
  3. package/abort-provenance.mjs +10 -10
  4. package/advisor/citations.mjs +5 -5
  5. package/advisor/compaction.mjs +16 -15
  6. package/advisor/loop.mjs +14 -14
  7. package/advisor/messages.mjs +6 -6
  8. package/advisor/project-context.mjs +5 -5
  9. package/advisor/repos.mjs +3 -3
  10. package/advisor/run.mjs +23 -13
  11. package/advisor.mjs +4 -4
  12. package/agent/assemble.mjs +111 -0
  13. package/agent/chat-call.mjs +68 -0
  14. package/agent/child-marks.mjs +2 -2
  15. package/agent/completion.mjs +9 -2
  16. package/agent/continue-decision.mjs +25 -0
  17. package/agent/dispatch-gates.mjs +140 -0
  18. package/agent/dispatch-run.mjs +167 -0
  19. package/agent/dispatch.mjs +55 -294
  20. package/agent/family-tools.mjs +11 -11
  21. package/agent/helpers.mjs +63 -3
  22. package/agent/live-beat.mjs +49 -0
  23. package/agent/post-turn.mjs +4 -8
  24. package/agent/record-results.mjs +23 -20
  25. package/agent/relay-prefix.mjs +2 -2
  26. package/agent/run-stages.mjs +19 -15
  27. package/agent/run-start.mjs +111 -0
  28. package/agent/setup-reminders.mjs +9 -9
  29. package/agent/setup.mjs +32 -4
  30. package/agent/spawn-child.mjs +9 -9
  31. package/agent/suspension.mjs +90 -30
  32. package/agent/timers.mjs +113 -0
  33. package/agent/turn-loop.mjs +244 -0
  34. package/agent/write-gate.mjs +103 -6
  35. package/agent-tools/advisor-async.mjs +11 -11
  36. package/agent-tools/advisor-settle.mjs +17 -17
  37. package/agent-tools/advisor.mjs +38 -43
  38. package/agent-tools/async-discard.mjs +44 -1
  39. package/agent-tools/async-settle.mjs +23 -20
  40. package/agent-tools/audit-block.mjs +7 -7
  41. package/agent-tools/bash-async.mjs +281 -0
  42. package/agent-tools/batch-lifecycle.mjs +65 -34
  43. package/agent-tools/batch-paths.mjs +186 -0
  44. package/agent-tools/batch-skeleton.mjs +19 -1
  45. package/agent-tools/batch.mjs +22 -119
  46. package/agent-tools/checkpoint.mjs +121 -0
  47. package/agent-tools/child-permission.mjs +5 -3
  48. package/agent-tools/consult.mjs +58 -58
  49. package/agent-tools/context.mjs +4 -20
  50. package/agent-tools/design-token.mjs +3 -3
  51. package/agent-tools/digest-budget.mjs +1 -1
  52. package/agent-tools/eng.mjs +5 -5
  53. package/agent-tools/escalate-async.mjs +18 -13
  54. package/agent-tools/goal.mjs +3 -10
  55. package/agent-tools/panel-blocks.mjs +3 -3
  56. package/agent-tools/parent-channel.mjs +3 -13
  57. package/agent-tools/plan.mjs +3 -4
  58. package/agent-tools/read-history.mjs +12 -28
  59. package/agent-tools/recent-changes.mjs +4 -5
  60. package/agent-tools/review-facts.mjs +1 -1
  61. package/agent-tools/settings.mjs +47 -20
  62. package/agent-tools/skill.mjs +3 -5
  63. package/agent-tools/spawn-gates.mjs +3 -3
  64. package/agent-tools/subagent-actions-query.mjs +270 -0
  65. package/agent-tools/subagent-actions.mjs +29 -273
  66. package/agent-tools/subagent-async.mjs +36 -31
  67. package/agent-tools/subagent-panel.mjs +38 -9
  68. package/agent-tools/subagent-run.mjs +44 -29
  69. package/agent-tools/subagent-scheduler.mjs +29 -28
  70. package/agent-tools/subagent-spawn.mjs +57 -25
  71. package/agent-tools/subagent.mjs +46 -66
  72. package/agent-tools/task.mjs +3 -9
  73. package/agent-tools/timer.mjs +12 -12
  74. package/agent-tools/verify.mjs +14 -13
  75. package/agent-tools.mjs +0 -1
  76. package/agent.mjs +20 -351
  77. package/attachments.mjs +125 -0
  78. package/auto-think.mjs +1 -1
  79. package/config-presets.mjs +12 -7
  80. package/config-watch.mjs +83 -0
  81. package/config.mjs +17 -4
  82. package/context-degrade.mjs +81 -0
  83. package/context-echo.mjs +88 -0
  84. package/context-push.mjs +51 -0
  85. package/context.mjs +8 -187
  86. package/conventions.mjs +110 -161
  87. package/declaration.mjs +194 -0
  88. package/embedding.mjs +3 -1
  89. package/env-ref.mjs +81 -0
  90. package/explore-distill.mjs +2 -3
  91. package/file-links.mjs +53 -0
  92. package/file-refs.mjs +103 -0
  93. package/generate-title.mjs +28 -4
  94. package/git/checkpoint.mjs +4 -8
  95. package/history-window.mjs +17 -2
  96. package/i18n.mjs +2 -0
  97. package/index-discover.mjs +7 -7
  98. package/ledger-cmd.mjs +34 -126
  99. package/ledger-db.mjs +35 -7
  100. package/ledger-migrate.mjs +299 -0
  101. package/ledger-tools.mjs +188 -0
  102. package/ledger.mjs +11 -5
  103. package/log.mjs +12 -7
  104. package/manifest-discovery.mjs +131 -0
  105. package/manifest-schema.mjs +166 -0
  106. package/manifest.mjs +18 -217
  107. package/mcp.mjs +4 -1
  108. package/memory/code-search.mjs +149 -0
  109. package/memory/code-sync.mjs +89 -230
  110. package/memory/core.mjs +47 -32
  111. package/memory/docs.mjs +63 -262
  112. package/memory/file-list.mjs +89 -0
  113. package/memory/file-walk.mjs +7 -4
  114. package/memory/memory-tool.mjs +229 -0
  115. package/memory/schema.mjs +18 -3
  116. package/memory/sweep.mjs +289 -0
  117. package/memory/sync-tail.mjs +81 -0
  118. package/memory-status.mjs +69 -0
  119. package/memory.mjs +3 -0
  120. package/model-specs.mjs +131 -66
  121. package/notify-policy.mjs +51 -0
  122. package/package.json +1 -1
  123. package/peer-claims.mjs +263 -0
  124. package/peer-domains.mjs +100 -71
  125. package/peer-instances.mjs +9 -10
  126. package/process-probe-exec.mjs +187 -0
  127. package/process-probe.mjs +27 -175
  128. package/prompt-overlays.mjs +11 -10
  129. package/prompts/advisor-design.md +2 -2
  130. package/prompts/advisor-round2.md +1 -1
  131. package/prompts/advisor-round3.md +1 -1
  132. package/prompts/common.md +19 -7
  133. package/prompts/discipline-engineering.md +57 -16
  134. package/prompts/discipline-normal.md +6 -3
  135. package/prompts/persona-coder.md +0 -1
  136. package/prompts/persona-eng-coder.md +1 -5
  137. package/prompts/persona-eng-designer.md +1 -2
  138. package/prompts/persona-engineering.md +41 -11
  139. package/prompts/persona-explore.md +0 -1
  140. package/prompts/persona-normal.md +10 -1
  141. package/prompts/persona-plan.md +3 -4
  142. package/prompts/subagent-base.md +14 -0
  143. package/provider/core.mjs +11 -48
  144. package/provider/list-models.mjs +3 -0
  145. package/provider/responses-request.mjs +237 -0
  146. package/provider/responses.mjs +6 -228
  147. package/provider-flows.mjs +248 -0
  148. package/queued.mjs +90 -0
  149. package/rules.mjs +209 -2
  150. package/session-gc.mjs +67 -6
  151. package/session-guard.mjs +27 -7
  152. package/session-lifecycle.mjs +90 -18
  153. package/session-rename.mjs +5 -1
  154. package/session-slot-claims.mjs +230 -0
  155. package/session-slot-scan.mjs +299 -0
  156. package/session-slot-verify.mjs +304 -0
  157. package/session-slot-write.mjs +74 -8
  158. package/session-slots-manifest.mjs +163 -236
  159. package/session-slots.mjs +99 -71
  160. package/session.mjs +30 -10
  161. package/shell-candidates.mjs +78 -0
  162. package/text-budget.mjs +2 -2
  163. package/think-off.mjs +49 -0
  164. package/token-ttl.mjs +15 -4
  165. package/tool-args.mjs +73 -0
  166. package/tool-docs/advisor.md +9 -0
  167. package/tool-docs/batch.md +1 -0
  168. package/tool-docs/code_search.md +1 -0
  169. package/tool-docs/consult_start.md +5 -0
  170. package/tool-docs/consult_stop.md +3 -0
  171. package/tool-docs/context.md +7 -0
  172. package/tool-docs/doc_search.md +1 -0
  173. package/tool-docs/edit.md +2 -13
  174. package/tool-docs/eng.md +1 -0
  175. package/tool-docs/execute.md +1 -9
  176. package/tool-docs/git.md +25 -51
  177. package/tool-docs/goal.md +6 -0
  178. package/tool-docs/ledger_add.md +1 -0
  179. package/tool-docs/ledger_close.md +1 -0
  180. package/tool-docs/ledger_count.md +1 -0
  181. package/tool-docs/ledger_query.md +1 -0
  182. package/tool-docs/ledger_update.md +1 -0
  183. package/tool-docs/main_history.md +1 -0
  184. package/tool-docs/memory.md +10 -0
  185. package/tool-docs/notify_parent.md +9 -0
  186. package/tool-docs/peer_instances.md +1 -0
  187. package/tool-docs/plan.md +5 -0
  188. package/tool-docs/process.md +7 -3
  189. package/tool-docs/question.md +1 -0
  190. package/tool-docs/read_history.md +7 -0
  191. package/tool-docs/recent_changes.md +1 -0
  192. package/tool-docs/repo_outline.md +1 -0
  193. package/tool-docs/settings.md +6 -0
  194. package/tool-docs/skill.md +5 -0
  195. package/tool-docs/subagent.md +31 -0
  196. package/tool-docs/task.md +7 -0
  197. package/tool-docs/timer.md +6 -0
  198. package/tool-docs/verify.md +1 -0
  199. package/tool-docs/wait_for.md +2 -1
  200. package/tools/bash.mjs +28 -4
  201. package/tools/edit-batch.mjs +10 -11
  202. package/tools/edit-diff.mjs +33 -25
  203. package/tools/exec-run.mjs +102 -4
  204. package/tools/execute.mjs +5 -2
  205. package/tools/file.mjs +11 -12
  206. package/tools/git-run.mjs +1 -1
  207. package/tools/git.mjs +0 -24
  208. package/tools/glob-dialect.mjs +3 -3
  209. package/tools/index.mjs +2 -1
  210. package/tools/linter.mjs +2 -1
  211. package/tools/node-child.mjs +21 -0
  212. package/tools/ops.mjs +50 -13
  213. package/tools/patch.mjs +4 -2
  214. package/tools/repomap.mjs +20 -9
  215. package/tools/search.mjs +2 -2
  216. package/tools/shared.mjs +9 -7
  217. package/tools/web.mjs +5 -2
  218. package/tools/write-path.mjs +1 -1
  219. package/traces/trace-store.mjs +17 -14
  220. package/vision-reader.mjs +76 -0
  221. package/agent-tools/batch-segment.mjs +0 -18
@@ -0,0 +1,281 @@
1
+ /**
2
+ * bash-async.mjs — #9 `bash` 后台任务池(异步 ∥ 后台执行;设计 = 批档
3
+ * `docs/batches/2026-09-29-tools-carryover.md` §2.1——口径同源 = consult ∥ escalate 族,
4
+ * AGENT-LOOP-ASYNC-POOL.md §6.8 ∥ §6.10)。
5
+ *
6
+ * 本档 = 后台任务单点(池 ∥ 起跑 ∥ 结算 ∥ 杀):
7
+ * - 池:分域池 `_bgTasks`(模式照 `_asyncAdvisors`——独立池 + 载体吸收:写侧父字段、读侧
8
+ * `carrierField`——缺省借用/别名 `history` 侧,VSC 形跨 runAgent 存活)。
9
+ * - 起跑:id 沿 `nextSubagentId` 共用命名空间(`bash#N` 前缀区分域);帽 `BG_TASK_MAX = 4`
10
+ * (对齐 `ASYNC_POOL_LIMITS` 单域 4)——超限**显式拒**(零静默丢;无排队面)。
11
+ * - 输出面:全量流式落盘 `configDir/tool-results/<ts>-bash-<id>.log`(轮转复用
12
+ * `cleanupOldToolResults`);内存尾部环形缓冲(注入面取尾截:≤120 行 ∧ ≤8KB 双帽 +
13
+ * 截尾注记;全量恒在 log——不经预算 offload)。
14
+ * - 送达:进程退出/被杀 ⇒ 结算 ⇒ 停靠 pending 单容器(consult 族同式「恒出池」)⇒ 唤醒挂起
15
+ * 驱动(`wakeAsyncWaiters`)⇒ 消化轮 run-start 注入(`injectBgResult`——子代理族注入器
16
+ * `injectAsyncResult` 按 role="bg" 分发到本档)。
17
+ * - 超时:async **不收**同步径默认 120s(长任务 = 本形目的);显式 `timeout` 到点杀树
18
+ * (结算状态 `killed: timeout`)。
19
+ * - 收尾两档(D9-7):回合中断(Ctrl+I)**不杀**——`bindChildController` interrupt 豁免,
20
+ * 池保留(后续经 wait_for ∥ settle 消费)∥ 会话中止(Ctrl+C ∥ Stop)⇒ 条目控制器中止 ⇒
21
+ * `async-discard.mjs` 收尾面逐条杀树 + 出池 + 墓碑(单不变量「杀 ⟺ 控制器已中止」)。
22
+ * - 边界(v1):不做可见面板块 ∥ 状态行段;不做跨会话存活(会话结束即清——进程退出径无钩子)。
23
+ *
24
+ * 模块图:→ agent-tools/async-settle.mjs(池 accessor ∥ pending 停靠 ∥ 墓碑 ∥ 唤醒 ∥ 控制器
25
+ * 链结)· subagent-scheduler.mjs(取号/令牌/入池键守卫)· tools/process-tree.mjs(树杀)·
26
+ * tools/shared.mjs(解码/清洗——与同步径同源)· tools/bash.mjs(`buildBashEnv` 单源)·
27
+ * agent/helpers.mjs(转义 ∥ 轮转清理)· config.mjs(configDir)· context.mjs(pushReal)
28
+ * ——叶子向、无环。
29
+ * 入径 = `tools/bash.mjs` / `tools/ops.mjs` **动态 import**(W8 契约②:本档静态链含
30
+ * agent-tools 族 ⇒ 端壳静态闭包不得经工具档命中 node:sqlite)。
31
+ */
32
+ import { createWriteStream } from "node:fs"
33
+ import { mkdir } from "node:fs/promises"
34
+ import { join } from "node:path"
35
+ import { spawn } from "node:child_process"
36
+ import { configDir } from "../config.mjs"
37
+ import { cleanupOldToolResults, escapeXml } from "../agent/helpers.mjs"
38
+ import { pushReal } from "../context.mjs"
39
+ import { makeDecoder, sanitizeOutput } from "../tools/shared.mjs"
40
+ import { killProcessTree } from "../tools/process-tree.mjs"
41
+ import { buildBashEnv } from "../tools/bash.mjs"
42
+ import {
43
+ bindChildController, buildChildSignal, carrierField, getAsyncPool, parkAsyncPending,
44
+ tombstoneOf, wakeAsyncWaiters, writeTombstone,
45
+ } from "./async-settle.mjs"
46
+ import { assertPoolKeyFree, consumeSubagentToken, nextSubagentId } from "./subagent-scheduler.mjs"
47
+
48
+ /** 后台任务帽(对齐 `ASYNC_POOL_LIMITS` 单域 4——AGENT-LOOP-ASYNC-POOL.md §6.10)。超限显式拒。 */
49
+ export const BG_TASK_MAX = 4
50
+
51
+ /** 注入面双帽(批档 §2.1.3 送达面):≤120 行 ∧ ≤8KB;全量恒在 log。 */
52
+ const DIGEST_TAIL_MAX_LINES = 120
53
+ const DIGEST_TAIL_MAX_CHARS = 8 * 1024
54
+ /** 内存尾部环形缓冲保留量(缓冲仅需覆盖注入帽——多留一成余量)。 */
55
+ const TAIL_KEEP_CHARS = 32 * 1024
56
+ /** exit→close 宽限(同同步径后台注记面:孙进程握管道时 close 不来——`bash.mjs` 1500ms 同值)。 */
57
+ const EXIT_GRACE_MS = 1500
58
+
59
+ /** 测试缝:log 目录重定向(单测沙箱——生产从不调用;`_setDigestOffloadDirForTest` 同式)。 */
60
+ let _bgTaskLogDir = null
61
+ export function _setBgTaskLogDirForTest(dir) { _bgTaskLogDir = dir }
62
+ function bgTaskLogDir() { return _bgTaskLogDir ?? join(configDir, "tool-results") }
63
+
64
+ /** 池写侧(绑定不变式:主容器落父字段;缺省借用 `history` 侧 + 载体别名——`writeTombstone`
65
+ * 借用法同式,VSC 形跨 runAgent 存活;CLI 形父字段恒在场——零差分)。 */
66
+ function ensureBgPool(parent) {
67
+ if (parent._bgTasks instanceof Map) return parent._bgTasks
68
+ const borrowed = carrierField(parent, "_bgTasks")
69
+ if (borrowed instanceof Map) { parent._bgTasks = borrowed; return borrowed }
70
+ parent._bgTasks = new Map()
71
+ if (parent?.history && typeof parent.history === "object") parent.history._bgTasks = parent._bgTasks
72
+ return parent._bgTasks
73
+ }
74
+
75
+ /** 条目读取(键形单源:写侧 `set(String(id))` ⇒ 读一律 String 归一;双形键兜底照子代理族)。 */
76
+ export function bgTaskEntry(parent, id) {
77
+ const pool = getAsyncPool(parent, "bg")
78
+ if (!(pool instanceof Map)) return null
79
+ const key = String(id)
80
+ return pool.get(key) ?? pool.get(Number(key)) ?? null
81
+ }
82
+
83
+ /** `wait_for` 条件 `bash id:N done` 判据(同式 `subagent id:N done`):池内 done ∥ **出池即 done**
84
+ * (结算恒出池——pending 停靠/丢弃/消费皆已结束,无可等待物;未知 id 亦按 done-by-vacuity)。 */
85
+ export function bgTaskDone(parent, id) {
86
+ const entry = bgTaskEntry(parent, id)
87
+ if (!entry) return true
88
+ return entry.done === true || entry.status === "done"
89
+ }
90
+
91
+ /** 杀单点:条目进程树(process kill ∥ discard 收尾面共用——树杀函数单源 = `tools/process-tree.mjs`)。 */
92
+ export function killBgTree(entry) {
93
+ if (entry?.child) killProcessTree(entry.child)
94
+ }
95
+
96
+ /**
97
+ * 起跑(ack 即返)。失败面 = 显式 `{ error }`(帽/深度/上下文——零静默丢)。
98
+ * @returns {{ id: number, ack: string, entry: object } | { error: string }}
99
+ */
100
+ export async function launchBgTask(parent, ctx, { command, timeout = null, shell = null } = {}) {
101
+ if (!parent) return { error: "no agent context — background tasks run in a depth-0 session only" }
102
+ if ((ctx?.depth ?? 0) > 0) {
103
+ return { error: "bash async is depth-0 only — a child agent has no background task pool (run the command synchronously)" }
104
+ }
105
+ const pool = ensureBgPool(parent)
106
+ const running = [...pool.values()].filter((e) => e.done !== true)
107
+ if (running.length >= BG_TASK_MAX) {
108
+ return {
109
+ error: `background task cap reached (${running.length}/${BG_TASK_MAX} running: ${running.map((e) => `bash#${e.id}`).join(", ")})` +
110
+ ` — wait for one to settle (wait_for "bash id:N done") or kill one (process action='kill' id:N)`,
111
+ }
112
+ }
113
+ const dir = bgTaskLogDir()
114
+ await cleanupOldToolResults(dir)
115
+ await mkdir(dir, { recursive: true })
116
+ // ED-5:取号 → 消费同步配对(无 await 间隙)+ 入池键守卫(同 id 二次入池 = 静默覆写 ⇒ 抛)
117
+ // ——守卫前置于 spawn:命中即抛时零副作用(无孤儿进程/无半开 log)。
118
+ const id = nextSubagentId(parent)
119
+ consumeSubagentToken(parent, id, "bash async launch", "bash")
120
+ assertPoolKeyFree(pool, id, "bash")
121
+ const logPath = join(dir, `${Date.now()}-bash-${id}.log`)
122
+ const cwd = ctx?.cwd ?? parent.cwd ?? process.cwd()
123
+ // UTF-8 码页前缀 = 同步径同款(win32 + 默认 shell);解码走 `makeDecoder`(同源)。
124
+ const effectiveCommand = process.platform === "win32" && !shell ? `chcp 65001 >nul && ${command}` : command
125
+ const child = spawn(effectiveCommand, [], {
126
+ cwd,
127
+ shell: shell ?? true,
128
+ windowsHide: true,
129
+ detached: process.platform !== "win32",
130
+ stdio: ["ignore", "pipe", "pipe"],
131
+ env: buildBashEnv(),
132
+ })
133
+ const entry = {
134
+ id, role: "bg", command, child, logPath,
135
+ status: "running", done: false, cancelled: false, discarded: false,
136
+ startedAt: Date.now(), endedAt: null, exit: null, killed: null, error: null,
137
+ report: null, tail: "", tailDropped: false, bytes: 0,
138
+ timeoutMs: null, timer: null, graceTimer: null, controller: null,
139
+ logStream: null, _settle: null, promise: null,
140
+ }
141
+ entry.promise = new Promise((res) => { entry._settle = res })
142
+ entry.logStream = createWriteStream(logPath, { flags: "a" })
143
+ entry.logStream.on("error", () => { /* log 写失败不进结算语义——尽力面 */ })
144
+ const outDecoder = makeDecoder()
145
+ const errDecoder = makeDecoder()
146
+ const appendChunk = (s) => {
147
+ if (!s) return
148
+ entry.bytes += s.length
149
+ try { entry.logStream.write(s) } catch { /* 同上——尽力面 */ }
150
+ entry.tail += s
151
+ if (entry.tail.length > TAIL_KEEP_CHARS) {
152
+ entry.tail = entry.tail.slice(entry.tail.length - TAIL_KEEP_CHARS)
153
+ entry.tailDropped = true
154
+ }
155
+ }
156
+ child.stdout.on("data", (d) => appendChunk(sanitizeOutput(outDecoder(d))))
157
+ child.stderr.on("data", (d) => appendChunk(sanitizeOutput(errDecoder(d))))
158
+ // 会话/回合信号链(#98 链结单点——interrupt 豁免内建):非 interrupt 中止 ⇒ 控制器中止 ⇒
159
+ // discard 收尾面(杀树 + 出池 + 墓碑);回合中断(Ctrl+I)⇒ 不动(F2 同款豁免)。
160
+ const ctrl = new AbortController()
161
+ entry.controller = ctrl
162
+ bindChildController(ctrl, buildChildSignal(parent, ctx))
163
+ // 显式 timeout(async 缺省无超时——本形目的):到点杀树,结算状态 `killed: timeout`。
164
+ if (Number.isFinite(timeout) && timeout > 0) {
165
+ entry.timeoutMs = timeout
166
+ entry.timer = setTimeout(() => {
167
+ entry.killed = "timeout"
168
+ killBgTree(entry)
169
+ }, timeout)
170
+ }
171
+ child.on("error", (error) => {
172
+ entry.error = error?.message ?? String(error)
173
+ finalizeBgEntry(parent, entry, { code: null, signal: null })
174
+ })
175
+ child.on("exit", (code, exitSignal) => {
176
+ if (entry.done) return
177
+ // 宽限窗(同步径同判据):孙进程握管道 ⇒ close 不来——到点按「可能仍在跑」收尾。
178
+ entry.graceTimer = setTimeout(() => {
179
+ finalizeBgEntry(parent, entry, { code, signal: exitSignal, background: true })
180
+ }, EXIT_GRACE_MS)
181
+ })
182
+ child.on("close", (code, exitSignal) => {
183
+ finalizeBgEntry(parent, entry, { code, signal: exitSignal })
184
+ })
185
+ pool.set(String(id), entry)
186
+ return { id, ack: `bash#${id} started (running) — log: ${logPath}`, entry }
187
+ }
188
+
189
+ /**
190
+ * 结算单点(close ∥ exit 宽限 ∥ spawn error 三径合流;幂等:entry.done 即返)。
191
+ * 杀/丢弃两态(报告不可达)⇒ 只落终态与唤醒;正常退出 ⇒ 停靠 pending + 唤醒挂起驱动。
192
+ */
193
+ function finalizeBgEntry(parent, entry, { code = null, signal = null, background = false } = {}) {
194
+ if (entry.done) return
195
+ entry.done = true
196
+ entry.status = "done"
197
+ entry.endedAt = Date.now()
198
+ entry.exit = { code, signal, background }
199
+ if (entry.graceTimer) { clearTimeout(entry.graceTimer); entry.graceTimer = null }
200
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null }
201
+ try { entry.logStream?.end() } catch { /* 尽力面 */ }
202
+ getAsyncPool(parent, "bg")?.delete(String(entry.id))
203
+ if (entry.cancelled === true || entry.discarded === true) {
204
+ entry._settle?.() // 定向杀 ∥ 会话中止收尾——不注入(状态/墓碑已由杀点落定)
205
+ return
206
+ }
207
+ entry.report = renderTail(entry).text
208
+ parkAsyncPending(parent, entry)
209
+ entry._settleSeq = (parent._asyncSettleSeq = (parent._asyncSettleSeq ?? 0) + 1)
210
+ entry._settle?.()
211
+ wakeAsyncWaiters(parent)
212
+ }
213
+
214
+ /** 注入面尾截(双帽 + 截尾判据:行帽/字符帽/环形缓冲丢头三径合一)。 */
215
+ function renderTail(entry) {
216
+ let text = String(entry.tail ?? "")
217
+ let truncated = entry.tailDropped === true
218
+ const lines = text.split("\n")
219
+ if (lines.length > DIGEST_TAIL_MAX_LINES) {
220
+ text = lines.slice(-DIGEST_TAIL_MAX_LINES).join("\n")
221
+ truncated = true
222
+ }
223
+ if (text.length > DIGEST_TAIL_MAX_CHARS) {
224
+ text = text.slice(-DIGEST_TAIL_MAX_CHARS)
225
+ truncated = true
226
+ }
227
+ return { text, truncated }
228
+ }
229
+
230
+ /** 注入器(digest 单点族成员——`injectAsyncResult` 按 role="bg" 分发;消费即墓碑)。 */
231
+ export async function injectBgResult(parent, entry) {
232
+ const exit = entry.exit ?? {}
233
+ const status = entry.killed === "timeout"
234
+ ? "killed: timeout"
235
+ : entry.error != null ? `spawn failed: ${entry.error}`
236
+ : exit.background ? `exit code ${exit.code} (a child process still holds the output pipe — output may be incomplete; it may still be running)`
237
+ : exit.signal ? `killed: ${exit.signal}`
238
+ : `exit code ${exit.code}`
239
+ const seconds = (Math.max(0, (entry.endedAt ?? Date.now()) - entry.startedAt) / 1000).toFixed(1)
240
+ const head = `[System reminder: background bash#${entry.id} finished — \`${entry.command}\` (${status}, ${seconds}s) — full log: ${entry.logPath}]`
241
+ const { text, truncated } = renderTail(entry)
242
+ const body = text.trim() ? escapeXml(text) : "(no output)"
243
+ const note = truncated ? `\n[... tail truncated — the full output is in the log: ${entry.logPath}]` : ""
244
+ pushReal(parent, { role: "user", content: `${head}\n${body}${note}` })
245
+ writeTombstone(parent, entry.id, entry.error != null ? "failed" : "consumed", "bg")
246
+ }
247
+
248
+ /**
249
+ * 杀单点(id 靶——`process` action='kill'):杀树 + 出池 + 墓碑(cancelled);重复 = 幂等
250
+ * (经终态墓碑返回同一确认——`cancelAsyncAdvisor` 同式)。
251
+ * @returns {{ id: string, status: "cancelled" | "error", error?: string, logPath?: string }}
252
+ */
253
+ export function killBgTask(parent, id) {
254
+ const key = String(id)
255
+ if (!key || key === "undefined" || key === "null") {
256
+ return { id: key, status: "error", error: "process kill id: requires a background bash task id (bash#N from the async ack)" }
257
+ }
258
+ const pool = getAsyncPool(parent, "bg")
259
+ const entry = bgTaskEntry(parent, key)
260
+ if (!entry) {
261
+ const tomb = tombstoneOf(parent, key)
262
+ if (tomb?.status === "cancelled" && tomb.role === "bg") return { id: key, status: "cancelled" } // 幂等确认
263
+ return { id: key, status: "error", error: `unknown background bash task id: ${key} — it has finished, was killed, or was never started` }
264
+ }
265
+ if (entry.done) return { id: key, status: "error", error: `bash#${key} has already finished — nothing to kill` }
266
+ if (entry.cancelled) return { id: key, status: "cancelled", logPath: entry.logPath } // 杀已在途——幂等
267
+ entry.cancelled = true
268
+ killBgTree(entry)
269
+ pool?.delete(key)
270
+ writeTombstone(parent, key, "cancelled", "bg")
271
+ return { id: key, status: "cancelled", logPath: entry.logPath }
272
+ }
273
+
274
+ /** 杀单点(pid 靶——`process` action='kill'):`killProcessTree` 同款(win32 `taskkill /T /F` ∥
275
+ * POSIX 组杀 + 直杀兜底);pid 形态非法 ⇒ false(调用方给显式错误)。 */
276
+ export function killBgPid(pid) {
277
+ const n = Number(pid)
278
+ if (!Number.isInteger(n) || n <= 0) return false
279
+ killProcessTree({ pid: n, kill: (sig) => process.kill(n, sig) })
280
+ return true
281
+ }
@@ -5,9 +5,9 @@
5
5
  * depth-0 在飞批定位(findInFlightBatch——D-BR21)。判定字面全部单源自 batch-skeleton.mjs
6
6
  * (SEGMENT_BY_ROLE / STATUS_WORDS / STATUS_LINE_RE / readBatchStatusLine / sectionHeaderRe /
7
7
  * batchSkeleton / TEMPLATE_PLACEHOLDERS / findPlaceholderResidue / placeholderResidueError);
8
- * 路径解析单源(resolveBatchDocPath / batchDocBases)与 #84 记账缝住 batch.mjs
9
- * 主档——**依赖单向(KD-4):skeleton ← lifecycle ← 主档**,主档把解析后的 cwd/bases/目标闭包
10
- * 传进来,本档不回 import 主档(防环)。
8
+ * 路径解析单源(`resolveBatchDocPath` / `batchDocBases` / `resolveBatchCreatePath`——住 `batch-paths.mjs`,
9
+ * 本档 import 取用)与 #84 记账缝住 batch.mjs 主档——**依赖单向(KD-4):paths ← skeleton ← lifecycle
10
+ * ← 主档**,主档把解析后的 cwd/bases/目标闭包传进来,本档不回 import 主档(防环)。
11
11
  *
12
12
  * 身份判据(D-BR17/D-BR18/D-BR21):主 agent 的工具调用 ctx 带 `depth === 0`(dispatch 装配);
13
13
  * eng 子代理 `ctx.depth > 0`;评审实例 ctx 无 depth 但工具以 `review: true` 绑定。create/close
@@ -16,12 +16,13 @@
16
16
  * 状态面走普通文档写);depth-0 的 status/path 面按 D-BR21(可选 path,缺省 = 在飞批唯一时取)。
17
17
  */
18
18
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
19
- import { dirname, isAbsolute, resolve, sep } from "node:path"
19
+ import { dirname, resolve } from "node:path"
20
20
 
21
21
  import {
22
- SEGMENT_BY_ROLE, STATUS_WORDS, STATUS_LINE_RE, readBatchStatusLine, sectionHeaderRe, batchSkeleton,
22
+ SEGMENT_BY_ROLE, STATUS_WORDS, STATUS_LINE_RE, readBatchStatusLine, sectionHasStatusLine, sectionHeaderRe, batchSkeleton,
23
23
  TEMPLATE_PLACEHOLDERS, findPlaceholderResidue, placeholderResidueError,
24
24
  } from "./batch-skeleton.mjs"
25
+ import { resolveBatchCreatePath } from "./batch-paths.mjs"
25
26
 
26
27
  /** 可读文件判据(存在且为文件——目录/缺失同判不可读;与主档同名 helper 同型——KD-4 单向
27
28
  * 依赖下各自持有,三行谓词不构成第二权威源)。 */
@@ -55,8 +56,22 @@ function assertMainAgentOnly(ctx, action, review) {
55
56
  }
56
57
  }
57
58
 
59
+ /** 递归收集目录下全部 `*.md`(#369 · KD-4:显式嵌套路径为合法 create 面 ⇒ 顶层平扫会欠计
60
+ * ⇒ 复数情形漏判、默认定位可落非预期档)。只认真目录(`dirent.isDirectory()`——symlink 不入,
61
+ * 防环);单目录读错按无候选处理(尽力面,同旧平扫)。 */
62
+ function collectMarkdownFiles(dir, out = []) {
63
+ let entries = []
64
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return out }
65
+ for (const e of entries) {
66
+ const abs = resolve(dir, e.name)
67
+ if (e.isDirectory()) collectMarkdownFiles(abs, out)
68
+ else if (e.isFile() && e.name.endsWith(".md")) out.push(abs)
69
+ }
70
+ return out
71
+ }
72
+
58
73
  /**
59
- * depth-0 目标定位(D-BR21):扫描全部基底根下 `*.md`,逐档读 §1 状态行判定 open
74
+ * depth-0 目标定位(D-BR21):扫描全部基底根下 `*.md`(**递归**——#369),逐档读 §1 状态行判定 open
60
75
  * (readBatchStatusLine === "open")。0 个 ⇒ throw(无在飞批);恰 1 个 ⇒ 返回该档绝对路径;
61
76
  * ≥2 ⇒ throw(复数在飞批——必传 path,错误消息列出候选)。
62
77
  * 基底根数组由主档传入(batchDocBases——声明面单源,恒非空);单根目录读错按无候选处理。
@@ -68,11 +83,7 @@ export function findInFlightBatch(cwd, bases) {
68
83
  void cwd
69
84
  const candidates = []
70
85
  for (const root of bases ?? []) {
71
- let entries = []
72
- try { entries = readdirSync(root) } catch { continue }
73
- for (const name of entries) {
74
- if (!name.endsWith(".md")) continue
75
- const abs = resolve(root, name)
86
+ for (const abs of collectMarkdownFiles(root)) {
76
87
  let src
77
88
  try { src = readFileSync(abs, "utf8") } catch { continue }
78
89
  if (readBatchStatusLine(src) === "open") candidates.push(abs)
@@ -87,21 +98,13 @@ export function findInFlightBatch(cwd, bases) {
87
98
  return candidates[0]
88
99
  }
89
100
 
90
- /** create 路径越界判据(fix 轮 #12):解析后须落在某一基底根之内;win32 大小写不敏感比较。 */
91
- function assertInsideBases(abs, bases, raw) {
92
- const norm = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
93
- const target = norm(abs)
94
- for (const b of bases) {
95
- const root = norm(b)
96
- if (target === root || target.startsWith(root.endsWith(sep) ? root : root + sep)) return
97
- }
98
- throw new Error(`batch: create path resolves outside the batch-record base roots — a batch record must live under the declared docRoot.batches base (fail-closed). Path: ${raw}`)
99
- }
101
+ /** create 路径越界判据已迁 `batch-paths.mjs`(`resolveBatchCreatePath` 内含——§4.15 单源)。 */
100
102
 
101
103
  /**
102
104
  * create——建档(§4.11,仅 depth-0)。六段骨架一次预齐(骨架模板单源 = batchSkeleton),
103
105
  * 建档即过 gate(§1 占位状态行含「进行中」);fail-closed:非 .md / 越基底 / 目标已存在 ⇒ throw
104
106
  * (不覆盖既有批档,BR-20);目录缺失 ⇒ mkdir -p 后落位(BR-25)。不代建台账条目。
107
+ * 路径解析 = `resolveBatchCreatePath`(§4.15 单源:候选序首个落基底内者 / 锚定串只解析项目根形)。
105
108
  * F11-B:`source` **必填**(topic 同款空拒——骨架编制行实参化;不传 = 死占位残留必被 F11-C 拒
106
109
  * ⇒ create 即拒,不留死锁);`prev` 传入值**幂等剥**「前情 = 」前缀(值规范化;默认值零变)。
107
110
  * @returns {string} 成功消息(含落盘绝对路径)
@@ -119,8 +122,7 @@ export function createBatchRecord({ args, ctx, review, cwd, bases, onWritten })
119
122
  if (!primary) {
120
123
  throw new Error("batch: no batch-record base root is available — fix PROJECT-MANIFEST.json docRoot.batches (or its default) before creating a record. Nothing was written.")
121
124
  }
122
- const abs = isAbsolute(raw) ? resolve(raw) : resolve(primary, raw.replace(/\\/g, "/"))
123
- assertInsideBases(abs, bases, raw)
125
+ const abs = resolveBatchCreatePath(cwd, raw, bases)
124
126
  if (readableFile(abs)) {
125
127
  throw new Error(`batch: create target already exists — refusing to overwrite an existing batch record (fail-closed, BR-20): ${raw}`)
126
128
  }
@@ -148,9 +150,10 @@ export function createBatchRecord({ args, ctx, review, cwd, bases, onWritten })
148
150
 
149
151
  /**
150
152
  * 段内状态行单行改写(status/close 共用——append-only 的唯一豁免,域限状态行):
151
- * 定位 `## §N` 段(sectionHeaderRe 单源),段内找 STATUS_LINE_RE 行整行替换为
152
- * `**状态行**:<value>`;缺失则段首(标题行后)插状态行 + 空行。段界外字节零变;
153
- * EOL 形态(\r\n | \n)随档保持。段标题缺失 ⇒ throw(create 是骨架唯一权威入口)。
153
+ * 定位 `## §N` 段(sectionHeaderRe 单源),段内逐行扫描 STATUS_LINE_RE 首命中行原位替换
154
+ * (行尾随命中行自身);缺失则标题行后偏移插入状态行 + 空行(插入块行尾 = 标题行自身
155
+ * 行尾——锚行后首二字节判读)。段界外字节零变(行切分 = /\r?\n/:EOL 形态无关)。
156
+ * 段标题缺失 ⇒ throw(create 是骨架唯一权威入口)。
154
157
  */
155
158
  function updateSectionStatusLine(src, seg, value) {
156
159
  const hdr = sectionHeaderRe(seg).exec(src)
@@ -161,13 +164,34 @@ function updateSectionStatusLine(src, seg, value) {
161
164
  nextRe.lastIndex = hdr.index + hdr[0].length
162
165
  const next = nextRe.exec(src)
163
166
  const endIdx = next ? next.index : src.length
164
- const eol = src.includes("\r\n") ? "\r\n" : "\n"
165
- const lines = src.slice(hdr.index, endIdx).split(eol)
166
167
  const line = `**状态行**:${value}`
167
- const idx = lines.findIndex((l) => STATUS_LINE_RE.test(l))
168
- if (idx >= 0) lines[idx] = line
169
- else lines.splice(1, 0, line, "", "")
170
- return src.slice(0, hdr.index) + lines.join(eol) + src.slice(endIdx)
168
+ const parts = src.slice(hdr.index, endIdx).split(/(\r?\n)/) // 保留分隔符(奇数下标 = 行尾)——替换只动命中行
169
+ const idx = parts.findIndex((p) => STATUS_LINE_RE.test(p))
170
+ if (idx >= 0) { parts[idx] = line } else {
171
+ const eol = parts[1] === "\r\n" ? "\r\n" : "\n" // 缺行插入块行尾 = 标题行自身行尾
172
+ if (parts.length === 1) parts.splice(1, 0, eol, line, eol, "", eol, "")
173
+ else parts.splice(2, 0, line, eol, "", eol, "", eol)
174
+ }
175
+ return src.slice(0, hdr.index) + parts.join("") + src.slice(endIdx)
176
+ }
177
+
178
+ /** §6 段体非空白内容判据(#422①——close 前闸:段头行余文(标题文本)不计入内容;
179
+ * §6 段头缺失同判空——create 是骨架唯一权威入口)。 */
180
+ function sectionSixHasContent(src) {
181
+ const hdr = sectionHeaderRe(6).exec(src)
182
+ if (!hdr) return false
183
+ const nextRe = /^## §\d/gm
184
+ nextRe.lastIndex = hdr.index + hdr[0].length
185
+ const next = nextRe.exec(src)
186
+ const body = src.slice(hdr.index + hdr[0].length, next ? next.index : src.length)
187
+ const nl = body.indexOf("\n") // 跳过段头行余文(`## §6 …` 的标题部分)
188
+ return (nl >= 0 ? body.slice(nl + 1) : "").trim() !== ""
189
+ }
190
+
191
+ /** §6 空闸拒绝句(#422①——空 ⇒ 拒 + 提示先写 §6;防「append 被拒 ⇒ close 照常 ⇒ §6 永久为空」
192
+ * 复发——2026-09-26 dispatch-sizing 批实证)。 */
193
+ function emptySectionSixError() {
194
+ return "batch: §6 收口段为空(non-empty gate)— close refuses: the closeout section (§6) has no content, and closing would freeze the record with that section permanently empty. Write the section first (append §6: the closeout / verification summary), then call close again. Nothing was written."
171
195
  }
172
196
 
173
197
  /** 冻结门(§4.9——append/status 同门;close 视为写同样过门):closed/unknown 的错误串与
@@ -178,7 +202,7 @@ function assertGateOpen(src) {
178
202
  throw new Error("batch: 已收口档不回改 — the record's §1 status line contains 「已收口」, so the record is frozen: its body is never written to again (整档冻结;改 = 新批新档). Nothing was written.")
179
203
  }
180
204
  if (gate === "unknown") {
181
- throw new Error("batch: 状态行不可解析或缺失 — the record has no §1 `**状态行**:` line whose value contains 已收口 or 进行中 (fail-closed: the write is refused as if frozen). Nothing was written.")
205
+ throw new Error("batch: 状态行不可解析或缺失 — the record has no §1 `**状态行**:` line whose value contains 已收口 or 进行中 (fail-closed: the write is refused as if frozen). Nothing was written. Do NOT close the record after a refused append — nothing landed, and closing freezes the missing section for good (失败 ⇒ 勿 close).")
182
206
  }
183
207
  }
184
208
 
@@ -286,13 +310,20 @@ export function statusBatchRecord({ args, ctx, review, pickTarget, onWritten })
286
310
  * close——收口冻结(§4.13,仅 depth-0)。§1 状态行 →「已收口 <YYYY-MM-DD>」;此后该档
287
311
  * append/status 全拒(冻结判据唯一真值 = §1 行——§4.9 零语义变)。对已收口档再 close ⇒ 拒
288
312
  * (close 本身是对冻结档的写)。不代写 §6 内容、不替代台账核销事务。
313
+ * 前提三规则(#422):① §6 非空闸(空 ⇒ 拒 + 提示先写 §6);② 拒句含「失败 ⇒ 勿 close」
314
+ * (append/status 面——见 batch.mjs 与上方 assertGateOpen);③ 无状态行档 ⇒ 自建机读位
315
+ * (updateSectionStatusLine 缺失即插——同路收口;「有行但不可解析」仍 fail-closed)。
289
316
  * @returns {string} 成功消息
290
317
  */
291
318
  export function closeBatchRecord({ args, ctx, review, pickTarget, onWritten }) {
292
319
  assertMainAgentOnly(ctx, "close", review)
293
320
  const abs = pickTarget(args?.path)
294
321
  const src = readFileSync(abs, "utf8")
295
- assertGateOpen(src)
322
+ // #422③:§1 无状态行档 ⇒ 自建机读位(下行走 updateSectionStatusLine 缺失即插——同路收口);
323
+ // 「有行但不可解析」维持 fail-closed(assertGateOpen 拒——防误冻结)。
324
+ if (readBatchStatusLine(src) !== "open" && sectionHasStatusLine(src, 1)) assertGateOpen(src)
325
+ // #422①:§6 非空闸(空 ⇒ 拒 + 提示先写 §6)——防「append 被拒 ⇒ close 照常 ⇒ §6 永久为空」复发。
326
+ if (!sectionSixHasContent(src)) throw new Error(emptySectionSixError())
296
327
  const date = todayLocal()
297
328
  const written = updateSectionStatusLine(src, 1, `已收口 ${date}`)
298
329
  writeFileSync(abs, written)
@@ -0,0 +1,186 @@
1
+ /**
2
+ * agent-tools/batch-paths.mjs — 批次档相对路径解析**单源**(`BATCH-RECORD.md` §4.15 · 台账 #287 · 2026-09-25 批)。
3
+ *
4
+ * 四处调用点全部经本档:create(`resolveBatchCreatePath`)· append / status / close 与评审门
5
+ * (`resolveBatchDocPath`——读面抛错形)· spawn 门(`resolveBatchReadPath`——读面非抛形)。
6
+ *
7
+ * 契约(§4.15 逐字):**候选序** = cwd → 项目根 → 逐基底(基底 = `batchDocBases` 声明面单源);
8
+ * **判据分野** = 读面取首个**可读**文件 / create 取首个**落基底根内**者;**锚定防嵌套** = 串以任一基底的
9
+ * 「项目根相对前缀」打头且前缀后为**路径段边界** ⇒ 只解析项目根形(不二次拼接;不落基底内 ⇒ fail-closed);
10
+ * **歧义锚**(条 5 · 2026-10-02 · #828)= create 按目标所属项目落基底 ∥ 无所属显式拒;读面候选腿追加;
11
+ * 在飞 ∥ 写门基底集 = 候选并集(`batchDocBases`);**零变面** = 绝对路径照旧、三个错误文案逐字、
12
+ * 「参数在 + 路径可读」判据句、`ok` / `none` 两态。
13
+ *
14
+ * 依赖(KD-4 单向):本档为**叶档**——只 import `node:*` 与 `../manifest.mjs`;主档 re-export 保既有面。
15
+ */
16
+ import { existsSync, statSync } from "node:fs"
17
+ import { isAbsolute, relative, resolve, sep } from "node:path"
18
+
19
+ import { docRootBase, docRootPaths, owningProject, projectRootView, readManifest, resolveProjectRoot } from "../manifest.mjs"
20
+
21
+ /** 可读文件判据(存在且为文件——目录 / 缺失同判不可读)。 */
22
+ function readableFile(abs) {
23
+ try { return existsSync(abs) && statSync(abs).isFile() } catch { return false }
24
+ }
25
+
26
+ /** 歧义锚候选(判定单点 = `projectRootView`——`ambiguous` 态 ⇒ 候选全列**按名排序**;余者 null)。 */
27
+ function ambiguousCandidates(cwd) {
28
+ const view = projectRootView(cwd ?? process.cwd())
29
+ return view.state === "ambiguous" ? view.candidates : null
30
+ }
31
+
32
+ /** 路径同一性比较(win32 大小写不敏感——同越基底判据口径)。 */
33
+ function samePath(a, b) {
34
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b
35
+ }
36
+
37
+ /** 「项目根」取根单源(§4.15 条 1——归属 ∨ 发现,无 manifest ⇒ `resolve(cwd ?? ".")` 兜底且**单值**):
38
+ * 同式 = 台账库键 / 写门指针基准的同一子表达式(`design/LEDGER.md` §6.1 口径 2)。 */
39
+ export function batchProjectRoot(cwd) {
40
+ return resolveProjectRoot(cwd) ?? resolve(cwd ?? ".")
41
+ }
42
+
43
+ /** manifest **声明基底**(声明面;manifest 缺失 / 非法 / 读错 ⇒ 空集——v1 单基底语义零变)。 */
44
+ function declaredBases(cwd) {
45
+ const base = cwd ?? process.cwd()
46
+ let man = { ok: false }
47
+ try { man = readManifest(base) } catch { /* 读错 → 按无 manifest 处理(回退默认基底) */ }
48
+ return man.ok ? docRootPaths(man.manifest?.docRoot?.batches, base) : []
49
+ }
50
+
51
+ /** 批次档基底根数组(**声明面单源**——create / 在飞扫描 / 锚定判据共用):manifest `docRoot.batches`
52
+ * (串 | 多根数组)→ 声明基底;缺失 / 非法 ⇒ 回退默认基底 `"docs/batches"`(恒返回非空数组)。
53
+ * **歧义锚**(条 5)⇒ 候选项目基底**并集**(候选按名排序、并集保序)——在飞扫描 / 写门基底集同取此值。
54
+ * @param {string} cwd
55
+ * @returns {string[]} */
56
+ export function batchDocBases(cwd) {
57
+ const amb = ambiguousCandidates(cwd)
58
+ if (amb) {
59
+ const union = []
60
+ for (const c of amb) for (const b of batchDocBases(c)) if (!union.includes(b)) union.push(b)
61
+ return union
62
+ }
63
+ const roots = declaredBases(cwd)
64
+ return roots.length ? roots : [resolve(docRootBase(cwd), "docs/batches")]
65
+ }
66
+
67
+ /** 基底的项目根相对前缀(归一 `/`;基底不落项目根下 / 与前缀重合 ⇒ 不产前缀——前缀不匹配者不做启发式改写)。 */
68
+ function basePrefixes(cwd, bases) {
69
+ const root = batchProjectRoot(cwd)
70
+ const out = []
71
+ for (const b of bases) {
72
+ const rel = relative(root, resolve(b)).replace(/\\/g, "/")
73
+ if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) continue
74
+ out.push(rel)
75
+ }
76
+ return out
77
+ }
78
+
79
+ /** 锚定判据(**按路径段**——前缀后须为 `/` 或串尾;`docs/batches-old/<x>.md` 不锚定,BR-35)。 */
80
+ function anchoredPrefix(p, prefixes) {
81
+ for (const pre of prefixes) if (p === pre || p.startsWith(pre + "/")) return pre
82
+ return null
83
+ }
84
+
85
+ /** 越基底判据(create 面):解析后须落在某一基底根之内;win32 大小写不敏感比较。 */
86
+ function insideBases(abs, bases) {
87
+ const norm = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
88
+ const target = norm(abs)
89
+ return bases.some((b) => {
90
+ const root = norm(b)
91
+ return target === root || target.startsWith(root.endsWith(sep) ? root : root + sep)
92
+ })
93
+ }
94
+
95
+ /** create 越基底(原文案逐字——零变面)。 */
96
+ const outsideBasesError = (raw) => new Error(`batch: create path resolves outside the batch-record base roots — a batch record must live under the declared docRoot.batches base (fail-closed). Path: ${raw}`)
97
+ /** create 锚定但落基底外(§4.15 新文案逐字——不二次拼接)。 */
98
+ const anchoredEscapeError = (raw) => new Error(`batch: create path is anchored at a batch base root but does not resolve under the declared docRoot.batches root(s) — refusing to nest it (fail-closed). Path: ${raw}`)
99
+ /** 歧义锚 create 拒面(**文案逐字** = `BATCH-RECORD.md` §4.15 条 5——机检锚 `ambiguous session anchor`)。 */
100
+ const ambiguousCreateError = (raw, candidates) => new Error(`batch: ambiguous session anchor — create target "${raw}" belongs to no candidate project; pass an explicit project path (e.g. "${candidates[0]}/docs/batches/<file>.md"). Candidates: ${candidates.join(", ")}`)
101
+
102
+ /** 歧义锚读面候选腿(条 5):逐候选项目——项目根形(`resolve(candidate, p)`)∥ 声明基底形
103
+ * (候选按名排序、并集保序——「首个可读」在此即为确定判读)。 */
104
+ function ambiguousReadLegs(cwd, p) {
105
+ const amb = ambiguousCandidates(cwd)
106
+ if (!amb) return []
107
+ const out = []
108
+ for (const c of amb) {
109
+ out.push(resolve(c, p))
110
+ for (const b of declaredBases(c)) out.push(resolve(b, p))
111
+ }
112
+ return out
113
+ }
114
+
115
+ /**
116
+ * **读面**解析(append / status / close 与评审门 / spawn 门共用):首个**可读**候选胜;无 ⇒ null
117
+ * (调用方各自抛既有文案)。锚定串只解析项目根形(跳过 ①③——已含基底相对前缀者不再二次拼接)。
118
+ * @param {string} cwd 会话锚(缺省 → 进程 cwd)
119
+ * @param {string} given 参数原值(相对 / 绝对、`\` 与 `/` 混写均可)
120
+ * @returns {string|null} 可读文件的绝对路径 / null
121
+ */
122
+ export function resolveBatchReadPath(cwd, given) {
123
+ const base = cwd ?? process.cwd()
124
+ const raw = typeof given === "string" ? given.trim() : ""
125
+ if (!raw) return null
126
+ const p = raw.replace(/\\/g, "/")
127
+ if (anchoredPrefix(p, basePrefixes(base, batchDocBases(base)))) {
128
+ const abs = resolve(batchProjectRoot(base), p)
129
+ return readableFile(abs) ? abs : null
130
+ }
131
+ const candidates = [resolve(base, p), resolve(batchProjectRoot(base), p), ...declaredBases(base).map((b) => resolve(b, p)), ...ambiguousReadLegs(base, p)]
132
+ for (const abs of candidates) if (readableFile(abs)) return abs
133
+ return null
134
+ }
135
+
136
+ /**
137
+ * 批次档路径门禁(评审侧 `BATCH-RECORD.md` §4.2 口径 = **「若传则须可读」**):空 / 非字符串 / 不可读 → throw。
138
+ * 非空且可读 → 返回绝对路径(`\` 归一——照 `files` / `batchDoc` spawn 门先例)。错误文案逐字零变(零回归硬约束)。
139
+ */
140
+ export function resolveBatchDocPath(cwd, given) {
141
+ const base = cwd ?? process.cwd()
142
+ const raw = typeof given === "string" ? given.trim() : ""
143
+ if (!raw) {
144
+ throw new Error("batchDoc must be a non-empty path to the batch record — pass the batch record currently in flight, or omit the parameter entirely when no batch record is in flight.")
145
+ }
146
+ const abs = resolveBatchReadPath(base, raw)
147
+ if (abs) return abs
148
+ throw new Error(`batchDoc is not a readable file: ${raw} — pass the path of the batch record currently in flight (a path that resolves to an existing file), or omit the parameter when no batch record is in flight.`)
149
+ }
150
+
151
+ /**
152
+ * **create 面**解析(§4.15 条 2/3):绝对路径照旧取用(仍过越基底判据);相对路径 = 候选序中首个**落基底
153
+ * 根内**者(新档不存在——可读性无判别力);锚定串只解析项目根形,不落基底内 ⇒ fail-closed 新文案。
154
+ * **歧义锚**(条 5):目标所属项目(`owningProject`)∈ 锚候选集 ⇒ 基底 = 该项目声明面(落其基底内;
155
+ * 越出 ⇒ 既有 fail-closed);无所属 ⇒ **显式拒**(列候选 + 显式路径指引——不静默落锚)。
156
+ * @param {string} cwd 会话锚
157
+ * @param {string} given create `path` 原值
158
+ * @param {string[]} bases 已解析的基底根数组(`batchDocBases` 单源;调用方传入以免重复读档)
159
+ * @returns {string} 落位绝对路径
160
+ */
161
+ export function resolveBatchCreatePath(cwd, given, bases) {
162
+ const base = cwd ?? process.cwd()
163
+ const raw = String(given ?? "").trim()
164
+ const p = raw.replace(/\\/g, "/")
165
+ let roots = bases
166
+ const amb = ambiguousCandidates(base)
167
+ if (amb) {
168
+ const owner = owningProject(isAbsolute(raw) ? resolve(raw) : resolve(base, p))
169
+ if (!owner || !amb.some((c) => samePath(c, owner))) throw ambiguousCreateError(raw, amb)
170
+ roots = batchDocBases(owner)
171
+ }
172
+ if (isAbsolute(raw)) {
173
+ const abs = resolve(raw)
174
+ if (!insideBases(abs, roots)) throw outsideBasesError(raw)
175
+ return abs
176
+ }
177
+ if (anchoredPrefix(p, basePrefixes(base, roots))) {
178
+ const abs = resolve(batchProjectRoot(base), p)
179
+ if (!insideBases(abs, roots)) throw anchoredEscapeError(raw)
180
+ return abs
181
+ }
182
+ for (const abs of [resolve(base, p), resolve(batchProjectRoot(base), p), ...roots.map((b) => resolve(b, p))]) {
183
+ if (insideBases(abs, roots)) return abs
184
+ }
185
+ throw outsideBasesError(raw)
186
+ }