claude-code-session-manager 0.62.0 → 0.63.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 (68) hide show
  1. package/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
  2. package/dist/assets/History-mWbqemhZ.js +2 -0
  3. package/dist/assets/Hooks-D_8sciZu.js +3 -0
  4. package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
  5. package/dist/assets/Library-CG1LDXLw.js +46 -0
  6. package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
  7. package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
  8. package/dist/assets/McpServers-Kc-djiVi.js +2 -0
  9. package/dist/assets/Memory-B60DGRTW.js +8 -0
  10. package/dist/assets/Panel-CbTPsYHq.js +1 -0
  11. package/dist/assets/Permissions-C8KrSfrd.js +3 -0
  12. package/dist/assets/Plugins-BT8EumHb.js +2 -0
  13. package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
  14. package/dist/assets/Scheduler-DbKPd0or.js +14 -0
  15. package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
  16. package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
  17. package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
  18. package/dist/assets/Skills-CVL_4X3T.js +3 -0
  19. package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
  20. package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
  21. package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-DaS0M_Ni.js} +1 -1
  22. package/dist/assets/Toggle-C0a-6xV7.js +1 -0
  23. package/dist/assets/index-CO_7DroC.js +3066 -0
  24. package/dist/assets/{index-DOXvph-Z.css → index-LlWpj2VJ.css} +1 -1
  25. package/dist/assets/listSkills-QORduPIk.js +1 -0
  26. package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
  27. package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -3
  30. package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
  31. package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
  32. package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
  33. package/src/main/__tests__/runLogRetention.test.cjs +343 -0
  34. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  35. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  36. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  37. package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
  38. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  39. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  40. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  41. package/src/main/config.cjs +32 -29
  42. package/src/main/files.cjs +20 -0
  43. package/src/main/health.cjs +32 -0
  44. package/src/main/heapSnapshot.cjs +122 -0
  45. package/src/main/historyAggregator.cjs +176 -36
  46. package/src/main/index.cjs +10 -63
  47. package/src/main/ipcSchemas.cjs +20 -209
  48. package/src/main/lib/opsOwnership.cjs +0 -2
  49. package/src/main/lib/runLogRetention.cjs +358 -0
  50. package/src/main/lib/summarize.cjs +0 -1
  51. package/src/main/runVerify.cjs +55 -6
  52. package/src/main/scheduler.cjs +46 -7
  53. package/src/main/transcripts.cjs +29 -5
  54. package/src/preload/api.d.ts +18 -199
  55. package/src/preload/index.cjs +12 -102
  56. package/dist/assets/index-BfiMRkln.js +0 -3225
  57. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  58. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  59. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  60. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  61. package/src/main/browserAgentServer.cjs +0 -224
  62. package/src/main/browserCapture.cjs +0 -728
  63. package/src/main/browserView.cjs +0 -916
  64. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  65. package/src/main/lib/browserAgentActions.cjs +0 -114
  66. package/src/main/lib/e2eStateMachine.cjs +0 -39
  67. package/src/main/webRemote.cjs +0 -1367
  68. package/src/preload/browserViewPreload.cjs +0 -118
@@ -0,0 +1,343 @@
1
+ /**
2
+ * runLogRetention.test.cjs — unit tests for src/main/lib/runLogRetention.cjs.
3
+ *
4
+ * Run: timeout 120 npx vitest run src/main/__tests__/runLogRetention.test.cjs
5
+ *
6
+ * Fixtures live under os.tmpdir() only — never the real
7
+ * ~/.claude/session-manager/scheduled-plans/runs.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, beforeEach, afterEach } from 'vitest';
13
+ const fs = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+ const {
17
+ scanRunEntries,
18
+ computeEligibility,
19
+ computeReport,
20
+ isRetentionEnabled,
21
+ applyRetention,
22
+ liveKeysFromJobs,
23
+ isLiveJob,
24
+ } = require('../lib/runLogRetention.cjs');
25
+
26
+ let runsDir;
27
+
28
+ beforeEach(() => {
29
+ runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-retention-test-'));
30
+ });
31
+
32
+ afterEach(() => {
33
+ fs.rmSync(runsDir, { recursive: true, force: true });
34
+ });
35
+
36
+ const DAY_MS = 24 * 60 * 60 * 1000;
37
+
38
+ function makeRun(runId, slug, { finishedAt, extraFiles = [] } = {}) {
39
+ const dir = path.join(runsDir, runId);
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ fs.writeFileSync(path.join(dir, `${slug}.log`), 'log output\n');
42
+ fs.writeFileSync(
43
+ path.join(dir, `${slug}.meta.json`),
44
+ JSON.stringify({ slug, exitCode: 0, finishedAt })
45
+ );
46
+ for (const name of extraFiles) {
47
+ fs.writeFileSync(path.join(dir, name), 'extra\n');
48
+ }
49
+ return dir;
50
+ }
51
+
52
+ // ─── scanRunEntries ─────────────────────────────────────────────────────────
53
+
54
+ test('scanRunEntries: returns [] for a missing runsDir (never throws)', () => {
55
+ const missing = path.join(runsDir, 'does-not-exist');
56
+ expect(scanRunEntries(missing)).toEqual([]);
57
+ });
58
+
59
+ test('scanRunEntries: finds one entry per slug, even when a dir holds many slugs', () => {
60
+ makeRun('2026-01-01T00-00-00-000Z', 'a-slug', { finishedAt: Date.now() });
61
+ const dir = path.join(runsDir, '2026-01-01T00-00-00-000Z');
62
+ fs.writeFileSync(path.join(dir, 'b-slug.log'), 'log\n');
63
+ fs.writeFileSync(path.join(dir, 'b-slug.meta.json'), JSON.stringify({ finishedAt: Date.now() }));
64
+
65
+ const entries = scanRunEntries(runsDir);
66
+ expect(entries.map((e) => e.slug).sort()).toEqual(['a-slug', 'b-slug']);
67
+ expect(entries.every((e) => e.files.length === 2)).toBe(true);
68
+ });
69
+
70
+ // ─── default is dry-run: nothing removed with no opt-in ────────────────────
71
+
72
+ test('applyRetention: DEFAULT (no settings) is dry-run — deletes nothing, regardless of how old the runs are', () => {
73
+ const now = Date.now();
74
+ makeRun('2020-01-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 400 * DAY_MS });
75
+ makeRun('2020-06-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 300 * DAY_MS });
76
+
77
+ const result = applyRetention(runsDir, undefined, { now });
78
+ expect(result.deleted).toBe(false);
79
+ expect(result.removedFiles).toBeUndefined();
80
+ expect(fs.readdirSync(runsDir).length).toBe(2);
81
+
82
+ // The same data WOULD flag one eligible run once a real policy is computed
83
+ // (this is what the human-facing report is built from) — proves the dry
84
+ // run above wasn't a no-op because nothing was ever eligible.
85
+ const withPolicy = computeReport(runsDir, { maxAgeDays: 30 }, { now });
86
+ expect(withPolicy.eligible.length).toBeGreaterThan(0);
87
+ });
88
+
89
+ test('applyRetention: enabled=false is still dry-run regardless of policy', () => {
90
+ const now = Date.now();
91
+ makeRun('2020-01-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 400 * DAY_MS });
92
+ makeRun('2020-06-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 300 * DAY_MS });
93
+
94
+ const settings = { schedulerRunLogRetention: { enabled: false, policy: { maxAgeDays: 30 } } };
95
+ const result = applyRetention(runsDir, settings, { now });
96
+ expect(result.deleted).toBe(false);
97
+ expect(fs.readdirSync(runsDir).length).toBe(2);
98
+ });
99
+
100
+ test('isRetentionEnabled: requires enabled===true AND a non-empty policy', () => {
101
+ expect(isRetentionEnabled(undefined)).toBe(false);
102
+ expect(isRetentionEnabled({})).toBe(false);
103
+ expect(isRetentionEnabled({ schedulerRunLogRetention: { enabled: true } })).toBe(false);
104
+ expect(isRetentionEnabled({ schedulerRunLogRetention: { enabled: true, policy: {} } })).toBe(false);
105
+ expect(
106
+ isRetentionEnabled({ schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } })
107
+ ).toBe(true);
108
+ expect(
109
+ isRetentionEnabled({ schedulerRunLogRetention: { enabled: 'true', policy: { maxAgeDays: 30 } } })
110
+ ).toBe(false); // must be boolean true, not truthy
111
+ });
112
+
113
+ // ─── explicit opt-in actually deletes ───────────────────────────────────────
114
+
115
+ test('applyRetention: deletes ONLY when schedulerRunLogRetention.enabled===true plus a policy', () => {
116
+ const now = Date.now();
117
+ makeRun('2020-01-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 400 * DAY_MS });
118
+ const recentDir = makeRun('2020-06-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 1 * DAY_MS });
119
+
120
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
121
+ const result = applyRetention(runsDir, settings, { now });
122
+
123
+ expect(result.deleted).toBe(true);
124
+ expect(result.removedFiles).toBeGreaterThan(0);
125
+ // old, non-most-recent run's directory is gone entirely
126
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(false);
127
+ // most-recent run for the slug survives regardless of policy
128
+ expect(fs.existsSync(recentDir)).toBe(true);
129
+ });
130
+
131
+ // ─── live-job protection ────────────────────────────────────────────────────
132
+
133
+ test('isLiveJob: pending/running/needs_review/investigating are live; completed/failed are not', () => {
134
+ expect(isLiveJob({ status: 'pending' })).toBe(true);
135
+ expect(isLiveJob({ status: 'running' })).toBe(true);
136
+ expect(isLiveJob({ status: 'needs_review' })).toBe(true);
137
+ expect(isLiveJob({ status: 'investigating' })).toBe(true);
138
+ expect(isLiveJob({ status: 'completed' })).toBe(false);
139
+ expect(isLiveJob({ status: 'failed' })).toBe(false);
140
+ expect(isLiveJob(null)).toBe(false);
141
+ });
142
+
143
+ test('a run belonging to a needs_review job is NEVER eligible, regardless of age', () => {
144
+ const now = Date.now();
145
+ // Two runs of the same slug so the protected one is not simply "most recent".
146
+ makeRun('2020-01-01T00-00-00-000Z', 'watched-slug', { finishedAt: now - 500 * DAY_MS });
147
+ makeRun('2020-02-01T00-00-00-000Z', 'watched-slug', { finishedAt: now - 490 * DAY_MS }); // this one is live
148
+ makeRun('2026-01-01T00-00-00-000Z', 'watched-slug', { finishedAt: now - 1 * DAY_MS }); // most recent
149
+
150
+ const jobs = [{ slug: 'watched-slug', runId: '2020-02-01T00-00-00-000Z', status: 'needs_review' }];
151
+ const liveKeys = liveKeysFromJobs(jobs);
152
+ expect(liveKeys.has('watched-slug|2020-02-01T00-00-00-000Z')).toBe(true);
153
+
154
+ const report = computeReport(runsDir, { maxAgeDays: 30 }, { now, liveKeys });
155
+ const eligibleRunIds = report.eligible.map((e) => e.runId);
156
+ expect(eligibleRunIds).toContain('2020-01-01T00-00-00-000Z'); // old, not live, not most-recent — eligible
157
+ expect(eligibleRunIds).not.toContain('2020-02-01T00-00-00-000Z'); // live — protected
158
+ expect(eligibleRunIds).not.toContain('2026-01-01T00-00-00-000Z'); // most recent — protected
159
+
160
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
161
+ const result = applyRetention(runsDir, settings, { now, liveKeys });
162
+ expect(fs.existsSync(path.join(runsDir, '2020-02-01T00-00-00-000Z'))).toBe(true);
163
+ });
164
+
165
+ test('queued/running jobs are also protected, same as needs_review', () => {
166
+ const now = Date.now();
167
+ makeRun('2020-01-01T00-00-00-000Z', 'busy-slug', { finishedAt: now - 500 * DAY_MS });
168
+ makeRun('2020-02-01T00-00-00-000Z', 'busy-slug', { finishedAt: now - 1 * DAY_MS }); // most recent, also running
169
+
170
+ const jobs = [{ slug: 'busy-slug', runId: '2020-02-01T00-00-00-000Z', status: 'running' }];
171
+ const liveKeys = liveKeysFromJobs(jobs);
172
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 0 } } };
173
+ const result = applyRetention(runsDir, settings, { now, liveKeys });
174
+
175
+ // old one is eligible and removed (age 0 means "older than 0 days")
176
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(false);
177
+ // running one is protected even though its own age also clears the 0-day cap
178
+ expect(fs.existsSync(path.join(runsDir, '2020-02-01T00-00-00-000Z'))).toBe(true);
179
+ });
180
+
181
+ // ─── most-recent-per-slug protection ────────────────────────────────────────
182
+
183
+ test('the most recent run for a slug is never eligible, even past the age cap', () => {
184
+ const now = Date.now();
185
+ makeRun('2018-01-01T00-00-00-000Z', 'lonely-slug', { finishedAt: now - 3000 * DAY_MS });
186
+
187
+ const report = computeReport(runsDir, { maxAgeDays: 1 }, { now });
188
+ expect(report.eligible.length).toBe(0);
189
+
190
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 1 } } };
191
+ const result = applyRetention(runsDir, settings, { now });
192
+ expect(result.removedFiles).toBe(0);
193
+ expect(fs.existsSync(path.join(runsDir, '2018-01-01T00-00-00-000Z'))).toBe(true);
194
+ });
195
+
196
+ // ─── age + count combinability ──────────────────────────────────────────────
197
+
198
+ test('age-based policy: only entries older than maxAgeDays (and not most-recent) are eligible', () => {
199
+ const now = Date.now();
200
+ makeRun('2020-01-01T00-00-00-000Z', 's', { finishedAt: now - 90 * DAY_MS });
201
+ makeRun('2020-06-01T00-00-00-000Z', 's', { finishedAt: now - 10 * DAY_MS });
202
+ makeRun('2020-09-01T00-00-00-000Z', 's', { finishedAt: now - 1 * DAY_MS }); // most recent
203
+
204
+ const report = computeReport(runsDir, { maxAgeDays: 30 }, { now });
205
+ expect(report.eligible.map((e) => e.runId)).toEqual(['2020-01-01T00-00-00-000Z']);
206
+ });
207
+
208
+ test('count-based policy: keeps the N most recent runs per slug, rest eligible', () => {
209
+ const now = Date.now();
210
+ const ids = [];
211
+ for (let i = 0; i < 5; i++) {
212
+ const runId = `2020-0${i + 1}-01T00-00-00-000Z`;
213
+ ids.push(runId);
214
+ makeRun(runId, 'count-slug', { finishedAt: now - (5 - i) * DAY_MS });
215
+ }
216
+
217
+ const report = computeReport(runsDir, { keepPerSlug: 2 }, { now });
218
+ // 5 runs, keep 2 most recent (ranks 0,1) -> 3 eligible (ranks 2,3,4)
219
+ expect(report.eligible.length).toBe(3);
220
+ const eligibleRunIds = new Set(report.eligible.map((e) => e.runId));
221
+ expect(eligibleRunIds.has(ids[4])).toBe(false); // most recent
222
+ expect(eligibleRunIds.has(ids[3])).toBe(false); // 2nd most recent, kept by count
223
+ expect(eligibleRunIds.has(ids[0])).toBe(true);
224
+ });
225
+
226
+ test('age + count policies combine conservatively (AND): an entry needs both to be eligible', () => {
227
+ const now = Date.now();
228
+ // Old by age, but within the keepPerSlug=3 window (rank 1 of 3).
229
+ makeRun('2020-01-01T00-00-00-000Z', 's', { finishedAt: now - 400 * DAY_MS });
230
+ makeRun('2020-02-01T00-00-00-000Z', 's', { finishedAt: now - 300 * DAY_MS });
231
+ makeRun('2020-03-01T00-00-00-000Z', 's', { finishedAt: now - 1 * DAY_MS }); // most recent
232
+
233
+ const policy = { maxAgeDays: 30, keepPerSlug: 3 };
234
+ const report = computeReport(runsDir, policy, { now });
235
+ // Both are old enough by age, but keepPerSlug=3 keeps all 3 ranks (0,1,2) — none eligible.
236
+ expect(report.eligible.length).toBe(0);
237
+ });
238
+
239
+ // ─── report surface: usage stats ────────────────────────────────────────────
240
+
241
+ test('computeReport: reports total bytes, dir count, and oldest run date', () => {
242
+ const now = Date.now();
243
+ makeRun('2020-01-01T00-00-00-000Z', 'a', { finishedAt: now - 100 * DAY_MS });
244
+ makeRun('2020-06-01T00-00-00-000Z', 'b', { finishedAt: now - 10 * DAY_MS });
245
+
246
+ const report = computeReport(runsDir, {}, { now });
247
+ expect(report.usage.dirCount).toBe(2);
248
+ expect(report.usage.runCount).toBe(2);
249
+ expect(report.usage.totalBytes).toBeGreaterThan(0);
250
+ expect(report.usage.oldestRunAt).toBe(now - 100 * DAY_MS);
251
+ // no policy set -> nothing eligible, purely informational
252
+ expect(report.eligible.length).toBe(0);
253
+ });
254
+
255
+ // ─── directory-level cleanup: partial dirs never fully removed ─────────────
256
+
257
+ test('a directory holding an unclaimed extra file is never in removableDirs, even if its own entry is eligible', () => {
258
+ const now = Date.now();
259
+ makeRun('2020-01-01T00-00-00-000Z', 'shared-dir-slug', {
260
+ finishedAt: now - 400 * DAY_MS,
261
+ extraFiles: ['definition-of-done-abc12345.md'],
262
+ });
263
+ makeRun('2026-01-01T00-00-00-000Z', 'shared-dir-slug', { finishedAt: now - 1 * DAY_MS });
264
+
265
+ const report = computeReport(runsDir, { maxAgeDays: 30 }, { now });
266
+ expect(report.eligible.length).toBe(1);
267
+ expect(report.removableDirs).toEqual([]);
268
+
269
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
270
+ const result = applyRetention(runsDir, settings, { now });
271
+ // the slug's own log+meta are removed...
272
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z', 'shared-dir-slug.log'))).toBe(false);
273
+ // ...but the directory itself survives because of the unclaimed DoD report
274
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(true);
275
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z', 'definition-of-done-abc12345.md'))).toBe(true);
276
+ });
277
+
278
+ test('a directory whose sole entry is eligible is fully removed (dir + files)', () => {
279
+ const now = Date.now();
280
+ makeRun('2020-01-01T00-00-00-000Z', 'solo-slug', { finishedAt: now - 400 * DAY_MS });
281
+ makeRun('2026-01-01T00-00-00-000Z', 'solo-slug', { finishedAt: now - 1 * DAY_MS });
282
+
283
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
284
+ applyRetention(runsDir, settings, { now });
285
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(false);
286
+ });
287
+
288
+ // ─── liveKeysFromJobs: null-runId backfill (needs_review can lose its runId) ─
289
+
290
+ test('liveKeysFromJobs: backfills protection via a runsDir scan when a live job has lost its runId', () => {
291
+ makeRun('2020-01-01T00-00-00-000Z', 'lost-slug', { finishedAt: Date.now() });
292
+ const jobs = [{ slug: 'lost-slug', runId: null, status: 'needs_review' }];
293
+ const keys = liveKeysFromJobs(jobs, { runsDir });
294
+ expect(keys.has('lost-slug|2020-01-01T00-00-00-000Z')).toBe(true);
295
+ });
296
+
297
+ test('liveKeysFromJobs: without a runsDir hint, a live job with no runId yields no protection (documented limitation — callers should always pass runsDir)', () => {
298
+ const jobs = [{ slug: 'lost-slug', runId: null, status: 'needs_review' }];
299
+ expect(liveKeysFromJobs(jobs).size).toBe(0);
300
+ });
301
+
302
+ // ─── applyRetention: fail-safe fallback when the caller forgets liveKeys/jobs ─
303
+
304
+ test('applyRetention: enabled=true with no liveKeys/jobs falls back to reading the real scheduler queue (queueStore) instead of silently protecting nothing', () => {
305
+ const now = Date.now();
306
+ makeRun('2020-05-01T00-00-00-000Z', 'fallback-slug', { finishedAt: now - 400 * DAY_MS });
307
+ makeRun('2020-06-01T00-00-00-000Z', 'fallback-slug', { finishedAt: now - 1 * DAY_MS }); // most recent, unrelated
308
+
309
+ const queueStore = require('../lib/queueStore.cjs');
310
+ const original = queueStore.readMergedSync;
311
+ queueStore.readMergedSync = () => ({
312
+ jobs: [{ slug: 'fallback-slug', runId: '2020-05-01T00-00-00-000Z', status: 'running' }],
313
+ });
314
+ try {
315
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 0 } } };
316
+ applyRetention(runsDir, settings, { now }); // deliberately no liveKeys, no jobs
317
+ expect(fs.existsSync(path.join(runsDir, '2020-05-01T00-00-00-000Z'))).toBe(true);
318
+ } finally {
319
+ queueStore.readMergedSync = original;
320
+ }
321
+ });
322
+
323
+ test('applyRetention: dry-run path (enabled=false) never reads queueStore for live protection', () => {
324
+ const now = Date.now();
325
+ makeRun('2020-05-01T00-00-00-000Z', 'no-touch-slug', { finishedAt: now - 400 * DAY_MS });
326
+ makeRun('2020-06-01T00-00-00-000Z', 'no-touch-slug', { finishedAt: now - 1 * DAY_MS });
327
+
328
+ const queueStore = require('../lib/queueStore.cjs');
329
+ const original = queueStore.readMergedSync;
330
+ let called = false;
331
+ queueStore.readMergedSync = () => {
332
+ called = true;
333
+ return { jobs: [] };
334
+ };
335
+ try {
336
+ const settings = { schedulerRunLogRetention: { enabled: false, policy: { maxAgeDays: 0 } } };
337
+ const result = applyRetention(runsDir, settings, { now });
338
+ expect(result.deleted).toBe(false);
339
+ expect(called).toBe(false);
340
+ } finally {
341
+ queueStore.readMergedSync = original;
342
+ }
343
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * runVerify-transcript-commit-evidence.test.cjs — regression tests for the
3
+ * transcript-commit-evidence fallback added to runVerify.cjs.
4
+ *
5
+ * Root cause (99-fix-e2e-needs-review-test, 2026-08-07): a job whose cwd is
6
+ * not a git repo (e.g. an investigation fix-plan that inherited cwd: /tmp)
7
+ * can never produce committedDuringRun=true — gitHead()/committedInWindow()
8
+ * both fail closed there — so the PASS+commit sentinel override could never
9
+ * fire even when the harness's own transcript recorded a real commit. This
10
+ * file asserts the fallback fires ONLY when both conditions hold: the cwd is
11
+ * genuinely not a git repo, AND the transcript itself carries harness-emitted
12
+ * commit evidence (never inferred from prose the model could fabricate).
13
+ *
14
+ * Run standalone: timeout 120 npx vitest run src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs
15
+ */
16
+
17
+ import { test } from 'vitest';
18
+ const assert = require('node:assert/strict');
19
+ const os = require('node:os');
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const { execFileSync } = require('node:child_process');
23
+ const { verifyRun } = require('../runVerify.cjs');
24
+
25
+ // ─── helpers (mirrors runVerify.test.cjs) ─────────────────────────────────────
26
+
27
+ function makeTmpDir() {
28
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-commit-evidence-test-'));
29
+ }
30
+
31
+ function rmdir(dir) {
32
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
33
+ }
34
+
35
+ function writeLog(dir, slug, events) {
36
+ const lines = ['[scheduler] starting ' + slug + ' at 2026-08-07T02:25:31.453Z'];
37
+ for (const ev of events) lines.push(JSON.stringify(ev));
38
+ lines.push('[scheduler] exit code=0 (raw code=0 signal=null) duration=47s');
39
+ fs.writeFileSync(path.join(dir, `${slug}.log`), lines.join('\n') + '\n');
40
+ }
41
+
42
+ function writePrd(dir, slug, body) {
43
+ const text = `---\ntitle: Test PRD\ncwd: /tmp\nestimateMinutes: 30\n---\n${body}\n`;
44
+ fs.writeFileSync(path.join(dir, `${slug}.md`), text);
45
+ return path.join(dir, `${slug}.md`);
46
+ }
47
+
48
+ function makeTmpGitRepo() {
49
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-commit-evidence-repo-'));
50
+ execFileSync('git', ['init', '-q'], { cwd: dir });
51
+ execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
52
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
53
+ fs.writeFileSync(path.join(dir, 'README.md'), 'init\n');
54
+ execFileSync('git', ['add', 'README.md'], { cwd: dir });
55
+ execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir });
56
+ return dir;
57
+ }
58
+
59
+ /**
60
+ * Build a run log with:
61
+ * - padding events so a late is_error lands in the final 20%
62
+ * - a late is_error tool_result (main-agent, no self-recovery)
63
+ * - optionally a harness-emitted commit-evidence line
64
+ * - a SCHEDULER_VERDICT: PASS result event
65
+ */
66
+ function buildRunEvents({ includeCommitEvidence, evidenceForm = 'vcs_state_changed' }) {
67
+ const events = [];
68
+ for (let k = 0; k < 8; k++) {
69
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
70
+ { type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
71
+ events.push({ type: 'user', message: { role: 'user', content: [
72
+ { type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
73
+ }
74
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
75
+ { type: 'tool_use', id: 'tbad', name: 'Agent', input: { description: 'run code review agent' } }] } });
76
+ events.push({ type: 'user', message: { role: 'user', content: [
77
+ { type: 'tool_result', tool_use_id: 'tbad',
78
+ content: "Agent type 'code-reviewer' not found. Available agents: architect, …", is_error: true }] } });
79
+
80
+ if (includeCommitEvidence) {
81
+ if (evidenceForm === 'vcs_state_changed') {
82
+ events.push({ type: 'system', subtype: 'vcs_state_changed', kind: 'commit', cwd: '/tmp' });
83
+ } else {
84
+ events.push({
85
+ type: 'user',
86
+ message: { role: 'user', content: [
87
+ { type: 'tool_result', tool_use_id: 'tcommit', content: 'committed', is_error: false }] },
88
+ tool_use_result: { gitOperation: { commit: { sha: '6ecbd40', kind: 'committed' } } },
89
+ });
90
+ }
91
+ }
92
+
93
+ events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' });
94
+ return events;
95
+ }
96
+
97
+ // ─── (1) regression case: non-git cwd + transcript evidence → clean ──────────
98
+
99
+ test('PASS + late is_error + transcript commit evidence + non-git cwd → clean (fallback fires)', async () => {
100
+ const tmp = makeTmpDir();
101
+ try {
102
+ const slug = '99-fix-e2e-needs-review-test';
103
+ writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: true }));
104
+ const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
105
+ const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
106
+ try {
107
+ const verdict = await verifyRun({
108
+ runDir: tmp,
109
+ prdPath,
110
+ queueEntry: { slug, status: 'running', cwd: nonGitCwd },
111
+ allJobs: [],
112
+ committedDuringRun: false,
113
+ });
114
+ assert.equal(verdict.verdict, 'clean', `expected clean via transcript fallback, got ${verdict.verdict}: ${verdict.reason}`);
115
+ assert.equal(verdict.downgradeTo, null);
116
+ const sidecar = JSON.parse(fs.readFileSync(path.join(tmp, `${slug}.verdicts.json`), 'utf8'));
117
+ assert.equal(sidecar.commitEvidenceSource, 'transcript', 'sidecar should record the fallback source');
118
+ } finally { rmdir(nonGitCwd); }
119
+ } finally { rmdir(tmp); }
120
+ });
121
+
122
+ test('PASS + late is_error + gitOperation.commit evidence + non-git cwd → clean (fallback fires)', async () => {
123
+ const tmp = makeTmpDir();
124
+ try {
125
+ const slug = '99-fix-e2e-needs-review-test-alt';
126
+ writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: true, evidenceForm: 'gitOperation' }));
127
+ const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
128
+ const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
129
+ try {
130
+ const verdict = await verifyRun({
131
+ runDir: tmp,
132
+ prdPath,
133
+ queueEntry: { slug, status: 'running', cwd: nonGitCwd },
134
+ allJobs: [],
135
+ committedDuringRun: false,
136
+ });
137
+ assert.equal(verdict.verdict, 'clean', `expected clean via transcript fallback, got ${verdict.verdict}: ${verdict.reason}`);
138
+ } finally { rmdir(nonGitCwd); }
139
+ } finally { rmdir(tmp); }
140
+ });
141
+
142
+ // ─── (2) no evidence lines → fallback must not fire, still transcript_errors ──
143
+
144
+ test('PASS + late is_error + NO transcript evidence + non-git cwd → transcript_errors (no fallback)', async () => {
145
+ const tmp = makeTmpDir();
146
+ try {
147
+ const slug = '99-fix-no-evidence';
148
+ writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: false }));
149
+ const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
150
+ const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
151
+ try {
152
+ const verdict = await verifyRun({
153
+ runDir: tmp,
154
+ prdPath,
155
+ queueEntry: { slug, status: 'running', cwd: nonGitCwd },
156
+ allJobs: [],
157
+ committedDuringRun: false,
158
+ });
159
+ assert.equal(verdict.verdict, 'transcript_errors', `expected transcript_errors without evidence, got ${verdict.verdict}: ${verdict.reason}`);
160
+ assert.equal(verdict.downgradeTo, 'needs_review');
161
+ } finally { rmdir(nonGitCwd); }
162
+ } finally { rmdir(tmp); }
163
+ });
164
+
165
+ // ─── (3) git-repo cwd + evidence + committedDuringRun:false → fallback must not apply ──
166
+
167
+ test('PASS + late is_error + transcript evidence + GIT-REPO cwd + committedDuringRun:false → transcript_errors (false-PASS guard)', async () => {
168
+ const tmp = makeTmpDir();
169
+ const repo = makeTmpGitRepo();
170
+ try {
171
+ const slug = '99-fix-git-repo-cwd';
172
+ writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: true }));
173
+ const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
174
+ const verdict = await verifyRun({
175
+ runDir: tmp,
176
+ prdPath,
177
+ queueEntry: { slug, status: 'running', cwd: repo },
178
+ allJobs: [],
179
+ committedDuringRun: false,
180
+ });
181
+ assert.equal(verdict.verdict, 'transcript_errors', `fallback must not apply when git ancestry is observable, got ${verdict.verdict}: ${verdict.reason}`);
182
+ assert.equal(verdict.downgradeTo, 'needs_review');
183
+ } finally { rmdir(tmp); rmdir(repo); }
184
+ });
185
+
186
+ // ─── (4) PASS + transcript evidence + non-git cwd must not be pass_no_commit ──
187
+
188
+ test('PASS (no error) + transcript evidence + non-git cwd → clean, not pass_no_commit', async () => {
189
+ const tmp = makeTmpDir();
190
+ try {
191
+ const slug = '99-fix-clean-pass';
192
+ const events = [
193
+ { type: 'assistant', message: { role: 'assistant', content: [
194
+ { type: 'tool_use', id: 't0', name: 'Bash', input: { description: 'run tests' } }] } },
195
+ { type: 'user', message: { role: 'user', content: [
196
+ { type: 'tool_result', tool_use_id: 't0', content: '535 passed', is_error: false }] } },
197
+ { type: 'system', subtype: 'vcs_state_changed', kind: 'commit', cwd: '/tmp' },
198
+ { type: 'result', subtype: 'success', result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' },
199
+ ];
200
+ writeLog(tmp, slug, events);
201
+ const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
202
+ const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
203
+ try {
204
+ const verdict = await verifyRun({
205
+ runDir: tmp,
206
+ prdPath,
207
+ queueEntry: { slug, status: 'running', cwd: nonGitCwd },
208
+ allJobs: [],
209
+ committedDuringRun: false,
210
+ });
211
+ assert.equal(verdict.verdict, 'clean', `expected clean, got ${verdict.verdict}: ${verdict.reason}`);
212
+ assert.notEqual(verdict.verdict, 'pass_no_commit');
213
+ } finally { rmdir(nonGitCwd); }
214
+ } finally { rmdir(tmp); }
215
+ });
@@ -8,7 +8,11 @@
8
8
 
9
9
  const { test } = require('node:test');
10
10
  const assert = require('node:assert/strict');
11
- const { buildInvestigationPrompt } = require('../scheduler.cjs');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { execFileSync } = require('node:child_process');
15
+ const { buildInvestigationPrompt, isGitRepoSync } = require('../scheduler.cjs');
12
16
 
13
17
  function makeArgs(overrides = {}) {
14
18
  return {
@@ -49,3 +53,40 @@ test('prompt includes the resolved job fields', () => {
49
53
  assert.match(prompt, /some log tail output/);
50
54
  assert.match(prompt, /05-fix-my-feature\.md/);
51
55
  });
56
+
57
+ // ─── cwd-must-be-git-repo-root guidance (99-fix-e2e-needs-review-test) ────────
58
+
59
+ test('prompt instructs that cwd must be the git repo root where the fix will land', () => {
60
+ const prompt = buildInvestigationPrompt(makeArgs());
61
+ assert.match(prompt, /cwd.*must be the git repo root/);
62
+ assert.match(prompt, /needs_review/);
63
+ });
64
+
65
+ test('prompt frontmatter block still declares exactly the four expected keys', () => {
66
+ const prompt = buildInvestigationPrompt(makeArgs());
67
+ const fenceMatch = prompt.match(/```\s*\n\s*---\n([\s\S]*?)\n\s*---\n\s*```/);
68
+ assert.ok(fenceMatch, 'expected a fenced frontmatter block in the prompt');
69
+ const keys = fenceMatch[1]
70
+ .split('\n')
71
+ .filter((line) => line.trim().length > 0)
72
+ .map((line) => line.split(':')[0].trim());
73
+ assert.deepEqual(keys, ['title', 'cwd', 'parallelGroup', 'estimateMinutes']);
74
+ });
75
+
76
+ // ─── isGitRepoSync (scheduler.cjs) ─────────────────────────────────────────────
77
+
78
+ test('isGitRepoSync: true for a real git repo, false for a non-repo dir, false for missing/undefined', () => {
79
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduler-isgitrepo-repo-'));
80
+ const nonRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduler-isgitrepo-plain-'));
81
+ try {
82
+ execFileSync('git', ['init', '-q'], { cwd: repo });
83
+ assert.equal(isGitRepoSync(repo), true);
84
+ assert.equal(isGitRepoSync(nonRepo), false);
85
+ assert.equal(isGitRepoSync(undefined), false);
86
+ assert.equal(isGitRepoSync(''), false);
87
+ assert.equal(isGitRepoSync('/definitely/does/not/exist'), false);
88
+ } finally {
89
+ fs.rmSync(repo, { recursive: true, force: true });
90
+ fs.rmSync(nonRepo, { recursive: true, force: true });
91
+ }
92
+ });