SLOPSHOPPER

cc10x

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…

newpanebandguardcommandagents
★ 166v12.10.0MITupdated 2026-10-21romiluz13/cc10x/plugins/cc10x
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc10x
│ ┃ cc10x loop ✕ › fix the failing auth test and add an audit log call │ ┃ no live workflow │ ⏺ 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 │ │ › /cc10x-loop │ ⎿ cc10x: cc10x loop pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cc10x loop
no live workflow
README

<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> &nbsp;·&nbsp; <strong>14 specialist agents</strong> &nbsp;·&nbsp; <strong>21 skills</strong> &nbsp;·&nbsp; <strong>8 workflows</strong>

Fail-closed gates &nbsp;·&nbsp; survives compaction &nbsp;·&nbsp; dispatch-by-reference &nbsp;·&nbsp; test honesty gates &nbsp;·&nbsp; anti-anchored review

Current version: 12.10.0


Install

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.

Update

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.

Manual install from a clone

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.

Recommended environment variables

VariableWhy
CLAUDE_CODE_ENABLE_TODO_TOOLS=1Claude 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.

Keep .cc10x/ out of git

cc10x 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.


Quick Start Examples

Build Something

"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

Fix a Bug

"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 Code

"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

Test a Feature

"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

Why cc10x

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 knowHow cc10x handles it
"Done!" on red testsintegration-verifier is independent of the builder. Phase-exit gates block advancement on partial evidence.
Silent failures nobody asked aboutfailure-hunter runs in parallel with review — greps for swallowed errors and empty catches.
Context falls apart after compactionWorkflow state on disk with stable UUIDs. Memory files the router auto-heals.
Planning is just a chatThree 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 rememberOne router. Every request hits cc10x-router first.
.claude/ prompt spam on every fanoutState lives at .cc10x/ — outside .claude/, so the harness's sensitive-file gate never fires.
Green tests that prove nothingTest 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'tEvery 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 historyDispatch 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.)

How It Works

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 8 Workflows

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.

PriorityWorkflowTrigger wordsWhat happens
1DEBUGerror, bug, fix, broken, crash, debugReproduce from evidence → isolate cause → validate fix → prove no regression (bug-investigator → code-reviewer → integration-verifier)
2PLANplan, design, architect, roadmap, spec, brainstormTurn rough intent into an execution-ready plan with explicit decisions, then a bounded fresh review (planner → plan-gap-reviewer)
3REVIEWreview, audit, analyze, assessHigh-signal review with confidence thresholds and file:line citations (code-reviewer). Advisory only: never creates code-changing tasks
4ORIENTzoom out, explain, "how does X work", "walk me through"Read-only orientation answered inline: no agents, no phase graph, no writes
5QAtest, QA, e2e, integration test, test plan, regressionTesting 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)
6TRIAGEtriage, "incoming issues"Categorize and verify incoming issues or PRs and write agent-ready briefs (triage-agent). Advisory only
7CODEBASE-HEALTHcodebase health, deepening, shallow modulesScan for deepening candidates and produce an HTML report (architecture-scanner). Advisory only
8BUILDbuild, 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.


Claude Setup Instructions

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:

Step 1: Check Existing Files

  • Read ~/.claude/CLAUDE.md (may not exist)
  • Read ~/.claude/settings.json (may not exist)

Step 2: Update ~/.claude/CLAUDE.md

If 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.md activates 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.md if 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)* | |

Step 3: Update ~/.claude/settings.json

If 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 an Edit(...) rule to every file-editing tool, Write included, so one rule covers both; a Write(.cc10x/*) allow rule does nothing (Claude Code warns that only Edit(...) rules are matched), which is why this template no longer has one. Claude Code drops a project's permissions.allow entries until you trust the workspace, so put the rule in your user settings or trust the folder first.

What the Bash rules do. The mkdir -p rules create fixed directories (a rule matches the command text exactly, so .cc10x and .cc10x/ are two rules) and the git rules are for inspecting the repository (git branch --show-current reads the current branch; direct git commands 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 any python3 command, 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 via python3; without it, every phase prompts mid-workflow. The template is a convenience, not a security boundary: python3:*, and git log or git diff through --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 explicit deny rules for the git commands you want blocked, then approve cc10x's commands when Claude Code prompts. Deny rules alone do not gate git while Bash(python3:*) is allowed, because an allowed python3 command can run git without a prompt; removing one rule alone does not close every path either. In bypassPermissions, allow rules have no effect and only explicit ask rules prompt; deny rules still apply, but a deny rule on git commands does not see git run through python3. Only an ask or deny rule on python3 itself 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 as Bash(python3:*).

Step 4: Set User Standards (Optional)

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.

Step 5: Scan Installed Skills & Add to Table

Where to find installed skills:

  1. ~/.claude/settings.json → check enabledPlugins object (plugins with value true)
  2. ~/.claude/plugins/installed_plugins.json → detailed plugin info
  3. ~/.claude/skills/ → personal skills (all projects)
  4. .claude/skills/ → project-specific skills

Skill naming in table:

  • Plugin skills: plugin-name:skill-name (e.g., mongodb-agent-skills:mongodb-schema-design)
  • Personal/project skills: just the skill name (e.g., 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` |

Step 6: Confirm

"cc10x is set up! Please restart Claude Code to activate."


Memory Persistence

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.

How .cc10x/ relates to Claude Code auto-memory

They 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 Code auto-memory: notes Claude Code itself keeps for you across conversations, stored under your Claude configuration 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.


Optional MCP Integrations

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.

MCPFeature UnlockedHow to Install
octocodeGitHub 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
brightdataWeb 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.


Troubleshooting

Claude Code keeps asking for permission to edit memory files

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.


cc10x asks for permission before writing plan/research docs

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.


cc10x not activating in a specific project

The global ~/.claude/CLAUDE.md activates cc10x in every project — you only need one install. If it's not activating in a specific project:

  1. Check if that project has its own .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.
  2. Verify the router reference — the CLAUDE.md section names the plugin skill 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.
  3. Restart Claude Code — or run /reload-plugins after a plugin change. A CLAUDE.md change is read on the next session start.
  4. Check Python — if the session start printed a CC10X python warning, the hooks are silently off until Python 3.9 or newer is on PATH.

Ubuntu / Linux install error: EXDEV cross-device link

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/tmp
Source 4 files
hooks/register.tsx 326 lines
1import { 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}
326
hooks/loop-routes.ts 100 lines
1// 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}
100
hooks/loop-state.ts 289 lines
1// 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}
289
types/index.d.ts 53 lines
1export 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