claude-code-session-manager 0.85.0 → 0.87.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 (147) hide show
  1. package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
  2. package/dist/assets/{DataModel-DRH-Ty20.js → DataModel--mISIJ6h.js} +1 -1
  3. package/dist/assets/{History-CfRhT1Im.js → History-C2ahUXTg.js} +2 -2
  4. package/dist/assets/{Hooks-wEmh_U6c.js → Hooks-BiC6oyR2.js} +3 -3
  5. package/dist/assets/{HostBilko-D_t7Rbi7.js → HostBilko-BPleEOld.js} +1 -1
  6. package/dist/assets/{Library-CpArQ-OJ.js → Library-Dc8Qst1R.js} +1 -1
  7. package/dist/assets/{ListDetail-pjaKYs84.js → ListDetail-DIXh-OLX.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
  9. package/dist/assets/{McpServers-ftqaV3kn.js → McpServers-DqcbLOLZ.js} +2 -2
  10. package/dist/assets/{Memory-ChMWkNd0.js → Memory-CW62MXlh.js} +4 -4
  11. package/dist/assets/{Panel-D9Kr40Ai.js → Panel-Bw1FhRuF.js} +1 -1
  12. package/dist/assets/Permissions-BcUC-5y8.js +3 -0
  13. package/dist/assets/{Plugins-BtChISho.js → Plugins-BnKx9flD.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DBA5EcYy.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
  15. package/dist/assets/{SaveBar-I0_dWNTX.js → SaveBar-CWr0O_w-.js} +1 -1
  16. package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-5GTEveb2.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
  18. package/dist/assets/Settings-DluB-vN1.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-DNBFGrYE.js → SkillReferenceGraph-CHLSseay.js} +1 -1
  20. package/dist/assets/{Skills-DJB6-bBM.js → Skills-gNdo_HNK.js} +2 -2
  21. package/dist/assets/{SystemPrompt-BiDDrJUA.js → SystemPrompt-Cru05-Ia.js} +1 -1
  22. package/dist/assets/{TagLibrary-_Wrevtop.js → TagLibrary-DNHY0xou.js} +1 -1
  23. package/dist/assets/{TiptapBody-OWWXdLRy.js → TiptapBody-I4lmbCgP.js} +1 -1
  24. package/dist/assets/{Toggle-B122N0HL.js → Toggle-bWMHjmRh.js} +1 -1
  25. package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
  26. package/dist/assets/{index-DhvuQL4C.js → index-fc_JjdxL.js} +724 -724
  27. package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +15 -14
  30. package/plugins/CLAUDE.md +61 -0
  31. package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
  32. package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
  33. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
  34. package/scripts/scheduler-mcp-server.cjs +7 -0
  35. package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
  36. package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
  37. package/src/main/__tests__/epicMint.test.cjs +2 -2
  38. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  39. package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
  40. package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
  41. package/src/main/__tests__/machineProfile.test.cjs +19 -1
  42. package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
  43. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
  44. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
  45. package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
  46. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
  47. package/src/main/__tests__/prdLocations.test.cjs +100 -2
  48. package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
  49. package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
  50. package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
  51. package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
  52. package/src/main/__tests__/queue-starvation-per-project.test.cjs +147 -0
  53. package/src/main/__tests__/queueHistory.test.cjs +63 -0
  54. package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
  55. package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
  56. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
  57. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
  58. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
  59. package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
  60. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
  61. package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
  62. package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
  63. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
  64. package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
  65. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
  66. package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
  67. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
  68. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
  69. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
  70. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +154 -0
  71. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
  72. package/src/main/__tests__/telemetryClient.test.cjs +75 -5
  73. package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
  74. package/src/main/chatRunner.cjs +8 -5
  75. package/src/main/health.cjs +76 -3
  76. package/src/main/historyAggregator.cjs +5 -0
  77. package/src/main/index.cjs +95 -44
  78. package/src/main/ipcSchemas.cjs +47 -0
  79. package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
  80. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
  81. package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
  82. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
  83. package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
  84. package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
  85. package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
  86. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
  87. package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
  88. package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
  89. package/src/main/lib/__tests__/telemetryBoot.test.cjs +11 -0
  90. package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
  91. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
  92. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
  93. package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
  94. package/src/main/lib/agentModelResolve.cjs +65 -27
  95. package/src/main/lib/bootSelfHeal.cjs +88 -0
  96. package/src/main/lib/delegationReadiness.cjs +290 -225
  97. package/src/main/lib/effectiveModelInfo.cjs +333 -0
  98. package/src/main/lib/ephemeralCwd.cjs +1 -1
  99. package/src/main/lib/epicMint.cjs +3 -3
  100. package/src/main/lib/gitWorktree.cjs +42 -12
  101. package/src/main/lib/guardShims.cjs +156 -0
  102. package/src/main/lib/jobDirtFilter.cjs +7 -2
  103. package/src/main/lib/launchFailure.cjs +2 -1
  104. package/src/main/lib/loadGate.cjs +23 -1
  105. package/src/main/lib/machineProfile.cjs +15 -0
  106. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  107. package/src/main/lib/needsReviewLedger.cjs +205 -0
  108. package/src/main/lib/opsErrorLog.cjs +1 -1
  109. package/src/main/lib/opsOwnership.cjs +1 -1
  110. package/src/main/lib/prdCreate.cjs +56 -1
  111. package/src/main/lib/prdDisposition.cjs +199 -0
  112. package/src/main/lib/prdFrontmatter.cjs +8 -2
  113. package/src/main/lib/prdLocations.cjs +167 -45
  114. package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
  115. package/src/main/lib/projectPageSummarySchema.cjs +1 -1
  116. package/src/main/lib/projectRootResolve.cjs +1 -1
  117. package/src/main/lib/queueHistory.cjs +19 -1
  118. package/src/main/lib/queueStore.cjs +6 -1
  119. package/src/main/lib/reaperHelpers.cjs +181 -15
  120. package/src/main/lib/scheduleJobSchema.cjs +8 -0
  121. package/src/main/lib/scheduleJobTransitions.cjs +33 -0
  122. package/src/main/lib/schedulerBatch.cjs +12 -1
  123. package/src/main/lib/schedulerConfig.cjs +37 -0
  124. package/src/main/lib/telemetryBoot.cjs +11 -8
  125. package/src/main/lib/telemetryClient.cjs +44 -2
  126. package/src/main/lib/telemetrySettings.cjs +20 -3
  127. package/src/main/lib/usageCircuit.cjs +159 -0
  128. package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
  129. package/src/main/pty.cjs +9 -0
  130. package/src/main/scheduler/prdParser.cjs +13 -0
  131. package/src/main/scheduler.cjs +1869 -193
  132. package/src/main/templates/PRD_AUTHORING.md +50 -0
  133. package/src/main/templates/project-pages-catalog.json +1 -1
  134. package/src/main/usage.cjs +21 -3
  135. package/src/preload/api.d.ts +92 -1
  136. package/src/preload/index.cjs +10 -0
  137. package/web/README.md +41 -0
  138. package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
  139. package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
  140. package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
  141. package/dist/assets/AgentLibrary-Bkv-HcP1.js +0 -3
  142. package/dist/assets/MarkdownEditor-Xc141kjj.js +0 -1
  143. package/dist/assets/Permissions-DKoNVgzj.js +0 -3
  144. package/dist/assets/Scheduler-CbES7MC8.js +0 -14
  145. package/dist/assets/Settings-BX3FElXk.js +0 -3
  146. package/dist/assets/settingsSchema-sGoCTd7J.js +0 -3
  147. /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * effectiveModelInfo.test.cjs — the main-process half of "what will this
3
+ * Epic actually run as": overlay-aware persona alias resolution plus
4
+ * evidence-based concrete model id (scheduler run log, then session
5
+ * transcript), never fabricated when there's no evidence.
6
+ *
7
+ * All fixtures live under a per-test mkdtemp'd $HOME (never the real
8
+ * ~/.claude) so persona lookup (which reads os.homedir()), scheduler run
9
+ * logs, and transcripts all resolve consistently.
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/effectiveModelInfo.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect, beforeEach, afterEach } from 'vitest';
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+ const os = require('node:os');
20
+ const { encodeCwd } = require('../encodeCwd.cjs');
21
+ const { resolveEffectiveModelInfo, isConcreteModelId, computeEffortReachable } = require('../effectiveModelInfo.cjs');
22
+ // config.cjs's `allowedRoots` is a module-level singleton seeded from
23
+ // os.homedir() at FIRST require — since this test's HOME changes every test
24
+ // (fresh mkdtemp), a persona lookup (getPersonaBody -> validatePath) would
25
+ // only pass for whichever tmpHome happened to be active the first time
26
+ // config.cjs got lazily required. addAllowedRoot mutates the live Set
27
+ // instead, so every test's own tmpHome is explicitly admitted.
28
+ const { addAllowedRoot } = require('../../config.cjs');
29
+
30
+ let tmpHome;
31
+ let originalHome;
32
+ let cwd;
33
+
34
+ beforeEach(() => {
35
+ originalHome = process.env.HOME;
36
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-effective-model-home-'));
37
+ process.env.HOME = tmpHome;
38
+ addAllowedRoot(tmpHome);
39
+ cwd = path.join(tmpHome, 'project');
40
+ fs.mkdirSync(cwd, { recursive: true });
41
+ });
42
+
43
+ afterEach(() => {
44
+ process.env.HOME = originalHome;
45
+ fs.rmSync(tmpHome, { recursive: true, force: true });
46
+ });
47
+
48
+ function writePersona(dir, name, frontmatterLines) {
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ const lines = ['---', ...frontmatterLines, '---', 'Body text.'];
51
+ fs.writeFileSync(path.join(dir, `${name}.md`), lines.join('\n'));
52
+ }
53
+
54
+ function writeGlobalPersona(name, frontmatterLines) {
55
+ writePersona(path.join(tmpHome, '.claude', 'agents'), name, frontmatterLines);
56
+ }
57
+
58
+ function writeOverlayPersona(name, frontmatterLines) {
59
+ writePersona(path.join(cwd, '.claude', 'agents'), name, frontmatterLines);
60
+ }
61
+
62
+ function writeActiveIndex(sessions) {
63
+ const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
64
+ fs.mkdirSync(dir, { recursive: true });
65
+ fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions, events: {} }, null, 2));
66
+ }
67
+
68
+ function writeQueueJobs(jobs) {
69
+ const dir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
70
+ fs.mkdirSync(dir, { recursive: true });
71
+ fs.writeFileSync(path.join(dir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
72
+ }
73
+
74
+ function writeHistoryEntries(entries) {
75
+ const dir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ fs.writeFileSync(path.join(dir, 'history.jsonl'), entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
78
+ }
79
+
80
+ function writeRunLog(runId, slug, text) {
81
+ const dir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ fs.writeFileSync(path.join(dir, `${slug}.log`), text);
84
+ }
85
+
86
+ function writeTranscript(sessionId, lines) {
87
+ const dir = path.join(tmpHome, '.claude', 'projects', encodeCwd(cwd));
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ fs.writeFileSync(path.join(dir, `${sessionId}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
90
+ }
91
+
92
+ // ---------- persona alias resolution ----------
93
+
94
+ test('resolves a global persona alias with modelSource "persona" and no fabricated evidence', async () => {
95
+ writeGlobalPersona('writer', ['model: sonnet']);
96
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'writer' });
97
+ expect(info.modelAlias).toBe('sonnet');
98
+ expect(info.modelSource).toBe('persona');
99
+ expect(info.resolvedModelId).toBeNull();
100
+ expect(info.resolvedFrom).toBeNull();
101
+ });
102
+
103
+ test('a persona model of "inherit" reports modelAlias null and modelSource "inherit"', async () => {
104
+ writeGlobalPersona('inheriting', ['model: inherit']);
105
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'inheriting' });
106
+ expect(info.modelAlias).toBeNull();
107
+ expect(info.modelSource).toBe('inherit');
108
+ });
109
+
110
+ test('a persona with no model field at all is treated the same as inherit', async () => {
111
+ writeGlobalPersona('bare', []);
112
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'bare' });
113
+ expect(info.modelAlias).toBeNull();
114
+ expect(info.modelSource).toBe('inherit');
115
+ });
116
+
117
+ test('a persona pinned to an already-concrete model id echoes it back with resolvedFrom null, ignoring conflicting evidence', async () => {
118
+ writeGlobalPersona('pinned', ['model: claude-opus-5-2027']);
119
+ // Conflicting scheduler evidence exists but must never override an
120
+ // already-concrete alias — no evidence lookup should even be attempted.
121
+ writeQueueJobs([{ slug: 'x', runId: 'r1', agentType: 'pinned', finishedAt: new Date().toISOString() }]);
122
+ writeRunLog('r1', 'x', '[scheduler] agentType=pinned persona=/whatever model=claude-sonnet-1\n');
123
+
124
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'pinned' });
125
+ expect(info.modelAlias).toBe('claude-opus-5-2027');
126
+ expect(info.modelSource).toBe('persona');
127
+ expect(info.resolvedModelId).toBe('claude-opus-5-2027');
128
+ expect(info.resolvedFrom).toBeNull();
129
+ });
130
+
131
+ test('a project-overlay persona wins over the global definition and reports modelSource "persona-overlay"', async () => {
132
+ writeGlobalPersona('shared-name', ['model: sonnet']);
133
+ writeOverlayPersona('shared-name', ['model: opus']);
134
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'shared-name' });
135
+ expect(info.modelAlias).toBe('opus');
136
+ expect(info.modelSource).toBe('persona-overlay');
137
+ });
138
+
139
+ test('a dangling agentType (no persona file anywhere) resolves modelSource "fallback" without throwing', async () => {
140
+ await expect(resolveEffectiveModelInfo({ cwd, agentType: 'ghost-persona' })).resolves.toBeTruthy();
141
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'ghost-persona' });
142
+ expect(info.modelAlias).toBeNull();
143
+ expect(info.modelSource).toBe('fallback');
144
+ expect(info.resolvedModelId).toBeNull();
145
+ });
146
+
147
+ // ---------- concrete-model-id evidence ----------
148
+
149
+ test('evidence hit via the scheduler run record: reads the model logged in the most recent matching job\'s run log', async () => {
150
+ writeGlobalPersona('runner', ['model: sonnet']);
151
+ writeQueueJobs([
152
+ { slug: 'older', runId: 'run-older', agentType: 'runner', finishedAt: '2026-01-01T00:00:00.000Z' },
153
+ { slug: 'newer', runId: 'run-newer', agentType: 'runner', finishedAt: '2026-02-01T00:00:00.000Z' },
154
+ ]);
155
+ writeRunLog('run-older', 'older', '[scheduler] agentType=runner persona=/p/runner.md model=claude-old-1\n');
156
+ writeRunLog('run-newer', 'newer', '[scheduler] agentType=runner persona=/p/runner.md model=claude-sonnet-5\n');
157
+
158
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'runner' });
159
+ expect(info.resolvedModelId).toBe('claude-sonnet-5');
160
+ expect(info.resolvedFrom).toBe('scheduler-run');
161
+ });
162
+
163
+ test('scheduler-run evidence also matches jobs already archived into history.jsonl', async () => {
164
+ writeGlobalPersona('archived-runner', ['model: opus']);
165
+ writeHistoryEntries([
166
+ { slug: 'old-hist', runId: 'run-hist', agentType: 'archived-runner', finishedAt: '2026-01-01T00:00:00.000Z' },
167
+ ]);
168
+ writeRunLog('run-hist', 'old-hist', '[scheduler] agentType=archived-runner persona=/p/x.md model=claude-opus-5\n');
169
+
170
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'archived-runner' });
171
+ expect(info.resolvedModelId).toBe('claude-opus-5');
172
+ expect(info.resolvedFrom).toBe('scheduler-run');
173
+ });
174
+
175
+ test('evidence hit via transcript when no scheduler run exists: takes the LAST "model" occurrence', async () => {
176
+ writeGlobalPersona('chatty', ['model: opus']);
177
+ writeActiveIndex({
178
+ 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-1', agentType: 'chatty', createdAt: '2026-03-01T00:00:00.000Z' },
179
+ });
180
+ writeTranscript('sess-1', [
181
+ { type: 'assistant', message: { model: 'claude-opus-4' } },
182
+ { type: 'assistant', message: { model: 'claude-opus-5' } },
183
+ ]);
184
+
185
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'chatty' });
186
+ expect(info.resolvedModelId).toBe('claude-opus-5');
187
+ expect(info.resolvedFrom).toBe('transcript');
188
+ });
189
+
190
+ test('transcript evidence prefers the most recently created matching Epic', async () => {
191
+ writeGlobalPersona('multi-epic', ['model: haiku']);
192
+ writeActiveIndex({
193
+ 'epic-old': { id: 'epic-old', claudeSessionId: 'sess-old', agentType: 'multi-epic', createdAt: '2026-01-01T00:00:00.000Z' },
194
+ 'epic-new': { id: 'epic-new', claudeSessionId: 'sess-new', agentType: 'multi-epic', createdAt: '2026-05-01T00:00:00.000Z' },
195
+ });
196
+ writeTranscript('sess-old', [{ model: 'claude-haiku-old' }]);
197
+ writeTranscript('sess-new', [{ model: 'claude-haiku-new' }]);
198
+
199
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'multi-epic' });
200
+ expect(info.resolvedModelId).toBe('claude-haiku-new');
201
+ expect(info.resolvedFrom).toBe('transcript');
202
+ });
203
+
204
+ test('evidence miss (no scheduler run, no transcript) reports resolvedModelId/resolvedFrom as null — never a guessed pricing-key fallback', async () => {
205
+ writeGlobalPersona('unseen', ['model: haiku']);
206
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'unseen' });
207
+ expect(info.resolvedModelId).toBeNull();
208
+ expect(info.resolvedFrom).toBeNull();
209
+ });
210
+
211
+ // ---------- edge cases ----------
212
+
213
+ test('never throws for a missing cwd/agentType', async () => {
214
+ await expect(resolveEffectiveModelInfo({})).resolves.toMatchObject({
215
+ modelAlias: null,
216
+ modelSource: 'fallback',
217
+ resolvedModelId: null,
218
+ resolvedFrom: null,
219
+ });
220
+ });
221
+
222
+ test('isConcreteModelId treats known CLI aliases as non-concrete and everything else as concrete', () => {
223
+ expect(isConcreteModelId('opus')).toBe(false);
224
+ expect(isConcreteModelId('sonnet')).toBe(false);
225
+ expect(isConcreteModelId('haiku')).toBe(false);
226
+ expect(isConcreteModelId('fable')).toBe(false);
227
+ expect(isConcreteModelId(null)).toBe(false);
228
+ expect(isConcreteModelId('claude-opus-5')).toBe(true);
229
+ });
230
+
231
+ test('effortReachable is always false for an app-launched session — cleanChildEnv strips CLAUDE_EFFORT and CLAUDE_CODE_* before a child ever sees them', () => {
232
+ expect(computeEffortReachable({})).toBe(false);
233
+ });
234
+
235
+ test('resolveEffectiveModelInfo surfaces effortReachable alongside the model fields', async () => {
236
+ writeGlobalPersona('any', ['model: sonnet']);
237
+ const info = await resolveEffectiveModelInfo({ cwd, agentType: 'any' });
238
+ expect(info.effortReachable).toBe(false);
239
+ });
@@ -300,6 +300,95 @@ test('[job] carry-over failure (git apply cannot apply) degrades cleanly: worktr
300
300
  }
301
301
  });
302
302
 
303
+ // ──────────────────────────────────────────── PRD: stream the base-WIP diff capture (job 1192)
304
+
305
+ test('[job] a wide dirty tree (300+ changed paths incl. a multi-MB file) captures and carries over without a maxBuffer overflow', async () => {
306
+ const files = [];
307
+ for (let i = 0; i < 300; i++) {
308
+ const rel = `wide-${i}.txt`;
309
+ fs.writeFileSync(path.join(repoCwd, rel), `initial ${i}\n`, 'utf8');
310
+ files.push(rel);
311
+ }
312
+ const bigRel = 'big-file.bin';
313
+ fs.writeFileSync(path.join(repoCwd, bigRel), Buffer.alloc(1024, 1));
314
+ git(['add', '-A'], repoCwd);
315
+ git(['commit', '-q', '-m', 'wide baseline'], repoCwd);
316
+
317
+ // Dirty every tracked file (uncommitted tracked edits) and grow the binary
318
+ // file well past Node's execFile default maxBuffer (1MB) — the exact shape
319
+ // that overflowed the old buffered capture (job 1192: ~240 dirty paths).
320
+ for (const rel of files) fs.appendFileSync(path.join(repoCwd, rel), 'dirtied\n', 'utf8');
321
+ const bigBuf = Buffer.alloc(3 * 1024 * 1024); // 3MB of NUL bytes — git detects this as binary
322
+ fs.writeFileSync(path.join(repoCwd, bigRel), bigBuf);
323
+
324
+ const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'wide-tree' });
325
+ expect(result.ok).toBe(true);
326
+ expect(result.carriedPaths.length).toBe(files.length + 1);
327
+ expect(fs.readFileSync(path.join(result.dir, files[0]), 'utf8')).toContain('dirtied');
328
+ expect(Buffer.compare(fs.readFileSync(path.join(result.dir, bigRel)), bigBuf)).toBe(0);
329
+
330
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
331
+ }, 30_000);
332
+
333
+ test('[job] a renamed file and a mutated binary file survive the capture/apply round trip using the same flags as production', async () => {
334
+ const binRel = 'asset.bin';
335
+ const binInitial = Buffer.from([0, 1, 2, 3, 4, 0, 255, 254]);
336
+ fs.writeFileSync(path.join(repoCwd, binRel), binInitial);
337
+ fs.writeFileSync(path.join(repoCwd, 'to-rename.txt'), 'original name content\n', 'utf8');
338
+ git(['add', '-A'], repoCwd);
339
+ git(['commit', '-q', '-m', 'binary + rename baseline'], repoCwd);
340
+
341
+ // Dirty tree: a tracked rename plus a mutated binary file, both uncommitted.
342
+ git(['mv', 'to-rename.txt', 'renamed.txt'], repoCwd);
343
+ const binChanged = Buffer.from([9, 8, 7, 0, 6, 5, 0, 4]);
344
+ fs.writeFileSync(path.join(repoCwd, binRel), binChanged);
345
+
346
+ const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'rename-binary' });
347
+ expect(result.ok).toBe(true);
348
+ expect(fs.existsSync(path.join(result.dir, 'to-rename.txt'))).toBe(false);
349
+ expect(fs.readFileSync(path.join(result.dir, 'renamed.txt'), 'utf8')).toBe('original name content\n');
350
+ expect(Buffer.compare(fs.readFileSync(path.join(result.dir, binRel)), binChanged)).toBe(0);
351
+
352
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
353
+ });
354
+
355
+ test('[job] a capture that cannot write its diff file (target dir missing) falls back with a specific reason, never the old generic maxBuffer message', async () => {
356
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
357
+ const bogusDir = path.join(tmpRoot, 'does-not-exist');
358
+ const result = await gitWorktree.captureAndCarryBaseDiff({ cwd: repoCwd, dir: bogusDir });
359
+ expect(result.ok).toBe(false);
360
+ expect(result.reason).toMatch(/capturing base diff failed/);
361
+ expect(result.reason).not.toMatch(/maxBuffer/i);
362
+ expect(fs.existsSync(bogusDir)).toBe(false); // nothing leaked into a directory that never existed
363
+ });
364
+
365
+ test('[job] the temp diff file is removed after carry-over on both the success and the git-apply-failure paths', async () => {
366
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
367
+ const success = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cleanup-ok' });
368
+ expect(success.ok).toBe(true);
369
+ expect(fs.readdirSync(success.dir).filter((f) => f.startsWith('.sm-worktree-carry-'))).toEqual([]);
370
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: success.dir, branch: success.branch });
371
+
372
+ // Force a git-apply failure on a genuine clean-base worktree by making the
373
+ // target file read-only before the capture/apply call, so the patch writes
374
+ // fine but applying it fails — the finally block must still remove the temp
375
+ // patch file even though apply itself errored.
376
+ const clean = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cleanup-fail' });
377
+ expect(clean.ok).toBe(true);
378
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'another uncommitted tracked edit\n', 'utf8');
379
+ const targetFile = path.join(clean.dir, 'README.md');
380
+ fs.chmodSync(targetFile, 0o444);
381
+ try {
382
+ const fail = await gitWorktree.captureAndCarryBaseDiff({ cwd: repoCwd, dir: clean.dir });
383
+ expect(fail.ok).toBe(false);
384
+ expect(fail.reason).toMatch(/git apply failed/);
385
+ } finally {
386
+ fs.chmodSync(targetFile, 0o644);
387
+ }
388
+ expect(fs.readdirSync(clean.dir).filter((f) => f.startsWith('.sm-worktree-carry-'))).toEqual([]);
389
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: clean.dir, branch: clean.branch });
390
+ }, 30_000);
391
+
303
392
  test('[job] falls back once the concurrency cap is reached, and recovers after cleanup', async () => {
304
393
  process.env.SM_JOB_WORKTREE_MAX = '1';
305
394
  const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-1' });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * guardShims.test.cjs — unit tests for the stable, app-upgrade-proof guard
3
+ * hook indirection (guardShims.cjs). Uses a temp "home" dir per test so the
4
+ * real ~/.claude is never touched.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/guardShims.test.cjs
7
+ */
8
+
9
+ import { test, expect, afterEach } from 'vitest';
10
+ const fs = require('node:fs');
11
+ const fsp = require('node:fs/promises');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { execFileSync } = require('node:child_process');
15
+ const {
16
+ APP_ROOT,
17
+ GUARD_NAMES,
18
+ hooksDir,
19
+ pointerPath,
20
+ shimPath,
21
+ writeGuardShims,
22
+ guardShimsExist,
23
+ } = require('../guardShims.cjs');
24
+
25
+ const tmpDirs = [];
26
+ afterEach(async () => {
27
+ while (tmpDirs.length) {
28
+ const d = tmpDirs.pop();
29
+ await fsp.rm(d, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ async function mkTmp(prefix) {
34
+ const d = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
35
+ tmpDirs.push(d);
36
+ return d;
37
+ }
38
+
39
+ /** Run a guard (by absolute path, e.g. a shim) as a child process, same
40
+ * pattern delegationReadiness.test.cjs uses to exercise the real guards —
41
+ * NEVER require() a guard/shim in-process, since its main() calls
42
+ * process.exit(0) as a require-time side effect. */
43
+ function runGuard(scriptPath, { toolName = 'Write', toolInput, cwd }) {
44
+ return JSON.parse(execFileSync('node', [scriptPath], {
45
+ input: JSON.stringify({ tool_name: toolName, tool_input: toolInput, cwd }),
46
+ encoding: 'utf8',
47
+ }));
48
+ }
49
+
50
+ test('writeGuardShims: writes the pointer file and all three shims under homeDir', async () => {
51
+ const homeDir = await mkTmp('sm-guard-shims-home-');
52
+ const r = await writeGuardShims({ homeDir });
53
+ expect(r.ok).toBe(true);
54
+
55
+ expect(fs.existsSync(pointerPath(homeDir))).toBe(true);
56
+ expect(JSON.parse(fs.readFileSync(pointerPath(homeDir), 'utf8'))).toEqual({ appRoot: APP_ROOT });
57
+ for (const name of GUARD_NAMES) {
58
+ expect(fs.existsSync(shimPath(name, homeDir))).toBe(true);
59
+ }
60
+ expect(guardShimsExist(homeDir)).toBe(true);
61
+ });
62
+
63
+ test('writeGuardShims: does NOT vendor guard logic — the shim body only references the guard by name, never copies its source', async () => {
64
+ const homeDir = await mkTmp('sm-guard-shims-home-');
65
+ await writeGuardShims({ homeDir });
66
+
67
+ const realSource = fs.readFileSync(path.join(APP_ROOT, 'scripts', 'hooks', 'guard-prd-writes.cjs'), 'utf8');
68
+ const shimSource = fs.readFileSync(shimPath('guard-prd-writes.cjs', homeDir), 'utf8');
69
+ expect(shimSource).not.toContain('SCHEDULER_SEGMENT'); // a guard-prd-writes-specific symbol
70
+ expect(shimSource.length).toBeLessThan(realSource.length / 2);
71
+ expect(shimSource).toContain("require(path.join(ptr.appRoot, 'scripts', 'hooks', 'guard-prd-writes.cjs'))");
72
+ });
73
+
74
+ test('writeGuardShims: idempotent — re-running with the same appRoot produces byte-identical files', async () => {
75
+ const homeDir = await mkTmp('sm-guard-shims-home-');
76
+ const r1 = await writeGuardShims({ homeDir });
77
+ expect(r1.ok).toBe(true);
78
+ const pointerBefore = fs.readFileSync(pointerPath(homeDir), 'utf8');
79
+ const shimsBefore = GUARD_NAMES.map((n) => fs.readFileSync(shimPath(n, homeDir), 'utf8'));
80
+
81
+ const r2 = await writeGuardShims({ homeDir });
82
+ expect(r2.ok).toBe(true);
83
+ expect(fs.readFileSync(pointerPath(homeDir), 'utf8')).toBe(pointerBefore);
84
+ GUARD_NAMES.forEach((n, i) => {
85
+ expect(fs.readFileSync(shimPath(n, homeDir), 'utf8')).toBe(shimsBefore[i]);
86
+ });
87
+ });
88
+
89
+ test('writeGuardShims: the installed shim actually resolves through the pointer and runs the real guard logic', async () => {
90
+ const homeDir = await mkTmp('sm-guard-shims-home-');
91
+ const cwd = await mkTmp('sm-guard-shims-cwd-');
92
+ await writeGuardShims({ homeDir });
93
+
94
+ const denied = runGuard(shimPath('guard-prd-writes.cjs', homeDir), {
95
+ toolInput: { file_path: path.join(cwd, 'session-manager-operations', 'scheduler', 'prds', '1234-x.md'), content: '# x' },
96
+ cwd,
97
+ });
98
+ expect(denied.hookSpecificOutput?.permissionDecision).toBe('deny');
99
+
100
+ const allowed = runGuard(shimPath('guard-prd-writes.cjs', homeDir), {
101
+ toolInput: { file_path: path.join(cwd, 'src', 'x.ts'), content: 'x' },
102
+ cwd,
103
+ });
104
+ expect(allowed.hookSpecificOutput?.permissionDecision).not.toBe('deny');
105
+ });
106
+
107
+ test('writeGuardShims: rewriting the pointer to a DIFFERENT app root lets an already-installed shim follow an app upgrade with no re-install', async () => {
108
+ const homeDir = await mkTmp('sm-guard-shims-home-');
109
+ const cwd = await mkTmp('sm-guard-shims-cwd-');
110
+ await writeGuardShims({ homeDir }); // "first install", points at the real APP_ROOT
111
+ const shimFile = shimPath('guard-prd-writes.cjs', homeDir);
112
+ const shimBytesBefore = fs.readFileSync(shimFile, 'utf8');
113
+
114
+ // Simulate an app upgrade: a second "app root" at a brand-new path, with
115
+ // its own copy of scripts/hooks/guard-prd-writes.cjs (this is the ONLY file
116
+ // guardShims ever reads through the pointer at invocation time).
117
+ const secondAppRoot = await mkTmp('sm-guard-shims-upgraded-app-');
118
+ await fsp.mkdir(path.join(secondAppRoot, 'scripts', 'hooks'), { recursive: true });
119
+ await fsp.copyFile(
120
+ path.join(APP_ROOT, 'scripts', 'hooks', 'guard-prd-writes.cjs'),
121
+ path.join(secondAppRoot, 'scripts', 'hooks', 'guard-prd-writes.cjs'),
122
+ );
123
+
124
+ const r = await writeGuardShims({ homeDir, appRoot: secondAppRoot });
125
+ expect(r.ok).toBe(true);
126
+ expect(JSON.parse(fs.readFileSync(pointerPath(homeDir), 'utf8')).appRoot).toBe(secondAppRoot);
127
+ // The shim FILE at the already-installed path never changed.
128
+ expect(fs.readFileSync(shimFile, 'utf8')).toBe(shimBytesBefore);
129
+
130
+ // A project hook that already references this exact shim path keeps working.
131
+ const denied = runGuard(shimFile, {
132
+ toolInput: { file_path: path.join(cwd, 'session-manager-operations', 'scheduler', 'prds', '1234-x.md'), content: '# x' },
133
+ cwd,
134
+ });
135
+ expect(denied.hookSpecificOutput?.permissionDecision).toBe('deny');
136
+ });
137
+
138
+ test('writeGuardShims: returns { ok:false, error } without throwing when homeDir cannot be written to', async () => {
139
+ const base = await mkTmp('sm-guard-shims-unwritable-');
140
+ const blocker = path.join(base, 'blocker-file');
141
+ await fsp.writeFile(blocker, 'x', 'utf8');
142
+ // A homeDir whose parent path segment is a plain FILE can never be mkdir'd
143
+ // into (ENOTDIR) — reproduces "unwritable ~/.claude" without relying on
144
+ // permission bits, which root would bypass.
145
+ const homeDir = path.join(blocker, 'home');
146
+
147
+ const r = await writeGuardShims({ homeDir });
148
+ expect(r.ok).toBe(false);
149
+ expect(r.error).toBeTruthy();
150
+ expect(guardShimsExist(homeDir)).toBe(false);
151
+ });
@@ -125,7 +125,7 @@ test('an ungated tick never audits', () => {
125
125
  assert.equal(g.evaluate().shouldAudit, false);
126
126
  });
127
127
 
128
- test('escalates once the gated stretch exceeds the escalation window, and the stretch resets when load drops', () => {
128
+ test('escalates once the gated stretch exceeds the escalation window, and the stretch only resets once load has stayed sub-threshold past the release window', () => {
129
129
  let l1 = 12.95;
130
130
  let t = 0;
131
131
  const g = createLoadGate({
@@ -134,6 +134,7 @@ test('escalates once the gated stretch exceeds the escalation window, and the st
134
134
  now: () => t,
135
135
  threshold: 0.85,
136
136
  escalateAfterMs: 45 * 60_000,
137
+ releaseWindowMs: 2 * 60_000,
137
138
  });
138
139
  assert.equal(g.evaluate().escalate, false);
139
140
  t += 44 * 60_000;
@@ -142,12 +143,43 @@ test('escalates once the gated stretch exceeds the escalation window, and the st
142
143
  const r = g.evaluate();
143
144
  assert.equal(r.escalate, true, 'at 45m: escalates');
144
145
  assert.equal(r.gatedSinceMs, 45 * 60_000);
146
+ // A single sub-threshold sample must NOT reset the stretch (the boundary-
147
+ // hovering bug this PRD fixes) — gatedSinceMs keeps growing.
145
148
  l1 = 1; t += 60_000;
146
- assert.equal(g.evaluate().gatedSinceMs, 0, 'load dropped: stretch resets');
149
+ const oneMinBelow = g.evaluate();
150
+ assert.equal(oneMinBelow.gated, false, 'gate decision is immediate');
151
+ assert.equal(oneMinBelow.gatedSinceMs, 46 * 60_000, 'stretch not yet released');
152
+ // Load climbs back above threshold before the release window elapses: the
153
+ // stretch was never actually cleared, so it just keeps accumulating.
154
+ l1 = 12.95; t += 30_000;
155
+ assert.equal(g.evaluate().gatedSinceMs, 46 * 60_000 + 30_000, 'stretch survived the brief dip');
156
+ // Now hold sub-threshold continuously past the release window (the window
157
+ // is measured from the first sub-threshold sample of this sustained drop,
158
+ // so it takes a tick to mark that start, then another once the window has
159
+ // actually elapsed).
160
+ l1 = 1; t += 1;
161
+ g.evaluate();
162
+ t += 2 * 60_000;
163
+ assert.equal(g.evaluate().gatedSinceMs, 0, 'sustained sub-threshold load past the release window clears the stretch');
147
164
  l1 = 12.95; t += 60_000;
148
165
  assert.equal(g.evaluate().gatedSinceMs, 0, 'a fresh stretch starts from zero');
149
166
  });
150
167
 
168
+ test('the gate decision itself is unaffected by hysteresis: a sub-threshold tick is never gated, even mid-stretch', () => {
169
+ let l1 = 12.95;
170
+ let t = 0;
171
+ const g = createLoadGate({
172
+ loadavg: () => [l1, 0, 0],
173
+ cores: () => 14,
174
+ now: () => t,
175
+ threshold: 0.85,
176
+ releaseWindowMs: 5 * 60_000,
177
+ });
178
+ assert.equal(g.evaluate().gated, true);
179
+ l1 = 1; t += 60_000;
180
+ assert.equal(g.evaluate().gated, false, 'below threshold: gated:false immediately, hysteresis notwithstanding');
181
+ });
182
+
151
183
  test('snapshot() reflects the last evaluation and is null before any', () => {
152
184
  const { g } = gateWith({});
153
185
  assert.equal(g.snapshot(), null);
@@ -157,3 +189,72 @@ test('snapshot() reflects the last evaluation and is null before any', () => {
157
189
  assert.equal(typeof s.at, 'string');
158
190
  assert.equal('shouldAudit' in s, false, 'per-tick flags are not part of the persisted snapshot');
159
191
  });
192
+
193
+ test('snapshot() exposes gated, ratio, threshold, loadavg1, cores and gatedSinceMs (what buildScheduleStatePayload surfaces as loadGate)', () => {
194
+ const { g } = gateWith({});
195
+ g.evaluate();
196
+ const s = g.snapshot();
197
+ assert.equal(typeof s.gated, 'boolean');
198
+ assert.equal(typeof s.ratio, 'number');
199
+ assert.equal(typeof s.threshold, 'number');
200
+ assert.equal(typeof s.loadavg1, 'number');
201
+ assert.equal(typeof s.cores, 'number');
202
+ assert.equal(typeof s.gatedSinceMs, 'number');
203
+ });
204
+
205
+ // ─── hysteresis: the real observed boundary-hovering sequence ──────────────
206
+
207
+ test('a boundary-hovering box (real observed sequence, alternating above/below threshold) grows gatedSinceMs monotonically across 50 simulated minutes and eventually escalates', () => {
208
+ // 14 cores, 0.85/core = 11.9 threshold; loadavg1 observed 2026-09-12
209
+ // oscillating 11.07 - 13.31, straddling the threshold every tick. Before
210
+ // this PRD, the immediate reset on ANY sub-threshold sample meant every
211
+ // one of these ticks reset gatedSince to null and escalate never fired.
212
+ const CORES = 14;
213
+ const THRESHOLD = 0.85;
214
+ const SEQUENCE = [11.07, 13.31, 11.51, 12.8];
215
+ let l1 = SEQUENCE[0];
216
+ let t = 0;
217
+ const g = createLoadGate({
218
+ loadavg: () => [l1, 0, 0],
219
+ cores: () => CORES,
220
+ now: () => t,
221
+ threshold: THRESHOLD,
222
+ escalateAfterMs: 20 * 60_000, // shorter than the 45m default so 50 sim-minutes crosses it
223
+ });
224
+
225
+ const TICK_MS = 60_000;
226
+ const TOTAL_MS = 50 * 60_000;
227
+ let prevGatedSinceMs = -1;
228
+ let sawEscalate = false;
229
+ let i = 0;
230
+ for (let elapsed = 0; elapsed < TOTAL_MS; elapsed += TICK_MS) {
231
+ l1 = SEQUENCE[i % SEQUENCE.length];
232
+ i += 1;
233
+ const r = g.evaluate();
234
+ assert.ok(r.gatedSinceMs >= prevGatedSinceMs, `gatedSinceMs must not shrink (was ${prevGatedSinceMs}, now ${r.gatedSinceMs})`);
235
+ prevGatedSinceMs = r.gatedSinceMs;
236
+ if (r.escalate) sawEscalate = true;
237
+ t += TICK_MS;
238
+ }
239
+ assert.equal(sawEscalate, true, 'escalate must fire once the (never-reset) stretch exceeds escalateAfterMs');
240
+ });
241
+
242
+ test('the release window actually releases: sustained sub-threshold load past the window reports gated:false and gatedSinceMs:0 on the next tick', () => {
243
+ let l1 = 13.31;
244
+ let t = 0;
245
+ const g = createLoadGate({
246
+ loadavg: () => [l1, 0, 0],
247
+ cores: () => 14,
248
+ now: () => t,
249
+ threshold: 0.85,
250
+ releaseWindowMs: 2 * 60_000,
251
+ });
252
+ assert.equal(g.evaluate().gated, true);
253
+ l1 = 5;
254
+ t += 1;
255
+ g.evaluate(); // marks the start of the sustained sub-threshold run
256
+ t += 2 * 60_000;
257
+ const r = g.evaluate();
258
+ assert.equal(r.gated, false);
259
+ assert.equal(r.gatedSinceMs, 0);
260
+ });
@@ -29,7 +29,7 @@ const fsp = require('node:fs/promises');
29
29
  const os = require('node:os');
30
30
  const path = require('node:path');
31
31
  const queueStore = require('../queueStore.cjs');
32
- const { activeProjectCwds } = require('../../../../scripts/lib/activeSessions.cjs');
32
+ const { activeProjectCwds } = require('../activeSessions.cjs');
33
33
 
34
34
  const tmpDirs = [];
35
35
 
@@ -149,7 +149,7 @@ test('a transcript cwd inside an ops root is normalized to the project root, not
149
149
  });
150
150
 
151
151
  test('normalization keeps the active-project signal rather than dropping the row', () => {
152
- const { projectRootOf } = require('../../../../scripts/lib/activeSessions.cjs');
152
+ const { projectRootOf } = require('../activeSessions.cjs');
153
153
  expect(projectRootOf('/p/session-manager-operations/prompt-sessions')).toBe('/p');
154
154
  expect(projectRootOf('/p/session-manager-operations')).toBe('/p');
155
155
  expect(projectRootOf('/p/session-manager-operations/scheduler/state')).toBe('/p');
@@ -185,7 +185,7 @@ test('projectStateDir fails closed on an ops-internal cwd, naming the offending
185
185
  // list at all. `worktreeMainRootOf` reverses a linked worktree's `.git` FILE
186
186
  // (`gitdir: <main>/.git/worktrees/<name>`) back to `<main>`.
187
187
 
188
- const { worktreeMainRootOf } = require('../../../../scripts/lib/activeSessions.cjs');
188
+ const { worktreeMainRootOf } = require('../activeSessions.cjs');
189
189
  const { KIND_CONFIG: WORKTREE_KIND_CONFIG } = require('../gitWorktree.cjs');
190
190
 
191
191
  // A worktree's MAIN tree root must live outside os.tmpdir() for these fixtures
@@ -285,13 +285,13 @@ test('worktreeMainRootOf returns null for a plain non-git directory', async () =
285
285
 
286
286
  test('projectRootOf resolves a worktree cwd to the main tree root', async () => {
287
287
  const { main, worktree } = await makeWorktreeFixture();
288
- const { projectRootOf } = require('../../../../scripts/lib/activeSessions.cjs');
288
+ const { projectRootOf } = require('../activeSessions.cjs');
289
289
  expect(projectRootOf(worktree)).toBe(main);
290
290
  });
291
291
 
292
292
  test('projectRootOf resolves a cwd nested inside a worktree\'s OWN ops tree to the main root', async () => {
293
293
  const { main, worktree } = await makeWorktreeFixture();
294
- const { projectRootOf } = require('../../../../scripts/lib/activeSessions.cjs');
294
+ const { projectRootOf } = require('../activeSessions.cjs');
295
295
  const nestedOps = path.join(worktree, 'session-manager-operations', 'scheduler', 'state');
296
296
  fs.mkdirSync(nestedOps, { recursive: true });
297
297
  expect(projectRootOf(nestedOps)).toBe(main);