superdev-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CODE_OF_CONDUCT.md +109 -0
- package/CONTRIBUTING.md +205 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +1051 -0
- package/SECURITY.md +135 -0
- package/THIRD-PARTY-NOTICES.md +253 -0
- package/hooks/hooks.json +61 -0
- package/hooks/run.mjs +88 -0
- package/package.json +65 -0
- package/references/confidentiality.md +48 -0
- package/references/evidence-and-risk.md +73 -0
- package/references/operating-model.md +105 -0
- package/references/platform-capabilities.md +51 -0
- package/references/project-record.md +91 -0
- package/references/provider-contracts.md +102 -0
- package/scripts/doctor/doctor.mjs +539 -0
- package/scripts/privacy/scan-history.mjs +163 -0
- package/scripts/privacy/scan.mjs +368 -0
- package/scripts/validate/README.md +94 -0
- package/scripts/validate/common.mjs +149 -0
- package/scripts/validate/data-model.mjs +225 -0
- package/scripts/validate/dependencies.mjs +69 -0
- package/scripts/validate/docs-templates.mjs +99 -0
- package/scripts/validate/footprint.mjs +78 -0
- package/scripts/validate/imports.mjs +61 -0
- package/scripts/validate/manifests.mjs +173 -0
- package/scripts/validate/markdown.mjs +123 -0
- package/scripts/validate/migrations.mjs +187 -0
- package/scripts/validate/no-tests.mjs +136 -0
- package/scripts/validate/privacy.mjs +115 -0
- package/scripts/validate/record-links.mjs +127 -0
- package/scripts/validate/skill-commands.mjs +190 -0
- package/scripts/validate/skills.mjs +112 -0
- package/scripts/validate/specification.mjs +88 -0
- package/scripts/validate/style.mjs +78 -0
- package/scripts/validate/validate-all.mjs +175 -0
- package/skills/debug/SKILL.md +68 -0
- package/skills/decision/SKILL.md +99 -0
- package/skills/docs/SKILL.md +86 -0
- package/skills/docs/assets/fragments/api/events.md +14 -0
- package/skills/docs/assets/fragments/api/graphql.md +14 -0
- package/skills/docs/assets/fragments/api/local-only.md +13 -0
- package/skills/docs/assets/fragments/api/rest.md +13 -0
- package/skills/docs/assets/fragments/api/rpc.md +13 -0
- package/skills/docs/assets/fragments/async/event-bus.md +13 -0
- package/skills/docs/assets/fragments/async/none.md +11 -0
- package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
- package/skills/docs/assets/fragments/async/queue.md +13 -0
- package/skills/docs/assets/fragments/async/scheduler.md +13 -0
- package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
- package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
- package/skills/docs/assets/fragments/data/document.md +13 -0
- package/skills/docs/assets/fragments/data/external-saas.md +13 -0
- package/skills/docs/assets/fragments/data/key-value.md +13 -0
- package/skills/docs/assets/fragments/data/none.md +12 -0
- package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
- package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
- package/skills/docs/assets/fragments/env/conventional.md +13 -0
- package/skills/docs/assets/fragments/env/envx.md +13 -0
- package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
- package/skills/docs/assets/fragments/env/none.md +11 -0
- package/skills/docs/assets/fragments/ui/api-only.md +13 -0
- package/skills/docs/assets/fragments/ui/cli.md +14 -0
- package/skills/docs/assets/fragments/ui/desktop.md +14 -0
- package/skills/docs/assets/fragments/ui/mobile.md +14 -0
- package/skills/docs/assets/fragments/ui/web.md +14 -0
- package/skills/docs/assets/templates/adr.md +62 -0
- package/skills/docs/assets/templates/api.md +30 -0
- package/skills/docs/assets/templates/architecture.md +52 -0
- package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
- package/skills/docs/assets/templates/compliance.md +29 -0
- package/skills/docs/assets/templates/data-schema.md +43 -0
- package/skills/docs/assets/templates/feature.md +43 -0
- package/skills/docs/assets/templates/foundations.md +55 -0
- package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
- package/skills/docs/assets/templates/module-inventory.md +19 -0
- package/skills/docs/assets/templates/module.md +50 -0
- package/skills/docs/assets/templates/nfr.md +22 -0
- package/skills/docs/assets/templates/observability.md +28 -0
- package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
- package/skills/docs/assets/templates/project-summary.md +44 -0
- package/skills/docs/assets/templates/roles-permissions.md +26 -0
- package/skills/docs/assets/templates/test-plan.md +23 -0
- package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
- package/skills/docs/references/adr-authoring.md +24 -0
- package/skills/docs/references/apis-and-data.md +23 -0
- package/skills/docs/references/capability-fragments.md +25 -0
- package/skills/docs/references/change-tracking.md +62 -0
- package/skills/docs/references/diagrams.md +31 -0
- package/skills/docs/references/discovery.md +59 -0
- package/skills/docs/references/edge-cases.md +45 -0
- package/skills/docs/references/foundations-modules.md +18 -0
- package/skills/docs/references/ingestion.md +52 -0
- package/skills/docs/references/initialize-adopt.md +25 -0
- package/skills/docs/references/module-decomposition.md +38 -0
- package/skills/docs/references/profiles.md +60 -0
- package/skills/docs/references/quality-attributes.md +27 -0
- package/skills/docs/references/reverse-engineer.md +21 -0
- package/skills/docs/references/spec-depths.md +31 -0
- package/skills/docs/references/summarize.md +18 -0
- package/skills/docs/references/surfaces-and-actions.md +24 -0
- package/skills/docs/references/validation.md +47 -0
- package/skills/docs/references/workflows-and-jobs.md +25 -0
- package/skills/docs/scripts/ingest.mjs +984 -0
- package/skills/docs/scripts/profile-detect.mjs +299 -0
- package/skills/docs/scripts/screen.mjs +96 -0
- package/skills/docs/scripts/template-lint.mjs +143 -0
- package/skills/docs/scripts/validate-docs.mjs +363 -0
- package/skills/doctor/SKILL.md +167 -0
- package/skills/feature/SKILL.md +132 -0
- package/skills/init/SKILL.md +137 -0
- package/skills/project/SKILL.md +261 -0
- package/skills/project/references/commands.md +213 -0
- package/skills/resume/SKILL.md +79 -0
- package/skills/review/SKILL.md +91 -0
- package/skills/status/SKILL.md +85 -0
- package/skills/task/SKILL.md +183 -0
- package/src/cli/product-map.mjs +468 -0
- package/src/cli/render.mjs +544 -0
- package/src/cli.mjs +2774 -0
- package/src/cloud/crypto.mjs +118 -0
- package/src/cloud/merge.mjs +186 -0
- package/src/cloud/policy.mjs +115 -0
- package/src/cloud/sync.mjs +512 -0
- package/src/cloud/transport.mjs +116 -0
- package/src/db/connect.mjs +189 -0
- package/src/db/maintenance.mjs +419 -0
- package/src/db/migrate.mjs +188 -0
- package/src/db/migrations/001_initial.sql +1024 -0
- package/src/db/migrations/002_docs_coverage.sql +126 -0
- package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
- package/src/db/migrations/004_task_categories.sql +59 -0
- package/src/db/migrations/005_executable_evidence.sql +32 -0
- package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
- package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
- package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
- package/src/db/migrations/009_retired_documents.sql +54 -0
- package/src/db/migrations/010_test_plan_evidence.sql +20 -0
- package/src/db/migrations/011_criterion_waiver.sql +13 -0
- package/src/db/migrations/012_synchronization.sql +56 -0
- package/src/db/store.mjs +327 -0
- package/src/decisions/record.mjs +167 -0
- package/src/docs/proposals.mjs +871 -0
- package/src/docs/render.mjs +1424 -0
- package/src/docs/templates.mjs +1281 -0
- package/src/features/acceptance.mjs +315 -0
- package/src/features/specify.mjs +248 -0
- package/src/init/discovery.mjs +901 -0
- package/src/init/index.mjs +784 -0
- package/src/init/questions.mjs +562 -0
- package/src/memory/benchmark.mjs +171 -0
- package/src/memory/capture.mjs +135 -0
- package/src/memory/consolidate.mjs +255 -0
- package/src/memory/index.mjs +810 -0
- package/src/model/ids.mjs +185 -0
- package/src/model/screening.mjs +150 -0
- package/src/model/toolkit.mjs +258 -0
- package/src/model/vocabulary.mjs +190 -0
- package/src/product/assumptions.mjs +120 -0
- package/src/product/changes.mjs +180 -0
- package/src/product/test-plans.mjs +183 -0
- package/src/progress/index.mjs +1364 -0
- package/src/runtime/harness.mjs +264 -0
- package/src/runtime/hooks.mjs +1021 -0
- package/src/runtime/identity.mjs +305 -0
- package/src/runtime/resume.mjs +343 -0
- package/src/runtime/session.mjs +679 -0
- package/src/runtime/version.mjs +315 -0
- package/src/service/assets/control-center.html +206 -0
- package/src/service/assets/control-center.manifest.json +7 -0
- package/src/service/manage.mjs +542 -0
- package/src/service/mutations.mjs +860 -0
- package/src/service/read-model.mjs +1557 -0
- package/src/service/server.mjs +783 -0
- package/src/tasks/categories.mjs +154 -0
- package/src/tasks/derive.mjs +977 -0
- package/src/tasks/lifecycle.mjs +830 -0
- package/src/verify/index.mjs +236 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// The questions worth a person's attention, and the rows that make a gap
|
|
2
|
+
// queryable instead of forgettable.
|
|
3
|
+
//
|
|
4
|
+
// Two rules shape this file. First, a question is only material when the answer
|
|
5
|
+
// changes what gets built; everything else gets a recommended default recorded
|
|
6
|
+
// as a deferral with an owner and a revisit trigger, which is the brief's fourth
|
|
7
|
+
// capability state and the reason a silent gap cannot happen here. Second,
|
|
8
|
+
// "I do not know" is a legitimate answer: it becomes a reversible assumption and
|
|
9
|
+
// the question stays open, because a forced guess recorded as a decision is
|
|
10
|
+
// worse than an honest blank.
|
|
11
|
+
|
|
12
|
+
import { create, mutate, patch, recordActivity, setStatus, DbError } from "../db/store.mjs";
|
|
13
|
+
import { sanitizeExternal } from "../model/screening.mjs";
|
|
14
|
+
import { CAPABILITY_AREAS, MODULE_STEPS, SYSTEM_TASK_CATEGORIES, TASK_CATEGORY_MEANINGS, count } from "../model/vocabulary.mjs";
|
|
15
|
+
import { slugify } from "../model/ids.mjs";
|
|
16
|
+
import { AREA } from "./discovery.mjs";
|
|
17
|
+
|
|
18
|
+
const nowIso = () => new Date().toISOString();
|
|
19
|
+
|
|
20
|
+
const clean = (text) => {
|
|
21
|
+
if (text === null || text === undefined) return null;
|
|
22
|
+
const out = sanitizeExternal(String(text)).trim();
|
|
23
|
+
return out || null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- materiality
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The areas where the answer changes the product rather than the process. These
|
|
30
|
+
* are the only ones allowed to become a question, which is what keeps the set
|
|
31
|
+
* small enough that somebody actually reads it.
|
|
32
|
+
*/
|
|
33
|
+
const MATERIAL = [
|
|
34
|
+
{
|
|
35
|
+
area: AREA.purpose,
|
|
36
|
+
scopeId: "statement",
|
|
37
|
+
question: "In one sentence, what is this product for, and what would prove it worked?",
|
|
38
|
+
whyItMatters:
|
|
39
|
+
"Every goal, milestone and acceptance criterion is measured against this. Without it Superdev can record that work happened but never that it mattered.",
|
|
40
|
+
recommendation: "State the outcome in one sentence and name one thing you could observe that would prove it.",
|
|
41
|
+
alternatives: [
|
|
42
|
+
"Give the outcome now and the measure once the first feature is real",
|
|
43
|
+
"Declare the project exploratory, with no success measure yet",
|
|
44
|
+
],
|
|
45
|
+
ifDeferred: "Goals and milestones stay unmeasurable and readiness can never report better than unknown.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
area: AREA.users,
|
|
49
|
+
question: "Who uses this, and does any of them see something the others must not?",
|
|
50
|
+
whyItMatters:
|
|
51
|
+
"Roles decide permissions, permissions decide enforcement points, and a role discovered late usually means reworking every surface and every query at once.",
|
|
52
|
+
recommendation: "Name one role now. Say plainly whether the data is shared by everybody or separated per account.",
|
|
53
|
+
alternatives: [
|
|
54
|
+
"One role, all data shared",
|
|
55
|
+
"Several roles, data shared",
|
|
56
|
+
"Several roles with per-account separation (multi-tenant)",
|
|
57
|
+
],
|
|
58
|
+
ifDeferred: "Authorization is designed after the data model, which is the most expensive order to do it in.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
area: AREA.frontend,
|
|
62
|
+
question: "What does a user actually touch: a web application, a command line, an API, or nothing yet?",
|
|
63
|
+
whyItMatters:
|
|
64
|
+
"The delivery shape decides whether surfaces, UI actions and accessibility are part of the specification at all, or deliberately not applicable.",
|
|
65
|
+
recommendation: "Name the one surface the first user will touch. A second one can be added when it exists.",
|
|
66
|
+
alternatives: ["A web application", "A command line tool", "An API or library only", "An existing host application"],
|
|
67
|
+
ifDeferred: "Surface and UI-action specifications cannot be started, so a whole layer of the product has no home.",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
area: AREA.backend,
|
|
71
|
+
question: "Is there a server, and does it own data other people can see?",
|
|
72
|
+
whyItMatters:
|
|
73
|
+
"A shared server turns authentication, authorization and data ownership from optional into mandatory, and turns a local tool into an operated service.",
|
|
74
|
+
recommendation: "Say whether anything runs outside the user's own machine. If nothing does, several capability areas become genuinely not applicable.",
|
|
75
|
+
alternatives: ["Local only", "A single service", "Several services with separate ownership"],
|
|
76
|
+
ifDeferred: "Backend boundaries, authentication and operational response are all left undecided together.",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
area: AREA.api,
|
|
80
|
+
question: "Does anything outside your control call this product?",
|
|
81
|
+
whyItMatters:
|
|
82
|
+
"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
|
+
recommendation: "Assume no external caller until one exists. Declare it the moment one does.",
|
|
84
|
+
alternatives: ["No external caller", "An internal client only", "A published, versioned public API"],
|
|
85
|
+
ifDeferred: "An internal shape becomes a public contract by accident, which is the most expensive kind of accident.",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
area: AREA.auth,
|
|
89
|
+
question: "Does a user sign in, and who issues the identity?",
|
|
90
|
+
whyItMatters:
|
|
91
|
+
"Sessions, tokens and the enforcement point for every permission hang off this answer, and retrofitting identity touches every route.",
|
|
92
|
+
recommendation: "If anything is per-user or private, decide identity now rather than after the first data model.",
|
|
93
|
+
alternatives: ["No sign in", "Identity from an existing provider", "Identity issued by this product"],
|
|
94
|
+
ifDeferred: "Every permission question gets deferred with it, and the data model is designed without an owner column.",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
area: AREA.database,
|
|
98
|
+
question: "What data does this own, and what would it cost to lose it?",
|
|
99
|
+
whyItMatters:
|
|
100
|
+
"Data ownership decides the store, the retention rules, the backup posture and whether deletion is a real feature or a database statement.",
|
|
101
|
+
recommendation: "Name the one or two things the product is the authority on. Say whether losing them is an inconvenience or a catastrophe.",
|
|
102
|
+
alternatives: ["Nothing durable yet", "Local data only", "Shared durable data with a recovery expectation"],
|
|
103
|
+
ifDeferred: "Entities are invented per feature and the same fact ends up stored in two places.",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
area: AREA.integrations,
|
|
107
|
+
question: "Which outside services must this work with?",
|
|
108
|
+
whyItMatters:
|
|
109
|
+
"Every integration adds an authentication approach, a failure behavior and an environment to configure. Discovering one late reopens the architecture.",
|
|
110
|
+
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
|
+
alternatives: ["None", "Payment or billing", "An email or messaging provider", "A model or inference provider"],
|
|
112
|
+
ifDeferred: "Integration failure behavior is invented at the moment of the first outage.",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
area: AREA.compliance,
|
|
116
|
+
question: "Does any regulated regime apply to the data this handles?",
|
|
117
|
+
whyItMatters:
|
|
118
|
+
"Compliance is only specified when it is declared. Declaring it late means auditing work that is already shipped, which is the expensive direction.",
|
|
119
|
+
recommendation: "Answer no unless you can name the regime. A named regime turns retention, deletion and audit into required specification.",
|
|
120
|
+
alternatives: ["No regulated data", "Personal data under a privacy regime", "Payment or health data"],
|
|
121
|
+
ifDeferred: "Retention and deletion are designed as convenience features and have to be rebuilt as obligations.",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
area: AREA.environments,
|
|
125
|
+
question: "Where does this run, and how are its secrets handled?",
|
|
126
|
+
whyItMatters:
|
|
127
|
+
"Superdev refuses to store a credential anywhere, so it needs to know where secrets do live before it can specify configuration honestly.",
|
|
128
|
+
recommendation: "Name the environments you will really have. Two is usually right; one is a valid answer for a local tool.",
|
|
129
|
+
alternatives: ["Local only", "Local and production", "Local, staging and production"],
|
|
130
|
+
ifDeferred: "Configuration is specified per feature and diverges between environments before anybody notices.",
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
export const MATERIAL_AREAS = MATERIAL.map((m) => m.area);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* When each deferred area has to be looked at again. A deferral without a
|
|
138
|
+
* trigger is just a gap with better manners, so every area has one.
|
|
139
|
+
*/
|
|
140
|
+
const REVISIT = {
|
|
141
|
+
[AREA.purpose]: "the first milestone is defined",
|
|
142
|
+
[AREA.users]: "a second kind of user appears",
|
|
143
|
+
[AREA.frontend]: "the first user-facing surface is specified",
|
|
144
|
+
[AREA.navigation]: "a second surface is added",
|
|
145
|
+
[AREA.design]: "the first surface is built",
|
|
146
|
+
[AREA.backend]: "anything runs outside the user's own machine",
|
|
147
|
+
[AREA.api]: "a caller outside this repository appears",
|
|
148
|
+
[AREA.auth]: "anything becomes private to one user",
|
|
149
|
+
[AREA.authz]: "a second role appears",
|
|
150
|
+
[AREA.database]: "the first durable record is stored",
|
|
151
|
+
[AREA.migrations]: "a stored shape changes after release",
|
|
152
|
+
[AREA.storage]: "a user uploads a file",
|
|
153
|
+
[AREA.search]: "a list grows past what a person will scroll",
|
|
154
|
+
[AREA.realtime]: "two people need to see the same change at once",
|
|
155
|
+
[AREA.offline]: "the product is used where the network is not guaranteed",
|
|
156
|
+
[AREA.jobs]: "work has to happen without somebody waiting for it",
|
|
157
|
+
[AREA.events]: "another system needs to react to a change here",
|
|
158
|
+
[AREA.integrations]: "the first outside service is added",
|
|
159
|
+
[AREA.notifications]: "a user has to be told something while away",
|
|
160
|
+
[AREA.rateLimit]: "an endpoint becomes reachable by the public",
|
|
161
|
+
[AREA.security]: "the first personal or private field is stored",
|
|
162
|
+
[AREA.compliance]: "a regulated regime is declared",
|
|
163
|
+
[AREA.observability]: "the product runs somewhere you cannot watch",
|
|
164
|
+
[AREA.performance]: "a user complains about waiting, or a target is agreed",
|
|
165
|
+
[AREA.environments]: "a second environment exists",
|
|
166
|
+
[AREA.ci]: "a second person or agent commits to this repository",
|
|
167
|
+
[AREA.infrastructure]: "the product is deployed anywhere",
|
|
168
|
+
[AREA.backups]: "losing the data would matter",
|
|
169
|
+
[AREA.analytics]: "a product decision needs usage evidence and consent is settled",
|
|
170
|
+
[AREA.testing]: "a regression costs more than the test would have",
|
|
171
|
+
[AREA.release]: "a release reaches somebody other than its author",
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** Technology slots for foundations/stack.md, and the area whose evidence fills them. */
|
|
175
|
+
export const STACK_SLOTS = [
|
|
176
|
+
["Language and runtime", null],
|
|
177
|
+
["Frontend framework", AREA.frontend],
|
|
178
|
+
["Navigation and routing", AREA.navigation],
|
|
179
|
+
["Styling and design system", AREA.design],
|
|
180
|
+
["Backend framework", AREA.backend],
|
|
181
|
+
["API style", AREA.api],
|
|
182
|
+
["Authentication", AREA.auth],
|
|
183
|
+
["Database", AREA.database],
|
|
184
|
+
["Migrations", AREA.migrations],
|
|
185
|
+
["File and object storage", AREA.storage],
|
|
186
|
+
["Search", AREA.search],
|
|
187
|
+
["Realtime transport", AREA.realtime],
|
|
188
|
+
["Background jobs", AREA.jobs],
|
|
189
|
+
["Notifications and delivery", AREA.notifications],
|
|
190
|
+
["Observability", AREA.observability],
|
|
191
|
+
["Testing", AREA.testing],
|
|
192
|
+
["Hosting and deployment", AREA.infrastructure],
|
|
193
|
+
["Continuous integration", AREA.ci],
|
|
194
|
+
["Secret management", AREA.environments],
|
|
195
|
+
["Product analytics", AREA.analytics],
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
const NOT_EVALUATED = "Not evaluated yet. Discovery has not reached this area.";
|
|
199
|
+
|
|
200
|
+
const consequenceFor = (area) =>
|
|
201
|
+
`Nothing in the specification covers ${lower(area)} until then, so the first feature that needs it stops at the gap.`;
|
|
202
|
+
|
|
203
|
+
const lower = (area) => `${area.charAt(0).toLowerCase()}${area.slice(1)}`;
|
|
204
|
+
|
|
205
|
+
// ------------------------------------------------------------------- seeding
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One row per capability area, plus the stack slots foundations renders from.
|
|
209
|
+
*
|
|
210
|
+
* Seeding all of them is the point: an area nobody thought about is a row in
|
|
211
|
+
* `awaiting_decision` or `deferred` that any query can find, rather than a
|
|
212
|
+
* judgement call nobody made. Pass `evidence` from `inspectEvidence` and the
|
|
213
|
+
* areas the repository already answers are seeded as specified, with the path
|
|
214
|
+
* that answered them.
|
|
215
|
+
*/
|
|
216
|
+
export async function seedCapabilityAreas(db, projectId, opts = {}) {
|
|
217
|
+
const at = opts.at ?? nowIso();
|
|
218
|
+
const answers = opts.evidence?.answers ?? {};
|
|
219
|
+
const owner = clean(opts.owner) ?? "project owner";
|
|
220
|
+
const created = [];
|
|
221
|
+
const skipped = [];
|
|
222
|
+
|
|
223
|
+
for (const [index, area] of CAPABILITY_AREAS.entries()) {
|
|
224
|
+
const existing = await findArea(db, projectId, "readiness", area);
|
|
225
|
+
if (existing) {
|
|
226
|
+
skipped.push(existing);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const answer = answers[area];
|
|
230
|
+
const values = answer
|
|
231
|
+
? { state: "specified", choice: clean(answer.what), evidence_ref: clean(answer.evidence), reason: null, owner: null, revisit_trigger: null, consequence: null }
|
|
232
|
+
: MATERIAL_AREAS.includes(area)
|
|
233
|
+
? {
|
|
234
|
+
state: "awaiting_decision",
|
|
235
|
+
reason: "Material to what gets built, and nothing in the repository answers it.",
|
|
236
|
+
owner,
|
|
237
|
+
revisit_trigger: REVISIT[area] ?? "the next specification touches it",
|
|
238
|
+
consequence: consequenceFor(area),
|
|
239
|
+
}
|
|
240
|
+
: {
|
|
241
|
+
state: "deferred",
|
|
242
|
+
reason: "Not needed by the first working slice, and nothing in the repository answers it.",
|
|
243
|
+
owner,
|
|
244
|
+
revisit_trigger: REVISIT[area] ?? "the next specification touches it",
|
|
245
|
+
consequence: consequenceFor(area),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
created.push(await create(db, "capability_area", {
|
|
249
|
+
project_id: projectId,
|
|
250
|
+
catalog: "readiness",
|
|
251
|
+
scope_type: "project",
|
|
252
|
+
scope_id: null,
|
|
253
|
+
area,
|
|
254
|
+
sequence: index,
|
|
255
|
+
updated_at: at,
|
|
256
|
+
...values,
|
|
257
|
+
}, { projectId, activity: false }));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const stackCreated = [];
|
|
261
|
+
for (const [index, [slot, area]] of STACK_SLOTS.entries()) {
|
|
262
|
+
const existing = await findArea(db, projectId, "stack_slot", slot);
|
|
263
|
+
if (existing) {
|
|
264
|
+
skipped.push(existing);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const answer = area ? answers[area] : stackChoice(opts.evidence);
|
|
268
|
+
stackCreated.push(await create(db, "capability_area", {
|
|
269
|
+
project_id: projectId,
|
|
270
|
+
catalog: "stack_slot",
|
|
271
|
+
scope_type: "project",
|
|
272
|
+
scope_id: null,
|
|
273
|
+
area: slot,
|
|
274
|
+
state: answer ? "specified" : "awaiting_decision",
|
|
275
|
+
choice: answer ? clean(answer.what) : null,
|
|
276
|
+
evidence_ref: answer ? clean(answer.evidence) : null,
|
|
277
|
+
reason: answer ? null : "No choice is recorded and none is implied by the repository.",
|
|
278
|
+
owner: answer ? null : owner,
|
|
279
|
+
revisit_trigger: answer ? null : "this slot is needed by a feature",
|
|
280
|
+
consequence: answer ? null : `An unfilled ${lower(slot)} slot becomes an accidental choice made by whoever writes the first line of code.`,
|
|
281
|
+
sequence: index,
|
|
282
|
+
updated_at: at,
|
|
283
|
+
}, { projectId, activity: false }));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// One event for the whole seed. Thirty-one rows are one act of setup, not
|
|
287
|
+
// thirty-one things a person wants to read in an activity feed.
|
|
288
|
+
if (created.length || stackCreated.length) {
|
|
289
|
+
await recordActivity(db, projectId, {
|
|
290
|
+
type: "project_initialized",
|
|
291
|
+
summary: `Seeded ${count(created.length, "capability area")} and ${count(stackCreated.length, "stack slot")}`,
|
|
292
|
+
actor: opts.actor ?? "superdev",
|
|
293
|
+
metadata: { areas: created.length, stackSlots: stackCreated.length, specified: created.filter((r) => r.state === "specified").length },
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return { areas: created, stackSlots: stackCreated, alreadyPresent: skipped.length };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const findArea = (db, projectId, catalog, area) =>
|
|
300
|
+
// scope_id is null here and SQLite treats nulls as distinct in a unique
|
|
301
|
+
// index, so the uniqueness rule has to be checked rather than relied on.
|
|
302
|
+
db.get(
|
|
303
|
+
"SELECT * FROM capability_areas WHERE project_id = ? AND catalog = ? AND area = ? AND scope_id IS NULL",
|
|
304
|
+
projectId, catalog, area,
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const stackChoice = (evidence) =>
|
|
308
|
+
evidence?.stacks?.length
|
|
309
|
+
? { what: evidence.stacks.join(", "), evidence: evidence.findings?.find((f) => f.epistemic === "confirmed")?.evidence ?? null }
|
|
310
|
+
: null;
|
|
311
|
+
|
|
312
|
+
/** The system task categories, seeded so a project can add its own alongside. */
|
|
313
|
+
export async function seedTaskCategories(db, projectId, opts = {}) {
|
|
314
|
+
const created = [];
|
|
315
|
+
let alreadyPresent = 0;
|
|
316
|
+
for (const [index, name] of SYSTEM_TASK_CATEGORIES.entries()) {
|
|
317
|
+
const existing = await db.get("SELECT * FROM task_categories WHERE project_id = ? AND name = ?", projectId, name);
|
|
318
|
+
if (existing) {
|
|
319
|
+
alreadyPresent += 1;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
created.push(await create(db, "task_category", {
|
|
323
|
+
project_id: projectId,
|
|
324
|
+
name,
|
|
325
|
+
color_token: `category-${slugify(name)}`,
|
|
326
|
+
// A category that cannot say what it means gets read differently by
|
|
327
|
+
// everyone who picks it.
|
|
328
|
+
description: TASK_CATEGORY_MEANINGS[name] ?? null,
|
|
329
|
+
system: 1,
|
|
330
|
+
active: 1,
|
|
331
|
+
sequence: index,
|
|
332
|
+
}, { projectId, activity: false }));
|
|
333
|
+
}
|
|
334
|
+
if (created.length) {
|
|
335
|
+
await recordActivity(db, projectId, {
|
|
336
|
+
type: "project_initialized",
|
|
337
|
+
summary: `Seeded ${created.length} task categories`,
|
|
338
|
+
actor: opts.actor ?? "superdev",
|
|
339
|
+
metadata: { categories: created.map((c) => c.name) },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return { created, alreadyPresent };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The twenty-step module loop, as twenty rows that start open.
|
|
347
|
+
*
|
|
348
|
+
* A step is closed by filling it or by marking it not applicable with a reason;
|
|
349
|
+
* the schema refuses the second without the reason, which is what stops "not
|
|
350
|
+
* applicable" from becoming a way to make a step disappear.
|
|
351
|
+
*/
|
|
352
|
+
export async function seedModuleCompleteness(db, moduleId, opts = {}) {
|
|
353
|
+
const at = opts.at ?? nowIso();
|
|
354
|
+
const created = [];
|
|
355
|
+
let alreadyPresent = 0;
|
|
356
|
+
for (const [index, name] of MODULE_STEPS.entries()) {
|
|
357
|
+
const step = index + 1;
|
|
358
|
+
const existing = await db.get("SELECT * FROM module_completeness WHERE module_id = ? AND step = ?", moduleId, step);
|
|
359
|
+
if (existing) {
|
|
360
|
+
alreadyPresent += 1;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
created.push(await create(db, "module_completeness", {
|
|
364
|
+
module_id: moduleId,
|
|
365
|
+
step,
|
|
366
|
+
step_name: name,
|
|
367
|
+
state: "open",
|
|
368
|
+
updated_at: at,
|
|
369
|
+
}, { activity: false }));
|
|
370
|
+
}
|
|
371
|
+
return { created, alreadyPresent };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------- materialQuestions
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The smallest set of questions whose answers actually change the product.
|
|
378
|
+
*
|
|
379
|
+
* An area the repository already answered is not asked about. An area already
|
|
380
|
+
* asked about is not asked again. What is left is the honest remainder, and it
|
|
381
|
+
* is usually under ten questions.
|
|
382
|
+
*/
|
|
383
|
+
export async function materialQuestions(db, projectId) {
|
|
384
|
+
const project = await db.get("SELECT * FROM projects WHERE id = ?", projectId);
|
|
385
|
+
if (!project) throw new DbError("E_NOT_FOUND", `Project ${projectId} does not exist.`);
|
|
386
|
+
|
|
387
|
+
const areas = await db.all(
|
|
388
|
+
"SELECT * FROM capability_areas WHERE project_id = ? AND catalog = 'readiness'",
|
|
389
|
+
projectId,
|
|
390
|
+
);
|
|
391
|
+
const byArea = new Map(areas.map((a) => [a.area, a]));
|
|
392
|
+
const asked = new Set(
|
|
393
|
+
(await db.all("SELECT question FROM questions WHERE project_id = ?", projectId)).map((q) => q.question),
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const open = MATERIAL.filter((entry) => {
|
|
397
|
+
const area = byArea.get(entry.area);
|
|
398
|
+
// No seeded row yet means discovery has not run; the question still applies.
|
|
399
|
+
if (area && area.state !== "awaiting_decision") return false;
|
|
400
|
+
if (entry.scopeId === "statement" && clean(project.statement)) return false;
|
|
401
|
+
return true;
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
return questionCatalog(open.map((entry) => entry.area), { project })
|
|
405
|
+
.filter((descriptor) => !asked.has(descriptor.question))
|
|
406
|
+
.map((descriptor) => ({ ...descriptor, areaId: byArea.get(descriptor.area)?.id ?? null }));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The question descriptors for a set of areas, with no database involved.
|
|
411
|
+
*
|
|
412
|
+
* `planInit` prints the exact questions before a project row exists, so the
|
|
413
|
+
* wording has to be derivable from the area name alone.
|
|
414
|
+
*/
|
|
415
|
+
export function questionCatalog(areas, { project = null } = {}) {
|
|
416
|
+
const wanted = new Set(areas);
|
|
417
|
+
return MATERIAL.filter((entry) => wanted.has(entry.area)).map((entry) => ({
|
|
418
|
+
area: entry.area,
|
|
419
|
+
scopeType: "project",
|
|
420
|
+
scopeId: entry.scopeId ?? null,
|
|
421
|
+
question: entry.question,
|
|
422
|
+
// The deferral consequence rides along in why_it_matters because a question
|
|
423
|
+
// read on its own, without joining its capability area, still has to say
|
|
424
|
+
// what happens if it is skipped.
|
|
425
|
+
whyItMatters: `${entry.whyItMatters} If this is deferred: ${entry.ifDeferred}`,
|
|
426
|
+
recommendation: entry.recommendation,
|
|
427
|
+
alternatives: entry.alternatives,
|
|
428
|
+
ifDeferred: entry.ifDeferred,
|
|
429
|
+
revisitTrigger: REVISIT[entry.area] ?? "the next specification touches it",
|
|
430
|
+
projectId: project?.id ?? null,
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ------------------------------------------------------------- answerQuestion
|
|
435
|
+
|
|
436
|
+
const DONT_KNOW =
|
|
437
|
+
/^(i\s+(do\s+not|don'?t)\s+know|idk|dunno|not\s+sure|unsure|no\s+idea|unknown|no\s+opinion|don'?t\s+care)\b/i;
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Record an answer, or record the honest absence of one.
|
|
441
|
+
*
|
|
442
|
+
* "I do not know" is not refused and is not turned into a decision. It becomes
|
|
443
|
+
* a reversible assumption carrying the recommended default, the question stays
|
|
444
|
+
* open, and the capability area moves to deferred with an owner and a revisit
|
|
445
|
+
* trigger. That way the project keeps moving without anybody later mistaking a
|
|
446
|
+
* guess for something that was actually decided.
|
|
447
|
+
*/
|
|
448
|
+
export async function answerQuestion(root, questionId, opts = {}) {
|
|
449
|
+
const at = opts.at ?? nowIso();
|
|
450
|
+
const actor = clean(opts.actor) ?? "superdev";
|
|
451
|
+
const answeredBy = clean(opts.answeredBy) ?? actor;
|
|
452
|
+
const answer = clean(opts.answer);
|
|
453
|
+
const unknown = opts.unknown === true || !answer || DONT_KNOW.test(answer);
|
|
454
|
+
|
|
455
|
+
return mutate(root, async (db) => {
|
|
456
|
+
const question = await db.get("SELECT * FROM questions WHERE id = ?", questionId);
|
|
457
|
+
if (!question) throw new DbError("E_NOT_FOUND", `Question ${questionId} does not exist.`);
|
|
458
|
+
if (question.status === "answered" && !opts.reopen) {
|
|
459
|
+
return { question, changed: false, reason: "This question is already answered. Pass reopen to replace the answer." };
|
|
460
|
+
}
|
|
461
|
+
const area = await db.get(
|
|
462
|
+
"SELECT * FROM capability_areas WHERE project_id = ? AND question_id = ?",
|
|
463
|
+
question.project_id, questionId,
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
if (unknown) return recordAssumption(db, { question, area, opts, at, actor, answeredBy });
|
|
467
|
+
return recordAnswer(db, { question, area, answer, at, actor, answeredBy });
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function recordAnswer(db, { question, area, answer, at, actor, answeredBy }) {
|
|
472
|
+
const updated = await patch(db, "question", question.id, question.version, {
|
|
473
|
+
answer,
|
|
474
|
+
answered_by: answeredBy,
|
|
475
|
+
answered_at: at,
|
|
476
|
+
deferral_reason: null,
|
|
477
|
+
}, { projectId: question.project_id, activity: false, at });
|
|
478
|
+
|
|
479
|
+
const answered = await setStatus(db, "question", question.id, "answered", {
|
|
480
|
+
projectId: question.project_id,
|
|
481
|
+
expectedVersion: updated.version,
|
|
482
|
+
actor,
|
|
483
|
+
note: answer,
|
|
484
|
+
at,
|
|
485
|
+
activityType: "specification_changed",
|
|
486
|
+
activitySummary: `Question ${question.id} answered: ${truncate(answer)}`,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
if (area) {
|
|
490
|
+
await patch(db, "capability_area", area.id, area.version, {
|
|
491
|
+
state: "specified",
|
|
492
|
+
choice: answer,
|
|
493
|
+
reason: null,
|
|
494
|
+
evidence_ref: `answered in ${question.id}`,
|
|
495
|
+
}, { projectId: question.project_id, activity: false, at });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The two project-level questions write straight through to the field they
|
|
499
|
+
// exist to fill, so an answer does not have to be copied by hand afterwards.
|
|
500
|
+
let project = null;
|
|
501
|
+
if (question.scope_type === "project" && ["statement", "problem"].includes(question.scope_id)) {
|
|
502
|
+
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 }, {
|
|
504
|
+
projectId: current.id, activity: false, at,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return { question: answered, area: area ? await reload(db, "capability_areas", area.id) : null, project, assumption: null, changed: true, deferred: false };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function recordAssumption(db, { question, area, opts, at, actor, answeredBy }) {
|
|
512
|
+
const revisit = clean(opts.revisitTrigger) ?? area?.revisit_trigger ?? "the next specification touches this";
|
|
513
|
+
const owner = clean(opts.owner) ?? area?.owner ?? answeredBy;
|
|
514
|
+
const statement = clean(question.recommendation)
|
|
515
|
+
?? `Proceeding without an answer to: ${question.question}`;
|
|
516
|
+
|
|
517
|
+
const assumption = await create(db, "discovery_item", {
|
|
518
|
+
project_id: question.project_id,
|
|
519
|
+
kind: "assumption",
|
|
520
|
+
statement,
|
|
521
|
+
epistemic_status: "assumed",
|
|
522
|
+
provenance: `recorded instead of an answer to ${question.id}; reversible, revisit when ${revisit}`,
|
|
523
|
+
status: "proposed",
|
|
524
|
+
created_at: at,
|
|
525
|
+
}, { projectId: question.project_id, activity: false });
|
|
526
|
+
|
|
527
|
+
// The question stays open. It has not been answered; it has been survived.
|
|
528
|
+
const updated = await patch(db, "question", question.id, question.version, {
|
|
529
|
+
deferral_reason: `No answer yet. Assumption ${assumption.id} applies in its place. Revisit when ${revisit}.`,
|
|
530
|
+
}, { projectId: question.project_id, activity: false, at });
|
|
531
|
+
|
|
532
|
+
if (area) {
|
|
533
|
+
await patch(db, "capability_area", area.id, area.version, {
|
|
534
|
+
state: "deferred",
|
|
535
|
+
reason: `No answer yet; assumption ${assumption.id} applies in its place.`,
|
|
536
|
+
owner,
|
|
537
|
+
revisit_trigger: revisit,
|
|
538
|
+
consequence: area.consequence ?? consequenceFor(area.area),
|
|
539
|
+
}, { projectId: question.project_id, activity: false, at });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
await recordActivity(db, question.project_id, {
|
|
543
|
+
type: "specification_changed",
|
|
544
|
+
summary: `Question ${question.id} left open with reversible assumption ${assumption.id}`,
|
|
545
|
+
actor,
|
|
546
|
+
metadata: { questionId: question.id, assumptionId: assumption.id, revisitTrigger: revisit, owner },
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
question: updated,
|
|
551
|
+
area: area ? await reload(db, "capability_areas", area.id) : null,
|
|
552
|
+
project: null,
|
|
553
|
+
assumption,
|
|
554
|
+
changed: true,
|
|
555
|
+
deferred: true,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const reload = (db, table, id) => db.get(`SELECT * FROM ${table} WHERE id = ?`, id);
|
|
560
|
+
|
|
561
|
+
const truncate = (text, max = 120) =>
|
|
562
|
+
String(text ?? "").length > max ? `${String(text).slice(0, max - 3)}...` : String(text ?? "");
|