dsh-vibe-math 2.7.1 → 2.7.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.
- package/README.en.md +682 -690
- package/README.md +19 -27
- package/cordis.patch.yml +112 -16
- package/docs/AUDIT-CHECKLIST.md +744 -518
- package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
- package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
- package/docs/final-paper.md +172 -172
- package/docs/formal-verification.md +588 -588
- package/docs/math-computation.md +194 -163
- package/docs/parameter-schema.md +37 -0
- package/docs/release-notes/README.en.md +49 -0
- package/docs/release-notes/README.md +48 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +72 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.2.md +72 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
- package/docs/release-notes/TEMPLATE.md +126 -0
- package/docs/status-report-fields.md +167 -0
- package/docs/test-timing.md +157 -154
- package/installer.js +27 -17
- package/package.json +55 -3
- package/prompt-corpus-persona/persona-corpus.json +9 -9
- package/prompt-corpus-persona/persona-corpus.md +225 -33
- package/prompt-corpus-v2/formal-verify-v2.json +305 -113
- package/prompt-corpus-v2/formal-verify-v2.md +2399 -279
- package/prompt-corpus-v3/formal-verify-v3.json +134 -110
- package/prompt-corpus-v3/formal-verify-v3.md +409 -141
- package/prompt-corpus-v4/formal-verify-v4.json +18 -18
- package/prompt-corpus-v4/formal-verify-v4.md +49 -44
- package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
- package/prompt-corpus-v5/prompt-corpus-v5.md +513 -259
- package/scripts/update-doc-counts.mjs +71 -0
- package/tests/audit-artifact-docs.mjs +72 -0
- package/tests/audit-artifact-docs.mutants.mjs +58 -0
- package/tests/audit-installer-assertions.mutants.mjs +63 -0
- package/tests/audit-installer-compat.test.mjs +52 -4
- package/tests/audit-installer-policy.test.mjs +41 -8
- package/tests/audit-math-computation-contract.mjs +107 -9
- package/tests/audit-math-computation-contract.mutants.mjs +106 -0
- package/tests/audit-math-computation-parity.mjs +164 -3
- package/tests/audit-math-computation-sensitivity.mjs +6 -4
- package/tests/audit-package-membership.mjs +57 -0
- package/tests/audit-package-membership.mutants.mjs +28 -0
- package/tests/audit-participant-set-parity.mjs +160 -0
- package/tests/audit-path-discipline.mjs +83 -0
- package/tests/audit-path-discipline.mutants.mjs +51 -0
- package/tests/audit-persona-sensitivity.mjs +14 -0
- package/tests/audit-persona-surface.mutants.mjs +69 -0
- package/tests/audit-persona-surface.test.mjs +63 -22
- package/tests/audit-prompt-duplication.mjs +143 -0
- package/tests/audit-prompt-duplication.mutants.mjs +50 -0
- package/tests/audit-prompt-invariants.mjs +43 -5
- package/tests/audit-readme-bilingual.test.mjs +152 -0
- package/tests/audit-readme-counts.mjs +95 -0
- package/tests/audit-readme-counts.mutants.mjs +52 -0
- package/tests/audit-registration.mjs +78 -0
- package/tests/audit-status-report-fields.mjs +172 -0
- package/tests/audit-status-report-fields.mutants.mjs +64 -0
- package/tests/audit-v3-registration-parity.mjs +156 -0
- package/tests/audit-v5-integrity.mjs +300 -13
- package/tests/audit-v5-lean-abstention.mjs +392 -0
- package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
- package/tests/audit-v5-prompt-duplication.mjs +88 -0
- package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
- package/tests/audit-v5-sensitivity.mjs +15 -8
- package/tests/e2e-identity-a6.mutants.mjs +65 -0
- package/tests/e2e-v5-round2.test.mjs +566 -12
- package/tests/formal-verify-v2.test.mjs +91 -5
- package/tests/formal-verify-v3.mutants.mjs +117 -0
- package/tests/formal-verify-v3.test.mjs +85 -6
- package/tests/formal-verify-v4.mutants.mjs +152 -0
- package/tests/formal-verify-v4.test.mjs +628 -9
- package/tests/formal-verify-v5.test.mjs +68 -9
- package/tests/helpers/doc-counts-format.mjs +41 -0
- package/tests/helpers/math-computation-fake-seam.mjs +4 -0
- package/tests/math-computation-a1.mutants.mjs +72 -0
- package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
- package/tests/math-computation-discovery.mutants.mjs +76 -0
- package/tests/math-computation-shared.test.mjs +396 -2
- package/tests/math-computation-v2.test.mjs +767 -0
- package/tests/math-computation-v3.test.mjs +759 -0
- package/tests/math-computation-v4.mutants.mjs +133 -0
- package/tests/math-computation-v4.test.mjs +739 -0
- package/tests/prompt-v5-integrity.test.mjs +13 -2
- package/tests/run-tests.mjs +139 -9
- package/tests/run-tests.mutants.mjs +70 -0
- package/tests/selfdrive-v5.mjs +53 -1
- package/tests/selfdrive-v5.mutants.mjs +132 -0
- package/tests/v2-fix-probes.mutants.mjs +100 -0
- package/tests/v2-fix-probes.test.mjs +1039 -0
- package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
- package/tests/v3-fix-probes.mutants.mjs +95 -0
- package/tests/v3-fix-probes.test.mjs +891 -0
- package/tests/v5-institute-fixes.mutants.mjs +190 -0
- package/vibe-math-v2/agent.cordis.yml +62 -6
- package/vibe-math-v2/math-computation.js +229 -29
- package/vibe-math-v2/math-engines.js +12 -12
- package/vibe-math-v2/vibe-math-v2.js +458 -119
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
- package/vibe-math-v3/agent.cordis.yml +72 -6
- package/vibe-math-v3/math-computation.js +229 -29
- package/vibe-math-v3/math-engines.js +12 -12
- package/vibe-math-v3/vibe-math-v3.js +482 -139
- package/vibe-math-v4/agent.cordis.yml +44 -8
- package/vibe-math-v4/math-computation.js +229 -29
- package/vibe-math-v4/math-engines.js +12 -12
- package/vibe-math-v4/vibe-math-v4.js +245 -52
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
- package/vibe-math-v5/agent.cordis.yml +46 -12
- package/vibe-math-v5/math-computation.js +229 -29
- package/vibe-math-v5/math-engines.js +12 -12
- package/vibe-math-v5/vibe-math-v5.js +1307 -243
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1655
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v3 round B regression suite — the four findings whose fix is only observable by
|
|
3
|
+
* writing/reading real Markdown or by driving the real scheduler:
|
|
4
|
+
*
|
|
5
|
+
* H3 `### vN(原因)` 的原因 must survive a compose→parse→compose round-trip
|
|
6
|
+
* H4 the association anchors 判断命题/来源命题 (problem cards) and
|
|
7
|
+
* 来源问题/来源方向 (proposition cards) must be WRITTEN, so a reload keeps them
|
|
8
|
+
* H5 a single verifier vote must NEVER conclude a proposition (no 概率=1, no Verified/ card)
|
|
9
|
+
* M7 parseAppTitle must not read the `|` separator as the 问题 value
|
|
10
|
+
*
|
|
11
|
+
* Uses the same fake-ctx harness as tests/e2e-v3.test.mjs. Writes only into a temp workspace.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join, dirname } from 'node:path';
|
|
16
|
+
|
|
17
|
+
// Seam for guard verification (same pattern as tests/math-computation-v2.test.mjs and the e2e suites):
|
|
18
|
+
// point the preset at a mutant copy so source-level guards can be reddened without editing the repo.
|
|
19
|
+
const PLUGIN = process.env.V3_PLUGIN
|
|
20
|
+
? new URL('file:///' + String(process.env.V3_PLUGIN).replace(/\\/g, '/'))
|
|
21
|
+
: new URL('../vibe-math-v3/vibe-math-v3.js', import.meta.url);
|
|
22
|
+
const WS = mkdtempSync(join(tmpdir(), 'vibe-fixprobe-'));
|
|
23
|
+
|
|
24
|
+
let passed = 0; let failed = 0;
|
|
25
|
+
const assert = (cond, msg) => { if (cond) { passed += 1; console.log(' ok - ' + msg); } else { failed += 1; console.error(' FAIL - ' + msg); } };
|
|
26
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
|
|
28
|
+
// ================= H3 + M7: pure helpers, imported for real =================
|
|
29
|
+
const mod = await import(PLUGIN.href + '?t=' + Date.now());
|
|
30
|
+
const H = mod.__testHelpers;
|
|
31
|
+
|
|
32
|
+
console.log('\n-- H3: method improvement 原因 survives a md round-trip --');
|
|
33
|
+
{
|
|
34
|
+
const card = ['# 方法|估算范式', '- 标题: 估算范式', '- ID: m1', '- 类型: 方法', '- 状态: 经验',
|
|
35
|
+
'- 可信断言: []', '- 上级体系: []', '- 子方法: []', '- 相关: []', '- 适用场景: 积分估计', '',
|
|
36
|
+
'## 核心内容', '核心。', '', '## 应用记录', '(暂无应用记录)', '', '## 改进历史',
|
|
37
|
+
'### v1(初始沉淀的原因)', '第一个改进的正文。', '',
|
|
38
|
+
'### v2(第二条原因)', '第二个改进的正文。', ''].join('\n');
|
|
39
|
+
const parsed = H.parseMethodMd('m1', card);
|
|
40
|
+
assert(parsed.improvements.length === 2, 'both improvement entries parse back (count=' + parsed.improvements.length + ')');
|
|
41
|
+
assert(parsed.improvements[0] && parsed.improvements[0].原因 === '初始沉淀的原因', 'v1 原因 = 初始沉淀的原因 (got ' + JSON.stringify(parsed.improvements[0] && parsed.improvements[0].原因) + ')');
|
|
42
|
+
assert(parsed.improvements[1] && parsed.improvements[1].原因 === '第二条原因', 'v2 原因 parsed');
|
|
43
|
+
assert(parsed.improvements[0] && parsed.improvements[0].text === '第一个改进的正文。', 'v1 正文 survives');
|
|
44
|
+
// second pass = what every sync_meta/compose cycle does
|
|
45
|
+
const again = H.parseMethodMd('m1', card.replace(/### v1(初始沉淀的原因)/, '### v1(' + parsed.improvements[0].原因 + ')'));
|
|
46
|
+
assert(again.improvements[0].原因 === '初始沉淀的原因', 'the reason written back is not the empty string (no `### v1()` regression)');
|
|
47
|
+
assert(!/###\s*v1\s*(\s*)/.test(again.improvements[0].原因), 'the empty-reason shape is gone');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log('\n-- M7: parseAppTitle does not eat the | separator --');
|
|
51
|
+
{
|
|
52
|
+
const t = H.parseAppTitle('2026-01-01|问题 |方向 d1');
|
|
53
|
+
assert(t.问题 === '', 'empty 问题 parses back as empty (got ' + JSON.stringify(t.问题) + ')');
|
|
54
|
+
assert(t.方向 === 'd1', '方向 still parses');
|
|
55
|
+
assert(t.at === '2026-01-01', 'timestamp still parses');
|
|
56
|
+
const t2 = H.parseAppTitle('应用 1|2026-01-01 00:00:00|问题 q1 方向 d1');
|
|
57
|
+
assert(t2.问题 === 'q1' && t2.方向 === 'd1', 'the normal shape (问题 q1 方向 d1) still parses');
|
|
58
|
+
const t3 = H.parseAppTitle('2026-01-01|问题 q-1/2 方向 d_1');
|
|
59
|
+
assert(t3.问题 === 'q-1/2' && t3.方向 === 'd_1', 'ids with - and _ still parse');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ================= H4 + H5: drive the real plugin =================
|
|
63
|
+
const listeners = {}; const toolRegs = []; const spawns = []; const cmdRegs = []; const latexRuns = [];
|
|
64
|
+
// ---- final-paper 探针用的可变宿主配置(spec §6):无 LaTeX = latexCfg null(真机形态)----
|
|
65
|
+
let latexCfg = null // { engines: [...], failFirst?, alwaysFail?, mode? }
|
|
66
|
+
let refuseNext = 0 // 接下来的 N 次 startContinuable 抛宿主激活上限(ACTIVATION_LIMIT_REACHED)
|
|
67
|
+
let lastPrompt = '' // 最近一次子代理提示词(断言用)
|
|
68
|
+
const latexSeen = {};
|
|
69
|
+
// §9.7 ⑱:v3 是模块级单例 harness ⇒ 用逐块重置的失败开关(默认全关,块内 try/finally 恢复)
|
|
70
|
+
const probe = { failList: false, failInterrupt: false, failWritePath: '', policyRoot: '' };
|
|
71
|
+
const ctx = {
|
|
72
|
+
get(name) {
|
|
73
|
+
if (name === 'sandboxPolicy' && probe.policyRoot) return { resolve(req) { return Object.assign({}, req && req.session ? { session: true } : {}, { workspaceRoot: probe.policyRoot }) } };
|
|
74
|
+
if (name === 'subprocess') {
|
|
75
|
+
return {
|
|
76
|
+
async resolveExecutable(cmd) {
|
|
77
|
+
if (!latexCfg) return undefined // 真机:没有任何 LaTeX
|
|
78
|
+
if (latexCfg.mode === 'resolver-empty') return undefined
|
|
79
|
+
return (latexCfg.engines || []).indexOf(cmd) !== -1 ? ('C:/fake/' + cmd) : undefined
|
|
80
|
+
},
|
|
81
|
+
// NOTE: sync on purpose — the plugin reads `handle.done` right after the call (Lean seam shape).
|
|
82
|
+
// An `async spawn` would hand back a Promise and `handle.done` would be undefined.
|
|
83
|
+
spawn({ argv, cwd }) {
|
|
84
|
+
const script = argv[argv.length - 1] || '';
|
|
85
|
+
if (/New-Item/.test(script)) { const m = script.match(/-Path\s+(?:'((?:[^']|'')*)'|"((?:[^"]|"")*)")/); const raw = (m && (m[1] || m[2])) || ''; for (const p of raw.split(',').map((x) => x.replace(/''/g, "'"))) if (p) mkdirSync(p, { recursive: true }); }
|
|
86
|
+
if (/paper\.tex/.test(argv.join(' '))) {
|
|
87
|
+
const nm = String(argv[0]).split(/[\\/]/).pop().replace(/\.(exe|cmd|bat)$/i, '');
|
|
88
|
+
latexSeen[nm] = (latexSeen[nm] || 0) + 1;
|
|
89
|
+
const failThis = !!latexCfg.alwaysFail || (!!latexCfg.failFirst && nm === (latexCfg.engines || [])[0]);
|
|
90
|
+
latexRuns.push({ name: nm, fail: failThis });
|
|
91
|
+
if (!failThis && cwd) { try { mkdirSync(cwd, { recursive: true }); writeFileSync(join(cwd, 'paper.pdf'), '%PDF-1.4 fake\n', 'utf8'); } catch (e) { /* ignore */ } }
|
|
92
|
+
return {
|
|
93
|
+
done: Promise.resolve({ exitCode: failThis ? 1 : 0, signal: null }),
|
|
94
|
+
collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: failThis ? '! LaTeX Error: fake failure' : '', nextOffset: 0, lossy: false }) } },
|
|
95
|
+
terminate() {},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { done: Promise.resolve({ exitCode: 0 }) };
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
},
|
|
104
|
+
on(e, fn) { (listeners[e] = listeners[e] || []).push(fn); },
|
|
105
|
+
effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d(); }; },
|
|
106
|
+
logger: { info() {}, warn() {}, error() {} },
|
|
107
|
+
tools: { register(spec) { toolRegs.push(spec); } },
|
|
108
|
+
commands: { register(spec) { cmdRegs.push(spec); return () => {}; } },
|
|
109
|
+
subagents: {
|
|
110
|
+
list() { if (probe.failList) throw new Error('mock list failure'); return ['spawn']; },
|
|
111
|
+
async startContinuable({ label, request }) {
|
|
112
|
+
if (refuseNext > 0) { refuseNext -= 1; throw new Error('ACTIVATION_LIMIT_REACHED: cannot start a new child: active child limit: 2'); }
|
|
113
|
+
const childId = 'c' + (spawns.length + 1);
|
|
114
|
+
lastPrompt = (request && request.prompt && request.prompt[0] && request.prompt[0].text) || '';
|
|
115
|
+
spawns.push({ label, childId, request }); return { childId };
|
|
116
|
+
},
|
|
117
|
+
interrupt() { if (probe.failInterrupt) throw new Error('mock interrupt failure'); },
|
|
118
|
+
},
|
|
119
|
+
agents: { roots() { return []; }, get() { return undefined; } },
|
|
120
|
+
fs: {
|
|
121
|
+
async resolve(rel, opts) { return join((opts && opts.cwd) || WS, ...String(rel).split('/')); },
|
|
122
|
+
async stat(t) { return existsSync(t) ? { type: 'file' } : undefined; },
|
|
123
|
+
async readText(t) { return readFileSync(t, 'utf8'); },
|
|
124
|
+
async writeText(t, content) { if (probe.failWritePath && String(t).includes(probe.failWritePath)) throw new Error('mock write failure: ' + probe.failWritePath); mkdirSync(dirname(t), { recursive: true }); writeFileSync(t, content, 'utf8'); },
|
|
125
|
+
async listDir(t) { if (!existsSync(t)) return []; return readdirSync(t, { withFileTypes: true }).map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })); },
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
const ROOT = { id: 'S', options: {}, session: { id: 'S', header: { cwd: WS } } };
|
|
129
|
+
(mod.default || mod).apply(ctx);
|
|
130
|
+
const call = async (n, a) => JSON.parse(await (toolRegs.find((s) => s.name === n)).execute(a || {}, { agent: ROOT }));
|
|
131
|
+
const fireEnd = (info) => { for (const fn of (listeners['subagent/end'] || [])) fn(info); };
|
|
132
|
+
const read = (p) => { try { return existsSync(p) ? readFileSync(p, 'utf8') : ''; } catch { return ''; } };
|
|
133
|
+
const cmd = async (raw) => { const spec = cmdRegs.find((s) => s.name === 'vibe'); return await spec.handler({ agent: ROOT, rawInput: raw }); };
|
|
134
|
+
const projDir = (proj) => join(WS, 'VibeMath', 'Projects', proj);
|
|
135
|
+
const pdir = (proj, id) => join(projDir(proj), 'Paper', id === undefined ? proj : id);
|
|
136
|
+
const readMeta = (proj, id) => JSON.parse(read(join(pdir(proj, id), 'paper.meta.json')) || 'null');
|
|
137
|
+
|
|
138
|
+
const PROJECT = join(WS, 'VibeMath', 'Projects', 'fx');
|
|
139
|
+
const waitFor = async (pred, ms = 6000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (pred()) return true; await sleep(80); } return false; };
|
|
140
|
+
|
|
141
|
+
console.log('\n-- H4: promotion anchors are written to disk --');
|
|
142
|
+
{
|
|
143
|
+
await call('vibe_math_new_project', { name: 'fx' });
|
|
144
|
+
await call('vibe_math_add_problem', { id: 'qMain', description: '证明 π² 是无理数', priority: 0 });
|
|
145
|
+
// `来源问题` is exactly what a solver-reported lemma carries (syncMeta sets it), and it is
|
|
146
|
+
// what the Verified card's `- 来源:` is built from — so drive that field through the tool too.
|
|
147
|
+
await call('vibe_math_add_proposition', { id: 'pX', 概述: '欧拉常数 γ 为有理数', 概率: 0.6, 分类: '分析', '价值/关键性': 0.9, 来源问题: 'qMain', 来源方向: 'd1' });
|
|
148
|
+
// processPromote runs on the first tick of a started scheduler
|
|
149
|
+
await call('vibe_math_set_params', { plannerEnabled: false, tickIntervalMs: 200 });
|
|
150
|
+
await call('vibe_math_start', {});
|
|
151
|
+
const promotedPath = join(PROJECT, 'Problems', 'q-promoted-pX.md');
|
|
152
|
+
const ok = await waitFor(() => existsSync(promotedPath));
|
|
153
|
+
assert(ok, 'high-value proposition promoted to a judge problem (Problems/q-promoted-pX.md)');
|
|
154
|
+
const pMd = read(promotedPath);
|
|
155
|
+
assert(/- 判断命题: pX/.test(pMd), 'promoted problem card carries `- 判断命题: pX`');
|
|
156
|
+
assert(/- 来源命题: pX/.test(pMd), 'promoted problem card carries `- 来源命题: pX`');
|
|
157
|
+
const pXmd = read(join(PROJECT, 'Propos', '分析', 'pX.md'));
|
|
158
|
+
// The direction is KNOWN here (it was passed to add_proposition), so the anchor must be on
|
|
159
|
+
// disk: `assert(/- 来源方向: d1/.test(...) || true)` was always true and proved nothing.
|
|
160
|
+
assert(pXmd.length > 0, 'the proposition card pX.md exists on disk');
|
|
161
|
+
assert(/- 来源方向: d1/.test(pXmd), 'proposition card carries the `- 来源方向: d1` anchor it was created with (H4)');
|
|
162
|
+
assert(!/- 来源方向:\s*$/m.test(pXmd), 'and it is not written as an empty shell');
|
|
163
|
+
// reload from disk — the whole point of H4
|
|
164
|
+
await call('vibe_math_pause', {});
|
|
165
|
+
await call('vibe_math_index', {});
|
|
166
|
+
await sleep(300);
|
|
167
|
+
const idx = JSON.parse(read(join(PROJECT, 'State', 'index.json')) || '{}');
|
|
168
|
+
const reloaded = (idx.problems || {})['q-promoted-pX'] || {};
|
|
169
|
+
assert(reloaded.判断命题 === 'pX', 'after a reload the promoted problem still knows its 判断命题 (got ' + JSON.stringify(reloaded.判断命题) + ')');
|
|
170
|
+
assert(reloaded.来源命题 === 'pX', 'after a reload the promoted problem still knows its 来源命题');
|
|
171
|
+
const pXreloaded = (idx.propos || {})['pX'] || {};
|
|
172
|
+
assert(pXreloaded.来源问题 === 'qMain', 'after a reload the proposition still knows its 来源问题 (got ' + JSON.stringify(pXreloaded.来源问题) + ')');
|
|
173
|
+
// The 来源 anchor of a Verified card comes from 来源问题 — prove the whole chain survives.
|
|
174
|
+
const sch = JSON.parse(read(join(PROJECT, 'State', 'scheduler_state.json')) || '{}');
|
|
175
|
+
assert(sch.running === false || sch.running === undefined, 'scheduler paused for the reload check');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log('\n-- H5: a single verifier vote must never conclude --');
|
|
179
|
+
{
|
|
180
|
+
await call('vibe_math_new_project', { name: 'onevote' });
|
|
181
|
+
const proj = join(WS, 'VibeMath', 'Projects', 'onevote');
|
|
182
|
+
await call('vibe_math_add_proposition', { id: 'p1', 概述: 'claims', 概率: 0.5, 分类: '分析' });
|
|
183
|
+
await call('vibe_math_set_params', { maxParallelThreshold: 1, verifierCount: 3, debateMaxRounds: 5, plannerEnabled: false, tickIntervalMs: 200 });
|
|
184
|
+
await call('vibe_math_start', {});
|
|
185
|
+
const before = spawns.length;
|
|
186
|
+
await waitFor(() => spawns.slice(before).some((s) => /^verifier:/.test(s.label)));
|
|
187
|
+
const vers = spawns.slice(before).filter((s) => /^verifier:/.test(s.label));
|
|
188
|
+
assert(vers.length >= 1, 'at least one verifier spawned (capacity 1)');
|
|
189
|
+
assert(vers.length < 3, 'the capacity-1 run really cannot spawn all 3 reviewers in one pass (spawned ' + vers.length + ')');
|
|
190
|
+
fireEnd({ id: vers[0].childId, runId: 'r1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n{"Result":1,"Reason":"single reviewer is sure"}\n```' }] });
|
|
191
|
+
await sleep(1200);
|
|
192
|
+
await call('vibe_math_pause', {});
|
|
193
|
+
const pMd = read(join(proj, 'Propos', '分析', 'p1.md'));
|
|
194
|
+
const vcard = read(join(proj, 'Verified', '命题', 'p1.md'));
|
|
195
|
+
// `read()` is deliberately tolerant, so every NEGATIVE assertion below would also hold for a
|
|
196
|
+
// file that does not exist at all. Prove the subject exists first, otherwise "one vote did
|
|
197
|
+
// not conclude" is indistinguishable from "the card was never written".
|
|
198
|
+
assert(pMd.length > 0, 'the proposition card p1.md exists on disk (the negatives below are not vacuous)');
|
|
199
|
+
assert(!/- 状态: 已验证·真/.test(pMd), 'a single 1-vote did NOT flip the proposition to 已验证·真');
|
|
200
|
+
assert(!/- 概率: 1$|- 概率: 1\r?$/m.test(pMd), 'a single 1-vote did NOT set 概率=1');
|
|
201
|
+
assert(!existsSync(join(proj, 'Verified', '命题', 'p1.md')) && vcard === '', 'no Verified/命题/p1.md card was written from one vote');
|
|
202
|
+
// and the task must still be alive, waiting for the rest of the quorum
|
|
203
|
+
const tasks = JSON.parse(read(join(proj, 'State', 'tasks.json')) || '{}');
|
|
204
|
+
const t = tasks['verify:r-p1'];
|
|
205
|
+
if (t) {
|
|
206
|
+
assert(t.round === 1, 'the verification task is still in round 1 (not finalised)');
|
|
207
|
+
assert(Object.keys(t.childResults || {}).length === 1, 'exactly one vote is recorded');
|
|
208
|
+
assert(t.status === 'spawning' || t.status === 'paused', 'task stays open for the missing votes (status=' + t.status + ')');
|
|
209
|
+
} else {
|
|
210
|
+
assert(false, 'the verification task was destroyed after one vote');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log('\n-- L3/L6: exponential probabilities and |/newline in an entry title survive the md round-trip --');
|
|
215
|
+
{
|
|
216
|
+
// L3: `概率5e-7` used to miss `([0-9.]+)`, so the WHOLE entry line failed to match and the entry
|
|
217
|
+
// silently disappeared on reload. L6: a title containing the | delimiter (or a newline) used to cut
|
|
218
|
+
// the lazy `(.*?)`早 inside the title. Both are pure compose→parse shapes.
|
|
219
|
+
const body = ['## 陈述', 'x', '', '## 解法候选',
|
|
220
|
+
'### 解法 1|' + H.escField('指数概率') + '|概率' + H.probText(5e-7) + '|状态未定论', '正文一', '',
|
|
221
|
+
'### 解法 2|' + H.escField('标题里有|概率0.5|状态乱入') + '|概率' + H.probText(0.25) + '|状态已验', '正文二', '',
|
|
222
|
+
'### 解法 3|' + H.escField('标题带\n换行') + '|概率' + H.probText(0.5) + '|状态未定论', '正文三', ''].join('\n');
|
|
223
|
+
const parsed = H.parseEntries(body, H.entryRe('解法')).map((e) => ({ title: H.unescField(e.title), prob: H.clamp01(e.prob), status: H.unescField(e.status), text: e.text }));
|
|
224
|
+
assert(parsed.length === 3, 'all three entries parse back (got ' + parsed.length + ')');
|
|
225
|
+
assert(parsed[0] && parsed[0].prob === 5e-7, 'an exponential-notation probability parses instead of dropping the entry (got ' + (parsed[0] && parsed[0].prob) + ')');
|
|
226
|
+
assert(parsed[1] && parsed[1].title === '标题里有|概率0.5|状态乱入', 'a title containing |概率/|状态 round-trips (got ' + JSON.stringify(parsed[1] && parsed[1].title) + ')');
|
|
227
|
+
assert(parsed[1] && parsed[1].status === '已验', 'the status field after the title is still read correctly');
|
|
228
|
+
assert(parsed[2] && parsed[2].title === '标题带\n换行', 'a title containing a newline round-trips (got ' + JSON.stringify(parsed[2] && parsed[2].title) + ')');
|
|
229
|
+
assert(H.escField('a|b') === 'a\\|b' && H.unescField('a\\|b') === 'a|b', 'escField/unescField really escape and restore the delimiter');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
console.log('\n-- L4: a fenced `## ` line inside a section body is NOT a section boundary --');
|
|
233
|
+
{
|
|
234
|
+
const card = ['## 陈述', 'line one', '```', '## not a real section', 'line two', '```', '## 来源与动机', '动机', ''].join('\n');
|
|
235
|
+
const stmt = H.section(card, '陈述');
|
|
236
|
+
assert(/## not a real section/.test(stmt) && /line two/.test(stmt), 'the fenced block stays inside 陈述 (section() no longer cuts at it)');
|
|
237
|
+
assert(!/动机/.test(stmt), 'and the next REAL section is still the boundary');
|
|
238
|
+
const heads = H.findSectionHeads(card).map((h) => h.name);
|
|
239
|
+
assert(JSON.stringify(heads) === JSON.stringify(['陈述', '来源与动机']), 'findSectionHeads only reports real (unfenced) headings (got ' + JSON.stringify(heads) + ')');
|
|
240
|
+
const parts = H.parseBodySections(card);
|
|
241
|
+
assert(parts.length === 2, 'parseBodySections agrees (2 sections, not 3 → no duplicate extra-section rewrite)');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
console.log('\n-- L9: a plan queue left by a PREVIOUS process is discarded on resume --');
|
|
245
|
+
{
|
|
246
|
+
await call('vibe_math_new_project', { name: 'planstale' });
|
|
247
|
+
const proj = join(WS, 'VibeMath', 'Projects', 'planstale');
|
|
248
|
+
await call('vibe_math_pause', {});
|
|
249
|
+
mkdirSync(join(proj, 'State'), { recursive: true });
|
|
250
|
+
writeFileSync(join(proj, 'State', 'plans.json'), JSON.stringify({ queued: [{ action: 'dispatch', role: 'solver', target: 'gone-object' }], epoch: 'previous-process' }), 'utf8');
|
|
251
|
+
await call('vibe_math_resume', {});
|
|
252
|
+
await sleep(300);
|
|
253
|
+
await call('vibe_math_pause', {});
|
|
254
|
+
const st = await call('vibe_math_status', {});
|
|
255
|
+
assert(st.queuedPlanActions === 0, 'the stale queue is not replayed (queuedPlanActions=' + st.queuedPlanActions + ')');
|
|
256
|
+
const acts = (st.recentActivity || []).map((a) => a.detail).join('\n');
|
|
257
|
+
assert(/丢弃上一进程留下的 1 条计划动作/.test(acts), 'and the discard is announced (no silent loss)');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log('\n-- L2: a defect on an object that NEVER had an archived proof must not create one --');
|
|
261
|
+
{
|
|
262
|
+
await call('vibe_math_new_project', { name: 'defectnopr' });
|
|
263
|
+
const proj = join(WS, 'VibeMath', 'Projects', 'defectnopr');
|
|
264
|
+
await call('vibe_math_set_params', { maxParallelThreshold: 8, verifierCount: 2, plannerEnabled: false, tickIntervalMs: 200, formalVerify: 'encourage' });
|
|
265
|
+
await call('vibe_math_add_proposition', { id: 'pNever', 概述: '从未有过归档证明', 概率: 0.5, 分类: '分析' });
|
|
266
|
+
await call('vibe_math_start', {});
|
|
267
|
+
const before = spawns.length;
|
|
268
|
+
await waitFor(() => spawns.slice(before).filter((s) => /^verifier:r-pNever:/.test(s.label)).length >= 2);
|
|
269
|
+
const vers = spawns.slice(before).filter((s) => /^verifier:r-pNever:/.test(s.label));
|
|
270
|
+
assert(vers.length >= 2, 'two reviewers spawned for the proposition');
|
|
271
|
+
fireEnd({ id: vers[0].childId, runId: 'd1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n' + JSON.stringify({ Result: 0.3, Reason: '忠实性有问题', formal: { target: 'pNever', decision: 'defect', note: '定义与命题原文不一致' } }) + '\n```' }] });
|
|
272
|
+
const rec = await waitFor(() => { const r = JSON.parse(read(join(proj, 'State', 'formal.json')) || '{}').records || {}; return r.pNever && r.pNever.decision === 'defect' });
|
|
273
|
+
assert(rec, 'the defect is recorded for the object');
|
|
274
|
+
await sleep(200);
|
|
275
|
+
await call('vibe_math_pause', {});
|
|
276
|
+
assert(!existsSync(join(proj, 'Verified', 'Lean', 'pNever.lean')), '★ no Verified/Lean/pNever.lean was fabricated for an object that never had a proof');
|
|
277
|
+
const ann = read(join(proj, 'Logs', '形式化.md'));
|
|
278
|
+
assert(/此前没有任何归档证明,无需撤回/.test(ann), 'the announcement says there was nothing to withdraw (got ' + JSON.stringify(ann.slice(-160)) + ')');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ================================================================ final paper (spec §6, v3)
|
|
282
|
+
const PAPER_REPLY = {
|
|
283
|
+
title: '假论文|收敛后的整理',
|
|
284
|
+
abstract: '原问题是「假问题」;主要结论是假定理成立。',
|
|
285
|
+
sections: [
|
|
286
|
+
{ name: '引言与问题背景', body: '背景:变量 a_b 与占比 50%,以及行内数学 $x^2+y^2$。' },
|
|
287
|
+
{ name: '原问题的完整解法', body: '解法步骤:\n- 第一步\n- 第二步' },
|
|
288
|
+
{ name: '已检验通过的命题', body: '命题 pClosed 的布尔估计为 1(经 ≥2 名验证者定论)。' },
|
|
289
|
+
{ name: '已解决的子问题与中间成果', body: '子问题 qSub 已解决。' },
|
|
290
|
+
{ name: '创造或发现的有价值之物', body: '方法 m1(可复用)。' },
|
|
291
|
+
{ name: '规律总结', body: '规律:先化简再归纳。' },
|
|
292
|
+
{ name: '讨论、局限与展望', body: '局限:pOpen 仍为 0.5(未定论),不得当成已成立。' },
|
|
293
|
+
],
|
|
294
|
+
}
|
|
295
|
+
const paperSpawns = (id) => spawns.filter((s) => s.label.startsWith('paper-writer:' + (id === undefined ? '' : id)))
|
|
296
|
+
const firePaper = (id, reply) => {
|
|
297
|
+
const w = paperSpawns(id); const last = w[w.length - 1]
|
|
298
|
+
if (!last) return false
|
|
299
|
+
fireEnd({ id: last.childId, runId: 'pw-' + last.childId, provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n' + JSON.stringify(reply || PAPER_REPLY) + '\n```' }] })
|
|
300
|
+
return true
|
|
301
|
+
}
|
|
302
|
+
const asyncFind = async (pred, tries = 60, ms = 150) => { for (let i = 0; i < tries; i++) { const v = await pred(); if (v) return v; await sleep(ms) } return undefined }
|
|
303
|
+
|
|
304
|
+
console.log('\n-- PAPER §6.1 (v3): params — defaults, schema, coercion, both registration tables --')
|
|
305
|
+
{
|
|
306
|
+
await call('vibe_math_new_project', { name: 'pparams' })
|
|
307
|
+
const setup = await call('vibe_math_setup', {})
|
|
308
|
+
const by = {}; for (const p of setup.parameters) by[p.name] = p
|
|
309
|
+
for (const k of ['finalPaper', 'paperFormat', 'paperLanguage', 'paperCompilePdf', 'paperLatexCommand']) assert(!!by[k], 'v3 setup schema exposes ' + k)
|
|
310
|
+
assert(by.finalPaper.default === true && by.paperFormat.default === 'both' && by.paperLanguage.default === 'zh' && by.paperCompilePdf.default === true && by.paperLatexCommand.default === '', 'v3 paper defaults are true/both/zh/true/""')
|
|
311
|
+
assert(JSON.stringify(by.paperFormat.options) === JSON.stringify(['both', 'md', 'tex']) && JSON.stringify(by.paperLanguage.options) === JSON.stringify(['zh', 'en']), 'v3 enums are documented in the schema/help text')
|
|
312
|
+
const bad = await call('vibe_math_set_params', { paperFormat: 'weird', paperLanguage: 'xx', finalPaper: 'false', paperCompilePdf: 'no', paperLatexCommand: ' ' })
|
|
313
|
+
assert(bad.params.paperFormat === 'both' && bad.params.paperLanguage === 'zh', 'v3 illegal enum values fall back to the defaults')
|
|
314
|
+
assert(bad.params.finalPaper === true && bad.params.paperCompilePdf === true, '★★ v3: a string "false"/"no" is NOT accepted as a boolean (spec v2 §B)')
|
|
315
|
+
const good = await call('vibe_math_set_params', { paperFormat: 'md', paperLanguage: 'en', finalPaper: false, paperCompilePdf: false, paperLatexCommand: 'xelatex' })
|
|
316
|
+
assert(good.params.paperFormat === 'md' && good.params.paperLanguage === 'en' && good.params.finalPaper === false && good.params.paperCompilePdf === false && good.params.paperLatexCommand === 'xelatex', 'v3 legal values are accepted verbatim')
|
|
317
|
+
const regs = toolRegs.filter((s) => s.name === 'vibe_math_set_params')
|
|
318
|
+
assert(regs.length === 1 && ['finalPaper', 'paperFormat', 'paperLanguage', 'paperCompilePdf', 'paperLatexCommand'].every((k) => !!regs[0].parameters.properties[k]), 'the registered v3 tool schema carries all five paper keys')
|
|
319
|
+
const src = readFileSync(PLUGIN, 'utf8')
|
|
320
|
+
// ---- F-B(单一真源):机读 parameters 由 PARAM_SCHEMA 派生,两处注册点、真源与文档三方一致 ----
|
|
321
|
+
{
|
|
322
|
+
const srcFb = readFileSync(PLUGIN, 'utf8')
|
|
323
|
+
const sitesFb = srcFb.match(/objParams\(paramProps\(\)\)/g) || []
|
|
324
|
+
assert(sitesFb.length === 2, '★★★ [F-B/v3] 两处注册点都由 paramProps() 从 PARAM_SCHEMA 派生(实测 ' + sitesFb.length + ' 处;写回硬编码字面量会在此变红)')
|
|
325
|
+
const keysFb = (() => { const m = /const PARAM_PROPS_KEYS = \[([^\]]*)\]/.exec(srcFb); return m ? m[1].split(/,/).map((x) => x.trim().replace(/^.|.$/g, "")).filter(Boolean) : [] })()
|
|
326
|
+
const propsFb = regs[0].parameters.properties
|
|
327
|
+
assert(keysFb.length > 0 && keysFb.length === Object.keys(propsFb).length, '★★★ [F-B/v3] 机读键集合 == PARAM_PROPS_KEYS(声明 ' + keysFb.length + ' vs schema ' + Object.keys(propsFb).length + ')')
|
|
328
|
+
const byNameFb = {}; for (const p of setup.parameters) byNameFb[p.name] = p
|
|
329
|
+
const missingFb = keysFb.filter((k) => !propsFb[k] || !propsFb[k].description)
|
|
330
|
+
assert(missingFb.length === 0, '★★★ [F-B/v3] 每个机读描述都来自真源且非空(缺失 ' + JSON.stringify(missingFb) + ')')
|
|
331
|
+
const mismatchFb = keysFb.filter((k) => byNameFb[k] && propsFb[k].description !== byNameFb[k].description)
|
|
332
|
+
assert(mismatchFb.length === 0, '★★★ [F-B/v3] 机读 description == PARAM_SCHEMA 真源逐字(不一致 ' + JSON.stringify(mismatchFb) + ')')
|
|
333
|
+
const extraFb = (() => { const m = /const PARAM_PROPS_EXTRA = \{([\s\S]*?)\n\}/.exec(srcFb); return m ? [...m[1].matchAll(/^\s*([A-Za-z_][A-Za-z0-9_]*):/gm)].map((x) => x[1]) : [] })()
|
|
334
|
+
const docFb = readFileSync(new URL('../docs/parameter-schema.md', import.meta.url), 'utf8')
|
|
335
|
+
const docKeysFb = new Set([...docFb.matchAll(/`([A-Za-z_][A-Za-z0-9_]*)`/g)].map((m) => m[1]))
|
|
336
|
+
const undocumentedFb = extraFb.filter((k) => !docKeysFb.has(k))
|
|
337
|
+
const nFb = docFb.indexOf('## v3(') >= 0 ? Number((docFb.slice(docFb.indexOf('## v3(')).match(/(\d+) 条例外/) || [])[1] || 0) : 0
|
|
338
|
+
assert(extraFb.length > 0 && undocumentedFb.length === 0 && extraFb.length === nFb, '★★★ [F-B/v3] 例外集合 == docs/parameter-schema.md(实测 ' + extraFb.length + ' vs 文档 ' + nFb + ',未记录 ' + JSON.stringify(undocumentedFb) + ')')
|
|
339
|
+
}
|
|
340
|
+
assert(/paper \[lang=zh\|en\] \[format=both\|md\|tex\] \[force\]/.test(src), 'the /vibe hint and usage advertise `paper`')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
console.log('\n-- PAPER §6.2/§6.3 (v3): closure trigger (leftoverVerify), idempotence, content --')
|
|
344
|
+
{
|
|
345
|
+
latexCfg = { engines: ['xelatex'] }
|
|
346
|
+
await call('vibe_math_new_project', { name: 'ppaper' })
|
|
347
|
+
await call('vibe_math_set_params', { tickIntervalMs: 200, plannerEnabled: false, finalPaper: true, paperFormat: 'both', paperCompilePdf: true })
|
|
348
|
+
await call('vibe_math_add_problem', { id: 'qOpen', description: '未解决问题', priority: 0 })
|
|
349
|
+
await call('vibe_math_add_proposition', { id: 'pOpen', 概述: '仍未定论', 概率: 0.5, 分类: '分析', '价值/关键性': 0.3 })
|
|
350
|
+
await call('vibe_math_start', {})
|
|
351
|
+
await sleep(900)
|
|
352
|
+
assert(paperSpawns('ppaper').length === 0, '★ v3 NOT converged (unsolved problem + verify candidate) → no paper writer')
|
|
353
|
+
await call('vibe_math_pause', {})
|
|
354
|
+
// 停靠到"真实可收口"的状态:问题 已解决/never;命题 优先级=never(未定论但不再是验证候选)。
|
|
355
|
+
// 这正是 v3 的真实完整性判据 leftoverVerify 为空(§A4)。
|
|
356
|
+
const qf = join(projDir('ppaper'), 'Problems', 'qOpen.md')
|
|
357
|
+
writeFileSync(qf, read(qf).replace('- 状态: 求解中', '- 状态: 已解决').replace(/- 优先级: \d+/, '- 优先级: never'), 'utf8')
|
|
358
|
+
const pf = join(projDir('ppaper'), 'Propos', '分析', 'pOpen.md')
|
|
359
|
+
writeFileSync(pf, read(pf).replace(/- 优先级: \d+/, '- 优先级: never'), 'utf8')
|
|
360
|
+
await call('vibe_math_abort', {})
|
|
361
|
+
await call('vibe_math_index', {})
|
|
362
|
+
await call('vibe_math_start', {})
|
|
363
|
+
const w = await asyncFind(() => (paperSpawns('ppaper').length ? paperSpawns('ppaper') : undefined), 60)
|
|
364
|
+
assert(!!w && w.length === 1, '★ v3 closure (leftoverVerify empty + no agents/tasks/plans) dispatched exactly one paper writer')
|
|
365
|
+
const prompt = (w && w[0].request && w[0].request.prompt[0].text) || ''
|
|
366
|
+
assert(prompt.indexOf('DEDICATED PAPER WRITER') !== -1, 'the writer prompt is the single-author paper contract')
|
|
367
|
+
for (const s of H.PAPER_SKELETON) assert(prompt.indexOf(s.key) !== -1, 'v3 prompt carries skeleton section: ' + s.key)
|
|
368
|
+
assert(/\[STILL UNVERIFIED/.test(prompt) && /\[UNRESOLVED \/ REFUTED/.test(prompt) && /pOpen/.test(prompt), '★★ the prompt marks the undecided item explicitly (spec v2 §A4)')
|
|
369
|
+
assert(/NEVER invent content/.test(prompt), 'the prompt forbids inventing content')
|
|
370
|
+
const st = await call('vibe_math_status', {})
|
|
371
|
+
assert(!!st.paper && st.paper.inFlight === (w && w[0].childId), 'status reports the in-flight paper writer')
|
|
372
|
+
firePaper('ppaper')
|
|
373
|
+
const meta = await asyncFind(() => readMeta('ppaper', 'ppaper'), 60)
|
|
374
|
+
assert(!!meta && meta.trigger === 'auto' && Number(meta.runStartedAt) > 0, 'v3 meta records the auto trigger + run id')
|
|
375
|
+
const md = read(join(pdir('ppaper', 'ppaper'), 'paper.md'))
|
|
376
|
+
const tex = read(join(pdir('ppaper', 'ppaper'), 'paper.tex'))
|
|
377
|
+
assert((md.match(/^## /gm) || []).length === 9, '★ v3 md carries exactly the 9-section skeleton')
|
|
378
|
+
for (const s of H.PAPER_SKELETON) assert(md.indexOf('## ' + s.key) !== -1, 'v3 md section present: ' + s.key)
|
|
379
|
+
assert(md.indexOf('### 证据与文件索引') !== -1 && md.indexOf('`Problems/qOpen.md`') !== -1 && md.indexOf('`Propos/分析/pOpen.md`') !== -1, '★ v3 appendix carries the evidence index built from the real md cards')
|
|
380
|
+
assert(md.indexOf('未定论') !== -1, 'the unresolved item is labelled in the paper')
|
|
381
|
+
assert((tex.match(/\\section\{/g) || []).length === 9, '★ v3 tex carries the same 9 sections')
|
|
382
|
+
assert(tex.indexOf('\\documentclass[11pt]{ctexart}') !== -1, 'v3 zh uses ctexart')
|
|
383
|
+
assert(tex.indexOf('a\\_b') !== -1 && tex.indexOf('50\\%') !== -1 && tex.indexOf('a_b') === -1 && tex.indexOf('50%') === -1, '★★ v3 tex escaping covers _ and %')
|
|
384
|
+
assert(tex.indexOf('$x^2+y^2$') !== -1, 'v3 inline math is passed through')
|
|
385
|
+
assert(meta.artifacts.md === true && meta.artifacts.tex === true && meta.artifacts.pdf === true && meta.compile === 'ok', '★ v3 fake compiler produced the pdf (compile=' + meta.compile + ')')
|
|
386
|
+
const log = read(join(pdir('ppaper', 'ppaper'), 'paper.log.md'))
|
|
387
|
+
assert(/\[dispatch\]/.test(log) && /\[finalize\]/.test(log) && /\[compile\]/.test(log), 'v3 paper.log.md carries the fixed single-author log format')
|
|
388
|
+
// 幂等(同一 run)+ 缺失产物补写 + force
|
|
389
|
+
const again = JSON.parse((await cmd('paper')).text)
|
|
390
|
+
assert(again.skipped === true && again.reason === 'already-finalized-this-run', '★ v3 repeat trigger in the same run is idempotent')
|
|
391
|
+
assert(paperSpawns('ppaper').length === 1, '★★ v3 idempotent path did NOT dispatch a second writer')
|
|
392
|
+
rmSync(join(pdir('ppaper', 'ppaper'), 'paper.tex'), { force: true })
|
|
393
|
+
const filled = JSON.parse((await cmd('paper')).text)
|
|
394
|
+
assert(filled.skipped === true && filled.reason === 'filled-missing-artifacts' && filled.filled.indexOf('paper.tex') !== -1, '★ v3 re-derives a missing artifact from the finalized content (no new writer)')
|
|
395
|
+
assert(existsSync(join(pdir('ppaper', 'ppaper'), 'paper.tex')) && paperSpawns('ppaper').length === 1, 'v3 tex is back and still no second writer')
|
|
396
|
+
const beforeForce = meta.finalizedAt
|
|
397
|
+
const forced = JSON.parse((await cmd('paper force')).text)
|
|
398
|
+
assert(forced.dispatched === true && paperSpawns('ppaper').length === 2, '★ v3 /vibe paper force re-dispatches')
|
|
399
|
+
firePaper('ppaper')
|
|
400
|
+
await asyncFind(() => { const m = readMeta('ppaper', 'ppaper'); return m && m.finalizedAt !== beforeForce ? m : undefined }, 40)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
console.log('\n-- PAPER §6.2b (v3): finalPaper=false → the AUTO trigger does not fire --')
|
|
404
|
+
{
|
|
405
|
+
latexCfg = null
|
|
406
|
+
await call('vibe_math_new_project', { name: 'pautooff' })
|
|
407
|
+
await call('vibe_math_set_params', { tickIntervalMs: 200, plannerEnabled: false, finalPaper: false })
|
|
408
|
+
await call('vibe_math_start', {}) // 空项目 ⇒ 立刻收口
|
|
409
|
+
await sleep(1400)
|
|
410
|
+
assert(paperSpawns('pautooff').length === 0, '★★ v3 finalPaper=false → closure does NOT dispatch a paper writer')
|
|
411
|
+
const st = await call('vibe_math_status', {})
|
|
412
|
+
assert(!!st.paper && st.paper.autoFinalPaper === false, 'v3 status reports the automatic paper trigger as disabled')
|
|
413
|
+
const man = JSON.parse((await cmd('paper')).text)
|
|
414
|
+
assert(man.dispatched === true && man.autoDisabled === true, 'v3 manual command still works and says the automatic trigger is off')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
console.log('\n-- PAPER §6.4 (v3): compile branches (ok / repaired / failed / no LaTeX) --')
|
|
418
|
+
{
|
|
419
|
+
// (a) 成功
|
|
420
|
+
latexCfg = { engines: ['xelatex'] }; latexRuns.length = 0
|
|
421
|
+
await call('vibe_math_new_project', { name: 'pc1' })
|
|
422
|
+
await cmd('paper'); firePaper('pc1')
|
|
423
|
+
let m = await asyncFind(() => readMeta('pc1', 'pc1'), 60)
|
|
424
|
+
assert(!!m && m.compile === 'ok' && m.artifacts.pdf === true, '★ v3 (a) success → pdf generated, compile=ok')
|
|
425
|
+
// (b) 换引擎修复
|
|
426
|
+
latexCfg = { engines: ['xelatex', 'latexmk'], failFirst: true }; latexRuns.length = 0
|
|
427
|
+
await call('vibe_math_new_project', { name: 'pc2' })
|
|
428
|
+
await cmd('paper'); firePaper('pc2')
|
|
429
|
+
m = await asyncFind(() => readMeta('pc2', 'pc2'), 60)
|
|
430
|
+
assert(!!m && m.compile === 'repaired' && m.compileEngine === 'latexmk' && m.compileAttempts[0].ok === false && m.compileAttempts[1].ok === true, '★ v3 (b) engine swap repaired the compile (' + (m && m.compile) + '/' + (m && m.compileEngine) + ')')
|
|
431
|
+
// (c) 持续失败 → 降级
|
|
432
|
+
latexCfg = { engines: ['xelatex', 'latexmk'], alwaysFail: true }; latexRuns.length = 0
|
|
433
|
+
await call('vibe_math_new_project', { name: 'pc3' })
|
|
434
|
+
await cmd('paper'); firePaper('pc3')
|
|
435
|
+
m = await asyncFind(() => readMeta('pc3', 'pc3'), 60)
|
|
436
|
+
assert(!!m && m.compile === 'failed' && m.artifacts.pdf === false && m.artifacts.md === true && m.artifacts.tex === true, '★ v3 (c) persistent failure → degrade to tex+md, compile=failed')
|
|
437
|
+
assert(!existsSync(join(pdir('pc3', 'pc3'), 'paper.pdf')), 'v3 (c) no pdf was faked')
|
|
438
|
+
const acts = (await call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')
|
|
439
|
+
assert(/编译失败/.test(acts), '★ v3 (c) the failure is reported on the activity log')
|
|
440
|
+
// (d) 真机无 LaTeX(本机形态)→ 干净降级
|
|
441
|
+
latexCfg = null
|
|
442
|
+
await call('vibe_math_new_project', { name: 'pc4' })
|
|
443
|
+
await cmd('paper'); firePaper('pc4')
|
|
444
|
+
m = await asyncFind(() => readMeta('pc4', 'pc4'), 60)
|
|
445
|
+
assert(!!m && m.compile === 'not-detected' && m.artifacts.tex === true && m.artifacts.md === true && m.artifacts.pdf === false, '★ v3 (d) no LaTeX → compile=not-detected with tex+md only')
|
|
446
|
+
assert(read(join(pdir('pc4', 'pc4'), 'paper.log.md')).indexOf('未检测到任何 LaTeX 引擎') !== -1, 'v3 (d) the log records why no pdf was produced')
|
|
447
|
+
// (e) resolver 在但一个引擎都解析不到
|
|
448
|
+
latexCfg = { mode: 'resolver-empty' }
|
|
449
|
+
await call('vibe_math_new_project', { name: 'pc5' })
|
|
450
|
+
await cmd('paper'); firePaper('pc5')
|
|
451
|
+
m = await asyncFind(() => readMeta('pc5', 'pc5'), 60)
|
|
452
|
+
assert(!!m && m.compile === 'not-detected', '★ v3 (e) an empty resolver also degrades to not-detected')
|
|
453
|
+
latexCfg = null
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
console.log('\n-- PAPER §6.5/§6.6 (v3): paths/id confinement, command surface, activation limit --')
|
|
457
|
+
{
|
|
458
|
+
for (const raw of ['../../etc/passwd', 'a/b', '..', 'C:\\x\\y']) {
|
|
459
|
+
const id = H.paperDirId(raw)
|
|
460
|
+
assert(id.indexOf('/') === -1 && id.indexOf('\\') === -1 && id.indexOf('..') === -1, 'v3 paperDirId(' + JSON.stringify(raw) + ') is one safe segment (' + JSON.stringify(id) + ')')
|
|
461
|
+
}
|
|
462
|
+
latexCfg = { engines: ['xelatex'] }
|
|
463
|
+
await call('vibe_math_new_project', { name: 'pcmd' })
|
|
464
|
+
await call('vibe_math_set_params', { finalPaper: false })
|
|
465
|
+
const r = JSON.parse((await cmd('paper')).text)
|
|
466
|
+
assert(r.ok === true && r.dispatched === true && r.autoDisabled === true && /自动触发已关闭/.test(r.message), '★ v3 /vibe paper works with finalPaper=false and says the auto trigger is off')
|
|
467
|
+
firePaper('pcmd')
|
|
468
|
+
const meta = await asyncFind(() => readMeta('pcmd', 'pcmd'), 60)
|
|
469
|
+
assert(!!meta && meta.dir === 'Paper/pcmd' && meta.trigger === 'manual', 'v3 manual run recorded under Paper/<id>/ with trigger=manual')
|
|
470
|
+
const files = readdirSync(pdir('pcmd', 'pcmd')).sort()
|
|
471
|
+
assert(files.every((f) => ['paper.md', 'paper.tex', 'paper.pdf', 'paper.meta.json', 'paper.log.md', 'paper.lock.json'].indexOf(f) !== -1), '★ v3 only paper artifacts live in Paper/<id>/ (got ' + JSON.stringify(files) + ')')
|
|
472
|
+
assert(!existsSync(join(projDir('pcmd'), 'Space')) && !existsSync(join(WS, 'etc')) && !existsSync(join(WS, 'y')), 'v3 no file was written outside the project tree')
|
|
473
|
+
const bl = await cmd('paper lang=xx'); const bf = await cmd('paper format=nope'); const bo = await cmd('paper what=1')
|
|
474
|
+
assert(bl.kind === 'error' && bf.kind === 'error' && bo.kind === 'error', '★ v3 illegal/unknown paper options return kind:error')
|
|
475
|
+
const beforeMd = readMeta('pcmd', 'pcmd').finalizedAt
|
|
476
|
+
const mdRun = JSON.parse((await cmd('paper lang=en format=md force')).text)
|
|
477
|
+
assert(mdRun.dispatched === true, 'v3 lang=/format= override dispatches')
|
|
478
|
+
firePaper('pcmd')
|
|
479
|
+
const m2 = await asyncFind(() => { const m = readMeta('pcmd', 'pcmd'); return m && m.finalizedAt !== beforeMd ? m : undefined }, 40)
|
|
480
|
+
assert(!!m2 && m2.params.paperFormat === 'md' && m2.params.paperLanguage === 'en' && m2.compile === 'skipped', '★★ v3 paperFormat=md records the override and skips compilation entirely (§E)')
|
|
481
|
+
// 激活上限:排队 + 心跳重试 + 可见告警
|
|
482
|
+
latexCfg = null
|
|
483
|
+
await call('vibe_math_new_project', { name: 'pqueue' })
|
|
484
|
+
refuseNext = 1
|
|
485
|
+
const q = JSON.parse((await cmd('paper')).text)
|
|
486
|
+
assert(q.ok === true && q.queued === true && q.reason === 'activation-limit-reached' && q.tries === 1 && q.limit === 2, '★ v3 activation-limit refusal is QUEUED with the host limit recorded')
|
|
487
|
+
const stq = await call('vibe_math_status', {})
|
|
488
|
+
assert(!!stq.paper && !!stq.paper.queued && stq.paper.queued.tries === 1, 'v3 status exposes the queued retry')
|
|
489
|
+
assert(/激活上限/.test((await call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')), '★★ v3 queueing is visible on the activity log')
|
|
490
|
+
const retried = await asyncFind(() => (paperSpawns().some((s) => s.label === 'paper-writer:pqueue') ? true : undefined), 80, 200)
|
|
491
|
+
assert(!!retried, '★★ v3 the paper heartbeat retried after the refusal (independent of scheduler.running)')
|
|
492
|
+
firePaper('pqueue')
|
|
493
|
+
const mq = await asyncFind(() => readMeta('pqueue', 'pqueue'), 60)
|
|
494
|
+
assert(!!mq, 'v3 the retried writer still finalized the paper')
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
console.log('\n-- PAPER §1 (v3): a hung writer can never wedge the paper (force refuses early, reaps after the window) --')
|
|
498
|
+
{
|
|
499
|
+
latexCfg = null
|
|
500
|
+
await call('vibe_math_new_project', { name: 'phang' })
|
|
501
|
+
await call('vibe_math_set_params', { finalPaper: false }) // 本节只考 force 的回收判定:关掉自动重派以保证确定性
|
|
502
|
+
assert(H.paperWriterVerdict({ inFlight: false }) === 'none', 'v3 no in-flight writer → none')
|
|
503
|
+
assert(H.paperWriterVerdict({ inFlight: true, force: false, ageMs: 1000, staleMs: 600000, childInRegistry: true }) === 'none', 'v3 fresh writer + no force → none')
|
|
504
|
+
assert(H.paperWriterVerdict({ inFlight: true, force: true, ageMs: 1000, staleMs: 600000, childInRegistry: true }) === 'refuse', '★ v3 force before the stale window → refuse (actionable error)')
|
|
505
|
+
assert(H.paperWriterVerdict({ inFlight: true, force: false, ageMs: 700000, staleMs: 600000, childInRegistry: true }) === 'reap', '★ v3 past the stale window → reap')
|
|
506
|
+
assert(H.paperWriterVerdict({ inFlight: true, force: true, ageMs: 700000, staleMs: 600000, childInRegistry: true }) === 'reap', '★ v3 force past the stale window → reap')
|
|
507
|
+
assert(H.paperWriterVerdict({ inFlight: true, force: false, ageMs: 10, staleMs: 600000, childInRegistry: false }) === 'reap', '★ v3 child gone from the registry → reap immediately')
|
|
508
|
+
const r1 = JSON.parse((await cmd('paper')).text)
|
|
509
|
+
assert(r1.dispatched === true, 'v3 a writer is dispatched')
|
|
510
|
+
const w1 = paperSpawns('phang')[0]
|
|
511
|
+
const refused = await cmd('paper force')
|
|
512
|
+
const rb = JSON.parse(refused.text)
|
|
513
|
+
assert(refused.kind === 'error' && rb.reason === 'writer-in-flight', '★★ v3 force before the window returns kind:error with reason=writer-in-flight')
|
|
514
|
+
assert(/在途/.test(rb.message) && /10 分钟/.test(rb.message) && /回收/.test(rb.message), '★★ v3 message is ACTIONABLE (child, window, automatic reap)')
|
|
515
|
+
assert(paperSpawns('phang').length === 1, 'v3 no second writer from the refused force')
|
|
516
|
+
const st = await call('vibe_math_status', {})
|
|
517
|
+
assert(st.paper.inFlight === w1.childId && st.paper.staleMs === 600000 && st.paper.inFlightAgeMs !== null, 'v3 status exposes the in-flight writer, its age and the stale window')
|
|
518
|
+
const realNow = Date.now
|
|
519
|
+
let after
|
|
520
|
+
try {
|
|
521
|
+
Date.now = () => realNow.call(Date) + 11 * 60 * 1000
|
|
522
|
+
await sleep(50)
|
|
523
|
+
after = JSON.parse((await cmd('paper force')).text)
|
|
524
|
+
} finally { Date.now = realNow }
|
|
525
|
+
assert(after.dispatched === true, '★ v3 force past the stale window reaps the hung writer and dispatches a fresh one')
|
|
526
|
+
assert(paperSpawns('phang').length === 2, 'v3 a fresh writer really was dispatched')
|
|
527
|
+
const log = readFileSync(join(pdir('phang', 'phang'), 'paper.log.md'), 'utf8')
|
|
528
|
+
assert(/\[reap\]/.test(log) && /\[force-refused\]/.test(log), '★★ v3 the reap and the earlier refusal are both logged')
|
|
529
|
+
firePaper('phang')
|
|
530
|
+
const meta = await asyncFind(() => readMeta('phang', 'phang'), 60)
|
|
531
|
+
assert(!!meta, '★★ v3 after reap+force the paper is produced — a dead writer can no longer wedge it')
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
console.log('\n-- PAPER §1b (v3): the heartbeat reaps a stalled writer and re-dispatches automatically --')
|
|
535
|
+
{
|
|
536
|
+
latexCfg = null
|
|
537
|
+
await call('vibe_math_new_project', { name: 'pheal' })
|
|
538
|
+
await call('vibe_math_set_params', { finalPaper: true })
|
|
539
|
+
const r = JSON.parse((await cmd('paper')).text)
|
|
540
|
+
assert(r.dispatched === true, 'v3 a writer is dispatched')
|
|
541
|
+
const realNow = Date.now
|
|
542
|
+
Date.now = () => realNow.call(Date) + 11 * 60 * 1000
|
|
543
|
+
const second = await asyncFind(() => (paperSpawns('pheal').length >= 2 ? true : undefined), 60, 150)
|
|
544
|
+
Date.now = realNow
|
|
545
|
+
assert(!!second, '★★ v3 the heartbeat reaped the stalled writer and auto-dispatched a replacement')
|
|
546
|
+
const log = readFileSync(join(pdir('pheal', 'pheal'), 'paper.log.md'), 'utf8')
|
|
547
|
+
assert(/\[reap\]/.test(log) && /\[retry-after-reap\]/.test(log), 'v3 the reap and the bounded auto-retry are logged')
|
|
548
|
+
assert(/回收卡死的论文撰写子代理/.test((await call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')), 'v3 the reap is visible on the activity log')
|
|
549
|
+
firePaper('pheal')
|
|
550
|
+
const meta = await asyncFind(() => readMeta('pheal', 'pheal'), 60)
|
|
551
|
+
assert(!!meta, 'v3 the auto-recovered writer finalized the paper')
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
console.log('\n-- PAPER §2 (v3): a pre-existing paper.pdf is never deleted or overwritten --')
|
|
555
|
+
{
|
|
556
|
+
const PDF = '%PDF-1.4 pre-existing bytes\n'
|
|
557
|
+
// (a) 无 LaTeX ⇒ not-detected:既有 pdf 必须逐字节不变
|
|
558
|
+
latexCfg = null
|
|
559
|
+
await call('vibe_math_new_project', { name: 'psafe1' })
|
|
560
|
+
await cmd('paper')
|
|
561
|
+
mkdirSync(pdir('psafe1', 'psafe1'), { recursive: true })
|
|
562
|
+
writeFileSync(join(pdir('psafe1', 'psafe1'), 'paper.pdf'), PDF, 'utf8')
|
|
563
|
+
firePaper('psafe1')
|
|
564
|
+
let m = await asyncFind(() => readMeta('psafe1', 'psafe1'), 60)
|
|
565
|
+
assert(!!m && m.compile === 'not-detected' && m.pdfPreserved === true, '★ v3 (a) no engine → not-detected and meta records pdfPreserved')
|
|
566
|
+
assert(read(join(pdir('psafe1', 'psafe1'), 'paper.pdf')) === PDF, '★★ v3 (a) the pre-existing paper.pdf is byte-identical after a not-detected run')
|
|
567
|
+
// (b) 编译器持续失败 ⇒ failed:既有 pdf 仍逐字节不变
|
|
568
|
+
latexCfg = { engines: ['xelatex', 'latexmk'], alwaysFail: true }
|
|
569
|
+
await call('vibe_math_new_project', { name: 'psafe2' })
|
|
570
|
+
await cmd('paper')
|
|
571
|
+
mkdirSync(pdir('psafe2', 'psafe2'), { recursive: true })
|
|
572
|
+
writeFileSync(join(pdir('psafe2', 'psafe2'), 'paper.pdf'), PDF, 'utf8')
|
|
573
|
+
firePaper('psafe2')
|
|
574
|
+
m = await asyncFind(() => readMeta('psafe2', 'psafe2'), 60)
|
|
575
|
+
assert(!!m && m.compile === 'failed' && m.pdfPreserved === true, '★ v3 (b) persistent failure → failed + pdfPreserved')
|
|
576
|
+
assert(read(join(pdir('psafe2', 'psafe2'), 'paper.pdf')) === PDF, '★★ v3 (b) the pre-existing paper.pdf survives a failed compile byte-identically')
|
|
577
|
+
// (c) paperFormat=md ⇒ 连引擎探测都不做,pdf 更不可能被碰
|
|
578
|
+
latexCfg = { engines: ['xelatex'] }
|
|
579
|
+
await call('vibe_math_new_project', { name: 'psafe3' })
|
|
580
|
+
const runsAtStart = latexRuns.length
|
|
581
|
+
await cmd('paper')
|
|
582
|
+
firePaper('psafe3')
|
|
583
|
+
const meta1 = await asyncFind(() => readMeta('psafe3', 'psafe3'), 60)
|
|
584
|
+
assert(!!meta1 && meta1.artifacts.tex === true, 'v3 (c) the default both run produced tex and probed the engines')
|
|
585
|
+
const runsAfterTex = latexRuns.length
|
|
586
|
+
assert(runsAfterTex > runsAtStart, 'v3 (c) the both-format run really probed/ran an engine')
|
|
587
|
+
writeFileSync(join(pdir('psafe3', 'psafe3'), 'paper.pdf'), PDF, 'utf8') // 哨兵
|
|
588
|
+
const beforeAt = meta1.finalizedAt
|
|
589
|
+
const mdRun = JSON.parse((await cmd('paper format=md force')).text)
|
|
590
|
+
assert(mdRun.dispatched === true, 'v3 (c) a forced md-only rewrite is dispatched')
|
|
591
|
+
firePaper('psafe3')
|
|
592
|
+
const m2 = await asyncFind(() => { const x = readMeta('psafe3', 'psafe3'); return x && x.finalizedAt !== beforeAt ? x : undefined }, 60)
|
|
593
|
+
assert(!!m2 && m2.params.paperFormat === 'md' && m2.compile === 'skipped', '★ v3 (c) paperFormat=md skips compilation entirely')
|
|
594
|
+
assert(latexRuns.length === runsAfterTex, '★★ v3 (c) md-only never even probes the LaTeX engines')
|
|
595
|
+
assert(read(join(pdir('psafe3', 'psafe3'), 'paper.pdf')) === PDF, '★★ v3 (c) the pre-existing paper.pdf is untouched by an md-only run')
|
|
596
|
+
latexCfg = null
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ================= D1 class guard: taught write-paths ⊆ the writer's whitelist =================
|
|
600
|
+
console.log('\n-- D1 guard: every member-facing write path is one the writer accepts --');
|
|
601
|
+
{
|
|
602
|
+
const js = readFileSync(PLUGIN, 'utf8');
|
|
603
|
+
const yml = readFileSync(new URL('../vibe-math-v3/agent.cordis.yml', import.meta.url), 'utf8');
|
|
604
|
+
const plugin = js + '\n' + yml;
|
|
605
|
+
// 1) the whitelist the writer really enforces (applyAgentWrites)
|
|
606
|
+
const wl = /if \(!\/\^\(([A-Za-z|]+)\)\\\/\//.exec(js);
|
|
607
|
+
assert(!!wl && wl[1] === 'Problems|Progress|Propos|Methods|Notes',
|
|
608
|
+
'D1 whitelist read from applyAgentWrites (got ' + (wl && wl[1]) + ')');
|
|
609
|
+
const roots = (wl ? wl[1] : '').split('|');
|
|
610
|
+
// 2) no member subtree exists in v3 at all — the D1 contradiction must not come back
|
|
611
|
+
assert(plugin.indexOf('Members/') === -1,
|
|
612
|
+
'★★ [D1] no `Members/` path is taught anywhere in the v3 prompts/persona (v3 has no member subtree)');
|
|
613
|
+
// 3) every `.md` path inside a WRITE-instruction line must start with an accepted root
|
|
614
|
+
const WRITE = /(写进|写入|写到|写一张|write into|write the |write its|write a )/;
|
|
615
|
+
const PATH = /`([A-Za-z][^`\s]*?\.md)`/g;
|
|
616
|
+
const FRAMEWORK_MAINTAINED = ['Formal/TODO.md']; // written by the framework itself, not by a member
|
|
617
|
+
const taught = [];
|
|
618
|
+
for (const line of plugin.split(/\r?\n/)) {
|
|
619
|
+
if (!WRITE.test(line)) continue;
|
|
620
|
+
let m;
|
|
621
|
+
while ((m = PATH.exec(line))) {
|
|
622
|
+
const p = m[1];
|
|
623
|
+
if (FRAMEWORK_MAINTAINED.indexOf(p) === -1) taught.push(p);
|
|
624
|
+
}
|
|
625
|
+
PATH.lastIndex = 0;
|
|
626
|
+
}
|
|
627
|
+
const uniq = [...new Set(taught)];
|
|
628
|
+
assert(uniq.length >= 3, 'D1 write instructions with explicit .md paths were found (' + uniq.length + ')');
|
|
629
|
+
const bad = uniq.filter((p) => roots.indexOf(p.split('/')[0]) === -1);
|
|
630
|
+
assert(bad.length === 0,
|
|
631
|
+
'★★ [D1] every taught write path is accepted by applyAgentWrites (' + roots.join('|') + '); offenders=' + JSON.stringify(bad));
|
|
632
|
+
// 4) the solver CHANNEL A prompt must agree with the persona about the narration path
|
|
633
|
+
const chanA = /CHANNEL A[^\n]*narrative into `Progress\/' \+ q\.id \+ '\/' \+ dir\.id \+ '\.md`/.test(js);
|
|
634
|
+
assert(chanA, '★★ [D1] CHANNEL A 的叙述路径 = Progress/<qid>/<dirId>.md(与 persona 同一扁平布局)');
|
|
635
|
+
const personaNarr = js.indexOf('写进 `Progress/<问题id>/<方向id>.md`') !== -1;
|
|
636
|
+
assert(personaNarr, '★★ [D1] persona 的求解器归属文件说明与 CHANNEL A 同路径(不再教 Members/…)');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ================= P1: add_proposition 拒绝覆盖已有 id =================
|
|
640
|
+
console.log('\n-- P1 (v3): add_proposition 拒绝覆盖已有 id --');
|
|
641
|
+
{
|
|
642
|
+
await call('vibe_math_new_project', { name: 'p1guard' });
|
|
643
|
+
const a1 = await call('vibe_math_add_proposition', { id: 'pP1v3', 概述: '第一版', 概率: 0.6, 分类: '分析' });
|
|
644
|
+
assert(a1.ok === true, 'P1 首次新建成功');
|
|
645
|
+
const a2 = await call('vibe_math_add_proposition', { id: 'pP1v3', 概述: '冒名覆盖', 概率: 0.9, 分类: '分析' });
|
|
646
|
+
assert(a2.ok !== true && String(a2.code) === 'PROPOSITION_ID_EXISTS', '★★ [P1] 同 id 再次新建被拒绝(实测 code=' + JSON.stringify(a2.code || a2.message) + ')');
|
|
647
|
+
assert(typeof a2.next === 'string' && a2.next.length > 0, '★★ [P1] 拒绝时给出可执行的 next');
|
|
648
|
+
const list = await call('vibe_math_list_propositions', {});
|
|
649
|
+
const kept = (list.propositions || []).filter((x) => x.id === 'pP1v3')[0];
|
|
650
|
+
assert(kept && Number(kept.概率) === 0.6, '★★ [P1] 已有卡未被覆盖(概率仍是 0.6,实测 ' + JSON.stringify(kept && kept.概率) + ')');
|
|
651
|
+
const a3 = await call('vibe_math_add_proposition', { id: 'pP1v3b', 概述: '新卡', 概率: 0.5, 分类: '分析' });
|
|
652
|
+
assert(a3.ok === true, '对照:换新 id 可正常新建');
|
|
653
|
+
}
|
|
654
|
+
// ================= P2: 状态提交完整性标记 =================
|
|
655
|
+
console.log('\n-- P2 (v3): 状态提交有完整性标记,撕裂提交可检测 --');
|
|
656
|
+
{
|
|
657
|
+
await call('vibe_math_new_project', { name: 'p2commit' });
|
|
658
|
+
await call('vibe_math_set_params', { verdictMode: 'flat' });
|
|
659
|
+
const st = await call('vibe_math_status', {});
|
|
660
|
+
assert(st.stateCommit && Number(st.stateCommit.seq) >= 1, '★★ [P2] 提交标记存在且带序号(实测 seq=' + (st.stateCommit && st.stateCommit.seq) + ')');
|
|
661
|
+
assert(st.stateCommit.complete === true && Array.isArray(st.stateCommit.files) && st.stateCommit.files.length >= 6, '★★ [P2] 标记列出全部状态文件且都非空(files=' + ((st.stateCommit.files || []).length) + ',mode=' + st.stateCommit.mode + ')');
|
|
662
|
+
const root = join(WS, 'VibeMath', 'Projects', 'p2commit');
|
|
663
|
+
const victimRel = st.stateCommit.files.filter((f) => /tasks\.json$/.test(f))[0] || st.stateCommit.files[0];
|
|
664
|
+
rmSync(join(root, victimRel), { force: true });
|
|
665
|
+
await call('vibe_math_resume', {});
|
|
666
|
+
const acts = (await call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n');
|
|
667
|
+
assert(acts.indexOf('上一次状态提交不完整') !== -1, '★★ [P2] 撕裂提交被明确报出(活动日志:' + JSON.stringify(acts.slice(-200)) + ')');
|
|
668
|
+
}
|
|
669
|
+
// ================= P5–P8 (v3): 状态完整性码形 =================
|
|
670
|
+
console.log('\n-- P5–P8 (v3): 状态完整性码形 --');
|
|
671
|
+
{
|
|
672
|
+
const src = readFileSync(PLUGIN, 'utf8');
|
|
673
|
+
assert(/State\/activity_log\.json/.test(src) && /State\/paper\.json/.test(src), '★★ [P5/P6] 活动日志与论文排队状态都进提交清单');
|
|
674
|
+
assert(/Date\.now\(\) \+ '_' \+ shortId\(\) \+ '\.json'/.test(src), '★★ [P7] 裁决日志名带 shortId 后缀');
|
|
675
|
+
assert(/function shortIdUnique\(isTaken\)/.test(src) && /'p-' \+ shortIdUnique\(/.test(src), '★★ [P8] 框架分配命题 id 走 shortIdUnique');
|
|
676
|
+
}
|
|
677
|
+
console.log('\n-- F2cap: status/report 的 recentActivity 共用同一上限 --');
|
|
678
|
+
{
|
|
679
|
+
const src = readFileSync(PLUGIN, 'utf8');
|
|
680
|
+
const m = /const ACTIVITY_REPORT_MAX = (\d+)/.exec(src);
|
|
681
|
+
assert(!!m, '★★★ [F2cap] 存在命名上限常量 ACTIVITY_REPORT_MAX');
|
|
682
|
+
const cap = Number(m ? m[1] : 0);
|
|
683
|
+
const uses = (src.match(/recentActivity: activityLog\.slice\(-Math\.min\(ACTIVITY_REPORT_MAX,/g) || []).length;
|
|
684
|
+
assert(uses === 2, '★★★ [F2cap] status 与 report 两处都走同一常量(实测 ' + uses + ' 处;硬编码字面量会在此变红)');
|
|
685
|
+
const entry = /\{ name: 'activityLogCap'[^]*?suggestion: 100 \},/.exec(src);
|
|
686
|
+
assert(!!entry && /ACTIVITY_REPORT_MAX/.test(entry[0]) && /条)/.test(entry[0]) && !/最多显示 30/.test(entry[0]), '★★★ [F2cap] 参数描述**引用**该常量(而非写死字面量)——doc↔code 交叉来源(cap=' + cap + ',entry=' + JSON.stringify(entry && entry[0].slice(-90)) + ')');
|
|
687
|
+
await call('vibe_math_set_params', { activityLogCap: 2 });
|
|
688
|
+
const st = await call('vibe_math_status', {});
|
|
689
|
+
const rep = await call('vibe_math_report', {});
|
|
690
|
+
const a = Array.isArray(st.recentActivity) ? st.recentActivity.length : -1;
|
|
691
|
+
const b = Array.isArray(rep.recentActivity) ? rep.recentActivity.length : -1;
|
|
692
|
+
assert(a >= 0 && a === b && a <= 2, '★★ [F2cap] 调小 activityLogCap 后两视图同步跟随(status=' + a + ' report=' + b + ')');
|
|
693
|
+
await call('vibe_math_set_params', { activityLogCap: 100 });
|
|
694
|
+
}
|
|
695
|
+
// ================= Frame contracts: planner vocabulary / method-keeper id =================
|
|
696
|
+
console.log('\n-- Frame F1/F2: planner vocabulary == the code\'s accepted actions --');
|
|
697
|
+
{
|
|
698
|
+
const src = readFileSync(PLUGIN, 'utf8');
|
|
699
|
+
const allowed = /const allowed = \{([^}]*)\}/.exec(src);
|
|
700
|
+
assert(!!allowed, 'F1: the plan validator allow-list is readable');
|
|
701
|
+
const codeActions = (allowed ? (allowed[1].match(/([a-z]+)\s*:\s*1/g) || []) : []).map((x) => x.split(':')[0].trim()).sort();
|
|
702
|
+
const block2 = src.slice(src.indexOf('ACTION VOCABULARY'), src.indexOf('HARD RULES'));
|
|
703
|
+
const docActions = [...new Set((block2.match(/"action":"([a-z]+)"/g) || []).map((x) => /"action":"([a-z]+)"/.exec(x)[1]))].sort();
|
|
704
|
+
assert(codeActions.join(',') === docActions.join(','), '★★ [F1] 帧列出的动作集合 == 代码 allowed 集合(doc=' + docActions.join(',') + ' / code=' + codeActions.join(',') + ')');
|
|
705
|
+
const advisory = (block2.match(/advisory only/g) || []).length;
|
|
706
|
+
assert(advisory >= 3 && /does NOT stop anyone/.test(block2), '★★ [F1] wait/continue/stop 三个 advisory 动作都被点名且写明无调度效果(实测 advisory only ×' + advisory + ')');
|
|
707
|
+
assert(block2.indexOf('HARD-VALIDATED') !== -1 && block2.indexOf('code validates every action against hard invariants') === -1, '★★ [F2] 帧点明哪些动作被硬校验、哪些只是 advisory(不再声称每个动作都被校验)');
|
|
708
|
+
}
|
|
709
|
+
console.log('\n-- Frame F3: method-keeper card-id contract --');
|
|
710
|
+
{
|
|
711
|
+
const src = readFileSync(PLUGIN, 'utf8');
|
|
712
|
+
assert(/in the FILE NAME must be EXACTLY the id you list in `created`/.test(src), '★★ [F3] 帧写明「卡文件名里的 id 必须与 created 的 id 逐字相同」(与引理处的分类一致性同型)');
|
|
713
|
+
assert(/调度器按 `created` 里的 id 去/.test(src), '★★ [F3] 帧解释了为什么要一致(调度器按 created 的 id 去找卡)');
|
|
714
|
+
await call('vibe_math_sync_meta', { meta: { kind: 'methods', created: ['m-probeF3'], used: [], improvements: [] } });
|
|
715
|
+
const walkFor = (d, name) => {
|
|
716
|
+
let hit = false;
|
|
717
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
718
|
+
const p = join(d, e.name);
|
|
719
|
+
if (e.isDirectory()) { if (walkFor(p, name)) hit = true; }
|
|
720
|
+
else if (e.name === name) hit = true;
|
|
721
|
+
if (hit) break;
|
|
722
|
+
}
|
|
723
|
+
return hit;
|
|
724
|
+
};
|
|
725
|
+
assert(walkFor(WS, 'm-probeF3.md'), '★★ [F3] created 里的 id 一定落成一张卡(框架兜底写标准卡,不留悬空引用;探针在整个工作树里找 m-probeF3.md)');
|
|
726
|
+
}
|
|
727
|
+
console.log('\n-- F1: pendingDecisions 在 status/report 同形 --');
|
|
728
|
+
{
|
|
729
|
+
const st = await call('vibe_math_status', {});
|
|
730
|
+
const rep = await call('vibe_math_report', {});
|
|
731
|
+
assert(typeof st.pendingDecisions === 'number' && typeof rep.pendingDecisions === 'number',
|
|
732
|
+
'★★★ [F1] pendingDecisions 两面同形(都是数字;实测 status=' + typeof st.pendingDecisions + ' report=' + typeof rep.pendingDecisions + ')');
|
|
733
|
+
assert(Array.isArray(rep.pendingDecisionItems) && rep.pendingDecisionItems.length === rep.pendingDecisions,
|
|
734
|
+
'★★ [F1] 明细用独立键名 pendingDecisionItems,且长度与计数一致(实测 ' + JSON.stringify(rep.pendingDecisionItems && rep.pendingDecisionItems.length) + ')');
|
|
735
|
+
assert(st.pendingDecisions === rep.pendingDecisions, '★★★ [F-A] 两面数字一致(合同):status=' + st.pendingDecisions + ' report=' + rep.pendingDecisions);
|
|
736
|
+
assert(st.pendingDecisionItems === undefined, '★★★ [F-A] status **不得**携带明细——有意拆分:数字是合同、明细只进 report(实测 ' + JSON.stringify(st.pendingDecisionItems) + ')');
|
|
737
|
+
const srcA = readFileSync(PLUGIN, 'utf8');
|
|
738
|
+
assert(/F-A(有意拆分)/.test(srcA), '★★ [F-A] 设计句在代码里(拆分理由可判定,不靠口头约定)');
|
|
739
|
+
}
|
|
740
|
+
console.log('\n-- F4c: shell 兜底失败不能沉默 --');
|
|
741
|
+
{
|
|
742
|
+
const src4c = readFileSync(PLUGIN, 'utf8');
|
|
743
|
+
assert(/function warnShellOnce\(/.test(src4c), '★★★ [F4c] v3 有 shell 失败的一次性告警器(warnShellOnce)');
|
|
744
|
+
const bodyOf = (name) => { const i = src4c.indexOf('async function ' + name + '('); if (i === -1) return ''; const j = src4c.indexOf('\n }', i); return j === -1 ? src4c.slice(i, i + 900) : src4c.slice(i, j + 4); };
|
|
745
|
+
assert(/warnShellOnce\('ensureDirs/.test(bodyOf('ensureDirs')), '★★★ [F4c] ensureDirs 失败不是沉默(调用 warnShellOnce)');
|
|
746
|
+
assert(/warnShellOnce\('removeFile/.test(bodyOf('removeFile')), '★★ [F4c] removeFile 失败同样告警(同一类降级路径)');
|
|
747
|
+
}
|
|
748
|
+
console.log('\n-- F6a/v3: list() 失败时的一次性回退告警 --');
|
|
749
|
+
{
|
|
750
|
+
const realErr = console.error; const buf = [];
|
|
751
|
+
try {
|
|
752
|
+
console.error = (...a) => buf.push(a.map(String).join(' '));
|
|
753
|
+
probe.failList = true;
|
|
754
|
+
await call('vibe_math_new_project', { name: 'p' });
|
|
755
|
+
await call('vibe_math_add_problem', { id: 'qA6v3', description: 'x' });
|
|
756
|
+
await call('vibe_math_start', {});
|
|
757
|
+
for (let i = 0; i < 40 && spawns.length === 0; i++) await sleep(150);
|
|
758
|
+
await sleep(200);
|
|
759
|
+
} finally { probe.failList = false; console.error = realErr; }
|
|
760
|
+
const hits = buf.join('\n').split('\n').filter((l) => /pickProvider\(\) falling back to 'spawn'/.test(l));
|
|
761
|
+
assert(hits.length === 1 && /subagents\.list\(\) failed/.test(hits[0] || ''), '★★★ [F6a/v3] list() 失败的一次性回退告警可见且内容正确(实测 ' + JSON.stringify(hits) + ',spawns=' + spawns.length + ')');
|
|
762
|
+
}
|
|
763
|
+
console.log('\n-- F6b/v3: 中断失败必须留痕并点名站点 --');
|
|
764
|
+
{
|
|
765
|
+
const beforeSpawns = spawns.length;
|
|
766
|
+
try {
|
|
767
|
+
probe.failInterrupt = true;
|
|
768
|
+
await call('vibe_math_new_project', { name: 'p' });
|
|
769
|
+
await call('vibe_math_add_problem', { id: 'qB6v3', description: 'x' });
|
|
770
|
+
await call('vibe_math_start', {});
|
|
771
|
+
for (let i = 0; i < 40 && spawns.length === beforeSpawns; i++) await sleep(150);
|
|
772
|
+
await call('vibe_math_abort', {});
|
|
773
|
+
} finally { probe.failInterrupt = false; }
|
|
774
|
+
const st6 = await call('vibe_math_status', {});
|
|
775
|
+
const abortedId = spawns.length > beforeSpawns ? spawns[beforeSpawns].childId : 'c1';
|
|
776
|
+
// v3 的 abort 会中断**所有**已登记子代理 ⇒ 具体 id 不是稳定站点键;站点由 event + why 标签 + 失败文本点名(id 只进诊断消息)。
|
|
777
|
+
const hits6 = (st6.recentActivity || []).filter((a) => String(a.event) === 'interrupt' && /中断失败(batch clear \(start\/abort\)/.test(String(a.detail)));
|
|
778
|
+
assert(hits6.length >= 1, '★★★ [F6b/v3] 中断失败进入活动日志并**点名站点**(event=interrupt + 子代理 id=' + abortedId + ' + why 标签 batch clear(start/abort);实测 ' + JSON.stringify((st6.recentActivity || []).filter((a) => String(a.event) === 'interrupt').slice(-1)[0] || null) + ')');
|
|
779
|
+
}
|
|
780
|
+
console.log('\n-- F6c/v3: 项目指针写失败必须可观察 --');
|
|
781
|
+
{
|
|
782
|
+
try {
|
|
783
|
+
probe.failWritePath = 'current.';
|
|
784
|
+
await call('vibe_math_new_project', { name: 'p' });
|
|
785
|
+
} finally { probe.failWritePath = ''; }
|
|
786
|
+
const st6c = await call('vibe_math_status', {});
|
|
787
|
+
const last6c = st6c.stateWriteFailures && st6c.stateWriteFailures.last;
|
|
788
|
+
assert(!!last6c && /current\./.test(JSON.stringify(last6c)), '★★★ [F6c/v3] 项目指针写失败进入 status.stateWriteFailures(实测 ' + JSON.stringify(st6c.stateWriteFailures) + ')');
|
|
789
|
+
}
|
|
790
|
+
console.log('\n-- F5/v3: 围栏根与会话工作区不一致必须一次性告警 --');
|
|
791
|
+
{
|
|
792
|
+
const realErr5 = console.error; const buf5 = [];
|
|
793
|
+
try {
|
|
794
|
+
console.error = (...a) => buf5.push(a.map(String).join(' '));
|
|
795
|
+
probe.policyRoot = join(tmpdir(), 'not-this-session');
|
|
796
|
+
await call('vibe_math_pause', {});
|
|
797
|
+
await call('vibe_math_resume', {});
|
|
798
|
+
} finally { probe.policyRoot = ''; console.error = realErr5; }
|
|
799
|
+
const hits5 = buf5.join('\n').split('\n').filter((l) => /sandbox fence root differs from this session workspace/.test(l));
|
|
800
|
+
assert(hits5.length === 1, '★★★ [F5/v3] 围栏根漂移一次性具名告警(实测 ' + JSON.stringify(hits5[0] || buf5.slice(-160)) + ')');
|
|
801
|
+
}
|
|
802
|
+
console.log('\n-- F3: fieldScopes 标注会话 vs 耐久 --');
|
|
803
|
+
{
|
|
804
|
+
const st = await call('vibe_math_status', {});
|
|
805
|
+
const sc = st.fieldScopes;
|
|
806
|
+
assert(sc && Array.isArray(sc.session) && Array.isArray(sc.durable) && typeof sc.note === 'string' && sc.note.length > 0, '★★★ [F3] status 带 fieldScopes(session/durable/note 齐备)');
|
|
807
|
+
const getPath = (p) => p.split('.').reduce((o, k) => (o === undefined || o === null ? undefined : o[k]), st);
|
|
808
|
+
const missing = (sc ? sc.session.concat(sc.durable) : []).filter((p) => getPath(p) === undefined);
|
|
809
|
+
assert(missing.length === 0, '★★★ [F3] fieldScopes 列的每个字段在载荷里真实存在(列出不存在的名字会在此变红;缺 ' + JSON.stringify(missing) + ')');
|
|
810
|
+
assert(sc && sc.session.indexOf('stateWriteFailures') !== -1 && sc.session.indexOf('registeredAgents') !== -1 && sc.durable.indexOf('formal') !== -1 && sc.durable.indexOf('stateWriteFailures') === -1, '★★ [F3] 语义抽查:stateWriteFailures/registeredAgents 属会话、formal 属耐久,两组互斥');
|
|
811
|
+
}
|
|
812
|
+
console.log('\n-- F5/F7/F4v3: 两面字段集、人读报告本地化、v3 推送行(字段文档一致性已移交随包 audit)--');
|
|
813
|
+
{
|
|
814
|
+
const st = await call('vibe_math_status', {});
|
|
815
|
+
const rep = await call('vibe_math_report', {});
|
|
816
|
+
assert('paper' in st && 'paper' in rep && 'at' in st && 'at' in rep, '★★★ [F5] paper/at 两面都有(status 与 report 字段集已对齐)');
|
|
817
|
+
const src3 = readFileSync(PLUGIN, 'utf8');
|
|
818
|
+
assert(/请调用 vibe_math_report 汇总当前进展,并用 vibe_math_list_agents 取各代理/.test(src3), '★★ [F4v3] v3 推送行也点名 vibe_math_list_agents(report 只给计数)');
|
|
819
|
+
// F7 行为面:report 工具会编译人读报告 Logs/报告.md
|
|
820
|
+
const findFile = (d, name) => {
|
|
821
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
822
|
+
const p = join(d, e.name);
|
|
823
|
+
if (e.isDirectory()) { const hit = findFile(p, name); if (hit) return hit; }
|
|
824
|
+
else if (e.name === name) return p;
|
|
825
|
+
}
|
|
826
|
+
return null;
|
|
827
|
+
};
|
|
828
|
+
const rp = findFile(WS, '报告.md');
|
|
829
|
+
assert(!!rp, '★★★ [F7] report 工具产出人读报告(Logs/报告.md)');
|
|
830
|
+
const txt = rp ? readFileSync(rp, 'utf8') : '';
|
|
831
|
+
assert(/运行中:(是|否)/.test(txt) && !/运行中:true|运行中:false/.test(txt), '★★★ [F7] 人读报告把裸布尔中文化(实测 ' + JSON.stringify((txt.match(/运行中:[^;]*/) || [''])[0]) + ')');
|
|
832
|
+
assert(/(方向:/.test(txt) ? /(方向:[^)]*\((active|dead-end|success|queued)\)/.test(txt) : true, '★★ [F7] 方向状态中文化并把代码 token 放括号里备查');
|
|
833
|
+
}
|
|
834
|
+
// ================= D2: file write locks are leases (renewed while the holder lives) =================
|
|
835
|
+
// The old defect: the refusal window was the literal 60000 and NOTHING ever renewed `at`, so a member
|
|
836
|
+
// writing a big file for >60 s lost exclusivity (another member could legally take the lock).
|
|
837
|
+
// Timing-based assertions are unusable in this harness (set_params values are reverted by the
|
|
838
|
+
// session's init()/settings reload mid-test — see the note in the run report), so this guard pins the
|
|
839
|
+
// lease semantics deterministically: the window is the configurable TTL, a live holder is renewed on
|
|
840
|
+
// the same poller as the project lock, and a holder that disappears expires (bounded, no deadlock).
|
|
841
|
+
console.log('\n-- D2: 文件写锁是租约(活着就续租,消失后过期) --');
|
|
842
|
+
{
|
|
843
|
+
const js = readFileSync(PLUGIN, 'utf8');
|
|
844
|
+
const callAs = async (n, a, agent) => JSON.parse(await (toolRegs.find((s) => s.name === n)).execute(a || {}, { agent }));
|
|
845
|
+
const ghost = { id: 'ghost-child', options: {}, session: { id: 'ghost-child', header: { cwd: WS } } };
|
|
846
|
+
const target = 'Progress/q-lock/dL.md';
|
|
847
|
+
await call('vibe_math_set_params', { fileLockTimeoutMs: 1000 });
|
|
848
|
+
const stLock = await call('vibe_math_status', {});
|
|
849
|
+
assert(Number(stLock.params.fileLockTimeoutMs) === 1000, 'D2 fileLockTimeoutMs 是可配置参数且被接受(实测 ' + stLock.params.fileLockTimeoutMs + ')');
|
|
850
|
+
// (a) the refusal window is the lease TTL, not a hardcoded 60000
|
|
851
|
+
assert(/\(now\(\) - \(owner\.at \|\| 0\)\) < fileLockTtl\(\)/.test(js),
|
|
852
|
+
'★★ [D2] 抢锁判定用的是 fileLockTtl() 而不是硬编码 60000');
|
|
853
|
+
assert(!/\(now\(\) - \(owner\.at \|\| 0\)\) < 60000/.test(js), '★★ [D2] 旧的字面量 60000 判定已不存在');
|
|
854
|
+
// (b) a live holder is renewed on the SAME poller as the project lock; a vanished one is not
|
|
855
|
+
assert(/renewLockIfDue: async function \(\) \{ await renewProjectLock\(\); await renewFileLocks\(\) \}/.test(js),
|
|
856
|
+
'★★ [D2] renewFileLocks 与 renewProjectLock 挂在同一个锁轮询上');
|
|
857
|
+
assert(/if \(!fileLockHolderAlive\(o\.childId\)\) continue/.test(js) && /o\.at = now\(\)/.test(js),
|
|
858
|
+
'★★ [D2] 只有存活的持有者被续租(消失者让租约自然到期,避免永久锁死)');
|
|
859
|
+
assert(/function fileLockHolderAlive\(id\)[\s\S]{0,400}agentRegistry\[cid\][\s\S]{0,200}rootAgent/.test(js),
|
|
860
|
+
'★★ [D2] 存活判定 = agentRegistry 里的子代理或本会话根(长文写入者不会在写入中被抢锁)');
|
|
861
|
+
// (c) deterministic behaviour: while a holder has the lock, another agent is refused; after release it can claim
|
|
862
|
+
const c1 = await call('vibe_math_claim_write', { target });
|
|
863
|
+
assert(c1.ok === true, 'D2 活的持有者(会话根)拿到写锁');
|
|
864
|
+
const c2 = await callAs('vibe_math_claim_write', { target }, ghost);
|
|
865
|
+
assert(c2.ok !== true && /busy|正被其他代理/.test(String(c2.message || '')),
|
|
866
|
+
'★★ [D2] 他人持锁期间另一个代理被拒(实测 busy=' + c2.busy + ')');
|
|
867
|
+
await call('vibe_math_release_write', { target });
|
|
868
|
+
const c3 = await callAs('vibe_math_claim_write', { target }, ghost);
|
|
869
|
+
assert(c3.ok === true, 'D2 释放后可被接管');
|
|
870
|
+
await call('vibe_math_set_params', { fileLockTimeoutMs: 60000 });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ================= D6: mkdir only when a write is actually needed (and silent without a shell) ======
|
|
874
|
+
console.log('\n-- D6: 每轮日志不再无条件跑 shell mkdir --');
|
|
875
|
+
{
|
|
876
|
+
const js = readFileSync(PLUGIN, 'utf8');
|
|
877
|
+
const flag = js.indexOf('let journalDirReady = false');
|
|
878
|
+
const mkdir = js.indexOf('if (subprocessOf()) await ensureProgressDir(qid)');
|
|
879
|
+
assert(flag !== -1 && mkdir > flag,
|
|
880
|
+
'★★ [D6] 建目录推迟到"确实要写"的分支(journalDirReady 在循环内、调用在其后)');
|
|
881
|
+
const mkdirCalls = js.split('await ensureProgressDir(qid)').length - 1;
|
|
882
|
+
assert(mkdirCalls === 1 && js.indexOf('await ensureProgressDir(qid)') > flag,
|
|
883
|
+
'★★ [D6] 旧的"循环前无条件 mkdir"形态已不存在(ensureProgressDir 只在按需分支里调用一次,实测 ' + mkdirCalls + ' 处)'); assert(/if \(subprocessOf\(\)\) await ensureProgressDir\(qid\)/.test(js),
|
|
884
|
+
'★★ [D6] 宿主没有 subprocess 时直接跳过 mkdir(无 shell 宿主的每轮日志不再留失败记录)');
|
|
885
|
+
assert(/if \(!journalDirReady\) \{[\s\S]{0,200}journalDirReady = true/.test(js),
|
|
886
|
+
'★★ [D6] 一轮只建一次目录(出现第一个待写文件时才建)');
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
console.log(`\n=== V3 FIX PROBE RESULT: ${passed} passed, ${failed} failed ===`);
|
|
890
|
+
rmSync(WS, { recursive: true, force: true });
|
|
891
|
+
process.exit(failed === 0 ? 0 : 1);
|