SLOPSHOPPER

bounded-prereqs-status-band

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…

newbandtimer
A shopper browsing a rack in a slop shop
README

The Bounded harness

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 legacy harness

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).

Status

What is missing or known to be weak is in docs/flight-state.md. Built and running on two real hosts:

  • Slices 1 to 3. Packs, typed extension points, contributions and composition (docs/slice-1.md); host-neutral events made of precise effects, verdicts, guards and dispatch over a composition (docs/slice-2.md); the protected-paths pack, the first pack (docs/slice-3.md). Each guide has a reading order and a worked example.
  • The protected-paths pack (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).
  • The prerequisites pack (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).
  • The package catalogue: each package that ships packs ships 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).
  • The Bounded log: every decision, and every refusal a host adapter makes itself, recorded in .bounded/log.jsonl (docs/bounded-log.md).
  • Configuration: bounded.config.ts selects packs; openProject(root, { ports }) gives the judge hosts ask, and refuses everything when the configuration is broken (docs/configuration.md).
  • Shell commands read once, for every pack: the host adapter reads each shell command (the programs it runs, the files it reads, lists and writes, and what only the shell could resolve) and the execute effect carries that reading, whose shape the core checks; bounded's reader is published as bounded/shell-command-reader (ADR 2026-020).
  • Drift: what a shell command changes in protected files is put back, or moved aside, reported and recorded; tampered snapshots are detected (docs/drift.md).
  • Host adapters: Claude Code hooks (PreToolUse, PostToolUse, PostToolUseFailure, SubagentStop; docs/adapter-claude-code.md) and a pi extension (tool_call, tool_result; docs/adapter-pi.md), in src/hosts/.

Design in brief

  • A pack has an id rooted at its npm package (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.
  • An extension point is declared inside its pack, which owns it, and accepts values of one type: its check parses each value and may normalise it.
  • A pack may contribute only to the points of packs it lists directly in dependsOn. Anything else does not compile, and is refused at composition if it is built from untyped data (ADRs 2026-003 and 2026-004).
  • Composition takes the available packs and the listed packs (both as pack objects), selects the listed packs and every pack they depend on, transitively (ADR 2026-018), and either refuses with a message naming the pack, the extension point and the fix, or returns a result from which each extension point is read, typed, dependencies' contributions first, independent of listing order.
  • Events and guards. A tool use is a list of effects (read, list, write, execute, fetch, delegate, invoke). The core pack 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).
  • Decisions are recorded. The judge-event feature decides an event and records the decision through an asynchronous log (in memory, or a JSON-lines file); if the decision cannot be recorded in time, the action is refused (docs/bounded-log.md, ADR 2026-022).
  • Configuration. A project selects its packs in 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).
  • Shell commands are read by the host adapter. The host adapter is trusted code; the model's tool input is what is judged. When it builds the core's event, the host adapter reads every execute effect's command with bounded's reader and the effect carries the reading, which is required and whose shape the core checks; the protected-paths pack judges from it, and a command that could not be read is refused. The reader, tree-sitter's bash grammar and the table of what commands do with their words live in a private context, bounded-shell-command-reader, published as bounded/shell-command-reader, so the core names no shell (ADR 2026-020).
  • Drift. What the protected-paths pack protects from writes is snapshotted before every allowed shell command and put back after it if it changed them, with a message for the agent and a record; what a command created is moved aside, never deleted. The core runs it through its 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).
  • The protected-paths pack (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/.

Installing

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.

  • It writes 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's own state in .bounded/;
  • Claude Code's project settings files (.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's loader (.pi/extensions/bounded/**);
  • Bounded's installed code (node_modules/bounded/**);
  • git's hooks (.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:

  • drift does not watch node_modules or .git, so what a command changes there is not put back;
  • commands the protected-paths pack does not recognise as writing, such as 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;
  • paths the protected-paths pack cannot resolve (variables, globs), which it allows;
  • user-level settings outside the project, such as ~/.claude/settings.json;
  • writes delayed into the background, after the tool call is judged;
  • drift's snapshots, kept in a state directory the user (and so the agent) can write.
  • It installs the hooks of your agent hosts. These are the hosts whose directory the project has (.claude/, .pi/), or those named with --host claude-code or --host pi.
  • Claude Code's hooks go into .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's loader goes under .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:

  • Claude Code runs the hook in a new node process on every tool call, so a new 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.
  • pi loads 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>.
  • A host another package installs is told to restart after every update too, as it may hold bounded in its process.

From a checkout (local development)

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.

Develop

bun install
bun run check   # typecheck, lint, tests (incl. architecture and compile-time tests)

Changes follow the development lifecycle.

Source 6 files
hooks/register.ts 135 lines
1// 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}
135
hooks/engine-subset.ts 99 lines
1// 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}
99
hooks/prereqs-status-band-lines.ts 92 lines
1// 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}
92
hooks/prereqs-status-file.ts 231 lines
1// 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}
231
hooks/project-config-names.ts 7 lines
1/**
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;
7
types/index.d.ts 9 lines
1// 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