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.
Files changed (114) hide show
  1. package/README.en.md +682 -690
  2. package/README.md +19 -27
  3. package/cordis.patch.yml +112 -16
  4. package/docs/AUDIT-CHECKLIST.md +744 -518
  5. package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/final-paper.md +172 -172
  8. package/docs/formal-verification.md +588 -588
  9. package/docs/math-computation.md +194 -163
  10. package/docs/parameter-schema.md +37 -0
  11. package/docs/release-notes/README.en.md +49 -0
  12. package/docs/release-notes/README.md +48 -0
  13. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +72 -0
  14. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +72 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  16. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  17. package/docs/release-notes/TEMPLATE.md +126 -0
  18. package/docs/status-report-fields.md +167 -0
  19. package/docs/test-timing.md +157 -154
  20. package/installer.js +27 -17
  21. package/package.json +55 -3
  22. package/prompt-corpus-persona/persona-corpus.json +9 -9
  23. package/prompt-corpus-persona/persona-corpus.md +225 -33
  24. package/prompt-corpus-v2/formal-verify-v2.json +305 -113
  25. package/prompt-corpus-v2/formal-verify-v2.md +2399 -279
  26. package/prompt-corpus-v3/formal-verify-v3.json +134 -110
  27. package/prompt-corpus-v3/formal-verify-v3.md +409 -141
  28. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  29. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  30. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  31. package/prompt-corpus-v5/prompt-corpus-v5.md +513 -259
  32. package/scripts/update-doc-counts.mjs +71 -0
  33. package/tests/audit-artifact-docs.mjs +72 -0
  34. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  35. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  36. package/tests/audit-installer-compat.test.mjs +52 -4
  37. package/tests/audit-installer-policy.test.mjs +41 -8
  38. package/tests/audit-math-computation-contract.mjs +107 -9
  39. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  40. package/tests/audit-math-computation-parity.mjs +164 -3
  41. package/tests/audit-math-computation-sensitivity.mjs +6 -4
  42. package/tests/audit-package-membership.mjs +57 -0
  43. package/tests/audit-package-membership.mutants.mjs +28 -0
  44. package/tests/audit-participant-set-parity.mjs +160 -0
  45. package/tests/audit-path-discipline.mjs +83 -0
  46. package/tests/audit-path-discipline.mutants.mjs +51 -0
  47. package/tests/audit-persona-sensitivity.mjs +14 -0
  48. package/tests/audit-persona-surface.mutants.mjs +69 -0
  49. package/tests/audit-persona-surface.test.mjs +63 -22
  50. package/tests/audit-prompt-duplication.mjs +143 -0
  51. package/tests/audit-prompt-duplication.mutants.mjs +50 -0
  52. package/tests/audit-prompt-invariants.mjs +43 -5
  53. package/tests/audit-readme-bilingual.test.mjs +152 -0
  54. package/tests/audit-readme-counts.mjs +95 -0
  55. package/tests/audit-readme-counts.mutants.mjs +52 -0
  56. package/tests/audit-registration.mjs +78 -0
  57. package/tests/audit-status-report-fields.mjs +172 -0
  58. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  59. package/tests/audit-v3-registration-parity.mjs +156 -0
  60. package/tests/audit-v5-integrity.mjs +300 -13
  61. package/tests/audit-v5-lean-abstention.mjs +392 -0
  62. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  63. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  64. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  65. package/tests/audit-v5-sensitivity.mjs +15 -8
  66. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  67. package/tests/e2e-v5-round2.test.mjs +566 -12
  68. package/tests/formal-verify-v2.test.mjs +91 -5
  69. package/tests/formal-verify-v3.mutants.mjs +117 -0
  70. package/tests/formal-verify-v3.test.mjs +85 -6
  71. package/tests/formal-verify-v4.mutants.mjs +152 -0
  72. package/tests/formal-verify-v4.test.mjs +628 -9
  73. package/tests/formal-verify-v5.test.mjs +68 -9
  74. package/tests/helpers/doc-counts-format.mjs +41 -0
  75. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  76. package/tests/math-computation-a1.mutants.mjs +72 -0
  77. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  78. package/tests/math-computation-discovery.mutants.mjs +76 -0
  79. package/tests/math-computation-shared.test.mjs +396 -2
  80. package/tests/math-computation-v2.test.mjs +767 -0
  81. package/tests/math-computation-v3.test.mjs +759 -0
  82. package/tests/math-computation-v4.mutants.mjs +133 -0
  83. package/tests/math-computation-v4.test.mjs +739 -0
  84. package/tests/prompt-v5-integrity.test.mjs +13 -2
  85. package/tests/run-tests.mjs +139 -9
  86. package/tests/run-tests.mutants.mjs +70 -0
  87. package/tests/selfdrive-v5.mjs +53 -1
  88. package/tests/selfdrive-v5.mutants.mjs +132 -0
  89. package/tests/v2-fix-probes.mutants.mjs +100 -0
  90. package/tests/v2-fix-probes.test.mjs +1039 -0
  91. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  92. package/tests/v3-fix-probes.mutants.mjs +95 -0
  93. package/tests/v3-fix-probes.test.mjs +891 -0
  94. package/tests/v5-institute-fixes.mutants.mjs +190 -0
  95. package/vibe-math-v2/agent.cordis.yml +62 -6
  96. package/vibe-math-v2/math-computation.js +229 -29
  97. package/vibe-math-v2/math-engines.js +12 -12
  98. package/vibe-math-v2/vibe-math-v2.js +458 -119
  99. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
  100. package/vibe-math-v3/agent.cordis.yml +72 -6
  101. package/vibe-math-v3/math-computation.js +229 -29
  102. package/vibe-math-v3/math-engines.js +12 -12
  103. package/vibe-math-v3/vibe-math-v3.js +482 -139
  104. package/vibe-math-v4/agent.cordis.yml +44 -8
  105. package/vibe-math-v4/math-computation.js +229 -29
  106. package/vibe-math-v4/math-engines.js +12 -12
  107. package/vibe-math-v4/vibe-math-v4.js +245 -52
  108. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
  109. package/vibe-math-v5/agent.cordis.yml +46 -12
  110. package/vibe-math-v5/math-computation.js +229 -29
  111. package/vibe-math-v5/math-engines.js +12 -12
  112. package/vibe-math-v5/vibe-math-v5.js +1307 -243
  113. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1655
  114. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -12,6 +12,21 @@ import { MATH_ENGINES, MATH_ENGINE_ORDER, mathEngineCandidates, mathEngineArgErr
12
12
 
13
13
  export const MATH_TOOL_NAME = 'math_computation'
14
14
 
15
+ // Sweep ruling: the cli policy is declared ONCE, in the descriptor (math-engines.js `cli.policy`).
16
+ // These two readers are the only place the rule is applied, so descriptor and behaviour cannot drift.
17
+ const CLI_POLICY = (MATH_ENGINES.cli && MATH_ENGINES.cli.policy) || {}
18
+
19
+ // Item-2 ruling: a commercial template's "VERIFY" provenance must REACH the user, and it is declared
20
+ // ONCE (descriptor `verify` + `verifyReason`). Assemble the fields ONLY when a reason exists, so an
21
+ // engine that declares nothing prints no empty slot.
22
+ function verifyFields(engine) {
23
+ const d = MATH_ENGINES[engine] || {}
24
+ if (!d.verify) return {}
25
+ const out = { verify: true }
26
+ if (typeof d.verifyReason === 'string' && d.verifyReason) out.verifyReason = d.verifyReason
27
+ return out
28
+ }
29
+
15
30
  export const MATH_PARAM_NAMES = Object.freeze([
16
31
  'mathComputation', 'mathMode', 'mathEngines', 'mathTimeoutMs', 'mathPackages', 'mathInstallScope',
17
32
  ])
@@ -72,11 +87,13 @@ export const MATH_PERSONA_TOOL_LINE = '- math_computation {op: probe|run|receipt
72
87
  // P2a: the archive -> edit -> re-run workflow. Injected into every preset's rule block (and appended
73
88
  // to both persona blocks) so an agent cannot miss that (a) the script is archived, (b) it may edit
74
89
  // it, and (c) re-running is what produces evidence for the edited code.
75
- export const MATH_ARCHIVE_WORKFLOW_LINE = '- 归档→编辑→重跑:mode:\'code\' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext>),你可以用普通文件工具打开并编辑它;'
76
- + '编辑后用 mode:\'file\' 指向它重跑,会写出一份**新回执/新 attempt**(含新的 scriptHash)。**旧回执对修改后的代码无效**——'
77
- + '报告里必须引用与当前代码哈希一致的那份回执;工具会在 scriptChanged / scriptChangedDuringRun 为 true 时显式告警。'
90
+ export const MATH_ARCHIVE_WORKFLOW_LINE = '- 归档→编辑→重跑:mode:\'code\' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext>,**相对项目根**);'
91
+ + '**成员的文件工具是按会话 cwd 解析的**,所以读它要用**绝对路径** `receipt.scriptAbs`,或把 `receipt.cwd` 与 `receipt.scriptPath` 拼起来(回执两个字段都有)。'
92
+ + '要拿到"改过代码"的证据,请**编辑你最初运行的那个源文件**,再用 mode:\'file\' 指向**同一个源路径**重跑:归档 id 以**源路径**为键,因此这落在**同一归档**的 **attempt ≥ 2**,并给出 `scriptChanged:true` 与 `previousReceipt`(指向上一次)。'
93
+ + '**指向归档副本本身**(`receipt.scriptAbs` 那个路径)按设计是**另一份新归档**:新 id、attempt 1、没有 `previousReceipt`、`scriptChanged:false`;旧 attempt 绝不会被覆盖,但它**不是**"同一归档的新 attempt"——工具会用 `fileIsArchivedScript` 与 `ARCHIVED_SCRIPT_RERUN` 警告明确说明。'
94
+ + '**旧回执对修改后的代码无效**——报告里必须引用与当前代码哈希一致的那份回执;工具会在 scriptChanged / scriptChangedDuringRun 为 true 时显式告警。'
78
95
 
79
- export const MATH_ARCHIVE_WORKFLOW_LINE_EN = '- Archive -> edit -> re-run: for mode:\'code\' the script original is at the receipt\'s scriptPath (Computation/<id>/script.<ext>) and you may open/edit it with your normal file tools; after editing, re-run it with mode:\'file\' to write a NEW receipt/attempt with a NEW scriptHash. **An old receipt is NOT evidence for edited code** - cite the receipt whose scriptHash matches the current code; the tool warns explicitly via scriptChanged / scriptChangedDuringRun.'
96
+ export const MATH_ARCHIVE_WORKFLOW_LINE_EN = '- Archive -> edit -> re-run: for mode:\'code\' the script original is at the receipt\'s scriptPath (Computation/<id>/script.<ext>, **relative to the project root**); member file tools resolve paths against the SESSION CWD, so READ it via the ABSOLUTE `receipt.scriptAbs`, or join `receipt.cwd` with `receipt.scriptPath` (both are in the receipt). To produce evidence for EDITED code, edit the SOURCE FILE you originally ran and re-run mode:\'file\' pointing at THAT SAME PATH: the archive id is keyed by the SOURCE PATH, so this lands on the SAME archive as attempt >= 2, with `scriptChanged:true` and `previousReceipt` pointing at the previous attempt. Pointing mode:\'file\' at the ARCHIVED COPY itself (the `receipt.scriptAbs` path) is a DIFFERENT archive BY DESIGN - a new id, attempt 1, no `previousReceipt`, `scriptChanged:false`: the earlier attempt is never overwritten, but it is NOT "a new attempt of the same archive", and the tool says so via `fileIsArchivedScript` and the `ARCHIVED_SCRIPT_RERUN` warning. **An old receipt is NOT evidence for edited code** - cite the receipt whose scriptHash matches the current code; the tool warns explicitly via scriptChanged / scriptChangedDuringRun.'
80
97
 
81
98
  // Round-6 (A): honesty about SUBSTITUTIONS. An alternative that weakens exactness or conclusion
82
99
  // strength must be declared, and the conclusion must never read as if the requested (exact) result
@@ -254,8 +271,10 @@ export function validateMathArgs(args, params) {
254
271
  if (mode !== 'code' && mode !== 'file' && mode !== 'expr') return bad('mode must be code|file|expr for op=run')
255
272
  const engine = args.engine || 'auto'
256
273
  if (engine === 'cli') {
257
- if (p.mathMode !== 'typed+shell') return bad('engine=cli is disabled while mathMode=' + p.mathMode, 'MATH_REFUSED', next('reason', { reason: 'policy' }))
258
- if (p.mathEngines.indexOf('cli') === -1) return bad('engine=cli is not in mathEngines', 'MATH_REFUSED', next('reason', { reason: 'engine-not-allowed' }))
274
+ // Sweep ruling (single source): the cli policy lives in the DESCRIPTOR; the code reads it here
275
+ // rather than hard-coding 'typed+shell', so the two can no longer drift apart.
276
+ if (CLI_POLICY.requiresMathMode && p.mathMode !== CLI_POLICY.requiresMathMode) return bad('engine=cli is disabled while mathMode=' + p.mathMode + ' (requires ' + CLI_POLICY.requiresMathMode + ')', 'MATH_REFUSED', next('reason', { reason: 'policy', requiresMathMode: CLI_POLICY.requiresMathMode }))
277
+ if (CLI_POLICY.requiresEngineInList && p.mathEngines.indexOf('cli') === -1) return bad('engine=cli is not in mathEngines', 'MATH_REFUSED', next('reason', { reason: 'engine-not-allowed', requiresEngineInList: true }))
259
278
  if (!isObj(args.cli)) return bad('engine=cli requires cli:{command,argv}', 'MATH_REFUSED', next('reason', { reason: 'missing-cli-command' }))
260
279
  if (mode === 'expr') return bad('cli does not support mode=expr', 'MATH_REFUSED', next('reason', { reason: 'expr-not-supported' }))
261
280
  }
@@ -276,6 +295,19 @@ export function validateMathArgs(args, params) {
276
295
 
277
296
  // ── failure / return shells ─────────────────────────────────────────────────────────────────────
278
297
  function next(kind, extra) { return Object.assign({ kind: kind }, extra || {}) }
298
+ /**
299
+ * S6: ONE wording for the version policy, so the install plan, the run path and the missing-package
300
+ * failure cannot drift into different sentences describing the same rule. Grep anchor:
301
+ * MATH_VERSION_POLICY (and its single call site family mathVersionPolicy).
302
+ */
303
+ /** S6: the ONE short clause that states the policy; the long sentence and the two human messages
304
+ * below are all built from it, so the semantics cannot drift. Grep anchor: MATH_VERSION_POLICY_CLAUSE. */
305
+ const MATH_VERSION_POLICY_CLAUSE = '只检查是否存在;版本求解交给包管理器'
306
+ const MATH_VERSION_POLICY = 'version-constraints-are-existence-only: 只按 base name 检查是否存在,约束本身从不校验(' + MATH_VERSION_POLICY_CLAUSE + ')'
307
+ function mathVersionPolicy(pinned) {
308
+ return MATH_VERSION_POLICY + ((pinned && pinned.length) ? ';本次未校验的约束:' + pinned.join(', ') : ';本次没有版本约束')
309
+ }
310
+
279
311
  function fail(code, engine, message, extra) {
280
312
  const out = { ok: false, op: null, engine: engine || null, code: code, message: message }
281
313
  if (extra && extra.next) out.next = extra.next
@@ -284,18 +316,47 @@ function fail(code, engine, message, extra) {
284
316
  if (extra && extra.missing) out.missing = extra.missing
285
317
  if (extra && extra.engines) out.engines = extra.engines
286
318
  if (extra && extra.available !== undefined) out.available = extra.available
319
+ if (extra && extra.configured) out.configured = extra.configured
320
+ if (extra && Array.isArray(extra.absent)) out.absent = extra.absent
287
321
  if (extra && extra.packages) out.packages = extra.packages
288
322
  // round-7 (live-session fix): the probe diagnostic must survive into the response, otherwise
289
323
  // "存在但不可用" is opaque (a real session showed exactly that).
290
324
  if (extra && extra.probe) out.probe = jsonSafeDiag(extra.probe)
325
+ // round-9 (F7): the version-constraint policy travels with failures too (no silent drop).
326
+ if (extra && extra.versionPolicy) out.versionPolicy = extra.versionPolicy
327
+ if (extra && extra.constraintsNotEnforced) out.constraintsNotEnforced = extra.constraintsNotEnforced
291
328
  return out
292
329
  }
293
330
 
294
- function userInstallNext(engine) {
331
+ // round-9 (real-engine finding): the suggested install command may not be runnable on this machine
332
+ // (e.g. `winget` is absent), and handing a user a command that cannot run is misleading. So the
333
+ // command is emitted ONLY when its package manager is actually resolvable; otherwise we return the
334
+ // official vendor/download pointer plus an explicit note. Async because resolution goes through the host.
335
+ async function userInstallNext(H, engine) {
295
336
  const d = MATH_ENGINES[engine] || {}
296
337
  const per = d.userInstall || {}
297
338
  const platform = process && process.platform === 'win32' ? 'windows' : (process && process.platform === 'darwin' ? 'macos' : 'linux')
298
- return next('user-install', { engine: engine, perOs: per, command: per[platform] || '', platform: platform })
339
+ const raw = per[platform] || ''
340
+ const mgr = (function () { const m = /^\s*(winget|brew|apt-get|apt|choco|scoop|dnf|pacman|zypper)\b/.exec(String(raw)); return m ? m[1] : null })()
341
+ let available = true
342
+ if (mgr) {
343
+ try { available = !!(await H.resolveExecutable(mgr)) } catch (e) { available = false }
344
+ }
345
+ return next('user-install', {
346
+ engine: engine,
347
+ perOs: per,
348
+ command: (mgr && !available) ? '' : raw,
349
+ suggestedCommand: raw,
350
+ platform: platform,
351
+ packageManager: mgr,
352
+ packageManagerAvailable: mgr ? available : null,
353
+ vendorUrl: d.vendor || null,
354
+ // Item-2: the not-found guidance carries the same provenance when the descriptor declares it.
355
+ ...verifyFields(engine),
356
+ note: (mgr && !available)
357
+ ? ('本机没有检测到包管理器 ' + mgr + ':建议命令无法运行,请按上面的官方地址手动安装(或先自行安装一个包管理器)。')
358
+ : null,
359
+ })
299
360
  }
300
361
 
301
362
  // ── host adaptation ─────────────────────────────────────────────────────────────────────────────
@@ -319,6 +380,9 @@ function adaptHost(host) {
319
380
  // round-7 (fix 2): optional bundled-runtime discovery (absolute roots + absolute listing).
320
381
  runtimeRoots: typeof h.runtimeRoots === 'function' ? h.runtimeRoots : null,
321
382
  listDirAbs: typeof h.listDirAbs === 'function' ? h.listDirAbs : null,
383
+ // round-B (F4): an OPTIONAL host field - a host (or a test) that knows its own install layout can
384
+ // supply the per-engine candidate roots, so discovery never has to trust this process's environment.
385
+ installRoots: typeof h.installRoots === 'function' ? h.installRoots : null,
322
386
  log: typeof h.log === 'function' ? h.log : function () {},
323
387
  }
324
388
  adapted.__mathHost = true
@@ -346,13 +410,81 @@ export async function probeMathEngines(host, opts) {
346
410
  if (d.license === 'commercial') continue // installed but not usable: reported by op=run as LICENSE_REQUIRED
347
411
  continue
348
412
  }
349
- engines.push({ name: name, version: v.version, path: hit, license: d.license })
413
+ engines.push(Object.assign({ name: name, version: v.version, path: hit, license: d.license }, verifyFields(name)))
350
414
  }
351
415
  const result = { ok: true, engines: engines, available: engines.length > 0 }
352
416
  PROBE_CACHE.set(H, result)
353
417
  return result
354
418
  }
355
419
 
420
+ // round-9 (real-engine finding): an engine can be INSTALLED yet invisible, because discovery only
421
+ // looked at the server process's PATH (R 4.6.1 landed in C:\Program Files\R\R-4.6.1\bin and was NOT
422
+ // added to PATH). Add the known per-OS INSTALL locations, globbed by version, still AFTER PATH and
423
+ // after the bundled runtime; existence is always proven by LISTING, never assumed.
424
+ // round-B (F4/F3): the candidate roots come from the HOST when it can supply them (`H.installRoots`),
425
+ // and only otherwise from this process's environment - so a test (or a host with its own layout) never
426
+ // depends on the ambient machine. ONE generic walker serves every engine: adding an engine is a table
427
+ // entry (roots + descriptor candidates), never a new code path. Existence is always proven by LISTING.
428
+ export function mathInstallRoots(engineName, env, win) {
429
+ const pf = String(env.ProgramFiles || 'C:/Program Files')
430
+ const pf86 = String(env['ProgramFiles(x86)'] || 'C:/Program Files (x86)')
431
+ const local = env.LOCALAPPDATA ? String(env.LOCALAPPDATA) : null
432
+ const home = env.USERPROFILE || env.HOME || null
433
+ const table = {
434
+ r: win
435
+ ? [pf + '/R', pf86 + '/R', local ? local + '/Programs/R' : null]
436
+ : ['/usr/lib/R', '/usr/lib64/R', '/Library/Frameworks/R.framework/Resources', '/usr/local/lib/R', '/opt/R'],
437
+ python: win
438
+ ? [local ? local + '/Programs/Python' : null, pf]
439
+ : ['/usr/local', '/usr', '/opt/python', '/opt/homebrew'],
440
+ octave: win
441
+ ? [pf + '/GNU Octave', local ? local + '/Programs/GNU Octave' : null]
442
+ : ['/usr/lib/octave', '/usr/local/octave', '/opt/octave', '/usr/share/octave'],
443
+ julia: win
444
+ ? [local ? local + '/Programs' : null, pf, home ? home + '/.juliaup/bin' : null]
445
+ : ['/opt', '/usr/local', home ? home + '/.juliaup/bin' : null],
446
+ matlab: win ? [pf + '/MATLAB'] : ['/usr/local/MATLAB', '/Applications'],
447
+ }
448
+ return (table[engineName] || []).filter((x) => typeof x === 'string' && x)
449
+ }
450
+
451
+ async function knownInstallCandidates(H, engineName) {
452
+ if (typeof H.listDirAbs !== 'function') return []
453
+ const cands = mathEngineCandidates(engineName)
454
+ if (!cands) return []
455
+ const env = (typeof process !== 'undefined' && process.env) || {}
456
+ const win = !!(typeof process !== 'undefined' && process.platform === 'win32')
457
+ let injected = null
458
+ if (typeof H.installRoots === 'function') {
459
+ try {
460
+ const r = await H.installRoots(engineName)
461
+ if (Array.isArray(r)) injected = r.filter((x) => typeof x === 'string' && x)
462
+ } catch (e) { injected = null }
463
+ }
464
+ const roots = injected || mathInstallRoots(engineName, env, win)
465
+ const exts = win ? ['', '.exe', '.cmd'] : ['']
466
+ const out = []
467
+ const listNames = async (dir, kind) => {
468
+ try { return ((await H.listDirAbs(dir)) || []).filter((e) => e && e.type === kind).map((e) => e.name) } catch (e) { return [] }
469
+ }
470
+ const match = async (dir) => {
471
+ const files = await listNames(dir, 'file')
472
+ for (const c of cands) for (const x of exts) if (files.indexOf(c + x) !== -1) out.push(dir + '/' + c + x)
473
+ }
474
+ for (const root of roots) {
475
+ await match(root) // flat layout (juliaup, /usr/local)
476
+ await match(root + '/bin') // unix / framework layout
477
+ for (const d of await listNames(root, 'directory')) {
478
+ await match(root + '/' + d) // versioned flat (Python312, Julia-1.10)
479
+ await match(root + '/' + d + '/bin') // versioned install (R-4.6.1/bin)
480
+ for (const s of await listNames(root + '/' + d, 'directory')) {
481
+ await match(root + '/' + d + '/' + s + '/bin') // nested layout (Octave-9.2.0/mingw64/bin)
482
+ }
483
+ }
484
+ }
485
+ return Array.from(new Set(out))
486
+ }
487
+
356
488
  async function resolveCandidate(H, d) {
357
489
  const cands = mathEngineCandidates(d.name)
358
490
  if (!cands) return null
@@ -362,11 +494,15 @@ async function resolveCandidate(H, d) {
362
494
  if (typeof p === 'string' && p) return p
363
495
  } catch (e) { /* not this one */ }
364
496
  }
365
- // round-7 (fix 2): DSH ships its own runtimes; PATH always wins, so this is the LAST resort. The
497
+ // round-7 (fix 2): DSH ships its own runtimes; PATH always wins, so this is a LAST resort. The
366
498
  // tree name is globbed (never hard-coded) and executable names come from the descriptor candidates.
367
499
  for (const p of await dshRuntimeCandidates(H, d.name)) {
368
500
  if (typeof p === 'string' && p) return p
369
501
  }
502
+ // round-9: then the known per-OS install locations (an engine installed without touching PATH).
503
+ for (const p of await knownInstallCandidates(H, d.name)) {
504
+ if (typeof p === 'string' && p) return p
505
+ }
370
506
  return null
371
507
  }
372
508
 
@@ -510,10 +646,10 @@ async function resolveEngine(H, requested, params, args) {
510
646
  const d = applyOverride({ name: name, desc: d0 }, params, args)
511
647
  if (params.mathEngines.indexOf(name) === -1) return fail('MATH_REFUSED', name, '引擎 ' + name + ' 不在 mathEngines 允许列表内', { next: next('reason', { reason: 'engine-not-allowed' }) })
512
648
  if (name === 'cli') {
513
- if (params.mathMode !== 'typed+shell') return fail('MATH_REFUSED', name, 'cli 被策略禁用(mathMode=' + params.mathMode + ')', { next: next('reason', { reason: 'policy' }) })
649
+ if (CLI_POLICY.requiresMathMode && params.mathMode !== CLI_POLICY.requiresMathMode) return fail('MATH_REFUSED', name, 'cli 被策略禁用(需要 mathMode=' + CLI_POLICY.requiresMathMode + ',当前 ' + params.mathMode + ')', { next: next('reason', { reason: 'policy', requiresMathMode: CLI_POLICY.requiresMathMode }) })
514
650
  let exe = null
515
651
  try { exe = await H.resolveExecutable(args.cli.command) } catch (e) { exe = null }
516
- if (!exe) return fail('MATH_ENGINE_NOT_FOUND', name, 'cli 命令无法解析:' + args.cli.command, { next: userInstallNext('cli') })
652
+ if (!exe) return fail('MATH_ENGINE_NOT_FOUND', name, 'cli 命令无法解析:' + args.cli.command, { next: await userInstallNext(H, 'cli') })
517
653
  // round-7 (finding 5): report the REAL version when the command's family is recognisable (the
518
654
  // descriptor for that family carries the version probe); unknown commands stay 'unknown'.
519
655
  let cliVersion = 'unknown'
@@ -532,7 +668,7 @@ async function resolveEngine(H, requested, params, args) {
532
668
  if (!v.ok) {
533
669
  if (d.license === 'commercial' || d.versionOptional) {
534
670
  const lic = await checkLicence(H, d, hit)
535
- if (!lic.ok) return fail('MATH_ENGINE_LICENSE_REQUIRED', name, name + ' 已安装但许可不可用(仅厂商可激活)', { next: next('vendor', { engine: name, url: d.vendor || '' }) })
671
+ if (!lic.ok) return fail('MATH_ENGINE_LICENSE_REQUIRED', name, name + ' 已安装但许可不可用(仅厂商可激活)', { next: next('vendor', Object.assign({ engine: name, url: d.vendor || '' }, verifyFields(name))) })
536
672
  return { ok: true, name: name, desc: d, exe: hit, version: 'unknown' }
537
673
  }
538
674
  // round-7 (live-session fix): the engine WAS found - so an install guide would be misleading
@@ -544,11 +680,11 @@ async function resolveEngine(H, requested, params, args) {
544
680
  })
545
681
  }
546
682
  const lic = await checkLicence(H, d, hit)
547
- if (!lic.ok) return fail('MATH_ENGINE_LICENSE_REQUIRED', name, name + ' 已安装但许可不可用(仅厂商可激活)', { next: next('vendor', { engine: name, url: d.vendor || '' }) })
683
+ if (!lic.ok) return fail('MATH_ENGINE_LICENSE_REQUIRED', name, name + ' 已安装但许可不可用(仅厂商可激活)', { next: next('vendor', Object.assign({ engine: name, url: d.vendor || '' }, verifyFields(name))) })
548
684
  return { ok: true, name: name, desc: d, exe: hit, version: v.version }
549
685
  }
550
686
  const first = requested === 'auto' ? (params.mathEngines[0] || 'python') : requested
551
- return fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(试过:' + names.join(', ') + ')', { next: userInstallNext(first) })
687
+ return fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(试过:' + names.join(', ') + ')', { next: await userInstallNext(H, first) })
552
688
  }
553
689
 
554
690
  // ── package probe ───────────────────────────────────────────────────────────────────────────────
@@ -643,11 +779,14 @@ function assembleArgv(det, mode, payload) {
643
779
  }
644
780
  }
645
781
  if (mode === 'code' || mode === 'file') {
646
- return { argv: [det.exe].concat((d.scriptArgv || []).map((a) => a === '<script>' ? payload.scriptAbs : a)) }
782
+ // Sweep finding (HIGH): substitution must be STRING-level, not exact-element equality - matlab's
783
+ // descriptor embeds the placeholder (`run('<script>')`), so an exact match let the literal
784
+ // `run('<script>')` reach the engine and the archived script never ran.
785
+ return { argv: [det.exe].concat((d.scriptArgv || []).map((a) => String(a).split('<script>').join(payload.scriptAbs))) }
647
786
  }
648
787
  if (mode === 'expr') {
649
788
  if (!d.evalArgv) return { refused: 'engine does not support mode=expr' }
650
- return { argv: [det.exe].concat(d.evalArgv.map((a) => a === '<expr>' ? payload.expr : a)) }
789
+ return { argv: [det.exe].concat(d.evalArgv.map((a) => String(a).split('<expr>').join(payload.expr))) }
651
790
  }
652
791
  return { refused: 'unknown mode' }
653
792
  }
@@ -799,6 +938,14 @@ async function opProbe(H, args, params) {
799
938
  const probeDet = familyProbeDet(chosen)
800
939
  if (want.length && probeDet) packages = await probePackages(H, probeDet, want)
801
940
  else if (want.length && chosen && chosen.ok && chosen.name === 'cli') packages = { requested: want.map((s) => parsePackageSpec(s).name || s), found: {}, precheckSkipped: 'engine=cli:无法从命令名判断引擎族(不阻塞)' }
941
+ // S2 + round-9 F3: the absence list must exist BEFORE the early failure return, because the
942
+ // failure a user actually hits must carry the same evidence as the success shape.
943
+ const absentFor = (names) => params.mathEngines
944
+ .filter((e) => names.indexOf(e) === -1)
945
+ .map((e) => ((MATH_ENGINES[e] && MATH_ENGINES[e].resolveFrom === 'cli.command')
946
+ ? { engine: e, why: 'needs-a-caller-supplied-command' }
947
+ : { engine: e, why: 'not-found-on-this-machine' }))
948
+
802
949
  if (requested && chosen && !chosen.ok) {
803
950
  // Policy/absence refusal for the REQUESTED engine, with its own guidance.
804
951
  const out = Object.assign({}, chosen)
@@ -810,7 +957,7 @@ async function opProbe(H, args, params) {
810
957
  }
811
958
  if (!probe.available && !(chosen && chosen.ok)) {
812
959
  const first = requested || params.mathEngines[0] || 'python'
813
- const out = fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(请求:' + first + ')', { next: userInstallNext(first), engines: [], available: false, packages: packages })
960
+ const out = fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(请求:' + first + ')', { next: await userInstallNext(H, first), engines: [], available: false, configured: params.mathEngines.slice(), absent: absentFor(probe.engines.map((e) => e.name)), packages: packages })
814
961
  out.op = 'probe'
815
962
  return out
816
963
  }
@@ -818,13 +965,22 @@ async function opProbe(H, args, params) {
818
965
  // available even when auto-detection found nothing - otherwise probe denied what run would do.
819
966
  const engines = probe.engines.slice()
820
967
  if (chosen && chosen.ok && !engines.some((e) => e.name === chosen.name)) {
821
- engines.push({ name: chosen.name, path: chosen.exe, version: chosen.version, license: (MATH_ENGINES[chosen.name] || {}).license })
822
- }
823
- const engineInfo = (chosen && chosen.ok) ? { name: chosen.name, path: chosen.exe, version: chosen.version } : null
968
+ engines.push(Object.assign({ name: chosen.name, path: chosen.exe, version: chosen.version, license: (MATH_ENGINES[chosen.name] || {}).license }, verifyFields(chosen.name)))
969
+ }
970
+ const engineInfo = (chosen && chosen.ok) ? Object.assign({ name: chosen.name, path: chosen.exe, version: chosen.version }, verifyFields(chosen.name)) : null
971
+ // round-9 (F3): the availability line must never be silently partial - name the CONFIGURED engines
972
+ // that were not found (and why), instead of just listing the ones that happened to resolve.
973
+ const foundNames = engines.map((e) => e.name)
974
+ // Per-engine absence explanation. The key is `why` (NOT the reserved machine-readable vocabulary
975
+ // key), so the parity guard's exhaustive `next.reason` set stays exactly the refusal vocabulary.
976
+ const absent = absentFor(foundNames)
824
977
  return {
825
978
  ok: true, op: 'probe', engine: engineInfo ? engineInfo.name : null, engineInfo: engineInfo,
826
979
  engines: engines, available: true, packages: packages,
827
- message: '可用引擎:' + engines.map((e) => e.name + ' ' + e.version).join('、') + (engineInfo ? '(请求 ' + engineInfo.name + ':' + engineInfo.version + ')' : ''),
980
+ configured: params.mathEngines.slice(), absent: absent,
981
+ message: '可用引擎:' + engines.map((e) => e.name + ' ' + e.version).join('、')
982
+ + (engineInfo ? '(请求 ' + engineInfo.name + ':' + engineInfo.version + ')' : '')
983
+ + (absent.length ? ';已配置但本机未发现:' + absent.map((a) => a.engine).join('、') : ';已配置的引擎都在'),
828
984
  }
829
985
  }
830
986
 
@@ -851,6 +1007,9 @@ async function opRun(H, args, params) {
851
1007
  const engineLabel = det.name === 'cli' ? ('cli:' + cliCommand) : det.name
852
1008
 
853
1009
  const want = (args.packages && args.packages.length) ? args.packages : params.mathPackages
1010
+ // round-9 (F7): version constraints are NOT enforced - we only check existence by base name.
1011
+ const pinnedSpecs = want.filter((sp) => parsePackageSpec(sp).pinned)
1012
+ const versionPolicy = mathVersionPolicy(pinnedSpecs)
854
1013
  // round-7 (finding 3): for cli the precheck runs against the FAMILY of `cli.command` (same
855
1014
  // discovery-shaped logic); an unrecognisable command SKIPS the precheck instead of reporting a
856
1015
  // false "missing" that would block the escape hatch.
@@ -861,10 +1020,11 @@ async function opRun(H, args, params) {
861
1020
  // timeout) must not be read as "missing" - that misled a real session.
862
1021
  const missing = (probeDet && !pk.probeFailed) ? want.map((s) => parsePackageSpec(s).name || s).filter((p) => Object.prototype.hasOwnProperty.call(pk.found, p) && !pk.found[p]) : []
863
1022
  if (missing.length) {
864
- return fail('MATH_MISSING_PACKAGES', det.name, '需要的包未安装:' + missing.join(', ') + '(只检查是否存在;版本求解交给包管理器)', {
1023
+ return fail('MATH_MISSING_PACKAGES', det.name, '需要的包未安装:' + missing.join(', ') + '(' + MATH_VERSION_POLICY_CLAUSE + ')', {
865
1024
  missing: missing,
866
1025
  next: next('agent-install', { engine: det.name, packages: missing, specs: want.slice(), dryRun: true }),
867
1026
  packages: pk,
1027
+ versionPolicy: versionPolicy, constraintsNotEnforced: pinnedSpecs.slice(),
868
1028
  })
869
1029
  }
870
1030
 
@@ -872,10 +1032,17 @@ async function opRun(H, args, params) {
872
1032
  let scriptText = ''
873
1033
  let fileRel = null
874
1034
  let sourceHashBefore = null
1035
+ // round-9 (N1): `mode:'file'` is keyed by the SOURCE PATH, so re-running an ORIGINAL source lands on
1036
+ // the same archive id (attempt>=2, scriptChanged). Pointing it at an ARCHIVED script instead targets
1037
+ // a different path and therefore a NEW archive id by design - the prior attempt is never touched, but
1038
+ // there is no previous receipt FOR THAT ID, so scriptChanged/previousReceipt are necessarily empty.
1039
+ // That is not a bug, but it must be SAID rather than looking like broken change detection.
1040
+ let archivedScriptSource = false
875
1041
  if (mode === 'code') scriptText = args.code
876
1042
  else if (mode === 'file') {
877
1043
  fileRel = projectRel('', args.file)
878
1044
  if (fileRel === null) return fail('MATH_REFUSED', det.name, 'file 必须位于项目内:' + args.file, { next: next('reason', { reason: 'path-outside-project' }) })
1045
+ archivedScriptSource = /^Computation\//.test(String(fileRel))
879
1046
  const txt = await H.readText(fileRel)
880
1047
  if (txt === undefined) return fail('MATH_REFUSED', det.name, '找不到文件:' + args.file, { next: next('reason', { reason: 'file-not-found' }) })
881
1048
  scriptText = txt
@@ -939,6 +1106,7 @@ async function opRun(H, args, params) {
939
1106
  }
940
1107
 
941
1108
  const warnings = []
1109
+ if (archivedScriptSource) warnings.push(warning('ARCHIVED_SCRIPT_RERUN', 'mode:\'file\' 指向的是**归档脚本**(Computation/…):按设计归档 id 以**源路径**为键,所以这是一次**新归档**(新目录、attempt 1),没有该 id 的历史回执 ⇒ scriptChanged/previousReceipt 为空是**预期**,且**旧 attempt 绝不会被覆盖**。要做"编辑→重跑→attempt≥2 + scriptChanged"的核对,请把 mode:\'file\' 指向**原始源文件**并编辑它,而不是指向归档副本。'))
942
1110
  if (!probeDet && want.length) warnings.push(warning('PACKAGE_PRECHECK_SKIPPED', 'engine=cli:无法从命令名判断引擎族,已跳过包预检(不阻塞执行,也不会误报缺包);如需预检请直接用 python/r/octave/julia 或改用可识别的解释器路径。'))
943
1111
  if (pk && pk.probeFailed && want.length) warnings.push(warning('PACKAGE_PRECHECK_UNKNOWN', '包预检未能执行(宿主 subprocess 在这几次尝试里返回空/超时,会话早期常见):本次不判定缺包、不阻塞执行;如需确定性结论请重试或直接用对应引擎。probe=' + JSON.stringify(pk.argv) + ' attempts=' + JSON.stringify(pk.attempts)))
944
1112
  if (fullOut.length > MATH_CAPS.stdout) warnings.push(warning('OUTPUT_TRUNCATED', 'stdout 超过 64KB,完整版见 ' + dir + '/stdout.txt'))
@@ -953,13 +1121,24 @@ async function opRun(H, args, params) {
953
1121
  engine: { name: engineLabel, path: det.exe, version: det.version, source: det.name === 'cli' ? 'cli' : 'path' },
954
1122
  script: { path: scriptRel, sha256: scriptHash, bytes: Buffer.byteLength(scriptText, 'utf8') },
955
1123
  scriptPath: scriptRel, scriptHash: scriptHash,
1124
+ // round-8 (P0/D3): member file tools are session-cwd relative, so the ABSOLUTE locations must be
1125
+ // in the receipt: `scriptAbs` is directly openable, `cwd` is the project root to join with.
1126
+ scriptAbs: scriptAbs, cwd: root,
956
1127
  sourceFile: fileRel || null,
1128
+ fileIsArchivedScript: archivedScriptSource,
1129
+ // round-9 (N2): a TIMEOUT must still carry the evidence it produced - the capped partial output
1130
+ // lives in the receipt as well as in stdout.txt/stderr.txt on disk.
1131
+ partialStdout: String(fullOut).slice(-2000),
1132
+ partialStderr: String(fullErr).slice(-2000),
957
1133
  sourceHashBefore: sourceHashBefore, sourceHashAfter: sourceHashAfter,
958
1134
  scriptChanged: scriptChanged, scriptChangedDuringRun: scriptChangedDuringRun,
959
1135
  previousReceipt: prevReceipt ? { runId: prevReceipt.runId, attempt: prevReceipt.attempt || 1, scriptHash: prevReceipt.scriptHash || (prevReceipt.script && prevReceipt.script.sha256) || null } : null,
960
1136
  argv: assembled.argv.slice(),
961
1137
  cwd: root,
962
1138
  packages: { requested: want.slice(), found: pk.found },
1139
+ // round-B (F6): the constraint policy is durable evidence too - a receipt read months later must
1140
+ // show that constraints were existence-only and WHICH ones were not checked.
1141
+ versionPolicy: versionPolicy, constraintsNotEnforced: pinnedSpecs.slice(),
963
1142
  exit: r.timedOut ? null : (r.exit === undefined ? null : r.exit),
964
1143
  timedOut: !!r.timedOut,
965
1144
  ms: Number(r.ms || 0),
@@ -979,12 +1158,20 @@ async function opRun(H, args, params) {
979
1158
 
980
1159
  const shell = {
981
1160
  ok: true, op: 'run', engine: engineLabel, mode: mode,
1161
+ runId: runId, attempt: attempt,
1162
+ versionPolicy: versionPolicy, constraintsNotEnforced: pinnedSpecs.slice(),
982
1163
  engineInfo: receipt.engine,
983
1164
  argv: assembled.argv.slice(),
984
1165
  scriptPath: scriptRel, scriptHash: scriptHash,
1166
+ scriptAbs: scriptAbs, cwd: root,
985
1167
  attempt: attempt, attemptDir: dir, baseRunDir: baseDir,
986
1168
  scriptChanged: scriptChanged, scriptChangedDuringRun: scriptChangedDuringRun,
987
1169
  sourceFile: fileRel || null,
1170
+ fileIsArchivedScript: archivedScriptSource,
1171
+ // round-9 (N2): a TIMEOUT must still carry the evidence it produced - the capped partial output
1172
+ // lives in the receipt as well as in stdout.txt/stderr.txt on disk.
1173
+ partialStdout: String(fullOut).slice(-2000),
1174
+ partialStderr: String(fullErr).slice(-2000),
988
1175
  previousReceipt: receipt.previousReceipt,
989
1176
  cliScriptAppended: !!(receipt.cli && receipt.cli.scriptAppended),
990
1177
  packages: pk.found,
@@ -994,8 +1181,13 @@ async function opRun(H, args, params) {
994
1181
  warnings: warnings,
995
1182
  }
996
1183
  if (receipt.timedOut) {
997
- return Object.assign(fail('MATH_TIMEOUT', engineLabel, '执行超时(' + timeoutMs + 'ms)已被终止', { argv: assembled.argv.slice(), receipt: receiptRef }),
998
- archiveFields(), { ms: receipt.ms, timedOut: true })
1184
+ // round-9 (F5): failure EVIDENCE must be uniform - a timeout carries the SAME fields a non-zero
1185
+ // exit does (exit + stderr), plus timedOut:true.
1186
+ const out = fail('MATH_TIMEOUT', engineLabel, '执行超时(' + timeoutMs + 'ms)已被终止', { argv: assembled.argv.slice(), receipt: receiptRef })
1187
+ out.exit = null
1188
+ out.stdout = String(fullOut).slice(0, MATH_CAPS.stdout)
1189
+ out.stderr = String(fullErr).slice(0, MATH_CAPS.stderr)
1190
+ return Object.assign(out, archiveFields(), { ms: receipt.ms, timedOut: true })
999
1191
  }
1000
1192
  if (r.exit !== 0) {
1001
1193
  const blob = fullErr + '\n' + fullOut
@@ -1008,11 +1200,13 @@ async function opRun(H, args, params) {
1008
1200
  next: next('engine-override', { engine: det.name, argv: assembled.argv.slice(), hint: MATH_BAD_ARGV_HINT }),
1009
1201
  })
1010
1202
  out.exit = r.exit
1203
+ out.stdout = fullOut.slice(0, MATH_CAPS.stdout)
1011
1204
  out.stderr = fullErr.slice(0, MATH_CAPS.stderr)
1012
1205
  return Object.assign(out, archiveFields())
1013
1206
  }
1014
1207
  const out = fail('MATH_NONZERO_EXIT', engineLabel, '引擎以非零退出码结束:' + r.exit, { argv: assembled.argv.slice(), receipt: receiptRef })
1015
1208
  out.exit = r.exit
1209
+ out.stdout = fullOut.slice(0, MATH_CAPS.stdout)
1016
1210
  out.stderr = fullErr.slice(0, MATH_CAPS.stderr)
1017
1211
  return Object.assign(out, archiveFields())
1018
1212
  }
@@ -1101,7 +1295,7 @@ async function opInstall(H, args, params) {
1101
1295
  if (!d) return fail('MATH_INVALID_ARGUMENT', engineName, 'unknown engine: ' + engineName)
1102
1296
  if (d.license === 'commercial' || !d.install) {
1103
1297
  return fail('MATH_REFUSED', engineName, engineName + ' 永不代装(商业引擎只给厂商指引)', {
1104
- next: next('vendor', { engine: engineName, url: d.vendor || '' }),
1298
+ next: next('vendor', Object.assign({ engine: engineName, url: d.vendor || '' }, verifyFields(engineName))),
1105
1299
  })
1106
1300
  }
1107
1301
  const scope = args.scope || params.mathInstallScope || 'user' // per-call only; never persisted anywhere
@@ -1137,7 +1331,7 @@ async function opInstall(H, args, params) {
1137
1331
  const plan = {
1138
1332
  engine: engineName, scope: scope, manager: activeManager, commands: commands,
1139
1333
  managerAssumed: !!(managerInfo && managerInfo.assumed), managerWhy: managerInfo ? managerInfo.why : '',
1140
- versionPolicy: '版本求解交给包管理器(本工具只检查是否已安装,并把 pkg<op>version 原样透传)',
1334
+ versionPolicy: mathVersionPolicy(args.packages || []),
1141
1335
  note: scope === 'user' ? '将安装到用户级目录' : '仅本次系统作用域(不会被记住)',
1142
1336
  }
1143
1337
  const planToken = sha256(JSON.stringify({ engine: engineName, packages: args.packages, scope: scope, commands: commands }) + '|planVersion=1').slice(0, 32)
@@ -1158,12 +1352,18 @@ async function opInstall(H, args, params) {
1158
1352
  const code = (r && r.timedOut) ? 'MATH_TIMEOUT' : 'MATH_NONZERO_EXIT'
1159
1353
  const out = fail(code, engineName, '安装失败(' + c.argv.join(' ') + '):' + (r ? ('exit=' + r.exit) : 'no subprocess'), { next: next('note', { audit: 'Computation/installs/' + planToken + '.json' }) })
1160
1354
  out.audit = 'Computation/installs/' + planToken + '.json'
1355
+ // S4/S5 (non-breaking): install failures are discriminated by op + installedSoFar + timedOut,
1356
+ // NOT by a new failure code (MATH_FAILURE_CODES is frozen). Mirrors the `out.audit`
1357
+ // post-assignment and needs nothing from fail()'s key whitelist.
1358
+ out.op = 'install'
1359
+ out.timedOut = !!(r && r.timedOut)
1360
+ out.installedSoFar = results.filter((x) => x.exit === 0).map((x) => x.argv)
1161
1361
  return out
1162
1362
  }
1163
1363
  }
1164
1364
  const audit = { schema: 'vibe-math/math-computation-install@1', planToken: planToken, scope: scope, manager: activeManager, commands: commands, results: results, exit: 0, timedOut: false, installed: args.packages, before: {}, after: {}, rollback: rollbackFor(d, exe, args.packages, uninstallTmpl), network: 'not-enforced-by-plugin' }
1165
1365
  await H.writeText('Computation/installs/' + planToken + '.json', JSON.stringify(audit, null, 2))
1166
- return { ok: true, op: 'install', engine: engineName, executed: true, scope: scope, audit: 'Computation/installs/' + planToken + '.json', message: '已安装:' + args.packages.join(', ') + '(审计:Computation/installs/' + planToken + '.json)' }
1366
+ return { ok: true, op: 'install', engine: engineName, executed: true, scope: scope, installedSoFar: commands.map((c) => c.argv), audit: 'Computation/installs/' + planToken + '.json', message: '已安装:' + args.packages.join(', ') + '(审计:Computation/installs/' + planToken + '.json)' }
1167
1367
  }
1168
1368
 
1169
1369
  // ── round-6 (B/D): package specs + Python package-manager dispatch ──────────────────────────────
@@ -1176,7 +1376,7 @@ export function parsePackageSpec(spec) {
1176
1376
  const raw = String(spec == null ? '' : spec).trim()
1177
1377
  if (!raw) return { ok: false, problem: 'empty package spec' }
1178
1378
  if (!PKG_SPEC_RE.test(raw)) {
1179
- return { ok: false, problem: 'unsupported version syntax: "' + raw + '"(版本求解交给包管理器:请用它能理解的形式,例如 pip 的 pkg==1.2 或 conda 的 pkg=1.2;本工具只检查是否已安装)' }
1379
+ return { ok: false, problem: 'unsupported version syntax: "' + raw + '"(' + MATH_VERSION_POLICY_CLAUSE + ':请用它能理解的形式,例如 pip 的 pkg==1.2 或 conda 的 pkg=1.2)' }
1180
1380
  }
1181
1381
  const name = raw.replace(/(?:==|>=|<=|~=|!=|>|<|=).*$/, '').replace(/\[.*$/, '')
1182
1382
  return { ok: true, name: name, spec: raw, pinned: raw !== name }
@@ -6,7 +6,7 @@
6
6
  // No repo imports — node builtins only (this file imports nothing at all).
7
7
  //
8
8
  // Placeholders used in the argv templates (each substitutes into ONE argv element only):
9
- // <script> <expr> <pkgs> <probeCode> <exe> <pkg> <cliArgv...>
9
+ // <script> <expr> <pkgs> <probeCode> <exe> <pkg>
10
10
  // A descriptor is pure data; every template can be overridden by the user via mathEngineOverride.
11
11
 
12
12
  export const MATH_ENGINE_ORDER = ['python', 'r', 'octave', 'julia', 'matlab', 'maple', 'wolfram', 'cli']
@@ -19,9 +19,9 @@ const ARG_ERROR_HINTS = ['unknown option', 'invalid option', 'unrecognized optio
19
19
  export const MATH_ENGINES = {
20
20
  python: {
21
21
  name: 'python', phase: 'P1',
22
- candidates: ['python3', 'python', 'py'], winPrefix: ['-3'],
22
+ candidates: ['python3', 'python', 'py'],
23
23
  versionArgv: ['--version'], versionRe: '(\\d+\\.\\d+\\.\\d+)',
24
- scriptArgv: ['<script>'], evalArgv: ['-c', '<expr>'], stdinArgv: ['-'], ext: '.py',
24
+ scriptArgv: ['<script>'], evalArgv: ['-c', '<expr>'], ext: '.py',
25
25
  // round-7 (live): NO shell metacharacters (`|`, `%`) in any probe argv - a real host rejected
26
26
  // them with a null spawn. One line per package: "name ok|missing".
27
27
  probeCode: "import importlib.util,sys\nfor p in sys.argv[1:]: print(p, 'ok' if importlib.util.find_spec(p) is not None else 'missing')",
@@ -70,7 +70,7 @@ export const MATH_ENGINES = {
70
70
  name: 'r', phase: 'P1',
71
71
  candidates: ['Rscript', 'R'],
72
72
  versionArgv: ['--version'], versionRe: '(\\d+\\.\\d+\\.\\d+)',
73
- scriptArgv: ['<script>'], evalArgv: ['-e', '<expr>'], stdinArgv: ['--no-save', '--slave'], ext: '.R',
73
+ scriptArgv: ['<script>'], evalArgv: ['-e', '<expr>'], ext: '.R',
74
74
  // round-7 (live): line-per-package, no `|` in the argv.
75
75
  probeCode: "for (p in strsplit('__PKGS__',',')[[1]]) cat(p, if (requireNamespace(p, quietly=TRUE)) 'ok' else 'missing', '\\n')",
76
76
  packageProbe: { argv: ['-e', '<probeCode>'], parse: 'pairs' },
@@ -89,7 +89,7 @@ export const MATH_ENGINES = {
89
89
  candidates: ['octave', 'octave-cli'],
90
90
  versionArgv: ['--version'], versionRe: '(\\d+\\.\\d+\\.\\d+)',
91
91
  scriptArgv: ['--no-gui', '--quiet', '<script>'], evalArgv: ['--no-gui', '--quiet', '--eval', '<expr>'],
92
- stdinArgv: ['--no-gui', '--quiet'], ext: '.m',
92
+ ext: '.m',
93
93
  // round-7 (live): line-per-package via disp, no `|` and no `%` in the argv.
94
94
  probeCode: "s=pkg('list'); n={'__QPKGS__'}; for i=1:numel(n); if any(cellfun(@(x) strcmp(x.name,n{i}),s)); disp([n{i} ' ok']); else; disp([n{i} ' missing']); end; end",
95
95
  packageProbe: { argv: ['--no-gui', '--quiet', '--eval', '<probeCode>'], parse: 'pairs' },
@@ -106,7 +106,7 @@ export const MATH_ENGINES = {
106
106
  name: 'julia', phase: 'P1',
107
107
  candidates: ['julia'],
108
108
  versionArgv: ['--version'], versionRe: '(\\d+\\.\\d+\\.\\d+)',
109
- scriptArgv: ['<script>'], evalArgv: ['-e', '<expr>'], stdinArgv: ['-'], ext: '.jl',
109
+ scriptArgv: ['<script>'], evalArgv: ['-e', '<expr>'], ext: '.jl',
110
110
  probeCode: 'for p in [__QPKGS__]; println(p, ":", Base.find_package(p) === nothing ? "missing" : "ok"); end',
111
111
  packageProbe: { argv: ['-e', '<probeCode>'], parse: 'pairs' },
112
112
  license: 'free', argErrorHints: ARG_ERROR_HINTS,
@@ -122,7 +122,7 @@ export const MATH_ENGINES = {
122
122
  name: 'matlab', phase: 'P1',
123
123
  candidates: ['matlab'],
124
124
  versionArgv: ['-batch', 'disp(version)'], versionRe: '(\\d+\\.\\d+)',
125
- scriptArgv: ['-batch', "run('<script>')"], evalArgv: ['-batch', '<expr>'], stdinArgv: null, ext: '.m',
125
+ scriptArgv: ['-batch', "run('<script>')"], evalArgv: ['-batch', '<expr>'], ext: '.m',
126
126
  // round-7 (live): no `|`/`%` in any probe argv (a real host rejected such argv with a null spawn).
127
127
  probeCode: "t={'__QPKGS__'}; for i=1:numel(t); if license('test',t{i}); disp([t{i} ' ok']); else; disp([t{i} ' missing']); end; end",
128
128
  packageProbe: { argv: ['-batch', '<probeCode>'], parse: 'pairs' },
@@ -135,7 +135,7 @@ export const MATH_ENGINES = {
135
135
  name: 'maple', phase: 'P1',
136
136
  candidates: ['maple'],
137
137
  versionArgv: ['--version'], versionRe: '(\\d+\\.\\d+)',
138
- scriptArgv: ['-q', '<script>'], evalArgv: ['-q', '-c', '<expr>'], stdinArgv: null, ext: '.mpl',
138
+ scriptArgv: ['-q', '<script>'], evalArgv: ['-q', '-c', '<expr>'], ext: '.mpl',
139
139
  // round-7 (live): `print` instead of printf so the argv carries no `|`/`%`.
140
140
  probeCode: 'for p in ["__PKGS__"] do try with(p) catch: print(p, " missing"); next end try; print(p, " ok") end do',
141
141
  packageProbe: { argv: ['-q', '-c', '<probeCode>'], parse: 'pairs' },
@@ -149,7 +149,7 @@ export const MATH_ENGINES = {
149
149
  name: 'wolfram', phase: 'P1',
150
150
  candidates: ['wolframscript', 'WolframKernel', 'math'],
151
151
  versionArgv: ['-code', '$Version'], versionRe: '(\\d+\\.\\d+(\\.\\d+)?)',
152
- scriptArgv: ['-file', '<script>'], evalArgv: ['-code', '<expr>'], stdinArgv: null, ext: '.wl',
152
+ scriptArgv: ['-file', '<script>'], evalArgv: ['-code', '<expr>'], ext: '.wl',
153
153
  probeCode: 'Do[Print[p,":",If[Quiet[Check[Needs[p];True,False]],"ok","missing"]],{p,{__QPKGS__}}]',
154
154
  packageProbe: { argv: ['-code', '<probeCode>'], parse: 'pairs' },
155
155
  license: 'commercial', argErrorHints: ARG_ERROR_HINTS,
@@ -158,10 +158,10 @@ export const MATH_ENGINES = {
158
158
  userInstall: { windows: 'vendor installer + activation', macos: 'vendor installer + activation', linux: 'vendor installer + activation' },
159
159
  },
160
160
  cli: {
161
- name: 'cli', phase: 'P1', defaultOn: true,
161
+ name: 'cli', phase: 'P1',
162
162
  candidates: [], resolveFrom: 'cli.command',
163
163
  versionArgv: ['--version'], versionRe: '([^\\s]+)', versionOptional: true,
164
- scriptArgv: ['<cliArgv...>'], evalArgv: null, stdinArgv: null, ext: '.txt',
164
+ evalArgv: null, ext: '.txt',
165
165
  probeCode: null, packageProbe: null,
166
166
  license: 'user', argErrorHints: ARG_ERROR_HINTS,
167
167
  install: null, vendor: null,
@@ -175,7 +175,7 @@ export const MATH_P2_ENGINES = {
175
175
  sage: {
176
176
  name: 'sage', phase: 'P2', candidates: ['sage'],
177
177
  versionArgv: ['--version'], versionRe: '(\\d+\\.\\d+)',
178
- scriptArgv: ['<script>'], evalArgv: ['-c', '<expr>'], stdinArgv: ['-'], ext: '.sage',
178
+ scriptArgv: ['<script>'], evalArgv: ['-c', '<expr>'], ext: '.sage',
179
179
  probeCode: null, packageProbe: { argv: ['-pip', 'list'], parse: 'lines' },
180
180
  license: 'free', argErrorHints: ARG_ERROR_HINTS, install: null,
181
181
  },