Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
16 changes: 16 additions & 0 deletions .github/workflows/guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ name: Guards
# is the one layer nothing bypasses. Jobs:
# dashes - the cross-repo dash ban: changed files, the shrink-only baseline, and the PR title/body
# copy - AI-cliche / placeholder / typed-uppercase locale values + hardcoded brand colors
# context-budget - the shrink-only always-loaded context budget and structural allowlists
# ratchet - the eslint-suppressions.json baselines may only shrink
# parity - a web-scope code change with zero mobile-scope change (or inverse) fails without the parity:exempt label
# expo-pin - apps/mobile/package.json changes must keep the Expo SDK pin resolution intact
Expand Down Expand Up @@ -62,6 +63,21 @@ jobs:
node-version: 22
- run: node tools/check-copy.mjs --check

context-budget:
name: Context Budget
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !contains(github.event.pull_request.labels.*.name, 'context:reseed')
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 22
- run: node tools/check-context-budget.mjs --check
env:
CONTEXT_BUDGET_BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}

frontmatter:
name: Skill and Agent Frontmatter
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Auth: web cookie is httpOnly + sameSite strict + secure; mobile tokens live in S
- `orbit-api` is a sibling at `C:\Users\thoma\Documents\Programming\Projects\orbit-api`; update it in the same task when a feature needs backend support. Separate git histories, branches, and PRs.
- C# LSP for orbit-api is wired via `.mcp.json`: the Roslyn-backed CWM.RoslynNavigator MCP server (install once: `dotnet tool install -g CWM.RoslynNavigator`) pointed at orbit-api's `Orbit.slnx`; copy from `.claude/mcp.json.example`.
- Reusable agent scripts live in `tools/` under the `tools/CONVENTIONS.md` contract (catalog: `tools/README.md`); build a new one with `/make-tool` once you have run the same incantation twice; a one-off stays in the scratchpad.
- Always-loaded context is budgeted by `tools/context-budget.json`; run `node tools/check-context-budget.mjs --check` after editing this file or `.claude/rules/`, with `context:reseed` reserved for deliberate growth.
- **The harness is EXECUTED before it merges, never only read.** `node tools/test-tools.mjs` runs every script in `tools/` (the CLI contract plus each tool's decision paths, orca stubbed, hermetic) and fails on a new tool with no coverage; `node .claude/hooks/test-hooks.mjs` runs the hooks and the agent-frontmatter guard. The `Harness Execution` job in `guards.yml` runs both on any PR touching `tools/**` or `.claude/**`. A claim that a harness change works, without a command that ran, is a review finding (rubric dimension 15), because a harness cannot certify itself by review: `claude-review.yml` reads the diff, this runs the code.
- Git: one feature/fix per PR (cross-repo work opens paired PRs, cross-linked); branches `feature/`|`fix/`|`chore/`; `main` is protected (no direct or force push, enforced by the `git-guardrails` hook); squash-merge only; never `--no-verify`/`--no-gpg-sign`; never reuse a squash-merged branch.
- Session hooks (`.claude/settings.json`) are the three rules no CI gate can enforce at act time: `git-guardrails` (protected main, no-verify, the worktree junction footgun), `forbid-ef-migration-raw-index` (idempotent raw index SQL in EF migrations), and `forbid-raw-linear-mutation` (a Linear WRITE goes through the orca CLI, never hand-rolled GraphQL; reads stay open, and `projectCreate`/`projectUpdate` are allowed raw because orca has no project write at all). Everything else the old hook fleet enforced migrated to the deterministic gates named under Code standards (D6), and the opencode dual-target engine is gone (D22). `node .claude/hooks/test-hooks.mjs` is the proof; run it after touching `.claude/hooks/` or an agent's frontmatter, and the Harness Execution job runs it again in CI.
Expand Down Expand Up @@ -83,6 +84,7 @@ Grep a doc's `At a glance` header before loading the whole file.
| `FEATURES.md` | Hand-maintained, gating- and platform-aware capability catalogue (the source the Play listing, landing, and QA copy derive from). Kept honest by the `/pr-review` feature-inventory parity gate (rubric #14), not by generation; it holds the Free/Trial/Pro/Yearly gating the generated arch map does not. |
| `TESTING.md` | How to write tests here + the catalog of every suite and what each proves. |
| `architecture.json` / `architecture.html` | The generated architecture map (routes and screens with parity pairs, endpoints, i18n key ownership), kept fresh by the `arch-map.yml` drift gate. What an agent reads INSTEAD of exploring the codebase. |
| `tools/context-budget.json` | The seeded shrink-only byte baseline for repo-visible always-loaded context. `tools/check-context-budget.mjs` also guards sibling `@` imports and unconditional rules files; the Context Budget CI job enforces both. |
| `.claude/rules/core.md` | The ~50 lines of judgement that auto-load on EVERY turn. A rule earns a place only if it applies in any turn AND no skill invocation reliably precedes it. Everything in `.claude/rules/` is paid for on every turn of every session, so treat additions here as expensive. |
| `.claude/playbooks/` | The on-demand judgement tier: standing rules no gate can check, themed by activity (debugging, review-and-audit, planning-and-artifacts, context-engineering). Nothing here auto-loads; the skill or agent that needs one cites it by path. `context-engineering.md` is the one to read before authoring anything the agent itself reads (a `CLAUDE.md`, a rule, a skill, an agent, a tool interface, a ticket body). |
| `.claude/manifests/surfaces.json` | The DERIVED visual-surface inventory (routes + the multi-view Today root + overlays, x theme x locale). Generated by `tools/surface-manifest.mjs`; carries no status field by design. With `tools/redesign-coverage.mjs` (every surface claimed by exactly one #539 ticket per D35, plus every `.tsx` under `apps/*/app` + `apps/*/components` mapped to exactly one ticket by directory rule per D38; exit 1 on any unclaimed surface or orphaned file) it is the redesign's denominator; both survive until the #539 Linear project completes, then fold into the arch map (D39). |
Expand Down
1 change: 1 addition & 0 deletions tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Read `CONVENTIONS.md` before adding one. Use the `/make-tool` skill to scaffold
| `arch-map.mjs` | Generates `architecture.json` + `architecture.html` (routes, parity pairs, endpoints, i18n ownership); the drift CI job (`arch-map.yml`) regenerates and fails on drift. | `node tools/arch-map.mjs` |
| `check-dashes.mjs` | The cross-repo dash ban: em dashes banned everywhere, en dashes only in numeric ranges. Backs the Dash Ban CI job, lefthook, and the shrink-only `dash-baseline.json`. | `--files <f>...` \| `--check-baseline` \| `--write-baseline` \| `--text "<s>"` |
| `check-copy.mjs` | The copy register: whole-file, values-only scan of locale copy for AI cliches, placeholder content, typed uppercase, and hardcoded brand colors. Backs the Copy Register CI job. | `node tools/check-copy.mjs --check` \| `--write-baseline` |
| `check-context-budget.mjs` | Enforces the shrink-only byte budget for repo-visible always-loaded context and the structural allowlists for sibling `@` imports and unconditional rule files. Reports resolvable sibling context without enforcing it. Backs the Context Budget CI job. | `node tools/check-context-budget.mjs --check` \| `--write-baseline` \| `--json` |
| `check-suppressions-ratchet.mjs` | Asserts the ESLint suppression baselines only shrink (escape hatch: the `ratchet:reseed` label). Backs the Suppressions Ratchet CI job. | `node tools/check-suppressions-ratchet.mjs` |
| `check-frontmatter.mjs` | Asserts every skill and agent `.md` has parseable YAML frontmatter (an unquoted `": "` silently drops the file's description, or the whole file). Backs the Skill and Agent Frontmatter CI job. | `node tools/check-frontmatter.mjs` (`--fix`) |
| `check-push-target.mjs` | The lefthook `pre-push` guard: reads git's pre-push stdin and rejects a push whose remote ref is a protected branch. | `node tools/check-push-target.mjs < <pre-push stdin>` |
Expand Down
301 changes: 301 additions & 0 deletions tools/check-context-budget.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
#!/usr/bin/env node

import { spawnSync } from "node:child_process"
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
import { dirname, join, relative, resolve } from "node:path"
import { fileURLToPath } from "node:url"

const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..")
const CLAUDE_PATH = join(REPO_ROOT, "CLAUDE.md")
const RULES_PATH = join(REPO_ROOT, ".claude", "rules")
const BASELINE_PATH = join(REPO_ROOT, "tools", "context-budget.json")
const BASELINE_REPO_PATH = "tools/context-budget.json"
const EXPECTED_IMPORTS = new Set(["@../orbit-api/CLAUDE.md", "@../orbit-landing-page/CLAUDE.md"])
const EXPECTED_UNCONDITIONAL_RULES = new Set([".claude/rules/core.md"])

const USAGE = `usage: check-context-budget.mjs [--check | --write-baseline] [--json]

--check compare with the target branch's tools/context-budget.json (default)
--write-baseline write the current enforced byte total and per-file counts
--json print the result as machine-readable JSON
--help, -h print this usage and exit 0

exit codes:
0 within budget, or baseline written
1 byte budget or structural allowlist exceeded
2 invalid arguments, malformed baseline, or tool error`

const toRepoPath = (absolutePath) => relative(REPO_ROOT, absolutePath).replace(/\\/g, "/")
const byteCount = (path) => readFileSync(path).byteLength
const estimatedTokens = (bytes) => Math.floor(bytes / 4)
const signed = (number) => `${number >= 0 ? "+" : ""}${number}`

function parseArguments() {
const argumentsList = process.argv.slice(2)
if (argumentsList.includes("--help") || argumentsList.includes("-h")) return { help: true }

const known = new Set(["--check", "--write-baseline", "--json"])
const unknown = argumentsList.find((argument) => !known.has(argument))
const checks = argumentsList.filter((argument) => argument === "--check").length
const writes = argumentsList.filter((argument) => argument === "--write-baseline").length
const jsonFlags = argumentsList.filter((argument) => argument === "--json").length
if (unknown || checks > 1 || writes > 1 || jsonFlags > 1 || (checks && writes)) {
throw new Error(USAGE)
}

return {
help: false,
json: jsonFlags === 1,
mode: writes === 1 ? "write" : "check",
}
}

function hasPathsFrontmatter(text) {
const normalized = text.replace(/^\uFEFF/, "")
if (!normalized.startsWith("---\n") && !normalized.startsWith("---\r\n")) return false
const lines = normalized.split(/\r?\n/)
const closing = lines.indexOf("---", 1)
if (closing === -1) return false
return lines.slice(1, closing).some((line) => /^paths\s*:/.test(line))
}

function measureRepository() {
if (!existsSync(CLAUDE_PATH) || !statSync(CLAUDE_PATH).isFile()) {
throw new Error("CLAUDE.md is missing")
}
if (!existsSync(RULES_PATH) || !statSync(RULES_PATH).isDirectory()) {
throw new Error(".claude/rules is missing")
}

const claudeText = readFileSync(CLAUDE_PATH, "utf8")
const imports = claudeText
.split(/\r?\n/)
.filter((line) => line.startsWith("@"))
.sort()
const unexpectedImports = imports.filter((line) => !EXPECTED_IMPORTS.has(line))

const unconditionalRules = []
for (const name of readdirSync(RULES_PATH).filter((name) => name.endsWith(".md")).sort()) {
const absolutePath = join(RULES_PATH, name)
if (!statSync(absolutePath).isFile()) continue
if (!hasPathsFrontmatter(readFileSync(absolutePath, "utf8"))) {
unconditionalRules.push(toRepoPath(absolutePath))
}
}
const unexpectedRules = unconditionalRules.filter((file) => !EXPECTED_UNCONDITIONAL_RULES.has(file))

const enforcedPaths = [CLAUDE_PATH, ...unconditionalRules.map((file) => join(REPO_ROOT, file))]
const files = Object.fromEntries(enforcedPaths.map((path) => [toRepoPath(path), byteCount(path)]))
const enforcedBytes = Object.values(files).reduce((total, bytes) => total + bytes, 0)

const siblingFiles = []
for (const importLine of imports.filter((line) => EXPECTED_IMPORTS.has(line))) {
const absolutePath = resolve(REPO_ROOT, importLine.slice(1))
if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) continue
siblingFiles.push({
file: relative(dirname(REPO_ROOT), absolutePath).replace(/\\/g, "/"),
bytes: byteCount(absolutePath),
})
}
const siblingBytes = siblingFiles.reduce((total, file) => total + file.bytes, 0)

return {
files,
enforcedBytes,
estimatedTokens: estimatedTokens(enforcedBytes),
imports,
unconditionalRules,
unexpectedImports,
unexpectedRules,
siblingFiles,
fullSessionBytes: enforcedBytes + siblingBytes,
fullSessionEstimatedTokens: estimatedTokens(enforcedBytes + siblingBytes),
}
}

function parseBaseline(text, source) {
let baseline
try {
baseline = JSON.parse(text)
} catch {
throw new Error(`${source} is not valid JSON`)
}

const validFiles =
baseline &&
typeof baseline === "object" &&
!Array.isArray(baseline) &&
baseline.files &&
typeof baseline.files === "object" &&
!Array.isArray(baseline.files) &&
Object.keys(baseline.files).length > 0 &&
Object.values(baseline.files).every((bytes) => Number.isInteger(bytes) && bytes >= 0)
const filesTotal = validFiles
? Object.values(baseline.files).reduce((total, bytes) => total + bytes, 0)
: -1
if (!validFiles || !Number.isInteger(baseline.bytes) || baseline.bytes < 0 || baseline.bytes !== filesTotal) {
throw new Error(`${source} must contain matching non-negative integer bytes and files values`)
}
return baseline
}

function readWorkingBaseline() {
if (!existsSync(BASELINE_PATH) || !statSync(BASELINE_PATH).isFile()) {
throw new Error("tools/context-budget.json is missing")
}
return parseBaseline(readFileSync(BASELINE_PATH, "utf8"), "tools/context-budget.json")
}

function runGit(argumentsList) {
return spawnSync("git", argumentsList, {
cwd: REPO_ROOT,
encoding: "utf8",
windowsHide: true,
})
}

function resolveTargetRef() {
const configured = process.env.CONTEXT_BUDGET_BASE_REF?.trim()
const githubBase = process.env.GITHUB_BASE_REF?.trim()
const candidates = configured
? [configured]
: githubBase
? [`refs/remotes/origin/${githubBase}`, githubBase]
: ["refs/remotes/origin/main", "main"]

for (const candidate of candidates) {
const result = runGit(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`])
if (result.status === 0) return candidate
}

const requested = configured ?? githubBase ?? "origin/main or main"
throw new Error(`target branch ${requested} is unavailable; fetch its history before checking`)
}

function readComparisonBaseline() {
const targetRef = resolveTargetRef()
const listing = runGit(["ls-tree", "--name-only", targetRef, "--", BASELINE_REPO_PATH])
if (listing.status !== 0) {
throw new Error(`could not inspect ${BASELINE_REPO_PATH} on target branch ${targetRef}`)
}
if (listing.stdout.trim() === "") {
return {
...readWorkingBaseline(),
source: `${targetRef} (working tree bootstrap)`,
}
}

const blob = runGit(["show", `${targetRef}:${BASELINE_REPO_PATH}`])
if (blob.status !== 0) {
throw new Error(`could not read ${BASELINE_REPO_PATH} from target branch ${targetRef}`)
}
return {
...parseBaseline(blob.stdout, `${BASELINE_REPO_PATH} on target branch ${targetRef}`),
source: targetRef,
}
}

function structuralFindings(measurement) {
return [
...measurement.unexpectedImports.map((line) => `unexpected root import: ${line}`),
...measurement.unexpectedRules.map((file) => `unexpected unconditional rule: ${file}`),
]
}

function outputHuman(result) {
console.log("Always-loaded context budget")
console.log("")
console.log("file".padEnd(44) + "bytes".padStart(10) + " ~tokens (estimate)".padStart(22))
for (const [file, bytes] of Object.entries(result.files)) {
console.log(file.padEnd(44) + String(bytes).padStart(10) + String(estimatedTokens(bytes)).padStart(22))
}
console.log("")
console.log(`enforced total: ${result.enforcedBytes} bytes (~${result.estimatedTokens} tokens, estimate)`)
if (result.baselineBytes !== null) {
console.log(`baseline: ${result.baselineBytes} bytes`)
console.log(`baseline source: ${result.baselineSource}`)
console.log(`delta: ${signed(result.deltaBytes)} bytes`)
}
if (result.siblingFiles.length > 0) {
console.log("")
console.log("resolved sibling imports:")
for (const file of result.siblingFiles) console.log(` ${file.file}: ${file.bytes} bytes`)
}
console.log(
`full session from resolved files: ${result.fullSessionBytes} bytes (~${result.fullSessionEstimatedTokens} tokens, estimate)`,
)
}

function main() {
let argumentsParsed
try {
argumentsParsed = parseArguments()
} catch (error) {
console.error(error.message)
return 2
}
if (argumentsParsed.help) {
console.log(USAGE)
return 0
}

try {
const measurement = measureRepository()
const findings = structuralFindings(measurement)
let baseline = null
if (argumentsParsed.mode === "check") baseline = readComparisonBaseline()

const baselineBytes = baseline?.bytes ?? (argumentsParsed.mode === "write" ? measurement.enforcedBytes : null)
const deltaBytes = baselineBytes === null ? null : measurement.enforcedBytes - baselineBytes
const fileGrowth = baseline
? Object.entries(measurement.files)
.map(([file, bytes]) => ({ file, bytes: bytes - (baseline.files[file] ?? 0) }))
.filter((entry) => entry.bytes > 0)
: []
const result = {
...measurement,
baselineBytes,
baselineSource: baseline?.source ?? null,
deltaBytes,
fileGrowth,
structuralFindings: findings,
status: findings.length > 0 || (deltaBytes !== null && deltaBytes > 0) ? "over" : "ok",
}

if (argumentsParsed.json) console.log(JSON.stringify(result, null, 2))
else outputHuman(result)

if (findings.length > 0) {
if (!argumentsParsed.json) {
console.error("")
console.error("Context structure grew beyond the allowlist:")
for (const finding of findings) console.error(` ${finding}`)
}
return 1
}

if (argumentsParsed.mode === "write") {
writeFileSync(
BASELINE_PATH,
JSON.stringify({ bytes: measurement.enforcedBytes, files: measurement.files }, null, 2) + "\n",
)
if (!argumentsParsed.json) console.log(`context-budget.json written: ${measurement.enforcedBytes} bytes`)
return 0
}

if (deltaBytes > 0) {
if (!argumentsParsed.json) {
console.error("")
console.error(`Always-loaded context grew by ${deltaBytes} bytes beyond the committed baseline:`)
for (const growth of fileGrowth) console.error(` ${growth.file}: +${growth.bytes} bytes`)
}
return 1
}
return 0
} catch (error) {
console.error(`check-context-budget: ${error.message}`)
return 2
}
}

process.exit(main())
7 changes: 7 additions & 0 deletions tools/context-budget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
Comment thread
thomasluizon marked this conversation as resolved.
Resolved
"bytes": 22393,
"files": {
"CLAUDE.md": 18950,
".claude/rules/core.md": 3443
}
}
Loading
Loading