release-skill 0.9.33 → 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 (85) 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 +28 -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 +5847 -4041
  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-help/SKILL.md +4 -1
  19. package/adapters/claude/skills/release-prepare/SKILL.md +9 -2
  20. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  21. package/adapters/codex/bin/release-skill.bundle.mjs +5847 -4041
  22. package/adapters/codex/schemas/.render-manifest.json +8 -4
  23. package/adapters/codex/schemas/release-plan.schema.json +85 -0
  24. package/adapters/codex/schemas/release-project.schema.json +18 -0
  25. package/adapters/codex/schemas/test-intake.schema.json +217 -0
  26. package/adapters/codex/skills/release-help/SKILL.md +4 -1
  27. package/adapters/codex/skills/release-prepare/SKILL.md +9 -2
  28. package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
  29. package/adapters/cursor/bin/release-skill.bundle.mjs +5847 -4041
  30. package/adapters/cursor/schemas/.render-manifest.json +8 -4
  31. package/adapters/cursor/schemas/release-plan.schema.json +85 -0
  32. package/adapters/cursor/schemas/release-project.schema.json +18 -0
  33. package/adapters/cursor/schemas/test-intake.schema.json +217 -0
  34. package/adapters/cursor/skills/release-help/SKILL.md +4 -1
  35. package/adapters/cursor/skills/release-prepare/SKILL.md +9 -2
  36. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  37. package/adapters/kimi/bin/release-skill.bundle.mjs +5847 -4041
  38. package/adapters/kimi/schemas/.render-manifest.json +8 -4
  39. package/adapters/kimi/schemas/release-plan.schema.json +85 -0
  40. package/adapters/kimi/schemas/release-project.schema.json +18 -0
  41. package/adapters/kimi/schemas/test-intake.schema.json +217 -0
  42. package/adapters/kimi/skills/release-help/SKILL.md +4 -1
  43. package/adapters/kimi/skills/release-prepare/SKILL.md +9 -2
  44. package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
  45. package/adapters/qoder/bin/release-skill.bundle.mjs +5847 -4041
  46. package/adapters/qoder/schemas/.render-manifest.json +8 -4
  47. package/adapters/qoder/schemas/release-plan.schema.json +85 -0
  48. package/adapters/qoder/schemas/release-project.schema.json +18 -0
  49. package/adapters/qoder/schemas/test-intake.schema.json +217 -0
  50. package/adapters/qoder/skills/release-help/SKILL.md +4 -1
  51. package/adapters/qoder/skills/release-prepare/SKILL.md +9 -2
  52. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  53. package/adapters/workbuddy/bin/release-skill.bundle.mjs +5847 -4041
  54. package/adapters/workbuddy/schemas/.render-manifest.json +8 -4
  55. package/adapters/workbuddy/schemas/release-plan.schema.json +85 -0
  56. package/adapters/workbuddy/schemas/release-project.schema.json +18 -0
  57. package/adapters/workbuddy/schemas/test-intake.schema.json +217 -0
  58. package/adapters/workbuddy/skills/release-help/SKILL.md +4 -1
  59. package/adapters/workbuddy/skills/release-prepare/SKILL.md +9 -2
  60. package/bin/release-skill-cli.mjs +37 -2
  61. package/bin/release-skill.bundle.mjs +5847 -4041
  62. package/package.json +1 -1
  63. package/platform-manifest.json +4 -4
  64. package/references/.render-manifest.json +6 -6
  65. package/references/01-state-machine.md +12 -1
  66. package/references/02-project-config.md +20 -0
  67. package/references/05-evidence-and-errors.md +30 -0
  68. package/schemas/.render-manifest.json +8 -4
  69. package/schemas/release-plan.schema.json +85 -0
  70. package/schemas/release-project.schema.json +18 -0
  71. package/schemas/test-intake.schema.json +217 -0
  72. package/skills/release-help/SKILL.md +4 -1
  73. package/skills/release-prepare/SKILL.md +9 -2
  74. package/skills-src/release-help/SKILL.md +4 -1
  75. package/skills-src/release-prepare/SKILL.md +9 -2
  76. package/src/commands/approve.mjs +13 -0
  77. package/src/commands/prepare.mjs +501 -5
  78. package/src/commands/publish.mjs +2 -0
  79. package/src/commands/reconcile.mjs +2 -0
  80. package/src/commands/route.mjs +32 -7
  81. package/src/commands/ship.mjs +168 -5
  82. package/src/core/config.mjs +5 -0
  83. package/src/core/plan.mjs +3 -1
  84. package/src/core/test-candidate.mjs +649 -0
  85. package/src/core/test-intake.mjs +645 -0
@@ -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
  };
@@ -1,4 +1,4 @@
1
- import { readFile, lstat, mkdir } from 'node:fs/promises';
1
+ import { readFile, lstat, mkdir, realpath } from 'node:fs/promises';
2
2
  import { basename, dirname, resolve } from 'node:path';
3
3
  import { publishFileOrReplace } from 'skill-family-harness-node';
4
4
 
@@ -21,6 +21,10 @@ import {
21
21
  PLAN_DIGEST_MISMATCH,
22
22
  CONSUMER_VERIFICATION_DEFERRED,
23
23
  } from '../core/errors.mjs';
24
+ import {
25
+ applyShipPrepareOutcome,
26
+ resolveShipCandidateContinuation,
27
+ } from '../core/test-candidate.mjs';
24
28
 
25
29
  function normalizeNewShipUnitIds(releaseUnits, requestedUnitIds) {
26
30
  if (requestedUnitIds === undefined) return undefined;
@@ -102,6 +106,66 @@ async function readFrozenPlanForUnitScope(planPath, statePath, stateUnitIds) {
102
106
  }
103
107
  }
104
108
 
109
+ async function sameAuthorityFile(left, right) {
110
+ const identity = async (value) => {
111
+ const absolute = resolve(value);
112
+ try {
113
+ return await realpath(absolute);
114
+ } catch {
115
+ return absolute;
116
+ }
117
+ };
118
+ return (await identity(left)) === (await identity(right));
119
+ }
120
+
121
+ /**
122
+ * A VERIFIED reattach keeps the release fact. An observed postverify is
123
+ * carried with its verify parent so open work can re-enter postVerifyRelease
124
+ * and completed work can feed the existing local finish checklist.
125
+ */
126
+ async function observedPostverifyContinuation(root, planPath) {
127
+ if (!planPath) return null;
128
+ let routing;
129
+ try {
130
+ const { readRunRouting } = await import('./route.mjs');
131
+ routing = await readRunRouting(root, {});
132
+ } catch {
133
+ return null;
134
+ }
135
+ const formal = resolve(planPath);
136
+ const bound = [];
137
+ for (const run of routing?.observedRuns ?? []) {
138
+ if (!run?.planPath || !run.status || !run.runPath || !run.command) continue;
139
+ if (await sameAuthorityFile(run.planPath, formal)) bound.push(run);
140
+ }
141
+ const postverifyRuns = bound.filter((run) => run.command === 'postverify');
142
+ const completed = postverifyRuns.filter((run) => run.status === 'DISTRIBUTED');
143
+ const open = postverifyRuns.filter((run) => run.status !== 'DISTRIBUTED');
144
+ // A completed authority supersedes open attempts for the same verify
145
+ // parent. Multiple completed records are ambiguous; never fall back to an
146
+ // older open attempt or choose by directory order or timestamp.
147
+ if (completed.length > 1) return null;
148
+ const pending = completed.length === 1 ? completed[0] : open.length === 1 ? open[0] : null;
149
+ if (!pending) return null;
150
+ let verify = null;
151
+ for (const run of bound) {
152
+ if (run.command !== 'verify' || run.status !== 'VERIFIED' || !run.runPath) continue;
153
+ if (pending.sourceRunPath && await sameAuthorityFile(run.runPath, pending.sourceRunPath)) {
154
+ verify = run;
155
+ break;
156
+ }
157
+ }
158
+ if (!verify) return null;
159
+ return {
160
+ verifyRunPath: verify.runPath,
161
+ ...(verify.approvalPath ? { approvalPath: verify.approvalPath } : {}),
162
+ postVerify: {
163
+ status: pending.status,
164
+ runPath: pending.runPath,
165
+ },
166
+ };
167
+ }
168
+
105
169
  async function writeJsonAtomic(path, value) {
106
170
  await mkdir(dirname(path), { recursive: true });
107
171
  const { stateDigest: _oldDigest, ...body } = value;
@@ -177,9 +241,10 @@ async function defaultDependencies() {
177
241
 
178
242
  function publicState(state, { postRelease } = {}) {
179
243
  const visiblePostRelease = postRelease === undefined ? state.postRelease : postRelease;
244
+ const showsTestCandidate = state.status === 'NEW' && state.testCandidate;
180
245
  return {
181
246
  command: 'ship',
182
- status: state.status,
247
+ status: showsTestCandidate ? 'NEEDS_INPUT' : state.status,
183
248
  statePath: state.statePath,
184
249
  targetVersion: state.targetVersion,
185
250
  ...(state.hooks && state.hooks.length > 0 ? {
@@ -199,6 +264,10 @@ function publicState(state, { postRelease } = {}) {
199
264
  ...(state.distributeRunPath ? { distributeRunPath: state.distributeRunPath } : {}),
200
265
  ...(state.postVerify ? { postVerify: state.postVerify } : {}),
201
266
  ...(state.requirements ? { requirements: state.requirements } : {}),
267
+ ...(showsTestCandidate ? { testCandidate: state.testCandidate } : {}),
268
+ ...(showsTestCandidate && state.pendingTestRequirements
269
+ ? { pendingTestRequirements: state.pendingTestRequirements }
270
+ : {}),
202
271
  ...(state.manualFollowUps ? { manualFollowUps: state.manualFollowUps } : {}),
203
272
  ...(state.metadataUpdate ? { metadataUpdate: state.metadataUpdate } : {}),
204
273
  ...(visiblePostRelease ? { postRelease: visiblePostRelease } : {}),
@@ -483,7 +552,77 @@ export async function advanceShip(options = {}, injected = {}) {
483
552
  });
484
553
  }
485
554
 
486
- if (state.status === 'NEW') {
555
+ if (state.status === 'NEW' && state.testCandidate) {
556
+ const loaded = await deps.loadProjectConfig({ root });
557
+ const continuation = resolveShipCandidateContinuation({
558
+ state,
559
+ loadedConfigDigest: loaded.configDigest,
560
+ requestedUnitIds: options.unitIds,
561
+ });
562
+ if (continuation.kind === 'conflict') {
563
+ throw new ReleaseError(GATE_FAILED, `ship test candidate conflict: ${continuation.reason}`, {
564
+ reason: continuation.reason,
565
+ });
566
+ }
567
+ const prepared = await deps.prepareRelease({
568
+ root,
569
+ candidatePath: continuation.candidatePath,
570
+ expectedCandidateSha256: continuation.candidateSha256,
571
+ expectedSubjectDigest: continuation.subjectDigest,
572
+ production: true,
573
+ offline: false,
574
+ offlineExplicit: false,
575
+ workflowExplicit: false,
576
+ version: state.targetVersion ?? undefined,
577
+ versionExplicit: state.targetVersion != null,
578
+ hooksAuthorized: true,
579
+ verificationGatesAuthorized: true,
580
+ hookCache: true,
581
+ ...(Array.isArray(state.selectedUnitIds) ? { unitIds: state.selectedUnitIds } : {}),
582
+ });
583
+ if (prepared?.status === 'NEEDS_INPUT') {
584
+ state = {
585
+ ...applyShipPrepareOutcome(state, {
586
+ ...prepared,
587
+ testCandidate: {
588
+ path: prepared.candidatePath ?? state.testCandidate.path,
589
+ sha256: prepared.candidateSha256 ?? state.testCandidate.sha256,
590
+ subjectDigest: prepared.subjectDigest ?? state.testCandidate.subjectDigest,
591
+ evidenceDir: prepared.evidenceDir ?? state.testCandidate.evidenceDir,
592
+ },
593
+ }),
594
+ updatedAt: new Date().toISOString(),
595
+ };
596
+ await writeJsonAtomic(statePath, state);
597
+ return publicState(state);
598
+ }
599
+ const outcome = applyShipPrepareOutcome(state, prepared);
600
+ // A reattached PARTIAL, PUBLISHED, or VERIFIED record stays that status.
601
+ // Returning here avoids a new approval and avoids falling through into
602
+ // reconcile, distribute, or verify in this same call.
603
+ if (outcome.status !== 'NEEDS_PLAN_APPROVAL') {
604
+ const continuation = outcome.status === 'VERIFIED'
605
+ ? await observedPostverifyContinuation(root, outcome.planPath)
606
+ : null;
607
+ state = {
608
+ ...outcome,
609
+ ...(continuation ?? {}),
610
+ updatedAt: new Date().toISOString(),
611
+ };
612
+ await writeJsonAtomic(statePath, state);
613
+ return publicState(state);
614
+ }
615
+ const maxApprovalMs = typeof deps.readMaxApprovalMs === 'function'
616
+ ? await deps.readMaxApprovalMs()
617
+ : MAX_APPROVAL_MS;
618
+ const approvalSummary = await buildApprovalSummary(prepared.planPath, maxApprovalMs);
619
+ state = {
620
+ ...outcome,
621
+ approvalSummary,
622
+ updatedAt: new Date().toISOString(),
623
+ };
624
+ await writeJsonAtomic(statePath, state);
625
+ } else if (state.status === 'NEW') {
487
626
  const loaded = await deps.loadProjectConfig({ root });
488
627
  let stateChanged = false;
489
628
  if (Array.isArray(state.selectedUnitIds)) {
@@ -538,6 +677,23 @@ export async function advanceShip(options = {}, injected = {}) {
538
677
  hookCache: true,
539
678
  ...(state.selectedUnitIds ? { unitIds: state.selectedUnitIds } : {}),
540
679
  });
680
+ if (prepared?.status === 'NEEDS_INPUT') {
681
+ state = {
682
+ ...applyShipPrepareOutcome(state, {
683
+ ...prepared,
684
+ testCandidate: {
685
+ path: prepared.candidatePath,
686
+ sha256: prepared.candidateSha256,
687
+ subjectDigest: prepared.subjectDigest,
688
+ evidenceDir: prepared.evidenceDir,
689
+ },
690
+ requirements: prepared.requirements,
691
+ }),
692
+ updatedAt: new Date().toISOString(),
693
+ };
694
+ await writeJsonAtomic(statePath, state);
695
+ return publicState(state);
696
+ }
541
697
  let frozenPlan = null;
542
698
  if (Array.isArray(state.selectedUnitIds) || deps.preflightGitTransports) {
543
699
  frozenPlan = Array.isArray(state.selectedUnitIds)
@@ -631,6 +787,9 @@ export async function advanceShip(options = {}, injected = {}) {
631
787
  }
632
788
 
633
789
  if (state.status === 'PARTIAL') {
790
+ // A reattached terminal record has no source run. Returning keeps that
791
+ // status; a publish that stored sourceRunPath still reconciles.
792
+ if (!state.sourceRunPath) return publicState(state);
634
793
  const reconciled = await deps.reconcileRelease({
635
794
  planPath: state.planPath,
636
795
  sourceRunPath: state.sourceRunPath,
@@ -653,6 +812,7 @@ export async function advanceShip(options = {}, injected = {}) {
653
812
  let postVerifyRanThisCall = false;
654
813
 
655
814
  if (state.status === 'PUBLISHED' || state.status === 'NEEDS_MANUAL_ATTESTATIONS') {
815
+ if (!state.sourceRunPath && !state.distributeRunPath) return publicState(state);
656
816
  // Step 1: Check if postPublish requires distribution.
657
817
  // Only targets and distribute-phase hooks route through distribute;
658
818
  // phase:postVerify hooks belong to their independent post-VERIFIED run.
@@ -699,10 +859,12 @@ export async function advanceShip(options = {}, injected = {}) {
699
859
  }
700
860
 
701
861
  // Step 2: Verify (this will check for distributed run if distribution was required)
862
+ const verifySource = needsDistribution ? state.distributeRunPath : state.sourceRunPath;
863
+ if (!verifySource) return publicState(state);
702
864
  try {
703
865
  const verified = await deps.verifyRelease({
704
866
  planPath: state.planPath,
705
- sourceRunPath: needsDistribution ? state.distributeRunPath : state.sourceRunPath,
867
+ sourceRunPath: verifySource,
706
868
  adapterRegistry: options.adapterRegistry,
707
869
  root,
708
870
  verificationGatesAuthorized: state.verificationGatesAuthorized === true,
@@ -737,7 +899,7 @@ export async function advanceShip(options = {}, injected = {}) {
737
899
  const postVerifyHooks = normalizePostPublishView(plan)
738
900
  .flatMap((declaration) => declaration.hooks ?? [])
739
901
  .filter((hook) => hook.phase === 'postVerify');
740
- if (state.status === 'VERIFIED' && postVerifyHooks.length > 0 && deps.postVerifyRelease) {
902
+ if (state.status === 'VERIFIED' && state.verifyRunPath && postVerifyHooks.length > 0 && deps.postVerifyRelease) {
741
903
  postVerifyRanThisCall = true;
742
904
  let postVerifyOutcome;
743
905
  try {
@@ -785,6 +947,7 @@ export async function advanceShip(options = {}, injected = {}) {
785
947
  if (
786
948
  state.status === 'VERIFIED'
787
949
  && deps.postVerifyRelease
950
+ && state.verifyRunPath
788
951
  && !postVerifyRanThisCall
789
952
  && (!state.postVerify || state.postVerify.status !== 'DISTRIBUTED')
790
953
  ) {
@@ -27,6 +27,7 @@ import { validatePublicSurfaceGlob } from './public-surface.mjs';
27
27
  import { canonicalPublicPath, publicPathCollisionKey } from '../snapshot/public-path.mjs';
28
28
  import { isReservedReleaseControlPath } from './baseline.mjs';
29
29
  import { readTrustedPackageResource } from './trusted-resource.mjs';
30
+ import { assertTestIntakePaths } from './test-intake.mjs';
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Constants
@@ -423,6 +424,10 @@ export async function loadProjectConfig({ root, configPath } = {}) {
423
424
  );
424
425
  }
425
426
 
427
+ if (config.testIntake) {
428
+ assertTestIntakePaths(config.testIntake);
429
+ }
430
+
426
431
  // --- Compile forbiddenContentPatterns (fail closed on invalid regex) ---
427
432
  const forbiddenContentPatterns = config.policy?.forbiddenContentPatterns ?? [];
428
433
  for (const pattern of forbiddenContentPatterns) {
package/src/core/plan.mjs CHANGED
@@ -210,7 +210,9 @@ export function validatePlan(plan) {
210
210
  * still written to the plan file (schema unchanged, evidence chain intact)
211
211
  * but is excluded from the digest. Everything else (planVersion, units with
212
212
  * every frozenSnapshot artifact identity field, externalActions identity,
213
- * production, configDigest, verificationGates, snapshotDigest) binds.
213
+ * production, configDigest, verificationGates, snapshotDigest, and
214
+ * testAcceptance when present) binds. testAcceptance is intentionally absent
215
+ * from the exclusion list below: it stays in the binding layer.
214
216
  * For v3 the frozen `postPublish` array and every item binding field stay in
215
217
  * the binding layer: any order or binding change moves the digest.
216
218
  *