@thincoder/core 0.10.8 → 0.10.9

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 (72) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/agent/family-tools.mjs +2 -2
  3. package/agent/helpers.mjs +6 -6
  4. package/agent/post-turn.mjs +34 -0
  5. package/agent/run-stages.mjs +43 -9
  6. package/agent/setup.mjs +10 -2
  7. package/agent/turn-loop.mjs +6 -0
  8. package/agent-tools/audit-block.mjs +4 -2
  9. package/agent-tools/batch-gate.mjs +198 -0
  10. package/agent-tools/batch-lifecycle.mjs +216 -36
  11. package/agent-tools/batch-skeleton.mjs +87 -0
  12. package/agent-tools/batch.mjs +11 -3
  13. package/agent-tools/context.mjs +28 -5
  14. package/agent-tools/design-slots.mjs +7 -33
  15. package/agent-tools/doc-check.mjs +44 -6
  16. package/agent-tools/spawn-gates.mjs +100 -41
  17. package/agent-tools/ssh-session.mjs +108 -61
  18. package/agent-tools/ssh-transport.mjs +17 -4
  19. package/agent-tools/ssh-vault.mjs +291 -0
  20. package/agent-tools/subagent-spawn.mjs +18 -11
  21. package/agent-tools/subagent.mjs +13 -7
  22. package/agent-tools/timer.mjs +2 -2
  23. package/config-defaults.mjs +92 -0
  24. package/config-normalize.mjs +109 -0
  25. package/config.mjs +88 -197
  26. package/doc-check/anchors.mjs +54 -12
  27. package/doc-check/index.mjs +161 -12
  28. package/doc-check/version.mjs +84 -0
  29. package/doc-check/width.mjs +5 -4
  30. package/i18n.mjs +2 -0
  31. package/ledger-cmd.mjs +314 -61
  32. package/ledger-db.mjs +22 -10
  33. package/ledger-migrate.mjs +15 -9
  34. package/ledger-tools.mjs +115 -69
  35. package/ledger.mjs +13 -10
  36. package/log.mjs +2 -2
  37. package/manifest-schema.mjs +15 -1
  38. package/package.json +1 -1
  39. package/permission.mjs +7 -1
  40. package/prompts/advisor-design.md +21 -21
  41. package/prompts/advisor-round1.md +18 -18
  42. package/prompts/advisor-round2.md +22 -22
  43. package/prompts/advisor-round3.md +24 -24
  44. package/prompts/common.md +108 -93
  45. package/prompts/consult-base.md +8 -8
  46. package/prompts/discipline-engineering.md +68 -65
  47. package/prompts/discipline-normal.md +69 -68
  48. package/prompts/persona-coder.md +6 -6
  49. package/prompts/persona-eng-coder.md +19 -18
  50. package/prompts/persona-eng-designer.md +36 -32
  51. package/prompts/persona-engineering.md +114 -98
  52. package/prompts/persona-explore.md +2 -2
  53. package/prompts/persona-normal.md +16 -16
  54. package/prompts/persona-plan.md +7 -7
  55. package/prompts/subagent-base.md +4 -4
  56. package/provider/anthropic.mjs +4 -2
  57. package/provider/core.mjs +4 -2
  58. package/provider/google.mjs +5 -1
  59. package/provider/responses-request.mjs +3 -2
  60. package/session-lifecycle.mjs +6 -6
  61. package/team.mjs +2 -1
  62. package/token-window.mjs +1 -1
  63. package/tool-docs/batch.md +1 -1
  64. package/tool-docs/doc_check.md +30 -7
  65. package/tool-docs/fetch.md +3 -2
  66. package/tool-docs/ledger.md +7 -5
  67. package/tool-docs/ssh.md +23 -6
  68. package/tool-docs/subagent.md +6 -11
  69. package/tool-docs/websearch.md +3 -1
  70. package/tools/ssh.mjs +24 -11
  71. package/tools/web-deepseek.mjs +172 -0
  72. package/tools/web.mjs +29 -4
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ Format: Keep a Changelog · 中文 · 号在发布时定(CalVer——见 `docs
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.10.9] — 2026-10-19
9
+
10
+ ### Removed
11
+
12
+ - **`design-slots` 的 `reveal` 开关与掩蔽(token-visibility-simplify 批 2026-10-17)**:清点面改**直读**——逐槽行恒携 `designToken`(过期照列、`status` 自标态;记录畸形 ⇒ `—`);删净 `reveal` 开关 / 掩蔽 / 一次一槽限制(用户裁定「没必要把 token 藏起来,agent 要就告诉他」);签发链(mint / settle / TTL / persist)零改。
13
+
8
14
  ## [0.10.8] — 2026-10-16
9
15
 
10
16
  > 0.10.7 → 0.10.8(月内 +1——发布时定号)
@@ -125,8 +125,8 @@ export async function assembleFamilyTools({
125
125
  ...subagentTool,
126
126
  name: "subagent",
127
127
  description: designer
128
- ? "Spawn a read-only `explore` sub-agent to SURVEY the current state for the design: it reads code / docs / existing designs and reports evidence with file:line. BLOCKING ONLY (no async) and action:'spawn' ONLY — the survey channel is read-only; escalate/status/cancel/panel/consume-design/observe/send are not available. Survey budget: ≤6 explore spawns per batch — the main agent's survey result is reference only; do your own."
129
- : "Spawn a read-only `explore` sub-agent to AUDIT your delivery against the design: it compares the delivered code with the design for divergence — partially implemented acceptance criteria, silent simplifications, doc drift, changes outside the approved file list. BLOCKING ONLY (no async — the audit report decides your next protocol step). action:'spawn' ONLY — the audit channel is a read-only spawn; escalate/status/cancel/panel/consume-design/observe/send are not available. The audit task book is appended MECHANICALLY — your own spawn task (docs involved / acceptance criteria / file list) plus the files you actually touched; never hand the audit a self-written file list (a self-report could omit exactly the out-of-scope file it must catch).",
128
+ ? "Spawn a read-only `explore` sub-agent to SURVEY the current state for the design: it reads code / docs / existing designs and reports evidence with file:line. BLOCKING ONLY (no async) and action:'spawn' ONLY — the survey channel is read-only; escalate/status/cancel/panel/consume-design/observe/send are not available. Survey budget: ≤6 explore spawns per batch — the main agent's survey result is reference only; do your own. The spawn carries the five task-book parameters (four core: goal / knownFacts / acceptance / reportFormat + `scope`); a task-only spawn is refused."
129
+ : "Spawn a read-only `explore` sub-agent to AUDIT your delivery against the design: it compares the delivered code with the design for divergence — partially implemented acceptance criteria, silent simplifications, doc drift, changes outside the approved file list. BLOCKING ONLY (no async — the audit report decides your next protocol step). action:'spawn' ONLY — the audit channel is a read-only spawn; escalate/status/cancel/panel/consume-design/observe/send are not available. The audit task book is appended MECHANICALLY — your own spawn task (docs involved / acceptance criteria / file list) plus the files you actually touched; never hand the audit a self-written file list (a self-report could omit exactly the out-of-scope file it must catch). The spawn carries the five task-book parameters (four core: goal / knownFacts / acceptance / reportFormat + `scope`); a task-only spawn is refused.",
130
130
  parameters: { ...subagentTool.parameters, properties: props },
131
131
  }
132
132
  })()
package/agent/helpers.mjs CHANGED
@@ -451,14 +451,14 @@ export const AUTO_TURN_DIGEST_DOMAIN =
451
451
  /** Engineering-mode auto-turn digest domain variant (mode = `agent.config.agent.engineering`;
452
452
  * clause 2 drops the task-tool pointer — F10: the task tool is disabled in engineering mode,
453
453
  * the batch record + ledger are the tracking authority). Single line, verbatim from the
454
- * design doc (TOOLS.md §6.15.3); mechanism = AGENT-LOOP-ASYNC-POOL.md §6.8. Content authority = parent side. */
454
+ * design doc (TOOLS.md §6.15.3); mechanism = AGENT-LOOP-ASYNC-POOL.md §6.8. Content basis = the mechanism doc; landing = eng-coder. */
455
455
  export const AUTO_TURN_DIGEST_DOMAIN_ENG =
456
456
  "[System reminder: auto-turn — background async subagents finished while there was no user message, and this turn runs automatically to digest their reports (the finished-report reminders above). No one is waiting for this reply, so organize only: 1) summarize each finished report's key points into this conversation for the user to read later; 2) (engineering mode: the task tool is disabled — no task-list update is expected this turn; the batch record + ledger are the tracking authority and are updated in real user turns); 3) write decision points with a suggested next step as text — do not execute it. FORBIDDEN this turn (mechanically enforced): modifying files, bash/execute/verify, spawning subagents, asking questions — those need a real user message. End the turn once the summaries are written.]"
457
457
 
458
458
  /** Up-stream wake-turn domain (AGENT-LOOP-UPSTREAM.md §6.27.12.8): a running subagent sent
459
459
  * an in-flight message and is waiting for the reply — the digest domain's "No one is waiting
460
460
  * for this reply" is the opposite of the truth, so the wake turn gets its own text.
461
- * Verbatim from the design doc (single line — no newlines). Content authority = parent side. */
461
+ * Verbatim from the design doc (single line — no newlines). Content basis = the mechanism doc; landing = eng-coder. */
462
462
  export const UPSTREAM_TURN_DOMAIN =
463
463
  "[System reminder: auto-turn — a running subagent sent you an in-flight message (shown below). No user message is waiting. Decide it now and reply with subagent action:'send' (id + message) — the child consumes the reply at its next turn boundary and keeps working on the unaffected parts; if the message needs no answer, say so in one line and move on. If finished subagent reports are also present above, summarize them as usual in the same turn. Do not start new work: FORBIDDEN this turn (mechanically enforced): modifying files, bash/execute/verify, spawning subagents, asking questions — those need a real user message. End the turn once the reply is sent.]"
464
464
 
@@ -466,16 +466,16 @@ export const UPSTREAM_TURN_DOMAIN =
466
466
  * expired while the session was idle and the end's latch opened this turn automatically —
467
467
  * neither the digest domain ("no one is waiting", organize-only) nor the up-stream domain
468
468
  * (a subagent waiting for a reply) fits: the timer's own semantics is "act on what the
469
- * timer was for". Verbatim from the parent-side final text (single line — no newlines).
470
- * Content authority = parent side. */
469
+ * timer was for". Verbatim from the mechanism doc (single line — no newlines).
470
+ * Content basis = the mechanism doc; landing = eng-coder. */
471
471
  export const TIMER_TURN_DOMAIN =
472
472
  "[System reminder: auto-turn — a timer you set earlier has expired (reminder above). No user message is waiting. Act on what the timer was for now: pull the data or run the check rather than continuing to reason in the abstract. Stay within the work that was in flight when the timer was set; do not open unrelated new work. Wrap up as soon as the action is done or blocked.]"
473
473
 
474
474
  /** Digest-accounting requirement line (AGENT-LOOP-ASYNC-POOL.md §6.31.3 · batch digest-accounting ·
475
475
  * 2026-10-05):消化轮账目要求——两档同发(手动 / AUTO);manual 域文本只覆盖手动档 ⇒ 要求行
476
476
  * 独立常量承载,避免 AUTO 轮无要求。`<ids>` = 未销账 id 清单占位(动态拼 = digest-account.mjs
477
- * `digestAccountRequirement`);注入面 = transient(机器线独有)。Verbatim from the parent-side
478
- * final text (single line — no newlines); content authority = parent side. */
477
+ * `digestAccountRequirement`);注入面 = transient(机器线独有)。Verbatim from the mechanism doc
478
+ * (single line — no newlines). Content basis = the mechanism doc; landing = eng-coder. */
479
479
  export const DIGEST_ACCOUNT_DOMAIN =
480
480
  "[System reminder: digest accounting — for every report listed below as not yet accounted, emit exactly one line: \"[digest-ack #<id>] digested — <key points>\" (handled) or \"[digest-ack #<id>] deferred — <reason>\" (not handled). Not yet accounted: <ids>. Do not omit any.]"
481
481
 
@@ -10,6 +10,31 @@ export const STALL_WINDOW_SIZE = 5
10
10
  export const STALL_THRESHOLD = 3
11
11
  export const GOAL_BUDGET_WARN_RATIO = 0.75
12
12
 
13
+ // 设计轮轮数注入阈值(AGENT-LOOP.md §6.6「设计轮轮数注入」· 台账 #1661):
14
+ // 5 = 在 persona 判定点(过 7 轮未落盘 ⇒ 降级)之前 3 轮给出计数;8 = 与 persona 同一判定点;
15
+ // 15 = persona 的首版预算(注入串里的 15 一律由它插值——改常量即改注入文本,不另留字面)。
16
+ export const DESIGN_TURN_NOTICE_AT = 5
17
+ export const DESIGN_TURN_SKELETON_AT = 8
18
+ export const DESIGN_FIRST_VERSION_TURNS = 15
19
+
20
+ /**
21
+ * 设计轮轮数提醒(纯函数——注入串 | `null`;判据源 = 链内累计轮号 + 本设计轮是否落过盘)。
22
+ * 逐字注入形 = `AGENT-LOOP.md` §6.6「注入形逐字」;角色表当前一行(`eng-designer`),
23
+ * `eng-coder` 零注入(其零落笔面另由零落笔看门狗兜)。
24
+ * @param {{role?:string|null, turn?:number, wrote?:boolean}} o `turn` = 链内累计轮号(1 起)
25
+ * @returns {string|null}
26
+ */
27
+ export function designTurnReminder({ role, turn, wrote } = {}) {
28
+ if (role !== "eng-designer") return null
29
+ const used = Number(turn ?? 0)
30
+ if (!Number.isFinite(used) || used < DESIGN_TURN_NOTICE_AT) return null
31
+ const budget = DESIGN_FIRST_VERSION_TURNS
32
+ if (used >= DESIGN_TURN_SKELETON_AT && !wrote) {
33
+ return `[System reminder: design round turn ${used}/${budget} — nothing has been written to disk yet. The 7-turn mark has passed with nothing landed: degrade the delivery NOW — write the skeleton (design doc + batch record §2) plus an unresolved list, then refine in place.]`
34
+ }
35
+ return `[System reminder: design round turn ${used}/${budget} — ${wrote ? "something has been written to disk" : "nothing has been written to disk yet"}. First version budget is ${budget} turns; past 7 turns with nothing landed the rule is a skeleton + unresolved list.]`
36
+ }
37
+
13
38
  /**
14
39
  * Inject all post-turn events into agent history.
15
40
  * Must be called after every tool-execution turn within runAgent's loop.
@@ -66,5 +91,14 @@ export function injectPostTurn(agent, results, recentCallSigs, callbacks, turn,
66
91
  })
67
92
  }
68
93
 
94
+ // 设计轮轮数注入(AGENT-LOOP.md §6.6 · 台账 #1661):轮号取 `_currentTurn`(链内累计、续跑不重置),
95
+ // 写入痕迹取 `_touchedFiles`(在 resume 保留集内 ⇒ 续跑段沿用前段痕迹);段内 `turn` 形参不参与判据。
96
+ const designReminder = designTurnReminder({
97
+ role: agent._role,
98
+ turn: agent._currentTurn ?? 0,
99
+ wrote: (agent._touchedFiles ?? []).length > 0,
100
+ })
101
+ if (designReminder) agent.history.push({ role: "user", content: designReminder })
102
+
69
103
  callbacks.onTurnEnd?.(agent, turn)
70
104
  }
@@ -8,6 +8,9 @@
8
8
  */
9
9
 
10
10
  import { compressIfNeeded, compressFallback, COMPRESS_FAILURE_LIMIT } from "../context.mjs"
11
+ // §6.17/D-CC32 溢出守卫(兜底,非主修):预算面单源 requestBudget ∥ 再估面 contextUsage(与压缩判定同一函数)
12
+ import { requestBudget } from "../config.mjs"
13
+ import { contextUsage } from "../token-window.mjs"
11
14
  import { ensureAutoReminder, injectEngineeringReminder, ContinueError, snapshotGuard } from "./helpers.mjs"
12
15
  import { pushManifestStateReminder } from "./setup-reminders.mjs"
13
16
  import { cleanupConsultSessions } from "../agent-tools/consult.mjs"
@@ -84,6 +87,16 @@ export async function runCompactionCheck(agent, ctx) {
84
87
  // 其余全同(同 splitHistory / 同尾部预算 / 同摘要链 / 同回注 / 同基线失效)。
85
88
  const pending = agent._pendingCompact
86
89
  agent._pendingCompact = null
90
+ // 成功记账(§6.17:成功记账段抽局部——阈值面与守卫面共用同一段,零复制)
91
+ const bookkeepCompaction = () => {
92
+ agent._compressFailures = 0
93
+ agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
94
+ recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
95
+ // Completion info (CONTEXT-COMPACTION §7 D-C2): { mode, tokensFreed, elapsedMs } —
96
+ // the TUI panel renders it; callers that ignore the arg keep prior onCompress semantics.
97
+ callbacks.onCompress?.(agent._lastCompressInfo ?? {})
98
+ ensureAutoReminder(agent)
99
+ }
87
100
  try {
88
101
  let compacted = await compressIfNeeded(
89
102
  agent, threshold, callbacks,
@@ -96,15 +109,7 @@ export async function runCompactionCheck(agent, ctx) {
96
109
  agent.history.push({ role: "user", content: compactNoopNote(), transient: true })
97
110
  compacted = await compressIfNeeded(agent, threshold, callbacks, compactionOverhead, signal)
98
111
  }
99
- if (compacted) {
100
- agent._compressFailures = 0
101
- agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
102
- recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
103
- // Completion info (CONTEXT-COMPACTION §7 D-C2): { mode, tokensFreed, elapsedMs } —
104
- // the TUI panel renders it; callers that ignore the arg keep prior onCompress semantics.
105
- callbacks.onCompress?.(agent._lastCompressInfo ?? {})
106
- ensureAutoReminder(agent)
107
- }
112
+ if (compacted) bookkeepCompaction()
108
113
  } catch (compressError) {
109
114
  // AbortError must not be swallowed: user cancellation must propagate
110
115
  if (compressError?.name === "AbortError" || signal?.aborted) throw compressError
@@ -122,6 +127,35 @@ export async function runCompactionCheck(agent, ctx) {
122
127
  if (compressFallback(agent)) callbacks.onCompress?.(agent._lastCompressInfo ?? {})
123
128
  }
124
129
  }
130
+
131
+ // 溢出守卫(CONTEXT-COMPACTION.md §6.17 / D-CC32——兜底,非主修;本缝 = catch 闭合后、返回前)。
132
+ // 判据 = maxTokensSource !== "absent" ∧ prompt 估算 > limit(limit = window − reserve,恒为数)。
133
+ // **判据不用 maxTokens**:未发时为 undefined ⇒ NaN ⇒ 守卫静默失效(§6.17);absent 档客户端未预留
134
+ // ⇒ 守卫不覆盖(服务端 400 兜——把规格 maxOutput 当实发判会让 doubao 三行永久发不出去)。
135
+ // 真 ⇒ 以 force 强制压缩一次(先压再判);再估仍超 ⇒ 抛三实数、回合请求零发。不截断、不静默削输出。
136
+ const budget = requestBudget(agent.provider)
137
+ if (budget.maxTokensSource !== "absent") {
138
+ let usage = contextUsage(agent, compactionOverhead)
139
+ if (usage.total > budget.limit) {
140
+ const compacted = await compressIfNeeded(
141
+ agent, threshold, callbacks, { ...compactionOverhead, force: true }, signal,
142
+ )
143
+ if (compacted) bookkeepCompaction()
144
+ usage = contextUsage(agent, compactionOverhead)
145
+ if (usage.total > budget.limit) {
146
+ const remedy = budget.limit < 0
147
+ ? `max_tokens ${budget.maxTokens} alone exceeds the window — lower provider.maxTokens or switch to a larger-window model`
148
+ : "reduce the conversation (/compact or prune) before retrying"
149
+ const overflow = new Error(
150
+ `context overflow: prompt ~${usage.total} tokens + max_tokens ${budget.maxTokens} > window ${budget.window}` +
151
+ ` (limit ${budget.limit}) — forced compaction ran but the prompt still exceeds the window; ${remedy}`,
152
+ )
153
+ overflow.kind = "context_overflow"
154
+ overflow.retryable = false
155
+ throw overflow
156
+ }
157
+ }
158
+ }
125
159
  }
126
160
 
127
161
  /**
package/agent/setup.mjs CHANGED
@@ -22,8 +22,8 @@ import { assembleFamilyTools } from "./family-tools.mjs"
22
22
  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
+ import { resolveCompactThreshold } from "../config.mjs"
25
26
 
26
- const DEFAULT_COMPACT_THRESHOLD = 100_000
27
27
  const DOC_SEARCH_LIMIT = 5
28
28
  const DOC_CHUNK_PREVIEW_LEN = 300
29
29
  /** UTF-16 安全截断(2026-09-02 deepseek 400 根因):slice(0, N) 按码元切会把 emoji 代理对切成孤立
@@ -51,7 +51,9 @@ export async function prepareRun(agent, input, callbacks, {
51
51
  injections = null, toolDecorate = undefined,
52
52
  } = {}) {
53
53
  const maxTurns = overrideTurns ?? agent.config?.agent?.maxTurns ?? DEFAULT_MAX_TURNS
54
- const threshold = agent.config?.agent?.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD
54
+ // §6.17 每 run 现算:数值在场 = 显式原样;不在场 = auto 现算(按在用 provider——切模型后下一 run 跟手)。
55
+ // 派生值不落 agent.compactThreshold(显式键永不被改写)。
56
+ const threshold = resolveCompactThreshold(agent.config?.agent?.compactThreshold, agent.provider).value
55
57
  // 注入开关(PROMPT-SYSTEM.md §6.24 · 台账 #1420):判据 `!== false`(缺省 = 现行为零变)——
56
58
  // 逐 run 现读 `agent.config` ⇒ `settings` 工具 ∕ `/config` 写入后本会话下一次组装即生效。
57
59
  const injectDocs = agent.config?.agent?.injectDocs !== false
@@ -62,6 +64,12 @@ export async function prepareRun(agent, input, callbacks, {
62
64
 
63
65
  agent._lastPromptTokens = null
64
66
  agent._usageAtLen = null
67
+ // Cumulative provider cache accounting (batch 2026-10-18-context-cache-visibility):
68
+ // the service reports prompt_cache_hit/miss per response; the terminal faces each kept
69
+ // their own tally, so the CORE had none and `context` could not report cache efficiency.
70
+ agent._cacheHitTokens = 0
71
+ agent._cacheMissTokens = 0
72
+ agent._usageTurns = 0
65
73
  agent.history = repairHistory(agent.history)
66
74
 
67
75
  if (!resume) {
@@ -180,6 +180,12 @@ export async function runTurnLoop(agent, {
180
180
 
181
181
  if (response.usage) {
182
182
  callbacks.onUsage?.(response.usage)
183
+ // Core-side cumulative cache tally (batch 2026-10-18-context-cache-visibility):
184
+ // every agent (parent or child) keeps its own hit/miss so `context` can report
185
+ // cache efficiency instead of the terminal faces being the only tally holders.
186
+ agent._cacheHitTokens = (agent._cacheHitTokens ?? 0) + (response.usage.prompt_cache_hit_tokens ?? 0)
187
+ agent._cacheMissTokens = (agent._cacheMissTokens ?? 0) + (response.usage.prompt_cache_miss_tokens ?? 0)
188
+ agent._usageTurns = (agent._usageTurns ?? 0) + 1
183
189
  if (response.usage.prompt_tokens != null) {
184
190
  agent._lastPromptTokens = response.usage.prompt_tokens
185
191
  agent._usageAtLen = agent.history.length
@@ -23,9 +23,11 @@
23
23
  */
24
24
  export function summarizeEngTaskBook(taskInput) {
25
25
  if (!taskInput) return "(unavailable)"
26
+ // 五项拼装形下,`designPoints` 一段同时承载「设计档落点」与「受影响文件清单」——
27
+ // 前两段同取该块是有意的(语义都在其中);代价 = 摘要里该块出现两次,不是抽取失败。
26
28
  const SECTIONS = [
27
- { name: "Design docs involved", markers: [/Docs? involved/i, /涉及文档/] },
28
- { name: "Affected-file list", markers: [/Files? (?:list|to (?:modify|change)|modified)/i, /受影响文件/, /文件清单/, /涉及文件/] },
29
+ { name: "Design docs involved", markers: [/Docs? involved/i, /涉及文档/, /^Design points/i] },
30
+ { name: "Affected-file list", markers: [/Files? (?:list|to (?:modify|change)|modified)/i, /受影响文件/, /文件清单/, /涉及文件/, /^Design points/i] },
29
31
  { name: "Acceptance criteria", markers: [/Acceptance(?: criteria)?/i, /验收标准/] },
30
32
  ]
31
33
  const lines = taskInput.split("\n")
@@ -0,0 +1,198 @@
1
+ /**
2
+ * agent-tools/batch-gate.mjs — 文档机检门(`batch` 工具两机械点的**单源门**)。
3
+ *
4
+ * 权威规格 = `docs/core/design/DOC-DISCIPLINE.md` §3.20「结构兜底」(判据 / 范围 / 范围分域 / 适用面 /
5
+ * ERROR 消费分野 / 门自身异常 / 不检面)——本档只实现,**不重述口径**(D2)。需求 = `DOC-CHECK.md` F9 / AC-7。
6
+ *
7
+ * 调用面两处、各一取值(`scope` 语义 = 规范「调用面开关,无第三值」):
8
+ * ① `batch.mjs` append 写 §2 / §5(`!depth0 && !review`)⇒ `scope: "writer"`——范围 = 写者触达记账
9
+ * (与工具面 gate 同源 `docsSource="session"`);`REFUSED` ⇒ 调用面 throw;`ERROR` / 门自身异常 ⇒ 放行 +
10
+ * 回执明示(子代理无法重启会话,卡死写账 = 无账之笔;② 会拒收口 ⇒ 轮次不会被当成已通过)。
11
+ * ② `batch-lifecycle.mjs` close ∥ status 值余核「已收口」⇒ `scope: "close"`——范围 = 本批笔域集(触达账;
12
+ * `git diff --name-only <批起 sha> HEAD` 的基 sha 只有父侧掌握 ⇒ 取不到基时只认触达账 + 回执明示)
13
+ * ∪ git 面(`git diff --name-only HEAD` ∪ `git ls-files --others --exclude-standard`,域求交归引擎);
14
+ * `REFUSED`(K ≥ 1)∥ `ERROR` ⇒ 调用面 throw(fail-closed);命中 ⊆ 他批在途集 ⇒ 不阻收口 + 固定行逐档列出。
15
+ *
16
+ * 判据零变:本档不新增判据——只决定「判谁」与「何时强制」;判据与裁定串由产品引擎
17
+ * `doc-check/index.mjs` 产出(判据源唯一)。引擎版本前置同源 = `doc-check/version.mjs`。
18
+ *
19
+ * 两条边界(设计与实测同口径):① **批档自身不入范围**(载体不是判据对象;声明域已排除 `batches`,
20
+ * 传入只产「跳过域外」行 = 假检查);② **本轮提交面不在工具面**——`<批起 sha>` 只有父侧掌握,
21
+ * ② 的写入集只认触达账 + 回执明示「基 sha 未定」,提交面由父侧协议清单兜(不静默)。
22
+ */
23
+ import { execFileSync } from "node:child_process"
24
+ import { relative, resolve } from "node:path"
25
+
26
+ import { owningProject, readManifest } from "../manifest.mjs"
27
+ import { runDocCheck } from "../doc-check/index.mjs"
28
+ import { firstVerdictLine, gateEngineError, staleEngineFiles } from "../doc-check/version.mjs"
29
+ import { batchProjectRoot } from "./batch-paths.mjs"
30
+
31
+ /** 平台路径键(win32 大小写不敏感——只作集比对,非判据面)。 */
32
+ const pathKey = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
33
+
34
+ /** 文档档谓词 + 绝对化(触达账 / git 面同形;非 `.md` 不进范围)。 */
35
+ const isDocPath = (p) => typeof p === "string" && p.trim() !== "" && p.toLowerCase().endsWith(".md")
36
+ function docPaths(list, base) {
37
+ const out = []
38
+ for (const p of Array.isArray(list) ? list : []) if (isDocPath(p)) out.push(resolve(base, p))
39
+ return out
40
+ }
41
+
42
+ /**
43
+ * git 面(他批在途集候选——只取清单,域求交归引擎):`git diff --name-only HEAD` ∪ 未跟踪档。
44
+ * 路径以**仓根**(`rev-parse --show-toplevel`)为基还原——项目根住大仓子目录时条目不丢(清单相对仓根)。
45
+ * 不可得(非仓 / 无 git)⇒ `ok: false` + 空集(调用面回执明示「git 面不可得」,不静默)。
46
+ */
47
+ function gitSurface(root) {
48
+ const run = (cwd, argv) => execFileSync("git", ["-C", cwd, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 8000 })
49
+ try {
50
+ const top = run(root, ["rev-parse", "--show-toplevel"]).trim()
51
+ const base = top || root
52
+ const changed = run(base, ["diff", "--name-only", "HEAD"]).split(/\r?\n/)
53
+ const untracked = run(base, ["ls-files", "--others", "--exclude-standard"]).split(/\r?\n/)
54
+ return { ok: true, base, files: [...changed, ...untracked].filter(Boolean) }
55
+ } catch {
56
+ return { ok: false, base: root, files: [] }
57
+ }
58
+ }
59
+
60
+ /** 命中 → `{ aligned, entries }`:`file` = 判定用(引擎给绝对路径);显示行取引擎已产出的 `✗` 行
61
+ * (报面单源不重写);行数与命中数不等(引擎报面行序变动)⇒ `aligned: false` + 自造行,由调用面回执明示。 */
62
+ function hitEntries(res, root) {
63
+ const lines = res.report.split("\n").filter((l) => l.startsWith("✗ "))
64
+ const hits = [...res.anchorHits, ...res.widthHits]
65
+ const aligned = lines.length === hits.length
66
+ const entries = hits.map((h, i) => ({
67
+ file: h.file,
68
+ line: aligned ? lines[i] : `✗ ${relative(root, h.file).replace(/\\/g, "/")}:${h.line}`,
69
+ }))
70
+ return { aligned, entries }
71
+ }
72
+
73
+ /** 固定行里的定位片(`档:行`——取自命中行;取不到则退回相对路径)。 */
74
+ function locationOf(entry, root) {
75
+ return /(\S+?\.md:\d+)/.exec(entry.line)?.[1] ?? relative(root, entry.file).replace(/\\/g, "/")
76
+ }
77
+
78
+ /** 报面组装:只换首行 + 插入本条附加行(其余行 = 引擎原样,不重写格式);`dropDisposal` ⇒ 剔除引擎的
79
+ * 「处置:同轮修正后复跑…」行(close 面 PASS 且命中全属他批在途时——该行会把读者引去改别人的档)。 */
80
+ function composeReport(res, first, notes, dropDisposal) {
81
+ const lines = String(res.report).split("\n").filter((l) => !(dropDisposal && l.startsWith("处置:")))
82
+ lines[0] = first
83
+ if (notes.length) {
84
+ const at = lines.findIndex((l) => l.startsWith("处置:"))
85
+ if (at >= 0) lines.splice(at, 0, ...notes)
86
+ else lines.push(...notes)
87
+ }
88
+ return lines.join("\n")
89
+ }
90
+
91
+ /** 空范围返回形(`range` 恒在场——回执要读 `范围 N 档`)。 */
92
+ const emptyRange = (basis) => ({ count: 0, k: 0, foreign: 0, basis })
93
+ const errorState = (first, range = emptyRange("none")) => ({ state: "error", first, report: first, hitLines: [], noteLines: [], range })
94
+
95
+ /**
96
+ * 跑一次门(**非抛错**:三因之外的门自身异常收为 `error` 态,由调用面按 `scope` 消费)。
97
+ * @param {{abs:string, cwd?:string, agent?:object, scope?:'writer'|'close', clock?:object}} o
98
+ * `abs` = 目标批次档绝对路径(根解析用);`agent._touchedFiles` = 触达记账;`clock` = 版本判定的测试注入缝。
99
+ * @returns {{state:'pass'|'refused'|'error'|'not-applicable', first:string|null, report:string,
100
+ * hitLines:string[], noteLines:string[], range:{count:number, k:number, foreign:number, basis:string}}}
101
+ * `first` = 唯一机判串(首行 `DOC-CHECK GATE:`);`not-applicable` ⇒ `first = null` + 固定回执行。
102
+ */
103
+ export function runBatchGate({ abs, cwd, agent = {}, scope = "writer", clock } = {}) {
104
+ // `scope` 两取值、无第三值(设计「`scope` 语义」)——未知值不静默降级(降级即 ② 变 fail-open)
105
+ if (scope !== "writer" && scope !== "close") {
106
+ return errorState(`DOC-CHECK GATE: ERROR(未知 scope:${JSON.stringify(scope)}——取值只有 "writer" ∥ "close")`)
107
+ }
108
+ try {
109
+ return evaluate({ abs, cwd, agent, scope, clock })
110
+ } catch (e) {
111
+ const first = `DOC-CHECK GATE: ERROR(门自身异常:${e?.message ?? String(e)})`
112
+ return errorState(first)
113
+ }
114
+ }
115
+
116
+ /** 门体(正常路径——异常由上抛给 `runBatchGate` 收口)。 */
117
+ function evaluate({ abs, cwd, agent, scope, clock }) {
118
+ const base = resolve(cwd ?? process.cwd())
119
+ const root = owningProject(resolve(abs)) ?? batchProjectRoot(base)
120
+ const close = scope === "close"
121
+
122
+ let read
123
+ try { read = readManifest(root) } catch (e) {
124
+ return errorState(`DOC-CHECK GATE: ERROR(manifest 不可读 —— ${e?.message ?? String(e)})`)
125
+ }
126
+ // 适用面(声明面驱动):无 manifest ⇒ 不适用 + 回执明示;**不是豁免清单**——判据参数来自 checkConfig。
127
+ if (!read.ok && read.reason === "missing") {
128
+ return { state: "not-applicable", first: null, report: "[doc-check gate] 未跑(本项目无声明面)", hitLines: [], noteLines: [], range: emptyRange("none") }
129
+ }
130
+ // 引擎版本前置(fail-closed):stale ⇒ 直接 ERROR 串,不进引擎(不产 PASS)。
131
+ const version = staleEngineFiles(clock ?? {})
132
+ if (version.stale.length) {
133
+ const report = gateEngineError(version)
134
+ return { state: "error", first: firstVerdictLine(report), report, hitLines: [], noteLines: [], range: emptyRange("none") }
135
+ }
136
+
137
+ // 范围(判谁):writer = 写者触达账;close = 本批笔域集(触达账)∪ git 面。
138
+ // 批档自身不入范围(载体不是判据对象;声明域已排除 batches ⇒ 传入只产「跳过域外」行 = 假检查)。
139
+ const selfKey = pathKey(resolve(abs))
140
+ const touched = docPaths(agent?._touchedFiles, base).filter((p) => pathKey(p) !== selfKey)
141
+ const git = close ? gitSurface(root) : { ok: false, base: root, files: [] }
142
+ const gitDocs = docPaths(git.ok ? git.files : [], git.base).filter((p) => pathKey(p) !== selfKey)
143
+ const docs = close ? [...new Set([...touched, ...gitDocs])] : touched
144
+ const basis = close ? (git.ok ? "touched+git" : "touched(git 面不可得)") : "touched"
145
+ const res = runDocCheck(root, { mode: "gate", docs, docsSource: close ? "explicit" : "session" })
146
+ if (res.error) return { state: "error", first: firstVerdictLine(res.report), report: res.report, hitLines: [], noteLines: [], range: { count: res.checkFiles.length, k: 0, foreign: 0, basis } }
147
+
148
+ const { aligned, entries } = hitEntries(res, root)
149
+ const ownKeys = new Set(touched.map(pathKey))
150
+ const own = close ? entries.filter((e) => ownKeys.has(pathKey(e.file))) : entries
151
+ const foreign = close ? entries.filter((e) => !ownKeys.has(pathKey(e.file))) : []
152
+ const k = own.length
153
+ const first = k >= 1
154
+ ? `DOC-CHECK GATE: REFUSED(命中 ${k} 条)`
155
+ : (res.checkFiles.length ? "DOC-CHECK GATE: PASS" : "DOC-CHECK GATE: PASS(0 档)")
156
+
157
+ // 附加行(只挂 close 面):他批在途(逐档列出、不计入 K)+ 两条取基明示(不静默)。
158
+ const notes = []
159
+ if (!aligned) notes.push("命中行不可对齐(引擎报面 ✗ 行数与命中数不等)——显示行按自造形,判定按命中对象")
160
+ if (close) {
161
+ if (foreign.length) notes.push(`他批在途 ${foreign.length} 档(非本批笔域 · 未计入 K · 请其作者在其批修复):${foreign.map((e) => locationOf(e, root)).join(" ∥ ")}`)
162
+ if (!git.ok) notes.push("git 面不可得——他批在途集只按触达账判(不静默)")
163
+ notes.push("基 sha 未定——本批笔域集只认触达账(git 提交面由父侧协议清单兜)")
164
+ }
165
+
166
+ return {
167
+ state: k >= 1 ? "refused" : "pass",
168
+ first,
169
+ report: composeReport(res, first, notes, k === 0 && foreign.length > 0),
170
+ hitLines: entries.map((e) => e.line),
171
+ noteLines: notes,
172
+ range: { count: res.checkFiles.length, k, foreign: foreign.length, basis },
173
+ }
174
+ }
175
+
176
+ /** 成功回执行(append / close / status 三面同形;`null` 门 ⇒ 零改——非挂门路径回执逐字旧形)。
177
+ * 附注行(他批在途逐档列出 ∥ 取基 / git 面明示)紧随其后**逐字**列出——不静默吞掉可见性。 */
178
+ export function gateReceiptLine(gate) {
179
+ if (!gate) return ""
180
+ if (gate.state === "not-applicable") return "\n[doc-check gate] 未跑(本项目无声明面)"
181
+ const head = `\n[doc-check gate] ${gate.first} · 范围 ${gate.range?.count ?? 0} 档`
182
+ return [head, ...(gate.noteLines ?? []).map((l) => `\n${l}`)].join("")
183
+ }
184
+
185
+ /**
186
+ * 拒绝串(throw 载荷;两机械点同源措辞):`batch:` 前缀 + verdict 首行 + 逐条 `✗` 行 + 处置句;
187
+ * ① 追加同路既有句「失败 ⇒ 勿 close」(字面同 append 既有拒因,不新造措辞)。
188
+ */
189
+ export function gateRefusalMessage(gate, action = "append") {
190
+ const lead = action === "append"
191
+ ? "batch: 文档机检门拒写(doc-check gate · 结构兜底 ①)—— 本次段写入未落盘,档字节不变。"
192
+ : `batch: 文档机检门拒收口(doc-check gate · 结构兜底 ②)—— ${action === "close" ? "close" : "status → 已收口"} 被拒绝,§1 状态行字节不变。`
193
+ const detail = gate.state === "error" ? String(gate.report ?? "").split("\n").slice(1) : gate.hitLines
194
+ const tail = action === "append"
195
+ ? "处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)。Nothing was written. Do NOT close the record after a refused append — nothing landed, and closing freezes the missing section for good (失败 ⇒ 勿 close)."
196
+ : "处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;命中他批笔域 ⇒ 报告父侧转派作者;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)。Nothing was written."
197
+ return [lead, gate.first, ...(detail ?? []), ...(gate.noteLines ?? []), tail].join("\n")
198
+ }