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
135 changes: 135 additions & 0 deletions .claude/hooks/check-architecture-rules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env node

/**
* PreToolUse guard: refuses a Write, Edit or MultiEdit that would leave a plugin's dispatch
* surface breaking one of the two architecture rules of issue #250. The rules live in
* `scripts/lib/architecture-rules.js` and their filesystem layer in `architecture-scan.js`;
* this script only reconstructs the unwritten content and speaks the host's refusal.
*
* It is the fast path, not the gate: `scripts/check-architecture-rules.js` runs on every commit,
* whichever tool made the edit. This one only ever sees Claude Code, and it fails open at every
* step β€” an unreadable payload, an unknown tool, an edit it cannot reconstruct or a crash lets
* the write through rather than halting unrelated work.
*/

const fs = require("node:fs");
const path = require("node:path");

const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
const PROCEED = 0;

function architecture() {
try {
const lib = path.resolve(__dirname, "..", "..", "scripts", "lib");
return {
...require(path.join(lib, "architecture-scan.js")),
classifyFile: require(path.join(lib, "architecture-rules.js")).classifyFile,
};
} catch {
return null;
}
}

function payloadFromStdin() {
try {
const parsed = JSON.parse(fs.readFileSync(0, "utf8"));
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}

function repoRelative(absolutePath, root) {
const relative = path.relative(root, absolutePath);
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
return relative.split(path.sep).join("/");
}

function readFile(absolutePath) {
try {
return fs.readFileSync(absolutePath, "utf8");
} catch {
return null;
}
}

/**
* Splices literally. `String.prototype.replace` reads `$&`, `` $` ``, `$'` and `$1` in its
* replacement even when the pattern is a plain string, which corrupts a `new_string` holding one.
*/
function spliced(content, { old_string: oldString, new_string: newString, replace_all: replaceAll }) {
if (typeof oldString !== "string" || typeof newString !== "string") return null;
if (!content.includes(oldString)) return null;
if (replaceAll) return content.split(oldString).join(newString);

const at = content.indexOf(oldString);
return content.slice(0, at) + newString + content.slice(at + oldString.length);
}

function editedContent(absolutePath, edits) {
let content = readFile(absolutePath);
if (content === null) return null;

for (const edit of edits) {
if (!edit || typeof edit !== "object") return null;
content = spliced(content, edit);
if (content === null) return null;
}
return content;
}

/** What the file will hold if this call goes through, or null when that cannot be determined. */
function prospectiveContent(toolName, toolInput, absolutePath) {
if (toolName === "Write") {
return typeof toolInput.content === "string" ? toolInput.content : null;
}
if (toolName === "Edit") return editedContent(absolutePath, [toolInput]);

const { edits } = toolInput;
if (!Array.isArray(edits) || edits.length === 0) return null;
return editedContent(absolutePath, edits);
}

function refuse(violations, describeFix) {
const reason = violations.map((v) => `${v.message}. Fix: ${describeFix(v)}.`).join("\n");
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: reason,
},
})
);
}

function main() {
const rules = architecture();
const payload = payloadFromStdin();
if (!rules || !payload) return PROCEED;

const { tool_name: toolName, tool_input: toolInput } = payload;
if (!WRITE_TOOLS.has(toolName)) return PROCEED;
if (!toolInput || typeof toolInput.file_path !== "string" || toolInput.file_path === "") return PROCEED;

// Resolved against the project root, never the process cwd: the two can differ, and a readdir
// against the wrong one comes back empty instead of failing.
const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const absolutePath = path.resolve(root, toolInput.file_path);
const relativePath = repoRelative(absolutePath, root);
if (!relativePath || !rules.classifyFile(relativePath)) return PROCEED;

const content = prospectiveContent(toolName, toolInput, absolutePath);
if (content === null) return PROCEED;

const violations = rules.violationsForFile(relativePath, content, absolutePath);
if (violations.length > 0) refuse(violations, rules.describeFix);

return PROCEED;
}

try {
process.exitCode = main();
} catch {
process.exitCode = PROCEED;
}
12 changes: 12 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-architecture-rules.js\"",
"timeout": 60
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"backlog": "ai-driven-dev/framework#250",
"written_at": "2026-09-18T09:24:15Z",
"written_by": "aidd-pm:04-spec"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@

# Instruction: The two rules, as a tested engine

## Architecture projection

> Tree of the final files. βœ… create Β· ✏️ modify Β· ❌ delete

```txt
.
β”œβ”€β”€ scripts
β”‚ β”œβ”€β”€ lib
β”‚ β”‚ └── architecture-rules.js βœ… the two rules, as pure functions
β”‚ └── __tests__
β”‚ β”œβ”€β”€ architecture-rules.test.js βœ… both rules, both directions, plus the clean-tree sweep
β”‚ └── fixtures
β”‚ └── architecture-rules βœ… synthetic plugin trees, written for this task
└── docs
└── ARCHITECTURE.md ✏️ one line pointing at the guard that now enforces the rule
```

## User Journey

```mermaid
flowchart TD
A[A contributor edits a plugin source] --> B{Does the prospective content break a rule?}
B -- no --> C[Nothing is said]
B -- "a sibling plugin is addressed" --> D[File, line, owning plugin]
B -- "the Actions section is out of step" --> E[File, line, the action that does not match]
D --> F[The contributor corrects it and edits again]
E --> F
```

## Test Scope

```mermaid
---
title: Test scope
---
journey
section Setup
Write the synthetic plugin fixtures under the test fixtures directory => fixtures on disk: 5: system
section Happy path
Run the rule engine over a fixture whose skill addresses only its own plugin => no violation: 5: cli
Run the rule engine over the repository's own plugins directory => no violation: 5: cli
section Edge case - a recipe skill addresses a sibling
A skill file names another plugin's skill => run the engine over it => one violation naming the file, the line and the owning plugin: 1: cli
section Edge case - a permission list addresses a sibling
An agent lists a sibling skill under its permission heading => run the engine over it => no violation: 1: cli
section Edge case - an orchestration reference addresses a sibling
An orchestrator reference names a provider => run the engine over it => no violation: 1: cli
section Edge case - an action file no section names
A skill gains an action file its Actions section never mentions => run the engine over it => one violation naming the file: 1: cli
section Edge case - a section whose mention is only prose
A word in the section's prose matches an action's stem but no row cites it => run the engine over it => one violation naming the file: 1: cli
```

## Tasks to do

### `1)` The failing tests come first

> Every rule gets its red before it gets its engine.

1. Write the synthetic fixtures: a plugin tree with a clean skill, a skill addressing a sibling, an agent permission list addressing a sibling, an orchestrator reference addressing a sibling, a skill with an unnamed action file, a skill citing an absent action.
2. Write `architecture-rules.test.js` covering each fixture and each expected verdict, plus one case that sweeps the repository's real `plugins/` and expects zero violations.
3. Run the suite and watch every case fail for the absence of the module, not for a typo.

### `2)` The orthogonality rule

> A dispatch surface never names a sibling plugin.

1. Expose a function taking a repository-relative path and the prospective content, returning violations with a line, a 1-indexed number, the address found and the plugin that owns it.
2. Match `/<plugin>:<skill>` and `@<plugin>:<agent>` addresses; a match whose plugin equals the file's own owner is not a violation.
3. Govern only `SKILL.md`, `actions/*.md`, `references/*.md` and `agents/*.md`. Anything under `assets/` returns nothing.
4. Exempt a file owned by an orchestrator plugin, and exempt the lines under an agent's `# Skills you may invoke` heading.

### `3)` The router coherence rule

> An Actions section cites every action that exists.

1. Take the prospective `SKILL.md` content and the names of the skill's action files.
2. Isolate the `## Actions` section, up to the next second-level heading.
3. Report an action file the section cites by neither its stem, its file name, nor an `actions/<name>.md` path. Collect a citation from the table's action column, never from running prose.
4. Report the line of the section heading. A citation with no file behind it is deliberately not reported β€” it cannot be told apart from one written just before the file it names.

### `4)` The rule the repository already follows

> A guard that is red on a clean tree is a guard nobody keeps.

1. Run the sweep case over the real `plugins/` tree.
2. When a rule fires there, the rule is wrong, not the tree. Narrow it and record why in `plan.md`'s decisions.

### `5)` The rule document points at its enforcement

> `docs/ARCHITECTURE.md` states the rule; say where it is now checked.

1. Add one sentence naming the guard, plugin-relative and in backticks, never as a link.

## Test acceptance criteria

| Task | Acceptance criteria |
| --- | --- |
| 1 | Every case in the suite fails before the engine exists, each for the missing module |
| 2 | A skill addressing a sibling yields a violation naming file, line and owning plugin; an agent permission list and an orchestrator reference yield none; a file under `assets/` yields none |
| 3 | An action file the section never cites yields one violation; a stem appearing only in prose does not count as a citation; a skill whose section and files agree yields none |
| 4 | Sweeping the repository's own `plugins/` yields zero violations |
| 5 | `docs/ARCHITECTURE.md` names the guard, and the markdown-link check still passes |
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@

# Instruction: The refusal, at the AI host's write moment

## Architecture projection

> Tree of the final files. βœ… create Β· ✏️ modify Β· ❌ delete

```txt
.
β”œβ”€β”€ .claude
β”‚ β”œβ”€β”€ hooks
β”‚ β”‚ └── check-architecture-rules.js βœ… reads the pending edit, calls the engine, refuses
β”‚ └── settings.json ✏️ one PreToolUse entry matching Write and Edit
└── scripts
└── __tests__
└── architecture-hook.test.js βœ… the payload shapes and the refusal contract
```

## User Journey

```mermaid
flowchart TD
A[An agent calls Write or Edit on a plugin source] --> B[PreToolUse hands the hook the pending content]
B --> C{Does the prospective content break a rule?}
C -- no --> D[The write proceeds]
C -- yes --> E[The call is denied, with file, line and owning plugin]
E --> F[The agent corrects the content and calls again]
F --> B
```

## Test Scope

```mermaid
---
title: Test scope
---
journey
section Setup
Build a PreToolUse payload for a plugin file => payload on stdin: 5: system
section Happy path
Run the hook on a payload whose content breaks no rule => exit zero and no output: 5: cli
section Edge case - a Write that introduces a sibling address
The payload carries content addressing another plugin => run the hook => a deny decision naming file, line and owning plugin: 1: cli
section Edge case - an Edit that introduces a sibling address
The payload carries an old and a new string => run the hook => the reconstructed content is judged, and the call is denied: 1: cli
section Edge case - an Edit whose old string is absent
The payload cannot be reconstructed => run the hook => exit zero, because the edit will fail on its own: 1: cli
section Edge case - a file outside the governed surface
The payload names a file under assets => run the hook => exit zero: 1: cli
section Teardown
Remove the temporary payload files => baseline restored: 5: system
```

## Tasks to do

### `1)` The hook, generated by the capability that owns hooks

> The decider asked for a host-native lifecycle hook produced by the hook capability, not a hand-placed script.

1. Run `/aidd-context:08-hook-generate` for a `PreToolUse` hook matching `Write|Edit`, at the project scope, for Claude Code β€” the one host this repository configures hooks for, per the plan's decisions.
2. Keep the generated entry and script; give the script the name the projection states.

### `2)` The prospective content

> Judge what the file will hold, not what it holds now.

1. Read the payload from standard input. Take `tool_input.file_path`.
2. For a `Write`, the prospective content is `tool_input.content`.
3. For an `Edit`, read the file and apply `old_string` to `new_string`, once or everywhere per `replace_all`.
4. When the file path is outside the governed surface, or the reconstruction fails, exit zero and say nothing.

### `3)` The refusal

> A refusal a reader cannot act on is noise.

1. Call the engine with the path, the prospective content, and the skill's action file names when the path is a `SKILL.md`.
2. On a violation, emit the `PreToolUse` deny decision, with a reason naming each file, line, and the plugin that owns the address, and what to write instead.
3. On no violation, exit zero with no output.

### `4)` The failing tests come first

> The hook is a contract with the host; test it as one.

1. Write `architecture-hook.test.js` driving the hook as a subprocess with each payload shape.
2. Watch each case fail before the hook exists.
3. Assert the decision is a deny and the reason carries the file and the line, never only the rule.

### `5)` The proof

> A guard nobody watched fire is a comment.

1. Write a plugin file addressing a sibling, through the agent's own Write tool, and record that the call was refused and what it said.
2. Write an agent permission list naming a sibling the same way, and record that it was applied.

## Test acceptance criteria

| Task | Acceptance criteria |
| --- | --- |
| 1 | The hook entry exists in `.claude/settings.json` at the project scope and fires on `Write` and `Edit` |
| 2 | A `Write` payload and an `Edit` payload over the same file yield the same verdict for the same resulting content |
| 3 | A denied call returns a reason naming file, line and owning plugin; a clean call returns nothing at all |
| 4 | Every case fails before the hook exists, then passes |
| 5 | An attempted write of a sibling address is refused in the same turn; an attempted write of a permission list is applied |
Loading
Loading