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
@@ -197,6 +197,10 @@ function buildFilteredEnv(envAllowlist, contextEnv) {
197
197
  * @param {Object} context
198
198
  * @param {string} context.root - Absolute project root.
199
199
  * @param {Record<string, string>} [context.env] - Extra env variables.
200
+ * @param {Record<string, string>} [context.injectEnv] - Always-injected
201
+ * contract variables (merged AFTER allowlist filtering; used by the
202
+ * postPublish hook context contract RELEASE_SKILL_POSTPUBLISH_CONTEXT).
203
+ * Keys must match /^[A-Z_][A-Z0-9_]*$/, values must be strings.
200
204
  *
201
205
  * @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
202
206
  *
@@ -233,6 +237,30 @@ export async function runHook(hook, context) {
233
237
  // --- Build safe environment ---
234
238
  const env = buildFilteredEnv(envAllowlist, context.env);
235
239
 
240
+ // --- Always-injected contract variables (postPublish context, v0.6.3 R1).
241
+ // Merged after allowlist filtering: declarations cannot be mutated to carry
242
+ // the context variable, and the runner must not depend on envAllowlist.
243
+ if (context.injectEnv !== undefined) {
244
+ if (!context.injectEnv || typeof context.injectEnv !== 'object' || Array.isArray(context.injectEnv)) {
245
+ throw new ReleaseError('INVALID_HOOK', 'context.injectEnv must be a plain object');
246
+ }
247
+ for (const [key, value] of Object.entries(context.injectEnv)) {
248
+ if (!ENV_KEY_PATTERN.test(key)) {
249
+ throw new ReleaseError(
250
+ 'INVALID_HOOK',
251
+ `context.injectEnv key "${key}" must match /^[A-Z_][A-Z0-9_]*$/`,
252
+ );
253
+ }
254
+ if (typeof value !== 'string') {
255
+ throw new ReleaseError(
256
+ 'INVALID_HOOK',
257
+ `context.injectEnv value for "${key}" must be a string`,
258
+ );
259
+ }
260
+ env[key] = value;
261
+ }
262
+ }
263
+
236
264
  // --- Set up timeout ---
237
265
  const executable = command[0];
238
266
  const args = command.slice(1);
@@ -0,0 +1,175 @@
1
+ /**
2
+ * marketplace-registry-entry preset: direct-edit downstream registry entry
3
+ * update (v0.6.3 R4, design §2.5).
4
+ *
5
+ * For downstream marketplaces WITHOUT their own governance/render pipeline:
6
+ * locate the registry entry by config.entryKey inside config.registryPath,
7
+ * update the declared fieldsFromPlan from the FROZEN plan values (§2.3
8
+ * context projection), run the declared downstream gates (argument arrays via
9
+ * the R1 hook runner), then push. Hubs with their own governance use
10
+ * proposal-inbox instead.
11
+ *
12
+ * Registry document shape (canonical, validated fail-closed):
13
+ * { "entries": [ { "key": "<entryKey>", ...fields } ] }
14
+ * - a missing registry file, a missing/malformed `entries` array, or a
15
+ * missing entry key is REMOTE_CONFLICT: the downstream state disagrees
16
+ * with the declaration and a human decides (nothing is ever invented);
17
+ * - the updated document is serialized deterministically (2-space indent +
18
+ * trailing newline): byte-identical output -> NO_CHANGE (idempotent);
19
+ * - every other entry and field is preserved untouched.
20
+ *
21
+ * fieldsFromPlan maps entry field -> §2.3 context field; only frozen plan
22
+ * values are ever written (version/tag/commit/tree/manifestDigest/planDigest/
23
+ * publishedAt/unitId). A source value absent from the frozen plan fails
24
+ * closed before any write.
25
+ *
26
+ * @module core/marketplace-registry-entry
27
+ */
28
+
29
+ import { readFile } from 'node:fs/promises';
30
+ import { join } from 'node:path';
31
+
32
+ import { ReleaseError, GATE_FAILED, REMOTE_CONFLICT } from './errors.mjs';
33
+ import { FIELDS_FROM_PLAN_SOURCES } from './presets.mjs';
34
+ import { applyDownstreamGitChange } from './preset-gitwrite.mjs';
35
+
36
+ export { FIELDS_FROM_PLAN_SOURCES };
37
+
38
+ /**
39
+ * Apply the frozen-plan field update to one registry document. Pure and
40
+ * deterministic: returns the updated document, or throws when the document
41
+ * shape or the entry disagrees with the declaration.
42
+ *
43
+ * @param {object} registry - Parsed registry document.
44
+ * @param {object} params - { entryKey, fieldsFromPlan, contextProjection }.
45
+ * @returns {object} The updated registry document (new object).
46
+ * @throws {ReleaseError} REMOTE_CONFLICT when the entry cannot be located;
47
+ * GATE_FAILED when a frozen source value is missing.
48
+ */
49
+ export function updateRegistryEntry(registry, params) {
50
+ const { entryKey, fieldsFromPlan, contextProjection } = params ?? {};
51
+ if (!registry || typeof registry !== 'object' || Array.isArray(registry)) {
52
+ throw new ReleaseError(REMOTE_CONFLICT, 'marketplace registry file is not a JSON object; human decision required', {});
53
+ }
54
+ if (!Array.isArray(registry.entries)) {
55
+ throw new ReleaseError(
56
+ REMOTE_CONFLICT,
57
+ 'marketplace registry file carries no "entries" array; the marketplace-registry-entry preset expects { "entries": [ { "key": ... } ] }',
58
+ {},
59
+ );
60
+ }
61
+ const entryIndex = registry.entries.findIndex(
62
+ (entry) => entry && typeof entry === 'object' && !Array.isArray(entry) && entry.key === entryKey,
63
+ );
64
+ if (entryIndex < 0) {
65
+ throw new ReleaseError(
66
+ REMOTE_CONFLICT,
67
+ `marketplace registry entry "${entryKey}" not found; registering a new entry requires a human decision`,
68
+ { entryKey },
69
+ );
70
+ }
71
+
72
+ const updated = JSON.parse(JSON.stringify(registry)); // deep, order-stable copy
73
+ const entry = updated.entries[entryIndex];
74
+ for (const [entryField, sourceField] of Object.entries(fieldsFromPlan)) {
75
+ const value = contextProjection?.[sourceField];
76
+ if (typeof value !== 'string' || value.length === 0) {
77
+ throw new ReleaseError(
78
+ GATE_FAILED,
79
+ `fieldsFromPlan."${entryField}" maps to context field "${sourceField}" which the frozen plan does not provide`,
80
+ { entryField, sourceField },
81
+ );
82
+ }
83
+ entry[entryField] = value;
84
+ }
85
+ return updated;
86
+ }
87
+
88
+ /** Deterministic registry serialization (byte-stable NO_CHANGE detection). */
89
+ export function serializeRegistry(registry) {
90
+ return `${JSON.stringify(registry, null, 2)}\n`;
91
+ }
92
+
93
+ /**
94
+ * Execute one marketplace-registry-entry preset hook end-to-end: read the
95
+ * downstream registry, apply the frozen-plan update, run the downstream
96
+ * gates, and push (never --force). Shared by distribute and postVerify.
97
+ *
98
+ * @param {object} params
99
+ * @param {object} params.hook - Declared hook entry (config bound).
100
+ * @param {object} params.contextProjection - The §2.3 context projection.
101
+ * @param {object} params.commitIdentity - Frozen commitIdentity.
102
+ * @param {string} params.releaseWorkspaceRoot - Release workspace root (the
103
+ * real project root; workspace addressing resolution + write exclusion).
104
+ * @param {Function} [params.exec] - Injectable git exec (tests).
105
+ * @param {Function} [params.hookRunner] - Injectable gate runner (tests).
106
+ * @returns {Promise<{ status: string, observation: object, registryPath: string }>}
107
+ */
108
+ export async function executeMarketplaceRegistryEntryHook(params) {
109
+ const { hook, contextProjection, commitIdentity, releaseWorkspaceRoot, exec, hookRunner } = params ?? {};
110
+ const config = hook?.config;
111
+ const target = config?.target;
112
+ if (!target || typeof target.branch !== 'string') {
113
+ throw new ReleaseError(GATE_FAILED, 'marketplace-registry-entry requires config.target with a branch');
114
+ }
115
+ const registryPath = config?.registryPath ?? 'registry.json';
116
+ const entryKey = config?.entryKey;
117
+ const fieldsFromPlan = config?.fieldsFromPlan;
118
+ if (typeof entryKey !== 'string' || entryKey.length === 0) {
119
+ throw new ReleaseError(GATE_FAILED, 'marketplace-registry-entry requires config.entryKey');
120
+ }
121
+ if (!fieldsFromPlan || typeof fieldsFromPlan !== 'object' || Object.keys(fieldsFromPlan).length === 0) {
122
+ throw new ReleaseError(GATE_FAILED, 'marketplace-registry-entry requires a non-empty config.fieldsFromPlan');
123
+ }
124
+
125
+ const unitId = contextProjection?.unitId ?? 'unknown';
126
+ const version = contextProjection?.version ?? 'unknown';
127
+
128
+ let currentText = null;
129
+ const mutate = async (worktree) => {
130
+ const absoluteRegistry = join(worktree, registryPath);
131
+ let raw;
132
+ try {
133
+ raw = await readFile(absoluteRegistry, 'utf8');
134
+ } catch {
135
+ throw new ReleaseError(
136
+ REMOTE_CONFLICT,
137
+ `marketplace registry file "${registryPath}" is missing in the downstream repository; creating it requires a human decision`,
138
+ { registryPath },
139
+ );
140
+ }
141
+ currentText = raw;
142
+ let registry;
143
+ try {
144
+ registry = JSON.parse(raw);
145
+ } catch {
146
+ throw new ReleaseError(
147
+ REMOTE_CONFLICT,
148
+ `marketplace registry file "${registryPath}" is not valid JSON; human decision required`,
149
+ { registryPath },
150
+ );
151
+ }
152
+ const updated = updateRegistryEntry(registry, { entryKey, fieldsFromPlan, contextProjection });
153
+ const serialized = serializeRegistry(updated);
154
+ if (serialized === currentText) {
155
+ // Leave the file untouched: the staged tree stays equal to the tip and
156
+ // the shared lifecycle reports NO_CHANGE.
157
+ return;
158
+ }
159
+ const { writeFile } = await import('node:fs/promises');
160
+ await writeFile(absoluteRegistry, serialized);
161
+ };
162
+
163
+ const result = await applyDownstreamGitChange({
164
+ target,
165
+ commitIdentity,
166
+ commitSubject: `release-skill marketplace-registry-entry ${unitId} ${version} (${entryKey})`,
167
+ mutate,
168
+ gates: config?.gates ?? [],
169
+ contextProjection,
170
+ releaseWorkspaceRoot,
171
+ ...(exec !== undefined ? { exec } : {}),
172
+ ...(hookRunner !== undefined ? { hookRunner } : {}),
173
+ });
174
+ return { ...result, registryPath };
175
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * notify-handoff preset: the zero-write floor (v0.6.3 R4, design §2.5).
3
+ *
4
+ * Every downstream scenario degrades to at least this behavior: NO writes of
5
+ * any kind — the §2.3 frozen context is rendered into a DETERMINISTIC
6
+ * downstream sync checklist (version/tag/sha/tree/evidence path/suggested
7
+ * actions), which the command layer writes into the run evidence and echoes.
8
+ * Zero configuration, usable by any project, requiresApproval defaults false.
9
+ *
10
+ * The renderer is pure: identical inputs produce byte-identical checklists
11
+ * (snapshot-locked by test/postpublish-notify-handoff.test.mjs). payloadDir
12
+ * and any other local-only artifact never enter the checklist.
13
+ *
14
+ * @module core/notify-handoff
15
+ */
16
+
17
+ /** Context fields rendered into the checklist, in deterministic order. */
18
+ const CHECKLIST_FACT_FIELDS = ['unitId', 'version', 'tag', 'commit', 'tree', 'manifestDigest', 'planDigest', 'runId', 'publishedAt'];
19
+
20
+ /**
21
+ * Render the deterministic downstream sync checklist for the notify-handoff
22
+ * preset. Pure function — no I/O, no writes.
23
+ *
24
+ * @param {object} contextProjection - The §2.3 context projection.
25
+ * @param {object} [options]
26
+ * @param {string} [options.evidencePath] - This run's evidence path (rendered
27
+ * as the `evidence:` line when provided).
28
+ * @returns {string[]} Checklist lines (deterministic order and wording).
29
+ */
30
+ export function renderNotifyHandoffChecklist(contextProjection, options = {}) {
31
+ const context = contextProjection ?? {};
32
+ const lines = [
33
+ 'notify-handoff downstream sync checklist (zero-write floor; no automated write was performed)',
34
+ ];
35
+ for (const field of CHECKLIST_FACT_FIELDS) {
36
+ const value = context[field];
37
+ if (typeof value === 'string' && value.length > 0) {
38
+ lines.push(`- ${field}: ${value}`);
39
+ }
40
+ }
41
+ const verifyEvidence = context.verifyEvidence;
42
+ if (verifyEvidence && typeof verifyEvidence === 'object') {
43
+ lines.push(`- verifyEvidence: runId=${verifyEvidence.runId ?? ''} status=${verifyEvidence.status ?? ''} finishedAt=${verifyEvidence.finishedAt ?? ''}`);
44
+ }
45
+ if (typeof options.evidencePath === 'string' && options.evidencePath.length > 0) {
46
+ lines.push(`- evidence: ${options.evidencePath}`);
47
+ }
48
+ lines.push('suggested actions:');
49
+ lines.push('- manually sync the frozen release facts above into every downstream consumer (marketplace entries, docs sites, hub registries)');
50
+ lines.push('- downstream updates require human action or the downstream governance workflow; this hook wrote nothing');
51
+ return lines;
52
+ }
53
+
54
+ /**
55
+ * Execute one notify-handoff preset hook: render the checklist. The command
56
+ * layer evidences/echoes it; this module performs zero writes.
57
+ *
58
+ * @param {object} params
59
+ * @param {object} params.contextProjection - The §2.3 context projection.
60
+ * @param {string} [params.evidencePath] - This run's evidence path.
61
+ * @returns {Promise<{ status: 'EXECUTED', mode: 'notify-handoff',
62
+ * checklist: string[], manualSyncPrompt: string, observation: object }>}
63
+ */
64
+ export async function executeNotifyHandoffHook(params) {
65
+ const { contextProjection, evidencePath } = params ?? {};
66
+ const checklist = renderNotifyHandoffChecklist(contextProjection, {
67
+ ...(evidencePath !== undefined ? { evidencePath } : {}),
68
+ });
69
+ return {
70
+ status: 'EXECUTED',
71
+ mode: 'notify-handoff',
72
+ checklist,
73
+ manualSyncPrompt: checklist.join('\n'),
74
+ observation: { mode: 'notify-handoff' },
75
+ };
76
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Checkpoint-level approval for requiresApproval postPublish hooks
3
+ * (v0.6.3 R1, design §2.7 ruling 2).
4
+ *
5
+ * A `requiresApproval: true` hook needs its own approval record binding
6
+ * (planDigest, hookId). The plan-level approval-record schema is NOT
7
+ * extended (its top level is additionalProperties: false); this module owns
8
+ * the separate postpublish-approval-record schema and its validation.
9
+ *
10
+ * Time semantics (24h max window, 5-minute clock-skew tolerance, expiry) are
11
+ * delegated wholesale to core/approval.mjs `validateApprovalTimeWindow` so
12
+ * both approval kinds can never drift apart. Hook config changes change the
13
+ * plan digest, so approvals invalidate naturally with the plan; `runId` is
14
+ * audit-only and never participates in binding.
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
+ *
36
+ * @module core/postpublish-approval
37
+ */
38
+
39
+ import { basename, dirname, relative, resolve, sep } from 'node:path';
40
+
41
+ import Ajv from 'ajv';
42
+ import addFormats from 'ajv-formats';
43
+
44
+ import { readFileStrict } from 'skill-family-harness-node';
45
+
46
+ import { ReleaseError, GATE_FAILED } from './errors.mjs';
47
+ import { computePlanDigest } from './plan.mjs';
48
+ import { computeApprovalDigest, validateApprovalTimeWindow } from './approval.mjs';
49
+ import { resolvePresetRequiresApproval } from './presets.mjs';
50
+ import { readTrustedPackageResource } from './trusted-resource.mjs';
51
+
52
+ const postpublishApprovalSchema = JSON.parse((await readTrustedPackageResource(
53
+ 'schemas/postpublish-approval-record.schema.json',
54
+ )).toString('utf8'));
55
+ const postpublishApprovalAjv = new Ajv({ allErrors: true, strict: false });
56
+ addFormats(postpublishApprovalAjv);
57
+ const validatePostPublishApprovalSchema = postpublishApprovalAjv.compile(postpublishApprovalSchema);
58
+
59
+ /**
60
+ * Schema-validate a postpublish checkpoint approval record.
61
+ *
62
+ * @param {object} approval
63
+ * @throws {ReleaseError} GATE_FAILED when the record violates the schema.
64
+ */
65
+ export function validatePostPublishApprovalRecordSchema(approval) {
66
+ if (validatePostPublishApprovalSchema(approval)) return;
67
+ const errors = validatePostPublishApprovalSchema.errors ?? [];
68
+ throw new ReleaseError(
69
+ GATE_FAILED,
70
+ `postpublish approval record schema validation failed: ${errors.map((error) => `${error.instancePath || '/'}: ${error.message}`).join('; ')}`,
71
+ { validationErrors: errors },
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Validate a checkpoint approval against the frozen plan.
77
+ *
78
+ * Bindings enforced (all fail-closed with GATE_FAILED):
79
+ * - record shape (postpublish-approval-record schema; additionalProperties
80
+ * is false, so plan-level fields like approvedActions are rejected);
81
+ * - planDigest equals the computed plan digest;
82
+ * - hookId names a hook declared in the frozen plan's postPublish.hooks;
83
+ * - that hook actually declares requiresApproval: true;
84
+ * - the shared approval time window (24h max, 5-minute skew, unexpired).
85
+ *
86
+ * @param {object} plan - Frozen plan (schema-valid, digest verified by caller).
87
+ * @param {object} approval - Parsed postpublish approval record.
88
+ * @param {object} [options]
89
+ * @param {() => string} [options.clock] - Clock function returning ISO-8601.
90
+ * @param {boolean} [options.requireUnexpired] - Default true.
91
+ * @returns {object} The approved hook declaration.
92
+ * @throws {ReleaseError} GATE_FAILED on any violation.
93
+ */
94
+ export function validatePostPublishApproval(plan, approval, options = {}) {
95
+ validatePostPublishApprovalRecordSchema(approval);
96
+
97
+ const actualDigest = computePlanDigest(plan);
98
+ if (approval.planDigest !== actualDigest) {
99
+ throw new ReleaseError(
100
+ GATE_FAILED,
101
+ `postpublish approval planDigest mismatch: approval says ${String(approval.planDigest).slice(0, 16)}..., plan is ${actualDigest.slice(0, 16)}...`,
102
+ { approvalPlanDigest: approval.planDigest, planDigest: actualDigest },
103
+ );
104
+ }
105
+
106
+ const hooks = plan.postPublish?.hooks ?? [];
107
+ const hook = hooks.find((entry) => entry.id === approval.hookId);
108
+ if (!hook) {
109
+ throw new ReleaseError(
110
+ GATE_FAILED,
111
+ `postpublish approval names hook "${approval.hookId}" which is not declared in the frozen plan`,
112
+ { hookId: approval.hookId, declaredHookIds: hooks.map((entry) => entry.id) },
113
+ );
114
+ }
115
+ // Effective requiresApproval (§2.6 grading): preset hooks may inherit the
116
+ // preset-declared default (proposal-inbox git-push -> true) without an
117
+ // explicit declaration; command hooks carry their declared value.
118
+ const effectiveRequiresApproval = hook.requiresApproval
119
+ ?? (hook.preset !== undefined ? resolvePresetRequiresApproval(hook.preset, hook.config) : false);
120
+ if (effectiveRequiresApproval !== true) {
121
+ throw new ReleaseError(
122
+ GATE_FAILED,
123
+ `postpublish approval names hook "${approval.hookId}" which does not require approval (requiresApproval is not true)`,
124
+ { hookId: approval.hookId, requiresApproval: effectiveRequiresApproval ?? false },
125
+ );
126
+ }
127
+
128
+ validateApprovalTimeWindow(approval, {
129
+ clock: options.clock,
130
+ requireUnexpired: options.requireUnexpired,
131
+ });
132
+
133
+ return hook;
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
+ }