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,743 @@
1
+ /**
2
+ * Bootstrap adoption engine and downstream closure.
3
+ *
4
+ * Provides:
5
+ * - `planAdoption({ plan, policy, artifactId, currentEntries, generatedEntries, runProducer })`
6
+ * — compute multi-hunk protected diff, resolve adoption routes, run producer
7
+ * closure, and project downstream closure.
8
+ * - `discardBootstrapHunk({ adoptionPlan, currentEntries, artifactId, hunkDigest, expectedPlanDigest, actor, reason, action?, replacementBytes? })`
9
+ * — record a per-hunk discard/replace decision with actor + reason,
10
+ * re-read currentEntries to verify bytes, and derive a new plan digest.
11
+ *
12
+ * Adoption routes are exact route objects: `{target, sourceArtifact, mode}`.
13
+ * v1 supports mode `exact-copy`. String routes are rejected.
14
+ *
15
+ * Protected hunks: text files produce multiple hunks from line-level diff;
16
+ * binary files produce one whole-file hunk. Each hunk carries:
17
+ * `{artifactId, hunkDigest, baseDigest, currentDigest, candidateDigest, range}`.
18
+ *
19
+ * Convergence gate: all protected hunks must be either reproduced by the
20
+ * producer closure (with matching candidateDigest) or explicitly decided
21
+ * (discard/replace) before the plan can transition from `ADOPTION_REQUIRED`.
22
+ *
23
+ * Decision binding: `{artifactId, hunkDigest, baseDigest, currentDigest,
24
+ * candidateDigest, action, actor, reason, decisionDigest}` — deterministic,
25
+ * no timestamp or random fields.
26
+ *
27
+ * @module artifacts/adoption
28
+ */
29
+
30
+ import {
31
+ ReleaseError,
32
+ ADOPTION_AMBIGUOUS,
33
+ PLAN_STALE,
34
+ MISSING_PARAMETERS,
35
+ } from '../core/errors.mjs';
36
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
37
+ import { buildProducerGraph } from './graph.mjs';
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Public API
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Compute an adoption plan from the init/inspect plan and current worktree state.
45
+ *
46
+ * @param {object} options
47
+ * @param {object} options.plan - The init/inspect artifact plan.
48
+ * @param {object} options.policy - Validated artifact policy.
49
+ * @param {string} [options.artifactId] - Specific artifact to adopt.
50
+ * @param {Map<string, object>} options.currentEntries - Current worktree entries.
51
+ * @param {Map<string, object>} options.generatedEntries - Producer-generated entries.
52
+ * @param {Function} [options.runProducer] - Injected producer runner for closure.
53
+ * @returns {Promise<AdoptionPlan>} Frozen adoption plan.
54
+ * @throws {ReleaseError} ADOPTION_AMBIGUOUS if route resolution is ambiguous.
55
+ */
56
+ export async function planAdoption({
57
+ plan,
58
+ policy,
59
+ artifactId,
60
+ currentEntries,
61
+ generatedEntries,
62
+ runProducer,
63
+ } = {}) {
64
+ validateAdoptionRoutes(policy);
65
+ const graph = buildProducerGraph(policy);
66
+ let closure = [];
67
+ let targetArtifactId = null;
68
+ let sourceArtifactId = null;
69
+ let route = null;
70
+ let sourceCandidate = null;
71
+
72
+ if (artifactId) {
73
+ const target = policy.artifacts.find((a) => a.id === artifactId);
74
+ if (!target || target.type !== 'generated') {
75
+ throw new ReleaseError(
76
+ MISSING_PARAMETERS,
77
+ `adoption target "${artifactId}" must be a generated artifact`,
78
+ { artifactId },
79
+ );
80
+ }
81
+
82
+ const rootRoutes = (target.adoptionRoutes ?? []).filter((candidate) => candidate.target === '/');
83
+ if (rootRoutes.length !== 1) {
84
+ throw new ReleaseError(
85
+ ADOPTION_AMBIGUOUS,
86
+ `generated artifact "${artifactId}" requires exactly one adoption route for target "/"`,
87
+ { artifactId, candidates: rootRoutes },
88
+ );
89
+ }
90
+ route = rootRoutes[0];
91
+ sourceArtifactId = route.sourceArtifact;
92
+ const source = policy.artifacts.find((candidate) => candidate.id === sourceArtifactId);
93
+ if (!source || !target.sourceArtifacts?.includes(sourceArtifactId)) {
94
+ throw new ReleaseError(
95
+ ADOPTION_AMBIGUOUS,
96
+ `route for "${artifactId}" does not identify one registered direct source`,
97
+ { artifactId, sourceArtifactId },
98
+ );
99
+ }
100
+
101
+ const currentTarget = currentEntries?.get(artifactId);
102
+ const currentTargetBytes = extractEntryBytes(currentTarget);
103
+ if (!currentTargetBytes || currentTarget?.kind !== 'regular') {
104
+ throw new ReleaseError(
105
+ MISSING_PARAMETERS,
106
+ `exact-copy adoption target "${artifactId}" must be a content-bearing regular entry`,
107
+ { artifactId },
108
+ );
109
+ }
110
+ sourceCandidate = Object.freeze({
111
+ artifactId: sourceArtifactId,
112
+ path: source.sourcePath,
113
+ kind: 'regular',
114
+ type: 'blob',
115
+ mode: currentTarget.mode ?? '100644',
116
+ sha256: sha256Hex(currentTargetBytes),
117
+ size: currentTargetBytes.length,
118
+ bytes: Buffer.from(currentTargetBytes),
119
+ content: Buffer.from(currentTargetBytes),
120
+ });
121
+
122
+ closure = [artifactId, ...graph.downstreamClosure(artifactId)];
123
+ targetArtifactId = artifactId;
124
+ }
125
+
126
+ const protectedHunks = [];
127
+ for (const planArtifact of plan.artifacts ?? []) {
128
+ if (artifactId && !closure.includes(planArtifact.id)) continue;
129
+ const current = currentEntries?.get(planArtifact.id);
130
+ const generated = generatedEntries?.get(planArtifact.id);
131
+ const candidate = resolveCandidate(planArtifact, generated);
132
+ const hunks = collectProtectedHunks(planArtifact.id, current, candidate);
133
+ protectedHunks.push(...hunks);
134
+ }
135
+
136
+ let closureResults = null;
137
+ if (runProducer && artifactId) {
138
+ const inputSnapshot = new Map();
139
+ for (const [id, entry] of currentEntries ?? []) {
140
+ inputSnapshot.set(id, [entry]);
141
+ }
142
+ for (const [id, entry] of generatedEntries ?? []) {
143
+ if (!inputSnapshot.has(id)) {
144
+ inputSnapshot.set(id, [entry]);
145
+ }
146
+ }
147
+ const targetPlanPath = (plan.artifacts ?? []).find((item) => item.id === artifactId)?.path;
148
+ inputSnapshot.set(artifactId, [Object.freeze({
149
+ ...sourceCandidate,
150
+ path: projectionRelativePath(sourceCandidate.path, targetPlanPath),
151
+ })]);
152
+
153
+ closureResults = await runProducer({
154
+ artifactIds: closure,
155
+ inputSnapshot,
156
+ graph,
157
+ route,
158
+ sourceCandidate,
159
+ targetArtifactId,
160
+ });
161
+ }
162
+
163
+ const enrichedHunks = protectedHunks.map((hunk) => {
164
+ if (!closureResults) return Object.freeze({ ...hunk, reproduced: false });
165
+ const candidateManifest = closureResults.byArtifact?.get(hunk.artifactId);
166
+ if (!candidateManifest) return Object.freeze({ ...hunk, reproduced: false });
167
+ const outputs = candidateManifest.outputs ?? candidateManifest.entries ?? [];
168
+ const producedEntry = outputs.length === 1 ? outputs[0] : null;
169
+ if (!producedEntry) return Object.freeze({ ...hunk, reproduced: false });
170
+ const producedBytes = producedEntry.bytes ?? producedEntry.content;
171
+ if (!producedBytes) return Object.freeze({ ...hunk, reproduced: false });
172
+ const currentEntry = currentEntries?.get(hunk.artifactId);
173
+ const currentBytes = currentEntry?.bytes ?? currentEntry?.content;
174
+ if (!currentBytes) return Object.freeze({ ...hunk, reproduced: false });
175
+ // Whole-entry equality is stricter than matching only one shifted range and
176
+ // proves every protected hunk converged in the same producer run.
177
+ const reproduced = sha256Hex(producedBytes) === sha256Hex(currentBytes);
178
+ return Object.freeze({ ...hunk, reproduced });
179
+ });
180
+
181
+ const closureManifests = closureResults
182
+ ? Object.freeze(Object.fromEntries(
183
+ [...closureResults.byArtifact.entries()].map(([id, manifest]) => [
184
+ id,
185
+ Object.freeze({
186
+ artifactId: id,
187
+ implementationDigest: manifest.implementationDigest ?? closureResults.implementationDigest ?? null,
188
+ inputManifestDigest: manifest.inputManifestDigest ?? null,
189
+ outputManifestDigest: manifest.outputManifestDigest ?? null,
190
+ outputDigest: digestManifestOutputs(manifest.outputs ?? manifest.entries ?? []),
191
+ }),
192
+ ]),
193
+ ))
194
+ : undefined;
195
+
196
+ const hasTargetClosureEvidence = Boolean(
197
+ artifactId && closure.length > 0 && closure.every((id) => closureResults?.byArtifact?.has(id)),
198
+ );
199
+ const allReproduced = enrichedHunks.every((h) => h.reproduced);
200
+ const status = artifactId
201
+ ? (hasTargetClosureEvidence && allReproduced ? 'CONVERGED' : 'ADOPTION_REQUIRED')
202
+ : (enrichedHunks.length === 0 ? 'CLEAN' : 'ADOPTION_REQUIRED');
203
+ const producerImplementationDigest = closureManifests
204
+ ? `sha256:${sha256Hex(canonicalJson(Object.fromEntries(
205
+ Object.entries(closureManifests).map(([id, manifest]) => [id, manifest.implementationDigest]),
206
+ )))}`
207
+ : undefined;
208
+
209
+ const digestInput = {
210
+ planDigest: plan.planDigest,
211
+ targetArtifactId,
212
+ sourceArtifactId,
213
+ route,
214
+ transactionClosure: closure,
215
+ sourceCandidate,
216
+ closureManifests,
217
+ artifactPaths: Object.fromEntries(
218
+ (plan.artifacts ?? []).filter((item) => item.path).map((item) => [item.id, item.path]),
219
+ ),
220
+ };
221
+ return Object.freeze({
222
+ planDigest: deriveAdoptionDigest(digestInput, [], enrichedHunks),
223
+ status,
224
+ protectedHunks: Object.freeze(enrichedHunks),
225
+ hunkDecisions: Object.freeze([]),
226
+ transactionClosure: Object.freeze(closure),
227
+ targetArtifactId,
228
+ sourceArtifactId,
229
+ route,
230
+ sourceCandidate,
231
+ artifactPaths: Object.freeze(Object.fromEntries(
232
+ (plan.artifacts ?? []).filter((item) => item.path).map((item) => [item.id, item.path]),
233
+ )),
234
+ safeToWrite: false,
235
+ targetUnchanged: true,
236
+ ...(producerImplementationDigest ? { producerImplementationDigest } : {}),
237
+ ...(closureManifests ? { closureManifests } : {}),
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Record a discard or replace decision for a single protected hunk.
243
+ *
244
+ * Re-reads currentEntries to verify bytes have not changed since the plan
245
+ * was created. If bytes changed, the operation is rejected with PLAN_STALE.
246
+ *
247
+ * @param {object} options
248
+ * @param {object} options.adoptionPlan - The current adoption plan.
249
+ * @param {Map<string, object>} options.currentEntries - Current worktree entries.
250
+ * @param {string} options.artifactId - Artifact containing the hunk.
251
+ * @param {string} options.hunkDigest - Digest of the hunk to decide.
252
+ * @param {string} options.expectedPlanDigest - Expected plan digest (stale guard).
253
+ * @param {string} options.actor - Who made this decision.
254
+ * @param {string} options.reason - Why this decision was made.
255
+ * @param {'discard'|'replace'} [options.action='discard'] - Decision type.
256
+ * @param {Buffer} [options.replacementBytes] - New content for 'replace' action.
257
+ * @returns {Promise<AdoptionPlan>} Updated adoption plan with new digest.
258
+ * @throws {ReleaseError} PLAN_STALE if plan or hunk bytes changed.
259
+ */
260
+ export async function discardBootstrapHunk({
261
+ adoptionPlan,
262
+ currentEntries,
263
+ artifactId,
264
+ hunkDigest,
265
+ expectedPlanDigest,
266
+ actor,
267
+ reason,
268
+ action = 'discard',
269
+ replacementBytes,
270
+ } = {}) {
271
+ if (!['discard', 'replace'].includes(action) || !actor?.trim() || !reason?.trim()) {
272
+ throw new ReleaseError(
273
+ MISSING_PARAMETERS,
274
+ 'hunk decision requires discard|replace, actor, and reason',
275
+ { action },
276
+ );
277
+ }
278
+ // Stale guard: plan digest must match
279
+ if (adoptionPlan.planDigest !== expectedPlanDigest) {
280
+ throw new ReleaseError(
281
+ PLAN_STALE,
282
+ 'plan has changed since this adoption plan was created',
283
+ { expectedPlanDigest, actualPlanDigest: adoptionPlan.planDigest },
284
+ );
285
+ }
286
+
287
+ // Locate the hunk by hunkDigest
288
+ const hunk = adoptionPlan.protectedHunks.find(
289
+ (h) => h.artifactId === artifactId && h.hunkDigest === hunkDigest,
290
+ );
291
+
292
+ if (!hunk) {
293
+ throw new ReleaseError(
294
+ MISSING_PARAMETERS,
295
+ `protected hunk not found: artifact="${artifactId}", digest="${hunkDigest}"`,
296
+ { artifactId, hunkDigest },
297
+ );
298
+ }
299
+
300
+ // Re-read currentEntries and verify bytes at the hunk range
301
+ const currentEntry = currentEntries?.get(artifactId);
302
+ const currentBytes = currentEntry?.bytes ?? currentEntry?.content;
303
+ if (!currentBytes || currentEntry?.kind !== 'regular' || !hunk.range) {
304
+ throw new ReleaseError(
305
+ PLAN_STALE,
306
+ `current bytes are unavailable for hunk revalidation: artifact="${artifactId}"`,
307
+ { artifactId, hunkDigest },
308
+ );
309
+ }
310
+ const slice = currentBytes.slice(hunk.range.start, hunk.range.start + hunk.range.length);
311
+ const sliceDigest = sha256Hex(slice);
312
+ if (hunk.currentDigest && sliceDigest !== hunk.currentDigest) {
313
+ throw new ReleaseError(
314
+ PLAN_STALE,
315
+ `current bytes changed for hunk at artifact="${artifactId}" range=${hunk.range.start}+${hunk.range.length}`,
316
+ {
317
+ expectedCurrentDigest: hunk.currentDigest,
318
+ actualCurrentDigest: sliceDigest,
319
+ artifactId,
320
+ hunkDigest,
321
+ },
322
+ );
323
+ }
324
+
325
+ // Already decided
326
+ const alreadyDecided = adoptionPlan.hunkDecisions.find(
327
+ (d) => d.artifactId === artifactId && d.hunkDigest === hunkDigest,
328
+ );
329
+ if (alreadyDecided) {
330
+ throw new ReleaseError(
331
+ PLAN_STALE,
332
+ `hunk already decided: artifact="${artifactId}", digest="${hunkDigest}"`,
333
+ { artifactId, hunkDigest, existingDecision: alreadyDecided },
334
+ );
335
+ }
336
+
337
+ // Build decision record — deterministic, no timestamp/random
338
+ const candidateDigest = replacementBytes == null
339
+ ? (hunk.candidateDigest ?? null)
340
+ : sha256Hex(Buffer.from(replacementBytes));
341
+
342
+ const decision = Object.freeze({
343
+ artifactId,
344
+ hunkDigest,
345
+ baseDigest: hunk.baseDigest ?? null,
346
+ currentDigest: hunk.currentDigest ?? null,
347
+ candidateDigest,
348
+ action,
349
+ actor,
350
+ reason,
351
+ decisionDigest: computeDecisionDigest({
352
+ artifactId,
353
+ hunkDigest,
354
+ baseDigest: hunk.baseDigest,
355
+ currentDigest: hunk.currentDigest,
356
+ candidateDigest,
357
+ action,
358
+ actor,
359
+ reason,
360
+ }),
361
+ });
362
+
363
+ // Update decisions
364
+ const updatedDecisions = [...adoptionPlan.hunkDecisions, decision];
365
+
366
+ // Derive new plan digest
367
+ const newPlanDigest = deriveAdoptionDigest(
368
+ adoptionPlan,
369
+ updatedDecisions,
370
+ adoptionPlan.protectedHunks,
371
+ );
372
+
373
+ // Determine if all hunks are now decided
374
+ const allDecided = adoptionPlan.protectedHunks.every(
375
+ (h) => updatedDecisions.some(
376
+ (d) => d.artifactId === h.artifactId && d.hunkDigest === h.hunkDigest,
377
+ ),
378
+ );
379
+
380
+ return Object.freeze({
381
+ ...adoptionPlan,
382
+ planDigest: newPlanDigest,
383
+ hunkDecisions: Object.freeze(updatedDecisions),
384
+ // A human decision resolves protection, but does not fabricate producer
385
+ // convergence. Phase 3 must apply/re-produce before CONVERGED is possible.
386
+ status: allDecided ? 'DECISIONS_COMPLETE' : 'ADOPTION_REQUIRED',
387
+ });
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Internal helpers
392
+ // ---------------------------------------------------------------------------
393
+
394
+ /**
395
+ * Validate that all adoption routes are exact route objects, not strings.
396
+ *
397
+ * @param {object} policy
398
+ * @throws {ReleaseError} ARTIFACT_POLICY_INVALID if string routes found.
399
+ */
400
+ function validateAdoptionRoutes(policy) {
401
+ for (const artifact of policy.artifacts) {
402
+ if (artifact.type !== 'generated') continue;
403
+ const routes = artifact.adoptionRoutes ?? [];
404
+ for (const route of routes) {
405
+ if (typeof route === 'string') {
406
+ throw new ReleaseError(
407
+ 'ARTIFACT_POLICY_INVALID',
408
+ `artifact "${artifact.id}": adoptionRoutes must be objects {target, sourceArtifact, mode}, got string "${route}"`,
409
+ { artifactId: artifact.id, route },
410
+ );
411
+ }
412
+ if (!route.target || !route.sourceArtifact || !route.mode) {
413
+ throw new ReleaseError(
414
+ 'ARTIFACT_POLICY_INVALID',
415
+ `artifact "${artifact.id}": adoptionRoute must have {target, sourceArtifact, mode}`,
416
+ { artifactId: artifact.id, route },
417
+ );
418
+ }
419
+ if (!(artifact.sourceArtifacts ?? []).includes(route.sourceArtifact)) {
420
+ throw new ReleaseError(
421
+ 'ARTIFACT_POLICY_INVALID',
422
+ `artifact "${artifact.id}": adoption route source must be a direct sourceArtifact`,
423
+ { artifactId: artifact.id, route },
424
+ );
425
+ }
426
+ }
427
+ }
428
+ }
429
+
430
+ function digestManifestOutputs(outputs) {
431
+ return `sha256:${sha256Hex(canonicalJson(outputs.map((entry) => ({
432
+ path: entry.path ?? null,
433
+ type: entry.type ?? entry.kind ?? null,
434
+ mode: entry.mode ?? null,
435
+ sha256: entry.sha256 ?? sha256Hex(entry.bytes ?? entry.content ?? Buffer.alloc(0)),
436
+ size: entry.size ?? (entry.bytes ?? entry.content ?? Buffer.alloc(0)).length,
437
+ }))))}`;
438
+ }
439
+
440
+ function projectionRelativePath(sourcePath, targetPath) {
441
+ const sourceParts = String(sourcePath ?? '').split('/').filter(Boolean);
442
+ const targetParts = String(targetPath ?? '').split('/').filter(Boolean);
443
+ let common = 0;
444
+ while (common < sourceParts.length && common < targetParts.length &&
445
+ sourceParts[sourceParts.length - 1 - common] === targetParts[targetParts.length - 1 - common]) {
446
+ common += 1;
447
+ }
448
+ return common > 0
449
+ ? sourceParts.slice(sourceParts.length - common).join('/')
450
+ : sourceParts.at(-1);
451
+ }
452
+
453
+ /**
454
+ * Resolve the candidate content for an artifact.
455
+ * Prefers firstCandidate from the plan, falls back to generated entry.
456
+ */
457
+ function resolveCandidate(planArtifact, generatedEntry) {
458
+ if (planArtifact.firstCandidate) {
459
+ const fc = planArtifact.firstCandidate;
460
+ const bytes = decodePlanBytes(fc);
461
+ if (bytes) {
462
+ return {
463
+ kind: 'regular',
464
+ bytes,
465
+ content: bytes,
466
+ sha256: fc.sha256 ?? sha256Hex(bytes),
467
+ };
468
+ }
469
+ }
470
+ return generatedEntry;
471
+ }
472
+
473
+ function decodePlanBytes(value) {
474
+ if (Buffer.isBuffer(value.bytes)) return Buffer.from(value.bytes);
475
+ if (typeof value.bytesBase64 === 'string') return Buffer.from(value.bytesBase64, 'base64');
476
+ if (value.bytes?.type === 'Buffer' && Array.isArray(value.bytes.data)) {
477
+ return Buffer.from(value.bytes.data);
478
+ }
479
+ return null;
480
+ }
481
+
482
+ /**
483
+ * Extract bytes from an entry for hunk comparison.
484
+ */
485
+ function extractEntryBytes(entry) {
486
+ if (!entry || entry.kind === 'absent') return null;
487
+ if (entry.bytes) return entry.bytes;
488
+ if (entry.content) return entry.content;
489
+ return null;
490
+ }
491
+
492
+ /**
493
+ * Compute protected hunks from current vs candidate comparison.
494
+ *
495
+ * For text files: produces multiple hunks from line-level diff.
496
+ * For binary files: produces one whole-file hunk.
497
+ *
498
+ * Each hunk: {artifactId, hunkDigest, baseDigest, currentDigest, candidateDigest, range}
499
+ */
500
+ function collectProtectedHunks(artifactId, current, candidate) {
501
+ const currentBytes = extractEntryBytes(current);
502
+ const candidateBytes = extractEntryBytes(candidate);
503
+
504
+ const currentSha = currentBytes ? sha256Hex(currentBytes) : (current?.sha256 ?? null);
505
+ const candidateSha = candidateBytes ? sha256Hex(candidateBytes) : (candidate?.sha256 ?? null);
506
+
507
+ // Both absent → no hunk
508
+ if (!currentSha && !candidateSha) return [];
509
+
510
+ // Same content → no protected hunk
511
+ if (currentSha && candidateSha && currentSha === candidateSha) return [];
512
+
513
+ // If current is absent, no protected hunks (new artifact)
514
+ if (!currentBytes) return [];
515
+
516
+ // Determine if content is text or binary
517
+ const isText = currentBytes && isTextContent(currentBytes)
518
+ && (!candidateBytes || isTextContent(candidateBytes));
519
+
520
+ if (isText && currentBytes && candidateBytes) {
521
+ return computeTextHunks(artifactId, currentBytes, candidateBytes);
522
+ }
523
+
524
+ // Binary or single-side: one whole-file hunk
525
+ return [computeWholeFileHunk(artifactId, currentBytes, candidateBytes)];
526
+ }
527
+
528
+ /**
529
+ * Check if content is likely text.
530
+ * Binary if: contains null byte, or >30% non-printable/non-whitespace bytes.
531
+ */
532
+ function isTextContent(bytes) {
533
+ if (bytes.length === 0) return true;
534
+ try {
535
+ new TextDecoder('utf-8', { fatal: true }).decode(bytes);
536
+ } catch {
537
+ return false;
538
+ }
539
+ const check = bytes.length > 8192 ? bytes.subarray(0, 8192) : bytes;
540
+ let nonPrintable = 0;
541
+ for (let i = 0; i < check.length; i++) {
542
+ const b = check[i];
543
+ if (b === 0) return false; // null byte → definitely binary
544
+ // Count non-printable (except common whitespace: tab, LF, CR)
545
+ if (b < 0x20 && b !== 0x09 && b !== 0x0a && b !== 0x0d) {
546
+ nonPrintable++;
547
+ }
548
+ if (b === 0x7f) nonPrintable++;
549
+ }
550
+ // If >30% non-printable, treat as binary
551
+ return nonPrintable / check.length <= 0.30;
552
+ }
553
+
554
+ /**
555
+ * Compute text hunks from line-level diff between current and candidate.
556
+ */
557
+ function computeTextHunks(artifactId, currentBytes, candidateBytes) {
558
+ const currentStr = currentBytes.toString('utf8');
559
+ const candidateStr = candidateBytes.toString('utf8');
560
+
561
+ const currentLines = currentStr.split('\n');
562
+ const candidateLines = candidateStr.split('\n');
563
+
564
+ // Find changed line indices
565
+ const changedLines = computeChangedLines(candidateLines, currentLines);
566
+
567
+ if (changedLines.length === 0) return [];
568
+
569
+ // Group into contiguous hunks
570
+ const hunkRanges = groupContiguousLines(changedLines, currentLines);
571
+ const candidateDigest = sha256Hex(candidateBytes);
572
+
573
+ return hunkRanges.map((hunkRange) => {
574
+ const { startByte, length } = hunkRange;
575
+ const hunkBytes = currentBytes.slice(startByte, startByte + length);
576
+ const hunkDigest = `sha256:${sha256Hex(`${artifactId}:hunk:${startByte}:${length}:${sha256Hex(hunkBytes)}`)}`;
577
+
578
+ return Object.freeze({
579
+ artifactId,
580
+ hunkDigest,
581
+ baseDigest: null, // bootstrap: no base
582
+ currentDigest: sha256Hex(hunkBytes),
583
+ candidateDigest,
584
+ range: Object.freeze({ start: startByte, length }),
585
+ });
586
+ });
587
+ }
588
+
589
+ /**
590
+ * Compute which line indices differ between produced and current.
591
+ * Returns array of 0-based line indices in current that are changed.
592
+ */
593
+ function computeChangedLines(producedLines, currentLines) {
594
+ // Find longest common prefix/suffix for each line position
595
+ const maxLen = Math.max(producedLines.length, currentLines.length);
596
+ const changed = [];
597
+
598
+ // Use simple LCS-based approach: find matching lines
599
+ const matched = new Set();
600
+ let pi = 0;
601
+
602
+ for (let ci = 0; ci < currentLines.length; ci++) {
603
+ let found = false;
604
+ for (let pj = pi; pj < producedLines.length; pj++) {
605
+ if (producedLines[pj] === currentLines[ci]) {
606
+ matched.add(ci);
607
+ pi = pj + 1;
608
+ found = true;
609
+ break;
610
+ }
611
+ }
612
+ if (!found && ci < producedLines.length) {
613
+ // Check if this line exists later in produced (not a pure add)
614
+ const existsLater = producedLines.includes(currentLines[ci]);
615
+ if (!existsLater) {
616
+ changed.push(ci);
617
+ }
618
+ } else if (!found) {
619
+ // Extra lines in current (beyond produced length)
620
+ changed.push(ci);
621
+ }
622
+ }
623
+
624
+ // Also check for lines that changed content (same position, different content)
625
+ for (let i = 0; i < Math.min(producedLines.length, currentLines.length); i++) {
626
+ if (!matched.has(i) && producedLines[i] !== currentLines[i]) {
627
+ if (!changed.includes(i)) {
628
+ changed.push(i);
629
+ }
630
+ }
631
+ }
632
+
633
+ changed.sort((a, b) => a - b);
634
+ return changed;
635
+ }
636
+
637
+ /**
638
+ * Group changed line indices into contiguous hunk ranges with byte offsets.
639
+ */
640
+ function groupContiguousLines(changedLines, lines) {
641
+ if (changedLines.length === 0) return [];
642
+
643
+ // Pre-compute line byte offsets
644
+ const lineOffsets = [];
645
+ let offset = 0;
646
+ const lastLine = lines.length - 1;
647
+ for (let i = 0; i < lines.length; i++) {
648
+ lineOffsets.push(offset);
649
+ const lineBytes = Buffer.byteLength(lines[i], 'utf8');
650
+ // Last empty line (from split on trailing \n) has no actual bytes
651
+ offset += (i === lastLine && lines[i] === '') ? 0 : lineBytes + 1;
652
+ }
653
+
654
+ function endOffset(lineIdx) {
655
+ const lineBytes = Buffer.byteLength(lines[lineIdx], 'utf8');
656
+ // Last empty line: ends at the previous line's end
657
+ if (lineIdx === lastLine && lines[lineIdx] === '') {
658
+ return lineOffsets[lineIdx];
659
+ }
660
+ return lineOffsets[lineIdx] + lineBytes + 1;
661
+ }
662
+
663
+ const ranges = [];
664
+ let groupStart = changedLines[0];
665
+ let groupEnd = changedLines[0];
666
+
667
+ for (let i = 1; i < changedLines.length; i++) {
668
+ if (changedLines[i] === groupEnd + 1) {
669
+ groupEnd = changedLines[i];
670
+ } else {
671
+ ranges.push({
672
+ startByte: lineOffsets[groupStart],
673
+ length: endOffset(groupEnd) - lineOffsets[groupStart],
674
+ });
675
+ groupStart = changedLines[i];
676
+ groupEnd = changedLines[i];
677
+ }
678
+ }
679
+
680
+ // Emit last group
681
+ ranges.push({
682
+ startByte: lineOffsets[groupStart],
683
+ length: endOffset(groupEnd) - lineOffsets[groupStart],
684
+ });
685
+
686
+ return ranges;
687
+ }
688
+
689
+ /**
690
+ * Compute a single whole-file hunk (for binary or non-text content).
691
+ */
692
+ function computeWholeFileHunk(artifactId, currentBytes, candidateBytes) {
693
+ const currentSha = currentBytes ? sha256Hex(currentBytes) : null;
694
+ const candidateSha = candidateBytes ? sha256Hex(candidateBytes) : null;
695
+ const length = currentBytes ? currentBytes.length : 0;
696
+
697
+ const hunkDigest = `sha256:${sha256Hex(`${artifactId}:whole:${currentSha ?? 'absent'}:${candidateSha ?? 'absent'}`)}`;
698
+
699
+ return Object.freeze({
700
+ artifactId,
701
+ hunkDigest,
702
+ baseDigest: null,
703
+ currentDigest: currentSha,
704
+ candidateDigest: candidateSha,
705
+ range: Object.freeze({ start: 0, length }),
706
+ });
707
+ }
708
+
709
+ /**
710
+ * Compute a deterministic digest for a hunk decision.
711
+ * No timestamp or random fields — pure deterministic.
712
+ */
713
+ function computeDecisionDigest(decision) {
714
+ return `sha256:${sha256Hex(canonicalJson({
715
+ artifactId: decision.artifactId,
716
+ hunkDigest: decision.hunkDigest,
717
+ baseDigest: decision.baseDigest ?? null,
718
+ currentDigest: decision.currentDigest ?? null,
719
+ candidateDigest: decision.candidateDigest ?? null,
720
+ action: decision.action,
721
+ actor: decision.actor,
722
+ reason: decision.reason,
723
+ }))}`;
724
+ }
725
+
726
+ /**
727
+ * Derive a new adoption plan digest from decisions and protected hunks.
728
+ */
729
+ function deriveAdoptionDigest(plan, decisions, hunks) {
730
+ const canonical = canonicalJson({
731
+ basePlanDigest: plan.planDigest ?? plan.plan?.planDigest,
732
+ targetArtifactId: plan.targetArtifactId ?? null,
733
+ sourceArtifactId: plan.sourceArtifactId ?? null,
734
+ route: plan.route ?? null,
735
+ transactionClosure: plan.transactionClosure ?? [],
736
+ sourceCandidateDigest: plan.sourceCandidate?.sha256 ?? null,
737
+ closureManifests: plan.closureManifests ?? null,
738
+ artifactPaths: plan.artifactPaths ?? null,
739
+ decisions: decisions.map((d) => d.decisionDigest ?? d),
740
+ hunkDigests: hunks.map((h) => h.hunkDigest ?? h.digest),
741
+ });
742
+ return `sha256:${sha256Hex(canonical)}`;
743
+ }