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,189 @@
1
+ /**
2
+ * Producer DAG for artifact generation.
3
+ *
4
+ * Builds a directed acyclic graph from the artifact policy's generated
5
+ * artifacts and their sourceArtifacts dependencies. Provides topological
6
+ * ordering and downstream closure queries.
7
+ *
8
+ * @module artifacts/graph
9
+ */
10
+
11
+ import { ReleaseError, ARTIFACT_POLICY_INVALID } from '../core/errors.mjs';
12
+
13
+ /**
14
+ * Build a producer graph from the artifact policy.
15
+ *
16
+ * Validates that:
17
+ * - All generated artifacts have known producers (if a registry is provided)
18
+ * - All sourceArtifacts references point to existing artifacts
19
+ * - The dependency graph has no cycles
20
+ *
21
+ * @param {object} policy - Validated artifact policy.
22
+ * @param {object} [registry] - Optional producer registry for validation.
23
+ * @returns {{ topologicalOrder: string[], downstreamClosure: (id: string) => string[] }}
24
+ * @throws {ReleaseError} ARTIFACT_POLICY_INVALID on cycle or unknown reference.
25
+ */
26
+ export function buildProducerGraph(policy, registry) {
27
+ const allArtifacts = new Map();
28
+ for (const a of policy.artifacts) allArtifacts.set(a.id, a);
29
+
30
+ const errors = [];
31
+
32
+ // Validate generated artifacts
33
+ for (const a of policy.artifacts) {
34
+ if (a.type !== 'generated') continue;
35
+
36
+ if (registry && !registry.get(a.producer)) {
37
+ errors.push(`artifact "${a.id}": unknown producer "${a.producer}"`);
38
+ }
39
+
40
+ for (const src of (a.sourceArtifacts ?? [])) {
41
+ if (!allArtifacts.has(src)) {
42
+ errors.push(`artifact "${a.id}": unknown sourceArtifact "${src}"`);
43
+ }
44
+ }
45
+ }
46
+
47
+ if (errors.length > 0) {
48
+ throw new ReleaseError(
49
+ ARTIFACT_POLICY_INVALID,
50
+ `producer graph validation failed: ${errors.join('; ')}`,
51
+ { errors },
52
+ );
53
+ }
54
+
55
+ // Build edges: generated artifact → its sourceArtifacts
56
+ const edges = new Map();
57
+ for (const a of policy.artifacts) {
58
+ if (a.type !== 'generated') continue;
59
+ edges.set(a.id, [...(a.sourceArtifacts ?? [])]);
60
+ }
61
+
62
+ const topologicalOrder = topologicalSort(edges);
63
+
64
+ // Build artifact ID → producer name mapping
65
+ const producerMap = new Map();
66
+ for (const a of policy.artifacts) {
67
+ if (a.type === 'generated' && a.producer) {
68
+ producerMap.set(a.id, a.producer);
69
+ }
70
+ }
71
+
72
+ // Cycle detection is implicit in topologicalSort
73
+ return Object.freeze({
74
+ topologicalOrder: Object.freeze(topologicalOrder),
75
+ downstreamClosure(id) {
76
+ return Object.freeze(downstreamOf(id, edges));
77
+ },
78
+ /**
79
+ * Get the producer name for an artifact ID.
80
+ * @param {string} id - Artifact ID.
81
+ * @returns {string|undefined} Producer name.
82
+ */
83
+ producerOf(id) {
84
+ return producerMap.get(id);
85
+ },
86
+ /**
87
+ * Get the direct upstream artifact IDs (sourceArtifacts) for a generated artifact.
88
+ * @param {string} id - Artifact ID.
89
+ * @returns {string[]} Direct upstream artifact IDs.
90
+ */
91
+ upstreamOf(id) {
92
+ return edges.get(id) ?? [];
93
+ },
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Topological sort of generated artifacts using Kahn's algorithm.
99
+ *
100
+ * @param {Map<string, string[]>} edges - Dependency map (artifact → sourceArtifacts).
101
+ * @returns {string[]} Topologically sorted artifact IDs.
102
+ * @throws {ReleaseError} ARTIFACT_POLICY_INVALID if a cycle is detected.
103
+ */
104
+ function topologicalSort(edges) {
105
+ // Build adjacency and in-degree for generated artifacts only
106
+ const nodes = new Set(edges.keys());
107
+ const inDegree = new Map();
108
+ const adj = new Map();
109
+
110
+ for (const id of nodes) {
111
+ inDegree.set(id, 0);
112
+ adj.set(id, []);
113
+ }
114
+
115
+ for (const [id, deps] of edges) {
116
+ for (const dep of deps) {
117
+ if (nodes.has(dep)) {
118
+ adj.get(dep).push(id);
119
+ inDegree.set(id, inDegree.get(id) + 1);
120
+ }
121
+ }
122
+ }
123
+
124
+ // Kahn's algorithm
125
+ const queue = [];
126
+ for (const [id, deg] of inDegree) {
127
+ if (deg === 0) queue.push(id);
128
+ }
129
+ queue.sort();
130
+
131
+ const order = [];
132
+ while (queue.length > 0) {
133
+ const node = queue.shift();
134
+ order.push(node);
135
+ for (const neighbor of adj.get(node)) {
136
+ const newDeg = inDegree.get(neighbor) - 1;
137
+ inDegree.set(neighbor, newDeg);
138
+ if (newDeg === 0) {
139
+ queue.push(neighbor);
140
+ queue.sort();
141
+ }
142
+ }
143
+ }
144
+
145
+ if (order.length !== nodes.size) {
146
+ const inCycle = [...nodes].filter((id) => !order.includes(id));
147
+ throw new ReleaseError(
148
+ ARTIFACT_POLICY_INVALID,
149
+ `dependency cycle detected among: ${inCycle.join(', ')}`,
150
+ { cycle: inCycle },
151
+ );
152
+ }
153
+
154
+ return order;
155
+ }
156
+
157
+ /**
158
+ * Compute the downstream closure of an artifact (all transitive dependents).
159
+ *
160
+ * @param {string} id - Artifact ID.
161
+ * @param {Map<string, string[]>} edges - Dependency map.
162
+ * @returns {string[]} All downstream artifact IDs (not including `id` itself).
163
+ */
164
+ function downstreamOf(id, edges) {
165
+ // Build reverse adjacency: source → [consumers]
166
+ const reverseAdj = new Map();
167
+ for (const [artifact, deps] of edges) {
168
+ for (const dep of deps) {
169
+ if (!reverseAdj.has(dep)) reverseAdj.set(dep, []);
170
+ reverseAdj.get(dep).push(artifact);
171
+ }
172
+ }
173
+
174
+ const visited = new Set();
175
+ const result = [];
176
+
177
+ function dfs(node) {
178
+ for (const consumer of (reverseAdj.get(node) ?? [])) {
179
+ if (!visited.has(consumer)) {
180
+ visited.add(consumer);
181
+ result.push(consumer);
182
+ dfs(consumer);
183
+ }
184
+ }
185
+ }
186
+
187
+ dfs(id);
188
+ return result;
189
+ }
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Artifact inspection and initialization (read-only).
3
+ *
4
+ * Provides:
5
+ * - `inspectArtifacts({ root, scope, output, mode })` — capture base/current/candidate
6
+ * and compute an artifact plan with `nextAction`.
7
+ * - `initArtifacts({ root, output })` — dry-run bootstrap: detect drift between
8
+ * policy-declared artifacts and current worktree, produce a plan that
9
+ * references `artifacts adopt --bootstrap-plan`.
10
+ *
11
+ * Both functions are strictly read-only with respect to inventory targets:
12
+ * - They never write to artifact paths in the worktree.
13
+ * - They only write a plan file to `.release-skill/runs/` or an explicit `--output`.
14
+ * - `targetUnchanged` is always `true`.
15
+ *
16
+ * @module artifacts/inspect
17
+ */
18
+
19
+ import { promisify } from 'node:util';
20
+ import { execFile } from 'node:child_process';
21
+ import { readdir, stat, readFile } from 'node:fs/promises';
22
+ import { join } from 'node:path';
23
+
24
+ import {
25
+ ReleaseError,
26
+ ARTIFACT_POLICY_INVALID,
27
+ DIRTY_SCOPE_CONFLICT,
28
+ PATH_UNSAFE,
29
+ PLAN_STALE,
30
+ } from '../core/errors.mjs';
31
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
32
+ import { loadArtifactPolicy } from './policy.mjs';
33
+ import { buildInventory } from './inventory.mjs';
34
+ import { readEntry, digestEntryManifest } from './entry.mjs';
35
+ import { buildProducerGraph } from './graph.mjs';
36
+ import { createBuiltInProducerRegistry, runProducerClosure } from './producer-registry.mjs';
37
+ import { readRepositoryIdentity } from './git-authority.mjs';
38
+ import { classifyArtifact } from './state.mjs';
39
+ import { assemblePlan, writePlan } from './artifact-plan.mjs';
40
+ import {
41
+ mergeEntry, mergeText, mergeTree,
42
+ mergeMarkdown, mergeJson, mergeYaml,
43
+ } from './merge/entry-merge.mjs';
44
+
45
+ const execFileAsync = promisify(execFile);
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Internal helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Check if a directory contains a nested git root (other than the top-level one).
53
+ *
54
+ * Nested git roots in the inventory signal a configuration error.
55
+ * Uses `git ls-tree -r --name-only HEAD` to find all tracked trees, then
56
+ * checks each for a `.git` subdirectory.
57
+ *
58
+ * @param {string} root - Repository root.
59
+ * @returns {Promise<boolean>} True if nested git roots are found.
60
+ */
61
+ async function hasNestedGitRoots(root) {
62
+ try {
63
+ // Get all tracked tree paths from HEAD
64
+ const { stdout } = await execFileAsync(
65
+ 'git',
66
+ ['ls-tree', '-r', '-d', '--name-only', 'HEAD'],
67
+ { cwd: root, shell: false, maxBuffer: 50 * 1024 * 1024 },
68
+ );
69
+ const dirs = stdout.split('\n').filter((s) => s.length > 0);
70
+ for (const dir of dirs) {
71
+ const absDir = join(root, dir);
72
+ try {
73
+ const nestedGit = join(absDir, '.git');
74
+ await stat(nestedGit);
75
+ return true; // Found a nested .git directory
76
+ } catch {
77
+ // No .git in this directory — OK
78
+ }
79
+ }
80
+ return false;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Check if the working tree has partially staged artifact entries.
88
+ *
89
+ * A partial stage occurs when some files are staged but not committed,
90
+ * and some artifact files have uncommitted modifications.
91
+ *
92
+ * @param {string} root - Repository root.
93
+ * @returns {Promise<boolean>} True if partially staged entries exist.
94
+ */
95
+ async function hasPartialStage(root) {
96
+ try {
97
+ // Check for staged changes (index vs HEAD)
98
+ const { stdout: staged } = await execFileAsync(
99
+ 'git',
100
+ ['diff', '--cached', '--name-only', '-z'],
101
+ { cwd: root, shell: false },
102
+ );
103
+ const hasStaged = staged.split('\0').filter((s) => s.length > 0).length > 0;
104
+
105
+ // Check for unstaged changes (working tree vs index)
106
+ const { stdout: unstaged } = await execFileAsync(
107
+ 'git',
108
+ ['diff', '--name-only', '-z'],
109
+ { cwd: root, shell: false },
110
+ );
111
+ const hasUnstaged = unstaged.split('\0').filter((s) => s.length > 0).length > 0;
112
+
113
+ // Partial stage = staged AND unstaged changes
114
+ return hasStaged && hasUnstaged;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Read all policy-declared artifact entries from the worktree.
122
+ *
123
+ * For each declared artifact in the policy, reads the entry at `sourcePath`
124
+ * from the worktree. Returns a map of artifact ID → entry.
125
+ *
126
+ * @param {string} root - Repository root.
127
+ * @param {object} policy - Validated artifact policy.
128
+ * @returns {Promise<Map<string, object>>} Map of artifact ID → entry.
129
+ */
130
+ async function readDeclaredEntries(root, policy) {
131
+ const result = new Map();
132
+ for (const artifact of policy.artifacts ?? []) {
133
+ if (artifact.type === 'declared' && artifact.sourcePath) {
134
+ const entry = await readEntry({ root, path: artifact.sourcePath, source: 'worktree' });
135
+ result.set(artifact.id, entry);
136
+ }
137
+ }
138
+ return result;
139
+ }
140
+
141
+ /**
142
+ * Compute the current manifest digest from worktree entries.
143
+ *
144
+ * @param {Map<string, object>} entries - Map of artifact ID → entry.
145
+ * @returns {string} Manifest digest string.
146
+ */
147
+ function computeCurrentManifestDigest(entries) {
148
+ const entryList = [];
149
+ for (const [id, entry] of entries) {
150
+ if (entry.kind === 'absent') {
151
+ entryList.push({ path: id, type: 'absent', mode: '', sha256: '', size: 0 });
152
+ } else if (entry.kind === 'regular') {
153
+ entryList.push({
154
+ path: entry.path ?? id,
155
+ type: entry.type,
156
+ mode: entry.mode,
157
+ sha256: entry.sha256,
158
+ size: entry.size,
159
+ });
160
+ } else if (entry.kind === 'tree') {
161
+ // Use manifestDigest for tree entries
162
+ entryList.push({
163
+ path: id,
164
+ type: 'tree',
165
+ mode: '040000',
166
+ sha256: entry.manifestDigest ?? '',
167
+ size: 0,
168
+ });
169
+ }
170
+ }
171
+ return digestEntryManifest(entryList);
172
+ }
173
+
174
+ /**
175
+ * Compute the producer closure digest for the policy's generated artifacts.
176
+ *
177
+ * If no generated artifacts exist, returns a stable empty digest.
178
+ *
179
+ * @param {object} policy - Validated artifact policy.
180
+ * @returns {Promise<string>} Producer closure digest.
181
+ */
182
+ async function computeProducerClosureDigest(policy) {
183
+ const generatedArtifacts = (policy.artifacts ?? []).filter((a) => a.type === 'generated');
184
+ if (generatedArtifacts.length === 0) {
185
+ // No generated artifacts — return stable empty digest
186
+ return `sha256:${sha256Hex(canonicalJson({ generated: 0 }))}`;
187
+ }
188
+
189
+ try {
190
+ const registry = await createBuiltInProducerRegistry();
191
+ const graph = buildProducerGraph(policy, registry);
192
+ const digests = [];
193
+ for (const id of graph.topologicalOrder) {
194
+ const entry = registry.get(graph.producerOf(id));
195
+ if (entry) digests.push(entry.implementationDigest);
196
+ }
197
+ return `sha256:${sha256Hex(canonicalJson(digests.sort()))}`;
198
+ } catch {
199
+ // Registry or graph creation failed — return fallback
200
+ return `sha256:${sha256Hex(canonicalJson({ generated: generatedArtifacts.length, error: true }))}`;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Classify each artifact against the inventory and produce decisions.
206
+ *
207
+ * For `init` mode, uses a simplified classification that focuses on
208
+ * whether bootstrap artifacts exist and match their declared content.
209
+ *
210
+ * @param {Map<string, object>} currentEntries - Current worktree entries.
211
+ * @param {object} policy - Validated artifact policy.
212
+ * @param {'inspect'|'init'|'status'} mode - Operation mode.
213
+ * @returns {Array<object>} Artifact decisions.
214
+ */
215
+ function classifyArtifacts(currentEntries, policy, mode) {
216
+ const decisions = [];
217
+ const emptyBase = { kind: 'absent' };
218
+
219
+ for (const artifact of policy.artifacts ?? []) {
220
+ const current = currentEntries.get(artifact.id) ?? { kind: 'absent' };
221
+ const ownership = artifact.ownership ?? policy.inventory?.defaultOwnership ?? 'human';
222
+
223
+ const decision = classifyArtifact({
224
+ base: emptyBase,
225
+ current,
226
+ generated: emptyBase,
227
+ ownership,
228
+ });
229
+
230
+ decisions.push(Object.freeze({
231
+ id: artifact.id,
232
+ type: artifact.type,
233
+ path: artifact.sourcePath,
234
+ status: decision.status,
235
+ safeToWrite: decision.safeToWrite,
236
+ allowedActions: decision.allowedActions,
237
+ priority: decision.priority,
238
+ }));
239
+ }
240
+
241
+ return decisions;
242
+ }
243
+
244
+ /**
245
+ * Determine the overall plan status from individual artifact decisions.
246
+ *
247
+ * @param {Array<object>} decisions - Artifact decisions.
248
+ * @param {'inspect'|'init'|'status'} mode - Operation mode.
249
+ * @returns {string} Overall status.
250
+ */
251
+ function determineOverallStatus(decisions, mode) {
252
+ if (mode === 'init') {
253
+ // Init mode: check if any artifact has drifted
254
+ const hasDrift = decisions.some(
255
+ (d) => d.status !== 'CLEAN' && d.status !== 'NEW',
256
+ );
257
+ return hasDrift ? 'BOOTSTRAP_DERIVED_DRIFT' : 'CLEAN';
258
+ }
259
+
260
+ // Inspect/status mode: any blocking status takes precedence
261
+ const BLOCKING = new Set([
262
+ 'BASE_UNAVAILABLE', 'POLICY_INVALID', 'POLICY_CHANGE_PENDING',
263
+ 'ISOLATION_UNAVAILABLE', 'PATH_UNSAFE', 'PRODUCER_SCOPE_VIOLATION',
264
+ 'PRODUCER_NONDETERMINISTIC', 'STRUCTURE_INVALID', 'CONFLICT',
265
+ 'ADOPTION_REQUIRED',
266
+ ]);
267
+
268
+ const blocking = decisions.find((d) => BLOCKING.has(d.status));
269
+ if (blocking) return blocking.status;
270
+
271
+ // Check for drift
272
+ const needsAction = decisions.some(
273
+ (d) => d.status !== 'CLEAN',
274
+ );
275
+ return needsAction ? 'ASSESSED' : 'CLEAN';
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Public API
280
+ // ---------------------------------------------------------------------------
281
+
282
+ /**
283
+ * Inspect artifacts: capture base/current/candidate and compute an artifact plan.
284
+ *
285
+ * This is a read-only operation:
286
+ * - Does not write to any inventory target.
287
+ * - Does not create an artifact-lock.
288
+ * - Optionally writes the plan to `output` or `.release-skill/runs/`.
289
+ *
290
+ * @param {object} options
291
+ * @param {string} options.root - Repository root (absolute).
292
+ * @param {string} [options.scope] - Scope filter for artifacts (reserved).
293
+ * @param {string} [options.output] - Explicit path to write the plan.
294
+ * @param {'inspect'|'init'|'status'} [options.mode='inspect'] - Operation mode.
295
+ * @returns {Promise<ArtifactPlanResult>}
296
+ */
297
+ export async function inspectArtifacts({
298
+ root,
299
+ scope,
300
+ output,
301
+ mode = 'inspect',
302
+ } = {}) {
303
+ const inputs = await captureInspectInputs({ root });
304
+ const result = inspectFromInputs({ inputs, mode });
305
+ if (output) {
306
+ await writePlan(result.plan, output);
307
+ }
308
+ const runId = `inspect-${Date.now().toString(36)}`;
309
+ const evidenceDir = `.release-skill/runs/${runId}`;
310
+ return Object.freeze({ ...result, evidenceDir });
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Phase functions for short-lock pattern
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /**
318
+ * Phase 1: Capture all immutable inputs from the repository.
319
+ *
320
+ * Reads policy, identity, inventory, and current artifact entries.
321
+ * This is the step that must run under the project lock to ensure
322
+ * a consistent snapshot.
323
+ *
324
+ * @param {object} options
325
+ * @param {string} options.root - Repository root (absolute).
326
+ * @returns {Promise<InspectInputs>} Frozen inputs snapshot.
327
+ */
328
+ export async function captureInspectInputs({ root } = {}) {
329
+ const { policy, policyDigest } = await loadArtifactPolicy({ root });
330
+ const identity = await readRepositoryIdentity(root);
331
+ const inventory = await buildInventory({ root, policy });
332
+ const currentEntries = await readDeclaredEntries(root, policy);
333
+ const currentManifestDigest = computeCurrentManifestDigest(currentEntries);
334
+ const baseManifestDigest = `sha256:${sha256Hex(canonicalJson({ empty: true }))}`;
335
+ const producerClosureDigest = await computeProducerClosureDigest(policy);
336
+
337
+ return Object.freeze({
338
+ root,
339
+ policy,
340
+ policyDigest,
341
+ identity,
342
+ inventory,
343
+ currentEntries,
344
+ currentManifestDigest,
345
+ baseManifestDigest,
346
+ producerClosureDigest,
347
+ capturedAt: new Date().toISOString(),
348
+ });
349
+ }
350
+
351
+ /**
352
+ * Phase 2: Classify artifacts and assemble the plan from captured inputs.
353
+ *
354
+ * This is a pure computation step that can run without the project lock.
355
+ * No filesystem reads are performed.
356
+ *
357
+ * @param {object} options
358
+ * @param {InspectInputs} options.inputs - Captured inputs from Phase 1.
359
+ * @param {'inspect'|'init'|'status'} [options.mode='inspect'] - Operation mode.
360
+ * @returns {object} Plan result (plan, status, safeToWrite, targetUnchanged, nextAction).
361
+ */
362
+ export function inspectFromInputs({ inputs, mode = 'inspect' } = {}) {
363
+ const { policy, policyDigest, identity, currentEntries, currentManifestDigest, baseManifestDigest, producerClosureDigest } = inputs;
364
+
365
+ const decisions = classifyArtifacts(currentEntries, policy, mode);
366
+ const status = determineOverallStatus(decisions, mode);
367
+ const safeToWrite = decisions.every((d) => d.safeToWrite);
368
+ const plan = assemblePlan({
369
+ operation: mode,
370
+ bindings: {
371
+ repositoryIdentity: identity.remoteUrlHash,
372
+ policyDigest,
373
+ baseManifestDigest,
374
+ currentManifestDigest,
375
+ producerClosureDigest,
376
+ },
377
+ artifacts: decisions,
378
+ safeToWrite,
379
+ targetUnchanged: true,
380
+ });
381
+
382
+ return Object.freeze({
383
+ plan,
384
+ status,
385
+ safeToWrite,
386
+ targetUnchanged: true,
387
+ nextAction: plan.nextAction,
388
+ });
389
+ }
390
+
391
+ /**
392
+ * Phase 3: Verify that repository inputs have not drifted since capture.
393
+ *
394
+ * Re-reads the current artifact entries and manifest digest from the
395
+ * repository and compares them against the previously captured inputs.
396
+ * Throws PLAN_STALE if drift is detected.
397
+ *
398
+ * This is the step that must run under the project lock before writing
399
+ * the plan, ensuring no concurrent mutations occurred during the
400
+ * unlocked producer phase.
401
+ *
402
+ * @param {object} options
403
+ * @param {string} options.root - Repository root (absolute).
404
+ * @param {InspectInputs} options.inputs - Previously captured inputs.
405
+ * @returns {Promise<void>}
406
+ * @throws {ReleaseError} PLAN_STALE if inputs have drifted.
407
+ */
408
+ export async function verifyInputsUnchanged({ root, inputs } = {}) {
409
+ const { policy } = await loadArtifactPolicy({ root });
410
+ const currentEntries = await readDeclaredEntries(root, policy);
411
+ const currentManifestDigest = computeCurrentManifestDigest(currentEntries);
412
+
413
+ if (currentManifestDigest !== inputs.currentManifestDigest) {
414
+ throw new ReleaseError(
415
+ PLAN_STALE,
416
+ 'artifact entries changed during inspection — inputs drifted',
417
+ {
418
+ capturedDigest: inputs.currentManifestDigest,
419
+ currentDigest: currentManifestDigest,
420
+ },
421
+ );
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Initialize artifacts: dry-run bootstrap inspection.
427
+ *
428
+ * Detects drift between policy-declared artifacts and the current worktree.
429
+ * Does NOT create an artifact-lock. Only writes to protocol run directory
430
+ * or explicit output path.
431
+ *
432
+ * Key constraints:
433
+ * - Rejects nested git roots (ARTIFACT_POLICY_INVALID).
434
+ * - Rejects partially staged artifact entries (DIRTY_SCOPE_CONFLICT).
435
+ * - `targetUnchanged` is always `true`.
436
+ * - `nextAction` references `artifacts adopt --bootstrap-plan`.
437
+ *
438
+ * @param {object} options
439
+ * @param {string} options.root - Repository root (absolute).
440
+ * @param {string} [options.output] - Explicit path to write the plan.
441
+ * @returns {Promise<ArtifactPlanResult>}
442
+ */
443
+ export async function initArtifacts({ root, output } = {}) {
444
+ // 1. Validate: no nested git roots
445
+ if (await hasNestedGitRoots(root)) {
446
+ throw new ReleaseError(
447
+ ARTIFACT_POLICY_INVALID,
448
+ 'nested git repositories detected in inventory scope',
449
+ { root },
450
+ );
451
+ }
452
+
453
+ // 2. Validate: no partially staged entries
454
+ if (await hasPartialStage(root)) {
455
+ throw new ReleaseError(
456
+ DIRTY_SCOPE_CONFLICT,
457
+ 'partially staged artifact entries detected; commit or unstage first',
458
+ { root },
459
+ );
460
+ }
461
+
462
+ // 3. Delegate to inspectArtifacts in init mode
463
+ return inspectArtifacts({ root, output, mode: 'init' });
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Merge integration
468
+ // ---------------------------------------------------------------------------
469
+
470
+ /**
471
+ * Re-export merge and adoption APIs for downstream consumers (resolution, CLI).
472
+ *
473
+ * These are the same exports from `merge/entry-merge.mjs` and `adoption.mjs`,
474
+ * surfaced here so that inspect.mjs is the single import point for artifact
475
+ * operations.
476
+ */
477
+ export { mergeEntry, mergeText, mergeTree, mergeMarkdown, mergeJson, mergeYaml };
478
+ export { planAdoption, discardBootstrapHunk } from './adoption.mjs';
479
+
480
+ /**
481
+ * Run three-way merge across all artifacts in a plan.
482
+ *
483
+ * For each artifact binding, performs `mergeEntry` using the provided
484
+ * base/current/generated entries and the artifact's declared driver.
485
+ *
486
+ * Returns a map of artifact ID → merge result. Does NOT write to any
487
+ * artifact target; all results are in-memory candidates.
488
+ *
489
+ * @param {object} options
490
+ * @param {Array<object>} options.bindings - Plan artifact bindings (each with id, driver).
491
+ * @param {Map<string, object>} options.baseEntries - Base entries keyed by artifact ID.
492
+ * @param {Map<string, object>} options.currentEntries - Current entries keyed by artifact ID.
493
+ * @param {Map<string, object>} options.generatedEntries - Generated entries keyed by artifact ID.
494
+ * @returns {Map<string, { status: string, candidate?: object, conflicts?: object[] }>}
495
+ */
496
+ export function mergeArtifactEntries({
497
+ bindings,
498
+ baseEntries,
499
+ currentEntries,
500
+ generatedEntries,
501
+ } = {}) {
502
+ const results = new Map();
503
+
504
+ for (const binding of bindings ?? []) {
505
+ const id = binding.id;
506
+ const driver = binding.driver ?? 'text';
507
+ const base = baseEntries?.get(id) ?? { kind: 'absent' };
508
+ const current = currentEntries?.get(id) ?? { kind: 'absent' };
509
+ const generated = generatedEntries?.get(id) ?? { kind: 'absent' };
510
+
511
+ const result = mergeEntry({ base, current, generated, driver });
512
+ results.set(id, Object.freeze({
513
+ status: result.status,
514
+ candidate: result.candidate,
515
+ conflicts: result.conflicts,
516
+ }));
517
+ }
518
+
519
+ return Object.freeze(results);
520
+ }