Skip to content

Instantly share code, notes, and snippets.

@kennethnwc
Created April 23, 2026 21:22
Show Gist options
  • Select an option

  • Save kennethnwc/6187d415640f213b560faccea32d1156 to your computer and use it in GitHub Desktop.

Select an option

Save kennethnwc/6187d415640f213b560faccea32d1156 to your computer and use it in GitHub Desktop.
Set up an agent first project

Agent-First Project Setup

Drop this file at ~/setup-agent-first.md. Pass to an AI coding agent via @~/setup-agent-first.md along with a project description. The agent will inspect the target repo, apply missing scaffolding, and leave the repo with a working doc infrastructure + enforcement.

How to use

Greenfield project: @~/setup-agent-first.md i want to start a project for <describe>. Set up the agent-first scaffolding first, before any product code.

Existing project: @~/setup-agent-first.md this repo already has some of this. Inspect current state, fill the gaps without overwriting what exists, report what you changed.

The file assumes a TypeScript/Node project (pnpm + turbo monorepo or single-app). Adapt paths for other stacks — the conventions are language-agnostic.


0. Philosophy (1-minute read)

From OpenAI's harness-engineering post: repository knowledge is the system of record; the agent can't use what isn't in the repo. The goal of this setup is to make every future agent session productive on day one by giving it a stable map, progressive disclosure, and mechanical enforcement that keeps the map honest.

Core beliefs (encoded in docs/design-docs/core-beliefs.md, see Phase E):

  1. Map, not manual. CLAUDE.md / AGENTS.md is a table of contents pointing to real docs. Never a 1000-line kitchen sink.
  2. Progressive disclosure. Agents start small, learn where to look next.
  3. File-size budgets. Skill files ~150L target, docs ~300L target. Targets, not fences.
  4. Skills encode conventions, docs encode knowledge. Skills auto-load on keyword; docs are pull-on-demand.
  5. Memory ≠ docs. Memory = user-specific cross-conversation state. Docs = project knowledge derivable from the repo.
  6. Documentation verification is mechanical. Broken links, stale frontmatter, missing round-trip pointers — all caught by CI, never human vigilance.
  7. Cross-cutting invariants get bidirectional links. One doc owns the rule; every code site points back via comment.
  8. The repo self-heals. Fix what you touch. No big refactors.

1. AI consumer instructions

Before writing anything, run this discovery pass:

# What exists in the target repo?
ls -la                                          # root inventory
cat CLAUDE.md 2>/dev/null | head -30           # existing agent instructions?
ls docs/ 2>/dev/null                           # existing doc tree?
ls .claude/skills/ 2>/dev/null                 # existing skills?
ls scripts/ 2>/dev/null                        # existing scripts?
cat package.json 2>/dev/null | head -30        # tech stack + existing npm scripts

Then decide the mode:

  • Greenfield (no CLAUDE.md, no docs/, empty or near-empty repo) — apply every phase below in order.
  • Partial (some pieces exist) — for each phase, inspect first, fill gaps, NEVER overwrite existing convention choices. If an existing file uses accepted/shipped instead of active for status, propose normalization but ask before changing.

Always ask before:

  • Deleting existing files (even ones that look redundant)
  • Changing existing naming conventions
  • Committing (this document does not authorize commits — the user triggers those)

Always preserve:

  • Existing commit history
  • Existing skill authoring if skills already exist
  • Existing ARCHITECTURE.md or README.md content — merge, don't overwrite

Report format: after each phase, print a short summary of what you created, skipped, or deferred.


2. Phases overview

Phase Deliverable Time
A Core scaffold — CLAUDE.md, ARCHITECTURE.md, docs/ tree, gitignore 5 min
B Doc-format convention — docs/design-docs/doc-format.md + frontmatter schema 10 min
C Mechanical enforcement — scripts/check-docs-links.mjs + scripts/garden-docs.mjs + pnpm entries 10 min
D Task MD convention — .template.md + INDEX.md + date-based filenames 5 min
E Core beliefs + subagent policy — docs/design-docs/core-beliefs.md 10 min
F Skills directory — .claude/skills/ pattern + one example skill 10 min
G Customization — replace placeholders, wire to project specifics 5 min
H Verification — run pnpm docs:check, confirm clean 2 min

Total: ~60 min for greenfield. Faster for partial (skip what exists).


Phase A — Core scaffold

A.1 CLAUDE.md (alias AGENTS.md via symlink)

Create as a short table-of-contents file. Never put policy content here; link out.

# Claude Code Instructions

Act as a Senior Principal Engineer. High information density, minimalism, technical precision. Assume high context.

This file is a **map, not a manual**. It gates every-turn behavior (skill loading, coding rules) and points to the real content elsewhere. For principles behind this layout, see [docs/design-docs/core-beliefs.md](docs/design-docs/core-beliefs.md).

## Repository Map — Read These First

| When you need… | Read |
|---|---|
| Top-level architecture (domains × packages × data flow) | [ARCHITECTURE.md](ARCHITECTURE.md) |
| Design decisions, core beliefs, subagent policy | [docs/design-docs/index.md](docs/design-docs/index.md) |
| Doc-format + frontmatter + cross-linking conventions | [docs/design-docs/doc-format.md](docs/design-docs/doc-format.md) |
| Active / completed execution plans | [docs/exec-plans/README.md](docs/exec-plans/README.md)[docs/exec-plans/tasks/INDEX.md](docs/exec-plans/tasks/INDEX.md) |
| Product specs (features, contracts) | [docs/product-specs/index.md](docs/product-specs/index.md) |
| Generated refs (DB schema, route catalog) | [docs/generated/README.md](docs/generated/README.md) |
| Vendor / tool references | [docs/references/README.md](docs/references/README.md) |
| Operational guides (dev, deploy, investigation) | [docs/guides/](docs/guides/) |

## Coding Guidelines

1. **YAGNI**: Never implement features, wrappers, or abstractions not explicitly requested.
2. **No "Junior" Patterns**: Avoid excessive defensive coding, unnecessary logging, or custom helpers when stdlib methods exist.
3. **Conciseness > "Robustness"**: 20 readable lines beat 100 lines of abstract architecture.
4. **Standard Libraries**: Prefer built-in language features over new logic.
5. **Comments**: Only for non-obvious *why*. Never restate *what*.
6. **Search First**: Before creating, search. Reuse and extend before creating.
7. **Consistency**: Match existing naming, patterns, doc style.
8. **Stewardship**: Fix dead imports, stale comments, misleading names, and defensive-but-dead code in files you touch. No big refactors — the repo self-heals one small diff at a time.

## Communication Guidelines

1. **No Fluff**: Start with the answer. No "Here is the code."
2. **BLUF**: Lead with the solution. Details only if necessary.
3. **Information Density**: Bullets, tables, short sentences.
4. **Correction**: If the premise is wrong, correct it immediately and neutrally.

## Skills — MANDATORY Load Gate

**Load the relevant skill BEFORE answering any architecture, convention, or implementation question about a package.** Skills carry authoritative conventions that override patterns you'd infer from grep alone.

| Skill | When to Load |
|---|---|
| {{ placeholder — list skills as you add them under .claude/skills/ }} | {{ when to load }} |

Skill authoring + update policy: [docs/design-docs/skill-system.md](docs/design-docs/skill-system.md).

## Subagent Policy (Brief)

Default to the main session — use Read, Grep, Glob, Edit directly. Spawn subagents only when the user explicitly asks, or the task is fully independent from current work (e.g. background test runs). Full rules: [docs/design-docs/core-beliefs.md §9](docs/design-docs/core-beliefs.md#9-subagent-policy).

## Execution Plans (Brief)

Non-trivial work is planned **before** code is written. Pick the right container:
- Single feature/fix → task MD (`docs/exec-plans/tasks/YYYY-MM-DD-*.md`)
- Multi-slice feature → exec-plan (`docs/exec-plans/active/<name>.md`)
- One-off fix → nothing, commit directly

Full decision tree + lifecycle: [docs/exec-plans/README.md](docs/exec-plans/README.md).

## Self-Healing Repo (Brief)

This is an **agent-first, self-healing repo**. Fix what you touch. No big refactors. Full belief + rules: [docs/design-docs/core-beliefs.md §8](docs/design-docs/core-beliefs.md#8-the-repo-self-heals).

Then: ln -s CLAUDE.md AGENTS.md (so both tools find the same file).

A.2 ARCHITECTURE.md

High-level map: domains × packages × data flow. One page max. Do NOT duplicate what's in product-specs or design-docs; this is the 30,000-foot view.

Template:

# Architecture

## Summary
{{ one paragraph: what this system does, who uses it, key constraints }}

## Runtime topology

| Component | Stack | Role |
|---|---|---|
| {{ app 1 }} | {{ stack }} | {{ role }} |
||||

## Domains

{{ list major business domains; each is a candidate skill later }}

## Data flow

{{ one diagram or prose paragraph showing how data moves through the system }}

## Where to look

- Conventions: `docs/design-docs/`
- Feature specs: `docs/product-specs/`
- Runbooks: `docs/guides/`
- Runtime details: source code under `apps/` (or the primary package dirs)

A.3 docs/ tree

docs/
├── design-docs/
│   ├── index.md
│   ├── core-beliefs.md
│   ├── doc-format.md
│   └── skill-system.md
├── product-specs/
│   └── index.md
├── guides/
├── exec-plans/
│   ├── README.md
│   ├── active/
│   └── tasks/
│       ├── .template.md
│       └── INDEX.md
├── generated/
│   └── README.md
└── references/
    └── README.md

Each index.md is a pointer table. Each README.md (for generated/ and references/) explains what belongs there and what doesn't.

docs/design-docs/index.md:

---
title: Design docs — index
status: active
last_verified: {{ YYYY-MM-DD }}
owner: {{ primary-author-handle }}
---

# Design Docs

Design decisions, beliefs, and policies that shape how this codebase is built and how agents work inside it. Prescriptive — says what should be true.

## Contents

| Doc | Scope |
|---|---|
| [core-beliefs.md](core-beliefs.md) | Agent-first operating principles — repo knowledge layout, subagent policy. |
| [doc-format.md](doc-format.md) | Doc format + frontmatter enum + structure rules. Enforced by `pnpm docs:check`. |
| [skill-system.md](skill-system.md) | Skill authoring rules, auto-update policy. |

## How to add a design doc

See [doc-format.md](doc-format.md) for the full format spec. Then:
1. Create `docs/design-docs/<slug>.md` (or a folder with `index.md` if multi-file).
2. Add a row to the table above.
3. Run `pnpm docs:check` before committing.

docs/product-specs/index.md: same pattern, scope = "features, contracts, domain glossary."

docs/exec-plans/README.md:

# Execution plans

Work planned before code is written. Two shapes:

- **Task MD** — single feature/fix. `tasks/YYYY-MM-DD-<slug>.md` with Status + Steps checklist.
- **Exec plan** — multi-slice feature. `active/<name>.md` with multiple phases.

DONE tasks move to `tasks/archive/` — still committed, read-only after archive.

See `tasks/.template.md` for the task-MD format. Filename is date-prefixed (collision-free under parallel engineering); legacy `T-NNN-` pattern is discouraged.

A.4 .gitignore additions

Whatever the base .gitignore has, ensure these are present:

.claude/projects/
.DS_Store
*.log
node_modules/
dist/
.turbo/
.next/

Phase B — Doc-format convention

Create docs/design-docs/doc-format.md verbatim:

---
title: Documentation format and conventions
status: active
last_verified: {{ YYYY-MM-DD }}
owner: {{ primary-author-handle }}
---

# Doc format and conventions

**Part of:** [index.md](index.md)

Single source of truth for how docs are written. Prescriptive. When a new doc is created or an existing one is edited, it should match the rules below.

### In scope

- `docs/design-docs/` — decisions, conventions, invariants
- `docs/product-specs/` — features, contracts, domain glossary
- `docs/guides/` (top level) — operational runbooks

### Out of scope

- `docs/references/` — external vendor docs (mostly imported, not authored)
- `docs/generated/` — auto-built, never hand-edited
- `docs/exec-plans/` — task MDs have their own header schema

Enforcement lives in `scripts/check-docs-links.mjs` (run via `pnpm docs:check`). Rules marked **(lint: error)** fail the check; **(lint: warn)** surface as warnings.

## Frontmatter

Every in-scope doc carries YAML frontmatter **(lint: warn if missing)**.

```yaml
---
title: <short descriptive title>
status: draft | active | deprecated | living
last_verified: YYYY-MM-DD
owner: <github-username or handle>
---

Status enum (lint: error if not in enum)

Value Meaning
draft Being written. Contains open questions. Not yet the source of truth.
active Current source of truth. Reflects shipped code + accepted decisions.
deprecated Superseded or no longer applicable. Kept for history.
living Intentionally evolving (indexes, open-questions, glossaries).

Fields

  • title — descriptive, not chaptered. Good: "Auth token lifecycle". Bad: "2. Auth".
  • last_verified — ISO date. Update when reviewed against code. Stale > 180 days raises a warning.
  • owner — who knows this best, who to route drift to. Usually one handle.

Structure

H1 heading

Exactly one H1 per doc. Should match title. First content element after the frontmatter.

**Part of:** pointer (lint: warn if missing on folder sub-file)

Every sub-file inside a folder that contains an index.md MUST start with:

**Part of:** [index.md](index.md)

Place directly under the H1.

**Related:** pointer

For peer cross-refs, use:

**Related:** [path/to/doc.md](path/to/doc.md) — one-line reason, [other.md](other.md) — another reason

Place under **Part of:** if present, otherwise directly under the H1. One-line reason is required — a bare link rots.

Links

  • Relative paths always. Never absolute URLs to your own repo.
  • Code identifiers in backticks: `functionName()`.
  • Inline file paths in backticks: `<app>/<feature>/file.ts`.

Tables

Prefer tables over prose for 3+ same-shape items.

Code blocks

  • Always fenced with language tag.
  • JSON examples must parse — no // comment lines.

File size

  • Doc: ~300L target, ~500L push-to-split, ~800L hard ceiling.
  • Skill: ~150L target, ~250L push-to-split, 500L hard ceiling.

When a doc exceeds push-to-split, extract into sibling files (same folder + **Part of:**) or a references/ subfolder for skills.

Invariants enforced across code sites

When a doc owns an invariant enforced at multiple code sites, use the Enforcement-table pattern:

  1. Section heading containing Enforcement (case-insensitive).
  2. Markdown table listing every code file that enforces/mirrors the rule (backticked paths).
  3. Each listed code file MUST contain the doc's filename in a comment — forms a round-trip grep.

(lint: error) Missing back-pointers fail the check.

Doc-gardening

pnpm docs:garden is the maintenance counterpart to pnpm docs:check. Scans in-scope docs for drift against code, prints a punch list grouped by owner. Never blocks CI.

Drift types v1 catches: path-drift (backticked paths must resolve), symbol-drift (Enforcement-table identifiers must grep non-zero in code), staleness (last_verified > 180 days).

How to add a new doc

  1. Pick the path — design-docs/ for policy, product-specs/ for features, guides/ for runbooks.
  2. Add frontmatter with all four fields.
  3. H1 matches title. Add **Part of:** if in a folder with index.md. Add **Related:** for cross-refs.
  4. Add a row to the parent folder's index.md.
  5. Run pnpm docs:check before committing.

---

## Phase C — Mechanical enforcement

Two scripts. Both Node stdlib-only. Both read-only.

### C.1 `scripts/check-docs-links.mjs`

This gates CI. Broken links, invalid status, missing Part-of, file-size warnings, staleness.

```javascript
#!/usr/bin/env node
// Verifies every `docs/...`, top-level `.md`, skill, and sibling-doc reference
// resolves to a real file + anchor. Also checks frontmatter, status enum,
// Part-of pointers, file size, and staleness.

import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";

const ROOT = resolve(process.argv[2] ?? ".");
const errors = [];
const warnings = [];

function walk(dir, predicate, out = []) {
  const s = safeStat(dir);
  if (!s?.isDirectory()) return out;
  for (const entry of readdirSync(dir)) {
    if (["node_modules", ".next", "dist", ".turbo", ".playwright-cli"].includes(entry)) continue;
    const full = join(dir, entry);
    const st = safeStat(full);
    if (!st) continue;
    if (st.isDirectory()) walk(full, predicate, out);
    else if (predicate(full)) out.push(full);
  }
  return out;
}

function safeStat(p) { try { return statSync(p); } catch { return null; } }
function safeRead(p) { try { return readFileSync(p, "utf8"); } catch { return null; } }
function rel(p) { return relative(ROOT, p); }

const mdFiles = [
  ...walk(join(ROOT, "docs"), (p) => p.endsWith(".md")),
  ...walk(join(ROOT, ".claude"), (p) => p.endsWith(".md")),
  ...["CLAUDE.md", "AGENTS.md", "ARCHITECTURE.md", "README.md"]
    .map((f) => join(ROOT, f))
    .filter((p) => safeStat(p)),
];

const codeFiles = [
  ...walk(join(ROOT, "apps"), (p) => /\.(ts|tsx)$/.test(p)),
  ...walk(join(ROOT, "packages"), (p) => /\.(ts|tsx)$/.test(p)),
  ...walk(join(ROOT, "src"), (p) => /\.(ts|tsx|js|jsx)$/.test(p)),  // non-monorepo
];

// ---------- anchor slugifier (GitHub-compatible) ----------
function slugify(h) {
  return h.toLowerCase().trim()
    .replace(/[`*~]/g, "")
    .replace(/[^\w\s-]/g, "")
    .replace(/\s/g, "-");
}
const anchorCache = new Map();
function anchorsFor(f) {
  if (anchorCache.has(f)) return anchorCache.get(f);
  const src = safeRead(f);
  const set = new Set();
  if (src) for (const m of src.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) set.add(slugify(m[1]));
  anchorCache.set(f, set);
  return set;
}

// ---------- link scanner ----------
const MD_LINK = /(?<!\!)\[[^\]]+\]\(([^)]+)\)/g;
const CODE_REF = /docs\/[A-Za-z0-9_\-./]+\.md(?:#[A-Za-z0-9_\-]+)?/g;

function check(source, target) {
  const [pathPart, anchor] = target.split("#");
  if (!pathPart) {
    const set = anchorsFor(source);
    if (anchor && !set.has(anchor)) errors.push(`${rel(source)}: missing in-page anchor #${anchor}`);
    return;
  }
  if (/^[a-z]+:/i.test(pathPart)) return;
  if (pathPart.startsWith("/")) return;
  const resolved = resolve(dirname(source), pathPart);
  const stat = safeStat(resolved);
  if (!stat) { errors.push(`${rel(source)}: broken link → ${target}`); return; }
  if (stat.isDirectory()) return;
  if (!resolved.endsWith(".md")) return;
  if (anchor && !anchorsFor(resolved).has(anchor)) errors.push(`${rel(source)}: missing anchor #${anchor} in ${rel(resolved)}`);
}

for (const md of mdFiles) {
  const src = safeRead(md);
  if (!src) continue;
  const stripped = src.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]+`/g, "");
  for (const m of stripped.matchAll(MD_LINK)) check(md, m[1]);
}

for (const code of codeFiles) {
  const src = safeRead(code);
  if (!src) continue;
  const comments = [...(src.match(/\/\/[^\n]*/g) ?? []), ...(src.match(/\/\*[\s\S]*?\*\//g) ?? [])].join("\n");
  for (const m of comments.matchAll(CODE_REF)) {
    const target = m[0];
    const [pathPart, anchor] = target.split("#");
    const resolved = resolve(ROOT, pathPart);
    const stat = safeStat(resolved);
    if (!stat) { errors.push(`${rel(code)}: broken link → ${target}`); continue; }
    if (anchor && !anchorsFor(resolved).has(anchor)) errors.push(`${rel(code)}: missing anchor #${anchor} in ${rel(resolved)}`);
  }
}

// ---------- doc↔code round-trip ----------
const designDocs = mdFiles.filter((f) => f.includes("/docs/design-docs/"));
for (const doc of designDocs) {
  const src = safeRead(doc);
  if (!src) continue;
  const sections = src.split(/^#{2,3}\s+/m);
  for (const section of sections) {
    const firstLine = section.split("\n", 1)[0];
    if (!/enforcement/i.test(firstLine)) continue;
    const rows = section.split("\n").filter((l) => l.startsWith("|"));
    if (rows.length < 2) continue;
    for (const row of rows.slice(2)) {
      const m = row.match(/`((?:apps|packages|src)\/[^`]+)`/);
      if (!m) continue;
      const codePath = m[1].replace(/[:\s].*$/, "");
      const full = resolve(ROOT, codePath);
      if (!safeStat(full)) { errors.push(`${rel(doc)}: Enforcement row points at missing file: ${codePath}`); continue; }
      const body = safeRead(full);
      const docBasename = doc.split("/").pop();
      if (body && !body.includes(docBasename)) errors.push(`${rel(doc)}: ${codePath} has no back-pointer comment (grep \`${docBasename}\`)`);
    }
  }
}

// ---------- file size + frontmatter + staleness ----------
const STATUS_ENUM = new Set(["draft", "active", "deprecated", "living"]);
const STALE_DAYS = 180;
const seenInode = new Set();
function firstSeen(p) {
  const s = safeStat(p); if (!s) return false;
  const key = `${s.dev}:${s.ino}`;
  if (seenInode.has(key)) return false;
  seenInode.add(key); return true;
}
function lineCount(p) { const s = safeRead(p); return s ? s.split("\n").length : 0; }

const skillFiles = mdFiles.filter((f) => /\/skills\/[^/]+\/SKILL\.md$/.test(f) && firstSeen(f));
for (const f of skillFiles) {
  const n = lineCount(f);
  if (n > 500) warnings.push(`${rel(f)}: ${n} lines over skill hard ceiling (500) — split via references/`);
  else if (n > 250) warnings.push(`${rel(f)}: ${n} lines over skill target (~150, push-to-split at 250)`);
}
const specDocs = mdFiles.filter((f) => /\/docs\/(design-docs|product-specs|guides)\/(?!workflows\/)/.test(f) && firstSeen(f));
for (const f of specDocs) {
  const n = lineCount(f);
  if (n > 800) warnings.push(`${rel(f)}: ${n} lines over doc hard ceiling (800) — mandatory split`);
  else if (n > 500) warnings.push(`${rel(f)}: ${n} lines over doc target (~300, push-to-split at 500)`);
  const src = safeRead(f); if (!src) continue;
  const fmMatch = src.match(/^---\n([\s\S]*?)\n---/);
  if (!fmMatch) { warnings.push(`${rel(f)}: missing YAML frontmatter`); continue; }
  const statusMatch = fmMatch[1].match(/^status:\s*(\S+)/m);
  if (!statusMatch) warnings.push(`${rel(f)}: frontmatter missing "status" field`);
  else if (!STATUS_ENUM.has(statusMatch[1])) errors.push(`${rel(f)}: invalid status "${statusMatch[1]}"`);
  const lvMatch = fmMatch[1].match(/^last_verified:\s*(\d{4}-\d{2}-\d{2})/m);
  if (lvMatch) {
    const ageDays = Math.floor((Date.now() - Date.parse(lvMatch[1])) / 86400000);
    if (ageDays > STALE_DAYS) warnings.push(`${rel(f)}: last_verified ${lvMatch[1]} is ${ageDays} days old`);
  }
  const basename = f.split("/").pop();
  if (basename !== "index.md") {
    const dir = f.slice(0, -(basename.length + 1));
    if (safeStat(join(dir, "index.md"))) {
      const head = src.slice(fmMatch[0].length).split("\n").slice(0, 12).join("\n");
      if (!/\*\*Part of:\*\*/.test(head)) warnings.push(`${rel(f)}: sub-file of ${rel(dir)}/ missing "**Part of:**" pointer`);
    }
  }
}

if (warnings.length) {
  console.warn(`docs/link check: ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`);
  for (const w of warnings) console.warn(`  WARN ${w}`);
}
if (errors.length) {
  console.error(`docs/link check: ${errors.length} error${errors.length === 1 ? "" : "s"}`);
  for (const e of errors) console.error(`  ${e}`);
  process.exit(1);
}
console.log(`docs/link check: OK (${mdFiles.length} md files, ${codeFiles.length} code files scanned)`);

C.2 scripts/garden-docs.mjs

Maintenance punch list. Separate from docs:check.

#!/usr/bin/env node
// Doc-gardening — scans in-scope docs for drift against code. Output is a
// maintenance punch list grouped by owner. Read-only; exits 0 regardless.

import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { execFileSync } from "node:child_process";

const ROOT = resolve(process.argv[2] ?? ".");
const STALE_DAYS = 180;

function safeStat(p) { try { return statSync(p); } catch { return null; } }
function safeRead(p) { try { return readFileSync(p, "utf8"); } catch { return null; } }
function walk(dir, predicate, out = []) {
  const s = safeStat(dir); if (!s?.isDirectory()) return out;
  for (const entry of readdirSync(dir)) {
    if (["node_modules", ".next", "dist", ".turbo", ".playwright-cli"].includes(entry)) continue;
    const full = join(dir, entry);
    const st = safeStat(full); if (!st) continue;
    if (st.isDirectory()) walk(full, predicate, out);
    else if (predicate(full)) out.push(full);
  }
  return out;
}
const inScopeDocs = [
  ...walk(join(ROOT, "docs/design-docs"), (p) => p.endsWith(".md")),
  ...walk(join(ROOT, "docs/product-specs"), (p) => p.endsWith(".md")),
  ...walk(join(ROOT, "docs/guides"), (p) => p.endsWith(".md") && !p.includes("/workflows/")),
];
const rel = (p) => relative(ROOT, p);

function parseFrontmatter(src) {
  const m = src?.match(/^---\n([\s\S]*?)\n---/);
  if (!m) return null;
  const fm = {};
  for (const line of m[1].split("\n")) {
    const kv = line.match(/^(\w+):\s*(.+?)\s*$/);
    if (kv) fm[kv[1]] = kv[2];
  }
  return { fm, bodyOffset: m[0].length };
}

const findings = [];
const add = (type, doc, owner, detail) => findings.push({ type, doc: rel(doc), owner: owner || "(unowned)", detail });

// Drift 1: code-path drift
const CODE_PATH_RE = /`((?:apps|packages|scripts|external|src|\.claude|\.agents)\/[\w\-./]+?)`/g;
function isLiteral(p) {
  if (/[<>*?{}]/.test(p)) return false;
  if (/\/\.\.\.|/.test(p)) return false;
  return true;
}
for (const doc of inScopeDocs) {
  const src = safeRead(doc); if (!src) continue;
  const owner = parseFrontmatter(src)?.fm.owner;
  const prose = src.replace(/```[\s\S]*?```/g, "");
  const seen = new Set();
  for (const m of prose.matchAll(CODE_PATH_RE)) {
    let path = m[1]; if (!isLiteral(path)) continue;
    path = path.replace(/:\d+(?:[-:,]\d+)*$/, "").replace(/#.*$/, "");
    if (seen.has(path)) continue; seen.add(path);
    if (!existsSync(resolve(ROOT, path))) add("path-drift", doc, owner, `missing path: ${path}`);
  }
}

// Drift 2: symbol drift in Enforcement tables
function hasSymbol(symbol) {
  try {
    execFileSync("grep", ["-r", "-l", "-w", "--include=*.ts", "--include=*.tsx", "--include=*.js", symbol, "apps", "packages", "src"], {
      cwd: ROOT, stdio: ["ignore", "pipe", "ignore"], timeout: 10_000,
    });
    return true;
  } catch (e) { return e.status === 1 ? false : true; }
}
for (const doc of inScopeDocs) {
  const src = safeRead(doc); if (!src) continue;
  const owner = parseFrontmatter(src)?.fm.owner;
  const sections = src.split(/^#{2,3}\s+/m);
  for (const section of sections) {
    if (!/enforcement/i.test(section.split("\n", 1)[0])) continue;
    const rows = section.split("\n").filter((l) => l.startsWith("|"));
    if (rows.length < 2) continue;
    for (const row of rows.slice(2)) {
      const m = row.match(/`(?:apps|packages|src)\/[^`]+`\s*[-]\s*`([A-Za-z_][\w$]*)`/);
      if (!m) continue;
      if (!hasSymbol(m[1])) add("symbol-drift", doc, owner, `zero hits for \`${m[1]}\` in code`);
    }
  }
}

// Drift 3: staleness elevation
const now = Date.now();
for (const doc of inScopeDocs) {
  const parsed = parseFrontmatter(safeRead(doc)); if (!parsed) continue;
  const lv = parsed.fm.last_verified;
  if (!lv || !/^\d{4}-\d{2}-\d{2}$/.test(lv)) continue;
  const ageDays = Math.floor((now - Date.parse(lv)) / 86_400_000);
  if (ageDays > STALE_DAYS) add("staleness", doc, parsed.fm.owner, `last_verified ${lv} (${ageDays} days old)`);
}

if (!findings.length) { console.log(`docs:garden — clean. Scanned ${inScopeDocs.length} docs.`); process.exit(0); }

const byOwner = new Map();
for (const f of findings) { if (!byOwner.has(f.owner)) byOwner.set(f.owner, []); byOwner.get(f.owner).push(f); }
console.log(`docs:garden — ${findings.length} findings across ${byOwner.size} owners\n`);
for (const owner of [...byOwner.keys()].sort((a, b) => byOwner.get(b).length - byOwner.get(a).length)) {
  const rows = byOwner.get(owner);
  console.log(`=== ${owner}${rows.length} items ===`);
  const byDoc = new Map();
  for (const r of rows) { if (!byDoc.has(r.doc)) byDoc.set(r.doc, []); byDoc.get(r.doc).push(r); }
  for (const [doc, docRows] of byDoc) {
    console.log(`  ${doc}`);
    for (const r of docRows) console.log(`    [${r.type}] ${r.detail}`);
  }
  console.log();
}
process.exit(0);

C.3 package.json entries

Add under scripts:

"docs:check": "node scripts/check-docs-links.mjs",
"docs:garden": "node scripts/garden-docs.mjs"

C.4 (Optional) CI wiring

If .github/workflows/ exists, add a docs-check.yml:

name: docs
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: node scripts/check-docs-links.mjs

If not, leave it manual. Users run pnpm docs:check before committing, husky pre-commit hook optional.


Phase D — Task MD convention

D.1 docs/exec-plans/tasks/.template.md

# {{YYYY-MM-DD}} — {{Title}}

**Created:** {{YYYY-MM-DD}} (matches filename prefix; never update)
**Branch:** {{branch-name}}
**Epic:** {{epic name or standalone}}
**Status:** PLANNED | IN_PROGRESS | DONE | BLOCKED

## Objective

{{One-paragraph description of what this task delivers.}}

## Context

{{Background: why this work exists, links to design, related decisions.}}

## Steps

- [ ] {{Step 1}}
- [ ] {{Step 2}}

## Files Changed

| File | Change |
|------|--------|
|      |        |

## Errors & Issues

| Issue | Description | Resolution |
|-------|-------------|------------|
|       |             |            |

## Outcome

{{Filled when status = DONE. What shipped, what was skipped, follow-ups.}}

D.2 docs/exec-plans/tasks/INDEX.md

# Task Index

Active tasks sit flat in this folder. DONE tasks move to [archive/](archive/).

## Active

| ID | Title | Status | Branch | Date |
|----|-------|--------|--------|------|

## Archive

| ID | Title | Status | Branch | Date |
|----|-------|--------|--------|------|

D.3 Filename rule

ID = date prefix, not a counter. Use YYYY-MM-DD-<slug>.md. Legacy T-NNN- from older projects is discouraged — it requires centralized counter coordination; the date prefix is collision-free.

When instructing an AI to create a task MD, explicitly say: "Follow .template.md. Filename prefix must be today's date in YYYY-MM-DD format."


Phase E — Core beliefs + subagent policy

Create docs/design-docs/core-beliefs.md — encodes the philosophy from §0. Shorter version that still does the job:

---
title: Core Beliefs — agent-first operating principles
status: active
last_verified: {{ YYYY-MM-DD }}
owner: {{ primary-author-handle }}
---

# Core Beliefs

**Part of:** [index.md](index.md)

The repository is the system of record. Humans and agents share it. Everything here exists to make both productive without one starving the other of context.

## 1. Map, not manual

`CLAUDE.md` is a table of contents. Every-turn gates (coding rules, skill loading) stay here; the rest lives in docs/ and skills/.

## 2. Progressive disclosure

Agents start with a small, stable entry point and learn where to look next. Don't preload everything.

### 2a. File-size targets

| Surface | Target | Push to split | Hard ceiling |
|---|---|---|---|
| Skill `SKILL.md` | ~150L | ~250 → move detail into `references/<topic>.md` | 500 |
| Design / spec doc | ~300L | ~500 → split into sibling files + index | ~800 (mandatory split) |

Targets, not fences. The trigger is: when editing feels scary, or the agent can no longer load it alongside the task context, split.

## 3. Execution plans are first-class

Non-trivial work starts with a task MD (`docs/exec-plans/tasks/YYYY-MM-DD-*.md`) before code is written. Versioned, checkpointable, survives context resets.

## 4. Skills encode conventions, docs encode knowledge

| Content | Home | Why |
|---|---|---|
| Package conventions ("how we name things here") | Skill | Auto-loads on keyword. |
| Feature contracts, domain glossary | `docs/product-specs/` | Stable reference. |
| Decisions, invariants, policy | `docs/design-docs/` | Prescriptive, read when needed. |
| DB schema, route catalog | `docs/generated/` | Auto-built. Never hand-edited. |

Duplicating across homes is worse than a broken link — broken links fail loudly, duplicates rot silently.

## 5. Memory is not docs

Agent memory is user-specific, cross-conversation state: preferences, feedback, ongoing context. Not project knowledge. Rule: if a new team member could reconstruct the fact from the repo, it doesn't belong in memory.

## 6. Documentation verification is mechanical

`pnpm docs:check` gates CI. `pnpm docs:garden` produces the maintenance punch list. If a rule can't be mechanically verified, assume it will rot.

## 7. Prefer code over prose

Prose describes a moment. Code is authoritative. Every doc either links to the code it describes, or explains a *decision* that code alone can't show.

### 7a. Cross-cutting invariants get bidirectional links

When a rule lives in multiple code sites, one doc owns it with an Enforcement table listing every site; each site carries a one-line comment pointing back. `docs:check` verifies the round-trip.

## 8. The repo self-heals

- **Fix what you touch.** Dead imports, stale comments, misleading names.
- **Fix what's adjacent.** But don't widen scope much.
- **No big refactors.** 100 small diffs, not one architectural overhaul.
- **Log slop you can't fix now** as a task MD. Don't pretend it isn't there.

## 9. Subagent policy

Default to main session — Read, Grep, Glob, Edit. Spawn subagents only when user explicitly asks, or task is fully independent (e.g. background test runs). Token efficiency is the scarce resource.

## 10. External systems with agent docs

When you pull in a subtree or sibling repo that has its own `CLAUDE.md`/`.claude/skills/`, read its entry point first for anything in that domain. Note staleness if the subtree is manually synced.

And docs/design-docs/skill-system.md — short:

---
title: Skill system
status: active
last_verified: {{ YYYY-MM-DD }}
owner: {{ primary-author-handle }}
---

# Skill system

**Part of:** [index.md](index.md)

## Layout

.claude/skills// ├── SKILL.md — entry point; description + trigger keywords in frontmatter └── references/ — detail extracted when SKILL.md exceeds ~250L └── .md


## SKILL.md frontmatter

```yaml
---
name: <skill-name>
description: <one-liner explaining what the skill does>. Trigger keywords - <comma-separated list>.
---

Claude auto-loads a skill when the user's message contains a trigger keyword.

Authoring rules

  1. One skill per package or domain (backend, frontend, infra, …).
  2. SKILL.md is the "Quick Reference" — tables, paths, the 80% case. Push detail into references/<topic>.md.
  3. Max ~150L target, ~250L push-to-split, 500L hard ceiling.
  4. References are link-target-only: SKILL.md points at them; they never link back up (keeps the skill cheap to load).
  5. Frontmatter description is load-bearing — it's the selection signal. Start with "when to use this skill," list trigger keywords at the end.

Maintenance

  • When you learn something new about a package, add it to the skill.
  • When you see a skill contradicting reality, fix the skill.
  • Large changes → split to references/.

---

## Phase F — Skills directory

### F.1 Directory bootstrap

.claude/skills/ ├── {{ skill-1 }}/ │ ├── SKILL.md │ └── references/ └── {{ skill-2 }}/ ├── SKILL.md └── references/


**Which skills to create?** One per major domain. For the project described by the user, identify 2–5 natural seams. Typical examples:

- `backend` — API conventions, routing, ORM patterns
- `frontend` — UI, styling, state management
- `infra` — AWS, DB, logs, deploy
- `domain` — business entities, workflows, glossary
- `testing` — test runners, coverage, fixtures

Ask the user before creating skills for domains they haven't mentioned. Start minimal — 2 skills is fine; skills are cheap to add later but expensive to get wrong upfront.

### F.2 SKILL.md template

```markdown
---
name: {{ skill-name }}
description: {{ one-line purpose }}. Trigger keywords - {{ comma-separated }}.
---

# {{ Skill Title }}

## Quick Reference

| Question | Answer |
|---|---|
| {{ Q1 }} | {{ A1 }} |
| {{ Q2 }} | {{ A2 }} |

## Core Patterns

### {{ Pattern 1 }}
{{ example code + explanation }}

### {{ Pattern 2 }}
{{ example code + explanation }}

## References

| Topic | File |
|---|---|
| {{ deeper topic }} | [references/{{ topic }}.md](references/{{ topic }}.md) |

F.3 AGENTS.md symlink (optional)

If the repo will be used by tools other than Claude Code (e.g. Codex, Cursor) that look for AGENTS.md, add a symlink:

ln -s CLAUDE.md AGENTS.md

Phase G — Customization checklist

Before declaring done, replace these placeholders:

Placeholder What to replace
{{ project-name }} Project / repo name
{{ primary-author-handle }} GitHub username of the person who knows the project best
{{ YYYY-MM-DD }} Today's date, ISO format
{{ branch-name }} (in task templates) leave — user fills when creating a task
Skills table in CLAUDE.md Replace with real skills after Phase F
ARCHITECTURE.md body Replace with actual domains + runtime topology
docs/product-specs/index.md "Contents" Add first real product specs as they come

Phase H — Verification

Run in order:

pnpm install                     # if you added node deps or new package.json
pnpm docs:check                  # must exit 0 (warnings allowed)
pnpm docs:garden                 # should find very little on day 1

Expected docs:check output:

docs/link check: OK (<N> md files, <M> code files scanned)

If warnings appear, they're probably:

  • Missing frontmatter on existing docs → add
  • Status uses shipped/accepted → normalize to active
  • Sub-files missing **Part of:** → add

If errors appear, they're real — fix before commit.


Appendix: failure modes seen in practice

Symptom Root cause Fix
"Agent ignored our convention" Convention lived in CLAUDE.md buried under 500 lines; agent didn't see it. Move to a skill; CLAUDE.md just points.
"Docs drifted silently for weeks" No mechanical verification; humans forgot to update. pnpm docs:check in pre-commit + CI; weekly pnpm docs:garden review.
"Two docs say different things" Duplicated content across design-docs + product-specs. One owns, the other links. §7a Enforcement-table for multi-site rules.
"Task MD filenames collide on merge" Using T-NNN- counter; two engineers bumped to same number. Switch to YYYY-MM-DD-slug.md. Collision-free.
"CLAUDE.md won't stay short" Tendency to paste policy there. Policy goes in design-docs/. CLAUDE.md is a map.
"Skill files grow past 250L" Detail accretes over time. Extract to references/<topic>.md. Pattern is intentional; use it.

Appendix: what this setup does NOT provide

  • Test infrastructure. Add vitest / playwright / whatever your stack uses; this setup is agnostic to testing.
  • CI platform. docs:check runs as a pnpm script. Wire to GitHub Actions / Vercel / whatever when ready.
  • Automated PR opening. v2 of doc-gardening deferred; needs a scheduler (GHA cron, etc.).
  • Style/lint for code. Separate concern. Add ESLint/Prettier/Biome per stack; they don't interact with this doc infrastructure.
  • Monorepo setup. Assumes it already exists if multi-package. Paths in scripts accommodate both apps/+packages/ (turborepo) and src/ (single-app).

Credits

Based on patterns from OpenAI's Harness engineering: leveraging Codex in an agent-first world blog and iterated on in production repos. Not all of it is in that blog; parts (doc-format convention, date-filename rule, doc-gardening drift types) came from practice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment