@thincoder/core 0.10.13 → 0.11.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 (164) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/advisor/messages.mjs +4 -2
  3. package/advisor/notice.mjs +1 -1
  4. package/advisor/project-context.mjs +121 -11
  5. package/agent/completion.mjs +13 -1
  6. package/agent/digest-account.mjs +73 -46
  7. package/agent/dispatch-run.mjs +1 -1
  8. package/agent/dispatch.mjs +29 -21
  9. package/agent/family-tools.mjs +9 -5
  10. package/agent/helpers.mjs +1 -94
  11. package/agent/offload.mjs +88 -0
  12. package/agent/record-results.mjs +1 -6
  13. package/agent/run-stages.mjs +69 -48
  14. package/agent/run-start.mjs +8 -8
  15. package/agent/setup.mjs +3 -11
  16. package/agent/suspension.mjs +1 -1
  17. package/agent/turn-loop.mjs +32 -3
  18. package/agent/write-face.mjs +99 -0
  19. package/agent/write-gate.mjs +354 -5
  20. package/agent-tools/advisor.mjs +127 -11
  21. package/agent-tools/async-settle.mjs +2 -2
  22. package/agent-tools/bash-async.mjs +8 -2
  23. package/agent-tools/batch-closeout.mjs +206 -0
  24. package/agent-tools/batch-gate.mjs +287 -73
  25. package/agent-tools/batch-ledger-bind.mjs +183 -0
  26. package/agent-tools/batch-ledger-link.mjs +73 -0
  27. package/agent-tools/batch-lifecycle.mjs +113 -367
  28. package/agent-tools/batch-paths.mjs +45 -40
  29. package/agent-tools/batch-read.mjs +1 -1
  30. package/agent-tools/batch-reopen.mjs +142 -0
  31. package/agent-tools/batch-skeleton.mjs +275 -80
  32. package/agent-tools/batch.mjs +243 -50
  33. package/agent-tools/context.mjs +6 -3
  34. package/agent-tools/design-slots.mjs +24 -24
  35. package/agent-tools/design-token.mjs +96 -15
  36. package/agent-tools/doc-check.mjs +18 -2
  37. package/agent-tools/eng.mjs +1 -1
  38. package/agent-tools/escalate-async.mjs +3 -3
  39. package/agent-tools/manifest.mjs +8 -5
  40. package/agent-tools/read-history.mjs +5 -5
  41. package/agent-tools/review-facts.mjs +31 -13
  42. package/agent-tools/spawn-gates.mjs +10 -14
  43. package/agent-tools/ssh-session.mjs +120 -111
  44. package/agent-tools/ssh-transfer.mjs +210 -0
  45. package/agent-tools/ssh-transport.mjs +277 -46
  46. package/agent-tools/subagent-actions-query.mjs +1 -1
  47. package/agent-tools/subagent-actions.mjs +1 -1
  48. package/agent-tools/subagent-async.mjs +3 -2
  49. package/agent-tools/subagent-scheduler.mjs +43 -9
  50. package/agent-tools/subagent-spawn.mjs +68 -9
  51. package/agent-tools/subagent.mjs +16 -14
  52. package/client-identity.mjs +22 -6
  53. package/config-presets.mjs +5 -3
  54. package/context.mjs +34 -3
  55. package/declaration.mjs +2 -2
  56. package/doc-check/anchors.mjs +128 -28
  57. package/doc-check/index.mjs +68 -15
  58. package/doc-check/line-refs.mjs +112 -0
  59. package/doc-check/paragraph.mjs +18 -5
  60. package/explore-distill.mjs +1 -1
  61. package/i18n.mjs +1 -1
  62. package/index-discover.mjs +4 -1
  63. package/ledger-close-proof.mjs +19 -37
  64. package/ledger-cmd.mjs +71 -195
  65. package/ledger-db.mjs +29 -28
  66. package/ledger-migrate.mjs +4 -4
  67. package/ledger-read.mjs +10 -8
  68. package/ledger-surface.mjs +1 -1
  69. package/ledger-tools.mjs +6 -6
  70. package/ledger-variant-notice.mjs +1 -1
  71. package/ledger-write-ops.mjs +373 -0
  72. package/ledger.mjs +2 -2
  73. package/manifest-discovery.mjs +2 -2
  74. package/manifest-schema.mjs +4 -0
  75. package/memory/memory-tool.mjs +5 -2
  76. package/memory/schema.mjs +5 -26
  77. package/package.json +1 -1
  78. package/path-resolve.mjs +217 -0
  79. package/permission.mjs +4 -0
  80. package/prompt-overlays.mjs +5 -0
  81. package/prompts/advisor-design.md +9 -6
  82. package/prompts/advisor-round1.md +4 -3
  83. package/prompts/advisor-round2.md +6 -5
  84. package/prompts/advisor-round3.md +6 -5
  85. package/prompts/common.md +23 -10
  86. package/prompts/consult-base.md +2 -1
  87. package/prompts/discipline-engineering.md +29 -48
  88. package/prompts/discipline-normal.md +10 -5
  89. package/prompts/persona-eng-coder.md +8 -0
  90. package/prompts/persona-eng-designer.md +22 -12
  91. package/prompts/persona-engineering.md +81 -68
  92. package/prompts/persona-plan.md +2 -1
  93. package/prompts/persona-tester.md +55 -0
  94. package/provider/core.mjs +2 -2
  95. package/provider/google.mjs +15 -5
  96. package/provider/rate.mjs +11 -3
  97. package/provider/sse.mjs +15 -4
  98. package/session-identity.mjs +21 -0
  99. package/session-lifecycle.mjs +9 -4
  100. package/session-slots.mjs +9 -14
  101. package/session.mjs +5 -0
  102. package/skip-dirs.mjs +48 -0
  103. package/team-ledger.mjs +4 -4
  104. package/text-budget.mjs +4 -0
  105. package/token-window.mjs +90 -14
  106. package/tool-docs/advisor.md +2 -2
  107. package/tool-docs/apply_patch.md +2 -0
  108. package/tool-docs/bash.md +4 -0
  109. package/tool-docs/batch.md +85 -1
  110. package/tool-docs/browser.md +3 -1
  111. package/tool-docs/code_search.md +3 -1
  112. package/tool-docs/consult_start.md +6 -2
  113. package/tool-docs/consult_stop.md +3 -1
  114. package/tool-docs/delete.md +3 -0
  115. package/tool-docs/doc_check.md +43 -8
  116. package/tool-docs/doc_search.md +5 -1
  117. package/tool-docs/edit.md +22 -1
  118. package/tool-docs/execute.md +7 -1
  119. package/tool-docs/fetch.md +3 -1
  120. package/tool-docs/file_ops.md +3 -1
  121. package/tool-docs/git.md +9 -1
  122. package/tool-docs/goal.md +2 -0
  123. package/tool-docs/grep.md +1 -1
  124. package/tool-docs/hashline_edit.md +2 -0
  125. package/tool-docs/insert_after.md +3 -1
  126. package/tool-docs/ledger.md +5 -5
  127. package/tool-docs/lint.md +1 -0
  128. package/tool-docs/manifest-read.md +2 -1
  129. package/tool-docs/manifest.md +9 -2
  130. package/tool-docs/memory.md +11 -1
  131. package/tool-docs/notify_parent.md +1 -1
  132. package/tool-docs/plan.md +2 -0
  133. package/tool-docs/question.md +1 -1
  134. package/tool-docs/read_history.md +15 -3
  135. package/tool-docs/read_image.md +3 -1
  136. package/tool-docs/recent_changes.md +3 -1
  137. package/tool-docs/repo_outline.md +3 -1
  138. package/tool-docs/settings.md +10 -2
  139. package/tool-docs/skill.md +2 -0
  140. package/tool-docs/ssh.md +30 -17
  141. package/tool-docs/subagent.md +50 -13
  142. package/tool-docs/task.md +2 -0
  143. package/tool-docs/team_ledger.md +1 -1
  144. package/tool-docs/timer.md +7 -0
  145. package/tool-docs/verify.md +13 -1
  146. package/tool-docs/websearch.md +3 -1
  147. package/tool-docs/write.md +2 -0
  148. package/tools/bash.mjs +12 -2
  149. package/tools/edit-batch.mjs +40 -33
  150. package/tools/edit-diff.mjs +69 -1
  151. package/tools/execute.mjs +10 -4
  152. package/tools/file.mjs +17 -27
  153. package/tools/git.mjs +27 -1
  154. package/tools/ops.mjs +2 -1
  155. package/tools/patch.mjs +12 -3
  156. package/tools/process-doc-mark.mjs +139 -0
  157. package/tools/search.mjs +15 -6
  158. package/tools/shared.mjs +24 -1
  159. package/tools/ssh.mjs +48 -7
  160. package/tools/tree.mjs +4 -1
  161. package/tools/write-path.mjs +64 -3
  162. package/traces/trace-store.mjs +4 -3
  163. package/agent/light-round.mjs +0 -63
  164. package/agent/unbooked-pen.mjs +0 -62
package/agent/helpers.mjs CHANGED
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * agent/helpers.mjs — Agent utility functions and constants
3
3
  */
4
- import { configDir } from "../config.mjs"
5
4
  import { readFileSync, readdirSync } from "node:fs"
6
5
  import { homedir } from "node:os"
7
- import { writeFile, mkdir, readdir, stat, unlink } from "node:fs/promises"
8
6
  import { join } from "node:path"
9
7
  import { execFile } from "node:child_process"
10
8
  import { promisify } from "node:util"
9
+ export { offloadToolResult, cleanupOldToolResults, TMP_RETENTION_MS } from "./offload.mjs"
11
10
 
12
11
  /** Single source for the AUTO-mode reminder (was duplicated in agent.mjs +
13
12
  * setup.mjs — consult P2, 2026-08-30). */
@@ -33,52 +32,6 @@ export const REPORT_CONTINUATION =
33
32
  "3. How you verified (tests run, commands executed, with results)\n" +
34
33
  "4. Anything left undone or worth follow-up"
35
34
 
36
- const TOOL_RESULT_OFFLOAD_LIMIT = 64 * 1024 // 65536 chars — offload only above 64K (2026-08-24)
37
- const TOOL_RESULT_PREVIEW = 64 * 1024 // total preview budget: head + middle note + tail ≤ 65536 (aligns with CLI/VS Code webview)
38
- const TOOL_RESULT_PREVIEW_HEAD = 16 * 1024 // head slice preserved (2026-09-04 §5 — dual-end preview)
39
- const TOOL_RESULT_PREVIEW_TAIL = 48 * 1024 // nominal tail slice (results/errors/stats live here — actual tail = budget remainder, see buildDualEndPreview)
40
-
41
- /** UTF-16 安全截断(2026-09-02 deepseek 400 根因):slice(0, N) 按码元切会把 emoji 代理对切成孤立
42
- * 高代理(如 🔴=U+D83D+DD34 只剩 D83D)——deepseek 解析器严格 UTF-16 报 400
43
- * "unexpected end of hex escape"。截断点落在高代理上时向前收一个码元。
44
- * 与 setup.mjs 的 safeSliceUTF16 同语义(两处独立实现——escape.mjs 的 sanitizeLoneSurrogates 是发送兜底,此处是源头)。 */
45
- function safeSliceUTF16(text, max) {
46
- if (text.length <= max) return text
47
- const cp = text.charCodeAt(max - 1)
48
- if (cp >= 0xd800 && cp <= 0xdbff) return text.slice(0, max - 1)
49
- return text.slice(0, max)
50
- }
51
-
52
- /** UTF-16 safe END slice (2026-09-04 §5 dual-end preview — review #5: both boundaries must not split
53
- * a surrogate pair). Same rule as safeSliceUTF16, mirrored: if the slice START lands on a LOW
54
- * surrogate (DC00-DFFF — the second half of a pair whose high half sits just before the boundary),
55
- * advance one code unit so the slice never begins with an orphan low surrogate. */
56
- function safeSliceUTF16End(text, max) {
57
- if (text.length <= max) return text
58
- const start = text.length - max
59
- const cp = text.charCodeAt(start)
60
- if (cp >= 0xdc00 && cp <= 0xdfff) return text.slice(start + 1)
61
- return text.slice(start)
62
- }
63
-
64
- /** Dual-end preview (design §5 D-4.1): head + middle-omitted note + tail — the tail carries
65
- * results/errors/stats that a pure-head truncation would cut off.
66
- * Budget (round1 review #2, fixed): head + note + tail ≤ TOOL_RESULT_PREVIEW (65536) — tail is
67
- * computed from constants (tail = TOOL_RESULT_PREVIEW − head − noteLen), never hardcoded.
68
- * The note length depends on the omitted digit count; text.length's digit count is an upper bound
69
- * for omitted (< text.length), so budgeting with it keeps the total ≤ 65536 while the printed
70
- * note reports the actual omitted count. Both boundaries run surrogate-safe slices (review #5). */
71
- function buildDualEndPreview(text) {
72
- const head = safeSliceUTF16(text, TOOL_RESULT_PREVIEW_HEAD)
73
- const noteFn = (omitted) => `\n\n… [middle omitted: ${omitted} chars] …\n\n`
74
- const tailLen = Math.min(TOOL_RESULT_PREVIEW_TAIL, TOOL_RESULT_PREVIEW - TOOL_RESULT_PREVIEW_HEAD - noteFn(text.length).length)
75
- const tail = safeSliceUTF16End(text, tailLen)
76
- return head + noteFn(Math.max(0, text.length - head.length - tail.length)) + tail
77
- }
78
-
79
- /** Offload-dir write-time self-cleanup retention window (2026-08-21): files older than 3 days are deleted on the next offload. */
80
- export const TMP_RETENTION_MS = 3 * 24 * 3600 * 1000
81
-
82
35
  const GIT_TIMEOUT_MS = 5000
83
36
  const MAX_GIT_CHANGES_DISPLAY = 20
84
37
 
@@ -127,52 +80,6 @@ export function tryCanonicalize(name, args) {
127
80
  try { return name + ":" + JSON.stringify(JSON.parse(args)) } catch { return name + ":" + args }
128
81
  }
129
82
 
130
- /**
131
- * Best-effort write-time self-cleanup: delete files in dir whose mtime exceeds TMP_RETENTION_MS.
132
- * Subdirectories are never touched; every failure is silent — cleanup must not affect offload.
133
- */
134
- export async function cleanupOldToolResults(dir) {
135
- const now = Date.now()
136
- let entries
137
- try {
138
- entries = await readdir(dir, { withFileTypes: true })
139
- } catch {
140
- return // dir missing or unreadable → nothing to clean
141
- }
142
- for (const entry of entries) {
143
- if (!entry.isFile()) continue // subdirectories untouched
144
- try {
145
- const st = await stat(join(dir, entry.name))
146
- if (now - st.mtimeMs > TMP_RETENTION_MS) await unlink(join(dir, entry.name))
147
- } catch {
148
- /* entry vanished concurrently or I/O error — best effort, keep going */
149
- }
150
- }
151
- }
152
-
153
- /** Offload a tool result / report to disk (§2.2): `force` = write regardless of size (async
154
- * subagent reports must survive the parent turn). Rendering splits on the SAME constant as the old
155
- * gate: ≤ TOOL_RESULT_OFFLOAD_LIMIT ⇒ body once + path (never dual-end — a short body would come
156
- * out twice with `middle omitted: 0 chars`); > ⇒ dual-end preview + path. Writes self-clean first. */
157
- export async function offloadToolResult(text, callId, dir = join(configDir, "tool-results"), { force = false } = {}) {
158
- const overLimit = text.length > TOOL_RESULT_OFFLOAD_LIMIT
159
- if (!force && !overLimit) return text
160
- try {
161
- await cleanupOldToolResults(dir)
162
- await mkdir(dir, { recursive: true })
163
- const file = join(dir, `${Date.now()}-${String(callId).replace(/[^a-zA-Z0-9_-]/g, "_")}.log`)
164
- await writeFile(file, text, "utf8")
165
- if (!overLimit) return `${text}\n\n[... full content saved to: ${file}\nPage through it with the read tool (offset/limit) if you need the full text later.]`
166
- return buildDualEndPreview(text) + `\n\n[... output too large (${text.length} chars total), full content saved to: ${file}\n` +
167
- `Page through it with the read tool (offset/limit) or sed -n 'START,ENDp' — do NOT re-run the tool blindly.]`
168
- } catch {
169
- // 写盘失败不吞报告:>LIMIT 走双端预览 + 原文案;≤LIMIT 正文整段在前(不谎称 truncated)
170
- const body = overLimit ? buildDualEndPreview(text) : text
171
- return body + (overLimit ? `\n\n[... truncated: ${text.length} chars total, offload to disk failed]`
172
- : `\n\n[... full text kept inline above (${text.length} chars), offload to disk failed]`)
173
- }
174
- }
175
-
176
83
  // 失败冷却 v1(用户裁——GIT-ASYNC L21):真失败/超时后 30s 内跳过该 cwd 的收集——
177
84
  // 病态 repo 周期拖慢变一次性。VSC setup-reminders.mjs 同构镜像。
178
85
  const GIT_FAILURE_COOLDOWN_MS = 30_000
@@ -0,0 +1,88 @@
1
+ /**
2
+ * agent/offload.mjs — 一次「把超长正文落到盘上、把注入体压成预览 + 路径」:门限常量 → 两端安全切片 → 预览拼装 → 落盘 → 写时自清理。
3
+ */
4
+ import { configDir } from "../config.mjs"
5
+ import { writeFile, mkdir, readdir, stat, unlink } from "node:fs/promises"
6
+ import { join } from "node:path"
7
+ import { safeSliceUTF16 } from "../text-budget.mjs"
8
+
9
+ const TOOL_RESULT_OFFLOAD_LIMIT = 64 * 1024 // 65536 chars — offload only above 64K (2026-08-24)
10
+ const TOOL_RESULT_PREVIEW = 64 * 1024 // total preview budget: head + middle note + tail ≤ 65536 (aligns with CLI/VS Code webview)
11
+ const TOOL_RESULT_PREVIEW_HEAD = 16 * 1024 // head slice preserved (2026-09-04 §5 — dual-end preview)
12
+ const TOOL_RESULT_PREVIEW_TAIL = 48 * 1024 // nominal tail slice (results/errors/stats live here — actual tail = budget remainder, see buildDualEndPreview)
13
+
14
+ /** UTF-16 safe END slice (2026-09-04 §5 dual-end preview — review #5: both boundaries must not split
15
+ * a surrogate pair). Same rule as safeSliceUTF16, mirrored: if the slice START lands on a LOW
16
+ * surrogate (DC00-DFFF — the second half of a pair whose high half sits just before the boundary),
17
+ * advance one code unit so the slice never begins with an orphan low surrogate. */
18
+ function safeSliceUTF16End(text, max) {
19
+ if (text.length <= max) return text
20
+ const start = text.length - max
21
+ const cp = text.charCodeAt(start)
22
+ if (cp >= 0xdc00 && cp <= 0xdfff) return text.slice(start + 1)
23
+ return text.slice(start)
24
+ }
25
+
26
+ /** Dual-end preview (design §5 D-4.1): head + middle-omitted note + tail — the tail carries
27
+ * results/errors/stats that a pure-head truncation would cut off.
28
+ * Budget (round1 review #2, fixed): head + note + tail ≤ TOOL_RESULT_PREVIEW (65536) — tail is
29
+ * computed from constants (tail = TOOL_RESULT_PREVIEW − head − noteLen), never hardcoded.
30
+ * The note length depends on the omitted digit count; text.length's digit count is an upper bound
31
+ * for omitted (< text.length), so budgeting with it keeps the total ≤ 65536 while the printed
32
+ * note reports the actual omitted count. Both boundaries run surrogate-safe slices (review #5). */
33
+ function buildDualEndPreview(text) {
34
+ const head = safeSliceUTF16(text, TOOL_RESULT_PREVIEW_HEAD)
35
+ const noteFn = (omitted) => `\n\n… [middle omitted: ${omitted} chars] …\n\n`
36
+ const tailLen = Math.min(TOOL_RESULT_PREVIEW_TAIL, TOOL_RESULT_PREVIEW - TOOL_RESULT_PREVIEW_HEAD - noteFn(text.length).length)
37
+ const tail = safeSliceUTF16End(text, tailLen)
38
+ return head + noteFn(Math.max(0, text.length - head.length - tail.length)) + tail
39
+ }
40
+
41
+ /** Offload-dir write-time self-cleanup retention window (2026-08-21): files older than 3 days are deleted on the next offload. */
42
+ export const TMP_RETENTION_MS = 3 * 24 * 3600 * 1000
43
+
44
+ /**
45
+ * Best-effort write-time self-cleanup: delete files in dir whose mtime exceeds TMP_RETENTION_MS.
46
+ * Subdirectories are never touched; every failure is silent — cleanup must not affect offload.
47
+ */
48
+ export async function cleanupOldToolResults(dir) {
49
+ const now = Date.now()
50
+ let entries
51
+ try {
52
+ entries = await readdir(dir, { withFileTypes: true })
53
+ } catch {
54
+ return // dir missing or unreadable → nothing to clean
55
+ }
56
+ for (const entry of entries) {
57
+ if (!entry.isFile()) continue // subdirectories untouched
58
+ try {
59
+ const st = await stat(join(dir, entry.name))
60
+ if (now - st.mtimeMs > TMP_RETENTION_MS) await unlink(join(dir, entry.name))
61
+ } catch {
62
+ /* entry vanished concurrently or I/O error — best effort, keep going */
63
+ }
64
+ }
65
+ }
66
+
67
+ /** Offload a tool result / report to disk (§2.2): `force` = write regardless of size (async
68
+ * subagent reports must survive the parent turn). Rendering splits on the SAME constant as the old
69
+ * gate: ≤ TOOL_RESULT_OFFLOAD_LIMIT ⇒ body once + path (never dual-end — a short body would come
70
+ * out twice with `middle omitted: 0 chars`); > ⇒ dual-end preview + path. Writes self-clean first. */
71
+ export async function offloadToolResult(text, callId, dir = join(configDir, "tool-results"), { force = false } = {}) {
72
+ const overLimit = text.length > TOOL_RESULT_OFFLOAD_LIMIT
73
+ if (!force && !overLimit) return text
74
+ try {
75
+ await cleanupOldToolResults(dir)
76
+ await mkdir(dir, { recursive: true })
77
+ const file = join(dir, `${Date.now()}-${String(callId).replace(/[^a-zA-Z0-9_-]/g, "_")}.log`)
78
+ await writeFile(file, text, "utf8")
79
+ if (!overLimit) return `${text}\n\n[... full content saved to: ${file}\nPage through it with the read tool (offset/limit) if you need the full text later.]`
80
+ return buildDualEndPreview(text) + `\n\n[... output too large (${text.length} chars total), full content saved to: ${file}\n` +
81
+ `Page through it with the read tool (offset/limit) or sed -n 'START,ENDp' — do NOT re-run the tool blindly.]`
82
+ } catch {
83
+ // 写盘失败不吞报告:>LIMIT 走双端预览 + 原文案;≤LIMIT 正文整段在前(不谎称 truncated)
84
+ const body = overLimit ? buildDualEndPreview(text) : text
85
+ return body + (overLimit ? `\n\n[... truncated: ${text.length} chars total, offload to disk failed]`
86
+ : `\n\n[... full text kept inline above (${text.length} chars), offload to disk failed]`)
87
+ }
88
+ }
@@ -26,8 +26,6 @@
26
26
  import { pushReal } from "../context.mjs"
27
27
  import { specForModel } from "../config.mjs"
28
28
  import { FILE_MUTATORS, toolTouchPaths } from "./helpers.mjs"
29
- // F-LC6(LIGHT-CHANNEL §2.9 · 台账 #1753):无账之笔提醒叶(谓词 + 文案 + 闩单源)。
30
- import { unbookedPenNotice } from "./unbooked-pen.mjs"
31
29
  import { capHistoryImages } from "../provider/normalize.mjs"
32
30
  import { resolve } from "node:path"
33
31
  import { advisorRuns, stripApprovedSuffix } from "../agent-tools/advisor-async.mjs"
@@ -84,10 +82,7 @@ export async function recordToolResults(agent, toolByName, results) {
84
82
  }
85
83
  } catch { /* Parse failure doesn't affect normal tool messages */ }
86
84
  }
87
- // F-LC6(LIGHT-CHANNEL §2.9):主 agent 会话首笔产品源文件笔 ⇒ 回执末尾追加一行通道提醒
88
- // (零拦截——只拼回执文本、入史时机不变;命中后本会话不再提示)。
89
- const penNotice = unbookedPenNotice(agent, toolCall, ok, tool)
90
- pushReal(agent, { role: "tool", tool_call_id: toolCall.id, name: toolCall.name, content: penNotice ? result + penNotice : result })
85
+ pushReal(agent, { role: "tool", tool_call_id: toolCall.id, name: toolCall.name, content: result })
91
86
  if (tool && ok) {
92
87
  if (FILE_MUTATORS.has(toolCall.name)) {
93
88
  // Direct file edit — code was changed. The prior advisor review and
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * run-stages.mjs — runAgent 主循环的阶段函数族(CLI,2026-09-05 实践轮:agent.mjs
3
3
  * runAgent 383 ≥300 按骨干—细节两层提取——VS Code 端同构文件(双端 parity——拆分
4
- * 两端同步))。本文件承载:回合压缩检查(runCompactionCheck)、回合注入组
4
+ * 两端同步))。本文件承载:回合压缩检查(runCompactionCheck)、越窗守卫 / 自适应共用原子
5
+ * (enforceContextFit——§6.17 守卫与 §6.17.1 链三共用一个压缩原子)、回合注入组
5
6
  * (injectTurnReminders——plan cadence + eng 状态)、回合收尾(finalizeAgentTurn——
6
7
  * consult 清理/async 池分流/guard 继承)与其收集器(collectSettledAsync——自
7
8
  * agent.mjs 迁入,agent.mjs 内无其他引用)。verbatim 迁移 + 签名化,语义零变。
@@ -73,6 +74,35 @@ export function injectResponseReminders(agent, response) {
73
74
  }
74
75
  }
75
76
 
77
+ // ─── 压缩记账三件(§6.17 阈值面 ∥ 守卫面 ∥ §6.17.1 链三自适应面共用同一段,零复制)───
78
+
79
+ /** 压缩成功记账:失败计数清零 / 计划提醒 cadence 复位 / 停滞检测计数复位 / 完成事件 / AUTO 提醒重注入。 */
80
+ function bookkeepCompaction(agent, ctx) {
81
+ agent._compressFailures = 0
82
+ agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
83
+ ctx.recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
84
+ // Completion info (CONTEXT-COMPACTION §7 D-C2): { mode, tokensFreed, elapsedMs } —
85
+ // the TUI panel renders it; callers that ignore the arg keep prior onCompress semantics.
86
+ ctx.callbacks?.onCompress?.(agent._lastCompressInfo ?? {})
87
+ ensureAutoReminder(agent)
88
+ }
89
+
90
+ /** 压缩失败记账——AbortError 必须透传(用户中止不得被当成压缩失败吞掉)。 */
91
+ function noteCompressFailure(agent, ctx, compressError) {
92
+ if (compressError?.name === "AbortError" || ctx.signal?.aborted) throw compressError
93
+ agent._compressFailures = (agent._compressFailures ?? 0) + 1
94
+ // Q3 (CONTEXT-COMPACTION §7 D-C1): a failed compression is surfaced to the panel;
95
+ // COMPRESS_FAILURE_LIMIT consecutive failures still degrade to compressFallback.
96
+ ctx.callbacks?.onCompressFail?.(compressError)
97
+ }
98
+
99
+ /** 三连败降级(确定性截断——零网络,§6.5 D6)。 */
100
+ function degradeIfCompressExhausted(agent, ctx) {
101
+ if (agent._compressFailures < COMPRESS_FAILURE_LIMIT) return
102
+ agent._compressFailures = 0
103
+ if (compressFallback(agent)) ctx.callbacks?.onCompress?.(agent._lastCompressInfo ?? {})
104
+ }
105
+
76
106
  /**
77
107
  * 回合压缩检查(2026-09-05 实践轮——自 runAgent 主循环提取):仅安全点(history 以
78
108
  * 完整交换结尾——user/tool 位)。成功:失败计数清零/计划提醒 cadence 复位/停滞检测
@@ -82,35 +112,12 @@ export function injectResponseReminders(agent, response) {
82
112
  * 模型主动压缩(`context` 工具)由本点消费(§6.16.2——取用即清槽;no-op 与失败各落一行注记)。
83
113
  */
84
114
  export async function runCompactionCheck(agent, ctx) {
85
- const { threshold, callbacks, compactionOverhead, signal, recentCallSigs } = ctx
115
+ const { threshold, callbacks, compactionOverhead, signal } = ctx
86
116
  // F-CC2(CONTEXT-COMPACTION.md §6.16.2):模型主动压缩(`context` 工具)在**本安全点**消费——
87
117
  // 取用即清槽(失败不重放;重发由模型再调);`force` 只跳过阈值早退(模型判定优先于阈值),
88
118
  // 其余全同(同 splitHistory / 同尾部预算 / 同摘要链 / 同回注 / 同基线失效)。
89
119
  const pending = agent._pendingCompact
90
120
  agent._pendingCompact = null
91
- // 成功记账(§6.17:成功记账段抽局部——阈值面与守卫面共用同一段,零复制)
92
- const bookkeepCompaction = () => {
93
- agent._compressFailures = 0
94
- agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
95
- recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
96
- // Completion info (CONTEXT-COMPACTION §7 D-C2): { mode, tokensFreed, elapsedMs } —
97
- // the TUI panel renders it; callers that ignore the arg keep prior onCompress semantics.
98
- callbacks.onCompress?.(agent._lastCompressInfo ?? {})
99
- ensureAutoReminder(agent)
100
- }
101
- // 压缩失败记账(两处共用:下面的阈值面 catch ∥ 下面的守卫面强制压缩)——AbortError 必须透传。
102
- const noteCompressFailure = (compressError) => {
103
- if (compressError?.name === "AbortError" || signal?.aborted) throw compressError
104
- agent._compressFailures = (agent._compressFailures ?? 0) + 1
105
- // Q3 (CONTEXT-COMPACTION §7 D-C1): a failed compression is surfaced to the panel;
106
- // COMPRESS_FAILURE_LIMIT consecutive failures still degrade to compressFallback.
107
- callbacks?.onCompressFail?.(compressError)
108
- }
109
- const degradeIfCompressExhausted = () => {
110
- if (agent._compressFailures < COMPRESS_FAILURE_LIMIT) return
111
- agent._compressFailures = 0
112
- if (compressFallback(agent)) callbacks.onCompress?.(agent._lastCompressInfo ?? {})
113
- }
114
121
  try {
115
122
  let compacted = await compressIfNeeded(
116
123
  agent, threshold, callbacks,
@@ -123,43 +130,57 @@ export async function runCompactionCheck(agent, ctx) {
123
130
  agent.history.push({ role: "user", content: compactNoopNote(), transient: true })
124
131
  compacted = await compressIfNeeded(agent, threshold, callbacks, compactionOverhead, signal)
125
132
  }
126
- if (compacted) bookkeepCompaction()
133
+ if (compacted) bookkeepCompaction(agent, ctx)
127
134
  } catch (compressError) {
128
- noteCompressFailure(compressError)
135
+ noteCompressFailure(agent, ctx, compressError)
129
136
  if (pending) {
130
137
  // 失败注记(§6.16.2 回执面②):模型面告知缺口——既有失败链 / 面板面零改,仅补一行机器注记
131
138
  const { compactFailureNote } = await import("../agent-tools/context.mjs")
132
139
  agent.history.push({ role: "user", content: compactFailureNote(errText(compressError), agent._compressFailures), transient: true })
133
140
  }
134
- degradeIfCompressExhausted()
141
+ degradeIfCompressExhausted(agent, ctx)
135
142
  }
136
143
 
137
144
  // 溢出守卫(CONTEXT-COMPACTION.md §6.17 / D-CC32——兜底,非主修;本缝 = catch 闭合后、返回前)。
145
+ // 判据 / 强压 / 再判 / 抛三实数 全住 `enforceContextFit`——与 §6.17.1 链三的自适应面**同一个原子**(零复制)。
146
+ await enforceContextFit(agent, ctx)
147
+ }
148
+
149
+ /**
150
+ * 越窗守卫 / 自适应共用原子(CONTEXT-COMPACTION.md §6.17 守卫 ∥ §6.17.1 链三自适应):
151
+ * `requestBudget` 判 → 强压一次(`force`)→ 再判 → 抛三实数(不截断、不静默削输出)。
152
+ *
153
+ * `opts.forceCompact`(链三面):**跳过前置估算判据**、直接强压——服务端已判越窗时估算准不准不再作门
154
+ * (本批要治的就是估算低估:死锁现场估 491K < limit 606,784,按估算开门会压 0 次、重试同一请求再吃 400)。
155
+ * 压后仍超的再判两面同判(压后仍越线说明压缩解不了,再压只是重复付费)。
156
+ * 返回是否走到了再判通过(守卫面估算未越线 ⇒ false、零动作;压后仍超⇒抛,不到达返回值)。
157
+ */
158
+ export async function enforceContextFit(agent, ctx, { forceCompact = false } = {}) {
159
+ const { threshold, callbacks, compactionOverhead, signal } = ctx
138
160
  // 判据 = prompt 估算 > `budget.limit`(**无档次门**——absent 档 limit 取地板 max(window − reserve, threshold),
139
161
  // 恒为正数、恒可判)。**判据不用 maxTokens**:未发时为 undefined ⇒ NaN ⇒ 守卫静默失效(§6.17)。
140
- // 真 ⇒ 以 force 强制压缩一次(先压再判);再估仍超 ⇒ 抛三实数、回合请求零发。不截断、不静默削输出。
141
162
  const budget = requestBudget(agent.provider)
142
163
  let usage = contextUsage(agent, compactionOverhead)
164
+ if (!forceCompact && !(usage.total > budget.limit)) return false
165
+ // 强压一次:失败走与阈值面同一条记账 / 降级链(不把摘要错误当回合错误直抛——
166
+ // 记账后仍按「压后仍超」判:降级截断可能已把上下文压到线下)。
167
+ try {
168
+ const compacted = await compressIfNeeded(
169
+ agent, threshold, callbacks, { ...compactionOverhead, force: true }, signal,
170
+ )
171
+ if (compacted) bookkeepCompaction(agent, ctx)
172
+ } catch (compressError) {
173
+ noteCompressFailure(agent, ctx, compressError)
174
+ degradeIfCompressExhausted(agent, ctx)
175
+ }
176
+ usage = contextUsage(agent, compactionOverhead)
143
177
  if (usage.total > budget.limit) {
144
- // 强压一次:失败走与阈值面同一条记账 / 降级链(不把摘要错误当回合错误直抛——
145
- // 记账后仍按「压后仍超」判:降级截断可能已把上下文压到线下)。
146
- try {
147
- const compacted = await compressIfNeeded(
148
- agent, threshold, callbacks, { ...compactionOverhead, force: true }, signal,
149
- )
150
- if (compacted) bookkeepCompaction()
151
- } catch (compressError) {
152
- noteCompressFailure(compressError)
153
- degradeIfCompressExhausted()
154
- }
155
- usage = contextUsage(agent, compactionOverhead)
156
- if (usage.total > budget.limit) {
157
- const remedy = budget.limit < 0
158
- ? `max_tokens ${budget.maxTokens} alone exceeds the window — lower provider.maxTokens or switch to a larger-window model`
159
- : "forced compaction ran but the prompt still exceeds the window; reduce the conversation (/compact or prune) before retrying"
160
- throw contextOverflowError(agent.provider, usage.total, budget, remedy)
161
- }
178
+ const remedy = budget.limit < 0
179
+ ? `max_tokens ${budget.maxTokens} alone exceeds the window — lower provider.maxTokens or switch to a larger-window model`
180
+ : "forced compaction ran but the prompt still exceeds the window; reduce the conversation (/compact or prune) before retrying"
181
+ throw contextOverflowError(agent.provider, usage.total, budget, remedy)
162
182
  }
183
+ return true
163
184
  }
164
185
 
165
186
  /**
@@ -282,7 +303,7 @@ export async function finalizeAgentTurn(agent, ctx) {
282
303
  // 恒达窗 = 正常 ∥ Abort-continue ∥ 异常抛三径同达;会话停止径不判(清场语义归 finishSuspension)。
283
304
  if (isDigestRound({ autoTurn, upstreamTurn, timerTurn }) && carrierField(agent, "_daSession") === true
284
305
  && !(signal?.aborted && !signal?.reason?.interrupt)) {
285
- accountDigestRound(agent)
306
+ await accountDigestRound(agent) // async(§6.31.5 升级支 ④ 终局披露复用注入器)
286
307
  }
287
308
  agent._inAutoTurn = false
288
309
  // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S6: auto-turn guard marks survive into the next USER run (restored at its
@@ -31,31 +31,31 @@ export async function beginRun(agent, input, callbacks, {
31
31
  // (consult → injectConsultResult;其余 → injectAsyncResult——族分支同 CONSULTATION.md §6.2 D-R17a/b)
32
32
  // ——单容器一处清,不再逐族三段。
33
33
  // §6.31 消化账务(批 digest-accounting · 2026-10-05 · 台账 #930):**投递 ≠ 销账**——
34
- // 会话态(`_daSession`)投递留容器(覆盖才离容器 / 未见账经消化轮回路重投);fallback
34
+ // 会话态(`_daSession`)投递留容器(覆盖才离容器 / 未见账计一个未签收轮、达 `DA_MISS_LIMIT` 升级——
35
+ // 投递恒一次,无重投回路:批 digest-retry-amplify · 台账 #1850);fallback
35
36
  // (headless / 直连 runAgent)缺省条逐字沿用取尽语义(先离容后注入 ∥ `releaseSettledEntry` 面;
36
37
  // 零账目动作——不销账 / 不重投),已投递未销账条同判据跳过且留容器(§6.31 出口缝② · 父侧裁定 2026-10-05)。
37
38
  const pendingAsync = agent._pendingAsyncResults
38
39
  const { carrierField, releaseChildHold, releaseSettledEntry } = await import("../agent-tools/async-settle.mjs")
39
40
  if (carrierField(agent, "_daSession") === true) {
40
41
  const { armAccountRound, isDigestRound, shouldDeliverEntry, digestAccountRequirement } = await import("./digest-account.mjs")
41
- armAccountRound(agent) // §6.31.5 落痕清位:会话态起跑即清(缺痕 ⇒ 全条未覆盖——安全方向)
42
+ armAccountRound(agent) // §6.31.5 落痕清位:会话态起跑即清(缺痕 ⇒ 该轮按未签收判一个轮次)
42
43
  if (pendingAsync?.length) {
43
44
  const { injectAsyncResult } = await import("../agent-tools/subagent.mjs")
44
45
  const { injectConsultResult } = await import("../agent-tools/consult.mjs")
45
46
  const digestRound = isDigestRound({ autoTurn, upstreamTurn, timerTurn })
46
- // 投递门控两态(§6.31.4 · §6.34 收窄):首投**仅系统轮**(`autoTurn`——用户回合零注入)∥ 重投仅消化轮;
47
- // 注入后**留容器**(不 splice)。
47
+ // 投递门控单门(§6.31.4 · 投递恒一次):首投**仅系统轮**(`autoTurn`——用户回合零注入),
48
+ // 已投递条恒跳过;注入后**留容器**(不 splice)。
48
49
  // 注入抛错 ⇒ 未投条目(`_daDelivered` 未置)留容器待下轮——收窄既有「splice 先取尽」的丢失窗。
49
50
  for (const e of [...pendingAsync]) {
50
- if (!shouldDeliverEntry(e, digestRound, autoTurn)) continue
51
+ if (!shouldDeliverEntry(e, autoTurn)) continue
51
52
  if (e.role === "consult") await injectConsultResult(agent, e)
52
53
  else await injectAsyncResult(agent, e)
53
54
  e._daDelivered = true
54
- e._daRetry = false
55
- releaseChildHold(e) // 仅释放 `childAgent`——`report` 保留至销账 / 升级(重投需原文)
55
+ releaseChildHold(e) // 仅释放 `childAgent`——`report` 保留至销账 / 升级(升级终局披露需原文)
56
56
  }
57
57
  // 账目要求行(§6.31.3):仅会话态消化轮且未销账清单非空时注入(先投递后拼清单——
58
- // 含本回合首投 / 重投);两档同发(手动 / AUTO);transient(机器线独有)。
58
+ // 含本回合首投);两档同发(手动 / AUTO);transient(机器线独有)。
59
59
  if (digestRound) {
60
60
  const ids = pendingAsync.filter((e) => e._daDelivered === true).map((e) => e.id)
61
61
  if (ids.length > 0) {
package/agent/setup.mjs CHANGED
@@ -23,20 +23,10 @@ import { normalizeOrigin } from "../memory/origin.mjs"
23
23
  import { searchOrigins } from "../memory/code-search.mjs"
24
24
  import { INDEX_ORIGIN_ROW_WARN } from "../memory/schema.mjs"
25
25
  import { resolveCompactThreshold } from "../config.mjs"
26
+ import { safeSliceUTF16 } from "../text-budget.mjs"
26
27
 
27
28
  const DOC_SEARCH_LIMIT = 5
28
29
  const DOC_CHUNK_PREVIEW_LEN = 300
29
- /** UTF-16 安全截断(2026-09-02 deepseek 400 根因):slice(0, N) 按码元切会把 emoji 代理对切成孤立
30
- * 高代理(如 🔴=U+D83D+DD34 只剩 D83D)——deepseek 解析器严格 UTF-16 报 400
31
- * "unexpected end of hex escape"。截断点落在高代理上时向前收一个码元。 */
32
- function safeSliceUTF16(text, max) {
33
- if (text.length <= max) return text
34
- const end = max
35
- // 截断点恰在高代理(D800-DBFF)上 → 收到高代理之前(不带它)
36
- const cp = text.charCodeAt(end - 1)
37
- if (cp >= 0xd800 && cp <= 0xdbff) return text.slice(0, end - 1)
38
- return text.slice(0, end)
39
- }
40
30
  const MEMORY_SEARCH_LIMIT = 3
41
31
 
42
32
  /**
@@ -67,6 +57,8 @@ export async function prepareRun(agent, input, callbacks, {
67
57
  // §6.17 估算自校准比(`_tok` / `_tokRatio`)**不在本处清**:本函数是每 run 的 pre-flight,
68
58
  // 而在每轮用户消息上归零会让池化比退化成单样本(R2 失效)。初值在 `agent.mjs` 构造字面量,
69
59
  // 清空点 = `/new`(`session-lifecycle.mjs`)∥ `key` 变自清(`token-window.mjs` `recordTokenSample`)。
60
+ // §6.17.1 链三自适应额度:run 级一次——本处复位(一次 = 全部额度;同一 run 内第二次独立越窗直接抛)。
61
+ agent._overflowRetried = false
70
62
  // Cumulative provider cache accounting (batch 2026-10-18-context-cache-visibility):
71
63
  // the service reports prompt_cache_hit/miss per response; the terminal faces each kept
72
64
  // their own tally, so the CORE had none and `context` could not report cache efficiency.
@@ -240,7 +240,7 @@ export function startSuspension(ctx) {
240
240
  const done = (async () => {
241
241
  carrier._suspended = true
242
242
  // §6.31.2 会话标记(消化账务批 · 2026-10-05 · 台账 #930):挂起会话期 true——投递留容器 /
243
- // 见账 / 重投回路的唯一判据;退出(finally)置 false ⇒ 会话外回 fallback 语义。
243
+ // 见账的唯一判据;退出(finally)置 false ⇒ 会话外回 fallback 语义。
244
244
  carrier._daSession = true
245
245
  hooks.onCounts?.(backgroundCounts(carrier))
246
246
  let aborted = false
@@ -11,8 +11,8 @@ import { specForModel, assistantToolCallMessage } from "../config.mjs"
11
11
  import { executeToolCalls } from "./dispatch.mjs"
12
12
  import { recordToolResults } from "./record-results.mjs"
13
13
  import { injectPostTurn } from "./post-turn.mjs"
14
- import { handleCompletion } from "./completion.mjs"
15
- import { runCompactionCheck, injectTurnReminders, injectResponseReminders } from "./run-stages.mjs"
14
+ import { handleCompletion, isSubstantiveResponse } from "./completion.mjs"
15
+ import { runCompactionCheck, enforceContextFit, injectTurnReminders, injectResponseReminders } from "./run-stages.mjs"
16
16
  import { recordTokenSample } from "../token-window.mjs"
17
17
  import { callModelTurn } from "./chat-call.mjs"
18
18
  import {
@@ -49,6 +49,28 @@ export async function runTurnLoop(agent, {
49
49
  // `context` 工具的 stats 报**同一口径**(不自行重算第二口径;VSC checkAndCompact 同款暂存)。
50
50
  agent._ctxBasis = { threshold, overhead: compactionOverhead }
51
51
 
52
+ // §6.17.1 链三(越窗 400 自适应):单回合调用外包一层 catch——服务端说越窗 ⇒ 强制压一次 + 重试同一回合一次。
53
+ // 排除面:`stage ∈ {compress, distill}` 的请求**不经本函数**(压缩机与蒸馏各自直调 `chat`,续写子请求的
54
+ // 失败已在 `provider/core.mjs` 折进 `_warnings`)⇒ 本 catch 只接回合请求的 typed 越窗,不存在「已流出再重放」。
55
+ const callTurn = async (turnCtx) => {
56
+ try {
57
+ return await callModelTurn(agent, turnCtx)
58
+ } catch (e) {
59
+ if (e?.kind !== "context_overflow") throw e
60
+ // 死循环守卫(批档 D6):额度 = run 级一次(`agent/setup.mjs` 每 run 复位)——已用过 ⇒ 原 typed 错误透传
61
+ // (三实数 + `/compact` 提示已在报文体里);同一 run 内第二次独立越窗不再自愈,用户 `/compact` 后可继续。
62
+ if (agent._overflowRetried) throw e
63
+ agent._overflowRetried = true
64
+ // 越窗 typed 错误有两个出口(同 kind,§6.17):HTTP 400 分类 ∥ 本地发送面净判——两面都归本链处理,
65
+ // 故估算不再作门(估算恰恰低估才有本链),整压一次;压后仍超 ⇒ 抛三实数、不再压。
66
+ await enforceContextFit(
67
+ agent, { threshold, callbacks, compactionOverhead, recentCallSigs, signal }, { forceCompact: true },
68
+ )
69
+ // 重试恰一次:此行不在 try 内——二次越窗的 typed 错误原样上抛(不截断、不静默)
70
+ return await callModelTurn(agent, turnCtx)
71
+ }
72
+ }
73
+
52
74
  // SUBAGENT-UPSTREAM-CHANNEL(AGENT-LOOP-UPSTREAM.md §6.27.4 消费点):子 → 父在飞消息的
53
75
  // 回合边界注入单点取用一次(模块缓存 ⇒ 每 run 一次代价);动态 import = 零新增静态边
54
76
  // (同款先例 = `run-start.mjs` 的 `injectAsyncResult` 动态 import)。
@@ -128,10 +150,17 @@ export async function runTurnLoop(agent, {
128
150
  // 2026-09-05 实践轮:plan cadence + eng 状态注入提为 injectTurnReminders(run-stages)
129
151
  await injectTurnReminders(agent, { depth })
130
152
 
131
- const response = await callModelTurn(agent, {
153
+ const response = await callTurn({
132
154
  systemPrompt, toolSchemas, streamOutput, callbacks, signal, streamRuleFired, autoTurn, depth, turn,
133
155
  })
134
156
 
157
+ // 空响应预算复位(AGENT-LOOP.md §6.5① · D-AL28):本轮响应**有实质** ⇒ 计数清 0(实质 = 有正文
158
+ // 或带工具调用;判据单源 = completion.mjs `isSubstantiveResponse`)。落位 = 本轮响应存在的第一处、
159
+ // 也是四支路(下方流规则 abort ∥ 用户中断 ∥ 工具批 ∥ 零工具)发散前的最后一处——落进任一分支都会漏其余三支。
160
+ // 与下方 `guardPushbacks = 0`(工具批执行处)分工:那处复位的是 verify ∥ advisor 推回预算、只看「有工具批」;
161
+ // 本处复位空响应预算、看「有实质」——正文本身已是实质,不必等工具批。
162
+ if (isSubstantiveResponse(response)) agent._emptyRetries = 0
163
+
135
164
  // §6.31.5 落痕(消化账务批 · 2026-10-05 · 台账 #930 ∥ #939 收正):本 run 内**逐轮追加**——
136
165
  // 每模型轮返回即写(会话态);首轮签收不为收口轮覆盖(见账取件读累积痕)。
137
166
  // 落父字段(抗 `history` 整体替换——`context.mjs` `applyCompression`);非会话零动作。
@@ -0,0 +1,99 @@
1
+ /**
2
+ * agent/write-face.mjs — 面判定单源(写锁与设计门共用的判据底座)。
3
+ *
4
+ * 权威规格 = `docs/core/design/BATCH-LEDGER-REVIEW-UNIFY.md` §2.1 / §2.10;面分类的原文正源 =
5
+ * `docs/core/requirements/ENGINEERING-MODE-V2.md` §13.5。本档只做一件事:给一个绝对路径判「产品代码面
6
+ * / 文档面」,判据逐步骤对应 §2.10 的两值表。
7
+ *
8
+ * 面**只有两值**:文档面(放行)∥ 产品面(拦)——设计门按它拦;写锁 2026-11-03 起不分面(设计档 §2.3),
9
+ * 面在那里只选拒文变体。四格先命中先定:
10
+ * 1 出辖(祖先链无 manifest)⇒ 放行(`out-of-jurisdiction`)——辖界,不是面
11
+ * 2 manifest `docRoot` 前缀命中 ⇒ 放行(`doc-root`,D1 写权矩阵在写面上的机判投影)
12
+ * 3 文件 = `PROJECT-MANIFEST.json`(项目状态档)⇒ 放行(`manifest-state`)
13
+ * 4 其余一切 ⇒ 产品面(`product`,fail-closed——判不出面就按产品面走全链)
14
+ *
15
+ * 为什么不再复用 `classifyPath`:旧形把「构建产物 / 仓根 `scripts` ∥ `test` ∥ `.thincoder` ∥ `.github`
16
+ * / 锁文件 / `classifyPath` 的 temp·aux·state」当放行桶——每多一个放行桶,就多一条「这一步要不要走流程」
17
+ * 的自判口子(用户 2026-11-02 裁定退役)。两值之后没有第三桶、没有豁免、没有待裁格;`classifyPath` /
18
+ * `isCodePath` **本体零改**,它们仍是索引 / 检索 / verify 那一族的判据(七个生产消费面)。
19
+ *
20
+ * `test` 标记与面值**分离**:路径含 `test` / `tests` 段 ⇒ `test: true`,只决定写锁用测试变体拒文,
21
+ * 不改 `face`。
22
+ */
23
+ import { isAbsolute, relative, resolve } from "node:path"
24
+
25
+ import { declarationForTarget } from "../conventions.mjs"
26
+ import { docRootPaths, manifestFilePath, readManifest } from "../manifest.mjs"
27
+ // 文档面键集**不另持字面**(单源 = `agent-tools/review-facts.mjs` 的 `REVIEW_ROOT_KEYS`——同一组 docRoot 子键,
28
+ // 两份清单漂移就是「评审面放的 / 写面拦的」各说各话)。
29
+ import { REVIEW_ROOT_KEYS } from "../agent-tools/review-facts.mjs"
30
+
31
+ /** 比较键(绝对化 + 分隔符归一 `/` + win32 大小写归一——同 `write-gate.mjs` 的 `gateKey` 口径)。 */
32
+ const faceKey = (p) => {
33
+ const s = resolve(String(p)).replace(/\\/g, "/")
34
+ return process.platform === "win32" ? s.toLowerCase() : s
35
+ }
36
+
37
+ /** 该路径所属项目根的文档面根集(绝对比较键)。manifest 读不到 ⇒ 空集(兜底 = 产品代码面)。 */
38
+ function docRootKeys(root, manifest) {
39
+ let man = manifest
40
+ if (!man || typeof man !== "object" || !man.docRoot) {
41
+ try { man = readManifest(root).manifest } catch { man = null }
42
+ }
43
+ if (!man || typeof man !== "object") return []
44
+ const out = []
45
+ for (const key of REVIEW_ROOT_KEYS) {
46
+ for (const abs of docRootPaths(man.docRoot?.[key], root)) out.push(faceKey(abs))
47
+ }
48
+ return out
49
+ }
50
+
51
+ /** 路径段里有没有 `test` / `tests`(拒文变体句的判据,与面值无关)。 */
52
+ function hasTestSegment(relOrAbs) {
53
+ return String(relOrAbs).split(/[\\/]+/).filter(Boolean).some((s) => {
54
+ const low = s.toLowerCase()
55
+ return low === "test" || low === "tests"
56
+ })
57
+ }
58
+
59
+ /**
60
+ * 面判定(判据本体)。返回 `{face, reason, test}`:
61
+ * - `face` = `"product"`(产品面 ⇒ 设计门拦;写锁里只选拒文变体)∥ `"pass"`(放行面);
62
+ * - `reason` = 命中的那一格——**只有四值**:`out-of-jurisdiction` / `doc-root` / `manifest-state` /
63
+ * `product`(回执与用例的定位面);
64
+ * - `test` = 路径含 `test` / `tests` 段(产品测试面 ⇒ 写锁换测试变体那句拒文;不改 `face`)。
65
+ * @param {string} absPath — 目标绝对路径(相对形由调用方先 resolve;本档不猜会话锚)
66
+ * @param {object|null} [manifest] — 所属项目的 manifest(缺省 ⇒ 按项目根读;读不到 ⇒ 文档面空集)
67
+ * @returns {{face: "product"|"pass", reason: "out-of-jurisdiction"|"doc-root"|"manifest-state"|"product", test: boolean}}
68
+ */
69
+ export function faceDetail(absPath, manifest = null) {
70
+ const abs = resolve(String(absPath ?? ""))
71
+ const conv = declarationForTarget(abs) // 出辖 ⇒ null(祖先链无 manifest)
72
+ if (!conv) return { face: "pass", reason: "out-of-jurisdiction", test: hasTestSegment(abs) }
73
+ const root = conv.root
74
+ const rel = relative(root, abs)
75
+ const inRoot = rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
76
+ const test = hasTestSegment(inRoot ? rel : abs)
77
+ if (inRoot) {
78
+ const key = faceKey(abs)
79
+ // 2 文档面(manifest docRoot)
80
+ if (docRootKeys(root, manifest).some((d) => key === d || key.startsWith(`${d}/`))) {
81
+ return { face: "pass", reason: "doc-root", test }
82
+ }
83
+ // 3 项目状态档(主 agent 的声明面——D1 写权矩阵同源)
84
+ if (key === faceKey(manifestFilePath(root))) return { face: "pass", reason: "manifest-state", test }
85
+ }
86
+ // 4 其余一切 = 产品面(fail-closed)
87
+ return { face: "product", reason: "product", test }
88
+ }
89
+
90
+ /**
91
+ * 面判定(§2.1 契约形):`"product"` = 产品面(设计门按它拦;写锁里只选拒文变体——台账在途行才是判据),
92
+ * `"pass"` = 放行面(文档面 / 出辖)。
93
+ * @param {string} absPath — 目标绝对路径
94
+ * @param {object|null} [manifest] — 所属项目 manifest(缺省 ⇒ 按项目根读)
95
+ * @returns {"product"|"pass"}
96
+ */
97
+ export function faceOf(absPath, manifest = null) {
98
+ return faceDetail(absPath, manifest).face
99
+ }