diff --git a/REPORT-T1.md b/REPORT-T1.md new file mode 100644 index 00000000..5ef8a7c2 --- /dev/null +++ b/REPORT-T1.md @@ -0,0 +1,94 @@ +# T1 report: canonical ungated Claude base and lease overlay + +## Changed + +- Replaced `packages/mosaic/framework/runtime/claude/settings.json` with the canonical ungated base. It retains the model, QA hooks, plugins, command allowlist, permissions, and `mcpServers.sequential-thinking`. +- Added `packages/mosaic/framework/runtime/claude/lease-overlay.json`. It contains only `hooks` and the six removed lease hook entries. +- Added the byte-identical pre-split source fixture at `packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json`. +- Added `packages/mosaic/src/runtime/claude-settings-base.spec.ts`. + +`framework-manifest.txt` already declares `runtime/**`, so the new overlay is framework-owned and shipped without a manifest change. + +## Lease-hook enumeration + +The actual template has six lease hook entries, matching fred's refined boundary: + +1. `PreToolUse` matcher `.*`: `mutator-gate.py` +2. `Stop`: one combined command containing `receipt-observer-client.py` then `promote-complete.py` +3. `UserPromptSubmit` matcher `^/mosaic-promote$`: `promote-begin.py` +4. `PreCompact`: `revoke-lease.py --reason pre-compact` +5. `SessionStart` matcher `compact`: `revoke-lease.py --reason session-start-compact` +6. `SessionStart` matcher `resume|clear`: `revoke-lease.py --reason session-start-rollover --bump-generation` + +There is no delta from the refined six-entry enumeration. The Stop entry contains the receipt-observer and promote-complete commands together, rather than as two separate hook objects. + +## Tests and checks + +`pnpm install --frozen-lockfile` was run first because `node_modules` was absent. It completed successfully. + +Red-first run before artifacts existed: + +```text +RUN v2.1.9 .../packages/mosaic +❯ src/runtime/claude-settings-base.spec.ts (4 tests | 4 failed) +× keeps every lease command out of the ungated base + → mutator-gate: expected true to be false +× reconstructs the pre-split gated hooks while retaining the canonical MCP correction + → ENOENT: .../lease-overlay.json +× ships sequential-thinking in the base + → expected undefined to deeply equal { 'sequential-thinking': ... } +× limits the overlay to lease hook entries + → ENOENT: .../lease-overlay.json +``` + +Final focused acceptance run: + +```text +RUN v2.1.9 .../packages/mosaic +✓ src/runtime/claude-settings-base.spec.ts (4 tests) 19ms +Test Files 1 passed (1) +Tests 4 passed (4) +``` + +`pnpm --filter @mosaicstack/mosaic lint` passed: + +```text +> @mosaicstack/mosaic@0.0.49 lint +> eslint src +``` + +`pnpm --filter @mosaicstack/mosaic typecheck` failed on pre-existing workspace resolution and unrelated package errors. The new spec no longer appears in the error list. Initial failures include missing `@mosaicstack/{brain,forge,log,macp,memory,queue,storage,quality-rails,db,config,prdy,types}` declarations, followed by existing `fleet-backlog.ts`, `gateway-doctor.ts`, and TUI implicit-`any` errors. Exit status: 2. + +A focused legacy consumer run confirms an existing assumption that `settings.json` itself is gated: + +```text +pnpm --filter @mosaicstack/mosaic exec vitest run src/mutator-gate/mutator-gate.acceptance.spec.ts +❯ src/mutator-gate/mutator-gate.acceptance.spec.ts (20 tests | 6 failed) +× non-dangerous parser residual is denied by the global all-tools hook without a lease + → expected all-tools mutator-gate command in settings.json +× Claude and Pi compaction observer wiring is complete and fail-closed + → expected PreCompact/SessionStart revoke-lease hooks in settings.json +``` + +The other four failures in that focused run reported `STALE_GENERATION` where the test expected `MUTATOR_UNVERIFIED`, plus one successful-gate assertion. I did not redesign this legacy suite because the task explicitly says to report consumers that assume the base is gated. + +## Consumers found + +Direct `runtime/claude/settings.json` path consumers found by the required repository grep: + +- `packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets`: copies the base to `~/.claude/settings.json`. +- `packages/mosaic/src/commands/install-ordering-guard.ts` and `.spec.ts`: documentation and behavior assume the source embeds enforcement hooks. +- `packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh`: comments and assertions expect `mutator-gate.py` and `receipt-observer-client.py` in the base. +- `packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts`: reads the base and asserts mutator, promotion, and compaction lease wiring. +- `packages/mosaic/src/lease-broker/promotion_trigger_unittest.py`: reads the base and asserts promotion wiring. +- `packages/mosaic/src/lease-broker/recovery_runtime_unittest.py`: reads the base. +- `packages/mosaic/src/runtime/update-checker.ts` and `.spec.ts`: references the path in settings wiring/update checks. +- Documentation-only references: `docs/compaction-refresh/probes/p6_constrained_recovery.py`, `docs/plans/agent-reflection-loop-PRD.md`, `docs/tasks/544-agent-reflection-loop.md`, and the framework QA documentation/scripts found by grep. + +I did not change these consumers. The install/link and lease acceptance consumers must be taught to select and compose `lease-overlay.json` when a gated promotion seat is requested. That composition behavior is outside T1. + +## Ambiguity handled + +The exact pre-split template fixture has no `mcpServers` key (SHA-256 `44e74ea1e9d424fffa020ee666402662ac856b88bf6ae7f3b8931eed29dc75a4`). The task simultaneously requires a byte-for-byte pre-split fixture, `mcpServers.sequential-thinking` in the base, and `deep-merge(base, overlay) == original`. Those three conditions cannot all hold because a merge cannot remove the required MCP key. + +The acceptance test preserves the exact fixture and asserts that the normalized merge equals the pre-split template plus the required canonical `mcpServers.sequential-thinking` correction. It verifies all original hook content is reconstructed and the base carries the required MCP. Production three-layer merge semantics remain W-F1 work. diff --git a/packages/mosaic/framework/runtime/claude/lease-overlay.json b/packages/mosaic/framework/runtime/claude/lease-overlay.json new file mode 100644 index 00000000..c2deaa11 --- /dev/null +++ b/packages/mosaic/framework/runtime/claude/lease-overlay.json @@ -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 + } + ] + } + ] + } +} diff --git a/packages/mosaic/framework/runtime/claude/settings.json b/packages/mosaic/framework/runtime/claude/settings.json index 0e6dcec1..bf79b5ba 100644 --- a/packages/mosaic/framework/runtime/claude/settings.json +++ b/packages/mosaic/framework/runtime/claude/settings.json @@ -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"] + } + } } diff --git a/packages/mosaic/src/runtime/claude-settings-base.spec.ts b/packages/mosaic/src/runtime/claude-settings-base.spec.ts new file mode 100644 index 00000000..71cf7c95 --- /dev/null +++ b/packages/mosaic/src/runtime/claude-settings-base.spec.ts @@ -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/); + } + }); +}); diff --git a/packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json b/packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json new file mode 100644 index 00000000..0e6dcec1 --- /dev/null +++ b/packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json @@ -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 +}