feat(fleet): HARNESS-HOMES fleet MVP — profile-driven seat composition and launch (#1209) #1213

Open
mos-dt-0 wants to merge 11 commits from mos-dt-0/stack:feat/wf-fleet-mvp into next
4 changed files with 70 additions and 6 deletions
Showing only changes of commit 92e790ae9d - Show all commits
@@ -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(
@@ -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')),
{},
);
}
@@ -82,6 +82,7 @@ describe('registerFleetCommand', () => {
expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'add',
'agent',
'apply',
'backlog',
'create',
@@ -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(