claude-code-session-manager 0.94.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 (122) hide show
  1. package/dist/assets/{DataModel-B0LDnnSL.js → DataModel-T0D00BpK.js} +1 -1
  2. package/dist/assets/History-DuAovTr7.js +2 -0
  3. package/dist/assets/{Hooks-CwLnp0Z_.js → Hooks-CgP9jsAN.js} +3 -3
  4. package/dist/assets/{HostBilko-CwEHEKYk.js → HostBilko-DHfz3Mxa.js} +1 -1
  5. package/dist/assets/{Library-BU9np05E.js → Library--poVP-d1.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-B74ogK2f.js → MarkdownEditor-C56-Pc4c.js} +1 -1
  7. package/dist/assets/{McpServers-BLM3ftzP.js → McpServers-D-ykP4Vi.js} +2 -2
  8. package/dist/assets/Memory-BrkJjRL8.js +8 -0
  9. package/dist/assets/{Permissions-3pGLJmxQ.js → Permissions-BMpAaxJu.js} +3 -3
  10. package/dist/assets/Plugins-OuEe5S_T.js +2 -0
  11. package/dist/assets/{ProvenanceBadge-BnjBQiV9.js → ProvenanceBadge-4eNbDYe8.js} +1 -1
  12. package/dist/assets/{SaveBar-Zq2NfcPf.js → SaveBar-BQ8y9Lkv.js} +1 -1
  13. package/dist/assets/Scheduler-DcMT5sT_.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-Cnojv22D.js → ScopeSwitcher-XdxtVKP-.js} +1 -1
  15. package/dist/assets/Settings-FdaPUq2t.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-BRDiE6Hv.js → SkillReferenceGraph-CJzvvc0_.js} +1 -1
  17. package/dist/assets/Skills-Wvm9OE1e.js +3 -0
  18. package/dist/assets/{SystemPrompt-tFr19Od6.js → SystemPrompt--LYw_69j.js} +1 -1
  19. package/dist/assets/TagLibrary-Be6HbMMx.js +1 -0
  20. package/dist/assets/{TiptapBody-DGzSg3BO.js → TiptapBody-DzFlWMgO.js} +1 -1
  21. package/dist/assets/{Toggle-DzoROibf.js → Toggle-BxtAFE1i.js} +1 -1
  22. package/dist/assets/index-XkrLB67D.js +3079 -0
  23. package/dist/assets/{settingsSchema-BYKVe1WM.js → settingsSchema-BIvDZ1Cj.js} +1 -1
  24. package/dist/index.html +1 -1
  25. package/package.json +10 -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 +4 -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__/health-tick-liveness.test.cjs +12 -0
  46. package/src/main/__tests__/memoryAggregate.test.cjs +40 -1
  47. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +1 -1
  48. package/src/main/__tests__/planValidator.test.cjs +71 -0
  49. package/src/main/__tests__/prdCreate.test.cjs +3 -1
  50. package/src/main/__tests__/prdSizing.test.cjs +106 -0
  51. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +46 -0
  52. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +10 -0
  53. package/src/main/__tests__/scheduler-looks-done.test.cjs +40 -0
  54. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +81 -0
  55. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +15 -0
  56. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +6 -1
  57. package/src/main/__tests__/scheduler-utilization-hold.test.cjs +89 -0
  58. package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +110 -0
  59. package/src/main/__tests__/seedAgentPersonas.test.cjs +19 -1
  60. package/src/main/__tests__/seedValidatorPersona.test.cjs +116 -0
  61. package/src/main/__tests__/telemetryClient.test.cjs +2 -2
  62. package/src/main/__tests__/telemetryContract.test.cjs +2 -2
  63. package/src/main/__tests__/usageSingleFlight.test.cjs +10 -0
  64. package/src/main/__tests__/validationSentinels.test.cjs +84 -0
  65. package/src/main/build-info.json +4 -4
  66. package/src/main/config.cjs +26 -14
  67. package/src/main/crashDiagnostics.cjs +9 -0
  68. package/src/main/exchanges.cjs +26 -21
  69. package/src/main/health.cjs +91 -5
  70. package/src/main/index.cjs +1 -1
  71. package/src/main/ipcSchemas.cjs +5 -1
  72. package/src/main/lib/__tests__/crashTelemetry.test.cjs +1 -1
  73. package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +115 -0
  74. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +85 -27
  75. package/src/main/lib/__tests__/gitWorktree.test.cjs +21 -27
  76. package/src/main/lib/__tests__/guardShims.test.cjs +10 -3
  77. package/src/main/lib/__tests__/loadGate.test.cjs +44 -1
  78. package/src/main/lib/__tests__/loopDelay.test.cjs +68 -0
  79. package/src/main/lib/__tests__/opsOwnership.test.cjs +14 -0
  80. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +4 -4
  81. package/src/main/lib/__tests__/telemetryConsent.test.cjs +2 -2
  82. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +1 -1
  83. package/src/main/lib/__tests__/usageCircuit.test.cjs +86 -17
  84. package/src/main/lib/agentModelResolve.cjs +1 -0
  85. package/src/main/lib/credentials.cjs +62 -0
  86. package/src/main/lib/cwdClassify.cjs +15 -0
  87. package/src/main/lib/definitionOfDone.cjs +9 -13
  88. package/src/main/lib/effectiveModelInfo.cjs +2 -9
  89. package/src/main/lib/epicWorktreeProjectConfig.cjs +75 -31
  90. package/src/main/lib/loadGate.cjs +29 -5
  91. package/src/main/lib/loopDelay.cjs +44 -0
  92. package/src/main/lib/opsOwnership.cjs +23 -0
  93. package/src/main/lib/planValidator.cjs +28 -0
  94. package/src/main/lib/prdCreate.cjs +8 -4
  95. package/src/main/lib/prdSizing.cjs +83 -0
  96. package/src/main/lib/runLogRetention.cjs +2 -8
  97. package/src/main/lib/telemetryBacklog.cjs +9 -3
  98. package/src/main/lib/telemetryClient.cjs +12 -3
  99. package/src/main/lib/telemetrySettings.cjs +8 -2
  100. package/src/main/lib/usageCircuit.cjs +40 -12
  101. package/src/main/lib/validationSentinels.cjs +29 -0
  102. package/src/main/memoryAggregate.cjs +36 -11
  103. package/src/main/otelSettings.cjs +8 -2
  104. package/src/main/scheduler.cjs +264 -34
  105. package/src/main/seedAgentPersonas.cjs +1 -1
  106. package/src/main/sessionsStore.cjs +8 -2
  107. package/src/main/supervisor.cjs +3 -3
  108. package/src/main/templates/PRD_AUTHORING.md +9 -9
  109. package/src/main/usage.cjs +9 -1
  110. package/src/main/voiceSettings.cjs +8 -2
  111. package/src/preload/api.d.ts +20 -5
  112. package/src/preload/index.cjs +5 -5
  113. package/src/seed/agents/dev-lead.md +24 -15
  114. package/src/seed/agents/validator.md +33 -0
  115. package/dist/assets/History-C685Kytt.js +0 -2
  116. package/dist/assets/Memory-DAP1t8bA.js +0 -8
  117. package/dist/assets/Plugins-Q1KoLEzn.js +0 -2
  118. package/dist/assets/Scheduler-D488ebKm.js +0 -16
  119. package/dist/assets/Settings-D5Lhj7ga.js +0 -3
  120. package/dist/assets/Skills-BQDqp-EN.js +0 -3
  121. package/dist/assets/TagLibrary-BjyrnaRE.js +0 -1
  122. package/dist/assets/index-CCl4tz-u.js +0 -3079
@@ -0,0 +1,68 @@
1
+ /**
2
+ * loopDelay.test.cjs — event-loop delay probe used by crashDiagnostics's heartbeat.
3
+ *
4
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/loopDelay.test.cjs
5
+ */
6
+ 'use strict';
7
+
8
+ import { test, expect } from 'vitest';
9
+
10
+ const { createLoopDelayMonitor, stallVerdict, LOOP_STALL_WARN_MS } = require('../loopDelay.cjs');
11
+
12
+ // Histogram stub: values in ns, like perf_hooks.
13
+ function stubHistogram(state) {
14
+ return () => ({
15
+ enabled: false,
16
+ enable() { this.enabled = true; },
17
+ disable() { this.enabled = false; },
18
+ percentile(p) { return p === 50 ? state.p50 : state.p99; },
19
+ get max() { return state.max; },
20
+ reset() { state.resets += 1; state.p50 = 0; state.p99 = 0; state.max = 0; },
21
+ });
22
+ }
23
+
24
+ test('normal beat: converts ns to ms (1 decimal) and does not flag a stall', () => {
25
+ const state = { p50: 21_040_000, p99: 34_960_000, max: 40_000_000, resets: 0 };
26
+ const m = createLoopDelayMonitor(stubHistogram(state));
27
+ m.enable();
28
+ const snap = m.snapshot();
29
+ expect(snap).toEqual({ loopDelayP50Ms: 21, loopDelayP99Ms: 35, loopDelayMaxMs: 40 });
30
+ expect(stallVerdict(snap)).toBeNull();
31
+ });
32
+
33
+ test('stall beat: max above threshold yields warn "main loop stall"', () => {
34
+ const state = { p50: 20_000_000, p99: 300_000_000, max: 412_300_000, resets: 0 };
35
+ const snap = createLoopDelayMonitor(stubHistogram(state)).snapshot();
36
+ expect(snap.loopDelayMaxMs).toBe(412.3);
37
+ expect(stallVerdict(snap)).toEqual({ level: 'warn', message: 'main loop stall' });
38
+ expect(stallVerdict({ loopDelayMaxMs: LOOP_STALL_WARN_MS })).toBeNull(); // strictly greater
39
+ });
40
+
41
+ test('reset between beats: a stall does not bleed into the next interval', () => {
42
+ const state = { p50: 20_000_000, p99: 300_000_000, max: 500_000_000, resets: 0 };
43
+ const m = createLoopDelayMonitor(stubHistogram(state));
44
+ expect(stallVerdict(m.snapshot())).not.toBeNull();
45
+ expect(state.resets).toBe(1);
46
+ const second = m.snapshot();
47
+ expect(second.loopDelayMaxMs).toBe(0);
48
+ expect(stallVerdict(second)).toBeNull();
49
+ expect(state.resets).toBe(2);
50
+ });
51
+
52
+ test('passes a 20 ms resolution to the histogram factory and disable() stops it', () => {
53
+ let opts; let h;
54
+ const m = createLoopDelayMonitor((o) => { opts = o; h = stubHistogram({ resets: 0 })(); return h; });
55
+ m.enable();
56
+ expect(opts).toEqual({ resolution: 20 });
57
+ expect(h.enabled).toBe(true);
58
+ m.disable();
59
+ expect(h.enabled).toBe(false);
60
+ });
61
+
62
+ test('real perf_hooks histogram produces finite numbers', () => {
63
+ const m = createLoopDelayMonitor();
64
+ m.enable();
65
+ const snap = m.snapshot();
66
+ m.disable();
67
+ for (const v of Object.values(snap)) expect(Number.isFinite(v)).toBe(true);
68
+ });
@@ -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
 
@@ -101,18 +101,74 @@ test('isResetFresh: past -> false, future -> true, null -> false', () => {
101
101
  assert.strictEqual(isResetFresh('not-a-date', now), false);
102
102
  });
103
103
 
104
- test('bindingWindow: limits[] with weekly_all is_active wins over five_hour', () => {
104
+ const REAL_LIMITS = [
105
+ { kind: 'session', group: 'session', percent: 6, severity: 'normal', resets_at: '2026-09-22T00:40:00.280514+00:00', scope: null, is_active: false },
106
+ { kind: 'weekly_all', group: 'weekly', percent: 64, severity: 'normal', resets_at: '2026-09-24T17:00:00.280532+00:00', scope: null, is_active: true },
107
+ { kind: 'weekly_scoped', group: 'weekly', percent: 18, severity: 'normal', resets_at: '2026-09-24T17:00:00.280763+00:00', scope: { model: { id: null, display_name: 'Fable' }, surface: null }, is_active: false },
108
+ ];
109
+
110
+ test('bindingWindow: verbatim real payload selects weekly_all 64', () => {
105
111
  const payload = {
106
- five_hour: { utilization: 5, resets_at: '2026-09-13T15:00:00Z' },
112
+ five_hour: { utilization: 6, resets_at: '2026-09-22T00:40:00.280514+00:00' },
113
+ seven_day: { utilization: 64, resets_at: '2026-09-24T17:00:00.280532+00:00' },
114
+ limits: REAL_LIMITS,
115
+ };
116
+ assert.deepStrictEqual(bindingWindow(payload), {
117
+ name: 'weekly_all',
118
+ utilization: 64,
119
+ resets_at: '2026-09-24T17:00:00.280532+00:00',
120
+ });
121
+ });
122
+
123
+ test('bindingWindow: max percent wins over is_active (session 95 beats weekly_all 64)', () => {
124
+ const win = bindingWindow({
107
125
  limits: [
108
- { type: 'five_hour', is_active: false, utilization: 5, resets_at: '2026-09-13T15:00:00Z' },
109
- { type: 'weekly_all', is_active: true, utilization: 81, resets_at: '2026-09-19T00:00:00Z' },
126
+ { kind: 'session', percent: 95, resets_at: '2026-09-22T00:40:00Z', scope: null, is_active: false },
127
+ { kind: 'weekly_all', percent: 64, resets_at: '2026-09-24T17:00:00Z', scope: null, is_active: true },
110
128
  ],
111
- };
112
- const win = bindingWindow(payload);
129
+ });
130
+ assert.strictEqual(win.name, 'session');
131
+ assert.strictEqual(win.utilization, 95);
132
+ });
133
+
134
+ test('bindingWindow: is_active breaks a percent tie', () => {
135
+ const win = bindingWindow({
136
+ limits: [
137
+ { kind: 'session', percent: 50, resets_at: null, scope: null, is_active: false },
138
+ { kind: 'weekly_all', percent: 50, resets_at: null, scope: null, is_active: true },
139
+ ],
140
+ });
113
141
  assert.strictEqual(win.name, 'weekly_all');
114
- assert.strictEqual(win.utilization, 81);
115
- assert.strictEqual(win.resets_at, '2026-09-19T00:00:00Z');
142
+ });
143
+
144
+ test('bindingWindow: a high weekly_scoped entry is never selected', () => {
145
+ const win = bindingWindow({
146
+ limits: [
147
+ { kind: 'session', percent: 6, resets_at: null, scope: null, is_active: false },
148
+ { kind: 'weekly_all', percent: 10, resets_at: null, scope: null, is_active: true },
149
+ { kind: 'weekly_scoped', percent: 99, resets_at: null, scope: { model: { display_name: 'Fable' } }, is_active: false },
150
+ ],
151
+ });
152
+ assert.strictEqual(win.name, 'weekly_all');
153
+ assert.strictEqual(win.utilization, 10);
154
+ });
155
+
156
+ test('bindingWindow: percent over 100 is returned raw, not clamped', () => {
157
+ const win = bindingWindow({ limits: [{ kind: 'session', percent: 112, resets_at: null, scope: null }] });
158
+ assert.strictEqual(win.utilization, 112);
159
+ });
160
+
161
+ test('bindingWindow: null resets_at backfills from the matching flat window', () => {
162
+ const win = bindingWindow({
163
+ five_hour: { utilization: 40, resets_at: '2026-09-22T00:40:00Z' },
164
+ seven_day: { utilization: 5, resets_at: '2026-09-24T17:00:00Z' },
165
+ limits: [
166
+ { kind: 'session', percent: 40, resets_at: null, scope: null },
167
+ { kind: 'weekly_all', percent: 5, resets_at: null, scope: null },
168
+ ],
169
+ });
170
+ assert.strictEqual(win.name, 'session');
171
+ assert.strictEqual(win.resets_at, '2026-09-22T00:40:00Z');
116
172
  });
117
173
 
118
174
  test('bindingWindow: limits[] absent falls back to five_hour', () => {
@@ -122,12 +178,12 @@ test('bindingWindow: limits[] absent falls back to five_hour', () => {
122
178
  assert.strictEqual(win.utilization, 42);
123
179
  });
124
180
 
125
- test('bindingWindow: limits[] present but nothing is_active also falls back to five_hour', () => {
181
+ test('bindingWindow: limits[] with only scoped / non-finite entries falls back to five_hour', () => {
126
182
  const payload = {
127
183
  five_hour: { utilization: 12, resets_at: null },
128
184
  limits: [
129
- { type: 'five_hour', is_active: false, utilization: 12, resets_at: null },
130
- { type: 'weekly_all', is_active: false, utilization: 81, resets_at: null },
185
+ { kind: 'weekly_scoped', percent: 81, resets_at: null, scope: { model: { display_name: 'Fable' } } },
186
+ { kind: 'session', resets_at: null, scope: null },
131
187
  ],
132
188
  };
133
189
  const win = bindingWindow(payload);
@@ -135,19 +191,32 @@ test('bindingWindow: limits[] present but nothing is_active also falls back to f
135
191
  assert.strictEqual(win.utilization, 12);
136
192
  });
137
193
 
138
- test('degradedBudget: never yields 0 utilization, even when the last good binding window read 0', () => {
194
+ test('degradedBudget: a last-good binding window that genuinely read 0% carries forward as 0, not 100', () => {
139
195
  const payload = { five_hour: { utilization: 0, resets_at: null } };
140
- const { utilization } = degradedBudget(payload, {});
141
- assert.notStrictEqual(utilization, 0);
142
- assert.strictEqual(utilization, 100); // no real signal -> conservative default
196
+ assert.strictEqual(degradedBudget(payload, {}).utilization, 0);
197
+ });
198
+
199
+ test('degradedBudget: no payload ever received (null/absent) still yields 100, never a blind 0', () => {
200
+ assert.strictEqual(degradedBudget(null, {}).utilization, 100);
201
+ assert.strictEqual(degradedBudget(undefined, {}).utilization, 100);
202
+ });
203
+
204
+ test('degradedBudget: a degraded cycle after a real-shape poll carries forward the real percent (64), not 100', () => {
205
+ const payload = {
206
+ limits: [
207
+ { kind: 'session', percent: 6, resets_at: null, scope: null, is_active: false },
208
+ { kind: 'weekly_all', percent: 64, resets_at: '2026-09-24T17:00:00Z', scope: null, is_active: true },
209
+ ],
210
+ };
211
+ assert.strictEqual(degradedBudget(payload, {}).utilization, 64);
143
212
  });
144
213
 
145
214
  test('degradedBudget: carries forward the real last-good BINDING (weekly_all) utilization, e.g. 81%, never 0', () => {
146
215
  const payload = {
147
216
  five_hour: { utilization: 0, resets_at: null },
148
217
  limits: [
149
- { type: 'five_hour', is_active: false, utilization: 0, resets_at: null },
150
- { type: 'weekly_all', is_active: true, utilization: 81, resets_at: '2026-09-19T00:00:00Z' },
218
+ { kind: 'session', is_active: false, percent: 0, resets_at: null, scope: null },
219
+ { kind: 'weekly_all', is_active: true, percent: 81, resets_at: '2026-09-19T00:00:00Z', scope: null },
151
220
  ],
152
221
  };
153
222
  const { utilization } = degradedBudget(payload, {});
@@ -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);
@@ -14,6 +14,7 @@ const path = require('node:path');
14
14
  const { spawn, spawnSync } = require('node:child_process');
15
15
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
16
16
  const { resolvePrdWriteDir } = require('./prdLocations.cjs');
17
+ const schedulerPaths = require('./schedulerPaths.cjs');
17
18
 
18
19
  // Regex identifying meta/dod slugs that must NOT influence the batchKey.
19
20
  // This is the load-bearing loop-avoidance filter: when the gate job itself
@@ -21,11 +22,6 @@ const { resolvePrdWriteDir } = require('./prdLocations.cjs');
21
22
  // a no-op (idempotent) instead of re-firing forever.
22
23
  const DOD_SLUG_RE = /(^|-)dod(-|$)|definition-of-done/i;
23
24
 
24
- const RUNS_DIR = path.join(
25
- os.homedir(),
26
- '.claude', 'session-manager', 'scheduled-plans', 'runs'
27
- );
28
-
29
25
  /**
30
26
  * Compute a stable short hash for a completed job-set.
31
27
  *
@@ -62,7 +58,7 @@ function batchKey(jobs) {
62
58
  function reportPathFor(key) {
63
59
  if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
64
60
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
65
- return path.join(RUNS_DIR, ts, `definition-of-done-${key}.md`);
61
+ return path.join(schedulerPaths.runsDir(), ts, `definition-of-done-${key}.md`);
66
62
  }
67
63
 
68
64
  /**
@@ -70,10 +66,10 @@ function reportPathFor(key) {
70
66
  * run subdirectory. Scans runs/<ts>/ (shallow, one level).
71
67
  *
72
68
  * @param {string} key Output of batchKey()
73
- * @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
69
+ * @param {string} [runsDir] Override for testing; defaults to schedulerPaths.runsDir()
74
70
  * @returns {boolean}
75
71
  */
76
- function reportExists(key, runsDir = RUNS_DIR) {
72
+ function reportExists(key, runsDir = schedulerPaths.runsDir()) {
77
73
  if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
78
74
  let entries;
79
75
  try {
@@ -604,7 +600,7 @@ const STATUS_EMOJI = { pass: '✅', fail: '❌', unverifiable: '⚠️' };
604
600
  function writeReport(key, { acResults = [], riskFlags = [], runsDir } = {}) {
605
601
  if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
606
602
 
607
- const resolvedRunsDir = runsDir ?? RUNS_DIR;
603
+ const resolvedRunsDir = runsDir ?? schedulerPaths.runsDir();
608
604
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
609
605
  const dir = path.join(resolvedRunsDir, ts);
610
606
  const reportPath = path.join(dir, `definition-of-done-${key}.md`);
@@ -781,10 +777,10 @@ const WATERMARK_FILENAME = '.dod-watermark.json';
781
777
  * sidecar means "beginning of time" (process everything), matching the
782
778
  * pre-watermark unbounded behavior on first-ever drain.
783
779
  *
784
- * @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
780
+ * @param {string} [runsDir] Override for testing; defaults to schedulerPaths.runsDir()
785
781
  * @returns {string|null} ISO8601 timestamp, or null if unset/unreadable.
786
782
  */
787
- function readWatermark(runsDir = RUNS_DIR) {
783
+ function readWatermark(runsDir = schedulerPaths.runsDir()) {
788
784
  try {
789
785
  const raw = fs.readFileSync(path.join(runsDir, WATERMARK_FILENAME), 'utf8');
790
786
  const parsed = JSON.parse(raw);
@@ -799,9 +795,9 @@ function readWatermark(runsDir = RUNS_DIR) {
799
795
  * jobs that completed after it.
800
796
  *
801
797
  * @param {string} lastFinishedAt ISO8601 timestamp.
802
- * @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
798
+ * @param {string} [runsDir] Override for testing; defaults to schedulerPaths.runsDir()
803
799
  */
804
- function writeWatermark(lastFinishedAt, runsDir = RUNS_DIR) {
800
+ function writeWatermark(lastFinishedAt, runsDir = schedulerPaths.runsDir()) {
805
801
  fs.mkdirSync(runsDir, { recursive: true });
806
802
  _writeFileAtomic(
807
803
  path.join(runsDir, WATERMARK_FILENAME),
@@ -28,6 +28,7 @@ const path = require('node:path');
28
28
  const os = require('node:os');
29
29
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
30
30
  const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
31
+ const schedulerPaths = require('./schedulerPaths.cjs');
31
32
 
32
33
  // Deliberately stays a FIXED alias set (reviewed against the catalog-driven raw picker): it answers
33
34
  // "alias vs concrete id", not "which options to offer", so live catalog ids must not enter it.
@@ -51,13 +52,6 @@ const MAX_RUN_LOG_HEAD_BYTES = 64 * 1024;
51
52
  // pass over a transcript that can reach tens of MB. O(min(size, cap)).
52
53
  const MAX_TRANSCRIPT_TAIL_BYTES = 8 * 1024 * 1024;
53
54
 
54
- // Mirrors scheduler.cjs's own ROOT/RUNS_DIR constants (same convention as
55
- // queueStore.cjs's LEGACY_QUEUE_PATH, which duplicates this literal rather
56
- // than requiring scheduler.cjs's ~530KB module graph for one path).
57
- function runsDir(homeDir) {
58
- return path.join(homeDir, '.claude', 'session-manager', 'scheduled-plans', 'runs');
59
- }
60
-
61
55
  function isConcreteModelId(modelAlias) {
62
56
  if (!modelAlias) return false;
63
57
  // 'opus[1m]' is an alias with a context suffix; 'claude-opus-4-7[1m]' keeps its id.
@@ -232,10 +226,9 @@ function findLatestSchedulerModel(cwd, agentType, deps) {
232
226
  const matches = jobs.filter((j) => j && j.agentType === agentType && j.runId && j.slug);
233
227
  matches.sort((a, b) => jobTimestampMs(b) - jobTimestampMs(a));
234
228
 
235
- const homeDir = deps.homeDir || os.homedir();
236
229
  const re = /\[scheduler\] agentType=\S+ persona=.*? model=(\S+)/g;
237
230
  for (const job of matches) {
238
- const logPath = path.join(runsDir(homeDir), job.runId, `${job.slug}.log`);
231
+ const logPath = path.join(schedulerPaths.runsDir(), job.runId, `${job.slug}.log`);
239
232
  const head = readHead(logPath, MAX_RUN_LOG_HEAD_BYTES, deps);
240
233
  if (!head) continue;
241
234
  let match;