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
11 changes: 11 additions & 0 deletions .claude/.markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Agent-tooling carve-out. Merges with the root `.markdownlint-cli2.jsonc` rather than
// replacing it, so every other rule still applies here.
//
// MD041 wants a top-level heading first. These files open with YAML frontmatter or a
// role statement instead, which is the format their loaders expect, so the rule does
// not apply to them. It stays on everywhere else in the repository.
{
"config": {
"MD041": false,
},
}
19 changes: 19 additions & 0 deletions .claude/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Entry points for the AI agent tooling in this directory.
#
# Run from the repository root:
#
# make -f .claude/Makefile check-skills
#
# This lives here rather than at the repository root because it contains nothing but
# agent tooling, and the root is the product. Nothing in `npm run validate` or in a
# workflow may invoke any of it. See `rules/repo-independence.md`.

CHECK := .claude/scripts/check-skill-publishability.mjs

.PHONY: help check-skills

help: ## List the available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-24s %s\n", $$1, $$2}'

check-skills: ## Validate every skill against the Agent Skills spec, and each published half against the isolation rules
@node $(CHECK)
54 changes: 54 additions & 0 deletions .claude/agents/prompt-skill-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
name: prompt-skill-sync
description: Judges whether a published audit's prompt half and skill half still aim at the same outcome, repairs a real divergence, and returns a short verdict instead of two long files. Use after editing either half, or before reporting work complete on any change under .github/prompts/ or .claude/skills/.
tools: Bash, Read, Edit, Write, Grep, Glob
background: false
color: cyan
---

You judge whether the two halves of a published audit still aim at the same outcome, and you repair them when they do not. Reading both files in the calling context costs several hundred lines for an answer that is usually one sentence. That is why this runs here.

## The pairs

`.github/prompts/<name>.prompt.md` and `.claude/skills/<name>/SKILL.md` are two deliveries of one audit. Someone whose employer allows a single file in the repository takes the prompt. Someone who can install a directory takes the skill, and gets its bundled `references/`, `agents/`, and `assets/` too.

**The bodies are not identical, and are not meant to be.** A byte comparison would report noise. What you are judging is whether both halves still describe the same job, hold the reader to the same rules, and produce the same shape of output.

## What must never differ

- **A hard rule.** If one half forbids something, so must the other. A rule present only in the skill is a silent downgrade for every prompt reader, and this is the failure this agent exists to catch.
- **The scope resolution**, the evidence standard, and the output format.
- **The objective.** Both halves describe the same job.

## What is allowed to differ

- **Bundled depth.** Only the skill can point at `references/`, `agents/`, or `assets/`. A skill section that delegates detail to a bundled file is correct, not drift, provided the rule itself still appears in both halves.
- **Fallback instructions.** The skill half may carry resolution steps for an agent that resolves less automatically than a prompt-file host does.
- **Frontmatter.** It was never shared.

## Procedure

1. **Run `make -f .claude/Makefile check-skills` first** and read the exit code. It decides the mechanical questions: specification validity, licences, bundled paths resolving, and the isolation rules. Fix anything it reports before judging parity, and report what you fixed.
2. **Read both halves in full.** There is no shortcut; the judgement is semantic.
3. **Build a rule inventory for each half.** List every hard rule, prohibition, evidence requirement, and output-format element. Compare the two lists rather than the two texts.
4. **Classify each difference** as allowed depth, allowed fallback, or a real divergence. State which for every difference you found, so the caller can check your reasoning.
5. **Repair a real divergence.** Establish which half carries the intended edit from `git diff` and `git status`. Where both changed, or where git cannot settle it, **ask rather than guess**: overwriting the edited half destroys work. Then port the rule into the half that lacks it, in that half's own voice and structure, rather than pasting text across.
6. **Re-run the check** and confirm exit 0.

## Also verify

- Neither half names the other, a sibling audit, or this repository. The prompt names nothing beside it; the skill names nothing outside itself.
- Both halves still pass `npm run lint:markdown:check` and `npx prettier --check`.
- The prompt frontmatter uses only `description`, `name`, `argument-hint`, `agent`, `model`, and `tools`. Any other key is ignored by the prompt-file hosts.
- No em-dash or en-dash appears in either file, and neither uses contractions.

## Output

Return a short verdict, not the file contents:

- **Parity: held or broken**, per pair.
- Every difference you found, each classified as allowed depth, allowed fallback, or divergence, in one line apiece.
- Any repair you made, which half you edited, and how you established the direction.
- The final exit code of the check.

If you could not establish a direction and had to stop, say so plainly and name the pair. A stopped run is a correct outcome; an overwritten edit is not.
7 changes: 4 additions & 3 deletions .claude/agents/validator.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: validator
description: Runs the repository quality gates (prettier, eslint, tsc, jest, build, markdownlint) and fixes what fails. Use proactively after any logic change and before reporting work complete.
description: Runs all seven of the repository's quality gates in the `npm run validate` chain and fixes what fails. Use proactively after any logic change and before reporting work complete.
tools: Bash, Read, Edit, Write, Grep, Glob
background: false
color: green
Expand All @@ -26,17 +26,18 @@ The chain is `&&`, so a failure at position 5 means `build` and `lint:markdown`

If `test:cypress:e2e` fails, quote the actual error. Treat it as an environment limit only when the Cypress **binary fails to launch**, an Electron or window-server error raised before any spec runs, since Cypress needs a GUI session a headless agent shell may not have. A failing assertion inside a spec is a real failure. Either way, report which gates actually ran (see [`code-qa.yaml`](../../.github/workflows/code-qa.yaml) for what CI covers).

Two ordering notes. `npm run prettier` and `npm run eslint` both write; run Prettier again after any ESLint fix, because the `curly` fix inserts braces inline where Prettier would break the statement across lines. Finish with `npm run prettier:check`, which is what CI runs.
Three of the gates write. `npm run prettier`, `npm run eslint`, and `npm run lint:markdown` each apply every fix their tool can apply and fail only on what is left, so exit code 0 can still leave a dirty tree. Report the files they rewrote. Run Prettier again after any ESLint fix, because the `curly` fix inserts braces inline where Prettier would break the statement across lines, then finish with `npm run prettier:check`, which is what CI runs.

`npm run test:jest` carries `--passWithNoTests`, so exit code 0 alone does not prove tests ran. Report the test count.

## Fixing

Fix the cause, not the symptom. Specifically:

- Never weaken, skip, or delete a test to make a gate pass. Read the test, read the source, find the cause. See [`testing.md`](../rules/testing.md).
- Never weaken, skip, or delete a test to make a gate pass. Read the test, read the source, find the cause. See [`typescript-code-and-test-standards`](../skills/typescript-code-and-test-standards/SKILL.md) for the rule and [`testing.md`](../rules/testing.md) for this repository's specifics.
- Never add a fallback in production code to satisfy a failing test.
- Never silence a type error with `any`, `unknown`, `@ts-ignore`, or an `eslint-disable`. Replace it with a concrete type. See [`code-style.md`](../rules/code-style.md).
- The skill publishability check is **not** one of these gates and is not part of `npm run validate`. Run it with `make -f .claude/Makefile check-skills` when a change touched a skill or a prompt. Whether a published audit's two halves still aim at the same outcome is a judgement rather than a diff, and it belongs to the `prompt-skill-sync` subagent, never to hand-copying.
- Re-run the failing gate after each fix, then re-run the gates that precede it if your fix touched files they check.

If a failure is pre-existing and unrelated to the change under test, fix it anyway when it is small, and report it plainly when it is not. Do not present it as passing.
Expand Down
3 changes: 1 addition & 2 deletions .claude/hooks/markdown-audit-reminder.mts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
//
// Type-stripping-safe TypeScript only: type annotations / interfaces, no enums,
// namespaces, or parameter properties.

import { readFileSync } from 'fs';

interface ToolInput {
Expand All @@ -22,7 +21,7 @@ interface HookPayload {

const REMINDER =
'This edit changed a Markdown file. Follow `.claude/rules/docs-authoring.md` ' +
'(canonical spec: `.github/prompts/audit-docs.prompt.md`): document only what the ' +
'(full rules: `.github/prompts/audit-docs.prompt.md`): document only what the ' +
'code provably does (no speculation), no subjective adjectives but not robotic prose ' +
'(lead with the point; show facts rather than assert them), reference files as ' +
'clickable markdown links to files (never bare names or directories), keep snippets ' +
Expand Down
93 changes: 93 additions & 0 deletions .claude/hooks/prompt-skill-sync.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env node
// PostToolUse hook (Write / Edit / MultiEdit). When one half of a published audit is
// edited, names the other half, which now has to be judged against it.
//
// Run via `node --experimental-strip-types` (no build step, no dependencies).
// The path-scoped rule `.claude/rules/prompt-skill-sync.md` carries the contract; this hook
// is the deterministic backstop; `.claude/scripts/check-skill-publishability.mjs` decides the
// mechanical rules, on demand through `make -f .claude/Makefile check-skills`. That check is
// deliberately not part of `npm run validate`: the repository must build, test, and lint with
// no agent tooling present.
//
// Type-stripping-safe TypeScript only: type annotations / interfaces, no enums,
// namespaces, or parameter properties.
import { existsSync, readFileSync } from 'fs';
import { join, resolve } from 'path';

interface ToolInput {
file_path?: string;
}

interface HookPayload {
tool_name?: string;
tool_input?: ToolInput;
}

/** Audits that ship as both a prompt file and a skill directory. */
const PAIRED = ['audit-docs', 'audit-pr', 'audit-quality'];

/**
* Returns the other half of a published audit, or null for any other file.
*
* Scoped to the named audits so that an unpaired skill, such as
* `typescript-code-and-test-standards`, is not told its counterpart is a prompt that was
* never written.
*/
function counterpartOf(filePath: string): string | null {
// Resolved from this file's own location so the existence test does not silently fail
// when the hook runs from another directory.
const repoRoot = resolve(import.meta.dirname, '..', '..');

const promptMatch = /\.github\/prompts\/([^/]+)\.prompt\.md$/.exec(filePath);
if (promptMatch) {
const counterpart = `.claude/skills/${promptMatch[1]}/SKILL.md`;

// A prompt with no skill yet is not half of a pair, and the checker skips it too.
return existsSync(join(repoRoot, counterpart)) ? counterpart : null;
}

const skillMatch = /\.claude\/skills\/([^/]+)\/SKILL\.md$/.exec(filePath);
if (skillMatch && PAIRED.includes(skillMatch[1])) {
return `.github/prompts/${skillMatch[1]}.prompt.md`;
}

return null;
}

function main(): void {
let payload: HookPayload;
try {
payload = JSON.parse(readFileSync(0, 'utf-8')) as HookPayload;
} catch {
process.exit(0);
}

const toolName = payload.tool_name ?? '';
if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'MultiEdit') {
process.exit(0);
}

const filePath = payload.tool_input?.file_path ?? '';
const counterpart = counterpartOf(filePath);
if (!counterpart) {
process.exit(0);
}

process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext:
`This file is one half of a published audit. Its counterpart \`${counterpart}\` must still ` +
'aim at the same outcome: the two carry the same objective and the same hard rules, and only ' +
'the skill half may carry bundled depth. That is a judgement rather than a diff, so hand both ' +
'to the `prompt-skill-sync` subagent before finishing, and run ' +
'`make -f .claude/Makefile check-skills` for the mechanical rules. Each half is downloaded ' +
'alone: the prompt may name nothing beside it, and the skill may name nothing outside itself.',
},
}),
);
process.exit(0);
}

main();
31 changes: 21 additions & 10 deletions .claude/hooks/validate-gate.mts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
// Run via `node --experimental-strip-types` (no build step, no dependencies).
// Type-stripping-safe TypeScript only: type annotations / interfaces, no enums,
// namespaces, or parameter properties.

import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { isAbsolute, join, relative } from 'path';
Expand Down Expand Up @@ -95,23 +94,34 @@ function clearState(sessionId: string): void {
/**
* Whether editing this file should require validation.
*
* Markdown counts because `lint:markdown` is one of the gates. The agent-tooling tree is
* excluded: `.claude/` is ignored by Prettier, ESLint, and markdownlint alike, so no gate
* can fail because of it.
* Markdown counts because `lint:markdown` is one of the gates. Most of the agent-tooling tree
* is excluded even so: ESLint skips everything under `.claude`, while Prettier and markdownlint
* do reach most of it, and the exclusion accepts that gap rather than marking the session dirty
* on every edit to a rule or skill file.
*
* Markdown under `.claude/skills/` is the exception. `lint:markdown` reaches every file there,
* so an unlinted addition would otherwise land locally and surface only as a CI failure.
* The skill publishability check itself is not a gate; it runs on demand via
* `make -f .claude/Makefile check-skills`.
*/
function requiresValidation(filePath: string, cwd: string): boolean {
if (!filePath) return false;

const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath;

if (rel.startsWith('..') || rel.startsWith('.claude/') || rel.includes('/.claude/')) return false;
if (rel.startsWith('..')) return false;

if (/^\.claude\/skills\/.+\.md$/.test(rel)) return true;

if (rel.startsWith('.claude/') || rel.includes('/.claude/')) return false;

if (rel.startsWith('src/') || rel.startsWith('cypress/') || rel.startsWith('jest/')) return true;

if (rel.endsWith('.md')) return true;

// Root-level configuration: `package.json`, `eslint.config.js`, `next.config.js`, and so on.
return !rel.includes('/') && /\.(ts|tsx|js|mjs|cjs|json)$/.test(rel);
// Root-level configuration: `package.json`, `eslint.config.js`, `.markdownlint-cli2.jsonc`,
// and so on.
return !rel.includes('/') && /\.(ts|tsx|mts|cts|js|mjs|cjs|json|jsonc|ya?ml)$/.test(rel);
}

/** Records gates run by a shell command, or marks the session dirty after an edit. */
Expand Down Expand Up @@ -172,15 +182,16 @@ function handleStop(payload: HookPayload, sessionId: string): void {
}

// `stop_hook_active` means this hook already blocked this turn. Releasing keeps a gate
// that cannot be satisfied (Cypress will not launch on macOS here) from looping.
// that cannot be satisfied in the current environment from looping.
if (payload.stop_hook_active) process.exit(0);

process.stderr.write(
`This session changed code, tests, config, or docs, and ${missing.length} of ${GATES.length} ` +
`quality gates have not been run: ${missing.join(', ')}. Run \`npm run validate\` and confirm ` +
'it reaches exit code 0 before finishing. The chain is `&&`, so if it stops partway, the gates ' +
'after the failure did not run: finish them individually (`npm run prettier`, `npm run eslint`, ' +
'`npm run tsc`, `npm run test:jest`, `npm run build`, `npm run lint:markdown`) rather than ' +
'after the failure did not run: finish them individually (`npm run prettier`, ' +
'`npm run eslint`, `npm run tsc`, `npm run test:jest`, `npm run build`, ' +
'`npm run lint:markdown`) rather than ' +
'treating them as passed.',
);
process.exit(2);
Expand Down
Loading