ddduck 0.1.2 → 0.2.0

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.
@@ -82,7 +82,7 @@ export function renderHelp(command) {
82
82
  const usage = {
83
83
  undefined: [
84
84
  "Usage: ddduck <command> [options]",
85
- "Commands: init, check, generate, query, install, create, move, split, retire.",
85
+ "Commands: init, check, generate, query, diff, install, create, move, split, retire.",
86
86
  "Run `ddduck <command> --help` for command usage.",
87
87
  ],
88
88
  init: [
@@ -118,19 +118,30 @@ export function renderHelp(command) {
118
118
  "JSON: output is always JSON; --json is accepted and has no effect.",
119
119
  "Options: --id <model-node-id> (repeatable for context), --root <product-root>, --history.",
120
120
  ],
121
+ diff: [
122
+ "Syntax: ddduck diff --base <previous-product-root> [--root <product-root>] [--json]",
123
+ "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); --base is required; output is text.",
124
+ "Writes: nothing; source roots are validated independently and generated freshness is not required.",
125
+ "Success output: a stable-ID comparison of added, removed, changed, and relocated canonical records, with source digests and explicit exclusions.",
126
+ "Exit status: 0 on a completed comparison (including differences) or help; 2 for a busy root; 1 for invalid input, source, interrupted state, or different Model IDs.",
127
+ "JSON: --json emits one ModelDiff document; comparison is canonical YAML only and requires human interpretation.",
128
+ ],
121
129
  install: [
122
130
  "Syntax: ddduck install skill update-ddduck-specs [--repo <repository-root>]",
123
131
  "Defaults: --repo is the current directory.",
124
- "Writes: the selected host skill adapter and .ddduck/agent-skills.lock.json in --repo.",
132
+ "Writes: the selected host skill bundle and .ddduck/agent-skills.lock.json in --repo.",
125
133
  "Success output: one text result with the action (created, upgraded, or no-op), repository, canonical path, and lock path.",
126
134
  "Exit status: 0 on installation, no-op, or help; nonzero on invalid input or conflicting host state.",
127
135
  "JSON: unavailable; --json is not accepted.",
128
136
  ],
129
137
  create: [
130
138
  "Syntax: ddduck create guarantee --origin <origin> --classification <invariant|acceptance-criterion> --owner <domain-id> --statement <text> [--root <product-root>] [--json]",
131
- "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); the next origin/classification serial is allocated.",
132
- "Writes: the new Guarantee, its owning Domain, and all generated views through staged publication.",
133
- "Success output: one text result with the allocated ID, root, canonical paths, and generated paths.",
139
+ "Syntax: ddduck create domain --id domain:<slug> --name <text> --purpose <text> [--root <product-root>] [--json]",
140
+ "Syntax: ddduck create concept --id concept:<slug> --owner domain:<slug> --name <text> --purpose <text> [--root <product-root>] [--json]",
141
+ "Syntax: ddduck create use-case --file <yaml-file> [--root <product-root>] [--json]",
142
+ "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); guarantees allocate the next origin/classification serial; other kinds require an explicit ID or complete UseCase input.",
143
+ "Writes: the new node, its owning collection, and all generated views through staged publication; input files are read-only.",
144
+ "Success output: one text result with affected IDs, root, canonical paths, and generated paths.",
134
145
  "Exit status: 0 on publication or help; 2 when the product root is busy (operation lock held by a running process, retryable); 1 with no intended product changes on any other failure.",
135
146
  "JSON: --json emits the same result as one JSON object.",
136
147
  ],
@@ -141,6 +141,12 @@ function renderReport(record, productionAnchors, testAnchors) {
141
141
  requirement: record.requirement,
142
142
  coverage: record.coverage,
143
143
  verdict: record.verdict,
144
+ verificationScope: {
145
+ verdictSource: "input-record",
146
+ anchorIntegrityChecked: true,
147
+ testsExecuted: false,
148
+ behaviorVerified: false,
149
+ },
144
150
  productionAnchors,
145
151
  testAnchors,
146
152
  reviewerDisposition: record.reviewerDisposition,
@@ -0,0 +1,52 @@
1
+ const kinds = {
2
+ Domain: { prefix: "domain", directory: "domains", collection: "domains" },
3
+ Concept: { prefix: "concept", directory: "concepts", collection: "concepts" },
4
+ UseCase: { prefix: "use-case", directory: "use-cases", collection: "useCases" },
5
+ };
6
+
7
+ export function buildAuthoringPlan(snapshot, request) {
8
+ const definition = kinds[request.kind];
9
+ if (!definition) throw new Error(`Unsupported creation kind ${request.kind}`);
10
+ const models = snapshot.nodes.filter(({ kind }) => kind === "Model");
11
+ if (models.length !== 1) throw new Error("Creation requires exactly one Model");
12
+ const [model] = models;
13
+ const node =
14
+ request.kind === "UseCase"
15
+ ? globalThis.structuredClone(request.node)
16
+ : {
17
+ schemaVersion: "1",
18
+ kind: request.kind,
19
+ id: request.id,
20
+ model: model.id,
21
+ ...(request.kind === "Concept" ? { ownerDomain: request.ownerDomain } : {}),
22
+ name: request.name,
23
+ purpose: request.purpose,
24
+ ...(request.kind === "Domain" ? { concepts: [], interfaces: [], guarantees: [] } : {}),
25
+ };
26
+ if (node?.kind !== request.kind) throw new Error(`create ${definition.prefix} requires kind ${request.kind}`);
27
+ if (node.model !== model.id) throw new Error(`New node model must be ${model.id}`);
28
+ if (typeof node.id !== "string" || !new RegExp(`^${definition.prefix}:[a-z0-9][a-z0-9-]*$`).test(node.id)) {
29
+ throw new Error(`Invalid ${request.kind} ID ${JSON.stringify(node.id)}`);
30
+ }
31
+ if (snapshot.nodes.some(({ id }) => id === node.id)) throw new Error(`Model node ${node.id} already exists`);
32
+ const destination = `model/${definition.directory}/${node.id.slice(definition.prefix.length + 1)}.yaml`;
33
+ if (Object.values(snapshot.canonicalPaths).includes(destination)) {
34
+ throw new Error(`Canonical destination already occupied: ${destination}`);
35
+ }
36
+ const parent =
37
+ request.kind === "Concept"
38
+ ? snapshot.nodes.find(({ id, kind }) => kind === "Domain" && id === node.ownerDomain)
39
+ : model;
40
+ if (!parent) throw new Error(`Unknown domain ${node.ownerDomain}`);
41
+ return {
42
+ operation: `create ${definition.prefix}`,
43
+ affectedIds: [node.id, parent.id],
44
+ replacements: [
45
+ { path: destination, value: node },
46
+ {
47
+ path: snapshot.canonicalPaths[parent.id],
48
+ value: { ...parent, [definition.collection]: [...(parent[definition.collection] ?? []), node.id] },
49
+ },
50
+ ],
51
+ };
52
+ }
@@ -0,0 +1,155 @@
1
+ import path from "node:path";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { validateProduct } from "../check-model.mjs";
4
+ import { shellQuote, sourceDigestFromSources } from "./context-pack.mjs";
5
+ import { detectProductLayout, loadProductNodes } from "./product-layout.mjs";
6
+ import { assertProductNotBusy, findLeftoverOperationState } from "./product-operation.mjs";
7
+
8
+ export function compareProductSnapshots(before, after) {
9
+ const beforeModel = modelId(before);
10
+ const afterModel = modelId(after);
11
+ if (beforeModel !== afterModel) {
12
+ const error = new Error(`Cannot compare different Model IDs: ${beforeModel} and ${afterModel}`);
13
+ error.nextAction = "Choose two product roots representing the same Model ID, then retry the diff.";
14
+ throw error;
15
+ }
16
+ const beforeNodes = new Map(before.nodes.map((node) => [node.id, node]));
17
+ const afterNodes = new Map(after.nodes.map((node) => [node.id, node]));
18
+ const result = { added: [], removed: [], changed: [], relocated: [] };
19
+ for (const id of [...new Set([...beforeNodes.keys(), ...afterNodes.keys()])].sort()) {
20
+ const previous = beforeNodes.get(id);
21
+ const current = afterNodes.get(id);
22
+ if (!previous) {
23
+ result.added.push(record(current, after.canonicalPaths[id]));
24
+ } else if (!current) {
25
+ result.removed.push(record(previous, before.canonicalPaths[id]));
26
+ } else {
27
+ const changes = fieldChanges(previous, current);
28
+ if (changes.length > 0) result.changed.push({ id, kind: current.kind, changes });
29
+ if (before.canonicalPaths[id] !== after.canonicalPaths[id]) {
30
+ result.relocated.push({
31
+ id,
32
+ kind: current.kind,
33
+ beforePath: before.canonicalPaths[id],
34
+ afterPath: after.canonicalPaths[id],
35
+ });
36
+ }
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+
42
+ export function compareProductRoots(beforeRoot, afterRoot) {
43
+ const before = loadDiffRoot(beforeRoot);
44
+ const after = loadDiffRoot(afterRoot);
45
+ return {
46
+ schemaVersion: "1",
47
+ kind: "ModelDiff",
48
+ before: { modelId: modelId(before.snapshot), sourceDigest: before.sourceDigest },
49
+ after: { modelId: modelId(after.snapshot), sourceDigest: after.sourceDigest },
50
+ scope: "canonical-yaml-only",
51
+ excludedScopes: ["decision-content", "evidence-content", "delivery-artifacts", "runtime"],
52
+ ...compareProductSnapshots(before.snapshot, after.snapshot),
53
+ };
54
+ }
55
+
56
+ export function renderProductDiff(report) {
57
+ const lines = [
58
+ `Model diff: ${report.before.modelId}`,
59
+ `Before: ${report.before.sourceDigest}`,
60
+ `After: ${report.after.sourceDigest}`,
61
+ `Scope: ${report.scope}`,
62
+ `Excluded: ${report.excludedScopes.join(", ")}`,
63
+ ];
64
+ for (const entry of report.added) lines.push(`Added ${entry.id} (${entry.kind}) at ${entry.sourcePath}`);
65
+ for (const entry of report.removed) lines.push(`Removed ${entry.id} (${entry.kind}) from ${entry.sourcePath}`);
66
+ for (const entry of report.changed) {
67
+ lines.push(`Changed ${entry.id} (${entry.kind})`);
68
+ for (const change of entry.changes) {
69
+ const before = change.beforePresent ? JSON.stringify(change.before) : "<absent>";
70
+ const after = change.afterPresent ? JSON.stringify(change.after) : "<absent>";
71
+ lines.push(` ${change.path}: ${before} -> ${after}`);
72
+ }
73
+ }
74
+ for (const entry of report.relocated) {
75
+ lines.push(`Relocated ${entry.id} (${entry.kind}): ${entry.beforePath} -> ${entry.afterPath}`);
76
+ }
77
+ if ([report.added, report.removed, report.changed, report.relocated].every((entries) => entries.length === 0)) {
78
+ lines.push("No canonical record changes.");
79
+ }
80
+ lines.push("Structural comparison requires human interpretation; source reads are not atomic snapshots.");
81
+ return lines.join("\n");
82
+ }
83
+
84
+ function loadDiffRoot(rootPath) {
85
+ const layout = detectProductLayout(rootPath);
86
+ try {
87
+ assertReadable(layout.root);
88
+ const check = validateProduct(layout.root, { includeDocumentation: false, sourceOnly: true });
89
+ if (check.errors.length > 0) throw new Error(`Validation failed for ${layout.root}:\n${check.errors.join("\n")}`);
90
+ const loaded = loadProductNodes(layout);
91
+ const canonicalPaths = Object.fromEntries(
92
+ [...loaded.nodeFiles].map(([id, filePath]) => [
93
+ id,
94
+ path.relative(layout.root, filePath).split(path.sep).join("/"),
95
+ ]),
96
+ );
97
+ const sources = new Map([...loaded.nodeSources].map(([id, bytes]) => [canonicalPaths[id], bytes]));
98
+ assertReadable(layout.root);
99
+ return {
100
+ snapshot: { nodes: [...loaded.nodes.values()], canonicalPaths },
101
+ sourceDigest: sourceDigestFromSources(sources),
102
+ };
103
+ } catch (error) {
104
+ error.nextAction ??= `Fix the product root and its source files, run ddduck check --root ${shellQuote(layout.root)}, then retry the diff.`;
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ function assertReadable(root) {
110
+ assertProductNotBusy(root);
111
+ const leftover = findLeftoverOperationState(root);
112
+ if (leftover) {
113
+ const error = new Error(`An interrupted ddduck operation left ${leftover.entries.join(", ")} in ${root}`);
114
+ error.nextAction = `Run ddduck generate --root ${shellQuote(root)} to reclaim the interrupted operation state, then retry the diff.`;
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ function modelId(snapshot) {
120
+ const models = snapshot.nodes.filter((node) => node.kind === "Model");
121
+ if (models.length !== 1) throw new Error("A product snapshot must contain exactly one Model record");
122
+ return models[0].id;
123
+ }
124
+
125
+ function record(node, sourcePath) {
126
+ return { id: node.id, kind: node.kind, sourcePath, node: globalThis.structuredClone(node) };
127
+ }
128
+
129
+ function fieldChanges(before, after, pointer = "") {
130
+ const changes = [];
131
+ for (const key of [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()) {
132
+ const fieldPath = `${pointer}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`;
133
+ const beforePresent = Object.hasOwn(before, key);
134
+ const afterPresent = Object.hasOwn(after, key);
135
+ const previous = before[key];
136
+ const current = after[key];
137
+ if (beforePresent && afterPresent && isDeepStrictEqual(previous, current)) continue;
138
+ if (beforePresent && afterPresent && isMapping(previous) && isMapping(current)) {
139
+ changes.push(...fieldChanges(previous, current, fieldPath));
140
+ } else {
141
+ changes.push({
142
+ path: fieldPath,
143
+ beforePresent,
144
+ afterPresent,
145
+ ...(beforePresent ? { before: globalThis.structuredClone(previous) } : {}),
146
+ ...(afterPresent ? { after: globalThis.structuredClone(current) } : {}),
147
+ });
148
+ }
149
+ }
150
+ return changes.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
151
+ }
152
+
153
+ function isMapping(value) {
154
+ return value !== null && typeof value === "object" && !Array.isArray(value);
155
+ }
@@ -26,7 +26,7 @@ import {
26
26
  } from "node:fs";
27
27
  import path from "node:path";
28
28
  import { fileURLToPath } from "node:url";
29
- import { isScalar, parseDocument, stringify } from "yaml";
29
+ import { isScalar, isSeq, parseDocument, stringify } from "yaml";
30
30
  import { checkGeneratedDocs } from "../check-generated-docs.mjs";
31
31
  import { checkGeneratedGraph } from "../check-generated-graph.mjs";
32
32
  import { validateProduct } from "../check-model.mjs";
@@ -476,6 +476,14 @@ function serializeReplacement(target, relativePath, value) {
476
476
  const existing = document.get(key, true);
477
477
  if (isScalar(existing) && (next === null || typeof next !== "object")) {
478
478
  existing.value = next;
479
+ } else if (
480
+ isSeq(existing) &&
481
+ Array.isArray(next) &&
482
+ next.length > existing.items.length &&
483
+ next.every((item) => item === null || typeof item !== "object") &&
484
+ existing.items.every((item, index) => isScalar(item) && item.value === next[index])
485
+ ) {
486
+ for (const item of next.slice(existing.items.length)) existing.add(document.createNode(item));
479
487
  } else {
480
488
  document.set(key, document.createNode(next));
481
489
  }
@@ -2,10 +2,10 @@
2
2
  * Installer for the bundled update-ddduck-specs agent skill, behind `ddduck
3
3
  * install skill`. Selects a host topology from the repository state (codex
4
4
  * .agents/, claude-code .claude/, or shared via symlink), plans create,
5
- * upgrade, or no-op against the canonical SKILL.md and the
5
+ * upgrade, or no-op against the canonical skill bundle and the
6
6
  * .ddduck/agent-skills.lock.json lock, and applies the plan with atomic
7
7
  * writes plus rollback of everything touched on failure. Conflicting host
8
- * state or a locally modified canonical file refuses with a nextAction
8
+ * state or a locally modified managed bundle refuses with a nextAction
9
9
  * naming the exact path to resolve.
10
10
  */
11
11
 
@@ -23,7 +23,7 @@ import {
23
23
  import { createHash, randomUUID } from "node:crypto";
24
24
  import path from "node:path";
25
25
 
26
- const lockSchemaVersion = 1;
26
+ const lockSchemaVersion = 2;
27
27
  const lockRelativePath = path.join(".ddduck", "agent-skills.lock.json");
28
28
 
29
29
  const defaultOperations = {
@@ -126,7 +126,7 @@ const installTopologies = [
126
126
  /**
127
127
  * Install (or upgrade) the bundled skill into a repository: load, plan, apply.
128
128
  * @param {{repository: string, skillName: string, skillPath: string, packageVersion: string, operations?: object}} options - Repository root, skill identity, bundled asset path, and ddduck version for the lock.
129
- * @returns {{action: "create"|"upgrade"|"no-op", skillSha256: string, canonicalPath: string, lockPath: string}} The installation result.
129
+ * @returns {{action: "create"|"upgrade"|"no-op", skillSha256: string, bundleSha256: string, canonicalPath: string, lockPath: string}} The installation result.
130
130
  */
131
131
  export function installSkill(options) {
132
132
  const bundle = loadSkillBundle(options);
@@ -135,25 +135,35 @@ export function installSkill(options) {
135
135
  }
136
136
 
137
137
  /**
138
- * Read the bundled skill asset and compute its sha256.
138
+ * Read the bundled skill directory and compute per-file and bundle digests.
139
139
  * @param {{skillName: string, skillPath: string, operations?: object}} options - Skill name, SKILL.md path, and fs overrides for tests.
140
- * @returns {{name: string, bytes: Buffer, sha256: string}} The loaded bundle.
140
+ * @returns {{name: string, files: {path: string, bytes: Buffer, sha256: string}[], skillSha256: string, bundleSha256: string, sha256: string}} The loaded bundle.
141
141
  */
142
142
  export function loadSkillBundle({ skillName, skillPath, operations = {} }) {
143
143
  const resolvedOperations = { ...defaultOperations, ...operations };
144
144
  if (pathState(skillPath, resolvedOperations).type !== "file") {
145
145
  throw new Error(`Missing bundled skill asset: ${skillPath}`);
146
146
  }
147
- const bytes = resolvedOperations.readFileSync(skillPath);
148
- return { name: skillName, bytes, sha256: sha256(bytes) };
147
+ const files = readBundleFiles(path.dirname(skillPath), resolvedOperations);
148
+ const skill = files.find(({ path: relativePath }) => relativePath === skillFileName);
149
+ if (!skill) throw new Error(`Missing bundled skill asset: ${skillPath}`);
150
+ const bundleSha256 = files.length === 1 ? skill.sha256 : digestBundle(files);
151
+ return {
152
+ name: skillName,
153
+ files,
154
+ skillSha256: skill.sha256,
155
+ bundleSha256,
156
+ // Preserve the internal single-file field while older callers migrate.
157
+ sha256: skill.sha256,
158
+ };
149
159
  }
150
160
 
151
161
  /**
152
162
  * Inspect the repository's lock, canonical file, and host adapters and decide
153
163
  * the action: create, upgrade, or no-op — or throw on conflicting host state,
154
164
  * an incomplete lock, or a locally modified canonical skill.
155
- * @param {{repository: string, skillName: string, bundle: {sha256: string}, operations?: object}} options - Repository root, skill name, loaded bundle, and fs overrides.
156
- * @returns {{action: string, paths: object, topology: object, adaptersToMaterialize: object[], writeCanonical: boolean}} The install plan for applySkillInstall.
165
+ * @param {{repository: string, skillName: string, bundle: object, operations?: object}} options - Repository root, skill name, loaded bundle, and fs overrides.
166
+ * @returns {{action: string, paths: object, topology: object, adaptersToMaterialize: object[], writeBundle: boolean, staleFiles: string[]}} The install plan for applySkillInstall.
157
167
  */
158
168
  export function planSkillInstall({ repository, skillName, bundle, operations = {} }) {
159
169
  const resolvedOperations = { ...defaultOperations, ...operations };
@@ -183,55 +193,71 @@ export function planSkillInstall({ repository, skillName, bundle, operations = {
183
193
  );
184
194
  }
185
195
  if (canonical.type === "absent") {
186
- return createPlan({ paths, adapters, writeCanonical: true });
196
+ return createPlan({ paths, adapters, writeBundle: true });
187
197
  }
188
- if (canonical.type !== "file" || sha256(resolvedOperations.readFileSync(paths.canonical)) !== bundle.sha256) {
198
+ if (
199
+ canonical.type !== "file" ||
200
+ sha256(resolvedOperations.readFileSync(paths.canonical)) !== bundle.skillSha256 ||
201
+ bundle.files.length !== 1
202
+ ) {
189
203
  throw conflictingHostState(`Conflicting canonical skill destination: ${paths.canonical}`, paths.canonical);
190
204
  }
191
- return createPlan({ paths, adapters, writeCanonical: false });
205
+ return createPlan({ paths, adapters, writeBundle: false });
192
206
  }
193
207
 
194
208
  const lock = lockState.value;
195
209
  if (!isValidLock(lock, skillName, topology)) throw incompleteLock(paths.lock);
196
- if (canonical.type === "absent") return createPlan({ paths, adapters, writeCanonical: true });
197
- if (canonical.type !== "file") throw locallyModifiedCanonical(paths.canonical);
198
- if (sha256(resolvedOperations.readFileSync(paths.canonical)) !== lock.skillSha256) {
199
- throw locallyModifiedCanonical(paths.canonical);
210
+ if (canonical.type === "absent") {
211
+ assertRemainingBundleMatchesLock(paths, lock, resolvedOperations);
212
+ return createPlan({ paths, adapters, writeBundle: true });
200
213
  }
214
+ if (canonical.type !== "file") throw locallyModifiedCanonical(paths.canonical);
215
+ assertInstalledBundleMatchesLock(paths, lock, resolvedOperations);
201
216
  if (adapters.some(({ state }) => state !== "valid")) throw incompleteLock(paths.lock);
202
217
 
218
+ const installedDigest = lock.bundleSha256 ?? lock.skillSha256;
219
+ const staleFiles =
220
+ lock.files
221
+ ?.map(({ path: relativePath }) => relativePath)
222
+ .filter((relativePath) => !bundle.files.some((file) => file.path === relativePath)) ?? [];
223
+
203
224
  return {
204
- action: lock.skillSha256 === bundle.sha256 ? "no-op" : "upgrade",
225
+ action: installedDigest === bundle.bundleSha256 ? "no-op" : "upgrade",
205
226
  paths,
206
227
  topology,
207
228
  adaptersToMaterialize: [],
208
- writeCanonical: lock.skillSha256 !== bundle.sha256,
229
+ writeBundle: installedDigest !== bundle.bundleSha256,
230
+ staleFiles,
209
231
  };
210
232
  }
211
233
 
212
234
  /**
213
- * Execute an install plan: atomically write the canonical skill, materialize
235
+ * Execute an install plan: atomically write each bundled file, materialize
214
236
  * host adapters, and write the lock; on failure roll back everything touched
215
237
  * and report any recovery failures in the thrown error.
216
- * @param {{packageVersion: string, bundle: {name: string, bytes: Buffer, sha256: string}, plan: object, operations?: object}} options - ddduck version for the lock, loaded bundle, plan from planSkillInstall, and fs overrides.
217
- * @returns {{action: string, skillSha256: string, canonicalPath: string, lockPath: string}} The installation result.
238
+ * @param {{packageVersion: string, bundle: object, plan: object, operations?: object}} options - ddduck version for the lock, loaded bundle, plan from planSkillInstall, and fs overrides.
239
+ * @returns {{action: string, skillSha256: string, bundleSha256: string, canonicalPath: string, lockPath: string}} The installation result.
218
240
  */
219
241
  export function applySkillInstall({ packageVersion, bundle, plan, operations = {} }) {
220
242
  const resolvedOperations = { ...defaultOperations, ...operations };
221
243
  if (plan.action === "no-op") return installResult("no-op", bundle, plan);
222
244
 
223
245
  const touched = [];
224
- const originalCanonical =
225
- plan.writeCanonical && pathState(plan.paths.canonical, resolvedOperations).type === "file"
226
- ? resolvedOperations.readFileSync(plan.paths.canonical)
227
- : null;
228
- let canonicalWritten = false;
246
+ const originalBundle = plan.writeBundle ? snapshotDirectory(plan.paths.canonicalDirectory, resolvedOperations) : null;
247
+ let bundleWritten = false;
229
248
  const materializedAdapters = [];
230
249
 
231
250
  try {
232
- if (plan.writeCanonical) {
233
- atomicWrite(plan.paths.canonical, bundle.bytes, resolvedOperations, touched);
234
- canonicalWritten = true;
251
+ if (plan.writeBundle) {
252
+ bundleWritten = true;
253
+ for (const file of bundle.files) {
254
+ atomicWrite(path.join(plan.paths.canonicalDirectory, file.path), file.bytes, resolvedOperations, touched);
255
+ }
256
+ for (const relativePath of plan.staleFiles ?? []) {
257
+ const stalePath = path.join(plan.paths.canonicalDirectory, relativePath);
258
+ touched.push(stalePath);
259
+ resolvedOperations.rmSync(stalePath, { force: true });
260
+ }
235
261
  }
236
262
  for (const adapter of plan.adaptersToMaterialize) {
237
263
  adapter.materialize({
@@ -252,8 +278,8 @@ export function applySkillInstall({ packageVersion, bundle, plan, operations = {
252
278
  } catch (error) {
253
279
  const recoveryFailures = restoreAfterFailure({
254
280
  plan,
255
- originalCanonical,
256
- canonicalWritten,
281
+ originalBundle,
282
+ bundleWritten,
257
283
  materializedAdapters,
258
284
  operations: resolvedOperations,
259
285
  touched,
@@ -267,19 +293,21 @@ export function applySkillInstall({ packageVersion, bundle, plan, operations = {
267
293
  function installResult(action, bundle, plan) {
268
294
  return {
269
295
  action,
270
- skillSha256: bundle.sha256,
296
+ skillSha256: bundle.skillSha256,
297
+ bundleSha256: bundle.bundleSha256,
271
298
  canonicalPath: toPosixPath(plan.topology.canonicalRelativePath),
272
299
  lockPath: toPosixPath(lockRelativePath),
273
300
  };
274
301
  }
275
302
 
276
- function createPlan({ paths, adapters, writeCanonical }) {
303
+ function createPlan({ paths, adapters, writeBundle }) {
277
304
  return {
278
305
  action: "create",
279
306
  paths,
280
307
  topology: paths.topology,
281
308
  adaptersToMaterialize: adapters.filter(({ state }) => state === "absent").map(({ adapter }) => adapter),
282
- writeCanonical,
309
+ writeBundle,
310
+ staleFiles: [],
283
311
  };
284
312
  }
285
313
 
@@ -358,23 +386,39 @@ function selectTopology({ root, lockState, operations }) {
358
386
  function isValidLock(lock, skillName, topology) {
359
387
  return (
360
388
  lock &&
361
- lock.schemaVersion === lockSchemaVersion &&
389
+ [1, lockSchemaVersion].includes(lock.schemaVersion) &&
362
390
  lock.skill === skillName &&
363
391
  typeof lock.ddduckVersion === "string" &&
364
392
  lock.ddduckVersion.length > 0 &&
365
393
  toPosixPath(lock.canonicalPath) === toPosixPath(topology.canonicalRelativePath) &&
366
394
  /^[a-f0-9]{64}$/.test(lock.skillSha256) &&
395
+ (lock.schemaVersion === 1 || isValidBundleLock(lock)) &&
367
396
  JSON.stringify(lock.adapters) === JSON.stringify(expectedAdapters(topology))
368
397
  );
369
398
  }
370
399
 
371
400
  function createLock({ packageVersion, bundle, topology }) {
401
+ if (bundle.files.length === 1) {
402
+ return {
403
+ schemaVersion: 1,
404
+ skill: bundle.name,
405
+ ddduckVersion: packageVersion,
406
+ canonicalPath: toPosixPath(topology.canonicalRelativePath),
407
+ skillSha256: bundle.skillSha256,
408
+ adapters: expectedAdapters(topology),
409
+ };
410
+ }
372
411
  return {
373
412
  schemaVersion: lockSchemaVersion,
374
413
  skill: bundle.name,
375
414
  ddduckVersion: packageVersion,
376
415
  canonicalPath: toPosixPath(topology.canonicalRelativePath),
377
- skillSha256: bundle.sha256,
416
+ skillSha256: bundle.skillSha256,
417
+ bundleSha256: bundle.bundleSha256,
418
+ files: bundle.files.map(({ path: relativePath, sha256: fileSha256 }) => ({
419
+ path: relativePath,
420
+ sha256: fileSha256,
421
+ })),
378
422
  adapters: expectedAdapters(topology),
379
423
  };
380
424
  }
@@ -412,7 +456,7 @@ function atomicWrite(destination, content, operations, touched) {
412
456
  }
413
457
  }
414
458
 
415
- function restoreAfterFailure({ plan, originalCanonical, canonicalWritten, materializedAdapters, operations, touched }) {
459
+ function restoreAfterFailure({ plan, originalBundle, bundleWritten, materializedAdapters, operations, touched }) {
416
460
  const failures = [];
417
461
  for (const adapter of materializedAdapters.toReversed()) {
418
462
  try {
@@ -425,17 +469,16 @@ function restoreAfterFailure({ plan, originalCanonical, canonicalWritten, materi
425
469
  failures.push(`remove ${adapter.host} adapter: ${error.message}`);
426
470
  }
427
471
  }
428
- if (!canonicalWritten) return failures;
472
+ if (!bundleWritten && originalBundle === null) return failures;
429
473
 
430
474
  try {
431
- if (originalCanonical) {
432
- atomicWrite(plan.paths.canonical, originalCanonical, operations, touched);
433
- } else {
434
- touched.push(plan.paths.canonical);
435
- operations.rmSync(plan.paths.canonical, { force: true });
475
+ touched.push(plan.paths.canonicalDirectory);
476
+ operations.rmSync(plan.paths.canonicalDirectory, { recursive: true, force: true });
477
+ for (const file of originalBundle ?? []) {
478
+ atomicWrite(path.join(plan.paths.canonicalDirectory, file.path), file.bytes, operations, touched);
436
479
  }
437
480
  } catch (error) {
438
- failures.push(`restore canonical skill: ${error.message}`);
481
+ failures.push(`restore canonical skill bundle: ${error.message}`);
439
482
  }
440
483
  return failures;
441
484
  }
@@ -454,6 +497,98 @@ function locallyModifiedCanonical(canonicalPath) {
454
497
  return error;
455
498
  }
456
499
 
500
+ function locallyModifiedBundle(bundlePath) {
501
+ const error = new Error(`Locally modified canonical skill bundle: ${bundlePath}`);
502
+ error.nextAction = `Revert or remove ${bundlePath}, then re-run ddduck install skill update-ddduck-specs.`;
503
+ return error;
504
+ }
505
+
506
+ function readBundleFiles(directory, operations, relativeDirectory = "") {
507
+ const current = path.join(directory, relativeDirectory);
508
+ const files = [];
509
+ const entries = operations
510
+ .readdirSync(current, { withFileTypes: true })
511
+ .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
512
+ for (const entry of entries) {
513
+ const relativePath = path.join(relativeDirectory, entry.name);
514
+ if (entry.isDirectory()) {
515
+ files.push(...readBundleFiles(directory, operations, relativePath));
516
+ continue;
517
+ }
518
+ if (!entry.isFile()) throw new Error(`Unsupported bundled skill entry: ${path.join(directory, relativePath)}`);
519
+ const bytes = operations.readFileSync(path.join(directory, relativePath));
520
+ files.push({ path: toPosixPath(relativePath), bytes, sha256: sha256(bytes) });
521
+ }
522
+ return files;
523
+ }
524
+
525
+ function digestBundle(files) {
526
+ const digest = createHash("sha256");
527
+ for (const file of files) {
528
+ digest.update(`${file.path.length}:${file.path}:${file.bytes.length}:`);
529
+ digest.update(file.bytes);
530
+ }
531
+ return digest.digest("hex");
532
+ }
533
+
534
+ function isValidBundleLock(lock) {
535
+ return (
536
+ /^[a-f0-9]{64}$/.test(lock.bundleSha256) &&
537
+ Array.isArray(lock.files) &&
538
+ lock.files.length > 0 &&
539
+ lock.files.every(
540
+ (file) =>
541
+ file &&
542
+ typeof file.path === "string" &&
543
+ file.path.length > 0 &&
544
+ !path.isAbsolute(file.path) &&
545
+ !file.path.split("/").includes("..") &&
546
+ /^[a-f0-9]{64}$/.test(file.sha256),
547
+ )
548
+ );
549
+ }
550
+
551
+ function assertInstalledBundleMatchesLock(paths, lock, operations) {
552
+ if (lock.schemaVersion === 1) {
553
+ if (sha256(operations.readFileSync(paths.canonical)) !== lock.skillSha256) {
554
+ throw locallyModifiedCanonical(paths.canonical);
555
+ }
556
+ const entries = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
557
+ if (entries.some(({ path: relativePath }) => relativePath !== skillFileName)) {
558
+ throw locallyModifiedBundle(paths.canonicalDirectory);
559
+ }
560
+ return;
561
+ }
562
+
563
+ const installed = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
564
+ const expectedPaths = lock.files.map(({ path: relativePath }) => relativePath);
565
+ if (
566
+ JSON.stringify(installed.map(({ path: relativePath }) => relativePath)) !== JSON.stringify(expectedPaths) ||
567
+ installed.some((file, index) => file.sha256 !== lock.files[index].sha256)
568
+ ) {
569
+ throw locallyModifiedBundle(paths.canonicalDirectory);
570
+ }
571
+ }
572
+
573
+ function assertRemainingBundleMatchesLock(paths, lock, operations) {
574
+ const installed = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
575
+ const expected = new Map(
576
+ lock.schemaVersion === 1
577
+ ? [[skillFileName, lock.skillSha256]]
578
+ : lock.files.map(({ path: relativePath, sha256: fileSha256 }) => [relativePath, fileSha256]),
579
+ );
580
+ if (installed.some((file) => expected.get(file.path) !== file.sha256)) {
581
+ throw locallyModifiedBundle(paths.canonicalDirectory);
582
+ }
583
+ }
584
+
585
+ function snapshotDirectory(directory, operations) {
586
+ const state = pathState(directory, operations);
587
+ if (state.type === "absent") return null;
588
+ if (state.type !== "directory") throw locallyModifiedBundle(directory);
589
+ return readBundleFiles(directory, operations);
590
+ }
591
+
457
592
  function sha256(bytes) {
458
593
  return createHash("sha256").update(bytes).digest("hex");
459
594
  }