release-skill 0.6.2 → 0.7.0

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 (112) 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 +48 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/INSTALL.md +47 -2
  9. package/INSTALL.zh-CN.md +29 -2
  10. package/README.md +137 -12
  11. package/README.zh-CN.md +110 -9
  12. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  13. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  14. package/adapters/claude/bin/error-codes.json +1 -1
  15. package/adapters/claude/bin/kernel-protocol.json +1 -1
  16. package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
  17. package/adapters/claude/bin/license-texts/MIT.txt +21 -0
  18. package/adapters/claude/bin/registry.json +64 -1
  19. package/adapters/claude/bin/release-skill.bundle.mjs +50570 -42208
  20. package/adapters/claude/bin/rules.json +1 -1
  21. package/adapters/claude/schemas/.render-manifest.json +10 -6
  22. package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
  23. package/adapters/claude/schemas/release-plan.schema.json +200 -4
  24. package/adapters/claude/schemas/release-project.schema.json +86 -5
  25. package/adapters/claude/schemas/release-run.schema.json +11 -6
  26. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  27. package/adapters/codex/bin/error-codes.json +1 -1
  28. package/adapters/codex/bin/kernel-protocol.json +1 -1
  29. package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
  30. package/adapters/codex/bin/license-texts/MIT.txt +21 -0
  31. package/adapters/codex/bin/registry.json +64 -1
  32. package/adapters/codex/bin/release-skill.bundle.mjs +50570 -42208
  33. package/adapters/codex/bin/rules.json +1 -1
  34. package/adapters/codex/schemas/.render-manifest.json +10 -6
  35. package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
  36. package/adapters/codex/schemas/release-plan.schema.json +200 -4
  37. package/adapters/codex/schemas/release-project.schema.json +86 -5
  38. package/adapters/codex/schemas/release-run.schema.json +11 -6
  39. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  40. package/adapters/kimi/bin/error-codes.json +1 -1
  41. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  42. package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
  43. package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
  44. package/adapters/kimi/bin/registry.json +64 -1
  45. package/adapters/kimi/bin/release-skill.bundle.mjs +50570 -42208
  46. package/adapters/kimi/bin/rules.json +1 -1
  47. package/adapters/kimi/schemas/.render-manifest.json +10 -6
  48. package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
  49. package/adapters/kimi/schemas/release-plan.schema.json +200 -4
  50. package/adapters/kimi/schemas/release-project.schema.json +86 -5
  51. package/adapters/kimi/schemas/release-run.schema.json +11 -6
  52. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  53. package/adapters/workbuddy/bin/error-codes.json +1 -1
  54. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  55. package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
  56. package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
  57. package/adapters/workbuddy/bin/registry.json +64 -1
  58. package/adapters/workbuddy/bin/release-skill.bundle.mjs +50570 -42208
  59. package/adapters/workbuddy/bin/rules.json +1 -1
  60. package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
  61. package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
  62. package/adapters/workbuddy/schemas/release-plan.schema.json +200 -4
  63. package/adapters/workbuddy/schemas/release-project.schema.json +86 -5
  64. package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
  65. package/bin/error-codes.json +1 -1
  66. package/bin/kernel-protocol.json +1 -1
  67. package/bin/license-texts/Apache-2.0.txt +201 -0
  68. package/bin/license-texts/MIT.txt +21 -0
  69. package/bin/registry.json +64 -1
  70. package/bin/release-skill-cli.mjs +187 -9
  71. package/bin/release-skill.bundle.mjs +50570 -42208
  72. package/bin/rules.json +1 -1
  73. package/package.json +6 -4
  74. package/platform-manifest.json +4 -4
  75. package/references/.render-manifest.json +5 -5
  76. package/references/01-state-machine.md +22 -2
  77. package/references/02-project-config.md +1 -1
  78. package/references/06-adapter-contract.md +6 -3
  79. package/schemas/.render-manifest.json +10 -6
  80. package/schemas/postpublish-approval-record.schema.json +47 -0
  81. package/schemas/release-plan.schema.json +200 -4
  82. package/schemas/release-project.schema.json +86 -5
  83. package/schemas/release-run.schema.json +11 -6
  84. package/src/adapters/distribute-git.mjs +17 -11
  85. package/src/commands/approve.mjs +163 -1
  86. package/src/commands/distribute.mjs +649 -89
  87. package/src/commands/postverify.mjs +808 -0
  88. package/src/commands/prepare.mjs +456 -45
  89. package/src/commands/setup.mjs +715 -0
  90. package/src/commands/ship.mjs +152 -5
  91. package/src/commands/verify.mjs +92 -15
  92. package/src/core/approval.mjs +93 -68
  93. package/src/core/bounded-output.mjs +46 -0
  94. package/src/core/derived-artifact-gates.mjs +258 -0
  95. package/src/core/docs-refresh-preset.mjs +171 -0
  96. package/src/core/errors.mjs +4 -0
  97. package/src/core/evidence.mjs +8 -1
  98. package/src/core/git-url-policy.mjs +214 -0
  99. package/src/core/hooks.mjs +28 -0
  100. package/src/core/marketplace-registry-entry.mjs +175 -0
  101. package/src/core/notify-handoff.mjs +76 -0
  102. package/src/core/postpublish-approval.mjs +241 -0
  103. package/src/core/postpublish-bundle.mjs +342 -0
  104. package/src/core/postpublish-projection.mjs +210 -0
  105. package/src/core/postpublish.mjs +449 -25
  106. package/src/core/preset-executor.mjs +175 -0
  107. package/src/core/preset-gitwrite.mjs +484 -0
  108. package/src/core/presets.mjs +754 -0
  109. package/src/core/proposal-inbox.mjs +646 -0
  110. package/src/core/redact.mjs +16 -3
  111. package/src/core/run.mjs +91 -6
  112. package/src/producers/build-adapters.mjs +28 -14
@@ -0,0 +1,808 @@
1
+ /**
2
+ * postVerify command: independent run routed AFTER the main run is VERIFIED
3
+ * (v0.6.3 R3, design §2.4).
4
+ *
5
+ * `postVerifyRelease` produces an INDEPENDENT run record (reused run +
6
+ * checkpoint machinery) whose lineage source is the VERIFIED verify run:
7
+ *
8
+ * - hooks receive the §2.3 context with `verifyEvidence` PRESENT (distribute
9
+ * phase contexts never carry it); `publishedAt` comes from the sealed
10
+ * publish run referenced by the verify run's own lineage;
11
+ * - failure classification reuses the distribute saga family: PARTIAL after
12
+ * an external success, BLOCKED at zero side effects, NEEDS_INPUT for a
13
+ * pure awaiting-approval park — and the source verify run is NEVER
14
+ * touched: VERIFIED never rolls back, the failure is evidenced;
15
+ * - requiresApproval hooks park at AWAITING_APPROVAL without a checkpoint
16
+ * approval (NEEDS_INPUT at zero side effects) and run once approved;
17
+ * - a non-verify / non-VERIFIED / differently-bound source run fails closed
18
+ * BEFORE any write (no run authority is allocated for a rejected lineage).
19
+ *
20
+ * Reconcile equivalence: a rerun IS the reconcile. Every hook starts from a
21
+ * fresh observation of its own downstream state (preset idempotence), so a
22
+ * failed postVerify run retries to DISTRIBUTED once the failure is cleared.
23
+ *
24
+ * @module commands/postverify
25
+ */
26
+
27
+ import { execFile as execFileCb } from 'node:child_process';
28
+ import { promisify } from 'node:util';
29
+ import { mkdir, readFile, realpath, rm } from 'node:fs/promises';
30
+ import { mkdtemp } from 'node:fs/promises';
31
+ import { tmpdir } from 'node:os';
32
+ import { join } from 'node:path';
33
+
34
+ import { assertImmutablePlanAuthority, computePlanDigest, validatePlan } from '../core/plan.mjs';
35
+ import {
36
+ assertImmutableApprovalAuthority,
37
+ computeApprovalDigest,
38
+ validateApproval,
39
+ validateApprovalRecordSchema,
40
+ } from '../core/approval.mjs';
41
+ import {
42
+ appendRunState,
43
+ createProductionRunDir,
44
+ loadRun,
45
+ resolveDefaultRunDir,
46
+ validateRunPlanDigest,
47
+ writeRunAtomic,
48
+ } from '../core/run.mjs';
49
+ import { createEvidenceWriter } from '../core/evidence.mjs';
50
+ import { runHook } from '../core/hooks.mjs';
51
+ import { boundedOutputTail } from '../core/bounded-output.mjs';
52
+ import {
53
+ buildPostPublishContext,
54
+ validatePostPublishDeclaration,
55
+ effectiveHookRequiresApproval,
56
+ POSTPUBLISH_CONTEXT_ENV,
57
+ PAYLOAD_SOURCE_TAG_WORKTREE,
58
+ } from '../core/postpublish.mjs';
59
+ import { verifyAndInstallExecutionBundle } from '../core/postpublish-bundle.mjs';
60
+ import { assertPostPublishApprovalAuthority, validatePostPublishApproval } from '../core/postpublish-approval.mjs';
61
+ import { executePresetHook } from '../core/preset-executor.mjs';
62
+ import {
63
+ ReleaseError,
64
+ GATE_FAILED,
65
+ POST_PUBLISH_VERIFY_FAILED,
66
+ } from '../core/errors.mjs';
67
+
68
+ const execFileAsync = promisify(execFileCb);
69
+
70
+ /** Executor identity recorded in every postVerify checkpoint trace. */
71
+ const EXECUTOR = 'release-skill postverify';
72
+
73
+ /** Full 40-hex commit sha. */
74
+ const SHA_RE = /^[a-f0-9]{40}$/;
75
+
76
+ /** Checkpoint error codes accepted by the release-run schema enum. */
77
+ const SCHEMA_ERROR_CODES = new Set([
78
+ 'CONFIG_INVALID',
79
+ 'BASELINE_CHANGED',
80
+ 'DIRTY_SCOPE_CONFLICT',
81
+ 'GATE_FAILED',
82
+ 'AUTH_MISSING',
83
+ 'REMOTE_CONFLICT',
84
+ 'REMOTE_UNAVAILABLE',
85
+ 'HOOK_TIMEOUT',
86
+ 'PARTIAL_RELEASE',
87
+ 'POST_PUBLISH_VERIFY_FAILED',
88
+ ]);
89
+
90
+ /** Run statuses. */
91
+ const DISTRIBUTING = 'DISTRIBUTING';
92
+ const DISTRIBUTED = 'DISTRIBUTED';
93
+ const PARTIAL = 'PARTIAL';
94
+ const BLOCKED = 'BLOCKED';
95
+ const NEEDS_INPUT = 'NEEDS_INPUT';
96
+
97
+ function defaultClock() {
98
+ return new Date().toISOString();
99
+ }
100
+
101
+ function defaultExec(command, args, options = {}) {
102
+ return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: 120_000, ...options });
103
+ }
104
+
105
+ /** Map a preset/transport error code onto the run-schema checkpoint enum. */
106
+ function mapToSchemaCode(code) {
107
+ return SCHEMA_ERROR_CODES.has(code) ? code : GATE_FAILED;
108
+ }
109
+
110
+ /**
111
+ * Execute the postVerify phase of a frozen, approved plan as an independent
112
+ * run bound to the VERIFIED verify run.
113
+ *
114
+ * @param {Object} options
115
+ * @param {string} options.planPath - Absolute path to the frozen release plan.
116
+ * @param {string} options.approvalPath - Absolute path to the approval record.
117
+ * @param {string} options.sourceRunPath - Absolute path to the sealed VERIFIED verify run.
118
+ * @param {string} [options.root] - Project root (git repository). Defaults to cwd.
119
+ * @param {string} [options.runDir] - Evidence directory override.
120
+ * @param {boolean} [options.dryRun] - Rehearsal: zero hook/preset execution.
121
+ * @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
122
+ * @param {Function} [options.execFn] - Injectable git exec (tests).
123
+ * @param {Function} [options.runHookFn] - Injectable hook runner (tests).
124
+ * @param {string[]} [options.postpublishApprovalPaths] - Checkpoint approval
125
+ * records for requiresApproval postVerify hooks; each record binds
126
+ * (planDigest, hookId), must be consumed from the immutable digest-
127
+ * addressed authority minted by approvePostPublishHook (F-02), and is
128
+ * validated fail-closed before any write.
129
+ *
130
+ * @returns {Promise<{ planPath: string, runPath: string, status: string, checkpoints: Object[] }>}
131
+ *
132
+ * @throws {ReleaseError} on any safety-gate failure (before any write while
133
+ * the lineage is untrusted; evidenced afterwards).
134
+ */
135
+ export async function postVerifyRelease(options) {
136
+ const {
137
+ planPath,
138
+ approvalPath,
139
+ sourceRunPath,
140
+ root = process.cwd(),
141
+ runDir: runDirOpt,
142
+ dryRun = false,
143
+ clock: clockOpt,
144
+ execFn,
145
+ runHookFn,
146
+ postpublishApprovalPaths,
147
+ } = options ?? {};
148
+
149
+ const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
150
+ const exec = typeof execFn === 'function' ? execFn : defaultExec;
151
+ const hookRunner = typeof runHookFn === 'function' ? runHookFn : runHook;
152
+
153
+ // =========================================================================
154
+ // Gate 1: load and validate the frozen plan (before any write — a rejected
155
+ // plan must never allocate a run authority).
156
+ // =========================================================================
157
+ let planRaw;
158
+ try {
159
+ planRaw = await readFile(planPath, 'utf8');
160
+ } catch (err) {
161
+ throw new ReleaseError(GATE_FAILED, `cannot read release plan: ${err.message}`, { planPath, cause: err.code });
162
+ }
163
+ let plan;
164
+ try {
165
+ plan = JSON.parse(planRaw);
166
+ } catch (err) {
167
+ throw new ReleaseError(GATE_FAILED, `release plan is not valid JSON: ${err.message}`, { planPath });
168
+ }
169
+ validatePlan(plan);
170
+ assertImmutablePlanAuthority(planPath, plan);
171
+
172
+ const actualDigest = computePlanDigest(plan);
173
+ if (plan.digest && plan.digest !== actualDigest) {
174
+ throw new ReleaseError(
175
+ GATE_FAILED,
176
+ `plan digest mismatch: expected ${String(plan.digest).slice(0, 16)}..., computed ${actualDigest.slice(0, 16)}...`,
177
+ { expected: plan.digest, actual: actualDigest },
178
+ );
179
+ }
180
+ if (!plan.postPublish) {
181
+ throw new ReleaseError(
182
+ GATE_FAILED,
183
+ 'release plan has no postPublish declaration; nothing to postVerify',
184
+ { planPath },
185
+ );
186
+ }
187
+
188
+ // =========================================================================
189
+ // Gate 2: approval record validation (binding checks; the post-publish
190
+ // phase waives only the expiry window, exactly like distribute).
191
+ // =========================================================================
192
+ let approvalRaw;
193
+ try {
194
+ approvalRaw = await readFile(approvalPath, 'utf8');
195
+ } catch (err) {
196
+ throw new ReleaseError(GATE_FAILED, `cannot read approval record: ${err.message}`, { approvalPath, cause: err.code });
197
+ }
198
+ let approval;
199
+ try {
200
+ approval = JSON.parse(approvalRaw);
201
+ } catch (err) {
202
+ throw new ReleaseError(GATE_FAILED, `approval record is not valid JSON: ${err.message}`, { approvalPath });
203
+ }
204
+ const approvalDigestValue = assertImmutableApprovalAuthority(approvalPath, plan, approvalRaw)
205
+ ?? computeApprovalDigest(approvalRaw);
206
+ validateApprovalRecordSchema(approval);
207
+ validateApproval(plan, approval, { clock: clockFn, requireUnexpired: false });
208
+
209
+ // =========================================================================
210
+ // Gate 3: source run lineage — ONLY a VERIFIED verify run bound to this
211
+ // exact plan may drive postVerify. Fails closed before any write.
212
+ // =========================================================================
213
+ const verifyRun = await loadRun(sourceRunPath, { requireDigest: true });
214
+ if (verifyRun.command !== 'verify') {
215
+ throw new ReleaseError(
216
+ GATE_FAILED,
217
+ `postVerify source run must be a verify run, got command "${verifyRun.command}"`,
218
+ { sourceRunPath, command: verifyRun.command },
219
+ );
220
+ }
221
+ if (verifyRun.status !== 'VERIFIED') {
222
+ throw new ReleaseError(
223
+ GATE_FAILED,
224
+ `postVerify source run must be VERIFIED, got "${verifyRun.status}"; the verify conclusion never rolls back, so only a VERIFIED run may drive postVerify`,
225
+ { sourceRunPath, status: verifyRun.status },
226
+ );
227
+ }
228
+ validateRunPlanDigest(verifyRun, plan, { planPath });
229
+
230
+ // The publish run referenced by the verify run's own lineage supplies
231
+ // publishedAt (context §2.3): verify runs never fabricate it.
232
+ if (!verifyRun.sourceRunPath || !verifyRun.sourceRunId || !verifyRun.sourceRunDigest) {
233
+ throw new ReleaseError(
234
+ GATE_FAILED,
235
+ 'postVerify source verify run is missing complete source run lineage; publishedAt cannot be trusted',
236
+ { sourceRunPath },
237
+ );
238
+ }
239
+ const publishRun = await loadRun(verifyRun.sourceRunPath, { requireDigest: true });
240
+ if (publishRun.runId !== verifyRun.sourceRunId || publishRun.runDigest !== verifyRun.sourceRunDigest) {
241
+ throw new ReleaseError(
242
+ GATE_FAILED,
243
+ 'postVerify source verify run lineage id/digest does not match the referenced publish run bytes',
244
+ { sourceRunPath, publishRunPath: verifyRun.sourceRunPath },
245
+ );
246
+ }
247
+
248
+ // =========================================================================
249
+ // Declaration re-validation + postVerify-phase hook selection.
250
+ // =========================================================================
251
+ const postPublish = plan.postPublish;
252
+ validatePostPublishDeclaration(postPublish, { unitId: postPublish.unitId });
253
+ const declaredHooks = postPublish.hooks ?? [];
254
+ const postVerifyHooks = declaredHooks.filter((hook) => (hook.phase ?? 'distribute') === 'postVerify');
255
+
256
+ // =========================================================================
257
+ // Gate 4: checkpoint approvals for requiresApproval hooks. Every provided
258
+ // record is validated fail-closed BEFORE any write: first the immutable
259
+ // authority binding (F-02: the consumption path must BE the digest-
260
+ // addressed authority minted by approvePostPublishHook — recomputed
261
+ // planDigest directory, recomputed approvalDigest file name, strict
262
+ // no-follow regular-file read, no symlinked ancestor), then the content
263
+ // checks (schema, planDigest binding, declared hook, requiresApproval,
264
+ // 24h window, expiry). A bad approval aborts the whole run; a missing one
265
+ // parks the hook at AWAITING_APPROVAL.
266
+ // =========================================================================
267
+ const approvedHookIds = new Set();
268
+ const hookApprovalPaths = postpublishApprovalPaths ?? [];
269
+ for (const hookApprovalPath of hookApprovalPaths) {
270
+ let hookApprovalRaw;
271
+ try {
272
+ hookApprovalRaw = await readFile(hookApprovalPath, 'utf8');
273
+ } catch (err) {
274
+ throw new ReleaseError(
275
+ GATE_FAILED,
276
+ `cannot read postpublish hook approval: ${err.message}`,
277
+ { hookApprovalPath, cause: err.code },
278
+ );
279
+ }
280
+ let hookApproval;
281
+ try {
282
+ hookApproval = JSON.parse(hookApprovalRaw);
283
+ } catch (err) {
284
+ throw new ReleaseError(
285
+ GATE_FAILED,
286
+ `postpublish hook approval is not valid JSON: ${err.message}`,
287
+ { hookApprovalPath },
288
+ );
289
+ }
290
+ // F-02: identical bytes anywhere else are not an approval. The authority
291
+ // assertion runs before content validation and before the hook may enter
292
+ // approvedHookIds.
293
+ await assertPostPublishApprovalAuthority(planPath, hookApprovalPath, plan, hookApprovalRaw);
294
+ validatePostPublishApproval(plan, hookApproval, { clock: clockFn });
295
+ if (approvedHookIds.has(hookApproval.hookId)) {
296
+ throw new ReleaseError(
297
+ GATE_FAILED,
298
+ `duplicate postpublish hook approvals for hook "${hookApproval.hookId}"`,
299
+ { hookId: hookApproval.hookId },
300
+ );
301
+ }
302
+ approvedHookIds.add(hookApproval.hookId);
303
+ }
304
+
305
+ // =========================================================================
306
+ // Gate 5: tag identity — the live tag must still point at the frozen
307
+ // tagCommit (read-only observation; still before any write).
308
+ // =========================================================================
309
+ if (postPublish.payloadSource !== PAYLOAD_SOURCE_TAG_WORKTREE) {
310
+ throw new ReleaseError(
311
+ GATE_FAILED,
312
+ `postPublish.payloadSource must be "${PAYLOAD_SOURCE_TAG_WORKTREE}"`,
313
+ { payloadSource: postPublish.payloadSource },
314
+ );
315
+ }
316
+ if (typeof postPublish.tagCommit !== 'string' || !SHA_RE.test(postPublish.tagCommit)) {
317
+ throw new ReleaseError(
318
+ GATE_FAILED,
319
+ 'postPublish is missing a frozen tagCommit binding; postVerify fails closed',
320
+ { tagCommit: postPublish.tagCommit ?? null },
321
+ );
322
+ }
323
+ let observedTagCommit;
324
+ try {
325
+ const { stdout } = await exec('git', ['-C', root, 'rev-parse', '--verify', `${postPublish.tag}^{commit}`]);
326
+ observedTagCommit = `${stdout}`.trim();
327
+ } catch (err) {
328
+ throw new ReleaseError(
329
+ GATE_FAILED,
330
+ `tag "${postPublish.tag}" does not resolve to a commit in the source repository; postVerify fails closed`,
331
+ { tag: postPublish.tag, stderrTail: boundedOutputTail(err?.stderr ?? err?.message) },
332
+ );
333
+ }
334
+ if (observedTagCommit !== postPublish.tagCommit) {
335
+ throw new ReleaseError(
336
+ GATE_FAILED,
337
+ `tag "${postPublish.tag}" points at ${observedTagCommit}, but the frozen plan binds tagCommit ${postPublish.tagCommit}`,
338
+ { tag: postPublish.tag, frozenTagCommit: postPublish.tagCommit, observedTagCommit },
339
+ );
340
+ }
341
+
342
+ // =========================================================================
343
+ // All gates passed: allocate the independent run authority. From here on,
344
+ // every failure is evidenced; the source verify run is never touched.
345
+ // =========================================================================
346
+ const runId = `postverify-${Date.now()}`;
347
+ let runDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'postverify', runId);
348
+ if (plan.production?.mode === 'github-npm-v1') {
349
+ runDir = await createProductionRunDir(runDir, planPath);
350
+ } else {
351
+ await mkdir(runDir, { recursive: true });
352
+ }
353
+ const runPath = join(runDir, 'release-run.json');
354
+ const evidence = createEvidenceWriter({ runDir, command: 'postverify', clock: clockFn });
355
+
356
+ let finalRecordWritten = false;
357
+ const startedAt = clockFn();
358
+ let stateSequence = -1;
359
+
360
+ const checkpoints = postVerifyHooks.map((hook) => ({
361
+ actionId: hook.id,
362
+ actionType: 'postpublish-hook',
363
+ status: 'PENDING',
364
+ executor: EXECUTOR,
365
+ }));
366
+ const checkpointById = new Map(checkpoints.map((cp) => [cp.actionId, cp]));
367
+
368
+ const buildPersistedState = (status, finishedAt) => ({
369
+ runId,
370
+ command: 'postverify',
371
+ status,
372
+ planDigest: plan.digest ?? actualDigest,
373
+ planPath,
374
+ ...(approvalDigestValue ? { approvalDigest: approvalDigestValue } : {}),
375
+ ...(approvalPath ? { approvalPath } : {}),
376
+ sourceRunId: verifyRun.runId,
377
+ sourceRunDigest: verifyRun.runDigest,
378
+ sourceRunPath,
379
+ startedAt,
380
+ ...(finishedAt ? { finishedAt } : {}),
381
+ checkpoints: checkpoints.map((cp) => ({
382
+ actionId: cp.actionId,
383
+ actionType: cp.actionType,
384
+ status: cp.status === 'SUCCEEDED' ? 'succeeded'
385
+ : cp.status === 'FAILED' ? 'failed'
386
+ : cp.status === 'SKIPPED' ? 'skipped'
387
+ : cp.status === 'UNCERTAIN' ? 'uncertain'
388
+ : cp.status === 'PENDING' ? 'pending'
389
+ : cp.status, // NO_CHANGE / AWAITING_APPROVAL pass through as-is
390
+ ...(cp.mode ? { mode: cp.mode } : {}),
391
+ ...(cp.pushedCommit ? { pushedCommit: cp.pushedCommit } : {}),
392
+ ...(cp.executor ? { executor: cp.executor } : {}),
393
+ ...(cp.startedAt ? { startedAt: cp.startedAt } : {}),
394
+ ...(cp.finishedAt ? { finishedAt: cp.finishedAt } : {}),
395
+ ...(cp.reason ? { reason: cp.reason } : {}),
396
+ ...(cp.error ? { error: { code: cp.error.code, ...(cp.error.message ? { message: cp.error.message } : {}) } } : {}),
397
+ })),
398
+ });
399
+
400
+ const snapshot = async (status) => {
401
+ stateSequence += 1;
402
+ return appendRunState(runDir, stateSequence, buildPersistedState(status));
403
+ };
404
+
405
+ /** Persist the BLOCKED run record (idempotent) once the run exists. */
406
+ const recordBlocked = async () => {
407
+ if (finalRecordWritten) return;
408
+ try {
409
+ await writeRunAtomic(runPath, buildPersistedState(BLOCKED, clockFn()));
410
+ finalRecordWritten = true;
411
+ } catch {
412
+ // Persistence must never mask the primary failure.
413
+ }
414
+ };
415
+
416
+ // Worktree cleanup: registered exactly once, idempotent, failure-tolerant.
417
+ let worktreePath = null;
418
+ let tmpBase = null;
419
+ const cleanupWorktree = async () => {
420
+ if (worktreePath) {
421
+ await exec('git', ['-C', root, 'worktree', 'remove', '--force', worktreePath]).catch(() => {});
422
+ worktreePath = null;
423
+ }
424
+ if (tmpBase) {
425
+ await rm(tmpBase, { recursive: true, force: true }).catch(() => {});
426
+ tmpBase = null;
427
+ }
428
+ };
429
+
430
+ try {
431
+ await evidence.append({
432
+ phase: 'postverify',
433
+ status: 'started',
434
+ sourceVerifyRunId: verifyRun.runId,
435
+ sourcePublishRunId: publishRun.runId,
436
+ hookCount: postVerifyHooks.length,
437
+ dryRun: dryRun === true,
438
+ });
439
+
440
+ // Durable pre-execute authority (seq 0).
441
+ await snapshot(DISTRIBUTING);
442
+
443
+ // =======================================================================
444
+ // F-04 root split: postVerify holds TWO distinct roots —
445
+ // - releaseWorkspaceRoot: the real project root the user releases from;
446
+ // only used to resolve preset target.workspace, compare the release-
447
+ // workspace write exclusion, and audit;
448
+ // - executionWorktreeRoot (worktreePath): the detached worktree at the
449
+ // frozen tagCommit; only used as the hook runner context.root for
450
+ // custom command hooks.
451
+ // The two roots never fall back onto each other through defaults.
452
+ // =======================================================================
453
+ let releaseWorkspaceRoot;
454
+ try {
455
+ releaseWorkspaceRoot = await realpath(root);
456
+ } catch (err) {
457
+ await recordBlocked();
458
+ throw new ReleaseError(
459
+ GATE_FAILED,
460
+ `release workspace root does not resolve to an existing directory: ${err.message}`,
461
+ { root, cause: err.code },
462
+ );
463
+ }
464
+
465
+ // R1 timing contract carries over: hooks run inside a detached worktree
466
+ // at the frozen tagCommit, never in the live workspace. Dry-run executes
467
+ // nothing, so no worktree is allocated for a rehearsal.
468
+ if (dryRun !== true) {
469
+ try {
470
+ tmpBase = await mkdtemp(join(tmpdir(), 'release-skill-postverify-'));
471
+ worktreePath = join(tmpBase, 'worktree');
472
+ await exec('git', ['-C', root, 'worktree', 'add', '--detach', worktreePath, postPublish.tagCommit]);
473
+ } catch (err) {
474
+ await evidence.append({
475
+ phase: 'worktree',
476
+ status: 'failed',
477
+ error: boundedOutputTail(err?.stderr ?? err?.message),
478
+ });
479
+ await recordBlocked();
480
+ throw new ReleaseError(
481
+ GATE_FAILED,
482
+ `cannot create the detached tag worktree at ${postPublish.tagCommit}: ${err?.message ?? err}`,
483
+ { tagCommit: postPublish.tagCommit },
484
+ );
485
+ }
486
+ await evidence.append({ phase: 'worktree', status: 'passed' });
487
+
488
+ // =====================================================================
489
+ // Private execution bundle (F-01 / T1): postVerify re-entry consumes
490
+ // the SAME frozen bundle bytes — never the live workspace copies.
491
+ // Strictly re-read the digest-addressed bytes, recompute the closure
492
+ // through Foundation, and install ONLY the verified bytes into the
493
+ // fresh tag worktree before any hook runs; any mismatch fails closed
494
+ // before a hook or an external write.
495
+ // =====================================================================
496
+ let installedBundlePaths = [];
497
+ try {
498
+ ({ installed: installedBundlePaths } = await verifyAndInstallExecutionBundle({
499
+ plan,
500
+ planPath,
501
+ worktreePath,
502
+ }));
503
+ } catch (err) {
504
+ await evidence.append({
505
+ phase: 'worktree',
506
+ gate: 'execution-bundle',
507
+ status: 'failed',
508
+ error: boundedOutputTail(err?.message ?? String(err)),
509
+ });
510
+ await recordBlocked();
511
+ throw err instanceof ReleaseError ? err : new ReleaseError(
512
+ GATE_FAILED,
513
+ `cannot verify the frozen execution bundle: ${err?.message ?? err}`,
514
+ );
515
+ }
516
+ await evidence.append({
517
+ phase: 'worktree',
518
+ gate: 'execution-bundle',
519
+ status: 'passed',
520
+ installed: installedBundlePaths,
521
+ });
522
+ }
523
+
524
+ // §2.3 context projection: verifyEvidence PRESENT (postVerify phase);
525
+ // publishedAt from the sealed publish run; payloadDir never travels.
526
+ const verifyEvidence = {
527
+ runId: verifyRun.runId,
528
+ status: verifyRun.status,
529
+ finishedAt: verifyRun.finishedAt,
530
+ };
531
+ const hookContextProjection = buildPostPublishContext({
532
+ plan,
533
+ runId,
534
+ sourceRun: publishRun,
535
+ payloadDir: undefined,
536
+ phase: 'postVerify',
537
+ verifyEvidence,
538
+ });
539
+
540
+ // Proposal documents must stay byte-deterministic across redeliveries of
541
+ // the SAME release event (NO_CHANGE idempotence): they travel with the
542
+ // stable lineage-derived event identity, not the per-attempt runId.
543
+ const proposalContextProjection = {
544
+ ...hookContextProjection,
545
+ runId: `postverify-${verifyRun.runId}`,
546
+ };
547
+
548
+ // =========================================================================
549
+ // postVerify hooks, declared order. A failure stops the chain; a
550
+ // requiresApproval hook without a checkpoint approval parks at
551
+ // AWAITING_APPROVAL and never executes; dry-run executes nothing.
552
+ // =========================================================================
553
+ let hooksStopped = false;
554
+ let failures = 0;
555
+ let awaitingApproval = 0;
556
+ let externalSuccesses = 0;
557
+
558
+ for (const hook of postVerifyHooks) {
559
+ const cp = checkpointById.get(hook.id);
560
+ cp.startedAt = clockFn();
561
+
562
+ if (hooksStopped) {
563
+ cp.status = 'SKIPPED';
564
+ cp.reason = 'EARLIER_HOOK_FAILED';
565
+ cp.finishedAt = clockFn();
566
+ await evidence.append({
567
+ phase: 'postpublish-hook',
568
+ hookId: hook.id,
569
+ status: 'skipped',
570
+ reason: 'EARLIER_HOOK_FAILED',
571
+ });
572
+ continue;
573
+ }
574
+
575
+ if (dryRun === true) {
576
+ cp.status = 'SKIPPED';
577
+ cp.reason = 'DRY_RUN';
578
+ cp.finishedAt = clockFn();
579
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'skipped', reason: 'DRY_RUN' });
580
+ continue;
581
+ }
582
+
583
+ if (effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id)) {
584
+ // No checkpoint approval: the hook must not execute. It parks (does
585
+ // not stop the chain — the approved rerun re-executes it).
586
+ cp.status = 'AWAITING_APPROVAL';
587
+ cp.finishedAt = clockFn();
588
+ awaitingApproval += 1;
589
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'awaiting-approval' });
590
+ continue;
591
+ }
592
+
593
+ // -----------------------------------------------------------------
594
+ // Preset hooks dispatch through the R4 preset executor (one seam for
595
+ // every registered preset; fail-closed wording for presets registered
596
+ // but not yet shipped).
597
+ // -----------------------------------------------------------------
598
+ if (hook.preset !== undefined) {
599
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
600
+ let delivery;
601
+ try {
602
+ delivery = await executePresetHook({
603
+ hook,
604
+ contextProjection: hookContextProjection,
605
+ proposalContextProjection,
606
+ commitIdentity: postPublish.commitIdentity,
607
+ // F-04: presets receive the RELEASE workspace root (target.workspace
608
+ // resolution + release-workspace write exclusion). The detached
609
+ // worktree is the execution worktree and never impersonates it.
610
+ releaseWorkspaceRoot,
611
+ evidencePath: join(runDir, 'evidence.jsonl'),
612
+ exec,
613
+ hookRunner,
614
+ });
615
+ } catch (err) {
616
+ const code = mapToSchemaCode(err?.code);
617
+ cp.status = 'FAILED';
618
+ cp.error = { code, message: err?.message ?? String(err) };
619
+ cp.finishedAt = clockFn();
620
+ failures += 1;
621
+ hooksStopped = true;
622
+ await evidence.append({
623
+ phase: 'postpublish-hook',
624
+ hookId: hook.id,
625
+ status: 'failed',
626
+ error: err?.message ?? String(err),
627
+ details: { code },
628
+ });
629
+ await snapshot(PARTIAL);
630
+ continue;
631
+ }
632
+
633
+ if (delivery.status === 'NO_CHANGE') {
634
+ cp.status = 'NO_CHANGE';
635
+ cp.mode = 'no-change';
636
+ cp.finishedAt = clockFn();
637
+ externalSuccesses += 1;
638
+ await evidence.append({
639
+ phase: 'postpublish-hook',
640
+ hookId: hook.id,
641
+ status: 'no-change',
642
+ ...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
643
+ // §2.6 execution realpath evidence (R4 review m-2).
644
+ ...(delivery.observation?.workspaceRealpath
645
+ ? { workspaceRealpath: delivery.observation.workspaceRealpath }
646
+ : {}),
647
+ ...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
648
+ });
649
+ await snapshot(PARTIAL);
650
+ continue;
651
+ }
652
+
653
+ cp.status = 'SUCCEEDED';
654
+ if (delivery.observation?.mode === 'pushed' && delivery.observation?.pushedCommit) {
655
+ cp.mode = 'pushed';
656
+ cp.pushedCommit = delivery.observation.pushedCommit;
657
+ }
658
+ cp.finishedAt = clockFn();
659
+ externalSuccesses += 1;
660
+ await evidence.append({
661
+ phase: 'postpublish-hook',
662
+ hookId: hook.id,
663
+ status: 'succeeded',
664
+ preset: hook.preset,
665
+ mode: delivery.mode ?? delivery.observation?.mode,
666
+ ...(delivery.observation?.pushedCommit ? { pushedCommit: delivery.observation.pushedCommit } : {}),
667
+ ...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
668
+ ...(delivery.checklist ? { checklist: delivery.checklist } : {}),
669
+ ...(delivery.degradedToNotifyHandoff === true ? { degradedToNotifyHandoff: true } : {}),
670
+ ...(Array.isArray(delivery.observations) ? { targets: delivery.observations } : {}),
671
+ // §2.6 execution realpath evidence (R4 review m-2) + explicit
672
+ // cross-check skip note (R4 review m-4).
673
+ ...(delivery.observation?.workspaceRealpath
674
+ ? { workspaceRealpath: delivery.observation.workspaceRealpath }
675
+ : {}),
676
+ ...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
677
+ ...(delivery.observation?.crossCheck ? { crossCheck: delivery.observation.crossCheck } : {}),
678
+ });
679
+ await snapshot(PARTIAL);
680
+ continue;
681
+ }
682
+
683
+ // -----------------------------------------------------------------
684
+ // Custom command hooks: executed inside the frozen tag worktree with
685
+ // the §2.3 context injected via the contract env var.
686
+ // -----------------------------------------------------------------
687
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
688
+ let hookExecution;
689
+ try {
690
+ hookExecution = await hookRunner(
691
+ {
692
+ command: hook.command,
693
+ ...(hook.cwd ? { cwd: hook.cwd } : {}),
694
+ ...(hook.timeoutMs !== undefined ? { timeoutMs: hook.timeoutMs } : {}),
695
+ ...(hook.envAllowlist ? { envAllowlist: hook.envAllowlist } : {}),
696
+ },
697
+ {
698
+ // F-04: custom command hooks keep running in the execution
699
+ // worktree; the runner's cwd containment binds them there.
700
+ root: worktreePath,
701
+ env: process.env,
702
+ injectEnv: { [POSTPUBLISH_CONTEXT_ENV]: JSON.stringify(hookContextProjection) },
703
+ },
704
+ );
705
+ } catch (err) {
706
+ // HOOK_TIMEOUT (or a runner defect): FAILED checkpoint, stop the chain.
707
+ const code = err?.code === 'HOOK_TIMEOUT' ? 'HOOK_TIMEOUT' : POST_PUBLISH_VERIFY_FAILED;
708
+ cp.status = 'FAILED';
709
+ cp.error = { code, message: err?.message ?? String(err) };
710
+ cp.finishedAt = clockFn();
711
+ failures += 1;
712
+ hooksStopped = true;
713
+ await evidence.append({
714
+ phase: 'postpublish-hook',
715
+ hookId: hook.id,
716
+ status: 'failed',
717
+ error: err?.message ?? String(err),
718
+ });
719
+ await snapshot(PARTIAL);
720
+ continue;
721
+ }
722
+
723
+ if (hookExecution.exitCode !== 0) {
724
+ cp.status = 'FAILED';
725
+ cp.error = {
726
+ code: POST_PUBLISH_VERIFY_FAILED,
727
+ message: `postVerify hook "${hook.id}" exited with code ${hookExecution.exitCode}`,
728
+ };
729
+ cp.finishedAt = clockFn();
730
+ failures += 1;
731
+ hooksStopped = true;
732
+ await evidence.append({
733
+ phase: 'postpublish-hook',
734
+ hookId: hook.id,
735
+ status: 'failed',
736
+ exitCode: hookExecution.exitCode,
737
+ stdoutTail: boundedOutputTail(hookExecution.stdout),
738
+ stderrTail: boundedOutputTail(hookExecution.stderr),
739
+ });
740
+ await snapshot(PARTIAL);
741
+ continue;
742
+ }
743
+
744
+ cp.status = 'SUCCEEDED';
745
+ cp.finishedAt = clockFn();
746
+ externalSuccesses += 1;
747
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'succeeded' });
748
+ await snapshot(PARTIAL);
749
+ }
750
+
751
+ // =========================================================================
752
+ // Classification (returned, not thrown) — distribute saga family:
753
+ // - DISTRIBUTED: no failures and no awaiting-approval hooks;
754
+ // - NEEDS_INPUT: only awaiting-approval checkpoints and zero external
755
+ // side effects so far (pure input-needed state, never PARTIAL);
756
+ // - PARTIAL: at least one external success alongside failures or
757
+ // awaiting-approval checkpoints;
758
+ // - BLOCKED: failures with zero external side effects landed.
759
+ // =========================================================================
760
+ let overallStatus;
761
+ if (failures === 0 && awaitingApproval === 0) {
762
+ overallStatus = DISTRIBUTED;
763
+ } else if (failures === 0) {
764
+ overallStatus = externalSuccesses > 0 ? PARTIAL : NEEDS_INPUT;
765
+ } else {
766
+ overallStatus = externalSuccesses > 0 ? PARTIAL : BLOCKED;
767
+ }
768
+
769
+ const finishedAt = clockFn();
770
+ await snapshot(overallStatus);
771
+ await writeRunAtomic(runPath, buildPersistedState(overallStatus, finishedAt));
772
+ finalRecordWritten = true;
773
+
774
+ await evidence.append({
775
+ phase: 'postverify',
776
+ status: 'completed',
777
+ overallStatus,
778
+ checkpointStatuses: checkpoints.map((cp) => cp.status),
779
+ });
780
+ await evidence.finish({
781
+ status: overallStatus,
782
+ planPath,
783
+ runPath,
784
+ finishedAt: clockFn(),
785
+ });
786
+
787
+ return { planPath, runPath, status: overallStatus, checkpoints };
788
+ } catch (err) {
789
+ try {
790
+ await evidence.append({
791
+ phase: 'postverify',
792
+ status: 'failed',
793
+ error: { code: err.code, message: err.message },
794
+ });
795
+ if (!finalRecordWritten) await recordBlocked();
796
+ } catch {
797
+ // Persistence must never mask the primary failure.
798
+ }
799
+ await evidence.finish({
800
+ status: BLOCKED,
801
+ error: { code: err.code, message: err.message },
802
+ failedAt: clockFn(),
803
+ }).catch(() => {});
804
+ throw err;
805
+ } finally {
806
+ await cleanupWorktree();
807
+ }
808
+ }