@bolloon/bolloon-agent 0.5.5 → 0.6.0

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 (109) hide show
  1. package/dist/agents/parse-tool-call.js +6 -1
  2. package/dist/agents/pi-sdk-session-factory.js +36 -3
  3. package/dist/agents/pi-sdk.js +349 -199
  4. package/dist/agents/run-context.js +20 -0
  5. package/dist/agents/runner-resolver.js +2 -1
  6. package/dist/electron-build/agents/parse-tool-call.js +6 -1
  7. package/dist/electron-build/agents/parse-tool-call.js.map +1 -1
  8. package/dist/electron-build/agents/pi-sdk-session-factory.js +36 -3
  9. package/dist/electron-build/agents/pi-sdk-session-factory.js.map +1 -1
  10. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  11. package/dist/electron-build/agents/pi-sdk.js +349 -199
  12. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  13. package/dist/electron-build/agents/run-context.js +24 -0
  14. package/dist/electron-build/agents/run-context.js.map +1 -0
  15. package/dist/electron-build/agents/runner-resolver.js +2 -1
  16. package/dist/electron-build/agents/runner-resolver.js.map +1 -1
  17. package/dist/electron-build/kernel/channel-actor.js +314 -0
  18. package/dist/electron-build/kernel/channel-actor.js.map +1 -0
  19. package/dist/index.js +24 -19
  20. package/dist/kernel/channel-actor.js +297 -0
  21. package/dist/kernel/control.js +95 -0
  22. package/dist/kernel/gate-scan.js +1036 -0
  23. package/dist/kernel/model-runtime.js +472 -0
  24. package/dist/kernel/plan-channel-actor.js +298 -0
  25. package/dist/kernel/plan-constraint.js +165 -0
  26. package/dist/kernel/plan-deletion.js +32 -0
  27. package/dist/kernel/plan-modelruntime.js +60 -0
  28. package/dist/kernel/plan-runcontext.js +163 -0
  29. package/dist/kernel/plan.js +153 -0
  30. package/dist/kernel/roster.js +242 -0
  31. package/dist/web/bolloon-web.json +3 -3
  32. package/dist/web/client.js +3 -2
  33. package/dist/web/routes-tasks.js +5 -1
  34. package/dist/web/server.js +58 -18
  35. package/dist/web/sw.js +1 -1
  36. package/dist/web/ui/message-renderer.js +473 -29
  37. package/dist/web/ui/step-timeline.js +13 -12
  38. package/package.json +1 -1
  39. package/scripts/build-web.ts +74 -1
  40. package/dist/constraint-runtime/_archive_helper.d.ts +0 -1
  41. package/dist/constraint-runtime/_archive_helper.js +0 -9
  42. package/dist/constraint-runtime/assistant/index.d.ts +0 -11
  43. package/dist/constraint-runtime/assistant/index.js +0 -12
  44. package/dist/constraint-runtime/bootstrap/index.d.ts +0 -11
  45. package/dist/constraint-runtime/bootstrap/index.js +0 -12
  46. package/dist/constraint-runtime/bridge/index.d.ts +0 -11
  47. package/dist/constraint-runtime/bridge/index.js +0 -12
  48. package/dist/constraint-runtime/buddy/index.d.ts +0 -11
  49. package/dist/constraint-runtime/buddy/index.js +0 -12
  50. package/dist/constraint-runtime/cli/index.d.ts +0 -11
  51. package/dist/constraint-runtime/cli/index.js +0 -12
  52. package/dist/constraint-runtime/components/index.d.ts +0 -11
  53. package/dist/constraint-runtime/components/index.js +0 -12
  54. package/dist/constraint-runtime/constants/index.d.ts +0 -11
  55. package/dist/constraint-runtime/constants/index.js +0 -12
  56. package/dist/constraint-runtime/coordinator/index.d.ts +0 -11
  57. package/dist/constraint-runtime/coordinator/index.js +0 -12
  58. package/dist/constraint-runtime/cost_hook.d.ts +0 -2
  59. package/dist/constraint-runtime/cost_hook.js +0 -4
  60. package/dist/constraint-runtime/entrypoints/index.d.ts +0 -11
  61. package/dist/constraint-runtime/entrypoints/index.js +0 -12
  62. package/dist/constraint-runtime/execution_registry.d.ts +0 -20
  63. package/dist/constraint-runtime/execution_registry.js +0 -44
  64. package/dist/constraint-runtime/hooks/index.d.ts +0 -11
  65. package/dist/constraint-runtime/hooks/index.js +0 -12
  66. package/dist/constraint-runtime/ink.d.ts +0 -1
  67. package/dist/constraint-runtime/ink.js +0 -4
  68. package/dist/constraint-runtime/keybindings/index.d.ts +0 -11
  69. package/dist/constraint-runtime/keybindings/index.js +0 -12
  70. package/dist/constraint-runtime/memdir/index.d.ts +0 -11
  71. package/dist/constraint-runtime/memdir/index.js +0 -12
  72. package/dist/constraint-runtime/migrations/index.d.ts +0 -11
  73. package/dist/constraint-runtime/migrations/index.js +0 -12
  74. package/dist/constraint-runtime/moreright/index.d.ts +0 -11
  75. package/dist/constraint-runtime/moreright/index.js +0 -12
  76. package/dist/constraint-runtime/native_ts/index.d.ts +0 -11
  77. package/dist/constraint-runtime/native_ts/index.js +0 -12
  78. package/dist/constraint-runtime/output_styles/index.d.ts +0 -11
  79. package/dist/constraint-runtime/output_styles/index.js +0 -12
  80. package/dist/constraint-runtime/plugins/index.d.ts +0 -11
  81. package/dist/constraint-runtime/plugins/index.js +0 -12
  82. package/dist/constraint-runtime/port_manifest.d.ts +0 -7
  83. package/dist/constraint-runtime/port_manifest.js +0 -11
  84. package/dist/constraint-runtime/query.d.ts +0 -6
  85. package/dist/constraint-runtime/query.js +0 -1
  86. package/dist/constraint-runtime/remote/index.d.ts +0 -11
  87. package/dist/constraint-runtime/remote/index.js +0 -12
  88. package/dist/constraint-runtime/schemas/index.d.ts +0 -11
  89. package/dist/constraint-runtime/schemas/index.js +0 -12
  90. package/dist/constraint-runtime/screens/index.d.ts +0 -11
  91. package/dist/constraint-runtime/screens/index.js +0 -12
  92. package/dist/constraint-runtime/server/index.d.ts +0 -11
  93. package/dist/constraint-runtime/server/index.js +0 -12
  94. package/dist/constraint-runtime/services/index.d.ts +0 -11
  95. package/dist/constraint-runtime/services/index.js +0 -12
  96. package/dist/constraint-runtime/state/index.d.ts +0 -11
  97. package/dist/constraint-runtime/state/index.js +0 -12
  98. package/dist/constraint-runtime/system_init.d.ts +0 -1
  99. package/dist/constraint-runtime/system_init.js +0 -20
  100. package/dist/constraint-runtime/types/index.d.ts +0 -11
  101. package/dist/constraint-runtime/types/index.js +0 -12
  102. package/dist/constraint-runtime/upstream_proxy/index.d.ts +0 -11
  103. package/dist/constraint-runtime/upstream_proxy/index.js +0 -12
  104. package/dist/constraint-runtime/utils/index.d.ts +0 -11
  105. package/dist/constraint-runtime/utils/index.js +0 -12
  106. package/dist/constraint-runtime/vim/index.d.ts +0 -11
  107. package/dist/constraint-runtime/vim/index.js +0 -12
  108. package/dist/constraint-runtime/voice/index.d.ts +0 -11
  109. package/dist/constraint-runtime/voice/index.js +0 -12
@@ -0,0 +1,1036 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ /**
4
+ * Bolloon Native Macro-Kernel —— K0 门的**判据** (纯函数: 吃源码文本, 出违规清单)
5
+ *
6
+ * 为什么判据是纯函数而不是几条 `expect`:
7
+ * 只有这样, 「变异验证」才能把**人为改坏的源码**喂给**同一份**判据 (测试里真的这么干)。
8
+ * 先例: src/agents/goal-flywheel/wiring/* 的 scan* + src/test/goal-flywheel-wiring-freeze.test.ts。
9
+ *
10
+ * 本文件只 import 名册 (kernel 内部), 自己就是 K1 的样本。
11
+ */
12
+ import { LAYERS, } from './roster.js';
13
+ import { HISTORY_OPS } from './plan-channel-actor.js';
14
+ /** 行内注释剥掉 (保护 `https://` 这类) —— 拿子串当判据前先剥注释是本仓的硬规矩 */
15
+ export function stripLineComment(line) {
16
+ const m = line.indexOf('//');
17
+ if (m < 0)
18
+ return line;
19
+ if (line[m - 1] === ':')
20
+ return line; // https:// 之类
21
+ return line.slice(0, m);
22
+ }
23
+ export function isCommentOnly(line) {
24
+ const s = line.trim();
25
+ return s.startsWith('//') || s.startsWith('*') || s.startsWith('/*') || s.startsWith('*/');
26
+ }
27
+ // 2026-10-02 修真缺陷: 原先只认 `from '...'` 与动态 `import('...')`,
28
+ // 漏掉**副作用 import** (`import './x.js';` —— 既无 from 也无括号) ⇒ 入边被少算 ⇒ 删除候选虚高。
29
+ const FROM_RE = /\bfrom\s*['"]([^'"]+)['"]|\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)|\bimport\s+['"]([^'"]+)['"]/g;
30
+ /** 抽出所有 import/export-from/动态 import 的说明符 + 行号 */
31
+ export function importSpecifiers(text) {
32
+ const out = [];
33
+ text.split('\n').forEach((raw, i) => {
34
+ if (isCommentOnly(raw))
35
+ return;
36
+ const line = stripLineComment(raw);
37
+ FROM_RE.lastIndex = 0;
38
+ let m;
39
+ while ((m = FROM_RE.exec(line)) !== null) {
40
+ const spec = m[1] ?? m[2] ?? m[3];
41
+ if (spec)
42
+ out.push({ line: i + 1, spec });
43
+ }
44
+ });
45
+ return out;
46
+ }
47
+ /**
48
+ * 把说明符解析成「相对 src/ 的路径 (无扩展名)」。
49
+ * 非相对说明符 (node 内置 / 第三方包) → null: **仓内跨层边**才是本门管的范围。
50
+ */
51
+ export function resolveFrom(file, spec) {
52
+ if (!spec.startsWith('.'))
53
+ return null;
54
+ const dir = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '';
55
+ const parts = (dir ? dir.split('/') : []).concat(spec.split('/'));
56
+ const stack = [];
57
+ for (const p of parts) {
58
+ if (p === '' || p === '.')
59
+ continue;
60
+ if (p === '..')
61
+ stack.pop();
62
+ else
63
+ stack.push(p);
64
+ }
65
+ let rel = stack.join('/');
66
+ if (rel.endsWith('.js'))
67
+ rel = rel.slice(0, -3);
68
+ if (rel.endsWith('.jsx'))
69
+ rel = rel.slice(0, -4);
70
+ if (rel.endsWith('.mjs'))
71
+ rel = rel.slice(0, -4);
72
+ return rel || null;
73
+ }
74
+ /** 这个路径属于哪一层 (目录前缀 或 精确文件) */
75
+ export function layerOf(rel) {
76
+ for (const layer of LAYERS) {
77
+ for (const p of layer.paths) {
78
+ if (p.endsWith('/') ? rel.startsWith(p) : rel === p)
79
+ return layer.id;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+ export function filesOfLayer(files, layer) {
85
+ return files.filter((f) => layerOf(f.path) === layer);
86
+ }
87
+ const KERNEL_BOUNDARY = 'kernel-dir-boundary';
88
+ /**
89
+ * K1 —— kernel 目录的每一条仓内 import 都必须落在允许前缀里。
90
+ * 违规 = 内核自己伸手去摸业务模块 (那正是 pi-sdk.ts 长成 4099 行单体的成因)。
91
+ */
92
+ export function scanKernelImports(kernel, allowedPrefixes) {
93
+ const out = [];
94
+ for (const f of kernel) {
95
+ for (const { line, spec } of importSpecifiers(f.text)) {
96
+ const rel = resolveFrom(f.path, spec);
97
+ if (rel === null)
98
+ continue; // node / 第三方
99
+ if (allowedPrefixes.some((p) => rel.startsWith(p)))
100
+ continue;
101
+ out.push({ rule: KERNEL_BOUNDARY, file: f.path, line, what: `${spec} → ${rel}` });
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+ /** K2 —— 一条禁令在给定源码面上的全部命中 */
107
+ export function scanProhibition(files, p) {
108
+ const out = [];
109
+ const targets = p.targets ?? [];
110
+ const calls = p.writeCalls ?? [];
111
+ const callRe = calls.length ? new RegExp(`\\b(${calls.join('|')})\\s*\\(`) : null;
112
+ for (const f of files) {
113
+ if (p.mode === 'import-edge') {
114
+ for (const { line, spec } of importSpecifiers(f.text)) {
115
+ const rel = resolveFrom(f.path, spec);
116
+ if (rel === null)
117
+ continue;
118
+ if (targets.some((t) => rel === t || rel.startsWith(`${t}/`))) {
119
+ out.push({ rule: p.id, file: f.path, line, what: `import ${spec}` });
120
+ }
121
+ }
122
+ continue;
123
+ }
124
+ // write-call: 只认**调用**(读不算) —— 定义处/import 解构都带不出 `(`
125
+ f.text.split('\n').forEach((raw, i) => {
126
+ if (isCommentOnly(raw))
127
+ return;
128
+ const line = stripLineComment(raw);
129
+ if (!callRe)
130
+ return;
131
+ const m = callRe.exec(line);
132
+ if (m)
133
+ out.push({ rule: p.id, file: f.path, line: i + 1, what: `${m[1]}()` });
134
+ });
135
+ }
136
+ return out;
137
+ }
138
+ /** 把命中归并成 (rule|file|call/import) → 次数, 供与欠账台账**逐字相等**比对 */
139
+ export function groupFindings(findings) {
140
+ const m = new Map();
141
+ for (const f of findings) {
142
+ const call = f.what.replace(/\(\)$/, '').replace(/^import\s+/, '');
143
+ const key = `${f.rule}|${f.file}|${call}`;
144
+ m.set(key, (m.get(key) ?? 0) + 1);
145
+ }
146
+ return [...m.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => a.key.localeCompare(b.key));
147
+ }
148
+ /** 与欠账台账比对: 双向相等 (多一条红 / 少一条也红, 强制台账与事实同步) */
149
+ export function debtDiff(findings, debt, rule) {
150
+ const actual = groupFindings(findings).filter((g) => g.key.startsWith(`${rule}|`));
151
+ const want = debt
152
+ .filter((d) => d.prohibition === rule)
153
+ .map((d) => `${d.prohibition}|${d.file}|${d.call}|${d.count}`)
154
+ .sort();
155
+ const got = actual.map((g) => `${g.key}|${g.count}`).sort();
156
+ return {
157
+ missing: got.filter((g) => !want.includes(g)),
158
+ extra: want.filter((w) => !got.includes(w)),
159
+ };
160
+ }
161
+ /** K3 —— 行数 (整文件行数; 空行也算, 因为预算量的是「文件有多大」) */
162
+ export function countCodeLines(files) {
163
+ return files.reduce((n, f) => n + f.text.split('\n').filter((_, i, a) => i < a.length - (a[a.length - 1] === '' ? 1 : 0)).length, 0);
164
+ }
165
+ /** 归属判定 = **最长前缀** (目录键以 / 结尾按前缀, 文件键按相等)。null = 无归属 ⇒ 门判红。 */
166
+ export function ownerOfFile(rel, owners) {
167
+ let best = null;
168
+ for (const o of owners) {
169
+ const hit = o.key.endsWith('/') ? rel.startsWith(o.key) : rel === o.key;
170
+ if (hit && (!best || o.key.length > best.key.length))
171
+ best = o;
172
+ }
173
+ return best;
174
+ }
175
+ export function scanOwnerCoverage(files, owners) {
176
+ const out = [];
177
+ for (const f of files) {
178
+ if (!ownerOfFile(f.path, owners))
179
+ out.push({ rule: 'owner-coverage', file: f.path, line: 1, what: '无 owner' });
180
+ }
181
+ return out;
182
+ }
183
+ /** owner 名册里声明了但盘上不存在的键 (空承诺) —— 先例: 冻结门的「名册路径必须真实存在」 */
184
+ export function scanOwnerPromises(owners, exists) {
185
+ return owners
186
+ .filter((o) => !exists(o.key))
187
+ .map((o) => ({ rule: 'owner-promise', file: o.key, line: 1, what: '名册声明但盘上不存在' }));
188
+ }
189
+ const PROMPT_CALL_RE = /([\w\.\)\]]+)\s*\.\s*(prompt|promptStream|promptWithPivotLoop)\s*\(/;
190
+ const PROMPT_DEF_RE = /^(export\s+)?(async\s+)?(prompt|promptStream|promptWithPivotLoop)\s*\(/;
191
+ /** 重算全仓入口调用点 (与 ENTRY_GRAPH 逐字比对; 任何新旁路都会浮出来) */
192
+ export function scanEntrySites(files, adapterFiles = ['agents/pi-sdk.ts']) {
193
+ const m = new Map();
194
+ for (const f of files) {
195
+ f.text.split('\n').forEach((raw) => {
196
+ if (isCommentOnly(raw))
197
+ return;
198
+ const line = stripLineComment(raw);
199
+ if (PROMPT_DEF_RE.test(line.trim()))
200
+ return;
201
+ const hit = PROMPT_CALL_RE.exec(line);
202
+ if (!hit)
203
+ return;
204
+ const recv = hit[1];
205
+ const kind = adapterFiles.includes(f.path)
206
+ ? 'adapter-internal'
207
+ : recv === 'this' ? 'readline-tui' : 'agent-entry';
208
+ const key = `${f.path}|${kind}|${hit[2]}`;
209
+ const prev = m.get(key);
210
+ m.set(key, { file: f.path, kind, method: hit[2], count: (prev?.count ?? 0) + 1 });
211
+ });
212
+ }
213
+ return [...m.values()].sort((a, b) => `${a.file}|${a.kind}|${a.method}`.localeCompare(`${b.file}|${b.kind}|${b.method}`));
214
+ }
215
+ export function entryGraphDiff(actual, declared) {
216
+ const key = (e) => `${e.file}|${e.kind}|${e.method}|${e.count}`;
217
+ const got = actual.map(key).sort();
218
+ const want = declared.map(key).sort();
219
+ return { extra: got.filter((g) => !want.includes(g)), missing: want.filter((w) => !got.includes(w)) };
220
+ }
221
+ // ─────────────────────────────────────────────────────────────────────────────
222
+ // K0 ④ 旧代码删除台账
223
+ // ─────────────────────────────────────────────────────────────────────────────
224
+ const DELETION_FIELDS = [
225
+ 'target', 'oldEntry', 'replacement', 'remainingRefs',
226
+ 'runtimeHits', 'acceptance', 'rollbackCommit', 'deletedAt',
227
+ ];
228
+ /** 8 字段缺一律红 (leo 的五个删除条件之 ②: 剩余引用必须为 0) */
229
+ export function validateDeletionRecord(rec) {
230
+ const bad = [];
231
+ for (const f of DELETION_FIELDS) {
232
+ const v = rec[f];
233
+ if (v === undefined || v === null || v === '')
234
+ bad.push(f);
235
+ if (f === 'remainingRefs' && v !== 0)
236
+ bad.push('remainingRefs≠0');
237
+ }
238
+ return bad;
239
+ }
240
+ /** 第一批删除候选 = 产品码里 **0 入边引用** 且非入口形态 (机械派生, 不手写) */
241
+ const ENTRY_SHAPE_RE = /(index\.ts$|cli-entry\.ts$|electron\.ts$|\.d\.ts$|server\.ts$|main\.ts$|route|command|register|setup|bootstrap|migration|types\.ts$|constants?\.ts$|config)/;
242
+ /**
243
+ * **豁免规则 (2026-10-02)**: 冻结面自身的文件 (KERNEL_FILES 成员 —— `kernel/roster.ts` / `kernel/plan*.ts`)
244
+ * **不算删除候选**。它们是台账/名册, 在"0 入边"口径下天然是孤岛, 每加一个台账就会让候选集变一次
245
+ * (已因此被迫改过三次 sha)。删除台账不是"删死代码", 走的是它自己的 8 字段记录流程。
246
+ */
247
+ // 2026-10-02 (第 4 次候选集漂移后定的根规则): **kernel 冻结面整体不算删除候选**。
248
+ // 理由: ① 台账/名册在"0 入边"口径下天然是孤岛, 每加一个就改一次 sha (已四次);
249
+ // ② 只被测试 import 的产品文件 (如 channel-actor.ts 这种"先落容器、后接线"的基建) 同样会被误判成死码;
250
+ // ③ kernel 内部要删东西, 走的是它自己的 8 字段删除记录流程, 不是"删死代码"。
251
+ const LEDGER_SELF_EXEMPT = /^kernel\//;
252
+ export function deletionCandidates(files) {
253
+ const paths = new Set(files.map((f) => f.path));
254
+ const inbound = new Map();
255
+ for (const f of files) {
256
+ for (const { spec } of importSpecifiers(f.text)) {
257
+ const rel = resolveFrom(f.path, spec);
258
+ if (!rel)
259
+ continue;
260
+ for (const c of [rel + '.ts', rel + '.tsx', rel + '/index.ts']) {
261
+ if (paths.has(c))
262
+ inbound.set(c, (inbound.get(c) ?? 0) + 1);
263
+ }
264
+ }
265
+ }
266
+ return files
267
+ .map((f) => f.path)
268
+ .filter((p) => (inbound.get(p) ?? 0) === 0 && !ENTRY_SHAPE_RE.test(p) && !LEDGER_SELF_EXEMPT.test(p))
269
+ .sort();
270
+ }
271
+ /** 归属 = 最长前缀; 入参 rel 相对 constraint-runtime/ */
272
+ export function constraintClassOf(rel, rules) {
273
+ let best = null;
274
+ for (const r of rules) {
275
+ const hit = r.key.endsWith('/') ? rel.startsWith(r.key) : rel === r.key;
276
+ if (hit && (!best || r.key.length > best.key.length))
277
+ best = r;
278
+ }
279
+ return best;
280
+ }
281
+ /** 每个源码文件必须恰好命中一条规则 (BUILD/META 子树另算) */
282
+ export function scanConstraintCoverage(sourceFiles, rules) {
283
+ const out = [];
284
+ for (const rel of sourceFiles) {
285
+ if (!constraintClassOf(rel, rules))
286
+ out.push({ rule: 'constraint-coverage', file: rel, line: 1, what: '未分类' });
287
+ }
288
+ return out;
289
+ }
290
+ /** 说明符 → 台账里的目标键 (包入口 / 去掉 src|dist 前缀与 .js) */
291
+ export function constraintTargetOf(spec) {
292
+ if (!spec.includes('constraint-runtime'))
293
+ return null;
294
+ if (/^@bolloon\/constraint-runtime$/.test(spec))
295
+ return '__pkg_entry__';
296
+ const marker = 'constraint-runtime/';
297
+ const i = spec.lastIndexOf(marker);
298
+ if (i < 0)
299
+ return '__pkg_entry__';
300
+ return spec.slice(i + marker.length).replace(/^(src|dist)\//, '').replace(/\.js$/, '');
301
+ }
302
+ /**
303
+ * 目标键 → 规则键。
304
+ * 台账里的 target 是**模块名**(无扩展名, 如 `tools/SafeSDK/deploySafe`), 而名册键是**文件路径**
305
+ * (如 `src/tools/SafeSDK/deploySafe.ts`) ⇒ 三种写法都试 (裸名 / +.ts / +/index.ts)。
306
+ */
307
+ export function constraintRuleOfTarget(target, rules) {
308
+ if (target === '__pkg_entry__')
309
+ return rules.find((r) => r.key === '__pkg_entry__') ?? null;
310
+ return constraintClassOf(`src/${target}`, rules)
311
+ ?? constraintClassOf(`src/${target}.ts`, rules)
312
+ ?? constraintClassOf(`src/${target}/index.ts`, rules);
313
+ }
314
+ /** 重算主仓 (非 constraint-runtime 子树) 对 constraint-runtime 的全部引用点 */
315
+ export function scanConstraintUses(files, constraintRoot) {
316
+ const m = new Map();
317
+ for (const f of files) {
318
+ if (f.path.startsWith(constraintRoot))
319
+ continue;
320
+ const kind = f.path.startsWith('test/') ? 'test' : 'prod';
321
+ for (const { spec } of importSpecifiers(f.text)) {
322
+ const target = constraintTargetOf(spec);
323
+ if (!target)
324
+ continue;
325
+ const key = `${f.path}|${target}|${kind}`;
326
+ const prev = m.get(key);
327
+ m.set(key, { file: f.path, target, count: (prev?.count ?? 0) + 1, kind });
328
+ }
329
+ }
330
+ return [...m.values()].sort((a, b) => key2(a).localeCompare(key2(b)));
331
+ }
332
+ function key2(r) { return `${r.file}|${r.target}|${r.kind}`; }
333
+ export function constraintUseDiff(actual, declared) {
334
+ const k = (r) => `${r.file}|${r.target}|${r.kind}|${r.count}`;
335
+ const got = actual.map(k).sort();
336
+ const want = declared.map(k).sort();
337
+ return { extra: got.filter((g) => !want.includes(g)), missing: want.filter((w) => !got.includes(w)) };
338
+ }
339
+ /**
340
+ * 越界引用 (K1 的核心判据):
341
+ * · C 类被 **prod** 引用 ⇒ 违规 (不许被 Kernel 侧 import);
342
+ * · B 类被 **prod** 直接引用 ⇒ 欠账 (只能经 Tool Capability 接入, K7 还清)。
343
+ */
344
+ export function scanConstraintViolations(uses, rules) {
345
+ const cViolations = [];
346
+ const bDebt = [];
347
+ for (const u of uses) {
348
+ if (u.kind !== 'prod')
349
+ continue;
350
+ const rule = constraintRuleOfTarget(u.target, rules);
351
+ if (!rule)
352
+ cViolations.push({ rule: 'constraint-unclassified-use', file: u.file, line: 1, what: u.target });
353
+ else if (rule.cls === 'C' || rule.cls === 'BUILD' || rule.cls === 'META')
354
+ cViolations.push({ rule: 'constraint-c-imported-by-prod', file: u.file, line: 1, what: `${u.target} (${rule.cls})` });
355
+ else if (rule.cls === 'B')
356
+ bDebt.push(u);
357
+ }
358
+ return { cViolations, bDebt };
359
+ }
360
+ /** 台账自身与门自身不算"引用" (否则会自己指自己) */
361
+ function isGateOwnFile(rel) {
362
+ return rel.startsWith('kernel/') || /^test\/kernel-[\w-]+\.test\.ts$/.test(rel);
363
+ }
364
+ /**
365
+ * verdict 同步判据:
366
+ * ready ⇒ 目标在盘上存在 **且** 仓内(排除门自身) 0 引用 —— 有引用就不许 ready;
367
+ * blocked ⇒ 每个 blocker 文件存在 **且** 它现在还真的提到这个目标 —— blocker 消失就必须改判 ready;
368
+ * done ⇒ 目标不在盘上 **且** DELETION_LEDGER 里有对应记录 (删了必须留账)。
369
+ */
370
+ export function scanDeletionVerdictSync(files, verdicts, opts) {
371
+ const out = [];
372
+ const scannable = files.filter((f) => !isGateOwnFile(f.path) && !f.path.startsWith('constraint-runtime/dist/'));
373
+ for (const v of verdicts) {
374
+ // basename 去扩展名: 目标常写作路径 (.../remote_runtime.ts), 而引用处写作模块名 (remote_runtime.js)
375
+ const basename = v.needle ?? v.target.replace(/\/$/, '').split('/').pop().replace(/\.(tsx?|js|mjs|json|cjs)$/, '');
376
+ if (v.verdict === 'ready') {
377
+ if (!opts.exists(v.target)) {
378
+ out.push({ rule: 'deletion-ready-but-missing', file: v.target, line: 1, what: '标了 ready 但盘上不存在 (是不是已删? 那要改 done + 记台账)' });
379
+ continue;
380
+ }
381
+ const refs = scannable.filter((f) => !f.path.startsWith(v.target.replace(/^src\//, '')) && f.text.includes(basename));
382
+ for (const r of refs.slice(0, 5)) {
383
+ out.push({ rule: 'deletion-ready-with-refs', file: r.path, line: 1, what: `标了 ready 但这里提到 ${basename}` });
384
+ }
385
+ continue;
386
+ }
387
+ if (v.verdict === 'blocked') {
388
+ if (v.blockers.length === 0) {
389
+ out.push({ rule: 'deletion-blocked-without-evidence', file: v.target, line: 1, what: '标了 blocked 却给不出 blocker' });
390
+ continue;
391
+ }
392
+ for (const b of v.blockers) {
393
+ if (!opts.exists(b.file)) {
394
+ out.push({ rule: 'deletion-stale-blocker', file: b.file, line: 1, what: `blocker 文件已不存在 (blocker 该重算了)` });
395
+ continue;
396
+ }
397
+ // blocker 的"还成立吗"判据 = 该文件现在仍然提到这个目标 (扫得到就行; 扫不到 = 借口过期)
398
+ const hit = scannable.find((f) => f.path === b.file && f.text.includes(basename));
399
+ if (!hit)
400
+ out.push({ rule: 'deletion-stale-blocker', file: b.file, line: 1, what: `blocker 已不再提到 ${basename} ⇒ 必须改判` });
401
+ }
402
+ continue;
403
+ }
404
+ if (v.verdict === 'not-deletable') {
405
+ // 明确"不许删"的一类 (环境声明等): 必须给出 blocker 且文件在盘上
406
+ if (v.blockers.length === 0)
407
+ out.push({ rule: 'deletion-blocked-without-evidence', file: v.target, line: 1, what: 'not-deletable 却没有依据' });
408
+ if (!opts.exists(v.target))
409
+ out.push({ rule: 'deletion-missing', file: v.target, line: 1, what: '标了 not-deletable 但盘上没了' });
410
+ continue;
411
+ }
412
+ // done
413
+ if (opts.exists(v.target))
414
+ out.push({ rule: 'deletion-done-but-present', file: v.target, line: 1, what: '标了 done 但盘上还在' });
415
+ if (!opts.ledgerTargets.includes(v.target))
416
+ out.push({ rule: 'deletion-done-without-record', file: v.target, line: 1, what: '标了 done 但没有删除记录' });
417
+ }
418
+ return out;
419
+ }
420
+ /**
421
+ * 判据 (吃源码文本, **先剥注释**):
422
+ * · 逐字段 `this.<name>` 实测计数必须与冻结值**双向相等**
423
+ * —— 多一处 = 新增泄漏; 少一处 = 改了代码没改账 (要求显式 rebase, 迁移必须是看得见的动作);
424
+ * · `migrated: true` 的字段访问必须为 0 —— 否则是假完成;
425
+ * · 每个字段的 `into` 必须落在 RunContext 目标清单里 —— 不然"外置"没有落点。
426
+ */
427
+ export function scanRunContextLeaks(files, fields, opts) {
428
+ const out = [];
429
+ for (const f of fields) {
430
+ if (!opts.target.includes(f.into)) {
431
+ out.push({ rule: 'runcontext-field-without-home', file: f.name, line: 1, what: `into='${f.into}' 不在 RunContext 目标清单里` });
432
+ }
433
+ // 口径: **匹配次数** (不是行数) —— 与 scanRunIdSeed 一致。一行含两处就读 2
434
+ // (2026-10-02 踩过: 两个判据一个按行、一个按匹配 ⇒ 同一条台账给出 37 / 38 两个答案)
435
+ const rx = new RegExp(`this\\.${f.name}\\b`, 'g');
436
+ let actual = 0;
437
+ for (const file of files) {
438
+ for (const raw of file.text.split('\n')) {
439
+ actual += (stripLineComment(raw).match(rx) || []).length;
440
+ }
441
+ }
442
+ if (actual !== f.accesses) {
443
+ out.push({
444
+ rule: 'runcontext-access-drift',
445
+ file: f.name,
446
+ line: 1,
447
+ what: `实测 ${actual} 处 ≠ 冻结 ${f.accesses} 处 (${actual > f.accesses ? '新增泄漏' : '已减少 ⇒ 必须把台账下调到实测值'})`,
448
+ });
449
+ }
450
+ if (f.migrated && actual > 0) {
451
+ out.push({ rule: 'runcontext-migrated-but-leaking', file: f.name, line: 1, what: `标了 migrated 但还有 ${actual} 处 this. 访问` });
452
+ }
453
+ }
454
+ return out;
455
+ }
456
+ /** K2 收尾: 从 seedRunContext 助手体里数"播种读取" —— 只数**助手体内**, 不数全仓同形字符串 */
457
+ export function extractSeedHelper(text) {
458
+ const i = text.indexOf('private seedRunContext(');
459
+ if (i < 0)
460
+ return '';
461
+ const j = text.indexOf('\n }', i);
462
+ return j < 0 ? text.slice(i) : text.slice(i, j + 4);
463
+ }
464
+ /**
465
+ * 判据 (吃源码文本, **先剥注释**):
466
+ * · `currentRunId` 总访问数 == 冻结值 (任何新读取点都判红, 含循环内部);
467
+ * · 播种读取 (`runId: this.currentRunId`) 恰好 `seedReads` 处, **且只在 seedRunContext 助手体内**;
468
+ * · 调用播种的入口数 == `seedSites` (且都必须在循环方法之外 —— 由"播种只存在于助手"间接保证);
469
+ * · 复位点 (`this.runCtx = createRunContext()`) 不得带播种 (清空不许携带身份)。
470
+ */
471
+ export function scanRunIdSeed(files, opts) {
472
+ const out = [];
473
+ const code = files.map((f) => f.text.split('\n').map(stripLineComment).join('\n')).join('\n');
474
+ const total = (code.match(/this\.currentRunId\b/g) || []).length; // 批次5 后应为 0 (名字已从 pi-sdk 消失)
475
+ if (total !== opts.frozenTotal) {
476
+ out.push({ rule: 'runid-access-drift', file: 'agents/pi-sdk.ts', line: 1, what: `currentRunId 实测 ${total} 处 ≠ 冻结 ${opts.frozenTotal} (循环内不得新增读取点; 减少也要下调台账)` });
477
+ }
478
+ const helper = extractSeedHelper(code);
479
+ // 批次5: 播种读取读的是**本体** (actor.state.activeRun) —— 语义不变 (仍是"恰好一处, 且在播种体内")
480
+ const seedInHelper = (helper.match(/runId: this\.actor!\.state\.activeRun\b/g) || []).length;
481
+ const seedAnywhere = (code.match(/runId: this\.currentRunId\b/g) || []).length;
482
+ if (seedInHelper !== opts.seedReads) {
483
+ out.push({ rule: 'runid-seed-count', file: 'agents/pi-sdk.ts', line: 1, what: `seedRunContext 体内播种读取 ${seedInHelper} 处 ≠ 冻结 ${opts.seedReads}` });
484
+ }
485
+ // 注: 这里**不**判"全文件播种模式出现几次" —— harness 上下文 / startRun 等处本就有同形字符串
486
+ // (`runId: this.currentRunId || undefined` 等), 那条规则会假阳性。别处偷偷加播种已由上面的
487
+ // "总访问数 == 冻结值" 覆盖 (任何新读取点都会让总数变)。2026-10-02 实测: 该规则误报 8 处后删除。
488
+ const sites = (code.match(/this\.seedRunContext\(/g) || []).length;
489
+ if (sites !== opts.seedSites) {
490
+ out.push({ rule: 'runid-seed-sites', file: 'agents/pi-sdk.ts', line: 1, what: `调用播种的入口 ${sites} 处 ≠ 冻结 ${opts.seedSites}` });
491
+ }
492
+ const resets = (code.match(/this\.runCtx = createRunContext\(\)/g) || []).length;
493
+ if ((code.match(/this\.runCtx = this\.seedRunContext\(\)/g) || []).length > 0) {
494
+ out.push({ rule: 'runid-seed-on-reset', file: 'agents/pi-sdk.ts', line: 1, what: '复位点带了播种 (清空不许携带身份)' });
495
+ }
496
+ if (resets < 5)
497
+ out.push({ rule: 'runid-reset-missing', file: 'agents/pi-sdk.ts', line: 1, what: `复位点只剩 ${resets} 处 (期望 ≥5)` });
498
+ return out;
499
+ }
500
+ /**
501
+ * 判据 (纯函数; 盘上事实由 `exists` 注入):
502
+ * ① 清单完整: Actor 状态 9 项 (名字唯一) · 验收 ≥6 · 步骤 8 · 删除前置 7;
503
+ * ② **与盘上事实同步**: 标 `not-started` ⇒ 容器文件**必须不存在**; 容器存在 ⇒ stage 必须已过 not-started;
504
+ * ③ 进度棘轮: `fieldsMigrated ≤ fieldsTotal` · `entriesWired ≤ entriesTotal` · not-started ⇒ 两个计数都必须是 0;
505
+ * ④ **跨台账一致**: 从 K2 移交的 4 个 session 字段, 其冻结访问数必须与 K2 台账逐字相等;
506
+ * ⑤ 验收标准里必须真的接住从 K2 移过来的那条 (history 不互相污染)。
507
+ */
508
+ export function scanActorLedger(ledger, opts) {
509
+ const out = [];
510
+ const f = (rule, what) => out.push({ rule, file: 'kernel/plan-channel-actor.ts', line: 1, what });
511
+ // ① 完整性
512
+ if (ledger.stateItems.length !== 9)
513
+ f('actor-state-count', `Actor 状态项 ${ledger.stateItems.length} 项 ≠ 9`);
514
+ const names = ledger.stateItems.map((i) => i.name);
515
+ if (new Set(names).size !== names.length)
516
+ f('actor-state-dup', 'Actor 状态项有重名');
517
+ for (const i of ledger.stateItems) {
518
+ if (!i.name || !i.why || !i.owner)
519
+ f('actor-state-shape', `状态项字段不全: ${i.name || '(空)'}`);
520
+ }
521
+ if (ledger.acceptance.length < 6)
522
+ f('actor-acceptance-count', `验收标准 ${ledger.acceptance.length} 条 < 6`);
523
+ if (ledger.steps.length !== 8)
524
+ f('actor-steps-count', `迁移步骤 ${ledger.steps.length} 步 ≠ 8`);
525
+ if (ledger.preconditions.length !== 7)
526
+ f('actor-precond-count', `删除前置 ${ledger.preconditions.length} 条 ≠ 7`);
527
+ if (!ledger.acceptance.some((a) => a.includes('history') && (a.includes('污染') || a.includes('隔离')))) {
528
+ f('actor-handoff-missing', '验收标准没有接住从 K2 移来的「history 不互相污染」');
529
+ }
530
+ // ② 与盘上事实同步
531
+ const containerExists = opts.exists(ledger.progress.containerPath);
532
+ if (ledger.progress.stage === 'not-started' && containerExists) {
533
+ f('actor-stage-stale', `标了 not-started 但 ${ledger.progress.containerPath} 已存在 ⇒ 台账该改`);
534
+ }
535
+ if (ledger.progress.stage !== 'not-started' && !containerExists) {
536
+ f('actor-container-missing', `stage=${ledger.progress.stage} 但容器文件不存在 ⇒ 假进度`);
537
+ }
538
+ // ③ 进度棘轮
539
+ if (ledger.progress.fieldsMigrated > ledger.progress.fieldsTotal)
540
+ f('actor-fields-overflow', 'fieldsMigrated > fieldsTotal');
541
+ if (ledger.progress.entriesWired > ledger.progress.entriesTotal)
542
+ f('actor-entries-overflow', 'entriesWired > entriesTotal');
543
+ if (ledger.progress.entrySites.wired > ledger.progress.entrySites.total)
544
+ f('actor-entrysites-overflow', 'entrySites.wired > entrySites.total');
545
+ if (ledger.progress.stage === 'not-started' && (ledger.progress.fieldsMigrated !== 0 || ledger.progress.entriesWired !== 0)) {
546
+ f('actor-progress-premature', 'not-started 阶段不许有非零进度');
547
+ }
548
+ // ③b 进度位不许自说自话: 迁了几个字段, 就得逐个点名 (名字还必须都在移交字段里)
549
+ const migrated = ledger.progress.migratedFieldNames ?? [];
550
+ if (migrated.length !== ledger.progress.fieldsMigrated) {
551
+ f('actor-fieldnames-mismatch', `fieldsMigrated=${ledger.progress.fieldsMigrated} 但名单 ${migrated.length} 个 ⇒ 进度位与名单不一致`);
552
+ }
553
+ for (const n of migrated) {
554
+ if (!ledger.inheritedFields.some((x) => x.name === n)) {
555
+ f('actor-fieldnames-unknown', `已迁名单里的 ${n} 不在移交字段 (K5_INHERITED_FIELDS) 里`);
556
+ }
557
+ }
558
+ if (ledger.progress.stage === 'fields-migrated' && ledger.progress.fieldsMigrated !== ledger.progress.fieldsTotal) {
559
+ f('actor-stage-ahead', `stage=fields-migrated 但 fieldsMigrated=${ledger.progress.fieldsMigrated}/${ledger.progress.fieldsTotal} ⇒ 阶段名超前`);
560
+ }
561
+ // ③c history 操作搬迁位: 搬了几个操作就得逐个点名, 名字必须 ∈ HISTORY_OPS, 且不许超总量
562
+ const ops = ledger.progress.historyOpsNames ?? [];
563
+ if (ops.length !== ledger.progress.historyOpsMigrated) {
564
+ f('actor-ops-mismatch', `historyOpsMigrated=${ledger.progress.historyOpsMigrated} 但名单 ${ops.length} 个 ⇒ 进度位与名单不一致`);
565
+ }
566
+ if (ledger.progress.historyOpsMigrated > ledger.progress.historyOpsTotal)
567
+ f('actor-ops-overflow', 'historyOpsMigrated > historyOpsTotal');
568
+ for (const n of ops) {
569
+ if (!HISTORY_OPS.includes(n))
570
+ f('actor-ops-unknown', `已搬操作 ${n} 不在 HISTORY_OPS 里`);
571
+ }
572
+ // ④ 跨台账一致 (K2 → K5)
573
+ for (const k5 of ledger.inheritedFields) {
574
+ const k2 = opts.k2SessionFields.find((x) => x.name === k5.name);
575
+ if (!k2) {
576
+ f('actor-inherit-unknown', `移交字段 ${k5.name} 在 K2 台账里找不到`);
577
+ continue;
578
+ }
579
+ if (k2.accesses !== k5.accesses) {
580
+ f('actor-inherit-drift', `${k5.name} 访问数 K5=${k5.accesses} ≠ K2=${k2.accesses} (两个台账必须逐字相等)`);
581
+ }
582
+ }
583
+ if (ledger.inheritedFields.length !== opts.k2SessionFields.length) {
584
+ f('actor-inherit-count', `移交字段 ${ledger.inheritedFields.length} 个 ≠ K2 session 级 ${opts.k2SessionFields.length} 个`);
585
+ }
586
+ return out;
587
+ }
588
+ /**
589
+ * **K2 ↔ K5 交接契约** (2026-10-02 定): session 级字段必须**仍是实例字段**,
590
+ * **除非 K5 台账 (migratedFieldNames) 声明它已迁**。
591
+ *
592
+ * 为什么需要这条: K2 的镜像规则原本是"session 级字段一律不许离开实例" —— 那是 K2 阶段的口径;
593
+ * K5 第 4 步要做的事正是把 `messageHistory` 迁走。若把禁令写死, K5 一到就得改门 (门就成了橡皮章);
594
+ * 若把禁令删掉, 半搬状态没人拦。⇒ 让**台账**当唯一的开关: 声明了才放行, 声明了不存在的字段也判红。
595
+ */
596
+ export function scanSessionFieldResidence(code, sessionFields, migratedByK5) {
597
+ const out = [];
598
+ const migrated = new Set(migratedByK5);
599
+ for (const f of sessionFields) {
600
+ if (migrated.has(f.name))
601
+ continue; // K5 已声明迁移 ⇒ 允许离开实例
602
+ if (!new RegExp(`private\\s+${f.name}\\s*[=:]`).test(code)) {
603
+ out.push({
604
+ rule: 'session-field-vanished',
605
+ file: 'agents/pi-sdk.ts',
606
+ line: 1,
607
+ what: `session 级字段 ${f.name} 既不在实例上, K5 台账也没声明迁移它 ⇒ 半搬状态`,
608
+ });
609
+ }
610
+ }
611
+ for (const n of migratedByK5) {
612
+ if (!sessionFields.some((f) => f.name === n)) {
613
+ out.push({
614
+ rule: 'k5-migration-unknown-field',
615
+ file: 'kernel/plan-channel-actor.ts',
616
+ line: 1,
617
+ what: `K5 声明迁移了 ${n}, 但它不是 K2 的 session 级字段`,
618
+ });
619
+ }
620
+ }
621
+ return out;
622
+ }
623
+ /**
624
+ * **K5 第 4 步 — history 写入的"唯一漏斗"判据**。
625
+ *
626
+ * 迁移前的盘上事实 (2026-10-02 实测, 记在台账 `HISTORY_WRITE_SITES`):
627
+ * `this.messageHistory.push(` × 31 · `this.messageHistory.pop()` × 1 · 整体赋值 `this.messageHistory = ` × 3
628
+ * 迁移目标: 全部收敛到三个漏斗方法 —— `pushHistory` / `popHistory` / `replaceHistory`。
629
+ * ⇒ 判据: 每个直写模式在 `pi-sdk.ts` 里**必须是 0 处** (漏斗本体写 `this._history` / actor 方法, 不碰 `this.messageHistory`),
630
+ * 且三个漏斗方法必须真的存在。
631
+ * (注释必须先由调用方剥掉 —— 否则文档里的示例会被当成真写入。)
632
+ */
633
+ export const HISTORY_WRITE_PATTERNS = [
634
+ { id: 'push', src: 'this\\.messageHistory\\.push\\(' },
635
+ { id: 'pop', src: 'this\\.messageHistory\\.pop\\(\\)' },
636
+ { id: 'assign', src: 'this\\.messageHistory\\s*=\\s' },
637
+ ];
638
+ /**
639
+ * 漏斗判据 —— 漏斗清单**由台账传入** (`plan-channel-actor.ts` 的 `HISTORY_WRITE_FUNNEL`): 台账是数据, 判据是纯函数。
640
+ * (曾经这里另抄了一份同名常量 = 两份真相, 改台账不改它就会让判据看着还绿。)
641
+ */
642
+ export function scanHistoryWriteSites(code, funnel) {
643
+ const out = [];
644
+ for (const p of HISTORY_WRITE_PATTERNS) {
645
+ const n = (code.match(new RegExp(p.src, 'g')) ?? []).length;
646
+ // 迁移后直写必须为 **0** —— 漏斗本体写的是 `this._history` / actor 的方法, 不碰 `this.messageHistory`
647
+ if (n > 0) {
648
+ out.push({
649
+ rule: `history-direct-${p.id}`,
650
+ file: 'agents/pi-sdk.ts',
651
+ line: 1,
652
+ what: `还有 ${n} 处 ${p.id} 直写 ⇒ 绕过了唯一漏斗 (pushHistory/popHistory/replaceHistory)`,
653
+ });
654
+ }
655
+ }
656
+ // 拿不到事实就拒跑 (不许"跳过")
657
+ if (!funnel || funnel.length === 0) {
658
+ return [{ rule: 'history-funnel-missing', file: 'agents/pi-sdk.ts', line: 1, what: '漏斗清单为空/拿不到 ⇒ 拒跑 (不许跳过)' }];
659
+ }
660
+ for (const f of funnel) {
661
+ // ① 声明必须在 (类方法声明: 行首缩进 + 可选可见性修饰符 + 名字 + "(")
662
+ const declared = new RegExp(`^\\s*(?:private\\s+|public\\s+)?${f.name}\\(`, 'm').test(code);
663
+ // ② 可见性必须与账一致 —— TS 里 public 是**默认**, 所以判"没有 private 修饰符"而不是"有 public"
664
+ const isPrivate = new RegExp(`^\\s*private\\s+${f.name}\\(`, 'm').test(code);
665
+ if (!declared) {
666
+ out.push({ rule: 'history-funnel-missing', file: 'agents/pi-sdk.ts', line: 1,
667
+ what: `写入漏斗 ${f.name} 不见了 (调用点会全部落到直写或编译失败)` });
668
+ }
669
+ else if (f.vis === 'private' && !isPrivate) {
670
+ out.push({ rule: 'history-funnel-missing', file: 'agents/pi-sdk.ts', line: 1,
671
+ what: `漏斗 ${f.name} 账上是 private, 盘上已不是 (可见性改动必须现形于台账)` });
672
+ }
673
+ else if (f.vis === 'public' && isPrivate) {
674
+ out.push({ rule: 'history-funnel-missing', file: 'agents/pi-sdk.ts', line: 1,
675
+ what: `漏斗 ${f.name} 账上是 public (唯一"种历史"入口), 盘上仍标 private` });
676
+ }
677
+ }
678
+ return out;
679
+ }
680
+ /**
681
+ * **K5 步骤④ — 入口执行点的精确计数口径** (判据与台账必须用同一口径, 否则数字对不上):
682
+ * ① 只数 `.<promptStream>(` 与 `.<prompt>(` (**非流式也算** —— 它同样会启动一次执行);
683
+ * ② 先剥 `//` 行注释, 并丢掉 `*` 开头的块注释行 (否则注释里的示例会被当成执行点);
684
+ * ③ `excludeReceivers`: **已核实的非执行点 receiver** 名单 (台账里的数据, 冻结):
685
+ * `this` = CLI readline (`this.prompt('> ')`); `s` = index.ts 的 UI 打印助手 (`s.prompt('📩 …')`)。
686
+ * 名单改动会出现在 diff 里 ⇒ 不能拿它偷偷把执行点数变小。
687
+ */
688
+ export function countEntryExecutionPoints(code, opts = {}) {
689
+ const excludeReceivers = opts.excludeReceivers ?? ['this'];
690
+ // **方法名单也是台账数据**: 启动一次执行的 AgentSession 方法 (不只是 prompt)
691
+ // —— 漏掉它会让 P2P 入站那种走 `summarizeDocument/improveDocument` 的入口永远数不到 (实测漏过 4 处)。
692
+ const methods = opts.methods ?? ['prompt', 'promptStream'];
693
+ let n = 0;
694
+ for (const raw of code.split(/\r?\n/)) {
695
+ const l = raw.replace(/\/\/.*$/, '');
696
+ if (/^\s*\*/.test(l))
697
+ continue; // 块注释行
698
+ for (const m of methods) {
699
+ // `[!?]?` 允许非空断言 / 可选调用: `session.runExecution!(req)` 也是执行点
700
+ // (实测: 不带这一段, 改成请求式的站点会从计数里消失 ⇒ 该文件 total 从 11 掉到 10)
701
+ const rx = new RegExp(`\\.\\s*${m}\\s*[!?]?\\s*\\(`, 'g');
702
+ if (!rx.test(l))
703
+ continue;
704
+ const recv = new RegExp(`([A-Za-z_$][\\w$]*|this)\\s*\\.\\s*${m}\\s*[!?]?\\s*\\(`).exec(l);
705
+ if (recv && excludeReceivers.includes(recv[1]))
706
+ continue; // 已核实的非执行点
707
+ n += 1;
708
+ }
709
+ }
710
+ return n;
711
+ }
712
+ /** 同一口径下**已投递**的点数 (`deliverThroughActor(` 的出现次数) */
713
+ export function countDeliveredPoints(code) {
714
+ return (code.match(/deliverThroughActor\(/g) ?? []).length;
715
+ }
716
+ /**
717
+ * **K5 步骤④ — 入口投递的进度不许自报**: 台账里的 `total` / `wired` 都必须**从盘上重算**得到。
718
+ * 少包一处却把 wired 写大 ⇒ 红; 新增执行点不登记 ⇒ 红; wired > total ⇒ 红; 台账里有盘上不存在的文件 ⇒ 红。
719
+ */
720
+ export function scanEntryDelivery(sources, progressLike) {
721
+ const out = [];
722
+ const f = (file, what) => out.push({ rule: 'entry-delivery-mismatch', file, line: 1, what });
723
+ const methods = progressLike.entryMethods ?? ['prompt', 'promptStream'];
724
+ const complete = new Set();
725
+ for (const s of progressLike.entrySites) {
726
+ const src = sources.find((x) => x.file === s.file);
727
+ if (!src) {
728
+ f(s.file, `台账登记的文件在扫描面里不存在 ⇒ 口径不可信`);
729
+ continue;
730
+ }
731
+ const total = countEntryExecutionPoints(src.text, { excludeReceivers: s.excludeReceivers ?? ['this'], methods });
732
+ const wired = countDeliveredPoints(src.text);
733
+ if (total !== s.total)
734
+ f(s.file, `入口执行点盘上 ${total} 处 ≠ 台账 ${s.total} ⇒ 新增/删除没登记`);
735
+ if (wired !== s.wired)
736
+ f(s.file, `已投递盘上 ${wired} 处 ≠ 台账 ${s.wired} ⇒ 进度对不上事实`);
737
+ if (s.wired > s.total)
738
+ f(s.file, `wired ${s.wired} > total ${s.total}`);
739
+ // `total === 0` (该文件没有执行点) 算**空真完成** —— 否则"入口声明完成"会因为它永远判红
740
+ if (s.total === s.wired)
741
+ complete.add(s.file);
742
+ }
743
+ // **入口级声明双向校验** (两侧都能判): 说完成了 ⇒ 它的文件必须全接完; 说没完成 ⇒ 必须真有文件没接完。
744
+ const groups = progressLike.entryGroups ?? [];
745
+ for (const g of groups) {
746
+ const all = g.files.every((x) => complete.has(x));
747
+ if (g.wired && !all)
748
+ f(g.files[0], `入口「${g.entry}」标完成, 但 ${g.files.filter((x) => !complete.has(x)).join('/')} 还没接完`);
749
+ if (!g.wired && all)
750
+ f(g.files[0], `入口「${g.entry}」的文件都接完了, 却还标着未完成 ⇒ 台账该前进`);
751
+ }
752
+ if (progressLike.entriesWired !== undefined && progressLike.entriesWired !== groups.filter((g) => g.wired).length) {
753
+ f('kernel/plan-channel-actor.ts', `entriesWired=${progressLike.entriesWired} ≠ 标完成的入口数 ${groups.filter((g) => g.wired).length}`);
754
+ }
755
+ return out;
756
+ }
757
+ /**
758
+ * **K5 步骤⑤ — channel 级串行锁的开关必须与盘上事实一致** (双向):
759
+ * `enabled === true` ⇒ 必须真有调用点传了 `serializeByChannel: true`;
760
+ * `enabled === false` ⇒ 一个都不许有 (否则"台账说没启用, 代码却启用了");
761
+ * 调用点数必须与台账 `callSites` 相等。
762
+ */
763
+ export function scanChannelLock(sources, lock) {
764
+ const out = [];
765
+ const f = (what) => out.push({ rule: 'channel-lock-mismatch', file: 'kernel/plan-channel-actor.ts', line: 1, what });
766
+ let sites = 0;
767
+ for (const s of sources) {
768
+ for (const raw of s.text.split(/\r?\n/)) {
769
+ const l = raw.replace(/\/\/.*$/, '');
770
+ if (/^\s*\*/.test(l))
771
+ continue;
772
+ sites += (l.match(/serializeByChannel:\s*true/g) ?? []).length;
773
+ }
774
+ }
775
+ if (sites !== lock.callSites)
776
+ f(`盘上 ${sites} 个调用点传了 serializeByChannel:true ≠ 台账 ${lock.callSites}`);
777
+ if (lock.enabled && sites === 0)
778
+ f('台账说 channel 级串行已启用, 但盘上一个调用点都没传 ⇒ 假开关');
779
+ if (!lock.enabled && sites > 0)
780
+ f('台账说未启用, 但盘上已有调用点启用 ⇒ 台账落后于代码');
781
+ return out;
782
+ }
783
+ /**
784
+ * **K5 步骤⑥ — Run 身份的归属判据** (双向):
785
+ * 标 `migrated: true` ⇒ 盘上源码**不许再有** `private currentRunId [=:]` (它已变成访问器, 本体在 actor);
786
+ * 标 `migrated: false` ⇒ 必须**还有**那个声明 (不许偷偷搬走却不改账)。
787
+ */
788
+ export function scanRunBoundaryResidence(code, rb) {
789
+ const out = [];
790
+ const f = (what) => out.push({ rule: 'run-boundary-residence', file: 'agents/pi-sdk.ts', line: 1, what });
791
+ const declared = new RegExp(`private\\s+${rb.field}\\s*[=:]`).test(code);
792
+ if (rb.migrated && declared)
793
+ f(`${rb.field} 仍以实例字段存在 (${rb.into} 已接管却被留下 ⇒ 两份真相)`);
794
+ if (!rb.migrated && !declared)
795
+ f(`${rb.field} 不见了, 台账却说没迁 (migrated=false) ⇒ 半搬状态`);
796
+ return out;
797
+ }
798
+ /**
799
+ * **K5 步骤⑦ — 一次性 `ExecutionRequest` 的收敛进度** (只验**真数得到**的那一半, 不编造可验证性):
800
+ * · `converted` = 入口面上 `runExecution` 调用数 (请求式投递; 允许非空断言 `runExecution!(`);
801
+ * · 判据: `converted` 必须等于盘上计数 · 不许超过已投递点数 · Pi 侧必须真的有
802
+ * `runExecution(` 与 `applyExecutionRequest(` (台账说加了就必须加, 反之亦然);
803
+ * · **`remaining` 是派生值** (= 已投递点数 − converted), 台账里注明它是算出来的, 不假装被独立验证:
804
+ * 位置参数式的形态太多 (多行 / `as any` / 带参箭头), 行级正则会**数不准**; 与其编一个假精确的门,
805
+ * 不如把"请求式"这半钉死, 剩下那半用算术表示。
806
+ */
807
+ export function scanExecutionRequest(entrySources, piCode, req) {
808
+ const out = [];
809
+ const f = (what) => out.push({ rule: 'execution-request-mismatch', file: 'kernel/plan-channel-actor.ts', line: 1, what });
810
+ let converted = 0;
811
+ for (const s of entrySources) {
812
+ for (const raw of s.text.split(/\r?\n/)) {
813
+ const l = raw.replace(/\/\/.*$/, '');
814
+ if (/^\s*\*/.test(l))
815
+ continue;
816
+ converted += (l.match(/runExecution[!?]?\(/g) ?? []).length;
817
+ }
818
+ }
819
+ if (converted !== req.converted)
820
+ f(`请求式投递 盘上 ${converted} 处 ≠ 台账 ${req.converted}`);
821
+ if (converted > req.wiredTotal)
822
+ f(`请求式 ${converted} > 已投递点数 ${req.wiredTotal}`);
823
+ const hasRun = /runExecution\s*\(/.test(piCode);
824
+ const hasApply = /applyExecutionRequest\s*\(/.test(piCode);
825
+ if (req.methodAdded && (!hasRun || !hasApply))
826
+ f('台账说唯一入口已加, 但 pi-sdk 里缺 runExecution/applyExecutionRequest');
827
+ if (!req.methodAdded && hasRun)
828
+ f('pi-sdk 里已有 runExecution, 台账却说没加');
829
+ return out;
830
+ }
831
+ /**
832
+ * **K5 步骤⑧ — "暂存字段已删"必须与盘上双向一致**: 台账说删了 ⇒ 源码里不许再有那些 `private <field>` 声明;
833
+ * 台账没列而源码里也没有 ⇒ 说明有字段被删了却没登记 (迁移必须看得见)。
834
+ */
835
+ export function scanStagingFieldDeletion(piCode, del) {
836
+ const out = [];
837
+ const f = (what) => out.push({ rule: 'staging-field-deletion', file: 'agents/pi-sdk.ts', line: 1, what });
838
+ for (const name of del.sourceFields) {
839
+ const declared = new RegExp(`private\\s+${name}\\b`).test(piCode);
840
+ const claimedDeleted = del.deletedStagingFields.includes(name);
841
+ if (claimedDeleted && declared)
842
+ f(`${name} 台账说已删, 源码里还在`);
843
+ if (!claimedDeleted && !declared)
844
+ f(`${name} 源码里已不存在, 台账却没登记删除`);
845
+ }
846
+ return out;
847
+ }
848
+ /**
849
+ * **K5 步骤⑧ 棘轮判据**: 盘上重算 pi-sdk 对已迁字段访问器的引用数, 与台账**逐字相等**。
850
+ * 增 ⇒ 迁移回退; 减 ⇒ 改了盘上没改账 (账必须与事实同步)。两侧都是真发现。
851
+ */
852
+ export function scanAccessorSurface(piCode, surface) {
853
+ const out = [];
854
+ const lines = piCode.split('\n').filter((l) => !/^\s*\*/.test(l));
855
+ for (const f of surface.accessorFields) {
856
+ const rx = new RegExp(`this\\.${f}\\b`, 'g');
857
+ let n = 0;
858
+ for (const l of lines)
859
+ n += (l.replace(/\/\/.*$/, '').match(rx) ?? []).length;
860
+ const frozen = surface.frozenInPiSdk[f];
861
+ if (frozen === undefined)
862
+ out.push({ rule: 'accessor-surface', file: 'agents/pi-sdk.ts', line: 1, what: `${f} 没有冻结值` });
863
+ else if (n > frozen)
864
+ out.push({ rule: 'accessor-surface', file: 'agents/pi-sdk.ts', line: 1, what: `${f} 引用数 ${n} > 冻结 ${frozen} (迁移回退)` });
865
+ else if (n < frozen)
866
+ out.push({ rule: 'accessor-surface', file: 'agents/pi-sdk.ts', line: 1, what: `${f} 引用数 ${n} < 冻结 ${frozen} (盘上变了账没跟上)` });
867
+ }
868
+ return out;
869
+ }
870
+ /**
871
+ * **前置必须点名背书**: 每条删除前置都要有 `backedBy`, 且每个背书要么是盘上存在的文件, 要么是 gate-scan 里存在的判据函数名。
872
+ * (只核"背书存在", 不代替真跑 —— 判据不谎称可验证性。)
873
+ */
874
+ export function scanPreconditionBacking(preconditions, opts) {
875
+ const out = [];
876
+ if (preconditions.length === 0)
877
+ out.push({ rule: 'precondition-backing', file: 'kernel/plan-channel-actor.ts', line: 1, what: '前置清单为空 (拿不到事实就拒跑)' });
878
+ for (const p of preconditions) {
879
+ if (!p.backedBy || p.backedBy.length === 0) {
880
+ out.push({ rule: 'precondition-backing', file: 'kernel/plan-channel-actor.ts', line: 1, what: `前置没有背书: ${p.text}` });
881
+ continue;
882
+ }
883
+ for (const b of p.backedBy) {
884
+ const isFile = b.includes('/') || b.endsWith('.test.ts');
885
+ const ok = isFile ? fs.existsSync(path.join(opts.repoRoot, b)) : opts.judgeNames.includes(b) || b.startsWith('K5_');
886
+ if (!ok)
887
+ out.push({ rule: 'precondition-backing', file: 'kernel/plan-channel-actor.ts', line: 1, what: `背书不存在: ${b} (前置: ${p.text})` });
888
+ }
889
+ }
890
+ return out;
891
+ }
892
+ /**
893
+ * **K5 步骤⑧ 完成判据** —— 声明必须与**从盘上重算**的事实一致 (双向):
894
+ * 条件 A: 5 个访问器在 pi-sdk 侧 0 引用;
895
+ * 条件 B: 5 个"绑定前暂存"字段的 `private <name>` 声明不存在;
896
+ * 条件 C: 入口投递 wired === total。
897
+ * 声明完成而任一条件不成立 ⇒ 红; 事实三条全成立而台账说没完成 ⇒ 红 (台账要跟上)。
898
+ */
899
+ export function scanStep8Completion(piCode, step8, surface, fieldDeletion) {
900
+ const out = [];
901
+ const f = (what) => out.push({ rule: 'step8-completion', file: 'kernel/plan-channel-actor.ts', line: 1, what });
902
+ const liveAccessors = scanAccessorSurface(piCode, surface).map((x) => x.what);
903
+ const stagingStillDeclared = fieldDeletion.sourceFields.filter((n) => new RegExp(`private\\s+${n}\\b`).test(piCode));
904
+ const entriesOk = step8.entriesTotal > 0 && step8.entriesWired === step8.entriesTotal;
905
+ const conditionsHold = liveAccessors.length === 0 && stagingStillDeclared.length === 0 && entriesOk;
906
+ if (step8.claimedComplete) {
907
+ if (liveAccessors.length > 0)
908
+ f(`声明⑧ 完成, 但访问器仍非 0 引用: ${liveAccessors.join(' / ')}`);
909
+ if (stagingStillDeclared.length > 0)
910
+ f(`声明⑧ 完成, 但暂存字段仍在: ${stagingStillDeclared.join(', ')}`);
911
+ if (!entriesOk)
912
+ f(`声明⑧ 完成, 但入口投递 ${step8.entriesWired}/${step8.entriesTotal}`);
913
+ }
914
+ else if (conditionsHold) {
915
+ f('三条完成条件都已成立, 台账却说⑧ 未完成 ⇒ 台账该跟上 (事实优先)');
916
+ }
917
+ return out;
918
+ }
919
+ /**
920
+ * **越权欠账不许"烂在账上"**: 若某条欠账写着 `payDownIn: <K>`, 而 `<K>` 已按台账收工 (`STAGE_STATUS[K] === 'done'`),
921
+ * 那就说明"记着让那个阶段还, 而那个阶段过完了" —— 必须二选一: 立刻还清, 或把 `payDownIn` 重排并写明 `note`。
922
+ * (台账自己的注释就写着"不许台账烂在上面", 但没有判据 ⇒ 这条规则让它可被抓。)
923
+ */
924
+ export function scanDebtPaydownStaleness(debt, stageStatus) {
925
+ const out = [];
926
+ for (const d of debt) {
927
+ if (!d.payDownIn) {
928
+ out.push({ rule: 'debt-unassigned', file: d.file, line: 1, what: `欠账 ${d.call} 没排期 (payDownIn 为空)` });
929
+ continue;
930
+ }
931
+ if (stageStatus[d.payDownIn] === 'done') {
932
+ out.push({
933
+ rule: 'debt-paydown-stale', file: d.file, line: 1,
934
+ what: `欠账 ${d.call} 记着由 ${d.payDownIn} 还清, 而 ${d.payDownIn} 已收工 —— 要么现在还清, 要么重排并写明 note`,
935
+ });
936
+ }
937
+ if (!d.note || d.note.trim().length < 10) {
938
+ out.push({ rule: 'debt-note-missing', file: d.file, line: 1, what: `欠账 ${d.call} 没有写明还款路径/重排原因 (note)` });
939
+ }
940
+ }
941
+ return out;
942
+ }
943
+ /**
944
+ * **旧写口调用点数的唯一口径** (台账与判据共用同一函数 —— 口径不一致会让两侧各报一个数):
945
+ * 全仓给定文件集 (调用方**排除** `test/` 与 `kernel/`) 里 `name(` 的匹配次数, **去掉含 `function name` 的声明行**。
946
+ * 为什么去声明行: 否则"函数自己那行"会被算成一个调用点, 额度虚高。
947
+ */
948
+ export function countModelWritePortCalls(files, name) {
949
+ const rx = new RegExp(`\\b${name}\\s*\\(`);
950
+ const decl = new RegExp(`\\bfunction\\s+${name}\\b`);
951
+ let n = 0;
952
+ for (const f of files) {
953
+ for (const line of f.text.split('\n')) {
954
+ if (decl.test(line))
955
+ continue;
956
+ n += (line.match(rx) || []).length;
957
+ }
958
+ }
959
+ return n;
960
+ }
961
+ /**
962
+ * **K6 门**:
963
+ * ① 旧写口棘轮: 每个写口的调用点数**从盘上重算**, 与台账逐字相等
964
+ * (增 ⇒ 新层偷偷改旧状态 = 回退; 减 ⇒ 改了盘没改账);
965
+ * ② 合计自洽: 各项之和 == 冻结值, 且冻结值 == 台账里的合计;
966
+ * ③ 能力清单每项都有非空 why + 合法 status; 明确不做清单 5 条齐全 (防越界);
967
+ * ④ **"声明未实现 ⇒ 运行时文件必须不存在"** (与 K5 的容器规则同款, 防假进度);
968
+ * ⑤ `acquire` 只读要求必须记为 `readOnly: true` 且写着 ratchet 口径。
969
+ */
970
+ export function scanModelRuntimeLedger(ledger, files, opts) {
971
+ const out = [];
972
+ const f = (what) => out.push({ rule: 'modelruntime-ledger', file: 'kernel/plan-modelruntime.ts', line: 1, what });
973
+ // ① 逐写口重算
974
+ for (const p of ledger.writePorts) {
975
+ const actual = countModelWritePortCalls(files, p.name);
976
+ if (actual > p.callSites)
977
+ f(`${p.name} 调用点 ${actual} > 冻结 ${p.callSites} ⇒ 新层在增加旧写口调用 (回退)`);
978
+ else if (actual < p.callSites)
979
+ f(`${p.name} 调用点 ${actual} < 冻结 ${p.callSites} ⇒ 盘上变了账没跟上`);
980
+ }
981
+ // ② 合计自洽
982
+ const sum = ledger.writePorts.reduce((a, b) => a + b.callSites, 0);
983
+ if (sum !== ledger.writePortsFrozenAt)
984
+ f(`写口调用点合计 ${sum} ≠ 冻结值 ${ledger.writePortsFrozenAt}`);
985
+ // ③ 能力清单 + 越界清单
986
+ if (ledger.capabilities.length === 0)
987
+ f('能力清单为空 (拿不到事实就拒跑)');
988
+ for (const c of ledger.capabilities) {
989
+ if (!c.why || c.why.length < 4)
990
+ f(`能力 ${c.key} 没写"为什么"(why)`);
991
+ if (!['not-started', 'in-progress', 'done'].includes(c.status))
992
+ f(`能力 ${c.key} 状态非法: ${c.status}`);
993
+ }
994
+ if (ledger.outOfScope.length !== 5)
995
+ f(`"明确不做"清单 ${ledger.outOfScope.length} 条 ≠ 5 (防越界清单不许缩减)`);
996
+ // ④ 假进度
997
+ const declaredNotStarted = ledger.progress.stage === 'not-started';
998
+ if (declaredNotStarted && opts.runtimeExists)
999
+ f(`标了 not-started 但 ${ledger.progress.runtimePath} 已存在 ⇒ 台账该改`);
1000
+ if (!declaredNotStarted && !opts.runtimeExists)
1001
+ f(`stage=${ledger.progress.stage} 但 ${ledger.progress.runtimePath} 不存在 ⇒ 假进度`);
1002
+ // ⑤ 只读要求
1003
+ if (ledger.acquireRule.readOnly !== true)
1004
+ f('acquire 的只读要求被拿掉了 (这是 K6 的核心不变量)');
1005
+ if (!ledger.acquireRule.ratchet.includes('只许不变或减少'))
1006
+ f('ratchet 口径文案被改 (必须写明"只许不变或减少")');
1007
+ return out;
1008
+ }
1009
+ /**
1010
+ * **K6 读写分离: 运行时文件里不许出现任何旧写口名** —— 这是"只读"最机械的那半证据
1011
+ * (另一半是真跑: 冻结的 snapshot 上调用不许抛 / 不许触发注入的写端口)。
1012
+ * 同时核: 文件必须真的含 `acquire(` 与 AbortController (否则"实现了"是空话),
1013
+ * 以及 `capabilitiesDone` 必须等于能力清单里 status==='done' 的条数 (台账不许自报)。
1014
+ */
1015
+ export function scanModelRuntimeFile(code, opts) {
1016
+ const out = [];
1017
+ const f = (what) => out.push({ rule: 'modelruntime-file', file: 'kernel/model-runtime.ts', line: 1, what });
1018
+ if (code.trim().length === 0) {
1019
+ f('运行时文件为空 ⇒ 拒跑 (不许跳过)');
1020
+ return out;
1021
+ }
1022
+ for (const name of opts.writePortNames) {
1023
+ if (new RegExp(`\\b${name}\\b`).test(code))
1024
+ f(`运行时文件里出现旧写口 ${name} ⇒ 读写没分开 (K6 只读红线)`);
1025
+ }
1026
+ if (!/\bacquire\s*\(/.test(code))
1027
+ f('没有 acquire( ⇒ 入口没实现');
1028
+ if (!/AbortController/.test(code))
1029
+ f('没有 AbortController ⇒ 取消/超时没有真做到 (只"等"不"中止")');
1030
+ if (!/clearTimeout/.test(code))
1031
+ f('没有 clearTimeout ⇒ 超时定时器会泄漏 (调用成功也留着)');
1032
+ const done = opts.capabilities.filter((c) => c.status === 'done').length;
1033
+ if (opts.progress.capabilitiesDone !== done)
1034
+ f(`capabilitiesDone=${opts.progress.capabilitiesDone} ≠ 清单里 done 的条数 ${done}`);
1035
+ return out;
1036
+ }