claude-code-session-manager 0.86.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 (128) hide show
  1. package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
  2. package/dist/assets/{DataModel-Q4jhl24R.js → DataModel--mISIJ6h.js} +1 -1
  3. package/dist/assets/{History-Cj2FejEo.js → History-C2ahUXTg.js} +2 -2
  4. package/dist/assets/{Hooks-CaelQI6t.js → Hooks-BiC6oyR2.js} +3 -3
  5. package/dist/assets/{HostBilko--v7cMR8I.js → HostBilko-BPleEOld.js} +1 -1
  6. package/dist/assets/{Library-DgI9oCCZ.js → Library-Dc8Qst1R.js} +1 -1
  7. package/dist/assets/{ListDetail-DYUZN-x-.js → ListDetail-DIXh-OLX.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
  9. package/dist/assets/{McpServers-ypCYURh3.js → McpServers-DqcbLOLZ.js} +2 -2
  10. package/dist/assets/{Memory-C2qYp-3M.js → Memory-CW62MXlh.js} +4 -4
  11. package/dist/assets/{Panel-Cj2kw-Zv.js → Panel-Bw1FhRuF.js} +1 -1
  12. package/dist/assets/Permissions-BcUC-5y8.js +3 -0
  13. package/dist/assets/{Plugins-C1Vj8_dU.js → Plugins-BnKx9flD.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DczPNM5U.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
  15. package/dist/assets/{SaveBar-Cd_7U6Gb.js → SaveBar-CWr0O_w-.js} +1 -1
  16. package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-DVSyI44-.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
  18. package/dist/assets/Settings-DluB-vN1.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-CUv1_Q2c.js → SkillReferenceGraph-CHLSseay.js} +1 -1
  20. package/dist/assets/{Skills-C_YHkAy-.js → Skills-gNdo_HNK.js} +2 -2
  21. package/dist/assets/{SystemPrompt-B8R7T9xn.js → SystemPrompt-Cru05-Ia.js} +1 -1
  22. package/dist/assets/{TagLibrary-dj9YHWyy.js → TagLibrary-DNHY0xou.js} +1 -1
  23. package/dist/assets/{TiptapBody-DnSBUjHE.js → TiptapBody-I4lmbCgP.js} +1 -1
  24. package/dist/assets/{Toggle-CjV_BJn6.js → Toggle-bWMHjmRh.js} +1 -1
  25. package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
  26. package/dist/assets/{index-CXFQIPhO.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 +14 -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__/needsReviewLedger.test.cjs +162 -0
  40. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
  41. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
  42. package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
  43. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
  44. package/src/main/__tests__/prdLocations.test.cjs +100 -2
  45. package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
  46. package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
  47. package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
  48. package/src/main/__tests__/queue-starvation-per-project.test.cjs +14 -2
  49. package/src/main/__tests__/queueHistory.test.cjs +63 -0
  50. package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
  51. package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
  52. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
  53. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
  54. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
  55. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
  56. package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
  57. package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
  58. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
  59. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
  60. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
  61. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
  62. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +14 -4
  63. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
  64. package/src/main/chatRunner.cjs +8 -5
  65. package/src/main/health.cjs +1 -1
  66. package/src/main/historyAggregator.cjs +5 -0
  67. package/src/main/index.cjs +95 -44
  68. package/src/main/ipcSchemas.cjs +47 -0
  69. package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
  70. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
  71. package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
  72. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
  73. package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
  74. package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
  75. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
  76. package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
  77. package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
  78. package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
  79. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
  80. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
  81. package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
  82. package/src/main/lib/agentModelResolve.cjs +65 -27
  83. package/src/main/lib/bootSelfHeal.cjs +88 -0
  84. package/src/main/lib/delegationReadiness.cjs +290 -225
  85. package/src/main/lib/effectiveModelInfo.cjs +333 -0
  86. package/src/main/lib/ephemeralCwd.cjs +1 -1
  87. package/src/main/lib/epicMint.cjs +3 -3
  88. package/src/main/lib/gitWorktree.cjs +42 -12
  89. package/src/main/lib/guardShims.cjs +156 -0
  90. package/src/main/lib/jobDirtFilter.cjs +7 -2
  91. package/src/main/lib/launchFailure.cjs +2 -1
  92. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  93. package/src/main/lib/needsReviewLedger.cjs +205 -0
  94. package/src/main/lib/opsErrorLog.cjs +1 -1
  95. package/src/main/lib/opsOwnership.cjs +1 -1
  96. package/src/main/lib/prdCreate.cjs +56 -1
  97. package/src/main/lib/prdDisposition.cjs +199 -0
  98. package/src/main/lib/prdFrontmatter.cjs +8 -2
  99. package/src/main/lib/prdLocations.cjs +167 -45
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
  101. package/src/main/lib/projectPageSummarySchema.cjs +1 -1
  102. package/src/main/lib/projectRootResolve.cjs +1 -1
  103. package/src/main/lib/queueHistory.cjs +19 -1
  104. package/src/main/lib/queueStore.cjs +6 -1
  105. package/src/main/lib/reaperHelpers.cjs +181 -15
  106. package/src/main/lib/scheduleJobSchema.cjs +8 -0
  107. package/src/main/lib/scheduleJobTransitions.cjs +33 -0
  108. package/src/main/lib/schedulerConfig.cjs +24 -0
  109. package/src/main/lib/usageCircuit.cjs +159 -0
  110. package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
  111. package/src/main/scheduler/prdParser.cjs +13 -0
  112. package/src/main/scheduler.cjs +1285 -143
  113. package/src/main/templates/PRD_AUTHORING.md +50 -0
  114. package/src/main/templates/project-pages-catalog.json +1 -1
  115. package/src/main/usage.cjs +21 -3
  116. package/src/preload/api.d.ts +92 -1
  117. package/src/preload/index.cjs +10 -0
  118. package/web/README.md +41 -0
  119. package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
  120. package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
  121. package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
  122. package/dist/assets/AgentLibrary-DTFL7y8G.js +0 -3
  123. package/dist/assets/MarkdownEditor-DCIubYWf.js +0 -1
  124. package/dist/assets/Permissions-BiYZNGYW.js +0 -3
  125. package/dist/assets/Scheduler-DcLBiJBq.js +0 -14
  126. package/dist/assets/Settings-Cv-pRyms.js +0 -3
  127. package/dist/assets/settingsSchema-BJVciriw.js +0 -3
  128. /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
+ });
@@ -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);