@smartmemory/compose 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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1014 -0
  3. package/bin/compose.js +1515 -0
  4. package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
  5. package/dist/assets/arc-SxJ2J1sh.js +1 -0
  6. package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
  7. package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
  8. package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
  9. package/dist/assets/channel-DGElom1e.js +1 -0
  10. package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
  11. package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
  12. package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
  13. package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
  14. package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
  15. package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
  16. package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
  17. package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
  18. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
  19. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
  20. package/dist/assets/clone-DUJKJXd7.js +1 -0
  21. package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
  22. package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
  23. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  24. package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
  25. package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
  26. package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
  27. package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
  28. package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
  29. package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
  30. package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
  31. package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
  32. package/dist/assets/graph-D0Cfv00Y.js +1 -0
  33. package/dist/assets/index-CUd6pFGF.css +1 -0
  34. package/dist/assets/index-DReRlzZI.js +1144 -0
  35. package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
  36. package/dist/assets/init-Gi6I4Gst.js +1 -0
  37. package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
  38. package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
  39. package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
  40. package/dist/assets/katex-DkKDou_j.js +257 -0
  41. package/dist/assets/layout-Bj72wOEB.js +1 -0
  42. package/dist/assets/linear-BRFo114D.js +1 -0
  43. package/dist/assets/min-GCHnKlJS.js +1 -0
  44. package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
  45. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  46. package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
  47. package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
  48. package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
  49. package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
  50. package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
  51. package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
  52. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
  53. package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
  54. package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
  55. package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
  56. package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
  57. package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
  58. package/dist/index.html +30 -0
  59. package/lib/agent-chains.js +65 -0
  60. package/lib/agent-string.js +86 -0
  61. package/lib/budget-ledger.js +86 -0
  62. package/lib/build-all.js +162 -0
  63. package/lib/build-dag.js +120 -0
  64. package/lib/build-stream-writer.js +190 -0
  65. package/lib/build.js +2997 -0
  66. package/lib/capability-checker.js +53 -0
  67. package/lib/cert-inject.js +38 -0
  68. package/lib/cli-progress.js +483 -0
  69. package/lib/constants.js +69 -0
  70. package/lib/cross-layer-audit.js +84 -0
  71. package/lib/debug-discipline.js +173 -0
  72. package/lib/feature-json.js +106 -0
  73. package/lib/gate-prompt.js +291 -0
  74. package/lib/gate-tiers.js +194 -0
  75. package/lib/health-history.js +119 -0
  76. package/lib/health-score.js +227 -0
  77. package/lib/ideabox.js +570 -0
  78. package/lib/import.js +244 -0
  79. package/lib/migrate-roadmap.js +94 -0
  80. package/lib/model-pricing.js +67 -0
  81. package/lib/new.js +413 -0
  82. package/lib/pipeline-cli.js +489 -0
  83. package/lib/plan-parser.js +103 -0
  84. package/lib/qa-scoping.js +474 -0
  85. package/lib/questionnaire.js +200 -0
  86. package/lib/resolve-port.js +7 -0
  87. package/lib/result-normalizer.js +349 -0
  88. package/lib/review-lenses.js +166 -0
  89. package/lib/roadmap-gen.js +210 -0
  90. package/lib/roadmap-parser.js +176 -0
  91. package/lib/server-probe.js +23 -0
  92. package/lib/staleness.js +87 -0
  93. package/lib/step-prompt.js +260 -0
  94. package/lib/step-validator.js +49 -0
  95. package/lib/stratum-mcp-client.js +365 -0
  96. package/lib/team-flag.js +46 -0
  97. package/lib/test-bootstrap.js +401 -0
  98. package/lib/triage.js +274 -0
  99. package/lib/vision-writer.js +391 -0
  100. package/package.json +111 -0
  101. package/pipelines/bug-fix.stratum.yaml +230 -0
  102. package/pipelines/build.stratum.yaml +498 -0
  103. package/pipelines/content.stratum.yaml +112 -0
  104. package/pipelines/coverage-sweep.stratum.yaml +52 -0
  105. package/pipelines/refactor.stratum.yaml +169 -0
  106. package/pipelines/research.stratum.yaml +88 -0
  107. package/pipelines/review-fix.stratum.yaml +109 -0
  108. package/presets/team-feature.stratum.yaml +105 -0
  109. package/presets/team-research.stratum.yaml +108 -0
  110. package/presets/team-review.stratum.yaml +106 -0
  111. package/scripts/agent-activity-hook.sh +31 -0
  112. package/scripts/agent-error-hook.sh +28 -0
  113. package/scripts/analyze-orphans.mjs +50 -0
  114. package/scripts/find-orphans.mjs +26 -0
  115. package/scripts/fix-phases.mjs +49 -0
  116. package/scripts/generate-stratum-spec.mjs +137 -0
  117. package/scripts/import-roadmap.mjs +116 -0
  118. package/scripts/phase-audit.mjs +33 -0
  119. package/scripts/run-pipeline.mjs +314 -0
  120. package/scripts/session-end-hook.sh +18 -0
  121. package/scripts/session-start-hook.sh +38 -0
  122. package/scripts/vision-hook.sh +104 -0
  123. package/scripts/vision-track.mjs +554 -0
  124. package/scripts/wire-all-orphans.mjs +108 -0
  125. package/scripts/wire-orphans.mjs +164 -0
  126. package/server/activity-routes.js +123 -0
  127. package/server/agent-health.js +197 -0
  128. package/server/agent-hooks.js +102 -0
  129. package/server/agent-mcp.js +10 -0
  130. package/server/agent-registry.js +95 -0
  131. package/server/agent-server.js +290 -0
  132. package/server/agent-spawn.js +251 -0
  133. package/server/agent-templates.js +77 -0
  134. package/server/artifact-manager.js +247 -0
  135. package/server/artifact-templates/architecture.md +28 -0
  136. package/server/artifact-templates/blueprint.md +21 -0
  137. package/server/artifact-templates/design.md +36 -0
  138. package/server/artifact-templates/plan.md +25 -0
  139. package/server/artifact-templates/prd.md +43 -0
  140. package/server/artifact-templates/report.md +40 -0
  141. package/server/block-tracker.js +90 -0
  142. package/server/build-stream-bridge.js +502 -0
  143. package/server/coalescing-buffer.js +46 -0
  144. package/server/compose-mcp-tools.js +479 -0
  145. package/server/compose-mcp.js +324 -0
  146. package/server/connectors/agent-connector.js +78 -0
  147. package/server/connectors/claude-sdk-connector.js +198 -0
  148. package/server/connectors/codex-connector.js +240 -0
  149. package/server/connectors/connector-discovery.js +18 -0
  150. package/server/connectors/connector-runtime.js +13 -0
  151. package/server/connectors/opencode-connector.js +200 -0
  152. package/server/design-routes.js +540 -0
  153. package/server/design-session.js +161 -0
  154. package/server/feature-scan.js +593 -0
  155. package/server/file-watcher.js +284 -0
  156. package/server/find-root.js +29 -0
  157. package/server/graph-export.js +343 -0
  158. package/server/ideabox-cache.js +77 -0
  159. package/server/ideabox-routes.js +294 -0
  160. package/server/index.js +156 -0
  161. package/server/model-tiers.js +49 -0
  162. package/server/pipeline-routes.js +288 -0
  163. package/server/policy-evaluator.js +36 -0
  164. package/server/project-root.js +122 -0
  165. package/server/security.js +23 -0
  166. package/server/session-manager.js +403 -0
  167. package/server/session-routes.js +190 -0
  168. package/server/session-store.js +107 -0
  169. package/server/settings-routes.js +35 -0
  170. package/server/settings-store.js +234 -0
  171. package/server/stratum-api.js +102 -0
  172. package/server/stratum-client.js +192 -0
  173. package/server/stratum-sync.js +193 -0
  174. package/server/summarizer.js +139 -0
  175. package/server/supervisor.js +196 -0
  176. package/server/vision-routes.js +668 -0
  177. package/server/vision-server.js +393 -0
  178. package/server/vision-store.js +360 -0
  179. package/server/vision-utils.js +179 -0
  180. package/server/worktree-gc.js +137 -0
  181. package/templates/ROADMAP.md +46 -0
@@ -0,0 +1,403 @@
1
+ /**
2
+ * SessionManager — session lifecycle, per-item accumulator, batched Haiku summaries.
3
+ *
4
+ * Sessions are NOT tracker entities — they're execution context that accumulates
5
+ * tool-use events, groups them into work blocks by resolved tracker item, and
6
+ * periodically summarises batches via Haiku.
7
+ */
8
+
9
+ import path from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { copyFile, mkdir } from 'node:fs/promises';
13
+ import { buildSummaryPrompt, summarize } from './summarizer.js';
14
+ import { updateBlock, closeCurrentBlock } from './block-tracker.js';
15
+ import { serializeSession, persistSession, readLastSession, readSessionsByFeature } from './session-store.js';
16
+
17
+ import { getTargetRoot, getDataDir } from './project-root.js';
18
+
19
+ const PROJECT_ROOT = getTargetRoot();
20
+ const SESSIONS_FILE = path.join(getDataDir(), 'sessions.json');
21
+
22
+ /** Tools whose events count toward the Haiku summary batch threshold */
23
+ const SIGNIFICANT_TOOLS = new Set(['Write', 'Edit', 'Bash', 'NotebookEdit']);
24
+
25
+ /** Number of significant events before triggering a Haiku summary call */
26
+ const BATCH_SIZE = 4;
27
+
28
+ export class SessionManager {
29
+ constructor({ getFeaturePhase, featureRoot, sessionsFile } = {}) {
30
+ /** @type {object|null} Current active session */
31
+ this.currentSession = null;
32
+
33
+ /** @type {function} Callback to get current phase for a featureCode */
34
+ this._getFeaturePhase = getFeaturePhase || (() => null);
35
+
36
+ /** @type {string} Root directory for feature folders */
37
+ this._featureRoot = featureRoot || 'docs/features';
38
+
39
+ /** @type {string} Path to sessions.json — injectable for tests */
40
+ this._sessionsFile = sessionsFile || SESSIONS_FILE;
41
+
42
+ /** @type {Array<{tool,filePath,input,itemIds,timestamp}>} Buffered significant events */
43
+ this._pendingBatch = [];
44
+
45
+ /** @type {boolean} Whether a Haiku flush is currently in-flight */
46
+ this._flushing = false;
47
+
48
+ /** @type {Promise|null} In-flight flush promise for awaiting */
49
+ this._flushPromise = null;
50
+
51
+ /** @type {Array<function>} Callbacks for when summaries arrive */
52
+ this._summaryListeners = [];
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Public API
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Start a new session.
61
+ * @param {'startup'|'resume'|'clear'|'compact'} source — what triggered the session
62
+ */
63
+ startSession(source = 'startup') {
64
+ // Close any lingering session synchronously (skip Haiku flush to avoid async race)
65
+ if (this.currentSession) {
66
+ closeCurrentBlock(this.currentSession);
67
+ const old = this.currentSession;
68
+ old.endedAt = new Date().toISOString();
69
+ old.endReason = 'replaced';
70
+ this._persist(this._serialize(old));
71
+ console.log(`[session] Ended ${old.id} (reason: replaced, tools: ${old.toolCount})`);
72
+ this.currentSession = null;
73
+ }
74
+
75
+ const now = new Date().toISOString();
76
+ this.currentSession = {
77
+ id: `session-${Date.now()}-${randomBytes(3).toString('hex')}`,
78
+ startedAt: now,
79
+ source,
80
+ toolCount: 0,
81
+ items: new Map(), // itemId → { title, summaries[], reads, writes, firstTouched, lastTouched }
82
+ currentBlock: null, // { itemIds: Set, startedAt, toolCount } | null
83
+ blocks: [], // closed blocks: { itemIds[], startedAt, endedAt, toolCount }
84
+ commits: [],
85
+ errors: [], // { type, severity, tool, message, itemIds, timestamp }
86
+ featureCode: null,
87
+ featureItemId: null,
88
+ phaseAtBind: null,
89
+ boundAt: null,
90
+ };
91
+
92
+ this._pendingBatch = [];
93
+ this._flushing = false;
94
+ this._flushPromise = null;
95
+
96
+ console.log(`[session] Started ${this.currentSession.id} (source: ${source})`);
97
+ return this.currentSession;
98
+ }
99
+
100
+ /**
101
+ * End the current session, persist it, return session data.
102
+ * @param {string} reason — why the session ended
103
+ * @param {string} [transcriptPath] — optional path to conversation transcript
104
+ * @returns {object|null} The completed session data, or null if no session active
105
+ */
106
+ async endSession(reason = 'manual', transcriptPath = null) {
107
+ if (!this.currentSession) return null;
108
+
109
+ await this.flush();
110
+
111
+ closeCurrentBlock(this.currentSession);
112
+
113
+ const session = this.currentSession;
114
+ session.endedAt = new Date().toISOString();
115
+ session.endReason = reason;
116
+ if (transcriptPath) session.transcriptPath = transcriptPath;
117
+
118
+ // Capture phaseAtEnd for bound sessions
119
+ if (session.featureCode) {
120
+ session.phaseAtEnd = this._getFeaturePhase(session.featureCode);
121
+ }
122
+
123
+ // Auto-file transcript to feature folder — awaited to ensure copy completes before process exit
124
+ if (session.featureCode && transcriptPath) {
125
+ try {
126
+ await this._fileTranscript(session.featureCode, session.id, transcriptPath);
127
+ } catch (err) {
128
+ console.error(`[session] Failed to file transcript to ${session.featureCode}:`, err.message);
129
+ }
130
+ }
131
+
132
+ const serializable = this._serialize(session);
133
+
134
+ this._persist(serializable);
135
+
136
+ console.log(`[session] Ended ${session.id} (reason: ${reason}, tools: ${session.toolCount})`);
137
+
138
+ this.currentSession = null;
139
+ this._pendingBatch = [];
140
+ return serializable;
141
+ }
142
+
143
+ /** Record a tool-use event. Accumulates per-item stats, detects block boundaries,
144
+ * and buffers significant events for Haiku summarization. */
145
+ recordActivity(tool, category, filePath, input, resolvedItems = []) {
146
+ if (!this.currentSession) return;
147
+
148
+ const now = new Date().toISOString();
149
+ const session = this.currentSession;
150
+ session.toolCount++;
151
+
152
+ const isWrite = ['Write', 'Edit', 'NotebookEdit'].includes(tool);
153
+ const isRead = tool === 'Read';
154
+
155
+ for (const item of resolvedItems) {
156
+ let acc = session.items.get(item.id);
157
+ if (!acc) {
158
+ acc = {
159
+ title: item.title,
160
+ summaries: [],
161
+ reads: 0,
162
+ writes: 0,
163
+ firstTouched: now,
164
+ lastTouched: now,
165
+ };
166
+ session.items.set(item.id, acc);
167
+ }
168
+ acc.lastTouched = now;
169
+ if (isRead) acc.reads++;
170
+ if (isWrite) acc.writes++;
171
+ }
172
+
173
+ const itemIds = resolvedItems.map(i => i.id);
174
+ if (itemIds.length > 0) {
175
+ updateBlock(session, itemIds, now, category);
176
+ }
177
+
178
+ if (SIGNIFICANT_TOOLS.has(tool)) {
179
+ this._pendingBatch.push({
180
+ tool,
181
+ category,
182
+ filePath,
183
+ input: this._truncateInput(input),
184
+ itemIds,
185
+ itemTitles: resolvedItems.map(i => i.title),
186
+ timestamp: now,
187
+ });
188
+
189
+ if (this._pendingBatch.length >= BATCH_SIZE && !this._flushing) {
190
+ this._flushPromise = this._flushSummary().catch(err => {
191
+ console.error('[session] Background flush failed:', err.message);
192
+ });
193
+ }
194
+ }
195
+ }
196
+
197
+ /** Record a detected error. Stored for persistence + Haiku batch context. */
198
+ recordError(tool, filePath, errorType, severity, message, resolvedItems = []) {
199
+ if (!this.currentSession) return;
200
+
201
+ this.currentSession.errors.push({
202
+ type: errorType,
203
+ severity,
204
+ tool,
205
+ filePath: filePath || null,
206
+ message: message.length > 200 ? message.slice(0, 197) + '...' : message,
207
+ itemIds: resolvedItems.map(i => i.id),
208
+ timestamp: new Date().toISOString(),
209
+ });
210
+ }
211
+
212
+ /** Force-flush any pending events to Haiku. Awaitable. */
213
+ async flush() {
214
+ if (this._flushPromise) {
215
+ await this._flushPromise;
216
+ }
217
+ if (this._pendingBatch.length > 0) {
218
+ await this._flushSummary();
219
+ }
220
+ }
221
+
222
+ /** Register a callback for when Haiku summaries arrive. */
223
+ onSummary(fn) {
224
+ this._summaryListeners.push(fn);
225
+ }
226
+
227
+ /** Return the most recent session summary for the SessionStart hook. */
228
+ getContext(featureCode) {
229
+ if (featureCode) {
230
+ return readSessionsByFeature(featureCode, 1, this._sessionsFile)[0] || null;
231
+ }
232
+ return readLastSession(this._sessionsFile);
233
+ }
234
+
235
+ /** Bind the current session to a lifecycle feature. One-shot — re-bind returns already_bound. */
236
+ bindToFeature(featureCode, itemId, phase) {
237
+ const session = this.currentSession;
238
+ if (!session) throw new Error('No active session');
239
+ if (session.featureCode) {
240
+ return { already_bound: true, featureCode: session.featureCode };
241
+ }
242
+ session.featureCode = featureCode;
243
+ session.featureItemId = itemId;
244
+ session.phaseAtBind = phase;
245
+ session.boundAt = new Date().toISOString();
246
+ return { bound: true, featureCode, itemId, phase };
247
+ }
248
+
249
+ /** COMP-UX-2b: Return recent sessions for visionState broadcast. */
250
+ getRecentSessions(limit = 20) {
251
+ const sessions = [];
252
+ // Current active session
253
+ if (this.currentSession) {
254
+ const s = this.currentSession;
255
+ let reads = 0, writes = 0, lastSummary = null;
256
+ if (s.items instanceof Map) {
257
+ for (const v of s.items.values()) {
258
+ reads += v.reads || 0;
259
+ writes += v.writes || 0;
260
+ if (v.summaries?.length) {
261
+ const s_ = v.summaries[v.summaries.length - 1];
262
+ lastSummary = typeof s_ === 'string' ? s_ : s_?.summary || null;
263
+ }
264
+ }
265
+ }
266
+ sessions.push({
267
+ id: s.id,
268
+ status: 'active',
269
+ agent: 'claude',
270
+ featureCode: s.featureCode || null,
271
+ summary: lastSummary,
272
+ startedAt: s.startedAt,
273
+ reads, writes,
274
+ errors: s.errors?.length || 0,
275
+ workType: s.phaseAtBind || 'general',
276
+ toolCount: s.toolCount || 0,
277
+ });
278
+ }
279
+ // Persisted sessions (most recent first)
280
+ try {
281
+ const persisted = readSessionsByFeature(null, limit, this._sessionsFile);
282
+ for (const s of persisted) {
283
+ if (sessions.length >= limit) break;
284
+ if (sessions.some(x => x.id === s.id)) continue;
285
+ // Aggregate reads/writes/summaries from per-item data
286
+ let reads = 0, writes = 0;
287
+ let lastSummary = null;
288
+ if (s.items && typeof s.items === 'object') {
289
+ for (const v of Object.values(s.items)) {
290
+ reads += v.reads || 0;
291
+ writes += v.writes || 0;
292
+ if (v.summaries?.length) {
293
+ const s_ = v.summaries[v.summaries.length - 1];
294
+ lastSummary = typeof s_ === 'string' ? s_ : s_?.summary || null;
295
+ }
296
+ }
297
+ }
298
+ sessions.push({
299
+ id: s.id,
300
+ status: 'completed',
301
+ agent: 'claude',
302
+ featureCode: s.featureCode || null,
303
+ summary: lastSummary,
304
+ startedAt: s.startedAt,
305
+ reads, writes,
306
+ errors: s.errors?.length || 0,
307
+ workType: s.phaseAtBind || 'general',
308
+ toolCount: s.toolCount || 0,
309
+ });
310
+ }
311
+ } catch { /* sessions file may not exist yet */ }
312
+ return sessions;
313
+ }
314
+
315
+ /** Expose sessions file path for use by routes. */
316
+ get sessionsFile() { return this._sessionsFile; }
317
+
318
+ /** True if the current session crosses the journal-worthiness threshold. */
319
+ meetsJournalThreshold() {
320
+ if (!this.currentSession) return false;
321
+ if (this.currentSession.toolCount > 20) return true;
322
+ const elapsed = Date.now() - new Date(this.currentSession.startedAt).getTime();
323
+ return elapsed > 10 * 60 * 1000;
324
+ }
325
+
326
+ // ── Internal: Haiku pipeline ─────────────────────────────────────────────
327
+
328
+ async _flushSummary() {
329
+ if (this._pendingBatch.length === 0) return;
330
+ this._flushing = true;
331
+
332
+ const batch = this._pendingBatch.splice(0);
333
+
334
+ try {
335
+ const prompt = buildSummaryPrompt(batch, PROJECT_ROOT);
336
+ const result = await summarize(prompt, { projectRoot: PROJECT_ROOT });
337
+ if (result) {
338
+ this._distributeSummary(result, batch);
339
+ for (const fn of this._summaryListeners) {
340
+ try { fn(result, batch); } catch { /* listener errors don't propagate */ }
341
+ }
342
+ }
343
+ } catch (err) {
344
+ console.error('[session] Haiku summary failed, raw data preserved:', err.message);
345
+ } finally {
346
+ this._flushing = false;
347
+ this._flushPromise = null;
348
+ }
349
+ }
350
+
351
+ _distributeSummary(result, batch) {
352
+ if (!this.currentSession) return;
353
+
354
+ const itemIds = new Set();
355
+ for (const evt of batch) {
356
+ for (const id of evt.itemIds) {
357
+ itemIds.add(id);
358
+ }
359
+ }
360
+
361
+ const summary = {
362
+ ...result,
363
+ batchSize: batch.length,
364
+ timestamp: new Date().toISOString(),
365
+ };
366
+
367
+ for (const id of itemIds) {
368
+ const acc = this.currentSession.items.get(id);
369
+ if (acc) {
370
+ acc.summaries.push(summary);
371
+ }
372
+ }
373
+
374
+ console.log(`[session] Haiku summary distributed to ${itemIds.size} items: "${result.summary}"`);
375
+ }
376
+
377
+ // ── Internal: Utilities ──────────────────────────────────────────────────
378
+
379
+ _truncateInput(input) {
380
+ if (!input) return '(no input)';
381
+ const raw = input.content
382
+ || input.new_string
383
+ || input.command
384
+ || input.new_source
385
+ || input.old_string
386
+ || input.pattern
387
+ || input.query
388
+ || (typeof input === 'string' ? input : JSON.stringify(input));
389
+ const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
390
+ return str.length > 200 ? str.slice(0, 197) + '...' : str;
391
+ }
392
+
393
+ async _fileTranscript(featureCode, sessionId, transcriptPath) {
394
+ const ext = path.extname(transcriptPath) || '.transcript';
395
+ const sessionsDir = path.join(this._featureRoot, featureCode, 'sessions');
396
+ await mkdir(sessionsDir, { recursive: true });
397
+ const dest = path.join(sessionsDir, `${sessionId}${ext}`);
398
+ await copyFile(transcriptPath, dest);
399
+ }
400
+
401
+ _serialize(session) { return serializeSession(session); }
402
+ _persist(session) { persistSession(session, this._sessionsFile); }
403
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * session-routes.js — Session lifecycle REST routes.
3
+ *
4
+ * Routes:
5
+ * POST /api/session/start
6
+ * POST /api/session/end
7
+ * POST /api/session/bind
8
+ * GET /api/session/history
9
+ * GET /api/session/current
10
+ */
11
+
12
+ import { readSessionsByFeature } from './session-store.js';
13
+
14
+ /**
15
+ * Attach session lifecycle routes to an Express app.
16
+ *
17
+ * @param {object} app — Express app
18
+ * @param {{
19
+ * sessionManager: object,
20
+ * scheduleBroadcast: function,
21
+ * broadcastMessage: function,
22
+ * spawnJournalAgent: function,
23
+ * projectRoot: string,
24
+ * store: object
25
+ * }} deps
26
+ */
27
+ export function attachSessionRoutes(app, { sessionManager, scheduleBroadcast, broadcastMessage, spawnJournalAgent, store }) {
28
+ // POST /api/session/start — hook calls this on SessionStart
29
+ app.post('/api/session/start', (req, res) => {
30
+ const { source } = req.body || {};
31
+ if (!sessionManager) return res.status(503).json({ error: 'No session manager' });
32
+ const session = sessionManager.startSession(source || 'startup');
33
+ const context = sessionManager.getContext();
34
+
35
+ broadcastMessage({
36
+ type: 'sessionStart',
37
+ sessionId: session.id,
38
+ source: source || 'startup',
39
+ timestamp: new Date().toISOString(),
40
+ });
41
+ scheduleBroadcast(); // COMP-UX-2b: refresh sessions list in visionState
42
+
43
+ res.json({ sessionId: session.id, context });
44
+ });
45
+
46
+ // POST /api/session/end — hook calls this on SessionEnd
47
+ app.post('/api/session/end', async (req, res) => {
48
+ const { reason, transcriptPath } = req.body || {};
49
+ if (!sessionManager) return res.status(503).json({ error: 'No session manager' });
50
+ const meetsThreshold = sessionManager.meetsJournalThreshold();
51
+ const session = await sessionManager.endSession(reason, transcriptPath);
52
+ if (!session) return res.json({ sessionId: null, persisted: false });
53
+ let journalSpawned = false;
54
+ if (meetsThreshold && transcriptPath) {
55
+ spawnJournalAgent(session, transcriptPath);
56
+ journalSpawned = true;
57
+ }
58
+
59
+ broadcastMessage({
60
+ type: 'sessionEnd',
61
+ sessionId: session.id,
62
+ reason,
63
+ toolCount: session.toolCount,
64
+ duration: Math.round((new Date(session.endedAt) - new Date(session.startedAt)) / 1000),
65
+ journalSpawned,
66
+ featureCode: session.featureCode || null,
67
+ phaseAtEnd: session.phaseAtEnd || null,
68
+ timestamp: new Date().toISOString(),
69
+ });
70
+ scheduleBroadcast(); // COMP-UX-2b: refresh sessions list in visionState
71
+
72
+ res.json({ sessionId: session.id, persisted: true, journalSpawned });
73
+ });
74
+
75
+ // POST /api/session/bind — bind active session to a lifecycle feature
76
+ app.post('/api/session/bind', (req, res) => {
77
+ try {
78
+ const { featureCode } = req.body;
79
+ if (!featureCode) return res.status(400).json({ error: 'featureCode required' });
80
+ if (!/^[A-Za-z0-9_-]+$/.test(featureCode)) return res.status(400).json({ error: 'Invalid featureCode' });
81
+
82
+ const session = sessionManager.currentSession;
83
+ if (!session) return res.status(409).json({ error: 'No active session' });
84
+
85
+ // If already bound, return early without validating the new feature code
86
+ if (session.featureCode) {
87
+ return res.json({ already_bound: true, featureCode: session.featureCode });
88
+ }
89
+
90
+ // Look up the vision item for this feature — reject if not found
91
+ const item = store.getItemByFeatureCode(featureCode);
92
+ if (!item) return res.status(404).json({ error: `No lifecycle item for feature code: ${featureCode}` });
93
+ const itemId = item.id;
94
+ const phase = item.lifecycle?.currentPhase || null;
95
+
96
+ const result = sessionManager.bindToFeature(featureCode, itemId, phase);
97
+
98
+ if (!result.already_bound) {
99
+ broadcastMessage({
100
+ type: 'sessionBound',
101
+ sessionId: session.id,
102
+ featureCode,
103
+ itemId,
104
+ phase,
105
+ timestamp: new Date().toISOString(),
106
+ });
107
+ }
108
+
109
+ res.json(result);
110
+ } catch (err) {
111
+ res.status(500).json({ error: err.message });
112
+ }
113
+ });
114
+
115
+ // GET /api/session/history — sessions bound to a feature
116
+ app.get('/api/session/history', (req, res) => {
117
+ try {
118
+ const { featureCode, limit } = req.query;
119
+ if (!featureCode) return res.status(400).json({ error: 'featureCode required' });
120
+ const sessions = readSessionsByFeature(featureCode, parseInt(limit) || 10, sessionManager.sessionsFile);
121
+ res.json({ sessions });
122
+ } catch (err) {
123
+ res.status(500).json({ error: err.message });
124
+ }
125
+ });
126
+
127
+ // GET /api/session/current — current session state (with optional featureCode enrichment)
128
+ app.get('/api/session/current', (_req, res) => {
129
+ const { featureCode } = _req.query;
130
+
131
+ if (!sessionManager?.currentSession) {
132
+ if (featureCode) {
133
+ return res.json(_buildFeatureContext(featureCode, null));
134
+ }
135
+ return res.json({ session: null });
136
+ }
137
+
138
+ const s = sessionManager.currentSession;
139
+ const items = {};
140
+ const allSummaries = [];
141
+ for (const [id, acc] of s.items) {
142
+ items[id] = { title: acc.title, reads: acc.reads, writes: acc.writes, summaries: acc.summaries };
143
+ for (const summary of (acc.summaries || [])) {
144
+ if (!summary) continue;
145
+ allSummaries.push(typeof summary === 'string' ? { summary } : summary);
146
+ }
147
+ }
148
+ const sessionData = {
149
+ id: s.id, startedAt: s.startedAt, source: s.source, toolCount: s.toolCount,
150
+ blockCount: s.blocks.length, errorCount: (s.errors || []).length, items,
151
+ summaries: allSummaries,
152
+ featureCode: s.featureCode || null,
153
+ featureItemId: s.featureItemId || null,
154
+ phaseAtBind: s.phaseAtBind || null,
155
+ boundAt: s.boundAt || null,
156
+ };
157
+
158
+ // When featureCode requested and active session is bound to THAT feature
159
+ if (featureCode && s.featureCode === featureCode) {
160
+ return res.json(_buildFeatureContext(featureCode, sessionData));
161
+ }
162
+
163
+ // When featureCode requested but active session is for a DIFFERENT feature
164
+ if (featureCode && s.featureCode !== featureCode) {
165
+ return res.json(_buildFeatureContext(featureCode, null));
166
+ }
167
+
168
+ // No featureCode requested — return generic active session (existing behavior)
169
+ res.json({ session: sessionData });
170
+ });
171
+
172
+ // Helper: normalize feature-aware response shape across all branches
173
+ function _buildFeatureContext(featureCode, sessionData) {
174
+ const item = store.getItemByFeatureCode(featureCode);
175
+ const recentSessions = readSessionsByFeature(featureCode, 3, sessionManager.sessionsFile);
176
+ const recentSummaries = recentSessions
177
+ .flatMap(rs => Object.values(rs.items || {}).flatMap(i => i.summaries || []))
178
+ .slice(0, 10);
179
+ return {
180
+ session: sessionData || recentSessions[0] || null,
181
+ lifecycle: item?.lifecycle ? {
182
+ currentPhase: item.lifecycle.currentPhase,
183
+ phaseHistory: (item.lifecycle.phaseHistory || []).map(h => ({ phase: h.phase, enteredAt: h.enteredAt, exitedAt: h.exitedAt })),
184
+ artifacts: item.lifecycle.artifacts || {},
185
+ pendingGate: item.lifecycle.pendingGate || null,
186
+ } : null,
187
+ recentSummaries,
188
+ };
189
+ }
190
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * session-store.js — Session persistence: serialize, persist, and read context.
3
+ *
4
+ * Extracted from SessionManager. All functions are pure/stateless helpers
5
+ * that operate on plain session objects and the sessions.json file path.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ /**
12
+ * Convert session state (with Maps/Sets) to a plain serializable object.
13
+ * @param {object} session
14
+ * @returns {object}
15
+ */
16
+ export function serializeSession(session) {
17
+ const items = {};
18
+ for (const [id, acc] of session.items) {
19
+ items[id] = { ...acc };
20
+ }
21
+ return {
22
+ id: session.id,
23
+ startedAt: session.startedAt,
24
+ endedAt: session.endedAt || null,
25
+ endReason: session.endReason || null,
26
+ source: session.source,
27
+ toolCount: session.toolCount,
28
+ items,
29
+ blocks: session.blocks,
30
+ commits: session.commits,
31
+ errors: session.errors || [],
32
+ transcriptPath: session.transcriptPath || null,
33
+ featureCode: session.featureCode || null,
34
+ featureItemId: session.featureItemId || null,
35
+ phaseAtBind: session.phaseAtBind || null,
36
+ phaseAtEnd: session.phaseAtEnd || null,
37
+ boundAt: session.boundAt || null,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Append a completed session to data/sessions.json.
43
+ * @param {object} session — serialized session data
44
+ * @param {string} sessionsFile — absolute path to sessions.json
45
+ */
46
+ export function persistSession(session, sessionsFile) {
47
+ try {
48
+ fs.mkdirSync(path.dirname(sessionsFile), { recursive: true });
49
+
50
+ let sessions = [];
51
+ try {
52
+ const raw = fs.readFileSync(sessionsFile, 'utf-8');
53
+ sessions = JSON.parse(raw);
54
+ if (!Array.isArray(sessions)) sessions = [];
55
+ } catch (parseErr) {
56
+ if (parseErr.code !== 'ENOENT') {
57
+ const backup = sessionsFile + '.bak';
58
+ try { fs.copyFileSync(sessionsFile, backup); } catch { /* best effort */ }
59
+ console.warn(`[session] Corrupted sessions.json backed up to ${backup}`);
60
+ }
61
+ }
62
+
63
+ sessions.push(session);
64
+ fs.writeFileSync(sessionsFile, JSON.stringify(sessions, null, 2), 'utf-8');
65
+ console.log(`[session] Persisted to ${sessionsFile} (${sessions.length} total sessions)`);
66
+ } catch (err) {
67
+ console.error('[session] Failed to persist session:', err.message);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Read the most recent session from sessions.json.
73
+ * @param {string} sessionsFile — absolute path to sessions.json
74
+ * @returns {object|null}
75
+ */
76
+ export function readLastSession(sessionsFile) {
77
+ try {
78
+ const raw = fs.readFileSync(sessionsFile, 'utf-8');
79
+ const sessions = JSON.parse(raw);
80
+ if (Array.isArray(sessions) && sessions.length > 0) {
81
+ return sessions[sessions.length - 1];
82
+ }
83
+ } catch {
84
+ // No sessions file yet — that's fine
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Read sessions bound to a specific feature, sorted descending by startedAt.
91
+ * @param {string} featureCode
92
+ * @param {number} limit — max results to return
93
+ * @param {string} sessionsFile — absolute path to sessions.json
94
+ * @returns {Array<object>}
95
+ */
96
+ export function readSessionsByFeature(featureCode, limit, sessionsFile) {
97
+ try {
98
+ const raw = fs.readFileSync(sessionsFile, 'utf8');
99
+ const sessions = JSON.parse(raw);
100
+ return sessions
101
+ .filter(s => featureCode === null || featureCode === undefined ? true : s.featureCode === featureCode)
102
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
103
+ .slice(0, limit);
104
+ } catch {
105
+ return [];
106
+ }
107
+ }