superdev-cli 0.1.2 → 0.2.1

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.
@@ -5,7 +5,7 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Superdev - tell it what you want to build, and it keeps the product map, documentation, decisions and status in step while specialist providers do the specialist work.",
8
- "version": "0.1.2",
8
+ "version": "0.2.1",
9
9
  "homepage": "https://github.com/superdev-ai/superdev"
10
10
  },
11
11
  "plugins": [
@@ -13,7 +13,7 @@
13
13
  "name": "superdev",
14
14
  "source": "./",
15
15
  "description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence - plus a self-contained visual dashboard. Routes specialist work to external providers and reports their readiness truthfully.",
16
- "version": "0.1.2",
16
+ "version": "0.2.1",
17
17
  "author": {
18
18
  "name": "Rahul Retnan"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdev",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence, plus a self-contained visual dashboard. Routes specialist work to external providers and reports their readiness truthfully.",
5
5
  "author": {
6
6
  "name": "Rahul Retnan"
@@ -16,6 +16,6 @@
16
16
  "homepage": "https://github.com/superdev-ai/superdev",
17
17
  "repository": "https://github.com/superdev-ai/superdev",
18
18
  "requires": {
19
- "cli": "0.1.2"
19
+ "cli": "0.2.1"
20
20
  }
21
21
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdev",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence, plus a self-contained visual dashboard.",
5
5
  "author": {
6
6
  "name": "Rahul Retnan"
@@ -22,6 +22,6 @@
22
22
  "homepage": "https://github.com/superdev-ai/superdev",
23
23
  "repository": "https://github.com/superdev-ai/superdev",
24
24
  "requires": {
25
- "cli": "0.1.2"
25
+ "cli": "0.2.1"
26
26
  }
27
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdev-cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Local-first product-building control system for humans and coding agents.",
6
6
  "author": "Rahul Retnan",
package/src/cli.mjs CHANGED
@@ -39,7 +39,7 @@ class Refusal extends Error {
39
39
  // Flags that carry no value. Everything else must be given one, so that
40
40
  // `--reason --apply` fails loudly instead of silently recording "true" as the
41
41
  // reason a task is blocked.
42
- const BOOLEAN = new Set(["apply", "json", "help", "all", "enabling", "end", "reports", "partial", "adopt", "dryRun", "resolve", "version", "noUpdateCheck", "updateCheck", "entry", "remove"]);
42
+ const BOOLEAN = new Set(["apply", "json", "help", "all", "enabling", "end", "reports", "partial", "adopt", "dryRun", "resolve", "version", "noUpdateCheck", "updateCheck", "entry", "remove", "open"]);
43
43
 
44
44
  const camel = (name) => name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
45
45
 
@@ -82,6 +82,14 @@ function parseArgs(argv) {
82
82
 
83
83
  const asList = (value) => (value === undefined ? [] : [].concat(value).map(String));
84
84
 
85
+ /** A JSON column's value, or the fallback when it is absent or malformed. */
86
+ const json = (value, fallback) => {
87
+ try {
88
+ const parsed = JSON.parse(value ?? "null");
89
+ return parsed ?? fallback;
90
+ } catch { return fallback; }
91
+ };
92
+
85
93
  /** A path with its symlinks resolved, or the path itself when it cannot be. */
86
94
  const realpathOf = (path) => {
87
95
  try { return realpathSync(path); } catch { return path; }
@@ -188,6 +196,9 @@ Knowledge
188
196
  milestone update <id> Rename it, restate it, or move its target date
189
197
  module record Record a slice of the product
190
198
  module rename <id> Rename a module, or restate what it owns
199
+ capability list Readiness areas and stack slots, and what settled each
200
+ capability specify <id> Record what was chosen for an area
201
+ capability not-applicable <id> Record that an area does not apply, with why
191
202
  scope record Record what the product will not do, and why
192
203
  scope list What is in scope, out of scope, and a non-goal
193
204
  scope remove <id> Take a scope line back out
@@ -231,6 +242,10 @@ Flags worth knowing
231
242
  feature specify --not <text>
232
243
  What the feature deliberately does not do. Repeatable.
233
244
  Its counterpart is --in, and --out stays global
245
+ ui | start --port <number>
246
+ Serve on this port instead of 4317. Every project defaults
247
+ to the same port, so the second one to start is refused
248
+ and needs this
234
249
 
235
250
  Exit codes
236
251
  0 it worked, 1 something was found or refused, 2 the command was misused`;
@@ -746,6 +761,23 @@ const service = () => import("./service/manage.mjs");
746
761
  // later rename of one field cannot silently make `ui` claim nothing is running.
747
762
  const isRunning = (report) => report?.state === "running" || report?.running === true;
748
763
 
764
+ /**
765
+ * The port to serve on, when the default one is taken.
766
+ *
767
+ * The refusal for a held port told the reader to start on another port and no
768
+ * command could. This is that flag, validated here rather than in two places:
769
+ * `ui` and `start` are the same decision made twice, and a port that is not a
770
+ * usable number has to be refused before a process is spawned against it.
771
+ */
772
+ function portFrom(ctx) {
773
+ if (ctx.flags.port === undefined) return null;
774
+ const value = Number(ctx.flags.port);
775
+ if (!Number.isInteger(value) || value < 1024 || value > 65535) {
776
+ throw new UsageError(`--port takes a whole number between 1024 and 65535. ${JSON.stringify(String(ctx.flags.port))} is not one.`);
777
+ }
778
+ return value;
779
+ }
780
+
749
781
  async function cmdUi(ctx) {
750
782
  const { serviceStatus, startService } = await service();
751
783
  const current = await serviceStatus(ctx.root);
@@ -758,21 +790,25 @@ async function cmdUi(ctx) {
758
790
  ]),
759
791
  };
760
792
  }
793
+ const port = portFrom(ctx);
761
794
  if (!ctx.apply) {
762
795
  return planned(current, "start the control center",
763
- R.wrap("The control center is not running. Starting it opens one local process for this project and listens on the loopback address only."));
796
+ R.wrap(`The control center is not running. Starting it opens one local process for this project and listens on the loopback address only${port ? `, on port ${port}` : ""}.`));
764
797
  }
765
- const started = await startService(ctx.root, { actor: ctx.actor });
798
+ const started = await startService(ctx.root, { actor: ctx.actor, ...(port ? { port } : {}) });
766
799
  return { data: { applied: true, service: started }, text: report(ctx, "Control center started", started) };
767
800
  }
768
801
 
769
802
  async function cmdStart(ctx) {
770
803
  const { startService, serviceStatus } = await service();
804
+ const port = portFrom(ctx);
771
805
  if (!ctx.apply) {
772
806
  return planned(await serviceStatus(ctx.root), "start the local service",
773
- "Starting the service opens one local process for this project.");
807
+ port
808
+ ? `Starting the service opens one local process for this project, on port ${port}.`
809
+ : "Starting the service opens one local process for this project.");
774
810
  }
775
- const started = await startService(ctx.root, { actor: ctx.actor });
811
+ const started = await startService(ctx.root, { actor: ctx.actor, ...(port ? { port } : {}) });
776
812
  return { data: { applied: true, service: started }, text: report(ctx, "Service started", started) };
777
813
  }
778
814
 
@@ -2325,41 +2361,63 @@ async function cmdMemorySearch(ctx) {
2325
2361
 
2326
2362
  // -------------------------------------------------------- questions, decisions
2327
2363
 
2364
+ /**
2365
+ * Answer a question, from its options or in your own words.
2366
+ *
2367
+ * This wrote the answer and set the status itself, which is a third
2368
+ * implementation of answering alongside the engine's and the control centre's.
2369
+ * The engine's is the complete one: it settles the capability area the question
2370
+ * belongs to, turns "I do not know" into a reversible assumption with a revisit
2371
+ * trigger rather than a decision, and writes a project-level answer through to the
2372
+ * field it exists to fill. This one did none of that, so the same question
2373
+ * answered here and answered there left the project in different states. Both
2374
+ * surfaces now call the engine.
2375
+ */
2328
2376
  async function cmdQuestionAnswer(ctx) {
2329
- const id = requireWord(ctx.words, 2, "Say which question to answer: superdev question answer <id> --answer <text>.");
2330
- const answer = ctx.flags.answer !== undefined
2331
- ? String(ctx.flags.answer)
2332
- : ctx.words.slice(3).join(" ");
2333
- if (!answer) throw new UsageError("An answer needs text. Pass --answer <text>.");
2377
+ const id = requireWord(ctx.words, 2, "Say which question to answer: superdev question answer <id> --answer <text> or --option <one of its options>.");
2378
+ const chosen = asList(ctx.flags.option);
2379
+ const typed = ctx.flags.answer !== undefined ? String(ctx.flags.answer) : ctx.words.slice(3).join(" ");
2380
+ if (!chosen.length && !typed) {
2381
+ throw new UsageError("An answer needs an option or some text. Pass --option <one of its options>, or --answer <text>. Run superdev question list to read the options.");
2382
+ }
2334
2383
 
2335
- const { query, mutate, patch, setStatus } = await store();
2384
+ const { query } = await store();
2336
2385
  const question = await query(ctx.root, (db) => db.get("SELECT * FROM questions WHERE id = ?", id));
2337
2386
  if (!question) throw new Refusal(`There is no question ${id}.`, "E_NOT_FOUND");
2338
2387
  if (question.status === "answered") {
2339
2388
  throw new Refusal(`${id} was already answered: ${question.answer}`, "E_ALREADY_ANSWERED");
2340
2389
  }
2341
2390
 
2391
+ const offered = json(question.alternatives_json, []);
2392
+ const unknown = chosen.filter((choice) => !offered.includes(choice));
2393
+ if (unknown.length) {
2394
+ throw new UsageError(`${id} does not offer ${unknown.map((u) => JSON.stringify(u)).join(", ")}. Its options are: ${offered.length ? offered.map((o) => JSON.stringify(o)).join(", ") : "none, so answer it in your own words with --answer"}.`);
2395
+ }
2396
+ if (question.select_mode !== "many" && chosen.length > 1) {
2397
+ throw new UsageError(`${id} takes one answer, and ${chosen.length} options were given. Pick one, or write what you mean with --answer.`);
2398
+ }
2399
+
2400
+ const answer = [chosen.join("; "), typed].filter(Boolean).join(". ");
2401
+
2342
2402
  if (!ctx.apply) {
2343
- return planned({ id, answer }, "record it", R.stitch([
2403
+ return planned({ id, answer, selected: chosen }, "record it", R.stitch([
2344
2404
  `Would answer ${id}: ${question.question}`,
2345
2405
  R.wrap(`Answer: ${answer}`, R.WIDTH, " "),
2346
2406
  question.recommendation ? R.wrap(`The recommendation on file was: ${question.recommendation}`, R.WIDTH, " ") : null,
2347
2407
  ]));
2348
2408
  }
2349
2409
 
2350
- const row = await mutate(ctx.root, async (db) => {
2351
- const current = await db.get("SELECT * FROM questions WHERE id = ?", id);
2352
- await patch(db, "question", id, current.version, {
2353
- answer,
2354
- answered_by: ctx.actor,
2355
- answered_at: nowIso(),
2356
- }, { projectId: current.project_id, actor: ctx.actor, activitySummary: `Answered ${id}: ${question.question}` });
2357
- return setStatus(db, "question", id, "answered", {
2358
- projectId: current.project_id, actor: ctx.actor, note: answer,
2359
- });
2410
+ const { answerQuestion } = await import("./init/questions.mjs");
2411
+ const out = await answerQuestion(ctx.root, id, {
2412
+ answer, inOwnWords: typed || null, actor: ctx.actor, answeredBy: ctx.actor,
2360
2413
  });
2361
-
2362
- return { data: { applied: true, question: row }, text: `${id} is answered.` };
2414
+ if (out.changed === false) throw new Refusal(out.reason, "E_ALREADY_ANSWERED");
2415
+ return {
2416
+ data: { applied: true, question: out.question ?? { id }, selected: chosen },
2417
+ text: R.wrap(out.area
2418
+ ? `${id} is answered, and ${out.area.id} ${out.area.area} is settled by it, which is what readiness counts.`
2419
+ : `${id} is answered.`),
2420
+ };
2363
2421
  }
2364
2422
 
2365
2423
  async function cmdDecisionList(ctx) {
@@ -2610,6 +2668,69 @@ async function cmdFeatureCreate(ctx) {
2610
2668
  };
2611
2669
  }
2612
2670
 
2671
+ async function cmdCapabilitySpecify(ctx) {
2672
+ const id = requireWord(ctx.words, 2, "Say which area: superdev capability specify <CAP-id> --choice \"<what was chosen>\".");
2673
+ const { settleCapabilityArea } = await import("./product/authoring.mjs");
2674
+ const out = await settleCapabilityArea(ctx.root, id, {
2675
+ choice: requireFlag(ctx.flags, "choice", "Say what was chosen for this area."),
2676
+ evidence: ctx.flags.evidence ?? null,
2677
+ actor: ctx.actor, apply: ctx.apply,
2678
+ });
2679
+ if (!out.applied) {
2680
+ return planned(out, "record it", R.wrap(`Would specify ${out.area}: ${out.choice}`));
2681
+ }
2682
+ return {
2683
+ data: out,
2684
+ text: R.wrap(`${id} ${out.area} is specified: "${out.choice}". It was ${R.status(out.was)}, and readiness now counts it as answered.`),
2685
+ };
2686
+ }
2687
+
2688
+ async function cmdCapabilityNotApplicable(ctx) {
2689
+ const id = requireWord(ctx.words, 2, "Say which area: superdev capability not-applicable <CAP-id> --reason \"<why>\".");
2690
+ const { settleCapabilityArea } = await import("./product/authoring.mjs");
2691
+ const out = await settleCapabilityArea(ctx.root, id, {
2692
+ notApplicable: true,
2693
+ reason: requireFlag(ctx.flags, "reason", "Say why this area does not apply to this product."),
2694
+ evidence: ctx.flags.evidence ?? null,
2695
+ actor: ctx.actor, apply: ctx.apply,
2696
+ });
2697
+ if (!out.applied) {
2698
+ return planned(out, "record it", R.wrap(`Would record ${out.area} as not applicable: ${out.reason}`));
2699
+ }
2700
+ return {
2701
+ data: out,
2702
+ // The reason is somebody's sentence and may not end in a full stop, so it is
2703
+ // quoted rather than run into the next sentence.
2704
+ text: R.wrap(`${id} ${out.area} is recorded as not applicable: "${out.reason}". A reason is required precisely so this stays different from an area nobody looked at.`),
2705
+ };
2706
+ }
2707
+
2708
+ async function cmdCapabilityList(ctx) {
2709
+ const { capabilityList } = await import("./product/authoring.mjs");
2710
+ const rows = await capabilityList(ctx.root, {
2711
+ catalog: ctx.flags.catalog ? String(ctx.flags.catalog) : null,
2712
+ unsettled: Boolean(ctx.flags.open),
2713
+ });
2714
+ if (!rows.length) {
2715
+ return { data: { areas: [] }, text: R.wrap("No capability area matches. Superdev seeds them during init.") };
2716
+ }
2717
+ return {
2718
+ data: { areas: rows },
2719
+ text: R.stitch([
2720
+ R.heading(`Capability areas (${rows.length})`),
2721
+ R.table(["Id", "Area", "State", "What settled it"],
2722
+ rows.map((a) => [
2723
+ a.id,
2724
+ a.area,
2725
+ R.status(a.state),
2726
+ a.choice ?? a.reason ?? "Nothing yet",
2727
+ ])),
2728
+ "",
2729
+ "Settle one with superdev capability specify <id> --choice, or capability not-applicable <id> --reason.",
2730
+ ]),
2731
+ };
2732
+ }
2733
+
2613
2734
  async function cmdScopeRecord(ctx) {
2614
2735
  const { recordScopeItem } = await import("./product/authoring.mjs");
2615
2736
  // --not is the same word here as in feature specify, and means the same thing.
@@ -3030,6 +3151,9 @@ const COMMANDS = {
3030
3151
  "feature goal": cmdFeatureGoal,
3031
3152
  "milestone update": cmdMilestoneUpdate,
3032
3153
  "module rename": cmdModuleRename,
3154
+ "capability list": cmdCapabilityList,
3155
+ "capability specify": cmdCapabilitySpecify,
3156
+ "capability not-applicable": cmdCapabilityNotApplicable,
3033
3157
  "scope record": cmdScopeRecord,
3034
3158
  "scope remove": cmdScopeRemove,
3035
3159
  "scope list": cmdScopeList,
@@ -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;
@@ -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
 
@@ -345,6 +346,9 @@ export async function applyInit(root, opts = {}) {
345
346
  why_it_matters: descriptor.whyItMatters,
346
347
  recommendation: descriptor.recommendation,
347
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,
348
352
  status: "open",
349
353
  created_at: stamp,
350
354
  }, { projectId: created.project.id, activity: false });
@@ -368,6 +372,11 @@ export async function applyInit(root, opts = {}) {
368
372
  metadata: { questions: rows.map((r) => r.id) },
369
373
  });
370
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 });
371
380
  return rows;
372
381
  });
373
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");
@@ -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
  }