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/.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 +540 -33
- package/src/db/migrations/013_question_options.sql +28 -0
- package/src/docs/proposals.mjs +19 -1
- package/src/init/discovery.mjs +46 -0
- package/src/init/index.mjs +142 -22
- package/src/init/questions.mjs +115 -5
- package/src/product/authoring.mjs +649 -0
- package/src/service/assets/control-center.html +62 -62
- package/src/service/assets/control-center.manifest.json +4 -4
- package/src/service/mutations.mjs +78 -30
- package/src/tasks/lifecycle.mjs +45 -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", "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
|
|
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
|
-
|
|
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
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
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
|
|
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
|
|
2245
|
-
|
|
2246
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|