diff --git a/packages/gittensory-miner/lib/coding-agent-house-rules.d.ts b/packages/gittensory-miner/lib/coding-agent-house-rules.d.ts new file mode 100644 index 000000000..b11877609 --- /dev/null +++ b/packages/gittensory-miner/lib/coding-agent-house-rules.d.ts @@ -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>> }>; +}; + +export function buildHouseRulesAgentSdkHooks(config?: HouseRulesConfig, options?: HouseRulesOptions): HouseRulesAgentSdkHooks; + +export function runHouseRulesEnforcedCodingAgentAttempt( + options: RunCodingAgentAttemptOptions & { houseRulesConfig?: HouseRulesConfig; houseRulesOptions?: HouseRulesOptions }, +): Promise<{ mode: CodingAgentExecutionMode; result: CodingAgentDriverResult & { lintGuard?: LintGuardResult } }>; diff --git a/packages/gittensory-miner/lib/coding-agent-house-rules.js b/packages/gittensory-miner/lib/coding-agent-house-rules.js new file mode 100644 index 000000000..30544d66a --- /dev/null +++ b/packages/gittensory-miner/lib/coding-agent-house-rules.js @@ -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[0]} [config] + * @param {Parameters[1]} [options] + * @returns {{ PreToolUse: Array<{ hooks: Array> }> }} + */ +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[0] & { + * houseRulesConfig?: Parameters[0], + * houseRulesOptions?: Parameters[1], + * }} options + * @returns {ReturnType} + */ +export function runHouseRulesEnforcedCodingAgentAttempt(options) { + const { houseRulesConfig, houseRulesOptions, ...attemptOptions } = options; + const hooks = attemptOptions.hooks ?? buildHouseRulesAgentSdkHooks(houseRulesConfig, houseRulesOptions); + return runCodingAgentAttempt({ ...attemptOptions, hooks }); +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 7ce87d07f..47d4491cd 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -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": "*" diff --git a/test/unit/miner-coding-agent-house-rules.test.ts b/test/unit/miner-coding-agent-house-rules.test.ts new file mode 100644 index 000000000..5006e6596 --- /dev/null +++ b/test/unit/miner-coding-agent-house-rules.test.ts @@ -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 { + 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[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[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; + 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[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[0] } = {}; + await runHouseRulesEnforcedCodingAgentAttempt({ + providerName: "agent-sdk", + task, + query: queryCapturing(captured), + houseRulesConfig: { repoFullName: "acme/widgets" }, + houseRulesOptions: { append }, + }); + + const hooks = captured.input!.options.hooks as ReturnType; + 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"); + }); +});