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,20 @@
1
+ -- Connect evidence to the test plan it satisfies.
2
+ --
3
+ -- Section 9.3 lists five conditions for completing a task, and one of them is
4
+ -- that the product tests defined by the accepted test plan pass. The gate
5
+ -- enforced the other four. Test plans existed as records, were shown in the
6
+ -- control centre, and reached nothing: a task could complete with passing
7
+ -- evidence that had nothing to do with the plan the owner accepted.
8
+ --
9
+ -- The missing piece was small and structural. Evidence knew the task it proved
10
+ -- and the command that produced it, and had no way to say which agreed
11
+ -- verification strategy it was a run of. So a plan could never be satisfied,
12
+ -- and a gate that can never be satisfied is one nobody can write.
13
+
14
+ ALTER TABLE verification_evidence
15
+ ADD COLUMN test_plan_id TEXT REFERENCES test_plans(id) ON DELETE SET NULL;
16
+
17
+ -- Asked on every completion, once per plan in scope, so it is worth an index
18
+ -- rather than a scan of every result ever recorded.
19
+ CREATE INDEX idx_verification_evidence_test_plan
20
+ ON verification_evidence(test_plan_id) WHERE test_plan_id IS NOT NULL;
@@ -0,0 +1,13 @@
1
+ -- Why a criterion was set aside.
2
+ --
3
+ -- The status vocabulary has allowed 'waived' since the first migration, two
4
+ -- refusals tell the reader to waive a criterion, and nothing could record one:
5
+ -- there was no command, and no column for the reason if there had been. A
6
+ -- status nothing can reach is the same as no status at all, except that it
7
+ -- makes the refusal a dead end.
8
+ --
9
+ -- The reason is the whole point. A waived criterion without one reads exactly
10
+ -- like a criterion somebody quietly stopped trying to meet, which is what
11
+ -- evidence gating exists to prevent.
12
+
13
+ ALTER TABLE feature_acceptance_criteria ADD COLUMN waiver_reason TEXT;
@@ -0,0 +1,56 @@
1
+ -- What synchronization needs to be real rather than described.
2
+ --
3
+ -- sync_peers and sync_conflicts have existed since the first migration and
4
+ -- nothing ever wrote to them: the commands refused, naming three decisions in
5
+ -- section 23 that were open. Those decisions are now recorded, so this adds the
6
+ -- three things the protocol needs that the schema could not express.
7
+ --
8
+ -- A base snapshot, so a conflict can be detected rather than guessed. Knowing
9
+ -- that two copies differ is not enough to know who changed what: without the
10
+ -- version both sides agreed on last time, every difference looks like a
11
+ -- conflict and a copy that only pulled would be told it had diverged.
12
+ --
13
+ -- A lease, so an assignment can be held across machines. The partial unique
14
+ -- index already makes two active assignments impossible in one database, and
15
+ -- says nothing about a second machine. A lease names its holder and expires, so
16
+ -- a crashed machine releases its claim without anyone intervening.
17
+ --
18
+ -- Where a peer is and how it is reached, because a peer nothing can locate is a
19
+ -- row rather than a connection.
20
+
21
+ -- What both sides agreed on at the end of the last successful sync. The middle
22
+ -- term of a three-way comparison, and the only thing that distinguishes "they
23
+ -- changed it" from "we both changed it".
24
+ CREATE TABLE sync_base (
25
+ id TEXT PRIMARY KEY,
26
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
27
+ peer_id TEXT NOT NULL REFERENCES sync_peers(id) ON DELETE CASCADE,
28
+ record_type TEXT NOT NULL,
29
+ record_id TEXT NOT NULL,
30
+ -- The version both sides held, and a hash of the row as it stood. The hash
31
+ -- catches a change to a table with no version column, which several have.
32
+ base_version INTEGER,
33
+ base_hash TEXT NOT NULL,
34
+ synced_at TEXT NOT NULL,
35
+ UNIQUE (peer_id, record_type, record_id)
36
+ );
37
+ CREATE INDEX sync_base_peer ON sync_base(peer_id, record_type);
38
+
39
+ -- How to reach a peer, and which key opens what it holds.
40
+ ALTER TABLE sync_peers ADD COLUMN transport TEXT NOT NULL DEFAULT 'directory';
41
+ ALTER TABLE sync_peers ADD COLUMN location TEXT;
42
+ ALTER TABLE sync_peers ADD COLUMN alias TEXT;
43
+ ALTER TABLE sync_peers ADD COLUMN key_fingerprint TEXT;
44
+ ALTER TABLE sync_peers ADD COLUMN connected_at TEXT;
45
+
46
+ -- An assignment that can be held from another machine.
47
+ --
48
+ -- The holder is an alias rather than a person: section 18 forbids disclosing a
49
+ -- developer's identity across the boundary, and refusing a second claim only
50
+ -- needs a name the other side recognises, not who that name belongs to.
51
+ ALTER TABLE task_assignments ADD COLUMN lease_holder TEXT;
52
+ ALTER TABLE task_assignments ADD COLUMN lease_expires_at TEXT;
53
+ ALTER TABLE task_assignments ADD COLUMN origin_peer TEXT;
54
+
55
+ CREATE INDEX task_assignments_lease ON task_assignments(lease_expires_at)
56
+ WHERE lease_expires_at IS NOT NULL;
@@ -0,0 +1,327 @@
1
+ // Repository layer. Every mutation goes through here so that four things are
2
+ // always true and never depend on a caller remembering: content is screened,
3
+ // history is appended, an activity event is recorded, and the record's version
4
+ // moves. Callers get rows, not SQL.
5
+
6
+ import { createHash } from "node:crypto";
7
+ import { join } from "node:path";
8
+ import { read, write, DbError, E as DBE } from "./connect.mjs";
9
+ import { migrate } from "./migrate.mjs";
10
+ import { nextId, tableFor, PREFIX } from "../model/ids.mjs";
11
+ import { assertRecordStorable } from "../model/screening.mjs";
12
+
13
+ export { DbError, DBE };
14
+
15
+ /** Everything Superdev writes lives under one git-ignored directory. */
16
+ export function paths(root) {
17
+ const dir = join(root, ".superdev");
18
+ return {
19
+ dir,
20
+ db: join(dir, "superdev.db"),
21
+ backups: join(dir, "backups"),
22
+ runtime: join(dir, "runtime"),
23
+ exports: join(dir, "exports"),
24
+ };
25
+ }
26
+
27
+ export async function ensureDatabase(root, { apply = true } = {}) {
28
+ const p = paths(root);
29
+ return migrate(p.db, { apply });
30
+ }
31
+
32
+ /** Read helper bound to a project root. Opens and closes; never pooled. */
33
+ export const query = (root, fn) => read(paths(root).db, fn);
34
+
35
+ /** Write helper bound to a project root. One transaction, then closed. */
36
+ export const mutate = (root, fn) => write(paths(root).db, fn);
37
+
38
+ // ------------------------------------------------------------------ project
39
+
40
+ export async function currentProject(db) {
41
+ return db.get("SELECT * FROM projects ORDER BY created_at LIMIT 1");
42
+ }
43
+
44
+ // ----------------------------------------------------------------- activity
45
+
46
+ const nowIso = () => new Date().toISOString();
47
+
48
+ /**
49
+ * Append an activity event. The sequence is allocated inside the caller's
50
+ * transaction, so it is gapless and ordered even with several writers, and the
51
+ * hash chains each event to the one before it.
52
+ */
53
+ export async function recordActivity(db, projectId, event) {
54
+ const prev = await db.get(
55
+ "SELECT sequence, immutable_hash FROM activity_events WHERE project_id = ? ORDER BY sequence DESC LIMIT 1",
56
+ projectId,
57
+ );
58
+ const sequence = (prev?.sequence ?? 0) + 1;
59
+ const id = await nextId(db, "activity_event");
60
+ const created_at = event.createdAt ?? nowIso();
61
+ const payload = {
62
+ id,
63
+ project_id: projectId,
64
+ session_id: event.sessionId ?? null,
65
+ actor_id: event.actorId ?? null,
66
+ actor_label: event.actor ?? "superdev",
67
+ task_id: event.taskId ?? null,
68
+ feature_id: event.featureId ?? null,
69
+ event_type: event.type,
70
+ summary: event.summary,
71
+ metadata_json: JSON.stringify(event.metadata ?? {}),
72
+ created_at,
73
+ sequence,
74
+ };
75
+ assertRecordStorable(payload);
76
+ payload.immutable_hash = createHash("sha256")
77
+ .update(String(prev?.immutable_hash ?? ""))
78
+ .update(`${sequence}\u0000${payload.event_type}\u0000${payload.summary}\u0000${created_at}`)
79
+ .digest("hex");
80
+
81
+ const cols = Object.keys(payload);
82
+ await db.run(
83
+ `INSERT INTO activity_events (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})`,
84
+ ...cols.map((c) => payload[c]),
85
+ );
86
+ return { id, sequence };
87
+ }
88
+
89
+ /** Append a status transition. Never revised, only added to. */
90
+ export async function recordStatusChange(db, projectId, recordType, recordId, from, to, opts = {}) {
91
+ const prev = await db.get(
92
+ "SELECT sequence FROM status_history WHERE record_type = ? AND record_id = ? ORDER BY sequence DESC LIMIT 1",
93
+ recordType, recordId,
94
+ );
95
+ const id = await nextId(db, "status_history");
96
+ await db.run(
97
+ `INSERT INTO status_history
98
+ (id, project_id, record_type, record_id, from_status, to_status, actor_label, note, created_at, sequence)
99
+ VALUES (?,?,?,?,?,?,?,?,?,?)`,
100
+ id, projectId, recordType, recordId, from, to,
101
+ opts.actor ?? "superdev", opts.note ?? null, opts.at ?? nowIso(), (prev?.sequence ?? 0) + 1,
102
+ );
103
+ return id;
104
+ }
105
+
106
+ // ------------------------------------------------------------------- writes
107
+
108
+ const AUTO = new Set(["created_at", "updated_at", "version"]);
109
+
110
+ /**
111
+ * Insert a record of `kind`. Mints the id, screens every field, stamps
112
+ * timestamps, and records the creation as activity when the table has a
113
+ * project.
114
+ */
115
+ export async function create(db, kind, values, opts = {}) {
116
+ const table = tableFor(kind);
117
+ if (!table) throw new Error(`unknown record kind: ${kind}`);
118
+ const columns = await columnsOf(db, table);
119
+ const at = opts.at ?? nowIso();
120
+
121
+ const row = { ...values };
122
+ if (!row.id) row.id = await nextId(db, kind);
123
+ if (columns.has("created_at") && !row.created_at) row.created_at = at;
124
+ if (columns.has("updated_at") && !row.updated_at) row.updated_at = at;
125
+
126
+ // A key that is not a column is a typo, and deleting it quietly is how a
127
+ // field silently keeps its default while the caller believes it was written.
128
+ const unknown = Object.keys(row).filter((key) => !columns.has(key));
129
+ if (unknown.length) {
130
+ throw new DbError(
131
+ "E_UNKNOWN_COLUMN",
132
+ `${table} has no column named ${unknown.join(", ")}. Check the spelling against the migration that defines the table.`,
133
+ { table, unknown },
134
+ );
135
+ }
136
+ assertRecordStorable(row);
137
+
138
+ const cols = Object.keys(row);
139
+ await db.run(
140
+ `INSERT INTO ${table} (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})`,
141
+ ...cols.map((c) => row[c]),
142
+ );
143
+
144
+ if (opts.projectId && opts.activity !== false) {
145
+ await recordActivity(db, opts.projectId, {
146
+ type: opts.activityType ?? "specification_changed",
147
+ summary: opts.activitySummary ?? `Created ${kind.replace(/_/g, " ")} ${row.id}`,
148
+ actor: opts.actor,
149
+ sessionId: opts.sessionId,
150
+ featureId: row.feature_id ?? opts.featureId ?? null,
151
+ taskId: kind === "task" ? row.id : opts.taskId ?? null,
152
+ metadata: { kind, id: row.id },
153
+ });
154
+ }
155
+ return db.get(`SELECT * FROM ${table} WHERE id = ?`, row.id);
156
+ }
157
+
158
+ /**
159
+ * Update a record under its optimistic version. A zero-row result means someone
160
+ * else moved it, which surfaces as a conflict rather than a silent no-op.
161
+ */
162
+ export async function patch(db, kind, id, expectedVersion, values, opts = {}) {
163
+ const table = tableFor(kind);
164
+ const columns = await columnsOf(db, table);
165
+ const row = {};
166
+ const unknown = [];
167
+ for (const [k, v] of Object.entries(values)) {
168
+ if (AUTO.has(k)) continue;
169
+ if (!columns.has(k)) { unknown.push(k); continue; }
170
+ row[k] = v;
171
+ }
172
+ if (unknown.length) {
173
+ throw new DbError(
174
+ "E_UNKNOWN_COLUMN",
175
+ `${table} has no column named ${unknown.join(", ")}. Check the spelling against the migration that defines the table.`,
176
+ { table, unknown },
177
+ );
178
+ }
179
+ if (!Object.keys(row).length) return db.get(`SELECT * FROM ${table} WHERE id = ?`, id);
180
+ assertRecordStorable(row);
181
+ if (columns.has("updated_at")) row.updated_at = opts.at ?? nowIso();
182
+
183
+ await db.versionedUpdate(table, id, expectedVersion, row);
184
+
185
+ if (opts.projectId && opts.activity !== false) {
186
+ await recordActivity(db, opts.projectId, {
187
+ type: opts.activityType ?? "specification_changed",
188
+ summary: opts.activitySummary ?? `Updated ${kind.replace(/_/g, " ")} ${id}`,
189
+ actor: opts.actor,
190
+ sessionId: opts.sessionId,
191
+ metadata: { kind, id, fields: Object.keys(row).filter((k) => k !== "updated_at") },
192
+ });
193
+ }
194
+ return db.get(`SELECT * FROM ${table} WHERE id = ?`, id);
195
+ }
196
+
197
+ /**
198
+ * Move a record's status, appending history and activity. Status is never
199
+ * changed through `patch`, so no transition can happen without a record of it.
200
+ */
201
+ export async function setStatus(db, kind, id, toStatus, opts = {}) {
202
+ const table = tableFor(kind);
203
+ const before = await db.get(`SELECT * FROM ${table} WHERE id = ?`, id);
204
+ if (!before) throw new DbError("E_NOT_FOUND", `${kind} ${id} does not exist.`);
205
+ if (before.status === toStatus) return before;
206
+
207
+ const assignments = { status: toStatus };
208
+ const columns = await columnsOf(db, table);
209
+ const at = opts.at ?? nowIso();
210
+ if (columns.has("updated_at")) assignments.updated_at = at;
211
+
212
+ // When a table keeps a column for the moment a status was reached, fill it.
213
+ // This used to run only for tasks, so a feature could be accepted and leave
214
+ // accepted_at null, and every reading built on that column counted zero: the
215
+ // status report headline read "0 features accepted" while 35 were.
216
+ const STAMP = {
217
+ accepted: "accepted_at",
218
+ complete: "completed_at",
219
+ completed: "completed_at",
220
+ cancelled: "cancelled_at",
221
+ in_progress: "started_at",
222
+ };
223
+ const stamp = STAMP[toStatus];
224
+ // started_at records the first start, so a task returning to in_progress
225
+ // keeps the original. Every other stamp records the latest transition.
226
+ if (stamp && columns.has(stamp) && !(stamp === "started_at" && before[stamp])) {
227
+ assignments[stamp] = at;
228
+ }
229
+
230
+ if (kind === "task") {
231
+ if (toStatus === "blocked") assignments.block_reason = opts.reason ?? before.block_reason ?? null;
232
+ if (before.status === "blocked" && toStatus !== "blocked") assignments.block_reason = null;
233
+ }
234
+
235
+ await db.versionedUpdate(table, id, opts.expectedVersion ?? before.version, assignments);
236
+ await recordStatusChange(db, opts.projectId ?? before.project_id, kind, id, before.status, toStatus, opts);
237
+
238
+ const projectId = opts.projectId ?? before.project_id ?? null;
239
+ if (projectId && opts.activity !== false) {
240
+ await recordActivity(db, projectId, {
241
+ type: opts.activityType ?? statusEventType(kind, toStatus),
242
+ summary: opts.activitySummary ?? `${labelFor(kind)} ${id} moved to ${toStatus.replace(/_/g, " ")}`,
243
+ actor: opts.actor,
244
+ sessionId: opts.sessionId,
245
+ taskId: kind === "task" ? id : null,
246
+ featureId: kind === "feature" ? id : before.feature_id ?? null,
247
+ metadata: { kind, id, from: before.status, to: toStatus, note: opts.note ?? null },
248
+ });
249
+ }
250
+ return db.get(`SELECT * FROM ${table} WHERE id = ?`, id);
251
+ }
252
+
253
+ function statusEventType(kind, to) {
254
+ if (kind !== "task") return "specification_changed";
255
+ return {
256
+ in_progress: "task_started",
257
+ blocked: "task_blocked",
258
+ complete: "task_completed",
259
+ cancelled: "task_cancelled",
260
+ ready: "task_updated",
261
+ }[to] ?? "task_updated";
262
+ }
263
+
264
+ const labelFor = (kind) => kind.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
265
+
266
+ // ------------------------------------------------------------------ helpers
267
+
268
+ const columnCache = new Map();
269
+ async function columnsOf(db, table) {
270
+ // Keyed by database as well as table: two roots at different schema versions
271
+ // in one process would otherwise share a column set.
272
+ const key = `${db.file ?? ""}\u0000${table}`;
273
+ if (columnCache.has(key)) return columnCache.get(key);
274
+ const info = await db.all(`PRAGMA table_info(${table})`);
275
+ const set = new Set(info.map((c) => c.name));
276
+ columnCache.set(key, set);
277
+ return set;
278
+ }
279
+
280
+
281
+ export const json = (value, fallback = []) => {
282
+ if (value === null || value === undefined || value === "") return fallback;
283
+ try {
284
+ return JSON.parse(value);
285
+ } catch {
286
+ return fallback;
287
+ }
288
+ };
289
+
290
+ /**
291
+ * Walk a parent chain iteratively. This engine has no recursive CTEs, and task
292
+ * and module trees are shallow enough that a few sub-millisecond reads beat any
293
+ * materialized path that could fall out of step.
294
+ */
295
+ export async function ancestors(db, table, id, parentColumn = "parent_task_id", limit = 32) {
296
+ const chain = [];
297
+ let current = id;
298
+ for (let i = 0; i < limit && current; i++) {
299
+ const row = await db.get(`SELECT * FROM ${table} WHERE id = ?`, current);
300
+ if (!row) break;
301
+ chain.push(row);
302
+ current = row[parentColumn];
303
+ }
304
+ return chain;
305
+ }
306
+
307
+ /** Collect a whole subtree, breadth first, without recursion in SQL. */
308
+ export async function descendants(db, table, id, parentColumn = "parent_task_id", limit = 5000) {
309
+ const out = [];
310
+ let frontier = [id];
311
+ const seen = new Set(frontier);
312
+ while (frontier.length && out.length < limit) {
313
+ const placeholders = frontier.map(() => "?").join(",");
314
+ const rows = await db.all(`SELECT * FROM ${table} WHERE ${parentColumn} IN (${placeholders})`, ...frontier);
315
+ if (!rows.length) break;
316
+ frontier = [];
317
+ for (const row of rows) {
318
+ if (seen.has(row.id)) continue;
319
+ seen.add(row.id);
320
+ out.push(row);
321
+ frontier.push(row.id);
322
+ }
323
+ }
324
+ return out;
325
+ }
326
+
327
+ export { PREFIX };
@@ -0,0 +1,167 @@
1
+ // Record a decision, and supersede one that no longer holds.
2
+ //
3
+ // The decisions table, its transitions, its supersession chain and the reports
4
+ // built on them all existed. Nothing could write a row. There was no CLI
5
+ // command, no service mutation, and convertDiscovery turns accepted content
6
+ // into goals, modules and features only, so a decision could be transitioned,
7
+ // governed by, reported on and expired, but never made.
8
+ //
9
+ // That is why the twenty decisions in this project's own database were written
10
+ // by a script reading the ADR files rather than by the product.
11
+ //
12
+ // The rules here are the ones the rest of the system already assumes:
13
+ //
14
+ // A decision states what was decided and why in observable terms. The
15
+ // rationale is what a reader can check, not what a model was thinking.
16
+ //
17
+ // Superseding is a link in both directions plus a status move on the old
18
+ // decision, done in one transaction. Half of that leaves the reports
19
+ // disagreeing about what governs.
20
+ //
21
+ // A partial supersession says which part stops applying. Without that nobody
22
+ // can tell what the old decision still governs, which is the same rule
23
+ // transitionDecision already enforces.
24
+
25
+ import { create, mutate, query, setStatus } from "../db/store.mjs";
26
+
27
+ export const E = {
28
+ NOT_FOUND: "E_NOT_FOUND",
29
+ INVALID: "E_INVALID",
30
+ ALREADY_SUPERSEDED: "E_ALREADY_SUPERSEDED",
31
+ };
32
+
33
+ export class DecisionError extends Error {
34
+ constructor(code, message, detail = {}) {
35
+ super(message);
36
+ this.name = "DecisionError";
37
+ this.code = code;
38
+ this.detail = detail;
39
+ }
40
+ }
41
+
42
+ const asList = (value) => {
43
+ if (value === null || value === undefined) return [];
44
+ return (Array.isArray(value) ? value : [value])
45
+ .map((v) => String(v).trim())
46
+ .filter(Boolean);
47
+ };
48
+
49
+ /**
50
+ * Record a decision.
51
+ *
52
+ * A title and the decision itself are required, because a record with neither
53
+ * is a heading nobody can act on. Everything else is optional and defaults to
54
+ * the empty shape the schema already declares.
55
+ */
56
+ export async function recordDecision(root, input = {}) {
57
+ const title = String(input.title ?? "").trim();
58
+ const decision = String(input.decision ?? "").trim();
59
+ if (!title) {
60
+ throw new DecisionError(E.INVALID, "A decision needs a title, in plain language, so it can be found again.");
61
+ }
62
+ if (!decision) {
63
+ throw new DecisionError(E.INVALID,
64
+ "A decision needs to say what was decided. A title alone records that a choice happened, not what it was.");
65
+ }
66
+
67
+ const {
68
+ context = null, rationale = null, verification = null,
69
+ scopeType = null, scopeId = null, status = "proposed",
70
+ acceptedBy = null, expiresAt = null, actor = "superdev", sessionId = null,
71
+ } = input;
72
+
73
+ if (scopeType && !scopeId) {
74
+ throw new DecisionError(E.INVALID, `A decision scoped to a ${scopeType} has to name which one.`);
75
+ }
76
+
77
+ return mutate(root, async (db) => {
78
+ const project = await db.get("SELECT id FROM projects LIMIT 1");
79
+ if (!project) throw new DecisionError(E.NOT_FOUND, "There is no project to record a decision against.");
80
+
81
+ const row = await create(db, "decision", {
82
+ project_id: project.id,
83
+ title,
84
+ decision,
85
+ context,
86
+ observable_rationale: rationale,
87
+ verification,
88
+ scope_type: scopeType,
89
+ scope_id: scopeId,
90
+ status,
91
+ accepted_by: acceptedBy,
92
+ accepted_at: status === "accepted" ? new Date().toISOString() : null,
93
+ expires_at: expiresAt,
94
+ evidence_json: JSON.stringify(asList(input.evidence)),
95
+ criteria_json: JSON.stringify(asList(input.criteria)),
96
+ options_json: JSON.stringify(asList(input.options)),
97
+ risks_json: JSON.stringify(asList(input.risks)),
98
+ enforcement_json: JSON.stringify(asList(input.enforcement)),
99
+ revisit_triggers_json: JSON.stringify(asList(input.revisitTriggers)),
100
+ }, {
101
+ projectId: project.id,
102
+ actor,
103
+ sessionId,
104
+ activityType: "decision_recorded",
105
+ activitySummary: `Decision recorded: ${title}`,
106
+ });
107
+
108
+ // A decision that governs something says so, or the reports that ask what
109
+ // binds a module find nothing and report that nothing does.
110
+ for (const target of asList(input.governs)) {
111
+ const [targetType, targetId] = target.split(":");
112
+ if (!targetType || !targetId) {
113
+ throw new DecisionError(E.INVALID,
114
+ `"${target}" is not a link. Write it as <type>:<id>, for example module:MOD-0001.`);
115
+ }
116
+ await create(db, "decision_link", {
117
+ decision_id: row.id,
118
+ target_type: targetType,
119
+ target_id: targetId,
120
+ relationship: "governs",
121
+ scope_note: "The record this decision binds. Changing it here means superseding the decision, not editing around it.",
122
+ }, { projectId: project.id, actor, sessionId, activityType: "decision_recorded",
123
+ activitySummary: `${row.id} governs ${targetId}` });
124
+ }
125
+
126
+ return row;
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Record a decision that replaces an earlier one.
132
+ *
133
+ * Both directions of the link and the old decision's status move happen in one
134
+ * transaction. Writing the new decision first and the supersession afterwards
135
+ * leaves a window where two decisions both claim to govern the same thing, and
136
+ * whichever report runs in that window is wrong.
137
+ */
138
+ export async function supersedeDecision(root, supersededId, input = {}) {
139
+ const scopeDelta = String(input.scopeDelta ?? "").trim();
140
+ const partial = Boolean(input.partial);
141
+ if (partial && !scopeDelta) {
142
+ throw new DecisionError(E.INVALID,
143
+ "A partial supersession has to say which part stops applying, otherwise nobody can tell what still governs.");
144
+ }
145
+
146
+ const existing = await query(root, (db) => db.get("SELECT * FROM decisions WHERE id = ?", supersededId));
147
+ if (!existing) throw new DecisionError(E.NOT_FOUND, `There is no decision ${supersededId}.`);
148
+ if (existing.superseded_by_id) {
149
+ throw new DecisionError(E.ALREADY_SUPERSEDED,
150
+ `${supersededId} was already superseded by ${existing.superseded_by_id}. Supersede that one instead, so the chain stays a chain.`);
151
+ }
152
+
153
+ const replacement = await recordDecision(root, {
154
+ ...input,
155
+ status: input.status ?? "accepted",
156
+ });
157
+
158
+ return mutate(root, async (db) => {
159
+ await db.run("UPDATE decisions SET supersedes_id = ? WHERE id = ?", supersededId, replacement.id);
160
+ await db.run("UPDATE decisions SET superseded_by_id = ? WHERE id = ?", replacement.id, supersededId);
161
+ await setStatus(db, "decision", supersededId, partial ? "partially_superseded" : "superseded", {
162
+ actor: input.actor ?? "superdev",
163
+ reason: scopeDelta || `Superseded by ${replacement.id}.`,
164
+ });
165
+ return { ...replacement, supersedes: supersededId, partial };
166
+ });
167
+ }