ByTheSlice runs your project like a grab-and-go pizza shop. v5 delivers in Pies (coherent 3 to 8 slice chapters, one PR each) built from Slices (single…

<!-- README.md --> <!-- ByTheSlice: npm + GitHub README. Pizza-themed, two-tier: simple-at-top + technical-below. -->
<h1><img src="assets/brand/logo.png" alt="ByTheSlice" width="460"></h1>
A grab-and-go pizza shop for shipping software.
Plan today's menu. Prep the line. Bake one pie at a time, slide it onto the display tray, and keep service moving until the board is clear.
ByTheSlice runs your project like a pizza shop. You don't build a whole app in one prompt - you prep the kitchen, then sell slices to customers one at a time, with quality checks every step of the way. No 200-file PRs nobody reads. No "what does this even do" three months later.
Three steps. Fourteen commands. The kitchen does the rest.
Start here
Under the hood (for developers + power agentic-coding users)
bytheslice.config.jsonThink of your project as a pizza shop. There are three things you do, in order. Every command name links to its full reference (SKILL.md).
Six things, in order, once per project:
| Command | In pizza terms | What it does |
|---|---|---|
/setup-shop | Unlock the doors, fire up the oven. | Bootstraps a new project or drops ByTheSlice into an existing one. |
/create-menu | Decide which pies to serve. | Turns a free-form brief into a structured PRD. |
/cook-pizzas | Pre-bake every pie on the menu. | Decomposes the PRD into Pies (3-8 slices each) + a nested master checklist. |
/set-display-case | Build the case the pies sit in. | Generates the design system - tokens, components, the /library preview route. |
/final-quality-check | Install the quality line. | Wires CI/CD, E2E tests, design-system compliance, visual regression. |
/open-the-shop | Flip the OPEN sign, stock ingredients. | Sets up env vars + external service credentials. The most hands-on prep step. |
Where you'll spend most of your time. /sell-pie is the forefront motion - bake a whole Pie autonomously, then box it at the boundary. Reach for /sell-slice when one slice needs a careful, hands-on touch.
| Command | In pizza terms | What it does |
|---|---|---|
/sell-pie | Bake a whole pie, then set the tray on the counter. | The primary everyday command. Autonomous baker over one Pie: per-slice build → test → verify → fix, commit + push each slice (no PR), then opens the boundary PR via /box-it-up. |
/sell-slice | Run one slice through the kitchen line by hand. | High-touch single-slice delivery with the library preview gate intact. For sensitive or collaborative work. |
/box-it-up | Box the pie, ring it up, hand it over. | Opens the Pie N PR, watches CI, auto-fixes on red, merges on your approval (never squash), syncs main, cleans up. --slice is the per-slice commit+push /sell-pie calls. |
A Pie is the unit of autonomy, PR, and context-refresh (by default; opt-in merge-per-slice delivery opens and merges one PR per slice). After each Pie merges, start a fresh chat and bake the next one - until the master checklist is empty.
On-demand, no particular order:
| Command | In pizza terms | What it does |
|---|---|---|
/special-order | Cook something off-menu on the spot. | Bolts new features onto an in-progress project - writes fresh stage files, hands off to /sell-slice. |
/open-kitchen | Watch the shop at work. | Opens the project's local read-only 3D kitchen in the host's native Browser pane when available. --lobby opens the lobby: every ByTheSlice project on this machine on one page. |
/inspect-display | Eyeball every pie on the tray. | Read-only audit of the running app - every route, every page, captured to a report. |
/run-the-day ⚠️ | Auto-pilot the whole day's service. | Thin chainer that drives /sell-pie across every Pie in sequence. Experimental - fine for short roadmaps, drifts across many pies. |
/close-shop ⚠️ | Sit down and debrief after service. | Friction retro on the workflow itself - drafts plugin improvements back to disk. Experimental. |
[!IMPORTANT] Shop rules the plugin enforces, no exceptions:
- Every slice passes the quality line, in tiers. Quick checks while coding, the slice's own tests and build before each commit, the full local suite once at slice end, and the full suite again in CI at the PR, plus the independent UI and data review. After a fix, only the checks that fix could change re-run. A bad pie doesn't go on the display.
- Selling and boxing are decoupled. Slices commit + push to the pie branch with no PR or CI;
/box-it-upopens the singlePie NPR and merges at the pie boundary - so CI fires once per pie, and you can taste-test between slices. (That is the default. Opt-in merge-per-slice delivery, viadelivery.modeor an explicit "merge each slice" instruction, gives every slice its own PR, merged on green CI.)- Only the conductor touches git. Agents hand back the files they changed; the conductor stages exactly those, commits at every task boundary, and pushes.
--no-verifyis never the answer.- Every skill is independently invocable. Drop
/set-display-caseonto any project to bolt on a design system, or/box-it-uponto any branch to push it. The full workflow is opt-in.
# 1. Install (one-time)
npx bytheslice install --target both
# 2. Open the shop (one-time per project)
/bytheslice:setup-shop
/bytheslice:create-menu
/bytheslice:cook-pizzas
/bytheslice:set-display-case
/bytheslice:final-quality-check
/bytheslice:open-the-shop
# 3. Serve customers (repeat, fresh chat per pie)
/bytheslice:sell-pie # bakes a whole Pie autonomously, opens the boundary PR
# or, for one careful slice at a time:
/bytheslice:sell-slice
/bytheslice:box-it-up
Repeat step 3 until the master checklist is empty. That's the whole motion.
[!NOTE] Every command also works without the
/bytheslice:prefix in Claude Code if no other plugin claims it (e.g. just/sell-slice). Old v3 names (/deliver-stage,/ship-pr, etc.) still work for one release as backward-compat aliases.
| Option | Command | When to use |
|---|---|---|
| Claude Code plugin (recommended) | /add-plugin bytheslice | Normal interactive use. |
| npm CLI | npx bytheslice install --target both | Automation, CI bootstraps, devcontainers. Scriptable + idempotent. |
| From GitHub | npx github:steve-piece/bytheslice install --target both | No npm install. |
| Pick & choose skills | npx bytheslice install --mode skills --skill setup-shop --skill sell-slice | Grab individual skills into ./.bytheslice-installs/skills/. |
Default install paths - Cursor: ~/.cursor/plugins/local/bytheslice · Claude Code: ~/.claude/plugins/bytheslice. Scope a single host with --target cursor / --target claude; override paths with --cursor-dir / --claude-dir / --skills-dir. For declarative installs, point --config <path> at a JSONC file (see skills-config.example.json).
[!NOTE] Updates take effect next session. A changed agent or skill file is read when a session starts, so start a new session after updating. A plugin installed into the plugin cache is a snapshot copy: reinstall it (or update it from its marketplace) to pick up a new version.
Run /open-kitchen from your project to open its local 3D kitchen. Characters follow active agents (off-duty crew live in town and run in when called), the lighting follows the session phase, and the order line follows the master checklist, with measured slice progress, pie details, a plain-words activity log and a profile for every person in town.
| Area | What it shows |
|---|---|
| Walls and opening crew | Project configuration and foundation work. Missing setup appears as a shop under construction. |
| Menu and ticket rail | The PRD, planned pies and slices, and the current order. |
| Display case | Design-system or library-route presence, plus finished pies from the checklist. |
| Safe | The local successful environment-check marker. No credentials appear in the kitchen. |
| Oven, door alarm and locks | CI workflow, pre-push hook and branch-protection setup-script presence. Presence does not prove those protections have run or are enabled remotely. |
| Cooking line and pass | Named builders, reviewers, testers and verifiers working at their stations. Click a character for its role and current task. |
| Service window and customer line | Open orders in checklist order, with the active order at the window. Orders, Crew (the People sheet, with Crew and Town tabs and a profile for anyone) and Log open the detail panels. |
| Progress (header button) | Slice progress per pie from the Pass ledger: a slice table (started, done, duration, fix loops, verifier verdict, cost) and a step line of slices done over time. Without a ledger it says so. |
| Sign and lighting | Checklist shop status and session phases: before opening, open service and after hours. |
The header's browser button enables after a valid local snapshot arrives. A click opens the same verified localhost kitchen in a new tab using the host's browser handoff. The host may keep that tab inside its pane. Camera recenter, zoom to fit and pause controls remain available.
A project with no setup markers shows the demo shop, with sample orders and lighting controls. Partial setup switches to live construction; completed setup switches to live service. Demo activity is illustrative, never evidence that your project shipped. The page still loads Three.js from unpkg and fonts from Google Fonts, so rendering needs those external resources.
Use a complete plugin install for the kitchen: its MCP server, hooks and viewer assets must travel together. --mode skills copies selected skills only and does not register the MCP server or hooks. The local launcher can also run directly without a Claude model session:
node "/absolute/plugin/root/skills/open-kitchen/open-kitchen.js" --project "/absolute/project/root"
Replace both paths with real absolute paths. The helper prints a verified http://127.0.0.1:<port>/ link for that project, reusing a matching server or starting one. The server defaults to port 4477, tries the next free port, and records discovery at .claude/.bytheslice-state/kitchen/port. The visible page refreshes state every three seconds. See the local transport and privacy contract.
Host verification status: both artifact bridge probes (host:plugin_bytheslice_kitchen and host:kitchen) returned server_not_connected. The localhost Browser-pane path is selected; native /open-kitchen invocation and a production /sell-slice session remain unverified. A ready localhost link alone does not complete the native command.
Run /open-kitchen --lobby to see every ByTheSlice project on this machine on one page, each as a card: its name and folder, what is in progress now (pie and slice), a bar of slices done, slices and pies done over total, and one state badge.
| Badge | Means |
|---|---|
| Needs you | An open HITL question in the project's Pass ledger, or a session that asked for a permission or sat idle at the prompt and has not had a new prompt since. |
| Active | A session is working (a prompt was submitted and it has not stopped) or has subagents running. The arrows spin; with reduced motion they hold still. |
| Done | Every pie in the master checklist is done. |
| Inactive | Anything else. |
Cards sort Needs you, Active, Done, Inactive, then by name, and the page refreshes every three seconds while it is visible. Several sessions or worktrees of one project roll up into one card. Click a card to open that project's city: a running kitchen opens straight away, otherwise the lobby starts that project's kitchen first ("Starting kitchen..."). An Inactive or Done project opens idle: no customer line, a "Nothing is in progress in this project" notice, and the time-of-day buttons unlocked so you can look around; idle ends for good once the kitchen sees an agent at work. A kitchen opened from the lobby shows a Lobby button in its header that leads back, and any kitchen answers /lobby with a redirect to the running lobby (or a 404 when none is running).
A project joins the lobby the first time a Claude Code or Cursor session starts in it with the 5.6.0 hooks installed; only a primary checkout holding docs/plans/00_master_checklist.md or bytheslice.config.json counts. The list lives in ~/.bytheslice/projects.json (BTS_HOME moves it), and a project whose folder is gone drops off on its own. The lobby reads checklists, ledgers and hook signals; it can start a registered project's kitchen and nothing else. Hooks record only folder paths and derived facts (working, subagent count, waiting kind), never prompts, messages or secrets. Cursor has no notification hook, so a Cursor session never shows Needs you for a waiting prompt. The helper runs it without a model session too:
node "/absolute/plugin/root/skills/open-kitchen/open-kitchen.js" --lobby
It prints one verified http://127.0.0.1:<port>/ line, reusing the running lobby or starting one (port 4466 by default, the next free one when taken). See the lobby's architecture.
In a pizzeria the pass is the counter where every order is checked before it goes out, with the ticket rail above it. The Pass is ByTheSlice's Claude Code mod, and it does both jobs: a live ticket rail beside the chat, and checks that warn when a delivery rule slips. It ships inside the plugin (mod/), loads with it, and needs no setup.
| Surface | What it shows |
|---|---|
Pane (The Pass) | The pie header; one row per slice with its status and last verdict; live agents, stalled ones flagged; the latest check warnings; the HITL inbox; the files the active slice touched (a builder edit after the verifier's pass shows red); the team roster in team mode; and the project's other sessions, read-only. It opens on its own when an interactive session starts in a project with a master checklist and the terminal is wide enough. |
| Band (above the prompt) | One line such as Pie 5 · Slice 5.2 verifying · 3 agents · fix 1/3 · HITL 1, in a warning style while a verdict failed or a HITL item is open, and for five minutes after a check warning. The band and the prompt hint add /box-it-up ready once every slice is done while the pie branch is still checked out; after the merge they read only "All pies done". |
| Toasts and notifications | A toast when a slice is done, a verdict fails, a branch is pushed or a check warns. A native notification only for a blocking HITL question and a finished pie. |
| Model tools | bts_status (the same projection the pane draws, with a Next: line that is advice), bts_note (a decision note), bts_hitl (raise or answer a HITL item), bts_pr_body (the pie PR's Evidence section) and bts_wake (a teammate's stand-in for a scheduled wakeup). |
| Command | What it does |
|---|---|
/pass | Opens or closes the pane. |
/pass checks | Lists the latest ten check warnings. |
/pass history | Prints progress per pie from the ledger. |
/pass replay <slice> | Steps through a slice's commits (and its uncommitted changes) with each file's diff, in a focused pane. |
/pass reconcile | Lists where the checklist and the ledger disagree. It edits nothing. |
The ledger is .bytheslice/ledger.jsonl at the primary checkout, so every pie worktree writes the same file, and the folder carries its own .gitignore, so it never reaches git. It records what happened: pie and slice starts, verdicts with the commit they judged, commits, pushes and merges, check warnings, HITL items and their waits, stalls, decision notes, usage, and file edits as path and role only. It never holds prompts, agent messages, file contents or secrets. Agent starts and stops come from the kitchen activity log rather than a second recorder. scripts/ledger-summary.js folds it for /pass history (--format text), the kitchen and /close-shop (--format json), and the pie PR (--format pr-body).
[!IMPORTANT]
- Nothing the Pass does blocks. Every check warns three ways (a
warnledger row, a toast, and a note the model reads after the call) and the edit, spawn, commit or push goes ahead unchanged. The check codes are listed in architecture.- Claude Code 2.1.299 or later. Mods arrived in 2.1.287; the Pass waits for 2.1.299 (teammate turn starts, spawn on resume) and stays inert on anything older, logging one line. Team mode also needs agent teams turned on (
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1).- Optional by design. Without the mod (Cursor, an older Claude Code, a host with mods turned off) everything still works: the skills, hooks and kitchen run as before, and the pane, band and checks simply do not appear.
The full design and every as-built note live in the Pass mod spec.
Everything below is for developers and power agentic-coding users. If you just want to ship features, you can stop at Quick Start.
flowchart TD
Setup["🔥 setup-shop"] --> Menu["📋 create-menu"]
Menu --> Cook["🍕 cook-pizzas"]
Cook --> Case["🪟 set-display-case"]
Case --> QC["✅ final-quality-check"]
QC --> Open["🚪 open-the-shop"]
Open ==> Pie["🥧 sell-pie<br/>bake one whole pie"]
Special["➕ special-order"] -.-> Pie
Pie -.->|one careful slice| Sell["🛎️ sell-slice"]
Pie ==> Box["📦 box-it-up<br/>pie-boundary PR"]
Sell -.-> Box
Box ==> Q{"Pies left<br/>in the case?"}
Q ==>|Yes, fresh chat| Pie
Q -->|No| Inspect["🚶 inspect-display"]
Inspect --> Close(["🌙 close-shop"])
classDef prep fill:#dbeafe,stroke:#2563eb,color:#1e3a8a;
classDef serve fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef side fill:#f3f4f6,stroke:#9ca3af,color:#374151;
classDef done fill:#ede9fe,stroke:#7c3aed,color:#4c1d95;
class Setup,Menu,Cook,Case,QC,Open prep;
class Pie,Sell,Box serve;
class Special,Inspect side;
class Close done;
🔵 Prep (once per project) · 🟢 Service (the daily loop) · ⚪ Side flows (on demand) · 🟣 Wrap-up. Thick arrows are the everyday path; dotted arrows are optional branches.
Finish a pie, start a fresh chat, bake the next - until the master checklist is green.
cook-pizzas produces docs/plans/00_master_checklist.md as a two-level Pie / Slice roadmap - Pie 1 - Foundations (tracked via the ## Prep gate) then the feature Pies (2+):
## Prep (Pie 1 Foundations, run once before any feature work)
- [ ] Display case built : run /bytheslice:set-display-case
- [ ] Quality line installed : run /bytheslice:final-quality-check
- [ ] Shop open : run /bytheslice:open-the-shop
- [ ] DB schema foundation : run /bytheslice:sell-slice on Slice 1.x (if backend)
## Pie 2: Blog Editor <!-- review: boundary -->
### Slice 2.6: Build the Blog Editor frontend
- [ ] step
- [ ] step
### Slice 2.7: Wire server actions into the editor
- [ ] step
A Pie is a coherent chapter of 3-8 slices; a Slice is one vertical deliverable. /sell-slice's prep gate refuses to start feature work until every Pie-1 / Prep box is [x]. For the full hierarchy, caps, and how legacy flat-v4 / v3 checklists are handled, see Architecture & conventions.
[!NOTE] Hard caps per slice: 6 tasks, ~10-15 files changed, completable in one fresh agent session. Override
stages.maxTasksPerStageinbytheslice.config.jsonif you really need a bigger slice - but the cap exists for a reason.
bytheslice.config.jsonDrop a bytheslice.config.json at your project root to override defaults:
{
"modelTiers": { "implementer": "opus", "qualityReviewer": "opus",
"sliceTester": "sonnet", "sliceVerifier": "sonnet" },
"stages": { "maxTasksPerStage": 6, "targetFeatureStages": "20-30" },
"verification": { "viewports": [375, 768, 1280],
"e2e": { "feature": "always", "regressionCore": "critical-only", "visual": "off" } },
"flow": { "autoApproveBuildPlan": false, "libraryGate": "self-critique" },
"review": { "default": "boundary" }, // per-pie override via the `<!-- review: -->` annotation
"delivery": { "mode": "pie-pr", "sliceBudgetHours": 3 }, // "merge-per-slice" = one PR per slice, merged on green CI; budget 0 = off
"mcps": { "shadcn": true, "magic": false, "figma": false, "chromeDevTools": true },
"visualReview": { "tools": ["claude-in-chrome", "chrome-devtools-mcp", "playwright"], "vizzly": false },
"hitl": { "additionalCategories": [] },
"rules": { "imports": [] },
"runPipeline": { "platformWalkEvery": 5, "haltOn": "broken" }
}
Precedence (top wins): env vars → bytheslice.config.json → project rules file (CLAUDE.md / AGENTS.md) → plugin defaults.
Full schema and precedence rules: bytheslice-config-schema.md. System-wide defaults live at ~/.bytheslice/defaults.json (created during first-time install). Config keys keep their v3 names for backward compatibility.
The kitchen runs on a handful of non-negotiable rules - per-slice verify-once, context-separated dispatch, preview-first library delivery, and warn-only hooks and Pass checks that never block. The full referenc
mod/register.tsx 282 lines1// mod/register.tsx
2// The Pass hooks module, wiring only: version gate, ledger capture (file edits as path and role only), checks that warn and never block, the stall watchdog, the HITL inbox, team mode (roster, teammate turns, idle and done), the cross-session strip, the bts tools, the pass command (replay and history included), pane, replay pane, band and hint.
3
4import { atom, read } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import { drawBand, hintProps, pushStatus } from './band.tsx'
8import { captureBash, captureEdit, captureSkill, captureTurn, markSpawn, recordDoneFlips } from './capture.ts'
9import { primeChecks, reportToModel, trackGit, trackSkill, warnResult } from './checks.ts'
10import { checkFileWrite, checkPush } from './checks-checklist.ts'
11import { checkCommitOnMain, checkFixSpawn, countFix, raiseFixCap } from './checks-git.ts'
12import { checkDrift, checkSpawn, trackSpawn } from './checks-spawn.ts'
13import { closeOnReply, isOperatorReply, serveHitl } from './hitl.ts'
14import { startSession } from './ledger.ts'
15import { pollAgents, refreshLedger, startPulse } from './live.ts'
16import { notifyPiesReady } from './notify.ts'
17import { gate, isChecklistPath, isLimited, refresh, watchChecklist } from './projection.ts'
18import { checksText, drawPane, openPane, togglePane } from './rail.tsx'
19import { reconcileText } from './reconcile.ts'
20import { REPLAY_PANE, drawReplay, openReplay } from './replay.tsx'
21import { clearStrip, pulseStrip, resetStrip } from './strip.ts'
22import { historyText } from './history.ts'
23import { captureTeammateTurn, recordDone, recordIdle, refreshTeam, teamActive, teammateOf } from './team.ts'
24import { registerTools, serveNote, servePrBody, serveStatus } from './tools.ts'
25import { markTouched, refreshTouched, refreshTouchedIfDirty, refreshTouchedSoon, resetTouched } from './touched.ts'
26import { stamp, stampTurn, startWatchdog, stopWatchdog } from './watchdog.ts'
27import { serveWake, stopWakes } from './wake.ts'
28
29const checklistAtom = atom({ plugin: 'bytheslice', key: 'pass.checklist' } as const, null)
30const ledgerAtom = atom({ plugin: 'bytheslice', key: 'pass.ledger' } as const, null)
31
32// Every checklist write goes through here: the status line follows it, a slice
33// that flipped to done is recorded, a pie it finished is announced once, and
34// the ledger projection is read again.
35async function reload($: EngineInterface): Promise<boolean> {
36 const before = await read($, checklistAtom).catch(() => null)
37 const c = await refresh($)
38 pushStatus($, c)
39 await recordDoneFlips($, before, c).catch(() => [])
40 await notifyPiesReady($, before, c).catch(() => [])
41 await refreshLedger($)
42 // The active slice may have moved: its touched files are read on the next pulse.
43 markTouched()
44 return c !== null
45}
46
47const inputOf = (e: unknown): Record<string, unknown> =>
48 e !== null && typeof e === 'object' ? (e as Record<string, unknown>) : {}
49
50export const register: Register = on => {
51 // Registered first, so it sees every tool call: a subagent's call stamps its
52 // loop's last activity for the watchdog (at most once per 5 seconds), and the
53 // call goes on unchanged.
54 on('tool.call', async ($, e, next) => {
55 await stamp($, e.agentId).catch(() => false)
56 return next(e)
57 }).catch(($, e, next) => next(e))
58
59 // A loop's turn start stamps it at once (a teammate's running time counts
60 // from it); a teammate's also refreshes the roster, which shows it running.
61 on('turn.start', async ($, e, next) => {
62 await stampTurn($, e.agentId).catch(() => false)
63 const started = await next(e)
64 if (e.agentId !== undefined && (await teammateOf($, e.agentId).catch(() => null))) await refreshTeam($).catch(() => null)
65 return started
66 }).catch(($, e, next) => next(e))
67
68 on('session.start', async ($, e, next) => {
69 if (!(await gate($))) {
70 await startSession($).catch(() => undefined)
71 resetTouched()
72 resetStrip()
73 // Awaited here, so the model has both tools listed by its first turn.
74 await registerTools($).catch(() => undefined)
75 await $.command
76 .register({
77 name: 'pass',
78 description:
79 'Open or close The Pass pane; pass replay <slice> steps through a slice; pass history prints progress per pie from the ledger; pass reconcile lists where the checklist and the ledger disagree; pass checks lists the latest check warnings.',
80 })
81 .catch(() => undefined)
82 const hasChecklist = await reload($)
83 await primeChecks($).catch(() => undefined)
84 if (hasChecklist) await refreshTouched($).catch(() => null)
85 // This session's place in the roadmap strip, and the other sessions of the project.
86 if (hasChecklist) await pulseStrip($, true).catch(() => [])
87 await watchChecklist($, () => reload($))
88 await pollAgents($)
89 // Every five seconds; the roster reads the ledger only when a teammate moved,
90 // the touched files run git only when an edit, verdict or commit came since,
91 // and the strip entry is written at most once per 10 seconds.
92 await startPulse($, () => Promise.all([refreshTeam($), refreshTouchedIfDirty($), pulseStrip($)]))
93 // Every 30 seconds: flags stalled agents; it warns and never stops one.
94 await startWatchdog($)
95 // Unasked, the engine places the pane only on a wide terminal; it waits undrawn below that.
96 if (hasChecklist && e.isInteractive) void openPane($).catch(() => undefined)
97 }
98 return next(e)
99 }).catch(($, e, next) => next(e))
100
101 // The watchdog stops with the session; a /clear keeps the process, so it starts again.
102 on('session.end', async ($, e, next) => {
103 const ended = await next(e)
104 // This session leaves the other sessions' strips.
105 await clearStrip($).catch(() => undefined)
106 stopWatchdog()
107 stopWakes()
108 if (e.reason === 'clear' && !(await isLimited($))) await startWatchdog($).catch(() => undefined)
109 return ended
110 }).catch(($, e, next) => next(e))
111
112 // Checks judge before the call from atoms only, always call next, and add any
113 // warning to what the model reads after the result; the call is never refused.
114 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
115 const findings = await checkFileWrite($, e).catch(() => [])
116 const ran = await next(e)
117 if (isChecklistPath(e.file_path)) {
118 if (!(await isLimited($))) await reload($).catch(() => false)
119 } else if ((await captureEdit($, e, e.tool, ran).catch(() => null)) !== null) {
120 // Observe only: the path and the role that edited it, never the content.
121 await refreshTouchedSoon($).catch(() => null)
122 }
123 return warnResult($, ran, findings)
124 }).catch(($, e, next) => next(e))
125
126 // The git result is read after the command ran and returned as it came, a warning added at most.
127 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
128 const findings = [
129 ...(await checkCommitOnMain($, e.command).catch(() => [])),
130 ...(await checkPush($, e.command).catch(() => [])),
131 ]
132 const ran = await next(e)
133 const rows = await captureBash($, e.command, ran).catch(() => [])
134 if (rows.length > 0) markTouched()
135 if (ran.deny === undefined && ran.isError !== true) await trackGit($, e.command, rows).catch(() => undefined)
136 return warnResult($, ran, findings)
137 }).catch(($, e, next) => next(e))
138
139 // Listed as mcp__<plugin>__<name>; the pattern also takes a prefixed plugin name a host may give.
140 on('tool.call', { tool: ['mcp__bytheslice__bts_status', /^mcp__\w*bytheslice\w*__bts_status$/] }, async ($, e) => ({ result: await serveStatus($, inputOf(e)) })).catch(() => ({
141 result: 'The Pass status is unavailable right now; read docs/plans/00_master_checklist.md instead. Nothing was halted.',
142 }))
143
144 on('tool.call', { tool: ['mcp__bytheslice__bts_note', /^mcp__\w*bytheslice\w*__bts_note$/] }, async ($, e) => ({ result: await serveNote($, inputOf(e)) })).catch(() => ({
145 result: 'not recorded: the Pass could not write the note. Nothing was halted.',
146 }))
147
148 // Read-only: the pie PR's evidence section from the shared ledger fold.
149 on('tool.call', { tool: ['mcp__bytheslice__bts_pr_body', /^mcp__\w*bytheslice\w*__bts_pr_body$/] }, async ($, e) => ({ result: await servePrBody($, inputOf(e)) })).catch(() => ({
150 result: 'bts_pr_body: the Pass could not build the evidence section; open the PR without it. Nothing was halted.',
151 }))
152
153 // A teammate's stand-in for ScheduleWakeup: arms one timer and answers at once.
154 on('tool.call', { tool: ['mcp__bytheslice__bts_wake', /^mcp__\w*bytheslice\w*__bts_wake$/] }, async ($, e) => ({
155 result: await serveWake($, inputOf(e), e.agentId),
156 })).catch(() => ({
157 result: 'not armed: the Pass could not arm that wake. Nothing was halted.',
158 }))
159
160 // Records and notifies, then returns at once: the skill's own HITL flow (ask in chat and stop) is what waits.
161 on('tool.call', { tool: ['mcp__bytheslice__bts_hitl', /^mcp__\w*bytheslice\w*__bts_hitl$/] }, async ($, e) => ({ result: await serveHitl($, inputOf(e)) })).catch(() => ({
162 result: 'not recorded: the Pass could not record that HITL item; ask the operator in chat anyway. Nothing was halted.',
163 }))
164
165 // The operator's own reply in chat answers the items the model was told to ask
166 // there; the prompt goes in unchanged and its words are never read or kept.
167 on('prompt.submit', async ($, e, next) => {
168 const entered = await next(e)
169 if (entered.drop === undefined && isOperatorReply(e)) await closeOnReply($).catch(() => 0)
170 return entered
171 }).catch(($, e, next) => next(e))
172
173 // The spawn policy and the fix-loop cap warn: the spawn runs on the model and task
174 // it asked for, and next's answer is returned untouched.
175 on('agent.spawn', async ($, e, next) => {
176 const judged = await checkSpawn($, e).catch(() => ({ findings: [], track: null }))
177 const fix = await checkFixSpawn($, e).catch(() => ({ spawn: null, findings: [] }))
178 const started = await next(e)
179 await markSpawn($, e, started).catch(() => undefined)
180 if (started.teammateId !== undefined) await refreshTeam($, { force: true }).catch(() => null)
181 if (started.deny === undefined) {
182 await countFix($, fix.spawn).catch(() => undefined)
183 await trackSpawn($, started.agentId, judged.track).catch(() => undefined)
184 await reportToModel($, [...fix.findings, ...judged.findings], e.parentAgentId ?? null).catch(() => [])
185 // Past the fix cap the loop also gets a HITL inbox item; the spawn already ran.
186 await raiseFixCap($, fix.spawn, fix.findings, e.parentAgentId ?? null).catch(() => null)
187 }
188 return started
189 }).catch(($, e, next) => next(e))
190
191 on('turn.complete', async ($, e, next) => {
192 const done = await next(e)
193 await captureTurn($, e).catch(() => undefined)
194 // A gate's verdict may make an earlier builder edit stale.
195 if (e.agentId !== undefined) markTouched()
196 // A teammate's turn is one usage row on its pie, and the roster counts it.
197 if ((await captureTeammateTurn($, e).catch(() => null)) !== null) await refreshTeam($, { force: true }).catch(() => null)
198 const drift = await checkDrift($, e).catch(() => ({ findings: [], parent: null }))
199 await reportToModel($, drift.findings, drift.parent).catch(() => [])
200 return done
201 }).catch(($, e, next) => next(e))
202
203 // A baker skill's start, from whichever of three sources the session raises;
204 // skill.prompt passes over an installed plugin where an organization manages
205 // Claude Code, so the Skill tool and the typed command are read too.
206 on('skill.prompt', async ($, e, next) => {
207 const prompt = await next(e)
208 await trackSkill($, e.skill).catch(() => null)
209 // It names no loop: with a team, the Skill tool call records whose start it was.
210 if (!(await teamActive($).catch(() => false))) await captureSkill($, e.skill).catch(() => [])
211 return prompt
212 }).catch(($, e, next) => next(e))
213
214 // In a teammate's loop the start is that teammate's: its pie, its address on the rows.
215 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
216 const ran = await next(e)
217 if (ran.deny === undefined && ran.isError !== true) {
218 await trackSkill($, e.skill).catch(() => null)
219 const baker = await teammateOf($, e.agentId).catch(() => null)
220 await captureSkill($, e.skill, baker ? { teammateId: baker.teammateId, pie: baker.pie } : null).catch(() => [])
221 if (baker) await refreshTeam($, { force: true }).catch(() => null)
222 }
223 return ran
224 }).catch(($, e, next) => next(e))
225
226 // Observe only: a teammate going idle and a teammate's task done land in the
227 // ledger once next let them happen (a settings hook beneath may keep the
228 // teammate working, or refuse the completion: then nothing is recorded).
229 on('classic.TeammateIdle', async ($, e, next) => {
230 const out = await next(e)
231 if (out.block === undefined) await recordIdle($, e).catch(() => null)
232 return out
233 }).catch(($, e, next) => next(e))
234
235 on('classic.TaskCompleted', async ($, e, next) => {
236 const out = await next(e)
237 if (out.block === undefined) await recordDone($, e).catch(() => null)
238 return out
239 }).catch(($, e, next) => next(e))
240
241 on('classic.UserPromptExpansion', async ($, e, next) => {
242 const expanded = await next(e)
243 await trackSkill($, e.command_name).catch(() => null)
244 await captureSkill($, e.command_name).catch(() => [])
245 return expanded
246 }).catch(($, e, next) => next(e))
247
248 on('command.run', { command: 'pass' }, async ($, e) => {
249 const sub = e.args.trim().toLowerCase()
250 if (sub === 'replay' || sub.startsWith('replay ')) return { text: await openReplay($, e.args.trim().slice(6)) }
251 if (sub === 'reconcile') {
252 await refreshLedger($)
253 return { text: reconcileText(await read($, ledgerAtom)) }
254 }
255 if (sub === 'history') return { text: await historyText($) }
256 if (sub === 'checks') {
257 await refreshLedger($)
258 return { text: checksText(await read($, ledgerAtom), await $.clock.now().catch(() => Date.now())) }
259 }
260 return { text: await togglePane($) }
261 }).catch(() => ({
262 text: 'The Pass could not answer that command; nothing changed.',
263 }))
264
265 on('ui.render', { component: 'Pane', requestId: 'pass' }, async ($, e) => drawPane($, e)).catch(($, e, next) =>
266 next(e),
267 )
268
269 on('ui.render', { component: 'Pane', requestId: REPLAY_PANE }, async ($, e) => drawReplay($, e)).catch(($, e, next) =>
270 next(e),
271 )
272
273 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => (await drawBand($, e)) ?? next(e)).catch(
274 ($, e, next) => next(e),
275 )
276
277 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
278 const props = await hintProps($, e)
279 return props ? next({ ...e, props }) : next(e)
280 }).catch(($, e, next) => next(e))
281}
282mod/band.tsx 153 lines1// mod/band.tsx
2// The Pass band above the prompt (live slice status, agents, fix loop, open HITL count, the team's bakers, warning style on a failed verdict, a check warning or an open HITL), the pinned status line and the prompt hint.
3
4import { atom, read } from 'claude-code'
5import type { EngineInterface, RenderElement, RenderInput, RenderPropsOf } from 'claude-code'
6
7import { FIX_CAP, findSlice, lastVerdict } from './ledger-projection.ts'
8import { activePie, activeSlice, allDone, boxReady, counts } from './projection.ts'
9import { PANE_ID } from './rail.tsx'
10import { bakersWord } from './team.ts'
11import type { PassChecklist, PassLive, PassProjection, PassTeam, PassWarning } from './types'
12
13const checklistAtom = atom({ plugin: 'bytheslice', key: 'pass.checklist' } as const, null)
14const ledgerAtom = atom({ plugin: 'bytheslice', key: 'pass.ledger' } as const, null)
15const liveAtom = atom({ plugin: 'bytheslice', key: 'pass.live' } as const, { at: 0, agents: [] })
16const warningAtom = atom({ plugin: 'bytheslice', key: 'pass.warning' } as const, null)
17const teamAtom = atom({ plugin: 'bytheslice', key: 'pass.team' } as const, { at: 0, bakers: [] })
18const branchAtom = atom({ plugin: 'bytheslice', key: 'pass.branch' } as const, null)
19
20export const NARROW_COLUMNS = 60
21export const DOCK_COLUMNS = 110
22export const READY = '/box-it-up ready'
23
24export type BandLine = { text: string; dim: boolean; warn?: boolean }
25
26const ACTIVE = new Set(['pending', 'running', 'waiting'])
27
28/** Open HITL items the projection holds. */
29export const openHitl = (ledger: PassProjection | null): number => ledger?.hitl?.open.length ?? 0
30
31/**
32 * The band's one line for a checklist at a width, or null to leave the band to
33 * the engine. With the ledger it reads `Pie 5 · Slice 5.2 verifying · 3 agents
34 * · fix 1/3 · HITL 1`, in the warning style while the slice's latest verdict
35 * failed, while any HITL item is open, and for five minutes after a check
36 * warning, which it then names. While the team has bakers it adds `· 2 bakers`.
37 * With every pie done it adds `/box-it-up ready` only while a pie branch is checked out.
38 */
39export function bandLine(
40 c: PassChecklist | null,
41 bodyColumns: number,
42 ledger: PassProjection | null = null,
43 live: PassLive | null = null,
44 warning: PassWarning | null = null,
45 team: PassTeam | null = null,
46 branch: string | null = null,
47): BandLine | null {
48 if (!c) return null
49 const narrow = bodyColumns < NARROW_COLUMNS
50 const hitl = openHitl(ledger)
51 const bakers = team?.bakers.length ?? 0
52 const crew = bakers > 0 ? (narrow ? ` ${bakersWord(bakers)}` : ` · ${bakersWord(bakers)}`) : ''
53 const asks = (hitl > 0 ? (narrow ? ` HITL ${hitl}` : ` · HITL ${hitl}`) : '') + crew
54 if (c.layout === 'stage') return hitl > 0 ? { text: `v4 checklist${asks}`, dim: false, warn: true } : { text: 'v4 checklist', dim: true }
55 if (allDone(c)) return { text: `${narrow ? 'all done' : boxReady(c, branch) ? `All pies done · ${READY}` : 'All pies done'}${asks}`, dim: false, ...(hitl > 0 ? { warn: true } : {}) }
56 const pie = activePie(c)
57 const slice = activeSlice(c)
58 if (!pie || !slice) return null
59 if (!ledger) {
60 const { done, total } = counts(c)
61 return narrow
62 ? { text: `P${pie.n} S${slice.id} ${done}/${total}`, dim: false }
63 : { text: `Pie ${pie.n} · Slice ${slice.id} · ${done}/${total} done`, dim: false }
64 }
65 const view = findSlice(ledger, slice.id)
66 const status = view?.status ?? 'queued'
67 const agents = (live?.agents ?? []).filter(agent => ACTIVE.has(agent.status) && agent.teammateId === undefined).length
68 const fix = view?.fixLoop ?? 0
69 const warn = lastVerdict(view)?.overall === 'fail' || warning !== null || hitl > 0
70 if (narrow) {
71 const tail = fix > 0 ? ` fix ${fix}/${FIX_CAP}` : ''
72 return { text: `P${pie.n} S${slice.id} ${status}${tail}${asks}`, dim: false, warn }
73 }
74 const parts = [`Pie ${pie.n} · Slice ${slice.id} ${status}`]
75 if (agents > 0) parts.push(`${agents} agent${agents === 1 ? '' : 's'}`)
76 if (fix > 0) parts.push(`fix ${fix}/${FIX_CAP}`)
77 if (hitl > 0) parts.push(`HITL ${hitl}`)
78 if (bakers > 0) parts.push(bakersWord(bakers))
79 if (warning !== null) parts.push(`warn ${warning.code}`)
80 return { text: parts.join(' · '), dim: false, warn }
81}
82
83/** The pinned status line (`P5 S5.1 ✓0/8`), or undefined to clear it. */
84export function statusText(c: PassChecklist | null): string | undefined {
85 if (!c) return undefined
86 if (c.layout === 'stage') return 'v4 checklist'
87 if (allDone(c)) return '✓ all done'
88 const pie = activePie(c)
89 const slice = activeSlice(c)
90 if (!pie || !slice) return undefined
91 const { done, total } = counts(c)
92 return `P${pie.n} S${slice.id} ✓${done}/${total}`
93}
94
95/** Pins the status line for a checklist; called after every projection write. */
96export function pushStatus($: EngineInterface, c: PassChecklist | null): void {
97 try {
98 $.ui.status(statusText(c))
99 } catch {
100 // A status line that cannot be pinned is not worth failing the refresh for.
101 }
102}
103
104async function paneDocked($: EngineInterface, e: RenderInput<'AbovePrompt'>): Promise<boolean> {
105 if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true) return false
106 if ((e.viewport.columns ?? 0) < DOCK_COLUMNS) return false
107 try {
108 const panes = await $.ui.panes()
109 return panes.some(pane => pane.id === PANE_ID && pane.isPlaced && pane.isShown)
110 } catch {
111 return false
112 }
113}
114
115/**
116 * Draws the band, or resolves null so the hook returns `next(e)`: no checklist,
117 * a survey holding the band, or the pane already docked beside a wide
118 * fullscreen terminal.
119 */
120export async function drawBand($: EngineInterface, e: RenderInput<'AbovePrompt'>): Promise<RenderElement | null> {
121 if (e.props.hasSurvey) return null
122 const [c, ledger, live, warning, team, branch] = await Promise.all([
123 read($, checklistAtom),
124 read($, ledgerAtom),
125 read($, liveAtom),
126 read($, warningAtom),
127 read($, teamAtom),
128 read($, branchAtom),
129 ])
130 const line = bandLine(c, e.props.bodyColumns, ledger, live, warning, team, branch)
131 if (!line) return null
132 if (await paneDocked($, e)) return null
133 const { Text } = $.ui.resolve(e)
134 return line.warn === true ? <Text color="warning">{line.text}</Text> : <Text dimColor={line.dim}>{line.text}</Text>
135}
136
137/**
138 * The prompt hint's props with `/box-it-up ready` added once every slice of
139 * every pie is done and a pie branch is still checked out, or null to leave the hint as the engine has it. The
140 * terminal keeps its own line and takes the words as `tail`; other surfaces
141 * draw no tail yet, so there the words join the hint.
142 */
143export async function hintProps(
144 $: EngineInterface,
145 e: RenderInput<'PromptHint'>,
146): Promise<RenderPropsOf['PromptHint'] | null> {
147 const [c, branch] = await Promise.all([read($, checklistAtom), read($, branchAtom)])
148 if (!boxReady(c, branch)) return null
149 if (e.surface === 'terminal') return { ...e.props, tail: READY }
150 const hint = e.props.hint.trim()
151 return { ...e.props, hint: hint === '' ? READY : `${hint} · ${READY}` }
152}
153mod/capture.ts 598 lines1// mod/capture.ts
2// The Pass event capture: observe-only hooks that turn skills, verdicts, git results, turns, file edits (path and role, never content) and checklist flips into ledger rows.
3
4import { atom, read, update } from 'claude-code'
5import type {
6 AgentSpawnInput,
7 AgentSpawnResult,
8 EngineInterface,
9 ToolCallResult,
10 TurnCompleteInput,
11} from 'claude-code'
12
13import { stripJsonc } from './jsonc.ts'
14import { appendRow, readRows } from './ledger.ts'
15import type { LedgerInput } from './ledger.ts'
16import { gateOf, readActivity, rollup } from './ledger-projection.ts'
17import { refreshLedger } from './live.ts'
18import { activePie, activeSlice } from './projection.ts'
19import { toastRow } from './toasts.ts'
20import { relativePath } from './touched.ts'
21import type { LedgerRow, PassAgentMark, PassChecklist, PassGate } from './types'
22
23const checklistAtom = atom({ plugin: 'bytheslice', key: 'pass.checklist' } as const, null)
24const limitedAtom = atom({ plugin: 'bytheslice', key: 'pass.limited' } as const, false)
25const agentsAtom = atom({ plugin: 'bytheslice', key: 'pass.agents' } as const, {})
26
27/** How many fix targets a verdict row keeps, and how long each may be. */
28export const TARGETS_KEEP = 10
29export const TARGET_MAX = 200
30/** How much of a report the verdict parser reads. */
31export const ANSWER_MAX = 200000
32/** How many agent marks the spawn map keeps. */
33export const MARKS_KEEP = 200
34
35export type ParsedVerdict = {
36 overall: 'pass' | 'fail' | 'unparsed'
37 fixTargets: string[]
38 rerun: string[]
39 slice?: string
40}
41
42const oneLine = (value: string): string =>
43 Array.from(value.replace(/[\r\n\t]+/g, ' ').trim()).slice(0, TARGET_MAX).join('')
44
45/** The `P<pie> S<slice>` dispatch label a description starts with, or null. */
46export function labelOf(text: unknown): { pie: number; slice: string } | null {
47 if (typeof text !== 'string') return null
48 const match = /^P(\d+) S(\d+\.\d+)/.exec(text.trim())
49 return match ? { pie: Number(match[1]), slice: match[2] ?? '' } : null
50}
51
52/** The `feat(pie-N): N.M ...` attribution of a commit subject, or null. */
53export function subjectOf(subject: unknown): { pie: number; slice: string } | null {
54 if (typeof subject !== 'string') return null
55 const match = /^\w+\(pie-(\d+)\):\s*(\d+\.\d+)\b/.exec(subject.trim())
56 return match ? { pie: Number(match[1]), slice: match[2] ?? '' } : null
57}
58
59// Every balanced top-level `{...}` span that names "overall", last first.
60function objectSpans(text: string): string[] {
61 const spans: string[] = []
62 for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) {
63 let depth = 0
64 let inString = false
65 let closed = false
66 for (let i = start; i < text.length; i += 1) {
67 const ch = text[i]
68 if (inString) {
69 if (ch === '\\') i += 1
70 else if (ch === '"') inString = false
71 continue
72 }
73 if (ch === '"') inString = true
74 else if (ch === '{') depth += 1
75 else if (ch === '}') {
76 depth -= 1
77 if (depth === 0) {
78 const span = text.slice(start, i + 1)
79 if (span.includes('"overall"')) spans.push(span)
80 start = i
81 closed = true
82 break
83 }
84 }
85 }
86 // An object that never closes holds every later brace too: stop scanning.
87 if (!closed) break
88 }
89 return spans.reverse()
90}
91
92const strings = (value: unknown): string[] =>
93 Array.isArray(value)
94 ? value
95 .map(item => (typeof item === 'string' ? item : JSON.stringify(item) ?? ''))
96 .filter(item => item !== '')
97 .slice(0, TARGETS_KEEP)
98 .map(oneLine)
99 : []
100
101const gatesOf = (value: unknown): string[] =>
102 Array.isArray(value)
103 ? value
104 .map(item =>
105 typeof item === 'string'
106 ? item
107 : item && typeof item === 'object' && typeof (item as { gate?: unknown }).gate === 'string'
108 ? String((item as { gate: string }).gate)
109 : '',
110 )
111 .filter(item => item !== '')
112 .slice(0, 20)
113 .map(oneLine)
114 : []
115
116function fromObject(value: Record<string, unknown>): ParsedVerdict {
117 const overall = value.overall === 'pass' || value.overall === 'fail' ? value.overall : 'unparsed'
118 const slice = typeof value.slice === 'string' && /^\d+\.\d+$/.test(value.slice) ? value.slice : undefined
119 return {
120 overall,
121 fixTargets: strings(value.fix_targets),
122 rerun: gatesOf(value.rerun),
123 ...(slice === undefined ? {} : { slice }),
124 }
125}
126
127// A YAML block list under `key:` (`- "item"` lines), as the tester writes it.
128function yamlList(text: string, key: string): string[] {
129 const lines = text.split('\n')
130 const at = lines.findIndex(line => new RegExp(`^\\s*${key}\\s*:\\s*$`).test(line))
131 if (at === -1) return []
132 const out: string[] = []
133 for (const line of lines.slice(at + 1)) {
134 const item = /^\s*-\s+(.*)$/.exec(line)
135 if (!item) break
136 const raw = (item[1] ?? '').trim().replace(/^["']|["']$/g, '')
137 if (raw !== '') out.push(oneLine(raw))
138 if (out.length >= TARGETS_KEEP) break
139 }
140 return out
141}
142
143/**
144 * Reads a tester or verifier report for its verdict: the JSON (or JSONC)
145 * object naming `overall`, else YAML `overall:` lines. Never throws; a report
146 * it cannot read gives `overall: 'unparsed'`.
147 */
148export function parseVerdict(answer: unknown): ParsedVerdict {
149 const text = typeof answer === 'string' ? answer.slice(0, ANSWER_MAX) : ''
150 try {
151 for (const span of objectSpans(text)) {
152 try {
153 const parsed: unknown = JSON.parse(stripJsonc(span))
154 if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'overall' in parsed) {
155 return fromObject(parsed as Record<string, unknown>)
156 }
157 } catch {
158 // Not this span.
159 }
160 }
161 const overall = /^\s*overall\s*:\s*["']?(pass|fail)\b/im.exec(text)?.[1]
162 if (overall === 'pass' || overall === 'fail') {
163 const slice = /^\s*slice\s*:\s*["']?(\d+\.\d+)/im.exec(text)?.[1]
164 return {
165 overall,
166 fixTargets: yamlList(text, 'fix_targets'),
167 rerun: yamlList(text, 'rerun'),
168 ...(slice === undefined ? {} : { slice }),
169 }
170 }
171 } catch {
172 // Falls through to unparsed.
173 }
174 return { overall: 'unparsed', fixTargets: [], rerun: [] }
175}
176
177export type GitIntent = { commit: boolean; push: boolean; merge: 'pr' | 'git' | null; pr: number | null }
178
179// Shell segments split on control operators; quoting is not modeled, which only
180// errs toward reading a quoted `git commit` as a command (the result decides).
181function segments(command: string): string[][] {
182 return command
183 .split(/&&|\|\||[;|\n]/)
184 .map(part => part.trim().split(/\s+/).filter(word => word !== ''))
185 .filter(words => words.length > 0)
186}
187
188const GIT_OPTS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace'])
189
190const WRAPPERS = new Set(['env', 'command', 'nice', 'time', 'exec', 'sudo'])
191
192// The command word of a segment: past `(`, `VAR=value` assignments and wrappers.
193function commandAt(words: string[]): number {
194 let i = 0
195 while (i < words.length) {
196 const word = (words[i] ?? '').replace(/^\(+/, '')
197 if (word === '' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || WRAPPERS.has(word)) i += 1
198 else return i
199 }
200 return -1
201}
202
203const named = (word: string | undefined, name: string): boolean => {
204 const bare = (word ?? '').replace(/^\(+/, '')
205 return bare === name || bare.endsWith(`/${name}`)
206}
207
208function gitSubcommand(words: string[]): string | null {
209 let i = commandAt(words)
210 if (i === -1 || !named(words[i], 'git')) return null
211 for (i += 1; i < words.length; i += 1) {
212 const word = words[i] ?? ''
213 if (GIT_OPTS_WITH_VALUE.has(word)) i += 1
214 else if (!word.startsWith('-')) return word
215 }
216 return null
217}
218
219/** Which git or gh steps a Bash command takes: commit, push, a PR merge or a git merge. */
220export function gitIntent(command: unknown): GitIntent {
221 const intent: GitIntent = { commit: false, push: false, merge: null, pr: null }
222 if (typeof command !== 'string') return intent
223 for (const words of segments(command)) {
224 const sub = gitSubcommand(words)
225 if (sub === 'commit') intent.commit = true
226 if (sub === 'push') intent.push = true
227 if (sub === 'merge') intent.merge = intent.merge ?? 'git'
228 const gh = commandAt(words)
229 if (gh !== -1 && named(words[gh], 'gh')) {
230 const rest = words.slice(gh + 1)
231 const pr = rest.indexOf('pr')
232 if (pr !== -1 && rest[pr + 1] === 'merge') {
233 intent.merge = 'pr'
234 const number = rest.slice(pr + 2).find(word => /^#?\d+$/.test(word))
235 if (number) intent.pr = Number(number.replace('#', ''))
236 }
237 }
238 }
239 return intent
240}
241
242/** The structured git record core attaches to a Bash result, when it did. */
243type GitOperation = {
244 commit?: { sha?: string; kind?: string; branch?: string }
245 push?: { branch?: string }
246 branch?: { ref?: string; action?: string }
247 pr?: { number?: number; action?: string }
248}
249
250async function isLimited($: EngineInterface): Promise<boolean> {
251 try {
252 return await read($, limitedAtom)
253 } catch {
254 return false
255 }
256}
257
258async function checklistOf($: EngineInterface): Promise<PassChecklist | null> {
259 try {
260 return await read($, checklistAtom)
261 } catch {
262 return null
263 }
264}
265
266// Capture runs only in a ByTheSlice project on a supported engine: a repo with
267// no master checklist never gets a .bytheslice folder from a turn or a commit.
268async function idle($: EngineInterface): Promise<boolean> {
269 return (await isLimited($)) || (await checklistOf($)) === null
270}
271
272async function activeWhere($: EngineInterface): Promise<{ pie?: number; slice?: string }> {
273 const c = await checklistOf($)
274 const pie = activePie(c)
275 const slice = activeSlice(c)
276 return { ...(pie ? { pie: pie.n } : {}), ...(slice ? { slice: slice.id } : {}) }
277}
278
279async function git($: EngineInterface, argv: string[]): Promise<string | null> {
280 try {
281 const run = await $.process.run(['git', ...argv], { timeoutMs: 5000 })
282 return run.exitCode === 0 ? run.stdout : null
283 } catch {
284 return null
285 }
286}
287
288/** HEAD's sha, branch and the pie and slice its subject names; any part may be missing. */
289async function headFacts($: EngineInterface): Promise<{ sha?: string; branch?: string; pie?: number; slice?: string }> {
290 const out = await git($, ['log', '-1', '--format=%H%n%D%n%s'])
291 if (out === null) return {}
292 const [sha = '', refs = '', subject = ''] = out.split('\n')
293 const branch = /HEAD -> ([^,\s]+)/.exec(refs)?.[1]
294 const named = subjectOf(subject)
295 return {
296 ...(/^[0-9a-f]{7,64}$/.test(sha.trim()) ? { sha: sha.trim() } : {}),
297 ...(branch ? { branch } : {}),
298 ...(named ?? {}),
299 }
300}
301
302/** Appends a row, then toasts it once and projects the ledger again. Resolves the row, or null. */
303export async function record($: EngineInterface, row: LedgerInput): Promise<LedgerRow | null> {
304 const written = await appendRow($, row)
305 if (written) {
306 await toastRow($, written)
307 await refreshLedger($)
308 }
309 return written
310}
311
312/** `agent.spawn`, after `next`: keeps the started agent's type and its label's pie and slice; a teammate's address and name too. */
313export async function markSpawn($: EngineInterface, e: AgentSpawnInput, result: AgentSpawnResult): Promise<void> {
314 if (result.deny !== undefined || typeof result.agentId !== 'string' || (await isLimited($))) return
315 const label = labelOf(e.description)
316 const teammateId = typeof result.teammateId === 'string' && result.teammateId !== '' ? result.teammateId : null
317 // A teammate bakes its own pie: only its label names it (else its first slice.started row does), never the active pie.
318 const where: { pie?: number; slice?: string } = label ?? (teammateId === null ? await activeWhere($) : {})
319 let at = Date.now()
320 try {
321 at = await $.clock.now()
322 } catch {
323 // Wall clock it is.
324 }
325 const name = typeof e.name === 'string' && e.name !== '' ? e.name : teammateId?.split('@')[0]
326 const mark: PassAgentMark = {
327 type: e.subagentType,
328 pie: where.pie ?? null,
329 slice: where.slice ?? null,
330 at,
331 ...(teammateId === null ? {} : { teammateId, ...(name ? { name } : {}) }),
332 }
333 const id = result.agentId
334 await update($, agentsAtom, marks => {
335 const kept = Object.entries(marks ?? {}).slice(-(MARKS_KEEP - 1))
336 return { ...Object.fromEntries(kept), [id]: mark }
337 }).catch(() => undefined)
338}
339
340async function typeOf($: EngineInterface, agentId: string): Promise<PassAgentMark | null> {
341 const marks = await read($, agentsAtom).catch(() => ({}) as Record<string, PassAgentMark>)
342 const mark = marks[agentId]
343 if (mark) return mark
344 try {
345 const listed = (await $.agent.list()).find(agent => agent.id === agentId)
346 return listed ? { type: listed.type, pie: labelOf(listed.description)?.pie ?? null, slice: labelOf(listed.description)?.slice ?? null, at: 0 } : null
347 } catch {
348 return null
349 }
350}
351
352/** A tester or verifier report becomes one verdict row: gate, overall, fix targets, re-runs and HEAD. */
353export async function captureVerdict($: EngineInterface, e: TurnCompleteInput): Promise<LedgerRow | null> {
354 if (typeof e.agentId !== 'string') return null
355 const mark = await typeOf($, e.agentId)
356 const gate: PassGate | null = gateOf(mark?.type)
357 if (!mark || !gate) return null
358 const verdict = parseVerdict(e.answer)
359 const head = (await git($, ['rev-parse', 'HEAD']))?.trim()
360 const fallback = await activeWhere($)
361 const slice = mark.slice ?? verdict.slice ?? fallback.slice
362 const pie = mark.pie ?? (slice !== undefined ? Number(slice.split('.')[0]) : fallback.pie)
363 return record($, {
364 ev: 'verdict',
365 ...(pie === undefined || Number.isNaN(pie) ? {} : { pie }),
366 ...(slice === undefined ? {} : { slice }),
367 aid: e.agentId,
368 agent: mark.type,
369 data: {
370 gate,
371 overall: verdict.overall,
372 fix_targets: verdict.fixTargets,
373 rerun: verdict.rerun,
374 head: head && /^[0-9a-f]{7,64}$/.test(head) ? head : null,
375 },
376 })
377}
378
379/** A main-loop turn becomes one usage row: its id, duration, tokens, model and the session's cost so far. */
380export async function captureUsage($: EngineInterface, e: TurnCompleteInput): Promise<LedgerRow | null> {
381 if (e.agentId !== undefined) return null
382 let costUsd: number | null = null
383 try {
384 const usd = (await $.session.usage()).cost?.usd
385 costUsd = typeof usd === 'number' && Number.isFinite(usd) ? usd : null
386 } catch {
387 costUsd = null
388 }
389 const usage = e.usage
390 return record($, {
391 ev: 'usage',
392 ...(await activeWhere($)),
393 data: {
394 turnId: e.turnId,
395 durationMs: e.durationMs,
396 tokens: usage
397 ? {
398 input_tokens: usage.input_tokens,
399 output_tokens: usage.output_tokens,
400 cache_read_input_tokens: usage.cache_read_input_tokens,
401 cache_creation_input_tokens: usage.cache_creation_input_tokens,
402 }
403 : null,
404 model: usage?.model ?? null,
405 costUsd,
406 },
407 })
408}
409
410/** `turn.complete`, after `next`: a gate's report is a verdict, a main-loop turn is usage. */
411export async function captureTurn($: EngineInterface, e: TurnCompleteInput): Promise<void> {
412 if (await idle($)) return
413 if (e.agentId === undefined) await captureUsage($, e)
414 else await captureVerdict($, e)
415}
416
417/**
418 * A Bash result, after `next`: a `git commit`, `git push`, `gh pr merge` or
419 * `git merge` that ran without error becomes a row. A failed git read still
420 * records the row, without a sha.
421 */
422export async function captureBash(
423 $: EngineInterface,
424 command: unknown,
425 ran: ToolCallResult,
426): Promise<LedgerRow[]> {
427 if (ran.deny !== undefined || ran.isError === true || (await idle($))) return []
428 const intent = gitIntent(command)
429 if (!intent.commit && !intent.push && intent.merge === null) return []
430 const result = (ran.result ?? {}) as { interrupted?: unknown; backgroundTaskId?: unknown; gitOperation?: GitOperation }
431 if (result.interrupted === true || typeof result.backgroundTaskId === 'string') return []
432 const op = result.gitOperation ?? {}
433 const facts = await headFacts($)
434 const active = await activeWhere($)
435 const where = facts.slice !== undefined ? { pie: facts.pie, slice: facts.slice } : active
436 const rows: LedgerRow[] = []
437 const push = async (row: LedgerInput) => {
438 const written = await record($, row)
439 if (written) rows.push(written)
440 }
441 if (intent.commit) {
442 // Core's record may carry an abbreviated sha: the full one wins when it extends it.
443 const recorded = op.commit?.sha
444 const sha = recorded === undefined || (facts.sha?.startsWith(recorded) ?? false) ? facts.sha ?? recorded : recorded
445 const branch = op.commit?.branch ?? facts.branch
446 await push({
447 ev: 'commit',
448 ...where,
449 data: { ...(sha ? { sha } : {}), ...(branch ? { branch } : {}), ...(op.commit?.kind ? { kind: op.commit.kind } : {}) },
450 })
451 }
452 if (intent.push) {
453 const branch = op.push?.branch ?? facts.branch
454 await push({ ev: 'push', ...where, data: { ...(facts.sha ? { sha: facts.sha } : {}), ...(branch ? { branch } : {}) } })
455 }
456 if (intent.merge !== null) {
457 const pr = op.pr?.action === 'merged' && typeof op.pr.number === 'number' ? op.pr.number : intent.pr
458 await push({
459 ev: 'merge',
460 ...active,
461 data: {
462 kind: intent.merge,
463 ...(pr !== null ? { pr } : {}),
464 ...(facts.branch ? { branch: facts.branch } : {}),
465 ...(facts.sha ? { sha: facts.sha } : {}),
466 },
467 })
468 }
469 return rows
470}
471
472const BAKERS = /(^|:)(sell-pie|sell-slice)$/
473
474/** The teammate a baker skill started in: its address and the pie its spawn label named. */
475export type SkillBaker = { teammateId: string; pie: number | null }
476
477/**
478 * A `/sell-pie` or `/sell-slice` run (its skill prompt, its Skill tool call or
479 * its typed command): records `pie.started` and `slice.started` for the active
480 * pie and slice, each only the first time, so the three sources never double up.
481 * Started in a teammate's loop (`baker`), it records that teammate's pie (its
482 * spawn label's, else the active pie) and that pie's first open slice, each row
483 * carrying `data.teammateId`; a slice already started by that teammate is not
484 * recorded again.
485 */
486export async function captureSkill($: EngineInterface, name: unknown, baker: SkillBaker | null = null): Promise<LedgerRow[]> {
487 if (typeof name !== 'string' || !BAKERS.test(name.trim()) || (await isLimited($))) return []
488 const c = await checklistOf($)
489 const own = baker?.pie == null ? null : c?.pies.find(entry => entry.n === baker.pie && entry.slices.some(s => !s.done)) ?? null
490 const pie = own ?? activePie(c)
491 const slice = own ? own.slices.find(s => !s.done) ?? null : activeSlice(c)
492 if (!pie) return []
493 const rows = await readRows($)
494 const skill = name.trim().slice(name.trim().lastIndexOf(':') + 1)
495 const who = baker ? { teammateId: baker.teammateId } : {}
496 const out: LedgerRow[] = []
497 if (!rows.some(row => row.ev === 'pie.started' && row.pie === pie.n)) {
498 const written = await record($, { ev: 'pie.started', pie: pie.n, data: { skill, ...who } })
499 if (written) out.push(written)
500 }
501 const started = (row: LedgerRow): boolean =>
502 row.ev === 'slice.started' && row.slice === slice?.id && (baker === null || row.data?.teammateId === baker.teammateId)
503 if (slice && !rows.some(started)) {
504 // The head it starts at is where its touched files and replay count from.
505 const head = (await git($, ['rev-parse', 'HEAD']))?.trim()
506 const at = head && /^[0-9a-f]{7,64}$/.test(head) ? { head } : {}
507 const written = await record($, { ev: 'slice.started', pie: pie.n, slice: slice.id, data: { skill, ...who, ...at } })
508 if (written) out.push(written)
509 }
510 return out
511}
512
513/** Slice ids the checklist shows done now that it did not before. */
514export function flipped(before: PassChecklist | null, after: PassChecklist | null): { pie: number; slice: string }[] {
515 if (!before || !after) return []
516 const was = new Set(before.pies.flatMap(pie => pie.slices.filter(slice => slice.done).map(slice => slice.id)))
517 const known = new Set(before.pies.flatMap(pie => pie.slices.map(slice => slice.id)))
518 return after.pies.flatMap(pie =>
519 pie.slices.filter(slice => slice.done && !was.has(slice.id) && known.has(slice.id)).map(slice => ({ pie: pie.n, slice: slice.id })),
520 )
521}
522
523/**
524 * After a checklist refresh: each slice that flipped to done gets one
525 * `slice.done` row (with its last slice-verifier verdict, if any) and one
526 * `slice.rollup` row from the activity log, so history survives rotation.
527 */
528export async function recordDoneFlips(
529 $: EngineInterface,
530 before: PassChecklist | null,
531 after: PassChecklist | null,
532): Promise<LedgerRow[]> {
533 const flips = flipped(before, after)
534 if (flips.length === 0 || (await isLimited($))) return []
535 const rows = await readRows($)
536 const activity = await readActivity($)
537 const out: LedgerRow[] = []
538 for (const { pie, slice } of flips) {
539 if (rows.some(row => row.ev === 'slice.done' && row.slice === slice)) continue
540 const verifier = [...rows]
541 .reverse()
542 .find(row => row.ev === 'verdict' && row.slice === slice && row.data?.gate === 'slice-verifier')
543 const done = await record($, {
544 ev: 'slice.done',
545 pie,
546 slice,
547 data: {
548 verdict: verifier
549 ? { overall: verifier.data?.overall ?? 'unparsed', head: verifier.data?.head ?? null, t: verifier.t }
550 : null,
551 },
552 })
553 if (done) out.push(done)
554 const totals = rollup(activity, slice)
555 const rolled = await record($, { ev: 'slice.rollup', pie, slice, data: totals })
556 if (rolled) out.push(rolled)
557 }
558 return out
559}
560
561/**
562 * An `Edit` or `Write` that ran without error becomes one `edit` row: the
563 * workspace-relative path, the agent id and its role from the spawn map
564 * (`main` for the main loop) and the tool. Never the file's content, the old
565 * or new string, or the result. A path outside the workspace is not recorded.
566 * Appended without a projection read, since edits come often.
567 */
568export async function captureEdit(
569 $: EngineInterface,
570 e: { file_path?: unknown; agentId?: string | undefined },
571 tool: string,
572 ran: ToolCallResult,
573): Promise<LedgerRow | null> {
574 if (ran.deny !== undefined || ran.isError === true || (await idle($))) return null
575 let cwd = '.'
576 try {
577 cwd = await $.session.cwd()
578 } catch {
579 // Relative paths still record.
580 }
581 const path = relativePath(e.file_path, cwd)
582 if (path === null) return null
583 const aid = typeof e.agentId === 'string' && e.agentId !== '' ? e.agentId : undefined
584 const mark = aid === undefined ? null : await typeOf($, aid)
585 const agent = mark ? mark.type.slice(mark.type.lastIndexOf(':') + 1) : aid === undefined ? 'main' : 'agent'
586 const active = await activeWhere($)
587 const slice = mark?.slice ?? active.slice
588 const pie = mark?.pie ?? (slice !== undefined ? Number(slice.split('.')[0]) : active.pie)
589 return appendRow($, {
590 ev: 'edit',
591 ...(pie === undefined || pie === null || Number.isNaN(pie) ? {} : { pie }),
592 ...(slice === undefined ? {} : { slice }),
593 ...(aid === undefined ? {} : { aid }),
594 agent,
595 data: { path, ...(aid === undefined ? {} : { aid }), agent, tool },
596 })
597}
598mod/checks.ts 278 lines1// mod/checks.ts
2// The Pass warning plumbing: report a finding (warn row, toast, band style, model note), the atoms checks read, and the policy at session start.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Timer, ToolCallResult } from 'claude-code'
6
7import { appendRow } from './ledger.ts'
8import type { LedgerRow } from './ledger.ts'
9import { refreshLedger } from './live.ts'
10import { DEFAULT_POLICY, loadPolicy } from './policy.ts'
11import type { PassPolicy, PassWarning } from './types'
12
13const checklistAtom = atom({ plugin: 'bytheslice', key: 'pass.checklist' } as const, null)
14const limitedAtom = atom({ plugin: 'bytheslice', key: 'pass.limited' } as const, false)
15const headAtom = atom({ plugin: 'bytheslice', key: 'pass.head' } as const, null)
16const branchAtom = atom({ plugin: 'bytheslice', key: 'pass.branch' } as const, null)
17const skillAtom = atom({ plugin: 'bytheslice', key: 'pass.activeSkill' } as const, null)
18const policyAtom = atom({ plugin: 'bytheslice', key: 'pass.policy' } as const, null)
19const reportedAtom = atom({ plugin: 'bytheslice', key: 'pass.reported' } as const, {})
20const warningAtom = atom({ plugin: 'bytheslice', key: 'pass.warning' } as const, null)
21
22/** One thing a check noticed. `key` names the instance: the same code and key report once a minute. */
23export type Finding = { code: string; text: string; slice?: string; key: string }
24
25/** A code and key report at most once in this window. */
26export const REPORT_WINDOW_MS = 60000
27/** How long the band keeps the warning style after a finding. */
28export const WARN_BAND_MS = 5 * 60 * 1000
29export const WARN_TOAST_MS = 8000
30/** How often the band's warning style is checked for expiry. */
31export const WARN_SWEEP_MS = 15000
32export const REPORTED_KEEP = 200
33const TOAST_MAX = 240
34
35/** The ByTheSlice skills whose start the Pass tracks (the same list hooks/lib/checklist.sh detects). */
36export const SKILLS: ReadonlySet<string> = new Set([
37 'sell-pie',
38 'sell-slice',
39 'box-it-up',
40 'cook-pizzas',
41 'special-order',
42 'run-the-day',
43 'inspect-display',
44 'close-shop',
45 'setup-shop',
46 'set-display-case',
47 'open-the-shop',
48 'create-menu',
49 'final-quality-check',
50])
51
52/** A ByTheSlice skill's bare name from a skill, Skill tool or typed command name, or null for any other. */
53export function skillName(name: unknown): string | null {
54 if (typeof name !== 'string') return null
55 const bare = name.trim().replace(/^\//, '').replace(/^[\w-]*bytheslice[\w-]*:/, '')
56 return SKILLS.has(bare) ? bare : null
57}
58
59/** True when two shas name the same commit: one extends the other, at seven characters or more. */
60export function headsMatch(a: string | null | undefined, b: string | null | undefined): boolean {
61 if (typeof a !== 'string' || typeof b !== 'string') return false
62 const [short, long] = a.length <= b.length ? [a, b] : [b, a]
63 return short.length >= 7 && long.startsWith(short)
64}
65
66/** A sha cut to seven characters, or `unknown`. */
67export const short = (sha: string | null | undefined): string => (typeof sha === 'string' && sha !== '' ? sha.slice(0, 7) : 'unknown')
68
69/** The pie a slice id belongs to (`5.3` reads 5), or undefined. */
70export function pieOf(slice: string | undefined): number | undefined {
71 if (slice === undefined) return undefined
72 const n = Number(slice.split('.')[0])
73 return Number.isFinite(n) ? n : undefined
74}
75
76/** What the model reads for a finding, after the tool result or as a hidden note. */
77export function modelText(finding: Finding): string {
78 return `The Pass warning (${finding.code}): ${finding.text} Nothing was blocked.`
79}
80
81/** The toast line for a finding: its code, its slice and its text, cut short. */
82export function toastText(finding: Finding): string {
83 const where = finding.slice === undefined ? '' : ` · Slice ${finding.slice}`
84 const line = `Pass ${finding.code}${where}: ${finding.text}`
85 const chars = Array.from(line)
86 return chars.length <= TOAST_MAX ? line : `${chars.slice(0, TOAST_MAX - 1).join('').trimEnd()}…`
87}
88
89async function nowOf($: EngineInterface): Promise<number> {
90 try {
91 return await $.clock.now()
92 } catch {
93 return Date.now()
94 }
95}
96
97/**
98 * The policy when checks run: null while the engine is limited, while there is
99 * no master checklist (a repo the Pass does not serve) or when the config
100 * turned checks off. Reads atoms only.
101 */
102export async function checksOn($: EngineInterface): Promise<PassPolicy | null> {
103 try {
104 if (await read($, limitedAtom)) return null
105 if ((await read($, checklistAtom)) === null) return null
106 const policy = (await read($, policyAtom)) ?? DEFAULT_POLICY
107 return policy.enabled ? policy : null
108 } catch {
109 return null
110 }
111}
112
113/**
114 * Reports a finding: one `warn` ledger row `{ code, text }`, one toast, the
115 * band's warning style for five minutes, and the text the model should read.
116 * The same code and key report at most once a minute: a repeat resolves null.
117 */
118export async function report($: EngineInterface, finding: Finding): Promise<string | null> {
119 const now = await nowOf($)
120 const id = `${finding.code}|${finding.key}`
121 try {
122 const seen = await read($, reportedAtom)
123 const last = seen[id]
124 if (last !== undefined && now - last < REPORT_WINDOW_MS) return null
125 await update($, reportedAtom, marks => {
126 const kept = Object.entries(marks ?? {}).filter(([key]) => key !== id).slice(-(REPORTED_KEEP - 1))
127 return { ...Object.fromEntries(kept), [id]: now }
128 })
129 } catch {
130 // Without the dedupe atom the finding still reports.
131 }
132 const pie = pieOf(finding.slice)
133 await appendRow($, {
134 ev: 'warn',
135 ...(pie === undefined ? {} : { pie }),
136 ...(finding.slice === undefined ? {} : { slice: finding.slice }),
137 data: { code: finding.code, text: finding.text },
138 })
139 try {
140 $.ui.toast(toastText(finding), { timeoutMs: WARN_TOAST_MS })
141 } catch {
142 // A toast that cannot show still leaves the row and the model's text.
143 }
144 const warning: PassWarning = { code: finding.code, slice: finding.slice ?? null, t: now, until: now + WARN_BAND_MS }
145 await update($, warningAtom, () => warning).catch(() => undefined)
146 await refreshLedger($)
147 return modelText(finding)
148}
149
150/** Reports each finding in order; resolves the texts that were not repeats. */
151export async function reportAll($: EngineInterface, findings: readonly Finding[]): Promise<string[]> {
152 const texts: string[] = []
153 for (const finding of findings) {
154 const text = await report($, finding).catch(() => null)
155 if (text !== null) texts.push(text)
156 }
157 return texts
158}
159
160/**
161 * A tool's result with each finding's text added to `context`, which the model
162 * reads after the result. A refused or failed call is returned as it came, with
163 * nothing reported; so is every result when reporting fails.
164 */
165export async function warnResult<R extends ToolCallResult>($: EngineInterface, ran: R, findings: readonly Finding[]): Promise<R> {
166 if (findings.length === 0 || ran.deny !== undefined || ran.isError === true) return ran
167 try {
168 const texts = await reportAll($, findings)
169 if (texts.length === 0) return ran
170 return { ...ran, context: [...(ran.context ?? []), ...texts] }
171 } catch {
172 return ran
173 }
174}
175
176/**
177 * Appends a hidden note the model reads (a user row it never typed) to the
178 * loop that spawned an agent, or to the main conversation; when that loop has
179 * ended, to the main one. Resolves whether a note was stored.
180 */
181export async function noteModel($: EngineInterface, text: string, agentId: string | null = null): Promise<boolean> {
182 const message = { type: 'user' as const, content: [{ type: 'text' as const, text }] }
183 for (const target of agentId === null ? [null] : [agentId, null]) {
184 try {
185 const stored = await $.session.append(target === null ? { message } : { message, agentId: target })
186 if ((stored as { deny?: unknown }).deny === undefined) return true
187 } catch {
188 // A loop that ended refuses the row; the main conversation is tried next.
189 }
190 }
191 return false
192}
193
194/** Reports findings a spawn or a turn raised and hands each new one to the model as a hidden note. */
195export async function reportToModel($: EngineInterface, findings: readonly Finding[], agentId: string | null): Promise<string[]> {
196 const texts = await reportAll($, findings)
197 for (const text of texts) await noteModel($, text, agentId)
198 return texts
199}
200
201async function git($: EngineInterface, argv: string[]): Promise<string | null> {
202 try {
203 const run = await $.process.run(['git', ...argv], { timeoutMs: 5000 })
204 const out = run.stdout.trim()
205 return run.exitCode === 0 && out !== '' ? out : null
206 } catch {
207 return null
208 }
209}
210
211/** Reads HEAD's sha into `pass.head`; resolves it, or null outside git. */
212export async function readHead($: EngineInterface): Promise<string | null> {
213 const sha = await git($, ['rev-parse', 'HEAD'])
214 const head = sha !== null && /^[0-9a-f]{7,64}$/.test(sha) ? sha : null
215 await update($, headAtom, () => head).catch(() => undefined)
216 return head
217}
218
219async function readBranch($: EngineInterface): Promise<string | null> {
220 const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])
221 await update($, branchAtom, () => branch).catch(() => undefined)
222 return branch
223}
224
225// Git steps after which HEAD or the branch may have moved.
226const MOVES = /\bgit\b[^;&|\n]*?\s(checkout|switch|worktree|merge|rebase|reset|pull|cherry-pick|revert|am|stash)\b/
227
228/**
229 * After a Bash command ran: a commit row has HEAD read again in the session's
230 * directory (a row's own sha may name a commit made elsewhere through
231 * `git -C`), and a command that may move HEAD or the branch (checkout, switch,
232 * worktree, merge, rebase, reset, pull, ...) has both read again. Off the hot path.
233 */
234export async function trackGit($: EngineInterface, command: unknown, rows: readonly LedgerRow[]): Promise<void> {
235 const moved = typeof command === 'string' && MOVES.test(command)
236 if (moved || rows.some(row => row.ev === 'commit')) await readHead($)
237 if (moved) await readBranch($)
238}
239
240/** A ByTheSlice skill's start becomes `pass.activeSkill`; any other skill leaves it as it was. */
241export async function trackSkill($: EngineInterface, name: unknown): Promise<string | null> {
242 const skill = skillName(name)
243 if (skill !== null) await update($, skillAtom, () => skill).catch(() => undefined)
244 return skill
245}
246
247let sweep: Timer | null = null
248
249/** Every 15 seconds: clears the band's warning style once its five minutes are up. */
250export async function startWarningSweep($: EngineInterface): Promise<void> {
251 sweep?.cancel()
252 try {
253 sweep = $.clock.every(WARN_SWEEP_MS, () => {
254 void (async () => {
255 const warning = await read($, warningAtom)
256 if (warning === null || (await nowOf($)) < warning.until) return
257 await update($, warningAtom, () => null)
258 })().catch(() => undefined)
259 })
260 } catch {
261 sweep = null
262 }
263}
264
265/**
266 * At session start, after the checklist was read: HEAD into `pass.head`, the
267 * policy read once into `pass.policy` (its overrides recorded when this is a
268 * ByTheSlice project), and the band's warning sweep.
269 */
270export async function primeChecks($: EngineInterface): Promise<PassPolicy> {
271 await readHead($)
272 const served = (await read($, checklistAtom).catch(() => null)) !== null
273 const policy = await loadPolicy($, { record: served }).catch(() => DEFAULT_POLICY)
274 await update($, policyAtom, () => policy).catch(() => undefined)
275 await startWarningSweep($)
276 return policy
277}
278mod/checks-checklist.ts 269 lines1// mod/checks-checklist.ts
2// Pass checks on typed tool input that warn and never block: a slice marked [x] without a verdict, a push of an unverified done slice, a plan write during sell-slice.
3
4import { atom, read } from 'claude-code'
5import type { EngineInterface } from 'claude-code'
6
7import { gitIntent } from './capture.ts'
8import { checklistText, isChecklistPath } from './projection.ts'
9import { parseChecklist } from './checklist.ts'
10import { checksOn, headsMatch, short } from './checks.ts'
11import type { Finding } from './checks.ts'
12import { findSlice } from './ledger-projection.ts'
13import type { PassChecklist, PassPolicy, PassProjection, PassSliceView, PassVerdict } from './types'
14
15const checklistAtom = atom({ plugin: 'bytheslice', key: 'pass.checklist' } as const, null)
16const ledgerAtom = atom({ plugin: 'bytheslice', key: 'pass.ledger' } as const, null)
17const headAtom = atom({ plugin: 'bytheslice', key: 'pass.head' } as const, null)
18const branchAtom = atom({ plugin: 'bytheslice', key: 'pass.branch' } as const, null)
19const skillAtom = atom({ plugin: 'bytheslice', key: 'pass.activeSkill' } as const, null)
20
21/** Slice types that want a slice-tester pass before `[x]` under the default policy. */
22export const UI_TYPES: ReadonlySet<string> = new Set(['frontend', 'full-stack'])
23/**
24 * The skills during which slice plans stay static: sell-slice alone, the rule
25 * the retired stage plan Bash hook held. sell-pie appends build notes to stage
26 * files by design, so a plan write there is normal and silent.
27 */
28export const BAKERS: ReadonlySet<string> = new Set(['sell-slice'])
29
30const PLAN_PATH = /(^|[\\/])docs[\\/]plans[\\/]stage_[^\\/]*$/
31const PIE_BRANCH = /(^|\/)pie[-/]\d+/
32
33/** What an Edit or a Write does to a file's text. */
34export type FileChange = { kind: 'write'; content: string } | { kind: 'edit'; old: string; next: string; all: boolean }
35
36/** A slice the change marks done that was open before, with its type when the checklist names one. */
37export type Flip = { id: string; type?: string }
38
39/** The change an Edit or Write input makes, or null for an input without the text. */
40export function changeOf(e: Record<string, unknown>): FileChange | null {
41 if (typeof e.content === 'string') return { kind: 'write', content: e.content }
42 if (typeof e.old_string === 'string' && typeof e.new_string === 'string') {
43 return { kind: 'edit', old: e.old_string, next: e.new_string, all: e.replace_all === true }
44 }
45 return null
46}
47
48const doneMap = (c: PassChecklist | null): Map<string, boolean> =>
49 new Map((c?.pies ?? []).flatMap(pie => pie.slices.map(slice => [slice.id, slice.done] as const)))
50
51const typeOf = (c: PassChecklist | null, id: string): string | undefined => {
52 for (const pie of c?.pies ?? []) {
53 const found = pie.slices.find(slice => slice.id === id)
54 if (found) return found.type
55 }
56 return undefined
57}
58
59// A slice heading or a Prep line, and the box it carries.
60const SLICE_LINE = /^\s*(?:#{2,3}\s+(?:\[[ xX-]\]\s*)?(?:~~)?Slice\s+(\d+\.\d+)\b|(?:-\s*)?\[[ xX]\]\s+Slice\s+(\d+\.\d+)\b)/i
61
62/** Each slice heading or Prep line in a piece of checklist text: its id and whether its box reads `[x]`. */
63export function boxMarks(text: string): Map<string, boolean> {
64 const marks = new Map<string, boolean>()
65 for (const line of text.split(/\r?\n/)) {
66 const match = SLICE_LINE.exec(line)
67 const id = match?.[1] ?? match?.[2]
68 if (id !== undefined) marks.set(id, /\[[xX]\]/.test(line))
69 }
70 return marks
71}
72
73// Boxes ticked in `after` that were open in `before`; a slice `before` does not
74// show counts when the checklist atom has it open.
75function ticked(before: Map<string, boolean>, after: Map<string, boolean>, was: Map<string, boolean>): string[] {
76 const ids: string[] = []
77 for (const [id, checked] of after) {
78 const open = before.has(id) ? before.get(id) === false : was.get(id) === false
79 if (checked && open) ids.push(id)
80 }
81 return ids
82}
83
84// Slices the parse of `after` reads done that the parse of `before` read open (a Status line, a struck heading).
85const parsedDone = (before: PassChecklist | null, after: PassChecklist): string[] => {
86 const was = doneMap(before)
87 return after.pies.flatMap(pie => pie.slices.filter(slice => slice.done && was.get(slice.id) === false).map(slice => slice.id))
88}
89
90/**
91 * The slices a change to the checklist marks done that were open: a heading or
92 * Prep box ticked from `[ ]` to `[x]`, or a slice the parser now reads done. A
93 * Write is read whole; an Edit is applied to the text the last refresh read
94 * (held in memory), and when that text no longer holds `old_string` it is
95 * judged from its own lines. Pure.
96 */
97export function flipsOf(change: FileChange, text: string | null, checklist: PassChecklist | null): Flip[] {
98 const was = doneMap(checklist)
99 const ids: string[] = []
100 let after: PassChecklist | null = null
101 if (change.kind === 'write') {
102 after = parseChecklist(change.content)
103 ids.push(...ticked(text === null ? new Map() : boxMarks(text), boxMarks(change.content), was))
104 ids.push(...parsedDone(text === null ? checklist : parseChecklist(text), after))
105 } else {
106 ids.push(...ticked(boxMarks(change.old), boxMarks(change.next), was))
107 if (change.old !== '' && text !== null && text.includes(change.old)) {
108 const edited = change.all ? text.split(change.old).join(change.next) : text.replace(change.old, () => change.next)
109 after = parseChecklist(edited)
110 // The whole lines around the edit, for an old_string cut mid-line.
111 ids.push(...ticked(boxMarks(text), boxMarks(edited), was))
112 ids.push(...parsedDone(parseChecklist(text), after))
113 }
114 }
115 const flips: Flip[] = []
116 for (const id of new Set(ids)) {
117 const type = typeOf(after, id) ?? typeOf(checklist, id)
118 flips.push({ id, ...(type === undefined ? {} : { type }) })
119 }
120 return flips
121}
122
123const latest = (view: PassSliceView | null, gate: PassVerdict['gate']): PassVerdict | null =>
124 view?.verdicts.find(verdict => verdict.gate === gate) ?? null
125
126/** True when the slice's latest slice-verifier verdict passed at this HEAD. */
127export function verifiedAt(view: PassSliceView | null, head: string | null): boolean {
128 const verifier = latest(view, 'slice-verifier')
129 return verifier?.overall === 'pass' && headsMatch(verifier.head, head)
130}
131
132function verdictReason(view: PassSliceView | null, head: string | null): string {
133 const verifier = latest(view, 'slice-verifier')
134 if (!verifier) return 'with no slice-verifier verdict in the ledger'
135 if (verifier.overall !== 'pass') return `while its latest slice-verifier verdict is ${verifier.overall}`
136 if (head === null) return 'while the Pass does not know HEAD, so its slice-verifier pass cannot be matched to it'
137 return `with its slice-verifier pass at ${short(verifier.head)}, not at HEAD ${short(head)}`
138}
139
140/** What the checkbox check reads: the checklist, the ledger projection, HEAD and the policy. */
141export type CheckboxView = {
142 checklist: PassChecklist | null
143 ledger: PassProjection | null
144 head: string | null
145 policy: Pick<PassPolicy, 'requireTester'>
146}
147
148/** Whether a slice of this type wants a slice-tester pass before `[x]`. */
149export function needsTester(type: string | undefined, mode: PassPolicy['requireTester']): boolean {
150 if (mode === 'always') return true
151 if (mode === 'never') return false
152 return type !== undefined && UI_TYPES.has(type)
153}
154
155/**
156 * Findings for slices marked done: `checkbox-without-verdict` unless the
157 * slice-verifier passed at HEAD, and `checkbox-without-tester` for a slice that
158 * wants a tester pass and has none. Pure.
159 */
160export function judgeFlips(flips: readonly Flip[], view: CheckboxView): Finding[] {
161 const findings: Finding[] = []
162 for (const flip of flips) {
163 const slice = findSlice(view.ledger, flip.id)
164 if (!verifiedAt(slice, view.head)) {
165 findings.push({
166 code: 'checkbox-without-verdict',
167 slice: flip.id,
168 key: flip.id,
169 text: `Slice ${flip.id} was marked [x] ${verdictReason(slice, view.head)}. The edit went through; run slice-verifier at HEAD before marking a slice done.`,
170 })
171 }
172 const type = flip.type ?? typeOf(view.checklist, flip.id)
173 const tester = latest(slice, 'slice-tester')
174 if (needsTester(type, view.policy.requireTester) && tester?.overall !== 'pass') {
175 const kind = type === undefined ? 'this slice' : `a ${type} slice`
176 const seen = tester ? ` (its latest slice-tester verdict is ${tester.overall})` : ''
177 findings.push({
178 code: 'checkbox-without-tester',
179 slice: flip.id,
180 key: flip.id,
181 text: `Slice ${flip.id} was marked [x] with no slice-tester pass in the ledger${seen}; ${kind} wants one. The edit went through.`,
182 })
183 }
184 }
185 return findings
186}
187
188/** The plan path from `docs/plans/` on, as the finding names it. */
189const planName = (path: string): string => {
190 const cut = path.replace(/\\/g, '/').lastIndexOf('docs/plans/')
191 return cut === -1 ? path : path.slice(cut)
192}
193
194/** `plan-write-during-bake` for a write under docs/plans/stage_ while sell-slice runs (not sell-pie). Pure. */
195export function judgePlanWrite(path: unknown, skill: string | null): Finding | null {
196 if (typeof path !== 'string' || !PLAN_PATH.test(path) || skill === null || !BAKERS.has(skill)) return null
197 const name = planName(path)
198 return {
199 code: 'plan-write-during-bake',
200 key: name,
201 text: `${name} changed while ${skill} runs; slice plans stay static during delivery (cook-pizzas and special-order change them). The write went through.`,
202 }
203}
204
205/** The slice whose ledger commits hold this HEAD, or null when no commit row names it. */
206export function sliceAtHead(ledger: PassProjection | null, head: string | null): PassSliceView | null {
207 if (!ledger || head === null) return null
208 for (const pie of ledger.pies) {
209 for (const slice of pie.slices) if (slice.commits.some(sha => headsMatch(sha, head))) return slice
210 }
211 return null
212}
213
214/**
215 * `push-slice-done-unverified` for a `git push` on a pie branch whose HEAD is a
216 * commit of a slice already marked done without a slice-verifier pass at that
217 * HEAD. A push mid-slice (HEAD a commit of an open slice) is normal and silent. Pure.
218 */
219export function judgePush(
220 command: unknown,
221 view: { branch: string | null; head: string | null; ledger: PassProjection | null },
222): Finding | null {
223 if (!gitIntent(command).push || view.branch === null || !PIE_BRANCH.test(view.branch)) return null
224 const slice = sliceAtHead(view.ledger, view.head)
225 if (!slice || !slice.done || verifiedAt(slice, view.head)) return null
226 return {
227 code: 'push-slice-done-unverified',
228 slice: slice.id,
229 key: `${slice.id}|${short(view.head)}`,
230 text: `This push carries Slice ${slice.id}, marked [x] ${verdictReason(slice, view.head)}. The push went ahead.`,
231 }
232}
233
234const inputOf = (e: unknown): Record<string, unknown> =>
235 e !== null && typeof e === 'object' ? (e as Record<string, unknown>) : {}
236
237/**
238 * Judges an Edit or Write before it runs, from atoms and the checklist text
239 * held in memory: the checkbox checks on the master checklist, the plan-write
240 * check under docs/plans/stage_. Never reads the disk or runs a process.
241 */
242export async function checkFileWrite($: EngineInterface, e: unknown): Promise<Finding[]> {
243 const input = inputOf(e)
244 const path = input.file_path
245 if (typeof path !== 'string') return []
246 const isPlan = PLAN_PATH.test(path)
247 const isChecklist = isChecklistPath(path)
248 if (!isPlan && !isChecklist) return []
249 const policy = await checksOn($)
250 if (!policy) return []
251 if (isPlan) {
252 const found = judgePlanWrite(path, await read($, skillAtom))
253 return found ? [found] : []
254 }
255 const change = changeOf(input)
256 if (!change) return []
257 const [checklist, ledger, head] = await Promise.all([read($, checklistAtom), read($, ledgerAtom), read($, headAtom)])
258 return judgeFlips(flipsOf(change, checklistText(), checklist), { checklist, ledger, head, policy })
259}
260
261/** Judges a Bash command before it runs: a push of an unverified done slice. Atoms only. */
262export async function checkPush($: EngineInterface, command: unknown): Promise<Finding[]> {
263 if (!gitIntent(command).push) return []
264 if (!(await checksOn($))) return []
265 const [branch, head, ledger] = await Promise.all([read($, branchAtom), read($, headAtom), read($, ledgerAtom)])
266 const found = judgePush(command, { branch, head, ledger })
267 return found ? [found] : []
268}
269mod/checks-git.ts 193 lines1// mod/checks-git.ts
2// Pass checks that warn and never block: a fix-attempter (or ci-fix-attempter) past its cap of 3, which also opens a HITL inbox item, and a git commit or push on main.
3
4import { atom, read, update } from 'claude-code'
5import type { AgentSpawnInput, EngineInterface } from 'claude-code'
6
7import { gitIntent, labelOf } from './capture.ts'
8import { checksOn, noteModel } from './checks.ts'
9import type { Finding } from './checks.ts'
10import { raiseHitl } from './hitl.ts'
11import { CI_FIX_CAP, FIX_CAP, findSlice } from './ledger-projection.ts'
12import { activePie, activeSlice, branchPie } from './projection.ts'
13import type { PassChecklist, PassFixCounts, PassProjection } from './types'
14
15const checklistAtom = atom({ plugin: 'bytheslice', key: 'pass.checklist' } as const, null)
16const ledgerAtom = atom({ plugin: 'bytheslice', key: 'pass.ledger' } as const, null)
17const branchAtom = atom({ plugin: 'bytheslice', key: 'pass.branch' } as const, null)
18const countsAtom = atom({ plugin: 'bytheslice', key: 'pass.fixCounts' } as const, { fix: {}, ci: {} })
19
20const MAINS: ReadonlySet<string> = new Set(['main', 'master'])
21const HITL = "only the operator's explicit reply at the HITL raises it"
22
23/** Which fix loop a spawn joins, and the counter it bumps once it started. */
24export type FixSpawn = { kind: 'fix' | 'ci'; key: string; attempt: number }
25
26/** The fix loop an agent type belongs to: `ci` for ci-fix-attempter, `fix` for fix-attempter, else null. */
27export function fixKind(subagentType: unknown): FixSpawn['kind'] | null {
28 if (typeof subagentType !== 'string') return null
29 if (subagentType.endsWith('ci-fix-attempter')) return 'ci'
30 if (subagentType.endsWith('fix-attempter')) return 'fix'
31 return null
32}
33
34export { branchPie } from './projection.ts'
35
36/** What the fix-loop check reads. */
37export type FixView = {
38 checklist: PassChecklist | null
39 ledger: PassProjection | null
40 branch: string | null
41 counts: PassFixCounts
42}
43
44/**
45 * Which attempt a fix spawn is: the slice (from its `P<pie> S<slice>` label,
46 * else the active slice) or, for ci-fix-attempter, the pie (label, branch,
47 * active pie), and the larger of what the activity log and this session
48 * counted, plus one. Null for any other spawn. Pure.
49 */
50export function fixSpawnOf(e: Pick<AgentSpawnInput, 'subagentType' | 'description'>, view: FixView): FixSpawn | null {
51 const kind = fixKind(e.subagentType)
52 if (kind === null) return null
53 if (kind === 'fix') {
54 const slice = labelOf(e.description)?.slice ?? activeSlice(view.checklist)?.id
55 if (slice === undefined) return null
56 const logged = findSlice(view.ledger, slice)?.fixLoop ?? 0
57 return { kind, key: slice, attempt: Math.max(logged, view.counts.fix[slice] ?? 0) + 1 }
58 }
59 const labelled = /^P(\d+)\b/.exec(typeof e.description === 'string' ? e.description.trim() : '')?.[1]
60 const pie = labelled !== undefined ? Number(labelled) : branchPie(view.branch) ?? activePie(view.checklist)?.n
61 if (pie === undefined) return null
62 const logged = view.ledger?.pies.find(entry => entry.n === pie)?.ciFixLoop ?? 0
63 return { kind, key: String(pie), attempt: Math.max(logged, view.counts.ci[String(pie)] ?? 0) + 1 }
64}
65
66/** `fix-loop-cap` for attempt 4 or later of a slice's fix loop, or round 4 of a pie's CI fixes. Pure. */
67export function judgeFixCap(spawn: FixSpawn | null): Finding | null {
68 if (!spawn) return null
69 if (spawn.kind === 'fix') {
70 if (spawn.attempt <= FIX_CAP) return null
71 return {
72 code: 'fix-loop-cap',
73 slice: spawn.key,
74 key: `fix|${spawn.key}|${spawn.attempt}`,
75 text: `fix-attempter attempt ${spawn.attempt} of cap ${FIX_CAP} on Slice ${spawn.key}; ${HITL}. The spawn went ahead.`,
76 }
77 }
78 if (spawn.attempt <= CI_FIX_CAP) return null
79 return {
80 code: 'fix-loop-cap',
81 key: `ci|${spawn.key}|${spawn.attempt}`,
82 text: `ci-fix-attempter round ${spawn.attempt} of cap ${CI_FIX_CAP} on Pie ${spawn.key}; ${HITL}. The spawn went ahead.`,
83 }
84}
85
86/** Judges a spawn before it starts, from atoms: the fix-loop cap. Resolves the spawn's loop and any finding. */
87export async function checkFixSpawn(
88 $: EngineInterface,
89 e: Pick<AgentSpawnInput, 'subagentType' | 'description'>,
90): Promise<{ spawn: FixSpawn | null; findings: Finding[] }> {
91 if (fixKind(e.subagentType) === null || !(await checksOn($))) return { spawn: null, findings: [] }
92 const [checklist, ledger, branch, counts] = await Promise.all([
93 read($, checklistAtom),
94 read($, ledgerAtom),
95 read($, branchAtom),
96 read($, countsAtom),
97 ])
98 const spawn = fixSpawnOf(e, { checklist, ledger, branch, counts })
99 const found = judgeFixCap(spawn)
100 return { spawn, findings: found ? [found] : [] }
101}
102
103/** The kind a fix-cap HITL item carries in the ledger. */
104export const FIX_CAP_KIND = 'fix-loop-cap'
105
106/** The HITL question a fix spawn past its cap raises. Pure. */
107export function fixCapQuestion(spawn: FixSpawn): string {
108 return spawn.kind === 'fix'
109 ? `Slice ${spawn.key} is on fix-attempter attempt ${spawn.attempt}, past the cap of ${FIX_CAP}. Grant more fix loops, or stop the slice here?`
110 : `Pie ${spawn.key} is on ci-fix-attempter round ${spawn.attempt}, past the cap of ${CI_FIX_CAP}. Grant more CI rounds, or stop here?`
111}
112
113/** The hidden note that tells the model which HITL item a fix cap opened. Pure. */
114export function fixCapNote(id: string): string {
115 return `The Pass opened HITL ${id} for the fix cap: ask the operator in chat, and when they answer call bts_hitl with { id: "${id}", answer }. Nothing was blocked.`
116}
117
118/**
119 * After a fix spawn past its cap started and its warning was reported: opens
120 * one HITL inbox item for its loop (`source: 'pass'`, category `other`, asked
121 * in chat) unless one is open, and tells the model its id in a hidden note to
122 * the loop that spawned it. Nothing is blocked. Resolves the item's id, or null.
123 */
124export async function raiseFixCap(
125 $: EngineInterface,
126 spawn: FixSpawn | null,
127 findings: readonly Finding[],
128 parent: string | null,
129): Promise<string | null> {
130 if (!spawn || !findings.some(finding => finding.code === 'fix-loop-cap')) return null
131 const projection = await read($, ledgerAtom).catch(() => null)
132 const pie = spawn.kind === 'fix' ? Number(spawn.key.split('.')[0]) : Number(spawn.key)
133 const slice = spawn.kind === 'fix' ? spawn.key : null
134 const open = (projection?.hitl?.open ?? []).find(
135 item => item.kind === FIX_CAP_KIND && item.slice === slice && (slice !== null || item.pie === pie),
136 )
137 if (open) return open.id
138 const raised = await raiseHitl($, {
139 category: 'other',
140 question: fixCapQuestion(spawn),
141 ...(slice === null ? {} : { slice }),
142 ...(Number.isNaN(pie) ? {} : { pie }),
143 source: 'pass',
144 asked: 'chat',
145 kind: FIX_CAP_KIND,
146 })
147 if (!raised.row) return null
148 await noteModel($, fixCapNote(raised.id), parent)
149 return raised.id
150}
151
152/** After a fix spawn started: this session's counter for its slice or pie moves to its attempt. */
153export async function countFix($: EngineInterface, spawn: FixSpawn | null): Promise<void> {
154 if (!spawn) return
155 await update($, countsAtom, counts => {
156 const current = counts ?? { fix: {}, ci: {} }
157 const table = { ...current[spawn.kind], [spawn.key]: Math.max(current[spawn.kind][spawn.key] ?? 0, spawn.attempt) }
158 return { ...current, [spawn.kind]: table }
159 }).catch(() => undefined)
160}
161
162// `git -C <path>` or `cd <path>` before the git step: the target's branch may not be the session's.
163const ELSEWHERE = /\bgit(?:\s+-c\s+\S+)*\s+-C\s+\S|(^|[;&|(\n]\s*)cd\s+\S/
164
165/**
166 * `commit-on-main` for a `git commit` or `git push` while the session's branch
167 * is main or master. A command that changes directory first is judged by the
168 * session's branch all the same, and says it is best effort. Pure.
169 */
170export function judgeCommitOnMain(command: unknown, branch: string | null): Finding | null {
171 const intent = gitIntent(command)
172 if ((!intent.commit && !intent.push) || branch === null || !MAINS.has(branch)) return null
173 const verb = intent.commit && intent.push ? 'git commit and git push' : intent.commit ? 'git commit' : 'git push'
174 const elsewhere =
175 typeof command === 'string' && ELSEWHERE.test(command)
176 ? " Best effort: the command changes directory first, so the Pass read the session's branch, not the target's."
177 : ''
178 return {
179 code: 'commit-on-main',
180 key: `${branch}|${verb}`,
181 text: `A ${verb} ran while this session is on ${branch}; ByTheSlice commits and pushes on a pie branch.${elsewhere} The command went ahead.`,
182 }
183}
184
185/** Judges a Bash command before it runs: a commit or push on main. Atoms only. */
186export async function checkCommitOnMain($: EngineInterface, command: unknown): Promise<Finding[]> {
187 const intent = gitIntent(command)
188 if (!intent.commit && !intent.push) return []
189 if (!(await checksOn($))) return []
190 const found = judgeCommitOnMain(command, await read($, branchAtom))
191 return found ? [found] : []
192}
193mod/checks-spawn.ts 206 lines1// mod/checks-spawn.ts
2// The Pass spawn policy as warnings: a model outside the role's tiers, a reviewer prompt carrying builder context, a gate started as a teammate, and a run that drifted to another tier.
3
4import { atom, read, update } from 'claude-code'
5import type { AgentSpawnInput, EngineInterface, TurnCompleteInput } from 'claude-code'
6
7import { labelOf } from './capture.ts'
8import { checksOn } from './checks.ts'
9import type { Finding } from './checks.ts'
10import { CONTEXT_ROLES, GATE_ROLES, PROMPT_MAX, TEAMMATE_ROLE, tierAllowed } from './policy.ts'
11import type { PassPolicy, PassSpawnTrack } from './types'
12
13const spawnsAtom = atom({ plugin: 'bytheslice', key: 'pass.spawns' } as const, {})
14
15/** How many tracked spawns the drift check keeps. */
16export const SPAWNS_KEEP = 200
17
18/** The role of an agent type: `bytheslice:slice-tester` reads `slice-tester`; another plugin's type stays whole. */
19export function roleOf(subagentType: unknown): string {
20 if (typeof subagentType !== 'string') return ''
21 return subagentType.startsWith('bytheslice:') ? subagentType.slice('bytheslice:'.length) : subagentType
22}
23
24const tiers = (allowed: readonly string[]): string => allowed.join(' or ')
25
26/** The spawn's model when it names one; `inherit` and an empty value leave the choice to the definition. */
27export function givenModel(model: unknown): string | null {
28 if (typeof model !== 'string') return null
29 const trimmed = model.trim()
30 return trimmed === '' || trimmed.toLowerCase() === 'inherit' ? null : trimmed
31}
32
33/** A spawn's identity before it has an agent id: its Agent call, and its place in a workflow run. */
34export function spawnKey(e: Pick<AgentSpawnInput, 'tool_use_id' | 'workflow'>): string {
35 return `${e.tool_use_id}#${e.workflow?.agentIndex ?? ''}`
36}
37
38/** The first configured marker the prompt holds (case-insensitive), or null. */
39export function markerIn(prompt: string, markers: readonly string[]): string | null {
40 const text = prompt.toLowerCase()
41 return markers.find(marker => marker !== '' && text.includes(marker.toLowerCase())) ?? null
42}
43
44/** What a spawn's judgment gave: its findings, and whether its model is checked again at its first turn's end. */
45export type SpawnJudgment = { findings: Finding[]; track: Omit<PassSpawnTrack, 'at'> | null }
46
47type SpawnFacts = Pick<
48 AgentSpawnInput,
49 'tool_use_id' | 'prompt' | 'subagentType' | 'model' | 'parentAgentId' | 'isTeammate' | 'resume' | 'workflow'
50> & { description?: string; name?: string }
51
52/**
53 * A teammate's spawn: `gate-teamified` when the role it was started as is a
54 * verify-once gate or reviewer (they stay one-shot subagents), and
55 * `model-policy` when it names a model outside the `teammate` role's tiers;
56 * one that names none or an allowed one is tracked for the drift check. Pure.
57 */
58export function judgeTeammate(e: SpawnFacts, policy: Pick<PassPolicy, 'roles'>): SpawnJudgment {
59 const role = roleOf(e.subagentType)
60 const key = spawnKey(e)
61 const slice = labelOf(e.description)?.slice ?? null
62 const where = slice === null ? {} : { slice }
63 const who = typeof e.name === 'string' && e.name !== '' ? `teammate ${e.name}` : 'a teammate'
64 const findings: Finding[] = []
65 if (GATE_ROLES.has(role)) {
66 findings.push({
67 code: 'gate-teamified',
68 ...where,
69 key,
70 text: `${role} was started as ${who}; gates and reviewers stay one-shot subagents (verify-once), so dispatch it with the Agent tool and no team name. The teammate still started.`,
71 })
72 }
73 const allowed = policy.roles[TEAMMATE_ROLE]
74 const given = givenModel(e.model)
75 let track: SpawnJudgment['track'] = null
76 if (allowed !== undefined && allowed.length > 0) {
77 if (given !== null && !tierAllowed(given, allowed)) {
78 findings.push({
79 code: 'model-policy',
80 ...where,
81 key,
82 text: `${who} was spawned on ${given}; the role table allows ${tiers(allowed)} for a teammate. The spawn went ahead on the model it named.`,
83 })
84 } else {
85 track = { role: TEAMMATE_ROLE, given, parent: e.parentAgentId ?? null, slice }
86 }
87 }
88 return { findings, track }
89}
90
91/**
92 * Judges a spawn against the role table: `model-policy` when it names a model
93 * outside the role's tiers, `builder-context` when a tester, verifier or
94 * reviewer prompt carries a marker or runs past 20,000 characters. A spawn
95 * that names no model (or an allowed one) is tracked for the drift check.
96 * A teammate is judged by judgeTeammate; resumed runs are left alone. Pure.
97 */
98export function judgeSpawn(e: SpawnFacts, policy: Pick<PassPolicy, 'roles' | 'contextMarkers'>): SpawnJudgment {
99 if (e.resume !== undefined) return { findings: [], track: null }
100 if (e.isTeammate === true) return judgeTeammate(e, policy)
101 const role = roleOf(e.subagentType)
102 const key = spawnKey(e)
103 const slice = labelOf(e.description)?.slice ?? null
104 const where = slice === null ? {} : { slice }
105 const findings: Finding[] = []
106 const allowed = policy.roles[role]
107 const given = givenModel(e.model)
108 let track: SpawnJudgment['track'] = null
109 if (allowed !== undefined && allowed.length > 0) {
110 if (given !== null && !tierAllowed(given, allowed)) {
111 findings.push({
112 code: 'model-policy',
113 ...where,
114 key,
115 text: `${role} was spawned on ${given}; the role table allows ${tiers(allowed)}. The spawn went ahead on the model it named.`,
116 })
117 } else {
118 track = { role, given, parent: e.parentAgentId ?? null, slice }
119 }
120 }
121 if (CONTEXT_ROLES.has(role)) {
122 const prompt = typeof e.prompt === 'string' ? e.prompt : ''
123 const marker = markerIn(prompt, policy.contextMarkers)
124 const long = prompt.length > PROMPT_MAX
125 if (marker !== null || long) {
126 const why = marker !== null ? `it holds "${marker}"` : `it runs ${prompt.length} characters, past ${PROMPT_MAX}`
127 findings.push({
128 code: 'builder-context',
129 ...where,
130 key,
131 text: `the ${role} prompt carries builder context (${why}); verify-once gives it only the manifest, the Exit criteria and the design-system path. The agent still runs.`,
132 })
133 }
134 }
135 return { findings, track }
136}
137
138/** Judges a spawn before it starts, from the policy atom alone. */
139export async function checkSpawn($: EngineInterface, e: SpawnFacts): Promise<SpawnJudgment> {
140 const policy = await checksOn($)
141 if (!policy) return { findings: [], track: null }
142 return judgeSpawn(e, policy)
143}
144
145/** After a spawn started: keeps it for the drift check at its first turn's end. */
146export async function trackSpawn(
147 $: EngineInterface,
148 agentId: string | undefined,
149 track: SpawnJudgment['track'],
150): Promise<void> {
151 if (typeof agentId !== 'string' || !track) return
152 let at = Date.now()
153 try {
154 at = await $.clock.now()
155 } catch {
156 // Wall clock it is.
157 }
158 await update($, spawnsAtom, spawns => {
159 const kept = Object.entries(spawns ?? {}).slice(-(SPAWNS_KEEP - 1))
160 return { ...Object.fromEntries(kept), [agentId]: { ...track, at } }
161 }).catch(() => undefined)
162}
163
164/** `model-drift` when a tracked agent's turn ran on a model outside its role's tiers. Pure. */
165export function judgeDrift(
166 agentId: string,
167 track: PassSpawnTrack,
168 usedModel: string,
169 policy: Pick<PassPolicy, 'roles'>,
170): Finding | null {
171 const allowed = policy.roles[track.role]
172 if (allowed === undefined || allowed.length === 0 || tierAllowed(usedModel, allowed)) return null
173 const asked = track.given === null ? 'it named no model' : `it named ${track.given}`
174 return {
175 code: 'model-drift',
176 ...(track.slice === null ? {} : { slice: track.slice }),
177 key: agentId,
178 text: `${track.role} ran on ${usedModel} (${asked}); the role table allows ${tiers(allowed)}. Nothing was changed.`,
179 }
180}
181
182/**
183 * At a subagent's turn end: a tracked spawn's model is compared with its
184 * role's tiers once, then forgotten. Resolves the finding and the loop to tell.
185 */
186export async function checkDrift(
187 $: EngineInterface,
188 e: Pick<TurnCompleteInput, 'agentId' | 'usage'>,
189): Promise<{ findings: Finding[]; parent: string | null }> {
190 const none = { findings: [], parent: null }
191 const agentId = e.agentId
192 const used = e.usage?.model
193 if (typeof agentId !== 'string' || typeof used !== 'string' || used === '') return none
194 const spawns = await read($, spawnsAtom).catch(() => ({}) as Record<string, PassSpawnTrack>)
195 const track = spawns[agentId]
196 if (!track) return none
197 await update($, spawnsAtom, all => {
198 const { [agentId]: _gone, ...rest } = all ?? {}
199 return rest
200 }).catch(() => undefined)
201 const policy = await checksOn($)
202 if (!policy) return none
203 const found = judgeDrift(agentId, track, used, policy)
204 return { findings: found ? [found] : [], parent: track.parent }
205}
206mod/hitl.ts 293 lines1// mod/hitl.ts
2// The Pass HITL inbox core: bts_hitl records a question or its answer in the ledger and returns at once; the operator's reply in chat, or the pane's button, answers it too.
3
4import { atom, read } from 'claude-code'
5import type { EngineInterface, PromptSubmitInput } from 'claude-code'
6
7import { record } from './capture.ts'
8import { readRows } from './ledger.ts'
9import { notifyHitl } from './notify.ts'
10import type { NotifyOutcome } from './notify.ts'
11import { activePie, activeSlice } from './projection.ts'
12import type { LedgerRow, PassHitlCategory, PassProjection } from './types'
13
14const checklistAtom = atom({ plugin: 'bytheslice', key: 'pass.checklist' } as const, null)
15const ledgerAtom = atom({ plugin: 'bytheslice', key: 'pass.ledger' } as const, null)
16
17export const HITL_TOOL = 'bts_hitl'
18/** The name a session lists the tool under (`mcp__<plugin>__<name>`); register.tsx matches it. */
19export const HITL_LISTED = 'mcp__bytheslice__bts_hitl'
20export const QUESTION_MAX = 1000
21export const CONTEXT_MAX = 1000
22export const ANSWER_MAX = 500
23/** The categories the model raises in: the four the agents already use. */
24export const CATEGORIES: readonly PassHitlCategory[] = ['prd_ambiguity', 'external_credentials', 'destructive_operation', 'creative_direction']
25export const NEXT_STEP = 'Ask the operator this in chat now and stop; when they answer, call bts_hitl with { id, answer }.'
26/** What an answer records when it came from the operator's reply in chat (never their words). */
27export const REPLY_ANSWER = 'operator replied in chat'
28export const PANE_ANSWER = 'marked answered in pane'
29/** Prompt origins that are the operator's own words: the terminal, Remote Control, an SDK host (the desktop app), a Slack ping. */
30export const HUMAN_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk', 'slack-ping'])
31
32/** Which way an answer arrived. */
33export type HitlVia = 'tool' | 'reply' | 'pane'
34
35/** What raising an item takes: the model's four categories, or `other` for the Pass's own. */
36export type HitlRaise = {
37 category: PassHitlCategory
38 question: string
39 context?: string | null
40 slice?: string
41 pie?: number
42 source: 'agent' | 'pass'
43 asked: 'chat' | 'pane'
44 kind?: string
45}
46
47/** A bts_hitl call read: a raise, an answer, or why it is neither. */
48export type HitlRequest =
49 | { kind: 'raise'; raise: HitlRaise }
50 | { kind: 'answer'; id: string; answer: string }
51 | { kind: 'invalid'; text: string }
52
53/** What a raise resolves: the new id, the row written (null when the ledger refused it) and what the notification did. */
54export type HitlRaised = { id: string; row: LedgerRow | null; notify: NotifyOutcome }
55
56const DESCRIPTION = [
57 'Raises a HITL question for the operator in the Pass inbox, or records its answer. It never waits and never blocks.',
58 'Raise: { category, question, context?, slice? }, category one of prd_ambiguity, external_credentials,',
59 'destructive_operation, creative_direction. It records the question in .bytheslice/ledger.jsonl, shows it in the',
60 'pane and the band, sends a native notification, and returns at once with { id, status: "open", next, notify }:',
61 'then ask the operator in chat as the skill HITL flow says and stop.',
62 'Answer: { id, answer }, answer a one-line summary of the decision (never a secret, never their message pasted whole).',
63 "The operator's own reply in chat already marks the item answered in the inbox; your answer records the decision.",
64].join(' ')
65
66const isObject = (value: unknown): value is Record<string, unknown> =>
67 value !== null && typeof value === 'object' && !Array.isArray(value)
68
69/** Text cut to `max` characters, trimmed; newlines kept. */
70export function cut(text: string, max: number): string {
71 const chars = Array.from(text.trim())
72 return chars.length <= max ? chars.join('') : `${chars.slice(0, max - 1).join('').trimEnd()}…`
73}
74
75// Token shapes a decision summary should never carry: provider keys, tokens, private keys,
76// and long mixed letter-and-digit runs. A match is redacted; the rest of the answer stays.
77const SECRETS: readonly RegExp[] = [
78 /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g,
79 /\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}/g,
80 /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
81 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
82 /\bAKIA[0-9A-Z]{16}\b/g,
83 /\b(?=[A-Za-z0-9_-]*\d)(?=[A-Za-z0-9_-]*[A-Za-z])[A-Za-z0-9_-]{32,}\b/g,
84]
85
86/**
87 * The answer a ledger row keeps: one line, at most 500 characters, secret
88 * shapes redacted; for `external_credentials` nothing of it at all.
89 */
90export function answerText(category: string, raw: string): string {
91 if (category === 'external_credentials') return '(not recorded: credentials category)'
92 let text = raw.replace(/\s+/g, ' ').trim()
93 for (const shape of SECRETS) text = text.replace(shape, '[redacted]')
94 return cut(text, ANSWER_MAX)
95}
96
97/** A slice id given as `N.M`, or undefined. */
98export function sliceId(value: unknown): string | undefined {
99 return typeof value === 'string' && /^\d+\.\d+$/.test(value.trim()) ? value.trim() : undefined
100}
101
102/** Reads a bts_hitl input: `{ id, answer }` answers, `{ category, question, ... }` raises. Pure. */
103export function parseHitl(input: unknown): HitlRequest {
104 const value = isObject(input) ? input : {}
105 if (typeof value.id === 'string' && value.id.trim() !== '') {
106 const answer = typeof value.answer === 'string' ? value.answer.trim() : ''
107 if (answer === '') return { kind: 'invalid', text: 'not recorded: an answer needs both id and answer.' }
108 return { kind: 'answer', id: value.id.trim(), answer }
109 }
110 const category = typeof value.category === 'string' ? value.category.trim() : ''
111 if (!(CATEGORIES as readonly string[]).includes(category)) {
112 return {
113 kind: 'invalid',
114 text: `not raised: category must be one of ${CATEGORIES.join(', ')} (or pass { id, answer } to answer an item).`,
115 }
116 }
117 const question = typeof value.question === 'string' ? value.question.trim() : ''
118 if (question === '') return { kind: 'invalid', text: 'not raised: question is required.' }
119 const context = typeof value.context === 'string' && value.context.trim() !== '' ? cut(value.context, CONTEXT_MAX) : null
120 const slice = sliceId(value.slice)
121 return {
122 kind: 'raise',
123 raise: {
124 category: category as PassHitlCategory,
125 question: cut(question, QUESTION_MAX),
126 context,
127 ...(slice === undefined ? {} : { slice }),
128 source: 'agent',
129 asked: 'chat',
130 },
131 }
132}
133
134/** A HITL id: `hitl-` with the time in base 36 and two random characters. */
135export function newId(t: number): string {
136 return `hitl-${Math.max(0, Math.floor(t)).toString(36)}${Math.random().toString(36).slice(2, 4).padEnd(2, '0')}`
137}
138
139/** True for a prompt the operator typed (or sent from Remote Control, an SDK host or Slack), not a command or a machine's. */
140export function isOperatorReply(e: Pick<PromptSubmitInput, 'origin' | 'command' | 'isMeta'>): boolean {
141 // An unstamped origin (never the engine's, only a bare call) is not counted as the operator's.
142 const origin: { kind?: unknown } | undefined = e.origin
143 return e.command === undefined && e.isMeta !== true && typeof origin?.kind === 'string' && HUMAN_ORIGINS.has(origin.kind)
144}
145
146async function nowOf($: EngineInterface): Promise<number> {
147 try {
148 return await $.clock.now()
149 } catch {
150 return Date.now()
151 }
152}
153
154/**
155 * Raises one item: notifies (natively when it is blocking, else a toast), then
156 * appends `hitl.raised` `{ id, category, question, context, source, asked,
157 * kind, notify }` on its slice. Halts nothing; resolves the id at once.
158 */
159export async function raiseHitl($: EngineInterface, raise: HitlRaise): Promise<HitlRaised> {
160 const t = await nowOf($)
161 const id = newId(t)
162 const c = await read($, checklistAtom).catch(() => null)
163 const slice = raise.slice ?? (raise.pie === undefined ? activeSlice(c)?.id : undefined)
164 const pie = raise.pie ?? (slice !== undefined ? Number(slice.split('.')[0]) : activePie(c)?.n)
165 const notify = await notifyHitl($, {
166 id,
167 category: raise.category,
168 question: raise.question,
169 asked: raise.asked,
170 slice,
171 }).catch((): NotifyOutcome => ({ isSent: false, reason: 'error' }))
172 const row = await record($, {
173 ev: 'hitl.raised',
174 ...(pie === undefined || Number.isNaN(pie) ? {} : { pie }),
175 ...(slice === undefined ? {} : { slice }),
176 data: {
177 id,
178 category: raise.category,
179 question: raise.question,
180 ...(raise.context ? { context: raise.context } : {}),
181 source: raise.source,
182 asked: raise.asked,
183 ...(raise.kind === undefined ? {} : { kind: raise.kind }),
184 notify,
185 },
186 })
187 return { id, row, notify }
188}
189
190/** What an answer came to: recorded or not, and the text the caller reads. */
191export type HitlAnswered = { ok: boolean; text: string; waitedMs?: number }
192
193/**
194 * Records an answer as `hitl.answered` `{ id, answer, waitedMs, via }`. An id
195 * the ledger never raised is refused with text. The first answer closes the
196 * item; a later one is ignored, except that the model's own summary after the
197 * operator's reply or a pane press is kept once (the decision, not the close).
198 */
199export async function answerHitl($: EngineInterface, id: string, answer: string, via: HitlVia): Promise<HitlAnswered> {
200 const rows = await readRows($)
201 const raised = rows.find(row => row.ev === 'hitl.raised' && row.data?.id === id)
202 if (!raised) {
203 const open = rows
204 .filter(row => row.ev === 'hitl.raised' && typeof row.data?.id === 'string')
205 .map(row => String(row.data?.id))
206 .filter(known => !rows.some(row => row.ev === 'hitl.answered' && row.data?.id === known))
207 const list = open.length === 0 ? 'none are open' : `open: ${open.slice(-5).join(', ')}`
208 return { ok: false, text: `not recorded: no HITL item has id ${id} (${list}).` }
209 }
210 const category = typeof raised.data?.category === 'string' ? raised.data.category : 'other'
211 const answers = rows.filter(row => row.ev === 'hitl.answered' && row.data?.id === id)
212 const first = answers[0]
213 let waitedMs: number
214 if (first) {
215 const summarized = answers.some(row => row.data?.via === 'tool' || row.data?.via === undefined)
216 if (via !== 'tool' || summarized) {
217 return { ok: false, text: `already answered (${String(first.data?.via ?? 'tool')}); nothing changed.` }
218 }
219 waitedMs = Math.max(0, first.t - raised.t)
220 } else {
221 waitedMs = Math.max(0, (await nowOf($)) - raised.t)
222 }
223 const row = await record($, {
224 ev: 'hitl.answered',
225 ...(raised.pie === undefined ? {} : { pie: raised.pie }),
226 ...(raised.slice === undefined ? {} : { slice: raised.slice }),
227 data: { id, answer: via === 'tool' ? answerText(category, answer) : answer, waitedMs, via },
228 })
229 if (!row) return { ok: false, text: 'not recorded: the ledger could not be written. Nothing was halted.' }
230 return { ok: true, text: JSON.stringify({ id, status: 'answered', waitedMs }), waitedMs }
231}
232
233/**
234 * The operator replied in chat: every open item the model was told to ask
235 * there, raised before now, is answered `via: 'reply'`; the reply's words are
236 * never read or kept. Items only the pane showed stay open. Resolves how many.
237 */
238export async function closeOnReply($: EngineInterface): Promise<number> {
239 const projection: PassProjection | null = await read($, ledgerAtom).catch(() => null)
240 const open = projection?.hitl?.open ?? []
241 if (open.length === 0) return 0
242 const now = await nowOf($)
243 let closed = 0
244 for (const item of open) {
245 if (item.asked !== 'chat' || item.t > now) continue
246 const done = await answerHitl($, item.id, REPLY_ANSWER, 'reply')
247 if (done.ok) closed += 1
248 }
249 return closed
250}
251
252/** The pane's button: answers one item `via: 'pane'`. */
253export function markAnswered($: EngineInterface, id: string): Promise<HitlAnswered> {
254 return answerHitl($, id, PANE_ANSWER, 'pane')
255}
256
257/**
258 * Serves bts_hitl: a raise resolves `{ id, status: 'open', next, notify }` as
259 * JSON text at once (`notify` says whether a native notification went, or why
260 * not); an answer resolves its record.
261 */
262export async function serveHitl($: EngineInterface, input: unknown): Promise<string> {
263 const request = parseHitl(input)
264 if (request.kind === 'invalid') return request.text
265 if (request.kind === 'answer') return (await answerHitl($, request.id, request.answer, 'tool')).text
266 const raised = await raiseHitl($, request.raise)
267 if (!raised.row) return `not raised: the ledger could not be written; ask the operator in chat anyway. Nothing was halted.`
268 const notified = raised.notify.isSent ? `sent (${raised.notify.channel})` : `not sent: ${raised.notify.reason} (toast shown)`
269 return JSON.stringify({ id: raised.id, status: 'open', next: NEXT_STEP, notify: notified })
270}
271
272/** Registers bts_hitl with its schema in the prompt's tool list; resolves the listed name. */
273export async function registerHitl($: EngineInterface): Promise<string> {
274 const listed = await $.tool.register({
275 name: HITL_TOOL,
276 description: DESCRIPTION,
277 inputSchema: {
278 type: 'object',
279 properties: {
280 category: { type: 'string', enum: [...CATEGORIES], description: 'Raise: the HITL category.' },
281 question: { type: 'string', description: `Raise: the question for the operator, up to ${QUESTION_MAX} characters.` },
282 context: { type: 'string', description: 'Raise: what triggered it, briefly. Never a secret.' },
283 slice: { type: 'string', description: 'Raise: the slice id, N.M; the active slice when left out.' },
284 id: { type: 'string', description: 'Answer: the id the raise returned.' },
285 answer: { type: 'string', description: 'Answer: a one-line summary of the decision. Never a secret.' },
286 },
287 additionalProperties: false,
288 },
289 isDeferred: false,
290 })
291 return listed.tool
292}
293mod/ledger.ts 240 lines1// mod/ledger.ts
2// The Pass ledger writer and reader: one append-only .bytheslice/ledger.jsonl per project, rooted at the primary checkout.
3
4import type { EngineInterface } from 'claude-code'
5
6import type { LedgerEvent, LedgerRow } from './types'
7
8export type { LedgerEvent, LedgerRow } from './types'
9
10export const LEDGER_DIR = '.bytheslice'
11export const LEDGER_FILE = 'ledger.jsonl'
12/** Lines kept when the ledger outgrows one `$.fs.read` (4 MiB); the newest are kept. */
13export const TAIL_LINES = 20000
14
15const EVENTS: ReadonlySet<string> = new Set<LedgerEvent>([
16 'pie.started',
17 'slice.started',
18 'slice.done',
19 'verdict',
20 'commit',
21 'push',
22 'merge',
23 'hitl.raised',
24 'hitl.answered',
25 'note',
26 'usage',
27 'slice.rollup',
28 'warn',
29 'agent.stalled',
30 'teammate.idle',
31 'teammate.done',
32 'edit',
33])
34
35/** A row as a caller hands it; the writer stamps `v`, `t` and `sid`. */
36export type LedgerInput = Omit<LedgerRow, 'v' | 't' | 'sid'>
37
38// Module state is per load: a hot reload resolves the root again and may warn once more.
39const roots = new Map<string, string>()
40const readyDirs = new Set<string>()
41let sid: string | null = null
42let warned = false
43
44/** Collapses `.` and `..` segments of an absolute POSIX path. */
45export function normalize(path: string): string {
46 const out: string[] = []
47 for (const part of path.split('/')) {
48 if (part === '' || part === '.') continue
49 if (part === '..') out.pop()
50 else out.push(part)
51 }
52 return `/${out.join('/')}`
53}
54
55/**
56 * The ledger root from `git rev-parse --git-common-dir` output: the common
57 * directory's parent when it is a `.git` folder (the primary checkout, so every
58 * worktree of the project shares one ledger), else the session directory.
59 */
60export function rootFromCommonDir(stdout: string, cwd: string): string {
61 const raw = stdout.trim()
62 if (raw === '') return cwd
63 const absolute = normalize(raw.startsWith('/') ? raw : `${cwd}/${raw}`)
64 const cut = absolute.lastIndexOf('/')
65 if (absolute.slice(cut + 1) !== '.git') return cwd
66 return cut <= 0 ? '/' : absolute.slice(0, cut)
67}
68
69/** The one compact JSON line a row is written as, fields in schema order. */
70export function rowLine(row: LedgerInput, t: number, session: string): string {
71 const full: LedgerRow = {
72 v: 1,
73 t,
74 sid: session,
75 ev: row.ev,
76 ...(row.pie === undefined ? {} : { pie: row.pie }),
77 ...(row.slice === undefined ? {} : { slice: row.slice }),
78 ...(row.aid === undefined ? {} : { aid: row.aid }),
79 ...(row.agent === undefined ? {} : { agent: row.agent }),
80 ...(row.data === undefined ? {} : { data: row.data }),
81 }
82 return JSON.stringify(full)
83}
84
85function isRow(value: unknown): value is LedgerRow {
86 if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
87 const row = value as Record<string, unknown>
88 return (
89 row.v === 1 &&
90 typeof row.t === 'number' &&
91 Number.isFinite(row.t) &&
92 typeof row.sid === 'string' &&
93 typeof row.ev === 'string' &&
94 EVENTS.has(row.ev) &&
95 (row.pie === undefined || typeof row.pie === 'number') &&
96 (row.slice === undefined || typeof row.slice === 'string')
97 )
98}
99
100/** Parses ledger text: a torn or foreign line is skipped, never thrown. */
101export function parseRows(text: string, sinceT = Number.NEGATIVE_INFINITY): LedgerRow[] {
102 const rows: LedgerRow[] = []
103 for (const line of text.split('\n')) {
104 if (line.trim() === '') continue
105 let parsed: unknown
106 try {
107 parsed = JSON.parse(line)
108 } catch {
109 continue
110 }
111 if (isRow(parsed) && parsed.t >= sinceT) rows.push(parsed)
112 }
113 return rows
114}
115
116async function cwdOf($: EngineInterface): Promise<string> {
117 try {
118 return await $.session.cwd()
119 } catch {
120 return '.'
121 }
122}
123
124async function nowOf($: EngineInterface): Promise<number> {
125 try {
126 return await $.clock.now()
127 } catch {
128 return Date.now()
129 }
130}
131
132/** The ledger's absolute path: `<primary checkout>/.bytheslice/ledger.jsonl`. */
133export async function ledgerPath($: EngineInterface): Promise<string> {
134 const cwd = await cwdOf($)
135 let root = roots.get(cwd)
136 if (root === undefined) {
137 root = cwd
138 try {
139 let run = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
140 timeoutMs: 5000,
141 })
142 // Git before 2.31 has no --path-format; the plain form answers relative to the cwd.
143 if (run.exitCode !== 0) run = await $.process.run(['git', 'rev-parse', '--git-common-dir'], { timeoutMs: 5000 })
144 // Only a git answer is kept: outside a repository the next row asks again.
145 if (run.exitCode === 0) {
146 root = rootFromCommonDir(run.stdout, cwd)
147 roots.set(cwd, root)
148 }
149 } catch {
150 root = cwd
151 }
152 }
153 return `${root.replace(/\/$/, '')}/${LEDGER_DIR}/${LEDGER_FILE}`
154}
155
156/** The id rows carry: the session's own id (the transcript's name), else one minted once per load. */
157export async function sessionId($: EngineInterface): Promise<string> {
158 if (sid !== null) return sid
159 try {
160 const id = await $.session.id()
161 if (typeof id === 'string' && id !== '') sid = id
162 } catch {
163 // Minted below.
164 }
165 if (sid === null) sid = `pass-${(await nowOf($)).toString(36)}-${Math.random().toString(36).slice(2, 8)}`
166 return sid
167}
168
169/** Reads the session id at session start, so every row of the session carries the same one. */
170export async function startSession($: EngineInterface): Promise<string> {
171 sid = null
172 return sessionId($)
173}
174
175// The folder ignores itself, so the ledger stays out of git in any project.
176async function ensureDir($: EngineInterface, path: string): Promise<void> {
177 const dir = path.slice(0, path.lastIndexOf('/'))
178 if (readyDirs.has(dir)) return
179 const ignore = `${dir}/.gitignore`
180 if (!(await $.fs.exists(ignore))) await $.fs.write(ignore, '*\n')
181 readyDirs.add(dir)
182}
183
184function warnOnce($: EngineInterface, reason: string): void {
185 if (warned) return
186 warned = true
187 try {
188 $.ui.log(`The Pass could not write its ledger (${reason}); that row was dropped and the session goes on.`)
189 } catch {
190 // Nothing else to tell.
191 }
192}
193
194/**
195 * Appends one row as a single short `O_APPEND` write (`tee -a`, since `$.fs`
196 * has no append), safe across sessions writing the same file. Never throws:
197 * a failure logs once and drops the row. Resolves the row written, or null.
198 */
199export async function appendRow($: EngineInterface, row: LedgerInput): Promise<LedgerRow | null> {
200 try {
201 const path = await ledgerPath($)
202 await ensureDir($, path)
203 const t = await nowOf($)
204 const line = rowLine(row, t, await sessionId($))
205 const run = await $.process.run(['tee', '-a', path], { stdin: `${line}\n`, timeoutMs: 5000 })
206 if (run.exitCode !== 0) throw new Error(run.stderr.trim() || `tee exited ${run.exitCode}`)
207 return JSON.parse(line) as LedgerRow
208 } catch (error) {
209 warnOnce($, error instanceof Error ? error.message.split('\n')[0] ?? 'unknown error' : 'unknown error')
210 return null
211 }
212}
213
214/**
215 * Reads the ledger's rows, oldest first; missing gives `[]`. A file past one
216 * read's 4 MiB is read from its newest lines. `sinceT` keeps rows at or after it.
217 */
218export async function readRows($: EngineInterface, opts: { sinceT?: number } = {}): Promise<LedgerRow[]> {
219 let path: string
220 try {
221 path = await ledgerPath($)
222 } catch {
223 return []
224 }
225 let text: string | null = null
226 try {
227 text = await $.fs.read(path)
228 } catch {
229 try {
230 if (await $.fs.exists(path)) {
231 const run = await $.process.run(['tail', '-n', String(TAIL_LINES), path], { timeoutMs: 5000 })
232 if (run.exitCode === 0) text = run.stdout
233 }
234 } catch {
235 text = null
236 }
237 }
238 return text === null ? [] : parseRows(text, opts.sinceT)
239}
240mod/live.ts 114 lines1// mod/live.ts
2// Keeps the Pass's live atoms current: the ledger projection (ledger plus kitchen activity) and the agent list poll.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Timer } from 'claude-code'
6
7import { readRows } from './ledger.ts'
8import { activityPath, projectLedger, readActivity, roleOf } from './ledger-projection.ts'
9import type { PassLive, PassLiveEntry, PassProjection } from './types'
10
11const checklistAtom = atom({ plugin: 'bytheslice', key: 'pass.checklist' } as const, null)
12const ledgerAtom = atom({ plugin: 'bytheslice', key: 'pass.ledger' } as const, null)
13const liveAtom = atom({ plugin: 'bytheslice', key: 'pass.live' } as const, { at: 0, agents: [] })
14const agentsAtom = atom({ plugin: 'bytheslice', key: 'pass.agents' } as const, {})
15
16export const PULSE_MS = 5000
17const ACTIVE = new Set(['pending', 'running', 'waiting'])
18
19async function nowOf($: EngineInterface): Promise<number> {
20 try {
21 return await $.clock.now()
22 } catch {
23 return Date.now()
24 }
25}
26
27/** Reads the ledger, the activity log and the checklist atom, and writes the projection atom. */
28export async function refreshLedger($: EngineInterface): Promise<PassProjection | null> {
29 try {
30 const [rows, activity, checklist, at] = await Promise.all([
31 readRows($),
32 readActivity($),
33 read($, checklistAtom),
34 nowOf($),
35 ])
36 const projection = projectLedger(rows, activity, checklist, at)
37 await update($, ledgerAtom, () => projection)
38 return projection
39 } catch {
40 return null
41 }
42}
43
44/** True while any agent of the list is pending, running or waiting. */
45export function hasActive(live: PassLive): boolean {
46 return live.agents.some(agent => ACTIVE.has(agent.status))
47}
48
49/**
50 * Polls `$.agent.list()` and writes `pass.live` when the list changed, or on
51 * every poll while an agent runs (so its running time redraws). Resolves the poll.
52 */
53export async function pollAgents($: EngineInterface): Promise<PassLive | null> {
54 try {
55 const [list, marks, before, at] = await Promise.all([
56 $.agent.list(),
57 read($, agentsAtom),
58 read($, liveAtom),
59 nowOf($),
60 ])
61 const agents: PassLiveEntry[] = list.map(agent => {
62 const mark = marks[agent.id]
63 const known = before.agents.find(entry => entry.id === agent.id)
64 return {
65 id: agent.id,
66 role: roleOf(agent.type),
67 status: agent.status,
68 startedAt: mark?.at ?? known?.startedAt ?? at,
69 ...(typeof agent.teammateId === 'string' && agent.teammateId !== '' ? { teammateId: agent.teammateId } : {}),
70 }
71 })
72 const next: PassLive = { at, agents }
73 const same = JSON.stringify(before.agents) === JSON.stringify(agents)
74 if (!same || hasActive(next)) await update($, liveAtom, () => next)
75 return next
76 } catch {
77 return null
78 }
79}
80
81let pulse: Timer | null = null
82
83async function activityMtime($: EngineInterface): Promise<number | null> {
84 try {
85 const stat = await $.fs.stat(await activityPath($))
86 return stat.mtimeMs + stat.size
87 } catch {
88 return null
89 }
90}
91
92/**
93 * Every five seconds: poll the agents, run `afterPoll` (the team roster's
94 * refresh), and project again when the activity log changed.
95 */
96export async function startPulse($: EngineInterface, afterPoll?: () => Promise<unknown>): Promise<void> {
97 pulse?.cancel()
98 let seen = await activityMtime($)
99 try {
100 pulse = $.clock.every(PULSE_MS, () => {
101 void (async () => {
102 await pollAgents($)
103 if (afterPoll) await afterPoll().catch(() => undefined)
104 const mark = await activityMtime($)
105 if (mark === seen) return
106 seen = mark
107 await refreshLedger($)
108 })().catch(() => undefined)
109 })
110 } catch {
111 pulse = null
112 }
113}
114mod/notify.ts 138 lines1// mod/notify.ts
2// The Pass's native notifications, each sent once: a blocking HITL question and a finished pie; one no channel sent falls back to a toast.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface } from 'claude-code'
6
7import { flipped } from './capture.ts'
8import type { PassChecklist } from './types'
9
10const notifiedAtom = atom({ plugin: 'bytheslice', key: 'pass.notified' } as const, [])
11
12export const HITL_TITLE = 'ByTheSlice needs you'
13export const PIE_TITLE = 'ByTheSlice'
14/** Keys of notifications already sent this session, newest kept. */
15export const NOTIFIED_KEEP = 200
16export const FALLBACK_TOAST_MS = 10000
17/** How much of a question a notification body or a toast carries. */
18export const BODY_MAX = 300
19const TOAST_MAX = 240
20
21/** The four categories that halt a ByTheSlice loop until the operator answers. */
22export const BLOCKING_CATEGORIES: ReadonlySet<string> = new Set([
23 'prd_ambiguity',
24 'external_credentials',
25 'destructive_operation',
26 'creative_direction',
27])
28
29/** What a notification came to: the channel that sent it, or why none did (`not-blocking` and `error` are the Pass's own words). */
30export type NotifyOutcome = { isSent: true; channel: string } | { isSent: false; reason: string }
31
32/** The facts a HITL notification needs. */
33export type NotifyItem = { id: string; category: string; question: string; asked: 'chat' | 'pane'; slice?: string | undefined }
34
35/** Text on one line, cut to `max` characters with an ellipsis. */
36export function cutLine(text: string, max: number): string {
37 const chars = Array.from(text.replace(/\s+/g, ' ').trim())
38 return chars.length <= max ? chars.join('') : `${chars.slice(0, max - 1).join('').trimEnd()}…`
39}
40
41/**
42 * A blocking HITL: one of the four categories, or any item the model was told
43 * to ask in chat (the loop stops on it). A Pass item only the pane shows (a
44 * stall) is not blocking: it toasts and never raises a native notification.
45 */
46export function isBlocking(item: Pick<NotifyItem, 'category' | 'asked'>): boolean {
47 return BLOCKING_CATEGORIES.has(item.category) || item.asked === 'chat'
48}
49
50/** The toast a HITL item shows when no native notification carried it. */
51export function hitlToast(item: Pick<NotifyItem, 'category' | 'question' | 'slice'>): string {
52 const where = item.slice === undefined ? '' : ` · Slice ${item.slice}`
53 return cutLine(`HITL ${item.category}${where}: ${item.question}`, TOAST_MAX)
54}
55
56function toast($: EngineInterface, text: string): void {
57 try {
58 $.ui.toast(text, { timeoutMs: FALLBACK_TOAST_MS })
59 } catch {
60 // The pane and the band still show it.
61 }
62}
63
64// Claims a key: true the first time this session, false after.
65async function claim($: EngineInterface, key: string): Promise<boolean> {
66 try {
67 if ((await read($, notifiedAtom)).includes(key)) return false
68 await update($, notifiedAtom, keys => [...(keys ?? []), key].slice(-NOTIFIED_KEEP))
69 } catch {
70 // Without the atom the notification still goes once from this call.
71 }
72 return true
73}
74
75/**
76 * Sends `text` as a native notification once per `key`, reading `isSent`; one
77 * no channel sent (or a rejected call) shows `fallback` as a toast instead.
78 * Resolves the outcome, or null when the key was already sent.
79 */
80export async function sendOnce(
81 $: EngineInterface,
82 key: string,
83 text: string,
84 title: string,
85 fallback: string,
86): Promise<NotifyOutcome | null> {
87 if (!(await claim($, key))) return null
88 let outcome: NotifyOutcome
89 try {
90 const sent = await $.ui.notify(text, { title })
91 outcome = sent.isSent ? { isSent: true, channel: sent.channel } : { isSent: false, reason: sent.reason }
92 } catch {
93 outcome = { isSent: false, reason: 'error' }
94 }
95 if (!outcome.isSent) toast($, fallback)
96 return outcome
97}
98
99/**
100 * A raised HITL item: a blocking one goes out as a native notification titled
101 * `ByTheSlice needs you` (a toast when no channel sent it); any other toasts.
102 * Resolves what happened, for the `hitl.raised` row.
103 */
104export async function notifyHitl($: EngineInterface, item: NotifyItem): Promise<NotifyOutcome> {
105 if (!isBlocking(item)) {
106 toast($, hitlToast(item))
107 return { isSent: false, reason: 'not-blocking' }
108 }
109 const sent = await sendOnce($, `hitl:${item.id}`, cutLine(item.question, BODY_MAX), HITL_TITLE, hitlToast(item))
110 return sent ?? { isSent: false, reason: 'already-sent' }
111}
112
113/** Pies a checklist write completed: a slice of the pie flipped to done and every slice of it is done now. Pure. */
114export function piesReady(before: PassChecklist | null, after: PassChecklist | null): number[] {
115 const pies = new Set(flipped(before, after).map(flip => flip.pie))
116 return [...pies].filter(n => {
117 const pie = after?.pies.find(entry => entry.n === n)
118 return pie !== undefined && pie.slices.length > 0 && pie.slices.every(slice => slice.done)
119 })
120}
121
122/** The words a finished pie's notification says. */
123export const pieText = (n: number): string => `Pie ${n} is ready for /box-it-up`
124
125/** After a checklist write: each pie whose last slice just flipped to done is announced once. Resolves the pies sent. */
126export async function notifyPiesReady(
127 $: EngineInterface,
128 before: PassChecklist | null,
129 after: PassChecklist | null,
130): Promise<number[]> {
131 const out: number[] = []
132 for (const n of piesReady(before, after)) {
133 const sent = await sendOnce($, `pie:${n}`, pieText(n), PIE_TITLE, pieText(n))
134 if (sent !== null) out.push(n)
135 }
136 return out
137}
138mod/projection.ts 196 lines1// mod/projection.ts
2// Read-only projection of the master checklist into session state atoms, with the pure active-pie helpers the pane and band draw from.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Timer } from 'claude-code'
6
7import { parseChecklist } from './checklist.ts'
8import type { PassChecklist, PassPie, PassSlice } from './types'
9
10export const CHECKLIST_PATH = 'docs/plans/00_master_checklist.md'
11export const WATCH_MS = 10000
12export const MIN_VERSION = [2, 1, 299] as const
13
14// `claude plugin validate` reads state references off each file's own consts, so a
15// file that reads or writes an atom declares it itself; band.tsx and rail.tsx do too.
16const checklistAtom = atom({ plugin: 'bytheslice', key: 'pass.checklist' } as const, null)
17const loadedAtAtom = atom({ plugin: 'bytheslice', key: 'pass.loadedAt' } as const, 0)
18const limitedAtom = atom({ plugin: 'bytheslice', key: 'pass.limited' } as const, false)
19const branchAtom = atom({ plugin: 'bytheslice', key: 'pass.branch' } as const, null)
20
21/** The active pie: the first pie with an open slice; null when none has one. */
22export function activePie(c: PassChecklist | null): PassPie | null {
23 if (!c) return null
24 return c.pies.find(pie => pie.slices.some(slice => !slice.done)) ?? null
25}
26
27/** The active slice: the first open slice of the active pie. */
28export function activeSlice(c: PassChecklist | null): PassSlice | null {
29 return activePie(c)?.slices.find(slice => !slice.done) ?? null
30}
31
32/** Slices done and in total for the active pie; zero and zero without one. */
33export function counts(c: PassChecklist | null): { done: number; total: number } {
34 const pie = activePie(c)
35 if (!pie) return { done: 0, total: 0 }
36 return { done: pie.slices.filter(slice => slice.done).length, total: pie.slices.length }
37}
38
39/** True when the checklist has pies and every one of them is done. */
40export function allDone(c: PassChecklist | null): boolean {
41 return c !== null && c.layout === 'pie' && c.pies.length > 0 && c.pies.every(pie => pie.done)
42}
43
44/** The pie a branch name carries (`pie-5-the-pass`, `pie/5`), or null. */
45export function branchPie(branch: string | null): number | null {
46 const match = /(^|\/)pie[-/](\d+)/.exec(branch ?? '')
47 return match ? Number(match[2]) : null
48}
49
50/**
51 * Every slice is done and a pie branch is still checked out, so its boundary
52 * PR is left to open. Once the pie is merged and the branch is gone there is
53 * nothing to box, and the surfaces say only that the pies are done.
54 */
55export const boxReady = (c: PassChecklist | null, branch: string | null): boolean => allDone(c) && branchPie(branch) !== null
56
57/** True for a tool path that names the master checklist. */
58export function isChecklistPath(path: unknown): boolean {
59 return typeof path === 'string' && /(^|[\\/])00_master_checklist\.md$/.test(path)
60}
61
62/** Whether an engine version is older than the Pass supports (2.1.299); an unreadable one counts as older. */
63export function isOlder(version: string | undefined): boolean {
64 const parts = /^(\d+)\.(\d+)\.(\d+)/.exec(version ?? '')
65 if (!parts) return true
66 const have = [Number(parts[1]), Number(parts[2]), Number(parts[3])]
67 for (let i = 0; i < MIN_VERSION.length; i += 1) {
68 const a = have[i] ?? 0
69 const b = MIN_VERSION[i] ?? 0
70 if (a !== b) return a < b
71 }
72 return false
73}
74
75async function readText($: EngineInterface): Promise<string | null> {
76 try {
77 return await $.fs.read(CHECKLIST_PATH)
78 } catch {
79 return null
80 }
81}
82
83async function readBranch($: EngineInterface): Promise<string | null> {
84 try {
85 const run = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 })
86 const branch = run.exitCode === 0 ? run.stdout.trim() : ''
87 return branch === '' ? null : branch
88 } catch {
89 return null
90 }
91}
92
93async function now($: EngineInterface): Promise<number> {
94 try {
95 return await $.clock.now()
96 } catch {
97 return Date.now()
98 }
99}
100
101// The text the last refresh read, held in memory so the checkbox check can
102// apply an Edit to it on the hot path without reading the disk.
103let lastText: string | null = null
104
105/** The checklist text as the last refresh read it, or null before one (or with no file). */
106export function checklistText(): string | null {
107 return lastText
108}
109
110/**
111 * Reads the checklist (a missing file gives null, never a throw), parses it and
112 * writes the atoms. Resolves the parsed checklist.
113 */
114export async function refresh($: EngineInterface): Promise<PassChecklist | null> {
115 const text = await readText($)
116 lastText = text
117 const parsed = text === null ? null : parseChecklist(text)
118 const branch = await readBranch($)
119 const at = await now($)
120 try {
121 await update($, checklistAtom, () => parsed)
122 await update($, branchAtom, () => branch)
123 await update($, loadedAtAtom, () => at)
124 } catch {
125 // State writes failing leave the last drawing in place; the next refresh tries again.
126 }
127 return parsed
128}
129
130/** True once the version check set the mod inert. */
131export async function isLimited($: EngineInterface): Promise<boolean> {
132 try {
133 return await read($, limitedAtom)
134 } catch {
135 return false
136 }
137}
138
139/**
140 * Reads the engine version: below 2.1.299 (or one the engine cannot say) sets
141 * `pass.limited` and logs once, so the mod stays inert instead of failing.
142 * Resolves true when limited.
143 */
144export async function gate($: EngineInterface): Promise<boolean> {
145 let version: string | undefined
146 try {
147 const v = await $.session.version()
148 version = v.base ?? v.version
149 } catch {
150 version = undefined
151 }
152 if (!isOlder(version)) {
153 await update($, limitedAtom, () => false).catch(() => undefined)
154 return false
155 }
156 const wasLimited = await read($, limitedAtom).catch(() => false)
157 await update($, limitedAtom, () => true).catch(() => undefined)
158 if (!wasLimited) {
159 $.ui.log(
160 `The Pass needs Claude Code 2.1.299 or later (this session runs ${version ?? 'an unknown version'}); its pane and band stay off.`,
161 )
162 }
163 return true
164}
165
166async function mtimeOf($: EngineInterface): Promise<number | null> {
167 try {
168 return (await $.fs.stat(CHECKLIST_PATH)).mtimeMs
169 } catch {
170 return null
171 }
172}
173
174let watcher: Timer | null = null
175
176/**
177 * Every 10 seconds compares the checklist's mtime with the last one seen and
178 * calls `reload` only when it changed (or the file appeared or went away).
179 */
180export async function watchChecklist($: EngineInterface, reload: () => Promise<unknown>): Promise<void> {
181 watcher?.cancel()
182 let seen = await mtimeOf($)
183 try {
184 watcher = $.clock.every(WATCH_MS, () => {
185 void (async () => {
186 const mtime = await mtimeOf($)
187 if (mtime === seen) return
188 seen = mtime
189 await reload()
190 })().catch(() => undefined)
191 })
192 } catch {
193 watcher = null
194 }
195}
196