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.
- package/dist/assets/{DataModel-DZK9QZpV.js → DataModel-T0D00BpK.js} +1 -1
- package/dist/assets/History-DuAovTr7.js +2 -0
- package/dist/assets/{Hooks-CmK6l0uf.js → Hooks-CgP9jsAN.js} +3 -3
- package/dist/assets/{HostBilko-D8TIx-Bx.js → HostBilko-DHfz3Mxa.js} +1 -1
- package/dist/assets/{Library-DTxpOjx8.js → Library--poVP-d1.js} +1 -1
- package/dist/assets/{MarkdownEditor-BPJyrevq.js → MarkdownEditor-C56-Pc4c.js} +1 -1
- package/dist/assets/{McpServers-B_rvIYDs.js → McpServers-D-ykP4Vi.js} +2 -2
- package/dist/assets/Memory-BrkJjRL8.js +8 -0
- package/dist/assets/{Permissions-DhMMp27n.js → Permissions-BMpAaxJu.js} +3 -3
- package/dist/assets/Plugins-OuEe5S_T.js +2 -0
- package/dist/assets/{ProvenanceBadge-ptlItdCK.js → ProvenanceBadge-4eNbDYe8.js} +1 -1
- package/dist/assets/{SaveBar-CSbNSl2W.js → SaveBar-BQ8y9Lkv.js} +1 -1
- package/dist/assets/Scheduler-DcMT5sT_.js +16 -0
- package/dist/assets/{ScopeSwitcher-BJof-GWH.js → ScopeSwitcher-XdxtVKP-.js} +1 -1
- package/dist/assets/Settings-FdaPUq2t.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CGl4B89-.js → SkillReferenceGraph-CJzvvc0_.js} +1 -1
- package/dist/assets/Skills-Wvm9OE1e.js +3 -0
- package/dist/assets/{SystemPrompt-DKj-q39J.js → SystemPrompt--LYw_69j.js} +1 -1
- package/dist/assets/TagLibrary-Be6HbMMx.js +1 -0
- package/dist/assets/{TiptapBody-BbcJL9az.js → TiptapBody-DzFlWMgO.js} +1 -1
- package/dist/assets/{Toggle-DAslvnnv.js → Toggle-BxtAFE1i.js} +1 -1
- package/dist/assets/index-XkrLB67D.js +3079 -0
- package/dist/assets/{settingsSchema-pzp0TgvN.js → settingsSchema-BIvDZ1Cj.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +8 -2
- package/plugins/CLAUDE.md +3 -3
- package/plugins/session-manager-dev/skills/develop/SKILL.md +83 -67
- package/plugins/session-manager-dev/skills/develop/standards.md +5 -11
- package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +5 -15
- package/scripts/README.md +1 -0
- package/scripts/hooks/guard-destructive-git.cjs +9 -374
- package/scripts/hooks/guard-inline-implementation.cjs +9 -128
- package/scripts/hooks/guard-prd-writes.cjs +9 -74
- package/scripts/hooks/guard-self-schedule.cjs +9 -61
- package/scripts/hooks/lib/guard-destructive-git-policy.cjs +410 -0
- package/scripts/hooks/lib/guard-inline-implementation-policy.cjs +159 -0
- package/scripts/hooks/lib/guard-prd-writes-policy.cjs +100 -0
- package/scripts/hooks/lib/guard-self-schedule-policy.cjs +89 -0
- package/scripts/scheduler-mcp-server.cjs +8 -1
- package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +59 -0
- package/src/main/__tests__/exchanges.test.cjs +41 -96
- package/src/main/__tests__/exchangesPromptId.test.cjs +16 -25
- package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
- package/src/main/__tests__/health-credentials.test.cjs +81 -0
- package/src/main/__tests__/memoryAggregate.test.cjs +40 -1
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +1 -1
- package/src/main/__tests__/planValidator.test.cjs +71 -0
- package/src/main/__tests__/prdCreate.test.cjs +3 -1
- package/src/main/__tests__/prdSizing.test.cjs +106 -0
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +46 -0
- package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +10 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +40 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +81 -0
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +6 -1
- package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +110 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +19 -1
- package/src/main/__tests__/seedValidatorPersona.test.cjs +116 -0
- package/src/main/__tests__/telemetryClient.test.cjs +2 -2
- package/src/main/__tests__/telemetryContract.test.cjs +2 -2
- package/src/main/__tests__/usageSingleFlight.test.cjs +10 -0
- package/src/main/__tests__/validationSentinels.test.cjs +84 -0
- package/src/main/build-info.json +4 -4
- package/src/main/config.cjs +26 -14
- package/src/main/exchanges.cjs +26 -21
- package/src/main/health.cjs +66 -4
- package/src/main/index.cjs +1 -1
- package/src/main/ipcSchemas.cjs +5 -1
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +1 -1
- package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +115 -0
- package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +85 -27
- package/src/main/lib/__tests__/gitWorktree.test.cjs +21 -27
- package/src/main/lib/__tests__/guardShims.test.cjs +10 -3
- package/src/main/lib/__tests__/loadGate.test.cjs +44 -1
- package/src/main/lib/__tests__/opsOwnership.test.cjs +14 -0
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +4 -4
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +2 -2
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +1 -1
- package/src/main/lib/agentModelResolve.cjs +1 -0
- package/src/main/lib/credentials.cjs +62 -0
- package/src/main/lib/cwdClassify.cjs +15 -0
- package/src/main/lib/definitionOfDone.cjs +9 -13
- package/src/main/lib/effectiveModelInfo.cjs +2 -9
- package/src/main/lib/epicWorktreeProjectConfig.cjs +75 -31
- package/src/main/lib/loadGate.cjs +29 -5
- package/src/main/lib/opsOwnership.cjs +23 -0
- package/src/main/lib/planValidator.cjs +28 -0
- package/src/main/lib/prdCreate.cjs +8 -4
- package/src/main/lib/prdSizing.cjs +83 -0
- package/src/main/lib/runLogRetention.cjs +2 -8
- package/src/main/lib/telemetryBacklog.cjs +9 -3
- package/src/main/lib/telemetryClient.cjs +12 -3
- package/src/main/lib/telemetrySettings.cjs +8 -2
- package/src/main/lib/validationSentinels.cjs +29 -0
- package/src/main/memoryAggregate.cjs +36 -11
- package/src/main/otelSettings.cjs +8 -2
- package/src/main/scheduler.cjs +215 -31
- package/src/main/seedAgentPersonas.cjs +1 -1
- package/src/main/sessionsStore.cjs +8 -2
- package/src/main/supervisor.cjs +3 -3
- package/src/main/templates/PRD_AUTHORING.md +9 -9
- package/src/main/voiceSettings.cjs +8 -2
- package/src/preload/api.d.ts +3 -3
- package/src/preload/index.cjs +5 -5
- package/src/seed/agents/dev-lead.md +24 -15
- package/src/seed/agents/validator.md +33 -0
- package/dist/assets/History-BDyJUGtR.js +0 -2
- package/dist/assets/Memory-B-9GlHqe.js +0 -8
- package/dist/assets/Plugins-DKfAlmMY.js +0 -2
- package/dist/assets/Scheduler--rn1QBYc.js +0 -16
- package/dist/assets/Settings-Dsx6XZP5.js +0 -3
- package/dist/assets/Skills-D522Zfol.js +0 -3
- package/dist/assets/TagLibrary-BH7CG7-6.js +0 -1
- package/dist/assets/index-Yf8e7VCB.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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
28
|
+
function exchangesLogPath(cwd) {
|
|
29
|
+
return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'exchanges.jsonl');
|
|
30
|
+
}
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
delete require.cache[
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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 =
|
|
46
|
+
const cwd = mkTmpProject();
|
|
53
47
|
await recordExchange({ sessionId: 's1', cwd, prompt: 'do X', result: 'done', promptId: 'ticket-abc-123' });
|
|
54
48
|
|
|
55
|
-
const
|
|
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 =
|
|
56
|
+
const cwd = mkTmpProject();
|
|
65
57
|
await recordExchange({ sessionId: 's2', cwd, prompt: 'do Y', result: 'done' });
|
|
66
58
|
|
|
67
|
-
const
|
|
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('.
|
|
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
|
+
});
|