Skip to content
Open
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
4 changes: 2 additions & 2 deletions .agents/shared/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ In plain English: we want the agent to get the right project-specific advice rig

1. The agent says it is about to edit a file.
2. The loader looks at that file path.
3. It finds any matching rule docs under `.agents/shared/skills/rules/`.
3. It finds any matching rule docs under `skills/shared/rules/`.
4. It bundles those matches into `additionalContext`.
5. The agent-specific adapter hands that context to the runtime in whatever proprietary shape that runtime expects.

Expand Down Expand Up @@ -42,7 +42,7 @@ If another runtime needs a different wrapper, it should reuse the shared loader

This spike is real because all of these are present:

- checked-in shared rules under `.agents/shared/skills/rules/`
- checked-in shared rules under `skills/shared/rules/`
- a shared loader that can be run directly from the CLI
- a Claude adapter that uses the shared loader
- contract tests proving the generic and Claude outputs carry the same rule content
Expand Down
20 changes: 12 additions & 8 deletions .agents/shared/hooks/inject-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// roots, matches their `globs` field against the file being edited,
// and emits matched rule content in the shape an adapter asks for.
//
// Spec: .agents/shared/skills/rules/README.md
// Spec: skills/shared/rules/README.md

import {
appendFileSync,
Expand Down Expand Up @@ -62,7 +62,7 @@ export const canonicalize = (path) => {
export const DEFAULT_REPO_ROOT = canonicalize(
resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'),
);
export const DEFAULT_RULE_ROOTS = ['.agents/shared/skills/rules'];
export const DEFAULT_RULE_ROOTS = ['skills/shared/rules'];
export const TRIGGER_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);

export const parseFrontmatter = (content) => {
Expand Down Expand Up @@ -136,24 +136,28 @@ export const collectRules = ({
continue;
}

for (const entry of readdirSync(dir)) {
if (!entry.endsWith('.md') || entry === 'README.md') {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}

const fullPath = join(dir, entry);
const content = readFileSync(fullPath, 'utf8');
const skillMd = join(dir, entry.name, 'SKILL.md');
if (!existsSync(skillMd)) {
continue;
}

const content = readFileSync(skillMd, 'utf8');
const { meta, body } = parseFrontmatter(content);

if (meta.kind !== 'rule') {
continue;
}

rules.push({
name: meta.name || entry.replace(/\.md$/, ''),
name: meta.name || entry.name,
globs: meta.globs,
body,
source: relative(repoRoot, fullPath),
source: relative(repoRoot, skillMd),
});
}
}
Expand Down
17 changes: 14 additions & 3 deletions .agents/shared/hooks/inject-rules.test-helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ export const registerTmpCleanup = () => {
});
};

/**
* Write a rule-skill into a rules root as <rules>/<name>/SKILL.md.
*/
export const writeRule = (ruleDir, name, frontmatter, body) => {
const dir = join(ruleDir, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'SKILL.md'), `---\n${frontmatter}\n---\n${body}`);
};

export const registerGuardrailsContractSuite = ({
suiteName,
buildResult,
Expand All @@ -37,9 +46,11 @@ export const registerGuardrailsContractSuite = ({
tmp = mkdtempSync(join(tmpdir(), TMP_PREFIX));
ruleDir = join(tmp, 'rules');
mkdirSync(ruleDir);
writeFileSync(
join(ruleDir, 'q.md'),
'---\nname: q\nglobs: src/**/api/**\nkind: rule\n---\nquery rule body',
writeRule(
ruleDir,
'q',
'name: q\nglobs: src/**/api/**\nkind: rule',
'query rule body',
);
mkdirSync(join(tmp, 'src', 'shared', 'api'), { recursive: true });
});
Expand Down
49 changes: 29 additions & 20 deletions .agents/shared/hooks/inject-rules.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
registerGuardrailsContractSuite,
registerTmpCleanup,
TMP_PREFIX,
writeRule,
} from './inject-rules.test-helpers.mjs';

registerTmpCleanup();
Expand Down Expand Up @@ -127,10 +128,12 @@ describe('collectRules', () => {
rmSync(tmp, { recursive: true, force: true });
});

it('discovers rule files with kind: rule', () => {
writeFileSync(
join(ruleDir, 'foo.md'),
'---\nname: foo\nglobs: src/foo/**\nkind: rule\n---\nfoo body',
it('discovers rule skills with kind: rule', () => {
writeRule(
ruleDir,
'foo',
'name: foo\nglobs: src/foo/**\nkind: rule',
'foo body',
);
const rules = collectRules({ repoRoot: tmp, ruleRoots: ['rules'] });
assert.equal(rules.length, 1);
Expand All @@ -139,7 +142,7 @@ describe('collectRules', () => {
assert.equal(rules[0].body.trim(), 'foo body');
});

it('skips README.md', () => {
it('ignores a stray README.md at the rule-root level', () => {
writeFileSync(
join(ruleDir, 'README.md'),
'---\nname: readme\nkind: rule\nglobs: **\n---\nx',
Expand All @@ -148,12 +151,12 @@ describe('collectRules', () => {
assert.equal(rules.length, 0);
});

it('skips files without kind: rule', () => {
writeFileSync(
join(ruleDir, 'cmd.md'),
'---\nname: cmd\nkind: command\nglobs: **\n---\nx',
);
writeFileSync(join(ruleDir, 'plain.md'), '# no frontmatter');
it('skips skills without kind: rule', () => {
// A SKILL.md without kind: rule is a workflow skill, not a rule.
writeRule(ruleDir, 'cmd', 'name: cmd\nkind: command\nglobs: **', 'x');
// A directory without SKILL.md is invisible to the loader.
mkdirSync(join(ruleDir, 'plain'));
writeFileSync(join(ruleDir, 'plain', 'notes.md'), '# no frontmatter');
const rules = collectRules({ repoRoot: tmp, ruleRoots: ['rules'] });
assert.equal(rules.length, 0);
});
Expand All @@ -169,13 +172,17 @@ describe('collectRules', () => {
it('merges across multiple rule roots', () => {
const dir2 = join(tmp, 'local');
mkdirSync(dir2);
writeFileSync(
join(ruleDir, 'shared.md'),
'---\nname: shared\nglobs: src/**\nkind: rule\n---\nshared',
writeRule(
ruleDir,
'shared',
'name: shared\nglobs: src/**\nkind: rule',
'shared',
);
writeFileSync(
join(dir2, 'local.md'),
'---\nname: local\nglobs: src/**\nkind: rule\n---\nlocal',
writeRule(
dir2,
'local',
'name: local\nglobs: src/**\nkind: rule',
'local',
);
const rules = collectRules({
repoRoot: tmp,
Expand All @@ -197,9 +204,11 @@ describe('buildRuleMatch', () => {
tmp = mkdtempSync(join(tmpdir(), TMP_PREFIX));
ruleDir = join(tmp, 'rules');
mkdirSync(ruleDir);
writeFileSync(
join(ruleDir, 'q.md'),
'---\nname: q\nglobs: src/**/api/**\nkind: rule\n---\nquery rule body',
writeRule(
ruleDir,
'q',
'name: q\nglobs: src/**/api/**\nkind: rule',
'query rule body',
);
mkdirSync(join(tmp, 'src', 'shared', 'api'), { recursive: true });
});
Expand Down
2 changes: 1 addition & 1 deletion .agents/shared/metrics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Telemetry for the rule-skills spike. Answers one question: **is this spike pulli

## Why this exists

The rule-skills system at `.agents/shared/skills/rules/` is a spike. It has a real maintenance cost (loader, frontmatter discipline, contributor education) and a speculative benefit (agents make fewer convention mistakes). We need numbers to know whether the trade is worth it.
The rule-skills system at `skills/shared/rules/` is a spike. It has a real maintenance cost (loader, frontmatter discipline, contributor education) and a speculative benefit (agents make fewer convention mistakes). We need numbers to know whether the trade is worth it.

The metrics in this folder answer:

Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ next-env.d.ts
.agents/**
!.agents/shared/
!.agents/shared/**
# generated skill discovery roots (pnpm skills:sync)
.agents/skills/
.claude/skills/
# skills CLI generated lockfile (regenerated by pnpm skills:sync)
skills-lock.json
.playwright-mcp
.patches
.omp
Expand Down
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Full wiring conventions (dynamic imports, definitions map, params) live in the `

Two parallel trees, each split into `shared/` (checked in) and `local/` (gitignored):

- `.agents/shared/` — agent-neutral commons: rule-skills, loader, metrics. Consumed by any runtime.
- `.agents/shared/` — agent-neutral commons: guardrails loader, metrics. Rule-skills live at `skills/shared/rules/` (see below). Consumed by any runtime.
- `.agents/local/` — IC-personal agent-neutral stuff (drafts, personal skills, metric buffer).
- `.claude/shared/` — Claude-specific shared wiring (the adapter hook). Tiny on purpose.
- `.claude/` (root) — Claude's required fixed paths: `settings.json` (checked in), `settings.local.json` and `CLAUDE.md` (gitignored, IC-personal).
Expand All @@ -57,13 +57,13 @@ Gitignore exposes `.agents/shared/**` and `.claude/shared/**` (plus `.claude/set

Narrow, prescriptive guardrails scoped by file path. Each rule fires only when the file you're editing matches its `globs` field.

Rules live at `.agents/shared/skills/rules/*.md` — always checked in, never per-IC. A rule that's worth firing on every PR is by definition a shared convention; personal preferences belong in `.claude/CLAUDE.md` or IC settings, not in the rule stream.
Rules live at `skills/shared/rules/<name>/SKILL.md` — always checked in, never per-IC. A rule that's worth firing on every PR is by definition a shared convention; personal preferences belong in `.claude/CLAUDE.md` or IC settings, not in the rule stream.

The shared loader lives at `.agents/shared/hooks/inject-rules.mjs`. The rule stream stays agent-agnostic; only the proprietary adapter shape differs. Claude Code consumes it via `.claude/shared/hooks/inject-rules.mjs`. Spec: `.agents/shared/skills/rules/README.md`.
The shared loader lives at `.agents/shared/hooks/inject-rules.mjs`. The rule stream stays agent-agnostic; only the proprietary adapter shape differs. Claude Code consumes it via `.claude/shared/hooks/inject-rules.mjs`. Spec: `skills/shared/rules/README.md`.

In plain English: this is a lazy-loaded guardrails system. Instead of putting every subtle convention in the root prompt, we keep narrow rules in Markdown and load only the ones that match the file being edited. The MVP/POC and its proof live in `.agents/shared/skills/rules/README.md` and `.agents/shared/hooks/README.md`.
In plain English: this is a lazy-loaded guardrails system. Instead of putting every subtle convention in the root prompt, we keep narrow rules in Markdown and load only the ones that match the file being edited. The MVP/POC and its proof live in `skills/shared/rules/README.md` and `.agents/shared/hooks/README.md`.

To author a new rule, copy an existing one in `.agents/shared/skills/rules/` and follow the README — the `rule-authoring` rule-skill auto-injects when you edit anything in that folder.
To author a new rule, copy an existing one in `skills/shared/rules/` and follow the README — the `rule-authoring` rule-skill auto-injects when you edit anything in that folder.

Authorship is bottom-up: when a code review surfaces a non-obvious convention, or you catch yourself fixing the same class of mistake more than once, propose a rule-skill update. Don't pre-write rules speculatively.

Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@
"test:changed": "turbo run test:changed",
"test:coverage": "pnpm validate:changesets && pnpm test:release-summary && turbo run test:coverage",
"test:guardrails": "node --test .agents/shared/hooks/*.test.mjs .claude/shared/hooks/*.test.mjs",
"test:hooks": "pnpm test:guardrails",
"test:skills": "node --test scripts/sync-skills.test.mjs",
"test:hooks": "pnpm test:guardrails && pnpm test:skills",
"test:release-summary": "node --test '.github/workflows/scripts/*.test.js'",
"validate:changesets": "node .github/workflows/scripts/validateChangesets.js",
"guardrails:record": "node .agents/shared/hooks/record-metrics.mjs",
"guardrails:stats": "node .agents/shared/hooks/stats-metrics.mjs",
"lint": "turbo run lint",
"lint:check": "turbo run lint:check",
"type-check": "turbo run type-check",
"skills:sync": "node scripts/sync-skills.mjs",
"postinstall": "pnpm run skills:sync",
"prepare": "husky"
},
"devDependencies": {
Expand All @@ -27,6 +30,7 @@
"@changesets/cli": "catalog:",
"husky": "catalog:",
"lint-staged": "catalog:",
"skills": "1.5.20",
"turbo": "catalog:",
"ultracite": "catalog:",
"vercel": "catalog:",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading