Pane beside the chat in englishstreetventures/osn: the branch, its issue and pull request, the changeset check, NEW-FEAT.md's gates, and /gates to run CI's…

An open, modular social platform. You own your identity and social graph, and choose which apps see them.
OSN splits the social graph from the apps that use it. You own your identity and relationships, opt into apps one at a time, and your access rules follow you across all of them. Block someone on OSN and the block reaches every app you connect — unless you say otherwise.
Two names, and they mean different things.
OSN is the system: a specification and the headless core that implements it — identity, the social graph, authorisation, and the OpenID Connect issuer. It ships no user interface, and anyone can run it for their own private social graph.
Musubi is our implementation and the product built on it: the social app, its marketing site, the brand, and musubi.social, the instance we host. Musubi is to OSN as Mastodon is to the fediverse.
The rule that decides which name anything takes: if an independent implementation must use the same string to interoperate, it is OSN; otherwise it is Musubi. So the token audiences, well-known paths and claim names stay OSN, while the app, the RP ID and the hosted instance are Musubi. Full detail in wiki/shared/osn-and-musubi.md.
OSN's graph is general — edges carry their own semantics, so follows and reports-to are one primitive. A corporate directory is a profile of OSN rather than a different system.
There is no separate specification repository yet. OSN today is the reference implementation plus the wire contract it defines; a written specification waits until a second implementer exists.
Phase 1. Three surfaces run in production on Cloudflare; the rest run locally.
| Surface | Packages | Where it runs |
|---|---|---|
| Identity / auth API | @osn/api | Live — Worker on id.musubi.social |
| Wedding invites (Cire) | @cire/api, @cire/invites, @cire/host, @cire/vendor, @cire/db, @cire/theme | Live — cireweddings.com zone |
| Wedding marketing site | @cire/landing | Live — apex cireweddings.com |
| Identity & graph UI | @musubi/social | Live — Pages on the apex musubi.social (also serves the OIDC consent screen) |
| Events (Pulse) | @pulse/web, @pulse/api, @pulse/db | Local only |
| Messaging (Zap) | @zap/api, @zap/db | Worker on zap.cireweddings.com — M1 in flight, client app not started |
| Musubi / Pulse marketing sites | @musubi/landing, @pulse/landing | Dev tier only — no production deploy |
CI deploys the osn-api, cire-api and zap-api Workers, the guest site (Worker SSR) and the identity app, organiser, vendor and cire landing sites (Pages) to production in releases from .github/workflows/production-release.yml, once a day or by hand, one approval each. Each production deploy applies its D1 migrations before the Worker. A merge to main deploys the dev tier at once, through the Alchemy stacks into its own Cloudflare account, every surface as a Worker. See wiki/shared/dev-environment.md and wiki/shared/production-deploy.md.
The identity and social-graph layer, and the OIDC issuer other apps authenticate against.
aud: "osn-access"), public keys at /.well-known/jwks.json. Downstream services verify through @shared/osn-auth-clientA bespoke digital wedding invite: a tactile, animated guest site plus a portal where the couple runs the guest list. Once its own repo, now a sibling workspace. The schema is already multi-tenant (a weddings root table), so it can become a product without a rewrite.
Detail lives in wiki/cire/cire.md and wiki/cire/cire-auth.md.
A unified events platform: the social ease of Facebook Events, the fun of Partiful and Luma, the tooling of Eventbrite.
Secure, playful messaging that doubles as the ecosystem's support and announcements channel. Aim: between Messenger, Instagram and iMessage — playful but not loud, modern, secure, open.
Two top-level views:
Core features:
@shared/crypto, planned)Key differentiator — organisation chats:
support@acme.com with @acme. Phishing gets harder because the verified handle is the only source of truth.@zap/api and @zap/db are scaffolded (M0 done, M1 in flight). @zap/app, the SolidJS client, has not started. See the open issues (gh issue list --repo englishstreetventures/osn --label product:zap) and wiki/zap/zap.md.
Multi-format social content: text posts, image posts, long-form writing, short-form video. Switch off any format you don't want — short video, say — and it disappears.
Organised by domain. Six top-level directories, one workspace prefix each. A seventh, tools/, holds the local-only developer tooling under @tools/*.
osn/ # @osn/* — identity stack, the headless system
api/ # Elysia identity server (:4000; prod Worker id.musubi.social)
auth-ui/ # SolidJS views for OSN's ceremonies (<SignIn>, <Register>, <SessionsView>…)
client/ # Client SDK (Effect-based + SolidJS bindings)
db/ # Drizzle schema — accounts, profiles, passkeys, sessions, graph, orgs
musubi/ # @musubi/* — our implementation of OSN
social/ # SolidJS app for identity + graph management (:1422; prod musubi.social)
landing/ # Marketing site, Astro + Solid (:4324)
pulse/ # @pulse/* — events stack
web/ # SolidJS client (browser SPA)
api/ # Elysia + Eden events server (:3001)
db/ # Drizzle schema — events, RSVPs
landing/ # Marketing site (:4325)
zap/ # @zap/* — messaging stack (M0 scaffolded; client planned)
api/ # Elysia messaging server (:3002)
db/ # Drizzle schema — chats, messages, group state
cire/ # @cire/* — wedding-invite stack
api/ # Elysia on Cloudflare Workers (:8787; prod api.cireweddings.com)
invites/ # Guest invite site, Astro + SolidJS (:4321; prod invite.cireweddings.com)
host/ # Organiser portal (:4322; prod host.cireweddings.com)
vendor/ # Vendor portal (:4326; prod vendor.cireweddings.com)
landing/ # Marketing site for the apex (:4323)
db/ # Drizzle schema + D1 migrations
theme/ # Zero-dependency theming validators (CSS-colour allowlist)
ui/ # cire's house component layer, on top of @shared/ui
invite-designs/ # The invite design catalogue
dietary/ # The dietary-requirement vocabulary (API and UI)
build-tools/ # Build-only Astro integration: each build's CSP names its own API
shared/ # @shared/* — cross-cutting utilities
crypto/ # ARC tokens, recovery-code helpers (Signal Protocol planned)
db-utils/ # Driver-agnostic Drizzle handle, makeDbLive, makeD1DbLive
design-tokens/ # The ui-* token contract every shared component reads
email/ # EmailService Tag — Resend / Cloudflare / Log / Noop transports
feature-flags/ # Key-optional, fail-safe flag client (GrowthBook)
observability/ # OpenTelemetry helpers, Elysia plugin, instrumentedFetch
osn-auth-client/ # Downstream access-JWT verification — JWKS cache, Elysia adapter
rate-limit/ # Per-IP / per-user limiter primitives, client-IP trust policy
redis/ # Redis wrapper, rate-limiter Lua, JTI / rotated-session stores
turnstile/ # Key-optional, fail-closed Turnstile verifier
typescript-config/ # base / node / solid tsconfigs
ui/ # The shared SolidJS primitives (Button, Card, Modal, Field, Table…)
tools/ # @tools/* — local-only developer tooling, never shipped
e2e/ # The end-to-end suite (`bun run test:e2e`)
lab/ # Component and three.js scratchpad (`bun run dev:lab`)
metrics/ # Session-metrics dashboard over .claude/metrics
oxlint/house/ # This repo's own lint rules (@tools/oxlint-house)
pr-metrics/ # The session-performance cards
stryker/ # Stryker mutation testing for a cire package (`test:mutation`)
Prefix rule: every workspace sits under exactly one of osn/, musubi/, pulse/, zap/, cire/, shared/ or tools/, and its package.json name uses the matching prefix.
Dependencies flow one way: shared/* depends on nothing internal; osn/* depends on shared/*; musubi/*, pulse/*, zap/* and cire/* may depend on osn/* and shared/* but never on each other. Cross-domain reads go through a bridge module — Pulse reaches the social graph through graphBridge, Cire through ARC-gated internal endpoints.
aot: false — Elysia's ahead-of-time compilation uses new Function, which workerd forbidsManagedRuntime, never per requestkid, scope and audience — for service-to-service authFour environments, two drivers, one Drizzle type:
| Environment | Driver | Where |
|---|---|---|
local | bun:sqlite | Dev machine — bun run dev and in-memory tests |
dev | Cloudflare D1 | deployed in the dev account by the Alchemy stacks; zap and pulse also run wrangler dev --env dev (miniflare) locally |
staging | Cloudflare D1 | Deployed |
production | Cloudflare D1 | Deployed |
@shared/db-utils broadens the Drizzle handle over both the sync (bun:sqlite) and async (D1) result kinds, so the same query code runs everywhere — service code must await. See wiki/shared/database-environments.md.
@shared/ui primitives, @osn/auth-ui ceremony views, @cire/ui for cire's house style@zap/api is the shared messaging backend, in two modes:
So Pulse users can join event chats without installing Zap.
| Layer | Technology |
|---|---|
| Runtime | Bun (local), Cloudflare Workers (production) |
| Language | TypeScript |
| Backend framework | Elysia |
| Functional core | Effect.ts |
| ORM | Drizzle |
| Database | bun:sqlite (local) → Cloudflare D1 (dev/staging/prod) |
| Cache / limiters | Upstash Redis, native Workers rate-limit bindings |
| API communication | Eden (internal), REST (external) |
| Real-time | WebSockets |
| Encryption | Signal Protocol (planned), ES256 JWTs, WebAuthn |
| Resend HTTP API | |
| Frontend framework | SolidJS |
| Web / marketing sites | Astro + Solid, on Cloudflare Pages |
| Monorepo | Turborepo |
| Testing | Vitest + @effect/vitest |
| Linting | oxlint |
| Formatting | bun fmt (Bun's built-in formatter, config in .oxfmtrc.json) |
| Validation | Elysia TypeBox at the HTTP boundary, Effect Schema in services |
| Observability | OpenTelemetry → Grafana Cloud |
| Feature flags | GrowthBook |
| Versioning | Changesets |
You need Bun — the pinned version is in .bun-version.
bun install # install every workspace
bun run dev # start everything (turbo)
Most of the time you want one stack, not all of them:
bun run dev:osn # identity API
bun run dev:social # identity API + social app
bun run dev:pulse # pulse API + app, identity API, zap API
bun run dev:zap # zap API + identity API
bun run dev:cire # cire API + guest + organiser, identity API
bun run dev:apis # backends only
Packages that need configuration ship a .env.example — copy it to .env and fill it in. Missing secrets degrade rather than break: no Resend key logs emails instead of sending them, no Turnstile secret leaves the bot check inert.
Checks:
bun run typecheck # type-check
bun run test # all tests
bun run lint # oxlint
bun run fmt # bun fmt (scope listed in package.json)
Database work runs from the owning package:
bun run --cwd cire/db db:generate --name <snake_case> # write the next migration (wiki/conventions/commands.md)
bun run --cwd pulse/db db:studio # Drizzle Studio
bun run db:reset # reset every local database
AGENTS.md holds the everyday commands and code rules; wiki/conventions/commands.md has the full list.
Every change goes through a pull request. Nobody pushes to main.
Work on a feature branch, and include a changeset describing the change and the packages it touches:
bun run changeset
Pick the packages, pick the bump type, write a summary. The CI "Changeset Check" job fails without one. It enforces two rules. Package names must match the workspace name exactly — "@pulse/web", not "pulse". One changeset must not mix version-less packages (@cire/*) with versioned ones.
On merge, CI runs changeset version to bump versions and update changelogs, lowers the lint-warning ceiling in scripts/lint-warning-ceiling.txt to the measured count, commits both to main in one commit. That push deploys the dev tier; production picks the change up in its next release.
Read next:
AGENTS.md — the entry point for coding agents: workflow, standing rules, commandswiki/index.md — map of the knowledge baseenglishstreetventures/osn-trackerAll source code, database structures, documentation, and asset components within this repository are the exclusive proprietary property of English Street Ventures Pty Ltd (ACN: 693 716 700, ABN: 25 693 716 700), Sydney, Australia.
Unauthorised copying, distribution, or use of this material is strictly prohibited.
hooks/register.tsx 499 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register, Timer, ToolCallResult } from "claude-code";
3
4import type {
5 ChangesetVerdict,
6 CheckRow,
7 CheckVerdict,
8 Checks,
9 CiVerdict,
10 GateStatus,
11 PrView,
12 RepoGate,
13 ReviewVerdict,
14 Snapshot,
15} from "../types";
16import { CHECKS, runCheck } from "./checks";
17import { issueRefOf, lookupComplexity, measure } from "./measure";
18import type { ChangesetMemo, IssueRef } from "./measure";
19import { readPr } from "./pr";
20import { git, printable } from "./spawn";
21
22// What the pane draws from, held by the host for the session so a hot reload
23// keeps it. A tag changes whenever its value's fields change: a value stored
24// under another tag reads as absent.
25// oxlint-disable anti-slop/no-shape-in-symbol-names -- `shape` is the engine's own name for an atom's tag
26const repo = atom({ plugin: "branch-gates", key: "repo" } as const, null, {
27 shape: "repo-2",
28});
29const snapshot = atom({ plugin: "branch-gates", key: "snapshot" } as const, null, {
30 shape: "snapshot-3",
31});
32const checks = atom({ plugin: "branch-gates", key: "checks" } as const, null, {
33 shape: "checks-1",
34});
35const pr = atom({ plugin: "branch-gates", key: "pr" } as const, null, {
36 shape: "pr-1",
37});
38// oxlint-enable anti-slop/no-shape-in-symbol-names
39
40// The last refresh's error, null once a refresh succeeds.
41const fault = atom({ plugin: "branch-gates", key: "fault" } as const, null);
42
43const PANE = "branch-gates";
44const TITLE = "Branch gates";
45const DESCRIPTION =
46 "Run this checkout's own lint, format and lint-warning scripts (about 30 s) and refresh the branch-gates pane";
47
48// The one repository the mod works in, on github.com: https or ssh, with or
49// without `.git`.
50const OSN_REMOTE =
51 /^(https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)englishstreetventures\/osn(\.git)?\/?$/i;
52
53// Tools whose run can change the branch, the plan or the working tree.
54const MUTATING = new Set(["Bash", "Edit", "Write", "NotebookEdit"]);
55// The tools main's guard hook judges, whose refusal text the engine writes.
56const GUARDED = new Set(["Bash", "Write", "Edit", "Agent"]);
57// A Bash command that can change what `gh pr view` answers: a push, `git -C`
58// or `-c` options before it allowed, or any gh command.
59const PR_TRIGGER = /(^|[\s;&|(])(git(\s+-[Cc]\s+\S+)*\s+push|gh)(\s|$)/;
60
61const MEASURE_WINDOW_MS = 2_000;
62const PR_WINDOW_MS = 60_000;
63
64type Tone = "plain" | "dim" | "heading" | "success" | "error" | "warning" | "subtle";
65type Line = { text: string; tone: Tone };
66
67const SPACER: Line = { text: " ", tone: "plain" };
68
69const GATE_MARK = {
70 pass: { glyph: "✓", tone: "success" },
71 fail: { glyph: "✗", tone: "error" },
72 pending: { glyph: "○", tone: "warning" },
73 info: { glyph: "·", tone: "subtle" },
74} satisfies Record<GateStatus, { glyph: string; tone: Tone }>;
75
76const CHANGESET = {
77 present: { text: "changeset ✓ present", tone: "success" },
78 missing: { text: "changeset ✗ required, missing", tone: "error" },
79 "not-needed": { text: "changeset · not needed", tone: "subtle" },
80 unknown: { text: "changeset ? unknown", tone: "subtle" },
81} satisfies Record<ChangesetVerdict, Line>;
82
83const CI_TONE = {
84 passing: "success",
85 failing: "error",
86 pending: "warning",
87} satisfies Record<CiVerdict, Tone>;
88
89const REVIEW = {
90 approved: { text: "review: approved", tone: "success" },
91 "changes-requested": { text: "review: changes requested", tone: "warning" },
92 "review-required": { text: "review: review required", tone: "dim" },
93} satisfies Record<ReviewVerdict, Line>;
94
95const CHECK_GLYPH = {
96 pass: "✓",
97 fail: "✗",
98 "timed-out": "✗",
99 "could-not-start": "✗",
100 "not-run": "○",
101} satisfies Record<CheckVerdict, string>;
102
103// A time as hh:mm:ss on the machine's own clock.
104const clockText = (ms: number) => {
105 const at = new Date(ms);
106 return [at.getHours(), at.getMinutes(), at.getSeconds()]
107 .map((part) => String(part).padStart(2, "0"))
108 .join(":");
109};
110
111const shortHead = (head: string | null) => head?.slice(0, 7) ?? "unknown HEAD";
112
113const statusLine = (snap: Snapshot) =>
114 [snap.branch, snap.issue, snap.complexity && `complexity ${snap.complexity}`]
115 .filter(Boolean)
116 .join(" · ");
117
118// The reason the guard hook gave, when this call's result is its refusal: the
119// call's own tool named at the very start of an errored result's text.
120const guardReason = (tool: string, ran: ToolCallResult) => {
121 if (!GUARDED.has(tool) || ran.isError !== true || ran.text === undefined) return null;
122 const prefix = `PreToolUse:${tool} hook error: agent-guard: `;
123 if (!ran.text.startsWith(prefix)) return null;
124 return printable(ran.text.slice(prefix.length).split("\n")[0] ?? "").trim() || null;
125};
126
127const branchLines = (snap: Snapshot, failure: string | null): Line[] => {
128 const issue = snap.issue ? `Issue ${snap.issue}` : "No issue line";
129 const lines: Line[] = [
130 { text: snap.branch, tone: "heading" },
131 { text: snap.complexity ? `${issue} · complexity ${snap.complexity}` : issue, tone: "dim" },
132 {
133 text:
134 snap.ahead === null || snap.changedFiles === null
135 ? "base unknown"
136 : `${snap.ahead} commits ahead · ${snap.changedFiles} files vs main`,
137 tone: "dim",
138 },
139 ];
140 if (snap.dirtyFiles === null) lines.push({ text: "uncommitted files unknown", tone: "warning" });
141 else if (snap.dirtyFiles > 0)
142 lines.push({ text: `${snap.dirtyFiles} uncommitted`, tone: "warning" });
143 lines.push(CHANGESET[snap.changeset], {
144 text: `updated ${clockText(snap.refreshedAt)}`,
145 tone: "dim",
146 });
147 if (failure !== null) lines.push({ text: `refresh failed: ${failure}`, tone: "error" });
148 return lines;
149};
150
151const prLines = (view: PrView | null): Line[] => {
152 if (view === null) return [{ text: "Reading the pull request…", tone: "dim" }];
153 if (view.kind === "gh-missing") return [{ text: "gh not installed", tone: "subtle" }];
154 if (view.kind === "gh-timed-out") return [{ text: "gh timed out", tone: "warning" }];
155 if (view.kind === "gh-error") return [{ text: `gh: ${view.line}`, tone: "subtle" }];
156 if (view.state !== "open") return [{ text: `#${view.number} ${view.state}`, tone: "subtle" }];
157
158 const threads = view.threads === null ? "threads unknown" : `${view.threads} unresolved`;
159 const lines: Line[] = [
160 { text: `#${view.number} open · CI ${view.ci} · ${threads}`, tone: CI_TONE[view.ci] },
161 ];
162 if (view.review !== null) lines.push(REVIEW[view.review]);
163 return lines;
164};
165
166const checkLine = (row: CheckRow, isStale: boolean): Line => {
167 const glyph = CHECK_GLYPH[row.verdict];
168 if (isStale) return { text: `○ ${row.name} — stale`, tone: "warning" };
169 if (row.verdict === "pass") return { text: `${glyph} ${row.name}`, tone: "success" };
170 if (row.verdict === "not-run") {
171 return { text: `${glyph} ${row.name} — not run: ${row.detail ?? ""}`, tone: "warning" };
172 }
173 const why = row.verdict === "fail" ? (row.detail ?? "") : row.verdict.replaceAll("-", " ");
174 return { text: `${glyph} ${row.name} — ${why}`, tone: "error" };
175};
176
177const checkLines = (run: Checks | null, snap: Snapshot): Line[] => {
178 if (run === null) {
179 return [
180 {
181 text: "Not run. Type /gates to run this checkout's lint, format and warning checks (about 30 s).",
182 tone: "dim",
183 },
184 ];
185 }
186 const isStale =
187 !run.isRunning && (run.isStale || run.head !== snap.head || run.statusMark !== snap.statusMark);
188 const lines: Line[] = [
189 { text: `ran at ${shortHead(run.head)} · ${clockText(run.at)}`, tone: "dim" },
190 ...run.rows.map((row) => checkLine(row, isStale)),
191 ];
192 const nextCheck = CHECKS[run.rows.length];
193 if (run.isRunning && nextCheck) lines.push({ text: `running ${nextCheck.name}…`, tone: "dim" });
194 return lines;
195};
196
197const gateLines = (snap: Snapshot): Line[] => {
198 if (!snap.hasPlan) return [{ text: "No NEW-FEAT.md. Run /new-feat.", tone: "dim" }];
199 if (snap.gates.length === 0) {
200 return [{ text: "NEW-FEAT.md lists no gates under ## Gates.", tone: "dim" }];
201 }
202 return snap.gates.map((gate) => ({
203 text: `${GATE_MARK[gate.status].glyph} ${gate.label}`,
204 tone: GATE_MARK[gate.status].tone,
205 }));
206};
207
208const summaryOf = (run: Checks, root: string) => {
209 if (run.rows.every((row) => row.verdict === "not-run")) {
210 return `Ran no checks: ${root} has no node_modules. bun install --frozen-lockfile installs them.`;
211 }
212 const names = CHECKS.map((check) => check.name);
213 const verdicts = run.rows.map((row) => `${row.name} ${CHECK_GLYPH[row.verdict]}`);
214 return `Ran this checkout's own ${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""} scripts at ${shortHead(run.head)}: ${verdicts.join(", ")}.`;
215};
216
217// What one load of the module holds: a hot reload starts it over and keeps
218// what is in `$.state`.
219type Live = {
220 complexities: Map<string, string | null>;
221 asking: Set<string>;
222 changeset: ChangesetMemo;
223 deciding: Promise<RepoGate> | null;
224 measuring: Promise<Snapshot | null>;
225 measuredAt: number | null;
226 measureTimer: Timer | null;
227 prAt: number | null;
228 prTimer: Timer | null;
229 isTouchedDuringRun: boolean;
230};
231
232const live: Live = {
233 complexities: new Map(),
234 asking: new Set(),
235 changeset: { key: null, verdict: "unknown" },
236 deciding: null,
237 measuring: Promise.resolve(null),
238 measuredAt: null,
239 measureTimer: null,
240 prAt: null,
241 prTimer: null,
242 isTouchedDuringRun: false,
243};
244
245// The repository the session started in, and whether it is this one: its
246// root from `git rev-parse --show-toplevel` in `cwd`, then that root's origin.
247const decide = async ($: EngineInterface, cwd: string) => {
248 const root = await git($, cwd, "rev-parse", "--show-toplevel");
249 const url = root === null ? null : await git($, root, "config", "--get", "remote.origin.url");
250 const gate: RepoGate =
251 root !== null && url !== null && OSN_REMOTE.test(url)
252 ? { verdict: "on", root }
253 : { verdict: "off", root: null };
254 await update($, repo, () => gate);
255 return gate;
256};
257
258// The pinned root while the mod is on, null while it is off. Worked out once:
259// from the session's directory at `session.start`, or from the session's
260// project root when another call comes first, then kept in state.
261const rootOf = async ($: EngineInterface, cwd?: string) => {
262 const held = await read($, repo);
263 if (held !== null) return held.verdict === "on" ? held.root : null;
264 live.deciding ??= decide($, cwd ?? (await $.session.root()));
265 const gate = await live.deciding;
266 return gate.verdict === "on" ? gate.root : null;
267};
268
269// The issue's complexity label, asked of gh once per issue per load and
270// drawn into the snapshot already on screen.
271const fillComplexity = async (
272 $: EngineInterface,
273 root: string,
274 snap: Snapshot,
275 issue: IssueRef,
276) => {
277 live.asking.add(issue.ref);
278 const complexity = await lookupComplexity($, root, issue);
279 live.complexities.set(issue.ref, complexity);
280 live.asking.delete(issue.ref);
281 if (complexity === null) return;
282 await update($, snapshot, (held) =>
283 held !== null && held.issue === snap.issue && held.complexity === null
284 ? { ...held, complexity }
285 : held,
286 );
287 $.ui.status(statusLine({ ...snap, complexity }));
288};
289
290const measureOnce = async ($: EngineInterface, root: string) => {
291 try {
292 const snap = await measure($, root, live.complexities, live.changeset);
293 await update($, snapshot, () => snap);
294 if ((await read($, fault)) !== null) await update($, fault, () => null);
295 $.ui.status(statusLine(snap));
296 const issue = issueRefOf(snap.issue);
297 if (issue !== null && !live.complexities.has(issue.ref) && !live.asking.has(issue.ref)) {
298 void fillComplexity($, root, snap, issue);
299 }
300 return snap;
301 } catch (error) {
302 const message = error instanceof Error ? error.message : String(error);
303 await update($, fault, () => printable(message));
304 return null;
305 }
306};
307
308// One measure at a time, each after the one before it.
309const measureNow = ($: EngineInterface, root: string) => {
310 live.measuring = live.measuring.then(() => measureOnce($, root));
311 return live.measuring;
312};
313
314const forceMeasure = async ($: EngineInterface, root: string) => {
315 live.measureTimer?.cancel();
316 live.measureTimer = null;
317 live.measuredAt = await $.clock.now();
318 return measureNow($, root);
319};
320
321// At most one measure per window; a request inside it runs once at its end.
322const requestMeasure = async ($: EngineInterface, root: string) => {
323 const now = await $.clock.now();
324 const { measuredAt } = live;
325 if (measuredAt === null || now - measuredAt >= MEASURE_WINDOW_MS) {
326 live.measuredAt = now;
327 await measureNow($, root);
328 return;
329 }
330 if (live.measureTimer !== null) return;
331 live.measureTimer = $.clock.after(measuredAt + MEASURE_WINDOW_MS - now, () => {
332 live.measureTimer = null;
333 void requestMeasure($, root);
334 });
335};
336
337// At most one pull-request read a minute, start and `/gates` excepted; a
338// request inside the window runs once at its end.
339const requestPr = async ($: EngineInterface, root: string, isForced: boolean) => {
340 const now = await $.clock.now();
341 const { prAt } = live;
342 if (isForced || prAt === null || now - prAt >= PR_WINDOW_MS) {
343 live.prTimer?.cancel();
344 live.prTimer = null;
345 live.prAt = now;
346 const view = await readPr($, root);
347 await update($, pr, () => view);
348 return;
349 }
350 if (live.prTimer !== null) return;
351 live.prTimer = $.clock.after(prAt + PR_WINDOW_MS - now, () => {
352 live.prTimer = null;
353 void requestPr($, root, false);
354 });
355};
356
357const markStale = async ($: EngineInterface, held: Checks | null) => {
358 if (held === null || held.isStale) return;
359 if (held.isRunning) {
360 live.isTouchedDuringRun = true;
361 return;
362 }
363 await update($, checks, (prior) => (prior === null ? null : { ...prior, isStale: true }));
364};
365
366const runChecks = async ($: EngineInterface, root: string, snap: Snapshot | null) => {
367 const base = {
368 head: snap?.head ?? null,
369 statusMark: snap?.statusMark ?? null,
370 at: await $.clock.now(),
371 };
372 const rows: CheckRow[] = [];
373 live.isTouchedDuringRun = false;
374 await update($, checks, () => ({ ...base, isRunning: true, isStale: false, rows: [] }));
375
376 const hasModules = await $.fs.exists(`${root}/node_modules`);
377 for (const check of CHECKS) {
378 const row: CheckRow = hasModules
379 ? // oxlint-disable-next-line no-await-in-loop -- the checks run one after another, as CI's steps do
380 await runCheck($, check, root)
381 : { name: check.name, verdict: "not-run", detail: "no node_modules" };
382 rows.push(row);
383 const sofar = [...rows];
384 // oxlint-disable-next-line no-await-in-loop -- each row shows as soon as it settles
385 await update($, checks, () => ({ ...base, isRunning: true, isStale: false, rows: sofar }));
386 }
387
388 const done: Checks = { ...base, isRunning: false, isStale: live.isTouchedDuringRun, rows };
389 await update($, checks, () => done);
390 return summaryOf(done, root);
391};
392
393export const register: Register = (on) => {
394 on("session.start", async ($, e, next) => {
395 const root = await rootOf($, e.cwd);
396 if (root !== null) {
397 await $.command.register({ name: "gates", description: DESCRIPTION });
398 void $.ui.open({ id: PANE, title: TITLE });
399 void forceMeasure($, root);
400 void requestPr($, root, true);
401 }
402
403 return next(e);
404 });
405
406 // Outside this repository a project's own `/gates` answers instead.
407 on("command.run", { command: "gates" }, async ($, e, next) => {
408 const root = await rootOf($);
409 if (root === null) return next(e);
410 if (e.origin.kind !== "composer") {
411 const from = e.origin.kind === "plugin" ? `plugin ${e.origin.name}` : e.origin.kind;
412 return {
413 text: `/gates runs this checkout's own scripts, so it runs only when typed at the prompt. This run came from ${from}.`,
414 };
415 }
416
417 await $.ui.open({ id: PANE, title: TITLE });
418 const reading = requestPr($, root, true);
419 const summary = await runChecks($, root, await forceMeasure($, root));
420 await reading;
421
422 return { text: summary };
423 });
424
425 on(
426 "tool.call",
427 { tool: ["Bash", "Edit", "Write", "NotebookEdit", "Agent"] },
428 async ($, e, next) => {
429 const ran = await next(e);
430 const [root, held] = await Promise.all([rootOf($), read($, checks)]);
431 if (root === null) return ran;
432
433 const reason = guardReason(e.tool, ran);
434 if (reason !== null) {
435 $.ui.toast(`Guard: ${reason}`);
436 return ran;
437 }
438 if (!MUTATING.has(e.tool)) return ran;
439 await markStale($, held);
440 void requestMeasure($, root);
441 if (e.tool === "Bash" && PR_TRIGGER.test(e.command)) void requestPr($, root, false);
442
443 return ran;
444 },
445 // oxlint-disable-next-line promise/no-callback-in-promise -- `on(...).catch` is the engine's handler for a failed hook, not a Promise's: `next(e)` replays the call's own result
446 ).catch(($, e, next) => next(e));
447
448 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
449 const snap = await read($, snapshot);
450 if (e.props.hasSurvey || snap?.changeset !== "missing") return next(e);
451
452 const { Text } = $.ui.resolve(e);
453
454 return (
455 <Text color="warning">
456 Changeset required for this diff, none on the branch: bun run changeset
457 </Text>
458 );
459 });
460
461 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
462 const { Box, Text } = $.ui.resolve(e);
463 const [snap, failure, view, run] = await Promise.all([
464 read($, snapshot),
465 read($, fault),
466 read($, pr),
467 read($, checks),
468 ]);
469
470 const draw = (line: Line) => {
471 if (line.tone === "plain") return <Text>{line.text}</Text>;
472 if (line.tone === "dim") return <Text dimColor>{line.text}</Text>;
473 if (line.tone === "heading") return <Text weight="semibold">{line.text}</Text>;
474 return <Text color={line.tone}>{line.text}</Text>;
475 };
476
477 if (snap === null) {
478 const waiting: Line[] = [{ text: "Reading the branch…", tone: "dim" }];
479 if (failure !== null) waiting.push({ text: `refresh failed: ${failure}`, tone: "error" });
480 return <Box flexDirection="column">{waiting.map(draw)}</Box>;
481 }
482
483 const lines: Line[] = [
484 ...branchLines(snap, failure),
485 SPACER,
486 { text: "Pull request", tone: "heading" },
487 ...prLines(view),
488 SPACER,
489 { text: "Checks", tone: "heading" },
490 ...checkLines(run, snap),
491 SPACER,
492 { text: "Gates", tone: "heading" },
493 ...gateLines(snap),
494 ];
495
496 return <Box flexDirection="column">{lines.map(draw)}</Box>;
497 });
498};
499hooks/checks.ts 37 lines1import type { EngineInterface } from "claude-code";
2
3import type { CheckRow } from "../types";
4import { MAIN, firstLine, git, spawn } from "./spawn";
5
6// CI's lint job, as its steps run it, from the pinned root. Each runs the
7// checkout's own scripts, so only `/gates`, typed by the person, starts them.
8export const CHECKS = [
9 { name: "lint", timeoutMs: 120_000 },
10 { name: "fmt:check", timeoutMs: 120_000 },
11 { name: "lint-warning ceiling", timeoutMs: 300_000 },
12] as const;
13
14export type Check = (typeof CHECKS)[number];
15
16// The command a check runs; null for the ceiling when the branch has no
17// merge base with main to compare against.
18async function argvOf($: EngineInterface, check: Check, root: string) {
19 if (check.name === "lint") {
20 return ["bash", "-c", "bash scripts/verify-vendored-anti-slop.sh && bun run lint"];
21 }
22 if (check.name === "fmt:check") return ["bun", "run", "fmt:check"];
23 const base = await git($, root, "merge-base", MAIN, "HEAD");
24 return base === null ? null : ["bash", "scripts/guard-lint-warnings.sh", "--base", base];
25}
26
27export async function runCheck($: EngineInterface, check: Check, root: string): Promise<CheckRow> {
28 const { name } = check;
29 const argv = await argvOf($, check, root);
30 if (argv === null) return { name, verdict: "not-run", detail: `no merge base with ${MAIN}` };
31 const ran = await spawn($, argv, { cwd: root, timeoutMs: check.timeoutMs });
32 if (ran.kind !== "exited") return { name, verdict: ran.kind, detail: null };
33 if (ran.exitCode === 0) return { name, verdict: "pass", detail: null };
34 const detail = firstLine(ran.stdout) || firstLine(ran.stderr) || `exit ${ran.exitCode}`;
35 return { name, verdict: "fail", detail };
36}
37hooks/measure.ts 170 lines1import type { EngineInterface } from "claude-code";
2
3import type { ChangesetVerdict, Snapshot } from "../types";
4import { parsePlan } from "./plan";
5import type { Plan } from "./plan";
6import { MAIN, git, printable } from "./spawn";
7
8// NEW-FEAT.md is read only as a regular file of at most this many bytes.
9const PLAN_LIMIT = 256 * 1024;
10
11// The last changeset verdict and what it was worked out from: the changed
12// files and the script's text.
13export type ChangesetMemo = { key: string | null; verdict: ChangesetVerdict };
14
15export type IssueRef = { ref: string; repo: string; number: string };
16
17const linesOf = (text: string) => text.split("\n").filter(Boolean);
18
19// A short digest of `git status --porcelain`, enough to tell that the
20// working tree changed between two reads.
21const digest = (text: string) => {
22 let hash = 0x81_1c_9d_c5;
23 for (const char of text) {
24 hash = Math.imul(hash ^ (char.codePointAt(0) ?? 0), 0x01_00_01_93) >>> 0;
25 }
26 return hash.toString(16);
27};
28
29async function readPlan($: EngineInterface, root: string): Promise<Plan | null> {
30 const path = `${root}/NEW-FEAT.md`;
31 try {
32 const found = await $.fs.stat(path);
33 if (found.kind !== "file" || found.isLink || found.size > PLAN_LIMIT) return null;
34 return parsePlan(await $.fs.read(path));
35 } catch {
36 return null;
37 }
38}
39
40// The first issue the `Issue:` line names, when it is `#<n>` or
41// `<owner>/<repo>#<n>`.
42export const issueRefOf = (issue: string | null): IssueRef | null => {
43 const ref = issue?.split(",")[0]?.trim() ?? "";
44 const match = /^(?:([\w.-]+\/[\w.-]+))?#(\d+)$/.exec(ref);
45 if (!match?.[2]) return null;
46 return { ref, repo: match[1] ?? "englishstreetventures/osn", number: match[2] };
47};
48
49// The issue's `complexity:<digit>` label, with "(unconfirmed)" when that
50// label is on it too; null when gh cannot say.
51export async function lookupComplexity(
52 $: EngineInterface,
53 root: string,
54 issue: IssueRef,
55): Promise<string | null> {
56 try {
57 const { exitCode, stdout } = await $.process.run(
58 [
59 "gh",
60 "issue",
61 "view",
62 issue.number,
63 "--repo",
64 issue.repo,
65 "--json",
66 "labels",
67 "-q",
68 ".labels[].name",
69 ],
70 { cwd: root, timeoutMs: 10_000 },
71 );
72 const labels = exitCode === 0 ? linesOf(stdout) : [];
73 const digit = labels.map((name) => /^complexity:(\d)$/.exec(name)?.[1]).find(Boolean);
74 if (!digit) return null;
75 return labels.includes("complexity:unconfirmed") ? `${digit} (unconfirmed)` : digit;
76 } catch {
77 return null;
78 }
79}
80
81// The changeset script of the worktree the mods load from: the folder that
82// holds `.claude/mods/<mod>`. That is the copy the person chose to trust by
83// loading the mods, and no ref of the session's repository stands in for it.
84async function readScript($: EngineInterface) {
85 const at = $.plugin.root.lastIndexOf("/.claude/mods/");
86 if (at < 0) return null;
87 try {
88 return await $.fs.read(`${$.plugin.root.slice(0, at)}/scripts/changeset-required.sh`);
89 } catch {
90 return null;
91 }
92}
93
94async function changesetOf(
95 $: EngineInterface,
96 root: string,
97 diff: readonly string[] | null,
98 memo: ChangesetMemo,
99): Promise<ChangesetVerdict> {
100 if (diff === null) return "unknown";
101 if (diff.some((file) => /^\.changeset\/(?!README).*\.md$/.test(file))) return "present";
102 if (diff.length === 0) return "not-needed";
103
104 const script = await readScript($);
105 if (script === null) return "unknown";
106 const key = JSON.stringify([diff, script]);
107 if (memo.key === key) return memo.verdict;
108
109 let verdict: ChangesetVerdict = "unknown";
110 try {
111 const { exitCode, stdout } = await $.process.run(["bash", "-c", script], {
112 cwd: root,
113 stdin: `${diff.join("\n")}\n`,
114 timeoutMs: 10_000,
115 });
116 const word = stdout.trim();
117 if (exitCode === 0 && word === "required") verdict = "missing";
118 if (exitCode === 0 && word === "skip") verdict = "not-needed";
119 } catch {
120 verdict = "unknown";
121 }
122 memo.key = key;
123 memo.verdict = verdict;
124 return verdict;
125}
126
127// One automatic refresh of the pinned root: four git reads at once and
128// NEW-FEAT.md, never a script from the checkout. The complexity comes from
129// what is already known; the caller fills it in when gh answers.
130export async function measure(
131 $: EngineInterface,
132 root: string,
133 complexities: ReadonlyMap<string, string | null>,
134 memo: ChangesetMemo,
135): Promise<Snapshot> {
136 const [refs, ahead, diffText, status, plan] = await Promise.all([
137 git($, root, "rev-parse", "HEAD", "--abbrev-ref", "HEAD"),
138 git($, root, "rev-list", "--count", `${MAIN}..HEAD`),
139 git($, root, "diff", "--name-only", `${MAIN}...HEAD`),
140 git($, root, "--no-optional-locks", "status", "--porcelain"),
141 readPlan($, root),
142 ]);
143 const [head = null, name = null] = refs === null ? [] : linesOf(refs);
144 const branch =
145 name === null
146 ? "unknown branch"
147 : name === "HEAD"
148 ? `detached ${head?.slice(0, 7)}`
149 : printable(name);
150 const diff = diffText === null ? null : linesOf(diffText);
151 const issue = plan?.issue ?? null;
152 const ref = issueRefOf(issue);
153
154 return {
155 root,
156 branch,
157 head,
158 statusMark: status === null ? null : digest(status),
159 issue,
160 complexity: ref === null ? null : (complexities.get(ref.ref) ?? null),
161 hasPlan: plan !== null,
162 gates: plan?.gates ?? [],
163 ahead: ahead === null || diff === null ? null : Number(ahead),
164 changedFiles: ahead === null || diff === null ? null : diff.length,
165 dirtyFiles: status === null ? null : linesOf(status).length,
166 changeset: await changesetOf($, root, diff, memo),
167 refreshedAt: await $.clock.now(),
168 };
169}
170hooks/pr.ts 144 lines1import type { EngineInterface, JsonValue } from "claude-code";
2
3import type { CiVerdict, PrState, PrView, ReviewVerdict } from "../types";
4import { firstLine, spawn } from "./spawn";
5
6const GH_TIMEOUT_MS = 20_000;
7const THREAD_PAGE = 100;
8
9const THREADS_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
10 repository(owner: $owner, name: $name) {
11 pullRequest(number: $number) { reviewThreads(first: ${THREAD_PAGE}) { nodes { isResolved } } }
12 }
13}`;
14
15const STATES = new Map<string, PrState>([
16 ["OPEN", "open"],
17 ["MERGED", "merged"],
18 ["CLOSED", "closed"],
19]);
20
21const REVIEWS = new Map<string, ReviewVerdict>([
22 ["APPROVED", "approved"],
23 ["CHANGES_REQUESTED", "changes-requested"],
24 ["REVIEW_REQUIRED", "review-required"],
25]);
26
27// A CheckRun's conclusion or a StatusContext's state. Anything else, an empty
28// conclusion (QUEUED, IN_PROGRESS) and PENDING included, is pending.
29const PASSING = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
30const FAILING = new Set([
31 "FAILURE",
32 "ERROR",
33 "TIMED_OUT",
34 "CANCELLED",
35 "ACTION_REQUIRED",
36 "STARTUP_FAILURE",
37]);
38
39type Fields = ReadonlyMap<string, JsonValue>;
40
41const parseJson = (text: string): JsonValue | undefined => {
42 try {
43 const parsed: JsonValue = JSON.parse(text);
44 return parsed;
45 } catch {
46 return undefined;
47 }
48};
49
50const fieldsOf = (value: JsonValue | undefined): Fields =>
51 value !== undefined && value !== null && typeof value === "object" && !Array.isArray(value)
52 ? new Map(Object.entries(value))
53 : new Map();
54
55const textOf = (value: JsonValue | undefined) => (typeof value === "string" ? value : "");
56
57const itemsOf = (value: JsonValue | undefined): readonly JsonValue[] =>
58 Array.isArray(value) ? value : [];
59
60const fieldPath = (value: JsonValue | undefined, ...names: string[]) =>
61 names.reduce<JsonValue | undefined>((at, name) => fieldsOf(at).get(name), value);
62
63const checkVerdict = (item: Fields): CiVerdict => {
64 const word =
65 textOf(item.get("__typename")) === "StatusContext"
66 ? textOf(item.get("state"))
67 : textOf(item.get("conclusion"));
68 if (PASSING.has(word)) return "passing";
69 if (FAILING.has(word)) return "failing";
70 return "pending";
71};
72
73const rollupVerdict = (items: readonly JsonValue[]): CiVerdict => {
74 const verdicts = items.map((item) => checkVerdict(fieldsOf(item)));
75 if (verdicts.includes("failing")) return "failing";
76 if (verdicts.length === 0 || verdicts.includes("pending")) return "pending";
77 return "passing";
78};
79
80// A github.com pull-request URL, and nothing else.
81const PULL_URL = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+$/;
82
83// Unresolved review threads among the first page, `n+` when the page is full.
84// Owner and name go as raw strings (`-f`), the number alone typed (`-F`).
85async function readThreads($: EngineInterface, root: string, url: string, number: number) {
86 const repo = PULL_URL.exec(url);
87 if (!repo) return null;
88 const ran = await spawn(
89 $,
90 [
91 "gh",
92 "api",
93 "graphql",
94 "-f",
95 `query=${THREADS_QUERY}`,
96 "-f",
97 `owner=${repo[1] ?? ""}`,
98 "-f",
99 `name=${repo[2] ?? ""}`,
100 "-F",
101 `number=${number}`,
102 ],
103 { cwd: root, timeoutMs: GH_TIMEOUT_MS },
104 );
105 if (ran.kind !== "exited" || ran.exitCode !== 0) return null;
106 const path = ["data", "repository", "pullRequest", "reviewThreads", "nodes"];
107 const nodes = itemsOf(fieldPath(parseJson(ran.stdout), ...path));
108 const unresolved = nodes.filter((node) => fieldsOf(node).get("isResolved") === false).length;
109 return nodes.length >= THREAD_PAGE ? `${unresolved}+` : String(unresolved);
110}
111
112// The branch's pull request as `gh pr view` sees it from the pinned root, or
113// why gh could not say.
114export async function readPr($: EngineInterface, root: string): Promise<PrView> {
115 const view = await spawn(
116 $,
117 ["gh", "pr", "view", "--json", "number,state,reviewDecision,statusCheckRollup,url"],
118 { cwd: root, timeoutMs: GH_TIMEOUT_MS },
119 );
120 if (view.kind === "timed-out") return { kind: "gh-timed-out" };
121 if (view.kind === "could-not-start") return { kind: "gh-missing" };
122 if (view.exitCode !== 0) {
123 return { kind: "gh-error", line: firstLine(view.stderr) || `exit ${view.exitCode}` };
124 }
125
126 const fields = fieldsOf(parseJson(view.stdout));
127 const state = STATES.get(textOf(fields.get("state")));
128 const number = fields.get("number");
129 if (state === undefined || typeof number !== "number") {
130 return { kind: "gh-error", line: "unreadable output from gh pr view" };
131 }
132 const url = textOf(fields.get("url"));
133
134 return {
135 kind: "found",
136 number,
137 state,
138 ci: rollupVerdict(itemsOf(fields.get("statusCheckRollup"))),
139 review: REVIEWS.get(textOf(fields.get("reviewDecision"))) ?? null,
140 threads: state === "open" ? await readThreads($, root, url, number) : null,
141 url,
142 };
143}
144hooks/spawn.ts 63 lines1import type { EngineInterface } from "claude-code";
2
3// Spelled in full: a tag or a local branch named `origin/main` would
4// otherwise take precedence.
5export const MAIN = "refs/remotes/origin/main";
6
7// How a child run ended: exited with a code, or rejected, which is a timeout
8// once at least its timeout has passed and a failed start before that.
9export type Spawned =
10 | { kind: "exited"; exitCode: number; stdout: string; stderr: string }
11 | { kind: "timed-out" }
12 | { kind: "could-not-start" };
13
14export type SpawnInit = { timeoutMs: number; cwd: string; stdin?: string };
15
16export async function spawn(
17 $: EngineInterface,
18 argv: readonly string[],
19 init: SpawnInit,
20): Promise<Spawned> {
21 const startedAt = await $.clock.now();
22 try {
23 const { exitCode, stdout, stderr } = await $.process.run(argv, init);
24 return { kind: "exited", exitCode, stdout, stderr };
25 } catch {
26 const elapsed = (await $.clock.now()) - startedAt;
27 return elapsed >= init.timeoutMs ? { kind: "timed-out" } : { kind: "could-not-start" };
28 }
29}
30
31// The text with every C0 and C1 control character removed, so nothing read
32// from a checkout or a remote reaches a surface as a terminal sequence.
33export const printable = (text: string) =>
34 [...text]
35 .filter((char) => {
36 const code = char.codePointAt(0) ?? 0;
37 return code >= 0x20 && (code < 0x7f || code > 0x9f);
38 })
39 .join("");
40
41// The first line of a child's output that holds anything, printable and cut
42// to 200 characters; empty when there is none.
43export const firstLine = (text: string) =>
44 printable(
45 text
46 .split("\n")
47 .map((line) => line.trim())
48 .find((line) => line.length > 0) ?? "",
49 )
50 .trim()
51 .slice(0, 200);
52
53// What git prints in `cwd`, trimmed, or null when it exits non-zero or
54// cannot run.
55export async function git($: EngineInterface, cwd: string, ...args: string[]) {
56 try {
57 const { exitCode, stdout } = await $.process.run(["git", ...args], { cwd, timeoutMs: 10_000 });
58 return exitCode === 0 ? stdout.trim() : null;
59 } catch {
60 return null;
61 }
62}
63hooks/plan.ts 57 lines1import type { Gate, GateStatus } from "../types";
2import { printable } from "./spawn";
3
4export type Plan = { issue: string | null; gates: Gate[] };
5
6const MAX_GATES = 30;
7const SEPARATOR = /^\|?\s*:?-{3,}/;
8const BULLET = /^([-*+]|\d+\.)\s+/;
9const SECTION_END = /^#{1,2}\s/;
10
11// A zero count of failures or errors is a clean result, not a failure.
12const NO_FAILURES = /\b0\s+(fail(ed|s|ures?)?|errors?)\b/gi;
13const PENDING = /NOT RUN|pending|todo|to do|not yet|\[ \]/i;
14const FAILING = /\b(fail(ed|s|ing|ures?)?|red|broken)\b|✗|❌/i;
15const PASSING = /\b(pass(ed|es|ing)?|green|clean|ok|done|ran)\b|✓|✅|\[x\]/i;
16
17const classify = (text: string): GateStatus => {
18 const read = text.replaceAll(NO_FAILURES, "");
19 if (PENDING.test(read)) return "pending";
20 if (FAILING.test(read)) return "fail";
21 if (PASSING.test(read)) return "pass";
22 return "info";
23};
24
25const tidy = (text: string) =>
26 printable(text.replaceAll(/\*\*|`|\[[ x]\]/gi, "").replaceAll(/\s+/g, " ")).trim();
27
28// The `Issue:` line anywhere in NEW-FEAT.md, and every row of its `## Gates`
29// section up to the next heading of level 1 or 2: each data row of a table
30// (its header and separator skipped) and each bullet, at most 30.
31export const parsePlan = (text: string): Plan => {
32 const lines = text.split("\n").map((line) => line.trim());
33 const issueLine = lines.find((line) => /^Issues?:/i.test(line));
34 const issue = issueLine ? printable(issueLine.replace(/^Issues?:/i, "")).trim() || null : null;
35
36 const start = lines.findIndex((line) => /^##\s+Gates\s*$/i.test(line));
37 const gates: Gate[] = [];
38 if (start === -1) return { issue, gates };
39
40 for (const [index, line] of lines.entries()) {
41 if (index <= start) continue;
42 if (SECTION_END.test(line) || gates.length >= MAX_GATES) break;
43 if (SEPARATOR.test(line)) continue;
44
45 if (line.startsWith("|")) {
46 const isHeader = SEPARATOR.test(lines[index + 1] ?? "");
47 const cells = line.split("|").map(tidy).filter(Boolean);
48 if (isHeader || cells.length === 0) continue;
49 gates.push({ label: cells.join(" — "), status: classify(cells.slice(1).join(" ")) });
50 } else if (BULLET.test(line)) {
51 gates.push({ label: tidy(line.replace(BULLET, "")), status: classify(line) });
52 }
53 }
54
55 return { issue, gates };
56};
57types/index.d.ts 82 lines1// Whether the session's repository is englishstreetventures/osn on
2// github.com, and its root: both worked out once, from the session's directory
3// at `session.start`, and kept for the session. Every spawn runs in that root.
4export type RepoGate = { verdict: "on" | "off"; root: string | null };
5
6export type GateStatus = "pass" | "fail" | "pending" | "info";
7
8// One row of the `## Gates` section of NEW-FEAT.md.
9export type Gate = { label: string; status: GateStatus };
10
11export type ChangesetVerdict = "present" | "missing" | "not-needed" | "unknown";
12
13// What one automatic refresh read from git and NEW-FEAT.md. A count or a ref
14// git could not give is null, never zero.
15export type Snapshot = {
16 root: string;
17 branch: string;
18 head: string | null;
19 // A digest of `git status --porcelain`, null when git status failed.
20 statusMark: string | null;
21 issue: string | null;
22 complexity: string | null;
23 hasPlan: boolean;
24 gates: Gate[];
25 ahead: number | null;
26 changedFiles: number | null;
27 dirtyFiles: number | null;
28 changeset: ChangesetVerdict;
29 refreshedAt: number;
30};
31
32export type CheckVerdict = "pass" | "fail" | "not-run" | "timed-out" | "could-not-start";
33
34export type CheckRow = { name: string; verdict: CheckVerdict; detail: string | null };
35
36// The last `/gates` run: the HEAD and working tree it ran on, when, and each
37// check's verdict. Stale once a later tool call or measure says the tree moved.
38export type Checks = {
39 head: string | null;
40 statusMark: string | null;
41 at: number;
42 isRunning: boolean;
43 isStale: boolean;
44 rows: CheckRow[];
45};
46
47export type CiVerdict = "passing" | "failing" | "pending";
48
49export type ReviewVerdict = "approved" | "changes-requested" | "review-required";
50
51export type PrState = "open" | "merged" | "closed";
52
53// The branch's pull request as `gh` last answered, or why it could not.
54export type PrView =
55 | {
56 kind: "found";
57 number: number;
58 state: PrState;
59 ci: CiVerdict;
60 review: ReviewVerdict | null;
61 // Unresolved review threads, `100+` past one page; null when unread.
62 threads: string | null;
63 url: string;
64 }
65 | { kind: "gh-error"; line: string }
66 | { kind: "gh-missing" }
67 | { kind: "gh-timed-out" };
68
69declare module "claude-code" {
70 interface PluginState {
71 "branch-gates": {
72 // oxlint-disable anti-slop/no-shape-in-symbol-names -- `Shaped` is the engine's own name for a tagged state value
73 repo: Shaped<RepoGate | null>;
74 snapshot: Shaped<Snapshot | null>;
75 checks: Shaped<Checks | null>;
76 pr: Shaped<PrView | null>;
77 // oxlint-enable anti-slop/no-shape-in-symbol-names
78 fault: string | null;
79 };
80 }
81}
82