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
560 changes: 142 additions & 418 deletions .ai/README.md

Large diffs are not rendered by default.

22 changes: 21 additions & 1 deletion .ai/scripts/ai-files.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,12 @@ export function listSkills() {
}

let trackedCache = null;
let trackedSetCache = null;

/** Every file tracked by git, as repository-relative POSIX paths. */
/**
* Every file tracked by git, as repository-relative POSIX paths. This reads the index, so
* staged new files count as tracked and untracked or ignored local files don't.
*/
export function trackedFiles() {
if (!trackedCache) {
trackedCache = execFileSync('git', ['ls-files', '-z'], {
Expand All @@ -176,6 +180,22 @@ export function trackedFiles() {
return trackedCache;
}

/** True when the absolute path `file` is tracked by git. */
export function isTracked(file) {
trackedSetCache ??= new Set(trackedFiles());
return trackedSetCache.has(rel(file));
}

/**
* Absolute paths of tracked files that pass `filter` (given the repository-relative path)
* and still exist on disk, so a deletion that isn't staged yet is skipped.
*/
export const trackedFilesOnDisk = (filter) =>
trackedFiles()
.filter(filter)
.map((f) => path.join(ROOT, f))
.filter((f) => existsSync(f));

if (typeof path.matchesGlob !== 'function') {
console.error(
`yarn lint:ai and yarn ai:sync need Node 22.5 or later (found ${process.version}). Run \`nvm use\` to switch to the version in .nvmrc.`
Expand Down
71 changes: 68 additions & 3 deletions .ai/scripts/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@
* .ai/rules/<name>.md → .github/instructions/<name>.instructions.md (Copilot)
* → .cursor/rules/<name>.mdc (Cursor)
* .ai/memory/<name>.md → the same targets, named memory-<name> (only with frontmatter)
* skill and rule frontmatter → the catalog block in .ai/README.md, between
* <!-- ai:catalog:start --> and <!-- ai:catalog:end -->
*
* Claude Code reads `.ai/rules` directly through the `.claude/rules` symlink, so it needs
* no generated copy.
*
* Usage:
* node .ai/scripts/sync.js Write generated files (`yarn ai:sync`)
* node .ai/scripts/sync.js --check Report drift without writing; exits 1 on drift
* node .ai/scripts/sync.js --check Report drift without writing; exits 1 on drift.
* Checks only git-tracked sources and generated files.
*/

import {
Expand All @@ -44,12 +47,19 @@ import { fileURLToPath } from 'url';
import { stringify as stringifyYaml } from 'yaml';

import {
AI_DIR,
GENERATED_MARKER,
isTracked,
listInstructionSources,
listSkills,
rel,
ROOT,
} from './ai-files.js';

const README = path.join(AI_DIR, 'README.md');
const CATALOG_START = '<!-- ai:catalog:start -->';
const CATALOG_END = '<!-- ai:catalog:end -->';

const header = (source) =>
`${GENERATED_MARKER} from ${source.rel}. Do not edit. Edit the source and run \`yarn ai:sync\`. -->`;

Expand Down Expand Up @@ -108,15 +118,53 @@ function isOwned(file) {
return readFileSync(file, 'utf8').includes(GENERATED_MARKER);
}

const escapeCell = (value) =>
String(value ?? '')
.replace(/\s+/g, ' ')
.replace(/\|/g, '\\|')
.trim();

function renderCatalog(sources, skills) {
const lines = [
CATALOG_START,
'',
'_Generated by `yarn ai:sync` from frontmatter. Do not edit this block by hand._',
'',
'### Instructions',
'',
...sources.map((s) => {
const file = s.rel.replace(/^\.ai\//, '');
const scope = s.data.paths.map((p) => `\`${p}\``).join(', ');
const excluded = s.data.excludeAgent
? `; not used by ${s.data.excludeAgent}`
: '';
return `- **[\`${file}\`](./${file})** (${scope}${excluded}): ${escapeCell(s.data.description)}`;
}),
'',
'### Skills',
'',
...skills.map(
(s) =>
`- **[\`${s.data?.name ?? s.dir}\`](./skills/${s.dir}/SKILL.md)**: ${escapeCell(s.data?.description)}`
),
'',
CATALOG_END,
];
return lines.join('\n');
}

/**
* Compute every generated file and compare it with disk.
* With `write: true`, update disk to match. Returns `{ errors, changes, fileCount }`.
*/
export async function syncAi({ write = false } = {}) {
const errors = [];
const changes = [];
// `--check` sees only tracked files, as CI does. Writing also covers untracked sources so
// a new rule generates before it's staged.
const inScope = (file) => write || isTracked(file);
const sources = listInstructionSources().filter(
(s) => s.data && !s.error && Array.isArray(s.data.paths)
(s) => inScope(s.file) && s.data && !s.error && Array.isArray(s.data.paths)
);

const expected = new Map();
Expand All @@ -127,6 +175,23 @@ export async function syncAi({ write = false } = {}) {
}
}

// The README catalog is generated only once the markers exist.
if (existsSync(README)) {
const current = readFileSync(README, 'utf8');
const start = current.indexOf(CATALOG_START);
const end = current.indexOf(CATALOG_END);
if (start !== -1 && end > start) {
const skills = listSkills().filter(
(s) => inScope(s.file) && s.data && !s.error
);
const replaced =
current.slice(0, start) +
renderCatalog(sources, skills) +
current.slice(end + CATALOG_END.length);
expected.set(README, await formatMarkdown(replaced, README));
}
}

for (const [filepath, content] of expected) {
const exists = existsSync(filepath) || isDanglingLink(filepath);
const isLink = exists && lstatSync(filepath).isSymbolicLink();
Expand Down Expand Up @@ -156,7 +221,7 @@ export async function syncAi({ write = false } = {}) {
}
for (const name of readdirSync(dir)) {
const filepath = path.join(dir, name);
if (expected.has(filepath) || !isOwned(filepath)) {
if (expected.has(filepath) || !inScope(filepath) || !isOwned(filepath)) {
continue;
}
changes.push(`${rel(filepath)}: orphaned (source removed)`);
Expand Down
78 changes: 29 additions & 49 deletions .ai/scripts/validate-frontmatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@
* Also: unique names, no hand-authored files in generated folders, and a warning for
* tool-specific wording (Claude, Cursor, .mdc) in tool-agnostic sources.
*
* Only git-tracked files are checked (staged new files count), so untracked or ignored
* local files, such as tool caches and handoff notes, never fail the run.
*
* Returns { errors, warnings, fileCount } for integration with validate.js.
*/

import { existsSync, readdirSync, readFileSync } from 'fs';
import { readFileSync } from 'fs';
import path from 'path';
import { parse as parseYaml } from 'yaml';

Expand All @@ -43,15 +46,17 @@ import {
EXCLUDE_AGENT_VALUES,
GENERATED_MARKER,
INSTRUCTION_KEYS,
isTracked,
listInstructionSources,
listSkills,
matchesGlob,
readMarkdown,
REJECTED_INSTRUCTION_KEYS,
REJECTED_SKILL_KEYS,
ROOT,
rel,
SKILL_KEYS,
trackedFiles,
trackedFilesOnDisk,
} from './ai-files.js';

/**
Expand Down Expand Up @@ -264,14 +269,11 @@ function validateUniqueness(sources, skills, errors) {
}
}

// Generated folders hold only generated files.
// Generated folders hold only generated files. Untracked local files, such as tool
// caches, are skipped because they never reach the repository.
for (const dir of ['.github/instructions', '.cursor/rules']) {
const full = path.join(ROOT, dir);
if (!existsSync(full)) {
continue;
}
for (const name of readdirSync(full)) {
const file = path.join(full, name);
const inDir = (f) => path.posix.dirname(f) === dir;
for (const file of trackedFilesOnDisk(inDir)) {
let text = '';
try {
text = readFileSync(file, 'utf8');
Expand All @@ -280,7 +282,7 @@ function validateUniqueness(sources, skills, errors) {
}
if (!text.includes(GENERATED_MARKER)) {
errors.push(
`${dir}/${name}: hand-authored file in a generated folder; add it to .ai/ instead`
`${rel(file)}: hand-authored file in a generated folder; add it to .ai/ instead`
);
}
}
Expand Down Expand Up @@ -315,8 +317,8 @@ export function validateFrontmatter() {
const errors = [...assertGlobSemantics()];
const warnings = [];

const sources = listInstructionSources();
const skills = listSkills();
const sources = listInstructionSources().filter((s) => isTracked(s.file));
const skills = listSkills().filter((s) => isTracked(s.file));

sources.forEach((s) => validateInstruction(s, errors, warnings));
skills.forEach((s) => validateSkill(s, errors, warnings));
Expand All @@ -326,46 +328,24 @@ export function validateFrontmatter() {
errors
);

const agents = [];
const walkAgents = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (
['node_modules', '.git', 'dist', 'storybook-static'].includes(
entry.name
)
) {
continue;
}
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkAgents(full);
} else if (entry.name === 'AGENTS.md') {
agents.push(readMarkdown(full));
}
}
};
walkAgents(ROOT);
const agents = trackedFilesOnDisk(
(f) => path.posix.basename(f) === 'AGENTS.md'
).map(readMarkdown);

const skillDirs = skills.map((skill) => `${rel(path.dirname(skill.file))}/`);
const skillSupport = trackedFilesOnDisk(
(f) =>
f.endsWith('.md') &&
path.posix.basename(f) !== 'SKILL.md' &&
skillDirs.some((dir) => f.startsWith(dir))
).map(readMarkdown);

const skillSupport = skills.flatMap((skill) => {
const dir = path.dirname(skill.file);
return readdirSync(dir, { recursive: true })
.filter(
(f) =>
String(f).endsWith('.md') && path.basename(String(f)) !== 'SKILL.md'
)
.map((f) => readMarkdown(path.join(dir, String(f))));
});
const aiDocs = trackedFilesOnDisk(
(f) => path.posix.dirname(f) === rel(AI_DIR) && f.endsWith('.md')
).map(readMarkdown);

validateToolWording(
[
...sources,
...skills,
...skillSupport,
...agents,
...readdirSync(AI_DIR)
.filter((f) => f.endsWith('.md'))
.map((f) => readMarkdown(path.join(AI_DIR, f))),
],
[...sources, ...skills, ...skillSupport, ...agents, ...aiDocs],
warnings
);

Expand Down
Loading
Loading