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,485 @@
1
+ /**
2
+ * projectHomeAdminRoutes.test.cjs — unit tests for
3
+ * lib/projectHomeAdminRoutes.cjs, the app-side half of "the generating
4
+ * session needs zero repo knowledge to build a Project Home page" (PRD:
5
+ * project-home-admin-routes).
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/projectHomeAdminRoutes.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, afterEach, vi } from 'vitest';
13
+ const fs = require('node:fs');
14
+ const fsp = require('node:fs/promises');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const config = require('../config.cjs');
18
+ const { registerAdminRoute } = require('../lib/projectHomeAdminRoutes.cjs');
19
+
20
+ const tmpDirs = [];
21
+ afterEach(async () => {
22
+ while (tmpDirs.length) {
23
+ const d = tmpDirs.pop();
24
+ await fsp.rm(d, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ async function mkProjectCwd() {
29
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-project-home-admin-'));
30
+ config.addAllowedRoot(dir);
31
+ fs.mkdirSync(path.join(dir, 'session-manager-operations'), { recursive: true });
32
+ tmpDirs.push(dir);
33
+ return dir;
34
+ }
35
+
36
+ /** Fake adminHttp stub — same pattern prdAdminRouteParity.test.cjs uses:
37
+ * captures registered routes and lets a test invoke one directly with a
38
+ * fake req/res/query, no real HTTP server or Electron boot needed. */
39
+ function makeFakeAdminHttp() {
40
+ const routes = new Map();
41
+ return {
42
+ registerRoute(method, url, handler) {
43
+ routes.set(`${method} ${url}`, handler);
44
+ },
45
+ async call(method, url, { query = new URLSearchParams(), body } = {}) {
46
+ const handler = routes.get(`${method} ${url}`);
47
+ if (!handler) throw new Error(`no route registered for ${method} ${url}`);
48
+ const chunks = body !== undefined ? [Buffer.from(JSON.stringify(body))] : [];
49
+ const req = {
50
+ on(event, cb) {
51
+ if (event === 'data') chunks.forEach((c) => cb(c));
52
+ if (event === 'end') cb();
53
+ return req;
54
+ },
55
+ };
56
+ let status = null;
57
+ let payload = null;
58
+ const res = {
59
+ writeHead(s) { status = s; },
60
+ end(b) { payload = b ? JSON.parse(b) : null; },
61
+ };
62
+ await handler(req, res, query);
63
+ return { status, body: payload };
64
+ },
65
+ routes,
66
+ };
67
+ }
68
+
69
+ function validSummary(overrides = {}) {
70
+ return {
71
+ identity: {
72
+ name: 'Demo', tag: 'demo', version: '1.0.0', oneLine: 'A demo project.',
73
+ claim: 'Ship faster.', sub: 'Subtitle text.', audience: 'Developers', install: 'npm i demo',
74
+ },
75
+ stats: [{ v: '5', k: 'years', n: 'since launch' }],
76
+ pillars: [{ t: 'Fast', d: 'It is fast.', k: 'speed' }],
77
+ quotes: [],
78
+ feature: {
79
+ name: 'Feature', kicker: 'New', status: 'shipped', owner: 'team', oneLine: 'One line.',
80
+ problem: 'Problem text.', solution: 'Solution text.',
81
+ steps: [{ t: 'Step', d: 'desc' }], rules: [{ t: 'Rule', d: 'desc' }],
82
+ specs: [['label', 'value', 'note']], faq: [{ q: 'Q', a: 'A' }],
83
+ timeline: [{ w: 'W1', t: 'Thing', s: 'done' }],
84
+ },
85
+ architecture: {
86
+ summary: 'Arch summary.',
87
+ principles: [{ t: 'P', d: 'd' }],
88
+ layers: [{ n: 'Layer', d: 'd', f: '5', tone: 'accent' }],
89
+ modules: [{ n: 'Mod', d: 'd', f: 3, dep: [], heat: 1 }],
90
+ flow: [{ a: 'A', b: 'B', t: 't', n: 'n' }],
91
+ decisions: [{ id: 'd1', t: 't', w: 'w', s: 'accepted' }],
92
+ risks: [],
93
+ },
94
+ ...overrides,
95
+ };
96
+ }
97
+
98
+ function picksFromCatalog(catalog) {
99
+ const picks = {};
100
+ for (const lens of catalog.lenses) {
101
+ picks[lens.id] = {};
102
+ for (const slot of lens.slots) {
103
+ picks[lens.id][slot.id] = slot.variants[0].id;
104
+ }
105
+ }
106
+ return picks;
107
+ }
108
+
109
+ // ─── Route registration ────────────────────────────────────────────────
110
+ test('registers all four project-home admin routes', () => {
111
+ const fake = makeFakeAdminHttp();
112
+ registerAdminRoute(fake);
113
+ expect(fake.routes.has('GET /admin/project-home/contract')).toBe(true);
114
+ expect(fake.routes.has('POST /admin/project-home/validate-summary')).toBe(true);
115
+ expect(fake.routes.has('POST /admin/project-home/render')).toBe(true);
116
+ expect(fake.routes.has('GET /admin/project-home/status')).toBe(true);
117
+ });
118
+
119
+ // ─── contract ──────────────────────────────────────────────────────────
120
+ test('contract: self-sufficient — 5 lenses, non-empty slots, absolute paths, no repo-relative path strings', async () => {
121
+ const cwd = await mkProjectCwd();
122
+ const fake = makeFakeAdminHttp();
123
+ registerAdminRoute(fake);
124
+
125
+ const { status, body } = await fake.call('GET', '/admin/project-home/contract', {
126
+ query: new URLSearchParams({ cwd }),
127
+ });
128
+ expect(status).toBe(200);
129
+ expect(body.ok).toBe(true);
130
+ expect(Array.isArray(body.protocol)).toBe(true);
131
+ expect(body.protocol.length).toBeGreaterThan(0);
132
+
133
+ expect(body.catalog.lenses.map((l) => l.id).sort()).toEqual(
134
+ ['architecture', 'brief', 'feature', 'home', 'marketing'].sort(),
135
+ );
136
+ for (const lens of body.catalog.lenses) {
137
+ expect(Array.isArray(lens.slots)).toBe(true);
138
+ expect(lens.slots.length).toBeGreaterThan(0);
139
+ }
140
+
141
+ expect(path.isAbsolute(body.paths.summaryPath)).toBe(true);
142
+ expect(path.isAbsolute(body.paths.picksPath)).toBe(true);
143
+ expect(path.isAbsolute(body.paths.outputDir)).toBe(true);
144
+ expect(body.paths.summaryPath).toContain(cwd);
145
+
146
+ expect(body.summarySchema).toBeTruthy();
147
+ expect(body.summarySchema.properties.identity).toBeTruthy();
148
+
149
+ // The operational fields (catalog/paths/protocol) must never point the
150
+ // caller at a repo-relative path — the whole point of this route. The
151
+ // free-form "spec.text" field is the architecture doc's own prose, which
152
+ // legitimately documents source file locations for background reading;
153
+ // it is explicitly excluded from this check (see route file's loadCatalog
154
+ // comment for why the catalog's own $comment is stripped for the same
155
+ // reason).
156
+ const operationalFields = JSON.stringify({ protocol: body.protocol, catalog: body.catalog, paths: body.paths });
157
+ expect(operationalFields).not.toMatch(/src\/renderer|src\/main|scripts\//);
158
+ // PRD 1093's exact banned-substring list (the class of path that broke
159
+ // generation on a foreign machine before this route existed).
160
+ for (const banned of [
161
+ 'session-manager-operations/architecture/',
162
+ 'session-manager-operations/design-mocks/',
163
+ '.claude/agents/',
164
+ 'src/renderer/',
165
+ 'npm run build:project-pages',
166
+ ]) {
167
+ expect(operationalFields, `operational fields must not mention "${banned}"`).not.toContain(banned);
168
+ }
169
+ });
170
+
171
+ test('contract: rejects a missing/non-absolute cwd with a structured error, not a throw', async () => {
172
+ const fake = makeFakeAdminHttp();
173
+ registerAdminRoute(fake);
174
+
175
+ const missing = await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams() });
176
+ expect(missing.status).toBe(400);
177
+ expect(missing.body.ok).toBe(false);
178
+
179
+ const relative = await fake.call('GET', '/admin/project-home/contract', {
180
+ query: new URLSearchParams({ cwd: 'relative/path' }),
181
+ });
182
+ expect(relative.status).toBe(400);
183
+ expect(relative.body.ok).toBe(false);
184
+ });
185
+
186
+ test('contract: rejects a cwd with no session-manager-operations/ directory', async () => {
187
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-not-a-project-'));
188
+ config.addAllowedRoot(dir);
189
+ tmpDirs.push(dir);
190
+ const fake = makeFakeAdminHttp();
191
+ registerAdminRoute(fake);
192
+
193
+ const { status, body } = await fake.call('GET', '/admin/project-home/contract', {
194
+ query: new URLSearchParams({ cwd: dir }),
195
+ });
196
+ expect(status).toBe(400);
197
+ expect(body.ok).toBe(false);
198
+ expect(body.error).toMatch(/not a Session Manager project/);
199
+ });
200
+
201
+ // ─── validate-summary ──────────────────────────────────────────────────
202
+ test('validate-summary: accepts a well-formed summary', async () => {
203
+ const cwd = await mkProjectCwd();
204
+ const fake = makeFakeAdminHttp();
205
+ registerAdminRoute(fake);
206
+
207
+ const { status, body } = await fake.call('POST', '/admin/project-home/validate-summary', {
208
+ body: { cwd, summary: validSummary() },
209
+ });
210
+ expect(status).toBe(200);
211
+ expect(body.valid).toBe(true);
212
+ expect(body.errors).toEqual([]);
213
+ });
214
+
215
+ test('validate-summary: rejects a malformed summary with per-field errors', async () => {
216
+ const cwd = await mkProjectCwd();
217
+ const fake = makeFakeAdminHttp();
218
+ registerAdminRoute(fake);
219
+
220
+ const bad = validSummary({ identity: { ...validSummary().identity, name: '' } });
221
+ const { status, body } = await fake.call('POST', '/admin/project-home/validate-summary', {
222
+ body: { cwd, summary: bad },
223
+ });
224
+ expect(status).toBe(200);
225
+ expect(body.valid).toBe(false);
226
+ expect(body.errors.length).toBeGreaterThan(0);
227
+ expect(body.errors[0]).toHaveProperty('field');
228
+ expect(body.errors[0]).toHaveProperty('message');
229
+ expect(body.errors.some((e) => e.field === 'identity.name')).toBe(true);
230
+ });
231
+
232
+ // ─── render ────────────────────────────────────────────────────────────
233
+ test('render: happy path writes summary.json, picks.json, all 5 lens htmls, and manifest.json', async () => {
234
+ const cwd = await mkProjectCwd();
235
+ const fake = makeFakeAdminHttp();
236
+ registerAdminRoute(fake);
237
+
238
+ const contract = await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams({ cwd }) });
239
+ const picks = picksFromCatalog(contract.body.catalog);
240
+ const summary = validSummary();
241
+
242
+ const { status, body } = await fake.call('POST', '/admin/project-home/render', {
243
+ body: { cwd, summary, picks },
244
+ });
245
+ expect(status).toBe(200);
246
+ expect(body.ok).toBe(true);
247
+ expect(typeof body.generatedAt).toBe('string');
248
+ expect(Number.isNaN(Date.parse(body.generatedAt))).toBe(false);
249
+ expect(Array.isArray(body.filesWritten)).toBe(true);
250
+
251
+ const dir = path.join(cwd, 'session-manager-operations', 'project-pages');
252
+ expect(fs.existsSync(path.join(dir, 'summary.json'))).toBe(true);
253
+ expect(fs.existsSync(path.join(dir, 'picks.json'))).toBe(true);
254
+ for (const lens of ['home', 'marketing', 'feature', 'architecture', 'brief']) {
255
+ expect(fs.existsSync(path.join(dir, 'output', `${lens}.html`))).toBe(true);
256
+ }
257
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'output', 'manifest.json'), 'utf8'));
258
+ expect(manifest.generatedAt).toBe(body.generatedAt);
259
+
260
+ expect(JSON.parse(fs.readFileSync(path.join(dir, 'summary.json'), 'utf8'))).toEqual(summary);
261
+ });
262
+
263
+ test('render: a malformed summary is rejected and writes NOTHING', async () => {
264
+ const cwd = await mkProjectCwd();
265
+ const fake = makeFakeAdminHttp();
266
+ registerAdminRoute(fake);
267
+
268
+ const bad = validSummary({ architecture: { ...validSummary().architecture, summary: '' } });
269
+ const { status, body } = await fake.call('POST', '/admin/project-home/render', {
270
+ body: { cwd, summary: bad, picks: {} },
271
+ });
272
+ expect(status).toBe(400);
273
+ expect(body.ok).toBe(false);
274
+ expect(body.valid).toBe(false);
275
+ expect(body.errors.length).toBeGreaterThan(0);
276
+
277
+ const dir = path.join(cwd, 'session-manager-operations', 'project-pages');
278
+ expect(fs.existsSync(dir)).toBe(false);
279
+ });
280
+
281
+ test('render: a malformed picks shape is rejected with a field error and writes NOTHING', async () => {
282
+ const cwd = await mkProjectCwd();
283
+ const fake = makeFakeAdminHttp();
284
+ registerAdminRoute(fake);
285
+
286
+ const { status, body } = await fake.call('POST', '/admin/project-home/render', {
287
+ body: { cwd, summary: validSummary(), picks: { home: { hero: 42 } } },
288
+ });
289
+ expect(status).toBe(400);
290
+ expect(body.ok).toBe(false);
291
+ expect(body.errors.some((e) => e.field === 'picks.home.hero')).toBe(true);
292
+
293
+ const dir = path.join(cwd, 'session-manager-operations', 'project-pages');
294
+ expect(fs.existsSync(dir)).toBe(false);
295
+ });
296
+
297
+ test('GET routes (contract, status) never widen the write boundary — only render does', async () => {
298
+ // mkProjectCwd() (used by every other test here) calls config.addAllowedRoot
299
+ // itself so validatePath succeeds for an arbitrary os.tmpdir() location —
300
+ // which would defeat this specific assertion. Use a cwd that's readable
301
+ // via validatePath's own default allowed root (os.homedir()) WITHOUT any
302
+ // addAllowedRoot call, so a write only succeeds if a route handler itself
303
+ // called it — matching prdCreate.test.cjs's / prdMigration.test.cjs's own
304
+ // "homedir-rooted tmp cwd" pattern for exactly this kind of check.
305
+ const cwd = fs.realpathSync(await fsp.mkdtemp(path.join(os.homedir(), '.sm-project-home-admin-')));
306
+ tmpDirs.push(cwd);
307
+ fs.mkdirSync(path.join(cwd, 'session-manager-operations'), { recursive: true });
308
+
309
+ const fake = makeFakeAdminHttp();
310
+ registerAdminRoute(fake);
311
+
312
+ await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams({ cwd }) });
313
+ await fake.call('GET', '/admin/project-home/status', { query: new URLSearchParams({ cwd }) });
314
+
315
+ // A write through config.cjs for this cwd must still be refused — the
316
+ // GET routes above must not have called config.addAllowedRoot for it.
317
+ const probePath = path.join(cwd, 'session-manager-operations', 'project-pages', 'probe.json');
318
+ await expect(config.writeJson(probePath, { a: 1 }, { writer: 'project-home' })).rejects.toThrow(
319
+ /Write outside allowed write boundaries/,
320
+ );
321
+ });
322
+
323
+ test('render: two concurrent renders for the same cwd are serialized, not interleaved', async () => {
324
+ const cwd = await mkProjectCwd();
325
+ const fake = makeFakeAdminHttp();
326
+ registerAdminRoute(fake);
327
+
328
+ const contract = await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams({ cwd }) });
329
+ const picks = picksFromCatalog(contract.body.catalog);
330
+
331
+ // Distinct summaries so writes for render A vs render B can be told apart
332
+ // by content — identity.oneLine is echoed into every lens's html output
333
+ // (see renderer.cjs's htmlDocument/proj.oneLine usage).
334
+ const summaryA = validSummary({ identity: { ...validSummary().identity, oneLine: 'MARKER_RENDER_A' } });
335
+ const summaryB = validSummary({ identity: { ...validSummary().identity, oneLine: 'MARKER_RENDER_B' } });
336
+
337
+ const events = [];
338
+ let releaseGate;
339
+ const gate = new Promise((resolve) => { releaseGate = resolve; });
340
+ let gateArmed = false;
341
+
342
+ function labelForArgs(args) {
343
+ const text = typeof args[1] === 'string' ? args[1] : JSON.stringify(args[1]);
344
+ if (text.includes('MARKER_RENDER_A')) return 'A';
345
+ if (text.includes('MARKER_RENDER_B')) return 'B';
346
+ return 'unknown';
347
+ }
348
+
349
+ const realWriteJson = config.writeJson.bind(config);
350
+ const realWriteTextAtomic = config.writeTextAtomic.bind(config);
351
+
352
+ async function instrumented(real, args) {
353
+ const label = labelForArgs(args);
354
+ events.push({ label, phase: 'start' });
355
+ // Only the very first write of the whole test is gated — this is render
356
+ // A's first write, deep inside its own Promise.all of writes. Holding it
357
+ // open gives render B a real window to start writing if serialization
358
+ // were absent; every other write (A's remaining writes, all of B's
359
+ // writes) proceeds unblocked.
360
+ if (!gateArmed) {
361
+ gateArmed = true;
362
+ await gate;
363
+ }
364
+ const result = await real(...args);
365
+ events.push({ label, phase: 'end' });
366
+ return result;
367
+ }
368
+
369
+ const writeJsonSpy = vi.spyOn(config, 'writeJson').mockImplementation((...args) => instrumented(realWriteJson, args));
370
+ const writeTextAtomicSpy = vi
371
+ .spyOn(config, 'writeTextAtomic')
372
+ .mockImplementation((...args) => instrumented(realWriteTextAtomic, args));
373
+
374
+ try {
375
+ const renderA = fake.call('POST', '/admin/project-home/render', { body: { cwd, summary: summaryA, picks } });
376
+ const renderB = fake.call('POST', '/admin/project-home/render', { body: { cwd, summary: summaryB, picks } });
377
+
378
+ // Yield microtasks (no wall-clock wait) until render A's first write has
379
+ // registered, then release it.
380
+ for (let i = 0; i < 1000 && events.length === 0; i += 1) {
381
+ await Promise.resolve();
382
+ }
383
+ expect(events.length).toBeGreaterThan(0);
384
+ releaseGate();
385
+
386
+ const [resA, resB] = await Promise.all([renderA, renderB]);
387
+ expect(resA.status).toBe(200);
388
+ expect(resB.status).toBe(200);
389
+
390
+ const labels = events.map((e) => e.label);
391
+ const firstA = labels.indexOf('A');
392
+ const lastA = labels.lastIndexOf('A');
393
+ const firstB = labels.indexOf('B');
394
+ const lastB = labels.lastIndexOf('B');
395
+ expect(firstA).toBeGreaterThanOrEqual(0);
396
+ expect(firstB).toBeGreaterThanOrEqual(0);
397
+ // Serialized means one render's entire write span is fully outside the
398
+ // other's — never interleaved.
399
+ expect(lastA < firstB || lastB < firstA).toBe(true);
400
+
401
+ const dir = path.join(cwd, 'session-manager-operations', 'project-pages');
402
+ const outputFiles = fs.readdirSync(path.join(dir, 'output')).sort();
403
+ expect(outputFiles).toEqual([
404
+ 'architecture.html',
405
+ 'brief.html',
406
+ 'feature.html',
407
+ 'home.html',
408
+ 'manifest.json',
409
+ 'marketing.html',
410
+ ]);
411
+ } finally {
412
+ writeJsonSpy.mockRestore();
413
+ writeTextAtomicSpy.mockRestore();
414
+ }
415
+ });
416
+
417
+ // ─── status ────────────────────────────────────────────────────────────
418
+ test('status: a project with no output dir yet returns a well-formed all-absent response, not an error', async () => {
419
+ const cwd = await mkProjectCwd();
420
+ const fake = makeFakeAdminHttp();
421
+ registerAdminRoute(fake);
422
+
423
+ const { status, body } = await fake.call('GET', '/admin/project-home/status', {
424
+ query: new URLSearchParams({ cwd }),
425
+ });
426
+ expect(status).toBe(200);
427
+ expect(body.ok).toBe(true);
428
+ expect(body.summary.exists).toBe(false);
429
+ expect(body.picks.exists).toBe(false);
430
+ expect(body.manifest.exists).toBe(false);
431
+ expect(body.manifest.generatedAt).toBe(null);
432
+ for (const lens of ['home', 'marketing', 'feature', 'architecture', 'brief']) {
433
+ expect(body.output[lens].exists).toBe(false);
434
+ }
435
+ });
436
+
437
+ test('status: reflects what render() just wrote', async () => {
438
+ const cwd = await mkProjectCwd();
439
+ const fake = makeFakeAdminHttp();
440
+ registerAdminRoute(fake);
441
+
442
+ const contract = await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams({ cwd }) });
443
+ const picks = picksFromCatalog(contract.body.catalog);
444
+ await fake.call('POST', '/admin/project-home/render', { body: { cwd, summary: validSummary(), picks } });
445
+
446
+ const { body } = await fake.call('GET', '/admin/project-home/status', { query: new URLSearchParams({ cwd }) });
447
+ expect(body.summary.exists).toBe(true);
448
+ expect(body.picks.exists).toBe(true);
449
+ expect(body.manifest.exists).toBe(true);
450
+ expect(typeof body.manifest.generatedAt).toBe('string');
451
+ for (const lens of ['home', 'marketing', 'feature', 'architecture', 'brief']) {
452
+ expect(body.output[lens].exists).toBe(true);
453
+ expect(typeof body.output[lens].mtimeMs).toBe('number');
454
+ }
455
+ });
456
+
457
+ // ─── bilkoHost interaction (PRD 1093) ─────────────────────────────────
458
+ // bilkoHost.cjs reads opsPath(cwd, 'project-pages', 'output', 'marketing.html')
459
+ // (around line 107) and opsPath(cwd, 'project-pages', 'output', `${lens}.html`)
460
+ // for project-page-lens documents (around line 219). Prove render()'s real
461
+ // output satisfies both read paths, so the publish half of the pipeline
462
+ // doesn't silently break the next time this file's output layout changes.
463
+ test('render output lands exactly where bilkoHost.cjs expects it', async () => {
464
+ const cwd = await mkProjectCwd();
465
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ name: 'demo-app', version: '1.0.0' }));
466
+ const fake = makeFakeAdminHttp();
467
+ registerAdminRoute(fake);
468
+
469
+ const contract = await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams({ cwd }) });
470
+ const picks = picksFromCatalog(contract.body.catalog);
471
+ await fake.call('POST', '/admin/project-home/render', { body: { cwd, summary: validSummary(), picks } });
472
+
473
+ const bilkoHost = require('../bilkoHost.cjs');
474
+ const hostState = await bilkoHost.get({ cwd });
475
+ expect(hostState.hasMarketingPage).toBe(true);
476
+
477
+ await bilkoHost.addDocument({
478
+ cwd,
479
+ subpath: 'feature',
480
+ title: 'Feature',
481
+ source: { kind: 'project-page-lens', lens: 'feature' },
482
+ });
483
+ const result = await bilkoHost.prepareBundle({ cwd, slug: 'demo-app' });
484
+ expect(fs.existsSync(path.join(result.distPath, 'feature', 'index.html'))).toBe(true);
485
+ });
@@ -18,7 +18,16 @@ const fsp = require('node:fs/promises');
18
18
  const os = require('node:os');
19
19
  const path = require('node:path');
20
20
  const config = require('../config.cjs');
21
- const { get, DEFAULT_HOME_PATH, readDefaultHomeHtml } = require('../projectPages.cjs');
21
+ const {
22
+ get,
23
+ DEFAULT_HOME_PATH,
24
+ readDefaultHomeHtml,
25
+ attachWindow,
26
+ watchOutput,
27
+ unwatchOutput,
28
+ closeAllOutputWatchers,
29
+ _outputWatchers,
30
+ } = require('../projectPages.cjs');
22
31
 
23
32
  const tmpDirs = [];
24
33
  afterEach(async () => {
@@ -149,3 +158,66 @@ test('the shipped default is honest: no fabricated project-specific content, and
149
158
  expect(html).toContain('Generate My Project Home');
150
159
  expect(html.toLowerCase()).not.toContain('lorem ipsum');
151
160
  });
161
+
162
+ // ── ephemeral cwd: get() must fall back, never throw/reject ────────────────
163
+ test('get() with an ephemeral cwd (os.tmpdir() itself) returns the shipped default instead of throwing', async () => {
164
+ config.addAllowedRoot(os.tmpdir());
165
+ const result = await get({ cwd: os.tmpdir() });
166
+ expect(result.output).not.toBeNull();
167
+ expect(result.output.isDefault).toBe(true);
168
+ });
169
+
170
+ // ── watcher ──────────────────────────────────────────────────────────────
171
+ afterEach(() => {
172
+ closeAllOutputWatchers();
173
+ });
174
+
175
+ test('watchOutput() on an ephemeral cwd refuses instead of crashing', async () => {
176
+ config.addAllowedRoot(os.tmpdir());
177
+ const result = await watchOutput(os.tmpdir());
178
+ expect(result).toEqual({ ok: false, reason: 'ephemeral' });
179
+ expect(_outputWatchers.size).toBe(0);
180
+ });
181
+
182
+ test('watchOutput() pushes project-pages:changed when manifest.json appears, and unwatchOutput() tears it down', async () => {
183
+ const cwd = await mkTmpCwd();
184
+ const sent = [];
185
+ const fakeWindow = {
186
+ isDestroyed: () => false,
187
+ webContents: {
188
+ isDestroyed: () => false,
189
+ isCrashed: () => false,
190
+ send: (channel, payload) => sent.push({ channel, payload }),
191
+ },
192
+ };
193
+ attachWindow(fakeWindow);
194
+
195
+ const watchResult = await watchOutput(cwd);
196
+ expect(watchResult).toEqual({ ok: true });
197
+ expect(_outputWatchers.size).toBe(1);
198
+
199
+ writeOutput(cwd, {
200
+ home: 'HOME',
201
+ marketing: 'MARKETING',
202
+ feature: 'FEATURE',
203
+ architecture: 'ARCHITECTURE',
204
+ });
205
+
206
+ // Bounded poll for the debounced (awaitWriteFinish) push instead of a bare
207
+ // sleep — chokidar's stability window is 50ms, so this gives it generous
208
+ // headroom without hanging the suite on a broken watcher.
209
+ const deadline = Date.now() + 5000;
210
+ while (sent.length === 0 && Date.now() < deadline) {
211
+ await new Promise((resolve) => setTimeout(resolve, 100));
212
+ }
213
+
214
+ expect(sent.length).toBeGreaterThan(0);
215
+ const last = sent[sent.length - 1];
216
+ expect(last.channel).toBe('project-pages:changed');
217
+ expect(last.payload.cwd).toBe(fs.realpathSync(cwd));
218
+ expect(last.payload.output.isDefault).toBe(false);
219
+ expect(last.payload.output.home).toBe('HOME');
220
+
221
+ unwatchOutput(cwd);
222
+ expect(_outputWatchers.size).toBe(0);
223
+ }, 10_000);
@@ -173,6 +173,60 @@ test('writeRcaReport: skips when there is no run directory to write into', async
173
173
  expect(res.reason).toBe('no-run-dir');
174
174
  });
175
175
 
176
+ // ─── recovery actions ────────────────────────────────────────────────────────
177
+
178
+ test('RECOVERY_ACTIONS: every FAILURE_CLASSES member has an entry from the closed set', () => {
179
+ const CLOSED_SET = new Set(['archive', 'resume-and-commit', 'verify-and-close', 'investigate']);
180
+ for (const failureClass of Object.values(rcaReport.FAILURE_CLASSES)) {
181
+ expect(rcaReport.RECOVERY_ACTIONS).toHaveProperty(failureClass);
182
+ expect(CLOSED_SET.has(rcaReport.RECOVERY_ACTIONS[failureClass])).toBe(true);
183
+ }
184
+ });
185
+
186
+ test('recoveryActionFor: maps each known failure class to its expected action', () => {
187
+ const { FAILURE_CLASSES, recoveryActionFor } = rcaReport;
188
+ expect(recoveryActionFor(FAILURE_CLASSES.ALREADY_SHIPPED)).toBe('archive');
189
+ expect(recoveryActionFor(FAILURE_CLASSES.UNCOMMITTED)).toBe('resume-and-commit');
190
+ expect(recoveryActionFor(FAILURE_CLASSES.NO_SENTINEL)).toBe('verify-and-close');
191
+ expect(recoveryActionFor(FAILURE_CLASSES.SELF_QUEUE)).toBe('investigate');
192
+ expect(recoveryActionFor(FAILURE_CLASSES.STUCK_LOOP)).toBe('investigate');
193
+ expect(recoveryActionFor(FAILURE_CLASSES.POST_AC_OVERRUN)).toBe('investigate');
194
+ expect(recoveryActionFor(FAILURE_CLASSES.TRANSCRIPT_ERRORS)).toBe('investigate');
195
+ expect(recoveryActionFor(FAILURE_CLASSES.UNKNOWN)).toBe('investigate');
196
+ });
197
+
198
+ test('recoveryActionFor: defaults to investigate for an unrecognised class', () => {
199
+ expect(rcaReport.recoveryActionFor('totally-made-up-class')).toBe('investigate');
200
+ expect(rcaReport.recoveryActionFor(undefined)).toBe('investigate');
201
+ });
202
+
203
+ test('writeRcaReport: markdown carries a machine-readable recovery-action line matching recoveryActionFor, for each failure class', async () => {
204
+ const cwd = makeProject();
205
+ const runDir = path.join(tmpHome, 'run1');
206
+ writeRun(runDir, 'testslug');
207
+ writePrd(cwd, 'testslug');
208
+
209
+ const cases = [
210
+ { verdict: 'pass_no_commit', logTail: 'The work was already implemented in a prior commit.', expectedClass: rcaReport.FAILURE_CLASSES.ALREADY_SHIPPED },
211
+ { verdict: 'transcript_errors', logTail: 'Launching skill: session-manager-dev:develop', expectedClass: rcaReport.FAILURE_CLASSES.SELF_QUEUE },
212
+ { verdict: 'transcript_errors', logTail: 'while true; do sleep 1; done', expectedClass: rcaReport.FAILURE_CLASSES.STUCK_LOOP },
213
+ { verdict: 'uncommitted_changes', logTail: 'plain log tail', expectedClass: rcaReport.FAILURE_CLASSES.UNCOMMITTED },
214
+ { verdict: 'no_verdict_sentinel', logTail: 'plain log tail', expectedClass: rcaReport.FAILURE_CLASSES.NO_SENTINEL },
215
+ { verdict: 'transcript_errors', logTail: 'plain log tail', expectedClass: rcaReport.FAILURE_CLASSES.TRANSCRIPT_ERRORS },
216
+ { verdict: 'some_other_verdict', logTail: 'plain log tail', expectedClass: rcaReport.FAILURE_CLASSES.UNKNOWN },
217
+ ];
218
+
219
+ for (const { verdict, logTail, expectedClass } of cases) {
220
+ writeRun(runDir, 'testslug', { logLines: logTail.split('\n') });
221
+ const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd }), runDir, verdict });
222
+ expect(res.failureClass).toBe(expectedClass);
223
+ const expectedAction = rcaReport.recoveryActionFor(expectedClass);
224
+ expect(res.recoveryAction).toBe(expectedAction);
225
+ const body = fs.readFileSync(res.path, 'utf8');
226
+ expect(body).toContain(`recovery-action: ${expectedAction}`);
227
+ }
228
+ });
229
+
176
230
  test('writeRcaReport: SM_RCA_DISABLE=1 skips without writing', async () => {
177
231
  process.env.SM_RCA_DISABLE = '1';
178
232
  const cwd = makeProject();