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,860 @@
1
+ // The bounded write surface. This module is the authority on what it accepts:
2
+ // ALLOWED_ACTIONS is derived from the table below, and docs/module-contract.md
3
+ // reads from it rather than restating a count that then falls out of date.
4
+ //
5
+ // The browser is an untrusted caller even though it is running on the same
6
+ // machine, so nothing it sends is ever interpolated into SQL, handed to a shell,
7
+ // or resolved as a filesystem path. Every field is checked for type, length and
8
+ // allowed values before it reaches the store, and a document is addressed by its
9
+ // record id rather than by a path the caller chose.
10
+ //
11
+ // Task work is delegated to ../tasks/lifecycle.mjs so a status never moves
12
+ // without the transition rules, the history and the activity event that the
13
+ // lifecycle owns.
14
+
15
+ import { createHash } from "node:crypto";
16
+ import {
17
+ create,
18
+ mutate,
19
+ patch,
20
+ query,
21
+ recordActivity,
22
+ setStatus,
23
+ currentProject,
24
+ } from "../db/store.mjs";
25
+ import { isId, uniqueSlug } from "../model/ids.mjs";
26
+ import {
27
+ createTask,
28
+ updateTask,
29
+ claimTask,
30
+ releaseTask,
31
+ startTask,
32
+ submitForReview,
33
+ verifyTask,
34
+ unblockTask,
35
+ blockTask,
36
+ completeTask,
37
+ reopenTask,
38
+ cancelTask,
39
+ linkContract,
40
+ addSubtask,
41
+ } from "../tasks/lifecycle.mjs";
42
+ import {
43
+ createCategory,
44
+ updateCategory,
45
+ setCategoryActive,
46
+ } from "../tasks/categories.mjs";
47
+ import { acceptProposal, rejectProposal } from "../docs/proposals.mjs";
48
+
49
+ export const E = {
50
+ UNKNOWN_ACTION: "E_UNKNOWN_ACTION",
51
+ INVALID_PAYLOAD: "E_INVALID_PAYLOAD",
52
+ NOT_FOUND: "E_NOT_FOUND",
53
+ INVALID_TRANSITION: "E_INVALID_TRANSITION",
54
+ };
55
+
56
+ export class MutationError extends Error {
57
+ constructor(code, message, detail) {
58
+ super(message);
59
+ this.name = "MutationError";
60
+ this.code = code;
61
+ if (detail !== undefined) this.detail = detail;
62
+ }
63
+ }
64
+
65
+ const invalid = (message, detail) => new MutationError(E.INVALID_PAYLOAD, message, detail);
66
+
67
+ // ------------------------------------------------------------------ checking
68
+
69
+ /** Longest value each shape of field may carry, so one request cannot be huge. */
70
+ const LIMIT = {
71
+ actor: 80,
72
+ name: 200,
73
+ line: 500,
74
+ prose: 4000,
75
+ listItems: 100,
76
+ positions: 2000,
77
+ };
78
+
79
+ // Line breaks and tabs are ordinary in prose. Every other control character is
80
+ // how a log line or a generated file gets corrupted later, and a NUL is how a
81
+ // string gets truncated by something downstream that reads it as C.
82
+ const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/;
83
+
84
+ function requireObject(payload) {
85
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
86
+ throw invalid("The payload has to be an object of named fields.");
87
+ }
88
+ return payload;
89
+ }
90
+
91
+ function text(payload, field, { required = false, max = LIMIT.prose } = {}) {
92
+ const raw = payload[field];
93
+ if (raw === undefined || raw === null || raw === "") {
94
+ if (required) throw invalid(`${field} is required.`);
95
+ return null;
96
+ }
97
+ if (typeof raw !== "string") throw invalid(`${field} has to be text.`);
98
+ const value = raw.trim();
99
+ if (!value) {
100
+ if (required) throw invalid(`${field} is required and cannot be only spaces.`);
101
+ return null;
102
+ }
103
+ if (value.length > max) {
104
+ throw invalid(`${field} is ${value.length} characters. The limit is ${max}.`);
105
+ }
106
+ if (CONTROL.test(value)) throw invalid(`${field} contains a control character.`);
107
+ return value;
108
+ }
109
+
110
+ function identifier(payload, field, { required = false } = {}) {
111
+ const value = text(payload, field, { required, max: 32 });
112
+ if (value === null) return null;
113
+ if (!isId(value)) {
114
+ throw invalid(`${field} has to be a Superdev record id such as FEAT-0007, not "${value}".`);
115
+ }
116
+ return value;
117
+ }
118
+
119
+ function oneOf(payload, field, allowed, { required = false, fallback = null } = {}) {
120
+ const value = text(payload, field, { required, max: 64 });
121
+ if (value === null) return fallback;
122
+ if (!allowed.includes(value)) {
123
+ throw invalid(`${field} has to be one of: ${allowed.join(", ")}.`);
124
+ }
125
+ return value;
126
+ }
127
+
128
+ function list(payload, field, { max = LIMIT.listItems, itemMax = LIMIT.line } = {}) {
129
+ const raw = payload[field];
130
+ if (raw === undefined || raw === null) return null;
131
+ if (!Array.isArray(raw)) throw invalid(`${field} has to be a list of text lines.`);
132
+ if (raw.length > max) throw invalid(`${field} has ${raw.length} entries. The limit is ${max}.`);
133
+ return raw.map((entry, index) => {
134
+ if (typeof entry !== "string") throw invalid(`${field}[${index}] has to be text.`);
135
+ const value = entry.trim();
136
+ if (value.length > itemMax) {
137
+ throw invalid(`${field}[${index}] is ${value.length} characters. The limit is ${itemMax}.`);
138
+ }
139
+ if (CONTROL.test(value)) throw invalid(`${field}[${index}] contains a control character.`);
140
+ return value;
141
+ });
142
+ }
143
+
144
+ function number(payload, field, { required = false, min = -1e7, max = 1e7 } = {}) {
145
+ const raw = payload[field];
146
+ if (raw === undefined || raw === null) {
147
+ if (required) throw invalid(`${field} is required.`);
148
+ return null;
149
+ }
150
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
151
+ throw invalid(`${field} has to be a finite number.`);
152
+ }
153
+ if (raw < min || raw > max) throw invalid(`${field} has to be between ${min} and ${max}.`);
154
+ return raw;
155
+ }
156
+
157
+ function integer(payload, field, { required = false, min = 0, max = 1e9 } = {}) {
158
+ const value = number(payload, field, { required, min, max });
159
+ if (value === null) return null;
160
+ if (!Number.isInteger(value)) throw invalid(`${field} has to be a whole number.`);
161
+ return value;
162
+ }
163
+
164
+ function flag(payload, field) {
165
+ const raw = payload[field];
166
+ if (raw === undefined || raw === null) return null;
167
+ if (typeof raw !== "boolean") throw invalid(`${field} has to be true or false.`);
168
+ return raw;
169
+ }
170
+
171
+ const actorOf = (payload) => text(payload, "actor", { max: LIMIT.actor }) ?? "control-center";
172
+
173
+ /** Only present keys are copied, so an absent field never blanks a column. */
174
+ function pick(source, keys) {
175
+ const out = {};
176
+ for (const key of keys) if (source[key] !== null && source[key] !== undefined) out[key] = source[key];
177
+ return out;
178
+ }
179
+
180
+ // ------------------------------------------------------------- vocabularies
181
+
182
+ const TASK_PRIORITIES = ["critical", "urgent", "high", "normal", "medium", "low"];
183
+
184
+ /** Statuses `task.transition` accepts. Terminal moves have their own actions. */
185
+ const TRANSITION_TARGETS = ["draft", "ready", "in_progress", "in_review", "verifying", "paused"];
186
+
187
+ const CONTRACT_TARGETS = [
188
+ "workflow_step", "ui_action", "api_operation", "data_entity", "schema_migration",
189
+ "integration", "job", "webhook", "nfr", "document", "decision", "acceptance_criterion",
190
+ "surface", "state_transition", "permission",
191
+ ];
192
+
193
+ const CONTRACT_RELATIONSHIPS = ["implements", "verifies", "modifies", "documents", "enables"];
194
+
195
+ const DECISION_STATUSES = [
196
+ "proposed", "accepted", "rejected", "deprecated", "partially_superseded",
197
+ "superseded", "time_boxed", "revisit_required",
198
+ ];
199
+
200
+ const DISCOVERY_KINDS = [
201
+ "problem", "user", "goal_candidate", "feature_candidate", "constraint",
202
+ "assumption", "risk", "unknown", "exclusion", "capability",
203
+ ];
204
+
205
+ const EPISTEMIC_STATUSES = ["confirmed", "inferred", "assumed", "unknown", "contradicted", "declined"];
206
+
207
+ const DISCOVERY_STATUSES = ["proposed", "accepted", "rejected", "superseded"];
208
+
209
+ const CANVASES = ["blueprint", "discovery", "data", "architecture", "workflow"];
210
+
211
+ const LAYOUT_RECORD_TYPES = [
212
+ "goal", "milestone", "module", "feature", "workflow", "workflow_step", "task",
213
+ "data_entity", "discovery_item", "runtime_piece", "decision", "surface",
214
+ ];
215
+
216
+ // --------------------------------------------------------------------- tasks
217
+
218
+ /**
219
+ * `startTask`, `submitForReview` and `verifyTask` each carry their own
220
+ * preconditions, so the target status selects the entry point rather than a
221
+ * generic status write.
222
+ */
223
+ async function transitionTask(root, payload) {
224
+ const taskId = identifier(payload, "taskId", { required: true });
225
+ const to = oneOf(payload, "to", TRANSITION_TARGETS, { required: true });
226
+ const actor = actorOf(payload);
227
+ const note = text(payload, "note", { max: LIMIT.line });
228
+
229
+ const task = await query(root, (db) => db.get("SELECT * FROM tasks WHERE id = ?", taskId));
230
+ if (!task) throw new MutationError(E.NOT_FOUND, `There is no task ${taskId}.`);
231
+
232
+ // Leaving blocked returns the task to what it was doing, which is a decision
233
+ // the lifecycle already knows how to make from the recorded history.
234
+ if (task.status === "blocked") return unblockTask(root, taskId, { actor, note, to });
235
+
236
+ if (to === "in_progress") return startTask(root, taskId, { actor, note });
237
+ if (to === "in_review") return submitForReview(root, taskId, { actor, note });
238
+ if (to === "verifying") return verifyTask(root, taskId, { actor, note });
239
+
240
+ // draft, ready and paused are plain repositionings within open work. They go
241
+ // through setStatus, which is what writes the history and the activity event.
242
+ const from = new Set({
243
+ draft: ["ready"],
244
+ ready: ["draft", "paused", "cancelled"],
245
+ paused: ["ready", "in_progress"],
246
+ }[to]);
247
+ if (!from.has(task.status)) {
248
+ throw new MutationError(
249
+ E.INVALID_TRANSITION,
250
+ `${taskId} is ${task.status.replace(/_/g, " ")} and cannot move to ${to.replace(/_/g, " ")} from there. Allowed from: ${[...from].join(", ")}.`,
251
+ );
252
+ }
253
+ return mutate(root, (db) =>
254
+ setStatus(db, "task", taskId, to, { projectId: task.project_id, actor, note }),
255
+ );
256
+ }
257
+
258
+ function taskInput(payload) {
259
+ return {
260
+ ...pick(
261
+ {
262
+ name: text(payload, "name", { required: true, max: LIMIT.name }),
263
+ description: text(payload, "description"),
264
+ expectedOutcome: text(payload, "expectedOutcome"),
265
+ whyNeeded: text(payload, "whyNeeded"),
266
+ completionCriteria: list(payload, "completionCriteria"),
267
+ verificationRequirements: list(payload, "verificationRequirements"),
268
+ affectedBoundaries: list(payload, "affectedBoundaries"),
269
+ priority: oneOf(payload, "priority", TASK_PRIORITIES),
270
+ risk: text(payload, "risk", { max: LIMIT.line }),
271
+ category: text(payload, "category", { max: LIMIT.name }),
272
+ estimate: text(payload, "estimate", { max: 64 }),
273
+ dueAt: text(payload, "dueAt", { max: 40 }),
274
+ enabling: flag(payload, "enabling"),
275
+ enabledFeatureId: identifier(payload, "enabledFeatureId"),
276
+ enablingRationale: text(payload, "enablingRationale"),
277
+ },
278
+ [
279
+ "name", "description", "expectedOutcome", "whyNeeded", "completionCriteria",
280
+ "verificationRequirements", "affectedBoundaries", "priority", "risk", "category",
281
+ "estimate", "dueAt", "enabling", "enabledFeatureId", "enablingRationale",
282
+ ],
283
+ ),
284
+ links: contractLinks(payload),
285
+ dependsOn: dependencyIds(payload),
286
+ actor: actorOf(payload),
287
+ };
288
+ }
289
+
290
+ function contractLinks(payload) {
291
+ const raw = payload.links;
292
+ if (raw === undefined || raw === null) return [];
293
+ if (!Array.isArray(raw)) throw invalid("links has to be a list of contract links.");
294
+ if (raw.length > 50) throw invalid("links has more than 50 entries.");
295
+ return raw.map((entry) => {
296
+ requireObject(entry);
297
+ return {
298
+ targetType: oneOf(entry, "targetType", CONTRACT_TARGETS, { required: true }),
299
+ targetId: identifier(entry, "targetId", { required: true }),
300
+ relationship: oneOf(entry, "relationship", CONTRACT_RELATIONSHIPS, { fallback: "implements" }),
301
+ };
302
+ });
303
+ }
304
+
305
+ function dependencyIds(payload) {
306
+ const raw = payload.dependsOn;
307
+ if (raw === undefined || raw === null) return [];
308
+ if (!Array.isArray(raw)) throw invalid("dependsOn has to be a list of task ids.");
309
+ if (raw.length > 50) throw invalid("dependsOn has more than 50 entries.");
310
+ return raw.map((entry, index) => {
311
+ if (typeof entry !== "string" || !isId(entry)) {
312
+ throw invalid(`dependsOn[${index}] has to be a task id such as TASK-0012.`);
313
+ }
314
+ return entry;
315
+ });
316
+ }
317
+
318
+ // ----------------------------------------------------------------- questions
319
+
320
+ async function answerQuestion(root, payload) {
321
+ const questionId = identifier(payload, "questionId", { required: true });
322
+ const status = oneOf(payload, "status", ["answered", "deferred", "withdrawn"], {
323
+ fallback: "answered",
324
+ });
325
+ const answer = text(payload, "answer", { required: status === "answered" });
326
+ const deferralReason = text(payload, "deferralReason", {
327
+ required: status === "deferred",
328
+ max: LIMIT.prose,
329
+ });
330
+ const answeredBy = text(payload, "answeredBy", { max: LIMIT.actor });
331
+ const actor = actorOf(payload);
332
+
333
+ return mutate(root, async (db) => {
334
+ const question = await db.get("SELECT * FROM questions WHERE id = ?", questionId);
335
+ if (!question) throw new MutationError(E.NOT_FOUND, `There is no question ${questionId}.`);
336
+ if (question.status !== "open" && question.status !== "deferred") {
337
+ throw new MutationError(
338
+ E.INVALID_TRANSITION,
339
+ `${questionId} is already ${question.status} and is not waiting on an answer.`,
340
+ );
341
+ }
342
+ const project = await currentProject(db);
343
+ await patch(
344
+ db,
345
+ "question",
346
+ questionId,
347
+ question.version,
348
+ pick(
349
+ {
350
+ answer,
351
+ answered_by: answeredBy ?? actor,
352
+ answered_at: new Date().toISOString(),
353
+ deferral_reason: deferralReason,
354
+ },
355
+ ["answer", "answered_by", "answered_at", "deferral_reason"],
356
+ ),
357
+ { projectId: project.id, actor, activity: false },
358
+ );
359
+ return setStatus(db, "question", questionId, status, {
360
+ projectId: project.id,
361
+ actor,
362
+ note: answer ?? deferralReason,
363
+ activityType: "specification_changed",
364
+ activitySummary: `${questionId} ${status}: ${(answer ?? deferralReason ?? "").slice(0, 160)}`,
365
+ });
366
+ });
367
+ }
368
+
369
+ // ----------------------------------------------------------------- decisions
370
+
371
+ async function transitionDecision(root, payload) {
372
+ const decisionId = identifier(payload, "decisionId", { required: true });
373
+ const to = oneOf(payload, "to", DECISION_STATUSES, { required: true });
374
+ const reason = text(payload, "reason", { max: LIMIT.prose });
375
+ const scopeDelta = text(payload, "scopeDelta", { max: LIMIT.prose });
376
+ const acceptedBy = text(payload, "acceptedBy", { max: LIMIT.actor });
377
+ const actor = actorOf(payload);
378
+
379
+ if (to === "partially_superseded" && !scopeDelta) {
380
+ throw invalid(
381
+ "A partial supersession has to say which part stops applying, otherwise nobody can tell what still governs.",
382
+ );
383
+ }
384
+
385
+ return mutate(root, async (db) => {
386
+ const decision = await db.get("SELECT * FROM decisions WHERE id = ?", decisionId);
387
+ if (!decision) throw new MutationError(E.NOT_FOUND, `There is no decision ${decisionId}.`);
388
+ if (decision.status === to) {
389
+ throw new MutationError(E.INVALID_TRANSITION, `${decisionId} is already ${to.replace(/_/g, " ")}.`);
390
+ }
391
+
392
+ const previous = await db.get(
393
+ `SELECT sequence, immutable_hash FROM decision_transitions
394
+ WHERE decision_id = ? ORDER BY sequence DESC LIMIT 1`,
395
+ decisionId,
396
+ );
397
+ const sequence = (previous?.sequence ?? 0) + 1;
398
+ const createdAt = new Date().toISOString();
399
+ await create(
400
+ db,
401
+ "decision_transition",
402
+ {
403
+ decision_id: decisionId,
404
+ from_status: decision.status,
405
+ to_status: to,
406
+ scope_delta: scopeDelta,
407
+ actor_id: null,
408
+ reason,
409
+ created_at: createdAt,
410
+ sequence,
411
+ // Chained to the transition before it, so the history of a decision
412
+ // cannot be rewritten without the break showing.
413
+ immutable_hash: createHash("sha256")
414
+ .update(String(previous?.immutable_hash ?? ""))
415
+ .update(`${sequence} ${decision.status} ${to} ${createdAt}`)
416
+ .digest("hex"),
417
+ },
418
+ { activity: false },
419
+ );
420
+
421
+ if (to === "accepted") {
422
+ await patch(
423
+ db,
424
+ "decision",
425
+ decisionId,
426
+ decision.version,
427
+ { accepted_by: acceptedBy ?? actor, accepted_at: createdAt },
428
+ { projectId: decision.project_id, actor, activity: false },
429
+ );
430
+ }
431
+
432
+ // setStatus re-reads the row, so it sees whatever version the patch left.
433
+ return setStatus(db, "decision", decisionId, to, {
434
+ projectId: decision.project_id,
435
+ actor,
436
+ note: reason,
437
+ activityType: "decision_transitioned",
438
+ activitySummary: `${decisionId} moved to ${to.replace(/_/g, " ")}${reason ? `: ${reason}` : "."}`,
439
+ });
440
+ });
441
+ }
442
+
443
+ // ----------------------------------------------------------------- discovery
444
+
445
+ async function upsertDiscovery(root, payload) {
446
+ const id = identifier(payload, "id");
447
+ const kind = oneOf(payload, "kind", DISCOVERY_KINDS, { required: !id });
448
+ const statement = text(payload, "statement", { required: !id, max: LIMIT.prose });
449
+ const epistemicStatus = oneOf(payload, "epistemicStatus", EPISTEMIC_STATUSES);
450
+ const status = oneOf(payload, "status", DISCOVERY_STATUSES);
451
+ const provenance = text(payload, "provenance", { max: LIMIT.prose });
452
+ const x = number(payload, "positionX");
453
+ const y = number(payload, "positionY");
454
+ const expectedVersion = integer(payload, "expectedVersion", { min: 1 });
455
+ const actor = actorOf(payload);
456
+
457
+ return mutate(root, async (db) => {
458
+ const project = await currentProject(db);
459
+ if (!project) throw new MutationError(E.NOT_FOUND, "This directory has no Superdev project yet.");
460
+
461
+ const values = pick(
462
+ {
463
+ kind,
464
+ statement,
465
+ epistemic_status: epistemicStatus,
466
+ provenance,
467
+ position_x: x,
468
+ position_y: y,
469
+ },
470
+ ["kind", "statement", "epistemic_status", "provenance", "position_x", "position_y"],
471
+ );
472
+
473
+ if (!id) {
474
+ return create(
475
+ db,
476
+ "discovery_item",
477
+ { project_id: project.id, ...values, status: status ?? "proposed" },
478
+ {
479
+ projectId: project.id,
480
+ actor,
481
+ activityType: "scope_changed",
482
+ activitySummary: `Discovery item recorded: ${statement.slice(0, 160)}`,
483
+ },
484
+ );
485
+ }
486
+
487
+ const existing = await db.get("SELECT * FROM discovery_items WHERE id = ?", id);
488
+ if (!existing) throw new MutationError(E.NOT_FOUND, `There is no discovery item ${id}.`);
489
+ if (existing.status === "converted") {
490
+ throw new MutationError(
491
+ E.INVALID_TRANSITION,
492
+ `${id} has already been converted into ${existing.converted_type} ${existing.converted_id} and is now read only.`,
493
+ );
494
+ }
495
+ if (status === "accepted") values.accepted_at = new Date().toISOString();
496
+ const row = Object.keys(values).length
497
+ ? await patch(db, "discovery_item", id, expectedVersion ?? existing.version, values, {
498
+ projectId: project.id,
499
+ actor,
500
+ activityType: "scope_changed",
501
+ })
502
+ : existing;
503
+ if (!status || status === row.status) return row;
504
+ return setStatus(db, "discovery_item", id, status, {
505
+ projectId: project.id,
506
+ actor,
507
+ activityType: "scope_changed",
508
+ });
509
+ });
510
+ }
511
+
512
+ /**
513
+ * Turn an accepted concept into the structured record it was always about,
514
+ * keeping the pointer back to where it came from. Nothing is deleted: the
515
+ * discovery item stays as the provenance of the record it became.
516
+ */
517
+ async function convertDiscovery(root, payload) {
518
+ const id = identifier(payload, "id", { required: true });
519
+ const to = oneOf(payload, "to", ["goal", "module", "feature"], { required: true });
520
+ const name = text(payload, "name", { max: LIMIT.name });
521
+ const moduleId = identifier(payload, "moduleId", { required: to === "feature" });
522
+ const milestoneId = identifier(payload, "milestoneId");
523
+ const actor = actorOf(payload);
524
+
525
+ return mutate(root, async (db) => {
526
+ const item = await db.get("SELECT * FROM discovery_items WHERE id = ?", id);
527
+ if (!item) throw new MutationError(E.NOT_FOUND, `There is no discovery item ${id}.`);
528
+ if (item.status === "converted") {
529
+ throw new MutationError(
530
+ E.INVALID_TRANSITION,
531
+ `${id} is already ${item.converted_type} ${item.converted_id}. Open that record instead of converting again.`,
532
+ );
533
+ }
534
+ const projectId = item.project_id;
535
+ const title = name ?? item.statement.slice(0, LIMIT.name);
536
+
537
+ let created;
538
+ if (to === "goal") {
539
+ created = await create(
540
+ db,
541
+ "goal",
542
+ { project_id: projectId, name: title, description: item.statement, why_it_matters: item.provenance },
543
+ { projectId, actor, activityType: "scope_changed", activitySummary: `Goal created from ${id}: ${title}` },
544
+ );
545
+ } else if (to === "module") {
546
+ created = await create(
547
+ db,
548
+ "module",
549
+ {
550
+ project_id: projectId,
551
+ name: title,
552
+ slug: await uniqueSlug(db, "modules", projectId, title, "module"),
553
+ purpose: item.statement,
554
+ },
555
+ { projectId, actor, activityType: "scope_changed", activitySummary: `Module created from ${id}: ${title}` },
556
+ );
557
+ } else {
558
+ const module = await db.get("SELECT * FROM modules WHERE id = ?", moduleId);
559
+ if (!module) {
560
+ throw new MutationError(
561
+ E.NOT_FOUND,
562
+ `There is no module ${moduleId}. A feature belongs to exactly one module, so pick the module first.`,
563
+ );
564
+ }
565
+ if (milestoneId) {
566
+ const milestone = await db.get("SELECT id FROM milestones WHERE id = ?", milestoneId);
567
+ if (!milestone) throw new MutationError(E.NOT_FOUND, `There is no milestone ${milestoneId}.`);
568
+ }
569
+ created = await create(
570
+ db,
571
+ "feature",
572
+ {
573
+ project_id: projectId,
574
+ module_id: module.id,
575
+ milestone_id: milestoneId,
576
+ name: title,
577
+ slug: await uniqueSlug(db, "features", projectId, title, "feature"),
578
+ purpose: item.statement,
579
+ user_statement: item.statement,
580
+ },
581
+ { projectId, actor, activityType: "scope_changed", activitySummary: `Feature created from ${id}: ${title}` },
582
+ );
583
+ }
584
+
585
+ await patch(
586
+ db,
587
+ "discovery_item",
588
+ id,
589
+ item.version,
590
+ { converted_type: to, converted_id: created.id },
591
+ { projectId, actor, activity: false },
592
+ );
593
+ await setStatus(db, "discovery_item", id, "converted", {
594
+ projectId,
595
+ actor,
596
+ activityType: "scope_changed",
597
+ activitySummary: `${id} became ${to} ${created.id}.`,
598
+ });
599
+ return { converted: created, from: id, to };
600
+ });
601
+ }
602
+
603
+ // -------------------------------------------------------------------- layout
604
+
605
+ /**
606
+ * Canvas positions decorate records rather than describing them. They are saved
607
+ * as one batch with one activity event, because a drag is a single act and a
608
+ * per-node event would drown the timeline it shares with real work.
609
+ */
610
+ async function saveLayout(root, payload) {
611
+ const canvas = oneOf(payload, "canvas", CANVASES, { required: true });
612
+ const actor = actorOf(payload);
613
+ const raw = payload.positions;
614
+ if (!Array.isArray(raw)) throw invalid("positions has to be a list of node positions.");
615
+ if (raw.length > LIMIT.positions) {
616
+ throw invalid(`positions has ${raw.length} entries. The limit is ${LIMIT.positions}.`);
617
+ }
618
+
619
+ const positions = raw.map((entry) => {
620
+ requireObject(entry);
621
+ return {
622
+ recordType: oneOf(entry, "recordType", LAYOUT_RECORD_TYPES, { required: true }),
623
+ recordId: identifier(entry, "recordId", { required: true }),
624
+ x: number(entry, "x", { required: true }),
625
+ y: number(entry, "y", { required: true }),
626
+ };
627
+ });
628
+ if (!positions.length) return { canvas, saved: 0 };
629
+
630
+ return mutate(root, async (db) => {
631
+ const project = await currentProject(db);
632
+ if (!project) throw new MutationError(E.NOT_FOUND, "This directory has no Superdev project yet.");
633
+ const at = new Date().toISOString();
634
+
635
+ for (const position of positions) {
636
+ // layout_positions has no version column, so versionedUpdate cannot
637
+ // address it. The values are two numbers and a record id, all already
638
+ // validated above, and they are bound parameters rather than SQL text.
639
+ const info = await db.run(
640
+ `UPDATE layout_positions SET x = ?, y = ?, updated_at = ?
641
+ WHERE project_id = ? AND canvas = ? AND record_type = ? AND record_id = ?`,
642
+ position.x, position.y, at, project.id, canvas, position.recordType, position.recordId,
643
+ );
644
+ if (info.changes) continue;
645
+ await create(
646
+ db,
647
+ "layout_position",
648
+ {
649
+ project_id: project.id,
650
+ canvas,
651
+ record_type: position.recordType,
652
+ record_id: position.recordId,
653
+ x: position.x,
654
+ y: position.y,
655
+ updated_at: at,
656
+ },
657
+ { activity: false },
658
+ );
659
+ }
660
+
661
+ await recordActivity(db, project.id, {
662
+ type: "specification_changed",
663
+ actor,
664
+ summary: `Saved ${positions.length} node position(s) on the ${canvas} canvas.`,
665
+ metadata: { canvas, count: positions.length },
666
+ });
667
+ return { canvas, saved: positions.length };
668
+ });
669
+ }
670
+
671
+ // ---------------------------------------------------------------- documents
672
+
673
+ /**
674
+ * A document is addressed by its record id. The path handed to the proposal
675
+ * machinery is the one already stored on that row, never a string the browser
676
+ * chose, so no request can name a file the project does not track.
677
+ */
678
+ async function resolveDocument(root, payload) {
679
+ const documentId = identifier(payload, "documentId");
680
+ const requested = text(payload, "path", { max: LIMIT.line });
681
+ if (!documentId && !requested) throw invalid("documentId is required.");
682
+
683
+ const row = await query(root, (db) =>
684
+ documentId
685
+ ? db.get("SELECT * FROM documents WHERE id = ?", documentId)
686
+ : db.get("SELECT * FROM documents WHERE path = ?", requested),
687
+ );
688
+ if (!row) {
689
+ throw new MutationError(
690
+ E.NOT_FOUND,
691
+ `${documentId ?? requested} is not a document this project tracks. Open the Readiness view for the list of documents with pending edits.`,
692
+ );
693
+ }
694
+ if (row.sync_status !== "manual_edit_pending") {
695
+ throw new MutationError(
696
+ E.INVALID_TRANSITION,
697
+ `${row.path} has no pending manual edit to decide about. It is currently ${row.sync_status.replace(/_/g, " ")}.`,
698
+ );
699
+ }
700
+ return row;
701
+ }
702
+
703
+ // ------------------------------------------------------------------- actions
704
+
705
+ /** The complete allowlist. Anything not named here is refused. */
706
+ const ACTIONS = {
707
+ "task.create": (root, payload) =>
708
+ createTask(root, { ...taskInput(payload), featureId: identifier(payload, "featureId", { required: true }) }),
709
+
710
+ "task.update": (root, payload) => {
711
+ const taskId = identifier(payload, "taskId", { required: true });
712
+ const values = pick(
713
+ {
714
+ name: text(payload, "name", { max: LIMIT.name }),
715
+ description: text(payload, "description"),
716
+ expectedOutcome: text(payload, "expectedOutcome"),
717
+ whyNeeded: text(payload, "whyNeeded"),
718
+ completionCriteria: list(payload, "completionCriteria"),
719
+ verificationRequirements: list(payload, "verificationRequirements"),
720
+ affectedBoundaries: list(payload, "affectedBoundaries"),
721
+ priority: oneOf(payload, "priority", TASK_PRIORITIES),
722
+ risk: text(payload, "risk", { max: LIMIT.line }),
723
+ estimate: text(payload, "estimate", { max: 64 }),
724
+ dueAt: text(payload, "dueAt", { max: 40 }),
725
+ sequence: integer(payload, "sequence", { max: 100000 }),
726
+ categoryId: identifier(payload, "categoryId"),
727
+ parentTaskId: identifier(payload, "parentTaskId"),
728
+ enabling: flag(payload, "enabling"),
729
+ enabledFeatureId: identifier(payload, "enabledFeatureId"),
730
+ enablingRationale: text(payload, "enablingRationale"),
731
+ },
732
+ [
733
+ "name", "description", "expectedOutcome", "whyNeeded", "completionCriteria",
734
+ "verificationRequirements", "affectedBoundaries", "priority", "risk", "estimate",
735
+ "dueAt", "sequence", "categoryId", "parentTaskId", "enabling", "enabledFeatureId",
736
+ "enablingRationale",
737
+ ],
738
+ );
739
+ if (!Object.keys(values).length) throw invalid("task.update was given nothing to change.");
740
+ return updateTask(root, taskId, values, {
741
+ actor: actorOf(payload),
742
+ expectedVersion: integer(payload, "expectedVersion", { min: 1 }),
743
+ });
744
+ },
745
+
746
+ "task.claim": (root, payload) =>
747
+ claimTask(root, identifier(payload, "taskId", { required: true }), {
748
+ developerId: identifier(payload, "developerId"),
749
+ agentId: identifier(payload, "agentId"),
750
+ branchId: identifier(payload, "branchId"),
751
+ sessionId: identifier(payload, "sessionId"),
752
+ actor: actorOf(payload),
753
+ }),
754
+
755
+ "task.release": (root, payload) =>
756
+ releaseTask(root, identifier(payload, "taskId", { required: true }), {
757
+ actor: actorOf(payload),
758
+ reason: text(payload, "reason", { max: LIMIT.line }),
759
+ }),
760
+
761
+ "task.transition": transitionTask,
762
+
763
+ "task.block": (root, payload) =>
764
+ blockTask(root, identifier(payload, "taskId", { required: true }), {
765
+ reason: text(payload, "reason", { required: true, max: LIMIT.prose }),
766
+ actor: actorOf(payload),
767
+ }),
768
+
769
+ "task.complete": (root, payload) =>
770
+ completeTask(root, identifier(payload, "taskId", { required: true }), {
771
+ actor: actorOf(payload),
772
+ note: text(payload, "note", { max: LIMIT.line }),
773
+ }),
774
+
775
+ "task.reopen": (root, payload) =>
776
+ reopenTask(root, identifier(payload, "taskId", { required: true }), {
777
+ reason: text(payload, "reason", { required: true, max: LIMIT.prose }),
778
+ actor: actorOf(payload),
779
+ to: oneOf(payload, "to", ["ready", "in_progress"]),
780
+ }),
781
+
782
+ "task.cancel": (root, payload) =>
783
+ cancelTask(root, identifier(payload, "taskId", { required: true }), {
784
+ reason: text(payload, "reason", { required: true, max: LIMIT.prose }),
785
+ actor: actorOf(payload),
786
+ }),
787
+
788
+ "task.link": (root, payload) =>
789
+ linkContract(
790
+ root,
791
+ identifier(payload, "taskId", { required: true }),
792
+ {
793
+ targetType: oneOf(payload, "targetType", CONTRACT_TARGETS, { required: true }),
794
+ targetId: identifier(payload, "targetId", { required: true }),
795
+ relationship: oneOf(payload, "relationship", CONTRACT_RELATIONSHIPS, { fallback: "implements" }),
796
+ },
797
+ { actor: actorOf(payload) },
798
+ ),
799
+
800
+ "task.addSubtask": (root, payload) =>
801
+ addSubtask(root, identifier(payload, "parentTaskId", { required: true }), taskInput(payload)),
802
+
803
+ "question.answer": answerQuestion,
804
+ "decision.transition": transitionDecision,
805
+ "discovery.upsert": upsertDiscovery,
806
+ "discovery.convert": convertDiscovery,
807
+ "layout.save": saveLayout,
808
+
809
+ "docs.acceptProposal": async (root, payload) => {
810
+ const document = await resolveDocument(root, payload);
811
+ return acceptProposal(root, document.path, { actor: actorOf(payload) });
812
+ },
813
+
814
+ "docs.rejectProposal": async (root, payload) => {
815
+ const document = await resolveDocument(root, payload);
816
+ return rejectProposal(root, document.path, { actor: actorOf(payload) });
817
+ },
818
+
819
+ // Categories are data, not constants: the seeded set is a starting
820
+ // vocabulary and a project is expected to add its own.
821
+ "category.create": (root, payload) =>
822
+ createCategory(root, {
823
+ name: text(payload, "name", { max: 60, required: true }),
824
+ description: text(payload, "description", { max: 240 }),
825
+ actor: actorOf(payload),
826
+ }),
827
+
828
+ "category.update": (root, payload) =>
829
+ updateCategory(root, identifier(payload, "categoryId", { required: true }), {
830
+ name: text(payload, "name", { max: 60 }),
831
+ description: text(payload, "description", { max: 240 }),
832
+ actor: actorOf(payload),
833
+ }),
834
+
835
+ // Retiring, never deleting: a category still names the work already filed
836
+ // under it, so removing the row would rewrite that history.
837
+ "category.retire": (root, payload) =>
838
+ setCategoryActive(root, identifier(payload, "categoryId", { required: true }), false, actorOf(payload)),
839
+
840
+ "category.restore": (root, payload) =>
841
+ setCategoryActive(root, identifier(payload, "categoryId", { required: true }), true, actorOf(payload)),
842
+ };
843
+
844
+ /** Every accepted action name, for the refusal message and for the interface. */
845
+ export const ALLOWED_ACTIONS = Object.freeze(Object.keys(ACTIONS));
846
+
847
+ /**
848
+ * Run one mutation. `action` must be on the allowlist and `payload` must survive
849
+ * field-by-field validation; anything else is refused with a stable code before
850
+ * a connection is opened.
851
+ */
852
+ export async function applyMutation(root, action, payload) {
853
+ if (typeof action !== "string" || !Object.hasOwn(ACTIONS, action)) {
854
+ throw new MutationError(
855
+ E.UNKNOWN_ACTION,
856
+ `${typeof action === "string" && action ? `"${action}"` : "That"} is not something this service can do. Accepted actions: ${ALLOWED_ACTIONS.join(", ")}.`,
857
+ );
858
+ }
859
+ return ACTIONS[action](root, requireObject(payload ?? {}));
860
+ }