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,116 @@
1
+ /**
2
+ * seedValidatorPersona.test.cjs — covers the `validator` persona's delivery
3
+ * through seedAgentPersonas.cjs's existing "names not yet in `seeded`" path.
4
+ *
5
+ * Run: timeout 120 npx vitest run src/main/__tests__/seedValidatorPersona.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ import { test, expect, beforeEach, afterEach } from 'vitest';
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+
15
+ let tmpHome;
16
+ let realHome;
17
+ let seedAgentPersonas;
18
+
19
+ // config.cjs bakes os.homedir() into top-level consts at first require
20
+ // (allowedRoots/WRITE_PREFIXES, used by writeJsonSync) — must be purged
21
+ // alongside seedAgentPersonas.cjs so each test's tmpHome takes effect.
22
+ const MODULES_TO_RELOAD = ['../seedAgentPersonas.cjs', '../config.cjs'];
23
+
24
+ function purgeRequireCache() {
25
+ for (const m of MODULES_TO_RELOAD) {
26
+ try { delete require.cache[require.resolve(m)]; } catch { /* not loaded yet */ }
27
+ }
28
+ }
29
+
30
+ beforeEach(() => {
31
+ realHome = process.env.HOME;
32
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'seed-validator-persona-test-'));
33
+ process.env.HOME = tmpHome;
34
+ delete process.env.SM_SEED_AGENT_PERSONAS_DISABLE;
35
+ purgeRequireCache();
36
+ ({ seedAgentPersonas } = require('../seedAgentPersonas.cjs'));
37
+ });
38
+
39
+ afterEach(() => {
40
+ process.env.HOME = realHome;
41
+ delete process.env.SM_SEED_AGENT_PERSONAS_DISABLE;
42
+ fs.rmSync(tmpHome, { recursive: true, force: true });
43
+ purgeRequireCache();
44
+ });
45
+
46
+ function agentsDir() {
47
+ return path.join(tmpHome, '.claude', 'agents');
48
+ }
49
+
50
+ function markerPath() {
51
+ return path.join(tmpHome, '.claude', 'session-manager', '.agent-personas-seeded');
52
+ }
53
+
54
+ function silentLogger() {
55
+ return { log: () => {}, warn: () => {} };
56
+ }
57
+
58
+ const ALL_PERSONAS = ['architect', 'dev-lead', 'project-home-builder', 'validator'];
59
+ const seedSrc = (name) => path.join(__dirname, '..', '..', 'seed', 'agents', `${name}.md`);
60
+
61
+ test('a fresh homedir gets all four persona files', async () => {
62
+ await seedAgentPersonas({ logger: silentLogger() });
63
+
64
+ for (const name of ALL_PERSONAS) {
65
+ const dest = path.join(agentsDir(), `${name}.md`);
66
+ expect(fs.existsSync(dest)).toBe(true);
67
+ expect(fs.readFileSync(dest, 'utf8')).toBe(fs.readFileSync(seedSrc(name), 'utf8'));
68
+ }
69
+
70
+ const marker = JSON.parse(fs.readFileSync(markerPath(), 'utf8'));
71
+ expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
72
+ });
73
+
74
+ test('a homedir whose marker already lists the first three receives validator.md, and the other three files are byte-identical before and after', async () => {
75
+ const mPath = markerPath();
76
+ fs.mkdirSync(path.dirname(mPath), { recursive: true });
77
+ fs.writeFileSync(
78
+ mPath,
79
+ JSON.stringify({ seeded: ['architect', 'dev-lead', 'project-home-builder'], attempts: 0, ts: '2026-01-01T00:00:00.000Z' })
80
+ );
81
+ fs.mkdirSync(agentsDir(), { recursive: true });
82
+ const preExisting = {};
83
+ for (const name of ['architect', 'dev-lead', 'project-home-builder']) {
84
+ const content = `existing ${name} content\n`;
85
+ fs.writeFileSync(path.join(agentsDir(), `${name}.md`), content);
86
+ preExisting[name] = content;
87
+ }
88
+
89
+ expect(fs.existsSync(path.join(agentsDir(), 'validator.md'))).toBe(false);
90
+
91
+ await seedAgentPersonas({ logger: silentLogger() });
92
+
93
+ expect(fs.readFileSync(path.join(agentsDir(), 'validator.md'), 'utf8')).toBe(fs.readFileSync(seedSrc('validator'), 'utf8'));
94
+
95
+ for (const name of ['architect', 'dev-lead', 'project-home-builder']) {
96
+ expect(fs.readFileSync(path.join(agentsDir(), `${name}.md`), 'utf8')).toBe(preExisting[name]);
97
+ }
98
+
99
+ const marker = JSON.parse(fs.readFileSync(mPath, 'utf8'));
100
+ expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
101
+ });
102
+
103
+ test('the seeded validator body is under AGENT_BODY_CHAR_CAP and contains both required sentinel lines', () => {
104
+ const { AGENT_BODY_CHAR_CAP } = require('../lib/agentModelResolve.cjs');
105
+ const { splitFrontmatter } = require('../lib/prdFrontmatter.cjs');
106
+ const raw = fs.readFileSync(seedSrc('validator'), 'utf8');
107
+ const { body } = splitFrontmatter(raw);
108
+ const trimmed = body.trim();
109
+
110
+ expect(trimmed.length).toBeLessThan(AGENT_BODY_CHAR_CAP);
111
+ expect(trimmed).toContain('VALIDATION: <slug> VERIFIED');
112
+ expect(trimmed).toContain('SCHEDULER_VERDICT: PASS');
113
+ expect(trimmed).not.toContain('Do not touch:');
114
+ expect(trimmed).not.toContain('Report explicitly:');
115
+ expect(trimmed.endsWith('report them.')).toBe(true);
116
+ });
@@ -78,8 +78,8 @@ function freshClient(home, profileOverrides) {
78
78
  // Explicit opt-in: this suite deliberately exercises telemetryClient's real
79
79
  // queue/sent-file I/O, so it must override the test-environment no-op guard
80
80
  // by pointing the spool at this test's own isolated tmp dir (never a real
81
- // user's ~/.config/session-manager).
82
- process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
81
+ // user's ~/.claude/session-manager).
82
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.claude', 'session-manager');
83
83
  for (const p of ['../lib/telemetryClient.cjs', '../config.cjs', '../lib/telemetrySettings.cjs', '../lib/machineProfile.cjs']) {
84
84
  const resolved = require.resolve(p);
85
85
  delete require.cache[resolved];
@@ -348,7 +348,7 @@ function freshClient(home, profileOverrides) {
348
348
  process.env.HOME = home;
349
349
  // Explicit opt-in: overrides the test-environment no-op guard so this
350
350
  // suite's real telemetryClient calls actually persist into an isolated dir.
351
- process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
351
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.claude', 'session-manager');
352
352
  for (const p of HOME_DEPENDENT_MODULES) {
353
353
  const resolved = require.resolve(p);
354
354
  delete require.cache[resolved];
@@ -695,7 +695,7 @@ test('records queued, a 503 mid-drain, then a fresh client flushing successfully
695
695
 
696
696
  expect(stubServer.state.errors.length).toBe(before); // nothing landed during the failure
697
697
 
698
- const wmPath = path.join(home, '.config', 'session-manager', 'telemetry-watermarks.json');
698
+ const wmPath = path.join(home, '.claude', 'session-manager', 'telemetry-watermarks.json');
699
699
  const wmAfterFailure = JSON.parse(await fsp.readFile(wmPath, 'utf8'));
700
700
  const entryAfterFailure = Object.values(wmAfterFailure).find((e) => e && typeof e === 'object' && 'bytesConfirmed' in e);
701
701
  expect(entryAfterFailure.bytesConfirmed).toBe(0); // bytesConfirmed frozen during the failed phase
@@ -88,6 +88,7 @@ afterEach(() => {
88
88
  process.env.HOME = realHome;
89
89
  vi.unstubAllGlobals();
90
90
  vi.restoreAllMocks();
91
+ vi.useRealTimers();
91
92
  purgeRequireCache();
92
93
  // maxRetries: a fire-and-forget cache write inside usage.cjs can land in .claude/ mid-rm
93
94
  // (ENOTEMPTY) when the machine is loaded; this is cleanup robustness, not a test retry.
@@ -112,6 +113,15 @@ test('two concurrent fetchUsage() calls produce exactly one HTTP fetch', async (
112
113
  });
113
114
 
114
115
  test('an ok result resets the shared circuit streak built up by prior failures', async () => {
116
+ // The breaker's open->half_open transition (usageCircuit.cjs's state())
117
+ // compares real Date.now() against a jittered 15-30s backoff deadline. On
118
+ // a loaded machine, real wall-clock time can drift far enough between the
119
+ // 3rd failure and the assertions below for that deadline to pass, flipping
120
+ // 'open' to 'half_open' out from under this test. Freeze time so the
121
+ // open/closed assertions are decided by call sequence, not the real clock.
122
+ vi.useFakeTimers();
123
+ vi.setSystemTime(Date.now());
124
+
115
125
  const usage = require('../usage.cjs');
116
126
  const circuit = usage.__usageCircuitForTest;
117
127
 
@@ -0,0 +1,84 @@
1
+ /**
2
+ * validationSentinels.test.cjs — unit tests for parseValidationSentinels
3
+ * (PRD 1407): a plan-level validator job ends its run with one
4
+ * `VALIDATION: <slug> VERIFIED` or `VALIDATION: <slug> REFUTED — <reason>`
5
+ * line per PRD; this module extracts those lines from the job's result text.
6
+ *
7
+ * Run: timeout 300 npx vitest run src/main/__tests__/validationSentinels.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect } from 'vitest';
13
+ const { parseValidationSentinels } = require('../lib/validationSentinels.cjs');
14
+
15
+ test('parses a VERIFIED line with no reason', () => {
16
+ expect(parseValidationSentinels('VALIDATION: 123-foo VERIFIED')).toEqual([
17
+ { slug: '123-foo', verdict: 'verified', reason: '' },
18
+ ]);
19
+ });
20
+
21
+ test('parses a REFUTED line with an em-dash reason', () => {
22
+ expect(parseValidationSentinels('VALIDATION: 123-foo REFUTED — missing test coverage')).toEqual([
23
+ { slug: '123-foo', verdict: 'refuted', reason: 'missing test coverage' },
24
+ ]);
25
+ });
26
+
27
+ test('parses a REFUTED line with an en-dash reason', () => {
28
+ expect(parseValidationSentinels('VALIDATION: 123-foo REFUTED – bad wiring')).toEqual([
29
+ { slug: '123-foo', verdict: 'refuted', reason: 'bad wiring' },
30
+ ]);
31
+ });
32
+
33
+ test('parses a REFUTED line with a hyphen reason', () => {
34
+ expect(parseValidationSentinels('VALIDATION: 123-foo REFUTED - wrong file touched')).toEqual([
35
+ { slug: '123-foo', verdict: 'refuted', reason: 'wrong file touched' },
36
+ ]);
37
+ });
38
+
39
+ test('parses a REFUTED line with no reason', () => {
40
+ expect(parseValidationSentinels('VALIDATION: 123-foo REFUTED')).toEqual([
41
+ { slug: '123-foo', verdict: 'refuted', reason: '' },
42
+ ]);
43
+ });
44
+
45
+ test('ignores lowercase verified/refuted', () => {
46
+ expect(parseValidationSentinels('VALIDATION: 123-foo verified')).toEqual([]);
47
+ expect(parseValidationSentinels('VALIDATION: 123-foo refuted')).toEqual([]);
48
+ });
49
+
50
+ test('ignores every other line in a multi-line transcript', () => {
51
+ const text = [
52
+ 'Some preamble text',
53
+ 'Running gate...',
54
+ 'VALIDATION: 111-alpha VERIFIED',
55
+ 'random noise mentioning VALIDATION: not-a-real-line',
56
+ 'VALIDATION: 222-beta REFUTED — the fix was incomplete',
57
+ 'trailing notes',
58
+ ].join('\n');
59
+
60
+ expect(parseValidationSentinels(text)).toEqual([
61
+ { slug: '111-alpha', verdict: 'verified', reason: '' },
62
+ { slug: '222-beta', verdict: 'refuted', reason: 'the fix was incomplete' },
63
+ ]);
64
+ });
65
+
66
+ test('last line per slug wins, order of first appearance kept', () => {
67
+ const text = [
68
+ 'VALIDATION: 111-alpha VERIFIED',
69
+ 'VALIDATION: 222-beta REFUTED — first reason',
70
+ 'VALIDATION: 111-alpha REFUTED — actually broken',
71
+ 'VALIDATION: 222-beta VERIFIED',
72
+ ].join('\n');
73
+
74
+ expect(parseValidationSentinels(text)).toEqual([
75
+ { slug: '111-alpha', verdict: 'refuted', reason: 'actually broken' },
76
+ { slug: '222-beta', verdict: 'verified', reason: '' },
77
+ ]);
78
+ });
79
+
80
+ test('returns [] for null, undefined, and empty input', () => {
81
+ expect(parseValidationSentinels(null)).toEqual([]);
82
+ expect(parseValidationSentinels(undefined)).toEqual([]);
83
+ expect(parseValidationSentinels('')).toEqual([]);
84
+ });
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.95.0",
3
- "gitSha": "42f7779080772b1b8432b6287aff06c52b9f0f90",
4
- "gitShortSha": "42f77790",
2
+ "version": "0.96.0",
3
+ "gitSha": "32cc4ebad387727ee0d691ffcf524d938efdcfca",
4
+ "gitShortSha": "32cc4eba",
5
5
  "gitBranch": "HEAD",
6
- "builtAt": "2026-09-21T22:01:57.972Z",
6
+ "builtAt": "2026-09-24T19:16:51.123Z",
7
7
  "dirty": false
8
8
  }
@@ -19,7 +19,7 @@ const fs = require('node:fs');
19
19
  const fsp = require('node:fs/promises');
20
20
  const path = require('node:path');
21
21
  const os = require('node:os');
22
- const { assertOpsWrite, opsPath } = require('./lib/opsOwnership.cjs');
22
+ const { assertOpsWrite, opsPath, OWNERS } = require('./lib/opsOwnership.cjs');
23
23
  const chokidar = require('chokidar');
24
24
  const logs = require('./logs.cjs');
25
25
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
@@ -113,7 +113,6 @@ const WRITE_PREFIXES = [
113
113
  path.join(os.homedir(), '.claude'),
114
114
  path.join(os.homedir(), '.claude.json'), // global MCP servers config
115
115
  path.join(os.homedir(), '.config', 'claude-code'),
116
- path.join(os.homedir(), '.config', 'session-manager'),
117
116
  ];
118
117
 
119
118
  /**
@@ -144,17 +143,15 @@ function validateWrite(realAbs) {
144
143
  if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
145
144
  return;
146
145
  }
147
- // Writable ops namespaces under a registered project root — this
148
- // repo's per-project artifact-store convention:
149
- // prompt-sessions PromptSession persistence (promptSessions.ts) and
150
- // the scheduler's read-modify-write of active-index
151
- // (promptSessionEvents.cjs, PRD 814)
152
- // scheduler PRD sources + Epic PRD dirs + state shards
153
- // (PRD 825; scheduler.cjs remote.writePrd)
154
- // project-brief projectBrief.cjs's brief.json (PRD 837)
155
- // bilko-host bilkoHost.cjs's bundle prep
156
- // project-pages projectHomeAdminRoutes.cjs's project_home_write
157
- // path (home.html)
146
+ // Writable ops namespaces under a registered project root — every
147
+ // namespace declared in opsOwnership.cjs's OWNERS map is writable here
148
+ // by construction (PRD 1410): OWNERS is the single source of truth for
149
+ // "this namespace exists and has an owner", so a namespace can never
150
+ // again be declared owned yet unwritable (incident: PRD 1398 added
151
+ // 'ui-prefs' to OWNERS but this list was hand-maintained separately and
152
+ // never updated, so every ui-prefs write threw here before
153
+ // assertOpsWrite's per-writer check ever ran). Which WRITER may use a
154
+ // given namespace is still assertOpsWrite's job below, not this list's.
158
155
  // Paths come from opsOwnership.opsPath (PRD 1082) — the one ops-root
159
156
  // resolver — which THROWS for an ephemeral root (a linked worktree that
160
157
  // pty.cjs registered as an allowed root for its spawn cwd, or
@@ -162,7 +159,7 @@ function validateWrite(realAbs) {
162
159
  // never a writable destination, so its exemptions simply do not exist.
163
160
  let opsSubs;
164
161
  try {
165
- opsSubs = ['prompt-sessions', 'scheduler', 'project-brief', 'bilko-host', 'project-pages'].map((ns) => opsPath(realRoot, ns));
162
+ opsSubs = Object.keys(OWNERS).map((ns) => opsPath(realRoot, ns));
166
163
  } catch {
167
164
  opsSubs = [];
168
165
  }
@@ -313,6 +310,20 @@ function writeJsonSync(abs, data, opts = {}) {
313
310
  return { ok: true, mtimeMs: stat.mtimeMs };
314
311
  }
315
312
 
313
+ /**
314
+ * One-shot best-effort move of a legacy config file to its new home. No-op
315
+ * (silent) when the new path already exists or the old path never existed —
316
+ * either way there is nothing to migrate. Same-filesystem rename is atomic,
317
+ * so there is no partial-write window to worry about.
318
+ */
319
+ function migrateLegacyHomeFile(oldPath, newPath) {
320
+ try {
321
+ if (fs.existsSync(newPath) || !fs.existsSync(oldPath)) return;
322
+ fs.mkdirSync(path.dirname(newPath), { recursive: true });
323
+ fs.renameSync(oldPath, newPath);
324
+ } catch { /* best-effort; a stale/missing legacy file must never block boot */ }
325
+ }
326
+
316
327
  async function listDir(abs, { filesOnly = false, dirsOnly = false, includeHidden = false } = {}) {
317
328
  try {
318
329
  abs = validatePath(expandHome(abs));
@@ -490,6 +501,7 @@ module.exports = {
490
501
  readText,
491
502
  writeJson,
492
503
  writeJsonSync,
504
+ migrateLegacyHomeFile,
493
505
  writeTextAtomic,
494
506
  writeBinaryAtomic,
495
507
  listDir,
@@ -3,8 +3,15 @@
3
3
  /**
4
4
  * exchanges.cjs — durable append-only log of completed terminal-chat exchanges.
5
5
  *
6
- * Appends one NDJSON record per successful chat run to:
7
- * ~/.claude/knowledge-log/exchanges/<encodeCwd(cwd)>.jsonl
6
+ * Appends one NDJSON record per successful chat run to the per-project ops
7
+ * root (single-writer law, lib/opsOwnership.cjs — 'prompt-sessions' is owned
8
+ * by 'epics'):
9
+ * <cwd>/session-manager-operations/prompt-sessions/exchanges.jsonl
10
+ *
11
+ * Historical rows from before this PRD live on, untouched, in the old global
12
+ * store (~/.claude/knowledge-log/exchanges/<encodeCwd(cwd)>.jsonl) — migrating
13
+ * them would require the same unsafe cwd-decoding this store is meant to
14
+ * avoid, so they are simply left behind; only new exchanges land here.
8
15
  *
9
16
  * Record shape (contract for PRDs 324 + 325):
10
17
  * { ts, sessionId, cwd, prompt, result, summary, degraded?, promptId? }
@@ -20,31 +27,30 @@
20
27
  */
21
28
 
22
29
  const fsp = require('node:fs/promises');
23
- const fs = require('node:fs');
24
30
  const path = require('node:path');
25
- const os = require('node:os');
26
- const { encodeCwd } = require('./lib/encodeCwd.cjs');
27
- const { validatePath } = require('./config.cjs');
31
+ const { opsPath, assertOpsWrite } = require('./lib/opsOwnership.cjs');
28
32
  const { summarize } = require('./lib/summarize.cjs');
29
33
 
30
- const HOME = os.homedir();
31
- const EXCHANGES_DIR = path.join(HOME, '.claude', 'knowledge-log', 'exchanges');
34
+ const EXCHANGES_WRITER = 'epics';
35
+
36
+ function exchangesFilePath(cwd) {
37
+ return opsPath(cwd, 'prompt-sessions', 'exchanges.jsonl');
38
+ }
32
39
 
33
40
  /**
34
- * Record a completed exchange. Creates the exchanges directory if needed.
35
- * Appends one JSON line; uses O_APPEND so concurrent writes from separate
36
- * processes are safe (each line is a single write, POSIX O_APPEND atomic for
37
- * pipe-sized payloads).
41
+ * Record a completed exchange. Creates the ops directory if needed. Appends
42
+ * one JSON line via raw fs O_APPEND (not config.cjs's writeJson) so
43
+ * concurrent writes from separate processes stay safe — each line is a
44
+ * single write, POSIX O_APPEND atomic for pipe-sized payloads. Because this
45
+ * bypasses config.cjs's normal write boundary, the single-writer law is
46
+ * asserted explicitly here.
38
47
  *
39
48
  * @param {{ sessionId: string, cwd: string, prompt: string, result: string, promptId?: string }} opts
40
49
  * @returns {Promise<void>}
41
50
  */
42
51
  async function recordExchange({ sessionId, cwd, prompt, result, promptId }) {
43
- const encoded = encodeCwd(cwd);
44
- const filePath = path.join(EXCHANGES_DIR, `${encoded}.jsonl`);
45
-
46
- // Security: validate that the target path stays within home dir
47
- validatePath(EXCHANGES_DIR);
52
+ const filePath = exchangesFilePath(cwd);
53
+ assertOpsWrite(filePath, EXCHANGES_WRITER);
48
54
 
49
55
  // Summarize — always resolves; never throws
50
56
  const { summary, model, degraded } = await summarize(result);
@@ -63,7 +69,7 @@ async function recordExchange({ sessionId, cwd, prompt, result, promptId }) {
63
69
 
64
70
  const line = JSON.stringify(record) + '\n';
65
71
 
66
- await fsp.mkdir(EXCHANGES_DIR, { recursive: true });
72
+ await fsp.mkdir(path.dirname(filePath), { recursive: true });
67
73
  await fsp.appendFile(filePath, line, { encoding: 'utf8' });
68
74
  }
69
75
 
@@ -79,8 +85,7 @@ const DEFAULT_LIMIT = 100;
79
85
  * @returns {Promise<object[]>}
80
86
  */
81
87
  async function listExchanges({ cwd, sessionId, limit = DEFAULT_LIMIT, offset = 0 }) {
82
- const encoded = encodeCwd(cwd);
83
- const filePath = path.join(EXCHANGES_DIR, `${encoded}.jsonl`);
88
+ const filePath = exchangesFilePath(cwd);
84
89
 
85
90
  let stat;
86
91
  try { stat = await fsp.stat(filePath); } catch { return []; }
@@ -128,4 +133,4 @@ async function listExchanges({ cwd, sessionId, limit = DEFAULT_LIMIT, offset = 0
128
133
  return filtered.slice(offset, offset + limit);
129
134
  }
130
135
 
131
- module.exports = { recordExchange, listExchanges, EXCHANGES_DIR };
136
+ module.exports = { recordExchange, listExchanges };
@@ -23,7 +23,7 @@ const { computeStallSummary, computeDepHistorySatisfaction, FAILURE_STREAK_ESCAL
23
23
  const { findStarvedProjects, findUnresolvableDepRoots, DEFAULT_PROJECT_CWD, DEP_HISTORY_FAIL_OPEN } = require('./lib/schedulerBatch.cjs');
24
24
  const { auditLogPath, readTail } = require('./lib/auditLog.cjs');
25
25
  const { resolveBuildIdentity } = require('./lib/buildIdentity.cjs');
26
- const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
26
+ const { computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
27
27
  const { allProjectCwds } = require('./lib/activeSessions.cjs');
28
28
  const { scanEpicTranscripts } = require('./lib/epicTranscriptDiagnostic.cjs');
29
29
 
@@ -627,6 +627,50 @@ function evaluateBuildFreshness({ running = null, installedOnDisk = null, repoHe
627
627
  return { ok: true, running, installedOnDisk, repoHead, restartNeeded, publishNeeded, ...(notes.length ? { note: notes.join('; ') } : {}) };
628
628
  }
629
629
 
630
+ /**
631
+ * evaluateCredentialHealth({ credState, now }) → { component, issues }
632
+ *
633
+ * Pure over its inputs — `credState` is the shape lib/credentials.cjs's
634
+ * refreshIfNeeded()/readCredentials() return ({ kind, message?, expiredAt? }),
635
+ * injected so this is testable without touching the Keychain. Surfaces the ONE
636
+ * failure that silently voids the whole scheduler on a user's machine: the
637
+ * OAuth token is expired (or missing) so every headless `claude -p` job fails
638
+ * to authenticate. The scheduler already pauses on a live 'auth' poll
639
+ * (scheduler.cjs setPaused('auth')), but nothing told the OPERATOR why the
640
+ * queue went quiet — `npm run health` read GREEN while no job could run. This
641
+ * names the cause and the fix (`claude login`).
642
+ *
643
+ * NON-CRITICAL by design: reported in components.credentials + issues so
644
+ * /local-project-health shows it, but it does not flip status.ok — health must
645
+ * not hard-fail a repo checkout just because THIS machine's token lapsed, and
646
+ * an expired token is an operator action (re-login), not a code defect. Maps:
647
+ * kind 'auth' → not ok, actionable `claude login` message (the real block)
648
+ * kind 'config' → not ok, credentials unreadable/absent
649
+ * kind 'ok'/'unsupported' → ok (token usable, or too-fresh-to-refresh)
650
+ */
651
+ function evaluateCredentialHealth({ credState, now = Date.now() } = {}) {
652
+ const issues = [];
653
+ if (!credState || typeof credState !== 'object') {
654
+ return { component: { ok: true, state: 'unknown' }, issues };
655
+ }
656
+ const { kind } = credState;
657
+ if (kind === 'auth') {
658
+ const expiredAt = typeof credState.expiredAt === 'number' ? credState.expiredAt : null;
659
+ const agoMin = expiredAt ? Math.max(0, Math.round((now - expiredAt) / 60_000)) : null;
660
+ const when = expiredAt ? new Date(expiredAt).toISOString() : 'unknown';
661
+ const msg = `Claude credentials expired${agoMin !== null ? ` (${agoMin}m ago, at ${when})` : ''} — headless \`claude -p\` scheduler jobs cannot authenticate. Run \`claude login\` in a terminal to refresh.`;
662
+ issues.push(msg);
663
+ return { component: { ok: false, state: 'expired', expiredAt, message: msg }, issues };
664
+ }
665
+ if (kind === 'config') {
666
+ const msg = `Claude credentials unreadable: ${credState.message ?? 'unknown'} — scheduler jobs cannot authenticate. Run \`claude login\`.`;
667
+ issues.push(msg);
668
+ return { component: { ok: false, state: 'unreadable', message: msg }, issues };
669
+ }
670
+ // 'ok' (fresh) or 'unsupported' (auto-refresh unavailable but token still valid)
671
+ return { component: { ok: true, state: kind === 'unsupported' ? 'valid-no-refresh' : 'valid' }, issues };
672
+ }
673
+
630
674
  /**
631
675
  * evaluateStarveEscalationHealth(jobs, now, thresholdMs, escalationReasons)
632
676
  * → { ok, projects?, message? }
@@ -1138,15 +1182,16 @@ async function check(opts = {}) {
1138
1182
  // `config`, same home as every other scheduler machine setting.
1139
1183
  try {
1140
1184
  const retentionCfg = queueState?.config?.schedulerRunLogRetention;
1141
- const liveKeys = liveKeysFromJobs(queueState?.jobs || [], { runsDir: DEFAULT_RUNS_DIR });
1185
+ const runsDir = schedulerPaths.runsDir();
1186
+ const liveKeys = liveKeysFromJobs(queueState?.jobs || [], { runsDir });
1142
1187
  const report = computeReport(
1143
- DEFAULT_RUNS_DIR,
1188
+ runsDir,
1144
1189
  retentionCfg?.policy || {},
1145
1190
  { liveKeys }
1146
1191
  );
1147
1192
  status.components.run_log_retention = {
1148
1193
  ok: true,
1149
- path: DEFAULT_RUNS_DIR,
1194
+ path: runsDir,
1150
1195
  totalBytes: report.usage.totalBytes,
1151
1196
  dirCount: report.usage.dirCount,
1152
1197
  runCount: report.usage.runCount,
@@ -1205,6 +1250,22 @@ async function check(opts = {}) {
1205
1250
  status.components.build = { ok: true, error: e.message };
1206
1251
  }
1207
1252
 
1253
+ // 6.8. Credential health (informational-but-loud): is the Claude OAuth token
1254
+ // usable? An expired/absent token silently voids every headless `claude -p`
1255
+ // job — the queue "runs" but nothing authenticates. refreshIfNeeded() reads
1256
+ // the Keychain/file and (post futile-refresh fix) never spawns a doomed CLI
1257
+ // fallback for an expired-no-refresh-token creds, so this is a cheap read.
1258
+ // Non-critical: names the cause + `claude login` fix without flipping ok.
1259
+ try {
1260
+ const { refreshIfNeeded } = require('./lib/credentials.cjs');
1261
+ const credState = await refreshIfNeeded();
1262
+ const { component, issues } = evaluateCredentialHealth({ credState, now: Date.now() });
1263
+ status.components.credentials = component;
1264
+ for (const i of issues) status.issues.push(i);
1265
+ } catch (e) {
1266
+ status.components.credentials = { ok: true, state: 'unknown', error: e.message };
1267
+ }
1268
+
1208
1269
  // 7. Summary scoring: ok if all critical components pass.
1209
1270
  // Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
1210
1271
  // Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
@@ -1231,6 +1292,7 @@ module.exports = {
1231
1292
  readFreshHeartbeat,
1232
1293
  evaluatePrdMigrationHealth,
1233
1294
  evaluateDelegationChainHealth,
1295
+ evaluateCredentialHealth,
1234
1296
  computeEpicIndexDrift,
1235
1297
  evaluateEpicIndexHealth,
1236
1298
  computeProjectProblemCounts,
@@ -1027,7 +1027,7 @@ ipcMain.handle('exchanges:list', validated(schemas.exchangesList, async (payload
1027
1027
  }
1028
1028
  }));
1029
1029
 
1030
- // OTEL telemetry export (opt-in via ~/.config/session-manager/otel.json).
1030
+ // OTEL telemetry export (opt-in via ~/.claude/session-manager/otel.json).
1031
1031
  ipcMain.handle('otel:get-config', async () => otelSettings.load());
1032
1032
  ipcMain.handle('otel:set-config', async (_e, cfg) => {
1033
1033
  if (!otelSettings.isValid(cfg)) {
@@ -632,10 +632,14 @@ const memoryStale = z.object({
632
632
  // the encoded cwd slug (memoryAggregate.cjs reads directly from
633
633
  // ~/.claude/projects/<workspace>/memory/), same regex as the other memory:*
634
634
  // handlers. `refresh: true` is the cost gate that fires the single claude -p
635
- // clustering pass; falsy returns the cached result.
635
+ // clustering pass; falsy returns the cached result. `cwd` (PRD 1389,
636
+ // optional — same shape as memory:stale's) addresses the per-project cache
637
+ // at <cwd>/session-manager-operations/memory-clusters/clusters.json;
638
+ // absent/invalid cwd just disables caching for that call.
636
639
  const memoryAggregate = z.object({
637
640
  workspace: z.string().regex(MEMORY_WORKSPACE_RE),
638
641
  refresh: z.boolean().optional(),
642
+ cwd: z.string().max(4096).optional(),
639
643
  }).strict();
640
644
 
641
645
  // ──────────────────────────────────────────── Project Brief (PRD 837)
@@ -35,7 +35,7 @@ async function mkHome() {
35
35
  process.env.HOME = dir;
36
36
  // Explicit opt-in: overrides the test-environment no-op guard so this
37
37
  // suite's real telemetryClient calls actually persist into an isolated dir.
38
- process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.config', 'session-manager');
38
+ process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.claude', 'session-manager');
39
39
  return dir;
40
40
  }
41
41