claude-code-session-manager 0.78.0 → 0.80.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 (74) hide show
  1. package/dist/assets/{AgentLibrary-13pfo8uY.js → AgentLibrary-zS3jw_1e.js} +1 -1
  2. package/dist/assets/{DataModel-SUyQbFlg.js → DataModel-Cy_vxTpi.js} +1 -1
  3. package/dist/assets/{History-2GJMS703.js → History-C6JRuqfT.js} +1 -1
  4. package/dist/assets/{Hooks-DM2nS3RT.js → Hooks-BafPy9mB.js} +1 -1
  5. package/dist/assets/{HostBilko-BLeC-lpp.js → HostBilko-BZwhQOFt.js} +1 -1
  6. package/dist/assets/{Library-BaRkU9m0.js → Library-C8JDDliz.js} +1 -1
  7. package/dist/assets/{ListDetail-D5scjSKq.js → ListDetail-CqiOdwLc.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-B1lgAo9T.js → MarkdownEditor-CyLyP67L.js} +1 -1
  9. package/dist/assets/{McpServers-BzyThQSM.js → McpServers-BzMv-_84.js} +1 -1
  10. package/dist/assets/{Memory-7UdaOTtl.js → Memory-DSBYQdJR.js} +1 -1
  11. package/dist/assets/{Panel-JbTMaOPq.js → Panel-CLUhkNNA.js} +1 -1
  12. package/dist/assets/{Permissions-UBam0bJG.js → Permissions-BfC2-HN4.js} +1 -1
  13. package/dist/assets/{Plugins-B3gUDkeb.js → Plugins-BKi40jT5.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-CeHOub7m.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
  15. package/dist/assets/{SaveBar-BcvQEq6h.js → SaveBar-avk2p9jv.js} +1 -1
  16. package/dist/assets/{Scheduler-Dc5qiP24.js → Scheduler-Bf_6MdJo.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-BvGQmw4Y.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
  18. package/dist/assets/{Settings-C2dEFb-v.js → Settings-Djd8OoBA.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-CIwlosBc.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
  20. package/dist/assets/{Skills-C0GzzVrQ.js → Skills-D_qAqxZ_.js} +1 -1
  21. package/dist/assets/{SystemPrompt-mtGPK8zo.js → SystemPrompt-DbHFLQV3.js} +1 -1
  22. package/dist/assets/{TagLibrary-DX54-mpd.js → TagLibrary-C2y91BT0.js} +1 -1
  23. package/dist/assets/{TiptapBody-yADC2RWE.js → TiptapBody-D9iz4xQx.js} +1 -1
  24. package/dist/assets/{Toggle-CRxaCYLI.js → Toggle-BGnFL2E5.js} +1 -1
  25. package/dist/assets/{index-D6ymGESc.js → index-_2ARyFDj.js} +4 -4
  26. package/dist/assets/{settingsSchema-TtMvT5Sx.js → settingsSchema-JK15eJU8.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
  30. package/scripts/project-pages-logic/dist/logic.cjs +12 -12
  31. package/scripts/render-project-pages/dist/renderer.cjs +22 -22
  32. package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
  33. package/src/main/__tests__/prdCreate.test.cjs +133 -8
  34. package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
  35. package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
  36. package/src/main/__tests__/queueHistory.test.cjs +33 -0
  37. package/src/main/__tests__/runLogRetention.test.cjs +59 -0
  38. package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
  39. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
  40. package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
  41. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
  42. package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
  43. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
  44. package/src/main/__tests__/scheduler-never-stop.test.cjs +157 -0
  45. package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +81 -0
  46. package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +123 -0
  47. package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +158 -0
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +30 -0
  49. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
  50. package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
  51. package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
  52. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
  53. package/src/main/ipcSchemas.cjs +15 -1
  54. package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
  55. package/src/main/lib/__tests__/reaperHelpers.test.cjs +90 -1
  56. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
  57. package/src/main/lib/depSlugResolve.cjs +72 -0
  58. package/src/main/lib/epicWorktreeMerge.cjs +3 -3
  59. package/src/main/lib/epicWorktreeMint.cjs +17 -5
  60. package/src/main/lib/fixPlanSlug.cjs +62 -0
  61. package/src/main/lib/gitWorktree.cjs +97 -12
  62. package/src/main/lib/jobDirtFilter.cjs +54 -0
  63. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  64. package/src/main/lib/prdCreate.cjs +84 -5
  65. package/src/main/lib/prdFrontmatter.cjs +56 -8
  66. package/src/main/lib/queueHistory.cjs +50 -5
  67. package/src/main/lib/rateLimitDetect.cjs +35 -0
  68. package/src/main/lib/reaperHelpers.cjs +31 -6
  69. package/src/main/lib/runLogRetention.cjs +82 -4
  70. package/src/main/lib/scheduleJobTransitions.cjs +12 -2
  71. package/src/main/lib/schedulerBatch.cjs +181 -23
  72. package/src/main/scheduler/prdParser.cjs +7 -0
  73. package/src/main/scheduler.cjs +1165 -70
  74. package/src/preload/api.d.ts +8 -0
@@ -196,9 +196,25 @@ function makeFakeRemoteWithPrdsDir(prdsDir) {
196
196
  return { ok: false, error: e?.message };
197
197
  }
198
198
  },
199
+ // Minimal stand-in for scheduler.cjs's real remote.listPrds: every
200
+ // `<slug>.md` under the fake project's prdsDir, as a `{ slug }` row —
201
+ // enough for prdCreate.cjs's dependsOn-existence validation, which only
202
+ // ever reads `.slug` off each entry.
203
+ async listPrds() {
204
+ const entries = await fsp.readdir(prdsDir);
205
+ const prds = entries
206
+ .filter((f) => f.endsWith('.md'))
207
+ .map((f) => ({ slug: f.slice(0, -3) }));
208
+ return { prds, total: prds.length, limit: prds.length, offset: 0, hasMore: false };
209
+ },
199
210
  };
200
211
  }
201
212
 
213
+ /** Pre-seed the fake project's prdsDir with an existing (empty-body) PRD file at `slug`. */
214
+ async function seedExistingPrd(prdsDir, slug) {
215
+ await fsp.writeFile(path.join(prdsDir, `${slug}.md`), '---\ntitle: seed\n---\n# Goal\n');
216
+ }
217
+
202
218
  function validCreateBody(overrides = {}) {
203
219
  return {
204
220
  title: 'Add widget frobnication',
@@ -797,16 +813,20 @@ test('createPrd refuses a depth-3 fix-chain slug the same way as depth-2', async
797
813
  expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
798
814
  });
799
815
 
800
- test('createPrd allows a depth-1 fix slug through the same code path that rejects depth 2 (matches MAX_INVESTIGATION_DEPTH=1)', async () => {
816
+ test('createPrd refuses a depth-1 fix slug too (PRD 1131) — that shape is reserved for spawnInvestigation-authored fix plans, not this API', async () => {
801
817
  const prdsDir = await mkTmpPrdsDir();
802
818
  const remote = makeFakeRemoteWithPrdsDir(prdsDir);
819
+ const writePrdSpy = vi.fn(remote.writePrd);
820
+ remote.writePrd = writePrdSpy;
803
821
 
804
822
  const result = await createPrd(validCreateBody({ slug: 'fix-widget-frobnication' }), remote);
805
823
 
806
- expect(result.ok).toBe(true);
807
- expect(result.filename).toMatch(/^\d+-fix-widget-frobnication\.md$/);
808
- const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
809
- expect(written).toMatch(/title: Add widget frobnication/);
824
+ expect(result.ok).toBe(false);
825
+ expect(result.status).toBe(400);
826
+ expect(result.error).toMatch(/reserved NN-fix-\.\.\. fix-plan naming convention/i);
827
+ expect(writePrdSpy).not.toHaveBeenCalled();
828
+ const entries = await fsp.readdir(prdsDir);
829
+ expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
810
830
  });
811
831
 
812
832
  test('createPrd is unaffected by the fix-chain guard for an ordinary slug with no fix chain at all', async () => {
@@ -820,14 +840,15 @@ test('createPrd is unaffected by the fix-chain guard for an ordinary slug with n
820
840
  expect(written).toMatch(/title: Add widget frobnication/);
821
841
  });
822
842
 
823
- test('createPrd is not fooled by a slug where a later segment merely reads "fix" (false-positive bait)', async () => {
843
+ test('createPrd refuses a slug starting with "fix-" even when a later segment merely reads "fix" (isFixPlanSlug matches on the leading segment alone, unlike fixChainDepthOf)', async () => {
824
844
  const prdsDir = await mkTmpPrdsDir();
825
845
  const remote = makeFakeRemoteWithPrdsDir(prdsDir);
826
846
 
827
847
  const result = await createPrd(validCreateBody({ slug: 'fix-loop-extraction-and-round-trip' }), remote);
828
848
 
829
- expect(result.ok).toBe(true);
830
- expect(result.filename).toMatch(/^\d+-fix-loop-extraction-and-round-trip\.md$/);
849
+ expect(result.ok).toBe(false);
850
+ expect(result.status).toBe(400);
851
+ expect(result.error).toMatch(/reserved NN-fix-\.\.\. fix-plan naming convention/i);
831
852
  });
832
853
 
833
854
  test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches no known Epic (plain SessionTab chat)', async () => {
@@ -850,3 +871,107 @@ test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches n
850
871
  await fsp.rm(root, { recursive: true, force: true });
851
872
  }
852
873
  });
874
+
875
+ // ──────────────────────────────────────────── PRD 1121: reject double-prefixed
876
+ // slugs, validate dependsOn against existing PRDs at write time
877
+
878
+ test('createPrd refuses a caller-supplied slug already starting with a numeric prefix', async () => {
879
+ const prdsDir = await mkTmpPrdsDir();
880
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
881
+
882
+ const result = await createPrd(validCreateBody({ slug: '254-perf-x' }), remote);
883
+
884
+ expect(result.ok).toBe(false);
885
+ expect(result.status).toBe(400);
886
+ expect(result.error).toMatch(/254-/);
887
+ expect(result.error).toMatch(/bare name/);
888
+ const entries = await fsp.readdir(prdsDir);
889
+ expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
890
+ });
891
+
892
+ test('createPrd still accepts a bare (non-prefixed) caller-supplied slug', async () => {
893
+ const prdsDir = await mkTmpPrdsDir();
894
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
895
+
896
+ const result = await createPrd(validCreateBody({ slug: 'perf-x' }), remote);
897
+
898
+ expect(result.ok).toBe(true);
899
+ expect(result.filename).toMatch(/^\d+-perf-x\.md$/);
900
+ });
901
+
902
+ test('createPrd accepts dependsOn naming an existing bare slug', async () => {
903
+ const prdsDir = await mkTmpPrdsDir();
904
+ await seedExistingPrd(prdsDir, '3-widget-base');
905
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
906
+
907
+ const result = await createPrd(validCreateBody({ slug: 'widget-follow-up', dependsOn: ['widget-base'] }), remote);
908
+
909
+ expect(result.ok).toBe(true);
910
+ const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
911
+ expect(written).toMatch(/^dependsOn: \[widget-base\]$/m);
912
+ });
913
+
914
+ test('createPrd accepts dependsOn naming an existing full NN-prefixed slug', async () => {
915
+ const prdsDir = await mkTmpPrdsDir();
916
+ await seedExistingPrd(prdsDir, '3-widget-base');
917
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
918
+
919
+ const result = await createPrd(validCreateBody({ slug: 'widget-follow-up-2', dependsOn: ['3-widget-base'] }), remote);
920
+
921
+ expect(result.ok).toBe(true);
922
+ });
923
+
924
+ test('createPrd refuses dependsOn naming nothing, listing near-matches in the message, and writes no file', async () => {
925
+ const prdsDir = await mkTmpPrdsDir();
926
+ await seedExistingPrd(prdsDir, '3-widget-base');
927
+ await seedExistingPrd(prdsDir, '4-widget-basics');
928
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
929
+
930
+ const result = await createPrd(validCreateBody({ slug: 'widget-follow-up-3', dependsOn: ['wigdet-bsae'] }), remote);
931
+
932
+ expect(result.ok).toBe(false);
933
+ expect(result.status).toBe(400);
934
+ expect(result.error).toMatch(/wigdet-bsae/);
935
+ expect(result.error).toMatch(/widget-base/);
936
+ const entries = await fsp.readdir(prdsDir);
937
+ expect(entries.filter((f) => f.endsWith('.md') && f.includes('widget-follow-up-3')).length).toBe(0);
938
+ });
939
+
940
+ test('createPrd treats an empty dependsOn array as a no-op (no listPrds call, no rejection)', async () => {
941
+ const prdsDir = await mkTmpPrdsDir();
942
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
943
+ const listPrdsSpy = vi.fn(remote.listPrds);
944
+ remote.listPrds = listPrdsSpy;
945
+
946
+ const result = await createPrd(validCreateBody({ slug: 'no-deps-here', dependsOn: [] }), remote);
947
+
948
+ expect(result.ok).toBe(true);
949
+ expect(listPrdsSpy).not.toHaveBeenCalled();
950
+ });
951
+
952
+ test('createPrd treats an absent dependsOn as a no-op (no listPrds call, no rejection)', async () => {
953
+ const prdsDir = await mkTmpPrdsDir();
954
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
955
+ const listPrdsSpy = vi.fn(remote.listPrds);
956
+ remote.listPrds = listPrdsSpy;
957
+
958
+ const result = await createPrd(validCreateBody({ slug: 'no-deps-field' }), remote);
959
+
960
+ expect(result.ok).toBe(true);
961
+ expect(listPrdsSpy).not.toHaveBeenCalled();
962
+ });
963
+
964
+ test('createPrd warns and skips dependsOn validation (never fails the write) when listPrds throws', async () => {
965
+ const prdsDir = await mkTmpPrdsDir();
966
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
967
+ remote.listPrds = async () => { throw new Error('simulated I/O failure'); };
968
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
969
+
970
+ try {
971
+ const result = await createPrd(validCreateBody({ slug: 'deps-during-outage', dependsOn: ['anything-at-all'] }), remote);
972
+ expect(result.ok).toBe(true);
973
+ expect(warnSpy).toHaveBeenCalled();
974
+ } finally {
975
+ warnSpy.mockRestore();
976
+ }
977
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * prdFrontmatterDependsOn.test.cjs — PRD 1124: `dependsOn` is a recognized,
3
+ * patchable PRD frontmatter key. Before this PRD, RECOGNIZED_KEYS omitted
4
+ * `dependsOn`, so it only round-tripped verbatim via `extras` and
5
+ * scheduler_update_prd could not patch it — the only repair path for a
6
+ * wrong dependsOn was archive-and-recreate, which is unsafe (archiving
7
+ * marks the PRD completed, freeing its dependents with zero work done).
8
+ *
9
+ * Covers the pure parse/serialize contract in prdFrontmatter.cjs. The
10
+ * write-time FK validation + clearing semantics wired into
11
+ * scheduler.remote.updatePrd are covered by
12
+ * prdUpdateDependsOn.test.cjs.
13
+ *
14
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdFrontmatterDependsOn.test.cjs
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ import { test, expect } from 'vitest';
20
+
21
+ const { parsePrdFile, serializePrdFile, RECOGNIZED_KEYS } = require('../lib/prdFrontmatter.cjs');
22
+
23
+ test('dependsOn is a recognized key', () => {
24
+ expect(RECOGNIZED_KEYS.has('dependsOn')).toBe(true);
25
+ });
26
+
27
+ test('parsePrdFile parses an inline dependsOn list into an array, and round-trips it byte-identically when unedited', () => {
28
+ const raw = [
29
+ '---',
30
+ 'title: A follow-up PRD',
31
+ 'cwd: ~/Projects/session-manager',
32
+ 'estimateMinutes: 20',
33
+ 'agentType: dev-lead',
34
+ 'dependsOn: [widget-base, widget-shared]',
35
+ '---',
36
+ '# Goal',
37
+ '',
38
+ 'Build on the base.',
39
+ '',
40
+ ].join('\n');
41
+
42
+ const { frontmatter, body } = parsePrdFile(raw);
43
+ expect(frontmatter.dependsOn).toEqual(['widget-base', 'widget-shared']);
44
+ expect(serializePrdFile(frontmatter, body)).toBe(raw);
45
+ });
46
+
47
+ test('parsePrdFile leaves dependsOn undefined when omitted — a PRD with no dependsOn round-trips unaffected', () => {
48
+ const raw = [
49
+ '---',
50
+ 'title: An older PRD authored before dependsOn was patchable',
51
+ 'cwd: ~/Projects/session-manager',
52
+ 'estimateMinutes: 10',
53
+ '---',
54
+ '# Goal',
55
+ '',
56
+ 'Do the thing.',
57
+ '',
58
+ ].join('\n');
59
+
60
+ const { frontmatter, body } = parsePrdFile(raw);
61
+ expect(frontmatter.dependsOn).toBeUndefined();
62
+ expect(serializePrdFile(frontmatter, body)).toBe(raw);
63
+
64
+ // An unrelated patch (title) must not introduce a dependsOn line.
65
+ const updated = serializePrdFile({ ...frontmatter, title: 'Renamed' }, body);
66
+ expect(updated).not.toContain('dependsOn');
67
+ });
68
+
69
+ test('serializePrdFile emits a newly-set dependsOn onto a frontmatter object that lacked one', () => {
70
+ const raw = [
71
+ '---',
72
+ 'title: Legacy PRD',
73
+ 'cwd: ~/Projects/session-manager',
74
+ 'estimateMinutes: 5',
75
+ '---',
76
+ 'body',
77
+ '',
78
+ ].join('\n');
79
+
80
+ const { frontmatter, body } = parsePrdFile(raw);
81
+ const updated = serializePrdFile({ ...frontmatter, dependsOn: ['some-other-prd'] }, body);
82
+ expect(updated).toContain('dependsOn: [some-other-prd]');
83
+ });
84
+
85
+ test('serializePrdFile patches an existing dependsOn to a new list', () => {
86
+ const raw = [
87
+ '---',
88
+ 'title: A follow-up PRD',
89
+ 'cwd: ~/Projects/session-manager',
90
+ 'estimateMinutes: 20',
91
+ 'dependsOn: [widget-base]',
92
+ '---',
93
+ 'body',
94
+ '',
95
+ ].join('\n');
96
+
97
+ const { frontmatter, body } = parsePrdFile(raw);
98
+ const updated = serializePrdFile({ ...frontmatter, dependsOn: ['widget-shared', 'widget-extra'] }, body);
99
+ expect(updated).toContain('dependsOn: [widget-shared, widget-extra]');
100
+ expect(updated).not.toContain('widget-base');
101
+ });
102
+
103
+ test('serializePrdFile patching dependsOn to an explicit empty array CLEARS it', () => {
104
+ const raw = [
105
+ '---',
106
+ 'title: A follow-up PRD',
107
+ 'cwd: ~/Projects/session-manager',
108
+ 'estimateMinutes: 20',
109
+ 'dependsOn: [widget-base]',
110
+ '---',
111
+ 'body',
112
+ '',
113
+ ].join('\n');
114
+
115
+ const { frontmatter, body } = parsePrdFile(raw);
116
+ const updated = serializePrdFile({ ...frontmatter, dependsOn: [] }, body);
117
+ expect(updated).not.toContain('dependsOn');
118
+ });
119
+
120
+ test('unrelated recognized-key patch leaves an existing dependsOn line untouched', () => {
121
+ const raw = [
122
+ '---',
123
+ 'title: A follow-up PRD',
124
+ 'cwd: ~/Projects/session-manager',
125
+ 'estimateMinutes: 20',
126
+ 'dependsOn: [widget-base]',
127
+ '---',
128
+ 'body',
129
+ '',
130
+ ].join('\n');
131
+
132
+ const { frontmatter, body } = parsePrdFile(raw);
133
+ const updated = serializePrdFile({ ...frontmatter, estimateMinutes: 30 }, body);
134
+ expect(updated).toContain('dependsOn: [widget-base]');
135
+ expect(updated).toContain('estimateMinutes: 30');
136
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * prdUpdateDependsOn.test.cjs — PRD 1124: scheduler.remote.updatePrd accepts
3
+ * a `dependsOn` array patch, validates it write-time against the SAME
4
+ * resolver (depSlugResolve.cjs) scheduler_create_prd's prdCreate.cjs uses,
5
+ * and accepts an explicit empty array to CLEAR the dependency.
6
+ *
7
+ * Mirrors scheduler-reconcile-quarantine.test.cjs's HOME-isolation fixture.
8
+ *
9
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdUpdateDependsOn.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ import { test, expect, beforeAll, afterAll } from 'vitest';
15
+ const fs = require('node:fs');
16
+ const os = require('node:os');
17
+ const path = require('node:path');
18
+
19
+ let tmpHome;
20
+ let originalHome;
21
+ let scheduler;
22
+ let queueStore;
23
+ let config;
24
+
25
+ beforeAll(() => {
26
+ originalHome = process.env.HOME;
27
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-update-prd-dependson-home-'));
28
+ process.env.HOME = tmpHome;
29
+
30
+ scheduler = require('../scheduler.cjs');
31
+ queueStore = require('../lib/queueStore.cjs');
32
+ config = require('../config.cjs');
33
+
34
+ if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
35
+ throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
36
+ }
37
+ });
38
+
39
+ afterAll(() => {
40
+ process.env.HOME = originalHome;
41
+ fs.rmSync(tmpHome, { recursive: true, force: true });
42
+ });
43
+
44
+ function registerActiveProject(cwd) {
45
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
46
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
47
+ fs.mkdirSync(slugDir, { recursive: true });
48
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
49
+ }
50
+
51
+ function makeFixtureProject(prefix) {
52
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
53
+ registerActiveProject(cwd);
54
+ // updatePrd writes through config.writeTextAtomic, which enforces
55
+ // validatePath's allowed-write-root boundary (os.homedir() by default).
56
+ // The live app widens this via pty.cjs's spawn / activeIndexMerge.cjs's
57
+ // boot registration as a side effect of opening the project; a test that
58
+ // never spawns a PTY has to register it explicitly, same as
59
+ // prdCreate.cjs's own createPrd does for a chat-only Epic.
60
+ config.addAllowedRoot(cwd);
61
+ const opsRoot = path.join(cwd, 'session-manager-operations');
62
+ const prdsDir = path.join(opsRoot, 'scheduler', 'epics', 'test-epic-1', 'prds');
63
+ const stateDir = path.join(opsRoot, 'scheduler', 'state');
64
+ fs.mkdirSync(prdsDir, { recursive: true });
65
+ fs.mkdirSync(stateDir, { recursive: true });
66
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
67
+ queueStore.bustCwdCache();
68
+ return { cwd, prdsDir, stateDir };
69
+ }
70
+
71
+ function writePrd(prdsDir, filename, cwd, extraFrontmatter = '') {
72
+ fs.writeFileSync(
73
+ path.join(prdsDir, filename),
74
+ `---\ntitle: ${filename}\ncwd: ${cwd}\nestimateMinutes: 10\ncreatedVia: scheduler-api\n${extraFrontmatter}---\n\n# Goal\nDo the thing.\n`,
75
+ 'utf8',
76
+ );
77
+ }
78
+
79
+ test('updatePrd patches dependsOn to a new valid list naming an existing bare slug', async () => {
80
+ const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-valid-');
81
+ try {
82
+ writePrd(prdsDir, '3-widget-base.md', cwd);
83
+ writePrd(prdsDir, '4-widget-follow-up.md', cwd);
84
+
85
+ const result = await scheduler.remote.updatePrd({
86
+ slug: '4-widget-follow-up',
87
+ cwd,
88
+ frontmatter: { dependsOn: ['widget-base'] },
89
+ });
90
+
91
+ expect(result.ok).toBe(true);
92
+ const written = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
93
+ expect(written).toMatch(/^dependsOn: \[widget-base\]$/m);
94
+ } finally {
95
+ fs.rmSync(cwd, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ test('updatePrd refuses a dependsOn entry that resolves to no existing PRD, with a near-match suggestion, and writes nothing', async () => {
100
+ const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-invalid-');
101
+ try {
102
+ writePrd(prdsDir, '3-widget-base.md', cwd);
103
+ writePrd(prdsDir, '4-widget-follow-up.md', cwd);
104
+ const before = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
105
+
106
+ const result = await scheduler.remote.updatePrd({
107
+ slug: '4-widget-follow-up',
108
+ cwd,
109
+ frontmatter: { dependsOn: ['wigdet-bsae'] },
110
+ });
111
+
112
+ expect(result.ok).toBe(false);
113
+ expect(result.error).toMatch(/does not resolve to any existing PRD/);
114
+ expect(result.error).toMatch(/widget-base/);
115
+ const after = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
116
+ expect(after).toBe(before);
117
+ } finally {
118
+ fs.rmSync(cwd, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ test('updatePrd patches dependsOn to an explicit empty array, clearing a previously-set dependency without validation', async () => {
123
+ const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-clear-');
124
+ try {
125
+ writePrd(prdsDir, '3-widget-base.md', cwd);
126
+ writePrd(prdsDir, '4-widget-follow-up.md', cwd, 'dependsOn: [widget-base]\n');
127
+
128
+ const result = await scheduler.remote.updatePrd({
129
+ slug: '4-widget-follow-up',
130
+ cwd,
131
+ frontmatter: { dependsOn: [] },
132
+ });
133
+
134
+ expect(result.ok).toBe(true);
135
+ const written = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
136
+ expect(written).not.toContain('dependsOn');
137
+ } finally {
138
+ fs.rmSync(cwd, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ test('updatePrd patching an unrelated field leaves a PRD with no dependsOn unaffected', async () => {
143
+ const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-unrelated-');
144
+ try {
145
+ writePrd(prdsDir, '3-standalone.md', cwd);
146
+
147
+ const result = await scheduler.remote.updatePrd({
148
+ slug: '3-standalone',
149
+ cwd,
150
+ frontmatter: { estimateMinutes: 25 },
151
+ });
152
+
153
+ expect(result.ok).toBe(true);
154
+ const written = fs.readFileSync(path.join(prdsDir, '3-standalone.md'), 'utf8');
155
+ expect(written).not.toContain('dependsOn');
156
+ expect(written).toMatch(/^estimateMinutes: 25$/m);
157
+ } finally {
158
+ fs.rmSync(cwd, { recursive: true, force: true });
159
+ }
160
+ });
@@ -257,3 +257,36 @@ test('historyTerminalBySlug: cache invalidates after a new appendHistory call (m
257
257
  expect(second.has('cache-1')).toBe(true);
258
258
  expect(second.has('cache-2')).toBe(true);
259
259
  });
260
+
261
+ // ---------- completedSlugsForCwd (PRD 1122) ----------
262
+ //
263
+ // Feeds scheduler.cjs's computeDepHistorySatisfaction: a dependsOn slug with
264
+ // no live queue row is only satisfied when it shows up here (or in
265
+ // prds-archived/) — scoped to ONE project's own history shard so a
266
+ // same-named PRD in an unrelated project can never satisfy a dep.
267
+
268
+ test('completedSlugsForCwd: returns slugs completed in this project\'s own shard only', async () => {
269
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-projA-'));
270
+ const otherCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-projB-'));
271
+ await queueHistory.appendHistory([
272
+ old({ slug: '900-base', status: 'completed', runId: 'ra', cwd: projectCwd }),
273
+ old({ slug: '901-failed', status: 'failed', runId: 'rb', cwd: projectCwd }),
274
+ old({ slug: '900-base', status: 'completed', runId: 'rc', cwd: otherCwd }),
275
+ ]);
276
+
277
+ const slugs = await queueHistory.completedSlugsForCwd(projectCwd);
278
+ expect(slugs.has('900-base')).toBe(true);
279
+ expect(slugs.has('901-failed')).toBe(false);
280
+
281
+ // Same slug completed in a DIFFERENT project must not leak across.
282
+ const otherSlugs = await queueHistory.completedSlugsForCwd(otherCwd);
283
+ expect(otherSlugs.has('900-base')).toBe(true);
284
+ const unrelatedCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-projC-'));
285
+ expect((await queueHistory.completedSlugsForCwd(unrelatedCwd)).size).toBe(0);
286
+ });
287
+
288
+ test('completedSlugsForCwd: returns an empty Set when the project has no history shard yet', async () => {
289
+ const freshCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-fresh-'));
290
+ const slugs = await queueHistory.completedSlugsForCwd(freshCwd);
291
+ expect(slugs.size).toBe(0);
292
+ });
@@ -20,6 +20,7 @@ const {
20
20
  isRetentionEnabled,
21
21
  applyRetention,
22
22
  liveKeysFromJobs,
23
+ runningRunIdsFromJobs,
23
24
  isLiveJob,
24
25
  runBootSweep,
25
26
  } = require('../lib/runLogRetention.cjs');
@@ -407,6 +408,64 @@ test('runBootSweep: a deletion failure (permission error) is caught, logged in r
407
408
  }
408
409
  });
409
410
 
411
+ // ─── orphan pass: empty run directories (PRD 1120) ─────────────────────────
412
+
413
+ test('runningRunIdsFromJobs: only status===running jobs with a runId are protected', () => {
414
+ const ids = runningRunIdsFromJobs([
415
+ { slug: 'a', status: 'running', runId: 'run-a' },
416
+ { slug: 'b', status: 'pending', runId: 'run-b' },
417
+ { slug: 'c', status: 'running', runId: null },
418
+ null,
419
+ ]);
420
+ expect(ids.has('run-a')).toBe(true);
421
+ expect(ids.has('run-b')).toBe(false);
422
+ expect(ids.size).toBe(1);
423
+ });
424
+
425
+ test('applyRetention orphan pass: an empty run directory 1 minute old is removed while a directory with a .log is retained', () => {
426
+ const now = Date.now();
427
+ // Orphan: minted by pickRunDir but the dispatch aborted before executeJob
428
+ // ever wrote into it — no meta.json, so scanRunEntries never sees it.
429
+ const orphanDir = path.join(runsDir, '2026-09-05T14-00-00-000Z');
430
+ fs.mkdirSync(orphanDir, { recursive: true });
431
+ const orphanTime = (now - 60_000) / 1000;
432
+ fs.utimesSync(orphanDir, orphanTime, orphanTime);
433
+
434
+ // Real run: has a .log, must survive regardless of the orphan pass.
435
+ makeRun('2026-09-05T15-00-00-000Z', 'real-slug', { finishedAt: now - 60_000 });
436
+
437
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 7, keepPerSlug: 1 } } };
438
+ const result = applyRetention(runsDir, settings, { now, jobs: [] });
439
+
440
+ expect(result.orphanDirsRemoved).toBe(1);
441
+ expect(fs.existsSync(orphanDir)).toBe(false);
442
+ expect(fs.existsSync(path.join(runsDir, '2026-09-05T15-00-00-000Z', 'real-slug.log'))).toBe(true);
443
+ });
444
+
445
+ test('applyRetention orphan pass: skips an empty directory whose runId belongs to a currently-running job (spawn in flight)', () => {
446
+ const now = Date.now();
447
+ const inFlightRunId = '2026-09-05T16-00-00-000Z';
448
+ const inFlightDir = path.join(runsDir, inFlightRunId);
449
+ fs.mkdirSync(inFlightDir, { recursive: true });
450
+
451
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 7, keepPerSlug: 1 } } };
452
+ const jobs = [{ slug: 'mid-dispatch', status: 'running', runId: inFlightRunId }];
453
+ const result = applyRetention(runsDir, settings, { now, jobs });
454
+
455
+ expect(result.orphanDirsRemoved).toBe(0);
456
+ expect(fs.existsSync(inFlightDir)).toBe(true);
457
+ });
458
+
459
+ test('applyRetention orphan pass: never runs when retention is not opted in (dry-run)', () => {
460
+ const now = Date.now();
461
+ const orphanDir = path.join(runsDir, '2020-01-01T00-00-00-000Z');
462
+ fs.mkdirSync(orphanDir, { recursive: true });
463
+
464
+ const result = applyRetention(runsDir, undefined, { now, jobs: [] });
465
+ expect(result.deleted).toBe(false);
466
+ expect(fs.existsSync(orphanDir)).toBe(true);
467
+ });
468
+
410
469
  test('applyRetention: dry-run path (enabled=false) never reads queueStore for live protection', () => {
411
470
  const now = Date.now();
412
471
  makeRun('2020-05-01T00-00-00-000Z', 'no-touch-slug', { finishedAt: now - 400 * DAY_MS });
@@ -120,6 +120,7 @@ test('LEGAL_TRANSITIONS covers every real scheduler.cjs edge', () => {
120
120
  ['needs_review', 'investigating'],
121
121
  ['needs_review', 'pending'],
122
122
  ['needs_review', 'completed'],
123
+ ['needs_review', 'running'],
123
124
  ['completed', 'pending'],
124
125
  ['skipped', 'pending'],
125
126
  ];