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

<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.
<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.
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.
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./exit, then claude). Or double-click setup-project.cmd and pick the folder. It makes one commit and never pushes.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.
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
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:
| Vanilla | Project Architect | |
|---|---|---|
| Average context per request | 459k to 547k tokens | 47k to 69k tokens |
| Context when the work starts | 100k to 300k tokens | about 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.
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.
See LICENSE.
hooks/register.tsx 2420 lines1// 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 lines1// 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}
116hooks/autopilot.tsx 3067 lines1// 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 lines1// 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: '' }
6hooks/card.ts 345 lines1// 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}
345hooks/contract.ts 290 lines1// 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}
290hooks/debug.ts 51 lines1// 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}
51hooks/inbox.ts 200 lines1// 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}
200hooks/journal.ts 251 lines1// 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}
251hooks/digest.ts 134 lines1// 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}
134hooks/feed.ts 508 lines1// 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}
508hooks/loop.ts 1234 lines1// 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, '')