Bounded's prereqs status band: bounded/prereqs's status above the prompt, from the status file bounded's Claude Code hook writes after each finished tool call…

A set of guardrails for coding agents, built on a small, pure core: the mechanism by which packs (selectable bundles of behaviour) extend one another. docs/spec.md is the requirement.
The original Bounded harness, which this code supersedes, is kept for reference in the private repository bounded-dev/bounded-legacy, with its own history: its decisions (ADRs LEG-2026-NNN) and dogfood records. This code was developed as bounded-core and merged into this repository (ADR 2026-014); the original harness later moved out to its own repository (ADR 2026-023).
What is missing or known to be weak is in docs/flight-state.md. Built and running on two real hosts:
bounded/protected-paths): deny-only rules on reads, listings and writes, file rules, honest redirects, and protection of the project's own configuration (ADR 2026-009).bounded/prereqs, in 3.2.0): an action waits until a delegation to a named agent has succeeded over files unchanged since; only a run the host says finished counts, as the agent the host resolved, and from 3.3.0 a background run counts at its finish, on Claude Code and on pi (ADR 2026-019, ADR 2026-025, ADR 2026-028, its README).bounded.catalog.json, built by bounded catalog build from its pack objects, their contracts and TSDoc, their READMEs and their tested examples, failing closed; bounded's tarball carries the catalogue of its two packs (ADR 2026-029, writing a pack)..bounded/log.jsonl (docs/bounded-log.md).bounded.config.ts selects packs; openProject(root, { ports }) gives the judge hosts ask, and refuses everything when the configuration is broken (docs/configuration.md).bounded/shell-command-reader (ADR 2026-020).src/hosts/.packIdsFor("bounded")("core") is "bounded/core"), the packs it depends on (the pack objects themselves), the extension points it declares and the contributions it makes.dependsOn. Anything else does not compile, and is refused at composition if it is built from untyped data (ADRs 2026-003 and 2026-004).bounded/core declares a guard point per event and effect kind; dispatchEvent fans a call out into its effects and asks each kind's guards, and the first refusal wins, naming the pack and the effect (ADRs 2026-005 to 2026-007).bounded.config.ts with defineConfig; host adapters call openProject(root, { ports: [...protectedPathsPortProvisions(), ...prereqsPortProvisions()] }) and ask its judge about every event. A broken configuration refuses everything (docs/configuration.md, ADR 2026-010).bounded-shell-command-reader, published as bounded/shell-command-reader, so the core names no shell (ADR 2026-020).beforeTool and afterTool lifecycle points; the host supplies the protected-paths pack's port provisions (protectedPathsPortProvisions, from bounded/protected-paths/adapters) to openProject (docs/drift.md, ADR 2026-011, ADR 2026-013, ADR 2026-017).bounded/protected-paths) is an ordinary pack. Packs and projects contribute deny-only rules to its protectedPaths point (a glob, its own exceptions, what it denies, a redirect); its guards refuse reads, listings and writes a rule denies, naming the rule and the pack that contributed it. A denial always wins. The pack ships no rules of its own: the configuration bounded init writes contributes seven defaults that protect the project's Bounded configuration, .bounded/, the files that run Bounded and git's hooks and config (ADR 2026-009).The code lives in src/ (the bounded package, ADR 2026-024), in the layered layout described in AGENTS.md. Decisions are in docs/adr/.
One package, bounded, carries everything: the library, the protected-paths pack, the bounded command and the hooks for Claude Code and pi. It runs on Node 22.18 or later (which loads bounded.config.ts by stripping its types), with npm, pnpm, yarn or bun. Bun is needed only to build and publish Bounded itself.
In your project (it needs a package.json):
npx bounded init
bounded init adds bounded as a devDependency, with the project's package manager. That is the lockfile's, else package.json's packageManager, else the one running npx. It then hands over to the bounded it installed, which does two things.
bounded.config.ts, selecting the protected-paths pack (which brings in the core). The protected-paths pack ships no rules of its own (ADR 2026-009). The configuration init writes contributes seven default rules. Agents may not change:bounded.config.*;.bounded/;.claude/settings*.json): they hold the hook, and settings.local.json could turn every hook off. The rest of .claude/, such as agents and skills, stays open to agents. Your user-level ~/.claude/settings.json is outside the project and not covered;.pi/extensions/bounded/**);node_modules/bounded/**);.git/hooks/**), which git runs later, outside Bounded's view, and git's config (.git/config), which can point core.hooksPath at other hooks.Keep, change or remove them.
Bounded discourages agents and keeps a record; it is not a security boundary. An agent running as your user can always get round it. For enforcement, pair it with your host's operating-system sandbox, such as Claude Code's sandbox mode. Known ways round it:
node_modules or .git, so what a command changes there is not put back;sed -i, perl -i, node -e and python -c;git config … and git -c core.hooksPath=…, which the protected-paths pack sees as reads, so they get past the rules on git's hooks and config;~/.claude/settings.json;.claude/, .pi/), or those named with --host claude-code or --host pi..claude/settings.json, running node "$CLAUDE_PROJECT_DIR/node_modules/bounded/dist/hosts/claude-code/hook.js", so the file can be committed and works in every checkout..pi/extensions/.Restart (or start) the hosts' sessions afterwards, as init says, so they load the hooks.
The first thing to do after init is to add your own rules beside the defaults, in the contribution(protectedPathsPack.points.protectedPaths, [...]) that init wrote. For example, to keep agents out of secrets/:
// bounded.config.ts, as init wrote it, with one rule added
import { contribution, defineConfig } from "bounded/domain";
import { protectedPathsPack } from "bounded/protected-paths";
export default defineConfig({
packs: [protectedPathsPack],
contributes: [
contribution(protectedPathsPack.points.protectedPaths, [
// ...init's seven default rules...
{ match: "secrets/**", deny: ["read", "create", "modify", "delete"], why: "secrets are kept by people", redirect: "Ask a maintainer for the value you need" },
]),
],
});
Configuring a project describes the rule fields and other packs.
To upgrade, run npx bounded update. It upgrades bounded to its latest version and checks the version it installed. It then hands over to the newly installed version, which refreshes the hooks. If the upgrade fails, package.json and the lockfile are restored. npx bounded update --no-upgrade refreshes the hooks from the installed version only. Neither touches bounded.config.ts.
Each says, per host, whether its sessions must restart:
bounded is live at once. Its sessions restart only when .claude/settings.json changed: Restart Claude Code sessions in this project so they load the new hooks. Otherwise: No need to restart Claude Code sessions: bounded <version> is live on their next tool call.bounded into its process at session start, and the CLI cannot know which version a running session loaded, so after every update: Restart pi sessions in this project to load bounded <version>.bounded in its process.To try a build that is not published, pack it and pass the tarball's directory with --from. npx is given the tarball too, while npm's bounded is still the legacy 2.x (ADR 2026-014):
(cd src && bun pm pack --destination /tmp/bounded) # its prepack builds dist/
npx -p /tmp/bounded/bounded-<version>.tgz bounded init --from /tmp/bounded
npx bounded update --from /tmp/bounded # later, with a newer tarball there
With --from, package.json overrides bounded with the tarball, each package manager in its own way: $bounded for npm and pnpm, the tarball for bun and yarn. bun and npm are tested end to end; pnpm and yarn are not yet. Releasing describes how a release is built and published.
bun install
bun run check # typecheck, lint, tests (incl. architecture and compile-time tests)
Changes follow the development lifecycle.
hooks/register.ts 135 lines1// The prereqs status band (ADR 2026-027): a Claude Code plugin that draws
2// bounded/prereqs's status above the prompt, from the status file bounded's
3// own hook writes after each finished tool call and agent run
4// (.bounded/prereqs/status.json, ADR 2026-026). It decides nothing and holds
5// no phase: it keeps one value, when it last saw a finish, so it never shows
6// a reading older than that as current. Every hook has a .catch: a band that
7// fails never changes or blocks an event, and draws "unavailable", never a
8// stale status. Every use of $ is in this file (the engine allows $ to be
9// passed only to functions declared here); the modules it imports are pure.
10import type { AbovePromptFields, BandEngine, On } from "./engine-subset";
11import { drawFailedLines, missingFileLines, otherProjectLines, READING_WAIT_MS, statusLines, unreadableLines } from "./prereqs-status-band-lines";
12import { readStatusFile, STATUS_FILE } from "./prereqs-status-file";
13import { PROJECT_CONFIG_NAMES } from "./project-config-names";
14
15/** The band's one value: when it last saw a finished tool call or agent run, from $.clock.now(). */
16const LAST_FINISH_SEEN_AT = { plugin: "bounded-prereqs-status-band", key: "lastFinishSeenAt" } as const;
17/** How often the band draws itself again, so "updating" turns into "unavailable" and a late file is found. */
18const REDRAW_EVERY_MS = 2_000;
19/** How many times a mark is tried when another finish's mark lands in between. */
20const MARK_ATTEMPTS = 5;
21
22/** Whether this environment's redraw timer runs: it dies with the environment (a reload), and so does this. */
23let redrawing = false;
24
25/** Starts the 2-second redraw, once per environment. */
26function startRedraw<Element>($: BandEngine<Element>): void {
27 if (redrawing) return;
28 redrawing = true;
29 $.clock.every(REDRAW_EVERY_MS, () => $.ui.invalidate("ui.render"));
30}
31
32/**
33 * Marks a finish: the mark only moves forward. The time is read once; the
34 * mark is written only while it stands at the version read, and read again
35 * when another write landed first, so two finishes whose writes land out of
36 * order leave the later time.
37 */
38async function markFinish<Element>($: BandEngine<Element>): Promise<void> {
39 const now = await $.clock.now();
40 for (let attempt = 0; attempt < MARK_ATTEMPTS; attempt += 1) {
41 const held = await $.state.get(LAST_FINISH_SEEN_AT);
42 if (typeof held.value === "number" && held.value >= now) return;
43 const written = await $.state.set(LAST_FINISH_SEEN_AT, now, { ifVersion: held.version });
44 if (written.isSet) break;
45 }
46 // Drawn again the moment the wait for its reading ends, so "updating" turns into "unavailable" then, not at the next 2-second redraw.
47 $.clock.after(READING_WAIT_MS + 1, () => $.ui.invalidate("ui.render"));
48}
49
50/** Where `path` lands, every link resolved; undefined when it leads nowhere. */
51async function realPathOf<Element>($: BandEngine<Element>, path: string): Promise<string | undefined> {
52 try {
53 return (await $.fs.stat(path, { resolve: true })).realPath;
54 } catch {
55 return undefined;
56 }
57}
58
59/** Whether the session's root holds a bounded configuration. */
60async function hasConfig<Element>($: BandEngine<Element>, root: string): Promise<boolean> {
61 for (const name of PROJECT_CONFIG_NAMES) if (await $.fs.exists(`${root}/${name}`)) return true;
62 return false;
63}
64
65/** The band's lines for the project at `root`: the status file read, refused, or not there. */
66async function linesFor<Element>($: BandEngine<Element>, root: string): Promise<string[]> {
67 const path = `${root}/${STATUS_FILE}`;
68 if (!(await $.fs.exists(path))) return missingFileLines();
69 let text: string;
70 try {
71 text = await $.fs.read(path);
72 } catch (thrown) {
73 return unreadableLines(thrown instanceof Error ? thrown.message : String(thrown));
74 }
75 const reading = readStatusFile(text);
76 if (reading.ok) {
77 const sessionRoot = await realPathOf($, root);
78 const projectRoot = await realPathOf($, reading.value.projectRoot);
79 if (sessionRoot === undefined || projectRoot === undefined || sessionRoot !== projectRoot) return otherProjectLines(reading.value.projectRoot, root);
80 }
81 const held = await $.state.get(LAST_FINISH_SEEN_AT);
82 return statusLines(reading, { lastFinishSeenAt: held.value, now: await $.clock.now() });
83}
84
85/** The band's tree: one truncated line of text per line. */
86function draw<Element>($: BandEngine<Element>, e: AbovePromptFields, lines: readonly string[]): Element {
87 const { Box, Text } = $.ui.resolve(e);
88 return Box({ flexDirection: "column", children: lines.map((line) => Text({ wrap: "truncate-end", children: line })) });
89}
90
91export function register(on: On): void {
92 on("session.start", async ($, e, next) => {
93 startRedraw($);
94 return next(e);
95 }).catch((_$, e, next) => next(e));
96
97 on("classic.PostToolUse", async ($, e, next) => {
98 startRedraw($);
99 await markFinish($);
100 const answer = await next(e);
101 $.ui.invalidate("ui.render");
102 return answer;
103 }).catch((_$, e, next) => next(e));
104
105 on("classic.PostToolUseFailure", async ($, e, next) => {
106 startRedraw($);
107 await markFinish($);
108 const answer = await next(e);
109 $.ui.invalidate("ui.render");
110 return answer;
111 }).catch((_$, e, next) => next(e));
112
113 // A finish with an empty agent_type names no agent a rule can require, and bounded's hook records nothing for it.
114 on("classic.SubagentStop", async ($, e, next) => {
115 startRedraw($);
116 if (e.agent_type !== "") await markFinish($);
117 const answer = await next(e);
118 $.ui.invalidate("ui.render");
119 return answer;
120 }).catch((_$, e, next) => next(e));
121
122 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
123 if (e.props.hasSurvey) return next(e);
124 const root = await $.session.root();
125 if (!(await hasConfig($, root))) return next(e);
126 return draw($, e, await linesFor($, root));
127 }).catch(($, e, next) => {
128 try {
129 return draw($, e, drawFailedLines(next.error.message ?? next.error.kind));
130 } catch {
131 return undefined;
132 }
133 });
134}
135hooks/engine-subset.ts 99 lines1// The part of Claude Code's plugin API the band uses, typed by hand (ADR
2// 2026-027), so the workspace's own tsc checks the band without the engine's
3// declarations, which Claude Code lays only beside a plugin it loads.
4// tests/register.typecheck.ts assigns the band's register to the engine's
5// Register, so tsc against those declarations proves this subset is the
6// engine's (bun run check:prereqs-status-band). Every member is a
7// function-typed property, so it is checked strictly. Hooks are generic in
8// the event, the chain's answer and the element type, so each takes the
9// engine's own types when the engine calls it. Types only.
10
11/** The surfaces that draw a component. */
12export type Surface = "terminal" | "desktop" | "mobile" | "vscode";
13
14/** The band's one value in $.state: when it last saw a finished tool call or agent run (ADR 2026-027). */
15export type LastFinishSeenAtRef = { readonly plugin: "bounded-prereqs-status-band"; readonly key: "lastFinishSeenAt" };
16
17/** The two elements the band draws with, from a surface's table. */
18export interface BandElements<Element> {
19 readonly Box: (props: { flexDirection: "column"; children: Element[] }) => Element;
20 readonly Text: (props: { wrap: "truncate-end"; children: string }) => Element;
21}
22
23/** The calls on $ the band makes. */
24export interface BandEngine<Element> {
25 readonly session: {
26 /** The session's project root, absolute. */
27 readonly root: () => Promise<string>;
28 };
29 readonly fs: {
30 readonly exists: (path: string) => Promise<boolean>;
31 /** Rejects when the file is missing or cannot be read. */
32 readonly read: (path: string) => Promise<string>;
33 /** With `resolve`, `realPath` too: where the path lands. Rejects when missing. */
34 readonly stat: (path: string, options: { resolve: boolean }) => Promise<{ readonly realPath?: string | undefined }>;
35 };
36 readonly state: {
37 readonly get: (ref: LastFinishSeenAtRef) => Promise<{ readonly value: number | null | undefined; readonly version: number }>;
38 readonly set: (ref: LastFinishSeenAtRef, value: number | null, options: { ifVersion: number }) => Promise<{ readonly isSet: boolean; readonly version: number }>;
39 };
40 readonly clock: {
41 /** Milliseconds since the epoch. */
42 readonly now: () => Promise<number>;
43 /** Calls `fn` once after `ms` milliseconds, unless the module reloads first. */
44 readonly after: (ms: number, fn: () => void) => unknown;
45 /** Calls `fn` every `ms` milliseconds until the module reloads. */
46 readonly every: (ms: number, fn: () => void) => unknown;
47 };
48 readonly ui: {
49 readonly invalidate: (event: "ui.render") => void;
50 readonly resolve: (e: { readonly surface: Surface; readonly component: "AbovePrompt" }) => BandElements<Element>;
51 };
52}
53
54/** `next` in a hook: the rest of the chain, resolving to its answer. */
55export type Next<E, Answer> = (e: E) => Promise<Answer>;
56
57/** A hook on a classic hook event or session.start, which passes the chain's answer through. */
58export type PassingHook<Fields> = <E extends Fields, Answer, Element>($: BandEngine<Element>, e: E, next: Next<E, Answer>) => Promise<Answer>;
59
60/** What `next` carries into a `.catch` handler: why the hook failed. */
61export interface Caught {
62 readonly error: { readonly kind: string; readonly message?: string | undefined };
63}
64
65/** The handler a registration's `.catch` sets: here, the chain's answer, run once. */
66export type PassingCatch<Fields> = <E extends Fields, Answer, Element>($: BandEngine<Element>, e: E, next: Next<E, Answer> & Caught) => Promise<Answer>;
67
68/** The band above the prompt, as its ui.render hook receives it. */
69export interface AbovePromptFields {
70 readonly surface: Surface;
71 readonly component: "AbovePrompt";
72 readonly props: { readonly hasSurvey: boolean };
73}
74
75/** The ui.render hook on AbovePrompt: a tree, or the engine's own (`next`). */
76export type RenderHook = <E extends AbovePromptFields, Answer, Element>($: BandEngine<Element>, e: E, next: Next<E, Answer>) => Promise<Element | Answer>;
77
78/** The ui.render hook's `.catch`: a tree saying the band could not draw, or undefined for the engine's own. */
79export type RenderCatch = <E extends AbovePromptFields, Answer, Element>($: BandEngine<Element>, e: E, next: Next<E, Answer> & Caught) => Element | undefined;
80
81/** What `on` answers: the registration, whose `.catch` sets the handler run when the hook throws. */
82export interface Registration<Handler> {
83 readonly catch: (handler: Handler) => void;
84}
85
86/** A SubagentStop: the agent type it carries may be empty. */
87export interface SubagentStopFields {
88 readonly agent_type: string;
89}
90
91/** `on`, for the events the band hooks. */
92export interface On {
93 (event: "session.start", hook: PassingHook<object>): Registration<PassingCatch<object>>;
94 (event: "classic.PostToolUse", hook: PassingHook<object>): Registration<PassingCatch<object>>;
95 (event: "classic.PostToolUseFailure", hook: PassingHook<object>): Registration<PassingCatch<object>>;
96 (event: "classic.SubagentStop", hook: PassingHook<SubagentStopFields>): Registration<PassingCatch<SubagentStopFields>>;
97 (event: "ui.render", matcher: { readonly component: "AbovePrompt" }, hook: RenderHook): Registration<RenderCatch>;
98}
99hooks/prereqs-status-band-lines.ts 92 lines1// What the prereqs status band draws (ADR 2026-027), pure: the freshness rule
2// and the lines, from a status file the band has read and the one value it
3// keeps, when it last saw a finished tool call or agent run. It never shows a
4// stale status as current: only a fresh read snapshot draws a status mark
5// (✓ holds, ◷ pending, ✗ stale, · missing or matching no file), and every
6// other line says why there is none.
7import { STATUS_FILE, type StatusFile, type StatusFileReading, type StatusFileRequirement, type StatusFileRule } from "./prereqs-status-file";
8
9/** How long after a finish the band waits for bounded's reading of it before saying the status is unavailable: above bounded's 20-second hook deadline. */
10export const READING_WAIT_MS = 30_000;
11
12/** Whether a reading is the one after the last finish the band saw. */
13export type Freshness = "no-reading-yet" | "fresh" | "updating" | "unavailable";
14
15/** The band's one value, and the time now, both from $.clock in milliseconds. */
16export interface Timing {
17 /** When the band last saw a finished tool call or agent run; undefined or null when it has seen none. */
18 readonly lastFinishSeenAt: number | null | undefined;
19 readonly now: number;
20}
21
22/**
23 * The freshness rule: before any finish the band saw, there is no reading of
24 * this session yet; a reading begun at or after the last finish is fresh;
25 * one begun before it is updating for 30 s, then unavailable.
26 */
27export function freshnessOf({ readingStartedAt, lastFinishSeenAt, now }: Timing & { readonly readingStartedAt: string }): Freshness {
28 if (lastFinishSeenAt === undefined || lastFinishSeenAt === null) return "no-reading-yet";
29 if (Date.parse(readingStartedAt) >= lastFinishSeenAt) return "fresh";
30 return now - lastFinishSeenAt <= READING_WAIT_MS ? "updating" : "unavailable";
31}
32
33/** A status that cannot be shown, and why: one line, no mark. */
34export const unavailableLines = (why: string): string[] => [`prereqs status unavailable: ${why}`];
35
36/** The status file is not there. */
37export const missingFileLines = (): string[] => unavailableLines(`no status file at ${STATUS_FILE}: is bounded/prereqs selected, and bounded's Claude Code hook installed?`);
38
39/** The status file is there but could not be read. */
40export const unreadableLines = (message: string): string[] => unavailableLines(`${STATUS_FILE} cannot be read: ${message}`);
41
42/** The status file was written for another project than the session's. */
43export const otherProjectLines = (projectRoot: string, sessionRoot: string): string[] => unavailableLines(`this status is for ${projectRoot}, not ${sessionRoot}`);
44
45/** The band itself failed while drawing. */
46export const drawFailedLines = (message: string): string[] => unavailableLines(`the band could not draw: ${message}`);
47
48/** A time as the band shows it: HH:MM:SS UTC. */
49const clockTime = (iso: string): string => `${new Date(iso).toISOString().slice(11, 19)} UTC`;
50
51function requirementLine(requirement: StatusFileRequirement): string {
52 const { requiredAgent, unchangedSince, status, filesMatched, pendingRun } = requirement;
53 const patterns = unchangedSince.join(", ");
54 if (filesMatched === 0) return `· ${requiredAgent} ${patterns} match no file yet`;
55 if (status === "holds") return `✓ ${requiredAgent} over ${patterns} as they are now`;
56 if (status === "pending") return `◷ ${requiredAgent} running since ${pendingRun === null ? "an unknown time" : clockTime(pendingRun.startedAt)}`;
57 if (status === "stale") return `✗ ${requiredAgent} stale: ${patterns} changed since`;
58 return `· ${requiredAgent} not yet run`;
59}
60
61/** The action a rule comes before, in the words bounded/prereqs's describeBefore uses. */
62function actionOf(rule: StatusFileRule): string {
63 const { before } = rule;
64 if ("delegate" in before) return `delegating to ${before.delegate}`;
65 if ("write" in before) return `writing ${before.write}`;
66 return `executing ${before.execute}`;
67}
68
69/** The rules in contribution order up to and including the first that is blocked, each with its requirement's agent and whether it holds. */
70function nextLine(file: StatusFile & { readonly outcome: "read" }): string {
71 if (file.rules.length === 0) return "bounded/prereqs has no rules in this project";
72 const blocked = file.rules.findIndex((rule) => !rule.allows);
73 if (blocked === -1) return "→ nothing is blocked";
74 const shown = file.rules.slice(0, blocked + 1).map((rule) => {
75 const agent = file.requirements.find((requirement) => requirement.key === rule.requirementKey)?.requiredAgent ?? rule.requirementKey;
76 return `${actionOf(rule)} needs ${agent} ${rule.allows ? "✓" : "✗"}`;
77 });
78 return `→ next: ${shown.join(" ")}`;
79}
80
81/** What the band draws for a status file it has read (or refused), given when it last saw a finish. */
82export function statusLines(reading: StatusFileReading, timing: Timing): string[] {
83 if (!reading.ok) return unavailableLines(reading.why);
84 const file = reading.value;
85 const freshness = freshnessOf({ ...timing, readingStartedAt: file.readingStartedAt });
86 if (freshness === "no-reading-yet") return ["prereqs status: no reading yet this session; it comes with the first finished tool call"];
87 if (freshness === "updating") return ["prereqs status updating: waiting for bounded's reading of the last call"];
88 if (freshness === "unavailable") return unavailableLines("the last reading predates the last finished call, and bounded's hook wrote no newer one");
89 if (file.outcome === "unread") return unavailableLines(file.why);
90 return [`prereqs status as of ${clockTime(file.readingStartedAt)}`, ...file.requirements.map(requirementLine), nextLine(file)];
91}
92hooks/prereqs-status-file.ts 231 lines1// Reads the text of bounded/prereqs's status file, `.bounded/prereqs/status.json`
2// (ADR 2026-026), into its file form, or says why it cannot be used (ADR
3// 2026-027). By hand, with no library: a hooks module loads only its own
4// files. It checks the file's shape, the same shape bounded/prereqs's value
5// object accepts plus the project's root, and none of the content rules of
6// agent names, file patterns or program prefixes, which the band only shows.
7// The format is read straight after the JSON parse, before anything else, so a
8// file of another format is named as such whatever else it holds.
9
10/** The status file's format this band reads. */
11export const PREREQS_STATUS_FORMAT = "bounded-prereqs-status/1";
12/** Where a project loads the band from that matches its installed bounded. */
13export const BAND_FOLDER = "node_modules/bounded/dist/hosts/claude-code/prereqs-status-band";
14/** Where the status file is, under the project's root. */
15export const STATUS_FILE = ".bounded/prereqs/status.json";
16
17export type RequirementStatus = "holds" | "pending" | "stale" | "missing";
18
19/** One requirement as the reading found it. */
20export interface StatusFileRequirement {
21 readonly key: string;
22 readonly requiredAgent: string;
23 readonly unchangedSince: readonly string[];
24 readonly status: RequirementStatus;
25 readonly filesMatched: number;
26 readonly pendingRun: { readonly agentRunId: string; readonly startedAt: string } | null;
27}
28
29/** The action a rule comes before, as bounded/prereqs's wire form gives it: exactly one of the three. */
30export type StatusFileBefore = { readonly delegate: string } | { readonly write: string } | { readonly execute: string };
31
32/** One rule as the reading found it, in contribution order. */
33export interface StatusFileRule {
34 readonly contributedBy: string;
35 readonly before: StatusFileBefore;
36 readonly requirementKey: string;
37 readonly allows: boolean;
38}
39
40/** A status file whose reading finished. */
41export interface ReadStatusFile {
42 readonly format: typeof PREREQS_STATUS_FORMAT;
43 readonly outcome: "read";
44 readonly readingStartedAt: string;
45 readonly requirements: readonly StatusFileRequirement[];
46 readonly rules: readonly StatusFileRule[];
47 readonly projectRoot: string;
48}
49
50/** A status file whose reading could not be made, and why. */
51export interface UnreadStatusFile {
52 readonly format: typeof PREREQS_STATUS_FORMAT;
53 readonly outcome: "unread";
54 readonly readingStartedAt: string;
55 readonly why: string;
56 readonly projectRoot: string;
57}
58
59export type StatusFile = ReadStatusFile | UnreadStatusFile;
60
61/** Why a status file's text cannot be used: not JSON or not an object; another format; or a shape the band refuses, naming the field. */
62export type StatusFileRefusal = { readonly ok: false; readonly kind: "malformed" | "format-mismatch" | "shape"; readonly why: string };
63
64export type StatusFileReading = { readonly ok: true; readonly value: StatusFile } | StatusFileRefusal;
65
66const READ_KEYS = ["format", "outcome", "readingStartedAt", "requirements", "rules", "projectRoot"];
67const UNREAD_KEYS = ["format", "outcome", "readingStartedAt", "why", "projectRoot"];
68const REQUIREMENT_KEYS = ["key", "requiredAgent", "unchangedSince", "status", "filesMatched", "pendingRun"];
69const RULE_KEYS = ["contributedBy", "before", "requirementKey", "allows"];
70const STATUSES: readonly RequirementStatus[] = ["holds", "pending", "stale", "missing"];
71
72/** Thrown inside shapeOf to stop at the first refused field; readStatusFile turns it into a refusal. */
73class ShapeRefused {
74 readonly why: string;
75
76 constructor(why: string) {
77 this.why = why;
78 }
79}
80
81const refuse = (why: string): never => {
82 throw new ShapeRefused(why);
83};
84
85/** A plain object's own field, read safely. */
86function field(raw: object, name: string): unknown {
87 return Object.getOwnPropertyDescriptor(raw, name)?.value;
88}
89
90function isRecord(raw: unknown): raw is object {
91 return typeof raw === "object" && raw !== null && !Array.isArray(raw);
92}
93
94/** Refuses `raw` unless its keys are exactly `keys`, naming a missing or unknown one. */
95function exactKeys(raw: object, keys: readonly string[], what: string): void {
96 const own = Object.keys(raw);
97 const unknown = own.find((name) => !keys.includes(name));
98 if (unknown !== undefined) refuse(`${what} has a key it does not know: ${unknown}`);
99 const missing = keys.find((name) => !own.includes(name));
100 if (missing !== undefined) refuse(`${what} has no ${missing}`);
101}
102
103function text(raw: object, name: string, what: string): string {
104 const value = field(raw, name);
105 if (typeof value !== "string" || value.trim() === "") refuse(`${what}'s ${name} is not a non-empty string`);
106 return value as string;
107}
108
109/** An ISO 8601 time in UTC, as Date.prototype.toISOString writes it (bounded's DecisionTime test). */
110function time(raw: object, name: string, what: string): string {
111 const value = field(raw, name);
112 let isIso = false;
113 try {
114 isIso = typeof value === "string" && new Date(value).toISOString() === value;
115 } catch {
116 isIso = false;
117 }
118 if (!isIso) refuse(`${what}'s ${name} is not an ISO 8601 time in UTC`);
119 return value as string;
120}
121
122/** The key bounded/prereqs gives the requirement of `agent` over `patterns`. */
123function requirementKeyOf(agent: string, patterns: readonly string[]): string {
124 return JSON.stringify([agent, [...new Set(patterns)].sort()]);
125}
126
127function requirementOf(raw: unknown, what: string): StatusFileRequirement {
128 if (!isRecord(raw)) return refuse(`${what} is not an object`);
129 exactKeys(raw, REQUIREMENT_KEYS, what);
130 const requiredAgent = text(raw, "requiredAgent", what);
131 const patterns = field(raw, "unchangedSince");
132 if (!Array.isArray(patterns) || patterns.length === 0 || !patterns.every((pattern) => typeof pattern === "string" && pattern.trim() !== "")) {
133 return refuse(`${what}'s unchangedSince is not a non-empty list of file patterns`);
134 }
135 const unchangedSince: string[] = patterns;
136 const key = field(raw, "key");
137 if (key !== requirementKeyOf(requiredAgent, unchangedSince)) refuse(`${what}'s key is not the key of its requiredAgent and unchangedSince`);
138 const status = STATUSES.find((known) => known === field(raw, "status"));
139 if (status === undefined) return refuse(`${what}'s status is not holds, pending, stale or missing`);
140 const filesMatched = field(raw, "filesMatched");
141 if (typeof filesMatched !== "number" || !Number.isSafeInteger(filesMatched) || filesMatched < 0) return refuse(`${what}'s filesMatched is not a whole number, 0 or more`);
142 if (filesMatched === 0 && status !== "missing") refuse(`${what}'s filesMatched is 0, so its status is missing`);
143 const rawRun = field(raw, "pendingRun");
144 let pendingRun: StatusFileRequirement["pendingRun"] = null;
145 if (rawRun !== null) {
146 if (!isRecord(rawRun)) return refuse(`${what}'s pendingRun is not null or { agentRunId, startedAt }`);
147 exactKeys(rawRun, ["agentRunId", "startedAt"], `${what}'s pendingRun`);
148 pendingRun = { agentRunId: text(rawRun, "agentRunId", `${what}'s pendingRun`), startedAt: time(rawRun, "startedAt", `${what}'s pendingRun`) };
149 }
150 if ((pendingRun !== null) !== (status === "pending")) refuse(`${what}'s pendingRun is present exactly when its status is pending`);
151 return { key: key as string, requiredAgent, unchangedSince, status, filesMatched, pendingRun };
152}
153
154function beforeOf(raw: unknown, what: string): StatusFileBefore {
155 if (!isRecord(raw)) return refuse(`${what}'s before is not an object`);
156 const keys = Object.keys(raw);
157 const [only] = keys;
158 if (keys.length !== 1 || (only !== "delegate" && only !== "write" && only !== "execute")) return refuse(`${what}'s before names exactly one of delegate, write or execute`);
159 const value = text(raw, only, `${what}'s before`);
160 if (only === "delegate") return { delegate: value };
161 if (only === "write") return { write: value };
162 return { execute: value };
163}
164
165function ruleOf(raw: unknown, what: string, requirements: readonly StatusFileRequirement[]): StatusFileRule {
166 if (!isRecord(raw)) return refuse(`${what} is not an object`);
167 exactKeys(raw, RULE_KEYS, what);
168 const contributedBy = text(raw, "contributedBy", what);
169 const before = beforeOf(field(raw, "before"), what);
170 const requirementKey = field(raw, "requirementKey");
171 const requirement = requirements.find((listed) => listed.key === requirementKey);
172 if (requirement === undefined) return refuse(`${what}'s requirementKey names no requirement in the file`);
173 const allows = field(raw, "allows");
174 if (typeof allows !== "boolean") return refuse(`${what}'s allows is not true or false`);
175 if (allows !== (requirement.status === "holds")) refuse(`${what}'s allows is not whether its requirement holds`);
176 return { contributedBy, before, requirementKey: requirement.key, allows };
177}
178
179function isAbsolute(path: string): boolean {
180 return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path);
181}
182
183function shapeOf(raw: object): StatusFile {
184 const outcome = field(raw, "outcome");
185 if (outcome !== "read" && outcome !== "unread") return refuse(`the file's outcome is not "read" or "unread"`);
186 exactKeys(raw, outcome === "read" ? READ_KEYS : UNREAD_KEYS, `the ${outcome} file`);
187 const readingStartedAt = time(raw, "readingStartedAt", "the file");
188 const projectRoot = field(raw, "projectRoot");
189 if (typeof projectRoot !== "string" || !isAbsolute(projectRoot)) refuse("the file's projectRoot is not an absolute path");
190 const root = projectRoot as string;
191 if (outcome === "unread") {
192 const why = text(raw, "why", "the file");
193 return { format: PREREQS_STATUS_FORMAT, outcome, readingStartedAt, why, projectRoot: root };
194 }
195 const rawRequirements = field(raw, "requirements");
196 if (!Array.isArray(rawRequirements)) return refuse("the file's requirements are not a list");
197 const requirements: StatusFileRequirement[] = [];
198 for (const [index, item] of rawRequirements.entries()) {
199 const requirement = requirementOf(item, `requirement ${index + 1}`);
200 const earlier = requirements.findIndex((listed) => listed.key === requirement.key);
201 if (earlier !== -1) refuse(`requirement ${index + 1}'s key repeats requirement ${earlier + 1}'s`);
202 requirements.push(requirement);
203 }
204 const rawRules = field(raw, "rules");
205 if (!Array.isArray(rawRules)) return refuse("the file's rules are not a list");
206 const rules = rawRules.map((item: unknown, index) => ruleOf(item, `rule ${index + 1}`, requirements));
207 return { format: PREREQS_STATUS_FORMAT, outcome, readingStartedAt, requirements, rules, projectRoot: root };
208}
209
210/** Reads the status file's text: its file form, or why it cannot be used. Never throws. */
211export function readStatusFile(fileText: string): StatusFileReading {
212 let raw: unknown;
213 try {
214 raw = JSON.parse(fileText);
215 } catch {
216 return { ok: false, kind: "malformed", why: `${STATUS_FILE} is not JSON` };
217 }
218 if (!isRecord(raw)) return { ok: false, kind: "malformed", why: `${STATUS_FILE} is JSON but not a JSON object` };
219 const format = field(raw, "format");
220 if (format !== PREREQS_STATUS_FORMAT) {
221 const found = typeof format === "string" ? `is in format ${format}` : "has no format";
222 return { ok: false, kind: "format-mismatch", why: `${STATUS_FILE} ${found}, and this band reads ${PREREQS_STATUS_FORMAT}: load the band that ships with the project's bounded, from ${BAND_FOLDER}` };
223 }
224 try {
225 return { ok: true, value: shapeOf(raw) };
226 } catch (thrown) {
227 if (thrown instanceof ShapeRefused) return { ok: false, kind: "shape", why: `${STATUS_FILE} is malformed: ${thrown.why}` };
228 return { ok: false, kind: "shape", why: `${STATUS_FILE} could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}` };
229 }
230}
231hooks/project-config-names.ts 7 lines1/**
2 * The names a project's bounded configuration may have, in the order bounded
3 * looks for them. The band's own copy (it imports nothing outside its folder),
4 * held equal to bounded's PROJECT_CONFIG_NAMES by a test.
5 */
6export const PROJECT_CONFIG_NAMES = ["bounded.config.ts", "bounded.config.js", "bounded.config.mjs"] as const;
7types/index.d.ts 9 lines1// The prereqs status band's state contract (ADR 2026-027), named by its
2// plugin.json: its one value in $.state, when it last saw a finished tool
3// call or agent run, in milliseconds from $.clock.now(); null when reset.
4declare module "claude-code" {
5 interface PluginState {
6 "bounded-prereqs-status-band": { lastFinishSeenAt: number | null };
7 }
8}
9