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,153 @@
1
+ /**
2
+ * gitWorktreeSalvageDelta.test.cjs — PRD 1098: `salvageDirtyDelta` is the
3
+ * shared-tree counterpart to `salvageWorktreeDiff` (gitWorktreeSalvage.test.cjs)
4
+ * — it salvages an IN-PLACE job's uncommitted work without a throwaway
5
+ * worktree checkout to diff and discard, so it must be strictly DELTA-scoped
6
+ * (only the caller-supplied paths, never a whole-tree dump) and must never
7
+ * mutate the shared tree (no add/stash/reset/checkout/clean) the way
8
+ * `salvageWorktreeDiff`'s `git add -A --intent-to-add` deliberately can
9
+ * against a disposable checkout.
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect, beforeEach, afterEach } from 'vitest';
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+ const { execFileSync } = require('node:child_process');
21
+ const gitWorktree = require('../gitWorktree.cjs');
22
+
23
+ let tmpRoot;
24
+ let repoCwd;
25
+ let outDir;
26
+
27
+ function git(args, cwd) {
28
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
29
+ }
30
+
31
+ function initRepo(dir) {
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ git(['init', '-q'], dir);
34
+ git(['config', 'user.email', 'test@example.com'], dir);
35
+ git(['config', 'user.name', 'Test'], dir);
36
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
37
+ fs.writeFileSync(path.join(dir, 'tracked-a.txt'), 'a\n', 'utf8');
38
+ fs.writeFileSync(path.join(dir, 'tracked-b.txt'), 'b\n', 'utf8');
39
+ git(['add', '-A'], dir);
40
+ git(['commit', '-q', '-m', 'initial'], dir);
41
+ }
42
+
43
+ beforeEach(() => {
44
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gitworktree-salvage-delta-'));
45
+ repoCwd = path.join(tmpRoot, 'repo');
46
+ initRepo(repoCwd);
47
+ outDir = fs.mkdtempSync(path.join(os.homedir(), '.claude', 'sm-test-salvage-delta-'));
48
+ });
49
+
50
+ afterEach(() => {
51
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
52
+ fs.rmSync(outDir, { recursive: true, force: true });
53
+ });
54
+
55
+ test('salvages only the given delta paths (tracked edit + untracked new file), never a pre-existing dirty file outside the delta', async () => {
56
+ // Pre-existing WIP that must NEVER appear in the patch — the whole point
57
+ // of delta-scoping.
58
+ fs.writeFileSync(path.join(repoCwd, 'tracked-b.txt'), 'b\npre-existing human WIP\n', 'utf8');
59
+
60
+ // This job's own delta: an edit to tracked-a.txt and a new untracked file.
61
+ fs.writeFileSync(path.join(repoCwd, 'tracked-a.txt'), 'a\nedited by job\n', 'utf8');
62
+ fs.writeFileSync(path.join(repoCwd, 'job-new-file.txt'), 'brand new job output\n', 'utf8');
63
+
64
+ const outFile = path.join(outDir, 'delta.uncommitted.patch');
65
+ const result = await gitWorktree.salvageDirtyDelta({
66
+ cwd: repoCwd,
67
+ paths: ['tracked-a.txt', 'job-new-file.txt'],
68
+ outFile,
69
+ });
70
+
71
+ expect(result.ok).toBe(true);
72
+ expect(result.bytes).toBeGreaterThan(0);
73
+ const patch = fs.readFileSync(outFile, 'utf8');
74
+ expect(patch).toContain('tracked-a.txt');
75
+ expect(patch).toContain('edited by job');
76
+ expect(patch).toContain('job-new-file.txt');
77
+ expect(patch).toContain('brand new job output');
78
+ // The sibling/human's pre-existing dirty file is NOT in the delta list —
79
+ // it must never leak into this job's patch.
80
+ expect(patch).not.toContain('pre-existing human WIP');
81
+ });
82
+
83
+ test('applies cleanly with git apply --check against a fresh clone of the base tree', async () => {
84
+ fs.writeFileSync(path.join(repoCwd, 'tracked-a.txt'), 'a\nedited\n', 'utf8');
85
+ fs.writeFileSync(path.join(repoCwd, 'job-new-file.txt'), 'new content\n', 'utf8');
86
+
87
+ const outFile = path.join(outDir, 'apply-check.uncommitted.patch');
88
+ const result = await gitWorktree.salvageDirtyDelta({
89
+ cwd: repoCwd,
90
+ paths: ['tracked-a.txt', 'job-new-file.txt'],
91
+ outFile,
92
+ });
93
+ expect(result.ok).toBe(true);
94
+
95
+ // Fresh clone of the same commit, clean, to prove the patch is
96
+ // self-sufficient and applies against the pristine base state.
97
+ const cleanCheckout = path.join(tmpRoot, 'clean-checkout');
98
+ git(['clone', '-q', repoCwd, cleanCheckout], tmpRoot);
99
+ expect(() => git(['apply', '--check', outFile], cleanCheckout)).not.toThrow();
100
+ });
101
+
102
+ test('never mutates the shared tree: git status and stash list are byte-identical before and after', async () => {
103
+ // A human's own pre-existing WIP, deliberately outside the delta.
104
+ fs.writeFileSync(path.join(repoCwd, 'tracked-b.txt'), 'b\nhuman wip\n', 'utf8');
105
+ fs.writeFileSync(path.join(repoCwd, 'tracked-a.txt'), 'a\njob edit\n', 'utf8');
106
+ fs.writeFileSync(path.join(repoCwd, 'job-new-file.txt'), 'job output\n', 'utf8');
107
+
108
+ const statusBefore = git(['status', '--porcelain'], repoCwd);
109
+ const stashBefore = git(['stash', 'list'], repoCwd);
110
+
111
+ const outFile = path.join(outDir, 'no-mutate.uncommitted.patch');
112
+ const result = await gitWorktree.salvageDirtyDelta({
113
+ cwd: repoCwd,
114
+ paths: ['tracked-a.txt', 'job-new-file.txt'],
115
+ outFile,
116
+ });
117
+ expect(result.ok).toBe(true);
118
+
119
+ const statusAfter = git(['status', '--porcelain'], repoCwd);
120
+ const stashAfter = git(['stash', 'list'], repoCwd);
121
+ expect(statusAfter).toBe(statusBefore);
122
+ expect(stashAfter).toBe(stashBefore);
123
+ });
124
+
125
+ test('no delta paths dirty at call time produces no patch file (no 0-byte artifact)', async () => {
126
+ const outFile = path.join(outDir, 'clean.uncommitted.patch');
127
+ const result = await gitWorktree.salvageDirtyDelta({
128
+ cwd: repoCwd,
129
+ paths: ['tracked-a.txt'],
130
+ outFile,
131
+ });
132
+ expect(result.ok).toBe(false);
133
+ expect(fs.existsSync(outFile)).toBe(false);
134
+ });
135
+
136
+ test('empty paths list is a no-op', async () => {
137
+ const outFile = path.join(outDir, 'empty.uncommitted.patch');
138
+ const result = await gitWorktree.salvageDirtyDelta({ cwd: repoCwd, paths: [], outFile });
139
+ expect(result.ok).toBe(false);
140
+ expect(fs.existsSync(outFile)).toBe(false);
141
+ });
142
+
143
+ test('a git failure (not a repo) is swallowed and never throws', async () => {
144
+ const notAGitRepo = path.join(tmpRoot, 'not-a-repo');
145
+ fs.mkdirSync(notAGitRepo, { recursive: true });
146
+ fs.writeFileSync(path.join(notAGitRepo, 'x.txt'), 'x\n', 'utf8');
147
+ const outFile = path.join(outDir, 'failure.uncommitted.patch');
148
+
149
+ await expect(
150
+ gitWorktree.salvageDirtyDelta({ cwd: notAGitRepo, paths: ['x.txt'], outFile }),
151
+ ).resolves.toEqual({ ok: false });
152
+ expect(fs.existsSync(outFile)).toBe(false);
153
+ });
@@ -142,11 +142,13 @@ test('falls back with a reason for a non-git cwd', async () => {
142
142
  expect(result.reason).toMatch(/not a git repository/);
143
143
  });
144
144
 
145
- test('falls back with a reason for a dirty base tree with a modified TRACKED file (never silently drops uncommitted human WIP)', async () => {
145
+ test('a dirty tracked base file is carried into the worktree instead of disabling isolation (never silently drops uncommitted human WIP)', async () => {
146
146
  fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
147
147
  const result = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
148
- expect(result.ok).toBe(false);
149
- expect(result.reason).toMatch(/uncommitted changes/);
148
+ expect(result.ok).toBe(true);
149
+ expect(result.carriedPaths).toEqual(['README.md']);
150
+ expect(fs.readFileSync(path.join(result.dir, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
151
+ await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
150
152
  });
151
153
 
152
154
  test('falls back once the concurrency cap is reached, and recovers after cleanup', async () => {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * landedSinceRun.test.cjs — the widened, path-scoped commit evidence helper
3
+ * behind reverifyNeedsReview's looksDone annotation (PRD 1102).
4
+ *
5
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/landedSinceRun.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ import { test } from 'vitest';
11
+ const assert = require('node:assert/strict');
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+ const { execFileSync } = require('node:child_process');
16
+ const { landedSinceRun } = require('../landedSinceRun.cjs');
17
+
18
+ function mkRepo() {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-since-run-'));
20
+ execFileSync('git', ['-C', dir, 'init', '-q']);
21
+ execFileSync('git', ['-C', dir, 'config', 'user.email', 'test@test.com']);
22
+ execFileSync('git', ['-C', dir, 'config', 'user.name', 'Test']);
23
+ return dir;
24
+ }
25
+
26
+ function commitFile(dir, relPath, content, message) {
27
+ const abs = path.join(dir, relPath);
28
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
29
+ fs.writeFileSync(abs, content);
30
+ execFileSync('git', ['-C', dir, 'add', relPath]);
31
+ execFileSync('git', ['-C', dir, 'commit', '-q', '-m', message]);
32
+ }
33
+
34
+ test('finds a commit landed after sinceIso that touches a declared path', async () => {
35
+ const dir = mkRepo();
36
+ const since = new Date().toISOString();
37
+ await new Promise((r) => setTimeout(r, 1100)); // git --since has 1s resolution
38
+ commitFile(dir, 'src/foo.js', 'hello', 'touch foo');
39
+ const shas = await landedSinceRun(dir, since, ['src/foo.js']);
40
+ assert.strictEqual(shas.length, 1);
41
+ });
42
+
43
+ test('ignores a commit that does not touch any declared path', async () => {
44
+ const dir = mkRepo();
45
+ const since = new Date().toISOString();
46
+ await new Promise((r) => setTimeout(r, 1100));
47
+ commitFile(dir, 'src/unrelated.js', 'hello', 'touch unrelated');
48
+ const shas = await landedSinceRun(dir, since, ['src/foo.js']);
49
+ assert.deepStrictEqual(shas, []);
50
+ });
51
+
52
+ test('empty paths never fabricates evidence — resolves []', async () => {
53
+ const dir = mkRepo();
54
+ commitFile(dir, 'src/foo.js', 'hello', 'touch foo');
55
+ const shas = await landedSinceRun(dir, new Date(0).toISOString(), []);
56
+ assert.deepStrictEqual(shas, []);
57
+ });
58
+
59
+ test('no cwd resolves [] without throwing', async () => {
60
+ const shas = await landedSinceRun(null, new Date().toISOString(), ['src/foo.js']);
61
+ assert.deepStrictEqual(shas, []);
62
+ });
63
+
64
+ test('git-unavailable (non-repo cwd) resolves [] without throwing', async () => {
65
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-since-run-norepo-'));
66
+ const shas = await landedSinceRun(dir, new Date().toISOString(), ['src/foo.js']);
67
+ assert.deepStrictEqual(shas, []);
68
+ });
69
+
70
+ test('has a bounded default timeout', () => {
71
+ const { LANDED_SINCE_RUN_TIMEOUT_MS } = require('../landedSinceRun.cjs');
72
+ assert.ok(LANDED_SINCE_RUN_TIMEOUT_MS > 0 && LANDED_SINCE_RUN_TIMEOUT_MS <= 60_000);
73
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * launchFailure.test.cjs — the non-run classifier + launch circuit breaker
3
+ * behind GitHub issue #11 (macOS, 2026-09-02): an outdated Claude CLI sent
4
+ * `thinking.type.enabled`, the API answered HTTP 400 on the first request,
5
+ * and 12 of 41 runs in one project were recorded as `failed` with
6
+ * `error: null` and then investigated by a probe that died the same way.
7
+ *
8
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/launchFailure.test.cjs
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import { test, expect } from 'vitest';
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const lf = require('../launchFailure.cjs');
18
+
19
+ // Verbatim shape from the issue-#11 transcript (2c3cd24f-…ccf0eb290fff.jsonl):
20
+ // a single assistant entry that IS the raw API error, then the result event.
21
+ const THINKING_400_RESULT = {
22
+ type: 'result', subtype: 'error', is_error: true, api_error_status: 400, num_turns: 1,
23
+ duration_ms: 23811, total_cost_usd: 0,
24
+ usage: { input_tokens: 0, output_tokens: 0 },
25
+ result: 'API Error: 400 {"detail":{"error":"{\\"message\\":\\"\\\\\\"thinking.type.enabled\\\\\\" is not supported for this model. Use \\\\\\"thinking.type.adaptive\\\\\\" and \\\\\\"output_config.effort\\\\\\" to control thinking behavior.\\"}"}}',
26
+ };
27
+
28
+ const REAL_WORK_FAILURE = {
29
+ type: 'result', subtype: 'error', is_error: true, num_turns: 19,
30
+ usage: { input_tokens: 4000, output_tokens: 2200 },
31
+ result: 'Error: expected 2 but got 3\n at test/foo.test.ts:12:5', terminal_reason: 'error_max_turns',
32
+ };
33
+
34
+ function tmpLog(lines) {
35
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-launch-'));
36
+ const p = path.join(dir, 'run.log');
37
+ fs.writeFileSync(p, lines.map((l) => (typeof l === 'string' ? l : JSON.stringify(l))).join('\n') + '\n');
38
+ return p;
39
+ }
40
+
41
+ test('parseResultEvent: flattens the last result event with turns/tokens/status', () => {
42
+ const r = lf.parseResultEvent([
43
+ '[scheduler] starting foo',
44
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'API Error: 400 …' }] } }),
45
+ JSON.stringify(THINKING_400_RESULT),
46
+ ].join('\n'));
47
+ expect(r).toMatchObject({ subtype: 'error', isError: true, numTurns: 1, outputTokens: 0, apiErrorStatus: 400 });
48
+ expect(r.resultText).toMatch(/thinking\.type\.enabled/);
49
+ });
50
+
51
+ test('parseResultEvent: null when no result event exists (process died first)', () => {
52
+ expect(lf.parseResultEvent('[scheduler] starting\n{"type":"system","subtype":"init"}\n')).toBeNull();
53
+ expect(lf.parseResultEvent('')).toBeNull();
54
+ });
55
+
56
+ test('classifyLaunchFailure: the issue-#11 thinking 400 → model_config_rejected with the API message', () => {
57
+ const c = lf.classifyLaunchFailure(lf.parseResultEvent(JSON.stringify(THINKING_400_RESULT)));
58
+ expect(c).not.toBeNull();
59
+ expect(c.kind).toBe('model_config_rejected');
60
+ expect(c.httpStatus).toBe(400);
61
+ expect(c.message).toMatch(/thinking\.type\.enabled.*not supported for this model/);
62
+ expect(c.message).not.toMatch(/^API Error/);
63
+ });
64
+
65
+ test('classifyLaunchFailure: a run that took real turns is NEVER a launch failure, even with API Error text', () => {
66
+ expect(lf.classifyLaunchFailure(lf.parseResultEvent(JSON.stringify(REAL_WORK_FAILURE)))).toBeNull();
67
+ const lateApiError = { ...THINKING_400_RESULT, num_turns: 7, usage: { output_tokens: 900 } };
68
+ expect(lf.classifyLaunchFailure(lf.parseResultEvent(JSON.stringify(lateApiError)))).toBeNull();
69
+ });
70
+
71
+ test('classifyLaunchFailure: zero-turn failure WITHOUT the API Error marker is not classified (someone else owns it)', () => {
72
+ const r = lf.parseResultEvent(JSON.stringify({ type: 'result', subtype: 'error', is_error: true, num_turns: 1, usage: { output_tokens: 0 }, result: 'Invalid prompt' }));
73
+ expect(lf.classifyLaunchFailure(r)).toBeNull();
74
+ });
75
+
76
+ test('classifyLaunchFailure: 429 is left to the rate-limit path', () => {
77
+ const r = lf.parseResultEvent(JSON.stringify({ type: 'result', subtype: 'error', is_error: true, api_error_status: 429, num_turns: 1, usage: { output_tokens: 0 }, result: 'API Error: 429 {"error":{"message":"rate limited"}}' }));
78
+ expect(lf.classifyLaunchFailure(r)).toBeNull();
79
+ });
80
+
81
+ test('classifyLaunchFailure: status → kind mapping', () => {
82
+ const mk = (status, text) => lf.classifyLaunchFailure(lf.parseResultEvent(JSON.stringify({
83
+ type: 'result', subtype: 'error', is_error: true, num_turns: 1, usage: { output_tokens: 0 }, result: `API Error: ${status} ${text}`,
84
+ })));
85
+ expect(mk(401, '{"error":{"message":"invalid x-api-key"}}').kind).toBe('auth_failed');
86
+ expect(mk(403, '{"error":{"message":"forbidden"}}').kind).toBe('auth_failed');
87
+ expect(mk(404, '{"error":{"message":"model: claude-x not found"}}').kind).toBe('model_not_found');
88
+ expect(mk(400, '{"error":{"message":"max_tokens must be > 0"}}').kind).toBe('bad_request');
89
+ expect(mk(529, '{"error":{"type":"overloaded_error","message":"Overloaded"}}').kind).toBe('api_overloaded');
90
+ expect(mk(500, '{"error":{"message":"internal"}}').kind).toBe('api_overloaded');
91
+ expect(mk(418, 'teapot').kind).toBe('api_error');
92
+ expect(mk(401, '{"error":{"message":"invalid x-api-key"}}').message).toBe('invalid x-api-key');
93
+ });
94
+
95
+ test('readResultEvent + classify on a real log tail (scheduler log lines interleaved)', () => {
96
+ const p = tmpLog([
97
+ '[scheduler] starting 04-fix-cdp at 2026-09-02T17:54:14.770Z',
98
+ { type: 'system', subtype: 'init', session_id: 'x' },
99
+ { type: 'user', message: { content: [{ type: 'text', text: 'PRD body' }] } },
100
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'API Error: 400 …' }] } },
101
+ THINKING_400_RESULT,
102
+ '[scheduler] exit code=1 (raw code=1 signal=null) duration=25s',
103
+ ]);
104
+ try {
105
+ const r = lf.readResultEvent(p);
106
+ expect(lf.classifyLaunchFailure(r)?.kind).toBe('model_config_rejected');
107
+ expect(lf.resultShowsRealTurn(r)).toBe(false);
108
+ } finally {
109
+ fs.rmSync(path.dirname(p), { recursive: true, force: true });
110
+ }
111
+ });
112
+
113
+ test('resultShowsRealTurn: true once a turn or any output token happened', () => {
114
+ expect(lf.resultShowsRealTurn({ numTurns: 1, outputTokens: 0 })).toBe(false);
115
+ expect(lf.resultShowsRealTurn({ numTurns: 2, outputTokens: 0 })).toBe(true);
116
+ expect(lf.resultShowsRealTurn({ numTurns: 1, outputTokens: 12 })).toBe(true);
117
+ expect(lf.resultShowsRealTurn(null)).toBe(false);
118
+ });
119
+
120
+ // ─── circuit breaker ────────────────────────────────────────────────────────
121
+
122
+ const T0 = Date.parse('2026-09-02T18:00:00Z');
123
+
124
+ test('armLaunchBlock: first failure opens with the kind backoff, a mitigation env, and an operator hint', () => {
125
+ const b = lf.armLaunchBlock(null, { kind: 'model_config_rejected', httpStatus: 400, message: 'm', now: T0, claudeVersion: '1.0.90', slug: 'a', runId: 'r1' });
126
+ expect(b.attempts).toBe(1);
127
+ expect(b.exhausted).toBe(false);
128
+ expect(Date.parse(b.until) - T0).toBe(lf.backoffMsFor('model_config_rejected', 1));
129
+ expect(b.mitigationEnv).toEqual({ MAX_THINKING_TOKENS: '0' });
130
+ expect(b.hint).toMatch(/claude update/);
131
+ expect(b.claudeVersion).toBe('1.0.90');
132
+ expect(b.probing).toBeNull();
133
+ });
134
+
135
+ test('armLaunchBlock: repeated same-kind failures escalate, cap at 60 min, then exhaust (until=null)', () => {
136
+ let b = null;
137
+ const seen = [];
138
+ for (let i = 0; i < lf.LAUNCH_BLOCK_MAX_ATTEMPTS; i++) {
139
+ b = lf.armLaunchBlock(b, { kind: 'api_error', message: 'x', now: T0 + i * 1000 });
140
+ seen.push(b.until ? Date.parse(b.until) - (T0 + i * 1000) : null);
141
+ }
142
+ expect(b.attempts).toBe(lf.LAUNCH_BLOCK_MAX_ATTEMPTS);
143
+ expect(b.exhausted).toBe(true);
144
+ expect(b.until).toBeNull();
145
+ const finite = seen.filter((x) => x !== null);
146
+ for (let i = 1; i < finite.length; i++) expect(finite[i]).toBeGreaterThanOrEqual(finite[i - 1]);
147
+ expect(Math.max(...finite)).toBeLessThanOrEqual(60 * 60_000);
148
+ expect(b.since).toBe(new Date(T0).toISOString()); // first-failure time survives re-arming
149
+ });
150
+
151
+ test('armLaunchBlock: a different kind restarts the attempt count', () => {
152
+ const a = lf.armLaunchBlock(null, { kind: 'api_overloaded', message: 'x', now: T0 });
153
+ const b = lf.armLaunchBlock(a, { kind: 'api_overloaded', message: 'x', now: T0 + 1 });
154
+ const c = lf.armLaunchBlock(b, { kind: 'auth_failed', message: 'y', now: T0 + 2 });
155
+ expect(b.attempts).toBe(2);
156
+ expect(c.attempts).toBe(1);
157
+ expect(c.mitigationEnv).toBeNull();
158
+ });
159
+
160
+ test('evaluateLaunchGate: open → blocked during backoff → probe after → blocked while a probe is in flight', () => {
161
+ expect(lf.evaluateLaunchGate(null, { now: T0 }).state).toBe('open');
162
+ const b = lf.armLaunchBlock(null, { kind: 'model_config_rejected', message: 'm', now: T0, claudeVersion: '1.0.90' });
163
+ expect(lf.evaluateLaunchGate(b, { now: T0 + 1000, claudeVersion: '1.0.90' }).state).toBe('blocked');
164
+ expect(lf.evaluateLaunchGate(b, { now: T0 + 1000 }).reason).toMatch(/re-probe in \d+ min/);
165
+ const afterBackoff = Date.parse(b.until) + 1;
166
+ expect(lf.evaluateLaunchGate(b, { now: afterBackoff, claudeVersion: '1.0.90' }).state).toBe('probe');
167
+ const probing = { ...b, probing: { slug: 'p', at: new Date(afterBackoff).toISOString() } };
168
+ const g = lf.evaluateLaunchGate(probing, { now: afterBackoff + 60_000, claudeVersion: '1.0.90' });
169
+ expect(g.state).toBe('blocked');
170
+ expect(g.reason).toMatch(/probe p in flight/);
171
+ // a probe that never reported back goes stale and the gate re-opens to a new probe
172
+ expect(lf.evaluateLaunchGate(probing, { now: afterBackoff + lf.LAUNCH_PROBE_STALE_MS + 1, claudeVersion: '1.0.90' }).state).toBe('probe');
173
+ });
174
+
175
+ test('evaluateLaunchGate: a CLI version change short-circuits the backoff (the real fix for issue #11)', () => {
176
+ const b = lf.armLaunchBlock(null, { kind: 'model_config_rejected', message: 'm', now: T0, claudeVersion: '1.0.90' });
177
+ const g = lf.evaluateLaunchGate(b, { now: T0 + 1000, claudeVersion: '1.0.128' });
178
+ expect(g.state).toBe('open');
179
+ expect(g.reason).toMatch(/cli-version-changed/);
180
+ // unknown current version (probe failed) → no shortcut, backoff still holds
181
+ expect(lf.evaluateLaunchGate(b, { now: T0 + 1000, claudeVersion: null }).state).toBe('blocked');
182
+ });
183
+
184
+ test('evaluateLaunchGate: an exhausted block stays blocked until version change', () => {
185
+ let b = null;
186
+ for (let i = 0; i < lf.LAUNCH_BLOCK_MAX_ATTEMPTS; i++) b = lf.armLaunchBlock(b, { kind: 'auth_failed', message: 'x', now: T0, claudeVersion: 'v1' });
187
+ expect(lf.evaluateLaunchGate(b, { now: T0 + 365 * 24 * 3600_000, claudeVersion: 'v1' }).state).toBe('blocked');
188
+ expect(lf.evaluateLaunchGate(b, { now: T0 + 1, claudeVersion: 'v2' }).state).toBe('open');
189
+ });
190
+
191
+ test('launchBlockKeyFor: persona is the key, default when absent', () => {
192
+ expect(lf.launchBlockKeyFor({ agentType: 'dev-lead' })).toBe('dev-lead');
193
+ expect(lf.launchBlockKeyFor({})).toBe('default');
194
+ expect(lf.launchBlockKeyFor(null)).toBe('default');
195
+ });
196
+
197
+ test('deriveTerminalReason: closed-set taxonomy', () => {
198
+ expect(lf.deriveTerminalReason({ effectiveStatus: 'completed', exitCode: 0 })).toBe('completed');
199
+ expect(lf.deriveTerminalReason({ effectiveStatus: 'failed', exitCode: 1 })).toBe('impl_failed:exit_1');
200
+ expect(lf.deriveTerminalReason({ effectiveStatus: 'failed', exitCode: 143 })).toBe('signal_kill');
201
+ expect(lf.deriveTerminalReason({ effectiveStatus: 'needs_review', exitCode: 143, sigtermOverride: { status: 'needs_review' } })).toBe('signal_kill_with_commit');
202
+ expect(lf.deriveTerminalReason({ effectiveStatus: 'needs_review', exitCode: 0, verifyResult: { verdict: 'silent_no_op' } })).toBe('verifier:silent_no_op');
203
+ expect(lf.deriveTerminalReason({ effectiveStatus: 'needs_review', exitCode: 0, worktreeIntegrationFailure: 'conflict' })).toBe('worktree_integration_failed');
204
+ });
205
+
206
+ test('writeOutcomeSidecar: writes <slug>.outcome.json atomically and never throws on a bad dir', () => {
207
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-outcome-'));
208
+ try {
209
+ const p = lf.writeOutcomeSidecar(dir, '12-foo', { numTurns: 1, outputTokens: 0, terminalReason: 'launch_failure:model_config_rejected' });
210
+ expect(p).toBe(path.join(dir, '12-foo.outcome.json'));
211
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
212
+ expect(parsed).toMatchObject({ slug: '12-foo', numTurns: 1, outputTokens: 0, terminalReason: 'launch_failure:model_config_rejected' });
213
+ expect(parsed.writtenAt).toMatch(/^\d{4}-/);
214
+ expect(fs.existsSync(`${p}.tmp`)).toBe(false);
215
+ } finally {
216
+ fs.rmSync(dir, { recursive: true, force: true });
217
+ }
218
+ expect(lf.writeOutcomeSidecar('/nonexistent/dir/for/sure', 'x', {})).toBeNull();
219
+ expect(lf.writeOutcomeSidecar(null, 'x', {})).toBeNull();
220
+ });
@@ -83,6 +83,7 @@ test('MCP_RECIPES covers queue-work, unstick-needs-review, and hand-off-to-anoth
83
83
  expect(ids).toContain('queue-work-via-develop');
84
84
  expect(ids).toContain('unstick-needs-review-job');
85
85
  expect(ids).toContain('hand-finding-to-another-project');
86
+ expect(ids).toContain('generate-project-home');
86
87
  for (const recipe of MCP_RECIPES) {
87
88
  expect(recipe.title).toBeTruthy();
88
89
  expect(Array.isArray(recipe.steps)).toBe(true);
@@ -27,6 +27,12 @@ test('the owner of a namespace may write it', () => {
27
27
  assert.equal(checkOpsWrite(`${P}/prompt-sessions/active-index.json`, 'epics').ok, true);
28
28
  assert.equal(checkOpsWrite(`${P}/scheduler/state/queue.json`, 'scheduler').ok, true);
29
29
  assert.equal(checkOpsWrite(`${P}/project-brief/brief.json`, 'project-home').ok, true);
30
+ // project-pages: declared owner is 'project-home', same as project-brief —
31
+ // this governs only the app's own /admin/project-home/render write path
32
+ // (config.cjs), NOT a project-home-builder Epic's own Write-tool authoring
33
+ // of summary.json/picks.json, which never goes through config.cjs at all
34
+ // and so is unaffected by this table either way (see project-pages/README.md).
35
+ assert.equal(checkOpsWrite(`${P}/project-pages/output/manifest.json`, 'project-home').ok, true);
30
36
  });
31
37
 
32
38
  test('a non-owner is refused, and the error names the owner', () => {
@@ -36,6 +42,7 @@ test('a non-owner is refused, and the error names the owner', () => {
36
42
  // The exact cross-write this law exists to prevent: another surface
37
43
  // rewriting the Epic store out from under Epics.
38
44
  assert.equal(checkOpsWrite(`${P}/prompt-sessions/psess-1.json`, 'scheduler').ok, false);
45
+ assert.equal(checkOpsWrite(`${P}/project-pages/output/manifest.json`, 'scheduler').ok, false);
39
46
  });
40
47
 
41
48
  test('an undeclared writer is refused (fail-closed)', () => {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * prdDeclaredPaths.test.cjs — extracting a PRD's declared file paths for the
3
+ * reverify self-heal pass's widened evidence window (PRD 1102).
4
+ *
5
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/prdDeclaredPaths.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ import { test } from 'vitest';
11
+ const assert = require('node:assert/strict');
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+ const { extractDeclaredPaths, declaredPathsForPrd } = require('../prdDeclaredPaths.cjs');
16
+
17
+ test('extracts backtick-quoted paths from Implementation notes', () => {
18
+ const body = [
19
+ '# Implementation notes',
20
+ '',
21
+ 'Edit `src/main/scheduler.cjs` and `src/main/lib/gitWorktree.cjs`.',
22
+ '',
23
+ '# Out of scope',
24
+ 'Do not touch `src/main/should-not-appear.cjs` here.',
25
+ ].join('\n');
26
+ const paths = extractDeclaredPaths(body);
27
+ assert.deepStrictEqual(paths, ['src/main/scheduler.cjs', 'src/main/lib/gitWorktree.cjs']);
28
+ });
29
+
30
+ test('extracts paths from Acceptance criteria and strips trailing :line', () => {
31
+ const body = [
32
+ '# Acceptance criteria',
33
+ '- [ ] Fix the bug in `src/main/scheduler.cjs:490`',
34
+ ].join('\n');
35
+ assert.deepStrictEqual(extractDeclaredPaths(body), ['src/main/scheduler.cjs']);
36
+ });
37
+
38
+ test('dedupes paths mentioned in both sections', () => {
39
+ const body = [
40
+ '# Acceptance criteria',
41
+ '- [ ] Fix `src/main/scheduler.cjs`',
42
+ '# Implementation notes',
43
+ 'See `src/main/scheduler.cjs` again.',
44
+ ].join('\n');
45
+ assert.deepStrictEqual(extractDeclaredPaths(body), ['src/main/scheduler.cjs']);
46
+ });
47
+
48
+ test('never fabricates a path — no match anywhere returns []', () => {
49
+ const body = [
50
+ '# Implementation notes',
51
+ 'Just prose, no backticked paths here.',
52
+ '# Acceptance criteria',
53
+ '- [ ] npm run typecheck passes',
54
+ ].join('\n');
55
+ assert.deepStrictEqual(extractDeclaredPaths(body), []);
56
+ });
57
+
58
+ test('empty/non-string body returns []', () => {
59
+ assert.deepStrictEqual(extractDeclaredPaths(''), []);
60
+ assert.deepStrictEqual(extractDeclaredPaths(null), []);
61
+ });
62
+
63
+ test('declaredPathsForPrd reads a real file and strips frontmatter', () => {
64
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prd-declared-paths-'));
65
+ const prdPath = path.join(dir, '01-test.md');
66
+ fs.writeFileSync(prdPath, [
67
+ '---',
68
+ 'title: Test',
69
+ '---',
70
+ '# Implementation notes',
71
+ 'Touch `src/main/scheduler.cjs`.',
72
+ ].join('\n'));
73
+ assert.deepStrictEqual(declaredPathsForPrd(prdPath), ['src/main/scheduler.cjs']);
74
+ });
75
+
76
+ test('declaredPathsForPrd on a missing file returns [] without throwing', () => {
77
+ assert.deepStrictEqual(declaredPathsForPrd('/nonexistent/path/does-not-exist.md'), []);
78
+ });
79
+
80
+ test('declaredPathsForPrd with null path returns []', () => {
81
+ assert.deepStrictEqual(declaredPathsForPrd(null), []);
82
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * queueHealth.test.cjs — computeQueueHealth: the pure per-project rollup
3
+ * behind the periodic queue-health sweep (PRD 1109).
4
+ *
5
+ * Pure helper, no electron import — mirrors reaperHelpers.test.cjs's pattern
6
+ * so it never needs scheduler.cjs's mocked IPC surface.
7
+ *
8
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/queueHealth.test.cjs
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import { test } from 'vitest';
14
+ const assert = require('node:assert/strict');
15
+ const { computeQueueHealth } = require('../queueHealth.cjs');
16
+
17
+ test('counts never_ran, looksDone, and stuck per project cwd', () => {
18
+ const jobs = [
19
+ { slug: 'a', cwd: '/proj-1', status: 'failed', gateOutcome: 'never_ran' },
20
+ { slug: 'b', cwd: '/proj-1', status: 'failed', looksDone: { commits: ['abc1234'] } },
21
+ { slug: 'c', cwd: '/proj-1', status: 'needs_review' },
22
+ { slug: 'd', cwd: '/proj-2', status: 'quarantined' },
23
+ ];
24
+ const rollup = computeQueueHealth(jobs);
25
+ const byProj = Object.fromEntries(rollup.map((r) => [r.cwd, r]));
26
+ assert.deepStrictEqual(byProj['/proj-1'], { cwd: '/proj-1', neverRan: 1, looksDone: 1, stuck: 1 });
27
+ assert.deepStrictEqual(byProj['/proj-2'], { cwd: '/proj-2', neverRan: 0, looksDone: 0, stuck: 1 });
28
+ });
29
+
30
+ test('a project with zero drift across all three counters is omitted entirely', () => {
31
+ const jobs = [
32
+ { slug: 'clean', cwd: '/proj-clean', status: 'completed' },
33
+ { slug: 'running', cwd: '/proj-clean', status: 'running' },
34
+ { slug: 'pending', cwd: '/proj-clean', status: 'pending' },
35
+ ];
36
+ assert.deepStrictEqual(computeQueueHealth(jobs), []);
37
+ });
38
+
39
+ test('gateOutcome values other than never_ran do not count', () => {
40
+ const jobs = [
41
+ { slug: 'a', cwd: '/proj', status: 'completed', gateOutcome: 'passed' },
42
+ { slug: 'b', cwd: '/proj', status: 'failed', gateOutcome: 'failed' },
43
+ { slug: 'c', cwd: '/proj', status: 'failed', gateOutcome: 'unknown' },
44
+ ];
45
+ assert.deepStrictEqual(computeQueueHealth(jobs), []);
46
+ });
47
+
48
+ test('empty/missing jobs array is handled without throwing', () => {
49
+ assert.deepStrictEqual(computeQueueHealth([]), []);
50
+ assert.deepStrictEqual(computeQueueHealth(undefined), []);
51
+ });
52
+
53
+ test('is a pure read: never mutates the input jobs array or its rows', () => {
54
+ const jobs = [{ slug: 'a', cwd: '/proj', status: 'needs_review', gateOutcome: 'never_ran' }];
55
+ const snapshot = JSON.parse(JSON.stringify(jobs));
56
+ computeQueueHealth(jobs);
57
+ assert.deepStrictEqual(jobs, snapshot);
58
+ });