ddduck 0.2.0 → 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.
package/README.md CHANGED
@@ -91,11 +91,16 @@ safety net.
91
91
  Install the evidence-backed model maintenance skill in a consumer repository:
92
92
 
93
93
  ```bash
94
- ddduck install skill update-ddduck-specs --repo <repository-root>
94
+ ddduck install skill --repo <repository-root>
95
95
  ```
96
96
 
97
- `--repo` defaults to the current directory. See [the CLI reference](docs/cli.md#install-an-agent-skill)
98
- for the installed layout, host invocation, and plan-only behavior.
97
+ `--repo` defaults to the current directory. ddduck installs nothing itself: it prints the exact
98
+ `npx --yes '--package=skills@^1.7.0' -- skills add <ddduck-package>/skills --skill '*' -y`
99
+ command it will run, asks `[y/n]`, and
100
+ delegates the installation to the [`skills`](https://www.npmjs.com/package/skills) CLI, which
101
+ supports 79 agent hosts. Pass `--yes` to skip the confirmation in CI or when an agent runs the
102
+ command. See [the CLI reference](docs/cli.md#install-an-agent-skill) for the delegated flags,
103
+ exit statuses, host invocation, and plan-only behavior.
99
104
 
100
105
  ## Product queries
101
106
 
package/docs/cli.md CHANGED
@@ -14,6 +14,25 @@ retry logic never has to string-match standard error; every other failure exits
14
14
  This package is published to npm as `ddduck`; install the CLI globally with `npm install -g ddduck`
15
15
  (see [the getting-started guide](getting-started.md#install-ddduck)).
16
16
 
17
+ ## `--version`
18
+
19
+ ```text
20
+ ddduck --version [--json]
21
+ ddduck -v [--json]
22
+ ```
23
+
24
+ | Option | Default | Meaning |
25
+ | -------- | ------- | ---------------------------------------------- |
26
+ | `--json` | false | Emit one JSON object instead of the text line. |
27
+
28
+ `--version` (alias `-v`) prints the installed package name and version taken from the package's
29
+ own `package.json`, as one text line — `ddduck <version>` — or, with `--json`, one object:
30
+ `{"name":"ddduck","version":"<version>"}`. It resolves no product root and reads no product, so
31
+ it is the cheapest way to confirm that a candidate executable really is ddduck and which version
32
+ is installed. `ddduck --version --help` prints the flag's contract like every other command.
33
+ Exit status: 0 on success or help; 1 on invalid input (an unknown option, for example). The
34
+ retryable busy exit 2 cannot occur, because no product root is touched.
35
+
17
36
  ## Product root resolution
18
37
 
19
38
  Product-facing commands accept `--root <product-root>`. When `--root` is omitted, ddduck resolves
@@ -286,28 +305,37 @@ The command does not recursively expand the perimeter.
286
305
  ## Install an agent skill
287
306
 
288
307
  ```text
289
- ddduck install skill update-ddduck-specs [--repo <repository-root>]
308
+ ddduck install skill [--repo <repository-root>] [--yes]
290
309
  ```
291
310
 
292
- `--repo` defaults to the current directory. The installer chooses the least intrusive host
293
- topology from the repository's existing directories:
311
+ `--repo` defaults to the current directory. ddduck installs nothing itself: it delegates to the
312
+ [`skills`](https://www.npmjs.com/package/skills) CLI, which supports 79 agent hosts and owns the
313
+ installed layout and its own state. The command prints the exact command it will run, on its own
314
+ line, and then runs it in `--repo`:
294
315
 
295
316
  ```text
296
- no .agents/ or .claude/ -> .agents/skills/update-ddduck-specs/SKILL.md
297
- .agents/ only -> .agents/skills/update-ddduck-specs/SKILL.md
298
- .claude/ only -> .claude/skills/update-ddduck-specs/SKILL.md
299
- .agents/ and .claude/ -> .agents/skills/update-ddduck-specs/SKILL.md
300
- .claude/skills/update-ddduck-specs -> ../../.agents/skills/update-ddduck-specs
317
+ npx --yes '--package=skills@^1.7.0' -- skills add <ddduck-package>/skills --skill '*' -y
301
318
  ```
302
319
 
303
- The installer writes `.ddduck/agent-skills.lock.json` with the selected canonical path, host
304
- adapters, package version, installed file manifest, `SKILL.md` SHA-256, and whole-bundle SHA-256.
305
- It installs `SKILL.md` and its bundled `references/` directory, refuses locally modified managed
306
- files, and upgrades legacy single-file locks without overwriting extra local files. It does not
307
- create a host directory for a host that is absent from the repository, except for the `.agents/`
308
- fallback when no host directory exists. On success it prints one result line naming the action
309
- (`created`, `upgraded`, or `no-op`), the repository, the canonical skill path, and the lock path. It does not accept
310
- `--json`.
320
+ The bundled skills directory is resolved inside the installed ddduck package
321
+ (`./node_modules/ddduck/skills` from a consumer repository, or the repository's own `skills/`
322
+ when the repository under analysis is ddduck itself). `--skill '*'` installs every bundled skill,
323
+ the absent `-g` keeps the install project-scoped, and `-y` answers the delegated CLI's own
324
+ prompts, because the command it applies has already been shown and confirmed here. `skills`
325
+ writes one canonical copy (by default `.agents/skills/<skill-name>/`) and symlinks it into the
326
+ agent directories that exist in the project.
327
+
328
+ `--package=` pins the delegated package so npx resolves it from the registry. Without it, npx
329
+ resolves the bare name `skills` against `--repo`'s own `node_modules/.bin` first, so an unrelated
330
+ binary under that generic name — a sibling package hoisted to a monorepo root, for example —
331
+ would run instead of the CLI the printed command names. The `--` separator keeps npx from
332
+ reading the command word as a second package specifier.
333
+
334
+ Before running it, ddduck asks `[y/n]` on standard input. Only `y` or `Y` proceeds; any other
335
+ answer — including an empty line and a closed standard input — aborts, installs nothing, and
336
+ exits 1. `--yes` skips that confirmation and keeps the command usable in CI and by agents; the
337
+ command is still printed. The delegated command's output streams through unchanged and its exit
338
+ status is propagated. `--json` is not accepted.
311
339
 
312
340
  Invoke the skill from the relevant host:
313
341
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ddduck",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "An opinionated DDD framework for authoring, validating, and visualizing machine-readable product-spec models.",
5
5
  "keywords": [
6
6
  "ddd",
@@ -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,7 +41,7 @@ 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
47
  import { buildAuthoringPlan } from "./lib/product-authoring.mjs";
@@ -50,6 +50,20 @@ import { compareProductRoots, renderProductDiff } from "./lib/product-diff.mjs";
50
50
 
51
51
  const frameworkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
52
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
+
53
67
  const cliArgs = process.argv.slice(2);
54
68
 
55
69
  try {
@@ -68,17 +82,21 @@ function run(args) {
68
82
  process.stdout.write(renderHelp());
69
83
  return;
70
84
  }
71
- const [command, ...commandArgs] = args;
72
- if (
73
- !command ||
74
- !["init", "check", "generate", "query", "diff", "install", "create", "move", "split", "retire"].includes(command)
75
- ) {
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)) {
76
90
  throw new CliUsageError(`Unknown command ${command ?? "(missing)"}`);
77
91
  }
78
92
  if (commandArgs.includes("--help")) {
79
93
  process.stdout.write(renderHelp(command));
80
94
  return;
81
95
  }
96
+ if (command === "--version") {
97
+ reportVersion(commandArgs);
98
+ return;
99
+ }
82
100
  if (command === "init") {
83
101
  const { result, json } = initialize(commandArgs);
84
102
  writeProductOperationResult(result, json);
@@ -121,39 +139,53 @@ function run(args) {
121
139
  }
122
140
 
123
141
  /**
124
- * Implement `ddduck install skill update-ddduck-specs`: install the bundled
125
- * host skill bundle 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.
126
162
  * @param {string[]} args - Arguments after the `install` command word.
127
163
  * @returns {void}
128
164
  */
129
165
  function install(args) {
130
166
  const { positionals, options } = parseCommandArgs(args, {
131
- positionals: { min: 2, max: 2 },
132
- 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 } },
133
169
  });
134
- const [kind, skillName] = positionals;
135
- if (kind !== "skill" || skillName !== "update-ddduck-specs") {
136
- 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])}`);
137
172
  }
138
- const packageVersion = JSON.parse(readFileSync(path.join(frameworkRoot, "package.json"), "utf8")).version;
139
173
  const repository = path.resolve(options.repo ?? process.cwd());
140
- // A typo'd --repo must fail instead of silently manufacturing a directory
141
- // 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.
142
176
  if (!existsSync(repository) || !statSync(repository).isDirectory()) {
143
177
  throw new CliUsageError(`install requires an existing repository directory: ${repository}`, {
144
178
  nextAction: "Pass --repo <existing-repository-root> and retry.",
145
179
  });
146
180
  }
147
- const result = installSkill({
148
- repository,
149
- skillName,
150
- skillPath: path.join(frameworkRoot, "skills", skillName, "SKILL.md"),
151
- packageVersion,
152
- });
153
- const actionLabels = { create: "created", upgrade: "upgraded", "no-op": "no-op" };
154
- process.stdout.write(
155
- `install skill ${skillName} (${actionLabels[result.action]}) in ${repository}; canonical: ${result.canonicalPath}; lock: ${result.lockPath}\n`,
156
- );
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;
157
189
  }
158
190
 
159
191
  /**
@@ -728,8 +760,8 @@ function writeConfigIfAbsent(destination) {
728
760
  }
729
761
 
730
762
  function nextActionFor(args) {
731
- const command = args[0];
732
- 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)) {
733
765
  return `Run ddduck ${command} --help, correct the input, and retry.`;
734
766
  }
735
767
  return "Run ddduck --help, choose a command, and retry.";
@@ -83,8 +83,17 @@ export function renderHelp(command) {
83
83
  undefined: [
84
84
  "Usage: ddduck <command> [options]",
85
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.",
@@ -127,11 +136,11 @@ export function renderHelp(command) {
127
136
  "JSON: --json emits one ModelDiff document; comparison is canonical YAML only and requires human interpretation.",
128
137
  ],
129
138
  install: [
130
- "Syntax: ddduck install skill update-ddduck-specs [--repo <repository-root>]",
131
- "Defaults: --repo is the current directory.",
132
- "Writes: the selected host skill bundle and .ddduck/agent-skills.lock.json in --repo.",
133
- "Success output: one text result with the action (created, upgraded, or no-op), repository, canonical path, and lock path.",
134
- "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.",
135
144
  "JSON: unavailable; --json is not accepted.",
136
145
  ],
137
146
  create: [
@@ -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
+ }
@@ -10,7 +10,7 @@ Maintain or bootstrap a ddduck product model from evidence in the current workin
10
10
  ## Operating contract
11
11
 
12
12
  - Resolve the repository root, read every applicable instruction file, and inspect Git status before analysis.
13
- - Use the repository-compatible ddduck executable. Do not install dependencies or substitute an unrelated global version.
13
+ - Resolve a repository-compatible ddduck executable with the probe order in [executable resolution](references/executable-resolution.md). Do not install dependencies or substitute an unrelated global version.
14
14
  - Default to plan-only. Mutate model files only when the current request explicitly authorizes applying the evidence-backed proposal.
15
15
  - Preserve unrelated work. Stop when intended model edits overlap user changes inseparably or the analyzed tree changes before application.
16
16
  - Write only `<root>/product.yaml`, `<root>/model/**`, `<root>/decisions/**`, and regenerated `<root>/generated/**`. Regenerate derived views; never edit them directly.
@@ -22,13 +22,14 @@ Maintain or bootstrap a ddduck product model from evidence in the current workin
22
22
 
23
23
  Read each selected reference completely before acting. All references are one level below this file.
24
24
 
25
+ - Before the first ddduck command, read [executable resolution](references/executable-resolution.md).
25
26
  - For every bootstrap, audit, or reconciliation, read [modeling and evidence](references/modeling-and-evidence.md).
26
27
  - When comparing revisions, reviewing moves/removals, or selecting affected context, read [reviewing changes](references/reviewing-changes.md). Start with `ddduck diff --base <before-root> --root <after-root> --json` when two valid roots exist.
27
28
  - Before proposing or applying canonical changes, read [authoring and verification](references/authoring-and-verification.md). Use `ddduck create domain`, `ddduck create concept`, and `ddduck create use-case` for the kinds they support.
28
29
 
29
30
  ## Workflow
30
31
 
31
- 1. Resolve and classify the product root. Record `query spec` and `check` independently; an invalid existing model is not an absent model.
32
+ 1. Resolve the executable, then resolve and classify the product root. Record `query spec` and `check` independently; an invalid existing model is not an absent model, and a root that was never inspected is `undetermined`, never absent.
32
33
  2. Gather current code, tests, interfaces, documentation, configuration, schemas, and accepted decisions. Record contradictions, exclusions, and coverage gaps.
33
34
  3. Separate observed behavior, accepted intent, open questions, and rejected alternatives. Classify every material model difference using the modeling reference.
34
35
  4. Produce the plan-only report defined in the authoring reference. A partial or zero-model-change result is valid when it is the evidence-backed outcome.
@@ -4,15 +4,17 @@ Read this reference before proposing or applying canonical ddduck changes.
4
4
 
5
5
  ## Resolve and classify once
6
6
 
7
- Use an explicitly requested root. Otherwise let ddduck resolve the enclosing product root, then `.ddduck/config.json`, then the unique repository candidate. `ddduck query spec --root <root> --json` reports the resolved root. Ambiguity is a stop condition: report candidates and ask rather than initializing a second model.
7
+ Root resolution is delegated to the executable, so resolve one first with the probe order in [executable resolution](executable-resolution.md). Every `ddduck …` command in this file names that resolved command; substitute it before running. Then use an explicitly requested root. Otherwise let ddduck resolve the enclosing product root, then `.ddduck/config.json`, then the unique repository candidate. `ddduck query spec --root <root> --json` reports the resolved root. Ambiguity is a stop condition: report candidates and ask rather than initializing a second model.
8
8
 
9
- Classify the selected path:
9
+ Classify the selected path. These three states are observations; each requires an inspection that actually ran.
10
10
 
11
11
  - `existing`: `product.yaml` exists. Run `query spec` and `check` independently. A failing model remains existing and invalid.
12
12
  - `absent`: the root is missing or empty. Inspect the repository before proposing initialization.
13
13
  - `path-collision`: the path is non-empty but is not a recognizable product. Report it and do not initialize over it.
14
14
 
15
- Stop before mutation when the executable is missing or incompatible, the root is ambiguous, the path collides, bootstrap identity or seams are ungrounded, evidence conflicts change the proposal materially, target files overlap inseparable user work, or the analyzed working tree changed.
15
+ When no executable resolves, or the inspection that distinguishes those states did not complete, the state is `undetermined`: the absence of an observation rather than a fourth thing observed. Report `undetermined` and stop; never downgrade it to `absent`, because `absent` is the only state that authorizes initialization.
16
+
17
+ Stop before mutation when the executable is unresolved, missing, or incompatible, the model state is `undetermined`, the root is ambiguous, the path collides, bootstrap identity or seams are ungrounded, evidence conflicts change the proposal materially, target files overlap inseparable user work, or the analyzed working tree changed.
16
18
 
17
19
  ## Plan-only report
18
20
 
@@ -0,0 +1,39 @@
1
+ # Executable resolution
2
+
3
+ Read this reference before running any ddduck command. Root classification, baselines, and verification results are only as trustworthy as the executable that produced them, so resolving one is a precondition for the workflow rather than a step inside it.
4
+
5
+ ## Probe in order
6
+
7
+ Take the first candidate that runs. Confirm a candidate by appending `--version` to that candidate's own command, never by running a different one: `node_modules/.bin/ddduck --version` for probe 2, `node scripts/ddduck.mjs --version` for probe 3, and the bare `ddduck --version` only when `ddduck` on `PATH` is itself the candidate being probed. A working repository-local candidate must not be rejected because `ddduck` is absent from `PATH`. The command prints `ddduck <version>`; read the resolved version from that output.
8
+
9
+ 1. A command or path supplied in the current request.
10
+ 2. `node_modules/.bin/ddduck` at the repository root, then at any enclosing workspace root.
11
+ 3. `node scripts/ddduck.mjs`, the repository's own `package.json` `bin` target, when the repository under analysis is ddduck itself.
12
+ 4. `ddduck` on `PATH`.
13
+ 5. A ddduck source checkout that the user named or that the repository records, only when its reported version matches the version the repository pins: a `ddduck` dependency in `package.json`, or `ddduckVersion` in `.ddduck/agent-skills.lock.json`.
14
+
15
+ Do not install dependencies and do not substitute an unrelated global version. A candidate that fails to run, or that reports a version incompatible with the pinned one, is not a resolution; continue with the next probe.
16
+
17
+ Record which probe resolved, the exact command, and its reported version. Reuse that one command for every subsequent ddduck invocation.
18
+
19
+ Every `ddduck <subcommand>` form written in this skill and its references names the resolved command, not the literal `ddduck` on `PATH`. Substitute the resolved command before running any of them: with probe 3 resolved, `ddduck check --root <root>` is run as `node scripts/ddduck.mjs check --root <root>`.
20
+
21
+ ## Named paths only, never scan
22
+
23
+ Probe only the paths listed above plus the paths the request or the repository explicitly names. Never search parent directories or the wider filesystem for a checkout. An unbounded search exhausts the available budget, times out, and still resolves nothing.
24
+
25
+ ## An unresolved executable is `undetermined`
26
+
27
+ `existing`, `absent`, and `path-collision` are observations, and each one requires an inspection that actually ran. When no probe resolves, that inspection never ran and the model state is `undetermined`.
28
+
29
+ Report `undetermined`, stop before any mutation, and never downgrade it to `absent`. Only `absent` authorizes initialization, and initializing on an unverified `absent` bootstraps a second product root beside a healthy one. A named `productRoot` in `.ddduck/config.json`, or an existing `product.yaml`, is evidence against `absent` even while the executable remains unresolved.
30
+
31
+ ## Remediation
32
+
33
+ When no probe resolves, report every probe attempted and its outcome, then present these options and let the user choose:
34
+
35
+ - `npm i -g ddduck`
36
+ - `npm i --save-dev ddduck`
37
+ - `npx ddduck@<version>`, using the pinned version when the repository records one
38
+
39
+ Present the options only. Do not install anything, and do not invoke an installer or `npx` on the user's behalf.
@@ -1,6 +1,6 @@
1
1
  # Reviewing changes
2
2
 
3
- Read this reference when two product roots or revisions must be compared, or when a move, removal, or affected-context review is requested.
3
+ Read this reference when two product roots or revisions must be compared, or when a move, removal, or affected-context review is requested. Every `ddduck …` command below names the command resolved by [executable resolution](executable-resolution.md); substitute it before running.
4
4
 
5
5
  ## Structural comparison
6
6
 
@@ -1,598 +0,0 @@
1
- /**
2
- * Installer for the bundled update-ddduck-specs agent skill, behind `ddduck
3
- * install skill`. Selects a host topology from the repository state (codex
4
- * .agents/, claude-code .claude/, or shared via symlink), plans create,
5
- * upgrade, or no-op against the canonical skill bundle and the
6
- * .ddduck/agent-skills.lock.json lock, and applies the plan with atomic
7
- * writes plus rollback of everything touched on failure. Conflicting host
8
- * state or a locally modified managed bundle refuses with a nextAction
9
- * naming the exact path to resolve.
10
- */
11
-
12
- import {
13
- lstatSync,
14
- mkdirSync,
15
- readFileSync,
16
- readdirSync,
17
- readlinkSync,
18
- renameSync,
19
- rmSync,
20
- symlinkSync,
21
- writeFileSync,
22
- } from "node:fs";
23
- import { createHash, randomUUID } from "node:crypto";
24
- import path from "node:path";
25
-
26
- const lockSchemaVersion = 2;
27
- const lockRelativePath = path.join(".ddduck", "agent-skills.lock.json");
28
-
29
- const defaultOperations = {
30
- lstatSync,
31
- mkdirSync,
32
- readFileSync,
33
- readdirSync,
34
- readlinkSync,
35
- renameSync,
36
- rmSync,
37
- symlinkSync,
38
- writeFileSync,
39
- };
40
-
41
- const codexSkillDirectory = path.join(".agents", "skills", "update-ddduck-specs");
42
- const claudeSkillDirectory = path.join(".claude", "skills", "update-ddduck-specs");
43
- const skillFileName = "SKILL.md";
44
-
45
- const hostSkillAdapters = {
46
- codex: {
47
- host: "codex",
48
- relativePath: codexSkillDirectory,
49
- lockEntry() {
50
- return { host: this.host, path: ".agents/skills/update-ddduck-specs" };
51
- },
52
- parentPaths(adapterPath) {
53
- return [path.dirname(path.dirname(adapterPath)), path.dirname(adapterPath)];
54
- },
55
- inspect({ adapterPath, operations }) {
56
- const state = pathState(adapterPath, operations);
57
- if (state.type === "absent") return "absent";
58
- return state.type === "directory" ? "valid" : "conflict";
59
- },
60
- materialize({ adapterPath, operations, touched }) {
61
- touched.push(adapterPath);
62
- operations.mkdirSync(adapterPath, { recursive: true });
63
- },
64
- },
65
- claudeDirectory: {
66
- host: "claude-code",
67
- relativePath: claudeSkillDirectory,
68
- lockEntry() {
69
- return { host: this.host, path: ".claude/skills/update-ddduck-specs" };
70
- },
71
- parentPaths(adapterPath) {
72
- return [path.dirname(path.dirname(adapterPath)), path.dirname(adapterPath)];
73
- },
74
- inspect({ adapterPath, operations }) {
75
- const state = pathState(adapterPath, operations);
76
- if (state.type === "absent") return "absent";
77
- return state.type === "directory" ? "valid" : "conflict";
78
- },
79
- materialize({ adapterPath, operations, touched }) {
80
- touched.push(adapterPath);
81
- operations.mkdirSync(adapterPath, { recursive: true });
82
- },
83
- },
84
- claudeSymlink: {
85
- host: "claude-code",
86
- relativePath: claudeSkillDirectory,
87
- target: "../../.agents/skills/update-ddduck-specs",
88
- lockEntry() {
89
- return { host: this.host, path: ".claude/skills/update-ddduck-specs", target: this.target };
90
- },
91
- parentPaths(adapterPath) {
92
- return [path.dirname(path.dirname(adapterPath)), path.dirname(adapterPath)];
93
- },
94
- inspect({ adapterPath, operations }) {
95
- const state = pathState(adapterPath, operations);
96
- if (state.type === "absent") return "absent";
97
- if (state.type !== "symlink" || operations.readlinkSync(adapterPath) !== this.target) return "conflict";
98
- return pathState(path.join(adapterPath, skillFileName), operations).type === "file" ? "valid" : "conflict";
99
- },
100
- materialize({ adapterPath, operations, touched }) {
101
- touched.push(path.dirname(adapterPath), adapterPath);
102
- operations.mkdirSync(path.dirname(adapterPath), { recursive: true });
103
- operations.symlinkSync(this.target, adapterPath);
104
- },
105
- },
106
- };
107
-
108
- const installTopologies = [
109
- {
110
- id: "codex",
111
- canonicalRelativePath: path.join(codexSkillDirectory, skillFileName),
112
- adapters: [hostSkillAdapters.codex],
113
- },
114
- {
115
- id: "claude-code",
116
- canonicalRelativePath: path.join(claudeSkillDirectory, skillFileName),
117
- adapters: [hostSkillAdapters.claudeDirectory],
118
- },
119
- {
120
- id: "shared",
121
- canonicalRelativePath: path.join(codexSkillDirectory, skillFileName),
122
- adapters: [hostSkillAdapters.codex, hostSkillAdapters.claudeSymlink],
123
- },
124
- ];
125
-
126
- /**
127
- * Install (or upgrade) the bundled skill into a repository: load, plan, apply.
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, bundleSha256: string, canonicalPath: string, lockPath: string}} The installation result.
130
- */
131
- export function installSkill(options) {
132
- const bundle = loadSkillBundle(options);
133
- const plan = planSkillInstall({ ...options, bundle });
134
- return applySkillInstall({ ...options, bundle, plan });
135
- }
136
-
137
- /**
138
- * Read the bundled skill directory and compute per-file and bundle digests.
139
- * @param {{skillName: string, skillPath: string, operations?: object}} options - Skill name, SKILL.md path, and fs overrides for tests.
140
- * @returns {{name: string, files: {path: string, bytes: Buffer, sha256: string}[], skillSha256: string, bundleSha256: string, sha256: string}} The loaded bundle.
141
- */
142
- export function loadSkillBundle({ skillName, skillPath, operations = {} }) {
143
- const resolvedOperations = { ...defaultOperations, ...operations };
144
- if (pathState(skillPath, resolvedOperations).type !== "file") {
145
- throw new Error(`Missing bundled skill asset: ${skillPath}`);
146
- }
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
- };
159
- }
160
-
161
- /**
162
- * Inspect the repository's lock, canonical file, and host adapters and decide
163
- * the action: create, upgrade, or no-op — or throw on conflicting host state,
164
- * an incomplete lock, or a locally modified canonical skill.
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.
167
- */
168
- export function planSkillInstall({ repository, skillName, bundle, operations = {} }) {
169
- const resolvedOperations = { ...defaultOperations, ...operations };
170
- const root = path.resolve(repository);
171
- const lockPath = path.join(root, lockRelativePath);
172
- const lockState = readLock(lockPath, resolvedOperations);
173
- const topology = selectTopology({ root, lockState, operations: resolvedOperations });
174
- const paths = installationPaths(root, topology);
175
- assertDirectoryParents(paths, topology, resolvedOperations);
176
- const canonical = pathState(paths.canonical, resolvedOperations);
177
- const adapters = inspectHostAdapters(paths, topology, resolvedOperations);
178
- const conflictingAdapter = adapters.find(({ state }) => state === "conflict");
179
-
180
- if (lockState.type === "invalid") throw incompleteLock(paths.lock);
181
- if (conflictingAdapter) throw conflictingHostAdapter(conflictingAdapter, paths);
182
-
183
- if (lockState.type === "absent") {
184
- const canonicalDirectory = pathState(paths.canonicalDirectory, resolvedOperations);
185
- if (
186
- canonicalDirectory.type === "directory" &&
187
- canonical.type === "absent" &&
188
- resolvedOperations.readdirSync(paths.canonicalDirectory).length > 0
189
- ) {
190
- throw conflictingHostState(
191
- `Conflicting canonical skill directory: ${paths.canonicalDirectory}`,
192
- paths.canonicalDirectory,
193
- );
194
- }
195
- if (canonical.type === "absent") {
196
- return createPlan({ paths, adapters, writeBundle: true });
197
- }
198
- if (
199
- canonical.type !== "file" ||
200
- sha256(resolvedOperations.readFileSync(paths.canonical)) !== bundle.skillSha256 ||
201
- bundle.files.length !== 1
202
- ) {
203
- throw conflictingHostState(`Conflicting canonical skill destination: ${paths.canonical}`, paths.canonical);
204
- }
205
- return createPlan({ paths, adapters, writeBundle: false });
206
- }
207
-
208
- const lock = lockState.value;
209
- if (!isValidLock(lock, skillName, topology)) throw incompleteLock(paths.lock);
210
- if (canonical.type === "absent") {
211
- assertRemainingBundleMatchesLock(paths, lock, resolvedOperations);
212
- return createPlan({ paths, adapters, writeBundle: true });
213
- }
214
- if (canonical.type !== "file") throw locallyModifiedCanonical(paths.canonical);
215
- assertInstalledBundleMatchesLock(paths, lock, resolvedOperations);
216
- if (adapters.some(({ state }) => state !== "valid")) throw incompleteLock(paths.lock);
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
-
224
- return {
225
- action: installedDigest === bundle.bundleSha256 ? "no-op" : "upgrade",
226
- paths,
227
- topology,
228
- adaptersToMaterialize: [],
229
- writeBundle: installedDigest !== bundle.bundleSha256,
230
- staleFiles,
231
- };
232
- }
233
-
234
- /**
235
- * Execute an install plan: atomically write each bundled file, materialize
236
- * host adapters, and write the lock; on failure roll back everything touched
237
- * and report any recovery failures in the thrown error.
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.
240
- */
241
- export function applySkillInstall({ packageVersion, bundle, plan, operations = {} }) {
242
- const resolvedOperations = { ...defaultOperations, ...operations };
243
- if (plan.action === "no-op") return installResult("no-op", bundle, plan);
244
-
245
- const touched = [];
246
- const originalBundle = plan.writeBundle ? snapshotDirectory(plan.paths.canonicalDirectory, resolvedOperations) : null;
247
- let bundleWritten = false;
248
- const materializedAdapters = [];
249
-
250
- try {
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
- }
261
- }
262
- for (const adapter of plan.adaptersToMaterialize) {
263
- adapter.materialize({
264
- adapterPath: plan.paths.adapters[adapter.host],
265
- operations: resolvedOperations,
266
- touched,
267
- });
268
- materializedAdapters.push(adapter);
269
- }
270
-
271
- atomicWrite(
272
- plan.paths.lock,
273
- `${JSON.stringify(createLock({ packageVersion, bundle, topology: plan.topology }), null, 2)}\n`,
274
- resolvedOperations,
275
- touched,
276
- );
277
- return installResult(plan.action, bundle, plan);
278
- } catch (error) {
279
- const recoveryFailures = restoreAfterFailure({
280
- plan,
281
- originalBundle,
282
- bundleWritten,
283
- materializedAdapters,
284
- operations: resolvedOperations,
285
- touched,
286
- });
287
- const paths = [...new Set(touched)].join(", ") || "none";
288
- const recovery = recoveryFailures.length === 0 ? "" : ` Recovery failures: ${recoveryFailures.join("; ")}.`;
289
- throw new Error(`Skill installation failed after touching: ${paths}. ${error.message}.${recovery}`);
290
- }
291
- }
292
-
293
- function installResult(action, bundle, plan) {
294
- return {
295
- action,
296
- skillSha256: bundle.skillSha256,
297
- bundleSha256: bundle.bundleSha256,
298
- canonicalPath: toPosixPath(plan.topology.canonicalRelativePath),
299
- lockPath: toPosixPath(lockRelativePath),
300
- };
301
- }
302
-
303
- function createPlan({ paths, adapters, writeBundle }) {
304
- return {
305
- action: "create",
306
- paths,
307
- topology: paths.topology,
308
- adaptersToMaterialize: adapters.filter(({ state }) => state === "absent").map(({ adapter }) => adapter),
309
- writeBundle,
310
- staleFiles: [],
311
- };
312
- }
313
-
314
- function installationPaths(root, topology) {
315
- const canonical = path.join(root, topology.canonicalRelativePath);
316
- return {
317
- canonical,
318
- canonicalDirectory: path.dirname(canonical),
319
- topology,
320
- adapters: Object.fromEntries(
321
- topology.adapters.map((adapter) => [adapter.host, path.join(root, adapter.relativePath)]),
322
- ),
323
- lock: path.join(root, lockRelativePath),
324
- };
325
- }
326
-
327
- function inspectHostAdapters(paths, topology, operations) {
328
- return topology.adapters.map((adapter) => ({
329
- adapter,
330
- state: adapter.inspect({ adapterPath: paths.adapters[adapter.host], operations }),
331
- }));
332
- }
333
-
334
- function assertDirectoryParents(paths, topology, operations) {
335
- const directories = [
336
- ...topology.adapters.flatMap((adapter) => adapter.parentPaths(paths.adapters[adapter.host])),
337
- path.dirname(paths.lock),
338
- ];
339
- for (const directory of new Set(directories)) {
340
- const state = pathState(directory, operations);
341
- if (!["absent", "directory"].includes(state.type)) {
342
- throw new Error(`Conflicting skill installation path: ${directory}`);
343
- }
344
- }
345
- }
346
-
347
- function readLock(lockPath, operations) {
348
- const state = pathState(lockPath, operations);
349
- if (state.type === "absent") return { type: "absent" };
350
- if (state.type !== "file") return { type: "invalid" };
351
- try {
352
- return { type: "valid", value: JSON.parse(operations.readFileSync(lockPath, "utf8")) };
353
- } catch {
354
- return { type: "invalid" };
355
- }
356
- }
357
-
358
- function pathState(filePath, operations) {
359
- try {
360
- const stat = operations.lstatSync(filePath);
361
- if (stat.isFile()) return { type: "file" };
362
- if (stat.isDirectory()) return { type: "directory" };
363
- if (stat.isSymbolicLink()) return { type: "symlink" };
364
- return { type: "other" };
365
- } catch (error) {
366
- if (error.code === "ENOENT") return { type: "absent" };
367
- throw error;
368
- }
369
- }
370
-
371
- function selectTopology({ root, lockState, operations }) {
372
- if (lockState.type === "valid") {
373
- const topology = installTopologies.find(
374
- (candidate) => toPosixPath(candidate.canonicalRelativePath) === toPosixPath(lockState.value.canonicalPath),
375
- );
376
- return topology ?? installTopologies[0];
377
- }
378
-
379
- const agents = pathState(path.join(root, ".agents"), operations).type;
380
- const claude = pathState(path.join(root, ".claude"), operations).type;
381
- if (agents === "directory" && claude === "directory") return installTopologies.find(({ id }) => id === "shared");
382
- if (claude === "directory") return installTopologies.find(({ id }) => id === "claude-code");
383
- return installTopologies.find(({ id }) => id === "codex");
384
- }
385
-
386
- function isValidLock(lock, skillName, topology) {
387
- return (
388
- lock &&
389
- [1, lockSchemaVersion].includes(lock.schemaVersion) &&
390
- lock.skill === skillName &&
391
- typeof lock.ddduckVersion === "string" &&
392
- lock.ddduckVersion.length > 0 &&
393
- toPosixPath(lock.canonicalPath) === toPosixPath(topology.canonicalRelativePath) &&
394
- /^[a-f0-9]{64}$/.test(lock.skillSha256) &&
395
- (lock.schemaVersion === 1 || isValidBundleLock(lock)) &&
396
- JSON.stringify(lock.adapters) === JSON.stringify(expectedAdapters(topology))
397
- );
398
- }
399
-
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
- }
411
- return {
412
- schemaVersion: lockSchemaVersion,
413
- skill: bundle.name,
414
- ddduckVersion: packageVersion,
415
- canonicalPath: toPosixPath(topology.canonicalRelativePath),
416
- skillSha256: bundle.skillSha256,
417
- bundleSha256: bundle.bundleSha256,
418
- files: bundle.files.map(({ path: relativePath, sha256: fileSha256 }) => ({
419
- path: relativePath,
420
- sha256: fileSha256,
421
- })),
422
- adapters: expectedAdapters(topology),
423
- };
424
- }
425
-
426
- function expectedAdapters(topology) {
427
- return topology.adapters.map((adapter) => adapter.lockEntry());
428
- }
429
-
430
- function conflictingHostAdapter({ adapter }, paths) {
431
- const label = adapter.host === "claude-code" ? "Claude Code" : adapter.host;
432
- return conflictingHostState(
433
- `Conflicting ${label} adapter: ${paths.adapters[adapter.host]}`,
434
- paths.adapters[adapter.host],
435
- );
436
- }
437
-
438
- // Host-state conflicts are environment failures, not input failures: name the
439
- // pre-existing path the user must resolve instead of the usage hint.
440
- function conflictingHostState(message, conflictingPath) {
441
- const error = new Error(message);
442
- error.nextAction = `Move ${conflictingPath} aside or remove it, then re-run ddduck install skill update-ddduck-specs.`;
443
- return error;
444
- }
445
-
446
- function atomicWrite(destination, content, operations, touched) {
447
- const directory = path.dirname(destination);
448
- const temporary = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.tmp`);
449
- touched.push(directory, temporary, destination);
450
- try {
451
- operations.mkdirSync(directory, { recursive: true });
452
- operations.writeFileSync(temporary, content);
453
- operations.renameSync(temporary, destination);
454
- } finally {
455
- if (pathState(temporary, operations).type !== "absent") operations.rmSync(temporary, { force: true });
456
- }
457
- }
458
-
459
- function restoreAfterFailure({ plan, originalBundle, bundleWritten, materializedAdapters, operations, touched }) {
460
- const failures = [];
461
- for (const adapter of materializedAdapters.toReversed()) {
462
- try {
463
- const adapterPath = plan.paths.adapters[adapter.host];
464
- touched.push(adapterPath);
465
- if (pathState(adapterPath, operations).type !== "absent") {
466
- operations.rmSync(adapterPath, { recursive: true, force: true });
467
- }
468
- } catch (error) {
469
- failures.push(`remove ${adapter.host} adapter: ${error.message}`);
470
- }
471
- }
472
- if (!bundleWritten && originalBundle === null) return failures;
473
-
474
- try {
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);
479
- }
480
- } catch (error) {
481
- failures.push(`restore canonical skill bundle: ${error.message}`);
482
- }
483
- return failures;
484
- }
485
-
486
- function incompleteLock(lockPath) {
487
- const error = new Error(`Incomplete or inconsistent skill lock: ${lockPath}`);
488
- // Deleting the lock is safe: the next install rebuilds it from the repository
489
- // state, and any canonical mismatch then surfaces as its own conflict.
490
- error.nextAction = `Delete ${lockPath}, then re-run ddduck install skill update-ddduck-specs to rebuild it.`;
491
- return error;
492
- }
493
-
494
- function locallyModifiedCanonical(canonicalPath) {
495
- const error = new Error(`Locally modified canonical skill: ${canonicalPath}`);
496
- error.nextAction = `Revert or remove ${canonicalPath}, then re-run ddduck install skill update-ddduck-specs.`;
497
- return error;
498
- }
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
-
592
- function sha256(bytes) {
593
- return createHash("sha256").update(bytes).digest("hex");
594
- }
595
-
596
- function toPosixPath(value) {
597
- return String(value).replaceAll("\\", "/");
598
- }