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,241 @@
1
+ /**
2
+ * scheduler-looks-done.test.cjs — PRD 1102.
3
+ *
4
+ * reverifyNeedsReview() used to have two hard limits that left real, finished
5
+ * work parked red forever: (1) isRescanCandidate required status ===
6
+ * 'needs_review' — a `failed` row was never revisited at all; (2)
7
+ * committedInWindow only looked inside [startedAt, finishedAt+60s], missing a
8
+ * commit that landed later (a retry, a sibling run, a human). This tests the
9
+ * widened pass: a `failed` row whose failure is unverified-shaped (no result
10
+ * event at all — classifyRunOutcome === 'no_result') is now a rescan
11
+ * candidate, and any candidate with a POST-window commit touching its own
12
+ * declared paths gets annotated `looksDone` for a human — never auto-
13
+ * completed. A `failed` row with a genuine result event (a real red gate) must
14
+ * stay excluded, and the pre-existing needs_review heal path must be
15
+ * unaffected.
16
+ *
17
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — see
18
+ * scheduler-reap-dead-running-jobs.test.cjs's comment for why (every path
19
+ * this code touches is baked into a top-level const from os.homedir() at
20
+ * require time).
21
+ *
22
+ * Run: timeout 180 npx vitest run src/main/__tests__/scheduler-looks-done.test.cjs
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const assert = require('node:assert/strict');
28
+ const fs = require('node:fs');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+ const { execFileSync } = require('node:child_process');
32
+
33
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'looks-done-test-'));
34
+ process.env.HOME = tmpHome;
35
+
36
+ const { reverifyNeedsReview, computeLooksDone } = require('../scheduler.cjs');
37
+ const { resolvePrdWriteDir } = require('../lib/prdLocations.cjs');
38
+ const { bustCwdCache } = require('../lib/queueStore.cjs');
39
+
40
+ function git(args, cwd) {
41
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
42
+ }
43
+
44
+ function initRepo(dir) {
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ git(['init', '-q'], dir);
47
+ git(['config', 'user.email', 'test@example.com'], dir);
48
+ git(['config', 'user.name', 'Test'], dir);
49
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
50
+ git(['add', '-A'], dir);
51
+ git(['commit', '-q', '-m', 'initial'], dir);
52
+ }
53
+
54
+ function commitFile(dir, relPath, content, message) {
55
+ const abs = path.join(dir, relPath);
56
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
57
+ fs.writeFileSync(abs, content);
58
+ git(['add', relPath], dir);
59
+ git(['commit', '-q', '-m', message], dir);
60
+ }
61
+
62
+ function registerActiveProject(cwd, slug) {
63
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
64
+ const slugDir = path.join(projectsDir, slug);
65
+ fs.mkdirSync(slugDir, { recursive: true });
66
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
67
+ bustCwdCache();
68
+ }
69
+
70
+ function writeProjectQueue(cwd, jobs) {
71
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
72
+ fs.mkdirSync(stateDir, { recursive: true });
73
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
74
+ return path.join(stateDir, 'queue.json');
75
+ }
76
+
77
+ function writeRunLog(runId, slug, lines) {
78
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
79
+ fs.mkdirSync(runDir, { recursive: true });
80
+ fs.writeFileSync(path.join(runDir, `${slug}.log`), lines.join('\n') + '\n');
81
+ }
82
+
83
+ function writePrd(cwd, slug, body) {
84
+ const dir = resolvePrdWriteDir(cwd);
85
+ fs.mkdirSync(dir, { recursive: true });
86
+ const text = `---\ntitle: Test PRD\ncwd: ${cwd}\nestimateMinutes: 30\n---\n${body}\n`;
87
+ fs.writeFileSync(path.join(dir, `${slug}.md`), text);
88
+ }
89
+
90
+ async function wait(ms) {
91
+ await new Promise((r) => setTimeout(r, ms));
92
+ }
93
+
94
+ test('failed + no result event (unverified-shaped) + later commit touching declared path → looksDone, transitions to needs_review', async () => {
95
+ const projectCwd = path.join(tmpHome, 'proj-171');
96
+ initRepo(projectCwd);
97
+ registerActiveProject(projectCwd, 'proj-171-slug');
98
+ writePrd(projectCwd, '171-example', [
99
+ '# Implementation notes',
100
+ 'Edit `src/foo.js`.',
101
+ ].join('\n'));
102
+
103
+ const startedAt = new Date().toISOString();
104
+ const queuePath = writeProjectQueue(projectCwd, [
105
+ {
106
+ slug: '171-example',
107
+ status: 'failed',
108
+ cwd: projectCwd,
109
+ runId: 'run-171',
110
+ startedAt,
111
+ finishedAt: new Date().toISOString(),
112
+ error: 'reaped: process gone (outcome=no_result) (outcome=no_result)',
113
+ },
114
+ ]);
115
+ writeRunLog('run-171', '171-example', ['[scheduler] starting 171-example']); // no result event
116
+
117
+ await wait(1100); // git --since has 1s resolution
118
+ commitFile(projectCwd, 'src/foo.js', 'hello', 'fix 171');
119
+
120
+ await reverifyNeedsReview();
121
+
122
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
123
+ assert.equal(jobs.length, 1);
124
+ assert.equal(jobs[0].status, 'needs_review', 'looksDone never auto-completes — only surfaces for a human');
125
+ assert.ok(jobs[0].looksDone, 'expected a looksDone annotation');
126
+ assert.equal(jobs[0].looksDone.commits.length, 1);
127
+ assert.deepEqual(jobs[0].looksDone.paths, ['src/foo.js']);
128
+ assert.match(jobs[0].error, /looks done — 1 commit/);
129
+ });
130
+
131
+ test('failed + a real result event (genuine gate failure) → not a candidate, stays failed untouched', async () => {
132
+ const projectCwd = path.join(tmpHome, 'proj-real-fail');
133
+ initRepo(projectCwd);
134
+ registerActiveProject(projectCwd, 'proj-real-fail-slug');
135
+ writePrd(projectCwd, '05-real-fail', [
136
+ '# Implementation notes',
137
+ 'Edit `src/bar.js`.',
138
+ ].join('\n'));
139
+
140
+ const startedAt = new Date().toISOString();
141
+ const queuePath = writeProjectQueue(projectCwd, [
142
+ {
143
+ slug: '05-real-fail',
144
+ status: 'failed',
145
+ cwd: projectCwd,
146
+ runId: 'run-real-fail',
147
+ startedAt,
148
+ finishedAt: new Date().toISOString(),
149
+ error: 'AC gate red: tests failed',
150
+ },
151
+ ]);
152
+ writeRunLog('run-real-fail', '05-real-fail', [
153
+ '[scheduler] starting 05-real-fail',
154
+ JSON.stringify({ type: 'result', subtype: 'error_during_execution', is_error: true, result: 'tests failed' }),
155
+ ]);
156
+
157
+ await wait(1100);
158
+ commitFile(projectCwd, 'src/bar.js', 'hello', 'unrelated later commit touching the declared path');
159
+
160
+ await reverifyNeedsReview();
161
+
162
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
163
+ assert.equal(jobs.length, 1);
164
+ assert.equal(jobs[0].status, 'failed', 'a genuine red gate must never become a heal candidate');
165
+ assert.equal(jobs[0].looksDone, undefined);
166
+ assert.equal(jobs[0].error, 'AC gate red: tests failed');
167
+ });
168
+
169
+ test('needs_review with an in-window commit heals exactly as today (regression — unchanged heal semantics)', async () => {
170
+ const projectCwd = path.join(tmpHome, 'proj-heals-today');
171
+ initRepo(projectCwd);
172
+ registerActiveProject(projectCwd, 'proj-heals-today-slug');
173
+ writePrd(projectCwd, '20-clean-feature', [
174
+ '# Acceptance criteria',
175
+ '- [ ] `npm test` passes',
176
+ ].join('\n'));
177
+
178
+ const startedAt = new Date().toISOString();
179
+ const queuePath = writeProjectQueue(projectCwd, [
180
+ {
181
+ slug: '20-clean-feature',
182
+ status: 'needs_review',
183
+ cwd: projectCwd,
184
+ runId: 'run-clean',
185
+ startedAt,
186
+ finishedAt: new Date().toISOString(),
187
+ verifierVerdict: 'transcript_errors',
188
+ error: 'transcript_errors: stale verdict',
189
+ },
190
+ ]);
191
+ writeRunLog('run-clean', '20-clean-feature', [
192
+ '[scheduler] starting 20-clean-feature',
193
+ JSON.stringify({
194
+ type: 'assistant',
195
+ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'npm test' } }] },
196
+ }),
197
+ JSON.stringify({
198
+ type: 'user',
199
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'all tests passed' }] },
200
+ }),
201
+ 'SCHEDULER_VERDICT: PASS',
202
+ '[scheduler] exit code=0 (raw code=0 signal=null) duration=10s',
203
+ ]);
204
+
205
+ await reverifyNeedsReview();
206
+
207
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
208
+ assert.equal(jobs.length, 1);
209
+ assert.equal(jobs[0].status, 'completed', 'a clean re-verify on a RESCANNABLE verdict must still heal');
210
+ assert.equal(jobs[0].verifierVerdict, undefined);
211
+ assert.equal(jobs[0].error, null);
212
+ });
213
+
214
+ test('computeLooksDone: no declared paths on the PRD → null, never fabricates evidence', async () => {
215
+ const projectCwd = path.join(tmpHome, 'proj-no-paths');
216
+ initRepo(projectCwd);
217
+ writePrd(projectCwd, '09-no-paths', [
218
+ '# Implementation notes',
219
+ 'Just prose here, no backticked file paths.',
220
+ ].join('\n'));
221
+
222
+ const startedAt = new Date().toISOString();
223
+ await wait(1100);
224
+ commitFile(projectCwd, 'src/whatever.js', 'x', 'some later commit');
225
+
226
+ const job = { slug: '09-no-paths', cwd: projectCwd, startedAt };
227
+ const looksDone = await computeLooksDone(job);
228
+ assert.equal(looksDone, null);
229
+ });
230
+
231
+ test('computeLooksDone: non-git cwd → null without throwing (git-unavailable)', async () => {
232
+ const notARepo = fs.mkdtempSync(path.join(tmpHome, 'not-a-repo-'));
233
+ writePrd(notARepo, '11-no-repo', [
234
+ '# Implementation notes',
235
+ 'Edit `src/thing.js`.',
236
+ ].join('\n'));
237
+
238
+ const job = { slug: '11-no-repo', cwd: notARepo, startedAt: new Date().toISOString() };
239
+ const looksDone = await computeLooksDone(job);
240
+ assert.equal(looksDone, null);
241
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * scheduler-prd-persona-spawn.test.cjs — PRD 1115: a PRD's `agentType`
3
+ * frontmatter must actually drive the headless spawn (--append-system-prompt
4
+ * with the persona's frontmatter-stripped, capped body, and --model with the
5
+ * persona's own model), not just sit in the prompt as prose the executor may
6
+ * or may not obey. Covers buildClaudeSpawnArgs' pure argv shape plus a real
7
+ * executeJob spawn (stub `claude` binary) proving the resolved persona
8
+ * actually reaches the child process, and that a dangling agentType still
9
+ * runs the job to completion (never parks or fails it).
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-prd-persona-spawn.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+
21
+ let tmpHome;
22
+ let originalHome;
23
+ let executeJob;
24
+ let buildClaudeSpawnArgs;
25
+
26
+ beforeAll(() => {
27
+ originalHome = process.env.HOME;
28
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-persona-spawn-'));
29
+ process.env.HOME = tmpHome;
30
+ ({ executeJob, buildClaudeSpawnArgs } = require('../scheduler.cjs'));
31
+ });
32
+
33
+ afterAll(() => {
34
+ process.env.HOME = originalHome;
35
+ fs.rmSync(tmpHome, { recursive: true, force: true });
36
+ });
37
+
38
+ afterEach(() => {
39
+ delete process.env.SM_CLAUDE_BIN;
40
+ });
41
+
42
+ // ---------- buildClaudeSpawnArgs: systemPrompt -> --append-system-prompt ----------
43
+
44
+ test('buildClaudeSpawnArgs: a given systemPrompt is passed via --append-system-prompt', () => {
45
+ const args = buildClaudeSpawnArgs({ prompt: 'prd body', model: 'opus', sessionId: 'sid', resume: false, systemPrompt: 'You are dev-lead.' });
46
+ const idx = args.indexOf('--append-system-prompt');
47
+ expect(idx).toBeGreaterThanOrEqual(0);
48
+ expect(args[idx + 1]).toBe('You are dev-lead.');
49
+ });
50
+
51
+ test('buildClaudeSpawnArgs: omits --append-system-prompt entirely when systemPrompt is absent', () => {
52
+ const args = buildClaudeSpawnArgs({ prompt: 'prd body', model: 'sonnet', sessionId: 'sid', resume: false });
53
+ expect(args).not.toContain('--append-system-prompt');
54
+ });
55
+
56
+ test('buildClaudeSpawnArgs: model is never omitted even without a systemPrompt', () => {
57
+ const args = buildClaudeSpawnArgs({ prompt: 'x', model: 'sonnet', sessionId: 'sid', resume: false });
58
+ const idx = args.indexOf('--model');
59
+ expect(idx).toBeGreaterThanOrEqual(0);
60
+ expect(args[idx + 1]).toBe('sonnet');
61
+ });
62
+
63
+ // ---------- real spawn: persona actually reaches the child process ----------
64
+
65
+ // Stub `claude` binary: dumps its own argv into a marker file, then emits a
66
+ // stream-json success result and exits 0 — same technique as
67
+ // scheduler-bash-timeout-env.test.cjs's env-var proof, applied to argv.
68
+ function writeClaudeStub() {
69
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-argv-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
70
+ const body = `
71
+ const fs = require('fs');
72
+ const path = require('path');
73
+ fs.writeFileSync(path.join(process.cwd(), 'argv.marker'), JSON.stringify(process.argv.slice(2)), 'utf8');
74
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
75
+ process.exit(0);
76
+ `;
77
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
78
+ return stubPath;
79
+ }
80
+
81
+ function writePersona(dir, name, frontmatterLines, bodyText) {
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ const lines = ['---', `name: ${name}`, ...frontmatterLines, '---', bodyText];
84
+ fs.writeFileSync(path.join(dir, `${name}.md`), lines.join('\n'));
85
+ }
86
+
87
+ async function runJobWithAgentType({ agentType }) {
88
+ const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-persona-main-'));
89
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
90
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-persona-run-'));
91
+ const slug = `1115-test-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
92
+ const prdsDir = path.join(mainCwd, 'session-manager-operations', 'scheduler', 'prds');
93
+ fs.mkdirSync(prdsDir, { recursive: true });
94
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Do the thing.', 'utf8');
95
+ process.env.SM_CLAUDE_BIN = writeClaudeStub();
96
+
97
+ const job = { slug, cwd: mainCwd, agentType };
98
+ const result = await executeJob(job, runDir, mainCwd, () => Promise.resolve());
99
+ const argv = JSON.parse(fs.readFileSync(path.join(mainCwd, 'argv.marker'), 'utf8'));
100
+ fs.rmSync(mainCwd, { recursive: true, force: true });
101
+ fs.rmSync(runDir, { recursive: true, force: true });
102
+ return { result, argv };
103
+ }
104
+
105
+ test('executeJob: a resolvable agentType reaches the spawned child as --append-system-prompt + --model', async () => {
106
+ const globalAgentsDir = path.join(tmpHome, '.claude', 'agents');
107
+ writePersona(globalAgentsDir, 'dev-lead', ['model: opus'], 'You are dev-lead. Ship it.');
108
+
109
+ const { result, argv } = await runJobWithAgentType({ agentType: 'dev-lead' });
110
+
111
+ expect(result.exitCode).toBe(0);
112
+ const spIdx = argv.indexOf('--append-system-prompt');
113
+ expect(spIdx).toBeGreaterThanOrEqual(0);
114
+ expect(argv[spIdx + 1]).toBe('You are dev-lead. Ship it.');
115
+ const modelIdx = argv.indexOf('--model');
116
+ expect(argv[modelIdx + 1]).toBe('opus');
117
+ });
118
+
119
+ test('executeJob: a dangling agentType (no resolvable persona file) still runs the job to completion, falling back to sonnet with no --append-system-prompt', async () => {
120
+ const { result, argv } = await runJobWithAgentType({ agentType: 'renamed-or-deleted-persona' });
121
+
122
+ expect(result.exitCode).toBe(0);
123
+ expect(argv).not.toContain('--append-system-prompt');
124
+ const modelIdx = argv.indexOf('--model');
125
+ expect(argv[modelIdx + 1]).toBe('sonnet');
126
+ });
127
+
128
+ test('executeJob: a job with no agentType at all runs unaffected (no persona, sonnet fallback)', async () => {
129
+ const { result, argv } = await runJobWithAgentType({ agentType: null });
130
+
131
+ expect(result.exitCode).toBe(0);
132
+ expect(argv).not.toContain('--append-system-prompt');
133
+ const modelIdx = argv.indexOf('--model');
134
+ expect(argv[modelIdx + 1]).toBe('sonnet');
135
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * scheduler-quiet-machine-lease.test.cjs — PRD 1107: the exclusive
3
+ * quiet-machine lease must be released on EVERY spawnJob exit path,
4
+ * including a killed job (SIGKILL/exit 137), not just a clean exit — the
5
+ * same finally block that already releases the sessionSlots token. Drives
6
+ * spawnJob end-to-end against a real git fixture repo with a stub `claude`
7
+ * binary that exits 137 mid-run (same pattern as
8
+ * scheduler-inplace-salvage.test.cjs), then asserts:
9
+ * 1. the lease is released once the job settles, and
10
+ * 2. an ordinary pending job — held behind the lease while it was held —
11
+ * is now eligible to dispatch.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-quiet-machine-lease.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+ const { execFileSync } = require('node:child_process');
23
+
24
+ let tmpHome;
25
+ let originalHome;
26
+ let originalDisable;
27
+ let spawnJob;
28
+ let quietMachineLease;
29
+ let pickNextBatch;
30
+
31
+ function git(args, cwd) {
32
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
33
+ }
34
+
35
+ function initRepo(dir) {
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ git(['init', '-q'], dir);
38
+ git(['config', 'user.email', 'test@example.com'], dir);
39
+ git(['config', 'user.name', 'Test'], dir);
40
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
41
+ git(['add', '-A'], dir);
42
+ git(['commit', '-q', '-m', 'initial'], dir);
43
+ }
44
+
45
+ // See scheduler-inplace-salvage.test.cjs's identical helper: readQueue()'s
46
+ // per-project discovery walks ~/.claude/projects/*/*.jsonl transcripts for a
47
+ // `cwd` field to find known project queue.json shards.
48
+ function registerActiveProject(cwd) {
49
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
50
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
51
+ fs.mkdirSync(slugDir, { recursive: true });
52
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
53
+ }
54
+
55
+ function writeProjectQueue(cwd, jobs) {
56
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
57
+ fs.mkdirSync(stateDir, { recursive: true });
58
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
59
+ return path.join(stateDir, 'queue.json');
60
+ }
61
+
62
+ // Stub `claude` binary that exits 137 (SIGKILL) with no result:success event,
63
+ // simulating a leased job killed mid-run before it could finish and release
64
+ // naturally through its own clean-exit path. Dirties the tree first (like
65
+ // scheduler-inplace-salvage.test.cjs's stub) so the scheduler classifies this
66
+ // as a genuine kill-with-partial-work, not an auto-retryable transient blip
67
+ // with no evidence of real execution.
68
+ function writeKilledClaudeStub() {
69
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-quiet-killed-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
70
+ const body = `
71
+ const fs = require('fs');
72
+ const path = require('path');
73
+ fs.writeFileSync(path.join(process.cwd(), 'job-output.txt'), 'work in progress when killed\\n', 'utf8');
74
+ process.exit(137);
75
+ `;
76
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
77
+ return stubPath;
78
+ }
79
+
80
+ let reapDeadRunningJobs;
81
+
82
+ beforeAll(() => {
83
+ originalHome = process.env.HOME;
84
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-quiet-lease-'));
85
+ process.env.HOME = tmpHome;
86
+ originalDisable = process.env.SM_JOB_WORKTREE_DISABLE;
87
+ process.env.SM_JOB_WORKTREE_DISABLE = '1';
88
+ ({ spawnJob, reapDeadRunningJobs } = require('../scheduler.cjs'));
89
+ ({ pickNextBatch } = require('../lib/schedulerBatch.cjs'));
90
+ quietMachineLease = require('../lib/quietMachineLease.cjs');
91
+ });
92
+
93
+ afterAll(() => {
94
+ process.env.HOME = originalHome;
95
+ if (originalDisable === undefined) delete process.env.SM_JOB_WORKTREE_DISABLE;
96
+ else process.env.SM_JOB_WORKTREE_DISABLE = originalDisable;
97
+ fs.rmSync(tmpHome, { recursive: true, force: true });
98
+ });
99
+
100
+ afterEach(() => {
101
+ delete process.env.SM_CLAUDE_BIN;
102
+ quietMachineLease.__resetForTests();
103
+ });
104
+
105
+ test('a killed quietMachine job releases the exclusive lease, and an ordinary job dispatches on the next pick', async () => {
106
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-quiet-lease-project-'));
107
+ initRepo(projectCwd);
108
+ registerActiveProject(projectCwd);
109
+
110
+ const quietSlug = `1107-test-quiet-killed-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
111
+ const ordinarySlug = `1107-test-ordinary-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
112
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
113
+ fs.mkdirSync(prdsDir, { recursive: true });
114
+ fs.writeFileSync(path.join(prdsDir, `${quietSlug}.md`), '---\nquietMachine: true\n---\nMeasure timing without contention, then get killed.', 'utf8');
115
+ fs.writeFileSync(path.join(prdsDir, `${ordinarySlug}.md`), 'An ordinary PRD queued behind the quiet job.', 'utf8');
116
+
117
+ const queuePath = writeProjectQueue(projectCwd, [
118
+ { slug: quietSlug, status: 'pending', cwd: projectCwd, quietMachine: true },
119
+ { slug: ordinarySlug, status: 'pending', cwd: projectCwd, createdAt: new Date().toISOString() },
120
+ ]);
121
+
122
+ process.env.SM_CLAUDE_BIN = writeKilledClaudeStub();
123
+
124
+ const runId = `run-${quietSlug}`;
125
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
126
+ fs.mkdirSync(runDir, { recursive: true });
127
+
128
+ try {
129
+ // Before spawnJob ever runs, demonstrate that a held lease (as it would
130
+ // be while quietSlug is genuinely running) blocks EVERY dispatch,
131
+ // including the ordinary sibling job sitting right behind it in the same
132
+ // project. Uses a standalone dummy holder so it doesn't interact with
133
+ // spawnJob's own acquire/release of the real lease below.
134
+ expect(quietMachineLease.acquire('dummy-holder-simulating-in-flight-run')).toBe(true);
135
+ const heldPick = pickNextBatch(
136
+ [
137
+ { slug: quietSlug, status: 'running', cwd: projectCwd, quietMachine: true },
138
+ { slug: ordinarySlug, status: 'pending', cwd: projectCwd, createdAt: new Date().toISOString() },
139
+ ],
140
+ new Set([quietSlug]),
141
+ 5,
142
+ { leaseHeld: quietMachineLease.isHeld(), machineInUse: 1, now: Date.now() },
143
+ );
144
+ expect(heldPick.batch).toEqual([]);
145
+ quietMachineLease.release('dummy-holder-simulating-in-flight-run');
146
+
147
+ // Now the real thing: spawnJob acquires the lease itself for quietSlug,
148
+ // runs the killed stub, and must release it on this exit path.
149
+ await spawnJob({ slug: quietSlug, cwd: projectCwd, quietMachine: true }, runId, runDir, projectCwd);
150
+
151
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
152
+ const quietRow = jobs.find((j) => j.slug === quietSlug);
153
+ expect(quietRow).toBeTruthy();
154
+ expect(quietRow.exitCode).toBe(137);
155
+ expect(quietRow.quietMachine).toBe(true);
156
+
157
+ // The lease was released on this killed-job exit path — spawnJob's own
158
+ // finally block, the same one that releases the sessionSlots token.
159
+ expect(quietMachineLease.isHeld()).toBe(false);
160
+
161
+ // With the lease free again, the ordinary sibling job is now eligible.
162
+ const nextPick = pickNextBatch(
163
+ [
164
+ { slug: quietSlug, status: 'failed', cwd: projectCwd, quietMachine: true },
165
+ { slug: ordinarySlug, status: 'pending', cwd: projectCwd, createdAt: new Date().toISOString() },
166
+ ],
167
+ new Set(),
168
+ 5,
169
+ { leaseHeld: quietMachineLease.isHeld(), machineInUse: 0, now: Date.now() },
170
+ );
171
+ expect(nextPick.batch.map((j) => j.slug)).toEqual([ordinarySlug]);
172
+ } finally {
173
+ fs.rmSync(projectCwd, { recursive: true, force: true });
174
+ fs.rmSync(runDir, { recursive: true, force: true });
175
+ }
176
+ }, 30_000);
177
+
178
+ test('reapDeadRunningJobs releases a quietMachine job\'s lease too — the process vanished without spawnJob\'s own finally ever running', async () => {
179
+ // This reaper exists exactly for the case spawnJob's own finally never
180
+ // runs (the process dies without the completion mutate() finishing) — see
181
+ // reapDeadRunningJobs's header comment. A quietMachine row reaped through
182
+ // THIS path must release the lease itself, or it stays wedged forever.
183
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-quiet-lease-reap-project-'));
184
+ registerActiveProject(projectCwd);
185
+
186
+ const slug = `1107-test-reaped-quiet-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
187
+ const queuePath = writeProjectQueue(projectCwd, [
188
+ {
189
+ slug,
190
+ status: 'running',
191
+ cwd: projectCwd,
192
+ quietMachine: true,
193
+ runId: `run-${slug}`,
194
+ runtime: { pid: 999999 }, // guaranteed-dead pid (see reaperHelpers tests)
195
+ },
196
+ ]);
197
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', `run-${slug}`);
198
+ fs.mkdirSync(runDir, { recursive: true });
199
+ fs.writeFileSync(
200
+ path.join(runDir, `${slug}.log`),
201
+ '{"type":"result","subtype":"success","result":"done","is_error":false}\n',
202
+ );
203
+
204
+ // Simulate the lease as held by this row, exactly as it would be if
205
+ // spawnJob's own acquire ran but its finally never got the chance to.
206
+ expect(quietMachineLease.acquire(slug)).toBe(true);
207
+
208
+ try {
209
+ await reapDeadRunningJobs();
210
+
211
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
212
+ const row = jobs.find((j) => j.slug === slug);
213
+ expect(row).toBeTruthy();
214
+ expect(row.status).toBe('completed');
215
+
216
+ // The lease must be released here too, not just in spawnJob's finally.
217
+ expect(quietMachineLease.isHeld()).toBe(false);
218
+ } finally {
219
+ fs.rmSync(projectCwd, { recursive: true, force: true });
220
+ fs.rmSync(runDir, { recursive: true, force: true });
221
+ }
222
+ });