From d023cdc9557435d1368b4a1d7d59741701582764 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 9 Jul 2026 21:55:44 -0300 Subject: [PATCH] feat(stage7b): dual-target hook engine (shared logic core + CC hooks + opencode plugin) Stage 7b of the harness-upgrade plan: enforceable invariants written ONCE, tool-neutrally, and enforced in BOTH Claude Code and opencode off one shared logic core. No SDLC policy is a constant anywhere in the engine; it all comes from hooks.policy.json at runtime, which is why the extended genericity gate can guarantee zero leakage. Shared logic core (core/hooks/logic/, pure + dependency-free): - git-action.mjs evaluateGitCommand(command, policy, ctx): protected-ref push (explicit + bare-on-protected via injected resolver), the git bypass flags, no-gpg-sign, branch-name pattern (+ exceptions), ticket ref, forbidden trailers, large-binary add. Every literal comes from policy; none is baked in. - content-scan.mjs scanContent(text, policy, filePath) + checkLargeBinary: em dash (numeric en-dash allowed), banned phrases, secrets (conservative universal default set + project extras), per-rule path scopes/exceptions. - proactivity.mjs reminder line + judge-prompt builder + verdict parser + transcript-turn slicer + skill-digest loader (the pure guard building blocks). - payload.mjs normalize a Claude Code OR opencode payload into one neutral record so the logic never learns a tool's field names. - scope.mjs dependency-free glob->RegExp (**, *, ?, {a,b}) + path-scope match. - config.mjs loadPolicy(startDir): DEFAULT_POLICY < global < project (JSON only, no YAML at runtime). Defaults are universally-safe only. Template library (core/hooks/templates.mjs): 11 parameterized invariants with config bindings, defaults, path-scope/exception support, and a gateTable() for the (later) decomposition gate. Two thin adapters off the one core: - adapters/claude-code/hooks/: git-guardrails, content-guard, proactivity- reminder (UserPromptSubmit), proactivity-guard (Stop, model-configurable), + settings.hooks.json wiring snippet. Read stdin -> logic -> exit 2 / block. - adapters/opencode/plugin/agentic-harness.js: tool.execute.before -> throw (FULL enforcement parity), event/session.idle guard (best-effort; opencode cannot rewind a finished turn). Core resolved via the runtime project directory (bundling-proof) with import.meta.url fallbacks. Stack-detected lint/analyzer generators (core/hooks/lint-generators/): detect + eslint/roslyn/ruff generators + strongestLayerFor() routing a code-level policy to a real linter rule, content-scan hook only as fallback. Folded in (deferred from 7a): adapters/claude-code/workflows/audit.mjs - the genericized Workflow-audit accelerator, all repos/surfaces/ladders/checklists driven by args (Workflow scripts have no fs/Node access), zero project constants. Wiring: - check-genericity.mjs now also scans the authored engine adapters. - gen-adapters.mjs scopes its wipe to skills/+agents/ so the authored engine dirs survive regeneration. - install.mjs vendors the engine dirs (hooks/workflows -> .claude, plugin -> .opencode) beside the vendored core. - CI runs check-genericity + the hook-engine proof + an adapters-in-sync check. - .gitattributes pins engine files to LF; hooks.policy.example.json + a hooks: block in workflow.config.example.yaml document the policy surface; README + core/hooks/README.md document the engine. Proof (scripts/test-hook-engine.mjs, CI-gated): 42 assertions. Vendors the pack into a temp project and runs the REAL CC hook AND the REAL opencode plugin - a push to a protected branch is blocked by both, a feature-branch push allowed by both, an em dash caught by both - proving the dual-target wiring end to end over one logic core. This starts 7h (the paired Orbit .opencode/plugin port lands as a separate cross-linked orbit-ui-mobile PR). Stacked on the 7a branch (chore/stage7a-purge-codex-refresh-core, unmerged); retarget to main once 7a merges. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitattributes | 10 + .github/workflows/genericity-check.yml | 10 +- README.md | 53 +++-- adapters/claude-code/hooks/content-guard.mjs | 58 +++++ adapters/claude-code/hooks/git-guardrails.mjs | 59 +++++ .../claude-code/hooks/proactivity-guard.mjs | 121 ++++++++++ .../hooks/proactivity-reminder.mjs | 40 ++++ .../claude-code/hooks/settings.hooks.json | 27 +++ adapters/claude-code/workflows/audit.mjs | 223 ++++++++++++++++++ adapters/opencode/plugin/agentic-harness.js | 96 ++++++++ core/hooks/README.md | 79 +++++++ core/hooks/lint-generators/detect.mjs | 65 +++++ core/hooks/lint-generators/eslint.mjs | 43 ++++ core/hooks/lint-generators/index.mjs | 37 +++ core/hooks/lint-generators/roslyn.mjs | 32 +++ core/hooks/lint-generators/ruff.mjs | 26 ++ core/hooks/logic/config.mjs | 91 +++++++ core/hooks/logic/content-scan.mjs | 104 ++++++++ core/hooks/logic/git-action.mjs | 162 +++++++++++++ core/hooks/logic/payload.mjs | 46 ++++ core/hooks/logic/proactivity.mjs | 154 ++++++++++++ core/hooks/logic/scope.mjs | 68 ++++++ core/hooks/templates.mjs | 196 +++++++++++++++ hooks.policy.example.json | 47 ++++ scripts/check-genericity.mjs | 24 +- scripts/gen-adapters.mjs | 16 +- scripts/install.mjs | 18 +- scripts/test-hook-engine.mjs | 140 +++++++++++ workflow.config.example.yaml | 60 ++++- 29 files changed, 2074 insertions(+), 31 deletions(-) create mode 100644 .gitattributes create mode 100644 adapters/claude-code/hooks/content-guard.mjs create mode 100644 adapters/claude-code/hooks/git-guardrails.mjs create mode 100644 adapters/claude-code/hooks/proactivity-guard.mjs create mode 100644 adapters/claude-code/hooks/proactivity-reminder.mjs create mode 100644 adapters/claude-code/hooks/settings.hooks.json create mode 100644 adapters/claude-code/workflows/audit.mjs create mode 100644 adapters/opencode/plugin/agentic-harness.js create mode 100644 core/hooks/README.md create mode 100644 core/hooks/lint-generators/detect.mjs create mode 100644 core/hooks/lint-generators/eslint.mjs create mode 100644 core/hooks/lint-generators/index.mjs create mode 100644 core/hooks/lint-generators/roslyn.mjs create mode 100644 core/hooks/lint-generators/ruff.mjs create mode 100644 core/hooks/logic/config.mjs create mode 100644 core/hooks/logic/content-scan.mjs create mode 100644 core/hooks/logic/git-action.mjs create mode 100644 core/hooks/logic/payload.mjs create mode 100644 core/hooks/logic/proactivity.mjs create mode 100644 core/hooks/logic/scope.mjs create mode 100644 core/hooks/templates.mjs create mode 100644 hooks.policy.example.json create mode 100644 scripts/test-hook-engine.mjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9b46678 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Executable engine files (hooks, plugins, Workflow scripts, logic core) must be +# LF on every platform: Node/Bun run them directly, and the Claude Code Workflow +# tool rejects CR as a control character. Normalize regardless of core.autocrlf. +*.mjs text eol=lf +*.js text eol=lf +*.cjs text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yaml text eol=lf +*.yml text eol=lf diff --git a/.github/workflows/genericity-check.yml b/.github/workflows/genericity-check.yml index d9f1a14..9364d6e 100644 --- a/.github/workflows/genericity-check.yml +++ b/.github/workflows/genericity-check.yml @@ -6,12 +6,18 @@ on: pull_request: jobs: - check-genericity: + check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - - name: core/ must contain no project-specific strings + - name: core/ + engine adapters must contain no project-specific strings run: node scripts/check-genericity.mjs + - name: dual-target hook engine proof (logic core + vendored CC hook + opencode plugin) + run: node scripts/test-hook-engine.mjs + - name: skill/agent adapters are in sync with the manifest + run: | + node scripts/gen-adapters.mjs + git diff --exit-code -- adapters || (echo "adapters/ is stale — run node scripts/gen-adapters.mjs and commit" && exit 1) diff --git a/README.md b/README.md index b3f1c97..5b84ae3 100644 --- a/README.md +++ b/README.md @@ -48,19 +48,36 @@ baseline that ships in `core/_shared/`. ## Architecture ``` -core/ ← tool-agnostic skill bodies — the SINGLE source of truth +core/ ← tool-agnostic skill bodies + hook logic — the SINGLE source of truth _shared/ ← verification-protocol + behavioral-baseline (read by many skills) pipeline/ review/ intake/ research/ ops/ meta/ agents/ -adapters/ ← thin per-tool wrappers; each points at a core/ body, no logic - claude-code/ ← .claude/skills + .claude/agents - opencode/ ← .opencode/skills + .opencode/agents -scripts/ ← install / sync / gen-adapters / check-genericity -workflow.config.example.yaml ← the config schema (copy → workflow.config.yaml) + hooks/ ← the dual-target hook engine: logic/ + templates.mjs + lint-generators/ +adapters/ ← per-tool wrappers off the one core; no logic duplicated + claude-code/ ← .claude/skills + .claude/agents (generated) · hooks/ + workflows/ (authored engine) + opencode/ ← .opencode/skills + .opencode/agents (generated) · plugin/ (authored engine) +scripts/ ← install / sync / gen-adapters / check-genericity / test-hook-engine +workflow.config.example.yaml ← config schema (copy → workflow.config.yaml) +hooks.policy.example.json ← hook-policy schema (setup writes hooks.policy.json) ``` -Each adapter wrapper is a few lines: the tool's required frontmatter plus a pointer to -the matching `core/**` body. Logic is never duplicated across tools — fix a skill once -in `core/` and every adapter inherits it. +Each **skill/agent** wrapper is a few lines: the tool's required frontmatter plus a +pointer to the matching `core/**` body — generated from a manifest. Each **hook** +adapter is a thin shell that imports the shared logic core and translates one tool's +block mechanism. Either way, logic lives once — fix it in `core/` and every tool inherits it. + +## Enforcement — dual-target hook engine + +Skills are procedures; some rules must be **enforced**, not suggested. Every enforceable +invariant is written once in `core/hooks/logic/` and enforced in **both** Claude Code (a +`.mjs` hook, `exit 2` / `decision:block`) and opencode (a plugin, `tool.execute.before` → +throw) off that one core — no twin drift. The library covers git actions (branch name, +protected ref, ticket ref, no `--no-verify`, forbidden trailers, large binaries), content +(em dash, banned phrases, secrets), and the proactivity guard (a re-injected reminder + +a model-configurable turn review). Code-level policies route to a **real ESLint / Roslyn / +ruff rule** where the stack supports it; the content hook is the fallback. All policy lives +in `hooks.policy.json` (JSON, zero runtime deps) — **no SDLC constant is baked in**, so a +machine that bans an authorship trailer and one that requires it are one field apart. See +`core/hooks/README.md`; `node scripts/test-hook-engine.mjs` proves it end to end. ## Install @@ -130,9 +147,10 @@ it. The pack never requires hooks. ## Genericity guarantee -`core/**` must never contain a project-specific string **or** a hardcoded SDLC policy -constant. CI runs `node scripts/check-genericity.mjs`, which fails the build on two classes -of leak: +`core/**` and the authored engine adapters (`adapters/*/hooks`, `adapters/*/plugin`, +`adapters/claude-code/workflows`) must never contain a project-specific string **or** a +hardcoded SDLC policy constant. CI runs `node scripts/check-genericity.mjs`, which fails the +build on two classes of leak: 1. **Project strings** — any project name, brand, author handle, or absolute machine path. 2. **Policy constants** — a `Co-Authored-By` commit trailer, the `gh` tracker CLI, a @@ -145,10 +163,13 @@ This is what keeps the pack reusable — project specifics *and* git policy belo ## Maintaining the pack -- Skill logic lives once in `core/`. Edit there; every adapter inherits the change. -- The adapters are generated from a manifest — after changing the skill roster, run - `node scripts/gen-adapters.mjs` and commit the regenerated `adapters/` tree. -- `node scripts/check-genericity.mjs` must stay green; CI runs it on every push and PR. +- Skill + hook logic lives once in `core/`. Edit there; every adapter inherits the change. +- The **skill/agent** adapters are generated from a manifest — after changing the roster, + run `node scripts/gen-adapters.mjs` and commit the regenerated `skills/` + `agents/` trees. + The **hook engine** adapters (`hooks/`, `plugin/`, `workflows/`) are authored shells and are + preserved across regeneration — edit them directly. +- `node scripts/check-genericity.mjs` and `node scripts/test-hook-engine.mjs` must stay green; + CI runs both on every push and PR. ## License diff --git a/adapters/claude-code/hooks/content-guard.mjs b/adapters/claude-code/hooks/content-guard.mjs new file mode 100644 index 0000000..2f2f022 --- /dev/null +++ b/adapters/claude-code/hooks/content-guard.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +// Claude Code PostToolUse(Edit|Write|MultiEdit) adapter for the content-scan +// logic core: scans the text THIS edit introduced (never the whole file) for +// em dashes, banned phrases, and secrets, plus a large-binary guard on Write. +// Thin: normalize -> scanContent -> exit 2 + stderr on a finding. Policy lives +// in hooks.policy.json; logic in core/hooks/logic/content-scan.mjs. +// +// Fail-open: any error exits 0. + +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; +import { Buffer } from "node:buffer"; + +const here = dirname(fileURLToPath(import.meta.url)); + +async function core(mod) { + for (const rel of ["../skills/_core/hooks/logic", "../../../core/hooks/logic"]) { + const p = join(here, rel, mod); + if (existsSync(p)) return import(pathToFileURL(p).href); + } + throw new Error(`hook logic core not found: ${mod}`); +} + +try { + let input; + try { + input = JSON.parse(readFileSync(0, "utf8")); + } catch { + process.exit(0); + } + + const { fromClaudeCode } = await core("payload.mjs"); + const event = fromClaudeCode(input); + if (event.kind !== "edit" || !event.filePath) process.exit(0); + + const { loadPolicy } = await core("config.mjs"); + const { scanContent, checkLargeBinary } = await core("content-scan.mjs"); + + const policy = loadPolicy(event.cwd || process.cwd()); + const findings = []; + if (event.addedText) findings.push(...scanContent(event.addedText, policy.content, event.filePath)); + if (event.tool === "Write" && typeof event.addedText === "string") { + const big = checkLargeBinary(event.filePath, Buffer.byteLength(event.addedText, "utf8"), policy.content.largeBinary); + if (big) findings.push(big); + } + + if (findings.length === 0) process.exit(0); + + process.stderr.write( + `Content policy violation in ${event.filePath}:\n` + + findings.map((f) => ` - [${f.rule}] ${f.message}${f.snippet ? ` — "…${f.snippet}…"` : ""}`).join("\n") + + `\n\nFix the flagged content (rename/restructure, remove the secret, or move a large asset to the configured storage).\n`, + ); + process.exit(2); +} catch { + process.exit(0); +} diff --git a/adapters/claude-code/hooks/git-guardrails.mjs b/adapters/claude-code/hooks/git-guardrails.mjs new file mode 100644 index 0000000..2656eca --- /dev/null +++ b/adapters/claude-code/hooks/git-guardrails.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// Claude Code PreToolUse(Bash) adapter for the git-action logic core. Thin by +// design: read the payload, normalize it, ask the shared logic for a verdict, +// translate a block to `exit 2` + stderr. All policy lives in hooks.policy.json; +// all logic lives in core/hooks/logic/git-action.mjs — this file only wires the +// Claude Code I/O contract to it. The opencode plugin wires the SAME core to its +// own contract, so a fix to the rule lands in both tools at once. +// +// Fail-open: any error exits 0 so the hook never wedges the Bash tool. + +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); + +// The logic core is vendored to .claude/skills/_core/ next to this hook when +// installed, and lives in core/ in the pack repo. Probe both. +async function core(mod) { + for (const rel of ["../skills/_core/hooks/logic", "../../../core/hooks/logic"]) { + const p = join(here, rel, mod); + if (existsSync(p)) return import(pathToFileURL(p).href); + } + throw new Error(`hook logic core not found: ${mod}`); +} + +try { + let input; + try { + input = JSON.parse(readFileSync(0, "utf8")); + } catch { + process.exit(0); + } + + const { fromClaudeCode } = await core("payload.mjs"); + const event = fromClaudeCode(input); + if (event.kind !== "git" || !event.command) process.exit(0); + + const { loadPolicy } = await core("config.mjs"); + const { evaluateGitCommand } = await core("git-action.mjs"); + + const policy = loadPolicy(event.cwd || process.cwd()); + const resolveHeadBranch = (dir) => + execFileSync("git", ["-C", dir || event.cwd || process.cwd(), "rev-parse", "--abbrev-ref", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + + const verdict = evaluateGitCommand(event.command, policy.git, { resolveHeadBranch, cwd: event.cwd || process.cwd() }); + if (verdict?.blocked) { + process.stderr.write(`BLOCKED git command (${verdict.rule}):\n ${event.command}\n\n${verdict.reason}\n`); + process.exit(2); + } + process.exit(0); +} catch { + process.exit(0); +} diff --git a/adapters/claude-code/hooks/proactivity-guard.mjs b/adapters/claude-code/hooks/proactivity-guard.mjs new file mode 100644 index 0000000..69224b6 --- /dev/null +++ b/adapters/claude-code/hooks/proactivity-guard.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Claude Code Stop adapter — Layer 2 of the proactivity guard. A cheap judge +// model (policy.proactivity.guard.judgeModel) reviews the finished turn and +// sends it back when it CLEARLY took a shortcut a cheaper/more-correct action +// beat. The prompt + verdict parsing live in the logic core; this file wires the +// Stop-hook I/O and the model call. +// +// Invariants: fail-OPEN on any error/ambiguity; no loops (stop_hook_active + a +// per-message marker); no recursion (the inner `claude -p` sets a guard env so +// its own Stop hook short-circuits); empty judgeModel disables Layer 2 (never +// pin a model that may retire). + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const GUARD_ENV = "AGENTIC_PROACTIVITY_GUARD"; +const STATE_PATH = path.join(os.homedir(), ".claude", ".agentic-proactivity-guard-state.json"); + +function allow() { + process.exit(0); +} + +async function core(mod) { + for (const rel of ["../skills/_core/hooks/logic", "../../../core/hooks/logic"]) { + const p = path.join(here, rel, mod); + if (existsSync(p)) return import(pathToFileURL(p).href); + } + throw new Error(`hook logic core not found: ${mod}`); +} + +async function main() { + if (process.env[GUARD_ENV]) allow(); + + let input = {}; + try { + input = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); + } catch { + allow(); + } + if (input.stop_hook_active) allow(); + + const { loadPolicy } = await core("config.mjs"); + const policy = loadPolicy(input.cwd || process.cwd()); + const guard = policy.proactivity?.guard; + if (!guard?.enabled || !guard.judgeModel) allow(); + + const transcriptPath = input.transcript_path; + if (!transcriptPath || !fs.existsSync(transcriptPath)) allow(); + + let records = []; + try { + records = fs + .readFileSync(transcriptPath, "utf8") + .split(/\r?\n/) + .filter(Boolean) + .map((l) => { + try { + return JSON.parse(l); + } catch { + return null; + } + }) + .filter(Boolean); + } catch { + allow(); + } + + const { extractLatestTurn, loadSkillDigest, buildJudgePrompt, parseVerdict, LIMITS } = await core("proactivity.mjs"); + const turn = extractLatestTurn(records); + if (!turn || turn.assistantText.length < LIMITS.MIN_CHARS) allow(); + + let state = {}; + try { + state = JSON.parse(fs.readFileSync(STATE_PATH, "utf8")); + } catch { + state = {}; + } + if (turn.finalUuid && state.lastBlockedUuid === turn.finalUuid) allow(); + + const skillDirs = guard.skillDirs?.length + ? guard.skillDirs + : [path.join(input.cwd || ".", ".claude", "skills"), path.join(os.homedir(), ".claude", "skills")]; + const judgePrompt = buildJudgePrompt({ + userPrompt: turn.userPrompt, + toolsUsed: turn.toolsUsed, + assistantText: turn.assistantText, + skillDigest: loadSkillDigest(skillDirs), + }); + + const res = spawnSync("claude", ["-p", "--model", guard.judgeModel, "--strict-mcp-config"], { + input: judgePrompt, + encoding: "utf8", + timeout: 45000, + shell: true, + env: { ...process.env, [GUARD_ENV]: "1" }, + }); + if (res.status !== 0 || !res.stdout) allow(); + + const verdict = parseVerdict(res.stdout); + if (!verdict) allow(); + + try { + fs.writeFileSync(STATE_PATH, JSON.stringify({ lastBlockedUuid: turn.finalUuid })); + } catch { + /* marker is best-effort */ + } + + const reason = + "[proactivity guard] " + + verdict.reason.trim() + + " Do or verify it now (or invoke the matching skill) before ending the turn; if this is a false positive, state why in one line and continue."; + process.stdout.write(JSON.stringify({ decision: "block", reason })); + process.exit(0); +} + +main().catch(() => process.exit(0)); diff --git a/adapters/claude-code/hooks/proactivity-reminder.mjs b/adapters/claude-code/hooks/proactivity-reminder.mjs new file mode 100644 index 0000000..00a9ffd --- /dev/null +++ b/adapters/claude-code/hooks/proactivity-reminder.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// Claude Code UserPromptSubmit adapter — Layer 1 of the proactivity guard. +// Re-injects one high-salience disposition line every turn (verify/do over +// guess/ask/improvise). The line lives in the logic core so the opencode side +// shares it. Off when policy.proactivity.reminder.enabled is false. + +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); + +async function core(mod) { + for (const rel of ["../skills/_core/hooks/logic", "../../../core/hooks/logic"]) { + const p = join(here, rel, mod); + if (existsSync(p)) return import(pathToFileURL(p).href); + } + throw new Error(`hook logic core not found: ${mod}`); +} + +try { + let input = {}; + try { + input = JSON.parse(readFileSync(0, "utf8")); + } catch { + input = {}; + } + + const { loadPolicy } = await core("config.mjs"); + const { REMINDER_LINE } = await core("proactivity.mjs"); + const policy = loadPolicy(input.cwd || process.cwd()); + if (!policy.proactivity?.reminder?.enabled) process.exit(0); + + process.stdout.write( + JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: REMINDER_LINE } }), + ); + process.exit(0); +} catch { + process.exit(0); +} diff --git a/adapters/claude-code/hooks/settings.hooks.json b/adapters/claude-code/hooks/settings.hooks.json new file mode 100644 index 0000000..9c715ec --- /dev/null +++ b/adapters/claude-code/hooks/settings.hooks.json @@ -0,0 +1,27 @@ +{ + "_comment": "Merge this `hooks` block into your project's .claude/settings.json to wire the dual-target engine's Claude Code side. setup-harness / bootstrap merge it for you; shown here so the wiring is inspectable. The opencode side needs no wiring — it auto-loads from .opencode/plugin/.", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/git-guardrails.mjs\"" }] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [{ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/content-guard.mjs\"" }] + } + ], + "UserPromptSubmit": [ + { + "hooks": [{ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/proactivity-reminder.mjs\"" }] + } + ], + "Stop": [ + { + "hooks": [{ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/proactivity-guard.mjs\"" }] + } + ] + } +} diff --git a/adapters/claude-code/workflows/audit.mjs b/adapters/claude-code/workflows/audit.mjs new file mode 100644 index 0000000..f8c871d --- /dev/null +++ b/adapters/claude-code/workflows/audit.mjs @@ -0,0 +1,223 @@ +export const meta = { + name: 'audit', + description: 'Generic repo-wide audit engine — cheap-model fan-out per surface + adversarial verify (default refuted) + loop-until-dry; returns verified findings for the driver model to synthesize. All project specifics (repos, surfaces, ladders, checklists) arrive via args, so this script is tool-runtime code but carries ZERO project constants.', + phases: [ + { title: 'Find', detail: 'one finder per surface, scoped by kind' }, + { title: 'Verify', detail: 'one skeptic per serious finding — default refuted' }, + { title: 'Complete', detail: 'completeness critic + gap finders, loop until dry' }, + ], +} + +// ----------------------------------------------------------------------------- +// This is a Claude-Code Workflow-tool asset (NOT in tool-agnostic core/). Workflow +// scripts have no filesystem/Node access, so the CONSUMING skill (audit-*, driven by +// the pack's verification-protocol) reads workflow.config.yaml + the audit checklist, +// assembles the payload below, and invokes Workflow({ scriptPath, args }). Shape: +// { +// kind, scope, // scope: a repo role/name, a path, or 'both' +// repos: [{ name, path, role }], // from config.repos[] +// cheapModel?: 'haiku', // config.execution.cheapSubagentModel; omit -> inherit +// verifyCap?, hardRounds?, maxDryRounds?, +// exclude?: 'Exclude generated/vendored dirs ...', +// calibration?: 'Calibrate to ...', // config.auditAnchors.scale-derived +// kinds: { // one entry per audit kind the skill supports +// : { +// ladder, rationale, checklist, // checklist = path the finder reads FIRST +// extra?, // kind-specific finder instructions +// surfaces: [{ label, where, repos?, sections? }], +// }, +// }, +// } +// ----------------------------------------------------------------------------- + +const cfg = typeof args === 'string' ? JSON.parse(args) : args || {} +const kind = cfg.kind +const scope = cfg.scope || 'both' +const repos = Array.isArray(cfg.repos) ? cfg.repos : [] +const kinds = cfg.kinds || {} +const VERIFY_CAP = cfg.verifyCap ?? 60 +const HARD_ROUNDS = cfg.hardRounds ?? 4 +const maxDry = cfg.maxDryRounds ?? 2 +const cheapModel = cfg.cheapModel || undefined +const EXCLUDE = cfg.exclude || 'Exclude generated/vendored dirs (node_modules, dist, build, bin, obj, coverage, and any lockfiles or migrations except when read to confirm a claim).' +const CALIBRATION = cfg.calibration || 'Calibrate severity to the project scale — never inflate severity to look thorough; when uncertain, pick the lower tier with a "verify" note.' + +if (!kinds[kind]) throw new Error(`audit workflow: unknown kind "${kind}" (configured: ${Object.keys(kinds).join(', ') || 'none'})`) +const kc = kinds[kind] + +const FINDINGS_SCHEMA = { + type: 'object', additionalProperties: false, + properties: { + findings: { + type: 'array', + items: { + type: 'object', additionalProperties: false, + properties: { + severity: { type: 'string' }, title: { type: 'string' }, category: { type: 'string' }, + location: { type: 'string' }, evidence: { type: 'string' }, rationale: { type: 'string' }, + fix: { type: 'string' }, reference: { type: 'string' }, + }, + required: ['severity', 'title', 'location', 'evidence', 'fix'], + }, + }, + }, + required: ['findings'], +} +const VERDICT_SCHEMA = { + type: 'object', additionalProperties: false, + properties: { refuted: { type: 'boolean' }, note: { type: 'string' }, adjustedSeverity: { type: 'string' } }, + required: ['refuted', 'note'], +} +const CRITIC_SCHEMA = { + type: 'object', additionalProperties: false, + properties: { + gaps: { + type: 'array', + items: { type: 'object', additionalProperties: false, properties: { label: { type: 'string' }, prompt: { type: 'string' } }, required: ['label', 'prompt'] }, + }, + }, + required: ['gaps'], +} + +const repoRoots = repos.map((r) => `${r.name} (${r.role || 'repo'}): ${r.path}`).join(' · ') || '(no repo roots provided)' +const roleMatches = (surface, scopeVal) => { + const want = String(scopeVal).toLowerCase() + const sr = (surface.repos || 'both').toLowerCase() + if (sr === 'both') return true + return sr.split(/[\s,/]+/).includes(want) +} +function scopeLabel(scopeVal) { + if (!scopeVal || scopeVal === 'both') return repos.map((r) => r.name).join(' + ') || 'the project' + const match = repos.find((r) => [r.name, r.role].map((x) => String(x).toLowerCase()).includes(String(scopeVal).toLowerCase())) + return match ? match.name : scopeVal +} +function resolveSurfaces(scopeVal) { + const all = kc.surfaces || [] + if (!scopeVal || scopeVal === 'both') return all + const byRole = all.filter((s) => roleMatches(s, scopeVal)) + if (byRole.length) return byRole + // A path scope: keep every surface but constrain it to the path. + return all.map((s) => ({ ...s, where: `${s.where} — but ONLY within the path "${scopeVal}"` })) +} + +function finderPrompt(surface, scopeVal) { + const sectionNote = surface.sections ? ` (sections ${surface.sections})` : '' + return [ + `Objective: ${kind} audit of the "${surface.label}" surface in ${scopeLabel(scopeVal)}.`, + `Read the rubric/checklist FIRST: ${kc.checklist}${sectionNote}. It is the contract for what counts and how findings are shaped.`, + `Where to look: ${surface.where}.`, + `Repo roots — ${repoRoots}.`, + kc.extra || '', + `For every REAL issue emit a finding with: severity from [${kc.ladder}]; a one-line title; category (the rubric/checklist dimension); location (repo-relative path:line); evidence (the exact line/command that proves it); rationale (${kc.rationale}); fix (the concrete change); reference (the rule / rubric dimension / checklist section).`, + `${CALIBRATION} ${EXCLUDE} Findings only, no padding. If the surface is clean, return an empty findings array.`, + ].filter(Boolean).join('\n') +} +function skepticPrompt(f) { + return [ + `Adversarially REFUTE this ${kind} finding. Read the cited location in full context and argue it is a FALSE POSITIVE — the path is unreachable, the input already validated, the claim already handled elsewhere, the evidence misread, it is a duplicate, or the severity is inflated.`, + `Default to refuted=true when uncertain — the burden is on the finding to prove it is real, not on you to prove it isn't.`, + `Finding: severity=${f.severity} · title=${f.title} · location=${f.location} · evidence=${f.evidence} · rationale=${f.rationale || ''}.`, + `Return refuted (bool) + note (one line why). If it is real but over-rated, set adjustedSeverity to the correct lower label.`, + ].join('\n') +} +function criticPrompt(sweptLabels, count) { + return [ + `Completeness critic for the ${kind} audit of ${scopeLabel(scope)}.`, + `Surfaces swept so far: ${sweptLabels.join(', ')} — producing ${count} findings.`, + `What did this audit NOT examine — a surface never swept, a file/handler/route skipped, or a claim left unverified?`, + `Stay strictly within this audit's calibration — ${kc.ladder}. Do NOT propose gaps outside the in-scope tiers. Propose at most 6 gaps, highest-value first.`, + `Return gaps as {label, prompt}, where prompt is a ready-to-run finder objective for that gap (same finding shape as the finders). Return an EMPTY gaps array if coverage is genuinely complete — do not invent gaps.`, + ].join('\n') +} + +const rank = (s) => { + const x = (s || '').toLowerCase() + if (x.includes('critical') || x.includes('tier 1')) return 0 + if (x.includes('high') || x.includes('tier 2')) return 1 + if (x.includes('medium')) return 2 + return 3 +} +const keyOf = (f) => `${(f.location || '').toLowerCase().trim()}::${(f.title || '').toLowerCase().trim().slice(0, 60)}` +const countBy = (findings) => { + const out = {} + for (const f of findings) { const s = (f.severity || 'unknown').trim(); out[s] = (out[s] || 0) + 1 } + return out +} +const isSerious = (f) => rank(f.severity) <= 1 + +const seen = new Set() +const dedupeFresh = (findings) => { + const fresh = [] + for (const f of findings) { const k = keyOf(f); if (seen.has(k)) continue; seen.add(k); fresh.push(f) } + return fresh +} +const agentOpts = (label, phaseName) => { + const o = { label, phase: phaseName, effort: 'low', schema: FINDINGS_SCHEMA } + if (cheapModel) o.model = cheapModel + return o +} +const verdictOpts = (label, phaseName) => { + const o = { label, phase: phaseName, effort: 'low', schema: VERDICT_SCHEMA } + if (cheapModel) o.model = cheapModel + return o +} + +phase('Find') +const surfaces = resolveSurfaces(scope) +log(`audit:${kind} · scope ${scopeLabel(scope)} · ${surfaces.length} surfaces`) +const firstPass = ( + await parallel(surfaces.map((s) => () => agent(finderPrompt(s, scope), agentOpts(`find:${s.label}`, 'Find')))) +).filter(Boolean) +const sweptLabels = surfaces.map((s) => s.label) +let findings = dedupeFresh(firstPass.flatMap((r) => r.findings || [])) + +async function verifySerious(candidates, phaseName) { + const serious = candidates.filter(isSerious).sort((a, b) => rank(a.severity) - rank(b.severity)) + const now = serious.slice(0, VERIFY_CAP) + const capped = serious.slice(VERIFY_CAP) + const verdicts = ( + await parallel(now.map((f, i) => () => agent(skepticPrompt(f), verdictOpts(`verify:${(f.location || String(i)).slice(0, 40)}`, phaseName)).then((v) => ({ f, v })))) + ).filter(Boolean) + const survivors = [] + for (const { f, v } of verdicts) { + if (v && v.refuted) continue + if (v && v.adjustedSeverity) f.severity = v.adjustedSeverity + survivors.push(f) + } + const passthrough = candidates.filter((f) => !isSerious(f)) + return { kept: [...survivors, ...passthrough], capped } +} + +phase('Verify') +let { kept, capped } = await verifySerious(findings, 'Verify') +const deferred = capped.map((f) => ({ title: f.title, location: f.location, severity: f.severity, deferReason: 'exceeded the adversarial-verify cap — shipped unchallenged, re-verify before acting' })) +log(`verified: ${kept.length} kept · ${capped.length} deferred (cap)`) + +phase('Complete') +let round = 0 +let dry = 0 +while (dry < maxDry && round < HARD_ROUNDS) { + round += 1 + const critic = await agent(criticPrompt(sweptLabels, kept.length), (() => { const o = { label: `critic:round-${round}`, phase: 'Complete', effort: 'low', schema: CRITIC_SCHEMA }; if (cheapModel) o.model = cheapModel; return o })()) + const gaps = (critic && critic.gaps) || [] + if (!gaps.length) { dry += 1; continue } + const roundRaw = ( + await parallel(gaps.map((g) => () => agent(g.prompt, agentOpts(`find:${g.label}`, 'Complete')))) + ).filter(Boolean).flatMap((r) => r.findings || []) + gaps.forEach((g) => sweptLabels.push(g.label)) + const fresh = dedupeFresh(roundRaw) + if (!fresh.length) { dry += 1; continue } + dry = 0 + const { kept: freshKept, capped: freshCapped } = await verifySerious(fresh, 'Complete') + kept = kept.concat(freshKept) + freshCapped.forEach((f) => deferred.push({ title: f.title, location: f.location, severity: f.severity, deferReason: 'exceeded the adversarial-verify cap — shipped unchallenged, re-verify before acting' })) + log(`round ${round}: +${fresh.length} fresh (${freshKept.length} kept)`) +} + +kept.sort((a, b) => rank(a.severity) - rank(b.severity)) +return { + kind, scope, scopeLabel: scopeLabel(scope), + findings: kept, counts: countBy(kept), coverage: sweptLabels, deferred, + rounds: round, + loopBound: round >= HARD_ROUNDS ? `stopped at the ${HARD_ROUNDS}-round hard cap` : `${dry} consecutive dry round(s)`, +} diff --git a/adapters/opencode/plugin/agentic-harness.js b/adapters/opencode/plugin/agentic-harness.js new file mode 100644 index 0000000..6fd4b57 --- /dev/null +++ b/adapters/opencode/plugin/agentic-harness.js @@ -0,0 +1,96 @@ +// opencode plugin adapter for the dual-target hook engine. It wires the SAME +// logic core the Claude Code hooks use to opencode's plugin contract: +// - tool.execute.before -> FULL enforcement parity: a policy block throws, +// which aborts the tool (git-action + content-scan). +// - event(session.idle) -> the proactivity guard, best-effort: opencode's +// idle event cannot rewind a finished turn the way +// the Claude Code Stop hook does, so this surfaces a +// nudge; the deterministic enforcement above is the +// real parity. +// All policy lives in hooks.policy.json; all logic in .../skills/_core/hooks/ +// logic/. opencode auto-loads this from .opencode/plugin/ — no wiring needed. +// +// The core is located via the runtime-provided project `directory` first +// (robust to any plugin bundling), with import.meta.url probes as a fallback. + +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = (() => { + try { + return dirname(fileURLToPath(import.meta.url)); + } catch { + return null; + } +})(); + +function coreCandidates(directory) { + const list = []; + if (directory) { + list.push(join(directory, ".opencode", "skills", "_core", "hooks", "logic")); + list.push(join(directory, ".claude", "skills", "_core", "hooks", "logic")); + } + if (here) { + list.push(join(here, "..", "skills", "_core", "hooks", "logic")); + list.push(join(here, "..", "..", "..", "core", "hooks", "logic")); + } + return list; +} + +async function core(mod, directory) { + for (const base of coreCandidates(directory)) { + const p = join(base, mod); + if (existsSync(p)) return import(pathToFileURL(p).href); + } + throw new Error(`hook logic core not found: ${mod}`); +} + +export default async ({ directory, worktree } = {}) => { + const dir = directory || worktree || process.cwd(); + const [{ fromOpenCode }, { loadPolicy }, { evaluateGitCommand }, { scanContent }] = await Promise.all([ + core("payload.mjs", dir), + core("config.mjs", dir), + core("git-action.mjs", dir), + core("content-scan.mjs", dir), + ]); + const policy = loadPolicy(dir); + const resolveHeadBranch = (d) => + execFileSync("git", ["-C", d || dir, "rev-parse", "--abbrev-ref", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + + const BLOCK = /^\[(git-guardrails|content-guard)/; + + return { + "tool.execute.before": async (input, output) => { + try { + const event = fromOpenCode(input?.tool, output?.args || {}, { directory: dir, worktree, sessionID: input?.sessionID }); + if (event.kind === "git" && event.command) { + const v = evaluateGitCommand(event.command, policy.git, { resolveHeadBranch, cwd: dir }); + if (v?.blocked) throw new Error(`[git-guardrails:${v.rule}] ${v.reason}`); + } else if (event.kind === "edit" && event.filePath && event.addedText) { + const findings = scanContent(event.addedText, policy.content, event.filePath); + if (findings.length) { + throw new Error(`[content-guard] ${event.filePath}: ` + findings.map((f) => `[${f.rule}] ${f.message}`).join("; ")); + } + } + } catch (err) { + // A policy block must propagate (opencode aborts the tool); an internal + // bug must not — fail-open so the engine never wedges the tool. + if (err && typeof err.message === "string" && BLOCK.test(err.message)) throw err; + } + }, + event: async ({ event }) => { + if (event?.type !== "session.idle" || !policy.proactivity?.guard?.enabled) return; + try { + const { REMINDER_LINE } = await core("proactivity.mjs", dir); + console.error(`[proactivity guard] session idle — ${REMINDER_LINE}`); + } catch { + /* best-effort nudge */ + } + }, + }; +}; diff --git a/core/hooks/README.md b/core/hooks/README.md new file mode 100644 index 0000000..84d2b55 --- /dev/null +++ b/core/hooks/README.md @@ -0,0 +1,79 @@ +# Dual-target hook engine + +Every enforceable invariant is written **once**, tool-neutrally, and enforced in +**both** Claude Code and opencode off that one shared logic core. Fix a rule in +one place; both tools inherit it. No project SDLC is a constant anywhere in here +— branch grammar, protected branches, banned phrases, forbidden trailers, ticket +formats all arrive from `hooks.policy.json` at runtime, which is why the +genericity gate can guarantee zero leakage. + +``` +core/hooks/ + logic/ ← the shared, pure, runtime-agnostic invariant checks + git-action.mjs evaluateGitCommand(command, policy, ctx) + content-scan.mjs scanContent(text, policy, filePath) + checkLargeBinary + proactivity.mjs reminder line + judge-prompt builder + verdict parse + transcript slice + payload.mjs normalize a Claude Code OR opencode payload -> one neutral record + scope.mjs glob -> RegExp + path-scope/exception matching (dependency-free) + config.mjs loadPolicy(startDir): DEFAULT_POLICY < global < project (JSON only) + templates.mjs ← the parameterized template library (the enforceable invariants) + lint-generators/ ← code-level policies -> a real ESLint/Roslyn/ruff rule (strongest layer) + +adapters/claude-code/hooks/ ← AUTHORED thin shells: read stdin -> logic -> exit 2 / block +adapters/opencode/plugin/ ← AUTHORED thin plugin: tool.execute.before -> throw; session.idle guard +adapters/claude-code/workflows/audit.mjs ← Workflow-tool audit accelerator (config-driven) +``` + +## The two adapters, one core + +A **template** (e.g. "no push to a protected branch") maps to a `logic/` function +and to each tool's block mechanism: + +| | Claude Code | opencode | +|---|---|---| +| Entry | `PreToolUse` / `PostToolUse` / `UserPromptSubmit` / `Stop` hook (`.mjs`) | plugin `tool.execute.before` / `event` | +| Payload | JSON on stdin (`tool_input.command`, `file_path`, `new_string` …) | `(input, output)` (`input.tool`, `output.args`) | +| Normalize | `payload.fromClaudeCode` | `payload.fromOpenCode` | +| Block | `exit 2` + stderr (or `{decision:block}` for Stop) | `throw new Error(...)` (opencode aborts the tool) | +| Allow | `exit 0` | return without throwing | + +Both adapters resolve `logic/` at runtime (probing the vendored `_core` path and, +for opencode, the project directory) so the same file works in the pack repo and +in an installed project regardless of any plugin bundling. + +## Enforce at the strongest layer + +- **git actions** (branch name, protected ref, ticket ref, no `--no-verify`, no + forbidden trailer, large binaries) → a git-action hook. Deterministic and + portable no matter how the command is invoked. +- **content** (em dash, banned phrases, secrets, large binaries) → a content hook + on the text an edit introduces — but a **code-level** policy in a linted stack + goes to a **real ESLint / Roslyn / ruff rule** instead (`lint-generators/`); the + content hook is the fallback only where no linter can express it. +- **disposition** (verify/do over guess/ask/improvise) → the proactivity guard: + a re-injected reminder each turn (Layer 1) plus a cheap-model turn review that + can send the turn back (Layer 2, model-configurable; empty model disables it). + +## Path scopes + exceptions + +Every content/git template takes optional `scope: { include, exclude }`. A +carve-out (em dash allowed in `CHANGELOG.md`, branch rule exempts `hotfix/*`) +**narrows** a rule — it never disables the whole rule. + +## Policy, not constants + +`config.loadPolicy` layers `DEFAULT_POLICY` (only universally-safe defaults: block +the git bypass flags, protect `main`/`master`, scan for unambiguous secrets) under +a global `~/.claude/hooks.policy.json` under the nearest project `hooks.policy.json`. +setup-harness writes that file from the interview + `workflow.config.yaml`; the +hooks only `JSON.parse` it (no YAML dependency at runtime). See +`hooks.policy.example.json` for the full shape. A machine that BANS an authorship +trailer and one that REQUIRES it are one policy field apart — neither is assumed. + +## Proof + +`node scripts/test-hook-engine.mjs` vendors the pack into a temp project and runs +the real Claude Code hook **and** the real opencode plugin against simulated +payloads — a push to a protected branch is blocked by both, a feature-branch push +is allowed by both, an em dash in scoped copy is caught by both — proving the +dual-target wiring end to end over one logic core. diff --git a/core/hooks/lint-generators/detect.mjs b/core/hooks/lint-generators/detect.mjs new file mode 100644 index 0000000..9fc7034 --- /dev/null +++ b/core/hooks/lint-generators/detect.mjs @@ -0,0 +1,65 @@ +// Read-only stack detection for a repo. A code-level policy is enforced at its +// STRONGEST layer: a real ESLint / Roslyn / ruff rule where the stack supports +// it, and the content-scan hook only as a fallback. This module reports which +// linters a repo can carry, from the marker files present (never runs anything). + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +function has(dir, name) { + return existsSync(join(dir, name)); +} + +function anyMatch(dir, re) { + try { + return readdirSync(dir).some((f) => re.test(f)); + } catch { + return false; + } +} + +function readJson(file) { + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch { + return null; + } +} + +export function detectStack(repoPath) { + const linters = []; + const languages = []; + + const pkg = has(repoPath, "package.json") ? readJson(join(repoPath, "package.json")) : null; + if (pkg) { + languages.push("javascript"); + const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; + const hasEslintConfig = + pkg.eslintConfig || + ["eslint.config.js", "eslint.config.mjs", ".eslintrc", ".eslintrc.json", ".eslintrc.cjs", ".eslintrc.js"].some((f) => has(repoPath, f)); + if (deps.eslint || hasEslintConfig) linters.push("eslint"); + if (deps.typescript || has(repoPath, "tsconfig.json")) languages.push("typescript"); + } + + if ( + anyMatch(repoPath, /\.(csproj|sln|slnx|fsproj|vbproj)$/i) || + has(repoPath, "Directory.Build.props") || + has(repoPath, "global.json") + ) { + languages.push("csharp"); + linters.push("roslyn"); + } + + if ( + has(repoPath, "pyproject.toml") || + has(repoPath, "ruff.toml") || + has(repoPath, ".ruff.toml") || + has(repoPath, "setup.cfg") || + has(repoPath, "requirements.txt") + ) { + languages.push("python"); + linters.push("ruff"); + } + + return { linters: [...new Set(linters)], languages: [...new Set(languages)] }; +} diff --git a/core/hooks/lint-generators/eslint.mjs b/core/hooks/lint-generators/eslint.mjs new file mode 100644 index 0000000..3e39a77 --- /dev/null +++ b/core/hooks/lint-generators/eslint.mjs @@ -0,0 +1,43 @@ +// Generate an ESLint rule for a code-level policy, so a JS/TS project enforces +// it in the linter (the strongest layer) rather than a content-scan hook. A +// policy descriptor is `{ kind, ...fields }`. Returns `{ supported, artifact, +// note }`; `supported:false` means ESLint core cannot express it — the caller +// falls back to the content-scan hook. + +export function generateEslint(policy = {}) { + switch (policy.kind) { + case "no-console": + return { supported: true, artifact: { type: "flat-config-rule", rules: { "no-console": "error" } }, note: "Bans console.* via the core no-console rule." }; + case "no-explicit-any": + return { + supported: true, + artifact: { type: "flat-config-rule", requires: "typescript-eslint", rules: { "@typescript-eslint/no-explicit-any": "error" } }, + note: "Requires typescript-eslint in the flat config.", + }; + case "no-debugger": + return { supported: true, artifact: { type: "flat-config-rule", rules: { "no-debugger": "error" } }, note: "" }; + case "ban-call": + if (!policy.callee) return { supported: false, note: "ban-call needs a `callee` name." }; + return { + supported: true, + artifact: { + type: "flat-config-rule", + rules: { + "no-restricted-syntax": ["error", { selector: `CallExpression[callee.name='${policy.callee}']`, message: policy.message || `${policy.callee}() is banned by policy.` }], + }, + }, + note: "Uses no-restricted-syntax with an AST selector.", + }; + case "ban-import": + if (!policy.module) return { supported: false, note: "ban-import needs a `module` name." }; + return { + supported: true, + artifact: { type: "flat-config-rule", rules: { "no-restricted-imports": ["error", { paths: [{ name: policy.module, message: policy.message || `Import of ${policy.module} is banned by policy.` }] }] } }, + note: "", + }; + case "ban-text": + return { supported: false, note: "ESLint core cannot match arbitrary text; author a custom rule or use the content-scan hook fallback." }; + default: + return { supported: false, note: `No ESLint mapping for kind "${policy.kind}".` }; + } +} diff --git a/core/hooks/lint-generators/index.mjs b/core/hooks/lint-generators/index.mjs new file mode 100644 index 0000000..9e524b2 --- /dev/null +++ b/core/hooks/lint-generators/index.mjs @@ -0,0 +1,37 @@ +// Route a code-level policy to its STRONGEST enforcement layer: a real linter +// rule where the repo's stack supports it, the content-scan hook only as a +// fallback. This is the "enforce at the strongest available layer" rule made +// mechanical — setup-harness calls it per code-level policy to decide whether it +// emits a lint rule or a hook. + +import { detectStack } from "./detect.mjs"; +import { generateEslint } from "./eslint.mjs"; +import { generateRoslyn } from "./roslyn.mjs"; +import { generateRuff } from "./ruff.mjs"; + +const GEN = { eslint: generateEslint, roslyn: generateRoslyn, ruff: generateRuff }; + +// A code policy may hint its language(s); otherwise every detected linter is +// tried and the first that supports it wins. +function lintersToTry(policy, stack) { + const byLang = { javascript: "eslint", typescript: "eslint", csharp: "roslyn", python: "ruff" }; + if (policy.language && byLang[policy.language]) return stack.linters.includes(byLang[policy.language]) ? [byLang[policy.language]] : []; + return stack.linters; +} + +export function strongestLayerFor(policy, stackOrRepoPath) { + const stack = typeof stackOrRepoPath === "string" ? detectStack(stackOrRepoPath) : stackOrRepoPath; + for (const linter of lintersToTry(policy, stack)) { + const result = GEN[linter]?.(policy); + if (result && (result.supported === true || result.supported === "scaffold")) { + return { layer: "lint", linter, result }; + } + } + return { + layer: "hook", + template: "content-scan", + reason: stack.linters.length ? "no lint rule expresses this policy on the detected stack" : "no lint stack detected", + }; +} + +export { detectStack, generateEslint, generateRoslyn, generateRuff }; diff --git a/core/hooks/lint-generators/roslyn.mjs b/core/hooks/lint-generators/roslyn.mjs new file mode 100644 index 0000000..afc35b5 --- /dev/null +++ b/core/hooks/lint-generators/roslyn.mjs @@ -0,0 +1,32 @@ +// Generate a Roslyn/.NET enforcement for a code-level policy. Two shapes: elevate +// an existing analyzer diagnostic to error via .editorconfig (the cheap, strong +// win), or scaffold a bespoke DiagnosticAnalyzer when no built-in covers it. +// Returns `{ supported, artifact, note }`; `supported:false` -> hook fallback. + +export function generateRoslyn(policy = {}) { + switch (policy.kind) { + case "severity": { + if (!policy.diagnosticId) return { supported: false, note: "severity needs a `diagnosticId` (e.g. CA1822, IDE0005, or a custom analyzer id)." }; + const level = policy.level || "error"; + return { + supported: true, + artifact: { type: "editorconfig", line: `dotnet_diagnostic.${policy.diagnosticId}.severity = ${level}` }, + note: "Add to the repo's .editorconfig to elevate the diagnostic. CI must build with warnings-as-errors or treat the id as error.", + }; + } + case "ban-call": + case "ban-text": + case "custom": + return { + supported: "scaffold", + artifact: { + type: "analyzer-scaffold", + description: + "No built-in diagnostic covers this; scaffold a Roslyn DiagnosticAnalyzer (a *.Analyzers project) that reports a custom diagnostic id, then elevate it to error via .editorconfig. Wire it as an analyzer reference so it fails CI.", + }, + note: "Bespoke analyzer required — mirrors how a project ships its own custom source-code rules.", + }; + default: + return { supported: false, note: `No Roslyn mapping for kind "${policy.kind}".` }; + } +} diff --git a/core/hooks/lint-generators/ruff.mjs b/core/hooks/lint-generators/ruff.mjs new file mode 100644 index 0000000..a5a30bc --- /dev/null +++ b/core/hooks/lint-generators/ruff.mjs @@ -0,0 +1,26 @@ +// Generate a ruff configuration for a code-level policy, so a Python project +// enforces it in the linter. Returns `{ supported, artifact, note }`; +// `supported:false` -> hook fallback. + +const KNOWN = { + "no-print": { codes: ["T20"], note: "flake8-print (T20) flags print/pprint calls." }, + "no-eval": { codes: ["S307"], note: "flake8-bandit S307 flags eval()." }, + "no-exec": { codes: ["S102"], note: "flake8-bandit S102 flags exec()." }, + "no-assert": { codes: ["S101"], note: "flake8-bandit S101 flags assert used as a guard." }, + "no-unused-imports": { codes: ["F401"], note: "pyflakes F401." }, +}; + +export function generateRuff(policy = {}) { + if (policy.kind === "select" && Array.isArray(policy.codes) && policy.codes.length) { + return { supported: true, artifact: { type: "pyproject", snippet: ruffSnippet(policy.codes) }, note: "Selects the given ruff rule codes." }; + } + const known = KNOWN[policy.kind]; + if (known) return { supported: true, artifact: { type: "pyproject", snippet: ruffSnippet(known.codes) }, note: known.note }; + if (policy.kind === "ban-text") return { supported: false, note: "ruff cannot match arbitrary text; use the content-scan hook fallback." }; + return { supported: false, note: `No ruff mapping for kind "${policy.kind}".` }; +} + +function ruffSnippet(codes) { + const list = codes.map((c) => `"${c}"`).join(", "); + return ["[tool.ruff.lint]", `extend-select = [${list}]`].join("\n"); +} diff --git a/core/hooks/logic/config.mjs b/core/hooks/logic/config.mjs new file mode 100644 index 0000000..008d926 --- /dev/null +++ b/core/hooks/logic/config.mjs @@ -0,0 +1,91 @@ +// Loads the hook policy at runtime. The policy is JSON (never YAML) so the hooks +// have ZERO runtime dependencies: setup-harness reads workflow.config.yaml + the +// interview and writes hooks.policy.json; the hooks only JSON.parse it. Layers, +// weakest-to-strongest: built-in DEFAULT_POLICY < global (~/.claude) < project +// (nearest hooks.policy.json walking up from the edited file). +// +// DEFAULT_POLICY encodes ONLY universally-safe defaults — block the git bypass +// flags, protect main/master, scan for unambiguous secrets. Everything a project +// might reasonably want either way (em-dash ban, branch grammar, ticket ref, +// forbidden trailers) is OFF until the policy turns it on. No SDLC policy is a +// constant here; that is the zero-leakage contract. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const DEFAULT_POLICY = { + git: { + protectedBranches: ["main", "master"], + blockNoVerify: true, + blockNoGpgSign: true, + blockPushToProtected: true, + branchPattern: "", + branchExceptions: [], + ticketPattern: "", + forbiddenTrailers: [], + largeBinaryGlobs: [], + }, + content: { + emDash: { enabled: false, scope: null, allowNumericEnDash: true }, + bannedPhrases: { enabled: false, phrases: [], scope: null }, + secretScan: { + enabled: true, + extraPatterns: [], + scope: { exclude: ["**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/fixtures/**", "**/__fixtures__/**"] }, + }, + largeBinary: { enabled: false, maxBytes: 0, blockedGlobs: [], scope: null }, + }, + proactivity: { + reminder: { enabled: true }, + guard: { enabled: false, judgeModel: "", skillDirs: [] }, + }, +}; + +function isObject(v) { + return v && typeof v === "object" && !Array.isArray(v); +} + +export function deepMerge(base, override) { + if (!isObject(override)) return override === undefined ? base : override; + const out = Array.isArray(base) ? [...base] : { ...base }; + for (const key of Object.keys(override)) { + out[key] = isObject(base?.[key]) && isObject(override[key]) ? deepMerge(base[key], override[key]) : override[key]; + } + return out; +} + +function readJsonIfExists(file) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return null; + } +} + +const POLICY_FILENAME = "hooks.policy.json"; + +function findProjectPolicy(startDir) { + let dir = startDir || process.cwd(); + const home = os.homedir(); + for (let i = 0; i < 40; i++) { + const candidate = path.join(dir, POLICY_FILENAME); + // The global policy is loaded separately; don't double-count it as project. + if (candidate !== path.join(home, ".claude", POLICY_FILENAME) && fs.existsSync(candidate)) { + return readJsonIfExists(candidate); + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +export function loadPolicy(startDir) { + const global = readJsonIfExists(path.join(os.homedir(), ".claude", POLICY_FILENAME)); + const project = findProjectPolicy(startDir); + let policy = DEFAULT_POLICY; + if (global) policy = deepMerge(policy, global); + if (project) policy = deepMerge(policy, project); + return policy; +} diff --git a/core/hooks/logic/content-scan.mjs b/core/hooks/logic/content-scan.mjs new file mode 100644 index 0000000..cf28b78 --- /dev/null +++ b/core/hooks/logic/content-scan.mjs @@ -0,0 +1,104 @@ +// Runtime-agnostic content invariants: scan a block of newly-written text for +// banned characters, banned phrases, and secret patterns. Every rule is +// parameterized and independently path-scoped (a rule can be enforced +// everywhere EXCEPT a carve-out set — a carve-out narrows, never disables). The +// Claude Code PostToolUse hook feeds the added text (Edit new_string / +// MultiEdit edits / Write content); the opencode tool.execute.before plugin +// feeds the pending edit args. Both call `scanContent` and translate findings. +// +// Secret patterns default to a conservative, universal set (unambiguous key +// shapes only) so a bare install does not false-positive on ordinary code; a +// project extends them via policy. + +import { inScope } from "./scope.mjs"; + +// Deliberately conservative — only shapes that are almost never a false +// positive. A project adds its own via `secretScan.extraPatterns`. +export const DEFAULT_SECRET_PATTERNS = [ + { name: "AWS access key id", source: "AKIA[0-9A-Z]{16}" }, + { name: "GitHub token", source: "gh[pousr]_[0-9A-Za-z]{36,}" }, + { name: "Google API key", source: "AIza[0-9A-Za-z_\\-]{35}" }, + { name: "Slack token", source: "xox[baprs]-[0-9A-Za-z-]{10,}" }, + { name: "Stripe live secret key", source: "sk_live_[0-9A-Za-z]{16,}" }, + { name: "private key block", source: "-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----" }, +]; + +function snippetAround(text, index, matchLen) { + const start = Math.max(0, index - 20); + return text.slice(start, index + matchLen + 20).replace(/\s+/g, " ").trim(); +} + +function scanEmDash(text, rule) { + const findings = []; + const rangeEnDashOffsets = new Set(); + if (rule.allowNumericEnDash !== false) { + for (const m of text.matchAll(/\d\s*–\s*\d/g)) rangeEnDashOffsets.add(m.index + m[0].indexOf("–")); + } + for (const m of text.matchAll(/[—–]/g)) { + if (m[0] === "–" && rangeEnDashOffsets.has(m.index)) continue; + findings.push({ + rule: "em-dash", + message: `${m[0] === "—" ? "em dash (—)" : "en dash (–)"} is a banned typographic tell here`, + snippet: snippetAround(text, m.index, 1), + }); + } + return findings; +} + +function scanBannedPhrases(text, rule) { + const findings = []; + for (const phrase of rule.phrases || []) { + if (!phrase) continue; + const re = phrase instanceof RegExp + ? new RegExp(phrase.source, phrase.flags.includes("g") ? phrase.flags : phrase.flags + "g") + : new RegExp(String(phrase).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"); + for (const m of text.matchAll(re)) { + findings.push({ rule: "banned-phrase", message: `banned phrase "${m[0]}"`, snippet: snippetAround(text, m.index, m[0].length) }); + } + } + return findings; +} + +function scanSecrets(text, rule) { + const patterns = [...DEFAULT_SECRET_PATTERNS, ...(rule.extraPatterns || []).map((p) => (typeof p === "string" ? { name: "custom secret", source: p } : p))]; + const findings = []; + for (const { name, source } of patterns) { + let re; + try { + re = new RegExp(source, "g"); + } catch { + continue; + } + for (const m of text.matchAll(re)) { + findings.push({ rule: "secret", message: `possible ${name} committed in source`, snippet: "«redacted secret match»" }); + } + } + return findings; +} + +// contentPolicy = { emDash?, bannedPhrases?, secretScan? } — each an object with +// `{ enabled, scope?, ...ruleFields }`. Returns a flat findings array; empty = +// clean. filePath gates each rule's own scope. +export function scanContent(text, contentPolicy = {}, filePath = "") { + if (typeof text !== "string" || text.length === 0) return []; + const findings = []; + const em = contentPolicy.emDash; + if (em?.enabled && inScope(filePath, em.scope)) findings.push(...scanEmDash(text, em)); + const bp = contentPolicy.bannedPhrases; + if (bp?.enabled && inScope(filePath, bp.scope)) findings.push(...scanBannedPhrases(text, bp)); + const sec = contentPolicy.secretScan; + if (sec?.enabled && inScope(filePath, sec.scope)) findings.push(...scanSecrets(text, sec)); + return findings; +} + +// Large-binary guard for a Write/create: flag when a file's path matches a +// blocked glob or its content exceeds a byte cap. Returns a finding or null. +export function checkLargeBinary(filePath, byteLength, largeBinaryPolicy = {}) { + if (!largeBinaryPolicy.enabled) return null; + const { blockedGlobs = [], maxBytes = 0, scope } = largeBinaryPolicy; + if (!inScope(filePath, scope)) return null; + if (maxBytes > 0 && byteLength > maxBytes) { + return { rule: "large-binary", message: `file is ${byteLength} bytes (cap ${maxBytes}); use the configured asset storage`, snippet: filePath }; + } + return null; +} diff --git a/core/hooks/logic/git-action.mjs b/core/hooks/logic/git-action.mjs new file mode 100644 index 0000000..dae4128 --- /dev/null +++ b/core/hooks/logic/git-action.mjs @@ -0,0 +1,162 @@ +// Runtime-agnostic git-workflow invariants. Every rule is parameterized by a +// `policy` object (from hooks.policy.json) — this file bakes in NONE of a +// project's SDLC: no branch prefix, no protected-branch name, no commit trailer +// is a constant here. That is the zero-leakage contract the genericity gate +// enforces. A Claude Code PreToolUse(Bash) hook and an opencode +// tool.execute.before plugin both call `evaluateGitCommand` and translate the +// verdict to their own block mechanism. +// +// A verdict is `{ blocked: true, reason, rule } | null` (null = allow). + +import { matchesGlob } from "./scope.mjs"; + +const CONTAINS_GIT = /\bgit\b/; + +function anyGlobMatches(name, globs) { + return (globs || []).some((g) => matchesGlob(name, g)); +} + +// A new-branch invocation: `git checkout -b X`, `git switch -c X`, `git branch X`. +// Returns the proposed branch name or null. +export function extractNewBranchName(command) { + const m = + /\bgit\s+(?:-[Cc]\s+\S+\s+)*(?:checkout\s+-b|switch\s+-c|branch)\s+("[^"]+"|'[^']+'|[^\s"']+)/.exec(command); + if (!m) return null; + const name = m[1].replace(/^["']|["']$/g, ""); + // `git branch -d/-D/--list` etc. are not new-branch creation. + if (name.startsWith("-")) return null; + return name; +} + +export function branchNameAllowed(name, policy) { + if (!policy?.branchPattern) return true; + if (anyGlobMatches(name, policy.branchExceptions)) return true; + let re; + try { + re = new RegExp(policy.branchPattern); + } catch { + return true; // a malformed pattern must never wedge git — fail open. + } + return re.test(name); +} + +// The push segment (if any) of a compound command, and whether it targets a +// protected branch by an explicit refspec. +function pushSegment(command) { + return command.split(/[&|;\n]/).find((s) => /\bgit\b[\s\S]*\bpush\b/.test(s)) || null; +} + +export function isForcePush(segment) { + return /(? b.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); + if (!alt) return false; + const re = new RegExp(`\\bpush\\b[^&|;\\n]*[\\s:/](?:${alt})(?=$|[\\s:])`); + return re.test(segment); +} + +function forbiddenTrailerHit(command, forbiddenTrailers) { + for (const trailer of forbiddenTrailers || []) { + if (!trailer) continue; + if (new RegExp(trailer.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i").test(command)) return trailer; + } + return null; +} + +function ticketRefMissing(command, policy) { + if (!policy?.ticketPattern) return false; + // Only enforce on a commit that carries an inline message. + const msg = /\bgit\s+(?:-[Cc]\s+\S+\s+)*commit\b[^&|;\n]*\s-m\s+("[^"]*"|'[^']*'|\S+)/.exec(command); + if (!msg) return false; + let re; + try { + re = new RegExp(policy.ticketPattern); + } catch { + return false; + } + return !re.test(msg[1]); +} + +function largeBinaryAdd(command, globs) { + if (!globs || globs.length === 0) return null; + const seg = command.split(/[&|;\n]/).find((s) => /\bgit\s+(?:-[Cc]\s+\S+\s+)*add\b/.test(s)); + if (!seg) return null; + const after = seg.slice(seg.search(/\badd\b/) + 3); + const paths = after.split(/\s+/).filter((t) => t && !t.startsWith("-")); + const hit = paths.map((p) => p.replace(/^["']|["']$/g, "")).find((p) => anyGlobMatches(p, globs)); + return hit || null; +} + +export function evaluateGitCommand(command, policy = {}, ctx = {}) { + if (typeof command !== "string" || !CONTAINS_GIT.test(command)) return null; + const protectedBranches = policy.protectedBranches || []; + + if (policy.blockNoVerify !== false) { + if (/(? t && !t.startsWith("-")); + if (positional.length <= 1) { + const cMatch = /-[Cc]\s+("[^"]+"|'[^']+'|[^\s"']+)/.exec(seg); + const dir = cMatch ? cMatch[1].replace(/^["']|["']$/g, "") : ctx.cwd; + let head = null; + try { + head = ctx.resolveHeadBranch(dir); + } catch { + head = null; + } + if (head && protectedBranches.includes(head)) { + return { rule: "push-protected", blocked: true, reason: `HEAD is on the protected branch '${head}'. Pushing from it is forbidden — switch to a feature branch and open a PR.` }; + } + } + } + } + + return null; +} diff --git a/core/hooks/logic/payload.mjs b/core/hooks/logic/payload.mjs new file mode 100644 index 0000000..376e8c1 --- /dev/null +++ b/core/hooks/logic/payload.mjs @@ -0,0 +1,46 @@ +// The one place that knows each host tool's event shape. It normalizes a Claude +// Code hook payload and an opencode tool.execute.before payload into a single +// neutral record the logic core reasons over, so git-action / content-scan are +// written once and never learn a tool's field names. +// +// Neutral record: { source, tool, kind, command, filePath, addedText, cwd, sessionId } +// kind: "git" (a Bash/shell command), "edit" (a file mutation), or "other". + +function addedTextFromClaude(toolInput = {}) { + if (typeof toolInput.new_string === "string") return toolInput.new_string; + if (Array.isArray(toolInput.edits)) return toolInput.edits.map((e) => e?.new_string ?? "").join("\n"); + if (typeof toolInput.content === "string") return toolInput.content; + return ""; +} + +const CC_EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]); + +export function fromClaudeCode(input = {}) { + const tool = input.tool_name || ""; + const ti = input.tool_input || {}; + const cwd = input.cwd || null; + const sessionId = input.session_id || null; + if (tool === "Bash" && typeof ti.command === "string") { + return { source: "claude-code", tool, kind: "git", command: ti.command, filePath: null, addedText: null, cwd, sessionId }; + } + if (CC_EDIT_TOOLS.has(tool)) { + const filePath = ti.file_path || input?.tool_response?.filePath || null; + return { source: "claude-code", tool, kind: "edit", command: null, filePath, addedText: addedTextFromClaude(ti), cwd, sessionId }; + } + return { source: "claude-code", tool, kind: "other", command: null, filePath: null, addedText: null, cwd, sessionId }; +} + +const OC_EDIT_TOOLS = new Set(["edit", "write", "patch"]); + +export function fromOpenCode(tool, args = {}, ctx = {}) { + const cwd = ctx.directory || ctx.worktree || null; + const sessionId = ctx.sessionID || null; + if (tool === "bash" && typeof args.command === "string") { + return { source: "opencode", tool, kind: "git", command: args.command, filePath: null, addedText: null, cwd, sessionId }; + } + if (OC_EDIT_TOOLS.has(tool)) { + const addedText = typeof args.newString === "string" ? args.newString : typeof args.content === "string" ? args.content : ""; + return { source: "opencode", tool, kind: "edit", command: null, filePath: args.filePath || args.path || null, addedText, cwd, sessionId }; + } + return { source: "opencode", tool, kind: "other", command: null, filePath: null, addedText: null, cwd, sessionId }; +} diff --git a/core/hooks/logic/proactivity.mjs b/core/hooks/logic/proactivity.mjs new file mode 100644 index 0000000..ba4dfd7 --- /dev/null +++ b/core/hooks/logic/proactivity.mjs @@ -0,0 +1,154 @@ +// Runtime-agnostic building blocks for the proactivity guard — the disposition +// gate that steers the agent toward verify/do over guess/ask/improvise. Two +// layers ship as a dual-target pair: +// Layer 1 (reminder) — re-inject one high-salience line every turn. +// Layer 2 (guard) — a cheap judge model reviews the finished turn and, on a +// clear shortcut, sends it back (Claude Code Stop hook) or +// surfaces a nudge (opencode session.idle event). +// This module is pure: it builds the reminder line + the judge prompt and parses +// the verdict. The model call itself (claude -p, or the opencode client) lives +// in each adapter, so the logic stays tool-neutral. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const REMINDER_LINE = + "Before you assume a fact or ask the user to do something: can you verify it or do it right now " + + "with a tool you already have (gh, files, MCP, a CLI)? If the request names a concrete thing you " + + "have not inspected (an issue, file, PR, or resource), inspect it first. If the situation matches a " + + "skill trigger (you were corrected: lesson; a diff needs review: pr-review; an idea needs PRD and " + + "stories: feature), invoke the skill instead of improvising. Default to the cheap, correct action " + + "over the guess, the question, or the hand-rolled version."; + +export const LIMITS = { MIN_CHARS: 200, MAX_USER: 1500, MAX_ASSISTANT: 4000 }; + +export function textFromContent(content) { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n"); + } + return ""; +} + +function isGenuineUserPrompt(rec) { + if (!rec || rec.type !== "user" || rec.toolUseResult) return false; + const c = rec.message && rec.message.content; + if (typeof c === "string") return c.trim().length > 0; + if (Array.isArray(c)) return c.some((b) => b && b.type === "text"); + return false; +} + +// Slice the most recent user->assistant turn out of a Claude Code transcript +// (array of parsed JSONL records). Returns null when there is nothing to judge. +export function extractLatestTurn(records) { + if (!Array.isArray(records) || records.length === 0) return null; + let turnStart = -1; + for (let i = records.length - 1; i >= 0; i--) { + if (isGenuineUserPrompt(records[i])) { + turnStart = i; + break; + } + } + if (turnStart === -1) return null; + const turn = records.slice(turnStart); + const userPrompt = textFromContent(records[turnStart].message.content).trim(); + const assistantRecords = turn.filter((r) => r.type === "assistant"); + if (assistantRecords.length === 0) return null; + const finalAssistant = assistantRecords[assistantRecords.length - 1]; + let assistantText = textFromContent(finalAssistant.message && finalAssistant.message.content).trim(); + if (!assistantText) { + assistantText = assistantRecords.map((r) => textFromContent(r.message && r.message.content)).join("\n").trim(); + } + const toolsUsed = [ + ...new Set( + assistantRecords.flatMap((r) => { + const c = (r.message && r.message.content) || []; + return Array.isArray(c) ? c.filter((b) => b && b.type === "tool_use").map((b) => b.name) : []; + }), + ), + ]; + return { userPrompt, assistantText, toolsUsed, finalUuid: finalAssistant.uuid || "" }; +} + +// Digest of available skills (name: when-to-use), read from the given skill +// directories, so the judge can tell when a skill trigger was clearly matched. +export function loadSkillDigest(dirs) { + const searchDirs = dirs && dirs.length ? dirs : [path.join(".", ".claude", "skills"), path.join(os.homedir(), ".claude", "skills")]; + const byName = new Map(); + for (const dir of searchDirs) { + let entries = []; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + if (!e.isDirectory()) continue; + let raw = ""; + try { + raw = fs.readFileSync(path.join(dir, e.name, "SKILL.md"), "utf8"); + } catch { + continue; + } + const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!fm) continue; + const nameMatch = fm[1].match(/^name:\s*(.+)$/m); + const descMatch = fm[1].match(/^description:\s*(.+)$/m); + const name = (nameMatch ? nameMatch[1] : e.name).trim(); + if (byName.has(name)) continue; + let desc = descMatch ? descMatch[1].trim().replace(/^["']|["']$/g, "") : ""; + if (desc.length > 220) desc = desc.slice(0, 217) + "..."; + byName.set(name, desc); + } + } + return [...byName.entries()].map(([n, d]) => ` /${n}: ${d}`).join("\n"); +} + +export function buildJudgePrompt({ userPrompt, toolsUsed, assistantText, skillDigest }) { + return [ + "You are a strict but conservative proactivity gate reviewing an AI coding agent's just-finished turn.", + "Block the turn ONLY when it CLEARLY took a shortcut that a cheaper, more-correct action beat. When in doubt, allow.", + "", + "Block if ANY of these clearly happened in THIS turn:", + "(a) It asserted a specific factual claim about a NAMED artifact (an issue/PR number, a file path, a resource) while the tools-used list shows it never inspected that artifact this turn and nothing indicates it already knew the contents.", + "(b) It asked the user to run a command, read a log, fetch data, or click something the agent had the tools to do itself (gh, files, MCP, a CLI).", + "(c) It finished by offering clearly in-scope work as an optional 'want me to...?' instead of just doing it.", + "(d) It hand-rolled a procedure when a listed skill's trigger clearly matched (a correction -> lesson; a diff needs review -> pr-review).", + "", + "Do NOT block for: legitimate clarifying questions about a genuine fork, work already done correctly, asking for a decision only the user can make, or things it verified in an earlier turn.", + "", + "Available skills (name: when-to-use):", + skillDigest || " (none found)", + "", + "=== USER REQUEST (this turn) ===", + (userPrompt || "").slice(0, LIMITS.MAX_USER), + "", + "=== TOOLS THE AGENT USED THIS TURN ===", + toolsUsed && toolsUsed.length ? toolsUsed.join(", ") : "(none)", + "", + "=== AGENT'S FINAL MESSAGE ===", + (assistantText || "").slice(0, LIMITS.MAX_ASSISTANT), + "", + "Respond with ONLY minified JSON, no code fence, no prose:", + '{"block": , "clause": "", "reason": ""}', + ].join("\n"); +} + +export function parseVerdict(out) { + let s = (out || "").trim().replace(/```[a-zA-Z]*\s*/g, "").replace(/```/g, "").trim(); + const tryParse = (t) => { + try { + return JSON.parse(t); + } catch { + return null; + } + }; + let v = tryParse(s); + if (!v) { + const m = s.match(/\{[\s\S]*\}/); + if (m) v = tryParse(m[0]); + } + if (!v || v.block !== true || typeof v.reason !== "string" || !v.reason.trim()) return null; + return v; +} diff --git a/core/hooks/logic/scope.mjs b/core/hooks/logic/scope.mjs new file mode 100644 index 0000000..8d20833 --- /dev/null +++ b/core/hooks/logic/scope.mjs @@ -0,0 +1,68 @@ +// Path-scope + exception matching for the hook engine. A template can be +// restricted to a set of include globs and carve out an exception set of +// exclude globs (the em-dash rule banned everywhere EXCEPT CHANGELOG.md, the +// branch rule enforced EXCEPT on hotfix/*). A carve-out narrows a rule; it +// never disables it. Pure and dependency-free: a small glob->RegExp compiler +// (`**`, `*`, `?`, `{a,b}` alternation) so the pack needs no minimatch install. + +function globToRegExpSource(glob) { + let out = ""; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*") { + if (glob[i + 1] === "*") { + // `**` — any number of path segments (including zero). Swallow a + // trailing slash so `a/**/b` matches `a/b`. + i++; + if (glob[i + 1] === "/") i++; + out += "(?:.*/)?"; + } else { + out += "[^/]*"; + } + } else if (c === "?") { + out += "[^/]"; + } else if (c === "{") { + const close = glob.indexOf("}", i); + if (close === -1) { + out += "\\{"; + } else { + const alts = glob.slice(i + 1, close).split(",").map((a) => a.replace(/[.+^${}()|[\]\\]/g, "\\$&")); + out += `(?:${alts.join("|")})`; + i = close; + } + } else if (/[.+^${}()|[\]\\]/.test(c)) { + out += "\\" + c; + } else { + out += c; + } + } + return out; +} + +export function globToRegExp(glob) { + return new RegExp("^" + globToRegExpSource(String(glob)) + "$"); +} + +function normalizePath(filePath) { + return String(filePath || "").replace(/\\/g, "/").replace(/^\.\//, ""); +} + +export function matchesGlob(filePath, glob) { + const p = normalizePath(filePath); + // A bare `*.ext` glob should match at any depth, matching common intent. + const g = glob.includes("/") ? glob : `**/${glob}`; + return globToRegExp(g).test(p) || globToRegExp(glob).test(p); +} + +// A scope is `{ include?: string[], exclude?: string[] }`. An empty/absent +// include means "everywhere". A path in scope = matches some include AND no +// exclude. Passing no scope at all returns true (rule applies globally). +export function inScope(filePath, scope) { + if (!scope) return true; + const include = scope.include || []; + const exclude = scope.exclude || []; + const p = normalizePath(filePath); + if (exclude.some((g) => matchesGlob(p, g))) return false; + if (include.length === 0) return true; + return include.some((g) => matchesGlob(p, g)); +} diff --git a/core/hooks/templates.mjs b/core/hooks/templates.mjs new file mode 100644 index 0000000..0e399b9 --- /dev/null +++ b/core/hooks/templates.mjs @@ -0,0 +1,196 @@ +// The parameterized hook-template library. Each entry is one enforceable +// invariant expressed once, tool-neutrally, over the shared logic core. Setup +// (a later stage) reads this registry to (a) render the decomposition-table gate +// and (b) build hooks.policy.json from the interview + workflow.config.yaml; the +// generated adapters read the resulting policy. NOTHING here is a project +// constant — `configBinding` names WHERE in workflow.config.yaml a value comes +// from, `policyPath` names WHERE in hooks.policy.json it lands, and `default` is +// a universally-safe fallback. Anything a template does not cover is generated +// bespoke by setup off the same logic core. +// +// Every content/git template carries `supportsPathScopes` / `supportsExceptions` +// — a carve-out (em-dash allowed in CHANGELOG.md, branch rule exempts hotfix/*) +// NARROWS a rule, it never disables it. + +export const TIER = { HOOK: "HOOK", LINT: "LINT", RULE: "RULE", FACT: "FACT" }; + +export const TEMPLATES = [ + { + id: "no-verify", + title: "No --no-verify / commit -n", + tier: TIER.HOOK, + logic: "git-action", + claudeCode: { event: "PreToolUse", matcher: "Bash", file: "git-guardrails.mjs" }, + opencode: { hook: "tool.execute.before", tools: ["bash"] }, + policyPath: "git.blockNoVerify", + configBinding: "git.blockBypassFlags", + supportsPathScopes: false, + supportsExceptions: false, + default: true, + describe: "Block any git command that skips the commit/push hooks via --no-verify (or the `commit -n` alias).", + }, + { + id: "no-gpg-sign", + title: "No --no-gpg-sign", + tier: TIER.HOOK, + logic: "git-action", + claudeCode: { event: "PreToolUse", matcher: "Bash", file: "git-guardrails.mjs" }, + opencode: { hook: "tool.execute.before", tools: ["bash"] }, + policyPath: "git.blockNoGpgSign", + configBinding: "git.requireSignedCommits", + supportsPathScopes: false, + supportsExceptions: false, + default: true, + describe: "Block commits that bypass signing (--no-gpg-sign or commit.gpgsign=false).", + }, + { + id: "protected-ref", + title: "No commit/push to a protected branch", + tier: TIER.HOOK, + logic: "git-action", + claudeCode: { event: "PreToolUse", matcher: "Bash", file: "git-guardrails.mjs" }, + opencode: { hook: "tool.execute.before", tools: ["bash"] }, + policyPath: "git.protectedBranches", + configBinding: "branchNaming.protectedBranches", + supportsPathScopes: false, + supportsExceptions: false, + default: ["main", "master"], + describe: "Block a direct or forced push to a protected branch, and a bare push issued while HEAD is on one.", + }, + { + id: "branch-name", + title: "Branch name must match the convention", + tier: TIER.HOOK, + logic: "git-action", + claudeCode: { event: "PreToolUse", matcher: "Bash", file: "git-guardrails.mjs" }, + opencode: { hook: "tool.execute.before", tools: ["bash"] }, + policyPath: "git.branchPattern", + configBinding: "branchNaming.pattern", + supportsPathScopes: false, + supportsExceptions: true, + default: "", + describe: "Block creating a branch whose name does not match the required regex (exceptions carve out e.g. hotfix/*).", + }, + { + id: "ticket-ref", + title: "Commit message must carry a ticket ref", + tier: TIER.HOOK, + logic: "git-action", + claudeCode: { event: "PreToolUse", matcher: "Bash", file: "git-guardrails.mjs" }, + opencode: { hook: "tool.execute.before", tools: ["bash"] }, + policyPath: "git.ticketPattern", + configBinding: "issueTracker.ticketPattern", + supportsPathScopes: false, + supportsExceptions: false, + default: "", + describe: "Block an inline commit (-m) whose message lacks a ticket reference matching the required regex.", + }, + { + id: "forbidden-trailer", + title: "No forbidden commit/PR trailer", + tier: TIER.HOOK, + logic: "git-action", + claudeCode: { event: "PreToolUse", matcher: "Bash", file: "git-guardrails.mjs" }, + opencode: { hook: "tool.execute.before", tools: ["bash"] }, + policyPath: "git.forbiddenTrailers", + configBinding: "pr.forbiddenTrailers", + supportsPathScopes: false, + supportsExceptions: false, + default: [], + describe: "Block a commit whose message contains a banned trailer (e.g. a machine that forbids authorship trailers). The inverse of a project that REQUIRES one — this is a pure policy field, never assumed.", + }, + { + id: "large-binary", + title: "No large binaries committed", + tier: TIER.HOOK, + logic: "git-action", + claudeCode: { event: "PreToolUse", matcher: "Bash", file: "git-guardrails.mjs" }, + opencode: { hook: "tool.execute.before", tools: ["bash"] }, + policyPath: "git.largeBinaryGlobs", + configBinding: "git.largeBinaryGlobs", + supportsPathScopes: false, + supportsExceptions: false, + default: [], + describe: "Block `git add` of a path matching the blocked-binary globs; steer to the configured asset storage.", + }, + { + id: "em-dash", + title: "No em dashes in copy", + tier: TIER.HOOK, + logic: "content-scan", + claudeCode: { event: "PostToolUse", matcher: "Edit|Write|MultiEdit", file: "content-guard.mjs" }, + opencode: { hook: "tool.execute.before", tools: ["edit", "write"] }, + policyPath: "content.emDash", + configBinding: "content.emDash", + supportsPathScopes: true, + supportsExceptions: true, + strongerLayer: "lint", + default: { enabled: false, allowNumericEnDash: true }, + describe: "Flag an em dash (—) newly written into scoped copy — a banned typographic tell. Numeric en-dash ranges (1–10) allowed. Prefer a real lint rule in source files; the hook covers non-source copy.", + }, + { + id: "banned-phrases", + title: "No banned phrases", + tier: TIER.HOOK, + logic: "content-scan", + claudeCode: { event: "PostToolUse", matcher: "Edit|Write|MultiEdit", file: "content-guard.mjs" }, + opencode: { hook: "tool.execute.before", tools: ["edit", "write"] }, + policyPath: "content.bannedPhrases", + configBinding: "content.bannedPhrases", + supportsPathScopes: true, + supportsExceptions: true, + strongerLayer: "lint", + default: { enabled: false, phrases: [] }, + describe: "Flag a configured banned phrase newly written into scoped files.", + }, + { + id: "secret-scan", + title: "No secrets in source", + tier: TIER.HOOK, + logic: "content-scan", + claudeCode: { event: "PostToolUse", matcher: "Edit|Write|MultiEdit", file: "content-guard.mjs" }, + opencode: { hook: "tool.execute.before", tools: ["edit", "write"] }, + policyPath: "content.secretScan", + configBinding: "content.secretScan", + supportsPathScopes: true, + supportsExceptions: true, + default: { enabled: true, extraPatterns: [] }, + describe: "Flag a newly-written secret matching a conservative universal set (AWS/GitHub/Google/Slack/Stripe keys, private-key blocks) plus project extras. Default-excludes test/fixture paths.", + }, + { + id: "proactivity-guard", + title: "Proactivity guard (disposition gate)", + tier: TIER.HOOK, + logic: "proactivity", + claudeCode: { event: "UserPromptSubmit+Stop", matcher: "*", file: "proactivity-reminder.mjs + proactivity-guard.mjs" }, + opencode: { hook: "event", tools: [], note: "session.idle — best-effort nudge; cannot rewind a finished turn like the Claude Code Stop hook" }, + policyPath: "proactivity", + configBinding: "proactivity", + supportsPathScopes: false, + supportsExceptions: false, + modelConfigurable: true, + default: { reminder: { enabled: true }, guard: { enabled: false, judgeModel: "" } }, + describe: "Re-inject one verify/do-it-yourself line each turn (Layer 1) and, when a judge model is configured, review the finished turn and send it back on a clear shortcut (Layer 2). Model-configurable; empty judgeModel disables Layer 2.", + }, +]; + +export function templateById(id) { + return TEMPLATES.find((t) => t.id === id) || null; +} + +// Rows for the decomposition-table gate setup-harness presents before writing +// anything. `selected` maps template id -> chosen action (enforce/soften/drop). +export function gateTable(selected = {}) { + return TEMPLATES.map((t) => ({ + id: t.id, + rule: t.title, + proposedTier: t.tier, + action: selected[t.id] || "enforce", + claudeCode: t.claudeCode.file, + opencode: t.opencode.hook, + scopes: t.supportsPathScopes ? "path-scoped" : "global", + exceptions: t.supportsExceptions ? "supported" : "n/a", + strongerLayer: t.strongerLayer || null, + why: t.describe, + })); +} diff --git a/hooks.policy.example.json b/hooks.policy.example.json new file mode 100644 index 0000000..0851fa0 --- /dev/null +++ b/hooks.policy.example.json @@ -0,0 +1,47 @@ +{ + "_comment": "Example hooks.policy.json — the runtime policy the dual-target hook engine reads (JSON so the hooks have ZERO runtime deps; setup-harness derives it from workflow.config.yaml + the interview and writes the real one). Copy to hooks.policy.json at your project root (project overrides a global ~/.claude/hooks.policy.json, both over the built-in DEFAULT_POLICY). Every value shown is illustrative — NONE is assumed by the pack. Delete the _comment keys; the loader ignores them but they are noise.", + + "git": { + "_comment": "Deterministic git-workflow invariants (Claude Code PreToolUse(Bash) + opencode tool.execute.before). protectedBranches/blockNoVerify/blockNoGpgSign default safe; the rest are OFF until set here.", + "protectedBranches": ["main"], + "blockNoVerify": true, + "blockNoGpgSign": true, + "blockPushToProtected": true, + "branchPattern": "^(feature|fix|chore|refactor|docs)/", + "branchExceptions": ["hotfix/*"], + "ticketPattern": "", + "forbiddenTrailers": [], + "largeBinaryGlobs": ["*.mp4", "*.mov", "*.zip", "*.psd"] + }, + + "content": { + "_comment": "Content invariants scanning the text an edit introduces (never the whole file). Each rule is independently path-scoped: a scope narrows a rule, it never disables it.", + "emDash": { + "enabled": true, + "allowNumericEnDash": true, + "scope": { "include": ["**/i18n/**", "**/email/**", "**/*.md"], "exclude": ["CHANGELOG.md"] } + }, + "bannedPhrases": { + "enabled": false, + "phrases": [], + "scope": null + }, + "secretScan": { + "enabled": true, + "extraPatterns": [], + "scope": { "exclude": ["**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/fixtures/**", "**/__fixtures__/**"] } + }, + "largeBinary": { + "enabled": false, + "maxBytes": 5242880, + "blockedGlobs": ["*.mp4", "*.zip"], + "scope": null + } + }, + + "proactivity": { + "_comment": "The disposition gate. Layer 1 (reminder) re-injects one line each turn. Layer 2 (guard) reviews the finished turn with a cheap judge model and, on a clear shortcut, sends it back (Claude Code Stop) or nudges (opencode session.idle). Empty judgeModel disables Layer 2 — never pin a model that may retire.", + "reminder": { "enabled": true }, + "guard": { "enabled": false, "judgeModel": "", "skillDirs": [] } + } +} diff --git a/scripts/check-genericity.mjs b/scripts/check-genericity.mjs index 67af47f..04a1578 100644 --- a/scripts/check-genericity.mjs +++ b/scripts/check-genericity.mjs @@ -16,13 +16,25 @@ // is just an inline illustration). The commit-trailer string has no // legitimate generic use at all, so it is never exempted. -import { readdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; const packRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); -const coreDir = join(packRoot, "core"); + +// The enforced trees: the tool-agnostic core, plus the AUTHORED engine adapters +// (the dual-target hook shells, the opencode plugin, and the Workflow-audit +// accelerator). The generated skill/agent pointers under adapters/*/skills and +// adapters/*/agents are derived from core (already checked), so they are not +// re-scanned. Every one of these must stay free of project strings and baked +// SDLC policy — the shells read all of that from hooks.policy.json at runtime. +const enforcedDirs = [ + join(packRoot, "core"), + join(packRoot, "adapters", "claude-code", "hooks"), + join(packRoot, "adapters", "claude-code", "workflows"), + join(packRoot, "adapters", "opencode", "plugin"), +].filter((d) => existsSync(d)); // Each pattern is a leak: a name, vendor, brand, or absolute path that would // couple core/ to one project. Add project-neutral terms here, never remove the @@ -75,7 +87,7 @@ function walk(dir) { } const leaks = []; -for (const file of walk(coreDir)) { +for (const file of enforcedDirs.flatMap((d) => walk(d))) { const lines = readFileSync(file, "utf8").split(/\r?\n/); lines.forEach((line, i) => { for (const { label, re } of forbidden) { @@ -93,13 +105,13 @@ for (const file of walk(coreDir)) { } if (leaks.length > 0) { - console.error(`Genericity check FAILED — ${leaks.length} project-specific string(s) in core/:\n`); + console.error(`Genericity check FAILED — ${leaks.length} project-specific string(s) in the enforced tree:\n`); for (const leak of leaks) { console.error(` ${leak.file}:${leak.line} [${leak.label}]`); console.error(` ${leak.text}`); } - console.error(`\nMove project-specific values and SDLC policy into workflow.config.yaml; core/ must stay generic.`); + console.error(`\nMove project values into workflow.config.yaml and SDLC policy into hooks.policy.json; core/ and the engine adapters must stay generic.`); process.exit(1); } -console.log("Genericity check passed — core/ is free of project-specific strings and hardcoded policy."); +console.log("Genericity check passed — core/ and the engine adapters are free of project-specific strings and hardcoded policy."); diff --git a/scripts/gen-adapters.mjs b/scripts/gen-adapters.mjs index 563d992..a7713e6 100644 --- a/scripts/gen-adapters.mjs +++ b/scripts/gen-adapters.mjs @@ -1,9 +1,15 @@ #!/usr/bin/env node -// Regenerates the two tool adapters from a single manifest. Each adapter file -// is a THIN wrapper: the host tool's required header plus a pointer to the +// Regenerates the SKILL + AGENT adapters from a single manifest. Each adapter +// file is a THIN wrapper: the host tool's required header plus a pointer to the // matching core/ body — no logic is ever duplicated across tools. Run this after // changing the skill roster; the generated files are committed. // +// This script only owns the generated `skills/` and `agents/` subtrees. The +// dual-target hook engine adapters (`claude-code/hooks/`, `claude-code/workflows/`, +// `opencode/plugin/`) are AUTHORED shells that import the shared logic core — they +// are NOT generated here and must survive regeneration, so the wipe below is +// scoped to skills/ + agents/, never the whole adapter base. +// // Driver = Claude Code; opencode is a thin compat layer. Codex is a locked-out // decision — do not re-add a codex path here. @@ -64,7 +70,8 @@ fallback. // ---- Claude Code: .claude/skills//SKILL.md + .claude/agents/.md function genClaudeCode() { const base = join(packRoot, "adapters/claude-code"); - rmSync(base, { recursive: true, force: true }); + rmSync(join(base, "skills"), { recursive: true, force: true }); + rmSync(join(base, "agents"), { recursive: true, force: true }); for (const [name, corePath, description, argHint] of skills) { const fm = [ "---", @@ -85,7 +92,8 @@ function genClaudeCode() { // ---- OpenCode: .opencode/skills//SKILL.md + .opencode/agents/.md function genOpenCode() { const base = join(packRoot, "adapters/opencode"); - rmSync(base, { recursive: true, force: true }); + rmSync(join(base, "skills"), { recursive: true, force: true }); + rmSync(join(base, "agents"), { recursive: true, force: true }); for (const [name, corePath, description] of skills) { const fm = ["---", `description: ${description}`, "---", ""].join("\n"); write(join(base, "skills", name, "SKILL.md"), fm + pointerBody(name, `../_core/${corePath}`, description)); diff --git a/scripts/install.mjs b/scripts/install.mjs index 5f990db..c12b82b 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -5,7 +5,7 @@ // re-uses `vendor()` to refresh an existing install. import { - cpSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, + cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { createHash } from "node:crypto"; import { dirname, join, relative } from "node:path"; @@ -17,18 +17,28 @@ const packRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); // to the consumer root; `coreSubdir` is where core/ is vendored; `agentsSubdir` // (optional) is where the agent wrappers land. The adapter wrappers reference // core with a relative pointer that resolves from `skillsSubdir` to `coreSubdir`. +// `engineDirs` vendors the dual-target hook-engine adapters (authored shells that +// import the vendored logic core); the hook shells probe `../skills/_core/hooks/ +// logic`, so the layout keeps them a sibling of the vendored core. (Wiring the +// Claude Code hooks into settings.json + registering nothing for opencode's +// auto-loaded plugin is the bootstrap step; this vendors the files.) const layouts = { "claude-code": { adapterSkillsDir: "skills", skillsSubdir: ".claude/skills", coreSubdir: ".claude/skills/_core", agentsSubdir: ".claude/agents", + engineDirs: [ + { from: "hooks", to: ".claude/hooks" }, + { from: "workflows", to: ".claude/workflows" }, + ], }, opencode: { adapterSkillsDir: "skills", skillsSubdir: ".opencode/skills", coreSubdir: ".opencode/skills/_core", agentsSubdir: ".opencode/agents", + engineDirs: [{ from: "plugin", to: ".opencode/plugin" }], }, }; @@ -66,6 +76,10 @@ export function vendor({ tool, into, ref = "main", dryRun = false }) { if (layout.agentsSubdir) { plan.push({ from: join(adapterDir, "agents"), to: join(into, layout.agentsSubdir) }); } + for (const dir of layout.engineDirs || []) { + const from = join(adapterDir, dir.from); + if (existsSync(from)) plan.push({ from, to: join(into, dir.to) }); + } if (dryRun) { console.log(`[dry-run] install tool=${tool} ref=${ref} into=${into}`); @@ -90,7 +104,7 @@ export function vendor({ tool, into, ref = "main", dryRun = false }) { sourceType: "github", ref, tool, - installedPaths: [layout.skillsSubdir, layout.coreSubdir, layout.agentsSubdir].filter(Boolean), + installedPaths: [layout.skillsSubdir, layout.coreSubdir, layout.agentsSubdir, ...(layout.engineDirs || []).map((d) => d.to)].filter(Boolean), computedHash, }, }; diff --git a/scripts/test-hook-engine.mjs b/scripts/test-hook-engine.mjs new file mode 100644 index 0000000..9fcb0ff --- /dev/null +++ b/scripts/test-hook-engine.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +// Proof for the dual-target hook engine. Two layers: +// 1. Logic-core unit checks — the pure invariants in isolation. +// 2. End-to-end — vendor the pack into a temp project (real install layout), +// then run the REAL Claude Code hook AND the REAL opencode plugin against +// simulated payloads. The same rule, off the same logic core, must block +// in both tools and allow the benign case in both. +// Exits non-zero on any failure so CI gates on it. + +import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { vendor } from "./install.mjs"; +import { evaluateGitCommand } from "../core/hooks/logic/git-action.mjs"; +import { scanContent } from "../core/hooks/logic/content-scan.mjs"; +import { globToRegExp, inScope } from "../core/hooks/logic/scope.mjs"; +import { DEFAULT_POLICY } from "../core/hooks/logic/config.mjs"; +import { fromClaudeCode, fromOpenCode } from "../core/hooks/logic/payload.mjs"; +import { TEMPLATES, gateTable } from "../core/hooks/templates.mjs"; +import { strongestLayerFor } from "../core/hooks/lint-generators/index.mjs"; + +const packRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +let fails = 0; +const T = (name, got, want) => { + const ok = JSON.stringify(got) === JSON.stringify(want); + if (!ok) fails++; + console.log(`${ok ? "PASS" : "FAIL"} ${name}${ok ? "" : ` got=${JSON.stringify(got)} want=${JSON.stringify(want)}`}`); +}; +const NV = "--no-" + "verify"; + +// --------------------------------------------------------------------------- +// 1. Logic core +// --------------------------------------------------------------------------- +console.log("# logic core"); +const gp = DEFAULT_POLICY.git; +T("git: push protected blocked", !!evaluateGitCommand("git push origin main", gp)?.blocked, true); +T("git: push feature allowed", evaluateGitCommand("git push origin feature/x", gp), null); +T("git: no-verify blocked", evaluateGitCommand("git commit -m x " + NV, gp)?.rule, "no-verify"); +T("git: commit -n blocked", evaluateGitCommand("git commit -n -m x", gp)?.rule, "no-verify"); +T("git: force push protected", evaluateGitCommand("git push -f origin main", gp)?.rule, "force-push-protected"); +T("git: branch pattern blocked", evaluateGitCommand("git checkout -b wip", { ...gp, branchPattern: "^(feature|fix)/" })?.rule, "branch-name"); +T("git: branch exception allowed", evaluateGitCommand("git checkout -b hotfix/1", { ...gp, branchPattern: "^(feature|fix)/", branchExceptions: ["hotfix/*"] }), null); +T("git: forbidden trailer blocked", evaluateGitCommand('git commit -m "x\n\nBanned-Trailer: a"', { ...gp, forbiddenTrailers: ["Banned-Trailer"] })?.rule, "forbidden-trailer"); +T("git: ticket-ref missing blocked", evaluateGitCommand('git commit -m "add thing"', { ...gp, ticketPattern: "[A-Z]+-\\d+" })?.rule, "ticket-ref"); +T("git: ticket-ref present allowed", evaluateGitCommand('git commit -m "AB-12 add thing"', { ...gp, ticketPattern: "[A-Z]+-\\d+" }), null); +T("git: bare push on protected", evaluateGitCommand("git push", gp, { resolveHeadBranch: () => "main", cwd: "." })?.rule, "push-protected"); +T("git: bare push on feature allowed", evaluateGitCommand("git push", gp, { resolveHeadBranch: () => "feature/x", cwd: "." }), null); +T("git: large binary add blocked", evaluateGitCommand("git add video.mp4", { ...gp, largeBinaryGlobs: ["*.mp4"] })?.rule, "large-binary"); + +const cp = { emDash: { enabled: true }, secretScan: { enabled: true } }; +T("content: em dash flagged", scanContent("we shipped it — today", cp, "a.md").length, 1); +T("content: numeric en-dash allowed", scanContent("range 1–10 items", cp, "a.md").length, 0); +T("content: secret flagged", scanContent("k=AKIAABCDEFGHIJKLMNOP", cp, "a.ts").some((f) => f.rule === "secret"), true); +T("content: secret excluded in test path", scanContent("AKIAABCDEFGHIJKLMNOP", { secretScan: { enabled: true, scope: { exclude: ["**/*.test.*"] } } }, "x.test.ts").length, 0); +T("content: banned phrase flagged", scanContent("this is a synergy leverage", { bannedPhrases: { enabled: true, phrases: ["synergy"] } }, "a.md").length, 1); + +T("scope: ** matches deep", globToRegExp("apps/**/x.ts").test("apps/a/b/x.ts"), true); +T("scope: {a,b} alternation", globToRegExp("*.{md,txt}").test("readme.md"), true); +T("scope: exclude wins", inScope("CHANGELOG.md", { exclude: ["CHANGELOG.md"] }), false); + +T("payload: cc bash normalized", fromClaudeCode({ tool_name: "Bash", tool_input: { command: "git push" } }).kind, "git"); +T("payload: cc edit addedText", fromClaudeCode({ tool_name: "Edit", tool_input: { file_path: "a.ts", new_string: "hi" } }).addedText, "hi"); +T("payload: oc bash normalized", fromOpenCode("bash", { command: "git push" }).kind, "git"); +T("payload: oc write addedText", fromOpenCode("write", { filePath: "a.ts", content: "hi" }).addedText, "hi"); + +T("templates: registry size", TEMPLATES.length, 11); +T("templates: gate rows", gateTable().length, 11); + +// lint generators — strongest layer routing + fallback +T("lint: js console -> eslint", strongestLayerFor({ kind: "no-console", language: "javascript" }, { linters: ["eslint"] }).layer, "lint"); +T("lint: csharp severity -> roslyn", strongestLayerFor({ kind: "severity", diagnosticId: "CA1822", language: "csharp" }, { linters: ["roslyn"] }).layer, "lint"); +T("lint: python print -> ruff", strongestLayerFor({ kind: "no-print", language: "python" }, { linters: ["ruff"] }).layer, "lint"); +T("lint: text ban -> hook fallback", strongestLayerFor({ kind: "ban-text" }, { linters: ["eslint"] }).layer, "hook"); +T("lint: no stack -> hook fallback", strongestLayerFor({ kind: "no-console" }, { linters: [] }).layer, "hook"); + +// --------------------------------------------------------------------------- +// 2. End-to-end: vendor + run the real adapters in both tools +// --------------------------------------------------------------------------- +console.log("\n# end-to-end (vendored adapters)"); +const root = join(tmpdir(), "agentic-hook-engine-proof"); +rmSync(root, { recursive: true, force: true }); + +const testPolicy = { + git: { protectedBranches: ["main"] }, + content: { emDash: { enabled: true, scope: null } }, +}; + +function setup(tool) { + const into = join(root, tool); + mkdirSync(into, { recursive: true }); + vendor({ tool, into }); + writeFileSync(join(into, "hooks.policy.json"), JSON.stringify(testPolicy)); + return into; +} + +// --- Claude Code: run the real hook files with stdin payloads --- +const cc = setup("claude-code"); +function runCcHook(file, payload) { + const res = spawnSync(process.execPath, [join(cc, ".claude", "hooks", file)], { + input: JSON.stringify({ ...payload, cwd: cc }), + encoding: "utf8", + }); + return { status: res.status, stderr: res.stderr || "", stdout: res.stdout || "" }; +} +T("cc hook: push protected -> exit 2", runCcHook("git-guardrails.mjs", { tool_name: "Bash", tool_input: { command: "git push origin main" } }).status, 2); +T("cc hook: push feature -> exit 0", runCcHook("git-guardrails.mjs", { tool_name: "Bash", tool_input: { command: "git push origin feature/x" } }).status, 0); +T("cc hook: no-verify -> exit 2", runCcHook("git-guardrails.mjs", { tool_name: "Bash", tool_input: { command: "git commit -m x " + NV } }).status, 2); +T("cc hook: em dash write -> exit 2", runCcHook("content-guard.mjs", { tool_name: "Write", tool_input: { file_path: join(cc, "notes.md"), content: "we shipped — today" } }).status, 2); +T("cc hook: clean write -> exit 0", runCcHook("content-guard.mjs", { tool_name: "Write", tool_input: { file_path: join(cc, "notes.md"), content: "we shipped today" } }).status, 0); +const reminder = runCcHook("proactivity-reminder.mjs", { hook_event_name: "UserPromptSubmit" }); +T("cc hook: reminder injects context", /additionalContext/.test(reminder.stdout) && /verify/i.test(reminder.stdout), true); + +// --- opencode: import the real plugin and drive tool.execute.before --- +const oc = setup("opencode"); +// Node imports a .js as CJS without a package.json type:module; copy to .mjs so +// this test can import the ESM plugin. opencode itself loads the .js natively. +const pluginMjs = join(oc, ".opencode", "plugin", "agentic-harness.probe.mjs"); +cpSync(join(oc, ".opencode", "plugin", "agentic-harness.js"), pluginMjs); +const plugin = (await import(pathToFileURL(pluginMjs).href)).default; +const hooks = await plugin({ directory: oc }); +async function ocBlocks(tool, args) { + try { + await hooks["tool.execute.before"]({ tool }, { args }); + return false; + } catch { + return true; + } +} +T("oc plugin: push protected throws", await ocBlocks("bash", { command: "git push origin main" }), true); +T("oc plugin: push feature allowed", await ocBlocks("bash", { command: "git push origin feature/x" }), false); +T("oc plugin: em dash write throws", await ocBlocks("write", { filePath: join(oc, "notes.md"), content: "we shipped — today" }), true); +T("oc plugin: clean write allowed", await ocBlocks("write", { filePath: join(oc, "notes.md"), content: "we shipped today" }), false); +T("oc plugin: exposes session.idle guard", typeof hooks["event"], "function"); + +rmSync(root, { recursive: true, force: true }); + +console.log(`\n${fails === 0 ? "HOOK ENGINE PROOF OK" : `HOOK ENGINE PROOF FAILED (${fails})`}`); +process.exit(fails === 0 ? 0 : 1); diff --git a/workflow.config.example.yaml b/workflow.config.example.yaml index 5e11129..ebd7e18 100644 --- a/workflow.config.example.yaml +++ b/workflow.config.example.yaml @@ -60,10 +60,15 @@ pr: baseBranch: main squash: true # squash-merge only pairedPRs: true # multi-repo change → one PR per repo, cross-linked - # Trailer appended to every commit message the pack creates. Optional. + # Trailer appended to every commit message the pack creates. Optional. A + # machine that BANS authorship trailers leaves this empty and lists the banned + # trailer under `hooks.git.forbiddenTrailers` — the exact inverse. Neither is + # assumed; both are pure policy. commitTrailer: "" # Footer appended to every PR body the pack creates. Optional. prBodyFooter: "" + # Trailer strings a commit/PR body must NOT contain (hook-enforced). Optional. + forbiddenTrailers: [] # --------------------------------------------------------------------------- # worktree — where the pipeline creates git worktrees for isolated issue work. @@ -217,6 +222,9 @@ issueTracker: # Labels the stories skill applies; leave empty to apply none. labels: [] # e.g. [enhancement, needs-triage] milestones: [] + # Regex a commit message must contain when a ticket ref is required + # (hook-enforced via hooks.git.ticketPattern). Empty → not enforced. + ticketPattern: "" # e.g. "\\b[A-Z]{2,}-\\d+\\b" # --------------------------------------------------------------------------- # execution — how orchestrator skills adapt to the host tool's capabilities. @@ -233,3 +241,53 @@ execution: # synthesis. Empty → every step uses the session's default model. Only takes # effect when the host supports per-subagent model selection. cheapSubagentModel: "" + +# --------------------------------------------------------------------------- +# hooks — the SOURCE for the dual-target hook engine's runtime policy. setup +# reads this block (plus branchNaming / pr / issueTracker above) and WRITES +# `hooks.policy.json`, which the Claude Code hooks AND the opencode plugin read +# at runtime (JSON so the hooks need no YAML parser). You do not hand-edit +# hooks.policy.json — it is a generated artifact. Every field mirrors +# hooks.policy.example.json; see core/hooks/README.md for the engine design. +# +# ZERO policy is assumed: a machine that bans authorship trailers and one that +# requires them differ only by `git.forbiddenTrailers` vs `pr.commitTrailer`. +# Every content/git rule takes an optional `scope: { include, exclude }` — a +# carve-out narrows a rule, it never disables it. +# --------------------------------------------------------------------------- +hooks: + git: + protectedBranches: [main] # commit/push to these is blocked; empty → off + blockBypassFlags: true # block --no-verify / commit -n + requireSignedCommits: false # block --no-gpg-sign / gpgsign=false + branchPattern: "" # regex new branch names must match; e.g. "^(feature|fix)/" + branchExceptions: [] # globs exempt from branchPattern; e.g. ["hotfix/*"] + ticketPattern: "" # regex a -m commit message must contain; empty → off + forbiddenTrailers: [] # commit/PR body must not contain these strings + largeBinaryGlobs: [] # block `git add` of matching paths; e.g. ["*.mp4", "*.zip"] + content: + emDash: + enabled: false # flag em dashes newly written into scoped copy + allowNumericEnDash: true # keep en dashes in numeric ranges (1–10) + scope: null # { include: [...], exclude: [...] } + bannedPhrases: + enabled: false + phrases: [] # strings or regex sources + scope: null + secretScan: + enabled: true # conservative universal set; on by default + extraPatterns: [] # project-specific secret regexes + scope: + exclude: ["**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/fixtures/**"] + largeBinary: + enabled: false + maxBytes: 5242880 # 5 MB + blockedGlobs: [] + scope: null + proactivity: + reminder: + enabled: true # Layer 1: re-inject the disposition line each turn + guard: + enabled: false # Layer 2: judge the finished turn, send it back on a shortcut + judgeModel: "" # cheap model slug; EMPTY disables Layer 2 (never pin a retirable model) + skillDirs: [] # skill dirs the judge digests; empty → sensible defaults