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,194 @@
1
+ /**
2
+ * scheduler-worktree-cap-defer.test.cjs — PRD 1112: a job that cannot get
3
+ * worktree isolation because the concurrency cap is currently exhausted must
4
+ * be DEFERRED (left 'pending', retried on the next dispatch pass), never
5
+ * spawned in a shared working tree the way every OTHER worktree.ok===false
6
+ * reason (e.g. "not a git repository") still falls back to running in place.
7
+ *
8
+ * Drives spawnJob end-to-end against a real git fixture repo, forcing the
9
+ * cap via SM_JOB_WORKTREE_MAX=1 plus a real occupying worktree (so the cap
10
+ * check itself, not a mock, is what defers the job).
11
+ *
12
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-worktree-cap-defer.test.cjs
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+ const { execFileSync } = require('node:child_process');
22
+
23
+ let tmpHome;
24
+ let originalHome;
25
+ let originalMax;
26
+ let spawnJob;
27
+ let gitWorktree;
28
+
29
+ function git(args, cwd) {
30
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
31
+ }
32
+
33
+ function initRepo(dir) {
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ git(['init', '-q'], dir);
36
+ git(['config', 'user.email', 'test@example.com'], dir);
37
+ git(['config', 'user.name', 'Test'], dir);
38
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
39
+ git(['add', '-A'], dir);
40
+ git(['commit', '-q', '-m', 'initial'], dir);
41
+ }
42
+
43
+ // See scheduler-quiet-machine-lease.test.cjs's identical helper: readQueue()'s
44
+ // per-project discovery walks ~/.claude/projects/*/*.jsonl transcripts for a
45
+ // `cwd` field to find known project queue.json shards.
46
+ function registerActiveProject(cwd) {
47
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
48
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
49
+ fs.mkdirSync(slugDir, { recursive: true });
50
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
51
+ }
52
+
53
+ function writeProjectQueue(cwd, jobs) {
54
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
55
+ fs.mkdirSync(stateDir, { recursive: true });
56
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
57
+ return path.join(stateDir, 'queue.json');
58
+ }
59
+
60
+ // Stub `claude` binary: writes a marker file into its OWN process.cwd() so a
61
+ // test can prove whether — and where — it was actually spawned, then emits a
62
+ // clean stream-json success result.
63
+ function writeClaudeStub() {
64
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-capdefer-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
65
+ const body = `
66
+ const fs = require('fs');
67
+ const path = require('path');
68
+ const { execFileSync } = require('child_process');
69
+ fs.writeFileSync(path.join(process.cwd(), 'ran-here.marker'), 'yes', 'utf8');
70
+ // Land a real commit + finish-protocol sentinel when possible, so a run
71
+ // that actually reaches the finish protocol lands 'completed' — a
72
+ // best-effort no-op when the cwd isn't a git repo (e.g. the non-git
73
+ // fallback case), where the run parks in needs_review instead, which is
74
+ // fine — this test only cares whether it ran in place, not its verdict.
75
+ try {
76
+ execFileSync('git', ['add', '-A'], { cwd: process.cwd() });
77
+ execFileSync('git', ['commit', '-q', '-m', 'stub commit'], { cwd: process.cwd() });
78
+ } catch { /* not a git repo — best-effort only */ }
79
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok\\nSCHEDULER_VERDICT: PASS' }) + '\\n');
80
+ process.exit(0);
81
+ `;
82
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
83
+ return stubPath;
84
+ }
85
+
86
+ beforeAll(() => {
87
+ originalHome = process.env.HOME;
88
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-'));
89
+ process.env.HOME = tmpHome;
90
+ originalMax = process.env.SM_JOB_WORKTREE_MAX;
91
+ ({ spawnJob } = require('../scheduler.cjs'));
92
+ gitWorktree = require('../lib/gitWorktree.cjs');
93
+ });
94
+
95
+ afterAll(() => {
96
+ process.env.HOME = originalHome;
97
+ if (originalMax === undefined) delete process.env.SM_JOB_WORKTREE_MAX;
98
+ else process.env.SM_JOB_WORKTREE_MAX = originalMax;
99
+ fs.rmSync(tmpHome, { recursive: true, force: true });
100
+ });
101
+
102
+ afterEach(() => {
103
+ delete process.env.SM_CLAUDE_BIN;
104
+ delete process.env.SM_JOB_WORKTREE_MAX;
105
+ gitWorktree._resetActiveWorktreeCountForTests('job', 0);
106
+ });
107
+
108
+ test('a job held behind the worktree cap stays pending with a heldReason, is never spawned in place, and dispatches once the cap frees up', async () => {
109
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-project-'));
110
+ initRepo(projectCwd);
111
+ registerActiveProject(projectCwd);
112
+
113
+ const slug = `1112-test-held-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
114
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
115
+ fs.mkdirSync(prdsDir, { recursive: true });
116
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Held behind the worktree cap.', 'utf8');
117
+ const queuePath = writeProjectQueue(projectCwd, [{ slug, status: 'pending', cwd: projectCwd }]);
118
+
119
+ process.env.SM_JOB_WORKTREE_MAX = '1';
120
+ process.env.SM_CLAUDE_BIN = writeClaudeStub();
121
+
122
+ // Occupy the single worktree slot with a REAL checkout, so the cap check
123
+ // spawnJob relies on is exercising the actual gitWorktree plumbing, not a
124
+ // mock.
125
+ const occupier = await gitWorktree.createJobWorktree({ cwd: projectCwd, slug: 'occupier' });
126
+ expect(occupier.ok).toBe(true);
127
+
128
+ const runId = `run-${slug}`;
129
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
130
+ fs.mkdirSync(runDir, { recursive: true });
131
+
132
+ try {
133
+ await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
134
+
135
+ // Held: still pending, never spawned, heldReason visible on the row.
136
+ let jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
137
+ let row = jobs.find((j) => j.slug === slug);
138
+ expect(row.status).toBe('pending');
139
+ expect(row.heldReason).toMatch(/worktree cap reached/);
140
+ expect(fs.existsSync(path.join(projectCwd, 'ran-here.marker'))).toBe(false);
141
+ expect(fs.existsSync(path.join(occupier.dir, 'ran-here.marker'))).toBe(false);
142
+
143
+ // Free the cap (mirrors cleanupJobWorktree releasing the counter), then
144
+ // retry — the held job dispatches on this next pass, exactly like a
145
+ // sessionSlots-blocked job retries once a slot frees up.
146
+ await gitWorktree.cleanupJobWorktree({ cwd: projectCwd, dir: occupier.dir, branch: occupier.branch });
147
+
148
+ await spawnJob({ slug, cwd: projectCwd }, `${runId}-2`, runDir, projectCwd);
149
+
150
+ jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
151
+ row = jobs.find((j) => j.slug === slug);
152
+ expect(row.status).toBe('completed');
153
+ expect(row.heldReason).toBeUndefined();
154
+ } finally {
155
+ fs.rmSync(projectCwd, { recursive: true, force: true });
156
+ fs.rmSync(runDir, { recursive: true, force: true });
157
+ }
158
+ }, 30_000);
159
+
160
+ test('a non-git cwd still falls back to running in place (unchanged behaviour — only cap-reached is a deferral)', async () => {
161
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-nongit-'));
162
+ fs.mkdirSync(projectCwd, { recursive: true }); // deliberately NOT a git repo
163
+ registerActiveProject(projectCwd);
164
+
165
+ const slug = `1112-test-nongit-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
166
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
167
+ fs.mkdirSync(prdsDir, { recursive: true });
168
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Non-git cwd, must run in place.', 'utf8');
169
+ const queuePath = writeProjectQueue(projectCwd, [{ slug, status: 'pending', cwd: projectCwd }]);
170
+
171
+ process.env.SM_CLAUDE_BIN = writeClaudeStub();
172
+
173
+ const runId = `run-${slug}`;
174
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
175
+ fs.mkdirSync(runDir, { recursive: true });
176
+
177
+ try {
178
+ await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
179
+
180
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
181
+ const row = jobs.find((j) => j.slug === slug);
182
+ // Not held/deferred — it actually ran (a non-git cwd can't commit, so it
183
+ // parks in needs_review rather than completed; this test only cares that
184
+ // it was dispatched in place, not its exact verdict).
185
+ expect(row.status).not.toBe('pending');
186
+ expect(row.heldReason).toBeUndefined();
187
+ expect(row.worktreeFallbackReason).toMatch(/not a git repository/);
188
+ // Ran IN PLACE — the marker landed directly in projectCwd, not a worktree.
189
+ expect(fs.existsSync(path.join(projectCwd, 'ran-here.marker'))).toBe(true);
190
+ } finally {
191
+ fs.rmSync(projectCwd, { recursive: true, force: true });
192
+ fs.rmSync(runDir, { recursive: true, force: true });
193
+ }
194
+ }, 30_000);
@@ -54,22 +54,20 @@ function silentLogger() {
54
54
  return { log: () => {}, warn: () => {} };
55
55
  }
56
56
 
57
- test('fresh seed writes both persona files', async () => {
58
- await seedAgentPersonas({ logger: silentLogger() });
57
+ const ALL_PERSONAS = ['architect', 'dev-lead', 'project-home-builder'];
59
58
 
60
- const architect = path.join(agentsDir(), 'architect.md');
61
- const devLead = path.join(agentsDir(), 'dev-lead.md');
62
- expect(fs.existsSync(architect)).toBe(true);
63
- expect(fs.existsSync(devLead)).toBe(true);
59
+ test('fresh seed writes every persona file', async () => {
60
+ await seedAgentPersonas({ logger: silentLogger() });
64
61
 
65
- const bundledArchitect = fs.readFileSync(
66
- path.join(__dirname, '..', '..', 'seed', 'agents', 'architect.md'),
67
- 'utf8'
68
- );
69
- expect(fs.readFileSync(architect, 'utf8')).toBe(bundledArchitect);
62
+ for (const name of ALL_PERSONAS) {
63
+ const dest = path.join(agentsDir(), `${name}.md`);
64
+ expect(fs.existsSync(dest)).toBe(true);
65
+ const bundled = fs.readFileSync(path.join(__dirname, '..', '..', 'seed', 'agents', `${name}.md`), 'utf8');
66
+ expect(fs.readFileSync(dest, 'utf8')).toBe(bundled);
67
+ }
70
68
 
71
69
  const marker = JSON.parse(fs.readFileSync(markerPath(), 'utf8'));
72
- expect(marker.done).toBe(true);
70
+ expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
73
71
  });
74
72
 
75
73
  test('a pre-existing persona file is left byte-identical', async () => {
@@ -81,20 +79,83 @@ test('a pre-existing persona file is left byte-identical', async () => {
81
79
  await seedAgentPersonas({ logger: silentLogger() });
82
80
 
83
81
  expect(fs.readFileSync(architect, 'utf8')).toBe(customContent);
84
- // dev-lead had no pre-existing file, so it should still be seeded.
82
+ // dev-lead and project-home-builder had no pre-existing file, so they should still be seeded.
85
83
  expect(fs.existsSync(path.join(agentsDir(), 'dev-lead.md'))).toBe(true);
84
+ expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
86
85
  });
87
86
 
88
- test('done:true marker short-circuits without touching agents dir', async () => {
87
+ test('a machine already fully seeded under the old done:true marker only picks up a newly added persona', async () => {
89
88
  const mPath = markerPath();
90
89
  fs.mkdirSync(path.dirname(mPath), { recursive: true });
90
+ // Legacy marker shape from before the seeded-set change — no `seeded` array.
91
91
  fs.writeFileSync(mPath, JSON.stringify({ done: true, attempts: 1, ts: '2026-01-01T00:00:00.000Z' }));
92
+ fs.mkdirSync(agentsDir(), { recursive: true });
93
+ fs.writeFileSync(path.join(agentsDir(), 'architect.md'), 'old architect content\n');
94
+ fs.writeFileSync(path.join(agentsDir(), 'dev-lead.md'), 'old dev-lead content\n');
95
+
96
+ await seedAgentPersonas({ logger: silentLogger() });
97
+
98
+ // Pre-existing files from the old run are untouched...
99
+ expect(fs.readFileSync(path.join(agentsDir(), 'architect.md'), 'utf8')).toBe('old architect content\n');
100
+ expect(fs.readFileSync(path.join(agentsDir(), 'dev-lead.md'), 'utf8')).toBe('old dev-lead content\n');
101
+ // ...but the newly added persona is delivered.
102
+ expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
103
+
104
+ const marker = JSON.parse(fs.readFileSync(mPath, 'utf8'));
105
+ expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
106
+ });
107
+
108
+ test('a machine already seeded under the new seeded-set marker picks up a newly added persona', async () => {
109
+ const mPath = markerPath();
110
+ fs.mkdirSync(path.dirname(mPath), { recursive: true });
111
+ fs.writeFileSync(mPath, JSON.stringify({ seeded: ['architect', 'dev-lead'], attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
112
+
113
+ await seedAgentPersonas({ logger: silentLogger() });
114
+
115
+ expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
116
+ const marker = JSON.parse(fs.readFileSync(mPath, 'utf8'));
117
+ expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
118
+ });
119
+
120
+ test('a machine with every known persona already in the seeded set short-circuits without touching agents dir', async () => {
121
+ const mPath = markerPath();
122
+ fs.mkdirSync(path.dirname(mPath), { recursive: true });
123
+ fs.writeFileSync(mPath, JSON.stringify({ seeded: ALL_PERSONAS, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
92
124
 
93
125
  await seedAgentPersonas({ logger: silentLogger() });
94
126
 
95
127
  expect(fs.existsSync(agentsDir())).toBe(false);
96
128
  });
97
129
 
130
+ test('a corrupt/unparseable marker file still seeds correctly', async () => {
131
+ const mPath = markerPath();
132
+ fs.mkdirSync(path.dirname(mPath), { recursive: true });
133
+ fs.writeFileSync(mPath, '{ not valid json');
134
+
135
+ await seedAgentPersonas({ logger: silentLogger() });
136
+
137
+ for (const name of ALL_PERSONAS) {
138
+ expect(fs.existsSync(path.join(agentsDir(), `${name}.md`))).toBe(true);
139
+ }
140
+ });
141
+
142
+ test('the bundled project-home-builder persona contains no session-manager-repo paths', () => {
143
+ const content = fs.readFileSync(
144
+ path.join(__dirname, '..', '..', 'seed', 'agents', 'project-home-builder.md'),
145
+ 'utf8'
146
+ );
147
+ const forbidden = [
148
+ 'session-manager-operations/architecture/',
149
+ '.claude/agents/',
150
+ 'session-manager-operations/design-mocks/',
151
+ 'scripts/',
152
+ 'npm run build:project-pages',
153
+ ];
154
+ for (const substr of forbidden) {
155
+ expect(content).not.toContain(substr);
156
+ }
157
+ });
158
+
98
159
  test('SM_SEED_AGENT_PERSONAS_DISABLE=1 short-circuits', async () => {
99
160
  process.env.SM_SEED_AGENT_PERSONAS_DISABLE = '1';
100
161
 
@@ -148,6 +148,9 @@ function validateWrite(realAbs) {
148
148
  // (PRD 825; scheduler.cjs remote.writePrd)
149
149
  // project-brief projectBrief.cjs's brief.json (PRD 837)
150
150
  // bilko-host bilkoHost.cjs's bundle prep
151
+ // project-pages projectHomeAdminRoutes.cjs's app-side render path
152
+ // (summary.json/picks.json/output/*; PRD:
153
+ // project-home-admin-routes)
151
154
  // Paths come from opsOwnership.opsPath (PRD 1082) — the one ops-root
152
155
  // resolver — which THROWS for an ephemeral root (a linked worktree that
153
156
  // pty.cjs registered as an allowed root for its spawn cwd, or
@@ -155,7 +158,7 @@ function validateWrite(realAbs) {
155
158
  // never a writable destination, so its exemptions simply do not exist.
156
159
  let opsSubs;
157
160
  try {
158
- opsSubs = ['prompt-sessions', 'scheduler', 'project-brief', 'bilko-host'].map((ns) => opsPath(realRoot, ns));
161
+ opsSubs = ['prompt-sessions', 'scheduler', 'project-brief', 'bilko-host', 'project-pages'].map((ns) => opsPath(realRoot, ns));
159
162
  } catch {
160
163
  opsSubs = [];
161
164
  }
@@ -27,6 +27,7 @@ const scheduler = require('./scheduler.cjs');
27
27
  const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
28
28
  const prdCreate = require('./lib/prdCreate.cjs');
29
29
  const prdAdminRoutes = require('./lib/prdAdminRoutes.cjs');
30
+ const projectHomeAdminRoutes = require('./lib/projectHomeAdminRoutes.cjs');
30
31
  const { appendAuditEvent } = require('./lib/auditLog.cjs');
31
32
  const chatRunner = require('./chatRunner.cjs');
32
33
  const promptSessionEvents = require('./promptSessionEvents.cjs');
@@ -45,6 +46,7 @@ const adminHttp = createAdminHttp();
45
46
  scheduler.registerAdminRoutes(adminHttp);
46
47
  prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
47
48
  prdAdminRoutes.registerAdminRoute(adminHttp, scheduler.remote);
49
+ projectHomeAdminRoutes.registerAdminRoute(adminHttp);
48
50
  chatRunner.registerAdminRoute(adminHttp);
49
51
  // Project-to-project feedback conduit — the app is the only process holding
50
52
  // several projects' operations roots open, so it performs the cross-folder
@@ -69,7 +71,7 @@ const schedulerConfig = require('./lib/schedulerConfig.cjs');
69
71
  const memoryTool = require('./memoryTool.cjs');
70
72
  const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
71
73
  const { registerProjectBriefIpc } = require('./projectBrief.cjs');
72
- const { registerProjectPagesIpc } = require('./projectPages.cjs');
74
+ const { registerProjectPagesIpc, attachWindow: attachProjectPagesWindow, closeAllOutputWatchers: closeAllProjectPagesWatchers } = require('./projectPages.cjs');
73
75
  const { registerBilkoHostIpc } = require('./bilkoHost.cjs');
74
76
  const promptSessionTranscript = require('./promptSessionTranscript.cjs');
75
77
  const { computeEpicDelegationStats } = require('./lib/epicDelegationStats.cjs');
@@ -300,6 +302,7 @@ function relaunchViaNpx() {
300
302
  async function rebootApp() {
301
303
  ptyManager.killAll();
302
304
  configMgr.closeAllWatchers();
305
+ closeAllProjectPagesWatchers();
303
306
  transcripts.closeAll();
304
307
  watchers.manager.killAll();
305
308
 
@@ -326,6 +329,7 @@ async function rebootApp() {
326
329
  chatRunner.attachWindow(mainWindow);
327
330
  promptSessionEvents.attachWindow(mainWindow);
328
331
  attachDocEditWindow(mainWindow);
332
+ attachProjectPagesWindow(mainWindow);
329
333
  rebooting = false;
330
334
  return;
331
335
  }
@@ -1168,6 +1172,7 @@ app.whenReady().then(async () => {
1168
1172
  chatRunner.attachWindow(mainWindow);
1169
1173
  promptSessionEvents.attachWindow(mainWindow);
1170
1174
  attachDocEditWindow(mainWindow);
1175
+ attachProjectPagesWindow(mainWindow);
1171
1176
  // Scheduler ownership (PRD 834): exactly one instance machine-wide runs
1172
1177
  // scheduler mutation (boot reconciliation, ticking, sweep, supervisor) and
1173
1178
  // the admin API. Electron's own single-instance lock is keyed by userData
@@ -1344,6 +1349,7 @@ app.on('window-all-closed', () => {
1344
1349
  if (rebooting) return; // new window is about to be created
1345
1350
  ptyManager.killAll();
1346
1351
  configMgr.closeAllWatchers();
1352
+ closeAllProjectPagesWatchers();
1347
1353
  transcripts.closeAll();
1348
1354
  watchers.manager.killAll();
1349
1355
  if (process.platform !== 'darwin') app.quit();
@@ -1358,6 +1364,7 @@ app.on('before-quit', () => {
1358
1364
  supervisor.stopSupervisor();
1359
1365
  ptyManager.killAll();
1360
1366
  configMgr.closeAllWatchers();
1367
+ closeAllProjectPagesWatchers();
1361
1368
  transcripts.closeAll();
1362
1369
  watchers.manager.killAll();
1363
1370
  adminHttp.stop().catch(() => {});
@@ -10,6 +10,7 @@ const os = require('node:os');
10
10
  const path = require('node:path');
11
11
  const { PromptSessionSchema, EpicTagSchema, EpicSourceSchema, EpicIntakeSectionSchema } = require('./lib/promptSessionSchema.cjs');
12
12
  const { PrdWorkTypeSchema } = require('./lib/workTypeLibrary.cjs');
13
+ const { PERSONA_NAME_RE } = require('./agentLibrary.cjs');
13
14
  const { AgentPersonaSaveSchema } = require('./lib/agentPersonaSchema.cjs');
14
15
 
15
16
  // ──────────────────────────────────────────── PTY
@@ -224,6 +225,12 @@ const promptSessionsMergeToMain = z.object({
224
225
  epicId: z.string().regex(PROMPT_SESSION_ID_RE),
225
226
  branch: z.string().regex(/^sm-epic\/[A-Za-z0-9_-]{1,64}$/),
226
227
  dir: z.string().min(1).max(4096),
228
+ // Base-tree WIP paths carried into this worktree at creation time
229
+ // (gitWorktree.cjs's createWorktree, PRD 1094) — threaded through so
230
+ // integrateEpicBranch can apply the same carried-wip-only skip the job
231
+ // path already gets. Absent on a worktree created before this field
232
+ // existed, or one whose base tree was clean at creation.
233
+ carriedPaths: z.array(z.string().min(1).max(4096)).max(2000).optional(),
227
234
  });
228
235
 
229
236
  // Renderer entry points for the per-project "disable Epic worktree
@@ -299,6 +306,13 @@ const PRD_CREATE_SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
299
306
  // frontmatter-shaped lines into the body. Block newlines at the boundary
300
307
  // instead of trying to escape them later.
301
308
  const NO_NEWLINE_RE = /^[^\r\n]*$/;
309
+ // WHO executes a PRD — a persona name (agentLibrary.cjs's PERSONA_NAME_RE),
310
+ // distinct from `tag` (the WORK TYPE, above). Not a closed enum here: the
311
+ // Agent Library is user-editable, so the actual FK check (does this name
312
+ // resolve to a readable persona file) happens at write time in
313
+ // prdCreate.cjs's createPrd via prdAgentType.cjs's assertAgentTypeWritable —
314
+ // this schema only enforces the name SHAPE.
315
+ const PrdAgentTypeSchema = z.string().regex(PERSONA_NAME_RE, 'agent name must be lowercase, hyphenated (e.g. "dev-lead")');
302
316
  const schedulerCreatePrd = z.object({
303
317
  title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
304
318
  // Optional (PRD: worktree-cwd Epic-lookup hazard) — when omitted, prdCreate.cjs's
@@ -352,6 +366,18 @@ const schedulerCreatePrd = z.object({
352
366
  // excluded here for the same reason adminPrdFrontmatterPatch excludes it
353
367
  // below — a discussion-tagged Epic never reaches PRD authoring.
354
368
  tag: PrdWorkTypeSchema.optional(),
369
+ // WHO executes this PRD (persona name, `~/.claude/agents/<name>.md`) —
370
+ // independent of `tag` above. Optional: prdCreate.cjs's buildPrdBody fills
371
+ // in DEFAULT_PRD_AGENT_TYPE ('dev-lead') when omitted.
372
+ agentType: PrdAgentTypeSchema.optional(),
373
+ // Opt-in exclusive-lease flag (PRD 1107): the scheduler dispatches this PRD
374
+ // only once zero other jobs are running machine-wide, and holds every other
375
+ // job off the whole session-slot pool while it runs — for a PRD whose
376
+ // acceptance criteria are wall-clock/timing measurements (frame time,
377
+ // performance fences) that CPU contention from sibling jobs would otherwise
378
+ // invalidate. See prdCreate.cjs's buildPrdBody and schedulerBatch.cjs's
379
+ // pickNextBatch.
380
+ quietMachine: z.boolean().optional(),
355
381
  });
356
382
 
357
383
  // Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
@@ -418,12 +444,17 @@ const adminPrdFrontmatterPatch = z.object({
418
444
  // discussion-tagged Epic never reaches PRD authoring) — see
419
445
  // workTypeLibrary.cjs's PrdWorkTypeSchema.
420
446
  tag: PrdWorkTypeSchema.optional(),
447
+ // WHO executes this PRD — patchable after creation too, same as `tag`.
448
+ agentType: PrdAgentTypeSchema.optional(),
421
449
  // Provenance stamp (PRD-authoring lockdown). Free-text rather than an enum
422
450
  // — 'scheduler-api' (create-time) and 'legacy-adopted' (migration/manual
423
451
  // adopt) are the two values this codebase writes today, but the field
424
452
  // itself is descriptive, not a closed set.
425
453
  createdVia: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
426
454
  issuedAt: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
455
+ // Opt-in exclusive-lease flag (PRD 1107) — patchable after creation too,
456
+ // same as every other recognized key here.
457
+ quietMachine: z.boolean().optional(),
427
458
  });
428
459
 
429
460
  const adminUpdatePrd = z.object({
@@ -582,6 +613,23 @@ const projectPagesCwd = z.object({
582
613
  cwd: z.string().min(1).max(4096),
583
614
  }).strict();
584
615
 
616
+ // ──────────────────────────────────────────── Project Home admin routes
617
+ // (PRD: project-home-admin-routes). Same validation split as
618
+ // projectPagesCwd above: real path validation is projectRootResolve.cjs +
619
+ // config.cjs's validatePath at first fs access.
620
+ const projectHomeAdminCwdQuery = z.object({
621
+ cwd: z.string().min(1).max(4096),
622
+ }).strict();
623
+ const projectHomeAdminValidateSummaryBody = z.object({
624
+ cwd: z.string().min(1).max(4096),
625
+ summary: z.unknown(),
626
+ }).strict();
627
+ const projectHomeAdminRenderBody = z.object({
628
+ cwd: z.string().min(1).max(4096),
629
+ summary: z.unknown(),
630
+ picks: z.unknown(),
631
+ }).strict();
632
+
585
633
  // ──────────────────────────────────────────── Host on Bilko.run
586
634
  // Same validation split: real path validation is config.cjs's validatePath
587
635
  // at first fs access in bilkoHost.cjs — these schemas only bound wire shape.
@@ -990,6 +1038,9 @@ module.exports = {
990
1038
  memoryStale,
991
1039
  projectBriefCwd,
992
1040
  projectPagesCwd,
1041
+ projectHomeAdminCwdQuery,
1042
+ projectHomeAdminValidateSummaryBody,
1043
+ projectHomeAdminRenderBody,
993
1044
  bilkoHostCwd,
994
1045
  bilkoHostPrepareBundle,
995
1046
  bilkoHostAddDocument,
@@ -61,3 +61,81 @@ describe('childWithLog post-exit group sweep (PRD 1065)', () => {
61
61
  15000,
62
62
  );
63
63
  });
64
+
65
+ // PRD 1110 — the sweep above kills silently; onExit must also report what it
66
+ // swept so a project whose jobs leak repeatedly can see it instead of
67
+ // inferring it from ps.
68
+ describe('childWithLog onExit leakedDescendants reporting (PRD 1110)', () => {
69
+ it.skipIf(process.platform !== 'linux')(
70
+ 'reports the same backgrounded grandchild the sweep reaped',
71
+ async () => {
72
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-child-leak-'));
73
+ const logPath = path.join(dir, 'job.log');
74
+ const pidFile = path.join(dir, 'grandchild.pid');
75
+ const { fd, safeLog, closeFd } = openLog(logPath);
76
+
77
+ const exitInfo = await new Promise((resolve) => {
78
+ withChildAndLog({
79
+ fd,
80
+ logPath,
81
+ safeLog,
82
+ closeFd,
83
+ spawn: {
84
+ command: 'sh',
85
+ args: [
86
+ '-c',
87
+ `sleep 30 & echo $! > ${pidFile}; exec sleep 0.2`,
88
+ ],
89
+ options: { detached: true },
90
+ },
91
+ onExit: (info) => resolve(info),
92
+ });
93
+ });
94
+
95
+ for (let i = 0; i < 20 && !fs.existsSync(pidFile); i++) {
96
+ await new Promise((r) => setTimeout(r, 50));
97
+ }
98
+ const grandchildPid = Number(fs.readFileSync(pidFile, 'utf8').trim());
99
+
100
+ expect(Array.isArray(exitInfo.leakedDescendants)).toBe(true);
101
+ expect(exitInfo.leakedDescendants.length).toBeGreaterThan(0);
102
+ const found = exitInfo.leakedDescendants.find((p) => p.pid === grandchildPid);
103
+ expect(found).toBeTruthy();
104
+ expect(found.comm).toBeTruthy();
105
+ expect(typeof found.pcpu).toBe('number');
106
+ expect(typeof found.etimes).toBe('number');
107
+
108
+ // Let the sweep's SIGTERM->SIGKILL cascade finish so the grandchild
109
+ // doesn't leak past this test.
110
+ await new Promise((r) => setTimeout(r, POST_EXIT_GROUP_SWEEP_GRACE_MS + 1000));
111
+ },
112
+ 15000,
113
+ );
114
+
115
+ it.skipIf(process.platform !== 'linux')(
116
+ 'reports [] for a clean job that leaked nothing',
117
+ async () => {
118
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-child-clean-'));
119
+ const logPath = path.join(dir, 'job.log');
120
+ const { fd, safeLog, closeFd } = openLog(logPath);
121
+
122
+ const exitInfo = await new Promise((resolve) => {
123
+ withChildAndLog({
124
+ fd,
125
+ logPath,
126
+ safeLog,
127
+ closeFd,
128
+ spawn: {
129
+ command: 'sh',
130
+ args: ['-c', 'exit 0'],
131
+ options: { detached: true },
132
+ },
133
+ onExit: (info) => resolve(info),
134
+ });
135
+ });
136
+
137
+ expect(exitInfo.leakedDescendants).toEqual([]);
138
+ },
139
+ 15000,
140
+ );
141
+ });