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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +148 -24
- package/src/db/migrations/013_question_options.sql +28 -0
- package/src/init/index.mjs +9 -0
- package/src/init/questions.mjs +115 -5
- package/src/product/authoring.mjs +65 -0
- package/src/runtime/hooks.mjs +84 -5
- package/src/service/assets/control-center.html +62 -62
- package/src/service/assets/control-center.manifest.json +4 -4
- package/src/service/manage.mjs +42 -5
- package/src/service/mutations.mjs +78 -30
- package/src/tasks/lifecycle.mjs +45 -0
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
19
|
+
"cli": "0.2.1"
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdev",
|
|
3
|
-
"version": "0.1
|
|
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
|
|
25
|
+
"cli": "0.2.1"
|
|
26
26
|
}
|
|
27
27
|
}
|
package/package.json
CHANGED
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(
|
|
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
|
-
|
|
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
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
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
|
|
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
|
|
2351
|
-
|
|
2352
|
-
|
|
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 {
|
|
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;
|
package/src/init/index.mjs
CHANGED
|
@@ -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");
|
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
|
}
|