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,658 @@
1
+ /**
2
+ * Safe conflict materialization and resolution submit with new plan derivation.
3
+ *
4
+ * @module artifacts/resolution
5
+ */
6
+
7
+ import { mkdir, open, readFile, stat, lstat, chmod } from 'node:fs/promises';
8
+ import { join, resolve, relative, isAbsolute, basename } from 'node:path';
9
+
10
+ import {
11
+ ReleaseError,
12
+ PLAN_STALE,
13
+ SENSITIVE_CONFLICT,
14
+ MISSING_PARAMETERS,
15
+ PATH_UNSAFE,
16
+ FORBIDDEN_CONTENT_DETECTED,
17
+ } from '../core/errors.mjs';
18
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
19
+ import { inspectArtifacts } from './inspect.mjs';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Constants
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const MAX_TEMPLATE_BYTES = 2 * 1024 * 1024; // 2 MiB
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Buffer decode — handles both Buffer and JSON-roundtrip {type:'Buffer',data}
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Decode a value that may be a native Buffer or a JSON-roundtrip
33
+ * {type:'Buffer',data:[...]} object into a proper Buffer.
34
+ * Fails closed on illegal shapes.
35
+ *
36
+ * @param {*} value
37
+ * @param {string} label - For error messages.
38
+ * @returns {Buffer|null} Decoded buffer, or null if value is nullish.
39
+ * @throws {ReleaseError} MISSING_PARAMETERS if shape is illegal.
40
+ */
41
+ function decodeBuffer(value, label) {
42
+ if (value == null) return null;
43
+ if (Buffer.isBuffer(value)) return value;
44
+ if (typeof value === 'object' && value.type === 'Buffer' && Array.isArray(value.data)) {
45
+ if (!value.data.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
46
+ throw new ReleaseError(
47
+ MISSING_PARAMETERS,
48
+ `illegal conflict ${label} byte array`,
49
+ { label },
50
+ );
51
+ }
52
+ return Buffer.from(value.data);
53
+ }
54
+ throw new ReleaseError(
55
+ MISSING_PARAMETERS,
56
+ `illegal conflict ${label} shape: expected Buffer or {type:'Buffer',data:[...]}`,
57
+ { label, received: typeof value },
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Decode and validate conflict buffers: fatal UTF-8, no NUL/control chars.
63
+ *
64
+ * @param {object} conflict
65
+ * @returns {{ base: Buffer|null, current: Buffer|null, generated: Buffer|null }}
66
+ */
67
+ function decodeAndValidateConflictBuffers(conflict) {
68
+ const base = decodeBuffer(conflict.base, 'base');
69
+ const current = decodeBuffer(conflict.current, 'current');
70
+ const generated = decodeBuffer(conflict.generated, 'generated');
71
+
72
+ for (const [label, buf] of [['base', base], ['current', current], ['generated', generated]]) {
73
+ if (!buf) continue;
74
+ assertSafeContent(buf, label);
75
+ }
76
+
77
+ return { base, current, generated };
78
+ }
79
+
80
+ /**
81
+ * Assert content is safe: valid UTF-8, no NUL, no control characters (except
82
+ * TAB/LF/CR), and within size limit.
83
+ */
84
+ function assertSafeContent(bytes, label) {
85
+ // Size check
86
+ if (bytes.length > MAX_TEMPLATE_BYTES) {
87
+ throw new ReleaseError(
88
+ FORBIDDEN_CONTENT_DETECTED,
89
+ `conflict ${label} exceeds size limit (${bytes.length} > ${MAX_TEMPLATE_BYTES})`,
90
+ { label, size: bytes.length, limit: MAX_TEMPLATE_BYTES },
91
+ );
92
+ }
93
+
94
+ // Fatal UTF-8 check
95
+ let text;
96
+ try {
97
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
98
+ } catch {
99
+ throw new ReleaseError(
100
+ FORBIDDEN_CONTENT_DETECTED,
101
+ `conflict ${label} is not valid UTF-8`,
102
+ { label },
103
+ );
104
+ }
105
+
106
+ // NUL and control character scan
107
+ for (let i = 0; i < text.length; i++) {
108
+ const code = text.charCodeAt(i);
109
+ if (code === 0) {
110
+ throw new ReleaseError(
111
+ FORBIDDEN_CONTENT_DETECTED,
112
+ `conflict ${label} contains NUL byte at offset ${i}`,
113
+ { label, offset: i },
114
+ );
115
+ }
116
+ // Allow TAB (0x09), LF (0x0A), CR (0x0D); reject other control chars
117
+ if (code < 0x20 && code !== 0x09 && code !== 0x0A && code !== 0x0D) {
118
+ throw new ReleaseError(
119
+ FORBIDDEN_CONTENT_DETECTED,
120
+ `conflict ${label} contains control character 0x${code.toString(16)} at offset ${i}`,
121
+ { label, offset: i, charCode: code },
122
+ );
123
+ }
124
+ }
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Sensitive content detection
129
+ // ---------------------------------------------------------------------------
130
+
131
+ const SENSITIVE_PATTERNS = Object.freeze([
132
+ /api[_-]?key\s*[=:]\s*["']?[A-Za-z0-9+/=_-]{16,}/i,
133
+ /secret[_-]?key\s*[=:]\s*["']?[A-Za-z0-9+/=_-]{16,}/i,
134
+ /password\s*[=:]\s*["']?[^\s"']{8,}/i,
135
+ /token\s*[=:]\s*["']?[A-Za-z0-9+/=_-]{16,}/i,
136
+ /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/,
137
+ /AKIA[0-9A-Z]{16}/,
138
+ /ghp_[A-Za-z0-9]{36}/,
139
+ ]);
140
+
141
+ function containsSensitivePattern(bytes) {
142
+ if (!bytes || bytes.length === 0) return false;
143
+ let content;
144
+ try { content = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { return false; }
145
+ return SENSITIVE_PATTERNS.some((p) => p.test(content));
146
+ }
147
+
148
+ function checkSensitiveConflict(artifact, decodedBuffers) {
149
+ const buffers = [decodedBuffers.base, decodedBuffers.current, decodedBuffers.generated].filter(Boolean);
150
+ for (const buf of buffers) {
151
+ if (containsSensitivePattern(buf)) {
152
+ return { sensitive: true, reason: 'conflict body contains sensitive data pattern' };
153
+ }
154
+ }
155
+ return { sensitive: false };
156
+ }
157
+
158
+ function isValidSensitiveAuthorization(auth) {
159
+ if (!auth || typeof auth !== 'object') return false;
160
+ return typeof auth.actor === 'string' && auth.actor.trim().length > 0
161
+ && typeof auth.reason === 'string' && auth.reason.trim().length > 0;
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Path safety
166
+ // ---------------------------------------------------------------------------
167
+
168
+ function assertSafeArtifactId(id) {
169
+ if (!id || typeof id !== 'string') {
170
+ throw new ReleaseError(PATH_UNSAFE, 'artifactId must be a non-empty string', { artifactId: id });
171
+ }
172
+ if (id.includes('/') || id.includes('\\') || id === '.' || id === '..'
173
+ || id.includes('..') || id.includes('\0')) {
174
+ throw new ReleaseError(PATH_UNSAFE, `artifactId contains unsafe path characters: "${id}"`, { artifactId: id });
175
+ }
176
+ if (!/^[A-Za-z0-9._-]+$/.test(id)) {
177
+ throw new ReleaseError(PATH_UNSAFE, `artifactId must be alphanumeric/dash/underscore/dot: "${id}"`, { artifactId: id });
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Assert no symlinks at any directory level from root to resolvedPath.
183
+ * Checks: .release-skill, resolution, artifactId dir, and the file itself.
184
+ */
185
+ async function assertNoSymlinksInPath(root, artifactId) {
186
+ const levels = [
187
+ join(root, '.release-skill'),
188
+ join(root, '.release-skill', 'resolution'),
189
+ join(root, '.release-skill', 'resolution', artifactId),
190
+ ];
191
+
192
+ for (const dir of levels) {
193
+ try {
194
+ const st = await lstat(dir);
195
+ if (st.isSymbolicLink()) {
196
+ throw new ReleaseError(PATH_UNSAFE, `directory is a symlink: ${dir}`, { path: dir });
197
+ }
198
+ } catch (err) {
199
+ if (err.code === 'ENOENT') continue; // doesn't exist yet, ok
200
+ if (err instanceof ReleaseError) throw err;
201
+ throw err;
202
+ }
203
+ }
204
+ }
205
+
206
+ async function assertSafeResolvedPath(root, artifactId, resolvedPath) {
207
+ const resolutionDir = resolve(root, '.release-skill', 'resolution', artifactId);
208
+ const resolved = resolve(resolvedPath);
209
+
210
+ await assertNoSymlinksInPath(root, artifactId);
211
+
212
+ const rel = relative(resolutionDir, resolved);
213
+ if (rel.startsWith('..') || isAbsolute(rel)) {
214
+ throw new ReleaseError(
215
+ PATH_UNSAFE,
216
+ `resolvedPath must be inside resolution directory ${resolutionDir}`,
217
+ { resolvedPath, resolutionDir },
218
+ );
219
+ }
220
+
221
+ // Exact filename check: must be <artifactId>.resolved
222
+ const filename = basename(resolvedPath);
223
+ if (filename !== `${artifactId}.resolved`) {
224
+ throw new ReleaseError(
225
+ PATH_UNSAFE,
226
+ `resolvedPath must be named "${artifactId}.resolved", got "${filename}"`,
227
+ { resolvedPath, expected: `${artifactId}.resolved`, actual: filename },
228
+ );
229
+ }
230
+ if (resolved !== resolve(resolutionDir, `${artifactId}.resolved`)) {
231
+ throw new ReleaseError(
232
+ PATH_UNSAFE,
233
+ 'resolvedPath must be the exact materialized resolution file',
234
+ { resolvedPath },
235
+ );
236
+ }
237
+
238
+ let st;
239
+ try {
240
+ st = await lstat(resolvedPath);
241
+ } catch (err) {
242
+ throw new ReleaseError(
243
+ MISSING_PARAMETERS,
244
+ `cannot stat resolved file: ${err.message}`,
245
+ { resolvedPath, cause: err.code },
246
+ );
247
+ }
248
+ if (st.isSymbolicLink()) {
249
+ throw new ReleaseError(PATH_UNSAFE, 'resolvedPath must not be a symlink', { resolvedPath });
250
+ }
251
+ if (!st.isFile()) {
252
+ throw new ReleaseError(PATH_UNSAFE, 'resolvedPath must be a regular file', { resolvedPath });
253
+ }
254
+
255
+ // Permission check: must be 0600 (owner read-write only)
256
+ const perms = st.mode & 0o777;
257
+ if (perms !== 0o600) {
258
+ throw new ReleaseError(
259
+ PATH_UNSAFE,
260
+ `resolvedPath must have 0600 permissions, got ${(perms).toString(8)}`,
261
+ { resolvedPath, permissions: perms },
262
+ );
263
+ }
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Conflict content formatting
268
+ // ---------------------------------------------------------------------------
269
+
270
+ function buildConflictTemplate(conflict, decodedBuffers) {
271
+ const base = decodedBuffers.base ? decodedBuffers.base.toString('utf8') : '';
272
+ const current = decodedBuffers.current ? decodedBuffers.current.toString('utf8') : '';
273
+ const generated = decodedBuffers.generated ? decodedBuffers.generated.toString('utf8') : '';
274
+
275
+ const lines = [
276
+ '<<<<<<< CURRENT (human)',
277
+ current,
278
+ '||||||| BASE',
279
+ base,
280
+ '=======',
281
+ generated,
282
+ '>>>>>>> GENERATED (producer)',
283
+ ];
284
+
285
+ const template = Buffer.from(lines.join('\n'), 'utf8');
286
+
287
+ // Size check on the assembled template
288
+ if (template.length > MAX_TEMPLATE_BYTES) {
289
+ throw new ReleaseError(
290
+ FORBIDDEN_CONTENT_DETECTED,
291
+ `assembled conflict template exceeds size limit (${template.length} > ${MAX_TEMPLATE_BYTES})`,
292
+ { size: template.length, limit: MAX_TEMPLATE_BYTES },
293
+ );
294
+ }
295
+
296
+ return template;
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Optimistic plan capture (injectable)
301
+ // ---------------------------------------------------------------------------
302
+
303
+ export async function withOptimisticPlanCapture(root, plan, expectedPlanDigest, fn, options = {}) {
304
+ const capture = options.captureBindings ?? defaultCaptureBindings;
305
+
306
+ const beforeCapture = await capture(root, plan);
307
+ const beforeBindings = canonicalJson(beforeCapture.bindings ?? {});
308
+
309
+ const planContentDigest = computePlanContentDigest(plan);
310
+ const expectedWithoutPrefix = expectedPlanDigest.startsWith('sha256:')
311
+ ? expectedPlanDigest.slice(7)
312
+ : expectedPlanDigest;
313
+ if (planContentDigest !== expectedWithoutPrefix) {
314
+ throw new ReleaseError(
315
+ PLAN_STALE,
316
+ 'plan content changed since this plan digest was issued',
317
+ { expectedPlanDigest, recomputedDigest: `sha256:${planContentDigest}` },
318
+ );
319
+ }
320
+
321
+ const planBindings = canonicalJson(plan.bindings ?? {});
322
+ if (beforeBindings !== planBindings) {
323
+ throw new ReleaseError(
324
+ PLAN_STALE,
325
+ 'captured bindings differ from plan bindings — plan is stale',
326
+ { expectedPlanDigest },
327
+ );
328
+ }
329
+
330
+ const result = await fn();
331
+
332
+ const afterCapture = await capture(root, plan);
333
+ const afterBindings = canonicalJson(afterCapture.bindings ?? {});
334
+ if (beforeBindings !== afterBindings) {
335
+ throw new ReleaseError(
336
+ PLAN_STALE,
337
+ 'plan bindings changed during resolution operation',
338
+ { expectedPlanDigest },
339
+ );
340
+ }
341
+
342
+ return result;
343
+ }
344
+
345
+ /**
346
+ * Default capture: calls inspectArtifacts({root, mode:'inspect'}) to re-read
347
+ * real bindings from the repository. Does NOT fall back to plan's own bindings.
348
+ * If inspect fails (no policy, no git, etc.), the error propagates — this is
349
+ * intentional: the caller must provide a real repository root or inject a
350
+ * stable capture function for unit tests.
351
+ */
352
+ async function defaultCaptureBindings(root, _plan) {
353
+ const result = await inspectArtifacts({ root, mode: 'inspect' });
354
+ return { bindings: result.plan.bindings };
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Plan digest computation (generic — strips only planDigest)
359
+ // ---------------------------------------------------------------------------
360
+
361
+ function computePlanContentDigest(plan) {
362
+ const { planDigest: _stripped, ...rest } = plan;
363
+ const plain = {};
364
+ for (const [k, v] of Object.entries(rest)) {
365
+ plain[k] = v;
366
+ }
367
+ return sha256Hex(canonicalJson(plain));
368
+ }
369
+
370
+ function computeResolutionDigest(plan) {
371
+ const { planDigest: _stripped, ...rest } = plan;
372
+ const plain = {};
373
+ for (const [k, v] of Object.entries(rest)) {
374
+ plain[k] = v;
375
+ }
376
+ return `sha256:${sha256Hex(canonicalJson(plain))}`;
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // Public API
381
+ // ---------------------------------------------------------------------------
382
+
383
+ export async function materializeResolution({
384
+ root,
385
+ plan,
386
+ planDigest,
387
+ artifactId,
388
+ sensitiveAuthorization,
389
+ } = {}) {
390
+ // --- Validate plan digest ---
391
+ const recomputed = computePlanContentDigest(plan);
392
+ const expectedHex = planDigest.startsWith('sha256:') ? planDigest.slice(7) : planDigest;
393
+ if (recomputed !== expectedHex) {
394
+ throw new ReleaseError(
395
+ PLAN_STALE,
396
+ 'plan digest does not match expected --plan-digest',
397
+ { expected: planDigest, recomputed: `sha256:${recomputed}` },
398
+ );
399
+ }
400
+
401
+ // --- Safe artifactId ---
402
+ assertSafeArtifactId(artifactId);
403
+
404
+ // --- Locate artifact ---
405
+ const artifact = (plan.artifacts ?? []).find((a) => a.id === artifactId);
406
+ if (!artifact) {
407
+ throw new ReleaseError(MISSING_PARAMETERS, `artifact "${artifactId}" not found in plan`, { artifactId });
408
+ }
409
+ if (artifact.status !== 'CONFLICT' && !artifact.conflict) {
410
+ throw new ReleaseError(MISSING_PARAMETERS, `artifact "${artifactId}" is not in CONFLICT status`, { artifactId, status: artifact.status });
411
+ }
412
+
413
+ // --- Decode conflict buffers (handles Buffer + JSON roundtrip shape) ---
414
+ const decodedBuffers = decodeAndValidateConflictBuffers(artifact.conflict ?? {});
415
+
416
+ // --- Sensitive scan on decoded buffers ---
417
+ const { sensitive, reason } = checkSensitiveConflict(artifact, decodedBuffers);
418
+ if (sensitive && !isValidSensitiveAuthorization(sensitiveAuthorization)) {
419
+ throw new ReleaseError(
420
+ SENSITIVE_CONFLICT,
421
+ `conflict for artifact "${artifactId}" contains sensitive data: ${reason}. ` +
422
+ 'Provide sensitiveAuthorization with non-empty actor and reason to override.',
423
+ { artifactId, reason },
424
+ );
425
+ }
426
+
427
+ // --- Build conflict template from decoded buffers ---
428
+ const template = buildConflictTemplate(artifact.conflict ?? {}, decodedBuffers);
429
+ const templateDigest = sha256Hex(template);
430
+
431
+ // --- Symlink check at all directory levels BEFORE creating anything ---
432
+ await assertNoSymlinksInPath(root, artifactId);
433
+
434
+ // --- Create resolution directory with restricted permissions ---
435
+ const resolutionDir = join(root, '.release-skill', 'resolution', artifactId);
436
+ await mkdir(resolutionDir, { recursive: true, mode: 0o700 });
437
+
438
+ // Re-assert permissions on existing directory
439
+ const dirStat = await stat(resolutionDir);
440
+ if ((dirStat.mode & 0o777) !== 0o700) {
441
+ await chmod(resolutionDir, 0o700);
442
+ }
443
+
444
+ // --- Write resolved file with exclusive open + restricted permissions ---
445
+ const resolvedPath = join(resolutionDir, `${artifactId}.resolved`);
446
+ const fh = await open(resolvedPath, 'wx', 0o600);
447
+ try {
448
+ await fh.write(template, 0, template.length);
449
+ await fh.sync();
450
+ } finally {
451
+ await fh.close();
452
+ }
453
+
454
+ return Object.freeze({
455
+ directory: resolutionDir,
456
+ resolvedPath,
457
+ metadata: Object.freeze({
458
+ artifactId,
459
+ templateDigest,
460
+ baseDigest: artifact.conflict?.baseDigest ?? null,
461
+ currentDigest: artifact.conflict?.currentDigest ?? null,
462
+ generatedDigest: artifact.conflict?.generatedDigest ?? null,
463
+ }),
464
+ });
465
+ }
466
+
467
+ export async function submitResolution({
468
+ root,
469
+ plan,
470
+ planDigest,
471
+ artifactId,
472
+ resolvedPath,
473
+ discardedHunkDigests = [],
474
+ captureOptions,
475
+ } = {}) {
476
+ assertSafeArtifactId(artifactId);
477
+ await assertSafeResolvedPath(root, artifactId, resolvedPath);
478
+
479
+ return withOptimisticPlanCapture(root, plan, planDigest, async () => {
480
+ const resolvedContent = await readAndValidateResolvedFile(resolvedPath);
481
+ assertConflictPointsResolved(plan, artifactId, resolvedContent);
482
+ validateDiscardedHunks(plan, artifactId, discardedHunkDigests);
483
+ verifyHumanHunksPreserved(plan, artifactId, resolvedContent, discardedHunkDigests);
484
+ return deriveResolvedPlan(plan, artifactId, planDigest, resolvedPath, resolvedContent, discardedHunkDigests);
485
+ }, captureOptions);
486
+ }
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // Internal
490
+ // ---------------------------------------------------------------------------
491
+
492
+ async function readAndValidateResolvedFile(resolvedPath) {
493
+ let content;
494
+ try {
495
+ content = await readFile(resolvedPath);
496
+ } catch (err) {
497
+ throw new ReleaseError(MISSING_PARAMETERS, `cannot read resolved file: ${err.message}`, { resolvedPath, cause: err.code });
498
+ }
499
+
500
+ assertSafeContent(content, 'resolved');
501
+
502
+ const text = content.toString('utf8');
503
+ if (text.includes('<<<<<<<') || text.includes('>>>>>>>') || text.includes('=======')) {
504
+ throw new ReleaseError(MISSING_PARAMETERS, 'resolved file still contains conflict markers — resolve before submitting', { resolvedPath });
505
+ }
506
+
507
+ return content;
508
+ }
509
+
510
+ function assertConflictPointsResolved(plan, artifactId, resolvedContent) {
511
+ const artifact = (plan.artifacts ?? []).find((a) => a.id === artifactId);
512
+ if (!artifact || !artifact.conflict) return;
513
+ if (resolvedContent.length === 0) {
514
+ throw new ReleaseError(MISSING_PARAMETERS, `resolved file for artifact "${artifactId}" is empty but conflict existed`, { artifactId });
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Validate discardedHunkDigests: must exist in plan (even if protectedHunks is
520
+ * empty → always reject) and be unique.
521
+ */
522
+ function validateDiscardedHunks(plan, artifactId, discardedHunkDigests) {
523
+ if (discardedHunkDigests.length === 0) return;
524
+
525
+ const artifact = (plan.artifacts ?? []).find((a) => a.id === artifactId);
526
+ const knownDigests = new Set(
527
+ (artifact?.protectedHunks ?? []).map((h) => h.hunkDigest),
528
+ );
529
+
530
+ const seen = new Set();
531
+ for (const digest of discardedHunkDigests) {
532
+ if (seen.has(digest)) {
533
+ throw new ReleaseError(MISSING_PARAMETERS, `duplicate discarded hunk digest: "${digest}"`, { artifactId, hunkDigest: digest });
534
+ }
535
+ seen.add(digest);
536
+
537
+ // Always reject if not found — even when knownDigests is empty
538
+ if (!knownDigests.has(digest)) {
539
+ throw new ReleaseError(
540
+ MISSING_PARAMETERS,
541
+ `discarded hunk digest not found in plan: "${digest}"`,
542
+ { artifactId, hunkDigest: digest, knownDigests: [...knownDigests] },
543
+ );
544
+ }
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Verify undiscarded protected human hunks are present in the resolved body.
550
+ * Strict byte-level inclusion — no trim, no string approximation.
551
+ * Range validation and currentDigest verification — fail closed on mismatch.
552
+ */
553
+ function verifyHumanHunksPreserved(plan, artifactId, resolvedContent, discardedHunkDigests) {
554
+ const artifact = (plan.artifacts ?? []).find((a) => a.id === artifactId);
555
+ if (!artifact?.protectedHunks || artifact.protectedHunks.length === 0) return;
556
+
557
+ const discarded = new Set(discardedHunkDigests);
558
+
559
+ // Decode current content from conflict (handles Buffer/JSON roundtrip)
560
+ const currentContent = decodeBuffer(artifact.conflict?.current, 'current');
561
+ if (!currentContent) {
562
+ throw new ReleaseError(
563
+ MISSING_PARAMETERS,
564
+ 'protected human hunks require current conflict bytes',
565
+ { artifactId },
566
+ );
567
+ }
568
+
569
+ for (const hunk of artifact.protectedHunks) {
570
+ if (discarded.has(hunk.hunkDigest)) continue;
571
+ if (!hunk.range) {
572
+ throw new ReleaseError(
573
+ MISSING_PARAMETERS,
574
+ 'protected human hunk is missing its byte range',
575
+ { artifactId, hunkDigest: hunk.hunkDigest },
576
+ );
577
+ }
578
+
579
+ const { start, length } = hunk.range;
580
+
581
+ // Range boundary check — fail closed
582
+ if (start < 0 || length < 0 || start + length > currentContent.length) {
583
+ throw new ReleaseError(
584
+ MISSING_PARAMETERS,
585
+ `protected hunk range out of bounds: start=${start}, length=${length}, content length=${currentContent.length}`,
586
+ { artifactId, hunkDigest: hunk.hunkDigest, range: hunk.range, contentLength: currentContent.length },
587
+ );
588
+ }
589
+
590
+ // Extract exact hunk bytes from current content
591
+ const hunkBytes = currentContent.slice(start, start + length);
592
+
593
+ // Verify currentDigest if provided — fail closed on mismatch
594
+ if (hunk.currentDigest) {
595
+ const actualDigest = sha256Hex(hunkBytes);
596
+ if (actualDigest !== hunk.currentDigest) {
597
+ throw new ReleaseError(
598
+ MISSING_PARAMETERS,
599
+ `protected hunk currentDigest mismatch: expected ${hunk.currentDigest}, got ${actualDigest}`,
600
+ { artifactId, hunkDigest: hunk.hunkDigest, expected: hunk.currentDigest, actual: actualDigest },
601
+ );
602
+ }
603
+ }
604
+
605
+ // Strict byte-level inclusion in resolved content — no trim
606
+ if (hunkBytes.length > 0) {
607
+ if (resolvedContent.indexOf(hunkBytes) === -1) {
608
+ throw new ReleaseError(
609
+ MISSING_PARAMETERS,
610
+ `protected human hunk not found in resolved body: "${hunk.hunkDigest}"`,
611
+ { artifactId, hunkDigest: hunk.hunkDigest },
612
+ );
613
+ }
614
+ }
615
+ }
616
+ }
617
+
618
+ // ---------------------------------------------------------------------------
619
+ // Plan derivation (phase 2)
620
+ // ---------------------------------------------------------------------------
621
+
622
+ function deriveResolvedPlan(plan, artifactId, oldPlanDigest, resolvedPath, resolvedContent, discardedHunkDigests) {
623
+ const resolvedDigest = sha256Hex(resolvedContent);
624
+ const resolutionRelPath = `.release-skill/resolution/${artifactId}/${artifactId}.resolved`;
625
+
626
+ const hunkDecisions = discardedHunkDigests.map((digest) => Object.freeze({
627
+ hunkDigest: digest,
628
+ action: 'discarded',
629
+ }));
630
+
631
+ const updatedArtifacts = (plan.artifacts ?? []).map((a) => {
632
+ if (a.id !== artifactId) return Object.freeze({ ...a });
633
+ return Object.freeze({
634
+ ...a,
635
+ status: 'RESOLVED',
636
+ safeToWrite: false,
637
+ resolvedDigest,
638
+ resolutionPath: resolutionRelPath,
639
+ hunkDecisions: Object.freeze(hunkDecisions),
640
+ allowedActions: ['inspect'],
641
+ });
642
+ });
643
+
644
+ const newPlan = Object.freeze({
645
+ apiVersion: plan.apiVersion,
646
+ operation: 'resolve',
647
+ bindings: Object.freeze({ ...(plan.bindings ?? {}) }),
648
+ artifacts: Object.freeze(updatedArtifacts),
649
+ safeToWrite: false,
650
+ targetUnchanged: true,
651
+ nextAction: Object.freeze({ command: 'artifacts inspect --plan-digest <new-digest>' }),
652
+ supersedesPlanDigest: oldPlanDigest,
653
+ planDigest: undefined,
654
+ });
655
+
656
+ const digest = computeResolutionDigest(newPlan);
657
+ return Object.freeze({ ...newPlan, planDigest: digest });
658
+ }