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,1021 @@
1
+ #!/usr/bin/env node
2
+ // Agent lifecycle hooks. Brief section 12.
3
+ //
4
+ // One entry point for every harness event. It is importable as a module and
5
+ // runnable as a script:
6
+ //
7
+ // node src/runtime/hooks.mjs session-start < payload.json > response.json
8
+ //
9
+ // Three properties hold for every path through this file, because a hook is the
10
+ // one piece of Superdev that runs whether or not anyone wanted it to:
11
+ //
12
+ // - IT NEVER BREAKS THE SESSION. Every failure produces valid JSON and exit 0.
13
+ // A hook that could not build context must never be the reason a session
14
+ // stops working.
15
+ // - IT IS BOUNDED. Work is raced against a 3500 ms budget and the response is
16
+ // written with a synchronous write before the process exits, so a slow
17
+ // database or a slow git can never hold the harness open.
18
+ // - IT NEVER INSTALLS, MIGRATES OR BACKS UP. All three are unbounded and belong
19
+ // to explicit commands. On schema drift this says to run superdev db migrate;
20
+ // it does not migrate.
21
+ //
22
+ // Secrets never reach the output: everything user facing goes through
23
+ // sanitizeExternal, which strips secret-shaped values and home paths.
24
+
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, writeSync, realpathSync } from "node:fs";
26
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
27
+ import { paths, query } from "../db/store.mjs";
28
+ import { availableMigrations } from "../db/migrate.mjs";
29
+ import { sanitizeExternal } from "../model/screening.mjs";
30
+ import { detectHarness, gitState } from "./identity.mjs";
31
+ import { harnessMatrix } from "./harness.mjs";
32
+ import { activeSession, compactSession, endSession, startSession, touchSession } from "./session.mjs";
33
+ import { resumeContext, sessionStartReport } from "./resume.mjs";
34
+
35
+ /** Self-imposed deadline. The harness timeout is stricter than the work is. */
36
+ const BUDGET_MS = 3500;
37
+
38
+ /** A payload larger than this is not a hook payload worth parsing. */
39
+ const MAX_STDIN_BYTES = 512 * 1024;
40
+
41
+ /** Tools whose use means the working tree may have moved away from the records. */
42
+ const MUTATING_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
43
+
44
+ /**
45
+ * A shell command that plausibly wrote to a file.
46
+ *
47
+ * The file-editing tools were the only ones watched, and an agent that edits
48
+ * through the shell was invisible: a session that rewrote a dozen source files
49
+ * with sed, a heredoc and a python one-liner left no marker at all, because
50
+ * none of it went through Write or Edit. Whether the record falls behind cannot
51
+ * depend on which tool an agent happens to prefer.
52
+ *
53
+ * This only decides whether to look. What actually changed is then read from
54
+ * git, which is the authority and cannot be fooled by a command that looks like
55
+ * a write and is not.
56
+ */
57
+ const SHELL_MIGHT_WRITE = /(^|[\s;&|])(sed\s+-i|tee|cp|mv|rm|install|touch|mkdir|patch|dd)\s|>>?\s*\S|<<\s*['"]?[A-Z]|\b(python3?|node|ruby|perl|awk)\b[^|]*(-e|-c|<<)|\bgit\s+(apply|checkout|restore|revert|stash|merge|rebase|reset)\b/;
58
+
59
+ /** What git says actually changed, which is the only reliable answer. */
60
+ async function changedFiles(root) {
61
+ const { execFile } = await import("node:child_process");
62
+ const { promisify } = await import("node:util");
63
+ try {
64
+ const { stdout } = await promisify(execFile)("git", ["status", "--porcelain"], {
65
+ cwd: root, timeout: 4000, maxBuffer: 4 * 1024 * 1024,
66
+ });
67
+ return stdout.split("\n").map((line) => line.slice(3).trim()).filter(Boolean).slice(0, MAX_TOUCHED);
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
72
+
73
+ /**
74
+ * At most one activity event per this interval, and one per batch boundary.
75
+ * Brief 12.3: no event per token, file read or shell command. Twenty edits in
76
+ * five minutes are one piece of work to a human reading the timeline.
77
+ */
78
+ const ACTIVITY_INTERVAL_MS = 5 * 60 * 1000;
79
+
80
+ /** Paths kept in the touch marker. Enough to describe the work, not a log. */
81
+ const MAX_TOUCHED = 40;
82
+
83
+ /** A provider readiness report older than this is stale and worth mentioning. */
84
+ const PROVIDER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
85
+
86
+ /** Records carried into a compaction packet, per list. See preparePacket. */
87
+ const PACKET_LIMIT = 3;
88
+
89
+ const EMPTY = Object.freeze({});
90
+
91
+ // --------------------------------------------------------------- entry point
92
+
93
+ /**
94
+ * Handle one lifecycle event and return the harness response.
95
+ *
96
+ * `root` is resolved by the caller so the same function serves the script, the
97
+ * CLI and a future in-process harness without each guessing at a directory.
98
+ */
99
+ export async function handleHook(event, payload = {}, { root = null } = {}) {
100
+ if (!root) return EMPTY;
101
+
102
+ switch (event) {
103
+ case "session-start":
104
+ return sessionStart(root, payload);
105
+ case "user-prompt-submit":
106
+ return userPromptSubmit(root, payload);
107
+ case "pre-tool-use":
108
+ // Deliberately empty. Superdev is a development orchestrator, not a
109
+ // security sandbox: destructive and outward-facing actions are governed
110
+ // by the harness permission model, never by a command parser here. With
111
+ // nothing to decide, launching a process before every tool call would be
112
+ // pure latency, so hooks.json does not wire this event either.
113
+ return EMPTY;
114
+ case "post-tool-use":
115
+ return postToolUse(root, payload);
116
+ case "post-tool-batch":
117
+ return postToolBatch(root, payload);
118
+ case "pre-compact":
119
+ return preCompact(root);
120
+ case "session-end":
121
+ return sessionEnd(root);
122
+ default:
123
+ return EMPTY;
124
+ }
125
+ }
126
+
127
+ // -------------------------------------------------------------- session start
128
+
129
+ /**
130
+ * Whether the installed CLI is old enough to break these skills.
131
+ *
132
+ * The plugin declares the minimum it needs. Nothing is enforced: an agent told
133
+ * its tools are mismatched can decide what to do, and refusing to start a
134
+ * session over a version number would be worse than the problem.
135
+ */
136
+ async function versionSkew() {
137
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
138
+ if (!pluginRoot) return null;
139
+ try {
140
+ const { self, isNewer } = await import("./version.mjs");
141
+ const manifest = JSON.parse(readFileSync(join(pluginRoot, ".claude-plugin", "plugin.json"), "utf8"));
142
+ const needed = manifest?.requires?.cli;
143
+ if (!needed) return null;
144
+ const me = self();
145
+ if (me.version === "unknown" || !isNewer(needed, me.version)) return null;
146
+ return `Superdev: these skills need CLI ${needed} or newer and ${me.version} is installed, so some commands they name will not exist. Update with npm install -g ${me.name}.`;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ async function sessionStart(root, payload) {
153
+ const lines = [];
154
+
155
+ // Before anything about the project, whether the two halves agree.
156
+ //
157
+ // The skills and the CLI ship separately now: the skills come from the plugin
158
+ // and the CLI from npm. A skill that tells an agent to run a command a newer
159
+ // CLI introduced gets "there is no such command" on an older one, and the
160
+ // agent has no way to work out why. Saying it here costs nothing and turns a
161
+ // confusing refusal into a sentence naming the fix.
162
+ const skew = await versionSkew();
163
+ if (skew) lines.push(skew);
164
+
165
+ const schema = await schemaState(root);
166
+
167
+ if (!schema.ok) {
168
+ // A database that is missing, unreadable or behind is reported and left
169
+ // alone. Migrating from a hook would run an unbounded schema change with a
170
+ // backup inside a five second window, against a database another process
171
+ // may be holding.
172
+ lines.push(`Superdev: ${schema.message}`);
173
+ lines.push(`Do this first: ${schema.remedy}`);
174
+ return context("SessionStart", lines.join("\n"));
175
+ }
176
+
177
+ // Registers developer, agent, branch and worktree, rejoins a live session
178
+ // rather than opening a second one, and reports any assignment this actor
179
+ // already holds. Best effort: a session that cannot be opened is worth
180
+ // reporting, never worth failing the hook over.
181
+ let session = null;
182
+ try {
183
+ session = await startSession(root, {
184
+ env: payload?.env,
185
+ sessionExternalId: payload?.session_id ?? null,
186
+ modelLabel: payload?.model?.display_name ?? payload?.model ?? null,
187
+ });
188
+ } catch (err) {
189
+ lines.push(`Superdev: session not recorded (${message(err)}). Run superdev resume to record it.`);
190
+ }
191
+
192
+ const report = await sessionStartReport(root, { env: payload?.env });
193
+ if (report) lines.push(report);
194
+
195
+ if (session?.assignment) {
196
+ lines.push(
197
+ `Assignment already held: ${session.assignment.task_id} ${session.assignment.task_name} (${session.assignment.task_status}). It stays claimed; there is no need to claim it again.`,
198
+ );
199
+ }
200
+
201
+ const providers = providerReadiness(root);
202
+ if (providers) lines.push(providers);
203
+
204
+ const gaps = coverageNote(payload?.env);
205
+ if (gaps) lines.push(gaps);
206
+
207
+ return context("SessionStart", lines.join("\n"));
208
+ }
209
+
210
+ /**
211
+ * Which lifecycle points this harness cannot drive from a hook.
212
+ *
213
+ * Stated at session start rather than discovered later, because an agent that
214
+ * assumes a hook is tracking its work will not run the command that actually
215
+ * records it. Nothing is printed for a harness with full hook coverage.
216
+ */
217
+ function coverageNote(env) {
218
+ const detected = detectHarness(env);
219
+ const matrix = harnessMatrix();
220
+ const known = matrix.harnesses.find((h) => h.harness === detected.harness);
221
+ if (!known) return null;
222
+
223
+ const uncovered = matrix.lifecycle.filter(
224
+ (l) => l.coverage[detected.harness]?.state === "command",
225
+ );
226
+ if (!uncovered.length) return null;
227
+
228
+ const points = uncovered.slice(0, 4).map((l) => `${l.point} (${l.fallback.command})`);
229
+ const rest = uncovered.length - points.length;
230
+ return [
231
+ `Not driven by a hook on ${detected.harness}: ${points.join("; ")}${rest > 0 ? `; and ${rest} more` : ""}.`,
232
+ "Run the command. Superdev never treats a hook as proof that something happened.",
233
+ ].join("\n");
234
+ }
235
+
236
+ // -------------------------------------------------------------- prompt submit
237
+
238
+ /**
239
+ * The 12.2 gate, stated only when it is not already satisfied.
240
+ *
241
+ * A reminder printed before every prompt stops being read, so this stays silent
242
+ * once a task is claimed and in progress. When nothing is claimed, the agent is
243
+ * about to change the product without a tracked unit of work, which is the one
244
+ * case worth interrupting.
245
+ */
246
+ /**
247
+ * Does this prompt ask for work that changes the product?
248
+ *
249
+ * Deliberately generous about what counts as changing the product and strict
250
+ * about what counts as a question, because the cost of the two mistakes is not
251
+ * symmetric: a missed warning lets untracked work happen, and a false warning
252
+ * on "what is the status" is the noise that makes every warning ignorable.
253
+ */
254
+ function asksForProductWork(prompt) {
255
+ const text = String(prompt).toLowerCase();
256
+ // A question about state is not a request to change it.
257
+ const ASKS = /^\s*(what|which|where|when|who|why|how|is|are|does|do|can|could|show|list|tell|explain|describe)\b/;
258
+ const CHANGES = /\b(add|build|create|implement|write|fix|change|update|remove|delete|refactor|rename|migrate|wire|expose|support|make it|set up|generate|install|upgrade|improve|redesign)\b/;
259
+ if (CHANGES.test(text)) return true;
260
+ if (ASKS.test(text)) return false;
261
+ // Anything else is treated as work. An unrecognised instruction is more
262
+ // likely to be a change than a question.
263
+ return true;
264
+ }
265
+
266
+ /**
267
+ * Does this prompt ask for something an accepted decision already settled?
268
+ *
269
+ * Section 13.2 requires detecting requests that contradict accepted decisions
270
+ * and asking for confirmation before overriding one. Section 22 makes it
271
+ * acceptance criterion 11. Nothing implemented it, so an agent could quietly
272
+ * undo a decision the owner made and nobody would know until the record
273
+ * disagreed with the code.
274
+ *
275
+ * The match is lexical and deliberately conservative. A decision is surfaced
276
+ * when the prompt names the thing the decision is about, and the agent is asked
277
+ * to check rather than told it is wrong: a hook cannot know whether a request
278
+ * genuinely conflicts, only that a decision exists in the same territory, and
279
+ * overstating that would make the warning ignorable.
280
+ */
281
+ async function decisionsInTheWay(root, prompt) {
282
+ const text = String(prompt ?? "").toLowerCase();
283
+ if (text.length < 12) return [];
284
+ const { query } = await import("../db/store.mjs");
285
+ const decisions = await query(root, (db) => db.all(
286
+ `SELECT id, title, decision FROM decisions
287
+ WHERE status IN ('accepted', 'time_boxed') ORDER BY id`)).catch(() => []);
288
+
289
+ const STOP = new Set(["the", "and", "for", "with", "that", "this", "from", "into", "should",
290
+ "must", "will", "when", "then", "than", "there", "which", "what", "make", "use", "using",
291
+ "superdev", "project", "record", "records", "every", "each", "through", "rather", "never",
292
+ "always", "would", "could", "before", "after", "about", "their", "these", "those", "them"]);
293
+ const words = (t) => new Set(String(t).toLowerCase().match(/[a-z][a-z-]{4,}/g)?.filter((w) => !STOP.has(w)) ?? []);
294
+ const asked = words(text);
295
+ if (asked.size === 0) return [];
296
+
297
+ const hits = [];
298
+ for (const d of decisions) {
299
+ const subject = words(`${d.title} ${d.decision ?? ""}`);
300
+ if (subject.size === 0) continue;
301
+ const shared = [...subject].filter((w) => asked.has(w));
302
+ // Two shared subject words is the floor. One is a coincidence in any
303
+ // reasonably sized vocabulary; the whole point is to stay quiet unless the
304
+ // prompt is plausibly in the same territory.
305
+ if (shared.length >= 2) hits.push({ ...d, shared });
306
+ }
307
+ return hits.sort((a, b) => b.shared.length - a.shared.length).slice(0, 3);
308
+ }
309
+
310
+ async function userPromptSubmit(root, payload) {
311
+ if (!existsSync(paths(root).db)) return EMPTY;
312
+
313
+ const session = await activeSession(root, {}).catch(() => null);
314
+ if (session?.activeTaskId && session.activeTaskStatus === "in_progress") return EMPTY;
315
+
316
+ // Only warn when the prompt asks for work that changes the product. Section
317
+ // 13.2 says to avoid blocking harmless conversational questions, and a
318
+ // warning that fires on every prompt is noise: it trains the reader to skip
319
+ // it, which is exactly how untracked work gets built while the hook is
320
+ // firing correctly on every turn.
321
+ const prompt = String(payload?.prompt ?? "");
322
+ const changesProduct = !prompt || asksForProductWork(prompt);
323
+
324
+ // A decision already in force on the same subject is worth raising even when
325
+ // the prompt reads as a question, because reading and then acting is the
326
+ // common path and the warning is cheap once the subject matches.
327
+ const governing = await decisionsInTheWay(root, prompt).catch(() => []);
328
+ if (!changesProduct && governing.length === 0) return EMPTY;
329
+
330
+ const lines = [];
331
+ if (governing.length > 0) {
332
+ lines.push(`Superdev: ${governing.length === 1 ? "a decision is" : `${governing.length} decisions are`} already in force on this subject.`);
333
+ for (const d of governing) {
334
+ lines.push(` ${d.id} ${d.title}: ${String(d.decision ?? "").slice(0, 160)}`);
335
+ }
336
+ lines.push("Check whether this request agrees with them. To change one, supersede it with superdev decision supersede, which keeps the chain, rather than editing around it.");
337
+ }
338
+ if (!changesProduct) {
339
+ return context("UserPromptSubmit", sanitizeExternal(lines.join("\n")), { suppress: true });
340
+ }
341
+ if (session?.activeTaskId) {
342
+ lines.push(
343
+ `Superdev: ${session.activeTaskId} ${session.activeTaskName ?? ""} is ${session.activeTaskStatus ?? "not started"}. Move it to in progress before changing the product.`.replace(/\s+/g, " "),
344
+ );
345
+ } else {
346
+ lines.push("Superdev: no task is claimed in this session.");
347
+ // What has already been built without one. A general reminder is easy to
348
+ // read past; a count of changes the record cannot explain is not, and it
349
+ // is the difference between a rule and a consequence.
350
+ const behind = await untrackedSince(root).catch(() => 0);
351
+ if (behind > 0) {
352
+ lines.push(
353
+ `${behind} ${behind === 1 ? "change has" : "changes have"} already been made with no task claimed, so that work is attached to no feature and counts toward nothing. Cover it with a task before adding more.`,
354
+ );
355
+ }
356
+ lines.push(
357
+ "Before product-changing work: find or create the task, link it to a feature and a workflow step or contract, check the decisions in force, claim it, then move it to in progress. Run superdev resume to see what is already open.",
358
+ );
359
+ }
360
+ return context("UserPromptSubmit", sanitizeExternal(lines.join("\n")), { suppress: true });
361
+ }
362
+
363
+ // ------------------------------------------------------------------ tool use
364
+
365
+ /**
366
+ * Mark the work touched, and flush at most one activity event.
367
+ *
368
+ * The marker is a file rather than a database write because this fires after
369
+ * every edit: a write per edit would cost a lock acquisition per keystroke-sized
370
+ * change and bury the timeline. The flush is rate limited, so a burst of twenty
371
+ * edits produces one event that says what changed.
372
+ */
373
+ /**
374
+ * Mark generated documentation as possibly behind when its source changes.
375
+ *
376
+ * Section 13.3 requires it, and nothing did it, so a document could describe a
377
+ * schema that had moved underneath it and doctor would still call the two in
378
+ * step. Only source under src/ counts: editing the generated Markdown itself is
379
+ * a hand edit, which the proposal path already handles.
380
+ *
381
+ * "Possibly" is the honest word. This hook knows a file changed, not whether
382
+ * the change altered what any document says, and docs diff answers that
383
+ * properly by comparing content.
384
+ */
385
+ async function markDocsPossiblyStale(root, file) {
386
+ const path = String(file ?? "");
387
+ if (!path || !/\/(src|scripts)\//.test(path)) return;
388
+ if (!existsSync(paths(root).db)) return;
389
+ const session = await activeSession(root, {}).catch(() => null);
390
+ if (!session?.id) return;
391
+
392
+ const { query, mutate } = await import("../db/store.mjs");
393
+ const recent = await query(root, (db) => db.get(
394
+ `SELECT created_at FROM activity_events
395
+ WHERE event_type = 'documentation_possibly_stale' ORDER BY sequence DESC LIMIT 1`)).catch(() => null);
396
+ if (recent?.created_at && Date.now() - Date.parse(recent.created_at) < UNTRACKED_QUIET_MS) return;
397
+
398
+ await mutate(root, async (db) => {
399
+ const project = await db.get("SELECT id FROM projects LIMIT 1");
400
+ if (!project) return;
401
+ const behind = await db.get(
402
+ "SELECT COUNT(*) n FROM documents WHERE sync_status = 'generated'");
403
+ if (!behind?.n) return;
404
+ const { recordActivity } = await import("../db/store.mjs");
405
+ await recordActivity(db, project.id, {
406
+ type: "documentation_possibly_stale",
407
+ actor: "superdev",
408
+ sessionId: session.id,
409
+ summary: `Source changed while ${behind.n} generated documents stand. They may now describe something that moved. Run superdev docs diff to find out, and docs generate to bring them back in line.`,
410
+ });
411
+ }).catch(() => {});
412
+ }
413
+
414
+ /** How long between markers, so a long editing run leaves one, not hundreds. */
415
+ const UNTRACKED_QUIET_MS = 15 * 60 * 1000;
416
+
417
+ /**
418
+ * Record that the product changed while nothing was tracking it.
419
+ *
420
+ * This does not create a task. Section 17.1 puts that duty on the agent and
421
+ * P-010 says no required behaviour may depend on a hook firing, so inventing
422
+ * one here would be the hook asserting work it cannot describe. What it can do
423
+ * honestly is leave a marker, so the gap between what was built and what the
424
+ * record knows is visible in the record itself.
425
+ */
426
+ /**
427
+ * Changes made since the last task was claimed or created.
428
+ *
429
+ * An older marker describes work somebody has since accounted for, so counting
430
+ * every marker ever recorded would keep reporting a gap that was closed.
431
+ */
432
+ export async function untrackedSince(root) {
433
+ if (!existsSync(paths(root).db)) return 0;
434
+ const { query } = await import("../db/store.mjs");
435
+ const row = await query(root, (db) => db.get(
436
+ `SELECT COUNT(*) AS n FROM activity_events
437
+ WHERE event_type = 'untracked_work'
438
+ AND created_at > coalesce(
439
+ (SELECT MAX(created_at) FROM activity_events
440
+ WHERE event_type IN ('task_claimed', 'task_created')), '')`)).catch(() => null);
441
+ return Number(row?.n ?? 0);
442
+ }
443
+
444
+ async function noteUntrackedWork(root, file) {
445
+ if (!existsSync(paths(root).db)) return;
446
+ const session = await activeSession(root, {}).catch(() => null);
447
+ if (session?.activeTaskId) return;
448
+
449
+ const { query, mutate } = await import("../db/store.mjs");
450
+ const recent = await query(root, (db) => db.get(
451
+ `SELECT created_at FROM activity_events
452
+ WHERE event_type = 'untracked_work' ORDER BY sequence DESC LIMIT 1`)).catch(() => null);
453
+ if (recent?.created_at && Date.now() - Date.parse(recent.created_at) < UNTRACKED_QUIET_MS) return;
454
+
455
+ await mutate(root, async (db) => {
456
+ const project = await db.get("SELECT id FROM projects LIMIT 1");
457
+ if (!project) return;
458
+ const { recordActivity } = await import("../db/store.mjs");
459
+ await recordActivity(db, project.id, {
460
+ type: "untracked_work",
461
+ actor: "superdev",
462
+ summary: "The product changed while no task was claimed, so this work is not attached to any feature or contract. Claim or create a task to bring the record back in line.",
463
+ metadata: { firstFile: file ? String(file).split("/").slice(-2).join("/") : null },
464
+ });
465
+ }).catch(() => {});
466
+ }
467
+
468
+ async function postToolUse(root, payload) {
469
+ const tool = String(payload?.tool_name ?? payload?.tool ?? "");
470
+ const command = String(payload?.tool_input?.command ?? "");
471
+ const viaShell = tool === "Bash" && SHELL_MIGHT_WRITE.test(command);
472
+ if (!MUTATING_TOOLS.has(tool) && !viaShell) return EMPTY;
473
+
474
+ let file = payload?.tool_input?.file_path ?? payload?.tool_input?.notebook_path ?? payload?.tool_input?.path;
475
+ if (viaShell) {
476
+ const changed = await changedFiles(root);
477
+ // A command that looked like a write and changed nothing is not work.
478
+ if (!changed.length) return EMPTY;
479
+ markTouched(root, changed);
480
+ file = file ?? changed[0];
481
+ }
482
+ markTouched(root, [file]);
483
+ await flushTouched(root, { force: false });
484
+
485
+ // A file changed with no task claimed is the record falling behind, and
486
+ // recording nothing is how it stays invisible. Section 13.3 says to record
487
+ // files touched by the active task; when there is no active task, that
488
+ // absence is the fact worth keeping. Rate limited so a long editing run
489
+ // leaves one honest marker rather than one per keystroke.
490
+ await noteUntrackedWork(root, file).catch(() => {});
491
+ await markDocsPossiblyStale(root, file).catch(() => {});
492
+ return EMPTY;
493
+ }
494
+
495
+ /**
496
+ * A batch is a natural boundary, so it flushes regardless of the interval.
497
+ *
498
+ * PostToolBatch has no first-party source and may never fire; see
499
+ * UNVERIFIED_EVENTS in harness.mjs. Nothing depends on it: post-tool-use flushes
500
+ * on its own interval, so the timeline is the same either way, just coarser.
501
+ */
502
+ async function postToolBatch(root, payload) {
503
+ const files = [];
504
+ for (const call of payload?.tool_calls ?? []) {
505
+ if (!MUTATING_TOOLS.has(String(call?.tool_name ?? ""))) continue;
506
+ files.push(call?.tool_input?.file_path ?? call?.tool_input?.notebook_path ?? call?.tool_input?.path);
507
+ }
508
+ markTouched(root, files);
509
+ await flushTouched(root, { force: true });
510
+ return EMPTY;
511
+ }
512
+
513
+ const touchFile = (root) => join(paths(root).runtime, "touched.json");
514
+
515
+ function readTouched(root) {
516
+ try {
517
+ const raw = readFileSync(touchFile(root), "utf8");
518
+ const parsed = JSON.parse(raw);
519
+ return {
520
+ paths: Array.isArray(parsed.paths) ? parsed.paths.filter((p) => typeof p === "string") : [],
521
+ total: Number.isFinite(parsed.total) ? parsed.total : 0,
522
+ since: typeof parsed.since === "string" ? parsed.since : null,
523
+ lastEventAt: typeof parsed.lastEventAt === "string" ? parsed.lastEventAt : null,
524
+ };
525
+ } catch {
526
+ return { paths: [], total: 0, since: null, lastEventAt: null };
527
+ }
528
+ }
529
+
530
+ function writeTouched(root, state) {
531
+ try {
532
+ mkdirSync(paths(root).runtime, { recursive: true });
533
+ writeFileSync(touchFile(root), JSON.stringify(state));
534
+ return true;
535
+ } catch {
536
+ return false;
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Record which project-relative paths changed.
542
+ *
543
+ * Anything outside the root is dropped rather than recorded: an absolute path
544
+ * carries the machine's account name, and a path outside the project is not this
545
+ * project's history.
546
+ */
547
+ export function markTouched(root, files) {
548
+ const kept = [];
549
+ for (const file of files) {
550
+ if (!file) continue;
551
+ const rel = relative(root, canonical(root, file)).split(sep).join("/");
552
+ if (!rel || rel.startsWith("../") || isAbsolute(rel)) continue;
553
+ if (rel.startsWith(".superdev/")) continue; // Superdev's own runtime state is not project work.
554
+ kept.push(rel);
555
+ }
556
+ if (!kept.length) return null;
557
+
558
+ const state = readTouched(root);
559
+ const merged = [...new Set([...state.paths, ...kept])].slice(0, MAX_TOUCHED);
560
+ const next = {
561
+ paths: merged,
562
+ total: state.total + kept.length,
563
+ since: state.since ?? new Date().toISOString(),
564
+ lastEventAt: state.lastEventAt,
565
+ };
566
+ writeTouched(root, next);
567
+ return next;
568
+ }
569
+
570
+ /**
571
+ * Resolve a tool's path the same way the root was resolved.
572
+ *
573
+ * The root is a realpath, so a path the harness reports through a symlink would
574
+ * compare as outside the project and the edit would be dropped. A symlinked
575
+ * project directory is ordinary (/tmp on macOS, home directories on network
576
+ * mounts), and silently losing every edit there is worse than the extra stat.
577
+ * The parent is resolved rather than the file itself, because a path is often
578
+ * reported after the file it names has been moved or deleted.
579
+ */
580
+ function canonical(root, file) {
581
+ const abs = resolve(root, String(file));
582
+ let dir = dirname(abs);
583
+ const rest = [basename(abs)];
584
+ // Walk up to the deepest ancestor that exists. A newly written file often has
585
+ // a parent directory that was created in the same batch, and a path that
586
+ // cannot be resolved at all must stay absolute so the caller rejects it.
587
+ for (let depth = 0; depth < 24; depth++) {
588
+ try {
589
+ return join(realpathSync(dir), ...rest);
590
+ } catch {
591
+ const parent = dirname(dir);
592
+ if (parent === dir) return abs;
593
+ rest.unshift(basename(dir));
594
+ dir = parent;
595
+ }
596
+ }
597
+ return abs;
598
+ }
599
+
600
+ /**
601
+ * Append one activity event for everything touched since the last one.
602
+ *
603
+ * Nothing is written when there is no live session: a session-less edit belongs
604
+ * to whoever runs superdev resume next, and the marker survives to tell them.
605
+ */
606
+ export async function flushTouched(root, { force = false } = {}) {
607
+ const state = readTouched(root);
608
+ if (!state.paths.length) return { recorded: false, reason: "nothing touched" };
609
+
610
+ // The window opens at the first change, not at the last event, so a session's
611
+ // opening edit joins the work around it instead of becoming an event that says
612
+ // one file changed. `since` is always set while paths are pending.
613
+ const anchor = Date.parse(state.lastEventAt ?? state.since ?? "") || 0;
614
+ if (!force && Date.now() - anchor < ACTIVITY_INTERVAL_MS) {
615
+ return { recorded: false, reason: "within the activity interval" };
616
+ }
617
+ if (!existsSync(paths(root).db)) return { recorded: false, reason: "no project database" };
618
+
619
+ const session = await activeSession(root, {}).catch(() => null);
620
+ if (!session?.id || session.endedAt) {
621
+ return { recorded: false, reason: "no live session to attribute the work to" };
622
+ }
623
+
624
+ // Shells out, so it happens before the write transaction opens. The engine
625
+ // holds an exclusive process lock for a connection's whole life.
626
+ const git = safeGit(root);
627
+
628
+ const result = await touchSession(root, session.id, {
629
+ type: "code_changed",
630
+ summary: describeTouched(state),
631
+ taskId: session.activeTaskId ?? null,
632
+ git,
633
+ metadata: { files: state.paths.length, changes: state.total },
634
+ }).catch((err) => ({ recorded: false, reason: message(err) }));
635
+
636
+ writeTouched(root, { paths: [], total: 0, since: null, lastEventAt: new Date().toISOString() });
637
+ return result;
638
+ }
639
+
640
+ /** A line a human would want in the timeline, not a file listing. */
641
+ function describeTouched(state) {
642
+ const dirs = [...new Set(state.paths.map((p) => (p.includes("/") ? dirname(p) : "the project root")))];
643
+ const where = dirs.slice(0, 3).join(", ");
644
+ const more = dirs.length > 3 ? ` and ${dirs.length - 3} more places` : "";
645
+ const files = state.paths.length;
646
+ return `Changed ${files} file${files === 1 ? "" : "s"} in ${where}${more}.`;
647
+ }
648
+
649
+ // ------------------------------------------------------- compaction and end
650
+
651
+ /**
652
+ * Persist the 12.5 packet before the transcript is discarded.
653
+ *
654
+ * Everything comes from the database rather than the conversation, so the next
655
+ * agent resumes from records. A session that is already over has nothing to
656
+ * persist, and saying so is better than opening a second one at the moment the
657
+ * first is being thrown away.
658
+ */
659
+ async function preCompact(root) {
660
+ const prepared = await preparePacket(root);
661
+ if (!prepared) return EMPTY;
662
+
663
+ await flushTouched(root, { force: true });
664
+ const result = await compactSession(root, prepared.sessionId, prepared.details);
665
+ return note(
666
+ `Superdev persisted the session state to the database: ${result.memories.length} entries kept. Next action: ${prepared.details.nextAction ?? "not recorded"}`,
667
+ );
668
+ }
669
+
670
+ /**
671
+ * Close the session, write its outcome and next action, release its claims.
672
+ *
673
+ * SessionEnd is not guaranteed on a killed process, which is exactly why the
674
+ * next session's start re-derives everything from records rather than trusting
675
+ * that this ran.
676
+ */
677
+ async function sessionEnd(root) {
678
+ const prepared = await preparePacket(root);
679
+ if (!prepared) return EMPTY;
680
+
681
+ await flushTouched(root, { force: true });
682
+
683
+ // Section 13.5 asks for short-term memory to be consolidated as the session
684
+ // closes, which is the moment it is worth doing: the session's memories are
685
+ // complete and nothing is mid-write. Failure here never blocks the close,
686
+ // because losing the session record would cost more than a skipped pass.
687
+ let consolidated = null;
688
+ try {
689
+ const { consolidate } = await import("../memory/consolidate.mjs");
690
+ consolidated = await consolidate(root, { apply: true });
691
+ } catch { consolidated = null; }
692
+
693
+ const result = await endSession(root, prepared.sessionId, {
694
+ ...prepared.details,
695
+ outcome: prepared.details.lastVerifiedResult?.content ?? prepared.details.objective ?? null,
696
+ });
697
+ const released = result.released?.length ?? 0;
698
+ const tidied = consolidated && (consolidated.duplicatesMerged || consolidated.noiseDiscarded || consolidated.contradictionsFound)
699
+ ? ` Memory: ${consolidated.duplicatesMerged} duplicates merged, ${consolidated.contradictionsFound} contradictions marked, ${consolidated.noiseDiscarded} discarded.`
700
+ : "";
701
+ return note(
702
+ `Superdev closed session ${prepared.sessionId}${released ? `, releasing ${released} claim${released === 1 ? "" : "s"}` : ""}.${tidied} Next action: ${prepared.details.nextAction ?? "not recorded"}`,
703
+ );
704
+ }
705
+
706
+ /**
707
+ * Build the compaction and handoff packet from the records.
708
+ *
709
+ * The lists are capped: the authoritative copy of a decision, a blocker, a scope
710
+ * change and a pending document is its own record, and the memory entry written
711
+ * here is a pointer for recall. Copying all of them on every compaction would
712
+ * grow the memory table without adding a single fact.
713
+ */
714
+ async function preparePacket(root) {
715
+ if (!existsSync(paths(root).db)) return null;
716
+
717
+ const context = await resumeContext(root);
718
+ const session = context.session;
719
+ if (!session?.id || session.endedAt) return null;
720
+
721
+ // A session compacts many times. Without this, every compaction copies the
722
+ // same blockers and decisions again, and the resume report those entries feed
723
+ // ends up listing one blocker five times: the module's own output degrades the
724
+ // longer a session runs.
725
+ const seen = await query(root, (db) =>
726
+ db.all("SELECT source_ref FROM memory_entries WHERE session_id = ? AND source_ref IS NOT NULL", session.id),
727
+ ).then((rows) => new Set(rows.map((r) => r.source_ref))).catch(() => new Set());
728
+
729
+ const cap = (list, fn) =>
730
+ (list ?? []).map(fn).filter((e) => e.sourceRef && !seen.has(e.sourceRef)).slice(0, PACKET_LIMIT);
731
+ const verified = context.lastVerified;
732
+
733
+ return {
734
+ sessionId: session.id,
735
+ details: {
736
+ objective: session.objective ?? null,
737
+ activeTaskId: context.activeTask?.id ?? null,
738
+ featureId: context.feature?.id ?? null,
739
+ git: context.git,
740
+ nextAction: context.nextAction?.action ?? null,
741
+ actor: context.developer?.displayName ?? context.agent?.harness ?? "superdev",
742
+ decisions: cap(context.governingDecisions, (d) => ({
743
+ title: `${d.id} ${d.title}`,
744
+ content: d.decision ?? d.observable_rationale ?? d.title,
745
+ sourceRef: d.id,
746
+ })),
747
+ blockers: cap(context.blockedTasks, (t) => ({
748
+ title: `${t.id} ${t.name}`,
749
+ content: t.reason ?? "The blocker was never given a reason. Record one before continuing.",
750
+ sourceRef: t.id,
751
+ })),
752
+ scopeChanges: cap(context.scopeChanges, (s) => ({
753
+ title: s.summary,
754
+ content: s.summary,
755
+ sourceRef: s.taskId ?? s.id,
756
+ })),
757
+ pendingDocumentation: cap(context.pendingDocumentation, (d) => ({
758
+ title: d.path,
759
+ content: `${d.path} is ${String(d.sync_status ?? "out of step").replace(/_/g, " ")} and is waiting on a person.`,
760
+ sourceRef: d.id,
761
+ })),
762
+ // Not deduplicated: this is the value the session's `outcome` column is
763
+ // written from, so it has to be present on every compaction.
764
+ lastVerifiedResult: verified
765
+ ? {
766
+ title: `${verified.result ?? "recorded"} ${verified.evidence_type ?? verified.evidenceType ?? "evidence"}`,
767
+ content: verified.summary ?? "Evidence was recorded without a summary.",
768
+ // The session's `outcome` column is filled from `summary`, so leaving
769
+ // it out writes the stringified object into the record instead.
770
+ summary: verified.summary ?? "Evidence was recorded without a summary.",
771
+ sourceRef: verified.id ?? null,
772
+ }
773
+ : null,
774
+ },
775
+ };
776
+ }
777
+
778
+ // ------------------------------------------------------------------- checks
779
+
780
+ /**
781
+ * Is the database there, readable, and at the revision this build expects?
782
+ *
783
+ * Two cheap questions and no repair. `PRAGMA quick_check` catches a truncated or
784
+ * corrupt file, and `PRAGMA user_version` against the highest migration on disk
785
+ * catches a database that predates this build. Repair is a command.
786
+ */
787
+ export async function schemaState(root) {
788
+ const file = paths(root).db;
789
+ const expected = availableMigrations().at(-1)?.version ?? 0;
790
+
791
+ if (!existsSync(file)) {
792
+ return {
793
+ ok: false,
794
+ state: "missing",
795
+ expected,
796
+ version: null,
797
+ message: "no project database in this directory.",
798
+ remedy: "run superdev init to create it.",
799
+ };
800
+ }
801
+
802
+ try {
803
+ const found = await query(root, async (db) => {
804
+ const quick = await db.get("PRAGMA quick_check(1)");
805
+ const version = await db.get("PRAGMA user_version");
806
+ return {
807
+ quick: String(quick?.quick_check ?? "unknown"),
808
+ version: Number(version?.user_version ?? 0),
809
+ };
810
+ });
811
+
812
+ if (found.quick !== "ok") {
813
+ return {
814
+ ok: false,
815
+ state: "corrupt",
816
+ expected,
817
+ version: found.version,
818
+ message: `the project database failed its integrity check (${found.quick}).`,
819
+ remedy: "run superdev db restore to recover from the most recent backup.",
820
+ };
821
+ }
822
+ if (found.version < expected) {
823
+ return {
824
+ ok: false,
825
+ state: "behind",
826
+ expected,
827
+ version: found.version,
828
+ message: `the project database is at schema revision ${found.version}, this build expects ${expected}.`,
829
+ remedy: "run superdev db migrate.",
830
+ };
831
+ }
832
+ if (found.version > expected) {
833
+ return {
834
+ ok: false,
835
+ state: "ahead",
836
+ expected,
837
+ version: found.version,
838
+ // Downgrading is not a migration and there is no safe automatic answer,
839
+ // so this reports the mismatch and stops.
840
+ message: `the project database is at schema revision ${found.version}, ahead of this build's ${expected}.`,
841
+ remedy: "update Superdev; do not write to the project with an older build.",
842
+ };
843
+ }
844
+ return { ok: true, state: "current", expected, version: found.version };
845
+ } catch (err) {
846
+ return {
847
+ ok: false,
848
+ state: "unreadable",
849
+ expected,
850
+ version: null,
851
+ message: `the project database could not be read (${message(err)}).`,
852
+ remedy: "run superdev doctor.",
853
+ };
854
+ }
855
+ }
856
+
857
+ /**
858
+ * Provider readiness, reported only when the recorded check has gone stale.
859
+ *
860
+ * Detection probes external CLIs, which is unbounded and belongs to superdev
861
+ * doctor. This reads what that already wrote and says nothing when the answer is
862
+ * fresh, because a readiness line on every session start is noise. Provider ids
863
+ * and states only, never a path or a credential.
864
+ */
865
+ export function providerReadiness(root, { now = Date.now() } = {}) {
866
+ const file = join(paths(root).runtime, "providers.json");
867
+ if (!existsSync(file)) return null;
868
+
869
+ let report;
870
+ try {
871
+ report = JSON.parse(readFileSync(file, "utf8"));
872
+ } catch {
873
+ return "Provider readiness: the recorded report could not be read. Run superdev doctor to check the providers again.";
874
+ }
875
+
876
+ const checkedAt = Date.parse(report?.checkedAt ?? "");
877
+ const age = Number.isFinite(checkedAt) ? now - checkedAt : Infinity;
878
+ if (age < PROVIDER_STALE_MS) return null;
879
+
880
+ const notReady = (report?.providers ?? [])
881
+ .filter((p) => p && p.state && p.state !== "available-and-ready")
882
+ .map((p) => `${p.id} ${p.state}`)
883
+ .slice(0, 4);
884
+
885
+ const when = Number.isFinite(checkedAt)
886
+ ? `last checked ${Math.round(age / 86_400_000)} days ago`
887
+ : "never checked";
888
+ const detail = notReady.length ? ` Last known not ready: ${notReady.join("; ")}.` : "";
889
+ return sanitizeExternal(
890
+ `Provider readiness is stale (${when}).${detail} Run superdev doctor to check again. Nothing is installed automatically.`,
891
+ );
892
+ }
893
+
894
+ // ------------------------------------------------------------------ helpers
895
+
896
+ /** Never let a git failure look like a hook failure. */
897
+ function safeGit(root) {
898
+ try {
899
+ return gitState(root);
900
+ } catch {
901
+ return null;
902
+ }
903
+ }
904
+
905
+ const message = (err) => sanitizeExternal(String(err?.message ?? err ?? "unknown error")).slice(0, 200);
906
+
907
+ /** The documented shape for injecting context. */
908
+ function context(hookEventName, text, { suppress = false } = {}) {
909
+ const additionalContext = sanitizeExternal(String(text ?? "")).trim();
910
+ if (!additionalContext) return EMPTY;
911
+ const out = { hookSpecificOutput: { hookEventName, additionalContext } };
912
+ if (suppress) out.suppressOutput = true;
913
+ return out;
914
+ }
915
+
916
+ /** One line the person sees, for events where context injection is pointless. */
917
+ const note = (text) => ({ systemMessage: sanitizeExternal(String(text)).slice(0, 500), suppressOutput: true });
918
+
919
+ /**
920
+ * Walk up for the directory that owns the project database.
921
+ *
922
+ * A hook fires wherever the person happens to be, which is often a subdirectory.
923
+ * Only what the filesystem confirms is trusted: the payload supplies a candidate,
924
+ * realpath decides whether it exists.
925
+ */
926
+ export function resolveRoot(payload = {}, env = process.env) {
927
+ const candidate = payload?.cwd ?? env.CLAUDE_PROJECT_DIR ?? process.cwd();
928
+ let dir;
929
+ try {
930
+ dir = realpathSync(String(candidate));
931
+ } catch {
932
+ return null;
933
+ }
934
+
935
+ for (let depth = 0; depth < 24; depth++) {
936
+ if (existsSync(paths(dir).db)) return dir;
937
+ const parent = dirname(dir);
938
+ if (parent === dir) break;
939
+ dir = parent;
940
+ }
941
+ // No project anywhere above. The starting directory is still the honest
942
+ // answer: session start reports that there is no project here.
943
+ return realpathSync(String(candidate));
944
+ }
945
+
946
+ // ------------------------------------------------------------------- script
947
+
948
+ function readStdin() {
949
+ try {
950
+ const buf = readFileSync(0);
951
+ if (buf.length > MAX_STDIN_BYTES) return {};
952
+ const text = buf.toString("utf8").trim();
953
+ return text ? JSON.parse(text) : {};
954
+ } catch {
955
+ return {};
956
+ }
957
+ }
958
+
959
+ /**
960
+ * Write the response and stop. `writeSync` rather than `process.stdout.write`
961
+ * because the harness reads a pipe and an ordinary write can still be buffered
962
+ * when `process.exit` runs, which truncates the JSON the harness is parsing.
963
+ */
964
+ function emit(value) {
965
+ let text;
966
+ try {
967
+ text = JSON.stringify(value ?? EMPTY);
968
+ } catch {
969
+ text = "{}";
970
+ }
971
+ const buf = Buffer.from(text ?? "{}", "utf8");
972
+ for (let attempt = 0, written = 0; attempt < 50 && written < buf.length; attempt++) {
973
+ try {
974
+ written += writeSync(1, buf, written);
975
+ } catch (err) {
976
+ if (err?.code !== "EAGAIN") return; // A closed pipe is not worth failing over.
977
+ }
978
+ }
979
+ }
980
+
981
+ export async function main(argv = process.argv) {
982
+ const event = String(argv[2] ?? "");
983
+ let response = EMPTY;
984
+
985
+ try {
986
+ const payload = readStdin();
987
+ const root = resolveRoot(payload);
988
+
989
+ // Raced rather than awaited: the point of the budget is that a slow database
990
+ // or a slow git cannot hold the harness open past its own timeout.
991
+ let timer;
992
+ const budget = new Promise((r) => {
993
+ timer = setTimeout(() => r(EMPTY), BUDGET_MS);
994
+ timer.unref?.();
995
+ });
996
+ response = await Promise.race([
997
+ handleHook(event, payload, { root }).catch(() => EMPTY),
998
+ budget,
999
+ ]);
1000
+ clearTimeout(timer);
1001
+ } catch {
1002
+ response = EMPTY;
1003
+ }
1004
+
1005
+ emit(response ?? EMPTY);
1006
+ // Hard exit: work may still be in flight behind the budget race, and an
1007
+ // abandoned write transaction rolls back rather than committing half of itself.
1008
+ process.exit(0);
1009
+ }
1010
+
1011
+ if (process.argv[1] && realpathSafe(process.argv[1]) === realpathSafe(new URL(import.meta.url).pathname)) {
1012
+ await main();
1013
+ }
1014
+
1015
+ function realpathSafe(p) {
1016
+ try {
1017
+ return realpathSync(p);
1018
+ } catch {
1019
+ return p;
1020
+ }
1021
+ }