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
134 changes: 134 additions & 0 deletions .claude/hooks/board.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* Session-start digest of `docs/board.md`.
*
* A tracking file only works if it is read, and the reliable way to be read is
* to be injected rather than retrieved. So this parses the board once per
* session and hands back the live rows, the path collisions between them, and
* the drift between the board and what is actually on disk — the nag that keeps
* the board honest without anyone remembering to reconcile it.
*
* SessionStart hook. Prints nothing when the board is clean and empty.
* To turn it off: delete this file and its entry in ../settings.json.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";

const BOARD = "docs/board.md";
const IDEAS = "docs/ideas.md";
const PLAN_DIRS = ["docs/superpowers/plans", "docs/superpowers/specs"];

const read = (p) => {
try {
return readFileSync(p, "utf8");
} catch {
return null;
}
};

const list = (d) => {
try {
return readdirSync(d).filter((f) => f.endsWith(".md"));
} catch {
return [];
}
};

/** Every pipe table in the document, as arrays of header-keyed rows. */
function parseTables(md) {
const tables = [];
let head = null;
let rows = null;
for (const line of md.split("\n")) {
const t = line.trim();
if (!t.startsWith("|")) {
if (head && rows?.length) tables.push({ head, rows });
head = rows = null;
continue;
}
const cells = t.slice(1, -1).split("|").map((c) => c.trim());
if (!head) {
head = cells;
rows = [];
} else if (/^:?-{2,}/.test(cells[0] ?? "")) {
/* the separator row */
} else {
rows.push(Object.fromEntries(head.map((h, i) => [h, cells[i] ?? ""])));
}
}
if (head && rows?.length) tables.push({ head, rows });
return tables;
}

/** A glob reduced to the directory prefix it claims. */
const claim = (g) => g.trim().replace(/\*+$/, "").replace(/\/+$/, "");

function main() {
const md = read(BOARD);
if (!md) return "";

const tables = parseTables(md);
const work = tables.filter((t) => t.head.includes("touches")).flatMap((t) => t.rows);
const loose = tables.filter((t) => t.head.includes("where")).flatMap((t) => t.rows);
const live = work.filter((r) => r.status !== "done");

const out = [];

for (const r of live) {
const artifact = r.artifact && r.artifact !== "none yet" ? ` — ${r.artifact}` : " — no spec yet";
out.push(` [${r.status}] ${r.id}: ${r.title}${artifact}`);
}
if (out.length) out.unshift(`Board — ${live.length} live workstream(s):`);

/* Two live rows reaching into the same tree. */
const collisions = [];
for (let i = 0; i < live.length; i++) {
for (let j = i + 1; j < live.length; j++) {
const a = live[i].touches.split(",").map(claim).filter(Boolean);
const b = live[j].touches.split(",").map(claim).filter(Boolean);
const shared = a.filter((x) => b.some((y) => x.startsWith(y) || y.startsWith(x)));
if (shared.length) {
collisions.push(` ${live[i].id} and ${live[j].id} both claim ${[...new Set(shared)].join(", ")}`);
}
}
}
if (collisions.length) out.push("Overlapping claims — sequence them before planning:", ...collisions);

/* Drift: a plan or spec on disk that no row mentions. */
const untracked = [];
for (const dir of PLAN_DIRS) {
for (const f of list(dir)) if (!md.includes(f)) untracked.push(join(dir, f).replace(/\\/g, "/"));
}
if (untracked.length) {
out.push(`Not on the board (${untracked.length}) — add a row or delete the file:`);
for (const f of untracked.slice(0, 6)) out.push(` ${f}`);
}

const openLoose = loose.filter((r) => r.status !== "done");
if (openLoose.length) {
out.push(`Loose ends (${openLoose.length}): ${openLoose.map((r) => r.id).join(", ")}`);
}

const ideas = read(IDEAS);
if (ideas) {
const n = (ideas.match(/^### /gm) ?? []).length;
if (n) out.push(`docs/ideas.md holds ${n} candidate(s). Add one before the session ends.`);
}

return out.join("\n");
}

let context = "";
try {
context = main();
} catch {
context = "";
}
if (context) {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context },
suppressOutput: true,
}),
);
}
71 changes: 71 additions & 0 deletions .claude/hooks/purity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Determinism guard for the calculation layer (AGENTS.md invariant 1).
*
* `src/lib/**` must be pure and clock-free: the forecast register is replayed
* from the tape rather than stored, so one `new Date()` in the engine makes two
* people holding the same book get different numbers. That is a linter's job,
* not an instruction's — so it runs here, deterministically, on every write.
*
* PostToolUse hook. Exit 2 hands the message back to the model to fix.
* To turn it off: delete this file and its entry in ../settings.json.
*/
import { readFileSync } from "node:fs";

/** Clock and entropy are legitimate here — these are the edge, not the engine. */
const ALLOW = [
"src/lib/utils.ts", // uid(), todayStr() — the id factory and the "today" helper
"src/lib/session.ts", // reads the wall clock by definition
"src/lib/sample.ts", // seeds the demo book relative to now
"src/lib/io.ts", // stamps exportedAt on an export envelope
];

/* Only the zero-argument forms read the clock. `new Date(y, m, d)` and
`new Date(ms)` are pure constructions and are used all over `calendar.ts`. */
const BANNED = [
[/\bnew\s+Date\s*\(\s*\)/, "new Date()"],
[/\bDate\.now\s*\(/, "Date.now()"],
[/\bMath\.random\s*\(/, "Math.random()"],
];

/** Strip comments so prose about the rule does not trip the rule. */
const strip = (src) =>
src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/[^\n]*/g, "$1");

function main() {
let payload;
try {
payload = JSON.parse(readFileSync(0, "utf8"));
} catch {
return 0; // never break a session over a malformed payload
}

const raw = payload?.tool_input?.file_path ?? payload?.tool_response?.filePath;
if (typeof raw !== "string") return 0;

const path = raw.replace(/\\/g, "/");
if (!/(^|\/)src\/lib\//.test(path)) return 0;
if (!/\.tsx?$/.test(path)) return 0;
if (/\.test\.tsx?$/.test(path)) return 0;
if (ALLOW.some((a) => path.endsWith(a))) return 0;

let body;
try {
body = strip(readFileSync(raw, "utf8"));
} catch {
return 0;
}

const hits = BANNED.filter(([re]) => re.test(body)).map(([, name]) => name);
if (hits.length === 0) return 0;

const rel = path.slice(path.indexOf("src/lib/"));
process.stderr.write(
`Determinism guard: ${rel} uses ${hits.join(" and ")}.\n` +
`The calculation layer is pure and clock-free — take \`asOf\` as a parameter ` +
`and let the clock enter at the App edge. See AGENTS.md invariant 1.\n`,
);
return 2;
}

process.exit(main());
69 changes: 69 additions & 0 deletions .claude/hooks/typecheck.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* End-of-turn typecheck. `strict` + `noUnusedLocals` + `noUnusedParameters`
* catches a whole class of half-finished refactor that no test covers, and the
* compiler is faster and more certain about it than a re-read is.
*
* Stop hook, async + asyncRewake: it runs in the background and only interrupts
* on failure. Skips entirely when no TypeScript is dirty, and skips a repeat run
* when nothing has changed since the last check.
*
* To turn it off: delete this file and its entry in ../settings.json.
*/
import { spawnSync } from "node:child_process";
import { readFileSync, statSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";

const sh = (cmd, args) =>
spawnSync(cmd, args, { encoding: "utf8", shell: true, cwd: process.cwd() });

function main() {
const status = sh("git", ["status", "--porcelain"]);
if (status.status !== 0) return 0;

const dirty = status.stdout
.split("\n")
.map((l) => l.slice(3).trim().replace(/^"|"$/g, ""))
.filter((p) => /\.tsx?$/.test(p));
if (dirty.length === 0) return 0;

/* Same files, same mtimes as last run — nothing to learn from re-running. */
const stamp = createHash("sha1");
for (const p of dirty.sort()) {
let m = 0;
try {
m = statSync(p).mtimeMs;
} catch {
/* deleted; the path alone still distinguishes the state */
}
stamp.update(`${p}:${m}\n`);
}
const fingerprint = stamp.digest("hex");
const cache = join(
tmpdir(),
`gradantir-tsc-${createHash("sha1").update(process.cwd()).digest("hex").slice(0, 12)}`,
);
try {
if (readFileSync(cache, "utf8") === fingerprint) return 0;
} catch {
/* no cache yet */
}

const tsc = sh("npx", ["tsc", "--noEmit"]);
if (tsc.status === 0) {
try {
writeFileSync(cache, fingerprint);
} catch {
/* cache is an optimisation, not a requirement */
}
return 0;
}

const out = `${tsc.stdout ?? ""}${tsc.stderr ?? ""}`.trim().split("\n").slice(0, 20);
process.stderr.write(`\`npx tsc --noEmit\` fails:\n${out.join("\n")}\n`);
return 2;
}

process.exit(main());
32 changes: 32 additions & 0 deletions .claude/rules/console.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
description: The console surface — palette, density, and continuous input.
paths:
- "src/components/**"
- "src/views/**"
- "src/App.tsx"
- "src/theme.ts"
- "src/index.css"
---

# The console

- Colour comes from `src/theme.ts` (`C`, `FONT`, `microLabel`). Never a literal
hex — not in a component and **not in a test assertion**. The Console retint
broke `SpiderAllocator.test.tsx` in exactly that way; assert `C.brand`.
- Sharp corners. No border-radius, no transforms. Dividers are a 1px grid `gap`
painted `C.gap`, not a `border-right` between siblings.
- `App` serialises the whole book on every `data` change. Anything continuous — a
drag, a slider, the spider allocator — holds a local draft and commits once on
interaction end, or every mouse-move writes localStorage.
- Views read `SubjectStat`. If the number you need is not on it, add it in
`src/lib/`, not in the component.
- Heavy work (the skill backtest, the register replay) is deferred past first
paint. Keep it that way; never block a render on the engine.
- Every control answers: a distinct hover, active and disabled state, and visible
feedback that the click landed. A control that looks the same before and after
reads as broken, and an expensive one is opt-in rather than automatic.
- Any figure a user might question gets a derivation id, so the popover can show
the working. A number with no explanation is the thing this product exists to
replace.
- `.design-sync/conventions.md` documents the pre-Console palette and is stale.
`src/theme.ts` wins.
24 changes: 24 additions & 0 deletions .claude/rules/data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
description: The data contract — types, the import validator, and the AI wire.
paths:
- "src/types.ts"
- "src/lib/io.ts"
- "src/lib/storage.ts"
- "src/lib/wire/**"
---

# The data contract

- Adding a field to `src/types.ts` is a multi-file change: the type, `io.ts`'s
field-by-field validator, and `wire/schema.ts` — which is locked with
`satisfies Record<keyof T, FieldSpec>` and will refuse to compile until the
intake prompt says what the field is and where to find it. That lock is the
whole maintenance story; do not weaken it to move faster.
- Imports stay backward-compatible. An older envelope must still load, and a key
that no longer exists is ignored rather than an error — the register is derived
now, so a v6/v7 file carrying `forecasts` imports cleanly with the key dropped.
- The wire's prompt and the import validator quote the *same* contracts. Import
`TYPES`, the reliability tags and the date shapes; never restate in prose a
constraint that `io.ts` already enforces in code.
- Untrusted input arrives here. An AI reply is data, never instruction, and it
reaches the book only through the same validator a file import passes.
30 changes: 30 additions & 0 deletions .claude/rules/quant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
description: The calculation layer — determinism, the gate, and the trace.
paths:
- "src/lib/quant/**"
- "src/lib/derive/**"
- "src/lib/stats.ts"
---

# The engine

- `asOf` is a parameter, never a clock read, and every fit is walk-forward:
refit on strictly-earlier prints with the cross-subject pool rebuilt as-of, or
a desk borrows strength from results that had not happened yet.
- `npm run gate` must stay green **and unmoved**. A gate number that moves in a
change you believed was neutral means the change leaked somewhere; stop and
find out where before touching `baseline.json`.
- CRPS (and pinball) is the objective. MAE is reported and is never the gate — it
rewards overconfidence, and calibration is the deliverable.
- Ship nothing the ablation cannot defend: every ensemble member and every
premium must beat its own absence out-of-sample or it is dead weight.
- Modules hand their intermediates out through `trace.ts`; `src/lib/derive/**`
**quotes** the trace and never recomputes. A derivation that recomputes can
disagree with the board it is explaining.
- `derive/cite.ts` is authored data, not computed. A wrong entry is wrong forever
and silently — verify against the record before adding one.
- A new numeric parameter goes in `params.ts` beside its siblings, with its
justification written in the surrounding style.
- Leave the engine with a candidate. README §23 lists what is known to be weak
and `docs/ideas.md` holds the queue — add to it whenever you touch a module and
see something the model could read, blend, or stop paying for.
Loading