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,100 @@
1
+ /**
2
+ * guard-prd-writes-policy.cjs — decision logic for
3
+ * scripts/hooks/guard-prd-writes.cjs, split out so it can be `require()`d
4
+ * in-process (by tests, notably) with no CLI-level side effects.
5
+ *
6
+ * guard-prd-writes.cjs itself runs its CLI as a require-time side effect
7
+ * (stdin -> decision -> process.exit, not gated on `require.main === module`
8
+ * — see its header for why). Requiring THIS module never reads stdin or
9
+ * calls `process.exit`. `decide()` is fully I/O-free — it only does path
10
+ * arithmetic on `payload` — so calling it directly from a test is equivalent
11
+ * to the real CLI decision, without a child-process spawn per case.
12
+ */
13
+ 'use strict';
14
+
15
+ const path = require('node:path');
16
+
17
+ const SCHEDULER_SEGMENT = `${path.sep}session-manager-operations${path.sep}scheduler${path.sep}`;
18
+ const FIX_PLAN_RE = /^\d+-fix-.*\.md$/;
19
+
20
+ /** Extract the single filesystem path a Write/Edit/NotebookEdit call targets. */
21
+ function targetPathFor(toolName, toolInput) {
22
+ if (!toolInput || typeof toolInput !== 'object') return null;
23
+ if (toolName === 'NotebookEdit') return toolInput.notebook_path ?? null;
24
+ return toolInput.file_path ?? null;
25
+ }
26
+
27
+ /** MCP tool name to point the agent at, based on the attempted operation. */
28
+ function suggestedToolFor(toolName, absPath) {
29
+ const isArchived = absPath.includes(`${path.sep}prds-archived${path.sep}`);
30
+ if (isArchived) return 'mcp__session-manager-scheduler__scheduler_archive_prd';
31
+ // Write = the file doesn't exist yet from the agent's point of view (a
32
+ // brand-new PRD); Edit/NotebookEdit = mutating an existing file in place.
33
+ if (toolName === 'Write') return 'mcp__session-manager-scheduler__scheduler_create_prd';
34
+ return 'mcp__session-manager-scheduler__scheduler_update_prd';
35
+ }
36
+
37
+ function buildAllow() {
38
+ return { continue: true };
39
+ }
40
+
41
+ function buildDeny(reason) {
42
+ return {
43
+ continue: true,
44
+ decision: 'block',
45
+ reason,
46
+ hookSpecificOutput: {
47
+ hookEventName: 'PreToolUse',
48
+ permissionDecision: 'deny',
49
+ permissionDecisionReason: reason,
50
+ },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Decision function: PreToolUse payload in, hook-response object out. No
56
+ * stdin/stdout and no `process.exit` — safe to call in-process (e.g. from
57
+ * tests), and fully I/O-free.
58
+ */
59
+ function decide(payload) {
60
+ try {
61
+ const toolName = payload.tool_name;
62
+ if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'NotebookEdit') {
63
+ return buildAllow();
64
+ }
65
+
66
+ const rawPath = targetPathFor(toolName, payload.tool_input);
67
+ if (!rawPath || typeof rawPath !== 'string') return buildAllow();
68
+
69
+ const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
70
+ const absPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
71
+
72
+ if (!absPath.includes(SCHEDULER_SEGMENT)) return buildAllow();
73
+
74
+ const base = path.basename(absPath);
75
+ if (FIX_PLAN_RE.test(base)) return buildAllow();
76
+
77
+ const suggested = suggestedToolFor(toolName, absPath);
78
+ const reason = [
79
+ `Direct ${toolName} to a session-manager-operations/scheduler/ path is blocked.`,
80
+ `PRDs must be authored through the scheduler API, not a raw file write — use the "${suggested}" MCP tool instead.`,
81
+ 'If the Session Manager app is not running (the admin API this MCP tool talks to only exists while it is), the degraded fallback documented in the develop skill applies: hand-write the file as a last resort and say so visibly in your report.',
82
+ ].join(' ');
83
+ return buildDeny(reason);
84
+ } catch (e) {
85
+ console.error(`[guard-prd-writes] internal error, failing open: ${e?.message}`);
86
+ return buildAllow();
87
+ }
88
+ }
89
+
90
+ /** Mirrors the CLI's stdin-JSON handling so it can be exercised without a spawn. */
91
+ function parsePayload(raw) {
92
+ try {
93
+ return raw ? JSON.parse(raw) : {};
94
+ } catch (e) {
95
+ console.error(`[guard-prd-writes] malformed stdin JSON, failing open: ${e?.message}`);
96
+ return {};
97
+ }
98
+ }
99
+
100
+ module.exports = { decide, parsePayload };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * guard-self-schedule-policy.cjs — decision logic for
3
+ * scripts/hooks/guard-self-schedule.cjs, split out so it can be `require()`d
4
+ * in-process (by tests, notably) with no CLI-level side effects.
5
+ *
6
+ * guard-self-schedule.cjs itself runs its CLI as a require-time side effect
7
+ * (stdin -> decision -> process.exit, not gated on `require.main === module`
8
+ * — see its header for why). Requiring THIS module never reads stdin or
9
+ * calls `process.exit`. `decide()` is fully I/O-free — it only reads
10
+ * `payload` and `process.env.SM_SCHEDULER_JOB_SLUG` — so calling it directly
11
+ * from a test is equivalent to the real CLI decision, without a child-process
12
+ * spawn per case.
13
+ */
14
+ 'use strict';
15
+
16
+ const ALWAYS_DENY_TOOLS = new Set(['ScheduleWakeup', 'CronCreate']);
17
+ const BACKGROUNDABLE_AGENT_TOOLS = new Set(['Task', 'Agent']);
18
+
19
+ const STANDARDS_QUOTE = 'You ARE the executor — never re-queue or self-schedule.';
20
+ const STANDARDS_CITE = 'plugins/session-manager-dev/skills/develop/standards.md:85 (Execution discipline)';
21
+
22
+ function buildAllow() {
23
+ return { continue: true };
24
+ }
25
+
26
+ function buildDeny(reason) {
27
+ return {
28
+ continue: true,
29
+ decision: 'block',
30
+ reason,
31
+ hookSpecificOutput: {
32
+ hookEventName: 'PreToolUse',
33
+ permissionDecision: 'deny',
34
+ permissionDecisionReason: reason,
35
+ },
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Decision function: PreToolUse payload in, hook-response object out. No
41
+ * stdin/stdout and no `process.exit` — safe to call in-process (e.g. from
42
+ * tests). Reads `process.env.SM_SCHEDULER_JOB_SLUG` at call time, not at
43
+ * module-load time, so a test can set/restore it around each call.
44
+ */
45
+ function decide(payload) {
46
+ try {
47
+ const jobSlug = process.env.SM_SCHEDULER_JOB_SLUG;
48
+ if (!jobSlug) return buildAllow(); // interactive session — this guard is inert here
49
+
50
+ const toolName = payload.tool_name;
51
+
52
+ if (ALWAYS_DENY_TOOLS.has(toolName)) {
53
+ const reason = [
54
+ `Blocked: \`${toolName}\` from inside a headless scheduler-spawned run (job '${jobSlug}').`,
55
+ `${STANDARDS_CITE}: "${STANDARDS_QUOTE}"`,
56
+ 'A headless run has no next turn — nothing will ever deliver this wakeup, so the job exits with its work stranded and parks in needs_review even if it was already correct.',
57
+ 'Do it now instead: commit what you have, then run any required review synchronously inline (call the reviewer and read its result in this same turn) before the finish protocol — never schedule it for later.',
58
+ ].join(' ');
59
+ return buildDeny(reason);
60
+ }
61
+
62
+ if (BACKGROUNDABLE_AGENT_TOOLS.has(toolName) && payload.tool_input && payload.tool_input.run_in_background) {
63
+ const reason = [
64
+ `Blocked: a backgrounded ${toolName} call (run_in_background: true) from inside a headless scheduler-spawned run (job '${jobSlug}').`,
65
+ `${STANDARDS_CITE}: "${STANDARDS_QUOTE}"`,
66
+ 'A headless run has no later turn to collect a background agent\'s result, so waiting on it (ListAgents, Monitor, ScheduleWakeup) strands the job in needs_review with its work already done but uncommitted.',
67
+ 'Call it synchronously instead — omit run_in_background (or set it to false) and read its result in this same turn, before the finish protocol.',
68
+ ].join(' ');
69
+ return buildDeny(reason);
70
+ }
71
+
72
+ return buildAllow();
73
+ } catch (e) {
74
+ console.error(`[guard-self-schedule] internal error, failing open: ${e?.message}`);
75
+ return buildAllow();
76
+ }
77
+ }
78
+
79
+ /** Mirrors the CLI's stdin-JSON handling so it can be exercised without a spawn. */
80
+ function parsePayload(raw) {
81
+ try {
82
+ return raw ? JSON.parse(raw) : {};
83
+ } catch (e) {
84
+ console.error(`[guard-self-schedule] malformed stdin JSON, failing open: ${e?.message}`);
85
+ return {};
86
+ }
87
+ }
88
+
89
+ module.exports = { decide, parsePayload };
@@ -514,7 +514,14 @@ async function handleCallTool(request) {
514
514
  const note = result?.ok !== false
515
515
  ? ' — PRD file written; the queue row is derived on the next scheduler reconcile pass, not by this call.'
516
516
  : '';
517
- return { content: [{ type: 'text', text: JSON.stringify(result) + note }] };
517
+ let text = JSON.stringify(result) + note;
518
+ // Sizing warnings (PRD 1403) are advisory only — never block the write,
519
+ // just surfaced here so the authoring session can decide to split the
520
+ // PRD before confirming it. See prdSizing.cjs.
521
+ if (Array.isArray(result?.warnings) && result.warnings.length > 0) {
522
+ text += `\nSizing warnings:\n${result.warnings.map((w) => `- ${w}`).join('\n')}`;
523
+ }
524
+ return { content: [{ type: 'text', text }] };
518
525
  }
519
526
  if (name === 'feedback_list_projects') {
520
527
  const result = await adminRequest('GET', '/admin/feedback/targets');
@@ -0,0 +1,59 @@
1
+ /**
2
+ * configWriteBoundaryOwners.test.cjs — validateWrite's ops-subtree exemption
3
+ * (config.cjs ~line 166) must derive its writable-namespace list from
4
+ * opsOwnership.cjs's OWNERS map, not a separately hand-maintained array. PRD
5
+ * 1398 declared 'ui-prefs' owned in OWNERS but config.cjs's own array was
6
+ * never updated, so every real ui-prefs write threw "Write outside allowed
7
+ * write boundaries" before assertOpsWrite's per-writer check ever ran (PRD
8
+ * 1410). This test exercises the REAL validateWrite (via writeJson) for
9
+ * every OWNERS namespace so that class of drift can never reoccur silently.
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/__tests__/configWriteBoundaryOwners.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect, afterEach } from 'vitest';
17
+ const fs = require('node:fs');
18
+ const fsp = require('node:fs/promises');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+ const config = require('../config.cjs');
22
+ const { OWNERS } = require('../lib/opsOwnership.cjs');
23
+
24
+ // Real-homedir-rooted (not os.tmpdir()) so the fixture root is classified as
25
+ // a plain project, not an ephemeral cwd — opsPath() throws for roots under
26
+ // os.tmpdir(), which would make every namespace's exemption silently
27
+ // evaporate (opsSubs = []) and the test would pass for the wrong reason. Same
28
+ // pattern as prdCreate.test.cjs's real-homedir-rooted tests.
29
+ const roots = [];
30
+ afterEach(async () => {
31
+ while (roots.length) {
32
+ const r = roots.pop();
33
+ await fsp.rm(r, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ async function mkProjectRoot(tag) {
38
+ const root = await fsp.mkdtemp(path.join(os.homedir(), `.sm-configwriteboundary-${tag}-`));
39
+ config.addAllowedRoot(root);
40
+ roots.push(root);
41
+ return root;
42
+ }
43
+
44
+ for (const [namespace, owner] of Object.entries(OWNERS)) {
45
+ test(`OWNERS namespace '${namespace}' is writable by its declared owner '${owner}'`, async () => {
46
+ const root = await mkProjectRoot(namespace);
47
+ const target = path.join(root, 'session-manager-operations', namespace, 'probe.json');
48
+ const result = await config.writeJson(target, { probe: 1 }, { writer: owner });
49
+ expect(result.ok).toBe(true);
50
+ expect(fs.existsSync(target)).toBe(true);
51
+ });
52
+ }
53
+
54
+ test('a namespace not declared in OWNERS is still rejected', async () => {
55
+ const root = await mkProjectRoot('undeclared');
56
+ const target = path.join(root, 'session-manager-operations', 'not-a-real-namespace', 'probe.json');
57
+ await expect(config.writeJson(target, { probe: 1 }, { writer: 'anyone' }))
58
+ .rejects.toThrow(/Write outside allowed write boundaries/);
59
+ });
@@ -3,84 +3,61 @@
3
3
  /**
4
4
  * Unit test for exchanges.cjs.
5
5
  *
6
- * Runs against a tmp HOME so no real files are touched.
6
+ * PRD 1391: exchanges now write to <cwd>/session-manager-operations/
7
+ * prompt-sessions/exchanges.jsonl instead of the old global
8
+ * ~/.claude/knowledge-log/exchanges/<encodeCwd(cwd)>.jsonl store — each test
9
+ * uses a fresh tmp directory as its project `cwd` so no real project state is
10
+ * touched.
11
+ *
7
12
  * Stubs summarize.cjs to avoid any network calls.
8
13
  *
9
14
  * Run: timeout 300 npx vitest run src/main/__tests__/exchanges.test.cjs
10
15
  */
11
16
 
12
- import { test, describe, beforeAll as before, afterAll as after } from 'vitest';
17
+ import { test, describe, beforeEach } from 'vitest';
13
18
  const assert = require('node:assert/strict');
14
19
  const fs = require('node:fs');
15
20
  const fsp = require('node:fs/promises');
16
21
  const path = require('node:path');
17
22
  const os = require('node:os');
18
23
 
19
- // ─── Tmp HOME setup ─────────────────────────────────────────────────────────
20
-
21
- let tmpHome;
22
- let origHome;
23
-
24
- before(() => {
25
- tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'exchanges-test-'));
26
- origHome = process.env.HOME;
27
- // node:os.homedir() reads HOME on Linux
28
- process.env.HOME = tmpHome;
29
- });
30
-
31
- after(async () => {
32
- process.env.HOME = origHome;
33
- await fsp.rm(tmpHome, { recursive: true, force: true });
34
- });
24
+ function mkTmpProject() {
25
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'exchanges-test-'));
26
+ }
35
27
 
36
- // ─── Module loading under tmp HOME ──────────────────────────────────────────
28
+ function exchangesLogPath(cwd) {
29
+ return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'exchanges.jsonl');
30
+ }
37
31
 
38
- // Clear module cache so encodeCwd / config / exchanges pick up the new HOME
39
- function freshRequire(mod) {
40
- delete require.cache[require.resolve(mod)];
41
- return require(mod);
32
+ function stubSummarize(fn) {
33
+ const summarizePath = require.resolve('../lib/summarize.cjs');
34
+ delete require.cache[summarizePath];
35
+ require.cache[summarizePath] = {
36
+ id: summarizePath,
37
+ filename: summarizePath,
38
+ loaded: true,
39
+ exports: { summarize: fn },
40
+ };
42
41
  }
43
42
 
44
- // ─── Tests ───────────────────────────────────────────────────────────────────
43
+ beforeEach(() => {
44
+ delete require.cache[require.resolve('../exchanges.cjs')];
45
+ });
45
46
 
46
47
  describe('recordExchange', () => {
47
48
  test('writes a complete record when summarize succeeds', async () => {
48
- // Stub summarize to return a fixed summary without a network call
49
- const summarizePath = require.resolve('../lib/summarize.cjs');
50
- delete require.cache[summarizePath];
51
- require.cache[summarizePath] = {
52
- id: summarizePath,
53
- filename: summarizePath,
54
- loaded: true,
55
- exports: {
56
- summarize: async () => ({ summary: 'Test summary.', model: 'claude-haiku-4-5' }),
57
- },
58
- };
59
-
60
- // Clear exchanges so it picks up the stub
61
- const exchangesPath = require.resolve('../exchanges.cjs');
62
- delete require.cache[exchangesPath];
63
- // Also clear config so it re-reads the new HOME
64
- const configPath = require.resolve('../config.cjs');
65
- delete require.cache[configPath];
66
- const encodeCwdPath = require.resolve('../lib/encodeCwd.cjs');
67
- delete require.cache[encodeCwdPath];
68
-
69
- const { recordExchange, EXCHANGES_DIR } = require('../exchanges.cjs');
49
+ stubSummarize(async () => ({ summary: 'Test summary.', model: 'claude-haiku-4-5' }));
50
+
51
+ const { recordExchange } = require('../exchanges.cjs');
70
52
 
53
+ const cwd = mkTmpProject();
71
54
  const sessionId = 'sess-abc-123';
72
- const cwd = '/home/user/myproject';
73
55
  const prompt = 'What is 2+2?';
74
56
  const result = 'The answer is 4.';
75
57
 
76
58
  await recordExchange({ sessionId, cwd, prompt, result });
77
59
 
78
- // Re-resolve the exchanges dir using the actual module's path (under tmpHome)
79
- const { encodeCwd } = require('../lib/encodeCwd.cjs');
80
- const encoded = encodeCwd(cwd);
81
- const logPath = path.join(tmpHome, '.claude', 'knowledge-log', 'exchanges', `${encoded}.jsonl`);
82
-
83
- const content = await fsp.readFile(logPath, 'utf8');
60
+ const content = await fsp.readFile(exchangesLogPath(cwd), 'utf8');
84
61
  const lines = content.trim().split('\n').filter(Boolean);
85
62
  assert.equal(lines.length, 1, 'should write exactly one line');
86
63
 
@@ -96,38 +73,21 @@ describe('recordExchange', () => {
96
73
  });
97
74
 
98
75
  test('still writes a record when summarize degrades (no_api_key)', async () => {
99
- // Stub summarize to return a degraded result
100
- const summarizePath = require.resolve('../lib/summarize.cjs');
101
- delete require.cache[summarizePath];
102
- require.cache[summarizePath] = {
103
- id: summarizePath,
104
- filename: summarizePath,
105
- loaded: true,
106
- exports: {
107
- summarize: async (text) => ({
108
- summary: text.slice(0, 600),
109
- model: 'raw',
110
- degraded: 'no_api_key',
111
- }),
112
- },
113
- };
114
-
115
- const exchangesPath = require.resolve('../exchanges.cjs');
116
- delete require.cache[exchangesPath];
76
+ stubSummarize(async (text) => ({
77
+ summary: text.slice(0, 600),
78
+ model: 'raw',
79
+ degraded: 'no_api_key',
80
+ }));
117
81
 
118
82
  const { recordExchange } = require('../exchanges.cjs');
119
- const { encodeCwd } = require('../lib/encodeCwd.cjs');
120
83
 
84
+ const cwd = mkTmpProject();
121
85
  const sessionId = 'sess-degrade-456';
122
- const cwd = '/home/user/anotherproject';
123
86
  const result = 'Some verbatim result text from the assistant.';
124
87
 
125
88
  await recordExchange({ sessionId, cwd, prompt: 'do something', result });
126
89
 
127
- const encoded = encodeCwd(cwd);
128
- const logPath = path.join(tmpHome, '.claude', 'knowledge-log', 'exchanges', `${encoded}.jsonl`);
129
-
130
- const content = await fsp.readFile(logPath, 'utf8');
90
+ const content = await fsp.readFile(exchangesLogPath(cwd), 'utf8');
131
91
  const lines = content.trim().split('\n').filter(Boolean);
132
92
  assert.equal(lines.length, 1);
133
93
 
@@ -141,31 +101,16 @@ describe('recordExchange', () => {
141
101
  });
142
102
 
143
103
  test('appends multiple records to the same file', async () => {
144
- // Stub summarize
145
- const summarizePath = require.resolve('../lib/summarize.cjs');
146
- delete require.cache[summarizePath];
147
- require.cache[summarizePath] = {
148
- id: summarizePath,
149
- filename: summarizePath,
150
- loaded: true,
151
- exports: {
152
- summarize: async () => ({ summary: 'ok', model: 'claude-haiku-4-5' }),
153
- },
154
- };
155
-
156
- const exchangesPath = require.resolve('../exchanges.cjs');
157
- delete require.cache[exchangesPath];
104
+ stubSummarize(async () => ({ summary: 'ok', model: 'claude-haiku-4-5' }));
105
+
158
106
  const { recordExchange } = require('../exchanges.cjs');
159
- const { encodeCwd } = require('../lib/encodeCwd.cjs');
160
107
 
161
- const cwd = '/home/user/multiproject';
108
+ const cwd = mkTmpProject();
162
109
 
163
110
  await recordExchange({ sessionId: 's1', cwd, prompt: 'first', result: 'res1' });
164
111
  await recordExchange({ sessionId: 's2', cwd, prompt: 'second', result: 'res2' });
165
112
 
166
- const encoded = encodeCwd(cwd);
167
- const logPath = path.join(tmpHome, '.claude', 'knowledge-log', 'exchanges', `${encoded}.jsonl`);
168
- const content = await fsp.readFile(logPath, 'utf8');
113
+ const content = await fsp.readFile(exchangesLogPath(cwd), 'utf8');
169
114
  const lines = content.trim().split('\n').filter(Boolean);
170
115
  assert.equal(lines.length, 2);
171
116
 
@@ -4,34 +4,31 @@
4
4
  * runs under node:test, not vitest) so this is picked up by
5
5
  * `npx vitest run` per this repo's test:unit convention.
6
6
  *
7
+ * PRD 1391: exchanges now write to <cwd>/session-manager-operations/
8
+ * prompt-sessions/exchanges.jsonl — each test uses a fresh tmp directory as
9
+ * its project `cwd`.
10
+ *
7
11
  * Run: timeout 120 npx vitest run src/main/__tests__/exchangesPromptId.test.cjs
8
12
  */
9
13
 
10
14
  'use strict';
11
15
 
12
- import { test, expect, beforeAll, afterAll, beforeEach } from 'vitest';
16
+ import { test, expect, beforeEach } from 'vitest';
13
17
  const fs = require('node:fs');
14
18
  const fsp = require('node:fs/promises');
15
19
  const path = require('node:path');
16
20
  const os = require('node:os');
17
21
 
18
- let tmpHome;
19
- let origHome;
20
-
21
- beforeAll(() => {
22
- tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'exchanges-promptid-test-'));
23
- origHome = process.env.HOME;
24
- process.env.HOME = tmpHome;
25
- });
22
+ function mkTmpProject() {
23
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'exchanges-promptid-test-'));
24
+ }
26
25
 
27
- afterAll(async () => {
28
- process.env.HOME = origHome;
29
- await fsp.rm(tmpHome, { recursive: true, force: true });
30
- });
26
+ function exchangesLogPath(cwd) {
27
+ return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'exchanges.jsonl');
28
+ }
31
29
 
32
30
  beforeEach(() => {
33
- // Stub summarize.cjs to avoid any network calls, and clear the modules
34
- // that cache HOME-derived paths so each test sees the tmp HOME.
31
+ // Stub summarize.cjs to avoid any network calls.
35
32
  const summarizePath = require.resolve('../lib/summarize.cjs');
36
33
  delete require.cache[summarizePath];
37
34
  require.cache[summarizePath] = {
@@ -41,30 +38,24 @@ beforeEach(() => {
41
38
  exports: { summarize: async () => ({ summary: 'ok', model: 'claude-haiku-4-5' }) },
42
39
  };
43
40
  delete require.cache[require.resolve('../exchanges.cjs')];
44
- delete require.cache[require.resolve('../config.cjs')];
45
- delete require.cache[require.resolve('../lib/encodeCwd.cjs')];
46
41
  });
47
42
 
48
43
  test('recordExchange persists promptId when supplied', async () => {
49
44
  const { recordExchange } = require('../exchanges.cjs');
50
- const { encodeCwd } = require('../lib/encodeCwd.cjs');
51
45
 
52
- const cwd = '/home/user/promptid-project';
46
+ const cwd = mkTmpProject();
53
47
  await recordExchange({ sessionId: 's1', cwd, prompt: 'do X', result: 'done', promptId: 'ticket-abc-123' });
54
48
 
55
- const logPath = path.join(tmpHome, '.claude', 'knowledge-log', 'exchanges', `${encodeCwd(cwd)}.jsonl`);
56
- const record = JSON.parse((await fsp.readFile(logPath, 'utf8')).trim());
49
+ const record = JSON.parse((await fsp.readFile(exchangesLogPath(cwd), 'utf8')).trim());
57
50
  expect(record.promptId).toBe('ticket-abc-123');
58
51
  });
59
52
 
60
53
  test('recordExchange omits promptId when not supplied (no backfill/synthesis)', async () => {
61
54
  const { recordExchange } = require('../exchanges.cjs');
62
- const { encodeCwd } = require('../lib/encodeCwd.cjs');
63
55
 
64
- const cwd = '/home/user/no-promptid-project';
56
+ const cwd = mkTmpProject();
65
57
  await recordExchange({ sessionId: 's2', cwd, prompt: 'do Y', result: 'done' });
66
58
 
67
- const logPath = path.join(tmpHome, '.claude', 'knowledge-log', 'exchanges', `${encodeCwd(cwd)}.jsonl`);
68
- const record = JSON.parse((await fsp.readFile(logPath, 'utf8')).trim());
59
+ const record = JSON.parse((await fsp.readFile(exchangesLogPath(cwd), 'utf8')).trim());
69
60
  expect('promptId' in record).toBe(false);
70
61
  });
@@ -29,7 +29,7 @@ test('denies writing autostart persistence entries', () => {
29
29
  });
30
30
 
31
31
  test('allows ordinary files — including shell rc, which developers do edit', () => {
32
- for (const p of [under('Projects', 'app', 'src', 'index.ts'), under('.bashrc'), under('.zshrc'), under('.config', 'session-manager', 'voice.json')]) {
32
+ for (const p of [under('Projects', 'app', 'src', 'index.ts'), under('.bashrc'), under('.zshrc'), under('.claude', 'session-manager', 'voice.json')]) {
33
33
  assert.doesNotThrow(() => rejectCredentials(p), `${p} should be allowed`);
34
34
  }
35
35
  });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * health-credentials.test.cjs — the credential health component.
3
+ *
4
+ * An expired/absent Claude OAuth token silently voids the whole scheduler:
5
+ * headless `claude -p` jobs cannot authenticate, so the queue "runs" but
6
+ * nothing completes. On this machine the token expired 2026-04-04 with no
7
+ * refresh token, yet `npm run health` read GREEN — no component pointed the
8
+ * operator at `claude login`. evaluateCredentialHealth is the missing signal.
9
+ * Pure over an injected credState (the shape refreshIfNeeded returns), so no
10
+ * Keychain access here.
11
+ *
12
+ * Run: timeout 120 npx vitest run src/main/__tests__/health-credentials.test.cjs
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ import { test, expect } from 'vitest';
18
+ const { evaluateCredentialHealth } = require('../health.cjs');
19
+
20
+ const NOW = Date.parse('2026-09-23T15:00:00.000Z');
21
+
22
+ test('an expired token is not ok, names claude login, and reports how long ago it lapsed', () => {
23
+ const expiredAt = NOW - 90 * 60_000; // 90 min ago
24
+ const { component, issues } = evaluateCredentialHealth({
25
+ credState: { kind: 'auth', message: 'Credentials expired.', expiredAt },
26
+ now: NOW,
27
+ });
28
+ expect(component.ok).toBe(false);
29
+ expect(component.state).toBe('expired');
30
+ expect(component.expiredAt).toBe(expiredAt);
31
+ expect(component.message).toMatch(/claude login/);
32
+ expect(component.message).toMatch(/90m ago/);
33
+ expect(issues.length).toBe(1);
34
+ expect(issues[0]).toMatch(/cannot authenticate/i);
35
+ });
36
+
37
+ test('an auth failure with no expiredAt still fails and still names the fix', () => {
38
+ const { component, issues } = evaluateCredentialHealth({
39
+ credState: { kind: 'auth', message: 'refresh rejected: HTTP 401' },
40
+ now: NOW,
41
+ });
42
+ expect(component.ok).toBe(false);
43
+ expect(component.state).toBe('expired');
44
+ expect(component.message).toMatch(/claude login/);
45
+ expect(component.message).not.toMatch(/NaN/);
46
+ expect(issues.length).toBe(1);
47
+ });
48
+
49
+ test('unreadable/absent credentials (kind:config) fail with the login fix', () => {
50
+ const { component, issues } = evaluateCredentialHealth({
51
+ credState: { kind: 'config', message: 'credentials not found' },
52
+ now: NOW,
53
+ });
54
+ expect(component.ok).toBe(false);
55
+ expect(component.state).toBe('unreadable');
56
+ expect(component.message).toMatch(/claude login/);
57
+ expect(issues.length).toBe(1);
58
+ });
59
+
60
+ test('a fresh token is ok and raises no issue', () => {
61
+ const { component, issues } = evaluateCredentialHealth({ credState: { kind: 'ok' }, now: NOW });
62
+ expect(component.ok).toBe(true);
63
+ expect(component.state).toBe('valid');
64
+ expect(issues.length).toBe(0);
65
+ });
66
+
67
+ test('kind:unsupported (auto-refresh unavailable, token still valid) is ok — the token works, refresh just can not', () => {
68
+ const { component, issues } = evaluateCredentialHealth({ credState: { kind: 'unsupported' }, now: NOW });
69
+ expect(component.ok).toBe(true);
70
+ expect(component.state).toBe('valid-no-refresh');
71
+ expect(issues.length).toBe(0);
72
+ });
73
+
74
+ test('a missing/garbage credState never throws and never fails health (unknown, not a false alarm)', () => {
75
+ for (const bad of [undefined, null, 'nope', 42]) {
76
+ const { component, issues } = evaluateCredentialHealth({ credState: bad, now: NOW });
77
+ expect(component.ok).toBe(true);
78
+ expect(component.state).toBe('unknown');
79
+ expect(issues.length).toBe(0);
80
+ }
81
+ });
@@ -92,6 +92,18 @@ test('no false positive: billing utilization at/above utilizationThreshold (when
92
92
  assert.strictEqual(result.reason, 'utilization-at-threshold');
93
93
  });
94
94
 
95
+ test('utilization hold with reset days out is flagged longHold; within 5h is benign', () => {
96
+ const far = evaluateTickLiveness(baseQueue(), { ts: NOW - 30_000, utilization: 95, utilizationWindow: 'weekly_all', nextReset: new Date(NOW + 3 * 86_400_000).toISOString() }, NOW);
97
+ assert.strictEqual(far.stalled, false);
98
+ assert.strictEqual(far.longHold, true);
99
+ assert.strictEqual(far.window, 'weekly_all');
100
+ const unknown = evaluateTickLiveness(baseQueue(), { ts: NOW - 30_000, utilization: 95, nextReset: null }, NOW);
101
+ assert.strictEqual(unknown.longHold, true);
102
+ const near = evaluateTickLiveness(baseQueue(), { ts: NOW - 30_000, utilization: 95, utilizationWindow: 'five_hour', nextReset: new Date(NOW + 2 * 3_600_000).toISOString() }, NOW);
103
+ assert.strictEqual(near.stalled, false);
104
+ assert.strictEqual(near.longHold, undefined);
105
+ });
106
+
95
107
  test('no false positive: no pending jobs at all', () => {
96
108
  const q = baseQueue({ jobs: [] });
97
109
  const result = evaluateTickLiveness(q, null, NOW);