claude-code-session-manager 0.60.0 → 0.62.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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * scheduler-verify-prd-path.test.cjs — regression test for PRD 991: the
3
+ * verifier's `prdPath` must resolve to a REAL, existing file for an
4
+ * Epic-scoped PRD, both live and archived.
5
+ *
6
+ * Before this PRD, `verifyRun`'s two call sites (the post-run verify in
7
+ * spawnJob and reverifyNeedsReview) built `prdPath` via `prdPathForJob`,
8
+ * which resolves to the RETIRED flat
9
+ * `<cwd>/session-manager-operations/scheduler/prds/` dir — a dir that holds
10
+ * only zero-byte `.reserved-NNN` stubs for every project that has migrated
11
+ * to Epic-scoped PRDs (i.e. every project today). The verifier therefore
12
+ * silently lost access to the PRD body (dependsOn checks, soak-floor dates,
13
+ * deliverable-path checks, the merge-main PR-number exemption) on every
14
+ * single run.
15
+ *
16
+ * This test exercises actual path resolution against a temp tree — no
17
+ * resolver-stubbing — the same class of gap that let this bug (and PRD
18
+ * 985's sibling notify-path bug) ship green twice.
19
+ *
20
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-verify-prd-path.test.cjs
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ import { test, expect, beforeAll, afterAll } from 'vitest';
26
+ const fs = require('node:fs');
27
+ const os = require('node:os');
28
+ const path = require('node:path');
29
+
30
+ let tmpHome;
31
+ let originalHome;
32
+ let resolveVerifyPrdPath;
33
+ let archivedPrdPathForJob;
34
+ let PRDS_DIR;
35
+
36
+ beforeAll(() => {
37
+ originalHome = process.env.HOME;
38
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-verify-prd-path-'));
39
+ process.env.HOME = tmpHome;
40
+
41
+ // Load AFTER HOME is stubbed — scheduler.cjs snapshots os.homedir() into
42
+ // PRDS_DIR at module load, not lazily (see scheduler-find-prd-dir.test.cjs).
43
+ ({ resolveVerifyPrdPath, archivedPrdPathForJob, PRDS_DIR } = require('../scheduler.cjs'));
44
+
45
+ if (!PRDS_DIR.startsWith(tmpHome)) {
46
+ throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
47
+ }
48
+
49
+ // allProjectCwds()/activeProjectCwds() discover project cwds by scanning
50
+ // ~/.claude/projects/*/*.jsonl for a `cwd` field — fake one project with a
51
+ // transcript pointing at a real on-disk cwd.
52
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
53
+ const projDir = path.join(projectsDir, 'fake-project-slug');
54
+ fs.mkdirSync(projDir, { recursive: true });
55
+ fs.writeFileSync(
56
+ path.join(projDir, 'session.jsonl'),
57
+ `${JSON.stringify({ cwd: projectCwd() })}\n`,
58
+ 'utf8',
59
+ );
60
+ });
61
+
62
+ afterAll(() => {
63
+ process.env.HOME = originalHome;
64
+ fs.rmSync(tmpHome, { recursive: true, force: true });
65
+ });
66
+
67
+ function projectCwd() {
68
+ return path.join(tmpHome, 'fake-project-cwd');
69
+ }
70
+
71
+ test('resolveVerifyPrdPath resolves a LIVE Epic-scoped PRD to a real existing path', async () => {
72
+ const cwd = projectCwd();
73
+ const epicId = 'test-epic-live';
74
+ const slug = `991-test-live-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
75
+ const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds');
76
+ fs.mkdirSync(prdsDir, { recursive: true });
77
+ const prdPath = path.join(prdsDir, `${slug}.md`);
78
+ fs.writeFileSync(prdPath, '---\ntitle: live fixture\n---\n\n# Goal\n\ntest\n', 'utf8');
79
+
80
+ const resolved = await resolveVerifyPrdPath({ slug, cwd });
81
+ expect(resolved).toBe(prdPath);
82
+ expect(fs.existsSync(resolved)).toBe(true);
83
+ });
84
+
85
+ test('resolveVerifyPrdPath returns null for an ARCHIVED-only PRD; archivedPrdPathForJob fills the gap', async () => {
86
+ const cwd = projectCwd();
87
+ const epicId = 'test-epic-archived';
88
+ const slug = `991-test-archived-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
89
+ const epicDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId);
90
+ const archiveDir = path.join(epicDir, 'prds-archived');
91
+ fs.mkdirSync(archiveDir, { recursive: true });
92
+ const archivedPath = path.join(archiveDir, `${slug}.md`);
93
+ fs.writeFileSync(archivedPath, '---\ntitle: archived fixture\n---\n\n# Goal\n\ntest\n', 'utf8');
94
+
95
+ const job = { slug, cwd };
96
+ const live = await resolveVerifyPrdPath(job);
97
+ expect(live).toBe(null);
98
+
99
+ const fallback = live ?? archivedPrdPathForJob(job);
100
+ expect(fallback).toBe(archivedPath);
101
+ expect(fs.existsSync(fallback)).toBe(true);
102
+ });
103
+
104
+ test('resolveVerifyPrdPath returns null for a slug that exists nowhere (never falls back to the retired flat dir)', async () => {
105
+ const cwd = projectCwd();
106
+ const slug = `991-test-nonexistent-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
107
+ const resolved = await resolveVerifyPrdPath({ slug, cwd });
108
+ expect(resolved).toBe(null);
109
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * scheduler-worktree-exec-cwd.test.cjs — PRD 994, LOAD-BEARING acceptance
3
+ * criterion: when a job is isolated into a worktree, its PRD file, queue
4
+ * state, and run logs must still resolve to the MAIN tree, never the
5
+ * worktree — `session-manager-operations/` is resolved from a `cwd` param
6
+ * (lib/prdLocations.cjs), so a naive cwd swap would give the job a
7
+ * different, empty ops root and orphan its own PRD.
8
+ *
9
+ * Drives executeJob's real `execCwd` parameter (the 5th arg scheduler.cjs
10
+ * passes when a worktree was created) with a stub `claude` binary, proving:
11
+ * 1. the child process's OWN cwd is the worktree dir (execCwd) — verified
12
+ * by having the stub write a marker file to `process.cwd()`;
13
+ * 2. the PRD source is still found via job.cwd (the main tree) even though
14
+ * the worktree dir has no PRD file in it at all;
15
+ * 3. the run's meta.json still records job.cwd, not the worktree dir.
16
+ *
17
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
23
+ const fs = require('node:fs');
24
+ const os = require('node:os');
25
+ const path = require('node:path');
26
+
27
+ let tmpHome;
28
+ let originalHome;
29
+ let executeJob;
30
+
31
+ beforeAll(() => {
32
+ originalHome = process.env.HOME;
33
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-execcwd-'));
34
+ process.env.HOME = tmpHome;
35
+ ({ executeJob } = require('../scheduler.cjs'));
36
+ });
37
+
38
+ afterAll(() => {
39
+ process.env.HOME = originalHome;
40
+ fs.rmSync(tmpHome, { recursive: true, force: true });
41
+ });
42
+
43
+ afterEach(() => {
44
+ delete process.env.SM_CLAUDE_BIN;
45
+ });
46
+
47
+ // Stub `claude` binary: writes a marker file into its OWN process.cwd() (proving
48
+ // where it actually ran), captures the '-p' prompt, then emits a stream-json
49
+ // success result and exits 0.
50
+ function writeClaudeStub() {
51
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
52
+ const body = `
53
+ const fs = require('fs');
54
+ const path = require('path');
55
+ fs.writeFileSync(path.join(process.cwd(), 'ran-here.marker'), 'yes', 'utf8');
56
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
57
+ process.exit(0);
58
+ `;
59
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
60
+ return stubPath;
61
+ }
62
+
63
+ test('executeJob spawns the child in execCwd (worktree) while resolving the PRD from job.cwd (main tree)', async () => {
64
+ const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-main-'));
65
+ const worktreeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-exec-'));
66
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
67
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-worktree-run-'));
68
+
69
+ try {
70
+ const slug = `994-test-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
71
+ const prdsDir = path.join(mainCwd, 'session-manager-operations', 'scheduler', 'prds');
72
+ fs.mkdirSync(prdsDir, { recursive: true });
73
+ const bodyText = 'Do the isolated-worktree thing.';
74
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), bodyText, 'utf8');
75
+
76
+ process.env.SM_CLAUDE_BIN = writeClaudeStub();
77
+
78
+ const job = { slug, cwd: mainCwd };
79
+ const result = await executeJob(job, runDir, mainCwd, () => Promise.resolve(), worktreeCwd);
80
+
81
+ expect(result.exitCode).toBe(0);
82
+
83
+ // 1. The child process really ran with cwd = worktreeCwd, not mainCwd.
84
+ expect(fs.existsSync(path.join(worktreeCwd, 'ran-here.marker'))).toBe(true);
85
+ expect(fs.existsSync(path.join(mainCwd, 'ran-here.marker'))).toBe(false);
86
+
87
+ // 2. The PRD source itself is untouched — resolved via job.cwd, not execCwd.
88
+ const onDiskPrd = fs.readFileSync(path.join(prdsDir, `${slug}.md`), 'utf8');
89
+ expect(onDiskPrd).toBe(bodyText);
90
+
91
+ // 3. The run's meta.json records job.cwd (the main tree), not the worktree.
92
+ const metaPath = path.join(runDir, `${slug}.meta.json`);
93
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
94
+ expect(meta.cwd).toBe(mainCwd);
95
+ } finally {
96
+ fs.rmSync(mainCwd, { recursive: true, force: true });
97
+ fs.rmSync(worktreeCwd, { recursive: true, force: true });
98
+ fs.rmSync(runDir, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ test('executeJob falls back to job.cwd for the spawn cwd when no execCwd is supplied (unchanged behaviour)', async () => {
103
+ const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-fallback-'));
104
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
105
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-worktree-run2-'));
106
+ try {
107
+ const slug = `994-test-fallback-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
108
+ const prdsDir = path.join(mainCwd, 'session-manager-operations', 'scheduler', 'prds');
109
+ fs.mkdirSync(prdsDir, { recursive: true });
110
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'no worktree here', 'utf8');
111
+ process.env.SM_CLAUDE_BIN = writeClaudeStub();
112
+
113
+ const job = { slug, cwd: mainCwd };
114
+ const result = await executeJob(job, runDir, mainCwd, () => Promise.resolve());
115
+
116
+ expect(result.exitCode).toBe(0);
117
+ expect(fs.existsSync(path.join(mainCwd, 'ran-here.marker'))).toBe(true);
118
+ } finally {
119
+ fs.rmSync(mainCwd, { recursive: true, force: true });
120
+ fs.rmSync(runDir, { recursive: true, force: true });
121
+ }
122
+ });
@@ -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) {
@@ -438,7 +462,10 @@ function registerConfigHandlers() {
438
462
  ipcMain.handle('config:read-text', v(s.configPath, ({ path: p }) => readText(p)));
439
463
  // `writer` is the renderer's declared owner id for the single-writer law
440
464
  // (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 })));
465
+ ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data, writer }) => {
466
+ registerPromptSessionsRoot(p);
467
+ return writeJson(p, data, { writer });
468
+ }));
442
469
  ipcMain.handle('config:write-text', v(s.configWriteText, ({ path: p, text, writer }) => writeTextAtomic(p, text, { writer })));
443
470
  ipcMain.handle('config:list-dir', v(s.configListDir, ({ path: p, opts }) => listDir(p, opts || {})));
444
471
  ipcMain.handle('config:exists', v(s.configPath, ({ path: p }) => exists(p)));
@@ -469,6 +496,7 @@ module.exports = {
469
496
  listDir,
470
497
  exists,
471
498
  addAllowedRoot,
499
+ registerPromptSessionsRoot,
472
500
  validatePath,
473
501
  validateWrite,
474
502
  };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * coldBootPromptSessionsWrite.test.cjs — regression coverage for the
3
+ * "Epics marked Completed come back Open" bug.
4
+ *
5
+ * config.cjs's allowedRoots starts as just {os.homedir()}, and
6
+ * validateWrite() explicitly skips the homedir root (a project cwd must be
7
+ * registered via addAllowedRoot() before writeJson()/writeTextAtomic() will
8
+ * accept a write under it). addAllowedRoot's only callers used to be pty.cjs
9
+ * (Terminal spawn) and lib/prdCreate.cjs — never the Epic persistence path —
10
+ * so on a cold boot, before any Terminal/PRD activity for a project, both
11
+ * mergeActiveIndex's own write and the renderer's Epic-archive write
12
+ * (config:write-json of promptSessionArchivePath) were silently rejected.
13
+ *
14
+ * Both cases below use a tmp dir INSIDE the homedir so validatePath (the
15
+ * READ boundary, satisfied by the homedir root alone) succeeds while
16
+ * validateWrite (which skips the homedir root, per config.cjs:113) still
17
+ * requires an explicit per-project root registration — reproducing the
18
+ * exact cold-boot gap without needing an out-of-home tmp root.
19
+ *
20
+ * Run: timeout 300 npx vitest run src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ import { test, expect, afterEach } from 'vitest';
26
+ const fsp = require('node:fs/promises');
27
+ const os = require('node:os');
28
+ const path = require('node:path');
29
+ const { mergeActiveIndex } = require('../activeIndexMerge.cjs');
30
+ const { readActiveIndex } = require('../epicMint.cjs');
31
+ const config = require('../../config.cjs');
32
+
33
+ const tmpDirs = [];
34
+ afterEach(async () => {
35
+ while (tmpDirs.length) {
36
+ const d = tmpDirs.pop();
37
+ await fsp.rm(d, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ async function mkColdBootCwd(prefix) {
42
+ // Deliberately under homedir (so validatePath's read boundary passes via
43
+ // the default homedir root) and deliberately NOT registered via
44
+ // config.addAllowedRoot() — simulates the exact state of allowedRoots
45
+ // right after app boot, before any Terminal/PRD activity for this project.
46
+ const cwd = await fsp.mkdtemp(path.join(os.homedir(), prefix));
47
+ tmpDirs.push(cwd);
48
+ return cwd;
49
+ }
50
+
51
+ function session(id, overrides = {}) {
52
+ return {
53
+ id,
54
+ cwd: overrides.cwd || '/proj',
55
+ goalText: overrides.goalText || `goal for ${id}`,
56
+ claudeSessionId: overrides.claudeSessionId || `claude-${id}`,
57
+ status: overrides.status || 'active',
58
+ createdAt: overrides.createdAt || '2026-08-06T13:48:00.000Z',
59
+ completedAt: overrides.completedAt ?? null,
60
+ };
61
+ }
62
+
63
+ test('mergeActiveIndex writes active-index.json on a cold boot with only the homedir registered', async () => {
64
+ const cwd = await mkColdBootCwd('.sm-test-activeindex-coldboot-');
65
+ const s = session('psess-coldboot-1');
66
+
67
+ await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: {} });
68
+
69
+ const index = readActiveIndex(cwd);
70
+ expect(index.sessions[s.id]).toEqual(s);
71
+ });
72
+
73
+ // Mirrors what the 'config:write-json' IPC handler now does before calling
74
+ // writeJson (config.cjs's registerPromptSessionsRoot) — the main-process fix
75
+ // for state/promptSessions.ts's markCompleted -> writeJson(promptSessionArchivePath).
76
+ test('an Epic archive write succeeds on a cold boot with only the homedir registered', async () => {
77
+ const cwd = await mkColdBootCwd('.sm-test-archive-coldboot-');
78
+ const archivePath = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'psess-archived-1.json');
79
+ const archive = { ...session('psess-archived-1', { status: 'completed' }), completedAt: '2026-08-06T14:00:00.000Z' };
80
+
81
+ config.registerPromptSessionsRoot(archivePath);
82
+ await config.writeJson(archivePath, archive, { writer: 'epics' });
83
+
84
+ const result = await config.readJson(archivePath);
85
+ expect(result.exists).toBe(true);
86
+ expect(result.data).toEqual(archive);
87
+ });
@@ -0,0 +1,197 @@
1
+ /**
2
+ * jobWorktree.test.cjs — PRD 994: per-job `git worktree` isolation.
3
+ *
4
+ * Exercises the real `git worktree` plumbing against throwaway repos under
5
+ * os.tmpdir() (fast — no network, no Electron). Covers: create/integrate/
6
+ * cleanup happy path, the two documented fallback reasons (non-git cwd, dirty
7
+ * base tree), the concurrency cap, the kill-switch env var, a real merge
8
+ * conflict surfacing as an explicit integration failure (branch preserved,
9
+ * not silently discarded), and boot-time reconciliation of a leaked worktree.
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/jobWorktree.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 jobWorktree = require('../jobWorktree.cjs');
22
+
23
+ let tmpRoot;
24
+ let repoCwd;
25
+ let originalDisable;
26
+ let originalMax;
27
+
28
+ function git(args, cwd) {
29
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
30
+ }
31
+
32
+ function initRepo(dir) {
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ git(['init', '-q'], dir);
35
+ git(['config', 'user.email', 'test@example.com'], dir);
36
+ git(['config', 'user.name', 'Test'], dir);
37
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
38
+ git(['add', '-A'], dir);
39
+ git(['commit', '-q', '-m', 'initial'], dir);
40
+ }
41
+
42
+ beforeEach(() => {
43
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-jobworktree-'));
44
+ repoCwd = path.join(tmpRoot, 'repo');
45
+ initRepo(repoCwd);
46
+ originalDisable = process.env.SM_JOB_WORKTREE_DISABLE;
47
+ originalMax = process.env.SM_JOB_WORKTREE_MAX;
48
+ delete process.env.SM_JOB_WORKTREE_DISABLE;
49
+ delete process.env.SM_JOB_WORKTREE_MAX;
50
+ jobWorktree._resetActiveWorktreeCountForTests(0);
51
+ });
52
+
53
+ afterEach(async () => {
54
+ if (originalDisable === undefined) delete process.env.SM_JOB_WORKTREE_DISABLE;
55
+ else process.env.SM_JOB_WORKTREE_DISABLE = originalDisable;
56
+ if (originalMax === undefined) delete process.env.SM_JOB_WORKTREE_MAX;
57
+ else process.env.SM_JOB_WORKTREE_MAX = originalMax;
58
+ // Best-effort: prune any worktree this test created before removing the repo.
59
+ try { await jobWorktree.reconcileWorktreesOnBoot([repoCwd]); } catch { /* ignore */ }
60
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
61
+ });
62
+
63
+ test('createJobWorktree creates a linked worktree on a fresh branch from HEAD', async () => {
64
+ const slug = 'test-slug-create';
65
+ const result = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug });
66
+ expect(result.ok).toBe(true);
67
+ expect(fs.existsSync(result.dir)).toBe(true);
68
+ expect(fs.existsSync(path.join(result.dir, 'README.md'))).toBe(true);
69
+ expect(result.branch).toBe(`sm-job/${slug}`);
70
+
71
+ await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
72
+ expect(fs.existsSync(result.dir)).toBe(false);
73
+ });
74
+
75
+ test('a commit made inside the worktree becomes visible on the main tree HEAD after integrateJobBranch', async () => {
76
+ const slug = 'test-slug-integrate';
77
+ const worktree = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug });
78
+ expect(worktree.ok).toBe(true);
79
+
80
+ fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'job output\n', 'utf8');
81
+ git(['add', '-A'], worktree.dir);
82
+ git(['commit', '-q', '-m', 'job commit'], worktree.dir);
83
+ const jobCommitSha = git(['rev-parse', 'HEAD'], worktree.dir).trim();
84
+
85
+ const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
86
+ const outcome = await jobWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
87
+ expect(outcome.ok).toBe(true);
88
+ expect(outcome.integrated).toBe(true);
89
+
90
+ const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
91
+ expect(headAfter).not.toBe(headBefore);
92
+ // Fast-forward, since the main tree never advanced independently.
93
+ expect(headAfter).toBe(jobCommitSha);
94
+ expect(fs.existsSync(path.join(repoCwd, 'new-file.txt'))).toBe(true);
95
+
96
+ await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
97
+ expect(fs.existsSync(worktree.dir)).toBe(false);
98
+ });
99
+
100
+ test('integrateJobBranch is a no-op (integrated:false) when the branch has no new commits', async () => {
101
+ const slug = 'test-slug-noop';
102
+ const worktree = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug });
103
+ const outcome = await jobWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
104
+ expect(outcome.ok).toBe(true);
105
+ expect(outcome.integrated).toBe(false);
106
+ await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
107
+ });
108
+
109
+ test('a real merge conflict surfaces as an explicit integration failure and preserves the branch', async () => {
110
+ const slug = 'test-slug-conflict';
111
+ const worktree = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug });
112
+
113
+ // Job edits README.md in its worktree and commits.
114
+ fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
115
+ git(['add', '-A'], worktree.dir);
116
+ git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
117
+
118
+ // Main tree ALSO advances with a conflicting edit to the same line.
119
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
120
+ git(['add', '-A'], repoCwd);
121
+ git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
122
+
123
+ const outcome = await jobWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
124
+ expect(outcome.ok).toBe(false);
125
+ expect(outcome.reason).toMatch(/conflict|merge failed/i);
126
+
127
+ // cwd must not be left mid-merge.
128
+ const status = git(['status', '--porcelain'], repoCwd);
129
+ expect(status.trim()).toBe('');
130
+
131
+ // Branch is preserved (keepBranch) — the job's commit is not discarded.
132
+ await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
133
+ const branches = git(['branch', '--list', worktree.branch], repoCwd);
134
+ expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
135
+ });
136
+
137
+ test('falls back with a reason for a non-git cwd', async () => {
138
+ const nonGitDir = path.join(tmpRoot, 'not-a-repo');
139
+ fs.mkdirSync(nonGitDir, { recursive: true });
140
+ const result = await jobWorktree.createJobWorktree({ cwd: nonGitDir, slug: 'x' });
141
+ expect(result.ok).toBe(false);
142
+ expect(result.reason).toMatch(/not a git repository/);
143
+ });
144
+
145
+ test('falls back with a reason for a dirty base tree (never silently drops uncommitted human WIP)', async () => {
146
+ fs.writeFileSync(path.join(repoCwd, 'dirty.txt'), 'uncommitted\n', 'utf8');
147
+ const result = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
148
+ expect(result.ok).toBe(false);
149
+ expect(result.reason).toMatch(/uncommitted changes/);
150
+ });
151
+
152
+ test('falls back once the concurrency cap is reached, and recovers after cleanup', async () => {
153
+ process.env.SM_JOB_WORKTREE_MAX = '1';
154
+ const first = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-1' });
155
+ expect(first.ok).toBe(true);
156
+
157
+ const second = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-2' });
158
+ expect(second.ok).toBe(false);
159
+ expect(second.reason).toMatch(/cap reached/);
160
+
161
+ await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
162
+
163
+ const third = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-3' });
164
+ expect(third.ok).toBe(true);
165
+ await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: third.dir, branch: third.branch });
166
+ });
167
+
168
+ test('SM_JOB_WORKTREE_DISABLE=1 restores in-place behaviour', async () => {
169
+ process.env.SM_JOB_WORKTREE_DISABLE = '1';
170
+ const result = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
171
+ expect(result.ok).toBe(false);
172
+ expect(result.reason).toMatch(/SM_JOB_WORKTREE_DISABLE/);
173
+ });
174
+
175
+ test('reconcileWorktreesOnBoot removes a leaked worktree (simulated crash) and its branch', async () => {
176
+ const slug = 'test-slug-leak';
177
+ const worktree = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug });
178
+ expect(fs.existsSync(worktree.dir)).toBe(true);
179
+ // Simulate a crash: the in-memory counter resets (as it would on restart)
180
+ // and nothing calls cleanupJobWorktree — the checkout is left registered.
181
+ jobWorktree._resetActiveWorktreeCountForTests(0);
182
+
183
+ await jobWorktree.reconcileWorktreesOnBoot([repoCwd]);
184
+
185
+ expect(fs.existsSync(worktree.dir)).toBe(false);
186
+ const branches = git(['branch', '--list', worktree.branch], repoCwd);
187
+ expect(branches.trim()).toBe('');
188
+ });
189
+
190
+ test('reconcileWorktreesOnBoot never touches a worktree outside WORKTREE_ROOT', async () => {
191
+ const humanWorktreeDir = path.join(tmpRoot, 'human-made-worktree');
192
+ git(['worktree', 'add', '-b', 'human-branch', humanWorktreeDir, 'HEAD'], repoCwd);
193
+ await jobWorktree.reconcileWorktreesOnBoot([repoCwd]);
194
+ expect(fs.existsSync(humanWorktreeDir)).toBe(true);
195
+ // cleanup so afterEach's rm doesn't leave a dangling worktree registration.
196
+ git(['worktree', 'remove', '--force', humanWorktreeDir], repoCwd);
197
+ });
@@ -33,6 +33,7 @@
33
33
  * explicit "un-tombstone" path; today one would be dead code.
34
34
  */
35
35
 
36
+ const path = require('path');
36
37
  const { activeIndexPath, withPathLock } = require('./epicMint.cjs');
37
38
  const config = require('../config.cjs');
38
39
 
@@ -69,6 +70,14 @@ function compareEventsChainAware(a, b) {
69
70
  */
70
71
  async function mergeActiveIndex(cwd, { sessions: rawMemorySessions = {}, events: rawMemoryEvents = {}, removedIds = [] } = {}) {
71
72
  if (!cwd || typeof cwd !== 'string') throw new Error('mergeActiveIndex: cwd is required');
73
+ // Register this project's cwd as a write-allowed root (config.cjs's
74
+ // validateWrite gate) — mirrors the precedent + comment in
75
+ // lib/prdCreate.cjs:152-155. A chat-only Epic (headless claude -p, no
76
+ // Terminal PTY ever spawned for this cwd) reaches this merge on a cold
77
+ // boot before pty.cjs's addAllowedRoot call has ever run for it — without
78
+ // this, every active-index.json write for such a project fails with
79
+ // "Write outside allowed write boundaries".
80
+ config.addAllowedRoot(path.resolve(cwd));
72
81
  const filePath = activeIndexPath(cwd);
73
82
  // Null-prototype copies of the CALLER'S OWN payload too: an Epic id equal
74
83
  // to "__proto__" is a plain string key from the caller's perspective, but
@@ -76,26 +76,38 @@ async function buildContextDigest({ cwd, epicId, maxChars } = {}) {
76
76
  const TASK_RESTATEMENT = 'Your task is the PRD at the top of this prompt. Implement it now.';
77
77
 
78
78
  /**
79
- * composeExecutorPrompt({ prdBody, digestText, maxChars }) → string
79
+ * composeExecutorPrompt({ prdBody, digestText, finishProtocol, maxChars }) → string
80
80
  *
81
- * Orders the executor prompt so the PRD body is unambiguously the task and
82
- * the Epic digest is unambiguously subordinate background: PRD body first,
83
- * then the fenced digest (if any), then a one-line restatement of the task
84
- * last (recency matters — a prompt that ends in someone else's conversation
85
- * invites a conversational reply instead of a diff).
81
+ * Orders the executor prompt so the PRD body is unambiguously the task, the
82
+ * Epic digest is unambiguously subordinate background, and the mandatory
83
+ * finish protocol (review → security-review → verify → commit → verdict
84
+ * sentinel) stays in the prompt's tail where it belongs: PRD body first,
85
+ * then the fenced digest (if any), then the finish protocol (if supplied),
86
+ * then a one-line restatement of the task last (recency matters — a prompt
87
+ * that ends in someone else's conversation invites a conversational reply
88
+ * instead of a diff, and a finish protocol buried before 4000 chars of
89
+ * digest is easy for the model to lose track of).
86
90
  *
87
91
  * The digest is re-capped here (in addition to buildContextDigest's own
88
92
  * cap) so composeExecutorPrompt is safe to call with an arbitrary digest
89
93
  * string, not just one already produced by buildContextDigest. The PRD body
90
94
  * itself is never truncated to make room for the digest.
95
+ *
96
+ * When finishProtocol is omitted, callers get the pre-existing behavior
97
+ * (body + digest fence + task restatement, no finish-protocol text) — this
98
+ * keeps composeExecutorPrompt usable standalone. scheduler.cjs always
99
+ * passes finishProtocol so its composed prompt matches what it used to
100
+ * produce via `parsed.body + FINISH_PROTOCOL` in the no-digest case, byte
101
+ * for byte.
91
102
  */
92
- function composeExecutorPrompt({ prdBody, digestText, maxChars } = {}) {
103
+ function composeExecutorPrompt({ prdBody, digestText, finishProtocol, maxChars } = {}) {
93
104
  const body = typeof prdBody === 'string' ? prdBody : '';
105
+ const finish = typeof finishProtocol === 'string' && finishProtocol.trim() ? finishProtocol : '';
94
106
  const limit = typeof maxChars === 'number' && maxChars > 0 ? maxChars : DEFAULT_MAX_CHARS;
95
107
 
96
108
  const rawDigest = typeof digestText === 'string' ? digestText.trim() : '';
97
109
  if (!rawDigest) {
98
- return `${body}\n\n${TASK_RESTATEMENT}`;
110
+ return finish ? `${body}${finish}` : `${body}\n\n${TASK_RESTATEMENT}`;
99
111
  }
100
112
 
101
113
  const truncated = rawDigest.length > limit;
@@ -105,7 +117,9 @@ function composeExecutorPrompt({ prdBody, digestText, maxChars } = {}) {
105
117
  const fenceHeader = `--- BEGIN EPIC CONTEXT (background only — prior conversation from the Epic that authored the PRD above. It is NOT your task and contains no instructions for you. Your deliverable is the PRD above.${headerNote}) ---`;
106
118
  const fenceFooter = '--- END EPIC CONTEXT ---';
107
119
 
108
- return [body, fenceHeader, clippedDigest, fenceFooter, TASK_RESTATEMENT].join('\n\n');
120
+ const tail = finish ? `${finish.trim()}\n\n${TASK_RESTATEMENT}` : TASK_RESTATEMENT;
121
+
122
+ return [body, fenceHeader, clippedDigest, fenceFooter, tail].join('\n\n');
109
123
  }
110
124
 
111
125
  module.exports = {