The dev loop that eats its own mistakes: milestone and phase workflows, long-lived specialist agents, and skills that rewrite themselves after every phase…

The dev loop that eats its own mistakes.
A Claude Code plugin that runs a disciplined, self-improving development loop. Every phase's failures are logged, turned into evals, and rewritten into the skills and agents that caused them, so the next phase starts smarter:
workflows/): milestone-kickoff, phase, milestone-exit, and kickoff-phase, which runs a kickoff and then the phase it planned in one run, handing the brief, the phase's tasks (with title, lane and touches from the kickoff) and the acceptance checks to the phase inline, so nothing has to write brief files or parse the plan in between; launch it by name with the kickoff args plus any phase args (phase for a later phase, worktree, branch, test_db, models). Kickoff runs one architect pass that reads the spec and plan once and returns the brief, the decisions and the phase-tagged tasks (fewer, larger tasks preferred), then the auditor writes the acceptance checks against that brief's names and commands, covering the goal, and commits them red; the brief's common part lists them, so the phase makes them green. The main session runs one workflow per phase; each phase implements its tasks with outside-in TDD through as few implementers as possible: tasks that share files form one chain done by one implementer in plan order, up to tasks_per_implementer tasks (default 4) each, the next implementer taking over from the previous one's handoff notes; disjoint chains run side by side in their own worktrees (up to implementer_slots, default 3, or one chain when the phase is pinned to a worktree) and are merged back in order; a task touching no files runs after every chain; fixes go to one fixer per chain; a merge conflict means the later task's implementer cherry-picks its own commits onto the merged branch and resolves them for both tasks' intent, re-implementing only when that fails; the merge step itself never resolves a conflict; a task or fix that comes back blocked in its worktree, e.g. a refused tool, runs again in place one at a time, and a task still blocked there is never passed as verified: the finished tasks are still reviewed and fixed; under merge_policy: ask the phase then checkpoints and opens its PR with each blocked task and its reason as an unchecked item, since the person merging is the gate, and the conductor accepts the blocked tasks' open boxes and names them when it reports the PR; under architect it escalates before the checkpoint; a step marked (needs: <resource>) never blocks a task), then, when it opens a phase PR, it merges the default branch's origin tip into the phase branch (a conflict escalates, never resolved by hand), so suite, review, fixes and checkpoint all see what the PR will merge; then the reviewer (correctness) and the architect (structure) review the whole phase diff once in parallel; fix rounds (at most three review rounds and two fix rounds in all) send each finding to its task's fixer together with the handoff note its implementer left (at most 300 words: decisions, gotchas, exact test commands, files left alone; also written to .claude/ouroboros/handoffs/<milestone>-<phase>/<task>.md so it survives a killed session); the fixer must address every finding and may reject one only by citing a test or code, which the reviewer rechecks; the phase result lists each fix as { task, round, mode, reason, outcome }; mode is always handoff for now, and reason says why it is not resume. Continuing the original implementer instead waits on Claude Code (docs/upstream-requests.md) (fixes of tasks whose diffs share no file run side by side in worktrees and merge back like the tasks did) and re-check only the findings, with only the reviewers that own a still-open finding (plus the correctness reviewer when a fix reached other files), on Sonnet at medium effort, without re-reading the brief or their skills; the architect comes back only for its own findings, and the checkpoint runs on Sonnet; then it checkpoints, running the full suites of every lane whose owned_paths match a file the phase changed (from git diff against the merge base, never from task tags) (implementers run only the tests of the files they change; branch, merge and PR steps run on a small model at low effort; exit takes CI's result for the branch head instead of rerunning the suites). An agent that comes back empty (an overloaded API, a failed agent) is retried once before the stage gives up, and every agent must end or kill the background test commands it started before it returns. The checkpoint is the phase's one full-suite run: a red checkpoint gets one fix round, each failure sent to the task that broke it (by blame, else by the file its diff touched), and runs again; only a second red escalates. The conductor, not an agent, accepts a checkpoint: the reported checkpoint sha must be HEAD with a phase(PN): subject, and every box of the tasks the phase ran must be ticked; otherwise the phase escalates. A task whose boxes are all ticked is never launched again. Merges follow merge_policy in .claude/ouroboros.json: ask (the default) starts the milestone on a fresh <branch_prefix><milestone>-<first phase> branch from the default branch's origin tip at kickoff, opens each phase PR into the default branch, reports it and holds the next phase until you merge it; the conductor checks the PR with gh on each prompt (at most every five minutes, or at once with /ouroboros resume) and, once it is merged, starts the next phase on a fresh branch from the updated default branch, so no merge or rebase of the old branch is ever needed. architect opens no phase PRs; the architect merges the milestone PR at exit once every gate is green. While a run is live (stamped less than twelve hours ago), the main session cannot start another loop workflow (by name, by a copied phase script, or with milestone args) nor run claude plugin update|install|uninstall: the refusal gives the exact Workflow({ scriptPath, resumeFromRunId }) call that resumes the run from its journal instead, and the conductor follows the resumed run's new task id. Nothing merges or rebases in the main session while a workflow is writing to the worktree; the workflow's own agents (its merge step) are not held by that guard, and neither is a retro nor a run marker older than twelve hours (or never stamped with a start time), so a run that ended unseen never blocks merges for good.agents/): architect, auditor, reviewer, implementer, skill-curator, adr-scribe. Persistent memory lives in each project's .claude/agent-memory/.agents/skill-curator.md): after every phase result the curator turns open incidents into eval-proven skill and agent rewrites, prunes agent memory that points at gone files, and slims every eager file over a third of eager_skills_max_chars: what a role needs only for some tasks moves into categorized project skills (domain-*, code-style-*, testing-*) loaded on demand, and leaves the role's eager_skills.skills/): generic process skills that the loop rewrites after every phase when an agent misreads or misuses them, each change proven by an eval.hooks/): incident capture, retro gate (implementers and phases wait while the project's own skills have open incidents; the plugin's own backlog never blocks a project, only work on the plugin itself), agent rollover, ADR scribe, planning lessons, status line, and the conductor: a state machine in .claude/ouroboros/state.json that files every workflow result under .claude/ouroboros/results/, files every skill-gap, skill-misread, skill-misuse and agent-behaviour finding of a phase result as an incident (workflow-started reviewers never reach the main session), lists review findings about code outside a task's diff on the phase PR as unchecked boxes to check before merging, appends them to the plan as a ### Task <id>-<PN>-follow-ups task tagged with the next phase (untagged after the last phase), starts the retro after every phase result, and hands the next Workflow launch to the main session as one line (a command such as kickoff, resume or collect submits it as a prompt once the command has returned; a result or a noticed merge puts it in the turn already running). /ouroboros status | pause | resume | escalations | kickoff <milestone> [<spec> <plan>] [goal] answer from that state without the model. Resume and kickoff first drop a pending launch whose work already ran (a recorded result, or a brief on disk with every phase carrying tasks; a phase(PN) commit with no open box in PN) and say why. /ouroboros adopt <task-id> <workflow> [phase] records a run started outside the conductor as in flight, and /ouroboros set phase <PN> / set status <status> repair the position; each prints the state before and after, so state.json never needs hand edits. /ouroboros collect <output-file> files a workflow result whose notification never reached the conductor: the in-flight run's result moves the loop on exactly as its notification would have (incidents, follow-ups, retro); any other run's result only files its incidents. With only a milestone, kickoff discovers the drafts under drafts_dir: the newest file with ### Task headings and checkboxes that names the milestone is the plan, and its Spec: line (or the file sharing its name) is the spec. Kickoff refuses to start without a .claude/ouroboros.json. --phase <PN> kicks off one later phase of a milestone that already ran: the planner appends ## Part C: <milestone> <PN> tasks, numbered after the highest task id and all tagged (<PN>), the briefs go to briefs/<milestone>-<PN>/, and only that phase runs.Project-specific configuration lives in the project's .claude/ouroboros.json and its own domain skills.
Once any task carries a phase tag, an untagged task never runs: the planner tags every task it adds (the acceptance-checks task with the first phase), and kickoff names any untagged task that still has open boxes. Specs and plans are ordinary markdown drafts: ### Task <id>: <title> (PN) headings (or (PN, <lane>) to pin a task's lane) and - [ ] boxes under drafts_dir in the main checkout, read the same way from any worktree.
Incidents land where the curator can fix them: a project skill's in <project>/.claude/skills/<skill>/incidents.md; a plugin skill's or agent's in the plugin checkout only when it is a writable git checkout (skills/<skill>/incidents.md, incidents/agents/<agent>.md; never under agents/, where every markdown file is read as an agent definition), otherwise in <project>/.claude/ouroboros/plugin-incidents/<plugin>/{skills,agents}/ for the curator to turn into a patch. Agent names are folded onto the plugin agent that plays them (implementer-ruby, implementer:ruby and architect-m1 are the implementer and the architect; the planner is the architect). A process finding that names no skill or agent goes to plugin-incidents/<plugin>/unowned.md, where the curator gives it an owner or closes it; none is dropped. Every loop workflow's findings are filed, its own top-level findings as well as its tasks'. Evidence is written relative to the project. Plan drift (a draft edited after kickoff) is not a skill incident: the ADR scribe notes it on the active ADR, and it never counts toward the retro gate.
A step that needs something only a person has ends with (needs: <resource>): it never counts as open work, the checkpoint skips it, and the phase PR lists it as an unchecked item. Progress is never kept in conversation memory: every plan task and step is a - [ ] checkbox, agents start from the first unchecked box and tick each one in the commit that verifies it (skills/checkbox-progress).
On any machine, add the marketplace and install:
claude plugin marketplace add StupidCodeFactory/ouroboros
claude plugin install ouroboros@ouroboros
If the plugin gets loaded twice (an installed copy plus a stale --plugin-dir or dev link), the instance loaded first refuses the second at plugin.register and toasts both roots, so every hook runs once. The hooks are function hooks: set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the env of ~/.claude/settings.json. To develop the plugin, point CLAUDE_CODE_PLUGIN_DIRS at a checkout instead of installing it; never do both, or every hook runs twice.
Commit a .claude/ouroboros.json naming the lanes (owned paths, test and lint commands, lint baseline, lane skills), each agent's eager skills, the planning skills, adr_dir and drafts_dir. Put language and domain rules in the project's own .claude/skills/; the plugin's skills stay project-agnostic. ouroboros hardcodes no skill or plugin from outside itself: every skill an agent loads is named here.
{
"lanes": {
"ruby": { "owned_paths": ["lib/**", "spec/**"], "test": "bundle exec rspec", "lint": "bundle exec rubocop", "lint_baseline": 0 }
},
"agents": {
"architect": { "eager_skills": ["ouroboros:checkbox-progress", "ouroboros:code-style", "ouroboros:phase-pr-workflow", "ouroboros:findings-contract", "ouroboros:adr-format", "your-plugin:design"] },
"implementer": {
"eager_skills": ["ouroboros:checkbox-progress", "ouroboros:code-style", "ouroboros:phase-pr-workflow", "your-plugin:tdd"],
"lanes": { "ruby": { "eager_skills": ["ruby-spec-conventions"] } }
},
"reviewer": { "eager_skills": ["ouroboros:checkbox-progress", "ouroboros:code-style", "ouroboros:findings-contract", "your-plugin:code-review"] },
"auditor": { "eager_skills": ["ouroboros:checkbox-progress", "ouroboros:findings-contract"] },
"adr-scribe": { "eager_skills": ["ouroboros:checkbox-progress", "ouroboros:adr-format"] },
"skill-curator": { "eager_skills": ["your-plugin:writing-skills"] }
},
"planning_skills": ["your-plugin:brainstorm", "your-plugin:write-plan"],
"eager_skills_max_chars": 60000,
"effort": { "brief": "high", "implement": "high", "review": "high", "audit": "medium", "checkpoint": "low" },
"adr_dir": "docs/adr",
"drafts_dir": "docs/drafts"
}
agents.<agent>.eager_skills (plus lanes.<lane>.eager_skills for implementers) is the only source of skills an agent loads; an agent the project lists nothing for gets ouroboros's own process skills for its role. planning_skills names the skills whose prompt gets the planning lessons appended; empty or missing means the planning hook never fires. drafts_dir is where isDraftPath looks for specs/ and plans/.
lanes.<lane>.env_notes (a list of lines: database and cache URLs, how to run the lane's tests, which wrappers to use) is appended to that lane's implementer eager file and to the auditor's, so no agent rediscovers its test setup. An optional models map ({ "implementer": "opus", "reviewer": …, "architect": …, "auditor": … }) overrides the model each role's agents run on, passed to every workflow as args.models: implementers, their fixers and rebasers, the first review round, the checkpoint, and kickoff's architect and auditor; an unset role keeps its agent definition's model, and the cheap git and recheck steps keep theirs. An optional effort map sets the reasoning effort per workflow stage; the conductor passes it to every workflow it launches as args.effort, and a stage left out inherits the session effort. Stages: brief, implement, review, architect_review, audit, fix, planner, checkpoint, merge; values: low, medium, high, xhigh, max.
Before a phase with a sliced brief implements anything, a small architect agent checks what merged into the default branch since the brief's common.md was written and appends a Landed since this brief section when those merges change names or APIs the phase's tasks use. Workflows take everything else through args: milestone-kickoff gets { milestone, goal, spec, plan }, phase gets { milestone, phase, brief_dir, tasks } with each task's touches from its brief slice (else, for a lane-tagged task, its lane's owned_paths, so tasks of different lanes still run side by side; globs overlap when one's fixed prefix contains the other's) and its lane (the heading's lane tag, else the lane whose owned_paths own most of its touched files), so one phase mixes lanes and checkpoints once after all of them (or brief_path, or an inline brief when no brief file exists; a long brief belongs in a file, never inline) (the conductor derives tasks from the plan's (PN) tags), milestone-exit gets { milestone }. Tasks whose commits already landed in an earlier run go in landed ([{ id, title }]): one small agent gathers all their commits and hunks at once, from their handoff notes and git, and they are suite-run, reviewed, fixed and checkpointed with the rest. A task titled as the phase checkpoint (PN checkpoint) is skipped, the checkpoint stage does that work, and a task whose touches is empty runs after every task before it. Every test_db in a launch's args (at any depth) is recorded in the shared git directory, ouroboros/test-resources.json, until the run's result is filed or twelve hours pass; a launch whose test databases sit on the same host and port as another live run's is warned about, since runs on one server can flush or race each other. The workflow tool only runs scripts you can already read, so a parent script cannot load workflows/phase.js from the plugin cache by scriptPath: copy it next to your script for that plugin version. To drive several milestones from one session, give each phase run worktree, branch and test_db: every agent prompt then starts in that worktree against that test database, and tasks run in place one at a time there instead of in parallel worktrees. milestone-exit gets { milestone }. Workflow agents are the plugin's own (ouroboros:<agent>); skills reach them only through the project config's per-agent lists.
ouroboros writes its runtime state under .claude/ouroboros/ at the repository root. It is local to each checkout or worktree, and the conductor drops a .claude/ouroboros/.gitignore holding * so the directory ignores itself; to say so in the project too, add it to the project's .gitignore:
.claude/ouroboros/
state.json: the conductor's loop state (milestone, phases, current phase, pending launch, run in flight, escalations).results/: the full result of every loop workflow, plus every oversized result of a plugin agent or loop workflow, each filed by task or tool-use id.eager/: each role's eager skills block (<role>.md, implementer-<lane>.md), written before a loop workflow launches and read by its agents first.briefs/: the architect brief of each milestone kickoff: <milestone>/common.md (forbidden list, review gates, shared constraints) and <milestone>/<task id>.md (where the task's code goes, what to reuse, the files it touches). Phases get the directory as brief_dir and each agent reads the common file plus its own task's slice. A kickoff that returned one plain-text brief files it as <milestone>.md and passes brief_path.spawns.jsonl: one line per agent spawn with the skills inlined and their hashes.The committed .claude/ouroboros.json is configuration, not runtime state; keep it under version control.
Run scripts/install-hooks.sh once after cloning: the pre-push hook runs scripts/guard_no_outside_skills.sh (no outside skill or plugin names, no absolute home paths, and none of the project names listed one per line in the untracked .git/info/project-names), scripts/check_workflow_mirrors.mjs (the workflow scripts cannot import, so their inline copies of the tested phase logic in hooks/phase_review.ts must match it), the plugin tests and plugin validate, and refuses the push on any failure.
Status: under construction.
hooks/register.ts 1022 lines1import type { EngineInterface, Register, TurnUsage } from 'claude-code'
2
3import { ACCEPT_MILESTONE_ADRS, FOLD_DRAFT_CHANGE, OPEN_PROPOSED_ADRS, adrScribePrompt, isDraftPath, parseDecisions } from './adr'
4import { GIT_COMMON_DIR, STATE_PATH, activeDraftsOf, assertUniqueTaskIds, checkoutRootOf, draftsPathOf, firstUncheckedBox, phaseTasks } from './drafts'
5import type { ActiveDrafts } from './drafts'
6import { briefFiles } from './conductor/briefs'
7import type { BriefSlices } from './conductor/briefs'
8import { digestedResult, isOversized } from './conductor/digest'
9import { bareName, blockedTaskIds, embeddedJson, isLoopNotification, kickoffDecisionsOf, loopEventOf, outputFileOf, verifiedCheckpoint, workflowResultOf } from './conductor/events'
10import { COMPACT_INSTRUCTIONS, escalationsText, loopHeader, statusReport, workflowCall } from './conductor/header'
11import { IDLE_STATE, kickoffState, parseState, serializeState } from './conductor/state'
12import { discoverDrafts, kickoffArgs, type Discovery, type DraftFile, type KickoffArgs } from './discover'
13import type { Launch, LoopState, Run } from './conductor/state'
14import { reconcilePending } from './conductor/reconcile'
15import { adoptRun, positionLine, setPosition } from './conductor/repair'
16import type { Repair } from './conductor/repair'
17import type { Evidence } from './conductor/reconcile'
18import { freshBranchName, mergeAccepted, nextAction } from './conductor/transitions'
19import type { Action, LoopEvent } from './conductor/transitions'
20import { isProcessIncident, parseFindings, resultIncidents } from './findings'
21import type { Finding } from './findings'
22import { incidentLogPath, incidentRow, openIncidentCount } from './incident_log'
23import type { IncidentPlaces } from './incident_log'
24import { phaseFollowUps } from './phase_review'
25import { sharedHostPorts, testDbsIn } from './test_resources'
26import { DEFAULT_EAGER_SKILLS_MAX_CHARS, eagerFileName, eagerSkillNames, laneEnvNotes, laneOf, workflowSeats } from './eager_skills/config'
27import type { OuroborosConfig, SkillRef } from './eager_skills/config'
28import { checkBudget, eagerBlock } from './eager_skills/inline'
29import type { InlinedSkill } from './eager_skills/inline'
30import { fixedSkillRoots, indexSkills, pluginCacheDir, pluginSkillRoots, resolveSkill } from './eager_skills/resolve'
31import type { SkillIndex, SkillListing } from './eager_skills/resolve'
32import { candidateRow, isPlanningSkill, withPlanningLessons } from './planning_lessons'
33import { IMPLEMENTER_AGENTS, checkpointPhaseOf, isGuardedMerge, isPhaseWorkflow, isPullRequestMerge, isRetroTrigger, retroPrompt } from './retro'
34import { SUBAGENT_COMPACTION_INSTRUCTIONS, contextShare, memoryDigestRequest, shouldRollOver } from './rollover'
35import { duplicateLoadWarning, isAnotherInstance } from './double_load'
36import { stripFrontmatter } from './skill_text'
37
38const REVIEWING_AGENTS = new Set(['reviewer', 'architect', 'auditor'])
39const INCIDENT_LOG_HEADER = '# Incidents\n\n'
40const PHASE_CHECKPOINT_SUBJECT = /^phase\(P(\d+)\):/
41const RETIRING = { plugin: 'ouroboros', key: 'retiring' } as const
42const PLANNING = { plugin: 'ouroboros', key: 'planning' } as const
43const PLANNING_IDLE = { active: false, ranThisTurn: false }
44const SKILL_INDEX = { plugin: 'ouroboros', key: 'skillIndex' } as const
45const CONFIG_PATH = '.claude/ouroboros.json'
46const PLUGIN_INCIDENTS_DIR = '.claude/ouroboros/plugin-incidents'
47const SPAWNS_LOG = '.claude/ouroboros/spawns.jsonl'
48const RESULTS_DIR = '.claude/ouroboros/results'
49const EAGER_DIR = '.claude/ouroboros/eager'
50const BRIEFS_DIR = '.claude/ouroboros/briefs'
51const OUROBOROS_COMMAND = {
52 name: 'ouroboros',
53 description: 'Conductor: /ouroboros status | pause | resume | escalations | kickoff <milestone> [<spec> <plan>] [--phase <PN>] [goal] | adopt <task-id> <workflow> [phase] | set phase <PN> | set status <status> | collect <output-file>',
54 argumentHint: '<subcommand>',
55}
56const SKILL_INCIDENT_COMMAND = {
57 name: 'skill-incident',
58 description: 'Log a correction against a skill: /skill-incident <skill> <text>',
59 argumentHint: '<skill> <text>',
60}
61
62const LOOP_WORKFLOWS = new Set(['milestone-kickoff', 'phase', 'milestone-exit', 'kickoff-phase'])
63
64const agentRole = (subagentType: string) => subagentType.slice(subagentType.lastIndexOf(':') + 1)
65
66const isLoopAgent = ($: EngineInterface, subagentType: string | undefined) => (subagentType ?? '').startsWith(`${$.plugin.name}:`)
67
68const isPluginChange = (command: string) => /\bclaude\s+plugin\s+(update|install|uninstall)\b/.test(command)
69
70const isLoopWorkflow = (name: string | undefined) => LOOP_WORKFLOWS.has(bareName(name ?? ''))
71
72const withEagerDir = (args: unknown, eagerDir: string) => ({ ...(args as Record<string, unknown> | undefined), eager_dir: eagerDir })
73
74const agentText = (result: unknown) => {
75 const content = (result as { content?: Array<{ text?: string }> } | undefined)?.content ?? []
76 return content.map(block => block.text ?? '').join('\n')
77}
78
79const nextPhaseAfter = (checkpointSubject: string) => {
80 const match = PHASE_CHECKPOINT_SUBJECT.exec(checkpointSubject.trim())
81 if (!match) return 'P0'
82 return `P${Number(match[1]) + 1}`
83}
84
85const sumGrepCounts = (grepStdout: string) =>
86 grepStdout
87 .split('\n')
88 .map(line => Number(line.slice(line.lastIndexOf(':') + 1)))
89 .filter(Number.isFinite)
90 .reduce((total, count) => total + count, 0)
91
92const hasSucceeded = (answered: { deny?: unknown; isError?: unknown }) => answered.deny === undefined && answered.isError === undefined
93
94const splitFirstWord = (text: string) => {
95 const trimmed = text.trim()
96 const boundary = trimmed.search(/\s/)
97 if (boundary === -1) return { head: trimmed, rest: '' }
98 return { head: trimmed.slice(0, boundary), rest: trimmed.slice(boundary).trim() }
99}
100
101type EagerSpawn = { prompt: string; lane: string | undefined; skills: Array<{ name: string; sha: string }> }
102
103const hex = (digest: ArrayBuffer) => [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')
104
105const sha256 = async (text: string) => hex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
106
107const sizesOf = (skills: InlinedSkill[]) => Object.fromEntries(skills.map(({ ref, body }) => [ref.name, body.length]))
108
109const shaOf = (skills: InlinedSkill[]) => Promise.all(skills.map(async ({ ref, body }) => ({ name: ref.name, sha: await sha256(body) })))
110
111const userCorrection = (skill: string, summary: string): Finding => ({ summary, root_cause: 'skill-gap', skill, agent: 'user' })
112
113async function lastCheckpointSubject($: EngineInterface) {
114 const { stdout } = await $.process.run(['git', 'log', '-1', '--grep=^phase(', '--format=%s'])
115 return stdout
116}
117
118async function currentPhase($: EngineInterface) {
119 return nextPhaseAfter(await lastCheckpointSubject($))
120}
121
122async function todayIso($: EngineInterface) {
123 return new Date(await $.clock.now()).toISOString().slice(0, 10)
124}
125
126async function countOpenIncidents($: EngineInterface) {
127 const projectRoot = await repositoryRoot($)
128 const { stdout } = await $.process.run([
129 'grep', '-rh', '| open |', `${projectRoot}/.claude/skills`, `${projectRoot}/${PLUGIN_INCIDENTS_DIR}`, `${$.plugin.root}/skills`, `${$.plugin.root}/incidents`,
130 ])
131 return openIncidentCount(stdout)
132}
133
134async function isWritableCheckout($: EngineInterface, root: string) {
135 const { exitCode } = await $.process.run(['sh', '-c', 'git -C "$1" rev-parse --is-inside-work-tree >/dev/null 2>&1 && test -w "$1"', 'sh', root])
136 return exitCode === 0
137}
138
139async function definedAgents($: EngineInterface) {
140 const agentsDir = `${$.plugin.root}/agents`
141 if (!(await $.fs.exists(agentsDir))) return []
142 return (await $.fs.list(agentsDir)).filter(entry => entry.kind === 'file' && entry.name.endsWith('.md')).map(entry => entry.name.slice(0, -3))
143}
144
145async function incidentPlaces($: EngineInterface): Promise<IncidentPlaces> {
146 return {
147 pluginName: $.plugin.name,
148 pluginRoot: $.plugin.root,
149 projectRoot: await repositoryRoot($),
150 pluginWritable: await isWritableCheckout($, $.plugin.root),
151 pluginSkills: (await subdirectories($, `${$.plugin.root}/skills`)).map(baseName),
152 pluginAgents: await definedAgents($),
153 }
154}
155
156async function countProposedAdrs($: EngineInterface) {
157 const { stdout } = await $.process.run(['grep', '-rc', 'Status: Proposed', 'docs/adr'])
158 return sumGrepCounts(stdout)
159}
160
161async function appendIncident($: EngineInterface, path: string, row: string) {
162 const existing = (await $.fs.exists(path)) ? await $.fs.read(path) : INCIDENT_LOG_HEADER
163 await $.fs.write(path, existing + row)
164}
165
166async function logIncidents($: EngineInterface, reviewText: string) {
167 const findings = parseFindings(reviewText).filter(isProcessIncident)
168 if (findings.length === 0) return
169 await fileIncidents($, findings, await currentPhase($))
170}
171
172async function fileIncidents($: EngineInterface, findings: Finding[], phase: string) {
173 if (findings.length === 0) return
174 const places = await incidentPlaces($)
175 const dateIso = await todayIso($)
176 for (const finding of findings) await appendIncident($, incidentLogPath(finding, places), incidentRow(finding, phase, dateIso, places.projectRoot))
177}
178
179async function logUserCorrection($: EngineInterface, skill: string, text: string) {
180 await fileIncidents($, [userCorrection(skill, text)], await currentPhase($))
181}
182
183async function showStatus($: EngineInterface) {
184 $.ui.status(`skills: ${await countOpenIncidents($)} open · ADR ${await countProposedAdrs($)} proposed`)
185}
186
187async function gatingIncidentDirs($: EngineInterface) {
188 const projectRoot = await repositoryRoot($)
189 const ownSkills = `${projectRoot}/.claude/skills`
190 return projectRoot === $.plugin.root ? [ownSkills, `${projectRoot}/skills`, `${projectRoot}/incidents`] : [ownSkills]
191}
192
193async function countGatingIncidents($: EngineInterface) {
194 const { stdout } = await $.process.run(['grep', '-rh', '| open |', ...(await gatingIncidentDirs($))])
195 return openIncidentCount(stdout)
196}
197
198async function retroPendingDenial($: EngineInterface) {
199 const openIncidents = await countGatingIncidents($)
200 if (openIncidents === 0) return undefined
201 return { deny: `retro pending: ${openIncidents} open incidents in this project's skills` }
202}
203
204async function eagerFiles($: EngineInterface) {
205 const dir = await projectPath($, EAGER_DIR)
206 if (!(await $.fs.exists(dir))) return []
207 return (await $.fs.list(dir)).filter(entry => entry.kind === 'file').map(entry => ({ name: entry.name, size: entry.size }))
208}
209
210async function slimmingLimit($: EngineInterface) {
211 return Math.floor(((await readConfig($)).eager_skills_max_chars ?? DEFAULT_EAGER_SKILLS_MAX_CHARS) / 3)
212}
213
214async function curatorPrompt($: EngineInterface) {
215 const files = await eagerFiles($).catch(() => [])
216 return retroPrompt(files, files.length === 0 ? 0 : await slimmingLimit($))
217}
218
219async function startRetro($: EngineInterface): Promise<Run> {
220 const spawned = await $.agent.spawn({ subagentType: `${$.plugin.name}:skill-curator`, description: 'retro', prompt: await curatorPrompt($) })
221 $.ui.toast('retro started')
222 return { id: spawned.agentId ?? 'retro', workflow: 'retro' }
223}
224
225async function readLoopState($: EngineInterface) {
226 return parseState((await $.fs.exists(STATE_PATH)) ? await $.fs.read(STATE_PATH) : undefined)
227}
228
229const RUNTIME_IGNORE = '.claude/ouroboros/.gitignore'
230
231async function ensureRuntimeIgnored($: EngineInterface) {
232 const path = await projectPath($, RUNTIME_IGNORE)
233 if (await $.fs.exists(path)) return
234 await $.fs.write(path, '*\n')
235}
236
237async function writeLoopState($: EngineInterface, state: LoopState) {
238 await ensureRuntimeIgnored($)
239 await $.fs.write(STATE_PATH, serializeState(state))
240}
241
242async function activePlanText($: EngineInterface) {
243 const drafts = await activeDrafts($)
244 if (drafts === null) return undefined
245 const path = await draftsPath($, drafts.plan)
246 return (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
247}
248
249const launchNote = (launch: Launch) => `launch now: ${workflowCall(launch)} (or later with /ouroboros resume)`
250
251const submitWhenIdle = ($: EngineInterface, text: string) =>
252 $.clock.after(0, () => {
253 $.prompt.submit({ text }).catch(error => $.ui.toast(loudly(error)))
254 })
255
256function deliverPendingLaunch($: EngineInterface, state: LoopState) {
257 if (state.pending === undefined || state.paused) return
258 submitWhenIdle($, launchNote(state.pending))
259}
260
261function deliverNote($: EngineInterface, settled: { state: LoopState; note?: string }) {
262 if (settled.state.pending !== undefined && !settled.state.paused) return deliverPendingLaunch($, settled.state)
263 if (settled.note !== undefined) submitWhenIdle($, `ouroboros: ${settled.note}`)
264}
265
266const MERGING_WORKFLOWS = new Set(['phase', 'milestone-exit'])
267
268const withConfigArgs = (launch: Launch, config: OuroborosConfig): Launch => {
269 const effort = config.effort === undefined ? {} : { effort: config.effort }
270 const models = config.models === undefined ? {} : { models: config.models }
271 const mergePolicy = MERGING_WORKFLOWS.has(launch.workflow) ? { merge_policy: config.merge_policy ?? 'ask' } : {}
272 return { ...launch, args: { ...launch.args, ...effort, ...models, ...mergePolicy } }
273}
274
275async function perform($: EngineInterface, state: LoopState, launch: Launch): Promise<{ state: LoopState; note?: string }> {
276 if (launch.workflow === 'retro') return { state: { ...state, run: await startRetro($), pending: undefined } }
277 const pending = withConfigArgs(launch, await readConfig($))
278 return { state: { ...state, pending, run: undefined }, note: launchNote(pending) }
279}
280
281type Launched = { taskId?: string; runId?: string; scriptPath?: string }
282
283const resumeHandle = (launched: Launched) => ({
284 ...(launched.runId === undefined ? {} : { run_id: launched.runId }),
285 ...(launched.scriptPath === undefined ? {} : { script_path: launched.scriptPath }),
286})
287
288async function recordLaunchedWorkflow($: EngineInterface, name: string | undefined, launched: Launched) {
289 const state = await readLoopState($)
290 if (state.pending === undefined || state.pending.workflow !== bareName(name ?? '')) return
291 const run = { id: launched.taskId ?? state.pending.workflow, workflow: state.pending.workflow, started_at: await $.clock.now(), ...resumeHandle(launched) }
292 await writeLoopState($, { ...state, pending: undefined, run })
293}
294
295async function followResumedRun($: EngineInterface, resumedRunId: string, launched: Launched) {
296 const state = await readLoopState($)
297 if (state.run === undefined || state.run.run_id !== resumedRunId || launched.taskId === undefined) return
298 await writeLoopState($, { ...state, run: { ...state.run, id: launched.taskId, started_at: await $.clock.now() } })
299}
300
301const launchesLoopWork = (input: { name?: string; scriptPath?: string; args?: unknown }) => {
302 const args = (input.args ?? {}) as { milestone?: unknown; phase?: unknown }
303 return isLoopWorkflow(input.name) || isPhaseWorkflow(input) || (typeof args.milestone === 'string' && args.milestone !== '')
304}
305
306const resumeCall = (run: Run) =>
307 run.run_id === undefined ? 'find its run id with /workflows and call Workflow with resumeFromRunId' : `Workflow({ scriptPath: "${run.script_path ?? '<its script path>'}", resumeFromRunId: "${run.run_id}" })`
308
309const stillRunning = (run: Run) =>
310 `${run.workflow} (${run.id}) is still running; wait for its result. If it died, resume it instead of starting over: ${resumeCall(run)}`
311
312async function liveRunOf($: EngineInterface) {
313 const run = (await readLoopState($).catch(() => IDLE_STATE)).run
314 return run !== undefined && (await isLiveWriter($, run)) ? run : undefined
315}
316
317async function settle($: EngineInterface, action: Action): Promise<{ state: LoopState; note?: string }> {
318 if (action.launch === undefined) {
319 await writeLoopState($, action.state)
320 return { state: action.state, note: action.notify }
321 }
322 const performed = await perform($, action.state, action.launch)
323 await writeLoopState($, performed.state)
324 return { state: performed.state, note: [action.notify, performed.note].filter(Boolean).join('; ') || undefined }
325}
326
327const laneOwnership = (config: OuroborosConfig) => Object.fromEntries(Object.entries(config.lanes ?? {}).map(([lane, settings]) => [lane, settings.owned_paths ?? []]))
328
329const isAbsolutePath = (path: string) => path.startsWith('/')
330
331async function existsInProject($: EngineInterface, path: string) {
332 return $.fs.exists(isAbsolutePath(path) ? path : await projectPath($, path))
333}
334
335async function briefOnDisk($: EngineInterface, state: LoopState) {
336 if (state.brief_dir !== undefined && (await existsInProject($, `${state.brief_dir}/common.md`))) return state.brief_dir
337 const candidates = [state.brief_path, `${BRIEFS_DIR}/${state.milestone}.md`].filter((path): path is string => path !== undefined)
338 for (const candidate of candidates) if (await existsInProject($, candidate)) return candidate
339 return undefined
340}
341
342async function hasPhaseCommit($: EngineInterface, phase: string) {
343 const { stdout } = await $.process.run(['git', 'log', '-1', `--grep=^phase(${phase}):`, '--format=%h'])
344 return stdout.trim() !== ''
345}
346
347async function committedPhases($: EngineInterface, phases: string[]) {
348 const committed: string[] = []
349 for (const phase of phases) if (await hasPhaseCommit($, phase)) committed.push(phase)
350 return committed
351}
352
353async function loopEvidence($: EngineInterface, state: LoopState): Promise<Evidence> {
354 return {
355 briefPath: await briefOnDisk($, state),
356 committedPhases: await committedPhases($, state.phases),
357 planText: await activePlanText($),
358 lanes: laneOwnership(await readConfig($)),
359 }
360}
361
362async function offerPending($: EngineInterface, state: LoopState, delivery: 'submit' | 'note-only' = 'submit') {
363 if (state.pending === undefined) {
364 await writeLoopState($, state)
365 return 'nothing queued'
366 }
367 const settled = await settle($, { state: { ...state, pending: undefined }, launch: state.pending })
368 if (delivery === 'submit') deliverPendingLaunch($, settled.state)
369 return settled.note ?? 'launched'
370}
371
372async function assertPlanReady($: EngineInterface) {
373 const planText = await activePlanText($)
374 if (planText !== undefined) assertUniqueTaskIds(planText)
375}
376
377const loudly = (error: unknown) => `ouroboros: ${error instanceof Error ? error.message : String(error)}`
378
379async function pullRequestState($: EngineInterface, prUrl: string) {
380 const { stdout } = await $.process.run(['gh', 'pr', 'view', prUrl, '--json', 'state', '--jq', '.state'])
381 return stdout.trim()
382}
383
384async function releasedFromMerge($: EngineInterface, state: LoopState): Promise<LoopState | string> {
385 const waiting = state.awaiting_merge
386 if (waiting === undefined) return state
387 const prState = await pullRequestState($, waiting.pr_url)
388 if (prState !== 'MERGED') return `waiting for you to merge ${waiting.pr_url} (${waiting.phase}, ${prState || 'state unknown'}); resume again once it is merged`
389 return mergeAccepted(state, (await readConfig($)).branch_prefix ?? 'milestone/')
390}
391
392async function continueLoop($: EngineInterface, state: LoopState, delivery: 'submit' | 'note-only' = 'submit') {
393 const reconciled = reconcilePending(state, await loopEvidence($, state))
394 return [...reconciled.dropped, `resumed: ${await offerPending($, reconciled.state, delivery)}`].join('\n')
395}
396
397async function resumeLoop($: EngineInterface, state: LoopState) {
398 await assertPlanReady($)
399 const released = await releasedFromMerge($, state)
400 if (typeof released === 'string') return released
401 return continueLoop($, { ...released, paused: false })
402}
403
404const MERGE_CHECK_INTERVAL_MS = 5 * 60 * 1000
405
406async function noticedMerge($: EngineInterface, state: LoopState) {
407 const waiting = state.awaiting_merge
408 if (waiting === undefined || state.paused || state.run !== undefined) return undefined
409 const now = await $.clock.now()
410 if (now - (state.merge_checked_at ?? 0) < MERGE_CHECK_INTERVAL_MS) return undefined
411 const released = await releasedFromMerge($, state)
412 if (typeof released === 'string') {
413 await writeLoopState($, { ...state, merge_checked_at: now })
414 return undefined
415 }
416 return `${waiting.pr_url} merged; ${await continueLoop($, released, 'note-only')}`
417}
418
419const MISSING_CONFIG = 'no .claude/ouroboros.json in this directory: create one (plugin README, Project setup) or open the session in the checkout that has it'
420
421async function draftFiles($: EngineInterface, draftsRoot: string): Promise<DraftFile[]> {
422 const { stdout } = await $.process.run(['sh', '-c', 'cd "$1" && find . -name "*.md" -type f -print0 | xargs -0 -r ls -t', 'sh', draftsRoot])
423 const paths = stdout.split('\n').filter(Boolean).map(path => path.replace(/^\.\//, ''))
424 return Promise.all(paths.map(async path => ({ path, text: await $.fs.read(`${draftsRoot}/${path}`) })))
425}
426
427async function resolveDrafts($: EngineInterface, milestone: string, parsed: KickoffArgs): Promise<Discovery> {
428 if (parsed.spec !== undefined && parsed.plan !== undefined) return { drafts: { spec: parsed.spec, plan: parsed.plan } }
429 const draftsDir = (await readConfig($)).drafts_dir ?? ''
430 const draftsRoot = (await draftsPath($, '')).replace(/\/$/, '')
431 return discoverDrafts(await draftFiles($, draftsRoot), milestone, draftsDir)
432}
433
434async function freshStart($: EngineInterface, state: LoopState) {
435 const config = await readConfig($)
436 if ((config.merge_policy ?? 'ask') !== 'ask') return {}
437 return { fresh_branch: freshBranchName(config.branch_prefix ?? 'milestone/', state.milestone, state.phases[0] ?? 'P0') }
438}
439
440const forPhase = (state: LoopState, phase: string | undefined): LoopState => (phase === undefined ? state : { ...state, phases: [phase], kickoff_phase: phase })
441
442const briefKey = (state: LoopState) => (state.kickoff_phase === undefined ? state.milestone : `${state.milestone}-${state.kickoff_phase}`)
443
444async function kickoff($: EngineInterface, args: string) {
445 const parsed = kickoffArgs(args)
446 if (parsed.milestone === undefined) return 'usage: /ouroboros kickoff <milestone> [<spec> <plan>] [--phase <PN>] [goal]'
447 if (!(await $.fs.exists(CONFIG_PATH))) return MISSING_CONFIG
448 const discovery = await resolveDrafts($, parsed.milestone, parsed)
449 if ('error' in discovery) return discovery.error
450 const { spec, plan } = discovery.drafts
451 const planPath = await draftsPath($, plan)
452 if (!(await $.fs.exists(planPath))) return `plan not found: ${planPath}`
453 const planText = await $.fs.read(planPath)
454 assertUniqueTaskIds(planText)
455 const fresh = forPhase(kickoffState(parsed.milestone, discovery.drafts, planText), parsed.phase)
456 const phaseArg = parsed.phase === undefined ? {} : { phase: parsed.phase }
457 const launch: Launch = { workflow: 'milestone-kickoff', args: { milestone: parsed.milestone, goal: parsed.goal, spec, plan, ...phaseArg, ...(await freshStart($, fresh)) } }
458 const state = { ...fresh, pending: launch }
459 const reconciled = reconcilePending(state, await loopEvidence($, state))
460 return [`spec: ${spec}`, `plan: ${plan}`, ...reconciled.dropped, await offerPending($, reconciled.state)].join('\n')
461}
462
463async function repaired($: EngineInterface, before: LoopState, repair: Repair) {
464 if ('error' in repair) return repair.error
465 await writeLoopState($, repair.state)
466 return `before: ${positionLine(before)}\nafter: ${positionLine(repair.state)}`
467}
468
469const words = (text: string) => text.split(/\s+/).filter(Boolean)
470
471async function adopt($: EngineInterface, state: LoopState, args: string) {
472 const [taskId = '', workflow = '', phase] = words(args)
473 const adopted = adoptRun(state, taskId, workflow, phase)
474 if ('error' in adopted || adopted.state.run === undefined) return repaired($, state, adopted)
475 return repaired($, state, { state: { ...adopted.state, run: { ...adopted.state.run, started_at: await $.clock.now() } } })
476}
477
478async function setField($: EngineInterface, state: LoopState, args: string) {
479 const [field = '', value = ''] = words(args)
480 return repaired($, state, setPosition(state, field, value))
481}
482
483const taskIdOfOutput = (outputFile: string) => (outputFile.split('/').pop() ?? '').replace(/\.output$/, '')
484
485const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`
486
487async function collect($: EngineInterface, state: LoopState, outputFile: string) {
488 if (outputFile === '' || !(await $.fs.exists(outputFile))) return 'usage: /ouroboros collect <output-file> (the <output-file> of the workflow notification)'
489 const taskId = taskIdOfOutput(outputFile)
490 if (state.run?.id === taskId) {
491 const { state: settled, note } = await conductLoopResult($, state, state.run, `<task-id>${taskId}</task-id><output-file>${outputFile}</output-file>`)
492 deliverPendingLaunch($, settled)
493 return [`collected ${taskId}`, note].filter(Boolean).join(': ')
494 }
495 const json = workflowResultOf(await $.fs.read(outputFile))
496 const incidents = resultIncidents(json)
497 await fileIncidents($, incidents, resultPhase(json, state))
498 return `${taskId} is not the run in flight: filed its ${plural(incidents.length, 'incident')}, state unchanged`
499}
500
501async function runConductorCommand($: EngineInterface, args: string) {
502 const { head, rest } = splitFirstWord(args)
503 const state = await readLoopState($)
504 if (head === 'status') return statusReport(state)
505 if (head === 'escalations') return escalationsText(state)
506 if (head === 'resume') return resumeLoop($, state)
507 if (head === 'kickoff') return kickoff($, rest)
508 if (head === 'adopt') return adopt($, state, rest)
509 if (head === 'set') return setField($, state, rest)
510 if (head === 'collect') return collect($, state, rest)
511 if (head !== 'pause') return OUROBOROS_COMMAND.description
512 await writeLoopState($, { ...state, paused: true })
513 return 'paused: results are still recorded, launches are queued until /ouroboros resume'
514}
515
516async function fileBriefSlices($: EngineInterface, milestone: string, slices: BriefSlices) {
517 const dir = await projectPath($, `${BRIEFS_DIR}/${milestone}`)
518 for (const file of briefFiles(slices)) await $.fs.write(`${dir}/${file.name}`, file.text)
519 return dir
520}
521
522async function withFiledBrief($: EngineInterface, milestone: string, event: LoopEvent): Promise<LoopEvent> {
523 if (event.type !== 'kickoff-done') return event
524 if (event.slices !== undefined) return { ...event, brief_dir: await fileBriefSlices($, milestone, event.slices) }
525 if (event.brief === '') return event
526 const briefPath = await projectPath($, `${BRIEFS_DIR}/${milestone}.md`)
527 await $.fs.write(briefPath, event.brief)
528 return { ...event, brief_path: briefPath }
529}
530
531async function isPrematureCheckpoint($: EngineInterface, command: string) {
532 const phase = checkpointPhaseOf(command)
533 if (phase === undefined) return false
534 const planText = await activePlanText($)
535 return planText !== undefined && firstUncheckedBox(planText, phase) !== null
536}
537
538const phaseAfter = (state: LoopState, phase: string) => (state.phases.includes(phase) ? state.phases[state.phases.indexOf(phase) + 1] : undefined)
539
540const resultPhase = (json: Record<string, unknown> | undefined, state: LoopState) => (typeof json?.phase === 'string' ? json.phase : (state.current ?? ''))
541
542async function headCommit($: EngineInterface) {
543 const { stdout } = await $.process.run(['git', 'log', '-1', '--format=%H%n%s'])
544 const [sha = '', subject = ''] = stdout.split('\n')
545 return { sha, subject }
546}
547
548const ranTaskIds = (json: Record<string, unknown> | undefined) =>
549 Array.isArray(json?.tasks) ? (json.tasks as Array<{ id?: unknown }>).flatMap(task => (typeof task.id === 'string' ? [task.id] : [])) : []
550
551async function openTasksOf($: EngineInterface, phase: string, json: Record<string, unknown> | undefined) {
552 const planText = await activePlanText($)
553 if (planText === undefined) return []
554 const ran = ranTaskIds(json)
555 const blocked = blockedTaskIds(json)
556 return phaseTasks(planText, phase).filter(task => ran.includes(task.id) && !blocked.includes(task.id) && task.unchecked > 0).map(task => task.id)
557}
558
559async function withVerifiedCheckpoint($: EngineInterface, event: LoopEvent, json: Record<string, unknown> | undefined) {
560 if (event.type !== 'phase-result' || event.status !== 'checkpointed') return event
561 return verifiedCheckpoint(event, {
562 head: await headCommit($),
563 phaseCommitFound: await hasPhaseCommit($, event.phase),
564 openTasks: await openTasksOf($, event.phase, json),
565 })
566}
567
568async function appendToPlan($: EngineInterface, section: string) {
569 const drafts = await activeDrafts($)
570 if (section === '' || drafts === null) return
571 const path = await draftsPath($, drafts.plan)
572 if (!(await $.fs.exists(path))) return
573 await $.fs.write(path, (await $.fs.read(path)) + section)
574}
575
576async function workflowOutputText($: EngineInterface, notificationText: string) {
577 const outputFile = outputFileOf(notificationText)
578 if (outputFile === undefined || !(await $.fs.exists(outputFile))) return undefined
579 return $.fs.read(outputFile)
580}
581
582async function conductLoopResult($: EngineInterface, state: LoopState, run: Run, text: string) {
583 await releaseTestResources($, run.id)
584 const resultPath = `${RESULTS_DIR}/${run.id}.json`
585 const outputText = await workflowOutputText($, text)
586 await $.fs.write(await projectPath($, resultPath), outputText ?? text)
587 const json = (outputText === undefined ? undefined : workflowResultOf(outputText)) ?? embeddedJson(text)
588 await openKickoffAdrs($, kickoffDecisionsOf(run, json))
589 if (bareName(run.workflow) === 'phase') await appendToPlan($, phaseFollowUps(json, phaseAfter(state, resultPhase(json, state))))
590 await fileIncidents($, resultIncidents(json), resultPhase(json, state) || bareName(run.workflow))
591 const event = await withVerifiedCheckpoint($, await withFiledBrief($, briefKey(state), loopEventOf(text, resultPath, run, state.current, json)), json)
592 return settle($, nextAction({ ...state, run: undefined }, event, await activePlanText($), laneOwnership(await readConfig($))))
593}
594
595async function conductNotification($: EngineInterface, text: string): Promise<{ filed: string } | { text: string } | undefined> {
596 const state = await readLoopState($)
597 if (state.run === undefined || !isLoopNotification(text, state.run)) return undefined
598 try {
599 const { note } = await conductLoopResult($, state, state.run, text)
600 return note === undefined ? { filed: 'ouroboros conductor filed the result' } : { text: note }
601 } catch (error) {
602 return { text: `${text}\n\n${loudly(error)}` }
603 }
604}
605
606async function finishRetroOf($: EngineInterface, agentId: string) {
607 const state = await readLoopState($)
608 if (state.run?.workflow !== 'retro' || state.run.id !== agentId) return
609 const settled = await settle($, nextAction({ ...state, run: undefined }, { type: 'retro-done' }, await activePlanText($), laneOwnership(await readConfig($))))
610 if (settled.note !== undefined) deliverNote($, settled)
611}
612
613const STALE_RUN_MS = 12 * 3_600_000
614
615async function isLiveWriter($: EngineInterface, run: Run) {
616 if (run.workflow === 'retro' || run.started_at === undefined) return false
617 return (await $.clock.now()) - run.started_at < STALE_RUN_MS
618}
619
620type TestResourceClaim = { run: string; worktree: string; test_db: string; started_at: number }
621
622async function testResourcesPath($: EngineInterface) {
623 const { stdout } = await $.process.run(GIT_COMMON_DIR)
624 return `${stdout.trim()}/ouroboros/test-resources.json`
625}
626
627async function readTestResourceClaims($: EngineInterface, path: string): Promise<TestResourceClaim[]> {
628 return (await $.fs.exists(path)) ? JSON.parse(await $.fs.read(path)) : []
629}
630
631async function claimedTestResources<R extends { result?: unknown; deny?: unknown; isError?: unknown }>($: EngineInterface, args: unknown, launched: R): Promise<R> {
632 const testDbs = testDbsIn(args)
633 if (testDbs.length === 0 || !hasSucceeded(launched)) return launched
634 const path = await testResourcesPath($)
635 const now = await $.clock.now()
636 const live = (await readTestResourceClaims($, path)).filter(claim => now - claim.started_at < STALE_RUN_MS)
637 const shared = sharedHostPorts(testDbs, live.map(claim => claim.test_db))
638 if (shared.length > 0) $.ui.toast(`ouroboros: test servers shared with another run: ${shared.join(', ')}; runs on one server can flush or race each other`)
639 const run = (launched.result as { taskId?: string } | undefined)?.taskId ?? 'workflow'
640 const worktree = await repositoryRoot($)
641 await $.fs.write(path, JSON.stringify([...live, ...testDbs.map(testDb => ({ run, worktree, test_db: testDb, started_at: now }))]))
642 return launched
643}
644
645async function releaseTestResources($: EngineInterface, runId: string) {
646 const path = await testResourcesPath($)
647 const claims = await readTestResourceClaims($, path)
648 if (claims.some(claim => claim.run === runId)) await $.fs.write(path, JSON.stringify(claims.filter(claim => claim.run !== runId)))
649}
650
651async function repositoryRoot($: EngineInterface) {
652 const { stdout } = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
653 return stdout.trim() || (await $.session.cwd())
654}
655
656async function projectPath($: EngineInterface, relative: string) {
657 return `${await repositoryRoot($)}/${relative}`
658}
659
660async function filedResult<T extends { result?: unknown }>($: EngineInterface, tool: string, toolUseId: string | undefined, answered: T): Promise<T> {
661 if (answered.result === undefined || !isOversized(answered.result)) return answered
662 const path = await projectPath($, `${RESULTS_DIR}/${toolUseId ?? 'result'}.json`)
663 await $.fs.write(path, JSON.stringify(answered.result))
664 return { ...answered, result: digestedResult(tool, answered.result, path) }
665}
666
667async function askAdrScribe($: EngineInterface, prompt: string) {
668 await $.agent.spawn({ subagentType: `${$.plugin.name}:adr-scribe`, description: 'adr', prompt })
669}
670
671async function openKickoffAdrs($: EngineInterface, decisions: readonly object[]) {
672 if (decisions.length === 0) return
673 await askAdrScribe($, adrScribePrompt(OPEN_PROPOSED_ADRS, decisions))
674}
675
676async function openProposedAdrs($: EngineInterface, architectText: string) {
677 const decisions = parseDecisions(architectText)
678 if (decisions.length === 0) return
679 await askAdrScribe($, adrScribePrompt(OPEN_PROPOSED_ADRS, decisions))
680}
681
682const planningLessonsPath = ($: EngineInterface, file: string) => `${$.plugin.root}/skills/planning-lessons/${file}`
683
684async function isDraft($: EngineInterface, path: string) {
685 return isDraftPath((await readConfig($)).drafts_dir, path)
686}
687
688async function foldDraftChange($: EngineInterface, draftPath: string) {
689 await askAdrScribe($, `${FOLD_DRAFT_CHANGE}: ${draftPath}`)
690}
691
692async function planningLessonsText($: EngineInterface) {
693 return stripFrontmatter(await $.fs.read(planningLessonsPath($, 'SKILL.md')))
694}
695
696async function planningState($: EngineInterface) {
697 const { value = PLANNING_IDLE } = await $.state.get(PLANNING)
698 return value
699}
700
701async function markPlanningSkillRan($: EngineInterface) {
702 await $.state.set(PLANNING, { active: true, ranThisTurn: true })
703}
704
705async function settlePlanningAfterTurn($: EngineInterface) {
706 const planning = await planningState($)
707 await $.state.set(PLANNING, { active: planning.active && planning.ranThisTurn, ranThisTurn: false })
708}
709
710async function logCandidateLesson($: EngineInterface, promptText: string) {
711 if (!(await planningState($)).active) return
712 const lessons: Finding = { summary: '', root_cause: 'skill-gap', skill: `${$.plugin.name}:planning-lessons` }
713 await appendIncident($, incidentLogPath(lessons, await incidentPlaces($)), candidateRow(await todayIso($), await currentPhase($), promptText))
714}
715
716async function subdirectories($: EngineInterface, path: string) {
717 if (!(await $.fs.exists(path))) return []
718 return (await $.fs.list(path)).filter(entry => entry.kind === 'dir').map(entry => `${path}/${entry.name}`)
719}
720
721const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
722
723async function manifestSkills($: EngineInterface, versionDir: string): Promise<string | string[] | undefined> {
724 const manifestPath = `${versionDir}/.claude-plugin/plugin.json`
725 if (!(await $.fs.exists(manifestPath))) return undefined
726 return JSON.parse(await $.fs.read(manifestPath)).skills
727}
728
729async function installedPluginSkillRoots($: EngineInterface, home: string) {
730 const roots: Array<[string, string[] | undefined, string]> = []
731 for (const marketplace of await subdirectories($, pluginCacheDir(home))) {
732 for (const plugin of await subdirectories($, marketplace)) {
733 for (const version of await subdirectories($, plugin)) {
734 for (const [root, names] of pluginSkillRoots(version, await manifestSkills($, version))) roots.push([root, names, baseName(plugin)])
735 }
736 }
737 }
738 return roots
739}
740
741async function skillNamesUnder($: EngineInterface, root: string, names: string[] | undefined, pluginPrefix: string | undefined): Promise<SkillListing[number]> {
742 return [root, names ?? (await subdirectories($, root)).map(baseName), pluginPrefix]
743}
744
745async function buildSkillIndex($: EngineInterface) {
746 const home = (await $.env.get('HOME')) ?? ''
747 const [projectRoot, pluginRoot, userRoot] = fixedSkillRoots(await repositoryRoot($), $.plugin.root, home)
748 const roots: Array<[string, string[] | undefined, string | undefined]> = [
749 [projectRoot as string, undefined, undefined],
750 [pluginRoot as string, undefined, $.plugin.name],
751 [userRoot as string, undefined, undefined],
752 ]
753 const listing = await Promise.all([...roots, ...(await installedPluginSkillRoots($, home))].map(([root, names, prefix]) => skillNamesUnder($, root, names, prefix)))
754 const index = indexSkills(listing)
755 await $.state.set(SKILL_INDEX, index)
756 return index
757}
758
759async function skillIndex($: EngineInterface): Promise<SkillIndex> {
760 const { value } = await $.state.get(SKILL_INDEX)
761 return value ?? buildSkillIndex($)
762}
763
764async function readConfig($: EngineInterface): Promise<OuroborosConfig> {
765 if (!(await $.fs.exists(CONFIG_PATH))) return {}
766 return JSON.parse(await $.fs.read(CONFIG_PATH))
767}
768
769export async function mainCheckoutRoot($: EngineInterface) {
770 const { stdout } = await $.process.run(GIT_COMMON_DIR)
771 return checkoutRootOf(stdout)
772}
773
774export async function draftsPath($: EngineInterface, relative: string) {
775 return draftsPathOf(await mainCheckoutRoot($), (await readConfig($)).drafts_dir, relative)
776}
777
778export async function activeDrafts($: EngineInterface) {
779 return activeDraftsOf((await $.fs.exists(STATE_PATH)) ? await $.fs.read(STATE_PATH) : undefined)
780}
781
782const agentDefinitionPath = ($: EngineInterface, agent: string) => `${$.plugin.root}/agents/${agent}.md`
783
784async function inlineSkills($: EngineInterface, refs: SkillRef[]): Promise<InlinedSkill[] | { deny: string }> {
785 const index = await skillIndex($)
786 const missing = refs.find(ref => resolveSkill(index, ref) === undefined)
787 if (missing !== undefined) return { deny: `eager skill not found: ${missing.name}` }
788 const resolved = refs.flatMap(ref => resolveSkill(index, ref) ?? [])
789 return Promise.all(resolved.map(async skill => ({ ref: skill.ref, body: stripFrontmatter(await $.fs.read(skill.path)) })))
790}
791
792async function eagerSkillsFor($: EngineInterface, config: OuroborosConfig, agent: string, lane: string | undefined) {
793 const skills = await inlineSkills($, eagerSkillNames(config, agent, lane))
794 if ('deny' in skills) return skills
795 const block = eagerBlock(skills)
796 const budget = checkBudget(block, config.eager_skills_max_chars ?? DEFAULT_EAGER_SKILLS_MAX_CHARS, sizesOf(skills))
797 if (!budget.ok) return { deny: budget.reason }
798 return { block, skills }
799}
800
801async function prepareEagerSpawn($: EngineInterface, agent: string, prompt: string): Promise<{ deny: string } | EagerSpawn> {
802 const lane = laneOf(prompt)
803 const eager = await eagerSkillsFor($, await readConfig($), agent, lane)
804 if ('deny' in eager) return eager
805 return { prompt: eager.block + prompt, lane, skills: await shaOf(eager.skills) }
806}
807
808async function writeEagerFiles($: EngineInterface): Promise<{ deny: string } | { dir: string }> {
809 const config = await readConfig($)
810 const dir = await projectPath($, EAGER_DIR)
811 await ensureRuntimeIgnored($)
812 for (const seat of workflowSeats(config)) {
813 const eager = await eagerSkillsFor($, config, seat.role, seat.lane)
814 if ('deny' in eager) return eager
815 await $.fs.write(`${dir}/${eagerFileName(seat)}`, eager.block + laneEnvNotes(config, seat))
816 }
817 return { dir }
818}
819
820async function recordSpawn($: EngineInterface, agent: string, spawn: EagerSpawn) {
821 const existing = (await $.fs.exists(SPAWNS_LOG)) ? await $.fs.read(SPAWNS_LOG) : ''
822 await $.fs.write(SPAWNS_LOG, `${existing}${JSON.stringify({ agent, lane: spawn.lane, skills: spawn.skills })}\n`)
823}
824
825async function addressableAgentName($: EngineInterface, agentId: string) {
826 const agents = await $.agent.list()
827 return agents.find(agent => agent.id === agentId)?.name
828}
829
830async function isPastRollover($: EngineInterface, usage: TurnUsage) {
831 const { context } = await $.session.usage()
832 return shouldRollOver(contextShare(usage, context.window))
833}
834
835async function retiringAgents($: EngineInterface) {
836 const { value = [] } = await $.state.get(RETIRING)
837 return value
838}
839
840async function retire($: EngineInterface, agentId: string, agentName: string) {
841 await $.session.send({ to: { agentId }, text: memoryDigestRequest(agentName) })
842 await $.state.set(RETIRING, [...new Set([...(await retiringAgents($)), agentName])])
843}
844
845async function rollOverIfPast($: EngineInterface, agentId: string, usage: TurnUsage) {
846 const agentName = await addressableAgentName($, agentId)
847 if (agentName === undefined) return
848 if (!(await isPastRollover($, usage))) return
849 await retire($, agentId, agentName)
850}
851
852async function spawnFresh($: EngineInterface, agentName: string, prompt: string) {
853 await $.state.set(RETIRING, (await retiringAgents($)).filter(name => name !== agentName))
854 const spawned = await $.agent.spawn({
855 subagentType: `${$.plugin.name}:${agentName}`,
856 description: `fresh ${agentName}`,
857 prompt,
858 name: agentName,
859 })
860 if (spawned.deny !== undefined) return { deny: spawned.deny }
861 return { result: { success: true, message: `${agentName} rolled over to a fresh instance ${spawned.agentId ?? ''}` } }
862}
863
864export const register: Register = on => {
865 on('plugin.register', async ($, e, next) => {
866 const active = { name: $.plugin.name, root: $.plugin.root }
867 if (!isAnotherInstance(e, active)) return next(e)
868 const warning = duplicateLoadWarning(active, e)
869 $.ui.toast(warning)
870 return { refuse: warning }
871 })
872
873 on('session.start', async ($, e, next) => {
874 await $.command.register(SKILL_INCIDENT_COMMAND)
875 await $.command.register(OUROBOROS_COMMAND)
876 await buildSkillIndex($)
877 return next(e)
878 })
879
880 on('agent.spawn', async ($, e, next) => {
881 if (e.subagentType === undefined) return next(e)
882 const agent = agentRole(e.subagentType)
883 if (!(await $.fs.exists(agentDefinitionPath($, agent)))) return next(e)
884 const prepared = await prepareEagerSpawn($, agent, e.prompt)
885 if ('deny' in prepared) return { deny: prepared.deny }
886 const spawned = await next({ ...e, prompt: prepared.prompt })
887 if (spawned.deny === undefined) await recordSpawn($, agent, prepared)
888 return spawned
889 })
890
891 on('command.run', { command: SKILL_INCIDENT_COMMAND.name }, async ($, e) => {
892 const { head: skill, rest: text } = splitFirstWord(e.args)
893 await logUserCorrection($, skill, text)
894 await showStatus($)
895 return { text: `logged against ${skill}` }
896 })
897
898 on('command.run', { command: OUROBOROS_COMMAND.name }, async ($, e) => {
899 try {
900 return { text: await runConductorCommand($, e.args) }
901 } catch (error) {
902 return { text: loudly(error) }
903 }
904 })
905
906 on('session.receive', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
907 if (e.agentId !== undefined) return next(e)
908 const conducted = await conductNotification($, e.text)
909 if (conducted === undefined) return next(e)
910 if ('filed' in conducted) return { consumed: conducted.filed }
911 return next({ ...e, text: conducted.text })
912 })
913
914 on('prompt.context', async ($, e, next) => {
915 if (!(await $.fs.exists(STATE_PATH))) return next(e)
916 const merged = await noticedMerge($, await readLoopState($)).catch(loudly)
917 const header = loopHeader(await readLoopState($))
918 return next({ ...e, blocks: [...e.blocks, { name: 'ouroboros', text: merged === undefined ? header : `${header}\n${merged}` }] })
919 })
920
921 on('skill.prompt', async ($, e, next) => {
922 if (!isPlanningSkill((await readConfig($)).planning_skills, e.skill)) return next(e)
923 const shown = await next(e)
924 await markPlanningSkillRan($)
925 return { text: withPlanningLessons(shown.text, await planningLessonsText($)) }
926 })
927
928 on('prompt.submit', async ($, e, next) => {
929 if (e.origin.kind !== 'task-notification') {
930 await logCandidateLesson($, e.text)
931 return next(e)
932 }
933 const conducted = await conductNotification($, e.text)
934 if (conducted === undefined) return next(e)
935 if ('filed' in conducted) return { drop: conducted.filed }
936 return next({ ...e, text: conducted.text })
937 })
938
939 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
940 const agentType = agentRole(e.subagent_type ?? '')
941 if (IMPLEMENTER_AGENTS.has(agentType)) return (await retroPendingDenial($)) ?? next(e)
942
943 const answered = await next(e)
944 const filed = isLoopAgent($, e.subagent_type) ? await filedResult($, 'Agent', e.tool_use_id, answered) : answered
945 if (!REVIEWING_AGENTS.has(agentType) || !hasSucceeded(answered)) return filed
946
947 await logIncidents($, agentText(answered.result))
948 if (agentType === 'architect') await openProposedAdrs($, agentText(answered.result))
949 await showStatus($)
950 return filed
951 })
952
953 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
954 const written = await next(e)
955 if (!hasSucceeded(written) || !(await isDraft($, e.file_path))) return written
956 await foldDraftChange($, e.file_path)
957 return written
958 })
959
960 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
961 const edited = await next(e)
962 if (!hasSucceeded(edited) || !(await isDraft($, e.file_path))) return edited
963 await foldDraftChange($, e.file_path)
964 return edited
965 })
966
967 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
968 if (e.agentId === undefined && e.resumeFromRunId !== undefined) {
969 const resumed = await next(e)
970 if (hasSucceeded(resumed)) await followResumedRun($, e.resumeFromRunId, (resumed.result ?? {}) as Launched)
971 return resumed
972 }
973 const live = e.agentId === undefined && launchesLoopWork(e) ? await liveRunOf($) : undefined
974 if (live !== undefined) return { deny: stillRunning(live) }
975 const denial = isPhaseWorkflow(e) ? await retroPendingDenial($) : undefined
976 if (denial !== undefined) return denial
977 if (!isLoopWorkflow(e.name)) return claimedTestResources($, e.args, await next(e))
978 const eager = await writeEagerFiles($)
979 if ('deny' in eager) return { deny: eager.deny }
980 const launched = await next({ ...e, args: withEagerDir(e.args, eager.dir) })
981 if (hasSucceeded(launched)) await recordLaunchedWorkflow($, e.name, (launched.result ?? {}) as Launched)
982 return filedResult($, 'Workflow', e.tool_use_id, await claimedTestResources($, e.args, launched))
983 })
984
985 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
986 const recipient = String(e.to)
987 if (typeof e.message !== 'string') return next(e)
988 if (!(await retiringAgents($)).includes(recipient)) return next(e)
989 return spawnFresh($, recipient, e.message)
990 })
991
992 on('turn.complete', async ($, e, next) => {
993 if (e.agentId === undefined) await settlePlanningAfterTurn($)
994 if (e.agentId !== undefined) await finishRetroOf($, e.agentId).catch(error => $.ui.toast(loudly(error)))
995 if (e.agentId !== undefined && e.usage !== undefined) await rollOverIfPast($, e.agentId, e.usage)
996 return next(e)
997 })
998
999 on('session.compact', async ($, e, next) => {
1000 if (e.agentId !== undefined) return next({ ...e, instructions: SUBAGENT_COMPACTION_INSTRUCTIONS })
1001 if (!(await $.fs.exists(STATE_PATH))) return next(e)
1002 return next({ ...e, instructions: COMPACT_INSTRUCTIONS })
1003 })
1004
1005 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1006 const updating = e.agentId === undefined && isPluginChange(e.command) ? await liveRunOf($) : undefined
1007 if (updating !== undefined) return { deny: `no plugin update now: ${stillRunning(updating)}` }
1008 const run = isGuardedMerge(e.command, e.agentId) ? (await readLoopState($)).run : undefined
1009 const writer = run !== undefined && (await isLiveWriter($, run)) ? run : undefined
1010 if (writer !== undefined) return { deny: `no merge or rebase now: ${writer.workflow} (${writer.id}) is writing to this worktree; once it has ended, /ouroboros collect <its output-file> files it and clears the marker` }
1011 const ran = await next(e)
1012 if (!isRetroTrigger(e.command, hasSucceeded(ran))) return ran
1013 if (await isPrematureCheckpoint($, e.command)) return ran
1014 if ((await readLoopState($)).run !== undefined) return ran
1015 if (isPullRequestMerge(e.command) && (await readLoopState($)).status === 'phase') return ran
1016
1017 await startRetro($)
1018 if (isPullRequestMerge(e.command)) await askAdrScribe($, ACCEPT_MILESTONE_ADRS)
1019 return ran
1020 })
1021}
1022hooks/adr.ts 27 lines1export type Decision = { title: string; context: string; decision: string; alternatives: string; consequences: string }
2
3const DECISIONS_BLOCK = /```json\s*(\{[\s\S]*?"decisions"[\s\S]*?\})\s*```/
4
5export const OPEN_PROPOSED_ADRS = 'open Proposed ADRs'
6export const FOLD_DRAFT_CHANGE =
7 'fold this draft change into its active ADR: note it under Implementation (or Outcome once Accepted); plan drift is never a skill incident'
8export const ACCEPT_MILESTONE_ADRS =
9 'fill Outcome and Verification, set Accepted, run the coverage check with the auditor, prune covered drafts'
10
11export const parseDecisions = (architectText: string): Decision[] => {
12 const match = DECISIONS_BLOCK.exec(architectText)
13 if (!match) return []
14 return JSON.parse(match[1] as string).decisions
15}
16
17const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
18
19export const isDraftPath = (draftsDir: string | undefined, path: string) => {
20 if (draftsDir === undefined) return false
21 return new RegExp(`(^|/)${escapeRegExp(draftsDir)}/(specs|plans)/`).test(path)
22}
23
24export const adrScribePrompt = (instruction: string, decisions: readonly object[]) =>
25 `${instruction}\n\n\`\`\`json\n${JSON.stringify({ decisions })}\n\`\`\``
26
27hooks/drafts.ts 102 lines1export type PhaseTask = { id: string; title: string; line: number; unchecked: number; lane?: string }
2export type UncheckedBox = { task: string; line: number; text: string }
3export type ActiveDrafts = { spec: string; plan: string }
4
5export const STATE_PATH = '.claude/ouroboros/state.json'
6export const GIT_COMMON_DIR = ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir']
7const TASK_HEADING = /^### Task (\S+?):\s+(.*)$/
8const PHASE_TAG = /\s*\((P\d+)(?:,\s*([\w-]+))?\)\s*$/
9const UNCHECKED_BOX = /^\s*- \[ \] (.*)$/
10const DROPPED_BOX = /^~~.*~~ dropped:/
11const NEEDS_A_PERSON = /\(needs: [^)]+\)/
12
13type Heading = { id: string; title: string; phase: string | undefined; lane: string | undefined; index: number }
14
15export const checkoutRootOf = (gitCommonDirStdout: string) => {
16 const commonDir = gitCommonDirStdout.trim()
17 return commonDir.slice(0, commonDir.lastIndexOf('/'))
18}
19
20export const draftsPathOf = (checkoutRoot: string, draftsDir: string | undefined, relative: string) => {
21 if (draftsDir === undefined) throw new Error('drafts_dir missing from .claude/ouroboros.json')
22 return `${checkoutRoot}/${draftsDir}/${relative}`
23}
24
25export const activeDraftsOf = (stateJson: string | undefined): ActiveDrafts | null => {
26 if (stateJson === undefined) return null
27 return JSON.parse(stateJson).drafts ?? null
28}
29
30const headingAt = (line: string, index: number): Heading | undefined => {
31 const match = TASK_HEADING.exec(line)
32 if (!match) return undefined
33 const rawTitle = match[2] as string
34 const tag = PHASE_TAG.exec(rawTitle)
35 return { id: match[1] as string, title: rawTitle.replace(PHASE_TAG, ''), phase: tag?.[1], lane: tag?.[2], index }
36}
37
38const headingsOf = (lines: string[]) => lines.flatMap((line, index) => headingAt(line, index) ?? [])
39
40const duplicateIds = (headings: Heading[]) => [...new Set(headings.map(heading => heading.id).filter((id, position, ids) => ids.indexOf(id) !== position))]
41
42export const assertUniqueTaskIds = (planText: string) => {
43 const duplicates = duplicateIds(headingsOf(planText.split('\n')))
44 if (duplicates.length > 0) throw new Error(`the plan has duplicate task ids: ${duplicates.join(', ')}; renumber them before running a phase`)
45}
46
47const inPhase = (headings: Heading[], phase: string) => {
48 if (headings.every(heading => heading.phase === undefined)) return headings
49 return headings.filter(heading => heading.phase === phase)
50}
51
52const sectionEnd = (lines: string[], start: number) => {
53 const next = lines.findIndex((line, index) => index > start && TASK_HEADING.test(line))
54 return next === -1 ? lines.length : next
55}
56
57const openBoxText = (line: string) => {
58 const text = UNCHECKED_BOX.exec(line)?.[1]
59 if (text === undefined || DROPPED_BOX.test(text) || NEEDS_A_PERSON.test(text)) return undefined
60 return text
61}
62
63const openBoxesIn = (lines: string[], heading: Heading): UncheckedBox[] =>
64 lines.slice(heading.index + 1, sectionEnd(lines, heading.index)).flatMap((line, offset) => {
65 const text = openBoxText(line)
66 return text === undefined ? [] : [{ task: heading.id, line: heading.index + offset + 2, text }]
67 })
68
69const isFollowUp = (heading: Heading) => heading.id.endsWith('-follow-ups')
70
71export const untaggedOpenTasks = (planText: string) => {
72 const lines = planText.split('\n')
73 const headings = headingsOf(lines)
74 if (headings.every(heading => heading.phase === undefined)) return []
75 return headings.filter(heading => heading.phase === undefined && !isFollowUp(heading) && openBoxesIn(lines, heading).length > 0).map(heading => heading.id)
76}
77
78export const phaseTasks = (planText: string, phase: string): PhaseTask[] => {
79 assertUniqueTaskIds(planText)
80 const lines = planText.split('\n')
81 return inPhase(headingsOf(lines), phase).map(heading => ({
82 id: heading.id,
83 title: heading.title,
84 line: heading.index + 1,
85 unchecked: openBoxesIn(lines, heading).length,
86 ...(heading.lane === undefined ? {} : { lane: heading.lane }),
87 }))
88}
89
90export const firstUncheckedBox = (planText: string, phase: string): UncheckedBox | null => {
91 const lines = planText.split('\n')
92 return inPhase(headingsOf(lines), phase).flatMap(heading => openBoxesIn(lines, heading))[0] ?? null
93}
94
95const phaseNumber = (phase: string) => Number(phase.slice(1))
96
97export const planPhases = (planText: string): string[] => {
98 const tagged = [...new Set(headingsOf(planText.split('\n')).flatMap(heading => heading.phase ?? []))]
99 if (tagged.length === 0) return ['P0']
100 return tagged.sort((left, right) => phaseNumber(left) - phaseNumber(right))
101}
102hooks/conductor/briefs.ts 17 lines1export type TaskSlice = { id: string; guidance: string; touches: string[] }
2export type BriefSlices = { common: string; tasks: TaskSlice[] }
3
4const isSliced = (brief: unknown): brief is BriefSlices =>
5 brief !== null && typeof brief === 'object' && typeof (brief as BriefSlices).common === 'string' && Array.isArray((brief as BriefSlices).tasks)
6
7export const briefSlicesOf = (json: Record<string, unknown> | undefined) => (isSliced(json?.brief) ? json.brief : undefined)
8
9const touchesText = (touches: string[]) => (touches.length === 0 ? '' : `\nTouches:\n${touches.map(file => `- ${file}\n`).join('')}`)
10
11export const briefFiles = (slices: BriefSlices) => [
12 { name: 'common.md', text: `${slices.common}\n` },
13 ...slices.tasks.map(task => ({ name: `${task.id}.md`, text: `${task.guidance}\n${touchesText(task.touches)}` })),
14]
15
16export const touchesByTask = (slices: BriefSlices): Record<string, string[]> => Object.fromEntries(slices.tasks.map(task => [task.id, task.touches]))
17hooks/conductor/digest.ts 27 lines1export const RESULT_LIMIT = 4000
2const VALUE_LIMIT = 60
3
4const clip = (text: string) => (text.length <= VALUE_LIMIT ? text : `${text.slice(0, VALUE_LIMIT)}…`)
5
6const field = ([key, value]: [string, unknown]) => {
7 if (Array.isArray(value)) return `${key}[${value.length}]`
8 if (value === null || typeof value === 'object') return undefined
9 return `${key}=${clip(String(value))}`
10}
11
12export const digestLine = (result: object) => Object.entries(result).flatMap(entry => field(entry) ?? []).join(' ')
13
14const summary = (result: object, path: string) => `${digestLine(result)}\nfull result: ${path}`
15
16const textOf = (result: unknown) => (typeof result === 'string' ? result : JSON.stringify(result) ?? '')
17
18export const isOversized = (result: unknown) => textOf(result).length > RESULT_LIMIT
19
20export const digestedResult = (tool: string, result: unknown, path: string): unknown => {
21 if (!isOversized(result)) return result
22 const record = result as Record<string, unknown>
23 if (tool === 'Bash') return { ...record, stdout: summary({ ...record, stdout: clip(String(record.stdout ?? '')) }, path) }
24 if (tool === 'Agent') return { ...record, content: [{ type: 'text', text: summary({ blocks: record.content }, path) }] }
25 return { ...record, summary: summary(record, path) }
26}
27hooks/conductor/events.ts 109 lines1import type { Run } from './state'
2import { briefSlicesOf } from './briefs'
3import type { LoopEvent } from './transitions'
4
5const PLUGIN_PREFIX = /^[\w-]+:/
6const OUTPUT_FILE = /<output-file>([^<]+)<\/output-file>/
7
8export const bareName = (name: string) => name.replace(PLUGIN_PREFIX, '')
9
10export const outputFileOf = (notificationText: string) => OUTPUT_FILE.exec(notificationText)?.[1]
11
12export const workflowResultOf = (outputFileText: string): Record<string, unknown> | undefined => {
13 try {
14 const { result } = JSON.parse(outputFileText) as { result?: unknown }
15 return result !== null && typeof result === 'object' ? (result as Record<string, unknown>) : undefined
16 } catch {
17 return undefined
18 }
19}
20
21const escaped = (literal: string) => literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
22
23export const isLoopNotification = (text: string, run: Run | undefined) =>
24 run !== undefined && new RegExp(`(^|[^\\w-])${escaped(run.id)}([^\\w-]|$)`).test(text)
25
26const parseFrom = (text: string, start: number, end: number) => {
27 try {
28 return JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
29 } catch {
30 return undefined
31 }
32}
33
34export const embeddedJson = (text: string): Record<string, unknown> | undefined => {
35 const end = text.lastIndexOf('}')
36 for (let start = text.indexOf('{'); start !== -1 && start < end; start = text.indexOf('{', start + 1)) {
37 const parsed = parseFrom(text, start, end)
38 if (parsed !== undefined) return parsed
39 }
40 return undefined
41}
42
43type BlockedTask = { id?: unknown; reason?: unknown }
44
45const blockedLine = (task: BlockedTask) => `${String(task.id)}: ${typeof task.reason === 'string' ? task.reason : 'blocked'}`
46
47const blockedOf = (json: Record<string, unknown> | undefined) =>
48 Array.isArray(json?.blocked_tasks) && json.blocked_tasks.length > 0 ? { blocked: (json.blocked_tasks as BlockedTask[]).map(blockedLine) } : {}
49
50export const blockedTaskIds = (json: Record<string, unknown> | undefined) =>
51 Array.isArray(json?.blocked_tasks) ? (json.blocked_tasks as BlockedTask[]).flatMap(task => (typeof task.id === 'string' ? [task.id] : [])) : []
52
53const phaseEvent = (json: Record<string, unknown> | undefined, phase: string | null, resultPath: string): LoopEvent => ({
54 type: 'phase-result',
55 status: json?.status === 'checkpointed' ? 'checkpointed' : 'escalate',
56 phase: typeof json?.phase === 'string' ? json.phase : (phase ?? ''),
57 result_path: resultPath,
58 ...(typeof json?.failing_gate === 'string' && json.failing_gate !== '' ? { failing_gate: json.failing_gate } : {}),
59 ...(typeof json?.pr_url === 'string' && json.pr_url !== '' && json.merged !== true ? { pr_url: json.pr_url } : {}),
60 ...(typeof json?.checkpoint_sha === 'string' && json.checkpoint_sha !== '' ? { checkpoint_sha: json.checkpoint_sha } : {}),
61 ...blockedOf(json),
62})
63
64const exitEvent = (json: Record<string, unknown> | undefined): LoopEvent => {
65 if (json?.merged === true) return { type: 'exit-result', merged: true }
66 return { type: 'exit-result', merged: false, failing_gate: typeof json?.failing_gate === 'string' ? json.failing_gate : 'result unreadable' }
67}
68
69export const kickoffDecisionsOf = (run: Run, json: Record<string, unknown> | undefined): object[] => {
70 if (bareName(run.workflow) !== 'milestone-kickoff' || !Array.isArray(json?.decisions)) return []
71 return json.decisions
72}
73
74const kickoffEvent = (json: Record<string, unknown> | undefined): LoopEvent => {
75 if (typeof json?.error === 'string' && json.error !== '') return { type: 'kickoff-done', brief: '', failed: json.error }
76 const slices = briefSlicesOf(json)
77 if (slices !== undefined) return { type: 'kickoff-done', brief: '', slices }
78 return { type: 'kickoff-done', brief: typeof json?.brief === 'string' ? json.brief : '' }
79}
80
81export const loopEventOf = (text: string, resultPath: string, run: Run, currentPhase: string | null, json = embeddedJson(text)): LoopEvent => {
82 const workflow = bareName(run.workflow)
83 if (workflow === 'milestone-kickoff') return kickoffEvent(json)
84 if (workflow === 'retro') return { type: 'retro-done' }
85 if (workflow === 'milestone-exit') return exitEvent(json)
86 return phaseEvent(json, currentPhase, resultPath)
87}
88
89export type CheckpointProof = { head: { sha: string; subject: string }; phaseCommitFound: boolean; openTasks: string[] }
90
91type PhaseResult = Extract<LoopEvent, { type: 'phase-result' }>
92
93const isSameCommit = (left: string, right: string) => left !== '' && right !== '' && (left.startsWith(right) || right.startsWith(left))
94
95const commitGap = (event: PhaseResult, proof: CheckpointProof) => {
96 if (event.checkpoint_sha === undefined) return proof.phaseCommitFound ? undefined : `the workflow reported checkpointed but no phase(${event.phase}) commit is on the branch`
97 if (isSameCommit(event.checkpoint_sha, proof.head.sha) && proof.head.subject.startsWith(`phase(${event.phase}):`)) return undefined
98 return `the workflow reported checkpoint ${event.checkpoint_sha} but HEAD is ${proof.head.sha.slice(0, 8)} "${proof.head.subject}"`
99}
100
101const boxGap = (proof: CheckpointProof) =>
102 proof.openTasks.length === 0 ? undefined : `the workflow reported checkpointed with open plan boxes in tasks ${proof.openTasks.join(', ')}`
103
104export const verifiedCheckpoint = (event: LoopEvent, proof: CheckpointProof): LoopEvent => {
105 if (event.type !== 'phase-result' || event.status !== 'checkpointed') return event
106 const gap = commitGap(event, proof) ?? boxGap(proof)
107 return gap === undefined ? event : { ...event, status: 'escalate', failing_gate: gap }
108}
109hooks/conductor/header.ts 81 lines1import type { Escalation, Launch, LoopState, LoopStatus } from './state'
2
3export const COMPACT_INSTRUCTIONS = 'Keep only the ouroboros loop header and the open escalations; drop everything else.'
4
5const escalationLine = ({ kind, phase, summary, result_path }: Escalation) => `${kind} ${phase}: ${summary} (${result_path})`
6
7export const escalationsText = (state: LoopState) => (state.escalations.length === 0 ? 'no escalations' : state.escalations.map(escalationLine).join('\n'))
8
9export const workflowCall = (launch: Launch) => `Workflow name=${launch.workflow} args=${JSON.stringify(launch.args)}`
10
11const pendingText = (state: LoopState) => (state.pending === undefined ? 'none' : workflowCall(state.pending))
12
13export const loopHeader = (state: LoopState) =>
14 [
15 `ouroboros ${state.milestone || 'no milestone'} · phase ${state.current ?? '-'} of ${state.phases.join(',') || '-'} · ${state.status}${state.paused ? ' (paused)' : ''}`,
16 `escalations: ${state.escalations.length} open · pending launch: ${pendingText(state)}`,
17 `drafts: ${state.drafts?.plan ?? 'none'} · in flight: ${state.run?.workflow ?? 'nothing'}`,
18 ].join('\n')
19
20const CURRENT_PHASE_STATE: Record<LoopStatus, string> = {
21 idle: 'done',
22 kickoff: 'queued',
23 phase: 'running',
24 retro: 'retro running',
25 exit: 'done',
26 paused: 'paused',
27 escalated: 'escalated',
28}
29
30const lastCellOrPadded = (widths: number[]) => (cell: string, column: number) => (column === widths.length - 1 ? cell : cell.padEnd(widths[column] ?? 0))
31
32const tableLine = (cells: string[], widths: number[]) => cells.map(lastCellOrPadded(widths)).join(' ')
33
34const columnWidths = (rows: string[][]) => (rows[0] ?? []).map((_, column) => Math.max(...rows.map(row => (row[column] ?? '').length)))
35
36const table = (header: string[], rows: string[][]) => {
37 const widths = columnWidths([header, ...rows])
38 return [header, widths.map(width => '-'.repeat(width)), ...rows].map(row => tableLine(row, widths)).join('\n')
39}
40
41const keyValues = (rows: string[][]) => {
42 const widths = columnWidths(rows)
43 return rows.map(row => tableLine(row, widths)).join('\n')
44}
45
46const phaseState = (state: LoopState, phase: string) => {
47 const current = state.current === null ? -1 : state.phases.indexOf(state.current)
48 const position = state.phases.indexOf(phase)
49 if (current === -1 || position > current) return 'queued'
50 if (position < current) return 'done'
51 return CURRENT_PHASE_STATE[state.status]
52}
53
54const launchedPhase = (launch: Launch) => {
55 const { phase } = launch.args as { phase?: unknown }
56 return typeof phase === 'string' ? ` ${phase}` : ''
57}
58
59const pendingLaunch = (state: LoopState) => (state.pending === undefined ? 'none' : `${state.pending.workflow}${launchedPhase(state.pending)} (run /ouroboros resume)`)
60
61const inFlight = (state: LoopState) => (state.run === undefined ? 'nothing' : `${state.run.workflow} (${state.run.id})`)
62
63const escalationTable = (state: LoopState) =>
64 state.escalations.length === 0
65 ? []
66 : ['', table(['escalation', 'phase', 'summary', 'result'], state.escalations.map(({ kind, phase, summary, result_path }) => [kind, phase, summary, result_path]))]
67
68export const statusReport = (state: LoopState) =>
69 [
70 `ouroboros · ${state.milestone || 'no milestone'} · ${state.status}${state.paused ? ' (paused)' : ''}`,
71 '',
72 table(['phase', 'state'], state.phases.map(phase => [phase, phaseState(state, phase)])),
73 '',
74 keyValues([
75 ['in flight', inFlight(state)],
76 ['pending launch', pendingLaunch(state)],
77 ['plan', state.drafts?.plan ?? 'none'],
78 ]),
79 ...escalationTable(state),
80 ].join('\n')
81hooks/conductor/state.ts 44 lines1import type { ActiveDrafts } from '../drafts'
2import { planPhases } from '../drafts'
3
4export type LoopStatus = 'idle' | 'kickoff' | 'phase' | 'retro' | 'exit' | 'paused' | 'escalated'
5export type Launch = { workflow: string; args: object }
6export type Run = { id: string; workflow: string; started_at?: number; run_id?: string; script_path?: string }
7export type Escalation = { kind: 'task-red' | 'check-red' | 'gate-refused'; phase: string; summary: string; result_path: string }
8export type LoopState = {
9 milestone: string
10 phases: string[]
11 current: string | null
12 status: LoopStatus
13 escalations: Escalation[]
14 results: Record<string, string>
15 drafts?: ActiveDrafts
16 brief?: string
17 brief_path?: string
18 brief_dir?: string
19 touches?: Record<string, string[]>
20 awaiting_merge?: { phase: string; pr_url: string }
21 kickoff_phase?: string
22 merge_checked_at?: number
23 run?: Run
24 pending?: Launch
25 paused?: boolean
26}
27
28export const IDLE_STATE: LoopState = { milestone: '', phases: [], current: null, status: 'idle', escalations: [], results: {} }
29
30export const parseState = (json: string | undefined): LoopState => {
31 if (json === undefined) return IDLE_STATE
32 return { ...IDLE_STATE, ...JSON.parse(json) }
33}
34
35export const serializeState = (state: LoopState) => JSON.stringify(state, null, 2)
36
37export const kickoffState = (milestone: string, drafts: ActiveDrafts, planText: string): LoopState => ({
38 ...IDLE_STATE,
39 milestone,
40 phases: planPhases(planText),
41 status: 'kickoff',
42 drafts,
43})
44hooks/discover.ts 53 lines1import type { ActiveDrafts } from './drafts'
2
3export type DraftFile = { path: string; text: string }
4export type Discovery = { drafts: ActiveDrafts } | { error: string }
5export type KickoffArgs = { milestone?: string; spec?: string; plan?: string; phase?: string; goal: string }
6
7const TASK_HEADING = /^### Task \S+?:/m
8const CHECKBOX = /^\s*- \[[ x]\] /m
9const SPEC_LINE = /^\**Spec:?\**:?\s*`?([^`\s]+\.md)`?/im
10const DATE_PREFIX = /^\d{4}-\d{2}-\d{2}-/
11const ROLE_SUFFIX = /-(design|spec|plan)$/
12
13const isMarkdown = (word: string | undefined) => word?.endsWith('.md') === true
14
15const PHASE_FLAG = /(?:^|\s)--phase\s+(P\d+)\b/
16
17const withPhase = (parsed: KickoffArgs, phase: string | undefined): KickoffArgs => (phase === undefined ? parsed : { ...parsed, phase })
18
19export const kickoffArgs = (args: string): KickoffArgs => {
20 const phase = PHASE_FLAG.exec(args)?.[1]
21 const [milestone, second, third, ...rest] = args.replace(PHASE_FLAG, ' ').split(/\s+/).filter(Boolean)
22 if (milestone === undefined) return { goal: '' }
23 if (isMarkdown(second) && isMarkdown(third)) return withPhase({ milestone, spec: second, plan: third, goal: rest.join(' ') }, phase)
24 return withPhase({ milestone, goal: [second, third, ...rest].filter(Boolean).join(' ') }, phase)
25}
26
27const isPlan = (file: DraftFile) => TASK_HEADING.test(file.text) && CHECKBOX.test(file.text)
28
29const namesMilestone = (file: DraftFile, milestone: string) => new RegExp(`\\b${milestone}\\b`).test(file.text)
30
31const slugOf = (path: string) => (path.split('/').pop() ?? path).replace(/\.md$/, '').replace(DATE_PREFIX, '').replace(ROLE_SUFFIX, '')
32
33const choosePlan = (plans: DraftFile[], milestone: string) => plans.find(file => namesMilestone(file, milestone)) ?? plans[0]
34
35const specFromLine = (plan: DraftFile, others: DraftFile[], draftsDir: string) => {
36 const reference = SPEC_LINE.exec(plan.text)?.[1]
37 if (reference === undefined) return undefined
38 const relative = reference.startsWith(`${draftsDir}/`) ? reference.slice(draftsDir.length + 1) : reference
39 return others.find(file => file.path === relative || relative.endsWith(`/${file.path}`) || file.path.endsWith(`/${relative}`))
40}
41
42const specBySlug = (plan: DraftFile, others: DraftFile[]) => others.find(file => slugOf(file.path) === slugOf(plan.path))
43
44export const discoverDrafts = (files: DraftFile[], milestone: string, draftsDir: string): Discovery => {
45 const plans = files.filter(isPlan)
46 const plan = choosePlan(plans, milestone)
47 if (plan === undefined) return { error: `no plan with task checkboxes under ${draftsDir}; pass /ouroboros kickoff ${milestone} <spec> <plan>` }
48 const others = files.filter(file => !isPlan(file))
49 const spec = specFromLine(plan, others, draftsDir) ?? specBySlug(plan, others)
50 if (spec === undefined) return { error: `found plan ${plan.path} but no spec (no Spec: line, no file sharing its name); pass /ouroboros kickoff ${milestone} <spec> ${plan.path}` }
51 return { drafts: { plan: plan.path, spec: spec.path } }
52}
53hooks/conductor/reconcile.ts 59 lines1import { firstUncheckedBox, phaseTasks } from '../drafts'
2import { bareName } from './events'
3import type { LaneOwnership } from './lanes'
4import type { LoopState } from './state'
5import { exitLaunch, phaseLaunch } from './transitions'
6
7export type Evidence = { briefPath?: string; committedPhases: string[]; planText?: string; lanes?: LaneOwnership }
8
9const everyPhaseCarriesTasks = (phases: string[], planText: string | undefined) =>
10 planText !== undefined && phases.every(phase => phaseTasks(planText, phase).length > 0)
11
12const kickoffRunReason = (state: LoopState, evidence: Evidence) => {
13 const recorded = state.results['kickoff']
14 if (recorded !== undefined) return `its result was recorded at ${recorded}`
15 if (evidence.briefPath === undefined || !everyPhaseCarriesTasks(state.phases, evidence.planText)) return undefined
16 return `its brief exists at ${evidence.briefPath} and every plan phase (${state.phases.join(', ')}) already carries tasks`
17}
18
19const phaseRunReason = (phase: string, evidence: Evidence) => {
20 if (!evidence.committedPhases.includes(phase) || evidence.planText === undefined) return undefined
21 if (firstUncheckedBox(evidence.planText, phase) !== null) return undefined
22 return `a phase(${phase}) commit is on the branch and none of its plan boxes is open`
23}
24
25const pendingPhase = (state: LoopState) => {
26 const { phase } = (state.pending?.args ?? {}) as { phase?: unknown }
27 return typeof phase === 'string' ? phase : undefined
28}
29
30const staleness = (state: LoopState, evidence: Evidence) => {
31 const workflow = bareName(state.pending?.workflow ?? '')
32 if (workflow === 'milestone-kickoff') return { label: 'milestone-kickoff', reason: kickoffRunReason(state, evidence), next: state.phases[0] }
33 const phase = pendingPhase(state)
34 if (workflow !== 'phase' || phase === undefined) return undefined
35 return { label: `phase ${phase}`, reason: phaseRunReason(phase, evidence), next: state.phases[state.phases.indexOf(phase) + 1] }
36}
37
38const withKnownBrief = (state: LoopState, evidence: Evidence): LoopState => {
39 if (state.brief_dir !== undefined || state.brief_path !== undefined || evidence.briefPath === undefined) return state
40 return { ...state, brief_path: evidence.briefPath }
41}
42
43const offering = (state: LoopState, phase: string | undefined, evidence: Evidence): LoopState => {
44 if (phase === undefined) return { ...state, status: 'exit', pending: exitLaunch(state) }
45 return { ...state, status: 'phase', current: phase, pending: phaseLaunch(state, phase, evidence.planText, evidence.lanes) }
46}
47
48export const reconcilePending = (state: LoopState, evidence: Evidence) => {
49 const dropped: string[] = []
50 let reconciled = state
51 for (let step = 0; step <= state.phases.length; step++) {
52 const stale = staleness(reconciled, evidence)
53 if (stale?.reason === undefined) break
54 dropped.push(`dropped ${stale.label}: ${stale.reason}`)
55 reconciled = offering(withKnownBrief(reconciled, evidence), stale.next, evidence)
56 }
57 return { state: reconciled, dropped }
58}
59hooks/conductor/repair.ts 43 lines1import { bareName } from './events'
2import type { LoopState, LoopStatus } from './state'
3
4export type Repair = { state: LoopState } | { error: string }
5
6const ADOPTED_STATUS: Record<string, LoopStatus> = { 'milestone-kickoff': 'kickoff', phase: 'phase', retro: 'retro', 'milestone-exit': 'exit' }
7const STATUSES: LoopStatus[] = ['idle', 'kickoff', 'phase', 'retro', 'exit', 'paused', 'escalated']
8const ADOPT_USAGE = 'usage: /ouroboros adopt <task-id> <workflow> [phase]'
9const SET_USAGE = 'usage: /ouroboros set phase <PN> | set status <status>'
10
11const unknownPhase = (state: LoopState, phase: string) => `unknown phase ${phase}: one of ${state.phases.join(', ')}`
12
13const isPendingFor = (state: LoopState, workflow: string) => state.pending !== undefined && bareName(state.pending.workflow) === workflow
14
15const adoptionError = (state: LoopState, taskId: string, workflow: string, phase: string | undefined) => {
16 if (taskId === '') return ADOPT_USAGE
17 if (state.milestone === '') return 'no milestone in state.json: run /ouroboros kickoff first'
18 if (ADOPTED_STATUS[workflow] === undefined) return `unknown workflow ${workflow}: one of ${Object.keys(ADOPTED_STATUS).join(', ')}`
19 if (workflow === 'phase' && phase === undefined) return `adopting a phase run needs its phase: one of ${state.phases.join(', ')}`
20 if (phase !== undefined && !state.phases.includes(phase)) return unknownPhase(state, phase)
21 return undefined
22}
23
24export const adoptRun = (state: LoopState, taskId: string, workflowName: string, phase?: string): Repair => {
25 const workflow = bareName(workflowName)
26 const error = adoptionError(state, taskId, workflow, phase)
27 if (error !== undefined) return { error }
28 const adopted: LoopState = { ...state, status: ADOPTED_STATUS[workflow] ?? state.status, current: phase ?? state.current, run: { id: taskId, workflow } }
29 return { state: isPendingFor(state, workflow) ? { ...adopted, pending: undefined } : adopted }
30}
31
32export const setPosition = (state: LoopState, field: string, value: string): Repair => {
33 if (field === 'phase') return state.phases.includes(value) ? { state: { ...state, current: value } } : { error: unknownPhase(state, value) }
34 if (field !== 'status') return { error: SET_USAGE }
35 const status = STATUSES.find(candidate => candidate === value)
36 return status === undefined ? { error: `unknown status ${value}: one of ${STATUSES.join(', ')}` } : { state: { ...state, status } }
37}
38
39const inFlight = (state: LoopState) => (state.run === undefined ? 'nothing' : `${state.run.workflow} (${state.run.id})`)
40
41export const positionLine = (state: LoopState) =>
42 `${state.milestone || 'no milestone'} · phase ${state.current ?? '-'} · ${state.status} · in flight ${inFlight(state)} · pending ${state.pending?.workflow ?? 'none'}`
43hooks/conductor/transitions.ts 152 lines1import { firstUncheckedBox, phaseTasks, untaggedOpenTasks } from '../drafts'
2import type { PhaseTask } from '../drafts'
3import { touchesByTask } from './briefs'
4import type { BriefSlices } from './briefs'
5import { taskLane } from './lanes'
6import type { LaneOwnership } from './lanes'
7import type { Escalation, Launch, LoopState } from './state'
8
9export type LoopEvent =
10 | { type: 'kickoff-done'; brief: string; brief_path?: string; slices?: BriefSlices; brief_dir?: string; failed?: string }
11 | { type: 'phase-result'; status: 'checkpointed' | 'escalate'; phase: string; result_path: string; failing_gate?: string; pr_url?: string; checkpoint_sha?: string; blocked?: string[] }
12 | { type: 'retro-done' }
13 | { type: 'exit-result'; merged: boolean; failing_gate?: string }
14
15export type Action = { state: LoopState; launch?: Launch; notify?: string }
16
17const RETRO_LAUNCH: Launch = { workflow: 'retro', args: {} }
18
19const briefArg = (state: LoopState) => {
20 if (state.brief_dir !== undefined) return { brief_dir: state.brief_dir }
21 if (state.brief_path !== undefined) return { brief_path: state.brief_path }
22 return { brief: state.brief ?? '' }
23}
24
25const withTouches = (task: PhaseTask, touches: LoopState['touches'], lanes: LaneOwnership) => {
26 const files = touches?.[task.id] ?? (task.lane === undefined ? undefined : lanes[task.lane])
27 return files === undefined ? task : { ...task, touches: files }
28}
29
30const withLane = <T extends PhaseTask & { touches?: string[] }>(task: T, lanes: LaneOwnership) => {
31 const lane = taskLane(task, lanes)
32 return lane === undefined ? task : { ...task, lane }
33}
34
35const phaseArgs = (state: LoopState, phase: string, planText: string | undefined, lanes: LaneOwnership) => {
36 const base = { milestone: state.milestone, phase, ...briefArg(state) }
37 if (planText === undefined) return base
38 const open = phaseTasks(planText, phase).filter(task => task.unchecked > 0)
39 return { ...base, tasks: open.map(task => withLane(withTouches(task, state.touches, lanes), lanes)) }
40}
41
42export const phaseLaunch = (state: LoopState, phase: string, planText: string | undefined, lanes: LaneOwnership = {}): Launch => ({
43 workflow: 'phase',
44 args: phaseArgs(state, phase, planText, lanes),
45})
46
47export const exitLaunch = (state: LoopState): Launch => ({ workflow: 'milestone-exit', args: { milestone: state.milestone } })
48
49const successorOf = (phases: string[], current: string | null) => phases[phases.indexOf(current ?? '') + 1]
50
51const firstOpenPhase = (phases: string[], planText: string) => phases.find(phase => firstUncheckedBox(planText, phase) !== null)
52
53const nextPhase = (state: LoopState, planText: string | undefined) =>
54 planText === undefined ? successorOf(state.phases, state.current) : firstOpenPhase(state.phases, planText)
55
56const escalated = (state: LoopState, escalation: Escalation, notify: string): Action => ({
57 state: { ...state, status: 'escalated', escalations: [...state.escalations, escalation] },
58 notify,
59})
60
61const launchPhase = (state: LoopState, phase: string, planText: string | undefined, lanes: LaneOwnership): Action => ({
62 state: { ...state, status: 'phase', current: phase },
63 launch: phaseLaunch(state, phase, planText, lanes),
64})
65
66const withBrief = (state: LoopState, event: Extract<LoopEvent, { type: 'kickoff-done' }>): LoopState => {
67 if (event.slices !== undefined && event.brief_dir !== undefined) return { ...state, brief_dir: event.brief_dir, touches: touchesByTask(event.slices) }
68 if (event.brief_path !== undefined) return { ...state, brief_path: event.brief_path }
69 return { ...state, brief: event.brief }
70}
71
72const onKickoffDone = (state: LoopState, event: Extract<LoopEvent, { type: 'kickoff-done' }>, planText: string | undefined, lanes: LaneOwnership): Action => {
73 if (state.status !== 'kickoff') return { state }
74 if (event.failed !== undefined) {
75 return escalated(state, { kind: 'gate-refused', phase: 'kickoff', summary: event.failed, result_path: '' }, `${state.milestone} kickoff failed: ${event.failed}`)
76 }
77 const first = state.phases[0]
78 if (first === undefined) return { state: { ...state, status: 'idle' }, notify: `${state.milestone}: the plan has no phases` }
79 const launched = launchPhase(withBrief(state, event), first, planText, lanes)
80 const untagged = planText === undefined ? [] : untaggedOpenTasks(planText)
81 if (untagged.length === 0) return launched
82 return { ...launched, notify: `${state.milestone}: task ${untagged.join(', ')} ${untagged.length === 1 ? 'has' : 'have'} open boxes but no phase tag, so no phase will run ${untagged.length === 1 ? 'it' : 'them'}; tag ${untagged.length === 1 ? 'it' : 'them'} (PN) in the plan` }
83}
84
85const blockedNote = (blocked: string[] | undefined) => (blocked === undefined || blocked.length === 0 ? '' : `; still blocked, listed on the PR: ${blocked.join('; ')}`)
86
87const onPhaseResult = (state: LoopState, event: Extract<LoopEvent, { type: 'phase-result' }>): Action => {
88 if (state.status !== 'phase') return { state }
89 const results = { ...state.results, [event.phase]: event.result_path }
90 if (event.status === 'escalate') {
91 const escalation: Escalation = { kind: 'task-red', phase: event.phase, summary: event.failing_gate ? `${event.phase}: ${event.failing_gate}` : `${event.phase} escalated after its fix rounds`, result_path: event.result_path }
92 return { ...escalated({ ...state, results }, escalation, `${state.milestone} ${event.phase} escalated: see ${event.result_path}`), launch: RETRO_LAUNCH }
93 }
94 if (event.pr_url === undefined) return { state: { ...state, status: 'retro', results }, launch: RETRO_LAUNCH }
95 return {
96 state: { ...state, status: 'retro', results, awaiting_merge: { phase: event.phase, pr_url: event.pr_url } },
97 launch: RETRO_LAUNCH,
98 notify: `${state.milestone} ${event.phase}: pull request ${event.pr_url} is open for your review and merge${blockedNote(event.blocked)}`,
99 }
100}
101
102const onRetroDone = (state: LoopState, planText: string | undefined, lanes: LaneOwnership): Action => {
103 if (state.status !== 'retro') return { state }
104 const phase = nextPhase(state, planText)
105 if (phase === undefined) return { state: { ...state, status: 'exit' }, launch: exitLaunch(state) }
106 return launchPhase(state, phase, planText, lanes)
107}
108
109const onExitResult = (state: LoopState, event: Extract<LoopEvent, { type: 'exit-result' }>): Action => {
110 if (state.status !== 'exit') return { state }
111 if (event.merged) return { state: { ...state, status: 'idle' }, notify: `${state.milestone} merged` }
112 const gate = event.failing_gate ?? 'unknown gate'
113 const escalation: Escalation = { kind: 'gate-refused', phase: state.current ?? '', summary: gate, result_path: state.results['exit'] ?? '' }
114 return escalated(state, escalation, `${state.milestone} merge refused: ${gate}`)
115}
116
117const transition = (state: LoopState, event: LoopEvent, planText: string | undefined, lanes: LaneOwnership): Action => {
118 if (event.type === 'kickoff-done') return onKickoffDone(state, event, planText, lanes)
119 if (event.type === 'phase-result') return onPhaseResult(state, event)
120 if (event.type === 'retro-done') return onRetroDone(state, planText, lanes)
121 return onExitResult(state, event)
122}
123
124const heldForMerge = (before: LoopState, action: Action): Action => {
125 const waiting = before.awaiting_merge
126 if (waiting === undefined || action.launch === undefined || before.status !== 'retro') return action
127 const next = (action.launch.args as { phase?: unknown }).phase
128 const nextName = typeof next === 'string' ? next : 'the milestone exit'
129 return {
130 state: { ...action.state, pending: action.launch },
131 notify: `${before.milestone}: merge ${waiting.pr_url} (${waiting.phase}), then /ouroboros resume starts ${nextName} on a fresh branch`,
132 }
133}
134
135export const freshBranchName = (prefix: string, milestone: string, phase: string) => `${prefix}${milestone}-${phase}`.toLowerCase()
136
137export const mergeAccepted = (state: LoopState, branchPrefix: string): LoopState => {
138 const { awaiting_merge: _merged, merge_checked_at: _checked, ...released } = state
139 const pending = released.pending
140 const phase = (pending?.args as { phase?: unknown } | undefined)?.phase
141 if (pending === undefined || typeof phase !== 'string') return released
142 return { ...released, pending: { ...pending, args: { ...pending.args, fresh_branch: freshBranchName(branchPrefix, state.milestone, phase) } } }
143}
144
145const heldWhilePaused = (before: LoopState, action: Action): Action => {
146 if (!before.paused || action.launch === undefined) return action
147 return { ...action, launch: undefined, state: { ...action.state, pending: action.launch } }
148}
149
150export const nextAction = (state: LoopState, event: LoopEvent, planText?: string, lanes: LaneOwnership = {}): Action =>
151 heldWhilePaused(state, heldForMerge(state, transition(state, event, planText, lanes)))
152