claude-code-session-manager 0.95.0 → 0.97.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 (114) hide show
  1. package/dist/assets/{DataModel-DZK9QZpV.js → DataModel-D93FoaOm.js} +1 -1
  2. package/dist/assets/History-CXxxqNP1.js +2 -0
  3. package/dist/assets/{Hooks-CmK6l0uf.js → Hooks-CzeYFQjq.js} +3 -3
  4. package/dist/assets/{HostBilko-D8TIx-Bx.js → HostBilko-nESrTRIg.js} +1 -1
  5. package/dist/assets/{Library-DTxpOjx8.js → Library-D2zPkwOC.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-BPJyrevq.js → MarkdownEditor-DbinP78A.js} +1 -1
  7. package/dist/assets/{McpServers-B_rvIYDs.js → McpServers-Bu_sVGLr.js} +2 -2
  8. package/dist/assets/Memory-BgiI6LZ_.js +8 -0
  9. package/dist/assets/{Permissions-DhMMp27n.js → Permissions-C1MMzZRm.js} +3 -3
  10. package/dist/assets/Plugins-Dhu6wAZi.js +2 -0
  11. package/dist/assets/{ProvenanceBadge-ptlItdCK.js → ProvenanceBadge-C3AcWKMG.js} +1 -1
  12. package/dist/assets/{SaveBar-CSbNSl2W.js → SaveBar-plwYm0iO.js} +1 -1
  13. package/dist/assets/Scheduler-BVYTeh39.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-BJof-GWH.js → ScopeSwitcher-DFy0vv_o.js} +1 -1
  15. package/dist/assets/Settings-DIhGgdfs.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-CGl4B89-.js → SkillReferenceGraph-DsI7hRPB.js} +1 -1
  17. package/dist/assets/Skills-3E748UfB.js +3 -0
  18. package/dist/assets/{SystemPrompt-DKj-q39J.js → SystemPrompt-BqmfUJOd.js} +1 -1
  19. package/dist/assets/TagLibrary-CPz6fO8Q.js +1 -0
  20. package/dist/assets/{TiptapBody-BbcJL9az.js → TiptapBody-D9EoBQ1P.js} +1 -1
  21. package/dist/assets/{Toggle-DAslvnnv.js → Toggle-BHAmZNDn.js} +1 -1
  22. package/dist/assets/index-tn5JIVPj.js +3079 -0
  23. package/dist/assets/{settingsSchema-pzp0TgvN.js → settingsSchema-Crqy_aNz.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 +90 -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__/ipcSchemas-dependsOn.test.cjs +39 -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 +125 -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-shard-quarantine.test.cjs +6 -1
  56. package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +110 -0
  57. package/src/main/__tests__/seedAgentPersonas.test.cjs +19 -1
  58. package/src/main/__tests__/seedValidatorPersona.test.cjs +116 -0
  59. package/src/main/__tests__/telemetryClient.test.cjs +2 -2
  60. package/src/main/__tests__/telemetryContract.test.cjs +2 -2
  61. package/src/main/__tests__/usageSingleFlight.test.cjs +10 -0
  62. package/src/main/__tests__/validationSentinels.test.cjs +84 -0
  63. package/src/main/build-info.json +4 -4
  64. package/src/main/config.cjs +26 -14
  65. package/src/main/exchanges.cjs +26 -21
  66. package/src/main/health.cjs +66 -4
  67. package/src/main/index.cjs +1 -1
  68. package/src/main/ipcSchemas.cjs +15 -7
  69. package/src/main/lib/__tests__/crashTelemetry.test.cjs +1 -1
  70. package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +115 -0
  71. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +85 -27
  72. package/src/main/lib/__tests__/gitWorktree.test.cjs +21 -27
  73. package/src/main/lib/__tests__/guardShims.test.cjs +10 -3
  74. package/src/main/lib/__tests__/loadGate.test.cjs +44 -1
  75. package/src/main/lib/__tests__/opsOwnership.test.cjs +14 -0
  76. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +4 -4
  77. package/src/main/lib/__tests__/telemetryConsent.test.cjs +2 -2
  78. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +1 -1
  79. package/src/main/lib/agentModelResolve.cjs +1 -0
  80. package/src/main/lib/credentials.cjs +62 -0
  81. package/src/main/lib/cwdClassify.cjs +15 -0
  82. package/src/main/lib/definitionOfDone.cjs +9 -13
  83. package/src/main/lib/effectiveModelInfo.cjs +2 -9
  84. package/src/main/lib/epicWorktreeProjectConfig.cjs +75 -31
  85. package/src/main/lib/loadGate.cjs +29 -5
  86. package/src/main/lib/opsOwnership.cjs +23 -0
  87. package/src/main/lib/planValidator.cjs +80 -0
  88. package/src/main/lib/prdCreate.cjs +8 -4
  89. package/src/main/lib/prdSizing.cjs +83 -0
  90. package/src/main/lib/runLogRetention.cjs +2 -8
  91. package/src/main/lib/telemetryBacklog.cjs +9 -3
  92. package/src/main/lib/telemetryClient.cjs +12 -3
  93. package/src/main/lib/telemetrySettings.cjs +8 -2
  94. package/src/main/lib/validationSentinels.cjs +29 -0
  95. package/src/main/memoryAggregate.cjs +36 -11
  96. package/src/main/otelSettings.cjs +8 -2
  97. package/src/main/scheduler.cjs +215 -31
  98. package/src/main/seedAgentPersonas.cjs +1 -1
  99. package/src/main/sessionsStore.cjs +8 -2
  100. package/src/main/supervisor.cjs +3 -3
  101. package/src/main/templates/PRD_AUTHORING.md +9 -9
  102. package/src/main/voiceSettings.cjs +8 -2
  103. package/src/preload/api.d.ts +3 -3
  104. package/src/preload/index.cjs +5 -5
  105. package/src/seed/agents/dev-lead.md +24 -15
  106. package/src/seed/agents/validator.md +33 -0
  107. package/dist/assets/History-BDyJUGtR.js +0 -2
  108. package/dist/assets/Memory-B-9GlHqe.js +0 -8
  109. package/dist/assets/Plugins-DKfAlmMY.js +0 -2
  110. package/dist/assets/Scheduler--rn1QBYc.js +0 -16
  111. package/dist/assets/Settings-Dsx6XZP5.js +0 -3
  112. package/dist/assets/Skills-D522Zfol.js +0 -3
  113. package/dist/assets/TagLibrary-BH7CG7-6.js +0 -1
  114. package/dist/assets/index-Yf8e7VCB.js +0 -3079
@@ -0,0 +1,110 @@
1
+ /**
2
+ * schedulerStateSidecarRestore.test.cjs — covers PRD 1392: scheduler-state.json
3
+ * previously left lastPollOk, lastFailureKind, backoffNextAt, cachedUtilization and
4
+ * cachedBindingWindowName memory-only, so a fresh boot showed misleading footer/KPI
5
+ * placeholders (a healthy poller reading "failed", a still-rate-limited state
6
+ * un-showable, utilization stuck at "—") until the next post-boot billing poll
7
+ * overwrote them. Proves loadSchedulerState() restores all five from disk instead of
8
+ * leaving the hardcoded defaults (false / null / null / null / null) in place.
9
+ *
10
+ * scheduler.cjs is a plain CJS require (like rateLimitPollerStreak.test.cjs's), so its
11
+ * module-level `let`s are a singleton for this test file's process — the first test
12
+ * below captures the pre-load snapshot itself (the hardcoded defaults) rather than
13
+ * assuming a fresh module per test, so the proof doesn't depend on test ordering.
14
+ *
15
+ * Run: timeout 120 npx vitest run src/main/__tests__/schedulerStateSidecarRestore.test.cjs
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ import { test, expect, beforeEach } from 'vitest';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ const { loadSchedulerState, persistSchedulerState, getSchedulerStateSnapshot } = require('../scheduler.cjs');
25
+ const schedulerPaths = require('../lib/schedulerPaths.cjs');
26
+
27
+ function writeSidecar(data) {
28
+ fs.mkdirSync(path.dirname(schedulerPaths.schedulerStatePath()), { recursive: true });
29
+ fs.writeFileSync(schedulerPaths.schedulerStatePath(), JSON.stringify(data));
30
+ }
31
+
32
+ beforeEach(() => {
33
+ try { fs.unlinkSync(schedulerPaths.schedulerStatePath()); } catch { /* no sidecar yet */ }
34
+ });
35
+
36
+ test('loadSchedulerState restores lastPollOk/lastFailureKind/backoffNextAt/cachedUtilization/cachedBindingWindowName from the sidecar, not the hardcoded defaults', () => {
37
+ // Before a restore, the module starts (or was left, by whatever ran before this test
38
+ // in-process) at whatever it was — capture it so the assertion below is a genuine
39
+ // "changed to match the file" proof, not an assumption about fresh-module defaults.
40
+ const before = getSchedulerStateSnapshot();
41
+
42
+ const written = {
43
+ version: 1,
44
+ lastPollOk: true,
45
+ lastFailureKind: 'meter_rate_limited',
46
+ backoffNextAt: 1_726_000_000_000,
47
+ cachedUtilization: 42,
48
+ cachedBindingWindowName: 'five_hour',
49
+ };
50
+ writeSidecar(written);
51
+
52
+ // Simulate a restart: a fresh process starts from the hardcoded module defaults
53
+ // (false / null / null / null / null) until loadSchedulerState() runs.
54
+ loadSchedulerState();
55
+
56
+ const after = getSchedulerStateSnapshot();
57
+ expect(after).toEqual({
58
+ lastPollOk: true,
59
+ lastFailureKind: 'meter_rate_limited',
60
+ backoffNextAt: 1_726_000_000_000,
61
+ cachedUtilization: 42,
62
+ cachedBindingWindowName: 'five_hour',
63
+ });
64
+ // The restore must have actually changed something relative to the pre-load
65
+ // snapshot — proves this run exercised the restore path, not a no-op.
66
+ expect(after).not.toEqual(before);
67
+ });
68
+
69
+ test('persistSchedulerState writes the same five fields back out, round-tripping through the sidecar', () => {
70
+ writeSidecar({
71
+ version: 1,
72
+ lastPollOk: false,
73
+ lastFailureKind: 'auth',
74
+ backoffNextAt: 1_726_100_000_000,
75
+ cachedUtilization: 87.5,
76
+ cachedBindingWindowName: 'weekly_all',
77
+ });
78
+ loadSchedulerState();
79
+
80
+ persistSchedulerState();
81
+
82
+ const onDisk = JSON.parse(fs.readFileSync(schedulerPaths.schedulerStatePath(), 'utf8'));
83
+ expect(onDisk.lastPollOk).toBe(false);
84
+ expect(onDisk.lastFailureKind).toBe('auth');
85
+ expect(onDisk.backoffNextAt).toBe(1_726_100_000_000);
86
+ expect(onDisk.cachedUtilization).toBe(87.5);
87
+ expect(onDisk.cachedBindingWindowName).toBe('weekly_all');
88
+ });
89
+
90
+ test('loadSchedulerState against a sidecar missing the five fields leaves the in-memory values unchanged (pre-PRD-1392 file)', () => {
91
+ // Establish a known non-default in-memory state first.
92
+ writeSidecar({
93
+ version: 1,
94
+ lastPollOk: true,
95
+ lastFailureKind: 'transient',
96
+ backoffNextAt: 5000,
97
+ cachedUtilization: 10,
98
+ cachedBindingWindowName: 'seven_day',
99
+ });
100
+ loadSchedulerState();
101
+ const established = getSchedulerStateSnapshot();
102
+
103
+ // A sidecar written before PRD 1392 (or first-boot with only the older fields) has
104
+ // none of the five new keys — loadSchedulerState's per-field `typeof` guards must
105
+ // leave whatever was already in memory alone, not stomp it back to a hardcoded default.
106
+ writeSidecar({ version: 1, lastPollAt: 123 });
107
+ loadSchedulerState();
108
+
109
+ expect(getSchedulerStateSnapshot()).toEqual(established);
110
+ });
@@ -54,7 +54,7 @@ function silentLogger() {
54
54
  return { log: () => {}, warn: () => {} };
55
55
  }
56
56
 
57
- const ALL_PERSONAS = ['architect', 'dev-lead', 'project-home-builder'];
57
+ const ALL_PERSONAS = ['architect', 'dev-lead', 'project-home-builder', 'validator'];
58
58
 
59
59
  test('fresh seed writes every persona file', async () => {
60
60
  await seedAgentPersonas({ logger: silentLogger() });
@@ -156,6 +156,24 @@ test('the bundled project-home-builder persona contains no session-manager-repo
156
156
  }
157
157
  });
158
158
 
159
+ test('seeded dev-lead persona pins an executor-tier model', () => {
160
+ const { splitFrontmatter } = require('../lib/prdFrontmatter.cjs');
161
+ const raw = fs.readFileSync(path.join(__dirname, '..', '..', 'seed', 'agents', 'dev-lead.md'), 'utf8');
162
+ const { fm } = splitFrontmatter(raw);
163
+ expect(fm.model).toBe('sonnet');
164
+ });
165
+
166
+ test('seeded dev-lead body fits AGENT_BODY_CHAR_CAP and carries the run contract', () => {
167
+ const { splitFrontmatter } = require('../lib/prdFrontmatter.cjs');
168
+ const { AGENT_BODY_CHAR_CAP } = require('../lib/agentModelResolve.cjs');
169
+ const raw = fs.readFileSync(path.join(__dirname, '..', '..', 'seed', 'agents', 'dev-lead.md'), 'utf8');
170
+ const { body } = splitFrontmatter(raw);
171
+ const trimmed = body.trim();
172
+ expect(trimmed.length).toBeLessThan(AGENT_BODY_CHAR_CAP);
173
+ expect(trimmed).toContain('## Run contract');
174
+ expect(trimmed).toContain('SCHEDULER_VERDICT: PASS');
175
+ });
176
+
159
177
  test('SM_SEED_AGENT_PERSONAS_DISABLE=1 short-circuits', async () => {
160
178
  process.env.SM_SEED_AGENT_PERSONAS_DISABLE = '1';
161
179
 
@@ -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.97.0",
3
+ "gitSha": "c68cf49f55d9fda43114a70f1fb21142979cce11",
4
+ "gitShortSha": "c68cf49f",
5
5
  "gitBranch": "HEAD",
6
- "builtAt": "2026-09-21T22:01:57.972Z",
6
+ "builtAt": "2026-09-25T03:03:56.111Z",
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 };