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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- A question's options, and which of them is recommended.
|
|
2
|
+
--
|
|
3
|
+
-- `alternatives_json` has held the options since the first migration, and both
|
|
4
|
+
-- surfaces treated them as prose: the control centre folded them into a read-only
|
|
5
|
+
-- list and offered no way to answer at all, and the command line took a free-text
|
|
6
|
+
-- answer only. So a question arrived with its options already worked out and the
|
|
7
|
+
-- reader had to retype one of them.
|
|
8
|
+
--
|
|
9
|
+
-- Three things were missing to make an option selectable rather than readable.
|
|
10
|
+
--
|
|
11
|
+
-- `select_mode` because some questions take one answer and some take several.
|
|
12
|
+
-- "Which database?" is one. "Which roles exist?" is several. Guessing wrongly in
|
|
13
|
+
-- either direction produces a record that does not say what the reader meant.
|
|
14
|
+
--
|
|
15
|
+
-- `recommended_json` because `recommendation` holds advice in prose ("State the
|
|
16
|
+
-- outcome in one sentence and name one thing you could observe"), which cannot be
|
|
17
|
+
-- matched against an option to tag it. Naming the recommended options separately
|
|
18
|
+
-- lets the tag sit on the option itself.
|
|
19
|
+
--
|
|
20
|
+
-- `recommendation_why` because a recommendation nobody can weigh is an
|
|
21
|
+
-- instruction. The reason is what lets somebody disagree with it on purpose.
|
|
22
|
+
|
|
23
|
+
ALTER TABLE questions ADD COLUMN select_mode TEXT NOT NULL DEFAULT 'one'
|
|
24
|
+
CHECK (select_mode IN ('one', 'many'));
|
|
25
|
+
|
|
26
|
+
ALTER TABLE questions ADD COLUMN recommended_json TEXT NOT NULL DEFAULT '[]';
|
|
27
|
+
|
|
28
|
+
ALTER TABLE questions ADD COLUMN recommendation_why TEXT;
|
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) {
|
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,
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
questionCatalog,
|
|
23
23
|
seedCapabilityAreas,
|
|
24
24
|
seedModuleCompleteness,
|
|
25
|
+
settleAnsweredAreas,
|
|
25
26
|
seedTaskCategories,
|
|
26
27
|
} from "./questions.mjs";
|
|
27
28
|
|
|
@@ -104,7 +105,18 @@ export async function planInit(root, opts = {}) {
|
|
|
104
105
|
warnings.push("The repository is larger than the inspection limit, so the evidence below is a sample rather than a census.");
|
|
105
106
|
}
|
|
106
107
|
if (detection.route === "adopt") {
|
|
107
|
-
|
|
108
|
+
// Name a command, never a function. This said "adoptProject leaves those
|
|
109
|
+
// files exactly where they are", which is the internal function, so the
|
|
110
|
+
// refusal told the reader the remedy in a vocabulary they cannot type. It
|
|
111
|
+
// also stayed silent about the confidence, and a low-confidence detection is
|
|
112
|
+
// exactly the case where the reader should know to override it.
|
|
113
|
+
warnings.push([
|
|
114
|
+
`This repository already has documentation (profile ${detection.docsProfile}, ${detection.confidence} confidence).`,
|
|
115
|
+
"Initialization is refused here. Run superdev adopt, which leaves every existing file exactly where it is.",
|
|
116
|
+
detection.confidence === "low" || detection.confidence === "unknown"
|
|
117
|
+
? "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."
|
|
118
|
+
: null,
|
|
119
|
+
].filter(Boolean).join(" "));
|
|
108
120
|
}
|
|
109
121
|
if (existing.project) {
|
|
110
122
|
warnings.push(`Project ${existing.project.id} already exists. Initialization will report what is present and create only what is missing.`);
|
|
@@ -334,6 +346,9 @@ export async function applyInit(root, opts = {}) {
|
|
|
334
346
|
why_it_matters: descriptor.whyItMatters,
|
|
335
347
|
recommendation: descriptor.recommendation,
|
|
336
348
|
alternatives_json: JSON.stringify(descriptor.alternatives ?? []),
|
|
349
|
+
select_mode: descriptor.selectMode ?? "one",
|
|
350
|
+
recommended_json: JSON.stringify(descriptor.recommended ?? []),
|
|
351
|
+
recommendation_why: descriptor.recommendedWhy ?? null,
|
|
337
352
|
status: "open",
|
|
338
353
|
created_at: stamp,
|
|
339
354
|
}, { projectId: created.project.id, activity: false });
|
|
@@ -357,6 +372,11 @@ export async function applyInit(root, opts = {}) {
|
|
|
357
372
|
metadata: { questions: rows.map((r) => r.id) },
|
|
358
373
|
});
|
|
359
374
|
}
|
|
375
|
+
// A material area whose question was not raised because the project already
|
|
376
|
+
// answers it is settled here, in the same transaction that raised the rest.
|
|
377
|
+
// Otherwise it sits awaiting a decision that nobody was asked about, which is
|
|
378
|
+
// the one state this checklist exists to make impossible.
|
|
379
|
+
await settleAnsweredAreas(db, created.project.id, { at: stamp, actor });
|
|
360
380
|
return rows;
|
|
361
381
|
});
|
|
362
382
|
record(7, questions.length ? "done" : "skipped", questions.length ? `${count(questions.length, "question")} raised: ${questions.map((q) => q.id).join(", ")}` : "every material area is answered by the repository or already asked");
|
|
@@ -372,15 +392,6 @@ export async function applyInit(root, opts = {}) {
|
|
|
372
392
|
// Step 14 and 15 run outside every transaction: both open their own.
|
|
373
393
|
// `notRun` rather than `skipped`: both generate and deriveAll return their own
|
|
374
394
|
// `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
395
|
let tasks = { notRun: "task derivation was turned off for this run" };
|
|
385
396
|
if (opts.deriveTasks !== false) {
|
|
386
397
|
tasks = await runQuietly(async () => {
|
|
@@ -408,6 +419,25 @@ export async function applyInit(root, opts = {}) {
|
|
|
408
419
|
metadata: { route: plan.route, questions: questions.length, discoveryItems: conceptMap.created.length, reused: created.reused },
|
|
409
420
|
}));
|
|
410
421
|
|
|
422
|
+
// Documents are generated last, after every record and every event init will
|
|
423
|
+
// write, and the step is still reported as 14 because the brief's order is
|
|
424
|
+
// what a reader follows.
|
|
425
|
+
//
|
|
426
|
+
// It ran before task derivation and before the closing event, so a document
|
|
427
|
+
// was stamped with a revision lower than the project's own, and freshness
|
|
428
|
+
// compares the two. Every project therefore began its life reporting that its
|
|
429
|
+
// documentation was built from an older database revision, seconds after init
|
|
430
|
+
// wrote it, and no command the user could run would clear it. A gate that is
|
|
431
|
+
// red on arrival teaches the reader that red means nothing.
|
|
432
|
+
let documents = { notRun: "documentation generation was turned off for this run" };
|
|
433
|
+
if (opts.generateDocs !== false) {
|
|
434
|
+
documents = await runQuietly(async () => {
|
|
435
|
+
const { generate } = await import("../docs/render.mjs");
|
|
436
|
+
return generate(at, { apply: true });
|
|
437
|
+
}, "documentation could not be generated");
|
|
438
|
+
}
|
|
439
|
+
record(14, outcomeOf(documents), documents.error ?? documents.notRun ?? summarize(documents));
|
|
440
|
+
|
|
411
441
|
return {
|
|
412
442
|
root: at,
|
|
413
443
|
project: created.project,
|
|
@@ -485,6 +515,34 @@ const titleFrom = (name) =>
|
|
|
485
515
|
* with no goal candidates gets no goals, and the gap stays visible as the
|
|
486
516
|
* unknown the concept map already wrote down.
|
|
487
517
|
*/
|
|
518
|
+
/**
|
|
519
|
+
* The module a feature names, or nothing.
|
|
520
|
+
*
|
|
521
|
+
* Matching is on the module's name appearing in the feature's own words, which is
|
|
522
|
+
* how a brief actually signals ownership: "Read the previous shift's handover"
|
|
523
|
+
* belongs to "Handover", and says so. A single common word is not a signal, so
|
|
524
|
+
* short and structural words are ignored, and nothing matching means nothing is
|
|
525
|
+
* claimed.
|
|
526
|
+
*/
|
|
527
|
+
const STRUCTURAL = new Set(["the", "and", "for", "with", "data", "core", "app", "api", "web", "ui", "service", "client", "server", "module", "system"]);
|
|
528
|
+
|
|
529
|
+
function ownerFor(modules, statement) {
|
|
530
|
+
const text = String(statement ?? "").toLowerCase();
|
|
531
|
+
let best = null;
|
|
532
|
+
for (const module of modules) {
|
|
533
|
+
const words = String(module.name).toLowerCase().split(/[^a-z0-9]+/)
|
|
534
|
+
.filter((word) => word.length > 3 && !STRUCTURAL.has(word));
|
|
535
|
+
if (!words.length) continue;
|
|
536
|
+
const hits = words.filter((word) => text.includes(word)).length;
|
|
537
|
+
// Every distinctive word of the module's name has to appear, so "Shot log"
|
|
538
|
+
// does not claim a feature that merely mentions a shot.
|
|
539
|
+
if (hits === words.length && (!best || words.length > best.words)) {
|
|
540
|
+
best = { module, words: words.length };
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return best?.module ?? null;
|
|
544
|
+
}
|
|
545
|
+
|
|
488
546
|
async function draftProduct(root, project, { actor, at, milestones }) {
|
|
489
547
|
const outcome = await mutate(root, async (db) => {
|
|
490
548
|
const items = await db.all(
|
|
@@ -513,9 +571,13 @@ async function draftProduct(root, project, { actor, at, milestones }) {
|
|
|
513
571
|
|
|
514
572
|
const modules = [];
|
|
515
573
|
for (const [index, item] of moduleCandidates.entries()) {
|
|
516
|
-
const
|
|
574
|
+
const stated = item.statement.replace(/^Module candidate:\s*/, "");
|
|
575
|
+
// The name is the label the brief gave the part, and the sentence after it
|
|
576
|
+
// is what the part does. Both used to become the name.
|
|
577
|
+
const { name: label, description } = splitLabel(stated);
|
|
578
|
+
const name = trim(label, 120);
|
|
517
579
|
if (!name) continue;
|
|
518
|
-
const module = await createModule(db, project, name,
|
|
580
|
+
const module = await createModule(db, project, name, description ?? stated, index, at);
|
|
519
581
|
if (!module) continue;
|
|
520
582
|
modules.push(module);
|
|
521
583
|
await convert(db, item, "module", module.id, at);
|
|
@@ -528,14 +590,27 @@ async function draftProduct(root, project, { actor, at, milestones }) {
|
|
|
528
590
|
}
|
|
529
591
|
|
|
530
592
|
const features = [];
|
|
531
|
-
|
|
532
|
-
|
|
593
|
+
const unowned = [];
|
|
594
|
+
for (const item of featureCandidates) {
|
|
595
|
+
const { name: label } = splitLabel(item.statement);
|
|
596
|
+
const name = trim(label, 120);
|
|
533
597
|
if (!name || !modules.length) continue;
|
|
534
598
|
if (await db.get("SELECT 1 FROM features WHERE project_id = ? AND name = ?", project.id, name)) continue;
|
|
535
599
|
const slug = await uniqueSlug(db, "features", project.id, name, "feature");
|
|
600
|
+
// Which module owns this feature is read from the feature, never from where
|
|
601
|
+
// it happened to sit in the list.
|
|
602
|
+
//
|
|
603
|
+
// It was `modules[index % modules.length]`, so feature n went to module n.
|
|
604
|
+
// Four features that were all screens landed on four different modules, one
|
|
605
|
+
// per supporting library, and the result was defensible only because the
|
|
606
|
+
// two lists happened to be the same length and in a compatible order.
|
|
607
|
+
// Reorder either list in the brief and the map becomes nonsense that reads
|
|
608
|
+
// as deliberate.
|
|
609
|
+
const matched = ownerFor(modules, item.statement);
|
|
610
|
+
const owner = matched ?? modules[0];
|
|
536
611
|
const feature = await create(db, "feature", {
|
|
537
612
|
project_id: project.id,
|
|
538
|
-
module_id:
|
|
613
|
+
module_id: owner.id,
|
|
539
614
|
name,
|
|
540
615
|
slug,
|
|
541
616
|
purpose: item.statement,
|
|
@@ -547,9 +622,54 @@ async function draftProduct(root, project, { actor, at, milestones }) {
|
|
|
547
622
|
updated_at: at,
|
|
548
623
|
}, { projectId: project.id, activity: false });
|
|
549
624
|
features.push(feature);
|
|
625
|
+
// An unmatched feature still needs a module, so it gets the first one and
|
|
626
|
+
// the guess is written down as a question rather than left to look decided.
|
|
627
|
+
if (!matched && modules.length > 1) unowned.push({ feature, owner });
|
|
550
628
|
await convert(db, item, "feature", feature.id, at);
|
|
551
629
|
}
|
|
552
630
|
|
|
631
|
+
// What the brief said is out of scope, stored where the product reads it.
|
|
632
|
+
//
|
|
633
|
+
// An "Out of scope" section became a discovery item of kind `exclusion` and
|
|
634
|
+
// stopped there. project_scope_items, the table the product document reads,
|
|
635
|
+
// was written by nothing at all, so the generated foundations said "None
|
|
636
|
+
// declared. An empty list here is a gap, not a statement" to somebody who had
|
|
637
|
+
// just declared three. The most valuable section of a brief was parsed,
|
|
638
|
+
// stored as a proposal, and contradicted in the same run.
|
|
639
|
+
const exclusions = items.filter((i) => i.kind === "exclusion");
|
|
640
|
+
const scope = [];
|
|
641
|
+
for (const [index, item] of exclusions.entries()) {
|
|
642
|
+
const { name: statement, description: why } = splitLabel(item.statement);
|
|
643
|
+
if (!statement) continue;
|
|
644
|
+
const already = await db.get(
|
|
645
|
+
"SELECT id FROM project_scope_items WHERE project_id = ? AND statement = ?", project.id, statement);
|
|
646
|
+
if (already) continue;
|
|
647
|
+
const row = await create(db, "project_scope_item", {
|
|
648
|
+
project_id: project.id,
|
|
649
|
+
direction: "non_goal",
|
|
650
|
+
statement,
|
|
651
|
+
why: why ?? item.provenance ?? null,
|
|
652
|
+
sequence: index,
|
|
653
|
+
}, { projectId: project.id, activity: false });
|
|
654
|
+
scope.push(row);
|
|
655
|
+
await convert(db, item, "project_scope_item", row.id, at);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Every guess about ownership, asked rather than assumed.
|
|
659
|
+
for (const { feature, owner } of unowned) {
|
|
660
|
+
await create(db, "question", {
|
|
661
|
+
project_id: project.id,
|
|
662
|
+
scope_type: "feature",
|
|
663
|
+
scope_id: feature.id,
|
|
664
|
+
question: `Which module owns ${feature.name}?`,
|
|
665
|
+
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.`,
|
|
666
|
+
recommendation: `Leave it in ${owner.name} if that is right, or run superdev feature move ${feature.id} --module <MOD-id>.`,
|
|
667
|
+
alternatives_json: JSON.stringify(modules.filter((m) => m.id !== owner.id).map((m) => m.name)),
|
|
668
|
+
status: "open",
|
|
669
|
+
created_at: at,
|
|
670
|
+
}, { projectId: project.id, activity: false });
|
|
671
|
+
}
|
|
672
|
+
|
|
553
673
|
const created = [];
|
|
554
674
|
for (const [index, supplied] of (milestones ?? []).entries()) {
|
|
555
675
|
created.push(await createMilestone(db, project, clean(supplied.name), clean(supplied.outcome), index, at));
|
|
@@ -564,24 +684,24 @@ async function draftProduct(root, project, { actor, at, milestones }) {
|
|
|
564
684
|
|
|
565
685
|
for (const module of modules) await seedModuleCompleteness(db, module.id, { at });
|
|
566
686
|
|
|
567
|
-
if (goals.length || modules.length || features.length || created.length) {
|
|
687
|
+
if (goals.length || modules.length || features.length || created.length || scope.length) {
|
|
568
688
|
await recordActivity(db, project.id, {
|
|
569
689
|
type: "specification_changed",
|
|
570
|
-
summary: `Drafted ${count(goals.length, "goal")}, ${count(modules.length, "module")}, ${count(features.length, "feature")}
|
|
690
|
+
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
691
|
actor,
|
|
572
|
-
metadata: { goals: goals.length, modules: modules.length, features: features.length, milestones: created.filter(Boolean).length },
|
|
692
|
+
metadata: { goals: goals.length, modules: modules.length, features: features.length, milestones: created.filter(Boolean).length, nonGoals: scope.length },
|
|
573
693
|
});
|
|
574
694
|
}
|
|
575
695
|
|
|
576
|
-
return { goals, modules, features, milestones: created.filter(Boolean) };
|
|
696
|
+
return { goals, modules, features, milestones: created.filter(Boolean), nonGoals: scope };
|
|
577
697
|
});
|
|
578
698
|
|
|
579
|
-
const total = outcome.goals.length + outcome.modules.length + outcome.features.length;
|
|
699
|
+
const total = outcome.goals.length + outcome.modules.length + outcome.features.length + outcome.nonGoals.length;
|
|
580
700
|
return {
|
|
581
701
|
...outcome,
|
|
582
702
|
created: total > 0,
|
|
583
703
|
detail: total
|
|
584
|
-
? `${count(outcome.goals.length, "goal")}, ${count(outcome.modules.length, "module")}, ${count(outcome.features.length, "feature")}, ${count(outcome.milestones.length, "milestone")}`
|
|
704
|
+
? `${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
705
|
: "the concept map held no candidates to convert, so nothing was invented to fill the gap",
|
|
586
706
|
};
|
|
587
707
|
}
|
package/src/init/questions.mjs
CHANGED
|
@@ -42,6 +42,9 @@ const MATERIAL = [
|
|
|
42
42
|
"Give the outcome now and the measure once the first feature is real",
|
|
43
43
|
"Declare the project exploratory, with no success measure yet",
|
|
44
44
|
],
|
|
45
|
+
selectMode: "one",
|
|
46
|
+
recommended: ["Give the outcome now and the measure once the first feature is real"],
|
|
47
|
+
recommendedWhy: "An outcome with no measure is still worth recording, and the measure is easier to name once one real feature exists. Declaring the project exploratory is honest but leaves readiness unable to report better than unknown.",
|
|
45
48
|
ifDeferred: "Goals and milestones stay unmeasurable and readiness can never report better than unknown.",
|
|
46
49
|
},
|
|
47
50
|
{
|
|
@@ -55,6 +58,9 @@ const MATERIAL = [
|
|
|
55
58
|
"Several roles, data shared",
|
|
56
59
|
"Several roles with per-account separation (multi-tenant)",
|
|
57
60
|
],
|
|
61
|
+
selectMode: "one",
|
|
62
|
+
recommended: ["One role, all data shared"],
|
|
63
|
+
recommendedWhy: "It is the only one of the three that can be widened later without reworking every query. Separation added early costs a column; separation added late costs every surface at once, so this is the cheap direction to be wrong in.",
|
|
58
64
|
ifDeferred: "Authorization is designed after the data model, which is the most expensive order to do it in.",
|
|
59
65
|
},
|
|
60
66
|
{
|
|
@@ -64,6 +70,10 @@ const MATERIAL = [
|
|
|
64
70
|
"The delivery shape decides whether surfaces, UI actions and accessibility are part of the specification at all, or deliberately not applicable.",
|
|
65
71
|
recommendation: "Name the one surface the first user will touch. A second one can be added when it exists.",
|
|
66
72
|
alternatives: ["A web application", "A command line tool", "An API or library only", "An existing host application"],
|
|
73
|
+
// No recommendation. Nothing about a product in general makes one of these
|
|
74
|
+
// likelier, and a tag with no reason behind it is an instruction wearing a
|
|
75
|
+
// recommendation's clothes.
|
|
76
|
+
selectMode: "one",
|
|
67
77
|
ifDeferred: "Surface and UI-action specifications cannot be started, so a whole layer of the product has no home.",
|
|
68
78
|
},
|
|
69
79
|
{
|
|
@@ -73,6 +83,7 @@ const MATERIAL = [
|
|
|
73
83
|
"A shared server turns authentication, authorization and data ownership from optional into mandatory, and turns a local tool into an operated service.",
|
|
74
84
|
recommendation: "Say whether anything runs outside the user's own machine. If nothing does, several capability areas become genuinely not applicable.",
|
|
75
85
|
alternatives: ["Local only", "A single service", "Several services with separate ownership"],
|
|
86
|
+
selectMode: "one",
|
|
76
87
|
ifDeferred: "Backend boundaries, authentication and operational response are all left undecided together.",
|
|
77
88
|
},
|
|
78
89
|
{
|
|
@@ -82,6 +93,9 @@ const MATERIAL = [
|
|
|
82
93
|
"A contract with an outside caller cannot be changed quietly. That single fact decides versioning, error contracts and how much of the specification is public.",
|
|
83
94
|
recommendation: "Assume no external caller until one exists. Declare it the moment one does.",
|
|
84
95
|
alternatives: ["No external caller", "An internal client only", "A published, versioned public API"],
|
|
96
|
+
selectMode: "one",
|
|
97
|
+
recommended: ["No external caller"],
|
|
98
|
+
recommendedWhy: "A contract with an outside caller cannot be withdrawn quietly, so it is worth declaring only when one actually exists. Nothing is lost by declaring it the day it does.",
|
|
85
99
|
ifDeferred: "An internal shape becomes a public contract by accident, which is the most expensive kind of accident.",
|
|
86
100
|
},
|
|
87
101
|
{
|
|
@@ -91,6 +105,7 @@ const MATERIAL = [
|
|
|
91
105
|
"Sessions, tokens and the enforcement point for every permission hang off this answer, and retrofitting identity touches every route.",
|
|
92
106
|
recommendation: "If anything is per-user or private, decide identity now rather than after the first data model.",
|
|
93
107
|
alternatives: ["No sign in", "Identity from an existing provider", "Identity issued by this product"],
|
|
108
|
+
selectMode: "one",
|
|
94
109
|
ifDeferred: "Every permission question gets deferred with it, and the data model is designed without an owner column.",
|
|
95
110
|
},
|
|
96
111
|
{
|
|
@@ -100,6 +115,7 @@ const MATERIAL = [
|
|
|
100
115
|
"Data ownership decides the store, the retention rules, the backup posture and whether deletion is a real feature or a database statement.",
|
|
101
116
|
recommendation: "Name the one or two things the product is the authority on. Say whether losing them is an inconvenience or a catastrophe.",
|
|
102
117
|
alternatives: ["Nothing durable yet", "Local data only", "Shared durable data with a recovery expectation"],
|
|
118
|
+
selectMode: "one",
|
|
103
119
|
ifDeferred: "Entities are invented per feature and the same fact ends up stored in two places.",
|
|
104
120
|
},
|
|
105
121
|
{
|
|
@@ -109,6 +125,9 @@ const MATERIAL = [
|
|
|
109
125
|
"Every integration adds an authentication approach, a failure behavior and an environment to configure. Discovering one late reopens the architecture.",
|
|
110
126
|
recommendation: "List the services you already know about, even the obvious ones. An empty list is a fine answer and is recorded as one.",
|
|
111
127
|
alternatives: ["None", "Payment or billing", "An email or messaging provider", "A model or inference provider"],
|
|
128
|
+
// Several of these are true at once on most products, and a question that
|
|
129
|
+
// forces one answer gets a wrong one rather than an incomplete one.
|
|
130
|
+
selectMode: "many",
|
|
112
131
|
ifDeferred: "Integration failure behavior is invented at the moment of the first outage.",
|
|
113
132
|
},
|
|
114
133
|
{
|
|
@@ -118,6 +137,9 @@ const MATERIAL = [
|
|
|
118
137
|
"Compliance is only specified when it is declared. Declaring it late means auditing work that is already shipped, which is the expensive direction.",
|
|
119
138
|
recommendation: "Answer no unless you can name the regime. A named regime turns retention, deletion and audit into required specification.",
|
|
120
139
|
alternatives: ["No regulated data", "Personal data under a privacy regime", "Payment or health data"],
|
|
140
|
+
selectMode: "many",
|
|
141
|
+
recommended: ["No regulated data"],
|
|
142
|
+
recommendedWhy: "Compliance is specified only when it is declared, and a regime nobody can name is not one. Naming one turns retention, deletion and audit into required specification, so this is worth answering yes to only deliberately.",
|
|
121
143
|
ifDeferred: "Retention and deletion are designed as convenience features and have to be rebuilt as obligations.",
|
|
122
144
|
},
|
|
123
145
|
{
|
|
@@ -127,6 +149,9 @@ const MATERIAL = [
|
|
|
127
149
|
"Superdev refuses to store a credential anywhere, so it needs to know where secrets do live before it can specify configuration honestly.",
|
|
128
150
|
recommendation: "Name the environments you will really have. Two is usually right; one is a valid answer for a local tool.",
|
|
129
151
|
alternatives: ["Local only", "Local and production", "Local, staging and production"],
|
|
152
|
+
selectMode: "one",
|
|
153
|
+
recommended: ["Local and production"],
|
|
154
|
+
recommendedWhy: "Two environments is what most products actually have, and configuration specified against environments that do not exist diverges from the real ones. One is a valid answer for a local tool.",
|
|
130
155
|
ifDeferred: "Configuration is specified per feature and diverges between environments before anybody notices.",
|
|
131
156
|
},
|
|
132
157
|
];
|
|
@@ -397,7 +422,7 @@ export async function materialQuestions(db, projectId) {
|
|
|
397
422
|
const area = byArea.get(entry.area);
|
|
398
423
|
// No seeded row yet means discovery has not run; the question still applies.
|
|
399
424
|
if (area && area.state !== "awaiting_decision") return false;
|
|
400
|
-
if (entry.
|
|
425
|
+
if (ALREADY_ANSWERED[entry.area]?.(project)) return false;
|
|
401
426
|
return true;
|
|
402
427
|
});
|
|
403
428
|
|
|
@@ -412,6 +437,76 @@ export async function materialQuestions(db, projectId) {
|
|
|
412
437
|
* `planInit` prints the exact questions before a project row exists, so the
|
|
413
438
|
* wording has to be derivable from the area name alone.
|
|
414
439
|
*/
|
|
440
|
+
/**
|
|
441
|
+
* Material areas the project already answers, and what answers them.
|
|
442
|
+
*
|
|
443
|
+
* This used to be one inline condition inside the question filter, and the same
|
|
444
|
+
* fact was not consulted when the area was seeded. So supplying a purpose
|
|
445
|
+
* statement suppressed the purpose question while leaving the area awaiting a
|
|
446
|
+
* decision, and readiness then reported, at high severity, that the product's
|
|
447
|
+
* purpose was awaiting a decision with nobody asked about it. To somebody who had
|
|
448
|
+
* just stated it. Neither remedy the warning named was a command that existed.
|
|
449
|
+
*
|
|
450
|
+
* Holding the rule in one place is what lets `settleAnsweredAreas` agree with the
|
|
451
|
+
* filter by construction rather than by both being edited together.
|
|
452
|
+
*/
|
|
453
|
+
const ALREADY_ANSWERED = {
|
|
454
|
+
[AREA.purpose]: (project) => Boolean(clean(project?.statement)),
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/** What the project's own record says, for an area that needs no question. */
|
|
458
|
+
const ANSWERED_BY = {
|
|
459
|
+
[AREA.purpose]: (project) => ({
|
|
460
|
+
choice: clean(project.statement),
|
|
461
|
+
evidence: "stated at initialization",
|
|
462
|
+
}),
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Settle every material area that needs no question because the project already
|
|
467
|
+
* answers it.
|
|
468
|
+
*
|
|
469
|
+
* Run after questions are raised, so "awaiting a decision with no question
|
|
470
|
+
* raised" is unreachable for these areas rather than merely unlikely. An area
|
|
471
|
+
* this cannot settle keeps its state and keeps its warning: the point is to close
|
|
472
|
+
* a hole, not to quieten the checklist.
|
|
473
|
+
*/
|
|
474
|
+
export async function settleAnsweredAreas(db, projectId, { at = nowIso(), actor = "superdev" } = {}) {
|
|
475
|
+
const project = await db.get("SELECT * FROM projects WHERE id = ?", projectId);
|
|
476
|
+
if (!project) return [];
|
|
477
|
+
const areas = await db.all(
|
|
478
|
+
`SELECT * FROM capability_areas
|
|
479
|
+
WHERE project_id = ? AND catalog = 'readiness'
|
|
480
|
+
AND state = 'awaiting_decision' AND question_id IS NULL`,
|
|
481
|
+
projectId,
|
|
482
|
+
);
|
|
483
|
+
const settled = [];
|
|
484
|
+
for (const area of areas) {
|
|
485
|
+
if (!ALREADY_ANSWERED[area.area]?.(project)) continue;
|
|
486
|
+
const answer = ANSWERED_BY[area.area]?.(project);
|
|
487
|
+
if (!answer?.choice) continue;
|
|
488
|
+
await patch(db, "capability_area", area.id, area.version, {
|
|
489
|
+
state: "specified",
|
|
490
|
+
choice: answer.choice,
|
|
491
|
+
evidence_ref: answer.evidence,
|
|
492
|
+
reason: null,
|
|
493
|
+
owner: null,
|
|
494
|
+
revisit_trigger: null,
|
|
495
|
+
consequence: null,
|
|
496
|
+
}, { projectId, activity: false, at });
|
|
497
|
+
settled.push({ id: area.id, area: area.area, evidence: answer.evidence });
|
|
498
|
+
}
|
|
499
|
+
if (settled.length) {
|
|
500
|
+
await recordActivity(db, projectId, {
|
|
501
|
+
type: "specification_changed",
|
|
502
|
+
actor,
|
|
503
|
+
summary: `${settled.length === 1 ? "1 capability area" : `${settled.length} capability areas`} settled from what the project already records`,
|
|
504
|
+
metadata: { areas: settled.map((a) => a.id) },
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return settled;
|
|
508
|
+
}
|
|
509
|
+
|
|
415
510
|
export function questionCatalog(areas, { project = null } = {}) {
|
|
416
511
|
const wanted = new Set(areas);
|
|
417
512
|
return MATERIAL.filter((entry) => wanted.has(entry.area)).map((entry) => ({
|
|
@@ -425,6 +520,11 @@ export function questionCatalog(areas, { project = null } = {}) {
|
|
|
425
520
|
whyItMatters: `${entry.whyItMatters} If this is deferred: ${entry.ifDeferred}`,
|
|
426
521
|
recommendation: entry.recommendation,
|
|
427
522
|
alternatives: entry.alternatives,
|
|
523
|
+
// How many of the options can be true at once, which options are recommended,
|
|
524
|
+
// and why. A question whose options nobody can select is a paragraph.
|
|
525
|
+
selectMode: entry.selectMode ?? "one",
|
|
526
|
+
recommended: entry.recommended ?? [],
|
|
527
|
+
recommendedWhy: entry.recommendedWhy ?? null,
|
|
428
528
|
ifDeferred: entry.ifDeferred,
|
|
429
529
|
revisitTrigger: REVISIT[entry.area] ?? "the next specification touches it",
|
|
430
530
|
projectId: project?.id ?? null,
|
|
@@ -463,12 +563,12 @@ export async function answerQuestion(root, questionId, opts = {}) {
|
|
|
463
563
|
question.project_id, questionId,
|
|
464
564
|
);
|
|
465
565
|
|
|
466
|
-
|
|
467
|
-
return recordAnswer(db, { question, area, answer, at, actor, answeredBy });
|
|
566
|
+
if (unknown) return recordAssumption(db, { question, area, opts, at, actor, answeredBy });
|
|
567
|
+
return recordAnswer(db, { question, area, answer, inOwnWords: clean(opts.inOwnWords), at, actor, answeredBy });
|
|
468
568
|
});
|
|
469
569
|
}
|
|
470
570
|
|
|
471
|
-
async function recordAnswer(db, { question, area, answer, at, actor, answeredBy }) {
|
|
571
|
+
async function recordAnswer(db, { question, area, answer, inOwnWords = null, at, actor, answeredBy }) {
|
|
472
572
|
const updated = await patch(db, "question", question.id, question.version, {
|
|
473
573
|
answer,
|
|
474
574
|
answered_by: answeredBy,
|
|
@@ -497,10 +597,20 @@ async function recordAnswer(db, { question, area, answer, at, actor, answeredBy
|
|
|
497
597
|
|
|
498
598
|
// The two project-level questions write straight through to the field they
|
|
499
599
|
// exist to fill, so an answer does not have to be copied by hand afterwards.
|
|
600
|
+
//
|
|
601
|
+
// What gets written through is the reader's own words when there are any. The
|
|
602
|
+
// purpose question's options are strategies for answering it ("give the outcome
|
|
603
|
+
// now and the measure once the first feature is real"), not the answer, so
|
|
604
|
+
// selecting one and writing the whole composed answer into projects.statement
|
|
605
|
+
// put advice where the product statement belongs. The question keeps the full
|
|
606
|
+
// answer, because what was chosen is part of the record; the field gets the
|
|
607
|
+
// sentence that was meant for it.
|
|
500
608
|
let project = null;
|
|
501
609
|
if (question.scope_type === "project" && ["statement", "problem"].includes(question.scope_id)) {
|
|
502
610
|
const current = await db.get("SELECT * FROM projects WHERE id = ?", question.project_id);
|
|
503
|
-
project = await patch(db, "project", current.id, current.version, {
|
|
611
|
+
project = await patch(db, "project", current.id, current.version, {
|
|
612
|
+
[question.scope_id]: inOwnWords ?? answer,
|
|
613
|
+
}, {
|
|
504
614
|
projectId: current.id, activity: false, at,
|
|
505
615
|
});
|
|
506
616
|
}
|