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,679 @@
1
+ // Work sessions: start, heartbeat, compaction, handoff, end, and presence.
2
+ //
3
+ // A session is the thread that ties a developer, an agent, a branch and a task
4
+ // together so the next agent can resume from records instead of a transcript.
5
+ // The heartbeat is `work_sessions.last_activity_at`, updated freely and
6
+ // silently; an activity event is only written at a boundary a human would want
7
+ // to read. Brief sections 12.1, 12.3, 12.5 and 13.
8
+
9
+ import { existsSync } from "node:fs";
10
+ import {
11
+ paths, query, mutate, create, currentProject, recordActivity, DbError,
12
+ } from "../db/store.mjs";
13
+ import { sanitizeExternal } from "../model/screening.mjs";
14
+ import { resolveIdentity } from "./identity.mjs";
15
+
16
+ /** No activity for four hours is idle. Idle is a healthy state, not an error. */
17
+ export const IDLE_AFTER_HOURS = 4;
18
+ const IDLE_AFTER_MS = IDLE_AFTER_HOURS * 60 * 60 * 1000;
19
+
20
+ /** A session is still live while it is being worked on or has just compacted. */
21
+ const LIVE_STATUSES = ["active", "compacted"];
22
+
23
+ const nowIso = () => new Date().toISOString();
24
+
25
+ const clean = (text) => {
26
+ const out = sanitizeExternal(text).trim();
27
+ return out || null;
28
+ };
29
+
30
+ const trim = (text, max = 240) => {
31
+ const value = clean(text);
32
+ if (!value) return null;
33
+ return value.length > max ? `${value.slice(0, max - 3)}...` : value;
34
+ };
35
+
36
+ // -------------------------------------------------------------------- start
37
+
38
+ /**
39
+ * Begin or rejoin a session for this developer, agent and branch.
40
+ *
41
+ * An agent that reconnects gets its live session back rather than a second one,
42
+ * so a restarted harness cannot leave a trail of sessions that all look active.
43
+ * Identity resolution happens first and in its own transaction, because it
44
+ * shells out to git.
45
+ */
46
+ export async function startSession(root, opts = {}) {
47
+ const identity = await resolveIdentity(root, opts);
48
+ const at = opts.at ?? nowIso();
49
+ const objective = clean(opts.objective);
50
+
51
+ return mutate(root, async (db) => {
52
+ const project = identity.project ?? await currentProject(db);
53
+ const { developer, agent, branch } = identity;
54
+
55
+ // An assignment outstrips whatever the caller thinks it is doing: if this
56
+ // actor already holds a task, that is the work in progress.
57
+ const assignment = await db.get(
58
+ `SELECT a.*, t.name AS task_name, t.status AS task_status
59
+ FROM task_assignments a JOIN tasks t ON t.id = a.task_id
60
+ WHERE a.active = 1 AND (a.developer_id IS ? OR a.agent_id IS ?)
61
+ ORDER BY a.assigned_at DESC LIMIT 1`,
62
+ developer?.id ?? null, agent?.id ?? null,
63
+ );
64
+ const taskId = opts.taskId ?? assignment?.task_id ?? null;
65
+
66
+ // A session that states no objective inherits the last one, because the
67
+ // work did not change purpose when the process restarted. Section 12.1 asks
68
+ // resume for everything the next session needs to carry on, and the
69
+ // objective is the first thing a reader looks for: without this, every
70
+ // resumed session reported nothing to carry on toward.
71
+ const carried = objective ?? (await db.get(
72
+ `SELECT objective FROM work_sessions
73
+ WHERE project_id = ? AND objective IS NOT NULL
74
+ ORDER BY started_at DESC LIMIT 1`,
75
+ project.id,
76
+ ))?.objective ?? null;
77
+
78
+ const live = await db.get(
79
+ `SELECT * FROM work_sessions
80
+ WHERE project_id = ? AND agent_id IS ? AND ended_at IS NULL
81
+ AND status IN (${LIVE_STATUSES.map(() => "?").join(",")})
82
+ ORDER BY started_at DESC LIMIT 1`,
83
+ project.id, agent?.id ?? null, ...LIVE_STATUSES,
84
+ );
85
+
86
+ if (live) {
87
+ await db.run(
88
+ `UPDATE work_sessions
89
+ SET last_activity_at = ?, status = 'active',
90
+ branch_id = coalesce(?, branch_id),
91
+ active_task_id = coalesce(?, active_task_id),
92
+ objective = coalesce(?, objective)
93
+ WHERE id = ?`,
94
+ at, branch?.id ?? null, taskId, carried, live.id,
95
+ );
96
+ const row = await db.get("SELECT * FROM work_sessions WHERE id = ?", live.id);
97
+ return { ...shapeSession(row), resumed: true, assignment: assignment ?? null };
98
+ }
99
+
100
+ const row = await create(db, "work_session", {
101
+ project_id: project.id,
102
+ developer_id: developer?.id ?? null,
103
+ agent_id: agent?.id ?? null,
104
+ branch_id: branch?.id ?? null,
105
+ active_task_id: taskId,
106
+ objective: carried,
107
+ started_at: at,
108
+ last_activity_at: at,
109
+ status: "active",
110
+ });
111
+
112
+ await recordActivity(db, project.id, {
113
+ type: "session_started",
114
+ summary: sessionSummary(developer, agent, branch, carried),
115
+ sessionId: row.id,
116
+ actorId: agent?.id ?? null,
117
+ actor: developer?.display_name ?? agent?.harness ?? "superdev",
118
+ taskId,
119
+ createdAt: at,
120
+ metadata: {
121
+ harness: identity.harness?.harness ?? agent?.harness ?? null,
122
+ hooks: identity.harness?.hooks ?? null,
123
+ branch: branch?.name ?? null,
124
+ head: branch?.head_revision ?? null,
125
+ },
126
+ });
127
+
128
+ return { ...shapeSession(row), resumed: false, assignment: assignment ?? null };
129
+ });
130
+ }
131
+
132
+ function sessionSummary(developer, agent, branch, objective) {
133
+ const who = developer?.display_name ?? "an unnamed developer";
134
+ const how = agent?.harness ? ` in ${agent.harness}` : "";
135
+ const where = branch?.name ? ` on ${branch.name}` : "";
136
+ return trim(`Session started by ${who}${how}${where}${objective ? `: ${objective}` : ""}`, 200);
137
+ }
138
+
139
+ // ---------------------------------------------------------------- heartbeat
140
+
141
+ /**
142
+ * Move the heartbeat, and record an event only when the caller names a boundary.
143
+ *
144
+ * Without a summary this writes timestamps and nothing else, which is what a
145
+ * file read or a shell command deserves. With a summary it appends one activity
146
+ * event, unless it repeats the session's previous event verbatim, because a loop
147
+ * that reports the same step twice is noise rather than history.
148
+ */
149
+ export async function touchSession(root, sessionId, opts = {}) {
150
+ const at = opts.at ?? nowIso();
151
+ const summary = trim(opts.summary, 300);
152
+ const type = opts.type ?? "specification_changed";
153
+ const git = opts.git ?? null;
154
+
155
+ return mutate(root, async (db) => {
156
+ const session = await requireLiveSession(db, sessionId);
157
+
158
+ await db.run(
159
+ `UPDATE work_sessions
160
+ SET last_activity_at = ?, active_task_id = coalesce(?, active_task_id)
161
+ WHERE id = ?`,
162
+ at, opts.taskId ?? null, sessionId,
163
+ );
164
+ if (session.agent_id) {
165
+ await db.run("UPDATE agents SET status = 'active', last_seen_at = ? WHERE id = ?", at, session.agent_id);
166
+ }
167
+ // Branch HEAD and dirty state, brief 12.3. `git` is computed by the caller
168
+ // before this transaction opens, never by shelling out from in here.
169
+ if (session.branch_id && git) {
170
+ await db.run(
171
+ "UPDATE branches SET head_revision = coalesce(?, head_revision), dirty = ?, last_seen_at = ? WHERE id = ?",
172
+ git.head ?? null, git.dirty ? 1 : 0, at, session.branch_id,
173
+ );
174
+ }
175
+
176
+ if (!summary) return { sessionId, lastActivityAt: at, recorded: false };
177
+
178
+ const previous = await db.get(
179
+ "SELECT event_type, summary FROM activity_events WHERE session_id = ? ORDER BY sequence DESC LIMIT 1",
180
+ sessionId,
181
+ );
182
+ if (previous && previous.event_type === type && previous.summary === summary) {
183
+ return { sessionId, lastActivityAt: at, recorded: false, reason: "repeat of the previous event" };
184
+ }
185
+
186
+ const event = await recordActivity(db, session.project_id, {
187
+ type,
188
+ summary,
189
+ sessionId,
190
+ actorId: session.agent_id,
191
+ actor: opts.actor,
192
+ taskId: opts.taskId ?? session.active_task_id ?? null,
193
+ featureId: opts.featureId ?? null,
194
+ createdAt: at,
195
+ metadata: opts.metadata ?? {},
196
+ });
197
+ return { sessionId, lastActivityAt: at, recorded: true, event };
198
+ });
199
+ }
200
+
201
+ // --------------------------------------------------------------- compaction
202
+
203
+ /**
204
+ * Persist everything brief 12.5 lists, so the next agent needs no conversation.
205
+ *
206
+ * The lists become memory entries in the same transaction rather than through
207
+ * the memory module, because that module opens its own write connection and the
208
+ * engine allows one writer at a time. The individual entries record no activity
209
+ * of their own; one session_compacted event stands for the whole compaction.
210
+ */
211
+ export async function compactSession(root, sessionId, details = {}) {
212
+ const at = details.at ?? nowIso();
213
+ return mutate(root, async (db) => {
214
+ const session = await requireLiveSession(db, sessionId);
215
+ const written = await persistWorkingState(db, session, details, at);
216
+
217
+ await db.run(
218
+ `UPDATE work_sessions
219
+ SET status = 'compacted', last_activity_at = ?,
220
+ objective = coalesce(?, objective),
221
+ active_task_id = coalesce(?, active_task_id),
222
+ outcome = coalesce(?, outcome),
223
+ next_action = coalesce(?, next_action)
224
+ WHERE id = ?`,
225
+ at, clean(details.objective), details.activeTaskId ?? null,
226
+ trim(details.lastVerifiedResult?.summary ?? details.lastVerifiedResult, 300),
227
+ trim(details.nextAction, 300), sessionId,
228
+ );
229
+
230
+ await recordActivity(db, session.project_id, {
231
+ type: "session_compacted",
232
+ summary: trim(
233
+ `Session ${sessionId} compacted. ${written.memories.length} memories kept. Next action: ${clean(details.nextAction) ?? "not recorded"}`,
234
+ 300,
235
+ ),
236
+ sessionId,
237
+ actorId: session.agent_id,
238
+ actor: details.actor,
239
+ taskId: details.activeTaskId ?? session.active_task_id ?? null,
240
+ featureId: written.featureId,
241
+ createdAt: at,
242
+ metadata: { memories: written.memories, counts: written.counts },
243
+ });
244
+
245
+ const row = await db.get("SELECT * FROM work_sessions WHERE id = ?", sessionId);
246
+ return { ...shapeSession(row), memories: written.memories };
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Hand the work to whoever comes next: the same persistence as compaction, plus
252
+ * the session is closed and its claim released, because an assignment held by a
253
+ * session that is over is a claim nobody is working.
254
+ */
255
+ export async function handoffSession(root, sessionId, details = {}) {
256
+ const at = details.at ?? nowIso();
257
+ return mutate(root, async (db) => {
258
+ const session = await requireLiveSession(db, sessionId);
259
+ const handoff = clean(details.handoff ?? details.note);
260
+ const written = await persistWorkingState(db, session, {
261
+ ...details,
262
+ handoff: handoff ?? details.nextAction,
263
+ }, at);
264
+
265
+ await db.run(
266
+ `UPDATE work_sessions
267
+ SET status = 'handed_off', ended_at = ?, last_activity_at = ?,
268
+ objective = coalesce(?, objective),
269
+ active_task_id = coalesce(?, active_task_id),
270
+ outcome = coalesce(?, outcome),
271
+ handoff = coalesce(?, handoff),
272
+ next_action = coalesce(?, next_action)
273
+ WHERE id = ?`,
274
+ at, at, clean(details.objective), details.activeTaskId ?? null,
275
+ trim(details.outcome ?? details.lastVerifiedResult?.summary ?? details.lastVerifiedResult, 300),
276
+ trim(handoff, 1000), trim(details.nextAction, 300), sessionId,
277
+ );
278
+
279
+ const released = await releaseAssignments(db, session, at, details.actor);
280
+ await closeAgent(db, session, at);
281
+
282
+ await recordActivity(db, session.project_id, {
283
+ type: "session_handed_off",
284
+ summary: trim(
285
+ `Session ${sessionId} handed off. Next action: ${clean(details.nextAction) ?? handoff ?? "not recorded"}`,
286
+ 300,
287
+ ),
288
+ sessionId,
289
+ actorId: session.agent_id,
290
+ actor: details.actor,
291
+ taskId: details.activeTaskId ?? session.active_task_id ?? null,
292
+ featureId: written.featureId,
293
+ createdAt: at,
294
+ metadata: { to: clean(details.to), released, memories: written.memories },
295
+ });
296
+
297
+ const row = await db.get("SELECT * FROM work_sessions WHERE id = ?", sessionId);
298
+ return { ...shapeSession(row), memories: written.memories, released };
299
+ });
300
+ }
301
+
302
+ /** Close the session, release any claim it holds, and say how it ended. */
303
+ export async function endSession(root, sessionId, details = {}) {
304
+ const at = details.at ?? nowIso();
305
+ return mutate(root, async (db) => {
306
+ const session = await db.get("SELECT * FROM work_sessions WHERE id = ?", sessionId);
307
+ if (!session) throw new DbError("E_NOT_FOUND", `Session ${sessionId} does not exist.`);
308
+ if (session.ended_at) return shapeSession(session);
309
+
310
+ const written = details.summarize === false
311
+ ? { memories: [], counts: {}, featureId: null }
312
+ : await persistWorkingState(db, session, details, at);
313
+
314
+ await db.run(
315
+ `UPDATE work_sessions
316
+ SET status = 'ended', ended_at = ?, last_activity_at = ?,
317
+ outcome = coalesce(?, outcome),
318
+ next_action = coalesce(?, next_action)
319
+ WHERE id = ?`,
320
+ at, at, trim(details.outcome, 1000), trim(details.nextAction, 300), sessionId,
321
+ );
322
+
323
+ const released = await releaseAssignments(db, session, at, details.actor);
324
+ await closeAgent(db, session, at);
325
+
326
+ await recordActivity(db, session.project_id, {
327
+ type: "session_ended",
328
+ summary: trim(
329
+ `Session ${sessionId} ended. ${clean(details.outcome) ?? "No outcome recorded."}`,
330
+ 300,
331
+ ),
332
+ sessionId,
333
+ actorId: session.agent_id,
334
+ actor: details.actor,
335
+ taskId: session.active_task_id,
336
+ featureId: written.featureId,
337
+ createdAt: at,
338
+ metadata: { released, memories: written.memories },
339
+ });
340
+
341
+ const row = await db.get("SELECT * FROM work_sessions WHERE id = ?", sessionId);
342
+ return { ...shapeSession(row), memories: written.memories, released };
343
+ });
344
+ }
345
+
346
+ // ------------------------------------------------------------------ reading
347
+
348
+ /**
349
+ * The session to resume. Live sessions win; with `includeEnded` the most recent
350
+ * finished one is returned so a resume can still read its handoff.
351
+ */
352
+ export async function activeSession(root, filter = {}) {
353
+ // Reads run wherever the caller happens to be. A missing database is "nobody
354
+ // is working here", not a fault, and reading one that was never created
355
+ // raises an engine I/O error that reads like a broken install.
356
+ if (!existsSync(paths(root).db)) return null;
357
+ return query(root, async (db) => {
358
+ const where = [];
359
+ const params = [];
360
+ if (filter.sessionId) { where.push("s.id = ?"); params.push(filter.sessionId); }
361
+ if (filter.developerId) { where.push("s.developer_id = ?"); params.push(filter.developerId); }
362
+ if (filter.agentId) { where.push("s.agent_id = ?"); params.push(filter.agentId); }
363
+ if (filter.branchId) { where.push("s.branch_id = ?"); params.push(filter.branchId); }
364
+ if (!filter.includeEnded) {
365
+ where.push(`s.ended_at IS NULL AND s.status IN (${LIVE_STATUSES.map(() => "?").join(",")})`);
366
+ params.push(...LIVE_STATUSES);
367
+ }
368
+
369
+ const row = await db.get(
370
+ `SELECT s.*, d.display_name AS developer_name, a.harness AS agent_harness,
371
+ a.model_label AS agent_model, b.name AS branch_name,
372
+ b.head_revision AS branch_head, b.dirty AS branch_dirty,
373
+ t.name AS task_name, t.status AS task_status
374
+ FROM work_sessions s
375
+ LEFT JOIN developers d ON d.id = s.developer_id
376
+ LEFT JOIN agents a ON a.id = s.agent_id
377
+ LEFT JOIN branches b ON b.id = s.branch_id
378
+ LEFT JOIN tasks t ON t.id = s.active_task_id
379
+ ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
380
+ ORDER BY CASE WHEN s.ended_at IS NULL THEN 0 ELSE 1 END,
381
+ coalesce(s.last_activity_at, s.started_at) DESC
382
+ LIMIT 1`,
383
+ ...params,
384
+ );
385
+ return row ? shapeSession(row) : null;
386
+ });
387
+ }
388
+
389
+ /**
390
+ * Who is here, on what, and when they were last seen. Feeds the Team view.
391
+ * A developer, agent or branch with no activity for four hours reads as idle,
392
+ * which is normal and is not reported as a fault.
393
+ */
394
+ export async function presence(root, opts = {}) {
395
+ const limit = Math.max(1, Math.min(Number(opts.sessionLimit) || 25, 200));
396
+ if (!existsSync(paths(root).db)) return emptyPresence();
397
+ return query(root, async (db) => {
398
+ const project = await currentProject(db);
399
+ if (!project) return emptyPresence();
400
+
401
+ const now = Date.now();
402
+ const [developers, agents, branches, sessions] = await Promise.all([
403
+ db.all("SELECT * FROM developers WHERE project_id = ? ORDER BY display_name", project.id),
404
+ db.all(
405
+ `SELECT a.* FROM agents a JOIN developers d ON d.id = a.developer_id
406
+ WHERE d.project_id = ? ORDER BY coalesce(a.last_seen_at, '') DESC`, project.id),
407
+ db.all(
408
+ "SELECT * FROM branches WHERE project_id = ? ORDER BY coalesce(last_seen_at, '') DESC", project.id),
409
+ db.all(
410
+ `SELECT s.*, d.display_name AS developer_name, a.harness AS agent_harness,
411
+ a.model_label AS agent_model, b.name AS branch_name,
412
+ b.head_revision AS branch_head, b.dirty AS branch_dirty,
413
+ t.name AS task_name, t.status AS task_status
414
+ FROM work_sessions s
415
+ LEFT JOIN developers d ON d.id = s.developer_id
416
+ LEFT JOIN agents a ON a.id = s.agent_id
417
+ LEFT JOIN branches b ON b.id = s.branch_id
418
+ LEFT JOIN tasks t ON t.id = s.active_task_id
419
+ WHERE s.project_id = ?
420
+ ORDER BY CASE WHEN s.ended_at IS NULL THEN 0 ELSE 1 END,
421
+ coalesce(s.last_activity_at, s.started_at) DESC
422
+ LIMIT ${limit}`, project.id),
423
+ ]);
424
+
425
+ const latestByDeveloper = new Map();
426
+ const latestByAgent = new Map();
427
+ const latestByBranch = new Map();
428
+ for (const s of sessions) {
429
+ const stamp = s.last_activity_at ?? s.started_at;
430
+ keepLatest(latestByDeveloper, s.developer_id, stamp);
431
+ keepLatest(latestByAgent, s.agent_id, stamp);
432
+ keepLatest(latestByBranch, s.branch_id, stamp);
433
+ }
434
+
435
+ return {
436
+ generatedAt: new Date(now).toISOString(),
437
+ idleAfterHours: IDLE_AFTER_HOURS,
438
+ note: `A developer, agent or branch with no activity for ${IDLE_AFTER_HOURS} hours is idle. Idle is a normal state.`,
439
+ developers: developers.map((d) => ({
440
+ id: d.id,
441
+ displayName: d.display_name,
442
+ externalIdentity: d.external_identity,
443
+ status: d.status,
444
+ lastActivityAt: latestByDeveloper.get(d.id) ?? null,
445
+ state: liveness(latestByDeveloper.get(d.id), now, d.status === "inactive"),
446
+ agents: agents.filter((a) => a.developer_id === d.id).map((a) => a.id),
447
+ })),
448
+ agents: agents.map((a) => ({
449
+ id: a.id,
450
+ developerId: a.developer_id,
451
+ harness: a.harness,
452
+ modelLabel: a.model_label,
453
+ status: a.status,
454
+ lastSeenAt: a.last_seen_at ?? latestByAgent.get(a.id) ?? null,
455
+ state: liveness(a.last_seen_at ?? latestByAgent.get(a.id), now, a.status === "ended"),
456
+ })),
457
+ branches: branches.map((b) => ({
458
+ id: b.id,
459
+ name: b.name,
460
+ headRevision: b.head_revision,
461
+ dirty: Boolean(b.dirty),
462
+ worktreeFingerprint: b.worktree_fingerprint,
463
+ status: b.status,
464
+ lastSeenAt: b.last_seen_at ?? latestByBranch.get(b.id) ?? null,
465
+ state: liveness(b.last_seen_at ?? latestByBranch.get(b.id), now, b.status !== "active"),
466
+ })),
467
+ sessions: sessions.map((s) => shapeSession(s, now)),
468
+ };
469
+ });
470
+ }
471
+
472
+ const emptyPresence = () => ({
473
+ generatedAt: nowIso(),
474
+ idleAfterHours: IDLE_AFTER_HOURS,
475
+ note: "No Superdev project in this directory yet.",
476
+ developers: [], agents: [], branches: [], sessions: [],
477
+ });
478
+
479
+ function keepLatest(map, key, stamp) {
480
+ if (!key || !stamp) return;
481
+ const held = map.get(key);
482
+ if (!held || stamp > held) map.set(key, stamp);
483
+ }
484
+
485
+ const liveness = (stamp, now, ended) => {
486
+ if (ended) return "ended";
487
+ if (!stamp) return "idle";
488
+ return now - Date.parse(stamp) > IDLE_AFTER_MS ? "idle" : "active";
489
+ };
490
+
491
+ // ------------------------------------------------------------------ helpers
492
+
493
+ async function requireLiveSession(db, sessionId) {
494
+ const session = await db.get("SELECT * FROM work_sessions WHERE id = ?", sessionId);
495
+ if (!session) throw new DbError("E_NOT_FOUND", `Session ${sessionId} does not exist.`);
496
+ if (session.ended_at) {
497
+ throw new DbError(
498
+ "E_SESSION_ENDED",
499
+ `Session ${sessionId} ended at ${session.ended_at}. Start a new session before recording more work.`,
500
+ );
501
+ }
502
+ return session;
503
+ }
504
+
505
+ /**
506
+ * The 12.5 packet, written as memory entries. Each list may hold plain strings
507
+ * or objects; both end up as a title and a body, cleaned of secrets and home
508
+ * paths before storage.
509
+ */
510
+ async function persistWorkingState(db, session, details, at) {
511
+ const taskId = details.activeTaskId ?? session.active_task_id ?? null;
512
+ const task = taskId ? await db.get("SELECT * FROM tasks WHERE id = ?", taskId) : null;
513
+ const featureId = details.featureId ?? task?.feature_id ?? null;
514
+
515
+ if (details.git && session.branch_id) {
516
+ await db.run(
517
+ "UPDATE branches SET head_revision = coalesce(?, head_revision), dirty = ?, last_seen_at = ? WHERE id = ?",
518
+ details.git.head ?? null, details.git.dirty ? 1 : 0, at, session.branch_id,
519
+ );
520
+ }
521
+
522
+ const groups = [
523
+ ["decision", details.decisions, "Decision"],
524
+ ["blocker", details.blockers, "Blocker"],
525
+ ["outcome", details.scopeChanges, "Scope change"],
526
+ ["unresolved_question", details.pendingDocumentation, "Pending documentation"],
527
+ ["outcome", details.lastVerifiedResult, "Last verified result"],
528
+ ["handoff", details.handoff, "Handoff"],
529
+ ];
530
+
531
+ const memories = [];
532
+ const counts = {};
533
+ for (const [kind, value, label] of groups) {
534
+ const items = normalize(value, label);
535
+ if (items.length) counts[label] = (counts[label] ?? 0) + items.length;
536
+ for (const item of items) {
537
+ const row = await create(db, "memory_entry", {
538
+ project_id: session.project_id,
539
+ session_id: session.id,
540
+ task_id: taskId,
541
+ feature_id: featureId,
542
+ kind,
543
+ title: item.title,
544
+ content: item.content,
545
+ source_ref: item.sourceRef,
546
+ epistemic_status: item.epistemicStatus,
547
+ embedding: null,
548
+ created_at: at,
549
+ });
550
+ memories.push(row.id);
551
+ }
552
+ }
553
+
554
+ const summary = compactionSummary(session, details, task);
555
+ if (summary) {
556
+ const row = await create(db, "memory_entry", {
557
+ project_id: session.project_id,
558
+ session_id: session.id,
559
+ task_id: taskId,
560
+ feature_id: featureId,
561
+ kind: "summary",
562
+ title: `Session ${session.id} state`,
563
+ content: summary,
564
+ epistemic_status: "confirmed",
565
+ embedding: null,
566
+ created_at: at,
567
+ });
568
+ memories.push(row.id);
569
+ }
570
+
571
+ return { memories, counts, featureId };
572
+ }
573
+
574
+ function normalize(value, label) {
575
+ const list = Array.isArray(value) ? value : value ? [value] : [];
576
+ return list
577
+ .map((item) => {
578
+ const source = typeof item === "string" ? { content: item } : item ?? {};
579
+ const content = clean(source.content ?? source.note ?? source.summary ?? source.title);
580
+ if (!content) return null;
581
+ const named = clean(source.title ?? source.summary ?? source.id);
582
+ return {
583
+ title: trim(named ? `${label}: ${named}` : `${label}: ${content}`, 160),
584
+ content,
585
+ sourceRef: clean(source.sourceRef ?? source.reference ?? source.id),
586
+ epistemicStatus: EPISTEMIC.has(source.epistemicStatus) ? source.epistemicStatus : "confirmed",
587
+ };
588
+ })
589
+ .filter(Boolean);
590
+ }
591
+
592
+ const EPISTEMIC = new Set(["confirmed", "inferred", "assumed", "unknown", "contradicted"]);
593
+
594
+ function compactionSummary(session, details, task) {
595
+ const lines = [
596
+ `Objective: ${clean(details.objective) ?? clean(session.objective) ?? "not recorded"}`,
597
+ `Active task: ${task ? `${task.id} ${task.name} (${task.status})` : "none"}`,
598
+ `Branch: ${clean(details.git?.branch) ?? "unknown"} at ${details.git?.head?.slice(0, 7) ?? "unknown"}${details.git?.dirty ? ", uncommitted changes" : ""}`,
599
+ `Next action: ${clean(details.nextAction) ?? "not recorded"}`,
600
+ ];
601
+ return lines.join("\n");
602
+ }
603
+
604
+ async function releaseAssignments(db, session, at, actor) {
605
+ const held = await db.all(
606
+ "SELECT * FROM task_assignments WHERE session_id = ? AND active = 1", session.id,
607
+ );
608
+ if (!held.length) return [];
609
+
610
+ await db.run(
611
+ "UPDATE task_assignments SET active = 0, released_at = ? WHERE session_id = ? AND active = 1",
612
+ at, session.id,
613
+ );
614
+ for (const assignment of held) {
615
+ await recordActivity(db, session.project_id, {
616
+ type: "task_released",
617
+ summary: `Task ${assignment.task_id} released when session ${session.id} finished.`,
618
+ sessionId: session.id,
619
+ actorId: session.agent_id,
620
+ actor,
621
+ taskId: assignment.task_id,
622
+ createdAt: at,
623
+ metadata: { assignment: assignment.id },
624
+ });
625
+ }
626
+ return held.map((a) => ({ id: a.id, taskId: a.task_id }));
627
+ }
628
+
629
+ async function closeAgent(db, session, at) {
630
+ if (!session.agent_id) return;
631
+ await db.run("UPDATE agents SET status = 'ended', last_seen_at = ? WHERE id = ?", at, session.agent_id);
632
+ }
633
+
634
+ /** Statuses a task never comes back from, so it stops being the active one. */
635
+ const TERMINAL_TASK = new Set(["complete", "completed", "cancelled", "superseded"]);
636
+
637
+ function shapeSession(row, now = Date.now()) {
638
+ const lastActivityAt = row.last_activity_at ?? row.started_at ?? null;
639
+ return {
640
+ id: row.id,
641
+ projectId: row.project_id,
642
+ developerId: row.developer_id,
643
+ developerName: row.developer_name ?? null,
644
+ agentId: row.agent_id,
645
+ harness: row.agent_harness ?? null,
646
+ modelLabel: row.agent_model ?? null,
647
+ branchId: row.branch_id,
648
+ branchName: row.branch_name ?? null,
649
+ branchHead: row.branch_head ?? null,
650
+ branchDirty: row.branch_dirty === undefined ? null : Boolean(row.branch_dirty),
651
+ // A task that has reached a terminal state is not the active task, however
652
+ // recently it was. active_task_id is written with coalesce and is therefore
653
+ // only ever set, never cleared, so a session goes on pointing at work that
654
+ // finished. Every reader then reports a completed or cancelled task as the
655
+ // thing to pick up next, and the session start hook tells a person to move
656
+ // a cancelled task to in progress, which it can never do.
657
+ //
658
+ // Resolved on read rather than by clearing the column, so existing sessions
659
+ // are correct immediately and no reader has to remember this rule.
660
+ ...(TERMINAL_TASK.has(String(row.task_status ?? ""))
661
+ ? { activeTaskId: null, activeTaskName: null, activeTaskStatus: null,
662
+ lastTaskId: row.active_task_id, lastTaskName: row.task_name ?? null,
663
+ lastTaskStatus: row.task_status ?? null }
664
+ : { activeTaskId: row.active_task_id,
665
+ activeTaskName: row.task_name ?? null,
666
+ activeTaskStatus: row.task_status ?? null }),
667
+ objective: row.objective,
668
+ startedAt: row.started_at,
669
+ lastActivityAt,
670
+ endedAt: row.ended_at,
671
+ outcome: row.outcome,
672
+ handoff: row.handoff,
673
+ nextAction: row.next_action,
674
+ status: row.status,
675
+ live: !row.ended_at && LIVE_STATUSES.includes(row.status),
676
+ state: liveness(lastActivityAt, now, Boolean(row.ended_at)),
677
+ idle: !row.ended_at && Boolean(lastActivityAt) && now - Date.parse(lastActivityAt) > IDLE_AFTER_MS,
678
+ };
679
+ }