claude-code-session-manager 0.75.2 → 0.76.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 (102) hide show
  1. package/dist/assets/{AgentLibrary-s1jRGOji.js → AgentLibrary-CBx9l4zN.js} +1 -1
  2. package/dist/assets/{DataModel-DNefcWbk.js → DataModel-Bf0EIE_t.js} +1 -1
  3. package/dist/assets/{History-4sMk80yI.js → History-CpdtWhC8.js} +1 -1
  4. package/dist/assets/{Hooks-CNUVwH5P.js → Hooks-DyUbMDmg.js} +1 -1
  5. package/dist/assets/{HostBilko-BhkyvIhw.js → HostBilko-By-wIpry.js} +1 -1
  6. package/dist/assets/{Library-BpockanA.js → Library-CQmo4QVC.js} +1 -1
  7. package/dist/assets/{ListDetail-Cre7h3U0.js → ListDetail-BQMd6NOm.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-x-FzvaXm.js → MarkdownEditor-DEp43FXX.js} +1 -1
  9. package/dist/assets/{McpServers-CXAcQLMB.js → McpServers-CLarzwqA.js} +1 -1
  10. package/dist/assets/{Memory-CGBv1Rco.js → Memory-B0sCdIy1.js} +1 -1
  11. package/dist/assets/{Panel-czEs21N2.js → Panel-BhWPVOCD.js} +1 -1
  12. package/dist/assets/{Permissions-rGkLOpqI.js → Permissions-Ddlq8T_O.js} +1 -1
  13. package/dist/assets/{Plugins-BTmB_vgU.js → Plugins-D2oA_2Jl.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-dmCivDq5.js → ProvenanceBadge-DgAgavUM.js} +1 -1
  15. package/dist/assets/{SaveBar-D80TNkAx.js → SaveBar-Qvc4Ek-H.js} +1 -1
  16. package/dist/assets/{Scheduler-C6wHSZP7.js → Scheduler-BmYJvNzK.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-DsaiYTgl.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
  18. package/dist/assets/{Settings-m2fyU8Q5.js → Settings-2Vx3X5SI.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-yS-lgBFU.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
  20. package/dist/assets/{Skills-DtQgCs9-.js → Skills-Cmrz_LeN.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CLuVdoMA.js → SystemPrompt-DVA1eYDP.js} +1 -1
  22. package/dist/assets/{TagLibrary-DQXoI752.js → TagLibrary-DYJGAKZu.js} +1 -1
  23. package/dist/assets/{TiptapBody-BGKVXrvq.js → TiptapBody-DmPc3amD.js} +1 -1
  24. package/dist/assets/{Toggle-RFnEV71m.js → Toggle-zfd5LJkK.js} +1 -1
  25. package/dist/assets/{index-Ddrw7J7O.js → index-B_4PNh9T.js} +676 -676
  26. package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
  27. package/dist/assets/{settingsSchema-LwuJXKD1.js → settingsSchema-B9es6fdA.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/scripts/lib/activeSessions.cjs +146 -4
  31. package/scripts/scheduler-mcp-server.cjs +154 -95
  32. package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
  33. package/src/main/__tests__/health-delegation-chain.test.cjs +105 -0
  34. package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
  35. package/src/main/__tests__/prdCreate.test.cjs +109 -0
  36. package/src/main/__tests__/scheduler-autofix-select.test.cjs +15 -3
  37. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
  38. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +60 -1
  39. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
  40. package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
  41. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
  42. package/src/main/bilkoHost.cjs +4 -3
  43. package/src/main/chatRunner.cjs +6 -1
  44. package/src/main/config.cjs +22 -33
  45. package/src/main/health.cjs +153 -2
  46. package/src/main/index.cjs +56 -4
  47. package/src/main/ipcSchemas.cjs +18 -1
  48. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
  49. package/src/main/lib/__tests__/childWithLog.test.cjs +63 -0
  50. package/src/main/lib/__tests__/delegationReadiness.test.cjs +241 -42
  51. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
  52. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +1 -1
  53. package/src/main/lib/__tests__/gitWorktree.test.cjs +14 -2
  54. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
  55. package/src/main/lib/__tests__/jobWorktree.test.cjs +2 -2
  56. package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
  57. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +101 -0
  58. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +202 -0
  59. package/src/main/lib/__tests__/opsRootNestedWrite.test.cjs +51 -0
  60. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  61. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  62. package/src/main/lib/__tests__/reaperHelpers.test.cjs +112 -0
  63. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  64. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  65. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  66. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  67. package/src/main/lib/activeIndexMerge.cjs +15 -0
  68. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  69. package/src/main/lib/buildTarget.cjs +3 -2
  70. package/src/main/lib/childWithLog.cjs +33 -1
  71. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  72. package/src/main/lib/delegationReadiness.cjs +408 -26
  73. package/src/main/lib/ephemeralCwd.cjs +78 -0
  74. package/src/main/lib/epicDelegationStats.cjs +2 -1
  75. package/src/main/lib/epicMint.cjs +17 -1
  76. package/src/main/lib/epicStatusMirror.cjs +95 -0
  77. package/src/main/lib/epicValidationHook.cjs +2 -1
  78. package/src/main/lib/gitWorktree.cjs +56 -2
  79. package/src/main/lib/jobWorktree.cjs +1 -0
  80. package/src/main/lib/loadGate.cjs +134 -0
  81. package/src/main/lib/mcpToolCatalog.cjs +285 -0
  82. package/src/main/lib/opsErrorLog.cjs +12 -1
  83. package/src/main/lib/opsOwnership.cjs +94 -0
  84. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  85. package/src/main/lib/prdCreate.cjs +46 -14
  86. package/src/main/lib/prdLocations.cjs +13 -6
  87. package/src/main/lib/projectRootResolve.cjs +134 -0
  88. package/src/main/lib/promptSessionSchema.cjs +7 -0
  89. package/src/main/lib/queueStore.cjs +70 -8
  90. package/src/main/lib/rcaReport.cjs +1 -1
  91. package/src/main/lib/reaperHelpers.cjs +47 -1
  92. package/src/main/lib/schedulerBatch.cjs +171 -29
  93. package/src/main/lib/schedulerConfig.cjs +80 -0
  94. package/src/main/projectBrief.cjs +3 -2
  95. package/src/main/projectPages.cjs +2 -1
  96. package/src/main/promptSessionTranscript.cjs +0 -0
  97. package/src/main/pty.cjs +5 -0
  98. package/src/main/queueOps.cjs +15 -8
  99. package/src/main/scheduler.cjs +346 -49
  100. package/src/main/seedSchedulerMcp.cjs +58 -4
  101. package/src/preload/api.d.ts +69 -1
  102. package/src/preload/index.cjs +2 -0
@@ -267,12 +267,18 @@ test('depth-1 (ordinary, no investigationDepth field) needs_review job is eligib
267
267
  assert.strictEqual(result.length, 1);
268
268
  });
269
269
 
270
- test('fix-plan job with investigationDepth: 2 is now ALSO eligible', () => {
271
- const jobs = [makeJob({ slug: '05-fix-foo', investigationDepth: 2 })];
270
+ test('fix-plan job with investigationDepth: 1 is still eligible (its first -fix follow-up)', () => {
271
+ const jobs = [makeJob({ slug: '05-fix-foo', investigationDepth: 1 })];
272
272
  const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
273
273
  assert.strictEqual(result.length, 1);
274
274
  });
275
275
 
276
+ test('fix-plan job with investigationDepth: 2 is now NOT eligible (cap lowered to permit at most -fix-fix)', () => {
277
+ const jobs = [makeJob({ slug: '05-fix-foo', investigationDepth: 2 })];
278
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
279
+ assert.strictEqual(result.length, 0);
280
+ });
281
+
276
282
  test('fix-plan job with investigationDepth: 3 is NOT eligible (bound holds)', () => {
277
283
  const jobs = [makeJob({ slug: '05-fix-foo', investigationDepth: 3 })];
278
284
  const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
@@ -289,7 +295,7 @@ test('isFixPlanBeyondDepthCap: non-fix-plan slug is never capped regardless of d
289
295
  assert.strictEqual(isFixPlanBeyondDepthCap('05-my-feature', 99), false);
290
296
  });
291
297
 
292
- test('isFixPlanBeyondDepthCap: fix-plan at depth 1 or 2 is not capped, depth 3 is', () => {
298
+ test('isFixPlanBeyondDepthCap: fix-plan at depth 1 is not capped, depth 2 is', () => {
293
299
  assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', 1), false);
294
300
  assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH), false);
295
301
  assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH + 1), true);
@@ -349,6 +355,12 @@ test('isEligibleForImmediateAutoFix: fix-plan job beyond the depth cap is exclud
349
355
  assert.strictEqual(result, false);
350
356
  });
351
357
 
358
+ test('isEligibleForImmediateAutoFix: fix-plan job at investigationDepth 2 is now excluded (previously eligible under the old cap)', () => {
359
+ const job = makeJob({ slug: '05-fix-foo', investigationDepth: 2 });
360
+ const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
361
+ assert.strictEqual(result, false);
362
+ });
363
+
352
364
  test('isEligibleForImmediateAutoFix: a different job in the same queue does not affect this job\'s eligibility', () => {
353
365
  const job = makeJob();
354
366
  const other = { slug: '05-fix-my-feature', status: 'pending', runId: null };
@@ -165,6 +165,47 @@ test('commitGuardVerdict: self-commit skip still works (regression check)', () =
165
165
  expect(verdict).toBeNull();
166
166
  });
167
167
 
168
+ test('commitGuardVerdict: ranInWorktree=true defeats siblingRunning — clean tree, no self-commit → silent_no_op (PRD 109 shape)', () => {
169
+ const verdict = commitGuardVerdict({
170
+ newlyDirty: [],
171
+ siblingRunning: true,
172
+ ranInWorktree: true,
173
+ jobSelfCommitted: false,
174
+ legitimateNoOp: false,
175
+ isFixPlanJob: false,
176
+ verifyResult: null,
177
+ });
178
+ expect(verdict).not.toBeNull();
179
+ expect(verdict.verdict).toBe('silent_no_op');
180
+ expect(verdict.downgradeTo).toBe('needs_review');
181
+ });
182
+
183
+ test('commitGuardVerdict: identical inputs with ranInWorktree=false → no flag (legacy shared-tree behaviour unchanged)', () => {
184
+ const verdict = commitGuardVerdict({
185
+ newlyDirty: [],
186
+ siblingRunning: true,
187
+ ranInWorktree: false,
188
+ jobSelfCommitted: false,
189
+ legitimateNoOp: false,
190
+ isFixPlanJob: false,
191
+ verifyResult: null,
192
+ });
193
+ expect(verdict).toBeNull();
194
+ });
195
+
196
+ test('commitGuardVerdict: ranInWorktree=true + siblingRunning=true but jobSelfCommitted=true → no flag (self-commit carve-out still applies)', () => {
197
+ const verdict = commitGuardVerdict({
198
+ newlyDirty: [],
199
+ siblingRunning: true,
200
+ ranInWorktree: true,
201
+ jobSelfCommitted: true,
202
+ legitimateNoOp: false,
203
+ isFixPlanJob: false,
204
+ verifyResult: null,
205
+ });
206
+ expect(verdict).toBeNull();
207
+ });
208
+
168
209
  test('commitGuardVerdict: carries forward a non-clean prior verdict as an annotation', () => {
169
210
  const verifyResult = {
170
211
  verdict: 'transcript_errors',
@@ -36,7 +36,8 @@ const path = require('node:path');
36
36
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reap-dead-running-jobs-test-'));
37
37
  process.env.HOME = tmpHome;
38
38
 
39
- const { reapDeadRunningJobs } = require('../scheduler.cjs');
39
+ const { reapDeadRunningJobs, PIDLESS_SPAWN_GRACE_MS } = require('../scheduler.cjs');
40
+ const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
40
41
 
41
42
  function registerActiveProject(cwd) {
42
43
  const projectsDir = path.join(tmpHome, '.claude', 'projects');
@@ -87,6 +88,64 @@ test('reapDeadRunningJobs reconciles a queue.json row stuck at status:running wi
87
88
  assert.equal(jobs[0].runtime, undefined);
88
89
  });
89
90
 
91
+ test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS with an empty run dir as failed, not completed, and audits it', async () => {
92
+ const projectCwd = path.join(tmpHome, 'c-project');
93
+ fs.mkdirSync(projectCwd, { recursive: true });
94
+ registerActiveProject(projectCwd);
95
+
96
+ const staleStartedAt = new Date(Date.now() - PIDLESS_SPAWN_GRACE_MS - 60_000).toISOString();
97
+ const queuePath = writeProjectQueue(projectCwd, [
98
+ {
99
+ slug: 'pidless-zombie',
100
+ status: 'running',
101
+ cwd: projectCwd,
102
+ runId: 'run-pidless-zombie',
103
+ startedAt: staleStartedAt,
104
+ estimateMinutes: 24,
105
+ // no runtime key at all — the spawn never got far enough to record one
106
+ },
107
+ ]);
108
+ // Empty run dir: created, but never written to (the exact 2026-09-01 repro).
109
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', 'run-pidless-zombie'), { recursive: true });
110
+
111
+ const auditSizeBefore = fs.existsSync(AUDIT_LOG_PATH) ? fs.statSync(AUDIT_LOG_PATH).size : 0;
112
+
113
+ await reapDeadRunningJobs();
114
+
115
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
116
+ assert.equal(jobs.length, 1);
117
+ assert.equal(jobs[0].status, 'failed', 'an empty run dir must never be reaped as completed');
118
+ assert.match(jobs[0].error, /no runtime\.pid recorded/);
119
+ assert.equal(jobs[0].runtime, undefined);
120
+
121
+ const auditText = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').slice(auditSizeBefore);
122
+ const auditLines = auditText.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
123
+ const pidlessEvent = auditLines.find((e) => e.kind === 'job_reaped_pidless' && e.slug === 'pidless-zombie');
124
+ assert.ok(pidlessEvent, 'reaping a pidless row must leave an audit trace');
125
+ });
126
+
127
+ test('reapDeadRunningJobs leaves a pidless row alone while it is still within the grace window', async () => {
128
+ const projectCwd = path.join(tmpHome, 'd-project');
129
+ fs.mkdirSync(projectCwd, { recursive: true });
130
+ registerActiveProject(projectCwd);
131
+
132
+ const freshStartedAt = new Date(Date.now() - 60_000).toISOString(); // 1 minute ago
133
+ const queuePath = writeProjectQueue(projectCwd, [
134
+ {
135
+ slug: 'mid-flight-spawn',
136
+ status: 'running',
137
+ cwd: projectCwd,
138
+ runId: 'run-mid-flight',
139
+ startedAt: freshStartedAt,
140
+ },
141
+ ]);
142
+
143
+ await reapDeadRunningJobs();
144
+
145
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
146
+ assert.equal(jobs[0].status, 'running', 'a genuinely mid-flight spawn must not be reaped');
147
+ });
148
+
90
149
  test('reapDeadRunningJobs is a no-op when no job is actually running in queue.json', async () => {
91
150
  const projectCwd = path.join(tmpHome, 'b-project');
92
151
  fs.mkdirSync(projectCwd, { recursive: true });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * scheduler-stranded-investigation.test.cjs — findStrandedInvestigations: a
3
+ * job left 'investigating' with nothing to restore it once the process that
4
+ * spawned its probe is gone.
5
+ *
6
+ * spawnInvestigation's own restore (onExit / synchronous-throw catch) runs
7
+ * ONLY inside the live process that spawned the probe. An app restart (or
8
+ * crash) mid-probe leaves the row frozen at 'investigating' forever — the
9
+ * comment at spawnInvestigation's onExit already asserts "'investigating'
10
+ * must never be the job's resting state", but nothing enforced that across a
11
+ * restart. Live repro (burrow, 2026-09-01): 834-session-summary-... sat
12
+ * 'investigating' for 1653 minutes even though finishedAt+exitCode were
13
+ * already recorded 27h earlier.
14
+ *
15
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-stranded-investigation.test.cjs
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ // vitest, NOT node:test — this repo's suite is vitest-only (CLAUDE.md).
21
+ import { test } from 'vitest';
22
+ const assert = require('node:assert/strict');
23
+ const { findStrandedInvestigations, INVESTIGATION_MAX_MS } = require('../scheduler.cjs');
24
+
25
+ const NOW = Date.parse('2026-09-01T12:00:00.000Z');
26
+ const agoMin = (m) => new Date(NOW - m * 60_000).toISOString();
27
+ const neverAlive = () => false;
28
+ const alwaysAlive = () => true;
29
+
30
+ test('stranded past the window is selected, with the pre-probe status as restoreStatus', () => {
31
+ const jobs = [
32
+ {
33
+ slug: 'a',
34
+ cwd: '/p1',
35
+ status: 'investigating',
36
+ statusHistory: [
37
+ { from: 'failed', to: 'investigating', at: agoMin(120), reason: 'spawning investigation probe' },
38
+ ],
39
+ },
40
+ ];
41
+ const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
42
+ assert.strictEqual(out.length, 1);
43
+ assert.strictEqual(out[0].slug, 'a');
44
+ assert.strictEqual(out[0].restoreStatus, 'failed');
45
+ assert.ok(out[0].ageMs >= 119 * 60_000);
46
+ });
47
+
48
+ test('within the window is left alone — a live probe must not be yanked mid-flight', () => {
49
+ const jobs = [
50
+ {
51
+ slug: 'b',
52
+ cwd: '/p1',
53
+ status: 'investigating',
54
+ statusHistory: [{ from: 'needs_review', to: 'investigating', at: agoMin(10) }],
55
+ },
56
+ ];
57
+ assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive), []);
58
+ });
59
+
60
+ test('a live probe process (pid alive) is never restored, even past the window', () => {
61
+ const jobs = [
62
+ {
63
+ slug: 'c',
64
+ cwd: '/p1',
65
+ status: 'investigating',
66
+ runtime: { pid: 4242 },
67
+ statusHistory: [{ from: 'failed', to: 'investigating', at: agoMin(120) }],
68
+ },
69
+ ];
70
+ assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, alwaysAlive), []);
71
+ });
72
+
73
+ test('a row with no runtime.pid past the window is still selected (dead-process default)', () => {
74
+ const jobs = [
75
+ {
76
+ slug: 'd',
77
+ cwd: '/p1',
78
+ status: 'investigating',
79
+ statusHistory: [{ from: 'failed', to: 'investigating', at: agoMin(120) }],
80
+ },
81
+ ];
82
+ const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, alwaysAlive);
83
+ assert.strictEqual(out.length, 1);
84
+ assert.strictEqual(out[0].slug, 'd');
85
+ });
86
+
87
+ test('missing statusHistory transition is warn-logged and not restored — cannot prove age', () => {
88
+ const jobs = [{ slug: 'e', cwd: '/p1', status: 'investigating', statusHistory: [] }];
89
+ const warnSpy = [];
90
+ const origWarn = console.warn;
91
+ console.warn = (...args) => warnSpy.push(args.join(' '));
92
+ try {
93
+ const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
94
+ assert.deepStrictEqual(out, []);
95
+ assert.ok(warnSpy.some((line) => line.includes('e') && line.includes('no statusHistory entry')), `expected a warn mentioning slug 'e', got: ${JSON.stringify(warnSpy)}`);
96
+ } finally {
97
+ console.warn = origWarn;
98
+ }
99
+ });
100
+
101
+ test('unparseable transition timestamp is warn-logged and not restored', () => {
102
+ const jobs = [
103
+ {
104
+ slug: 'f',
105
+ cwd: '/p1',
106
+ status: 'investigating',
107
+ statusHistory: [{ from: 'failed', to: 'investigating', at: 'not-a-date' }],
108
+ },
109
+ ];
110
+ const warnSpy = [];
111
+ const origWarn = console.warn;
112
+ console.warn = (...args) => warnSpy.push(args.join(' '));
113
+ try {
114
+ const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
115
+ assert.deepStrictEqual(out, []);
116
+ assert.ok(warnSpy.some((line) => line.includes('f') && line.includes('unparseable')), `expected a warn mentioning slug 'f', got: ${JSON.stringify(warnSpy)}`);
117
+ } finally {
118
+ console.warn = origWarn;
119
+ }
120
+ });
121
+
122
+ test('the burrow-834 shape: finishedAt + non-null exitCode already recorded is restored using that recorded outcome, not re-run', () => {
123
+ const jobs = [
124
+ {
125
+ slug: '834-session-summary-reports-planner-intent-as-outcome',
126
+ cwd: '/home/bilko/Projects/burrow',
127
+ status: 'investigating',
128
+ finishedAt: '2026-08-31T15:13:15.181Z',
129
+ exitCode: 0,
130
+ error: 'is_error=true in final 20% of transcript (event 91/101)',
131
+ estimateMinutes: 60,
132
+ statusHistory: [
133
+ { from: 'needs_review', to: 'investigating', at: '2026-08-31T15:20:00.000Z', reason: 'spawning investigation probe' },
134
+ ],
135
+ },
136
+ ];
137
+ const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
138
+ assert.strictEqual(out.length, 1);
139
+ assert.strictEqual(out[0].restoreStatus, 'needs_review');
140
+ assert.ok(out[0].ageMs / 60_000 > 1200, `expected well past the 60m threshold, got ${out[0].ageMs / 60_000}m`);
141
+ });
142
+
143
+ test('only the most recent investigating transition is used when a job was investigated more than once', () => {
144
+ const jobs = [
145
+ {
146
+ slug: 'g',
147
+ cwd: '/p1',
148
+ status: 'investigating',
149
+ statusHistory: [
150
+ { from: 'failed', to: 'investigating', at: agoMin(500) },
151
+ { from: 'investigating', to: 'failed', at: agoMin(400) },
152
+ { from: 'failed', to: 'investigating', at: agoMin(10) }, // recent retry — still within the window
153
+ ],
154
+ },
155
+ ];
156
+ assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive), []);
157
+ });
158
+
159
+ test('non-investigating rows are never considered', () => {
160
+ const base = { cwd: '/p1', statusHistory: [{ from: 'x', to: 'investigating', at: agoMin(500) }] };
161
+ const jobs = [
162
+ { ...base, slug: 'h', status: 'failed' },
163
+ { ...base, slug: 'i', status: 'running' },
164
+ { ...base, slug: 'j', status: 'needs_review' },
165
+ ];
166
+ assert.deepStrictEqual(findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive), []);
167
+ });
168
+
169
+ test('a corrupted/illegal statusHistory `from` value falls back to failed rather than being handed to transitionJob unvalidated', () => {
170
+ const jobs = [
171
+ {
172
+ slug: 'k',
173
+ cwd: '/p1',
174
+ status: 'investigating',
175
+ statusHistory: [{ from: 'quarantined', to: 'investigating', at: agoMin(120) }],
176
+ },
177
+ ];
178
+ const out = findStrandedInvestigations(jobs, NOW, INVESTIGATION_MAX_MS, neverAlive);
179
+ assert.strictEqual(out.length, 1);
180
+ assert.strictEqual(out[0].restoreStatus, 'failed');
181
+ });
182
+
183
+ test('the shipped default is the documented one (60 minutes)', () => {
184
+ assert.strictEqual(INVESTIGATION_MAX_MS, 60 * 60_000);
185
+ });
@@ -143,3 +143,69 @@ test('a failed attempt bumps the counter and stops after MAX_ATTEMPTS', async ()
143
143
  expect(called).toBe(false);
144
144
  expect(readMarker()).toMatchObject({ done: false, attempts: 3 });
145
145
  });
146
+
147
+ // ─────────────────────────────── repair: marker done, script path stale
148
+
149
+ test('marker done + user-scope script path missing -> removes then re-adds, marker stays done', async () => {
150
+ fs.mkdirSync(path.dirname(markerPath()), { recursive: true });
151
+ fs.writeFileSync(markerPath(), JSON.stringify({ done: true, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
152
+ fs.writeFileSync(
153
+ claudeJsonPath(),
154
+ JSON.stringify({
155
+ mcpServers: {
156
+ 'session-manager-scheduler': { type: 'stdio', command: 'node', args: ['/definitely/does/not/exist.cjs'] },
157
+ },
158
+ })
159
+ );
160
+
161
+ const removeCalls = [];
162
+ const removeFn = async () => { removeCalls.push(1); return { ok: true }; };
163
+ const addCalls = [];
164
+ const addFn = async (serverPath) => { addCalls.push(serverPath); return { ok: true, exitCode: 0 }; };
165
+
166
+ await seedSchedulerMcp({ logger: silentLogger(), addFn, removeFn });
167
+
168
+ expect(removeCalls).toHaveLength(1);
169
+ expect(addCalls).toHaveLength(1);
170
+ expect(path.isAbsolute(addCalls[0])).toBe(true);
171
+ expect(readMarker().done).toBe(true);
172
+ });
173
+
174
+ test('marker done + user-scope entry ABSENT (deliberate remove) stays removed, no repair attempted', async () => {
175
+ fs.mkdirSync(path.dirname(markerPath()), { recursive: true });
176
+ fs.writeFileSync(markerPath(), JSON.stringify({ done: true, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
177
+ // No ~/.claude.json at all — same as a deliberate `claude mcp remove`.
178
+
179
+ let removeCalled = false;
180
+ let addCalled = false;
181
+ const removeFn = async () => { removeCalled = true; return { ok: true }; };
182
+ const addFn = async () => { addCalled = true; return { ok: true }; };
183
+
184
+ await seedSchedulerMcp({ logger: silentLogger(), addFn, removeFn });
185
+
186
+ expect(removeCalled).toBe(false);
187
+ expect(addCalled).toBe(false);
188
+ });
189
+
190
+ test('marker done + user-scope script path exists -> healthy, no repair attempted', async () => {
191
+ fs.mkdirSync(path.dirname(markerPath()), { recursive: true });
192
+ fs.writeFileSync(markerPath(), JSON.stringify({ done: true, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
193
+ fs.writeFileSync(
194
+ claudeJsonPath(),
195
+ JSON.stringify({
196
+ mcpServers: {
197
+ 'session-manager-scheduler': { type: 'stdio', command: 'node', args: [markerPath()] }, // any existing file
198
+ },
199
+ })
200
+ );
201
+
202
+ let removeCalled = false;
203
+ let addCalled = false;
204
+ const removeFn = async () => { removeCalled = true; return { ok: true }; };
205
+ const addFn = async () => { addCalled = true; return { ok: true }; };
206
+
207
+ await seedSchedulerMcp({ logger: silentLogger(), addFn, removeFn });
208
+
209
+ expect(removeCalled).toBe(false);
210
+ expect(addCalled).toBe(false);
211
+ });
@@ -101,11 +101,20 @@ describe('pickForProject dependsOn eligibility (PRD 832)', () => {
101
101
  // like anything else with no blocking dependsOn — the 828 row is no longer
102
102
  // held back merely for carrying a higher number.
103
103
  it('legacy same-NN pending jobs are eligible alongside higher-numbered ones', () => {
104
- const a = job('827-one', 'pending', 827);
105
- const b = job('827-two', 'pending', 827);
106
- const later = job('828-three', 'pending', 828);
107
- const res = pickForProject([a, b, later], new Set(), 3);
108
- expect(res.batch.map((j) => j.slug).sort()).toEqual(['827-one', '827-two', '828-three']);
104
+ // Predates the per-project job cap (PRD 1066, default 2); raise it so
105
+ // this test keeps exercising parallelGroup semantics, not the cap.
106
+ const prevCap = process.env.SM_PROJECT_JOB_CAP;
107
+ process.env.SM_PROJECT_JOB_CAP = '10';
108
+ try {
109
+ const a = job('827-one', 'pending', 827);
110
+ const b = job('827-two', 'pending', 827);
111
+ const later = job('828-three', 'pending', 828);
112
+ const res = pickForProject([a, b, later], new Set(), 3);
113
+ expect(res.batch.map((j) => j.slug).sort()).toEqual(['827-one', '827-two', '828-three']);
114
+ } finally {
115
+ if (prevCap === undefined) delete process.env.SM_PROJECT_JOB_CAP;
116
+ else process.env.SM_PROJECT_JOB_CAP = prevCap;
117
+ }
109
118
  });
110
119
  });
111
120
 
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  const { ipcMain } = require('electron');
32
+ const { opsPath } = require('./lib/opsOwnership.cjs');
32
33
  const fsp = require('node:fs/promises');
33
34
  const path = require('node:path');
34
35
  const crypto = require('node:crypto');
@@ -46,7 +47,7 @@ const WRITER = 'bilko-host';
46
47
  const PROJECT_PAGE_LENSES = new Set(['home', 'marketing', 'feature', 'architecture']);
47
48
 
48
49
  function bilkoHostDir(cwd) {
49
- return path.join(cwd, 'session-manager-operations', 'bilko-host');
50
+ return opsPath(cwd, 'bilko-host');
50
51
  }
51
52
 
52
53
  function distDir(cwd) {
@@ -103,7 +104,7 @@ async function get({ cwd }) {
103
104
  const realCwd = config.validatePath(cwd);
104
105
 
105
106
  const marketingResult = await config.readText(
106
- path.join(realCwd, 'session-manager-operations', 'project-pages', 'output', 'marketing.html'),
107
+ opsPath(realCwd, 'project-pages', 'output', 'marketing.html'),
107
108
  );
108
109
  const pkgResult = await config.readJson(path.join(realCwd, 'package.json'));
109
110
  const pkg = pkgResult.exists && !pkgResult.parseError ? pkgResult.data : null;
@@ -215,7 +216,7 @@ async function removeDocument({ cwd, id }) {
215
216
  async function resolveDocumentHtml(cwd, doc) {
216
217
  if (doc.source.kind === 'project-page-lens') {
217
218
  const result = await config.readText(
218
- path.join(cwd, 'session-manager-operations', 'project-pages', 'output', `${doc.source.lens}.html`),
219
+ opsPath(cwd, 'project-pages', 'output', `${doc.source.lens}.html`),
219
220
  );
220
221
  if (!result.exists) {
221
222
  throw new Error(`document "${doc.title}" points at the ${doc.source.lens} Project Page, but it hasn't been generated yet`);
@@ -518,7 +518,12 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
518
518
  // resolveSourcePromptIdFromClaudeSession. sessionId IS the Epic's
519
519
  // claudeSessionId for an Epic-backed tab (domain model: Epic:claude-session
520
520
  // 1:1); for a non-Epic tab, resolution below simply finds no match.
521
- const childEnv = cleanChildEnv({ PATH: pathWithUserBins(), SM_CHAT_SESSION_ID: sessionId });
521
+ // SM_PROJECT_ROOT is the real project cwd (never the worktree spawn cwd
522
+ // resolved below) — scheduler-mcp-server.cjs forwards it as
523
+ // originProjectRoot on create-prd/open-session/readiness calls so those
524
+ // routes can resolve the right project even from a worktree pwd. See
525
+ // projectRootResolve.cjs.
526
+ const childEnv = cleanChildEnv({ PATH: pathWithUserBins(), SM_CHAT_SESSION_ID: sessionId, SM_PROJECT_ROOT: cwd });
522
527
 
523
528
  // Prepend the stop-signal protocol instruction and the chat-mode truth
524
529
  // instruction to every prompt
@@ -19,7 +19,7 @@ const fs = require('node:fs');
19
19
  const fsp = require('node:fs/promises');
20
20
  const path = require('node:path');
21
21
  const os = require('node:os');
22
- const { assertOpsWrite } = require('./lib/opsOwnership.cjs');
22
+ const { assertOpsWrite, opsPath } = require('./lib/opsOwnership.cjs');
23
23
  const chokidar = require('chokidar');
24
24
  const logs = require('./logs.cjs');
25
25
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
@@ -139,39 +139,28 @@ function validateWrite(realAbs) {
139
139
  if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
140
140
  return;
141
141
  }
142
- // PromptSession persistence (active-index.json + per-session archives,
143
- // promptSessions.ts) and the scheduler's own read-modify-write of that
144
- // same active index (promptSessionEvents.cjs, PRD 814) — narrowly
145
- // scoped to session-manager-operations/prompt-sessions/, this repo's
146
- // existing per-project artifact-store convention.
147
- const promptSessionsSub = path.join(realRoot, 'session-manager-operations', 'prompt-sessions');
148
- if (realAbs === promptSessionsSub || realAbs.startsWith(promptSessionsSub + path.sep)) {
149
- return;
150
- }
151
- // Scheduler PRD sources (PRD 825 — per-project Epic PRD dirs under
152
- // scheduler/epics/<id>/prds/, plus the legacy flat scheduler/prds/ and
153
- // scheduler/state/ shards): narrowly scoped to
154
- // session-manager-operations/scheduler/, this repo's existing
155
- // per-project artifact-store convention (scheduler.cjs's remote.writePrd
156
- // writes here via config.writeTextAtomic).
157
- const schedulerSub = path.join(realRoot, 'session-manager-operations', 'scheduler');
158
- if (realAbs === schedulerSub || realAbs.startsWith(schedulerSub + path.sep)) {
159
- return;
160
- }
161
- // Project Brief persistence (PRD 837 — projectBrief.cjs's writeJson of
162
- // brief.json): narrowly scoped to session-manager-operations/project-brief/,
163
- // this repo's existing per-project artifact-store convention.
164
- const briefSub = path.join(realRoot, 'session-manager-operations', 'project-brief');
165
- if (realAbs === briefSub || realAbs.startsWith(briefSub + path.sep)) {
166
- return;
142
+ // Writable ops namespaces under a registered project root — this
143
+ // repo's per-project artifact-store convention:
144
+ // prompt-sessions PromptSession persistence (promptSessions.ts) and
145
+ // the scheduler's read-modify-write of active-index
146
+ // (promptSessionEvents.cjs, PRD 814)
147
+ // scheduler PRD sources + Epic PRD dirs + state shards
148
+ // (PRD 825; scheduler.cjs remote.writePrd)
149
+ // project-brief projectBrief.cjs's brief.json (PRD 837)
150
+ // bilko-host bilkoHost.cjs's bundle prep
151
+ // Paths come from opsOwnership.opsPath (PRD 1082) — the one ops-root
152
+ // resolver — which THROWS for an ephemeral root (a linked worktree that
153
+ // pty.cjs registered as an allowed root for its spawn cwd, or
154
+ // os.tmpdir()). That throw is the point: a worktree's ops subtree is
155
+ // never a writable destination, so its exemptions simply do not exist.
156
+ let opsSubs;
157
+ try {
158
+ opsSubs = ['prompt-sessions', 'scheduler', 'project-brief', 'bilko-host'].map((ns) => opsPath(realRoot, ns));
159
+ } catch {
160
+ opsSubs = [];
167
161
  }
168
- // Host on Bilko.run bundle prep (bilkoHost.cjs's prepareBundle/
169
- // addDocument/removeDocument): narrowly scoped to
170
- // session-manager-operations/bilko-host/, this repo's existing
171
- // per-project artifact-store convention.
172
- const bilkoHostSub = path.join(realRoot, 'session-manager-operations', 'bilko-host');
173
- if (realAbs === bilkoHostSub || realAbs.startsWith(bilkoHostSub + path.sep)) {
174
- return;
162
+ for (const sub of opsSubs) {
163
+ if (realAbs === sub || realAbs.startsWith(sub + path.sep)) return;
175
164
  }
176
165
  }
177
166
  }