SLOPSHOPPER

pa

Project Architect 3.2 router: the infinite session's partner, its state file, the inbox the watcher writes, and the journal that replaces compaction

newpanebandspinnerrowsguard
★ 98v3.30.7NOASSERTIONupdated 2026-10-22Druthulu/ProjectArchitect/project-architect-3.0/mod/pa
A shopper browsing a rack in a slop shop
README

<img src="project-architect-3.0/docs/images/banner.svg" alt="Project Architect 3.0: a team with a plan, inside Claude Code" width="860">

<img alt="version 3.30.7" src="https://img.shields.io/badge/version-3.30.7-3b82f6"> <img alt="for Claude Code" src="https://img.shields.io/badge/for-Claude%20Code-d97757"> <img alt="Windows, WSL, Linux, macOS" src="https://img.shields.io/badge/runs%20on-Windows%20%7C%20WSL%20%7C%20Linux%20%7C%20macOS-64748b">

Project Architect turns Claude Code into a small team that works from a plan. It is a project management system and a token-saving system in one. As a project manager, it keeps the plan: phases with a milestone a machine can check, your approval before any work starts, and a memory in your repository that every new session starts from. As a token saver, it runs each task in a small, fresh context, and helpers do the long reading and the build-and-test loops, so your session never carries them; a local ledger measures what that saves, and the statusline shows it. What PA3 is and does explains every part in plain words.

What you get

  • A plan that outlives every session. Phases with a milestone a machine can check, task summaries, decisions, rules and techniques, all kept as files in your repository. A new session starts from them, not from a transcript.
  • Work that runs on its own between two gates. You approve the plan and confirm the close; in between, nobody stops to ask unless a decision is genuinely yours.
  • Savings you can see. A local ledger prices every request, and the statusline shows, live, what the helpers kept out of your context.
  • Models that fit your plan. Max 20x, Max 5x or Pro: PA3 reads which from your login and picks its models to match.
  • Cheaper coders when you want them. A task's coding can run on DeepSeek or GLM through OpenRouter, on your own key, while planning and judgment stay on Claude.

See it

<img src="project-architect-3.0/docs/images/statusline.png" alt="The PA3 statusline during a phase" width="860">

White is what you used; blue is what the helpers saved. Here this session used 1 % of the five-hour window and its helpers saved another 2 %, and over the project's life its work has gone 2.6 times as far as it would have without them. Every number, line by line.

Install

You need Claude Code 2.1.292 or newer, a Claude subscription (Max 20x, Max 5x or Pro), Python 3.12 or newer, and git.

  1. Once per machine. Download this repository (Code → Download ZIP, then extract) and open its project-architect-3.0 folder. On Windows, double-click install.cmd; on WSL, Linux or macOS, run ./install.sh. It asks nothing, and it also installs the mod, the Claude Code plugin that runs a plan's tasks.
  2. Once per repository. Open the repository in Claude Code: PA3 shows one line that sets it up, with the path already filled in. Type it, then restart Claude Code (/exit, then claude). Or double-click setup-project.cmd and pick the folder. It makes one commit and never pushes.
  3. Start. Open a bare claude in your repository and talk with Claude, who works out where the project stands and takes it from there. The first session shows a line pointing to the Claude app's Settings → Billing page and the command that stores the day your plan renews, so the month figures count from it.

Step by step, updating and removing: Install. Every switch: INSTALL.md.

How it works

flowchart LR
    you((You)) <--> claude["Claude"]
    claude --> thinkers["thinkers"]
    claude --> retrievers["retrievers"]
    mod["the mod<br/>runs the loop"] --> expert["expert<br/>one per task"]
    expert --> coder["coder"]
    expert --> retrievers

You talk with one partner, Claude, in one session. Claude drafts each phase's plan with you; after you approve it, the mod runs one expert per task in a fresh context while you keep talking with Claude, and the expert briefs coders and retrievers. Every change an expert wants to make to the plan goes to Claude, who settles what stays inside the milestone and asks you about the rest. A task that hits a wall comes back blocked with its evidence and a recommendation. When the milestone is met, Claude asks you before the phase closes, and the close leaves a plain-English recap of what was done. The session can stay open for as long as you like: no model reads the main session, the mod tells Claude Code so, and it trims the main session to the last finished task. Claude, every expert and every coder runs in its own context, handed to a fresh one before it grows too large, so nothing grows without bound. One session that never fills up · What PA3 is and does · The roles · A day in the life

Why Project Architect

A long Claude Code session re-sends everything it has seen with every request: the files it read, the command output, its own reasoning. The context keeps growing, and every later request pays for all of it again. Project Architect changes the shape of the work:

  • Memory that carries forward. Every session, and every expert inside one, starts from the files the work left behind (the plan, the task summaries, the decisions, the rules and techniques) instead of a long transcript. Nothing has to stay in context to be remembered.
  • Helpers that keep the weight off. Retrievers read the long files and return one answer, coders run the edit, build and test loops, and each expert starts its task with a small, fresh context. What they read never lands in your session, and the statusline counts what that kept out.
VanillaProject Architect
Average context per request459k to 547k tokens47k to 69k tokens
Context when the work starts100k to 300k tokensabout 15k tokens
Cost per request, at API prices$0.35 to $0.42$0.08 to $0.11

Vanilla: real projects worked in one long Claude Code session. Project Architect: its own first phases, measured the same way. The whole story, with the measurements.

Learn more

The wiki has the rest: the statusline and the savings, the bench that measures the agents themselves (results in bench/results/), external coders, why PA3 stays serial, and the FAQ. What changed in each release: CHANGES.md.

License

See LICENSE.

Source 20 files
hooks/register.tsx 2420 lines
1// PA 3.2 router plugin `pa` (phase 3.22 T5a skeleton, T5b route): activation, the state file and its generation token,
2// recovery after /clear or resume, the inbox drain, the debug log, the compaction veto, the prune of old session
3// folders, and the conversation route (route.ts): local main-loop answers, prompt routing, the partner, hand-backs,
4// replies as marker prompts, and the render rules that hide the route's rows. A .tsx module for the render rules' JSX.
5// The engine allows `$` only as `$.noun.method(...)` at the call site (never stored, passed, spread or returned), so
6// helpers are pure (activation.ts, state.ts, inbox.ts, debug.ts) and every `$` call is spelled inline in a hook or a
7// closure inside one (design.md, "Execution model"). Hooks record and return; steps run from ticks.
8// T8a: journal entries are queued in the state by the hooks and written by the route tick (which rewrites the month's
9// index); a fresh partner is seeded from the journal; a warming tick pings the idle partner; the route tick raises the
10// waiting toast.
11// T8c: inbox `plan` and `ask` requests queue a dialog; the presentation tick shows one at a time when no agent runs and
12// the prompt box holds no draft: a plan in the plan-mode view (path 0), else a Markdown pane and `$.ui.ask` (fallback);
13// an ask in `$.ui.ask`. The answer goes to the caller through the outbox (planview.ts).
14// 3.23 T6a1: a dialog opens only while the loop is `off` or held (loop.json, read only), with `.run/mod/<sid>/dialog.json`
15// (this router its one writer) open meanwhile, so the loop's tick does nothing; the decision goes to the request's caller
16// (a live agent, else the partner); an approved generation or phase plan becomes the loop's `install` request, and the
17// answer to the loop's own ask (caller `loop`) its `answer` request. PA_MOD_TRIAL_APPROVE=1 answers without a dialog.
18// 3.23 T13: the cards (AssistantMessage) and the band (AbovePrompt): the autopilot's tick writes `pa.band`, the rule draws it.
19// 3.24 T6: an active arm writes `.run/mod/heartbeat.json`; an activation hook that throws writes `.run/mod/error.json`
20// (pa/health.py reads both; an inert session writes neither).
21// 3.24 T13B: every Claude reply is an orange Claude card; a reply with no hand-back goes through the feed while it lives
22// (no "Prompt from the pa plugin" row), the marker prompt otherwise; the band gains Claude's rows, the conversation-only
23// note and `‹pa› working` with cycling dots (the work tick).
24import { atom, read, update } from 'claude-code'
25import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
26
27import type { Band, BandRow, FeedDrawn, FeedEdit, FeedRun, Work } from '../types'
28
29import { decide, ERROR_FILE, errorText, HEARTBEAT_FILE, heartbeatText, ledgerPath, paSetting, releaseOf, versionOf } from './activation'
30import { armAutopilot, sessionStartPending } from './autopilot'
31import { BUILD } from './build'
32import {
33  type BandAgent, bandCells, bandHex, bandIds, bandStepOf, bandView, borderOf, type Card, type CardStyle, cardStyleOf, claudeCard, claudeRows, coderIdOf, finishCardOf,
34  loopNoteOf, noteCardOf, numstatOf, parseCard, receivedRow, ROLE_HEX, type Sender,
35} from './card'
36import { parseCoderReturn } from './contract'
37import { clip, eventFile, isStale, PRUNE_DAYS, pruneArgv } from './debug'
38import { forwardRequest, handle, isLoopKind, noAnswer, parseRequest, pendingRequests, requestFile, RETRIEVE_TYPE, retrievePrompt } from './inbox'
39import { dayFilePath, type Entry, indexAgrees, indexPath, JOURNAL_DIR, mergeIndex, parseEntry, parseEntryPath, splitDay, stamp, unindexed } from './journal'
40import { idleNow, runningNow, taskAgents } from './digest'
41import {
42  addRecords, beatOf, deathOf, emptyFeed, emptyLife, FEED_BEAT_MS, FEED_DESC, FEED_STALE_MS, FEED_TIMEOUT_MS, feedBlock, feedCommand, feedErrorLine, feedEvents,
43  announces, editHead, feedLook, type FeedRecord, feedText, fromLog, missingNs, NUDGE, nudgeName, replyRecord, restartGate, seenBeat, senderOf, shownOf, speakerOf, staleOf, unseenFeed,
44} from './feed'
45import { EXT_SEEN, EXT_STATUS, extAt, externalReminder, FIRST_RUN_WAITS, firstRunLine, LOOP_FILE, loopAgentIds, nextIdOf, ownsLoop, parseLoop, planPhase, projectName, queuedTo, RESTART_LINE, sessionTitle, setupNotice, SHELLS_FILE, shellsOf, shellsOut, statusWord, talkLine } from './loop'
46import {
47  answerLine, APPROVED, askBody, decisionLine, dialogCopy, DIALOG_FILE, dialogJson, FALLBACK_OPTIONS, FALLBACK_QUESTION, fallbackLine, installs, isReplan,
48  dialogKey, dropAgentDialogs, holdingAgent, learnedFile, PLAN_PANE, planBody, planFilePath, planLogLines, presentHold, slugArgv, slugFile, trialLine,
49} from './planview'
50import { cacheTtlOf, expertStatus, isCold, partnerStatus, pingCount, pingDecision, pingUsd, type Rates, ratesFrom, tierRates } from './policy'
51import {
52  addAttach, addReply, applyReply, bangOf, bangText, childOut, classify, clearTurn, dueMarkers, emptyTurns, enqueue, entryUsage, fileEntry,
53  framedOther, handbackChild, hideAssistant, hideSpinner, hideUser, isBusy, isLive, isSeedName, journalOut, localAnswer, markerReply,
54  markerText, newestPartner, newPartner, otherTarget, PARTNER_TYPE, stepFactsOf, nextMarker, outboxHeld, parentOf, partnerReport, partnerRoute, partnerTurnEnded, priceCacheOf,
55  registerTurn, replyKey, RESUME_CHECK_MS, seedEntries, takeHandback, takeReply, toastArgv, toastOut, usageCtx,
56  handbackReport, isNote, noteBody, isFeedText, armYourTurn, yourTurnStep, loopPending, STUB, dueToast, heardDeveloper,
57  isPointer, lateReport, markerFacts, workingIds, noReportHandback, NO_REPORT_REASON, imageSourceLines, imagesPending,
58  withImageSources,
59} from './route'
60import { buildSeed, type Child as SeedChild, localStamp, SEED_BUDGET_TOKENS } from './seed'
61import {
62  defaultState, drawnPath, feedDirOf, lastJson, lastPath, markDone, mergeDrawn, parseDrawn, parseLast, parseState, pruneDone, serializeDrawn,
63  serializeState, statePath,
64} from './state'
65import type { Drawn, Present, PriceCache, RouterState, ToastOut, WarmAgent } from './state'
66
67const genAtom = atom({ plugin: 'pa', key: 'gen' } as const, 0)
68const activeAtom = atom({ plugin: 'pa', key: 'active' } as const, false)
69const stateAtom = atom({ plugin: 'pa', key: 'state' } as const, null)
70const routeAtom = atom({ plugin: 'pa', key: 'route' } as const, false)
71const sdkAtom = atom({ plugin: 'pa', key: 'sdkAsUser' } as const, false)
72const turnsAtom = atom({ plugin: 'pa', key: 'turns' } as const, emptyTurns())
73const ratesAtom = atom({ plugin: 'pa', key: 'rates' } as const, {} as Record<string, PriceCache | null>)
74const paneAtom = atom({ plugin: 'pa', key: 'pane' } as const, '')
75const cardsAtom = atom({ plugin: 'pa', key: 'cards' } as const, [] as string[])
76const bandAtom = atom({ plugin: 'pa', key: 'band' } as const, { line: '', rows: [] } as Band)
77// 3.24 T13B: the router's own band part, written by its work tick (Claude's rows, the conversation-only note, the dots).
78const workAtom = atom({ plugin: 'pa', key: 'work' } as const, { rows: [], note: '', working: false, dots: 1 } as Work)
79/** 3.29 T3: when the router took a developer message Claude has not started on yet (ms); 0 none. */
80const receivedAtom = atom({ plugin: 'pa', key: 'received' } as const, 0)
81// 3.23 T13d: the feed's records collected at prompt.submit and not yet drawn, the last drawn number and speaker.
82const feedAtom = atom({ plugin: 'pa', key: 'feed' } as const, emptyFeed())
83// 3.23 T13d.c1c: the feed's life (a feed seen, the first developer prompt, the restarts, the router's Monitor call running).
84const lifeAtom = atom({ plugin: 'pa', key: 'feedLife' } as const, emptyLife())
85// 3.23 T13d.c2: the feed answers' stored texts and runs (capped by MAX_CARDS): the AssistantMessage rule draws these.
86const feedRunsAtom = atom({ plugin: 'pa', key: 'feedRuns' } as const, [] as FeedDrawn[])
87// 3.24 T8 (L18): the open permission dialogs (planview.ts dialogKey), from classic.PermissionRequest to that call's
88// PostToolUse, PostToolUseFailure or PermissionDenied (or its agent's turn end); an open one holds every dialog of ours.
89const dialogsAtom = atom({ plugin: 'pa', key: 'dialogs' } as const, [] as string[])
90
91export const COMPACT_SKIP = 'PA 3.2: the journal replaces compaction'
92const DRAIN_MS = 1_000
93/** 3.24 T13B: the router's band part's tick (its dots cycle at this rate while `‹pa› working` shows). */
94const WORK_MS = 500
95const WARM_MS = 30_000 // was 60_000; 3.28.1 T12: a 5-minute agent's ping window (3.28.8: 4-4.5 min) is 30 s wide
96const SEED_DAYS = 14
97/** Card texts kept in the registry at most (3.23 T13); an older card's row draws as its plain markdown. */
98const MAX_CARDS = 100
99
100// Module variables are lost on a hot reload or a lost hooks worker (design.md); everything here is re-derivable.
101let debugBase = '' // `<root>/.run/mod` under PA_MOD_DEBUG=1, else '' (no debug log)
102let debugSid = 'unknown'
103let counter = 0
104let rendered = new Set<string>()
105/** 3.23 T13d.c11, debug only: each main-loop answer's text (trimmed) → its kind and wall time, for `pa.draw`'s delay. */
106const answered = new Map<string, { kind: DrawKind; ms: number }>()
107let armed = { sid: '', gen: 0 }
108let draining = false
109let routing = false
110let warming = false
111let jseq = 0 // per-load sequence of the router's journal entries
112let fseq = 0 // the loop `forward` requests' sequence in the inbox (3.23 T5d2a)
113let nseq = 0 // the feed's nudge files' sequence (3.23 T13d)
114let expertSkip = '' // 3.23 T13b.c4: warmExpert's last skip reason, logged once per change
115let noToastLogged = false
116/** 3.28.8: the developer's latest message with attachments (when, how many) and Claude Code's source lines for them. */
117let imageWait: { at: number; count: number; lines: string[] } | null = null
118let lastBang = '' // 3.29.8: the `!` command whose output row comes next
119let remindedSid = '' // 3.3 T7: the session whose first developer message carried the external-coder reminder
120let presenting = false // the presentation tick's one dialog at a time
121let lastHold = '' // the last deferral logged, so a long wait logs once
122let turnWaitLogged = '' // 3.24 T17j: the your-turn toast's last loop wait logged (turnId + why), once per change
123let modeSeen = '' // the permission mode the last classic.PostToolUse carried ('' unread)
124// T8e: this session's transcript (classic.SessionStart's `transcript_path`): its slug names the main plan file.
125let transcriptSeen = { sid: '', path: '' }
126// The arming closure session.start builds; the re-arm hooks call it. Holds no `$` of its own: it is a closure inside
127// the session.start hook, so its `$` calls stay inline there.
128let rearm: ((why: string, sid: string) => Promise<void>) | null = null
129let feedRestart: ((why: string) => Promise<void>) | null = null // 3.23 T13d.c1c: set by arm, run by a death signal
130
131/** 3.23 T13d.c6a: drawn.json rewritten with an entry added (read, change, write: the file keeps every session's entries). */
132async function keepDrawn($: Pick<EngineInterface, 'fs' | 'session'>, change: (d: Drawn) => Drawn): Promise<void> {
133  const path = drawnPath(`${await $.session.root()}/.run/mod`)
134  const d = parseDrawn(await $.fs.read(path).catch(() => null))
135  await $.fs.write(path, serializeDrawn(change(d))).catch(quiet)
136}
137
138/** 3.24 T6: the error record pa/health.py reads, for an activation hook that threw (`sid` '' for this session's); only
139 * where the mod would be active (pa.json mod: on), so an inert session still writes nothing. Never throws. */
140async function errorRecord($: Pick<EngineInterface, 'fs' | 'session' | 'env' | 'clock'>, sid: string, hook: string, error: string): Promise<void> {
141  try {
142    const root = await $.session.root()
143    const paText = await $.fs.read(`${root}/.claude/pa.json`).catch(() => null)
144    if (decide(paText, await $.env.get('PA_MOD_OFF'), versionOf(await $.session.version())).active) {
145      await $.fs.write(`${root}/${ERROR_FILE}`, errorText(sid === '' ? await $.session.id() : sid, 'pa', hook, error, await $.clock.now()))
146    }
147  } catch {
148    // best effort: the record must never fail the handler that writes it
149  }
150}
151
152/** One debug event file: [path, text] for `$.fs.write`. */
153function ev(kind: string, data: Record<string, unknown>): [string, string] {
154  const ms = Date.now()
155  counter += 1
156  return [eventFile(`${debugBase}/${debugSid}/debug`, ms, counter, `pa.${kind}`), JSON.stringify({ ms, ev: `pa.${kind}`, ...data }, null, 1)]
157}
158
159const quiet = (): undefined => undefined
160
161/** 3.24 T8: under PA_MOD_DEBUG a dialog's open or close logs `$.agent.list()`'s statuses: the live record of which
162 * status a subagent shows during its own permission dialog. */
163async function dialogLog($: EngineInterface, kind: string, key: string, open: number): Promise<void> {
164  if (debugBase !== '') {
165    const agents = (await $.agent.list().catch(() => [])).map(a => ({ id: a.id, type: a.type, status: a.status }))
166    await $.fs.write(...ev(kind, { key: key.slice(0, 200), open, agents })).catch(quiet)
167  }
168}
169
170/** 3.24 T8: one open permission dialog closes (`why`: done, failure, denied); a key not open is a no-op. */
171async function dialogClose($: EngineInterface, key: string, why: string): Promise<void> {
172  if (!(await read($, activeAtom)) || !(await read($, dialogsAtom)).includes(key)) {
173    return
174  }
175  const left = await update($, dialogsAtom, ks => ks.filter(k => k !== key))
176  await dialogLog($, `dialog.close.${why}`, key, left.length)
177}
178
179/** What a main-loop answer or a drawn assistant row is, for the draw log (3.23 T13d.c11). */
180type DrawKind = 'feed' | 'card' | 'reply' | 'other'
181const drawKindOf = (kind: string): DrawKind =>
182  kind === 'feed' || kind === 'card' ? kind : kind === 'marker' || kind === 'reply' ? 'reply' : 'other'
183
184/** A card (3.23 T13, card.ts) in its role's color under a `card_style`: a Box border (`quote` a side bar, `round`) with a
185 * bold title, the Markdown body and the diff as Code; plain draws no border, `● ` and the title, the rest indented 2. */
186function drawCard(el: Pick<Elements['terminal'], 'Box' | 'Text' | 'Markdown' | 'Code'>, card: Card, style: CardStyle): RenderElement {
187  const { Box, Text, Markdown, Code } = el
188  const hex = ROLE_HEX[card.role]
189  const border = borderOf(style, card.done)
190  // 3.28.2 T16: Claude's installed-update card draws the mod's restart line (loop.ts RESTART_LINE) bold green with
191  // "(not /clear)" red; the stored text stays markdown, so Remote Control shows it as is.
192  const at = card.role === 'claude' ? card.body.indexOf(RESTART_LINE) : -1
193  const body = at < 0
194    ? (card.body === '' ? null : <Markdown text={card.body} />)
195    : [
196        card.body.slice(0, at).trim() === '' ? null : <Markdown text={card.body.slice(0, at).trimEnd()} />,
197        card.body.slice(0, at).trim() === '' ? null : <Text> </Text>, // 3.28.2 T17: the empty line before it, always
198        <Text><Text bold color="#00af00">To load it: /exit, then claude --continue</Text> <Text color="#d70000">(not /clear)</Text></Text>,
199        card.body.slice(at + RESTART_LINE.length).trim() === '' ? null : <Markdown text={card.body.slice(at + RESTART_LINE.length).trimStart()} />,
200      ]
201  const rest = (
202    <Box flexDirection="column" paddingLeft={border === null ? 2 : 0}>
203      {body}
204      {card.diff === '' ? null : <Code source={card.diff} format="diff" />}
205    </Box>
206  )
207  if (border === null) {
208    return (
209      <Box flexDirection="column">
210        <Text bold color={hex}>{`● ${card.title}`}</Text>
211        {rest}
212      </Box>
213    )
214  }
215  return (
216    <Box flexDirection="column" borderStyle={border} borderColor={hex} paddingLeft={1} paddingRight={1}>
217      <Text bold color={hex}>{card.title}</Text>
218      {rest}
219    </Box>
220  )
221}
222
223/** 3.24 T8b: a file edit in native Claude Code's look (docs/pa3-build/3.2/trial/look/native-edit-view.png): the bold
224 * `Updated <path>` (or `Created <path>`) and its counts, then each hunk as a numbered diff (a created file: its first lines,
225 * numbered from 1) with a dim `...` between hunks, then a dim `… +N lines` when rows were left out. */
226function drawEdit(el: Pick<Elements['terminal'], 'Box' | 'Text' | 'Code'>, edit: FeedEdit): RenderElement[] {
227  const { Text, Code } = el
228  const head = editHead(edit) // not `h`: the JSX factory
229  const out: RenderElement[] = [<Text color="text"><Text bold>{head.verb}</Text>{head.counts}</Text>]
230  edit.hunks.forEach((hunk, i) => {
231    if (i > 0) {
232      out.push(<Text dimColor>...</Text>)
233    }
234    out.push(edit.created ? <Code source={hunk} path={edit.path} startLine={1} /> : <Code source={hunk} path={edit.path} format="diff" />)
235  })
236  if (edit.more > 0) {
237    out.push(<Text dimColor>{`… +${edit.more} lines`}</Text>)
238  }
239  return out
240}
241
242/** A feed answer (3.23 T13d.c2, feed.ts feedLook) in the cards' look: per run a note card's border in the role's hex
243 * (plain: none, the lines indented 2), the bold label in the role's hex on a titled run only, each line as Text in the
244 * theme's primary text color (3.23 T13d.c7: `color="text"`, white on dark themes, readable on light ones).
245 * 3.24 T13r: a run's lines are one `Markdown` block, drawn as an assistant reply is (was a `Text` per line: raw markers). */
246function drawFeed(el: Pick<Elements['terminal'], 'Box' | 'Text' | 'Markdown' | 'Code'>, runs: readonly FeedRun[], style: CardStyle): RenderElement {
247  const { Box, Text, Markdown } = el
248  return (
249    <Box flexDirection="column">
250      {runs.map(run => {
251        const look = feedLook(run, style)
252        const body = (
253          <Box flexDirection="column" paddingLeft={look.border === null ? 2 : 0}>
254            {look.edit === undefined ? <Markdown text={look.lines.join('\n')} /> : drawEdit(el, look.edit)}
255          </Box>
256        )
257        const title = look.title === '' ? null : <Text bold color={look.color}>{look.title}</Text>
258        if (look.border === null) {
259          return (
260            <Box flexDirection="column">
261              {title}
262              {body}
263            </Box>
264          )
265        }
266        return (
267          <Box flexDirection="column" borderStyle={look.border} borderColor={look.color} paddingLeft={1}>
268            {title}
269            {body}
270          </Box>
271        )
272      })}
273    </Box>
274  )
275}
276
277/** The band (3.23 T13): the loop's line, then a row per running agent (the name bold in its role's hex, the task plain,
278 * the step and the time dim), each one row cut to `cols`, never wrapped. 3.24 T13B: its lines (bandView). */
279function drawBand(el: Pick<Elements['terminal'], 'Box' | 'Text'>, band: { lines: string[]; rows: BandRow[] }, cols: number): RenderElement {
280  const { Box, Text } = el
281  return (
282    <Box flexDirection="column">
283      {band.lines.map(line => <Text wrap="truncate-end">{line.length > cols ? `${line.slice(0, Math.max(0, cols - 1))}…` : line}</Text>)}
284      {band.rows.map(r => {
285        const c = bandCells(r, cols)
286        return (
287          <Box flexDirection="row">
288            <Text bold color={bandHex(r.role)} wrap="truncate-end">{c.name}</Text>
289            <Text wrap="truncate-end">{c.task}</Text>
290            <Text dimColor wrap="truncate-end">{c.dim}</Text>
291          </Box>
292        )
293      })}
294    </Box>
295  )
296}
297
298export const register: Register = (on, options) => {
299  // 3.23 T13: the `/config` row pa.card_style: how the cards draw (mixed, boxed, bar, plain).
300  const cardStyle = cardStyleOf(options)
301  on('session.start', async ($, e, next) => {
302    const root = await $.session.root()
303    const sid = await $.session.id()
304    const base = `${root}/.run/mod`
305    // 3.24 T8: the debug log opens only once arm() finds the mod active, so an inert session writes nothing.
306    const debugWanted = (await $.env.get('PA_MOD_DEBUG')) === '1'
307    debugBase = ''
308    debugSid = sid
309    // The route runs only in an interactive session, or under the trial flag that makes a `-p` prompt the developer's.
310    const interactive = e.isInteractive
311    const sdkAsUser = (await $.env.get('PA_MOD_SDK_AS_USER')) === '1'
312
313    // Decide, restore the state under a new generation, save it, and start this generation's ticks. `why` is
314    // `start` (session.start: the session's own state file, if a resume left one) or a re-arm (also through last.json).
315    const arm = async (why: string, sidNow: string): Promise<void> => {
316      if (why !== 'start' && armed.sid === sidNow && armed.gen !== 0 && (await read($, genAtom)) === armed.gen) {
317        return // already armed for this session and $.state was not reset: never a second set of ticks
318      }
319      debugSid = sidNow
320      const paText = await $.fs.read(`${root}/.claude/pa.json`).catch(() => null)
321      const version = versionOf(await $.session.version())
322      const decision = decide(paText, await $.env.get('PA_MOD_OFF'), version)
323      await update($, activeAtom, () => decision.active)
324      const routeOn = decision.active && (interactive || sdkAsUser)
325      await update($, routeAtom, () => routeOn)
326      await update($, sdkAtom, () => sdkAsUser)
327      debugBase = debugWanted && decision.active ? base : ''
328      if (!decision.active) {
329        return
330      }
331      // 3.24 T6: the heartbeat pa/health.py checks: this session's mod loaded (the release from the plugin's root, two up).
332      // 3.30.2: the release this code is, when the install stamped it (VERSION may already name a newer one).
333      const release = BUILD.version !== '' ? BUILD.version : releaseOf(await $.fs.read(`${$.plugin.root}/../../VERSION`).catch(() => null))
334      await $.fs.write(`${root}/${HEARTBEAT_FILE}`, heartbeatText(sidNow, release, version, await $.clock.now(), 'pa')).catch(quiet)
335      if (debugBase !== '') {
336        await $.fs.write(...ev('activation', { why, sid: sidNow, version, decision, route: routeOn, interactive, sdkAsUser })).catch(quiet)
337      }
338      // 3.23 T13d.c6a: the drawn registries come back from drawn.json (a resumed or restarted process starts without them).
339      const kept = parseDrawn(await $.fs.read(drawnPath(base)).catch(() => null))
340      await update($, cardsAtom, cs => mergeDrawn(kept.cards, cs, c => c, MAX_CARDS))
341      await update($, feedRunsAtom, xs => mergeDrawn(kept.feedRuns, xs, x => x.text, MAX_CARDS))
342      // 3.23 T13d.c6c: what the load brought back, so a resumed session's reload can be checked.
343      if (debugBase !== '') {
344        const reason = why === 'start' ? 'start' : why.includes('resume') ? 'resume' : 'clear'
345        await $.fs.write(...ev('drawn.load', { reason, cards: kept.cards.length, feedTexts: kept.feedRuns.length })).catch(quiet)
346      }
347      const now = await $.clock.now()
348      const lastSid = parseLast(await $.fs.read(lastPath(base)).catch(() => null))
349      let prev: RouterState | null = null
350      let from = ''
351      const own = await $.fs.read(statePath(base, sidNow)).catch(() => null)
352      if (own !== null) {
353        prev = parseState(own, sidNow, now)
354        from = sidNow
355      }
356      if (prev === null && why !== 'start' && lastSid !== null && lastSid !== sidNow) {
357        const theirs = await $.fs.read(statePath(base, lastSid)).catch(() => null)
358        prev = theirs === null ? null : parseState(theirs, lastSid, now)
359        from = prev === null ? '' : lastSid
360      }
361      const gen = Math.max(await read($, genAtom), armed.gen, prev?.gen ?? 0) + 1
362      const s: RouterState = { ...(prev ?? defaultState(sidNow, gen)), sid: sidNow, gen }
363      await update($, stateAtom, () => s)
364      await update($, genAtom, () => gen)
365      await $.fs.write(statePath(base, sidNow), serializeState(s))
366      await $.fs.write(lastPath(base), lastJson(sidNow, now))
367      if (armed.sid !== sidNow) {
368        rendered = new Set<string>()
369      }
370      armed = { sid: sidNow, gen }
371      if (debugBase !== '') {
372        await $.fs.write(...ev('gen', { why, gen, from })).catch(quiet)
373      }
374      const log = async (kind: string, data: Record<string, unknown>): Promise<void> => {
375        if (debugBase !== '') {
376          await $.fs.write(...ev(kind, data)).catch(quiet)
377        }
378      }
379
380      // The journal (journal.ts): entries under `<root>/.claude-state/journal/<date>/`, the month's index beside them.
381      const python = paSetting(paText, 'python')
382      const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
383      const ledger = ledgerPath(await $.env.get('PA_MOD_PA3_DIR'), home)
384      const rotateAt = Number((await $.env.get('PA_MOD_ROTATE_AT')) ?? '') || 0
385      // Trial knob (3.23 T5d2b): a ping falls due this long after the last request instead of policy.PING_AFTER_MS.
386      const pingAfterMs = Number((await $.env.get('PA_MOD_PING_AFTER_MS')) ?? '') || undefined
387      const jdir = `${root}/${JOURNAL_DIR}`
388      // Both forms (3.23 T7): a live day folder `<date>/` and a rolled day file `<date>.md` (tools/journal_rollup.py).
389      const days = async (): Promise<string[]> => {
390        if (!(await $.fs.exists(jdir))) {
391          return []
392        }
393        const names = (await $.fs.list(jdir)).map(f => f.name.replace(/\.md$/, '')).filter(n => /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(n))
394        return [...new Set(names)].sort()
395      }
396      const rolled = async (day: string): Promise<Entry[]> => {
397        const text = await $.fs.read(`${root}/${dayFilePath(day)}`).catch(() => null)
398        return text === null ? [] : splitDay(day, text)
399      }
400      const dayFiles = async (day: string): Promise<string[]> => {
401        const live = (await $.fs.exists(`${jdir}/${day}`))
402          ? (await $.fs.list(`${jdir}/${day}`)).filter(f => f.kind === 'file').map(f => `${JOURNAL_DIR}/${day}/${f.name}`)
403          : []
404        return [...live, ...(await rolled(day)).map(e => e.path)].filter(rel => parseEntryPath(rel) !== null).sort()
405      }
406      const readEntry = async (rel: string): Promise<Entry | null> => {
407        const hash = rel.indexOf('.md#')
408        if (hash >= 0) {
409          return (await rolled(parseEntryPath(rel)?.date ?? '')).find(e => e.path === rel) ?? null
410        }
411        const text = await $.fs.read(`${root}/${rel}`).catch(() => null)
412        return text === null ? null : { path: rel, ...(parseEntry(text) ?? { meta: {}, body: text }) }
413      }
414      // The month's index, rewritten whole when it disagrees with the month's entries (its lines kept, new entries read).
415      const syncIndex = async (month: string): Promise<void> => {
416        const paths: string[] = []
417        for (const d of (await days()).filter(n => n.startsWith(`${month}-`))) {
418          paths.push(...(await dayFiles(d)))
419        }
420        const ip = `${root}/${indexPath(month)}`
421        const text = (await $.fs.read(ip).catch(() => null)) ?? ''
422        if ((paths.length === 0 && text === '') || indexAgrees(text, paths)) {
423          return
424        }
425        const fresh: Entry[] = []
426        for (const rel of unindexed(text, paths)) {
427          const e = await readEntry(rel)
428          if (e !== null) {
429            fresh.push(e)
430          }
431        }
432        await $.fs.write(ip, mergeIndex(text, paths, fresh))
433        await log('journal.index', { month, entries: paths.length, read: fresh.length })
434      }
435      $.clock.after(0, () => {
436        void (async () => {
437          await syncIndex(stamp(await $.clock.now())[0].slice(0, 7))
438        })().catch(async err => log('journal.error', { err: clip(String(err), 300) }))
439      })
440
441      if (why === 'start') {
442        const argv = pruneArgv(paSetting(paText, 'python'), base, [sidNow, lastSid ?? ''], PRUNE_DAYS)
443        if (argv !== null) {
444          $.clock.after(0, () => {
445            void (async () => {
446              try {
447                const ran = await $.process.run(argv, { cwd: root, timeoutMs: 15_000 })
448                if (debugBase !== '') {
449                  await $.fs.write(...ev('prune', { exitCode: ran.exitCode, stdout: clip(ran.stdout, 120), stderr: clip(ran.stderr, 300) })).catch(quiet)
450                }
451              } catch (err) {
452                if (debugBase !== '') {
453                  await $.fs.write(...ev('prune.error', { err: clip(String(err), 300) })).catch(quiet)
454                }
455              }
456              // The journal's daily roll-up (3.23 T7): finished days only, so safe in any session; then the month's index.
457              try {
458                const ran = await $.process.run([python, 'tools/journal_rollup.py'], { cwd: root, timeoutMs: 60_000 })
459                await log('rollup', { exitCode: ran.exitCode, stdout: clip(ran.stdout, 600), stderr: clip(ran.stderr, 300) })
460                await syncIndex(stamp(await $.clock.now())[0].slice(0, 7))
461              } catch (err) {
462                await log('rollup.error', { err: clip(String(err), 300) })
463              }
464            })()
465          })
466        }
467      }
468
469      // The inbox drain: this generation's one tick. Stale (a /clear reset $.state, or a re-arm moved on): it stops.
470      const inbox = `${base}/${sidNow}/inbox`
471      const timer = $.clock.every(DRAIN_MS, () => {
472        void (async () => {
473          const current = await read($, genAtom)
474          if (isStale(gen, current)) {
475            timer.cancel()
476            if (debugBase !== '') {
477              await $.fs.write(...ev('gen.stale', { tickGen: gen, current })).catch(quiet)
478            }
479            return
480          }
481          if (draining) {
482            return
483          }
484          draining = true
485          try {
486            if (!(await $.fs.exists(inbox))) {
487              return
488            }
489            const names = (await $.fs.list(inbox)).filter(f => f.kind === 'file').map(f => f.name)
490            const at = await $.clock.now()
491            const st = await read($, stateAtom)
492            if (st === null) {
493              return
494            }
495            // The loop's own kinds (`release`, `plan_edit`) are drained by its tick (autopilot.tsx), never here.
496            for (const name of pendingRequests(names, st.inboxDone, at).filter(n => !isLoopKind(n))) {
497              if (isStale(gen, await read($, genAtom))) {
498                return
499              }
500              const text = await $.fs.read(`${inbox}/${name}`).catch(() => '')
501              const req = parseRequest(text, name)
502              // Done before acting: a crash mid-act never replays a request. A functional update: the route's hooks
503              // change the state between ticks.
504              const id = name.slice(0, -'.json'.length)
505              const saved = await update($, stateAtom, s => (s === null ? s : markDone(pruneDone(s, at), id, at)))
506              if (saved === null) {
507                return
508              }
509              await $.fs.write(statePath(base, sidNow), serializeState(saved))
510              await $.fs.write(lastPath(base), lastJson(sidNow, at))
511              if (req === null) {
512                if (debugBase !== '') {
513                  await $.fs.write(...ev('inbox.bad', { name, text: clip(text, 120) })).catch(quiet)
514                }
515                continue
516              }
517              for (const fx of handle(req, root)) {
518                if (fx.do === 'log' && debugBase !== '') {
519                  await $.fs.write(...ev(fx.kind, fx.data)).catch(quiet)
520                } else if (fx.do === 'present' && isReplan(fx.item, await $.fs.exists(`${root}/phase-ends/current/REPLAN.md`),
521                  (await $.fs.read(`${root}/phase-ends/current/PHASE_PLAN.md`).catch(() => null)) ?? '')) {
522                  // 3.28.1 T6: a replan's draft goes straight to the loop, which installs it at the next task boundary.
523                  const item = fx.item
524                  fseq += 1
525                  const r = requestFile(at, fseq, 'install', item.caller, {
526                    kind: 'phase', path: item.path, title: item.title, rules: item.rules ?? [], candidates: item.candidates ?? [],
527                  })
528                  await $.fs.write(`${inbox}/${r.name}`, r.body)
529                  await log('present.replan', { id, caller: item.caller })
530                } else if (fx.do === 'present') {
531                  // A dialog queued for the presentation tick (once per request id).
532                  const item = fx.item
533                  const after = await update($, stateAtom, s =>
534                    s === null || s.present.some(p => p.id === item.id) ? s : { ...s, present: [...s.present, item] })
535                  if (after !== null) {
536                    await $.fs.write(statePath(base, sidNow), serializeState(after))
537                  }
538                  await log('present.queued', { id, kind: item.kind, caller: item.caller })
539                } else if (fx.do === 'notice') {
540                  // A loop notice (3.23 T5b): a framed `other` message, filed like any other prompt; `child` names its
541                  // addressee (a caller) or ''.
542                  const f = fx
543                  jseq += 1
544                  const seq = jseq
545                  const after = await update($, stateAtom, s => (s === null ? s : fileEntry(
546                    enqueue(s, 'other', framedOther(req.caller || 'pa', f.text), f.to, at),
547                    journalOut(at, seq, 'mod', 'prompt', { session: s.sid, role: req.caller || 'pa' }, f.text))))
548                  if (after !== null) {
549                    await $.fs.write(statePath(base, sidNow), serializeState(after))
550                  }
551                  await log('inbox.notice', { id, to: f.to, text: clip(f.text, 200) })
552                } else if (fx.do === 'topic' || fx.do === 'toast' || fx.do === 'stop') {
553                  // The topic flag on the calling partner (rotate at the next message); an open ask's toast; T13b: a
554                  // loop stop's toast cause, owed to Claude's next reply (null: Claude's ask took its place). T13b.c2: a
555                  // loop ask while a stop's toast is owed adds none (its picker waits out Claude's turn; that toast covers it).
556                  const f = fx
557                  const after = await update($, stateAtom, s => {
558                    if (s === null) {
559                      return s
560                    }
561                    if (f.do === 'stop') {
562                      return { ...s, stopToast: f.cause }
563                    }
564                    if (f.do === 'toast') {
565                      // 3.24 T13E: a loop toast (the generation end's) drops an armed your-turn toast: one toast, not two.
566                      const t = req.caller === 'loop' ? { ...s, yourTurn: null } : s
567                      return req.caller === 'loop' && s.stopToast !== null ? t : { ...t, toasts: [...t.toasts, toastOut(f.cause, f.body, at)] }
568                    }
569                    const p = s.partner
570                    return p === null || (f.agentId !== '' && f.agentId !== p.agentId) ? s : { ...s, partner: { ...p, topic: true } }
571                  })
572                  if (after !== null) {
573                    await $.fs.write(statePath(base, sidNow), serializeState(after))
574                  }
575                  await log(`inbox.${fx.do}`, { id, agentId: f.do === 'topic' ? f.agentId : '', ...(f.do === 'stop' ? { cause: f.cause } : {}) })
576                } else if (fx.do === 'card') {
577                  // 3.24 T13E: a card of the loop's (the review release's accepted card): registered so the AssistantMessage
578                  // rule draws it, journaled, and queued as a reply marker (the route tick sends it as a feed reply
579                  // record or the marker prompt; its local turn answers the card as it is). Zero tokens.
580                  const c = fx.text
581                  await update($, cardsAtom, cs => (cs.includes(c) ? cs : [...cs, c].slice(-MAX_CARDS)))
582                  await keepDrawn($, d => ({ ...d, cards: mergeDrawn(d.cards, [c], x => x, MAX_CARDS) }))
583                  jseq += 1
584                  const seq = jseq
585                  const n = { v: 0 }
586                  const after = await update($, stateAtom, s => {
587                    if (s === null) {
588                      return s
589                    }
590                    const out = journalOut(at, seq, 'mod', 'card', { session: s.sid }, c)
591                    n.v = nextMarker(s)
592                    return { ...fileEntry(s, out), markers: { ...s.markers, [String(n.v)]: `${root}/${out.rel}` }, pendingMarkers: [...s.pendingMarkers, n.v] }
593                  })
594                  if (after !== null) {
595                    await $.fs.write(statePath(base, sidNow), serializeState(after))
596                  }
597                  await log('inbox.card', { id, n: n.v, text: clip(c, 120) })
598                } else if (fx.do === 'retrieve') {
599                  // 3.3 T7: an external coder's lookup (pa_ext.py ask): PA3's retriever in this session, on the
600                  // subscription; prompt.submit writes its hand-back's text to `answer`, then `answer.done`, never to
601                  // Claude. Pending in the state (`pending`, kind `retrieve`, id the answer path) across restarts.
602                  const f = fx
603                  const before = new Set((await $.agent.list().catch(() => [])).map(a => a.id))
604                  const r = await $.agent.spawn({ subagentType: RETRIEVE_TYPE, prompt: retrievePrompt(f.question), description: `Lookup for External coder ${f.label}`.trim() })
605                  const added = r.deny !== undefined ? [] : (await $.agent.list().catch(() => [])).filter(a => !before.has(a.id))
606                  const aid = r.deny !== undefined ? '' : r.agentId ?? (added.filter(a => a.type === RETRIEVE_TYPE).pop() ?? added[added.length - 1])?.id ?? ''
607                  if (aid === '') {
608                    await $.fs.write(f.answer, noAnswer(`the retriever spawn was refused (${clip(r.deny ?? 'no agentId', 200)})`))
609                    await $.fs.write(`${f.answer}.done`, '')
610                  } else {
611                    const after = await update($, stateAtom, s => (s === null ? s
612                      : { ...s, pending: [...s.pending.filter(p => p.id !== f.answer), { id: f.answer, agentId: aid, kind: 'retrieve', at }] }))
613                    if (after !== null) {
614                      await $.fs.write(statePath(base, sidNow), serializeState(after))
615                    }
616                  }
617                  await log('inbox.retrieve', { id, agentId: aid, label: f.label, answer: f.answer })
618                }
619              }
620            }
621          } catch (err) {
622            if (debugBase !== '') {
623              await $.fs.write(...ev('inbox.error', { err: clip(String(err), 300) })).catch(quiet)
624            }
625          } finally {
626            draining = false
627          }
628        })()
629      })
630
631      // 3.28.1 T12: the partner's own turn has ended (a turn end after the last message delivered to it); its idle
632      // stretch still has warming pings; it is cold (policy.isCold), idle and not working on a message.
633      const partnerIdle = (x: RouterState): boolean =>
634        x.partner !== null && x.partner.lastTurnAt > 0 && x.partner.lastTurnAt >= x.sentAt && !x.partner.pingInFlight
635      const partnerPingsLeft = (x: RouterState): boolean =>
636        x.warm.waitPings < (x.warm.waitCount > 0 ? x.warm.waitCount : pingCount(x.partner?.ctx ?? 0, 'claude'))
637      const partnerCold = (x: RouterState, list: readonly { id: string; status: string }[], now: number): boolean => {
638        const p = x.partner
639        if (p === null || !partnerIdle(x)) {
640          return false
641        }
642        const status = partnerStatus(list.find(a => a.id === p.agentId)?.status, p.lastTurnAt, true)
643        return (status === 'idle' || status === 'completed') &&
644          isCold(Math.max(p.lastTurnAt, p.spawnedAt, x.warm.lastPingAt), now, '1h', partnerPingsLeft(x), pingAfterMs)
645      }
646      // The loop's expert `id`, cold (autopilot's coldOf): nothing is sent to it; the loop's `forward` request with the
647      // text has the autopilot start a fresh expert with PROGRESS and the text.
648      const loopExpertCold = async (x: RouterState, id: string, now: number): Promise<boolean> => {
649        const t = parseLoop(await $.fs.read(`${root}/${LOOP_FILE}`).catch(() => null)).task
650        if (t === null || t.agentId !== id || id === '' || t.lastCompleteAt <= 0) {
651          return false
652        }
653        const w = x.warmAgents[id]
654        const ttl = cacheTtlOf(await $.fs.read(`${root}/.claude/agents/${t.agent}.md`).catch(() => null))
655        const pingsLeft = w === undefined || w.waitCount === 0 || w.waitPings < w.waitCount
656        return isCold(Math.max(t.lastCompleteAt, t.spawnedAt, w?.lastPingAt ?? 0), now, ttl, pingsLeft)
657      }
658      const toColdExpert = async (id: string, child: string, text: string): Promise<void> => {
659        fseq += 1
660        const now = await $.clock.now()
661        const r = requestFile(now, fseq, 'forward', 'router', { agentId: id, child, text })
662        await $.fs.write(`${base}/${sidNow}/inbox/${r.name}`, r.body).catch(quiet)
663        await log('route.cold', { agentId: id, child })
664      }
665      // The route tick: the journal's queued entries, the partner's resume check, the outbox, then the reply markers
666      // and the waiting toasts, each transition saved. Steps run here: `$.prompt.submit` threw "no session is bound"
667      // from a timer made inside an agent's dispatch.
668      const stPath = statePath(base, sidNow)
669      const routeTimer = $.clock.every(DRAIN_MS, () => {
670        void (async () => {
671          if (isStale(gen, await read($, genAtom))) {
672            routeTimer.cancel()
673            return
674          }
675          if (routing || !(await read($, routeAtom))) {
676            return
677          }
678          routing = true
679          const step = async (f: (x: RouterState) => RouterState): Promise<RouterState | null> => {
680            const after = await update($, stateAtom, x => (x === null ? x : f(x)))
681            if (after !== null) {
682              await $.fs.write(stPath, serializeState(after))
683            }
684            return after
685          }
686          // Each queued entry written, then dropped from the queue (a rewrite is the same file), then the month's index.
687          const flushJournal = async (): Promise<void> => {
688            const months = new Set<string>()
689            let cur = await read($, stateAtom)
690            while (cur !== null && cur.journal.length > 0) {
691              const out = cur.journal[0]
692              if (out === undefined) {
693                break
694              }
695              await $.fs.write(`${root}/${out.rel}`, out.text)
696              months.add(parseEntryPath(out.rel)?.date.slice(0, 7) ?? '')
697              cur = await step(x => ({ ...x, journal: x.journal.filter(j => j.rel !== out.rel) }))
698            }
699            for (const m of months) {
700              if (m !== '') {
701                await syncIndex(m)
702              }
703            }
704          }
705          // A fresh partner's seed (seed.ts): the last 14 days' conversation, the running work, the plans.
706          const seedFor = async (sending: string): Promise<string> => {
707            const now = await $.clock.now()
708            const since = stamp(now - SEED_DAYS * 86_400_000)[0]
709            const entries: Entry[] = []
710            for (const d of (await days()).filter(n => n >= since)) {
711              const day = await rolled(d)
712              entries.push(...day.filter(e => isSeedName(e.path)))
713              for (const rel of (await dayFiles(d)).filter(r => isSeedName(r) && !r.includes('.md#'))) {
714                const e = await readEntry(rel)
715                if (e !== null) {
716                  entries.push(e)
717                }
718              }
719            }
720            const cur = await read($, stateAtom)
721            const old = cur?.partner?.agentId ?? ''
722            const children: SeedChild[] = (await $.agent.list())
723              .filter(a => old !== '' && a.parentId === old && isBusy(a.status))
724              .map(a => ({ who: a.type, agentId: a.id, status: a.status }))
725            const planPaths: string[] = []
726            for (const rel of ['GENERATION_PLAN.md', 'phase-ends/current/PHASE_PLAN.md']) {
727              if (await $.fs.exists(`${root}/${rel}`)) {
728                planPaths.push(rel)
729              }
730            }
731            if (await $.fs.exists(`${root}/.run/plans`)) {
732              for (const f of await $.fs.list(`${root}/.run/plans`)) {
733                if (f.kind === 'file' && f.name.endsWith('.md')) {
734                  planPaths.push(`.run/plans/${f.name}`)
735                }
736              }
737            }
738            // 3.23 T7: the loop (loop.json, read only; its phase from the plan's first line) and the memory index, when present.
739            const loopText = await $.fs.read(`${root}/${LOOP_FILE}`).catch(() => null)
740            const planText = await $.fs.read(`${root}/phase-ends/current/PHASE_PLAN.md`).catch(() => null)
741            const loop = loopText === null ? undefined : { state: parseLoop(loopText), phase: planText === null ? '' : planPhase(planText).phase }
742            const memory = (await $.fs.read(`${root}/.claude-state/memory/MEMORY.md`).catch(() => null)) ?? undefined
743            // 3.24 T7b: Claude's card slice (`card.py slice claude`), so the card reaches every fresh Claude; none on a failure.
744            const sl = python === '' ? null : await $.process.run([python, 'tools/card.py', 'slice', 'claude'], { cwd: root, timeoutMs: 15_000 }).catch(() => null)
745            const card = sl !== null && sl.exitCode === 0 && sl.stdout.trim() !== '' ? sl.stdout : undefined
746            // 3.24 T7b-2c: a pending first run (pa.json) says plans wait for the memory curator.
747            const firstRun = firstRunLine(await $.fs.read(`${root}/.claude/pa.json`).catch(() => null), '') === '' ? undefined : FIRST_RUN_WAITS
748            return buildSeed(seedEntries(entries, sending), { children, planPaths, budgetTokens: SEED_BUDGET_TOKENS, session: cur?.sid || undefined, loop, memory, card, firstRun })
749          }
750          const spawnPartner = async (text: string, why: string): Promise<void> => {
751            const before = (await read($, stateAtom))?.partner?.agentId ?? ''
752            const seed = await seedFor(text)
753            const spawned = await $.agent.spawn({ subagentType: PARTNER_TYPE, prompt: `${seed}\n\n${text}`, description: 'Claude' })
754            // agentId is optional in the result: absent, the newest live partner the list shows is the one started.
755            const id = spawned.deny !== undefined
756              ? undefined
757              : spawned.agentId ?? newestPartner(await $.agent.list(), before)
758            if (typeof id === 'string' && id !== '') {
759              const at = await $.clock.now()
760              const model = spawned.model ?? ''
761              const seedTokens = Math.round(seed.length / 4)
762              jseq += 1
763              const out = journalOut(at, jseq, 'mod', 'start', { session: sidNow, agentId: id, model },
764                `Fresh partner ${id} (${why}); seed ~${String(seedTokens)} tokens.${before === '' ? '' : ` Replaces ${before}.`}`)
765              await step(x => fileEntry({ ...x, partner: newPartner(id, model, at), awaiting: null }, out))
766              await log('route.spawn', { why, agentId: id, model, seedTokens, from: before })
767              await flushJournal()
768            } else {
769              await log('route.spawn.deny', { why, result: clip(spawned, 300) })
770            }
771          }
772          try {
773            await flushJournal()
774            let st = await read($, stateAtom)
775            if (st === null) {
776              return
777            }
778            const now = await $.clock.now()
779            const aw = st.awaiting
780            if (aw !== null) {
781              const listed = (await $.agent.list()).find(a => a.id === aw.agentId)
782              if (isLive(listed?.status)) {
783                st = await step(x => ({ ...x, awaiting: null }))
784                await log('route.seen', { agentId: aw.agentId, status: listed?.status })
785              } else if (now - aw.at >= RESUME_CHECK_MS) {
786                // After --resume the old partner is gone: the message seeds a fresh one.
787                await log('route.resume.fail', { agentId: aw.agentId, status: listed?.status ?? 'unlisted' })
788                await step(x => ({ ...x, awaiting: null }))
789                await spawnPartner(aw.text, 'resume-check')
790                st = await read($, stateAtom)
791              }
792            }
793            while (st !== null && !outboxHeld(st) && st.outbox.length > 0) {
794              if (isStale(gen, await read($, genAtom))) {
795                return
796              }
797              const cur: RouterState = st
798              const msg = cur.outbox[0]
799              // 3.28.8: a developer message with attachments waits for Claude Code's source lines (IMAGE_WAIT_MS at most).
800              if (msg?.kind === 'dev' && imagesPending(imageWait, await $.clock.now())) {
801                break
802              }
803              // Taken before acting: a crash mid-send never sends twice.
804              st = await step(x => ({ ...x, outbox: x.outbox.slice(1) }))
805              if (msg === undefined) {
806                continue
807              }
808              if (msg.kind === 'forward') {
809                const list = await $.agent.list()
810                const parent = parentOf(cur.children, msg.child, list)
811                const status = parent === null ? undefined : list.find(a => a.id === parent)?.status
812                if (parent === null || isLive(status)) {
813                  await log('forward.skip', { child: msg.child, parent, status })
814                  continue
815                }
816                // 3.28.1 T12: never to a cold Claude; a fresh one, seeded from the journal, takes the hand-back.
817                if (parent === cur.partner?.agentId && partnerCold(cur, list, await $.clock.now())) {
818                  await spawnPartner(msg.text, 'cold')
819                  st = await step(x => ({ ...x, sentKind: 'forward', sentAt: x.partner?.spawnedAt ?? 0 }))
820                  continue
821                }
822                if (await loopExpertCold(cur, parent, await $.clock.now())) {
823                  await toColdExpert(parent, msg.child, msg.text)
824                  continue
825                }
826                const sent = await $.session.send({ to: { agentId: parent }, text: msg.text })
827                await log('forward.sent', { child: msg.child, parent, status: status ?? 'unlisted', sent })
828                if (sent.isDelivered && parent === cur.partner?.agentId) {
829                  const at = await $.clock.now()
830                  st = await step(x => ({ ...x, sentKind: 'forward', sentAt: at }))
831                }
832                continue
833              }
834              const p = cur.partner
835              // A loop notice's reply goes to its caller when that is a listed agent other than the partner.
836              const direct = msg.kind === 'other' && msg.child !== '' ? otherTarget(msg.child, p?.agentId ?? '', await $.agent.list()) : ''
837              if (direct !== '' && (await loopExpertCold(cur, direct, await $.clock.now()))) {
838                await toColdExpert(direct, '', msg.text)
839                continue
840              }
841              if (direct !== '') {
842                const sent = await $.session.send({ to: { agentId: direct }, text: msg.text })
843                await log('route.direct', { agentId: direct, sent })
844                continue
845              }
846              let route: 'resume' | 'fresh' = 'fresh'
847              let cold = false
848              if (p !== null) {
849                const list = await $.agent.list()
850                const out = childOut(p.agentId, cur.children, list)
851                // 3.28.1 T12: busy by its warming status (a turn ended, listed `waiting` on its own work, is not busy).
852                const busy = isBusy(partnerStatus(list.find(a => a.id === p.agentId)?.status, p.lastTurnAt, partnerIdle(cur)))
853                const now = await $.clock.now()
854                route = partnerRoute(p, now, { childOut: out, busy, rotateAt, pingsLeft: partnerPingsLeft(cur) })
855                cold = route === 'fresh' && partnerCold(cur, list, now)
856                if (route === 'fresh') {
857                  await log('route.rotate', { agentId: p.agentId, ctx: p.ctx, topic: p.topic, lastTurnAt: p.lastTurnAt, rotateAt, busy, cold })
858                }
859              }
860              if (p !== null && route === 'resume') {
861                const sent = await $.session.send({ to: { agentId: p.agentId }, text: msg.text })
862                await log('route.send', { agentId: p.agentId, kind: msg.kind, sent })
863                if (sent.isDelivered) {
864                  const at = await $.clock.now()
865                  st = await step(x => ({ ...x, awaiting: { agentId: p.agentId, text: msg.text, at }, sentKind: msg.kind, sentAt: at }))
866                  continue
867                }
868              }
869              await spawnPartner(msg.text, p === null ? 'none' : cold ? 'cold' : route === 'fresh' ? 'rotate' : 'undelivered')
870              st = await step(x => ({ ...x, sentKind: msg.kind, sentAt: x.partner?.spawnedAt ?? 0 }))
871            }
872            // Markers only for entries on disk: those queued before this flush (a later answer waits a tick). An answer
873            // whose hand-back did not come within HANDBACK_WAIT_MS gets its marker (T8d's fallback).
874            await flushJournal()
875            st = await read($, stateAtom)
876            const tDue = await $.clock.now()
877            if (st !== null && dueMarkers(st, tDue) !== st) {
878              const before = st.pendingMarkers.length
879              st = await step(x => dueMarkers(x, tDue))
880              await log('route.fallback', { markers: st?.pendingMarkers.slice(before) })
881            }
882            const ready = st?.pendingMarkers ?? []
883            for (const n of ready) {
884              if (isStale(gen, await read($, genAtom))) {
885                return
886              }
887              st = await step(x => ({ ...x, pendingMarkers: x.pendingMarkers.filter(k => k !== n) }))
888              // 3.24 T13B (decision 3): while the feed lives (a fresh heartbeat, no launcher error), the reply goes as a
889              // feed record (feed.ts replyRecord): a feed turn draws it as a Claude card and its notification row is
890              // hidden; the marker prompt's "Prompt from the pa plugin" row is not. Its number and file stay the marker's.
891              const fdir = `${root}/${st?.feed.dir ?? feedDirOf(sidNow)}`
892              const tNow = await $.clock.now()
893              const beat = beatOf(await $.fs.read(`${fdir}/alive`).catch(() => null))
894              const path = st?.markers[String(n)]
895              if (beat !== null && tNow - beat < FEED_STALE_MS && (await read($, lifeAtom)).error === '' && path !== undefined) {
896                const entry = await $.fs.read(path).catch(() => null)
897                const reply = entry === null ? null : markerReply(entry)
898                if (entry !== null && reply !== null && reply.trim() !== '') {
899                  nseq += 1
900                  const mf = markerFacts(entry) // 3.24 T17g: the card's title names the answer's model and effort
901                  await $.fs.write(`${fdir}/in/${nudgeName(tNow, nseq, '7')}`, replyRecord({ agent: st?.partner?.agentId ?? '', n, text: reply, at: tNow, shown: shownOf(mf.model, mf.effort) }))
902                  await log('route.marker', { n, mode: 'feed' })
903                  continue
904                }
905              }
906              // asUser: a plugin-framed prompt row escapes every render hook (T11 recheck, no UserMessage call for
907              // it); a prompt submitted as the user's is drawn as a user row, which the UserMessage rule hides
908              await $.prompt.submit({ text: markerText(n), asUser: true })
909              await log('route.marker', { n, mode: 'marker' })
910            }
911            // 3.23 T13d.c10: the your-turn toast (route.yourTurnStep): Claude's reply landed, no agent live, YOUR_TURN_MS
912            // without a developer message; once per reply; dropped under a dialog or a stop's owed toast, or an agent started.
913            const yt = st?.yourTurn ?? null
914            if (st !== null && yt !== null && !yt.fired) {
915              const live = workingIds(await $.agent.list().catch(() => [])) // 3.24 T17g: a waiting agent works
916              const held = presenting || st.present.length > 0 ? 'dialog' : st.stopToast !== null ? 'stop' : ''
917              const at = await $.clock.now()
918              // 3.24 T17j: a hand-back the loop has yet to act on (its review, close question or park brings its own toast)
919              const lp = parseLoop(await $.fs.read(`${root}/${LOOP_FILE}`).catch(() => null))
920              const go = yourTurnStep(yt, at, live, st.partner?.agentId ?? '', held, ownsLoop(lp, sidNow, at) ? loopPending(lp) : '')
921              const mine = (x: RouterState): boolean => x.yourTurn?.turnId === yt.turnId
922              if (go.do === 'wait' && go.why !== undefined && turnWaitLogged !== `${yt.turnId} ${go.why}`) {
923                turnWaitLogged = `${yt.turnId} ${go.why}`
924                await log('yourturn.wait', { turnId: yt.turnId, why: go.why })
925              }
926              if (go.do === 'fire') {
927                const t: ToastOut = { cause: 'input', body: yt.body, at, turn: true }
928                st = await step(x => (mine(x) ? { ...x, yourTurn: { ...yt, fired: true }, toasts: [...x.toasts, t] } : x))
929                await log('yourturn.fire', { turnId: yt.turnId, waitedMs: at - yt.at })
930              } else if (go.do === 'drop') {
931                st = await step(x => (mine(x) ? { ...x, yourTurn: null } : x))
932                await log('yourturn.drop', { turnId: yt.turnId, why: go.why, agents: go.agents })
933              }
934            }
935            // The waiting toast, once per answer that asks (or open ask), through PA3's own toast().
936            // 3.24 T17t: a stop's toast only once due (route.dueToast: 60 s of developer quiet); it waits in `toasts`.
937            const tDrain = st !== null && st.toasts.length > 0 ? await $.clock.now() : 0
938            while (st !== null && st.toasts.some(x => dueToast(x, tDrain))) {
939              const t = st.toasts.find(x => dueToast(x, tDrain))
940              st = await step(x => {
941                const i = x.toasts.findIndex(y => dueToast(y, tDrain))
942                return i < 0 ? x : { ...x, toasts: x.toasts.filter((_, j) => j !== i) }
943              })
944              if (t === undefined) {
945                continue
946              }
947              let why = python === '' || ledger === '' ? 'no python or home' : ''
948              if (why === '') {
949                try {
950                  const ran = await $.process.run(toastArgv(python, ledger, sidNow, root, t), { cwd: root, timeoutMs: 10_000 })
951                  why = ran.exitCode === 0 ? '' : `exit ${String(ran.exitCode)}: ${clip(ran.stderr, 200)}`
952                } catch (err) {
953                  why = clip(String(err), 200)
954                }
955              }
956              if (why === '') {
957                await log('toast', { cause: t.cause })
958              } else if (!noToastLogged) {
959                noToastLogged = true
960                await log('toast.unavailable', { why })
961              }
962            }
963          } catch (err) {
964            await log('route.error', { err: clip(String(err), 300) })
965          } finally {
966            routing = false
967          }
968        })()
969      })
970
971      // T8c: a state transition saved (the presentation tick's).
972      const save = async (f: (x: RouterState) => RouterState): Promise<void> => {
973        const after = await update($, stateAtom, x => (x === null ? x : f(x)))
974        if (after !== null) {
975          await $.fs.write(statePath(base, sidNow), serializeState(after))
976        }
977      }
978      // Path 0 (3.21 P7, its verified order; T8e): EnterPlanMode; the main plan file known for this session, else this
979      // session's transcript slug names it, else an ExitPlanMode with no plan written answers its `filePath` (one extra
980      // "Exit plan mode?" dialog per session) and EnterPlanMode again; write the plan there; ExitPlanMode: its result is
981      // the decision. (Outside plan mode ExitPlanMode errors on 2.1.293: T11.) Any step failing: the fallback, a Markdown
982      // pane and `$.ui.ask`. Zero model tokens either way; never the main model.
983      const presentPlan = async (p: Present): Promise<string> => {
984        const src = planFilePath(root, p.path)
985        const text = await $.fs.read(src).catch(() => null)
986        // 3.23 T13d.c3: no `▣ plan presented` line; the plan itself is the screen's (the dialog, the pane or the fallback lines).
987        // 3.24 T13F: the dialog is shown a copy within its limits (a long line soft-wrapped); the draft stays as it is,
988        // and a copy still past a limit goes to the fallback, before plan mode is entered.
989        const shown = dialogCopy(text ?? '')
990        let why = text === null ? `plan file unreadable: ${src}` : shown.why === '' ? '' : `the dialog would withhold it: ${shown.why}`
991        let line = ''
992        // 3.24 T13F: in plan mode by the router's EnterPlanMode and not yet out by an approval or the learning exit.
993        let inPlan = false
994        const enterPlan = async (step: string): Promise<void> => {
995          try {
996            const enter = await $.tool.call({ tool: 'EnterPlanMode' })
997            await log(step, { result: clip(enter, 300), mode: modeSeen })
998            if (enter.deny !== undefined) {
999              why = `enter denied: ${clip(enter.deny, 200)}`
1000            } else {
1001              inPlan = true
1002            }
1003          } catch (err) {
1004            why = `enter: ${clip(String(err), 200)}`
1005          }
1006        }
1007        if (why === '') {
1008          await enterPlan('plan.enter')
1009        }
1010        const known = (await read($, stateAtom))?.planFile
1011        let file = known?.sid === sidNow ? known.path : ''
1012        if (why === '' && file === '') {
1013          const transcript = transcriptSeen.sid === sidNow ? transcriptSeen.path : ''
1014          const argv = slugArgv(python, transcript)
1015          const ran = argv === null ? null : await $.process.run(argv, { cwd: root, timeoutMs: 10_000 }).catch(() => null)
1016          file = slugFile(ran)
1017          await log('plan.slug', { transcript, file, exitCode: ran?.exitCode ?? null, stderr: clip(ran?.stderr ?? '', 200) })
1018        }
1019        if (why === '' && file === '') {
1020          // 3.23 T13b: `learning` set just around this call; the `tool.check` hook allows it without the dialog.
1021          await save(x => ({ ...x, learning: true }))
1022          try {
1023            const learned = await $.tool.call({ tool: 'ExitPlanMode' })
1024            file = learnedFile(learned)
1025            inPlan = file === ''
1026            await log('plan.learn', { result: clip(learned, 600), file, mode: modeSeen })
1027            if (file === '') {
1028              why = 'no plan file learned'
1029            }
1030          } catch (err) {
1031            why = `learn: ${clip(String(err), 200)}`
1032          } finally {
1033            await save(x => ({ ...x, learning: false }))
1034          }
1035          if (why === '') {
1036            await enterPlan('plan.enter.again')
1037          }
1038        }
1039        if (why === '' && known?.path !== file) {
1040          const pf = { sid: sidNow, path: file }
1041          await save(x => ({ ...x, planFile: pf }))
1042        }
1043        if (why === '') {
1044          try {
1045            await $.fs.write(file, shown.copy)
1046            if (shown.copy !== text) {
1047              await log('plan.wrap', { lines: (text ?? '').split('\n').length, shown: shown.copy.split('\n').length })
1048            }
1049          } catch (err) {
1050            why = `write: ${clip(String(err), 200)}`
1051          }
1052        }
1053        if (why === '') {
1054          try {
1055            const exit = await $.tool.call({ tool: 'ExitPlanMode' })
1056            const d = decisionLine(exit)
1057            await log('plan.exit', { result: clip(exit, 3000), mode: modeSeen, line: clip(d.line, 300) })
1058            if (!d.known) {
1059              await log('decision.unknown', { result: clip(exit, 3000) })
1060            }
1061            line = d.line
1062            inPlan = line !== APPROVED
1063          } catch (err) {
1064            why = `exit: ${clip(String(err), 200)}`
1065          }
1066        }
1067        if (inPlan) {
1068          // 3.24 T13F: a decision that is not an approval (changes asked, rejected, withheld then Esc) or a failed step
1069          // leaves the engine in plan mode; round 3's partner held off on its reminder, idle. One more exit with the
1070          // allow flag up leaves it; `mode` after it is the mode the engine restored.
1071          await save(x => ({ ...x, learning: true }))
1072          try {
1073            const left = await $.tool.call({ tool: 'ExitPlanMode' })
1074            await log('plan.leave', { result: clip(left, 600), mode: modeSeen })
1075          } catch (err) {
1076            await log('plan.leave', { err: clip(String(err), 200), mode: modeSeen })
1077          } finally {
1078            await save(x => ({ ...x, learning: false }))
1079          }
1080        }
1081        if (why !== '') {
1082          await log('plan.fallback', { id: p.id, why })
1083          const shown = text ?? `(The plan file ${p.path} could not be read.)`
1084          await update($, paneAtom, () => `# ${p.title}\n\n${shown}`)
1085          // `focus: true` tried (T8e): whether a focused open is placed below 144 columns is logged with the width's reason.
1086          const opened = await $.ui.open({ id: PLAN_PANE, title: `Plan: ${p.title}`, focus: true }).catch((err: unknown) => ({ isPlaced: false, reason: String(err) }))
1087          await log('plan.pane', { opened: clip(opened, 200), focus: true })
1088          if (!opened.isPlaced) {
1089            // The pane waits undrawn: the plan goes into the transcript, line by line, before the picker.
1090            for (const l of planLogLines(p.title, shown)) {
1091              $.ui.log(l, { to: 'transcript' })
1092            }
1093          }
1094          let answer: string | null = null
1095          try {
1096            answer = await $.ui.ask(FALLBACK_QUESTION, [...FALLBACK_OPTIONS])
1097          } catch (err) {
1098            await log('plan.ask.dismissed', { err: clip(String(err), 200) })
1099          }
1100          await $.ui.close({ id: PLAN_PANE }).catch(quiet)
1101          await update($, paneAtom, () => '')
1102          line = fallbackLine(answer)
1103        }
1104        const at = await $.clock.now()
1105        jseq += 1
1106        const planOut = journalOut(at, jseq, 'mod', 'plan', { session: sidNow, agentId: p.caller }, planBody(p, text ?? ''))
1107        jseq += 1
1108        const decOut = journalOut(at, jseq, 'dev', 'decision', { session: sidNow, agentId: p.caller }, line)
1109        await save(x => fileEntry(fileEntry(x, planOut), decOut))
1110        return line
1111      }
1112      // An ask: `$.ui.ask` under the same deferral; a dismissal (a rejection) answers `dismissed`.
1113      const presentAsk = async (p: Present): Promise<string> => {
1114        let answer: string | null = null
1115        try {
1116          answer = await $.ui.ask(p.question, p.options)
1117        } catch (err) {
1118          await log('ask.dismissed', { id: p.id, err: clip(String(err), 200) })
1119        }
1120        const line = answerLine(answer)
1121        const at = await $.clock.now()
1122        jseq += 1
1123        const askOut = journalOut(at, jseq, 'mod', 'prompt', { session: sidNow, agentId: p.caller, role: 'ask' }, askBody(p))
1124        jseq += 1
1125        const decOut = journalOut(at, jseq, 'dev', 'decision', { session: sidNow, agentId: p.caller }, line)
1126        await save(x => fileEntry(fileEntry(x, askOut), decOut))
1127        return line
1128      }
1129      // 3.23 T6a1, PA_MOD_TRIAL_APPROVE=1: no dialog; a plan approved, an ask its first option; logged and journaled.
1130      const presentTrial = async (p: Present): Promise<string> => {
1131        const line = trialLine(p)
1132        await log('present.trial', { id: p.id, kind: p.kind, line })
1133        const text = p.kind === 'plan' ? await $.fs.read(planFilePath(root, p.path)).catch(() => '') : ''
1134        const at = await $.clock.now()
1135        jseq += 1
1136        const shown = p.kind === 'plan'
1137          ? journalOut(at, jseq, 'mod', 'plan', { session: sidNow, agentId: p.caller }, planBody(p, text))
1138          : journalOut(at, jseq, 'mod', 'prompt', { session: sidNow, agentId: p.caller, role: 'ask' }, askBody(p))
1139        jseq += 1
1140        const decOut = journalOut(at, jseq, 'dev', 'decision', { session: sidNow, agentId: p.caller }, line)
1141        await save(x => fileEntry(fileEntry(x, shown), decOut))
1142        return line
1143      }
1144      const dialogPath = `${base}/${sidNow}/${DIALOG_FILE}`
1145
1146      // The presentation tick (T8c, planview.ts): the head of `present`, one dialog at a time, never while an agent is
1147      // running, pending or waiting (a mode switch reaches it) nor over a draft. 3.23 T13d.c7: that agent rule is the
1148      // plans'; an ask waits only for its caller running or pending, or an open permission dialog (3.24 T8, planview.ts
1149      // holdingAgent; a loop ask also for Claude's running turn). Plan mode and `$.ui.ask` work only from
1150      // a tick made here: from a timer made in an agent's dispatch they run in that agent's loop (design.md).
1151      const presentTimer = $.clock.every(DRAIN_MS, () => {
1152        void (async () => {
1153          if (isStale(gen, await read($, genAtom))) {
1154            presentTimer.cancel()
1155            return
1156          }
1157          if (presenting || !(await read($, activeAtom))) {
1158            return
1159          }
1160          const head = (await read($, stateAtom))?.present[0]
1161          if (head === undefined) {
1162            return
1163          }
1164          presenting = true
1165          try {
1166            const holder = holdingAgent(head, await $.agent.list(), await read($, dialogsAtom)) // an ask waits only for real conflicts
1167            const hold = presentHold({ busy: holder !== null, draft: (await $.prompt.read()).text })
1168            if (hold !== '') {
1169              const seen = `${hold}|${holder?.id ?? ''}|${holder?.status ?? ''}`
1170              if (seen !== lastHold) {
1171                lastHold = seen
1172                await log('present.defer', { id: head.id, why: hold, agent: holder?.id ?? '', status: holder?.status ?? '' })
1173              }
1174              return
1175            }
1176            lastHold = ''
1177            await $.fs.write(dialogPath, dialogJson(true, head.id, await $.clock.now()))
1178            // Taken before acting: a crash mid-dialog never shows it twice.
1179            await save(x => ({ ...x, present: x.present.filter(p => p.id !== head.id) }))
1180            // 3.24 T7b-2c: while the project's first run is pending (pa.json), no plan is presented; Claude is told why.
1181            if (head.kind === 'plan' && firstRunLine(await $.fs.read(`${root}/.claude/pa.json`).catch(() => null), '') !== '') {
1182              const at = await $.clock.now()
1183              await $.fs.write(dialogPath, dialogJson(false, head.id, at))
1184              await save(x => enqueue(x, 'other', `Not presented: ${FIRST_RUN_WAITS}`, head.caller, at))
1185              await log('present.firstrun', { id: head.id, caller: head.caller })
1186              return
1187            }
1188            let line = ''
1189            try {
1190              line = (await $.env.get('PA_MOD_TRIAL_APPROVE')) === '1'
1191                ? await presentTrial(head)
1192                : head.kind === 'plan' ? await presentPlan(head) : await presentAsk(head)
1193            } finally {
1194              await $.fs.write(dialogPath, dialogJson(false, head.id, await $.clock.now()))
1195            }
1196            const at = await $.clock.now()
1197            // 3.24 T17t: the developer answered: a stop's toast still waiting for his quiet is dropped.
1198            await save(heardDeveloper)
1199            if (head.caller === 'loop' || (installs(head) && line === APPROVED)) {
1200              // The loop's own ask answered, or an approved plan for the loop to install: a request in its inbox.
hooks/activation.ts 116 lines
1// Activation of the PA 3.2 mod (design.md, "Activation"): pure, so `claude plugin test` covers it. The watcher holds a
2// copy (mod/pa-watch/hooks/activation.ts); plugins never import across each other.
3
4/** The lowest Claude Code release the mod runs on: P3b, path 0 and the file channel were verified there (3.21). */
5export const VERSION_FLOOR = '2.1.292'
6
7export type Decision = { active: boolean; reason: string }
8
9/** `[major, minor, patch]` of a version's leading `x.y.z`, or `x.y` as `[x, y, 0]` (3.3 T7: VERSION 3.30), or null
10 * when it does not start with one. */
11export function parseVersion(version: string): [number, number, number] | null {
12  const m = /^\s*(\d+)\.(\d+)(?:\.(\d+))?/.exec(version)
13  if (m === null) {
14    return null
15  }
16  return [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)]
17}
18
19/** Numeric compare of `version` against `floor`; null when either does not parse. */
20export function versionAtLeast(version: string, floor: string): boolean | null {
21  const a = parseVersion(version)
22  const b = parseVersion(floor)
23  if (a === null || b === null) {
24    return null
25  }
26  for (let i = 0; i < 3; i += 1) {
27    if (a[i] !== b[i]) {
28      return (a[i] ?? 0) > (b[i] ?? 0)
29    }
30  }
31  return true
32}
33
34/** The release a `$.session.version()` answer names: its `base` when given, else its full `version`. */
35export function versionOf(v: { version: string; base?: string }): string {
36  return v.base ?? v.version
37}
38
39/** One string field of pa.json, or '' when absent, not a string, or pa.json does not parse. */
40export function paSetting(paJsonText: string | null, key: string): string {
41  if (paJsonText === null) {
42    return ''
43  }
44  try {
45    const doc: unknown = JSON.parse(paJsonText)
46    const value = typeof doc === 'object' && doc !== null ? (doc as Record<string, unknown>)[key] : undefined
47    return typeof value === 'string' ? value : ''
48  } catch {
49    return ''
50  }
51}
52
53/**
54 * On only with `.claude/pa.json` `"mod": "on"`. Off without pa.json, with any other value, under PA_MOD_OFF (non-empty
55 * and not "0"), or below Claude Code VERSION_FLOOR.
56 */
57export function decide(paJsonText: string | null, envOff: string | undefined, version: string): Decision {
58  if (envOff !== undefined && envOff !== '' && envOff !== '0') {
59    return { active: false, reason: 'PA_MOD_OFF is set' }
60  }
61  const atLeast = versionAtLeast(version, VERSION_FLOOR)
62  if (atLeast !== true) {
63    return { active: false, reason: `Claude Code ${version} is below ${VERSION_FLOOR} or unreadable` }
64  }
65  if (paJsonText === null) {
66    return { active: false, reason: 'no .claude/pa.json' }
67  }
68  let doc: unknown
69  try {
70    doc = JSON.parse(paJsonText)
71  } catch {
72    return { active: false, reason: 'pa.json does not parse' }
73  }
74  const mod = typeof doc === 'object' && doc !== null ? (doc as Record<string, unknown>)['mod'] : undefined
75  if (mod === 'on') {
76    return { active: true, reason: 'pa.json mod: on' }
77  }
78  return { active: false, reason: `pa.json mod: ${String(mod ?? 'absent')}` }
79}
80
81/**
82 * The ledger CLI (design.md, "Execution model"): `$PA_MOD_PA3_DIR/pa_ledger.py` when PA_MOD_PA3_DIR is set (trials point
83 * it at a snapshot's project-architect-3.0), else `<home>/.claude/pa3/pa_ledger.py`; '' with neither. Forward slashes.
84 */
85export function ledgerPath(pa3Dir: string | undefined, home: string | undefined): string {
86  if (pa3Dir !== undefined && pa3Dir !== '') {
87    return `${pa3Dir.replace(/\\/g, '/').replace(/\/+$/, '')}/pa_ledger.py`
88  }
89  if (home === undefined || home === '') {
90    return ''
91  }
92  return `${home.replace(/\\/g, '/')}/.claude/pa3/pa_ledger.py`
93}
94
95// 3.24 T6: the heartbeat and the error record pa/health.py reads under `<root>/.run/mod/` (the session-start and first
96// prompt checks); written only by an active session, so an inert one still writes nothing (3.24 T8).
97export const HEARTBEAT_FILE = '.run/mod/heartbeat.json'
98export const ERROR_FILE = '.run/mod/error.json'
99
100/** The PA3 release a VERSION file names: its `version:` line, else its first line; '' when absent. */
101export function releaseOf(text: string | null): string {
102  const lines = (text ?? '').split(/\r?\n/)
103  const v = lines.find(l => /^\s*version\s*:/.test(l))
104  return (v === undefined ? lines[0] ?? '' : v.slice(v.indexOf(':') + 1)).trim()
105}
106
107/** The heartbeat `{sid, version, engine, at, plugin}`: the PA3 release, the Claude Code release, `at` in seconds. */
108export function heartbeatText(sid: string, version: string, engine: string, ms: number, plugin: string): string {
109  return JSON.stringify({ sid, version, engine, at: ms / 1000, plugin }, null, 1)
110}
111
112/** The error record `{sid, plugin, hook, error, at}` of an activation hook that threw (`at` in seconds). */
113export function errorText(sid: string, plugin: string, hook: string, error: string, ms: number): string {
114  return JSON.stringify({ sid, plugin, hook, error: error.slice(0, 500), at: ms / 1000 }, null, 1)
115}
116
hooks/autopilot.tsx 3067 lines
1// PA 3.2 router plugin `pa`, the autopilot's effects (phase 3.23; 3.23-autopilot.md, "The loop (router `pa`)").
2// T4's probe: hooks.json `modules` takes one hooks module per plugin (validate refuses a second entry), so this is an
3// `on`-taking helper that register.tsx calls last; its hooks nest inside the router's (a plugin's registrations nest in
4// order, first outermost). An event the router also hooks repeats only under a matcher (`{}` matches every event).
5// T5a: the task loop's core. `.run/mod/loop.json` (project-level, loop.ts) is read at every tick and written after every
6// transition while this session owns the loop; the tick lives in session.start's closure under the router's generation
7// token. Boundary: launch.py --state-json, plan_edit.py next, the window gate, status.py set, the expert's spawn. Capture:
8// the loop agent's last STATUS block (hand-back report or turn.complete answer), held as `pendingContract` (journal
9// `T<n>/interim`) and routed only at loop.runEnded; else (run ended) its transcript, else a block (T5c.c3). Routing:
10// done / handoff / review / question-or-block (loop.routeStatus). Commits run under Git Bash (resolveBash). Hooks record
11// facts and return; steps run from the tick, each re-derived from loop.json, status.json and the plan.
12// T5b: stops reach Claude as `notice` requests in the router's inbox (the router's drain frames them for the partner,
13// or the caller, and raises the toast); the tick drains its own `release` requests from that inbox (done-marked in
14// loop.json `inboxDone` before acting; the router skips the kind) and applies loop.releaseHold. A `plan_edit` during a
15// review, question or (3.30.1) the developer's stop is a decision, applied at once and committed (what was queued
16// lands first); otherwise it is queued in `queuedEdits`. 3.30.1: before a stopped, questioned or cut-off task goes on,
17// the queue lands, and a task the plan has set back (loop.SET_BACK) is set aside with its progress, never resumed.
18// T5b2: queued edits land at the next boundary, before the pick, each leaving the queue once run. A developer prompt
19// while the loop is active pushes `talk` (recorded by this prompt.submit, pushed by the tick): no new task starts, the
20// running expert finishes and is routed. Claude's answers set `lastAnswerAsked`; silence (loop.silenceReleases) pops it.
21// T5c: `steer` requests (a loop kind) reach the running task's expert through `$.session.send`; nothing is ever stopped.
22// T5d1: the heartbeat every 15 s, stale after 45 s (loop.ts). A session that takes the loop over (another owner, or a
23// stale heartbeat) with a task running and no stop runs recovery: `continue:` to the recorded expert, then, not
24// delivered or not seen running within 30 s (or a stale run_in_flight), a respawn with PROGRESS, TASK_PROGRESS.md
25// written by the mod when the hook's kill digest is absent. A second live session is conversation-only (the watcher's
26// usage line says so).
27// T5d2a: the router's forward of a child's hand-back to the loop's expert reaches the tick as a `forward` request
28// (`task.forwardAt`, queued to the expert until its next turn.complete). A picked task's WAIT-FOR (`<file> contains
29// <literal>`, loop.json `waitFor`) is polled every 30 s and holds the queue; met, the task runs; after 50 min,
30// `task_log.py review` and the review stop. `.run/health.json` `savings_missing.hook_repair` at a boundary is a note to
31// Claude (a FIX promotes the root); the FIX spawn runs only with PA_MOD_FIX_SPAWN=1 and PA_MOD_PA3_DIR unset.
32// T6a1: with no phase plan and a generation plan, `held(plan)` and a "phase plan is needed" notice to Claude. The router's
33// `install` request (an approved generation or phase plan) runs here, a step a tick in loop.json `install`: a generation
34// plan written and committed; a phase draft linted (ERROR lines go back as changes asked), from-draft, approve with the
35// caller's model/effort (the watcher's facts), one `ask` per rules and candidates line (the loop's own asks, caller
36// `loop`, answered by the router's `answer` request), REPLAN.md removed, committed; the decision line plus `installed:`
37// to the caller. A REPLAN.md at a question's release: status.py replan-written and `held(plan)`. While the router's
38// dialog flag is open the tick drains, spawns, resumes and sends nothing.
39// T6a2: launch.py mode `planner-gen` with no phase plan opens the generation start (loop.json `genStart`, a step a tick):
40// status.py inbox-consume, launch.py again, the curator ask (Run it now / Pause: `held(plan)`, asked next session),
41// `memory-curator`, `auditor` when a PhaseEnd exists, one notice to Claude, `held(plan)`; the migration curate line asks
42// once a session before the next spawn. The curator and the auditor are loop agents (loop.loopAgentIds): captured here,
43// never forwarded, steered or warmed. `managed.py drift` at each boundary: one ask per row (loop.json `drift`), stamp or
44// decline, committed. hook-context rows (`session.append`) are filed `mod/context`, once a session: an update line is a
45// notice to Claude (the update ask and `pa3_update.py` run only with PA_MOD_UPDATE=1 and PA_MOD_PA3_DIR unset), the
46// renewal line a notice. A STATUS block textually identical to the task's last routed contract (a `.` ping re-sends an
47// expert's last hand-back, T5d2b) is ignored, with no journal line (3.25 T3).
48// T6b: the phase close. At plan end `held(close)` and the close question to Claude; in it a queued `plan_edit` or a
49// changed INBOX.md runs a boundary (queued edits, status.py inbox-consume to Claude, `next`; a task pops `close`).
50// `release(close)` sets `closeRequested` and keeps its note (Claude's phase report) as `closeRecap`: the same boundary,
51// then at NONE-done the close itself, no agent (3.25 T2: the developer's yes is the milestone): RECAP.md from the note,
52// then the chain (state `closing`, loop.json `chainStep` the next step, archive's RUN:/stage: lines kept): recalc,
53// assemble, the PhaseEnd commit, archive, its RUN: lines, the archived commit, status.py clear, the closed line; then the
54// no-plan boundary (plan needed, or the generation start).
55// T11-fix2: the loop agent's `$.agent.list()` status every tick: pending/running/waiting busy; killed, failed, or absent
56// 15 s after its spawn, with no contract: the `stopped` hold and a notice (a Ctrl+C is the developer's; never resumed by
57// the loop); with a contract, routed. `release(stopped)` resumes it by message, else (or `fresh`) respawns it.
58// 3.23 T13: after each step the tick writes the band (`pa.band`): the loop's line (phase, task n of m from loop.json
59// `taskPos`, kept at spawn, holds, the close step) and a row per live agent under the loop task or the side agent.
60// 3.23 T13d.c11: nothing starts on its own. Every session start (startup, resume, /clear) whose loop would act (a spawn, a
61// recovery, the close chain, the next generation's start, a plan to draft) pushes `held(session)`; silence never ends it,
62// the developer's message pushes no `talk` under it, and Claude's `release({hold: "session"})` lets the loop go on.
63// 3.28.1 T9c: a task cut off by a session end (a restart, an update, a closed terminal) is no stop: its recovery runs
64// under `held(session)` and Claude tells the developer in one line. Only an agent this process saw at work and then
65// killed is the developer's stop.
66// 3.24 T6: the update ask and run and the health FIX spawn are on in production (PA_MOD_PA3_DIR empty); a trial forces
67// them with PA_MOD_UPDATE=1 (was: only PA_MOD_UPDATE=1 / PA_MOD_FIX_SPAWN=1 with PA_MOD_PA3_DIR unset). Under `upgrade:
68// auto` the session-start hook starts `pa3_update.py` itself (its line does not match contextLines); under `ask` the
69// update line becomes the picker ask, and a yes runs it here. The run marker (`.run/mod/update.json`): no task starts
70// while it says running; a finished run (the hook's detached one, a heal, or ours) is reported once (loop.json
71// `updateSeenAt`): its whole log to the journal and the debug log, "from X to Y" and CHANGES.md to Claude, a restart;
72// conflicts become a one-off UPGRADE expert at the next boundary (loop.json `upgrade`), then `pa3_update.py` once more;
73// the mod's own files changed (MANIFEST.json `mod/` entries against the ones read at arm): Claude says once that the
74// new mod loads at the next start or /reload-plugins, and no task starts on the old code.
75// The engine allows `$` only as `$.noun.method(...)` at the call site (design.md, "Execution model").
76import { atom, read, update } from 'claude-code'
77import type { On } from 'claude-code'
78
79import type { Band, HoldName, LoopState, LoopTask, ToastOut } from '../types'
80
81import { paSetting, versionAtLeast } from './activation'
82import { auditorBrief, curatorBrief, expertBrief, type ExpertOpts, type External, fixBrief, type PlanTask, upgradeBrief } from './brief'
83import { acceptedCard, type BandAgent, bandIds, bandRows, bandStepOf } from './card'
84import { CHAIN_SCRIPTS, parseAuditorReturn, parseExpertContract, splitCommand } from './contract'
85import { clip, eventFile, isStale } from './debug'
86import { BUILD } from './build'
87import { isProgressEntry, recoveryDigest } from './digest'
88import { isLoopKind, noticeRequest, parseRequest, pendingRequests, type Request, requestFile } from './inbox'
89import { JOURNAL_DIR, stamp, type Meta } from './journal'
90import {
91  agentFor, candidateOf, effortFor, effortHint, CANDIDATE_OPTIONS, CONTINUE_TEXT, editIsDecision, HEARTBEAT_MS, type Install, installOf, lintErrors, LOOP_FILE,
92  nextRuleId, ownsLoop, parseLoop, plannerIdOf, popHold, pushHold, queuedTo, releaseHold, routeStatus, ruleArgs, RULE_OPTIONS, runEnded, nextTaskOf,
93  serializeLoop, silenceReleases, steerText, stoppedText, quietReply, liveness, ABSENT_MS, takesOver, WAIT_MAX_MS, WAIT_POLL_MS, waitSpec, windowResumes,
94  contextLines, CURATE_OPTIONS, curateQuestion, deferredOf, FIRST_RUN_WAITS, firstRunLine, type Drift, DRIFT_OPTIONS, driftOf, driftQuestion, driftRows, type GenStart,
95  genNotice, genStartOf, newGenStart, phaseEndsOf, renewalNotice, type UpdateAsk, updateNotice, updateOf, UPDATE_OPTIONS,
96  archiveLines, bandLine, CHAIN_STEPS, closeNotice, extHealthNotice, extHealthOf, genEndOf, keepSummary, planPhase, closedLine, recalcSessions, summariesOf, taskPosOf, withPhaseSid,
97  BANK_MARKER, BANK_OPTIONS, bankNotice, bankQuestion, generalizableCount, SET_BACK, setAsideOf,
98  installingNotice, loadedNotice, olderRunWork, RUN_REVIEW_MARKER, RUN_REVIEW_OPTIONS, RUN_REVIEW_QUESTION, runReviewNotice, CLEANUP_ASK, CLEANUP_LIST, CLEANUP_OPTIONS, cleanupQuestion, START_CHECK, startCheckOffline, startCheckState, OFFLINE_NOTICE, pausedHandbackNotice, pausedNotice, markerOf, modFilesKey, RELOAD_NOTICE, RELEASE_CHECK_MS, RELEASE_MARKER, releaseNotice, releaseOf, RELEASE_OPTIONS, releaseQuestion, runRunning, runToReport, UPDATE_LOG, UPDATE_MARKER, updateDoneNotice, updateFact, updateOutcome, updateSeenOf,
99  UPGRADE_MD, upgradeLeftNotice, upgradeOf, UPGRADE_ROUNDS, FORWARD_TTL_MS, SHELLS_FILE, shellNotice, shellsOf, shellsOut, waitingShell,
100} from './loop'
101import { APPROVED, changesAsked, DIALOG_FILE, dialogOpen, planFilePath } from './planview'
102import { asksQuestion, classify, handbackChild, isNote, journalOut, noteBody, partnerReport } from './route'
103import { DONE_TTL_MS, feedDirOf } from './state'
104import { cacheTtlOf, isCold } from './policy'
105import { modRecord, nudgeName, startRecord } from './feed'
106import { anchorOf, cutPoint, markOf, tailOf, trimFailure, trimInstructions } from './trim'
107
108/** The router's generation token (register.tsx `genAtom`, the same key): a re-arm moves it on and old ticks stop. */
109const genAtom = atom({ plugin: 'pa', key: 'gen' } as const, 0)
110/** The router's atoms (register.tsx, same keys), read only: its state (the partner), its route switch, sdk-as-user. */
111const stateAtom = atom({ plugin: 'pa', key: 'state' } as const, null)
112const routeAtom = atom({ plugin: 'pa', key: 'route' } as const, false)
113const sdkAtom = atom({ plugin: 'pa', key: 'sdkAsUser' } as const, false)
114/** The band above the prompt (register.tsx `bandAtom`, the same key): this tick its one writer (3.23 T13). */
115const bandAtom = atom({ plugin: 'pa', key: 'band' } as const, { line: '', rows: [] } as Band)
116
117const PLAN = 'phase-ends/current/PHASE_PLAN.md'
118const REVIEW = 'phase-ends/current/REVIEW.md'
119const PROGRESS = 'phase-ends/current/TASK_PROGRESS.md'
120const GEN_PLAN = 'GENERATION_PLAN.md'
121const REPLAN = 'phase-ends/current/REPLAN.md'
122const INBOX_MD = 'phase-ends/current/INBOX.md'
123/** The close's recap (3.25 T2): `## Recap` (and `## Generation Recap` at a generation end) from Claude's report. */
124const RECAP = 'phase-ends/current/RECAP.md'
125/** How long the chain's recalc step waits for that run before it goes on (3.23 T13d.c4). */
126const RECALC_WAIT_MS = 10_000
127/** The chain's recalc step reads the done-file this often (3.24 T10b). */
128const RECALC_POLL_MS = 2_000
129/** The detached recalc's done-file (3.24 T10b: `pa_ledger.py recalc --done-file`, removed by its launcher). */
130const RECALC_DONE = '.run/mod/recalc.done'
131const PLAN_NEEDED = '‹pa› a phase plan is needed: draft the next phase plan with the pa-planning skill and present it with plan(kind: phase)'
132/** 3.24 T13A: after a first run's recap notice, PLAN_NEEDED waits for Claude's turn that relayed it, at most this long. */
133const RELAY_WAIT_MS = 5 * 60_000
134const REPLAN_NOTICE = '‹pa› the plan needs replanning (REPLAN.md): draft the phase plan with the pa-planning skill and present it with plan(kind: phase)'
135/** Removes a file if present (no `$.fs` remove); no double quote, so Windows argv quoting cannot bend it. */
136const RM_PY = 'import os,sys;p=sys.argv[1];os.path.isfile(p) and os.remove(p)'
137const TICK_MS = 2_000
138// 3.29.9: the display trim runs this long after its request (off the loop's path), retries a refusal while a turn runs
139// every TRIM_RETRY_MS up to TRIM_TRIES times (about 30 s), and the session start's catch-up waits TRIM_START_MS.
140const TRIM_DELAY_MS = 1_000
141const TRIM_RETRY_MS = 2_000
142const TRIM_TRIES = 15
143const TRIM_START_MS = 3_000
144const TRIM_FILE = '.run/mod/trim.json' // H1.c3: the last mark trimmed: { sid, task, len, at }
145/** Recovery (T5d1): an expert sent `continue:` and not seen running this long after is respawned. */
146const RECOVER_WAIT_MS = 30_000
147/** An agent `$.agent.list()` shows at work (recovery's "seen running"). */
148const AT_WORK = new Set(['pending', 'running'])
149/** While `off` (no approved plan), launch.py is asked at most this often. */
150const OFF_RECHECK_MS = 30_000
151/** Every process call's timeout, at most. */
152const PROC_MS = 60_000
153/**
154 * 3.28.1 T10: a git write's timeout (`commit_task.sh`, every mod commit goes through it). A commit in a repo of several
155 * GB outran PROC_MS and was killed mid-commit, leaving `.git/index.lock` (TerrainDiffusion 2026-10-15). Five minutes: a
156 * held child stalls the session's queue while it runs (3.24 T10b), but only a slow commit holds it, and a killed one costs
157 * the commit and a stale lock.
158 */
159export const GIT_WRITE_MS = 300_000
160/**
161 * Prints `none` (no lock), `busy` (a git process that may hold it is running: kept) or `removed`. Any repo's git process
162 * counts (Windows cannot tell which repo a process works in), except `--no-optional-locks` ones (Claude Code's own
163 * `git status`; they take no lock). A failing check exits non-zero, printing nothing: kept. No double quote.
164 */
165const GIT_LOCK_PY = "import os,sys,subprocess as s;p=sys.argv[1];w=os.name=='nt';o=os.path.isfile(p) and s.run(['powershell','-NoProfile','-Command','Get-CimInstance Win32_Process | ? Name -eq git.exe | % CommandLine'] if w else ['ps','-eo','args'],capture_output=True,text=True,check=True).stdout;g=[l for l in (o or '').splitlines() if l.strip() and (w or os.path.basename(l.split()[0])=='git') and '--no-optional-locks' not in l];print('none' if o is False else 'busy' if g else (os.remove(p) or 'removed'))"
166/** A spawn recorded without its agentId this long is lost (a crash between the spawn and its save): a block. */
167const SPAWN_LOST_MS = 60_000
168const HANDBACK_KINDS = new Set(['task-notification', 'peer', 'peer-send-message'])
169const HOLD_NAMES: readonly string[] = ['talk', 'review', 'question', 'plan', 'close', 'window', 'stopped', 'generation', 'session']
170const MILESTONE = 'milestone: `PY tools/plan_edit.py show --section Milestone`'
171const DECIDE = 'Decide small changes inside the milestone yourself (plan_edit, then release({hold: "question", note})); ask the developer about the milestone or the scope.'
172const BUSY = new Set(['pending', 'running', 'waiting'])
173/** 3.29.1 T8: the releases that start work (resume a stop, go past a review or question, close); they wait for the gate. */
174const WORK_HOLDS = new Set(['stopped', 'review', 'question', 'close'])
175/** A request file's `release` hold, '' for any other kind or a bad file. */
176const holdOf = (text: string): string => {
177  try {
178    const o = JSON.parse(text) as { kind?: unknown; input?: { hold?: unknown } }
179    return o.kind === 'release' && typeof o.input?.hold === 'string' ? o.input.hold : ''
180  } catch {
181    return ''
182  }
183}
184const MEMORY_DIR = '.claude-state/memory'
185/** launch.py's mode, cached this long while no plan is installed (T6a2). */
186const GEN_MODE_MS = 30_000
187const strOf = (v: unknown): string => (typeof v === 'string' ? v : '')
188
189/**
190 * What the hooks saw of a loop agent: its last STATUS-bearing text (hand-back report or turn.complete answer, whichever
191 * came last) and its last turn end (a turn.complete or a hand-back); for the generation start's agent (T6a2), `answer`,
192 * its last answer of any form (the curator's recap has no STATUS). 3.24 T13A: `handbackAt`, its hand-back's time (the run
193 * ends without the quiet wait, loop.runEnded); after it, a side agent's later turns never replace the hand-back's report.
194 */
195type Fact = { status: string | null; lastCompleteAt: number; answer?: string; handbackAt?: number }
196
197// Module variables are lost on a hot reload; each is re-derived (loopAgent from loop.json at the next tick, a lost fact
198// through the transcript fallback).
199let counter = 0
200let facts: Record<string, Fact> = {}
201let loopAgent = '' // the running task's expert, from loop.json at each tick
202let ticking = false
203let offCheckedAt = 0
204let jseq = 500 // the autopilot's journal sequence, apart from the router's
205let nseq = 0 // the notices' sequence in the router's inbox
206let sseq = 0 // the start records' sequence in the feed's `in/` (3.23 T13d.c3)
207// The close's ledger recalc (3.23 T13d.c4), launched detached at the close's start (3.24 T10b; 3.25 T2): its phase and, when it
208// was not launched, the journal line ('' launched: the chain reads RECALC_DONE). Lost on a hot reload: the chain starts one.
209let recalc: { phase: string; line: string } | null = null
210let armedAp = { sid: '', gen: 0 }
211let transcriptSeen = { sid: '', path: '' }
212let apRearm: ((sid: string) => Promise<void>) | null = null
213// 3.29.9: the armed session's display trim request (null until armed), and its single flight: a request while one runs
214// is run once more after it.
215let apTrim: ((why: string) => void) | null = null
216let trimBusy = false
217let trimAgain = ''
218// A session whose SessionStart (startup, resume or clear) its first owning tick has not seen: it pushes `held(session)`
219// when the loop would act (3.23 T13d.c11; was T13b.c3's freshSid, which lifted `held(generation)` on startup or clear).
220let startSid = ''
221let clearSid = '' // a session whose SessionStart was `clear`: it owns the loop at once from this process's earlier session (T13b.c4)
222const procSids = new Set<string>() // every session this process armed the loop for (T13b.c4)
223let devAt = 0 // the developer's last prompt (ms), for the tick's `talk`
224let devSid = '' // the session of the developer's last prompt: a pending first run is asked with it (3.24 T7b-2c)
225let partnerSaid = { at: 0, asked: false, notice: false } // Claude's last answer (ms), whether it asked the developer something, whether it followed a mod notice
226let waitPolledAt = 0 // the WAIT-FOR file's last poll (ms); 0 polls at once (T5d2a)
227let genMode: { sid: string; at: number; doc: Record<string, unknown> | null } = { sid: '', at: 0, doc: null } // launch.py, cached (T6a2)
228let sideAgent = '' // the generation start's running agent (curator or auditor), from loop.json at each tick (T6a2)
229let routedText = '' // the running task's last routed contract, from loop.json at each tick (T6a2)
230let contextQ: { sid: string; kind: 'update' | 'renewal'; line: string }[] = [] // hook-context lines owed (T6a2)
231const bandSince = new Map<string, number>() // agent id -> first seen by the band's tick (ms; the expert's spawnedAt)
232const contextSeen = new Set<string>() // `<sid>\n<line>`: each handled once a session
233const driftAsked = new Set<string>() // `<sid>\n<rel>`: each drift row asked once a session
234// 3.28.1 T9c: loop agents this process saw not stopped in `$.agent.list()`: only these can be the developer's stop; one
235// killed or absent and never seen was cut off by a session end and is recovered.
236const seenLive = new Set<string>()
237// 3.28.1 T15: `WAITING: shell <what>` hand-backs seen by the hooks, for the tick to tell Claude (part d).
238let shellQ: { agentId: string; what: string; at: number }[] = []
239function waitsOnShell(agentId: string | undefined, text: string, at: number): void {
240  const what = waitingShell(text)
241  if (what !== null && agentId !== undefined && agentId !== '') {
242    shellQ = [...shellQ.filter(q => q.agentId !== agentId), { agentId, what, at }]
243  }
244}
245// 3.24 T6: the root MANIFEST.json's `mod/` entries read at the first arm of this load (null unread), and whether an
246// update changed them since: the loaded code is then the old one, so no task starts until a reload or the next start
247// (both drop these). `updWait`: the last no-start reason logged, so a long wait logs once.
248let modSeen: string | null = null
249let modStale = false
250let updWait = ''
251let startAt = 0 // 3.29.1 T8: this process's session start (ms): the update check's note must be this start's
252let pausedAskedSid = '' // 3.29.1 T9: the session Claude was asked about a paused task in (once a session start)
253let runReviewDue = false // 3.29.2: the one-time `.run/` cleanup is to be offered once the update gate opens
254let bankDue: { all: number; current: number } | null = null // 3.30: the one-time cookbook banking, offered then too
255let releaseCheckedAt = 0 // 3.28.1 T14: the 8-hour release check's last run (ms); 0 until the first tick
256let modLoaded = '' // 3.28.2 T17: the PA3 release this process loaded (3.30.2: its own code's BUILD; a checkout reads VERSION)
257let modGit = '' // 3.30.2: the commit this process's code was installed from (BUILD.git); '' in a checkout
258let offlineToldSid = '' // 3.30.2: the session start whose timed-out update check Claude was told about
259
260const quiet = (): undefined => undefined
261
262/**
263 * Git Bash for `commit_task.sh`: pa.json `bash`, else CLAUDE_CODE_GIT_BASH_PATH, else derived from `git --exec-path`
264 * (…/mingw64/libexec/git-core → …/bin/bash.exe). On win32 a bare name (no path separator) is never used; '' when
265 * nothing resolves there. Elsewhere `bash` is the last resort.
266 */
267export function resolveBash(paBash: string, envBash: string, execPath: string, win32: boolean): string {
268  const usable = (p: string): boolean => p.trim() !== '' && (!win32 || /[\\/]/.test(p))
269  if (usable(paBash)) {
270    return paBash.trim()
271  }
272  if (usable(envBash)) {
273    return envBash.trim()
274  }
275  const m = /^(.*)\/mingw(?:64|32)\/libexec\/git-core\/?$/i.exec(execPath.trim().replace(/\\/g, '/'))
276  if (m !== null && (m[1] ?? '') !== '') {
277    return `${m[1] ?? ''}/bin/bash.exe`
278  }
279  return win32 ? '' : 'bash'
280}
281
282type GitRan = { exitCode: number; stdout: string; stderr: string }
283/**
284 * 3.28.1 T10: a git write under GIT_WRITE_MS. Killed or timed out (exit -1), or refused on `index.lock`: `clearLock`
285 * (GIT_LOCK_PY's answer), then one retry unless a git process kept the lock; `note` says what happened when it still fails.
286 */
287export async function gitWrite(argv: string[], run: (argv: string[], ms: number) => Promise<GitRan>,
288  clearLock: () => Promise<string>): Promise<{ r: GitRan; note: string }> {
289  const r = await run(argv, GIT_WRITE_MS)
290  if (r.exitCode === 0 || (r.exitCode !== -1 && !/index\.lock/.test(`${r.stderr}${r.stdout}`))) {
291    return { r, note: '' }
292  }
293  const lock = (await clearLock()).trim()
294  if (lock !== 'removed' && lock !== 'none') {
295    return { r, note: 'index.lock kept: a git process is running, not retried' }
296  }
297  const again = await run(argv, GIT_WRITE_MS)
298  return { r: again, note: again.exitCode === 0 ? '' : `retried once${lock === 'removed' ? ' after removing a stale index.lock' : ''}` }
299}
300
301/** The last assistant text of a transcript (JSONL, `{type: "assistant", message: {content: [...]}}` rows); null if none. */
302export function lastAssistantText(jsonl: string): string | null {
303  let last: string | null = null
304  for (const line of jsonl.split(/\r?\n/)) {
305    if (line.trim() === '') {
306      continue
307    }
308    let row: unknown
309    try {
310      row = JSON.parse(line)
311    } catch {
312      continue
313    }
314    const r = row as { type?: unknown; message?: { content?: unknown } }
315    if (r.type !== 'assistant') {
316      continue
317    }
318    const content = r.message?.content
319    const text = typeof content === 'string'
320      ? content
321      : Array.isArray(content)
322        ? content.map(c => (typeof c === 'object' && c !== null && (c as { type?: unknown }).type === 'text' ? String((c as { text?: unknown }).text ?? '') : '')).join('')
323        : ''
324    if (text.trim() !== '') {
325      last = text
326    }
327  }
328  return last
329}
330
331/** `<project transcript dir>/<sid>/subagents/agent-<id>.jsonl` from the session's own `transcript_path`. */
332export function subagentTranscript(transcriptPath: string, sid: string, agentId: string): string {
333  const p = transcriptPath.replace(/\\/g, '/')
334  return `${p.slice(0, p.lastIndexOf('/'))}/${sid}/subagents/agent-${agentId}.jsonl`
335}
336
337function jsonOf(text: string): Record<string, unknown> | null {
338  try {
339    const v: unknown = JSON.parse(text.trim().split(/\r?\n/).pop() ?? '')
340    return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
341  } catch {
342    return null
343  }
344}
345
346/** 3.30.2: the build this process's code is: PA_MOD_BUILD's `{"version","git"}` (a test's), else build.ts's stamp. */
347const buildFrom = (env: string | null | undefined): { version: string; git: string } => {
348  const o = objectOf(env ?? '')
349  return o === null ? BUILD : { version: strOf(o['version']), git: strOf(o['git']) }
350}
351/** 3.30.2: VERSION's `git:` line ('' when none). */
352const gitOfVersion = (text: string | null): string => /^git:\s*(\S+)/m.exec(text ?? '')?.[1] ?? ''
353/** Two commit ids name one commit when either starts the other (VERSION and a run record carry short ids). */
354const sameCommit = (a: string, b: string): boolean => a !== '' && b !== '' && (a.startsWith(b) || b.startsWith(a))
355
356/** agentId is optional in a spawn's result: the newest agent the list added, of the spawned type when one is. */
357const newAgent = (added: readonly { id: string; type: string }[], type: string): string =>
358  (added.filter(a => a.type === type).pop() ?? added[added.length - 1])?.id ?? ''
359
360/** A trial knob (`T<n>` or `T<n>:<text>`) names this task. */
361const knob = (value: string, id: string): string | null => {
362  const [who, ...rest] = value.split(':')
363  return (who ?? '').trim() === id ? rest.join(':').trim() : null
364}
365
366/** The plan entry a loop task was spawned from (kept on the task in loop.json). */
367const planOf = (t: LoopTask): PlanTask | null => {
368  const p = (t as LoopTask & { plan?: unknown }).plan
369  return typeof p === 'object' && p !== null ? (p as PlanTask) : null
370}
371const routedOf = (t: LoopTask): string => {
372  const r = (t as LoopTask & { routed?: unknown }).routed
373  return typeof r === 'string' ? r : ''
374}
375
376/** The question-or-block stop's notice: what stopped, the milestone pointer, who decides. */
377export function questionNotice(task: string, what: string): string {
378  return [`‹pa› ${task === '' ? 'the loop' : task} stopped: ${what}`, MILESTONE, DECIDE].join('\n')
379}
380
381/** The review stop's notice. */
382export function reviewNotice(task: string): string {
383  return `‹pa› ${task} stopped for your review: phase-ends/current/REVIEW.md. Present it to the developer with your recommendation; ` +
384    'apply their choices with plan_edit (during this stop they apply at once); call release({hold: "review"}) when the developer approves. ' +
385    `The release is refused while ${task} is open: when the developer approves, mark it done first with plan_edit (\`set-status ${task} done\`).`
386}
387
388/** 3.24 T8: the message that asks a task's expert for its missing `Review:` block (it resumes; nothing is killed). */
389export function reviewAskText(task: string, summary: string, why: string): string {
390  return `NOTE: ${task} returned STATUS review, but \`task_log.py review ${task}\` found no Review: block in ${summary} (${why}). ` +
391    'Add it to the summary: a flush-left `Review:` line, then indented `ran:`, `results:`, `seen:`, `decisions:` and `edits:` lines ' +
392    '(decisions and edits as `- ` items under their key). Commit the summary, then return the same contract again.'
393}
394
395/** 3.24 T8: the review stop's notice when the summary still has no Review: block: Claude presents from the summary. */
396export function reviewSummaryNotice(task: string, summary: string, why: string): string {
397  return `‹pa› ${task} stopped for your review, but its summary has no Review: block (\`task_log.py review\`: ${why}), so there is ` +
398    `no REVIEW.md. Present the review to the developer from the summary ${summary}, with your recommendation; ` +
399    'apply their choices with plan_edit (during this stop they apply at once); call release({hold: "review"}) when the developer approves. ' +
400    `The release is refused while ${task} is open: when the developer approves, mark it done first with plan_edit (\`set-status ${task} done\`).`
401}
402
403/** The WAIT-FOR timeout's review notice (T5d2a). */
404export function waitReviewNotice(task: string, file: string, literal: string): string {
405  return `‹pa› ${task} waited 50 min for ${file} to contain ${literal} and stopped for your review: phase-ends/current/REVIEW.md. ` +
406    'Present it to the developer with your recommendation; apply their choices with plan_edit (during this stop they apply at once); ' +
407    `call release({hold: "review"}) when they decide. Unchanged, ${task} waits again.`
408}
409
410/** A whole JSON file's object (health.json is indented), null when it is not one. */
411function objectOf(text: string): Record<string, unknown> | null {
412  try {
413    const v: unknown = JSON.parse(text)
414    return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
415  } catch {
416    return null
417  }
418}
419
420/** loop.json's `inboxDone` (request id -> ms done), the loop's own drained requests. */
421const doneOf = (s: LoopState): Record<string, number> => {
422  const d = (s as LoopState & { inboxDone?: unknown }).inboxDone
423  return typeof d === 'object' && d !== null && !Array.isArray(d) ? (d as Record<string, number>) : {}
424}
425
426/** 3.24 T12c: `sid`'s session start has not had its first owning tick yet (its `session` hold not judged): the router's
427 * first message must not get the talk line that tick is about to supersede (a `--continue` restart routed the developer's
428 * message ~2 s before the hold, and Claude released `talk`, a silent no-op). */
429export const sessionStartPending = (sid: string): boolean => sid !== '' && startSid === sid
430
431/** Registers the autopilot's hooks on the router's `on`; called once, last, from register.tsx's `register`. */
432export function armAutopilot(on: On): void {
433  on('session.start', {}, async ($, e, next) => {
434    const out = await next(e)
435    const root = await $.session.root()
436    const sid = await $.session.id()
437    const debug = (await $.env.get('PA_MOD_DEBUG')) === '1'
438    if (debug && (await read($, genAtom)) !== 0) { // 3.24 T8: gen 0 is an inert router, which writes nothing
439      const ms = Date.now()
440      counter += 1
441      const file = eventFile(`${root}/.run/mod/${sid}/debug`, ms, counter, 'autopilot.armed')
442      await $.fs.write(file, JSON.stringify({ ms, ev: 'autopilot.armed', sid }, null, 1)).catch(quiet)
443    }
444
445    // Armed under the router's current generation (0: the router is inactive, no loop). Its one tick re-reads loop.json.
446    const arm = async (sidNow: string, fresh = false): Promise<void> => {
447      const gen = await read($, genAtom)
448      if (gen === 0 || (!fresh && armedAp.sid === sidNow && armedAp.gen === gen)) {
449        return
450      }
451      const paText = await $.fs.read(`${root}/.claude/pa.json`).catch(() => null)
452      const python = paSetting(paText, 'python')
453      if (python === '') {
454        return
455      }
456      armedAp = { sid: sidNow, gen }
457      procSids.add(sidNow)
458      const loopPath = `${root}/${LOOP_FILE}`
459      // 3.24 T6: PA3's machine root (a trial's snapshot under PA_MOD_PA3_DIR); production is PA_MOD_PA3_DIR empty.
460      const pa3Env = (await $.env.get('PA_MOD_PA3_DIR')) ?? ''
461      const pa3 = pa3Env || `${((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')}/.claude/pa3`
462      const updateOn = pa3Env === '' || (await $.env.get('PA_MOD_UPDATE')) === '1'
463      if (modSeen === null) {
464        modSeen = modFilesKey(await $.fs.read(`${pa3}/MANIFEST.json`).catch(() => null))
465      }
466      const log = async (kind: string, data: Record<string, unknown>): Promise<void> => {
467        if (debug) {
468          const ms = Date.now()
469          counter += 1
470          await $.fs.write(eventFile(`${root}/.run/mod/${sidNow}/debug`, ms, counter, `autopilot.${kind}`),
471            JSON.stringify({ ms, ev: `autopilot.${kind}`, ...data }, null, 1)).catch(quiet)
472        }
473      }
474      type Ran = { exitCode: number; stdout: string; stderr: string }
475      const run = async (argv: string[], timeoutMs = PROC_MS): Promise<Ran> => {
476        const t0 = Date.now() // 3.24 T10b: each call's start and duration, so a stall shows in the debug log
477        try {
478          const r = await $.process.run(argv, { cwd: root, timeoutMs })
479          await log('run', { argv: argv.slice(1, 4), exitCode: r.exitCode, t0, durMs: Date.now() - t0 })
480          return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }
481        } catch (err) {
482          await log('run.error', { argv: argv.slice(1, 4), err: clip(String(err), 300), t0, durMs: Date.now() - t0 })
483          return { exitCode: -1, stdout: '', stderr: clip(String(err), 300) }
484        }
485      }
486      const py = async (...args: string[]): Promise<Ran> => run([python, ...args])
487      let bash = ''
488      const commit = async (msg: string, paths: string[], id = 'router'): Promise<string> => {
489        if (bash === '') {
490          const win32 = (await $.env.get('OS')) === 'Windows_NT'
491          const paBash = paSetting(paText, 'bash')
492          const envBash = (await $.env.get('CLAUDE_CODE_GIT_BASH_PATH')) ?? ''
493          const named = (p: string): boolean => p.trim() !== '' && (!win32 || /[\\/]/.test(p))
494          let exec = ''
495          if (!named(paBash) && !named(envBash)) {
496            const r = await run(['git', '--exec-path'])
497            exec = r.exitCode === 0 ? r.stdout.trim() : ''
498          }
499          bash = resolveBash(paBash, envBash, exec, win32)
500          await log('bash', { bash })
501        }
502        if (bash === '') {
503          return 'no Git Bash (pa.json bash, CLAUDE_CODE_GIT_BASH_PATH, git --exec-path)'
504        }
505        // 3.28.1 T10: its own limit; a killed one clears a stale lock and retries once, its note in the caller's one line.
506        const { r, note } = await gitWrite([bash, 'tools/commit_task.sh', id, msg, ...paths], run, async () => {
507          const at = await run(['git', 'rev-parse', '--git-path', 'index.lock'])
508          const lock = at.exitCode === 0 ? at.stdout.trim() : ''
509          const out = lock === '' ? 'busy' : (await run([python, '-c', GIT_LOCK_PY, lock])).stdout
510          await log('git.lock', { lock, out: clip(out.trim(), 100) })
511          return out
512        })
513        return r.exitCode === 0 ? '' : `commit "${msg}" exit ${String(r.exitCode)}: ${clip(r.stderr || r.stdout, 200)}${note === '' ? '' : ` (${note})`}`
514      }
515      const journal = async (who: string, kind: string, meta: Meta, body: string): Promise<void> => {
516        jseq += 1
517        let o: { rel: string; text: string }
518        try {
519          o = journalOut(await $.clock.now(), jseq, who, kind, { session: sidNow, ...meta }, body)
520        } catch (err) {
521          // The naming rejected it (an unknown who or kind): visible, never silently dropped (3.23 T7).
522          await log('journal.bad', { who, kind, err: clip(String(err), 300) })
523          return
524        }
525        try {
526          await $.fs.write(`${root}/${o.rel}`, o.text)
527        } catch (err) {
528          await log('journal.error', { err: clip(String(err), 300) })
529        }
530      }
531
532      // 3.29.9: the display trim (trim.ts). The loop state's `doneMark` (the last completed task's `trimMark`, this
533      // session's) is found again in the main conversation and the rows before it are compacted away by the watcher
534      // (pa-watch answers the `pa-trim` compaction; no model). Off the loop's path: a refusal while a turn runs retries,
535      // a headless run skips; a trim is one `mod/note` journal line, a skip or a give-up a debug event.
536      const trimFile = `${root}/${TRIM_FILE}`
537      const trimRun = async (why: string): Promise<void> => {
538        for (let tries = 1; ; tries += 1) {
539          const dm = markOf((parseLoop(await $.fs.read(loopPath).catch(() => null)) as { doneMark?: unknown }).doneMark)
540          if (dm === null || dm.sid !== sidNow) {
541            await log('trim.skip', { why, reason: 'no mark' })
542            return
543          }
544          // H1.c3: a mark already trimmed (its own file, apart from loop.json's saves) is never searched again
545          const done = objectOf((await $.fs.read(trimFile).catch(() => null)) ?? '')
546          if (done !== null && done['sid'] === dm.sid && done['task'] === dm.task && done['len'] === dm.len) {
547            await log('trim.skip', { why, task: dm.task, reason: 'trimmed' })
548            return
549          }
550          const rows = await $.session.messages().catch(() => null)
551          const k = rows === null ? -1 : cutPoint(rows, dm)
552          if (rows === null || k < 0) {
553            await log('trim.skip', { why, task: dm.task, reason: 'no cut point', rows: rows?.length ?? -1 })
554            return
555          }
556          try {
557            const r = await $.session.compact({ instructions: trimInstructions(rows, k) })
558            if (r.skip !== undefined) {
559              await log('trim.skip', { why, task: dm.task, reason: r.skip })
560              return
561            }
562            await $.fs.write(trimFile, `${JSON.stringify({ sid: dm.sid, task: dm.task, len: dm.len, at: await $.clock.now() })}\n`).catch(quiet)
563            await journal('mod', 'note', { task: dm.task },
564              `Display trimmed (${why}): the main conversation kept from ${dm.task}'s start, ${String(k)} rows dropped, ${String(r.messages.length)} kept.`)
565            return
566          } catch (err) {
567            const fail = trimFailure(err, tries, TRIM_TRIES)
568            if (fail === 'headless') {
569              await log('trim.skip', { why, task: dm.task, reason: 'headless' })
570              return
571            }
572            if (fail === 'giveup') {
573              await log('trim.giveup', { why, task: dm.task, tries, err: clip(String(err), 200) })
574              return
575            }
576            await log('trim.retry', { why, task: dm.task, tries, err: clip(String(err), 200) })
577          }
578          await new Promise<void>(res => { $.clock.after(TRIM_RETRY_MS, res) })
579        }
580      }
581      const trimSoon = (why: string): void => {
582        $.clock.after(TRIM_DELAY_MS, () => {
583          void (async () => {
584            if (trimBusy) {
585              trimAgain = why
586              return
587            }
588            trimBusy = true
589            try {
590              let w = why
591              while (w !== '') {
592                trimAgain = ''
593                await trimRun(w)
594                w = trimAgain
595              }
596            } finally {
597              trimBusy = false
598            }
599          })().catch(quiet)
600        })
601      }
602      apTrim = trimSoon
603      // The loop state's `doneMark` at task `id`'s done: its `trimMark` when this session marked it, else none.
604      const doneMarkOf = (st: LoopState, id: string): { doneMark: unknown } => {
605        const m = markOf((st as { trimMark?: unknown }).trimMark)
606        return { doneMark: m !== null && m.task === id && m.sid === sidNow ? m : null }
607      }
608
609      // A notice to Claude through the router's inbox (the router writes its own state.json): text, a toast cause, and
610      // `to`, a request's caller for a reply.
611      const inboxDir = `${root}/.run/mod/${sidNow}/inbox`
612      const notice = async (text: string, toast: ToastOut['cause'] | '' = '', to = ''): Promise<void> => {
613        nseq += 1
614        const r = noticeRequest(await $.clock.now(), nseq, text, toast, to)
615        try {
616          await $.fs.write(`${inboxDir}/${r.name}`, r.body)
617          await log('notice', { name: r.name, toast, to, text: clip(text, 200) })
618        } catch (err) {
619          await log('notice.error', { err: clip(String(err), 300) })
620        }
621      }
622
623      // 3.23 T13d.c3: a confirmation to its caller: sent as a notice, or, when it only says done (quietReply), journaled
624      // as the entry the sent notice gets (`mod prompt`, role loop; register.tsx) without waking Claude.
625      const confirm = async (line: string, to: string): Promise<void> => {
626        if (quietReply(line)) {
627          await journal('mod', 'prompt', { role: 'loop' }, line)
628        } else {
629          await notice(line, '', to)
630        }
631      }
632
633      const dialogFile = `${root}/.run/mod/${sidNow}/${DIALOG_FILE}`
634      const dialogIsOpen = async (): Promise<boolean> => dialogOpen(await $.fs.read(dialogFile).catch(() => null))
635
636      // 3.23 T13: the band above the prompt, from loop.json, the plan's phase, `$.agent.list()` and the watcher's facts
637      // files; the atom is written only when it changed, and the AbovePrompt rule (register.tsx) only draws it.
638      const bandTick = async (): Promise<void> => {
639        const now = await $.clock.now()
640        const s = parseLoop(await $.fs.read(loopPath).catch(() => null))
641        const t = s.task
642        const roots = [
643          ...(t !== null && t.agentId !== '' ? [{ id: t.agentId, task: t.id }] : []),
644          ...(sideAgent === '' ? [] : [{ id: sideAgent, task: '' }]),
645        ]
646        const listed = roots.length === 0 ? [] : await $.agent.list()
647        const agents: BandAgent[] = listed.map(a => {
648          if (!bandSince.has(a.id)) {
649            bandSince.set(a.id, a.id === t?.agentId && t.spawnedAt > 0 ? t.spawnedAt : now)
650          }
651          return { id: a.id, type: a.type || (a.id === t?.agentId ? t.agent : ''), name: a.name, status: a.status, parentId: a.parentId, description: a.description, since: bandSince.get(a.id) ?? now }
652        })
653        const shells = shellsOf(await $.fs.read(`${root}/.run/mod/${sidNow}/${SHELLS_FILE}`).catch(() => null))
654        for (const { id } of bandIds(agents, roots, new Set(Object.keys(shells)))) {
655          // 3.23 T13d.c2: the agent's latest working line (the facts' `line`), else its last step.
656          const step = bandStepOf(jsonOf((await $.fs.read(`${root}/.run/mod/${sidNow}/agents/${id}.json`).catch(() => null)) ?? ''))
657          const a = agents.find(x => x.id === id)
658          if (a !== undefined && step !== undefined) {
659            a.step = step
660          }
661        }
662        // 3.30: an external coder's shell is drawn as the coder: its latest step from pa_ext's facts file (3.30.3: its
663        // model only on the statusline's row).
664        const ext: Record<string, string> = {}
665        for (const sh of Object.values(shells).flat()) {
666          const label = /^external (\S+)$/.exec(sh.name)?.[1]
667          if (label !== undefined) {
668            ext[label] = bandStepOf(jsonOf((await $.fs.read(`${root}/.run/mod/${sidNow}/agents/ext-${label}.json`).catch(() => null)) ?? '')) ?? ''
669          }
670        }
671        const phase = planPhase((await $.fs.read(`${root}/${PLAN}`).catch(() => null)) ?? '').phase
672        const band: Band = { line: bandLine(s, phase), rows: bandRows(agents, roots, now, shells, ext) }
673        if (JSON.stringify(band) !== JSON.stringify(await read($, bandAtom))) {
674          await update($, bandAtom, () => band)
675        }
676      }
677
678      const step = async (): Promise<void> => {
679        const hasPlan = await $.fs.exists(`${root}/${PLAN}`)
680        const now = await $.clock.now()
681        let s: LoopState = parseLoop(await $.fs.read(loopPath).catch(() => null))
682        // launch.py's mode, cached GEN_MODE_MS for this session (T6a2).
683        const modeOf = async (): Promise<string> => {
684          if (genMode.sid !== sidNow || genMode.at === 0 || now - genMode.at >= GEN_MODE_MS) {
685            const st = await py('tools/launch.py', '--state-json')
686            genMode = { sid: sidNow, at: now, doc: st.exitCode === 0 ? jsonOf(st.stdout) : null }
687          }
688          return strOf(genMode.doc?.['mode'])
689        }
690        const g0 = genStartOf(s)
691        // 3.24 T7b: the project's first run in 3.2 still owed this session (pa.json `first_run`; firstRunLine), '' none.
692        // T7b-2c: `firstPending` while it is not done (asked or paused this session too): no plan is installed or held for.
693        const firstLine = firstRunLine(await $.fs.read(`${root}/.claude/pa.json`).catch(() => null), MEMORY_DIR)
694        const firstPending = firstLine !== ''
695        const firstRun = g0 === null || g0.sid !== sidNow ? firstLine : ''
696        // No phase plan: the loop wakes only for a generation plan, an install in progress or its own requests (T6a1); T6a2:
697        // also for a live generation start of this session, owed hook-context lines, an update ask, or launch.py's
698        // planner-gen with none started this session; 3.24 T7b: or a first run owed.
699        if (!hasPlan && installOf(s) === null && !(await $.fs.exists(`${root}/${GEN_PLAN}`))) {
700          const owed = (await $.fs.exists(inboxDir))
701            ? pendingRequests((await $.fs.list(inboxDir)).filter(f => f.kind === 'file').map(f => f.name), doneOf(s), now).filter(isLoopKind)
702            : []
703          const live = (g0 !== null && g0.sid === sidNow && g0.step !== 'done') || contextQ.some(c => c.sid === sidNow) || updateOf(s) !== null
704          if (owed.length === 0 && !live && firstRun === '' && !((g0 === null || g0.sid !== sidNow) && (await modeOf()) === 'planner-gen')) {
705            startSid = startSid === sidNow ? '' : startSid // nothing would act: the session start needs no hold
706            return
707          }
708        }
709        // T13b.c4: after a `/clear` the owner is this process's previous session (its heartbeat still fresh): owned at once.
710        const prevSid = clearSid === sidNow && procSids.has(s.ownerSid) ? s.ownerSid : ''
711        if (!ownsLoop(s, sidNow, now, prevSid)) {
712          loopAgent = ''
713          return
714        }
715        const save = async (n: LoopState, what: string): Promise<void> => {
716          s = { ...n, ownerSid: sidNow, phaseSids: withPhaseSid(n.phaseSids, sidNow), heartbeatAt: await $.clock.now() }
717          await $.fs.write(loopPath, serializeLoop(s))
718          if (what !== '') {
719            await log('loop', { what, state: s.state, holds: s.holds, task: s.task?.id ?? '' })
720          }
721        }
722        // T5d1: a takeover (another owner, or a stale heartbeat: a restart), judged before this tick's first save.
723        const taken = takesOver(s, sidNow, now, prevSid)
724        // 3.23 T13d.c11: nothing starts on its own. A session start (SessionStart startup, resume or clear) holds the loop
725        // in `session` at this session's first owning tick when it would act: a task to spawn or recover, the close chain,
726        // the next generation's start (the generation park stays under this hold; its release lifts both), a generation
727        // start in flight or a phase plan to draft. Silence never ends it; Claude's release({hold: "session"}) does, when
728        // the developer's message asks for the loop to go on. (Was T13b.c3: a fresh session lifted the generation park at
729        // once; a resume kept it.)
730        if (startSid === sidNow) {
731          startSid = ''
732          const parked = s.holds.includes('generation')
733          const held = s.holds.some(h => h !== 'talk' && h !== 'window')
734          const genLive = g0 !== null && g0.sid === sidNow && g0.step !== 'done'
735          const acts = parked || (!held && (s.state !== 'off' || genLive || firstRun !== '' || (hasPlan
736            ? (await modeOf()) === 'router'
737            : (await modeOf()) === 'planner-gen' || (await $.fs.exists(`${root}/${GEN_PLAN}`)))))
738          if (acts) {
739            await save(pushHold(s, 'session'), 'session')
740            await journal('mod', 'hold', { task: s.task?.id }, 'session: a session start; the loop waits for the developer (release session).')
741          }
742        }
743        // 3.28.1 T15: the running task's expert or one of its children handed back `WAITING: shell <what>`: Claude tells
744        // the developer in one line (part d); the shell's completion notice wakes the agent by itself.
745        if (shellQ.length > 0) {
746          const seen = shellQ
747          shellQ = []
748          const t = s.task
749          const list = t === null || t.agentId === '' ? [] : await $.agent.list().catch(() => [])
750          for (const q of seen) {
751            const child = list.find(a => a.id === q.agentId && a.parentId === t?.agentId)
752            if (t === null || (q.agentId !== t.agentId && child === undefined)) {
753              continue
754            }
755            const role = q.agentId === t.agentId ? 'expert' : /coder/.test(child?.type ?? '') ? 'coder' : child?.type || 'agent'
756            await journal(t.id, 'shell', { task: t.id, agentId: q.agentId }, `${q.agentId} waits on its background shell: ${q.what}`)
757            await notice(shellNotice(t.id, role, q.agentId, q.what))
758          }
759        }
760        // A block: status.json waits on a question, `held(question)`; the journal says why; Claude gets the stop's notice
761        // (`what`, else the reason). Every block is a stop: its toast cause is `question` by default (T13b; raised by
762        // Claude's presentation, router side).
763        const block = async (n: LoopState, reason: string, who: string, kind: string, body: string, what = '',
764          toast: ToastOut['cause'] | '' = 'question'): Promise<void> => {
765          await py('tools/status.py', 'wait', 'question', '--note', clip(reason, 200))
766          await journal(who, kind, { task: n.task?.id }, body === '' ? reason : `${body}\n\n(${reason})`)
767          await save(pushHold(n, 'question'), `block: ${reason}`)
768          await notice(questionNotice(n.task?.id ?? '', what === '' ? reason : what), toast)
769        }
770        // The agent's last step as the watcher saw it (facts `updatedAt`, ms; 0 when absent): runEnded's activity (T11-fix).
771        const activityAt = async (agentId: string): Promise<number> => {
772          const v = jsonOf((await $.fs.read(`${root}/.run/mod/${sidNow}/agents/${agentId}.json`).catch(() => null)) ?? '')?.['updatedAt']
773          return typeof v === 'number' && Number.isFinite(v) ? v : 0
774        }
775        // T5d1: the heartbeat every 15 s. A takeover (`taken`, above) of a running task with no stop starts recovery, a step
776        // a tick in `task.recovery`.
777        if (s.ownerSid !== sidNow || now - s.heartbeatAt >= HEARTBEAT_MS) {
778          await save(s, taken ? 'takeover' : '')
779        }
780        // 3.23 T13d.c11: under `held(session)` the recovery is recorded; 3.28.1 T9c: it runs at once (no release needed).
781        if (taken && s.state === 'running' && s.task !== null && !s.holds.some(h => h !== 'talk' && h !== 'session')) {
782          if (runReviewDue) {
783            // 3.29.7: an update's after-restart work (the one-time `.run/` cleanup) comes first: the task is held as
784            // stopped until it is done and the developer says to continue (Claude asks; release stopped).
785            const pt = s.task
786            await save(pushHold({ ...s, task: { ...pt, stopped: { at: now, status: 'post-update' }, recovery: null } }, 'stopped'), 'post-update.held')
787            await journal('mod', 'hold', { task: pt.id, agentId: pt.agentId },
788              `${pt.id} held at this session start: the update's after-restart work (the .run/ cleanup) comes first.`)
789          } else if ((s.task.paused ?? null) !== null) {
790            // 3.29.1 T9: paused on the developer's request: never recovered on its own; held as stopped, and once the
791            // update check lets work go on, Claude asks whether to resume (release stopped).
792            const pt = s.task
793            await save(pushHold({ ...s, task: { ...pt, stopped: { at: now, status: 'paused' }, recovery: null } }, 'stopped'), 'paused.held')
794            await journal('mod', 'hold', { task: pt.id, agentId: pt.agentId },
795              `${pt.id} was paused on the developer's request: not resumed at this session start; Claude asks.`)
796          } else {
797            await save({ ...s, task: { ...s.task, recovery: { phase: 'start', at: now } } }, 'recover')
798          }
799        }
800        loopAgent = s.task?.agentId ?? ''
801        routedText = s.task?.lastRouted ?? ''
802        const gs = genStartOf(s)
803        sideAgent = gs === null || gs.step === 'done' ? '' : gs.agentId
804        // hook-context lines (T6a2), each once a session: filed `mod/context`; the renewal line a notice; an update line
805        // the update ask's record (3.24 T6: production, or PA_MOD_UPDATE=1), else a notice.
806        const ctx = contextQ.filter(c => c.sid === sidNow)
807        if (ctx.length > 0) {
808          contextQ = contextQ.filter(c => c.sid !== sidNow)
809          for (const c of ctx) {
810            const line = c.kind === 'renewal' ? c.line : updateFact(c.line) // 3.28.6 T19: the fact, never the steps
811            await journal('mod', 'context', {}, line)
812            if (c.kind === 'renewal') {
813              await notice(renewalNotice(c.line))
814            } else if (updateOn && updateOf(s) === null) {
815              await save({ ...s, update: { line, asked: '', answer: null } } as LoopState, 'update')
816            } else {
817              await notice(updateNotice(line))
818            }
819          }
820        }
821        // 3.24 T6: a finished update run reported once (the hook's detached run or heal, or the mod's own): the whole
822        // log to the journal and the debug log; "from X to Y", CHANGES.md, a restart, or the failure to Claude; conflicts
823        // open the next UPGRADE round (at most UPGRADE_ROUNDS); the mod's own files changed: the reload notice, once.
824        const um = markerOf(await $.fs.read(`${root}/${UPDATE_MARKER}`).catch(() => null))
825        if (um !== null && runToReport(um, updateSeenOf(s), now)) {
826          const whole = (await $.fs.read(`${root}/${UPDATE_LOG}`).catch(() => null)) ?? ''
827          const o = updateOutcome(whole)
828          const conflicts = um.rc === 0 && (o.conflicts || (await $.fs.exists(`${root}/${UPGRADE_MD}`)))
829          const rounds = upgradeOf(s)?.round ?? 0
830          const upgrade = conflicts && rounds < UPGRADE_ROUNDS ? { agentId: '', at: 0, round: rounds + 1 } : null
831          await journal('mod', 'decision', {}, `PA3 ${um.mode} finished (exit ${String(um.rc)}), the project from ` +
832            `${um.from.phase || '?'} ${um.from.git} to ${um.to.phase || '?'} ${um.to.git}; its whole output:\n\n${whole}`)
833          await log('update.done', { mode: um.mode, rc: um.rc, from: um.from, to: um.to, conflicts, restart: o.restart, output: whole })
834          await save({ ...s, updateSeenAt: um.atMs, upgrade } as LoopState, 'update.seen')
835          // 3.28.2 T17: a restart loaded it. 3.30.2: by this process's own build, the commit when it has one (a same-version
836          // rebuild is new code too); a checkout compares versions.
837          const loaded = um.mode !== 'repair' &&
838            (modGit !== '' ? sameCommit(modGit, um.to.git) : versionAtLeast(modLoaded, um.to.phase) === true)
839          // 3.30.3: a run that ended before this process started (a hand-run update): that start loaded its settings too
840          const restart = o.restart && um.atMs >= startAt
841          await notice(updateDoneNotice(um, { restart, conflicts: upgrade !== null, loaded }, pa3, whole.trimEnd().split(/\r?\n/).slice(-12).join('\n')))
842          if (conflicts && upgrade === null) {
843            await notice(upgradeLeftNotice(rounds))
844          }
845          // The mod's own code is not what is installed now: the reload notice and the hold. 3.30.2: by the build, since a
846          // MANIFEST read at this session's start may already be the new one; a checkout compares the MANIFEST's key.
847          const key = um.rc === 0 && !modStale && modGit === '' ? modFilesKey(await $.fs.read(`${pa3}/MANIFEST.json`).catch(() => null)) : ''
848          const builtStale = um.rc === 0 && !modStale && modGit !== '' && um.mode !== 'repair' && !loaded
849          if (builtStale || (key !== '' && (modSeen ?? '') !== '' && key !== modSeen)) {
850            modStale = true
851            await journal('mod', 'hold', {}, 'The update changed the mod\'s own files: no task starts until the new mod loads (the next start or /reload-plugins).')
852            await notice(RELOAD_NOTICE)
853          }
854          // 3.28.1 T14: the release the developer chose to install is installed: no task starts on the old code until
855          // the restart (updateDoneNotice told Claude: /exit, then claude --continue); a toast says so too.
856          // 3.29.2: a run the start hook began records its release in the marker (`running`): installed, the statusline's
857          // "installed" stage until the restart; failed, the line goes. The mod's own install (below) does the same.
858          const hookRel = objectOf((await $.fs.read(`${root}/${RELEASE_MARKER}`).catch(() => null)) ?? '')
859          if (releaseOf(s) === null && hookRel?.['state'] === 'running' && um.mode !== 'repair') {
860            const done = um.rc === 0 ? { ...hookRel, state: 'installed' } : {}
861            await $.fs.write(`${root}/${RELEASE_MARKER}`, `${JSON.stringify(done)}\n`).catch(quiet)
862          }
863          const rl = releaseOf(s)
864          if (rl !== null && rl.step === 'running') {
865            if (um.rc === 0 && !loaded) {
866              modStale = true
867              await journal('mod', 'hold', {}, `PA3 ${rl.version} is installed: no task starts until Claude Code restarts (/exit, then claude --continue).`)
868              nseq += 1
869              const r = requestFile(now, nseq, 'toast', 'loop', { cause: 'input', body: `Project Architect ${rl.version} ${rl.kind} installed · /exit, then claude --continue (not /clear)` })
870              await $.fs.write(`${inboxDir}/${r.name}`, r.body).catch(quiet)
871              // 3.28.5 T18: the run finished with exit 0: the statusline's "installed" stage
872              await $.fs.write(`${root}/${RELEASE_MARKER}`, `${JSON.stringify({ version: rl.version, kind: rl.kind, state: 'installed' })}\n`).catch(quiet)
873            } else if (um.rc !== 0) {
874              await $.fs.write(`${root}/${RELEASE_MARKER}`, '{}\n').catch(quiet) // failed (told above): the line goes
875            }
876            await save({ ...s, release: { ...rl, step: 'done' } } as LoopState, 'release.done')
877          } else if (rl !== null && rl.step !== 'done' && um.rc === 0) {
878            // 3.28.2 T17: the session start's run installed the release (a restart before the clean stop): the marker
879            // names it again, so the next start gives the loaded line once (the start emptied it before the run ended).
880            const installed = ((await $.fs.read(`${pa3}/VERSION`).catch(() => null)) ?? '').split(/\r?\n/)[0]?.replace(/^version:/, '').trim() ?? ''
881            if (installed === rl.version) {
882              await $.fs.write(`${root}/${RELEASE_MARKER}`, `${JSON.stringify({ version: rl.version, kind: rl.kind, state: 'install' })}\n`).catch(quiet)
883              await save({ ...s, release: { ...rl, step: 'done' } } as LoopState, 'release.done')
884            }
885          }
886        }
887        // 3.28.2 T17: this process runs the release (whichever path installed it): the record and the statusline's line go.
888        const rlLoaded = releaseOf(s)
889        if (rlLoaded !== null && versionAtLeast(modLoaded, rlLoaded.version) === true) {
890          await save({ ...s, release: null } as LoopState, 'release.loaded')
891          await $.fs.write(`${root}/${RELEASE_MARKER}`, '{}\n').catch(quiet)
892        }
893        // 3.28.1 T14: every RELEASE_CHECK_MS of a running session, the session start's release check (pa_ledger.py
894        // update-check: the clone's fetch, I48). A release not asked about before: the loop's picker (tasks keep running;
895        // no answer is not now), one line to Claude, and the statusline's marker.
896        if (releaseCheckedAt === 0) { // the last check's time: the session start's (update.json `checked_at`, s), else now
897          const at = jsonOf((await $.fs.read(`${pa3}/update.json`).catch(() => null)) ?? '')?.['checked_at']
898          releaseCheckedAt = typeof at === 'number' && Number.isFinite(at) && at > 0 && at * 1000 <= now ? at * 1000 : now
899        } else if (now - releaseCheckedAt >= RELEASE_CHECK_MS && updateOn && !modStale && !runRunning(um, now)) {
900          releaseCheckedAt = now
901          const r = await run([python, `${pa3}/pa_ledger.py`, 'update-check', root])
902          const found = r.exitCode === 0 ? jsonOf(r.stdout.trim()) : null
903          const version = strOf(found?.['version'])
904          await log('release.check', { exitCode: r.exitCode, version })
905          if (version !== '' && version !== releaseOf(s)?.version) {
906            const kind = strOf(found?.['kind']) || 'hotfix'
907            const title = strOf(found?.['title'])
908            // 3.28.6 T19: the facts only; the hook's line (its pa3_update.py steps) never reaches Claude or the seed
909            await journal('mod', 'context', {}, `PA3 ${version}, a ${kind}${title === '' ? '' : ` (${title})`}, is out (the release check).`)
910            nseq += 1
911            const q = requestFile(now, nseq, 'ask', 'loop', { question: releaseQuestion(version, kind, title), options: [...RELEASE_OPTIONS] })
912            await $.fs.write(`${inboxDir}/${q.name}`, q.body)
913            await save({ ...s, release: { version, kind, asked: q.name.slice(0, -'.json'.length), step: 'asked' } } as LoopState, 'release.ask')
914            await $.fs.write(`${root}/${RELEASE_MARKER}`, `${JSON.stringify({ version, kind, state: 'later' })}\n`).catch(quiet)
915            await notice(releaseNotice(version, kind))
916          }
917        }
918        const t0 = s.task
919        const f0 = t0 === null ? undefined : facts[t0.agentId]
920        if (t0 !== null && f0 !== undefined && f0.lastCompleteAt > t0.lastCompleteAt) {
921          await save({ ...s, task: { ...t0, lastCompleteAt: f0.lastCompleteAt } }, '')
922        }
923        if (windowResumes(s, now)) {
924          await save({ ...popHold(s, 'window'), resumeAt: 0 }, 'window.resume')
925        }
926        // The hold (T5b2): the hooks' facts land here. A developer prompt while the loop is active pushes `talk`; Claude's
927        // answers record whether it asked; silence pops `talk`.
928        // 3.23 T13d.c11: under `held(session)` no `talk` (the session hold holds everything; Claude's release lets it go).
929        if (devAt > s.lastDevAt) {
930          const still = s.state === 'off' || s.holds.includes('session')
931          const fresh = !still && !s.holds.includes('talk')
932          await save({ ...(still ? s : pushHold(s, 'talk')), lastDevAt: devAt }, fresh ? 'talk' : '')
933          if (fresh) {
934            await journal('mod', 'hold', { task: s.task?.id }, `talk: the queue is held while the developer talks${s.task === null ? '' : ` (${s.task.id} running)`}.`)
935          }
936        }
937        if (partnerSaid.at > s.lastAnswerAt) {
938          const said = partnerSaid
939          await save({ ...s, lastAnswerAt: said.at, ...(said.notice ? {} : { talkAnswerAt: said.at, lastAnswerAsked: said.asked }) }, '')
940        }
941        if (silenceReleases(s, now)) {
942          await save(popHold(s, 'talk'), 'talk.silence')
943          await journal('mod', 'hold', { task: s.task?.id }, 'talk released: 15 min with no developer message and no question pending; the loop goes on.')
944        }
945
946        // Status, then the spawn; agentId, spawnedAt and startSha recorded, and a `T<n>/start` entry.
947        const spawnTask = async (plan: PlanTask, attempt: number, kind: string, opts: ExpertOpts): Promise<void> => {
948          const agent = agentFor(plan.agent, plan.effort)
949          const effort = effortFor(plan.agent, plan.effort)
950          const title = plan.title ?? ''
951          const startSha = (await run(['git', 'rev-parse', 'HEAD'])).stdout.trim()
952          const spawnedAt = await $.clock.now()
953          const task: LoopTask = {
954            id: plan.id, title, agent, coder: plan.coder ?? 'opus55', effort, attempt, kind, agentId: '',
955            startSha, spawnedAt, lastCompleteAt: 0, contract: null,
956          }
957          // 3.29.9: a task's first spawn marks where it began in the main conversation (the display trim's `trimMark`);
958          // a relaunch or handoff keeps the mark already there.
959          const msgs = kind === 'task' ? await $.session.messages().catch(() => null) : null
960          const mark = msgs === null ? {} : { trimMark: { sid: sidNow, task: plan.id, len: msgs.length, tail: tailOf(msgs, msgs.length), anchor: anchorOf(msgs, msgs.length) } }
961          await save({ ...s, state: 'running', task: { ...task, plan } as LoopTask, ...mark } as LoopState, '')
962          // 3.24 T7b: the brief carries the card's expert slice, so the card reaches every task by construction.
963          const sl = await py('tools/card.py', 'slice', 'expert')
964          const card = sl.exitCode === 0 ? sl.stdout : ''
965          // 3.3 T7: pa.json `external` names the default external coder for the CODER line; missing or bad: null.
966          let external: External | null = null
967          try {
968            const x = (JSON.parse((await $.fs.read(`${root}/.claude/pa.json`).catch(() => null)) ?? 'null') as { external?: unknown } | null)?.external
969            external = typeof x === 'object' && x !== null && !Array.isArray(x) ? (x as External) : null
970          } catch {
971            external = null
972          }
973          const before = new Set((await $.agent.list()).map(a => a.id))
974          const r = await $.agent.spawn({ subagentType: agent, prompt: expertBrief(plan, { ...opts, card, external }),
975            description: `${`${plan.id} ${title}`.trim()}${effort === 'medium' ? '' : ` ${effortHint(effort)}`}` })
976          const id = r.deny !== undefined
977            ? ''
978            : r.agentId ?? newAgent((await $.agent.list()).filter(a => !before.has(a.id)), agent)
979          if (id === '') {
980            await block(s, `spawn of ${agent} for ${plan.id} failed: ${clip(r.deny ?? 'no agentId', 200)}`, 'mod', 'hold', '')
981            return
982          }
983          loopAgent = id
984          // The task after this one, for the router's talk line (T5b2).
985          const rows = await py('tools/plan_edit.py', 'show', '--tasks')
986          const nextId = rows.exitCode === 0 ? nextTaskOf(rows.stdout, plan.id) : ''
987          // Its place in the plan, for the band's `T3 (2 of 5)` (3.23 T13).
988          const taskPos = rows.exitCode === 0 ? taskPosOf(rows.stdout, plan.id) : null
989          await save({ ...s, task: { ...task, plan, agentId: id } as LoopTask, nextId, taskPos } as LoopState, 'spawn')
990          await journal(plan.id, 'start', { agentId: id, role: agent, task: plan.id },
991            `${agent} ${id} started on ${plan.id} ${title} (${kind}, attempt ${String(attempt)}); start ${startSha || '?'}.`)
992          // 3.23 T13d.c3: the start card is one feed record, labelled as this task's expert (zero tokens).
993          await feedIn(at => startRecord({ agent: id, type: agent, id: plan.id, title, at }))
994        }
995
996        // A record the router writes into the feed's `in/` (3.23 T13d.c3/c4), named `nudgeName(ms, seq, '8')`.
997        const feedIn = async (body: (at: number) => string): Promise<void> => {
998          const fdir = (await read($, stateAtom) as { feed?: { dir?: string } } | null)?.feed?.dir ?? feedDirOf(sidNow)
999          const at = await $.clock.now()
1000          sseq += 1
1001          await $.fs.write(`${root}/${fdir}/in/${nudgeName(at, sseq, '8')}`, body(at)).catch(quiet)
1002        }
1003
1004        // A plan change, split without a shell (contract.splitCommand), run, then committed (PLAN, plus `rules` for
1005        // rules_add.py); `ran` follows the run (or the refusal). The caller's `applied: <first output line>` or `refused:`.
1006        const applyEdit = async (command: string, why: string, what: string, ran = async (): Promise<void> => undefined): Promise<string> => {
1007          const sp = splitCommand(command, python)
1008          if ('refused' in sp) {
1009            await ran()
1010            return `refused: ${sp.refused}`
1011          }
1012          const r = await run(sp.argv)
1013          await ran()
1014          if (r.exitCode !== 0) {
1015            return `refused: ${command}: exit ${String(r.exitCode)} ${clip((r.stderr || r.stdout).trim(), 200)}`
1016          }
1017          const rules = sp.argv[1] === 'tools/rules_add.py'
1018          const failed = await commit(clip(why.trim() === '' ? command : why.trim(), 90), rules ? [PLAN, 'rules'] : [PLAN])
1019          await journal('mod', 'hold', { task: s.task?.id }, `${what} applied: ${command}${why === '' ? '' : ` (${why})`}.`)
1020          const first = r.stdout.trim().split(/\r?\n/)[0]?.trim() || command
1021          return failed === '' ? `applied: ${first}` : `applied: ${first} (not committed: ${failed})`
1022        }
1023
1024        // Queued plan changes, oldest first; each leaves the queue once run (a restart mid-way re-runs only the one in flight).
1025        const landQueued = async (): Promise<void> => {
1026          for (let e = s.queuedEdits[0]; e !== undefined; e = s.queuedEdits[0]) {
1027            const head = e
1028            const line = await applyEdit(head.command, head.why, 'Queued change',
1029              async () => save({ ...s, queuedEdits: s.queuedEdits.slice(1) }, 'plan_edit.landed'))
1030            await confirm(line, head.caller)
1031            await log('plan_edit', { line: clip(line, 200), queued: true, sent: !quietReply(line) })
1032          }
1033        }
1034
1035        // The close's boundary (T6b): queued edits, inbox-consume's items to Claude, then `next` (null: blocked).
1036        const closeBoundary = async (): Promise<Record<string, unknown> | null> => {
1037          await landQueued()
1038          const ic = await py('tools/status.py', 'inbox-consume')
1039          const items = ic.stdout.split(/\r?\n/).filter(l => l.trim() !== '' && l.trim() !== 'none').join('\n')
1040          if (items !== '') {
1041            await journal('mod', 'hold', {}, `INBOX consumed at the close:\n${items}`)
1042            await notice(`‹pa› INBOX items before the phase closes; route each (plan_edit, or tell the developer):\n${items}`)
1043          }
1044          await save({ ...s, closeInbox: (await $.fs.read(`${root}/${INBOX_MD}`).catch(() => null)) ?? '' }, '')
1045          const nx = await py('tools/plan_edit.py', 'next', '--brief', '--json')
1046          const pick = nx.exitCode === 0 ? jsonOf(nx.stdout) : null
1047          if (pick === null) {
1048            await block(s, `plan_edit next: exit ${String(nx.exitCode)} ${clip(nx.stderr || nx.stdout, 200)}`, 'mod', 'hold', '')
1049          }
1050          return pick
1051        }
1052
1053        // 3.23 T13d.c4: the ledger recalc (the PhaseEnd's `## Agent runs` source) in the background, so it runs while the
1054        // close goes on; one per phase. 3.24 T10b: launched detached (`--detach`, back at once) with `--rows-only` (the
1055        // PhaseEnd reads only the rows; the refit and the summary rebuild are the statusline child's), over every session
1056        // that owned the phase. A mod `$.process.run` child alive for minutes stalled the session's message queue, one
1057        // item per 60 s, and the closer's card never came (3.23 c6c; logs/T10.md Part b). A failure is only journaled.
1058        const startRecalc = async (phase: string): Promise<void> => {
1059          if (recalc !== null && recalc.phase === phase) {
1060            return
1061          }
1062          const status = jsonOf((await $.fs.read(`${root}/.run/status.json`).catch(() => null)) ?? '')
1063          const lp = parseLoop(await $.fs.read(`${root}/${LOOP_FILE}`).catch(() => null))
1064          const sids = recalcSessions(lp.phaseSids, lp.ownerSid, strOf(status?.['router_session']), sidNow)
1065          if (sids.length === 0) {
1066            recalc = { phase, line: 'Ledger recalc skipped: no session to recalc.' }
1067            return
1068          }
1069          const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
1070          const dir = ((await $.env.get('PA_MOD_PA3_DIR')) ?? '') || `${home.replace(/\\/g, '/')}/.claude/pa3`
1071          const x = await run([python, `${dir}/pa_ledger.py`, 'recalc', '--detach', '--rows-only', '--from', 'turns',
1072            ...sids.flatMap(id => ['--session', id]), '--done-file', `${root}/${RECALC_DONE}`])
1073          recalc = { phase, line: x.exitCode === 0 ? ''
1074            : `Ledger recalc not started: exit ${String(x.exitCode)} ${clip((x.stderr || x.stdout).trim(), 200)}.` }
1075        }
1076        // The detached recalc's done-file as its journal line; null while it runs.
1077        const recalcDone = async (): Promise<string | null> => {
1078          const d = jsonOf((await $.fs.read(`${root}/${RECALC_DONE}`).catch(() => null)) ?? '')
1079          if (d === null) {
1080            return null
1081          }
1082          const sids = Array.isArray(d['sessions']) ? (d['sessions'] as unknown[]).map(strOf).join(', ') : ''
1083          return `Ledger recalc (sessions ${sids}): exit ${String(d['exit'])} after ${String(d['secs'])} s.`
1084        }
1085        // 3.25 T2: the close itself, no agent (the developer's yes at the close question is the milestone): RECAP.md from
1086        // Claude's phase report (`release(close)`'s note, kept as `closeRecap`; `-` when empty), with `## Generation Recap`
1087        // too at a generation end (genend_index.py reads it), the recalc started, then the chain from `recalc`.
1088        const startClose = async (): Promise<void> => {
1089          const pp = planPhase((await $.fs.read(`${root}/${PLAN}`).catch(() => null)) ?? '')
1090          const phase = pp.phase !== '' ? pp.phase : s.chainPhase ?? '?'
1091          const genEnd = genEndOf((await $.fs.read(`${root}/${GEN_PLAN}`).catch(() => null)) ?? '', pp.gen)
1092          const note = (s.closeRecap ?? '').trim() || '-'
1093          try {
1094            await $.fs.write(`${root}/${RECAP}`, `## Recap\n${note}\n` + (genEnd ? `\n## Generation Recap\n${note}\n` : ''))
1095          } catch (err) {
1096            await block({ ...s, closeRequested: false }, `the close: ${RECAP} not written: ${clip(String(err), 200)}`, 'mod', 'hold', '')
1097            return
1098          }
1099          await journal('mod', 'close', {}, `Phase ${phase} close started${genEnd ? ' (generation end)' : ''}: ${RECAP} written from Claude's report.`)
1100          await startRecalc(phase)
1101          const n = { ...s, state: 'closing', task: null, closeRequested: false, chainPhase: phase, chainGenEnd: genEnd, chainStep: 'recalc' } as LoopState
1102          delete n.closeRecap
1103          await save(n, 'close.start')
1104        }
1105
1106        // The close chain (T6b), from loop.json `chainStep` (the next step; each saved once it ran, so a crash resumes
1107        // there). Nothing waits on the developer; a failed step is a block for Claude and keeps its chainStep.
1108        const runChain = async (): Promise<void> => {
1109          const phase = s.chainPhase ?? '?'
1110          const stop = async (reason: string, what: string): Promise<void> => {
1111            await block(s, `phase ${phase} close, ${s.chainStep}: ${reason}`, 'mod', 'close', what === '' ? reason : what, what)
1112          }
1113          const go = async (to: string, extra: Partial<LoopState> = {}): Promise<void> => {
1114            await save({ ...s, ...extra, chainStep: to } as LoopState, `chain:${to}`)
1115          }
1116          const okCommit = (why: string): boolean => why === '' || /nothing staged/.test(why)
1117          while (CHAIN_STEPS.includes(s.chainStep) && !s.holds.some(h => h !== 'talk')) {
1118            const step = s.chainStep
1119            // 3.24 T13A: the plan archived already (round 2: the closer ran the archive itself): the steps up to the archive
1120            // are done, and the chain goes on at `archived`, so it always reaches the closed line, the park and the toast.
1121            if (['assemble', 'commit', 'archive'].includes(step) && !(await $.fs.exists(`${root}/${PLAN}`)) &&
1122              (await $.fs.exists(`${root}/phase-ends/phase-${phase}/PHASE_PLAN.md`))) {
1123              await journal('mod', 'close', {}, `Phase ${phase} was archived already (phase-ends/phase-${phase}/): the chain goes on at the archived commit.`)
1124              await go('archived', { chainRuns: [], chainStage: [] })
1125              continue
1126            }
1127            if (step === 'recalc') {
1128              // 3.23 T13d.c4: the recalc started at the close's start (here when this process has none for the phase),
1129              // waited for at most RECALC_WAIT_MS more. The ledger is the PhaseEnd's Agent runs source, not a gate: the
1130              // chain goes on with whatever it holds, and the journal says whether the recalc had finished. 3.24 T10b: its
1131              // done-file is polled; nothing else waits on it.
1132              if (recalc === null || recalc.phase !== phase) {
1133                await startRecalc(phase)
1134              }
1135              const r = recalc
1136              let line = r === null ? 'Ledger recalc: none.' : r.line
1137              if (r !== null && r.line === '') {
1138                const until = (await $.clock.now()) + RECALC_WAIT_MS
1139                let done = await recalcDone()
1140                while (done === null && (await $.clock.now()) < until) {
1141                  await new Promise<void>(res => { $.clock.after(RECALC_POLL_MS, res) })
1142                  done = await recalcDone()
1143                }
1144                line = done ?? `Ledger recalc still running after the chain's ${String(RECALC_WAIT_MS / 1000)} s wait; the PhaseEnd uses the ledger as it is.`
1145              }
1146              await journal('mod', 'close', {}, line)
1147              await go('assemble')
1148            } else if (step === 'assemble') {
1149              // 3.25 T2: once; RECAP.md exists before the chain (startClose).
1150              const r = await py('tools/phaseend_index.py', 'assemble', phase)
1151              if (r.exitCode !== 0) {
1152                await stop(`assemble exit ${String(r.exitCode)}`, clip((r.stderr || r.stdout).trim(), 600))
1153                return
1154              }
1155              await go('commit')
1156            } else if (step === 'commit') {
1157              const why = await commit(`PhaseEnd ${phase}`, [`phase-ends/PhaseEnd_Phase${phase}.md`], 'phase-end')
1158              if (!okCommit(why)) {
1159                await stop(why, '')
1160                return
1161              }
1162              await go('archive')
1163            } else if (step === 'archive') {
1164              const r = await py('tools/phaseend_index.py', 'archive')
1165              if (r.exitCode !== 0) {
1166                await stop(`archive exit ${String(r.exitCode)}`, clip((r.stderr || r.stdout).trim(), 600))
1167                return
1168              }
1169              const a = archiveLines(r.stdout)
1170              await journal('mod', 'close', {}, `Phase ${phase} archived:\n${r.stdout.trim()}`)
1171              await go('run', { chainRuns: a.runs, chainStage: a.stage })
1172            } else if (step === 'run') {
1173              const line = (s.chainRuns ?? [])[0]
1174              if (line === undefined) {
1175                await go('archived')
1176                continue
1177              }
1178              const sp = splitCommand(line, python, CHAIN_SCRIPTS)
1179              if ('refused' in sp) {
1180                await stop(`RUN: ${line} refused: ${sp.refused}`, '')
1181                return
1182              }
1183              const r = await run(sp.argv)
1184              if (r.exitCode !== 0) {
1185                await stop(`RUN: ${line}: exit ${String(r.exitCode)} ${clip((r.stderr || r.stdout).trim(), 300)}`, '')
1186                return
1187              }
1188              await journal('mod', 'close', {}, `RUN: ${line}\n${clip(r.stdout.trim(), 400)}`)
1189              await go('run', { chainRuns: (s.chainRuns ?? []).slice(1) })
1190            } else if (step === 'archived') {
1191              const why = await commit(`Phase ${phase} archived`, ['phase-ends', GEN_PLAN, ...(s.chainStage ?? [])], 'phase-end')
1192              if (!okCommit(why)) {
1193                await stop(why, '')
1194                return
1195              }
1196              await go('clear')
1197            } else if (step === 'clear') {
1198              await py('tools/status.py', 'clear')
1199              // 3.29 T5: the phase's temp folder `.run/<phase>/` goes once the plan's phase-cleanup task is done; never a stop
1200              try {
hooks/build.ts 6 lines
1// 3.30.2: the release this mod's code is. The root install (pa/install/root.py) stamps this file in the installed mod
2// with the package's version and commit, so a running process knows the code it loaded, whatever the files on disk say
3// since: the session-start hook's background install can rewrite VERSION and MANIFEST.json while a session starts. In a
4// checkout (tests, a dev run) it stays empty, and the installed VERSION file stands in.
5export const BUILD: { version: string; git: string } = { version: '', git: '' }
6
hooks/card.ts 345 lines
1// The router's cards (3.23-autopilot.md, "T13 display and voice"): the answers of locally answered main-loop turns that
2// show a loop agent's note or finish. Pure, no `$`. The stored text is clean markdown, because Remote Control shows the
3// stored text (no render hook runs on the phone): a bold title led by the role's colored square, the body, then any diff
4// in a ```diff fence. register.tsx keeps a registry of the texts it answered and draws only those, in the role's color.
5import { type CoderReturn, type ExpertContract, parseCoderReturn, parseExpertContract } from './contract'
6import { handbackReport } from './route'
7import type { Band, BandRow, Shell, Work } from '../types'
8import { RESTART_LINE, SPINNER } from './loop'
9
10export type CardRole = 'claude' | 'planning' | 'expert' | 'coder' | 'thinker' | 'curator' | 'auditor'
11
12/** The title's square by role (no teal square exists: the thinker takes the expert's blue; no pink one: the curator takes
13 * the planners' purple, the steel-blue auditor the expert's blue). */
14export const SQUARE: Record<CardRole, string> = {
15  claude: '🟧', planning: '🟪', expert: '🟦', coder: '🟩', thinker: '🟦', curator: '🟪', auditor: '🟦',
16}
17
18/** The roles' hex colors (the feed's and the cards' palette). 3.24 T13B: the memory curator
19 * pink, the auditor steel blue. */
20export const ROLE_HEX: Record<CardRole, string> = {
21  claude: '#d7875f', planning: '#af5fff', expert: '#0087ff', coder: '#00af00', thinker: '#00afaf',
22  curator: '#d75faf', auditor: '#5f87af',
23}
24
25const LABEL: Record<CardRole, string> = {
26  claude: 'Claude', planning: 'Planning', expert: 'Expert', coder: 'Coder', thinker: 'Thinker',
27  curator: 'Curator', auditor: 'Auditor',
28}
29
30/** A card's text: `**<square> [✓ ]<Role>[ · <what>]**`, the body, then the diff in a ```diff fence when there is one. */
31export function cardText(role: CardRole, what: string, body: string, opts: { done?: boolean; diff?: string } = {}): string {
32  const title = `**${SQUARE[role]} ${opts.done === true ? '✓ ' : ''}${LABEL[role]}${what === '' ? '' : ` · ${what}`}**`
33  const parts = [title, body.trim()].filter(p => p !== '')
34  const diff = (opts.diff ?? '').replace(/\s+$/, '')
35  if (diff !== '') {
36    parts.push(`\`\`\`diff\n${diff}\n\`\`\``)
37  }
38  return parts.join('\n\n')
39}
40
41export type Card = { role: CardRole; done: boolean; title: string; body: string; diff: string }
42
43const TITLE_RE = /^\*\*(\S+) (✓ )?(Claude|Planning|Expert|Coder|Thinker|Curator|Auditor)((?: · [^*\n]+)?)\*\*(?:\n|$)/
44
45/** A card text split for drawing: its role, done mark, title (without the square), body and diff; null when no card. */
46export function parseCard(text: string): Card | null {
47  const m = TITLE_RE.exec(text)
48  if (m === null) {
49    return null
50  }
51  const role = (Object.keys(LABEL) as CardRole[]).find(r => LABEL[r] === m[3]) ?? 'claude'
52  let rest = text.slice(m[0].length)
53  let diff = ''
54  const f = /(?:^|\n)```diff\n([\s\S]*?)\n```\s*$/.exec(rest)
55  if (f !== null) {
56    diff = f[1] ?? ''
57    rest = rest.slice(0, f.index)
58  }
59  return { role, done: m[2] !== undefined, title: `${m[2] ?? ''}${m[3] ?? ''}${m[4] ?? ''}`, body: rest.trim(), diff }
60}
61
62/** The first meaningful line of a report: not blank, a fence, a heading mark or a `KEY:` contract line. */
63export function firstLine(text: string): string {
64  for (const raw of text.split(/\r?\n/)) {
65    const l = raw.replace(/^\s*#+\s*/, '').trim()
66    if (l !== '' && !/^(```|~~~)/.test(l) && !/^[A-Z][A-Z_ ]*\s*[:=]/.test(l)) {
67      return l
68    }
69  }
70  return ''
71}
72
73/** The expert's finish: its SUMMARY (else the report's first meaningful line) and the done mark (only for STATUS done,
74 * so a blocked expert's card draws unfinished); no status, commit or path (3.23 T13d.c3: cards are announcements). */
75export function expertFinish(task: string, c: ExpertContract, report: string): string {
76  return cardText('expert', task, c.summary.trim() || firstLine(report), { done: c.status === 'done' })
77}
78
79/** 3.24 T8b: one file of a commit, from `git show --numstat`: lines added and removed (null, null for a binary file). */
80export type FileStat = { path: string; add: number | null; del: number | null }
81
82/** The files of `git show --numstat --format= <commit>` stdout, in its order; lines that are not `add<TAB>del<TAB>path` skipped. */
83export function numstatOf(stdout: string): FileStat[] {
84  const out: FileStat[] = []
85  for (const line of stdout.split(/\r?\n/)) {
86    const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line)
87    if (m !== null) {
88      out.push({ path: m[3] ?? '', add: m[1] === '-' ? null : Number(m[1]), del: m[2] === '-' ? null : Number(m[2]) })
89    }
90  }
91  return out
92}
93
94/** The file list a coder's finish card shows at most; the rest is `… +N files`. */
95export const FILES_MAX = 20
96
97/** 3.24 T8b: the coder's finish (round border under `mixed`, as the developer's Style D): `✓ Coder · T3 · c1, committed
98 * <hash>`, its SUMMARY (else the report's first line), then its commit's files with +/− counts, so an edit made through
99 * Bash or a script shows too. The done mark only for STATUS done. */
100export function coderFinish(what: string, c: CoderReturn, report: string, files: readonly FileStat[]): string {
101  const commit = /^[0-9a-f]{7,40}$/i.test(c.commit.trim()) ? c.commit.trim().slice(0, 7) : ''
102  const list = files.slice(0, FILES_MAX).map(f => `- \`${f.path}\` ${f.add === null ? '(binary)' : `+${f.add} −${f.del ?? 0}`}`)
103  if (files.length > FILES_MAX) {
104    list.push(`- … +${files.length - FILES_MAX} files`)
105  }
106  const body = [c.summary.trim() || firstLine(report), list.join('\n')].filter(p => p !== '').join('\n\n')
107  return cardText('coder', `${what}${commit === '' ? '' : `, committed ${commit}`}`, body, { done: c.status === 'done' })
108}
109
110/** 3.24 T13B (decision 8): every reply of Claude's (a hand-back turn's, a marker turn's, a feed reply record's) is an
111 * orange Claude card, `**🟧 Claude**` then the reply. Retires 3.24's "white dot = Claude's replies, drawn natively".
112 * 3.24 T17g: `shown`, the model and effort of the request that made the reply (feed.ts shownOf), joins the title
113 * (`**🟧 Claude · Opus 5.5 · medium**`), so a /model or /effort change shows in the record. */
114export const claudeCard = (reply: string, shown = ''): string => cardText('claude', shown, restartGap(reply))
115
116/** 3.28.2 T17: one empty line before the mod's restart line (loop.ts RESTART_LINE) whenever the line before it isn't
117 * empty, whatever Claude's spacing; the stored text carries it, so Remote Control matches the drawing. */
118export function restartGap(reply: string): string {
119  const at = reply.indexOf(RESTART_LINE)
120  const before = at < 0 ? '' : reply.slice(0, at)
121  if (before.trim() === '' || /\n[ \t]*\n[ \t]*$/.test(before)) {
122    return reply
123  }
124  return `${before.replace(/[ \t]*\n?[ \t]*$/, '')}\n\n${reply.slice(at)}`
125}
126
127/** 3.24 T17g: the review release's accepted card, `✓ Expert · T<n> · accepted`, with the task's SUMMARY as a done card
128 * carries it (the contract routed for the review; empty when it has none). Was an empty card. */
129export const acceptedCard = (task: string, contract: string): string =>
130  cardText('expert', `${task} · accepted`, parseExpertContract(contract)?.summary ?? '', { done: true })
131
132/** The thinker's finish: only that it answered; Claude relays the answer. */
133export const thinkerFinish = (): string => cardText('thinker', '', 'Answered; Claude relays it.', { done: true })
134
135/** A note: an agent's explain-before-code paragraph, sent to the main session with SendMessage. */
136export const noteCard = (role: CardRole, what: string, body: string): string => cardText(role, what, body)
137
138/** Who sent a hand-back or a note, as the turn.start hook works it out: the loop's expert, a coder under it,
139 * a thinker, or anyone else (no card). `task` is the loop task's id; `coder` is `c<k>` when known, else ''. */
140export type Sender = { role: 'expert' | 'coder' | 'thinker' | 'other'; task: string; coder: string }
141
142/** The coder id a text names (`T3.c2`, `T13a.c1`): `c<k>`, else ''. */
143export const coderIdOf = (text: string): string => /\bT\d+[a-z]?\.(c\d+)\b/.exec(text)?.[1] ?? ''
144
145const what = (s: Sender): string => (s.role === 'coder' && s.coder !== '' ? `${s.task} · ${s.coder}` : s.task)
146
147/** A note's card: from the loop's expert or a coder under it; null for anyone else. */
148export function noteCardOf(s: Sender, body: string): string | null {
149  if (body.trim() === '' || (s.role !== 'expert' && s.role !== 'coder')) {
150    return null
151  }
152  return noteCard(s.role, what(s), body)
153}
154
155/** A hand-back's finish card, or null (stays the stub): a `.` ping, a `WAITING:` hand-back, no contract, or a sender with
156 * no card (retrievers, the curator, the auditor, the partner). 3.24 T8b: a coder's finish is a card again (3.23 T13d.c3 had dropped it), with `files`, its commit's numstat.
157 * 3.24 T17g: `late`, the agent's final text when the notification only points at a hand-back already spent (route.ts
158 * lateReport), stands in for the report. */
159export function finishCardOf(s: Sender, text: string, files: readonly FileStat[] = [], late: string | null = null): string | null {
160  const report = late ?? handbackReport(text)
161  if (report === null || report.trim() === '.' || report.trim() === '' || /^\s*WAITING:/.test(report)) {
162    return null
163  }
164  if (s.role === 'thinker') {
165    return thinkerFinish()
166  }
167  if (s.role === 'expert') {
168    const c = parseExpertContract(report)
169    if (c === null) {
170      return null
171    }
172    return expertFinish(s.task, c, report)
173  }
174  if (s.role === 'coder') {
175    const c = parseCoderReturn(report)
176    return c === null ? null : coderFinish(what(s), c, report, files)
177  }
178  return null
179}
180
181/** The `card_style` userConfig (3.23 T13): `mixed` (default: notes a side bar, finishes a round border), `boxed`, `bar`, `plain`. */
182export type CardStyle = 'mixed' | 'boxed' | 'bar' | 'plain'
183
184export function cardStyleOf(options: Readonly<Record<string, unknown>>): CardStyle {
185  const v = options['card_style']
186  return v === 'boxed' || v === 'bar' || v === 'plain' ? v : 'mixed'
187}
188
189/** The Box border a card draws in under a style: `quote` (a side bar), `round`, or null (plain: no border). */
190export function borderOf(style: CardStyle, done: boolean): 'quote' | 'round' | null {
191  if (style === 'plain') {
192    return null
193  }
194  if (style === 'mixed') {
195    return done ? 'round' : 'quote'
196  }
197  return style === 'boxed' ? 'round' : 'quote'
198}
199
200// The band above the prompt (3.23 T13): the autopilot's tick works out the rows, the AbovePrompt rule draws them.
201
202/** An agent as the band sees it: `$.agent.list()`'s id, type, status, parent and description, with when it was first
203 * seen (ms) and its last step (`Tool short-arg` from the watcher's facts file, '' none). */
204export type BandAgent = { id: string; type: string; name?: string; status: string; parentId?: string; description: string; since: number; step?: string }
205
206/** A band root: the loop task's expert with its task id, or the generation start's curator or auditor (''). */
207export type BandRoot = { id: string; task: string }
208
209const LIVE = new Set(['pending', 'running', 'waiting'])
210
211/** The live agents under the roots (the roots included), each root's tree in turn, children by first seen; their root's task. */
212export function bandIds(agents: readonly BandAgent[], roots: readonly BandRoot[], keep: ReadonlySet<string> = new Set()): { id: string; task: string }[] {
213  const out: { id: string; task: string }[] = []
214  const live = agents.filter(a => LIVE.has(a.status) || keep.has(a.id)) // 3.28.1 T15: `keep`, an agent whose shell runs
215  for (const r of roots) {
216    const ids = new Set([r.id])
217    const tree = live.filter(a => a.id === r.id)
218    for (let grew = true; grew;) {
219      grew = false
220      for (const a of [...live].sort((x, y) => x.since - y.since || x.id.localeCompare(y.id))) {
221        if (!ids.has(a.id) && a.parentId !== undefined && ids.has(a.parentId)) {
222          ids.add(a.id)
223          tree.push(a)
224          grew = true
225        }
226      }
227    }
228    out.push(...tree.filter(a => !out.some(o => o.id === a.id)).map(a => ({ id: a.id, task: r.task })))
229  }
230  return out
231}
232
233/** Elapsed time, short: `42s`, `7m`, `1h05m`. */
234export function elapsedText(ms: number): string {
235  const s = Math.max(0, Math.floor(ms / 1000))
236  if (s < 60) {
237    return `${String(s)}s`
238  }
239  const m = Math.floor(s / 60)
240  return m < 60 ? `${String(m)}m` : `${String(Math.floor(m / 60))}h${String(m % 60).padStart(2, '0')}m`
241}
242
243/** An agent type's role (`expert-opus55` → expert, `coder-opus55` → coder); 3.24 T13B: `memory-curator` → curator (pink
244 * in the band); 3.24 T17b: `pa-planner` → planner; 3.25 T6b: `pa-expert`, `pa-coder`, `pa-thinker` (and an old
245 * `deep-thinker`) by the name after `pa-`. */
246export const bandRole = (type: string): string => {
247  const t = type === 'deep-thinker' ? 'thinker' : /^pa-(expert|coder|thinker|planner)$/.test(type) ? type.slice(3) : type
248  const r = t === 'memory-curator' ? 'curator' : (t.split('-')[0] ?? '') || 'agent'
249  return r === 'planner' ? 'planning' : r   // 3.28.1: ROLE_HEX names the planners' purple `planning`
250}
251
252/** The band's rows: one per live agent under the roots, its role, name, task (`T<n>`, `T<n> · c<k>` for a coder), step and
253 * elapsed time at `now`; 3.28.1 T15: under each agent a row per background shell of its (`shells`, pa-watch's
254 * shells.json): `◐ shell` (the spinner's frame from the clock; `✗` once it ended badly), its task, its name, its time.
255 * 3.30: a `pa_ext.py run` shell (`external <label>`) is the coder it runs: `pa-coder`, `T<n> · c<k>` from its label, and
256 * its latest step (`ext`, from its facts file by label; 3.30.3: no model, the statusline's row has it). */
257export function bandRows(agents: readonly BandAgent[], roots: readonly BandRoot[], now: number, shells: Record<string, Shell[]> = {},
258  ext: Record<string, string> = {}): BandRow[] {
259  const byId = new Map(agents.map(a => [a.id, a]))
260  return bandIds(agents, roots, new Set(Object.keys(shells))).flatMap(({ id, task }) => {
261    const a = byId.get(id)
262    if (a === undefined) {
263      return []
264    }
265    const role = bandRole(a.type)
266    const k = role === 'coder' ? coderIdOf(a.description) : ''
267    const t = /\bT\d+[a-z]?\b/.exec(a.description)?.[0] ?? task
268    const rowTask = k === '' ? t : `${t} · ${k}`
269    return [{
270      role, name: a.name || a.type, task: rowTask, step: (a.step ?? '').replace(/\s+/g, ' ').trim(),
271      elapsed: elapsedText(now - a.since),
272    }, ...(shells[id] ?? []).map(s => {
273      const label = /^external (\S+)$/.exec(s.name)?.[1]
274      if (label !== undefined) {
275        const [lt = '', lk] = label.split('.')
276        return {
277          role: 'coder', name: s.exit !== undefined ? '✗ pa-coder' : 'pa-coder', task: lk === undefined ? lt : `${lt} · ${lk}`,
278          step: (ext[label] ?? '').replace(/\s+/g, ' ').trim(), elapsed: elapsedText(now - s.at),
279        }
280      }
281      // the frame steps once a tick (the autopilot's 2 s), so each of the four shows
282      return {
283        role: 'shell', name: `${s.exit !== undefined ? '✗' : SPINNER[Math.floor(now / 2000) % SPINNER.length] ?? ''} shell`, task: rowTask,
284        step: s.name, elapsed: elapsedText(now - s.at),
285      }
286    })]
287  })
288}
289
290/** A band row's step from an agent's facts (3.23 T13d.c2): the watcher's `line` (the latest working line), else the last
291 * of `steps`; undefined when neither. */
292export function bandStepOf(facts: Record<string, unknown> | null): string | undefined {
293  const line = facts?.['line']
294  if (typeof line === 'string' && line.trim() !== '') {
295    return line
296  }
297  const steps = facts?.['steps']
298  const last: unknown = Array.isArray(steps) ? steps[steps.length - 1] : undefined
299  return typeof last === 'string' ? last : undefined
300}
301
302/** 3.24 T13B: the band's lines and rows: the loop's line and the conversation-only note, the autopilot's rows then the
303 * router's (Claude's); none of those while something works: `‹pa› working` with 1-3 cycling dots. */
304export function bandView(band: Band, work: Work): { lines: string[]; rows: BandRow[] } {
305  const lines = [band.line, work.note].filter(l => l !== '')
306  const rows = [...band.rows, ...work.rows]
307  if (lines.length === 0 && rows.length === 0 && work.working) {
308    lines.push(`‹pa› working${'.'.repeat(Math.min(3, Math.max(1, work.dots)))}`)
309  }
310  return { lines, rows }
311}
312
313/** 3.24 T13B: the router's band rows for Claude: the partner (`Claude`, orange) and its live children (`since` and `step`
314 * as the caller set them: first seen live, bandStepOf); '' partner: none. */
315export function claudeRows(agents: readonly BandAgent[], partnerId: string, now: number): BandRow[] {
316  if (partnerId === '') {
317    return []
318  }
319  const named = agents.map(a => (a.id === partnerId ? { ...a, type: 'claude', name: 'Claude' } : a))
320  return bandRows(named, [{ id: partnerId, task: '' }], now)
321}
322
323/** 3.29 T3: Claude's row while a developer message waits for Claude's turn to start: `received`, with the time since. */
324export const receivedRow = (at: number, now: number): BandRow =>
325  ({ role: 'claude', name: 'Claude', task: '', step: 'received', elapsed: elapsedText(now - at) })
326
327/** The conversation-only note (3.23 T5d1, moved from pa-watch's usage line in 3.24 T13B): another session owns the loop
328 * with a fresh heartbeat; '' when this session may own it. */
329export const loopNoteOf = (owns: boolean, ownerSid: string): string =>
330  owns || ownerSid === '' ? '' : `conversation only: the loop runs in ${ownerSid.slice(0, 8)}`
331
332/** A role's hex for the band's names (ROLE_HEX; a retriever or another role: none, the default color). */
333export const bandHex = (role: string): string | undefined => (role in ROLE_HEX ? ROLE_HEX[role as CardRole] : undefined)
334
335const cut = (s: string, n: number): string => (n <= 0 ? '' : s.length <= n ? s : n === 1 ? '…' : `${s.slice(0, n - 1)}…`)
336
337/** A row's three cells cut to `cols` columns, never wrapped: the name (bold, the role's hex), ` <task>` (plain), then
338 * `  <step> · <elapsed>` (dim); the dim cell gives way first, then the task, then the name. */
339export function bandCells(r: BandRow, cols: number): { name: string; task: string; dim: string } {
340  const name = cut(r.name, cols)
341  const task = cut(r.task === '' ? '' : ` ${r.task}`, cols - name.length)
342  const dim = cut(`  ${[r.step, r.elapsed].filter(p => p !== '').join(' · ')}`, cols - name.length - task.length)
343  return { name, task, dim }
344}
345
hooks/contract.ts 290 lines
1// The autopilot's parsers (3.23-autopilot.md, "The loop (router `pa`)" and "Stops, holds and decisions"): the expert's,
2// the closer's and the auditor's returns, and the POSIX-style splitter of Claude's `plan_edit` commands. Pure, no `$`.
3
4/** An expert's (or the closer's) return; '' for a field it does not carry. `status` is lower-case. */
5export type ExpertContract = {
6  status: string
7  log: string
8  ctx: string
9  commit: string
10  milestone: string
11  recommended: string
12  question: string
13  /** 3.23 T13: the plain-English summary of 2-4 sentences (optional; '' when absent). */
14  summary: string
15}
16
17/** A coder's return, as far as its finish card reads it (3.23 T13). */
18export type CoderReturn = { status: string; summary: string; commit: string }
19
20/** The auditor's return (agents/auditor.md, "Return only"). */
21export type AuditorReturn = {
22  status: string
23  verdicts: string
24  fixed: string
25  candidates: string
26  file: string
27  recommended: string
28}
29
30// As pa/hooks/user_prompt_submit.py:30-31: the first STATUS within the first 2000 chars.
31const STATUS_RE = /STATUS\s*[:=]\s*([A-Za-z-]+)/i
32const STATUS_WINDOW = 2000
33const FENCE_RE = /^\s*(```|~~~)/
34const FIELD_RE = /^\s*([A-Za-z_]+)\s*[:=]\s?(.*)$/
35
36/**
37 * The fields after the first STATUS (STATUS within the first 2000 chars), by `KEY: value` lines; a line that is no known
38 * key continues the field before it (QUESTION and RECOMMENDED may wrap), a blank line or a fence ends it. Null without
39 * STATUS.
40 */
41function fieldsAfterStatus(text: string, keys: readonly string[]): Record<string, string> | null {
42  const m = STATUS_RE.exec(text.slice(0, STATUS_WINDOW))
43  if (m === null) {
44    return null
45  }
46  const out: Record<string, string> = { status: (m[1] ?? '').toLowerCase() }
47  scanFields(text.slice(m.index).split(/\r?\n/).slice(1), keys, out)
48  return out
49}
50
51/** The `KEY: value` lines of `lines` into `out` (fieldsAfterStatus's reading). */
52function scanFields(lines: readonly string[], keys: readonly string[], out: Record<string, string>): void {
53  let current = ''
54  for (const line of lines) {
55    if (FENCE_RE.test(line) || line.trim() === '') {
56      current = ''
57      continue
58    }
59    const f = FIELD_RE.exec(line)
60    const key = f === null ? '' : (f[1] ?? '').toLowerCase()
61    if (f !== null && keys.includes(key)) {
62      if (out[key] === undefined) {
63        out[key] = (f[2] ?? '').trim()
64        current = key
65      } else {
66        current = '' // a second block's field: never overwrites the first
67      }
68    } else if (key === 'status') {
69      break // the next block
70    } else if (current !== '') {
71      out[current] = `${out[current] ?? ''}\n${line.trim()}`
72    }
73  }
74}
75
76// 3.25 T3 (3.25-loose.md, decision 2): the plain hand-back. The first non-blank line's first word, markdown and trailing
77// punctuation stripped, when it is one of these; everything after it is free text.
78const PLAIN_WORDS: readonly string[] = ['done', 'question', 'blocked', 'block', 'review', 'handoff', 'partial']
79const PLAIN_RE = /^[\s#>*_-]*([A-Za-z]+)[\s*_:.,—–-]*(.*)$/
80// A commit hash in free text: a 7-40 hex token after the word "commit" on the same line.
81const COMMIT_IN_TEXT_RE = /\bcommit\w*\b[^\n]*?(?<![0-9a-z])([0-9a-f]{7,40})(?![0-9a-z])/i
82
83/**
84 * The legacy STATUS block's fields, else a plain report's: `status` the first word, `text` the free text after it (the
85 * rest of the first line and the lines after, trimmed), any legacy `KEY:` lines among those lines as fields. Null with
86 * neither (`.`, a blank text and `WAITING:` stay null).
87 */
88function looseFields(text: string, keys: readonly string[]): Record<string, string> | null {
89  const legacy = fieldsAfterStatus(text, keys)
90  if (legacy !== null) {
91    return legacy
92  }
93  const lines = text.split(/\r?\n/)
94  const i = lines.findIndex(l => l.trim() !== '')
95  const m = i < 0 ? null : PLAIN_RE.exec(lines[i] ?? '')
96  const word = (m?.[1] ?? '').toLowerCase()
97  if (m === null || !PLAIN_WORDS.includes(word)) {
98    return null
99  }
100  const out: Record<string, string> = { status: word }
101  scanFields(lines.slice(i + 1), keys, out)
102  out['text'] = [m[2] ?? '', ...lines.slice(i + 1)].join('\n').trim()
103  return out
104}
105
106/** The commit: the `commit` field's leading hash, else the field as written; without one, a hash after "commit" in the text. */
107function commitOf(f: Record<string, string>, text: string): string {
108  const field = f['commit'] ?? ''
109  const hash = /^\s*([0-9a-f]{7,40})(?![0-9a-z])/i.exec(field)?.[1]
110  return hash ?? (field !== '' ? field : (COMMIT_IN_TEXT_RE.exec(text)?.[1] ?? ''))
111}
112
113const EXPERT_KEYS = ['log', 'ctx', 'commit', 'milestone', 'recommended', 'question', 'summary'] as const
114
115/** An expert's return: the legacy block (`STATUS · LOG · CTX · COMMIT · MILESTONE · RECOMMENDED · QUESTION`, optional
116 * `SUMMARY`) or a plain report (3.25 T3: `summary` the free text, and for a question `question` too); null with neither.
117 * 3.28.8: a plain report's free text wins over a `Summary: <path>` line inside it (TerrainDiffusion T12's card showed
118 * only that pointer line). */
119export function parseExpertContract(text: string): ExpertContract | null {
120  const f = looseFields(text, EXPERT_KEYS)
121  if (f === null) {
122    return null
123  }
124  const g = (k: string): string => f[k] ?? ''
125  const free = g('text')
126  return {
127    status: g('status'), log: g('log'), ctx: g('ctx'), commit: commitOf(f, text), milestone: g('milestone'),
128    recommended: g('recommended'), question: g('question') || (g('status') === 'question' ? free : ''),
129    summary: free || g('summary'),
130  }
131}
132
133// Every coder key, so none continues the field before it.
134const CODER_KEYS = ['changed', 'verified', 'log', 'ctx', 'commit', 'deviations', 'blocker', 'summary'] as const
135
136/** A coder's return (`STATUS · CHANGED · VERIFIED · … · COMMIT · … · SUMMARY`, or a plain report, 3.25 T3): its status,
137 * summary and commit; null with neither.
138 * 3.24 T12c: `commit` is the field's leading hash when it has one (`75c1b8d (rename, on top of e45aa75)` → `75c1b8d`), so
139 * a coder's note after the hash still gives the finish card its commit and files; otherwise the field as written; without
140 * the field, a hash after "commit" in the text. */
141export function parseCoderReturn(text: string): CoderReturn | null {
142  const f = looseFields(text, CODER_KEYS)
143  return f === null ? null : { status: f['status'] ?? '', summary: (f['text'] ?? '') || (f['summary'] ?? ''), commit: commitOf(f, text) }
144}
145
146const AUDITOR_KEYS = ['verdicts', 'fixed', 'candidates', 'file', 'recommended'] as const
147
148/** The auditor's return (`STATUS · VERDICTS · FIXED · CANDIDATES · FILE · RECOMMENDED`); null without STATUS. */
149export function parseAuditorReturn(text: string): AuditorReturn | null {
150  const f = fieldsAfterStatus(text, AUDITOR_KEYS)
151  if (f === null) {
152    return null
153  }
154  const g = (k: string): string => f[k] ?? ''
155  return { status: g('status'), verdicts: g('verdicts'), fixed: g('fixed'), candidates: g('candidates'), file: g('file'), recommended: g('recommended') }
156}
157
158/** The only scripts a `plan_edit` command may run, after `PY`. */
159export const ALLOWED_SCRIPTS: readonly string[] = ['tools/plan_edit.py', 'tools/rules_add.py']
160/** The phase close's `RUN:` lines (3.23 T6b): plan_edit.py gen-close and genend_index.py assemble. */
161export const CHAIN_SCRIPTS: readonly string[] = [...ALLOWED_SCRIPTS, 'tools/genend_index.py']
162
163export type Split = { argv: string[] } | { refused: string }
164
165/**
166 * Claude's `plan_edit` command as argv, never through a shell: split POSIX-style (single quotes literal; in double quotes
167 * a backslash escapes `\ " $` and the backtick; outside quotes a backslash escapes any character). Refused where a shell
168 * would substitute (`$(` or a backtick outside single quotes, unescaped) or chain or redirect (`;`, `&&`, `|`, `<`, `>`
169 * outside quotes); an unterminated quote; any script but `tools/plan_edit.py` or `tools/rules_add.py`. The script may
170 * follow `PY` or any interpreter-looking token, stand first, or be left out (bare args mean plan_edit.py; rules_add.py
171 * only when named). The interpreter becomes `python`, one argv element. `allowed` widens the scripts (CHAIN_SCRIPTS).
172 */
173export function splitCommand(line: string, python: string, allowed: readonly string[] = ALLOWED_SCRIPTS): Split {
174  const argv: string[] = []
175  let word = ''
176  let inWord = false
177  let quote: '' | "'" | '"' = ''
178  for (let i = 0; i < line.length; i += 1) {
179    const c = line[i] ?? ''
180    const next = line[i + 1] ?? ''
181    if (quote === "'") {
182      if (c === "'") {
183        quote = ''
184      } else {
185        word += c
186      }
187      continue
188    }
189    if (c === '\\') {
190      if (quote === '"') {
191        if (next === '\\' || next === '"' || next === '$' || next === '`') {
192          word += next
193          i += 1
194        } else if (next === '\n') {
195          i += 1
196        } else {
197          word += c
198        }
199      } else if (next === '\n') {
200        i += 1
201      } else if (next !== '') {
202        word += next
203        inWord = true
204        i += 1
205      } else {
206        word += c
207        inWord = true
208      }
209      continue
210    }
211    if (c === '`') {
212      return { refused: 'a backtick outside single quotes' }
213    }
214    if (c === '$' && next === '(') {
215      return { refused: '$( command substitution' }
216    }
217    if (quote === '"') {
218      if (c === '"') {
219        quote = ''
220      } else {
221        word += c
222      }
223      continue
224    }
225    if (c === ';' || c === '|' || c === '<' || c === '>') {
226      return { refused: `${c} outside quotes` }
227    }
228    if (c === '&' && next === '&') {
229      return { refused: '&& outside quotes' }
230    }
231    if (c === "'" || c === '"') {
232      quote = c
233      inWord = true
234      continue
235    }
236    if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
237      if (inWord) {
238        argv.push(word)
239        word = ''
240        inWord = false
241      }
242      continue
243    }
244    word += c
245    inWord = true
246  }
247  if (quote !== '') {
248    return { refused: 'an unterminated quote' }
249  }
250  if (inWord) {
251    argv.push(word)
252  }
253  if (argv.length === 0) {
254    return { refused: 'an empty command' }
255  }
256  // The forms Claude writes: `PY <script> …`, `<interpreter> <script> …`, `<script> …`, or bare plan_edit.py args.
257  const rest = isInterpreter(argv[0] ?? '') ? argv.slice(1) : argv
258  const first = rest[0] ?? ''
259  const script = scriptOf(first, allowed)
260  let args: string[]
261  if (script !== null) {
262    args = [script, ...rest.slice(1)]
263  } else if (rest !== argv || !/^[a-z][a-z-]*$/.test(first)) {
264    return { refused: `not an allowed script: ${first === '' ? '(none)' : first}` }
265  } else if (SHELLS.includes(first)) {
266    return { refused: `${first} runs nothing here: only tools/plan_edit.py or tools/rules_add.py` }
267  } else {
268    args = ['tools/plan_edit.py', ...rest] // bare args mean plan_edit.py
269  }
270  if (python === '') {
271    return { refused: 'no python in pa.json' }
272  }
273  return { argv: [python, ...args] }
274}
275
276// Bare args go to plan_edit.py, whose argparse refuses an unknown subcommand; a shell's name gets a plain refusal instead.
277const SHELLS = ['bash', 'sh', 'zsh', 'cmd', 'powershell', 'pwsh', 'env', 'exec']
278
279/** `PY`, or a token naming a Python interpreter (`python`, `python3.14`, `py`, `…/python.exe`). */
280function isInterpreter(tok: string): boolean {
281  const base = tok.split(/[\\/]/).pop() ?? ''
282  return tok === 'PY' || /^(python[\d.]*|py)(\.exe)?$/i.test(base)
283}
284
285/** The allowed script a token names (`tools/plan_edit.py`, `./tools/plan_edit.py`, `plan_edit.py`, backslashes), else null. */
286function scriptOf(tok: string, allowed: readonly string[]): string | null {
287  const t = tok.replace(/\\/g, '/').replace(/^\.\//, '')
288  return allowed.find(s => t === s || t === s.slice('tools/'.length)) ?? null
289}
290
hooks/debug.ts 51 lines
1// Pure helpers of the router's wiring: debug event files (one JSON file per event under PA_MOD_DEBUG=1, since `$.fs`
2// has no append), the generation token, and the argv of the prune of old `.run/mod/<sid>` folders.
3
4/** One file per event, sortable by time then by a per-load counter. */
5export function eventFile(dir: string, ms: number, n: number, kind: string): string {
6  const safe = kind.replace(/[^a-z0-9.]+/gi, '_')
7  return `${dir}/${String(ms)}-${String(n).padStart(5, '0')}-${safe}.json`
8}
9
10/** A value as text, cut to `n` characters, so event files stay small. */
11export function clip(value: unknown, n: number): string {
12  let text: string
13  if (typeof value === 'string') {
14    text = value
15  } else {
16    try {
17      text = JSON.stringify(value) ?? String(value)
18    } catch {
19      text = String(value)
20    }
21  }
22  return text.length > n ? `${text.slice(0, n)}…` : text
23}
24
25/**
26 * A tick holds the generation it was armed under; `/clear` resets `$.state` (the current generation reads 0) and
27 * re-arming bumps it, so a tick whose token differs from the current one is stale and stops.
28 */
29export function isStale(tickGen: number, currentGen: number): boolean {
30  return tickGen !== currentGen
31}
32
33/** `.run/mod/<sid>` folders untouched this many days are pruned at session start. */
34export const PRUNE_DAYS = 14
35
36// argv: base, days, then the folder names to keep. A folder's age is the newer of its own and its state.json mtime;
37// only 36-character names (session ids) are candidates. Prints how many it removed.
38const PRUNE_SCRIPT =
39  'import os,sys,time,shutil;b=sys.argv[1];c=time.time()-float(sys.argv[2])*86400;k=set(sys.argv[3:]);' +
40  "s=lambda p:max(os.path.getmtime(p),os.path.getmtime(os.path.join(p,'state.json')) if os.path.exists(os.path.join(p,'state.json')) else 0);" +
41  'o=[os.path.join(b,x) for x in (os.listdir(b) if os.path.isdir(b) else []) if len(x)==36 and x not in k and os.path.isdir(os.path.join(b,x))];' +
42  'o=[p for p in o if s(p)<c];[shutil.rmtree(p,ignore_errors=True) for p in o];print(len(o))'
43
44/** The `$.process.run` argv of the prune, or null when pa.json names no interpreter. */
45export function pruneArgv(python: string, base: string, keep: readonly string[], days: number): string[] | null {
46  if (python === '') {
47    return null
48  }
49  return [python, '-c', PRUNE_SCRIPT, base, String(days), ...keep.filter(k => k !== '')]
50}
51
hooks/inbox.ts 200 lines
1// The router's inbox `.run/mod/<sid>/inbox/` (design.md, "The plugins talk through files"): the watcher writes one file
2// per tool request, `<ms>-<seq>-<kind>.json` holding `{id, kind, caller, input, at}`; the router's one-second tick
3// drains it. Pure: naming, parsing, which files are owed, and the handler table. A handler returns effects (data) that
4// the drain performs inline, since `$` cannot be passed to a helper.
5
6import type { ToastOut } from '../types'
7
8import { presentOf } from './planview'
9import { DONE_TTL_MS, type Present } from './state'
10
11export type Request = { id: string; kind: string; caller: string; input: unknown; at: number }
12
13export type RequestName = { id: string; ms: number; seq: number; kind: string }
14
15/** What a handler asks the drain to do: the debug log; T8a: the topic flag on the calling partner, a waiting toast;
16 * T8c: a dialog queued for the presentation tick (planview.ts); 3.23 T5b: a loop notice queued as a framed `other`
17 * message for `to` (a request's caller), else the partner; T13b: a loop stop's toast cause owed to Claude's next reply
18 * (`stop`; null clears it); 3.24 T13E: a card the loop drew (`card`: registered, journaled, drawn at zero tokens). */
19export type Effect =
20  | { do: 'log'; kind: string; data: Record<string, unknown> }
21  | { do: 'topic'; agentId: string }
22  | { do: 'toast'; cause: ToastOut['cause']; body: string }
23  | { do: 'present'; item: Present }
24  | { do: 'notice'; text: string; to: string }
25  | { do: 'stop'; cause: ToastOut['cause'] | null }
26  | { do: 'card'; text: string }
27  | { do: 'retrieve'; question: string; label: string; answer: string }
28
29/**
30 * Kinds the loop's tick drains itself (autopilot.tsx, marked done in loop.json); the router's drain skips them. T5d2a:
31 * `forward`, written by the router when it queues a forward of a child's hand-back to the loop's expert. T6a1: the
32 * router's `install` (an approved generation or phase plan) and `answer` (the answer to the loop's own `ask`).
33 */
34export const LOOP_KINDS: ReadonlySet<string> = new Set(['release', 'plan_edit', 'steer', 'forward', 'install', 'answer', 'cleanup'])
35
36/** A file name's kind is one the loop drains. */
37export const isLoopKind = (name: string): boolean => LOOP_KINDS.has(parseRequestName(name)?.kind ?? '')
38
39/** A handler reads the request and the project root (3.3 T7: the `retrieve` answer path is checked against it). */
40export type Handler = (req: Request, root: string) => Effect[]
41
42const KIND = /^[a-z][a-z0-9_]*$/
43
44/** The file name of a request; its id is the name without `.json`. */
45export function requestName(ms: number, seq: number, kind: string): string {
46  if (!KIND.test(kind)) {
47    throw new Error(`bad request kind: ${kind}`)
48  }
49  return `${String(ms)}-${String(seq)}-${kind}.json`
50}
51
52export function parseRequestName(name: string): RequestName | null {
53  const m = /^(\d+)-(\d+)-([a-z][a-z0-9_]*)\.json$/.exec(name)
54  if (m === null) {
55    return null
56  }
57  return { id: name.slice(0, -'.json'.length), ms: Number(m[1]), seq: Number(m[2]), kind: m[3] ?? '' }
58}
59
60/** The request a file holds, or null when it does not parse or its id or kind disagrees with its name. */
61export function parseRequest(text: string, name: string): Request | null {
62  const n = parseRequestName(name)
63  if (n === null) {
64    return null
65  }
66  let doc: unknown
67  try {
68    doc = JSON.parse(text)
69  } catch {
70    return null
71  }
72  if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
73    return null
74  }
75  const o = doc as Record<string, unknown>
76  if (o['id'] !== n.id || o['kind'] !== n.kind) {
77    return null
78  }
79  return {
80    id: n.id,
81    kind: n.kind,
82    caller: typeof o['caller'] === 'string' ? o['caller'] : '',
83    input: o['input'] ?? null,
84    at: typeof o['at'] === 'number' ? o['at'] : n.ms,
85  }
86}
87
88/** The request files owed: well named, not done, inside the horizon; oldest first (ms, then seq). */
89export function pendingRequests(names: readonly string[], done: Readonly<Record<string, number>>, now: number,
90  ttl: number = DONE_TTL_MS): string[] {
91  const owed: RequestName[] = []
92  for (const name of names) {
93    const n = parseRequestName(name)
94    if (n !== null && done[n.id] === undefined && now - n.ms <= ttl) {
95      owed.push(n)
96    }
97  }
98  owed.sort((a, b) => a.ms - b.ms || a.seq - b.seq || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0))
99  return owed.map(n => `${n.id}.json`)
100}
101
102const noop: Handler = req => [{ do: 'log', kind: 'inbox.drain', data: { id: req.id, kind: req.kind } }]
103
104const inputText = (input: unknown, key: string): string => {
105  const v = typeof input === 'object' && input !== null ? (input as Record<string, unknown>)[key] : undefined
106  return typeof v === 'string' ? v : ''
107}
108
109/** A `plan` or `ask` queued for the presentation tick; an input it cannot read is logged and dropped. */
110const presentFx = (req: Request): Effect[] => {
111  const item = presentOf(req)
112  return item === null
113    ? [{ do: 'log', kind: 'present.bad', data: { id: req.id, kind: req.kind } }]
114    : [{ do: 'present', item }]
115}
116
117const CAUSES: readonly ToastOut['cause'][] = ['review', 'question', 'input']
118
119/** A loop notice (`caller: "loop"`, input `{text, toast?, to?}`): the text to `to` or the partner; a named toast is a
120 * stop's, owed to Claude's next reply (T13b: one toast per stop, none at the notice). */
121const noticeFx = (req: Request): Effect[] => {
122  const text = inputText(req.input, 'text')
123  if (text.trim() === '') {
124    return [{ do: 'log', kind: 'notice.bad', data: { id: req.id } }]
125  }
126  const cause = CAUSES.find(c => c === inputText(req.input, 'toast'))
127  const fx: Effect[] = [{ do: 'notice', text, to: inputText(req.input, 'to') }]
128  return cause === undefined ? fx : [...fx, { do: 'stop', cause }]
129}
130
131/** 3.3 T7: the agent an external coder's lookup spawns, and its prompt: the question, then the answer's shape. */
132export const RETRIEVE_TYPE = 'retriever-code'
133export const retrievePrompt = (question: string): string => `${question}\n\nAnswer in under 40 lines: file:line facts only.`
134/** What a lookup's answer file holds when no answer came. */
135export const noAnswer = (reason: string): string => `no answer: ${reason}`
136
137/** 3.3 T7: an answer path is a `.md` file inside `<root>/.run/mod/`, forward-slash compared, no `..` segment. */
138export function answerInside(answer: string, root: string): boolean {
139  const fwd = (p: string): string => p.replace(/\\/g, '/').replace(/^([a-z]):/, (_m, d: string) => `${d.toUpperCase()}:`)
140  const a = fwd(answer)
141  const r = fwd(root).replace(/\/+$/, '')
142  return r !== '' && a.endsWith('.md') && !a.split('/').includes('..') && a.startsWith(`${r}/.run/mod/`)
143}
144
145/** 3.3 T7: an external coder's lookup (`pa_ext.py ask`, input `{question, label, answer}`); a bad one is logged. */
146const retrieveFx = (req: Request, root: string): Effect[] => {
147  const question = inputText(req.input, 'question')
148  const answer = inputText(req.input, 'answer')
149  return question.trim() === '' || !answerInside(answer, root)
150    ? [{ do: 'log', kind: 'retrieve.bad', data: { id: req.id, answer } }]
151    : [{ do: 'retrieve', question, label: inputText(req.input, 'label'), answer }]
152}
153
154/** kind -> handler; any other kind goes to `noop`. `ask` raises the toast and queues the picker (Claude's ask takes a
155 * pending stop's place, T13b); `plan` queues path 0. */
156export const HANDLERS: Readonly<Record<string, Handler>> = {
157  topic: req => [...noop(req), { do: 'topic', agentId: req.caller }],
158  ask: req => [...noop(req), { do: 'toast', cause: 'input', body: inputText(req.input, 'question') },
159    ...(req.caller === 'loop' ? [] : [{ do: 'stop', cause: null } as const]), ...presentFx(req)],
160  plan: req => [...noop(req), ...presentFx(req)],
161  notice: req => [...noop(req), ...noticeFx(req)],
162  // 3.23 T13d.c4: a toast raised at once, no Claude turn (the generation end's closed line; input `{cause, body}`).
163  toast: req => [...noop(req), { do: 'toast', cause: CAUSES.find(c => c === inputText(req.input, 'cause')) ?? 'input', body: inputText(req.input, 'body') }],
164  // 3.24 T13E: a card of the loop's (input `{text}`, a cardText; the review release's "✓ Expert · T<n> · accepted").
165  card: req => [...noop(req), ...(inputText(req.input, 'text').trim() === ''
166    ? [{ do: 'log', kind: 'card.bad', data: { id: req.id } } as const]
167    : [{ do: 'card', text: inputText(req.input, 'text') } as const])],
168  retrieve: (req, root) => [...noop(req), ...retrieveFx(req, root)],
169}
170
171/** A loop notice request: its file name and body, in the format `parseRequest` reads. */
172export function noticeRequest(ms: number, seq: number, text: string, toast: ToastOut['cause'] | '' = '', to = ''):
173  { name: string; body: string } {
174  const name = requestName(ms, seq, 'notice')
175  const input: Record<string, string> = { text }
176  if (toast !== '') {
177    input['toast'] = toast
178  }
179  if (to !== '') {
180    input['to'] = to
181  }
182  return { name, body: JSON.stringify({ id: name.slice(0, -'.json'.length), kind: 'notice', caller: 'loop', input, at: ms }, null, 1) }
183}
184
185/** The router's `forward` request (3.23 T5d2a): a forward of `child`'s hand-back to the loop expert `agentId` is queued. */
186export function forwardRequest(ms: number, seq: number, agentId: string, child: string): { name: string; body: string } {
187  const name = requestName(ms, seq, 'forward')
188  return { name, body: JSON.stringify({ id: name.slice(0, -'.json'.length), kind: 'forward', caller: 'router', input: { agentId, child }, at: ms }, null, 1) }
189}
190
191/** Any request file (3.23 T6a1: the router's `install` and `answer`, the loop's `ask` with caller `loop`). */
192export function requestFile(ms: number, seq: number, kind: string, caller: string, input: unknown): { name: string; body: string } {
193  const name = requestName(ms, seq, kind)
194  return { name, body: JSON.stringify({ id: name.slice(0, -'.json'.length), kind, caller, input, at: ms }, null, 1) }
195}
196
197export function handle(req: Request, root = ''): Effect[] {
198  return (HANDLERS[req.kind] ?? noop)(req, root)
199}
200
hooks/journal.ts 251 lines
1// Pure helpers of the journal (design.md "The journal"): entry paths, entry frontmatter and body, and the monthly
2// index. One Markdown file per entry under `.claude-state/journal/<YYYY-MM-DD>/`; the watcher (`mod/pa-watch`) copies
3// the naming and pins it with the same test vectors.
4
5export const JOURNAL_DIR = '.claude-state/journal'
6
7export const WRITERS = ['r', 'w'] as const
8export const WHOS = ['dev', 'claude', 'partner', 'thinker', 'coder', 'critic', 'mod'] as const // plus T<n>; `partner`: entries before 3.22 T8f
9export const KINDS = ['msg', 'answer', 'thinking', 'start', 'end', 'prompt', 'hold', 'release', 'plan', 'decision', 'note', 'steer', 'interim', 'recover', 'repeat', 'context', 'close', 'feed', 'card', 'shell'] as const // note: the watcher's approach notes, steer: mod/steer (3.23 T5c), interim: T<n>/interim (T5c.c3), recover: mod/recover (T5d1), repeat: T<n>/repeat (3.23 T6a2), context: mod/context (T6a2), close: mod/close (3.23 T6b), feed: mod/feed (3.23 T13d.c2: the feed could not start), card: mod/card (3.24 T13E: a card the loop drew, the review release's accepted card), shell: T<n>/shell (3.28.1 T15: a task's agent handed back to wait on its background shell)
10
11export type Writer = (typeof WRITERS)[number]
12export type Kind = (typeof KINDS)[number]
13
14export function isWho(who: string): boolean {
15  return (WHOS as readonly string[]).includes(who) || /^T[0-9]+$/.test(who)
16}
17
18export function isKind(kind: string): boolean {
19  return (KINDS as readonly string[]).includes(kind)
20}
21
22/** `.claude-state/journal/<date>/<HHMMSS>-<seq><writer>-<who>-<kind>.md`; throws on any unknown or malformed part. */
23export function entryPath(date: string, hhmmss: string, seq: number, writer: string, who: string, kind: string): string {
24  if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(date)) throw new Error(`journal: bad date ${JSON.stringify(date)}`)
25  if (!/^[0-9]{6}$/.test(hhmmss)) throw new Error(`journal: bad time ${JSON.stringify(hhmmss)}`)
26  if (!Number.isInteger(seq) || seq < 0 || seq > 999) throw new Error(`journal: bad seq ${String(seq)}`)
27  if (!(WRITERS as readonly string[]).includes(writer)) throw new Error(`journal: bad writer ${JSON.stringify(writer)}`)
28  if (!isWho(who)) throw new Error(`journal: bad who ${JSON.stringify(who)}`)
29  if (!isKind(kind)) throw new Error(`journal: bad kind ${JSON.stringify(kind)}`)
30  return `${JOURNAL_DIR}/${date}/${hhmmss}-${String(seq).padStart(3, '0')}${writer}-${who}-${kind}.md`
31}
32
33export type EntryName = { date: string; time: string; seq: number; writer: Writer; who: string; kind: Kind }
34
35/**
36 * The parts of an entry path (or its tail from the date folder on), or null when it is not one. Both forms: the live
37 * `<date>/<stem>.md` and the rolled `<date>.md#<stem>` (tools/journal_rollup.py, 3.23 T7).
38 */
39export function parseEntryPath(path: string): EntryName | null {
40  const m = /(?:^|\/)([0-9]{4}-[0-9]{2}-[0-9]{2})(?:\/|\.md#)([0-9]{6})-([0-9]{3})([rw])-(T[0-9]+|[a-z]+)-([a-z]+)(\.md)?$/.exec(path)
41  if (!m || !isWho(m[5]) || !isKind(m[6]) || (m[7] === undefined) !== path.includes('.md#')) return null
42  return { date: m[1], time: m[2], seq: Number(m[3]), writer: m[4] as Writer, who: m[5], kind: m[6] as Kind }
43}
44
45/** A rolled day's file `.claude-state/journal/<date>.md` (tools/journal_rollup.py). */
46export function dayFilePath(date: string): string {
47  return `${JOURNAL_DIR}/${date}.md`
48}
49
50const STEM = /^[0-9]{6}-[0-9]{3}[rw]-(?:T[0-9]+|[a-z]+)-[a-z]+$/
51
52/**
53 * The entries of a rolled day file (journal_rollup.py split_day): each `## <stem>` heading starts one, its text the
54 * entry file unchanged; paths `.claude-state/journal/<date>.md#<stem>`.
55 */
56export function splitDay(date: string, text: string): Entry[] {
57  const out: Entry[] = []
58  let stem = ''
59  let buf: string[] = []
60  const flush = (): void => {
61    if (stem === '') return
62    const t = buf.join('\n')
63    out.push({ path: `${dayFilePath(date)}#${stem}`, ...(parseEntry(t) ?? { meta: {}, body: t }) })
64  }
65  for (const line of text.replace(/\r\n/g, '\n').split('\n')) {
66    const m = /^## (\S+)$/.exec(line)
67    if (m && STEM.test(m[1])) {
68      flush()
69      stem = m[1]
70      buf = []
71    } else if (stem !== '') buf.push(line)
72  }
73  flush()
74  return out
75}
76
77/** `.claude-state/journal/INDEX-<YYYY-MM>.md`. */
78export function indexPath(month: string): string {
79  if (!/^[0-9]{4}-[0-9]{2}$/.test(month)) throw new Error(`journal: bad month ${JSON.stringify(month)}`)
80  return `${JOURNAL_DIR}/INDEX-${month}.md`
81}
82
83export type Usage = { input: number; cacheRead: number; cacheWrite: number; output: number }
84
85export type Meta = {
86  ts?: string
87  session?: string
88  agentId?: string
89  parentId?: string
90  role?: string
91  model?: string
92  effort?: string
93  turnId?: string
94  task?: string
95  agentType?: string // 3.24 T17b
96  usage?: Usage
97}
98
99export type Entry = { path: string; meta: Meta; body: string }
100
101const STRING_KEYS = ['ts', 'session', 'agentId', 'parentId', 'role', 'model', 'effort', 'turnId', 'task', 'agentType'] as const // agentType: the watcher's `spawn` entries (3.24 T17b; doctor reads them)
102const USAGE_KEYS = ['input', 'cacheRead', 'cacheWrite', 'output'] as const
103
104// Plain when it cannot be misread; otherwise a JSON string, which is also a YAML double-quoted scalar on one line.
105function scalar(value: string): string {
106  return /^[A-Za-z0-9][A-Za-z0-9_.\-/+@:[\]]*$/.test(value) && !value.endsWith(':') ? value : JSON.stringify(value)
107}
108
109function unscalar(raw: string): string | null {
110  if (!raw.startsWith('"')) return raw
111  try {
112    const v: unknown = JSON.parse(raw)
113    return typeof v === 'string' ? v : null
114  } catch {
115    return null
116  }
117}
118
119/** `---`, one `key: value` line per present key, `---`, a blank line, the body. */
120export function renderEntry(meta: Meta, body: string): string {
121  const lines = ['---']
122  for (const k of STRING_KEYS) {
123    const v = meta[k]
124    if (typeof v === 'string') lines.push(`${k}: ${scalar(v)}`)
125  }
126  const u = meta.usage
127  if (u) lines.push(`usage: {${USAGE_KEYS.map((k) => `${k}: ${String(Number(u[k]) || 0)}`).join(', ')}}`)
128  lines.push('---', '', body.replace(/\r\n/g, '\n').replace(/\n*$/, ''), '')
129  return lines.join('\n')
130}
131
132/** The frontmatter and body of an entry file, or null when it has no frontmatter. Unknown keys are ignored. */
133export function parseEntry(text: string): { meta: Meta; body: string } | null {
134  const lines = text.replace(/\r\n/g, '\n').split('\n')
135  if (lines[0] !== '---') return null
136  const end = lines.indexOf('---', 1)
137  if (end < 0) return null
138  const meta: Meta = {}
139  for (const line of lines.slice(1, end)) {
140    const m = /^([A-Za-z]+): (.*)$/.exec(line)
141    if (!m) continue
142    const [, key, raw] = m
143    if (key === 'usage') {
144      const u: Partial<Usage> = {}
145      for (const part of raw.replace(/^\{|\}$/g, '').split(',')) {
146        const p = /^\s*([A-Za-z]+): (-?[0-9.]+)\s*$/.exec(part)
147        if (p && (USAGE_KEYS as readonly string[]).includes(p[1]) && Number.isFinite(Number(p[2]))) {
148          u[p[1] as keyof Usage] = Number(p[2])
149        }
150      }
151      if (USAGE_KEYS.every((k) => typeof u[k] === 'number')) meta.usage = u as Usage
152    } else if ((STRING_KEYS as readonly string[]).includes(key)) {
153      const v = unscalar(raw)
154      if (v !== null) meta[key as (typeof STRING_KEYS)[number]] = v
155    }
156  }
157  let rest = lines.slice(end + 1)
158  if (rest[0] === '') rest = rest.slice(1)
159  return { meta, body: rest.join('\n').replace(/\n*$/, '') }
160}
161
162/** The first non-blank line of a body, trimmed. */
163export function firstLine(body: string): string {
164  return (
165    body
166      .split(/\r?\n/)
167      .map((l) => l.trim())
168      .find((l) => l !== '') ?? ''
169  )
170}
171
172export type IndexLine = { ts: string; who: string; kind: string; first: string; path: string }
173
174function escapeCell(text: string): string {
175  return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|')
176}
177
178function unescapeCell(text: string): string {
179  return text.replace(/\\([\\|])/g, '$1')
180}
181
182function byTsThenPath(a: { ts: string; path: string }, b: { ts: string; path: string }): number {
183  if (a.ts !== b.ts) return a.ts < b.ts ? -1 : 1
184  return a.path < b.path ? -1 : a.path > b.path ? 1 : 0
185}
186
187/** `ts | who | kind | first line (≤ 80 chars, pipes escaped) | path`, one line per entry, sorted by ts then path. */
188export function buildIndex(entries: Entry[]): string {
189  const rows = entries.map((e) => {
190    const name = parseEntryPath(e.path)
191    return {
192      ts: e.meta.ts ?? '',
193      who: name?.who ?? '?',
194      kind: name?.kind ?? '?',
195      first: firstLine(e.body).slice(0, 80),
196      path: e.path,
197    }
198  })
199  rows.sort(byTsThenPath)
200  return rows.map((r) => `${r.ts} | ${r.who} | ${r.kind} | ${escapeCell(r.first)} | ${r.path}\n`).join('')
201}
202
203/** The lines of an index; lines that are not index lines are skipped. */
204export function parseIndex(text: string): IndexLine[] {
205  const out: IndexLine[] = []
206  for (const line of text.replace(/\r\n/g, '\n').split('\n')) {
207    const m = /^([^|]*) \| ([^|]*) \| ([^|]*) \| (.*) \| ([^|]+)$/.exec(line)
208    if (!m) continue
209    out.push({ ts: m[1], who: m[2], kind: m[3], first: unescapeCell(m[4]), path: m[5] })
210  }
211  return out
212}
213
214/** True only when the index lists exactly these paths, each once. */
215export function indexAgrees(indexText: string, paths: string[]): boolean {
216  const listed = parseIndex(indexText).map((l) => l.path)
217  const want = new Set(paths)
218  if (listed.length !== want.size || new Set(listed).size !== listed.length) return false
219  return listed.every((p) => want.has(p))
220}
221
222const two = (n: number): string => String(n).padStart(2, '0')
223
224/** `[YYYY-MM-DD, HHMMSS]` of `ms` in local time, as the watcher stamps its entries. */
225export function stamp(ms: number): [string, string] {
226  const d = new Date(ms)
227  return [
228    `${String(d.getFullYear())}-${two(d.getMonth() + 1)}-${two(d.getDate())}`,
229    `${two(d.getHours())}${two(d.getMinutes())}${two(d.getSeconds())}`,
230  ]
231}
232
233/** The paths an index does not list yet: the entries to read before `mergeIndex`. */
234export function unindexed(indexText: string, paths: string[]): string[] {
235  const listed = new Set(parseIndex(indexText).map((l) => l.path))
236  return paths.filter((p) => !listed.has(p))
237}
238
239/** The index of exactly `paths`: an old line is kept for a path it lists, `read` supplies the rest. */
240export function mergeIndex(indexText: string, paths: string[], read: Entry[]): string {
241  const want = new Set(paths)
242  const byPath = new Map<string, Entry>()
243  for (const l of parseIndex(indexText)) {
244    if (want.has(l.path)) byPath.set(l.path, { path: l.path, meta: { ts: l.ts }, body: l.first })
245  }
246  for (const e of read) {
247    if (want.has(e.path)) byPath.set(e.path, e)
248  }
249  return buildIndex([...byPath.values()])
250}
251
hooks/digest.ts 134 lines
1// The running-now note (3.23-autopilot.md, "Visible and steerable"): while work runs, each developer message reaches
2// Claude with the running task, its expert and coders, and each one's latest approach note, from the watcher's facts
3// files (`.run/mod/<sid>/agents/<id>.json`, extended). Pure, no `$`. 3.23 T13d.c2: no tool steps (the feed block on the
4// message carries what the agents said).
5import type { AgentFacts, RunningAgent } from '../types'
6
7export const DIGEST_CAP = 800
8const NOTE_LINES = 2
9
10const oneLine = (s: string, n: number): string => {
11  const t = s.replace(/\s+/g, ' ').trim()
12  return t.length > n ? `${t.slice(0, n - 1)}…` : t
13}
14
15/** The `‹pa› running now:` block: the task, then the expert and each coder (id, status, note ≤ 2 lines); ≤ ~800 chars. */
16export function runningNow(task: { id: string; title: string }, agents: RunningAgent[]): string {
17  const out = [`‹pa› running now: ${task.id} ${task.title}`.trim()]
18  const ordered = [...agents.filter(a => a.role === 'expert'), ...agents.filter(a => a.role !== 'expert')]
19  for (const a of ordered) {
20    out.push(`- ${a.role} ${a.id}${a.model === '' ? '' : ` (${a.model})`}${(a.state ?? '') === '' ? '' : `: ${a.state ?? ''}`}`)
21    const note = (a.note ?? '').split(/\r?\n/).map(l => l.trim()).filter(l => l !== '').slice(0, NOTE_LINES)
22    for (const l of note) {
23      out.push(`  note: ${oneLine(l, 200)}`)
24    }
25  }
26  const text = out.join('\n')
27  return text.length > DIGEST_CAP ? `${text.slice(0, DIGEST_CAP - 1)}…` : text
28}
29
30/** 3.24 T17p: the note while the loop is on and no task runs: no task, the last one done and when, the next, the holds. */
31export function idleNow(last: { id: string; hm: string } | null, next: string, holds: readonly string[]): string {
32  const parts = ['‹pa› running now: no task']
33  if (last !== null) {
34    parts.push(`${last.id} done${last.hm === '' ? '' : ` at ${last.hm}`}`)
35  }
36  parts.push(`next: ${next === '' ? 'none listed' : next}`)
37  if (holds.length > 0) {
38    parts.push(`waiting on: ${holds.join(', ')}`)
39  }
40  return parts.join(' · ')
41}
42
43/** One facts file's text as AgentFacts, or null when it does not parse. */
44function factsOf(text: string): AgentFacts | null {
45  try {
46    const v: unknown = JSON.parse(text)
47    return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as AgentFacts) : null
48  } catch {
49    return null
50  }
51}
52
53/** The running task's agents (T5c): its expert, then its descendants by the facts' `parentId`, from `[agentId, file text]`. */
54export function taskAgents(expertId: string, files: readonly [string, string][]): RunningAgent[] {
55  const all = new Map<string, AgentFacts>()
56  for (const [id, text] of files) {
57    const f = factsOf(text)
58    if (f !== null) {
59      all.set(id, f)
60    }
61  }
62  const out: RunningAgent[] = []
63  const ex = all.get(expertId)
64  out.push({ model: '', effort: '', ctx: 0, at: 0, ...ex, id: expertId, role: 'expert' })
65  const ids = new Set([expertId])
66  for (let grew = true; grew;) {
67    grew = false
68    for (const [id, f] of [...all].sort((a, b) => a[0].localeCompare(b[0]))) {
69      if (!ids.has(id) && f.parentId !== undefined && ids.has(f.parentId)) {
70        ids.add(id)
71        out.push({ ...f, id, role: 'coder' })
72        grew = true
73      }
74    }
75  }
76  return out
77}
78
79/** A journal entry the recovery digest keeps (T5d1): a `note` or `thinking` entry of the task's agents, not Claude's or a thinker's;
80 * 3.28.1 T12: and the expert's earlier reports (`T<n>-interim`). */
81export const isProgressEntry = (name: string): boolean =>
82  /-(note|thinking|interim)\.md$/.test(name) && !/-(claude|thinker)-(note|thinking)\.md$/.test(name)
83
84const ENTRY_CHARS = 1_500
85const ENTRIES = 30
86const THINKING = 10 // 3.28.1 T12: reasoning kept apart (TerrainDiffusion: 281 coder-thinking a day crowded out the notes)
87
88/**
89 * TASK_PROGRESS.md written by the mod at recovery (3.23 T5d1) when the hook's kill digest is absent: `git status
90 * --short`, `git log --oneline <startSha>..HEAD`, what it waits on (3.28.1 T12), and the task's note and report entries
91 * (`[name, text]`, their frontmatter `ts` at or after `since`), oldest first, the last 30, then its thinking, the last 10,
92 * each at most 1,500 chars.
93 */
94export function recoveryDigest(o: {
95  id: string; attempt: number; startSha: string; since: number; status: string; log: string; entries: readonly [string, string][]
96  waiting?: readonly string[]
97}): string {
98  const kept: string[] = []
99  const thought: string[] = []
100  for (const [name, text] of [...o.entries].sort((a, b) => a[0].localeCompare(b[0]))) {
101    const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
102    const ts = Date.parse(/^ts: *(.+)$/m.exec(m?.[1] ?? '')?.[1] ?? '')
103    if (!isProgressEntry(name) || !Number.isFinite(ts) || ts < o.since) {
104      continue
105    }
106    const body = (m?.[2] ?? '').trim()
107    const to = name.endsWith('-thinking.md') ? thought : kept
108    to.push(`### ${name}\n${body.length > ENTRY_CHARS ? `${body.slice(0, ENTRY_CHARS - 1)}…` : body}`)
109  }
110  const block = (s: string): string => `\`\`\`\n${s.trim() === '' ? '(none)' : s.replace(/^(\s*\n)+/, '').trimEnd()}\n\`\`\``
111  return [
112    `# TASK_PROGRESS: recovery digest for ${o.id} (attempt ${String(o.attempt)})`,
113    '',
114    'Written by the mod at a session restart: the expert died with its session and left no progress file. ' +
115      'This is the state it left; check the tree, then continue from its last step.',
116    '',
117    '## git status --short',
118    block(o.status),
119    '',
120    `## git log --oneline ${o.startSha || '?'}..HEAD`,
121    block(o.log),
122    '',
123    '## Waiting on',
124    (o.waiting ?? []).length === 0 ? '(nothing recorded)' : (o.waiting ?? []).map(x => `- ${x}`).join('\n'),
125    '',
126    `## Journal notes and reports since ${new Date(o.since).toISOString()}`,
127    kept.length === 0 ? '(none)' : kept.slice(-ENTRIES).join('\n\n'),
128    '',
129    `## Reasoning, the last ${String(THINKING)}`,
130    thought.length === 0 ? '(none)' : thought.slice(-THINKING).join('\n\n'),
131    '',
132  ].join('\n')
133}
134
hooks/feed.ts 508 lines
1// The router's live feed (3.23 T13d.c1b; brief .run/t13d-c1.md, "The feed contract"): pure parts. The watcher drops each
2// agent utterance into `<feed.dir>/in/`; the feed script (mod/pa/feed/pa_feed.py) numbers it into `log.jsonl` and prints
3// it; Claude Code delivers each printed batch as a `task-notification` turn ("pa feed"). The router collects the records
4// at prompt.submit (each queued notification passes there on its own), fills any gap in the numbering from the log, and
5// answers the next feed turn with every pending record: a bold title with the role's square when the speaker changes,
6// then each line after an em space. register.tsx spells the `$` calls.
7import { borderOf, type CardRole, type CardStyle, claudeCard, parseCard, ROLE_HEX, type Sender, SQUARE } from './card'
8import { parentOf, xmlText } from './route'
9import type { Child, FeedEdit, FeedLife, FeedPending, FeedRecord, FeedRun, LoopTask } from '../types'
10
11export type { FeedDrawn, FeedEdit, FeedLife, FeedPending, FeedRecord, FeedRun } from '../types'
12
13/** What one `<event>` line holds: a record, a nudge, the launcher's error (3.23 T13d.c2: the feed cannot start), a
14 * record's line Claude Code cut ("…(truncated)", 3.23 T13d.c6a: its number, read from the log at once), or Claude Code's
15 * own bracketed line (housekeeping). */
16export type FeedEvent =
17  | { kind: 'record'; rec: FeedRecord }
18  | { kind: 'nudge' }
19  | { kind: 'error'; error: string }
20  | { kind: 'cut'; n: number }
21  | { kind: 'note'; line: string }
22
23export const emptyFeed = (): FeedPending => ({ pending: [], drawn: null, speaker: '' })
24
25/** A nudge file's body: the feed script prints it as one event, so a feed turn follows and draws what is pending. */
26export const NUDGE = '{"nudge":true}\n'
27
28/** A nudge file's name in `in/`: `<13-digit ms>-9<5-digit seq>.json` (the 9 keeps the router's names apart from the
29 * watcher's at the same ms; the names still sort by time). */
30export function nudgeName(ms: number, seq: number, lead = '9'): string {
31  return `${String(Math.max(0, Math.floor(ms))).padStart(13, '0')}-${lead}${String(seq % 100_000).padStart(5, '0')}.json`
32}
33
34/** 3.23 T13d.c3: the start announcement, one feed record the router writes when the loop spawns a task's expert (its id
35 * and type, so the label is `Expert · T<n>`): `Starting <id>: <title>`. Zero tokens. Named `nudgeName(ms, seq, '8')`. */
36export function startRecord(o: { agent: string; type: string; id: string; title: string; at: number }): string {
37  const text = `Starting ${o.id}${o.title === '' ? '' : `: ${o.title}`}`
38  return `${JSON.stringify({ agent: o.agent, type: o.type, desc: `${o.id} ${o.title}`.trim(), planning: false, text, at: o.at })}\n`
39}
40
41/** 3.25 T2: the close's closed line as the mod's own record (agent `mod`, type `pa`, so the label is `pa`); no agent closes. */
42export function modRecord(o: { text: string; at: number }): string {
43  return `${JSON.stringify({ agent: 'mod', type: 'pa', desc: '', planning: false, text: o.text, at: o.at })}\n`
44}
45
46/** 3.24 T13B (decision 3): a reply of Claude's that came with no hand-back, as a feed record the router writes (named
47 * `nudgeName(ms, seq, '7')`) instead of the `‹pa reply <n>›` prompt, whose "Prompt from the pa plugin" row no render hook
48 * hides; a feed notification's row is hidden, as every feed turn's. `reply` is the marker's number (`state.markers`: the
49 * reply's journal file); type '' (the partner's: never in a developer message's feed block). Zero tokens.
50 * 3.24 T17g: `shown`, the reply's model and effort (shownOf), for its card's title; omitted when ''. */
51export function replyRecord(o: { agent: string; n: number; text: string; at: number; shown?: string }): string {
52  const shown = o.shown === undefined || o.shown === '' ? {} : { shown: o.shown }
53  return `${JSON.stringify({ agent: o.agent, type: '', desc: 'Claude', planning: false, text: o.text, at: o.at, reply: o.n, ...shown })}\n`
54}
55
56const str = (x: unknown): string => (typeof x === 'string' ? x : '')
57const count = (x: unknown): number => (typeof x === 'number' && Number.isInteger(x) && x >= 0 ? x : 0)
58
59/** 3.24 T8b: a record's `edit` (the watcher's edit card, mod/pa-watch/hooks/edit.ts), undefined when absent or not one. */
60export function editOfRecord(x: unknown): FeedEdit | undefined {
61  if (typeof x !== 'object' || x === null || Array.isArray(x)) {
62    return undefined
63  }
64  const e = x as Record<string, unknown>
65  if (str(e['path']) === '') {
66    return undefined
67  }
68  const hunks = Array.isArray(e['hunks']) ? e['hunks'].filter((h): h is string => typeof h === 'string') : []
69  const label = str(e['label'])
70  return { path: str(e['path']), created: e['created'] === true, add: count(e['add']), del: count(e['del']), hunks, more: count(e['more']), ...(label === '' ? {} : { label }) }
71}
72
73/** 3.24 T8b: an edit card's header, native: `Updated <path>` and ` (+N -M)`, or `Created <path>` and ` (N lines)`; 3.24
74 * T13B: the record's `label` in place of the verb and path (a plan draft: `Plan drafted (58 lines)`). */
75export function editHead(e: FeedEdit): { verb: string; counts: string } {
76  return e.created
77    ? { verb: e.label ?? `Created ${e.path}`, counts: ` (${e.add} ${e.add === 1 ? 'line' : 'lines'})` }
78    : { verb: e.label ?? `Updated ${e.path}`, counts: ` (+${e.add} -${e.del})` }
79}
80
81/** 3.24 T8b: the stored text of an edit card (the phone): the header line, then a ```diff fence of the hunks (a created
82 * file's lines as `+` lines), then `… +N lines` when rows were left out. 3.24 T13p: every line quoted (`quote`), the
83 * fence inside the quote. */
84export function editText(e: FeedEdit): string {
85  const h = editHead(e)
86  const body = e.hunks.flatMap(b => b.split('\n').map(l => (e.created ? `+${l}` : l)))
87  const out = [`${h.verb}${h.counts}`]
88  if (body.length > 0) {
89    out.push('```diff', ...body, '```')
90  }
91  if (e.more > 0) {
92    out.push(`… +${e.more} lines`)
93  }
94  return out.map(quote).join('\n')
95}
96
97/** A record of one JSON line (an event line or a `log.jsonl` line), null when it is not one. */
98export function parseFeedLine(line: string): FeedRecord | null {
99  let o: unknown
100  try {
101    o = JSON.parse(line)
102  } catch {
103    return null
104  }
105  if (typeof o !== 'object' || o === null || Array.isArray(o)) {
106    return null
107  }
108  const r = o as Record<string, unknown>
109  const n = r['n']
110  if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || typeof r['text'] !== 'string') {
111    return null
112  }
113  const edit = editOfRecord(r['edit'])
114  const reply = r['reply']
115  return {
116    n, agent: str(r['agent']), type: str(r['type']), desc: str(r['desc']), planning: r['planning'] === true, text: r['text'],
117    at: typeof r['at'] === 'number' ? r['at'] : 0, ...(edit === undefined ? {} : { edit }),
118    ...(typeof reply === 'number' && Number.isInteger(reply) && reply >= 1 ? { reply } : {}),
119    ...(typeof r['shown'] === 'string' && r['shown'] !== '' ? { shown: r['shown'] } : {}), // 3.24 T17g
120  }
121}
122
123/** The events of a feed notification: every line inside its `<event>` blocks, blank lines skipped; 3.24 T13r: the block
124 * unescaped once (route.ts xmlText: Claude Code escapes `&`, `<`, `>` in it), so a record's text is the agent's own. */
125export function feedEvents(text: string): FeedEvent[] {
126  const out: FeedEvent[] = []
127  for (const m of text.matchAll(/<event>([\s\S]*?)<\/event>/g)) {
128    for (const raw of xmlText(m[1] ?? '').split('\n')) {
129      const line = raw.trim()
130      if (line === '') {
131        continue
132      }
133      if (line.startsWith('{')) {
134        let o: unknown = null
135        try {
136          o = JSON.parse(line)
137        } catch {
138          o = null
139        }
140        const r = typeof o === 'object' && o !== null ? (o as Record<string, unknown>) : null
141        if (r !== null && r['pa'] === 'feed' && r['nudge'] === true) {
142          out.push({ kind: 'nudge' })
143          continue
144        }
145        if (r !== null && r['pa'] === 'feed' && typeof r['error'] === 'string') {
146          out.push({ kind: 'error', error: r['error'] })
147          continue
148        }
149        const rec = r !== null && r['pa'] === 'feed' ? parseFeedLine(line) : null
150        if (rec !== null) {
151          out.push({ kind: 'record', rec })
152          continue
153        }
154        const cut = o === null ? /^\{\s*"pa"\s*:\s*"feed"\s*,\s*"n"\s*:\s*([0-9]+)/.exec(line) : null
155        if (cut !== null && Number(cut[1]) >= 1) {
156          out.push({ kind: 'cut', n: Number(cut[1]) })
157          continue
158        }
159      }
160      out.push({ kind: 'note', line })
161    }
162  }
163  return out
164}
165
166/** The atom with `recs` added: a number drawn already or pending already is dropped; pending stays in `n` order. */
167export function addRecords(f: FeedPending, recs: readonly FeedRecord[]): FeedPending {
168  const have = new Set(f.pending.map(r => r.n))
169  const add: FeedRecord[] = []
170  for (const r of recs) {
171    if ((f.drawn !== null && r.n <= f.drawn) || have.has(r.n)) {
172      continue
173    }
174    have.add(r.n)
175    add.push(r)
176  }
177  return add.length === 0 ? f : { ...f, pending: [...f.pending, ...add].sort((a, b) => a.n - b.n) }
178}
179
180/** Numbers skipped below the newest pending one: from the last drawn + 1 (before any draw this load, from the oldest
181 * pending, so a reload never redraws the log), each not pending. */
182export function missingNs(f: FeedPending): number[] {
183  const top = f.pending[f.pending.length - 1]?.n
184  if (top === undefined) {
185    return []
186  }
187  const from = f.drawn === null ? (f.pending[0]?.n ?? top) : f.drawn + 1
188  const have = new Set(f.pending.map(r => r.n))
189  const out: number[] = []
190  for (let n = from; n < top; n += 1) {
191    if (!have.has(n)) {
192      out.push(n)
193    }
194  }
195  return out
196}
197
198/** The records of `log.jsonl` whose numbers are in `ns`. */
199export function fromLog(text: string, ns: readonly number[]): FeedRecord[] {
200  const want = new Set(ns)
201  const out: FeedRecord[] = []
202  for (const line of text.split('\n')) {
203    const r = line.trim() === '' ? null : parseFeedLine(line.trim())
204    if (r !== null && want.has(r.n)) {
205      want.delete(r.n)
206      out.push(r)
207    }
208  }
209  return out
210}
211
212/** The agents `$.agent.list()` gives, as the sender logic reads them. */
213type Listed = { id: string; type?: string; parentId?: string }
214
215/**
216 * Who an agent is to the loop (the cards' sender logic, shared with the feed): the loop task's expert, a coder under it
217 * (any depth, a `coder*` or `pa-coder` type), a thinker (`pa-thinker`, or an old `thinker`/`deep-thinker`), else other. `coder` is the coder's
218 * `c<k>` the card text names ('' none).
219 */
220export function senderOf(id: string, lt: LoopTask | null, children: Record<string, Child>, list: readonly Listed[], coder = ''): Sender {
221  const typeOf = (x: string): string => children[x]?.type ?? list.find(a => a.id === x)?.type ?? ''
222  const sender: Sender = { role: 'other', task: lt?.id ?? '', coder }
223  if (lt !== null && lt.agentId !== '' && id === lt.agentId) {
224    sender.role = 'expert'
225    sender.task = lt.id
226  } else if (lt !== null && lt.agentId !== '') {
227    let p = parentOf(children, id, list)
228    for (let hop = 0; p !== null && p !== lt.agentId && hop < 8; hop += 1) {
229      p = parentOf(children, p, list)
230    }
231    sender.role = p === lt.agentId && /^(pa-)?coder/.test(typeOf(id)) ? 'coder' : 'other'
232  }
233  if (sender.role === 'other' && ['pa-thinker', 'thinker', 'deep-thinker'].includes(typeOf(id))) { // 3.25 T6b: one agent per role
234    sender.role = 'thinker'
235  }
236  return sender
237}
238
239/** A record's speaker: its card role (null: no square) and label. */
240export type Speaker = { role: CardRole | null; label: string }
241
242const titled = (name: string, task: string): string => (task === '' ? name : `${name} · ${task}`)
243
244/** The label of a record's agent: Claude or Planning (the partner, by id or by the watcher's '' type), Planning (the
245 * planners, `pa-planner` included), the loop's expert or coder, Thinker, Curator or Auditor (3.24 T13B), else the agent's type. */
246export function speakerOf(rec: FeedRecord, partnerId: string, sender: Sender): Speaker {
247  if ((partnerId !== '' && rec.agent === partnerId) || rec.type === '') {
248    return rec.planning ? { role: 'planning', label: 'Planning' } : { role: 'claude', label: 'Claude' }
249  }
250  if (rec.type === 'planner-gen' || rec.type === 'planner-phase' || rec.type === 'pa-planner') { // 3.24 T17b: pa-planner
251    return { role: 'planning', label: 'Planning' }
252  }
253  if (rec.type === 'ext-coder') { // 3.3 T7: an external coder's record (tools/pa_ext.py), titled by its brief's id
254    return { role: 'coder', label: rec.desc === '' ? 'External coder' : `External coder · ${rec.desc}` }
255  }
256  if (sender.role === 'expert') {
257    return { role: 'expert', label: titled('Expert', sender.task) }
258  }
259  if (sender.role === 'coder') {
260    return { role: 'coder', label: titled('Coder', sender.task) }
261  }
262  if (sender.role === 'thinker' || ['pa-thinker', 'thinker', 'deep-thinker'].includes(rec.type)) {
263    return { role: 'thinker', label: 'Thinker' }
264  }
265  if (rec.type === 'memory-curator') {
266    return { role: 'curator', label: 'Curator' }
267  }
268  if (rec.type === 'auditor') {
269    return { role: 'auditor', label: 'Auditor' }
270  }
271  return { role: null, label: rec.type }
272}
273
274/** 3.24 T17b: a model id as the developer reads it: `claude-opus-5-5` → `Opus 5.5` (a date or a `[1m]` tail dropped);
275 * anything else as given. */
276export function modelName(id: string): string {
277  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?:\[[^\]]*\])?$/.exec(id.trim())
278  const fam = m?.[1] ?? ''
279  if (m === null || fam === '') {
280    return id.trim()
281  }
282  return `${fam.charAt(0).toUpperCase()}${fam.slice(1)} ${m[2] ?? ''}${m[3] === undefined ? '' : `.${m[3]}`}`
283}
284
285/** 3.24 T17b: an agent's model and effort as its first titled run shows them (`Opus 5.5 · max`); '' with no model. */
286export function shownOf(model: string, effort: string): string {
287  return model === '' ? '' : effort === '' ? modelName(model) : `${modelName(model)} · ${effort}`
288}
289
290/** 3.24 T17b: whether a record's agent is announced (its model and effort shown once): any agent but Claude (the
291 * partner by id or the watcher's '' type) and Claude's replies. */
292export const announces = (rec: FeedRecord, partnerId: string): boolean =>
293  rec.reply === undefined && rec.type !== '' && rec.agent !== '' && !(partnerId !== '' && rec.agent === partnerId)
294
295/** 3.24 T13p: a stored feed line as a blockquote line (`> <line>`, a bare `>` for a blank line), so the phone renders the
296 * run's markdown. Was `INDENT`, an em space (U+2003) before each line: CommonMark does not take it as indentation, so
297 * headings, lists and fences stayed literal on the phone. */
298export const quote = (line: string): string => (line === '' ? '>' : `> ${line}`)
299
300/**
301 * The stored text of a feed turn (what the phone shows): per run of one speaker, the title `**<square> <label>**` only
302 * when the speaker differs from `last`, then the run as a blockquote (3.24 T13p: `quote` on every line, a bare `>`
303 * between two records, a blank line before each run so the last quote ends). Returns the text, the
304 * last speaker's key and the runs (3.23 T13d.c2: what the terminal draws; a first run continuing `last` is untitled).
305 * 3.24 T8b: an edit record is a run of its own (untitled when it continues the speaker), stored as editText.
306 * 3.24 T13B: a reply record is a run of its own, always titled, `card` set, stored as its Claude card (claudeCard); the
307 * record after it is titled again. 3.24 T13E: a reply record whose text is a card already (the loop's accepted card) is
308 * stored and drawn as that card, not wrapped in Claude's.
309 * 3.24 T17b: `shown` (by agent id) is added to an agent's title (`Expert · T1 · Opus 5.5 · medium`); `announced` lists
310 * the agents whose title carried it.
311 */
312export function feedText(items: readonly { rec: FeedRecord; who: Speaker }[], last: string, shown: Readonly<Record<string, string>> = {}): { text: string; speaker: string; runs: FeedRun[]; announced: string[] } {
313  const out: string[] = []
314  const runs: FeedRun[] = []
315  const announced: string[] = []
316  let speaker = last
317  for (const { rec, who } of items) {
318    if (rec.reply !== undefined) {
319      if (out.length > 0) {
320        out.push('')
321      }
322      const own = parseCard(rec.text)
323      const mine = rec.shown ?? '' // 3.24 T17g: the reply's model and effort in its title
324      out.push(own === null ? claudeCard(rec.text, mine) : rec.text)
325      const body = own === null ? rec.text : own.body
326      runs.push({ label: own === null ? (mine === '' ? 'Claude' : `Claude · ${mine}`) : own.title, role: own === null ? 'claude' : own.role, titled: true, lines: body === '' ? [] : body.split('\n').map(l => l.replace(/\s+$/, '')), card: true })
327      speaker = `${rec.agent}\treply`
328      continue
329    }
330    const key = `${rec.agent}\t${who.label}`
331    if (key !== speaker) {
332      if (out.length > 0) {
333        out.push('') // 3.24 T13p: else the title is the last quote's lazy continuation
334      }
335      const extra = shown[rec.agent] ?? ''
336      const label = extra === '' || announced.includes(rec.agent) ? who.label : `${who.label} · ${extra}`
337      if (label !== who.label) {
338        announced.push(rec.agent)
339      }
340      out.push(`**${who.role === null ? '' : `${SQUARE[who.role]} `}${label}**`)
341      speaker = key
342      runs.push({ label, role: who.role, titled: true, lines: [] })
343    } else if (runs.length === 0 || rec.edit !== undefined || runs[runs.length - 1]?.edit !== undefined || runs[runs.length - 1]?.card === true) {
344      if (out.length > 0) {
345        out.push('')
346      }
347      runs.push({ label: who.label, role: who.role, titled: false, lines: [] })
348    }
349    if (rec.edit !== undefined) {
350      const run = runs[runs.length - 1]
351      if (run !== undefined) {
352        run.edit = rec.edit
353      }
354      out.push(editText(rec.edit))
355      continue
356    }
357    // 3.24 T13r: a run's records are drawn as one Markdown block, so a blank line between two keeps each its own paragraph.
358    const cur = runs[runs.length - 1]
359    if (cur !== undefined && cur.lines.length > 0) {
360      cur.lines.push('')
361      out.push(quote(''))
362    }
363    for (const line of rec.text.split('\n')) {
364      out.push(quote(line.replace(/\s+$/, '')))
365      cur?.lines.push(line.replace(/\s+$/, ''))
366    }
367  }
368  return { text: out.join('\n'), speaker, runs, announced }
369}
370
371/** How the terminal draws one feed run (3.23 T13d.c2), the cards' look: a note card's border under `style` (null:
372 * plain), the role's hex (none for an unknown type), the title (the bold label, `● <label>` under plain; '' on a
373 * continuation run), the lines. */
374export function feedLook(run: FeedRun, style: CardStyle): { border: 'quote' | 'round' | null; color: string | undefined; title: string; lines: string[]; edit?: FeedEdit; card?: boolean } {
375  const border = borderOf(style, false)
376  const title = !run.titled ? '' : border === null ? `● ${run.label}` : run.label
377  return {
378    border, color: run.role === null ? undefined : ROLE_HEX[run.role], title, lines: run.lines, ...(run.edit === undefined ? {} : { edit: run.edit }),
379    ...(run.card === true ? { card: true } : {}),
380  }
381}
382
383/** The feed records a developer message carries at most (3.23 T13d.c2). */
384export const FEED_BLOCK_MAX = 40
385
386/** The records of `log.jsonl` the developer saw and Claude did not (3.23 T13d.c2): drawn (`n` <= `drawn`; none before
387 * the first draw of this load), after Claude's last answer (`at` > `since`), not the partner's own (by id, or the
388 * watcher's '' type), the latest FEED_BLOCK_MAX in `n` order. */
389export function unseenFeed(logText: string, drawn: number | null, since: number, partnerId: string): FeedRecord[] {
390  if (drawn === null) {
391    return []
392  }
393  const out: FeedRecord[] = []
394  for (const line of logText.split('\n')) {
395    const r = line.trim() === '' ? null : parseFeedLine(line.trim())
396    if (r !== null && r.n <= drawn && r.at > since && r.type !== '' && !(partnerId !== '' && r.agent === partnerId)) {
397      out.push(r)
398    }
399  }
400  return out.sort((a, b) => a.n - b.n).slice(-FEED_BLOCK_MAX)
401}
402
403/** The feed block's cap on one record's text, in characters (3.23 T13d.c7). */
404export const FEED_BLOCK_CAP = 400
405
406/** The feed block on a developer message (3.23 T13d.c2): a first line naming the log, then `<label>: <line>` per
407 * non-blank text line; '' when there is nothing. */
408export function feedBlock(items: readonly { rec: FeedRecord; who: Speaker }[], dir: string): string {
409  // 3.23 T13d.c7: a size guard for Claude's input: each record's text cut at FEED_BLOCK_CAP chars (`…` when cut); the
410  // screen shows statements whole.
411  const cut = (t: string): string => (t.length > FEED_BLOCK_CAP ? `${t.slice(0, FEED_BLOCK_CAP)}…` : t)
412  const lines = items.flatMap(({ rec, who }) =>
413    cut(rec.text).split('\n').map(l => l.trim()).filter(l => l !== '').map(l => `${who.label}: ${l}`))
414  if (lines.length === 0) {
415    return ''
416  }
417  return [`‹pa› the feed you have not seen (since your last answer; older lines: ${dir}/log.jsonl):`, ...lines].join('\n')
418}
419
420/** The plain line the developer gets when the launcher reports the feed cannot start (3.23 T13d.c2). */
421export const feedErrorLine = (error: string): string =>
422  error === 'no python' ? "The live feed could not start: no Python found (pa.json's python key)." : `The live feed could not start: ${error}.`
423
424// 3.23 T13d.c1c: the feed never stays dead. Claude Code never restarts a monitor (not on a reload, not after it dies), so
425// the router re-arms it through the Monitor tool at its end notice, an expiry or stop line, or a heartbeat (`alive`'s
426// `beat`, rewritten every 5 s by the feed script) older than FEED_STALE_MS; only in a session that had a feed (a fresh
427// heartbeat or a feed notification seen) or FEED_WAIT_MS after the first developer prompt from the composer or the
428// bridge with no heartbeat at all (headless `-p` sessions never arm); at most RESTART_MAX in RESTART_WINDOW_MS.
429export const FEED_DESC = 'pa feed'
430export const FEED_TIMEOUT_MS = 1_800_000
431export const FEED_BEAT_MS = 5_000
432export const FEED_STALE_MS = 20_000
433export const FEED_WAIT_MS = 30_000
434export const RESTART_MAX = 6
435export const RESTART_WINDOW_MS = 600_000
436
437export const emptyLife = (): FeedLife => ({ seen: false, devAt: null, restarts: [], calling: false, waiting: false, error: '' })
438
439/** `alive`'s `beat` (ms), null when the file is absent or not the heartbeat. */
440export function beatOf(text: string | null): number | null {
441  if (text === null) {
442    return null
443  }
444  try {
445    const o = JSON.parse(text) as unknown
446    const beat = typeof o === 'object' && o !== null ? (o as Record<string, unknown>)['beat'] : null
447    return typeof beat === 'number' ? beat : null
448  } catch {
449    return null
450  }
451}
452
453/** The life with a fresh heartbeat counted as a feed seen. */
454export const seenBeat = (life: FeedLife, beat: number | null, now: number): FeedLife =>
455  !life.seen && beat !== null && now - beat <= FEED_STALE_MS ? { ...life, seen: true } : life
456
457/** Why a feed notification says its monitor is dead: `end` (the end notice: a `<status>` and `Monitor "pa feed" …`),
458 * `expired` or `stopped` (Claude Code's bracketed line in an event); '' when it does not. */
459export function deathOf(text: string): string {
460  if (/<status>[^<]*<\/status>/.test(text) && /<summary>Monitor "pa feed"/.test(text)) {
461    return 'end'
462  }
463  for (const x of feedEvents(text)) {
464    if (x.kind === 'note' && /^\[Monitor expired/.test(x.line)) {
465      return 'expired'
466    }
467    if (x.kind === 'note' && /^\[Monitor stopped/.test(x.line)) {
468      return 'stopped'
469    }
470  }
471  return ''
472}
473
474/** Why the heartbeat says the feed is dead at `now`: `stale` (a feed seen, no beat for FEED_STALE_MS), `none` (no feed
475 * ever, FEED_WAIT_MS after the first developer prompt); '' otherwise. A restart counts as a beat, so it gets its time.
476 * 3.23 T13d.c2: never after the feed's error; `none` never while a restart's Monitor has not ended (its end notice or
477 * expiry clears `waiting`), so a feed that cannot start does not arm one idle Monitor after another. */
478export function staleOf(life: FeedLife, beat: number | null, now: number): string {
479  const last = Math.max(beat ?? 0, life.restarts[life.restarts.length - 1] ?? 0)
480  if (life.error !== '') {
481    return ''
482  }
483  if (life.seen) {
484    return now - last > FEED_STALE_MS ? 'stale' : ''
485  }
486  return !life.waiting && life.devAt !== null && now - Math.max(life.devAt, last) >= FEED_WAIT_MS ? 'none' : ''
487}
488
489/** Whether a restart may run at `now`: not while one runs, not within FEED_STALE_MS of the last (the old monitor's late
490 * notices), fewer than RESTART_MAX in RESTART_WINDOW_MS. `recent` is the window's restarts. */
491export function restartGate(life: FeedLife, now: number): { ok: boolean; why: string; recent: number[] } {
492  const recent = life.restarts.filter(t => now - t < RESTART_WINDOW_MS)
493  const last = recent[recent.length - 1]
494  const why = life.calling ? 'calling' : last !== undefined && now - last < FEED_STALE_MS ? 'grace' : recent.length >= RESTART_MAX ? 'limit' : ''
495  return { ok: why === '', why, recent }
496}
497
498const fwd = (p: string): string => p.split(String.fromCharCode(92)).join('/').replace(/[/]+$/, '') // the loader's parser trips on a backslash regex
499
500/** The restart's Monitor command: the launcher with the project and the feed folder as an absolute path, forward
501 * slashes. `pluginRoot` may name `.claude-plugin` (the folder holding plugin.json); the launcher is beside it. */
502export function feedCommand(pluginRoot: string, root: string, dir: string): string {
503  const plug = fwd(pluginRoot).replace(/\/\.claude-plugin$/, '')
504  const d = fwd(dir)
505  const abs = /^([A-Za-z]:)?\//.test(d) ? d : `${fwd(root)}/${d}`
506  return `bash "${plug}/feed/pa_feed.sh" "${fwd(root)}" --dir "${abs}"`
507}
508
hooks/loop.ts 1234 lines
1// The autopilot's loop state and its pure decisions (3.23-autopilot.md, "The loop (router `pa`)" and "Stops, holds and
2// decisions"): `.run/mod/loop.json`, project-level, written at every transition and read first at session start. Its
3// types live in the contract, types/index.d.ts. Pure, no `$`: autopilot.tsx reads the clock and files and acts.
4import type { HoldName, LoopState, LoopStateName, LoopTask, Shell, WaitFor } from '../types'
5
6import { parseExpertContract } from './contract'
7
8export const LOOP_V = 1
9export const LOOP_FILE = '.run/mod/loop.json'
10/** The owner writes its heartbeat this often (3.23 T5d1). */
11export const HEARTBEAT_MS = 15_000 // was 30_000 (autopilot.tsx)
12/** The loop runs only in the owner session while its heartbeat is younger than this; older, a session takes it over. */
13export const OWNER_STALE_MS = 45_000 // was 120_000; T5d1: three missed heartbeats
14/**
15 * A run has ended only this long after its last turn.complete (with no busy child and nothing queued to it). T5d1: a
16 * `-p` run ended ~25 s after its expert's `done`, before 60 s of quiet, so the routing was never seen (T5c.c3 log).
17 */
18export const RUN_QUIET_MS = 15_000 // was 60_000
19/**
20 * With no STATUS yet (no `pendingContract`), a run has ended only after this much silence since the agent's last activity
21 * (3.23 T11-fix: a closer ended a turn to wait on its own background shell; read as ended at 15 s, "no contract").
22 */
23export const NO_CONTRACT_QUIET_MS = 10 * 60_000
24/** `talk` ends after this much developer silence, unless Claude's last reply asked something. */
25export const TALK_SILENCE_MS = 15 * 60_000
26/** A WAIT-FOR's file is polled this often (3.23 T5d2a; pa-session step 2). */
27export const WAIT_POLL_MS = 30_000
28/** A WAIT-FOR not met this long goes to the review stop. */
29export const WAIT_MAX_MS = 50 * 60_000
30/** A forward with no turn.complete after it stops counting as queued after this (3.23 T5d2a.c2: undelivered, parent gone). */
31export const FORWARD_TTL_MS = 5 * 60_000
32
33const STATES: readonly LoopStateName[] = ['off', 'boundary', 'running', 'routing', 'closing']
34const HOLDS: readonly HoldName[] = ['talk', 'review', 'question', 'plan', 'close', 'window', 'stopped', 'generation', 'session']
35
36export function defaultLoop(): LoopState {
37  return {
38    v: LOOP_V, ownerSid: '', heartbeatAt: 0, state: 'off', holds: [], task: null, queuedEdits: [], chainStep: '',
39    dialog: false, waitFor: null, resumeAt: 0, lastDevAt: 0, lastAnswerAt: 0, talkAnswerAt: 0, lastAnswerAsked: false,
40  }
41}
42
43const str = (v: unknown): string => (typeof v === 'string' ? v : '')
44const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
45
46function parseTask(v: unknown): LoopTask | null {
47  if (typeof v !== 'object' || v === null) {
48    return null
49  }
50  const t = v as Record<string, unknown>
51  if (str(t['id']) === '') {
52    return null
53  }
54  return {
55    ...t,
56    id: str(t['id']), title: str(t['title']), agent: str(t['agent']), coder: str(t['coder']), effort: str(t['effort']),
57    attempt: num(t['attempt']) || 1, kind: str(t['kind']) || 'task', agentId: str(t['agentId']), startSha: str(t['startSha']),
58    spawnedAt: num(t['spawnedAt']), lastCompleteAt: num(t['lastCompleteAt']),
59    contract: typeof t['contract'] === 'string' ? t['contract'] : null,
60  }
61}
62
63function parseWaitFor(v: unknown): WaitFor | null {
64  const w = typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : null
65  return w === null || str(w['task']) === '' || str(w['file']) === ''
66    ? null
67    : { task: str(w['task']), file: str(w['file']), literal: str(w['literal']), since: num(w['since']) }
68}
69
70/** A plan entry's `wait-for` (`<file> contains <literal>`, a quoted or backticked literal unwrapped); null when it is not one. */
71export function waitSpec(spec: string): { file: string; literal: string } | null {
72  const m = /^\s*`?(.+?)`?\s+contains\s+(.+?)\s*$/.exec(spec)
73  if (m === null) {
74    return null
75  }
76  const literal = (m[2] ?? '').replace(/^(["'`])(.*)\1$/, '$2')
77  return literal === '' ? null : { file: m[1] ?? '', literal }
78}
79
80/** 3.24 T17b: whose model and effort an approved plan records (`plan_edit.py approve --planner`): the latest `pa-planner`
81 * the presenting caller spawned (Claude briefs it, then presents its draft), else the caller itself. */
82export function plannerIdOf(list: readonly { id: string; type: string; parentId?: string }[], caller: string): string {
83  const mine = list.filter(a => a.type === 'pa-planner' && a.parentId === caller)
84  return mine[mine.length - 1]?.id ?? caller
85}
86
87/** loop.json text as a LoopState: tolerant (a bad field takes its default, unknown fields are kept); default when absent or unreadable. */
88export function parseLoop(text: string | null): LoopState {
89  const d = defaultLoop()
90  if (text === null) {
91    return d
92  }
93  let doc: unknown
94  try {
95    doc = JSON.parse(text)
96  } catch {
97    return d
98  }
99  if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
100    return d
101  }
102  const o = doc as Record<string, unknown>
103  const state = STATES.find(s => s === o['state']) ?? d.state
104  const holds = Array.isArray(o['holds']) ? (o['holds'] as unknown[]).filter((h): h is HoldName => HOLDS.includes(h as HoldName)) : []
105  const queued = Array.isArray(o['queuedEdits']) ? (o['queuedEdits'] as unknown[]) : []
106  return {
107    ...o,
108    v: LOOP_V, ownerSid: str(o['ownerSid']), heartbeatAt: num(o['heartbeatAt']), state, holds, task: parseTask(o['task']),
109    queuedEdits: queued.flatMap(q => {
110      const e = typeof q === 'object' && q !== null ? (q as Record<string, unknown>) : {}
111      return str(e['command']) === '' ? [] : [{ command: str(e['command']), why: str(e['why']), at: num(e['at']), caller: str(e['caller']) }]
112    }),
113    chainStep: str(o['chainStep']), dialog: o['dialog'] === true, waitFor: parseWaitFor(o['waitFor']), resumeAt: num(o['resumeAt']),
114    lastDevAt: num(o['lastDevAt']), lastAnswerAt: num(o['lastAnswerAt']),
115    talkAnswerAt: num(o['talkAnswerAt'] ?? o['lastAnswerAt']), lastAnswerAsked: o['lastAnswerAsked'] === true,
116  }
117}
118
119export function serializeLoop(s: LoopState): string {
120  return `${JSON.stringify(s, null, 1)}\n`
121}
122
123/** The hold on top of the stack, or null. */
124export function topHold(s: LoopState): HoldName | null {
125  return s.holds[s.holds.length - 1] ?? null
126}
127
128/** Pushes a hold once: `talk` overlays any other (always on top); another hold goes beneath a `talk`. */
129export function pushHold(s: LoopState, hold: HoldName): LoopState {
130  if (s.holds.includes(hold)) {
131    return s
132  }
133  const holds = [...s.holds]
134  if (hold !== 'talk' && holds[holds.length - 1] === 'talk') {
135    holds.splice(holds.length - 1, 0, hold)
136  } else {
137    holds.push(hold)
138  }
139  return { ...s, holds }
140}
141
142/** Removes the named hold wherever it sits. */
143export function popHold(s: LoopState, hold: HoldName): LoopState {
144  return s.holds.includes(hold) ? { ...s, holds: s.holds.filter(h => h !== hold) } : s
145}
146
147/**
148 * This session runs the loop: it owns it, nobody does, or the owner's heartbeat is older than OWNER_STALE_MS. T13b.c4:
149 * or the owner is `prevSid`, this process's session before a `/clear` (its heartbeat is this process's own).
150 */
151export function ownsLoop(s: LoopState, sid: string, now: number, prevSid = ''): boolean {
152  return s.ownerSid === sid || s.ownerSid === '' || (prevSid !== '' && s.ownerSid === prevSid) || now - s.heartbeatAt > OWNER_STALE_MS
153}
154
155/**
156 * This session takes the loop over (T5d1): it runs it, and the owner is another session or the heartbeat is stale (a
157 * restart under the same id). A takeover with a task running and no stop runs recovery.
158 */
159export function takesOver(s: LoopState, sid: string, now: number, prevSid = ''): boolean {
160  return ownsLoop(s, sid, now, prevSid) && (s.ownerSid !== sid || now - s.heartbeatAt > OWNER_STALE_MS)
161}
162
163/** 3.28.1 T15: background shells (Bash `run_in_background`), recorded by pa-watch per agent in
164 * `.run/mod/<sid>/shells.json` (`{ <agentId>: Shell[] }`) from start to completion notice; `exit` set when it ended badly. */
165export const SHELLS_FILE = 'shells.json'
166
167/** shells.json's records by agent, each well-formed; {} for a missing or broken file. */
168export function shellsOf(text: string | null): Record<string, Shell[]> {
169  let v: unknown
170  try {
171    v = JSON.parse(text ?? '')
172  } catch {
173    return {}
174  }
175  if (typeof v !== 'object' || v === null || Array.isArray(v)) return {}
176  const out: Record<string, Shell[]> = {}
177  for (const [agentId, list] of Object.entries(v as Record<string, unknown>)) {
178    const shells = (Array.isArray(list) ? list : []).flatMap((x: unknown) => {
179      const j = typeof x === 'object' && x !== null ? (x as Record<string, unknown>) : {}
180      if (str(j['id']) === '') return []
181      const sh: Shell = { id: str(j['id']), name: str(j['name']), at: num(j['at']), type: str(j['type']) }
182      if (typeof j['exit'] === 'number') sh.exit = j['exit']
183      return [sh]
184    })
185    if (shells.length > 0) out[agentId] = shells
186  }
187  return out
188}
189
190/** What a hand-back or answer waits on (`WAITING: shell <what is running>`), null when it is not a shell wait. */
191export function waitingShell(text: string): string | null {
192  const m = /^\s*WAITING:\s*shell\b[ \t:]*(.*)/i.exec(text)
193  return m === null ? null : (m[1] ?? '').trim()
194}
195
196/** The live shells (no `exit`) of these agents. */
197export function shellsOut(shells: Record<string, Shell[]>, ids: string[]): Shell[] {
198  return ids.flatMap((id) => (shells[id] ?? []).filter((s) => s.exit === undefined))
199}
200
201/** The spinner's frames; the frame comes from the clock. */
202export const SPINNER = ['◐', '◓', '◑', '◒'] as const
203
204/** The note Claude gets when a task's expert or coder hands back to wait on its background shell (3.28.1 T15 part d). */
205export const shellNotice = (task: string, role: string, id: string, what: string): string =>
206  `‹pa› ${task}'s ${role} (${id}) is waiting on a background shell${what === '' ? '' : `: ${what}`}. It picks up by itself ` +
207  'when the shell ends. Tell the developer in one plain line: what is running, for which task, and that it picks up by itself.'
208
209/** The text recovery sends the running task's expert (T5d1). */
210export const CONTINUE_TEXT = 'continue: the session restarted; continue your task from your last step; your brief and the plan are unchanged'
211
212/**
213 * The text `release(stopped)` sends the stopped agent to resume it under the same id (3.23 T11-fix2): a stop is the
214 * developer's (Ctrl+C), so the loop never resumes one on its own.
215 */
216export function stoppedText(note: string): string {
217  return `continue: the developer stopped you and now wants you to go on.${note.trim() === '' ? '' : ` ${note.trim()}`} ` +
218    'Check what your coder left (git status, git diff) before continuing; a coder stopped mid-edit may have left partial changes.'
219}
220/** A loop agent absent from `$.agent.list()` this long after its spawn counts as killed (T11-fix2). */
221export const ABSENT_MS = 15_000
222
223/**
224 * A loop agent's liveness from its `$.agent.list()` status (T11-fix2; engine AgentStatus): `pending`, `running` and
225 * `waiting` (on its own background work) are busy, never ended; `idle` and `completed` go to runEnded's rules;
226 * `killed` and `failed` are stopped, and so is an agent absent from the list ABSENT_MS after its spawn.
227 */
228export function liveness(status: string | undefined, sinceSpawn: number): 'busy' | 'idle' | 'stopped' {
229  if (status === undefined) {
230    return sinceSpawn > ABSENT_MS ? 'stopped' : 'busy'
231  }
232  return status === 'killed' || status === 'failed' ? 'stopped' : status === 'idle' || status === 'completed' ? 'idle' : 'busy'
233}
234
235/** The running-now note's word for an agent's status (T11-fix2): `stopped`, `waiting`, else '' (at work or idle). */
236export function statusWord(status: string | undefined): string {
237  return status === undefined || status === 'killed' || status === 'failed' ? 'stopped' : status === 'waiting' ? 'waiting' : ''
238}
239
240/**
241 * The task's run has ended: no busy child, nothing queued to it, and RUN_QUIET_MS since its last turn.complete (one seen).
242 * T5d2a.c2: and RUN_QUIET_MS since its children went idle (`childIdleAt`): a coder's `completed` can be listed a tick
243 * before its hand-back's forward request lands.
244 * T11-fix: with no `pendingContract`, NO_CONTRACT_QUIET_MS since the latest of lastCompleteAt, childIdleAt and
245 * `activityAt` (the watcher facts' `updatedAt`, reset by any step the agent takes).
246 * 3.24 T13A: `handbackAt`, the agent's hand-back time (0 none): once it is in, no RUN_QUIET_MS wait from the last turn
247 * (round 2: 15 s of silence after the curator's hand-back); the wait stays where no hand-back arrived, and from children
248 * that went idle after it (their hand-back's forward may still land).
249 */
250export function runEnded(task: LoopTask, childrenBusy: number, queuedToIt: number, now: number, activityAt = 0, handbackAt = 0): boolean {
251  const idleAt = task.childIdleAt ?? 0
252  const quiet = childrenBusy === 0 && queuedToIt === 0 && task.lastCompleteAt > 0 &&
253    (handbackAt > 0 || now - task.lastCompleteAt >= RUN_QUIET_MS) &&
254    ((handbackAt > 0 && handbackAt >= idleAt) || now - idleAt >= RUN_QUIET_MS)
255  return quiet && ((task.pendingContract ?? null) !== null ||
256    now - Math.max(task.lastCompleteAt, task.childIdleAt ?? 0, activityAt) >= NO_CONTRACT_QUIET_MS)
257}
258
259/**
260 * Messages queued to the task's expert (3.23 T5c): a `steer` sent after its last turn.complete counts 1 until the next;
261 * T5d2a: so does the router's forward of its child's hand-back (a revived run read as ended before it, "no contract"),
262 * for at most FORWARD_TTL_MS (T5d2a.c2: an undelivered forward never gets its turn.complete).
263 */
264export function queuedTo(task: LoopTask, now: number): number {
265  const fwd = task.forwardAt ?? 0
266  return ((task.steerAt ?? 0) > task.lastCompleteAt ? 1 : 0) + (fwd > task.lastCompleteAt && now - fwd < FORWARD_TTL_MS ? 1 : 0)
267}
268
269/** The text a `steer` sends the running task's expert. */
270export function steerText(text: string): string {
271  return `‹pa› A correction from the developer, relayed by Claude: ${text}\n` +
272    'Work out what it means for your task, tell your running coder with SendMessage what to stop, keep or undo (never stop it), and log the correction under Deviations.'
273}
274
275/** 3.23 T13d.c3: a confirmation that only says done is not sent to Claude (each one wakes it for a turn): a bare
276 * `released: review|generation|question`, `released: stopped (resumed)`, `queued: …`, a plain `applied: …`, `steered: T<n>`. Refusals,
277 * `applied: … (not committed: …)`, `released: stopped (restarted, …)` and a release with added text are sent.
278 * 3.24 T13A (decision 3): `released: talk|close` too are quiet: a release Claude made itself, nothing refused or changed
279 * (round 2: `released: talk` woke Claude for a reply that repeated its answer). The 3.23 record (`fbd6850f`) sent them
280 * only because c3's brief did not list them; it gives no reason. */
281export function quietReply(line: string): boolean {
282  const l = line.trim()
283  if (l.includes('\n')) {
284    return false
285  }
286  return /^released: (review|generation|question|session|talk|close)$/.test(l) || l === 'released: stopped (resumed)' || /^queued: /.test(l) ||
287    (/^applied: /.test(l) && !l.includes('(not committed: ')) || /^steered: \S+$/.test(l)
288}
289
290export type Route = 'done' | 'handoff' | 'review' | 'question' | 'block'
291
292/** The route of an expert's contract text: a missing or unparseable one, `blocked` or any other status is a block. */
293export function routeStatus(contract: string | null): Route {
294  const c = contract === null ? null : parseExpertContract(contract)
295  switch (c?.status) {
296    case 'done':
297      return 'done'
298    case 'handoff':
299      return 'handoff'
300    case 'review':
301      return 'review'
302    case 'question':
303      return 'question'
304    default:
305      return 'block'
306  }
307}
308
309export type Release = { ok: true; state: LoopState; note: string } | { ok: false; refused: string }
310
311/** `release({hold, note})`: ends the named hold; `review` is refused while the task is still open. */
312export function releaseHold(s: LoopState, r: { hold: HoldName; note: string }, taskOpen: boolean): Release {
313  if (!s.holds.includes(r.hold)) {
314    return { ok: false, refused: `not held: ${r.hold}` }
315  }
316  if (r.hold === 'review' && taskOpen) {
317    return { ok: false, refused: `mark ${s.task?.id ?? 'the task'} done or reopen it first` }
318  }
319  return { ok: true, state: popHold(s, r.hold), note: r.note }
320}
321
322/** A `plan_edit` now is a decision on the stop (applied at once), not a change queued for the boundary. 3.30.1: the
323 * developer's stop too (`stopped`, a pause included): no expert works then, and a reorder must land before it goes on. */
324export function editIsDecision(s: LoopState): boolean {
325  return s.holds.includes('review') || s.holds.includes('question') || s.holds.includes('stopped')
326}
327
328/** 3.30.1: the plan statuses that set a held or cut-off task back: it is not resumed but set aside (Terrain T9: `blocked`). */
329export const SET_BACK: readonly string[] = ['queued', 'blocked', 'superseded', 'done']
330
331/** 3.30.1: the tasks set aside with their progress (loop.json `setAside`), id → the attempt cut short; {} when none. */
332export function setAsideOf(s: LoopState): Record<string, { attempt: number }> {
333  const v = s.setAside
334  if (typeof v !== 'object' || v === null || Array.isArray(v)) {
335    return {}
336  }
337  return Object.fromEntries(Object.entries(v).filter(([, x]) => typeof x === 'object' && x !== null && Number.isFinite(x.attempt)))
338}
339
340/**
341 * Silence ends `talk` (the only hold it ends): on top, 15 min since the developer's last message and Claude's last
342 * answer to him (3.28.1 T9b: an answer after a mod notice does not count), and that answer asked nothing.
343 */
344export function silenceReleases(s: LoopState, now: number): boolean {
345  return topHold(s) === 'talk' && !s.lastAnswerAsked && now - Math.max(s.lastDevAt, s.talkAnswerAt) >= TALK_SILENCE_MS
346}
347
348/** The task after `running` from `plan_edit.py show --tasks` rows (`T3 | next | agent | …`): the first next or queued one; '' none. */
349export function nextTaskOf(rows: string, running: string): string {
350  for (const line of rows.split(/\r?\n/)) {
351    const [id = '', status = ''] = line.split('|').map(c => c.trim())
352    if (/^T\d/.test(id) && id !== running && (status === 'next' || status === 'queued')) {
353      return id
354    }
355  }
356  return ''
357}
358
359/** The `nextId` the loop keeps in loop.json (an extra field, set at each spawn); '' none. */
360export const nextIdOf = (s: LoopState): string => {
361  const n = (s as LoopState & { nextId?: unknown }).nextId
362  return typeof n === 'string' ? n : ''
363}
364
365/** The running task's place in the plan from `plan_edit.py show --tasks` rows: `[n, m]`, n its row among the m task rows; null unlisted. */
366export function taskPosOf(rows: string, running: string): [number, number] | null {
367  const ids = rows.split(/\r?\n/).map(l => (l.split('|')[0] ?? '').trim()).filter(id => /^T\d/.test(id))
368  const n = ids.indexOf(running)
369  return n < 0 ? null : [n + 1, ids.length]
370}
371
372/** The `taskPos` the loop keeps in loop.json (an extra field, set at each spawn like `nextId`, 3.23 T13); null none. */
373export const taskPosIn = (s: LoopState): [number, number] | null => {
374  const p = (s as LoopState & { taskPos?: unknown }).taskPos
375  return Array.isArray(p) && p.length === 2 && p.every(v => typeof v === 'number' && Number.isFinite(v)) ? [p[0] as number, p[1] as number] : null
376}
377
378/** The band's loop line (3.23 T13): `Phase <p> · <task> (n of m) · held: <holds>`, the close step (`close: assemble`) in place of
379 * the task while the chain runs; '' when the loop is off, or has no task, hold or close step. */
380export function bandLine(s: LoopState, phase: string): string {
381  if (s.state === 'off' || (s.task === null && s.holds.length === 0 && s.chainStep === '')) {
382    return ''
383  }
384  const parts = [`Phase ${s.chainPhase || phase || '?'}`]
385  const pos = taskPosIn(s)
386  if (s.chainStep !== '') {
387    parts.push(`close: ${s.chainStep}`)
388  } else if (s.task !== null) {
389    parts.push(pos === null ? s.task.id : `${s.task.id} (${String(pos[0])} of ${String(pos[1])})`)
390  }
391  if (s.holds.length > 0) {
392    parts.push(`held: ${s.holds.join(', ')}`)
393  }
394  return parts.join(' · ')
395}
396
397/**
398 * The line the router puts before the developer's first message while the loop is active and `talk` is not held yet;
399 * null otherwise. 3.23 T13d.c11: while `session` is held (a session start; nothing has started since), the waiting line
400 * instead, on each developer message until the release.
401 */
402export function talkLine(s: LoopState): string | null {
403  if (s.holds.includes('session')) {
404    const what = s.task !== null ? s.task.id : s.state === 'closing' ? 'the phase close' : s.holds.includes('generation')
405      ? "the next generation's start" : 'the next step'
406    return `‹pa› the loop is waiting for you: nothing has started or continued since this session began (waiting: ${what}). ` +
407      'Call release({hold: "session"}) only when the developer asks for the work to go on. A question about where ' +
408      'things stand, or any other talk, is not that: just answer it.'
409  }
410  if (s.state === 'off' || s.holds.includes('talk')) {
411    return null
412  }
413  const running = s.task === null ? 'no task' : s.task.id
414  return `‹pa› the queue is held while you talk (${running} running; next: ${nextIdOf(s) || 'none listed'}). ` +
415    'Call release({hold: "talk"}) when the conversation is done or does not touch the plan.'
416}
417
418/** `held(window)` is over: its `resume_at` has passed. */
419export function windowResumes(s: LoopState, now: number): boolean {
420  return s.holds.includes('window') && s.resumeAt > 0 && now >= s.resumeAt
421}
422
423/** The agent to spawn for a plan entry's column (3.25 T6b: one agent per role): no column, or an old expert type
424 * (`expert-opus55`, `expert-fable`, their `-5m` twins), is `pa-expert`; another column passes through, a `-5m` twin as
425 * its 1h type. The effort is the spawn's `[effort <level>]` hint (effortFor), not the type. */
426export function agentFor(column: string | null, _effort: string | null): string {
427  const c = (column ?? '').trim()
428  if (c === '' || c === '-' || c === '—' || /^expert-/.test(c)) {
429    return 'pa-expert'
430  }
431  return c.endsWith('-5m') ? c.slice(0, -3) : c
432}
433
434/** A plan entry's effort: its own, else high for an old `expert-fable…` column, else medium (3.25 T6b). */
435export function effortFor(column: string | null, effort: string | null): string {
436  const e = (effort ?? '').trim()
437  if (e !== '') {
438    return e
439  }
440  return /^expert-fable/.test((column ?? '').trim()) ? 'high' : 'medium'
441}
442
443/** A spawn description's trailing `[effort <level>]` hint (3.25 T6b): the watcher resolves the row from it; medium (the base
444 * row) goes without one. */
445export const effortHint = (effort: string): string => `[effort ${effort}]`
446
447// 3.23 T6a1: an approved generation or phase plan being installed (loop.json `install`, an extra field), a step a tick so
448// a crash resumes at the recorded step: `start` (a generation plan written and committed; a phase draft linted),
449// `from-draft`, `approve`, `asks` (one ask per rules line, then per candidates line; `asked` the request id, `answer`
450// its answer, null while owed), `commit`.
451export type Install = {
452  kind: 'generation' | 'phase'
453  path: string
454  title: string
455  rules: string[]
456  candidates: string[]
457  caller: string
458  step: 'start' | 'from-draft' | 'approve' | 'asks' | 'commit'
459  i: number
460  asked: string
461  answer: string | null
462  applied: string[]
463  phase: string
464}
465
466const STEPS: readonly Install['step'][] = ['start', 'from-draft', 'approve', 'asks', 'commit']
467const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
468
469/** loop.json's install in progress, null none. */
470export function installOf(s: LoopState): Install | null {
471  const v = (s as LoopState & { install?: unknown }).install
472  if (typeof v !== 'object' || v === null) {
473    return null
474  }
475  const o = v as Record<string, unknown>
476  const kind = o['kind'] === 'generation' || o['kind'] === 'phase' ? o['kind'] : null
477  if (kind === null || str(o['path']) === '') {
478    return null
479  }
480  return {
481    kind, path: str(o['path']), title: str(o['title']), rules: strs(o['rules']), candidates: strs(o['candidates']), caller: str(o['caller']),
482    step: STEPS.find(x => x === o['step']) ?? 'start', i: num(o['i']), asked: str(o['asked']),
483    answer: typeof o['answer'] === 'string' ? o['answer'] : null, applied: strs(o['applied']), phase: str(o['phase']),
484  }
485}
486
487export const RULE_OPTIONS: readonly string[] = ['add (Recommended)', 'reject']
488export const CANDIDATE_OPTIONS: readonly string[] = ['build this phase', 'defer', 'drop']
489
490/** A lint run's ERROR lines, joined; '' none. */
491export function lintErrors(out: string): string {
492  return out.split(/\r?\n/).map(l => l.trim()).filter(l => l.startsWith('ERROR')).join('; ')
493}
494
495/** A rules line (`M13: <title>`, else the title alone under `nextId`) as `rules_add.py add` arguments. */
496export function ruleArgs(line: string, nextId: string, origin: string): string[] {
497  const m = /^\s*(?:-\s*)?([A-Za-z]+\d+(?:\.\d+)?)\s*[:|—]\s*(.+)$/.exec(line)
498  const id = m?.[1] ?? nextId
499  const title = (m?.[2] ?? line.replace(/^\s*-\s*/, '')).trim()
500  return ['add', '--id', id, '--title', title, '--origin', origin]
501}
502
503/** The next free `M<n>` among rule file names. */
504export function nextRuleId(names: readonly string[]): string {
505  const ns = names.map(n => /^M(\d+)\.md$/.exec(n)?.[1]).filter((x): x is string => x !== undefined).map(Number)
506  return `M${String(ns.length === 0 ? 1 : Math.max(...ns) + 1)}`
507}
508
509/** An audit candidates line (`- Tool candidate: <name> | does: … | replaces: … | …`): its name, what it does, what it replaces. */
510export function candidateOf(line: string): { name: string; does: string; replaces: string } {
511  const body = line.replace(/^\s*-?\s*(?:Tool|Rule)\s+candidate:\s*/i, '').trim()
512  const fields = body.split('|').map(f => f.trim())
513  const name = fields[0] ?? body
514  const get = (k: string): string => fields.find(f => f.startsWith(`${k}:`))?.slice(k.length + 1).trim() ?? ''
515  return { name, does: get('does') || name, replaces: get('replaces') || `${name} is in place` }
516}
517
518// 3.23 T6a2: the generation start (launch.py mode `planner-gen`) or the migration curate line (any mode), loop.json
519// `genStart`, a step a tick: `inbox` (status.py inbox-consume, then launch.py --state-json for curate, audit_flag and
520// deferred), `ask` (the curator ask, `asked` the request id, `answer` null while owed), `curator` and `audit` (a loop
521// agent: `agentId` spawned, its last answer read at runEnded), `notice` (one notice to Claude), `done`. `sid`: the
522// session it ran in (a pause is asked again next session).
523export type GenStart = {
524  kind: 'gen' | 'migration'
525  sid: string
526  step: 'inbox' | 'ask' | 'curator' | 'audit' | 'notice' | 'done'
527  curate: string
528  auditFlag: string
529  deferred: { id: string; text: string }[]
530  asked: string
531  answer: string | null
532  agentId: string
533  spawnedAt: number
534  lastCompleteAt: number
535  childBusy: boolean
536  childIdleAt: number
537  recap: string
538  audit: string
539}
540
541const GEN_STEPS: readonly GenStart['step'][] = ['inbox', 'ask', 'curator', 'audit', 'notice', 'done']
542
543/** A fresh generation-start record. */
544export function newGenStart(kind: GenStart['kind'], sid: string, curate = ''): GenStart {
545  return {
546    kind, sid, step: 'inbox', curate, auditFlag: '', deferred: [], asked: '', answer: null, agentId: '', spawnedAt: 0,
547    lastCompleteAt: 0, childBusy: false, childIdleAt: 0, recap: '', audit: '',
548  }
549}
550
551/** launch.py --state-json's `deferred` list. */
552export function deferredOf(v: unknown): { id: string; text: string }[] {
553  return (Array.isArray(v) ? (v as unknown[]) : []).flatMap(x => {
554    const o = typeof x === 'object' && x !== null ? (x as Record<string, unknown>) : {}
555    return str(o['id']) === '' ? [] : [{ id: str(o['id']), text: str(o['text']) }]
556  })
557}
558
559/** loop.json's generation start, null none. */
560export function genStartOf(s: LoopState): GenStart | null {
561  const v = (s as LoopState & { genStart?: unknown }).genStart
562  if (typeof v !== 'object' || v === null) {
563    return null
564  }
565  const o = v as Record<string, unknown>
566  const kind = o['kind'] === 'gen' || o['kind'] === 'migration' ? o['kind'] : null
567  if (kind === null) {
568    return null
569  }
570  return {
571    kind, sid: str(o['sid']), step: GEN_STEPS.find(x => x === o['step']) ?? 'inbox', curate: str(o['curate']), auditFlag: str(o['auditFlag']),
572    deferred: deferredOf(o['deferred']), asked: str(o['asked']), answer: typeof o['answer'] === 'string' ? o['answer'] : null,
573    agentId: str(o['agentId']), spawnedAt: num(o['spawnedAt']), lastCompleteAt: num(o['lastCompleteAt']), childBusy: o['childBusy'] === true,
574    childIdleAt: num(o['childIdleAt']), recap: str(o['recap']), audit: str(o['audit']),
575  }
576}
577
578export const CURATE_OPTIONS: readonly string[] = ['Run it now (Recommended)', 'Pause']
579
580/** 3.24 T7b: the project's first run in 3.2 from `.claude/pa.json` (T5's `first_run: {from, at}`, pending while
581 * `migrated` ≠ "3.2"): the curator's line `curate: migration 3.2 -> gen 3.2 · FROM: <from> · MEMORY: <dir>`; '' none. */
582export function firstRunLine(paText: string | null, memoryDir: string): string {
583  let o: Record<string, unknown>
584  try {
585    const v: unknown = JSON.parse(paText ?? '')
586    o = typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
587  } catch {
588    return ''
589  }
590  const fr = o['first_run']
591  if (typeof fr !== 'object' || fr === null || o['migrated'] === '3.2') {
592    return ''
593  }
594  return `curate: migration 3.2 -> gen 3.2 · FROM: ${str((fr as Record<string, unknown>)['from']) || 'unknown'} · MEMORY: ${memoryDir}`
595}
596
597/** A first-run curate line (firstRunLine). */
598export const isFirstRun = (curate: string): boolean => /^curate: migration 3\.2\b/.test(curate)
599
600/** 3.24 T7b-2c: why plans wait while a first run is pending (firstRunLine): Claude's seed line, and the refusal's reason
601 * ("Not presented:", "not installed:"; all three reach Claude only). 3.24 T13g: names the mod as the one who asks. */
602export const FIRST_RUN_WAITS = "The project's first run in PA 3.2 sorts its memories first: no plan is presented or installed " +
603  'until the memory curator has run. The mod asks the developer itself, in its own picker; do not ask about the curator ' +
604  "yourself. If they pause it, the mod asks again at the next session start. Relay the curator's recap when it arrives."
605
606/** The curator ask's question (pa-session.md:86-89); 3.24 T7b: the project's first run in 3.2 says so. */
607export const curateQuestion = (curate: string): string => isFirstRun(curate)
608  ? `This project's first run in PA 3.2: the memory curator sorts its memories once (${curate}), card first, ` +
609    'memory kept lean, the rest archived, and recaps after. Run it now?'
610  : `The memory curator runs at every generation start (${curate}). Run it now?`
611
612/** Where the curator archives demoted memories: `gen<G>.md` (G the closing generation); for a migration the line's
613 * target (`-> gen 3.2`: `gen3.2.md`, 3.24 T7b), else `genlegacy.md`. */
614export function archiveOf(curate: string): string {
615  const g = /closing gen\s+(\d+)/.exec(curate)?.[1]
616  const m = /migration.*?->\s*gen\s+([\w.]+)/.exec(curate)?.[1]
617  return `.claude-state/memory/gen${/migration/.test(curate) ? (m ?? 'legacy') : (g ?? '<G>')}.md`
618}
619
620const natKey = (x: string): (string | number)[] => x.split(/(\d+)/).map((p, i) => (i % 2 === 1 ? Number(p) : p))
621function natCmp(a: string, b: string): number {
622  const x = natKey(a)
623  const y = natKey(b)
624  for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
625    const p = x[i]
626    const q = y[i]
627    if (p === undefined || q === undefined) {
628      return p === undefined ? -1 : 1
629    }
630    const c = typeof p === 'number' && typeof q === 'number' ? p - q : String(p).localeCompare(String(q))
631    if (c !== 0) {
632      return c
633    }
634  }
635  return 0
636}
637
638/** The newest `PhaseEnd_*.md` among `phase-ends/` names (natural order), its phase id and the one before; null none. */
639export function phaseEndsOf(names: readonly string[]): { newest: string; previous: string; phase: string } | null {
640  const pes = names.flatMap(n => {
641    const m = /^PhaseEnd_(?:Phase)?(.+)\.md$/.exec(n)
642    return m === null ? [] : [{ name: n, phase: m[1] ?? '' }]
643  }).sort((a, b) => natCmp(a.phase, b.phase))
644  const last = pes[pes.length - 1]
645  return last === undefined ? null : { newest: last.name, previous: pes[pes.length - 2]?.name ?? '', phase: last.phase }
646}
647
648/** The generation start's one notice to Claude: the recap and archive, the audit counts, the deferred items, the plan. */
649export function genNotice(o: {
650  kind: GenStart['kind']
651  curate: string
652  recap: string
653  audit: { verdicts: string; fixed: string; candidates: string } | null
654  auditText: string
655  deferred: readonly { id: string; text: string }[]
656}): string {
657  const out = [o.kind === 'gen' ? '‹pa› Generation start.' : isFirstRun(o.curate)
658    ? `‹pa› The project's first run in PA 3.2: the memory curator ran (${o.curate}). Relay its recap to the developer in plain words.`
659    : `‹pa› The memory curator ran (${o.curate}).`]
660  if (o.curate !== '') {
661    out.push(`memory curator recap:\n${o.recap.trim() === '' ? '(no recap)' : o.recap.trim()}`,
662      `archived memories: ${archiveOf(o.curate)} (tell the developer, in case he wants to keep something)`)
663  }
664  if (o.audit !== null) {
665    out.push(`auditor: VERDICTS ${o.audit.verdicts || '?'}, FIXED ${o.audit.fixed || '?'}, CANDIDATES ${o.audit.candidates || '?'}; ` +
666      'its candidates are in phase-ends/current/AUDIT.md; carry them in plan(candidates)')
667  } else if (o.auditText.trim() !== '') {
668    out.push(`auditor: no contract: ${o.auditText.trim().slice(0, 400)}`)
669  }
670  if (o.deferred.length > 0) {
671    out.push(`deferred items:\n${o.deferred.map(d => `- ${d.id}: ${d.text}`).join('\n')}\n` +
672      'settle these with the developer (tools/discussion.py triage) before drafting')
673  }
674  if (o.kind === 'gen') {
675    out.push('a generation plan is needed: draft it with the pa-planning skill and present it with plan(kind: generation)')
676  }
677  return out.join('\n')
678}
679
680// 3.23 T6a2: drift at boundaries (pa-session.md:166-170): loop.json `drift`, one ask per `managed.py drift` row.
681export type DriftRow = { rel: string; ids: string }
682export type Drift = { rows: DriftRow[]; i: number; asked: string; answer: string | null; done: string[] }
683export const DRIFT_OPTIONS: readonly string[] = ['stamp (Recommended)', 'decline']
684
685/** `managed.py drift` output (`none`, or `<rel> | base … | <sha8> | edited in <ids>` lines) as rows. */
686export function driftRows(out: string): DriftRow[] {
687  return out.split(/\r?\n/).map(l => l.trim()).filter(l => l !== '' && l !== 'none' && l.includes('|')).map(l => {
688    const f = l.split('|').map(x => x.trim())
689    const ed = f.find(x => x.startsWith('edited in')) ?? ''
690    return { rel: f[0] ?? '', ids: ed.replace(/^edited in\s*/, '') || '—' }
691  }).filter(r => r.rel !== '')
692}
693
694export const driftQuestion = (r: DriftRow): string =>
695  `${r.rel} differs from the installed copy (edited in ${r.ids}). Keep it as your version?`
696
697/** loop.json's drift asks in progress, null none. */
698export function driftOf(s: LoopState): Drift | null {
699  const v = (s as LoopState & { drift?: unknown }).drift
700  if (typeof v !== 'object' || v === null) {
701    return null
702  }
703  const o = v as Record<string, unknown>
704  const rows = (Array.isArray(o['rows']) ? (o['rows'] as unknown[]) : []).flatMap(x => {
705    const r = typeof x === 'object' && x !== null ? (x as Record<string, unknown>) : {}
706    return str(r['rel']) === '' ? [] : [{ rel: str(r['rel']), ids: str(r['ids']) }]
707  })
708  return rows.length === 0
709    ? null
710    : { rows, i: num(o['i']), asked: str(o['asked']), answer: typeof o['answer'] === 'string' ? o['answer'] : null, done: strs(o['done']) }
711}
712
713// 3.23 T6a2: hook-context lines (session_start.py's update lines, renewal.py's renewal line).
714const CONTEXT_RE = /PA3: (?:update available|the installed copy|this project's files|the plan renewal day)/
715
716/** The update and renewal lines in a hook-context row's text, each from its `PA3:` on. */
717export function contextLines(text: string): { kind: 'update' | 'renewal'; line: string }[] {
718  return text.split(/\r?\n/).flatMap(l => {
719    const m = CONTEXT_RE.exec(l)
720    if (m === null) {
721      return []
722    }
723    const line = l.slice(m.index).trim()
724    return [{ kind: line.startsWith('PA3: the plan renewal day') ? 'renewal' as const : 'update' as const, line }]
725  })
726}
727
728/** 3.28.6 T19: an update line's fact without its steps (`Upgrade: auto: … run …`, `Say \`update pa3\` to run …`): in a
729 * `mod: on` session the mod owns the update, so neither Claude nor the journal's seed gets a pre-3.2 router's command. */
730export function updateFact(line: string): string {
731  const i = line.search(/\s(?:Upgrade: |Say `update pa3`)/)
732  return i < 0 ? line : line.slice(0, i).trim()
733}
734
735// 3.24 T6: the update ask is on in production, so this notice (a trial without PA_MOD_UPDATE=1) only reports the line.
736export const updateNotice = (line: string): string =>
737  `‹pa› PA3 says: ${line.replace(/\.\s*$/, '')}. Mention it to the developer once.`
738export const renewalNotice = (line: string): string =>
739  `‹pa› ${line} Ask the developer for the day once this session, then run the line's command with the day in place of <day>.`
740
741// The update ask (3.24 T6: on in production, PA_MOD_PA3_DIR empty; a trial forces it with PA_MOD_UPDATE=1): loop.json `update`.
742export type UpdateAsk = { line: string; asked: string; answer: string | null }
743export const UPDATE_OPTIONS: readonly string[] = ['Update now (Recommended)', 'Later']
744
745/** loop.json's update ask, null none. */
746export function updateOf(s: LoopState): UpdateAsk | null {
747  const v = (s as LoopState & { update?: unknown }).update
748  const o = typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : null
749  return o === null || str(o['line']) === ''
750    ? null
751    : { line: str(o['line']), asked: str(o['asked']), answer: typeof o['answer'] === 'string' ? o['answer'] : null }
752}
753
754/** 3.28.1 T14: a running session checks for a newer release this often (the session start checks too). */
755export const RELEASE_CHECK_MS = 8 * 3_600_000
756/** The check's release (`{version, kind, state}`, state `later` | `install`) for the statusline's green line
757 * (pa/statusline.py): "available", "installs after the current task", then "installed" once the installed VERSION
758 * reaches it. The mod empties it when it starts. */
759export const RELEASE_MARKER = '.run/mod/release.json'
760/** The restart that loads an installed update: a resume is a new process (the mod, the agents, the session-start
761 * check all load) and keeps the session id; /clear starts none of it. */
762export const RESTART = '/exit, then claude --continue (not /clear); you\'ll pick up where you left off'
763/** 3.28.2 T16: the restart line Claude relays on its own line, after a blank line, in bold. */
764export const RESTART_LINE = '**To load it: /exit, then claude --continue (not /clear)**'
765/** 3.28.2 T16: the first start after an installed release: Claude's first line, so the restart doesn't read as a loop. */
766export const loadedNotice = (version: string): string =>
767  `‹pa› PA3 ${version} was installed and this start loaded it. Say this one line first, before anything else:\n\n` +
768  `**Project Architect ${version} is installed and loaded. Ready to continue work.**\n\nThen carry on as usual.`
769/** 3.29.1 T8: the start hook's note that this session start's update check has answered (pa/hooks/session_start.py
770 * `_start_checked`): `{session, at (s), update}`. */
771export const START_CHECK = '.run/mod/start-check.json'
772/** 3.29.1 T8: how long work waits for that note before going on (a broken hook must not freeze the project). */
773export const START_CHECK_WAIT_MS = 120_000
774/** A note this much older than the mod's session start belongs to an earlier start of the same session (a resume). */
775const START_CHECK_SKEW_MS = 60_000
776/** 3.29.1 T8: this start's check: `pending` (no answer yet), `update` (it started an update run), `done`, or `timeout`. */
777export function startCheckState(text: string | null, sid: string, startAt: number, now: number): 'pending' | 'update' | 'done' | 'timeout' {
778  let doc: unknown = null
779  try {
780    doc = text === null ? null : JSON.parse(text)
781  } catch {
782    doc = null
783  }
784  const o = typeof doc === 'object' && doc !== null ? (doc as Record<string, unknown>) : {}
785  const at = typeof o['at'] === 'number' ? o['at'] * 1000 : 0
786  if (o['session'] === sid && at >= startAt - START_CHECK_SKEW_MS && o['state'] !== 'checking') { // 3.29.2: still checking
787    return o['update'] === true ? 'update' : 'done'
788  }
789  return now - startAt >= START_CHECK_WAIT_MS ? 'timeout' : 'pending'
790}
791/** 3.30.2: this start's update check got no answer from PA3's source in time (its fetch failed or timed out). */
792export function startCheckOffline(text: string | null, sid: string, startAt: number): boolean {
793  let doc: unknown = null
794  try {
795    doc = text === null ? null : JSON.parse(text)
796  } catch {
797    doc = null
798  }
799  const o = typeof doc === 'object' && doc !== null ? (doc as Record<string, unknown>) : {}
800  const at = typeof o['at'] === 'number' ? o['at'] * 1000 : 0
801  return o['session'] === sid && at >= startAt - START_CHECK_SKEW_MS && o['state'] === 'offline'
802}
803/** 3.30.2: said once when this start's update check timed out, so the statusline's vanished line is no mystery. */
804export const OFFLINE_NOTICE = '‹pa› The update check at this start got no answer from PA3\'s source in time, so no update ' +
805  'was looked for. PA3 checks again at the next start; to check now, the developer can run `tools/pa3_update.py` in a ' +
806  'terminal before starting Claude Code. Tell the developer in one line.'
807/** 3.29.1 T9: a task paused on the developer's request, found at a session start once the update check let work go on. */
808export const pausedNotice = (id: string): string =>
809  `‹pa› ${id} was paused on the developer's request before the last session ended, and it has not resumed. Ask them in ` +
810  'one line, such as "The session was paused on your request. Ready to resume?"; on their yes, in any words, call ' +
811  'release({hold: "stopped"}).'
812/** 3.30.1: a paused task's expert handed back (`handoff`): held, never restarted on its own. */
813export const pausedHandbackNotice = (id: string): string =>
814  `‹pa› ${id}'s expert handed back while ${id} is paused on the developer's request: it is held, not restarted. Tell ` +
815  'the developer in one line; when they want it to go on, in any words, call release({hold: "stopped"}).'
816/** 3.3 T7: Claude's line, with the session's first developer message, in a project that has not set up external
817 * coders: '' when pa.json's `external` sets a `coder`, `remind: false` or `use: false` (or pa.json is unreadable). */
818export function externalReminder(paJsonText: string | null): string {
819  let doc: unknown = null
820  try {
821    doc = paJsonText === null ? null : JSON.parse(paJsonText)
822  } catch {
823    return ''
824  }
825  if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
826    return ''
827  }
828  const x = (doc as Record<string, unknown>)['external']
829  const ext = typeof x === 'object' && x !== null && !Array.isArray(x) ? (x as Record<string, unknown>) : {}
830  const coder = ext['coder']
831  if ((coder !== undefined && coder !== null && coder !== '') || ext['remind'] === false || ext['use'] === false) {
832    return ''
833  }
834  return 'PA3: external coders are available (OpenRouter or local models do the coding; experts and you stay on Claude), ' +
835    'and this project has not set them up. Ask the developer once in this reply whether they want them. A yes: load the ' +
836    'pa-external-agents skill. To stop this reminder, set pa.json external.remind to false (PY tools/pa_ext.py set --remind off).'
837}
838/** 3.3 T7: the external-coder setup's machine-level result (`<home>/` + this: `{at (s), ok, text}`), and the project's
839 * note of the last one passed to Claude (`{at}`). */
840export const EXT_STATUS = '.claude/pa3-ext/status.json'
841export const EXT_SEEN = '.run/mod/ext-seen.json'
842const EXT_FRESH_S = 24 * 3_600
843/** A status or seen file's `at` (s); 0 when it does not parse. */
844export function extAt(text: string | null): number {
845  try {
846    const o = (text === null ? null : JSON.parse(text)) as Record<string, unknown> | null
847    return typeof o === 'object' && o !== null && typeof o['at'] === 'number' && Number.isFinite(o['at']) ? o['at'] : 0
848  } catch {
849    return 0
850  }
851}
852/** 3.3 T7: Claude's line on the setup's result, once per project: null unless the status parses, is under 24 h old and
853 * newer than the project's seen note. */
854export function setupNotice(statusText: string | null, seenText: string | null, nowS: number): string | null {
855  const at = extAt(statusText)
856  if (at <= 0 || nowS - at >= EXT_FRESH_S || at <= extAt(seenText)) {
857    return null
858  }
859  const o = JSON.parse(statusText ?? '') as Record<string, unknown>
860  const text = typeof o['text'] === 'string' ? o['text'].trim().replace(/\.+$/, '') : ''
861  return `PA3: the developer ran the external-coder setup: ${text}. If it failed, explain why and the fix from the ` +
862    'pa-external-agents skill\'s troubleshooting table. If it saved, load that skill, lay out the config, run PY ' +
863    'tools/pa_ext.py check, and ask how and when they want external coders used in this project.'
864}
865/** 3.29 T6: the one-time `.run/` cleanup's marker: offered once per project, at its first start on 3.29. */
866export const RUN_REVIEW_MARKER = '.run/mod/run-review.json'
867/** PA3's own `.run/` entries, never older work. */
868const RUN_OWN = new Set(['mod', 'status.json', 'health.json', 'plans', 'PHASE_PLAN.draft.md', 'GENERATION_PLAN.draft.md'])
869/** 3.29 T6: whether `.run/` holds work from before 3.29: an entry that is not PA3's own, or run.sh logs in `.run/logs/`. */
870export const olderRunWork = (names: readonly string[], logsCount: number): boolean =>
871  names.some(n => !RUN_OWN.has(n) && n !== 'logs') || logsCount > 0
872/** 3.29 T6: Claude offers the one-time cleanup (agents/pa-claude.md, "The one-time `.run/` cleanup"). */
873/** 3.29.3: the mod's own picker for the one-time cleanup (the recommended answer first). */
874export const RUN_REVIEW_QUESTION = 'PA3 3.29 added a cleanup step to every phase end: each phase keeps its temp work in ' +
875  '.run/<phase>/, and the phase close clears it. This project\'s .run/ still holds work from before 3.29, which that ' +
876  'step never covers. A one-time cleanup is highly recommended: Claude reviews it folder by folder and shows you what it ' +
877  'would keep, rebuild or delete, with sizes. Nothing is deleted until you say yes. Clean it up?'
878export const RUN_REVIEW_OPTIONS: readonly string[] = ['Clean it up (Recommended)', 'Not now']
879/** 3.29.3: the developer's answer, to Claude (agents/pa-claude.md, "The one-time `.run/` cleanup"). */
880/** 3.29.8: the cleanup's list (Claude writes it), the picker's ask (the mod writes it), the picker. */
881export const CLEANUP_LIST = '.run/mod/cleanup.json'
882export const CLEANUP_ASK = '.run/mod/cleanup-ask.json'
883export const CLEANUP_OPTIONS: readonly string[] = ['Delete them', 'Not now']
884export const cleanupQuestion = (summary: string, checked: string): string =>
885  `Delete the items Claude listed above${summary === '' ? '' : ` (${summary})`}? ${checked}. Only those items are ` +
886  'deleted, each by name; nothing outside .run/ is touched.'
887/** 3.29.7: work waits for the developer's go after the update's after-restart work. */
888const CONTINUE_ASK = ' No work resumes until they say so: ask "Ready to continue work?", and on their yes, in any words, ' +
889  'call release({hold: "session"}), and release({hold: "stopped"}) too when a task is held.'
890export const runReviewNotice = (go: boolean): string => go
891  ? '‹pa› The developer chose the one-time `.run/` cleanup in the mod\'s picker. Do it now, as your instructions say: ' +
892    'review, then show them what you would keep, rebuild or delete; write exactly the items to delete to ' +
893    '.run/mod/cleanup.json and call cleanup({summary}): the mod asks them with its picker and deletes on their yes ' +
894    '(never delete anything yourself). When the cleanup is done (or they stop it), the update\'s after-restart work ' +
895    'is done.' + CONTINUE_ASK
896  : '‹pa› The developer put off the one-time `.run/` cleanup; do nothing about it unless they ask, it is not asked again. ' +
897    'The update\'s after-restart work is done.' + CONTINUE_ASK
898/** 3.30: the one-time cookbook banking of the lessons the 3.2 close stopped promoting: offered once per project with
899 * the mod's picker when its task summaries mark lessons for the cookbook (`tools/task_log.py gotchas`). */
900export const BANK_MARKER = '.run/mod/bank-backlog.json'
901/** 3.30: a task summary's lessons marked for the cookbook, counted as tools/task_log.py's _TAG_RE finds them. */
902export const generalizableCount = (text: string): number =>
903  text.split(/\r?\n/).filter(l => /^\s*(?:-\s*)?(?:[A-Za-z][A-Za-z ]*:\s*)?generalizable:\s*\S/.test(l)).length
904export const BANK_OPTIONS: readonly string[] = ['Add the banking task (Recommended)', 'Not now']
905export const bankQuestion = (all: number, current: number): string =>
906  'PA3 3.30 brings back cookbook banking: each expert now banks what a coder learned before it starts the next one ' +
907  `(the step was lost in PA3 3.2). This project's task summaries mark ${String(all)} lesson${all === 1 ? '' : 's'} ` +
908  `for the cookbook (${String(current)} in the current phase), and some were never banked. A one-time task can bank ` +
909  "the missing ones by this project's own cookbook guidelines. Add it to the plan?"
910/** 3.30: the one-time banking task as bare plan_edit.py args, placed after `after` (the running or last done task). */
911export const bankTask = (after: string): string =>
912  'add-task --title "Bank the waiting cookbook lessons (one time, PA3 3.30)" --done-when "every lesson a task summary ' +
913  'marks generalizable is banked in the cookbook or ruled out, by the project cookbook guidelines" --coder none' +
914  (after === '' ? '' : ` --after ${after}`)
915export const bankNotice = (go: boolean, after = ''): string => go
916  ? '‹pa› The developer chose the one-time cookbook banking in the mod\'s picker. Add the task now with plan_edit, ' +
917    `command \`${bankTask(after)}\`, why "PA3 3.30: bank the lessons the 3.2 close stopped promoting"; it runs at the ` +
918    'next task boundary. Tell the developer in one line.'
919  : '‹pa› The developer put off the one-time cookbook banking; do nothing about it unless they ask, it is not asked again.'
920/** loop.json `release`: the check's release, asked once per version (`asked` the picker's request id); `step` asked →
921 * install (the developer's yes) → running (pa3_update.py started) → done, or later (not now, or no answer). */
922export type Release = { version: string; kind: string; asked: string; step: 'asked' | 'install' | 'running' | 'later' | 'done' }
923export const RELEASE_OPTIONS: readonly string[] = ['Install after the current task', 'Not now (I\'m away or busy)']
924
925/** loop.json's release record, null none. */
926export function releaseOf(s: LoopState): Release | null {
927  const v = (s as LoopState & { release?: unknown }).release
928  const o = typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : null
929  const step = str(o?.['step'])
930  return o === null || str(o['version']) === '' || !['asked', 'install', 'running', 'later', 'done'].includes(step)
931    ? null
932    : { version: str(o['version']), kind: str(o['kind']) || 'hotfix', asked: str(o['asked']), step: step as Release['step'] }
933}
934
935/** The release picker's question: the release, its title, what "Not now" does. */
936export function releaseQuestion(version: string, kind: string, title: string): string {
937  return `Project Architect ${version}, a ${kind}${title === '' ? '' : ` (${title})`}, is out. Install it? Tasks keep ` +
938    'running while you decide.\nNot now: when you\'re back at this machine, just tell Claude you\'re back and want the ' +
939    'update — it installs after the current task. Or restart (/exit, then claude --continue) and it installs on the way in.'
940}
941
942/** The check's line to Claude, once, with the picker. */
943export function releaseNotice(version: string, kind: string): string {
944  return `‹pa› PA3 ${version}, a ${kind}, is out; the developer is asked with a picker whether to install it after the ` +
945    'current task. Tasks keep running; no answer means not now. Tell the developer this once, in one line. Whenever ' +
946    'they say, in any words, that they want the update now, call release({hold: "update"}).'
947}
948/** 3.28.5 T18: the chosen release's run started (once per install): Claude says so in one line. */
949export const installingNotice = (version: string): string =>
950  `‹pa› the install of PA3 ${version} has started. Tell the developer this one line now:\n\n` +
951  `Installing Project Architect ${version} now; please wait, I'll tell you when it's done.`
952
953// 3.24 T6: the update run's marker and log (tools/pa3_update.py writes both; pa/health.py writes the running marker of a
954// run the session-start hook starts). The mod starts no task while the marker says running and reports a finished run once.
955export const UPDATE_MARKER = '.run/mod/update.json'
956export const UPDATE_LOG = '.run/mod/update.log'
957export const UPGRADE_MD = '.claude/pa3-upgrade/UPGRADE.md'
958/** A running marker this old is stale (pa/health.py RUN_STALE_S, pa3_update.py's lock: 20 min). */
959export const RUN_STALE_MS = 20 * 60_000
960/** A finished run first seen this long after it ended is not reported (a marker older than this loop.json's record). */
961export const RUN_REPORT_MS = 24 * 3_600_000
962/** Conflict rounds (UPGRADE.md's expert, then the rerun) before the conflicts go to Claude instead. */
963export const UPGRADE_ROUNDS = 2
964export type RunRecord = { phase: string; git: string }
965export type UpdateMarker = { state: 'running' | 'done'; mode: string; rc: number; atMs: number; from: RunRecord; to: RunRecord }
966
967const recordOf = (v: unknown): RunRecord => {
968  const o = typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {}
969  return { phase: str(o['phase']), git: str(o['git']) }
970}
971
972/** The run marker (`at` in seconds, as Python writes it), null when absent or malformed. */
973export function markerOf(text: string | null): UpdateMarker | null {
974  let o: Record<string, unknown>
975  try {
976    const v: unknown = JSON.parse(text ?? '')
977    o = typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
978  } catch {
979    return null
980  }
981  const state = o['state'] === 'running' || o['state'] === 'done' ? o['state'] : null
982  if (state === null || num(o['at']) === 0) {
983    return null
984  }
985  return { state, mode: str(o['mode']) || 'update', rc: num(o['rc']), atMs: Math.round(num(o['at']) * 1000), from: recordOf(o['from']), to: recordOf(o['to']) }
986}
987
988export const runRunning = (m: UpdateMarker | null, now: number): boolean =>
989  m !== null && m.state === 'running' && now - m.atMs < RUN_STALE_MS
990/** A finished run not yet reported: newer than loop.json `updateSeenAt` and recent. */
991export const runToReport = (m: UpdateMarker | null, seenAt: number, now: number): boolean =>
992  m !== null && m.state === 'done' && m.atMs > seenAt && now - m.atMs < RUN_REPORT_MS
993
994/** What a run's whole output says: conflicts staged (managed.py `! <rel> kept yours; upstream staged`) and the
995 * installer's `restart: needed` (both indented note lines). */
996export function updateOutcome(log: string): { conflicts: boolean; restart: boolean } {
997  return { conflicts: /^\s*! \S.* kept yours; upstream staged\s*$/m.test(log), restart: /^\s*restart: needed\b/m.test(log) }
998}
999
1000const recordText = (r: RunRecord): string => (r.phase || '?') + (r.git === '' ? '' : ` (${r.git.slice(0, 8)})`)
1001
1002/** The finished run's notice to Claude: from X to Y and where the changes are, else the failure; a restart; conflicts. */
1003export function updateDoneNotice(m: UpdateMarker, o: { conflicts: boolean; restart: boolean; loaded?: boolean }, pa3: string, tail: string): string {
1004  const what = m.mode === 'repair' ? 'reinstall' : 'update'
1005  if (m.rc !== 0) {
1006    return `‹pa› the PA3 ${what} failed (exit ${String(m.rc)}); its whole output is in ${UPDATE_LOG}. Its end:\n${tail}\nTell the developer in plain words what failed.`
1007  }
1008  const head = m.mode === 'repair'
1009    ? `‹pa› the PA3 reinstall finished: the installed release is restored (the project at ${recordText(m.to)}). Tell the developer in plain words.`
1010    : m.from.phase === m.to.phase && m.from.git === m.to.git
1011      ? `‹pa› the PA3 update finished; the project stays at ${recordText(m.to)}. Tell the developer once.`
1012      : `‹pa› PA3 updated from ${recordText(m.from)} to ${recordText(m.to)}. Read ${pa3}/CHANGES.md for the entries after ${m.from.phase || 'the old version'} up to ${m.to.phase || 'the new one'} and tell the developer in plain words what changed.` +
1013        // 3.28.1 T14: the mod and the agents load at a session start; /clear starts none, /reload-plugins skips the agents
1014        // 3.28.2 T16: the restart on its own bold line after a blank line, so it stands out
1015        // 3.28.2 T17: a restart already loaded the new mod before the run ended: the loaded line, no restart
1016        (o.loaded === true
1017          ? ` This session already runs ${m.to.phase} (a restart loaded it before the run ended): no restart for it. Then say this one line:\n\n**Project Architect ${m.to.phase} is installed and loaded. Ready to continue work.**\n\nThen carry on as usual.`
1018          : ` Then tell them once, in this shape: the line "Project Architect ${m.to.phase}, a ${m.to.phase.split('.').length >= 3 ? 'hotfix' : 'feature update'} (<the title of its CHANGES.md heading>), is installed.", a blank line, then on its own line:\n\n${RESTART_LINE}\n\nStart no work until then: if they ask for work, say that bold line again.`)
1019  return head +
1020    (o.restart ? ' A setting changed that takes effect only once Claude Code restarts (restart: needed): tell the developer to restart it.' : '') +
1021    (o.conflicts ? ` Some files the developer edited also changed in PA3 (${UPGRADE_MD}): an expert resolves them at the next boundary, then the project step runs once more.` : '')
1022}
1023
1024/** Said once when the mod's own files changed under a running session (3.24 T2 row P11i: no hot reload). */
1025export const RELOAD_NOTICE = `‹pa› the PA3 mod's own files changed in this update: the new mod and agents load at the next start of Claude Code (${RESTART}). No task starts until then; tell the developer once, with the update.`
1026
1027/** The conflicts' notice when the rounds are spent. */
1028export const upgradeLeftNotice = (rounds: number): string =>
1029  `‹pa› PA3 update conflicts remain in ${UPGRADE_MD} after ${String(rounds)} rounds; tell the developer: each line wants \`| resolve: keep|upstream|merged: <path>\`, then \`update pa3\` applies them.`
1030
1031/** The `mod/` entries of a root MANIFEST.json (`files: {rel: sha256}`) as one comparable key; '' when unreadable. */
1032export function modFilesKey(text: string | null): string {
1033  try {
1034    const v: unknown = JSON.parse(text ?? '')
1035    const files = typeof v === 'object' && v !== null ? (v as Record<string, unknown>)['files'] : undefined
1036    if (typeof files !== 'object' || files === null) {
1037      return ''
1038    }
1039    return JSON.stringify(Object.entries(files as Record<string, unknown>).filter(([k]) => k.startsWith('mod/')).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
1040  } catch {
1041    return ''
1042  }
1043}
1044
1045/** loop.json's conflict task (3.24 T6): its expert, spawn time and round; null none. */
1046export type UpgradeTask = { agentId: string; at: number; round: number }
1047export function upgradeOf(s: LoopState): UpgradeTask | null {
1048  const v = (s as LoopState & { upgrade?: unknown }).upgrade
1049  const o = typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : null
1050  return o === null ? null : { agentId: str(o['agentId']), at: num(o['at']), round: num(o['round']) }
1051}
1052export const updateSeenOf = (s: LoopState): number => num((s as LoopState & { updateSeenAt?: unknown }).updateSeenAt)
1053
1054export type TaskSummary = { title: string; summary: string }
1055
1056/** loop.json's kept SUMMARY lines (3.23 T13b), by task id in capture order; malformed entries dropped. */
1057export function summariesOf(s: LoopState): Record<string, TaskSummary> {
1058  const v = (s as LoopState & { summaries?: unknown }).summaries
1059  const o = typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
1060  const out: Record<string, TaskSummary> = {}
1061  for (const [id, x] of Object.entries(o)) {
1062    const r = typeof x === 'object' && x !== null ? (x as Record<string, unknown>) : {}
1063    if (str(r['summary']).trim() !== '') {
1064      out[id] = { title: str(r['title']), summary: str(r['summary']).trim() }
1065    }
1066  }
1067  return out
1068}
1069
1070/** `s` with task `t`'s contract SUMMARY kept (a later capture of the same task replaces it); `s` when it has none. */
1071export function keepSummary(s: LoopState, t: LoopTask, contract: string | null): LoopState {
1072  const summary = (parseExpertContract(contract ?? '')?.summary ?? '').trim()
1073  return summary === '' ? s : { ...s, summaries: { ...summariesOf(s), [t.id]: { title: t.title, summary } } }
1074}
1075
1076/** 3.30: an expert's `EXT-HEALTH:` proposals in its report (what later external coders need, from a finished one's
1077 * HEALTH lines), each without the prefix; [] when none. */
1078export function extHealthOf(contract: string): string[] {
1079  return contract.split(/\r?\n/).map(l => l.trim()).filter(l => /^EXT-HEALTH:/i.test(l))
1080    .map(l => l.replace(/^EXT-HEALTH:\s*/i, '')).filter(l => l !== '')
1081}
1082
1083/** 3.30: the notice that takes an expert's EXT-HEALTH proposals to Claude, for the developer's word. */
1084export const extHealthNotice = (task: string, proposals: readonly string[]): string =>
1085  [`‹pa› ${task}'s expert proposes changes for later external coders. Tell the developer each in plain words with its ` +
1086    'evidence and apply only the ones they approve (the pa-external-agents skill, "Learning from each run"):',
1087  ...proposals.map(p => `- ${p}`)].join('\n')
1088
1089// The phase close (3.23 T6b): the close question, the chain's steps in order (chainStep names the next), the recap.
1090// T13b: the notice carries each finished task's summary; Claude's card says how to report it. 3.25 T2: the yes carries
1091// Claude's report as release's note (the mod writes it to RECAP.md); `gen`, the generation a generation-end close ends.
1092// 3.30: `ext`, the external coder runs since the last external review (`pa_ext.py runs --count`): after the close, the
1093// auditor reviews them.
1094export const closeNotice = (phase: string, sums: Record<string, TaskSummary> = {}, gen = '', ext = 0): string => {
1095  const lines = Object.entries(sums).map(([id, x]) => `${id} ${x.title}: ${x.summary.replace(/\s*\r?\n\s*/g, ' ')}`)
1096  return `‹pa› Plan end: phase ${phase} has no task left.` +
1097    (lines.length === 0 ? ' No task left a summary.' : ` Each task's summary:\n${lines.join('\n')}\n`) +
1098    ' Report the phase to the developer, then ask "Phase ' + phase + ': every task is done. Is the milestone met: does' +
1099    ' what the phase built run?" with the answers "Yes, close the phase (recommended)" and "Not yet: <what is missing>",' +
1100    ' and wait. What is missing goes through plan_edit; on yes, call release({hold: "close", note: "<your report>"}).' +
1101    (gen === '' ? '' : ` This close also ends Generation ${gen}, so the report covers the whole generation.`) +
1102    (ext > 0 ? ` ${String(ext)} external coder run${ext === 1 ? '' : 's'} finished since the last external review: once` +
1103      ' the close is released, start the auditor in the background with `EXTERNAL REVIEW · PHASE ' + phase + '` and bring' +
1104      ' its proposals to the developer (the pa-external-agents skill, "Learning from each run").' : '')
1105}
1106// 3.23 T13d.c4: the archived line, one feed record (3.25 T2: the mod's own; no recap turn).
1107// The generation end parks the loop in `held(generation)` (T13b.c3); its sentence says the next one is the developer's.
1108export const closedLine = (phase: string, genEnd = false): string =>
1109  `Phase ${phase} closed. Push when ready.` + (genEnd ? ' The generation is complete; the next one is yours to start.' : '')
1110export const CHAIN_STEPS: readonly string[] = ['recalc', 'assemble', 'commit', 'archive', 'run', 'archived', 'clear', 'recap'] // 3.25 T2: no lint
1111
1112/** The plan's first line `# Phase <N> — … (implements … <G>)`: the phase id and its generation phase ('' unknown). */
1113export function planPhase(text: string): { phase: string; gen: string } {
1114  const head = (text.split(/\r?\n/)[0] ?? '').trim()
1115  const m = /^#\s*Phase\s+(\S+)\s*[-—]+\s*(.*?)\s*(?:\(implements(.*)\))?$/.exec(head)
1116  const g = m?.[3] === undefined ? null : /([0-9]+(?:\.[0-9]+)*)/.exec(m[3])
1117  return { phase: m?.[1] ?? '', gen: g?.[1] ?? '' }
1118}
1119
1120/** The phase plan's task rows, counted as the status line counts them (pa/statusline.py scan_phase_plan): a row
1121 * `- T<digits/dots> | <status> | …` (plan_edit TASK_RE); `superseded` is not in the total, `done` is done (3.23 T13d.c2). */
1122export function planCounts(text: string): { rows: number; done: number; total: number } {
1123  const out = { rows: 0, done: 0, total: 0 }
1124  for (const raw of text.split(/\r?\n/)) {
1125    const line = raw.trim()
1126    if (!line.startsWith('- T') || !line.includes('|')) {
1127      continue
1128    }
1129    const fields = line.split('|')
1130    const id = (fields[0] ?? '').trim().slice(2).trim()
1131    if (!/^T[0-9]+(\.[0-9]+)*$/.test(id)) { // plan_edit TASK_RE: `T<digits/dots> |`, never a Context bullet (3.24 T13f)
1132      continue
1133    }
1134    const status = (fields[1] ?? '').trim().toLowerCase()
1135    out.rows += 1
1136    out.total += status === 'superseded' ? 0 : 1
1137    out.done += status === 'done' ? 1 : 0
1138  }
1139  return out
1140}
1141
1142/** The project's name for the session title: pa.json's `"project"`, else the root folder's name. */
1143export function projectName(paJson: string | null, root: string): string {
1144  try {
1145    const p = paJson === null ? null : (JSON.parse(paJson) as Record<string, unknown>)['project']
1146    if (typeof p === 'string' && p.trim() !== '') {
1147      return p.trim()
1148    }
1149  } catch {
1150    // not JSON: the folder's name
1151  }
1152  return root.split(String.fromCharCode(92)).join('/').split('/').filter(x => x !== '').pop() ?? '' // the loader's parser trips on a backslash regex
1153}
1154
1155/** The session title (3.23 T13d.c2), the status line header's form: `<project> · Phase <id> · <done>/<total>`;
1156 * no count when every row is superseded. With no open phase or no task rows (3.23 T13d.c6a): `<project> · Phase <next>`,
1157 * the next phase GENERATION_PLAN.md names (nextPhaseOf); `<project>` alone when it names none. */
1158export function sessionTitle(project: string, planText: string, genText = ''): string {
1159  const phase = planPhase(planText).phase
1160  const c = planCounts(planText)
1161  if (phase === '' || c.rows === 0) {
1162    const next = nextPhaseOf(genText)
1163    return next === '' ? project : `${project} · Phase ${next}`
1164  }
1165  return [project, `Phase ${phase}`, ...(c.total > 0 ? [`${String(c.done)}/${String(c.total)}`] : [])].filter(x => x !== '').join(' · ')
1166}
1167
1168/** The next phase of GENERATION_PLAN.md's `## Phases` (3.23 T13d.c6a): the first row not `status: closed`; every row
1169 * closed (a generation end), the next generation's first phase `<generation + 1>.1`; '' with no phase rows. */
1170export function nextPhaseOf(genText: string): string {
1171  let inPhases = false
1172  let top = 0
1173  for (const raw of genText.split(/\r?\n/)) {
1174    if (/^##\s/.test(raw)) {
1175      inPhases = /^##\s+Phases\s*$/.test(raw.trim())
1176      continue
1177    }
1178    const m = inPhases ? /^\s*-\s+([0-9]+)((?:\.[0-9]+)*)\s/.exec(raw) : null
1179    if (m === null) {
1180      continue
1181    }
1182    if (!/status:\s*closed(\s|$)/.test(raw)) {
1183      return `${m[1] ?? ''}${m[2] ?? ''}`
1184    }
1185    top = Math.max(top, Number(m[1]))
1186  }
1187  return top === 0 ? '' : `${String(top + 1)}.1`
1188}
1189
1190/** loop.json's `phaseSids` (3.24 T10b: every session that owned the open phase) with `sid` appended once; tolerant. */
1191export function withPhaseSid(v: unknown, sid: string): string[] {
1192  const a = Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x !== '') : []
1193  return sid === '' || a.includes(sid) ? a : [...a, sid]
1194}
1195
1196/** The sessions the close's ledger recalc reads (3.24 T10b): loop.json's `phaseSids`, every session that owned the
1197 * phase (a `/clear`, a restart); else 3.23 T13d.c6a's one: `ownerSid`, else status.json's Gen 3 `router_session`, else
1198 * the router's own session; [] none. */
1199export function recalcSessions(phaseSids: unknown, ownerSid: string, routerSession: string, sid: string): string[] {
1200  const all = withPhaseSid(phaseSids, '')