dsh-vibe-math 2.7.0 → 2.7.1

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 (41) hide show
  1. package/README.en.md +5 -0
  2. package/README.md +5 -0
  3. package/cordis.patch.yml +6 -0
  4. package/docs/math-computation.md +44 -2
  5. package/docs/release-notes/RELEASE-NOTES-2.7.1.en.md +86 -0
  6. package/docs/release-notes/RELEASE-NOTES-2.7.1.md +86 -0
  7. package/package.json +5 -3
  8. package/prompt-corpus-persona/persona-corpus.json +8 -8
  9. package/prompt-corpus-persona/persona-corpus.md +12 -0
  10. package/prompt-corpus-v2/formal-verify-v2.json +162 -96
  11. package/prompt-corpus-v2/formal-verify-v2.md +949 -0
  12. package/prompt-corpus-v3/formal-verify-v3.json +171 -57
  13. package/prompt-corpus-v3/formal-verify-v3.md +1146 -0
  14. package/prompt-corpus-v4/formal-verify-v4.json +10 -10
  15. package/prompt-corpus-v4/formal-verify-v4.md +20 -0
  16. package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
  17. package/prompt-corpus-v5/prompt-corpus-v5.md +61 -0
  18. package/tests/audit-math-computation-contract.mjs +13 -0
  19. package/tests/audit-math-computation-parity.mjs +30 -1
  20. package/tests/audit-math-computation-sensitivity.mjs +1 -1
  21. package/tests/e2e-v5-round2.test.mjs +7 -0
  22. package/tests/formal-verify-v2.test.mjs +70 -2
  23. package/tests/formal-verify-v3.test.mjs +84 -2
  24. package/tests/helpers/math-computation-fake-seam.mjs +30 -2
  25. package/tests/math-computation-shared.test.mjs +304 -2
  26. package/vibe-math-v2/agent.cordis.yml +2 -0
  27. package/vibe-math-v2/math-computation.js +390 -47
  28. package/vibe-math-v2/math-engines.js +47 -6
  29. package/vibe-math-v2/vibe-math-v2.js +17 -1
  30. package/vibe-math-v3/agent.cordis.yml +2 -0
  31. package/vibe-math-v3/math-computation.js +390 -47
  32. package/vibe-math-v3/math-engines.js +47 -6
  33. package/vibe-math-v3/vibe-math-v3.js +17 -1
  34. package/vibe-math-v4/agent.cordis.yml +4 -0
  35. package/vibe-math-v4/math-computation.js +390 -47
  36. package/vibe-math-v4/math-engines.js +47 -6
  37. package/vibe-math-v4/vibe-math-v4.js +15 -0
  38. package/vibe-math-v5/agent.cordis.yml +4 -0
  39. package/vibe-math-v5/math-computation.js +390 -47
  40. package/vibe-math-v5/math-engines.js +47 -6
  41. package/vibe-math-v5/vibe-math-v5.js +15 -3
@@ -62,7 +62,9 @@ export const MATH_TOOL_DESCRIPTION = '数学计算:先用 op:\'probe\' 预检
62
62
  + '且结论必须标注未经工具归档))。每次执行都会归档成可复核回执(Computation/<id>/),回执里含 scriptPath/scriptHash 与**实际 argv**;'
63
63
  + '脚本原件就在那个归档目录里,你可以用普通文件工具打开/编辑它,编辑后用 mode:\'file\' 重跑会得到一份**新回执**——'
64
64
  + '**不得**拿旧回执当作修改后代码的证据。缺引擎/缺包只报告并给"用户自装指引"或"代理代装计划"(先计划、再确认);'
65
- + '商业引擎只给厂商指引;引擎选项报错时会回显 argv 并提示用 mathEngineOverride。计算结果是经验证据,不是证明。'
65
+ + '商业引擎只给厂商指引;引擎选项报错时会回显 argv 并提示用 mathEngineOverride。'
66
+ + '替代方案若改变精确性或结论强度(精确解→数值近似、闭式解→采样/求积、改精度/容差/假设、换算法类)**必须在结论里声明**,不得读起来像得到了原本的结果。'
67
+ + '计算结果是经验证据,不是证明。'
66
68
 
67
69
  export const MATH_PERSONA_TOOL_LINE = '- math_computation {op: probe|run|receipt|install, engine, mode: code|file|expr, …} — '
68
70
  + '先 probe 预检引擎/包/许可,再调引擎计算并把脚本与输出归档成可复核回执;缺引擎/缺包只报告与给安装指引/计划;shell 兜底不算归档。'
@@ -76,12 +78,21 @@ export const MATH_ARCHIVE_WORKFLOW_LINE = '- 归档→编辑→重跑:mode:\'c
76
78
 
77
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.'
78
80
 
81
+ // Round-6 (A): honesty about SUBSTITUTIONS. An alternative that weakens exactness or conclusion
82
+ // strength must be declared, and the conclusion must never read as if the requested (exact) result
83
+ // had been obtained. Injected through mathAvailabilityLine (rule block) AND appended to both persona
84
+ // blocks by the presets - same mechanism as the archive-workflow line.
85
+ export const MATH_SUBSTITUTION_RULE_LINE = '- 替代必须声明(诚实性):当替代方案改变了**精确性或结论强度**时(精确符号解 → 数值近似、闭式解 → 采样/求积、改了精度/容差/假设、换了算法类),结论里**必须写明**,不得读起来像得到了原本(精确/所要求的)结果;拿不到精确结果就直说。'
86
+
87
+ export const MATH_SUBSTITUTION_RULE_LINE_EN = '- Declare substitutions (honesty): when an alternative changes EXACTNESS or conclusion strength (exact symbolic solution -> numerical approximation, closed form -> sampling/quadrature, changed precision/tolerances/assumptions, a different algorithm class), the conclusion MUST say so explicitly and must not read as if the original (exact/requested) result had been obtained; if the exact result is unavailable, say so plainly.'
88
+
79
89
  export const MATH_RULE_LINES = Object.freeze([
80
90
  '- 需要精确数值、符号化简、反例搜索、统计或线性代数时调用 math_computation:先 probe,再 run。',
81
91
  '- 复核他人的数值结论时用 op:\'receipt\'(或 op:\'run\', mode:\'file\' 指向同一脚本)重跑,并把回执路径写进报告。',
82
92
  '- 归档即引用:报告里带 Computation/<id>/receipt.json;这是"支撑材料"的用法。',
83
93
  '- 判断标准:① 有价值或可能复用;② 较为关键或必要;③ 你对该陈述有把握(置信度高)——没把握的先别入库。',
84
94
  '- 不得把计算结果当成"已证明":对象是否已验证仍只由本预设既有的验证/共识路径给出。',
95
+ MATH_SUBSTITUTION_RULE_LINE,
85
96
  ])
86
97
 
87
98
  // The shell-fallback sentence is deliberately NOT part of MATH_RULE_LINES: it is only advertised
@@ -96,6 +107,7 @@ export const MATH_RULE_LINES_EN = Object.freeze([
96
107
  '- Archive means cite: put Computation/<id>/receipt.json in your report - that is what "supporting material" means.',
97
108
  '- Criteria: (1) valuable or likely reusable; (2) important or necessary; (3) you are confident in it - do not archive what you are unsure about.',
98
109
  '- Never treat a computation result as "proved": whether an object is verified is still decided only by this preset\'s existing verification/consensus path.',
110
+ MATH_SUBSTITUTION_RULE_LINE_EN,
99
111
  ])
100
112
 
101
113
  export const MATH_SHELL_RULE_LINE_EN = '- Shell fallback: if the tool cannot run you may use the host shell, but mark the conclusion "not tool-archived (shell path)" - shell runs have no receipt and no timeout/output guarantees; only tool-routed computations count as reproducible supporting material.'
@@ -210,7 +222,14 @@ export function validateMathArgs(args, params) {
210
222
  if (typeof args.engine !== 'string') return bad('engine must be a string')
211
223
  if (args.engine !== 'auto' && !MATH_ENGINES[args.engine]) return bad('unknown engine: ' + args.engine)
212
224
  }
213
- if (args.packages !== undefined && (!Array.isArray(args.packages) || args.packages.some((x) => typeof x !== 'string'))) return bad('packages must be a string[]')
225
+ if (args.packages !== undefined) {
226
+ if (!Array.isArray(args.packages) || args.packages.some((x) => typeof x !== 'string')) return bad('packages must be a string[]')
227
+ // round-6 (D): accept the manager-understood pass-through forms; refuse anything else explicitly.
228
+ for (const spec of args.packages) {
229
+ const parsed = parsePackageSpec(spec)
230
+ if (!parsed.ok) return bad(parsed.problem, 'MATH_INVALID_ARGUMENT', next('reason', { reason: 'unsupported-version-syntax' }))
231
+ }
232
+ }
214
233
  if (args.captureFiles !== undefined && (!Array.isArray(args.captureFiles) || args.captureFiles.some((x) => typeof x !== 'string'))) return bad('captureFiles must be a string[]')
215
234
  if (args.timeoutMs !== undefined) {
216
235
  const n = Number(args.timeoutMs)
@@ -266,6 +285,9 @@ function fail(code, engine, message, extra) {
266
285
  if (extra && extra.engines) out.engines = extra.engines
267
286
  if (extra && extra.available !== undefined) out.available = extra.available
268
287
  if (extra && extra.packages) out.packages = extra.packages
288
+ // round-7 (live-session fix): the probe diagnostic must survive into the response, otherwise
289
+ // "存在但不可用" is opaque (a real session showed exactly that).
290
+ if (extra && extra.probe) out.probe = jsonSafeDiag(extra.probe)
269
291
  return out
270
292
  }
271
293
 
@@ -294,6 +316,9 @@ function adaptHost(host) {
294
316
  spawn: h.spawn,
295
317
  hasSubprocess: typeof h.hasSubprocess === 'function' ? h.hasSubprocess : null,
296
318
  listDir: typeof h.listDir === 'function' ? h.listDir : null,
319
+ // round-7 (fix 2): optional bundled-runtime discovery (absolute roots + absolute listing).
320
+ runtimeRoots: typeof h.runtimeRoots === 'function' ? h.runtimeRoots : null,
321
+ listDirAbs: typeof h.listDirAbs === 'function' ? h.listDirAbs : null,
297
322
  log: typeof h.log === 'function' ? h.log : function () {},
298
323
  }
299
324
  adapted.__mathHost = true
@@ -337,20 +362,138 @@ async function resolveCandidate(H, d) {
337
362
  if (typeof p === 'string' && p) return p
338
363
  } catch (e) { /* not this one */ }
339
364
  }
365
+ // round-7 (fix 2): DSH ships its own runtimes; PATH always wins, so this is the LAST resort. The
366
+ // tree name is globbed (never hard-coded) and executable names come from the descriptor candidates.
367
+ for (const p of await dshRuntimeCandidates(H, d.name)) {
368
+ if (typeof p === 'string' && p) return p
369
+ }
340
370
  return null
341
371
  }
342
372
 
343
- async function probeVersion(H, d, exe) {
344
- const r = await H.spawn({ argv: [exe].concat(d.versionArgv || []), cwd: await H.projectRoot(), timeoutMs: 5000, stdoutCap: 8192, stderrCap: 8192 })
345
- if (!r || r.timedOut) return { ok: false }
373
+ // Generic discovery of a DSH-bundled runtime for one engine:
374
+ // <root>/dsh-runtimes/<tree>/dependencies/<engine>/<candidate><ext>
375
+ // `runtimeRoots` and `listDirAbs` are OPTIONAL host fields (each preset knows its own home and fs);
376
+ // when the host does not provide them this returns [] and behaviour is exactly as before.
377
+ async function dshRuntimeCandidates(H, engineName) {
378
+ if (typeof H.listDirAbs !== 'function' || typeof H.runtimeRoots !== 'function') return []
379
+ const cands = mathEngineCandidates(engineName)
380
+ if (!cands) return []
381
+ let roots = []
382
+ try { roots = (await H.runtimeRoots()) || [] } catch (e) { return [] }
383
+ if (!Array.isArray(roots) || !roots.length) return []
384
+ const win = !!(typeof process !== 'undefined' && process.platform === 'win32')
385
+ const exts = win ? ['.exe', '.cmd', ''] : ['']
386
+ const dirAliases = engineName === 'r' ? ['r', 'R'] : [engineName]
387
+ const out = []
388
+ for (const root of roots) {
389
+ if (!root) continue
390
+ const base = String(root).replace(/[\\/]+$/, '') + '/dsh-runtimes'
391
+ let trees = []
392
+ try { trees = (await H.listDirAbs(base)) || [] } catch (e) { continue }
393
+ for (const tree of trees) {
394
+ if (!tree || tree.type !== 'directory' || !tree.name) continue
395
+ for (const dirName of dirAliases) {
396
+ const depDir = base + '/' + tree.name + '/dependencies/' + dirName
397
+ let entries = []
398
+ try { entries = (await H.listDirAbs(depDir)) || [] } catch (e) { continue }
399
+ // Only names the descriptor itself would look for (python3/python/py, …), so a bundled
400
+ // runtime cannot smuggle in an unrelated binary.
401
+ for (const c of cands) {
402
+ for (const ext of exts) {
403
+ const hit = entries.find((e) => e && e.type === 'file' && String(e.name) === c + ext)
404
+ if (hit) out.push(depDir + '/' + hit.name)
405
+ }
406
+ }
407
+ }
408
+ }
409
+ }
410
+ return Array.from(new Set(out))
411
+ }
412
+
413
+ // round-7 (live root cause): probe-ish spawns used `projectRoot()` as cwd, but on a BRAND-NEW session
414
+ // that directory does not exist yet, and the host answers such a spawn with `spawned:true, exit:null`
415
+ // (retries cannot help - same cwd, same result). Runs are unaffected because they write the receipt
416
+ // (creating the tree) before spawning. So probes use a cwd that is guaranteed to exist: an explicit
417
+ // host `probeCwd`, else the OS temp dir, else the project root.
418
+ async function probeCwd(H) {
419
+ if (typeof H.probeCwd === 'function') {
420
+ try { const p = await H.probeCwd(); if (typeof p === 'string' && p) return p } catch (e) { /* fall through */ }
421
+ }
422
+ const env = (typeof process !== 'undefined' && process.env) || {}
423
+ const tmp = env.TEMP || env.TMPDIR || env.TMP
424
+ if (typeof tmp === 'string' && tmp) return tmp
425
+ return await H.projectRoot()
426
+ }
427
+
428
+ async function probeVersion(H, d, exe, opts) {
429
+ const o = opts || {}
430
+ const argv = [exe].concat(d.versionArgv || [])
431
+ const timeoutMs = Number(o.timeoutMs) || 20000 // a COLD host runner can take seconds; 5s was too tight
432
+ const r = await H.spawn({ argv: argv, cwd: await probeCwd(H), timeoutMs: timeoutMs, stdoutCap: 8192, stderrCap: 8192 })
433
+ const diag = {
434
+ argv: argv.slice(0, 3),
435
+ exit: r ? (r.exit === undefined ? null : r.exit) : null,
436
+ timedOut: !!(r && r.timedOut),
437
+ ms: r ? r.ms : null,
438
+ spawned: !!r,
439
+ stderrTail: String((r && r.stderr) || '').slice(-300),
440
+ }
441
+ if (!r) return { ok: false, version: 'unknown', diag: diag }
442
+ if (r.timedOut) return { ok: false, version: 'unknown', diag: diag }
346
443
  const m = new RegExp(d.versionRe).exec(String(r.stdout || '') + '\n' + String(r.stderr || ''))
347
- return { ok: r.exit === 0 && !!m, version: m ? m[1] : 'unknown' }
444
+ return { ok: r.exit === 0 && !!m, version: m ? m[1] : 'unknown', diag: diag }
445
+ }
446
+
447
+ // round-7 (live-session fix): an engine that RESOLVED but whose version probe failed is retried once
448
+ // with a longer budget WHEN the failure looks like a cold-start/timeout (timedOut, or a spawn that
449
+ // produced no exit code). A deterministic failure (non-zero exit, unparsable output) is NOT retried -
450
+ // retrying every failure would double every probe spawn. The diagnostic travels with the result
451
+ // either way, so "存在但不可用" is never opaque.
452
+ // round-7 (live regression): diagnostics must be JSON-SAFE. An earlier version stored a retry diag
453
+ // that was the SAME object as its parent, so `JSON.stringify(response)` threw ("circular structure")
454
+ // and the caller saw no probe result at all. Project plain fields only, never keep back-references.
455
+ function jsonSafeDiag(d) {
456
+ if (!d || typeof d !== 'object') return null
457
+ const out = { argv: Array.isArray(d.argv) ? d.argv.map(String).slice(0, 4) : [] }
458
+ for (const k of ['exit', 'timedOut', 'ms', 'spawned', 'stderrTail', 'attempts', 'retried']) {
459
+ const v = d[k]
460
+ if (v === undefined) continue
461
+ out[k] = (v === null || typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') ? v : (Array.isArray(v) ? v.map((x) => (x && typeof x === 'object') ? jsonSafeDiag(x) : x) : null)
462
+ }
463
+ if (d.retryDiag) out.retryDiag = jsonSafeDiag(d.retryDiag)
464
+ return out
465
+ }
466
+
467
+ async function probeVersionWithRetry(H, d, exe) {
468
+ const attempts = []
469
+ let last = null
470
+ for (let i = 0; i < 3; i++) {
471
+ last = await probeVersion(H, d, exe, { timeoutMs: i === 0 ? 20000 : 45000 })
472
+ attempts.push(jsonSafeDiag(last.diag))
473
+ if (last.ok) break
474
+ // A NULL spawn (host returned nothing usable) and a timeout both look like the session-early
475
+ // flakiness the live test hit; a deterministic failure (non-zero exit, unparsable output) is not
476
+ // retried, so a genuinely broken engine is reported immediately instead of three times slower.
477
+ const looksCold = !!(last.diag && (last.diag.timedOut || last.diag.exit === null || !last.diag.spawned))
478
+ if (!looksCold) break
479
+ await new Promise((resolve) => setTimeout(resolve, 250 + i * 400))
480
+ }
481
+ if (last && last.diag) {
482
+ last.diag = jsonSafeDiag(last.diag)
483
+ last.diag.attempts = attempts.length
484
+ if (attempts.length > 1) {
485
+ last.diag.retried = true
486
+ // COPIES, never a reference to this same object (that produced the circular payload).
487
+ last.diag.retryDiag = attempts[attempts.length - 1]
488
+ }
489
+ }
490
+ return last
348
491
  }
349
492
 
350
493
  async function checkLicence(H, d, exe) {
351
494
  if (d.license !== 'commercial') return { ok: true }
352
495
  if (!d.licenseProbe) return { ok: false }
353
- const r = await H.spawn({ argv: [exe].concat(d.licenseProbe.argv), cwd: await H.projectRoot(), timeoutMs: 10000, stdoutCap: 8192, stderrCap: 8192 })
496
+ const r = await H.spawn({ argv: [exe].concat(d.licenseProbe.argv), cwd: await probeCwd(H), timeoutMs: 10000, stdoutCap: 8192, stderrCap: 8192 })
354
497
  if (!r || r.timedOut || r.exit !== 0) return { ok: false }
355
498
  if (d.licenseProbe.okWhen === 'trim-1') return { ok: String(r.stdout || '').trim() === '1' }
356
499
  if (d.licenseProbe.okWhen === 'not-unlicensed') return { ok: !/Unlicensed/i.test(String(r.stdout || '')) }
@@ -371,18 +514,34 @@ async function resolveEngine(H, requested, params, args) {
371
514
  let exe = null
372
515
  try { exe = await H.resolveExecutable(args.cli.command) } catch (e) { exe = null }
373
516
  if (!exe) return fail('MATH_ENGINE_NOT_FOUND', name, 'cli 命令无法解析:' + args.cli.command, { next: userInstallNext('cli') })
374
- return { ok: true, name: name, desc: d, exe: exe, version: 'unknown' }
517
+ // round-7 (finding 5): report the REAL version when the command's family is recognisable (the
518
+ // descriptor for that family carries the version probe); unknown commands stay 'unknown'.
519
+ let cliVersion = 'unknown'
520
+ let cliProbe = null
521
+ const fam = engineFamilyForExe(exe)
522
+ if (fam && MATH_ENGINES[fam]) {
523
+ const fv = await probeVersionWithRetry(H, MATH_ENGINES[fam], exe)
524
+ cliProbe = fv.diag || null
525
+ if (fv.ok) cliVersion = fv.version
526
+ }
527
+ return { ok: true, name: name, desc: d, exe: exe, version: cliVersion, family: fam || null, probe: cliProbe }
375
528
  }
376
529
  const hit = await resolveCandidate(H, d)
377
530
  if (!hit) continue
378
- const v = await probeVersion(H, d, hit)
531
+ const v = await probeVersionWithRetry(H, d, hit)
379
532
  if (!v.ok) {
380
533
  if (d.license === 'commercial' || d.versionOptional) {
381
534
  const lic = await checkLicence(H, d, hit)
382
535
  if (!lic.ok) return fail('MATH_ENGINE_LICENSE_REQUIRED', name, name + ' 已安装但许可不可用(仅厂商可激活)', { next: next('vendor', { engine: name, url: d.vendor || '' }) })
383
536
  return { ok: true, name: name, desc: d, exe: hit, version: 'unknown' }
384
537
  }
385
- return fail('MATH_ENGINE_UNUSABLE', name, name + ' 存在但不可用(版本探针失败)', { next: userInstallNext(name) })
538
+ // round-7 (live-session fix): the engine WAS found - so an install guide would be misleading
539
+ // (the user does not need to install anything; the host path is what failed). Report the probe
540
+ // diagnostic instead and keep the escape hatch advisory.
541
+ return fail('MATH_ENGINE_UNUSABLE', name, name + ' 存在但不可用(版本探针失败:exit=' + String(v.diag.exit) + (v.diag.timedOut ? ',超时' : '') + ',argv=' + JSON.stringify(v.diag.argv) + ')', {
542
+ probe: v.diag,
543
+ next: next('note', { guidance: '引擎已找到但探测失败:请检查宿主 subprocess 通路(冷启动/超时/权限),或用 mathEngineOverride 调整 versionArgv 模板;已装的引擎不需要重装。' }),
544
+ })
386
545
  }
387
546
  const lic = await checkLicence(H, d, hit)
388
547
  if (!lic.ok) return fail('MATH_ENGINE_LICENSE_REQUIRED', name, name + ' 已安装但许可不可用(仅厂商可激活)', { next: next('vendor', { engine: name, url: d.vendor || '' }) })
@@ -397,26 +556,63 @@ function buildProbeArgv(d, pkgs) {
397
556
  const quoted = pkgs.map((p) => '"' + String(p).replace(/"/g, '') + '"').join(',')
398
557
  const plain = pkgs.join(',')
399
558
  const code = String(d.probeCode || '').replace(/__QPKGS__/g, quoted).replace(/__PKGS__/g, plain)
400
- return (d.packageProbe.argv || []).map((a) => a === '<probeCode>' ? code : a === '<pkgs>' ? plain : a)
559
+ const out = []
560
+ for (const a of (d.packageProbe.argv || [])) {
561
+ // `<pkgs...>` expands to ONE argv item per package (probes that read `sys.argv`); `<pkgs>` stays
562
+ // the comma-joined string for templates that interpolate it into code (R/Julia/Octave).
563
+ if (a === '<probeCode>') out.push(code)
564
+ else if (a === '<pkgs>') out.push(plain)
565
+ else if (a === '<pkgs...>') for (const p of pkgs) out.push(String(p))
566
+ else out.push(a)
567
+ }
568
+ return out
401
569
  }
402
570
 
403
571
  async function probePackages(H, det, pkgs) {
572
+ // round-6 (D): presence is probed by BASE NAME (a version constraint is the manager's business);
573
+ // the full spec is what the install plan passes through.
574
+ const names = []
575
+ for (const s of pkgs) { const n = parsePackageSpec(s).name || String(s); if (names.indexOf(n) === -1) names.push(n) }
404
576
  const found = {}
405
- for (const p of pkgs) found[p] = null
406
- if (!pkgs.length || !det.desc.packageProbe) return { requested: pkgs.slice(), found: found }
407
- const r = await H.spawn({ argv: [det.exe].concat(buildProbeArgv(det.desc, pkgs)), cwd: await H.projectRoot(), timeoutMs: 15000, stdoutCap: 16384, stderrCap: 16384 })
408
- if (r && !r.timedOut) {
409
- if (det.desc.packageProbe.parse === 'lines') {
410
- const text = String(r.stdout || '')
411
- for (const p of pkgs) if (new RegExp('(^|\\s)' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\s|$)', 'm').test(text)) found[p] = 'present'
412
- } else {
413
- for (const tok of String(r.stdout || '').split('|')) {
414
- const idx = tok.indexOf(':')
415
- if (idx <= 0) continue
416
- const name = tok.slice(0, idx).trim()
417
- const state = tok.slice(idx + 1).trim()
418
- if (Object.prototype.hasOwnProperty.call(found, name)) found[name] = (state === 'ok' || state === '1' || state === 'TRUE' || state === 'True') ? 'present' : null
577
+ for (const p of names) found[p] = null
578
+ if (!names.length || !det.desc.packageProbe) return { requested: names, found: found }
579
+ const argv = [det.exe].concat(buildProbeArgv(det.desc, names))
580
+ // round-7 (live-session fix): a NULL spawn (the host's subprocess seam returned nothing - seen in
581
+ // real sessions right after session start) or a timeout must NOT be reported as "package missing".
582
+ // Retry a couple of times, and if the precheck still cannot run, say so explicitly (fail-open).
583
+ let r = null
584
+ const attempts = []
585
+ for (let i = 0; i < 3; i++) {
586
+ r = await H.spawn({ argv: argv, cwd: await probeCwd(H), timeoutMs: 15000 + i * 15000, stdoutCap: 16384, stderrCap: 16384 })
587
+ attempts.push(r ? { exit: r.exit === undefined ? null : r.exit, timedOut: !!r.timedOut } : null)
588
+ const usable = !!(r && !r.timedOut && r.exit !== null && r.exit !== undefined)
589
+ if (usable) break
590
+ await new Promise((resolve) => setTimeout(resolve, 250 + i * 400))
591
+ }
592
+ const ran = !!(r && !r.timedOut && r.exit !== null && r.exit !== undefined)
593
+ if (!ran) return { requested: names, found: found, probeFailed: true, attempts: attempts, argv: argv.slice(0, 3) }
594
+ if (det.desc.packageProbe.parse === 'lines') {
595
+ const text = String(r.stdout || '')
596
+ for (const p of pkgs) if (new RegExp('(^|\\s)' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\s|$)', 'm').test(text)) found[p] = 'present'
597
+ } else {
598
+ // Accept BOTH shapes: `name:ok|name:missing` (historical) and one-per-line `name ok` (round-7:
599
+ // the probe codes avoid shell metacharacters such as `|`, which a real host's spawn rejected).
600
+ for (const rawTok of String(r.stdout || '').split(/[|\n]/)) {
601
+ const tok = rawTok.trim()
602
+ if (!tok) continue
603
+ let name = ''
604
+ let state = ''
605
+ const idx = tok.indexOf(':')
606
+ if (idx > 0) {
607
+ name = tok.slice(0, idx).trim()
608
+ state = tok.slice(idx + 1).trim()
609
+ } else {
610
+ const m = /^(\S+)\s+(\S+)$/.exec(tok)
611
+ if (!m) continue
612
+ name = m[1]
613
+ state = m[2]
419
614
  }
615
+ if (Object.prototype.hasOwnProperty.call(found, name)) found[name] = (state === 'ok' || state === '1' || state === 'TRUE' || state === 'True') ? 'present' : null
420
616
  }
421
617
  }
422
618
  return { requested: pkgs.slice(), found: found }
@@ -427,7 +623,24 @@ function assembleArgv(det, mode, payload) {
427
623
  const d = det.desc
428
624
  if (det.name === 'cli') {
429
625
  const cliArgv = (payload.cli && Array.isArray(payload.cli.argv)) ? payload.cli.argv : []
430
- return { argv: [det.exe].concat(cliArgv), cli: { command: payload.cli.command, argv: cliArgv } }
626
+ const argv = [det.exe].concat(cliArgv)
627
+ // round-7 (finding 4): with mode:'code' the script is archived as `script.txt`, but the caller
628
+ // cannot know the runId in advance - so the archived script path is appended automatically and
629
+ // the documented "run this code" flow actually RUNS it (instead of dropping into a REPL, exit 0,
630
+ // empty stdout).
631
+ // round-7 (live nuance): ONLY when the caller's argv does not already provide a program slot.
632
+ // `python -c 'print(1)'` runs the caller's code, and appending the archived file would silently
633
+ // feed it as `sys.argv[1]` - the caller's argv must win there.
634
+ const runsOwnCode = cliArgv.some((a) => a === '-c' || a === '-m' || a === '-e' || a === '--eval' || a === '--command')
635
+ const appendScript = !!payload.scriptAbs && (mode === 'code' || mode === 'file') && !runsOwnCode
636
+ if (appendScript) argv.push(payload.scriptAbs)
637
+ return {
638
+ argv: argv,
639
+ cli: {
640
+ command: payload.cli.command, argv: cliArgv, scriptAppended: appendScript,
641
+ scriptSkipped: (!!payload.scriptAbs && (mode === 'code' || mode === 'file') && runsOwnCode) ? 'caller argv already provides a program slot (-c/-m/-e/--eval)' : null,
642
+ },
643
+ }
431
644
  }
432
645
  if (mode === 'code' || mode === 'file') {
433
646
  return { argv: [det.exe].concat((d.scriptArgv || []).map((a) => a === '<script>' ? payload.scriptAbs : a)) }
@@ -439,6 +652,19 @@ function assembleArgv(det, mode, payload) {
439
652
  return { refused: 'unknown mode' }
440
653
  }
441
654
 
655
+ // round-7 (findings 3/5): for `engine:'cli'` the descriptor is generic, so the FAMILY of the resolved
656
+ // command has to be inferred from its basename to (a) run the right package precheck and (b) report a
657
+ // real version. Unknown commands are reported as unknown and their package precheck is SKIPPED (a
658
+ // false "missing" must never block the escape hatch).
659
+ function engineFamilyForExe(exe) {
660
+ const base = String(exe || '').replace(/\\/g, '/').split('/').pop().toLowerCase().replace(/\.(exe|cmd|bat|sh)$/, '')
661
+ if (/^python[0-9.]*$/.test(base) || base === 'py') return 'python'
662
+ if (base === 'rscript' || base === 'r') return 'r'
663
+ if (base === 'octave' || base === 'octave-cli') return 'octave'
664
+ if (base === 'julia') return 'julia'
665
+ return null
666
+ }
667
+
442
668
  // AUDIT-B FIX (HIGH): `applyOverride` used to read only `params.mathEngineOverride`, which no preset
443
669
  // can ever set (the six frozen params are copied verbatim by effectiveMathParams), while the tool
444
670
  // description, MATH_BAD_ARGV_HINT and the BAD_ARGV message all advertised it. The per-call ARGUMENT
@@ -561,18 +787,55 @@ async function opProbe(H, args, params) {
561
787
  const probe = await probeMathEngines(H, { refresh: !!(args.packages && args.packages.indexOf('refresh') !== -1) })
562
788
  const pkgs = (args.packages || []).filter((p) => p !== 'refresh')
563
789
  const want = pkgs.length ? pkgs : params.mathPackages
790
+ // round-7 (fix 1): a probe that NAMES an engine must describe THAT engine. Previously the response
791
+ // carried `engine: null` (or the first ALLOWED engine) and the failure path always blamed
792
+ // `params.mathEngines[0]` (python) - so `probe {engine:'r'}` echoed python and told the user to
793
+ // install python. Resolve the requested engine up front instead.
794
+ const requested = (args.engine && args.engine !== 'auto') ? String(args.engine) : null
795
+ const chosen = requested
796
+ ? await resolveEngine(H, requested, params, args)
797
+ : (probe.engines.length ? await resolveEngine(H, probe.engines[0].name, params, args) : null)
564
798
  let packages = { requested: want.slice(), found: {} }
565
- if (want.length && probe.engines.length) {
566
- const det = await resolveEngine(H, args.engine && args.engine !== 'auto' ? args.engine : probe.engines[0].name, params, args)
567
- if (det.ok) packages = await probePackages(H, det, want)
799
+ const probeDet = familyProbeDet(chosen)
800
+ if (want.length && probeDet) packages = await probePackages(H, probeDet, want)
801
+ else if (want.length && chosen && chosen.ok && chosen.name === 'cli') packages = { requested: want.map((s) => parsePackageSpec(s).name || s), found: {}, precheckSkipped: 'engine=cli:无法从命令名判断引擎族(不阻塞)' }
802
+ if (requested && chosen && !chosen.ok) {
803
+ // Policy/absence refusal for the REQUESTED engine, with its own guidance.
804
+ const out = Object.assign({}, chosen)
805
+ out.op = 'probe'
806
+ out.engines = probe.engines
807
+ out.available = !!probe.available
808
+ out.packages = packages
809
+ return out
568
810
  }
569
- if (!probe.available) {
570
- const first = params.mathEngines[0] || 'python'
571
- const out = fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎', { next: userInstallNext(first), engines: [], available: false, packages: packages })
811
+ if (!probe.available && !(chosen && chosen.ok)) {
812
+ 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 })
572
814
  out.op = 'probe'
573
815
  return out
574
816
  }
575
- return { ok: true, op: 'probe', engine: null, engines: probe.engines, available: true, packages: packages, message: '可用引擎:' + probe.engines.map((e) => e.name + ' ' + e.version).join('、') }
817
+ // A REQUESTED engine that resolved (e.g. `cli`, or a runtime-discovered interpreter) counts as
818
+ // available even when auto-detection found nothing - otherwise probe denied what run would do.
819
+ const engines = probe.engines.slice()
820
+ 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
824
+ return {
825
+ ok: true, op: 'probe', engine: engineInfo ? engineInfo.name : null, engineInfo: engineInfo,
826
+ engines: engines, available: true, packages: packages,
827
+ message: '可用引擎:' + engines.map((e) => e.name + ' ' + e.version).join('、') + (engineInfo ? '(请求 ' + engineInfo.name + ':' + engineInfo.version + ')' : ''),
828
+ }
829
+ }
830
+
831
+ // round-7 (finding 3): a cli command has no package probe of its own, so the FAMILY descriptor of
832
+ // the resolved executable must provide one. Returns null when the family is unknown - the caller then
833
+ // SKIPS the precheck (never a false "missing", which blocked the escape hatch).
834
+ function familyProbeDet(det) {
835
+ if (!det || det.name !== 'cli') return det
836
+ const fam = det.family || engineFamilyForExe(det.exe)
837
+ const fd = fam ? MATH_ENGINES[fam] : null
838
+ return (fd && fd.packageProbe) ? { ok: true, name: fam, desc: fd, exe: det.exe, version: det.version } : null
576
839
  }
577
840
 
578
841
  // ── op: run ─────────────────────────────────────────────────────────────────────────────────────
@@ -588,12 +851,19 @@ async function opRun(H, args, params) {
588
851
  const engineLabel = det.name === 'cli' ? ('cli:' + cliCommand) : det.name
589
852
 
590
853
  const want = (args.packages && args.packages.length) ? args.packages : params.mathPackages
591
- const pk = await probePackages(H, det2, want)
592
- const missing = want.filter((p) => Object.prototype.hasOwnProperty.call(pk.found, p) && !pk.found[p])
854
+ // round-7 (finding 3): for cli the precheck runs against the FAMILY of `cli.command` (same
855
+ // discovery-shaped logic); an unrecognisable command SKIPS the precheck instead of reporting a
856
+ // false "missing" that would block the escape hatch.
857
+ const probeDet = familyProbeDet(det2)
858
+ const pk = probeDet ? await probePackages(H, probeDet, want) : { requested: want.map((s) => parsePackageSpec(s).name || s), found: {} }
859
+ // Presence is keyed by BASE NAME (round-6 D); the plan keeps the original specs so the manager
860
+ // receives the version constraint verbatim. round-7: a precheck that could not RUN (null spawn /
861
+ // timeout) must not be read as "missing" - that misled a real session.
862
+ const missing = (probeDet && !pk.probeFailed) ? want.map((s) => parsePackageSpec(s).name || s).filter((p) => Object.prototype.hasOwnProperty.call(pk.found, p) && !pk.found[p]) : []
593
863
  if (missing.length) {
594
- return fail('MATH_MISSING_PACKAGES', det.name, '需要的包未安装:' + missing.join(', '), {
864
+ return fail('MATH_MISSING_PACKAGES', det.name, '需要的包未安装:' + missing.join(', ') + '(只检查是否存在;版本求解交给包管理器)', {
595
865
  missing: missing,
596
- next: next('agent-install', { engine: det.name, packages: missing, dryRun: true }),
866
+ next: next('agent-install', { engine: det.name, packages: missing, specs: want.slice(), dryRun: true }),
597
867
  packages: pk,
598
868
  })
599
869
  }
@@ -669,6 +939,8 @@ async function opRun(H, args, params) {
669
939
  }
670
940
 
671
941
  const warnings = []
942
+ if (!probeDet && want.length) warnings.push(warning('PACKAGE_PRECHECK_SKIPPED', 'engine=cli:无法从命令名判断引擎族,已跳过包预检(不阻塞执行,也不会误报缺包);如需预检请直接用 python/r/octave/julia 或改用可识别的解释器路径。'))
943
+ if (pk && pk.probeFailed && want.length) warnings.push(warning('PACKAGE_PRECHECK_UNKNOWN', '包预检未能执行(宿主 subprocess 在这几次尝试里返回空/超时,会话早期常见):本次不判定缺包、不阻塞执行;如需确定性结论请重试或直接用对应引擎。probe=' + JSON.stringify(pk.argv) + ' attempts=' + JSON.stringify(pk.attempts)))
672
944
  if (fullOut.length > MATH_CAPS.stdout) warnings.push(warning('OUTPUT_TRUNCATED', 'stdout 超过 64KB,完整版见 ' + dir + '/stdout.txt'))
673
945
  if (scriptChanged) warnings.push(warning(MATH_SCRIPT_CHANGED_WARNING, 'scriptChanged:文件 ' + fileRel + ' 自上一次回执以来已改变(旧回执不再代表当前代码),本次已写出新 attempt ' + attempt + '。'))
674
946
  if (scriptChangedDuringRun) warnings.push(warning(MATH_SCRIPT_CHANGED_DURING_RUN_WARNING, 'scriptChangedDuringRun:文件 ' + fileRel + ' 在本次运行期间被改动(可能有另一个成员在编辑);本次回执的 scriptHash 是运行前版本 ' + String(sourceHashBefore).slice(0, 12) + '…,请重跑后再引用。'))
@@ -698,7 +970,7 @@ async function opRun(H, args, params) {
698
970
  warnings: warnings,
699
971
  determinism: { inputSha256: input, packagesSorted: true, noWallClockInId: true },
700
972
  }
701
- if (assembled.cli) receipt.cli = { command: assembled.cli.command, argv: assembled.cli.argv, stdinUsed: false }
973
+ if (assembled.cli) receipt.cli = { command: assembled.cli.command, argv: assembled.cli.argv, stdinUsed: false, scriptAppended: !!assembled.cli.scriptAppended, scriptSkipped: assembled.cli.scriptSkipped || null }
702
974
  const receiptJson = JSON.stringify(receipt, null, 2)
703
975
  await H.writeText(dir + '/receipt.json', receiptJson)
704
976
  await H.writeText(dir + '/receipt.md', renderReceiptMd(receipt, fullOut, fullErr))
@@ -714,6 +986,7 @@ async function opRun(H, args, params) {
714
986
  scriptChanged: scriptChanged, scriptChangedDuringRun: scriptChangedDuringRun,
715
987
  sourceFile: fileRel || null,
716
988
  previousReceipt: receipt.previousReceipt,
989
+ cliScriptAppended: !!(receipt.cli && receipt.cli.scriptAppended),
717
990
  packages: pk.found,
718
991
  exit: receipt.exit, timedOut: receipt.timedOut, ms: receipt.ms,
719
992
  stdout: fullOut.slice(0, MATH_CAPS.stdout), stderr: fullErr.slice(0, MATH_CAPS.stderr),
@@ -832,15 +1105,44 @@ async function opInstall(H, args, params) {
832
1105
  })
833
1106
  }
834
1107
  const scope = args.scope || params.mathInstallScope || 'user' // per-call only; never persisted anywhere
835
- const tmpl = scope === 'system' ? d.install.systemArgv : d.install.userArgv
836
- if (!tmpl) return fail('MATH_REFUSED', engineName, engineName + ' 不支持 system 作用域(只提供用户级安装)', { next: next('reason', { reason: 'system-scope-unsupported' }) })
1108
+ // round-6 (B): resolve the interpreter the SAME way the run path does (the descriptor's candidate
1109
+ // list, in order) so the plan targets the interpreter that will actually run - and so the manager
1110
+ // detection sees that interpreter's REAL path (a conda `python3` is not a bare `python`).
837
1111
  let exe = engineName
838
- try { exe = (await H.resolveExecutable(engineName === 'r' ? 'Rscript' : engineName)) || engineName } catch (e) { exe = engineName }
1112
+ {
1113
+ const cands = mathEngineCandidates(engineName) || [engineName === 'r' ? 'Rscript' : engineName]
1114
+ for (const c of cands) {
1115
+ try { const hit = await H.resolveExecutable(c); if (hit) { exe = hit; break } } catch (e) { /* try the next candidate */ }
1116
+ }
1117
+ }
1118
+ // round-6 (B): python dispatches on the DETECTED environment; every other engine keeps its own
1119
+ // single template. The chosen manager (and whether it is only an assumption) is part of the plan.
1120
+ let managerInfo = null
1121
+ let mgrTmpl = null
1122
+ if (d.install.managers) {
1123
+ managerInfo = await detectPythonManager(H, exe)
1124
+ mgrTmpl = d.install.managers[managerInfo.manager] || null
1125
+ }
1126
+ const activeManager = managerInfo ? managerInfo.manager : d.install.manager
1127
+ const userTmpl = (mgrTmpl && mgrTmpl.userArgv) || d.install.userArgv
1128
+ const systemTmpl = (mgrTmpl && mgrTmpl.systemArgv) || (mgrTmpl ? null : d.install.systemArgv)
1129
+ const tmpl = scope === 'system' ? systemTmpl : userTmpl
1130
+ if (!tmpl) {
1131
+ // round-6 (C): the refusal says WHY per engine (or per detected manager) instead of a generic line.
1132
+ const why = (mgrTmpl && mgrTmpl.systemUnsupportedReason) || d.install.systemUnsupportedReason || '该引擎只提供用户级安装'
1133
+ return fail('MATH_REFUSED', engineName, engineName + ' 不支持 system 作用域:' + why, { next: next('reason', { reason: 'system-scope-unsupported' }) })
1134
+ }
1135
+ const uninstallTmpl = (mgrTmpl && mgrTmpl.uninstallArgv) || d.install.uninstallArgv
839
1136
  const commands = args.packages.map((p) => ({ argv: substInstall(tmpl, engineName, exe, p), why: '缺少 ' + p }))
840
- const plan = { engine: engineName, scope: scope, manager: d.install.manager, commands: commands, note: scope === 'user' ? '将安装到用户级目录' : '仅本次系统作用域(不会被记住)' }
1137
+ const plan = {
1138
+ engine: engineName, scope: scope, manager: activeManager, commands: commands,
1139
+ managerAssumed: !!(managerInfo && managerInfo.assumed), managerWhy: managerInfo ? managerInfo.why : '',
1140
+ versionPolicy: '版本求解交给包管理器(本工具只检查是否已安装,并把 pkg<op>version 原样透传)',
1141
+ note: scope === 'user' ? '将安装到用户级目录' : '仅本次系统作用域(不会被记住)',
1142
+ }
841
1143
  const planToken = sha256(JSON.stringify({ engine: engineName, packages: args.packages, scope: scope, commands: commands }) + '|planVersion=1').slice(0, 32)
842
1144
  if (!args.confirm) {
843
- return { ok: true, op: 'install', engine: engineName, plan: plan, planToken: planToken, next: next('agent-install', { dryRun: true }), message: '安装计划(尚未执行):' + commands.map((c) => c.argv.join(' ')).join(' ; ') }
1145
+ return { ok: true, op: 'install', engine: engineName, plan: plan, planToken: planToken, next: next('agent-install', { dryRun: true, manager: activeManager, managerAssumed: !!plan.managerAssumed }), message: '安装计划(尚未执行,管理器=' + activeManager + (plan.managerAssumed ? '(假设:' + plan.managerWhy + ')' : '') + '):' + commands.map((c) => c.argv.join(' ')).join(' ; ') }
844
1146
  }
845
1147
  if (args.confirm !== planToken) {
846
1148
  return fail('MATH_REFUSED', engineName, 'planToken 不匹配(计划可能已变),请重新出计划', { next: next('reason', { reason: 'plan-token-mismatch' }) })
@@ -851,7 +1153,7 @@ async function opInstall(H, args, params) {
851
1153
  const r = await H.spawn({ argv: c.argv, cwd: await H.projectRoot(), timeoutMs: timeoutMs, stdoutCap: MATH_CAPS.file, stderrCap: MATH_CAPS.file })
852
1154
  results.push({ argv: c.argv, exit: r ? r.exit : null, timedOut: !!(r && r.timedOut), ms: r ? r.ms : 0, stdoutTail: String((r && r.stdout) || '').slice(-2000), stderrTail: String((r && r.stderr) || '').slice(-2000) })
853
1155
  if (!r || r.timedOut || r.exit !== 0) {
854
- const audit0 = { schema: 'vibe-math/math-computation-install@1', planToken: planToken, scope: scope, manager: d.install.manager, commands: commands, results: results, exit: r ? r.exit : null, timedOut: !!(r && r.timedOut), installed: args.packages, rollback: rollbackFor(d, exe, args.packages), network: 'not-enforced-by-plugin' }
1156
+ const audit0 = { schema: 'vibe-math/math-computation-install@1', planToken: planToken, scope: scope, manager: activeManager, commands: commands, results: results, exit: r ? r.exit : null, timedOut: !!(r && r.timedOut), installed: args.packages, rollback: rollbackFor(d, exe, args.packages, uninstallTmpl), network: 'not-enforced-by-plugin' }
855
1157
  await H.writeText('Computation/installs/' + planToken + '.json', JSON.stringify(audit0, null, 2))
856
1158
  const code = (r && r.timedOut) ? 'MATH_TIMEOUT' : 'MATH_NONZERO_EXIT'
857
1159
  const out = fail(code, engineName, '安装失败(' + c.argv.join(' ') + '):' + (r ? ('exit=' + r.exit) : 'no subprocess'), { next: next('note', { audit: 'Computation/installs/' + planToken + '.json' }) })
@@ -859,13 +1161,54 @@ async function opInstall(H, args, params) {
859
1161
  return out
860
1162
  }
861
1163
  }
862
- const audit = { schema: 'vibe-math/math-computation-install@1', planToken: planToken, scope: scope, manager: d.install.manager, commands: commands, results: results, exit: 0, timedOut: false, installed: args.packages, before: {}, after: {}, rollback: rollbackFor(d, exe, args.packages), network: 'not-enforced-by-plugin' }
1164
+ 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' }
863
1165
  await H.writeText('Computation/installs/' + planToken + '.json', JSON.stringify(audit, null, 2))
864
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)' }
865
1167
  }
866
1168
 
867
- function rollbackFor(d, exe, packages) {
868
- const tmpl = d.install && d.install.uninstallArgv
1169
+ // ── round-6 (B/D): package specs + Python package-manager dispatch ──────────────────────────────
1170
+ // Version SOLVING is delegated to the package manager: the tool only checks presence (by base name)
1171
+ // and passes the spec through. The accepted grammar is small and manager-understood; anything else
1172
+ // (spaces, `;`, `|`, `&`, `$`, backticks, `@`, parentheses, empty) is refused with an explicit
1173
+ // reason instead of being handed to a shell.
1174
+ const PKG_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?(?:(?:==|>=|<=|~=|!=|>|<|=)[A-Za-z0-9._*+!-]+)?$/
1175
+ export function parsePackageSpec(spec) {
1176
+ const raw = String(spec == null ? '' : spec).trim()
1177
+ if (!raw) return { ok: false, problem: 'empty package spec' }
1178
+ if (!PKG_SPEC_RE.test(raw)) {
1179
+ return { ok: false, problem: 'unsupported version syntax: "' + raw + '"(版本求解交给包管理器:请用它能理解的形式,例如 pip 的 pkg==1.2 或 conda 的 pkg=1.2;本工具只检查是否已安装)' }
1180
+ }
1181
+ const name = raw.replace(/(?:==|>=|<=|~=|!=|>|<|=).*$/, '').replace(/\[.*$/, '')
1182
+ return { ok: true, name: name, spec: raw, pinned: raw !== name }
1183
+ }
1184
+
1185
+ // Detect WHICH python package manager the interpreter's own environment implies - detect, don't
1186
+ // guess. conda/mamba markers live in the interpreter path (…/envs/<name>/… or …/miniconda3/…) and
1187
+ // must be confirmed by resolving the manager; uv counts only when it sits NEXT TO the interpreter
1188
+ // (a uv-managed venv). Anything ambiguous falls back to pip, and `assumed: true` travels into the
1189
+ // plan/message so the operator is told which manager the plan assumes.
1190
+ async function detectPythonManager(H, exe) {
1191
+ const p = String(exe || '').replace(/\\/g, '/').toLowerCase()
1192
+ if (/\/envs\/|\/conda|\/miniconda|\/anaconda|\/mambaforge|\/miniforge/.test(p)) {
1193
+ for (const mgr of ['conda', 'mamba']) {
1194
+ try {
1195
+ const found = await H.resolveExecutable(mgr)
1196
+ if (found) return { manager: mgr, assumed: false, why: '解释器路径看起来像 ' + mgr + ' 环境(' + p.split('/').slice(-3).join('/') + ')' }
1197
+ } catch (e) { /* try the next candidate */ }
1198
+ }
1199
+ }
1200
+ try {
1201
+ const uv = await H.resolveExecutable('uv')
1202
+ if (uv) {
1203
+ const dirOf = (x) => String(x).replace(/\\/g, '/').replace(/\/[^/]*$/, '')
1204
+ if (dirOf(uv) === dirOf(exe)) return { manager: 'uv', assumed: false, why: 'uv 与解释器同目录(uv 管理的虚拟环境)' }
1205
+ }
1206
+ } catch (e) { /* fall through to pip */ }
1207
+ return { manager: 'pip', assumed: true, why: '未检测到 conda/mamba 标记,也没有与解释器同目录的 uv ⇒ 按文档回退到 pip(计划中的管理器是**假设**)' }
1208
+ }
1209
+
1210
+ function rollbackFor(d, exe, packages, uninstallTmpl) {
1211
+ const tmpl = uninstallTmpl || (d.install && d.install.uninstallArgv)
869
1212
  if (!tmpl) return { supported: 'partial', commands: [], note: '该管理器未提供卸载模板;本工具不做自动回滚' }
870
1213
  return { supported: 'partial', commands: packages.map((p) => substInstall(tmpl, d.name, exe, p)), note: '包管理器各自支持卸载;本工具不做自动回滚' }
871
1214
  }