ddduck 0.1.0 → 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.
Files changed (41) hide show
  1. package/README.md +11 -6
  2. package/docs/architecture.md +5 -2
  3. package/docs/cli.md +135 -53
  4. package/docs/definition-workflow.md +130 -0
  5. package/docs/getting-started.md +21 -48
  6. package/docs/model-reference.md +4 -0
  7. package/docs/model.md +1 -0
  8. package/docs/templates/change-brief.md +48 -0
  9. package/package.json +8 -5
  10. package/schemas/model-diff.schema.json +107 -0
  11. package/scripts/audit-fr-to-code.mjs +25 -0
  12. package/scripts/check-generated-docs.mjs +24 -5
  13. package/scripts/check-generated-graph-svg.mjs +26 -8
  14. package/scripts/check-generated-graph.mjs +25 -5
  15. package/scripts/check-model.mjs +95 -5
  16. package/scripts/ddduck.mjs +216 -22
  17. package/scripts/generate-agent-readiness-report.mjs +8 -0
  18. package/scripts/generate-docs.mjs +29 -3
  19. package/scripts/generate-graph-svg.mjs +53 -16
  20. package/scripts/generate-graph.mjs +26 -1
  21. package/scripts/lib/agent-readiness-evals.mjs +32 -0
  22. package/scripts/lib/agent-readiness-report.mjs +14 -0
  23. package/scripts/lib/cli-contract.mjs +65 -15
  24. package/scripts/lib/context-pack.mjs +49 -1
  25. package/scripts/lib/ddduck-config.mjs +31 -1
  26. package/scripts/lib/fr-to-code-audit.mjs +30 -0
  27. package/scripts/lib/product-authoring.mjs +52 -0
  28. package/scripts/lib/product-diff.mjs +155 -0
  29. package/scripts/lib/product-layout.mjs +42 -1
  30. package/scripts/lib/product-operation.mjs +140 -22
  31. package/scripts/lib/product-paths.mjs +16 -0
  32. package/scripts/lib/product-query.mjs +102 -20
  33. package/scripts/lib/product-root-resolver.mjs +40 -0
  34. package/scripts/lib/scan-ignore.mjs +14 -3
  35. package/scripts/lib/skill-installer.mjs +227 -39
  36. package/scripts/query-model.mjs +18 -7
  37. package/scripts/run-agent-readiness-evals.mjs +18 -2
  38. package/skills/update-ddduck-specs/SKILL.md +25 -83
  39. package/skills/update-ddduck-specs/references/authoring-and-verification.md +60 -0
  40. package/skills/update-ddduck-specs/references/modeling-and-evidence.md +56 -0
  41. package/skills/update-ddduck-specs/references/reviewing-changes.md +45 -0
@@ -1,3 +1,14 @@
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
+
1
12
  import {
2
13
  lstatSync,
3
14
  mkdirSync,
@@ -12,7 +23,7 @@ import {
12
23
  import { createHash, randomUUID } from "node:crypto";
13
24
  import path from "node:path";
14
25
 
15
- const lockSchemaVersion = 1;
26
+ const lockSchemaVersion = 2;
16
27
  const lockRelativePath = path.join(".ddduck", "agent-skills.lock.json");
17
28
 
18
29
  const defaultOperations = {
@@ -112,21 +123,48 @@ const installTopologies = [
112
123
  },
113
124
  ];
114
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
+ */
115
131
  export function installSkill(options) {
116
132
  const bundle = loadSkillBundle(options);
117
133
  const plan = planSkillInstall({ ...options, bundle });
118
134
  return applySkillInstall({ ...options, bundle, plan });
119
135
  }
120
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
+ */
121
142
  export function loadSkillBundle({ skillName, skillPath, operations = {} }) {
122
143
  const resolvedOperations = { ...defaultOperations, ...operations };
123
144
  if (pathState(skillPath, resolvedOperations).type !== "file") {
124
145
  throw new Error(`Missing bundled skill asset: ${skillPath}`);
125
146
  }
126
- const bytes = resolvedOperations.readFileSync(skillPath);
127
- 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
+ };
128
159
  }
129
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
+ */
130
168
  export function planSkillInstall({ repository, skillName, bundle, operations = {} }) {
131
169
  const resolvedOperations = { ...defaultOperations, ...operations };
132
170
  const root = path.resolve(repository);
@@ -149,51 +187,77 @@ export function planSkillInstall({ repository, skillName, bundle, operations = {
149
187
  canonical.type === "absent" &&
150
188
  resolvedOperations.readdirSync(paths.canonicalDirectory).length > 0
151
189
  ) {
152
- throw new Error(`Conflicting canonical skill directory: ${paths.canonicalDirectory}`);
190
+ throw conflictingHostState(
191
+ `Conflicting canonical skill directory: ${paths.canonicalDirectory}`,
192
+ paths.canonicalDirectory,
193
+ );
153
194
  }
154
195
  if (canonical.type === "absent") {
155
- return createPlan({ paths, adapters, writeCanonical: true });
196
+ return createPlan({ paths, adapters, writeBundle: true });
156
197
  }
157
- if (canonical.type !== "file" || sha256(resolvedOperations.readFileSync(paths.canonical)) !== bundle.sha256) {
158
- throw new Error(`Conflicting canonical skill destination: ${paths.canonical}`);
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);
159
204
  }
160
- return createPlan({ paths, adapters, writeCanonical: false });
205
+ return createPlan({ paths, adapters, writeBundle: false });
161
206
  }
162
207
 
163
208
  const lock = lockState.value;
164
209
  if (!isValidLock(lock, skillName, topology)) throw incompleteLock(paths.lock);
165
- if (canonical.type === "absent") return createPlan({ paths, adapters, writeCanonical: true });
166
- if (canonical.type !== "file") throw locallyModifiedCanonical(paths.canonical);
167
- if (sha256(resolvedOperations.readFileSync(paths.canonical)) !== lock.skillSha256) {
168
- throw locallyModifiedCanonical(paths.canonical);
210
+ if (canonical.type === "absent") {
211
+ assertRemainingBundleMatchesLock(paths, lock, resolvedOperations);
212
+ return createPlan({ paths, adapters, writeBundle: true });
169
213
  }
214
+ if (canonical.type !== "file") throw locallyModifiedCanonical(paths.canonical);
215
+ assertInstalledBundleMatchesLock(paths, lock, resolvedOperations);
170
216
  if (adapters.some(({ state }) => state !== "valid")) throw incompleteLock(paths.lock);
171
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
+
172
224
  return {
173
- action: lock.skillSha256 === bundle.sha256 ? "no-op" : "upgrade",
225
+ action: installedDigest === bundle.bundleSha256 ? "no-op" : "upgrade",
174
226
  paths,
175
227
  topology,
176
228
  adaptersToMaterialize: [],
177
- writeCanonical: lock.skillSha256 !== bundle.sha256,
229
+ writeBundle: installedDigest !== bundle.bundleSha256,
230
+ staleFiles,
178
231
  };
179
232
  }
180
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
+ */
181
241
  export function applySkillInstall({ packageVersion, bundle, plan, operations = {} }) {
182
242
  const resolvedOperations = { ...defaultOperations, ...operations };
183
243
  if (plan.action === "no-op") return installResult("no-op", bundle, plan);
184
244
 
185
245
  const touched = [];
186
- const originalCanonical =
187
- plan.writeCanonical && pathState(plan.paths.canonical, resolvedOperations).type === "file"
188
- ? resolvedOperations.readFileSync(plan.paths.canonical)
189
- : null;
190
- let canonicalWritten = false;
246
+ const originalBundle = plan.writeBundle ? snapshotDirectory(plan.paths.canonicalDirectory, resolvedOperations) : null;
247
+ let bundleWritten = false;
191
248
  const materializedAdapters = [];
192
249
 
193
250
  try {
194
- if (plan.writeCanonical) {
195
- atomicWrite(plan.paths.canonical, bundle.bytes, resolvedOperations, touched);
196
- 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
+ }
197
261
  }
198
262
  for (const adapter of plan.adaptersToMaterialize) {
199
263
  adapter.materialize({
@@ -214,8 +278,8 @@ export function applySkillInstall({ packageVersion, bundle, plan, operations = {
214
278
  } catch (error) {
215
279
  const recoveryFailures = restoreAfterFailure({
216
280
  plan,
217
- originalCanonical,
218
- canonicalWritten,
281
+ originalBundle,
282
+ bundleWritten,
219
283
  materializedAdapters,
220
284
  operations: resolvedOperations,
221
285
  touched,
@@ -229,19 +293,21 @@ export function applySkillInstall({ packageVersion, bundle, plan, operations = {
229
293
  function installResult(action, bundle, plan) {
230
294
  return {
231
295
  action,
232
- skillSha256: bundle.sha256,
296
+ skillSha256: bundle.skillSha256,
297
+ bundleSha256: bundle.bundleSha256,
233
298
  canonicalPath: toPosixPath(plan.topology.canonicalRelativePath),
234
299
  lockPath: toPosixPath(lockRelativePath),
235
300
  };
236
301
  }
237
302
 
238
- function createPlan({ paths, adapters, writeCanonical }) {
303
+ function createPlan({ paths, adapters, writeBundle }) {
239
304
  return {
240
305
  action: "create",
241
306
  paths,
242
307
  topology: paths.topology,
243
308
  adaptersToMaterialize: adapters.filter(({ state }) => state === "absent").map(({ adapter }) => adapter),
244
- writeCanonical,
309
+ writeBundle,
310
+ staleFiles: [],
245
311
  };
246
312
  }
247
313
 
@@ -320,23 +386,39 @@ function selectTopology({ root, lockState, operations }) {
320
386
  function isValidLock(lock, skillName, topology) {
321
387
  return (
322
388
  lock &&
323
- lock.schemaVersion === lockSchemaVersion &&
389
+ [1, lockSchemaVersion].includes(lock.schemaVersion) &&
324
390
  lock.skill === skillName &&
325
391
  typeof lock.ddduckVersion === "string" &&
326
392
  lock.ddduckVersion.length > 0 &&
327
393
  toPosixPath(lock.canonicalPath) === toPosixPath(topology.canonicalRelativePath) &&
328
394
  /^[a-f0-9]{64}$/.test(lock.skillSha256) &&
395
+ (lock.schemaVersion === 1 || isValidBundleLock(lock)) &&
329
396
  JSON.stringify(lock.adapters) === JSON.stringify(expectedAdapters(topology))
330
397
  );
331
398
  }
332
399
 
333
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
+ }
334
411
  return {
335
412
  schemaVersion: lockSchemaVersion,
336
413
  skill: bundle.name,
337
414
  ddduckVersion: packageVersion,
338
415
  canonicalPath: toPosixPath(topology.canonicalRelativePath),
339
- 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
+ })),
340
422
  adapters: expectedAdapters(topology),
341
423
  };
342
424
  }
@@ -347,7 +429,18 @@ function expectedAdapters(topology) {
347
429
 
348
430
  function conflictingHostAdapter({ adapter }, paths) {
349
431
  const label = adapter.host === "claude-code" ? "Claude Code" : adapter.host;
350
- return new Error(`Conflicting ${label} adapter: ${paths.adapters[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;
351
444
  }
352
445
 
353
446
  function atomicWrite(destination, content, operations, touched) {
@@ -363,7 +456,7 @@ function atomicWrite(destination, content, operations, touched) {
363
456
  }
364
457
  }
365
458
 
366
- function restoreAfterFailure({ plan, originalCanonical, canonicalWritten, materializedAdapters, operations, touched }) {
459
+ function restoreAfterFailure({ plan, originalBundle, bundleWritten, materializedAdapters, operations, touched }) {
367
460
  const failures = [];
368
461
  for (const adapter of materializedAdapters.toReversed()) {
369
462
  try {
@@ -376,23 +469,26 @@ function restoreAfterFailure({ plan, originalCanonical, canonicalWritten, materi
376
469
  failures.push(`remove ${adapter.host} adapter: ${error.message}`);
377
470
  }
378
471
  }
379
- if (!canonicalWritten) return failures;
472
+ if (!bundleWritten && originalBundle === null) return failures;
380
473
 
381
474
  try {
382
- if (originalCanonical) {
383
- atomicWrite(plan.paths.canonical, originalCanonical, operations, touched);
384
- } else {
385
- touched.push(plan.paths.canonical);
386
- 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);
387
479
  }
388
480
  } catch (error) {
389
- failures.push(`restore canonical skill: ${error.message}`);
481
+ failures.push(`restore canonical skill bundle: ${error.message}`);
390
482
  }
391
483
  return failures;
392
484
  }
393
485
 
394
486
  function incompleteLock(lockPath) {
395
- return new Error(`Incomplete or inconsistent skill lock: ${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;
396
492
  }
397
493
 
398
494
  function locallyModifiedCanonical(canonicalPath) {
@@ -401,6 +497,98 @@ function locallyModifiedCanonical(canonicalPath) {
401
497
  return error;
402
498
  }
403
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
+
404
592
  function sha256(bytes) {
405
593
  return createHash("sha256").update(bytes).digest("hex");
406
594
  }
@@ -1,6 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import path from "node:path";
3
+ /**
4
+ * Implements `ddduck query`, the read-only query surface: dispatches the
5
+ * operations node, neighbors, impact, anchors, spec, and context to
6
+ * lib/product-query.mjs and lib/context-pack.mjs, emitting exactly one JSON
7
+ * document on stdout. The product root auto-resolves (enclosing directory,
8
+ * config, or unique discovery) unless --root is passed; busy or interrupted
9
+ * roots are refused before any answer is served. Also imported by ddduck.mjs
10
+ * and the agent-readiness evals as the runQuery entry point.
11
+ */
12
+
4
13
  import { fileURLToPath } from "node:url";
5
14
  import { resolveContextPack } from "./lib/context-pack.mjs";
6
15
  import { resolveProductRoot } from "./lib/product-root-resolver.mjs";
@@ -14,6 +23,12 @@ import {
14
23
  } from "./lib/product-query.mjs";
15
24
  import { CliUsageError, parseCommandArgs, renderHelp, writeCliError } from "./lib/cli-contract.mjs";
16
25
 
26
+ /**
27
+ * Parse and run one query invocation, writing the JSON document to stdout.
28
+ * @param {string[]} args - Arguments after the `query` command word.
29
+ * @param {{cwd?: string, stdout?: {write: (chunk: string) => unknown}}} [io] - Working directory for root resolution and the output stream (used by the evals harness to capture output).
30
+ * @returns {void}
31
+ */
17
32
  export function runQuery(args, { cwd = process.cwd(), stdout = process.stdout } = {}) {
18
33
  if (args.includes("--help")) {
19
34
  stdout.write(renderHelp("query"));
@@ -30,14 +45,10 @@ export function runQuery(args, { cwd = process.cwd(), stdout = process.stdout }
30
45
  const ids = options.id;
31
46
  const id = ids[0];
32
47
  if (operation !== "context" && ids.length > 1) throw new CliUsageError(`query ${operation} accepts exactly one --id`);
33
- if (operation === "context") {
34
- if (options.history) throw new CliUsageError("query context does not support --history");
35
- if (!options.root) throw new CliUsageError("query context requires --root <product-root>");
36
- }
48
+ if (operation === "context" && options.history) throw new CliUsageError("query context does not support --history");
37
49
  if (operation !== "spec" && !id) throw new CliUsageError(`query ${operation} requires --id <model-node-id>`);
38
50
 
39
- const root =
40
- operation === "context" ? path.resolve(cwd, options.root) : resolveProductRoot({ cwd, explicitRoot: options.root });
51
+ const root = resolveProductRoot({ cwd, explicitRoot: options.root });
41
52
  const product = loadQueryProduct(root, { history: options.history });
42
53
  if (operation === "spec" && id && id !== product.rootModelId) {
43
54
  throw new CliUsageError(`query spec --id must be ${product.rootModelId}`);
@@ -1,8 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * CLI wrapper for the agent-readiness eval harness: reads a JSONL file of eval
5
+ * records (--input) that each name a product root below --repo-root, a query
6
+ * to run, and candidate evidence to verify, then delegates to
7
+ * lib/agent-readiness-evals.mjs. Emits one JSON result document on stdout,
8
+ * optionally mirrors it to --output (contained below the repo root, never
9
+ * .git), and exits 1 unless every case passed.
10
+ */
11
+
3
12
  import { mkdirSync, writeFileSync } from "node:fs";
4
13
  import path from "node:path";
5
- import { format } from "prettier";
6
14
  import { runAgentReadinessEvals } from "./lib/agent-readiness-evals.mjs";
7
15
  import { resolveContainedOutput } from "./lib/product-paths.mjs";
8
16
 
@@ -10,7 +18,9 @@ try {
10
18
  const options = parseArgs(process.argv.slice(2));
11
19
  const outputTarget = options.output ? resolveOutputPath(options.repoRoot, options.output) : null;
12
20
  const result = runAgentReadinessEvals(options);
13
- const output = await format(JSON.stringify(result), { parser: "json", printWidth: 120 });
21
+ // Deterministic serialization with no runtime formatter dependency: the
22
+ // published package must run without devDependencies.
23
+ const output = `${JSON.stringify(result, null, 2)}\n`;
14
24
  process.stdout.write(output);
15
25
  if (outputTarget) writeOutput(outputTarget, output);
16
26
  process.exitCode = result.passed ? 0 : 1;
@@ -19,6 +29,12 @@ try {
19
29
  process.exitCode = 1;
20
30
  }
21
31
 
32
+ /**
33
+ * Parse the --input/--repo-root/--output arguments, rejecting duplicates and
34
+ * missing values.
35
+ * @param {string[]} args - Raw CLI arguments.
36
+ * @returns {{inputPath: string, repoRoot: string, output: string|undefined}} Resolved option paths.
37
+ */
22
38
  function parseArgs(args) {
23
39
  const options = {};
24
40
  for (let index = 0; index < args.length; index += 1) {
@@ -1,98 +1,40 @@
1
1
  ---
2
2
  name: update-ddduck-specs
3
- description: Use when a repository's ddduck product model needs to be created, audited against current code, tests, and documentation, or reconciled after product changes.
3
+ description: Use when a repository's ddduck product model needs to be created, audited against current code, tests, and documentation, compared across revisions, or reconciled after product changes.
4
4
  ---
5
5
 
6
6
  # Update ddduck Specs
7
7
 
8
- Maintain or bootstrap a repository's ddduck product model from evidence visible in the current working tree.
8
+ Maintain or bootstrap a ddduck product model from evidence in the current working tree. Preserve the difference between observed implementation, accepted intent, canonical product meaning, generated views, and runtime proof.
9
9
 
10
- ## Inputs and boundaries
10
+ ## Operating contract
11
11
 
12
- - Resolve the repository root, read all applicable repository instructions, and inspect Git status before analysis.
13
- - Use an explicitly requested product root; otherwise let ddduck resolve it in its own order: the enclosing product root of the current directory, else the `productRoot` in `.ddduck/config.json`, else the unique discovered product root in the repository. `ddduck query spec` reports the resolved root. Ambiguous resolution is a stop condition: report the candidates and ask; never bootstrap a second product root beside an existing one.
14
- - Default to plan-only. Mutate files only when the current user request explicitly authorizes application, including prose that clearly authorizes the evidence-backed changes. `--root <path>` and `--apply` may be convenient shorthand, but ordinary prose must work.
15
- - Preserve unrelated and uncommitted work. Stop when intended target files overlap user changes inseparably.
16
- - Write only `<root>/product.yaml`, `<root>/model/**`, `<root>/decisions/**`, and regenerated `<root>/generated/**`.
17
- - Never edit generated views directly. Regenerate them with ddduck.
18
- - Use only evidence visible in the current working tree. Do not rely on prior chat, cursor, cache, or an assumed previous revision.
19
- - Preserve stable IDs and Guarantee lifecycle. Never silently delete or reuse a Guarantee ID. Use ddduck lifecycle commands where they cover the mutation.
20
- - Never create an ADR merely to satisfy validation or justify an inferred change.
21
- - Keep unresolved questions out of canonical model facts.
22
- - Prefer the smallest coherent product-model change. Avoid ornamental DDD vocabulary and speculative structure.
23
- - Do not commit or push consumer changes unless the user separately requests it.
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.
14
+ - Default to plan-only. Mutate model files only when the current request explicitly authorizes applying the evidence-backed proposal.
15
+ - Preserve unrelated work. Stop when intended model edits overlap user changes inseparably or the analyzed tree changes before application.
16
+ - Write only `<root>/product.yaml`, `<root>/model/**`, `<root>/decisions/**`, and regenerated `<root>/generated/**`. Regenerate derived views; never edit them directly.
17
+ - Preserve stable IDs and Guarantee history. Use lifecycle commands for Guarantee transitions and the supported creation commands for Domain, Concept, and UseCase.
18
+ - Keep unresolved questions in prose. Canonicalize only meaning that is unambiguous and supported by inspected evidence or accepted authority.
19
+ - Commit, push, consumer migration, and external effects require separate authorization.
24
20
 
25
- Use a repository-compatible ddduck executable. Do not install dependencies or silently fall back to an unrelated global version.
21
+ ## Load the relevant reference
26
22
 
27
- ## Establish the baseline
23
+ Read each selected reference completely before acting. All references are one level below this file.
28
24
 
29
- Classify the selected root exactly once:
25
+ - For every bootstrap, audit, or reconciliation, read [modeling and evidence](references/modeling-and-evidence.md).
26
+ - 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
+ - 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.
30
28
 
31
- - `existing`: `product.yaml` exists. Run `ddduck query spec --root <root> --json` and `ddduck check --root <root>`, recording both outcomes independently. A failing existing model is invalid, not absent, and must not be reinitialized.
32
- - `absent`: the root is missing or empty. Inspect the repository before proposing initialization.
33
- - `path-collision`: the root is non-empty but not a recognizable ddduck product. Report the collision and never initialize over it.
29
+ ## Workflow
34
30
 
35
- Stop before mutation when the ddduck executable is missing or incompatible, multiple product roots are plausible and none was selected, the selected root is a non-empty path collision, bootstrap identity, purpose, or initial domain seams are not grounded, evidence conflicts materially change the proposed model, target model files overlap inseparable user changes, or the analyzed working-tree state changed before application.
31
+ 1. Resolve and classify the product root. Record `query spec` and `check` independently; an invalid existing model is not an absent model.
32
+ 2. Gather current code, tests, interfaces, documentation, configuration, schemas, and accepted decisions. Record contradictions, exclusions, and coverage gaps.
33
+ 3. Separate observed behavior, accepted intent, open questions, and rejected alternatives. Classify every material model difference using the modeling reference.
34
+ 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.
35
+ 5. If application is explicitly authorized, recheck Git status, decisive evidence, and target files; then apply only the approved unambiguous changes.
36
+ 6. Run the complete verification sequence from the authoring reference. Any failed command makes the result `incomplete`.
36
37
 
37
- ## Gather evidence
38
+ ## Completion criteria
38
39
 
39
- Inspect relevant current code, tests, public interfaces, documentation, configuration, schemas, workspace structure, and accepted decisions. Record material exclusions and coverage gaps.
40
-
41
- For every candidate fact, record:
42
-
43
- - proposed model assertion;
44
- - repository-relative path plus line, symbol, heading, or test name;
45
- - evidence role: implementation, verification, documentation, decision, or configuration;
46
- - contradictory evidence;
47
- - inspected scope and remaining unknowns.
48
-
49
- Executable behavior and passing tests establish observed behavior. Accepted requirements and decisions establish intended behavior. Treat conflicts between them as inconsistencies; do not silently encode either a possible bug or an unimplemented requirement as product truth.
50
-
51
- The current schema restricts persisted interface evidence anchors to paths inside the product root. Cite repository-wide evidence in the plan and final report, but persist only schema-supported product-root anchors. Do not copy source evidence into the product root, invent unsupported metadata, or create bridge documents merely to manufacture provenance.
52
-
53
- For a greenfield repository, use runtime-supported subagents only when no model exists and the relevant corpus spans several substantial, independent packages, applications, or domain areas that cannot be covered reliably in the coordinating context. Repository file count alone is not sufficient. Subagents are read-only evidence adapters: assign non-overlapping scopes, provide applicable repository instructions, forbid writes and canonical model synthesis, require candidate facts with exact evidence locations, conflicts, unknowns, and coverage, then re-read decisive evidence before adopting it. The coordinator is the sole writer. If subagents are unavailable, inspect the same scopes sequentially and disclose the coverage limitations.
54
-
55
- ## Compare and classify
56
-
57
- Classify every material difference as exactly one of:
58
-
59
- - verified omission;
60
- - stale modeled fact;
61
- - structural inconsistency with an unambiguous repair;
62
- - contradiction or uncertainty requiring a human decision;
63
- - irrelevant implementation detail;
64
- - insufficiently covered.
65
-
66
- For existing models, preserve identity and history.
67
-
68
- ## Plan-only workflow
69
-
70
- Before any mutation, report in this order:
71
-
72
- 1. Mode, resolved root, and model state.
73
- 2. Baseline query and validation status.
74
- 3. Inspected coverage, exclusions, and gaps.
75
- 4. Proposed changes with classification, concrete evidence, and exact target files.
76
- 5. Contradictions, uncertainties, and required decisions.
77
- 6. Exact generation and verification commands.
78
-
79
- Without explicit application authorization, stop before all writes. Plan mode performs no writes, including initialization and generation.
80
-
81
- ## Apply workflow
82
-
83
- When application is explicitly authorized:
84
-
85
- 1. Recheck Git status, intended target files, and decisive evidence.
86
- 2. Initialize only an absent or empty root whose model identity, purpose, and initial domain seams are explicit or unambiguously grounded. Otherwise request the missing decision.
87
- 3. Apply only planned, evidence-backed changes whose meaning is unambiguous.
88
- 4. Leave unresolved findings unchanged.
89
- 5. Use ddduck lifecycle commands for Guarantee transitions. Author other canonical YAML against installed schemas and existing model conventions.
90
- 6. Run `ddduck check --root <root> --source-only` before generation; hand-authored canonical edits legitimately leave generated views stale until step 7.
91
- 7. Run `ddduck generate --root <root>`.
92
- 8. Run `ddduck check --root <root>` again.
93
- 9. Run `ddduck query spec --root <root> --json` and require every generated view to be fresh.
94
- 10. Inspect the final diff for scope.
95
-
96
- Any command failure makes the result incomplete. Inspect and report the resulting working tree; never destructively roll back unrelated user work.
97
-
98
- Report changed canonical, decision, and generated files; evidence supporting each material change; skipped and unresolved findings; exact command outcomes and diagnostics; final generated-view freshness; and remaining coverage gaps. Explicitly report `incomplete` when any verification or scope check fails. A verified no-op is a valid result; do not create model content merely to demonstrate activity.
40
+ Report the resolved root and mode, baseline outcomes, inspected coverage, changed or proposed IDs and files, supporting evidence, contradictions and open decisions, exact command results, generated-view freshness, and remaining gaps. For comparisons, distinguish structural differences from semantic approval. For applications, inspect the final scoped diff and report every skipped or unresolved finding.