claude-code-session-manager 0.95.0 → 0.96.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 (113) hide show
  1. package/dist/assets/{DataModel-DZK9QZpV.js → DataModel-T0D00BpK.js} +1 -1
  2. package/dist/assets/History-DuAovTr7.js +2 -0
  3. package/dist/assets/{Hooks-CmK6l0uf.js → Hooks-CgP9jsAN.js} +3 -3
  4. package/dist/assets/{HostBilko-D8TIx-Bx.js → HostBilko-DHfz3Mxa.js} +1 -1
  5. package/dist/assets/{Library-DTxpOjx8.js → Library--poVP-d1.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-BPJyrevq.js → MarkdownEditor-C56-Pc4c.js} +1 -1
  7. package/dist/assets/{McpServers-B_rvIYDs.js → McpServers-D-ykP4Vi.js} +2 -2
  8. package/dist/assets/Memory-BrkJjRL8.js +8 -0
  9. package/dist/assets/{Permissions-DhMMp27n.js → Permissions-BMpAaxJu.js} +3 -3
  10. package/dist/assets/Plugins-OuEe5S_T.js +2 -0
  11. package/dist/assets/{ProvenanceBadge-ptlItdCK.js → ProvenanceBadge-4eNbDYe8.js} +1 -1
  12. package/dist/assets/{SaveBar-CSbNSl2W.js → SaveBar-BQ8y9Lkv.js} +1 -1
  13. package/dist/assets/Scheduler-DcMT5sT_.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-BJof-GWH.js → ScopeSwitcher-XdxtVKP-.js} +1 -1
  15. package/dist/assets/Settings-FdaPUq2t.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-CGl4B89-.js → SkillReferenceGraph-CJzvvc0_.js} +1 -1
  17. package/dist/assets/Skills-Wvm9OE1e.js +3 -0
  18. package/dist/assets/{SystemPrompt-DKj-q39J.js → SystemPrompt--LYw_69j.js} +1 -1
  19. package/dist/assets/TagLibrary-Be6HbMMx.js +1 -0
  20. package/dist/assets/{TiptapBody-BbcJL9az.js → TiptapBody-DzFlWMgO.js} +1 -1
  21. package/dist/assets/{Toggle-DAslvnnv.js → Toggle-BxtAFE1i.js} +1 -1
  22. package/dist/assets/index-XkrLB67D.js +3079 -0
  23. package/dist/assets/{settingsSchema-pzp0TgvN.js → settingsSchema-BIvDZ1Cj.js} +1 -1
  24. package/dist/index.html +1 -1
  25. package/package.json +8 -2
  26. package/plugins/CLAUDE.md +3 -3
  27. package/plugins/session-manager-dev/skills/develop/SKILL.md +83 -67
  28. package/plugins/session-manager-dev/skills/develop/standards.md +5 -11
  29. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +5 -15
  30. package/scripts/README.md +1 -0
  31. package/scripts/hooks/guard-destructive-git.cjs +9 -374
  32. package/scripts/hooks/guard-inline-implementation.cjs +9 -128
  33. package/scripts/hooks/guard-prd-writes.cjs +9 -74
  34. package/scripts/hooks/guard-self-schedule.cjs +9 -61
  35. package/scripts/hooks/lib/guard-destructive-git-policy.cjs +410 -0
  36. package/scripts/hooks/lib/guard-inline-implementation-policy.cjs +159 -0
  37. package/scripts/hooks/lib/guard-prd-writes-policy.cjs +100 -0
  38. package/scripts/hooks/lib/guard-self-schedule-policy.cjs +89 -0
  39. package/scripts/scheduler-mcp-server.cjs +8 -1
  40. package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +59 -0
  41. package/src/main/__tests__/exchanges.test.cjs +41 -96
  42. package/src/main/__tests__/exchangesPromptId.test.cjs +16 -25
  43. package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
  44. package/src/main/__tests__/health-credentials.test.cjs +81 -0
  45. package/src/main/__tests__/memoryAggregate.test.cjs +40 -1
  46. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +1 -1
  47. package/src/main/__tests__/planValidator.test.cjs +71 -0
  48. package/src/main/__tests__/prdCreate.test.cjs +3 -1
  49. package/src/main/__tests__/prdSizing.test.cjs +106 -0
  50. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +46 -0
  51. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +10 -0
  52. package/src/main/__tests__/scheduler-looks-done.test.cjs +40 -0
  53. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +81 -0
  54. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +6 -1
  55. package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +110 -0
  56. package/src/main/__tests__/seedAgentPersonas.test.cjs +19 -1
  57. package/src/main/__tests__/seedValidatorPersona.test.cjs +116 -0
  58. package/src/main/__tests__/telemetryClient.test.cjs +2 -2
  59. package/src/main/__tests__/telemetryContract.test.cjs +2 -2
  60. package/src/main/__tests__/usageSingleFlight.test.cjs +10 -0
  61. package/src/main/__tests__/validationSentinels.test.cjs +84 -0
  62. package/src/main/build-info.json +4 -4
  63. package/src/main/config.cjs +26 -14
  64. package/src/main/exchanges.cjs +26 -21
  65. package/src/main/health.cjs +66 -4
  66. package/src/main/index.cjs +1 -1
  67. package/src/main/ipcSchemas.cjs +5 -1
  68. package/src/main/lib/__tests__/crashTelemetry.test.cjs +1 -1
  69. package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +115 -0
  70. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +85 -27
  71. package/src/main/lib/__tests__/gitWorktree.test.cjs +21 -27
  72. package/src/main/lib/__tests__/guardShims.test.cjs +10 -3
  73. package/src/main/lib/__tests__/loadGate.test.cjs +44 -1
  74. package/src/main/lib/__tests__/opsOwnership.test.cjs +14 -0
  75. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +4 -4
  76. package/src/main/lib/__tests__/telemetryConsent.test.cjs +2 -2
  77. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +1 -1
  78. package/src/main/lib/agentModelResolve.cjs +1 -0
  79. package/src/main/lib/credentials.cjs +62 -0
  80. package/src/main/lib/cwdClassify.cjs +15 -0
  81. package/src/main/lib/definitionOfDone.cjs +9 -13
  82. package/src/main/lib/effectiveModelInfo.cjs +2 -9
  83. package/src/main/lib/epicWorktreeProjectConfig.cjs +75 -31
  84. package/src/main/lib/loadGate.cjs +29 -5
  85. package/src/main/lib/opsOwnership.cjs +23 -0
  86. package/src/main/lib/planValidator.cjs +28 -0
  87. package/src/main/lib/prdCreate.cjs +8 -4
  88. package/src/main/lib/prdSizing.cjs +83 -0
  89. package/src/main/lib/runLogRetention.cjs +2 -8
  90. package/src/main/lib/telemetryBacklog.cjs +9 -3
  91. package/src/main/lib/telemetryClient.cjs +12 -3
  92. package/src/main/lib/telemetrySettings.cjs +8 -2
  93. package/src/main/lib/validationSentinels.cjs +29 -0
  94. package/src/main/memoryAggregate.cjs +36 -11
  95. package/src/main/otelSettings.cjs +8 -2
  96. package/src/main/scheduler.cjs +215 -31
  97. package/src/main/seedAgentPersonas.cjs +1 -1
  98. package/src/main/sessionsStore.cjs +8 -2
  99. package/src/main/supervisor.cjs +3 -3
  100. package/src/main/templates/PRD_AUTHORING.md +9 -9
  101. package/src/main/voiceSettings.cjs +8 -2
  102. package/src/preload/api.d.ts +3 -3
  103. package/src/preload/index.cjs +5 -5
  104. package/src/seed/agents/dev-lead.md +24 -15
  105. package/src/seed/agents/validator.md +33 -0
  106. package/dist/assets/History-BDyJUGtR.js +0 -2
  107. package/dist/assets/Memory-B-9GlHqe.js +0 -8
  108. package/dist/assets/Plugins-DKfAlmMY.js +0 -2
  109. package/dist/assets/Scheduler--rn1QBYc.js +0 -16
  110. package/dist/assets/Settings-Dsx6XZP5.js +0 -3
  111. package/dist/assets/Skills-D522Zfol.js +0 -3
  112. package/dist/assets/TagLibrary-BH7CG7-6.js +0 -1
  113. package/dist/assets/index-Yf8e7VCB.js +0 -3079
@@ -0,0 +1,115 @@
1
+ /**
2
+ * credentials-futile-refresh.test.cjs — the 2026-09-23 credential-refresh
3
+ * storm. On this machine the OAuth creds were expired with NO refresh token
4
+ * (expiredAtMs 1775277875159). Every 15 s billing poll called refreshIfNeeded,
5
+ * which tried the OAuth grant (instant 'unsupported' — no refresh token),
6
+ * then spawned `claude --version` as a CLI fallback (which shares the same
7
+ * missing token, so it can't help either), then logged auth_failed_expired —
8
+ * the identical triple, forever, spawning a doomed child process each time.
9
+ *
10
+ * The fix: a futile-refresh short-circuit. When the token is expired AND has
11
+ * no refresh token, refreshIfNeeded re-reads once (to catch an out-of-band
12
+ * `claude login`) and otherwise returns the auth verdict WITHOUT the OAuth
13
+ * call or the CLI-fallback spawn, logging at most once per cooldown window.
14
+ *
15
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/credentials-futile-refresh.test.cjs
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ import { test, expect, beforeEach, afterAll } from 'vitest';
21
+ const fs = require('node:fs');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+
25
+ // credentials.cjs freezes CREDS_PATH and REFRESH_LOG_PATH from os.homedir() at
26
+ // require time. os.homedir() honors $HOME on POSIX, so point HOME at a temp
27
+ // dir BEFORE the require (not in beforeEach — that runs too late) so both the
28
+ // creds file we write and the log the code writes resolve under our sandbox.
29
+ const originalHome = process.env.HOME;
30
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-cred-futile-'));
31
+ process.env.HOME = tmpHome;
32
+
33
+ const credentials = require('../credentials.cjs');
34
+ const { isRefreshFutile, refreshIfNeeded, __resetFutileRefreshThrottle } = credentials;
35
+
36
+ const CREDS_FILE = path.join(tmpHome, '.claude', '.credentials.json');
37
+ const LOG_FILE = path.join(tmpHome, '.claude', 'session-manager', 'credential-refresh.log');
38
+
39
+ function writeCreds(oauth) {
40
+ fs.mkdirSync(path.dirname(CREDS_FILE), { recursive: true });
41
+ fs.writeFileSync(CREDS_FILE, JSON.stringify({ claudeAiOauth: oauth }), 'utf8');
42
+ }
43
+
44
+ function readLogLines() {
45
+ try {
46
+ return fs.readFileSync(LOG_FILE, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ beforeEach(() => {
53
+ // Clean slate per test: no stale creds file, no carried-over log or throttle.
54
+ try { fs.unlinkSync(CREDS_FILE); } catch { /* absent */ }
55
+ try { fs.unlinkSync(LOG_FILE); } catch { /* absent */ }
56
+ __resetFutileRefreshThrottle();
57
+ });
58
+
59
+ afterAll(() => {
60
+ process.env.HOME = originalHome;
61
+ fs.rmSync(tmpHome, { recursive: true, force: true });
62
+ });
63
+
64
+ test('isRefreshFutile: expired + no refresh token is futile; anything else is not', () => {
65
+ const past = Date.now() - 60_000;
66
+ const future = Date.now() + 60 * 60_000;
67
+ expect(isRefreshFutile({ accessToken: 'a', expiresAt: past })).toBe(true);
68
+ // Has a refresh token → OAuth grant is worth attempting, not futile.
69
+ expect(isRefreshFutile({ accessToken: 'a', expiresAt: past, refreshToken: 'r' })).toBe(false);
70
+ // Not expired → not futile even without a refresh token.
71
+ expect(isRefreshFutile({ accessToken: 'a', expiresAt: future })).toBe(false);
72
+ });
73
+
74
+ test('a futile refresh returns an auth verdict without spawning the CLI fallback, and logs the no-refresh-token event once', async () => {
75
+ writeCreds({ accessToken: 'expired', expiresAt: Date.now() - 60_000 }); // expired, no refreshToken
76
+
77
+ const res = await refreshIfNeeded();
78
+
79
+ expect(res.kind).toBe('auth');
80
+ expect(res.message).toMatch(/no refresh token/i);
81
+ expect(typeof res.expiredAt).toBe('number');
82
+
83
+ const events = readLogLines().map((e) => e.event);
84
+ // The distinctive no-refresh-token event fires...
85
+ expect(events).toContain('auth_failed_expired_no_refresh_token');
86
+ // ...and the storm's spawn/oauth triple never runs.
87
+ expect(events).not.toContain('cli_fallback_ok');
88
+ expect(events).not.toContain('cli_fallback_failed');
89
+ expect(events).not.toContain('oauth_refresh_unsupported');
90
+ });
91
+
92
+ test('repeated polls in the cooldown window do NOT re-log — the every-15s storm is suppressed', async () => {
93
+ writeCreds({ accessToken: 'expired', expiresAt: Date.now() - 60_000 });
94
+
95
+ // Simulate several poll cycles back-to-back (as pollLoop would, every 15 s).
96
+ for (let i = 0; i < 5; i++) {
97
+ const res = await refreshIfNeeded();
98
+ expect(res.kind).toBe('auth');
99
+ }
100
+
101
+ const futileLogs = readLogLines().filter((e) => e.event === 'auth_failed_expired_no_refresh_token');
102
+ expect(futileLogs.length).toBe(1); // once, not five times
103
+ });
104
+
105
+ test('an out-of-band `claude login` (fresh creds on disk) is picked up on the next call', async () => {
106
+ writeCreds({ accessToken: 'expired', expiresAt: Date.now() - 60_000 });
107
+ expect((await refreshIfNeeded()).kind).toBe('auth');
108
+
109
+ // User runs `claude` in a terminal → fresh creds land on disk.
110
+ writeCreds({ accessToken: 'fresh', expiresAt: Date.now() + 60 * 60_000 });
111
+
112
+ const res = await refreshIfNeeded();
113
+ expect(res.kind).toBe('ok');
114
+ expect(res.creds.accessToken).toBe('fresh');
115
+ });
@@ -1,9 +1,13 @@
1
1
  /**
2
- * epicWorktreeProjectConfig.test.cjs — PRD 1035: the per-project "disable
3
- * Epic worktree isolation" UI toggle. Points the module at a throwaway
4
- * tmpdir file via SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH so it never touches
5
- * the real ~/.claude/session-manager/epic-worktree-project-config.json on
6
- * the machine running the tests.
2
+ * epicWorktreeProjectConfig.test.cjs — PRD 1035/1390: the per-project "disable
3
+ * Epic worktree isolation" UI toggle, now stored at
4
+ * `<cwd>/session-manager-operations/prompt-sessions/epic-worktree-config.json`
5
+ * instead of a single machine-wide map. Uses real mkdtemp'd project cwds (the
6
+ * same convention as epicStatusMirror.test.cjs) since the file location is
7
+ * now derived from `cwd` via opsPath(), not from a single overridable path.
8
+ * SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH still points the LEGACY global map (the
9
+ * one-shot migration source) at a throwaway tmpdir file, so tests never touch
10
+ * the real ~/.claude/session-manager/epic-worktree-project-config.json.
7
11
  *
8
12
  * Run: timeout 120 npx vitest run src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs
9
13
  */
@@ -16,60 +20,79 @@ const os = require('node:os');
16
20
  const path = require('node:path');
17
21
 
18
22
  let tmpRoot;
19
- let configFile;
23
+ let legacyConfigFile;
20
24
  let originalOverride;
25
+ const projectDirs = [];
21
26
 
22
27
  beforeEach(() => {
23
28
  tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-epic-worktree-project-config-'));
24
- configFile = path.join(tmpRoot, 'config.json');
29
+ legacyConfigFile = path.join(tmpRoot, 'legacy-config.json');
25
30
  originalOverride = process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
26
- process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = configFile;
31
+ process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = legacyConfigFile;
27
32
  });
28
33
 
29
34
  afterEach(() => {
30
35
  if (originalOverride === undefined) delete process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
31
36
  else process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = originalOverride;
32
37
  fs.rmSync(tmpRoot, { recursive: true, force: true });
38
+ while (projectDirs.length) fs.rmSync(projectDirs.pop(), { recursive: true, force: true });
33
39
  });
34
40
 
35
41
  function freshModule() {
36
42
  return require('../epicWorktreeProjectConfig.cjs');
37
43
  }
38
44
 
45
+ function mkProjectCwd(name) {
46
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `sm-epic-worktree-project-${name}-`));
47
+ projectDirs.push(dir);
48
+ return dir;
49
+ }
50
+
51
+ function perProjectFile(m, cwd) {
52
+ return m.perProjectConfigPath(cwd);
53
+ }
54
+
39
55
  test('defaults to not disabled for a project never touched', () => {
40
56
  const m = freshModule();
41
- expect(m.isEpicWorktreeDisabledForProject('/some/project')).toBe(false);
57
+ const cwd = mkProjectCwd('a');
58
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
42
59
  });
43
60
 
44
- test('setEpicWorktreeDisabledForProject(true) persists across a fresh read', () => {
61
+ test('setEpicWorktreeDisabledForProject(true) persists across a fresh read, in the per-project ops file', () => {
45
62
  const m = freshModule();
46
- m.setEpicWorktreeDisabledForProject('/proj/a', true);
47
- expect(m.isEpicWorktreeDisabledForProject('/proj/a')).toBe(true);
48
- expect(fs.existsSync(configFile)).toBe(true);
49
- const onDisk = JSON.parse(fs.readFileSync(configFile, 'utf8'));
50
- expect(onDisk).toEqual({ '/proj/a': true });
63
+ const cwd = mkProjectCwd('a');
64
+ m.setEpicWorktreeDisabledForProject(cwd, true);
65
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(true);
66
+ const file = perProjectFile(m, cwd);
67
+ expect(fs.existsSync(file)).toBe(true);
68
+ expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ disabled: true });
51
69
  });
52
70
 
53
71
  test('is scoped per cwd — a different project is unaffected', () => {
54
72
  const m = freshModule();
55
- m.setEpicWorktreeDisabledForProject('/proj/a', true);
56
- expect(m.isEpicWorktreeDisabledForProject('/proj/b')).toBe(false);
73
+ const cwdA = mkProjectCwd('a');
74
+ const cwdB = mkProjectCwd('b');
75
+ m.setEpicWorktreeDisabledForProject(cwdA, true);
76
+ expect(m.isEpicWorktreeDisabledForProject(cwdB)).toBe(false);
57
77
  });
58
78
 
59
- test('setEpicWorktreeDisabledForProject(false) clears the entry rather than writing false', () => {
79
+ test('setEpicWorktreeDisabledForProject(false) writes disabled:false rather than deleting the file', () => {
60
80
  const m = freshModule();
61
- m.setEpicWorktreeDisabledForProject('/proj/a', true);
62
- m.setEpicWorktreeDisabledForProject('/proj/a', false);
63
- expect(m.isEpicWorktreeDisabledForProject('/proj/a')).toBe(false);
64
- const onDisk = JSON.parse(fs.readFileSync(configFile, 'utf8'));
65
- expect(onDisk).toEqual({});
81
+ const cwd = mkProjectCwd('a');
82
+ m.setEpicWorktreeDisabledForProject(cwd, true);
83
+ m.setEpicWorktreeDisabledForProject(cwd, false);
84
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
85
+ const file = perProjectFile(m, cwd);
86
+ expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ disabled: false });
66
87
  });
67
88
 
68
- test('a missing or corrupt config file reads as not-disabled rather than throwing', () => {
89
+ test('a missing or corrupt per-project config file reads as not-disabled rather than throwing', () => {
69
90
  const m = freshModule();
70
- fs.mkdirSync(path.dirname(configFile), { recursive: true });
71
- fs.writeFileSync(configFile, '{ not valid json');
72
- expect(m.isEpicWorktreeDisabledForProject('/proj/a')).toBe(false);
91
+ const cwd = mkProjectCwd('a');
92
+ const file = perProjectFile(m, cwd);
93
+ fs.mkdirSync(path.dirname(file), { recursive: true });
94
+ fs.writeFileSync(file, '{ not valid json');
95
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
73
96
  });
74
97
 
75
98
  test('isEpicWorktreeDisabledForProject rejects a non-string cwd instead of throwing', () => {
@@ -77,3 +100,38 @@ test('isEpicWorktreeDisabledForProject rejects a non-string cwd instead of throw
77
100
  expect(m.isEpicWorktreeDisabledForProject(null)).toBe(false);
78
101
  expect(m.isEpicWorktreeDisabledForProject(undefined)).toBe(false);
79
102
  });
103
+
104
+ test('one-shot migration: a legacy global entry seeds the new per-project file on first read', () => {
105
+ const m = freshModule();
106
+ const cwd = mkProjectCwd('a');
107
+ fs.writeFileSync(legacyConfigFile, JSON.stringify({ [cwd]: true }));
108
+
109
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(true);
110
+ const file = perProjectFile(m, cwd);
111
+ expect(fs.existsSync(file)).toBe(true);
112
+ expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ disabled: true });
113
+
114
+ // The legacy file itself is left in place, untouched.
115
+ expect(JSON.parse(fs.readFileSync(legacyConfigFile, 'utf8'))).toEqual({ [cwd]: true });
116
+ });
117
+
118
+ test('migration leaves the legacy map intact for OTHER, still-unmigrated projects', () => {
119
+ const m = freshModule();
120
+ const cwdA = mkProjectCwd('a');
121
+ const cwdB = mkProjectCwd('b');
122
+ fs.writeFileSync(legacyConfigFile, JSON.stringify({ [cwdA]: true, [cwdB]: true }));
123
+
124
+ // Touching cwdA migrates only cwdA's own per-project file.
125
+ expect(m.isEpicWorktreeDisabledForProject(cwdA)).toBe(true);
126
+ expect(fs.existsSync(perProjectFile(m, cwdB))).toBe(false);
127
+
128
+ // cwdB still resolves correctly straight from the untouched legacy map.
129
+ expect(m.isEpicWorktreeDisabledForProject(cwdB)).toBe(true);
130
+ });
131
+
132
+ test('a project with no legacy entry migrates to disabled:false without throwing', () => {
133
+ const m = freshModule();
134
+ const cwd = mkProjectCwd('a');
135
+ fs.writeFileSync(legacyConfigFile, JSON.stringify({ '/some/other/project': true }));
136
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
137
+ });
@@ -847,34 +847,28 @@ test('[epic] SM_EPIC_WORKTREE_DISABLE=1 restores in-place behaviour independentl
847
847
 
848
848
  test('[epic] the per-project UI toggle (epicWorktreeProjectConfig.cjs) disables isolation for just that cwd', async () => {
849
849
  const projectConfig = require('../epicWorktreeProjectConfig.cjs');
850
- const originalOverride = process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
851
- process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = path.join(tmpRoot, 'epic-worktree-project-config.json');
852
- try {
853
- projectConfig.setEpicWorktreeDisabledForProject(repoCwd, true);
854
- expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(true);
850
+ projectConfig.setEpicWorktreeDisabledForProject(repoCwd, true);
851
+ expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(true);
855
852
 
856
- const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-x' });
857
- expect(result.ok).toBe(false);
858
- expect(result.reason).toMatch(/isolation toggle/);
859
-
860
- // A different, untouched project cwd is unaffected — the toggle is
861
- // per-project, not machine-wide like the env var.
862
- const otherCwd = path.join(tmpRoot, 'other-repo');
863
- initRepo(otherCwd);
864
- const otherResult = await gitWorktree.createEpicWorktree({ cwd: otherCwd, epicId: 'toggle-y' });
865
- expect(otherResult.ok).toBe(true);
866
- await gitWorktree.cleanupEpicWorktree({ cwd: otherCwd, dir: otherResult.dir, branch: otherResult.branch });
867
-
868
- // Turning it back off restores isolation for the original project.
869
- projectConfig.setEpicWorktreeDisabledForProject(repoCwd, false);
870
- expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(false);
871
- const restored = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-z' });
872
- expect(restored.ok).toBe(true);
873
- await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: restored.dir, branch: restored.branch });
874
- } finally {
875
- if (originalOverride === undefined) delete process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
876
- else process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = originalOverride;
877
- }
853
+ const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-x' });
854
+ expect(result.ok).toBe(false);
855
+ expect(result.reason).toMatch(/isolation toggle/);
856
+
857
+ // A different, untouched project cwd is unaffected — the toggle is
858
+ // per-project (its own <cwd>/session-manager-operations/prompt-sessions/
859
+ // epic-worktree-config.json), not machine-wide like the env var.
860
+ const otherCwd = path.join(tmpRoot, 'other-repo');
861
+ initRepo(otherCwd);
862
+ const otherResult = await gitWorktree.createEpicWorktree({ cwd: otherCwd, epicId: 'toggle-y' });
863
+ expect(otherResult.ok).toBe(true);
864
+ await gitWorktree.cleanupEpicWorktree({ cwd: otherCwd, dir: otherResult.dir, branch: otherResult.branch });
865
+
866
+ // Turning it back off restores isolation for the original project.
867
+ projectConfig.setEpicWorktreeDisabledForProject(repoCwd, false);
868
+ expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(false);
869
+ const restored = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-z' });
870
+ expect(restored.ok).toBe(true);
871
+ await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: restored.dir, branch: restored.branch });
878
872
  });
879
873
 
880
874
  test('[epic] reconcileWorktreesOnBoot removes a leaked epic worktree with no liveness predicate given', async () => {
@@ -112,14 +112,21 @@ test('writeGuardShims: rewriting the pointer to a DIFFERENT app root lets an alr
112
112
  const shimBytesBefore = fs.readFileSync(shimFile, 'utf8');
113
113
 
114
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).
115
+ // its own copy of scripts/hooks/guard-prd-writes.cjs — the file guardShims
116
+ // reads through the pointer at invocation time — plus the sibling policy
117
+ // module it `require()`s at load time (guard-prd-writes-policy.cjs, PRD
118
+ // 1415); a real upgrade ships both since both are listed in package.json's
119
+ // "files".
117
120
  const secondAppRoot = await mkTmp('sm-guard-shims-upgraded-app-');
118
- await fsp.mkdir(path.join(secondAppRoot, 'scripts', 'hooks'), { recursive: true });
121
+ await fsp.mkdir(path.join(secondAppRoot, 'scripts', 'hooks', 'lib'), { recursive: true });
119
122
  await fsp.copyFile(
120
123
  path.join(APP_ROOT, 'scripts', 'hooks', 'guard-prd-writes.cjs'),
121
124
  path.join(secondAppRoot, 'scripts', 'hooks', 'guard-prd-writes.cjs'),
122
125
  );
126
+ await fsp.copyFile(
127
+ path.join(APP_ROOT, 'scripts', 'hooks', 'lib', 'guard-prd-writes-policy.cjs'),
128
+ path.join(secondAppRoot, 'scripts', 'hooks', 'lib', 'guard-prd-writes-policy.cjs'),
129
+ );
123
130
 
124
131
  const r = await writeGuardShims({ homeDir, appRoot: secondAppRoot });
125
132
  expect(r.ok).toBe(true);
@@ -9,7 +9,7 @@
9
9
  // batteries. These tests pin the pure decision, the audit rate-limit and the
10
10
  // escalation, with loadavg/cores/clock all injected.
11
11
  const assert = require('node:assert/strict');
12
- const { isLoadGated, createLoadGate, AUDIT_INTERVAL_MS } = require('../loadGate.cjs');
12
+ const { isLoadGated, createLoadGate, topCpuConsumers, AUDIT_INTERVAL_MS } = require('../loadGate.cjs');
13
13
  const { loadGateThreshold, LOAD_GATE_PER_CORE } = require('../schedulerConfig.cjs');
14
14
 
15
15
  const ORIGINAL_ENV = process.env.SM_LOAD_GATE_PER_CORE;
@@ -65,6 +65,49 @@ test('SM_LOAD_GATE_PER_CORE is honored and clamped to [0.25, 4]; 0 disables; gar
65
65
  assert.equal(loadGateThreshold(), 0.85);
66
66
  });
67
67
 
68
+ // ─── topCpuConsumers: platform-correct ps invocation ───────────────────────
69
+ //
70
+ // The audit line the scheduler warn-logs when the load gate escalates. Purely
71
+ // diagnostic (nothing branches on it) but it was blank on macOS: the code only
72
+ // ran the GNU `ps --sort` form, which BSD ps rejects with "illegal option",
73
+ // so every Mac logged `top CPU: n/a` exactly when the operator needed to know
74
+ // what was saturating the box.
75
+
76
+ test('Linux uses the GNU ps form (-eo … --sort=-pcpu) and skips the header row', () => {
77
+ const calls = [];
78
+ const fakeExec = (bin, args) => {
79
+ calls.push({ bin, args });
80
+ return 'PID %CPU COMMAND\n 1 90.0 node\n 2 10.0 tsc\n';
81
+ };
82
+ const rows = topCpuConsumers(2, { execImpl: fakeExec, platform: 'linux' });
83
+ assert.deepEqual(calls[0], { bin: 'ps', args: ['-eo', 'pid,pcpu,comm', '--sort=-pcpu'] });
84
+ assert.deepEqual(rows, ['1 90.0 node', '2 10.0 tsc']);
85
+ });
86
+
87
+ test('macOS uses the BSD ps form (-Aco … -r), NOT the GNU --sort that BSD ps rejects', () => {
88
+ const calls = [];
89
+ const fakeExec = (bin, args) => {
90
+ calls.push({ bin, args });
91
+ return ' PID %CPU COMM\n 847 94.4 suggestd\n 443 15.1 WindowServer\n';
92
+ };
93
+ const rows = topCpuConsumers(3, { execImpl: fakeExec, platform: 'darwin' });
94
+ assert.deepEqual(calls[0].args, ['-Aco', 'pid,pcpu,comm', '-r']);
95
+ assert.ok(!calls[0].args.some((a) => String(a).includes('--sort')), 'must not pass the GNU --sort to BSD ps');
96
+ assert.deepEqual(rows, ['847 94.4 suggestd', '443 15.1 WindowServer']);
97
+ });
98
+
99
+ test('an unsupported platform returns [] without ever spawning ps', () => {
100
+ let spawned = false;
101
+ const rows = topCpuConsumers(3, { execImpl: () => { spawned = true; return ''; }, platform: 'win32' });
102
+ assert.deepEqual(rows, []);
103
+ assert.equal(spawned, false);
104
+ });
105
+
106
+ test('a ps failure (throw) degrades to [] rather than propagating — the audit line is best-effort', () => {
107
+ const rows = topCpuConsumers(3, { execImpl: () => { throw new Error('boom'); }, platform: 'darwin' });
108
+ assert.deepEqual(rows, []);
109
+ });
110
+
68
111
  // ─── createLoadGate: decision, 1-minute-only, bypass ───────────────────────
69
112
 
70
113
  function gateWith({ l1 = 12.95, l5 = 0, l15 = 0, cores = 14, threshold = 0.85, clock }) {
@@ -73,6 +73,20 @@ test('assertOpsWrite throws for a refused write and is silent for an allowed one
73
73
  assert.doesNotThrow(() => assertOpsWrite(`${P}/project-brief/brief.json`, 'project-home'));
74
74
  });
75
75
 
76
+ test('assertOpsWrite fails closed (not a raw crash) on a relative path that looks like it is inside an ops root', () => {
77
+ // A caller that skips opsPath/resolveProjectRoot (which already throw on a
78
+ // relative cwd) and builds its own path string by hand must still get a
79
+ // graceful, catchable refusal here — this is the last line of defense
80
+ // (PRD 1082). Before this guard, classifyCwd's absolute-path check (added
81
+ // for the worktree-relative-cwd hazard) left innermostOpsRoot null for a
82
+ // relative path, and path.dirname(null) threw an untagged TypeError
83
+ // instead of the intended `unknownCwd`-tagged Error.
84
+ assert.throws(
85
+ () => assertOpsWrite('session-manager-operations/scheduler/state/queue.json', 'scheduler'),
86
+ (e) => e.unknownCwd === true && /absolute/.test(e.message),
87
+ );
88
+ });
89
+
76
90
  test('every declared owner is a non-empty string (table sanity)', () => {
77
91
  for (const [ns, owner] of Object.entries(OWNERS)) {
78
92
  assert.equal(typeof owner, 'string', `${ns} owner must be a string`);
@@ -56,7 +56,7 @@ function freshTelemetry(home) {
56
56
  process.env.HOME = home;
57
57
  // Explicit opt-in: overrides the test-environment no-op guard so this
58
58
  // suite's real telemetryClient calls actually persist into an isolated dir.
59
- process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
59
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.claude', 'session-manager');
60
60
  for (const p of ['../telemetryClient.cjs', '../../config.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs']) {
61
61
  const resolved = require.resolve(p);
62
62
  delete require.cache[resolved];
@@ -133,7 +133,7 @@ function makeDeps({ home, telemetryClient, config, tabCwds }) {
133
133
  }
134
134
 
135
135
  function watermarksFileFor(home) {
136
- return path.join(home, '.config', 'session-manager', 'telemetry-watermarks.json');
136
+ return path.join(home, '.claude', 'session-manager', 'telemetry-watermarks.json');
137
137
  }
138
138
 
139
139
  async function readWatermarks(home) {
@@ -569,7 +569,7 @@ test('bootDrain always drains with reason "boot", and drains a second time with
569
569
 
570
570
  test('drainBacklog resolves (never rejects) when readdir fails, telemetryClient throws, and the watermarks file is corrupt', async () => {
571
571
  const home = await mkHome();
572
- await fsp.mkdir(path.join(home, '.config', 'session-manager'), { recursive: true });
572
+ await fsp.mkdir(path.join(home, '.claude', 'session-manager'), { recursive: true });
573
573
  await fsp.writeFile(watermarksFileFor(home), 'not valid { json', 'utf8');
574
574
  const project = await mkProject('contained');
575
575
  writeErrorsFile(project, recentDateStr(), [errLine(1)]);
@@ -584,7 +584,7 @@ test('drainBacklog resolves (never rejects) when readdir fails, telemetryClient
584
584
  logLine: async () => { throw new Error('boom client'); },
585
585
  flush: async () => { throw new Error('boom flush'); },
586
586
  isPending: async () => { throw new Error('boom pending'); },
587
- sentPath: () => path.join(home, '.config', 'session-manager', 'telemetry-sent.json'),
587
+ sentPath: () => path.join(home, '.claude', 'session-manager', 'telemetry-sent.json'),
588
588
  };
589
589
  const deps = {
590
590
  sessionsStore: { load: async () => ({ tabs: [{ cwd: project }] }) },
@@ -40,7 +40,7 @@ function freshModules(home) {
40
40
  process.env.HOME = home;
41
41
  // Explicit opt-in: overrides the test-environment no-op guard so this
42
42
  // suite's real telemetryClient calls actually persist into an isolated dir.
43
- process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
43
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.claude', 'session-manager');
44
44
  for (const p of ['../telemetryConsent.cjs', '../telemetryClient.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs', '../../config.cjs']) {
45
45
  const resolved = require.resolve(p);
46
46
  delete require.cache[resolved];
@@ -105,7 +105,7 @@ test('turning off does NOT clear telemetry-sent.json or the watermarks — re-en
105
105
  expect(JSON.parse(sentPathContent).ids.length).toBeGreaterThan(0);
106
106
 
107
107
  // Also seed a watermarks file to prove opt-out leaves it untouched.
108
- const watermarksDir = path.join(home, '.config', 'session-manager');
108
+ const watermarksDir = path.join(home, '.claude', 'session-manager');
109
109
  await fsp.mkdir(watermarksDir, { recursive: true });
110
110
  const watermarksPath = path.join(watermarksDir, 'telemetry-watermarks.json');
111
111
  const watermarksBefore = { 'abc123/errors-2026-01-01.jsonl': { bytesEnqueued: 100, bytesConfirmed: 100 } };
@@ -37,7 +37,7 @@ async function mkHome() {
37
37
  process.env.HOME = dir;
38
38
  // Explicit opt-in: overrides the test-environment no-op guard so this
39
39
  // suite's real telemetryClient calls actually persist into an isolated dir.
40
- process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.config', 'session-manager');
40
+ process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.claude', 'session-manager');
41
41
  return dir;
42
42
  }
43
43
 
@@ -238,6 +238,7 @@ async function resolvePrdPersonaForSpawn({ cwd, agentType, fallbackModel = FALLB
238
238
 
239
239
  module.exports = {
240
240
  FALLBACK_MODEL,
241
+ AGENT_BODY_CHAR_CAP,
241
242
  resolveEpicModel,
242
243
  findAgentTypeByClaudeSessionId,
243
244
  findEpicByClaudeSessionId,
@@ -120,6 +120,38 @@ function isExpiringSoon(creds, withinMs = 5 * 60_000) {
120
120
  return ms !== null && ms - Date.now() < withinMs;
121
121
  }
122
122
 
123
+ /**
124
+ * isRefreshFutile(creds) → boolean
125
+ *
126
+ * True when the token is already expired AND there is no refresh token to
127
+ * present. In that state every refresh avenue is a dead end: tryOAuthRefresh
128
+ * short-circuits to 'unsupported' (it needs `creds.refreshToken`), and the
129
+ * `claude --version` CLI fallback performs its own silent OAuth refresh from
130
+ * that SAME missing refresh token, so it cannot help either. The only recovery
131
+ * is an out-of-band `claude login` — which refreshIfNeeded picks up on its very
132
+ * next call via the isExpiringSoon early-return, since fresh creds never reach
133
+ * this path. Left ungated, this state made pollLoop spawn a `claude --version`
134
+ * process and write the oauth_refresh_unsupported → cli_fallback_ok →
135
+ * auth_failed_expired triple on EVERY 15 s poll, forever (observed 2026-09-23:
136
+ * expiredAtMs 1775277875159, identical triple every ~15 min across the log).
137
+ */
138
+ function isRefreshFutile(creds) {
139
+ return isExpired(creds) && !creds.refreshToken;
140
+ }
141
+
142
+ // Throttle for the futile-refresh state above: once seen, don't respawn the
143
+ // CLI fallback or re-log the auth triple on every poll. Re-probe at most once
144
+ // per this window (an external `claude login` is still caught immediately by
145
+ // refreshIfNeeded's isExpiringSoon early-return, and by the cheap re-read
146
+ // below — neither needs the storm). Mirrors loadGate.cjs's AUDIT_INTERVAL_MS.
147
+ const FUTILE_REFRESH_COOLDOWN_MS = 15 * 60_000;
148
+ let lastFutileRefreshLoggedAt = null;
149
+
150
+ /** Test-only: reset the futile-refresh throttle so each test starts clean. */
151
+ function __resetFutileRefreshThrottle() {
152
+ lastFutileRefreshLoggedAt = null;
153
+ }
154
+
123
155
  async function writeCredentials(rawData, freshOauth, source = 'file') {
124
156
  const next = { ...rawData, claudeAiOauth: { ...rawData.claudeAiOauth, ...freshOauth } };
125
157
  if (source === 'keychain') {
@@ -222,6 +254,33 @@ async function refreshIfNeeded(forceRefresh = false) {
222
254
 
223
255
  const alreadyExpired = isExpired(creds);
224
256
 
257
+ // Futile-refresh short-circuit: token already expired AND no refresh token
258
+ // to present. Every downstream avenue (OAuth grant, `claude --version` CLI
259
+ // fallback) is a guaranteed dead end from the same missing token, so running
260
+ // them on every 15 s poll only spawns a doomed child process and re-logs the
261
+ // same triple forever (2026-09-23 storm). Re-read once — cheap, and the ONE
262
+ // real recovery (`claude login`) lands here — then, if still futile, return
263
+ // the auth verdict WITHOUT the OAuth call or the CLI-fallback spawn, logging
264
+ // at most once per FUTILE_REFRESH_COOLDOWN_MS instead of every poll.
265
+ if (isRefreshFutile(creds)) {
266
+ const recheckCr = await readCredentials();
267
+ if (recheckCr.kind === 'ok' && !isRefreshFutile(recheckCr.creds) && !isExpired(recheckCr.creds)) {
268
+ appendRefreshLog({ event: 'externally_refreshed_ok', recheckExpiresAt: recheckCr.creds.expiresAt ?? null });
269
+ lastFutileRefreshLoggedAt = null;
270
+ return { kind: 'ok', creds: recheckCr.creds };
271
+ }
272
+ const nowMs = Date.now();
273
+ if (lastFutileRefreshLoggedAt === null || nowMs - lastFutileRefreshLoggedAt >= FUTILE_REFRESH_COOLDOWN_MS) {
274
+ lastFutileRefreshLoggedAt = nowMs;
275
+ appendRefreshLog({ event: 'auth_failed_expired_no_refresh_token', expiredAtMs: expiresAtMs(creds) });
276
+ }
277
+ return {
278
+ kind: 'auth',
279
+ message: 'Credentials expired and cannot be auto-refreshed (no refresh token). Run `claude` in a terminal to log in.',
280
+ expiredAt: expiresAtMs(creds),
281
+ };
282
+ }
283
+
225
284
  // Stretch: try OAuth refresh endpoint first.
226
285
  const oauthResult = await tryOAuthRefresh(creds);
227
286
  appendRefreshLog({ event: `oauth_refresh_${oauthResult.kind}`, message: oauthResult.message ?? null });
@@ -277,8 +336,11 @@ module.exports = {
277
336
  expiresAtMs,
278
337
  isExpired,
279
338
  isExpiringSoon,
339
+ isRefreshFutile,
280
340
  refreshIfNeeded,
281
341
  parseCredsRaw,
342
+ __resetFutileRefreshThrottle,
343
+ FUTILE_REFRESH_COOLDOWN_MS,
282
344
  KEYCHAIN_SERVICE,
283
345
  CREDS_PATH,
284
346
  };
@@ -152,6 +152,21 @@ function classifyCwd(cwd) {
152
152
  if (!cwd || typeof cwd !== 'string') {
153
153
  return { kind: 'unknown', projectRoot: null, outermostOpsRoot: null, innermostOpsRoot: null, reason: 'cwd is not a non-empty string' };
154
154
  }
155
+ // Must run BEFORE inspectGit: nearestGitEntry walks ancestors via
156
+ // fs.statSync, which silently resolves a relative `dir` against
157
+ // process.cwd() — so a relative cwd that happens to be a real subpath of
158
+ // WHATEVER PROCESS IS RUNNING THIS CODE (not the transcript's actual
159
+ // project) can walk up into a real `.git`, including a linked worktree's
160
+ // `.git` FILE, and return that worktree's real main-tree root as if it
161
+ // were the relative fragment's own project. The 'worktree' branch below
162
+ // returns early on g.shape alone with no absolute check of its own, so
163
+ // this guard cannot be deferred to after inspectGit runs (confirmed via
164
+ // opsRootAbsoluteCwd.test.cjs: reproduces in every git worktree, not just
165
+ // job worktrees — process.cwd() there is a worktree whose .git FILE
166
+ // resolves back to the real main tree root).
167
+ if (!path.isAbsolute(cwd)) {
168
+ return { kind: 'unknown', projectRoot: null, outermostOpsRoot: null, innermostOpsRoot: null, reason: 'cwd is not an absolute path' };
169
+ }
155
170
  const parts = cwd.split(path.sep);
156
171
  const first = parts.indexOf(OPS_DIRNAME);
157
172
  const last = parts.lastIndexOf(OPS_DIRNAME);