@thincoder/core 0.10.9 → 0.10.10

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 (42) hide show
  1. package/advisor/messages.mjs +4 -5
  2. package/advisor/project-context.mjs +69 -44
  3. package/agent/family-tools.mjs +9 -7
  4. package/agent/helpers.mjs +6 -0
  5. package/agent/run-start.mjs +1 -0
  6. package/agent/turn-loop.mjs +17 -13
  7. package/agent-tools/batch-gate.mjs +4 -5
  8. package/agent-tools/checkpoint.mjs +11 -6
  9. package/agent-tools/doc-check.mjs +18 -13
  10. package/agent-tools/manifest.mjs +44 -12
  11. package/agent-tools/subagent-spawn.mjs +34 -19
  12. package/agent-tools/subagent.mjs +1 -1
  13. package/agent-tools.mjs +1 -1
  14. package/declaration.mjs +5 -12
  15. package/doc-check/anchors.mjs +51 -59
  16. package/doc-check/index.mjs +96 -134
  17. package/doc-check/width.mjs +9 -54
  18. package/i18n.mjs +5 -0
  19. package/manifest-schema.mjs +7 -35
  20. package/manifest.mjs +4 -4
  21. package/memory/code-search.mjs +5 -14
  22. package/memory/docs.mjs +5 -14
  23. package/memory/read-gate.mjs +26 -0
  24. package/package.json +1 -1
  25. package/prompts/advisor-design.md +89 -38
  26. package/prompts/advisor-round1.md +72 -24
  27. package/prompts/advisor-round2.md +62 -25
  28. package/prompts/advisor-round3.md +68 -24
  29. package/prompts/common.md +212 -57
  30. package/prompts/consult-base.md +10 -3
  31. package/prompts/discipline-engineering.md +180 -53
  32. package/prompts/discipline-normal.md +91 -40
  33. package/prompts/persona-coder.md +3 -1
  34. package/prompts/persona-eng-coder.md +29 -9
  35. package/prompts/persona-eng-designer.md +87 -33
  36. package/prompts/persona-engineering.md +322 -89
  37. package/prompts/persona-explore.md +1 -1
  38. package/prompts/persona-normal.md +16 -8
  39. package/tool-docs/doc_check.md +27 -15
  40. package/tool-docs/manifest-read.md +8 -0
  41. package/tool-docs/manifest.md +9 -8
  42. package/tool-docs/subagent.md +2 -2
@@ -155,15 +155,14 @@ function buildAdvisorUserMessageInner(agent, prior, reviewType, designToken = nu
155
155
  }
156
156
  }
157
157
 
158
- // Engineering mode: the project's DECLARED standards document
159
- // (advisor.standardsDoc) — undeclared degrades visibly (PO-2).
158
+ // Engineering mode: the project standards section — the declaration face is gone with the
159
+ // manifest `advisor` key, so this is always an explicit degradation (PO-2).
160
160
  if (agent.config?.agent?.engineering) {
161
161
  injectProjectStandards(agent, parts, guideRoot)
162
162
  }
163
163
 
164
- // Document map: declared path wins, the built-in fallback probe (docs/README.md
165
- // → docs/design/README.md) stays; neither → explicit degradation sentence
166
- // (never the old silent skip).
164
+ // Document map: derived from the manifest `docRoot` (declared roots' README, then their common
165
+ // ancestor); neither → explicit degradation sentence (never a silent skip).
167
166
  injectDocumentMap(agent, parts, guideRoot ?? agent.cwd)
168
167
 
169
168
  parts.push("## Instructions")
@@ -12,13 +12,14 @@
12
12
  * absent — in a project with a different layout the reviewer still scored the
13
13
  * dimension it could no longer check (silent failure, the worst class: invisible
14
14
  * and unfixable by the user). Missing pieces now say so, and the reviewer is told
15
- * to state the limitation in its findings. Projects can point at their own files
16
- * through the project manifest (advisor.docMap / advisor.standardsDoc).
15
+ * to state the limitation in its findings. The document map is derived from the
16
+ * manifest's `docRoot` (the one place that says where the docs live); the standards
17
+ * face has no derivable convention name and is therefore an explicit degradation.
17
18
  */
18
19
  import { readFileSync, existsSync } from "node:fs"
19
20
  import { resolve, join, relative, dirname, sep } from "node:path"
20
21
  import { providerSpec } from "../config.mjs"
21
- import { loadProjectDeclaration } from "../conventions.mjs"
22
+ import { DEFAULT_MANIFEST, docRootPaths, readManifest } from "../manifest.mjs"
22
23
 
23
24
  /** Project guide (AGENTS.md) injection budget — decision 2026-08-08:
24
25
  * NO fixed truncation; long-context models (1M+) get up to 5% of their context
@@ -32,8 +33,8 @@ const PROJECT_GUIDE_FRACTION = 0.05 // 5% of the reviewer model's context window
32
33
  export const NO_GUIDE_NOTICE = "(No AGENTS.md found — neither at the working directory root nor in any review-scope subdirectory. Judge the user's requirements from the conversation background, and say so explicitly if the requirements are unclear.)"
33
34
 
34
35
  /** Explicit degradation sentences (PORTABILITY design §4.4 — verbatim). */
35
- export const NO_DOC_MAP_NOTICE = "(No document map found under the project root, and none is declared — the Document ownership criterion is degraded: check placement against the Project Guide where present, and state the limitation in your findings.)"
36
- export const NO_STANDARDS_NOTICE = "(No project standards document was declared — judge methodology compliance from the Project Guide (when present) and the review criteria above; state the limitation in your findings.)"
36
+ export const NO_DOC_MAP_NOTICE = "(No document map found — no README.md under the declared document roots and none at their common ancestor — the Document ownership criterion is degraded: check placement against the Project Guide where present, and state the limitation in your findings.)"
37
+ export const NO_STANDARDS_NOTICE = "(No project standards document — nothing in the project declares one — judge methodology compliance from the Project Guide (when present) and the review criteria above; state the limitation in your findings.)"
37
38
  export const NO_GIT_NOTICE = "(No git repository detected — change-set context is unavailable; read the review-scope files directly.)"
38
39
 
39
40
  /**
@@ -126,25 +127,66 @@ export function injectProjectGuide(agent, parts, scopeFiles = []) {
126
127
  return root // guide injected — requirement-fit criteria apply (truthy root)
127
128
  }
128
129
 
130
+ /** `docRoot` probe order: the face a reviewer needs first (design) → down to the batch records. */
131
+ const DOC_ROOT_ORDER = ["design", "requirements", "specs", "modules", "batches"]
132
+
133
+ /** Declared document roots as ABSOLUTE paths, in declaration order (`null` / missing face ⇒ no root). */
134
+ function declaredDocRoots(base) {
135
+ const read = readManifest(base)
136
+ const docRoot = (read.ok && read.manifest ? read.manifest : DEFAULT_MANIFEST).docRoot ?? {}
137
+ const out = []
138
+ for (const key of DOC_ROOT_ORDER) {
139
+ for (const p of docRootPaths(docRoot[key], base)) if (!out.includes(p)) out.push(p)
140
+ }
141
+ return out
142
+ }
143
+
144
+ /** Common ancestor of every root (segment-wise — `docs/design` and `docs/designX` are not siblings).
145
+ * A lone drive root is no map home ⇒ null. */
146
+ function commonAncestor(paths) {
147
+ if (paths.length === 0) return null
148
+ const split = (p) => p.split(/[\\/]+/)
149
+ let acc = split(paths[0])
150
+ for (const p of paths.slice(1)) {
151
+ const segs = split(p)
152
+ let i = 0
153
+ while (i < acc.length && i < segs.length && acc[i] === segs[i]) i++
154
+ acc = acc.slice(0, i)
155
+ }
156
+ if (acc.length === 0) return null
157
+ const joined = acc.join(sep)
158
+ return /^[A-Za-z]:$/.test(joined) ? null : joined
159
+ }
160
+
129
161
  /**
130
- * Inject the project document map (PO-1). Resolution: the DECLARED path
131
- * (`advisor.docMap`, project-root-relative) wins; the built-in probe
132
- * (`docs/README.md` → `docs/design/README.md`) stays as a fallback for projects
133
- * laid out that way. Neither usable → an explicit degradation sentence — never a
134
- * silent skip: the reviewer must know the Document-ownership criterion is unchecked.
162
+ * Discover the document map from the manifest's `docRoot` — a pure fs probe, no index generation
163
+ * (the map is a hand-maintained register; a generated one would be a second index, and a second
164
+ * index is exactly the duplicate pointer the retired declaration face used to create).
165
+ * Ladder: ① `README.md` under each declared doc root (declaration order) ② `README.md` at the common
166
+ * ancestor of every declared root. Neither ⇒ null (caller injects `NO_DOC_MAP_NOTICE` — no silent skip).
167
+ * @param {string} base project root (or any dir inside it — `docRoot` resolves against the project root)
168
+ * @returns {string|null} absolute path of the map file, or null when degraded
169
+ */
170
+ export function findDocumentMap(base) {
171
+ const roots = declaredDocRoots(base)
172
+ for (const r of roots) {
173
+ const p = join(r, "README.md")
174
+ if (existsSync(p)) return p
175
+ }
176
+ const ancestor = commonAncestor(roots)
177
+ if (!ancestor) return null
178
+ const p = join(ancestor, "README.md")
179
+ return existsSync(p) ? p : null
180
+ }
181
+
182
+ /**
183
+ * Inject the project document map (PO-1), derived from `docRoot`. Degrades visibly: the reviewer
184
+ * must know the Document-ownership criterion is unchecked — never a silent skip.
135
185
  * @returns {string|null} the injected map path, or null when degraded
136
186
  */
137
187
  export function injectDocumentMap(agent, parts, root) {
138
188
  const base = root ?? agent.cwd
139
- const conv = loadProjectDeclaration(agent.cwd)
140
- let mapPath = null
141
- if (conv.advisor.docMap) {
142
- const declared = resolve(base, conv.advisor.docMap)
143
- if (existsSync(declared)) mapPath = declared
144
- } else {
145
- mapPath = [resolve(base, "docs", "README.md"), resolve(base, "docs", "design", "README.md")]
146
- .find((p) => existsSync(p)) ?? null
147
- }
189
+ const mapPath = findDocumentMap(base)
148
190
  parts.push("## Document Map")
149
191
  let text = null
150
192
  if (mapPath) {
@@ -166,32 +208,15 @@ export function injectDocumentMap(agent, parts, root) {
166
208
  }
167
209
 
168
210
  /**
169
- * Inject the project standards document (PO-2). DECLARATION ONLY
170
- * (`advisor.standardsDoc`, project-root-relative): the old hardcoded
171
- * `METHODOLOGY.md` probe injected a file most projects do not have — and its
172
- * absence was silent. Undeclared (or unreadable) → explicit degradation sentence.
173
- * @returns {string|null} the injected standards path, or null when degraded
211
+ * Inject the project standards section (PO-2). The declaration face is gone with the manifest
212
+ * `advisor` key, and no standards file name is derivable from what the project does declare (the old
213
+ * `METHODOLOGY.md` probe was deleted exactly because most projects have none). The section stays so the
214
+ * reviewer is TOLD the dimension is unchecked — visible degradation, not a silent skip.
215
+ * @returns {null} always degraded
174
216
  */
175
- export function injectProjectStandards(agent, parts, root) {
176
- const base = root ?? agent.cwd
177
- const conv = loadProjectDeclaration(agent.cwd)
178
- const declared = conv.advisor.standardsDoc ? resolve(base, conv.advisor.standardsDoc) : null
217
+ export function injectProjectStandards(_agent, parts, _root) {
179
218
  parts.push("## Project Standards")
180
- let text = null
181
- if (declared && existsSync(declared)) {
182
- try {
183
- text = readFileSync(declared, "utf8")
184
- } catch (e) {
185
- console.warn(`[advisor] project standards unreadable at ${declared}: ${e.message}`)
186
- }
187
- }
188
- if (text === null) {
189
- parts.push(NO_STANDARDS_NOTICE) // degraded — visible by contract
190
- parts.push("")
191
- return null
192
- }
193
- parts.push("The project declares these standards for this review. Judge the design/changes against them:")
194
- parts.push(text)
219
+ parts.push(NO_STANDARDS_NOTICE) // degraded — visible by contract
195
220
  parts.push("")
196
- return declared
221
+ return null
197
222
  }
@@ -26,7 +26,7 @@ export async function assembleFamilyTools({
26
26
  decorate = null, // object 端差面:{ subagent?, consultStart?, consultStop?, settings? }
27
27
  } = {}) {
28
28
  // CORE-UNIFICATION TOOLS #83:consult 家族随统一登记册自 `../agent-tools.mjs` 取用(单一来源)
29
- const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool, timerTool, advisorTool, engTool, readHistoryTool, contextTool, batchTool, manifestTool, consultStartTool, consultStopTool, parentChannelTool, docCheckTool } = await import("../agent-tools.mjs")
29
+ const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool, timerTool, advisorTool, engTool, readHistoryTool, contextTool, batchTool, manifestTool, manifestReadTool, consultStartTool, consultStopTool, parentChannelTool, docCheckTool } = await import("../agent-tools.mjs")
30
30
  // 台账统一入口(2026-10-05 统一入口批——台账 #923):全量变体 = depth-0 段 ∥ 只读变体 = 全部
31
31
  // depth>0 段。动态 import——ledger 链静态达 node:sqlite(W8 契约②);两变体同名单对象、schema 窄化。
32
32
  const { ledgerTool, ledgerReadTool } = await import("../ledger.mjs")
@@ -149,7 +149,7 @@ export async function assembleFamilyTools({
149
149
  // 团队台账全量变体(五 action——同律:子代理段只挂读变体;登录态门在工具内)
150
150
  teamLedgerTool,
151
151
  // manifest 工具(`MANIFEST.md` §2.10 · 2026-10-08 · 台账 #1098):主 agent 读写出口——
152
- // **两模式同挂**(需求未设模式门);子代理各段零挂(未装配 = 权限面第一道)。
152
+ // **两模式同挂**(需求未设模式门);全量变体仅 depth-0——子代理各段挂只读变体(见下 `:187-193`)。
153
153
  manifestTool,
154
154
  // 文档纪律机检(`DOC-DISCIPLINE.md` §3.20 条目 S):只读报告态——depth-0 主 agent 段;
155
155
  // 写文档 / 设计档的角色段(eng-coder / eng-designer)另挂(见下两分支)。
@@ -182,13 +182,15 @@ export async function assembleFamilyTools({
182
182
  // consult 段不入(其角色语义 = 父发起的一次性会诊,父在其 settle 前不期望中途对话)。
183
183
  // 台账只读变体(query / count)——全部 depth>0 角色段(含 consult ∕ 兜底;写三 schema 级不可达);
184
184
  // 团队台账只读变体(team-ledger 批)同律。
185
- : engChildRole === "eng-coder" ? [parentChannelTool, ledgerReadTool, teamLedgerReadTool, advisorTool, verifyTool, batchTool(batchDoc), docCheckTool,
185
+ // manifest 只读变体(MANIFEST.md §2.10.1 · 2026-10-22 · 台账 #1738):同名 `manifest`、action 枚举只剩 `read`;
186
+ // 与上两条只读先例同律挂在全部 depth>0 角色段(全量变体仅 depth-0——两变体互斥装配,同名不塌缩)。
187
+ : engChildRole === "eng-coder" ? [parentChannelTool, ledgerReadTool, teamLedgerReadTool, manifestReadTool, advisorTool, verifyTool, batchTool(batchDoc), docCheckTool,
186
188
  ...(engChildSubagent ? [engChildSubagent] : [])]
187
- : engChildRole === "eng-designer" ? [parentChannelTool, ledgerReadTool, teamLedgerReadTool, batchTool(batchDoc), docCheckTool,
189
+ : engChildRole === "eng-designer" ? [parentChannelTool, ledgerReadTool, teamLedgerReadTool, manifestReadTool, batchTool(batchDoc), docCheckTool,
188
190
  ...(engChildSubagent ? [engChildSubagent] : [])]
189
- : role === "coder" ? [parentChannelTool, ledgerReadTool, teamLedgerReadTool, verifyTool, advisorTool, ...(batchDoc ? [batchTool(batchDoc)] : [])]
190
- : role === "consult" ? [recentChangesTool, ledgerReadTool, teamLedgerReadTool]
191
- : [parentChannelTool, ledgerReadTool, teamLedgerReadTool]
191
+ : role === "coder" ? [parentChannelTool, ledgerReadTool, teamLedgerReadTool, manifestReadTool, verifyTool, advisorTool, ...(batchDoc ? [batchTool(batchDoc)] : [])]
192
+ : role === "consult" ? [recentChangesTool, ledgerReadTool, teamLedgerReadTool, manifestReadTool]
193
+ : [parentChannelTool, ledgerReadTool, teamLedgerReadTool, manifestReadTool]
192
194
 
193
195
  // 固定段(装配序 = agent.tools → 固定段 → 家族段 → extraTools):timer 全模式全深度;
194
196
  // plan 随模式位——ENG-PLAN-EXCLUSION(FR31 ① · KD8 卸载而非「注册 + 报错」· KD11 全深度两端);
package/agent/helpers.mjs CHANGED
@@ -275,6 +275,12 @@ export function turnFrame(seq, turn, maxTurns) {
275
275
  return { turn: seq, maxTurns: seq - turn - 1 + maxTurns }
276
276
  }
277
277
 
278
+ /** #934(零落笔看门狗——AGENT-LOOP-UPSTREAM.md §6.32.2 ① 判据式):上轮结账至今写序是否推进;非整数(未跟踪面 / NaN)⇒ true 记「有落笔」不判(不用 `?? 0` 兜底——未跟踪面当零落笔正是本收正要消除的误报方向)。 */
279
+ export function wroteSince(agent, mark) {
280
+ const seq = agent._mutationSeq
281
+ return Number.isInteger(seq) ? seq !== mark : true
282
+ }
283
+
278
284
  /** Repair malformed conversation history: remove orphan tool messages and fill missing tool results */
279
285
  export function repairHistory(history) {
280
286
  const out = []
@@ -105,6 +105,7 @@ export async function beginRun(agent, input, callbacks, {
105
105
  // #417(撞帽载荷「本段零落盘轮数」——只报数:零阈值常量 / 零自动动作):段起点复位(首段 /
106
106
  // 续跑新段——与 `_continueSegments` 同点);段内累加,采集点在回合环(与 `_turnSeq` 同源面)。
107
107
  agent._zeroWriteTurns = 0
108
+ agent._segmentTurns = 0 // #934 B 面读数分母(§6.32.4):同点复位——同段同源(分子 ≤ 分母)
108
109
  if (!resume) {
109
110
  // 第 19 批(TURN-ACROSS-SEGMENTS——设计 TURN-CAP-CONTINUE.md §4):链内累计编号
110
111
  // 只在链起点复位——续跑(resume:true)不重置、不回退(编号帧公式见 helpers.mjs
@@ -18,6 +18,7 @@ import {
18
18
  turnFrame, // 第 19 批(TURN-ACROSS-SEGMENTS):跨段累计编号帧(设计 TURN-CAP-CONTINUE.md §4)
19
19
  ContinueError,
20
20
  FILE_MUTATORS, toolTouchPaths,
21
+ wroteSince, // #934(§6.32.2 ① 判据式):写序是否推进——轮顶 ∥ 撞帽收尾两处判据点共用
21
22
  } from "./helpers.mjs"
22
23
 
23
24
  export async function runTurnLoop(agent, {
@@ -57,9 +58,10 @@ export async function runTurnLoop(agent, {
57
58
  // §6.31.5 落痕写点门(#939):载体吸收经 `carrierField` 单点(§6.31.4 同式);动态 import = 零新增静态边(沿上方两条同款)。
58
59
  const { carrierField } = await import("../agent-tools/async-settle.mjs")
59
60
 
60
- // #417(撞帽载荷「本段零落盘轮数」——只报数:零阈值 / 零自动动作):计数基线——回合环逐轮
61
- // 比对 `_touchedFiles` 增量(轮顶对上一轮结账;撞帽收尾轮在环外单结)。
62
- agent._turnFilesMark = agent._touchedFiles.length
61
+ // #417(撞帽载荷「本段零落盘轮数」——只报数:零阈值 / 零自动动作)∥ #934(零落笔看门狗):计数基线——
62
+ // 判据物 = 写序(每次写事件 +1,不去重);`_touchedFiles` 是去重路径集,同档重写长度不变 ⇒ 旧判据在
63
+ // 此读成零落笔(本收正要消除的误报方向)。环内逐轮结账、撞帽收尾轮在环外单结(同一谓词)。
64
+ agent._mutSeqMark = agent._mutationSeq
63
65
 
64
66
  for (let turn = 0; turn < maxTurns; turn++) {
65
67
  // #793(核面小修批——AGENT-LOOP.md §6.2 环边界中止前置 ∥ 需求 F5):环头检查点——中止恒以
@@ -73,21 +75,23 @@ export async function runTurnLoop(agent, {
73
75
  const frame = turnFrame(++agent._turnSeq, turn, maxTurns)
74
76
  agent._currentTurn = frame.turn
75
77
  agent._maxTurns = frame.maxTurns
76
- // #417(撞帽载荷「本段零落盘轮数」——采集点与 `_turnSeq` 同源面):上一轮至今 `_touchedFiles`
77
- // 零增 ⇒ 上一轮零落盘,计数 +1;mark 前移(收尾轮在环外结账——见下方 ContinueError 前)。
78
- // #934(零落笔看门狗——§6.32.2 ① 单点采集):streak 与越阈判定同点——上轮零写 ⇒ +1 并判越阈;
79
- // 有写 ⇒ streak 归零 + 清闩(写后重臂——每连续零写段恰一条)。#417 计数语义逐字保持。
78
+ // #934 B 面读数分母(§6.32.4):本段轮数逐轮 +1——段起点复位见 run-start.mjs(与零落笔计数同点)。
79
+ agent._segmentTurns = (agent._segmentTurns ?? 0) + 1
80
+ // #417(撞帽载荷「本段零落盘轮数」——采集点与 `_turnSeq` 同源面):上一轮至今写序未推进
81
+ // ⇒ 上一轮零落笔,计数 +1;写序标每轮前移(撞帽收尾轮在环外结账——见下方 ContinueError 前)。
82
+ // #934(零落笔看门狗——§6.32.2 ① 单点采集):streak 与越阈判定同点——上轮零落笔 ⇒ +1 并判越阈;
83
+ // 有落笔 ⇒ streak 归零 + 清闩(写后重臂——每连续零落笔段恰一条)。#417 计数与 streak 同源同谓词。
80
84
  if (turn > 0) {
81
- if (agent._touchedFiles.length === agent._turnFilesMark) {
85
+ if (!wroteSince(agent, agent._mutSeqMark)) {
82
86
  agent._zeroWriteTurns += 1
83
87
  agent._zeroWriteStreak += 1
84
- maybeZeroWriteAlert(agent) // #934:连续零写越阈 ⇒ 自动上行提醒(只推——§6.32)
88
+ maybeZeroWriteAlert(agent) // #934:连续零落笔越阈 ⇒ 自动上行提醒(只推——§6.32)
85
89
  } else {
86
90
  agent._zeroWriteStreak = 0
87
91
  agent._zeroWriteAlerted = false // 写后重臂
88
92
  }
89
93
  }
90
- agent._turnFilesMark = agent._touchedFiles.length
94
+ agent._mutSeqMark = agent._mutationSeq
91
95
  // D2 (AGENT-LOOP-SUBAGENT.md §6.7.2): depth>0 children emit a ⟦ev⟧turn progress token each turn —
92
96
  // single emit point covering all three spawn tools; phase=llm (tool/done progress rides
93
97
  // the onToolCall/onToolResult relay — no token for those). 第 19 批:载荷取上方帧值
@@ -271,9 +275,9 @@ export async function runTurnLoop(agent, {
271
275
  injectPostTurn(agent, results, recentCallSigs, callbacks, turn, { suspDriven })
272
276
  }
273
277
 
274
- // #417(撞帽载荷「本段零落盘轮数」):收尾轮(撞帽轮)若零落盘同样计入——轮顶计数只
275
- // 覆盖到倒数第二轮(maxTurns ≤ 0 时环未跑,不虚计)。
276
- if (maxTurns > 0 && agent._touchedFiles.length === agent._turnFilesMark) agent._zeroWriteTurns += 1
278
+ // #417(撞帽载荷「本段零落盘轮数」):收尾轮(撞帽轮)单结——与环内同一谓词(不另作 alert ∥ streak
279
+ // 判定,链尾无下一轮可推报);轮顶计数只覆盖到倒数第二轮(maxTurns ≤ 0 时环未跑,不虚计)。
280
+ if (maxTurns > 0 && !wroteSince(agent, agent._mutSeqMark)) agent._zeroWriteTurns += 1
277
281
  // #793(核面小修批——AGENT-LOOP.md §6.2 环边界中止前置 ∥ 需求 F5):环尾检查点——中止恒以
278
282
  // AbortError 收束(不落 ContinueError——用户 Stop 优先于继续提示;消费契约 = 外层按 reason 判定)。
279
283
  if (signal?.aborted) throw abortError(signal, "agent", "turn-tail")
@@ -16,8 +16,8 @@
16
16
  * 判据零变:本档不新增判据——只决定「判谁」与「何时强制」;判据与裁定串由产品引擎
17
17
  * `doc-check/index.mjs` 产出(判据源唯一)。引擎版本前置同源 = `doc-check/version.mjs`。
18
18
  *
19
- * 两条边界(设计与实测同口径):① **批档自身不入范围**(载体不是判据对象;声明域已排除 `batches`,
20
- * 传入只产「跳过域外」行 = 假检查);② **本轮提交面不在工具面**——`<批起 sha>` 只有父侧掌握,
19
+ * 两条边界(设计与实测同口径):① **批档自身不入范围**(载体不是判据对象——判它只会把载体的行宽记成本批欠账);
20
+ * ② **本轮提交面不在工具面**——`<批起 sha>` 只有父侧掌握,
21
21
  * ② 的写入集只认触达账 + 回执明示「基 sha 未定」,提交面由父侧协议清单兜(不静默)。
22
22
  */
23
23
  import { execFileSync } from "node:child_process"
@@ -123,7 +123,7 @@ function evaluate({ abs, cwd, agent, scope, clock }) {
123
123
  try { read = readManifest(root) } catch (e) {
124
124
  return errorState(`DOC-CHECK GATE: ERROR(manifest 不可读 —— ${e?.message ?? String(e)})`)
125
125
  }
126
- // 适用面(声明面驱动):无 manifest ⇒ 不适用 + 回执明示;**不是豁免清单**——判据参数来自 checkConfig。
126
+ // 适用面(声明面驱动):无 manifest ⇒ 不适用 + 回执明示;**不是豁免清单**——判据参数 = 引擎内置常量。
127
127
  if (!read.ok && read.reason === "missing") {
128
128
  return { state: "not-applicable", first: null, report: "[doc-check gate] 未跑(本项目无声明面)", hitLines: [], noteLines: [], range: emptyRange("none") }
129
129
  }
@@ -135,7 +135,7 @@ function evaluate({ abs, cwd, agent, scope, clock }) {
135
135
  }
136
136
 
137
137
  // 范围(判谁):writer = 写者触达账;close = 本批笔域集(触达账)∪ git 面。
138
- // 批档自身不入范围(载体不是判据对象;声明域已排除 batches ⇒ 传入只产「跳过域外」行 = 假检查)。
138
+ // 批档自身不入范围(载体不是判据对象——判它只会把载体的行宽记成本批欠账)。
139
139
  const selfKey = pathKey(resolve(abs))
140
140
  const touched = docPaths(agent?._touchedFiles, base).filter((p) => pathKey(p) !== selfKey)
141
141
  const git = close ? gitSurface(root) : { ok: false, base: root, files: [] }
@@ -143,7 +143,6 @@ function evaluate({ abs, cwd, agent, scope, clock }) {
143
143
  const docs = close ? [...new Set([...touched, ...gitDocs])] : touched
144
144
  const basis = close ? (git.ok ? "touched+git" : "touched(git 面不可得)") : "touched"
145
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
146
 
148
147
  const { aligned, entries } = hitEntries(res, root)
149
148
  const ownKeys = new Set(touched.map(pathKey))
@@ -21,13 +21,16 @@ import { getAsyncPool } from "./async-settle.mjs"
21
21
  * 段数 / 累计轮次二元文本(§4 留痕口径——两值同读一次):
22
22
  * `_continueSegments`(链内段数,首段 1)· `_turnSeq`(链内累计轮次,跨段不重置)。
23
23
  * 两值均挂子 agent 本体。
24
- * #417 追加第三元「本段零落盘轮数」(`_zeroWriteTurns`——核回合环采集,与 `_turnSeq` 同源面):
25
- * **只报数**(零阈值 / 零自动动作——父侧据此判断「在推进」还是「在原地读」)。缺该字段的
26
- * 执行面(端侧自有回合环、未跟踪)不渲染该元——缺省不冒充 `0`(「未跟踪」不得读作「零消耗」)。
24
+ * #417 追加第三元「本段零落盘轮数」,#934 收正后带分母(`_zeroWriteTurns` of `_segmentTurns`):
25
+ * **只报数**(零阈值 / 零自动动作——父侧据此判断「在推进」还是「在原地读」)。分子分母任一缺字段
26
+ * 或非整数的执行面(端侧自有回合环、未跟踪)不渲染该元——缺省不冒充 `0`(「未跟踪」不得读作「零消耗」)。
27
27
  */
28
28
  function traceText(child) {
29
29
  const base = `segments ${child?._continueSegments ?? 1} · accumulated ${child?._turnSeq ?? 0} turns`
30
- return Number.isInteger(child?._zeroWriteTurns) ? `${base} · zero-write rounds this segment: ${child._zeroWriteTurns}` : base
30
+ const zw = child?._zeroWriteTurns, seg = child?._segmentTurns
31
+ return Number.isInteger(zw) && Number.isInteger(seg)
32
+ ? `${base} · ${zw} of this segment's ${seg} rounds produced no file write`
33
+ : base
31
34
  }
32
35
 
33
36
  /** 留痕片段(§3.1 池条目留痕行):挂池条目摘要**文本**面(status / observe)——桥字段零新增。 */
@@ -45,7 +48,7 @@ function replyHint(parent, carrier, key) {
45
48
 
46
49
  /**
47
50
  * 登记检查点并挂起(异步族 askContinue 的唯一实现——逃逸 ①②③):
48
- * 登记 `carrier._turnCapRec` + 父侧 ask(载荷 = 段数 / 累计轮次 + 去向一句话)——
51
+ * 登记 `carrier._turnCapRec` + 父侧 ask(载荷 = 段数 / 累计轮次 / 本段零落笔读数 + 职责句 + 去向一句话)——
49
52
  * ask 入队即唤醒父侧挂起驱动(`pushChildUpstream`)。
50
53
  * @param {object} child 撞帽的子 agent(`_upstream` 携父与来源标签)。
51
54
  * @param {object} carrier 兑现载体:池条目(子代 / 飞刀)或会诊 session。
@@ -83,7 +86,9 @@ export function registerTurnCapCheckpoint(child, carrier, { turn = 0, signal } =
83
86
  from: child._upstream.label,
84
87
  kind: "ask",
85
88
  message: `[turn cap] ${traceText(child)} — this child hit its per-segment turn cap and is suspended `
86
- + `(history kept, no re-dispatch). ${replyHint(parent, carrier, key)}`,
89
+ + `(history kept, no re-dispatch). `
90
+ + `Check for a non-writing loop before you grant or stop: inspect (subagent action:'observe'), then steer or cancel. `
91
+ + replyHint(parent, carrier, key),
87
92
  })
88
93
  return promise
89
94
  }
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * agent-tools/doc-check.mjs — `doc_check` 工具:文档纪律机检(产品内建 · 两种问法)。
3
3
  *
4
- * 设计 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S(判据 = 锚 ∥ 行宽 · 声明面驱动)。
5
- * 工具形:**无动作枚举**——`mode` = 同一条判据的两种问法(`report` = 全域报告态 ∥ `gate` = 交付时点
6
- * 的范围化裁定);**只读**(零副作用 · 免审批 · 可并行);参数 `target` / `mode` / `docs`
4
+ * 设计 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S(判据 = 锚 ∥ 行宽 · 参数内置常量)。
5
+ * 工具形:**无动作枚举**——`mode` = 同一条判据的两种问法(`report` = 报告态 ∥ `gate` = 交付时点的裁定);
6
+ * **只读**(零副作用 · 免审批 · 可并行);参数 `target` / `mode` / `docs`
7
7
  * (`target` 指向多项目工作区 ⇒ 报明候选并要求指名,同 `manifest` 工具面)。
8
- * 判据参数读目标项目的 `PROJECT-MANIFEST.json` `checkConfig`——不硬编码任何本仓路径(换项目开箱可用)。
8
+ * 判据参数 = 引擎内置常量 `DOC_CHECK_BUILTINS`——不读 manifest、不硬编码本仓路径、不扫目录。
9
+ * 判定范围 = 调用方传入的档:`report` 面 `docs` 必填(不给 ⇒ 明确报错,零遍历);
10
+ * `gate` 面缺省 = 本会话触达记账里的 `.md`(交付面按轮次收窄的唯一来源)。
9
11
  * `report` 非门禁(硬):输出不得被读作拒执行的理由(用户令 > 机检——§2 总则族);
10
12
  * `gate` 的强制面住交付协议 + 批次档 §6 前置,工具本体同样不阻断任何动作。
11
13
  * gate 前置(fail-closed)= 引擎版本一致:本会话进程持启动时加载的 ESM 引擎,引擎档晚于启动 ⇒ ERROR
@@ -30,8 +32,8 @@ export const docCheckTool = {
30
32
  type: "object",
31
33
  properties: {
32
34
  target: { type: "string", description: "Explicit project root to check — defaults to the session anchor; relative paths resolve against it. A workspace target (several projects below it) is reported with the full candidate list — pass the project root you mean." },
33
- mode: { type: "string", enum: ["report", "gate"], description: "How to ask the same two-criteria check. \"report\" (default): the whole declared domain, non-gating report. \"gate\": the delivery gate — only the documents this round touched, whole-file judged, first line a machine-readable verdict (DOC-CHECK GATE: PASS / REFUSED / ERROR)." },
34
- docs: { type: "array", items: { type: "string" }, description: "Gate only: the documents this round touched (paths relative to the project root, or absolute). Omitted in gate mode ⇒ the documents this session wrote. Passing docs with mode \"report\" is refused, not silently ignored." },
35
+ mode: { type: "string", enum: ["report", "gate"], description: "How to ask the same two-criteria check. \"report\" (default): a non-gating report over the documents you list in docs. \"gate\": the delivery gate — the documents this round touched (docs, or this session's written documents when docs is omitted), whole-file judged, first line a machine-readable verdict (DOC-CHECK GATE: PASS / REFUSED / ERROR)." },
36
+ docs: { type: "array", items: { type: "string" }, description: "The documents to judge, as paths relative to the project root or absolute — each is judged whole; nothing else is read except the targets those documents point at. Required in \"report\" mode (there is no default range: omitting it is refused, not widened). Omitted in \"gate\" mode ⇒ the Markdown documents this session wrote. A path that is not on disk or is not a Markdown document is an error (no document judged) in report mode and a visible skipped line in gate mode." },
35
37
  },
36
38
  },
37
39
  readonly: true,
@@ -39,13 +41,13 @@ export const docCheckTool = {
39
41
  const anchor = ctx?.agent?.cwd ?? ctx?.cwd ?? process.cwd()
40
42
  const target = args?.target ? resolve(anchor, String(args.target)) : resolve(anchor)
41
43
  const mode = args?.mode === undefined ? "report" : String(args.mode)
42
- if (mode !== "report" && mode !== "gate") return `doc_check: unknown mode "${mode}" — use "report" (default: the whole declared domain) or "gate" (this round's documents).`
43
- const explicit = Array.isArray(args?.docs) ? args.docs.map(String) : null
44
+ if (mode !== "report" && mode !== "gate") return `doc_check: unknown mode "${mode}" — use "report" (a report over the documents you list) or "gate" (this round's documents).`
45
+ const explicit = Array.isArray(args?.docs) ? args.docs : null
44
46
  if (args?.docs !== undefined && !Array.isArray(args.docs)) {
45
47
  return `doc_check: \`docs\` must be an array of paths (got ${typeof args.docs}) — a bare string is refused rather than read as a whole-domain request; pass ["<path>"].`
46
48
  }
47
- if (mode === "report" && explicit) {
48
- return "doc_check: `docs` is only valid with mode:\"gate\" — report mode always checks the whole declared domain; pass mode:\"gate\" to scope the check to the documents this round touched."
49
+ if (explicit && explicit.some((d) => typeof d !== "string")) {
50
+ return "doc_check: every `docs` entry must be a path string — a non-string entry is refused rather than coerced (no document was judged)."
49
51
  }
50
52
  const view = projectRootView(target)
51
53
  if (view.state === "workspace") {
@@ -64,12 +66,15 @@ export const docCheckTool = {
64
66
  // 引擎版本校验先于引擎(A-DD30 ①):stale 非空 ⇒ 直接 ERROR 串,不进引擎
65
67
  const version = staleEngineFiles()
66
68
  if (version.stale.length) return gateEngineError(version)
67
- // 会话候选先取 `.md`(触达记账含代码档——代码档不是文档,不该进范围行);域求交归引擎单源
69
+ // 会话候选先取 `.md`(触达记账含代码档——代码档不是文档,不该进范围行);可判集归引擎单源
68
70
  const docs = explicit ?? (Array.isArray(ctx?.agent?._touchedFiles) ? ctx.agent._touchedFiles : [])
69
71
  .filter((p) => typeof p === "string" && p.toLowerCase().endsWith(".md"))
70
72
  return runDocCheck(root, { mode: "gate", docs, docsSource: explicit ? "explicit" : "session" }).report
71
73
  }
72
- // 报告态读数标注(A-DD30 ①):引擎陈旧 ⇒ 首行前一行注记;无陈旧 ⇒ 报面逐字同改前
73
- return withEngineNote(runDocCheck(root).report, staleEngineFiles())
74
+ // 报告态:清单是唯一范围来源——不给就是拒绝(不是「全域」)。引擎陈旧 ⇒ 首行前一行注记(A-DD30 ①)。
75
+ if (explicit === null) {
76
+ return "doc_check: `docs` is required in report mode — pass the explicit list of documents to judge (e.g. docs: [\"docs/design/X.md\"]). There is no default range: nothing is scanned unless you name it."
77
+ }
78
+ return withEngineNote(runDocCheck(root, { docs: explicit, docsSource: "explicit" }).report, staleEngineFiles())
74
79
  },
75
80
  }
@@ -11,10 +11,9 @@
11
11
  * - `write` ⇒ **点改**:读现档 → 设节点 → 落盘(`writeManifest` 内先 `validateManifest`——
12
12
  * 编辑结果非法 ⇒ 拒 + 盘零变);**缺档 ⇒ 拒** + 引导 `init`(写不建档)。
13
13
  *
14
- * 写面白名单(判据单源 = `MANIFEST_SCHEMA`——钉死谓词):`keys` 中**非** `nestedKeys` 父键者
15
- * (`version` / `phase` / `promptsLanding` / `codePaths` 整键)+ `nestedKeys` 子键
16
- * (`docRoot.*` / `checkConfig.*` / `index.*`(含 `publicRepos`)/ `advisor.*`);**四族整键写
17
- * (传整个对象)⇒ 拒**;白名单外 ⇒ 拒(拼写保护——未知键写盘 = 静默死键,KD-M1-36 问题本体)。
14
+ * 写面白名单(判据单源 = `MANIFEST_SCHEMA`——钉死谓词):**12 个可写节点** = `keys` 中**非** `nestedKeys`
15
+ * 父键者(`version` / `phase` / `codePaths` 整键)+ `nestedKeys` 子键(`docRoot.*` 五子键 / `index.*` 四子键);
16
+ * **两族整键写(传整个对象)⇒ 拒**;白名单外 ⇒ 拒(拼写保护——未知键写盘 = 静默死键,KD-M1-36 问题本体)。
18
17
  * `value` 解析 = 同款口径 `settings.mjs` 的 `parseValue`(先 `JSON.parse`,失败取字面串;`"null"` ⇒ `null`
19
18
  * ——`docRoot.*` 的「本面无」,KD-M1-37)。
20
19
  *
@@ -31,6 +30,11 @@
31
30
  * 缓存失效(#1367 面⑦):写档成功(init ∥ write——全部成功路径)⇒ `clearDeclarationCache()`——
32
31
  * 「补档即回归」进程内即时(声明 ∥ 范围缓存同清);失败路径不清(盘零变 ⇒ 缓存照旧);
33
32
  * 手工改档 ⇒ 重启生效(零文件侦听)。
33
+ *
34
+ * 只读变体(`manifestReadTool`——2026-10-22 · 台账 #1738):子代理面同名 `manifest`,`action` 枚举只剩
35
+ * `read`(schema 级不可达 = 权限面第一道)+ execute 入口机械拒绝(第二道,providers 不强制 schema enum);
36
+ * `readAction` 的 `canInit=false` 让缺档 / 无项目两态的引导行不指 `init`。全量工具 `manifestTool` 零改
37
+ * (`readonly: false` = 动作级分类面,只读性归 `dispatch-gates` 的 `read` 判定)。
34
38
  */
35
39
  import { existsSync } from "node:fs"
36
40
  import { join, resolve } from "node:path"
@@ -51,8 +55,8 @@ function resolveTarget(args, ctx) {
51
55
  }
52
56
 
53
57
  /** 写面白名单谓词(**钉死**——设计 §2.10):点分路径恰两形——
54
- * ① 单段:`keys` ∩ ∉ `nestedKeys`(= `version` / `phase` / `promptsLanding` / `codePaths` 整键);
55
- * ② 双段:`nestedKeys[父]` 含子键(四族子键);其余(四族整键写 / 未知键 / 更深路径)⇒ 拒。
58
+ * ① 单段:`keys` ∩ ∉ `nestedKeys`(= `version` / `phase` / `codePaths` 整键);
59
+ * ② 双段:`nestedKeys[父]` 含子键(两族子键);其余(两族整键写 / 未知键 / 更深路径)⇒ 拒。
56
60
  * `Array.isArray` 守卫:原型链名(`__proto__` / `constructor` / `toString` 类)取到非数组 ⇒ 落拒面
57
61
  * (不得 TypeError 直出——白名单外一律 `refused` 统一前缀)。 */
58
62
  function isWritableKey(key) {
@@ -90,8 +94,10 @@ function workspaceReason(target, candidates) {
90
94
  }
91
95
 
92
96
  /** read 动作(五态报明——**不抛**):`state=` 稳定锚 + 各态附行(ok 细节面 / missing 引导 /
93
- * workspace 下辖项目全列 / invalid 错误行)。 */
94
- function readAction(target) {
97
+ * workspace 下辖项目全列 / invalid 错误行)。
98
+ * `canInit`(缺省 `true` ⇒ depth-0 输出逐字零变):`false` 时 `missing` / `no-project` 的引导行不指
99
+ * `init`——子代理面无该动作,指向不可达动作会把模型送进必然失败的调用。 */
100
+ function readAction(target, { canInit = true } = {}) {
95
101
  const view = projectView(target)
96
102
  const lines = [`state=${view.state}`]
97
103
  if (view.path) lines.push(`path=${view.path}`)
@@ -102,9 +108,13 @@ function readAction(target) {
102
108
  if (m.unknownKeys?.length) lines.push(`unknownKeys=${m.unknownKeys.join(", ")}`)
103
109
  if (m.errors?.length) lines.push(`errors=${m.errors.join(";")}`)
104
110
  } else if (view.state === "missing") {
105
- lines.push(`hint: run manifest action=init to create the default manifest here`)
111
+ lines.push(canInit
112
+ ? "hint: run manifest action=init to create the default manifest here"
113
+ : "hint: no manifest here — report it; creating one is a main-agent action")
106
114
  } else if (view.state === "no-project") {
107
- lines.push(`hint: no project on the ancestor chain and none below — manifest action=init can create one at the target`)
115
+ lines.push(canInit
116
+ ? "hint: no project on the ancestor chain and none below — manifest action=init can create one at the target"
117
+ : "hint: no project on the ancestor chain and none below — report it; creating one is a main-agent action")
108
118
  } else if (view.state === "workspace") {
109
119
  lines.push(`projects (${(view.candidates ?? []).length}):`)
110
120
  for (const c of view.candidates ?? []) lines.push(`- ${c}`)
@@ -123,7 +133,7 @@ export const manifestTool = {
123
133
  properties: {
124
134
  action: { type: "string", enum: ["read", "init", "write"], description: "read — current manifest + validation state (five-state anchor, never throws); init — create the default manifest when missing (refused when one already exists); write — point-edit one node (key + value) of an existing manifest" },
125
135
  target: { type: "string", description: "Explicit target directory — defaults to the session anchor; relative paths resolve against it. A workspace target (several projects below it) is refused with the full project list — pass the project root you mean." },
126
- key: { type: "string", description: "(write) Dot path of the node to set — version / phase / promptsLanding / codePaths, or a sub-key of docRoot / checkConfig / index / advisor (e.g. docRoot.design / index.publicRepos / advisor.docMap); whole-object writes of those four families are refused", },
136
+ key: { type: "string", description: "(write) Dot path of the node to set — version / phase / codePaths, or a sub-key of docRoot / index (e.g. docRoot.design / index.publicRepos); whole-object writes of those two families are refused", },
127
137
  value: { type: "string", description: "(write) New value — parsed as JSON first (null / true / 5 / [\"a\"]), otherwise kept as a literal string; \"null\" is the explicit no-such-face for docRoot.<key>" },
128
138
  },
129
139
  required: ["action"],
@@ -169,7 +179,7 @@ export const manifestTool = {
169
179
  if (!key) throw new Error("manifest write: key is required (dot path, e.g. docRoot.design)")
170
180
  if (args?.value === undefined) throw new Error("manifest write: value is required (JSON-parsed first, otherwise a literal string)")
171
181
  if (!isWritableKey(key)) {
172
- throw refuse("write", `key "${key}" is not writable — writable nodes: version / phase / promptsLanding / codePaths + sub-keys of docRoot / checkConfig / index / advisor (whole-object writes of the four families are refused)`)
182
+ throw refuse("write", `key "${key}" is not writable — writable nodes: version / phase / codePaths + sub-keys of docRoot / index (whole-object writes of the two families are refused)`)
173
183
  }
174
184
  let current
175
185
  try {
@@ -198,3 +208,25 @@ export const manifestTool = {
198
208
  return `manifest write: set ${key} = ${JSON.stringify(value)}\npath=${file}`
199
209
  },
200
210
  }
211
+
212
+ /** 子代理只读变体(同名 `manifest`——action 枚举只剩 `read`):init / write schema 级不可达(权限面第一道);
213
+ * execute 入口再做一次机械拒绝(providers 不强制 schema enum——第二道)。 */
214
+ export const manifestReadTool = {
215
+ name: "manifest",
216
+ description: DESC("manifest-read"),
217
+ parameters: {
218
+ type: "object",
219
+ properties: {
220
+ action: { type: "string", enum: ["read"], description: "read — current manifest + validation state (five-state anchor, never throws)" },
221
+ target: { type: "string", description: "Explicit target directory — defaults to the session anchor; relative paths resolve against it. A workspace target (several projects below it) is reported with the full project list — pass the project root you mean." },
222
+ },
223
+ required: ["action"],
224
+ },
225
+ readonly: true,
226
+ async execute(args, ctx) {
227
+ if (args?.action !== "read") {
228
+ throw new Error(`manifest read: refused — this read-only variant exposes only action=read (got ${JSON.stringify(args?.action)})`)
229
+ }
230
+ return readAction(resolveTarget(args, ctx), { canInit: false })
231
+ },
232
+ }