superdev-cli 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.
@@ -443,15 +443,23 @@ export function renderProposals(report) {
443
443
  * in one line, so the failing one is findable without reading the rest.
444
444
  */
445
445
  export function renderDoctor({ checks = [], findings = [] } = {}) {
446
- const rows = checks.map((c) => [c.name, c.ok ? "Pass" : "Problem", c.detail ?? ""]);
447
- const bad = checks.filter((c) => !c.ok).length;
446
+ // Three verdicts, because a check that threw is neither. Pass beside "could
447
+ // not be determined" was a real defect: it is the answer a reader trusts least
448
+ // once they notice it, and most when they do not.
449
+ const verdict = (ok) => (ok === true ? "Pass" : ok === false ? "Problem" : "Unknown");
450
+ const rows = checks.map((c) => [c.name, verdict(c.ok), c.detail ?? ""]);
451
+ const bad = checks.filter((c) => c.ok === false).length;
452
+ const unknown = checks.filter((c) => c.ok !== true && c.ok !== false).length;
448
453
  return stitch([
449
454
  heading("Doctor"),
450
455
  table(["Check", "Verdict", "Detail"], rows),
451
456
  "",
452
457
  bad
453
458
  ? `${plural(bad, "check")} found a problem.`
454
- : "Every check passed.",
459
+ : unknown
460
+ ? "No check found a problem."
461
+ : "Every check passed.",
462
+ unknown ? `${plural(unknown, "check")} could not determine an answer, so nothing here vouches for it.` : null,
455
463
  findings.length ? "" : null,
456
464
  findings.length ? block(`Findings (${findings.length})`, findingList(findings)) : null,
457
465
  ]);
package/src/cli.mjs CHANGED
@@ -11,9 +11,9 @@
11
11
  // 2 a usage error. A finding is a real answer, so `doctor` and `docs diff`
12
12
  // return 1 when they find something rather than pretending nothing is wrong.
13
13
 
14
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
14
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync, existsSync } from "node:fs";
15
15
  import { dirname, join, relative, resolve } from "node:path";
16
- import { pathToFileURL } from "node:url";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
17
  import * as R from "./cli/render.mjs";
18
18
 
19
19
  const nowIso = () => new Date().toISOString();
@@ -39,7 +39,7 @@ class Refusal extends Error {
39
39
  // Flags that carry no value. Everything else must be given one, so that
40
40
  // `--reason --apply` fails loudly instead of silently recording "true" as the
41
41
  // reason a task is blocked.
42
- const BOOLEAN = new Set(["apply", "json", "help", "all", "enabling", "end", "reports", "partial", "adopt", "dryRun", "resolve", "version", "noUpdateCheck", "updateCheck"]);
42
+ const BOOLEAN = new Set(["apply", "json", "help", "all", "enabling", "end", "reports", "partial", "adopt", "dryRun", "resolve", "version", "noUpdateCheck", "updateCheck", "entry", "remove"]);
43
43
 
44
44
  const camel = (name) => name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
45
45
 
@@ -82,6 +82,11 @@ function parseArgs(argv) {
82
82
 
83
83
  const asList = (value) => (value === undefined ? [] : [].concat(value).map(String));
84
84
 
85
+ /** A path with its symlinks resolved, or the path itself when it cannot be. */
86
+ const realpathOf = (path) => {
87
+ try { return realpathSync(path); } catch { return path; }
88
+ };
89
+
85
90
  function requireFlag(flags, name, what) {
86
91
  const value = flags[camel(name)];
87
92
  if (value === undefined || value === true) throw new UsageError(`${what} Pass --${name} <value>.`);
@@ -172,7 +177,21 @@ Knowledge
172
177
  decision record Record a decision, with what it governs
173
178
  decision supersede Replace a decision that no longer holds
174
179
  decision list Decisions and what they still govern
180
+ feature create Add a feature to a module
181
+ feature move <id> Reassign a feature's module or milestone
182
+ feature goal <id> Say which goal a feature advances
175
183
  feature specify <id> Write the specification its depth requires
184
+ goal record Record a lasting outcome
185
+ goal criterion <id> Add a success criterion, with how it is measured
186
+ milestone record Record a delivery stage
187
+ milestone condition <id> Add an exit condition, or --entry for an entry one
188
+ milestone update <id> Rename it, restate it, or move its target date
189
+ module record Record a slice of the product
190
+ module rename <id> Rename a module, or restate what it owns
191
+ scope record Record what the product will not do, and why
192
+ scope list What is in scope, out of scope, and a non-goal
193
+ scope remove <id> Take a scope line back out
194
+ retire <id> Take a goal or milestone out of scope, with the reason
176
195
  feature waive <id> Set an acceptance criterion aside, with the reason
177
196
  feature depth Read what a depth requires, or set one
178
197
  feature accept Accept a feature, refused while its depth is unmet
@@ -203,6 +222,16 @@ Options
203
222
  --actor <name> Who to record as responsible. Defaults to superdev
204
223
  --help This text
205
224
 
225
+ Flags worth knowing
226
+ init --brief <file> Read the project from a document. See requirement.md in
227
+ the Superdev repository for the format it reads best
228
+ init --adopt The documents already here are input to this project, not
229
+ a record of it. Say this when init routes to adopt but the
230
+ files it found are a brief rather than a projection
231
+ feature specify --not <text>
232
+ What the feature deliberately does not do. Repeatable.
233
+ Its counterpart is --in, and --out stays global
234
+
206
235
  Exit codes
207
236
  0 it worked, 1 something was found or refused, 2 the command was misused`;
208
237
 
@@ -554,6 +583,23 @@ async function cmdDoctor(ctx) {
554
583
  : "Nothing has been generated yet",
555
584
  });
556
585
 
586
+ // Markdown in the docs root that no record claims.
587
+ //
588
+ // Two Superdev architectures existed under the same version number, and both
589
+ // wrote to talks/. A copy of the older one, driving its own engine, would fill
590
+ // that directory with files this database has never heard of, and every check
591
+ // here would stay green because each one asks about records rather than about
592
+ // the directory. An unclaimed file is also what a hand-created document looks
593
+ // like, which is worth naming for its own sake.
594
+ const foreign = await unclaimedDocuments(ctx.root);
595
+ if (foreign.length) {
596
+ checks.push({
597
+ name: "Docs directory",
598
+ ok: false,
599
+ detail: `${countWord(foreign.length, "file")} under the docs root ${foreign.length === 1 ? "belongs" : "belong"} to no record: ${foreign.slice(0, 3).join(", ")}${foreign.length > 3 ? ", and more" : ""}. Superdev did not write ${foreign.length === 1 ? "it" : "them"}, so nothing here keeps ${foreign.length === 1 ? "it" : "them"} true.`,
600
+ });
601
+ }
602
+
557
603
  const view = await withProject(ctx.root, async (db, project) => ({
558
604
  warnings: await alignmentWarnings(db, project.id),
559
605
  freshness: await freshness(db, project.id),
@@ -595,13 +641,13 @@ async function cmdDoctor(ctx) {
595
641
  FROM verification_evidence WHERE status = 'current'`));
596
642
  checks.push({
597
643
  name: "Evidence",
598
- ok: (evidence?.failing ?? 0) === 0,
644
+ ok: evidence?.total ? (evidence.failing ?? 0) === 0 : null,
599
645
  detail: evidence?.total
600
646
  ? `${evidence.total - evidence.manual} of ${evidence.total} can be re-run${evidence.failing ? `, ${evidence.failing} last failed` : ""}. Run superdev verify.`
601
647
  : "No evidence recorded yet",
602
648
  });
603
649
 
604
- const ok = checks.every((c) => c.ok);
650
+ const ok = checks.every((c) => c.ok !== false);
605
651
  return {
606
652
  data: { ok, checks, migrations, integrity: { ok: integrity.ok, counts: integrity.counts }, findings: view.warnings },
607
653
  text: R.renderDoctor({ checks, findings: view.warnings }),
@@ -609,6 +655,45 @@ async function cmdDoctor(ctx) {
609
655
  };
610
656
  }
611
657
 
658
+ /**
659
+ * Markdown under the project's docs root that no document record claims.
660
+ *
661
+ * Read only, and bounded: this is a health check, not a crawl. The docs root is
662
+ * read from the project rather than assumed, because adoption points it at
663
+ * whatever the repository already used.
664
+ */
665
+ async function unclaimedDocuments(root) {
666
+ const found = [];
667
+ try {
668
+ const { readdirSync } = await import("node:fs");
669
+ const project = await withProject(root, async (db) => ({
670
+ docsRoot: (await db.get("SELECT docs_profile FROM projects LIMIT 1"))?.docs_profile === "talks-v1" ? "talks" : null,
671
+ known: new Set((await db.all("SELECT path FROM documents")).map((d) => d.path)),
672
+ }));
673
+ if (!project.docsRoot) return [];
674
+ const base = resolve(root, project.docsRoot);
675
+ const walk = (dir, depth) => {
676
+ if (depth > 4 || found.length > 50) return;
677
+ let entries;
678
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
679
+ for (const entry of entries) {
680
+ if (entry.name.startsWith(".")) continue;
681
+ const full = join(dir, entry.name);
682
+ if (entry.isDirectory()) walk(full, depth + 1);
683
+ else if (entry.name.endsWith(".md")) {
684
+ const rel = relative(resolve(root), full).split("\\").join("/");
685
+ if (!project.known.has(rel)) found.push(rel);
686
+ }
687
+ }
688
+ };
689
+ walk(base, 0);
690
+ } catch {
691
+ // A health check that throws is worse than one that says nothing here.
692
+ return [];
693
+ }
694
+ return found;
695
+ }
696
+
612
697
  /**
613
698
  * Detect the installed providers and write the report the session hook reads.
614
699
  *
@@ -640,7 +725,16 @@ async function recordProviderReadiness(root, paths) {
640
725
  : "No provider was detected",
641
726
  };
642
727
  } catch (error) {
643
- return { name: "Providers", ok: true, detail: `Readiness could not be determined: ${String(error.message).slice(0, 80)}` };
728
+ // ok: null, not ok: true. This returned Pass beside the words "could not be
729
+ // determined", which is a contradiction printed in green, and the detail
730
+ // column truncates at 80 characters so on a narrow terminal only the green
731
+ // was visible. Doctor exists so nothing is silently skipped; its own checks
732
+ // are held to that.
733
+ return {
734
+ name: "Providers",
735
+ ok: null,
736
+ detail: `Readiness could not be determined: ${String(error.message).slice(0, 120)}`,
737
+ };
644
738
  }
645
739
  }
646
740
 
@@ -1571,12 +1665,24 @@ async function cmdSettings(ctx) {
1571
1665
  }
1572
1666
 
1573
1667
  const on = checkingEnabled(ctx.root);
1668
+ // Which copy is answering, not just which version.
1669
+ //
1670
+ // Superdev ships as an npm package and as a plugin, and both carry the same
1671
+ // version number. A stale plugin copy of an older architecture sat in a cache
1672
+ // beside the current one under the same number, and no command could say which
1673
+ // of them a session was actually using. The running file settles it, so
1674
+ // "superdev 0.1.1" stops naming two different things.
1675
+ const running = realpathOf(fileURLToPath(import.meta.url));
1676
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ? realpathOf(process.env.CLAUDE_PLUGIN_ROOT) : null;
1677
+ const throughPlugin = Boolean(pluginRoot && running.startsWith(pluginRoot));
1574
1678
  return {
1575
- data: { version: me.version, package: me.name, updateCheck: on },
1679
+ data: { version: me.version, package: me.name, updateCheck: on, running, pluginRoot, throughPlugin },
1576
1680
  text: R.stitch([
1577
1681
  R.heading("Settings"),
1578
1682
  R.pairs([
1579
1683
  ["Version", `${me.name} ${me.version}`],
1684
+ ["Running from", running],
1685
+ ["Reached as", throughPlugin ? "the plugin's own copy" : "an installed superdev-cli"],
1580
1686
  ["Update check", on ? "On" : "Off"],
1581
1687
  ]),
1582
1688
  R.wrap(on
@@ -2377,6 +2483,283 @@ async function cmdCategoryRestore(ctx) {
2377
2483
  }
2378
2484
 
2379
2485
 
2486
+ // ------------------------------------------------------- authoring the map
2487
+ //
2488
+ // Every product record could only be created by `init`, so a project was frozen
2489
+ // the moment it was initialized. The read side was complete, which is why the
2490
+ // gap was invisible: goal list and goal show both worked while nothing could
2491
+ // write a goal. These are the writes.
2492
+
2493
+ async function cmdGoalRecord(ctx) {
2494
+ const { recordGoal } = await import("./product/authoring.mjs");
2495
+ const out = await recordGoal(ctx.root, {
2496
+ name: requireFlag(ctx.flags, "name", "A goal needs a name that states the outcome, not the work."),
2497
+ why: ctx.flags.why ?? null,
2498
+ description: ctx.flags.description ?? null,
2499
+ actor: ctx.actor, apply: ctx.apply,
2500
+ });
2501
+ if (!out.applied) {
2502
+ return planned(out, "record it", R.wrap(`Would record the goal "${out.name}".`));
2503
+ }
2504
+ return {
2505
+ data: out,
2506
+ text: R.stitch([
2507
+ R.wrap(`${out.goal.id} recorded: ${out.goal.name}`),
2508
+ R.wrap(`It has no success criteria yet, so nothing can say whether it is met and progress counts it as unmeasurable. Add one with superdev goal criterion ${out.goal.id} --criterion "<what is true>" --measurement "<how it is checked>" --apply.`),
2509
+ ]),
2510
+ };
2511
+ }
2512
+
2513
+ async function cmdGoalCriterion(ctx) {
2514
+ const { count } = await import("./model/vocabulary.mjs");
2515
+ const id = requireWord(ctx.words, 2, "Say which goal: superdev goal criterion <GOAL-id>.");
2516
+ const { addGoalCriterion } = await import("./product/authoring.mjs");
2517
+ const out = await addGoalCriterion(ctx.root, id, {
2518
+ criterion: requireFlag(ctx.flags, "criterion", "Say what has to be true. A criterion nobody can check is not one."),
2519
+ measurement: ctx.flags.measurement ?? null,
2520
+ target: ctx.flags.target ?? null,
2521
+ actor: ctx.actor, apply: ctx.apply,
2522
+ });
2523
+ if (!out.applied) {
2524
+ return planned(out, "record it", R.wrap(`Would add to ${id}: ${out.criterion}`));
2525
+ }
2526
+ return {
2527
+ data: out,
2528
+ text: R.stitch([
2529
+ R.wrap(`${out.criterion.id} recorded against ${out.goal.name}: ${out.criterion.criterion}`),
2530
+ R.wrap(`${out.goal.id} now carries ${count(out.total, "success criterion", "success criteria")}. It is met when every one of them is.`),
2531
+ ]),
2532
+ };
2533
+ }
2534
+
2535
+ async function cmdMilestoneRecord(ctx) {
2536
+ const { recordMilestone } = await import("./product/authoring.mjs");
2537
+ const out = await recordMilestone(ctx.root, {
2538
+ name: requireFlag(ctx.flags, "name", "A milestone needs a name that says what is delivered by it."),
2539
+ outcome: ctx.flags.outcome ?? null,
2540
+ targetDate: ctx.flags.date ?? null,
2541
+ actor: ctx.actor, apply: ctx.apply,
2542
+ });
2543
+ if (!out.applied) {
2544
+ return planned(out, "record it", R.wrap(`Would record the milestone "${out.name}".`));
2545
+ }
2546
+ return {
2547
+ data: out,
2548
+ text: R.stitch([
2549
+ R.wrap(`${out.milestone.id} recorded: ${out.milestone.name}`),
2550
+ R.wrap(`It has no exit conditions, so nothing says when it is reached. Add one with superdev milestone condition ${out.milestone.id} --condition "<what must hold>" --apply.`),
2551
+ ]),
2552
+ };
2553
+ }
2554
+
2555
+ async function cmdMilestoneCondition(ctx) {
2556
+ const { count } = await import("./model/vocabulary.mjs");
2557
+ const id = requireWord(ctx.words, 2, "Say which milestone: superdev milestone condition <MS-id>.");
2558
+ const { addMilestoneCondition } = await import("./product/authoring.mjs");
2559
+ const out = await addMilestoneCondition(ctx.root, id, {
2560
+ condition: requireFlag(ctx.flags, "condition", "Say what has to hold before this milestone is reached."),
2561
+ check: ctx.flags.check ?? null,
2562
+ entry: Boolean(ctx.flags.entry),
2563
+ actor: ctx.actor, apply: ctx.apply,
2564
+ });
2565
+ if (!out.applied) {
2566
+ return planned(out, "record it", R.wrap(`Would add to ${id} as ${out.entry ? "an entry" : "an exit"} condition: ${out.condition}`));
2567
+ }
2568
+ return {
2569
+ data: out,
2570
+ text: R.wrap(out.entry
2571
+ ? `Recorded against ${id}. It now carries ${count(out.total, "entry condition")}, and cannot be started until every one is met.`
2572
+ : `Recorded against ${id}. It now carries ${count(out.total, "exit condition")}, and is reached when every one is met.`),
2573
+ };
2574
+ }
2575
+
2576
+ async function cmdModuleRecord(ctx) {
2577
+ const { recordModule } = await import("./product/authoring.mjs");
2578
+ const out = await recordModule(ctx.root, {
2579
+ name: requireFlag(ctx.flags, "name", "A module needs a name."),
2580
+ purpose: ctx.flags.purpose ?? null,
2581
+ actor: ctx.actor, apply: ctx.apply,
2582
+ });
2583
+ if (!out.applied) {
2584
+ return planned(out, "record it", R.wrap(`Would record the module "${out.name}".`));
2585
+ }
2586
+ return {
2587
+ data: out,
2588
+ text: R.wrap(`${out.module.id} recorded: ${out.module.name}. Put a feature in it with superdev feature create --module ${out.module.id}.`),
2589
+ };
2590
+ }
2591
+
2592
+ async function cmdFeatureCreate(ctx) {
2593
+ const { createFeature } = await import("./product/authoring.mjs");
2594
+ const out = await createFeature(ctx.root, {
2595
+ moduleId: ctx.flags.module ? String(ctx.flags.module) : null,
2596
+ milestoneId: ctx.flags.milestone ? String(ctx.flags.milestone) : null,
2597
+ name: requireFlag(ctx.flags, "name", "A feature needs a name that states what somebody can do."),
2598
+ purpose: ctx.flags.purpose ?? null,
2599
+ actor: ctx.actor, apply: ctx.apply,
2600
+ });
2601
+ if (!out.applied) {
2602
+ return planned(out, "create it", R.wrap(`Would draft "${out.name}" in module ${out.moduleId}.`));
2603
+ }
2604
+ return {
2605
+ data: out,
2606
+ text: R.stitch([
2607
+ R.wrap(`${out.feature.id} drafted in ${out.module.name}: ${out.feature.name}`),
2608
+ R.wrap(`It is Draft at microspec depth. Write its specification with superdev feature specify ${out.feature.id}, then accept it. The depth gate refuses acceptance while anything it promises is missing.`),
2609
+ ]),
2610
+ };
2611
+ }
2612
+
2613
+ async function cmdScopeRecord(ctx) {
2614
+ const { recordScopeItem } = await import("./product/authoring.mjs");
2615
+ // --not is the same word here as in feature specify, and means the same thing.
2616
+ const direction = ctx.flags.in !== undefined ? "in" : ctx.flags.out !== undefined ? "out" : "non_goal";
2617
+ const statement = ctx.flags.in ?? ctx.flags.out ?? ctx.flags.not ?? ctx.words[2];
2618
+ if (statement === undefined) {
2619
+ throw new UsageError('Say what is decided: superdev scope record --not "<what this will not do>" [--why "<reason>"].');
2620
+ }
2621
+ const out = await recordScopeItem(ctx.root, {
2622
+ statement: String(statement),
2623
+ direction,
2624
+ why: ctx.flags.why ?? null,
2625
+ horizon: ctx.flags.horizon ?? null,
2626
+ actor: ctx.actor, apply: ctx.apply,
2627
+ });
2628
+ if (!out.applied) {
2629
+ return planned(out, "record it", R.wrap(`Would record as ${SAID_AS[out.direction]}: ${out.statement}`));
2630
+ }
2631
+ return {
2632
+ data: out,
2633
+ text: R.wrap(`${out.item.id} records this as ${out.said}. It appears in the product foundations, which is the one place a deliberate exclusion is distinguishable from something nobody thought of.`),
2634
+ };
2635
+ }
2636
+
2637
+ async function cmdScopeRemove(ctx) {
2638
+ const id = requireWord(ctx.words, 2, "Say which scope item: superdev scope remove <SCOPE-id>.");
2639
+ const { removeScopeItem } = await import("./product/authoring.mjs");
2640
+ const out = await removeScopeItem(ctx.root, id, { actor: ctx.actor, apply: ctx.apply });
2641
+ if (!out.applied) {
2642
+ return planned(out, "remove it", R.wrap(`Would stop recording as ${out.said}: ${out.statement}`));
2643
+ }
2644
+ return { data: out, text: R.wrap(`${id} is no longer recorded as ${out.said}.`) };
2645
+ }
2646
+
2647
+ async function cmdScopeList(ctx) {
2648
+ const { scopeList } = await import("./product/authoring.mjs");
2649
+ const rows = await scopeList(ctx.root);
2650
+ if (!rows.length) {
2651
+ return {
2652
+ data: { scope: [] },
2653
+ text: R.wrap('Nothing is recorded as in or out of scope. What a product deliberately does not do is the only place a decision is distinguishable from an oversight; record one with superdev scope record --not "<what this will not do>".'),
2654
+ };
2655
+ }
2656
+ return {
2657
+ data: { scope: rows },
2658
+ text: R.stitch([
2659
+ R.heading(`Scope (${rows.length})`),
2660
+ R.table(["Id", "Direction", "Statement", "Why"],
2661
+ rows.map((r) => [r.id, SAID_AS[r.direction] ?? r.direction, r.statement, r.why ?? "Not recorded"])),
2662
+ ]),
2663
+ };
2664
+ }
2665
+
2666
+ const SAID_AS = { in: "in scope", out: "out of scope", non_goal: "a non-goal" };
2667
+
2668
+ async function cmdMilestoneUpdate(ctx) {
2669
+ const id = requireWord(ctx.words, 2, "Say which milestone: superdev milestone update <MS-id> [--name <text>] [--outcome <text>] [--target <date>].");
2670
+ const { updateMilestone } = await import("./product/authoring.mjs");
2671
+ const out = await updateMilestone(ctx.root, id, {
2672
+ name: ctx.flags.name ?? null,
2673
+ outcome: ctx.flags.outcome ?? null,
2674
+ targetDate: ctx.flags.target ?? null,
2675
+ actor: ctx.actor, apply: ctx.apply,
2676
+ });
2677
+ const said = Object.entries(out.changes).map(([field, value]) => `${SAYS[field] ?? field} to ${value}`).join(", ");
2678
+ if (!out.applied) return planned(out, "change it", R.wrap(`Would set ${id} ${said}.`));
2679
+ return { data: out, text: R.wrap(`${id} updated: ${said}.`) };
2680
+ }
2681
+
2682
+ async function cmdModuleRename(ctx) {
2683
+ const id = requireWord(ctx.words, 2, "Say which module: superdev module rename <MOD-id> --name <text>.");
2684
+ const { renameModule } = await import("./product/authoring.mjs");
2685
+ const out = await renameModule(ctx.root, id, {
2686
+ name: ctx.flags.name ?? ctx.words[3] ?? null,
2687
+ purpose: ctx.flags.purpose ?? null,
2688
+ actor: ctx.actor, apply: ctx.apply,
2689
+ });
2690
+ const said = Object.entries(out.changes).map(([field, value]) => `${SAYS[field] ?? field} to ${value}`).join(", ");
2691
+ if (!out.applied) return planned(out, "change it", R.wrap(`Would set ${id} ${said}.`));
2692
+ return {
2693
+ data: out,
2694
+ text: R.wrap(out.changes.name
2695
+ ? `${id} is now ${out.changes.name}. Its documentation directory is named from the module, so run superdev docs generate to move it.`
2696
+ : `${id} updated: ${said}.`),
2697
+ };
2698
+ }
2699
+
2700
+ /** Field names as a reader would say them. */
2701
+ const SAYS = { name: "its name", purpose: "what it owns", outcome: "what it delivers", target_date: "its target date" };
2702
+
2703
+ async function cmdFeatureGoal(ctx) {
2704
+ const id = requireWord(ctx.words, 2, "Say which feature: superdev feature goal <FEAT-id> --goal <GOAL-id>.");
2705
+ const goalId = requireFlag(ctx.flags, "goal", "Say which goal it advances: --goal <GOAL-id>.");
2706
+ const { linkFeatureGoal } = await import("./product/authoring.mjs");
2707
+ const remove = Boolean(ctx.flags.remove);
2708
+ const out = await linkFeatureGoal(ctx.root, id, String(goalId), { remove, actor: ctx.actor, apply: ctx.apply });
2709
+ if (out.unchanged) {
2710
+ return { data: out, text: R.wrap(`${id} already serves ${out.goal}.`) };
2711
+ }
2712
+ if (!out.applied) {
2713
+ return planned(out, remove ? "unlink it" : "link it",
2714
+ R.wrap(remove
2715
+ ? `Would stop ${id} ${out.name} counting toward ${out.goal}.`
2716
+ : `Would record that ${id} ${out.name} advances ${out.goal}.`));
2717
+ }
2718
+ return {
2719
+ data: out,
2720
+ text: R.wrap(remove
2721
+ ? `${id} no longer serves ${out.goal}.`
2722
+ : `${id} now serves ${out.goal}. A feature that advances no goal is scope nobody can justify when scope is cut, which is what the alignment report was warning about.`),
2723
+ };
2724
+ }
2725
+
2726
+ async function cmdFeatureMove(ctx) {
2727
+ const id = requireWord(ctx.words, 2, "Say which feature: superdev feature move <FEAT-id> --module <MOD-id>.");
2728
+ const { moveFeature } = await import("./product/authoring.mjs");
2729
+ const out = await moveFeature(ctx.root, id, {
2730
+ moduleId: ctx.flags.module ? String(ctx.flags.module) : null,
2731
+ milestoneId: ctx.flags.milestone ? String(ctx.flags.milestone) : null,
2732
+ actor: ctx.actor, apply: ctx.apply,
2733
+ });
2734
+ if (!out.applied) {
2735
+ return planned(out, "move it", R.stitch([
2736
+ R.wrap(`Would move ${id} ${out.name}.`),
2737
+ out.moduleId ? R.wrap(`Module: ${out.fromModule ?? "none"} to ${out.moduleId}`, R.WIDTH, " ") : null,
2738
+ out.milestoneId ? R.wrap(`Milestone: ${out.fromMilestone ?? "none"} to ${out.milestoneId}`, R.WIDTH, " ") : null,
2739
+ ]));
2740
+ }
2741
+ return {
2742
+ data: out,
2743
+ text: R.wrap(`${id} moved${out.toModule ? ` into module ${out.toModule}` : ""}${out.toMilestone ? ` into ${out.toMilestone}` : ""}. Its contract, tasks and evidence are untouched.`),
2744
+ };
2745
+ }
2746
+
2747
+ async function cmdRetire(ctx) {
2748
+ const id = requireWord(ctx.words, 1, "Say which goal or milestone: superdev retire <id> --reason <why>.");
2749
+ const { retire } = await import("./product/authoring.mjs");
2750
+ const out = await retire(ctx.root, id, {
2751
+ reason: requireFlag(ctx.flags, "reason", "Say why it is being retired, so nobody later has to guess whether it was decided or forgotten."),
2752
+ actor: ctx.actor, apply: ctx.apply,
2753
+ });
2754
+ if (!out.applied) {
2755
+ return planned(out, "retire it", R.wrap(`Would retire ${out.kind} ${id} ${out.name}. Because: ${out.reason}`));
2756
+ }
2757
+ return {
2758
+ data: out,
2759
+ text: R.wrap(`${id} is retired. Nothing is deleted: it keeps its history and stops counting toward progress.`),
2760
+ };
2761
+ }
2762
+
2380
2763
  // ------------------------------------------------------------ feature depth
2381
2764
 
2382
2765
  async function cmdFeatureAccept(ctx) {
@@ -2451,11 +2834,29 @@ async function cmdFeatureSpecify(ctx) {
2451
2834
  return { criterion, verification: verification || null };
2452
2835
  });
2453
2836
 
2837
+ if (ctx.flags.out !== undefined) {
2838
+ throw new UsageError(
2839
+ "--out is the global flag for writing a command's output to a file. What is deliberately out of scope goes in --not, so that the two cannot be confused.",
2840
+ );
2841
+ }
2842
+
2454
2843
  const input = {
2455
2844
  purpose: ctx.flags.purpose ? String(ctx.flags.purpose) : null,
2456
2845
  userStatement: ctx.flags.user ? String(ctx.flags.user) : null,
2457
2846
  scopeIn: asList(ctx.flags.in).map(String),
2458
- scopeOut: asList(ctx.flags.out).map(String),
2847
+ // --not, never --out.
2848
+ //
2849
+ // This flag was --out, which is also the global "write this command's output
2850
+ // to this path" flag. The database write succeeded, then the joined scope-out
2851
+ // sentences were treated as a filename: three files named after their own
2852
+ // contents appeared in a real user's repository root, and the fourth call
2853
+ // died with ENAMETOOLONG. Both outcomes were silent, because the output that
2854
+ // would have said what happened went into the junk file.
2855
+ //
2856
+ // Renaming it is the fix rather than marking the flag consumed, because a
2857
+ // command where --out means something different from every other command is
2858
+ // a trap even when it works.
2859
+ scopeOut: asList(ctx.flags.not).map(String),
2459
2860
  flow: asList(ctx.flags.flow).map(String),
2460
2861
  criteria,
2461
2862
  edgeCases,
@@ -2619,6 +3020,20 @@ const COMMANDS = {
2619
3020
  "decision supersede": cmdDecisionSupersede,
2620
3021
  "decision list": cmdDecisionList,
2621
3022
  "feature accept": cmdFeatureAccept,
3023
+ "goal record": cmdGoalRecord,
3024
+ "goal criterion": cmdGoalCriterion,
3025
+ "milestone record": cmdMilestoneRecord,
3026
+ "milestone condition": cmdMilestoneCondition,
3027
+ "module record": cmdModuleRecord,
3028
+ "feature create": cmdFeatureCreate,
3029
+ "feature move": cmdFeatureMove,
3030
+ "feature goal": cmdFeatureGoal,
3031
+ "milestone update": cmdMilestoneUpdate,
3032
+ "module rename": cmdModuleRename,
3033
+ "scope record": cmdScopeRecord,
3034
+ "scope remove": cmdScopeRemove,
3035
+ "scope list": cmdScopeList,
3036
+ retire: cmdRetire,
2622
3037
  "feature depth": cmdFeatureDepth,
2623
3038
  "feature specify": cmdFeatureSpecify,
2624
3039
  "feature waive": cmdFeatureWaive,
@@ -2767,8 +3182,24 @@ export async function run(argv = process.argv.slice(2)) {
2767
3182
  }
2768
3183
  }
2769
3184
 
2770
- // Only dispatch when this file is the program. Imported (by a hook, or by a
2771
- // test of the parser) it stays inert.
2772
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
3185
+ /**
3186
+ * Whether this file is the program, rather than something that imported it.
3187
+ *
3188
+ * Compared through realpath on both sides, because npm installs a `bin` as a
3189
+ * symlink. Run as `superdev`, argv[1] is the link in the bin directory while
3190
+ * import.meta.url is the real file inside the package, so comparing them
3191
+ * directly is always false: every command exited 0 having printed nothing.
3192
+ *
3193
+ * That was invisible until the package had a binary at all. `node src/cli.mjs`
3194
+ * matches on the nose and had been the only way it was ever run.
3195
+ */
3196
+ function isProgram() {
3197
+ const invoked = process.argv[1];
3198
+ if (!invoked) return false;
3199
+ return realpathOf(invoked) === realpathOf(fileURLToPath(import.meta.url));
3200
+ }
3201
+
3202
+ // Imported, by a hook or by a test of the parser, it stays inert.
3203
+ if (isProgram()) {
2773
3204
  process.exitCode = await run();
2774
3205
  }
@@ -486,6 +486,19 @@ function valueFor(column, lines) {
486
486
  return lines.join("\n").trim();
487
487
  }
488
488
 
489
+ /**
490
+ * Sections that are lists of records rather than one field, and the command that
491
+ * writes each. These cannot be read back out of Markdown, because a bullet list
492
+ * cannot say which record each line is, but every one of them can be written.
493
+ */
494
+ const WRITTEN_BY = {
495
+ non_goals: 'Non-goals are records rather than one field. Add one with superdev scope record --not "<what this will not do>" --why "<reason>" --apply.',
496
+ out_of_scope: 'This is a list of records. Add one with superdev scope record --out "<what is out>" --apply.',
497
+ success_criteria: 'Success criteria belong to a goal. Add one with superdev goal criterion <GOAL-id> --criterion "<what must be true>" --measurement "<how it is read>" --apply.',
498
+ goals: 'A goal is a record. Add one with superdev goal record --name "<the outcome>" --apply.',
499
+ milestones: 'A milestone is a record. Add one with superdev milestone record --name "<the stage>" --apply.',
500
+ };
501
+
489
502
  function columnFor(name, columns, constrained) {
490
503
  const key = normalizeHeading(name);
491
504
  if (!key) return null;
@@ -636,7 +649,12 @@ async function planAcceptance(db, doc, state) {
636
649
  }
637
650
  const column = item.heading ? columnFor(item.heading, columns, constrained) : null;
638
651
  if (!column) {
639
- reject(where, `No ${kind} field is projected from this section, so its edit cannot be stored.`);
652
+ // A section backed by child records has a command, and naming it is the
653
+ // difference between a refusal and a dead end. Non-goals invited an edit
654
+ // in its own generated prose and then refused it with nowhere to go, so a
655
+ // reader who did exactly what the document asked had no next step.
656
+ reject(where, WRITTEN_BY[normalizeHeading(item.heading ?? "")]
657
+ ?? `No ${kind} field is projected from this section, so its edit cannot be stored.`);
640
658
  continue;
641
659
  }
642
660
  if (item.section.lines.some((line) => TABLE_ROW.test(line) || LABEL_LINE.test(line))) {