@thincoder/core 0.9.1 → 0.9.3

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 (75) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +90 -0
  4. package/advisor/loop.mjs +2 -2
  5. package/advisor/run.mjs +1 -1
  6. package/agent/completion.mjs +3 -1
  7. package/agent/family-tools.mjs +24 -11
  8. package/agent/helpers.mjs +11 -2
  9. package/agent/run-stages.mjs +27 -5
  10. package/agent/setup-reminders.mjs +67 -11
  11. package/agent/setup.mjs +6 -0
  12. package/agent/write-gate.mjs +5 -5
  13. package/agent-tools/advisor-async.mjs +4 -4
  14. package/agent-tools/advisor.mjs +3 -3
  15. package/agent-tools/async-discard.mjs +1 -1
  16. package/agent-tools/audit-block.mjs +106 -0
  17. package/agent-tools/batch-lifecycle.mjs +301 -0
  18. package/agent-tools/batch-segment.mjs +16 -263
  19. package/agent-tools/batch-skeleton.mjs +156 -0
  20. package/agent-tools/batch.mjs +410 -0
  21. package/agent-tools/context.mjs +174 -0
  22. package/agent-tools/eng.mjs +4 -0
  23. package/agent-tools/goal.mjs +7 -0
  24. package/agent-tools/parent-channel.mjs +18 -1
  25. package/agent-tools/plan.mjs +39 -5
  26. package/agent-tools/read-history.mjs +122 -24
  27. package/agent-tools/settings.mjs +4 -2
  28. package/agent-tools/subagent-async.mjs +3 -3
  29. package/agent-tools/subagent-spawn.mjs +29 -101
  30. package/agent-tools/task.mjs +11 -0
  31. package/agent-tools.mjs +9 -2
  32. package/agent.mjs +10 -4
  33. package/config.mjs +1 -1
  34. package/context.mjs +66 -121
  35. package/fts-text.mjs +41 -0
  36. package/generate-title.mjs +6 -6
  37. package/i18n.mjs +4 -4
  38. package/ledger-cmd.mjs +30 -7
  39. package/ledger-db.mjs +22 -2
  40. package/ledger-executors.mjs +103 -0
  41. package/ledger-surface.mjs +15 -8
  42. package/ledger.mjs +15 -4
  43. package/manifest.mjs +172 -60
  44. package/memory/core.mjs +4 -18
  45. package/memory/schema.mjs +4 -11
  46. package/package.json +22 -1
  47. package/prompts/advisor-design.md +1 -1
  48. package/prompts/advisor-round2.md +1 -1
  49. package/prompts/advisor-round3.md +1 -1
  50. package/prompts/common.md +3 -3
  51. package/prompts/discipline-engineering.md +17 -2
  52. package/prompts/persona-eng-coder.md +1 -1
  53. package/prompts/persona-eng-designer.md +1 -1
  54. package/prompts/persona-engineering.md +9 -6
  55. package/session-gc.mjs +129 -74
  56. package/session-index-build.mjs +298 -0
  57. package/session-index-cmd.mjs +61 -0
  58. package/session-index-pass.mjs +95 -0
  59. package/session-index-query.mjs +102 -0
  60. package/session-index.mjs +285 -0
  61. package/session-lifecycle.mjs +18 -5
  62. package/session-slots-manifest.mjs +55 -3
  63. package/session-stale.mjs +247 -0
  64. package/token-window.mjs +188 -0
  65. package/tools/bash.mjs +4 -15
  66. package/tools/execute.mjs +5 -13
  67. package/tools/git-checkpoint.mjs +1 -1
  68. package/tools/git-ext.mjs +23 -20
  69. package/tools/git-run.mjs +141 -0
  70. package/tools/git.mjs +56 -45
  71. package/tools/index.mjs +3 -1
  72. package/tools/process-tree.mjs +20 -0
  73. package/tools/shared.mjs +8 -4
  74. package/traces/trace-cleanup.mjs +109 -0
  75. package/traces/trace-store.mjs +32 -36
@@ -0,0 +1,410 @@
1
+ /**
2
+ * agent-tools/batch.mjs — 批次档生命周期工具(KD-4 拆分主档:action 分发 + append 迁移面 +
3
+ * 路径解析单源 + #84 记账缝)。
4
+ *
5
+ * 单工具 `batch`,action = create(建档,§4.11)/ append(段写入——原 batch-segment.mjs 语义
6
+ * 逐字迁移,§4.1 全表)/ status(段属主状态行流转,§4.12)/ close(收口冻结,§4.13)。
7
+ * 权威规格 = `docs/core/design/BATCH-RECORD.md` §4(契约/挂载/用例 BR-1–26/骨架 §4.10/
8
+ * 参数面 §4.11–§4.13/别名 §4.14);骨架与判定字面单源 = `batch-skeleton.mjs`;lifecycle 面 =
9
+ * `batch-lifecycle.mjs`。**依赖单向(KD-4):skeleton ← lifecycle ← 主档**。
10
+ *
11
+ * 身份判据(D-BR17/D-BR18/D-BR21):主 agent 调用 ctx 带 `depth === 0`(dispatch 装配);
12
+ * eng 子代理 `depth > 0`(`agent._role` 定段);评审实例 ctx 无 depth 但工具以 `review: true`
13
+ * 绑定。create/close 仅 depth-0 放行;depth-0 的 append/status/close 可选 `path`(D-BR21:
14
+ * 缺省 = findInFlightBatch 取唯一在飞批,0/复数 ⇒ throw);子代理/评审传 path ⇒ 拒(目标 =
15
+ * spawn/实例注入,语法上写不到别处——运行期门拒)。append 段白名单:depth-0 → §1/§4/§6 ·
16
+ * eng-designer → §2 · 评审 → §3 · eng-coder → §5;status 写域 = 调用者自己段(主 agent 仅 §1
17
+ * ——轮 2 #3 裁定②)。append 迁移面错误串**逐字保持 "batch_segment:" 前缀**(§4.1 全表锚断言);
18
+ * "batch:" 前缀 = 本批新增错误面(create/close/status/findInFlight/path 门/dispatch)。
19
+ *
20
+ * 导出面:`batchTool`(主名)· `batchSegmentTool`(**过渡别名**——name 仍 "batch_segment",
21
+ * schema 仅 {segment,text}、描述逐字,execute ⇒ append 同一执行体;撤除判据 = BATCH-RECORD
22
+ * §4.14)· `resolveBatchDocPath` · `batchDocBases` · `batchDocForReview` ·
23
+ * `configureBatchSegment` / `resetBatchSegment`(#84 记账缝——契约名不变,AC-11 零改)·
24
+ * `MAX_TEXT_CHARS` · `SEGMENT_BY_ROLE`(re-export——shim 导出全超集需要)。
25
+ *
26
+ * #84 记账面(CORE-UNIFICATION §2.13.4):写入成功后的记账**按端注入**——核内缺省 no-op = CLI
27
+ * 语义零行为变;端装配经 `configureBatchSegment({ onWrite })` 覆盖(VSC 侧现形 = `_touchedFiles`
28
+ * 记账)。核内零端名分支(契约 5——只认 `onWrite` 函数名)。
29
+ */
30
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
31
+ import { resolve } from "node:path"
32
+
33
+ import { docRootBase, docRootPaths, readManifest } from "../manifest.mjs"
34
+ import { SEGMENT_BY_ROLE, readBatchStatusLine, sectionHeaderRe, findPlaceholderResidue, placeholderResidueError } from "./batch-skeleton.mjs"
35
+ import { closeBatchRecord, createBatchRecord, findInFlightBatch, statusBatchRecord } from "./batch-lifecycle.mjs"
36
+
37
+ export { SEGMENT_BY_ROLE }
38
+
39
+ /** text 单次上限(§2.20.1——超出引导分段追加,不承诺"不新盖戳")。append 迁移域私有区常量
40
+ * (随执行体自 batch-segment.mjs 迁入——骨架档只住骨架/状态行单源,lifecycle 档头枚举口径);
41
+ * export = shim 导出全超集(旧 `batch-segment.mjs` 导出面等价保持)。 */
42
+ export const MAX_TEXT_CHARS = 20000
43
+ /** 工具写入的轮次节标题形态(N 计数口径 = 该形态行;`### 轮次与发现(…)` 骨架行不匹配)。 */
44
+ const ROUND_HEADING_RE = /^### 轮次 \d+(评审子代理)/
45
+ /** 凭证形态(§2.7 冒号态):`[DESIGN-TOKEN:…]` 与 `designId: …`——本节自有正则。 */
46
+ const CRED_RE = /\[DESIGN-TOKEN:[^\]]*\]|designId\s*:\s*\S+/g
47
+ const CRED_TEST_RE = /\[DESIGN-TOKEN:[^\]]*\]|designId\s*:\s*\S+/
48
+
49
+ // ─── #84 记账面注入缝(「记账面按端注入」,形态参 §2.13.5 注入缝)───────────────
50
+ /**
51
+ * 写入成功后的记账注入面(**缺省 no-op** = CLI 语义零行为变):端装配层可覆盖——把写成的
52
+ * 批次档记入调用者写域(VSC 侧现形 = `agent._touchedFiles` 记账,供子代理合入 / recent_changes /
53
+ * verify / advisor 默认范围消费)。核内零端名分支(契约 5——本档只认 `onWrite` 函数名)。
54
+ * 形态与 `tools/write-path.mjs` 的 `configureWritePath` 同款:模块级、缺省不覆盖、null 撤销。
55
+ */
56
+ let injectedOnWrite = null
57
+ export function configureBatchSegment(impl) {
58
+ injectedOnWrite = impl && typeof impl.onWrite === "function" ? impl.onWrite : null
59
+ }
60
+ /** 撤销注入(测试与端装配生命周期用——缺省态 = 零记账)。 */
61
+ export function resetBatchSegment() { injectedOnWrite = null }
62
+
63
+ /** 可读文件判据(存在且为文件——目录/缺失同判不可读;与 lifecycle 档同名 helper 同型——
64
+ * KD-4 单向依赖下各自持有,三行谓词不构成第二权威源)。 */
65
+ function readableFile(abs) {
66
+ try { return existsSync(abs) && statSync(abs).isFile() } catch { return false }
67
+ }
68
+
69
+ /**
70
+ * 批次档路径门禁(评审侧 §2.20.2 口径 = **「若传则须可读」**):空/非字符串/不可读 → throw。
71
+ * 非空且可读 → 返回绝对路径(`\` 归一——照 `files`/`batchDoc` spawn 门先例)。
72
+ * v2:cwd 不可读 → manifest `docRoot.batches` 复判(M3 模块设计 §2.1#4——N3 可迁移;
73
+ * 值形态 = 串 | 多根数组——逐基底按序复判,首个可读者胜)。
74
+ */
75
+ export function resolveBatchDocPath(cwd, given) {
76
+ const base = cwd ?? process.cwd()
77
+ const raw = typeof given === "string" ? given.trim() : ""
78
+ if (!raw) {
79
+ throw new Error("batchDoc must be a non-empty path to the batch record — pass the batch record currently in flight, or omit the parameter entirely when no batch record is in flight.")
80
+ }
81
+ const abs = resolve(base, raw.replace(/\\/g, "/"))
82
+ if (readableFile(abs)) return abs
83
+ // 双基底(M3 模块设计 §2.1#4):cwd 不可读 → manifest docRoot.batches 复判(逐基底按序,
84
+ // 首个可读者胜);manifest 缺失 / 非法 / 读错 → 无第二基底(v1 单基底语义零变),
85
+ // 全不可读 → throw。
86
+ let man = { ok: false }
87
+ try { man = readManifest(base) } catch { /* 权限等读错——按无 manifest 处理(v1 语义) */ }
88
+ if (man.ok) {
89
+ for (const root of docRootPaths(man.manifest?.docRoot?.batches, base)) {
90
+ const alt = resolve(root, raw.replace(/\\/g, "/"))
91
+ if (readableFile(alt)) return alt
92
+ }
93
+ }
94
+ throw new Error(`batchDoc is not a readable file: ${raw} — pass the path of the batch record currently in flight (a path that resolves to an existing file), or omit the parameter when no batch record is in flight.`)
95
+ }
96
+
97
+ /**
98
+ * 批次档基底根数组(**声明面单源**——轮 2 裁定:create 相对基底 / depth-0 在飞扫描共用,
99
+ * 不写死 `docs/batches`):manifest `docRoot.batches`(串 | 多根数组)→ `docRootPaths`
100
+ * 归一去重(相对基底按项目根解析);manifest 缺失/非法/读错 → 回退默认基底 `"docs/batches"`
101
+ * (v1 单基底语义零变)。恒返回非空数组(create 的 bases[0] / findInFlightBatch 扫描面消费)。
102
+ * @param {string} cwd
103
+ * @returns {string[]}
104
+ */
105
+ export function batchDocBases(cwd) {
106
+ const base = cwd ?? process.cwd()
107
+ let man = { ok: false }
108
+ try { man = readManifest(base) } catch { /* 读错 → 按无 manifest 处理(回退默认基底) */ }
109
+ const roots = man.ok ? docRootPaths(man.manifest?.docRoot?.batches, base) : []
110
+ return roots.length ? roots : [resolve(docRootBase(base), "docs/batches")]
111
+ }
112
+
113
+ /**
114
+ * 设计评审的实例绑定解析(§2.20.2/§2.20.3——评审实例的唯一路径来源):
115
+ * - 同步路径:调用方(advisor 工具)把实例绑定显式放进 callbacks(**带 batchDoc 键**,
116
+ * 未绑定即 null)——以此为准,不再回看池条目(防同步/异步混跑时串档);
117
+ * - 异步路径:本评审所在**池条目**的 `run.batchDoc`(文档集 = 实例键,各评审各取各条)。
118
+ */
119
+ export function batchDocForReview(agent, documents, callbacks = null) {
120
+ if (callbacks && "batchDoc" in callbacks) return callbacks.batchDoc ?? null
121
+ const key = JSON.stringify([...(documents ?? [])].sort())
122
+ for (const e of agent?._asyncAdvisors?.values?.() ?? []) {
123
+ if (e.status === "running" && e.reviewType === "design" && JSON.stringify([...(e.documents ?? [])].sort()) === key) {
124
+ return e.run?.batchDoc ?? null
125
+ }
126
+ }
127
+ return null
128
+ }
129
+
130
+ // ─── append 迁移面(原 batch-segment.mjs :129-184 私有区——逐字迁移,错误串逐字保序)───
131
+
132
+ /** 段号解析:`§2` / `2` / `§2 批次任务` 均可;无法解析 → null。 */
133
+ function segmentNumber(raw) {
134
+ const m = /^§?\s*(\d+)/.exec(String(raw ?? "").trim())
135
+ return m ? Number(m[1]) : null
136
+ }
137
+
138
+ /** 身份 → 可写段号;无写权身份 → null。评审实例绑定优先(评审者的身份即"设计评审"。)
139
+ * depth-0 的段白名单(append = §1/§4/§6)由 appendBatchRecord 分支,不经本表。 */
140
+ function allowedSegment(agent, review) {
141
+ if (review) return 3
142
+ return SEGMENT_BY_ROLE[agent?._role] ?? null
143
+ }
144
+
145
+ /**
146
+ * 凭证剥除 + 伪造轮次标题丢弃(§2.20.1 F6/AC30/AC34)。
147
+ * 逐行处理:含凭证形态的行 → 剥除该子串,剥后为空则整行丢弃(其余内容逐字保留)。
148
+ * `dropStamp`(仅 §3 目标段):调用方自带的 `### 轮次 N(评审子代理)` 行被丢弃——
149
+ * 工具生成才是唯一来源戳(否则伪造戳会污染 N 计数)。
150
+ */
151
+ function sanitizeText(text, dropStamp) {
152
+ const kept = []
153
+ for (const line of text.replace(/\r\n?/g, "\n").split("\n")) {
154
+ if (dropStamp && ROUND_HEADING_RE.test(line)) continue
155
+ if (!CRED_TEST_RE.test(line)) { kept.push(line); continue }
156
+ const cleaned = line.replace(CRED_RE, "").replace(/[ \t]+$/, "")
157
+ if (cleaned.trim()) kept.push(cleaned)
158
+ }
159
+ return kept.join("\n")
160
+ }
161
+
162
+ /** §3 内工具写入的轮次行计数(N = 计数 + 1;收窄口径——只数工具戳形态)。 */
163
+ function roundCount(src, headerMatch, endIdx) {
164
+ const body = src.slice(headerMatch.index + headerMatch[0].length, endIdx)
165
+ return body.split("\n").filter((l) => ROUND_HEADING_RE.test(l)).length
166
+ }
167
+
168
+ /** 段尾定位 + 插入(append-only:只在段尾插入,既有字节不变;返回新全文 + 本轮 N)。 */
169
+ function insertIntoSection(src, seg, text) {
170
+ const hdr = sectionHeaderRe(seg).exec(src)
171
+ if (!hdr) {
172
+ throw new Error(`batch_segment: the bound batch record has no "## §${seg}" section header — the six-section skeleton is written by the record's creator before any segment write. Fix: ask the parent/creator to add the "## §${seg} …" heading (with the template's sub-headings) first, then call batch_segment again. Nothing was written.`)
173
+ }
174
+ const nextRe = /^## §\d/gm
175
+ nextRe.lastIndex = hdr.index + hdr[0].length
176
+ const next = nextRe.exec(src)
177
+ const endIdx = next ? next.index : src.length
178
+ // 来源戳(仅 §3):工具生成标题 + N——调用方写不进自己的标题(sanitize 已丢弃同名行)。
179
+ const roundN = seg === 3 ? roundCount(src, hdr, endIdx) + 1 : 0
180
+ const payload = (seg === 3 ? `### 轮次 ${roundN}(评审子代理)\n\n` : "") + text
181
+ const before = src.slice(0, endIdx)
182
+ const after = src.slice(endIdx)
183
+ const eol = src.includes("\r\n") ? "\r\n" : "\n"
184
+ const lead = before === "" ? "" : before.endsWith(eol + eol) ? "" : before.endsWith(eol) ? eol : eol + eol
185
+ // 尾随 eol 恒加(块自成行);后面还有段标题时再补一个空行(保持模板的空行分隔)。
186
+ const written = before + lead + payload.replaceAll("\n", eol) + eol + (after === "" ? "" : eol) + after
187
+ return { written, roundN }
188
+ }
189
+
190
+ /**
191
+ * append 执行体(action=append——原 batch_segment execute 迁移 + depth-0 分支):
192
+ * - 段白名单:depth-0 → §1/§4/§6(新错误面,"batch:" 前缀);eng-designer → §2 · 评审 → §3 ·
193
+ * eng-coder → §5(迁移错误面,"batch_segment:" 前缀**逐字保持**);
194
+ * - 目标定位(D-BR21):depth-0 path 可选(缺省 = findInFlightBatch 唯一在飞批);子代理/评审
195
+ * 传 path ⇒ 拒,目标 = spawn/实例绑定;
196
+ * - 其余(gate / text 校验 / 剥凭证 / 骨架保护 / **死占位机检(F11-C)** / 插入 / 记账 / 回执)逐字保持原语义。
197
+ */
198
+ function appendBatchRecord({ args, ctx, review, batchDoc, onWritten }) {
199
+ const agent = ctx?.agent ?? {}
200
+ const cwd = agent.cwd ?? process.cwd()
201
+ const depth0 = ctx?.depth === 0
202
+ const seg = allowedSegment(agent, review)
203
+ if (!depth0 && seg === null) {
204
+ throw new Error("batch_segment: no segment is writable by this caller — the channel exists for eng-designer (§2), eng-coder (§5) and design reviews bound to a batch record (§3); the parent agent writes §1/§4/§6 through ordinary document writes.")
205
+ }
206
+ const n = segmentNumber(args?.segment)
207
+ if (n === null) {
208
+ if (depth0) {
209
+ throw new Error(`batch: unknown segment ${JSON.stringify(args?.segment ?? null)} — depth-0 append writes §1/§4/§6 of the in-flight record (pass the section number you write, e.g. "§4").`)
210
+ }
211
+ throw new Error(`batch_segment: unknown segment ${JSON.stringify(args?.segment ?? null)} — pass the section number you write (e.g. "§${seg}").`)
212
+ }
213
+ if (depth0) {
214
+ if (n !== 1 && n !== 4 && n !== 6) {
215
+ throw new Error(`batch: §${n} is not yours to write — depth-0 append writes §1/§4/§6 only (一段一作者: §2 = eng-designer, §3 = design review, §5 = eng-coder).`)
216
+ }
217
+ } else if (n !== seg) {
218
+ throw new Error(`batch_segment: §${n} is not yours to write — this caller writes §${seg} only (一段一作者: eng-designer → §2, design review → §3, eng-coder → §5).`)
219
+ }
220
+ // 目标定位(D-BR21)——path 拒面先于绑定解析(与 status 同序:段白名单 → path 门 → 取值)。
221
+ const givenPath = typeof args?.path === "string" && args.path.trim() ? args.path : null
222
+ let abs
223
+ if (givenPath) {
224
+ if (!depth0) {
225
+ throw new Error("batch: path is a depth-0-only parameter (D-BR21) — your target record arrives via the spawn binding / the review instance key. Nothing was written.")
226
+ }
227
+ abs = resolveBatchDocPath(cwd, givenPath)
228
+ } else if (depth0) {
229
+ abs = findInFlightBatch(cwd, batchDocBases(cwd))
230
+ } else {
231
+ if (!batchDoc) {
232
+ throw new Error("batch_segment: no batch record is bound to this caller — there is no path parameter by design (the target arrives via the spawn binding / the review instance key). Report the section as not written.")
233
+ }
234
+ abs = resolveBatchDocPath(cwd, batchDoc)
235
+ }
236
+ const src = readFileSync(abs, "utf8")
237
+ const status = readBatchStatusLine(src)
238
+ if (status === "closed") {
239
+ throw new Error("batch_segment: 已收口档不回改 — the bound batch record's §1 status line contains 「已收口」, so the record is frozen: its body is never written to again (整档冻结;改 = 新批新档). Nothing was written. Report the section as not written.")
240
+ }
241
+ if (status === "unknown") {
242
+ throw new Error("batch_segment: 状态行不可解析或缺失 — the bound batch record has no §1 `**状态行**:` line whose value contains 已收口 or 进行中 (fail-closed: the write is refused as if frozen). Ask the record's creator to set the §1 status line, then call again. Nothing was written.")
243
+ }
244
+ if (typeof args?.text !== "string") {
245
+ throw new Error("batch_segment: text must be a string (the markdown to append).")
246
+ }
247
+ if (args.text.length > MAX_TEXT_CHARS) {
248
+ throw new Error(`batch_segment: text is ${args.text.length} characters — the limit is ${MAX_TEXT_CHARS} per call. Split it into multiple calls: each call becomes its own section and the round number N continues (分段追加——每次调用各成节、N 顺延).`)
249
+ }
250
+ if (/^## §\d/m.test(args.text)) {
251
+ throw new Error("batch_segment: the text contains a section header line matching `^## §N` — that would break section location and the append-only guarantee. Rewrite it (escape the heading, or drop the leading `## §N`), then call again (骨架保护). Nothing was written.")
252
+ }
253
+ const body = sanitizeText(args.text, n === 3).replace(/^\n+/, "").replace(/\s+$/, "")
254
+ if (!body.trim()) {
255
+ throw new Error("batch_segment: nothing to append — the text is empty after credential stripping (credential values never reach the record).")
256
+ }
257
+ // F11-C(挂点:gate / text 校验后 · insertIntoSection 前):档头 + 目标段死占位残留 ⇒ 拒
258
+ // (他段占位不拦——一段一作者;骨架枚举单源 = skeleton 档 TEMPLATE_PLACEHOLDERS)。
259
+ const residues = findPlaceholderResidue(src, n)
260
+ if (residues.length) throw new Error(placeholderResidueError(residues))
261
+ const { written, roundN } = insertIntoSection(src, n, body)
262
+ writeFileSync(abs, written)
263
+ // #84:记账面按端注入(缺省 no-op = CLI 语义零行为变——端装配经 configureBatchSegment 覆盖)。
264
+ onWritten?.(agent, abs)
265
+ return `batch_segment: appended ${body.length} characters to §${n} of the batch record${roundN ? ` (### 轮次 ${roundN}(评审子代理))` : ""}.`
266
+ }
267
+
268
+ /**
269
+ * 批次档生命周期工具工厂(主名 `batch`——单工具四 action)。
270
+ * @param {string|null} batchDoc — 绑定的目标档(spawn:`child._batchDoc`;评审:实例键;
271
+ * depth-0 主 agent 挂载时 = null——目标走 D-BR21 可选 path / 在飞扫描)
272
+ * @param {{review?: boolean}} [opts] — review=true 表示"设计评审实例"形态(写 §3 + 工具盖戳)
273
+ */
274
+ export function batchTool(batchDoc = null, { review = false } = {}) {
275
+ return {
276
+ name: "batch",
277
+ description:
278
+ "Batch-record lifecycle tool (一段一作者 — six append-only sections, one author each). " +
279
+ "actions: create (depth-0 only — write a new six-section skeleton record; pass path + topic + source) · " +
280
+ "append (add text to the END of YOUR section — existing lines are never rewritten or deleted) · " +
281
+ "status (update YOUR section's `**状态行**:` line — the value must carry exactly ONE legal keyword of your section's STATUS_WORDS entry, decorated only by surrounding symbols / a trailing ISO date / whitespace; prose goes to the separate `note` field, which lands as a parenthetical) · " +
282
+ "close (depth-0 only — freeze the record: §1 → 「已收口 <date>」, every further write is refused). " +
283
+ "Identity fixes what you may write (段白名单): main agent (depth 0) — append §1/§4/§6, status §1 only, create/close; eng-designer — §2; eng-coder — §5; design review (review binding) — §3. A write outside your own section is refused. " +
284
+ "Skeleton placeholders (dead literals — `#<编号>` / `<板块>` and the older `<BATCH-ID>` / `<讨论来源>`) in the record header or in your target section block append/status: fill them first (the main agent fills the header right after create); `<§N 模板占位:…>` template lines are legal and never blocked. " +
285
+ "Target record: sub-agents and design reviews have NO path parameter in practice — the record arrives via the spawn binding / the review instance key (passing path is refused); depth-0 MAY pass path (若传则须可读), omitting it picks the unique in-flight record (0 or ≥2 in flight ⇒ refuse — pass path). " +
286
+ "A design review's append is stamped by the tool with a `### 轮次 N(评审子代理)` heading — N is tool-counted; do not write your own heading (it would be dropped). " +
287
+ "Credential values are stripped mechanically before writing (never write a token or designId value). " +
288
+ "Failures are hard and visible (no silent fallback): if a write is refused or fails, say so in your report — “§× 未写入”.",
289
+ parameters: {
290
+ type: "object",
291
+ properties: {
292
+ action: {
293
+ type: "string",
294
+ enum: ["create", "append", "status", "close"],
295
+ description: "Which lifecycle step to run: create (new record) / append (your section) / status (your section's status line) / close (freeze the record).",
296
+ },
297
+ path: {
298
+ type: "string",
299
+ description: "depth-0 only (D-BR21) — the batch record to operate on (若传则须可读). Omit it to target the unique in-flight record (0 or ≥2 in flight ⇒ refuse). Sub-agents and design reviews: do NOT pass path — your target record arrives via the spawn binding / the review instance key.",
300
+ },
301
+ segment: {
302
+ type: "string",
303
+ description: "append/status only — the batch-record section you are writing (e.g. \"§4\"). Declares the section number only; your identity decides what is actually writable (main agent: append §1/§4/§6 · status §1; eng-designer §2; design review §3; eng-coder §5).",
304
+ },
305
+ text: {
306
+ type: "string",
307
+ description: "append only — the markdown to append (verbatim — findings table + VERDICT + counts for a design review §3). Limit 20000 characters per call; longer content is refused — split it into multiple calls (each call becomes its own section, N continues).",
308
+ },
309
+ value: {
310
+ type: "string",
311
+ description: "status only — the new status-line value (single line; exactly one legal keyword of your section's STATUS_WORDS entry: §1 进行中/已收口 · §2 设计完成 · §3 评审完成 · §5 实施完成 — plus decoration only: surrounding symbols / a trailing ISO date / whitespace; prose goes to `note`).",
312
+ },
313
+ note: {
314
+ type: "string",
315
+ description: "status only — prose that lands as a parenthetical on the status line (`**状态行**:进行中(note)`). Single line; must carry no STATUS_WORDS keyword (those go in `value`) and no skeleton placeholders. Omit for a plain value.",
316
+ },
317
+ topic: {
318
+ type: "string",
319
+ description: "create only — the batch subject word shared by the record header (keep the file name aligned with it).",
320
+ },
321
+ source: {
322
+ type: "string",
323
+ description: "create only — REQUIRED: the record's origin line (来源 = …; single line). It fills the header's 编制 line; without it the skeleton's placeholder would block the record's first append/status (topic-style fail-closed). Nothing is written when it is missing.",
324
+ },
325
+ date: {
326
+ type: "string",
327
+ description: "create only — the record date (YYYY-MM-DD); defaults to today.",
328
+ },
329
+ prev: {
330
+ type: "string",
331
+ description: "create only — the predecessor pointer line (前情); a leading `前情 = ` prefix is stripped automatically (idempotent — repeated prefixes included); defaults to 无(独立批).",
332
+ },
333
+ },
334
+ required: ["action"],
335
+ },
336
+ async execute(args, ctx) {
337
+ const agent = ctx?.agent ?? {}
338
+ const cwd = agent.cwd ?? process.cwd()
339
+ const bases = batchDocBases(cwd)
340
+ const onWritten = injectedOnWrite
341
+ /** depth-0 目标定位(D-BR21):path 传 ⇒ 可读性门;缺省 ⇒ 唯一在飞批(depth-0)/
342
+ * 绑定解析(子代理/评审)。status/close 经此注入 lifecycle;append 自带同款分支。 */
343
+ const pickTarget = (raw) => {
344
+ const given = typeof raw === "string" && raw.trim() ? raw : null
345
+ if (given) {
346
+ if (review || ctx?.depth !== 0) {
347
+ throw new Error("batch: path is a depth-0-only parameter (D-BR21) — your target record arrives via the spawn binding / the review instance key. Nothing was written.")
348
+ }
349
+ return resolveBatchDocPath(cwd, given)
350
+ }
351
+ if (!review && ctx?.depth === 0) return findInFlightBatch(cwd, bases)
352
+ if (batchDoc) return resolveBatchDocPath(cwd, batchDoc)
353
+ throw new Error("batch: no batch record is bound to this caller — the target arrives via the spawn binding / the review instance key / the depth-0 in-flight scan, and none is present. Nothing was written.")
354
+ }
355
+ switch (args?.action) {
356
+ case "create":
357
+ return createBatchRecord({ args, ctx, review, cwd, bases, onWritten })
358
+ case "append":
359
+ return appendBatchRecord({ args, ctx, review, batchDoc, onWritten })
360
+ case "status":
361
+ return statusBatchRecord({ args, ctx, review, pickTarget, onWritten })
362
+ case "close":
363
+ return closeBatchRecord({ args, ctx, review, pickTarget, onWritten })
364
+ default:
365
+ throw new Error(`batch: unknown action ${JSON.stringify(args?.action ?? null)} — pass one of create / append / status / close.`)
366
+ }
367
+ },
368
+ }
369
+ }
370
+
371
+ /**
372
+ * 过渡别名(BATCH-RECORD §4.14——撤除判据见该节):name 仍 "batch_segment"、schema 仅
373
+ * {segment, text}、描述**逐字**保持原 batch-segment.mjs 基准;execute ⇒ append(同一执行体,
374
+ * 行为等价——AC-2/AC-9)。生产挂载面已全切主名 `batch`(BATCH-RECORD §4.3);本工厂仅供
375
+ * 过渡 shim 导出面(旧测试 / VSC 侧 import 消费)与撤除判据实跑使用。
376
+ * @param {string|null} batchDoc — 绑定的目标档(spawn:`child._batchDoc`;评审:实例键)
377
+ * @param {{review?: boolean}} [opts] — review=true 表示"设计评审实例"形态(写 §3 + 工具盖戳)
378
+ */
379
+ export function batchSegmentTool(batchDoc = null, { review = false } = {}) {
380
+ const tool = batchTool(batchDoc, { review })
381
+ const own = review ? "§3" : null
382
+ return {
383
+ name: "batch_segment",
384
+ description:
385
+ "Append your own section of the batch record (一段一作者). " +
386
+ "There is NO path parameter: the target record is bound to you (at spawn for eng-designer/eng-coder, per review instance for a design review) and your identity fixes the section you may write " +
387
+ "(eng-designer → §2, design review → §3, eng-coder → §5) — a write outside your own section is refused. " +
388
+ "Append-only: the text lands at the end of your section; existing lines are never rewritten or deleted. " +
389
+ "Credential values are stripped mechanically before writing (never write a token or designId value). " +
390
+ "A design review's append is stamped by the tool with a `### 轮次 N(评审子代理)` heading — N is tool-counted; do not write your own heading (it would be dropped). " +
391
+ "Failures are hard and visible (no silent fallback): if the write is refused or fails, say so in your report — “§× 未写入”.",
392
+ parameters: {
393
+ type: "object",
394
+ properties: {
395
+ segment: {
396
+ type: "string",
397
+ description: `The batch-record section you are writing${own ? ` — yours is ${own}` : ""}. Declares the section number only; your identity decides what is actually writable.`,
398
+ },
399
+ text: {
400
+ type: "string",
401
+ description: "The markdown to append (verbatim — findings table + VERDICT + counts for a design review §3). Limit 20000 characters per call; longer content is refused — split it into multiple calls (each call becomes its own section, N continues).",
402
+ },
403
+ },
404
+ required: ["segment", "text"],
405
+ },
406
+ async execute(args, ctx) {
407
+ return tool.execute({ action: "append", segment: args?.segment, text: args?.text }, ctx)
408
+ },
409
+ }
410
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * agent-tools/context.mjs — `context` 工具(CONTEXT-COMPACTION.md §6.16 · context-tool 批 2026-09-21)。
3
+ * 单一工具三操作(用户定形 F-CC5):`stats`(感知 F-CC1)· `prune`(噪声清理 F-CC3)· `compact`(主动压缩 F-CC2,携 focus + 自动附任务/目标 + 可回查锚——裁令 A/D)。
4
+ * 内联 description(**不新增 tool-docs 档**——24 档族计数零动);装配面 = 登记册 `agent-tools.mjs`(#83)+ `agent/family-tools.mjs` 的 depth-0 段(`depthOnly`)——两端自动同表;**depth>0 不给**(§6.16.6)。
5
+ * 本档同时是**注记文案 / 轻推行 / 回执文案的单源**(核与 VSC 的 run-stages 消费点经动态 import 取用——零新增静态边)。
6
+ * 机械面:`stats` 不新增第三口径(阈值取本回合判定值;百分比走 `historyPercent` 与状态行同式);`prune` = 合格集(`token-window.mjs` 单源)+「原位换内容」应用面(`context.mjs`);`compact` **当次零执行**(只登记 `agent._pendingCompact`,由下一安全点消费——§6.16.2)。
7
+ */
8
+ import { contextUsage, estimateTokens, collectStaleToolOutputs, historyPercent, PRUNE_MIN_TOKENS } from "../token-window.mjs"
9
+ import { pruneStaleToolOutputs, COMPRESS_FAILURE_LIMIT } from "../context.mjs"
10
+ import { slotPath } from "../session-slots.mjs"
11
+
12
+ /** 轻推行前缀(单活体去重判据——§6.16.5「同前缀旧行先滤」)。 */
13
+ export const CONTEXT_NUDGE_PREFIX = "[System reminder: task/goal changed —"
14
+
15
+ /** 压缩结果注记前缀(机器行族——同 `[System reminder:` 门:既有 `isRealUserMsg` / plan 节律等消费方按该族识别机器行)——成功**不落**注记。 */
16
+ export const CONTEXT_NOTE_PREFIX = "[System reminder: context compact"
17
+
18
+ /** 压缩 no-op 注记(§6.16.2 回执面②——强制面无可压:短历史无中段)。 */
19
+ export function compactNoopNote() {
20
+ return `${CONTEXT_NOTE_PREFIX} did nothing right now (no middle section — the history is too short to summarize). The context is unchanged.]`
21
+ }
22
+
23
+ /** 压缩失败注记(§6.16.2 回执面②——模型面告知缺口;失败链本体复用既有:连续 3 次降级确定性截断)。 */
24
+ export function compactFailureNote(message, attempt) {
25
+ return `${CONTEXT_NOTE_PREFIX} failed (attempt ${attempt} of ${COMPRESS_FAILURE_LIMIT}): ${message}]`
26
+ }
27
+
28
+ /** 轻推锚(§6.16.5):active goal 目标 ⇒ in_progress 任务标题 ⇒ 首个 pending 标题 ⇒ `(none set)`。 */
29
+ function nudgeAnchor(agent) {
30
+ if (agent?.goal?.status === "active" && agent.goal.objective) return agent.goal.objective
31
+ const tasks = agent?.tasks ?? []
32
+ const inProgress = tasks.find((t) => t.status === "in_progress")
33
+ if (inProgress) return inProgress.title
34
+ const pending = tasks.find((t) => t.status === "pending")
35
+ if (pending) return pending.title
36
+ return "(none set)"
37
+ }
38
+
39
+ /**
40
+ * 方向转换轻推(F-CC4 · §6.16.5):task/goal 变更后直推**一行**机器行(`transient: true`,非命令)。
41
+ * 去重 = **单活体行**:同前缀旧行先滤再推一行 ⇒ 任意次数变更后恒**恰一行**(in-place splice 保
42
+ * 数组引用——VSC 面板持同一数组:`agent.history = <新数组>` 会让端侧局部引用失效,先例 = D-CC24
43
+ * 同一顾虑)。压缩重建时该行无特殊处理(一次建议——落入中段即随摘要退场、落入 tail 则保留,良性)。
44
+ * 深度门由调用方落(`ctx.depth === 0`——子代理拿不到 `context`,提示它拿不到的工具即噪音)。
45
+ */
46
+ export function pushContextNudge(agent) {
47
+ const history = agent.history
48
+ for (let i = history.length - 1; i >= 0; i--) {
49
+ const m = history[i]
50
+ if (m?.role === "user" && typeof m.content === "string" && m.content.startsWith(CONTEXT_NUDGE_PREFIX)) {
51
+ history.splice(i, 1)
52
+ }
53
+ }
54
+ history.push({
55
+ role: "user",
56
+ content: `${CONTEXT_NUDGE_PREFIX} current objective: ${nudgeAnchor(agent)}. ` +
57
+ `If the earlier context no longer serves this direction, you may compact it (context tool, action="compact" with a focus) — the decision is yours.]`,
58
+ transient: true,
59
+ })
60
+ }
61
+
62
+ /** stats 报面(§6.16.4 逐行定稿——机判可解析;三段 / 工具输出小计 / 状态行百分比 / 压缩履历)。 */
63
+ function statsReport(agent) {
64
+ const usage = contextUsage(agent, agent?._ctxBasis?.overhead ?? {})
65
+ const stale = collectStaleToolOutputs(agent?.history ?? [], agent?.provider)
66
+ let toolOut = 0
67
+ let toolCount = 0
68
+ for (const m of agent?.history ?? []) {
69
+ if (m?.role !== "tool") continue
70
+ toolCount++
71
+ toolOut += estimateTokens([m])
72
+ }
73
+ const pct = Math.round((usage.total / usage.threshold) * 100)
74
+ const toGo = Math.max(0, usage.threshold - usage.total) // 已越阈值 ⇒ 0(自动压缩将在下一安全点接手)
75
+ const info = agent?._lastCompressInfo
76
+ const compaction = !info ? "none"
77
+ : info.mode === "summary" ? `last summary freed ${info.tokensFreed} tokens`
78
+ : "last fallback truncation"
79
+ return [
80
+ `Context ≈${usage.total} tokens vs compaction threshold ${usage.threshold} (${pct}% — ${toGo} to go); window ${usage.window}`,
81
+ `Segments ≈: system ${usage.system} · tools ${usage.tools} · history ${usage.history} (basis: ${usage.basis})`,
82
+ `History: tool outputs ${toolOut} tokens in ${toolCount} msgs · prunable stale ${stale.tokens} tokens in ${stale.indexes.length} msgs · protected tail ${stale.tailCount} msgs`,
83
+ `Status line: context ${historyPercent(agent?.history ?? [], agent?.provider)}% (${usage.history} history estimate / ${usage.window} window)`,
84
+ `Compaction so far: ${compaction} · failures ${agent?._compressFailures ?? 0}/${COMPRESS_FAILURE_LIMIT}`,
85
+ ].join("\n")
86
+ }
87
+
88
+ /** prune 回执(§6.16.3:计数 + 扫过候选 / 保护尾内保留 / 门槛下跳过 + 记录面零改)。 */
89
+ function pruneReceipt(agent) {
90
+ const r = pruneStaleToolOutputs(agent)
91
+ const scan = `(scanned ${r.candidates} tool result(s): ${r.tailKept} inside the protected tail, ${r.belowMin} below the ${PRUNE_MIN_TOKENS}-token floor)`
92
+ if (r.pruned === 0) {
93
+ return `Nothing to prune — no stale tool output matched ${scan}. The context and the session record are unchanged.`
94
+ }
95
+ return `Pruned ${r.pruned} stale tool output(s) ≈${r.freed} tokens freed ${scan}. ` +
96
+ `Tool pairing is intact and the session record is unchanged — the full text is still on disk; re-run a tool if you need its output again.`
97
+ }
98
+
99
+ /**
100
+ * 可回查锚(父侧裁令 A · §6.16.7 两分支逐字):回执尾段给出「压掉的可一步取回」的坐标与调用形。
101
+ * 取回面 = `read_history` 既有 `path=`(**不新建通道**);未绑定槽(会话首次保存前 / VSC 端面板槽
102
+ * 不在 agent 上)⇒ `cwd:` 发现面(等效取回,已登记的端差)。
103
+ */
104
+ function retrievabilityAnchor(agent) {
105
+ const slot = agent?._slot
106
+ // 已绑定槽分支需 cwd 才能给槽文件坐标(cwd 缺省 ⇒ 退 cwd 发现面——工具内不抛)
107
+ if (slot != null && agent?.cwd) {
108
+ const file = slotPath(agent.cwd, slot)
109
+ return `Full record (never compacted): ${file} — read it back with: read_history path="${file}" (add keyword / since / role filters to target it).`
110
+ }
111
+ return `Full record: this session's file is created on first save — list this project's sessions with: read_history path="cwd:${agent.cwd}", then copy the listed path into path= to query it.`
112
+ }
113
+
114
+ /** compact 回执(§6.16.2 回执面①:排队事实 + 替换明示 + focus 回显 + 落点说明 + 可回查锚)。 */
115
+ function compactReceipt(agent, focus, replaced) {
116
+ return [
117
+ `Compaction queued${replaced ? " (replaces the request queued earlier — only the latest focus is used)" : ""} — ` +
118
+ `it runs at the next safe point, before the next request; the current exchange is never cut mid-flight.`,
119
+ `Focus: ${focus}`,
120
+ `The summary is weighted toward that focus; the current task list and goal are attached automatically. ` +
121
+ `Same summary chain, same panel, same failure handling as the automatic compaction.`,
122
+ retrievabilityAnchor(agent),
123
+ ].join("\n")
124
+ }
125
+
126
+ export const contextTool = {
127
+ name: "context",
128
+ description:
129
+ "Manage your own context window — see how full it is, drop stale tool output, or compact the earlier " +
130
+ "conversation into a summary at a moment of your own choosing.\n" +
131
+ "- action='stats': current usage — total vs the compaction threshold, per-segment shares, and how much " +
132
+ "is prunable / reclaimable.\n" +
133
+ "- action='prune': drop the CONTENT of stale tool results (older than the protected tail) and replace it " +
134
+ "with a short stub — tool pairing stays intact, the session record keeps the full text, and the tool can " +
135
+ "simply be re-run.\n" +
136
+ "- action='compact': compact the earlier conversation now, with 'focus' — write what the UPCOMING work " +
137
+ "needs (1–3 sentences: the goal, the files, the constraints that must survive). The summary is written to " +
138
+ "serve that focus; the current task list and goal are attached automatically. It runs at the next safe " +
139
+ "point (before the next request — never mid-exchange) through the same machine as the automatic " +
140
+ "compaction: same summary chain, same panel, same failure chain (3 consecutive failures degrade to a " +
141
+ "deterministic truncation). The receipt carries this session's record path, so nothing is lost for good.\n" +
142
+ "Nothing here runs on its own — the automatic threshold compaction stays in place as a fallback; whether " +
143
+ "to compact is your call. Errors come back as 'Error: ...' and change nothing.\n" +
144
+ "Parameters:\n" +
145
+ "- action (required): stats | prune | compact\n" +
146
+ "- focus (required for compact): what the upcoming work needs — the summary is weighted toward it",
147
+ parameters: {
148
+ type: "object",
149
+ properties: {
150
+ action: { type: "string", enum: ["stats", "prune", "compact"], description: "stats — usage report; prune — drop stale tool-output content; compact — summarize the earlier conversation now." },
151
+ focus: { type: "string", description: "compact only (required): what the upcoming work needs — 1–3 sentences. The summary is weighted toward it." },
152
+ },
153
+ required: ["action"],
154
+ },
155
+ readonly: true, // 只动机内状态(同 task / goal 先例)——planMode 放行、只读角色过滤放行、无权限问询
156
+ async execute(args, ctx) {
157
+ const agent = ctx.agent
158
+ const action = args?.action
159
+ if (action === "stats") return statsReport(agent)
160
+ if (action === "prune") return pruneReceipt(agent)
161
+ if (action === "compact") {
162
+ const focus = typeof args?.focus === "string" ? args.focus.trim() : ""
163
+ // fail-closed(§6.16.1 参数面):schema 层 `focus` 非 required ⇒ 运行期判——不排队、零副作用
164
+ if (!focus) {
165
+ return "Error: 'focus' is required for action='compact' — write 1–3 sentences on what the upcoming work needs " +
166
+ "(the goal, the files, the constraints that must survive). Nothing was queued."
167
+ }
168
+ const replaced = agent._pendingCompact != null
169
+ agent._pendingCompact = { focus, at: Date.now() } // 单槽:重复调用后者覆盖前者(回执明示)
170
+ return compactReceipt(agent, focus, replaced)
171
+ }
172
+ return `Error: unknown action '${action}' — valid actions: stats | prune | compact`
173
+ },
174
+ }
@@ -15,6 +15,7 @@
15
15
  * 拒翻 ⇒ 返回原因串 + **零副作用**(模式保持 OFF)。OFF 方向与幂等 enter 零改。
16
16
  */
17
17
  import { ENG_ON_REMINDER, ENG_OFF_REMINDER } from "../agent.mjs"
18
+ import { clearPlanMode } from "./plan.mjs"
18
19
  import { purgeExpiredDesignTokens } from "../token-ttl.mjs"
19
20
  import { resolveEngineeringManifest } from "../manifest.mjs"
20
21
 
@@ -83,6 +84,9 @@ export const engTool = {
83
84
  }
84
85
  ctx.agent.manifest = r.manifest
85
86
  ctx.agent.config.agent.engineering = true
87
+ // FR31 ③ / KD10(翻转点①):工程模式 ⇒ planMode 恒 false——中途开模式时清残留
88
+ //(planMode 位 + reminder 计数 + 未注入的 plan 提示语),半状态(工程纪律 × plan 只读)不产生。
89
+ clearPlanMode(ctx.agent)
86
90
  // R16 (F-R16b ②): off→on 不重评——只清过期 token(用户裁定"打开工程模式时
87
91
  // 应该清理"),有效 token 原样保留——遍历 Map 删过期,返回文案含清理个数。
88
92
  const cleared = purgeExpiredDesignTokens(ctx.agent)
@@ -1,3 +1,6 @@
1
+ // F-CC4(CONTEXT-COMPACTION.md §6.16.5):goal 任一状态变更 ⇒ 方向转换轻推(本档只调用;文案/去重单源 = agent-tools/context.mjs)
2
+ import { pushContextNudge } from "./context.mjs"
3
+
1
4
  /**
2
5
  * goal tool: lifecycle management for long-running autonomous goals (completion contract).
3
6
  * Three states: active / complete / blocked. Completion must pass a verify evidence threshold;
@@ -28,6 +31,7 @@ export const goalTool = {
28
31
  const agent = ctx.agent
29
32
  if (args.action === "cancel") {
30
33
  agent.goal = null
34
+ if (ctx.depth === 0) pushContextNudge(agent) // F-CC4(depth-0 门)
31
35
  return "Goal cancelled. If the goal was blocked or impossible, explain why in your next message — the user can clarify, adjust scope, or confirm cancellation."
32
36
  }
33
37
  if (args.action === "set") {
@@ -43,6 +47,7 @@ export const goalTool = {
43
47
  turnsUsed: 0,
44
48
  _blockTally: null, // { reason, count } — consecutive count of the same blocking condition (for blocked audit)
45
49
  }
50
+ if (ctx.depth === 0) pushContextNudge(agent) // F-CC4(depth-0 门)
46
51
  return `Goal set: ${agent.goal.objective}\nDone when: ${agent.goal.criteria}\nThe system will inject goal status every turn. Completion and blocked claims are audited — see the reminders.`
47
52
  }
48
53
  if (!agent.goal || agent.goal.status !== "active") {
@@ -100,6 +105,7 @@ Has this goal been achieved? Answer ONLY "YES" or "NO" followed by a one-sentenc
100
105
  }
101
106
 
102
107
  agent.goal.status = "complete"
108
+ if (ctx.depth === 0) pushContextNudge(agent) // F-CC4(depth-0 门)
103
109
  return `Goal verified complete ✓: ${agent.goal.objective}\nIn your next message, summarize the evidence (what check ran, what it showed) — the user should be able to audit this claim.`
104
110
  }
105
111
  if (args.action === "blocked") {
@@ -112,6 +118,7 @@ Has this goal been achieved? Answer ONLY "YES" or "NO" followed by a one-sentenc
112
118
  return `Blocked not accepted yet (${count}/3 for this condition). Try a genuinely different approach first; report blocked only if the same condition stops you ${3 - count} more time(s).`
113
119
  }
114
120
  agent.goal.status = "blocked"
121
+ if (ctx.depth === 0) pushContextNudge(agent) // F-CC4(depth-0 门)
115
122
  return `Goal marked blocked after 3 attempts: ${args.reason}\nExplain the blocker to the user in your next message — what you tried, and what you need (clarification, permission, a decision).`
116
123
  }
117
124
  return `Error: unknown action '${args.action}'.`