fix(fleet): additive hook-event merge and gated-composition acceptance reads

Integration adjudication (fred, W-F1): the general arrays-replace merge rule
conflicts with the gap-7 base/overlay split — base and lease overlay share
the PreToolUse and Stop events, so replace semantics would silently drop the
base QA hooks from every gated seat. Ruling: hook event arrays directly
under the top-level hooks key concatenate (base first); all other arrays
keep replace semantics; null tombstones still delete an event.

- mutator-gate acceptance now asserts lease wiring against the COMPOSED
  gated settings (base + lease-overlay via the launcher's own merge),
  matching the post-split contract.
- fleet subcommand canary gains the intended new 'agent' surface from T3.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
This commit is contained in:
Jason Woltje
2026-08-13 11:44:58 -05:00
co-authored by Claude Fable 5
parent 0fdcfa0ff4
commit 92e790ae9d
4 changed files with 70 additions and 6 deletions
@@ -253,9 +253,12 @@ 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)) {
@@ -264,20 +267,36 @@ 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)
? mergeObject(
lowValue,
highValue,
context === 'root' && key === 'hooks' ? 'hooks' : 'nested',
)
: cloneValue(highValue);
}
return result;
}
/** Deep object merge. Scalars and arrays replace; null in a higher layer deletes. */
/**
* 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.
*/
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)),
(merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer, 'root')),
{},
);
}