claude-code-session-manager 0.61.0 → 0.62.1

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 (38) hide show
  1. package/dist/assets/{TiptapBody-CCmz3PdX.js → TiptapBody-BwN4pNC0.js} +42 -42
  2. package/dist/assets/{index-CPMP2XZ_.css → index-CVCCMw5o.css} +1 -1
  3. package/dist/assets/index-ZjTDk5b7.js +3197 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -2
  6. package/src/main/__tests__/chat-preamble-anchors.test.cjs +100 -0
  7. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  8. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  9. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  10. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  11. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  12. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  13. package/src/main/chatRunner.cjs +4 -0
  14. package/src/main/config.cjs +30 -23
  15. package/src/main/files.cjs +20 -0
  16. package/src/main/index.cjs +1 -63
  17. package/src/main/ipcSchemas.cjs +11 -209
  18. package/src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs +87 -0
  19. package/src/main/lib/activeIndexMerge.cjs +9 -0
  20. package/src/main/lib/opsOwnership.cjs +0 -2
  21. package/src/main/lib/summarize.cjs +0 -1
  22. package/src/main/runVerify.cjs +55 -6
  23. package/src/main/scheduler.cjs +46 -7
  24. package/src/preload/api.d.ts +2 -197
  25. package/src/preload/index.cjs +1 -100
  26. package/dist/assets/index-B5994QOJ.js +0 -3222
  27. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  28. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  29. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  30. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  31. package/src/main/browserAgentServer.cjs +0 -224
  32. package/src/main/browserCapture.cjs +0 -728
  33. package/src/main/browserView.cjs +0 -916
  34. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  35. package/src/main/lib/browserAgentActions.cjs +0 -114
  36. package/src/main/lib/e2eStateMachine.cjs +0 -39
  37. package/src/main/webRemote.cjs +0 -1367
  38. package/src/preload/browserViewPreload.cjs +0 -118
package/dist/index.html CHANGED
@@ -7,10 +7,10 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-B5994QOJ.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-ZjTDk5b7.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
- <link rel="stylesheet" crossorigin href="./assets/index-CPMP2XZ_.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-CVCCMw5o.css">
14
14
  </head>
15
15
  <body class="bg-bg text-fg font-sans antialiased">
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.61.0",
3
+ "version": "0.62.1",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -40,7 +40,9 @@
40
40
  "test:e2e:gen-fixture": "espeak-ng -w /tmp/sm-raw.wav -s 140 'testing one two three four five' && ffmpeg -y -i /tmp/sm-raw.wav -ar 48000 -ac 2 -sample_fmt s16 e2e/fixtures/speech.wav",
41
41
  "refresh:vad-assets": "cp node_modules/@ricky0123/vad-web/dist/silero_vad_v5.onnx node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js src/renderer/public/vad/ && cp node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.wasm node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.mjs node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.wasm node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.mjs src/renderer/public/vad/",
42
42
  "health": "node src/main/health.cjs",
43
- "lint:selectors": "node scripts/check-unstable-selectors.cjs"
43
+ "lint:selectors": "node scripts/check-unstable-selectors.cjs",
44
+ "lint:hooks": "node scripts/check-conditional-hooks.cjs",
45
+ "lint": "npm run lint:selectors && npm run lint:hooks"
44
46
  },
45
47
  "keywords": [
46
48
  "claude",
@@ -0,0 +1,100 @@
1
+ /**
2
+ * chat-preamble-anchors.test.cjs — drift guard between chatRunner's injected
3
+ * instruction blocks and the renderer's splitter that hides them.
4
+ *
5
+ * `src/renderer/lib/promptPreamble.ts` anchors each block on its FIRST and
6
+ * LAST sentence only, because it cannot import this main-process .cjs. That
7
+ * indirection is safe ONLY while those anchors still bracket the real text —
8
+ * so this test composes the exact `fullPrompt` chatRunner builds and asserts
9
+ * the splitter recovers the human's words exactly. Reword either block's
10
+ * opening or closing sentence and this fails here, loudly, instead of the
11
+ * boilerplate silently reappearing in every user bubble.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/__tests__/chat-preamble-anchors.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const path = require('path');
19
+
20
+ import { test, expect } from 'vitest';
21
+
22
+ const { STOP_SIGNAL_INSTRUCTION, CHAT_MODE_TRUTH_INSTRUCTION } = require('../chatRunner.cjs');
23
+
24
+ const HUMAN = 'Review the Alpaca limits and report back what we can trade for free.';
25
+
26
+ async function loadSplitter() {
27
+ const mod = await import(
28
+ /* @vite-ignore */ path.resolve(__dirname, '..', '..', 'renderer', 'lib', 'promptPreamble.ts')
29
+ );
30
+ return mod;
31
+ }
32
+
33
+ test('the real fullPrompt splits back into exactly the human’s words', async () => {
34
+ const { splitInjectedPreamble } = await loadSplitter();
35
+ // Mirrors chatRunner's own composition at its `-p` call site.
36
+ const fullPrompt = STOP_SIGNAL_INSTRUCTION + CHAT_MODE_TRUTH_INSTRUCTION + HUMAN;
37
+
38
+ const split = splitInjectedPreamble(fullPrompt);
39
+ expect(split.body).toBe(HUMAN);
40
+ expect(split.blockKeys).toEqual(['stop-signal', 'chat-mode-truth']);
41
+ expect(split.preamble).toBeTruthy();
42
+ // Nothing is invented or lost: preamble + body reconstitute the input.
43
+ expect(split.preamble.length + split.body.length).toBeLessThanOrEqual(fullPrompt.length);
44
+ expect(fullPrompt.startsWith(split.preamble)).toBe(true);
45
+ expect(fullPrompt.endsWith(split.body)).toBe(true);
46
+ });
47
+
48
+ test('each block strips independently, so one being absent still works', async () => {
49
+ const { splitInjectedPreamble } = await loadSplitter();
50
+
51
+ const onlyStop = splitInjectedPreamble(STOP_SIGNAL_INSTRUCTION + HUMAN);
52
+ expect(onlyStop.body).toBe(HUMAN);
53
+ expect(onlyStop.blockKeys).toEqual(['stop-signal']);
54
+
55
+ const onlyTruth = splitInjectedPreamble(CHAT_MODE_TRUTH_INSTRUCTION + HUMAN);
56
+ expect(onlyTruth.body).toBe(HUMAN);
57
+ expect(onlyTruth.blockKeys).toEqual(['chat-mode-truth']);
58
+ });
59
+
60
+ test('a prompt with no injected blocks is returned untouched', async () => {
61
+ const { splitInjectedPreamble } = await loadSplitter();
62
+ const split = splitInjectedPreamble(HUMAN);
63
+ expect(split.preamble).toBe(null);
64
+ expect(split.body).toBe(HUMAN);
65
+ expect(split.blockKeys).toEqual([]);
66
+ });
67
+
68
+ test('EDGE: a boilerplate-only prompt is NOT stripped to an empty bubble', async () => {
69
+ const { splitInjectedPreamble } = await loadSplitter();
70
+ const onlyBoilerplate = STOP_SIGNAL_INSTRUCTION + CHAT_MODE_TRUTH_INSTRUCTION;
71
+ const split = splitInjectedPreamble(onlyBoilerplate);
72
+ expect(split.preamble).toBe(null);
73
+ expect(split.body).toBe(onlyBoilerplate);
74
+ });
75
+
76
+ test('EDGE: a truncated block (opening present, closing missing) is left fully intact', async () => {
77
+ const { splitInjectedPreamble } = await loadSplitter();
78
+ // Simulates a reworded/cut-off block: better to over-show boilerplate than
79
+ // to guess at a boundary and eat the human's words.
80
+ const truncated = `${STOP_SIGNAL_INSTRUCTION.slice(0, 120)}\n\n${HUMAN}`;
81
+ const split = splitInjectedPreamble(truncated);
82
+ expect(split.preamble).toBe(null);
83
+ expect(split.body).toBe(truncated);
84
+ });
85
+
86
+ test('the two blocks strip in either order', async () => {
87
+ const { splitInjectedPreamble } = await loadSplitter();
88
+ const reversed = CHAT_MODE_TRUTH_INSTRUCTION + STOP_SIGNAL_INSTRUCTION + HUMAN;
89
+ const split = splitInjectedPreamble(reversed);
90
+ expect(split.body).toBe(HUMAN);
91
+ expect(split.blockKeys).toEqual(['chat-mode-truth', 'stop-signal']);
92
+ });
93
+
94
+ test('EDGE: boilerplate quoted inside the human’s own message is never stripped', async () => {
95
+ const { splitInjectedPreamble } = await loadSplitter();
96
+ const quoting = `Why does chat prepend this?\n\n${STOP_SIGNAL_INSTRUCTION}`;
97
+ const split = splitInjectedPreamble(quoting);
98
+ expect(split.preamble).toBe(null);
99
+ expect(split.body).toBe(quoting);
100
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * runVerify-transcript-commit-evidence.test.cjs — regression tests for the
3
+ * transcript-commit-evidence fallback added to runVerify.cjs.
4
+ *
5
+ * Root cause (99-fix-e2e-needs-review-test, 2026-08-07): a job whose cwd is
6
+ * not a git repo (e.g. an investigation fix-plan that inherited cwd: /tmp)
7
+ * can never produce committedDuringRun=true — gitHead()/committedInWindow()
8
+ * both fail closed there — so the PASS+commit sentinel override could never
9
+ * fire even when the harness's own transcript recorded a real commit. This
10
+ * file asserts the fallback fires ONLY when both conditions hold: the cwd is
11
+ * genuinely not a git repo, AND the transcript itself carries harness-emitted
12
+ * commit evidence (never inferred from prose the model could fabricate).
13
+ *
14
+ * Run standalone: timeout 120 npx vitest run src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs
15
+ */
16
+
17
+ import { test } from 'vitest';
18
+ const assert = require('node:assert/strict');
19
+ const os = require('node:os');
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const { execFileSync } = require('node:child_process');
23
+ const { verifyRun } = require('../runVerify.cjs');
24
+
25
+ // ─── helpers (mirrors runVerify.test.cjs) ─────────────────────────────────────
26
+
27
+ function makeTmpDir() {
28
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-commit-evidence-test-'));
29
+ }
30
+
31
+ function rmdir(dir) {
32
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
33
+ }
34
+
35
+ function writeLog(dir, slug, events) {
36
+ const lines = ['[scheduler] starting ' + slug + ' at 2026-08-07T02:25:31.453Z'];
37
+ for (const ev of events) lines.push(JSON.stringify(ev));
38
+ lines.push('[scheduler] exit code=0 (raw code=0 signal=null) duration=47s');
39
+ fs.writeFileSync(path.join(dir, `${slug}.log`), lines.join('\n') + '\n');
40
+ }
41
+
42
+ function writePrd(dir, slug, body) {
43
+ const text = `---\ntitle: Test PRD\ncwd: /tmp\nestimateMinutes: 30\n---\n${body}\n`;
44
+ fs.writeFileSync(path.join(dir, `${slug}.md`), text);
45
+ return path.join(dir, `${slug}.md`);
46
+ }
47
+
48
+ function makeTmpGitRepo() {
49
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-commit-evidence-repo-'));
50
+ execFileSync('git', ['init', '-q'], { cwd: dir });
51
+ execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
52
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
53
+ fs.writeFileSync(path.join(dir, 'README.md'), 'init\n');
54
+ execFileSync('git', ['add', 'README.md'], { cwd: dir });
55
+ execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir });
56
+ return dir;
57
+ }
58
+
59
+ /**
60
+ * Build a run log with:
61
+ * - padding events so a late is_error lands in the final 20%
62
+ * - a late is_error tool_result (main-agent, no self-recovery)
63
+ * - optionally a harness-emitted commit-evidence line
64
+ * - a SCHEDULER_VERDICT: PASS result event
65
+ */
66
+ function buildRunEvents({ includeCommitEvidence, evidenceForm = 'vcs_state_changed' }) {
67
+ const events = [];
68
+ for (let k = 0; k < 8; k++) {
69
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
70
+ { type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
71
+ events.push({ type: 'user', message: { role: 'user', content: [
72
+ { type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
73
+ }
74
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
75
+ { type: 'tool_use', id: 'tbad', name: 'Agent', input: { description: 'run code review agent' } }] } });
76
+ events.push({ type: 'user', message: { role: 'user', content: [
77
+ { type: 'tool_result', tool_use_id: 'tbad',
78
+ content: "Agent type 'code-reviewer' not found. Available agents: architect, …", is_error: true }] } });
79
+
80
+ if (includeCommitEvidence) {
81
+ if (evidenceForm === 'vcs_state_changed') {
82
+ events.push({ type: 'system', subtype: 'vcs_state_changed', kind: 'commit', cwd: '/tmp' });
83
+ } else {
84
+ events.push({
85
+ type: 'user',
86
+ message: { role: 'user', content: [
87
+ { type: 'tool_result', tool_use_id: 'tcommit', content: 'committed', is_error: false }] },
88
+ tool_use_result: { gitOperation: { commit: { sha: '6ecbd40', kind: 'committed' } } },
89
+ });
90
+ }
91
+ }
92
+
93
+ events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' });
94
+ return events;
95
+ }
96
+
97
+ // ─── (1) regression case: non-git cwd + transcript evidence → clean ──────────
98
+
99
+ test('PASS + late is_error + transcript commit evidence + non-git cwd → clean (fallback fires)', async () => {
100
+ const tmp = makeTmpDir();
101
+ try {
102
+ const slug = '99-fix-e2e-needs-review-test';
103
+ writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: true }));
104
+ const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
105
+ const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
106
+ try {
107
+ const verdict = await verifyRun({
108
+ runDir: tmp,
109
+ prdPath,
110
+ queueEntry: { slug, status: 'running', cwd: nonGitCwd },
111
+ allJobs: [],
112
+ committedDuringRun: false,
113
+ });
114
+ assert.equal(verdict.verdict, 'clean', `expected clean via transcript fallback, got ${verdict.verdict}: ${verdict.reason}`);
115
+ assert.equal(verdict.downgradeTo, null);
116
+ const sidecar = JSON.parse(fs.readFileSync(path.join(tmp, `${slug}.verdicts.json`), 'utf8'));
117
+ assert.equal(sidecar.commitEvidenceSource, 'transcript', 'sidecar should record the fallback source');
118
+ } finally { rmdir(nonGitCwd); }
119
+ } finally { rmdir(tmp); }
120
+ });
121
+
122
+ test('PASS + late is_error + gitOperation.commit evidence + non-git cwd → clean (fallback fires)', async () => {
123
+ const tmp = makeTmpDir();
124
+ try {
125
+ const slug = '99-fix-e2e-needs-review-test-alt';
126
+ writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: true, evidenceForm: 'gitOperation' }));
127
+ const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
128
+ const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
129
+ try {
130
+ const verdict = await verifyRun({
131
+ runDir: tmp,
132
+ prdPath,
133
+ queueEntry: { slug, status: 'running', cwd: nonGitCwd },
134
+ allJobs: [],
135
+ committedDuringRun: false,
136
+ });
137
+ assert.equal(verdict.verdict, 'clean', `expected clean via transcript fallback, got ${verdict.verdict}: ${verdict.reason}`);
138
+ } finally { rmdir(nonGitCwd); }
139
+ } finally { rmdir(tmp); }
140
+ });
141
+
142
+ // ─── (2) no evidence lines → fallback must not fire, still transcript_errors ──
143
+
144
+ test('PASS + late is_error + NO transcript evidence + non-git cwd → transcript_errors (no fallback)', async () => {
145
+ const tmp = makeTmpDir();
146
+ try {
147
+ const slug = '99-fix-no-evidence';
148
+ writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: false }));
149
+ const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
150
+ const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
151
+ try {
152
+ const verdict = await verifyRun({
153
+ runDir: tmp,
154
+ prdPath,
155
+ queueEntry: { slug, status: 'running', cwd: nonGitCwd },
156
+ allJobs: [],
157
+ committedDuringRun: false,
158
+ });
159
+ assert.equal(verdict.verdict, 'transcript_errors', `expected transcript_errors without evidence, got ${verdict.verdict}: ${verdict.reason}`);
160
+ assert.equal(verdict.downgradeTo, 'needs_review');
161
+ } finally { rmdir(nonGitCwd); }
162
+ } finally { rmdir(tmp); }
163
+ });
164
+
165
+ // ─── (3) git-repo cwd + evidence + committedDuringRun:false → fallback must not apply ──
166
+
167
+ test('PASS + late is_error + transcript evidence + GIT-REPO cwd + committedDuringRun:false → transcript_errors (false-PASS guard)', async () => {
168
+ const tmp = makeTmpDir();
169
+ const repo = makeTmpGitRepo();
170
+ try {
171
+ const slug = '99-fix-git-repo-cwd';
172
+ writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: true }));
173
+ const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
174
+ const verdict = await verifyRun({
175
+ runDir: tmp,
176
+ prdPath,
177
+ queueEntry: { slug, status: 'running', cwd: repo },
178
+ allJobs: [],
179
+ committedDuringRun: false,
180
+ });
181
+ assert.equal(verdict.verdict, 'transcript_errors', `fallback must not apply when git ancestry is observable, got ${verdict.verdict}: ${verdict.reason}`);
182
+ assert.equal(verdict.downgradeTo, 'needs_review');
183
+ } finally { rmdir(tmp); rmdir(repo); }
184
+ });
185
+
186
+ // ─── (4) PASS + transcript evidence + non-git cwd must not be pass_no_commit ──
187
+
188
+ test('PASS (no error) + transcript evidence + non-git cwd → clean, not pass_no_commit', async () => {
189
+ const tmp = makeTmpDir();
190
+ try {
191
+ const slug = '99-fix-clean-pass';
192
+ const events = [
193
+ { type: 'assistant', message: { role: 'assistant', content: [
194
+ { type: 'tool_use', id: 't0', name: 'Bash', input: { description: 'run tests' } }] } },
195
+ { type: 'user', message: { role: 'user', content: [
196
+ { type: 'tool_result', tool_use_id: 't0', content: '535 passed', is_error: false }] } },
197
+ { type: 'system', subtype: 'vcs_state_changed', kind: 'commit', cwd: '/tmp' },
198
+ { type: 'result', subtype: 'success', result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' },
199
+ ];
200
+ writeLog(tmp, slug, events);
201
+ const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
202
+ const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
203
+ try {
204
+ const verdict = await verifyRun({
205
+ runDir: tmp,
206
+ prdPath,
207
+ queueEntry: { slug, status: 'running', cwd: nonGitCwd },
208
+ allJobs: [],
209
+ committedDuringRun: false,
210
+ });
211
+ assert.equal(verdict.verdict, 'clean', `expected clean, got ${verdict.verdict}: ${verdict.reason}`);
212
+ assert.notEqual(verdict.verdict, 'pass_no_commit');
213
+ } finally { rmdir(nonGitCwd); }
214
+ } finally { rmdir(tmp); }
215
+ });
@@ -8,7 +8,11 @@
8
8
 
9
9
  const { test } = require('node:test');
10
10
  const assert = require('node:assert/strict');
11
- const { buildInvestigationPrompt } = require('../scheduler.cjs');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { execFileSync } = require('node:child_process');
15
+ const { buildInvestigationPrompt, isGitRepoSync } = require('../scheduler.cjs');
12
16
 
13
17
  function makeArgs(overrides = {}) {
14
18
  return {
@@ -49,3 +53,40 @@ test('prompt includes the resolved job fields', () => {
49
53
  assert.match(prompt, /some log tail output/);
50
54
  assert.match(prompt, /05-fix-my-feature\.md/);
51
55
  });
56
+
57
+ // ─── cwd-must-be-git-repo-root guidance (99-fix-e2e-needs-review-test) ────────
58
+
59
+ test('prompt instructs that cwd must be the git repo root where the fix will land', () => {
60
+ const prompt = buildInvestigationPrompt(makeArgs());
61
+ assert.match(prompt, /cwd.*must be the git repo root/);
62
+ assert.match(prompt, /needs_review/);
63
+ });
64
+
65
+ test('prompt frontmatter block still declares exactly the four expected keys', () => {
66
+ const prompt = buildInvestigationPrompt(makeArgs());
67
+ const fenceMatch = prompt.match(/```\s*\n\s*---\n([\s\S]*?)\n\s*---\n\s*```/);
68
+ assert.ok(fenceMatch, 'expected a fenced frontmatter block in the prompt');
69
+ const keys = fenceMatch[1]
70
+ .split('\n')
71
+ .filter((line) => line.trim().length > 0)
72
+ .map((line) => line.split(':')[0].trim());
73
+ assert.deepEqual(keys, ['title', 'cwd', 'parallelGroup', 'estimateMinutes']);
74
+ });
75
+
76
+ // ─── isGitRepoSync (scheduler.cjs) ─────────────────────────────────────────────
77
+
78
+ test('isGitRepoSync: true for a real git repo, false for a non-repo dir, false for missing/undefined', () => {
79
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduler-isgitrepo-repo-'));
80
+ const nonRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduler-isgitrepo-plain-'));
81
+ try {
82
+ execFileSync('git', ['init', '-q'], { cwd: repo });
83
+ assert.equal(isGitRepoSync(repo), true);
84
+ assert.equal(isGitRepoSync(nonRepo), false);
85
+ assert.equal(isGitRepoSync(undefined), false);
86
+ assert.equal(isGitRepoSync(''), false);
87
+ assert.equal(isGitRepoSync('/definitely/does/not/exist'), false);
88
+ } finally {
89
+ fs.rmSync(repo, { recursive: true, force: true });
90
+ fs.rmSync(nonRepo, { recursive: true, force: true });
91
+ }
92
+ });
@@ -23,6 +23,7 @@ const path = require('node:path');
23
23
  let tmpHome;
24
24
  let originalHome;
25
25
  let executeJob;
26
+ let spawnJob;
26
27
  let PRDS_DIR;
27
28
 
28
29
  beforeAll(() => {
@@ -30,7 +31,7 @@ beforeAll(() => {
30
31
  tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-skip-'));
31
32
  process.env.HOME = tmpHome;
32
33
 
33
- ({ executeJob, PRDS_DIR } = require('../scheduler.cjs'));
34
+ ({ executeJob, spawnJob, PRDS_DIR } = require('../scheduler.cjs'));
34
35
 
35
36
  if (!PRDS_DIR.startsWith(tmpHome)) {
36
37
  throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
@@ -87,3 +88,55 @@ test('a job whose PRD source exists but is unreadable for a non-ENOENT reason st
87
88
  fs.rmSync(runDir, { recursive: true, force: true });
88
89
  }
89
90
  });
91
+
92
+ function registerActiveProject(cwd) {
93
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
94
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
95
+ fs.mkdirSync(slugDir, { recursive: true });
96
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
97
+ }
98
+
99
+ function writeProjectQueue(cwd, jobs) {
100
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
101
+ fs.mkdirSync(stateDir, { recursive: true });
102
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
103
+ return path.join(stateDir, 'queue.json');
104
+ }
105
+
106
+ // This is the actual regression case: PRD 812's fix only covered executeJob's
107
+ // own return value. The bug lived one layer up, in spawnJob's post-run
108
+ // handler, which only short-circuited on skipped === 'prd-archived' and let
109
+ // 'prd-missing' fall through into the verify path meant for real runs —
110
+ // downgrading a stale-queue-entry retire into a needs_review with a bogus RCA
111
+ // feedback item. Drive spawnJob itself (not just executeJob) so this covers
112
+ // the actual defect, not just the classification it depends on.
113
+ test("spawnJob's post-run handler retires a prd-missing skip as a plain completion, without running verify", async () => {
114
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-spawnjob-project-'));
115
+ registerActiveProject(projectCwd);
116
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-missing-spawnjob-run-'));
117
+ try {
118
+ const slug = `812-test-spawnjob-missing-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
119
+ const queuePath = writeProjectQueue(projectCwd, [
120
+ { slug, status: 'pending', cwd: projectCwd },
121
+ ]);
122
+
123
+ const job = { slug, cwd: projectCwd };
124
+ await spawnJob(job, 'run-spawnjob-missing', runDir, projectCwd);
125
+
126
+ const state = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
127
+ const row = state.jobs.find((j) => j.slug === slug);
128
+ expect(row).toBeDefined();
129
+ expect(row.status).toBe('completed');
130
+ expect(row.exitCode).toBe(0);
131
+ expect(row.error).toBeNull();
132
+ // No verify pass ran and no RCA feedback fired: a verified run always
133
+ // stamps a verify-derived status ('needs_review') or a landedCommit/verify
134
+ // field onto the row. Neither is present here — the row was completed by
135
+ // the early-return skip branch, not the verification block further down.
136
+ expect(row.status).not.toBe('needs_review');
137
+ expect(row.landedCommit).toBeUndefined();
138
+ } finally {
139
+ fs.rmSync(projectCwd, { recursive: true, force: true });
140
+ fs.rmSync(runDir, { recursive: true, force: true });
141
+ }
142
+ });
@@ -29,6 +29,12 @@ beforeEach(() => {
29
29
 
30
30
  afterEach(() => {
31
31
  fs.rmSync(cwd, { recursive: true, force: true });
32
+ // writeTranscript mkdir -p's this run's folder under the REAL
33
+ // ~/.claude/projects (that's where transcriptPath resolves to), so leaving
34
+ // it behind accumulates one phantom project directory per test run — 1760
35
+ // of them had piled up before this cleanup existed, and Home counted every
36
+ // one as a project. Remove it with the tmp cwd it mirrors.
37
+ fs.rmSync(path.dirname(transcriptPath(cwd, 'x')), { recursive: true, force: true });
32
38
  });
33
39
 
34
40
  function writeTranscript(cwdArg, sessionId, lines) {
@@ -36,6 +36,12 @@ beforeEach(() => {
36
36
 
37
37
  afterEach(() => {
38
38
  fs.rmSync(cwd, { recursive: true, force: true });
39
+ // writeTranscript mkdir -p's this run's folder under the REAL
40
+ // ~/.claude/projects (that's where transcriptPath resolves to), so leaving
41
+ // it behind accumulates one phantom project directory per test run. Thousands
42
+ // had piled up before this cleanup existed, and Home counted every one as a
43
+ // project. Remove it with the tmp cwd it mirrors.
44
+ fs.rmSync(path.dirname(transcriptPath(cwd, 'x')), { recursive: true, force: true });
39
45
  });
40
46
 
41
47
  function writeTranscript(cwdArg, sessionId, lines) {
@@ -24,6 +24,12 @@ beforeEach(() => {
24
24
 
25
25
  afterEach(() => {
26
26
  fs.rmSync(cwd, { recursive: true, force: true });
27
+ // writeTranscript mkdir -p's this run's folder under the REAL
28
+ // ~/.claude/projects (that's where transcriptPath resolves to), so leaving
29
+ // it behind accumulates one phantom project directory per test run. Thousands
30
+ // had piled up before this cleanup existed, and Home counted every one as a
31
+ // project. Remove it with the tmp cwd it mirrors.
32
+ fs.rmSync(path.dirname(transcriptPath(cwd, 'x')), { recursive: true, force: true });
27
33
  });
28
34
 
29
35
  function writeTranscript(cwdArg, sessionId, lines) {
@@ -891,6 +891,10 @@ module.exports = {
891
891
  splitStopSignal,
892
892
  parseContextUsageMarkdown,
893
893
  STOP_SENTINEL,
894
+ // Both instruction blocks are exported so the renderer's preamble splitter
895
+ // (lib/promptPreamble.ts) can be drift-tested against the REAL text rather
896
+ // than a copy — see chat-preamble-anchors.test.cjs.
897
+ STOP_SIGNAL_INSTRUCTION,
894
898
  CHAT_MODE_TRUTH_INSTRUCTION,
895
899
  KILL_CEILING_MS,
896
900
  KILL_CEILING_MIN,
@@ -70,6 +70,30 @@ function addAllowedRoot(dir) {
70
70
  if (dir) allowedRoots.add(path.resolve(dir));
71
71
  }
72
72
 
73
+ // PromptSession archive writes (state/promptSessions.ts's markCompleted ->
74
+ // window.api.config.writeJson(promptSessionArchivePath(...))) reach this
75
+ // generic IPC handler with no pty ever having spawned for their cwd (a
76
+ // chat-only Epic on a cold boot), same hazard prdCreate.cjs:152-155 already
77
+ // documents for PRD creation. Registering the root here — narrowly, only
78
+ // when the resolved path is actually under a project's
79
+ // session-manager-operations/prompt-sessions/ — mirrors that precedent for
80
+ // the one other write path (besides mergeActiveIndex's own) that needs it,
81
+ // without widening addAllowedRoot's use to every config:write-json call.
82
+ const PROMPT_SESSIONS_MARKER = `${path.sep}session-manager-operations${path.sep}prompt-sessions${path.sep}`;
83
+
84
+ function registerPromptSessionsRoot(p) {
85
+ if (!p || typeof p !== 'string') return;
86
+ let abs;
87
+ try {
88
+ abs = path.resolve(expandHome(p));
89
+ } catch {
90
+ return;
91
+ }
92
+ const idx = abs.indexOf(PROMPT_SESSIONS_MARKER);
93
+ if (idx === -1) return;
94
+ addAllowedRoot(abs.slice(0, idx));
95
+ }
96
+
73
97
  function validatePath(abs) {
74
98
  const real = realResolve(abs);
75
99
  for (const root of allowedRoots) {
@@ -105,10 +129,7 @@ function validateWrite(realAbs) {
105
129
  (p) => realAbs === p || realAbs.startsWith(p + path.sep)
106
130
  );
107
131
  if (inWritePrefix) return;
108
- // Also allowed inside a registered project root's .claude/ subtree, OR its
109
- // tests/fixtures/browser-capture/ subtree (PRD 407 "PRD fixture" capture
110
- // destination — narrowly scoped to that one path segment, not a general
111
- // project-root write grant).
132
+ // Also allowed inside a registered project root's .claude/ subtree.
112
133
  for (const root of allowedRoots) {
113
134
  if (root === os.homedir()) continue;
114
135
  let realRoot;
@@ -118,24 +139,6 @@ function validateWrite(realAbs) {
118
139
  if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
119
140
  return;
120
141
  }
121
- const fixturesSub = path.join(realRoot, 'tests', 'fixtures', 'browser-capture');
122
- if (realAbs === fixturesSub || realAbs.startsWith(fixturesSub + path.sep)) {
123
- return;
124
- }
125
- // PRD 410 Recorder → Playwright-spec export: narrowly scoped to
126
- // tests/e2e/ (matches the repo's existing e2e spec location), not a
127
- // general project-root write grant.
128
- const e2eSub = path.join(realRoot, 'tests', 'e2e');
129
- if (realAbs === e2eSub || realAbs.startsWith(e2eSub + path.sep)) {
130
- return;
131
- }
132
- // Browser tab scratch saves (DOM captures, screenshots, recorded
133
- // flows): narrowly scoped to session-manager-operations/browser/,
134
- // this repo's existing per-project artifact-store convention.
135
- const browserSub = path.join(realRoot, 'session-manager-operations', 'browser');
136
- if (realAbs === browserSub || realAbs.startsWith(browserSub + path.sep)) {
137
- return;
138
- }
139
142
  // PromptSession persistence (active-index.json + per-session archives,
140
143
  // promptSessions.ts) and the scheduler's own read-modify-write of that
141
144
  // same active index (promptSessionEvents.cjs, PRD 814) — narrowly
@@ -438,7 +441,10 @@ function registerConfigHandlers() {
438
441
  ipcMain.handle('config:read-text', v(s.configPath, ({ path: p }) => readText(p)));
439
442
  // `writer` is the renderer's declared owner id for the single-writer law
440
443
  // (lib/opsOwnership.cjs). Ignored outside the ops root; required inside it.
441
- ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data, writer }) => writeJson(p, data, { writer })));
444
+ ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data, writer }) => {
445
+ registerPromptSessionsRoot(p);
446
+ return writeJson(p, data, { writer });
447
+ }));
442
448
  ipcMain.handle('config:write-text', v(s.configWriteText, ({ path: p, text, writer }) => writeTextAtomic(p, text, { writer })));
443
449
  ipcMain.handle('config:list-dir', v(s.configListDir, ({ path: p, opts }) => listDir(p, opts || {})));
444
450
  ipcMain.handle('config:exists', v(s.configPath, ({ path: p }) => exists(p)));
@@ -469,6 +475,7 @@ module.exports = {
469
475
  listDir,
470
476
  exists,
471
477
  addAllowedRoot,
478
+ registerPromptSessionsRoot,
472
479
  validatePath,
473
480
  validateWrite,
474
481
  };
@@ -318,6 +318,21 @@ async function deleteEntry(filePath) {
318
318
  }
319
319
  }
320
320
 
321
+ /**
322
+ * Binary-safe atomic write (formerly browser:save-binary). Routes through
323
+ * config.cjs's writeBinaryAtomic so the single-writer law still applies to
324
+ * ops-root destinations; `writer` is the renderer's declared owner id.
325
+ */
326
+ async function saveBinary(filePath, base64, writer) {
327
+ const { writeBinaryAtomic } = require('./config.cjs');
328
+ try {
329
+ await writeBinaryAtomic(filePath, Buffer.from(base64, 'base64'), { writer });
330
+ return { ok: true };
331
+ } catch (e) {
332
+ return { ok: false, error: e && e.message ? e.message : String(e) };
333
+ }
334
+ }
335
+
321
336
  async function openExternal(filePath) {
322
337
  let resolved;
323
338
  try { resolved = validateHomePath(filePath); }
@@ -385,6 +400,10 @@ function registerFilesHandlers() {
385
400
  const { path: p } = filesPath.parse(payload);
386
401
  return deleteEntry(p);
387
402
  });
403
+ ipcMain.handle('files:save-binary', (_e, payload) => {
404
+ const { path: p, base64, writer } = schemas.filesSaveBinary.parse(payload);
405
+ return saveBinary(p, base64, writer);
406
+ });
388
407
  // files:open-external / files:show-in-finder consolidated into shell:open
389
408
  // (index.cjs, as: 'openPath' / 'revealPath'). The openExternal/showInFinder
390
409
  // functions are exported for that handler to call.
@@ -406,4 +425,5 @@ module.exports = {
406
425
  deleteEntry,
407
426
  duplicateEntry,
408
427
  duplicateNameFor,
428
+ saveBinary,
409
429
  };