release-skill 0.6.2 → 0.6.3

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 (73) 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 +23 -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 +126 -9
  11. package/README.zh-CN.md +108 -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/release-skill.bundle.mjs +6408 -1654
  15. package/adapters/claude/schemas/.render-manifest.json +10 -6
  16. package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
  17. package/adapters/claude/schemas/release-plan.schema.json +65 -1
  18. package/adapters/claude/schemas/release-project.schema.json +73 -1
  19. package/adapters/claude/schemas/release-run.schema.json +11 -6
  20. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  21. package/adapters/codex/bin/release-skill.bundle.mjs +6408 -1654
  22. package/adapters/codex/schemas/.render-manifest.json +10 -6
  23. package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
  24. package/adapters/codex/schemas/release-plan.schema.json +65 -1
  25. package/adapters/codex/schemas/release-project.schema.json +73 -1
  26. package/adapters/codex/schemas/release-run.schema.json +11 -6
  27. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  28. package/adapters/kimi/bin/release-skill.bundle.mjs +6408 -1654
  29. package/adapters/kimi/schemas/.render-manifest.json +10 -6
  30. package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
  31. package/adapters/kimi/schemas/release-plan.schema.json +65 -1
  32. package/adapters/kimi/schemas/release-project.schema.json +73 -1
  33. package/adapters/kimi/schemas/release-run.schema.json +11 -6
  34. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  35. package/adapters/workbuddy/bin/release-skill.bundle.mjs +6408 -1654
  36. package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
  37. package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
  38. package/adapters/workbuddy/schemas/release-plan.schema.json +65 -1
  39. package/adapters/workbuddy/schemas/release-project.schema.json +73 -1
  40. package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
  41. package/bin/release-skill-cli.mjs +181 -3
  42. package/bin/release-skill.bundle.mjs +6408 -1654
  43. package/package.json +2 -1
  44. package/platform-manifest.json +4 -4
  45. package/references/.render-manifest.json +5 -5
  46. package/references/01-state-machine.md +22 -2
  47. package/schemas/.render-manifest.json +10 -6
  48. package/schemas/postpublish-approval-record.schema.json +47 -0
  49. package/schemas/release-plan.schema.json +65 -1
  50. package/schemas/release-project.schema.json +73 -1
  51. package/schemas/release-run.schema.json +11 -6
  52. package/src/commands/approve.mjs +167 -1
  53. package/src/commands/distribute.mjs +411 -33
  54. package/src/commands/postverify.mjs +734 -0
  55. package/src/commands/prepare.mjs +280 -42
  56. package/src/commands/setup.mjs +715 -0
  57. package/src/commands/ship.mjs +152 -5
  58. package/src/commands/verify.mjs +92 -15
  59. package/src/core/approval.mjs +93 -68
  60. package/src/core/bounded-output.mjs +46 -0
  61. package/src/core/derived-artifact-gates.mjs +258 -0
  62. package/src/core/docs-refresh-preset.mjs +167 -0
  63. package/src/core/errors.mjs +4 -0
  64. package/src/core/hooks.mjs +28 -0
  65. package/src/core/marketplace-registry-entry.mjs +174 -0
  66. package/src/core/notify-handoff.mjs +76 -0
  67. package/src/core/postpublish-approval.mjs +110 -0
  68. package/src/core/postpublish.mjs +424 -7
  69. package/src/core/preset-executor.mjs +156 -0
  70. package/src/core/preset-gitwrite.mjs +463 -0
  71. package/src/core/presets.mjs +706 -0
  72. package/src/core/proposal-inbox.mjs +630 -0
  73. package/src/core/run.mjs +91 -6
@@ -0,0 +1,630 @@
1
+ /**
2
+ * proposal-inbox preset transports (v0.6.3 R3 git-push / R4 local-file,
3
+ * design §2.5/§2.3).
4
+ *
5
+ * Delivers a machine-readable update proposal — the §2.3 context projection
6
+ * plus a deterministic change summary — to a downstream repository as
7
+ * `incoming/<unitId>-<version>.json`. The downstream consumes it on its own
8
+ * terms (hub governance); delivery success + the deterministic manual-sync
9
+ * prompt in evidence is the closed loop.
10
+ *
11
+ * git-push transport semantics (mirror the distribute-git stance):
12
+ * - clone -> write the proposal -> commit with the frozen bot identity ->
13
+ * plain push (NEVER --force); GIT_TERMINAL_PROMPT=0 everywhere — the host
14
+ * credential helper either answers or the delivery fails fast; credentials
15
+ * are never read, printed, or retried;
16
+ * - identical existing content -> NO_CHANGE (idempotent success, no write);
17
+ * - different existing content -> REMOTE_CONFLICT (human decision required;
18
+ * a downstream proposal is never auto-overwritten);
19
+ * - after a push, `git ls-remote` cross-checks the pushed commit (review N-3);
20
+ * - the proposal document never carries payloadDir or local absolute paths.
21
+ *
22
+ * local-file transport semantics (R4):
23
+ * - writes the SAME proposal document into the local checkout declared by
24
+ * config.target.workspace and commits ONLY that file with the frozen bot
25
+ * identity — but NEVER pushes (the upstream stays untouched; a human or the
26
+ * downstream governance completes the delivery);
27
+ * - the §2.6 workspace execution checks apply (preflight realpath, TOCTOU
28
+ * re-check, release-workspace/runtime-directory exclusion), and the
29
+ * checkout must be on the declared branch before any write;
30
+ * - gates reused from git-push: deterministic snapshot serialization,
31
+ * NO_CHANGE idempotence on identical existing content, REMOTE_CONFLICT on
32
+ * different existing content (never auto-overwritten).
33
+ *
34
+ * @module core/proposal-inbox
35
+ */
36
+
37
+ import { execFile as execFileCb } from 'node:child_process';
38
+ import { promisify } from 'node:util';
39
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
40
+ import { tmpdir } from 'node:os';
41
+ import { dirname, join } from 'node:path';
42
+
43
+ import {
44
+ ReleaseError,
45
+ GATE_FAILED,
46
+ REMOTE_CONFLICT,
47
+ REMOTE_UNAVAILABLE,
48
+ POST_PUBLISH_VERIFY_FAILED,
49
+ } from './errors.mjs';
50
+ import { preflightPresetWorkspace, assertPresetWorkspaceExecution } from './presets.mjs';
51
+
52
+ const execFileAsync = promisify(execFileCb);
53
+
54
+ /** Proposal document identity. */
55
+ export const PROPOSAL_SCHEMA_VERSION = 1;
56
+ export const PROPOSAL_KIND = 'release-skill/update-proposal';
57
+
58
+ const PROBE_TIMEOUT_MS = 30_000;
59
+ const GIT_TIMEOUT_MS = 120_000;
60
+ const TRANSFER_TIMEOUT_MS = 300_000;
61
+ const TMP_PREFIX = 'release-skill-proposal-';
62
+
63
+ const SAFE_REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
64
+ const SAFE_BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
65
+ const SHA_RE = /^[a-f0-9]{40}$/;
66
+
67
+ /** Credential hygiene: git is never allowed to prompt. */
68
+ const NEVER_PROMPT_ENV = { GIT_TERMINAL_PROMPT: '0' };
69
+
70
+ const AUTH_FAILURE_PATTERNS = [
71
+ /authentication failed/i,
72
+ /could not read username/i,
73
+ /could not read password/i,
74
+ /terminal prompts disabled/i,
75
+ /invalid username or password/i,
76
+ /permission denied/i,
77
+ /access denied/i,
78
+ /authorization failed/i,
79
+ /\b403\b/,
80
+ /\b401\b/,
81
+ ];
82
+
83
+ const TRANSPORT_FAILURE_PATTERNS = [
84
+ /could not resolve host/i,
85
+ /unable to access/i,
86
+ /connection refused/i,
87
+ /connection timed out/i,
88
+ /operation timed out/i,
89
+ /network is unreachable/i,
90
+ /ssl certificate problem/i,
91
+ /could not resolve proxy/i,
92
+ /failed to connect/i,
93
+ ];
94
+
95
+ function defaultExec(command, args, options = {}) {
96
+ return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, ...options });
97
+ }
98
+
99
+ function netEnv() {
100
+ return { ...process.env, ...NEVER_PROMPT_ENV };
101
+ }
102
+
103
+ function hasControlChars(value) {
104
+ return /[\x00-\x1f\x7f]/.test(value);
105
+ }
106
+
107
+ function assertSafeRemoteUrl(remoteUrl) {
108
+ if (typeof remoteUrl !== 'string' || !SAFE_REMOTE_URL_RE.test(remoteUrl) || hasControlChars(remoteUrl)) {
109
+ throw new ReleaseError(
110
+ GATE_FAILED,
111
+ 'proposal-inbox remoteUrl must be an http(s)/file URL ending in .git',
112
+ { remoteUrl },
113
+ );
114
+ }
115
+ }
116
+
117
+ function assertSafeBranch(branch) {
118
+ if (typeof branch !== 'string' || !SAFE_BRANCH_RE.test(branch)
119
+ || branch.includes('..') || branch.endsWith('.') || branch.endsWith('.lock')) {
120
+ throw new ReleaseError(GATE_FAILED, 'proposal-inbox branch is not a safe Git branch name', { branch });
121
+ }
122
+ }
123
+
124
+ function assertCommitIdentity(identity) {
125
+ if (!identity || typeof identity !== 'object') {
126
+ throw new ReleaseError(GATE_FAILED, 'proposal-inbox requires a frozen commitIdentity');
127
+ }
128
+ for (const field of ['name', 'email']) {
129
+ const value = identity[field];
130
+ if (typeof value !== 'string' || value.length === 0 || value.startsWith('-') || hasControlChars(value)) {
131
+ throw new ReleaseError(GATE_FAILED, `proposal-inbox commitIdentity.${field} is missing or unsafe`, { field });
132
+ }
133
+ }
134
+ }
135
+
136
+ /** The proposal path is repository-relative and must never escape the clone. */
137
+ function assertSafeProposalPath(proposalPath) {
138
+ if (typeof proposalPath !== 'string' || proposalPath.length === 0
139
+ || proposalPath.startsWith('/') || proposalPath.startsWith('./')
140
+ || proposalPath.includes('..') || proposalPath.includes('\\')
141
+ || proposalPath.includes(':') || hasControlChars(proposalPath)) {
142
+ throw new ReleaseError(GATE_FAILED, 'proposal path is not a safe repository-relative path', { proposalPath });
143
+ }
144
+ }
145
+
146
+ function stderrText(error) {
147
+ return `${error?.stderr ?? ''}\n${error?.message ?? ''}`;
148
+ }
149
+
150
+ function stderrTail(error, limit = 400) {
151
+ const text = stderrText(error).trim();
152
+ return text.length > limit ? `…${text.slice(-limit)}` : text;
153
+ }
154
+
155
+ /** Classify a failed network git call without ever touching credentials. */
156
+ function classifyNetFailure(text) {
157
+ const output = typeof text === 'string' ? text : '';
158
+ if (AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(output))) return 'auth';
159
+ if (TRANSPORT_FAILURE_PATTERNS.some((pattern) => pattern.test(output))) return 'transport';
160
+ return 'transport';
161
+ }
162
+
163
+ /** Parse `git ls-remote` stdout lines ("<sha>\t<ref>") into sha per ref. */
164
+ function parseLsRemote(stdout) {
165
+ const refs = new Map();
166
+ for (const line of `${stdout ?? ''}`.trim().split('\n').filter(Boolean)) {
167
+ const tabIndex = line.indexOf('\t');
168
+ if (tabIndex < 0) continue;
169
+ const sha = line.slice(0, tabIndex);
170
+ const ref = line.slice(tabIndex + 1);
171
+ if (!SHA_RE.test(sha)) continue;
172
+ if (ref.endsWith('^{}')) {
173
+ refs.set(ref.slice(0, -3), sha);
174
+ } else if (!refs.has(ref)) {
175
+ refs.set(ref, sha);
176
+ }
177
+ }
178
+ return refs;
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Proposal document (deterministic projection of the §2.3 context)
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /** Proposal file location inside the downstream repository. */
186
+ export function proposalFileName(unitId, version) {
187
+ if (typeof unitId !== 'string' || unitId.length === 0
188
+ || typeof version !== 'string' || version.length === 0) {
189
+ throw new ReleaseError(GATE_FAILED, 'proposal file name requires unitId and version', { unitId, version });
190
+ }
191
+ return `incoming/${unitId}-${version}.json`;
192
+ }
193
+
194
+ /**
195
+ * Build the proposal document from the §2.3 context projection. Pure and
196
+ * deterministic. Deliberately EXCLUDES payloadDir (a local materialization
197
+ * path has no place in a downstream-facing document), and optional frozen
198
+ * fields are ABSENT rather than null when the plan did not freeze them.
199
+ *
200
+ * Field set suffices to backfill a missing downstream hub entry:
201
+ * unitId/version/tag/sha/commit/tree/manifestDigest/planDigest/runId/
202
+ * publishedAt/verifyEvidence/changeSummary.
203
+ *
204
+ * @param {object} context - The §2.3 context projection.
205
+ * @returns {object} The proposal document.
206
+ */
207
+ export function buildProposalDocument(context) {
208
+ const required = ['unitId', 'version', 'tag', 'commit', 'planDigest', 'runId'];
209
+ for (const field of required) {
210
+ if (typeof context?.[field] !== 'string' || context[field].length === 0) {
211
+ throw new ReleaseError(GATE_FAILED, `proposal document requires context field "${field}"`, { field });
212
+ }
213
+ }
214
+ const { unitId, version, tag, commit, tree, manifestDigest, planDigest, runId, publishedAt, verifyEvidence } = context;
215
+ return {
216
+ schemaVersion: PROPOSAL_SCHEMA_VERSION,
217
+ kind: PROPOSAL_KIND,
218
+ unitId,
219
+ version,
220
+ tag,
221
+ sha: commit,
222
+ commit,
223
+ ...(typeof tree === 'string' && tree.length > 0 ? { tree } : {}),
224
+ ...(typeof manifestDigest === 'string' && manifestDigest.length > 0 ? { manifestDigest } : {}),
225
+ planDigest,
226
+ runId,
227
+ ...(publishedAt !== undefined ? { publishedAt } : {}),
228
+ ...(verifyEvidence !== undefined ? { verifyEvidence } : {}),
229
+ changeSummary: `release ${unitId} ${version} (tag ${tag}, commit ${commit})`,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Deterministic manual-sync prompt (closure semantics): delivery success plus
235
+ * this prompt in evidence IS the closed loop — downstream consumption is the
236
+ * hub's own governance. Names the hub repo, the proposal path, and the
237
+ * suggested manual action.
238
+ *
239
+ * @param {object} params - { remoteUrl, proposalPath, branch }
240
+ * @returns {string}
241
+ */
242
+ export function buildManualSyncPrompt({ remoteUrl, proposalPath, branch }) {
243
+ return `manual sync required: review proposal ${proposalPath} in downstream repository ${remoteUrl} (branch ${branch}); suggested action: open a pull request or apply the proposal through the downstream governance workflow`;
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // git-push transport
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Post-push cross-check (review N-3): `git ls-remote` must confirm the branch
252
+ * tip equals the pushed commit; any mismatch fails closed.
253
+ *
254
+ * @param {Function} exec - Injectable git exec (argument arrays only).
255
+ * @param {string} remoteUrl
256
+ * @param {string} branch
257
+ * @param {string} pushedCommit - Full 40-hex commit sha.
258
+ * @returns {Promise<string>} The observed branch tip.
259
+ * @throws {ReleaseError} POST_PUBLISH_VERIFY_FAILED on mismatch/missing ref.
260
+ */
261
+ export async function crossCheckPushedCommit(exec, remoteUrl, branch, pushedCommit) {
262
+ assertSafeRemoteUrl(remoteUrl);
263
+ assertSafeBranch(branch);
264
+ if (typeof pushedCommit !== 'string' || !SHA_RE.test(pushedCommit)) {
265
+ throw new ReleaseError(GATE_FAILED, 'cross-check requires the full 40-hex pushed commit', { pushedCommit });
266
+ }
267
+ const execFn = typeof exec === 'function' ? exec : defaultExec;
268
+ const { stdout } = await execFn('git', ['ls-remote', remoteUrl, `refs/heads/${branch}`], {
269
+ env: netEnv(),
270
+ timeout: PROBE_TIMEOUT_MS,
271
+ shell: false,
272
+ });
273
+ const observed = parseLsRemote(stdout).get(`refs/heads/${branch}`) ?? null;
274
+ if (observed !== pushedCommit) {
275
+ throw new ReleaseError(
276
+ POST_PUBLISH_VERIFY_FAILED,
277
+ `post-push cross-check failed: remote branch ${branch} is at ${observed ?? '<missing>'}, which disagrees with the pushed commit ${pushedCommit}`,
278
+ { remoteUrl, branch, pushedCommit, observed },
279
+ );
280
+ }
281
+ return observed;
282
+ }
283
+
284
+ /**
285
+ * Deliver one proposal document through the git-push transport:
286
+ * clone -> write (absent only) -> commit (frozen identity) -> push.
287
+ *
288
+ * - identical existing content -> NO_CHANGE, nothing written;
289
+ * - different existing content -> REMOTE_CONFLICT (human decision);
290
+ * - NEVER --force.
291
+ *
292
+ * No preset-level dry-run (R4 review m-3): the command-level DRY_RUN skip
293
+ * contract skips preset hooks wholesale (SKIPPED/DRY_RUN checkpoints in
294
+ * distribute/postVerify) before this delivery is ever invoked, so a dryRun
295
+ * parameter here would be unreachable dead surface.
296
+ *
297
+ * @param {object} params
298
+ * @param {string} params.remoteUrl - Downstream repository URL.
299
+ * @param {string} params.branch - Downstream branch.
300
+ * @param {string} params.proposalPath - Repository-relative proposal path.
301
+ * @param {object} params.proposalDocument - The proposal document.
302
+ * @param {object} params.commitIdentity - Frozen { name, email }.
303
+ * @param {Function} [params.exec] - Injectable git exec (tests).
304
+ * @returns {Promise<{ status: 'EXECUTED'|'NO_CHANGE', observation: object }>}
305
+ */
306
+ export async function deliverProposalGitPush(params) {
307
+ const {
308
+ remoteUrl,
309
+ branch,
310
+ proposalPath,
311
+ proposalDocument,
312
+ commitIdentity,
313
+ exec: execOpt,
314
+ } = params ?? {};
315
+ assertSafeRemoteUrl(remoteUrl);
316
+ assertSafeBranch(branch);
317
+ assertSafeProposalPath(proposalPath);
318
+ assertCommitIdentity(commitIdentity);
319
+ if (!proposalDocument || typeof proposalDocument !== 'object' || Array.isArray(proposalDocument)) {
320
+ throw new ReleaseError(GATE_FAILED, 'proposal-inbox requires a proposal document');
321
+ }
322
+ const exec = typeof execOpt === 'function' ? execOpt : defaultExec;
323
+ const serialized = `${JSON.stringify(proposalDocument, null, 2)}\n`;
324
+
325
+ const cloneDir = await mkdtemp(join(tmpdir(), TMP_PREFIX));
326
+ try {
327
+ // Clone the existing downstream to continue its history (append-only).
328
+ try {
329
+ await exec('git', ['clone', '--quiet', remoteUrl, cloneDir], {
330
+ env: netEnv(),
331
+ timeout: TRANSFER_TIMEOUT_MS,
332
+ shell: false,
333
+ });
334
+ } catch (error) {
335
+ const classification = classifyNetFailure(stderrText(error));
336
+ throw new ReleaseError(
337
+ classification === 'auth' ? REMOTE_CONFLICT : REMOTE_UNAVAILABLE,
338
+ classification === 'auth'
339
+ ? `proposal-inbox remote refused authentication for ${remoteUrl}; the host keychain credential is reused and never prompted, read, or retried`
340
+ : `cannot reach proposal-inbox remote ${remoteUrl} — start VPN / check network; delivery fails closed and never retries with credentials`,
341
+ { remoteUrl, stderrTail: stderrTail(error) },
342
+ );
343
+ }
344
+
345
+ const git = (args, options = {}) => exec('git', args, { cwd: cloneDir, shell: false, timeout: GIT_TIMEOUT_MS, ...options });
346
+
347
+ // Continue the declared branch when it exists; start a root commit otherwise.
348
+ let previousHead = null;
349
+ try {
350
+ const { stdout } = await git(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`]);
351
+ previousHead = stdout.trim() || null;
352
+ } catch {
353
+ previousHead = null;
354
+ }
355
+ if (previousHead) {
356
+ await git(['checkout', '--quiet', '-B', branch, previousHead]);
357
+ } else {
358
+ await git(['checkout', '--quiet', '--orphan', branch]);
359
+ }
360
+
361
+ // Observe-before-write: an existing proposal decides the outcome.
362
+ const targetPath = join(cloneDir, proposalPath);
363
+ let existing = null;
364
+ try {
365
+ existing = await readFile(targetPath, 'utf8');
366
+ } catch {
367
+ existing = null;
368
+ }
369
+ if (existing !== null) {
370
+ if (existing === serialized) {
371
+ return {
372
+ status: 'NO_CHANGE',
373
+ observation: { mode: 'no-change', previousHead, branchTip: previousHead },
374
+ };
375
+ }
376
+ throw new ReleaseError(
377
+ REMOTE_CONFLICT,
378
+ `proposal ${proposalPath} already exists in ${remoteUrl} with different content; overwriting a downstream proposal requires a human decision`,
379
+ { remoteUrl, proposalPath },
380
+ );
381
+ }
382
+
383
+ await mkdir(dirname(targetPath), { recursive: true });
384
+ await writeFile(targetPath, serialized);
385
+ await git(['add', '-A']);
386
+ await git([
387
+ '-c', `user.name=${commitIdentity.name}`,
388
+ '-c', `user.email=${commitIdentity.email}`,
389
+ 'commit', '--quiet', '-m', `release-skill proposal ${proposalPath}`,
390
+ ]);
391
+ const { stdout: headOut } = await git(['rev-parse', 'HEAD']);
392
+ const localCommit = headOut.trim();
393
+
394
+ // Plain push. NEVER --force.
395
+ try {
396
+ await exec('git', ['push', '--quiet', 'origin', branch], {
397
+ cwd: cloneDir,
398
+ env: netEnv(),
399
+ timeout: TRANSFER_TIMEOUT_MS,
400
+ shell: false,
401
+ });
402
+ } catch (error) {
403
+ const classification = classifyNetFailure(stderrText(error));
404
+ throw new ReleaseError(
405
+ classification === 'auth' ? REMOTE_CONFLICT : REMOTE_UNAVAILABLE,
406
+ classification === 'auth'
407
+ ? `proposal-inbox push refused authentication for ${remoteUrl}; the host keychain credential is reused and never prompted, read, or retried`
408
+ : `proposal-inbox push to ${remoteUrl} failed — start VPN / check network; delivery fails closed and never retries with credentials`,
409
+ { remoteUrl, branch, stderrTail: stderrTail(error) },
410
+ );
411
+ }
412
+
413
+ return {
414
+ status: 'EXECUTED',
415
+ observation: { mode: 'pushed', pushedCommit: localCommit, previousHead, branchTip: localCommit },
416
+ };
417
+ } finally {
418
+ await rm(cloneDir, { recursive: true, force: true }).catch(() => {});
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Execute one proposal-inbox git-push preset hook end-to-end: build the
424
+ * document from the §2.3 context, deliver it, and cross-check the pushed
425
+ * commit (review N-3). Shared by the distribute saga and the postVerify run.
426
+ * Dry-run semantics live one level up (command-level DRY_RUN skip contract;
427
+ * R4 review m-3) — this executor path always delivers.
428
+ *
429
+ * @param {object} params
430
+ * @param {object} params.hook - Declared hook entry (config.target bound).
431
+ * @param {object} params.contextProjection - The §2.3 context projection.
432
+ * @param {object} params.commitIdentity - Frozen commitIdentity.
433
+ * @param {Function} [params.exec]
434
+ * @returns {Promise<{ status: string, observation: object, manualSyncPrompt: string }>}
435
+ */
436
+ export async function executeProposalInboxGitPushHook(params) {
437
+ const { hook, contextProjection, commitIdentity, exec } = params ?? {};
438
+ const target = hook?.config?.target;
439
+ if (!target || typeof target.remoteUrl !== 'string' || typeof target.branch !== 'string') {
440
+ throw new ReleaseError(GATE_FAILED, 'proposal-inbox git-push requires config.target.remoteUrl and config.target.branch');
441
+ }
442
+ const document = buildProposalDocument(contextProjection);
443
+ const proposalPath = proposalFileName(contextProjection.unitId, contextProjection.version);
444
+ const delivery = await deliverProposalGitPush({
445
+ remoteUrl: target.remoteUrl,
446
+ branch: target.branch,
447
+ proposalPath,
448
+ proposalDocument: document,
449
+ commitIdentity,
450
+ ...(exec !== undefined ? { exec } : {}),
451
+ });
452
+ if (delivery.status === 'EXECUTED' && delivery.observation.mode === 'pushed') {
453
+ await crossCheckPushedCommit(exec, target.remoteUrl, target.branch, delivery.observation.pushedCommit);
454
+ }
455
+ return {
456
+ ...delivery,
457
+ proposalPath,
458
+ manualSyncPrompt: buildManualSyncPrompt({
459
+ remoteUrl: target.remoteUrl,
460
+ proposalPath,
461
+ branch: target.branch,
462
+ }),
463
+ };
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // local-file transport (R4)
468
+ // ---------------------------------------------------------------------------
469
+
470
+ /**
471
+ * Deterministic manual-sync prompt for the local-file transport: names the
472
+ * checkout, the proposal path, and the missing push step (delivery is
473
+ * completed by a human or the downstream governance workflow).
474
+ *
475
+ * @param {object} params - { workspace, proposalPath, branch }
476
+ * @returns {string}
477
+ */
478
+ export function buildLocalFileSyncPrompt({ workspace, proposalPath, branch }) {
479
+ return `manual sync required: proposal ${proposalPath} was committed to the local downstream checkout ${workspace} (branch ${branch}) but not pushed; suggested action: review the proposal and push or apply it through the downstream governance workflow`;
480
+ }
481
+
482
+ /**
483
+ * Deliver one proposal document through the local-file transport: write the
484
+ * proposal into the local checkout and commit ONLY that file with the frozen
485
+ * bot identity. NEVER pushes — the upstream stays untouched.
486
+ *
487
+ * - identical existing content -> NO_CHANGE, nothing written;
488
+ * - different existing content -> REMOTE_CONFLICT (human decision);
489
+ * - the checkout must be on the declared branch before any write.
490
+ *
491
+ * @param {object} params
492
+ * @param {string} params.workspaceRealpath - Resolved realpath of the local
493
+ * checkout (already execution-checked by the caller, §2.6).
494
+ * @param {string} params.branch - Declared downstream branch.
495
+ * @param {string} params.proposalPath - Repository-relative proposal path.
496
+ * @param {object} params.proposalDocument - The proposal document.
497
+ * @param {object} params.commitIdentity - Frozen { name, email }.
498
+ * @param {Function} [params.exec] - Injectable git exec (tests).
499
+ * @returns {Promise<{ status: 'EXECUTED'|'NO_CHANGE', observation: object }>}
500
+ */
501
+ export async function deliverProposalLocalFile(params) {
502
+ const {
503
+ workspaceRealpath,
504
+ branch,
505
+ proposalPath,
506
+ proposalDocument,
507
+ commitIdentity,
508
+ exec: execOpt,
509
+ } = params ?? {};
510
+ assertSafeBranch(branch);
511
+ assertSafeProposalPath(proposalPath);
512
+ assertCommitIdentity(commitIdentity);
513
+ if (typeof workspaceRealpath !== 'string' || workspaceRealpath.length === 0) {
514
+ throw new ReleaseError(GATE_FAILED, 'proposal-inbox local-file requires a resolved workspace realpath');
515
+ }
516
+ if (!proposalDocument || typeof proposalDocument !== 'object' || Array.isArray(proposalDocument)) {
517
+ throw new ReleaseError(GATE_FAILED, 'proposal-inbox requires a proposal document');
518
+ }
519
+ const exec = typeof execOpt === 'function' ? execOpt : defaultExec;
520
+ const git = (args, options = {}) => exec('git', args, { cwd: workspaceRealpath, shell: false, timeout: GIT_TIMEOUT_MS, ...options });
521
+
522
+ // Branch guard: the proposal lands on the declared branch only; switching
523
+ // branches in a user checkout is never done automatically.
524
+ let headRef = '';
525
+ try {
526
+ const { stdout } = await git(['symbolic-ref', '--quiet', 'HEAD']);
527
+ headRef = `${stdout}`.trim();
528
+ } catch {
529
+ headRef = '';
530
+ }
531
+ if (headRef !== `refs/heads/${branch}`) {
532
+ throw new ReleaseError(
533
+ GATE_FAILED,
534
+ `proposal-inbox local-file requires the checkout to be on branch "${branch}", but HEAD is at "${headRef || '<detached>'}"; check out the branch and rerun`,
535
+ { workspace: workspaceRealpath, branch, headRef: headRef || null },
536
+ );
537
+ }
538
+
539
+ // Observe-before-write: an existing proposal decides the outcome.
540
+ const serialized = `${JSON.stringify(proposalDocument, null, 2)}\n`;
541
+ const targetPath = join(workspaceRealpath, proposalPath);
542
+ let existing = null;
543
+ try {
544
+ existing = await readFile(targetPath, 'utf8');
545
+ } catch {
546
+ existing = null;
547
+ }
548
+ if (existing !== null) {
549
+ if (existing === serialized) {
550
+ return { status: 'NO_CHANGE', observation: { mode: 'no-change', branch } };
551
+ }
552
+ throw new ReleaseError(
553
+ REMOTE_CONFLICT,
554
+ `proposal ${proposalPath} already exists in the local checkout with different content; overwriting a downstream proposal requires a human decision`,
555
+ { workspace: workspaceRealpath, proposalPath },
556
+ );
557
+ }
558
+
559
+ await mkdir(dirname(targetPath), { recursive: true });
560
+ await writeFile(targetPath, serialized);
561
+ // Stage ONLY the proposal file: unrelated checkout state is never swept in.
562
+ await git(['add', '--', proposalPath]);
563
+ await git([
564
+ '-c', `user.name=${commitIdentity.name}`,
565
+ '-c', `user.email=${commitIdentity.email}`,
566
+ 'commit', '--quiet', '-m', `release-skill proposal ${proposalPath}`,
567
+ ]);
568
+ const { stdout: headOut } = await git(['rev-parse', 'HEAD']);
569
+ const localCommit = headOut.trim();
570
+
571
+ // Deliberately NO push: the upstream is never touched by local-file.
572
+ return {
573
+ status: 'EXECUTED',
574
+ observation: { mode: 'local-file', localCommit, branch },
575
+ };
576
+ }
577
+
578
+ /**
579
+ * Execute one proposal-inbox local-file preset hook end-to-end: run the §2.6
580
+ * workspace execution checks, build the document from the §2.3 context, and
581
+ * deliver it into the local checkout (never pushed). Shared by the
582
+ * distribute saga and the postVerify run.
583
+ *
584
+ * @param {object} params
585
+ * @param {object} params.hook - Declared hook entry (config.target bound).
586
+ * @param {object} params.contextProjection - The §2.3 context projection.
587
+ * @param {object} params.commitIdentity - Frozen commitIdentity.
588
+ * @param {string} params.root - Release workspace root (workspace resolution).
589
+ * @param {Function} [params.exec] - Injectable git exec (tests).
590
+ * @returns {Promise<{ status: string, observation: object, proposalPath: string, manualSyncPrompt: string }>}
591
+ */
592
+ export async function executeProposalInboxLocalFileHook(params) {
593
+ const { hook, contextProjection, commitIdentity, root, exec } = params ?? {};
594
+ const target = hook?.config?.target;
595
+ if (!target || typeof target.workspace !== 'string' || typeof target.branch !== 'string') {
596
+ throw new ReleaseError(GATE_FAILED, 'proposal-inbox local-file requires config.target.workspace and config.target.branch');
597
+ }
598
+ if (typeof root !== 'string' || root.length === 0) {
599
+ throw new ReleaseError(GATE_FAILED, 'proposal-inbox local-file requires the release workspace root');
600
+ }
601
+
602
+ // §2.6 workspace execution checks: preflight realpath, TOCTOU re-check,
603
+ // release-workspace/runtime-directory exclusion — all before any write.
604
+ const preflight = await preflightPresetWorkspace(target.workspace, { root });
605
+ const execution = await assertPresetWorkspaceExecution(target.workspace, {
606
+ root,
607
+ preflightRealpath: preflight.realpath,
608
+ });
609
+
610
+ const document = buildProposalDocument(contextProjection);
611
+ const proposalPath = proposalFileName(contextProjection.unitId, contextProjection.version);
612
+ const delivery = await deliverProposalLocalFile({
613
+ workspaceRealpath: execution.realpath,
614
+ branch: target.branch,
615
+ proposalPath,
616
+ proposalDocument: document,
617
+ commitIdentity,
618
+ ...(exec !== undefined ? { exec } : {}),
619
+ });
620
+ return {
621
+ ...delivery,
622
+ proposalPath,
623
+ workspaceRealpath: execution.realpath,
624
+ manualSyncPrompt: buildLocalFileSyncPrompt({
625
+ workspace: execution.realpath,
626
+ proposalPath,
627
+ branch: target.branch,
628
+ }),
629
+ };
630
+ }