release-skill 0.9.0 → 0.9.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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +81 -0
  7. package/INSTALL.md +22 -9
  8. package/INSTALL.zh-CN.md +15 -8
  9. package/README.md +40 -35
  10. package/README.zh-CN.md +31 -29
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/consumer-contract-vectors.json +110 -0
  14. package/adapters/claude/bin/error-codes.json +8 -1
  15. package/adapters/claude/bin/foundation-resource-binding.json +1 -1
  16. package/adapters/claude/bin/registry.json +22 -1
  17. package/adapters/claude/bin/release-skill.bundle.mjs +4772 -2238
  18. package/adapters/claude/bin/rules.json +1 -1
  19. package/adapters/claude/bin/schemas/consumer-contract-vector.schema.json +149 -0
  20. package/adapters/claude/schemas/release-project.schema.json +4 -0
  21. package/adapters/claude/schemas/release-run.schema.json +41 -1
  22. package/adapters/claude/skills/release-config/SKILL.md +5 -3
  23. package/adapters/claude/skills/release-docs/SKILL.md +4 -2
  24. package/adapters/claude/skills/release-finish/SKILL.md +6 -4
  25. package/adapters/claude/skills/release-help/SKILL.md +14 -5
  26. package/adapters/claude/skills/release-marketplace/SKILL.md +3 -1
  27. package/adapters/claude/skills/release-prepare/SKILL.md +4 -2
  28. package/adapters/claude/skills/release-publish/SKILL.md +19 -10
  29. package/adapters/claude/skills/release-verify/SKILL.md +2 -0
  30. package/adapters/claude/src/schemas/executable-identity-observation.schema.json +162 -0
  31. package/adapters/claude/src/schemas/filesystem-tree-observation.schema.json +18 -2
  32. package/adapters/claude/src/schemas/migration-manifest.schema.json +271 -7
  33. package/adapters/claude/src/schemas/plugin-verification-request.schema.json +147 -22
  34. package/adapters/claude/src/schemas/plugin-verification-result.schema.json +436 -18
  35. package/adapters/claude/src/schemas/profile-adoption-declaration.schema.json +8 -0
  36. package/adapters/claude/src/schemas/skill-family-directory-verification-request.schema.json +100 -0
  37. package/adapters/claude/src/schemas/skill-family-directory-verification-result.schema.json +193 -0
  38. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  39. package/adapters/codex/bin/consumer-contract-vectors.json +110 -0
  40. package/adapters/codex/bin/error-codes.json +8 -1
  41. package/adapters/codex/bin/foundation-resource-binding.json +1 -1
  42. package/adapters/codex/bin/registry.json +22 -1
  43. package/adapters/codex/bin/release-skill.bundle.mjs +4772 -2238
  44. package/adapters/codex/bin/rules.json +1 -1
  45. package/adapters/codex/bin/schemas/consumer-contract-vector.schema.json +149 -0
  46. package/adapters/codex/schemas/release-project.schema.json +4 -0
  47. package/adapters/codex/schemas/release-run.schema.json +41 -1
  48. package/adapters/codex/skills/release-config/SKILL.md +5 -3
  49. package/adapters/codex/skills/release-docs/SKILL.md +4 -2
  50. package/adapters/codex/skills/release-finish/SKILL.md +6 -4
  51. package/adapters/codex/skills/release-help/SKILL.md +14 -5
  52. package/adapters/codex/skills/release-marketplace/SKILL.md +3 -1
  53. package/adapters/codex/skills/release-prepare/SKILL.md +4 -2
  54. package/adapters/codex/skills/release-publish/SKILL.md +19 -10
  55. package/adapters/codex/skills/release-verify/SKILL.md +2 -0
  56. package/adapters/codex/src/schemas/executable-identity-observation.schema.json +162 -0
  57. package/adapters/codex/src/schemas/filesystem-tree-observation.schema.json +18 -2
  58. package/adapters/codex/src/schemas/migration-manifest.schema.json +271 -7
  59. package/adapters/codex/src/schemas/plugin-verification-request.schema.json +147 -22
  60. package/adapters/codex/src/schemas/plugin-verification-result.schema.json +436 -18
  61. package/adapters/codex/src/schemas/profile-adoption-declaration.schema.json +8 -0
  62. package/adapters/codex/src/schemas/skill-family-directory-verification-request.schema.json +100 -0
  63. package/adapters/codex/src/schemas/skill-family-directory-verification-result.schema.json +193 -0
  64. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  65. package/adapters/kimi/bin/consumer-contract-vectors.json +110 -0
  66. package/adapters/kimi/bin/error-codes.json +8 -1
  67. package/adapters/kimi/bin/foundation-resource-binding.json +1 -1
  68. package/adapters/kimi/bin/registry.json +22 -1
  69. package/adapters/kimi/bin/release-skill.bundle.mjs +4772 -2238
  70. package/adapters/kimi/bin/rules.json +1 -1
  71. package/adapters/kimi/bin/schemas/consumer-contract-vector.schema.json +149 -0
  72. package/adapters/kimi/schemas/release-project.schema.json +4 -0
  73. package/adapters/kimi/schemas/release-run.schema.json +41 -1
  74. package/adapters/kimi/skills/release-config/SKILL.md +5 -3
  75. package/adapters/kimi/skills/release-docs/SKILL.md +4 -2
  76. package/adapters/kimi/skills/release-finish/SKILL.md +6 -4
  77. package/adapters/kimi/skills/release-help/SKILL.md +14 -5
  78. package/adapters/kimi/skills/release-marketplace/SKILL.md +3 -1
  79. package/adapters/kimi/skills/release-prepare/SKILL.md +4 -2
  80. package/adapters/kimi/skills/release-publish/SKILL.md +19 -10
  81. package/adapters/kimi/skills/release-verify/SKILL.md +2 -0
  82. package/adapters/kimi/src/schemas/executable-identity-observation.schema.json +162 -0
  83. package/adapters/kimi/src/schemas/filesystem-tree-observation.schema.json +18 -2
  84. package/adapters/kimi/src/schemas/migration-manifest.schema.json +271 -7
  85. package/adapters/kimi/src/schemas/plugin-verification-request.schema.json +147 -22
  86. package/adapters/kimi/src/schemas/plugin-verification-result.schema.json +436 -18
  87. package/adapters/kimi/src/schemas/profile-adoption-declaration.schema.json +8 -0
  88. package/adapters/kimi/src/schemas/skill-family-directory-verification-request.schema.json +100 -0
  89. package/adapters/kimi/src/schemas/skill-family-directory-verification-result.schema.json +193 -0
  90. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  91. package/adapters/workbuddy/bin/consumer-contract-vectors.json +110 -0
  92. package/adapters/workbuddy/bin/error-codes.json +8 -1
  93. package/adapters/workbuddy/bin/foundation-resource-binding.json +1 -1
  94. package/adapters/workbuddy/bin/registry.json +22 -1
  95. package/adapters/workbuddy/bin/release-skill.bundle.mjs +4772 -2238
  96. package/adapters/workbuddy/bin/rules.json +1 -1
  97. package/adapters/workbuddy/bin/schemas/consumer-contract-vector.schema.json +149 -0
  98. package/adapters/workbuddy/schemas/release-project.schema.json +4 -0
  99. package/adapters/workbuddy/schemas/release-run.schema.json +41 -1
  100. package/adapters/workbuddy/skills/release-config/SKILL.md +5 -3
  101. package/adapters/workbuddy/skills/release-docs/SKILL.md +4 -2
  102. package/adapters/workbuddy/skills/release-finish/SKILL.md +6 -4
  103. package/adapters/workbuddy/skills/release-help/SKILL.md +14 -5
  104. package/adapters/workbuddy/skills/release-marketplace/SKILL.md +3 -1
  105. package/adapters/workbuddy/skills/release-prepare/SKILL.md +4 -2
  106. package/adapters/workbuddy/skills/release-publish/SKILL.md +19 -10
  107. package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -0
  108. package/adapters/workbuddy/src/schemas/executable-identity-observation.schema.json +162 -0
  109. package/adapters/workbuddy/src/schemas/filesystem-tree-observation.schema.json +18 -2
  110. package/adapters/workbuddy/src/schemas/migration-manifest.schema.json +271 -7
  111. package/adapters/workbuddy/src/schemas/plugin-verification-request.schema.json +147 -22
  112. package/adapters/workbuddy/src/schemas/plugin-verification-result.schema.json +436 -18
  113. package/adapters/workbuddy/src/schemas/profile-adoption-declaration.schema.json +8 -0
  114. package/adapters/workbuddy/src/schemas/skill-family-directory-verification-request.schema.json +100 -0
  115. package/adapters/workbuddy/src/schemas/skill-family-directory-verification-result.schema.json +193 -0
  116. package/bin/consumer-contract-vectors.json +110 -0
  117. package/bin/error-codes.json +8 -1
  118. package/bin/foundation-resource-binding.json +1 -1
  119. package/bin/registry.json +22 -1
  120. package/bin/release-skill-cli.mjs +72 -4
  121. package/bin/release-skill.bundle.mjs +4772 -2238
  122. package/bin/rules.json +1 -1
  123. package/bin/schemas/consumer-contract-vector.schema.json +149 -0
  124. package/package.json +5 -5
  125. package/platform-manifest.json +5 -5
  126. package/references/01-state-machine.md +16 -1
  127. package/references/05-evidence-and-errors.md +2 -2
  128. package/schemas/release-project.schema.json +4 -0
  129. package/schemas/release-run.schema.json +41 -1
  130. package/skills/release-config/SKILL.md +5 -3
  131. package/skills/release-docs/SKILL.md +4 -2
  132. package/skills/release-finish/SKILL.md +6 -4
  133. package/skills/release-help/SKILL.md +14 -5
  134. package/skills/release-marketplace/SKILL.md +3 -1
  135. package/skills/release-prepare/SKILL.md +4 -2
  136. package/skills/release-publish/SKILL.md +19 -10
  137. package/skills/release-verify/SKILL.md +2 -0
  138. package/skills-src/release-config/SKILL.md +5 -3
  139. package/skills-src/release-docs/SKILL.md +4 -2
  140. package/skills-src/release-finish/SKILL.md +6 -4
  141. package/skills-src/release-help/SKILL.md +14 -5
  142. package/skills-src/release-marketplace/SKILL.md +3 -1
  143. package/skills-src/release-prepare/SKILL.md +4 -2
  144. package/skills-src/release-publish/SKILL.md +19 -10
  145. package/skills-src/release-verify/SKILL.md +2 -0
  146. package/src/commands/post-release-local.mjs +249 -58
  147. package/src/commands/prepare.mjs +178 -9
  148. package/src/commands/route.mjs +597 -18
  149. package/src/commands/setup.mjs +2 -0
  150. package/src/commands/ship.mjs +143 -16
  151. package/src/commands/verify.mjs +21 -0
  152. package/src/core/adoption-assessment.mjs +59 -0
  153. package/src/core/derived-artifact-gates.mjs +32 -5
  154. package/src/core/foundation-plugin-verification.mjs +213 -1
  155. package/src/core/hook-cache.mjs +318 -26
  156. package/src/core/hooks.mjs +8 -1
  157. package/src/producers/foundation-resource-projection.mjs +16 -3
  158. package/src/schemas/executable-identity-observation.schema.json +162 -0
  159. package/src/schemas/filesystem-tree-observation.schema.json +18 -2
  160. package/src/schemas/migration-manifest.schema.json +271 -7
  161. package/src/schemas/plugin-verification-request.schema.json +147 -22
  162. package/src/schemas/plugin-verification-result.schema.json +436 -18
  163. package/src/schemas/profile-adoption-declaration.schema.json +8 -0
  164. package/src/schemas/skill-family-directory-verification-request.schema.json +100 -0
  165. package/src/schemas/skill-family-directory-verification-result.schema.json +193 -0
@@ -34,11 +34,20 @@
34
34
  */
35
35
 
36
36
  import { execFileSync } from 'node:child_process';
37
- import { readdir, readFile } from 'node:fs/promises';
38
- import { resolve } from 'node:path';
39
- import { ReleaseError } from '../core/errors.mjs';
37
+ import { lstat, readdir, readFile } from 'node:fs/promises';
38
+ import { relative, resolve, basename, dirname, join } from 'node:path';
39
+ import { ReleaseError, GATE_FAILED } from '../core/errors.mjs';
40
40
  import { listReleaseTags } from './lineage.mjs';
41
41
  import { readRunRecovery, renderRecoveryCommand } from '../core/recovery.mjs';
42
+ import {
43
+ loadRun,
44
+ validateRunLineage,
45
+ validateRunPlanDigest,
46
+ validateRunCheckpointMapping,
47
+ } from '../core/run.mjs';
48
+ import { computePlanDigest, validatePlan } from '../core/plan.mjs';
49
+ import { resolveProducerVersion } from '../core/evidence.mjs';
50
+ import { normalizePostPublishView, postPublishActionId } from '../core/postpublish.mjs';
42
51
 
43
52
  const WORKFLOW_KINDS = Object.freeze([
44
53
  'full-happy-end',
@@ -349,38 +358,129 @@ export async function classifyBaselineSurface(root, prevCommit) {
349
358
  */
350
359
  export async function readRunRouting(root, options = {}) {
351
360
  const cwd = resolve(root);
361
+ const targetVersion = options.targetVersion ?? null;
352
362
  const runsDir = resolve(cwd, '.release-skill', 'runs');
353
363
  let runDirs;
354
364
  try {
355
365
  runDirs = await readdir(runsDir);
356
366
  } catch (error) {
357
367
  if (error.code === 'ENOENT') return { hasPartialRun: false, recoveryActionCode: null, runs: [] };
358
- return { hasPartialRun: false, recoveryActionCode: 'DIAGNOSE', runs: [], diagnostic: { code: error.code, message: error.message } };
368
+ const relativePath = relative(cwd, runsDir);
369
+ const diagnostic = {
370
+ code: error.code ?? 'RUNS_DIRECTORY_UNREADABLE',
371
+ message: `cannot read release runs directory: ${error.message}`,
372
+ relativePath,
373
+ nextActionCode: 'DIAGNOSE',
374
+ nextAction: '保留历史记录,先按该路径完成人工诊断,再决定正式恢复动作',
375
+ classification: 'UNKNOWN_LEGACY_OR_CORRUPT_AUTHORITY',
376
+ };
377
+ const run = {
378
+ runPath: runsDir,
379
+ recoveryActionCode: null,
380
+ diagnostic,
381
+ };
382
+ return {
383
+ hasPartialRun: false,
384
+ recoveryActionCode: null,
385
+ runs: [],
386
+ diagnostics: [{
387
+ path: relativePath,
388
+ runPath: runsDir,
389
+ classification: diagnostic.classification,
390
+ basis: diagnostic.message,
391
+ recoveryActionCode: null,
392
+ nextActionCode: 'DIAGNOSE',
393
+ nextAction: diagnostic.nextAction,
394
+ }],
395
+ };
359
396
  }
360
397
  const records = [];
398
+ const diagnostics = [];
361
399
  for (const runDir of runDirs) {
362
400
  let runPath = resolve(runsDir, runDir, 'release-run.json');
401
+ const runDirectory = resolve(runsDir, runDir);
402
+ let authorityExists = false;
403
+ let authorityMissing = false;
404
+ let markerlessRunDebris = false;
363
405
  try {
364
406
  await readFile(runPath);
407
+ authorityExists = true;
365
408
  } catch (error) {
366
409
  if (error.code === 'ENOENT') {
410
+ authorityMissing = true;
367
411
  // Prepare/assess have evidence directories but no release-run contract.
368
412
  if (/^(prepare|assess|hooks-validate)-/.test(runDir)) continue;
413
+ const statesPath = resolve(runsDir, runDir, 'states');
414
+ const stateInspection = await inspectStateDirectory(statesPath);
415
+ if (!stateInspection.ok) {
416
+ records.push({
417
+ runPath: stateInspection.path,
418
+ recoveryActionCode: 'DIAGNOSE',
419
+ diagnostic: {
420
+ code: stateInspection.code,
421
+ message: stateInspection.message,
422
+ },
423
+ });
424
+ continue;
425
+ }
369
426
  // An append-only sequence is ordered by its bound slot, never by time.
370
427
  // readRunRecovery reuses validateRunLineage to check its predecessors.
371
- const states = await readdir(resolve(runsDir, runDir, 'states')).catch(() => []);
372
- const slots = states.filter((name) => /^\d{6}\.json$/.test(name)).sort();
373
- if (slots.length > 0) runPath = resolve(runsDir, runDir, 'states', slots.at(-1));
428
+ const slots = stateInspection.entries.filter((name) => /^\d{6}\.json$/.test(name)).sort();
429
+ if (slots.length > 0) {
430
+ runPath = resolve(runsDir, runDir, 'states', slots.at(-1));
431
+ authorityExists = true;
432
+ }
374
433
  }
375
434
  }
376
- records.push(await readRunRecovery(runPath, options));
435
+ if (!authorityExists && authorityMissing
436
+ && await isProvenPreAuthorityFailure(runDirectory, runDir)) {
437
+ diagnostics.push({
438
+ path: relative(cwd, runDirectory),
439
+ relativePath: relative(cwd, runPath),
440
+ runPath,
441
+ classification: 'PRE_AUTHORITY_FAILURE',
442
+ basis: '版本化 evidence writer、发布命令顺序和缺少 states/release-run.json 共同证明未取得运行 authority,未进入 checkpoint execute',
443
+ recoveryActionCode: 'RETRY_COMMAND',
444
+ nextActionCode: 'RETRY_COMMAND',
445
+ nextAction: '修复失败原因后重新运行原命令;该目录不是发布 authority',
446
+ });
447
+ continue;
448
+ }
449
+ const record = runDir.startsWith('postverify-') && authorityExists
450
+ ? await readLegacyPostverifyRecovery(runPath)
451
+ : await readRunRecovery(runPath, options);
452
+ if (targetVersion && authorityMissing) {
453
+ markerlessRunDebris = await isMarkerlessRunDebris(runDirectory);
454
+ if (markerlessRunDebris) record.emptyRunDebris = true;
455
+ }
456
+ records.push(record);
457
+ }
458
+ const targetScopedRecords = [];
459
+ const historicalDiagnostics = [];
460
+ for (const record of records) {
461
+ // Without an explicit target there is no safe release authority to
462
+ // recover. Keep every finding visible, but leave workflow selection to
463
+ // diff/baseline classification. This prevents an old DIAGNOSE or PARTIAL
464
+ // record from becoming an implicit target or an authorization gate.
465
+ if (!targetVersion) {
466
+ historicalDiagnostics.push(buildHistoricalDiagnostic(cwd, record, null));
467
+ continue;
468
+ }
469
+ if (!(await classifyTargetScope(cwd, record, targetVersion))) {
470
+ historicalDiagnostics.push(buildHistoricalDiagnostic(cwd, record, targetVersion));
471
+ } else {
472
+ targetScopedRecords.push(record);
473
+ }
377
474
  }
378
475
  const identity = (run) => `${run.planDigest}:${run.runId}:${run.runDigest}`;
379
476
  const consumed = new Set(records.filter((r) => r.run).flatMap((r) => r.lineage.map((edge) => identity(edge.run))));
380
477
  const samePublication = (a, b) => a.planDigest === b.planDigest
381
478
  && a.sourceRunId === b.sourceRunId && a.sourceRunDigest === b.sourceRunDigest;
382
- const unresolved = records.filter((record) => {
383
- if (!record.run) return true; // invalid/unknown never disappears as "no PARTIAL"
479
+ const unresolved = targetScopedRecords.filter((record) => {
480
+ // A record is eligible for current recovery only after the existing
481
+ // reader has validated its run and its bound plan. Invalid, legacy, and
482
+ // damaged records remain diagnostics and can never authorize recovery.
483
+ if (!record.run || !record.plan) return false;
384
484
  if (consumed.has(identity(record.run))) return false;
385
485
  // Distribute retries point to the same publication, not to each other.
386
486
  // Only a validated completed distribution can retire its failed sibling.
@@ -394,7 +494,14 @@ export async function readRunRouting(root, options = {}) {
394
494
  return true;
395
495
  });
396
496
  const priority = ['DIAGNOSE', 'RECONCILE', 'DISTRIBUTE', 'VERIFY', 'RETRY_COMMAND'];
397
- const recoveryActionCode = priority.find((code) => unresolved.some((r) => r.recoveryActionCode === code)) ?? null;
497
+ const actionable = unresolved.filter((record) =>
498
+ typeof record.recoveryActionCode === 'string' && record.recoveryActionCode.length > 0);
499
+ // Multiple valid unfinished authorities for one explicit target cannot be
500
+ // selected by directory order, mtime, or producer metadata. Surface all of
501
+ // them and require an operator to choose the exact lineage.
502
+ const recoveryActionCode = actionable.length > 1
503
+ ? 'DIAGNOSE'
504
+ : priority.find((code) => unresolved.some((r) => r.recoveryActionCode === code)) ?? null;
398
505
  const runs = unresolved.filter((r) => r.recoveryActionCode).map((r) => ({
399
506
  runPath: r.runPath,
400
507
  planPath: r.run?.planPath,
@@ -402,9 +509,447 @@ export async function readRunRouting(root, options = {}) {
402
509
  status: r.run?.status,
403
510
  recoveryActionCode: r.recoveryActionCode,
404
511
  recoveryRunPath: r.recoveryRunPath,
405
- ...(r.diagnostic ? { diagnostic: r.diagnostic } : {}),
512
+ ...(r.diagnostic ? {
513
+ diagnostic: {
514
+ ...r.diagnostic,
515
+ relativePath: r.runPath ? relative(cwd, r.runPath) : null,
516
+ nextActionCode: 'DIAGNOSE',
517
+ nextAction: '保留历史记录,先按该路径完成人工诊断,再决定正式恢复动作',
518
+ classification: 'UNKNOWN_LEGACY_OR_CORRUPT_AUTHORITY',
519
+ },
520
+ } : {}),
406
521
  })).sort((a, b) => a.runPath.localeCompare(b.runPath));
407
- return { hasPartialRun: unresolved.some((r) => r.run?.status === 'PARTIAL'), recoveryActionCode, runs };
522
+ const unifiedDiagnostics = [
523
+ ...diagnostics,
524
+ ...historicalDiagnostics,
525
+ ...runs.filter((run) => run.diagnostic).map((run) => ({
526
+ path: run.diagnostic.relativePath,
527
+ runPath: run.runPath,
528
+ classification: run.diagnostic.classification,
529
+ basis: run.diagnostic.message,
530
+ recoveryActionCode: run.recoveryActionCode,
531
+ nextActionCode: run.diagnostic.nextActionCode,
532
+ nextAction: run.diagnostic.nextAction,
533
+ })),
534
+ ];
535
+ return {
536
+ hasPartialRun: unresolved.some((r) => r.run?.status === 'PARTIAL'),
537
+ recoveryActionCode,
538
+ runs,
539
+ ...(unifiedDiagnostics.length > 0 ? { diagnostics: unifiedDiagnostics } : {}),
540
+ };
541
+ }
542
+
543
+ /**
544
+ * Resolve whether a validated record belongs to the explicitly requested
545
+ * target. Plans are the only release-domain target authority; evidence and
546
+ * directory names never substitute for a plan binding.
547
+ */
548
+ function planTargetVersions(plan) {
549
+ return [...new Set((Array.isArray(plan?.units) ? plan.units : [])
550
+ .map((unit) => unit?.targetVersion)
551
+ .filter((version) => typeof version === 'string' && version.length > 0))];
552
+ }
553
+
554
+ async function classifyTargetScope(cwd, record, targetVersion) {
555
+ if (record.emptyRunDebris) return false;
556
+ // Only the fully validated run + plan pair is a current-recovery
557
+ // candidate. A readable path, producer version, directory name, timestamp,
558
+ // summary, or error text cannot substitute for that authority.
559
+ if (!(record.run && record.plan)) return false;
560
+ const versions = planTargetVersions(record.plan);
561
+ return versions.length > 0 && versions.every((version) => version === targetVersion);
562
+ }
563
+
564
+ function buildHistoricalDiagnostic(cwd, record, targetVersion) {
565
+ const path = record.runPath ? relative(cwd, record.runPath) : null;
566
+ const versions = planTargetVersions(record.plan);
567
+ const binding = !targetVersion
568
+ ? '未指定目标版本;该记录只能作为历史诊断,不能决定当前工作流'
569
+ : record.emptyRunDebris
570
+ ? '目录内没有可识别 authority、summary、evidence 或 state 制品的空 debris'
571
+ : versions.length > 0
572
+ ? `记录绑定版本 ${versions.join('、')}`
573
+ : '记录属于不能绑定目标版本的旧 authority 或 legacy schema';
574
+ return {
575
+ path,
576
+ runPath: record.runPath,
577
+ classification: 'HISTORICAL_OUT_OF_SCOPE',
578
+ basis: targetVersion
579
+ ? `${binding},显式目标为 ${targetVersion};保留历史诊断,不参与目标版本阻断计数或下一动作`
580
+ : `${binding};保留历史诊断,不参与当前工作流或恢复动作`,
581
+ recoveryActionCode: record.recoveryActionCode ?? null,
582
+ nextActionCode: null,
583
+ nextAction: '仅作历史诊断,不参与当前目标版本路由',
584
+ };
585
+ }
586
+
587
+ async function isMarkerlessRunDebris(runDirectory) {
588
+ const knownConsumers = new Set(['claude', 'codex', 'kimi', 'codebuddy', 'workbuddy']);
589
+ const readDirectory = async (path) => {
590
+ try {
591
+ return await readdir(path, { withFileTypes: true });
592
+ } catch {
593
+ return null;
594
+ }
595
+ };
596
+ const entries = await readDirectory(runDirectory);
597
+ if (entries === null) return false;
598
+ if (entries.length === 0) return true;
599
+
600
+ // A lifecycle run has its markers at the run root. The only markerless
601
+ // exception is the known consumer-install evidence layout, whose payload
602
+ // is nested below `evidence/` (with optional consumer homes beside it).
603
+ const allowed = new Set(['evidence', 'consumers']);
604
+ if (entries.some((entry) => !allowed.has(entry.name) || !entry.isDirectory() || entry.isSymbolicLink())) return false;
605
+ const evidence = entries.find((entry) => entry.name === 'evidence');
606
+ if (!evidence) return false;
607
+ const evidenceEntries = await readDirectory(join(runDirectory, 'evidence'));
608
+ if (!evidenceEntries || evidenceEntries.length === 0) return false;
609
+ for (const evidenceEntry of evidenceEntries) {
610
+ if (!evidenceEntry.isDirectory() || evidenceEntry.isSymbolicLink()
611
+ || !/^[a-z][a-z0-9-]*$/i.test(evidenceEntry.name)) return false;
612
+ const evidenceFiles = await readDirectory(join(runDirectory, 'evidence', evidenceEntry.name));
613
+ if (!evidenceFiles || evidenceFiles.length !== 1
614
+ || evidenceFiles[0].name !== 'release-skill-install-evidence.json'
615
+ || !evidenceFiles[0].isFile() || evidenceFiles[0].isSymbolicLink()) return false;
616
+ try {
617
+ const evidenceRecord = JSON.parse(await readFile(
618
+ join(runDirectory, 'evidence', evidenceEntry.name, evidenceFiles[0].name),
619
+ 'utf8',
620
+ ));
621
+ if (!evidenceRecord || typeof evidenceRecord !== 'object' || Array.isArray(evidenceRecord)
622
+ || !knownConsumers.has(evidenceRecord.consumer)
623
+ || typeof evidenceRecord.plugin !== 'string' || evidenceRecord.plugin.length === 0
624
+ || typeof evidenceRecord.version !== 'string' || evidenceRecord.version.length === 0) return false;
625
+ } catch {
626
+ return false;
627
+ }
628
+ }
629
+
630
+ const consumers = entries.find((entry) => entry.name === 'consumers');
631
+ if (consumers) {
632
+ const consumerEntries = await readDirectory(join(runDirectory, 'consumers'));
633
+ if (!consumerEntries || consumerEntries.length === 0) return false;
634
+ for (const consumerEntry of consumerEntries) {
635
+ const consumer = consumerEntry.name.split('-', 1)[0];
636
+ if (!consumerEntry.isDirectory() || consumerEntry.isSymbolicLink()
637
+ || !knownConsumers.has(consumer)) return false;
638
+ const homeEntries = await readDirectory(join(runDirectory, 'consumers', consumerEntry.name));
639
+ if (!homeEntries || homeEntries.some((entry) => !entry.isDirectory() || entry.isSymbolicLink()
640
+ || !knownConsumers.has(entry.name.slice(1)))) return false;
641
+ }
642
+ }
643
+ return true;
644
+ }
645
+
646
+ /**
647
+ * The publish writer creates the run directory before evidence, but writes
648
+ * its first immutable run state only after global preflight and immediately
649
+ * before checkpoint execution. Its final catch appends one generic failed
650
+ * `publish` event; when no authority exists, all preceding events must still
651
+ * be pre-authority phases. A current v2 summary/evidence pair with that exact
652
+ * shape therefore proves a failed, non-authoritative attempt. Legacy v1
653
+ * streams use the same writer order but have no producer envelope; they are
654
+ * accepted only when their older, looser event shape still proves the same
655
+ * boundary. Summaries without matching evidence and any stream that reached
656
+ * a persisted `publish` or checkpoint event remain diagnostic.
657
+ */
658
+ async function inspectStateDirectory(statesPath) {
659
+ let directoryStat;
660
+ try {
661
+ directoryStat = await lstat(statesPath);
662
+ } catch (error) {
663
+ if (error.code === 'ENOENT') return { ok: true, entries: [] };
664
+ return {
665
+ ok: false,
666
+ path: statesPath,
667
+ code: 'STATE_DIRECTORY_UNREADABLE',
668
+ message: `cannot inspect run states directory: ${error.message}`,
669
+ };
670
+ }
671
+ if (!directoryStat.isDirectory()) {
672
+ return {
673
+ ok: false,
674
+ path: statesPath,
675
+ code: 'STATE_DIRECTORY_INVALID',
676
+ message: 'run states path is not a regular directory',
677
+ };
678
+ }
679
+ let entries;
680
+ try {
681
+ entries = await readdir(statesPath);
682
+ } catch (error) {
683
+ return {
684
+ ok: false,
685
+ path: statesPath,
686
+ code: 'STATE_DIRECTORY_UNREADABLE',
687
+ message: `cannot read run states directory: ${error.message}`,
688
+ };
689
+ }
690
+ for (const entry of entries) {
691
+ let entryStat;
692
+ const entryPath = join(statesPath, entry);
693
+ try {
694
+ entryStat = await lstat(entryPath);
695
+ } catch (error) {
696
+ return {
697
+ ok: false,
698
+ path: entryPath,
699
+ code: 'STATE_ENTRY_UNREADABLE',
700
+ message: `cannot inspect run state entry: ${error.message}`,
701
+ };
702
+ }
703
+ if (!/^\d{6}\.json$/.test(entry) || !entryStat.isFile() || entryStat.isSymbolicLink()) {
704
+ return {
705
+ ok: false,
706
+ path: entryPath,
707
+ code: 'STATE_ENTRY_INVALID',
708
+ message: 'run states must contain only regular files named NNNNNN.json',
709
+ };
710
+ }
711
+ }
712
+ return { ok: true, entries };
713
+ }
714
+
715
+ async function isProvenPreAuthorityFailure(runDir, runName) {
716
+ if (!/^publish-/.test(runName)) return false;
717
+ const summaryPath = join(runDir, 'summary.json');
718
+ const evidencePath = join(runDir, 'evidence.jsonl');
719
+ let summary;
720
+ let events;
721
+ try {
722
+ summary = JSON.parse(await readFile(summaryPath, 'utf8'));
723
+ events = (await readFile(evidencePath, 'utf8')).trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
724
+ } catch {
725
+ return false;
726
+ }
727
+
728
+ // The v1 writer (used before the producer-bound v2 envelope) created the
729
+ // run directory and appended safety-gate events before its first
730
+ // `release-run.json` state. A failed global preflight then appended only a
731
+ // generic `publish` failure and sealed a summary. This branch deliberately
732
+ // accepts that historical shape only when every byte still proves the same
733
+ // pre-authority boundary. It never infers safety from a directory name,
734
+ // mtime, summary alone, or a loosely similar phase.
735
+ const legacyPreAuthorityPhases = new Set([
736
+ 'safety-gate',
737
+ 'global-preflight-arbitration',
738
+ 're-observe-previous-public-baseline',
739
+ ]);
740
+ const legacyInputFailure = /(?:global preflight failed|source authority .*failed|cannot read (?:release plan|approval record)|production (?:publish|commands) require|plan (?:digest mismatch|action completeness gate failed|is not valid|requires)|baseline (?:changed|check failed)|previous-public-baseline|adapter .*failed)/i;
741
+ const legacyUnsafeFailure = /(?:remote (?:tag|branch|release) .*already exists|human intervention required|remote conflict|external write)/i;
742
+ const legacyPreAuthorityGates = new Set([
743
+ 'plan-load',
744
+ 'plan-schema',
745
+ 'plan-digest',
746
+ 'action-completeness',
747
+ 'approval-load',
748
+ 'adapter-availability',
749
+ 'baseline-check',
750
+ 'source-authority',
751
+ ]);
752
+ const validLegacyTimestamp = (value) => typeof value === 'string'
753
+ && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
754
+ && Number.isFinite(Date.parse(value));
755
+ const validLegacyError = (error) => error && typeof error === 'object' && !Array.isArray(error)
756
+ && typeof error.code === 'string' && error.code.length > 0
757
+ && (error.message === undefined || typeof error.message === 'string');
758
+ const validLegacyEvent = (event, runId) => event && typeof event === 'object' && !Array.isArray(event)
759
+ && event.schemaVersion === 1
760
+ && !Object.hasOwn(event, 'producer')
761
+ && typeof event.runId === 'string' && event.runId === runId
762
+ && Number.isSafeInteger(event.sequence) && event.sequence >= 1
763
+ && validLegacyTimestamp(event.timestamp)
764
+ && event.command === 'publish'
765
+ && typeof event.phase === 'string'
766
+ && (legacyPreAuthorityPhases.has(event.phase) || event.phase === 'publish')
767
+ && typeof event.status === 'string' && event.status.length > 0
768
+ && (event.error === undefined || event.error === null || validLegacyError(event.error))
769
+ && (event.duration === undefined || Number.isSafeInteger(event.duration) && event.duration >= 0)
770
+ && (event.details === undefined || event.details !== null
771
+ && typeof event.details === 'object' && !Array.isArray(event.details));
772
+ const legacyRunId = basename(runDir);
773
+ const legacyTerminal = events.at(-1);
774
+ const legacyValid = summary && summary.status === 'FAILED'
775
+ && validLegacyError(summary.error)
776
+ && typeof summary.error.message === 'string' && legacyInputFailure.test(summary.error.message)
777
+ && !legacyUnsafeFailure.test(summary.error.message)
778
+ && (summary.recoveryActionCode === undefined || summary.recoveryActionCode === 'RETRY_COMMAND')
779
+ && summary.runPath === undefined
780
+ && summary.finalRunDigest === undefined
781
+ && summary.latestStatePath === undefined
782
+ && summary.checkpointStatuses === undefined
783
+ && Array.isArray(events) && events.length >= 2
784
+ && events.every((event, index) => validLegacyEvent(event, legacyRunId)
785
+ && event.sequence === index + 1)
786
+ && legacyTerminal.phase === 'publish'
787
+ && legacyTerminal.status === 'failed'
788
+ && validLegacyError(legacyTerminal.error)
789
+ && legacyTerminal.error.code === summary.error.code
790
+ && events.slice(0, -1).filter((event) => event.status === 'failed').every((event) =>
791
+ event.phase === 'safety-gate'
792
+ && legacyPreAuthorityGates.has(event.gate)
793
+ && validLegacyError(event.error))
794
+ && !events.some((event) => event.phase === 'global-preflight-arbitration'
795
+ && event.status !== 'pre-observe')
796
+ && !events.slice(0, -1).some((event) => event.phase === 'publish'
797
+ || /checkpoint|execute|postpublish/i.test(event.phase)
798
+ || Object.hasOwn(event, 'prePersistedRunPath')
799
+ || Object.hasOwn(event, 'checkpointCount')
800
+ || Object.hasOwn(event, 'checkpointStatuses'));
801
+ if (legacyValid) return true;
802
+
803
+ const producerVersion = resolveProducerVersion();
804
+ const sameProducer = (value) => value && typeof value === 'object' && !Array.isArray(value)
805
+ && Object.keys(value).length === 2
806
+ && value.name === 'release-skill' && value.version === producerVersion;
807
+ const envelopeKeys = new Set([
808
+ 'schemaVersion', 'runId', 'sequence', 'timestamp', 'command', 'producer',
809
+ 'phase', 'status', 'error', 'duration', 'details',
810
+ ]);
811
+ const validTimestamp = (value) => typeof value === 'string'
812
+ && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
813
+ && Number.isFinite(Date.parse(value));
814
+ const detailValidators = {
815
+ unitId: (value) => typeof value === 'string',
816
+ gate: (value) => typeof value === 'string',
817
+ hookName: (value) => typeof value === 'string',
818
+ actionId: (value) => typeof value === 'string',
819
+ step: (value) => typeof value === 'string',
820
+ hookId: (value) => typeof value === 'string',
821
+ tagCommit: (value) => typeof value === 'string',
822
+ cached: (value) => typeof value === 'boolean',
823
+ testSelection: (value) => typeof value === 'string',
824
+ cacheKey: (value) => typeof value === 'string',
825
+ durationMs: (value) => value === null || Number.isSafeInteger(value) && value >= 0,
826
+ exitCode: (value) => Number.isSafeInteger(value),
827
+ findingCount: (value) => Number.isSafeInteger(value) && value >= 0,
828
+ };
829
+ const validDetails = (value) => value === undefined
830
+ || value !== null && typeof value === 'object' && !Array.isArray(value)
831
+ && Object.entries(value).every(([key, detail]) => !detailValidators[key] || detailValidators[key](detail));
832
+ const validEnvelope = (event) => event && typeof event === 'object'
833
+ && Object.keys(event).every((key) => envelopeKeys.has(key))
834
+ && event.schemaVersion === 2
835
+ && typeof event.runId === 'string' && event.runId === basename(runDir)
836
+ && Number.isSafeInteger(event.sequence) && event.sequence >= 1
837
+ && validTimestamp(event.timestamp)
838
+ && event.command === 'publish'
839
+ && typeof event.phase === 'string' && event.phase.length > 0
840
+ && typeof event.status === 'string' && event.status.length > 0
841
+ && sameProducer(event.producer)
842
+ && (event.error === undefined || event.error === null
843
+ || (typeof event.error === 'object' && !Array.isArray(event.error)
844
+ && Object.keys(event.error).every((key) => key === 'code' || key === 'message')
845
+ && typeof event.error.code === 'string' && event.error.code.length > 0
846
+ && (event.error.message === undefined || typeof event.error.message === 'string')))
847
+ && (event.duration === undefined || Number.isSafeInteger(event.duration) && event.duration >= 0)
848
+ && validDetails(event.details);
849
+ if (summary.status !== 'FAILED' || summary.recoveryActionCode !== 'RETRY_COMMAND'
850
+ || summary.evidencePath !== 'evidence.jsonl' || !sameProducer(summary.producer)
851
+ || !Array.isArray(events) || events.length === 0) return false;
852
+ let previousSequence = 0;
853
+ let firstFailure = null;
854
+ let genericFailure = null;
855
+ for (const event of events) {
856
+ if (!validEnvelope(event) || event.sequence !== previousSequence + 1) return false;
857
+ previousSequence = event.sequence;
858
+ if (event.status === 'failed' && firstFailure === null) firstFailure = event;
859
+ if (event.phase === 'publish') {
860
+ if (event.status !== 'failed' || genericFailure !== null || event.details?.prePersistedRunPath
861
+ || event.details?.recoveryActionCode !== summary.recoveryActionCode) return false;
862
+ genericFailure = event;
863
+ } else if (!['safety-gate', 'global-preflight-arbitration'].includes(event.phase)) {
864
+ return false;
865
+ }
866
+ }
867
+ if (!firstFailure || !genericFailure || events.at(-1) !== genericFailure
868
+ || summary.evidenceSequence !== firstFailure.sequence
869
+ || summary.stablePhase !== firstFailure.phase) return false;
870
+ return !events.some((event) => event.details?.prePersistedRunPath);
871
+ }
872
+
873
+ /**
874
+ * Postverify is a downstream run type that predates the generic recovery
875
+ * reader. A terminal legacy record is consumable only when its summary agrees
876
+ * with its sealed status and its verify parent is fully lineage-valid.
877
+ */
878
+ async function readLegacyPostverifyRecovery(runPath) {
879
+ try {
880
+ const run = await loadRun(runPath, { requireDigest: true });
881
+ if (run.command !== 'postverify' || run.status !== 'DISTRIBUTED') {
882
+ throw new ReleaseError(GATE_FAILED, 'legacy postverify status is not terminal DISTRIBUTED');
883
+ }
884
+ const planRaw = await readFile(run.planPath, 'utf8');
885
+ const plan = JSON.parse(planRaw);
886
+ validatePlan(plan);
887
+ if (!plan.digest || plan.digest !== computePlanDigest(plan)) {
888
+ throw new ReleaseError(GATE_FAILED, 'legacy postverify plan digest is not intact');
889
+ }
890
+ validateRunPlanDigest(run, plan, { planPath: run.planPath });
891
+ const production = Boolean(plan.production);
892
+ if (production) {
893
+ // Production run authority is digest-addressed and must remain under
894
+ // the plan's sibling runs/ directory, exactly as recovery consumes it.
895
+ await loadRun(runPath, { requireDigest: true, authorityPlanPath: run.planPath });
896
+ }
897
+ if (!run.sourceRunPath || !run.sourceRunId || !run.sourceRunDigest) {
898
+ throw new ReleaseError(GATE_FAILED, 'legacy postverify is missing complete verify parent lineage');
899
+ }
900
+ const parent = await loadRun(run.sourceRunPath, {
901
+ requireDigest: true,
902
+ ...(production ? { authorityPlanPath: run.planPath } : {}),
903
+ });
904
+ if (parent.command !== 'verify' || parent.status !== 'VERIFIED'
905
+ || parent.runId !== run.sourceRunId || parent.runDigest !== run.sourceRunDigest) {
906
+ throw new ReleaseError(GATE_FAILED, 'legacy postverify requires the same-lineage VERIFIED verify parent');
907
+ }
908
+ validateRunCheckpointMapping(parent, plan.externalActions ?? []);
909
+ if (parent.checkpoints.some((checkpoint) => !['succeeded', 'skipped', 'NO_CHANGE'].includes(checkpoint.status))) {
910
+ throw new ReleaseError(GATE_FAILED, 'legacy postverify verify parent has incomplete checkpoints');
911
+ }
912
+ await validateRunLineage(parent, {
913
+ plan,
914
+ planPath: run.planPath,
915
+ runPath: run.sourceRunPath,
916
+ production,
917
+ });
918
+ const summary = JSON.parse(await readFile(join(resolve(runPath, '..'), 'summary.json'), 'utf8'));
919
+ if (!summary || summary.status !== run.status) {
920
+ throw new ReleaseError(GATE_FAILED, 'legacy postverify summary status does not match its sealed run status');
921
+ }
922
+ const expectedPostVerifyActions = normalizePostPublishView(plan).flatMap((declaration) => (
923
+ (declaration.hooks ?? [])
924
+ .filter((hook) => (hook.phase ?? 'distribute') === 'postVerify')
925
+ .map((hook) => ({
926
+ id: postPublishActionId({
927
+ planVersion: plan.planVersion,
928
+ unitId: declaration.unitId,
929
+ localId: hook.id,
930
+ }),
931
+ type: 'postpublish-hook',
932
+ }))
933
+ ));
934
+ validateRunCheckpointMapping(run, expectedPostVerifyActions);
935
+ if (run.checkpoints.some((checkpoint) => !['succeeded', 'skipped', 'NO_CHANGE'].includes(checkpoint.status))) {
936
+ throw new ReleaseError(GATE_FAILED, 'legacy postverify DISTRIBUTED run has incomplete checkpoints');
937
+ }
938
+ return {
939
+ runPath: resolve(runPath),
940
+ run,
941
+ plan,
942
+ lineage: [{ run: parent, runPath: resolve(run.sourceRunPath) }],
943
+ recoveryActionCode: null,
944
+ legacyPostverify: true,
945
+ };
946
+ } catch (error) {
947
+ return {
948
+ runPath: resolve(runPath),
949
+ recoveryActionCode: 'DIAGNOSE',
950
+ diagnostic: { code: error.code ?? GATE_FAILED, message: error.message },
951
+ };
952
+ }
408
953
  }
409
954
 
410
955
  export async function hasPartialRun(root) {
@@ -434,7 +979,10 @@ export function recommendWorkflow(diffClassification, baselineSurface, targetVer
434
979
  const { code, docs, config, marketplace, mixed } = diffClassification;
435
980
 
436
981
  const recovery = options.recovery;
437
- if (recovery?.recoveryActionCode) {
982
+ // Recovery advice is target-bound. With no explicit target, route must
983
+ // remain a diff/baseline workflow recommendation even when diagnostics
984
+ // contain old PARTIAL or DIAGNOSE records.
985
+ if (targetVersion && recovery?.recoveryActionCode) {
438
986
  const action = recovery.recoveryActionCode;
439
987
  const selected = recovery.runs.find((run) => run.recoveryActionCode === action);
440
988
  const recoveryRunPath = selected?.recoveryRunPath ?? selected?.runPath;
@@ -444,13 +992,20 @@ export function recommendWorkflow(diffClassification, baselineSurface, targetVer
444
992
  reason: action === 'DIAGNOSE' ? '运行权威、冲突或批准尚需诊断;保留既有记录,不开始新发布'
445
993
  : action === 'RETRY_COMMAND' ? '已校验为零写入阻断;修复原因后重试原命令'
446
994
  : '根据已校验的计划、运行和血缘继续未完成阶段',
447
- firstCommand: renderRecoveryCommand(action, { planPath: selected?.planPath, runPath: recoveryRunPath }) ?? 'release-skill help',
448
- routing: { rule: 1, hasPartialRun, recoveryActionCode: action, runs: recovery.runs },
995
+ firstCommand: renderRecoveryCommand(action, { planPath: selected?.planPath, runPath: recoveryRunPath })
996
+ ?? `无自动安全恢复入口;按 recoveryActionCode=${action} 处理既有记录`,
997
+ routing: {
998
+ rule: 1,
999
+ hasPartialRun,
1000
+ recoveryActionCode: action,
1001
+ runs: recovery.runs,
1002
+ ...(recovery.diagnostics?.length > 0 ? { diagnostics: recovery.diagnostics } : {}),
1003
+ },
449
1004
  };
450
1005
  }
451
1006
 
452
1007
  // Rule 1: PARTIAL run → reconcile first (highest priority).
453
- if (hasPartialRun) {
1008
+ if (targetVersion && hasPartialRun) {
454
1009
  return {
455
1010
  workflowKind: 'reconcile',
456
1011
  reason: '存在 PARTIAL run,必须先 reconcile 再开始任何新操作',
@@ -632,6 +1187,24 @@ function formatRecommendationText(recommendation) {
632
1187
  if (recommendation.requiresAuthorization) {
633
1188
  lines.push('Note: 远程写入(publish)未授权——prepare 只读冻结计划,publish 需另行授权。', '');
634
1189
  }
1190
+ const diagnostics = recommendation.routing?.diagnostics ?? [
1191
+ ...(recommendation.routing?.runs ?? [])
1192
+ .filter((run) => run.diagnostic)
1193
+ .map((run) => ({ ...run.diagnostic, path: run.diagnostic.relativePath })),
1194
+ ];
1195
+ if (diagnostics.length > 0) {
1196
+ lines.push('Diagnostics:');
1197
+ for (const diagnostic of diagnostics) {
1198
+ lines.push(` - 路径: ${diagnostic.path ?? diagnostic.relativePath ?? 'unknown'}`);
1199
+ lines.push(` 分类: ${diagnostic.classification ?? 'UNKNOWN_LEGACY_OR_CORRUPT_AUTHORITY'}`);
1200
+ lines.push(` 动作: ${diagnostic.nextActionCode ?? diagnostic.recoveryActionCode ?? 'DIAGNOSE'}`);
1201
+ if (diagnostic.nextAction) lines.push(` 下一步: ${diagnostic.nextAction}`);
1202
+ if ((diagnostic.nextActionCode ?? diagnostic.recoveryActionCode) === 'DIAGNOSE') {
1203
+ lines.push(' 无自动安全恢复入口');
1204
+ }
1205
+ }
1206
+ lines.push('');
1207
+ }
635
1208
  return lines.join('\n');
636
1209
  }
637
1210
 
@@ -700,7 +1273,7 @@ export default async function main(args = [], options = {}) {
700
1273
  } else {
701
1274
  baselineSurface = { status: 'indeterminable', kind: null, categories: {}, paths: [] };
702
1275
  }
703
- const recovery = await readRunRouting(root);
1276
+ const recovery = await readRunRouting(root, { targetVersion });
704
1277
  const partial = recovery.hasPartialRun;
705
1278
  const recommendation = recommendWorkflow(
706
1279
  classification,
@@ -709,6 +1282,12 @@ export default async function main(args = [], options = {}) {
709
1282
  partial,
710
1283
  { publishAuthorized, recovery },
711
1284
  );
1285
+ if (recovery.diagnostics?.length > 0) {
1286
+ recommendation.routing = {
1287
+ ...recommendation.routing,
1288
+ diagnostics: recovery.diagnostics,
1289
+ };
1290
+ }
712
1291
 
713
1292
  if (json) {
714
1293
  console.log(JSON.stringify({