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,72 @@
1
+ /**
2
+ * Safe filesystem backend — JS loader, capability probe, and directory handle.
3
+ *
4
+ * Loads the native Node-API addon that provides no-follow/openat relative
5
+ * directory primitives. The addon must be explicitly built with `native:build`
6
+ * or loaded from a prebuild addon registered in prebuilds.json.
7
+ *
8
+ * There is NO automatic install/postinstall native build. If the addon is
9
+ * not available, all mutating commands fail closed with SAFE_WRITE_UNAVAILABLE.
10
+ *
11
+ * @module artifacts/safe-fs
12
+ */
13
+
14
+ import { realpath } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+
17
+ import { ReleaseError, SAFE_WRITE_UNAVAILABLE } from '../core/errors.mjs';
18
+
19
+ import {
20
+ loadNativeAddon,
21
+ createBackend,
22
+ } from './safe-fs-backend-internal.mjs';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Public API
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Load the safe filesystem backend.
30
+ *
31
+ * The public entry point does NOT accept `_testAddon` or any test hooks.
32
+ * All addon loading goes through `loadNativeAddon` which validates the
33
+ * addon path, lstat identity, and production manifest integrity.
34
+ *
35
+ * Tests that need a fake addon must import `createBackend` directly from
36
+ * `./safe-fs-backend-internal.mjs`.
37
+ */
38
+ export async function loadSafeFs(options = {}) {
39
+ const addon = loadNativeAddon(options.addonPath);
40
+ return createBackend(addon);
41
+ }
42
+
43
+ /**
44
+ * Hard gate: load safe filesystem primitives and verify they work.
45
+ * Throws SAFE_WRITE_UNAVAILABLE if the native addon is not available or
46
+ * the real probe fails. Callers cannot proceed with writes based on
47
+ * platform name alone.
48
+ */
49
+ export async function requireSafeFs(options = {}) {
50
+ const backend = await loadSafeFs(options);
51
+
52
+ // Real probe — creates, writes, renames, reads back actual data.
53
+ // Only realpath the default system tmpdir (macOS /var -> /private/var safety).
54
+ // Explicit user-provided root is NOT realpath'd — openRoot will reject
55
+ // ancestor symlinks via O_NOFOLLOW segment walk (C1).
56
+ let root;
57
+ if (options.root) {
58
+ root = options.root;
59
+ } else {
60
+ root = await realpath(tmpdir());
61
+ }
62
+ const result = await backend.probe(root);
63
+
64
+ if (!result.supported) {
65
+ throw new ReleaseError(
66
+ SAFE_WRITE_UNAVAILABLE,
67
+ 'safe write primitives are not functional on this platform',
68
+ );
69
+ }
70
+
71
+ return backend;
72
+ }
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Artifact state algebra: four-dimensional classification of artifact entries.
3
+ *
4
+ * Classifies an artifact by comparing its base (accepted), current (working
5
+ * tree or commit), and generated (producer output) entries across four
6
+ * dimensions: existence, type, mode, and content.
7
+ *
8
+ * @module artifacts/state
9
+ */
10
+
11
+ import { ReleaseError, PATH_UNSAFE, STRUCTURE_INVALID } from '../core/errors.mjs';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Status priority (lower index = higher priority = shown first)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const PRIORITY = Object.freeze([
18
+ 'BASE_UNAVAILABLE',
19
+ 'POLICY_INVALID',
20
+ 'POLICY_CHANGE_PENDING',
21
+ 'ISOLATION_UNAVAILABLE',
22
+ 'PATH_UNSAFE',
23
+ 'PRODUCER_SCOPE_VIOLATION',
24
+ 'PRODUCER_NONDETERMINISTIC',
25
+ 'STRUCTURE_INVALID',
26
+ 'CONFLICT',
27
+ 'ADOPTION_REQUIRED',
28
+ 'MERGEABLE',
29
+ 'GENERATOR_CHANGED',
30
+ 'HUMAN_CHANGED',
31
+ 'NEW',
32
+ 'CLEAN',
33
+ ]);
34
+
35
+ const PRIORITY_MAP = new Map(PRIORITY.map((s, i) => [s, i]));
36
+
37
+ /**
38
+ * Get the numeric priority index for a status (lower = higher priority).
39
+ *
40
+ * @param {string} status
41
+ * @returns {number}
42
+ */
43
+ export function statusPriority(status) {
44
+ return PRIORITY_MAP.get(status) ?? PRIORITY.length;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Entry equality
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Check if two artifact entries are equal across all four dimensions:
53
+ * existence, type, mode, and content.
54
+ *
55
+ * Absent entries are compared by identity (both absent = equal).
56
+ * Regular entries are compared by type, mode, path, and sha256.
57
+ * Tree entries are compared by manifestDigest.
58
+ *
59
+ * @param {object|null} a
60
+ * @param {object|null} b
61
+ * @returns {boolean}
62
+ */
63
+ function entriesEqual(a, b) {
64
+ // Both absent
65
+ if (!a && !b) return true;
66
+ // One absent, one present
67
+ if (!a || !b) return false;
68
+ // Both present
69
+ if (a.type !== b.type) return false;
70
+ if (a.mode !== b.mode) return false;
71
+ if (a.path !== b.path) return false;
72
+ // Tree comparison: use manifestDigest for full tree equality
73
+ if (a.type === 'tree' && b.type === 'tree') {
74
+ return a.manifestDigest === b.manifestDigest;
75
+ }
76
+ if (a.sha256 !== b.sha256) return false;
77
+ return true;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Entry validation
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * Validate that an entry does not use a dangerous filesystem type
86
+ * (symlink, hardlink, special file).
87
+ *
88
+ * @param {object|null} entry
89
+ * @param {string} label - Label for error messages (e.g. 'current', 'generated').
90
+ * @throws {ReleaseError} PATH_UNSAFE on dangerous types.
91
+ */
92
+ function validateEntry(entry, label) {
93
+ if (!entry || entry.kind === 'absent') return;
94
+ if (entry.kind === 'symlink' || entry.kind === 'special') {
95
+ throw new ReleaseError(
96
+ PATH_UNSAFE,
97
+ `dangerous entry type "${entry.kind}" in ${label}`,
98
+ { path: entry.path, label },
99
+ );
100
+ }
101
+ // Reject symlink mode
102
+ if (entry.mode === '120000') {
103
+ throw new ReleaseError(
104
+ PATH_UNSAFE,
105
+ `symlink mode 120000 rejected in ${label}`,
106
+ { path: entry.path, label },
107
+ );
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Conflict analysis
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Analyze differences between two entries relative to a base entry.
117
+ *
118
+ * @param {object|null} base - Base entry (may be null if absent).
119
+ * @param {object} compare - The entry being analyzed.
120
+ * @param {string} label - Label for the change list.
121
+ * @returns {string[]} Array of human-readable change descriptions.
122
+ */
123
+ function diffAgainstBase(base, compare, label) {
124
+ const changes = [];
125
+
126
+ if (!base) {
127
+ changes.push(`${label}: new ${compare.kind ?? compare.type} "${compare.path ?? '(unknown)'}"`);
128
+ return changes;
129
+ }
130
+
131
+ if (!compare) {
132
+ changes.push(`${label}: removed "${base.path ?? '(unknown)'}"`);
133
+ return changes;
134
+ }
135
+
136
+ if (base.type !== compare.type) {
137
+ changes.push(`${label}: type ${base.type} → ${compare.type}`);
138
+ }
139
+ if (base.mode !== compare.mode) {
140
+ changes.push(`${label}: mode ${base.mode} → ${compare.mode}`);
141
+ }
142
+ if (base.sha256 !== compare.sha256) {
143
+ changes.push(`${label}: content changed`);
144
+ }
145
+
146
+ return changes;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Allowed actions
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /**
154
+ * Compute allowed actions for an artifact decision.
155
+ *
156
+ * @param {string} status - Decision status.
157
+ * @param {object|null} current - Current entry.
158
+ * @param {string} ownership - 'human' | 'generator' | 'system'.
159
+ * @returns {string[]}
160
+ */
161
+ function computeAllowedActions(status, current, ownership) {
162
+ switch (status) {
163
+ case 'CLEAN':
164
+ return ['skip', 'inspect'];
165
+
166
+ case 'NEW':
167
+ return ownership === 'generator' ? ['adopt', 'inspect'] : ['skip', 'inspect'];
168
+
169
+ case 'GENERATOR_CHANGED':
170
+ return ['accept', 'inspect'];
171
+
172
+ case 'HUMAN_CHANGED':
173
+ return ['skip', 'inspect'];
174
+
175
+ case 'MERGEABLE':
176
+ return ['merge', 'accept', 'skip', 'inspect'];
177
+
178
+ case 'ADOPTION_REQUIRED':
179
+ return ['adopt', 'skip'];
180
+
181
+ case 'CONFLICT':
182
+ return ['merge', 'skip'];
183
+
184
+ default:
185
+ return ['skip', 'inspect'];
186
+ }
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Public API
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Classify an artifact by comparing base, current, and generated entries.
195
+ *
196
+ * The four dimensions of comparison are:
197
+ * 1. **Existence** — absent vs present
198
+ * 2. **Type** — blob vs tree
199
+ * 3. **Mode** — 100644 vs 100755
200
+ * 4. **Content** — sha256 digest (or manifestDigest for trees)
201
+ *
202
+ * When `producerRelation` is provided, the producer's implementation and
203
+ * input drift are factored into the classification:
204
+ * - `implementationChanged: true` → at least GENERATOR_CHANGED
205
+ * - `inputChanged: true` → at least GENERATOR_CHANGED
206
+ *
207
+ * For generator-owned areas (`ownership='generator'` with `projection='write'`),
208
+ * any human delta not reproduced by the exact generated projection requires
209
+ * ADOPTION_REQUIRED (Design §10.1).
210
+ *
211
+ * @param {object} options
212
+ * @param {object|null} options.base - Base (accepted) entry, or `{ kind: 'absent' }`.
213
+ * @param {object|null} options.current - Current (worktree) entry, or `{ kind: 'absent' }`.
214
+ * @param {object|null} options.generated - Generated (producer) entry, or `{ kind: 'absent' }`.
215
+ * @param {'human'|'generator'|'system'} [options.ownership='human'] - Path ownership.
216
+ * @param {'read'|'write'} [options.projection='read'] - Projection scope.
217
+ * @param {object} [options.producerRelation] - Producer drift flags.
218
+ * @param {boolean} [options.producerRelation.implementationChanged=false]
219
+ * @param {boolean} [options.producerRelation.inputChanged=false]
220
+ * @param {Array<object>} [options.projections] - Required for mixed ownership.
221
+ * @returns {{ status: string, priority: number, safeToWrite: boolean, allowedActions: string[], humanChanges?: string[], generatorChanges?: string[] }}
222
+ */
223
+ export function classifyArtifact({
224
+ base,
225
+ current,
226
+ generated,
227
+ ownership = 'human',
228
+ projection = 'read',
229
+ producerRelation,
230
+ projections,
231
+ } = {}) {
232
+ if (ownership === 'mixed') {
233
+ if (!Array.isArray(projections) || projections.length === 0) {
234
+ throw new ReleaseError(
235
+ STRUCTURE_INVALID,
236
+ 'mixed artifact requires non-empty projections',
237
+ { field: 'projections' },
238
+ );
239
+ }
240
+
241
+ const ids = new Set();
242
+ const decisions = [];
243
+ for (const item of projections) {
244
+ if (!item || typeof item.id !== 'string' || item.id.length === 0 || ids.has(item.id)) {
245
+ throw new ReleaseError(
246
+ STRUCTURE_INVALID,
247
+ 'mixed projection ids must be non-empty and unique',
248
+ { field: 'projections.id', projectionId: item?.id },
249
+ );
250
+ }
251
+ if (item.ownership !== 'human' && item.ownership !== 'generator') {
252
+ throw new ReleaseError(
253
+ STRUCTURE_INVALID,
254
+ 'mixed projection ownership must be human or generator',
255
+ { field: 'projections.ownership', projectionId: item.id },
256
+ );
257
+ }
258
+ ids.add(item.id);
259
+ decisions.push(Object.freeze({
260
+ id: item.id,
261
+ decision: classifyArtifact({
262
+ base: item.base,
263
+ current: item.current,
264
+ generated: item.generated,
265
+ ownership: item.ownership,
266
+ projection: item.ownership === 'generator' ? 'write' : 'read',
267
+ producerRelation: item.producerRelation,
268
+ }),
269
+ }));
270
+ }
271
+
272
+ const winner = decisions.reduce((best, item) =>
273
+ item.decision.priority < best.decision.priority ? item : best);
274
+ const actionSets = decisions.map((item) => new Set(item.decision.allowedActions));
275
+ const allowedActions = [...actionSets[0]].filter((action) =>
276
+ actionSets.every((set) => set.has(action)));
277
+ if (!allowedActions.includes('inspect')) allowedActions.push('inspect');
278
+
279
+ return Object.freeze({
280
+ status: winner.decision.status,
281
+ priority: winner.decision.priority,
282
+ safeToWrite: decisions.every((item) => item.decision.safeToWrite),
283
+ allowedActions: Object.freeze(allowedActions),
284
+ projectionDecisions: Object.freeze(decisions),
285
+ });
286
+ }
287
+
288
+ // Validate entries for dangerous types
289
+ validateEntry(current, 'current');
290
+ validateEntry(generated, 'generated');
291
+
292
+ // Normalize: extract "has content" from kind
293
+ const hasBase = base && base.kind !== 'absent';
294
+ const hasCurrent = current && current.kind !== 'absent';
295
+ const hasGenerated = generated && generated.kind !== 'absent';
296
+
297
+ // Extract actual entries for comparison (null when absent)
298
+ const baseEntry = hasBase ? base : null;
299
+ const currentEntry = hasCurrent ? current : null;
300
+ const generatedEntry = hasGenerated ? generated : null;
301
+
302
+ // Compare across dimensions
303
+ const baseEqCurrent = entriesEqual(baseEntry, currentEntry);
304
+ const baseEqGenerated = entriesEqual(baseEntry, generatedEntry);
305
+
306
+ /** Helper: apply ADOPTION_REQUIRED when generator owns the area and human changed */
307
+ function applyAdoptionCheck(status, decision) {
308
+ // ADOPTION_REQUIRED when generator owns the area (projection=write) and
309
+ // the human side has a delta that the generated projection does not reproduce.
310
+ if (projection === 'write' && ownership === 'generator') {
311
+ // Any human delta in a generator-owned area requires adoption
312
+ if (!baseEqCurrent) {
313
+ return makeDecision({ status: 'ADOPTION_REQUIRED', current, generated, ownership });
314
+ }
315
+ // No base but current was added by human
316
+ if (!hasBase && hasCurrent) {
317
+ return makeDecision({ status: 'ADOPTION_REQUIRED', current, generated, ownership });
318
+ }
319
+ }
320
+ return decision;
321
+ }
322
+
323
+ // --- Base absent (no prior accepted state) ---
324
+ if (!hasBase) {
325
+ if (!hasCurrent && !hasGenerated) {
326
+ // All absent
327
+ if (ownership === 'generator') {
328
+ return applyAdoptionCheck('NEW', makeDecision({ status: 'NEW', current, generated, ownership }));
329
+ }
330
+ return makeDecision({ status: 'CLEAN', current, generated, ownership });
331
+ }
332
+
333
+ if (hasCurrent && !hasGenerated) {
334
+ // Design 10.1: absent/present/absent → HUMAN_CHANGED for human area,
335
+ // but ADOPTION_REQUIRED for generator-owned area
336
+ if (projection === 'write' && ownership === 'generator') {
337
+ return makeDecision({ status: 'ADOPTION_REQUIRED', current, generated, ownership });
338
+ }
339
+ return makeDecision({ status: 'HUMAN_CHANGED', current, generated, ownership });
340
+ }
341
+
342
+ if (!hasCurrent && hasGenerated) {
343
+ // New file from generator — projection=write requires explicit adoption
344
+ if (ownership === 'human' && projection === 'write') {
345
+ return makeDecision({ status: 'ADOPTION_REQUIRED', current, generated, ownership });
346
+ }
347
+ return makeDecision({ status: 'GENERATOR_CHANGED', current, generated, ownership });
348
+ }
349
+
350
+ if (!entriesEqual(currentEntry, generatedEntry)) {
351
+ // Both exist but differ — generator owns it if it's the source
352
+ if (ownership === 'generator') {
353
+ return makeDecision({ status: 'NEW', current, generated, ownership });
354
+ }
355
+ return makeDecision({
356
+ status: 'CONFLICT',
357
+ base: baseEntry,
358
+ current,
359
+ generated,
360
+ ownership,
361
+ });
362
+ }
363
+
364
+ // Both exist and are identical → mergeable
365
+ return makeDecision({ status: 'MERGEABLE', current, generated, ownership });
366
+ }
367
+
368
+ // --- Base exists ---
369
+ // Both current and generated agree the entry was removed (or both absent)
370
+ if (!hasCurrent && !hasGenerated) {
371
+ if (ownership === 'human') {
372
+ return makeDecision({ status: 'CLEAN', current, generated, ownership });
373
+ }
374
+ return makeDecision({ status: 'MERGEABLE', current, generated, ownership });
375
+ }
376
+
377
+ if (baseEqCurrent && baseEqGenerated) {
378
+ // Everything matches — but check producerRelation for drift
379
+ const withDrift = applyProducerDrift('CLEAN', { current, generated, ownership, producerRelation });
380
+ return withDrift;
381
+ }
382
+
383
+ if (baseEqCurrent && !baseEqGenerated) {
384
+ // Generator changed the file while human left it alone
385
+ return makeDecision({ status: 'GENERATOR_CHANGED', current, generated, ownership });
386
+ }
387
+
388
+ if (!baseEqCurrent && baseEqGenerated) {
389
+ // Human changed the file while generator left it alone
390
+ // In generator-owned write area → ADOPTION_REQUIRED
391
+ if (projection === 'write' && ownership === 'generator') {
392
+ return makeDecision({ status: 'ADOPTION_REQUIRED', current, generated, ownership });
393
+ }
394
+ return makeDecision({ status: 'HUMAN_CHANGED', current, generated, ownership });
395
+ }
396
+
397
+ // Both differ from base
398
+ if (entriesEqual(currentEntry, generatedEntry)) {
399
+ return makeDecision({ status: 'MERGEABLE', current, generated, ownership });
400
+ }
401
+
402
+ // Both differ from base AND differ from each other → conflict
403
+ return makeDecision({
404
+ status: 'CONFLICT',
405
+ base: baseEntry,
406
+ current,
407
+ generated,
408
+ ownership,
409
+ });
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Internal: Producer drift escalation
414
+ // ---------------------------------------------------------------------------
415
+
416
+ /**
417
+ * Apply producer relation drift to a status. If the producer implementation
418
+ * or input changed, the status must be at least GENERATOR_CHANGED.
419
+ *
420
+ * @param {string} baseStatus - The status before drift check.
421
+ * @param {object} ctx - Classification context.
422
+ * @returns {object} Frozen ArtifactDecision.
423
+ */
424
+ function applyProducerDrift(baseStatus, { current, generated, ownership, producerRelation }) {
425
+ if (!producerRelation) {
426
+ return makeDecision({ status: baseStatus, current, generated, ownership });
427
+ }
428
+
429
+ const implChanged = producerRelation.implementationChanged === true;
430
+ const inputChanged = producerRelation.inputChanged === true;
431
+
432
+ if (implChanged || inputChanged) {
433
+ // Escalate to at least GENERATOR_CHANGED
434
+ const baseP = PRIORITY_MAP.get(baseStatus) ?? PRIORITY.length;
435
+ const genP = PRIORITY_MAP.get('GENERATOR_CHANGED') ?? PRIORITY.length;
436
+ if (baseP > genP) {
437
+ // baseStatus is lower priority than GENERATOR_CHANGED — escalate
438
+ return makeDecision({ status: 'GENERATOR_CHANGED', current, generated, ownership });
439
+ }
440
+ }
441
+
442
+ return makeDecision({ status: baseStatus, current, generated, ownership });
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Internal: Decision construction
447
+ // ---------------------------------------------------------------------------
448
+
449
+ /**
450
+ * Construct a frozen ArtifactDecision object.
451
+ *
452
+ * @param {object} options
453
+ * @param {string} options.status - Decision status.
454
+ * @param {object|null} [options.base] - Base entry (for conflict analysis).
455
+ * @param {object} options.current - Current entry.
456
+ * @param {object} options.generated - Generated entry.
457
+ * @param {string} options.ownership - Path ownership.
458
+ * @returns {object} Frozen ArtifactDecision.
459
+ */
460
+ function makeDecision({ status, base = null, current, generated, ownership }) {
461
+ const priority = PRIORITY_MAP.get(status) ?? PRIORITY.length;
462
+ const allowedActions = computeAllowedActions(status, current, ownership);
463
+ const safeToWrite = !BLOCKING_STATUSES.has(status);
464
+
465
+ const decision = {
466
+ status,
467
+ priority,
468
+ safeToWrite,
469
+ allowedActions,
470
+ };
471
+
472
+ // Add conflict details for CONFLICT status
473
+ if (status === 'CONFLICT') {
474
+ decision.humanChanges = diffAgainstBase(base, current, 'current');
475
+ decision.generatorChanges = diffAgainstBase(base, generated, 'generated');
476
+ }
477
+
478
+ return Object.freeze(decision);
479
+ }
480
+
481
+ /**
482
+ * Statuses that block writes (safeToWrite = false).
483
+ */
484
+ const BLOCKING_STATUSES = Object.freeze(new Set([
485
+ 'BASE_UNAVAILABLE',
486
+ 'POLICY_INVALID',
487
+ 'POLICY_CHANGE_PENDING',
488
+ 'ISOLATION_UNAVAILABLE',
489
+ 'PATH_UNSAFE',
490
+ 'PRODUCER_SCOPE_VIOLATION',
491
+ 'PRODUCER_NONDETERMINISTIC',
492
+ 'STRUCTURE_INVALID',
493
+ 'CONFLICT',
494
+ 'ADOPTION_REQUIRED',
495
+ ]));