superdev-cli 0.1.1 → 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.
package/src/cli.mjs CHANGED
@@ -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", "open"]);
43
43
 
44
44
  const camel = (name) => name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
45
45
 
@@ -82,6 +82,19 @@ function parseArgs(argv) {
82
82
 
83
83
  const asList = (value) => (value === undefined ? [] : [].concat(value).map(String));
84
84
 
85
+ /** A JSON column's value, or the fallback when it is absent or malformed. */
86
+ const json = (value, fallback) => {
87
+ try {
88
+ const parsed = JSON.parse(value ?? "null");
89
+ return parsed ?? fallback;
90
+ } catch { return fallback; }
91
+ };
92
+
93
+ /** A path with its symlinks resolved, or the path itself when it cannot be. */
94
+ const realpathOf = (path) => {
95
+ try { return realpathSync(path); } catch { return path; }
96
+ };
97
+
85
98
  function requireFlag(flags, name, what) {
86
99
  const value = flags[camel(name)];
87
100
  if (value === undefined || value === true) throw new UsageError(`${what} Pass --${name} <value>.`);
@@ -172,7 +185,24 @@ Knowledge
172
185
  decision record Record a decision, with what it governs
173
186
  decision supersede Replace a decision that no longer holds
174
187
  decision list Decisions and what they still govern
188
+ feature create Add a feature to a module
189
+ feature move <id> Reassign a feature's module or milestone
190
+ feature goal <id> Say which goal a feature advances
175
191
  feature specify <id> Write the specification its depth requires
192
+ goal record Record a lasting outcome
193
+ goal criterion <id> Add a success criterion, with how it is measured
194
+ milestone record Record a delivery stage
195
+ milestone condition <id> Add an exit condition, or --entry for an entry one
196
+ milestone update <id> Rename it, restate it, or move its target date
197
+ module record Record a slice of the product
198
+ module rename <id> Rename a module, or restate what it owns
199
+ capability list Readiness areas and stack slots, and what settled each
200
+ capability specify <id> Record what was chosen for an area
201
+ capability not-applicable <id> Record that an area does not apply, with why
202
+ scope record Record what the product will not do, and why
203
+ scope list What is in scope, out of scope, and a non-goal
204
+ scope remove <id> Take a scope line back out
205
+ retire <id> Take a goal or milestone out of scope, with the reason
176
206
  feature waive <id> Set an acceptance criterion aside, with the reason
177
207
  feature depth Read what a depth requires, or set one
178
208
  feature accept Accept a feature, refused while its depth is unmet
@@ -203,6 +233,16 @@ Options
203
233
  --actor <name> Who to record as responsible. Defaults to superdev
204
234
  --help This text
205
235
 
236
+ Flags worth knowing
237
+ init --brief <file> Read the project from a document. See requirement.md in
238
+ the Superdev repository for the format it reads best
239
+ init --adopt The documents already here are input to this project, not
240
+ a record of it. Say this when init routes to adopt but the
241
+ files it found are a brief rather than a projection
242
+ feature specify --not <text>
243
+ What the feature deliberately does not do. Repeatable.
244
+ Its counterpart is --in, and --out stays global
245
+
206
246
  Exit codes
207
247
  0 it worked, 1 something was found or refused, 2 the command was misused`;
208
248
 
@@ -554,6 +594,23 @@ async function cmdDoctor(ctx) {
554
594
  : "Nothing has been generated yet",
555
595
  });
556
596
 
597
+ // Markdown in the docs root that no record claims.
598
+ //
599
+ // Two Superdev architectures existed under the same version number, and both
600
+ // wrote to talks/. A copy of the older one, driving its own engine, would fill
601
+ // that directory with files this database has never heard of, and every check
602
+ // here would stay green because each one asks about records rather than about
603
+ // the directory. An unclaimed file is also what a hand-created document looks
604
+ // like, which is worth naming for its own sake.
605
+ const foreign = await unclaimedDocuments(ctx.root);
606
+ if (foreign.length) {
607
+ checks.push({
608
+ name: "Docs directory",
609
+ ok: false,
610
+ 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.`,
611
+ });
612
+ }
613
+
557
614
  const view = await withProject(ctx.root, async (db, project) => ({
558
615
  warnings: await alignmentWarnings(db, project.id),
559
616
  freshness: await freshness(db, project.id),
@@ -595,13 +652,13 @@ async function cmdDoctor(ctx) {
595
652
  FROM verification_evidence WHERE status = 'current'`));
596
653
  checks.push({
597
654
  name: "Evidence",
598
- ok: (evidence?.failing ?? 0) === 0,
655
+ ok: evidence?.total ? (evidence.failing ?? 0) === 0 : null,
599
656
  detail: evidence?.total
600
657
  ? `${evidence.total - evidence.manual} of ${evidence.total} can be re-run${evidence.failing ? `, ${evidence.failing} last failed` : ""}. Run superdev verify.`
601
658
  : "No evidence recorded yet",
602
659
  });
603
660
 
604
- const ok = checks.every((c) => c.ok);
661
+ const ok = checks.every((c) => c.ok !== false);
605
662
  return {
606
663
  data: { ok, checks, migrations, integrity: { ok: integrity.ok, counts: integrity.counts }, findings: view.warnings },
607
664
  text: R.renderDoctor({ checks, findings: view.warnings }),
@@ -609,6 +666,45 @@ async function cmdDoctor(ctx) {
609
666
  };
610
667
  }
611
668
 
669
+ /**
670
+ * Markdown under the project's docs root that no document record claims.
671
+ *
672
+ * Read only, and bounded: this is a health check, not a crawl. The docs root is
673
+ * read from the project rather than assumed, because adoption points it at
674
+ * whatever the repository already used.
675
+ */
676
+ async function unclaimedDocuments(root) {
677
+ const found = [];
678
+ try {
679
+ const { readdirSync } = await import("node:fs");
680
+ const project = await withProject(root, async (db) => ({
681
+ docsRoot: (await db.get("SELECT docs_profile FROM projects LIMIT 1"))?.docs_profile === "talks-v1" ? "talks" : null,
682
+ known: new Set((await db.all("SELECT path FROM documents")).map((d) => d.path)),
683
+ }));
684
+ if (!project.docsRoot) return [];
685
+ const base = resolve(root, project.docsRoot);
686
+ const walk = (dir, depth) => {
687
+ if (depth > 4 || found.length > 50) return;
688
+ let entries;
689
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
690
+ for (const entry of entries) {
691
+ if (entry.name.startsWith(".")) continue;
692
+ const full = join(dir, entry.name);
693
+ if (entry.isDirectory()) walk(full, depth + 1);
694
+ else if (entry.name.endsWith(".md")) {
695
+ const rel = relative(resolve(root), full).split("\\").join("/");
696
+ if (!project.known.has(rel)) found.push(rel);
697
+ }
698
+ }
699
+ };
700
+ walk(base, 0);
701
+ } catch {
702
+ // A health check that throws is worse than one that says nothing here.
703
+ return [];
704
+ }
705
+ return found;
706
+ }
707
+
612
708
  /**
613
709
  * Detect the installed providers and write the report the session hook reads.
614
710
  *
@@ -640,7 +736,16 @@ async function recordProviderReadiness(root, paths) {
640
736
  : "No provider was detected",
641
737
  };
642
738
  } catch (error) {
643
- return { name: "Providers", ok: true, detail: `Readiness could not be determined: ${String(error.message).slice(0, 80)}` };
739
+ // ok: null, not ok: true. This returned Pass beside the words "could not be
740
+ // determined", which is a contradiction printed in green, and the detail
741
+ // column truncates at 80 characters so on a narrow terminal only the green
742
+ // was visible. Doctor exists so nothing is silently skipped; its own checks
743
+ // are held to that.
744
+ return {
745
+ name: "Providers",
746
+ ok: null,
747
+ detail: `Readiness could not be determined: ${String(error.message).slice(0, 120)}`,
748
+ };
644
749
  }
645
750
  }
646
751
 
@@ -1571,12 +1676,24 @@ async function cmdSettings(ctx) {
1571
1676
  }
1572
1677
 
1573
1678
  const on = checkingEnabled(ctx.root);
1679
+ // Which copy is answering, not just which version.
1680
+ //
1681
+ // Superdev ships as an npm package and as a plugin, and both carry the same
1682
+ // version number. A stale plugin copy of an older architecture sat in a cache
1683
+ // beside the current one under the same number, and no command could say which
1684
+ // of them a session was actually using. The running file settles it, so
1685
+ // "superdev 0.1.1" stops naming two different things.
1686
+ const running = realpathOf(fileURLToPath(import.meta.url));
1687
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ? realpathOf(process.env.CLAUDE_PLUGIN_ROOT) : null;
1688
+ const throughPlugin = Boolean(pluginRoot && running.startsWith(pluginRoot));
1574
1689
  return {
1575
- data: { version: me.version, package: me.name, updateCheck: on },
1690
+ data: { version: me.version, package: me.name, updateCheck: on, running, pluginRoot, throughPlugin },
1576
1691
  text: R.stitch([
1577
1692
  R.heading("Settings"),
1578
1693
  R.pairs([
1579
1694
  ["Version", `${me.name} ${me.version}`],
1695
+ ["Running from", running],
1696
+ ["Reached as", throughPlugin ? "the plugin's own copy" : "an installed superdev-cli"],
1580
1697
  ["Update check", on ? "On" : "Off"],
1581
1698
  ]),
1582
1699
  R.wrap(on
@@ -2219,41 +2336,63 @@ async function cmdMemorySearch(ctx) {
2219
2336
 
2220
2337
  // -------------------------------------------------------- questions, decisions
2221
2338
 
2339
+ /**
2340
+ * Answer a question, from its options or in your own words.
2341
+ *
2342
+ * This wrote the answer and set the status itself, which is a third
2343
+ * implementation of answering alongside the engine's and the control centre's.
2344
+ * The engine's is the complete one: it settles the capability area the question
2345
+ * belongs to, turns "I do not know" into a reversible assumption with a revisit
2346
+ * trigger rather than a decision, and writes a project-level answer through to the
2347
+ * field it exists to fill. This one did none of that, so the same question
2348
+ * answered here and answered there left the project in different states. Both
2349
+ * surfaces now call the engine.
2350
+ */
2222
2351
  async function cmdQuestionAnswer(ctx) {
2223
- const id = requireWord(ctx.words, 2, "Say which question to answer: superdev question answer <id> --answer <text>.");
2224
- const answer = ctx.flags.answer !== undefined
2225
- ? String(ctx.flags.answer)
2226
- : ctx.words.slice(3).join(" ");
2227
- if (!answer) throw new UsageError("An answer needs text. Pass --answer <text>.");
2352
+ const id = requireWord(ctx.words, 2, "Say which question to answer: superdev question answer <id> --answer <text> or --option <one of its options>.");
2353
+ const chosen = asList(ctx.flags.option);
2354
+ const typed = ctx.flags.answer !== undefined ? String(ctx.flags.answer) : ctx.words.slice(3).join(" ");
2355
+ if (!chosen.length && !typed) {
2356
+ throw new UsageError("An answer needs an option or some text. Pass --option <one of its options>, or --answer <text>. Run superdev question list to read the options.");
2357
+ }
2228
2358
 
2229
- const { query, mutate, patch, setStatus } = await store();
2359
+ const { query } = await store();
2230
2360
  const question = await query(ctx.root, (db) => db.get("SELECT * FROM questions WHERE id = ?", id));
2231
2361
  if (!question) throw new Refusal(`There is no question ${id}.`, "E_NOT_FOUND");
2232
2362
  if (question.status === "answered") {
2233
2363
  throw new Refusal(`${id} was already answered: ${question.answer}`, "E_ALREADY_ANSWERED");
2234
2364
  }
2235
2365
 
2366
+ const offered = json(question.alternatives_json, []);
2367
+ const unknown = chosen.filter((choice) => !offered.includes(choice));
2368
+ if (unknown.length) {
2369
+ throw new UsageError(`${id} does not offer ${unknown.map((u) => JSON.stringify(u)).join(", ")}. Its options are: ${offered.length ? offered.map((o) => JSON.stringify(o)).join(", ") : "none, so answer it in your own words with --answer"}.`);
2370
+ }
2371
+ if (question.select_mode !== "many" && chosen.length > 1) {
2372
+ throw new UsageError(`${id} takes one answer, and ${chosen.length} options were given. Pick one, or write what you mean with --answer.`);
2373
+ }
2374
+
2375
+ const answer = [chosen.join("; "), typed].filter(Boolean).join(". ");
2376
+
2236
2377
  if (!ctx.apply) {
2237
- return planned({ id, answer }, "record it", R.stitch([
2378
+ return planned({ id, answer, selected: chosen }, "record it", R.stitch([
2238
2379
  `Would answer ${id}: ${question.question}`,
2239
2380
  R.wrap(`Answer: ${answer}`, R.WIDTH, " "),
2240
2381
  question.recommendation ? R.wrap(`The recommendation on file was: ${question.recommendation}`, R.WIDTH, " ") : null,
2241
2382
  ]));
2242
2383
  }
2243
2384
 
2244
- const row = await mutate(ctx.root, async (db) => {
2245
- const current = await db.get("SELECT * FROM questions WHERE id = ?", id);
2246
- await patch(db, "question", id, current.version, {
2247
- answer,
2248
- answered_by: ctx.actor,
2249
- answered_at: nowIso(),
2250
- }, { projectId: current.project_id, actor: ctx.actor, activitySummary: `Answered ${id}: ${question.question}` });
2251
- return setStatus(db, "question", id, "answered", {
2252
- projectId: current.project_id, actor: ctx.actor, note: answer,
2253
- });
2385
+ const { answerQuestion } = await import("./init/questions.mjs");
2386
+ const out = await answerQuestion(ctx.root, id, {
2387
+ answer, inOwnWords: typed || null, actor: ctx.actor, answeredBy: ctx.actor,
2254
2388
  });
2255
-
2256
- return { data: { applied: true, question: row }, text: `${id} is answered.` };
2389
+ if (out.changed === false) throw new Refusal(out.reason, "E_ALREADY_ANSWERED");
2390
+ return {
2391
+ data: { applied: true, question: out.question ?? { id }, selected: chosen },
2392
+ text: R.wrap(out.area
2393
+ ? `${id} is answered, and ${out.area.id} ${out.area.area} is settled by it, which is what readiness counts.`
2394
+ : `${id} is answered.`),
2395
+ };
2257
2396
  }
2258
2397
 
2259
2398
  async function cmdDecisionList(ctx) {
@@ -2377,6 +2516,346 @@ async function cmdCategoryRestore(ctx) {
2377
2516
  }
2378
2517
 
2379
2518
 
2519
+ // ------------------------------------------------------- authoring the map
2520
+ //
2521
+ // Every product record could only be created by `init`, so a project was frozen
2522
+ // the moment it was initialized. The read side was complete, which is why the
2523
+ // gap was invisible: goal list and goal show both worked while nothing could
2524
+ // write a goal. These are the writes.
2525
+
2526
+ async function cmdGoalRecord(ctx) {
2527
+ const { recordGoal } = await import("./product/authoring.mjs");
2528
+ const out = await recordGoal(ctx.root, {
2529
+ name: requireFlag(ctx.flags, "name", "A goal needs a name that states the outcome, not the work."),
2530
+ why: ctx.flags.why ?? null,
2531
+ description: ctx.flags.description ?? null,
2532
+ actor: ctx.actor, apply: ctx.apply,
2533
+ });
2534
+ if (!out.applied) {
2535
+ return planned(out, "record it", R.wrap(`Would record the goal "${out.name}".`));
2536
+ }
2537
+ return {
2538
+ data: out,
2539
+ text: R.stitch([
2540
+ R.wrap(`${out.goal.id} recorded: ${out.goal.name}`),
2541
+ 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.`),
2542
+ ]),
2543
+ };
2544
+ }
2545
+
2546
+ async function cmdGoalCriterion(ctx) {
2547
+ const { count } = await import("./model/vocabulary.mjs");
2548
+ const id = requireWord(ctx.words, 2, "Say which goal: superdev goal criterion <GOAL-id>.");
2549
+ const { addGoalCriterion } = await import("./product/authoring.mjs");
2550
+ const out = await addGoalCriterion(ctx.root, id, {
2551
+ criterion: requireFlag(ctx.flags, "criterion", "Say what has to be true. A criterion nobody can check is not one."),
2552
+ measurement: ctx.flags.measurement ?? null,
2553
+ target: ctx.flags.target ?? null,
2554
+ actor: ctx.actor, apply: ctx.apply,
2555
+ });
2556
+ if (!out.applied) {
2557
+ return planned(out, "record it", R.wrap(`Would add to ${id}: ${out.criterion}`));
2558
+ }
2559
+ return {
2560
+ data: out,
2561
+ text: R.stitch([
2562
+ R.wrap(`${out.criterion.id} recorded against ${out.goal.name}: ${out.criterion.criterion}`),
2563
+ R.wrap(`${out.goal.id} now carries ${count(out.total, "success criterion", "success criteria")}. It is met when every one of them is.`),
2564
+ ]),
2565
+ };
2566
+ }
2567
+
2568
+ async function cmdMilestoneRecord(ctx) {
2569
+ const { recordMilestone } = await import("./product/authoring.mjs");
2570
+ const out = await recordMilestone(ctx.root, {
2571
+ name: requireFlag(ctx.flags, "name", "A milestone needs a name that says what is delivered by it."),
2572
+ outcome: ctx.flags.outcome ?? null,
2573
+ targetDate: ctx.flags.date ?? null,
2574
+ actor: ctx.actor, apply: ctx.apply,
2575
+ });
2576
+ if (!out.applied) {
2577
+ return planned(out, "record it", R.wrap(`Would record the milestone "${out.name}".`));
2578
+ }
2579
+ return {
2580
+ data: out,
2581
+ text: R.stitch([
2582
+ R.wrap(`${out.milestone.id} recorded: ${out.milestone.name}`),
2583
+ 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.`),
2584
+ ]),
2585
+ };
2586
+ }
2587
+
2588
+ async function cmdMilestoneCondition(ctx) {
2589
+ const { count } = await import("./model/vocabulary.mjs");
2590
+ const id = requireWord(ctx.words, 2, "Say which milestone: superdev milestone condition <MS-id>.");
2591
+ const { addMilestoneCondition } = await import("./product/authoring.mjs");
2592
+ const out = await addMilestoneCondition(ctx.root, id, {
2593
+ condition: requireFlag(ctx.flags, "condition", "Say what has to hold before this milestone is reached."),
2594
+ check: ctx.flags.check ?? null,
2595
+ entry: Boolean(ctx.flags.entry),
2596
+ actor: ctx.actor, apply: ctx.apply,
2597
+ });
2598
+ if (!out.applied) {
2599
+ return planned(out, "record it", R.wrap(`Would add to ${id} as ${out.entry ? "an entry" : "an exit"} condition: ${out.condition}`));
2600
+ }
2601
+ return {
2602
+ data: out,
2603
+ text: R.wrap(out.entry
2604
+ ? `Recorded against ${id}. It now carries ${count(out.total, "entry condition")}, and cannot be started until every one is met.`
2605
+ : `Recorded against ${id}. It now carries ${count(out.total, "exit condition")}, and is reached when every one is met.`),
2606
+ };
2607
+ }
2608
+
2609
+ async function cmdModuleRecord(ctx) {
2610
+ const { recordModule } = await import("./product/authoring.mjs");
2611
+ const out = await recordModule(ctx.root, {
2612
+ name: requireFlag(ctx.flags, "name", "A module needs a name."),
2613
+ purpose: ctx.flags.purpose ?? null,
2614
+ actor: ctx.actor, apply: ctx.apply,
2615
+ });
2616
+ if (!out.applied) {
2617
+ return planned(out, "record it", R.wrap(`Would record the module "${out.name}".`));
2618
+ }
2619
+ return {
2620
+ data: out,
2621
+ text: R.wrap(`${out.module.id} recorded: ${out.module.name}. Put a feature in it with superdev feature create --module ${out.module.id}.`),
2622
+ };
2623
+ }
2624
+
2625
+ async function cmdFeatureCreate(ctx) {
2626
+ const { createFeature } = await import("./product/authoring.mjs");
2627
+ const out = await createFeature(ctx.root, {
2628
+ moduleId: ctx.flags.module ? String(ctx.flags.module) : null,
2629
+ milestoneId: ctx.flags.milestone ? String(ctx.flags.milestone) : null,
2630
+ name: requireFlag(ctx.flags, "name", "A feature needs a name that states what somebody can do."),
2631
+ purpose: ctx.flags.purpose ?? null,
2632
+ actor: ctx.actor, apply: ctx.apply,
2633
+ });
2634
+ if (!out.applied) {
2635
+ return planned(out, "create it", R.wrap(`Would draft "${out.name}" in module ${out.moduleId}.`));
2636
+ }
2637
+ return {
2638
+ data: out,
2639
+ text: R.stitch([
2640
+ R.wrap(`${out.feature.id} drafted in ${out.module.name}: ${out.feature.name}`),
2641
+ 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.`),
2642
+ ]),
2643
+ };
2644
+ }
2645
+
2646
+ async function cmdCapabilitySpecify(ctx) {
2647
+ const id = requireWord(ctx.words, 2, "Say which area: superdev capability specify <CAP-id> --choice \"<what was chosen>\".");
2648
+ const { settleCapabilityArea } = await import("./product/authoring.mjs");
2649
+ const out = await settleCapabilityArea(ctx.root, id, {
2650
+ choice: requireFlag(ctx.flags, "choice", "Say what was chosen for this area."),
2651
+ evidence: ctx.flags.evidence ?? null,
2652
+ actor: ctx.actor, apply: ctx.apply,
2653
+ });
2654
+ if (!out.applied) {
2655
+ return planned(out, "record it", R.wrap(`Would specify ${out.area}: ${out.choice}`));
2656
+ }
2657
+ return {
2658
+ data: out,
2659
+ text: R.wrap(`${id} ${out.area} is specified: "${out.choice}". It was ${R.status(out.was)}, and readiness now counts it as answered.`),
2660
+ };
2661
+ }
2662
+
2663
+ async function cmdCapabilityNotApplicable(ctx) {
2664
+ const id = requireWord(ctx.words, 2, "Say which area: superdev capability not-applicable <CAP-id> --reason \"<why>\".");
2665
+ const { settleCapabilityArea } = await import("./product/authoring.mjs");
2666
+ const out = await settleCapabilityArea(ctx.root, id, {
2667
+ notApplicable: true,
2668
+ reason: requireFlag(ctx.flags, "reason", "Say why this area does not apply to this product."),
2669
+ evidence: ctx.flags.evidence ?? null,
2670
+ actor: ctx.actor, apply: ctx.apply,
2671
+ });
2672
+ if (!out.applied) {
2673
+ return planned(out, "record it", R.wrap(`Would record ${out.area} as not applicable: ${out.reason}`));
2674
+ }
2675
+ return {
2676
+ data: out,
2677
+ // The reason is somebody's sentence and may not end in a full stop, so it is
2678
+ // quoted rather than run into the next sentence.
2679
+ text: R.wrap(`${id} ${out.area} is recorded as not applicable: "${out.reason}". A reason is required precisely so this stays different from an area nobody looked at.`),
2680
+ };
2681
+ }
2682
+
2683
+ async function cmdCapabilityList(ctx) {
2684
+ const { capabilityList } = await import("./product/authoring.mjs");
2685
+ const rows = await capabilityList(ctx.root, {
2686
+ catalog: ctx.flags.catalog ? String(ctx.flags.catalog) : null,
2687
+ unsettled: Boolean(ctx.flags.open),
2688
+ });
2689
+ if (!rows.length) {
2690
+ return { data: { areas: [] }, text: R.wrap("No capability area matches. Superdev seeds them during init.") };
2691
+ }
2692
+ return {
2693
+ data: { areas: rows },
2694
+ text: R.stitch([
2695
+ R.heading(`Capability areas (${rows.length})`),
2696
+ R.table(["Id", "Area", "State", "What settled it"],
2697
+ rows.map((a) => [
2698
+ a.id,
2699
+ a.area,
2700
+ R.status(a.state),
2701
+ a.choice ?? a.reason ?? "Nothing yet",
2702
+ ])),
2703
+ "",
2704
+ "Settle one with superdev capability specify <id> --choice, or capability not-applicable <id> --reason.",
2705
+ ]),
2706
+ };
2707
+ }
2708
+
2709
+ async function cmdScopeRecord(ctx) {
2710
+ const { recordScopeItem } = await import("./product/authoring.mjs");
2711
+ // --not is the same word here as in feature specify, and means the same thing.
2712
+ const direction = ctx.flags.in !== undefined ? "in" : ctx.flags.out !== undefined ? "out" : "non_goal";
2713
+ const statement = ctx.flags.in ?? ctx.flags.out ?? ctx.flags.not ?? ctx.words[2];
2714
+ if (statement === undefined) {
2715
+ throw new UsageError('Say what is decided: superdev scope record --not "<what this will not do>" [--why "<reason>"].');
2716
+ }
2717
+ const out = await recordScopeItem(ctx.root, {
2718
+ statement: String(statement),
2719
+ direction,
2720
+ why: ctx.flags.why ?? null,
2721
+ horizon: ctx.flags.horizon ?? null,
2722
+ actor: ctx.actor, apply: ctx.apply,
2723
+ });
2724
+ if (!out.applied) {
2725
+ return planned(out, "record it", R.wrap(`Would record as ${SAID_AS[out.direction]}: ${out.statement}`));
2726
+ }
2727
+ return {
2728
+ data: out,
2729
+ 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.`),
2730
+ };
2731
+ }
2732
+
2733
+ async function cmdScopeRemove(ctx) {
2734
+ const id = requireWord(ctx.words, 2, "Say which scope item: superdev scope remove <SCOPE-id>.");
2735
+ const { removeScopeItem } = await import("./product/authoring.mjs");
2736
+ const out = await removeScopeItem(ctx.root, id, { actor: ctx.actor, apply: ctx.apply });
2737
+ if (!out.applied) {
2738
+ return planned(out, "remove it", R.wrap(`Would stop recording as ${out.said}: ${out.statement}`));
2739
+ }
2740
+ return { data: out, text: R.wrap(`${id} is no longer recorded as ${out.said}.`) };
2741
+ }
2742
+
2743
+ async function cmdScopeList(ctx) {
2744
+ const { scopeList } = await import("./product/authoring.mjs");
2745
+ const rows = await scopeList(ctx.root);
2746
+ if (!rows.length) {
2747
+ return {
2748
+ data: { scope: [] },
2749
+ 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>".'),
2750
+ };
2751
+ }
2752
+ return {
2753
+ data: { scope: rows },
2754
+ text: R.stitch([
2755
+ R.heading(`Scope (${rows.length})`),
2756
+ R.table(["Id", "Direction", "Statement", "Why"],
2757
+ rows.map((r) => [r.id, SAID_AS[r.direction] ?? r.direction, r.statement, r.why ?? "Not recorded"])),
2758
+ ]),
2759
+ };
2760
+ }
2761
+
2762
+ const SAID_AS = { in: "in scope", out: "out of scope", non_goal: "a non-goal" };
2763
+
2764
+ async function cmdMilestoneUpdate(ctx) {
2765
+ const id = requireWord(ctx.words, 2, "Say which milestone: superdev milestone update <MS-id> [--name <text>] [--outcome <text>] [--target <date>].");
2766
+ const { updateMilestone } = await import("./product/authoring.mjs");
2767
+ const out = await updateMilestone(ctx.root, id, {
2768
+ name: ctx.flags.name ?? null,
2769
+ outcome: ctx.flags.outcome ?? null,
2770
+ targetDate: ctx.flags.target ?? null,
2771
+ actor: ctx.actor, apply: ctx.apply,
2772
+ });
2773
+ const said = Object.entries(out.changes).map(([field, value]) => `${SAYS[field] ?? field} to ${value}`).join(", ");
2774
+ if (!out.applied) return planned(out, "change it", R.wrap(`Would set ${id} ${said}.`));
2775
+ return { data: out, text: R.wrap(`${id} updated: ${said}.`) };
2776
+ }
2777
+
2778
+ async function cmdModuleRename(ctx) {
2779
+ const id = requireWord(ctx.words, 2, "Say which module: superdev module rename <MOD-id> --name <text>.");
2780
+ const { renameModule } = await import("./product/authoring.mjs");
2781
+ const out = await renameModule(ctx.root, id, {
2782
+ name: ctx.flags.name ?? ctx.words[3] ?? null,
2783
+ purpose: ctx.flags.purpose ?? null,
2784
+ actor: ctx.actor, apply: ctx.apply,
2785
+ });
2786
+ const said = Object.entries(out.changes).map(([field, value]) => `${SAYS[field] ?? field} to ${value}`).join(", ");
2787
+ if (!out.applied) return planned(out, "change it", R.wrap(`Would set ${id} ${said}.`));
2788
+ return {
2789
+ data: out,
2790
+ text: R.wrap(out.changes.name
2791
+ ? `${id} is now ${out.changes.name}. Its documentation directory is named from the module, so run superdev docs generate to move it.`
2792
+ : `${id} updated: ${said}.`),
2793
+ };
2794
+ }
2795
+
2796
+ /** Field names as a reader would say them. */
2797
+ const SAYS = { name: "its name", purpose: "what it owns", outcome: "what it delivers", target_date: "its target date" };
2798
+
2799
+ async function cmdFeatureGoal(ctx) {
2800
+ const id = requireWord(ctx.words, 2, "Say which feature: superdev feature goal <FEAT-id> --goal <GOAL-id>.");
2801
+ const goalId = requireFlag(ctx.flags, "goal", "Say which goal it advances: --goal <GOAL-id>.");
2802
+ const { linkFeatureGoal } = await import("./product/authoring.mjs");
2803
+ const remove = Boolean(ctx.flags.remove);
2804
+ const out = await linkFeatureGoal(ctx.root, id, String(goalId), { remove, actor: ctx.actor, apply: ctx.apply });
2805
+ if (out.unchanged) {
2806
+ return { data: out, text: R.wrap(`${id} already serves ${out.goal}.`) };
2807
+ }
2808
+ if (!out.applied) {
2809
+ return planned(out, remove ? "unlink it" : "link it",
2810
+ R.wrap(remove
2811
+ ? `Would stop ${id} ${out.name} counting toward ${out.goal}.`
2812
+ : `Would record that ${id} ${out.name} advances ${out.goal}.`));
2813
+ }
2814
+ return {
2815
+ data: out,
2816
+ text: R.wrap(remove
2817
+ ? `${id} no longer serves ${out.goal}.`
2818
+ : `${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.`),
2819
+ };
2820
+ }
2821
+
2822
+ async function cmdFeatureMove(ctx) {
2823
+ const id = requireWord(ctx.words, 2, "Say which feature: superdev feature move <FEAT-id> --module <MOD-id>.");
2824
+ const { moveFeature } = await import("./product/authoring.mjs");
2825
+ const out = await moveFeature(ctx.root, id, {
2826
+ moduleId: ctx.flags.module ? String(ctx.flags.module) : null,
2827
+ milestoneId: ctx.flags.milestone ? String(ctx.flags.milestone) : null,
2828
+ actor: ctx.actor, apply: ctx.apply,
2829
+ });
2830
+ if (!out.applied) {
2831
+ return planned(out, "move it", R.stitch([
2832
+ R.wrap(`Would move ${id} ${out.name}.`),
2833
+ out.moduleId ? R.wrap(`Module: ${out.fromModule ?? "none"} to ${out.moduleId}`, R.WIDTH, " ") : null,
2834
+ out.milestoneId ? R.wrap(`Milestone: ${out.fromMilestone ?? "none"} to ${out.milestoneId}`, R.WIDTH, " ") : null,
2835
+ ]));
2836
+ }
2837
+ return {
2838
+ data: out,
2839
+ text: R.wrap(`${id} moved${out.toModule ? ` into module ${out.toModule}` : ""}${out.toMilestone ? ` into ${out.toMilestone}` : ""}. Its contract, tasks and evidence are untouched.`),
2840
+ };
2841
+ }
2842
+
2843
+ async function cmdRetire(ctx) {
2844
+ const id = requireWord(ctx.words, 1, "Say which goal or milestone: superdev retire <id> --reason <why>.");
2845
+ const { retire } = await import("./product/authoring.mjs");
2846
+ const out = await retire(ctx.root, id, {
2847
+ reason: requireFlag(ctx.flags, "reason", "Say why it is being retired, so nobody later has to guess whether it was decided or forgotten."),
2848
+ actor: ctx.actor, apply: ctx.apply,
2849
+ });
2850
+ if (!out.applied) {
2851
+ return planned(out, "retire it", R.wrap(`Would retire ${out.kind} ${id} ${out.name}. Because: ${out.reason}`));
2852
+ }
2853
+ return {
2854
+ data: out,
2855
+ text: R.wrap(`${id} is retired. Nothing is deleted: it keeps its history and stops counting toward progress.`),
2856
+ };
2857
+ }
2858
+
2380
2859
  // ------------------------------------------------------------ feature depth
2381
2860
 
2382
2861
  async function cmdFeatureAccept(ctx) {
@@ -2451,11 +2930,29 @@ async function cmdFeatureSpecify(ctx) {
2451
2930
  return { criterion, verification: verification || null };
2452
2931
  });
2453
2932
 
2933
+ if (ctx.flags.out !== undefined) {
2934
+ throw new UsageError(
2935
+ "--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.",
2936
+ );
2937
+ }
2938
+
2454
2939
  const input = {
2455
2940
  purpose: ctx.flags.purpose ? String(ctx.flags.purpose) : null,
2456
2941
  userStatement: ctx.flags.user ? String(ctx.flags.user) : null,
2457
2942
  scopeIn: asList(ctx.flags.in).map(String),
2458
- scopeOut: asList(ctx.flags.out).map(String),
2943
+ // --not, never --out.
2944
+ //
2945
+ // This flag was --out, which is also the global "write this command's output
2946
+ // to this path" flag. The database write succeeded, then the joined scope-out
2947
+ // sentences were treated as a filename: three files named after their own
2948
+ // contents appeared in a real user's repository root, and the fourth call
2949
+ // died with ENAMETOOLONG. Both outcomes were silent, because the output that
2950
+ // would have said what happened went into the junk file.
2951
+ //
2952
+ // Renaming it is the fix rather than marking the flag consumed, because a
2953
+ // command where --out means something different from every other command is
2954
+ // a trap even when it works.
2955
+ scopeOut: asList(ctx.flags.not).map(String),
2459
2956
  flow: asList(ctx.flags.flow).map(String),
2460
2957
  criteria,
2461
2958
  edgeCases,
@@ -2619,6 +3116,23 @@ const COMMANDS = {
2619
3116
  "decision supersede": cmdDecisionSupersede,
2620
3117
  "decision list": cmdDecisionList,
2621
3118
  "feature accept": cmdFeatureAccept,
3119
+ "goal record": cmdGoalRecord,
3120
+ "goal criterion": cmdGoalCriterion,
3121
+ "milestone record": cmdMilestoneRecord,
3122
+ "milestone condition": cmdMilestoneCondition,
3123
+ "module record": cmdModuleRecord,
3124
+ "feature create": cmdFeatureCreate,
3125
+ "feature move": cmdFeatureMove,
3126
+ "feature goal": cmdFeatureGoal,
3127
+ "milestone update": cmdMilestoneUpdate,
3128
+ "module rename": cmdModuleRename,
3129
+ "capability list": cmdCapabilityList,
3130
+ "capability specify": cmdCapabilitySpecify,
3131
+ "capability not-applicable": cmdCapabilityNotApplicable,
3132
+ "scope record": cmdScopeRecord,
3133
+ "scope remove": cmdScopeRemove,
3134
+ "scope list": cmdScopeList,
3135
+ retire: cmdRetire,
2622
3136
  "feature depth": cmdFeatureDepth,
2623
3137
  "feature specify": cmdFeatureSpecify,
2624
3138
  "feature waive": cmdFeatureWaive,
@@ -2781,14 +3295,7 @@ export async function run(argv = process.argv.slice(2)) {
2781
3295
  function isProgram() {
2782
3296
  const invoked = process.argv[1];
2783
3297
  if (!invoked) return false;
2784
- const real = (path) => {
2785
- try {
2786
- return realpathSync(path);
2787
- } catch {
2788
- return path;
2789
- }
2790
- };
2791
- return real(invoked) === real(fileURLToPath(import.meta.url));
3298
+ return realpathOf(invoked) === realpathOf(fileURLToPath(import.meta.url));
2792
3299
  }
2793
3300
 
2794
3301
  // Imported, by a hook or by a test of the parser, it stays inert.