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.
@@ -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;
@@ -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
- reject(where, `No ${kind} field is projected from this section, so its edit cannot be stored.`);
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))) {
@@ -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) {
@@ -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
- warnings.push(`This repository already has documentation (profile ${detection.docsProfile}). Initialization is refused here; adoptProject leaves those files exactly where they are.`);
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 name = trim(item.statement.replace(/^Module candidate:\s*/, ""), 120);
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, item.statement, index, at);
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
- for (const [index, item] of featureCandidates.entries()) {
532
- const name = trim(item.statement, 120);
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: modules[index % modules.length].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")} and ${count(created.filter(Boolean).length, "milestone")} from the concept map`,
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
  }
@@ -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.scopeId === "statement" && clean(project.statement)) return false;
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
- if (unknown) return recordAssumption(db, { question, area, opts, at, actor, answeredBy });
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, { [question.scope_id]: answer }, {
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
  }