claude-code-session-manager 0.36.0 → 0.37.1

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 (34) hide show
  1. package/dist/assets/{TiptapBody-D5b7nejd.js → TiptapBody-DXQDiOUx.js} +58 -58
  2. package/dist/assets/index--a8m5_ET.js +3496 -0
  3. package/dist/assets/index-CTTjT08J.css +32 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +2 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
  7. package/scripts/lib/activeSessions.cjs +117 -0
  8. package/scripts/lib/watchdogHelpers.cjs +699 -0
  9. package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
  10. package/src/main/__tests__/historyDashboard.test.cjs +163 -0
  11. package/src/main/__tests__/historyRollup.test.cjs +333 -0
  12. package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
  13. package/src/main/__tests__/queueHistory.test.cjs +233 -0
  14. package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
  15. package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
  16. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
  17. package/src/main/chatRunner.cjs +46 -12
  18. package/src/main/config.cjs +8 -0
  19. package/src/main/historyAggregator.cjs +391 -50
  20. package/src/main/historyDashboard.cjs +226 -0
  21. package/src/main/index.cjs +35 -1
  22. package/src/main/ipcSchemas.cjs +5 -0
  23. package/src/main/lib/broadcastCoalescer.cjs +46 -0
  24. package/src/main/lib/historyRollup.cjs +148 -0
  25. package/src/main/lib/queueHistory.cjs +216 -0
  26. package/src/main/lib/rcaFeedbackHook.cjs +346 -0
  27. package/src/main/lib/schedulerConfig.cjs +16 -0
  28. package/src/main/queueOps.cjs +92 -0
  29. package/src/main/scheduler/prdParser.cjs +52 -1
  30. package/src/main/scheduler.cjs +237 -29
  31. package/src/preload/api.d.ts +69 -1
  32. package/src/preload/index.cjs +1 -0
  33. package/dist/assets/index-CkiGRskz.css +0 -32
  34. package/dist/assets/index-DrzirIUy.js +0 -3562
@@ -0,0 +1,233 @@
1
+ /**
2
+ * queueHistory.test.cjs — unit tests for src/main/lib/queueHistory.cjs and
3
+ * scheduler.cjs's selectHistoryJobs merge.
4
+ *
5
+ * Run: timeout 300 npx vitest run src/main/__tests__/queueHistory.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ import { test, expect, beforeEach, afterEach } from 'vitest';
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+
15
+ const RETENTION_MS = 7 * 24 * 60 * 60_000;
16
+ const NOW = Date.parse('2026-07-24T12:00:00.000Z');
17
+
18
+ function fresh(overrides = {}) {
19
+ return {
20
+ slug: '01-fresh',
21
+ status: 'completed',
22
+ runId: 'run-1',
23
+ finishedAt: new Date(NOW - 1 * 60 * 60_000).toISOString(), // 1h ago
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function old(overrides = {}) {
29
+ return {
30
+ slug: '02-old',
31
+ status: 'completed',
32
+ runId: 'run-2',
33
+ finishedAt: new Date(NOW - 10 * 24 * 60 * 60_000).toISOString(), // 10d ago
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ let queueHistory;
39
+
40
+ beforeEach(() => {
41
+ // Fresh require per test so HISTORY_PATH's target file starts clean.
42
+ delete require.cache[require.resolve('../lib/queueHistory.cjs')];
43
+ queueHistory = require('../lib/queueHistory.cjs');
44
+ try { fs.unlinkSync(queueHistory.HISTORY_PATH); } catch { /* ok if absent */ }
45
+ });
46
+
47
+ afterEach(() => {
48
+ try { fs.unlinkSync(queueHistory.HISTORY_PATH); } catch { /* ok if absent */ }
49
+ });
50
+
51
+ // ---------- partitionJobs ----------
52
+
53
+ test('partitionJobs: fresh completed job stays hot', () => {
54
+ const { hot, toArchive } = queueHistory.partitionJobs([fresh()], NOW);
55
+ expect(hot.map((j) => j.slug)).toEqual(['01-fresh']);
56
+ expect(toArchive).toEqual([]);
57
+ });
58
+
59
+ test('partitionJobs: old completed job archives', () => {
60
+ const { hot, toArchive } = queueHistory.partitionJobs([old()], NOW);
61
+ expect(hot).toEqual([]);
62
+ expect(toArchive.map((j) => j.slug)).toEqual(['02-old']);
63
+ });
64
+
65
+ test('partitionJobs: old failed job archives', () => {
66
+ const j = old({ slug: '03-old-failed', status: 'failed', runId: 'run-3' });
67
+ const { hot, toArchive } = queueHistory.partitionJobs([j], NOW);
68
+ expect(hot).toEqual([]);
69
+ expect(toArchive.map((x) => x.slug)).toEqual(['03-old-failed']);
70
+ });
71
+
72
+ test('partitionJobs: running and pending jobs never archive, regardless of age', () => {
73
+ const running = old({ slug: '04-running', status: 'running', runId: 'run-4', finishedAt: null });
74
+ const pending = old({ slug: '05-pending', status: 'pending', runId: null, finishedAt: null });
75
+ const { hot, toArchive } = queueHistory.partitionJobs([running, pending], NOW);
76
+ expect(toArchive).toEqual([]);
77
+ expect(hot.map((j) => j.slug).sort()).toEqual(['04-running', '05-pending']);
78
+ });
79
+
80
+ test('partitionJobs: needs_review jobs never archive', () => {
81
+ const j = old({ slug: '06-review', status: 'needs_review', runId: 'run-6' });
82
+ const { hot, toArchive } = queueHistory.partitionJobs([j], NOW);
83
+ expect(toArchive).toEqual([]);
84
+ expect(hot.map((x) => x.slug)).toEqual(['06-review']);
85
+ });
86
+
87
+ test('partitionJobs: job with no/invalid finishedAt stays hot even if old status', () => {
88
+ const j = { slug: '07-nofinish', status: 'completed', runId: 'run-7', finishedAt: null };
89
+ const { hot, toArchive } = queueHistory.partitionJobs([j], NOW);
90
+ expect(toArchive).toEqual([]);
91
+ expect(hot.map((x) => x.slug)).toEqual(['07-nofinish']);
92
+ });
93
+
94
+ test('partitionJobs: fix-plan linkage keeps an old original hot while its fix is pending', () => {
95
+ const original = old({ slug: '08-thing', status: 'failed', runId: 'run-8' });
96
+ const fixPlan = { slug: '08-fix-thing', status: 'pending', runId: null, finishedAt: null };
97
+ const { hot, toArchive } = queueHistory.partitionJobs([original, fixPlan], NOW);
98
+ expect(toArchive).toEqual([]);
99
+ expect(hot.map((x) => x.slug).sort()).toEqual(['08-fix-thing', '08-thing']);
100
+ });
101
+
102
+ test('partitionJobs: fix-plan linkage keeps an old original hot while its fix is running', () => {
103
+ const original = old({ slug: '09-thing', status: 'failed', runId: 'run-9' });
104
+ const fixPlan = { slug: '09-fix-thing', status: 'running', runId: 'run-9b', finishedAt: null };
105
+ const { hot, toArchive } = queueHistory.partitionJobs([original, fixPlan], NOW);
106
+ expect(toArchive).toEqual([]);
107
+ expect(hot.map((x) => x.slug).sort()).toEqual(['09-fix-thing', '09-thing']);
108
+ });
109
+
110
+ test('partitionJobs: an old original with a COMPLETED fix-plan is free to archive (no longer protected)', () => {
111
+ const original = old({ slug: '10-thing', status: 'completed', runId: 'run-10' });
112
+ const fixPlan = old({ slug: '10-fix-thing', status: 'completed', runId: 'run-10b' });
113
+ const { hot, toArchive } = queueHistory.partitionJobs([original, fixPlan], NOW);
114
+ expect(hot).toEqual([]);
115
+ expect(toArchive.map((x) => x.slug).sort()).toEqual(['10-fix-thing', '10-thing']);
116
+ });
117
+
118
+ test('partitionJobs: respects opts.retentionMs override', () => {
119
+ const j = { slug: '11-recent', status: 'completed', runId: 'run-11', finishedAt: new Date(NOW - 2 * 60 * 60_000).toISOString() };
120
+ const { toArchive } = queueHistory.partitionJobs([j], NOW, { retentionMs: 60 * 60_000 }); // 1h retention
121
+ expect(toArchive.map((x) => x.slug)).toEqual(['11-recent']);
122
+ });
123
+
124
+ // ---------- appendHistory / readHistory ----------
125
+
126
+ test('appendHistory writes entries, readHistory reads them back newest-first', async () => {
127
+ const a = old({ slug: 'a', runId: 'ra', finishedAt: new Date(NOW - 3 * 60 * 60_000).toISOString() });
128
+ const b = old({ slug: 'b', runId: 'rb', finishedAt: new Date(NOW - 1 * 60 * 60_000).toISOString() });
129
+ await queueHistory.appendHistory([a, b]);
130
+ const read = await queueHistory.readHistory({ limit: 10 });
131
+ // appended in file order a,b; readHistory reverses file order to newest-appended-first
132
+ expect(read.map((j) => j.slug)).toEqual(['b', 'a']);
133
+ });
134
+
135
+ test('appendHistory dedupes by slug+runId against what is already on disk', async () => {
136
+ const a = old({ slug: 'dup', runId: 'r1' });
137
+ const r1 = await queueHistory.appendHistory([a]);
138
+ expect(r1.appended).toBe(1);
139
+ const r2 = await queueHistory.appendHistory([a]); // replay of the same batch (crash-recovery scenario)
140
+ expect(r2.appended).toBe(0);
141
+ const read = await queueHistory.readHistory({ limit: 10 });
142
+ expect(read.length).toBe(1);
143
+ });
144
+
145
+ test('readHistory respects limit and returns [] when file is absent', async () => {
146
+ const empty = await queueHistory.readHistory({ limit: 5 });
147
+ expect(empty).toEqual([]);
148
+
149
+ const entries = Array.from({ length: 8 }, (_, i) => old({ slug: `s${i}`, runId: `r${i}`, finishedAt: new Date(NOW - i * 60_000).toISOString() }));
150
+ await queueHistory.appendHistory(entries);
151
+ const read = await queueHistory.readHistory({ limit: 3 });
152
+ expect(read.length).toBe(3);
153
+ // newest-appended-first == last 3 entries reversed
154
+ expect(read.map((j) => j.slug)).toEqual(['s7', 's6', 's5']);
155
+ });
156
+
157
+ test('readHistory tail-reads correctly when the file is larger than one chunk', async () => {
158
+ // Force many small appends so the file exceeds the initial 64KB read chunk.
159
+ const bigEntries = Array.from({ length: 2000 }, (_, i) => ({
160
+ slug: `bulk-${i}`,
161
+ status: 'completed',
162
+ runId: `bulk-run-${i}`,
163
+ finishedAt: new Date(NOW - i * 1000).toISOString(),
164
+ bodyPreview: 'x'.repeat(80),
165
+ }));
166
+ await queueHistory.appendHistory(bigEntries);
167
+ const read = await queueHistory.readHistory({ limit: 5 });
168
+ // appended in ascending index order; readHistory reverses to newest-appended-first
169
+ expect(read.map((j) => j.slug)).toEqual(['bulk-1999', 'bulk-1998', 'bulk-1997', 'bulk-1996', 'bulk-1995']);
170
+ });
171
+
172
+ // ---------- selectHistoryJobs merge (scheduler.cjs) ----------
173
+
174
+ test('selectHistoryJobs merges hot jobs[] and history tail, newest-first, deduped', () => {
175
+ const { selectHistoryJobs } = require('../scheduler.cjs');
176
+ const hotJob = fresh({ slug: 'hot-1', runId: 'rh1' });
177
+ const historyEntry = old({ slug: 'hist-1', runId: 'rhist1' });
178
+ const merged = selectHistoryJobs([hotJob], 10, [historyEntry]);
179
+ expect(merged.map((j) => j.slug)).toEqual(['hot-1', 'hist-1']); // hot is newer
180
+ });
181
+
182
+ test('selectHistoryJobs: jobs[] entry wins over a duplicate history entry with the same slug+runId', () => {
183
+ const { selectHistoryJobs } = require('../scheduler.cjs');
184
+ const hotCopy = fresh({ slug: 'dup', runId: 'rd', bodyPreview: 'from-hot' });
185
+ const historyCopy = fresh({ slug: 'dup', runId: 'rd', bodyPreview: 'from-history' });
186
+ const merged = selectHistoryJobs([hotCopy], 10, [historyCopy]);
187
+ expect(merged.length).toBe(1);
188
+ expect(merged[0].bodyPreview).toBe('from-hot');
189
+ });
190
+
191
+ test('selectHistoryJobs: default historyEntries=[] preserves old call shape', () => {
192
+ const { selectHistoryJobs } = require('../scheduler.cjs');
193
+ const merged = selectHistoryJobs([fresh()], 10);
194
+ expect(merged.map((j) => j.slug)).toEqual(['01-fresh']);
195
+ });
196
+
197
+ // ---------- historyTerminalBySlug ----------
198
+ //
199
+ // Guards against scheduler.cjs's reconcile() resurrecting an already-
200
+ // archived-to-history job as a fresh 'pending' entry (and the scheduler
201
+ // then genuinely re-executing an already-completed PRD) just because its
202
+ // job row already left jobs[]. reconcile() consults this map before
203
+ // treating an unmatched on-disk PRD slug as brand-new.
204
+
205
+ test('historyTerminalBySlug: returns status + finishedAt for every archived slug', async () => {
206
+ const a = old({ slug: 'term-completed', status: 'completed', runId: 'rc' });
207
+ const b = old({ slug: 'term-failed', status: 'failed', runId: 'rf' });
208
+ await queueHistory.appendHistory([a, b]);
209
+
210
+ const map = await queueHistory.historyTerminalBySlug();
211
+ expect(map.get('term-completed')).toEqual({ status: 'completed', finishedAt: a.finishedAt });
212
+ expect(map.get('term-failed')).toEqual({ status: 'failed', finishedAt: b.finishedAt });
213
+ expect(map.has('never-archived-slug')).toBe(false);
214
+ });
215
+
216
+ test('historyTerminalBySlug: returns an empty map when history.jsonl is absent', async () => {
217
+ const map = await queueHistory.historyTerminalBySlug();
218
+ expect(map.size).toBe(0);
219
+ });
220
+
221
+ test('historyTerminalBySlug: cache invalidates after a new appendHistory call (mtime changes)', async () => {
222
+ const a = old({ slug: 'cache-1', runId: 'rca' });
223
+ await queueHistory.appendHistory([a]);
224
+ const first = await queueHistory.historyTerminalBySlug();
225
+ expect(first.has('cache-1')).toBe(true);
226
+ expect(first.has('cache-2')).toBe(false);
227
+
228
+ const b = old({ slug: 'cache-2', runId: 'rcb' });
229
+ await queueHistory.appendHistory([b]);
230
+ const second = await queueHistory.historyTerminalBySlug();
231
+ expect(second.has('cache-1')).toBe(true);
232
+ expect(second.has('cache-2')).toBe(true);
233
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * queueOpsAutoArchive.test.cjs — unit tests for queueOps.cjs's
3
+ * selectAutoArchivable predicate + autoArchiveCompleted orchestration
4
+ * (auto-archiving completed PRDs' .md files out of the live prds/ dir).
5
+ *
6
+ * selectAutoArchivable is pure (no fs) so it's fully covered here without
7
+ * touching disk. autoArchiveCompleted's disk-mutating path (archiveMany) is
8
+ * exercised only via its kill-switch / no-op branches, since archiveMany
9
+ * targets the real ~/.claude/session-manager/scheduled-plans/prds-archived
10
+ * directory (not test-injectable) — mutating that from a test run would be
11
+ * an unwanted side effect on the developer's real filesystem.
12
+ *
13
+ * Run: timeout 300 npx vitest run src/main/__tests__/queueOpsAutoArchive.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect, afterEach } from 'vitest';
19
+
20
+ const RETENTION_MS = 7 * 24 * 60 * 60_000;
21
+ const NOW = Date.parse('2026-07-24T12:00:00.000Z');
22
+
23
+ const { selectAutoArchivable, autoArchiveCompleted } = require('../queueOps.cjs');
24
+
25
+ function fresh(overrides = {}) {
26
+ return {
27
+ slug: '01-fresh',
28
+ status: 'completed',
29
+ finishedAt: new Date(NOW - 1 * 60 * 60_000).toISOString(), // 1h ago
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ function old(overrides = {}) {
35
+ return {
36
+ slug: '02-old',
37
+ status: 'completed',
38
+ finishedAt: new Date(NOW - 10 * 24 * 60 * 60_000).toISOString(), // 10d ago
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ afterEach(() => {
44
+ delete process.env.SM_PRD_AUTOARCHIVE_DISABLE;
45
+ });
46
+
47
+ // ---------- selectAutoArchivable ----------
48
+
49
+ test('selectAutoArchivable: fresh completed job stays (not old enough)', () => {
50
+ const slugs = selectAutoArchivable([fresh()], { nowMs: NOW });
51
+ expect(slugs).toEqual([]);
52
+ });
53
+
54
+ test('selectAutoArchivable: old completed job archives', () => {
55
+ const slugs = selectAutoArchivable([old()], { nowMs: NOW });
56
+ expect(slugs).toEqual(['02-old']);
57
+ });
58
+
59
+ test('selectAutoArchivable: needs_review jobs never archive, regardless of age', () => {
60
+ const j = old({ slug: '03-review', status: 'needs_review' });
61
+ const slugs = selectAutoArchivable([j], { nowMs: NOW });
62
+ expect(slugs).toEqual([]);
63
+ });
64
+
65
+ test('selectAutoArchivable: failed jobs never archive, regardless of age', () => {
66
+ const j = old({ slug: '04-failed', status: 'failed' });
67
+ const slugs = selectAutoArchivable([j], { nowMs: NOW });
68
+ expect(slugs).toEqual([]);
69
+ });
70
+
71
+ test('selectAutoArchivable: pending jobs never archive', () => {
72
+ const j = old({ slug: '05-pending', status: 'pending', finishedAt: null });
73
+ const slugs = selectAutoArchivable([j], { nowMs: NOW });
74
+ expect(slugs).toEqual([]);
75
+ });
76
+
77
+ test('selectAutoArchivable: running jobs never archive', () => {
78
+ const j = old({ slug: '06-running', status: 'running', finishedAt: null });
79
+ const slugs = selectAutoArchivable([j], { nowMs: NOW });
80
+ expect(slugs).toEqual([]);
81
+ });
82
+
83
+ test('selectAutoArchivable: a completed job with a PENDING NN-fix-<slug> sibling is protected', () => {
84
+ const original = old({ slug: '07-thing', status: 'completed' });
85
+ const fixPlan = { slug: '07-fix-thing', status: 'pending', finishedAt: null };
86
+ const slugs = selectAutoArchivable([original, fixPlan], { nowMs: NOW });
87
+ expect(slugs).toEqual([]);
88
+ });
89
+
90
+ test('selectAutoArchivable: a completed job with a RUNNING NN-fix-<slug> sibling is protected', () => {
91
+ const original = old({ slug: '08-thing', status: 'completed' });
92
+ const fixPlan = { slug: '08-fix-thing', status: 'running', finishedAt: null };
93
+ const slugs = selectAutoArchivable([original, fixPlan], { nowMs: NOW });
94
+ expect(slugs).toEqual([]);
95
+ });
96
+
97
+ test('selectAutoArchivable: a completed job with a COMPLETED fix-plan sibling is free to archive (no longer protected)', () => {
98
+ const original = old({ slug: '09-thing', status: 'completed' });
99
+ const fixPlan = old({ slug: '09-fix-thing', status: 'completed' });
100
+ const slugs = selectAutoArchivable([original, fixPlan], { nowMs: NOW }).sort();
101
+ expect(slugs).toEqual(['09-fix-thing', '09-thing']);
102
+ });
103
+
104
+ test('selectAutoArchivable: job with no/invalid finishedAt never archives even if status completed', () => {
105
+ const j = { slug: '10-nofinish', status: 'completed', finishedAt: null };
106
+ const slugs = selectAutoArchivable([j], { nowMs: NOW });
107
+ expect(slugs).toEqual([]);
108
+ });
109
+
110
+ test('selectAutoArchivable: respects retentionMs override', () => {
111
+ const j = { slug: '11-recent', status: 'completed', finishedAt: new Date(NOW - 2 * 60 * 60_000).toISOString() };
112
+ const slugs = selectAutoArchivable([j], { nowMs: NOW, retentionMs: 60 * 60_000 }); // 1h retention
113
+ expect(slugs).toEqual(['11-recent']);
114
+ });
115
+
116
+ test('selectAutoArchivable: defaults retentionMs to the shared HISTORY_RETENTION_MS constant', () => {
117
+ const j = { slug: '12-boundary', status: 'completed', finishedAt: new Date(NOW - RETENTION_MS - 1).toISOString() };
118
+ const slugs = selectAutoArchivable([j], { nowMs: NOW });
119
+ expect(slugs).toEqual(['12-boundary']);
120
+ });
121
+
122
+ // ---------- autoArchiveCompleted ----------
123
+
124
+ test('autoArchiveCompleted: kill switch SM_PRD_AUTOARCHIVE_DISABLE=1 skips entirely', async () => {
125
+ process.env.SM_PRD_AUTOARCHIVE_DISABLE = '1';
126
+ const result = await autoArchiveCompleted({ jobs: [old()] }, { nowMs: NOW });
127
+ expect(result.archived).toBe(0);
128
+ expect(result.skipped).toBe('disabled');
129
+ });
130
+
131
+ test('autoArchiveCompleted: no eligible slugs returns a no-op without touching disk', async () => {
132
+ const result = await autoArchiveCompleted({ jobs: [fresh()] }, { nowMs: NOW });
133
+ expect(result.ok).toBe(true);
134
+ expect(result.archived).toBe(0);
135
+ expect(result.archivedTo).toBe(null);
136
+ expect(result.results).toEqual([]);
137
+ });
138
+
139
+ test('autoArchiveCompleted: tolerates a missing/malformed state.jobs', async () => {
140
+ const result = await autoArchiveCompleted({}, { nowMs: NOW });
141
+ expect(result.archived).toBe(0);
142
+ });
@@ -0,0 +1,259 @@
1
+ /**
2
+ * rcaFeedbackHook.test.cjs — unit tests for src/main/lib/rcaFeedbackHook.cjs.
3
+ *
4
+ * Run: timeout 120 npx vitest run src/main/__tests__/rcaFeedbackHook.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ import { test, expect, beforeEach, afterEach } from 'vitest';
10
+ const fs = require('node:fs');
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+
14
+ let tmpHome;
15
+ let realHome;
16
+ let rcaFeedbackHook;
17
+
18
+ // config.cjs and rcaFeedbackHook.cjs both bake os.homedir() into top-level
19
+ // consts at first require (allowedRoots/WRITE_PREFIXES, PRDS_DIR/SM_REPO_ROOT).
20
+ // Node's require cache must be purged per test so each test's tmpHome takes
21
+ // effect — vi.resetModules() only resets vitest's ESM graph, not require().
22
+ const MODULES_TO_RELOAD = ['../lib/rcaFeedbackHook.cjs', '../config.cjs'];
23
+
24
+ function purgeRequireCache() {
25
+ for (const m of MODULES_TO_RELOAD) {
26
+ try { delete require.cache[require.resolve(m)]; } catch { /* not loaded yet */ }
27
+ }
28
+ }
29
+
30
+ beforeEach(() => {
31
+ realHome = process.env.HOME;
32
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rca-feedback-hook-test-'));
33
+ process.env.HOME = tmpHome;
34
+ delete process.env.SM_RCA_DISABLE;
35
+ purgeRequireCache();
36
+ rcaFeedbackHook = require('../lib/rcaFeedbackHook.cjs');
37
+ // The session-manager-repo fallback destination must exist as a real dir.
38
+ fs.mkdirSync(path.join(tmpHome, 'Projects', 'session-manager', 'session-manager-operations', 'feedback'), { recursive: true });
39
+ });
40
+
41
+ afterEach(() => {
42
+ process.env.HOME = realHome;
43
+ delete process.env.SM_RCA_DISABLE;
44
+ fs.rmSync(tmpHome, { recursive: true, force: true });
45
+ purgeRequireCache();
46
+ });
47
+
48
+ // ─── helpers ──────────────────────────────────────────────────────────────────
49
+
50
+ function makeProjectWithInbox(name = 'proj') {
51
+ const cwd = path.join(tmpHome, name);
52
+ fs.mkdirSync(path.join(cwd, 'session-manager-operations', 'feedback'), { recursive: true });
53
+ return cwd;
54
+ }
55
+
56
+ function makeProjectWithoutInbox(name = 'bare-proj') {
57
+ const cwd = path.join(tmpHome, name);
58
+ fs.mkdirSync(cwd, { recursive: true });
59
+ return cwd;
60
+ }
61
+
62
+ function writeRun(runDir, slug, { logLines = ['line1', 'line2'], exitCode = 1, durationMs = 1234 } = {}) {
63
+ fs.mkdirSync(runDir, { recursive: true });
64
+ fs.writeFileSync(path.join(runDir, `${slug}.log`), logLines.join('\n') + '\n');
65
+ fs.writeFileSync(path.join(runDir, `${slug}.meta.json`), JSON.stringify({ exitCode, durationMs }));
66
+ }
67
+
68
+ function writePrd(slug, { acBody = '- [ ] `timeout 10 echo ok` passes.' } = {}) {
69
+ const prdsDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'prds');
70
+ fs.mkdirSync(prdsDir, { recursive: true });
71
+ const body = ['---', `title: ${slug}`, '---', '', '# Goal', '', 'Do the thing.', '', '# Acceptance criteria', '', acBody, '', '# Out of scope', '', '- N/A'].join('\n');
72
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), body);
73
+ }
74
+
75
+ function baseJob(overrides = {}) {
76
+ return { slug: 'testslug', runId: '2026-07-24T00-00-00-000Z', exitCode: 1, error: 'something broke', ...overrides };
77
+ }
78
+
79
+ // ─── dedupe / new-runId ───────────────────────────────────────────────────────
80
+
81
+ test('fileRcaFeedback: second call with same (slug, runId) is a no-op', async () => {
82
+ const cwd = makeProjectWithInbox();
83
+ const runDir = path.join(tmpHome, 'run1');
84
+ writeRun(runDir, 'testslug');
85
+ writePrd('testslug');
86
+ const job = baseJob({ cwd });
87
+
88
+ const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
89
+ expect(first.filed).toBe(true);
90
+
91
+ const second = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
92
+ expect(second.filed).toBe(false);
93
+ expect(second.reason).toBe('duplicate');
94
+
95
+ const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
96
+ expect(files.length).toBe(1);
97
+ });
98
+
99
+ test('fileRcaFeedback: new runId for the same slug files a new RCA', async () => {
100
+ const cwd = makeProjectWithInbox();
101
+ const runDir1 = path.join(tmpHome, 'run1');
102
+ const runDir2 = path.join(tmpHome, 'run2');
103
+ writeRun(runDir1, 'testslug');
104
+ writeRun(runDir2, 'testslug');
105
+ writePrd('testslug');
106
+
107
+ const r1 = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, runId: 'run-aaa' }), runDir: runDir1, verdict: 'transcript_errors' });
108
+ const r2 = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, runId: 'run-bbb' }), runDir: runDir2, verdict: 'transcript_errors' });
109
+
110
+ expect(r1.filed).toBe(true);
111
+ expect(r2.filed).toBe(true);
112
+ expect(r1.path).not.toBe(r2.path);
113
+
114
+ const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
115
+ expect(files.length).toBe(2);
116
+ });
117
+
118
+ // ─── failure-class matching ───────────────────────────────────────────────────
119
+
120
+ test('classifyFailure: detects stuck-loop from an until/while-true/sleep tail', () => {
121
+ const logTail = ['doing work', 'until curl -s http://x | jq .uptime; do sleep 5; done', 'still waiting'].join('\n');
122
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('stuck-loop');
123
+ });
124
+
125
+ test('classifyFailure: detects post-ac-overrun when work continues well past the last AC checkbox with no PASS', () => {
126
+ const lines = ['- [x] first AC item done', '- [x] last AC item done'];
127
+ for (let i = 0; i < 30; i++) lines.push(`extra post-AC work line ${i}`);
128
+ const logTail = lines.join('\n');
129
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('post-ac-overrun');
130
+ });
131
+
132
+ test('classifyFailure: falls back to uncommitted-changes for that verdict', () => {
133
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'uncommitted_changes', logTail: 'no special markers here' })).toBe('uncommitted-changes');
134
+ });
135
+
136
+ test('classifyFailure: falls back to no-sentinel for no_verdict_sentinel/pass_no_commit', () => {
137
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail: 'clean tail, no loops' })).toBe('no-sentinel');
138
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail: 'clean tail, no loops' })).toBe('no-sentinel');
139
+ });
140
+
141
+ test('classifyFailure: falls back to transcript-errors for transcript_errors/verify_unavailable', () => {
142
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'transcript_errors', logTail: 'Traceback (most recent call last)' })).toBe('transcript-errors');
143
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'verify_unavailable', logTail: 'verifier threw' })).toBe('transcript-errors');
144
+ });
145
+
146
+ test('classifyFailure: unknown verdict with no matching markers falls back to unknown', () => {
147
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'halt', logTail: 'nothing special' })).toBe('unknown');
148
+ });
149
+
150
+ // ─── AC extraction ─────────────────────────────────────────────────────────────
151
+
152
+ test('extractAcceptanceCriteria: pulls the section body between the heading and the next heading', () => {
153
+ const body = ['# Goal', '', 'Do the thing.', '', '# Acceptance criteria', '', '- [ ] one', '- [ ] two', '', '# Out of scope', '', '- N/A'].join('\n');
154
+ expect(rcaFeedbackHook.extractAcceptanceCriteria(body)).toBe('- [ ] one\n- [ ] two');
155
+ });
156
+
157
+ test('extractAcceptanceCriteria: returns null when no such heading exists', () => {
158
+ const body = ['# Goal', '', 'Do the thing.'].join('\n');
159
+ expect(rcaFeedbackHook.extractAcceptanceCriteria(body)).toBeNull();
160
+ });
161
+
162
+ // ─── investigation <RCA> block extraction ──────────────────────────────────────
163
+
164
+ test('extractRcaBlock: pulls plain-text block content', () => {
165
+ const log = 'preamble\n<RCA>\nForgot to bound the poll loop.\n</RCA>\npostamble';
166
+ expect(rcaFeedbackHook.extractRcaBlock(log)).toBe('Forgot to bound the poll loop.');
167
+ });
168
+
169
+ test('extractRcaBlock: unescapes JSON-escaped newlines from a stream-json transcript', () => {
170
+ const log = '{"text":"<RCA>\\nForgot to bound the poll loop.\\nSee scheduler.cjs:1509.\\n</RCA>"}';
171
+ expect(rcaFeedbackHook.extractRcaBlock(log)).toBe('Forgot to bound the poll loop.\nSee scheduler.cjs:1509.');
172
+ });
173
+
174
+ test('extractRcaBlock: returns null when no block is present', () => {
175
+ expect(rcaFeedbackHook.extractRcaBlock('no block here')).toBeNull();
176
+ });
177
+
178
+ // ─── destination resolution ────────────────────────────────────────────────────
179
+
180
+ test('resolveDestination: uses the job cwd inbox when it exists', () => {
181
+ const cwd = makeProjectWithInbox();
182
+ const dest = rcaFeedbackHook.resolveDestination({ cwd });
183
+ expect(dest.dir).toBe(path.join(cwd, 'session-manager-operations', 'feedback'));
184
+ expect(dest.targetProjectNote).toBeNull();
185
+ });
186
+
187
+ test('resolveDestination: falls back to the session-manager repo inbox when the target has none', () => {
188
+ const cwd = makeProjectWithoutInbox();
189
+ const dest = rcaFeedbackHook.resolveDestination({ cwd });
190
+ expect(dest.dir).toBe(rcaFeedbackHook.SM_REPO_FEEDBACK_DIR);
191
+ expect(dest.targetProjectNote).toContain(cwd);
192
+ });
193
+
194
+ test('fileRcaFeedback: actually writes into the fallback dir when the target inbox is missing', async () => {
195
+ const cwd = makeProjectWithoutInbox();
196
+ const runDir = path.join(tmpHome, 'run1');
197
+ writeRun(runDir, 'testslug');
198
+ writePrd('testslug');
199
+
200
+ const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
201
+ expect(res.filed).toBe(true);
202
+ expect(res.path.startsWith(rcaFeedbackHook.SM_REPO_FEEDBACK_DIR)).toBe(true);
203
+ const content = fs.readFileSync(res.path, 'utf8');
204
+ expect(content).toContain(cwd);
205
+ });
206
+
207
+ test('fileRcaFeedback: rejects a slug containing path-traversal segments', async () => {
208
+ const cwd = makeProjectWithInbox();
209
+ const runDir = path.join(tmpHome, 'run1');
210
+ fs.mkdirSync(runDir, { recursive: true });
211
+
212
+ const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, slug: '../../etc/passwd' }), runDir, verdict: 'transcript_errors' });
213
+ expect(res.filed).toBe(false);
214
+ expect(res.reason).toBe('unsafe-slug');
215
+ });
216
+
217
+ // ─── kill-switch ────────────────────────────────────────────────────────────────
218
+
219
+ test('fileRcaFeedback: SM_RCA_DISABLE=1 skips without writing', async () => {
220
+ process.env.SM_RCA_DISABLE = '1';
221
+ const cwd = makeProjectWithInbox();
222
+ const runDir = path.join(tmpHome, 'run1');
223
+ writeRun(runDir, 'testslug');
224
+ writePrd('testslug');
225
+
226
+ const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
227
+ expect(res.filed).toBe(false);
228
+ expect(res.reason).toBe('disabled');
229
+ expect(fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback')).length).toBe(0);
230
+ });
231
+
232
+ // ─── investigation-text enrichment ──────────────────────────────────────────────
233
+
234
+ test('fileRcaFeedback: investigationText updates the existing RCA instead of duplicating it', async () => {
235
+ const cwd = makeProjectWithInbox();
236
+ const runDir = path.join(tmpHome, 'run1');
237
+ writeRun(runDir, 'testslug');
238
+ writePrd('testslug');
239
+ const job = baseJob({ cwd });
240
+
241
+ const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
242
+ expect(first.filed).toBe(true);
243
+ const before = fs.readFileSync(first.path, 'utf8');
244
+ expect(before).not.toContain('## Investigation analysis');
245
+
246
+ const second = await rcaFeedbackHook.fileRcaFeedback({
247
+ job, runDir, verdict: 'transcript_errors', investigationText: 'Root cause: forgot to bound the poll loop.',
248
+ });
249
+ expect(second.filed).toBe(true);
250
+ expect(second.updated).toBe(true);
251
+ expect(second.path).toBe(first.path);
252
+
253
+ const after = fs.readFileSync(second.path, 'utf8');
254
+ expect(after).toContain('## Investigation analysis');
255
+ expect(after).toContain('forgot to bound the poll loop');
256
+
257
+ const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
258
+ expect(files.length).toBe(1);
259
+ });