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.
- package/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- 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
|
+
}
|