release-skill 0.1.1

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 (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,627 @@
1
+ /**
2
+ * Artifacts CLI command routing.
3
+ *
4
+ * Handles artifact inspection, resolution, and durable apply commands.
5
+ *
6
+ * JSON error output always includes:
7
+ * - `status`, `safeToWrite`, `targetUnchanged`, `evidenceDir`
8
+ * - A unique `nextAction` with a `command` field
9
+ *
10
+ * @module commands/artifacts
11
+ */
12
+
13
+ import {
14
+ ReleaseError,
15
+ MISSING_PARAMETERS,
16
+ } from '../core/errors.mjs';
17
+ import {
18
+ inspectArtifacts, initArtifacts,
19
+ captureInspectInputs, inspectFromInputs, verifyInputsUnchanged,
20
+ } from '../artifacts/inspect.mjs';
21
+ import { writePlan } from '../artifacts/artifact-plan.mjs';
22
+ import { planAdoption, discardBootstrapHunk } from '../artifacts/adoption.mjs';
23
+ import { materializeResolution, submitResolution } from '../artifacts/resolution.mjs';
24
+ import { acquireProjectLock, breakProjectLock } from '../artifacts/project-lock.mjs';
25
+ import { readFile } from 'node:fs/promises';
26
+ import { join } from 'node:path';
27
+ import { loadArtifactPolicy } from '../artifacts/policy.mjs';
28
+ import { readEntry } from '../artifacts/entry.mjs';
29
+ import { createBuiltInProducerRegistry, runProducerClosure } from '../artifacts/producer-registry.mjs';
30
+ import { buildProducerGraph } from '../artifacts/graph.mjs';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Valid subcommands
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const VALID_SUBCOMMANDS = new Set([
37
+ 'status', 'inspect', 'init', 'adopt', 'bootstrap', 'resolve',
38
+ 'break-lock', 'update', 'apply',
39
+ ]);
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Public API
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Run an artifacts subcommand.
47
+ *
48
+ * @param {object} options
49
+ * @param {string} options.subcommand - One of 'status', 'inspect', 'init', 'adopt', 'bootstrap'.
50
+ * @param {string[]} options.args - Raw CLI arguments.
51
+ * @param {string} options.root - Repository root (absolute).
52
+ * @returns {Promise<object>} Command result with status, plan, nextAction.
53
+ * @throws {ReleaseError} MISSING_PARAMETERS on invalid subcommand.
54
+ */
55
+ export async function runArtifactsCommand({ subcommand, args, root } = {}) {
56
+ if (!VALID_SUBCOMMANDS.has(subcommand)) {
57
+ throw new ReleaseError(
58
+ MISSING_PARAMETERS,
59
+ `unknown artifacts subcommand: "${subcommand}"; valid: ${[...VALID_SUBCOMMANDS].join(', ')}`,
60
+ { subcommand, valid: [...VALID_SUBCOMMANDS] },
61
+ );
62
+ }
63
+
64
+ // --- Adopt subcommand ---
65
+ if (subcommand === 'adopt') {
66
+ const lock = await acquireProjectLock({ root, command: 'adopt', mode: 'exclusive' });
67
+ try {
68
+ return await handleAdopt({ args, root });
69
+ } finally {
70
+ await lock.release();
71
+ }
72
+ }
73
+
74
+ // --- Bootstrap subcommand (discard/replace) ---
75
+ if (subcommand === 'bootstrap') {
76
+ const lock = await acquireProjectLock({ root, command: 'bootstrap', mode: 'exclusive' });
77
+ try {
78
+ return await handleBootstrap({ args, root });
79
+ } finally {
80
+ await lock.release();
81
+ }
82
+ }
83
+
84
+ // --- Apply/update --apply use the same durable transaction authority ---
85
+ if (subcommand === 'apply'
86
+ || (subcommand === 'update' && args.includes('--apply'))) {
87
+ return handleApply({ args, root });
88
+ }
89
+ if (subcommand === 'update') {
90
+ throw new ReleaseError(
91
+ MISSING_PARAMETERS,
92
+ 'update requires --apply --plan <path> and --plan-digest <digest>',
93
+ { subcommand: 'update' },
94
+ );
95
+ }
96
+
97
+ // --- Resolve subcommand (materialize/submit) ---
98
+ if (subcommand === 'resolve') {
99
+ return handleResolve({ args, root });
100
+ }
101
+
102
+ // --- Break-lock subcommand ---
103
+ if (subcommand === 'break-lock') {
104
+ return handleBreakLock({ args, root });
105
+ }
106
+
107
+ // Parse common optional flags from args
108
+ const outputIdx = args.indexOf('--output');
109
+ const output = outputIdx !== -1 && args[outputIdx + 1]
110
+ ? args[outputIdx + 1]
111
+ : undefined;
112
+
113
+ // Resolve mode from subcommand
114
+ const modeMap = {
115
+ status: 'status',
116
+ inspect: 'inspect',
117
+ init: 'init',
118
+ };
119
+ const mode = modeMap[subcommand];
120
+
121
+ // Short lock for status/inspect/init:
122
+ // 1. Acquire lock → capture immutable inputs → release lock
123
+ // 2. Run inspection (classify, assemble) without lock
124
+ // 3. Acquire lock → verify inputs unchanged → write plan → release lock
125
+ if (mode) {
126
+ // init uses its own validation (nested git roots, partial stages)
127
+ if (mode === 'init') {
128
+ const lock = await acquireProjectLock({ root, command: subcommand, mode: 'exclusive' });
129
+ try {
130
+ const result = await initArtifacts({ root, output });
131
+ return formatResult(result, output);
132
+ } finally {
133
+ await lock.release();
134
+ }
135
+ }
136
+
137
+ // Phase 1: Acquire lock → capture inputs → release
138
+ let inputs;
139
+ {
140
+ const lock = await acquireProjectLock({ root, command: subcommand, mode: 'exclusive' });
141
+ try {
142
+ inputs = await captureInspectInputs({ root });
143
+ } finally {
144
+ await lock.release();
145
+ }
146
+ }
147
+
148
+ // Phase 2: Run inspection (classify + assemble) without lock
149
+ const result = inspectFromInputs({ inputs, mode });
150
+
151
+ // Phase 3: Acquire lock → verify inputs unchanged → write plan → release
152
+ {
153
+ const lock = await acquireProjectLock({ root, command: subcommand, mode: 'exclusive' });
154
+ try {
155
+ await verifyInputsUnchanged({ root, inputs });
156
+ if (output) {
157
+ await writePlan(result.plan, output);
158
+ }
159
+ } finally {
160
+ await lock.release();
161
+ }
162
+ }
163
+
164
+ const runId = `inspect-${Date.now().toString(36)}`;
165
+ return formatResult({ ...result, evidenceDir: `.release-skill/runs/${runId}` }, output);
166
+ }
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Internal helpers
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Format the result for CLI output.
175
+ */
176
+ function formatResult(result, outputPath) {
177
+ return Object.freeze({
178
+ status: result.status,
179
+ safeToWrite: result.safeToWrite,
180
+ targetUnchanged: result.targetUnchanged,
181
+ evidenceDir: result.evidenceDir,
182
+ nextAction: result.nextAction,
183
+ plan: result.plan,
184
+ ...(outputPath ? { planPath: outputPath } : {}),
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Parse a flag value from args array.
190
+ */
191
+ function getFlag(args, flag) {
192
+ const idx = args.indexOf(flag);
193
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : undefined;
194
+ }
195
+
196
+ /**
197
+ * Build a producer runner that uses the real built-in producer registry.
198
+ * Returns a function compatible with planAdoption's runProducer option.
199
+ */
200
+ async function buildProducerRunner(root) {
201
+ const registry = await createBuiltInProducerRegistry();
202
+ return async ({ artifactIds, inputSnapshot, graph }) => {
203
+ const result = await runProducerClosure({
204
+ registry,
205
+ graph,
206
+ inputSnapshot,
207
+ artifactIds,
208
+ });
209
+ return {
210
+ byArtifact: result.byArtifact,
211
+ implementationDigest: null, // captured from registry per-producer
212
+ };
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Handle the `artifacts adopt` subcommand.
218
+ *
219
+ * Reads the init plan, loads the policy, reads current/generated entries
220
+ * from the plan's firstCandidate fields, runs producer closure, and
221
+ * computes an adoption plan with protected hunks and downstream closure.
222
+ */
223
+ async function handleAdopt({ args, root }) {
224
+ const planPath = getFlag(args, '--plan') ?? getFlag(args, '--bootstrap-plan');
225
+ const adoptIndex = args.indexOf('adopt');
226
+ const positional = adoptIndex >= 0 && args[adoptIndex + 1] && !args[adoptIndex + 1].startsWith('--')
227
+ ? args[adoptIndex + 1]
228
+ : undefined;
229
+ const artifactId = getFlag(args, '--artifact') ?? positional;
230
+ const expectedDigest = getFlag(args, '--plan-digest');
231
+
232
+ if (!planPath || !artifactId) {
233
+ throw new ReleaseError(
234
+ MISSING_PARAMETERS,
235
+ 'adopt requires <generated-artifact-id> and --plan <path> or --bootstrap-plan <path>',
236
+ { subcommand: 'adopt' },
237
+ );
238
+ }
239
+
240
+ // Read and validate the plan
241
+ const planRaw = await readFile(planPath, 'utf8');
242
+ const plan = JSON.parse(planRaw);
243
+
244
+ if (expectedDigest && plan.planDigest !== expectedDigest) {
245
+ throw new ReleaseError(
246
+ 'PLAN_STALE',
247
+ 'plan digest does not match expected --plan-digest',
248
+ { expected: expectedDigest, actual: plan.planDigest },
249
+ );
250
+ }
251
+
252
+ // Load policy
253
+ const { policy } = await loadArtifactPolicy({ root });
254
+
255
+ // Read current entries from worktree
256
+ const currentEntries = new Map();
257
+ for (const artifact of plan.artifacts ?? []) {
258
+ if (artifact.path) {
259
+ const entry = await readEntry({ root, path: artifact.path, source: 'worktree' });
260
+ if (entry.kind === 'regular') {
261
+ const bytes = await readFile(join(root, artifact.path));
262
+ currentEntries.set(artifact.id, Object.freeze({ ...entry, bytes, content: bytes }));
263
+ } else {
264
+ currentEntries.set(artifact.id, entry);
265
+ }
266
+ } else {
267
+ currentEntries.set(artifact.id, { kind: 'absent' });
268
+ }
269
+ }
270
+
271
+ // Read generated entries from plan's firstCandidate (real data, not fake absent)
272
+ const generatedEntries = new Map();
273
+ for (const artifact of plan.artifacts ?? []) {
274
+ if (artifact.firstCandidate) {
275
+ const fc = artifact.firstCandidate;
276
+ const bytes = Buffer.isBuffer(fc.bytes)
277
+ ? Buffer.from(fc.bytes)
278
+ : (typeof fc.bytesBase64 === 'string'
279
+ ? Buffer.from(fc.bytesBase64, 'base64')
280
+ : (fc.bytes?.type === 'Buffer' && Array.isArray(fc.bytes.data)
281
+ ? Buffer.from(fc.bytes.data)
282
+ : null));
283
+ if (bytes) {
284
+ generatedEntries.set(artifact.id, {
285
+ kind: 'regular',
286
+ bytes,
287
+ sha256: fc.sha256,
288
+ });
289
+ } else {
290
+ generatedEntries.set(artifact.id, { kind: 'absent' });
291
+ }
292
+ } else {
293
+ generatedEntries.set(artifact.id, { kind: 'absent' });
294
+ }
295
+ }
296
+
297
+ // Build producer runner (real registry)
298
+ const runProducer = await buildProducerRunner(root);
299
+
300
+ // Compute adoption plan
301
+ const adoptionPlan = await planAdoption({
302
+ plan,
303
+ policy,
304
+ artifactId,
305
+ currentEntries,
306
+ generatedEntries,
307
+ runProducer,
308
+ });
309
+
310
+ return Object.freeze({
311
+ ...adoptionPlan,
312
+ nextAction: adoptionPlan.status === 'ADOPTION_REQUIRED'
313
+ ? Object.freeze({ command: 'artifacts bootstrap discard|replace --artifact <id> --hunk-digest <digest>' })
314
+ : Object.freeze({ command: 'artifacts accept --plan' }),
315
+ });
316
+ }
317
+
318
+ /**
319
+ * Handle the `artifacts bootstrap discard|replace` subcommand.
320
+ *
321
+ * Records a per-hunk discard or replace decision with actor + reason,
322
+ * re-reads currentEntries to verify bytes, and derives a new plan digest.
323
+ */
324
+ async function handleBootstrap({ args, root }) {
325
+ const bootstrapIndex = args.indexOf('bootstrap');
326
+ const action = bootstrapIndex >= 0 && args[bootstrapIndex + 1] && !args[bootstrapIndex + 1].startsWith('--')
327
+ ? args[bootstrapIndex + 1]
328
+ : 'discard';
329
+ if (action !== 'discard' && action !== 'replace') {
330
+ throw new ReleaseError(
331
+ MISSING_PARAMETERS,
332
+ `bootstrap subcommand must be "discard" or "replace", got "${action}"`,
333
+ { action },
334
+ );
335
+ }
336
+
337
+ const artifactId = getFlag(args, '--artifact');
338
+ const hunkDigest = getFlag(args, '--hunk-digest');
339
+ const planPath = getFlag(args, '--plan');
340
+ const expectedDigest = getFlag(args, '--plan-digest');
341
+ const actor = getFlag(args, '--actor');
342
+ const reason = getFlag(args, '--reason');
343
+ const replacementPath = getFlag(args, '--replacement-file');
344
+
345
+ if (!artifactId || !hunkDigest || !planPath || !expectedDigest || !actor || !reason) {
346
+ throw new ReleaseError(
347
+ MISSING_PARAMETERS,
348
+ 'bootstrap requires --artifact, --hunk-digest, --plan, --plan-digest, --actor, and --reason',
349
+ { subcommand: 'bootstrap' },
350
+ );
351
+ }
352
+
353
+ // Read the adoption plan
354
+ const planRaw = await readFile(planPath, 'utf8');
355
+ const adoptionPlan = JSON.parse(planRaw);
356
+
357
+ // Re-read current entries from the exact artifact paths bound into the plan.
358
+ const currentEntries = new Map();
359
+ for (const id of new Set((adoptionPlan.protectedHunks ?? []).map((h) => h.artifactId))) {
360
+ const artifactPath = adoptionPlan.artifactPaths?.[id];
361
+ if (!artifactPath) {
362
+ throw new ReleaseError('PLAN_STALE', `artifact path missing from adoption plan: ${id}`, { id });
363
+ }
364
+ const entry = await readEntry({ root, path: artifactPath, source: 'worktree' });
365
+ if (entry.kind !== 'regular') {
366
+ throw new ReleaseError('PLAN_STALE', `artifact is no longer a regular file: ${id}`, { id });
367
+ }
368
+ const bytes = await readFile(join(root, artifactPath));
369
+ currentEntries.set(id, Object.freeze({ ...entry, bytes, content: bytes }));
370
+ }
371
+
372
+ // Read replacement bytes if action is replace
373
+ let replacementBytes;
374
+ if (action === 'replace' && replacementPath) {
375
+ replacementBytes = await readFile(replacementPath);
376
+ }
377
+
378
+ const updated = await discardBootstrapHunk({
379
+ adoptionPlan,
380
+ currentEntries,
381
+ artifactId,
382
+ hunkDigest,
383
+ expectedPlanDigest: expectedDigest,
384
+ actor,
385
+ reason,
386
+ action,
387
+ replacementBytes,
388
+ });
389
+
390
+ return Object.freeze({
391
+ ...updated,
392
+ nextAction: updated.status === 'ADOPTION_REQUIRED'
393
+ ? Object.freeze({ command: 'artifacts bootstrap discard|replace --artifact <id> --hunk-digest <digest>' })
394
+ : Object.freeze({ command: 'artifacts inspect --plan-digest <new-digest>' }),
395
+ });
396
+ }
397
+
398
+ /**
399
+ * Handle the `artifacts resolve materialize|submit` subcommand.
400
+ *
401
+ * Subcommands:
402
+ * - `materialize`: Scan conflict for sensitive content, create resolution
403
+ * directory (0700) with editable conflict file (0600).
404
+ * - `submit`: Read resolved file, verify bindings unchanged, derive new plan.
405
+ *
406
+ * Flags:
407
+ * --artifact <id> Artifact to resolve
408
+ * --plan <path> Path to the artifact plan
409
+ * --plan-digest <digest> Expected plan digest (stale guard)
410
+ * --resolved-file <path> (submit only) Path to the resolved file
411
+ * --discarded-hunks <digests> (submit only) Comma-separated hunk digests
412
+ */
413
+ async function handleResolve({ args, root }) {
414
+ const resolveIndex = args.indexOf('resolve');
415
+ const action = resolveIndex >= 0 && args[resolveIndex + 1] && !args[resolveIndex + 1].startsWith('--')
416
+ ? args[resolveIndex + 1]
417
+ : 'materialize';
418
+
419
+ if (action !== 'materialize' && action !== 'submit') {
420
+ throw new ReleaseError(
421
+ MISSING_PARAMETERS,
422
+ `resolve subcommand must be "materialize" or "submit", got "${action}"`,
423
+ { action },
424
+ );
425
+ }
426
+
427
+ const artifactId = getFlag(args, '--artifact');
428
+ const planPath = getFlag(args, '--plan');
429
+ const expectedDigest = getFlag(args, '--plan-digest');
430
+
431
+ if (!artifactId || !planPath || !expectedDigest) {
432
+ throw new ReleaseError(
433
+ MISSING_PARAMETERS,
434
+ 'resolve requires --artifact, --plan, and --plan-digest',
435
+ { subcommand: 'resolve' },
436
+ );
437
+ }
438
+
439
+ // Read and validate the plan
440
+ const planRaw = await readFile(planPath, 'utf8');
441
+ const plan = JSON.parse(planRaw);
442
+
443
+ if (plan.planDigest !== expectedDigest) {
444
+ throw new ReleaseError(
445
+ 'PLAN_STALE',
446
+ 'plan digest does not match expected --plan-digest',
447
+ { expected: expectedDigest, actual: plan.planDigest },
448
+ );
449
+ }
450
+
451
+ // Long lock for both materialize and submit
452
+ const lock = await acquireProjectLock({ root, command: `resolve ${action}`, mode: 'exclusive' });
453
+ try {
454
+ if (action === 'materialize') {
455
+ // Optional sensitive authorization
456
+ const sensitiveActor = getFlag(args, '--sensitive-actor');
457
+ const sensitiveReason = getFlag(args, '--sensitive-reason');
458
+ const sensitiveAuthorization = sensitiveActor && sensitiveReason
459
+ ? { actor: sensitiveActor, reason: sensitiveReason }
460
+ : undefined;
461
+
462
+ const result = await materializeResolution({
463
+ root,
464
+ plan,
465
+ planDigest: expectedDigest,
466
+ artifactId,
467
+ sensitiveAuthorization,
468
+ });
469
+
470
+ return Object.freeze({
471
+ status: 'MATERIALIZED',
472
+ directory: result.directory,
473
+ resolvedPath: result.resolvedPath,
474
+ metadata: result.metadata,
475
+ nextAction: Object.freeze({
476
+ command: `artifacts resolve submit --artifact ${artifactId} --plan ${planPath} --plan-digest ${expectedDigest} --resolved-file ${result.resolvedPath}`,
477
+ }),
478
+ });
479
+ }
480
+
481
+ // action === 'submit'
482
+ const resolvedPath = getFlag(args, '--resolved-file');
483
+ if (!resolvedPath) {
484
+ throw new ReleaseError(
485
+ MISSING_PARAMETERS,
486
+ 'resolve submit requires --resolved-file',
487
+ { subcommand: 'resolve submit' },
488
+ );
489
+ }
490
+
491
+ const discardedRaw = getFlag(args, '--discarded-hunks');
492
+ const discardedHunkDigests = discardedRaw
493
+ ? discardedRaw.split(',').map((s) => s.trim()).filter(Boolean)
494
+ : [];
495
+
496
+ const resolved = await submitResolution({
497
+ root,
498
+ plan,
499
+ planDigest: expectedDigest,
500
+ artifactId,
501
+ resolvedPath,
502
+ discardedHunkDigests,
503
+ });
504
+
505
+ return Object.freeze({
506
+ ...resolved,
507
+ nextAction: Object.freeze({
508
+ command: 'artifacts inspect --plan-digest <new-digest>',
509
+ }),
510
+ });
511
+ } finally {
512
+ await lock.release();
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Handle the `artifacts break-lock` subcommand.
518
+ *
519
+ * Breaks a held project lock by matching the exact owner and writing audit
520
+ * evidence. Requires --owner (JSON with all 6 fields) and --reason.
521
+ *
522
+ * The audit record contains no absolute paths.
523
+ *
524
+ * @param {object} options
525
+ * @param {string[]} options.args - CLI arguments.
526
+ * @param {string} options.root - Repository root.
527
+ * @returns {Promise<object>} Audit record or structured error.
528
+ */
529
+ async function handleBreakLock({ args, root }) {
530
+ const ownerJson = getFlag(args, '--owner');
531
+ const reason = getFlag(args, '--reason');
532
+
533
+ // Validate required flags
534
+ if (!ownerJson) {
535
+ throw new ReleaseError(
536
+ MISSING_PARAMETERS,
537
+ 'break-lock requires --owner <JSON> with all 6 fields (pid, host, bootId, nonce, command, startedAt)',
538
+ { subcommand: 'break-lock', missing: '--owner' },
539
+ );
540
+ }
541
+ if (!reason) {
542
+ throw new ReleaseError(
543
+ MISSING_PARAMETERS,
544
+ 'break-lock requires --reason <text> explaining why the lock is being broken',
545
+ { subcommand: 'break-lock', missing: '--reason' },
546
+ );
547
+ }
548
+
549
+ // Parse owner JSON
550
+ let expectedOwner;
551
+ try {
552
+ expectedOwner = JSON.parse(ownerJson);
553
+ } catch {
554
+ throw new ReleaseError(
555
+ MISSING_PARAMETERS,
556
+ 'break-lock --owner must be valid JSON',
557
+ { subcommand: 'break-lock' },
558
+ );
559
+ }
560
+
561
+ // Validate all 6 required fields
562
+ const requiredFields = ['pid', 'host', 'bootId', 'nonce', 'command', 'startedAt'];
563
+ const missingFields = requiredFields.filter((f) => expectedOwner[f] === undefined || expectedOwner[f] === null);
564
+ if (missingFields.length > 0) {
565
+ throw new ReleaseError(
566
+ MISSING_PARAMETERS,
567
+ `break-lock --owner JSON missing required fields: ${missingFields.join(', ')}`,
568
+ { subcommand: 'break-lock', missingFields },
569
+ );
570
+ }
571
+
572
+ // Delegate to breakProjectLock which does exact owner match + audit
573
+ const auditRecord = await breakProjectLock({ root, expectedOwner, reason });
574
+
575
+ return Object.freeze({
576
+ status: 'LOCK_BROKEN',
577
+ auditRecord,
578
+ });
579
+ }
580
+
581
+ /**
582
+ * Handle the `artifacts apply` subcommand.
583
+ *
584
+ * Applies an artifact plan with durable transaction journaling.
585
+ * Requires --plan and --plan-digest flags.
586
+ *
587
+ * @param {object} options
588
+ * @param {string[]} options.args - CLI arguments.
589
+ * @param {string} options.root - Repository root.
590
+ * @returns {Promise<object>} Transaction result.
591
+ * @throws {ReleaseError} on validation failure.
592
+ */
593
+ async function handleApply({ args, root }) {
594
+ const planPath = getFlag(args, '--plan');
595
+ const planDigest = getFlag(args, '--plan-digest');
596
+
597
+ if (!planPath || !planDigest) {
598
+ throw new ReleaseError(
599
+ MISSING_PARAMETERS,
600
+ 'apply requires --plan <path> and --plan-digest <digest>',
601
+ { subcommand: args.includes('--apply') ? 'update' : 'apply' },
602
+ );
603
+ }
604
+
605
+ // Loader failures are already sanitised, stable fail-closed errors. Preserve
606
+ // them instead of replacing the actionable cause with a generic null-backend
607
+ // error.
608
+ const { loadSafeFs } = await import('../artifacts/safe-fs.mjs');
609
+ const safeFs = await loadSafeFs();
610
+
611
+ // Apply the plan
612
+ const { applyArtifactPlan } = await import('../artifacts/transaction.mjs');
613
+ const result = await applyArtifactPlan({
614
+ root,
615
+ planPath,
616
+ planDigest,
617
+ safeFs,
618
+ });
619
+
620
+ return Object.freeze({
621
+ status: 'APPLIED',
622
+ transactionId: result.transactionId,
623
+ state: result.state,
624
+ results: result.results,
625
+ journal: result.journal,
626
+ });
627
+ }