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
@@ -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<Record<string, unknown>> {
const base = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as Record<string, unknown>;
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<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
};
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(