release-skill 0.9.32 → 0.9.34

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 (118) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codebuddy-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.qoder-plugin/plugin.json +1 -1
  7. package/CHANGELOG.md +54 -0
  8. package/INSTALL.md +2 -2
  9. package/INSTALL.zh-CN.md +2 -2
  10. package/README.md +31 -13
  11. package/README.zh-CN.md +17 -13
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +5855 -4045
  14. package/adapters/claude/schemas/.render-manifest.json +8 -4
  15. package/adapters/claude/schemas/release-plan.schema.json +85 -0
  16. package/adapters/claude/schemas/release-project.schema.json +18 -0
  17. package/adapters/claude/schemas/test-intake.schema.json +217 -0
  18. package/adapters/claude/skills/release-assess/SKILL.md +11 -0
  19. package/adapters/claude/skills/release-config/SKILL.md +6 -0
  20. package/adapters/claude/skills/release-help/SKILL.md +20 -4
  21. package/adapters/claude/skills/release-prepare/SKILL.md +11 -2
  22. package/adapters/claude/skills/release-setup/SKILL.md +15 -2
  23. package/adapters/claude/skills/release-verify/SKILL.md +2 -0
  24. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  25. package/adapters/codex/bin/release-skill.bundle.mjs +5855 -4045
  26. package/adapters/codex/schemas/.render-manifest.json +8 -4
  27. package/adapters/codex/schemas/release-plan.schema.json +85 -0
  28. package/adapters/codex/schemas/release-project.schema.json +18 -0
  29. package/adapters/codex/schemas/test-intake.schema.json +217 -0
  30. package/adapters/codex/skills/release-assess/SKILL.md +11 -0
  31. package/adapters/codex/skills/release-config/SKILL.md +6 -0
  32. package/adapters/codex/skills/release-help/SKILL.md +20 -4
  33. package/adapters/codex/skills/release-prepare/SKILL.md +11 -2
  34. package/adapters/codex/skills/release-setup/SKILL.md +15 -2
  35. package/adapters/codex/skills/release-verify/SKILL.md +2 -0
  36. package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
  37. package/adapters/cursor/bin/release-skill.bundle.mjs +5855 -4045
  38. package/adapters/cursor/schemas/.render-manifest.json +8 -4
  39. package/adapters/cursor/schemas/release-plan.schema.json +85 -0
  40. package/adapters/cursor/schemas/release-project.schema.json +18 -0
  41. package/adapters/cursor/schemas/test-intake.schema.json +217 -0
  42. package/adapters/cursor/skills/release-assess/SKILL.md +11 -0
  43. package/adapters/cursor/skills/release-config/SKILL.md +6 -0
  44. package/adapters/cursor/skills/release-help/SKILL.md +20 -4
  45. package/adapters/cursor/skills/release-prepare/SKILL.md +11 -2
  46. package/adapters/cursor/skills/release-setup/SKILL.md +15 -2
  47. package/adapters/cursor/skills/release-verify/SKILL.md +2 -0
  48. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  49. package/adapters/kimi/bin/release-skill.bundle.mjs +5855 -4045
  50. package/adapters/kimi/schemas/.render-manifest.json +8 -4
  51. package/adapters/kimi/schemas/release-plan.schema.json +85 -0
  52. package/adapters/kimi/schemas/release-project.schema.json +18 -0
  53. package/adapters/kimi/schemas/test-intake.schema.json +217 -0
  54. package/adapters/kimi/skills/release-assess/SKILL.md +11 -0
  55. package/adapters/kimi/skills/release-config/SKILL.md +6 -0
  56. package/adapters/kimi/skills/release-help/SKILL.md +20 -4
  57. package/adapters/kimi/skills/release-prepare/SKILL.md +11 -2
  58. package/adapters/kimi/skills/release-setup/SKILL.md +15 -2
  59. package/adapters/kimi/skills/release-verify/SKILL.md +2 -0
  60. package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
  61. package/adapters/qoder/bin/release-skill.bundle.mjs +5855 -4045
  62. package/adapters/qoder/schemas/.render-manifest.json +8 -4
  63. package/adapters/qoder/schemas/release-plan.schema.json +85 -0
  64. package/adapters/qoder/schemas/release-project.schema.json +18 -0
  65. package/adapters/qoder/schemas/test-intake.schema.json +217 -0
  66. package/adapters/qoder/skills/release-assess/SKILL.md +11 -0
  67. package/adapters/qoder/skills/release-config/SKILL.md +6 -0
  68. package/adapters/qoder/skills/release-help/SKILL.md +20 -4
  69. package/adapters/qoder/skills/release-prepare/SKILL.md +11 -2
  70. package/adapters/qoder/skills/release-setup/SKILL.md +15 -2
  71. package/adapters/qoder/skills/release-verify/SKILL.md +2 -0
  72. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  73. package/adapters/workbuddy/bin/release-skill.bundle.mjs +5855 -4045
  74. package/adapters/workbuddy/schemas/.render-manifest.json +8 -4
  75. package/adapters/workbuddy/schemas/release-plan.schema.json +85 -0
  76. package/adapters/workbuddy/schemas/release-project.schema.json +18 -0
  77. package/adapters/workbuddy/schemas/test-intake.schema.json +217 -0
  78. package/adapters/workbuddy/skills/release-assess/SKILL.md +11 -0
  79. package/adapters/workbuddy/skills/release-config/SKILL.md +6 -0
  80. package/adapters/workbuddy/skills/release-help/SKILL.md +20 -4
  81. package/adapters/workbuddy/skills/release-prepare/SKILL.md +11 -2
  82. package/adapters/workbuddy/skills/release-setup/SKILL.md +15 -2
  83. package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -0
  84. package/bin/release-skill-cli.mjs +37 -2
  85. package/bin/release-skill.bundle.mjs +5855 -4045
  86. package/package.json +1 -1
  87. package/platform-manifest.json +4 -4
  88. package/references/.render-manifest.json +6 -6
  89. package/references/01-state-machine.md +12 -1
  90. package/references/02-project-config.md +20 -0
  91. package/references/05-evidence-and-errors.md +30 -0
  92. package/schemas/.render-manifest.json +8 -4
  93. package/schemas/release-plan.schema.json +85 -0
  94. package/schemas/release-project.schema.json +18 -0
  95. package/schemas/test-intake.schema.json +217 -0
  96. package/skills/release-assess/SKILL.md +11 -0
  97. package/skills/release-config/SKILL.md +6 -0
  98. package/skills/release-help/SKILL.md +20 -4
  99. package/skills/release-prepare/SKILL.md +11 -2
  100. package/skills/release-setup/SKILL.md +15 -2
  101. package/skills/release-verify/SKILL.md +2 -0
  102. package/skills-src/release-assess/SKILL.md +11 -0
  103. package/skills-src/release-config/SKILL.md +6 -0
  104. package/skills-src/release-help/SKILL.md +20 -4
  105. package/skills-src/release-prepare/SKILL.md +11 -2
  106. package/skills-src/release-setup/SKILL.md +15 -2
  107. package/skills-src/release-verify/SKILL.md +2 -0
  108. package/src/commands/approve.mjs +13 -0
  109. package/src/commands/prepare.mjs +501 -5
  110. package/src/commands/publish.mjs +2 -0
  111. package/src/commands/reconcile.mjs +2 -0
  112. package/src/commands/route.mjs +32 -7
  113. package/src/commands/setup.mjs +13 -6
  114. package/src/commands/ship.mjs +168 -5
  115. package/src/core/config.mjs +5 -0
  116. package/src/core/plan.mjs +3 -1
  117. package/src/core/test-candidate.mjs +649 -0
  118. package/src/core/test-intake.mjs +645 -0
@@ -36,7 +36,15 @@ import {
36
36
  } from '../core/public-surface.mjs';
37
37
  import { runSnapshotVerificationGates } from '../core/verification-gates.mjs';
38
38
  import { asError, createEvidenceWriter } from '../core/evidence.mjs';
39
- import { computePlanDigest, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
39
+ import { computePlanDigest, validatePlan, validatePlanActionCompleteness, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
40
+ import { assertTestIntakeDeclaration, readTestRequirements, subjectDigestOf } from '../core/test-intake.mjs';
41
+ import {
42
+ decideTestCandidateHandoff,
43
+ projectPrepared,
44
+ readCandidateEvidenceSha256,
45
+ readTestCandidate,
46
+ writeTestCandidate,
47
+ } from '../core/test-candidate.mjs';
40
48
  import { sha256Hex } from '../core/digest.mjs';
41
49
  import {
42
50
  CHECKER_VERSION as SKILL_RESOURCE_CHECKER_VERSION,
@@ -54,6 +62,9 @@ import {
54
62
  computeFrozenSnapshot,
55
63
  normalizeGitTimestamp,
56
64
  sealFrozenSnapshot,
65
+ verifyFrozenFile,
66
+ verifyFrozenGitRepository,
67
+ verifyFrozenSnapshot,
57
68
  } from '../snapshot/frozen.mjs';
58
69
  import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT, BUNDLE_STALE } from '../core/errors.mjs';
59
70
  import { assertBundleFreshness } from '../core/bundle-freshness.mjs';
@@ -91,10 +102,11 @@ import {
91
102
  normalizePostPublishDeclaration,
92
103
  orderNormalizedHooks,
93
104
  validatePostPublishHookIdUniqueness,
105
+ normalizePostPublishView,
94
106
  PAYLOAD_SOURCE_TAG_WORKTREE,
95
107
  } from '../core/postpublish.mjs';
96
- import { freezeExecutionBundle, bundleRootForAuthorityDir } from '../core/postpublish-bundle.mjs';
97
- import { digestReleaseAssetIdentities } from '../core/release-assets.mjs';
108
+ import { freezeExecutionBundle, bundleRootForAuthorityDir, verifyExecutionBundle } from '../core/postpublish-bundle.mjs';
109
+ import { digestReleaseAssetIdentities, normalizeReleaseAssets } from '../core/release-assets.mjs';
98
110
 
99
111
  // ---------------------------------------------------------------------------
100
112
  // 安装契约常量
@@ -3288,6 +3300,363 @@ function resolveRequestedReleaseUnits(config, requestedUnitIds, retryOptions) {
3288
3300
  };
3289
3301
  }
3290
3302
 
3303
+ function candidateReprepare(message, drifts, details = {}) {
3304
+ throw new ReleaseError(GATE_FAILED, message, {
3305
+ reason: 'TEST_CANDIDATE_REPREPARE',
3306
+ drifts,
3307
+ ...details,
3308
+ });
3309
+ }
3310
+
3311
+ async function recheckCandidateClosure(realRoot, prepared, config, unitIds) {
3312
+ const authority = prepared?.sourceAuthority;
3313
+ if (!authority) return;
3314
+ const stored = authority.inputDigest;
3315
+ if (typeof stored !== 'string' || stored.length === 0) {
3316
+ candidateReprepare('test candidate source inputDigest is missing', ['inputDigest'], {
3317
+ storedInputDigest: stored ?? null,
3318
+ });
3319
+ }
3320
+ const selected = new Set(unitIds ?? []);
3321
+ const units = (config.releaseUnits ?? []).filter((unit) => selected.has(unit.id));
3322
+ const closure = await computeSourceInputClosure({ units, root: realRoot });
3323
+ if (closure.digest !== stored) {
3324
+ candidateReprepare(
3325
+ `test candidate source inputDigest changed: ${stored} -> ${closure.digest}`,
3326
+ [`inputDigest:${stored}->${closure.digest}`],
3327
+ { inputDigest: closure.digest, storedInputDigest: stored },
3328
+ );
3329
+ }
3330
+ }
3331
+
3332
+ async function recheckExecutionBundle(realRoot, prepared) {
3333
+ let declarations;
3334
+ try {
3335
+ declarations = normalizePostPublishView(prepared);
3336
+ } catch (err) {
3337
+ if (!prepared?.postPublish) return;
3338
+ throw err;
3339
+ }
3340
+ const anchor = resolve(realRoot, '.release-skill', 'release-plan.json');
3341
+ for (const declaration of declarations) {
3342
+ if (!declaration?.executionBundle) continue;
3343
+ try {
3344
+ await verifyExecutionBundle({ planPath: anchor, postPublish: declaration });
3345
+ } catch (err) {
3346
+ candidateReprepare(
3347
+ `test candidate execution bundle changed: ${err.message}`,
3348
+ ['executionBundle'],
3349
+ { cause: err.code ?? 'unknown' },
3350
+ );
3351
+ }
3352
+ }
3353
+ }
3354
+
3355
+ function recheckReceipt(prepared) {
3356
+ if (!prepared?.publicSourceAuthorityReceipt) return;
3357
+ const completeness = validatePlanActionCompleteness({
3358
+ ...prepared,
3359
+ status: prepared.status ?? 'PREPARED',
3360
+ });
3361
+ const failures = (completeness.details?.failures ?? []).filter((failure) => (
3362
+ String(failure).includes('publicSourceAuthorityReceipt')
3363
+ ));
3364
+ if (failures.length > 0) {
3365
+ candidateReprepare(
3366
+ `test candidate publicSourceAuthorityReceipt is incomplete: ${failures.join('; ')}`,
3367
+ ['receipt'],
3368
+ { failures },
3369
+ );
3370
+ }
3371
+ }
3372
+
3373
+ /**
3374
+ * Reattach one saved test candidate. Does not rebuild and does not write a
3375
+ * second candidate. Frozen git checks run only when the saved unit still
3376
+ * carries a frozenSnapshot.
3377
+ */
3378
+ async function reattachTestCandidate({
3379
+ realRoot,
3380
+ runDir,
3381
+ releaseDir,
3382
+ evidence,
3383
+ evidenceDir,
3384
+ config,
3385
+ configDigest,
3386
+ production,
3387
+ workflow,
3388
+ offline,
3389
+ version,
3390
+ options,
3391
+ clock,
3392
+ runWarnings,
3393
+ adoptedCandidate,
3394
+ output,
3395
+ }) {
3396
+ const savedOptions = adoptedCandidate.candidate.prepareOptions;
3397
+ const workflowExplicit = options?.workflowExplicit === true;
3398
+ const offlineExplicit = options?.offlineExplicit === true;
3399
+ const versionExplicit = options?.versionExplicit === true;
3400
+ const resolvedWorkflow = workflowExplicit ? workflow : savedOptions.workflow;
3401
+ const resolvedOffline = offlineExplicit ? offline : savedOptions.offline;
3402
+ const resolvedUnitIds = Array.isArray(options?.unitIds)
3403
+ ? options.unitIds
3404
+ : savedOptions.unitIds;
3405
+ const extraDrifts = [];
3406
+ if (versionExplicit) {
3407
+ for (const unitId of savedOptions.unitIds) {
3408
+ if (savedOptions.unitVersions[unitId] !== version) extraDrifts.push(`targetVersion.${unitId}`);
3409
+ }
3410
+ }
3411
+ for (const unitId of savedOptions.unitIds) {
3412
+ const unit = (config.releaseUnits ?? []).find((item) => item.id === unitId);
3413
+ if (!unit) {
3414
+ extraDrifts.push(`unit.${unitId}`);
3415
+ continue;
3416
+ }
3417
+ try {
3418
+ const onDisk = await resolveUnitVersion(unit, realRoot, undefined);
3419
+ if (onDisk !== savedOptions.unitVersions[unitId]) extraDrifts.push(`unitVersions.${unitId}`);
3420
+ } catch {
3421
+ extraDrifts.push(`unitVersions.${unitId}`);
3422
+ }
3423
+ }
3424
+ const requirements = await readTestRequirements({
3425
+ root: realRoot,
3426
+ config,
3427
+ selectedUnitIds: savedOptions.unitIds,
3428
+ });
3429
+ const evidenceCandidateSha256 = await readCandidateEvidenceSha256(
3430
+ realRoot,
3431
+ options.candidatePath,
3432
+ );
3433
+ const decision = await decideTestCandidateHandoff({
3434
+ root: realRoot,
3435
+ config,
3436
+ production,
3437
+ candidatePath: options.candidatePath,
3438
+ requirements,
3439
+ prepareOptions: {
3440
+ workflow: resolvedWorkflow,
3441
+ offline: resolvedOffline,
3442
+ production: true,
3443
+ unitIds: resolvedUnitIds,
3444
+ unitVersions: savedOptions.unitVersions,
3445
+ },
3446
+ evidenceCandidateSha256,
3447
+ expectedCandidateSha256: options.expectedCandidateSha256,
3448
+ expectedSubjectDigest: options.expectedSubjectDigest,
3449
+ configDigest,
3450
+ extraDrifts,
3451
+ });
3452
+ const savedPrepared = adoptedCandidate.candidate.prepared;
3453
+ await recheckCandidateClosure(realRoot, savedPrepared, config, savedOptions.unitIds);
3454
+ await recheckExecutionBundle(realRoot, savedPrepared);
3455
+ recheckReceipt(savedPrepared);
3456
+ const now = clock ? clock() : new Date().toISOString();
3457
+ if (decision.kind === 'await-results') {
3458
+ await evidence.append({
3459
+ phase: 'test-candidate',
3460
+ status: 'awaiting',
3461
+ candidatePath: decision.candidatePath,
3462
+ candidateSha256: decision.candidateSha256,
3463
+ subjectDigest: decision.subjectDigest,
3464
+ });
3465
+ await evidence.finish({
3466
+ status: 'NEEDS_INPUT',
3467
+ candidatePath: decision.candidatePath,
3468
+ candidateSha256: decision.candidateSha256,
3469
+ subjectDigest: decision.subjectDigest,
3470
+ completedAt: now,
3471
+ });
3472
+ return {
3473
+ status: 'NEEDS_INPUT',
3474
+ code: 'GATE_FAILED',
3475
+ exitCode: 13,
3476
+ candidatePath: decision.candidatePath,
3477
+ candidateSha256: decision.candidateSha256,
3478
+ subjectDigest: decision.subjectDigest,
3479
+ evidenceDir,
3480
+ requirements: (decision.gaps ?? []).map((item) => item.requirementId).filter(Boolean),
3481
+ gaps: decision.gaps,
3482
+ message: 'testIntake results for this candidate are not accepted yet',
3483
+ nextSteps: [{
3484
+ code: 'TEST_INTAKE_NEEDS_INPUT',
3485
+ message: 'Bind the result file to this subjectDigest and re-run prepare with the same candidate.',
3486
+ }],
3487
+ warnings: runWarnings,
3488
+ };
3489
+ }
3490
+ if (decision.kind === 'already-accepted') {
3491
+ await evidence.finish({
3492
+ status: decision.status ?? 'PREPARED',
3493
+ planDigest: decision.planDigest,
3494
+ completedAt: now,
3495
+ });
3496
+ return {
3497
+ status: decision.status,
3498
+ planPath: resolve(realRoot, decision.planPath),
3499
+ planDigest: decision.planDigest,
3500
+ evidenceDir,
3501
+ warnings: runWarnings,
3502
+ nextSteps: [],
3503
+ };
3504
+ }
3505
+ if (decision.prepared?.sourceAuthority?.entries) {
3506
+ try {
3507
+ const dirty = await checkSourceInputDirty({
3508
+ closure: { entries: decision.prepared.sourceAuthority.entries },
3509
+ root: realRoot,
3510
+ });
3511
+ if (dirty.dirty) {
3512
+ throw new ReleaseError(GATE_FAILED, 'test candidate source inputs changed', {
3513
+ reason: 'TEST_CANDIDATE_REPREPARE',
3514
+ drifts: dirty.dirtyPaths.map((filePath) => `source:${filePath}`),
3515
+ });
3516
+ }
3517
+ } catch (err) {
3518
+ if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
3519
+ throw new ReleaseError(GATE_FAILED, 'test candidate source inputs could not be rechecked', {
3520
+ reason: 'TEST_CANDIDATE_REPREPARE',
3521
+ drifts: ['sourceAuthority'],
3522
+ cause: err.code ?? 'unknown',
3523
+ });
3524
+ }
3525
+ }
3526
+ for (const unit of decision.prepared.units ?? []) {
3527
+ const frozen = unit.frozenSnapshot;
3528
+ if (!frozen) continue;
3529
+ try {
3530
+ // snapshotDigest is the pre-seal content hash. sealFrozenSnapshot
3531
+ // changes file mode, and that mode is part of the sealed digest stored
3532
+ // on frozenSnapshot.manifestDigest — the same field publish rechecks.
3533
+ if (frozen.path || frozen.manifestDigest) {
3534
+ await verifyFrozenSnapshot({
3535
+ root: realRoot,
3536
+ snapshotPath: frozen.path,
3537
+ expectedDigest: frozen.manifestDigest,
3538
+ });
3539
+ }
3540
+ if (frozen.gitObjectDir) {
3541
+ await verifyFrozenGitRepository({
3542
+ root: realRoot,
3543
+ gitObjectDir: frozen.gitObjectDir,
3544
+ commit: frozen.commit,
3545
+ tree: frozen.tree,
3546
+ parentCommit: frozen.parentCommit,
3547
+ commitTimestamp: frozen.commitTimestamp,
3548
+ });
3549
+ }
3550
+ const npmDistribution = (unit.distributions ?? []).find((dist) => dist.type === 'npm');
3551
+ if (frozen.npm?.tarballPath && npmDistribution) {
3552
+ await verifyFrozenNpmTarballContract({
3553
+ package: npmDistribution.package,
3554
+ version: unit.targetVersion,
3555
+ tarballPath: frozen.npm.tarballPath,
3556
+ tarballSha256: frozen.npm.tarballSha256,
3557
+ integrity: frozen.npm.integrity,
3558
+ }, realRoot);
3559
+ }
3560
+ } catch (err) {
3561
+ if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
3562
+ throw new ReleaseError(GATE_FAILED, `test candidate frozen object changed for unit "${unit.id}"`, {
3563
+ reason: 'TEST_CANDIDATE_REPREPARE',
3564
+ drifts: [`frozen:${unit.id}`],
3565
+ cause: err.code ?? 'unknown',
3566
+ });
3567
+ }
3568
+ }
3569
+ for (const action of decision.prepared.externalActions ?? []) {
3570
+ let assets = [];
3571
+ if (action.parameters?.releaseAssets !== undefined) {
3572
+ try {
3573
+ assets = normalizeReleaseAssets(action.parameters.releaseAssets);
3574
+ } catch (err) {
3575
+ throw new ReleaseError(GATE_FAILED, `test candidate release assets could not be rechecked for "${action.id}"`, {
3576
+ reason: 'TEST_CANDIDATE_REPREPARE',
3577
+ drifts: [`releaseAsset:${action.id}`],
3578
+ cause: err.code ?? 'unknown',
3579
+ });
3580
+ }
3581
+ }
3582
+ for (const releaseAsset of assets) {
3583
+ try {
3584
+ await verifyFrozenFile({
3585
+ root: realRoot,
3586
+ filePath: releaseAsset.path,
3587
+ expectedSha256: releaseAsset.sha256,
3588
+ label: `frozen GitHub Release asset "${releaseAsset.name}"`,
3589
+ });
3590
+ } catch (err) {
3591
+ if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
3592
+ throw new ReleaseError(GATE_FAILED, `test candidate release asset changed for "${releaseAsset.name}"`, {
3593
+ reason: 'TEST_CANDIDATE_REPREPARE',
3594
+ drifts: [`releaseAsset:${releaseAsset.name}`],
3595
+ cause: err.code ?? 'unknown',
3596
+ });
3597
+ }
3598
+ }
3599
+ }
3600
+ if (savedOptions.production === true && decision.prepared?.production?.mode !== 'github-npm-v1') {
3601
+ candidateReprepare('test candidate prepared.production does not match production prepare', ['prepared.production']);
3602
+ }
3603
+ const plan = {
3604
+ ...decision.prepared,
3605
+ status: 'PREPARED',
3606
+ testAcceptance: decision.testAcceptance,
3607
+ createdAt: now,
3608
+ };
3609
+ try {
3610
+ validatePlan(plan);
3611
+ } catch (err) {
3612
+ if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
3613
+ candidateReprepare(`test candidate plan structure is incomplete: ${err.message}`, ['plan-structure']);
3614
+ }
3615
+ const completeness = validatePlanActionCompleteness(plan);
3616
+ if (!completeness.passed) {
3617
+ const failures = completeness.details?.failures ?? [];
3618
+ candidateReprepare(
3619
+ `test candidate action completeness failed: ${failures.join('; ')}`,
3620
+ ['action-completeness'],
3621
+ { failures },
3622
+ );
3623
+ }
3624
+ const latestPlanPath = output ?? resolve(releaseDir, 'release-plan.json');
3625
+ const plannedDigest = computePlanDigest(plan);
3626
+ const immutablePlanPath = resolve(dirname(latestPlanPath), 'plans', `${plannedDigest}.json`);
3627
+ const { planPath: writtenPath, planDigest } = await writePlanImmutable(immutablePlanPath, plan);
3628
+ await writePlanAtomic(latestPlanPath, plan);
3629
+ await writeFrozenMarker(releaseDir, {
3630
+ planDigest,
3631
+ targetVersions: savedOptions.unitVersions,
3632
+ createdAt: plan.createdAt,
3633
+ runId: basename(runDir),
3634
+ });
3635
+ await evidence.append({
3636
+ phase: 'plan-write',
3637
+ status: 'completed',
3638
+ planPath: writtenPath,
3639
+ planDigest,
3640
+ });
3641
+ await evidence.finish({
3642
+ status: 'PREPARED',
3643
+ planPath: writtenPath,
3644
+ planDigest,
3645
+ configDigest,
3646
+ offline,
3647
+ workflowKind: resolvedWorkflow,
3648
+ completedAt: now,
3649
+ });
3650
+ return {
3651
+ planPath: writtenPath,
3652
+ planDigest,
3653
+ evidenceDir,
3654
+ warnings: runWarnings,
3655
+ nextSteps: [],
3656
+ status: 'PREPARED',
3657
+ };
3658
+ }
3659
+
3291
3660
  export async function prepareRelease(options) {
3292
3661
  const {
3293
3662
  root,
@@ -3411,7 +3780,20 @@ export async function prepareRelease(options) {
3411
3780
  await evidence.append({ phase: 'config', status: 'started' });
3412
3781
 
3413
3782
  const { config, configPath, configDigest } = await loadProjectConfig({ root: realRoot });
3414
- const releaseSelection = resolveRequestedReleaseUnits(config, unitIds, {
3783
+ assertTestIntakeDeclaration({ config, production });
3784
+ if (options?.candidatePath && !config.testIntake) {
3785
+ throw new ReleaseError(CONFIG_INVALID, 'candidate path requires testIntake', {
3786
+ reason: 'TEST_CANDIDATE_WITHOUT_INTAKE',
3787
+ field: 'candidatePath',
3788
+ });
3789
+ }
3790
+ const adoptedCandidate = options?.candidatePath
3791
+ ? await readTestCandidate({ root: realRoot, candidatePath: options.candidatePath })
3792
+ : null;
3793
+ const selectionUnitIds = adoptedCandidate && !Array.isArray(options?.unitIds)
3794
+ ? adoptedCandidate.candidate.prepareOptions.unitIds
3795
+ : unitIds;
3796
+ const releaseSelection = resolveRequestedReleaseUnits(config, selectionUnitIds, {
3415
3797
  root,
3416
3798
  version,
3417
3799
  offline,
@@ -3455,6 +3837,34 @@ export async function prepareRelease(options) {
3455
3837
  });
3456
3838
  }
3457
3839
 
3840
+ if (adoptedCandidate) {
3841
+ return await reattachTestCandidate({
3842
+ realRoot,
3843
+ runDir,
3844
+ releaseDir,
3845
+ evidence,
3846
+ evidenceDir,
3847
+ config,
3848
+ configDigest,
3849
+ production,
3850
+ workflow,
3851
+ offline,
3852
+ version,
3853
+ options,
3854
+ clock,
3855
+ runWarnings,
3856
+ adoptedCandidate,
3857
+ output,
3858
+ });
3859
+ }
3860
+ if (config.testIntake) {
3861
+ await readTestRequirements({
3862
+ root: realRoot,
3863
+ config,
3864
+ selectedUnitIds: releaseSelection.selectedUnitIds,
3865
+ });
3866
+ }
3867
+
3458
3868
  // --- Step 1-fresh: Bundle freshness gate (BUNDLE_STALE, fail-closed) ---
3459
3869
  // 2026-08-18 investigation §4.2: a stale bin/release-skill.bundle.mjs
3460
3870
  // used to surface only deep inside test hooks. Compare the deterministic
@@ -4371,7 +4781,38 @@ export async function prepareRelease(options) {
4371
4781
  // approval, it cannot be disabled by project overlays. Workflows that
4372
4782
  // trim declared hooks record the trim; projects declaring no test hook
4373
4783
  // pass vacuously (nothing can run incrementally there).
4374
- if (skipDeclaredHooks) {
4784
+ if (config.testIntake) {
4785
+ await evidence.append({
4786
+ phase: 'full-test-gate',
4787
+ status: 'replaced',
4788
+ reason: 'testIntake acceptance replaces the vacuous full-test pass; applicable requirements stay required',
4789
+ });
4790
+ if (!skipDeclaredHooks && config.hooks?.test) {
4791
+ const testRecord = hookRecords.find((record) => record.name === 'test');
4792
+ const satisfied = Boolean(
4793
+ testRecord && testRecord.completed && testRecord.testSelection === 'full',
4794
+ );
4795
+ if (!satisfied) {
4796
+ await evidence.append({
4797
+ phase: 'full-test-gate',
4798
+ status: 'blocking',
4799
+ testSelection: testRecord?.testSelection ?? null,
4800
+ cached: Boolean(testRecord?.cached),
4801
+ });
4802
+ throw new ReleaseError(
4803
+ GATE_FAILED,
4804
+ testRecord?.testSelection === 'incremental'
4805
+ ? 'full-test freeze gate failed: incremental test selection cannot satisfy the freeze-time requirement of a completed full test run in this prepare run'
4806
+ : 'full-test freeze gate failed: prepare requires a completed full-mode test hook in this run before the plan digest is computed',
4807
+ {
4808
+ gate: 'full-test-freeze',
4809
+ testSelection: testRecord?.testSelection ?? null,
4810
+ cached: Boolean(testRecord?.cached),
4811
+ },
4812
+ );
4813
+ }
4814
+ }
4815
+ } else if (skipDeclaredHooks) {
4375
4816
  await evidence.append({
4376
4817
  phase: 'full-test-gate',
4377
4818
  status: 'skipped',
@@ -4977,6 +5418,61 @@ export async function prepareRelease(options) {
4977
5418
  actionCount: externalActions.length,
4978
5419
  });
4979
5420
 
5421
+ if (config.testIntake) {
5422
+ const requirementsNow = await readTestRequirements({
5423
+ root: realRoot,
5424
+ config,
5425
+ selectedUnitIds: releaseSelection.selectedUnitIds,
5426
+ });
5427
+ const saved = await writeTestCandidate({
5428
+ root: realRoot,
5429
+ runDir,
5430
+ prepared: projectPrepared(plan),
5431
+ requirements: requirementsNow,
5432
+ prepareOptions: {
5433
+ workflow,
5434
+ offline,
5435
+ production: true,
5436
+ unitIds: releaseSelection.selectedUnitIds,
5437
+ unitVersions: Object.fromEntries(
5438
+ configUnits.map((unit, index) => [unit.id, resolvedVersions[index]]),
5439
+ ),
5440
+ },
5441
+ });
5442
+ const subjectDigest = subjectDigestOf(projectPrepared(plan));
5443
+ await evidence.append({
5444
+ phase: 'test-candidate',
5445
+ status: 'saved',
5446
+ candidatePath: saved.candidatePath,
5447
+ candidateSha256: saved.sha256,
5448
+ subjectDigest,
5449
+ });
5450
+ await evidence.finish({
5451
+ status: 'NEEDS_INPUT',
5452
+ candidatePath: saved.candidatePath,
5453
+ candidateSha256: saved.sha256,
5454
+ subjectDigest,
5455
+ configDigest,
5456
+ completedAt: (clock ? clock() : new Date().toISOString()),
5457
+ });
5458
+ return {
5459
+ status: 'NEEDS_INPUT',
5460
+ code: 'GATE_FAILED',
5461
+ exitCode: 13,
5462
+ candidatePath: saved.candidatePath,
5463
+ candidateSha256: saved.sha256,
5464
+ subjectDigest,
5465
+ evidenceDir,
5466
+ requirements: requirementsNow.preFreeze.map((item) => item.id),
5467
+ message: 'testIntake candidate saved; results for this object are not accepted yet',
5468
+ nextSteps: [{
5469
+ code: 'TEST_INTAKE_NEEDS_INPUT',
5470
+ message: 'Call TEST on this subject, bind the result file to subjectDigest, then re-run prepare with the same candidate.',
5471
+ }],
5472
+ warnings: runWarnings,
5473
+ };
5474
+ }
5475
+
4980
5476
  // --- Step 8: Validate and write plan atomically ---
4981
5477
  await evidence.append({ phase: 'plan-write', status: 'started' });
4982
5478
 
@@ -31,6 +31,7 @@ import { PLATFORMS, normalizeHostId } from '../platforms/registry.mjs';
31
31
  import { deriveSurfaceHostBinding, groupSurfaceHostBindings, pluginRootFromManifestRelativePath } from '../core/surface-host-bindings.mjs';
32
32
 
33
33
  import { assertImmutablePlanAuthority, computePlanDigest, validatePlan, validatePlanActionCompleteness } from '../core/plan.mjs';
34
+ import { verifyTestAcceptance } from '../core/test-intake.mjs';
34
35
  import {
35
36
  assertImmutableApprovalAuthority,
36
37
  computeApprovalDigest,
@@ -497,6 +498,7 @@ export async function publishRelease(options) {
497
498
  }
498
499
  validatePlan(plan);
499
500
  assertImmutablePlanAuthority(planPath, plan);
501
+ await verifyTestAcceptance({ root, plan });
500
502
  const isProductionPlan = plan.production?.mode === 'github-npm-v1';
501
503
 
502
504
  // --- Set up directories ---
@@ -27,6 +27,7 @@ import { readFile, mkdir } from 'node:fs/promises';
27
27
  import { join } from 'node:path';
28
28
 
29
29
  import { assertImmutablePlanAuthority, computePlanDigest, validatePlan, validatePlanActionCompleteness } from '../core/plan.mjs';
30
+ import { verifyTestAcceptance } from '../core/test-intake.mjs';
30
31
  import {
31
32
  assertImmutableApprovalAuthority,
32
33
  computeApprovalDigest,
@@ -148,6 +149,7 @@ export async function reconcileRelease(options) {
148
149
  }
149
150
  validatePlan(plan);
150
151
  assertImmutablePlanAuthority(planPath, plan);
152
+ await verifyTestAcceptance({ root, plan });
151
153
  const isProductionPlan = plan.production?.mode === 'github-npm-v1';
152
154
 
153
155
  // --- Set up directories ---
@@ -354,7 +354,7 @@ export async function classifyBaselineSurface(root, prevCommit) {
354
354
  * read. Invalid authority is diagnostic, never permission for a new release.
355
355
  *
356
356
  * @param {string} root - project root directory
357
- * @returns {Promise<{hasPartialRun: boolean, recoveryActionCode: string|null, runs: object[]}>}
357
+ * @returns {Promise<{hasPartialRun: boolean, recoveryActionCode: string|null, runs: object[], observedRuns: object[]}>}
358
358
  */
359
359
  export async function readRunRouting(root, options = {}) {
360
360
  const cwd = resolve(root);
@@ -364,7 +364,9 @@ export async function readRunRouting(root, options = {}) {
364
364
  try {
365
365
  runDirs = await readdir(runsDir);
366
366
  } catch (error) {
367
- if (error.code === 'ENOENT') return { hasPartialRun: false, recoveryActionCode: null, runs: [] };
367
+ if (error.code === 'ENOENT') {
368
+ return { hasPartialRun: false, recoveryActionCode: null, runs: [], observedRuns: [] };
369
+ }
368
370
  const relativePath = relative(cwd, runsDir);
369
371
  const diagnostic = {
370
372
  code: error.code ?? 'RUNS_DIRECTORY_UNREADABLE',
@@ -383,6 +385,7 @@ export async function readRunRouting(root, options = {}) {
383
385
  hasPartialRun: false,
384
386
  recoveryActionCode: null,
385
387
  runs: [],
388
+ observedRuns: [],
386
389
  diagnostics: [{
387
390
  path: relativePath,
388
391
  runPath: runsDir,
@@ -455,6 +458,22 @@ export async function readRunRouting(root, options = {}) {
455
458
  }
456
459
  records.push(record);
457
460
  }
461
+ // Runs with a null recovery code, and runs seen without a target version,
462
+ // stay out of `runs`. Callers that need the recorded status still see them.
463
+ // Recovery selection below is unchanged.
464
+ const observedRuns = records.flatMap((record) => {
465
+ const run = record.run;
466
+ if (!run?.planPath || !run.status) return [];
467
+ return [{
468
+ runPath: record.runPath ?? null,
469
+ planPath: run.planPath,
470
+ command: run.command ?? null,
471
+ status: run.status,
472
+ sourceRunPath: run.sourceRunPath ?? null,
473
+ ...(run.approvalPath ? { approvalPath: run.approvalPath } : {}),
474
+ recoveryActionCode: record.recoveryActionCode ?? null,
475
+ }];
476
+ }).sort((a, b) => String(a.runPath).localeCompare(String(b.runPath)));
458
477
  const targetScopedRecords = [];
459
478
  const historicalDiagnostics = [];
460
479
  for (const record of records) {
@@ -536,6 +555,7 @@ export async function readRunRouting(root, options = {}) {
536
555
  hasPartialRun: unresolved.some((r) => r.run?.status === 'PARTIAL'),
537
556
  recoveryActionCode,
538
557
  runs,
558
+ observedRuns,
539
559
  ...(unifiedDiagnostics.length > 0 ? { diagnostics: unifiedDiagnostics } : {}),
540
560
  };
541
561
  }
@@ -872,14 +892,15 @@ async function isProvenPreAuthorityFailure(runDir, runName) {
872
892
 
873
893
  /**
874
894
  * 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.
895
+ * reader. Its completed and open records are consumable only when the summary
896
+ * agrees with the sealed status and the verify parent is fully lineage-valid.
877
897
  */
878
898
  async function readLegacyPostverifyRecovery(runPath) {
879
899
  try {
880
900
  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');
901
+ const acceptedStatuses = new Set(['DISTRIBUTED', 'PARTIAL', 'BLOCKED', 'NEEDS_INPUT']);
902
+ if (run.command !== 'postverify' || !acceptedStatuses.has(run.status)) {
903
+ throw new ReleaseError(GATE_FAILED, 'postverify status is not a recognized completed or open state');
883
904
  }
884
905
  const planRaw = await readFile(run.planPath, 'utf8');
885
906
  const plan = JSON.parse(planRaw);
@@ -932,7 +953,8 @@ async function readLegacyPostverifyRecovery(runPath) {
932
953
  }))
933
954
  ));
934
955
  validateRunCheckpointMapping(run, expectedPostVerifyActions);
935
- if (run.checkpoints.some((checkpoint) => !['succeeded', 'skipped', 'NO_CHANGE'].includes(checkpoint.status))) {
956
+ if (run.status === 'DISTRIBUTED'
957
+ && run.checkpoints.some((checkpoint) => !['succeeded', 'skipped', 'NO_CHANGE'].includes(checkpoint.status))) {
936
958
  throw new ReleaseError(GATE_FAILED, 'legacy postverify DISTRIBUTED run has incomplete checkpoints');
937
959
  }
938
960
  return {
@@ -940,6 +962,9 @@ async function readLegacyPostverifyRecovery(runPath) {
940
962
  run,
941
963
  plan,
942
964
  lineage: [{ run: parent, runPath: resolve(run.sourceRunPath) }],
965
+ // Postverify continuation is owned by the existing ship entry point.
966
+ // Route exposes its validated status but does not turn it into a second
967
+ // release recovery action.
943
968
  recoveryActionCode: null,
944
969
  legacyPostverify: true,
945
970
  };