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.
Files changed (182) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/.codex-plugin/plugin.json +27 -0
  4. package/CODE_OF_CONDUCT.md +109 -0
  5. package/CONTRIBUTING.md +205 -0
  6. package/LICENSE +202 -0
  7. package/NOTICE +11 -0
  8. package/README.md +1051 -0
  9. package/SECURITY.md +135 -0
  10. package/THIRD-PARTY-NOTICES.md +253 -0
  11. package/hooks/hooks.json +61 -0
  12. package/hooks/run.mjs +88 -0
  13. package/package.json +65 -0
  14. package/references/confidentiality.md +48 -0
  15. package/references/evidence-and-risk.md +73 -0
  16. package/references/operating-model.md +105 -0
  17. package/references/platform-capabilities.md +51 -0
  18. package/references/project-record.md +91 -0
  19. package/references/provider-contracts.md +102 -0
  20. package/scripts/doctor/doctor.mjs +539 -0
  21. package/scripts/privacy/scan-history.mjs +163 -0
  22. package/scripts/privacy/scan.mjs +368 -0
  23. package/scripts/validate/README.md +94 -0
  24. package/scripts/validate/common.mjs +149 -0
  25. package/scripts/validate/data-model.mjs +225 -0
  26. package/scripts/validate/dependencies.mjs +69 -0
  27. package/scripts/validate/docs-templates.mjs +99 -0
  28. package/scripts/validate/footprint.mjs +78 -0
  29. package/scripts/validate/imports.mjs +61 -0
  30. package/scripts/validate/manifests.mjs +173 -0
  31. package/scripts/validate/markdown.mjs +123 -0
  32. package/scripts/validate/migrations.mjs +187 -0
  33. package/scripts/validate/no-tests.mjs +136 -0
  34. package/scripts/validate/privacy.mjs +115 -0
  35. package/scripts/validate/record-links.mjs +127 -0
  36. package/scripts/validate/skill-commands.mjs +190 -0
  37. package/scripts/validate/skills.mjs +112 -0
  38. package/scripts/validate/specification.mjs +88 -0
  39. package/scripts/validate/style.mjs +78 -0
  40. package/scripts/validate/validate-all.mjs +175 -0
  41. package/skills/debug/SKILL.md +68 -0
  42. package/skills/decision/SKILL.md +99 -0
  43. package/skills/docs/SKILL.md +86 -0
  44. package/skills/docs/assets/fragments/api/events.md +14 -0
  45. package/skills/docs/assets/fragments/api/graphql.md +14 -0
  46. package/skills/docs/assets/fragments/api/local-only.md +13 -0
  47. package/skills/docs/assets/fragments/api/rest.md +13 -0
  48. package/skills/docs/assets/fragments/api/rpc.md +13 -0
  49. package/skills/docs/assets/fragments/async/event-bus.md +13 -0
  50. package/skills/docs/assets/fragments/async/none.md +11 -0
  51. package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
  52. package/skills/docs/assets/fragments/async/queue.md +13 -0
  53. package/skills/docs/assets/fragments/async/scheduler.md +13 -0
  54. package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
  55. package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
  56. package/skills/docs/assets/fragments/data/document.md +13 -0
  57. package/skills/docs/assets/fragments/data/external-saas.md +13 -0
  58. package/skills/docs/assets/fragments/data/key-value.md +13 -0
  59. package/skills/docs/assets/fragments/data/none.md +12 -0
  60. package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
  61. package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
  62. package/skills/docs/assets/fragments/env/conventional.md +13 -0
  63. package/skills/docs/assets/fragments/env/envx.md +13 -0
  64. package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
  65. package/skills/docs/assets/fragments/env/none.md +11 -0
  66. package/skills/docs/assets/fragments/ui/api-only.md +13 -0
  67. package/skills/docs/assets/fragments/ui/cli.md +14 -0
  68. package/skills/docs/assets/fragments/ui/desktop.md +14 -0
  69. package/skills/docs/assets/fragments/ui/mobile.md +14 -0
  70. package/skills/docs/assets/fragments/ui/web.md +14 -0
  71. package/skills/docs/assets/templates/adr.md +62 -0
  72. package/skills/docs/assets/templates/api.md +30 -0
  73. package/skills/docs/assets/templates/architecture.md +52 -0
  74. package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
  75. package/skills/docs/assets/templates/compliance.md +29 -0
  76. package/skills/docs/assets/templates/data-schema.md +43 -0
  77. package/skills/docs/assets/templates/feature.md +43 -0
  78. package/skills/docs/assets/templates/foundations.md +55 -0
  79. package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
  80. package/skills/docs/assets/templates/module-inventory.md +19 -0
  81. package/skills/docs/assets/templates/module.md +50 -0
  82. package/skills/docs/assets/templates/nfr.md +22 -0
  83. package/skills/docs/assets/templates/observability.md +28 -0
  84. package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
  85. package/skills/docs/assets/templates/project-summary.md +44 -0
  86. package/skills/docs/assets/templates/roles-permissions.md +26 -0
  87. package/skills/docs/assets/templates/test-plan.md +23 -0
  88. package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
  89. package/skills/docs/references/adr-authoring.md +24 -0
  90. package/skills/docs/references/apis-and-data.md +23 -0
  91. package/skills/docs/references/capability-fragments.md +25 -0
  92. package/skills/docs/references/change-tracking.md +62 -0
  93. package/skills/docs/references/diagrams.md +31 -0
  94. package/skills/docs/references/discovery.md +59 -0
  95. package/skills/docs/references/edge-cases.md +45 -0
  96. package/skills/docs/references/foundations-modules.md +18 -0
  97. package/skills/docs/references/ingestion.md +52 -0
  98. package/skills/docs/references/initialize-adopt.md +25 -0
  99. package/skills/docs/references/module-decomposition.md +38 -0
  100. package/skills/docs/references/profiles.md +60 -0
  101. package/skills/docs/references/quality-attributes.md +27 -0
  102. package/skills/docs/references/reverse-engineer.md +21 -0
  103. package/skills/docs/references/spec-depths.md +31 -0
  104. package/skills/docs/references/summarize.md +18 -0
  105. package/skills/docs/references/surfaces-and-actions.md +24 -0
  106. package/skills/docs/references/validation.md +47 -0
  107. package/skills/docs/references/workflows-and-jobs.md +25 -0
  108. package/skills/docs/scripts/ingest.mjs +984 -0
  109. package/skills/docs/scripts/profile-detect.mjs +299 -0
  110. package/skills/docs/scripts/screen.mjs +96 -0
  111. package/skills/docs/scripts/template-lint.mjs +143 -0
  112. package/skills/docs/scripts/validate-docs.mjs +363 -0
  113. package/skills/doctor/SKILL.md +167 -0
  114. package/skills/feature/SKILL.md +132 -0
  115. package/skills/init/SKILL.md +137 -0
  116. package/skills/project/SKILL.md +261 -0
  117. package/skills/project/references/commands.md +213 -0
  118. package/skills/resume/SKILL.md +79 -0
  119. package/skills/review/SKILL.md +91 -0
  120. package/skills/status/SKILL.md +85 -0
  121. package/skills/task/SKILL.md +183 -0
  122. package/src/cli/product-map.mjs +468 -0
  123. package/src/cli/render.mjs +544 -0
  124. package/src/cli.mjs +2774 -0
  125. package/src/cloud/crypto.mjs +118 -0
  126. package/src/cloud/merge.mjs +186 -0
  127. package/src/cloud/policy.mjs +115 -0
  128. package/src/cloud/sync.mjs +512 -0
  129. package/src/cloud/transport.mjs +116 -0
  130. package/src/db/connect.mjs +189 -0
  131. package/src/db/maintenance.mjs +419 -0
  132. package/src/db/migrate.mjs +188 -0
  133. package/src/db/migrations/001_initial.sql +1024 -0
  134. package/src/db/migrations/002_docs_coverage.sql +126 -0
  135. package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
  136. package/src/db/migrations/004_task_categories.sql +59 -0
  137. package/src/db/migrations/005_executable_evidence.sql +32 -0
  138. package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
  139. package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
  140. package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
  141. package/src/db/migrations/009_retired_documents.sql +54 -0
  142. package/src/db/migrations/010_test_plan_evidence.sql +20 -0
  143. package/src/db/migrations/011_criterion_waiver.sql +13 -0
  144. package/src/db/migrations/012_synchronization.sql +56 -0
  145. package/src/db/store.mjs +327 -0
  146. package/src/decisions/record.mjs +167 -0
  147. package/src/docs/proposals.mjs +871 -0
  148. package/src/docs/render.mjs +1424 -0
  149. package/src/docs/templates.mjs +1281 -0
  150. package/src/features/acceptance.mjs +315 -0
  151. package/src/features/specify.mjs +248 -0
  152. package/src/init/discovery.mjs +901 -0
  153. package/src/init/index.mjs +784 -0
  154. package/src/init/questions.mjs +562 -0
  155. package/src/memory/benchmark.mjs +171 -0
  156. package/src/memory/capture.mjs +135 -0
  157. package/src/memory/consolidate.mjs +255 -0
  158. package/src/memory/index.mjs +810 -0
  159. package/src/model/ids.mjs +185 -0
  160. package/src/model/screening.mjs +150 -0
  161. package/src/model/toolkit.mjs +258 -0
  162. package/src/model/vocabulary.mjs +190 -0
  163. package/src/product/assumptions.mjs +120 -0
  164. package/src/product/changes.mjs +180 -0
  165. package/src/product/test-plans.mjs +183 -0
  166. package/src/progress/index.mjs +1364 -0
  167. package/src/runtime/harness.mjs +264 -0
  168. package/src/runtime/hooks.mjs +1021 -0
  169. package/src/runtime/identity.mjs +305 -0
  170. package/src/runtime/resume.mjs +343 -0
  171. package/src/runtime/session.mjs +679 -0
  172. package/src/runtime/version.mjs +315 -0
  173. package/src/service/assets/control-center.html +206 -0
  174. package/src/service/assets/control-center.manifest.json +7 -0
  175. package/src/service/manage.mjs +542 -0
  176. package/src/service/mutations.mjs +860 -0
  177. package/src/service/read-model.mjs +1557 -0
  178. package/src/service/server.mjs +783 -0
  179. package/src/tasks/categories.mjs +154 -0
  180. package/src/tasks/derive.mjs +977 -0
  181. package/src/tasks/lifecycle.mjs +830 -0
  182. package/src/verify/index.mjs +236 -0
@@ -0,0 +1,315 @@
1
+ // The spec-depth gate. Brief section 9.2.
2
+ //
3
+ // Depth comes from risk, and it is a promise about how much of the contract is
4
+ // written down before anyone builds against it. A feature marked `full` that
5
+ // carries microspec content is worse than one honestly marked `microspec`,
6
+ // because the depth is what a reader trusts when deciding whether to ask more
7
+ // questions.
8
+ //
9
+ // So depth is checked at the one moment it matters: acceptance. Before that a
10
+ // feature is a draft and may be as thin as it likes.
11
+
12
+ import { mutate, query, setStatus, currentProject, json, DbError } from "../db/store.mjs";
13
+ import { DEPTH_REQUIREMENTS, SPEC_DEPTHS } from "../model/vocabulary.mjs";
14
+
15
+ export const E = {
16
+ NOT_FOUND: "E_FEATURE_NOT_FOUND",
17
+ DEPTH_UNMET: "E_SPEC_DEPTH_UNMET",
18
+ UNKNOWN_DEPTH: "E_UNKNOWN_SPEC_DEPTH",
19
+ };
20
+
21
+ /**
22
+ * What each requirement means, in the words used when one is missing, plus how
23
+ * to satisfy it. The check is deliberately shallow: it asks whether the record
24
+ * exists, never whether it is any good. Judging quality is a person's job and
25
+ * pretending otherwise would make the gate a nuisance rather than a help.
26
+ */
27
+ const CHECKS = {
28
+ purpose: {
29
+ says: "what this feature is for",
30
+ fix: "Set the feature's purpose.",
31
+ met: (s) => Boolean(s.feature.purpose?.trim()),
32
+ },
33
+ user_statement: {
34
+ says: "who wants it and what they are trying to do",
35
+ fix: "Set the feature's user statement.",
36
+ met: (s) => Boolean(s.feature.user_statement?.trim()),
37
+ },
38
+ scope: {
39
+ says: "what is in scope and what is deliberately out",
40
+ fix: "Record at least one in-scope and one out-of-scope item.",
41
+ met: (s) => json(s.feature.scope_in_json, []).length > 0 && json(s.feature.scope_out_json, []).length > 0,
42
+ },
43
+ flow: {
44
+ says: "the primary flow, step by step",
45
+ fix: "Record the numbered primary flow.",
46
+ met: (s) => s.flows.length > 0,
47
+ },
48
+ acceptance_criteria: {
49
+ says: "how anyone can tell it works",
50
+ fix: "Record at least one acceptance criterion with a verification method.",
51
+ met: (s) => s.criteria.length > 0,
52
+ },
53
+ edge_cases: {
54
+ says: "what happens when things go wrong",
55
+ fix: "Walk the edge-case categories, marking each one specified or not applicable with a reason.",
56
+ met: (s) => s.edgeCases.length > 0,
57
+ },
58
+ surfaces: {
59
+ says: "the surfaces and actions a person touches",
60
+ fix: "Record the surfaces this feature owns, or record an edge case in the platform_variance category saying it has no interface.",
61
+ met: (s) =>
62
+ s.surfaces.length > 0 || s.edgeCases.some((e) => e.category === "platform_variance"),
63
+ },
64
+ api_or_data: {
65
+ says: "the API operations or data entities behind it",
66
+ fix: "Record an API operation or a data entity.",
67
+ met: (s) => s.apis.length > 0 || s.entities.length > 0,
68
+ },
69
+ workflow: {
70
+ says: "the workflow and its steps",
71
+ fix: "Record a workflow with its ordered steps.",
72
+ met: (s) => s.workflows.length > 0 && s.steps.length > 0,
73
+ },
74
+ observability: {
75
+ says: "the signals that prove it works in the open",
76
+ fix: "Record an observability requirement, or set the workflow's observability.",
77
+ met: (s) =>
78
+ s.nfrs.some((n) => /observab|monitor|telemetry|logging|alert/i.test(n.category ?? "")) ||
79
+ s.workflows.some((w) => Boolean(w.observability?.trim())),
80
+ },
81
+ test_plan: {
82
+ says: "how it will be tested",
83
+ fix: "Give each acceptance criterion a verification method, or record the module test plan.",
84
+ met: (s) => s.criteria.length > 0 && s.criteria.every((c) => Boolean(c.verification_method?.trim())),
85
+ },
86
+ decision: {
87
+ says: "the decision that governs the load-bearing choice",
88
+ fix: "Record a decision scoped to this feature.",
89
+ met: (s) => s.decisions.length > 0,
90
+ },
91
+ migration_or_rollback: {
92
+ says: "how the data moves and how the release is undone",
93
+ fix: "Give every schema migration a rollback plan, or record an edge case in the data_migration_states category saying why none applies.",
94
+ // Every migration needs its rollback, not just one of them, and a feature
95
+ // that changes no schema says so deliberately rather than by silence.
96
+ met: (s) =>
97
+ (s.migrations.length > 0 && s.migrations.every((m) => Boolean(m.rollback_plan?.trim()))) ||
98
+ s.edgeCases.some((e) => e.category === "data_migration_states"),
99
+ },
100
+ security_privacy: {
101
+ says: "the security and privacy analysis",
102
+ fix: "Record a security or privacy requirement for this feature.",
103
+ met: (s) => s.nfrs.some((n) => /secur|privac|complian/i.test(n.category ?? "")),
104
+ },
105
+ };
106
+
107
+ async function gather(db, featureId) {
108
+ const feature = await db.get("SELECT * FROM features WHERE id = ?", featureId);
109
+ if (!feature) throw new DbError(E.NOT_FOUND, `There is no feature ${featureId}.`);
110
+ const all = (sql, ...p) => db.all(sql, ...p);
111
+ const workflows = await all("SELECT * FROM workflows WHERE feature_id = ?", featureId);
112
+ const steps = workflows.length
113
+ ? await all(
114
+ `SELECT * FROM workflow_steps WHERE workflow_id IN (${workflows.map(() => "?").join(",")})`,
115
+ ...workflows.map((w) => w.id),
116
+ )
117
+ : [];
118
+ return {
119
+ feature,
120
+ flows: await all("SELECT * FROM feature_flows WHERE feature_id = ?", featureId),
121
+ criteria: await all("SELECT * FROM feature_acceptance_criteria WHERE feature_id = ?", featureId),
122
+ edgeCases: await all("SELECT * FROM feature_edge_cases WHERE feature_id = ?", featureId),
123
+ surfaces: await all("SELECT * FROM surfaces WHERE feature_id = ?", featureId),
124
+ apis: await all("SELECT * FROM api_operations WHERE feature_id = ?", featureId),
125
+ entities: await all("SELECT * FROM data_entities WHERE feature_id = ?", featureId),
126
+ workflows,
127
+ steps,
128
+ nfrs: await all("SELECT * FROM non_functional_requirements WHERE feature_id = ?", featureId),
129
+ migrations: await all("SELECT * FROM schema_migrations WHERE feature_id = ?", featureId),
130
+ decisions: await all(
131
+ `SELECT d.* FROM decisions d
132
+ WHERE (d.scope_type = 'feature' AND d.scope_id = ?)
133
+ OR EXISTS (SELECT 1 FROM decision_links l
134
+ WHERE l.decision_id = d.id AND l.target_type = 'feature' AND l.target_id = ?)`,
135
+ featureId, featureId,
136
+ ),
137
+ };
138
+ }
139
+
140
+ /** What the declared depth requires, and which parts are present. */
141
+ export async function depthReadiness(db, featureId) {
142
+ const state = await gather(db, featureId);
143
+ const depth = String(state.feature.spec_depth ?? "standard");
144
+ const required = DEPTH_REQUIREMENTS[depth];
145
+ if (!required) {
146
+ throw new DbError(E.UNKNOWN_DEPTH, `"${depth}" is not a spec depth. Use one of: ${SPEC_DEPTHS.join(", ")}.`);
147
+ }
148
+ const components = required.map((key) => {
149
+ const check = CHECKS[key];
150
+ return {
151
+ requirement: key,
152
+ says: check.says,
153
+ met: Boolean(check.met(state)),
154
+ fix: check.fix,
155
+ };
156
+ });
157
+ const missing = components.filter((c) => !c.met);
158
+ return {
159
+ featureId,
160
+ name: state.feature.name,
161
+ depth,
162
+ status: state.feature.status,
163
+ required: components.length,
164
+ met: components.length - missing.length,
165
+ components,
166
+ missing,
167
+ acceptable: missing.length === 0,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Accept a feature, refusing while its declared depth is unmet.
173
+ *
174
+ * The refusal names every missing part at once rather than one at a time: being
175
+ * sent back four times in a row for four separate reasons is how a gate teaches
176
+ * people to route around it.
177
+ */
178
+ export async function acceptFeature(root, featureId, { apply = false, actor = "superdev" } = {}) {
179
+ const report = await query(root, (db) => depthReadiness(db, featureId));
180
+
181
+ if (!report.acceptable) {
182
+ throw new DbError(
183
+ E.DEPTH_UNMET,
184
+ `${report.name} is declared ${report.depth} depth, and ${report.missing.length} of the ${report.required} things that depth promises are missing: ` +
185
+ report.missing.map((m) => m.says).join("; ") +
186
+ `. Record them, or lower the depth to match what this feature actually is.`,
187
+ { featureId, depth: report.depth, missing: report.missing },
188
+ );
189
+ }
190
+
191
+ if (!apply) return { ...report, applied: false };
192
+
193
+ await mutate(root, async (db) => {
194
+ // Re-checked inside the transaction that accepts. Reading the gate on one
195
+ // connection and applying on another leaves a window where the rows that
196
+ // satisfied it can disappear between the two.
197
+ const confirmed = await depthReadiness(db, featureId);
198
+ if (!confirmed.acceptable) {
199
+ throw new DbError(
200
+ E.DEPTH_UNMET,
201
+ `${confirmed.name} changed while it was being accepted and no longer carries what ${confirmed.depth} depth promises: ${confirmed.missing.map((x) => x.says).join("; ")}.`,
202
+ { featureId, missing: confirmed.missing },
203
+ );
204
+ }
205
+ const project = await currentProject(db);
206
+ await setStatus(db, "feature", featureId, "accepted", {
207
+ projectId: project?.id,
208
+ actor,
209
+ note: `Accepted at ${report.depth} depth with all ${report.required} requirements recorded`,
210
+ activityType: "specification_changed",
211
+ activitySummary: `Accepted ${report.name} at ${report.depth} depth`,
212
+ });
213
+ });
214
+ return { ...report, status: "accepted", applied: true };
215
+ }
216
+
217
+ /** Every feature whose recorded depth is not backed by what it carries. */
218
+ export async function depthGaps(root) {
219
+ return query(root, async (db) => {
220
+ const features = await db.all("SELECT id FROM features ORDER BY id");
221
+ const out = [];
222
+ for (const { id } of features) {
223
+ const report = await depthReadiness(db, id);
224
+ // A draft is allowed to be thin. Anything already accepted is claiming
225
+ // its depth is real, so a gap there is a live inaccuracy.
226
+ if (!report.acceptable && report.status === "accepted") out.push(report);
227
+ }
228
+ return out;
229
+ });
230
+ }
231
+
232
+ // --------------------------------------------------------------- completion
233
+
234
+ /**
235
+ * Whether a feature's contract is satisfied, and what is missing when it is not.
236
+ *
237
+ * Section 6.3 says completion is derived, never asserted. This is the
238
+ * derivation: every acceptance criterion under the feature is met or waived
239
+ * with a reason, and every task against it is finished or deliberately stopped.
240
+ * Nothing here reads the feature's own status, because that column is the thing
241
+ * being decided rather than evidence about it.
242
+ */
243
+ export async function completionReadiness(db, featureId) {
244
+ const feature = await db.get("SELECT * FROM features WHERE id = ?", featureId);
245
+ if (!feature) throw new DbError(E.NOT_FOUND, `There is no feature ${featureId}.`);
246
+
247
+ const criteria = await db.all(
248
+ "SELECT id, criterion, status FROM feature_acceptance_criteria WHERE feature_id = ?", featureId);
249
+ const tasks = await db.all(
250
+ "SELECT id, name, status FROM tasks WHERE feature_id = ?", featureId);
251
+
252
+ const unmet = criteria.filter((c) => !["met", "waived"].includes(c.status));
253
+ const open = tasks.filter((t) => !["complete", "cancelled", "superseded"].includes(t.status));
254
+
255
+ const missing = [
256
+ criteria.length === 0
257
+ ? { says: "no acceptance criterion, so nothing states what finishing it would mean", fix: `Record one with superdev feature specify ${featureId} --criterion.` }
258
+ : null,
259
+ unmet.length
260
+ ? { says: `${unmet.length} acceptance criteria not yet met: ${unmet.map((c) => c.id).join(", ")}`,
261
+ fix: `Attach passing evidence with superdev task evidence <task> --criterion, or waive one with superdev feature waive <AC-id> --reason.` }
262
+ : null,
263
+ open.length
264
+ ? { says: `${open.length} tasks still open: ${open.map((t) => `${t.id} (${t.status})`).join(", ")}`,
265
+ fix: "Complete or cancel them." }
266
+ : null,
267
+ ].filter(Boolean);
268
+
269
+ return {
270
+ featureId, name: feature.name, status: feature.status,
271
+ criteria: criteria.length, tasks: tasks.length,
272
+ complete: missing.length === 0,
273
+ missing,
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Move a feature to complete, and only ever because its contract says so.
279
+ *
280
+ * This exists because nothing did. A feature could be accepted and never
281
+ * finished: `setStatus` was called for `accepted` and for nothing else, so the
282
+ * only way a feature ever reached complete was a direct update that left no
283
+ * history, which is precisely what section 7.4 forbids and what this module is
284
+ * otherwise careful about. Eighty seven features carried a completion nobody
285
+ * recorded, and four that genuinely were finished sat at accepted because there
286
+ * was no way to say so.
287
+ */
288
+ export async function completeFeature(root, featureId, { apply = false, actor = "superdev" } = {}) {
289
+ const report = await query(root, (db) => completionReadiness(db, featureId));
290
+ if (!report.complete) {
291
+ throw new DbError(E.DEPTH_UNMET,
292
+ `${report.name} is not finished: ${report.missing.map((m) => m.says).join("; ")}.`,
293
+ { featureId, missing: report.missing });
294
+ }
295
+ if (report.status === "complete") return { ...report, applied: false, unchanged: true };
296
+ if (!apply) return { ...report, applied: false };
297
+
298
+ await mutate(root, async (db) => {
299
+ const confirmed = await completionReadiness(db, featureId);
300
+ if (!confirmed.complete) {
301
+ throw new DbError(E.DEPTH_UNMET,
302
+ `${confirmed.name} changed while it was being completed: ${confirmed.missing.map((m) => m.says).join("; ")}.`,
303
+ { featureId });
304
+ }
305
+ const project = await currentProject(db);
306
+ await setStatus(db, "feature", featureId, "complete", {
307
+ projectId: project?.id,
308
+ actor,
309
+ note: `Every one of its ${confirmed.criteria} acceptance criteria is met or waived and all ${confirmed.tasks} tasks are finished`,
310
+ activityType: "specification_changed",
311
+ activitySummary: `${confirmed.name} is complete: its contract is satisfied`,
312
+ });
313
+ });
314
+ return { ...report, status: "complete", applied: true };
315
+ }
@@ -0,0 +1,248 @@
1
+ // Write a feature's specification.
2
+ //
3
+ // The depth gate refuses to accept a feature until it carries what its depth
4
+ // promises, and `feature depth` says exactly what is missing and how to close
5
+ // it. Nothing could close it. Every fix line named a database record, and no
6
+ // command wrote one, so a feature drafted by init stayed at purpose only and
7
+ // could never be accepted. The feature skill's step 8 says to fill the
8
+ // contracts the depth requires, and was not a thing anyone could do.
9
+ //
10
+ // This writes the six microspec covers, which is what stands between a drafted
11
+ // feature and an acceptable one: purpose, who wants it, what is in and out of
12
+ // scope, the primary flow, the acceptance criteria and the edge cases. Deeper
13
+ // covers hang off records that already have their own commands and their own
14
+ // homes, and are not duplicated here.
15
+ //
16
+ // Repeated values replace rather than accumulate. Specifying a flow twice means
17
+ // the flow was rewritten, not that the feature now has two of them.
18
+
19
+ import { create, mutate, json } from "../db/store.mjs";
20
+ import { EDGE_CASE_CATEGORIES } from "../model/vocabulary.mjs";
21
+ import { sanitizeExternal } from "../model/screening.mjs";
22
+
23
+ export const E = {
24
+ NOT_FOUND: "E_FEATURE_NOT_FOUND",
25
+ UNKNOWN_CATEGORY: "E_UNKNOWN_EDGE_CATEGORY",
26
+ NOT_SPECIFIABLE: "E_FEATURE_NOT_SPECIFIABLE",
27
+ NOTHING_GIVEN: "E_NOTHING_TO_SPECIFY",
28
+ };
29
+
30
+ export class SpecError extends Error {
31
+ constructor(code, message, detail) {
32
+ super(message);
33
+ this.name = "SpecError";
34
+ this.code = code;
35
+ if (detail !== undefined) this.detail = detail;
36
+ }
37
+ }
38
+
39
+ const clean = (value, max = 600) => {
40
+ const text = sanitizeExternal(String(value ?? "")).replace(/\s+/g, " ").trim();
41
+ return text ? text.slice(0, max) : null;
42
+ };
43
+
44
+ /**
45
+ * Apply a specification to a feature.
46
+ *
47
+ * Everything is one transaction, so a specification that fails halfway leaves
48
+ * the feature as it was rather than half rewritten.
49
+ */
50
+ export async function specifyFeature(root, featureId, input = {}, { actor = "superdev", sessionId = null, apply = false } = {}) {
51
+ const {
52
+ purpose = null, userStatement = null, scopeIn = [], scopeOut = [],
53
+ flow = [], criteria = [], edgeCases = [],
54
+ } = input;
55
+
56
+ const given = [
57
+ purpose, userStatement,
58
+ scopeIn.length ? scopeIn : null, scopeOut.length ? scopeOut : null,
59
+ flow.length ? flow : null, criteria.length ? criteria : null,
60
+ edgeCases.length ? edgeCases : null,
61
+ ].filter(Boolean);
62
+ if (!given.length) {
63
+ throw new SpecError(E.NOTHING_GIVEN,
64
+ "Nothing to specify. Pass at least one of --purpose, --user, --in, --out, --flow, --criterion or --edge.");
65
+ }
66
+
67
+ for (const edge of edgeCases) {
68
+ if (!EDGE_CASE_CATEGORIES.includes(edge.category)) {
69
+ throw new SpecError(E.UNKNOWN_CATEGORY,
70
+ `${edge.category} is not an edge-case category. The list is fixed so a reader can walk it: ${EDGE_CASE_CATEGORIES.join(", ")}.`);
71
+ }
72
+ }
73
+
74
+ const summary = {
75
+ featureId,
76
+ purpose: Boolean(purpose),
77
+ userStatement: Boolean(userStatement),
78
+ scopeIn: scopeIn.length,
79
+ scopeOut: scopeOut.length,
80
+ flow: flow.length,
81
+ criteria: criteria.length,
82
+ edgeCases: edgeCases.length,
83
+ };
84
+ if (!apply) return { applied: false, ...summary };
85
+
86
+ return mutate(root, async (db) => {
87
+ const feature = await db.get("SELECT * FROM features WHERE id = ?", featureId);
88
+ if (!feature) throw new SpecError(E.NOT_FOUND, `There is no feature ${featureId}. Run superdev feature list to see them.`);
89
+ // An accepted feature is a contract others have linked work to. Changing it
90
+ // is a Change, recorded through the change surface, not an edit.
91
+ if (feature.status === "accepted" || feature.status === "complete") {
92
+ throw new SpecError(E.NOT_SPECIFIABLE,
93
+ `${featureId} is ${feature.status} and tasks may already implement it. Record what is moving with superdev change record, then specify it again.`);
94
+ }
95
+
96
+ const meta = (text) => ({
97
+ projectId: feature.project_id, actor, sessionId,
98
+ activityType: "specification_changed", activitySummary: clean(text, 200),
99
+ });
100
+
101
+ const fields = {};
102
+ if (purpose) fields.purpose = clean(purpose, 1000);
103
+ if (userStatement) fields.user_statement = clean(userStatement, 1000);
104
+ if (scopeIn.length) fields.scope_in_json = JSON.stringify(scopeIn.map((x) => clean(x, 300)).filter(Boolean));
105
+ if (scopeOut.length) fields.scope_out_json = JSON.stringify(scopeOut.map((x) => clean(x, 300)).filter(Boolean));
106
+ if (Object.keys(fields).length) {
107
+ const sets = Object.keys(fields).map((k) => `${k} = ?`).join(", ");
108
+ await db.run(`UPDATE features SET ${sets} WHERE id = ?`, ...Object.values(fields), featureId);
109
+ }
110
+
111
+ if (flow.length) {
112
+ await db.run("DELETE FROM feature_flows WHERE feature_id = ?", featureId);
113
+ for (const [index, step] of flow.entries()) {
114
+ await create(db, "feature_flow", {
115
+ feature_id: featureId, sequence: index + 1, step: clean(step, 400),
116
+ }, meta(`Primary flow recorded for ${featureId}`));
117
+ }
118
+ }
119
+
120
+ if (criteria.length) {
121
+ // Criteria already carrying evidence are left alone: deleting one would
122
+ // orphan the proof that it was met.
123
+ const proven = await db.all(
124
+ "SELECT id, criterion FROM feature_acceptance_criteria WHERE feature_id = ? AND evidence_id IS NOT NULL",
125
+ featureId,
126
+ );
127
+ const keep = new Set(proven.map((c) => c.criterion));
128
+ await db.run(
129
+ "DELETE FROM feature_acceptance_criteria WHERE feature_id = ? AND evidence_id IS NULL",
130
+ featureId,
131
+ );
132
+ let sequence = proven.length;
133
+ for (const criterion of criteria) {
134
+ const text = clean(criterion.criterion ?? criterion, 500);
135
+ if (!text || keep.has(text)) continue;
136
+ sequence += 1;
137
+ await create(db, "feature_acceptance_criterion", {
138
+ feature_id: featureId,
139
+ criterion: text,
140
+ verification_method: clean(criterion.verification ?? "Checked by hand against the running product.", 300),
141
+ status: "unmet",
142
+ sequence,
143
+ }, meta(`Acceptance criterion recorded for ${featureId}`));
144
+ }
145
+ }
146
+
147
+ if (edgeCases.length) {
148
+ for (const edge of edgeCases) {
149
+ const notApplicable = edge.applicability === "not_applicable";
150
+ const behavior = clean(edge.behavior, 500);
151
+ // One row per category, so specifying the same category twice is the
152
+ // same question answered again rather than asked twice.
153
+ await db.run(
154
+ "DELETE FROM feature_edge_cases WHERE feature_id = ? AND category = ?",
155
+ featureId, edge.category,
156
+ );
157
+ await create(db, "feature_edge_case", {
158
+ feature_id: featureId,
159
+ category: edge.category,
160
+ applicability: notApplicable ? "not_applicable" : "applicable",
161
+ behavior: notApplicable ? null : behavior,
162
+ reason_not_applicable: notApplicable ? behavior : null,
163
+ }, meta(`Edge case recorded for ${featureId}: ${edge.category}`));
164
+ }
165
+ }
166
+
167
+ const after = await db.get("SELECT * FROM features WHERE id = ?", featureId);
168
+ return {
169
+ applied: true,
170
+ ...summary,
171
+ feature: {
172
+ id: after.id,
173
+ name: after.name,
174
+ status: after.status,
175
+ depth: after.spec_depth,
176
+ scopeIn: json(after.scope_in_json, []).length,
177
+ scopeOut: json(after.scope_out_json, []).length,
178
+ },
179
+ };
180
+ });
181
+ }
182
+
183
+ /** Declare how much specification this feature owes before it can be accepted. */
184
+ export async function setDepth(root, featureId, depth, { actor = "superdev", apply = false } = {}) {
185
+ const { SPEC_DEPTHS } = await import("../model/vocabulary.mjs");
186
+ if (!SPEC_DEPTHS.includes(depth)) {
187
+ throw new SpecError(E.NOT_SPECIFIABLE,
188
+ `${depth} is not a specification depth. Section 9.2 names three: ${SPEC_DEPTHS.join(", ")}.`);
189
+ }
190
+ return mutate(root, async (db) => {
191
+ const feature = await db.get("SELECT * FROM features WHERE id = ?", featureId);
192
+ if (!feature) throw new SpecError(E.NOT_FOUND, `There is no feature ${featureId}.`);
193
+ if (feature.spec_depth === depth) {
194
+ return { applied: false, unchanged: true, featureId, depth, name: feature.name };
195
+ }
196
+ if (!apply) {
197
+ return { applied: false, featureId, name: feature.name, from: feature.spec_depth, depth };
198
+ }
199
+ await db.run("UPDATE features SET spec_depth = ? WHERE id = ?", depth, featureId);
200
+ return { applied: true, featureId, name: feature.name, from: feature.spec_depth, depth };
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Set an acceptance criterion aside, with the reason on the record.
206
+ *
207
+ * The schema has carried a waived status since the first migration and nothing
208
+ * could set it, while two refusals told the reader to waive a criterion. A
209
+ * status no command can reach is the same as no status at all, except that it
210
+ * makes the refusal a dead end.
211
+ *
212
+ * The reason is required. A waived criterion with no reason is indistinguishable
213
+ * from one somebody quietly gave up on, which is the thing evidence gating
214
+ * exists to prevent.
215
+ */
216
+ export async function waiveCriterion(root, criterionId, { reason, actor = "superdev", sessionId = null, apply = false } = {}) {
217
+ const text = clean(reason, 500);
218
+ if (!text) {
219
+ throw new SpecError(E.NOTHING_GIVEN,
220
+ "Say why this criterion is being set aside. A waiver without a reason reads the same as forgetting.");
221
+ }
222
+ return mutate(root, async (db) => {
223
+ const criterion = await db.get("SELECT * FROM feature_acceptance_criteria WHERE id = ?", criterionId);
224
+ if (!criterion) throw new SpecError(E.NOT_FOUND, `There is no acceptance criterion ${criterionId}.`);
225
+ if (criterion.status === "met") {
226
+ throw new SpecError(E.NOT_SPECIFIABLE,
227
+ `${criterionId} is already met, so there is nothing to waive. Retract the evidence first if it should not be.`);
228
+ }
229
+ const feature = await db.get("SELECT id, project_id, name FROM features WHERE id = ?", criterion.feature_id);
230
+ if (!apply) {
231
+ return { applied: false, criterionId, criterion: criterion.criterion, feature: feature?.id ?? null, reason: text };
232
+ }
233
+ await db.run(
234
+ "UPDATE feature_acceptance_criteria SET status = 'waived', waiver_reason = ? WHERE id = ?",
235
+ text, criterionId,
236
+ );
237
+ const { recordActivity } = await import("../db/store.mjs");
238
+ await recordActivity(db, feature.project_id, {
239
+ type: "specification_changed",
240
+ actor,
241
+ sessionId,
242
+ featureId: feature.id,
243
+ summary: `${criterionId} waived on ${feature.name}: ${text}`.slice(0, 200),
244
+ metadata: { criterion: criterionId, reason: text },
245
+ });
246
+ return { applied: true, criterionId, criterion: criterion.criterion, feature: feature.id, reason: text };
247
+ });
248
+ }