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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +48 -0
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +47 -2
- package/INSTALL.zh-CN.md +29 -2
- package/README.md +137 -12
- package/README.zh-CN.md +110 -9
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +1 -1
- package/adapters/claude/bin/kernel-protocol.json +1 -1
- package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/claude/bin/license-texts/MIT.txt +21 -0
- package/adapters/claude/bin/registry.json +64 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +10 -6
- package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/claude/schemas/release-plan.schema.json +200 -4
- package/adapters/claude/schemas/release-project.schema.json +86 -5
- package/adapters/claude/schemas/release-run.schema.json +11 -6
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +1 -1
- package/adapters/codex/bin/kernel-protocol.json +1 -1
- package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/codex/bin/license-texts/MIT.txt +21 -0
- package/adapters/codex/bin/registry.json +64 -1
- package/adapters/codex/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +10 -6
- package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/codex/schemas/release-plan.schema.json +200 -4
- package/adapters/codex/schemas/release-project.schema.json +86 -5
- package/adapters/codex/schemas/release-run.schema.json +11 -6
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +1 -1
- package/adapters/kimi/bin/kernel-protocol.json +1 -1
- package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
- package/adapters/kimi/bin/registry.json +64 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/kimi/schemas/.render-manifest.json +10 -6
- package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/kimi/schemas/release-plan.schema.json +200 -4
- package/adapters/kimi/schemas/release-project.schema.json +86 -5
- package/adapters/kimi/schemas/release-run.schema.json +11 -6
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +1 -1
- package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
- package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
- package/adapters/workbuddy/bin/registry.json +64 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
- package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/workbuddy/schemas/release-plan.schema.json +200 -4
- package/adapters/workbuddy/schemas/release-project.schema.json +86 -5
- package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
- package/bin/error-codes.json +1 -1
- package/bin/kernel-protocol.json +1 -1
- package/bin/license-texts/Apache-2.0.txt +201 -0
- package/bin/license-texts/MIT.txt +21 -0
- package/bin/registry.json +64 -1
- package/bin/release-skill-cli.mjs +187 -9
- package/bin/release-skill.bundle.mjs +50570 -42208
- package/bin/rules.json +1 -1
- package/package.json +6 -4
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +5 -5
- package/references/01-state-machine.md +22 -2
- package/references/02-project-config.md +1 -1
- package/references/06-adapter-contract.md +6 -3
- package/schemas/.render-manifest.json +10 -6
- package/schemas/postpublish-approval-record.schema.json +47 -0
- package/schemas/release-plan.schema.json +200 -4
- package/schemas/release-project.schema.json +86 -5
- package/schemas/release-run.schema.json +11 -6
- package/src/adapters/distribute-git.mjs +17 -11
- package/src/commands/approve.mjs +163 -1
- package/src/commands/distribute.mjs +649 -89
- package/src/commands/postverify.mjs +808 -0
- package/src/commands/prepare.mjs +456 -45
- package/src/commands/setup.mjs +715 -0
- package/src/commands/ship.mjs +152 -5
- package/src/commands/verify.mjs +92 -15
- package/src/core/approval.mjs +93 -68
- package/src/core/bounded-output.mjs +46 -0
- package/src/core/derived-artifact-gates.mjs +258 -0
- package/src/core/docs-refresh-preset.mjs +171 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/evidence.mjs +8 -1
- package/src/core/git-url-policy.mjs +214 -0
- package/src/core/hooks.mjs +28 -0
- package/src/core/marketplace-registry-entry.mjs +175 -0
- package/src/core/notify-handoff.mjs +76 -0
- package/src/core/postpublish-approval.mjs +241 -0
- package/src/core/postpublish-bundle.mjs +342 -0
- package/src/core/postpublish-projection.mjs +210 -0
- package/src/core/postpublish.mjs +449 -25
- package/src/core/preset-executor.mjs +175 -0
- package/src/core/preset-gitwrite.mjs +484 -0
- package/src/core/presets.mjs +754 -0
- package/src/core/proposal-inbox.mjs +646 -0
- package/src/core/redact.mjs +16 -3
- package/src/core/run.mjs +91 -6
- package/src/producers/build-adapters.mjs +28 -14
package/src/core/hooks.mjs
CHANGED
|
@@ -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
|
+
}
|