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
@@ -94,6 +94,36 @@ describe('three-layer settings merge', () => {
).toEqual({ nested: { keep: true } });
});
it('concatenates hook event arrays so an overlay adds gating without erasing base hooks', () => {
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' }] };
expect(
deepMergeSettings(
{ hooks: { Stop: [qaStop], PreToolUse: [qaPre] } },
{ hooks: { Stop: [leaseStop] } },
),
).toEqual({ hooks: { Stop: [qaStop, leaseStop], PreToolUse: [qaPre] } });
});
it('still deletes a whole hook event via the null tombstone', () => {
expect(
deepMergeSettings(
{ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'qa-stop.sh' }] }] } },
{ hooks: { Stop: null } },
),
).toEqual({ hooks: {} });
});
it('keeps replace semantics for arrays outside the top-level hooks object', () => {
expect(
deepMergeSettings(
{ plugins: ['base'], nested: { hooks: { Stop: ['base'] } } },
{ plugins: ['user'], nested: { hooks: { Stop: ['user'] } } },
),
).toEqual({ plugins: ['user'], nested: { hooks: { Stop: ['user'] } } });
});
it('deep-merges all three layers in precedence order', () => {
expect(
deepMergeSettings(