ddduck 0.1.2 → 0.3.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.
@@ -2,8 +2,8 @@
2
2
 
3
3
  /**
4
4
  * Entry point for the `ddduck` CLI (the package bin). Dispatches the commands
5
- * init, check, generate, query, install, and the guarantee lifecycle commands
6
- * create/move/split/retire. Mutations run through the locked, staged operation
5
+ * init, check, generate, query, install, --version, and the guarantee lifecycle
6
+ * commands create/move/split/retire. Mutations run through the locked, staged operation
7
7
  * runner in lib/product-operation.mjs; init publishes a fresh product root via
8
8
  * PID-stamped staging; check delegates to check-model.mjs plus the generated
9
9
  * freshness gates. Errors leave through writeCliError with a Next: action line.
@@ -41,12 +41,29 @@ import {
41
41
  import { resolveContainedOutput } from "./lib/product-paths.mjs";
42
42
  import { initStagingPrefix, resolveInitDestination, resolveProductRoot } from "./lib/product-root-resolver.mjs";
43
43
  import { defaultConfigIgnore, findRepositoryRoot, loadDdduckConfig } from "./lib/ddduck-config.mjs";
44
- import { installSkill } from "./lib/skill-installer.mjs";
44
+ import { delegateSkillInstall } from "./lib/skill-delegation.mjs";
45
45
  import { runQuery } from "./query-model.mjs";
46
46
  import { CliUsageError, parseCommandArgs, renderHelp, writeCliError } from "./lib/cli-contract.mjs";
47
+ import { buildAuthoringPlan } from "./lib/product-authoring.mjs";
48
+ import { parseYamlMapping } from "./lib/product-layout.mjs";
49
+ import { compareProductRoots, renderProductDiff } from "./lib/product-diff.mjs";
47
50
 
48
51
  const frameworkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
49
52
 
53
+ const topLevelCommands = [
54
+ "init",
55
+ "check",
56
+ "generate",
57
+ "query",
58
+ "diff",
59
+ "install",
60
+ "create",
61
+ "move",
62
+ "split",
63
+ "retire",
64
+ "--version",
65
+ ];
66
+
50
67
  const cliArgs = process.argv.slice(2);
51
68
 
52
69
  try {
@@ -65,17 +82,21 @@ function run(args) {
65
82
  process.stdout.write(renderHelp());
66
83
  return;
67
84
  }
68
- const [command, ...commandArgs] = args;
69
- if (
70
- !command ||
71
- !["init", "check", "generate", "query", "install", "create", "move", "split", "retire"].includes(command)
72
- ) {
85
+ const [rawCommand, ...commandArgs] = args;
86
+ // -v is the only short alias in the contract; normalizing it here keeps the
87
+ // help lookup, the dispatch, and the error next action on one command name.
88
+ const command = rawCommand === "-v" ? "--version" : rawCommand;
89
+ if (!command || !topLevelCommands.includes(command)) {
73
90
  throw new CliUsageError(`Unknown command ${command ?? "(missing)"}`);
74
91
  }
75
92
  if (commandArgs.includes("--help")) {
76
93
  process.stdout.write(renderHelp(command));
77
94
  return;
78
95
  }
96
+ if (command === "--version") {
97
+ reportVersion(commandArgs);
98
+ return;
99
+ }
79
100
  if (command === "init") {
80
101
  const { result, json } = initialize(commandArgs);
81
102
  writeProductOperationResult(result, json);
@@ -93,10 +114,24 @@ function run(args) {
93
114
  runQuery(commandArgs);
94
115
  return;
95
116
  }
117
+ if (command === "diff") {
118
+ const { options } = parseCommandArgs(commandArgs, {
119
+ options: { base: { value: true }, root: { value: true }, json: { value: false } },
120
+ });
121
+ const base = requiredOption(options, "base", "diff requires --base <previous-product-root>");
122
+ const root = resolveProductRoot({ explicitRoot: options.root });
123
+ const report = compareProductRoots(path.resolve(base), root);
124
+ process.stdout.write(`${options.json ? JSON.stringify(report) : renderProductDiff(report)}\n`);
125
+ return;
126
+ }
96
127
  if (command === "install") {
97
128
  install(commandArgs);
98
129
  return;
99
130
  }
131
+ if (command === "create" && ["domain", "concept", "use-case"].includes(commandArgs[0])) {
132
+ createNode(commandArgs);
133
+ return;
134
+ }
100
135
  if (["create", "move", "split", "retire"].includes(command)) {
101
136
  transitionGuarantee(command, commandArgs);
102
137
  return;
@@ -104,39 +139,53 @@ function run(args) {
104
139
  }
105
140
 
106
141
  /**
107
- * Implement `ddduck install skill update-ddduck-specs`: install the bundled
108
- * host skill adapter and .ddduck/agent-skills.lock.json into --repo.
142
+ * Implement `ddduck --version` (alias `-v`): print the installed package name
143
+ * and version from the package's own manifest. It reads no product, so it also
144
+ * serves as the probe that confirms a candidate executable really is ddduck.
145
+ * @param {string[]} args - Arguments after the version flag.
146
+ * @returns {void}
147
+ */
148
+ function reportVersion(args) {
149
+ const { options } = parseCommandArgs(args, { options: { json: { value: false } } });
150
+ const { name, version } = readPackageManifest();
151
+ process.stdout.write(options.json ? `${JSON.stringify({ name, version })}\n` : `${name} ${version}\n`);
152
+ }
153
+
154
+ function readPackageManifest() {
155
+ return JSON.parse(readFileSync(path.join(frameworkRoot, "package.json"), "utf8"));
156
+ }
157
+
158
+ /**
159
+ * Implement `ddduck install skill`: print the `npx skills add` command for the
160
+ * bundled skills directory, confirm it unless --yes was passed, run it in
161
+ * --repo, and propagate its exit status. ddduck installs nothing itself.
109
162
  * @param {string[]} args - Arguments after the `install` command word.
110
163
  * @returns {void}
111
164
  */
112
165
  function install(args) {
113
166
  const { positionals, options } = parseCommandArgs(args, {
114
- positionals: { min: 2, max: 2 },
115
- options: { repo: { value: true } },
167
+ positionals: { min: 1, max: 1, syntax: "ddduck install skill [--repo <repository-root>] [--yes]" },
168
+ options: { repo: { value: true }, yes: { value: false } },
116
169
  });
117
- const [kind, skillName] = positionals;
118
- if (kind !== "skill" || skillName !== "update-ddduck-specs") {
119
- throw new CliUsageError("install requires skill update-ddduck-specs");
170
+ if (positionals[0] !== "skill") {
171
+ throw new CliUsageError(`install requires the subject skill, not ${JSON.stringify(positionals[0])}`);
120
172
  }
121
- const packageVersion = JSON.parse(readFileSync(path.join(frameworkRoot, "package.json"), "utf8")).version;
122
173
  const repository = path.resolve(options.repo ?? process.cwd());
123
- // A typo'd --repo must fail instead of silently manufacturing a directory
124
- // tree (and a lock) at the wrong path while the real repository gets nothing.
174
+ // A typo'd --repo must fail instead of installing into the wrong directory
175
+ // (npx would happily create it) while the real repository gets nothing.
125
176
  if (!existsSync(repository) || !statSync(repository).isDirectory()) {
126
177
  throw new CliUsageError(`install requires an existing repository directory: ${repository}`, {
127
178
  nextAction: "Pass --repo <existing-repository-root> and retry.",
128
179
  });
129
180
  }
130
- const result = installSkill({
131
- repository,
132
- skillName,
133
- skillPath: path.join(frameworkRoot, "skills", skillName, "SKILL.md"),
134
- packageVersion,
135
- });
136
- const actionLabels = { create: "created", upgrade: "upgraded", "no-op": "no-op" };
137
- process.stdout.write(
138
- `install skill ${skillName} (${actionLabels[result.action]}) in ${repository}; canonical: ${result.canonicalPath}; lock: ${result.lockPath}\n`,
139
- );
181
+ const skillsDirectory = path.join(frameworkRoot, "skills");
182
+ if (!existsSync(skillsDirectory) || !statSync(skillsDirectory).isDirectory()) {
183
+ const error = new Error(`Missing bundled skills directory: ${skillsDirectory}`);
184
+ error.nextAction = "Reinstall ddduck so the packaged skills are present, then retry.";
185
+ throw error;
186
+ }
187
+ const { status } = delegateSkillInstall({ skillsDirectory, repository, assumeYes: options.yes });
188
+ if (status !== 0) process.exitCode = status;
140
189
  }
141
190
 
142
191
  /**
@@ -358,6 +407,38 @@ function generate(args) {
358
407
  writeProductOperationResult(result, options.json);
359
408
  }
360
409
 
410
+ function createNode(args) {
411
+ const kind = args[0];
412
+ const shared = { root: { value: true }, json: { value: false } };
413
+ const fields =
414
+ kind === "use-case"
415
+ ? { file: { value: true } }
416
+ : {
417
+ id: { value: true },
418
+ name: { value: true },
419
+ purpose: { value: true },
420
+ ...(kind === "concept" ? { owner: { value: true } } : {}),
421
+ };
422
+ const { options } = parseCommandArgs(args, {
423
+ positionals: { min: 1, max: 1 },
424
+ options: { ...shared, ...fields },
425
+ });
426
+ const required = (field) => requiredOption(options, field, `create ${kind} requires --${field} <value>`);
427
+ const request =
428
+ kind === "use-case"
429
+ ? { kind: "UseCase", node: parseYamlMapping(path.resolve(required("file"))) }
430
+ : {
431
+ kind: kind === "domain" ? "Domain" : "Concept",
432
+ id: required("id"),
433
+ name: required("name"),
434
+ purpose: required("purpose"),
435
+ ...(kind === "concept" ? { ownerDomain: required("owner") } : {}),
436
+ };
437
+ const root = resolveProductRoot({ explicitRoot: options.root });
438
+ const result = runProductOperation({ root, transform: (snapshot) => buildAuthoringPlan(snapshot, request) });
439
+ writeProductOperationResult(result, options.json);
440
+ }
441
+
361
442
  /**
362
443
  * Implement the guarantee lifecycle commands create, move, split, and retire:
363
444
  * parse per-command options, require a registered decision for split/retire,
@@ -679,8 +760,8 @@ function writeConfigIfAbsent(destination) {
679
760
  }
680
761
 
681
762
  function nextActionFor(args) {
682
- const command = args[0];
683
- if (["init", "check", "generate", "query", "install", "create", "move", "split", "retire"].includes(command)) {
763
+ const command = args[0] === "-v" ? "--version" : args[0];
764
+ if (topLevelCommands.includes(command)) {
684
765
  return `Run ddduck ${command} --help, correct the input, and retry.`;
685
766
  }
686
767
  return "Run ddduck --help, choose a command, and retry.";
@@ -137,7 +137,7 @@ function renderModelOverview(view) {
137
137
  lines.push("## Decisions", "");
138
138
  for (const decision of view.decisions) lines.push(`- \`${decision}\``);
139
139
  lines.push("");
140
- return lines.join("\n");
140
+ return `${lines.join("\n").trimEnd()}\n`;
141
141
  }
142
142
 
143
143
  function renderNodeList(lines, title, nodes) {
@@ -82,9 +82,18 @@ 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
+ "Flags: --version (-v) prints the installed ddduck version.",
86
87
  "Run `ddduck <command> --help` for command usage.",
87
88
  ],
89
+ "--version": [
90
+ "Syntax: ddduck --version [--json] (alias: ddduck -v)",
91
+ "Defaults: text output; no product root is resolved and no product is read.",
92
+ "Writes: nothing.",
93
+ "Success output: one text line naming the package and its installed version.",
94
+ "Exit status: 0 on success or help; 1 on invalid input.",
95
+ "JSON: --json emits one object with name and version.",
96
+ ],
88
97
  init: [
89
98
  "Syntax: ddduck init [destination] --id model:<product-id> [--json]",
90
99
  "Defaults: destination is the productRoot configured in .ddduck/config.json, else ddd.",
@@ -118,19 +127,30 @@ export function renderHelp(command) {
118
127
  "JSON: output is always JSON; --json is accepted and has no effect.",
119
128
  "Options: --id <model-node-id> (repeatable for context), --root <product-root>, --history.",
120
129
  ],
130
+ diff: [
131
+ "Syntax: ddduck diff --base <previous-product-root> [--root <product-root>] [--json]",
132
+ "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); --base is required; output is text.",
133
+ "Writes: nothing; source roots are validated independently and generated freshness is not required.",
134
+ "Success output: a stable-ID comparison of added, removed, changed, and relocated canonical records, with source digests and explicit exclusions.",
135
+ "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.",
136
+ "JSON: --json emits one ModelDiff document; comparison is canonical YAML only and requires human interpretation.",
137
+ ],
121
138
  install: [
122
- "Syntax: ddduck install skill update-ddduck-specs [--repo <repository-root>]",
123
- "Defaults: --repo is the current directory.",
124
- "Writes: the selected host skill adapter and .ddduck/agent-skills.lock.json in --repo.",
125
- "Success output: one text result with the action (created, upgraded, or no-op), repository, canonical path, and lock path.",
126
- "Exit status: 0 on installation, no-op, or help; nonzero on invalid input or conflicting host state.",
139
+ "Syntax: ddduck install skill [--repo <repository-root>] [--yes]",
140
+ "Defaults: --repo is the current directory; the confirmation prompt is asked unless --yes is passed.",
141
+ "Writes: nothing directly; it runs `npx --yes '--package=skills@^1.7.0' -- skills add <ddduck>/skills --skill '*' -y` in --repo, and the skills CLI installs every bundled skill into that project (canonical copy plus per-agent symlinks) and owns its own state.",
142
+ "Success output: the exact delegated command on its own line, the confirmation prompt, then the streamed output of the delegated command.",
143
+ "Exit status: 0 on a successful delegated install or help; 1 when the confirmation is declined (nothing installed), when input is invalid, or when npx cannot be started; otherwise the exit status of the delegated command.",
127
144
  "JSON: unavailable; --json is not accepted.",
128
145
  ],
129
146
  create: [
130
147
  "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.",
148
+ "Syntax: ddduck create domain --id domain:<slug> --name <text> --purpose <text> [--root <product-root>] [--json]",
149
+ "Syntax: ddduck create concept --id concept:<slug> --owner domain:<slug> --name <text> --purpose <text> [--root <product-root>] [--json]",
150
+ "Syntax: ddduck create use-case --file <yaml-file> [--root <product-root>] [--json]",
151
+ "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.",
152
+ "Writes: the new node, its owning collection, and all generated views through staged publication; input files are read-only.",
153
+ "Success output: one text result with affected IDs, root, canonical paths, and generated paths.",
134
154
  "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
155
  "JSON: --json emits the same result as one JSON object.",
136
156
  ],
@@ -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
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Delegation for `ddduck install skill`. ddduck installs nothing itself: it
3
+ * builds the `npx skills add` command for the bundled skills directory, prints
4
+ * that command verbatim, confirms it on standard input unless --yes was
5
+ * passed, then runs it as a child process whose output streams through and
6
+ * whose exit status is propagated. The `skills` CLI owns the host topology
7
+ * (canonical copy plus per-agent symlinks) and its own installation state.
8
+ */
9
+
10
+ import { Buffer } from "node:buffer";
11
+ import { spawnSync } from "node:child_process";
12
+ import { readSync } from "node:fs";
13
+ import { shellQuote } from "./context-pack.mjs";
14
+
15
+ const defaultOperations = { spawnSync, readAnswer };
16
+
17
+ /** The delegated CLI, pinned so npx resolves it from the registry. */
18
+ export const skillsPackageSpecifier = "skills@^1.7.0";
19
+
20
+ /**
21
+ * Build the delegated command: every bundled skill, project scope, and no
22
+ * second confirmation inside the child (ddduck already confirmed the printed
23
+ * command). `npx --yes` keeps an uncached `skills` from prompting to install.
24
+ * `--package=` is load-bearing: a bare command name resolves against the
25
+ * target repository's node_modules/.bin first, so an unrelated `skills` binary
26
+ * hoisted there (a generic name in a monorepo) would run instead of the CLI
27
+ * the printed command names. The `--` separator keeps npx from reading the
28
+ * command word as another package specifier.
29
+ * @param {string} skillsDirectory - Absolute path of the bundled skills directory.
30
+ * @returns {{command: string, args: string[]}} The command and its argument vector.
31
+ */
32
+ export function buildSkillsAddCommand(skillsDirectory) {
33
+ return {
34
+ command: "npx",
35
+ args: [
36
+ "--yes",
37
+ `--package=${skillsPackageSpecifier}`,
38
+ "--",
39
+ "skills",
40
+ "add",
41
+ skillsDirectory,
42
+ "--skill",
43
+ "*",
44
+ "-y",
45
+ ],
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Render the delegated command as one copy-pasteable shell line.
51
+ * @param {string} skillsDirectory - Absolute path of the bundled skills directory.
52
+ * @returns {string} The command line, shell-quoted where needed.
53
+ */
54
+ export function renderSkillsAddCommand(skillsDirectory) {
55
+ const { command, args } = buildSkillsAddCommand(skillsDirectory);
56
+ return [command, ...args].map(shellQuote).join(" ");
57
+ }
58
+
59
+ /**
60
+ * Print the delegated command, confirm it unless --yes was passed, and run it
61
+ * in the target repository.
62
+ * @param {{skillsDirectory: string, repository: string, assumeYes?: boolean, stdout?: {write: (chunk: string) => unknown}, operations?: {spawnSync?: Function, readAnswer?: Function}}} options - Bundled skills directory, repository to install into, confirmation bypass, output stream, and child-process/stdin overrides for tests.
63
+ * @returns {{status: number}} The delegated command's exit status (1 when it was killed by a signal).
64
+ */
65
+ export function delegateSkillInstall({
66
+ skillsDirectory,
67
+ repository,
68
+ assumeYes = false,
69
+ stdout = process.stdout,
70
+ operations = {},
71
+ }) {
72
+ const resolvedOperations = { ...defaultOperations, ...operations };
73
+ const { command, args } = buildSkillsAddCommand(skillsDirectory);
74
+
75
+ stdout.write(`install skill delegates to the skills CLI; ddduck runs this command in ${repository}:\n`);
76
+ stdout.write(`${renderSkillsAddCommand(skillsDirectory)}\n`);
77
+ if (!assumeYes) {
78
+ stdout.write("Run it? [y/n] ");
79
+ const answer = resolvedOperations.readAnswer();
80
+ if (answer !== "y" && answer !== "Y") throw declinedConfirmation();
81
+ }
82
+
83
+ const result = resolvedOperations.spawnSync(command, args, { cwd: repository, stdio: "inherit" });
84
+ if (result.error) {
85
+ const error = new Error(`Failed to run the skills CLI via npx: ${result.error.message}`);
86
+ error.nextAction = "Make sure npx (Node.js) is on PATH, then re-run the printed command yourself.";
87
+ throw error;
88
+ }
89
+ // A signal-killed child reports a null status; the CLI still has to exit
90
+ // nonzero, because nothing was necessarily installed.
91
+ return { status: Number.isInteger(result.status) ? result.status : 1 };
92
+ }
93
+
94
+ // A declined confirmation is neither an input error nor a host-state error:
95
+ // nothing was touched, and the only two ways forward are the flag or the
96
+ // printed command.
97
+ function declinedConfirmation() {
98
+ const error = new Error("install skill declined at the confirmation prompt; nothing was installed");
99
+ error.nextAction = "Re-run with --yes to skip the confirmation, or run the printed command yourself.";
100
+ return error;
101
+ }
102
+
103
+ /**
104
+ * Read one answer line from standard input synchronously, one byte at a time,
105
+ * so the prompt works on a terminal without switching the synchronous CLI to
106
+ * an asynchronous readline. A closed or exhausted stdin yields an empty
107
+ * answer, which declines.
108
+ * @returns {string} The trimmed answer (empty at end of input).
109
+ */
110
+ function readAnswer() {
111
+ const buffer = Buffer.alloc(1);
112
+ let answer = "";
113
+ for (;;) {
114
+ let bytesRead;
115
+ try {
116
+ bytesRead = readSync(0, buffer, 0, 1, null);
117
+ } catch (error) {
118
+ // A non-blocking terminal has nothing to give yet; EOF ends the answer.
119
+ if (error.code === "EAGAIN") continue;
120
+ if (error.code === "EOF") break;
121
+ throw error;
122
+ }
123
+ if (bytesRead === 0) break;
124
+ const character = buffer.toString("utf8");
125
+ if (character === "\n" || character === "\r") break;
126
+ answer += character;
127
+ }
128
+ return answer.trim();
129
+ }