claude-code-session-manager 0.75.3 → 0.77.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 (164) hide show
  1. package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
  2. package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
  3. package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
  4. package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
  5. package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
  6. package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
  7. package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
  9. package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
  10. package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
  11. package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
  12. package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
  13. package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
  15. package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
  16. package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
  18. package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
  20. package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
  21. package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
  22. package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
  23. package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
  24. package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
  25. package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
  26. package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
  27. package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-6IOLjZZN.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +8 -2
  30. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  31. package/scripts/lib/activeSessions.cjs +116 -6
  32. package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
  33. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  34. package/scripts/render-project-pages.cjs +70 -0
  35. package/scripts/scheduler-mcp-server.cjs +269 -96
  36. package/scripts/validate-project-pages-summary.cjs +62 -0
  37. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  38. package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
  39. package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
  40. package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
  41. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  42. package/src/main/__tests__/prdCreate.test.cjs +247 -0
  43. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  44. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  45. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  46. package/src/main/__tests__/projectPages.test.cjs +73 -1
  47. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  48. package/src/main/__tests__/runVerify.test.cjs +94 -0
  49. package/src/main/__tests__/scheduler-autofix-select.test.cjs +58 -3
  50. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  51. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
  52. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  53. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  54. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  55. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  56. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  57. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  58. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  59. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  60. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  61. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +207 -1
  62. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  63. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
  64. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  65. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  66. package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
  67. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
  68. package/src/main/bilkoHost.cjs +4 -3
  69. package/src/main/chatRunner.cjs +6 -1
  70. package/src/main/config.cjs +25 -33
  71. package/src/main/health.cjs +153 -2
  72. package/src/main/index.cjs +64 -5
  73. package/src/main/ipcSchemas.cjs +69 -1
  74. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
  75. package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
  76. package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
  77. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
  78. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
  79. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  80. package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
  81. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
  82. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  83. package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
  84. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  85. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  86. package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
  87. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
  88. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  89. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
  90. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  91. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  92. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  93. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  94. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  95. package/src/main/lib/__tests__/reaperHelpers.test.cjs +133 -0
  96. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  97. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  98. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  99. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  100. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  101. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  102. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  103. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  104. package/src/main/lib/activeIndexMerge.cjs +15 -0
  105. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  106. package/src/main/lib/agentModelResolve.cjs +58 -0
  107. package/src/main/lib/buildTarget.cjs +3 -2
  108. package/src/main/lib/childWithLog.cjs +69 -2
  109. package/src/main/lib/claudeBin.cjs +54 -1
  110. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  111. package/src/main/lib/definitionOfDone.cjs +3 -2
  112. package/src/main/lib/delegationReadiness.cjs +514 -26
  113. package/src/main/lib/ephemeralCwd.cjs +78 -0
  114. package/src/main/lib/epicDelegationStats.cjs +2 -1
  115. package/src/main/lib/epicMint.cjs +17 -1
  116. package/src/main/lib/epicStatusMirror.cjs +95 -0
  117. package/src/main/lib/epicValidationHook.cjs +2 -1
  118. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  119. package/src/main/lib/fixChainDepth.cjs +45 -0
  120. package/src/main/lib/gitWorktree.cjs +520 -21
  121. package/src/main/lib/jobWorktree.cjs +2 -0
  122. package/src/main/lib/landedSinceRun.cjs +55 -0
  123. package/src/main/lib/launchFailure.cjs +357 -0
  124. package/src/main/lib/loadGate.cjs +134 -0
  125. package/src/main/lib/mcpToolCatalog.cjs +370 -0
  126. package/src/main/lib/opsErrorLog.cjs +12 -1
  127. package/src/main/lib/opsOwnership.cjs +106 -0
  128. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  129. package/src/main/lib/prdAgentType.cjs +84 -0
  130. package/src/main/lib/prdCreate.cjs +103 -15
  131. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  132. package/src/main/lib/prdFrontmatter.cjs +17 -3
  133. package/src/main/lib/prdLocations.cjs +13 -6
  134. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  135. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  136. package/src/main/lib/projectRootResolve.cjs +134 -0
  137. package/src/main/lib/promptSessionSchema.cjs +7 -0
  138. package/src/main/lib/queueHealth.cjs +38 -0
  139. package/src/main/lib/queueStore.cjs +40 -7
  140. package/src/main/lib/quietMachineLease.cjs +48 -0
  141. package/src/main/lib/rcaReport.cjs +54 -4
  142. package/src/main/lib/reaperHelpers.cjs +64 -1
  143. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  144. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  145. package/src/main/lib/schedulerBatch.cjs +301 -55
  146. package/src/main/lib/schedulerConfig.cjs +99 -0
  147. package/src/main/projectBrief.cjs +3 -2
  148. package/src/main/projectPages.cjs +162 -3
  149. package/src/main/promptSessionTranscript.cjs +0 -0
  150. package/src/main/pty.cjs +5 -0
  151. package/src/main/queueOps.cjs +15 -8
  152. package/src/main/runVerify.cjs +50 -9
  153. package/src/main/scheduler/prdParser.cjs +18 -1
  154. package/src/main/scheduler.cjs +1701 -130
  155. package/src/main/seedAgentPersonas.cjs +62 -21
  156. package/src/main/seedSchedulerMcp.cjs +58 -4
  157. package/src/main/templates/project-pages-catalog.json +741 -0
  158. package/src/main/templates/project-pages-pipeline.md +417 -0
  159. package/src/preload/api.d.ts +187 -3
  160. package/src/preload/index.cjs +9 -0
  161. package/src/seed/agents/project-home-builder.md +59 -0
  162. package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
  163. package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
  164. package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
@@ -0,0 +1,194 @@
1
+ /**
2
+ * scheduler-worktree-cap-defer.test.cjs — PRD 1112: a job that cannot get
3
+ * worktree isolation because the concurrency cap is currently exhausted must
4
+ * be DEFERRED (left 'pending', retried on the next dispatch pass), never
5
+ * spawned in a shared working tree the way every OTHER worktree.ok===false
6
+ * reason (e.g. "not a git repository") still falls back to running in place.
7
+ *
8
+ * Drives spawnJob end-to-end against a real git fixture repo, forcing the
9
+ * cap via SM_JOB_WORKTREE_MAX=1 plus a real occupying worktree (so the cap
10
+ * check itself, not a mock, is what defers the job).
11
+ *
12
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-worktree-cap-defer.test.cjs
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+ const { execFileSync } = require('node:child_process');
22
+
23
+ let tmpHome;
24
+ let originalHome;
25
+ let originalMax;
26
+ let spawnJob;
27
+ let gitWorktree;
28
+
29
+ function git(args, cwd) {
30
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
31
+ }
32
+
33
+ function initRepo(dir) {
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ git(['init', '-q'], dir);
36
+ git(['config', 'user.email', 'test@example.com'], dir);
37
+ git(['config', 'user.name', 'Test'], dir);
38
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
39
+ git(['add', '-A'], dir);
40
+ git(['commit', '-q', '-m', 'initial'], dir);
41
+ }
42
+
43
+ // See scheduler-quiet-machine-lease.test.cjs's identical helper: readQueue()'s
44
+ // per-project discovery walks ~/.claude/projects/*/*.jsonl transcripts for a
45
+ // `cwd` field to find known project queue.json shards.
46
+ function registerActiveProject(cwd) {
47
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
48
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
49
+ fs.mkdirSync(slugDir, { recursive: true });
50
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
51
+ }
52
+
53
+ function writeProjectQueue(cwd, jobs) {
54
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
55
+ fs.mkdirSync(stateDir, { recursive: true });
56
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
57
+ return path.join(stateDir, 'queue.json');
58
+ }
59
+
60
+ // Stub `claude` binary: writes a marker file into its OWN process.cwd() so a
61
+ // test can prove whether — and where — it was actually spawned, then emits a
62
+ // clean stream-json success result.
63
+ function writeClaudeStub() {
64
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-capdefer-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
65
+ const body = `
66
+ const fs = require('fs');
67
+ const path = require('path');
68
+ const { execFileSync } = require('child_process');
69
+ fs.writeFileSync(path.join(process.cwd(), 'ran-here.marker'), 'yes', 'utf8');
70
+ // Land a real commit + finish-protocol sentinel when possible, so a run
71
+ // that actually reaches the finish protocol lands 'completed' — a
72
+ // best-effort no-op when the cwd isn't a git repo (e.g. the non-git
73
+ // fallback case), where the run parks in needs_review instead, which is
74
+ // fine — this test only cares whether it ran in place, not its verdict.
75
+ try {
76
+ execFileSync('git', ['add', '-A'], { cwd: process.cwd() });
77
+ execFileSync('git', ['commit', '-q', '-m', 'stub commit'], { cwd: process.cwd() });
78
+ } catch { /* not a git repo — best-effort only */ }
79
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok\\nSCHEDULER_VERDICT: PASS' }) + '\\n');
80
+ process.exit(0);
81
+ `;
82
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
83
+ return stubPath;
84
+ }
85
+
86
+ beforeAll(() => {
87
+ originalHome = process.env.HOME;
88
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-'));
89
+ process.env.HOME = tmpHome;
90
+ originalMax = process.env.SM_JOB_WORKTREE_MAX;
91
+ ({ spawnJob } = require('../scheduler.cjs'));
92
+ gitWorktree = require('../lib/gitWorktree.cjs');
93
+ });
94
+
95
+ afterAll(() => {
96
+ process.env.HOME = originalHome;
97
+ if (originalMax === undefined) delete process.env.SM_JOB_WORKTREE_MAX;
98
+ else process.env.SM_JOB_WORKTREE_MAX = originalMax;
99
+ fs.rmSync(tmpHome, { recursive: true, force: true });
100
+ });
101
+
102
+ afterEach(() => {
103
+ delete process.env.SM_CLAUDE_BIN;
104
+ delete process.env.SM_JOB_WORKTREE_MAX;
105
+ gitWorktree._resetActiveWorktreeCountForTests('job', 0);
106
+ });
107
+
108
+ test('a job held behind the worktree cap stays pending with a heldReason, is never spawned in place, and dispatches once the cap frees up', async () => {
109
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-project-'));
110
+ initRepo(projectCwd);
111
+ registerActiveProject(projectCwd);
112
+
113
+ const slug = `1112-test-held-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
114
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
115
+ fs.mkdirSync(prdsDir, { recursive: true });
116
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Held behind the worktree cap.', 'utf8');
117
+ const queuePath = writeProjectQueue(projectCwd, [{ slug, status: 'pending', cwd: projectCwd }]);
118
+
119
+ process.env.SM_JOB_WORKTREE_MAX = '1';
120
+ process.env.SM_CLAUDE_BIN = writeClaudeStub();
121
+
122
+ // Occupy the single worktree slot with a REAL checkout, so the cap check
123
+ // spawnJob relies on is exercising the actual gitWorktree plumbing, not a
124
+ // mock.
125
+ const occupier = await gitWorktree.createJobWorktree({ cwd: projectCwd, slug: 'occupier' });
126
+ expect(occupier.ok).toBe(true);
127
+
128
+ const runId = `run-${slug}`;
129
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
130
+ fs.mkdirSync(runDir, { recursive: true });
131
+
132
+ try {
133
+ await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
134
+
135
+ // Held: still pending, never spawned, heldReason visible on the row.
136
+ let jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
137
+ let row = jobs.find((j) => j.slug === slug);
138
+ expect(row.status).toBe('pending');
139
+ expect(row.heldReason).toMatch(/worktree cap reached/);
140
+ expect(fs.existsSync(path.join(projectCwd, 'ran-here.marker'))).toBe(false);
141
+ expect(fs.existsSync(path.join(occupier.dir, 'ran-here.marker'))).toBe(false);
142
+
143
+ // Free the cap (mirrors cleanupJobWorktree releasing the counter), then
144
+ // retry — the held job dispatches on this next pass, exactly like a
145
+ // sessionSlots-blocked job retries once a slot frees up.
146
+ await gitWorktree.cleanupJobWorktree({ cwd: projectCwd, dir: occupier.dir, branch: occupier.branch });
147
+
148
+ await spawnJob({ slug, cwd: projectCwd }, `${runId}-2`, runDir, projectCwd);
149
+
150
+ jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
151
+ row = jobs.find((j) => j.slug === slug);
152
+ expect(row.status).toBe('completed');
153
+ expect(row.heldReason).toBeUndefined();
154
+ } finally {
155
+ fs.rmSync(projectCwd, { recursive: true, force: true });
156
+ fs.rmSync(runDir, { recursive: true, force: true });
157
+ }
158
+ }, 30_000);
159
+
160
+ test('a non-git cwd still falls back to running in place (unchanged behaviour — only cap-reached is a deferral)', async () => {
161
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-nongit-'));
162
+ fs.mkdirSync(projectCwd, { recursive: true }); // deliberately NOT a git repo
163
+ registerActiveProject(projectCwd);
164
+
165
+ const slug = `1112-test-nongit-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
166
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
167
+ fs.mkdirSync(prdsDir, { recursive: true });
168
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Non-git cwd, must run in place.', 'utf8');
169
+ const queuePath = writeProjectQueue(projectCwd, [{ slug, status: 'pending', cwd: projectCwd }]);
170
+
171
+ process.env.SM_CLAUDE_BIN = writeClaudeStub();
172
+
173
+ const runId = `run-${slug}`;
174
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
175
+ fs.mkdirSync(runDir, { recursive: true });
176
+
177
+ try {
178
+ await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
179
+
180
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
181
+ const row = jobs.find((j) => j.slug === slug);
182
+ // Not held/deferred — it actually ran (a non-git cwd can't commit, so it
183
+ // parks in needs_review rather than completed; this test only cares that
184
+ // it was dispatched in place, not its exact verdict).
185
+ expect(row.status).not.toBe('pending');
186
+ expect(row.heldReason).toBeUndefined();
187
+ expect(row.worktreeFallbackReason).toMatch(/not a git repository/);
188
+ // Ran IN PLACE — the marker landed directly in projectCwd, not a worktree.
189
+ expect(fs.existsSync(path.join(projectCwd, 'ran-here.marker'))).toBe(true);
190
+ } finally {
191
+ fs.rmSync(projectCwd, { recursive: true, force: true });
192
+ fs.rmSync(runDir, { recursive: true, force: true });
193
+ }
194
+ }, 30_000);
@@ -54,22 +54,20 @@ function silentLogger() {
54
54
  return { log: () => {}, warn: () => {} };
55
55
  }
56
56
 
57
- test('fresh seed writes both persona files', async () => {
58
- await seedAgentPersonas({ logger: silentLogger() });
57
+ const ALL_PERSONAS = ['architect', 'dev-lead', 'project-home-builder'];
59
58
 
60
- const architect = path.join(agentsDir(), 'architect.md');
61
- const devLead = path.join(agentsDir(), 'dev-lead.md');
62
- expect(fs.existsSync(architect)).toBe(true);
63
- expect(fs.existsSync(devLead)).toBe(true);
59
+ test('fresh seed writes every persona file', async () => {
60
+ await seedAgentPersonas({ logger: silentLogger() });
64
61
 
65
- const bundledArchitect = fs.readFileSync(
66
- path.join(__dirname, '..', '..', 'seed', 'agents', 'architect.md'),
67
- 'utf8'
68
- );
69
- expect(fs.readFileSync(architect, 'utf8')).toBe(bundledArchitect);
62
+ for (const name of ALL_PERSONAS) {
63
+ const dest = path.join(agentsDir(), `${name}.md`);
64
+ expect(fs.existsSync(dest)).toBe(true);
65
+ const bundled = fs.readFileSync(path.join(__dirname, '..', '..', 'seed', 'agents', `${name}.md`), 'utf8');
66
+ expect(fs.readFileSync(dest, 'utf8')).toBe(bundled);
67
+ }
70
68
 
71
69
  const marker = JSON.parse(fs.readFileSync(markerPath(), 'utf8'));
72
- expect(marker.done).toBe(true);
70
+ expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
73
71
  });
74
72
 
75
73
  test('a pre-existing persona file is left byte-identical', async () => {
@@ -81,20 +79,83 @@ test('a pre-existing persona file is left byte-identical', async () => {
81
79
  await seedAgentPersonas({ logger: silentLogger() });
82
80
 
83
81
  expect(fs.readFileSync(architect, 'utf8')).toBe(customContent);
84
- // dev-lead had no pre-existing file, so it should still be seeded.
82
+ // dev-lead and project-home-builder had no pre-existing file, so they should still be seeded.
85
83
  expect(fs.existsSync(path.join(agentsDir(), 'dev-lead.md'))).toBe(true);
84
+ expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
86
85
  });
87
86
 
88
- test('done:true marker short-circuits without touching agents dir', async () => {
87
+ test('a machine already fully seeded under the old done:true marker only picks up a newly added persona', async () => {
89
88
  const mPath = markerPath();
90
89
  fs.mkdirSync(path.dirname(mPath), { recursive: true });
90
+ // Legacy marker shape from before the seeded-set change — no `seeded` array.
91
91
  fs.writeFileSync(mPath, JSON.stringify({ done: true, attempts: 1, ts: '2026-01-01T00:00:00.000Z' }));
92
+ fs.mkdirSync(agentsDir(), { recursive: true });
93
+ fs.writeFileSync(path.join(agentsDir(), 'architect.md'), 'old architect content\n');
94
+ fs.writeFileSync(path.join(agentsDir(), 'dev-lead.md'), 'old dev-lead content\n');
95
+
96
+ await seedAgentPersonas({ logger: silentLogger() });
97
+
98
+ // Pre-existing files from the old run are untouched...
99
+ expect(fs.readFileSync(path.join(agentsDir(), 'architect.md'), 'utf8')).toBe('old architect content\n');
100
+ expect(fs.readFileSync(path.join(agentsDir(), 'dev-lead.md'), 'utf8')).toBe('old dev-lead content\n');
101
+ // ...but the newly added persona is delivered.
102
+ expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
103
+
104
+ const marker = JSON.parse(fs.readFileSync(mPath, 'utf8'));
105
+ expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
106
+ });
107
+
108
+ test('a machine already seeded under the new seeded-set marker picks up a newly added persona', async () => {
109
+ const mPath = markerPath();
110
+ fs.mkdirSync(path.dirname(mPath), { recursive: true });
111
+ fs.writeFileSync(mPath, JSON.stringify({ seeded: ['architect', 'dev-lead'], attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
112
+
113
+ await seedAgentPersonas({ logger: silentLogger() });
114
+
115
+ expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
116
+ const marker = JSON.parse(fs.readFileSync(mPath, 'utf8'));
117
+ expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
118
+ });
119
+
120
+ test('a machine with every known persona already in the seeded set short-circuits without touching agents dir', async () => {
121
+ const mPath = markerPath();
122
+ fs.mkdirSync(path.dirname(mPath), { recursive: true });
123
+ fs.writeFileSync(mPath, JSON.stringify({ seeded: ALL_PERSONAS, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
92
124
 
93
125
  await seedAgentPersonas({ logger: silentLogger() });
94
126
 
95
127
  expect(fs.existsSync(agentsDir())).toBe(false);
96
128
  });
97
129
 
130
+ test('a corrupt/unparseable marker file still seeds correctly', async () => {
131
+ const mPath = markerPath();
132
+ fs.mkdirSync(path.dirname(mPath), { recursive: true });
133
+ fs.writeFileSync(mPath, '{ not valid json');
134
+
135
+ await seedAgentPersonas({ logger: silentLogger() });
136
+
137
+ for (const name of ALL_PERSONAS) {
138
+ expect(fs.existsSync(path.join(agentsDir(), `${name}.md`))).toBe(true);
139
+ }
140
+ });
141
+
142
+ test('the bundled project-home-builder persona contains no session-manager-repo paths', () => {
143
+ const content = fs.readFileSync(
144
+ path.join(__dirname, '..', '..', 'seed', 'agents', 'project-home-builder.md'),
145
+ 'utf8'
146
+ );
147
+ const forbidden = [
148
+ 'session-manager-operations/architecture/',
149
+ '.claude/agents/',
150
+ 'session-manager-operations/design-mocks/',
151
+ 'scripts/',
152
+ 'npm run build:project-pages',
153
+ ];
154
+ for (const substr of forbidden) {
155
+ expect(content).not.toContain(substr);
156
+ }
157
+ });
158
+
98
159
  test('SM_SEED_AGENT_PERSONAS_DISABLE=1 short-circuits', async () => {
99
160
  process.env.SM_SEED_AGENT_PERSONAS_DISABLE = '1';
100
161
 
@@ -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,31 @@ 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
+ // project-pages projectHomeAdminRoutes.cjs's app-side render path
152
+ // (summary.json/picks.json/output/*; PRD:
153
+ // project-home-admin-routes)
154
+ // Paths come from opsOwnership.opsPath (PRD 1082) — the one ops-root
155
+ // resolver — which THROWS for an ephemeral root (a linked worktree that
156
+ // pty.cjs registered as an allowed root for its spawn cwd, or
157
+ // os.tmpdir()). That throw is the point: a worktree's ops subtree is
158
+ // never a writable destination, so its exemptions simply do not exist.
159
+ let opsSubs;
160
+ try {
161
+ opsSubs = ['prompt-sessions', 'scheduler', 'project-brief', 'bilko-host', 'project-pages'].map((ns) => opsPath(realRoot, ns));
162
+ } catch {
163
+ opsSubs = [];
167
164
  }
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;
165
+ for (const sub of opsSubs) {
166
+ if (realAbs === sub || realAbs.startsWith(sub + path.sep)) return;
175
167
  }
176
168
  }
177
169
  }