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.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CODE_OF_CONDUCT.md +109 -0
- package/CONTRIBUTING.md +205 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +1051 -0
- package/SECURITY.md +135 -0
- package/THIRD-PARTY-NOTICES.md +253 -0
- package/hooks/hooks.json +61 -0
- package/hooks/run.mjs +88 -0
- package/package.json +65 -0
- package/references/confidentiality.md +48 -0
- package/references/evidence-and-risk.md +73 -0
- package/references/operating-model.md +105 -0
- package/references/platform-capabilities.md +51 -0
- package/references/project-record.md +91 -0
- package/references/provider-contracts.md +102 -0
- package/scripts/doctor/doctor.mjs +539 -0
- package/scripts/privacy/scan-history.mjs +163 -0
- package/scripts/privacy/scan.mjs +368 -0
- package/scripts/validate/README.md +94 -0
- package/scripts/validate/common.mjs +149 -0
- package/scripts/validate/data-model.mjs +225 -0
- package/scripts/validate/dependencies.mjs +69 -0
- package/scripts/validate/docs-templates.mjs +99 -0
- package/scripts/validate/footprint.mjs +78 -0
- package/scripts/validate/imports.mjs +61 -0
- package/scripts/validate/manifests.mjs +173 -0
- package/scripts/validate/markdown.mjs +123 -0
- package/scripts/validate/migrations.mjs +187 -0
- package/scripts/validate/no-tests.mjs +136 -0
- package/scripts/validate/privacy.mjs +115 -0
- package/scripts/validate/record-links.mjs +127 -0
- package/scripts/validate/skill-commands.mjs +190 -0
- package/scripts/validate/skills.mjs +112 -0
- package/scripts/validate/specification.mjs +88 -0
- package/scripts/validate/style.mjs +78 -0
- package/scripts/validate/validate-all.mjs +175 -0
- package/skills/debug/SKILL.md +68 -0
- package/skills/decision/SKILL.md +99 -0
- package/skills/docs/SKILL.md +86 -0
- package/skills/docs/assets/fragments/api/events.md +14 -0
- package/skills/docs/assets/fragments/api/graphql.md +14 -0
- package/skills/docs/assets/fragments/api/local-only.md +13 -0
- package/skills/docs/assets/fragments/api/rest.md +13 -0
- package/skills/docs/assets/fragments/api/rpc.md +13 -0
- package/skills/docs/assets/fragments/async/event-bus.md +13 -0
- package/skills/docs/assets/fragments/async/none.md +11 -0
- package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
- package/skills/docs/assets/fragments/async/queue.md +13 -0
- package/skills/docs/assets/fragments/async/scheduler.md +13 -0
- package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
- package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
- package/skills/docs/assets/fragments/data/document.md +13 -0
- package/skills/docs/assets/fragments/data/external-saas.md +13 -0
- package/skills/docs/assets/fragments/data/key-value.md +13 -0
- package/skills/docs/assets/fragments/data/none.md +12 -0
- package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
- package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
- package/skills/docs/assets/fragments/env/conventional.md +13 -0
- package/skills/docs/assets/fragments/env/envx.md +13 -0
- package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
- package/skills/docs/assets/fragments/env/none.md +11 -0
- package/skills/docs/assets/fragments/ui/api-only.md +13 -0
- package/skills/docs/assets/fragments/ui/cli.md +14 -0
- package/skills/docs/assets/fragments/ui/desktop.md +14 -0
- package/skills/docs/assets/fragments/ui/mobile.md +14 -0
- package/skills/docs/assets/fragments/ui/web.md +14 -0
- package/skills/docs/assets/templates/adr.md +62 -0
- package/skills/docs/assets/templates/api.md +30 -0
- package/skills/docs/assets/templates/architecture.md +52 -0
- package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
- package/skills/docs/assets/templates/compliance.md +29 -0
- package/skills/docs/assets/templates/data-schema.md +43 -0
- package/skills/docs/assets/templates/feature.md +43 -0
- package/skills/docs/assets/templates/foundations.md +55 -0
- package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
- package/skills/docs/assets/templates/module-inventory.md +19 -0
- package/skills/docs/assets/templates/module.md +50 -0
- package/skills/docs/assets/templates/nfr.md +22 -0
- package/skills/docs/assets/templates/observability.md +28 -0
- package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
- package/skills/docs/assets/templates/project-summary.md +44 -0
- package/skills/docs/assets/templates/roles-permissions.md +26 -0
- package/skills/docs/assets/templates/test-plan.md +23 -0
- package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
- package/skills/docs/references/adr-authoring.md +24 -0
- package/skills/docs/references/apis-and-data.md +23 -0
- package/skills/docs/references/capability-fragments.md +25 -0
- package/skills/docs/references/change-tracking.md +62 -0
- package/skills/docs/references/diagrams.md +31 -0
- package/skills/docs/references/discovery.md +59 -0
- package/skills/docs/references/edge-cases.md +45 -0
- package/skills/docs/references/foundations-modules.md +18 -0
- package/skills/docs/references/ingestion.md +52 -0
- package/skills/docs/references/initialize-adopt.md +25 -0
- package/skills/docs/references/module-decomposition.md +38 -0
- package/skills/docs/references/profiles.md +60 -0
- package/skills/docs/references/quality-attributes.md +27 -0
- package/skills/docs/references/reverse-engineer.md +21 -0
- package/skills/docs/references/spec-depths.md +31 -0
- package/skills/docs/references/summarize.md +18 -0
- package/skills/docs/references/surfaces-and-actions.md +24 -0
- package/skills/docs/references/validation.md +47 -0
- package/skills/docs/references/workflows-and-jobs.md +25 -0
- package/skills/docs/scripts/ingest.mjs +984 -0
- package/skills/docs/scripts/profile-detect.mjs +299 -0
- package/skills/docs/scripts/screen.mjs +96 -0
- package/skills/docs/scripts/template-lint.mjs +143 -0
- package/skills/docs/scripts/validate-docs.mjs +363 -0
- package/skills/doctor/SKILL.md +167 -0
- package/skills/feature/SKILL.md +132 -0
- package/skills/init/SKILL.md +137 -0
- package/skills/project/SKILL.md +261 -0
- package/skills/project/references/commands.md +213 -0
- package/skills/resume/SKILL.md +79 -0
- package/skills/review/SKILL.md +91 -0
- package/skills/status/SKILL.md +85 -0
- package/skills/task/SKILL.md +183 -0
- package/src/cli/product-map.mjs +468 -0
- package/src/cli/render.mjs +544 -0
- package/src/cli.mjs +2774 -0
- package/src/cloud/crypto.mjs +118 -0
- package/src/cloud/merge.mjs +186 -0
- package/src/cloud/policy.mjs +115 -0
- package/src/cloud/sync.mjs +512 -0
- package/src/cloud/transport.mjs +116 -0
- package/src/db/connect.mjs +189 -0
- package/src/db/maintenance.mjs +419 -0
- package/src/db/migrate.mjs +188 -0
- package/src/db/migrations/001_initial.sql +1024 -0
- package/src/db/migrations/002_docs_coverage.sql +126 -0
- package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
- package/src/db/migrations/004_task_categories.sql +59 -0
- package/src/db/migrations/005_executable_evidence.sql +32 -0
- package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
- package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
- package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
- package/src/db/migrations/009_retired_documents.sql +54 -0
- package/src/db/migrations/010_test_plan_evidence.sql +20 -0
- package/src/db/migrations/011_criterion_waiver.sql +13 -0
- package/src/db/migrations/012_synchronization.sql +56 -0
- package/src/db/store.mjs +327 -0
- package/src/decisions/record.mjs +167 -0
- package/src/docs/proposals.mjs +871 -0
- package/src/docs/render.mjs +1424 -0
- package/src/docs/templates.mjs +1281 -0
- package/src/features/acceptance.mjs +315 -0
- package/src/features/specify.mjs +248 -0
- package/src/init/discovery.mjs +901 -0
- package/src/init/index.mjs +784 -0
- package/src/init/questions.mjs +562 -0
- package/src/memory/benchmark.mjs +171 -0
- package/src/memory/capture.mjs +135 -0
- package/src/memory/consolidate.mjs +255 -0
- package/src/memory/index.mjs +810 -0
- package/src/model/ids.mjs +185 -0
- package/src/model/screening.mjs +150 -0
- package/src/model/toolkit.mjs +258 -0
- package/src/model/vocabulary.mjs +190 -0
- package/src/product/assumptions.mjs +120 -0
- package/src/product/changes.mjs +180 -0
- package/src/product/test-plans.mjs +183 -0
- package/src/progress/index.mjs +1364 -0
- package/src/runtime/harness.mjs +264 -0
- package/src/runtime/hooks.mjs +1021 -0
- package/src/runtime/identity.mjs +305 -0
- package/src/runtime/resume.mjs +343 -0
- package/src/runtime/session.mjs +679 -0
- package/src/runtime/version.mjs +315 -0
- package/src/service/assets/control-center.html +206 -0
- package/src/service/assets/control-center.manifest.json +7 -0
- package/src/service/manage.mjs +542 -0
- package/src/service/mutations.mjs +860 -0
- package/src/service/read-model.mjs +1557 -0
- package/src/service/server.mjs +783 -0
- package/src/tasks/categories.mjs +154 -0
- package/src/tasks/derive.mjs +977 -0
- package/src/tasks/lifecycle.mjs +830 -0
- 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
|
+
}
|