release-skill 0.6.3 → 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 (89) 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 +25 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +25 -17
  10. package/README.zh-CN.md +16 -14
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/error-codes.json +1 -1
  14. package/adapters/claude/bin/kernel-protocol.json +1 -1
  15. package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
  16. package/adapters/claude/bin/license-texts/MIT.txt +21 -0
  17. package/adapters/claude/bin/registry.json +64 -1
  18. package/adapters/claude/bin/release-skill.bundle.mjs +34860 -31252
  19. package/adapters/claude/bin/rules.json +1 -1
  20. package/adapters/claude/schemas/.render-manifest.json +6 -6
  21. package/adapters/claude/schemas/release-plan.schema.json +136 -4
  22. package/adapters/claude/schemas/release-project.schema.json +13 -4
  23. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  24. package/adapters/codex/bin/error-codes.json +1 -1
  25. package/adapters/codex/bin/kernel-protocol.json +1 -1
  26. package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
  27. package/adapters/codex/bin/license-texts/MIT.txt +21 -0
  28. package/adapters/codex/bin/registry.json +64 -1
  29. package/adapters/codex/bin/release-skill.bundle.mjs +34860 -31252
  30. package/adapters/codex/bin/rules.json +1 -1
  31. package/adapters/codex/schemas/.render-manifest.json +6 -6
  32. package/adapters/codex/schemas/release-plan.schema.json +136 -4
  33. package/adapters/codex/schemas/release-project.schema.json +13 -4
  34. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  35. package/adapters/kimi/bin/error-codes.json +1 -1
  36. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  37. package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
  38. package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
  39. package/adapters/kimi/bin/registry.json +64 -1
  40. package/adapters/kimi/bin/release-skill.bundle.mjs +34860 -31252
  41. package/adapters/kimi/bin/rules.json +1 -1
  42. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  43. package/adapters/kimi/schemas/release-plan.schema.json +136 -4
  44. package/adapters/kimi/schemas/release-project.schema.json +13 -4
  45. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  46. package/adapters/workbuddy/bin/error-codes.json +1 -1
  47. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  48. package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
  49. package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
  50. package/adapters/workbuddy/bin/registry.json +64 -1
  51. package/adapters/workbuddy/bin/release-skill.bundle.mjs +34860 -31252
  52. package/adapters/workbuddy/bin/rules.json +1 -1
  53. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  54. package/adapters/workbuddy/schemas/release-plan.schema.json +136 -4
  55. package/adapters/workbuddy/schemas/release-project.schema.json +13 -4
  56. package/bin/error-codes.json +1 -1
  57. package/bin/kernel-protocol.json +1 -1
  58. package/bin/license-texts/Apache-2.0.txt +201 -0
  59. package/bin/license-texts/MIT.txt +21 -0
  60. package/bin/registry.json +64 -1
  61. package/bin/release-skill-cli.mjs +6 -6
  62. package/bin/release-skill.bundle.mjs +34860 -31252
  63. package/bin/rules.json +1 -1
  64. package/package.json +5 -4
  65. package/platform-manifest.json +2 -2
  66. package/references/02-project-config.md +1 -1
  67. package/references/06-adapter-contract.md +6 -3
  68. package/schemas/.render-manifest.json +6 -6
  69. package/schemas/release-plan.schema.json +136 -4
  70. package/schemas/release-project.schema.json +13 -4
  71. package/src/adapters/distribute-git.mjs +17 -11
  72. package/src/commands/approve.mjs +6 -10
  73. package/src/commands/distribute.mjs +251 -69
  74. package/src/commands/postverify.mjs +81 -7
  75. package/src/commands/prepare.mjs +176 -3
  76. package/src/core/docs-refresh-preset.mjs +8 -4
  77. package/src/core/evidence.mjs +8 -1
  78. package/src/core/git-url-policy.mjs +214 -0
  79. package/src/core/marketplace-registry-entry.mjs +4 -3
  80. package/src/core/postpublish-approval.mjs +132 -1
  81. package/src/core/postpublish-bundle.mjs +342 -0
  82. package/src/core/postpublish-projection.mjs +210 -0
  83. package/src/core/postpublish.mjs +26 -19
  84. package/src/core/preset-executor.mjs +25 -6
  85. package/src/core/preset-gitwrite.mjs +37 -16
  86. package/src/core/presets.mjs +67 -19
  87. package/src/core/proposal-inbox.mjs +36 -20
  88. package/src/core/redact.mjs +16 -3
  89. package/src/producers/build-adapters.mjs +28 -14
@@ -13,15 +13,39 @@
13
13
  * plan digest, so approvals invalidate naturally with the plan; `runId` is
14
14
  * audit-only and never participates in binding.
15
15
  *
16
+ * F-02 (architecture gap remediation T2): consumption is bound to the
17
+ * immutable authority minted by commands/approve.mjs
18
+ * `approvePostPublishHook`:
19
+ *
20
+ * <plan owning .release-skill>/approvals/postpublish/<planDigest>/<approvalDigest>.json
21
+ *
22
+ * `assertPostPublishApprovalAuthority` recomputes planDigest from the
23
+ * current plan and approvalDigest from the consumed raw bytes, requires the
24
+ * consumption path to EQUAL that authority path byte-for-byte (identical
25
+ * content anywhere else is not an approval), and strictly re-reads the file
26
+ * through Foundation `readFileStrict` (existence, containment against every
27
+ * root-escape class, no-follow regular-file identity, digest receipt bound
28
+ * to the recomputed approvalDigest). Symbolic-link semantics are fully
29
+ * delegated to Foundation: anything escaping the root is rejected, a leaf
30
+ * symbolic link is never an authority, and a root-internal ancestor alias
31
+ * is not an independent security failure (2026-08-21 handoff ruling C). The
32
+ * check is strictly read-only: it never creates a directory. After the
33
+ * authority passes, the existing schema/hook/grading/time-window validation
34
+ * continues unchanged.
35
+ *
16
36
  * @module core/postpublish-approval
17
37
  */
18
38
 
39
+ import { basename, dirname, relative, resolve, sep } from 'node:path';
40
+
19
41
  import Ajv from 'ajv';
20
42
  import addFormats from 'ajv-formats';
21
43
 
44
+ import { readFileStrict } from 'skill-family-harness-node';
45
+
22
46
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
23
47
  import { computePlanDigest } from './plan.mjs';
24
- import { validateApprovalTimeWindow } from './approval.mjs';
48
+ import { computeApprovalDigest, validateApprovalTimeWindow } from './approval.mjs';
25
49
  import { resolvePresetRequiresApproval } from './presets.mjs';
26
50
  import { readTrustedPackageResource } from './trusted-resource.mjs';
27
51
 
@@ -108,3 +132,110 @@ export function validatePostPublishApproval(plan, approval, options = {}) {
108
132
 
109
133
  return hook;
110
134
  }
135
+
136
+ /**
137
+ * Derive the immutable checkpoint-approval authority location for a plan.
138
+ *
139
+ * This is the SINGLE source of truth for the authority layout, shared by the
140
+ * minter (commands/approve.mjs `approvePostPublishHook`) and the consumers
141
+ * (`assertPostPublishApprovalAuthority` below): the record lives at
142
+ *
143
+ * <releaseDir>/approvals/postpublish/<planDigest>/<approvalDigest>.json
144
+ *
145
+ * where `releaseDir` is the directory owning the frozen plan — the
146
+ * `.release-skill` authority root when the plan uses the canonical
147
+ * `plans/<planDigest>.json` naming, otherwise the plan's own directory
148
+ * (non-production aliases), mirroring the minter exactly.
149
+ *
150
+ * @param {string} planPath - Absolute path of the frozen plan.
151
+ * @param {string} planDigest - Plan digest recomputed from the current plan.
152
+ * @param {string} approvalDigest - Digest recomputed from the approval bytes.
153
+ * @returns {{releaseDir: string, authorityPath: string}}
154
+ */
155
+ export function derivePostPublishApprovalAuthorityPath(planPath, planDigest, approvalDigest) {
156
+ const absolutePlanPath = resolve(planPath);
157
+ const planDir = dirname(absolutePlanPath);
158
+ const releaseDir = basename(planDir) === 'plans' && basename(absolutePlanPath) === `${planDigest}.json`
159
+ ? dirname(planDir)
160
+ : planDir;
161
+ return {
162
+ releaseDir,
163
+ authorityPath: resolve(releaseDir, 'approvals', 'postpublish', planDigest, `${approvalDigest}.json`),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Assert that a checkpoint approval is consumed from its immutable authority.
169
+ *
170
+ * Fail-closed checks, in order (all GATE_FAILED):
171
+ * 1. `planDigest` is recomputed from the CURRENT plan;
172
+ * 2. `approvalDigest` is recomputed from the consumed raw approval bytes;
173
+ * 3. the consumption path must EQUAL the expected absolute authority path —
174
+ * the same bytes copied anywhere else are never an approval;
175
+ * 4. Foundation `readFileStrict` re-reads the record through the authority
176
+ * root: containment rejects every escape class (lexical traversal, a
177
+ * final-component symlink pointing out, and any ancestor chain whose
178
+ * canonical target leaves the root), the record must be one ordinary
179
+ * file, never a symbolic link (no-follow, O_NOFOLLOW open, dev/ino
180
+ * identity re-stat), and its bytes must digest to the recomputed
181
+ * approvalDigest (binding the consumed bytes to the authoritative bytes).
182
+ * A root-internal ancestor alias is a path alias, not an escape, and is
183
+ * not an independent security failure (2026-08-21 handoff ruling C).
184
+ *
185
+ * The check is strictly read-only: it never creates a directory or file.
186
+ * Callers continue with `validatePostPublishApproval` (schema, hook,
187
+ * grading, time window) only after this assertion passes.
188
+ *
189
+ * @param {string} planPath - Absolute path of the frozen plan.
190
+ * @param {string} approvalPath - Path the consumer read the approval from.
191
+ * @param {object} plan - Parsed frozen plan (schema-valid).
192
+ * @param {string|Buffer} approvalRaw - Raw approval bytes the consumer read.
193
+ * @returns {Promise<{planDigest: string, approvalDigest: string, authorityPath: string}>}
194
+ * @throws {ReleaseError} GATE_FAILED on any violation.
195
+ */
196
+ export async function assertPostPublishApprovalAuthority(planPath, approvalPath, plan, approvalRaw) {
197
+ if (!plan || typeof plan !== 'object') {
198
+ throw new ReleaseError(GATE_FAILED, 'postpublish approval authority check requires the parsed plan');
199
+ }
200
+ if (typeof approvalPath !== 'string' || approvalPath.length === 0) {
201
+ throw new ReleaseError(GATE_FAILED, 'postpublish approval authority check requires a consumption path');
202
+ }
203
+
204
+ const planDigest = computePlanDigest(plan);
205
+ const approvalDigest = computeApprovalDigest(approvalRaw);
206
+ const { releaseDir, authorityPath } = derivePostPublishApprovalAuthorityPath(
207
+ planPath,
208
+ planDigest,
209
+ approvalDigest,
210
+ );
211
+
212
+ // Exact consumption-path equality: copying the bytes elsewhere never mints
213
+ // an approval. The comparison is lexical on resolve()d paths (no realpath
214
+ // normalization), so aliased spellings fail closed too.
215
+ const consumedPath = resolve(approvalPath);
216
+ if (consumedPath !== authorityPath) {
217
+ throw new ReleaseError(
218
+ GATE_FAILED,
219
+ `postpublish checkpoint approval must be consumed from its immutable authority path; expected ${authorityPath}, got ${consumedPath}`,
220
+ { planDigest, approvalDigest, expectedAuthorityPath: authorityPath, consumedPath },
221
+ );
222
+ }
223
+
224
+ // Strict authority read through Foundation (FG-1 semantics, never local):
225
+ // containment (every root-escape class), no-follow leaf, regular-file
226
+ // identity, digest receipt. The expected digest binds the authoritative
227
+ // bytes to the consumed bytes.
228
+ const relPath = relative(releaseDir, authorityPath).split(sep).join('/');
229
+ try {
230
+ await readFileStrict(releaseDir, relPath, { expectedSha256: approvalDigest });
231
+ } catch (cause) {
232
+ if (cause instanceof ReleaseError) throw cause;
233
+ throw new ReleaseError(
234
+ GATE_FAILED,
235
+ `postpublish checkpoint approval authority read failed: ${cause?.message ?? cause}`,
236
+ { authorityPath, kind: cause?.details?.kind },
237
+ );
238
+ }
239
+
240
+ return Object.freeze({ planDigest, approvalDigest, authorityPath });
241
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Post-publish private execution bundle (F-01 / T1).
3
+ *
4
+ * Release-domain orchestration ONLY. Path classification, containment,
5
+ * digesting, strict reads, exclusive publication, and temporary workspaces
6
+ * all come from `skill-family-harness-node` (0.6.0) by package import; this
7
+ * module never reimplements a hash, a containment check, an atomic writer,
8
+ * or a temporary-workspace algorithm. It supplies exactly the release
9
+ * semantics the Foundation mechanisms do not own:
10
+ *
11
+ * - WHERE the frozen bytes live: digest-addressed under the SAME
12
+ * `.release-skill` that owns the plan authority — mechanically derived,
13
+ * never a caller-supplied arbitrary root:
14
+ *
15
+ * <plan authority .release-skill>/postpublish-bundles/<closure.digest>/<resource.path>
16
+ *
17
+ * - WHAT gets frozen: the closed-world `postPublish.executionFiles` manifest
18
+ * (parent-workspace files that post-publish commands need but the frozen
19
+ * tag tree does not contain; helpers must be declared explicitly). The
20
+ * closure is the verbatim return of `computeResourceClosure()` — sorted,
21
+ * deduplicated, containment-read, digested by Foundation. The frozen plan
22
+ * (`postPublish.executionBundle`) is the ONLY source of truth for the
23
+ * bundle; there is no parallel manifest and no second bundle digest —
24
+ * `publicFiles` ride the plan digest with everything else.
25
+ *
26
+ * - HOW the bytes return at execution time: distribute/postVerify strictly
27
+ * re-read every bundle file (frozen sha256 content guard + publication
28
+ * mode guard), recompute the closure through Foundation in a disposable
29
+ * workspace, compare it with the plan's frozen closure, and only then
30
+ * install the verified bytes into the detached tag worktree — exclusively,
31
+ * so a bundle entry can never shadow a frozen tag file. Any mismatch
32
+ * fails closed BEFORE any hook or external write.
33
+ *
34
+ * Private files execute through the tag's interpreter and never depend on
35
+ * live executable modes; the bundle publishes and re-verifies one fixed
36
+ * regular-file mode.
37
+ *
38
+ * @module core/postpublish-bundle
39
+ */
40
+
41
+ import { mkdir } from 'node:fs/promises';
42
+ import { basename, join, resolve, sep } from 'node:path';
43
+
44
+ import {
45
+ HARNESS_ERROR_KINDS,
46
+ classifyPathInput,
47
+ computeResourceClosure,
48
+ publishFileExclusive,
49
+ readFileStrict,
50
+ withTemporaryWorkspace,
51
+ } from 'skill-family-harness-node';
52
+
53
+ import { ReleaseError, GATE_FAILED } from './errors.mjs';
54
+
55
+ /** Foundation mechanism identity frozen into every executionBundle. */
56
+ export const EXECUTION_BUNDLE_MECHANISM = 'foundation.harness.resource-closure';
57
+
58
+ /** Bundle store directory name under the plan authority `.release-skill`. */
59
+ export const POSTPUBLISH_BUNDLES_DIRNAME = 'postpublish-bundles';
60
+
61
+ /** Authority directory that owns plans, approvals, runs — and bundles. */
62
+ export const RELEASE_SKILL_DIRNAME = '.release-skill';
63
+
64
+ /**
65
+ * The single regular-file mode every bundle byte is published with and must
66
+ * still carry at execution time. A mode deviation is tamper evidence: the
67
+ * frozen closure binds content, and publication mode is this fixed policy
68
+ * constant — no per-file mode manifest exists.
69
+ */
70
+ export const EXECUTION_BUNDLE_FILE_MODE = 0o644;
71
+
72
+ function fail(message, details = {}) {
73
+ throw new ReleaseError(GATE_FAILED, `execution bundle: ${message}`, details);
74
+ }
75
+
76
+ /**
77
+ * Mechanically derive the bundle store root from the `.release-skill`
78
+ * authority directory that owns the plan. The caller never supplies an
79
+ * arbitrary root: the store is a fixed child of the plan's own authority
80
+ * directory.
81
+ *
82
+ * @param {string} releaseSkillDir - Absolute path of a `.release-skill` dir.
83
+ * @returns {string} Absolute bundle store root (may not exist yet).
84
+ */
85
+ export function bundleRootForAuthorityDir(releaseSkillDir) {
86
+ const absolute = resolve(releaseSkillDir);
87
+ if (basename(absolute) !== RELEASE_SKILL_DIRNAME) {
88
+ fail('bundle root must be derived from a .release-skill authority directory', { releaseSkillDir: absolute });
89
+ }
90
+ return join(absolute, POSTPUBLISH_BUNDLES_DIRNAME);
91
+ }
92
+
93
+ /**
94
+ * Mechanically derive the bundle store root from a plan authority path:
95
+ * anchor on the nearest `.release-skill` ancestor of the plan file.
96
+ *
97
+ * @param {string} planPath - Absolute path of the frozen plan.
98
+ * @returns {string} Absolute bundle store root.
99
+ */
100
+ export function deriveBundleRoot(planPath) {
101
+ const absolute = resolve(planPath);
102
+ const segments = absolute.split(sep).filter(Boolean);
103
+ const anchorIndex = segments.lastIndexOf(RELEASE_SKILL_DIRNAME);
104
+ if (anchorIndex < 0) {
105
+ fail('plan path has no .release-skill ancestor; cannot derive the bundle store root', { planPath: absolute });
106
+ }
107
+ const prefix = absolute.startsWith(sep) ? sep : '';
108
+ const releaseSkillDir = prefix + join(...segments.slice(0, anchorIndex + 1));
109
+ return bundleRootForAuthorityDir(releaseSkillDir);
110
+ }
111
+
112
+ /**
113
+ * Freeze the private execution bundle for one postPublish declaration.
114
+ *
115
+ * Order of operations (all before the plan is written):
116
+ * 1. Foundation path classification for every declared entry (no parallel
117
+ * path regex; ambiguous inputs never reach the filesystem);
118
+ * 2. `computeResourceClosure()` — Foundation sorts, deduplicates,
119
+ * containment-reads (every root-escape class rejected, root-internal
120
+ * ancestor aliases are path aliases, not escapes), and digests; the
121
+ * return value is frozen VERBATIM;
122
+ * 3. strict re-read of every entry bound to its frozen sha256 (any
123
+ * concurrent drift fails closed; a symbolic-link entry is never readable
124
+ * — Foundation no-follow), then exclusive publication of the exact bytes
125
+ * under `<bundleRoot>/<closure.digest>/<resource.path>`. Re-preparing
126
+ * identical bytes is idempotent; divergent bytes on an occupied
127
+ * digest-addressed slot fail closed.
128
+ *
129
+ * Symbolic-link policy is fully delegated to Foundation (2026-08-21 handoff
130
+ * ruling C): anything escaping the workspace root is rejected by closure
131
+ * containment, a leaf symbolic link is rejected by `readFileStrict`
132
+ * (no-follow), and ordinary-file identity and digests stay bound.
133
+ *
134
+ * @param {object} params
135
+ * @param {string} params.workspaceRoot - Release workspace root.
136
+ * @param {string} params.releaseSkillDir - `.release-skill` authority dir
137
+ * that will own the frozen plan (mechanical bundle-root anchor).
138
+ * @param {string[]} [params.executionFiles] - Closed-world manifest.
139
+ * @param {object[]} [params.publicFiles] - Release-unit public projection
140
+ * frozen into the plan alongside the closure.
141
+ * @returns {Promise<{foundationMechanism: string, closure: object, publicFiles: object[]}>}
142
+ */
143
+ export async function freezeExecutionBundle({ workspaceRoot, releaseSkillDir, executionFiles = [], publicFiles = [] } = {}) {
144
+ if (!workspaceRoot || typeof workspaceRoot !== 'string') {
145
+ throw new TypeError('freezeExecutionBundle: workspaceRoot must be a directory path string');
146
+ }
147
+ if (!Array.isArray(executionFiles)) {
148
+ throw new TypeError('freezeExecutionBundle: executionFiles must be an array');
149
+ }
150
+ for (const relPath of executionFiles) {
151
+ const classification = classifyPathInput(relPath);
152
+ if (!classification.ok) {
153
+ fail(`executionFiles entry is not a safe workspace-relative path (kind: ${classification.kind})`, {
154
+ path: typeof relPath === 'string' ? relPath : typeof relPath,
155
+ kind: classification.kind,
156
+ });
157
+ }
158
+ }
159
+
160
+ // Foundation closure: sort, dedupe, containment reads, sha256 — verbatim.
161
+ // Thin adapter mapping only: Foundation mechanism errors (lexical
162
+ // traversal, symlink/realpath escapes, missing inputs) surface as
163
+ // release-domain GATE_FAILED without reimplementing any mechanism.
164
+ let closure;
165
+ try {
166
+ closure = await computeResourceClosure({
167
+ root: workspaceRoot,
168
+ resources: executionFiles.map((path) => ({ path, role: 'input' })),
169
+ });
170
+ } catch (cause) {
171
+ if (cause instanceof ReleaseError) throw cause;
172
+ fail(`cannot compute the executionFiles closure: ${cause?.message ?? cause}`, {
173
+ kind: cause?.details?.kind,
174
+ });
175
+ }
176
+
177
+ const bundleRoot = bundleRootForAuthorityDir(releaseSkillDir);
178
+ await mkdir(bundleRoot, { recursive: true });
179
+
180
+ for (const resource of closure.resources) {
181
+ // Strict re-read bound to the frozen digest: any byte drift between the
182
+ // closure computation and the publication fails closed.
183
+ let receipt;
184
+ try {
185
+ receipt = await readFileStrict(workspaceRoot, resource.path, { expectedSha256: resource.sha256 });
186
+ } catch (cause) {
187
+ fail(`cannot strictly read executionFiles entry for publication: ${cause?.message ?? cause}`, {
188
+ path: resource.path,
189
+ kind: cause?.details?.kind,
190
+ });
191
+ }
192
+ const relTarget = join(closure.digest, resource.path);
193
+ try {
194
+ await publishFileExclusive(bundleRoot, relTarget, receipt.content, {
195
+ mode: EXECUTION_BUNDLE_FILE_MODE,
196
+ createParents: true,
197
+ });
198
+ } catch (cause) {
199
+ if (cause?.details?.kind === HARNESS_ERROR_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) {
200
+ // Idempotent same-bytes re-prepare: the existing slot must carry the
201
+ // exact frozen bytes at the publication mode, verified strictly.
202
+ try {
203
+ const existing = await readFileStrict(bundleRoot, relTarget, { expectedSha256: resource.sha256 });
204
+ if (existing.mode !== EXECUTION_BUNDLE_FILE_MODE) {
205
+ fail('existing bundle file carries an unexpected mode', { path: resource.path, mode: existing.mode });
206
+ }
207
+ continue;
208
+ } catch (verifyCause) {
209
+ fail(`bundle slot already exists with divergent bytes: ${verifyCause?.message ?? verifyCause}`, {
210
+ path: resource.path,
211
+ kind: verifyCause?.details?.kind,
212
+ });
213
+ }
214
+ }
215
+ fail(`cannot publish bundle bytes: ${cause?.message ?? cause}`, {
216
+ path: resource.path,
217
+ kind: cause?.details?.kind,
218
+ });
219
+ }
220
+ }
221
+
222
+ return {
223
+ foundationMechanism: EXECUTION_BUNDLE_MECHANISM,
224
+ closure,
225
+ publicFiles: structuredClone(publicFiles),
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Field-by-field closure comparison (the recomputed Foundation closure must
231
+ * equal the frozen plan closure; the digest alone is compared first).
232
+ */
233
+ function sameClosureResources(actual, expected) {
234
+ if (!Array.isArray(actual) || !Array.isArray(expected) || actual.length !== expected.length) return false;
235
+ for (let index = 0; index < expected.length; index += 1) {
236
+ const left = actual[index] ?? {};
237
+ const right = expected[index] ?? {};
238
+ if (left.path !== right.path || left.role !== right.role
239
+ || left.exists !== right.exists || left.sha256 !== right.sha256) {
240
+ return false;
241
+ }
242
+ }
243
+ return true;
244
+ }
245
+
246
+ /**
247
+ * Re-verify the frozen execution bundle and install ONLY the verified bytes
248
+ * into the detached tag worktree. Runs BEFORE any hook or external write.
249
+ *
250
+ * Steps:
251
+ * 1. Strictly read every frozen resource from the digest-addressed store
252
+ * (`readFileStrict` with the frozen sha256 content guard); the file mode
253
+ * must still equal the publication mode — any deviation is tamper;
254
+ * 2. recompute the closure through Foundation in a disposable workspace and
255
+ * compare it (digest + resources) with the plan's frozen closure;
256
+ * 3. exclusively publish the verified bytes into the worktree — an occupied
257
+ * target means the bundle would shadow a frozen tag file and fails closed.
258
+ *
259
+ * @param {object} params
260
+ * @param {object} params.plan - Frozen plan (postPublish.executionBundle).
261
+ * @param {string} params.planPath - Absolute plan authority path (mechanical
262
+ * bundle-root anchor).
263
+ * @param {string} params.worktreePath - Detached tag worktree root.
264
+ * @returns {Promise<{installed: string[]}>} Installed relative paths.
265
+ */
266
+ export async function verifyAndInstallExecutionBundle({ plan, planPath, worktreePath } = {}) {
267
+ const bundle = plan?.postPublish?.executionBundle;
268
+ if (!bundle) return { installed: [] };
269
+ const closure = bundle.closure;
270
+ if (!closure || !Array.isArray(closure.resources) || typeof closure.digest !== 'string') {
271
+ fail('plan carries a malformed executionBundle closure');
272
+ }
273
+
274
+ const bundleRoot = deriveBundleRoot(planPath);
275
+ const bytesByPath = new Map();
276
+ for (const resource of closure.resources) {
277
+ if (!resource || resource.role !== 'input' || resource.exists !== true || typeof resource.sha256 !== 'string') {
278
+ fail('executionBundle closure resources must be existing input resources with frozen digests', {
279
+ path: resource?.path,
280
+ });
281
+ }
282
+ let receipt;
283
+ try {
284
+ receipt = await readFileStrict(bundleRoot, join(closure.digest, resource.path), {
285
+ expectedSha256: resource.sha256,
286
+ });
287
+ } catch (cause) {
288
+ fail(`bundle resource is missing or drifted from the frozen closure: ${cause?.message ?? cause}`, {
289
+ path: resource.path,
290
+ kind: cause?.details?.kind,
291
+ });
292
+ }
293
+ if (receipt.mode !== EXECUTION_BUNDLE_FILE_MODE) {
294
+ fail(`bundle resource mode was tampered (expected ${EXECUTION_BUNDLE_FILE_MODE.toString(8)}, got ${receipt.mode.toString(8)})`, {
295
+ path: resource.path,
296
+ });
297
+ }
298
+ bytesByPath.set(resource.path, receipt.content);
299
+ }
300
+
301
+ // Foundation recomputation in a disposable workspace: any inconsistency
302
+ // fails before a hook or an external write.
303
+ try {
304
+ await withTemporaryWorkspace(async (workspace) => {
305
+ for (const resource of closure.resources) {
306
+ await workspace.writeFile(resource.path, bytesByPath.get(resource.path));
307
+ }
308
+ const recomputed = await computeResourceClosure({
309
+ root: workspace.root,
310
+ resources: closure.resources.map((resource) => ({ path: resource.path, role: resource.role })),
311
+ });
312
+ if (recomputed.digest !== closure.digest || !sameClosureResources(recomputed.resources, closure.resources)) {
313
+ fail('recomputed bundle closure differs from the frozen plan closure');
314
+ }
315
+ }, { prefix: 'rs-execution-bundle-verify-' });
316
+ } catch (cause) {
317
+ if (cause instanceof ReleaseError) throw cause;
318
+ fail(`bundle closure recomputation failed: ${cause?.message ?? cause}`, { kind: cause?.details?.kind });
319
+ }
320
+
321
+ // Install verified bytes into the tag worktree — exclusively: the bundle
322
+ // must never overwrite (shadow) a file that already belongs to the tag.
323
+ const installed = [];
324
+ for (const resource of closure.resources) {
325
+ try {
326
+ await publishFileExclusive(worktreePath, resource.path, bytesByPath.get(resource.path), {
327
+ mode: EXECUTION_BUNDLE_FILE_MODE,
328
+ createParents: true,
329
+ });
330
+ } catch (cause) {
331
+ if (cause?.details?.kind === HARNESS_ERROR_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) {
332
+ fail(`execution bundle would overwrite a frozen tag file: ${resource.path}`, { path: resource.path });
333
+ }
334
+ fail(`cannot install bundle bytes into the tag worktree: ${cause?.message ?? cause}`, {
335
+ path: resource.path,
336
+ kind: cause?.details?.kind,
337
+ });
338
+ }
339
+ installed.push(resource.path);
340
+ }
341
+ return { installed };
342
+ }