Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/gittensory-miner/lib/coding-agent-house-rules.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { AgentSdkHooks, CodingAgentDriverResult, CodingAgentExecutionMode, LintGuardResult, RunCodingAgentAttemptOptions } from "@jsonbored/gittensory-engine";
import type { DenyRule } from "./deny-hooks.js";
import type { appendGovernorEvent } from "./governor-ledger.js";

export type HouseRulesConfig = {
rules?: readonly DenyRule[];
repoFullName?: string;
};

export type HouseRulesOptions = {
append?: typeof appendGovernorEvent;
};

/** The concrete shape {@link buildHouseRulesAgentSdkHooks} returns -- a single PreToolUse matcher group
* holding the one house-rules callback. Structurally assignable to the engine's opaque `AgentSdkHooks`. */
export type HouseRulesAgentSdkHooks = AgentSdkHooks & {
PreToolUse: Array<{ hooks: Array<(input: unknown, toolUseId?: string, context?: unknown) => Promise<Record<string, unknown>>> }>;
};

export function buildHouseRulesAgentSdkHooks(config?: HouseRulesConfig, options?: HouseRulesOptions): HouseRulesAgentSdkHooks;

export function runHouseRulesEnforcedCodingAgentAttempt(
options: RunCodingAgentAttemptOptions & { houseRulesConfig?: HouseRulesConfig; houseRulesOptions?: HouseRulesOptions },
): Promise<{ mode: CodingAgentExecutionMode; result: CodingAgentDriverResult & { lintGuard?: LintGuardResult } }>;
53 changes: 53 additions & 0 deletions packages/gittensory-miner/lib/coding-agent-house-rules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// House-rules-enforced coding-agent construction (#2343 follow-up). buildHouseRulesPreToolUseHook
// (pretooluse-hook.js) is the LIVE PreToolUse interception point, but the engine package's
// createCodingAgentDriver / runCodingAgentAttempt (driver-factory.ts) cannot import it directly -- the
// dependency only ever flows gittensory-miner -> @jsonbored/gittensory-engine, never the reverse (the engine
// package is portable and cannot depend on the miner CLI package). This module is the missing miner-side
// glue: it wraps runCodingAgentAttempt so the `agent-sdk` provider gets house-rule enforcement by DEFAULT --
// a future real call site does not need to remember to attach it itself, closing the exact gap
// buildHouseRulesPreToolUseHook's own doc comment already anticipated ("the live interception wiring itself").
//
// This does not build a CLI entrypoint -- nothing in this package constructs a coding-agent driver in
// production yet (verified: no caller of createCodingAgentDriver/runCodingAgentAttempt exists anywhere in
// packages/gittensory-miner today). That is separate, larger follow-up work. What this DOES guarantee: once
// such a call site exists, it only has to call `runHouseRulesEnforcedCodingAgentAttempt` (a drop-in
// replacement for the engine's own `runCodingAgentAttempt`) to get real, unbypassable house-rule enforcement
// automatically, rather than depending on that future author to remember to wire hooks by hand.

import { runCodingAgentAttempt } from "@jsonbored/gittensory-engine";
import { buildHouseRulesPreToolUseHook } from "./pretooluse-hook.js";

/**
* Wrap {@link buildHouseRulesPreToolUseHook}'s callback into the Claude Agent SDK's own `hooks.PreToolUse`
* registration shape (an array of matcher groups, each holding an array of hook callbacks) -- the exact
* contract `agent-sdk-driver.ts`'s own doc comment names as "#2343's stated attachment point", and the shape
* `packages/gittensory-engine/test/agent-sdk-driver.test.ts` asserts is forwarded to the SDK verbatim.
*
* @param {Parameters<typeof buildHouseRulesPreToolUseHook>[0]} [config]
* @param {Parameters<typeof buildHouseRulesPreToolUseHook>[1]} [options]
* @returns {{ PreToolUse: Array<{ hooks: Array<ReturnType<typeof buildHouseRulesPreToolUseHook>> }> }}
*/
export function buildHouseRulesAgentSdkHooks(config = {}, options = {}) {
return { PreToolUse: [{ hooks: [buildHouseRulesPreToolUseHook(config, options)] }] };
}

/**
* Drop-in replacement for the engine's `runCodingAgentAttempt` that defaults `hooks` to
* {@link buildHouseRulesAgentSdkHooks} for the `agent-sdk` provider, so house-rule enforcement (#2343) is ON
* by default rather than opt-in. An explicitly-supplied `hooks` option always wins (e.g. a test injecting its
* own hook double, or a caller composing additional hooks of its own) -- this only fills the gap when the
* caller omitted it entirely. Providers other than `agent-sdk` (`noop`, `claude-cli`, `codex-cli`) ignore
* `hooks` entirely (only the in-process SDK session has a hook-registration concept), so defaulting it for
* them is inert, never an error.
*
* @param {Parameters<typeof runCodingAgentAttempt>[0] & {
* houseRulesConfig?: Parameters<typeof buildHouseRulesPreToolUseHook>[0],
* houseRulesOptions?: Parameters<typeof buildHouseRulesPreToolUseHook>[1],
* }} options
* @returns {ReturnType<typeof runCodingAgentAttempt>}
*/
export function runHouseRulesEnforcedCodingAgentAttempt(options) {
const { houseRulesConfig, houseRulesOptions, ...attemptOptions } = options;
const hooks = attemptOptions.hooks ?? buildHouseRulesAgentSdkHooks(houseRulesConfig, houseRulesOptions);
return runCodingAgentAttempt({ ...attemptOptions, hooks });
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"expected-engine.version"
],
"scripts": {
"build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js"
"build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js"
},
"dependencies": {
"@jsonbored/gittensory-engine": "*"
Expand Down
132 changes: 132 additions & 0 deletions test/unit/miner-coding-agent-house-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from "vitest";

vi.mock("@jsonbored/gittensory-engine", async () => {
return import("../../packages/gittensory-engine/src/index");
});

import { buildHouseRulesAgentSdkHooks, runHouseRulesEnforcedCodingAgentAttempt } from "../../packages/gittensory-miner/lib/coding-agent-house-rules.js";
// Typed from source, not the "@jsonbored/gittensory-engine" package specifier: that resolves via the
// workspace package's dist/ (git-ignored, only built by CI's later "Build engine package" step -- #ci-engine-
// build-order), which runs AFTER Typecheck, so a real (non-vi.mock) `import type` from the package specifier
// fails TS2307 in CI even though it resolves fine locally with a stale/leftover dist/ already on disk. The
// vi.mock above already redirects the package specifier to this exact source file at runtime; importing
// types from the same source path keeps both resolutions consistent and needs no build step at all.
import type { AgentSdkQueryFn, CodingAgentDriverTask } from "../../packages/gittensory-engine/src/index";

// buildHouseRulesPreToolUseHook's own deny-rule matching logic (matcher, glob, path-tokenizing, force-push
// detection) is already exhaustively tested in miner-pretooluse-hook.test.ts. These tests cover only this
// module's own job: wrapping that hook into the SDK's registration shape, and defaulting `hooks` for
// runCodingAgentAttempt's `agent-sdk` provider without overriding a caller-supplied value.

const task: CodingAgentDriverTask = {
attemptId: "attempt-1",
workingDirectory: "/tmp/worktrees/attempt-1",
acceptanceCriteriaPath: "/tmp/worktrees/attempt-1/ACCEPTANCE-CRITERIA.md",
instructions: "Apply the fix described in ACCEPTANCE-CRITERIA.md.",
maxTurns: 4,
};

function assistantResult(): Record<string, unknown> {
return { type: "result", subtype: "success", is_error: false, num_turns: 1, result: "done" };
}

/** A fake AgentSdkQueryFn that captures its own call input (mirrors agent-sdk-driver.test.ts's own helper),
* so a test can assert on exactly what `hooks` shape reached the SDK session. */
function queryCapturing(captured: { input?: Parameters<AgentSdkQueryFn>[0] }): AgentSdkQueryFn {
return (input) => {
captured.input = input;
return (async function* () {
yield assistantResult();
})();
};
}

describe("buildHouseRulesAgentSdkHooks (#2343 follow-up)", () => {
it("wraps the house-rules hook in the SDK's documented PreToolUse matcher-group shape", () => {
const hooks = buildHouseRulesAgentSdkHooks();
expect(Object.keys(hooks)).toEqual(["PreToolUse"]);
expect(hooks.PreToolUse).toHaveLength(1);
expect(hooks.PreToolUse[0]!.hooks).toHaveLength(1);
expect(typeof hooks.PreToolUse[0]!.hooks[0]).toBe("function");
});

it("the wrapped callback actually enforces the house-rule denylist (not just a matching shape)", async () => {
const hooks = buildHouseRulesAgentSdkHooks();
const callback = hooks.PreToolUse[0]!.hooks[0]!;
const denied = await callback({ tool_name: "Read", tool_input: { file_path: ".env" } });
expect(denied).toMatchObject({ hookSpecificOutput: { permissionDecision: "deny" } });
const allowed = await callback({ tool_name: "Read", tool_input: { file_path: "src/index.ts" } });
expect(allowed).toEqual({});
});

it("threads config.repoFullName and options.append through to the underlying hook", async () => {
const append = vi.fn();
const hooks = buildHouseRulesAgentSdkHooks({ repoFullName: "acme/widgets" }, { append });
const callback = hooks.PreToolUse[0]!.hooks[0]!;
await callback({ tool_name: "Read", tool_input: { file_path: ".env" } });
expect(append).toHaveBeenCalledWith(expect.objectContaining({ repoFullName: "acme/widgets" }));
});
});

describe("runHouseRulesEnforcedCodingAgentAttempt (#2343 follow-up)", () => {
it("defaults hooks to house-rules enforcement for the agent-sdk provider when the caller omits it", async () => {
const captured: { input?: Parameters<AgentSdkQueryFn>[0] } = {};
const result = await runHouseRulesEnforcedCodingAgentAttempt({
providerName: "agent-sdk",
task,
query: queryCapturing(captured),
});

expect(result.mode).toBe("live");
expect(result.result.ok).toBe(true);
const hooks = captured.input!.options.hooks as ReturnType<typeof buildHouseRulesAgentSdkHooks>;
expect(Object.keys(hooks)).toEqual(["PreToolUse"]);
// Prove it's a REAL, enforcing hook, not an empty placeholder shape.
const callback = hooks.PreToolUse[0]!.hooks[0]!;
const denied = await callback({ tool_name: "Read", tool_input: { file_path: ".env" } });
expect(denied).toMatchObject({ hookSpecificOutput: { permissionDecision: "deny" } });
});

it("does NOT override a caller-supplied hooks option", async () => {
const captured: { input?: Parameters<AgentSdkQueryFn>[0] } = {};
const callerHooks = { PreToolUse: [{ hooks: ["caller-supplied-marker"] }] };
await runHouseRulesEnforcedCodingAgentAttempt({
providerName: "agent-sdk",
task,
query: queryCapturing(captured),
hooks: callerHooks,
});

expect(captured.input!.options.hooks).toBe(callerHooks);
});

it("threads houseRulesConfig/houseRulesOptions into the defaulted hook without leaking them to the driver factory", async () => {
const append = vi.fn();
const captured: { input?: Parameters<AgentSdkQueryFn>[0] } = {};
await runHouseRulesEnforcedCodingAgentAttempt({
providerName: "agent-sdk",
task,
query: queryCapturing(captured),
houseRulesConfig: { repoFullName: "acme/widgets" },
houseRulesOptions: { append },
});

const hooks = captured.input!.options.hooks as ReturnType<typeof buildHouseRulesAgentSdkHooks>;
await hooks.PreToolUse[0]!.hooks[0]!({ tool_name: "Read", tool_input: { file_path: ".env" } });
expect(append).toHaveBeenCalledWith(expect.objectContaining({ repoFullName: "acme/widgets" }));
});

it("is inert (no error) for a provider that ignores hooks entirely", async () => {
const result = await runHouseRulesEnforcedCodingAgentAttempt({ providerName: "noop", task });
expect(result.result.ok).toBe(true);
});

it("a paused/dry-run attempt never constructs a driver, so hooks default harmlessly without ever being consulted", async () => {
const result = await runHouseRulesEnforcedCodingAgentAttempt({
providerName: "agent-sdk",
task,
agentPaused: true,
});
expect(result.mode).toBe("paused");
});
});