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,305 @@
1
+ // Who is working, in which harness, on which worktree.
2
+ //
3
+ // Identity is resolved before anything else in a session, because every later
4
+ // write is attributed to it. Harness detection reports the evidence that decided
5
+ // it rather than a bare verdict: brief section 12.6 requires hook coverage to be
6
+ // stated honestly, and a wrong guess about hook support is the difference
7
+ // between a tracked session and a silent one.
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync } from "node:fs";
12
+ import { userInfo } from "node:os";
13
+ import { resolve } from "node:path";
14
+ import { paths, mutate, create, currentProject, DbError } from "../db/store.mjs";
15
+ import { sanitizeExternal } from "../model/screening.mjs";
16
+
17
+ /**
18
+ * Environment signals per harness, with the hook coverage each one actually
19
+ * guarantees. `hooks` is what the orchestrator must plan around: anything less
20
+ * than "supported" means state is updated explicitly rather than on a hook.
21
+ */
22
+ const HARNESSES = [
23
+ {
24
+ harness: "claude-code",
25
+ hooks: "supported",
26
+ note: "Claude Code lifecycle hooks fire. Superdev still verifies state from the database rather than trusting the hook alone.",
27
+ vars: ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID", "CLAUDE_PLUGIN_ROOT", "CLAUDE_PROJECT_DIR"],
28
+ sessionVars: ["CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID"],
29
+ },
30
+ {
31
+ harness: "codex",
32
+ hooks: "partial",
33
+ note: "Codex hook coverage is partial. Superdev runs an explicit preflight and an explicit handoff instead of relying on a hook.",
34
+ vars: ["CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_HOME", "CODEX_THREAD_ID", "CODEX_CLI_VERSION"],
35
+ sessionVars: ["CODEX_THREAD_ID", "CODEX_SESSION_ID"],
36
+ },
37
+ {
38
+ harness: "skills-sh",
39
+ hooks: "none",
40
+ note: "skills.sh gives no lifecycle-hook guarantee, so session start, compaction and handoff are written explicitly by the orchestrator.",
41
+ vars: ["SKILLS_SH", "SKILLS_SH_SESSION", "SKILLS_SH_HOME", "SKILL_SH_RUN"],
42
+ sessionVars: ["SKILLS_SH_SESSION", "SKILL_SH_SESSION_ID"],
43
+ },
44
+ ];
45
+
46
+ const UNKNOWN = {
47
+ harness: "unknown",
48
+ hooks: "none",
49
+ note: "No harness signal in the environment. Nothing in Superdev depends on a hook firing here; state is written explicitly.",
50
+ };
51
+
52
+ /**
53
+ * Which harness this process is running under, and why.
54
+ *
55
+ * Evidence carries variable names, never their values, so a report built from
56
+ * this can never leak a token that happened to be in the environment.
57
+ */
58
+ export function detectHarness(env = process.env) {
59
+ const vars = env ?? {};
60
+ const present = (name) => typeof vars[name] === "string" && vars[name] !== "";
61
+
62
+ const override = String(vars.SUPERDEV_HARNESS ?? "").trim();
63
+ const named = HARNESSES.find((h) => h.harness === override);
64
+ if (named) {
65
+ return {
66
+ ...describe(named, vars),
67
+ evidence: [{ signal: "SUPERDEV_HARNESS", source: "env", value: named.harness }],
68
+ confidence: "confirmed",
69
+ };
70
+ }
71
+
72
+ const scored = HARNESSES
73
+ .map((h) => ({ h, hits: h.vars.filter(present) }))
74
+ .filter((s) => s.hits.length)
75
+ .sort((a, b) => b.hits.length - a.hits.length);
76
+
77
+ if (!scored.length) return { ...UNKNOWN, evidence: [], confidence: "assumed", sessionExternalId: null };
78
+
79
+ const winner = scored[0];
80
+ return {
81
+ ...describe(winner.h, vars),
82
+ evidence: winner.hits.map((signal) => ({ signal, source: "env" })),
83
+ // Two harnesses claiming the environment at once means one is nested inside
84
+ // the other, so the verdict is a best guess and says so.
85
+ confidence: scored.length > 1 ? "inferred" : "confirmed",
86
+ };
87
+ }
88
+
89
+ function describe(harness, vars) {
90
+ const sessionVar = harness.sessionVars.find((name) => vars[name]);
91
+ return {
92
+ harness: harness.harness,
93
+ hooks: harness.hooks,
94
+ note: harness.note,
95
+ // A harness session id is an opaque correlation handle, not a credential.
96
+ sessionExternalId: sessionVar ? clean(vars[sessionVar]) : null,
97
+ };
98
+ }
99
+
100
+ // ---------------------------------------------------------------------- git
101
+
102
+ const RUN = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000, maxBuffer: 8 * 1024 * 1024 };
103
+
104
+ function git(root, args) {
105
+ return execFileSync("git", args, { cwd: root, ...RUN }).trim();
106
+ }
107
+
108
+ function gitOrNull(root, args) {
109
+ try {
110
+ return git(root, args) || null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Branch, HEAD and cleanliness for a working tree.
118
+ *
119
+ * This shells out, so it must be called before a write transaction is opened and
120
+ * never inside one: the engine holds an exclusive process lock for a
121
+ * connection's whole life, and a subprocess inside that window blocks every
122
+ * other writer for as long as git takes.
123
+ */
124
+ export function gitState(root) {
125
+ const top = gitOrNull(root, ["rev-parse", "--show-toplevel"]);
126
+ if (!top) return { branch: null, head: null, dirty: false, worktreeFingerprint: null };
127
+
128
+ // --show-current is empty on a detached HEAD and on an unborn branch, where
129
+ // rev-parse --abbrev-ref would throw or answer "HEAD".
130
+ const branch = gitOrNull(root, ["branch", "--show-current"]);
131
+ const head = gitOrNull(root, ["rev-parse", "HEAD"]);
132
+ const status = gitOrNull(root, ["status", "--porcelain"]);
133
+ const common = gitOrNull(root, ["rev-parse", "--git-common-dir"]) ?? ".git";
134
+
135
+ return {
136
+ branch: clean(branch),
137
+ head,
138
+ dirty: Boolean(status),
139
+ worktreeFingerprint: fingerprint(top, resolve(top, common)),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * A stable handle for one working tree. The paths are hashed rather than stored
145
+ * because an absolute path carries the machine's account name, which must never
146
+ * reach a record. Linked worktrees share a common git dir but differ at the top
147
+ * level, so both go into the hash.
148
+ */
149
+ const fingerprint = (top, commonDir) =>
150
+ createHash("sha256").update(`${top}\n${commonDir}`).digest("hex").slice(0, 16);
151
+
152
+ const clean = (text) => {
153
+ const out = sanitizeExternal(text).trim();
154
+ return out || null;
155
+ };
156
+
157
+ // ----------------------------------------------------------------- identity
158
+
159
+ /**
160
+ * Find or register the developer, agent and branch this process is working as.
161
+ *
162
+ * Everything that shells out happens first; only then is the transaction opened.
163
+ * Pass `git` if the caller already has a `gitState` result, so the same session
164
+ * start does not run git twice.
165
+ */
166
+ export async function resolveIdentity(root, opts = {}) {
167
+ const harness = opts.harness && typeof opts.harness === "object"
168
+ ? opts.harness
169
+ : detectHarness(opts.env);
170
+ const state = opts.git ?? gitState(root);
171
+
172
+ // Checked before the connection is opened: opening a write connection on a
173
+ // path that has no database creates an empty one, so a hook firing in the
174
+ // wrong directory would leave a stray .superdev behind and then fail on a
175
+ // missing table rather than saying what is actually wrong.
176
+ if (!existsSync(paths(root).db)) {
177
+ throw new DbError("E_NOT_FOUND", "This directory has no Superdev project yet. Run superdev init first.");
178
+ }
179
+
180
+ const env = opts.env ?? process.env;
181
+ const externalIdentity = clean(
182
+ opts.externalIdentity ?? env.SUPERDEV_EXTERNAL_IDENTITY ?? gitOrNull(root, ["config", "--get", "user.email"]),
183
+ );
184
+ const displayName = clean(
185
+ opts.displayName ?? gitOrNull(root, ["config", "--get", "user.name"]) ?? safeUsername(),
186
+ ) ?? "developer";
187
+ const sessionExternalId = clean(opts.sessionExternalId ?? harness.sessionExternalId);
188
+ const modelLabel = clean(opts.modelLabel);
189
+ const at = opts.at ?? new Date().toISOString();
190
+
191
+ return mutate(root, async (db) => {
192
+ const project = await currentProject(db);
193
+ if (!project) {
194
+ throw new DbError("E_NOT_FOUND", "This directory has no Superdev project yet. Run superdev init first.");
195
+ }
196
+
197
+ const developer = await upsertDeveloper(db, project.id, { displayName, externalIdentity, at });
198
+ const agent = await upsertAgent(db, developer.id, {
199
+ harness: harness.harness, modelLabel, sessionExternalId, at,
200
+ });
201
+ const branch = await upsertBranch(db, project.id, state, at);
202
+
203
+ return { developer, agent, branch, project, harness, git: state };
204
+ });
205
+ }
206
+
207
+ function safeUsername() {
208
+ try {
209
+ return userInfo().username;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ // developers, agents, branches and work_sessions carry no `version` column, so
216
+ // `patch` and `setStatus` cannot touch them: both go through versionedUpdate,
217
+ // which always writes `version = version + 1`. These four tables are presence
218
+ // bookkeeping rather than specification records, so the update is written here
219
+ // and the lifecycle events that matter to a human are recorded by session.mjs.
220
+
221
+ async function upsertDeveloper(db, projectId, { displayName, externalIdentity, at }) {
222
+ const found = externalIdentity
223
+ ? await db.get("SELECT * FROM developers WHERE project_id = ? AND external_identity = ?", projectId, externalIdentity)
224
+ : null;
225
+ const row = found
226
+ ?? await db.get("SELECT * FROM developers WHERE project_id = ? AND display_name = ?", projectId, displayName);
227
+
228
+ if (!row) {
229
+ // No activity event: registering an actor is bookkeeping, and the
230
+ // session_started event that follows names the developer anyway.
231
+ return create(db, "developer", {
232
+ project_id: projectId,
233
+ display_name: displayName,
234
+ external_identity: externalIdentity,
235
+ status: "active",
236
+ created_at: at,
237
+ });
238
+ }
239
+
240
+ // The display name is never rewritten: it is half of a unique key, and a
241
+ // rename would collide with whoever already holds the new name.
242
+ const external = row.external_identity ?? externalIdentity;
243
+ if (row.status !== "active" || external !== row.external_identity) {
244
+ await db.run(
245
+ "UPDATE developers SET status = 'active', external_identity = ? WHERE id = ?",
246
+ external, row.id,
247
+ );
248
+ return db.get("SELECT * FROM developers WHERE id = ?", row.id);
249
+ }
250
+ return row;
251
+ }
252
+
253
+ async function upsertAgent(db, developerId, { harness, modelLabel, sessionExternalId, at }) {
254
+ const row = await db.get(
255
+ `SELECT * FROM agents
256
+ WHERE developer_id = ? AND harness = ? AND session_external_id IS ?
257
+ ORDER BY last_seen_at DESC LIMIT 1`,
258
+ developerId, harness, sessionExternalId,
259
+ );
260
+
261
+ if (!row) {
262
+ return create(db, "agent", {
263
+ developer_id: developerId,
264
+ harness,
265
+ model_label: modelLabel,
266
+ session_external_id: sessionExternalId,
267
+ status: "active",
268
+ last_seen_at: at,
269
+ });
270
+ }
271
+
272
+ await db.run(
273
+ "UPDATE agents SET status = 'active', last_seen_at = ?, model_label = coalesce(?, model_label) WHERE id = ?",
274
+ at, modelLabel, row.id,
275
+ );
276
+ return db.get("SELECT * FROM agents WHERE id = ?", row.id);
277
+ }
278
+
279
+ async function upsertBranch(db, projectId, state, at) {
280
+ if (!state.branch && !state.worktreeFingerprint) return null;
281
+ const name = state.branch ?? "detached";
282
+
283
+ const row = await db.get(
284
+ "SELECT * FROM branches WHERE project_id = ? AND name = ? AND worktree_fingerprint IS ?",
285
+ projectId, name, state.worktreeFingerprint,
286
+ );
287
+
288
+ if (!row) {
289
+ return create(db, "branch", {
290
+ project_id: projectId,
291
+ name,
292
+ worktree_fingerprint: state.worktreeFingerprint,
293
+ head_revision: state.head,
294
+ dirty: state.dirty ? 1 : 0,
295
+ status: "active",
296
+ last_seen_at: at,
297
+ });
298
+ }
299
+
300
+ await db.run(
301
+ "UPDATE branches SET head_revision = ?, dirty = ?, last_seen_at = ? WHERE id = ?",
302
+ state.head, state.dirty ? 1 : 0, at, row.id,
303
+ );
304
+ return db.get("SELECT * FROM branches WHERE id = ?", row.id);
305
+ }
@@ -0,0 +1,343 @@
1
+ // Resuming without conversation memory.
2
+ //
3
+ // Everything here is read from the project database. A new agent, or the same
4
+ // agent after a compaction, gets the active task, the feature and workflow step
5
+ // it serves, the decisions that govern it, what is blocking it, what changed in
6
+ // scope, which documents are waiting on a person, what was last verified, where
7
+ // the working tree is, and the one thing to do next. Brief sections 12.1 and 13.
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { paths, query, currentProject, json } from "../db/store.mjs";
11
+ import { handoffPacket } from "../memory/index.mjs";
12
+ import { sanitizeExternal } from "../model/screening.mjs";
13
+ import { detectHarness, gitState } from "./identity.mjs";
14
+ import { activeSession, IDLE_AFTER_HOURS } from "./session.mjs";
15
+ import { untrackedSince } from "./hooks.mjs";
16
+
17
+ const nowIso = () => new Date().toISOString();
18
+
19
+ const SOURCE_NOTE =
20
+ "Read from the project database, not from conversation. Memory entries are recall and must be checked against the records before they change anything.";
21
+
22
+ const noProject = (git) => ({
23
+ generatedAt: nowIso(),
24
+ project: null,
25
+ git,
26
+ nextAction: { action: "Run superdev init to create the project database in this directory.", source: "project" },
27
+ note: SOURCE_NOTE,
28
+ });
29
+
30
+ /** Everything needed to continue the work in this directory. */
31
+ export async function resumeContext(root) {
32
+ // Shells out, so it happens before anything opens a connection.
33
+ const git = gitState(root);
34
+
35
+ // A session-start hook runs wherever the person happens to be. Opening a
36
+ // database that was never created reports an engine I/O error, which reads
37
+ // like a fault when the honest answer is that there is no project here.
38
+ if (!existsSync(paths(root).db)) return noProject(git);
39
+
40
+ const session = await activeSession(root, { includeEnded: true });
41
+ const packet = session ? await handoffPacket(root, session.id) : null;
42
+
43
+ const extra = await query(root, async (db) => {
44
+ const project = await currentProject(db);
45
+ if (!project) return { project: null };
46
+
47
+ const taskId = packet?.activeTask?.id ?? null;
48
+
49
+ const workflowSteps = taskId
50
+ ? await db.all(
51
+ `SELECT s.id, s.sequence, s.action, s.owner_type, s.owner_ref, s.input_contract,
52
+ s.expected_result, s.failure_behavior, s.status,
53
+ w.id AS workflow_id, w.name AS workflow_name, w.completion_criteria
54
+ FROM task_contract_links l
55
+ JOIN workflow_steps s ON s.id = l.target_id
56
+ JOIN workflows w ON w.id = s.workflow_id
57
+ WHERE l.task_id = ? AND l.target_type = 'workflow_step'
58
+ ORDER BY s.sequence`, taskId)
59
+ : [];
60
+
61
+ // Scope changes are recorded as activity, which is the only append-only
62
+ // record of them; the task's own fields show the result, not the change.
63
+ const scopeChanges = await db.all(
64
+ `SELECT id, summary, task_id, feature_id, created_at, metadata_json
65
+ FROM activity_events
66
+ WHERE project_id = ? AND event_type = 'scope_changed'
67
+ ORDER BY sequence DESC LIMIT 10`, project.id);
68
+
69
+ const blockedTasks = await db.all(
70
+ `SELECT id, name, status, block_reason, feature_id FROM tasks
71
+ WHERE project_id = ? AND status = 'blocked'
72
+ ORDER BY updated_at DESC LIMIT 5`, project.id);
73
+
74
+ const readyTasks = await db.all(
75
+ `SELECT t.id, t.name, t.priority, t.feature_id FROM tasks t
76
+ LEFT JOIN task_assignments a ON a.task_id = t.id AND a.active = 1
77
+ WHERE t.project_id = ? AND t.status = 'ready' AND a.task_id IS NULL
78
+ ORDER BY t.sequence, t.id LIMIT 5`, project.id);
79
+
80
+ const lastVerified = await db.get(
81
+ `SELECT id, task_id, feature_id, evidence_type, summary, reference, result, status, recorded_at
82
+ FROM verification_evidence
83
+ WHERE project_id = ? AND status <> 'superseded'
84
+ ORDER BY recorded_at DESC LIMIT 1`, project.id);
85
+
86
+ const recordedBranch = git.branch
87
+ ? await db.get(
88
+ "SELECT * FROM branches WHERE project_id = ? AND name = ? AND worktree_fingerprint IS ?",
89
+ project.id, git.branch, git.worktreeFingerprint)
90
+ : null;
91
+
92
+ const revision = await db.value(
93
+ "SELECT max(sequence) FROM activity_events WHERE project_id = ?", project.id);
94
+
95
+ return {
96
+ project: {
97
+ id: project.id,
98
+ name: project.name,
99
+ status: project.status,
100
+ workingMode: project.working_mode,
101
+ statement: project.statement,
102
+ },
103
+ revision: revision ?? 0,
104
+ workflowSteps: workflowSteps.map((s) => ({
105
+ id: s.id,
106
+ workflowId: s.workflow_id,
107
+ workflowName: s.workflow_name,
108
+ sequence: s.sequence,
109
+ action: s.action,
110
+ ownerType: s.owner_type,
111
+ ownerRef: s.owner_ref,
112
+ inputContract: s.input_contract,
113
+ expectedResult: s.expected_result,
114
+ failureBehavior: s.failure_behavior,
115
+ status: s.status,
116
+ completionCriteria: s.completion_criteria,
117
+ })),
118
+ scopeChanges: scopeChanges.map((e) => ({
119
+ id: e.id,
120
+ summary: e.summary,
121
+ taskId: e.task_id,
122
+ featureId: e.feature_id,
123
+ at: e.created_at,
124
+ detail: json(e.metadata_json, {}),
125
+ })),
126
+ blockedTasks: blockedTasks.map((t) => ({
127
+ id: t.id, name: t.name, status: t.status, reason: t.block_reason, featureId: t.feature_id,
128
+ })),
129
+ readyTasks: readyTasks.map((t) => ({
130
+ id: t.id, name: t.name, priority: t.priority, featureId: t.feature_id,
131
+ })),
132
+ lastVerified: lastVerified ?? null,
133
+ recordedBranch,
134
+ };
135
+ });
136
+
137
+ if (!extra.project) return noProject(git);
138
+
139
+ const context = {
140
+ generatedAt: nowIso(),
141
+ note: SOURCE_NOTE,
142
+ project: extra.project,
143
+ revision: extra.revision,
144
+ git: {
145
+ branch: git.branch,
146
+ head: git.head,
147
+ dirty: git.dirty,
148
+ worktreeFingerprint: git.worktreeFingerprint,
149
+ recordedBranchId: extra.recordedBranch?.id ?? null,
150
+ // The branch row is refreshed on session start and at boundaries, so it
151
+ // can trail the working tree. The live reading above is the authority.
152
+ recordedHead: extra.recordedBranch?.head_revision ?? null,
153
+ },
154
+ session: session ?? null,
155
+ developer: packet?.developer ?? null,
156
+ agent: packet?.agent ?? null,
157
+ activeTask: packet?.activeTask ?? null,
158
+ feature: packet?.feature ?? null,
159
+ workflowSteps: extra.workflowSteps,
160
+ governingDecisions: packet?.governingDecisions ?? [],
161
+ openQuestions: packet?.openQuestions ?? [],
162
+ blockers: packet?.blockers ?? [],
163
+ blockedTasks: extra.blockedTasks,
164
+ scopeChanges: extra.scopeChanges,
165
+ pendingDocumentation: packet?.pendingDocumentation ?? [],
166
+ lastVerified: packet?.evidence?.[0] ?? extra.lastVerified,
167
+ memory: packet?.memory ?? [],
168
+ recentActivity: packet?.recentActivity ?? [],
169
+ readyTasks: extra.readyTasks,
170
+ handoff: session?.handoff ?? null,
171
+ // Work the record cannot explain. Section 12.1 asks resume for everything
172
+ // the next session needs to carry on, and changes nobody attached to a task
173
+ // are exactly what the next session would otherwise rebuild or undo.
174
+ untrackedChanges: await untrackedSince(root).catch(() => 0),
175
+ };
176
+
177
+ context.nextAction = nextAction(context);
178
+ return context;
179
+ }
180
+
181
+ /**
182
+ * One instruction, and where it came from. A recorded next action always wins,
183
+ * because someone wrote it deliberately; everything after it is derived from the
184
+ * state the records are actually in.
185
+ */
186
+ function nextAction(context) {
187
+ const session = context.session;
188
+ if (session?.nextAction) return { action: session.nextAction, source: "session" };
189
+ if (session?.handoff && session.status === "handed_off") {
190
+ return { action: `Pick up the handoff from ${session.id}: ${session.handoff}`, source: "handoff" };
191
+ }
192
+
193
+ const task = context.activeTask;
194
+ if (task) {
195
+ if (task.status === "blocked") {
196
+ return {
197
+ action: `Clear the blocker on ${task.id}: ${task.blockReason ?? "the reason was never recorded, so record it first"}`,
198
+ source: "task",
199
+ };
200
+ }
201
+ const open = task.openSubtasks?.[0];
202
+ if (open) {
203
+ return { action: `Finish subtask ${open.id} ${open.name} before ${task.id} can complete.`, source: "subtask" };
204
+ }
205
+ const waiting = task.dependencies?.[0];
206
+ if (waiting) {
207
+ return { action: `${task.id} waits on ${waiting.id} ${waiting.name} (${waiting.status}).`, source: "dependency" };
208
+ }
209
+ if (task.status === "ready" || task.status === "draft") {
210
+ return { action: `Claim ${task.id} ${task.name} and move it to in progress.`, source: "task" };
211
+ }
212
+ if (task.status === "verifying" || task.status === "in_review") {
213
+ return { action: `Verify ${task.id} against ${describeVerification(task)} and attach the evidence.`, source: "task" };
214
+ }
215
+ return {
216
+ action: `Continue ${task.id} ${task.name}. Expected outcome: ${task.expectedOutcome ?? "not recorded, write it before continuing"}`,
217
+ source: "task",
218
+ };
219
+ }
220
+
221
+ const blocked = context.blockedTasks[0];
222
+ if (blocked) {
223
+ return { action: `Clear the blocker on ${blocked.id} ${blocked.name}: ${blocked.reason ?? "reason not recorded"}`, source: "blocked-task" };
224
+ }
225
+ const ready = context.readyTasks[0];
226
+ if (ready) return { action: `Claim ${ready.id} ${ready.name}.`, source: "ready-task" };
227
+ if (context.feature?.nextAction) return { action: context.feature.nextAction, source: "feature" };
228
+
229
+ const pending = context.pendingDocumentation[0];
230
+ if (pending) {
231
+ return { action: `Review the documentation change waiting on ${pending.path} and accept or reject it.`, source: "documentation" };
232
+ }
233
+ const question = context.openQuestions[0];
234
+ if (question) return { action: `Answer ${question.id}: ${question.question}`, source: "question" };
235
+
236
+ return { action: "No open work is recorded. Plan the next feature before writing code.", source: "none" };
237
+ }
238
+
239
+ const describeVerification = (task) => {
240
+ const requirements = task.verificationRequirements ?? [];
241
+ return requirements.length ? requirements.join("; ") : "the verification its feature requires";
242
+ };
243
+
244
+ // ------------------------------------------------------------------- report
245
+
246
+ /**
247
+ * The compact text a session-start hook injects.
248
+ *
249
+ * It stays short because it is prepended to every session, and it is passed
250
+ * through the storage screen on the way out so no environment value, credential
251
+ * or machine path can reach a transcript even if one reached a record.
252
+ */
253
+ export async function sessionStartReport(root, opts = {}) {
254
+ const harness = typeof opts.harness === "string"
255
+ ? { harness: opts.harness, hooks: null, note: null }
256
+ : opts.harness ?? detectHarness(opts.env);
257
+
258
+ const context = await resumeContext(root);
259
+ const lines = [];
260
+
261
+ if (!context.project) {
262
+ lines.push("Superdev: no project in this directory.");
263
+ lines.push(`Next action: ${context.nextAction.action}`);
264
+ return sanitizeExternal(lines.join("\n"));
265
+ }
266
+
267
+ lines.push(`Superdev: ${context.project.name} (${context.project.status}, ${context.project.workingMode} mode)`);
268
+ lines.push(`Harness: ${harness.harness}${harness.hooks ? `, lifecycle hooks ${harness.hooks}` : ""}`);
269
+ if (harness.hooks && harness.hooks !== "supported") lines.push(`- ${harness.note}`);
270
+
271
+ lines.push(`Branch: ${context.git.branch ?? "none"} at ${short(context.git.head)}${context.git.dirty ? ", uncommitted changes" : ", clean"}`);
272
+
273
+ const session = context.session;
274
+ if (session) {
275
+ const state = session.endedAt ? `${session.status} ${ago(session.endedAt)}` : `${session.state}, last activity ${ago(session.lastActivityAt)}`;
276
+ lines.push(`Session: ${session.id} ${state}${session.objective ? ` (${session.objective})` : ""}`);
277
+ if (session.state === "idle" && !session.endedAt) {
278
+ lines.push(`- Idle means no recorded activity for ${IDLE_AFTER_HOURS} hours. That is normal, not a fault.`);
279
+ }
280
+ } else {
281
+ lines.push("Session: none started yet.");
282
+ }
283
+
284
+ const task = context.activeTask;
285
+ if (task) {
286
+ lines.push(`Active task: ${task.id} ${task.name} (${task.status})`);
287
+ if (context.feature) lines.push(`- Feature: ${context.feature.id} ${context.feature.name} (${context.feature.status})`);
288
+ const step = context.workflowSteps[0];
289
+ if (step) lines.push(`- Workflow step: ${step.id} step ${step.sequence} of ${step.workflowName}, ${step.action}`);
290
+ } else {
291
+ lines.push("Active task: none claimed.");
292
+ }
293
+
294
+ if (context.governingDecisions.length) {
295
+ lines.push(`Decisions in force: ${list(context.governingDecisions.map((d) => `${d.id} ${d.title}`), 3)}`);
296
+ }
297
+
298
+ const blockerText = [
299
+ ...context.blockedTasks.map((t) => `${t.id} ${t.reason ?? "reason not recorded"}`),
300
+ ...context.blockers.map((b) => b.title),
301
+ ];
302
+ lines.push(`Blockers: ${blockerText.length ? list(blockerText, 3) : "none recorded"}`);
303
+
304
+ if (context.scopeChanges.length) {
305
+ lines.push(`Scope changes: ${list(context.scopeChanges.map((s) => s.summary), 2)}`);
306
+ }
307
+ if (context.pendingDocumentation.length) {
308
+ lines.push(`Documentation waiting on a person: ${list(context.pendingDocumentation.map((d) => d.path), 3)}`);
309
+ }
310
+ if (context.openQuestions.length) {
311
+ lines.push(`Open questions: ${context.openQuestions.length}, first is ${context.openQuestions[0].id} ${context.openQuestions[0].question}`);
312
+ }
313
+
314
+ const verified = context.lastVerified;
315
+ lines.push(verified
316
+ ? `Last verified: ${verified.result} ${ago(verified.recorded_at ?? verified.recordedAt)}, ${verified.summary}`
317
+ : "Last verified: nothing recorded yet.");
318
+
319
+ lines.push(`Next action: ${context.nextAction.action}`);
320
+
321
+ return sanitizeExternal(lines.join("\n"));
322
+ }
323
+
324
+ const short = (head) => (head ? String(head).slice(0, 7) : "no commit");
325
+
326
+ function list(values, max) {
327
+ const kept = values.filter(Boolean).slice(0, max);
328
+ const rest = values.length - kept.length;
329
+ return `${kept.join("; ")}${rest > 0 ? `; and ${rest} more` : ""}`;
330
+ }
331
+
332
+ function ago(iso) {
333
+ if (!iso) return "at an unrecorded time";
334
+ const ms = Date.now() - Date.parse(iso);
335
+ if (!Number.isFinite(ms)) return "at an unrecorded time";
336
+ if (ms < 60_000) return "just now";
337
+ const minutes = Math.round(ms / 60_000);
338
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
339
+ const hours = Math.round(minutes / 60);
340
+ if (hours < 48) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
341
+ const days = Math.round(hours / 24);
342
+ return `${days} day${days === 1 ? "" : "s"} ago`;
343
+ }