From 92e790ae9d92836c0aa0228a57575a8ea725c15f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 13 Aug 2026 11:44:58 -0500 Subject: [PATCH] fix(fleet): additive hook-event merge and gated-composition acceptance reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh --- .../src/commands/fleet-launch-command.spec.ts | 30 +++++++++++++++++++ .../src/commands/fleet-launch-command.ts | 25 ++++++++++++++-- packages/mosaic/src/commands/fleet.spec.ts | 1 + .../mutator-gate.acceptance.spec.ts | 20 +++++++++++-- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/src/commands/fleet-launch-command.spec.ts b/packages/mosaic/src/commands/fleet-launch-command.spec.ts index bcf8f1eb..cc2f1043 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -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( diff --git a/packages/mosaic/src/commands/fleet-launch-command.ts b/packages/mosaic/src/commands/fleet-launch-command.ts index f718a3fb..3a9a8083 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -253,9 +253,12 @@ function cloneValue(value: unknown): unknown { return value; } +type MergeContext = 'root' | 'hooks' | 'nested'; + function mergeObject( lower: Record, higher: Record, + context: MergeContext = 'nested', ): Record { const result = cloneValue(lower) as Record; 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 | undefined> ): Record { return layers.reduce>( - (merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer)), + (merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer, 'root')), {}, ); } diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index b18d98be..c6e6b516 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -82,6 +82,7 @@ describe('registerFleetCommand', () => { expect(fleet).toBeDefined(); expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([ 'add', + 'agent', 'apply', 'backlog', 'create', diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index f2f48e2e..dbc27b41 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -6,6 +6,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { afterEach, describe, expect, test } from 'vitest'; import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js'; +import { deepMergeSettings } from '../commands/fleet-launch-command.js'; import { observeAndPromoteReceiptChallenge, requestBrokerReply, @@ -42,6 +43,19 @@ const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py') const revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py'); const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md'); const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json'); +const claudeLeaseOverlayPath = join(frameworkRoot, 'runtime/claude/lease-overlay.json'); + +// The gated seat contract is the COMPOSITION of the ungated base and the +// lease overlay (gap-7 split); assertions about lease wiring must read that +// composed view, produced by the same merge the launcher uses. +async function readGatedClaudeSettings(): Promise> { + const base = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as Record; + const overlay = JSON.parse(await readFile(claudeLeaseOverlayPath, 'utf8')) as Record< + string, + unknown + >; + return deepMergeSettings(base, overlay); +} const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts'); const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts'); const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh'); @@ -364,7 +378,7 @@ describe('whole mutator-class lease gate', () => { expect(parserResult.status).toBe(0); expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 }); - const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { + const settings = (await readGatedClaudeSettings()) as unknown as { hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> }; }; const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*'); @@ -660,7 +674,7 @@ describe('whole mutator-class lease gate', () => { }); test('Claude and Pi compaction observer wiring is complete and fail-closed', async () => { - const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { + const settings = (await readGatedClaudeSettings()) as unknown as { hooks: Record }>>; }; expect( @@ -956,7 +970,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_prese await promote(socket, sessionId, pending.receipt_challenge!); expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0); - const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { + const settings = (await readGatedClaudeSettings()) as unknown as { hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> }; }; expect(