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": [
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matcher": ".*",
|
"matcher": ".*",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
@@ -56,6 +66,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Stop": [
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('three-layer settings merge', () => {
|
|||||||
).toEqual({ nested: { keep: true } });
|
).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 qaStop = { hooks: [{ type: 'command', command: 'qa-stop.sh' }] };
|
||||||
const leaseStop = { hooks: [{ type: 'command', command: 'receipt-observer.py' }] };
|
const leaseStop = { hooks: [{ type: 'command', command: 'receipt-observer.py' }] };
|
||||||
const qaPre = { matcher: 'Write', hooks: [{ type: 'command', command: 'qa-pre.sh' }] };
|
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: [qaStop], PreToolUse: [qaPre] } },
|
||||||
{ hooks: { Stop: [leaseStop] } },
|
{ 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', () => {
|
it('still deletes a whole hook event via the null tombstone', () => {
|
||||||
@@ -115,13 +248,13 @@ describe('three-layer settings merge', () => {
|
|||||||
).toEqual({ hooks: {} });
|
).toEqual({ hooks: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps replace semantics for arrays outside the top-level hooks object', () => {
|
it('replaces an allowedCommands-shaped non-hook array wholesale', () => {
|
||||||
expect(
|
expect(
|
||||||
deepMergeSettings(
|
deepMergeSettings(
|
||||||
{ plugins: ['base'], nested: { hooks: { Stop: ['base'] } } },
|
{ allowedCommands: ['pnpm', 'git'], nested: { hooks: { Stop: ['base'] } } },
|
||||||
{ plugins: ['user'], nested: { hooks: { Stop: ['user'] } } },
|
{ 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', () => {
|
it('deep-merges all three layers in precedence order', () => {
|
||||||
|
|||||||
@@ -253,12 +253,9 @@ function cloneValue(value: unknown): unknown {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MergeContext = 'root' | 'hooks' | 'nested';
|
|
||||||
|
|
||||||
function mergeObject(
|
function mergeObject(
|
||||||
lower: Record<string, unknown>,
|
lower: Record<string, unknown>,
|
||||||
higher: Record<string, unknown>,
|
higher: Record<string, unknown>,
|
||||||
context: MergeContext = 'nested',
|
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const result = cloneValue(lower) as Record<string, unknown>;
|
const result = cloneValue(lower) as Record<string, unknown>;
|
||||||
for (const [key, highValue] of Object.entries(higher)) {
|
for (const [key, highValue] of Object.entries(higher)) {
|
||||||
@@ -267,36 +264,23 @@ function mergeObject(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const lowValue = result[key];
|
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] =
|
result[key] =
|
||||||
isPlainObject(lowValue) && isPlainObject(highValue)
|
isPlainObject(lowValue) && isPlainObject(highValue)
|
||||||
? mergeObject(
|
? mergeObject(lowValue, highValue)
|
||||||
lowValue,
|
|
||||||
highValue,
|
|
||||||
context === 'root' && key === 'hooks' ? 'hooks' : 'nested',
|
|
||||||
)
|
|
||||||
: cloneValue(highValue);
|
: cloneValue(highValue);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deep object merge. Scalars and arrays replace; null in a higher layer
|
* Deep object merge. Objects merge recursively; scalar and array conflicts
|
||||||
* deletes. Exception: hook event arrays directly under the top-level `hooks`
|
* resolve last-layer-wins; null in a higher layer deletes the key.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function deepMergeSettings(
|
export function deepMergeSettings(
|
||||||
...layers: ReadonlyArray<Record<string, unknown> | undefined>
|
...layers: ReadonlyArray<Record<string, unknown> | undefined>
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return layers.reduce<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