claude-code-session-manager 0.76.0 → 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 (123) hide show
  1. package/dist/assets/{AgentLibrary-CBx9l4zN.js → AgentLibrary-B2ie8bbw.js} +2 -2
  2. package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-BIJPYw32.js} +1 -1
  3. package/dist/assets/{History-CpdtWhC8.js → History-CeY6dk9S.js} +2 -2
  4. package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-BFH2ocKg.js} +2 -2
  5. package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-36gj9wLz.js} +1 -1
  6. package/dist/assets/{Library-CQmo4QVC.js → Library-C-hBct39.js} +1 -1
  7. package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-CNq64VWV.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
  9. package/dist/assets/{McpServers-CLarzwqA.js → McpServers-DpGN0oyz.js} +1 -1
  10. package/dist/assets/{Memory-B0sCdIy1.js → Memory-D59hUjC4.js} +6 -6
  11. package/dist/assets/{Panel-BhWPVOCD.js → Panel-DCgbaoci.js} +1 -1
  12. package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-DAmQ0DYV.js} +2 -2
  13. package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-Dyfgn6Is.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DgAgavUM.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-C_zWEtIl.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
  18. package/dist/assets/{Settings-2Vx3X5SI.js → Settings-BL4vf5aX.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
  20. package/dist/assets/{Skills-Cmrz_LeN.js → Skills-BV08gDUH.js} +2 -2
  21. package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-CLftSsDw.js} +1 -1
  22. package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
  23. package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-jCpuB6E5.js} +1 -1
  24. package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-D2paA1xf.js} +1 -1
  25. package/dist/assets/{index-B_4PNh9T.js → index-BDRSqBl3.js} +175 -175
  26. package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
  27. package/dist/assets/{settingsSchema-B9es6fdA.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/project-pages-logic/dist/logic.cjs +4709 -0
  32. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  33. package/scripts/render-project-pages.cjs +70 -0
  34. package/scripts/scheduler-mcp-server.cjs +115 -1
  35. package/scripts/validate-project-pages-summary.cjs +62 -0
  36. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  37. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  38. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  39. package/src/main/__tests__/prdCreate.test.cjs +138 -0
  40. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  41. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  42. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  43. package/src/main/__tests__/projectPages.test.cjs +73 -1
  44. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  45. package/src/main/__tests__/runVerify.test.cjs +94 -0
  46. package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
  47. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  48. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  49. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  50. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  51. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  52. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  53. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  54. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  55. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  56. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  57. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
  58. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  59. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  60. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  61. package/src/main/config.cjs +4 -1
  62. package/src/main/index.cjs +8 -1
  63. package/src/main/ipcSchemas.cjs +51 -0
  64. package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
  65. package/src/main/lib/__tests__/delegationReadiness.test.cjs +152 -2
  66. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
  67. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  68. package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
  69. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  70. package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
  71. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  72. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  73. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
  74. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  75. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  76. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  77. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  78. package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
  79. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  80. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  81. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  82. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  83. package/src/main/lib/agentModelResolve.cjs +58 -0
  84. package/src/main/lib/childWithLog.cjs +40 -5
  85. package/src/main/lib/claudeBin.cjs +54 -1
  86. package/src/main/lib/definitionOfDone.cjs +3 -2
  87. package/src/main/lib/delegationReadiness.cjs +115 -9
  88. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  89. package/src/main/lib/fixChainDepth.cjs +45 -0
  90. package/src/main/lib/gitWorktree.cjs +464 -19
  91. package/src/main/lib/jobWorktree.cjs +1 -0
  92. package/src/main/lib/landedSinceRun.cjs +55 -0
  93. package/src/main/lib/launchFailure.cjs +357 -0
  94. package/src/main/lib/mcpToolCatalog.cjs +87 -2
  95. package/src/main/lib/opsOwnership.cjs +12 -0
  96. package/src/main/lib/prdAgentType.cjs +84 -0
  97. package/src/main/lib/prdCreate.cjs +57 -1
  98. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  99. package/src/main/lib/prdFrontmatter.cjs +17 -3
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  101. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  102. package/src/main/lib/queueHealth.cjs +38 -0
  103. package/src/main/lib/queueStore.cjs +9 -2
  104. package/src/main/lib/quietMachineLease.cjs +48 -0
  105. package/src/main/lib/rcaReport.cjs +53 -3
  106. package/src/main/lib/reaperHelpers.cjs +18 -1
  107. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  108. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  109. package/src/main/lib/schedulerBatch.cjs +133 -29
  110. package/src/main/lib/schedulerConfig.cjs +19 -0
  111. package/src/main/projectPages.cjs +160 -2
  112. package/src/main/runVerify.cjs +50 -9
  113. package/src/main/scheduler/prdParser.cjs +18 -1
  114. package/src/main/scheduler.cjs +1371 -97
  115. package/src/main/seedAgentPersonas.cjs +62 -21
  116. package/src/main/templates/project-pages-catalog.json +741 -0
  117. package/src/main/templates/project-pages-pipeline.md +417 -0
  118. package/src/preload/api.d.ts +118 -2
  119. package/src/preload/index.cjs +7 -0
  120. package/src/seed/agents/project-home-builder.md +59 -0
  121. package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
  122. package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
  123. package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
@@ -0,0 +1,350 @@
1
+ /**
2
+ * schedulerMcpServerProjectHome.test.cjs — the four project_home_* MCP
3
+ * tools (PRD: project-home-mcp-tools) that wrap PRD 1089's admin routes.
4
+ * Covers: presence in TOOLS, catalog-composed descriptions, correct
5
+ * route/payload dispatch via a stubbed admin HTTP server, cwd defaulting to
6
+ * SM_PROJECT_ROOT/process.cwd() when omitted, the app-not-running failure
7
+ * shape, and that project_home_get_contract's composed response is
8
+ * self-sufficient (mentions all 5 lens ids, no repo-relative path).
9
+ *
10
+ * Same HOME-repointing pattern as schedulerMcpServerHelp.test.cjs — see that
11
+ * file's header for why.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs
14
+ */
15
+ 'use strict';
16
+
17
+ import { test, expect, afterEach } from 'vitest';
18
+ const fsp = require('node:fs/promises');
19
+ const http = require('node:http');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ const SERVER_PATH = path.join(__dirname, '../../../../scripts/scheduler-mcp-server.cjs');
24
+ const { MCP_TOOL_CATALOG, composeDescription } = require('../mcpToolCatalog.cjs');
25
+
26
+ const PROJECT_HOME_TOOL_NAMES = [
27
+ 'project_home_get_contract',
28
+ 'project_home_validate_summary',
29
+ 'project_home_render',
30
+ 'project_home_status',
31
+ ];
32
+
33
+ const tmpDirs = [];
34
+ const servers = [];
35
+ let originalHome;
36
+ let originalProjectRoot;
37
+
38
+ afterEach(async () => {
39
+ if (originalHome !== undefined) {
40
+ process.env.HOME = originalHome;
41
+ originalHome = undefined;
42
+ }
43
+ if (originalProjectRoot !== undefined) {
44
+ if (originalProjectRoot === null) delete process.env.SM_PROJECT_ROOT;
45
+ else process.env.SM_PROJECT_ROOT = originalProjectRoot;
46
+ originalProjectRoot = undefined;
47
+ }
48
+ delete require.cache[require.resolve(SERVER_PATH)];
49
+ while (servers.length) {
50
+ const s = servers.pop();
51
+ await new Promise((resolve) => s.close(resolve));
52
+ }
53
+ while (tmpDirs.length) {
54
+ const d = tmpDirs.pop();
55
+ await fsp.rm(d, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ async function mkTmp() {
60
+ const d = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-mcp-project-home-'));
61
+ tmpDirs.push(d);
62
+ return d;
63
+ }
64
+
65
+ /** Starts a fake admin HTTP server that records every request and answers with `body`. */
66
+ function startFakeAdminServer(token, body) {
67
+ const requests = [];
68
+ return new Promise((resolve) => {
69
+ const server = http.createServer((req, res) => {
70
+ const chunks = [];
71
+ req.on('data', (c) => chunks.push(c));
72
+ req.on('end', () => {
73
+ if (req.headers.authorization !== `Bearer ${token}`) {
74
+ res.writeHead(401, { 'Content-Type': 'application/json' });
75
+ res.end(JSON.stringify({ ok: false, error: 'bad token' }));
76
+ return;
77
+ }
78
+ const raw = Buffer.concat(chunks).toString('utf8');
79
+ requests.push({
80
+ method: req.method,
81
+ url: req.url,
82
+ body: raw ? JSON.parse(raw) : undefined,
83
+ });
84
+ res.writeHead(200, { 'Content-Type': 'application/json' });
85
+ res.end(JSON.stringify(body));
86
+ });
87
+ });
88
+ server.listen(0, '127.0.0.1', () => resolve({ server, requests }));
89
+ });
90
+ }
91
+
92
+ function requireServerWithHome(homeDir) {
93
+ originalHome = process.env.HOME;
94
+ process.env.HOME = homeDir;
95
+ delete require.cache[require.resolve(SERVER_PATH)];
96
+ const mod = require(SERVER_PATH);
97
+ process.env.HOME = originalHome;
98
+ originalHome = undefined;
99
+ return mod;
100
+ }
101
+
102
+ async function writeAdminConfig(homeDir, { port, token }) {
103
+ const tokenPath = path.join(homeDir, '.claude', 'session-manager', 'admin-api.json');
104
+ await fsp.mkdir(path.dirname(tokenPath), { recursive: true });
105
+ await fsp.writeFile(tokenPath, JSON.stringify({ port, token }), 'utf8');
106
+ }
107
+
108
+ function callTool(handleCallTool, name, args) {
109
+ return handleCallTool({ params: { name, arguments: args } });
110
+ }
111
+
112
+ test('all four project_home_* tools are present in TOOLS with correct input schemas', async () => {
113
+ const homeDir = await mkTmp();
114
+ const { TOOLS } = requireServerWithHome(homeDir);
115
+ for (const name of PROJECT_HOME_TOOL_NAMES) {
116
+ const tool = TOOLS.find((t) => t.name === name);
117
+ expect(tool).toBeTruthy();
118
+ expect(tool.inputSchema.type).toBe('object');
119
+ expect(tool.inputSchema.properties.cwd).toBeTruthy();
120
+ // cwd is never required on any of the four
121
+ expect(tool.inputSchema.required ?? []).not.toContain('cwd');
122
+ }
123
+ const validate = TOOLS.find((t) => t.name === 'project_home_validate_summary');
124
+ expect(validate.inputSchema.required).toContain('summary');
125
+ const render = TOOLS.find((t) => t.name === 'project_home_render');
126
+ expect(render.inputSchema.required).toEqual(expect.arrayContaining(['summary', 'picks']));
127
+ });
128
+
129
+ test.each(PROJECT_HOME_TOOL_NAMES)('%s description equals the catalog-composed string', async (name) => {
130
+ const homeDir = await mkTmp();
131
+ const { TOOLS } = requireServerWithHome(homeDir);
132
+ const tool = TOOLS.find((t) => t.name === name);
133
+ const entry = MCP_TOOL_CATALOG.find((e) => e.name === name);
134
+ expect(entry).toBeTruthy();
135
+ expect(entry.group).toBe('project-home');
136
+ expect(tool.description).toBe(composeDescription(entry));
137
+ });
138
+
139
+ test('project_home_get_contract dispatches GET /admin/project-home/contract with cwd query param', async () => {
140
+ const homeDir = await mkTmp();
141
+ const token = 'test-token';
142
+ const { server, requests } = await startFakeAdminServer(token, {
143
+ ok: true,
144
+ protocol: ['step one'],
145
+ catalog: { lenses: [{ id: 'home' }, { id: 'marketing' }, { id: 'feature' }, { id: 'architecture' }, { id: 'brief' }] },
146
+ paths: { summaryPath: '/abs/summary.json', picksPath: '/abs/picks.json', outputDir: '/abs/output' },
147
+ spec: { text: 'spec text', path: '/abs/spec.md' },
148
+ });
149
+ servers.push(server);
150
+ await writeAdminConfig(homeDir, { port: server.address().port, token });
151
+
152
+ const { handleCallTool } = requireServerWithHome(homeDir);
153
+ const result = await callTool(handleCallTool, 'project_home_get_contract', { cwd: '/home/bilko/Projects/session-manager' });
154
+ expect(result.isError).toBeFalsy();
155
+ expect(requests).toHaveLength(1);
156
+ expect(requests[0].method).toBe('GET');
157
+ expect(requests[0].url).toBe('/admin/project-home/contract?cwd=%2Fhome%2Fbilko%2FProjects%2Fsession-manager');
158
+ });
159
+
160
+ test('project_home_get_contract composed response mentions all 5 lens ids and no repo-relative path', async () => {
161
+ const homeDir = await mkTmp();
162
+ const token = 'test-token';
163
+ const { server } = await startFakeAdminServer(token, {
164
+ ok: true,
165
+ protocol: [
166
+ 'Call GET /admin/project-home/status?cwd=<abs> first.',
167
+ 'Compose a ProjectPageSummary matching summarySchema.',
168
+ 'For each lens in catalog.lenses, pick a variant per slot.',
169
+ 'POST /admin/project-home/validate-summary with {cwd, summary}.',
170
+ 'POST /admin/project-home/render with {cwd, summary, picks}.',
171
+ ],
172
+ summarySchema: { type: 'object' },
173
+ picksSchema: { type: 'object' },
174
+ catalog: {
175
+ lenses: [
176
+ { id: 'home', slots: [] },
177
+ { id: 'marketing', slots: [] },
178
+ { id: 'feature', slots: [] },
179
+ { id: 'architecture', slots: [] },
180
+ { id: 'brief', slots: [] },
181
+ ],
182
+ },
183
+ paths: { summaryPath: '/abs/project-pages/summary.json', picksPath: '/abs/project-pages/picks.json', outputDir: '/abs/project-pages/output' },
184
+ spec: { text: 'This project uses absolute paths only, never repo-relative ones.', path: '/abs/project-pages-pipeline.md' },
185
+ });
186
+ servers.push(server);
187
+ await writeAdminConfig(homeDir, { port: server.address().port, token });
188
+
189
+ const { handleCallTool } = requireServerWithHome(homeDir);
190
+ const result = await callTool(handleCallTool, 'project_home_get_contract', {});
191
+ expect(result.isError).toBeFalsy();
192
+ const text = result.content[0].text;
193
+ for (const lens of ['home', 'marketing', 'feature', 'architecture', 'brief']) {
194
+ expect(text).toContain(lens);
195
+ }
196
+ expect(text).not.toMatch(/session-manager-operations\/architecture\//);
197
+ expect(text).not.toMatch(/\.claude\/agents\//);
198
+ });
199
+
200
+ test('project_home_validate_summary dispatches POST /admin/project-home/validate-summary with cwd+summary', async () => {
201
+ const homeDir = await mkTmp();
202
+ const token = 'test-token';
203
+ const { server, requests } = await startFakeAdminServer(token, { ok: true, valid: true, errors: [] });
204
+ servers.push(server);
205
+ await writeAdminConfig(homeDir, { port: server.address().port, token });
206
+
207
+ const { handleCallTool } = requireServerWithHome(homeDir);
208
+ const summary = { title: 'Example Project' };
209
+ const result = await callTool(handleCallTool, 'project_home_validate_summary', {
210
+ cwd: '/home/bilko/Projects/session-manager',
211
+ summary,
212
+ });
213
+ expect(result.isError).toBeFalsy();
214
+ expect(requests).toHaveLength(1);
215
+ expect(requests[0].method).toBe('POST');
216
+ expect(requests[0].url).toBe('/admin/project-home/validate-summary');
217
+ expect(requests[0].body).toEqual({ cwd: '/home/bilko/Projects/session-manager', summary });
218
+ });
219
+
220
+ test('project_home_validate_summary surfaces per-field errors from the route, not a generic failure', async () => {
221
+ const homeDir = await mkTmp();
222
+ const token = 'test-token';
223
+ const { server } = await startFakeAdminServer(token, {
224
+ ok: true,
225
+ valid: false,
226
+ errors: [{ field: 'quotes[0].attribution', message: 'quotes[0].attribution must not be a placeholder value' }],
227
+ });
228
+ servers.push(server);
229
+ await writeAdminConfig(homeDir, { port: server.address().port, token });
230
+
231
+ const { handleCallTool } = requireServerWithHome(homeDir);
232
+ const result = await callTool(handleCallTool, 'project_home_validate_summary', {
233
+ cwd: '/home/bilko/Projects/session-manager',
234
+ summary: { title: 'x', quotes: [{ attribution: 'TODO' }] },
235
+ });
236
+ expect(result.isError).toBeFalsy();
237
+ const body = JSON.parse(result.content[0].text);
238
+ expect(body.valid).toBe(false);
239
+ expect(body.errors).toEqual([
240
+ { field: 'quotes[0].attribution', message: 'quotes[0].attribution must not be a placeholder value' },
241
+ ]);
242
+ });
243
+
244
+ test('project_home_validate_summary requires summary', async () => {
245
+ const homeDir = await mkTmp();
246
+ const { handleCallTool } = requireServerWithHome(homeDir);
247
+ const result = await callTool(handleCallTool, 'project_home_validate_summary', { cwd: '/x' });
248
+ expect(result.isError).toBe(true);
249
+ expect(result.content[0].text).toContain('summary');
250
+ });
251
+
252
+ test('project_home_render dispatches POST /admin/project-home/render with cwd+summary+picks', async () => {
253
+ const homeDir = await mkTmp();
254
+ const token = 'test-token';
255
+ const { server, requests } = await startFakeAdminServer(token, {
256
+ ok: true,
257
+ filesWritten: ['/abs/summary.json'],
258
+ generatedAt: '2026-09-01T00:00:00.000Z',
259
+ });
260
+ servers.push(server);
261
+ await writeAdminConfig(homeDir, { port: server.address().port, token });
262
+
263
+ const { handleCallTool } = requireServerWithHome(homeDir);
264
+ const summary = { title: 'Example Project' };
265
+ const picks = { home: { hero: 'variant-a' } };
266
+ const result = await callTool(handleCallTool, 'project_home_render', {
267
+ cwd: '/home/bilko/Projects/session-manager',
268
+ summary,
269
+ picks,
270
+ });
271
+ expect(result.isError).toBeFalsy();
272
+ expect(requests).toHaveLength(1);
273
+ expect(requests[0].method).toBe('POST');
274
+ expect(requests[0].url).toBe('/admin/project-home/render');
275
+ expect(requests[0].body).toEqual({ cwd: '/home/bilko/Projects/session-manager', summary, picks });
276
+ });
277
+
278
+ test('project_home_render requires both summary and picks', async () => {
279
+ const homeDir = await mkTmp();
280
+ const { handleCallTool } = requireServerWithHome(homeDir);
281
+
282
+ const missingSummary = await callTool(handleCallTool, 'project_home_render', { picks: {} });
283
+ expect(missingSummary.isError).toBe(true);
284
+ expect(missingSummary.content[0].text).toContain('summary');
285
+
286
+ const missingPicks = await callTool(handleCallTool, 'project_home_render', { summary: {} });
287
+ expect(missingPicks.isError).toBe(true);
288
+ expect(missingPicks.content[0].text).toContain('picks');
289
+ });
290
+
291
+ test('project_home_status dispatches GET /admin/project-home/status with cwd query param', async () => {
292
+ const homeDir = await mkTmp();
293
+ const token = 'test-token';
294
+ const { server, requests } = await startFakeAdminServer(token, {
295
+ ok: true,
296
+ summary: { exists: false, mtimeMs: null },
297
+ picks: { exists: false, mtimeMs: null },
298
+ output: {
299
+ home: { exists: false, mtimeMs: null },
300
+ marketing: { exists: false, mtimeMs: null },
301
+ feature: { exists: false, mtimeMs: null },
302
+ architecture: { exists: false, mtimeMs: null },
303
+ brief: { exists: false, mtimeMs: null },
304
+ },
305
+ manifest: { exists: false, generatedAt: null },
306
+ });
307
+ servers.push(server);
308
+ await writeAdminConfig(homeDir, { port: server.address().port, token });
309
+
310
+ const { handleCallTool } = requireServerWithHome(homeDir);
311
+ const result = await callTool(handleCallTool, 'project_home_status', { cwd: '/home/bilko/Projects/session-manager' });
312
+ expect(result.isError).toBeFalsy();
313
+ expect(requests).toHaveLength(1);
314
+ expect(requests[0].method).toBe('GET');
315
+ expect(requests[0].url).toBe('/admin/project-home/status?cwd=%2Fhome%2Fbilko%2FProjects%2Fsession-manager');
316
+
317
+ // Never-generated project: well-formed all-absent result, not an error.
318
+ const body = JSON.parse(result.content[0].text);
319
+ expect(body.ok).toBe(true);
320
+ expect(body.summary.exists).toBe(false);
321
+ expect(body.manifest.exists).toBe(false);
322
+ expect(body.output.brief.exists).toBe(false);
323
+ });
324
+
325
+ test.each(PROJECT_HOME_TOOL_NAMES)('%s defaults cwd to SM_PROJECT_ROOT when omitted', async (name) => {
326
+ const homeDir = await mkTmp();
327
+ const token = 'test-token';
328
+ const { server, requests } = await startFakeAdminServer(token, { ok: true, valid: true, errors: [] });
329
+ servers.push(server);
330
+ await writeAdminConfig(homeDir, { port: server.address().port, token });
331
+
332
+ originalProjectRoot = process.env.SM_PROJECT_ROOT ?? null;
333
+ process.env.SM_PROJECT_ROOT = '/home/bilko/Projects/session-manager/session-manager-operations/scheduler/epics/some-epic/worktree';
334
+
335
+ const { handleCallTool } = requireServerWithHome(homeDir);
336
+ const args = name === 'project_home_render' ? { summary: {}, picks: {} } : name === 'project_home_validate_summary' ? { summary: {} } : {};
337
+ await callTool(handleCallTool, name, args);
338
+ expect(requests).toHaveLength(1);
339
+ const encoded = requests[0].body?.cwd ?? new URLSearchParams(requests[0].url.split('?')[1]).get('cwd');
340
+ expect(encoded).toBe(process.env.SM_PROJECT_ROOT);
341
+ });
342
+
343
+ test.each(PROJECT_HOME_TOOL_NAMES)('%s returns the app-not-running error when the admin API is unreachable', async (name) => {
344
+ const homeDir = await mkTmp();
345
+ const { handleCallTool, NOT_RUNNING_ERROR } = requireServerWithHome(homeDir);
346
+ const args = name === 'project_home_render' ? { summary: {}, picks: {} } : name === 'project_home_validate_summary' ? { summary: {} } : {};
347
+ const result = await callTool(handleCallTool, name, args);
348
+ expect(result.isError).toBe(true);
349
+ expect(result.content[0].text).toBe(NOT_RUNNING_ERROR);
350
+ });
@@ -120,9 +120,67 @@ function resolveEpicModel({ cwd, claudeSessionId, fallbackModel = FALLBACK_MODEL
120
120
  }
121
121
  }
122
122
 
123
+ /** Mirrors epicIntake.ts's AGENT_BODY_CHAR_CAP — a runaway persona file must
124
+ * not be able to dominate a spawned job's system prompt either. Duplicated
125
+ * rather than imported: epicIntake.ts is a renderer module (no ES modules
126
+ * in main). */
127
+ const AGENT_BODY_CHAR_CAP = 6000;
128
+
129
+ /** Trims a persona's frontmatter-stripped body and caps it at
130
+ * AGENT_BODY_CHAR_CAP, appending a truncation notice naming `personaPath`
131
+ * — same shape as epicIntake.ts's buildPersonaBodyText. Returns null for an
132
+ * empty body. */
133
+ function buildPersonaBodyText(rawBody, personaPath) {
134
+ const body = (rawBody || '').trim();
135
+ if (!body) return null;
136
+ if (body.length <= AGENT_BODY_CHAR_CAP) return body;
137
+ const truncated = body.slice(0, AGENT_BODY_CHAR_CAP);
138
+ const pathLabel = personaPath || 'the persona file';
139
+ return `${truncated}\n\n[Truncated — ${pathLabel} exceeds ${AGENT_BODY_CHAR_CAP} characters; see the file for the full body.]`;
140
+ }
141
+
142
+ /**
143
+ * Resolves the persona system-prompt + model a scheduled PRD's `agentType`
144
+ * should launch its headless `claude -p` spawn with. Never throws — a
145
+ * missing agentType, or one that no longer resolves to a readable persona
146
+ * file (renamed/deleted), falls back to `{ systemPrompt: null, model:
147
+ * fallbackModel, personaPath: null }` and logs once via prdAgentType.cjs's
148
+ * reportDanglingAgentTypeOnce (the PRD-frontmatter FK's own dangling-read
149
+ * convention) — a dangling persona must never park or fail the job.
150
+ *
151
+ * Reuses agentLibrary.cjs's getPersonaBody (project-overlay-then-global
152
+ * read, same precedence resolvePersonaPaths documents) rather than a second
153
+ * persona-file reader, and this module's own frontmatter-stripped-body cap
154
+ * (mirroring epicIntake.ts's Epic-path treatment) so the PRD path gets the
155
+ * same runaway-file protection.
156
+ *
157
+ * @param {{ cwd: string, agentType: string|null, fallbackModel?: string, deps?: object }} opts
158
+ * @returns {Promise<{ model: string, systemPrompt: string|null, personaPath: string|null }>}
159
+ */
160
+ async function resolvePrdPersonaForSpawn({ cwd, agentType, fallbackModel = FALLBACK_MODEL, deps = {} } = {}) {
161
+ const miss = { model: fallbackModel, systemPrompt: null, personaPath: null };
162
+ if (!agentType) return miss;
163
+ try {
164
+ const getBody = deps.getPersonaBody || require('../agentLibrary.cjs').getPersonaBody;
165
+ const persona = await getBody({ cwd, name: agentType });
166
+ if (!persona) {
167
+ const reportOnce = deps.reportDanglingAgentTypeOnce || require('./prdAgentType.cjs').reportDanglingAgentTypeOnce;
168
+ reportOnce(cwd, agentType, deps);
169
+ return miss;
170
+ }
171
+ const { fm, body } = splitFrontmatter(persona.text);
172
+ const model = fm.model && fm.model !== 'inherit' ? fm.model : fallbackModel;
173
+ const systemPrompt = buildPersonaBodyText(body, persona.path);
174
+ return { model, systemPrompt, personaPath: persona.path };
175
+ } catch {
176
+ return miss;
177
+ }
178
+ }
179
+
123
180
  module.exports = {
124
181
  FALLBACK_MODEL,
125
182
  resolveEpicModel,
126
183
  findAgentTypeByClaudeSessionId,
127
184
  readPersonaModel,
185
+ resolvePrdPersonaForSpawn,
128
186
  };
@@ -40,6 +40,10 @@
40
40
  * killedByWatchdog — string | null
41
41
  * error — Error | undefined (child 'error' event or sync spawn throw)
42
42
  * spawnFailed — boolean, true when the error came from a synchronous spawn throw
43
+ * leakedDescendants — { pid, pcpu, etimes, comm }[], the process-group survivors
44
+ * sweepChildProcessGroup found still alive right before it
45
+ * swept them. [] in the normal case (nothing leaked, or
46
+ * non-Linux, or enumeration failed). See sweepChildProcessGroup.
43
47
  * safeLog — same safeLog (usable inside onExit for final log lines)
44
48
  *
45
49
  * Returns { child, cancel } where cancel() clears all timers + SIGKILLs.
@@ -47,7 +51,7 @@
47
51
  */
48
52
 
49
53
  const fs = require('node:fs');
50
- const { spawn } = require('node:child_process');
54
+ const { spawn, execFileSync } = require('node:child_process');
51
55
 
52
56
  // Grace period between the post-exit SIGTERM sweep and the follow-up SIGKILL
53
57
  // sweep of a detached child's process group. See sweepChildProcessGroup below.
@@ -136,11 +140,39 @@ function openLog(logPath) {
136
140
  // a job's descendants must not outlive the job. Killing the job's own process
137
141
  // group at the job's own exit is ownership-based and needs no /proc heuristics
138
142
  // to distinguish an abandoned descendant from a live one.
143
+ // Enumerate the still-alive members of process group `pgid` (Linux only).
144
+ // Read-only — never throws, never blocks the caller's exit path. Used to
145
+ // report what sweepChildProcessGroup is about to kill, since the kill itself
146
+ // is silent (ESRCH on the normal empty-group case).
147
+ function enumerateProcessGroupSurvivors(pgid) {
148
+ if (process.platform !== 'linux') return [];
149
+ if (!pgid || pgid <= 1) return [];
150
+ try {
151
+ const out = execFileSync('ps', ['-eo', 'pid,pgrp,pcpu,etimes,comm'], { encoding: 'utf8', timeout: 2000 });
152
+ const survivors = [];
153
+ for (const line of out.split('\n').slice(1)) {
154
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(.+)$/);
155
+ if (!m) continue;
156
+ const [, pidStr, pgrpStr, pcpuStr, etimesStr, comm] = m;
157
+ if (Number(pgrpStr) !== pgid) continue;
158
+ survivors.push({ pid: Number(pidStr), pcpu: Number(pcpuStr), etimes: Number(etimesStr), comm });
159
+ }
160
+ return survivors;
161
+ } catch {
162
+ return [];
163
+ }
164
+ }
165
+
166
+ // Returns the leaked descendants found (see enumerateProcessGroupSurvivors),
167
+ // having enumerated them BEFORE issuing SIGTERM so the report reflects what
168
+ // was actually swept, not what happened to still be running afterward.
139
169
  function sweepChildProcessGroup(child, detached) {
140
- if (!detached) return;
141
- if (!child || !child.pid || child.pid <= 1) return;
170
+ if (!detached) return [];
171
+ if (!child || !child.pid || child.pid <= 1) return [];
142
172
  // Defensive: never target the Session Manager process's own group.
143
- if (child.pid === process.pid) return;
173
+ if (child.pid === process.pid) return [];
174
+
175
+ const leaked = enumerateProcessGroupSurvivors(child.pid).filter((p) => p.pid !== child.pid);
144
176
 
145
177
  try { process.kill(-child.pid, 'SIGTERM'); } catch { /* ESRCH: empty group, the normal case */ }
146
178
 
@@ -148,6 +180,8 @@ function sweepChildProcessGroup(child, detached) {
148
180
  try { process.kill(-child.pid, 'SIGKILL'); } catch { /* ESRCH: empty group, the normal case */ }
149
181
  }, POST_EXIT_GROUP_SWEEP_GRACE_MS);
150
182
  if (t.unref) t.unref();
183
+
184
+ return leaked;
151
185
  }
152
186
 
153
187
  function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watchdogs = [], onExit }) {
@@ -204,6 +238,7 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
204
238
  // calls onExit (caller may still safeLog inside), then closes the fd.
205
239
  const handleDone = (exitCode, signal, error, spawnFailed) => {
206
240
  clearAllTimers();
241
+ const leakedDescendants = sweepChildProcessGroup(ctx.child, !!spawnSpec.options?.detached);
207
242
  if (onExit) {
208
243
  onExit({
209
244
  exitCode,
@@ -211,10 +246,10 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
211
246
  killedByWatchdog: ctx.killedByWatchdog,
212
247
  error,
213
248
  spawnFailed: spawnFailed ?? false,
249
+ leakedDescendants,
214
250
  safeLog,
215
251
  });
216
252
  }
217
- sweepChildProcessGroup(ctx.child, !!spawnSpec.options?.detached);
218
253
  closeFd();
219
254
  };
220
255
 
@@ -39,4 +39,57 @@ function resolveClaudeBin() {
39
39
  return cached;
40
40
  }
41
41
 
42
- module.exports = { resolveClaudeBin };
42
+ // ---------- version probe ----------
43
+ //
44
+ // `claude --version`, cached. The scheduler's launch circuit breaker
45
+ // (lib/launchFailure.cjs) records the CLI version a launch block was armed
46
+ // under and drops the block the moment the version changes — the incident
47
+ // behind it (issue #11: an outdated CLI sending a thinking parameter the
48
+ // model rejects) is fixed by `claude update`, and the queue must notice
49
+ // that without a human pressing anything. Async, bounded, never throws:
50
+ // a probe failure yields null and the breaker simply loses the
51
+ // version-change shortcut (backoff still recovers).
52
+ const VERSION_TTL_MS = 5 * 60_000;
53
+ const VERSION_PROBE_TIMEOUT_MS = 8_000;
54
+ let versionCache = { at: 0, value: null, inflight: null };
55
+
56
+ function probeClaudeVersion({ now = Date.now(), execFileImpl } = {}) {
57
+ // Operator/test pin: skips executing the binary entirely.
58
+ if (process.env.SM_CLAUDE_VERSION) return Promise.resolve(process.env.SM_CLAUDE_VERSION);
59
+ if (versionCache.inflight) return versionCache.inflight;
60
+ if (versionCache.value !== null && now - versionCache.at < VERSION_TTL_MS) {
61
+ return Promise.resolve(versionCache.value);
62
+ }
63
+ const execFile = execFileImpl || require('node:child_process').execFile;
64
+ const bin = resolveClaudeBin();
65
+ versionCache.inflight = new Promise((resolve) => {
66
+ let settled = false;
67
+ const done = (value) => {
68
+ if (settled) return;
69
+ settled = true;
70
+ versionCache = { at: Date.now(), value, inflight: null };
71
+ resolve(value);
72
+ };
73
+ try {
74
+ // cwd is a neutral temp dir on purpose: the version does not depend on
75
+ // it, and the binary must never be handed the caller's working tree —
76
+ // a stub `claude` (tests point SM_CLAUDE_BIN at one) that writes
77
+ // markers or commits into process.cwd() once did so in the real repo.
78
+ execFile(bin, ['--version'], { timeout: VERSION_PROBE_TIMEOUT_MS, windowsHide: true, cwd: os.tmpdir() }, (err, stdout) => {
79
+ if (err) return done(null);
80
+ const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(String(stdout || ''));
81
+ done(m ? m[1] : (String(stdout || '').trim() || null));
82
+ });
83
+ } catch {
84
+ done(null);
85
+ }
86
+ });
87
+ return versionCache.inflight;
88
+ }
89
+
90
+ /** Test hook — forget the cached version so the next probe re-executes. */
91
+ function resetClaudeVersionCache() {
92
+ versionCache = { at: 0, value: null, inflight: null };
93
+ }
94
+
95
+ module.exports = { resolveClaudeBin, probeClaudeVersion, resetClaudeVersionCache };
@@ -328,7 +328,7 @@ const RISK_HEURISTICS = [
328
328
  * @param {string} headingText Case-insensitive heading to find.
329
329
  * @returns {string}
330
330
  */
331
- function _extractSection(body, headingText) {
331
+ function extractSection(body, headingText) {
332
332
  const escapedHeading = headingText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
333
333
  const headingRe = new RegExp(`^#+\\s+${escapedHeading}`, 'i');
334
334
  const lines = body.split('\n');
@@ -398,7 +398,7 @@ function flagRiskySurfaces(jobs, { prdsDir, gitTimeoutMs = 10_000 } = {}) {
398
398
  try {
399
399
  const raw = fs.readFileSync(path.join(resolvedPrdsDir, `${job.slug}.md`), 'utf8');
400
400
  const body = splitFrontmatter(raw).body;
401
- candidateText = _extractSection(body, 'Implementation notes');
401
+ candidateText = extractSection(body, 'Implementation notes');
402
402
  } catch { /* no candidate text — skip */ }
403
403
  }
404
404
 
@@ -573,6 +573,7 @@ module.exports = {
573
573
  reportPathFor,
574
574
  reportExists,
575
575
  extractAcCommand,
576
+ extractSection,
576
577
  reverifyAc,
577
578
  reverifyBatch,
578
579
  flagRiskySurfaces,