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.
This commit is contained in:
terra
2026-08-13 14:38:19 -05:00
parent cb960237d3
commit 9de9ffa56b
3 changed files with 162 additions and 26 deletions
@@ -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": [
{
@@ -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', () => {
@@ -253,12 +253,9 @@ function cloneValue(value: unknown): unknown {
return value;
}
type MergeContext = 'root' | 'hooks' | 'nested';
function mergeObject(
lower: Record<string, unknown>,
higher: Record<string, unknown>,
context: MergeContext = 'nested',
): Record<string, unknown> {
const result = cloneValue(lower) as Record<string, unknown>;
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<Record<string, unknown> | undefined>
): Record<string, unknown> {
return layers.reduce<Record<string, unknown>>(
(merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer, 'root')),
(merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer)),
{},
);
}