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,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
+ });
@@ -36,6 +36,7 @@
36
36
  const path = require('path');
37
37
  const { activeIndexPath, withPathLock } = require('./epicMint.cjs');
38
38
  const config = require('../config.cjs');
39
+ const { mirrorEpicStatus } = require('./epicStatusMirror.cjs');
39
40
 
40
41
  function hasOwn(obj, key) {
41
42
  return Object.prototype.hasOwnProperty.call(obj, key);
@@ -160,6 +161,20 @@ async function mergeActiveIndex(cwd, { sessions: rawMemorySessions = {}, events:
160
161
  // payload, so a compromised/forged renderer call can't claim a
161
162
  // different writer id.
162
163
  await config.writeJson(filePath, index, { writer: 'epics' });
164
+ // Status mirror (epicStatusMirror.cjs) — this IPC handler is the
165
+ // renderer-driven persist path (persistActiveIndex), the write site for
166
+ // both the 'proposed'->'active' start transition (approveProposed) and
167
+ // every other in-place session mutation. Mirrors every surviving row so
168
+ // a lost/clobbered active-index.json can be reconstructed from these
169
+ // files (activeIndexRebuild.cjs). Best-effort per row: one bad write must
170
+ // not undo the index write that already landed above.
171
+ for (const [id, sess] of Object.entries(mergedSessions)) {
172
+ try {
173
+ mirrorEpicStatus(cwd, id, { session: sess, status: sess.status });
174
+ } catch (e) {
175
+ console.warn(`[activeIndexMerge] mirrorEpicStatus failed for ${id}`, e);
176
+ }
177
+ }
163
178
  return { sessions: mergedSessions, events: mergedEvents };
164
179
  });
165
180
  }
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * activeIndexRebuild.cjs — reconstructs `active-index.json` from the
5
+ * per-Epic status mirrors `epicStatusMirror.cjs` writes onto `prompt-
6
+ * sessions/<epicId>.json`. This is the recovery path for the incident the
7
+ * mirror exists to survive: a lost, clobbered, git-reverted, or
8
+ * worktree-snapshotted index that would otherwise silently erase every open
9
+ * Epic it held with no way to get them back.
10
+ *
11
+ * Rebuild NEVER mints — SINGLE-CREATOR LAW (epicMint.cjs) is untouched
12
+ * because no new Epic id is ever created here, only rows for ids that
13
+ * already have a mirrored file re-materialized. It also never resurrects a
14
+ * tombstoned or archived Epic (see the two "never resurrect" guards below) —
15
+ * a human's delete/complete decision always wins.
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+ const { activeIndexPath, readActiveIndex, writeActiveIndex, withPathLock } = require('./epicMint.cjs');
21
+
22
+ function promptSessionsDir(cwd) {
23
+ const { opsPath } = require('./opsOwnership.cjs');
24
+ return opsPath(cwd, 'prompt-sessions');
25
+ }
26
+
27
+ function hasOwn(obj, key) {
28
+ return Object.prototype.hasOwnProperty.call(obj, key);
29
+ }
30
+
31
+ function listMirrorFiles(dir) {
32
+ let entries;
33
+ try {
34
+ entries = fs.readdirSync(dir);
35
+ } catch {
36
+ return [];
37
+ }
38
+ return entries.filter((f) => f.endsWith('.json') && f !== 'active-index.json');
39
+ }
40
+
41
+ /**
42
+ * rebuildActiveIndex(cwd, { dryRun? }) → { rows, skipped, reasons }
43
+ *
44
+ * `rows` — reconstructed PromptSession objects, one per mirrored file that
45
+ * is a live (proposed/active), non-tombstoned, non-archived Epic. These
46
+ * become the new `sessions` map (dryRun: false is the default — pass
47
+ * `{ dryRun: true }` to preview without writing).
48
+ * `skipped` — file names not turned into a row.
49
+ * `reasons` — `{ [fileName]: humanReadableReason }`, one entry per skipped
50
+ * file. A file is never guessed at: no mirrored `status` is a skip, not a
51
+ * fabricated 'unknown' row (see scripts/mirror-epic-status.cjs for the
52
+ * only place that vocabulary is deliberately introduced, for the
53
+ * migration's own back-fill bookkeeping).
54
+ *
55
+ * `events` and `tombstones` from the existing on-disk index (if any) are
56
+ * carried through completely untouched — this function only ever
57
+ * reconstructs `sessions`.
58
+ */
59
+ function rebuildActiveIndex(cwd, { dryRun = false } = {}) {
60
+ if (!cwd || typeof cwd !== 'string') throw new Error('rebuildActiveIndex: cwd is required');
61
+ cwd = path.resolve(cwd);
62
+ return withPathLock(activeIndexPath(cwd), () => {
63
+ const dir = promptSessionsDir(cwd);
64
+ const existing = readActiveIndex(cwd);
65
+ const tombstones = existing.tombstones || {};
66
+ const files = listMirrorFiles(dir);
67
+
68
+ const rows = [];
69
+ const skipped = [];
70
+ const reasons = {};
71
+
72
+ for (const file of files) {
73
+ const id = file.slice(0, -'.json'.length);
74
+ const full = path.join(dir, file);
75
+ let parsed;
76
+ try {
77
+ parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
78
+ } catch (e) {
79
+ skipped.push(file);
80
+ reasons[file] = `unreadable: ${e.message}`;
81
+ continue;
82
+ }
83
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
84
+ skipped.push(file);
85
+ reasons[file] = 'unreadable: not a JSON object';
86
+ continue;
87
+ }
88
+ // Never resurrected — a tombstoned id stays out even if its file says
89
+ // 'active' (see activeIndexMerge.cjs's header on why tombstones are
90
+ // never auto-cleared).
91
+ if (hasOwn(tombstones, id)) {
92
+ skipped.push(file);
93
+ reasons[file] = 'tombstoned — never resurrected by a rebuild';
94
+ continue;
95
+ }
96
+ if (!parsed.status) {
97
+ skipped.push(file);
98
+ reasons[file] = 'no status mirror on this file — never guessed at';
99
+ continue;
100
+ }
101
+ // A completed archive (markCompleted's PromptSessionArchive, or any
102
+ // mirror written after archival) is never restored as a live row.
103
+ if (parsed.archivedAt) {
104
+ skipped.push(file);
105
+ reasons[file] = 'archivedAt is set — completed Epic, not a live row';
106
+ continue;
107
+ }
108
+ if (parsed.status !== 'proposed' && parsed.status !== 'active') {
109
+ skipped.push(file);
110
+ reasons[file] = `status '${parsed.status}' is not a live row`;
111
+ continue;
112
+ }
113
+ // Drop mirror-only bookkeeping (`indexedAt`) — it is not part of the
114
+ // PromptSession shape active-index.json's `sessions` map holds.
115
+ const { indexedAt, archivedAt, ...row } = parsed;
116
+ rows.push({ ...row, id, cwd: parsed.cwd || cwd });
117
+ }
118
+
119
+ if (!dryRun) {
120
+ const sessions = {};
121
+ for (const row of rows) sessions[row.id] = row;
122
+ writeActiveIndex(cwd, {
123
+ sessions,
124
+ events: existing.events || {},
125
+ tombstones,
126
+ });
127
+ }
128
+
129
+ return { rows, skipped, reasons };
130
+ });
131
+ }
132
+
133
+ module.exports = { rebuildActiveIndex };
@@ -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
  };
@@ -30,7 +30,7 @@
30
30
  const fs = require('node:fs');
31
31
  const path = require('node:path');
32
32
 
33
- const BUILD_TARGET_SUBPATH = ['session-manager-operations', 'architecture', 'build-target.json'];
33
+ const BUILD_TARGET_SUBPATH = ['architecture', 'build-target.json']; // joined under opsOwnership.opsPath
34
34
 
35
35
  function readJson(filePath) {
36
36
  try {
@@ -48,7 +48,8 @@ function readJson(filePath) {
48
48
  function resolveBuildTarget(cwd) {
49
49
  if (!cwd || typeof cwd !== 'string') return null;
50
50
 
51
- const configPath = path.join(cwd, ...BUILD_TARGET_SUBPATH);
51
+ const { opsPath } = require('./opsOwnership.cjs');
52
+ const configPath = opsPath(cwd, ...BUILD_TARGET_SUBPATH);
52
53
  const explicit = readJson(configPath);
53
54
  if (explicit && typeof explicit === 'object' && !Array.isArray(explicit) && typeof explicit.packageName === 'string') {
54
55
  return explicit;