superdev-cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/README.md +7 -2
- package/package.json +3 -2
- 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/skill-commands.mjs +71 -1
- package/scripts/validate/validate-all.mjs +2 -1
- package/skills/docs/SKILL.md +1 -1
- package/skills/docs/references/change-tracking.md +9 -1
- package/skills/docs/references/ingestion.md +7 -2
- 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 +442 -11
- 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/skills/docs/scripts/ingest.mjs +0 -984
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) {
|
package/src/init/index.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { ensureDatabase, mutate, paths, query, create, patch, setStatus, recordA
|
|
|
14
14
|
import { slugify, uniqueSlug } from "../model/ids.mjs";
|
|
15
15
|
import { sanitizeExternal } from "../model/screening.mjs";
|
|
16
16
|
import { CAPABILITY_AREAS, SYSTEM_TASK_CATEGORIES, count } from "../model/vocabulary.mjs";
|
|
17
|
-
import { buildConceptMap, detectProjectKind, ingestSources, inspectEvidence, titleOf } from "./discovery.mjs";
|
|
17
|
+
import { buildConceptMap, detectProjectKind, ingestSources, inspectEvidence, splitLabel, titleOf } from "./discovery.mjs";
|
|
18
18
|
import {
|
|
19
19
|
MATERIAL_AREAS,
|
|
20
20
|
STACK_SLOTS,
|
|
@@ -104,7 +104,18 @@ export async function planInit(root, opts = {}) {
|
|
|
104
104
|
warnings.push("The repository is larger than the inspection limit, so the evidence below is a sample rather than a census.");
|
|
105
105
|
}
|
|
106
106
|
if (detection.route === "adopt") {
|
|
107
|
-
|
|
107
|
+
// Name a command, never a function. This said "adoptProject leaves those
|
|
108
|
+
// files exactly where they are", which is the internal function, so the
|
|
109
|
+
// refusal told the reader the remedy in a vocabulary they cannot type. It
|
|
110
|
+
// also stayed silent about the confidence, and a low-confidence detection is
|
|
111
|
+
// exactly the case where the reader should know to override it.
|
|
112
|
+
warnings.push([
|
|
113
|
+
`This repository already has documentation (profile ${detection.docsProfile}, ${detection.confidence} confidence).`,
|
|
114
|
+
"Initialization is refused here. Run superdev adopt, which leaves every existing file exactly where it is.",
|
|
115
|
+
detection.confidence === "low" || detection.confidence === "unknown"
|
|
116
|
+
? "If those documents are input rather than a record of this project, for example a brief you are initializing from, run superdev init --adopt to say so and initialization will proceed."
|
|
117
|
+
: null,
|
|
118
|
+
].filter(Boolean).join(" "));
|
|
108
119
|
}
|
|
109
120
|
if (existing.project) {
|
|
110
121
|
warnings.push(`Project ${existing.project.id} already exists. Initialization will report what is present and create only what is missing.`);
|
|
@@ -372,15 +383,6 @@ export async function applyInit(root, opts = {}) {
|
|
|
372
383
|
// Step 14 and 15 run outside every transaction: both open their own.
|
|
373
384
|
// `notRun` rather than `skipped`: both generate and deriveAll return their own
|
|
374
385
|
// `skipped` collection, and an empty array is still truthy.
|
|
375
|
-
let documents = { notRun: "documentation generation was turned off for this run" };
|
|
376
|
-
if (opts.generateDocs !== false) {
|
|
377
|
-
documents = await runQuietly(async () => {
|
|
378
|
-
const { generate } = await import("../docs/render.mjs");
|
|
379
|
-
return generate(at, { apply: true });
|
|
380
|
-
}, "documentation could not be generated");
|
|
381
|
-
}
|
|
382
|
-
record(14, outcomeOf(documents), documents.error ?? documents.notRun ?? summarize(documents));
|
|
383
|
-
|
|
384
386
|
let tasks = { notRun: "task derivation was turned off for this run" };
|
|
385
387
|
if (opts.deriveTasks !== false) {
|
|
386
388
|
tasks = await runQuietly(async () => {
|
|
@@ -408,6 +410,25 @@ export async function applyInit(root, opts = {}) {
|
|
|
408
410
|
metadata: { route: plan.route, questions: questions.length, discoveryItems: conceptMap.created.length, reused: created.reused },
|
|
409
411
|
}));
|
|
410
412
|
|
|
413
|
+
// Documents are generated last, after every record and every event init will
|
|
414
|
+
// write, and the step is still reported as 14 because the brief's order is
|
|
415
|
+
// what a reader follows.
|
|
416
|
+
//
|
|
417
|
+
// It ran before task derivation and before the closing event, so a document
|
|
418
|
+
// was stamped with a revision lower than the project's own, and freshness
|
|
419
|
+
// compares the two. Every project therefore began its life reporting that its
|
|
420
|
+
// documentation was built from an older database revision, seconds after init
|
|
421
|
+
// wrote it, and no command the user could run would clear it. A gate that is
|
|
422
|
+
// red on arrival teaches the reader that red means nothing.
|
|
423
|
+
let documents = { notRun: "documentation generation was turned off for this run" };
|
|
424
|
+
if (opts.generateDocs !== false) {
|
|
425
|
+
documents = await runQuietly(async () => {
|
|
426
|
+
const { generate } = await import("../docs/render.mjs");
|
|
427
|
+
return generate(at, { apply: true });
|
|
428
|
+
}, "documentation could not be generated");
|
|
429
|
+
}
|
|
430
|
+
record(14, outcomeOf(documents), documents.error ?? documents.notRun ?? summarize(documents));
|
|
431
|
+
|
|
411
432
|
return {
|
|
412
433
|
root: at,
|
|
413
434
|
project: created.project,
|
|
@@ -485,6 +506,34 @@ const titleFrom = (name) =>
|
|
|
485
506
|
* with no goal candidates gets no goals, and the gap stays visible as the
|
|
486
507
|
* unknown the concept map already wrote down.
|
|
487
508
|
*/
|
|
509
|
+
/**
|
|
510
|
+
* The module a feature names, or nothing.
|
|
511
|
+
*
|
|
512
|
+
* Matching is on the module's name appearing in the feature's own words, which is
|
|
513
|
+
* how a brief actually signals ownership: "Read the previous shift's handover"
|
|
514
|
+
* belongs to "Handover", and says so. A single common word is not a signal, so
|
|
515
|
+
* short and structural words are ignored, and nothing matching means nothing is
|
|
516
|
+
* claimed.
|
|
517
|
+
*/
|
|
518
|
+
const STRUCTURAL = new Set(["the", "and", "for", "with", "data", "core", "app", "api", "web", "ui", "service", "client", "server", "module", "system"]);
|
|
519
|
+
|
|
520
|
+
function ownerFor(modules, statement) {
|
|
521
|
+
const text = String(statement ?? "").toLowerCase();
|
|
522
|
+
let best = null;
|
|
523
|
+
for (const module of modules) {
|
|
524
|
+
const words = String(module.name).toLowerCase().split(/[^a-z0-9]+/)
|
|
525
|
+
.filter((word) => word.length > 3 && !STRUCTURAL.has(word));
|
|
526
|
+
if (!words.length) continue;
|
|
527
|
+
const hits = words.filter((word) => text.includes(word)).length;
|
|
528
|
+
// Every distinctive word of the module's name has to appear, so "Shot log"
|
|
529
|
+
// does not claim a feature that merely mentions a shot.
|
|
530
|
+
if (hits === words.length && (!best || words.length > best.words)) {
|
|
531
|
+
best = { module, words: words.length };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return best?.module ?? null;
|
|
535
|
+
}
|
|
536
|
+
|
|
488
537
|
async function draftProduct(root, project, { actor, at, milestones }) {
|
|
489
538
|
const outcome = await mutate(root, async (db) => {
|
|
490
539
|
const items = await db.all(
|
|
@@ -513,9 +562,13 @@ async function draftProduct(root, project, { actor, at, milestones }) {
|
|
|
513
562
|
|
|
514
563
|
const modules = [];
|
|
515
564
|
for (const [index, item] of moduleCandidates.entries()) {
|
|
516
|
-
const
|
|
565
|
+
const stated = item.statement.replace(/^Module candidate:\s*/, "");
|
|
566
|
+
// The name is the label the brief gave the part, and the sentence after it
|
|
567
|
+
// is what the part does. Both used to become the name.
|
|
568
|
+
const { name: label, description } = splitLabel(stated);
|
|
569
|
+
const name = trim(label, 120);
|
|
517
570
|
if (!name) continue;
|
|
518
|
-
const module = await createModule(db, project, name,
|
|
571
|
+
const module = await createModule(db, project, name, description ?? stated, index, at);
|
|
519
572
|
if (!module) continue;
|
|
520
573
|
modules.push(module);
|
|
521
574
|
await convert(db, item, "module", module.id, at);
|
|
@@ -528,14 +581,27 @@ async function draftProduct(root, project, { actor, at, milestones }) {
|
|
|
528
581
|
}
|
|
529
582
|
|
|
530
583
|
const features = [];
|
|
531
|
-
|
|
532
|
-
|
|
584
|
+
const unowned = [];
|
|
585
|
+
for (const item of featureCandidates) {
|
|
586
|
+
const { name: label } = splitLabel(item.statement);
|
|
587
|
+
const name = trim(label, 120);
|
|
533
588
|
if (!name || !modules.length) continue;
|
|
534
589
|
if (await db.get("SELECT 1 FROM features WHERE project_id = ? AND name = ?", project.id, name)) continue;
|
|
535
590
|
const slug = await uniqueSlug(db, "features", project.id, name, "feature");
|
|
591
|
+
// Which module owns this feature is read from the feature, never from where
|
|
592
|
+
// it happened to sit in the list.
|
|
593
|
+
//
|
|
594
|
+
// It was `modules[index % modules.length]`, so feature n went to module n.
|
|
595
|
+
// Four features that were all screens landed on four different modules, one
|
|
596
|
+
// per supporting library, and the result was defensible only because the
|
|
597
|
+
// two lists happened to be the same length and in a compatible order.
|
|
598
|
+
// Reorder either list in the brief and the map becomes nonsense that reads
|
|
599
|
+
// as deliberate.
|
|
600
|
+
const matched = ownerFor(modules, item.statement);
|
|
601
|
+
const owner = matched ?? modules[0];
|
|
536
602
|
const feature = await create(db, "feature", {
|
|
537
603
|
project_id: project.id,
|
|
538
|
-
module_id:
|
|
604
|
+
module_id: owner.id,
|
|
539
605
|
name,
|
|
540
606
|
slug,
|
|
541
607
|
purpose: item.statement,
|
|
@@ -547,9 +613,54 @@ async function draftProduct(root, project, { actor, at, milestones }) {
|
|
|
547
613
|
updated_at: at,
|
|
548
614
|
}, { projectId: project.id, activity: false });
|
|
549
615
|
features.push(feature);
|
|
616
|
+
// An unmatched feature still needs a module, so it gets the first one and
|
|
617
|
+
// the guess is written down as a question rather than left to look decided.
|
|
618
|
+
if (!matched && modules.length > 1) unowned.push({ feature, owner });
|
|
550
619
|
await convert(db, item, "feature", feature.id, at);
|
|
551
620
|
}
|
|
552
621
|
|
|
622
|
+
// What the brief said is out of scope, stored where the product reads it.
|
|
623
|
+
//
|
|
624
|
+
// An "Out of scope" section became a discovery item of kind `exclusion` and
|
|
625
|
+
// stopped there. project_scope_items, the table the product document reads,
|
|
626
|
+
// was written by nothing at all, so the generated foundations said "None
|
|
627
|
+
// declared. An empty list here is a gap, not a statement" to somebody who had
|
|
628
|
+
// just declared three. The most valuable section of a brief was parsed,
|
|
629
|
+
// stored as a proposal, and contradicted in the same run.
|
|
630
|
+
const exclusions = items.filter((i) => i.kind === "exclusion");
|
|
631
|
+
const scope = [];
|
|
632
|
+
for (const [index, item] of exclusions.entries()) {
|
|
633
|
+
const { name: statement, description: why } = splitLabel(item.statement);
|
|
634
|
+
if (!statement) continue;
|
|
635
|
+
const already = await db.get(
|
|
636
|
+
"SELECT id FROM project_scope_items WHERE project_id = ? AND statement = ?", project.id, statement);
|
|
637
|
+
if (already) continue;
|
|
638
|
+
const row = await create(db, "project_scope_item", {
|
|
639
|
+
project_id: project.id,
|
|
640
|
+
direction: "non_goal",
|
|
641
|
+
statement,
|
|
642
|
+
why: why ?? item.provenance ?? null,
|
|
643
|
+
sequence: index,
|
|
644
|
+
}, { projectId: project.id, activity: false });
|
|
645
|
+
scope.push(row);
|
|
646
|
+
await convert(db, item, "project_scope_item", row.id, at);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Every guess about ownership, asked rather than assumed.
|
|
650
|
+
for (const { feature, owner } of unowned) {
|
|
651
|
+
await create(db, "question", {
|
|
652
|
+
project_id: project.id,
|
|
653
|
+
scope_type: "feature",
|
|
654
|
+
scope_id: feature.id,
|
|
655
|
+
question: `Which module owns ${feature.name}?`,
|
|
656
|
+
why_it_matters: `The brief named ${modules.length} modules and none of them appears in this feature, so it was placed in ${owner.name} to have a home. Module ownership decides which test plan covers it and which documentation directory it is written to.`,
|
|
657
|
+
recommendation: `Leave it in ${owner.name} if that is right, or run superdev feature move ${feature.id} --module <MOD-id>.`,
|
|
658
|
+
alternatives_json: JSON.stringify(modules.filter((m) => m.id !== owner.id).map((m) => m.name)),
|
|
659
|
+
status: "open",
|
|
660
|
+
created_at: at,
|
|
661
|
+
}, { projectId: project.id, activity: false });
|
|
662
|
+
}
|
|
663
|
+
|
|
553
664
|
const created = [];
|
|
554
665
|
for (const [index, supplied] of (milestones ?? []).entries()) {
|
|
555
666
|
created.push(await createMilestone(db, project, clean(supplied.name), clean(supplied.outcome), index, at));
|
|
@@ -564,24 +675,24 @@ async function draftProduct(root, project, { actor, at, milestones }) {
|
|
|
564
675
|
|
|
565
676
|
for (const module of modules) await seedModuleCompleteness(db, module.id, { at });
|
|
566
677
|
|
|
567
|
-
if (goals.length || modules.length || features.length || created.length) {
|
|
678
|
+
if (goals.length || modules.length || features.length || created.length || scope.length) {
|
|
568
679
|
await recordActivity(db, project.id, {
|
|
569
680
|
type: "specification_changed",
|
|
570
|
-
summary: `Drafted ${count(goals.length, "goal")}, ${count(modules.length, "module")}, ${count(features.length, "feature")}
|
|
681
|
+
summary: `Drafted ${count(goals.length, "goal")}, ${count(modules.length, "module")}, ${count(features.length, "feature")}, ${count(created.filter(Boolean).length, "milestone")} and ${count(scope.length, "non-goal")} from the concept map`,
|
|
571
682
|
actor,
|
|
572
|
-
metadata: { goals: goals.length, modules: modules.length, features: features.length, milestones: created.filter(Boolean).length },
|
|
683
|
+
metadata: { goals: goals.length, modules: modules.length, features: features.length, milestones: created.filter(Boolean).length, nonGoals: scope.length },
|
|
573
684
|
});
|
|
574
685
|
}
|
|
575
686
|
|
|
576
|
-
return { goals, modules, features, milestones: created.filter(Boolean) };
|
|
687
|
+
return { goals, modules, features, milestones: created.filter(Boolean), nonGoals: scope };
|
|
577
688
|
});
|
|
578
689
|
|
|
579
|
-
const total = outcome.goals.length + outcome.modules.length + outcome.features.length;
|
|
690
|
+
const total = outcome.goals.length + outcome.modules.length + outcome.features.length + outcome.nonGoals.length;
|
|
580
691
|
return {
|
|
581
692
|
...outcome,
|
|
582
693
|
created: total > 0,
|
|
583
694
|
detail: total
|
|
584
|
-
? `${count(outcome.goals.length, "goal")}, ${count(outcome.modules.length, "module")}, ${count(outcome.features.length, "feature")}, ${count(outcome.milestones.length, "milestone")}`
|
|
695
|
+
? `${count(outcome.goals.length, "goal")}, ${count(outcome.modules.length, "module")}, ${count(outcome.features.length, "feature")}, ${count(outcome.milestones.length, "milestone")}, ${count(outcome.nonGoals.length, "non-goal")}`
|
|
585
696
|
: "the concept map held no candidates to convert, so nothing was invented to fill the gap",
|
|
586
697
|
};
|
|
587
698
|
}
|