SLOPSHOPPER

doctrine

An execution posture for substantial agent work: parallel agents in phases and waves, adversarial red-teaming, looping until confident, ruthless simplicity…

newbandtimer
v2.17.0MITupdated 2026-10-10scottcrosby-securebine/doctrine-skills
A shopper browsing a rack in a slop shop
README

Doctrine Skills

Fourteen skills for Claude Code that put an agent's work through a real gate: every check your project documents, an adversary that did not write the code, and a loop that does not exit until one full pass comes back clean.

The problem

You already get good work out of Claude Code by pushing on it: check that again, did the linter run, are you sure. That works, and you do it differently every time, so what you get back tracks how much attention you had that day. The failures that survive it do not look like failures.

  • The tests pass, the linter is clean, the build is green, and the code is wrong.
  • The context that wrote the code reviews it and finds it reasonable.
  • One defect gets fixed in the one place you were looking at.
  • The fix creates the next bug, and the round that would have found it is over.
  • A long run gets compacted, and the list of what still fails goes with it.

What the doctrine does

Seven rules, applied to every job the hub and its eight wrappers run. Rules 3 to 6 are a loop rather than a sequence, which is the point of rule 6.

  1. Ask first. Questions reach you before work is aimed, and your answers go verbatim to every agent that later judges the result.
  2. Split the work into phases with checkable exit gates, and run independent work in parallel.
  3. Run every check your project documents, not the ones an agent thought of. A check that could not run is recorded as not run, never as clean.
  4. Send an adversary: a reviewer that did not write the code, a different model if you have one installed. Its findings are verified against source first, because adversaries invent things too. Without a second model the run says so, rather than reporting a stronger gate than it ran.
  5. Ask where else. Every blocking finding that survives verification gets one question: what else the same mistake would have touched. The fix goes to the cause, not just the place it showed up.
  6. Loop until one full pass comes back clean with no blocking finding left over from an earlier pass. Two alarms stop the loop and put the decision to you rather than spending your budget without telling you.
  7. Cut what nobody asked for before the pass that certifies the work, so the cut is inside what that pass checked, then deliver by whatever route your project normally ships work, and say whether what shipped is what the clean pass certified.

What it touches. It edits your working tree, the way Claude Code already does. It commits the way your repo does, and where your repo documents no norm the coding workflow commits locally and asks. It pushes or opens a PR only if that is your documented norm or you asked for one. Where it cannot write, it hands you the diff instead. A phase writes its state to a file as it goes, so a run that is interrupted or compacted resumes from disk rather than from whatever the conversation still remembers.

What that catches

Three defects from real work. In each pair the first block is the defect and the second is the fix. The code is real and the identifiers are renamed: no client, product, person or repository is named, so these are mnemonics for the rule beside them, not citations you can follow.

Every check was green

const pairs = (o: Record<string, unknown>) =>
  Object.entries(o).map(([key, value]) => `${words(key)}: ${value === null ? '' : String(value)}`)
const show = (value: unknown) =>
  value === null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)

String(x) throws when x is an object whose own toString is not callable, and custom field names are user-supplied. Name a field toString, open that record's history, and the page dies. It dies again on every reopen, because the event is stored.

At that moment: 837 backend tests green, 492 frontend tests green, a clean build, linters green apart from one pre-existing error the change did not cause, and a reviewer that read the whole 2,283-line diff end to end and returned nothing blocking. A different-model adversary read it and also returned nothing. The same adversary, run again on that unchanged revision with no memory of the first pass, found it.

One finding was a class

const globalItem = poolItems.find(g => g.id === item.itemId);
const defaultValue = globalItem?.value ?? 0;
onOverride(item.itemId, defaultValue, item.calcType);
const globalItem = poolItems.find((g) => g.id === itemId);
if (!globalItem) {
  setError('Re-include is unavailable: the pool could not be read. Reload and try again.');
  return;
}
const value = globalItem.value;

?? 0 is the defensive default a linter and a reviewer both read as correct null handling. It is wrong here because this zero is written to the database as a price: when a background reload failed, clicking "Re-include" on a $5,000 benefit saved it as $0, silently.

The different-model adversary found one button. Rule 5 asked what else could reach the same code, and the answer was five other handlers that already did, so the fix went into the shared function they all call rather than into the button the round happened to be looking at.

The fix was where the next bug lived

labels: list[str] = []
seen = {normalize(name) for name in header}
for label in custom_labels:
    if normalize(label) not in seen:
        seen.add(normalize(label))
        labels.append(label)
levels = {normalize(name) for name in LEVEL_COLUMNS}
labels = [label for label in custom_labels if normalize(label) not in levels]

De-duplicating a header against every name already in it is textbook correct, and it fixed the bug that round had reported. But the importer resolves custom labels above ordinary field names, so dropping the duplicate leaves the wrong column standing: export the catalog, import the same file back unedited, and the import silently overwrites a field on every product with an unrelated catalog value.

The first block is not the original code. It is the previous round's repair. Before it, the importer rejected that file outright and named the duplicate header, so the repair replaced a loud refusal with a silent write across the catalog. The next round's different-model adversary caught it by running the real exporter and importer rather than reading them.

How this differs from looping

Running an agent in a loop is not new and this does not claim it. Geoffrey Huntley's Ralph is an unconditional bash loop that pipes a prompt file into the agent over and over. Each pass gets a fresh context and progress lives on disk, a design this borrows rather than improves on.

What differs is what ends the loop.

Ralph's published loop has no exit condition, and the post documents no stopping rule. Clayton Farr's Ralph playbook adds one, an optional iteration cap. Anthropic's own Ralph plugin adds two, an iteration counter or a phrase the working agent emits about itself, and its README says to rely on the counter rather than the phrase.

Here the loop cannot reach a certified exit until a reviewer that did not write the work passes it, on a revision that has actually run, with no blocking findings left over from any earlier pass. It can still end other ways, and the report names which: stopped by you, shipped at an alarm with findings open, or closed with a punch list of what it did not fix. What it never does is present an uncertified ending as a clean one.

Independent review and gating are both documented practice rather than inventions here. Anthropic's own docs describe an adversarial reviewer in a fresh context, a stop hook that blocks a turn until a check passes, and a separate evaluator that keeps working until a goal resolves. What this adds is the combination: the reviewer is the gate, and the gate's conditions include the real run and the findings carried over from before.

Those conditions are also what lets a long run leave you alone. It stops at its own thresholds and comes back to you there, rather than needing you to watch for the end.

Scope. Your words are written down before any work is aimed and handed to every agent that later judges the result, and delivery walks your original request item by item.

Where Ralph is better. Greenfield. Huntley: "There's no way in heck would I use Ralph in an existing code base though, if you try, I'd be interested in hearing what your outcomes are. This works best as a technique for bootstrapping Greenfield, with the expectation you'll get 90% done with it." That is the opposite end of the problem from this one.

One number worth carrying, from Addy Osmani's case for adversarial review: across four review tools on 146 real pull requests, 93.4% of flagged locations were caught by exactly one tool, and none by all four. He draws the conclusion this page draws, that heterogeneity is the point, and attaches a limit this page keeps too: measure it on your own code, because each of those results was specific to a codebase.

Install

In Claude Code:

/plugin marketplace add scottcrosby-securebine/doctrine-skills
/plugin install doctrine@doctrine-skills

If the install message says to, run /reload-plugins or restart. A freshly installed plugin does not always load into the session that installed it.

In Codex CLI:

codex plugin marketplace add scottcrosby-securebine/doctrine-skills
codex plugin add doctrine@doctrine-skills

On Codex the hub reads a Codex reference file for its tools, and runs its red team through Claude. Codex loads no plugin hooks, so the seat panes, the restore after /clear, the context gauge and auto-cycle need one install command, run again after each plugin update:

node ~/.codex/plugins/cache/doctrine-skills/doctrine/<version>/hooks/dctr-codex.mjs install

It writes the hook entries into ~/.codex/hooks.json (or $CODEX_HOME's) and trusts them in config.toml. Where sandbox_workspace_write.network_access is unset it sets it to true, so commands in the workspace-write sandbox can reach herdr, and where agents.max_concurrent_threads_per_session is unset it sets it to 8, so Codex does not refuse a seat past its default limit. A value you set for either is left as it is, comment and all, and the install prints a line saying so. Where either setting's table is written inline (agents = { ... }) or with dotted keys and the setting is unset, it leaves that line as it is and prints how to add the setting by hand. doctrine-pane needs the same install on Codex.

Codex has no setting that turns automatic compaction off. model_auto_compact_token_limit in config.toml can only make it come earlier, and by default Codex compacts at 90% of the model's context window. So for auto-cycle on Codex, set the tier on the record's auto-cycle: on cap <n> tier <tokens or percent> line low enough that the gauge warns, and the handoff that follows the warning finishes, before that point.

Codex fires no hook for a session left idle, a turn ended by an API error, or the exit of a background terminal, so while auto-cycle is on each prompt starts a detached watcher, hooks/dctr-watch.mjs, that follows the turn and hands the auto-cycle hook the event Claude Code would have fired. Idle is a turn whose end is in the session's rollout with no error, no background work and a last message that is not the ready line, and, where the session runs in a herdr pane, herdr reporting that pane idle or done, for 60 seconds. An API error is the error the turn's end carries in the rollout, and a turn whose rollout has not grown for 15 minutes while it is unfinished is handed on as one. A running background terminal is a live process under the session's Codex process whose environment carries CODEX_SESSION_ID, read from /proc. The Stop reads it too and waits while one runs, and the watcher hands the Stop back once the work holding it has finished. A process whose environment cannot be read counts as running, and when that holds a Stop back for 60 seconds the record gets a paused line saying the doctrine could not tell.

After a Codex, Claude Code or herdr update, run node <plugin-root>/hooks/dctr-doctor.mjs, with --host codex or --host claude to check one. It drives each installed host in scratch homes under its own herdr server, never writes ~/.codex or ~/.claude, and prints a row for each host signal the hooks depend on, the areas its drive does not exercise, and a verdict. Exit 0 means every signal it observed holds; a signal the run could not observe prints unobs on its row and the verdict names it. Exit 1 means drift, with the row naming each drifted signal, and 2 means it could not run, with the message saying why. A run takes a minute or two and two cheap model turns per host.

Try it on one file

Do not start with a big audit. Point it at one file you suspect: use doctrine-audit on src/whatever.ts (scope is that file only). It asks what counts as a bug before changing anything, runs whatever your repo has written down as a gate, and puts the verdict at the top of its report, ahead of the work:

Gate: clean pass on 4a3e4ca, real run on record. No blockers open.
Gate: shipped at an escalation, round 6, zero clean passes. Open: 2 blocking findings.

Those are two of the forms. A run can also stop for your decision, end blocked on a question you have not answered, or close a prose job with a list of what it did not fix. The wording varies. What the line always does is distinguish a clean pass from an ending without one, and name what is still open.

The fourteen skills

SkillUse it for
doctrineThe shared posture. The eight wrappers invoke it.
doctrine-codeFeatures, specs and tickets.
doctrine-debugAnything broken, throwing, failing or slow.
doctrine-auditBug hunts and deep code audits.
doctrine-docsDocumentation sweeps.
doctrine-writeProposals, briefs, PRDs, reports.
doctrine-researchMulti-source questions needing a fact-checked answer.
doctrine-gauntletWeb design, judged on the rendered page.
doctrine-projectStarting or adopting a repo's project and epic tracking, with a checkable meaning of done.
doctrine-paneAn interactive terminal session in a pane you can take over. Not a wrapper: it does not load the posture.
doctrine-backupSaving the session's memory file for the next session, red-teamed before it is done.
doctrine-handoffA detailed handoff under docs/handoffs/, tied to the plan, so nothing is lost across a cleared session.
doctrine-resumePicking up where the last session left off: state, handoff, drift.
doctrine-primerA cold start: everything doctrine-resume restores, plus a tour of the project.

What it costs

More time and more tokens than a single pass, so point it at work where being wrong is expensive. No mode skips the gate, though doctrine-gauntlet asks which of its two modes you want and the hub proposes a gate, with the reach evidence behind it, for every phase whose exit it sets, and you choose that gate or the full one. No token figures appear here: the runs behind this page did not measure them, and an unsourced number is what this page argues against.

Built on other people's work

These are the disciplines the loop runs on, each published by someone who does this work. The doctrine invokes them by name at runtime rather than copying them, so their updates flow through. All optional, each with a fallback: Matt Pocock's engineering skills for the build and review disciplines; superpowers, by Jesse Vincent and Prime Radiant, for parallel dispatch and worktree isolation; ponytail, by Dietrich Gebert, for the pass that deletes what nobody asked for; writing-clearly-and-concisely, Strunk's rules as a skill, by Josh Thomas via softaworks; OpenAI's codex plugin for the different-model red team; and the gauntlet loop by Matt Shumer, whose method doctrine-gauntlet builds on. One exception to invoking by name: you install Matt Pocock's code-review yourself as a renamed matts-code-review copy, because the original name collides with Claude Code's own /code-review. This plugin does not bundle it.

Requirements

Twelve of the fourteen skills need nothing installed. doctrine-gauntlet judges rendered pages, so it needs a browser, and without one its harness refuses to run rather than report a pass. doctrine-pane needs herdr 0.8.2 or later, and outside herdr it refuses. Neither degrades into a weaker version of itself.

Optional, each with a fallback: Matt Pocock's engineering skills, superpowers, the OpenAI codex plugin on Claude Code, the Claude Code CLI (claude, signed in) on Codex, ponytail, writing-clearly-and-concisely, Claude Code's deep-research workflow, Claude Code's Workflow tool, Claude Design, and herdr for everything other than doctrine-pane and auto-cycle, below. docs/requirements.md has the install commands and says what happens when each is missing.

Auto-cycle is off by default and switched on per phase in its record. Using it needs these set up once: herdr, with the session running in a herdr pane and herdr's Claude integration installed (herdr integration install claude); auto-compaction off, with claude config set -g autoCompactEnabled false (a global config key in ~/.claude.json, not settings.json), since the phase resets by /clear and a compaction mid-phase discards the session's context and the gauge's reading; and the statusline bridge, installed with node <plugin-root>/hooks/dctr-bridge.mjs install and re-run after every plugin update. docs/requirements.md says what each one does, and docs/watching-a-run.md gives the $autocycle sidebar row that shows whether it is cycling or paused.

Watching it work

A doctrine run dispatches a lot of subagents and Claude Code shows you none of them: the terminal goes quiet and an answer appears some minutes later. Run it inside herdr and every subagent gets a live pane you can read while it works. Optional, and nothing here needs it: docs/watching-a-run.md.

On Claude Code the plugin also draws the doctrine band, one line above the prompt, while the record your session's kickoff reaches is Open or Blocked: the phase, its state, the last round, the round alarm count, how many subagents are out, and what is waiting on you. Green is a live phase with nothing owed, bold magenta is owed to you, cyan is subagents out, and grey is labels and counts. It reads the record every five seconds and draws nothing in a session with no live phase. The Doctrine band row in /config turns it off. Codex has no band.

Limits

These rules came out of real work, and the failures behind them are ones this author hit. There is no third-party benchmark, and the three examples are anonymized, so you cannot check them yourself. Defects found and not yet fixed are in docs/known-issues.md.

The specification is skills/doctrine/SKILL.md, the file the agent actually loads, so trust it over this page wherever the two disagree. How the work is checked, and what those checks cannot reach, are in docs/how-this-is-tested.md.

License

MIT

Source 3 files
hooks/band/register.tsx 92 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { bandParts, followChain } from './band'
4import type { Summary, Tone } from './band'
5
6// The doctrine band: one line above the prompt naming a live doctrine phase's state, read from the record the
7// session's kickoff chain reaches. Paths are absolute, built from the session's own directory, because a relative
8// path read for a seat resolves inside the seat's worktree. It draws nothing when the chain reaches no Open or
9// Blocked record, hooks no tool call, and calls no herdr. The `band` config row set off registers nothing (E11-D13).
10const COLOR: Record<Tone, string | undefined> = { ok: 'green', owed: 'magenta', flight: 'cyan', quiet: undefined, plain: undefined }
11const OUT = new Set(['pending', 'running', 'waiting'])
12// The record changes when the orchestrator writes a line, which is no engine event, so the band polls.
13const POLL_MS = 5000
14
15let root: string | null = null
16let shown: Summary | null = null
17let fingerprint = 'null'
18let busy = false
19
20async function readOrNull($: EngineInterface, file: string): Promise<string | null> {
21  try {
22    return await $.fs.read(file)
23  } catch {
24    return null
25  }
26}
27
28/** Re-reads the chain and the seat list; true when what the band shows has changed. */
29async function refresh($: EngineInterface): Promise<boolean> {
30  const chain = root === null ? null : await followChain(root, file => readOrNull($, file))
31  let next = chain === null ? null : bandParts(chain.phase, chain.recordText, 0)
32  if (chain !== null && next !== null) {
33    // Asked only for a live record, so a terminal one clears without waiting on it; a refused list reads as none.
34    const agents = await $.agent.list().catch(() => [])
35    next = bandParts(chain.phase, chain.recordText, agents.filter(a => OUT.has(a.status)).length)
36  }
37  const print = JSON.stringify(next)
38  const changed = print !== fingerprint
39  fingerprint = print
40  shown = next
41  return changed
42}
43
44/** One refresh at a time: a poll that comes due while one is still out is skipped, so refreshes never overlap and
45 *  each one that settles draws what it read. Nothing awaits it, so an engine call that answers late or never holds
46 *  only the band, never the session. */
47function poll($: EngineInterface): void {
48  if (busy) return
49  busy = true
50  refresh($)
51    .then(changed => {
52      if (changed) $.ui.invalidate('ui.render')
53    }, () => {})
54    .finally(() => {
55      busy = false
56    })
57}
58
59export const register: Register = (on, options) => {
60  if (options.band === false) return
61
62  on('session.start', async ($, e, next) => {
63    const started = await next(e)
64    root = e.cwd
65    poll($)
66    $.clock.every(POLL_MS, () => poll($))
67    return started
68  })
69
70  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
71    const beneath = await next(e)
72    if (e.props.hasSurvey || shown === null) return beneath
73
74    const { Box, Text } = $.ui.resolve(e)
75    return (
76      <Box flexDirection="column">
77        <Box>
78          <Text dimColor>doctrine </Text>
79          <Text bold>{shown.phase}</Text>
80          {shown.parts.flatMap((part, i) => [
81            <Text key={'gap' + i}>{'  '}</Text>,
82            <Text key={'part' + i} color={COLOR[part.tone]} dimColor={part.tone === 'quiet'} bold={part.tone === 'owed'}>
83              {part.text}
84            </Text>,
85          ])}
86        </Box>
87        {beneath}
88      </Box>
89    )
90  })
91}
92
hooks/band/band.ts 67 lines
1// The doctrine band's pure decisions: which record the session's kickoff chain reaches, and what one line says
2// about it. Every reader comes from ../dctr-record.mjs, the plugin's one record parser (E11-D14).
3import { handoffHeader, kickoffHandoff, parseRecord, restoreState } from '../dctr-record.mjs'
4
5/** ok: a live phase with nothing owed. owed: waits on the owner. flight: seats out. quiet: labels and counts.
6 *  plain: the state word Open while something is owed. One colour per tone, each meaning one thing. */
7export type Tone = 'ok' | 'owed' | 'flight' | 'quiet' | 'plain'
8export type Part = { text: string; tone: Tone }
9export type Summary = { phase: string; parts: Part[] }
10
11/** A POSIX root or a Windows drive or UNC root. */
12const absolute = (p: string) => /^(?:[a-zA-Z]:)?[\\/]/.test(p)
13
14/** `read(path)` resolves a file's text, or null when it cannot be read. */
15export type Read = (path: string) => Promise<string | null>
16
17/** The chain the restore hook follows (followKickoff in dctr-lib.mjs): SESSION_MEMORY.md under `root`, its kickoff's
18 *  handoff, that handoff's `record:` line resolved absolute, else under `root`, else under its parent. Null where it
19 *  stops. */
20export async function followChain(root: string, read: Read): Promise<{ phase: string; recordText: string } | null> {
21  const at = (ref: string, dir: string) => (absolute(ref) ? ref : dir + '/' + ref)
22  const memory = await read(root + '/SESSION_MEMORY.md')
23  const ref = memory === null ? null : kickoffHandoff(memory)
24  const handoff = ref === null ? null : await read(at(ref, root))
25  const header = handoff === null ? null : handoffHeader(handoff)
26  if (!header?.record) return null
27  const parent = root.slice(0, Math.max(root.lastIndexOf('/'), root.lastIndexOf('\\'))) || '/'
28  const tries = absolute(header.record) ? [header.record] : [at(header.record, root), at(header.record, parent)]
29  for (const file of tries) {
30    const recordText = await read(file)
31    if (recordText !== null) return { phase: header.phase ?? '(unnamed)', recordText }
32  }
33  return null
34}
35
36/** What the band says about one record, `seatsOut` from the engine, or null when the record's last state line reads
37 *  anything but Open or Blocked, so the band draws nothing (E11-D11). The alarm count is the last round line's, with
38 *  no threshold beside it and no colour for nearness, since the record does not carry the owner's figure (E11-D5, D6). */
39export function bandParts(phase: string, recordText: string, seatsOut: number): Summary | null {
40  const { entries, state } = parseRecord(recordText)
41  const word = restoreState(state?.value)
42  if (!word) return null
43  const last = (kind: string) => entries.filter(e => e.kind === kind).at(-1)
44  const round = last('round')
45  const alarm = last('alarm')
46  const ruling = last('ruling')
47  // A question is owed while it has an opened line with no answered line for its id after it, as pausingStates reads.
48  const owes = (id: string, line: number) => !entries.some(a => a.kind === 'question-answered' && a.id === id && a.line > line)
49  const open = [...new Set(entries.filter(e => e.kind === 'question-opened' && owes(e.id, e.line)).map(e => e.id))]
50
51  const owed: Part[] = []
52  if (alarm && (!ruling || ruling.line < alarm.line)) owed.push({ text: alarm.which + ' alarm fired: ruling owed', tone: 'owed' })
53  if (open.length) owed.push({ text: 'ruling owed ' + open.join(' '), tone: 'owed' })
54  const blocked = /^blocked$/i.test(word)
55
56  return {
57    phase,
58    parts: [
59      { text: word, tone: blocked ? 'owed' : owed.length ? 'plain' : 'ok' },
60      { text: 'round ' + (round ? round.n : 0), tone: 'quiet' },
61      { text: 'alarm ' + (round ? round.alarm : 0), tone: 'quiet' },
62      ...(seatsOut > 0 ? [{ text: seatsOut + (seatsOut === 1 ? ' seat out' : ' seats out'), tone: 'flight' as Tone }] : []),
63      ...owed,
64    ],
65  }
66}
67
hooks/dctr-record.mjs 153 lines
1// doctrine — the parser for a phase record's event lines (E8-D24).
2//
3// Pure: a string in, a value out. No filesystem, no clock, no herdr. The line forms are the ones hub step 5
4// (skills/doctrine/SKILL.md) defines, each read with prose allowed after its fixed part (TAIL), and the selftest's fixture
5// record holds every key and every auto-cycle sub-form as a list item and bare, and every parsed entry is compared whole
6// against a hand-written table, so a form or field this file loses is a selftest failure and never a silent miss. The selftest never reads the hub: a hub change to the forms is caught by review, not here.
7//
8// Every form may be a list item (`- `) or bare, its key in any case. The forms the agent writes (auto-cycle on, off,
9// ready and paused) are also read with one inline code, bold or italic span wrapping the whole line (unwrapLine); a
10// line with two spans, or words outside the span, is prose. A
11// line that starts like a form but does not parse is not an entry, and neither is prose: a hook acts only on a line it
12// can read whole.
13
14/** A state line starts with `state:` after an optional `- ` and an optional `**`, the two forms run records
15 *  use. */
16export const STATE_LINE = /^(?:-\s+)?(?:\*\*)?state:\s*/i
17
18/** A line as an agent may write it, with its list marker and the inline code, bold or italic markers that wrap all
19 *  the rest removed, and nothing else: `` - `auto-cycle: ready` ``, `**auto-cycle: off**` and `*auto-cycle: ready*`
20 *  read as their text (K4-RL). A wrapper counts only when it is one span over the whole rest of the line, its text
21 *  holding no instance of its own delimiter, so a line with words beside the span, or two spans (`` `a` then `b` ``),
22 *  is left as it was and parses as prose (DSP7-B1). */
23export function unwrapLine(l) {
24  let t = String(l ?? '').trim().replace(/^(?:[-*+]|\d+\.)\s+/, '')
25  for (let m; (m = /^(`+|\*\*|\*|__|_)(\S(?:.*\S)?)\1$/.exec(t)) && !m[2].includes(m[1]); ) t = m[2].trim()
26  return t
27}
28
29const TIME = '(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?Z)'
30/** What may follow a form's fixed part: nothing, or one optional `.`, `,`, `;` or `:` and then whitespace and
31 *  prose, or an opening parenthesis after whitespace. A model appends a clause to a line it writes, and a hook that
32 *  stopped reading the line would act as if it were missing. A fixed part run straight into more word characters
33 *  (`alarm 1x`, `count 4.5`, `offline`) is a near miss, not a tail. */
34const TAIL = '(?:[.,;:]?(?:\\s.*)?)'
35const form = (body, tail = TAIL) => new RegExp(`^(?:-\\s+)?${body}${tail}$`, 'i')
36const num = Number
37
38/** Marks a form the agent writes (the on, off, ready and paused lines, hub step 5), which is also read through
39 *  unwrapLine; the hook-written forms (warned, cycle) and the rest are read only as written. */
40const AGENT = true
41
42/** Each form: its regex and how its captures become fields. The tier is kept as written, since judging a
43 *  malformed tier is the gauge's job (E8-D11) and a parser that dropped the line would hide it. */
44const FORMS = [
45  // The handle is the rest of the line, trimmed, up to the first via token, which may carry prose after it (E8-D24):
46  // drives write `handle agent:<id> (worktree <path>, branch <name>)`, `handle worktree <path> branch <name>`
47  // and `handle <path> via doctrine-backup; <what the seat does>`. With no via the handle runs to the line end.
48  [form(`wave:\\s+${TIME}\\s+seat\\s+(\\S+)\\s+handle\\s+(\\S.*?)(?:\\s+via\\s+(doctrine-handoff|doctrine-backup)${TAIL}|\\s*)`, ''),
49    (m) => ({ kind: 'wave', time: m[1], seat: m[2], handle: m[3], via: m[4] ? m[4].toLowerCase() : null })],
50  [form(`round:\\s+(\\d+)\\s+closed\\s+${TIME}\\s+at\\s+(\\S+)\\s+blockers\\s+(\\d+)\\s+alarm\\s+(\\d+)`),
51    (m) => ({ kind: 'round', n: num(m[1]), time: m[2], revision: m[3], blockers: num(m[4]), alarm: num(m[5]) })],
52  [form(`finding:\\s+(\\S+)\\s+raised\\s+${TIME}\\s+(blocking|non-blocking)\\s+(\\S.*)`),
53    (m) => ({ kind: 'finding-raised', id: m[1], time: m[2], blocking: m[3].toLowerCase() === 'blocking', text: m[4].trim() })],
54  [form(`finding:\\s+(\\S+)\\s+cleared\\s+${TIME}\\s+(\\S.*)`),
55    (m) => ({ kind: 'finding-cleared', id: m[1], time: m[2], evidence: m[3].trim() })],
56  [form(`ruling:\\s+(\\S+)\\s+${TIME}\\s+(\\S.*)`),
57    (m) => ({ kind: 'ruling', id: m[1], time: m[2], text: m[3].trim() })],
58  [form(`alarm:\\s+(round|time)\\s+fired\\s+${TIME}\\s+count\\s+(\\d+)`),
59    (m) => ({ kind: 'alarm', which: m[1].toLowerCase(), time: m[2], count: num(m[3]) })],
60  [form(`question:\\s+(\\S+)\\s+(opened|answered)\\s+${TIME}\\s+(\\S.*)`),
61    (m) => ({ kind: `question-${m[2].toLowerCase()}`, id: m[1], time: m[3], text: m[4].trim() })],
62  [form('auto-cycle:\\s+on\\s+cap\\s+(\\d+)\\s+tier\\s+(\\S+?)'), (m) => ({ kind: 'auto-cycle', sub: 'on', cap: num(m[1]), tier: m[2] }), AGENT],
63  [form('auto-cycle:\\s+off'), () => ({ kind: 'auto-cycle', sub: 'off' }), AGENT],
64  [form('auto-cycle:\\s+warned\\s+(\\S+)\\s+(\\S+?)'), (m) => ({ kind: 'auto-cycle', sub: 'warned', session: m[1], tier: m[2] })],
65  [form('auto-cycle:\\s+cycle\\s+(\\d+)\\s+tree\\s+([0-9a-f]+)'), (m) => ({ kind: 'auto-cycle', sub: 'cycle', n: num(m[1]), hash: m[2] })],
66  [form('auto-cycle:\\s+ready'), () => ({ kind: 'auto-cycle', sub: 'ready' }), AGENT],
67  [form('auto-cycle paused:\\s+(\\S.*)'), (m) => ({ kind: 'auto-cycle', sub: 'paused', reason: m[1].trim() }), AGENT],
68]
69
70/** `wrapper: <name>`, key in any case, as a list item or bare, optionally bold. The value is the first
71 *  whitespace-delimited token after the key with backticks, `**`, a `doctrine:` prefix and a trailing period, comma
72 *  or semicolon stripped: the E7 drive kit's line `Wrapper: doctrine:doctrine-code. Opened 2026-09-20T09:00:00Z.` reads
73 *  `doctrine-code`. */
74const WRAPPER_LINE = /^(?:-\s+)?(?:\*\*)?wrapper:\s*(?:\*\*)?\s*(\S+)/i
75export const wrapperValue = (tok) => tok.replace(/[`*]/g, '').replace(/^doctrine:/i, '').replace(/[.,;]+$/, '') || null
76
77/**
78 * `{ entries, state, wrapper }`. `entries` is every line that parses as a form, in file order, each
79 * `{ kind, line, ...fields }` with `line` 1-based. A state entry carries `raw`, the line as written, and `value`,
80 * what follows the key with every `**` removed, so `- **State: Open.** limiter phase.` and `**State:** Open` both
81 * read `Open`. `state` is the last state entry or null: where a record
82 * carries more than one state line the last is current (hub step 5). `wrapper` is the last wrapper line's
83 * value or null, the last for the same reason: a record is corrected by appending.
84 */
85export function parseRecord(text) {
86  const entries = []
87  let state = null, wrapper = null
88  String(text ?? '').split('\n').forEach((raw, i) => {
89    const l = raw.trim()
90    const line = i + 1
91    if (STATE_LINE.test(l)) {
92      state = { kind: 'state', line, raw: l, value: l.replace(STATE_LINE, '').replace(/\*\*/g, '').trim() }
93      entries.push(state)
94      return
95    }
96    const w = WRAPPER_LINE.exec(l)
97    if (w) { wrapper = wrapperValue(w[1]); return }
98    for (const [re, fields, agent] of FORMS) {
99      const m = re.exec(l) || (agent ? re.exec(unwrapLine(l)) : null)
100      if (m) { entries.push({ ...fields(m), line }); return }
101    }
102  })
103  return { entries, state, wrapper }
104}
105
106// The kickoff chain's readers (doctrine-backup's kickoff line, a handoff's header lines, the state a live phase
107// reads), here rather than in dctr-lib.mjs so the band, a hooks module that cannot import node:*, reads with them too.
108
109/** The `handoff:` path from the first line of `## Next Session Kickoff` that has the machine shape
110 *  `handoff: <path> | state: <word>` (doctrine-backup), backticks stripped, or null for `none`, for no
111 *  kickoff section, and for a kickoff with no such line: a `handoff:` line without its `| state:` half is not
112 *  the machine line and selects nothing. */
113export function kickoffHandoff(memoryText) {
114  const lines = String(memoryText ?? '').split('\n')
115  const at = lines.findIndex((l) => /^##\s+Next Session Kickoff\s*$/i.test(l.trim()))
116  if (at < 0) return null
117  for (const l of lines.slice(at + 1)) {
118    if (/^##\s/.test(l)) return null
119    const m = /^handoff:\s*`?([^`|\s]+)`?\s*\|\s*state:/i.exec(l.trim())
120    if (m) return m[1].toLowerCase() === 'none' ? null : m[1]
121  }
122  return null
123}
124
125/** The header lines of a handoff, above its first `##` section (doctrine step 5, doctrine-handoff step 3),
126 *  whether they sit above or below the file's `#` title. Each value is the first word after the key, as the hub
127 *  states the forms: backticks, quotes, `*` and a trailing comma, semicolon or period stripped; for `record:` a
128 *  trailing `:<line>` cut off too; `wrapper:` read as a record's wrapper line is. The first line for each key wins.
129 *  A key may be bold or a list item. Each null when absent. */
130export function handoffHeader(text) {
131  const out = { phase: null, record: null, wrapper: null }
132  for (const raw of String(text ?? '').split('\n')) {
133    if (/^##\s/.test(raw.trim())) break
134    const m = /^(?:-\s+)?(?:\*\*)?(phase|record|wrapper)(?:\*\*)?:(?:\*\*)?\s*(.*)$/i.exec(raw.trim())
135    if (!m) continue
136    const key = m[1].toLowerCase(), v = m[2].trim()
137    if (out[key] !== null) continue
138    const tok = v.split(/\s+/)[0].replace(/[`'"*]/g, '').replace(/[.,;]+$/, '')
139    if (key === 'phase') {
140      out.phase = tok || null
141    } else if (key === 'record') {
142      out.record = tok.replace(/:\d+$/, '') || null
143    } else {
144      out.wrapper = wrapperValue(tok)
145    }
146  }
147  return out
148}
149
150/** The state the restore hook acts on: `Open` or `Blocked` as the first word of a state entry's value, else
151 *  null (scope choice SC1: any other state injects nothing). */
152export const restoreState = (value) => /^(Open|Blocked)\b/i.exec(value || '')?.[1] || null
153