SLOPSHOPPER

ouroboros

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

newguardcommandstatuspromptprocess
v0.20.0no licenseupdated 2026-10-05StupidCodeFactory/ouroboros
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ouroboros
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /skill-incident ⎿ ouroboros: logged against ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ouroboros: skills: 0 open · ADR 0 proposed
README

ouroboros

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 (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 (agents/): architect, auditor, reviewer, implementer, skill-curator, adr-scribe. Persistent memory lives in each project's .claude/agent-memory/.
  • Retro (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 (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 (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).

Install

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.

Project setup

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.

Runtime state

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.

Development

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.

Source 26 files
hooks/register.ts 1022 lines
1import 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}
1022
hooks/adr.ts 27 lines
1export 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
27
hooks/drafts.ts 102 lines
1export 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}
102
hooks/conductor/briefs.ts 17 lines
1export 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]))
17
hooks/conductor/digest.ts 27 lines
1export 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}
27
hooks/conductor/events.ts 109 lines
1import 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}
109
hooks/conductor/header.ts 81 lines
1import 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')
81
hooks/conductor/state.ts 44 lines
1import 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})
44
hooks/discover.ts 53 lines
1import 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}
53
hooks/conductor/reconcile.ts 59 lines
1import { 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}
59
hooks/conductor/repair.ts 43 lines
1import { 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'}`
43
hooks/conductor/transitions.ts 152 lines
1import { 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