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:
@@ -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)),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user