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: 0 additions & 135 deletions .claude/hooks/check-architecture-rules.js

This file was deleted.

12 changes: 0 additions & 12 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,6 @@
]
}
],
"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
8 changes: 6 additions & 2 deletions aidd_docs/memory/coding-assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Never state in a commit message or a report anything not just observed in output

| Order | Command | Checks |
| ----- | ------- | ------ |
| 1 | `pnpm exec lefthook run pre-commit` | JSON and YAML validity, `scripts/` tests, skill frontmatter and argument hints, context imports and reference form, markdown links, the paths the prose names and a sentence written in two documents (`scripts/check-doc-duplication.js`); `cli` lint, architecture, typecheck and type honesty when `cli/` changed. `cli` knip and the full `cli` suite are pre-push, not pre-commit — see below |
| 1 | `pnpm exec lefthook run pre-commit` | JSON and YAML validity, `scripts/` tests, skill frontmatter and argument hints, cross-plugin orthogonality and router coherence (`scripts/check-architecture-rules.js`), context imports and reference form, markdown links, the paths the prose names and a sentence written in two documents (`scripts/check-doc-duplication.js`); `cli` lint, architecture, typecheck and type honesty when `cli/` changed. `cli` knip and the full `cli` suite are pre-push, not pre-commit — see below |
| 2 | `pnpm exec commitlint --edit` | the message against `commitlint.config.cjs` |

Same hook regenerates each plugin's `CATALOG.md`, the README counts and `docs/prompts-documentation.md`, and stages them.
Expand All @@ -45,7 +45,11 @@ Every `cli` job is globbed on `cli/**`. A change under `kanban/` alone fires no

Each runs through `scripts/gate-witness.js`, which skips a gate this exact tree already passed: same index, same unstaged edits, same untracked files. A tree that changed while the gate ran is never stamped.

`--no-verify` buys nothing: `validate.yml` re-runs the whole pre-commit over the whole tree on every push and pull request.
`--no-verify` buys little, not nothing:

- A push to a feature branch fires no workflow.
- Any pull request fires `ci.yml` and `cli-ci.yml`, whatever its base.
- Only a pull request targeting `main` or `next` replays the pre-commit, and `validate.yml` drops `cli-biome`, `cli-architecture` and `cli-typecheck` from it.

## Behavior

Expand Down
7 changes: 5 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ flowchart LR

Recipe skills route to self-contained actions with inputs, outputs, process steps, and tests. An orchestrator with no domain logic may instead route through numbered reference protocols that define handoffs and delegate the work to capabilities discovered at runtime.

A skill never links outside itself. The same tree ships flat, where the skill folder is renamed `<plugin>-<skill>`, or as a marketplace, so no relative path survives both. A bundled script is named plugin-relative in backticks, never linked.
A skill never links outside itself (`scripts/__tests__/a-skill-links-only-inside-itself.test.js`). The same tree ships flat, where the skill folder is renamed `<plugin>-<skill>`, or as a marketplace, so no relative path survives both. A bundled script is named plugin-relative in backticks, never linked.

## 🤖 Skills and agents

Expand All @@ -155,7 +155,10 @@ Address a capability only where the dispatch is declared: a router's `## Actions

Recipe skills never hardcode a sibling provider. They discover cross-plugin capabilities at runtime through description matching. Agent permission lists and orchestration references are responsibility maps, so they name the current provider with its canonical `/plugin:folder` or `@plugin:agent` address. The orchestrator must verify that provider is installed before calling it.

`scripts/lib/architecture-rules.js` is the guard that decides both rules from a path and its content, and `architecture-scan.js` is the only place that reads the disk for it. Two callers share them. `scripts/check-architecture-rules.js` runs as the `architecture-rules` pre-commit job, so every edit that reaches a commit is covered whichever tool or person made it, and `validate.yml` replays it over the whole tree on each pull request. The `PreToolUse` hook on `Write`/`Edit`/`MultiEdit` is the fast path on top: it only ever sees Claude Code, but it refuses the write in the same turn instead of at commit time. `plugins/aidd-context/skills/00-onboard/**` is exempt from rule one: its reference menus name addresses because those are what the skill hands a person to type, not a hardcoded sibling provider. That exemption is temporary and ends with the follow-up issue on making that skill resolve its providers at runtime. The guard refuses an edit leaving a `## Actions` section without a citation for an action file that exists; it never refuses a citation with no file behind it, because that is how a skill legitimately grows one.
Two paths are exempt, both in `isExemptFromOrthogonality`:

- `plugins/aidd-orchestrator/**`, whose references are responsibility maps.
- `plugins/aidd-context/skills/00-onboard/**`, whose menus name addresses a person types. Temporary: it ends when that skill resolves its providers at runtime.

This distinction keeps recipe plugins swappable while making orchestration handoffs explicit and auditable.

Expand Down
178 changes: 178 additions & 0 deletions scripts/__tests__/architecture-doc-matches-the-tree.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");

const { checkOrthogonality } = require("../lib/architecture-rules.js");

const ROOT = path.resolve(__dirname, "../..");
const DOC = path.join(ROOT, "docs/ARCHITECTURE.md");

/**
* `docs/ARCHITECTURE.md` describes the tree in two of its tables: the bundled hooks each plugin
* declares, and the plugin roster. Both were right when written and nothing compared them to the
* tree afterwards, which is how a document goes quietly wrong. These tests are that comparison.
*
* Tables are found by their header cells and read by column name, never by position, so adding a
* column, reordering two tables under one heading or leaving a blank line between rows changes
* nothing here. What must not change is a header cell this file names.
*/

const CELL_SEPARATOR = /(?<!\\)\|/;

function rowCells(line) {
return line
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split(CELL_SEPARATOR)
.map((cell) => cell.trim());
}

function isSeparatorRow(cells) {
return cells.every((cell) => /^:?-+:?$/.test(cell));
}

/**
* Every table in the file, as `{ header, rows }`. A blank line does not end a table, because a
* table split for grouping is still one table. Any other non-row line does, because otherwise two
* tables separated by prose read as one.
*/
function tables() {
const found = [];
let current = null;

for (const line of fs.readFileSync(DOC, "utf8").split("\n")) {
if (!line.trim().startsWith("|")) {
if (line.trim() !== "") current = null;
continue;
}
const cells = rowCells(line);
if (isSeparatorRow(cells)) continue;
if (current === null) {
current = { header: cells, rows: [] };
found.push(current);
} else {
current.rows.push(cells);
}
}
return found;
}

/** The one table whose header holds every name given. Never two, never none. */
function tableWithColumns(...names) {
const matches = tables().filter((table) =>
names.every((name) => table.header.some((cell) => cell.toLowerCase() === name.toLowerCase()))
);
assert.equal(
matches.length,
1,
`expected exactly one table in ARCHITECTURE.md with columns ${names.join(", ")}, found ${matches.length}`
);
return matches[0];
}

function column(table, name) {
const at = table.header.findIndex((cell) => cell.toLowerCase() === name.toLowerCase());
return (cells) => cells[at] ?? "";
}

/** `` `aidd-ui` 🚧 `` and `` `SessionStart` · `Stop` `` both mean the backticked tokens. */
function backticked(cell) {
return [...cell.matchAll(/`([^`]+)`/g)].map((match) => match[1]);
}

function onlyBacktickedName(cell, where) {
const names = backticked(cell);
assert.equal(names.length, 1, `${where}: expected one backticked name, got ${JSON.stringify(cell)}`);
return names[0];
}

function pluginDirectories() {
return fs
.readdirSync(path.join(ROOT, "plugins"), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}

function declaredHookEvents(plugin) {
const manifest = path.join(ROOT, "plugins", plugin, "hooks/hooks.json");
if (!fs.existsSync(manifest)) return null;
return Object.keys(JSON.parse(fs.readFileSync(manifest, "utf8")).hooks ?? {}).sort();
}

const hooksTable = () => tableWithColumns("Plugin", "Event", "Runs");
const concernsTable = () => tableWithColumns("Plugin", "Concern", "Layer");

test("the bundled hooks table names every plugin that declares hooks, and no other", () => {
const table = hooksTable();
const plugin = column(table, "Plugin");
const documented = table.rows.map((cells) => onlyBacktickedName(plugin(cells), "hooks table")).sort();
assert.deepEqual(documented, pluginDirectories().filter((name) => declaredHookEvents(name) !== null));
});

test("the bundled hooks table names the events each plugin actually declares", () => {
const table = hooksTable();
const plugin = column(table, "Plugin");
const event = column(table, "Event");
for (const cells of table.rows) {
const name = onlyBacktickedName(plugin(cells), "hooks table");
assert.deepEqual(
backticked(event(cells)).sort(),
declaredHookEvents(name),
`${name}: the table and plugins/${name}/hooks/hooks.json disagree`
);
}
});

test("the bundled hooks table names a script that exists", () => {
// Without this the table can point at a deleted hook and still read as current.
const table = hooksTable();
const plugin = column(table, "Plugin");
const runs = column(table, "Runs");
for (const cells of table.rows) {
const name = onlyBacktickedName(plugin(cells), "hooks table");
const script = onlyBacktickedName(runs(cells), `hooks table, ${name} Runs cell`);
const full = path.join(ROOT, "plugins", name, script);
assert.ok(fs.existsSync(full), `plugins/${name}/${script} is named in ARCHITECTURE.md and does not exist`);
}
});

test("the plugin concerns table has one row per plugin in the tree", () => {
const table = concernsTable();
const plugin = column(table, "Plugin");
const documented = table.rows.map((cells) => onlyBacktickedName(plugin(cells), "concerns table")).sort();
assert.deepEqual(documented, pluginDirectories());
});

/**
* Rule one has two holes, both deliberate, both in `isExemptFromOrthogonality`. The 00-onboard one
* is load-bearing only while that skill still hardcodes the addresses it hands a person to type.
* Once it resolves its providers at runtime the hole protects nothing and should go, and nothing
* else would say so.
*/
test("the 00-onboard exemption still protects something", () => {
const skillDir = path.join(ROOT, "plugins/aidd-context/skills/00-onboard");
const files = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith(".md")) files.push(full);
}
};
walk(skillDir);

// Judged under a path the exemption does not cover, so the rule speaks instead of skipping.
const probePath = "plugins/aidd-context/skills/99-exemption-probe/SKILL.md";
const wouldViolate = files.filter(
(file) => checkOrthogonality(probePath, fs.readFileSync(file, "utf8")).length > 0
);

assert.ok(
wouldViolate.length > 0,
"00-onboard no longer hardcodes a sibling address. Delete ONBOARD_EXEMPT_PREFIX from " +
"scripts/lib/architecture-rules.js, its exemption bullet in docs/ARCHITECTURE.md, and this test."
);
});
Loading
Loading