ddduck 0.1.0 → 0.1.2

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.
@@ -1,5 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * The model checker behind `ddduck check`: validates a product root's canonical
5
+ * YAML against the framework schemas, referential integrity (single Model,
6
+ * ownership, decisions), evidence anchors, guarantee lifecycle rules, and the
7
+ * executable policy checks (ownership, documentation model-reference
8
+ * resolution). With --base it also enforces guarantee history retention against
9
+ * a previous product root. Exports validateProduct for the operation runner,
10
+ * query loader, and root resolver; runs standalone as a CLI (exit 1 on errors).
11
+ */
12
+
3
13
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
4
14
  import path from "node:path";
5
15
  import { fileURLToPath } from "node:url";
@@ -7,7 +17,7 @@ import Ajv2020 from "ajv/dist/2020.js";
7
17
  import { unified } from "unified";
8
18
  import remarkParse from "remark-parse";
9
19
  import { parseDocument } from "yaml";
10
- import { detectProductLayout, loadProductNodes } from "./lib/product-layout.mjs";
20
+ import { detectProductLayout, loadProductNodes, nodeDirectoryKinds } from "./lib/product-layout.mjs";
11
21
  import { shouldIgnoreScanEntry } from "./lib/scan-ignore.mjs";
12
22
  import { findRepositoryRoot, resolveConfiguredIgnores } from "./lib/ddduck-config.mjs";
13
23
 
@@ -18,7 +28,7 @@ const executableRuleChecks = new Set([
18
28
  ]);
19
29
 
20
30
  const markdownReferencePattern =
21
- /\b(?:(?:model|domain|concept|rel|rule|use-case|interface):[a-z0-9][a-z0-9-]*|[A-Z][A-Z0-9-]+-(?:INV|AC)-[0-9]+|ADR-[0-9]{3})\b/g;
31
+ /\b(?:(?:model|domain|concept|rel|use-case|interface):[a-z0-9][a-z0-9-]*|[A-Z][A-Z0-9-]+-(?:INV|AC)-[0-9]+|ADR-[0-9]{3})\b/g;
22
32
 
23
33
  const defaultFrameworkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
24
34
 
@@ -58,6 +68,11 @@ function parseArgs(args) {
58
68
  return parsed;
59
69
  }
60
70
 
71
+ /**
72
+ * One validation run over a product root: loads nodes and decisions, then
73
+ * accumulates every diagnostic in `errors` (file-relative, one per line) while
74
+ * counting nodes, references, and executed policies for the verbose summary.
75
+ */
61
76
  class ProductCheck {
62
77
  constructor(rootPath, frameworkPath, basePath, documentationRoots, { sourceOnly = false } = {}) {
63
78
  this.root = rootPath;
@@ -78,6 +93,7 @@ class ProductCheck {
78
93
  this.loadDecisions();
79
94
  this.loadNodes();
80
95
  this.validateSchemas();
96
+ this.validateNodeDirectories();
81
97
  this.validateEvidenceAnchors();
82
98
  this.validateReferences();
83
99
  this.validateGuaranteeLifecycle();
@@ -147,6 +163,22 @@ class ProductCheck {
147
163
  }
148
164
  }
149
165
 
166
+ validateNodeDirectories() {
167
+ for (const [id, filePath] of this.nodeFiles) {
168
+ const segments = path.relative(this.root, filePath).split(path.sep);
169
+ if (segments[0] !== "model") continue;
170
+ const expectedKind = nodeDirectoryKinds.get(segments[1]);
171
+ if (!expectedKind) continue;
172
+ const node = this.nodes.get(id);
173
+ if (node.kind !== expectedKind) {
174
+ this.addError(
175
+ id,
176
+ `node kind ${node.kind} does not match directory model/${segments[1]} (expected ${expectedKind})`,
177
+ );
178
+ }
179
+ }
180
+ }
181
+
150
182
  validateReferences() {
151
183
  const models = [...this.nodes.values()].filter((node) => node.kind === "Model");
152
184
  if (models.length !== 1) {
@@ -195,6 +227,15 @@ class ProductCheck {
195
227
  node.id,
196
228
  `evidence anchor path must resolve to an existing regular file below product root: ${evidence.path}`,
197
229
  );
230
+ continue;
231
+ }
232
+ // Markdown targets must contain the anchor text; non-Markdown anchors
233
+ // stay free-form labels.
234
+ if (evidence.path.endsWith(".md") && typeof evidence.anchor === "string") {
235
+ const content = readFileSync(path.resolve(this.root, evidence.path), "utf8");
236
+ if (!content.includes(evidence.anchor)) {
237
+ this.addError(node.id, `evidence anchor not found in ${evidence.path}: ${evidence.anchor}`);
238
+ }
198
239
  }
199
240
  }
200
241
  }
@@ -207,8 +248,17 @@ class ProductCheck {
207
248
  if (successors.length === 0) this.addError(node.id, "split guarantee requires active successors");
208
249
  for (const id of successors) {
209
250
  const successor = this.nodes.get(id);
210
- if (!successor || successor.kind !== "Guarantee" || successor.status !== "active")
211
- this.addError(node.id, `split successor must be an active guarantee ${id}`);
251
+ if (!successor || successor.kind !== "Guarantee") {
252
+ this.addError(node.id, `split successor must be a guarantee ${id}`);
253
+ continue;
254
+ }
255
+ // A successor may leave `active` through its own authorized lifecycle
256
+ // transition; its record must then carry a registered lifecycleDecision.
257
+ if (successor.status !== "active" && !this.decisions.has(successor.lifecycleDecision))
258
+ this.addError(
259
+ node.id,
260
+ `split successor must be an active guarantee or carry a registered lifecycleDecision ${id}`,
261
+ );
212
262
  }
213
263
  }
214
264
  if (node.kind === "DomainInterface" || node.kind === "UseCase") {
@@ -269,9 +319,35 @@ class ProductCheck {
269
319
  this.errors.push(`base: ${error.message}`);
270
320
  return;
271
321
  }
322
+ const legalStatusTransitions = new Map([
323
+ ["active", ["active", "split", "retired"]],
324
+ ["split", ["split"]],
325
+ ["retired", ["retired"]],
326
+ ]);
272
327
  for (const baseNode of baseNodes.values()) {
273
- if (baseNode.kind === "Guarantee" && !this.nodes.has(baseNode.id)) {
328
+ if (baseNode.kind !== "Guarantee") continue;
329
+ const current = this.nodes.get(baseNode.id);
330
+ if (!current) {
274
331
  this.errors.push(`guarantee disappeared from the product: ${baseNode.id}`);
332
+ continue;
333
+ }
334
+ if (!(legalStatusTransitions.get(baseNode.status) ?? []).includes(current.status)) {
335
+ this.addError(baseNode.id, `illegal guarantee status transition ${baseNode.status} -> ${current.status}`);
336
+ continue;
337
+ }
338
+ if (baseNode.status === "split" || baseNode.status === "retired") {
339
+ if (current.lifecycleDecision !== baseNode.lifecycleDecision)
340
+ this.addError(
341
+ baseNode.id,
342
+ `closed guarantee must keep lifecycleDecision ${baseNode.lifecycleDecision} from base`,
343
+ );
344
+ const baseSuccessors = asArray(baseNode.successors);
345
+ const currentSuccessors = asArray(current.successors);
346
+ if (
347
+ baseSuccessors.length !== currentSuccessors.length ||
348
+ baseSuccessors.some((id, index) => currentSuccessors[index] !== id)
349
+ )
350
+ this.addError(baseNode.id, `closed guarantee must keep successors ${baseSuccessors.join(", ")} from base`);
275
351
  }
276
352
  }
277
353
  }
@@ -373,6 +449,14 @@ function listFiles(directory, pattern) {
373
449
  }
374
450
  }
375
451
 
452
+ /**
453
+ * Recursively list Markdown files under a documentation root, skipping ignored
454
+ * scan entries, nested git checkouts, nested product roots, and the audit and
455
+ * superpowers doc areas.
456
+ * @param {string} rootPath - Documentation root to walk.
457
+ * @param {string[]} [ignoredEntryNames] - Extra directory names to skip (from .ddduck config).
458
+ * @returns {string[]} Sorted absolute Markdown file paths.
459
+ */
376
460
  function listMarkdownFiles(rootPath, ignoredEntryNames = []) {
377
461
  const files = [];
378
462
 
@@ -454,6 +538,12 @@ function executePolicyChecks(check, checksToRun, { includeDocumentation = true }
454
538
  }
455
539
  }
456
540
 
541
+ /**
542
+ * Validate a product root and return the completed check (inspect `.errors`).
543
+ * @param {string} rootPath - Product root to validate.
544
+ * @param {{baseRoot?: string, includeDocumentation?: boolean, documentationRoots?: string[], sourceOnly?: boolean}} [options] - Base product for history retention, documentation scope, and whether generated docs are scanned.
545
+ * @returns {ProductCheck} The finished check with errors and counters.
546
+ */
457
547
  export function validateProduct(
458
548
  rootPath,
459
549
  { baseRoot, includeDocumentation = true, documentationRoots, sourceOnly = false } = {},
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
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
7
+ * runner in lib/product-operation.mjs; init publishes a fresh product root via
8
+ * PID-stamped staging; check delegates to check-model.mjs plus the generated
9
+ * freshness gates. Errors leave through writeCliError with a Next: action line.
10
+ */
11
+
3
12
  import {
4
13
  existsSync,
5
14
  mkdirSync,
@@ -10,6 +19,7 @@ import {
10
19
  renameSync,
11
20
  rmSync,
12
21
  rmdirSync,
22
+ statSync,
13
23
  writeFileSync,
14
24
  } from "node:fs";
15
25
  import path from "node:path";
@@ -20,10 +30,17 @@ import { writeModelOverview } from "./generate-docs.mjs";
20
30
  import { writeModelGraph } from "./generate-graph.mjs";
21
31
  import { checkGeneratedDocs } from "./check-generated-docs.mjs";
22
32
  import { checkGeneratedGraph } from "./check-generated-graph.mjs";
23
- import { assertProductNotBusy, findLeftoverOperationState, runProductOperation } from "./lib/product-operation.mjs";
33
+ import {
34
+ assertProductNotBusy,
35
+ findLeftoverOperationState,
36
+ generatedPaths,
37
+ isProcessAlive,
38
+ runProductOperation,
39
+ validationFailureError,
40
+ } from "./lib/product-operation.mjs";
24
41
  import { resolveContainedOutput } from "./lib/product-paths.mjs";
25
42
  import { initStagingPrefix, resolveInitDestination, resolveProductRoot } from "./lib/product-root-resolver.mjs";
26
- import { defaultConfigIgnore, findRepositoryRoot } from "./lib/ddduck-config.mjs";
43
+ import { defaultConfigIgnore, findRepositoryRoot, loadDdduckConfig } from "./lib/ddduck-config.mjs";
27
44
  import { installSkill } from "./lib/skill-installer.mjs";
28
45
  import { runQuery } from "./query-model.mjs";
29
46
  import { CliUsageError, parseCommandArgs, renderHelp, writeCliError } from "./lib/cli-contract.mjs";
@@ -38,6 +55,11 @@ try {
38
55
  writeCliError(error, { nextAction: nextActionFor(cliArgs) });
39
56
  }
40
57
 
58
+ /**
59
+ * Dispatch one parsed CLI invocation to its command handler.
60
+ * @param {string[]} args - Raw CLI arguments (process.argv minus node and script).
61
+ * @returns {void}
62
+ */
41
63
  function run(args) {
42
64
  if (args.length === 1 && args[0] === "--help") {
43
65
  process.stdout.write(renderHelp());
@@ -81,6 +103,12 @@ function run(args) {
81
103
  }
82
104
  }
83
105
 
106
+ /**
107
+ * Implement `ddduck install skill update-ddduck-specs`: install the bundled
108
+ * host skill adapter and .ddduck/agent-skills.lock.json into --repo.
109
+ * @param {string[]} args - Arguments after the `install` command word.
110
+ * @returns {void}
111
+ */
84
112
  function install(args) {
85
113
  const { positionals, options } = parseCommandArgs(args, {
86
114
  positionals: { min: 2, max: 2 },
@@ -92,6 +120,13 @@ function install(args) {
92
120
  }
93
121
  const packageVersion = JSON.parse(readFileSync(path.join(frameworkRoot, "package.json"), "utf8")).version;
94
122
  const repository = path.resolve(options.repo ?? process.cwd());
123
+ // A typo'd --repo must fail instead of silently manufacturing a directory
124
+ // tree (and a lock) at the wrong path while the real repository gets nothing.
125
+ if (!existsSync(repository) || !statSync(repository).isDirectory()) {
126
+ throw new CliUsageError(`install requires an existing repository directory: ${repository}`, {
127
+ nextAction: "Pass --repo <existing-repository-root> and retry.",
128
+ });
129
+ }
95
130
  const result = installSkill({
96
131
  repository,
97
132
  skillName,
@@ -104,6 +139,14 @@ function install(args) {
104
139
  );
105
140
  }
106
141
 
142
+ /**
143
+ * Implement `ddduck init`: build a new product root (canonical directories,
144
+ * product.yaml, all generated views) in a PID-stamped staging directory beside
145
+ * the destination, then publish it atomically via rename and write
146
+ * .ddduck/config.json if absent.
147
+ * @param {string[]} args - Arguments after the `init` command word.
148
+ * @returns {{result: object, json: boolean}} The operation result and whether --json was requested.
149
+ */
107
150
  function initialize(args) {
108
151
  const { positionals, options } = parseCommandArgs(args, {
109
152
  positionals: { min: 0, max: 1 },
@@ -112,7 +155,9 @@ function initialize(args) {
112
155
  const destination = resolveInitDestination({ explicitDestination: positionals[0] });
113
156
  const productId = requiredOption(options, "id", "init requires --id model:<product-id>");
114
157
  if (!/^model:[a-z0-9][a-z0-9-]*$/.test(productId)) {
115
- throw new Error(`Invalid product ID ${JSON.stringify(productId)}`);
158
+ throw new Error(
159
+ `Invalid product ID ${JSON.stringify(productId)}; expected model:<lowercase-slug> (for example model:library)`,
160
+ );
116
161
  }
117
162
 
118
163
  mkdirSync(path.dirname(destination), { recursive: true });
@@ -121,14 +166,19 @@ function initialize(args) {
121
166
  }
122
167
 
123
168
  // Stage names embed the destination so concurrent inits to sibling
124
- // destinations never sweep each other's live staging directories.
169
+ // destinations never sweep each other's live staging directories, and the
170
+ // creating PID so concurrent inits to the SAME destination only sweep stages
171
+ // whose creator is dead (mirroring the mutation lock's reclaim rule). Stages
172
+ // without a parseable live PID are interrupted-init debris and get swept.
125
173
  const destinationStagePrefix = `${initStagingPrefix}${path.basename(destination)}-`;
126
174
  for (const entry of readdirSync(path.dirname(destination))) {
127
- if (entry.startsWith(destinationStagePrefix)) {
128
- rmSync(path.join(path.dirname(destination), entry), { recursive: true, force: true });
129
- }
175
+ if (!entry.startsWith(destinationStagePrefix)) continue;
176
+ const stagePid = Number.parseInt(entry.slice(destinationStagePrefix.length).match(/^(\d+)-/)?.[1] ?? "", 10);
177
+ if (Number.isInteger(stagePid) && stagePid > 0 && isProcessAlive(stagePid)) continue;
178
+ rmSync(path.join(path.dirname(destination), entry), { recursive: true, force: true });
130
179
  }
131
- const stagingRoot = mkdtempSync(path.join(path.dirname(destination), destinationStagePrefix));
180
+ const stagingRoot = mkdtempSync(path.join(path.dirname(destination), `${destinationStagePrefix}${process.pid}-`));
181
+ let config;
132
182
  try {
133
183
  for (const directory of ["domains", "concepts", "relationships", "use-cases", "interfaces", "guarantees"]) {
134
184
  mkdirSync(resolveContainedOutput(stagingRoot, path.join("model", directory)), { recursive: true });
@@ -145,10 +195,22 @@ function initialize(args) {
145
195
  decisions: [],
146
196
  });
147
197
  refreshDerivedOutput(stagingRoot);
148
- if (existsSync(destination)) rmdirSync(destination);
149
- renameSync(stagingRoot, destination);
150
198
  try {
151
- writeConfigIfAbsent(destination);
199
+ if (existsSync(destination)) rmdirSync(destination);
200
+ renameSync(stagingRoot, destination);
201
+ } catch (error) {
202
+ // A concurrent init to the same destination can publish between the
203
+ // emptiness check above and this rename; name the collision instead of
204
+ // surfacing the raw filesystem error.
205
+ if (error.code === "ENOTEMPTY" || error.code === "EEXIST") {
206
+ throw new Error(
207
+ `Refusing to initialize non-empty directory ${destination}; another init published it concurrently`,
208
+ );
209
+ }
210
+ throw error;
211
+ }
212
+ try {
213
+ config = writeConfigIfAbsent(destination);
152
214
  } catch (error) {
153
215
  // Writing the config is the final publish step. If it fails (for example a
154
216
  // regular file already occupies the .ddduck config path), roll back the
@@ -160,6 +222,7 @@ function initialize(args) {
160
222
  } finally {
161
223
  rmSync(stagingRoot, { recursive: true, force: true });
162
224
  }
225
+ if (!config.created) warnPinnedRepositoryDefault(config, destination);
163
226
  return {
164
227
  json: options.json,
165
228
  result: {
@@ -167,15 +230,46 @@ function initialize(args) {
167
230
  root: realpathSync(destination),
168
231
  affectedIds: [productId],
169
232
  canonicalPaths: ["product.yaml"],
170
- generatedPaths: [
171
- "generated/docs/model-overview.md",
172
- "generated/graph/model-graph.json",
173
- "generated/graph/model-graph.ndjson",
174
- ],
233
+ generatedPaths: [...generatedPaths],
234
+ ...(config.created ? { configPath: config.configPath } : {}),
175
235
  },
176
236
  };
177
237
  }
178
238
 
239
+ // A pre-existing repository config keeps selecting its own product root; a
240
+ // second init must say so or every rootless command silently addresses the
241
+ // other product.
242
+ function warnPinnedRepositoryDefault(config, destination) {
243
+ let configuredRoot;
244
+ try {
245
+ const loaded = loadDdduckConfig(config.repositoryRoot);
246
+ if (!loaded) return;
247
+ configuredRoot = path.resolve(config.repositoryRoot, loaded.productRoot);
248
+ } catch {
249
+ return; // An unreadable config surfaces on the next root resolution.
250
+ }
251
+ if (canonicalPath(configuredRoot) === canonicalPath(destination)) return;
252
+ process.stderr.write(
253
+ `init: ${config.configPath} still selects ${configuredRoot} as the repository default root; pass --root or update the config to select ${path.resolve(destination)}.\n`,
254
+ );
255
+ }
256
+
257
+ function canonicalPath(candidate) {
258
+ try {
259
+ return realpathSync(candidate);
260
+ } catch {
261
+ return path.resolve(candidate);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Implement `ddduck check`: run check-model.mjs in a child process against the
267
+ * resolved root, then gate generated-view freshness (docs, graph JSON/NDJSON,
268
+ * and the SVG via a subprocess because rendering is async WASM) and refuse
269
+ * leftover interrupted-operation state. Busy roots exit 2 via ProductBusyError.
270
+ * @param {string[]} args - Arguments after the `check` command word.
271
+ * @returns {void}
272
+ */
179
273
  function executeChecker(args) {
180
274
  const { options } = parseCommandArgs(args, {
181
275
  options: {
@@ -186,6 +280,11 @@ function executeChecker(args) {
186
280
  },
187
281
  });
188
282
  const root = resolveProductRoot({ explicitRoot: options.root });
283
+ // Name the validated root when it was resolved implicitly, so a config-pinned
284
+ // root can never be validated invisibly. stderr keeps stdout script-safe.
285
+ if (!options.root) {
286
+ process.stderr.write(`check: validating ${root} (root resolved automatically; pass --root to override)\n`);
287
+ }
189
288
  assertProductNotBusy(root);
190
289
  const checkerArgs = [path.join(frameworkRoot, "scripts", "check-model.mjs"), "--root", root];
191
290
  if (options.base) checkerArgs.push("--base", path.resolve(options.base));
@@ -200,7 +299,8 @@ function executeChecker(args) {
200
299
  if (result.stdout) process.stdout.write(result.stdout);
201
300
  if (result.status !== 0) {
202
301
  const diagnostic = result.stderr.trim();
203
- throw new CliUsageError(`Validation failed for ${root}${diagnostic ? `:\n${diagnostic}` : ""}`);
302
+ if (!diagnostic) throw new CliUsageError(`Validation failed for ${root}`);
303
+ throw validationFailureError(root, diagnostic.split("\n"));
204
304
  }
205
305
  if (options["source-only"]) return;
206
306
  try {
@@ -222,7 +322,11 @@ function executeChecker(args) {
222
322
  throw new Error(`Failed to run the model graph SVG check for ${root}: ${svgCheck.error.message}`);
223
323
  }
224
324
  if (svgCheck.status !== 0) {
225
- const diagnostic = (svgCheck.stderr || "").trim() || "generated/graph/model-graph.svg is missing or stale";
325
+ // The standalone gate prints its own regenerate remedy; strip it here so
326
+ // the remedy appears exactly once, in the Next: line below.
327
+ const diagnostic =
328
+ (svgCheck.stderr || "").trim().replace(/; run ddduck generate --root [^\n]*/g, "") ||
329
+ "generated/graph/model-graph.svg is missing or stale";
226
330
  throw new CliUsageError(`Validation failed for ${root}: ${diagnostic}`, {
227
331
  nextAction: `Run ddduck generate --root ${root}, then re-run ddduck check.`,
228
332
  });
@@ -238,6 +342,12 @@ function executeChecker(args) {
238
342
  }
239
343
  }
240
344
 
345
+ /**
346
+ * Implement `ddduck generate`: refresh every generated view through the locked
347
+ * staged operation runner with an empty (no canonical replacement) plan.
348
+ * @param {string[]} args - Arguments after the `generate` command word.
349
+ * @returns {void}
350
+ */
241
351
  function generate(args) {
242
352
  const { options } = parseCommandArgs(args, { options: { root: { value: true }, json: { value: false } } });
243
353
  const root = resolveProductRoot({ explicitRoot: options.root });
@@ -248,6 +358,14 @@ function generate(args) {
248
358
  writeProductOperationResult(result, options.json);
249
359
  }
250
360
 
361
+ /**
362
+ * Implement the guarantee lifecycle commands create, move, split, and retire:
363
+ * parse per-command options, require a registered decision for split/retire,
364
+ * and run the resulting plan through the staged operation runner.
365
+ * @param {"create"|"move"|"split"|"retire"} command - Lifecycle command name.
366
+ * @param {string[]} args - Arguments after the command word.
367
+ * @returns {void}
368
+ */
251
369
  function transitionGuarantee(command, args) {
252
370
  const optionDefinitions = {
253
371
  create: {
@@ -307,6 +425,12 @@ function requireRegisteredDecision(root, decision, command) {
307
425
  );
308
426
  }
309
427
 
428
+ /**
429
+ * Print an operation result as one text line or one JSON object (--json).
430
+ * @param {{operation: string, root: string, affectedIds: string[], canonicalPaths: string[], generatedPaths: string[], configPath?: string}} result - Result from the operation runner or init.
431
+ * @param {boolean} json - Emit JSON instead of the text form.
432
+ * @returns {void}
433
+ */
310
434
  function writeProductOperationResult(result, json) {
311
435
  if (json) {
312
436
  process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -314,11 +438,21 @@ function writeProductOperationResult(result, json) {
314
438
  }
315
439
  const affected = result.affectedIds.length > 0 ? ` ${result.affectedIds.join(", ")}` : "";
316
440
  const canonical = result.canonicalPaths.length > 0 ? result.canonicalPaths.join(", ") : "none";
441
+ const config = result.configPath ? `; config: ${result.configPath} (created)` : "";
317
442
  process.stdout.write(
318
- `${result.operation}${affected} in ${result.root}; canonical: ${canonical}; generated: ${result.generatedPaths.join(", ")}\n`,
443
+ `${result.operation}${affected} in ${result.root}; canonical: ${canonical}; generated: ${result.generatedPaths.join(", ")}${config}\n`,
319
444
  );
320
445
  }
321
446
 
447
+ /**
448
+ * Build the operation plan for one guarantee lifecycle command from a frozen
449
+ * product snapshot; move/split/retire require the source guarantee to be active.
450
+ * @param {"create"|"move"|"split"|"retire"} command - Lifecycle command name.
451
+ * @param {{nodes: object[], canonicalPaths: Record<string, string>}} snapshot - Frozen staged product snapshot.
452
+ * @param {string|undefined} id - Target guarantee ID (absent for create).
453
+ * @param {Record<string, string|boolean>} options - Parsed command options.
454
+ * @returns {{operation: string, affectedIds: string[], replacements: {path: string, value: object}[]}} Plan for the operation runner.
455
+ */
322
456
  function buildGuaranteePlan(command, snapshot, id, options) {
323
457
  if (command === "create") return createGuarantee(snapshot, options);
324
458
  const guarantee = requireGuarantee(snapshot, id);
@@ -395,10 +529,14 @@ function moveGuarantee(snapshot, guarantee, destinationDomain) {
395
529
  if (guarantee.ownerDomain === destinationDomain)
396
530
  throw new Error(`${guarantee.id} is already owned by ${destinationDomain}`);
397
531
  const previousOwner = guarantee.ownerDomain;
532
+ // ownershipHistory lists former owning Domains only (model-reference.md), so
533
+ // an owner that regains the guarantee leaves the history again.
398
534
  const updated = {
399
535
  ...guarantee,
400
536
  ownerDomain: destinationDomain,
401
- ownershipHistory: [...new Set([...(guarantee.ownershipHistory ?? []), previousOwner])],
537
+ ownershipHistory: [...new Set([...(guarantee.ownershipHistory ?? []), previousOwner])].filter(
538
+ (domainId) => domainId !== destinationDomain,
539
+ ),
402
540
  };
403
541
  return {
404
542
  operation: "move guarantee",
@@ -517,10 +655,16 @@ function writeYaml(filePath, value) {
517
655
  writeFileSync(filePath, stringify(value));
518
656
  }
519
657
 
658
+ /**
659
+ * Write .ddduck/config.json at the repository root selecting the new product
660
+ * root, unless a config already exists (then it is left untouched).
661
+ * @param {string} destination - Absolute path of the freshly published product root.
662
+ * @returns {{configPath: string, repositoryRoot: string, created: boolean}} Where the config lives and whether it was created.
663
+ */
520
664
  function writeConfigIfAbsent(destination) {
521
665
  const repositoryRoot = findRepositoryRoot(destination);
522
666
  const configPath = path.join(repositoryRoot, ".ddduck", "config.json");
523
- if (existsSync(configPath)) return;
667
+ if (existsSync(configPath)) return { configPath, repositoryRoot, created: false };
524
668
  const relativeProductRoot = path.relative(repositoryRoot, destination).split(path.sep).join("/");
525
669
  // findRepositoryRoot falls back to the destination itself when no enclosing
526
670
  // .git exists, which makes the relative path empty; "." keeps productRoot
@@ -531,6 +675,7 @@ function writeConfigIfAbsent(destination) {
531
675
  configPath,
532
676
  `${JSON.stringify({ schemaVersion: "1", productRoot, ignore: [...defaultConfigIgnore] }, null, 2)}\n`,
533
677
  );
678
+ return { configPath, repositoryRoot, created: true };
534
679
  }
535
680
 
536
681
  function nextActionFor(args) {
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * CLI wrapper for the agent-readiness report (`--root <product-root>`):
5
+ * delegates to lib/agent-readiness-report.mjs and prints the JSON report of
6
+ * missing evidence roles, unresolved references, stale generated views,
7
+ * orphaned nodes, and ambiguous ownership. Exits 1 when validation failed
8
+ * (report reduced to unresolvedReferences) or on any error.
9
+ */
10
+
3
11
  import path from "node:path";
4
12
  import { generateAgentReadinessReport } from "./lib/agent-readiness-report.mjs";
5
13
 
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Renders the generated Markdown view generated/docs/model-overview.md from a
5
+ * product root's canonical YAML: domains with their concepts, interfaces, and
6
+ * active guarantees, then use cases, interfaces, relationships, and decisions.
7
+ * Inactive (split/retired) guarantees are excluded. buildModelOverview feeds
8
+ * the freshness gate (check-generated-docs.mjs); writeModelOverview is called
9
+ * by init and the staged operation runner. Also runnable standalone via --root.
10
+ */
11
+
3
12
  import { mkdirSync, writeFileSync } from "node:fs";
4
13
  import path from "node:path";
5
14
  import { fileURLToPath } from "node:url";
@@ -9,12 +18,22 @@ import { resolveProductRoot } from "./lib/product-root-resolver.mjs";
9
18
 
10
19
  const outputPath = path.join("generated", "docs", "model-overview.md");
11
20
 
21
+ /**
22
+ * Build the model overview Markdown for a product root without writing it.
23
+ * @param {string} rootPath - Product root path.
24
+ * @returns {string} The full generated Markdown document.
25
+ */
12
26
  export function buildModelOverview(rootPath) {
13
27
  const layout = detectProductLayout(rootPath);
14
28
  const graph = loadGraph(layout);
15
29
  return renderModelOverview(assembleModelView(graph, findModelId(graph)));
16
30
  }
17
31
 
32
+ /**
33
+ * Write generated/docs/model-overview.md below the product root.
34
+ * @param {string} rootPath - Product root path.
35
+ * @returns {string} The root-relative path that was written.
36
+ */
18
37
  export function writeModelOverview(rootPath) {
19
38
  const output = buildModelOverview(rootPath);
20
39
  const absoluteOutputPath = resolveContainedOutput(rootPath, outputPath);
@@ -30,6 +49,14 @@ function loadGraph(layout) {
30
49
  };
31
50
  }
32
51
 
52
+ /**
53
+ * Assemble the sorted, resolved view of the model that the renderer consumes:
54
+ * domains with their owned nodes resolved, relationships, decisions, use
55
+ * cases, and every DomainInterface in the product.
56
+ * @param {{nodes: Map<string, object>}} graph - Loaded product nodes (active guarantees only).
57
+ * @param {string} modelId - ID of the single Model node.
58
+ * @returns {{model: object, domains: object[], relationships: object[], decisions: string[], useCases: object[], interfaces: object[]}} The renderable view.
59
+ */
33
60
  function assembleModelView(graph, modelId) {
34
61
  const model = resolveNode(graph, modelId);
35
62
  const domains = asArray(model.domains)
@@ -62,8 +89,7 @@ function renderModelOverview(view) {
62
89
  "",
63
90
  `# ${view.model.name} (\`${view.model.id}\`)`,
64
91
  "",
65
- `Name status: \`${view.model.nameStatus ?? "stable"}\``,
66
- "",
92
+ ...(view.model.nameStatus === undefined ? [] : [`Name status: \`${view.model.nameStatus}\``, ""]),
67
93
  view.model.purpose,
68
94
  "",
69
95
  "## Domains",