From 9de9ffa56bd4800b4833d429ce41bd7d43e71f81 Mon Sep 17 00:00:00 2001 From: terra Date: Thu, 13 Aug 2026 14:38:19 -0500 Subject: [PATCH] fix(lease): restore uniform settings array replacement AMD1213-B1: preserve the gated Claude hook composition explicitly in the lease overlay while restoring last-layer-wins arrays and null tombstones. --- .../runtime/claude/lease-overlay.json | 19 +++ .../src/commands/fleet-launch-command.spec.ts | 145 +++++++++++++++++- .../src/commands/fleet-launch-command.ts | 24 +-- 3 files changed, 162 insertions(+), 26 deletions(-) diff --git a/packages/mosaic/framework/runtime/claude/lease-overlay.json b/packages/mosaic/framework/runtime/claude/lease-overlay.json index c2deaa11..edd34d77 100644 --- a/packages/mosaic/framework/runtime/claude/lease-overlay.json +++ b/packages/mosaic/framework/runtime/claude/lease-overlay.json @@ -44,6 +44,16 @@ } ], "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh", + "timeout": 10 + } + ] + }, { "matcher": ".*", "hooks": [ @@ -56,6 +66,15 @@ } ], "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh", + "timeout": 15 + } + ] + }, { "hooks": [ { diff --git a/packages/mosaic/src/commands/fleet-launch-command.spec.ts b/packages/mosaic/src/commands/fleet-launch-command.spec.ts index 39007d66..42cce95d 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -94,7 +94,7 @@ describe('three-layer settings merge', () => { ).toEqual({ nested: { keep: true } }); }); - it('concatenates hook event arrays so an overlay adds gating without erasing base hooks', () => { + it('replaces a hook event array wholesale with the higher layer', () => { const qaStop = { hooks: [{ type: 'command', command: 'qa-stop.sh' }] }; const leaseStop = { hooks: [{ type: 'command', command: 'receipt-observer.py' }] }; const qaPre = { matcher: 'Write', hooks: [{ type: 'command', command: 'qa-pre.sh' }] }; @@ -103,7 +103,140 @@ describe('three-layer settings merge', () => { { hooks: { Stop: [qaStop], PreToolUse: [qaPre] } }, { hooks: { Stop: [leaseStop] } }, ), - ).toEqual({ hooks: { Stop: [qaStop, leaseStop], PreToolUse: [qaPre] } }); + ).toEqual({ hooks: { Stop: [leaseStop], PreToolUse: [qaPre] } }); + }); + + it('reconstructs every gated hook event from the base and lease overlay', () => { + const fx = fixture({ schema: 1, harness: 'claude', overlay: 'lease-overlay.json' }); + const frameworkRuntime = join(process.cwd(), 'framework', 'runtime', 'claude'); + writeFileSync( + join(fx.systemHome, 'runtime', 'claude', 'settings.json'), + readFileSync(join(frameworkRuntime, 'settings.json'), 'utf8'), + ); + writeFileSync( + join(fx.agentDir, 'lease-overlay.json'), + readFileSync(join(frameworkRuntime, 'lease-overlay.json'), 'utf8'), + ); + + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + + expect(plan.settings.merged['hooks']).toEqual({ + PreToolUse: [ + { + matcher: 'Write|Edit|MultiEdit', + hooks: [ + { + type: 'command', + command: '~/.config/mosaic/tools/qa/prevent-memory-write.sh', + timeout: 10, + }, + ], + }, + { + 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, + }, + ], + }, + ], + 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: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh', + timeout: 15, + }, + ], + }, + { + 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, + }, + ], + }, + ], + 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, + }, + ], + }, + ], + }); }); it('still deletes a whole hook event via the null tombstone', () => { @@ -115,13 +248,13 @@ describe('three-layer settings merge', () => { ).toEqual({ hooks: {} }); }); - it('keeps replace semantics for arrays outside the top-level hooks object', () => { + it('replaces an allowedCommands-shaped non-hook array wholesale', () => { expect( deepMergeSettings( - { plugins: ['base'], nested: { hooks: { Stop: ['base'] } } }, - { plugins: ['user'], nested: { hooks: { Stop: ['user'] } } }, + { allowedCommands: ['pnpm', 'git'], nested: { hooks: { Stop: ['base'] } } }, + { allowedCommands: ['node'], nested: { hooks: { Stop: ['user'] } } }, ), - ).toEqual({ plugins: ['user'], nested: { hooks: { Stop: ['user'] } } }); + ).toEqual({ allowedCommands: ['node'], nested: { hooks: { Stop: ['user'] } } }); }); it('deep-merges all three layers in precedence order', () => { diff --git a/packages/mosaic/src/commands/fleet-launch-command.ts b/packages/mosaic/src/commands/fleet-launch-command.ts index 42ebbe01..61f58f5f 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -253,12 +253,9 @@ function cloneValue(value: unknown): unknown { return value; } -type MergeContext = 'root' | 'hooks' | 'nested'; - function mergeObject( lower: Record, higher: Record, - context: MergeContext = 'nested', ): Record { const result = cloneValue(lower) as Record; for (const [key, highValue] of Object.entries(higher)) { @@ -267,36 +264,23 @@ function mergeObject( continue; } const lowValue = result[key]; - if (context === 'hooks' && Array.isArray(lowValue) && Array.isArray(highValue)) { - result[key] = [...(lowValue as unknown[]), ...(cloneValue(highValue) as unknown[])]; - continue; - } result[key] = isPlainObject(lowValue) && isPlainObject(highValue) - ? mergeObject( - lowValue, - highValue, - context === 'root' && key === 'hooks' ? 'hooks' : 'nested', - ) + ? mergeObject(lowValue, highValue) : cloneValue(highValue); } return result; } /** - * Deep object merge. Scalars and arrays replace; null in a higher layer - * deletes. Exception: hook event arrays directly under the top-level `hooks` - * key concatenate (base entries first), so an overlay ADDS gating without - * erasing the base QA hooks that share an event — replacing them would make - * the gap-7 base/overlay split unimplementable without duplicating base - * hooks inside the lease overlay. Removing an event entirely still works via - * the null tombstone. + * Deep object merge. Objects merge recursively; scalar and array conflicts + * resolve last-layer-wins; null in a higher layer deletes the key. */ export function deepMergeSettings( ...layers: ReadonlyArray | undefined> ): Record { return layers.reduce>( - (merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer, 'root')), + (merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer)), {}, ); }