forked from mosaicstack/stack
feat(mosaic): split Claude lease overlay from base
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "^/mosaic-promote$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,7 @@
|
||||
{
|
||||
"model": "opus",
|
||||
"hooks": {
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "^/mosaic-promote$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
@@ -91,11 +38,6 @@
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||
"timeout": 15
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||
@@ -315,5 +257,11 @@
|
||||
"cpan",
|
||||
"nohup"
|
||||
],
|
||||
"enableAllMcpTools": true
|
||||
"enableAllMcpTools": true,
|
||||
"mcpServers": {
|
||||
"sequential-thinking": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||||
type JsonObject = { [key: string]: Json };
|
||||
|
||||
const frameworkRoot = fileURLToPath(new URL('../../framework/', import.meta.url));
|
||||
const basePath = `${frameworkRoot}runtime/claude/settings.json`;
|
||||
const overlayPath = `${frameworkRoot}runtime/claude/lease-overlay.json`;
|
||||
const gatedFixturePath = fileURLToPath(
|
||||
new URL('./fixtures/claude-settings.gated.pre-split.json', import.meta.url),
|
||||
);
|
||||
|
||||
function readJson(path: string): JsonObject {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as JsonObject;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// This fixture contract composes hook event arrays additively. It is deliberately
|
||||
// limited to verifying that the split is lossless; production launch merge
|
||||
// semantics remain owned by W-F1.
|
||||
function deepMerge(base: Json, overlay: Json): Json {
|
||||
if (Array.isArray(base) && Array.isArray(overlay)) {
|
||||
const merged = [...base];
|
||||
for (const entry of overlay) {
|
||||
if (!isObject(entry) || !Array.isArray(entry['hooks'])) {
|
||||
merged.push(entry);
|
||||
continue;
|
||||
}
|
||||
const matchingIndex = merged.findIndex(
|
||||
(candidate) =>
|
||||
isObject(candidate) &&
|
||||
Array.isArray(candidate['hooks']) &&
|
||||
candidate['matcher'] === entry['matcher'],
|
||||
);
|
||||
if (matchingIndex === -1) merged.push(entry);
|
||||
else merged[matchingIndex] = deepMerge(merged[matchingIndex]!, entry);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
if (isObject(base) && isObject(overlay)) {
|
||||
const merged: JsonObject = { ...base };
|
||||
for (const [key, value] of Object.entries(overlay)) {
|
||||
merged[key] = key in merged ? deepMerge(merged[key]!, value) : value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function normalize(value: Json): Json {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(normalize)
|
||||
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
||||
}
|
||||
if (isObject(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, nested]) => [key, normalize(nested)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hookCommands(settings: JsonObject): string[] {
|
||||
const hooks = settings['hooks'];
|
||||
if (!isObject(hooks)) return [];
|
||||
|
||||
return Object.values(hooks).flatMap((event) => {
|
||||
if (!Array.isArray(event)) return [];
|
||||
return event.flatMap((entry) => {
|
||||
if (!isObject(entry) || !Array.isArray(entry['hooks'])) return [];
|
||||
return entry['hooks'].flatMap((hook) =>
|
||||
isObject(hook) && typeof hook['command'] === 'string' ? [hook['command']] : [],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const sequentialThinking: JsonObject = {
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||
};
|
||||
|
||||
describe('canonical Claude base and lease-promotion overlay', () => {
|
||||
it('keeps every lease command out of the ungated base', () => {
|
||||
const base = readJson(basePath);
|
||||
const commands = hookCommands(base);
|
||||
|
||||
for (const marker of ['mutator-gate', 'receipt-observer', 'promote-', 'revoke-lease']) {
|
||||
expect(
|
||||
commands.some((command) => command.includes(marker)),
|
||||
marker,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('reconstructs the pre-split gated hooks while retaining the canonical MCP correction', () => {
|
||||
const base = readJson(basePath);
|
||||
const overlay = readJson(overlayPath);
|
||||
const preSplit = readJson(gatedFixturePath);
|
||||
const expected: JsonObject = {
|
||||
...preSplit,
|
||||
mcpServers: { 'sequential-thinking': sequentialThinking },
|
||||
};
|
||||
|
||||
expect(normalize(deepMerge(base, overlay))).toEqual(normalize(expected));
|
||||
});
|
||||
|
||||
it('ships sequential-thinking in the base', () => {
|
||||
const base = readJson(basePath);
|
||||
expect(base['mcpServers']).toEqual({ 'sequential-thinking': sequentialThinking });
|
||||
});
|
||||
|
||||
it('limits the overlay to lease hook entries', () => {
|
||||
const overlay = readJson(overlayPath);
|
||||
expect(Object.keys(overlay)).toEqual(['hooks']);
|
||||
|
||||
const commands = hookCommands(overlay);
|
||||
expect(commands).toHaveLength(6);
|
||||
for (const command of commands) {
|
||||
expect(command).toMatch(/mutator-gate|receipt-observer|promote-|revoke-lease/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
{
|
||||
"model": "opus",
|
||||
"hooks": {
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "^/mosaic-promote$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|MultiEdit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/qa-hook-stdin.sh",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|MultiEdit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/typecheck-hook.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||
"timeout": 15
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"frontend-design@claude-plugins-official": true,
|
||||
"feature-dev@claude-plugins-official": true,
|
||||
"code-review@claude-plugins-official": true,
|
||||
"pr-review-toolkit@claude-plugins-official": true
|
||||
},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"allowedCommands": [
|
||||
"npm",
|
||||
"npm install",
|
||||
"npm run",
|
||||
"npm test",
|
||||
"npm build",
|
||||
"npm start",
|
||||
"npm run dev",
|
||||
"npm run build",
|
||||
"npm run lint",
|
||||
"npm run typecheck",
|
||||
"npm run test:ci",
|
||||
"npm run test:e2e",
|
||||
"npm run test:unit",
|
||||
"npm run test:integration",
|
||||
"npm run test:cov",
|
||||
"npm run test:security",
|
||||
"npm run security:scan",
|
||||
"npm run security:audit",
|
||||
"npm run performance:benchmark",
|
||||
"npm run build:dev",
|
||||
"npm run build:prod",
|
||||
"npm run test",
|
||||
"npm run test:watch",
|
||||
"npm run migrate",
|
||||
"npm run migrate:rollback",
|
||||
"npm run db:seed",
|
||||
"npm run db:reset",
|
||||
"node",
|
||||
"yarn",
|
||||
"pnpm",
|
||||
"npx",
|
||||
"npx tsc",
|
||||
"npx eslint",
|
||||
"npx prettier",
|
||||
"npx jest",
|
||||
"npx vitest",
|
||||
"git",
|
||||
"git add",
|
||||
"git commit",
|
||||
"git push",
|
||||
"git pull",
|
||||
"git status",
|
||||
"git diff",
|
||||
"git log",
|
||||
"git branch",
|
||||
"git checkout",
|
||||
"git merge",
|
||||
"git init",
|
||||
"git remote",
|
||||
"git fetch",
|
||||
"git reset",
|
||||
"git rebase",
|
||||
"git stash",
|
||||
"git tag",
|
||||
"git show",
|
||||
"git config",
|
||||
"gh",
|
||||
"gh issue",
|
||||
"gh pr",
|
||||
"gh repo",
|
||||
"gh api",
|
||||
"docker",
|
||||
"docker build",
|
||||
"docker run",
|
||||
"docker ps",
|
||||
"docker logs",
|
||||
"docker exec",
|
||||
"docker stop",
|
||||
"docker start",
|
||||
"docker pull",
|
||||
"docker push",
|
||||
"docker-compose",
|
||||
"docker-compose up",
|
||||
"docker-compose down",
|
||||
"docker-compose build",
|
||||
"docker-compose logs",
|
||||
"docker-compose ps",
|
||||
"docker-compose exec",
|
||||
"kubectl",
|
||||
"kubectl get",
|
||||
"kubectl describe",
|
||||
"kubectl logs",
|
||||
"kubectl apply",
|
||||
"kubectl delete",
|
||||
"kubectl port-forward",
|
||||
"mkdir",
|
||||
"touch",
|
||||
"chmod",
|
||||
"chown",
|
||||
"ls",
|
||||
"cd",
|
||||
"pwd",
|
||||
"cp",
|
||||
"mv",
|
||||
"rm",
|
||||
"cat",
|
||||
"echo",
|
||||
"head",
|
||||
"tail",
|
||||
"grep",
|
||||
"grep -E",
|
||||
"grep -r",
|
||||
"find",
|
||||
"find -name",
|
||||
"find -type",
|
||||
"find -path",
|
||||
"find -exec",
|
||||
"find . -type f",
|
||||
"find . -type d",
|
||||
"wc",
|
||||
"sort",
|
||||
"uniq",
|
||||
"curl",
|
||||
"wget",
|
||||
"ping",
|
||||
"netstat",
|
||||
"ss",
|
||||
"lsof",
|
||||
"psql",
|
||||
"pg_dump",
|
||||
"pg_restore",
|
||||
"sqlite3",
|
||||
"jest",
|
||||
"vitest",
|
||||
"playwright",
|
||||
"cypress",
|
||||
"artillery",
|
||||
"lighthouse",
|
||||
"tsc",
|
||||
"eslint",
|
||||
"prettier",
|
||||
"snyk",
|
||||
"semgrep",
|
||||
"tar",
|
||||
"gzip",
|
||||
"unzip",
|
||||
"zip",
|
||||
"which",
|
||||
"whoami",
|
||||
"id",
|
||||
"env",
|
||||
"export",
|
||||
"source",
|
||||
"sleep",
|
||||
"date",
|
||||
"uptime",
|
||||
"df",
|
||||
"du",
|
||||
"free",
|
||||
"top",
|
||||
"htop",
|
||||
"ps",
|
||||
"tree",
|
||||
"jq",
|
||||
"sed",
|
||||
"awk",
|
||||
"xargs",
|
||||
"tee",
|
||||
"test",
|
||||
"true",
|
||||
"false",
|
||||
"basename",
|
||||
"dirname",
|
||||
"realpath",
|
||||
"readlink",
|
||||
"stat",
|
||||
"file",
|
||||
"make",
|
||||
"cmake",
|
||||
"gcc",
|
||||
"g++",
|
||||
"clang",
|
||||
"python",
|
||||
"python3",
|
||||
"pip",
|
||||
"pip3",
|
||||
"pip install",
|
||||
"poetry",
|
||||
"pipenv",
|
||||
"go",
|
||||
"go build",
|
||||
"go test",
|
||||
"go run",
|
||||
"go mod",
|
||||
"cargo",
|
||||
"rustc",
|
||||
"ruby",
|
||||
"gem",
|
||||
"bundle",
|
||||
"rake",
|
||||
"java",
|
||||
"javac",
|
||||
"mvn",
|
||||
"gradle",
|
||||
"dotnet",
|
||||
"msbuild",
|
||||
"php",
|
||||
"composer",
|
||||
"perl",
|
||||
"cpan",
|
||||
"nohup"
|
||||
],
|
||||
"enableAllMcpTools": true
|
||||
}
|
||||
Reference in New Issue
Block a user