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,1557 @@
1
+ // One function per GET route in MODULE_CONTRACT.md.
2
+ //
3
+ // The control center reads nothing but these payloads and never touches the
4
+ // filesystem, so every question a screen asks has to be answerable from here.
5
+ // Each function opens one readonly connection, builds its whole payload inside
6
+ // it, and stamps the revision it was built from. Two payloads never disagree
7
+ // because each is a single pinned snapshot.
8
+ //
9
+ // Progress is never recomputed here. It comes from ../progress/index.mjs, which
10
+ // the CLI also uses, so the number in the terminal is the number in the browser.
11
+
12
+ import {
13
+ query,
14
+ json,
15
+ DbError,
16
+ } from "../db/store.mjs";
17
+ import {
18
+ featureProgress,
19
+ moduleProgress,
20
+ milestoneProgress,
21
+ goalProgress,
22
+ projectProgress,
23
+ readiness,
24
+ freshness,
25
+ nextAction,
26
+ alignmentWarnings,
27
+ honestPercent,
28
+ SESSION_STALE_MS,
29
+ } from "../progress/index.mjs";
30
+ import { MODULE_STEPS, agrees, count, titleCase } from "../model/vocabulary.mjs";
31
+
32
+ const marks = (n) => Array.from({ length: n }, () => "?").join(",");
33
+ const nowIso = () => new Date().toISOString();
34
+
35
+ /** A session quiet for longer than this is Idle rather than Live. */
36
+ const LIVE_WITHIN_MS = 5 * 60 * 1000;
37
+
38
+ /** How many plain-language lines the overview lists before it stops. */
39
+ const OVERVIEW_LINES = 8;
40
+
41
+ /**
42
+ * Every `*_json` column is stored as text and consumed as structured data. The
43
+ * interface types declare arrays and objects, so the parse happens once here
44
+ * rather than in every component.
45
+ */
46
+ function hydrate(row) {
47
+ if (!row) return row ?? null;
48
+ const out = {};
49
+ for (const [key, value] of Object.entries(row)) {
50
+ out[key] = key.endsWith("_json") ? json(value, null) : value;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ const hydrateAll = (rows) => rows.map(hydrate);
56
+
57
+ /**
58
+ * The progress shape the interface renders.
59
+ *
60
+ * `counts` is the NOUN the totals are counted in, because the interface builds
61
+ * "5 of 47 readiness items addressed" from it and then pluralizes it. The
62
+ * per-component breakdown travels separately as a list: joining the breakdown
63
+ * into this field produced a run-on headline with a pluralizing "s" stuck on
64
+ * the end of the last sentence, which is what the lists are for avoiding.
65
+ */
66
+ function toProgress(p) {
67
+ return {
68
+ ...p,
69
+ counts: p.unit ?? "tracked item",
70
+ countsBreakdown: p.whatCounts ?? [],
71
+ remainsBreakdown: p.whatRemains ?? [],
72
+ // Joined with a space, three clauses ran together as one unreadable line:
73
+ // "Accepted features delivered: 1 outstanding Milestones reached: 2
74
+ // outstanding Goal success criteria met: 2 outstanding".
75
+ remains: p.whatRemains?.length ? p.whatRemains.join("; ") : null,
76
+ computedAt: p.generatedAt,
77
+ };
78
+ }
79
+
80
+ async function revisionOf(db) {
81
+ return (await db.value("SELECT max(sequence) FROM activity_events")) ?? 0;
82
+ }
83
+
84
+ async function projectOf(db) {
85
+ const project = await db.get("SELECT * FROM projects ORDER BY created_at LIMIT 1");
86
+ if (!project) {
87
+ throw new DbError(
88
+ "E_NOT_FOUND",
89
+ "This directory has no Superdev project yet. Run `superdev init` in the project root, then reload.",
90
+ );
91
+ }
92
+ return project;
93
+ }
94
+
95
+ /**
96
+ * Run one read and wrap it in the freshness envelope. The revision is read from
97
+ * the same pinned snapshot the payload came from, so it always describes
98
+ * exactly the data that is being returned.
99
+ */
100
+ async function envelope(root, build) {
101
+ return query(root, async (db) => {
102
+ const project = await projectOf(db);
103
+ const data = await build(db, project);
104
+ const revision = await revisionOf(db);
105
+ return {
106
+ data,
107
+ meta: {
108
+ revision,
109
+ lastEventSequence: revision,
110
+ generatedAt: nowIso(),
111
+ // A fresh connection per read is pinned to the present, so a payload
112
+ // that was just built cannot already lag the database.
113
+ stale: false,
114
+ },
115
+ };
116
+ });
117
+ }
118
+
119
+ /** Saved canvas positions for one blueprint surface. */
120
+ async function layoutFor(db, projectId, canvas) {
121
+ const rows = await db.all(
122
+ "SELECT * FROM layout_positions WHERE project_id = ? AND canvas = ?",
123
+ projectId,
124
+ canvas,
125
+ );
126
+ return rows.map((r) => ({ id: r.record_id, recordType: r.record_type, x: r.x, y: r.y }));
127
+ }
128
+
129
+ /** The primary flow lives in its own table, so features carry it as a list. */
130
+ async function flowsByFeature(db, featureIds) {
131
+ const map = new Map(featureIds.map((id) => [id, []]));
132
+ if (!featureIds.length) return map;
133
+ const rows = await db.all(
134
+ `SELECT * FROM feature_flows WHERE feature_id IN (${marks(featureIds.length)})
135
+ ORDER BY feature_id, sequence`,
136
+ ...featureIds,
137
+ );
138
+ for (const row of rows) map.get(row.feature_id)?.push(row.step);
139
+ return map;
140
+ }
141
+
142
+ // ------------------------------------------------------------------ overview
143
+
144
+ export function overview(root) {
145
+ return envelope(root, async (db, project) => {
146
+ const [progress, fresh, next, warnings] = [
147
+ await projectProgress(db, project.id),
148
+ await freshness(db, project.id),
149
+ await nextAction(db, project.id),
150
+ await alignmentWarnings(db, project.id),
151
+ ];
152
+
153
+ const features = hydrateAll(await db.all("SELECT * FROM features WHERE project_id = ?", project.id));
154
+ const tasks = await db.all("SELECT * FROM tasks WHERE project_id = ?", project.id);
155
+ const milestones = await db.all("SELECT * FROM milestones WHERE project_id = ?", project.id);
156
+ const questions = await db.all(
157
+ "SELECT * FROM questions WHERE project_id = ? AND status = 'open'",
158
+ project.id,
159
+ );
160
+
161
+ const liveFeatures = features.filter((f) => !["cancelled", "superseded"].includes(f.status));
162
+ const openTasks = tasks.filter((t) => !["complete", "cancelled", "superseded"].includes(t.status));
163
+ const inFlight = openTasks.filter((t) => ["in_progress", "in_review", "verifying"].includes(t.status));
164
+ const blockedTasks = openTasks.filter((t) => t.status === "blocked");
165
+
166
+ const headline = [
167
+ { label: "Features in scope", value: liveFeatures.length, unit: "feature", href: "#/product" },
168
+ {
169
+ label: "Features complete",
170
+ value: liveFeatures.filter((f) => f.status === "complete").length,
171
+ unit: "feature",
172
+ href: "#/product",
173
+ },
174
+ { label: "Work in progress", value: inFlight.length, unit: "task", href: "#/tasks" },
175
+ { label: "Blocked", value: blockedTasks.length, unit: "task", href: "#/tasks" },
176
+ { label: "Open tasks", value: openTasks.length, unit: "task", href: "#/tasks" },
177
+ { label: "Open questions", value: questions.length, unit: "question", href: "#/discovery" },
178
+ {
179
+ label: "Milestones reached",
180
+ value: milestones.filter((m) => ["complete", "accepted", "delivered"].includes(m.status)).length,
181
+ unit: "milestone",
182
+ href: "#/product",
183
+ },
184
+ ];
185
+
186
+ const whatWorks = [];
187
+ for (const feature of liveFeatures) {
188
+ if (feature.what_works_now) whatWorks.push(`${feature.name}: ${feature.what_works_now}`);
189
+ else if (feature.status === "complete") whatWorks.push(`${feature.name} is complete.`);
190
+ if (whatWorks.length >= OVERVIEW_LINES) break;
191
+ }
192
+
193
+ const whatIsBlocked = blockedTasks
194
+ .slice(0, OVERVIEW_LINES)
195
+ .map((t) => `${t.name}: ${t.block_reason ?? "blocked with no reason recorded."}`);
196
+ for (const warning of warnings) {
197
+ if (whatIsBlocked.length >= OVERVIEW_LINES) break;
198
+ if (warning.severity === "high") whatIsBlocked.push(`${warning.title}: ${warning.detail}`);
199
+ }
200
+
201
+ const activeWork = await activeWorkItems(db, inFlight, features);
202
+
203
+ return {
204
+ project: hydrate(project),
205
+ headline,
206
+ whatWorks,
207
+ whatIsBlocked,
208
+ activeWork,
209
+ nextAction: {
210
+ ...next,
211
+ summary: next.title,
212
+ reason: next.detail,
213
+ targetType: next.recordType,
214
+ targetId: next.recordId,
215
+ href: hrefFor(next.recordType, next.recordId),
216
+ },
217
+ warnings,
218
+ progress: toProgress(progress),
219
+ freshness: {
220
+ ...fresh,
221
+ databaseRevision: fresh.revision,
222
+ activeSessionHeartbeatAt: fresh.lastSessionHeartbeatAt,
223
+ branchHead: fresh.branches[0]?.headRevision ?? null,
224
+ staleEvidenceCount: fresh.staleEvidence.length,
225
+ pendingDocProposalCount: fresh.pendingDocumentProposals.length,
226
+ },
227
+ };
228
+ });
229
+ }
230
+
231
+ function hrefFor(recordType, recordId) {
232
+ if (!recordType || !recordId) return null;
233
+ const route = {
234
+ task: "#/tasks",
235
+ feature: "#/features",
236
+ module: "#/product",
237
+ milestone: "#/product",
238
+ goal: "#/product",
239
+ question: "#/discovery",
240
+ decision: "#/decisions",
241
+ verification_evidence: "#/tasks",
242
+ feature_acceptance_criterion: "#/features",
243
+ }[recordType];
244
+ return route ? `${route}/${recordId}` : null;
245
+ }
246
+
247
+ async function activeWorkItems(db, tasks, features) {
248
+ if (!tasks.length) return [];
249
+ const featureName = new Map(features.map((f) => [f.id, f.name]));
250
+ const assignments = await db.all(
251
+ `SELECT * FROM task_assignments WHERE active = 1 AND task_id IN (${marks(tasks.length)})`,
252
+ ...tasks.map((t) => t.id),
253
+ );
254
+ const byTask = new Map(assignments.map((a) => [a.task_id, a]));
255
+ const developers = new Map(
256
+ (await db.all("SELECT * FROM developers")).map((d) => [d.id, d.display_name]),
257
+ );
258
+ const agents = new Map(
259
+ (await db.all("SELECT * FROM agents")).map((a) => [a.id, a.model_label ?? a.harness]),
260
+ );
261
+ const branches = new Map((await db.all("SELECT * FROM branches")).map((b) => [b.id, b.name]));
262
+ const sessions = await db.all("SELECT * FROM work_sessions WHERE status = 'active'");
263
+ const sessionByTask = new Map(sessions.filter((s) => s.active_task_id).map((s) => [s.active_task_id, s]));
264
+
265
+ return tasks.map((task) => {
266
+ const assignment = byTask.get(task.id);
267
+ const session = sessionByTask.get(task.id);
268
+ return {
269
+ taskId: task.id,
270
+ taskName: task.name,
271
+ featureId: task.feature_id,
272
+ featureName: featureName.get(task.feature_id) ?? null,
273
+ status: task.status,
274
+ actor:
275
+ (assignment && (developers.get(assignment.developer_id) ?? agents.get(assignment.agent_id))) ??
276
+ null,
277
+ branch: assignment ? branches.get(assignment.branch_id) ?? null : null,
278
+ startedAt: task.started_at,
279
+ lastActivityAt: session?.last_activity_at ?? task.updated_at,
280
+ };
281
+ });
282
+ }
283
+
284
+ // ----------------------------------------------------------------- discovery
285
+
286
+ export function discovery(root) {
287
+ return envelope(root, async (db, project) => {
288
+ const items = hydrateAll(
289
+ await db.all(
290
+ "SELECT * FROM discovery_items WHERE project_id = ? ORDER BY created_at, id",
291
+ project.id,
292
+ ),
293
+ );
294
+ const ids = items.map((i) => i.id);
295
+ const links = ids.length
296
+ ? await db.all(
297
+ `SELECT * FROM discovery_links WHERE from_id IN (${marks(ids.length)})`,
298
+ ...ids,
299
+ )
300
+ : [];
301
+
302
+ return {
303
+ sources: hydrateAll(
304
+ await db.all(
305
+ "SELECT * FROM source_material WHERE project_id = ? ORDER BY received_at, id",
306
+ project.id,
307
+ ),
308
+ ),
309
+ items,
310
+ links,
311
+ questions: hydrateAll(
312
+ await db.all("SELECT * FROM questions WHERE project_id = ? ORDER BY created_at, id", project.id),
313
+ ),
314
+ assumptions: items.filter((i) => i.kind === "assumption"),
315
+ risks: items.filter((i) => i.kind === "risk"),
316
+ // A concept dragged on the mind map keeps its position on the item row;
317
+ // the layout table covers records that have no position of their own.
318
+ layout: [
319
+ ...items
320
+ .filter((i) => i.position_x !== null && i.position_y !== null)
321
+ .map((i) => ({ id: i.id, recordType: "discovery_item", x: i.position_x, y: i.position_y })),
322
+ ...(await layoutFor(db, project.id, "discovery")),
323
+ ],
324
+ };
325
+ });
326
+ }
327
+
328
+ // ------------------------------------------------------------------- product
329
+
330
+ export function product(root) {
331
+ return envelope(root, async (db, project) => {
332
+ const goals = hydrateAll(
333
+ await db.all("SELECT * FROM goals WHERE project_id = ? ORDER BY sequence, id", project.id),
334
+ );
335
+ const milestones = hydrateAll(
336
+ await db.all("SELECT * FROM milestones WHERE project_id = ? ORDER BY sequence, id", project.id),
337
+ );
338
+ const modules = hydrateAll(
339
+ await db.all("SELECT * FROM modules WHERE project_id = ? ORDER BY sequence, id", project.id),
340
+ );
341
+ const features = hydrateAll(
342
+ await db.all("SELECT * FROM features WHERE project_id = ? ORDER BY module_id, name", project.id),
343
+ );
344
+
345
+ const criteria = goals.length
346
+ ? await db.all(
347
+ `SELECT * FROM goal_success_criteria WHERE goal_id IN (${marks(goals.length)})
348
+ ORDER BY goal_id, sequence`,
349
+ ...goals.map((g) => g.id),
350
+ )
351
+ : [];
352
+ const featureGoals = await db.all("SELECT * FROM feature_goals");
353
+ const flows = await flowsByFeature(db, features.map((f) => f.id));
354
+ const scope = await db.all(
355
+ "SELECT * FROM project_scope_items WHERE project_id = ? ORDER BY direction, sequence",
356
+ project.id,
357
+ );
358
+
359
+ return {
360
+ goals: await Promise.all(
361
+ goals.map(async (goal) => ({
362
+ ...goal,
363
+ successCriteria: criteria.filter((c) => c.goal_id === goal.id),
364
+ progress: toProgress(await goalProgress(db, goal.id)),
365
+ })),
366
+ ),
367
+ milestones: await Promise.all(
368
+ milestones.map(async (m) => ({ ...m, progress: toProgress(await milestoneProgress(db, m.id)) })),
369
+ ),
370
+ modules: await Promise.all(
371
+ modules.map(async (m) => ({ ...m, progress: toProgress(await moduleProgress(db, m.id)) })),
372
+ ),
373
+ features: await Promise.all(
374
+ features.map(async (f) => ({
375
+ ...f,
376
+ primary_flow_json: flows.get(f.id) ?? [],
377
+ goalIds: featureGoals.filter((fg) => fg.feature_id === f.id).map((fg) => fg.goal_id),
378
+ progress: toProgress(await featureProgress(db, f.id)),
379
+ })),
380
+ ),
381
+ scope,
382
+ // The blueprint canvas draws from this payload and reads its saved
383
+ // positions out of `layout`. Without this the canvas asked for a layout
384
+ // on every load, always got nothing, and every arrangement anyone made
385
+ // was lost on refresh.
386
+ layout: await layoutFor(db, project.id, "blueprint"),
387
+ progress: toProgress(await projectProgress(db, project.id)),
388
+ };
389
+ });
390
+ }
391
+
392
+ // ------------------------------------------------------------ feature detail
393
+
394
+ export function feature(root, featureId) {
395
+ return envelope(root, async (db) => {
396
+ const row = await db.get("SELECT * FROM features WHERE id = ?", featureId);
397
+ if (!row) {
398
+ throw new DbError("E_NOT_FOUND", `There is no feature ${featureId}. Check the id, or open the Product view to pick one.`);
399
+ }
400
+ const record = hydrate(row);
401
+ const flows = await flowsByFeature(db, [featureId]);
402
+
403
+ const workflows = hydrateAll(
404
+ await db.all("SELECT * FROM workflows WHERE feature_id = ? ORDER BY name", featureId),
405
+ );
406
+ const surfaces = hydrateAll(
407
+ await db.all("SELECT * FROM surfaces WHERE feature_id = ? ORDER BY name", featureId),
408
+ );
409
+ const uiActions = surfaces.length
410
+ ? hydrateAll(
411
+ await db.all(
412
+ `SELECT * FROM ui_actions WHERE surface_id IN (${marks(surfaces.length)}) ORDER BY surface_id, name`,
413
+ ...surfaces.map((s) => s.id),
414
+ ),
415
+ )
416
+ : [];
417
+ const surfaceStates = surfaces.length
418
+ ? await db.all(
419
+ `SELECT * FROM surface_states WHERE surface_id IN (${marks(surfaces.length)})`,
420
+ ...surfaces.map((s) => s.id),
421
+ )
422
+ : [];
423
+
424
+ const goalIds = (await db.all("SELECT * FROM feature_goals WHERE feature_id = ?", featureId)).map(
425
+ (fg) => fg.goal_id,
426
+ );
427
+
428
+ const migrations = await db.all(
429
+ "SELECT * FROM schema_migrations WHERE feature_id = ? ORDER BY sequence",
430
+ featureId,
431
+ );
432
+
433
+ return {
434
+ feature: { ...record, primary_flow_json: flows.get(featureId) ?? [], goalIds },
435
+ module: hydrate(await db.get("SELECT * FROM modules WHERE id = ?", record.module_id)),
436
+ milestone: record.milestone_id
437
+ ? hydrate(await db.get("SELECT * FROM milestones WHERE id = ?", record.milestone_id))
438
+ : null,
439
+ goals: goalIds.length
440
+ ? hydrateAll(await db.all(`SELECT * FROM goals WHERE id IN (${marks(goalIds.length)})`, ...goalIds))
441
+ : [],
442
+ acceptanceCriteria: await db.all(
443
+ "SELECT * FROM feature_acceptance_criteria WHERE feature_id = ? ORDER BY sequence",
444
+ featureId,
445
+ ),
446
+ edgeCases: await db.all(
447
+ "SELECT * FROM feature_edge_cases WHERE feature_id = ? ORDER BY category",
448
+ featureId,
449
+ ),
450
+ workflows,
451
+ surfaces,
452
+ surfaceStates,
453
+ uiActions,
454
+ apiOperations: hydrateAll(
455
+ await db.all("SELECT * FROM api_operations WHERE feature_id = ? ORDER BY name", featureId),
456
+ ),
457
+ dataEntities: hydrateAll(
458
+ await db.all("SELECT * FROM data_entities WHERE feature_id = ? ORDER BY name", featureId),
459
+ ),
460
+ migrations,
461
+ integrations: hydrateAll(
462
+ await db.all("SELECT * FROM integrations WHERE feature_id = ? ORDER BY name", featureId),
463
+ ),
464
+ jobs: hydrateAll(await db.all("SELECT * FROM jobs WHERE feature_id = ? ORDER BY name", featureId)),
465
+ webhooks: hydrateAll(
466
+ await db.all("SELECT * FROM webhooks WHERE feature_id = ? ORDER BY name", featureId),
467
+ ),
468
+ nfrs: await db.all(
469
+ "SELECT * FROM non_functional_requirements WHERE feature_id = ? ORDER BY category",
470
+ featureId,
471
+ ),
472
+ decisions: hydrateAll(
473
+ await db.all(
474
+ `SELECT d.* FROM decisions d
475
+ WHERE (d.scope_type = 'feature' AND d.scope_id = ?)
476
+ OR d.id IN (SELECT decision_id FROM decision_links
477
+ WHERE target_type = 'feature' AND target_id = ?)
478
+ ORDER BY d.created_at`,
479
+ featureId,
480
+ featureId,
481
+ ),
482
+ ),
483
+ tasks: hydrateAll(
484
+ await db.all("SELECT * FROM tasks WHERE feature_id = ? ORDER BY sequence, id", featureId),
485
+ ),
486
+ evidence: await db.all(
487
+ "SELECT * FROM verification_evidence WHERE feature_id = ? ORDER BY recorded_at DESC",
488
+ featureId,
489
+ ),
490
+ progress: toProgress(await featureProgress(db, featureId)),
491
+ };
492
+ });
493
+ }
494
+
495
+ // ----------------------------------------------------------------- workflows
496
+
497
+ export function workflows(root) {
498
+ return envelope(root, async (db, project) => {
499
+ const rows = hydrateAll(
500
+ await db.all(
501
+ `SELECT w.* FROM workflows w
502
+ JOIN features f ON f.id = w.feature_id
503
+ WHERE f.project_id = ? ORDER BY w.name`,
504
+ project.id,
505
+ ),
506
+ );
507
+ const ids = rows.map((w) => w.id);
508
+
509
+ const actorRows = ids.length
510
+ ? await db.all(
511
+ `SELECT * FROM workflow_actors WHERE workflow_id IN (${marks(ids.length)}) ORDER BY workflow_id, sequence`,
512
+ ...ids,
513
+ )
514
+ : [];
515
+ const steps = ids.length
516
+ ? await db.all(
517
+ `SELECT * FROM workflow_steps WHERE workflow_id IN (${marks(ids.length)}) ORDER BY workflow_id, sequence`,
518
+ ...ids,
519
+ )
520
+ : [];
521
+ const branches = ids.length
522
+ ? await db.all(`SELECT * FROM workflow_branches WHERE workflow_id IN (${marks(ids.length)})`, ...ids)
523
+ : [];
524
+
525
+ // The execution overlay: which tasks claim to implement each step.
526
+ const stepLinks = await db.all(
527
+ "SELECT * FROM task_contract_links WHERE target_type = 'workflow_step'",
528
+ );
529
+ const tasksByStep = new Map();
530
+ for (const link of stepLinks) {
531
+ if (!tasksByStep.has(link.target_id)) tasksByStep.set(link.target_id, []);
532
+ tasksByStep.get(link.target_id).push(link.task_id);
533
+ }
534
+ const taskStatus = new Map(
535
+ (await db.all("SELECT id, status FROM tasks WHERE project_id = ?", project.id)).map((t) => [
536
+ t.id,
537
+ t.status,
538
+ ]),
539
+ );
540
+
541
+ const machines = await db.all(
542
+ `SELECT * FROM state_machines
543
+ WHERE module_id IN (SELECT id FROM modules WHERE project_id = ?)
544
+ OR feature_id IN (SELECT id FROM features WHERE project_id = ?)`,
545
+ project.id,
546
+ project.id,
547
+ );
548
+ const machineIds = machines.map((m) => m.id);
549
+
550
+ return {
551
+ workflows: rows.map((w) => ({
552
+ ...w,
553
+ actors_json: actorRows.filter((a) => a.workflow_id === w.id).map((a) => a.actor),
554
+ })),
555
+ steps: steps.map((s) => ({
556
+ ...s,
557
+ taskIds: tasksByStep.get(s.id) ?? [],
558
+ taskStatuses: (tasksByStep.get(s.id) ?? []).map((id) => taskStatus.get(id) ?? "unknown"),
559
+ })),
560
+ branches,
561
+ actors: [...new Set(actorRows.map((a) => a.actor))].sort(),
562
+ stateMachines: machines,
563
+ states: machineIds.length
564
+ ? hydrateAll(
565
+ await db.all(
566
+ `SELECT * FROM states WHERE state_machine_id IN (${marks(machineIds.length)}) ORDER BY state_machine_id, sequence`,
567
+ ...machineIds,
568
+ ),
569
+ )
570
+ : [],
571
+ transitions: machineIds.length
572
+ ? await db.all(
573
+ `SELECT * FROM state_transitions WHERE state_machine_id IN (${marks(machineIds.length)})`,
574
+ ...machineIds,
575
+ )
576
+ : [],
577
+ layout: await layoutFor(db, project.id, "workflow"),
578
+ };
579
+ });
580
+ }
581
+
582
+ // ---------------------------------------------------------------------- data
583
+
584
+ export function data(root) {
585
+ return envelope(root, async (db, project) => {
586
+ const entities = hydrateAll(
587
+ await db.all(
588
+ `SELECT e.* FROM data_entities e
589
+ JOIN modules m ON m.id = e.module_id
590
+ WHERE m.project_id = ? ORDER BY e.name`,
591
+ project.id,
592
+ ),
593
+ );
594
+ const ids = entities.map((e) => e.id);
595
+
596
+ return {
597
+ entities,
598
+ fields: ids.length
599
+ ? hydrateAll(
600
+ await db.all(
601
+ `SELECT * FROM data_fields WHERE entity_id IN (${marks(ids.length)}) ORDER BY entity_id, sequence`,
602
+ ...ids,
603
+ ),
604
+ )
605
+ : [],
606
+ relationships: ids.length
607
+ ? await db.all(
608
+ `SELECT * FROM data_relationships WHERE from_entity_id IN (${marks(ids.length)})`,
609
+ ...ids,
610
+ )
611
+ : [],
612
+ migrations: await db.all(
613
+ "SELECT * FROM schema_migrations WHERE project_id = ? ORDER BY sequence",
614
+ project.id,
615
+ ),
616
+ migrationEntities: await db.all(
617
+ `SELECT * FROM schema_migration_entities
618
+ WHERE migration_id IN (SELECT id FROM schema_migrations WHERE project_id = ?)`,
619
+ project.id,
620
+ ),
621
+ layout: [
622
+ ...entities
623
+ .filter((e) => e.position_x !== null && e.position_y !== null)
624
+ .map((e) => ({ id: e.id, recordType: "data_entity", x: e.position_x, y: e.position_y })),
625
+ ...(await layoutFor(db, project.id, "data")),
626
+ ],
627
+ };
628
+ });
629
+ }
630
+
631
+ // -------------------------------------------------------------- architecture
632
+
633
+ export function architecture(root) {
634
+ return envelope(root, async (db, project) => {
635
+ const pieces = await db.all(
636
+ "SELECT * FROM runtime_pieces WHERE project_id = ? ORDER BY sequence, name",
637
+ project.id,
638
+ );
639
+ const pieceIds = pieces.map((p) => p.id);
640
+ const edges = pieceIds.length
641
+ ? await db.all(
642
+ `SELECT * FROM runtime_piece_edges WHERE from_piece_id IN (${marks(pieceIds.length)})`,
643
+ ...pieceIds,
644
+ )
645
+ : [];
646
+
647
+ const modules = hydrateAll(
648
+ await db.all("SELECT * FROM modules WHERE project_id = ? ORDER BY sequence, id", project.id),
649
+ );
650
+ const dependencies = moduleDependencies(modules);
651
+ const entities = await db.all(
652
+ `SELECT id, name, module_id FROM data_entities
653
+ WHERE module_id IN (SELECT id FROM modules WHERE project_id = ?)`,
654
+ project.id,
655
+ );
656
+
657
+ return {
658
+ pieces: pieces.map((p) => ({
659
+ id: p.id,
660
+ name: p.name,
661
+ kind: p.runs_where ?? "component",
662
+ purpose: null,
663
+ runsWhere: p.runs_where,
664
+ evidenceRef: p.evidence_ref,
665
+ moduleId: null,
666
+ status: "specified",
667
+ })),
668
+ edges: edges.map((e) => ({
669
+ id: `${e.from_piece_id}->${e.to_piece_id}`,
670
+ from_id: e.from_piece_id,
671
+ to_id: e.to_piece_id,
672
+ relationship: e.protocol ? `talks to over ${e.protocol}` : "talks to",
673
+ protocol: e.protocol,
674
+ })),
675
+ modules: modules.map((m) => ({
676
+ id: m.id,
677
+ name: m.name,
678
+ purpose: m.purpose,
679
+ status: m.status,
680
+ owns: m.owns_json ?? [],
681
+ consumes: m.consumes_json ?? [],
682
+ ownsEntityIds: entities.filter((e) => e.module_id === m.id).map((e) => e.id),
683
+ })),
684
+ moduleDependencies: dependencies,
685
+ criticalPath: criticalPath(modules, dependencies),
686
+ integrations: hydrateAll(
687
+ await db.all(
688
+ `SELECT * FROM integrations
689
+ WHERE module_id IN (SELECT id FROM modules WHERE project_id = ?) ORDER BY name`,
690
+ project.id,
691
+ ),
692
+ ),
693
+ layout: await layoutFor(db, project.id, "architecture"),
694
+ };
695
+ });
696
+ }
697
+
698
+ const normalize = (value) => String(value ?? "").trim().toLowerCase();
699
+
700
+ /**
701
+ * A module dependency is not stored as an edge. It is implied: one module
702
+ * consumes what another owns. Deriving it keeps the two lists honest, because a
703
+ * consumed thing nobody owns shows up as an absent edge rather than as a lie.
704
+ */
705
+ function moduleDependencies(modules) {
706
+ const owners = new Map();
707
+ for (const module of modules) {
708
+ for (const owned of module.owns_json ?? []) {
709
+ const key = normalize(owned);
710
+ if (key) owners.set(key, module.id);
711
+ }
712
+ }
713
+ const out = [];
714
+ const seen = new Set();
715
+ for (const module of modules) {
716
+ for (const consumed of module.consumes_json ?? []) {
717
+ const owner = owners.get(normalize(consumed));
718
+ if (!owner || owner === module.id) continue;
719
+ const key = `${module.id}->${owner}`;
720
+ if (seen.has(key)) continue;
721
+ seen.add(key);
722
+ out.push({
723
+ from_module_id: module.id,
724
+ to_module_id: owner,
725
+ reason: `${module.name} consumes ${consumed}, which ${
726
+ modules.find((m) => m.id === owner)?.name ?? owner
727
+ } owns.`,
728
+ critical: false,
729
+ });
730
+ }
731
+ }
732
+ return out;
733
+ }
734
+
735
+ /**
736
+ * The longest chain of dependent modules. Anything shorter can be built in
737
+ * parallel with it, so this is the ordering that actually bounds delivery.
738
+ * Cycles are broken rather than followed, because a dependency cycle is a
739
+ * modelling error that must not hang the request.
740
+ */
741
+ function criticalPath(modules, dependencies) {
742
+ const out = new Map(modules.map((m) => [m.id, []]));
743
+ for (const dep of dependencies) out.get(dep.from_module_id)?.push(dep.to_module_id);
744
+
745
+ const memo = new Map();
746
+ const visiting = new Set();
747
+ const longest = (id) => {
748
+ if (memo.has(id)) return memo.get(id);
749
+ if (visiting.has(id)) return [id];
750
+ visiting.add(id);
751
+ let best = [];
752
+ for (const next of out.get(id) ?? []) {
753
+ const chain = longest(next);
754
+ if (chain.length > best.length) best = chain;
755
+ }
756
+ visiting.delete(id);
757
+ const path = [id, ...best];
758
+ memo.set(id, path);
759
+ return path;
760
+ };
761
+
762
+ let winner = [];
763
+ for (const module of modules) {
764
+ const path = longest(module.id);
765
+ if (path.length > winner.length) winner = path;
766
+ }
767
+ // A single module is not a path worth drawing.
768
+ const chain = winner.length > 1 ? winner : [];
769
+ const onPath = new Set(chain.map((id, i) => `${id}->${chain[i + 1]}`));
770
+ for (const dep of dependencies) {
771
+ if (onPath.has(`${dep.from_module_id}->${dep.to_module_id}`)) dep.critical = true;
772
+ }
773
+ return chain;
774
+ }
775
+
776
+ // --------------------------------------------------------------------- tasks
777
+
778
+ export function tasks(root) {
779
+ return envelope(root, async (db, project) => {
780
+ const rows = hydrateAll(
781
+ await db.all("SELECT * FROM tasks WHERE project_id = ? ORDER BY sequence, id", project.id),
782
+ );
783
+ const ids = rows.map((t) => t.id);
784
+
785
+ const categories = await db.all(
786
+ "SELECT * FROM task_categories WHERE project_id = ? ORDER BY sequence, name",
787
+ project.id,
788
+ );
789
+ const contractLinks = ids.length
790
+ ? await db.all(`SELECT * FROM task_contract_links WHERE task_id IN (${marks(ids.length)})`, ...ids)
791
+ : [];
792
+ const dependencies = ids.length
793
+ ? await db.all(`SELECT * FROM task_dependencies WHERE task_id IN (${marks(ids.length)})`, ...ids)
794
+ : [];
795
+ const assignments = ids.length
796
+ ? await db.all(
797
+ `SELECT * FROM task_assignments WHERE task_id IN (${marks(ids.length)}) ORDER BY assigned_at DESC`,
798
+ ...ids,
799
+ )
800
+ : [];
801
+ const evidence = await db.all(
802
+ "SELECT * FROM verification_evidence WHERE project_id = ? ORDER BY recorded_at DESC",
803
+ project.id,
804
+ );
805
+
806
+ const featureName = new Map(
807
+ (await db.all("SELECT id, name FROM features WHERE project_id = ?", project.id)).map((f) => [
808
+ f.id,
809
+ f.name,
810
+ ]),
811
+ );
812
+ const categoryName = new Map(categories.map((c) => [c.id, c.name]));
813
+ const activeAssignment = new Map(assignments.filter((a) => a.active).map((a) => [a.task_id, a]));
814
+ const children = new Map();
815
+ for (const task of rows) {
816
+ if (!task.parent_task_id) continue;
817
+ if (!children.has(task.parent_task_id)) children.set(task.parent_task_id, []);
818
+ children.get(task.parent_task_id).push(task.id);
819
+ }
820
+
821
+ return {
822
+ tasks: rows.map((task) => ({
823
+ ...task,
824
+ featureName: featureName.get(task.feature_id) ?? null,
825
+ categoryName: categoryName.get(task.category_id) ?? null,
826
+ statusLabel: titleCase(task.status),
827
+ assignment: activeAssignment.get(task.id) ?? null,
828
+ subtaskIds: children.get(task.id) ?? [],
829
+ contractLinkCount: contractLinks.filter((l) => l.task_id === task.id).length,
830
+ })),
831
+ categories,
832
+ contractLinks,
833
+ dependencies,
834
+ assignments,
835
+ evidence,
836
+ developers: await db.all("SELECT * FROM developers WHERE project_id = ?", project.id),
837
+ agents: await db.all(
838
+ `SELECT * FROM agents WHERE developer_id IN (SELECT id FROM developers WHERE project_id = ?)`,
839
+ project.id,
840
+ ),
841
+ branches: await db.all("SELECT * FROM branches WHERE project_id = ?", project.id),
842
+ };
843
+ });
844
+ }
845
+
846
+ // ---------------------------------------------------------------------- team
847
+
848
+ export function team(root) {
849
+ return envelope(root, async (db, project) => {
850
+ const developers = await db.all(
851
+ "SELECT * FROM developers WHERE project_id = ? ORDER BY display_name",
852
+ project.id,
853
+ );
854
+ const agents = await db.all(
855
+ `SELECT * FROM agents WHERE developer_id IN (SELECT id FROM developers WHERE project_id = ?)
856
+ ORDER BY last_seen_at DESC`,
857
+ project.id,
858
+ );
859
+ const branches = await db.all(
860
+ "SELECT * FROM branches WHERE project_id = ? ORDER BY last_seen_at DESC",
861
+ project.id,
862
+ );
863
+ const sessions = await db.all(
864
+ "SELECT * FROM work_sessions WHERE project_id = ? ORDER BY started_at DESC",
865
+ project.id,
866
+ );
867
+
868
+ const branchName = new Map(branches.map((b) => [b.id, b.name]));
869
+ const developerName = new Map(developers.map((d) => [d.id, d.display_name]));
870
+ const now = Date.now();
871
+
872
+ const presence = [];
873
+ const seen = new Set();
874
+ for (const session of sessions.filter((s) => s.status === "active")) {
875
+ const at = Date.parse(session.last_activity_at ?? session.started_at ?? "");
876
+ const kind = session.agent_id ? "agent" : "developer";
877
+ const actorId = session.agent_id ?? session.developer_id;
878
+ if (!actorId || seen.has(actorId)) continue;
879
+ seen.add(actorId);
880
+ const agent = agents.find((a) => a.id === session.agent_id);
881
+ presence.push({
882
+ actorId,
883
+ actorName:
884
+ kind === "agent"
885
+ ? agent?.model_label ?? agent?.harness ?? actorId
886
+ : developerName.get(actorId) ?? actorId,
887
+ actorKind: kind,
888
+ presence: presenceLabel(now - at),
889
+ lastActivityAt: session.last_activity_at ?? session.started_at ?? null,
890
+ activeTaskId: session.active_task_id,
891
+ branchName: branchName.get(session.branch_id) ?? null,
892
+ sessionId: session.id,
893
+ objective: session.objective,
894
+ });
895
+ }
896
+
897
+ return {
898
+ developers,
899
+ agents,
900
+ branches,
901
+ sessions,
902
+ presence,
903
+ handoffs: sessions
904
+ .filter((s) => s.handoff)
905
+ .map((s) => ({
906
+ sessionId: s.id,
907
+ handoff: s.handoff,
908
+ nextAction: s.next_action,
909
+ outcome: s.outcome,
910
+ endedAt: s.ended_at,
911
+ })),
912
+ conflicts: hydrateAll(
913
+ await db.all(
914
+ "SELECT * FROM sync_conflicts WHERE project_id = ? AND status = 'open' ORDER BY detected_at DESC",
915
+ project.id,
916
+ ),
917
+ ),
918
+ };
919
+ });
920
+ }
921
+
922
+ function presenceLabel(ageMs) {
923
+ if (!Number.isFinite(ageMs)) return "Offline";
924
+ if (ageMs < LIVE_WITHIN_MS) return "Live";
925
+ if (ageMs < SESSION_STALE_MS) return "Idle";
926
+ return "Offline";
927
+ }
928
+
929
+ // ----------------------------------------------------------------- decisions
930
+
931
+ export function decisions(root) {
932
+ return envelope(root, async (db, project) => {
933
+ const rows = hydrateAll(
934
+ await db.all("SELECT * FROM decisions WHERE project_id = ? ORDER BY created_at, id", project.id),
935
+ );
936
+ const ids = rows.map((d) => d.id);
937
+ const links = ids.length
938
+ ? await db.all(`SELECT * FROM decision_links WHERE decision_id IN (${marks(ids.length)})`, ...ids)
939
+ : [];
940
+ const transitions = ids.length
941
+ ? await db.all(
942
+ `SELECT * FROM decision_transitions WHERE decision_id IN (${marks(ids.length)})
943
+ ORDER BY decision_id, sequence`,
944
+ ...ids,
945
+ )
946
+ : [];
947
+
948
+ return {
949
+ decisions: rows.map((d) => ({ ...d, statusLabel: titleCase(d.status) })),
950
+ links,
951
+ transitions,
952
+ conflicts: decisionConflicts(rows, links),
953
+ };
954
+ });
955
+ }
956
+
957
+ /**
958
+ * A conflict is a decision that is still being treated as binding while
959
+ * something about it has stopped being true. Each one names the decision, what
960
+ * is being asked of it, and where the clash is, so it can be acted on rather
961
+ * than merely noticed.
962
+ */
963
+ function decisionConflicts(decisions, links) {
964
+ const now = Date.now();
965
+ const out = [];
966
+ const governing = new Map();
967
+
968
+ for (const decision of decisions) {
969
+ if (decision.status === "accepted" && decision.expires_at && Date.parse(decision.expires_at) <= now) {
970
+ out.push({
971
+ decisionId: decision.id,
972
+ request: "Continued reliance on a decision whose time box has run out",
973
+ explanation: `${decision.title} was time boxed to ${decision.expires_at} and is still marked accepted. Re-accept it or replace it.`,
974
+ targetType: decision.scope_type ?? null,
975
+ targetId: decision.scope_id ?? null,
976
+ });
977
+ }
978
+ if (decision.status === "revisit_required") {
979
+ out.push({
980
+ decisionId: decision.id,
981
+ request: "Work continuing under a decision that asked to be revisited",
982
+ explanation: `${decision.title} is marked revisit required${
983
+ decision.revisit_triggers_json?.length ? `: ${decision.revisit_triggers_json.join("; ")}` : "."
984
+ }`,
985
+ targetType: decision.scope_type ?? null,
986
+ targetId: decision.scope_id ?? null,
987
+ });
988
+ }
989
+ if (decision.status === "partially_superseded" && !decision.superseded_by_id) {
990
+ out.push({
991
+ decisionId: decision.id,
992
+ request: "Reading a partially superseded decision as if it were whole",
993
+ explanation: `${decision.title} is partially superseded but names no successor, so which part still governs is unrecorded.`,
994
+ targetType: decision.scope_type ?? null,
995
+ targetId: decision.scope_id ?? null,
996
+ });
997
+ }
998
+ }
999
+
1000
+ const byId = new Map(decisions.map((d) => [d.id, d]));
1001
+ for (const link of links) {
1002
+ if (link.relationship !== "governs") continue;
1003
+ if (byId.get(link.decision_id)?.status !== "accepted") continue;
1004
+ const key = `${link.target_type}:${link.target_id}`;
1005
+ if (!governing.has(key)) governing.set(key, []);
1006
+ governing.get(key).push(link.decision_id);
1007
+ }
1008
+ for (const [key, owners] of governing) {
1009
+ if (owners.length < 2) continue;
1010
+ const [type, id] = key.split(":");
1011
+ for (const decisionId of owners) {
1012
+ out.push({
1013
+ decisionId,
1014
+ request: `Two accepted decisions both govern ${id}`,
1015
+ explanation: `${owners.join(" and ")} each claim to govern ${type} ${id}. One of them has to supersede the other.`,
1016
+ targetType: type,
1017
+ targetId: id,
1018
+ });
1019
+ }
1020
+ }
1021
+ return out;
1022
+ }
1023
+
1024
+ // ------------------------------------------------------------------ activity
1025
+
1026
+ const ACTIVITY_PAGE = 100;
1027
+ const ACTIVITY_MAX_PAGE = 500;
1028
+
1029
+ /** `%` and `_` are wildcards, so a searched literal has to escape them. */
1030
+ const likeLiteral = (text) => text.replace(/[\\%_]/g, "\\$&");
1031
+
1032
+ export function activity(root, options = {}) {
1033
+ const before = Number.isFinite(options.before) && options.before > 0 ? Math.floor(options.before) : null;
1034
+ const limit = Math.min(
1035
+ Math.max(Number.isFinite(options.limit) ? Math.floor(options.limit) : ACTIVITY_PAGE, 1),
1036
+ ACTIVITY_MAX_PAGE,
1037
+ );
1038
+ const search = typeof options.search === "string" ? options.search.trim().slice(0, 200) : "";
1039
+
1040
+ return envelope(root, async (db, project) => {
1041
+ const where = ["project_id = ?"];
1042
+ const params = [project.id];
1043
+ if (before !== null) {
1044
+ where.push("sequence < ?");
1045
+ params.push(before);
1046
+ }
1047
+ if (search) {
1048
+ where.push("(summary LIKE ? ESCAPE '\\' OR event_type LIKE ? ESCAPE '\\')");
1049
+ params.push(`%${likeLiteral(search)}%`, `%${likeLiteral(search)}%`);
1050
+ }
1051
+
1052
+ // One row over the page size answers hasMore without a second count query.
1053
+ const rows = await db.all(
1054
+ `SELECT * FROM activity_events WHERE ${where.join(" AND ")} ORDER BY sequence DESC LIMIT ?`,
1055
+ ...params,
1056
+ limit + 1,
1057
+ );
1058
+ const hasMore = rows.length > limit;
1059
+ const events = hydrateAll(rows.slice(0, limit));
1060
+
1061
+ const actorNames = new Map(
1062
+ (await db.all("SELECT id, display_name FROM developers WHERE project_id = ?", project.id)).map(
1063
+ (d) => [d.id, d.display_name],
1064
+ ),
1065
+ );
1066
+
1067
+ const memory = hydrateAll(
1068
+ search
1069
+ ? await db.all(
1070
+ `SELECT * FROM memory_entries
1071
+ WHERE project_id = ? AND superseded_by IS NULL
1072
+ AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')
1073
+ ORDER BY created_at DESC LIMIT ?`,
1074
+ project.id,
1075
+ `%${likeLiteral(search)}%`,
1076
+ `%${likeLiteral(search)}%`,
1077
+ limit,
1078
+ )
1079
+ : await db.all(
1080
+ `SELECT * FROM memory_entries WHERE project_id = ? AND superseded_by IS NULL
1081
+ ORDER BY created_at DESC LIMIT ?`,
1082
+ project.id,
1083
+ limit,
1084
+ ),
1085
+ );
1086
+
1087
+ return {
1088
+ events: events.map((e) => ({
1089
+ ...e,
1090
+ actorName: actorNames.get(e.actor_id) ?? e.actor_label ?? null,
1091
+ typeLabel: titleCase(e.event_type),
1092
+ })),
1093
+ memory,
1094
+ sessions: await db.all(
1095
+ "SELECT * FROM work_sessions WHERE project_id = ? ORDER BY started_at DESC LIMIT 50",
1096
+ project.id,
1097
+ ),
1098
+ page: {
1099
+ nextCursor: hasMore ? events.at(-1)?.sequence ?? null : null,
1100
+ hasMore,
1101
+ returned: events.length,
1102
+ },
1103
+ };
1104
+ });
1105
+ }
1106
+
1107
+ // ----------------------------------------------------------------- readiness
1108
+
1109
+ export function readinessView(root) {
1110
+ return envelope(root, async (db, project) => {
1111
+ const report = await readiness(db, project.id);
1112
+ const fresh = await freshness(db, project.id);
1113
+ const warnings = await alignmentWarnings(db, project.id);
1114
+
1115
+ const modules = await db.all(
1116
+ "SELECT * FROM modules WHERE project_id = ? ORDER BY sequence, id",
1117
+ project.id,
1118
+ );
1119
+ const steps = modules.length
1120
+ ? await db.all(
1121
+ `SELECT * FROM module_completeness WHERE module_id IN (${marks(modules.length)})
1122
+ ORDER BY module_id, step`,
1123
+ ...modules.map((m) => m.id),
1124
+ )
1125
+ : [];
1126
+ const questions = hydrateAll(
1127
+ await db.all(
1128
+ "SELECT * FROM questions WHERE project_id = ? AND status IN ('open','deferred') ORDER BY created_at",
1129
+ project.id,
1130
+ ),
1131
+ );
1132
+ const discoveryRisks = await db.all(
1133
+ "SELECT * FROM discovery_items WHERE project_id = ? AND kind = 'risk' AND status <> 'rejected'",
1134
+ project.id,
1135
+ );
1136
+
1137
+ const moduleCompleteness = modules.map((module) => {
1138
+ const mine = steps.filter((s) => s.module_id === module.id);
1139
+ const byStep = new Map(mine.map((s) => [s.step, s]));
1140
+ const rendered = MODULE_STEPS.map((name, index) => {
1141
+ const step = byStep.get(index + 1);
1142
+ return {
1143
+ moduleId: module.id,
1144
+ step: index + 1,
1145
+ name: step?.step_name ?? name,
1146
+ state: step?.state ?? "open",
1147
+ summary: step?.summary ?? null,
1148
+ reason: step?.reason_not_applicable ?? null,
1149
+ };
1150
+ });
1151
+ const applicable = rendered.filter((s) => s.state !== "not_applicable");
1152
+ const filled = applicable.filter((s) => s.state === "filled").length;
1153
+ return {
1154
+ moduleId: module.id,
1155
+ moduleName: module.name,
1156
+ steps: rendered,
1157
+ progress: {
1158
+ completed: filled,
1159
+ total: applicable.length,
1160
+ percent: honestPercent(filled, applicable.length),
1161
+ measurable: applicable.length > 0,
1162
+ counts: `Module steps filled: ${filled} of ${applicable.length}.`,
1163
+ remains: applicable.length - filled
1164
+ ? `${count(applicable.length - filled, "step")} still open.`
1165
+ : null,
1166
+ sourceRevision: report.sourceRevision,
1167
+ computedAt: report.generatedAt,
1168
+ },
1169
+ };
1170
+ });
1171
+
1172
+ const risks = [
1173
+ ...discoveryRisks.map((r) => ({
1174
+ id: r.id,
1175
+ statement: r.statement,
1176
+ severity: "Unassessed",
1177
+ mitigation: null,
1178
+ owner: null,
1179
+ source: "discovery",
1180
+ })),
1181
+ ...warnings.map((w) => ({
1182
+ id: w.recordId ?? w.code,
1183
+ statement: `${w.title}: ${w.detail}`,
1184
+ severity: titleCase(w.severity),
1185
+ mitigation: w.remedy,
1186
+ owner: null,
1187
+ source: "alignment",
1188
+ })),
1189
+ ];
1190
+
1191
+ const blockers = [];
1192
+ const unresolved = report.areas.filter(
1193
+ (a) => a.state === "awaiting_decision" || a.state === "deferred",
1194
+ );
1195
+ if (unresolved.length) {
1196
+ blockers.push(`${count(unresolved.length, "capability area")} ${agrees(unresolved.length, "has", "have")} no answer yet.`);
1197
+ }
1198
+ const silent = report.areas.filter((a) => a.silentGap);
1199
+ if (silent.length) {
1200
+ blockers.push(
1201
+ `${silent.length} of those have no owner and no open question, which is a silent gap.`,
1202
+ );
1203
+ }
1204
+ const openSteps = moduleCompleteness.reduce(
1205
+ (n, m) => n + (m.progress.total - m.progress.completed),
1206
+ 0,
1207
+ );
1208
+ if (openSteps) blockers.push(`${count(openSteps, "module completeness step")} ${agrees(openSteps, "is", "are")} still open.`);
1209
+ const open = questions.filter((q) => q.status === "open");
1210
+ if (open.length) blockers.push(`${count(open.length, "question")} are waiting on an answer.`);
1211
+ if (fresh.pendingDocumentProposals.length) {
1212
+ blockers.push(
1213
+ `${count(fresh.pendingDocumentProposals.length, "document")} were edited by hand and are unresolved.`,
1214
+ );
1215
+ }
1216
+ if (fresh.staleEvidence.length) {
1217
+ blockers.push(`${count(fresh.staleEvidence.length, "verification record")} ${agrees(fresh.staleEvidence.length, "is", "are")} out of date.`);
1218
+ }
1219
+
1220
+ return {
1221
+ capabilityAreas: report.areas.map((a) => ({
1222
+ id: a.id,
1223
+ name: a.area,
1224
+ state: a.state,
1225
+ stateLabel: titleCase(a.state),
1226
+ choice: a.choice,
1227
+ reason: a.reason,
1228
+ owner: a.owner,
1229
+ revisitTrigger: a.revisitTrigger,
1230
+ consequence: a.consequence,
1231
+ decisionId: a.decisionId,
1232
+ silentGap: a.silentGap,
1233
+ questionIds: a.questionId ? [a.questionId] : [],
1234
+ })),
1235
+ areaCounts: report.areaCounts,
1236
+ modules: moduleCompleteness,
1237
+ openQuestions: questions,
1238
+ risks,
1239
+ releaseReadiness: {
1240
+ verdict: !report.measurable
1241
+ ? "Not measurable"
1242
+ : blockers.length
1243
+ ? "Not ready to release"
1244
+ : "Ready to release",
1245
+ explanation: !report.measurable
1246
+ ? "No readiness checklist has been recorded, so this cannot be answered yet. Run discovery to evaluate the capability areas."
1247
+ : blockers.length
1248
+ ? "Everything below has to be closed or explicitly accepted before this can be called ready."
1249
+ : "Every capability area is answered, every module step is filled or explicitly not applicable, and no question or document edit is outstanding.",
1250
+ blockers,
1251
+ },
1252
+ progress: toProgress(report),
1253
+ };
1254
+ });
1255
+ }
1256
+
1257
+ /**
1258
+ * The interface a person touches, and the actions on it.
1259
+ *
1260
+ * Section 16.1 requires a UI Surfaces area. The records existed and nothing
1261
+ * served them, so twenty surfaces and their actions were in the database and
1262
+ * reachable from nowhere.
1263
+ */
1264
+ async function surfacesView(root) {
1265
+ return envelope(root, async (db, project) => {
1266
+ const surfaces = await db.all(
1267
+ `SELECT s.*, m.name AS module_name, f.name AS feature_name
1268
+ FROM surfaces s
1269
+ LEFT JOIN modules m ON m.id = s.module_id
1270
+ LEFT JOIN features f ON f.id = s.feature_id
1271
+ ORDER BY s.name`);
1272
+ const actions = surfaces.length
1273
+ ? await db.all(
1274
+ `SELECT * FROM ui_actions WHERE surface_id IN (${marks(surfaces.length)}) ORDER BY name`,
1275
+ ...surfaces.map((s) => s.id))
1276
+ : [];
1277
+ const bySurface = new Map();
1278
+ for (const a of actions) {
1279
+ if (!bySurface.has(a.surface_id)) bySurface.set(a.surface_id, []);
1280
+ bySurface.get(a.surface_id).push(a);
1281
+ }
1282
+ return {
1283
+ surfaces: surfaces.map((s) => ({
1284
+ ...s,
1285
+ entities_shown_json: json(s.entities_shown_json, []),
1286
+ components_json: json(s.components_json, []),
1287
+ actions: bySurface.get(s.id) ?? [],
1288
+ })),
1289
+ counts: { surfaces: surfaces.length, actions: actions.length },
1290
+ };
1291
+ });
1292
+ }
1293
+
1294
+ /**
1295
+ * The API surface: every operation and the boundary that groups it.
1296
+ *
1297
+ * Section 16.1 requires an APIs area. Seventy operations were recorded and the
1298
+ * control centre showed none of them.
1299
+ */
1300
+ async function apisView(root) {
1301
+ return envelope(root, async (db, project) => {
1302
+ const services = await db.all(
1303
+ `SELECT s.*, m.name AS module_name FROM api_services s
1304
+ LEFT JOIN modules m ON m.id = s.module_id ORDER BY s.sequence, s.id`);
1305
+ const operations = await db.all(
1306
+ `SELECT o.*, m.name AS module_name, f.name AS feature_name
1307
+ FROM api_operations o
1308
+ LEFT JOIN modules m ON m.id = o.module_id
1309
+ LEFT JOIN features f ON f.id = o.feature_id
1310
+ ORDER BY o.name`);
1311
+ return {
1312
+ services,
1313
+ operations: operations.map((o) => ({
1314
+ ...o,
1315
+ side_effects_json: json(o.side_effects_json, []),
1316
+ changesState: json(o.side_effects_json, []).length > 0,
1317
+ })),
1318
+ counts: {
1319
+ services: services.length,
1320
+ operations: operations.length,
1321
+ // An operation with no service is loose: nothing says which boundary
1322
+ // owns it, which is the gap section 6.1 draws between the two objects.
1323
+ ungrouped: operations.filter((o) => !o.api_service_id).length,
1324
+ },
1325
+ };
1326
+ });
1327
+ }
1328
+
1329
+ /**
1330
+ * What moved in accepted scope, and why.
1331
+ *
1332
+ * Section 16.1 requires a Changes area and 16.3 requires navigating from a
1333
+ * change to the records it affected.
1334
+ */
1335
+ async function changesView(root) {
1336
+ return envelope(root, async (db, project) => {
1337
+ const changes = await db.all(
1338
+ `SELECT c.*, d.title AS decision_title FROM changes c
1339
+ LEFT JOIN decisions d ON d.id = c.decision_id
1340
+ ORDER BY c.created_at DESC, c.id DESC LIMIT 200`);
1341
+ const targets = changes.length
1342
+ ? await db.all(
1343
+ `SELECT * FROM change_targets WHERE change_id IN (${marks(changes.length)})`,
1344
+ ...changes.map((c) => c.id))
1345
+ : [];
1346
+ const byChange = new Map();
1347
+ for (const t of targets) {
1348
+ if (!byChange.has(t.change_id)) byChange.set(t.change_id, []);
1349
+ byChange.get(t.change_id).push(t);
1350
+ }
1351
+ const assumptions = await db.all("SELECT * FROM assumptions ORDER BY CASE status WHEN 'holding' THEN 0 ELSE 1 END, created_at DESC");
1352
+ return {
1353
+ changes: changes.map((c) => ({ ...c, targets: byChange.get(c.id) ?? [] })),
1354
+ assumptions,
1355
+ counts: {
1356
+ changes: changes.length,
1357
+ assumptions: assumptions.length,
1358
+ holding: assumptions.filter((a) => a.status === "holding").length,
1359
+ },
1360
+ };
1361
+ });
1362
+ }
1363
+
1364
+ /**
1365
+ * What proves the product works.
1366
+ *
1367
+ * Section 16.1 requires an Evidence area, and 16.3 requires navigating from a
1368
+ * task to its evidence. Evidence was only ever visible inside one task at a
1369
+ * time, so nobody could see how much of the product was actually proven.
1370
+ */
1371
+ async function evidenceView(root) {
1372
+ return envelope(root, async (db, project) => {
1373
+ const evidence = await db.all(
1374
+ `SELECT e.*, t.name AS task_name, t.status AS task_status, f.name AS feature_name
1375
+ FROM verification_evidence e
1376
+ LEFT JOIN tasks t ON t.id = e.task_id
1377
+ LEFT JOIN features f ON f.id = e.feature_id
1378
+ ORDER BY e.recorded_at DESC LIMIT 300`);
1379
+ const criteria = await db.all(
1380
+ `SELECT c.*, f.name AS feature_name FROM feature_acceptance_criteria c
1381
+ LEFT JOIN features f ON f.id = c.feature_id ORDER BY c.feature_id, c.sequence`);
1382
+ const plans = await db.all(
1383
+ `SELECT p.*, f.name AS feature_name FROM test_plans p
1384
+ LEFT JOIN features f ON f.id = p.feature_id ORDER BY p.id`);
1385
+ const cases = plans.length
1386
+ ? await db.all(`SELECT * FROM test_plan_cases WHERE test_plan_id IN (${marks(plans.length)}) ORDER BY sequence`,
1387
+ ...plans.map((p) => p.id))
1388
+ : [];
1389
+ const current = evidence.filter((e) => e.status === "current");
1390
+ return {
1391
+ evidence,
1392
+ criteria,
1393
+ testPlans: plans.map((p) => ({ ...p, cases: cases.filter((c) => c.test_plan_id === p.id) })),
1394
+ counts: {
1395
+ recorded: evidence.length,
1396
+ current: current.length,
1397
+ passing: current.filter((e) => e.result === "pass").length,
1398
+ failing: current.filter((e) => e.result === "fail").length,
1399
+ inconclusive: current.filter((e) => e.result === "inconclusive").length,
1400
+ stale: evidence.filter((e) => e.status === "stale").length,
1401
+ // Evidence that can be run again is the only kind that stays true on
1402
+ // its own. The rest is a sentence somebody wrote once.
1403
+ reRunnable: current.filter((e) => e.check_command).length,
1404
+ criteriaMet: criteria.filter((c) => c.status === "met").length,
1405
+ criteriaTotal: criteria.length,
1406
+ },
1407
+ };
1408
+ });
1409
+ }
1410
+
1411
+ /**
1412
+ * What this installation is and how it is configured.
1413
+ *
1414
+ * Section 16.1 requires a Settings area. Nothing here is editable from the
1415
+ * browser: these are facts about the machine and the project, and changing them
1416
+ * belongs to the commands that own them.
1417
+ */
1418
+ async function settingsView(root) {
1419
+ return envelope(root, async (db, project) => {
1420
+ const migrations = await db.all("SELECT version, name, applied_at FROM applied_migrations ORDER BY version");
1421
+ const categories = await db.all("SELECT * FROM task_categories ORDER BY sequence, id").catch(() => []);
1422
+ const counts = {};
1423
+ for (const table of ["goals", "milestones", "modules", "features", "workflows", "tasks",
1424
+ "data_entities", "api_operations", "surfaces", "integrations",
1425
+ "decisions", "questions", "assumptions", "changes",
1426
+ "memory_entries", "verification_evidence", "activity_events", "documents"]) {
1427
+ counts[table] = (await db.get(`SELECT COUNT(*) n FROM ${table}`).catch(() => ({ n: 0 }))).n;
1428
+ }
1429
+ return {
1430
+ project,
1431
+ schema: {
1432
+ version: migrations.at(-1)?.version ?? 0,
1433
+ applied: migrations.length,
1434
+ migrations,
1435
+ },
1436
+ categories,
1437
+ counts,
1438
+ storage: {
1439
+ // Where things live, so a person can find them without reading source.
1440
+ database: ".superdev/superdev.db",
1441
+ backups: ".superdev/backups",
1442
+ exports: ".superdev/exports",
1443
+ documents: "talks",
1444
+ },
1445
+ };
1446
+ });
1447
+ }
1448
+
1449
+ /**
1450
+ * Test plans: the agreed verification strategy, and the cases inside it.
1451
+ *
1452
+ * Section 6.1 defines a test plan and 9.3 gates task completion on the tests it
1453
+ * defines. It had no route of its own, so the strategy a task is judged against
1454
+ * was only visible bundled inside the evidence payload.
1455
+ */
1456
+ /**
1457
+ * Synchronization, as it actually stands.
1458
+ *
1459
+ * Two questions matter here and both are easy to answer wrongly by omission:
1460
+ * what would leave this machine, and what never does. A page that showed only
1461
+ * a connection state would let a reader assume the answer to the second, and
1462
+ * section 18 makes that assumption the expensive kind.
1463
+ */
1464
+ async function syncView(root) {
1465
+ return envelope(root, async (db, project) => {
1466
+ const { SHARED_TABLES, WITHHELD } = await import("../cloud/policy.mjs");
1467
+ const peer = await db.get("SELECT * FROM sync_peers ORDER BY connected_at DESC LIMIT 1");
1468
+ const conflicts = await db.all(
1469
+ "SELECT * FROM sync_conflicts ORDER BY status, detected_at DESC LIMIT 100").catch(() => []);
1470
+ const leases = await db.all(
1471
+ `SELECT a.task_id, a.lease_holder, a.lease_expires_at, a.origin_peer, t.name AS task_name
1472
+ FROM task_assignments a LEFT JOIN tasks t ON t.id = a.task_id
1473
+ WHERE a.active = 1 AND a.lease_holder IS NOT NULL`).catch(() => []);
1474
+ const tracked = await db.get(
1475
+ "SELECT COUNT(*) AS n FROM sync_base WHERE peer_id = ?", peer?.id ?? "").catch(() => ({ n: 0 }));
1476
+
1477
+ return {
1478
+ peer: peer ?? null,
1479
+ conflicts,
1480
+ leases,
1481
+ trackedRecords: Number(tracked?.n ?? 0),
1482
+ shared: SHARED_TABLES,
1483
+ withheld: Object.entries(WITHHELD).map(([table, reason]) => ({ table, reason })),
1484
+ counts: {
1485
+ shared: SHARED_TABLES.length,
1486
+ withheld: Object.keys(WITHHELD).length,
1487
+ openConflicts: conflicts.filter((c) => c.status === "open").length,
1488
+ leases: leases.length,
1489
+ },
1490
+ };
1491
+ });
1492
+ }
1493
+
1494
+ async function testPlansView(root) {
1495
+ return envelope(root, async (db, project) => {
1496
+ const plans = await db.all(
1497
+ `SELECT p.*, f.name AS feature_name, w.name AS workflow_name, m.name AS module_name,
1498
+ (SELECT COUNT(*) FROM verification_evidence e
1499
+ WHERE e.test_plan_id = p.id AND e.status = 'current' AND e.result = 'pass'
1500
+ AND (e.last_check_result IS NULL OR e.last_check_result = 'pass')) AS passing_runs,
1501
+ (SELECT e.summary FROM verification_evidence e
1502
+ WHERE e.test_plan_id = p.id ORDER BY e.recorded_at DESC LIMIT 1) AS last_run_summary,
1503
+ (SELECT e.recorded_at FROM verification_evidence e
1504
+ WHERE e.test_plan_id = p.id ORDER BY e.recorded_at DESC LIMIT 1) AS last_run_at,
1505
+ (SELECT e.result FROM verification_evidence e
1506
+ WHERE e.test_plan_id = p.id ORDER BY e.recorded_at DESC LIMIT 1) AS last_run_result
1507
+ FROM test_plans p
1508
+ LEFT JOIN features f ON f.id = p.feature_id
1509
+ LEFT JOIN workflows w ON w.id = p.workflow_id
1510
+ LEFT JOIN modules m ON m.id = p.module_id
1511
+ ORDER BY p.id`);
1512
+ const cases = plans.length
1513
+ ? await db.all(`SELECT * FROM test_plan_cases WHERE test_plan_id IN (${marks(plans.length)}) ORDER BY sequence`,
1514
+ ...plans.map((p) => p.id))
1515
+ : [];
1516
+ return {
1517
+ testPlans: plans.map((p) => ({
1518
+ ...p,
1519
+ passing_runs: Number(p.passing_runs ?? 0),
1520
+ // Whether the plan has been run is the question section 9.3 makes it
1521
+ // answer, and the reason a task it covers can or cannot complete.
1522
+ satisfied: Number(p.passing_runs ?? 0) > 0,
1523
+ cases: cases.filter((c) => c.test_plan_id === p.id),
1524
+ })),
1525
+ counts: {
1526
+ plans: plans.length,
1527
+ accepted: plans.filter((p) => p.status === "accepted").length,
1528
+ unrun: plans.filter((p) => p.status === "accepted" && !Number(p.passing_runs ?? 0)).length,
1529
+ cases: cases.length,
1530
+ // A case with a command can be run by anyone. One without is a check
1531
+ // somebody performs, which is honest but not automatic.
1532
+ runnable: cases.filter((c) => c.command).length,
1533
+ },
1534
+ };
1535
+ });
1536
+ }
1537
+
1538
+ /** Route table, so the server maps a path to a builder without a switch. */
1539
+ export const routes = {
1540
+ "/api/test-plans": testPlansView,
1541
+ "/api/sync": syncView,
1542
+ "/api/surfaces": surfacesView,
1543
+ "/api/apis": apisView,
1544
+ "/api/changes": changesView,
1545
+ "/api/evidence": evidenceView,
1546
+ "/api/settings": settingsView,
1547
+ "/api/overview": overview,
1548
+ "/api/discovery": discovery,
1549
+ "/api/product": product,
1550
+ "/api/workflows": workflows,
1551
+ "/api/data": data,
1552
+ "/api/architecture": architecture,
1553
+ "/api/tasks": tasks,
1554
+ "/api/team": team,
1555
+ "/api/decisions": decisions,
1556
+ "/api/readiness": readinessView,
1557
+ };