superdev-cli 0.1.1 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/package.json +2 -1
- package/scripts/providers/adapter.mjs +196 -0
- package/scripts/providers/detect.mjs +519 -0
- package/scripts/providers/registry.mjs +248 -0
- package/scripts/validate/packaging.mjs +116 -0
- package/scripts/validate/validate-all.mjs +2 -1
- package/skills/docs/scripts/profile-detect.mjs +56 -2
- package/src/cli/product-map.mjs +10 -2
- package/src/cli/render.mjs +11 -3
- package/src/cli.mjs +422 -14
- package/src/docs/proposals.mjs +19 -1
- package/src/init/discovery.mjs +46 -0
- package/src/init/index.mjs +133 -22
- package/src/product/authoring.mjs +584 -0
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"]);
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -2781,14 +3196,7 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
2781
3196
|
function isProgram() {
|
|
2782
3197
|
const invoked = process.argv[1];
|
|
2783
3198
|
if (!invoked) return false;
|
|
2784
|
-
|
|
2785
|
-
try {
|
|
2786
|
-
return realpathSync(path);
|
|
2787
|
-
} catch {
|
|
2788
|
-
return path;
|
|
2789
|
-
}
|
|
2790
|
-
};
|
|
2791
|
-
return real(invoked) === real(fileURLToPath(import.meta.url));
|
|
3199
|
+
return realpathOf(invoked) === realpathOf(fileURLToPath(import.meta.url));
|
|
2792
3200
|
}
|
|
2793
3201
|
|
|
2794
3202
|
// Imported, by a hook or by a test of the parser, it stays inert.
|
package/src/docs/proposals.mjs
CHANGED
|
@@ -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
|
-
|
|
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))) {
|
package/src/init/discovery.mjs
CHANGED
|
@@ -892,6 +892,52 @@ const splitSentences = (text) =>
|
|
|
892
892
|
.map((s) => s.trim())
|
|
893
893
|
.filter(Boolean);
|
|
894
894
|
|
|
895
|
+
/**
|
|
896
|
+
* A statement's name and its description, when the statement carries both.
|
|
897
|
+
*
|
|
898
|
+
* Briefs list their parts as `**Screens.** Four routes with shared layout,
|
|
899
|
+
* navigation, and session state.` Every word of that was taken as the name, so a
|
|
900
|
+
* product map read as a column of truncated sentences whose first several words
|
|
901
|
+
* were identical, and the module slug reached the filesystem as
|
|
902
|
+
* `screens-four-routes-with-shared-layout-navigation-and-sessio`, cut mid-word,
|
|
903
|
+
* as a committed directory path.
|
|
904
|
+
*
|
|
905
|
+
* A label is short, comes first, and has a description after it. The three forms
|
|
906
|
+
* briefs actually use are `Name. Rest`, `Name: Rest` and `Name - Rest`, and the
|
|
907
|
+
* dash arrives as a hyphen, an en dash or an em dash because a person typed it.
|
|
908
|
+
* When there is no description the whole statement is the name, minus the
|
|
909
|
+
* full stop that would otherwise be part of it.
|
|
910
|
+
*
|
|
911
|
+
* The limits are deliberately tight. A label is a noun phrase: "Weather client",
|
|
912
|
+
* "Course data". A long first clause is a sentence, and treating it as a label
|
|
913
|
+
* would rename every feature to its opening clause, which is a different defect
|
|
914
|
+
* with the same cause. Word count separates the two better than length does:
|
|
915
|
+
* "The outgoing chef records what is prepped and what ran out" is short enough to
|
|
916
|
+
* pass any character limit and is plainly not a name.
|
|
917
|
+
*/
|
|
918
|
+
export function splitLabel(statement, limit = 60, words = 6) {
|
|
919
|
+
const text = String(statement ?? "").replace(/\s+/g, " ").trim();
|
|
920
|
+
if (!text) return { name: null, description: null };
|
|
921
|
+
|
|
922
|
+
// The dashes are written as escapes because the em dash character is refused in
|
|
923
|
+
// this project's own source, and this has to match one a person typed.
|
|
924
|
+
const separated = text.match(/^(.{2,}?)\s*(?:[.:]|\s[-\u2013\u2014])\s+(\S.*)$/);
|
|
925
|
+
if (separated) {
|
|
926
|
+
const label = separated[1].trim();
|
|
927
|
+
// A label holds no sentence-ending punctuation of its own, and a trailing
|
|
928
|
+
// "and" or "or" means the split landed inside a list rather than after a name.
|
|
929
|
+
if (
|
|
930
|
+
label.length <= limit &&
|
|
931
|
+
label.split(/\s+/).length <= words &&
|
|
932
|
+
!/[.!?:]/.test(label) &&
|
|
933
|
+
!/\b(and|or|but|with|for|to|of)$/i.test(label)
|
|
934
|
+
) {
|
|
935
|
+
return { name: label, description: separated[2].trim() };
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return { name: text.replace(/\s*\.$/, ""), description: null };
|
|
939
|
+
}
|
|
940
|
+
|
|
895
941
|
const normalize = (text) => String(text ?? "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
896
942
|
|
|
897
943
|
function clean(text) {
|