dsh-vibe-math 2.3.16 → 2.4.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 (32) hide show
  1. package/README.en.md +49 -46
  2. package/README.md +48 -45
  3. package/cordis.patch.yml +1123 -10
  4. package/docs/AUDIT-CHECKLIST.md +45 -0
  5. package/docs/COMPAT-AUDIT-ROUND2.md +3 -0
  6. package/docs/COMPAT-AUDIT-ROUND3.md +139 -0
  7. package/docs/generate_framework_diagram_v5.mjs +27 -27
  8. package/docs/release-notes/RELEASE-NOTES-2.4.0.md +172 -0
  9. package/docs/test-timing.md +2 -1
  10. package/installer.js +89 -21
  11. package/package.json +33 -9
  12. package/preset-declaration.js +62 -0
  13. package/prompt-corpus-v5/prompt-corpus-v5.json +7 -7
  14. package/prompt-corpus-v5/prompt-corpus-v5.md +8 -129
  15. package/scripts/build-preset-rows.mjs +146 -0
  16. package/tests/audit-installer-compat.test.mjs +9 -3
  17. package/tests/audit-preset-rows.test.mjs +185 -0
  18. package/tests/audit-v5-integrity.mjs +49 -3
  19. package/tests/e2e-v5-round2.test.mjs +137 -67
  20. package/tests/formal-verify-v5.test.mjs +43 -35
  21. package/tests/prompt-v5-integrity.test.mjs +49 -47
  22. package/tests/selfdrive-v5.mjs +42 -55
  23. package/vibe-math-v2/vibe-math-v2.js +6 -2
  24. package/vibe-math-v3/vibe-math-v3.js +6 -2
  25. package/vibe-math-v4/vibe-math-v4.js +191 -13
  26. package/vibe-math-v5/agent.cordis.yml +8 -8
  27. package/vibe-math-v5/preset.yml +1 -1
  28. package/vibe-math-v5/vibe-math-v5.js +215 -91
  29. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +56 -44
  30. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +24 -22
  31. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5-en.svg +14 -14
  32. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5.svg +14 -14
@@ -68,11 +68,17 @@ for (const [range, version, pre, expected] of CASES) {
68
68
  'matcher: ' + JSON.stringify(range) + ' vs ' + version + (pre ? ' (includePrerelease)' : ''))
69
69
  }
70
70
  // our own declaration, both semantics (the exact chain the market and the installer share)
71
- for (const v of ['0.1.2-alpha.4', '0.1.2-rc.1', '0.1.3-alpha.2', '0.1.5-rc.1', '0.1.5-rc.2']) {
71
+ // The window is per-tuple: every supported line has its own prerelease branch, because npm's
72
+ // prerelease rule only admits a version when a comparator in the SAME [major,minor,patch] tuple also
73
+ // carries a prerelease. 0.2.0 joined the window with the DSH 0.2.0 adaptation.
74
+ for (const v of ['0.1.2-alpha.4', '0.1.2-rc.1', '0.1.3-alpha.2', '0.1.5-rc.1', '0.1.5-rc.2', '0.1.6-alpha.2', '0.1.7-rc.2', '0.2.0-rc.1', '0.2.0-rc.2']) {
72
75
  eq(satisfiesDshRange(v, pkg.engines.dsh), true, 'own range admits ' + v + ' (default semantics)')
73
76
  eq(satisfiesDshRange(v, pkg.engines.dsh, { includePrerelease: true }), true, 'own range admits ' + v + ' (includePrerelease)')
74
77
  }
75
- for (const v of ['0.1.1-rc.1', '0.2.0-rc.1', '0.2.0', '1.0.0']) {
78
+ for (const v of ['0.2.0']) {
79
+ eq(satisfiesDshRange(v, pkg.engines.dsh, { includePrerelease: true }), true, 'own range admits the released ' + v)
80
+ }
81
+ for (const v of ['0.1.1-rc.1', '0.3.0-rc.1', '0.3.0', '1.0.0']) {
76
82
  eq(satisfiesDshRange(v, pkg.engines.dsh, { includePrerelease: true }), false, 'own range rejects ' + v)
77
83
  }
78
84
  // unsupported forms are UNKNOWN (null) — reported, never asserted incompatible
@@ -88,7 +94,7 @@ eq(satisfiesDshRange('not-a-version', pkg.engines.dsh), null, 'an unparseable ho
88
94
  const a = dshVersionVerdict('0.1.5-rc.2', pkg)
89
95
  ok(a.status === 'compatible' && a.basis === 'engines', 'our own host line is compatible via the declared range', JSON.stringify(a))
90
96
  eq(a.requirement, pkg.engines.dsh, 'the verdict surfaces the declared range (so the log and the market card say the same thing)')
91
- const b = dshVersionVerdict('0.2.0', pkg)
97
+ const b = dshVersionVerdict('0.3.0', pkg)
92
98
  ok(b.status === 'incompatible' && b.basis === 'engines', 'a host outside the declared range is incompatible via the range', JSON.stringify(b))
93
99
  const c = dshVersionVerdict('0.1.0-rc.6', pkg)
94
100
  ok(c.status === 'incompatible', 'a host below the declared floor is incompatible', JSON.stringify(c))
@@ -0,0 +1,185 @@
1
+ // ============================================================================================
2
+ // PRESET DECLARATIONS — the bundle patch that delivers the four presets on the CURRENT DSH line.
3
+ //
4
+ // The defect class this guards (2.4.0 adaptation to DSH 0.2.0): a preset that is present in the
5
+ // package but invisible to the host. Three concrete ways that happened while adapting:
6
+ //
7
+ // 1. `dsh.bundle.patch` as an ARRAY. DSH >= 0.1.7 accepts a list of patch files, but 0.1.5/0.1.6
8
+ // pass the value straight into `path.join`, so an array aborts the whole profile boot with
9
+ // ERR_INVALID_ARG_TYPE. One patch file is valid on every line.
10
+ // 2. A declaration row naming `@deepseek-ai/dsh-agent-preset`. That package exists only from 0.1.7,
11
+ // so on the older line the row fails to import and is reported as an activation failure on every
12
+ // boot. The rows name this package's own `preset-declaration` module instead, which resolves on
13
+ // both lines and registers only where the `agentPresets` service exists.
14
+ // 3. A `disabled: !!js` gate on the declaration row. Measured on DSH 0.2.0-rc.2: an expression that
15
+ // CALLS `ctx.get(...)` throws inside the loader's patch evaluation, the entry never initialises,
16
+ // and the preset silently never registers (`dsh: warning: N entries did not activate`). Constants
17
+ // and `typeof ctx` work; service lookups do not — so there is no such gate anywhere.
18
+ //
19
+ // It also pins the SHARED SOURCE OF TRUTH: cordis.patch.yml is generated from the frozen 0.1.x
20
+ // compositions, so a hand edit to either side fails here instead of drifting.
21
+ //
22
+ // Usage: node tests/audit-preset-rows.test.mjs
23
+ // ============================================================================================
24
+ import { readFileSync, existsSync } from 'node:fs'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { join, dirname, resolve } from 'node:path'
27
+ import { buildPatch } from '../scripts/build-preset-rows.mjs'
28
+
29
+ const HERE = fileURLToPath(new URL('./', import.meta.url))
30
+ const REPO = resolve(HERE, '..')
31
+ const pkg = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf8'))
32
+ const patch = readFileSync(join(REPO, 'cordis.patch.yml'), 'utf8')
33
+ const VERSIONS = ['v2', 'v3', 'v4', 'v5']
34
+
35
+ let passed = 0, failed = 0
36
+ const failures = []
37
+ function ok(cond, label, detail) {
38
+ if (cond) { passed++; console.log(' ok ' + label); return true }
39
+ failed++; failures.push(label + (detail ? ' — ' + detail : ''))
40
+ console.error(' FAIL ' + label + (detail ? ' — ' + detail : ''))
41
+ return false
42
+ }
43
+ const at = (rel) => join(REPO, rel)
44
+
45
+ // ---- 1. the generated patch is in sync with its generator ------------------------------------
46
+ ok(buildPatch() === patch, 'cordis.patch.yml is exactly what scripts/build-preset-rows.mjs generates',
47
+ 'hand-edited? regenerate with: node scripts/build-preset-rows.mjs')
48
+
49
+ // ---- 2. bundle patch declaration --------------------------------------------------------------
50
+ ok(typeof pkg.dsh.bundle.patch === 'string' && pkg.dsh.bundle.patch === './cordis.patch.yml',
51
+ 'dsh.bundle.patch is the single patch file as a STRING (an array breaks the 0.1.5/0.1.6 boot)',
52
+ JSON.stringify(pkg.dsh.bundle.patch))
53
+ ok(pkg.files.includes('cordis.patch.yml') && pkg.files.includes('scripts/build-preset-rows.mjs')
54
+ && pkg.files.includes('preset-declaration.js'),
55
+ 'the patch, its generator and the declaration module all ship in package.json files')
56
+
57
+ // ---- 3. rows ----------------------------------------------------------------------------------
58
+ ok(/^ {4}- id: vibe-math-preset-installer$/m.test(patch)
59
+ && /^ {6}name: dsh-vibe-math\/installer$/m.test(patch),
60
+ 'the installer row is inserted first')
61
+ const declarationRows = [...patch.matchAll(/^ {4}- id: preset-vibe-math-(v\d)$/gm)].map((m) => m[1])
62
+ ok(declarationRows.join(',') === VERSIONS.join(','),
63
+ 'one declaration row per preset, in order', declarationRows.join(','))
64
+ ok((patch.match(/name: dsh-vibe-math\/preset-declaration/g) || []).length === 4,
65
+ 'every declaration names this package\'s own preset-declaration module')
66
+ ok(!patch.includes("name: '@deepseek-ai/dsh-agent-preset'"),
67
+ 'no row names @deepseek-ai/dsh-agent-preset (that package does not exist on DSH <= 0.1.6)')
68
+ ok(!/^ {4,6}disabled:/m.test(patch),
69
+ 'no `disabled: !!js` gate on a DECLARATION row (an expression calling ctx.get() throws in patch evaluation and the row never loads; the nested platform gates on tool-bash/tool-pwsh inside a composition are constants and fine)')
70
+
71
+ // ---- 4. every declaration's composition -------------------------------------------------------
72
+ const IDS = { v2: '20', v3: '21', v4: '22', v5: '23' }
73
+ for (const v of VERSIONS) {
74
+ const start = patch.indexOf('- id: preset-vibe-math-' + v)
75
+ const next = patch.indexOf('\n- insert:', start)
76
+ const body = patch.slice(start, next === -1 ? undefined : next)
77
+ const need = [
78
+ ['declared preset id', new RegExp('^ {8}id: vibe-math-' + v + '$', 'm')],
79
+ ['roster order', new RegExp('^ {8}order: ' + IDS[v] + '$', 'm')],
80
+ ['persona prefix', /^ {14}prefix: \|-/m],
81
+ ['no legacy persona.text key', /^(?![\s\S]*^ {14}text:)/m],
82
+ ['agent-instructions row', /name: '@deepseek-ai\/dsh-agent-instructions'/],
83
+ ['plan-mode inside an isolate realm', /isolate:\n\s+planMode: true/],
84
+ ['compaction isolate realm', /isolate:\n\s+compaction: true\n\s+toolResultPruner: true/],
85
+ ['delegation isolate realm', /isolate:\n\s+workflowEngine: true/],
86
+ ['the 0.1.7+ workflow engine', /name: '@deepseek-ai\/dsh-workflow-ptc'/],
87
+ ['present row (deliverable cards)', /id: present\n\s+name: '@deepseek-ai\/dsh-tool-present'/],
88
+ ['command-goal row', /id: command-goal\n\s+name: '@deepseek-ai\/dsh-command-goal'/],
89
+ ['framework row by package subpath', new RegExp("name: 'dsh-vibe-math/vibe-math-" + v + "/vibe-math-" + v + "\\.js'")],
90
+ ['framework row NOT a relative path', new RegExp("(?!name: '\\./vibe-math-" + v + "\\.js')")],
91
+ ]
92
+ const missing = need.filter(([, re]) => !re.test(body)).map(([label]) => label)
93
+ ok(missing.length === 0, v + ': the composition carries every required row', 'missing: ' + missing.join(', '))
94
+ ok(!body.includes('workflow-worker-thread'), v + ': the removed worker-thread engine is not referenced')
95
+ }
96
+
97
+ // ---- 4b. the declaration module itself ---------------------------------------------------------
98
+ // It is the only piece of code in this mechanism, so its two behaviours are pinned directly:
99
+ // register exactly the row's declaration where the service exists, and do NOTHING (no throw, no call)
100
+ // where it does not — the older DSH line reads the directory instead and must boot silently.
101
+ {
102
+ const declaration = await import('../preset-declaration.js')
103
+ const CONFIG = { id: 'x', name: 'X', description: 'd', order: 7, plugins: [{ id: 'p', name: '@deepseek-ai/dsh-persona' }] }
104
+ const makeCtx = (service) => {
105
+ const calls = []
106
+ const warnings = []
107
+ const ctx = {
108
+ calls, warnings,
109
+ logger: { warn: (m) => warnings.push(String(m)) },
110
+ get: (n) => (n === 'agentPresets' ? service : undefined),
111
+ effect: (fn) => { const d = fn(); ctx.disposers.push(d); return () => { if (typeof d === 'function') d() } },
112
+ disposers: [],
113
+ inject: (deps, cb) => cb(ctx),
114
+ }
115
+ return ctx
116
+ }
117
+ const settle = () => new Promise((r) => setTimeout(r, 0))
118
+
119
+ // (a) service present → the row's declaration reaches register()
120
+ {
121
+ let seen
122
+ const ctx = makeCtx({ register: async (def) => { seen = def; return async () => {} } })
123
+ declaration.apply(ctx, CONFIG)
124
+ await settle()
125
+ ok(seen !== undefined && seen.id === 'x' && seen.order === 7 && Array.isArray(seen.plugins) && seen.plugins.length === 1,
126
+ 'preset-declaration: the row config is handed to agentPresets.register() unchanged')
127
+ }
128
+ // (b) service absent (DSH <= 0.1.6) → no call, no throw (the directory form is the mechanism there)
129
+ {
130
+ const ctx = makeCtx(undefined)
131
+ let threw = false
132
+ try { declaration.apply(ctx, CONFIG); await settle() } catch (e) { threw = true }
133
+ ok(!threw && ctx.warnings.length === 0, 'preset-declaration: on a host without the service it registers nothing and stays silent')
134
+ }
135
+ // (c) a duplicate id (the user saved their own declaration) → reported once, never thrown
136
+ {
137
+ const ctx = makeCtx({ register: async () => { throw new Error('Duplicate agent preset: x') } })
138
+ let threw = false
139
+ try { declaration.apply(ctx, CONFIG); await settle() } catch (e) { threw = true }
140
+ ok(!threw && ctx.warnings.length === 1 && /Duplicate agent preset/.test(ctx.warnings[0]),
141
+ 'preset-declaration: a duplicate id is logged once (the profile declaration wins) and never breaks the boot',
142
+ ctx.warnings.join(' | '))
143
+ }
144
+ // (d) no ctx.inject (an older Cordis) → the direct path still registers
145
+ {
146
+ let seen
147
+ const ctx = makeCtx({ register: async (def) => { seen = def; return async () => {} } })
148
+ delete ctx.inject
149
+ declaration.apply(ctx, CONFIG)
150
+ await settle()
151
+ ok(seen !== undefined, 'preset-declaration: without ctx.inject it registers directly')
152
+ }
153
+ // (e) a row with no config must not throw
154
+ {
155
+ let threw = false
156
+ try { declaration.apply(makeCtx(undefined), undefined); declaration.apply(makeCtx(undefined), null) } catch (e) { threw = true }
157
+ ok(!threw, 'preset-declaration: a missing config is ignored instead of throwing')
158
+ }
159
+ }
160
+
161
+ // ---- 5. module resolution ---------------------------------------------------------------------
162
+ for (const v of VERSIONS) {
163
+ const sub = './vibe-math-' + v + '/vibe-math-' + v + '.js'
164
+ ok(pkg.exports[sub] === sub && existsSync(at(sub)),
165
+ v + ': the framework plugin is exported as ' + sub)
166
+ ok(pkg.files.includes('vibe-math-' + v + '/vibe-math-' + v + '.js'),
167
+ v + ': the framework plugin ships')
168
+ }
169
+ ok(pkg.exports['./preset-declaration'] === './preset-declaration.js' && existsSync(at('preset-declaration.js')),
170
+ 'preset-declaration.js is exported and present')
171
+ ok(pkg.files.includes('preset-declaration.js'), 'preset-declaration.js ships')
172
+
173
+ // ---- 6. the frozen 0.1.x compositions stay the field-tested shape -----------------------------
174
+ for (const v of VERSIONS) {
175
+ const src = readFileSync(at('vibe-math-' + v + '/agent.cordis.yml'), 'utf8')
176
+ ok(src.includes("name: '@deepseek-ai/dsh-workflow-worker-thread'"),
177
+ v + ': the frozen composition still uses the worker-thread engine (what <= 0.1.6 ships)')
178
+ ok(/^ {4}text: \|-/m.test(src) || /^ {2}text: \|-/m.test(src),
179
+ v + ': the frozen composition still carries the legacy persona.text key (DSH <= 0.1.2 needs it)')
180
+ }
181
+
182
+ console.log('')
183
+ console.log('=== PRESET DECLARATIONS: ' + passed + ' passed, ' + failed + ' failed ===')
184
+ if (failed) { for (const f of failures) console.error(' - ' + f); process.exit(1) }
185
+ console.log('ALL GREEN')
@@ -142,6 +142,34 @@ else {
142
142
  }
143
143
 
144
144
  // ---- 3. session-API surface used by tool handlers -----------------------
145
+ // `s` is not always the session API object. The toolFilter sanitiser added for the v2/v3/v4
146
+ // parity guard contains `.map(function(s){ return s.trim() })` — there `s` is a LOCAL string
147
+ // parameter, and a flat `s.NAME(` scan reported the phantom finding "calls s.trim() but the
148
+ // session API does not export it". A false FINDING is as corrosive as a blind spot (it trains
149
+ // the reader to ignore the audit), so the scan now respects the ONE shadowing form the plugin
150
+ // actually uses: a plain `function (...)` whose parameter list binds `s`.
151
+ //
152
+ // This is scope analysis, not an allowlist: any `s.X()` inside such a body CANNOT be a call on
153
+ // the enclosing session object — the parameter shadows it. The tool handlers themselves are
154
+ // ARROW functions (`(s, a, x) => s.…)`), so their `s` IS the session API and stays scanned; the
155
+ // self-check at the bottom of this file proves the skip does not blind those.
156
+ function localSParamBodySpans(text) {
157
+ const spans = []
158
+ const re = /function\s*(?:[A-Za-z_$][\w$]*)?\s*\(([^)]*)\)\s*\{/g
159
+ let m
160
+ while ((m = re.exec(text)) !== null) {
161
+ const params = m[1].split(',').map((p) => p.trim().replace(/[={].*$/s, '').trim())
162
+ if (params.indexOf('s') === -1) continue
163
+ let i = m.index + m[0].length - 1 // sitting on the opening '{'
164
+ let depth = 0
165
+ for (; i < text.length; i++) {
166
+ if (text[i] === '{') depth++
167
+ else if (text[i] === '}') { depth--; if (depth === 0) break }
168
+ }
169
+ spans.push([m.index, i])
170
+ }
171
+ return spans
172
+ }
145
173
  // The returned API object is the last `return { ... }` inside makeSession.
146
174
  const apiStart = src.lastIndexOf(' return {\n sessionId,')
147
175
  if (apiStart === -1) findings.push('could not locate the session API return object')
@@ -151,14 +179,19 @@ else {
151
179
  const apiKeys = new Set()
152
180
  for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*:/g)) apiKeys.add(m[1])
153
181
  for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*,/g)) apiKeys.add(m[1])
182
+ const localScopes = localSParamBodySpans(src)
183
+ const inLocalScope = (idx) => localScopes.some(([a, b]) => idx >= a && idx <= b)
154
184
  const usedOnS = new Map()
185
+ let shadowed = 0
155
186
  for (const m of src.matchAll(/(?<![\w$.])s\.([A-Za-z_$][\w$]*)\s*\(/g)) {
187
+ if (inLocalScope(m.index)) { shadowed++; continue }
156
188
  if (!usedOnS.has(m[1])) usedOnS.set(m[1], lineOf(m.index))
157
189
  }
158
190
  for (const [k, line] of usedOnS) {
159
191
  if (!apiKeys.has(k)) findings.push('line ' + line + ': tool handler calls s.' + k + '() but the session API does not export it')
160
192
  }
161
- notes.push('session API keys: ' + apiKeys.size + '; s.*() called: ' + usedOnS.size)
193
+ notes.push('session API keys: ' + apiKeys.size + '; s.*() called: ' + usedOnS.size +
194
+ (shadowed ? '; skipped ' + shadowed + ' inside a local `function(s)` scope (a shadowing parameter, not the session API)' : ''))
162
195
  }
163
196
 
164
197
  // ---- 4. error codes ----------------------------------------------------
@@ -333,7 +366,7 @@ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5
333
366
  ['reusable definitions live in the GLOBAL cross-project library', 'const okWrite = await writeTextAbs(instRootless(rel), body)'],
334
367
  ['the Lean path guard normalises .. (no string-prefix traversal hole)', 'const norm = normalizeAbsPath(abs)'],
335
368
  ['a missing toolchain degrades to a readable result, not a crash', "code: 'LEAN_NOT_FOUND'"],
336
- ['the formal record is durable projection state', "formal: 'vibe5/formal',"],
369
+ ['the formal record is durable state (folded into State/<institute>.v5state.json)', "formal: 'vibe5/formal',"],
337
370
  ['the state block advertises the formalization counts', "b.push('[形式化] ' + (formalMode()"],
338
371
  ['the reply contract carries the difficulty judgement', "L.push(' \"formal\": {"],
339
372
  ]
@@ -428,7 +461,20 @@ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5
428
461
  const stripped = stripNoise(fixture)
429
462
  if (stripped.includes('phantom_call')) findings.push('stripNoise() left a comment in the code stream (a phantom call site would be reported)')
430
463
  if (!/'/.test(stripped)) findings.push('stripNoise() dropped a value placeholder')
431
- notes.push('scanner self-check: stripped output parses=' + (r.status === 0) + '; quoted-class fixture=' + (!stripped.includes('phantom_call')))
464
+ // The session-API scan must ignore a SHADOWING `function(s)` parameter while still seeing the
465
+ // real arrow handlers. Without this, either the false `s.trim()` finding comes back or the
466
+ // scope skip silently blinds every tool-handler call — both are failures worth failing on.
467
+ const scopeFixture = [
468
+ 'const names = m[1].split(",").map(function(s){ return s.trim() })', // local string param
469
+ 'registerTool("t", "d", {}, (s, a) => s.resume())', // the session API
470
+ ].join('\n')
471
+ const spans = localSParamBodySpans(scopeFixture)
472
+ const idxShadow = scopeFixture.indexOf('s.trim()')
473
+ const idxApi = scopeFixture.indexOf('s.resume()')
474
+ if (!spans.some(([a, b]) => idxShadow >= a && idxShadow <= b)) findings.push('the session-API scan would report a PHANTOM finding for a shadowing `function(s)` parameter (s.trim())')
475
+ if (spans.some(([a, b]) => idxApi >= a && idxApi <= b)) findings.push('the session-API scan would SKIP a real arrow tool handler (s.resume() was classified as shadowed)')
476
+ notes.push('scanner self-check: stripped output parses=' + (r.status === 0) + '; quoted-class fixture=' + (!stripped.includes('phantom_call')) +
477
+ '; shadowed-param skip=' + spans.length)
432
478
  } finally {
433
479
  rmSync(tmp, { recursive: true, force: true })
434
480
  }
@@ -1,6 +1,6 @@
1
1
  // ============================================================
2
2
  // V5 ROUND-2 E2E — the behaviour paths the first suite never exercised:
3
- // · the hardened JSON FALLBACK backend (host without sessionProjections)
3
+ // · the hardened JSON backend (the ONLY backend: `makeFileBackend`/`installBackend`)
4
4
  // · a simulated PROCESS RESTART (fresh agent + fresh backend over the same workspace)
5
5
  // · duplicate subagent/end idempotence
6
6
  // · hire quotas (per-member and institute-wide)
@@ -13,7 +13,7 @@
13
13
  // · the verification watchdog abandoning a stuck round
14
14
  // · meeting PARKED behind an in-flight verification, then resumed
15
15
  // · compaction/keepalive directives appear only when they should
16
- // · session-log REPLAY reproduces the institute state in a new session
16
+ // · the host session log is NEVER written, and the state reloads through the JSON file
17
17
  // Run: node tests/e2e-v5-round2.test.mjs
18
18
  // ============================================================
19
19
  import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
@@ -32,30 +32,12 @@ const failures = []
32
32
  const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
33
33
  const sleep = ms => new Promise(r => setTimeout(r, ms))
34
34
 
35
- function makeProjectionRegistry() {
36
- const units = new Map()
37
- const cells = new Map()
38
- const cellMap = (sess) => { const id = String(sess.id); let m = cells.get(id); if (!m) { m = new Map(); cells.set(id, m) } return m }
39
- return {
40
- register(def) { units.set(def.key, def); return () => { units.delete(def.key) } },
41
- stateOf(session, key) {
42
- const def = units.get(key); if (!def) return undefined
43
- const m = cellMap(session)
44
- if (!m.has(key)) m.set(key, def.init(session.header, 0))
45
- return m.get(key)
46
- },
47
- checkpoint(session) { const o = {}; const m = cellMap(session); for (const [k, d] of units) o[k] = { ver: d.stateVersion, seq: session.seq, val: m.get(k) }; return o },
48
- _drive(session, event) {
49
- const m = cellMap(session)
50
- for (const [k, def] of units) {
51
- const cur = m.has(k) ? m.get(k) : def.init(session.header, 0)
52
- let next; try { next = def.apply(cur, event) } catch (e) { next = cur }
53
- m.set(k, next)
54
- }
55
- },
56
- }
57
- }
58
-
35
+ // The host contract v5 needs has NO session services at all: the plugin declares
36
+ // `inject = ['subagents','agents','fs','tools','commands','timer']` and keeps its state in a
37
+ // hardened JSON file. `Session.append` cannot mark an event `ignorable`, and DSH's session
38
+ // persistence refuses to load a log carrying an unknown event type — so a host that offers
39
+ // `sessionProjections` must still never receive an institute event. Every session created
40
+ // here records whatever it is given, so the suites can assert that NOTHING was appended.
59
41
  function makeSession(id, parentSession) {
60
42
  const events = []
61
43
  const s = {
@@ -63,27 +45,28 @@ function makeSession(id, parentSession) {
63
45
  header: { version: 1, id, createdAt: Date.now(), cwd: null, parentSession, isSeeded: false },
64
46
  inheritedEventCount: 0,
65
47
  get seq() { return events.length },
66
- append(type, data) { const ev = { type, data, seq: events.length, time: Date.now() }; events.push(ev); this._drive(ev); return ev },
48
+ append(type, data) { const ev = { type, data, seq: events.length, time: Date.now() }; events.push(ev); return ev },
67
49
  deriveMessages() { return [] },
68
50
  snapshotEvents(from) { return events.slice(from || 0) },
69
51
  ownEvents() { return events.slice() },
70
52
  _events: events,
71
- _drive: null,
72
53
  }
73
54
  return s
74
55
  }
75
56
 
76
- /** A fresh host+plugin instance. `withProjections:false` exercises the JSON fallback. */
57
+ /** A fresh host+plugin instance over a workspace (a fresh temp dir by default). */
77
58
  function makeHost(opts) {
78
59
  const o = opts || {}
79
60
  const WS = o.ws || mkdtempSync(join(tmpdir(), 'vibe-v5r2-'))
80
- const projections = o.withProjections === false ? undefined : makeProjectionRegistry()
81
61
  const listeners = {}, toolRegs = [], commandRegs = [], spawns = [], wakes = [], interrupts = [], drains = []
82
62
  const liveAgents = new Map()
63
+ // The two services the fix removed. This host does not provide them, and records every
64
+ // request so the suite can assert the plugin never even LOOKS for them any more.
65
+ const removedServiceQueries = []
83
66
 
84
67
  const ctx = {
85
68
  get(name) {
86
- if (name === 'sessionProjections') return projections
69
+ if (name === 'sessions' || name === 'sessionProjections') removedServiceQueries.push(name)
87
70
  if (name === 'sandboxPolicy') return undefined
88
71
  if (name === 'compaction') return o.compaction
89
72
  if (name === 'subprocess') {
@@ -106,7 +89,6 @@ function makeHost(opts) {
106
89
  timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
107
90
  tools: { register(spec) { toolRegs.push(spec); return () => {} } },
108
91
  commands: { register(spec) { commandRegs.push(spec); return () => {} } },
109
- sessions: { async flush() { return true } },
110
92
  subagents: {
111
93
  list() { return ['spawn'] },
112
94
  async startContinuable({ label, request }) {
@@ -114,7 +96,6 @@ function makeHost(opts) {
114
96
  const childSession = makeSession(id, 'sess-A'); childSession.header.cwd = WS
115
97
  liveAgents.set(id, { id, session: childSession, options: request && request.agentOptions })
116
98
  spawns.push({ label, request, childId: id, persona: request && request.persona, ended: false })
117
- if (projections) childSession._drive = (ev) => projections._drive(childSession, ev)
118
99
  return { childId: id, messageId: 'm' + spawns.length }
119
100
  },
120
101
  async sendMessage(parent, childId, blocks) { wakes.push({ childId, blocks }); return 'w' + wakes.length },
@@ -138,7 +119,6 @@ function makeHost(opts) {
138
119
  const ROOT_SESSION = makeSession('sess-A', undefined)
139
120
  ROOT_SESSION.header.cwd = WS
140
121
  const ROOT = { id: 'sess-A', options: {}, session: ROOT_SESSION, ctx: undefined }
141
- if (projections) ROOT_SESSION._drive = (ev) => projections._drive(ROOT_SESSION, ev)
142
122
 
143
123
  const mod = o.pluginModule
144
124
  mod.apply(ctx)
@@ -211,48 +191,70 @@ function makeHost(opts) {
211
191
  }
212
192
  return null
213
193
  }
214
- return { WS, ctx, ROOT, ROOT_SESSION, projections, spawns, wakes, interrupts, drains, toolRegs, commandRegs, listeners, callTool, childAgent, fireEnd, spawnOf, childOf, labelOf, kindOf, settleSpawns, drain, peekWakeOf, set plannedVotes(v) { plannedVotes = v }, get plannedVotes() { return plannedVotes }, set solvePlan(v) { solvePlan = v } }
194
+ return { WS, ctx, ROOT, ROOT_SESSION, removedServiceQueries, spawns, wakes, interrupts, drains, toolRegs, commandRegs, listeners, callTool, childAgent, fireEnd, spawnOf, childOf, labelOf, kindOf, settleSpawns, drain, peekWakeOf, set plannedVotes(v) { plannedVotes = v }, get plannedVotes() { return plannedVotes }, set solvePlan(v) { solvePlan = v } }
215
195
  }
216
196
 
217
197
  const pluginModule = await import(PLUGIN.href + '?t=' + Date.now())
218
198
  const PROBLEM = '证明素数有无穷多个'
219
199
 
200
+ // The durable authority is the hardened JSON file. A commit is applied to the in-memory
201
+ // snapshot immediately but written through a deferred per-file chain, so a test that wants to
202
+ // observe "this was committed" must poll the FILE — reading the session log is no longer possible
203
+ // (and is exactly what the fix removed).
204
+ const statePathOf = (ws) => join(ws, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
205
+ const readStateAt = (ws) => { try { return JSON.parse(readFileSync(statePathOf(ws), 'utf8')) } catch (e) { return null } }
206
+ const instOfState = (s) => (s && s.institutes) ? s.institutes['default::institute'] : undefined
207
+ async function waitInst(ws, pred) {
208
+ let inst = instOfState(readStateAt(ws))
209
+ for (let i = 0; i < 60; i++) {
210
+ if (inst && (!pred || pred(inst))) return inst
211
+ await sleep(10)
212
+ inst = instOfState(readStateAt(ws))
213
+ }
214
+ return inst
215
+ }
216
+
220
217
  // ============================================================
221
218
  console.log('-- V5 round-2 e2e --')
222
219
 
223
- // ---------- 1. the hardened JSON FALLBACK backend ----------
224
- console.log('\n[1] fallback backend (host without sessionProjections)')
220
+ // ---------- 1. the hardened JSON backend (the only one) ----------
221
+ console.log('\n[1] the hardened JSON backend (host with no session services at all)')
225
222
  {
226
- const h = makeHost({ pluginModule, withProjections: false })
223
+ const h = makeHost({ pluginModule })
227
224
  const st = await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
228
- assert(st.ok === true, 'start works without sessionProjections (fallback path)')
225
+ assert(st.ok === true, 'start works on a host with no sessions/sessionProjections service (got ' + JSON.stringify(st).slice(0, 90) + ')')
226
+ assert(Array.isArray(pluginModule.inject) && pluginModule.inject.indexOf('sessions') === -1 && pluginModule.inject.indexOf('sessionProjections') === -1,
227
+ '★ the plugin declares no sessions/sessionProjections dependency (inject=' + JSON.stringify(pluginModule.inject) + ')')
228
+ assert(h.removedServiceQueries.length === 0,
229
+ '★ the plugin never even resolves the removed services (queries=' + JSON.stringify(h.removedServiceQueries) + ')')
229
230
  const s0 = await h.callTool('vibe_v5_status', {})
230
231
  assert(s0.backend === 'file', 'status reports the file backend (got ' + s0.backend + ')')
231
232
  await h.settleSpawns()
232
233
  const stFile = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
233
234
  // the state file is written lazily (deferred stringify chain) — give it a tick
234
235
  for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
235
- assert(existsSync(stFile), 'the fallback persisted the institute state to State/institute.v5state.json')
236
+ assert(existsSync(stFile), 'the backend persisted the institute state to State/institute.v5state.json')
236
237
  if (existsSync(stFile)) {
237
238
  const parsed = JSON.parse(readFileSync(stFile, 'utf8'))
238
239
  const inst = parsed.institutes['default::institute']
239
- assert(!!inst && inst.members.length === 3, 'the persisted fallback state holds 1 academician + 2 researchers (got ' + (inst ? inst.members.length : 'none') + ')')
240
+ assert(!!inst && inst.members.length === 3, 'the persisted state holds 1 academician + 2 researchers (got ' + (inst ? inst.members.length : 'none') + ')')
240
241
  }
242
+ assert(h.ROOT_SESSION._events.length === 0, 'the whole founding wrote NOTHING to the host session log (' + h.ROOT_SESSION._events.length + ' events)')
241
243
  }
242
244
 
243
245
  // ---------- 2. simulated PROCESS RESTART ----------
244
246
  console.log('\n[2] simulated process restart (fresh host, fresh backend, same workspace)')
245
247
  {
246
248
  const WS = mkdtempSync(join(tmpdir(), 'vibe-v5r2-restart-'))
247
- const h1 = makeHost({ pluginModule, withProjections: false, ws: WS })
249
+ const h1 = makeHost({ pluginModule, ws: WS })
248
250
  await h1.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
249
251
  await h1.settleSpawns()
250
- const stFile = join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
252
+ const stFile = statePathOf(WS)
251
253
  for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
252
254
  assert(existsSync(stFile), 'first process persisted its state')
253
255
 
254
256
  // a genuinely fresh host = new backend instance, empty in-memory state
255
- const h2 = makeHost({ pluginModule, withProjections: false, ws: WS })
257
+ const h2 = makeHost({ pluginModule, ws: WS })
256
258
  const s2 = await h2.callTool('vibe_v5_status', {})
257
259
  assert(s2.members.length === 3, 'a FRESH host reads the persisted roster back (got ' + s2.members.length + ' members)')
258
260
  const res = await h2.callTool('vibe_v5_resume', {})
@@ -267,16 +269,24 @@ console.log('\n[3] duplicate subagent/end idempotence')
267
269
  const h = makeHost({ pluginModule })
268
270
  await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
269
271
  const cid = h.childOf('r-1')
270
- // settle the founding turn normally, then REPLAY the exact same end event
271
- const before = h.ROOT_SESSION._events.length
272
+ // settle the founding turn normally, then REPLAY the exact same end event. "Committed" is
273
+ // observed where the state now lives — State/institute.v5state.json (`artifactCount` is what
274
+ // recording a card bumps) — and the host session log must stay untouched either way.
275
+ const logBefore = h.ROOT_SESSION._events.length
272
276
  h.fireEnd(cid, { progress: '唯一一次', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
273
- await sleep(40)
274
- const afterFirst = h.ROOT_SESSION._events.length
277
+ const inst1 = await waitInst(h.WS, (x) => Number(x.artifactCount) >= 1)
278
+ const afterFirst = inst1 ? Number(inst1.artifactCount) : 0
275
279
  h.fireEnd(cid, { progress: '重复投递', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
276
- await sleep(40)
277
- const afterSecond = h.ROOT_SESSION._events.length
278
- assert(afterSecond === afterFirst, 'a replayed end commits NOTHING new (' + afterFirst + ' -> ' + afterSecond + ' events)')
279
- assert(afterFirst > before, 'the first end did commit state')
280
+ await sleep(60)
281
+ const inst2 = await waitInst(h.WS)
282
+ const afterSecond = inst2 ? Number(inst2.artifactCount) : 0
283
+ assert(afterSecond === afterFirst, 'a replayed end commits NOTHING new (' + afterFirst + ' -> ' + afterSecond + ' artifacts in the v5state JSON)')
284
+ assert(afterFirst >= 1, 'the first end did commit state (artifactCount=' + afterFirst + ' in State/institute.v5state.json)')
285
+ assert(h.ROOT_SESSION._events.length === logBefore, 'neither end appended anything to the host session log (' + logBefore + ' -> ' + h.ROOT_SESSION._events.length + ' events)')
286
+ // the card itself is durable too, and the replay did not append a second progress entry
287
+ const prog = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Members', 'r-1', 'Progress', 'progress.md')
288
+ const progText = existsSync(prog) ? readFileSync(prog, 'utf8') : ''
289
+ assert(/唯一一次/.test(progText) && !/重复投递/.test(progText), 'only the FIRST end reached the member\'s progress log')
280
290
  }
281
291
 
282
292
  // ---------- 4. hire quotas ----------
@@ -492,26 +502,86 @@ console.log('\n[13] compaction directive appears only when warranted')
492
502
  if (later) h.fireEnd(later.childId, { progress: 'y', contextPct: 15 })
493
503
  }
494
504
 
495
- // ---------- 14. session-log REPLAY reproduces state ----------
496
- console.log('\n[14] session-log replay reproduces the institute in a new session')
505
+ // ---------- 14. the host session log is never written; the JSON file is the reload path ----------
506
+ console.log('\n[14] nothing is appended to the host session log; state survives a reload through the JSON file')
497
507
  {
498
508
  const h = makeHost({ pluginModule })
499
509
  await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
500
510
  await h.settleSpawns()
501
511
  const s0 = await h.callTool('vibe_v5_status', {})
502
- const events = h.ROOT_SESSION._events.filter(e => String(e.type).indexOf('vibe5/') === 0)
503
- assert(events.length > 0, 'the session log holds institute events (' + events.length + ')')
504
- // replay those events into a brand-new session and read the state through a new root
505
- const replayed = makeSession('sess-REPLAY', undefined)
506
- replayed.header.cwd = h.WS
507
- replayed._drive = (ev) => h.projections._drive(replayed, ev)
508
- for (const e of events) replayed.append(e.type, e.data)
509
- const ROOT2 = { id: 'sess-REPLAY', options: {}, session: replayed }
510
- const s1 = await h.callTool('vibe_v5_status', {}, ROOT2)
511
- assert(s1.members.length === s0.members.length, 'replay reproduced the roster (' + s1.members.length + ' vs ' + s0.members.length + ')')
512
- assert(s1.quorum.m === s0.quorum.m, 'replay reproduced the quorum')
513
- assert(s1.tasks.length === s0.tasks.length, 'replay reproduced the task board')
514
- assert(s1.institute === s0.institute && s1.project === s0.project, 'replay reproduced the institute identity')
512
+ const appended = h.ROOT_SESSION._events.filter(e => String(e.type).indexOf('vibe5/') === 0)
513
+ assert(h.ROOT_SESSION._events.length === 0 && appended.length === 0,
514
+ '★ the plugin appends NOTHING to the host session log (' + h.ROOT_SESSION._events.length + ' events): an institute event in the log would make the user session unresumable')
515
+ const stFile = statePathOf(h.WS)
516
+ for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
517
+ assert(existsSync(stFile), 'the institute state is on disk instead (State/institute.v5state.json)')
518
+ // A brand-new host over the same workspace is a genuine reload: fresh backend, empty memory.
519
+ const h2 = makeHost({ pluginModule, ws: h.WS })
520
+ const s1 = await h2.callTool('vibe_v5_status', {})
521
+ assert(s1.members.length === s0.members.length, 'the reload reproduced the roster (' + s1.members.length + ' vs ' + s0.members.length + ')')
522
+ assert(s1.quorum.m === s0.quorum.m, 'the reload reproduced the quorum')
523
+ assert(s1.tasks.length === s0.tasks.length, 'the reload reproduced the task board')
524
+ assert(s1.institute === s0.institute && s1.project === s0.project, 'the reload reproduced the institute identity')
525
+ assert(h2.ROOT_SESSION._events.length === 0, 'the reloading host also appended nothing to its session log')
526
+ }
527
+
528
+ // ---------- 15. the host's live-child cap is survived, named and not re-asked ----------
529
+ // DSH ≥0.2 caps the number of LIVE continuable children per ROOT agent (ActivationPool.reserve,
530
+ // capacity `maxActiveSubagents` on the `subagent` row, default 8; the throw is NOT in the .d.ts).
531
+ // The mock below refuses exactly like the host once the cap is full and frees a slot when a child is
532
+ // interrupted — the two host facts the preset has to survive. NOTE: the preset remembers the ceiling
533
+ // at MODULE scope (a host fact), and every host in this file shares that module, so this case MUST
534
+ // stay the LAST one.
535
+ console.log('\n[15] the host live-child cap (ACTIVATION_LIMIT_REACHED) is named, remembered and not re-asked')
536
+ {
537
+ const h = makeHost({ pluginModule })
538
+ const CAP = 2
539
+ let live = 0
540
+ let hostCalls = 0
541
+ const capError = () => {
542
+ const e = new Error('subagent limit reached (active child limit: ' + CAP + '); wait for an existing child to finish or complete this work with the current agents')
543
+ e.code = 'ACTIVATION_LIMIT_REACHED'
544
+ return e
545
+ }
546
+ const origStart = h.ctx.subagents.startContinuable
547
+ h.ctx.subagents.startContinuable = async function (spec) {
548
+ hostCalls += 1
549
+ if (live >= CAP) throw capError()
550
+ live += 1
551
+ return await origStart.call(this, spec)
552
+ }
553
+ const origInterrupt = h.ctx.subagents.interrupt
554
+ h.ctx.subagents.interrupt = function (childId) { live = Math.max(0, live - 1); return origInterrupt.call(this, childId) }
555
+ const errs = []
556
+ const origErr = console.error
557
+ console.error = function () { errs.push(Array.prototype.join.call(arguments, ' ')) }
558
+ let started
559
+ try {
560
+ started = await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 4, academician: false })
561
+ } finally { console.error = origErr }
562
+ assert(started.ok === true, 'the institute still founds a partial team when the host refuses members (' + JSON.stringify(started).slice(0, 140) + ')')
563
+ assert(h.spawns.length === CAP, 'only the members the host accepted were created (spawns=' + h.spawns.length + ')')
564
+ const capLines = errs.filter(l => /maxActiveSubagents/.test(l))
565
+ assert(capLines.length === 1, 'ONE actionable line for the whole founding round, not one per refused member (got ' + capLines.length + ')')
566
+ assert(capLines.length === 1 && /ACTIVATION_LIMIT_REACHED/.test(capLines[0]),
567
+ 'the line names the host ceiling and the parameter that raises it: ' + String(capLines[0]).slice(0, 160))
568
+ const st = await h.callTool('vibe_v5_status', {})
569
+ const refused = st.members.filter(m => m.phase === 'failed')
570
+ assert(refused.length === 2, 'the refused members are recorded as failed, not left active (got ' + refused.length + ')')
571
+ assert(refused.every(m => /maxActiveSubagents/.test(m.error)), 'each failed record carries the cap, not the opaque host string: ' + JSON.stringify(refused.map(m => m.error)).slice(0, 200))
572
+ assert(refused.every(m => m.busy !== true), 'no refused member is left marked busy')
573
+ // Once the ceiling is KNOWN, a refused provisioning must not even reach the host.
574
+ const callsBefore = hostCalls
575
+ const hired = await h.callTool('vibe_v5_hire', { purpose: '被上限拒绝', initial_task: 'x' }, h.childAgent(h.childOf('r-1')))
576
+ assert(hired.ok === false && hired.code === 'ACTIVATION_LIMIT_REACHED', 'hire reports the host cap by its typed code (' + JSON.stringify(hired).slice(0, 160) + ')')
577
+ assert(/maxActiveSubagents/.test(hired.message || ''), 'the hire failure names maxActiveSubagents, not the opaque host string')
578
+ assert(hostCalls === callsBefore, 'the host was NOT asked again once the ceiling was known (skip-before-spawn; hostCalls=' + hostCalls + ')')
579
+ // The refused work is QUEUED, not lost: freeing a slot (fire a member) lets the next round retry.
580
+ await h.callTool('vibe_v5_fire', { id: 'r-1', reason: 'cap test' })
581
+ const resumed = await h.callTool('vibe_v5_resume', {})
582
+ assert(resumed.ok === true && resumed.respawned >= 1, 'a slot freed by firing a member lets resume rebuild a capped member (respawned=' + (resumed && resumed.respawned) + ')')
583
+ const st2 = await h.callTool('vibe_v5_status', {})
584
+ assert(st2.members.filter(m => m.phase === 'active' && m.childId).length === CAP, 'the institute is back at the host ceiling (active=' + st2.members.filter(m => m.phase === 'active').map(m => m.id).join(',') + ')')
515
585
  }
516
586
 
517
587
  console.log('')