SLOPSHOPPER

bytheslice

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…

new
v5.6.0MITupdated 2026-10-24steve-piece/bytheslice
A shopper browsing a rack in a slop shop
No preview yet.
README

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

npm version License: MIT Node Made for Claude Code Made for Cursor GitHub stars


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.


Table of Contents

Start here

Under the hood (for developers + power agentic-coding users)


Start here

How it works: in three steps

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

🔥 Step 1: Open the shop (one-time prep)

Six things, in order, once per project:

CommandIn pizza termsWhat it does
/setup-shopUnlock the doors, fire up the oven.Bootstraps a new project or drops ByTheSlice into an existing one.
/create-menuDecide which pies to serve.Turns a free-form brief into a structured PRD.
/cook-pizzasPre-bake every pie on the menu.Decomposes the PRD into Pies (3-8 slices each) + a nested master checklist.
/set-display-caseBuild the case the pies sit in.Generates the design system - tokens, components, the /library preview route.
/final-quality-checkInstall the quality line.Wires CI/CD, E2E tests, design-system compliance, visual regression.
/open-the-shopFlip the OPEN sign, stock ingredients.Sets up env vars + external service credentials. The most hands-on prep step.

🛎️ Step 2: Serve customers (the everyday loop)

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.

CommandIn pizza termsWhat it does
/sell-pieBake 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-sliceRun 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-upBox 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.

✨ Step 3: Handle the unusual (side flows)

On-demand, no particular order:

CommandIn pizza termsWhat it does
/special-orderCook something off-menu on the spot.Bolts new features onto an in-progress project - writes fresh stage files, hands off to /sell-slice.
/open-kitchenWatch 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-displayEyeball 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:

  1. 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.
  2. Selling and boxing are decoupled. Slices commit + push to the pie branch with no PR or CI; /box-it-up opens the single Pie N PR 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, via delivery.mode or an explicit "merge each slice" instruction, gives every slice its own PR, merged on green CI.)
  3. 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-verify is never the answer.
  4. Every skill is independently invocable. Drop /set-display-case onto any project to bolt on a design system, or /box-it-up onto any branch to push it. The full workflow is opt-in.

Quick Start

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


Install

OptionCommandWhen to use
Claude Code plugin (recommended)/add-plugin bythesliceNormal interactive use.
npm CLInpx bytheslice install --target bothAutomation, CI bootstraps, devcontainers. Scriptable + idempotent.
From GitHubnpx github:steve-piece/bytheslice install --target bothNo npm install.
Pick & choose skillsnpx bytheslice install --mode skills --skill setup-shop --skill sell-sliceGrab 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.


Watch the kitchen

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.

AreaWhat it shows
Walls and opening crewProject configuration and foundation work. Missing setup appears as a shop under construction.
Menu and ticket railThe PRD, planned pies and slices, and the current order.
Display caseDesign-system or library-route presence, plus finished pies from the checklist.
SafeThe local successful environment-check marker. No credentials appear in the kitchen.
Oven, door alarm and locksCI 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 passNamed builders, reviewers, testers and verifiers working at their stations. Click a character for its role and current task.
Service window and customer lineOpen 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 lightingChecklist 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.

The lobby

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.

BadgeMeans
Needs youAn 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.
ActiveA 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.
DoneEvery pie in the master checklist is done.
InactiveAnything 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.


The Pass

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.

SurfaceWhat 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 notificationsA 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 toolsbts_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).
CommandWhat it does
/passOpens or closes the pane.
/pass checksLists the latest ten check warnings.
/pass historyPrints 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 reconcileLists 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 warn ledger 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.


Under the hood

Everything below is for developers and power agentic-coding users. If you just want to ship features, you can stop at Quick Start.

The Kitchen: workflow diagram

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.


The master checklist: Prep + Pies

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.maxTasksPerStage in bytheslice.config.json if you really need a bigger slice - but the cap exists for a reason.


Personalize: bytheslice.config.json

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


Architecture & conventions

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

Source 29 files
mod/register.tsx 282 lines
1// 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}
282
mod/band.tsx 153 lines
1// 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}
153
mod/capture.ts 598 lines
1// 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}
598
mod/checks.ts 278 lines
1// 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}
278
mod/checks-checklist.ts 269 lines
1// 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}
269
mod/checks-git.ts 193 lines
1// 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}
193
mod/checks-spawn.ts 206 lines
1// 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}
206
mod/hitl.ts 293 lines
1// 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}
293
mod/ledger.ts 240 lines
1// 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}
240
mod/live.ts 114 lines
1// 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}
114
mod/notify.ts 138 lines
1// 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}
138
mod/projection.ts 196 lines
1// 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