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:
co-authored by
Claude Fable 5
parent
0fdcfa0ff4
commit
92e790ae9d
@@ -94,6 +94,36 @@ 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', () => {
|
||||||
|
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', () => {
|
it('deep-merges all three layers in precedence order', () => {
|
||||||
expect(
|
expect(
|
||||||
deepMergeSettings(
|
deepMergeSettings(
|
||||||
|
|||||||
@@ -253,9 +253,12 @@ 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)) {
|
||||||
@@ -264,20 +267,36 @@ 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(lowValue, highValue)
|
? mergeObject(
|
||||||
|
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 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(
|
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)),
|
(merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer, 'root')),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ describe('registerFleetCommand', () => {
|
|||||||
expect(fleet).toBeDefined();
|
expect(fleet).toBeDefined();
|
||||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||||
'add',
|
'add',
|
||||||
|
'agent',
|
||||||
'apply',
|
'apply',
|
||||||
'backlog',
|
'backlog',
|
||||||
'create',
|
'create',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
|||||||
import { afterEach, describe, expect, test } from 'vitest';
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
|
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
|
||||||
|
import { deepMergeSettings } from '../commands/fleet-launch-command.js';
|
||||||
import {
|
import {
|
||||||
observeAndPromoteReceiptChallenge,
|
observeAndPromoteReceiptChallenge,
|
||||||
requestBrokerReply,
|
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 revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py');
|
||||||
const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md');
|
const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md');
|
||||||
const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
|
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 piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts');
|
||||||
const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts');
|
const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts');
|
||||||
const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
|
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(parserResult.status).toBe(0);
|
||||||
expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 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 }> }> };
|
hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> };
|
||||||
};
|
};
|
||||||
const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*');
|
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 () => {
|
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 }> }>>;
|
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
|
||||||
};
|
};
|
||||||
expect(
|
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!);
|
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||||
expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0);
|
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 }> }> };
|
hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> };
|
||||||
};
|
};
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
Reference in New Issue
Block a user