claude-code-session-manager 0.58.0 → 0.59.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 (38) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/{TiptapBody-BEBLdJl_.js → TiptapBody-PUx4oZTh.js} +1 -1
  3. package/dist/assets/{index-DEQzGYa6.js → index-BPnfPdLW.js} +1076 -1081
  4. package/dist/assets/{index-DwUffaDq.css → index-CPMP2XZ_.css} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
  8. package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
  9. package/src/main/__tests__/prdMigration.test.cjs +160 -0
  10. package/src/main/__tests__/projectPages.test.cjs +151 -0
  11. package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
  12. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
  13. package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
  14. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
  15. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
  16. package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
  17. package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
  18. package/src/main/health.cjs +5 -1
  19. package/src/main/index.cjs +1 -1
  20. package/src/main/ipcSchemas.cjs +26 -3
  21. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
  22. package/src/main/lib/classifyTranscriptLine.cjs +131 -48
  23. package/src/main/lib/epicMint.cjs +6 -1
  24. package/src/main/lib/epicValidationHook.cjs +192 -0
  25. package/src/main/lib/prdMigration.cjs +76 -5
  26. package/src/main/lib/promptSessionSchema.cjs +16 -0
  27. package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
  28. package/src/main/lib/schedulerBatch.cjs +70 -111
  29. package/src/main/lib/schedulerConfig.cjs +0 -1
  30. package/src/main/otel.cjs +3 -1
  31. package/src/main/projectPages.cjs +60 -14
  32. package/src/main/promptSessionEvents.cjs +16 -1
  33. package/src/main/scheduler.cjs +226 -47
  34. package/src/main/templates/project-pages-default-home.html +123 -0
  35. package/src/main/transcripts.cjs +191 -32
  36. package/src/main/webRemote.cjs +8 -7
  37. package/src/preload/api.d.ts +75 -9
  38. package/src/preload/index.cjs +3 -0
@@ -8,7 +8,7 @@
8
8
  const { z } = require('zod');
9
9
  const os = require('node:os');
10
10
  const path = require('node:path');
11
- const { PromptSessionSchema, EpicTagSchema, EpicSourceSchema } = require('./lib/promptSessionSchema.cjs');
11
+ const { PromptSessionSchema, EpicTagSchema, EpicSourceSchema, EpicIntakeSectionSchema } = require('./lib/promptSessionSchema.cjs');
12
12
 
13
13
  // ──────────────────────────────────────────── PTY
14
14
  const ptySpawn = z.object({
@@ -183,6 +183,23 @@ const transcriptUsageFor = z.object({
183
183
  sessionIds: z.array(z.string().regex(SESSION_UUID_RE)).max(500),
184
184
  });
185
185
 
186
+ // Paged read over a subscribed transcript's line-offset index. Bounded line
187
+ // numbers — a page is a scroll window, never an unbounded range request.
188
+ const transcriptPage = z.object({
189
+ tabId: z.string().min(1).max(128),
190
+ startLine: z.number().int().min(0),
191
+ endLine: z.number().int().min(0),
192
+ });
193
+
194
+ // Single-line full-payload read via a classifier byte reference (expand-to-
195
+ // full path, PRD chat-typed-event-renderers). byteLength bound mirrors
196
+ // transcripts.cjs's MAX_REF_BYTES — one JSONL line, never a whole-file read.
197
+ const transcriptReadRef = z.object({
198
+ filePath: z.string().min(1).max(4096),
199
+ byteOffset: z.number().int().min(0),
200
+ byteLength: z.number().int().min(1).max(64 * 1024 * 1024),
201
+ });
202
+
186
203
  // ──────────────────────────────────────────── Config
187
204
  const configPath = z.object({ path: z.string().min(1).max(4096) });
188
205
 
@@ -285,6 +302,11 @@ const promptSessionsCreateEpic = z.object({
285
302
  tag: EpicTagSchema.optional(),
286
303
  agentType: z.string().min(1).max(256).optional(),
287
304
  source: EpicSourceSchema.optional(),
305
+ // The full first-prompt body + its labeled sections (epicIntake.ts's
306
+ // composeEpicIntake) — both optional, since not every mint caller (e.g. a
307
+ // future scripted Epic) composes a full opening prompt.
308
+ openingPrompt: z.string().max(200000).optional(),
309
+ sections: z.array(EpicIntakeSectionSchema).max(200).optional(),
288
310
  });
289
311
 
290
312
  // ──────────────────────────────────────────── Sessions
@@ -439,7 +461,6 @@ const home = os.homedir();
439
461
  const setConfigSchema = z.object({
440
462
  enabled: z.boolean().optional(),
441
463
  offsetMinutes: z.number().int().min(0).max(180).optional(),
442
- concurrencyCap: z.number().int().min(1).max(20).optional(),
443
464
  defaultCwd: z.string().max(4096).refine(
444
465
  (s) => s === home || s.startsWith(home + path.sep),
445
466
  'defaultCwd must be inside home directory'
@@ -457,7 +478,7 @@ const setConfigSchema = z.object({
457
478
  // Machine-wide claude -p slot pool cap (sessionSlots.cjs) — deliberately its own
458
479
  // schema/channel rather than folded into setConfigSchema: it governs the shared
459
480
  // process pool, not any one project's scheduler config, and its range (0-10, 0
460
- // pauses new launches) differs from concurrencyCap's (1-20).
481
+ // pauses new launches) is the single concurrency control.
461
482
  const setSessionSlotsSchema = z.object({
462
483
  cap: z.number().int().min(0).max(10),
463
484
  }).strict();
@@ -965,6 +986,8 @@ module.exports = {
965
986
  transcriptTabId,
966
987
  transcriptPath,
967
988
  transcriptUsageFor,
989
+ transcriptPage,
990
+ transcriptReadRef,
968
991
  configPath,
969
992
  configWriteJson,
970
993
  configWriteText,
@@ -92,3 +92,133 @@ test('a FAILED bare-named dep holds the dependent and reports an explicit reason
92
92
  assert.match(reason, /depends-gate/);
93
93
  assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
94
94
  });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Parallelism regression coverage.
98
+ //
99
+ // `parallelGroup` used to gate batch membership: the picker fired at most one
100
+ // group per tick and held every higher group while a lower one was in flight.
101
+ // PRD 832 made the number strictly unique per PRD, so every group became a
102
+ // singleton and the batch was always exactly ONE job — measured max
103
+ // concurrency 1 across 25 recorded runs against a 5-slot pool. These tests
104
+ // pin the corrected contract: dependsOn is the only barrier, parallelGroup is
105
+ // a priority hint.
106
+ // ---------------------------------------------------------------------------
107
+
108
+ test('fires EVERY dependency-eligible job, not one per parallelGroup', () => {
109
+ const jobs = [
110
+ job('983-a', 'pending'),
111
+ job('984-b', 'pending'),
112
+ job('985-c', 'pending'),
113
+ job('986-d', 'pending'),
114
+ ];
115
+ const { batch } = pick(jobs, new Set(), 5);
116
+ // Pre-fix this returned exactly ['983-a'] — one singleton group.
117
+ assert.deepEqual(batch.map((j) => j.slug), ['983-a', '984-b', '985-c', '986-d']);
118
+ });
119
+
120
+ test('a higher-numbered job is NOT held behind an in-flight lower-numbered one', () => {
121
+ const jobs = [
122
+ job('979-running', 'running'),
123
+ job('988-independent', 'pending'),
124
+ ];
125
+ const { batch } = pick(jobs, new Set(['979-running']), 5);
126
+ // Pre-fix: held by the running-gate ("g979 in flight, holding g988"), which
127
+ // is how the fix for this very bug ended up stuck behind the bug.
128
+ assert.deepEqual(batch.map((j) => j.slug), ['988-independent']);
129
+ });
130
+
131
+ test('dependency-blocked jobs are excluded while independent siblings fire together', () => {
132
+ const jobs = [
133
+ job('985-foundation', 'pending'),
134
+ job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
135
+ job('987-independent', 'pending'),
136
+ ];
137
+ const { batch } = pick(jobs, new Set(), 5);
138
+ assert.deepEqual(batch.map((j) => j.slug), ['985-foundation', '987-independent']);
139
+ });
140
+
141
+ test('a FAILED job holds its transitive dependents but not unrelated jobs', () => {
142
+ const jobs = [
143
+ job('980-broken', 'failed'),
144
+ job('981-direct', 'pending', { dependsOn: ['broken'] }),
145
+ job('982-transitive', 'pending', { dependsOn: ['direct'] }),
146
+ job('983-unrelated', 'pending'),
147
+ ];
148
+ const { batch } = pick(jobs, new Set(), 5);
149
+ // Pre-fix the cross-group failure gate held 983 too, purely for having a
150
+ // higher number than the failure.
151
+ assert.deepEqual(batch.map((j) => j.slug), ['983-unrelated']);
152
+ });
153
+
154
+ test('parallelGroup orders the batch when eligible jobs exceed free slots', () => {
155
+ const jobs = [
156
+ job('990-c', 'pending'),
157
+ job('988-a', 'pending'),
158
+ job('989-b', 'pending'),
159
+ ];
160
+ const { batch } = pick(jobs, new Set(), 2);
161
+ assert.deepEqual(batch.map((j) => j.slug), ['988-a', '989-b']);
162
+ });
163
+
164
+ test('zero free slots holds everything with an explicit reason', () => {
165
+ const jobs = [job('988-a', 'pending'), job('989-b', 'pending')];
166
+ const { batch, reason } = pick(jobs, new Set(), 0);
167
+ assert.deepEqual(batch, []);
168
+ assert.match(reason, /no slots free/);
169
+ });
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Per-job hold records (PRD 990). The picker already knew exactly which dep
173
+ // held which row; it only ever reached console.log. These pin it as data.
174
+ // ---------------------------------------------------------------------------
175
+
176
+ test('reports a per-job hold record naming the blocking dep and its status', () => {
177
+ const jobs = [
178
+ job('985-foundation', 'pending'),
179
+ job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
180
+ ];
181
+ const { batch, holds } = pick(jobs, new Set(), 5);
182
+ assert.deepEqual(batch.map((j) => j.slug), ['985-foundation']);
183
+ assert.deepEqual(holds, [
184
+ { slug: '986-dependent', dep: 'foundation', depStatus: 'pending' },
185
+ ]);
186
+ });
187
+
188
+ test('hold record carries a running dep status', () => {
189
+ const jobs = [
190
+ job('985-foundation', 'running'),
191
+ job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
192
+ ];
193
+ const { holds } = pick(jobs, new Set(['985-foundation']), 5);
194
+ assert.deepEqual(holds, [
195
+ { slug: '986-dependent', dep: 'foundation', depStatus: 'running' },
196
+ ]);
197
+ });
198
+
199
+ test('hold record carries a failed dep status alongside the depends-gate reason', () => {
200
+ const jobs = [
201
+ job('985-foundation', 'failed'),
202
+ job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
203
+ ];
204
+ const { batch, reason, holds } = pick(jobs, new Set(), 5);
205
+ assert.deepEqual(batch, []);
206
+ assert.match(reason, /depends-gate/);
207
+ assert.deepEqual(holds, [
208
+ { slug: '986-dependent', dep: 'foundation', depStatus: 'failed' },
209
+ ]);
210
+ });
211
+
212
+ test('no holds when nothing is dependency-blocked', () => {
213
+ const jobs = [job('988-a', 'pending'), job('989-b', 'pending')];
214
+ const { batch, holds } = pick(jobs, new Set(), 5);
215
+ assert.equal(batch.length, 2);
216
+ assert.deepEqual(holds, []);
217
+ });
218
+
219
+ test('an idle queue reports no holds (empty must not read as blocked)', () => {
220
+ const jobs = [job('988-a', 'completed')];
221
+ const { batch, holds } = pick(jobs, new Set(), 5);
222
+ assert.deepEqual(batch, []);
223
+ assert.deepEqual(holds, []);
224
+ });
@@ -1,17 +1,34 @@
1
1
  'use strict';
2
2
 
3
+ // Bound on any single string field kept inline in the in-memory `raw`
4
+ // projection stored per event (events are ring-buffered up to 500 deep,
5
+ // see transcripts.cjs's sub.buffer). Anything longer is truncated here —
6
+ // the full untruncated line is still recoverable via the event's `ref`
7
+ // (byte offset/length into the transcript file on disk, see makeEvent).
3
8
  const MAX_RAW_STR = 4096;
4
9
 
5
- // Block types whose text/content fields are parsed structurally by
6
- // orchestrator.ts / race.ts — truncating them produces mid-token "…" and
7
- // unparseable JSON, so they are exempt from the size cap.
8
- const EXEMPT_TYPES = new Set(['tool_result', 'tool_use']);
10
+ // Bound on the short human-scannable preview attached to every event.
11
+ // Deliberately much smaller than MAX_RAW_STR — previewText is for a
12
+ // glance, not a document; the full payload is always one disk read away
13
+ // via `ref`.
14
+ const PREVIEW_CHARS = 280;
15
+
16
+ // Historically tool_use/tool_result blocks were exempted from the
17
+ // MAX_RAW_STR cap so orchestrator.ts/race.ts could structurally re-parse
18
+ // them without a truncated mid-token "…". Both files were deleted
19
+ // 2026-07-30 (commit e3848c3, "delete Subagents-only dead code") along
20
+ // with the rest of the Subagents tab, and no current consumer needs an
21
+ // untruncated tool_result/tool_use block in the in-memory `raw`
22
+ // projection — holding one verbatim (e.g. a large Read tool_result) in a
23
+ // 500-entry ring buffer was a real memory-cliff incident. Every block
24
+ // type is now trimmed the same way; this stays exported (empty) in case
25
+ // a future structural consumer needs to opt a block type back out.
26
+ const EXEMPT_TYPES = new Set();
9
27
 
10
28
  /**
11
- * Cap string fields in a content block array so arbitrary tool output doesn't
12
- * bloat the ring buffer. Blocks whose type is in EXEMPT_TYPES are passed
13
- * through intact so that structured result payloads survive to the digest
14
- * parsers in race.ts / orchestrator.ts.
29
+ * Cap string fields in a content block array so arbitrary tool output
30
+ * doesn't bloat the ring buffer. Blocks whose type is in EXEMPT_TYPES are
31
+ * passed through intact (currently no type is exempt — see comment above).
15
32
  */
16
33
  function trimContentArray(content) {
17
34
  if (!Array.isArray(content)) return content;
@@ -32,58 +49,124 @@ function trimContentArray(content) {
32
49
  });
33
50
  }
34
51
 
35
- /** Build the slim raw projection used by race.ts and orchestrator.ts. */
52
+ /**
53
+ * Build the raw projection carried on every event. Preserves every
54
+ * top-level field on the line (attribution*, effort, gitBranch,
55
+ * isSidechain, isMeta, requestId, isApiErrorMessage,
56
+ * interruptedByShutdown, permissionMode, promptSource, toolUseResult,
57
+ * etc.) — not just message.content — so the renderer can surface all of
58
+ * it, however trivial or redundant. message.content is still run through
59
+ * trimContentArray to bound size in the ring buffer.
60
+ */
36
61
  function makeRaw(obj) {
37
- const msgContent = obj?.message?.content;
38
- return { message: { content: trimContentArray(msgContent) } };
62
+ if (!obj || typeof obj !== 'object') return {};
63
+ const raw = {};
64
+ for (const key of Object.keys(obj)) {
65
+ if (key === 'message') continue;
66
+ raw[key] = obj[key];
67
+ }
68
+ if (obj.message && typeof obj.message === 'object') {
69
+ raw.message = { ...obj.message, content: trimContentArray(obj.message.content) };
70
+ }
71
+ return raw;
72
+ }
73
+
74
+ /** Bounded, human-scannable preview of an event's data — never the source of truth. */
75
+ function buildPreviewText(data) {
76
+ let s;
77
+ if (typeof data === 'string') {
78
+ s = data;
79
+ } else {
80
+ try {
81
+ s = JSON.stringify(data);
82
+ } catch {
83
+ s = String(data);
84
+ }
85
+ }
86
+ if (!s) return '';
87
+ return s.length > PREVIEW_CHARS ? s.slice(0, PREVIEW_CHARS) + '…' : s;
88
+ }
89
+
90
+ /**
91
+ * Build one event. `ref` (optional) is `{ filePath, byteOffset, byteLength }`
92
+ * pointing at the exact bytes of the source line on disk, so the full,
93
+ * untruncated line can be re-read on demand instead of being held in
94
+ * memory. classifyLine stays a pure function of (obj, ref) — callers that
95
+ * don't have file context (e.g. unit tests) may omit ref.
96
+ */
97
+ function makeEvent(kind, data, obj, ref) {
98
+ return { kind, data, raw: makeRaw(obj), previewText: buildPreviewText(data), ref: ref || null };
39
99
  }
40
100
 
41
101
  /**
42
- * Parse one JSONL line defensively. Real schema drifts, so we pass through
43
- * anything that parses and tag a coarse `kind`.
102
+ * Classify one content block and push its event(s) onto `events`. Known
103
+ * tool_use names get a specific kind (todo_write/plan/agent_spawn/tool_use);
104
+ * tool_result blocks get 'tool_result'; text blocks inherit the message's
105
+ * own type (assistant/user/…) so existing type-based consumers keep
106
+ * working. Any other block type — including ones Anthropic hasn't shipped
107
+ * yet — is surfaced under its own `content_<type>` kind rather than
108
+ * silently dropped, so new block kinds stay visible instead of vanishing.
44
109
  */
45
- function classifyLine(obj) {
46
- if (!obj || typeof obj !== 'object') return null;
47
- // Many shapes exist — try several common fields.
110
+ function classifyBlock(block, obj, ref, type, events) {
111
+ if (!block || typeof block !== 'object') return;
112
+ if (block.type === 'tool_use') {
113
+ if (block.name === 'TodoWrite') {
114
+ events.push(makeEvent('todo_write', block.input?.todos || block.input || [], obj, ref));
115
+ } else if (block.name === 'ExitPlanMode' || block.name === 'EnterPlanMode') {
116
+ events.push(makeEvent('plan', block.input, obj, ref));
117
+ } else if (block.name === 'Agent' || block.name === 'Task') {
118
+ // Include block.id as toolUseId so the live store can match the
119
+ // corresponding tool_result and update per-agent lastActivityAt.
120
+ events.push(makeEvent('agent_spawn', { ...block.input, toolUseId: block.id }, obj, ref));
121
+ } else {
122
+ events.push(makeEvent('tool_use', { name: block.name, input: block.input, id: block.id }, obj, ref));
123
+ }
124
+ return;
125
+ }
126
+ // tool_result carries the tool_use_id of the completed Task/Agent call.
127
+ // The live store uses this to update the agent's lastActivityAt bookend.
128
+ if (block.type === 'tool_result' && block.tool_use_id) {
129
+ events.push(makeEvent('tool_result', { toolUseId: block.tool_use_id }, obj, ref));
130
+ return;
131
+ }
132
+ if (block.type === 'text' && typeof block.text === 'string') {
133
+ events.push(makeEvent(type || 'text', block.text, obj, ref));
134
+ return;
135
+ }
136
+ events.push(makeEvent(block.type ? `content_${block.type}` : 'content_block', block, obj, ref));
137
+ }
138
+
139
+ /**
140
+ * Parse one JSONL line defensively. Real schema drifts, so we pass
141
+ * through anything that parses and tag a coarse `kind` per event.
142
+ *
143
+ * Returns an ARRAY of events (empty array for an unclassifiable line) —
144
+ * a single line can legitimately carry more than one event: a usage
145
+ * rollup alongside real content, or an assistant turn with text plus
146
+ * several tool calls in the same content array. No caller may assume a
147
+ * line produces at most one event.
148
+ */
149
+ function classifyLine(obj, ref) {
150
+ if (!obj || typeof obj !== 'object') return [];
151
+ const events = [];
48
152
  const type = obj.type || obj.event || obj.role;
49
153
  const msg = obj.message || obj;
50
154
  const content = msg?.content;
155
+ const usage = obj.usage || msg?.usage;
51
156
 
52
- // Usage rollups arrive as summary events.
53
- if (obj.usage || msg?.usage) {
54
- return { kind: 'usage', data: obj.usage || msg.usage, raw: makeRaw(obj) };
157
+ // Usage rollups arrive as summary events — emitted alongside whatever
158
+ // content the same line carries, never instead of it.
159
+ if (usage) {
160
+ events.push(makeEvent('usage', usage, obj, ref));
55
161
  }
56
162
 
57
- // Tool uses: scan content array for tool_use blocks.
58
- if (Array.isArray(content)) {
59
- for (const block of content) {
60
- if (block?.type === 'tool_use') {
61
- if (block.name === 'TodoWrite') {
62
- return { kind: 'todo_write', data: block.input?.todos || block.input || [], raw: makeRaw(obj) };
63
- }
64
- if (block.name === 'ExitPlanMode' || block.name === 'EnterPlanMode') {
65
- return { kind: 'plan', data: block.input, raw: makeRaw(obj) };
66
- }
67
- if (block.name === 'Agent' || block.name === 'Task') {
68
- // Include block.id as toolUseId so the live store can match the
69
- // corresponding tool_result and update per-agent lastActivityAt.
70
- return { kind: 'agent_spawn', data: { ...block.input, toolUseId: block.id }, raw: makeRaw(obj) };
71
- }
72
- return {
73
- kind: 'tool_use',
74
- data: { name: block.name, input: block.input, id: block.id },
75
- raw: makeRaw(obj),
76
- };
77
- }
78
- // tool_result carries the tool_use_id of the completed Task/Agent call.
79
- // The live store uses this to update the agent's lastActivityAt bookend.
80
- if (block?.type === 'tool_result' && block.tool_use_id) {
81
- return { kind: 'tool_result', data: { toolUseId: block.tool_use_id }, raw: makeRaw(obj) };
82
- }
83
- }
163
+ if (Array.isArray(content) && content.length > 0) {
164
+ for (const block of content) classifyBlock(block, obj, ref, type, events);
165
+ } else if (!usage) {
166
+ events.push(makeEvent(type || 'message', obj, obj, ref));
84
167
  }
85
168
 
86
- return { kind: type || 'message', data: obj, raw: makeRaw(obj) };
169
+ return events;
87
170
  }
88
171
 
89
- module.exports = { MAX_RAW_STR, EXEMPT_TYPES, trimContentArray, makeRaw, classifyLine };
172
+ module.exports = { MAX_RAW_STR, PREVIEW_CHARS, EXEMPT_TYPES, trimContentArray, makeRaw, buildPreviewText, classifyLine };
@@ -141,7 +141,7 @@ function withPathLock(lockPath, task) {
141
141
  * The Epic's id doubles as its directory name under scheduler/epics/, so the
142
142
  * PromptSession ↔ on-disk Epic mapping is 1:1 with no lookup table.
143
143
  */
144
- function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, source = null, agentType = null, mintAuthority = null } = {}) {
144
+ function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, sections = null, source = null, agentType = null, mintAuthority = null } = {}) {
145
145
  if (!cwd || typeof cwd !== 'string') throw new Error('ensureEpic: cwd is required');
146
146
  // A relative cwd (e.g. a caller passing '.') would otherwise get stored
147
147
  // verbatim on the minted Epic's `cwd` field — the renderer's EpicsWorkspace
@@ -218,6 +218,11 @@ function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'prop
218
218
  // Full body for a proposal whose goalText is only a one-line title;
219
219
  // sent verbatim as the first prompt when a human approves it.
220
220
  ...(openingPrompt ? { openingPrompt: String(openingPrompt) } : {}),
221
+ // Structured slices of the same openingPrompt (composeEpicIntake's
222
+ // EpicIntakeSection[]) — carried alongside it so the Epic's first turn
223
+ // can render an AIM briefing card instead of re-parsing the flat
224
+ // string. Absent whenever openingPrompt is absent too.
225
+ ...(Array.isArray(sections) && sections.length ? { sections } : {}),
221
226
  // Structured trace of which surface minted this Epic — see EpicSource in
222
227
  // state/promptSessions.ts.
223
228
  ...(source ? { source } : {}),
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * epicValidationHook.cjs — a PRD check-in triggers validation in the
5
+ * authoring Epic; it never asserts the PRD is done (PRD 986).
6
+ *
7
+ * WHY: PRD 972 ran 34 s, made zero edits, exited 0, and the queue recorded
8
+ * `completed`. Three layers of scheduler-side automation failed to notice.
9
+ * The party with the context to judge whether the work is right is the Epic
10
+ * that WROTE the PRD — so a check-in is inverted from "assertion of done"
11
+ * into a REQUEST TO VALIDATE: when the scheduler appends a check-in response
12
+ * event to the authoring Epic's chain, this hook enqueues ONE validation
13
+ * prompt into that Epic's own chat session instructing it to independently
14
+ * verify each acceptance criterion against the real working tree and answer
15
+ * VERIFIED or REFUTED with evidence. The job's self-reported status is an
16
+ * input to that check, never a substitute for it.
17
+ *
18
+ * Shape mirrors lib/dodDrainHook.cjs: fire-and-forget (never throws to the
19
+ * caller — errors are logged), kill-switched, idempotent.
20
+ *
21
+ * Kill-switch: SM_EPIC_VALIDATION_DISABLE=1 (mirrors the SM_DOD_DISABLE
22
+ * precedent) — turns the whole hook off without a code change.
23
+ *
24
+ * SESSION SLOT POOL: this module spawns NOTHING. The prompt is enqueued via
25
+ * chatRunner.cjs's enqueueExternalPrompt → `chat:external-send` → the
26
+ * renderer's chat queue → chatRunner's pump, which acquires a slot from the
27
+ * machine-wide lib/sessionSlots.cjs pool (chatRunner.cjs pump()) before any
28
+ * `claude -p` process starts. So the validation session cannot start outside
29
+ * the pool — if the pool is exhausted the prompt simply waits in the chat
30
+ * lane's FIFO, it never fans out into an extra parallel process (the
31
+ * 2026-06-10 OOM shape this AC exists to prevent).
32
+ *
33
+ * Cost note: this spends tokens per PRD check-in — intended trade. The
34
+ * once-per-(epicId, prdSlug) guard and the kill-switch keep it bounded.
35
+ *
36
+ * Join-only: nothing here can create an Epic (epicMint.cjs's SINGLE-CREATOR
37
+ * LAW). If no active authoring Epic exists, log and do nothing.
38
+ */
39
+
40
+ const fs = require('node:fs');
41
+ const path = require('node:path');
42
+
43
+ /**
44
+ * LOOP GUARD + once-per-pair bookkeeping.
45
+ *
46
+ * `_fired` records every (epicId, prdSlug) pair this process has already
47
+ * enqueued a validation prompt for — the fast in-memory half of the
48
+ * once-per-pair guard (the durable half re-reads the Epic's own event chain,
49
+ * see maybeEnqueueValidationPrompt below).
50
+ */
51
+ const _fired = new Set();
52
+
53
+ function pairKey(epicId, prdSlug) {
54
+ return `${epicId}::${prdSlug}`;
55
+ }
56
+
57
+ /**
58
+ * Default active-index reader: the same on-disk file
59
+ * promptSessionEvents.cjs writes. Read-only here (no lock needed — a torn
60
+ * read degrades to "skip", never to a bad write). Returns null on any
61
+ * error/missing file so callers treat it as "no active Epic".
62
+ */
63
+ function defaultReadActiveIndex(cwd) {
64
+ try {
65
+ const p = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
66
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * buildValidationPrompt — pure prompt builder.
74
+ *
75
+ * The prompt must carry: the PRD slug, the absolute path to its .md, the
76
+ * job's self-reported outcome explicitly labelled as an UNVERIFIED CLAIM,
77
+ * the instruction to check every Acceptance Criterion against the actual
78
+ * working tree, and the VERIFIED/REFUTED reply contract with per-criterion
79
+ * evidence. It also warns against the exact failure mode that produced
80
+ * PRD 986: exit 0 / a green queue row / a confident report are not evidence.
81
+ */
82
+ function buildValidationPrompt({ prdSlug, prdPath, outcome }) {
83
+ const pathLine = prdPath
84
+ ? `PRD file (absolute path): ${prdPath}`
85
+ : 'PRD file: path could not be resolved — locate it under session-manager-operations/scheduler/epics/*/prds-archived/ by slug.';
86
+ return [
87
+ `VALIDATION REQUEST for PRD ${prdSlug} — this is a request to validate, NOT a completion notice.`,
88
+ pathLine,
89
+ `The scheduler job self-reported outcome "${outcome}". Treat that strictly as an UNVERIFIED CLAIM — it carries no authority about whether the work actually landed.`,
90
+ '',
91
+ 'Do the following, independently:',
92
+ `1. Read the PRD's own "Acceptance criteria" section from the file above.`,
93
+ '2. Check EACH criterion against the actual working tree (read the real files, run the real commands).',
94
+ '3. Run `git diff --stat` over the run window (and `git log --stat` for commits landed during the run). An empty diff on an implementation PRD means the work did not land — treat that as REFUTED.',
95
+ '',
96
+ 'WARNING — the failure mode this validation exists to catch: an exit code of 0, a green queue row, or a confident completion report are NOT evidence that anything shipped. Only the working tree is evidence. (A prior PRD reported "completed" having made zero edits.)',
97
+ '',
98
+ 'Reply with exactly one verdict word, VERIFIED or REFUTED, followed by per-criterion evidence: for each acceptance criterion cite file:line or paste the command output that proves or disproves it.',
99
+ ].join('\n');
100
+ }
101
+
102
+ /**
103
+ * maybeEnqueueValidationPrompt(args, deps) → { enqueued: boolean, reason?: string }
104
+ *
105
+ * Called by scheduler.cjs's notifyOriginatingTab immediately after a
106
+ * SUCCESSFUL appendResponseEventIfKnown for a terminal (completed/failed)
107
+ * PRD outcome. Fire-and-forget: never throws; every refusal returns a
108
+ * reason so tests (and log lines) can tell the gates apart.
109
+ *
110
+ * Guards, in order (all four AC gates):
111
+ * 1. SM_EPIC_VALIDATION_DISABLE=1 kill-switch → skip.
112
+ * 2. LOOP GUARD — how the guard distinguishes a check-in from a
113
+ * validation result: a scheduler check-in event is born with
114
+ * `validation: 'unvalidated'` (stamped by appendResponseEventIfKnown's
115
+ * meta), while a validation RESULT event carries 'validating' /
116
+ * 'verified' / 'refuted' (and a plain chat response carries no
117
+ * validation field at all). Only `eventValidation === 'unvalidated'`
118
+ * may trigger a prompt, so an appended validation result can never
119
+ * enqueue a further prompt — no loop.
120
+ * 3. Epic must exist AND have status 'active' in the on-disk
121
+ * active-index.json (re-checked here even though the append already
122
+ * enforced it, so the gate holds for any future call site too).
123
+ * 4. Once per (epicId, prdSlug): in-memory `_fired` Set for the common
124
+ * path, plus a durable re-check of the Epic's own event chain — the
125
+ * check-in event just appended for this pair accounts for ONE
126
+ * validation-stamped response event with this prdSlug; two or more
127
+ * means an earlier check-in already requested validation (e.g. a
128
+ * re-notify after an app restart emptied `_fired`), so skip.
129
+ *
130
+ * (Gate: slot pool — see the module doc comment; no spawn happens here.)
131
+ *
132
+ * Complexity: O(n) over the Epic's event chain for the durable dedup scan.
133
+ */
134
+ function maybeEnqueueValidationPrompt(
135
+ { cwd, epicId, prdSlug, prdPath = null, outcome, eventValidation },
136
+ { sendPrompt, readActiveIndex = defaultReadActiveIndex, log = console } = {},
137
+ ) {
138
+ try {
139
+ // Gate 1: kill-switch (SM_EPIC_VALIDATION_DISABLE, per SM_DOD_DISABLE precedent).
140
+ if (process.env.SM_EPIC_VALIDATION_DISABLE === '1') return { enqueued: false, reason: 'disabled' };
141
+
142
+ // Gate 2: LOOP GUARD (see doc comment above for how the field value
143
+ // distinguishes a check-in from a validation result).
144
+ if (eventValidation !== 'unvalidated') return { enqueued: false, reason: 'not-a-checkin' };
145
+
146
+ if (!cwd || !epicId || !prdSlug || typeof sendPrompt !== 'function') {
147
+ return { enqueued: false, reason: 'missing-args' };
148
+ }
149
+
150
+ // Gate 4a: in-memory once-per-pair (checked before the disk read — cheap first).
151
+ const key = pairKey(epicId, prdSlug);
152
+ if (_fired.has(key)) return { enqueued: false, reason: 'already-fired' };
153
+
154
+ // Gate 3: authoring Epic must be a known, still-active session.
155
+ const index = readActiveIndex(cwd);
156
+ const session = index && index.sessions && index.sessions[epicId];
157
+ if (!session || session.status !== 'active') {
158
+ return { enqueued: false, reason: 'epic-not-active' };
159
+ }
160
+
161
+ // Gate 4b: durable once-per-pair — the just-appended check-in accounts
162
+ // for one validation-stamped response event for this prdSlug; a second
163
+ // one means a previous check-in already asked.
164
+ const events = (index.events && index.events[epicId]) || [];
165
+ const priorCheckins = events.filter(
166
+ (e) => e && e.kind === 'response' && e.prdSlug === prdSlug && e.validation !== undefined,
167
+ );
168
+ if (priorCheckins.length >= 2) {
169
+ _fired.add(key); // remember so later re-notifies skip the disk read too
170
+ return { enqueued: false, reason: 'already-fired-durable' };
171
+ }
172
+
173
+ const prompt = buildValidationPrompt({ prdSlug, prdPath, outcome });
174
+ sendPrompt(epicId, prompt);
175
+ _fired.add(key);
176
+ return { enqueued: true };
177
+ } catch (e) {
178
+ try { (log || console).error('[epicValidationHook] enqueue error', prdSlug, e); } catch { /* noop */ }
179
+ return { enqueued: false, reason: 'error' };
180
+ }
181
+ }
182
+
183
+ /** Test hook: clear the once-per-pair memory. */
184
+ function __resetForTests() {
185
+ _fired.clear();
186
+ }
187
+
188
+ module.exports = {
189
+ buildValidationPrompt,
190
+ maybeEnqueueValidationPrompt,
191
+ __resetForTests,
192
+ };