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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
10 changes: 8 additions & 2 deletions .github/workflows/genericity-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
53 changes: 37 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
58 changes: 58 additions & 0 deletions adapters/claude-code/hooks/content-guard.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
59 changes: 59 additions & 0 deletions adapters/claude-code/hooks/git-guardrails.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
121 changes: 121 additions & 0 deletions adapters/claude-code/hooks/proactivity-guard.mjs
Original file line number Diff line number Diff line change
@@ -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));
40 changes: 40 additions & 0 deletions adapters/claude-code/hooks/proactivity-reminder.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading