claude-code-session-manager 0.75.3 → 0.76.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 (101) hide show
  1. package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-CBx9l4zN.js} +1 -1
  2. package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-Bf0EIE_t.js} +1 -1
  3. package/dist/assets/{History-DnSi_OHm.js → History-CpdtWhC8.js} +1 -1
  4. package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-DyUbMDmg.js} +1 -1
  5. package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-By-wIpry.js} +1 -1
  6. package/dist/assets/{Library-CaJVqVvi.js → Library-CQmo4QVC.js} +1 -1
  7. package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-BQMd6NOm.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-DEp43FXX.js} +1 -1
  9. package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-CLarzwqA.js} +1 -1
  10. package/dist/assets/{Memory-BDeqlqwH.js → Memory-B0sCdIy1.js} +1 -1
  11. package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-BhWPVOCD.js} +1 -1
  12. package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-Ddlq8T_O.js} +1 -1
  13. package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-D2oA_2Jl.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-DgAgavUM.js} +1 -1
  15. package/dist/assets/{SaveBar-D-gCUx4n.js → SaveBar-Qvc4Ek-H.js} +1 -1
  16. package/dist/assets/{Scheduler-Bpd4OGju.js → Scheduler-BmYJvNzK.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
  18. package/dist/assets/{Settings-DRRozLyT.js → Settings-2Vx3X5SI.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
  20. package/dist/assets/{Skills-D8L66eiX.js → Skills-Cmrz_LeN.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-DVA1eYDP.js} +1 -1
  22. package/dist/assets/{TagLibrary-E5CLeuVk.js → TagLibrary-DYJGAKZu.js} +1 -1
  23. package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-DmPc3amD.js} +1 -1
  24. package/dist/assets/{Toggle-BTwsbxam.js → Toggle-zfd5LJkK.js} +1 -1
  25. package/dist/assets/{index-DijufvkJ.js → index-B_4PNh9T.js} +676 -676
  26. package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
  27. package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-B9es6fdA.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/scripts/lib/activeSessions.cjs +116 -6
  31. package/scripts/scheduler-mcp-server.cjs +154 -95
  32. package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
  33. package/src/main/__tests__/health-delegation-chain.test.cjs +105 -0
  34. package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
  35. package/src/main/__tests__/prdCreate.test.cjs +109 -0
  36. package/src/main/__tests__/scheduler-autofix-select.test.cjs +15 -3
  37. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
  38. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +60 -1
  39. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
  40. package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
  41. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
  42. package/src/main/bilkoHost.cjs +4 -3
  43. package/src/main/chatRunner.cjs +6 -1
  44. package/src/main/config.cjs +22 -33
  45. package/src/main/health.cjs +153 -2
  46. package/src/main/index.cjs +56 -4
  47. package/src/main/ipcSchemas.cjs +18 -1
  48. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
  49. package/src/main/lib/__tests__/childWithLog.test.cjs +63 -0
  50. package/src/main/lib/__tests__/delegationReadiness.test.cjs +241 -42
  51. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
  52. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +1 -1
  53. package/src/main/lib/__tests__/gitWorktree.test.cjs +14 -2
  54. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
  55. package/src/main/lib/__tests__/jobWorktree.test.cjs +2 -2
  56. package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
  57. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +101 -0
  58. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
  59. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  60. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  61. package/src/main/lib/__tests__/reaperHelpers.test.cjs +112 -0
  62. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  63. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  64. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  65. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  66. package/src/main/lib/activeIndexMerge.cjs +15 -0
  67. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  68. package/src/main/lib/buildTarget.cjs +3 -2
  69. package/src/main/lib/childWithLog.cjs +33 -1
  70. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  71. package/src/main/lib/delegationReadiness.cjs +408 -26
  72. package/src/main/lib/ephemeralCwd.cjs +78 -0
  73. package/src/main/lib/epicDelegationStats.cjs +2 -1
  74. package/src/main/lib/epicMint.cjs +17 -1
  75. package/src/main/lib/epicStatusMirror.cjs +95 -0
  76. package/src/main/lib/epicValidationHook.cjs +2 -1
  77. package/src/main/lib/gitWorktree.cjs +56 -2
  78. package/src/main/lib/jobWorktree.cjs +1 -0
  79. package/src/main/lib/loadGate.cjs +134 -0
  80. package/src/main/lib/mcpToolCatalog.cjs +285 -0
  81. package/src/main/lib/opsErrorLog.cjs +12 -1
  82. package/src/main/lib/opsOwnership.cjs +94 -0
  83. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  84. package/src/main/lib/prdCreate.cjs +46 -14
  85. package/src/main/lib/prdLocations.cjs +13 -6
  86. package/src/main/lib/projectRootResolve.cjs +134 -0
  87. package/src/main/lib/promptSessionSchema.cjs +7 -0
  88. package/src/main/lib/queueStore.cjs +31 -5
  89. package/src/main/lib/rcaReport.cjs +1 -1
  90. package/src/main/lib/reaperHelpers.cjs +47 -1
  91. package/src/main/lib/schedulerBatch.cjs +171 -29
  92. package/src/main/lib/schedulerConfig.cjs +80 -0
  93. package/src/main/projectBrief.cjs +3 -2
  94. package/src/main/projectPages.cjs +2 -1
  95. package/src/main/promptSessionTranscript.cjs +0 -0
  96. package/src/main/pty.cjs +5 -0
  97. package/src/main/queueOps.cjs +15 -8
  98. package/src/main/scheduler.cjs +346 -49
  99. package/src/main/seedSchedulerMcp.cjs +58 -4
  100. package/src/preload/api.d.ts +69 -1
  101. package/src/preload/index.cjs +2 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * epicStatusMirror.test.cjs — every write path that changes an Epic's status
3
+ * in active-index.json must leave the SAME status mirrored onto
4
+ * prompt-sessions/<id>.json (epicStatusMirror.cjs), so a lost/clobbered index
5
+ * can be reconstructed (activeIndexRebuild.cjs) without silently erasing
6
+ * open Epics.
7
+ *
8
+ * Run: timeout 300 npx vitest run src/main/__tests__/epicStatusMirror.test.cjs
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import { test, expect, afterEach } from 'vitest';
14
+ const fs = require('node:fs');
15
+ const fsp = require('node:fs/promises');
16
+ const os = require('node:os');
17
+ const path = require('node:path');
18
+ const { ensureEpic, removeEpic, readActiveIndex, MINT_AUTHORITY_NEW_EPIC_UI } = require('../lib/epicMint.cjs');
19
+ const { mergeActiveIndex } = require('../lib/activeIndexMerge.cjs');
20
+ const { epicMirrorPath } = require('../lib/epicStatusMirror.cjs');
21
+ const config = require('../config.cjs');
22
+
23
+ const tmpDirs = [];
24
+ afterEach(async () => {
25
+ while (tmpDirs.length) {
26
+ const d = tmpDirs.pop();
27
+ await fsp.rm(d, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ async function mkCwd() {
32
+ const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-statusmirror-cwd-'));
33
+ config.addAllowedRoot(cwd);
34
+ tmpDirs.push(cwd);
35
+ return cwd;
36
+ }
37
+
38
+ function readMirror(cwd, epicId) {
39
+ return JSON.parse(fs.readFileSync(epicMirrorPath(cwd, epicId), 'utf8'));
40
+ }
41
+
42
+ test('minting an Epic leaves the file\'s status equal to the index row\'s', async () => {
43
+ const cwd = await mkCwd();
44
+
45
+ const { epicId } = await ensureEpic(cwd, {
46
+ goalText: 'a fresh Epic',
47
+ mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI,
48
+ });
49
+
50
+ const index = readActiveIndex(cwd);
51
+ const mirror = readMirror(cwd, epicId);
52
+ expect(mirror.status).toBe(index.sessions[epicId].status);
53
+ expect(mirror.status).toBe('proposed');
54
+ expect(mirror.cwd).toBe(cwd);
55
+ expect(mirror.goalText).toBe('a fresh Epic');
56
+ expect(typeof mirror.indexedAt).toBe('string');
57
+ expect(mirror.archivedAt).toBeNull();
58
+ });
59
+
60
+ test('the approve/start transition (mergeActiveIndex) updates the file\'s status to match', async () => {
61
+ const cwd = await mkCwd();
62
+ const { epicId } = await ensureEpic(cwd, {
63
+ goalText: 'to be started',
64
+ mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI,
65
+ });
66
+
67
+ const proposedIndex = readActiveIndex(cwd);
68
+ const activated = { ...proposedIndex.sessions[epicId], status: 'active' };
69
+ await mergeActiveIndex(cwd, {
70
+ sessions: { [epicId]: activated },
71
+ events: proposedIndex.events,
72
+ });
73
+
74
+ const index = readActiveIndex(cwd);
75
+ const mirror = readMirror(cwd, epicId);
76
+ expect(index.sessions[epicId].status).toBe('active');
77
+ expect(mirror.status).toBe('active');
78
+ expect(mirror.status).toBe(index.sessions[epicId].status);
79
+ });
80
+
81
+ test('removeEpic (mint rollback) deletes the mirror file too, leaving no orphan', async () => {
82
+ const cwd = await mkCwd();
83
+ const { epicId } = await ensureEpic(cwd, {
84
+ goalText: 'will be rolled back',
85
+ mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI,
86
+ });
87
+ expect(fs.existsSync(epicMirrorPath(cwd, epicId))).toBe(true);
88
+
89
+ await removeEpic(cwd, epicId);
90
+
91
+ expect(fs.existsSync(epicMirrorPath(cwd, epicId))).toBe(false);
92
+ expect(readActiveIndex(cwd).sessions[epicId]).toBeUndefined();
93
+ });
94
+
95
+ test('a merge that tombstones an Epic leaves its prior mirror file in place (rebuild excludes it via the tombstone, not file deletion)', async () => {
96
+ const cwd = await mkCwd();
97
+ const { epicId } = await ensureEpic(cwd, {
98
+ goalText: 'to be removed',
99
+ mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI,
100
+ });
101
+
102
+ await mergeActiveIndex(cwd, { sessions: {}, events: {}, removedIds: [epicId] });
103
+
104
+ const index = readActiveIndex(cwd);
105
+ expect(index.sessions[epicId]).toBeUndefined();
106
+ expect(index.tombstones[epicId]).toBeDefined();
107
+ // The mirror file is untouched — activeIndexRebuild.cjs is what must keep
108
+ // this id out, by consulting tombstones, not by the file being gone.
109
+ expect(fs.existsSync(epicMirrorPath(cwd, epicId))).toBe(true);
110
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * health-delegation-chain.test.cjs — "can this machine actually delegate
3
+ * work to the scheduler?" health section (PRD 1049), built on top of the
4
+ * delegation-readiness probe (delegationReadiness.cjs, PRD 1048). Exercises
5
+ * evaluateDelegationChainHealth() directly since it's pure — no need to spawn
6
+ * the full check() to cover shaping/degradation — plus one end-to-end check()
7
+ * run against this repo's own real config, where every check is expected to
8
+ * pass (this repo registers the scheduler MCP in its own .mcp.json and wires
9
+ * guard-prd-writes in .claude/settings.json).
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/__tests__/health-delegation-chain.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect } from 'vitest';
17
+ const { evaluateDelegationChainHealth, check } = require('../health.cjs');
18
+
19
+ function makeCheck(id, ok, overrides = {}) {
20
+ return { id, label: `${id} label`, ok, detail: `${id} detail`, fix: ok ? null : `fix ${id}`, ...overrides };
21
+ }
22
+
23
+ test('all checks passing -> component ok, no issues', () => {
24
+ const delegationResult = {
25
+ ok: true,
26
+ checks: [
27
+ makeCheck('scheduler-mcp', true),
28
+ makeCheck('scheduler-mcp-live', true),
29
+ makeCheck('scheduler-mcp-project-duplicate', true, { warn: false }),
30
+ makeCheck('dev-plugin', true),
31
+ makeCheck('agent-personas', true),
32
+ makeCheck('prd-write-guard', true),
33
+ ],
34
+ };
35
+ const { component, issues } = evaluateDelegationChainHealth(delegationResult);
36
+ expect(component.ok).toBe(true);
37
+ expect(component.checks).toHaveLength(6);
38
+ expect(issues).toEqual([]);
39
+ });
40
+
41
+ test('one failing check -> component not ok, one issue citing label/detail/fix', () => {
42
+ const delegationResult = {
43
+ ok: false,
44
+ checks: [
45
+ makeCheck('scheduler-mcp', false, { label: 'Scheduler MCP server registered', detail: 'no entry found', fix: 'claude mcp add ...' }),
46
+ makeCheck('scheduler-mcp-live', true, { skipped: true }),
47
+ makeCheck('scheduler-mcp-project-duplicate', true, { warn: false }),
48
+ makeCheck('dev-plugin', true),
49
+ makeCheck('agent-personas', true),
50
+ makeCheck('prd-write-guard', true),
51
+ ],
52
+ };
53
+ const { component, issues } = evaluateDelegationChainHealth(delegationResult);
54
+ expect(component.ok).toBe(false);
55
+ expect(issues).toHaveLength(1);
56
+ expect(issues[0]).toContain('Scheduler MCP server registered');
57
+ expect(issues[0]).toContain('no entry found');
58
+ expect(issues[0]).toContain('claude mcp add ...');
59
+ });
60
+
61
+ test('multiple failing checks -> one issue per failing check, passing checks silent', () => {
62
+ const delegationResult = {
63
+ ok: false,
64
+ checks: [
65
+ makeCheck('scheduler-mcp', false),
66
+ makeCheck('scheduler-mcp-live', false),
67
+ makeCheck('scheduler-mcp-project-duplicate', true, { warn: false }),
68
+ makeCheck('dev-plugin', false),
69
+ makeCheck('agent-personas', true),
70
+ makeCheck('prd-write-guard', true),
71
+ ],
72
+ };
73
+ const { issues } = evaluateDelegationChainHealth(delegationResult);
74
+ expect(issues).toHaveLength(3);
75
+ });
76
+
77
+ test('failing check with no fix omits the "(fix: ...)" suffix', () => {
78
+ const delegationResult = {
79
+ ok: false,
80
+ checks: [makeCheck('scheduler-mcp', false, { fix: null })],
81
+ };
82
+ const { issues } = evaluateDelegationChainHealth(delegationResult);
83
+ expect(issues[0]).not.toContain('(fix:');
84
+ });
85
+
86
+ test('end-to-end: check() reports delegation_chain for this repo, expected to pass here', async () => {
87
+ const status = await check();
88
+ expect(status.components.delegation_chain).toBeDefined();
89
+ expect(status.components.delegation_chain.checks).toHaveLength(6);
90
+ const ids = status.components.delegation_chain.checks.map((c) => c.id);
91
+ expect(ids).toEqual([
92
+ 'scheduler-mcp',
93
+ 'scheduler-mcp-live',
94
+ 'scheduler-mcp-project-duplicate',
95
+ 'dev-plugin',
96
+ 'agent-personas',
97
+ 'prd-write-guard',
98
+ ]);
99
+ // This repo registers the scheduler MCP at user scope (~/.claude.json) and
100
+ // wires guard-prd-writes in .claude/settings.json, and no longer carries a
101
+ // project-scope duplicate in its own .mcp.json, so on this machine every
102
+ // check is expected to pass — a failure here means the probe or the
103
+ // wiring is wrong, not that the assertion should be relaxed.
104
+ expect(status.components.delegation_chain.ok).toBe(true);
105
+ }, 90_000);
@@ -110,6 +110,12 @@ async function startAdmin() {
110
110
  process.env.SM_E2E = '1';
111
111
  const admin = createAdminHttp();
112
112
  prdAdminRoutes.registerAdminRoute(admin, scheduler.remote);
113
+ // Also wires reset-job/jobs (scheduler.cjs's own admin routes) onto the
114
+ // SAME admin instance/port, against the real (not faked) `remote` — this
115
+ // file's real-filesystem setup is what the reset-job/cancel-job/archive-prd
116
+ // slug-vs-not-found split needs, unlike scheduler-admin-routes.test.cjs's
117
+ // fake remote.
118
+ scheduler.registerAdminRoutes(admin, scheduler.remote);
113
119
  const { port, token } = await admin.start();
114
120
  if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
115
121
  return { admin, port, token };
@@ -388,6 +394,295 @@ test('POST /admin/scheduler/archive-prd moves a PRD to prds-archived/', async ()
388
394
  }
389
395
  });
390
396
 
397
+ // ─────────────────────────────────────── invalid-slug vs unknown-slug split
398
+ //
399
+ // PRD: safeSlugPath used to collapse "malformed slug" and "well-formed slug
400
+ // found nowhere" into the same `null` -> "invalid slug" response, so an
401
+ // agent that typed a real slug for a PRD in the wrong project (or a since-
402
+ // archived one) read the error as "I typed it wrong" and retried instead of
403
+ // listing PRDs. These tests pin the split for all three verbs plus the new
404
+ // optional `cwd` narrowing.
405
+
406
+ test('POST /admin/scheduler/reset-job with a malformed slug returns "invalid slug"', async () => {
407
+ const { admin, port, token } = await startAdmin();
408
+ try {
409
+ const res = await request(port, {
410
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: 'not a slug!!' },
411
+ });
412
+ expect(res.status).toBe(200);
413
+ expect(res.json.ok).toBe(false);
414
+ expect(res.json.error).toBe('invalid slug');
415
+ } finally {
416
+ await admin.stop();
417
+ }
418
+ });
419
+
420
+ test('POST /admin/scheduler/reset-job with a well-formed slug that exists nowhere returns a distinct unknown-slug error', async () => {
421
+ const { admin, port, token } = await startAdmin();
422
+ try {
423
+ const slug = uniqueSlug('definitely-not-a-real-prd');
424
+ const res = await request(port, {
425
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug },
426
+ });
427
+ expect(res.status).toBe(200);
428
+ expect(res.json.ok).toBe(false);
429
+ expect(res.json.error).not.toBe('invalid slug');
430
+ expect(res.json.error).toMatch(/unknown slug/);
431
+ expect(res.json.error).toMatch(/scheduler_list_prds/);
432
+ } finally {
433
+ await admin.stop();
434
+ }
435
+ });
436
+
437
+ test('POST /admin/scheduler/reset-job with a cwd naming a project with no PRD dir yields the unknown-slug error, not a crash', async () => {
438
+ const cwd = projectCwd();
439
+ const epicId = 'epic-reset-cwd-miss';
440
+ const slug = uniqueSlug('reset-cwd-miss');
441
+ await writePrdFixture({ cwd, epicId, slug }); // exists under `cwd`, not under `otherCwd`
442
+
443
+ const otherCwd = path.join(tmpHome, 'fake-project-cwd-empty');
444
+ fs.mkdirSync(otherCwd, { recursive: true });
445
+
446
+ const { admin, port, token } = await startAdmin();
447
+ try {
448
+ const res = await request(port, {
449
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug, cwd: otherCwd },
450
+ });
451
+ expect(res.status).toBe(200);
452
+ expect(res.json.ok).toBe(false);
453
+ expect(res.json.error).toMatch(/unknown slug/);
454
+ } finally {
455
+ await admin.stop();
456
+ }
457
+ });
458
+
459
+ test('POST /admin/scheduler/reset-job with a matching cwd resets a known job', async () => {
460
+ const cwd = projectCwd();
461
+ const epicId = 'epic-reset-cwd-hit';
462
+ const slug = uniqueSlug('reset-cwd-hit');
463
+ await writePrdFixture({ cwd, epicId, slug });
464
+ await scheduler.writeQueue({
465
+ jobs: [{ slug, title: 'x', cwd, status: 'needs_review' }],
466
+ config: {},
467
+ paused: null,
468
+ });
469
+
470
+ const { admin, port, token } = await startAdmin();
471
+ try {
472
+ const res = await request(port, {
473
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug, cwd },
474
+ });
475
+ expect(res.status).toBe(200);
476
+ expect(res.json).toEqual({ ok: true, slug, status: 'pending' });
477
+ } finally {
478
+ await admin.stop();
479
+ await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
480
+ }
481
+ });
482
+
483
+ test('POST /admin/scheduler/reset-job with cwd resets the SAME-slug job belonging to that project, not a same-slug job from another project (cwd narrows the job lookup, not just the file lookup)', async () => {
484
+ // Slugs derive from title text with no cwd salt, so two different projects
485
+ // can legitimately queue an identically-slugged job. `cwd` must disambiguate
486
+ // which one gets reset, not just which PRD file gets read for existence.
487
+ const cwdA = projectCwd();
488
+ const cwdB = path.join(tmpHome, 'fake-project-cwd-collision-b');
489
+ fs.mkdirSync(cwdB, { recursive: true });
490
+ config.addAllowedRoot(cwdB);
491
+ // queueStore.cjs's readMerged only reads back a project's shard if that
492
+ // project's cwd is discoverable via allProjectCwds (scans ~/.claude/projects/
493
+ // *.jsonl for a `cwd` field) — same fake-transcript trick beforeAll used for
494
+ // the shared fixture cwd.
495
+ const projDirB = path.join(tmpHome, '.claude', 'projects', 'fake-project-slug-b');
496
+ fs.mkdirSync(projDirB, { recursive: true });
497
+ fs.writeFileSync(path.join(projDirB, 'session.jsonl'), `${JSON.stringify({ cwd: cwdB })}\n`, 'utf8');
498
+ const { bustCwdCache } = require('../lib/queueStore.cjs');
499
+ bustCwdCache();
500
+ const slug = uniqueSlug('collision-slug');
501
+ const epicId = 'epic-reset-collision';
502
+ await writePrdFixture({ cwd: cwdB, epicId, slug });
503
+ await scheduler.writeQueue({
504
+ jobs: [
505
+ { slug, title: 'project A job', cwd: cwdA, status: 'needs_review' },
506
+ { slug, title: 'project B job', cwd: cwdB, status: 'needs_review' },
507
+ ],
508
+ config: {},
509
+ paused: null,
510
+ });
511
+
512
+ const { admin, port, token } = await startAdmin();
513
+ try {
514
+ const res = await request(port, {
515
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug, cwd: cwdB },
516
+ });
517
+ expect(res.status).toBe(200);
518
+ expect(res.json).toEqual({ ok: true, slug, status: 'pending' });
519
+
520
+ const state = await scheduler.remote.listJobs();
521
+ const jobA = state.find((j) => j.cwd === cwdA);
522
+ const jobB = state.find((j) => j.cwd === cwdB);
523
+ expect(jobA.status).toBe('needs_review'); // untouched
524
+ expect(jobB.status).toBe('pending'); // the one actually targeted
525
+ } finally {
526
+ await admin.stop();
527
+ await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
528
+ }
529
+ });
530
+
531
+ test('POST /admin/scheduler/cancel-job with a malformed slug is rejected at the request-schema layer (400) — never reaches "not found"', async () => {
532
+ const { admin, port, token } = await startAdmin();
533
+ try {
534
+ const res = await request(port, {
535
+ method: 'POST', path: '/admin/scheduler/cancel-job', token, body: { slug: 'not a slug!!' },
536
+ });
537
+ expect(res.status).toBe(400);
538
+ expect(res.json.ok).toBe(false);
539
+ expect(res.json.error).not.toMatch(/unknown slug|not found/i);
540
+ } finally {
541
+ await admin.stop();
542
+ }
543
+ });
544
+
545
+ test('remote.cancelJob itself (bypassing the HTTP schema layer) returns "invalid slug" for a malformed slug, distinct from "unknown slug"', async () => {
546
+ const malformed = await scheduler.remote.cancelJob('not a slug!!');
547
+ expect(malformed).toEqual({ ok: false, error: 'invalid slug' });
548
+
549
+ const wellFormedMissing = await scheduler.remote.cancelJob(uniqueSlug('cancel-remote-missing'));
550
+ expect(wellFormedMissing.ok).toBe(false);
551
+ expect(wellFormedMissing.error).not.toBe('invalid slug');
552
+ expect(wellFormedMissing.error).toMatch(/unknown slug/);
553
+ });
554
+
555
+ test('POST /admin/scheduler/cancel-job with a well-formed slug that has no queued job returns a distinct unknown-slug error (404)', async () => {
556
+ const { admin, port, token } = await startAdmin();
557
+ try {
558
+ const slug = uniqueSlug('cancel-missing');
559
+ const res = await request(port, {
560
+ method: 'POST', path: '/admin/scheduler/cancel-job', token, body: { slug },
561
+ });
562
+ expect(res.status).toBe(404);
563
+ expect(res.json.ok).toBe(false);
564
+ expect(res.json.error).not.toBe('invalid slug');
565
+ expect(res.json.error).toMatch(/unknown slug/);
566
+ } finally {
567
+ await admin.stop();
568
+ }
569
+ });
570
+
571
+ test('POST /admin/scheduler/cancel-job with cwd narrows job lookup to that project', async () => {
572
+ const cwd = projectCwd();
573
+ const slug = uniqueSlug('cancel-cwd-scope');
574
+ await scheduler.writeQueue({
575
+ jobs: [{ slug, title: 'x', cwd, status: 'pending' }],
576
+ config: {},
577
+ paused: null,
578
+ });
579
+ const otherCwd = path.join(tmpHome, 'fake-project-cwd-cancel-miss');
580
+ fs.mkdirSync(otherCwd, { recursive: true });
581
+
582
+ const { admin, port, token } = await startAdmin();
583
+ try {
584
+ const missRes = await request(port, {
585
+ method: 'POST', path: '/admin/scheduler/cancel-job', token, body: { slug, cwd: otherCwd },
586
+ });
587
+ expect(missRes.status).toBe(404);
588
+ expect(missRes.json.ok).toBe(false);
589
+
590
+ const hitRes = await request(port, {
591
+ method: 'POST', path: '/admin/scheduler/cancel-job', token, body: { slug, cwd },
592
+ });
593
+ expect(hitRes.status).toBe(200);
594
+ expect(hitRes.json.ok).toBe(true);
595
+ } finally {
596
+ await admin.stop();
597
+ await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
598
+ }
599
+ });
600
+
601
+ test('POST /admin/scheduler/archive-prd with a malformed slug is rejected at the request-schema layer (400) — never reaches "not found"', async () => {
602
+ const { admin, port, token } = await startAdmin();
603
+ try {
604
+ const res = await request(port, {
605
+ method: 'POST', path: '/admin/scheduler/archive-prd', token, body: { slugs: ['not a slug!!'] },
606
+ });
607
+ expect(res.status).toBe(400);
608
+ expect(res.json.ok).toBe(false);
609
+ } finally {
610
+ await admin.stop();
611
+ }
612
+ });
613
+
614
+ test('queueOps.archiveMany itself (bypassing the HTTP schema layer) splits "invalid slug" from "not found in any PRDs dir"', async () => {
615
+ const queueOps = require('../queueOps.cjs');
616
+ const malformed = await queueOps.archiveMany(['not a slug!!']);
617
+ expect(malformed.results[0]).toMatchObject({ ok: false, error: 'invalid slug' });
618
+
619
+ const missing = await queueOps.archiveMany([uniqueSlug('archive-remote-missing')]);
620
+ expect(missing.results[0]).toMatchObject({ ok: false, error: 'not found in any PRDs dir' });
621
+ });
622
+
623
+ test('POST /admin/scheduler/archive-prd with a well-formed slug found nowhere returns a distinct not-found error', async () => {
624
+ const { admin, port, token } = await startAdmin();
625
+ try {
626
+ const slug = uniqueSlug('archive-missing');
627
+ const res = await request(port, {
628
+ method: 'POST', path: '/admin/scheduler/archive-prd', token, body: { slugs: [slug] },
629
+ });
630
+ expect(res.status).toBe(200);
631
+ expect(res.json.results[0].ok).toBe(false);
632
+ expect(res.json.results[0].error).toBe('not found in any PRDs dir');
633
+ expect(res.json.results[0].error).not.toBe('invalid slug');
634
+ } finally {
635
+ await admin.stop();
636
+ }
637
+ });
638
+
639
+ test('POST /admin/scheduler/archive-prd with cwd narrows the search: a cwd with no PRD dir misses, the real cwd hits', async () => {
640
+ const cwd = projectCwd();
641
+ const epicId = 'epic-archive-cwd';
642
+ const slug = uniqueSlug('archive-cwd-scope');
643
+ const filePath = await writePrdFixture({ cwd, epicId, slug });
644
+
645
+ const otherCwd = path.join(tmpHome, 'fake-project-cwd-archive-miss');
646
+ fs.mkdirSync(otherCwd, { recursive: true });
647
+
648
+ const { admin, port, token } = await startAdmin();
649
+ try {
650
+ const missRes = await request(port, {
651
+ method: 'POST', path: '/admin/scheduler/archive-prd', token, body: { slugs: [slug], cwd: otherCwd },
652
+ });
653
+ expect(missRes.json.results[0].ok).toBe(false);
654
+ expect(missRes.json.results[0].error).toBe('not found in any PRDs dir');
655
+ expect(fs.existsSync(filePath)).toBe(true);
656
+
657
+ const hitRes = await request(port, {
658
+ method: 'POST', path: '/admin/scheduler/archive-prd', token, body: { slugs: [slug], cwd },
659
+ });
660
+ expect(hitRes.json.results[0].ok).toBe(true);
661
+ expect(fs.existsSync(filePath)).toBe(false);
662
+ } finally {
663
+ await admin.stop();
664
+ }
665
+ });
666
+
667
+ test('GET /admin/mcp/readiness requires cwd and returns the delegation-readiness checks for it', async () => {
668
+ const cwd = projectCwd();
669
+ const { admin, port, token } = await startAdmin();
670
+ try {
671
+ const missing = await request(port, { path: '/admin/mcp/readiness', token });
672
+ expect(missing.status).toBe(400);
673
+ expect(missing.json.ok).toBe(false);
674
+
675
+ const res = await request(port, { path: `/admin/mcp/readiness?cwd=${encodeURIComponent(cwd)}`, token });
676
+ expect(res.status).toBe(200);
677
+ expect(res.json.ok).toBe(true);
678
+ expect(typeof res.json.ready).toBe('boolean');
679
+ expect(Array.isArray(res.json.checks)).toBe(true);
680
+ expect(res.json.checks.some((c) => c.id === 'scheduler-mcp')).toBe(true);
681
+ } finally {
682
+ await admin.stop();
683
+ }
684
+ });
685
+
391
686
  test('POST /admin/scheduler/retag-prd rewrites estimateMinutes frontmatter', async () => {
392
687
  const cwd = projectCwd();
393
688
  const epicId = 'epic-retag';
@@ -15,6 +15,7 @@ const fsp = require('node:fs/promises');
15
15
  const os = require('node:os');
16
16
  const path = require('node:path');
17
17
  const http = require('node:http');
18
+ const { execFileSync } = require('node:child_process');
18
19
  const {
19
20
  deriveSlugFromTitle,
20
21
  buildPrdBody,
@@ -583,6 +584,114 @@ test('createPrd resolves sourcePromptId from originClaudeSessionId when the call
583
584
  });
584
585
 
585
586
  // Same real-homedir rationale as the two tests above.
587
+ // ──────────────────────────────────────────── worktree-cwd Epic-lookup
588
+ // hazard (PRD: projectRootResolve.cjs) — reproduces the live starry-night-
589
+ // ships shape: main's active-index.json has the Epic, a linked worktree's
590
+ // own (git-tracked, frozen-at-branch-time) copy does not. Passing the
591
+ // worktree's pwd as `cwd` must still resolve to main and join the Epic there.
592
+
593
+ function git(args, cwd) {
594
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
595
+ }
596
+
597
+ function initRepo(dir) {
598
+ fs.mkdirSync(dir, { recursive: true });
599
+ git(['init', '-q'], dir);
600
+ git(['config', 'user.email', 'test@example.com'], dir);
601
+ git(['config', 'user.name', 'Test'], dir);
602
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
603
+ git(['add', '-A'], dir);
604
+ git(['commit', '-q', '-m', 'initial'], dir);
605
+ }
606
+
607
+ test('createPrd resolves a worktree cwd to the main tree and joins the Epic whose row only exists in main\'s active-index (live starry-night-ships shape)', async () => {
608
+ const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-worktree-cwd-hazard-'));
609
+ const mainRepo = path.join(tmpRoot, 'main');
610
+ const worktreeDir = path.join(tmpRoot, 'worktree', 'epic-1');
611
+ config.addAllowedRoot(mainRepo);
612
+ try {
613
+ initRepo(mainRepo);
614
+ fs.mkdirSync(path.dirname(worktreeDir), { recursive: true });
615
+ git(['worktree', 'add', '-b', 'sm-epic/epic-1', worktreeDir, 'HEAD'], mainRepo);
616
+
617
+ // Main's index has the Epic; the worktree's own copy (as checked out at
618
+ // branch time) does not — the exact live-repro shape.
619
+ await writeActiveIndexFixture(mainRepo, {
620
+ 'epic-1': { id: 'epic-1', claudeSessionId: 'claude-sess-epic-1', status: 'active' },
621
+ });
622
+
623
+ const prdsDir = path.join(mainRepo, 'session-manager-operations', 'scheduler', 'epics', 'epic-1', 'prds');
624
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
625
+ const receivedCwds = [];
626
+ const realAllocate = remote.allocateParallelGroup.bind(remote);
627
+ remote.allocateParallelGroup = async (cwd) => { receivedCwds.push(cwd); return realAllocate(cwd); };
628
+
629
+ const result = await createPrd(
630
+ validCreateBody({ cwd: worktreeDir, slug: 'joins-from-worktree', sourcePromptId: 'epic-1' }),
631
+ remote,
632
+ );
633
+
634
+ expect(result.ok).toBe(true);
635
+ // Resolved and passed to the remote as the MAIN tree, never the worktree.
636
+ expect(receivedCwds.every((c) => c === mainRepo)).toBe(true);
637
+ const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
638
+ expect(written).toMatch(/sourcePromptId: epic-1/);
639
+ expect(written).toMatch(new RegExp(`cwd: ${mainRepo.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
640
+ } finally {
641
+ await fsp.rm(tmpRoot, { recursive: true, force: true });
642
+ }
643
+ });
644
+
645
+ test('createPrd omits cwd entirely and resolves the project purely from sourcePromptId (cwd optional inside an Epic session)', async () => {
646
+ const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-cwd-optional-epic-'));
647
+ config.addAllowedRoot(root);
648
+ // resolveProjectContext's cross-project scan (used when cwd is omitted
649
+ // entirely) is built on activeSessions.allProjectCwds — a scan of REAL
650
+ // ~/.claude/projects transcripts. In production this is always populated
651
+ // (the calling session itself opened `root` as a project), so seed a
652
+ // matching transcript here rather than the fixture root being invisible to
653
+ // the scan. Cleaned up in `finally`.
654
+ const transcriptDir = path.join(os.homedir(), '.claude', 'projects', `sm-cwd-optional-test-${Date.now()}`);
655
+ try {
656
+ await writeActiveIndexFixture(root, {
657
+ 'epic-cwd-optional': { id: 'epic-cwd-optional', claudeSessionId: 'claude-sess-cwd-optional', status: 'active' },
658
+ });
659
+ await fsp.mkdir(transcriptDir, { recursive: true });
660
+ await fsp.writeFile(path.join(transcriptDir, 'a.jsonl'), `${JSON.stringify({ cwd: root })}\n`, 'utf8');
661
+
662
+ const prdsDir = path.join(root, 'session-manager-operations', 'scheduler', 'epics', 'epic-cwd-optional', 'prds');
663
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
664
+
665
+ const body = validCreateBody({ slug: 'no-cwd-supplied', sourcePromptId: 'epic-cwd-optional' });
666
+ delete body.cwd;
667
+
668
+ const result = await createPrd(body, remote);
669
+
670
+ expect(result.ok).toBe(true);
671
+ const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
672
+ expect(written).toMatch(/sourcePromptId: epic-cwd-optional/);
673
+ } finally {
674
+ await fsp.rm(transcriptDir, { recursive: true, force: true });
675
+ await fsp.rm(root, { recursive: true, force: true });
676
+ }
677
+ });
678
+
679
+ test('createPrd with neither cwd nor a resolvable Epic fails with a clear cwd-required error', async () => {
680
+ const remote = {
681
+ async allocateParallelGroup() { throw new Error('must not be called'); },
682
+ async readPrd() { throw new Error('must not be called'); },
683
+ async writePrd() { throw new Error('must not be called'); },
684
+ };
685
+ const body = validCreateBody();
686
+ delete body.cwd;
687
+
688
+ const result = await createPrd(body, remote);
689
+
690
+ expect(result.ok).toBe(false);
691
+ expect(result.status).toBe(400);
692
+ expect(result.error).toMatch(/cwd is required/);
693
+ });
694
+
586
695
  test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches no known Epic (plain SessionTab chat)', async () => {
587
696
  const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-no-match-'));
588
697
  config.addAllowedRoot(root);