The Loop Engine: a harness for Claude Code. One router sends each request to one of eight workflows (build, debug, review, plan, QA, orient, triage, codebase…

<img src="assets/logo.png" alt="CC10x — The Loop Engine for Claude Code. Orange stylized X with starburst and CC10x wordmark." width="280" />
<h1 align="center">cc10x — The Loop Engine for Claude Code</h1>
<em>Same model. Same Claude Code. Different outcome.</em>
The agent forgets between turns. <strong>The harness remembers across runs.</strong><br /> cc10x writes every workflow to disk — intent, evidence, verdicts, failures — so resume, review, and verification read from the artifact, not from a context window that's already gone.
<strong>1 router</strong> · <strong>14 specialist agents</strong> · <strong>21 skills</strong> · <strong>8 workflows</strong>
Fail-closed gates · survives compaction · dispatch-by-reference · test honesty gates · anti-anchored review
Current version: 12.10.0
cc10x needs Python 3.9 or newer on PATH (3.13 is the version the test suite runs on): every hook except the preflight is a python3 script. A SessionStart preflight tells the agent when python3 is missing or too old.
Step 1 — Add the marketplace (once per machine):
claude plugin marketplace add romiluz13/cc10x
Step 2 — Install the plugin:
claude plugin install cc10x@cc10x
Inside a Claude Code session the same two steps are /plugin marketplace add romiluz13/cc10x and /plugin install cc10x@cc10x.
Then say "set up cc10x for me" in Claude Code and restart. Done.
claude plugin update cc10x@cc10x --scope user
Use the scope you installed at (user, project, local or managed). Then run /reload-plugins in an open session, or restart Claude Code.
Auto-update is off by default for third-party marketplaces such as romiluz13/cc10x, so a new release does not arrive on its own. Either update by hand as above, or open /plugin, go to the Marketplaces tab, select the marketplace and choose Enable auto-update.
git clone https://github.com/romiluz13/cc10x.git
claude --plugin-dir "$PWD/cc10x/plugins/cc10x"
--plugin-dir loads the plugin for that one session only. Skills are then namespaced cc10x:<skill>, and the router is cc10x:cc10x-router.
| Variable | Why |
|---|---|
CLAUDE_CODE_ENABLE_TODO_TOOLS=1 | Claude Code ships its task tools by default only on some models. cc10x works without them (it falls back to tracking the graph in the workflow artifact) but this restores them on every model. |
CLAUDE_CODE_TASK_LIST_ID (optional) | Shares one task list across sessions. |
CLAUDE_CONFIG_DIR (optional) | Moves Claude Code's configuration directory off ~/.claude. Wherever this README says ~/.claude, read $CLAUDE_CONFIG_DIR. |
CLAUDE_CODE_PLUGIN_CACHE_DIR (optional) | Moves the plugins root off ~/.claude/plugins. |
.cc10x/ out of gitcc10x writes memory and workflow state under .cc10x/ in each project. It is session state, not source, so gitignore .cc10x/ in each project: add the line .cc10x/ to its .gitignore. How it relates to Claude Code's own memory is under Memory Persistence.
"build a user authentication system"
→ Router detects BUILD intent
→ Stops to resolve missing requirements first
→ component-builder drives RED → GREEN → REFACTOR
→ code-reviewer + failure-hunter run in parallel
→ integration-verifier checks wiring, artifacts, and behavior
→ Workflow state and memory are updated
"debug the payment processing error"
→ Router detects DEBUG intent
→ Loads prior failures and project memory
→ bug-investigator starts from logs and observed behavior
→ code-reviewer checks the fix path
→ integration-verifier confirms the bug is actually closed
→ Useful findings go back into memory
"review this PR for security issues"
→ Router detects REVIEW intent
→ code-reviewer uses repo and git context
→ Reports findings only when confidence clears the bar
→ Every finding includes file:line evidence
"write an end-to-end test plan for checkout and run it"
→ Router detects QA intent
→ one negotiation at step 0 records grants and the test-plan approval policy; the router asks once, batched, when a grant is set to ask — agents never ask
→ the topology is recalled from a machine-local record under ~/.cc10x/qa/topology/ (or $CC10X_HOME/qa/topology/ — set CC10X_HOME to an absolute path: the CLI resolves a relative one against the current directory) and written back after confirmation and after the run (a regression re-run that never recalled a record skips the write)
→ qa-researcher agents survey the code, specs and tickets, one source each
→ A test plan is reviewed by plan-gap-reviewer before anything is built
→ the reviewed test plan is shown on screen for approval (policy-controlled)
→ qa-harness-builder provisions the environment
→ code-reviewer + failure-hunter check the harness
→ qa-executor runs the plan and fills in the report
Ask Claude for something complex. It works for a while. Then it declares "Done!" — tests still red, refactor half-finished, and by message 40 it's contradicting itself because the context is gone.
cc10x fixes the loop, not the prompt. The same model, constrained and looped correctly, does better than the same model running free. That's the whole bet.
| The pain you know | How cc10x handles it |
|---|---|
| "Done!" on red tests | integration-verifier is independent of the builder. Phase-exit gates block advancement on partial evidence. |
| Silent failures nobody asked about | failure-hunter runs in parallel with review — greps for swallowed errors and empty catches. |
| Context falls apart after compaction | Workflow state on disk with stable UUIDs. Memory files the router auto-heals. |
| Planning is just a chat | Three planning modes chosen by intent, with a fresh anti-anchored review by a reviewer who never saw the planner's rationale. |
| 12 slash commands to remember | One router. Every request hits cc10x-router first. |
.claude/ prompt spam on every fanout | State lives at .cc10x/ — outside .claude/, so the harness's sensitive-file gate never fires. |
| Green tests that prove nothing | Test Honesty Gates grep for getByTestId('…-mock'), as any, .find() bypass, setTimeout() waits. A hit can't count as PASS on that test's strength alone. |
| Reviewer findings that sound right but aren't | Every finding at confidence ≥80 needs a verbatim file:line quote or it's auto-demoted. The verifier independently re-reads the line and drops hallucinated findings before they can gate. |
| Orchestrator context rotting with pasted history | Dispatch by reference, not by blob — the diff is written to disk, the prompt passes a path, never a body. (One real dispatch hit 42k chars, 99% pasted history. The scar became law.) |
You describe the work. cc10x routes it, brings in the right specialists, and keeps the bar for "done" higher than a convincing paragraph.
YOU
│
▼
┌────────────────────────┐
│ cc10x-router │ ◄── only entry point
│ detects intent │
└────────────┬───────────┘
│
┌────────┬────────┬──────────┼───────┬───────┬────────┬──────────────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
DEBUG PLAN REVIEW ORIENT QA TRIAGE CODEBASE- BUILD
HEALTH (the default)
Each workflow is a chain of the specialist agents. The full table is below.
┌──────────────────────────┐
│ STATE (every workflow) │
│ activeContext.md │
│ patterns.md │
│ progress.md │
│ {wf}.json + .events │
└──────────────────────────┘
The router picks the workflow from the request's primary deliverable. When more than one fits, the lower priority number wins; anything that matches no other row is BUILD.
| Priority | Workflow | Trigger words | What happens |
|---|---|---|---|
| 1 | DEBUG | error, bug, fix, broken, crash, debug | Reproduce from evidence → isolate cause → validate fix → prove no regression (bug-investigator → code-reviewer → integration-verifier) |
| 2 | PLAN | plan, design, architect, roadmap, spec, brainstorm | Turn rough intent into an execution-ready plan with explicit decisions, then a bounded fresh review (planner → plan-gap-reviewer) |
| 3 | REVIEW | review, audit, analyze, assess | High-signal review with confidence thresholds and file:line citations (code-reviewer). Advisory only: never creates code-changing tasks |
| 4 | ORIENT | zoom out, explain, "how does X work", "walk me through" | Read-only orientation answered inline: no agents, no phase graph, no writes |
| 5 | QA | test, QA, e2e, integration test, test plan, regression | Testing the code is the deliverable: grants negotiation → topology recalled from ~/.cc10x/qa/topology/ → survey (user action inventory + behaviour-varying surface) → test plan → plan review → on-screen plan approval → environment → execution (qa-researcher ×N → plan-gap-reviewer → qa-harness-builder → qa-executor) |
| 6 | TRIAGE | triage, "incoming issues" | Categorize and verify incoming issues or PRs and write agent-ready briefs (triage-agent). Advisory only |
| 7 | CODEBASE-HEALTH | codebase health, deepening, shallow modules | Scan for deepening candidates and produce an HTML report (architecture-scanner). Advisory only |
| 8 | BUILD | build, implement, create, add (and everything else) | Clarify scope → TDD implementation → adversarial review → integration verification (component-builder → code-reviewer ∥ failure-hunter → integration-verifier) |
The router file is the source of truth for the table: plugins/cc10x/skills/cc10x-router/SKILL.md, section 1.
This section is written for Claude Code to follow when the user says "set up cc10x for me".
When user says "set up cc10x", "install cc10x", or similar:
~/.claude/CLAUDE.md (may not exist)~/.claude/settings.json (may not exist)~/.claude/CLAUDE.mdIf file doesn't exist: CREATE with the template below. If file exists: PREPEND the cc10x section below, keep user's existing content.
Multi-project note: The global
~/.claude/CLAUDE.mdactivates cc10x in every project automatically — you do not need to reinstall or reconfigure per project. Only add the cc10x section to a project's.claude/CLAUDE.mdif that project has its own conflicting CLAUDE.md already.
# CC10x Orchestration (Always On)
IMPORTANT: For multi-step development work (build, debug, review, plan), do minimal orientation first, then invoke cc10x-router before planning, implementation, review, or code changes.
IMPORTANT: Minimal orientation means only the nearest project instructions, manifest, and immediate target surface. Do not do broad exploration before routing.
IMPORTANT: Prefer retrieval-led reasoning over pre-training-led reasoning for orchestration decisions.
IMPORTANT: The router is the default for multi-step development work. Route write-heavy BUILD/DEBUG work through it; the router's fail-closed gates and durable artifacts are the value. A small edit still routes: the router runs a single trivial change as BUILD with trivial scope (a reduced task graph that keeps the verifier's proof path), and anything that spans files, has separable concerns, or changes a contract gets the full chain.
Precedence (highest first): explicit user instructions > project standards (CLAUDE.md / repo conventions) > approved plans and design docs > domain-specific skills > cc10x internal skills > router defaults. The router enforces quality; it does not override the user.
**Skip CC10x ONLY when:**
- User EXPLICITLY says "don't use cc10x", "without cc10x", or "skip cc10x"
- No interpretation. No guessing. Only these exact opt-out phrases.
The router is the plugin skill `cc10x:cc10x-router`.
---
## Complementary Skills (Work Together with CC10x)
**Skills are additive, not exclusive.** CC10x provides orchestration. Domain skills provide expertise. Both work together.
**GATE:** Before writing code, check if task matches a skill below. If match, invoke it via `Skill(skill="...")`.
| When task involves... | Invoke |
|-----------------------|--------|
| *(Add user's installed skills here)* | |
~/.claude/settings.jsonIf file doesn't exist: CREATE with the template below. If file exists: MERGE these permissions into the existing permissions.allow array (don't overwrite!):
"Bash(mkdir -p .cc10x)",
"Bash(mkdir -p .cc10x/)",
"Bash(mkdir -p docs/plans)",
"Bash(mkdir -p docs/research)",
"Bash(mkdir -p docs/solutions)",
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git branch --show-current)",
"Bash(git blame:*)",
"Bash(git ls-files:*)",
"Bash(git rev-parse:*)",
"Bash(python3:*)",
"Edit(.cc10x/**)"
What
Edit(.cc10x/) does.** It pre-approves file edits under.cc10x/at any depth, so memory files, workflow artifacts and QA files stop prompting on every write. Claude Code applies anEdit(...)rule to every file-editing tool,Writeincluded, so one rule covers both; aWrite(.cc10x/*)allow rule does nothing (Claude Code warns that onlyEdit(...)rules are matched), which is why this template no longer has one. Claude Code drops a project'spermissions.allowentries until you trust the workspace, so put the rule in your user settings or trust the folder first.
What the
Bashrules do. Themkdir -prules create fixed directories (a rule matches the command text exactly, so.cc10xand.cc10x/are two rules) and thegitrules are for inspecting the repository (git branch --show-currentreads the current branch; directgitcommands that create or delete branches or push are not pre-approved, so they go through your Claude Code permission settings).Bash(python3:*)is broad: it approves anypython3command, not just cc10x's tools. It is in the template because every BUILD phase records its base SHA (git rev-parse HEAD) and the router builds review diff packages and phase briefs by running plugin tools viapython3; without it, every phase prompts mid-workflow. The template is a convenience, not a security boundary:python3:*, andgit logorgit diffthrough--output(which can write files such as.git/config), can be used to run other commands, git operations included, without a prompt, and deny rules see only the command text. If you need git operations gated, remove the broad rules (Bash(python3:*),Bash(git log:*),Bash(git diff:*)) and add explicitdenyrules for the git commands you want blocked, then approve cc10x's commands when Claude Code prompts. Deny rules alone do not gate git whileBash(python3:*)is allowed, because an allowedpython3command can run git without a prompt; removing one rule alone does not close every path either. InbypassPermissions, allow rules have no effect and only explicitaskrules prompt;denyrules still apply, but a deny rule on git commands does not see git run throughpython3. Only anaskordenyrule onpython3itself closes that path, and it also gates cc10x's own tools, so use that mode only in an isolated container or VM. In auto mode, a classifier decides most actions instead of prompting, and entering it drops broad allow rules that grant code execution, such asBash(python3:*).
Ask the user:
"Do you have coding standards or principles you want cc10x agents to always follow? (e.g. 'always use TypeScript strict mode', 'follow SOLID principles', 'never use
any', 'prefer functional patterns')"
If user provides standards, write them to the project's memory:
Bash(command="mkdir -p .cc10x")
# Check if patterns.md already exists (Read returns error = doesn't exist)
Read(file_path=".cc10x/patterns.md")
# If it DOESN'T exist — create with standards already populated:
Write(file_path=".cc10x/patterns.md", content="# Project Patterns\n<!-- CC10X MEMORY CONTRACT: Do not rename headings. Used as Edit anchors. -->\n\n## User Standards\n- {standard 1}\n- {standard 2}\n\n## Common Gotchas\n\n## Project SKILL_HINTS\n\n## Last Updated\n{date}")
# If it DOES exist — append under User Standards:
Edit(file_path=".cc10x/patterns.md",
old_string="## User Standards",
new_string="## User Standards\n- {standard 1}\n- {standard 2}")
Read(file_path=".cc10x/patterns.md") # Verify
If user skips: No action. The memory file will be created on first workflow run with an empty ## User Standards section for them to fill in later.
Where to find installed skills:
~/.claude/settings.json → check enabledPlugins object (plugins with value true)~/.claude/plugins/installed_plugins.json → detailed plugin info~/.claude/skills/ → personal skills (all projects).claude/skills/ → project-specific skillsSkill naming in table:
plugin-name:skill-name (e.g., mongodb-agent-skills:mongodb-schema-design)react-best-practices)Example: If user has these:
# In enabledPlugins:
"mongodb-agent-skills@mongodb-agent-skills": true
# In ~/.claude/skills/:
react-best-practices/SKILL.md
Add to the Complementary Skills table:
| When task involves... | Invoke |
|-----------------------|--------|
| MongoDB, schema, queries | `mongodb-agent-skills:mongodb-schema-design` |
| React, Next.js, UI | `react-best-practices` |
"cc10x is set up! Please restart Claude Code to activate."
cc10x survives context compaction. This is critical for long sessions.
.cc10x/
├── activeContext.md # What you're working on NOW
│ - Current task
│ - Active decisions (and WHY)
│ - Learnings this session
│
├── patterns.md # Project conventions
│ - Code patterns
│ - Common gotchas (bugs → fixes)
│ - Architectural decisions
│
└── progress.md # What's done, what's left
- Completed items (with evidence)
- Remaining tasks
- Blockers
The live namespace is .cc10x/ (memory .cc10x/*.md, workflow state .cc10x/workflows/*). Two legacy residue locations are ignored by current router hydration if present: .claude/cc10x/ (pre-10.1.20, before the workflow state moved out of .claude/ to escape the harness sensitive-file gate) and the version-segmented .cc10x/v10/ layout (v10.x, before the namespace was de-versioned in v11). cc10x does not migrate either; a fresh .cc10x/ is created on first use.
Iron Law: Every workflow loads memory at START and updates at END.
.cc10x/ relates to Claude Code auto-memoryThey are separate systems and neither replaces the other.
.cc10x/ (cc10x memory): per-project files that cc10x's router and agents read at the start and write at the end of each workflow, plus the workflow artifacts and event logs. They hold this project's task context, conventions and progress, and they live in the project directory.~/.claude, or $CLAUDE_CONFIG_DIR), outside the project. cc10x neither reads nor writes it.Use auto-memory for preferences that follow you across projects, and .cc10x/ for what one project's workflows need to resume. Keep .cc10x/ out of git, as above.
cc10x works out of the box with no MCPs required. These are optional — they unlock specific features when installed in your own Claude Code MCP settings.
| MCP | Feature Unlocked | How to Install |
|---|---|---|
| octocode | GitHub research: find packages, search code across repos, read PR history. Triggered automatically when planner or bug-investigator needs external research. | Install via Claude Code MCP settings using the server name octocode |
| brightdata | Web scraping for research tasks — used as fallback when web content is needed beyond GitHub. | Install via Claude Code MCP settings using the server name brightdata |
Important: CC10X no longer ships MCP server config inside the plugin. This avoids startup warnings for users who do not have Bright Data or Octocode credentials configured.
Without these MCPs: cc10x still works fully. The research agents degrade to built-in Claude Code tools and note the lower-confidence path in their outputs.
With octocode installed: When the router detects new/unfamiliar tech, 3+ failed debug attempts, or explicit research requests, it automatically calls octocode tools to search GitHub before invoking the planner or bug-investigator.
Add this line to ~/.claude/settings.json under permissions.allow:
"Edit(.cc10x/**)"
It covers the live .cc10x/ namespace at any depth (see Step 3 of the setup instructions for what it does and why there is no Write(...) rule).
Or run "Set up cc10x for me" again — the setup wizard adds them automatically.
Working as intended. .cc10x/ orchestration state is pre-permitted, but outward-facing project artifacts (docs/plans/, docs/research/, docs/solutions/) prompt for approval by design — trust-first friction, not missing configuration. Approve the write to continue.
The global ~/.claude/CLAUDE.md activates cc10x in every project — you only need one install. If it's not activating in a specific project:
.claude/CLAUDE.md — open it and verify the cc10x section is present. If the project-level file exists but doesn't have the cc10x entry, add it there.cc10x:cc10x-router. A relative path like plugins/cc10x/skills/cc10x-router/SKILL.md only resolves inside the cc10x repo itself, not in your projects./reload-plugins after a plugin change. A CLAUDE.md change is read on the next session start.PATH.If you see:
Error: Failed to install: EXDEV: cross-device link not permitted
This is a Linux filesystem issue — /tmp and your home directory are on different filesystems, so the installer can't rename() across them. Fix:
# Set TMPDIR to a directory on the same filesystem as your home:
mkdir -p ~/.claude/tmp
TMPDIR=~/.claude/tmphooks/register.tsx 326 lines1import { atom, read, update } from 'claude-code'
2import type { CoreEngineInterface, Register } from 'claude-code'
3
4import type { LoopState, LoopView, Spawn, Step } from '../types'
5import { REMEDIATION, ROUTES } from './loop-routes'
6import { BREAKER, WORKFLOWS, bandLine, deriveLoop, elapsed, insideRoot, isFinished, parseEvents, remediation, workflowDirs, workflowOf, type Artifact } from './loop-state'
7
8const PANE = 'cc10x-loop'
9const EMPTY: LoopView = { live: null, last: null, error: null }
10export const loop = atom({ plugin: 'cc10x', key: 'loop' } as const, EMPTY)
11export const spawns = atom({ plugin: 'cc10x', key: 'spawns' } as const, [] as Spawn[])
12const opened = atom({ plugin: 'cc10x', key: 'opened' } as const, [] as string[])
13
14// Every agent some chain dispatches (the router is no spawn).
15const AGENTS = new Set(
16 [...Object.values(ROUTES), ...Object.values(REMEDIATION)]
17 .flat()
18 .map(row => row.agent)
19 .filter(agent => agent !== 'router'),
20)
21// The engine resolves a plugin agent's type to `<pluginName>:<agent>`; `provider.plugin` is the
22// plugin id (`cc10x@cc10x`, `cc10x@inline`), so the type, not the provider, names a cc10x agent.
23const SPAWNED = /^cc10x:([a-z-]+)$/
24
25type Api = Pick<CoreEngineInterface, 'session' | 'fs' | 'state' | 'ui'>
26
27const NOT_FOUND = 'no .cc10x/workflows found from '
28
29// The newest not-finished workflow in one workflows dir, newest artifact first (at most 20).
30// A bad artifact is skipped and named in `errors`; when it is the one shown, the shown view stays.
31async function scanDir($: Api, dir: string, previous: LoopView, recorded: Spawn[]): Promise<{ live: LoopState | null; last: LoopView['last']; errors: string[] }> {
32 const entries = await $.fs.list(dir)
33 const names = new Set(entries.map(f => f.name))
34 const artifacts = entries
35 .filter(f => f.kind === 'file' && /^wf-.*\.json$/.test(f.name))
36 .sort((a, b) => b.mtimeMs - a.mtimeMs)
37 .slice(0, 20)
38 const errors: string[] = []
39 let last: LoopView['last'] = null
40 for (const [i, file] of artifacts.entries()) {
41 const path = dir + '/' + file.name
42 const log = file.name.replace(/\.json$/, '.events.jsonl')
43 // The router writes the artifact before its log: a log not there yet is no events on the newest
44 // artifact; an older artifact without one is an abandoned orphan, never live.
45 if (i > 0 && !names.has(log)) continue
46 let reading = 'artifact unreadable'
47 try {
48 const raw: unknown = JSON.parse(await $.fs.read(path))
49 if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('not a JSON object')
50 const artifact = raw as Artifact
51 reading = 'events log unreadable'
52 const events = names.has(log) ? parseEvents(await $.fs.read(dir + '/' + log)) : []
53 if (isFinished(artifact, events)) {
54 last ??= { wf: workflowOf(artifact), finishedAt: events[events.length - 1]?.ts ?? '', path }
55 continue
56 }
57 // shortcut: otherLive stays 0 because the scan stops at the first live workflow; count the rest if the pane needs it.
58 const live = deriveLoop({ path, artifact, events, spawns: recorded, otherLive: 0, warnings: [] })
59 return { live, last, errors }
60 } catch (err) {
61 const message = path + ': ' + reading + ': ' + (err instanceof Error ? err.message : String(err))
62 if (previous.live?.path === path) return { live: previous.live, last, errors: [...errors, message] }
63 errors.push(message)
64 }
65 }
66 return { live: null, last, errors }
67}
68
69// The view plus where it looked: `searched` names the dirs read, or why none was found.
70type Scan = { view: LoopView; searched: string }
71
72// Reads each existing workflows dir in workflowDirs order and stops at the first that yields a live workflow;
73// the first finished one seen is `last`. Inside the root that walk is all (the nearest live wins, so a cd to a
74// sibling never keeps the one left behind); outside it, the dir of the workflow last shown or finished is tried
75// last, so a brief cd elsewhere does not drop it.
76async function scan($: Api, previous: LoopView): Promise<Scan> {
77 const cwd = await $.session.cwd()
78 const root = await $.session.root()
79 const dirs = workflowDirs(cwd, root)
80 const remembered = (previous.live ?? previous.last)?.path
81 const shown = previous.live?.path.slice(0, previous.live.path.lastIndexOf('/'))
82 if (remembered && !insideRoot(cwd, root)) {
83 const dir = remembered.slice(0, remembered.lastIndexOf('/'))
84 if (!dirs.includes(dir)) dirs.push(dir)
85 }
86 const recorded = await read($, spawns)
87 const searched: string[] = []
88 const errors: string[] = []
89 let last: LoopView['last'] = null
90 for (const dir of dirs) {
91 if (!(await $.fs.exists(dir))) continue
92 searched.push(dir)
93 let found: Awaited<ReturnType<typeof scanDir>>
94 try {
95 found = await scanDir($, dir, previous, recorded)
96 } catch (err) {
97 // An existing dir that cannot be listed is named and skipped; if it holds the shown workflow, that view stays.
98 errors.push(dir + ': ' + (err instanceof Error ? err.message : String(err)))
99 if (dir === shown && previous.live !== null) return { view: { live: previous.live, last, error: errors.join('; ') }, searched: 'searched: ' + searched.join(', ') }
100 continue
101 }
102 errors.push(...found.errors)
103 last ??= found.last
104 if (found.live !== null) return { view: { live: found.live, last, error: errors.join('; ') || null }, searched: 'searched: ' + searched.join(', ') }
105 }
106 if (searched.length > 0) return { view: { live: null, last, error: errors.join('; ') || null }, searched: 'searched: ' + searched.join(', ') }
107 // No workflows dir: nothing has run here, which is idle, not an error, unless that would drop a
108 // shown workflow (or already did) without saying why.
109 const reason = NOT_FOUND + cwd
110 const dropped = previous.live !== null || previous.error?.startsWith(NOT_FOUND) === true
111 return { view: dropped ? { live: null, last: previous.last, error: reason } : EMPTY, searched: reason }
112}
113
114// Hooks of parallel calls run concurrently: only the latest-started read writes its view.
115let seq = 0
116
117// Re-reads the live workflow into the loop atom and returns the view it read, written or not, with where it looked.
118// A failure keeps the previous view and records why.
119async function readLoop($: Api): Promise<Scan> {
120 const gen = ++seq
121 const previous = await read($, loop)
122 let result: Scan
123 try {
124 result = await scan($, previous)
125 } catch (err) {
126 result = { view: { ...previous, error: err instanceof Error ? err.message : String(err) }, searched: '' }
127 }
128 const { view } = result
129 if (gen === seq) await update($, loop, () => view)
130 if (view.live !== null) await openOnce($, view.live.wf || view.live.path)
131 return result
132}
133
134// Whether the surface docks a pane beside the transcript, as the band's viewport last said (undefined: not said).
135// A module variable: a render hook may not write state, and the terminal raises no session.attach.
136let docks: boolean | undefined
137
138// OD-1 B: the first refresh that sees a live workflow opens the pane unasked, once per workflow id, and only
139// where it docks as a sidebar; elsewhere the id stays unmarked so a refresh after docking still opens.
140async function openOnce($: Api, id: string): Promise<void> {
141 if (docks !== true) return
142 try {
143 if ((await read($, opened)).includes(id)) return
144 let fresh = false
145 await update($, opened, list => {
146 fresh = !list.includes(id)
147 return fresh ? [...list, id].slice(-20) : list
148 })
149 if (fresh) await $.ui.open({ id: PANE, title: 'cc10x loop' })
150 } catch (err) {
151 $.ui.log(`cc10x: pane open failed: ${err instanceof Error ? err.message : String(err)}`)
152 }
153}
154
155// shortcut: times are the UTC hh:mm the router stamped; convert to local time if users ask.
156const hhmm = (ts: string | null) => ts?.slice(11, 16) ?? ''
157const MARK = { done: '✓', running: '▶', pending: '·' } as const
158
159// The pane's lines for a live workflow: header, cursor, phases, steps, remediation, last three events.
160function paneLines(live: LoopState, now: number): string[] {
161 const join = (parts: string[], by = ' ') => parts.filter(p => p !== '').join(by)
162 const row = (s: Step) =>
163 join([
164 // A result outside the chain is marked + (always done), so it never reads as a chain step.
165 s.extra ? '+' : MARK[s.status],
166 s.agent,
167 s.status === 'running' ? 'running ' + elapsed(now - (s.startedAt ?? now)) : (s.decision ?? ''),
168 hhmm(s.at),
169 '(' + s.phase + ')',
170 ])
171 const phases = live.phases.map(p => p.id + ' ' + (p.status === 'completed' ? '✓' : p.id === live.cursor ? '▶' : '·'))
172 const rounds = remediation(live.rounds) + (live.rounds > BREAKER ? ' (breaker ' + BREAKER + ')' : '')
173 const events = live.events.slice(-3).map(e => join([hhmm(e.ts), e.event, e.agent, e.phase, e.decision ?? '']))
174 return [
175 join(['cc10x', join([live.type, live.wf]), live.request.replace(/\s+/g, ' ').slice(0, 40), live.otherLive > 0 ? `+${live.otherLive} other live` : ''], ' · '),
176 `cursor: ${live.rawCursor ?? 'none'}`,
177 join(phases),
178 ...live.steps.map(row),
179 `remediation ${rounds} · gate: ${live.gate ?? 'none'}`,
180 ...(events.length > 0 ? ['last events', ...events] : ['no events yet']),
181 ].filter(l => l !== '')
182}
183
184export const register: Register = on => {
185 on('session.start', async ($, e, next) => {
186 docks = undefined
187 try {
188 await $.command.register({
189 name: PANE,
190 description: 'Show the cc10x workflow loop in a pane',
191 })
192 } catch (err) {
193 $.ui.log(`cc10x: command.register failed: ${err instanceof Error ? err.message : String(err)}`)
194 }
195
196 const started = await next(e)
197 await readLoop($)
198
199 return started
200 }).catch(($, e, next) => {
201 $.ui.log(`cc10x: loop read failed: ${next.error.message ?? next.error.kind}`)
202
203 return next(e)
204 })
205
206 // The router writes its artifact and events log with Edit, Write or Bash: re-read after such a call.
207 on('tool.call', async ($, e, next) => {
208 const ran = await next(e)
209 const target = e.tool === 'Bash' ? e.command : e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : ''
210 if (target.includes(WORKFLOWS + '/')) await readLoop($)
211
212 return ran
213 }).catch(($, e, next) => {
214 $.ui.log(`cc10x: loop refresh failed: ${next.error.message ?? next.error.kind}`)
215
216 return next(e)
217 })
218
219 // Records a started cc10x chain agent against the live workflow as it stands now, then re-reads so the row runs.
220 on('agent.spawn', async ($, e, next) => {
221 const started = await next(e)
222 const agent = SPAWNED.exec(e.subagentType)?.[1]
223 if (started.deny === undefined && agent !== undefined && AGENTS.has(agent)) {
224 // A router write the tool.call filter missed may have moved the boundary: read before recording,
225 // and record from this read even when a parallel spawn's newer read superseded its write.
226 const { live } = (await readLoop($)).view
227 const spawn: Spawn = {
228 agent,
229 atIndex: live?.events.length ?? 0,
230 startedAt: await $.clock.now(),
231 phaseId: live?.cursor ?? 'N/A',
232 wf: live?.wf ?? '',
233 }
234 await update($, spawns, list => [...list, spawn].slice(-50))
235 await readLoop($)
236 }
237
238 return started
239 }).catch(async ($, e, next) => {
240 // `next.called` is always true here (the hook calls next first): the replay tells whether the spawn itself failed.
241 const replay = next(e)
242 const spawned = await replay.then(() => true, () => false)
243 $.ui.log(`cc10x: ${spawned ? 'spawn record failed' : 'spawn failed'}: ${next.error.message ?? next.error.kind}`)
244
245 return replay
246 })
247
248 on('turn.complete', async ($, e, next) => {
249 const done = await next(e)
250 await readLoop($)
251
252 return done
253 }).catch(($, e, next) => {
254 $.ui.log(`cc10x: loop refresh failed: ${next.error.message ?? next.error.kind}`)
255
256 return next(e)
257 })
258
259 on('command.run', { command: PANE }, async ($, e) => {
260 if (e.args.trim() === 'status') {
261 try {
262 const { view, searched } = await readLoop($)
263 // The not-found reason can be both `searched` and the error: print it once.
264 const lines = [view.live?.path ?? 'no live workflow', searched, view.error ?? '']
265
266 return { text: lines.filter((l, i) => l !== '' && lines.indexOf(l) === i).join('\n') }
267 } catch (err) {
268 // readLoop's own state read and write sit outside its catch: status still answers, never "pane did not open".
269 return { text: `cc10x loop status unavailable: ${err instanceof Error ? err.message : String(err)}` }
270 }
271 }
272 const opened = await $.ui.open({ id: PANE, title: 'cc10x loop' })
273
274 return {
275 text: opened.isPlaced
276 ? 'cc10x loop pane opened.'
277 : `cc10x loop pane waiting: ${opened.reason}`,
278 }
279 }).catch(($, e, next) => ({
280 // A registered command has no engine core: answer here, or the user sees
281 // the engine's "no command.run hook answered it" hint instead of the reason.
282 text: `cc10x loop pane did not open: ${next.error.message ?? next.error.kind}`,
283 }))
284
285 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
286 if (e.viewport?.isFullscreen !== undefined) docks = e.viewport.isFullscreen
287 const { live, error } = await read($, loop)
288 if (e.props.hasSurvey || live === null) return next(e)
289 const { Text } = $.ui.resolve(e)
290 // A failed re-read keeps the old view: say so, or the band reads as current.
291 const stale = error === null ? '' : ' · stale'
292
293 return <Text dimColor>{bandLine(live, await $.clock.now()) + stale}</Text>
294 }).catch(($, e, next) => {
295 $.ui.log(`cc10x: band render failed: ${next.error.message ?? next.error.kind}`)
296
297 return next(e)
298 })
299
300 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
301 const { Box, Text } = $.ui.resolve(e)
302 const { live, last, error } = await read($, loop)
303 // A finished workflow is never drawn live: the scan yields live null and names it in last.
304 const lines =
305 live !== null
306 ? paneLines(live, await $.clock.now())
307 : ['no live workflow', ...(last === null ? [] : [`(last: ${last.wf} finished${last.finishedAt ? ' ' + last.finishedAt : ''})`])]
308 const dim = [...(live?.warnings ?? []), ...(error === null ? [] : [error])]
309
310 return (
311 <Box flexDirection="column">
312 {lines.map(l => (
313 <Text>{l}</Text>
314 ))}
315 {dim.map(l => (
316 <Text dimColor>{l}</Text>
317 ))}
318 </Box>
319 )
320 }).catch(($, e, next) => {
321 $.ui.log(`cc10x: pane render failed: ${next.error.message ?? next.error.kind}`)
322
323 return next(e)
324 })
325}
326hooks/loop-routes.ts 100 lines1// Route table only: the task chain the router runs per workflow type, in dispatch order.
2// `parallel` rows run side by side; `anyPhase` matches the agent's result on any task phase
3// (REM-FIX carries the originating task's phase). Python pins every phase to the router enum
4// and every agent to a file under agents/ (scripts/test_cc10x_loop_routes.py).
5export type Route = { agent: string; phase: string; parallel?: boolean; anyPhase?: boolean }
6
7const finish: Route[] = [
8 { agent: 'doc-syncer', phase: 'build-doc-sync' },
9 { agent: 'router', phase: 'build-finish' },
10 { agent: 'router', phase: 'memory-finalize' },
11]
12const memory: Route = { agent: 'router', phase: 'memory-finalize' }
13
14export const ROUTES: Record<string, Route[]> = {
15 BUILD: [
16 { agent: 'component-builder', phase: 'build-implement' },
17 { agent: 'code-reviewer', phase: 'build-review', parallel: true },
18 { agent: 'failure-hunter', phase: 'build-hunt', parallel: true },
19 { agent: 'integration-verifier', phase: 'build-verify' },
20 ...finish,
21 ],
22 BUILD_TRIVIAL: [
23 { agent: 'component-builder', phase: 'build-implement' },
24 { agent: 'integration-verifier', phase: 'build-verify' },
25 memory,
26 ],
27 PLAN: [
28 { agent: 'planner', phase: 'plan-create' },
29 { agent: 'plan-gap-reviewer', phase: 'plan-review-gap-1' },
30 { agent: 'planner', phase: 're-plan' },
31 { agent: 'plan-gap-reviewer', phase: 'plan-review-gap-2' },
32 memory,
33 ],
34 DEBUG: [
35 { agent: 'bug-investigator', phase: 'debug-investigate' },
36 { agent: 'code-reviewer', phase: 'debug-review' },
37 { agent: 'integration-verifier', phase: 'debug-verify' },
38 memory,
39 ],
40 REVIEW: [{ agent: 'code-reviewer', phase: 'review-audit' }, memory],
41 QA: [
42 { agent: 'qa-researcher', phase: 'qa-research' },
43 { agent: 'planner', phase: 'qa-plan' },
44 { agent: 'plan-gap-reviewer', phase: 'qa-plan-review' },
45 { agent: 'planner', phase: 'qa-re-plan' },
46 { agent: 'plan-gap-reviewer', phase: 'qa-plan-review-2' },
47 { agent: 'qa-harness-builder', phase: 'qa-preflight' },
48 { agent: 'qa-harness-builder', phase: 'qa-build' },
49 { agent: 'code-reviewer', phase: 'qa-review', parallel: true },
50 { agent: 'failure-hunter', phase: 'qa-hunt', parallel: true },
51 { agent: 'qa-executor', phase: 'qa-execute' },
52 memory,
53 ],
54 // A QA workflow with source_wf set is a regression run (qa-workflow.md section 6): the executor only.
55 QA_REGRESSION: [{ agent: 'qa-executor', phase: 're-qa-execute' }, memory],
56 TRIAGE: [{ agent: 'triage-agent', phase: 'triage' }, memory],
57 'CODEBASE-HEALTH': [{ agent: 'architecture-scanner', phase: 'codebase-health' }, memory],
58}
59
60export const REMEDIATION: Record<string, Route[]> = {
61 BUILD: [
62 { agent: 'component-builder', phase: 'build-implement', anyPhase: true },
63 { agent: 'code-reviewer', phase: 're-review', parallel: true },
64 { agent: 'failure-hunter', phase: 're-hunt', parallel: true },
65 { agent: 'integration-verifier', phase: 're-verify' },
66 ...finish,
67 ],
68 // A trivial BUILD runs the same re-review loop and skips doc-sync and finishing.
69 BUILD_TRIVIAL: [
70 { agent: 'component-builder', phase: 'build-implement', anyPhase: true },
71 { agent: 'code-reviewer', phase: 're-review', parallel: true },
72 { agent: 'failure-hunter', phase: 're-hunt', parallel: true },
73 { agent: 'integration-verifier', phase: 're-verify' },
74 memory,
75 ],
76 DEBUG: [
77 { agent: 'bug-investigator', phase: 'debug-investigate', anyPhase: true },
78 { agent: 'code-reviewer', phase: 're-review' },
79 { agent: 'integration-verifier', phase: 're-verify' },
80 memory,
81 ],
82 QA: [
83 { agent: 'qa-harness-builder', phase: 're-qa-build' },
84 { agent: 'code-reviewer', phase: 'qa-review', parallel: true },
85 { agent: 'failure-hunter', phase: 'qa-hunt', parallel: true },
86 // The original qa-execute runs after a re-qa-build; re-qa-execute is a regression run in its own workflow.
87 { agent: 'qa-executor', phase: 'qa-execute' },
88 memory,
89 ],
90 // A harness defect in a regression run routes to re-qa-build, then a fresh qa-review / qa-hunt pair
91 // and the rerun (qa-workflow.md section 6). REVIEW, PLAN, TRIAGE and CODEBASE-HEALTH never remediate.
92 QA_REGRESSION: [
93 { agent: 'qa-harness-builder', phase: 're-qa-build' },
94 { agent: 'code-reviewer', phase: 'qa-review', parallel: true },
95 { agent: 'failure-hunter', phase: 'qa-hunt', parallel: true },
96 { agent: 'qa-executor', phase: 're-qa-execute' },
97 memory,
98 ],
99}
100hooks/loop-state.ts 289 lines1// Pure loop state: artifact + events log + spawn records -> LoopState. No engine calls here.
2// Mirrors the router's step rule (SKILL.md section 4 step 6c) and the hooklib finished rule as the
3// data on disk has them: every position is an index into the parsed events array, never a timestamp.
4import type { LoopEvent, LoopState, Spawn, Step } from '../types'
5import { REMEDIATION, ROUTES, type Route } from './loop-routes'
6
7export type Artifact = Record<string, unknown>
8
9export const WORKFLOWS = '.cc10x/workflows'
10
11// Where to look for the workflows dir, in order: the session cwd and each parent (the router writes
12// .cc10x/ beside the shell's cwd, which a shell cd moves), then the project root. The walk stops at the
13// root when cwd is inside it, so an ancestor's workflows never pass for this project's; outside it, it runs to /.
14// Inside the root this walk is the whole answer; only outside it may the caller add the last-shown workflow's dir.
15// shortcut: '/' separators only (macOS/Linux); split on '\\' too if the mod ever runs on Windows.
16export function insideRoot(cwd: string, root: string): boolean {
17 const top = root.replace(/\/+$/, '')
18 const from = cwd.replace(/\/+$/, '')
19 return from === top || from.startsWith(top + '/')
20}
21
22export function workflowDirs(cwd: string, root: string): string[] {
23 const top = root.replace(/\/+$/, '')
24 const from = cwd.replace(/\/+$/, '')
25 const inside = insideRoot(cwd, root)
26 const dirs: string[] = []
27 for (let d = from; ; d = d.slice(0, d.lastIndexOf('/'))) {
28 dirs.push(d + '/' + WORKFLOWS)
29 if (d === '' || (inside && d === top)) break
30 }
31 const fallback = top + '/' + WORKFLOWS
32 return dirs.includes(fallback) ? dirs : [...dirs, fallback]
33}
34
35const NO_PHASE = 'N/A'
36const OPEN = new Set(['NEEDS_INFO', 'NEEDS_GRILLING'])
37const TERMINAL = new Set(['memory_finalized', 'workflow_completed', 'workflow_failed'])
38const RESULTS = new Set(['result_persisted', 'memory_finalized'])
39// Router law (SKILL.md section 4 step 6c): a pending original verifier that has not run takes the
40// re-verify slot and keeps its task phase, on BUILD and DEBUG alike. Every other remediation row is a
41// new task with its own phase (re-review, re-hunt, re-qa-build) or reuses the original phase outright.
42// A QA regression run may log its executor result as qa-execute (qa-workflow.md, `qa-execute` is independently re-runnable).
43const REUSED: Record<string, string[]> = { 're-verify': ['build-verify', 'debug-verify'], 're-qa-execute': ['qa-execute'] }
44
45const str = (v: unknown): string | null => (typeof v === 'string' ? v : null)
46const obj = (v: unknown): Artifact => (v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Artifact) : {})
47// null, missing, '' and 'N/A' are one phase id; any other value reads as its String form.
48const phaseKey = (v: unknown): string => (v === null || v === undefined || v === '' ? NO_PHASE : String(v))
49export const workflowOf = (artifact: Artifact): string => str(artifact.workflow_uuid) || str(artifact.workflow_id) || ''
50
51export function parseEvents(text: string): LoopEvent[] {
52 const out: LoopEvent[] = []
53 for (const line of text.replace(/^\uFEFF/, '').split('\n')) {
54 let raw: unknown
55 try {
56 raw = JSON.parse(line)
57 } catch {
58 continue
59 }
60 const r = obj(raw)
61 if (typeof r.event !== 'string' || r.agent === 'hook') continue
62 out.push({
63 ts: str(r.ts) ?? '',
64 event: r.event,
65 agent: str(r.agent) ?? '',
66 phase: str(r.phase) ?? '',
67 decision: str(r.decision),
68 reason: str(r.reason),
69 phaseId: phaseKey(obj(r.details).phase_id),
70 })
71 }
72 return out
73}
74
75// A string or number reads as its String form, like phaseKey on the events side; anything else, or '', has no id.
76const idOf = (v: unknown): string | null => (typeof v === 'string' || typeof v === 'number' ? String(v) || null : null)
77
78function phasesOf(artifact: Artifact, warnings: string[] = []): { id: string; status: string }[] {
79 const raw = Array.isArray(artifact.normalized_phases) ? artifact.normalized_phases : []
80 const status = obj(artifact.phase_status)
81 return raw.map((p, i) => {
82 // Entries are keyed `phase_id` (0161b108, 2026-10-15) or `id` (7e99ef54, 10-18; 419e2736, 10-20);
83 // a bare string or number entry is its own id.
84 const id = idOf(p) ?? idOf(obj(p).id)
85 const phaseId = idOf(obj(p).phase_id)
86 if (id !== null && phaseId !== null && id !== phaseId) {
87 warnings.push('normalized_phases[' + i + ']: id ' + id + ' and phase_id ' + phaseId + ' differ; using id')
88 }
89 const key = id ?? phaseId ?? ''
90 if (key === '') warnings.push('normalized_phases[' + i + '] has no id')
91 return { id: key, status: str(status[key]) ?? 'pending' }
92 })
93}
94
95// The comparison cursor: 'N/A' unless phase_cursor names a phase, or it is 'memory-finalize' on a
96// phased workflow (the router flips it at the memory step, after build-finish; the last phase id still matches).
97function cursorOf(artifact: Artifact): string {
98 const raw = str(artifact.phase_cursor)
99 const ids = phasesOf(artifact)
100 .map(p => p.id)
101 .filter(id => id !== '')
102 if (raw !== null && ids.includes(raw)) return raw
103 if (raw === 'memory-finalize' && ids.length > 0) return ids[ids.length - 1]
104 return NO_PHASE
105}
106
107export function isFinished(artifact: Artifact, events: LoopEvent[]): boolean {
108 if (artifact.phase_cursor === 'memory-finalize' && obj(artifact.phase_status)['memory-finalize'] === 'completed') return true
109 const history = Array.isArray(artifact.status_history) ? artifact.status_history : []
110 const last = obj(history[history.length - 1])
111 const historyTerminal = typeof last.event === 'string' && TERMINAL.has(last.event)
112 const newest = events[events.length - 1]
113 if (newest === undefined) return historyTerminal
114 const logTerminal = TERMINAL.has(newest.event)
115 const historyTs = str(last.ts)
116 // Router-written ISO stamps compare as strings; the later one decides when both exist and differ.
117 if (historyTs !== null && newest.ts !== '' && historyTs !== newest.ts) {
118 return historyTs > newest.ts ? historyTerminal : logTerminal
119 }
120 return historyTerminal || logTerminal
121}
122
123function boundaryOf(events: LoopEvent[], cursor: string): number {
124 let boundary = -1
125 let started = -1
126 events.forEach((e, i) => {
127 if (e.event === 'workflow_started') started = i
128 const isBoundary =
129 (e.event === 'remediation_created' || (e.event === 'phase_started' && cursor !== NO_PHASE)) && e.phaseId === cursor
130 if (isBoundary) boundary = i
131 })
132 return boundary === -1 ? started : boundary
133}
134
135function chainOf(artifact: Artifact, remediation: boolean, warnings: string[]): Route[] {
136 const type = str(artifact.workflow_type) ?? ''
137 // Escalation to standard is one-way (build-workflow.md escalation rule): standard in either location wins.
138 const scopes = [artifact.build_scope, obj(artifact.results).build_scope]
139 const trivial = scopes.includes('trivial') && !scopes.includes('standard')
140 const regression = type === 'QA' && str(artifact.source_wf)
141 const key = type === 'BUILD' && trivial ? 'BUILD_TRIVIAL' : regression ? 'QA_REGRESSION' : type
142 // Own keys only: a type naming an Object.prototype key ('constructor') is unknown, never a chain.
143 const route = Object.hasOwn(ROUTES, key) ? ROUTES[key] : undefined
144 const fix = Object.hasOwn(REMEDIATION, key) ? REMEDIATION[key] : undefined
145 if (remediation && fix === undefined && route !== undefined) warnings.push('no remediation chain for ' + key)
146 const chain = (remediation && fix) || route
147 if (chain === undefined) warnings.push('unknown workflow type: ' + (type || '(missing)'))
148 return chain ?? []
149}
150
151export function deriveSteps(artifact: Artifact, events: LoopEvent[], spawns: Spawn[], warnings: string[] = []): Step[] {
152 const cursor = cursorOf(artifact)
153 const boundary = boundaryOf(events, cursor)
154 const chain = chainOf(artifact, events[boundary]?.event === 'remediation_created', warnings)
155 const results = events
156 .map((e, i) => ({ e, i }))
157 .filter(({ e, i }) => i > boundary && RESULTS.has(e.event) && e.phaseId === cursor)
158 const matches = (row: Route, e: LoopEvent) =>
159 e.agent === row.agent && (row.anyPhase === true || e.phase === row.phase || REUSED[row.phase]?.includes(e.phase) === true)
160
161 const steps: Step[] = chain.map(row => {
162 const hits = results.filter(({ e }) => matches(row, e))
163 const last = hits[hits.length - 1]
164 const base = { agent: row.agent, phase: row.phase, parallel: row.parallel === true, startedAt: null, extra: false }
165 if (last !== undefined && !OPEN.has(last.e.decision ?? '')) {
166 return { ...base, status: 'done', decision: last.e.decision, at: last.e.ts }
167 }
168 return { ...base, status: 'pending', decision: last?.e.decision ?? null, at: last?.e.ts ?? null }
169 })
170
171 // A live spawn (this workflow's, after the boundary, no result of its agent at or after it) runs exactly
172 // one row of its agent: the first not done in chain order, else the last (a re-dispatch without
173 // remediation_created). Spawns outlive their workflow, so another workflow's spawn never binds, and
174 // an unknown workflow ('') never binds on either side.
175 const wf = workflowOf(artifact)
176 for (const agent of new Set(chain.map(row => row.agent))) {
177 const live = spawns.filter(
178 s =>
179 s.agent === agent &&
180 s.phaseId === cursor &&
181 s.atIndex > boundary &&
182 !events.some((e, i) => i >= s.atIndex && e.event === 'result_persisted' && e.agent === s.agent),
183 )
184 // Another workflow's spawn is a correct non-match; only a spawn of no known workflow is an anomaly.
185 for (const s of live) {
186 if (s.wf === '') {
187 warnings.push('spawn of ' + agent + ' (workflow unknown) not bound to ' + (wf || 'unknown'))
188 }
189 }
190 const spawn = live.filter(s => s.wf !== '' && s.wf === wf).pop()
191 if (spawn === undefined) continue
192 const rows = steps.flatMap((s, i) => (s.agent === agent ? [i] : []))
193 const at = rows.find(i => steps[i].status !== 'done') ?? rows[rows.length - 1]
194 steps[at] = { ...steps[at], status: 'running', decision: null, at: null, startedAt: spawn.startedAt }
195 }
196
197 // Nothing the router logged is hidden: results that fit no chain row become extra done rows.
198 const extras = new Map<string, Step>()
199 for (const { e, i } of events.map((e, i) => ({ e, i }))) {
200 if (i <= boundary || e.event !== 'result_persisted' || chain.some(row => matches(row, e))) continue
201 extras.set(e.agent + '/' + e.phase, {
202 agent: e.agent,
203 phase: e.phase,
204 status: 'done',
205 decision: e.decision,
206 at: e.ts,
207 startedAt: null,
208 parallel: false,
209 extra: true,
210 })
211 }
212 return [...steps, ...extras.values()]
213}
214
215// The next step: the first pending row after the last done or running chain row (extra rows never
216// count), skipping the parallel siblings of a running row (they were dispatched together), else the
217// first pending row (a skipped optional step never pins it once a later row completed).
218export function nextStep(steps: Step[]): Step | undefined {
219 let last = -1
220 steps.forEach((s, i) => {
221 if (s.status !== 'pending' && !s.extra) last = i
222 })
223 let from = last + 1
224 while (last >= 0 && steps[last].status === 'running' && steps[last].parallel && steps[from]?.parallel) from += 1
225 return steps.slice(from).find(s => s.status === 'pending') ?? steps.find(s => s.status === 'pending')
226}
227
228export function deriveLoop(input: {
229 path: string
230 artifact: Artifact
231 events: LoopEvent[]
232 spawns: Spawn[]
233 otherLive: number
234 warnings: string[]
235}): LoopState {
236 const { artifact, events } = input
237 const cursor = cursorOf(artifact)
238 const warnings = [...input.warnings]
239 return {
240 wf: workflowOf(artifact),
241 path: input.path,
242 type: str(artifact.workflow_type) ?? '',
243 request: str(artifact.user_request) ?? '',
244 phases: phasesOf(artifact, warnings),
245 cursor,
246 rawCursor: str(artifact.phase_cursor),
247 steps: deriveSteps(artifact, events, input.spawns, warnings),
248 gate: str(artifact.pending_gate),
249 // Workflow-wide, like the circuit breaker (remediation-and-research.md): the larger of remediation_history
250 // and the log's remediation_created count, since a missed append on either side never lowers it.
251 rounds: Math.max(
252 Array.isArray(artifact.remediation_history) ? artifact.remediation_history.length : 0,
253 events.filter(e => e.event === 'remediation_created').length,
254 ),
255 events,
256 otherLive: input.otherLive,
257 warnings,
258 }
259}
260
261export function elapsed(ms: number): string {
262 const total = Math.max(0, Math.floor(ms / 1000))
263 const m = Math.floor(total / 60)
264 const s = String(total % 60).padStart(2, '0')
265 return m >= 60 ? Math.floor(m / 60) + 'h' + String(m % 60).padStart(2, '0') + 'm' : m + 'm' + s + 's'
266}
267
268// The circuit breaker's remediation round cap.
269export const BREAKER = 3
270
271// The workflow-wide round count against the circuit breaker: 'N/3' up to it; past it the bare count,
272// since '8/3' reads like a cap the count can never pass.
273export const remediation = (rounds: number): string => (rounds > BREAKER ? String(rounds) : rounds + '/' + BREAKER)
274
275export function bandLine(state: LoopState, now: number): string {
276 // Plain concatenation throughout: this file stays free of engine calls and of template syntax.
277 const head = ['cc10x', state.type, state.cursor === NO_PHASE ? '' : state.cursor + '/' + state.phases.length].filter(s => s !== '').join(' ')
278 const running = state.steps.filter(s => s.status === 'running')
279 const started = Math.min(...running.map(s => s.startedAt ?? now))
280 const next = nextStep(state.steps)
281 const segments = [
282 running.length > 0 ? running.map(s => s.agent).join(' ‖ ') + ' running ' + elapsed(now - started) : '',
283 next !== undefined ? 'next: ' + next.agent : '',
284 state.rounds > 0 ? 'rem ' + remediation(state.rounds) : '',
285 state.gate !== null ? 'gate: ' + state.gate : '',
286 ]
287 return [head, ...segments.filter(s => s !== '')].join(' · ')
288}
289types/index.d.ts 53 lines1export type StepStatus = 'done' | 'running' | 'pending'
2export type Step = {
3 agent: string
4 phase: string
5 status: StepStatus
6 decision: string | null
7 at: string | null
8 startedAt: number | null
9 parallel: boolean
10 // true for a result that fits no chain row (appended after the chain, always done)
11 extra: boolean
12}
13export type LoopEvent = {
14 ts: string
15 event: string
16 agent: string
17 phase: string
18 decision: string | null
19 reason: string | null
20 phaseId: string
21}
22// `wf` is the spawning workflow's workflow_uuid (else workflow_id): a spawn binds rows of that workflow only.
23// `wf` '' means the workflow is unknown: such a spawn never binds a row.
24export type Spawn = { agent: string; atIndex: number; startedAt: number; phaseId: string; wf: string }
25export type LoopState = {
26 wf: string
27 path: string
28 type: string
29 request: string
30 phases: { id: string; status: string }[]
31 cursor: string
32 rawCursor: string | null
33 steps: Step[]
34 gate: string | null
35 rounds: number
36 events: LoopEvent[]
37 otherLive: number
38 warnings: string[]
39}
40export type LoopView = {
41 live: LoopState | null
42 // `path`: the finished artifact, so a refresh from outside the root can re-read its dir.
43 last: { wf: string; finishedAt: string; path: string } | null
44 error: string | null
45}
46
47declare module 'claude-code' {
48 interface PluginState {
49 // `opened`: the live workflow ids the pane already opened for unasked (OD-1 B), newest last.
50 cc10x: { loop: LoopView; spawns: Spawn[]; opened: string[] }
51 }
52}
53