claude-code-session-manager 0.75.3 → 0.77.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/{AgentLibrary-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
- package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-6IOLjZZN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/lib/activeSessions.cjs +116 -6
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +269 -96
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +247 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +58 -3
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +207 -1
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +25 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +64 -5
- package/src/main/ipcSchemas.cjs +69 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +133 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +69 -2
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +514 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +520 -21
- package/src/main/lib/jobWorktree.cjs +2 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +370 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +106 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +103 -15
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +40 -7
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +54 -4
- package/src/main/lib/reaperHelpers.cjs +64 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +301 -55
- package/src/main/lib/schedulerConfig.cjs +99 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +162 -3
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1701 -130
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/seedSchedulerMcp.cjs +58 -4
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +187 -3
- package/src/preload/index.cjs +9 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
- package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
- package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
|
@@ -6,15 +6,30 @@
|
|
|
6
6
|
* Run: timeout 300 npx vitest run src/main/lib/__tests__/delegationReadiness.test.cjs
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { test, expect, afterEach } from 'vitest';
|
|
9
|
+
import { test, expect, afterEach, beforeEach } from 'vitest';
|
|
10
10
|
const fs = require('node:fs');
|
|
11
11
|
const fsp = require('node:fs/promises');
|
|
12
12
|
const os = require('node:os');
|
|
13
13
|
const path = require('node:path');
|
|
14
|
-
const {
|
|
14
|
+
const {
|
|
15
|
+
checkDelegationReadiness,
|
|
16
|
+
installPrdWriteGuard,
|
|
17
|
+
installDestructiveGitGuard,
|
|
18
|
+
probeSchedulerMcpLive,
|
|
19
|
+
clearLiveProbeCache,
|
|
20
|
+
PRD_WRITE_GUARD_SCRIPT,
|
|
21
|
+
DESTRUCTIVE_GIT_GUARD_SCRIPT,
|
|
22
|
+
} = require('../delegationReadiness.cjs');
|
|
23
|
+
|
|
24
|
+
const REQUIRED_TOOLS = ['scheduler_create_prd', 'session_manager_help'];
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
clearLiveProbeCache();
|
|
28
|
+
});
|
|
15
29
|
|
|
16
30
|
const tmpDirs = [];
|
|
17
31
|
afterEach(async () => {
|
|
32
|
+
clearLiveProbeCache();
|
|
18
33
|
while (tmpDirs.length) {
|
|
19
34
|
const d = tmpDirs.pop();
|
|
20
35
|
await fsp.rm(d, { recursive: true, force: true });
|
|
@@ -32,46 +47,86 @@ async function writeJson(absPath, value) {
|
|
|
32
47
|
await fsp.writeFile(absPath, JSON.stringify(value), 'utf8');
|
|
33
48
|
}
|
|
34
49
|
|
|
50
|
+
/** A stub stdio MCP server: answers initialize + tools/list over stdin/stdout. */
|
|
51
|
+
async function writeAnsweringStub(dir, { toolNames = REQUIRED_TOOLS, countFile = null } = {}) {
|
|
52
|
+
const scriptPath = path.join(dir, 'stub-mcp-server.cjs');
|
|
53
|
+
const body = `
|
|
54
|
+
'use strict';
|
|
55
|
+
const readline = require('node:readline');
|
|
56
|
+
${countFile ? `require('node:fs').appendFileSync(${JSON.stringify(countFile)}, 'x\\n');` : ''}
|
|
57
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
58
|
+
rl.on('line', (line) => {
|
|
59
|
+
let msg;
|
|
60
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
61
|
+
if (msg.id === 1) {
|
|
62
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: { name: 'stub', version: '1' } } }) + '\\n');
|
|
63
|
+
} else if (msg.id === 2) {
|
|
64
|
+
const tools = ${JSON.stringify(toolNames)}.map((name) => ({ name }));
|
|
65
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 2, result: { tools } }) + '\\n');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
`;
|
|
69
|
+
await fsp.writeFile(scriptPath, body, 'utf8');
|
|
70
|
+
return scriptPath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A stub that never responds to anything — exercises the timeout path. */
|
|
74
|
+
async function writeSilentStub(dir) {
|
|
75
|
+
const scriptPath = path.join(dir, 'silent-mcp-server.cjs');
|
|
76
|
+
await fsp.writeFile(scriptPath, 'process.stdin.resume();\n', 'utf8');
|
|
77
|
+
return scriptPath;
|
|
78
|
+
}
|
|
79
|
+
|
|
35
80
|
async function makeGreenFixtures() {
|
|
36
81
|
const homeDir = await mkTmp('sm-delegation-home-');
|
|
37
82
|
const cwd = await mkTmp('sm-delegation-cwd-');
|
|
38
83
|
|
|
84
|
+
const scriptPath = await writeAnsweringStub(homeDir);
|
|
39
85
|
await writeJson(path.join(homeDir, '.claude.json'), {
|
|
40
|
-
mcpServers: { 'session-manager-scheduler': { type: 'stdio', command: 'node', args: [] } },
|
|
86
|
+
mcpServers: { 'session-manager-scheduler': { type: 'stdio', command: 'node', args: [scriptPath] } },
|
|
41
87
|
});
|
|
42
88
|
await writeJson(path.join(homeDir, '.claude', 'settings.json'), {
|
|
43
89
|
enabledPlugins: { 'session-manager-dev@session-manager': true },
|
|
44
90
|
});
|
|
45
91
|
await fsp.mkdir(path.join(homeDir, '.claude', 'agents'), { recursive: true });
|
|
46
92
|
await fsp.writeFile(path.join(homeDir, '.claude', 'agents', 'dev-lead.md'), '# dev-lead', 'utf8');
|
|
93
|
+
// The canonical, sanctioned form: an ABSOLUTE path to session-manager's own
|
|
94
|
+
// guard script (installPrdWriteGuard's "reference, never vendor" decision).
|
|
47
95
|
await writeJson(path.join(cwd, '.claude', 'settings.json'), {
|
|
48
96
|
hooks: {
|
|
49
97
|
PreToolUse: [
|
|
50
98
|
{
|
|
51
99
|
matcher: 'Write|Edit|NotebookEdit',
|
|
52
|
-
hooks: [{ type: 'command', command:
|
|
100
|
+
hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
matcher: 'Bash',
|
|
104
|
+
hooks: [{ type: 'command', command: `node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}` }],
|
|
53
105
|
},
|
|
54
106
|
],
|
|
55
107
|
},
|
|
56
108
|
});
|
|
57
109
|
|
|
58
|
-
return { homeDir, cwd };
|
|
110
|
+
return { homeDir, cwd, scriptPath };
|
|
59
111
|
}
|
|
60
112
|
|
|
61
|
-
test('all
|
|
113
|
+
test('all seven checks pass on a fully-configured project', async () => {
|
|
62
114
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
63
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
115
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
64
116
|
|
|
65
117
|
expect(result.ok).toBe(true);
|
|
66
|
-
expect(result.checks).toHaveLength(
|
|
118
|
+
expect(result.checks).toHaveLength(7);
|
|
67
119
|
expect(result.checks.every((c) => c.ok)).toBe(true);
|
|
68
120
|
expect(result.checks.map((c) => c.id)).toEqual([
|
|
69
121
|
'scheduler-mcp',
|
|
122
|
+
'scheduler-mcp-live',
|
|
123
|
+
'scheduler-mcp-project-duplicate',
|
|
70
124
|
'dev-plugin',
|
|
71
125
|
'agent-personas',
|
|
72
126
|
'prd-write-guard',
|
|
127
|
+
'destructive-git-guard',
|
|
73
128
|
]);
|
|
74
|
-
});
|
|
129
|
+
}, 15_000);
|
|
75
130
|
|
|
76
131
|
test('scheduler-mcp: passes via project-scope .mcp.json even without user scope', async () => {
|
|
77
132
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
@@ -80,47 +135,72 @@ test('scheduler-mcp: passes via project-scope .mcp.json even without user scope'
|
|
|
80
135
|
mcpServers: { 'session-manager-scheduler': { command: 'node', args: [] } },
|
|
81
136
|
});
|
|
82
137
|
|
|
83
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
138
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
84
139
|
const check = result.checks.find((c) => c.id === 'scheduler-mcp');
|
|
85
140
|
expect(check.ok).toBe(true);
|
|
86
|
-
|
|
87
|
-
});
|
|
141
|
+
}, 15_000);
|
|
88
142
|
|
|
89
143
|
test('scheduler-mcp: fails with a runnable fix when absent from both scopes', async () => {
|
|
90
144
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
91
145
|
await writeJson(path.join(homeDir, '.claude.json'), { mcpServers: {} });
|
|
92
146
|
|
|
93
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
147
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
94
148
|
const check = result.checks.find((c) => c.id === 'scheduler-mcp');
|
|
95
149
|
expect(check.ok).toBe(false);
|
|
96
150
|
expect(result.ok).toBe(false);
|
|
97
151
|
expect(check.fix).toMatch(/^claude mcp add session-manager-scheduler --scope user/);
|
|
98
152
|
expect(check.fix).toContain('scheduler-mcp-server.cjs');
|
|
99
|
-
});
|
|
153
|
+
}, 15_000);
|
|
154
|
+
|
|
155
|
+
test('scheduler-mcp: fails when the user-scope script path is RELATIVE', async () => {
|
|
156
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
157
|
+
await writeJson(path.join(homeDir, '.claude.json'), {
|
|
158
|
+
mcpServers: { 'session-manager-scheduler': { command: 'node', args: ['scripts/scheduler-mcp-server.cjs'] } },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
162
|
+
const check = result.checks.find((c) => c.id === 'scheduler-mcp');
|
|
163
|
+
expect(check.ok).toBe(false);
|
|
164
|
+
expect(check.detail).toMatch(/non-absolute/);
|
|
165
|
+
expect(check.fix).toMatch(/^claude mcp add session-manager-scheduler --scope user/);
|
|
166
|
+
// Live probe has nothing runnable to trust, so it must not report ready either.
|
|
167
|
+
expect(result.checks.find((c) => c.id === 'scheduler-mcp-live').skipped).toBe(true);
|
|
168
|
+
}, 15_000);
|
|
169
|
+
|
|
170
|
+
test('scheduler-mcp: fails when the user-scope script path is absolute but MISSING', async () => {
|
|
171
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
172
|
+
await writeJson(path.join(homeDir, '.claude.json'), {
|
|
173
|
+
mcpServers: { 'session-manager-scheduler': { command: 'node', args: [path.join(homeDir, 'does-not-exist.cjs')] } },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
177
|
+
const check = result.checks.find((c) => c.id === 'scheduler-mcp');
|
|
178
|
+
expect(check.ok).toBe(false);
|
|
179
|
+
expect(check.detail).toMatch(/no longer exists/);
|
|
180
|
+
}, 15_000);
|
|
100
181
|
|
|
101
182
|
test('dev-plugin: fails independently when enabledPlugins is missing the key', async () => {
|
|
102
183
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
103
184
|
await writeJson(path.join(homeDir, '.claude', 'settings.json'), { enabledPlugins: {} });
|
|
104
185
|
|
|
105
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
186
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
106
187
|
const check = result.checks.find((c) => c.id === 'dev-plugin');
|
|
107
188
|
expect(check.ok).toBe(false);
|
|
108
189
|
expect(result.ok).toBe(false);
|
|
109
190
|
expect(check.fix).toBeTruthy();
|
|
110
|
-
// the other
|
|
191
|
+
// the other checks still pass independently
|
|
111
192
|
expect(result.checks.filter((c) => c.id !== 'dev-plugin').every((c) => c.ok)).toBe(true);
|
|
112
|
-
});
|
|
193
|
+
}, 15_000);
|
|
113
194
|
|
|
114
195
|
test('agent-personas: fails independently when ~/.claude/agents has no personas', async () => {
|
|
115
196
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
116
197
|
await fsp.rm(path.join(homeDir, '.claude', 'agents', 'dev-lead.md'));
|
|
117
198
|
|
|
118
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
199
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
119
200
|
const check = result.checks.find((c) => c.id === 'agent-personas');
|
|
120
201
|
expect(check.ok).toBe(false);
|
|
121
202
|
expect(result.ok).toBe(false);
|
|
122
|
-
|
|
123
|
-
});
|
|
203
|
+
}, 15_000);
|
|
124
204
|
|
|
125
205
|
test('agent-personas: passes via project-scope .claude/agents/ even without global personas', async () => {
|
|
126
206
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
@@ -128,31 +208,20 @@ test('agent-personas: passes via project-scope .claude/agents/ even without glob
|
|
|
128
208
|
await fsp.mkdir(path.join(cwd, '.claude', 'agents'), { recursive: true });
|
|
129
209
|
await fsp.writeFile(path.join(cwd, '.claude', 'agents', 'builder.md'), '# builder', 'utf8');
|
|
130
210
|
|
|
131
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
211
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
132
212
|
const check = result.checks.find((c) => c.id === 'agent-personas');
|
|
133
213
|
expect(check.ok).toBe(true);
|
|
134
|
-
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test('agent-personas: fails when the directory does not exist at all', async () => {
|
|
138
|
-
const { homeDir, cwd } = await makeGreenFixtures();
|
|
139
|
-
await fsp.rm(path.join(homeDir, '.claude', 'agents'), { recursive: true, force: true });
|
|
140
|
-
|
|
141
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
142
|
-
const check = result.checks.find((c) => c.id === 'agent-personas');
|
|
143
|
-
expect(check.ok).toBe(false);
|
|
144
|
-
});
|
|
214
|
+
}, 15_000);
|
|
145
215
|
|
|
146
216
|
test('prd-write-guard: fails independently when the hook is missing', async () => {
|
|
147
217
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
148
218
|
await writeJson(path.join(cwd, '.claude', 'settings.json'), { hooks: { PreToolUse: [] } });
|
|
149
219
|
|
|
150
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
220
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
151
221
|
const check = result.checks.find((c) => c.id === 'prd-write-guard');
|
|
152
222
|
expect(check.ok).toBe(false);
|
|
153
223
|
expect(result.ok).toBe(false);
|
|
154
|
-
|
|
155
|
-
});
|
|
224
|
+
}, 15_000);
|
|
156
225
|
|
|
157
226
|
test('unparseable JSON files yield ok:false with a detail, never a throw', async () => {
|
|
158
227
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
@@ -160,8 +229,8 @@ test('unparseable JSON files yield ok:false with a detail, never a throw', async
|
|
|
160
229
|
await fs.promises.writeFile(path.join(homeDir, '.claude', 'settings.json'), '{ not valid json', 'utf8');
|
|
161
230
|
await fs.promises.writeFile(path.join(cwd, '.claude', 'settings.json'), '{ not valid json', 'utf8');
|
|
162
231
|
|
|
163
|
-
expect(
|
|
164
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
232
|
+
await expect(checkDelegationReadiness({ cwd, homeDir })).resolves.toBeTruthy();
|
|
233
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
165
234
|
|
|
166
235
|
const scheduler = result.checks.find((c) => c.id === 'scheduler-mcp');
|
|
167
236
|
const devPlugin = result.checks.find((c) => c.id === 'dev-plugin');
|
|
@@ -173,13 +242,293 @@ test('unparseable JSON files yield ok:false with a detail, never a throw', async
|
|
|
173
242
|
expect(guard.ok).toBe(false);
|
|
174
243
|
expect(guard.detail).toBeTruthy();
|
|
175
244
|
expect(result.ok).toBe(false);
|
|
176
|
-
});
|
|
245
|
+
}, 15_000);
|
|
246
|
+
|
|
247
|
+
// ─────────────────────────────── scheduler-mcp-project-duplicate
|
|
248
|
+
|
|
249
|
+
test('scheduler-mcp-project-duplicate: warns when both scopes register the server', async () => {
|
|
250
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
251
|
+
await writeJson(path.join(cwd, '.mcp.json'), {
|
|
252
|
+
mcpServers: { 'session-manager-scheduler': { command: 'node', args: ['scripts/scheduler-mcp-server.cjs'] } },
|
|
253
|
+
});
|
|
177
254
|
|
|
178
|
-
|
|
255
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
256
|
+
const check = result.checks.find((c) => c.id === 'scheduler-mcp-project-duplicate');
|
|
257
|
+
expect(check.ok).toBe(true);
|
|
258
|
+
expect(check.warn).toBe(true);
|
|
259
|
+
expect(check.detail).toMatch(/user scope is canonical/);
|
|
260
|
+
// A warning never fails the overall gate.
|
|
261
|
+
expect(result.ok).toBe(true);
|
|
262
|
+
}, 15_000);
|
|
263
|
+
|
|
264
|
+
test('scheduler-mcp-project-duplicate: no warning when only user scope is registered', async () => {
|
|
265
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
266
|
+
|
|
267
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
268
|
+
const check = result.checks.find((c) => c.id === 'scheduler-mcp-project-duplicate');
|
|
269
|
+
expect(check.ok).toBe(true);
|
|
270
|
+
expect(check.warn).toBe(false);
|
|
271
|
+
}, 15_000);
|
|
272
|
+
|
|
273
|
+
// ─────────────────────────────── scheduler-mcp-live
|
|
274
|
+
|
|
275
|
+
test('scheduler-mcp-live: ok when the server answers tools/list with both required tools', async () => {
|
|
276
|
+
const dir = await mkTmp('sm-live-probe-');
|
|
277
|
+
const scriptPath = await writeAnsweringStub(dir);
|
|
278
|
+
|
|
279
|
+
const result = await probeSchedulerMcpLive({ command: 'node', args: [scriptPath], env: {} });
|
|
280
|
+
expect(result.ok).toBe(true);
|
|
281
|
+
expect(result.detail).toContain('scheduler_create_prd');
|
|
282
|
+
}, 15_000);
|
|
283
|
+
|
|
284
|
+
test('scheduler-mcp-live: fails naming the missing tool when the server answers WITHOUT it', async () => {
|
|
285
|
+
const dir = await mkTmp('sm-live-probe-');
|
|
286
|
+
const scriptPath = await writeAnsweringStub(dir, { toolNames: ['scheduler_create_prd'] });
|
|
287
|
+
|
|
288
|
+
const result = await probeSchedulerMcpLive({ command: 'node', args: [scriptPath], env: {} });
|
|
289
|
+
expect(result.ok).toBe(false);
|
|
290
|
+
expect(result.detail).toContain('session_manager_help');
|
|
291
|
+
}, 15_000);
|
|
292
|
+
|
|
293
|
+
test('scheduler-mcp-live: times out (bounded) against a server that never answers, and kills the child', async () => {
|
|
294
|
+
const dir = await mkTmp('sm-live-probe-');
|
|
295
|
+
const scriptPath = await writeSilentStub(dir);
|
|
296
|
+
|
|
297
|
+
const start = Date.now();
|
|
298
|
+
const result = await probeSchedulerMcpLive({ command: 'node', args: [scriptPath], env: {} });
|
|
299
|
+
const elapsed = Date.now() - start;
|
|
300
|
+
|
|
301
|
+
expect(result.ok).toBe(false);
|
|
302
|
+
expect(result.detail).toBe('timeout');
|
|
303
|
+
expect(elapsed).toBeLessThan(15_000);
|
|
304
|
+
}, 20_000);
|
|
305
|
+
|
|
306
|
+
test('scheduler-mcp-live: is skipped, not failed, when scheduler-mcp already failed', async () => {
|
|
307
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
308
|
+
await writeJson(path.join(homeDir, '.claude.json'), { mcpServers: {} });
|
|
309
|
+
|
|
310
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
311
|
+
const live = result.checks.find((c) => c.id === 'scheduler-mcp-live');
|
|
312
|
+
expect(live.ok).toBe(true);
|
|
313
|
+
expect(live.skipped).toBe(true);
|
|
314
|
+
// One red row for the registration failure, not two.
|
|
315
|
+
expect(result.checks.filter((c) => !c.ok)).toHaveLength(1);
|
|
316
|
+
}, 15_000);
|
|
317
|
+
|
|
318
|
+
test('scheduler-mcp-live: memoizes the probe per registration signature within the TTL', async () => {
|
|
319
|
+
const dir = await mkTmp('sm-live-probe-');
|
|
320
|
+
const countFile = path.join(dir, 'spawn-count.txt');
|
|
321
|
+
const scriptPath = await writeAnsweringStub(dir, { countFile });
|
|
322
|
+
|
|
323
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
324
|
+
await writeJson(path.join(homeDir, '.claude.json'), {
|
|
325
|
+
mcpServers: { 'session-manager-scheduler': { type: 'stdio', command: 'node', args: [scriptPath] } },
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await checkDelegationReadiness({ cwd, homeDir });
|
|
329
|
+
await checkDelegationReadiness({ cwd, homeDir });
|
|
330
|
+
|
|
331
|
+
const spawnCount = (await fsp.readFile(countFile, 'utf8')).trim().split('\n').filter(Boolean).length;
|
|
332
|
+
expect(spawnCount).toBe(1);
|
|
333
|
+
}, 15_000);
|
|
334
|
+
|
|
335
|
+
// ─────────────────────────────── installPrdWriteGuard (unchanged behavior)
|
|
336
|
+
|
|
337
|
+
test('installPrdWriteGuard: writes the canonical ABSOLUTE-path entry and turns the check green', async () => {
|
|
179
338
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
180
339
|
await fsp.rm(path.join(cwd, '.claude', 'settings.json'));
|
|
181
340
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
expect(
|
|
341
|
+
const r = await installPrdWriteGuard({ cwd });
|
|
342
|
+
expect(r.ok).toBe(true);
|
|
343
|
+
expect(r.action).toBe('installed');
|
|
344
|
+
expect(r.command).toBe(`node ${PRD_WRITE_GUARD_SCRIPT}`);
|
|
345
|
+
|
|
346
|
+
const written = JSON.parse(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8'));
|
|
347
|
+
expect(written.hooks.PreToolUse).toEqual([
|
|
348
|
+
{ matcher: 'Write|Edit|NotebookEdit', hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }] },
|
|
349
|
+
]);
|
|
350
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
351
|
+
expect(result.checks.find((c) => c.id === 'prd-write-guard').ok).toBe(true);
|
|
352
|
+
}, 15_000);
|
|
353
|
+
|
|
354
|
+
test('installPrdWriteGuard: is idempotent — a healthy guard is a no-op', async () => {
|
|
355
|
+
const { cwd } = await makeGreenFixtures();
|
|
356
|
+
await installPrdWriteGuard({ cwd });
|
|
357
|
+
const before = fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8');
|
|
358
|
+
|
|
359
|
+
const again = await installPrdWriteGuard({ cwd });
|
|
360
|
+
expect(again.action).toBe('already-installed');
|
|
361
|
+
expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe(before);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('installPrdWriteGuard: refuses on unparseable settings rather than discarding them', async () => {
|
|
365
|
+
const { cwd } = await makeGreenFixtures();
|
|
366
|
+
await fsp.writeFile(path.join(cwd, '.claude', 'settings.json'), '{ not valid json', 'utf8');
|
|
367
|
+
|
|
368
|
+
const r = await installPrdWriteGuard({ cwd });
|
|
369
|
+
expect(r.ok).toBe(false);
|
|
370
|
+
expect(r.action).toBe('error');
|
|
371
|
+
expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe('{ not valid json');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// The guard script is not just referenced — it is EXERCISED here, so the
|
|
375
|
+
// "certifies a config that has never been proven to run" gap is closed by a
|
|
376
|
+
// real end-to-end run of the exact command installPrdWriteGuard writes.
|
|
377
|
+
test('the installed command actually DENIES a scheduler PRD write and allows a normal one', async () => {
|
|
378
|
+
const { cwd } = await makeGreenFixtures();
|
|
379
|
+
const { execFileSync } = require('node:child_process');
|
|
380
|
+
|
|
381
|
+
const run = (toolInput) => JSON.parse(execFileSync('node', [PRD_WRITE_GUARD_SCRIPT], {
|
|
382
|
+
input: JSON.stringify({ tool_name: 'Write', tool_input: toolInput, cwd }),
|
|
383
|
+
encoding: 'utf8',
|
|
384
|
+
}));
|
|
385
|
+
|
|
386
|
+
const denied = run({ file_path: path.join(cwd, 'session-manager-operations', 'scheduler', 'prds', '1234-x.md'), content: '# x' });
|
|
387
|
+
expect(denied.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
388
|
+
|
|
389
|
+
const allowed = run({ file_path: path.join(cwd, 'src', 'x.ts'), content: 'x' });
|
|
390
|
+
expect(allowed.hookSpecificOutput?.permissionDecision).not.toBe('deny');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ─────────────────────────────── destructive-git-guard / installDestructiveGitGuard
|
|
394
|
+
|
|
395
|
+
test('destructive-git-guard: fails independently when the hook is missing', async () => {
|
|
396
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
397
|
+
await writeJson(path.join(cwd, '.claude', 'settings.json'), {
|
|
398
|
+
hooks: {
|
|
399
|
+
PreToolUse: [
|
|
400
|
+
{
|
|
401
|
+
matcher: 'Write|Edit|NotebookEdit',
|
|
402
|
+
hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
409
|
+
const check = result.checks.find((c) => c.id === 'destructive-git-guard');
|
|
410
|
+
expect(check.ok).toBe(false);
|
|
411
|
+
expect(result.ok).toBe(false);
|
|
412
|
+
// the other checks still pass independently
|
|
413
|
+
expect(result.checks.filter((c) => c.id !== 'destructive-git-guard').every((c) => c.ok)).toBe(true);
|
|
414
|
+
}, 15_000);
|
|
415
|
+
|
|
416
|
+
test('destructive-git-guard: fails when the hook names a script that does not exist', async () => {
|
|
417
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
418
|
+
await writeJson(path.join(cwd, '.claude', 'settings.json'), {
|
|
419
|
+
hooks: {
|
|
420
|
+
PreToolUse: [
|
|
421
|
+
{
|
|
422
|
+
matcher: 'Write|Edit|NotebookEdit',
|
|
423
|
+
hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
matcher: 'Bash',
|
|
427
|
+
hooks: [{ type: 'command', command: 'node /does/not/exist/guard-destructive-git.cjs' }],
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
434
|
+
const check = result.checks.find((c) => c.id === 'destructive-git-guard');
|
|
435
|
+
expect(check.ok).toBe(false);
|
|
436
|
+
expect(check.detail).toMatch(/does not exist/);
|
|
437
|
+
}, 15_000);
|
|
438
|
+
|
|
439
|
+
test('installDestructiveGitGuard: writes the canonical ABSOLUTE-path entry and turns the check green', async () => {
|
|
440
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
441
|
+
await writeJson(path.join(cwd, '.claude', 'settings.json'), {
|
|
442
|
+
hooks: {
|
|
443
|
+
PreToolUse: [
|
|
444
|
+
{
|
|
445
|
+
matcher: 'Write|Edit|NotebookEdit',
|
|
446
|
+
hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const r = await installDestructiveGitGuard({ cwd });
|
|
453
|
+
expect(r.ok).toBe(true);
|
|
454
|
+
expect(r.action).toBe('installed');
|
|
455
|
+
expect(r.command).toBe(`node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}`);
|
|
456
|
+
|
|
457
|
+
const written = JSON.parse(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8'));
|
|
458
|
+
// The pre-existing Write|Edit|NotebookEdit matcher (and its hook) survives untouched.
|
|
459
|
+
expect(written.hooks.PreToolUse).toContainEqual(
|
|
460
|
+
{ matcher: 'Write|Edit|NotebookEdit', hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }] },
|
|
461
|
+
);
|
|
462
|
+
expect(written.hooks.PreToolUse).toContainEqual(
|
|
463
|
+
{ matcher: 'Bash', hooks: [{ type: 'command', command: `node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}` }] },
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
467
|
+
expect(result.checks.find((c) => c.id === 'destructive-git-guard').ok).toBe(true);
|
|
468
|
+
expect(result.checks.find((c) => c.id === 'prd-write-guard').ok).toBe(true);
|
|
469
|
+
}, 15_000);
|
|
470
|
+
|
|
471
|
+
test('installDestructiveGitGuard: is idempotent — a healthy guard is a no-op', async () => {
|
|
472
|
+
const { cwd } = await makeGreenFixtures();
|
|
473
|
+
const before = fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8');
|
|
474
|
+
|
|
475
|
+
const again = await installDestructiveGitGuard({ cwd });
|
|
476
|
+
expect(again.action).toBe('already-installed');
|
|
477
|
+
expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe(before);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('installDestructiveGitGuard: repairs a broken entry in place rather than duplicating it', async () => {
|
|
481
|
+
const { cwd } = await makeGreenFixtures();
|
|
482
|
+
await writeJson(path.join(cwd, '.claude', 'settings.json'), {
|
|
483
|
+
hooks: {
|
|
484
|
+
PreToolUse: [
|
|
485
|
+
{
|
|
486
|
+
matcher: 'Write|Edit|NotebookEdit',
|
|
487
|
+
hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
matcher: 'Bash',
|
|
491
|
+
hooks: [{ type: 'command', command: 'node /does/not/exist/guard-destructive-git.cjs' }],
|
|
492
|
+
},
|
|
493
|
+
],
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const r = await installDestructiveGitGuard({ cwd });
|
|
498
|
+
expect(r.action).toBe('repaired');
|
|
499
|
+
|
|
500
|
+
const written = JSON.parse(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8'));
|
|
501
|
+
const bashMatchers = written.hooks.PreToolUse.filter((m) => m.matcher === 'Bash');
|
|
502
|
+
expect(bashMatchers).toHaveLength(1);
|
|
503
|
+
expect(bashMatchers[0].hooks).toEqual([{ type: 'command', command: `node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}` }]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('installDestructiveGitGuard: refuses on unparseable settings rather than discarding them', async () => {
|
|
507
|
+
const { cwd } = await makeGreenFixtures();
|
|
508
|
+
await fsp.writeFile(path.join(cwd, '.claude', 'settings.json'), '{ not valid json', 'utf8');
|
|
509
|
+
|
|
510
|
+
const r = await installDestructiveGitGuard({ cwd });
|
|
511
|
+
expect(r.ok).toBe(false);
|
|
512
|
+
expect(r.action).toBe('error');
|
|
513
|
+
expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe('{ not valid json');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// The guard script is EXERCISED here too, same rationale as the
|
|
517
|
+
// installPrdWriteGuard end-to-end test above.
|
|
518
|
+
test('the installed destructive-git command actually DENIES a shared-tree stash and allows it inside an sm-job/ worktree', async () => {
|
|
519
|
+
const { cwd } = await makeGreenFixtures();
|
|
520
|
+
const { execFileSync } = require('node:child_process');
|
|
521
|
+
const os2 = require('node:os');
|
|
522
|
+
|
|
523
|
+
const run = (command, runCwd) => JSON.parse(execFileSync('node', [DESTRUCTIVE_GIT_GUARD_SCRIPT], {
|
|
524
|
+
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd: runCwd }),
|
|
525
|
+
encoding: 'utf8',
|
|
526
|
+
}));
|
|
527
|
+
|
|
528
|
+
const denied = run('git stash', cwd);
|
|
529
|
+
expect(denied.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
530
|
+
|
|
531
|
+
const worktreeCwd = path.join(os2.tmpdir(), 'session-manager-job-worktrees', 'somehash', 'some-slug');
|
|
532
|
+
const allowed = run('git stash', worktreeCwd);
|
|
533
|
+
expect(allowed.hookSpecificOutput?.permissionDecision).not.toBe('deny');
|
|
185
534
|
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ephemeralCwd.test.cjs — isEphemeralCwd(cwd) must refuse a cwd that
|
|
3
|
+
* resolves to os.tmpdir() itself, into a managed worktree root, or onto a
|
|
4
|
+
* linked git worktree's own root — and must allow an ordinary project cwd.
|
|
5
|
+
*
|
|
6
|
+
* See ephemeralCwd.cjs's header for the live incident (2026-09-01) this
|
|
7
|
+
* predicate closes: queueStore.writeSplit materializing scheduler state
|
|
8
|
+
* inside an ephemeral epic worktree, torn down with the Epic and silently
|
|
9
|
+
* losing the write.
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/ephemeralCwd.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 { isEphemeralCwd } = require('../ephemeralCwd.cjs');
|
|
22
|
+
const { KIND_CONFIG } = require('../gitWorktree.cjs');
|
|
23
|
+
|
|
24
|
+
const tmpDirs = [];
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
while (tmpDirs.length) {
|
|
28
|
+
await fsp.rm(tmpDirs.pop(), { recursive: true, force: true }).catch(() => {});
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// A real project must never itself live under /tmp — only ephemeral fixtures
|
|
33
|
+
// (worktree checkouts) do. `test-results/` is already gitignored.
|
|
34
|
+
const NON_TMP_SCRATCH_ROOT = path.join(process.cwd(), 'test-results', 'ephemeral-cwd-fixtures');
|
|
35
|
+
|
|
36
|
+
async function mkNonTmpDir(prefix) {
|
|
37
|
+
fs.mkdirSync(NON_TMP_SCRATCH_ROOT, { recursive: true });
|
|
38
|
+
return fsp.mkdtemp(path.join(NON_TMP_SCRATCH_ROOT, prefix));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function makeWorktreeFixture() {
|
|
42
|
+
const main = await mkNonTmpDir('sm-ephemeral-main-');
|
|
43
|
+
tmpDirs.push(main);
|
|
44
|
+
fs.mkdirSync(path.join(main, '.git'), { recursive: true });
|
|
45
|
+
const worktree = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-ephemeral-worktree-'));
|
|
46
|
+
tmpDirs.push(worktree);
|
|
47
|
+
const worktreeName = 'sm-epic-abc123';
|
|
48
|
+
const worktreeGitFile = path.join(worktree, '.git');
|
|
49
|
+
const adminDir = path.join(main, '.git', 'worktrees', worktreeName);
|
|
50
|
+
fs.mkdirSync(adminDir, { recursive: true });
|
|
51
|
+
fs.writeFileSync(path.join(adminDir, 'gitdir'), `${worktreeGitFile}\n`, 'utf8');
|
|
52
|
+
fs.writeFileSync(worktreeGitFile, `gitdir: ${adminDir}\n`, 'utf8');
|
|
53
|
+
return { main, worktree };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('isEphemeralCwd refuses os.tmpdir() itself', () => {
|
|
57
|
+
expect(isEphemeralCwd(os.tmpdir())).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('isEphemeralCwd refuses a cwd under the managed epic worktree root', () => {
|
|
61
|
+
const cwd = path.join(KIND_CONFIG.epic.root, 'some-hash', 'some-epic-slug');
|
|
62
|
+
expect(isEphemeralCwd(cwd)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('isEphemeralCwd refuses a cwd under the managed job worktree root', () => {
|
|
66
|
+
const cwd = path.join(KIND_CONFIG.job.root, 'some-job-id');
|
|
67
|
+
expect(isEphemeralCwd(cwd)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('isEphemeralCwd refuses a linked git worktree root anywhere on disk', async () => {
|
|
71
|
+
const { worktree } = await makeWorktreeFixture();
|
|
72
|
+
expect(isEphemeralCwd(worktree)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('isEphemeralCwd allows an ordinary project cwd (the worktree fixture\'s own main tree)', async () => {
|
|
76
|
+
const { main } = await makeWorktreeFixture();
|
|
77
|
+
expect(isEphemeralCwd(main)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('isEphemeralCwd allows an ordinary tmpdir-adjacent fixture used by other test suites (not os.tmpdir() itself, not a managed worktree root, not a linked worktree)', async () => {
|
|
81
|
+
const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-some-other-test-fixture-'));
|
|
82
|
+
tmpDirs.push(cwd);
|
|
83
|
+
expect(isEphemeralCwd(cwd)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('isEphemeralCwd is false for non-absolute or missing input, never throws', () => {
|
|
87
|
+
expect(isEphemeralCwd('relative/path')).toBe(false);
|
|
88
|
+
expect(isEphemeralCwd('')).toBe(false);
|
|
89
|
+
expect(isEphemeralCwd(null)).toBe(false);
|
|
90
|
+
expect(isEphemeralCwd(undefined)).toBe(false);
|
|
91
|
+
});
|
|
@@ -80,12 +80,14 @@ test('a non-git cwd resolves to null rather than throwing (Epic proceeds unisola
|
|
|
80
80
|
expect(result).toBeNull();
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
test('a dirty base tree
|
|
83
|
+
test('a dirty base tree still creates the worktree, carrying the uncommitted edit in rather than falling back unisolated', async () => {
|
|
84
84
|
const cwd = await mkRepoCwd();
|
|
85
|
-
fs.writeFileSync(path.join(cwd, '
|
|
85
|
+
fs.writeFileSync(path.join(cwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
86
86
|
|
|
87
87
|
const result = await handler(null, { cwd, epicId: 'epic-mint-3' });
|
|
88
|
-
expect(result).toBeNull();
|
|
88
|
+
expect(result).not.toBeNull();
|
|
89
|
+
expect(fs.readFileSync(path.join(result.dir, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
|
|
90
|
+
expect(fs.readFileSync(path.join(cwd, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
|
|
89
91
|
});
|
|
90
92
|
|
|
91
93
|
test('SM_EPIC_WORKTREE_DISABLE=1 resolves to null rather than throwing', async () => {
|