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,1364 @@
1
+ // The one progress and readiness service. Brief section 16.
2
+ //
3
+ // It exists exactly once because the CLI imports it in process and the local
4
+ // HTTP service wraps the same functions, so "the number in the terminal is the
5
+ // number in the browser" is a property of the code rather than a claim in a
6
+ // document. Three divergent implementations is what this replaces.
7
+ //
8
+ // Two rules shape everything below:
9
+ // - only applicable components count, so a backend feature with no UI is
10
+ // never permanently stuck at five of six;
11
+ // - a record with no declared completion contract is not measurable, and is
12
+ // reported as such rather than as zero or one hundred percent.
13
+ //
14
+ // Every function takes an open connection (from query() or mutate()), never a
15
+ // project root, so a caller composes several of these inside one read.
16
+
17
+ import { json, DbError } from "../db/store.mjs";
18
+ import { agrees, count } from "../model/vocabulary.mjs";
19
+
20
+ const DAY_MS = 86_400_000;
21
+
22
+ /** Evidence older than this stops counting as proof. Brief section 16.1. */
23
+ export const EVIDENCE_STALE_DAYS = 30;
24
+
25
+ /** A session quiet for longer than this is reported as no longer live. */
26
+ export const SESSION_STALE_MS = 4 * 60 * 60 * 1000;
27
+
28
+ /** Statuses that mean a record is finished, whichever table it lives in. */
29
+ const SETTLED = new Set([
30
+ "accepted", "specified", "implemented", "complete", "completed", "delivered",
31
+ "verified", "done", "met", "filled", "applied", "configured", "answered",
32
+ ]);
33
+
34
+ /**
35
+ * Statuses that take a record out of the totals. The record stays queryable and
36
+ * stays in history; it simply stops being work that can be finished.
37
+ */
38
+ const RETIRED = new Set([
39
+ "cancelled", "superseded", "rejected", "deprecated", "withdrawn",
40
+ "not_applicable", "waived", "declined", "abandoned",
41
+ ]);
42
+
43
+ /**
44
+ * Statuses that claim the work itself is finished. Narrower than SETTLED on
45
+ * purpose: `accepted` means the specification was agreed, not that anything
46
+ * was built, and conflating the two would accuse every accepted feature of
47
+ * lying about its progress.
48
+ */
49
+ const FINISHED = new Set(["complete", "completed", "delivered", "implemented", "verified", "done"]);
50
+
51
+ const settled = (status) => SETTLED.has(String(status ?? "").toLowerCase());
52
+ const retired = (status) => RETIRED.has(String(status ?? "").toLowerCase());
53
+ const finished = (status) => FINISHED.has(String(status ?? "").toLowerCase());
54
+
55
+ /** Component name used by every subject, kept in one place so parents can find it. */
56
+ const SPEC_COMPONENT = "Accepted specification";
57
+
58
+ const marks = (n) => Array.from({ length: n }, () => "?").join(",");
59
+ const iso = (ms) => new Date(ms).toISOString();
60
+
61
+ /**
62
+ * Never round an unfinished thing up to complete, and never round started work
63
+ * down to nothing. Both lies are the reason people stop trusting a percentage.
64
+ *
65
+ * Exported because it is a rule rather than a calculation: any caller that
66
+ * rounds for itself will eventually round a project to finished.
67
+ */
68
+ export function honestPercent(done, total) {
69
+ if (!total) return null;
70
+ const raw = Math.round((done / total) * 100);
71
+ if (raw >= 100 && done < total) return 99;
72
+ if (raw <= 0 && done > 0) return 1;
73
+ return raw;
74
+ }
75
+
76
+ /** Count rows into a component, dropping retired rows from the total entirely. */
77
+ function tally(rows, isDone = (row) => settled(row.status)) {
78
+ let done = 0;
79
+ let total = 0;
80
+ for (const row of rows) {
81
+ if (retired(row.status)) continue;
82
+ total += 1;
83
+ if (isDone(row)) done += 1;
84
+ }
85
+ return { done, total };
86
+ }
87
+
88
+ const comp = (name, counts) => ({
89
+ name,
90
+ done: counts.done,
91
+ total: counts.total,
92
+ applies: counts.total > 0,
93
+ });
94
+
95
+ /**
96
+ * Build the one progress shape. Nothing else in this file constructs that
97
+ * object, so every value the product shows carries the same fields.
98
+ */
99
+ function summarize({
100
+ components,
101
+ revision,
102
+ measurable,
103
+ now,
104
+ whatCountsExtra = [],
105
+ whatRemainsExtra = [],
106
+ stale = false,
107
+ reason = null,
108
+ // The noun the totals are counted in, so a caller can say "5 of 47 readiness
109
+ // items addressed". This is deliberately separate from whatCounts, which is
110
+ // the per-component breakdown: putting the breakdown in the noun slot is how
111
+ // a headline turns into an unreadable run-on sentence.
112
+ unit = "tracked item",
113
+ }) {
114
+ const applicable = components.filter((c) => c.applies);
115
+ const completed = applicable.reduce((n, c) => n + c.done, 0);
116
+ const total = applicable.reduce((n, c) => n + c.total, 0);
117
+
118
+ const whatCounts = applicable.map((c) => `${c.name}: ${c.done} of ${c.total}`);
119
+ whatCounts.push(...whatCountsExtra);
120
+ const skipped = components.filter((c) => !c.applies).map((c) => c.name);
121
+ if (skipped.length) whatCounts.push(`Does not apply here: ${skipped.join(", ")}.`);
122
+
123
+ const whatRemains = applicable
124
+ .filter((c) => c.done < c.total)
125
+ .map((c) => `${c.name}: ${c.total - c.done} outstanding`);
126
+ whatRemains.push(...whatRemainsExtra);
127
+ if (!measurable) whatRemains.unshift(reason ?? "No declared completion contract.");
128
+
129
+ return {
130
+ percent: measurable ? honestPercent(completed, total) : null,
131
+ completed,
132
+ total,
133
+ components,
134
+ unit,
135
+ whatCounts,
136
+ whatRemains,
137
+ sourceRevision: revision,
138
+ measurable,
139
+ // Freshness travels with the value. A percentage without an as-of is how a
140
+ // stale snapshot gets read as the present.
141
+ stale,
142
+ generatedAt: iso(now),
143
+ };
144
+ }
145
+
146
+ /** The database revision every value is reported against. */
147
+ async function revisionOf(db, projectId) {
148
+ const value = await db.value(
149
+ "SELECT max(sequence) FROM activity_events WHERE project_id = ?",
150
+ projectId,
151
+ );
152
+ return value ?? 0;
153
+ }
154
+
155
+ function evidenceIsCurrent(row, now) {
156
+ if (row.status !== "current") return false;
157
+ if (row.stale_at && Date.parse(row.stale_at) <= now) return false;
158
+ const at = Date.parse(row.recorded_at ?? "");
159
+ // An unreadable timestamp means the age is unknown, and unknown age is not proof.
160
+ if (!Number.isFinite(at)) return false;
161
+ return now - at < EVIDENCE_STALE_DAYS * DAY_MS;
162
+ }
163
+
164
+ // ---------------------------------------------------------------- feature
165
+
166
+ /**
167
+ * Progress for one feature, from the eleven components of brief section 16.1.
168
+ * Components with a total of zero do not apply and are excluded from the maths
169
+ * while staying visible in `components` so the reader can see why.
170
+ */
171
+ export async function featureProgress(db, featureId) {
172
+ const feature = await db.get("SELECT * FROM features WHERE id = ?", featureId);
173
+ if (!feature) throw new DbError("E_NOT_FOUND", `Feature ${featureId} does not exist.`);
174
+ return computeFeature(db, feature, await revisionOf(db, feature.project_id), Date.now());
175
+ }
176
+
177
+ async function computeFeature(db, feature, revision, now) {
178
+ const id = feature.id;
179
+
180
+ const criteria = await db.all(
181
+ "SELECT * FROM feature_acceptance_criteria WHERE feature_id = ? ORDER BY sequence",
182
+ id,
183
+ );
184
+ const workflows = await db.all("SELECT * FROM workflows WHERE feature_id = ?", id);
185
+ const steps = workflows.length
186
+ ? await db.all(
187
+ `SELECT * FROM workflow_steps WHERE workflow_id IN (${marks(workflows.length)})`,
188
+ ...workflows.map((w) => w.id),
189
+ )
190
+ : [];
191
+ const surfaces = await db.all("SELECT * FROM surfaces WHERE feature_id = ?", id);
192
+ const actions = surfaces.length
193
+ ? await db.all(
194
+ `SELECT * FROM ui_actions WHERE surface_id IN (${marks(surfaces.length)})`,
195
+ ...surfaces.map((s) => s.id),
196
+ )
197
+ : [];
198
+ const apis = await db.all("SELECT * FROM api_operations WHERE feature_id = ?", id);
199
+ const entities = await db.all("SELECT * FROM data_entities WHERE feature_id = ?", id);
200
+ const migrations = await db.all("SELECT * FROM schema_migrations WHERE feature_id = ?", id);
201
+ const integrations = await db.all("SELECT * FROM integrations WHERE feature_id = ?", id);
202
+ const nfrs = await db.all("SELECT * FROM non_functional_requirements WHERE feature_id = ?", id);
203
+ const tasks = await db.all("SELECT * FROM tasks WHERE feature_id = ?", id);
204
+ const evidence = await db.all("SELECT * FROM verification_evidence WHERE feature_id = ?", id);
205
+ const documents = await db.all(
206
+ "SELECT * FROM documents WHERE scope_type = 'feature' AND scope_id = ?",
207
+ id,
208
+ );
209
+
210
+ const accepted = Boolean(feature.accepted_at) || settled(feature.status);
211
+ const verifiable = criteria.filter((c) => !retired(c.status));
212
+ const proven = new Set(
213
+ evidence
214
+ .filter((e) => e.acceptance_criterion_id && e.result === "pass" && evidenceIsCurrent(e, now))
215
+ .map((e) => e.acceptance_criterion_id),
216
+ );
217
+ const staleEvidence = evidence.filter(
218
+ (e) => e.status !== "superseded" && !evidenceIsCurrent(e, now),
219
+ );
220
+
221
+ const components = [
222
+ comp(SPEC_COMPONENT, { done: accepted ? 1 : 0, total: 1 }),
223
+ comp("Acceptance criteria", tally(criteria)),
224
+ comp("Workflows and steps", tally([...workflows, ...steps])),
225
+ comp("Surfaces and UI actions", tally([...surfaces, ...actions])),
226
+ comp("APIs", tally(apis)),
227
+ comp("Data and migrations", tally([...entities, ...migrations])),
228
+ comp("Integrations", tally(integrations, (r) => r.verification_status === "verified")),
229
+ comp("NFRs", tally(nfrs)),
230
+ comp("Tasks", tally(tasks)),
231
+ comp("Verification evidence", {
232
+ done: verifiable.filter((c) => proven.has(c.id)).length,
233
+ total: verifiable.length,
234
+ }),
235
+ comp("Documentation sync", tally(documents, (r) =>
236
+ r.sync_status === "generated" || r.sync_status === "accepted")),
237
+ ];
238
+
239
+ // Acceptance of the specification is not itself a completion contract: a
240
+ // feature whose only declared content is its own acceptance has nothing to
241
+ // count, and saying zero percent about it would be an invented fact.
242
+ const measurable = components.some((c) => c.applies && c.name !== SPEC_COMPONENT);
243
+
244
+ const whatRemainsExtra = [];
245
+ if (staleEvidence.length) {
246
+ whatRemainsExtra.push(
247
+ `${count(staleEvidence.length, "verification record")} ${agrees(staleEvidence.length, "is", "are")} older than ${EVIDENCE_STALE_DAYS} days and no longer count as proof.`,
248
+ );
249
+ }
250
+ const retiredTasks = tasks.filter((t) => retired(t.status)).length;
251
+ const whatCountsExtra = retiredTasks
252
+ ? [`${count(retiredTasks, "cancelled or superseded task")} ${agrees(retiredTasks, "is", "are")} excluded from the totals and stay in history.`]
253
+ : [];
254
+
255
+ return {
256
+ ...summarize({
257
+ unit: "feature contract item",
258
+ components,
259
+ revision,
260
+ measurable,
261
+ now,
262
+ whatCountsExtra,
263
+ whatRemainsExtra,
264
+ stale: staleEvidence.length > 0,
265
+ reason:
266
+ "This feature declares no completion contract yet: no acceptance criteria, workflows, surfaces, APIs, data, integrations, NFRs or tasks. Progress is not measurable.",
267
+ }),
268
+ subject: { type: "feature", id, name: feature.name, status: feature.status },
269
+ };
270
+ }
271
+
272
+ // ----------------------------------------------------------------- parents
273
+
274
+ /**
275
+ * Roll child progress up as delivered or not. A child with no completion
276
+ * contract cannot be delivered, so it is named rather than quietly counted as
277
+ * either done or outstanding.
278
+ */
279
+ function delivered(progresses) {
280
+ const measurable = progresses.filter((p) => p.measurable);
281
+ return {
282
+ done: measurable.filter((p) => p.percent === 100).length,
283
+ total: measurable.length,
284
+ };
285
+ }
286
+
287
+ const unmeasurableNote = (progresses, noun) => {
288
+ const n = progresses.filter((p) => !p.measurable).length;
289
+ if (!n) return [];
290
+ // The noun arrives singular and is agreed with the count here, and so is the
291
+ // verb: "1 feature(s) have no declared completion contract" is a sentence
292
+ // that already knows the number and hedges about it anyway.
293
+ return [
294
+ `${count(n, noun)} ${n === 1 ? "has" : "have"} no declared completion contract and cannot count toward this number.`,
295
+ ];
296
+ };
297
+
298
+ /** Sum the child components by name so a parent shows where the work sits. */
299
+ function sumComponents(progresses) {
300
+ const byName = new Map();
301
+ for (const p of progresses) {
302
+ for (const c of p.components) {
303
+ const current = byName.get(c.name) ?? { done: 0, total: 0 };
304
+ current.done += c.done;
305
+ current.total += c.total;
306
+ byName.set(c.name, current);
307
+ }
308
+ }
309
+ return [...byName.entries()].map(([name, counts]) => comp(name, counts));
310
+ }
311
+
312
+ /** Conditions stored as JSON. A plain string carries no state, so it is outstanding. */
313
+ function conditionCounts(list) {
314
+ let done = 0;
315
+ let total = 0;
316
+ for (const entry of list) {
317
+ if (entry && typeof entry === "object") {
318
+ const status = entry.status ?? entry.state;
319
+ if (retired(status)) continue;
320
+ total += 1;
321
+ if (entry.met === true || settled(status)) done += 1;
322
+ } else if (String(entry ?? "").trim()) {
323
+ total += 1;
324
+ }
325
+ }
326
+ return { done, total };
327
+ }
328
+
329
+ /** Module progress derives from its features. Brief section 16.2. */
330
+ export async function moduleProgress(db, moduleId) {
331
+ const module = await db.get("SELECT * FROM modules WHERE id = ?", moduleId);
332
+ if (!module) throw new DbError("E_NOT_FOUND", `Module ${moduleId} does not exist.`);
333
+ const revision = await revisionOf(db, module.project_id);
334
+ const now = Date.now();
335
+ const features = (await db.all("SELECT * FROM features WHERE module_id = ?", moduleId))
336
+ .filter((f) => !retired(f.status));
337
+ const progresses = [];
338
+ for (const feature of features) progresses.push(await computeFeature(db, feature, revision, now));
339
+ return moduleFrom(module, progresses, revision, now);
340
+ }
341
+
342
+ function moduleFrom(module, progresses, revision, now) {
343
+ const components = sumComponents(progresses);
344
+ const complete = delivered(progresses);
345
+ return {
346
+ ...summarize({
347
+ // What is summed here is each feature's contract items, so the total is
348
+ // contract items and not features. Calling it "feature" made a module
349
+ // with four features report "4 of 16 features complete", which is a
350
+ // number nobody can reconcile with the four features listed beside it.
351
+ unit: "feature contract item across this module",
352
+ components,
353
+ revision,
354
+ measurable: progresses.some((p) => p.measurable),
355
+ now,
356
+ whatCountsExtra: [
357
+ `${complete.done} of ${count(complete.total, "measurable feature")} in this module are complete.`,
358
+ ],
359
+ whatRemainsExtra: unmeasurableNote(progresses, "feature"),
360
+ stale: progresses.some((p) => p.stale),
361
+ reason:
362
+ "This module has no feature with a declared completion contract, so its progress is not measurable.",
363
+ }),
364
+ subject: { type: "module", id: module.id, name: module.name, status: module.status },
365
+ features: progresses.map(childSummary),
366
+ };
367
+ }
368
+
369
+ /** Milestone progress derives from its delivered features and its exit conditions. */
370
+ export async function milestoneProgress(db, milestoneId) {
371
+ const milestone = await db.get("SELECT * FROM milestones WHERE id = ?", milestoneId);
372
+ if (!milestone) throw new DbError("E_NOT_FOUND", `Milestone ${milestoneId} does not exist.`);
373
+ const revision = await revisionOf(db, milestone.project_id);
374
+ const now = Date.now();
375
+ const features = (await db.all("SELECT * FROM features WHERE milestone_id = ?", milestoneId))
376
+ .filter((f) => !retired(f.status));
377
+ const progresses = [];
378
+ for (const feature of features) progresses.push(await computeFeature(db, feature, revision, now));
379
+ return milestoneFrom(milestone, progresses, revision, now);
380
+ }
381
+
382
+ function milestoneFrom(milestone, progresses, revision, now) {
383
+ const exit = conditionCounts(json(milestone.exit_conditions_json, []));
384
+ const components = [
385
+ comp("Features delivered", delivered(progresses)),
386
+ comp("Exit conditions", exit),
387
+ ];
388
+ return {
389
+ ...summarize({
390
+ unit: "delivery item",
391
+ components,
392
+ revision,
393
+ measurable: components.some((c) => c.applies),
394
+ now,
395
+ whatRemainsExtra: unmeasurableNote(progresses, "feature"),
396
+ stale: progresses.some((p) => p.stale),
397
+ reason:
398
+ "This milestone has no exit conditions and no feature with a completion contract, so it cannot be measured.",
399
+ }),
400
+ subject: { type: "milestone", id: milestone.id, name: milestone.name, status: milestone.status },
401
+ features: progresses.map(childSummary),
402
+ };
403
+ }
404
+
405
+ /** Goal progress derives from supporting feature outcomes and success criteria. */
406
+ export async function goalProgress(db, goalId) {
407
+ const goal = await db.get("SELECT * FROM goals WHERE id = ?", goalId);
408
+ if (!goal) throw new DbError("E_NOT_FOUND", `Goal ${goalId} does not exist.`);
409
+ const revision = await revisionOf(db, goal.project_id);
410
+ const now = Date.now();
411
+ const features = (
412
+ await db.all(
413
+ `SELECT f.* FROM features f
414
+ JOIN feature_goals fg ON fg.feature_id = f.id
415
+ WHERE fg.goal_id = ?`,
416
+ goalId,
417
+ )
418
+ ).filter((f) => !retired(f.status));
419
+ const criteria = await db.all(
420
+ "SELECT * FROM goal_success_criteria WHERE goal_id = ? ORDER BY sequence",
421
+ goalId,
422
+ );
423
+ const progresses = [];
424
+ for (const feature of features) progresses.push(await computeFeature(db, feature, revision, now));
425
+ return goalFrom(goal, progresses, criteria, revision, now);
426
+ }
427
+
428
+ function goalFrom(goal, progresses, criteria, revision, now) {
429
+ const components = [
430
+ comp("Supporting features delivered", delivered(progresses)),
431
+ comp("Success criteria met", tally(criteria)),
432
+ ];
433
+ const unmeasured = criteria.filter((c) => c.status === "unmeasured").length;
434
+ const whatRemainsExtra = unmeasurableNote(progresses, "supporting feature");
435
+ if (unmeasured) {
436
+ whatRemainsExtra.push(
437
+ `${count(unmeasured, "success criterion", "success criteria")} ${agrees(unmeasured, "has", "have")} never been measured, so they count as unmet.`,
438
+ );
439
+ }
440
+ return {
441
+ ...summarize({
442
+ unit: "goal outcome",
443
+ components,
444
+ revision,
445
+ measurable: components.some((c) => c.applies),
446
+ now,
447
+ whatRemainsExtra,
448
+ stale: progresses.some((p) => p.stale),
449
+ reason:
450
+ "This goal declares no success criteria and no supporting feature with a completion contract, so it cannot be measured.",
451
+ }),
452
+ subject: { type: "goal", id: goal.id, name: goal.name, status: goal.status },
453
+ features: progresses.map(childSummary),
454
+ };
455
+ }
456
+
457
+ const childSummary = (p) => ({
458
+ id: p.subject.id,
459
+ name: p.subject.name,
460
+ percent: p.percent,
461
+ completed: p.completed,
462
+ total: p.total,
463
+ measurable: p.measurable,
464
+ stale: p.stale,
465
+ });
466
+
467
+ /**
468
+ * Project progress covers the currently accepted scope, not every idea ever
469
+ * discussed. Drafts are named in `whatCounts` so their exclusion is visible
470
+ * rather than silent.
471
+ */
472
+ export async function projectProgress(db, projectId) {
473
+ const project = await db.get("SELECT * FROM projects WHERE id = ?", projectId);
474
+ if (!project) throw new DbError("E_NOT_FOUND", `Project ${projectId} does not exist.`);
475
+ const revision = await revisionOf(db, projectId);
476
+ const now = Date.now();
477
+
478
+ const allFeatures = await db.all("SELECT * FROM features WHERE project_id = ?", projectId);
479
+ const live = allFeatures.filter((f) => !retired(f.status));
480
+ const byId = new Map();
481
+ for (const feature of live) byId.set(feature.id, await computeFeature(db, feature, revision, now));
482
+
483
+ const acceptedFeatures = live.filter((f) => Boolean(f.accepted_at) || settled(f.status));
484
+ const acceptedProgress = acceptedFeatures.map((f) => byId.get(f.id));
485
+
486
+ const milestones = (await db.all("SELECT * FROM milestones WHERE project_id = ?", projectId))
487
+ .filter((m) => !retired(m.status));
488
+ const milestoneProgresses = milestones.map((m) =>
489
+ milestoneFrom(m, live.filter((f) => f.milestone_id === m.id).map((f) => byId.get(f.id)), revision, now),
490
+ );
491
+
492
+ const goals = (await db.all("SELECT * FROM goals WHERE project_id = ?", projectId))
493
+ .filter((g) => !retired(g.status));
494
+ const goalCriteria = goals.length
495
+ ? await db.all(
496
+ `SELECT * FROM goal_success_criteria WHERE goal_id IN (${marks(goals.length)})`,
497
+ ...goals.map((g) => g.id),
498
+ )
499
+ : [];
500
+
501
+ const components = [
502
+ comp("Accepted features delivered", delivered(acceptedProgress)),
503
+ comp("Milestones reached", delivered(milestoneProgresses)),
504
+ comp("Goal success criteria met", tally(goalCriteria)),
505
+ ];
506
+
507
+ const drafts = live.length - acceptedFeatures.length;
508
+ const whatCountsExtra = [
509
+ `Only accepted scope counts: ${count(acceptedFeatures.length, "accepted feature")} of ${live.length} live.`,
510
+ ];
511
+ if (drafts) whatCountsExtra.push(`${count(drafts, "feature")} are not accepted yet and are excluded.`);
512
+ const retiredFeatures = allFeatures.length - live.length;
513
+ if (retiredFeatures) {
514
+ whatCountsExtra.push(
515
+ `${count(retiredFeatures, "cancelled or superseded feature")} ${agrees(retiredFeatures, "is", "are")} excluded from the totals and stay in history.`,
516
+ );
517
+ }
518
+
519
+ return {
520
+ ...summarize({
521
+ unit: "scope item",
522
+ components,
523
+ revision,
524
+ measurable: components.some((c) => c.applies),
525
+ now,
526
+ whatCountsExtra,
527
+ whatRemainsExtra: unmeasurableNote(acceptedProgress, "accepted feature"),
528
+ stale: [...byId.values()].some((p) => p.stale),
529
+ reason:
530
+ "Nothing is accepted yet: no accepted feature, milestone or goal success criterion to measure. Accept scope before asking how far along it is.",
531
+ }),
532
+ subject: { type: "project", id: project.id, name: project.name, status: project.status },
533
+ milestones: milestoneProgresses.map(childSummary),
534
+ features: acceptedProgress.map(childSummary),
535
+ };
536
+ }
537
+
538
+ // --------------------------------------------------------------- readiness
539
+
540
+ /**
541
+ * Production readiness: the capability checklist, the twenty-step module loop,
542
+ * open questions and documentation sync. Brief sections 8.2, 9.3 and 15.2.
543
+ * Deferred and awaiting-decision areas count as unresolved, because a silent
544
+ * gap is the failure this checklist exists to prevent.
545
+ */
546
+ export async function readiness(db, projectId) {
547
+ const revision = await revisionOf(db, projectId);
548
+ const now = Date.now();
549
+
550
+ const areas = await db.all(
551
+ `SELECT * FROM capability_areas
552
+ WHERE project_id = ? AND catalog = 'readiness'
553
+ ORDER BY sequence, area`,
554
+ projectId,
555
+ );
556
+ const modules = (await db.all("SELECT * FROM modules WHERE project_id = ? ORDER BY sequence, name", projectId))
557
+ .filter((m) => !retired(m.status));
558
+ const steps = modules.length
559
+ ? await db.all(
560
+ `SELECT * FROM module_completeness WHERE module_id IN (${marks(modules.length)}) ORDER BY module_id, step`,
561
+ ...modules.map((m) => m.id),
562
+ )
563
+ : [];
564
+ const questions = await db.all("SELECT * FROM questions WHERE project_id = ?", projectId);
565
+ const documents = await db.all("SELECT * FROM documents WHERE project_id = ?", projectId);
566
+
567
+ const areaCounts = { specified: 0, awaiting_decision: 0, deferred: 0, not_applicable: 0 };
568
+ for (const area of areas) areaCounts[area.state] = (areaCounts[area.state] ?? 0) + 1;
569
+
570
+ const applicableAreas = areas.filter((a) => a.state !== "not_applicable");
571
+ const applicableSteps = steps.filter((s) => s.state !== "not_applicable");
572
+ const liveQuestions = questions.filter((q) => q.status !== "withdrawn");
573
+
574
+ const components = [
575
+ comp("Capability areas specified", {
576
+ done: applicableAreas.filter((a) => a.state === "specified").length,
577
+ total: applicableAreas.length,
578
+ }),
579
+ comp("Module completeness steps", {
580
+ done: applicableSteps.filter((s) => s.state === "filled").length,
581
+ total: applicableSteps.length,
582
+ }),
583
+ comp("Questions answered", {
584
+ done: liveQuestions.filter((q) => q.status === "answered").length,
585
+ total: liveQuestions.length,
586
+ }),
587
+ // Retired documents leave the total rather than counting against it. The
588
+ // record each described is gone and nothing generates them any more, so
589
+ // counting them as out of sync asks for work nobody can do and holds
590
+ // readiness below a hundred forever.
591
+ comp("Documentation in sync", tally(
592
+ documents.filter((d) => d.sync_status !== "retired"),
593
+ (d) => d.sync_status === "generated" || d.sync_status === "accepted")),
594
+ ];
595
+
596
+ const unresolvedAreas = areas.filter(
597
+ (a) => a.state === "awaiting_decision" || a.state === "deferred",
598
+ );
599
+ const silentGaps = unresolvedAreas.filter((a) => !a.question_id && !a.owner);
600
+ const whatRemainsExtra = [];
601
+ if (silentGaps.length) {
602
+ whatRemainsExtra.push(
603
+ `${count(silentGaps.length, "unresolved capability area")} ${agrees(silentGaps.length, "has", "have")} no owner and no open question, which is a silent gap.`,
604
+ );
605
+ }
606
+ const pendingProposals = documents.filter((d) => d.sync_status === "manual_edit_pending");
607
+ if (pendingProposals.length) {
608
+ whatRemainsExtra.push(
609
+ `${count(pendingProposals.length, "document")} were edited by hand and are waiting on an accept or reject decision.`,
610
+ );
611
+ }
612
+
613
+ return {
614
+ ...summarize({
615
+ unit: "readiness item",
616
+ components,
617
+ revision,
618
+ measurable: components.some((c) => c.applies),
619
+ now,
620
+ whatCountsExtra: areaCounts.not_applicable
621
+ ? [`${count(areaCounts.not_applicable, "capability area")} ${agrees(areaCounts.not_applicable, "is", "are")} recorded as not applicable, with a reason, and are excluded.`]
622
+ : [],
623
+ whatRemainsExtra,
624
+ stale: pendingProposals.length > 0,
625
+ reason:
626
+ "No readiness checklist has been recorded for this project, so readiness cannot be measured. Run discovery to evaluate the capability areas.",
627
+ }),
628
+ subject: { type: "project", id: projectId },
629
+ areaCounts,
630
+ areas: areas.map((a) => ({
631
+ id: a.id,
632
+ area: a.area,
633
+ state: a.state,
634
+ choice: a.choice,
635
+ reason: a.reason,
636
+ owner: a.owner,
637
+ revisitTrigger: a.revisit_trigger,
638
+ consequence: a.consequence,
639
+ questionId: a.question_id,
640
+ decisionId: a.decision_id,
641
+ silentGap: !a.question_id && !a.owner && (a.state === "awaiting_decision" || a.state === "deferred"),
642
+ })),
643
+ modules: modules.map((m) => {
644
+ const mine = steps.filter((s) => s.module_id === m.id);
645
+ const applicable = mine.filter((s) => s.state !== "not_applicable");
646
+ return {
647
+ id: m.id,
648
+ name: m.name,
649
+ filled: applicable.filter((s) => s.state === "filled").length,
650
+ applicable: applicable.length,
651
+ openSteps: applicable
652
+ .filter((s) => s.state !== "filled")
653
+ .map((s) => ({ step: s.step, name: s.step_name })),
654
+ };
655
+ }),
656
+ questions: {
657
+ open: liveQuestions.filter((q) => q.status === "open").map(questionSummary),
658
+ deferred: liveQuestions.filter((q) => q.status === "deferred").map(questionSummary),
659
+ },
660
+ pendingDocumentProposals: pendingProposals.map((d) => ({ id: d.id, path: d.path, kind: d.kind })),
661
+ };
662
+ }
663
+
664
+ const questionSummary = (q) => ({
665
+ id: q.id,
666
+ question: q.question,
667
+ whyItMatters: q.why_it_matters,
668
+ recommendation: q.recommendation,
669
+ scopeType: q.scope_type,
670
+ scopeId: q.scope_id,
671
+ deferralReason: q.deferral_reason,
672
+ });
673
+
674
+ // --------------------------------------------------------------- freshness
675
+
676
+ /**
677
+ * Is what we are looking at the present? Brief section 16.3. This asks a
678
+ * different question from progress: a perfectly complete picture can still be
679
+ * a fortnight out of date, and folding the two together is how "on track" gets
680
+ * said about a snapshot that has since been overtaken.
681
+ */
682
+ export async function freshness(db, projectId) {
683
+ const now = Date.now();
684
+ const last = await db.get(
685
+ `SELECT sequence, created_at, event_type, summary FROM activity_events
686
+ WHERE project_id = ? ORDER BY sequence DESC LIMIT 1`,
687
+ projectId,
688
+ );
689
+ const documents = await db.all("SELECT * FROM documents WHERE project_id = ?", projectId);
690
+ const sessions = await db.all(
691
+ `SELECT * FROM work_sessions WHERE project_id = ? AND status = 'active'
692
+ ORDER BY last_activity_at DESC`,
693
+ projectId,
694
+ );
695
+ const branches = await db.all(
696
+ "SELECT * FROM branches WHERE project_id = ? AND status = 'active' ORDER BY last_seen_at DESC",
697
+ projectId,
698
+ );
699
+ const evidence = await db.all(
700
+ "SELECT * FROM verification_evidence WHERE project_id = ? AND status <> 'superseded'",
701
+ projectId,
702
+ );
703
+ const conflicts = await db.all(
704
+ "SELECT count(*) AS n FROM sync_conflicts WHERE project_id = ? AND status = 'open'",
705
+ projectId,
706
+ );
707
+
708
+ const revision = last?.sequence ?? 0;
709
+ const generated = documents
710
+ .map((d) => d.generated_at)
711
+ .filter(Boolean)
712
+ .sort();
713
+ // A document is stale when the records it was rendered from have moved on,
714
+ // not when it was merely written. `generate` stamps each row with the revision
715
+ // it rendered at and only then records its own documentation_generated event,
716
+ // so comparing against the raw maximum sequence would report every document as
717
+ // stale the instant it was produced, and no run could ever clear it.
718
+ const contentRevision = (await db.get(
719
+ `SELECT max(sequence) AS s FROM activity_events
720
+ WHERE project_id = ? AND event_type NOT IN
721
+ ('documentation_generated','documentation_proposal_raised',
722
+ 'documentation_proposal_resolved','documentation_possibly_stale')`,
723
+ projectId,
724
+ ))?.s ?? 0;
725
+ const live = documents.filter((d) => d.sync_status !== "retired");
726
+ const behind = live.filter((d) => (d.database_revision ?? 0) < contentRevision);
727
+ const pendingProposals = documents.filter((d) => d.sync_status === "manual_edit_pending");
728
+ const missingDocuments = documents.filter((d) => d.sync_status === "missing");
729
+ const staleEvidence = evidence.filter((e) => !evidenceIsCurrent(e, now));
730
+
731
+ const liveSessions = sessions.map((s) => {
732
+ const at = Date.parse(s.last_activity_at ?? s.started_at ?? "");
733
+ return {
734
+ id: s.id,
735
+ developerId: s.developer_id,
736
+ agentId: s.agent_id,
737
+ branchId: s.branch_id,
738
+ activeTaskId: s.active_task_id,
739
+ objective: s.objective,
740
+ lastActivityAt: s.last_activity_at ?? s.started_at ?? null,
741
+ stale: !Number.isFinite(at) || now - at > SESSION_STALE_MS,
742
+ };
743
+ });
744
+
745
+ const reasons = [];
746
+ if (behind.length) {
747
+ reasons.push(
748
+ `${count(behind.length, "generated document")} ${behind.length === 1 ? "was" : "were"} built from an older database revision than ${contentRevision}.`,
749
+ );
750
+ }
751
+ if (missingDocuments.length) {
752
+ reasons.push(`${count(missingDocuments.length, "expected document")} ${agrees(missingDocuments.length, "is", "are")} missing from disk.`);
753
+ }
754
+ if (pendingProposals.length) {
755
+ reasons.push(`${count(pendingProposals.length, "document")} have an unresolved manual edit.`);
756
+ }
757
+ if (staleEvidence.length) {
758
+ reasons.push(
759
+ `${count(staleEvidence.length, "verification record")} ${agrees(staleEvidence.length, "is", "are")} older than ${EVIDENCE_STALE_DAYS} days.`,
760
+ );
761
+ }
762
+ const abandoned = liveSessions.filter((s) => s.stale);
763
+ if (abandoned.length) {
764
+ // "1 session are still marked active" reads as a bug in the product, which
765
+ // is a poor way to report somebody else's housekeeping. count() gets the
766
+ // noun right and the verb has to agree with it.
767
+ reasons.push(`${count(abandoned.length, "session")} ${
768
+ abandoned.length === 1 ? "is" : "are"
769
+ } still marked active but ${
770
+ abandoned.length === 1 ? "has" : "have"
771
+ } gone quiet.`);
772
+ }
773
+ const openConflicts = conflicts[0]?.n ?? 0;
774
+ if (openConflicts) reasons.push(`${count(openConflicts, "unresolved sync conflict")}.`);
775
+
776
+ return {
777
+ revision,
778
+ lastEventSequence: revision,
779
+ lastEventAt: last?.created_at ?? null,
780
+ lastEventSummary: last?.summary ?? null,
781
+ lastDocumentationGeneratedAt: generated.at(-1) ?? null,
782
+ documentsBehindRevision: behind.length,
783
+ pendingDocumentProposals: pendingProposals.map((d) => ({ id: d.id, path: d.path })),
784
+ missingDocuments: missingDocuments.map((d) => ({ id: d.id, path: d.path })),
785
+ staleEvidence: staleEvidence.map((e) => ({
786
+ id: e.id,
787
+ featureId: e.feature_id,
788
+ taskId: e.task_id,
789
+ summary: e.summary,
790
+ recordedAt: e.recorded_at,
791
+ result: e.result,
792
+ })),
793
+ sessions: liveSessions,
794
+ lastSessionHeartbeatAt: liveSessions[0]?.lastActivityAt ?? null,
795
+ branches: branches.map((b) => ({
796
+ id: b.id,
797
+ name: b.name,
798
+ headRevision: b.head_revision,
799
+ dirty: Boolean(b.dirty),
800
+ lastSeenAt: b.last_seen_at,
801
+ })),
802
+ openSyncConflicts: openConflicts,
803
+ stale: reasons.length > 0,
804
+ reasons,
805
+ generatedAt: iso(now),
806
+ };
807
+ }
808
+
809
+ // -------------------------------------------------------------- next action
810
+
811
+ const PRIORITY_RANK = { critical: 0, urgent: 1, high: 2, normal: 3, medium: 3, low: 4 };
812
+ const rank = (task) => PRIORITY_RANK[String(task.priority ?? "normal").toLowerCase()] ?? 3;
813
+ const byPriority = (a, b) =>
814
+ rank(a) - rank(b) || (a.sequence ?? 0) - (b.sequence ?? 0) || String(a.id).localeCompare(String(b.id));
815
+
816
+ const IN_FLIGHT = new Set(["in_progress", "in_review", "verifying"]);
817
+
818
+ /**
819
+ * One answer to "what should happen next", as a strict cascade: a blocking
820
+ * problem beats work in progress, which beats ready work, which beats
821
+ * unspecified scope, which beats verification. The cascade is fixed so the CLI
822
+ * and the control center never disagree about the top of the list.
823
+ */
824
+ export async function nextAction(db, projectId) {
825
+ const now = Date.now();
826
+ const revision = await revisionOf(db, projectId);
827
+ const context = { revision, generatedAt: iso(now) };
828
+
829
+
830
+
831
+ const tasks = await db.all("SELECT * FROM tasks WHERE project_id = ?", projectId);
832
+ const open = tasks.filter((t) => !retired(t.status));
833
+ const byId = new Map(tasks.map((t) => [t.id, t]));
834
+
835
+ // 1. Blocking problems.
836
+ const blocked = open.filter((t) => t.status === "blocked").sort(byPriority);
837
+ if (blocked.length) {
838
+ const task = blocked[0];
839
+ return answer(context, {
840
+ kind: "blocking_problem",
841
+ recordType: "task",
842
+ record: task,
843
+ title: `Unblock ${task.id}: ${task.name}`,
844
+ detail: task.block_reason
845
+ ? `It is blocked because: ${task.block_reason}`
846
+ : "It is blocked and no reason was recorded, so the reason has to be established first.",
847
+ remedy: "Resolve the blocker or record why the work should be cancelled instead.",
848
+ others: blocked.slice(1),
849
+ });
850
+ }
851
+
852
+ const failing = await db.all(
853
+ `SELECT * FROM verification_evidence
854
+ WHERE project_id = ? AND result = 'fail' AND status = 'current'
855
+ ORDER BY recorded_at DESC`,
856
+ projectId,
857
+ );
858
+ if (failing.length) {
859
+ const row = failing[0];
860
+ return answer(context, {
861
+ kind: "blocking_problem",
862
+ recordType: "verification_evidence",
863
+ record: row,
864
+ title: `Fix the failing check on ${row.feature_id ?? row.task_id ?? row.id}`,
865
+ detail: `Verification recorded a failure: ${row.summary}`,
866
+ remedy: "Repair the behaviour, then record fresh evidence.",
867
+ others: failing.slice(1),
868
+ });
869
+ }
870
+
871
+ const conflicts = await db.all(
872
+ "SELECT * FROM sync_conflicts WHERE project_id = ? AND status = 'open' ORDER BY detected_at",
873
+ projectId,
874
+ );
875
+ if (conflicts.length) {
876
+ const row = conflicts[0];
877
+ return answer(context, {
878
+ kind: "blocking_problem",
879
+ recordType: "sync_conflict",
880
+ record: row,
881
+ title: `Resolve the conflict on ${row.record_type} ${row.record_id}`,
882
+ detail: "Two versions of this record disagree and nothing downstream can be trusted until it is settled.",
883
+ remedy: "Choose the local or remote value, or merge them, and record the resolution.",
884
+ others: conflicts.slice(1),
885
+ });
886
+ }
887
+
888
+ // 2. Work already in progress. Finishing beats starting.
889
+ const active = open.filter((t) => IN_FLIGHT.has(t.status)).sort(byPriority);
890
+ if (active.length) {
891
+ const task = active[0];
892
+ return answer(context, {
893
+ kind: "work_in_progress",
894
+ recordType: "task",
895
+ record: task,
896
+ title: `Finish ${task.id}: ${task.name}`,
897
+ detail: `It is already ${task.status.replace(/_/g, " ")}. Finishing open work beats starting more.`,
898
+ remedy: task.expected_outcome
899
+ ? `Expected outcome: ${task.expected_outcome}`
900
+ : "Complete it and attach the verification it declares.",
901
+ others: active.slice(1),
902
+ });
903
+ }
904
+
905
+ // 3. Ready work whose dependencies are all satisfied.
906
+ const dependencies = await db.all(
907
+ `SELECT d.* FROM task_dependencies d
908
+ JOIN tasks t ON t.id = d.task_id
909
+ WHERE t.project_id = ? AND d.dependency_type = 'blocks'`,
910
+ projectId,
911
+ );
912
+ const waitingOn = new Map();
913
+ for (const dep of dependencies) {
914
+ const upstream = byId.get(dep.depends_on_task_id);
915
+ if (!upstream) continue;
916
+ if (upstream.status === "complete" || retired(upstream.status)) continue;
917
+ waitingOn.set(dep.task_id, [...(waitingOn.get(dep.task_id) ?? []), upstream.id]);
918
+ }
919
+ const ready = open
920
+ .filter((t) => (t.status === "ready" || t.status === "paused") && !waitingOn.has(t.id))
921
+ .sort(byPriority);
922
+ if (ready.length) {
923
+ const task = ready[0];
924
+ return answer(context, {
925
+ kind: "ready_work",
926
+ recordType: "task",
927
+ record: task,
928
+ title: `${task.status === "paused" ? "Resume" : "Start"} ${task.id}: ${task.name}`,
929
+ detail: task.status === "paused"
930
+ ? "It was paused and nothing is blocking it now."
931
+ : "It is ready and nothing is blocking it.",
932
+ remedy: task.expected_outcome ? `Expected outcome: ${task.expected_outcome}` : "Claim it and begin.",
933
+ others: ready.slice(1),
934
+ });
935
+ }
936
+
937
+ // 4. Scope that cannot be worked because it was never specified.
938
+ const features = (await db.all("SELECT * FROM features WHERE project_id = ?", projectId))
939
+ .filter((f) => !retired(f.status));
940
+ const criteriaCount = await countBy(db, "feature_acceptance_criteria", "feature_id");
941
+ const taskCount = new Map();
942
+ for (const task of open) taskCount.set(task.feature_id, (taskCount.get(task.feature_id) ?? 0) + 1);
943
+ const unspecified = features.filter(
944
+ (f) => !(criteriaCount.get(f.id) > 0) && !(taskCount.get(f.id) > 0),
945
+ );
946
+ if (unspecified.length) {
947
+ const feature = unspecified[0];
948
+ return answer(context, {
949
+ kind: "unspecified_feature",
950
+ recordType: "feature",
951
+ record: feature,
952
+ title: `Specify ${feature.id}: ${feature.name}`,
953
+ detail: "It has no acceptance criteria and no tasks, so its progress cannot be measured at all.",
954
+ remedy: "Write the acceptance criteria, then derive tasks from them.",
955
+ others: unspecified.slice(1),
956
+ });
957
+ }
958
+
959
+ const questions = await db.all(
960
+ "SELECT * FROM questions WHERE project_id = ? AND status = 'open' ORDER BY created_at",
961
+ projectId,
962
+ );
963
+ if (questions.length) {
964
+ const question = questions[0];
965
+ return answer(context, {
966
+ kind: "unspecified_feature",
967
+ recordType: "question",
968
+ record: question,
969
+ title: `Answer ${question.id}: ${question.question}`,
970
+ detail: `It matters because: ${question.why_it_matters}`,
971
+ remedy: question.recommendation
972
+ ? `Recommendation on file: ${question.recommendation}`
973
+ : "Decide, or defer it with an owner and a revisit trigger.",
974
+ others: questions.slice(1),
975
+ });
976
+ }
977
+
978
+ // 5. Work that is done but unproven.
979
+ const evidence = await db.all(
980
+ "SELECT * FROM verification_evidence WHERE project_id = ?",
981
+ projectId,
982
+ );
983
+ const proven = new Set(
984
+ evidence
985
+ .filter((e) => e.acceptance_criterion_id && e.result === "pass" && evidenceIsCurrent(e, now))
986
+ .map((e) => e.acceptance_criterion_id),
987
+ );
988
+ const criteria = features.length
989
+ ? await db.all(
990
+ `SELECT * FROM feature_acceptance_criteria WHERE feature_id IN (${marks(features.length)})`,
991
+ ...features.map((f) => f.id),
992
+ )
993
+ : [];
994
+ const unproven = criteria.filter((c) => c.status === "met" && !proven.has(c.id));
995
+ if (unproven.length) {
996
+ const criterion = unproven[0];
997
+ return answer(context, {
998
+ kind: "verification",
999
+ recordType: "feature_acceptance_criterion",
1000
+ record: criterion,
1001
+ title: `Prove ${criterion.id} on ${criterion.feature_id}`,
1002
+ detail: `It is marked met with no current evidence: ${criterion.criterion}`,
1003
+ remedy: criterion.verification_method
1004
+ ? `Run the declared check and record the result: ${criterion.verification_method}`
1005
+ : "Record how it was verified, or move it back to unmet.",
1006
+ others: unproven.slice(1),
1007
+ });
1008
+ }
1009
+
1010
+ const stale = evidence.filter((e) => e.status !== "superseded" && !evidenceIsCurrent(e, now));
1011
+ if (stale.length) {
1012
+ const row = stale[0];
1013
+ return answer(context, {
1014
+ kind: "verification",
1015
+ recordType: "verification_evidence",
1016
+ record: row,
1017
+ title: `Re-verify ${row.feature_id ?? row.task_id ?? row.id}`,
1018
+ detail: `Its proof is older than ${EVIDENCE_STALE_DAYS} days, so it no longer counts: ${row.summary}`,
1019
+ remedy: "Run the check again and record fresh evidence.",
1020
+ others: stale.slice(1),
1021
+ });
1022
+ }
1023
+
1024
+ const drafts = open.filter((t) => t.status === "draft").sort(byPriority);
1025
+ if (drafts.length) {
1026
+ const task = drafts[0];
1027
+ return answer(context, {
1028
+ kind: "ready_work",
1029
+ recordType: "task",
1030
+ record: task,
1031
+ title: `Complete the definition of ${task.id}: ${task.name}`,
1032
+ detail: "It is still a draft, so it cannot be claimed. A task leaves draft only with a contract link or a named feature it unblocks.",
1033
+ remedy: "Link it to what it implements, then move it to ready.",
1034
+ others: drafts.slice(1),
1035
+ });
1036
+ }
1037
+
1038
+ return {
1039
+ kind: "nothing_pending",
1040
+ title: "Nothing is pending",
1041
+ detail: "No blocked work, no work in progress, no ready work, no unspecified feature and no unproven criterion.",
1042
+ remedy: "Accept more scope, or ship.",
1043
+ recordType: null,
1044
+ recordId: null,
1045
+ record: null,
1046
+ alternatives: [],
1047
+ sourceRevision: revision,
1048
+ generatedAt: context.generatedAt,
1049
+ };
1050
+ }
1051
+
1052
+ function answer(context, { kind, recordType, record, title, detail, remedy, others = [] }) {
1053
+ return {
1054
+ kind,
1055
+ title,
1056
+ detail,
1057
+ remedy,
1058
+ recordType,
1059
+ recordId: record.id,
1060
+ record,
1061
+ alternatives: others.slice(0, 4).map((o) => ({
1062
+ id: o.id,
1063
+ label: o.name ?? o.question ?? o.summary ?? o.criterion ?? o.record_id ?? o.id,
1064
+ })),
1065
+ alsoWaiting: others.length,
1066
+ sourceRevision: context.revision,
1067
+ generatedAt: context.generatedAt,
1068
+ };
1069
+ }
1070
+
1071
+ async function countBy(db, table, column) {
1072
+ const rows = await db.all(`SELECT ${column} AS key, count(*) AS n FROM ${table} GROUP BY ${column}`);
1073
+ return new Map(rows.map((r) => [r.key, r.n]));
1074
+ }
1075
+
1076
+ // --------------------------------------------------------------- alignment
1077
+
1078
+ const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
1079
+
1080
+ /**
1081
+ * Coherence of the map, which is a separate question from progress and from
1082
+ * freshness. Every warning names one record and one thing to do about it: a
1083
+ * warning nobody can act on is noise, and noise is how people learn to ignore
1084
+ * the whole list.
1085
+ */
1086
+ export async function alignmentWarnings(db, projectId) {
1087
+ const now = Date.now();
1088
+ const out = [];
1089
+ const warn = (severity, code, recordType, recordId, title, detail, remedy) =>
1090
+ out.push({ severity, code, recordType, recordId, title, detail, remedy });
1091
+
1092
+ const features = (await db.all("SELECT * FROM features WHERE project_id = ?", projectId))
1093
+ .filter((f) => !retired(f.status));
1094
+ const criteria = features.length
1095
+ ? await db.all(
1096
+ `SELECT * FROM feature_acceptance_criteria WHERE feature_id IN (${marks(features.length)})`,
1097
+ ...features.map((f) => f.id),
1098
+ )
1099
+ : [];
1100
+ // A feature that says it is specified to full depth while carrying microspec
1101
+ // content is a promise the record does not keep, and the depth is exactly what
1102
+ // a reader trusts when deciding whether to ask more questions.
1103
+ const { depthReadiness } = await import("../features/acceptance.mjs");
1104
+ for (const feature of features) {
1105
+ if (feature.status !== "accepted") continue;
1106
+ try {
1107
+ const depth = await depthReadiness(db, feature.id);
1108
+ if (depth.acceptable) continue;
1109
+ warn(
1110
+ "high",
1111
+ "feature_thinner_than_its_depth",
1112
+ "feature",
1113
+ feature.id,
1114
+ `${feature.name} claims ${depth.depth} depth but does not carry it`,
1115
+ `${depth.met} of the ${depth.required} things ${depth.depth} depth promises are recorded. Missing: ${depth.missing.map((m) => m.says).join("; ")}.`,
1116
+ "Record what is missing, or lower the feature's depth so the record matches the feature.",
1117
+ );
1118
+ } catch {
1119
+ // An unreadable depth is not a reason to lose every other warning.
1120
+ }
1121
+ }
1122
+ const tasks = await db.all("SELECT * FROM tasks WHERE project_id = ?", projectId);
1123
+ const links = await db.all("SELECT * FROM feature_goals");
1124
+ const evidence = await db.all("SELECT * FROM verification_evidence WHERE project_id = ?", projectId);
1125
+
1126
+ const criteriaByFeature = new Map();
1127
+ for (const c of criteria) {
1128
+ if (retired(c.status)) continue;
1129
+ criteriaByFeature.set(c.feature_id, [...(criteriaByFeature.get(c.feature_id) ?? []), c]);
1130
+ }
1131
+ const tasksByFeature = new Map();
1132
+ for (const t of tasks) {
1133
+ if (retired(t.status)) continue;
1134
+ tasksByFeature.set(t.feature_id, [...(tasksByFeature.get(t.feature_id) ?? []), t]);
1135
+ }
1136
+ const goalsByFeature = new Set(links.map((l) => l.feature_id));
1137
+ const proven = new Set(
1138
+ evidence
1139
+ .filter((e) => e.acceptance_criterion_id && e.result === "pass" && evidenceIsCurrent(e, now))
1140
+ .map((e) => e.acceptance_criterion_id),
1141
+ );
1142
+
1143
+ for (const feature of features) {
1144
+ const mine = criteriaByFeature.get(feature.id) ?? [];
1145
+ const myTasks = tasksByFeature.get(feature.id) ?? [];
1146
+ if (!mine.length) {
1147
+ warn("medium", "feature_without_acceptance_criteria", "feature", feature.id,
1148
+ `${feature.id} has no acceptance criteria`,
1149
+ "Nothing states when this feature is done, so its progress cannot be measured and no task can be verified against it.",
1150
+ "Write at least one acceptance criterion with a verification method.");
1151
+ }
1152
+ if (!goalsByFeature.has(feature.id)) {
1153
+ warn("medium", "feature_without_goal", "feature", feature.id,
1154
+ `${feature.id} serves no goal`,
1155
+ "It is not linked to any goal, so it cannot be traded off against anything or justified when scope is cut.",
1156
+ "Link it to the goal it advances, or move it out of scope.");
1157
+ }
1158
+ const accepted = Boolean(feature.accepted_at) || settled(feature.status);
1159
+ if (accepted && !myTasks.length && mine.some((c) => c.status !== "met")) {
1160
+ warn("medium", "accepted_feature_without_tasks", "feature", feature.id,
1161
+ `${feature.id} is accepted with unmet criteria and no open tasks`,
1162
+ "Accepted scope with nothing planned against it quietly stalls.",
1163
+ "Derive tasks from its acceptance criteria.");
1164
+ }
1165
+ if (finished(feature.status) && mine.some((c) => c.status === "unmet")) {
1166
+ const n = mine.filter((c) => c.status === "unmet").length;
1167
+ warn("high", "feature_complete_with_unmet_criteria", "feature", feature.id,
1168
+ `${feature.id} claims ${feature.status} with ${count(n, "unmet criterion", "unmet criteria")}`,
1169
+ "The status says finished and the contract says otherwise. One of the two is wrong.",
1170
+ "Meet the criteria, waive them with a reason, or move the feature back.");
1171
+ }
1172
+ if (finished(feature.status)) {
1173
+ const openTasks = myTasks.filter((t) => t.status !== "complete");
1174
+ if (openTasks.length) {
1175
+ warn("high", "feature_complete_with_open_tasks", "feature", feature.id,
1176
+ `${feature.id} claims ${feature.status} with ${count(openTasks.length, "open task")}`,
1177
+ "A feature cannot be finished while the work it declared is not.",
1178
+ "Close or cancel the remaining tasks, or reopen the feature.");
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ for (const c of criteria) {
1184
+ if (c.status === "met" && !proven.has(c.id)) {
1185
+ warn("medium", "criterion_met_without_evidence", "feature_acceptance_criterion", c.id,
1186
+ `${c.id} is met with no current evidence`,
1187
+ `It was marked met but nothing current proves it: ${c.criterion}`,
1188
+ "Attach passing verification evidence, or move it back to unmet.");
1189
+ }
1190
+ }
1191
+
1192
+ for (const e of evidence) {
1193
+ if (e.status !== "superseded" && !evidenceIsCurrent(e, now)) {
1194
+ warn("medium", "stale_evidence", "verification_evidence", e.id,
1195
+ `${e.id} is no longer current proof`,
1196
+ `Recorded ${e.recorded_at} and older than ${EVIDENCE_STALE_DAYS} days: ${e.summary}`,
1197
+ "Run the check again and record fresh evidence.");
1198
+ }
1199
+ }
1200
+
1201
+ const goals = (await db.all("SELECT * FROM goals WHERE project_id = ?", projectId))
1202
+ .filter((g) => !retired(g.status));
1203
+ const goalCriteria = goals.length
1204
+ ? await db.all(
1205
+ `SELECT * FROM goal_success_criteria WHERE goal_id IN (${marks(goals.length)})`,
1206
+ ...goals.map((g) => g.id),
1207
+ )
1208
+ : [];
1209
+ const supported = new Set(links.map((l) => l.goal_id));
1210
+ for (const goal of goals) {
1211
+ if (!goalCriteria.some((c) => c.goal_id === goal.id)) {
1212
+ warn("medium", "goal_without_success_criteria", "goal", goal.id,
1213
+ `${goal.id} has no success criteria`,
1214
+ "Without a measurable criterion this goal can never be shown to have been reached.",
1215
+ "Add at least one criterion with a measurement method and a target.");
1216
+ }
1217
+ if (!supported.has(goal.id)) {
1218
+ warn("medium", "goal_without_features", "goal", goal.id,
1219
+ `${goal.id} has no supporting features`,
1220
+ "Nothing being built advances this goal.",
1221
+ "Link the features that serve it, or retire the goal.");
1222
+ }
1223
+ }
1224
+
1225
+ const milestones = (await db.all("SELECT * FROM milestones WHERE project_id = ?", projectId))
1226
+ .filter((m) => !retired(m.status));
1227
+ for (const milestone of milestones) {
1228
+ if (!conditionCounts(json(milestone.exit_conditions_json, [])).total) {
1229
+ warn("medium", "milestone_without_exit_conditions", "milestone", milestone.id,
1230
+ `${milestone.id} has no exit conditions`,
1231
+ "There is no stated test for whether this milestone has been reached.",
1232
+ "Record the conditions that must hold before it can be called done.");
1233
+ }
1234
+ // A milestone with no features and no exit conditions is empty and worth
1235
+ // reporting. One with exit conditions and no features is a gate rather than
1236
+ // a delivery phase: a release checkpoint whose test is a set of conditions
1237
+ // over the whole product, not a bag of scheduled work. Flagging that told
1238
+ // the reader to assign scope to something that deliberately has none, and
1239
+ // following the advice would have double counted every feature.
1240
+ const hasFeatures = features.some((f) => f.milestone_id === milestone.id);
1241
+ const hasExitConditions = conditionCounts(json(milestone.exit_conditions_json, [])).total > 0;
1242
+ if (!hasFeatures && !hasExitConditions) {
1243
+ warn("low", "milestone_without_features", "milestone", milestone.id,
1244
+ `${milestone.id} has neither features nor exit conditions`,
1245
+ "No scope is assigned to it and nothing says when it is reached, so it cannot be delivered or measured.",
1246
+ "Assign the features it delivers, record the conditions that must hold, or remove it.");
1247
+ }
1248
+ }
1249
+
1250
+ const modules = (await db.all("SELECT * FROM modules WHERE project_id = ?", projectId))
1251
+ .filter((m) => !retired(m.status));
1252
+ for (const module of modules) {
1253
+ if (!features.some((f) => f.module_id === module.id)) {
1254
+ warn("low", "module_without_features", "module", module.id,
1255
+ `${module.id} has no features`,
1256
+ "The module is declared but owns nothing.",
1257
+ "Add the features it owns, or deprecate it.");
1258
+ }
1259
+ }
1260
+
1261
+ const contractLinks = await db.all("SELECT DISTINCT task_id FROM task_contract_links");
1262
+ const mapped = new Set(contractLinks.map((r) => r.task_id));
1263
+ for (const task of tasks) {
1264
+ if (retired(task.status) || task.status === "draft") continue;
1265
+ if (task.enabling) {
1266
+ if (!task.enabled_feature_id || !task.enabling_rationale) {
1267
+ warn("high", "enabling_task_without_target", "task", task.id,
1268
+ `${task.id} is enabling work with no named target`,
1269
+ "Enabling work that names no feature it unblocks is indistinguishable from unmapped work.",
1270
+ "Name the feature it unblocks and record why.");
1271
+ }
1272
+ continue;
1273
+ }
1274
+ if (!mapped.has(task.id)) {
1275
+ warn("high", "task_without_contract", "task", task.id,
1276
+ `${task.id} implements nothing declared`,
1277
+ "It left draft without a contract link, so no specification moves when it completes.",
1278
+ "Link it to the criterion, workflow step, API, entity or surface it implements.");
1279
+ }
1280
+ }
1281
+
1282
+ const areas = await db.all(
1283
+ "SELECT * FROM capability_areas WHERE project_id = ? AND catalog = 'readiness'",
1284
+ projectId,
1285
+ );
1286
+ for (const area of areas) {
1287
+ if (area.state === "awaiting_decision" && !area.question_id) {
1288
+ warn("high", "capability_gap_without_question", "capability_area", area.id,
1289
+ `${area.area} is awaiting a decision with no question raised`,
1290
+ "An unanswered area with nobody asked is a silent gap, which this checklist exists to make impossible.",
1291
+ "Raise the question, or record the area as not applicable with a reason.");
1292
+ }
1293
+ if (area.state === "deferred" && (!area.owner || !area.revisit_trigger)) {
1294
+ warn("medium", "deferred_without_owner", "capability_area", area.id,
1295
+ `${area.area} is deferred without an owner or a revisit trigger`,
1296
+ "A deferral with nobody to revisit it is a decision never to do it.",
1297
+ "Record the owner, the revisit trigger and the consequence of leaving it.");
1298
+ }
1299
+ }
1300
+
1301
+ const decisions = await db.all(
1302
+ "SELECT * FROM decisions WHERE project_id = ? AND status IN ('accepted','time_boxed','revisit_required')",
1303
+ projectId,
1304
+ );
1305
+ for (const decision of decisions) {
1306
+ const expiry = Date.parse(decision.expires_at ?? "");
1307
+ if (Number.isFinite(expiry) && expiry <= now) {
1308
+ warn("high", "decision_expired", "decision", decision.id,
1309
+ `${decision.id} has passed its expiry`,
1310
+ `A time-boxed decision stopped governing on ${decision.expires_at} and is still marked ${decision.status}.`,
1311
+ "Re-accept it, supersede it, or record what replaced it.");
1312
+ }
1313
+ if (decision.status === "revisit_required") {
1314
+ warn("medium", "decision_needs_revisit", "decision", decision.id,
1315
+ `${decision.id} is flagged for revisit`,
1316
+ "A revisit trigger fired and the decision has not been looked at since.",
1317
+ "Confirm it still holds, or supersede it.");
1318
+ }
1319
+ }
1320
+
1321
+ const documents = await db.all(
1322
+ "SELECT * FROM documents WHERE project_id = ? AND sync_status IN ('manual_edit_pending','missing')",
1323
+ projectId,
1324
+ );
1325
+ for (const doc of documents) {
1326
+ warn("medium", `document_${doc.sync_status}`, "document", doc.id,
1327
+ doc.sync_status === "missing"
1328
+ ? `${doc.path} is missing from disk`
1329
+ : `${doc.path} was edited by hand`,
1330
+ doc.sync_status === "missing"
1331
+ ? "A generated document that should exist is not there, so what people read and what the database holds have parted company."
1332
+ : "The file no longer matches what was generated, so regeneration is held back until someone decides.",
1333
+ doc.sync_status === "missing"
1334
+ ? "Regenerate it."
1335
+ : "Accept the edit back into the database, or reject it and regenerate.");
1336
+ }
1337
+
1338
+ // The product changing while nothing tracked it is the record falling behind,
1339
+ // which is what this whole report is for. The hook has recorded that marker
1340
+ // since it was written and nothing read it, so the one place a reader looks
1341
+ // for "is the record still true?" never mentioned the clearest sign that it
1342
+ // is not. Only markers since the last task was claimed count: an older one
1343
+ // describes work somebody has since accounted for.
1344
+ const untracked = await db.get(
1345
+ `SELECT COUNT(*) AS n, MAX(created_at) AS latest FROM activity_events
1346
+ WHERE project_id = ? AND event_type = 'untracked_work'
1347
+ AND created_at > coalesce(
1348
+ (SELECT MAX(created_at) FROM activity_events
1349
+ WHERE project_id = ? AND event_type IN ('task_claimed', 'task_created')), '')`,
1350
+ projectId, projectId,
1351
+ );
1352
+ if (Number(untracked?.n ?? 0) > 0) {
1353
+ warn("high", "work_without_a_task", "project", projectId,
1354
+ `${untracked.n} ${Number(untracked.n) === 1 ? "change was" : "changes were"} made while no task was claimed`,
1355
+ "The product changed and nothing in the record says why, which feature it serves, or what would prove it. Progress is derived from tracked work, so this work is invisible to every number on this page.",
1356
+ "Create or claim a task covering it, then link it to the contract it implements.");
1357
+ }
1358
+
1359
+ return out.sort(
1360
+ (a, b) =>
1361
+ SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
1362
+ String(a.recordId).localeCompare(String(b.recordId)),
1363
+ );
1364
+ }