Opinionated pipeline from a feature, bug, or refactor description to a verified, publish-ready implementation: scoped interview, wave-based plan, parallelized…

A Claude Code plugin that turns a one-line feature, bug, or refactor description into a verified, reviewed implementation on its own git branch. You type one command; devcycle asks clarifying questions to scope and brainstorm the work, designs a spec with you, plans it as parallel tasks, and implements them test-first with subagents running in parallel. It reviews the finished branch against the spec and — when the change is visible on screen — drives the running app through claude-in-chrome to auto-check every item a browser can confirm structurally, then walks you through the rest, since how it actually looks and feels is your verdict, not a script's.
Beyond building the change, devcycle closes the loop around a pull request: it reads a PR's review comments and reconciles them into the right fixes and consented replies. And it benchmarks its own runs and learns from past sessions, its learning report carrying a per-period ledger that nets win savings against culprit cost, and a routing advisory that measures what each model actually cost per accepted task — so the pipeline gets better the more you use it.
Policy — what it may do with git, which models it runs, how deep reviews go — is configuration, not something you re-explain each session, and a single profile preset sets the cost-versus-rigor level for all of it at once. devcycle is built entirely on the [superpowers] plugin (a required dependency): superpowers is the toolkit, devcycle is the guide for using it as a repeatable pipeline. Brainstorming and debugging are upstream's, unmodified; devcycle adds the stages, gates, and mechanics around them, and ships compact native planning and execution engines that overlay their upstream counterparts only at the thorough profile.
---
title: devcycle — where to start
accDescr: Command-level map of devcycle's nine slash commands. /devcycle:onboard scaffolds a new repo before the first cycle; /devcycle:cycle runs the guided pipeline through Scope/Design, Plan, Build, Review, and Ship; /devcycle:continue resumes a stopped cycle; and /devcycle:review, /devcycle:doctor, /devcycle:verify, /devcycle:learn, /devcycle:maintain, and /devcycle:reconcile each run standalone at any time.
---
flowchart LR
CYCLE(["/devcycle:cycle"]):::tool
CONT(["/devcycle:continue"]):::tool
ONBOARD(["/devcycle:onboard"]):::tool
REVIEWCMD(["/devcycle:review"]):::tool
DOCTOR(["/devcycle:doctor"]):::tool
VERIFY(["/devcycle:verify"]):::tool
LEARN(["/devcycle:learn"]):::tool
MAINTAIN(["/devcycle:maintain"]):::tool
RECONCILE(["/devcycle:reconcile"]):::tool
ONBOARD -. "first, in a new repo" .-> CYCLE
CYCLE --> SCOPE["Scope / Design"]:::stage
SCOPE --> PLAN["Plan"]:::stage
PLAN --> BUILD["Build"]:::stage
BUILD --> REVIEW["Review"]:::stage
REVIEW --> SHIP["Ship"]:::stage
CONT -. "resumes wherever the cycle stopped" .-> PLAN
REVIEWCMD -. "standalone, anytime" .-> SHIP
DOCTOR -. "standalone, anytime" .-> SHIP
VERIFY -. "standalone, anytime" .-> SHIP
LEARN -. "standalone, anytime" .-> SHIP
MAINTAIN -. "standalone, anytime" .-> SHIP
RECONCILE -. "standalone, anytime" .-> SHIP
classDef stage fill:#EEEDFE,stroke:#534AB7,color:#3C3489;
classDef tool fill:#E1F5EE,stroke:#0F6E56,color:#085041,stroke-dasharray:5 5;
classDef structural fill:#F1EFE8,stroke:#5F5E5A,color:#444441;
Command-level only — for stage internals, see docs/pipeline/.
Those nine commands are devcycle's entire surface — everything else it ships is machinery they load. The full stage-level pipeline is in docs/pipeline/.
Two of the standalone commands form a review write-back path around a pull request: /devcycle:review produces a ranked findings document and can file those findings back onto an open PR, and /devcycle:reconcile responds to a PR's own review comments — triaging them into fixes and consent-gated replies. Both post through one shared comment-body shape, so what Claude files and what it replies look the same on the PR.
claude plugin marketplace add KonstantinRoehrl/devcycle
claude plugin install devcycle@devcycle
devcycle depends on the [superpowers] plugin. Installing devcycle installs it automatically from the official Claude Code plugin directory; afterwards claude plugin list shows both devcycle and superpowers as enabled.
Requires Claude Code 2.1.287 or later — earlier releases reject the manifest's userConfig pick-lists (before 2.1.271) or run an earlier hooks-module API, under which devcycle's hooks module stays off.
What the hooks module can see. Besides its settings hooks, devcycle ships a hooks module (hooks/devcycle-mod.mjs) that Claude Code runs in-process: it sees your sessions' prompts, tool calls and tool results, it is not sandboxed, and claude plugin validate <plugin directory> lists every hook it registers and every engine call it makes. In every session it looks for a .devcycle/state.md in the working directory or a parent; where that file names an active run, it runs a short Node check (node hooks/mod-sink.mjs check-joined) to learn whether this session joined the run. Beyond that it acts only in a session that joined an active devcycle run, and what it writes are run-record rows of ids, counts and enum values.
For the on-device verification stage's automatic checks, also install the claude-in-chrome plugin — Claude Code's integration with your own Chrome (without it, every checklist item falls to you).
Start a cycle with a description of any maturity — a one-liner is fine:
/devcycle:cycle add CSV export to the report page
What to expect: devcycle first judges how developed your description is (a rough idea starts with a scope interview; a detailed ticket skips ahead). Questions come in small batches with concrete options to pick from, never one-at-a-time trickles. You approve the spec, then the plan; implementation, testing, and review then run without you; at the end you get a branch (and, for UI work, a short guided walkthrough of what to check on the running app).
The pipeline saves its position to files under .devcycle/ at every stage boundary, so it survives /clear, compaction, and new sessions — resume any time with /devcycle:continue. How the stages hand off and how the state file is verified is covered in docs/pipeline/.
The pipeline pauses between stages by design: it stops at most stage boundaries and asks you to run /clear and then /devcycle:continue, so a cycle plays out as several short sessions rather than one long one.
Execution can also walk its waves without you: when /devcycle:continue resumes at execution it asks whether to run them unattended (or follows the executionMode setting), then starts a driver that gives each wave a fresh session, watches it, and stops at the first thing that needs you — see running execution unattended.
Add .devcycle/ to the target repo's .gitignore — none of it belongs in git history. What devcycle attempts to commit outside that directory is docTrackingPolicy's call.
profile is the one knob most people need — it sets the cost-versus-rigor level for the whole run at once. The full configuration surface is in docs/configuration/.
claude plugin list — the [superpowers] dependency is missing or disabled. It normally installs automatically from the official plugin directory (claude plugin install superpowers@claude-plugins-official re-resolves it); once present and enabled, both plugins load.claude plugin uninstall superpowers@superpowers-marketplace.${user_config.KEY} string appears in output — you are looking at the resolver invocation of an entry command with that option unset; this is expected, and scripts/resolve-knobs.mjs resolves it per docs/configuration/. Set the option in /plugin configure to make the value substitute. Configured and still literal under claude --plugin-dir? That load never sees configured values — docs/platform-notes.md § (h)./scripts/ — a plugin script was named with ${CLAUDE_PLUGIN_ROOT} in text that reached a shell, where that token is empty: it is substituted when a playbook is rendered into a prompt, not by the shell. devcycle names plugin scripts "$(devcycle-root)/scripts/<name>.mjs" instead, quotes included — bin/devcycle-root prints the installed plugin root, and Claude Code puts <plugin>/bin on PATH, so it resolves from any shell, a dispatched subagent's included. The quotes are load-bearing: command substitution is word-split, so an unquoted form tears a plugin root containing a space and fails as a module-not-found on the truncated path.docs/ is the documentation hub — the design, the full pipeline, every playbook, the configuration surface, the decision log, and the full inventory of what the plugin ships. Start there.
docs/design/docs/decisions/references/delegation.mddocs/known-issues.mdCHANGELOG.mdCONTRIBUTING.md. scripts/doctor.mjs re-measures devcycle's own token profile from a local Claude Code session corpus, which is how the cost claims are kept honest rather than assumed; it reads each run's workload from records the hooks/workload-sensor.mjs commit-sensor writes on every commit, not from a single finish-stage step, and reports agent depth per stage from the per-subagent depth rows hooks/dispatch-sensor.mjs writes whenever a cycle's own session sees a subagent finish. The hooks module (hooks/devcycle-mod.mjs) adds an agent-trace row per finished subagent turn, which doctor cross-checks against those depth rows. The depth gate runs scripts/depth-probe.mjs, which measures one transcript without loading doctor. Every /devcycle:doctor cost-analysis reply carries a per-version and per-stage overview, rendered by scripts/doctor-overview.mjs from the same tables as the full report. A step that needs one section of a shared reference loads only that section, through scripts/read-section.mjs.scripts/self-dev-check.mjs guards the case where the repo under work is this plugin's own source, so the pipeline runs from the installed copy while the deliverable belongs in the repo. /devcycle:cycle records what the installed copy holds when the run starts — by content hash, via scripts/tree-hash.mjs — and the finish stage fails when an expected deliverable is absent from the branch or the installed copy was written to during the run. It is inert in every other repo.scripts/plan-check.mjs in its self-review — one fail-closed gate over seven legs, including the budget-fixture and authored-claims checks — and /devcycle:cycle runs scripts/contract-staleness-check.mjs as an advisory preflight that warns when a cached plugin's contract predates the target repo's. A host repo can declare its whole-gate checks in the required-checks manifest tests/fixtures/required-gate-checks.json — a committed JSON array of substrings every convention whole-gate command must contain, enforced by plan-check against the plan's gate commands and by scripts/evidence-completeness-check.mjs against each capture's cmd:, and a no-op when absent, keeping the plugin repo-agnostic. Every capture is written by scripts/capture-evidence.mjs, which runs the task's gate command, so a capture's header, the report's cmd: and the gate cannot drift apart. The contract itself lives in references/evidence.md.[superpowers]: https://github.com/obra/superpowers
hooks/devcycle-mod.mjs 312 lines1// devcycle's hooks module, loaded in-process from hooks/hooks.json's `modules`. In a session that
2// joined the active devcycle run it observes each subagent and writes one agent-trace run-record row
3// per finished subagent turn, under the agentId hooks/dispatch-sensor.mjs keys its agent-depth row by;
4// it bounds a subagent's context by the subagentBudget tier, and shows the coordinator's stage budget
5// on the status line. The engine's own forks (compaction, memory) are neither traced nor bounded.
6// It runs without Node — nothing it imports may import a node: module, call require or import
7// dynamically — so the state file's run, the joined-run check and the run-record write go through
8// hooks/mod-sink.mjs, which it spawns. Settings hooks sit beneath it in one chain, so every hook that
9// is not answering for itself returns next's result; every non-streaming hook passes the call through
10// when it fails. Claude Code's loader refuses the module unless `$` reaches only functions declared
11// at the top of this file and each event is registered once, so every helper below takes the
12// per-load state, `st`, as an argument, and one tool.call hook serves every behaviour.
13import { HARD_STOP, OVER_BUDGET, STAGE_FILES_READ, STAGE_TOOL_CALLS, budgetBand, contextDepth, windowFor } from "../scripts/depth-bands.mjs";
14import { activeRun } from "./lib/run-scope.mjs";
15
16// The first Claude Code whose hooks-module API this module is written against; below it the
17// early-access API differs, so the module stays inert (docs/platform-notes.md § (j)).
18const MIN_CLAUDE_CODE = [2, 1, 287];
19// A module's environment has no CLAUDE_PLUGIN_ROOT; its own file URL is the anchor.
20const SINK = decodeURIComponent(new URL("./mod-sink.mjs", import.meta.url).pathname);
21const STATE_REL = ".devcycle/state.md";
22const MAX_WALK = 64;
23const INERT = Object.freeze({ active: false, stateFile: null });
24// The limiter's tiers, the reads `enforce` refuses at the hard stop (never Write, Edit or Bash), and
25// how often the over-budget note repeats after its first crossing.
26const TIERS = ["off", "warn", "enforce"];
27const BOUNDED_READS = new Set(["Read", "Grep", "Glob", "WebFetch", "WebSearch"]);
28const NOTE_EVERY = 5;
29const percent = (fraction) => Math.round(fraction * 100);
30const HARD_STOP_DENY =
31 `devcycle budget: hard stop at ${percent(HARD_STOP)}% of the context window. Do not read further; ` +
32 "return what you have and name what you did not explore.";
33const budgetNote = (fraction) =>
34 `devcycle budget: this subagent is at ≈${percent(fraction)}% of its context window (over budget at ` +
35 `${percent(OVER_BUDGET)}%, hard stop at ${percent(HARD_STOP)}%). Finish with what you have; name what you did not explore.`;
36
37function atLeastFloor(base) {
38 const parts = String(base ?? "").match(/^(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number);
39 if (!parts) return false;
40 for (let i = 0; i < 3; i++) if (parts[i] !== MIN_CLAUDE_CODE[i]) return parts[i] > MIN_CLAUDE_CODE[i];
41 return true;
42}
43
44const stateFileIn = (dir) => `${dir === "/" ? "" : dir}/${STATE_REL}`;
45// A worktree's `.git` is a file, so only its existence is asked.
46const gitIn = (dir) => `${dir === "/" ? "" : dir}/.git`;
47const parentOf = (dir) => dir.slice(0, dir.lastIndexOf("/")) || "/";
48// A session joins a run by appending its session row from a main-loop Bash call (commands/cycle.md,
49// commands/continue.md), which leaves the state file untouched.
50const appendsSessionRow = (e) => e.tool === "Bash" && typeof e.command === "string" && e.command.includes("--kind session");
51
52// A subagent's per-turn counters. Its identity survives a reset, because a continued agent
53// (SendMessage, a resume) keeps its agentId.
54const turnCounters = () => ({ steps: 0, peakDepth: 0, lastDepth: null, model: null, toolResultChars: 0, warned: 0, refused: 0, overResults: 0 });
55
56// Everything one load of the module knows. `joinedKey` is "<session id> <run id>" once the sink
57// answered joined; a negative answer is never cached. `scope` is the promise active() answers from.
58// `tier` is the subagentBudget value Claude Code hands register; anything else reads as warn.
59// `meter` is the stage meter's counters for the stage it last displayed.
60function newState(options) {
61 return { versionOk: null, notNested: null, joinedKey: null, scope: null, agents: new Map(),
62 tier: TIERS.includes(options?.subagentBudget) ? options.subagentBudget : "warn",
63 meter: { stage: null, calls: 0, files: new Set(), percent: null, shown: false } };
64}
65
66async function resolveScope(st, $) {
67 st.versionOk ??= $.session.version().then((v) => atLeastFloor(v?.base), () => false);
68 st.notNested ??= $.env.get("DEVCYCLE_NESTED_RUN").then((v) => !v, () => false);
69 if (!(await st.versionOk) || !(await st.notNested)) return INERT;
70 const cwd = await $.session.cwd();
71 let stateFile = null;
72 let root = null;
73 for (let dir = cwd, i = 0; i < MAX_WALK && !stateFile; i++) {
74 if (await $.fs.exists(stateFileIn(dir))) stateFile = stateFileIn(dir);
75 else {
76 if (root === null && (await $.fs.exists(gitIn(dir)))) root = dir;
77 if (parentOf(dir) === dir) break;
78 dir = parentOf(dir);
79 }
80 }
81 // A cycle writes its state file at the repo root (references/resume.md § The state file), so with
82 // none found yet that is where one appears; the cwd's own candidate covers a directory outside git.
83 if (!stateFile) return { active: false, stateFile: null, candidates: [...new Set([root ?? cwd, cwd])].map(stateFileIn) };
84 const { mtimeMs } = await $.fs.stat(stateFile);
85 const run = activeRun(await $.fs.read(stateFile));
86 if (!run) return { active: false, stateFile, mtimeMs };
87 const sessionId = await $.session.id();
88 const key = `${sessionId} ${run.run}`;
89 if (st.joinedKey !== key) {
90 const answer = await $.process.run(["node", SINK, "check-joined", "--run", run.run, "--session", sessionId, "--cwd", cwd]);
91 if (answer.exitCode !== 0 || answer.stdout.trim() !== "joined") return { active: false, stateFile, mtimeMs };
92 st.joinedKey = key;
93 }
94 return { active: true, stateFile, mtimeMs, run: run.run, stage: run.stage, sessionId, cwd };
95}
96
97function current(st, $) {
98 return (st.scope ??= resolveScope(st, $).catch(() => INERT));
99}
100
101function refresh(st, $) {
102 st.scope = resolveScope(st, $).catch(() => INERT);
103}
104
105async function active(st, $) {
106 return (await current(st, $)).active;
107}
108
109function entry(st, agentId) {
110 let a = st.agents.get(agentId);
111 if (!a) {
112 a = { tracked: false, engineFork: false, agentType: null, requestedModel: null, resolvedModel: null,
113 parentAgentId: null, background: false, fork: false, isTeammate: false, ...turnCounters() };
114 st.agents.set(agentId, a);
115 }
116 return a;
117}
118
119// One stat per main-loop tool call: a cycle's start, a stage advance or a finished cycle writes the
120// state file. A session-row append while inactive asks the joined question again, once, right then.
121async function afterMainLoopCall(st, $, e) {
122 const seen = await current(st, $);
123 if ((appendsSessionRow(e) && !seen.active) || (await stateFileChanged($, seen))) refresh(st, $);
124 await updateMeter(st, $, e);
125}
126
127// A state file that appeared or disappeared changed as much as a rewritten one. With none known, only
128// the repo root's and the cwd's candidates are checked, so outside a devcycle run each call costs at
129// most two exists.
130async function stateFileChanged($, seen) {
131 if (seen.stateFile) return (await $.fs.stat(seen.stateFile).then((s) => s.mtimeMs, () => null)) !== seen.mtimeMs;
132 for (const candidate of seen.candidates ?? []) if (await $.fs.exists(candidate)) return true;
133 return false;
134}
135
136// A section read is a file read the meter must see too. The agent's command carries the expanded
137// plugin root, so this matches the script name rather than the ${CLAUDE_PLUGIN_ROOT} citation form.
138const SECTION_READ_CALL = /([^\s"']*)read-section\.mjs["']?\s+(["']?)(references\/[A-Za-z0-9._-]+\.md)\2/g;
139
140// Keyed like a Read's file_path, so one file read both ways counts once: an absolute script path
141// (<root>/scripts/read-section.mjs) names the plugin root; without one the relative path stands.
142function sectionReadKey([, scriptDir, , reference]) {
143 return scriptDir.startsWith("/") ? scriptDir.replace(/[^/]+\/$/, "") + reference : reference;
144}
145
146// The stage meter: in an interactive session, the coordinator's stage budget on the status line
147// (references/delegation.md § The stage budget). It only displays; it never refuses a call.
148async function updateMeter(st, $, e) {
149 const meter = st.meter;
150 const now = await current(st, $);
151 if (!now.active || (await $.session.surfaces()).length === 0) {
152 if (meter.shown) {
153 meter.shown = false;
154 await $.ui.status(undefined);
155 }
156 return;
157 }
158 if (now.stage !== meter.stage) Object.assign(meter, { stage: now.stage, calls: 0, files: new Set() });
159 meter.calls += 1;
160 if (e.tool === "Read" && typeof e.file_path === "string") meter.files.add(e.file_path);
161 if (e.tool === "Bash" && typeof e.command === "string")
162 for (const m of e.command.matchAll(SECTION_READ_CALL)) meter.files.add(sectionReadKey(m));
163 meter.shown = true;
164 // Claude Code labels a plugin's status entry with the plugin's name, so the text leaves it out.
165 await $.ui.status(`${meter.stage} · calls ${meter.calls}/~${STAGE_TOOL_CALLS} · ` +
166 `files ${meter.files.size}/~${STAGE_FILES_READ} · ctx ${meter.percent ?? "–"}%`);
167}
168
169async function mainLoopToolCall(st, $, e, next) {
170 const result = await next(e);
171 await afterMainLoopCall(st, $, e);
172 return result;
173}
174
175// Where a bounded subagent stands, or null: untracked, a fork (it inherits its parent's context, so
176// it would meet the hard stop on its first step) or a teammate (a session of its own), no measured
177// step yet, or a window no model table knows. An assumed window counts.
178function standing(st, agentId) {
179 const a = st.agents.get(agentId);
180 if (!a?.tracked || a.fork || a.isTeammate || a.lastDepth === null || !a.model) return null;
181 const w = windowFor(a.model);
182 return w ? { a, band: budgetBand(a.lastDepth, w.window), fraction: a.lastDepth / w.window } : null;
183}
184
185// The observer's character count and the limiter: past the over-budget band a bounded subagent's
186// result carries the note on the first crossing and every NOTE_EVERY-th result after it; under
187// enforce a bounded read at the hard stop is refused before it runs. A refused call carries no
188// result to attach a note to.
189async function subagentToolCall(st, $, e, next) {
190 const at = st.tier === "off" ? null : standing(st, e.agentId);
191 const bounded = at !== null && at.band !== "ok" && (await active(st, $));
192 if (bounded && st.tier === "enforce" && at.band === "hard-stop" && BOUNDED_READS.has(e.tool)) {
193 at.a.refused += 1;
194 return { deny: HARD_STOP_DENY };
195 }
196 const result = await next(e);
197 const a = st.agents.get(e.agentId);
198 // A character count of the result text, named as such: not tokens.
199 if (a?.tracked) a.toolResultChars += result.text?.length ?? 0;
200 if (!bounded || result.deny !== undefined) return result;
201 at.a.overResults += 1;
202 if ((at.a.overResults - 1) % NOTE_EVERY !== 0) return result;
203 at.a.warned += 1;
204 return { ...result, context: [...(result.context ?? []), budgetNote(at.fraction)] };
205}
206
207// Whether a loop is a subagent's or a teammate's. One with no spawn on record counts only when
208// `$.agent.list()` names it, as it does an agent whose spawn a module reload lost and never the
209// engine's own forks; the list is asked once per such loop.
210async function subagentLoop(st, $, agentId) {
211 const known = st.agents.get(agentId);
212 if (known) return !known.engineFork;
213 const listed = (await $.agent.list()).some((agent) => agent.id === agentId);
214 entry(st, agentId).engineFork = !listed;
215 return listed;
216}
217
218function flush($, seen, agentId, a, e) {
219 const w = a.model ? windowFor(a.model) : null;
220 const record = {
221 agentId, agentType: a.agentType, requestedModel: a.requestedModel, resolvedModel: a.resolvedModel,
222 parentAgentId: a.parentAgentId, background: a.background, fork: a.fork, steps: a.steps, peakDepth: a.peakDepth,
223 window: w?.window ?? null, windowAssumed: w?.windowAssumed === true, peakBand: w ? budgetBand(a.peakDepth, w.window) : null,
224 toolResultChars: a.toolResultChars, warned: a.warned, refused: a.refused, reason: e.reason, isAborted: e.isAborted === true,
225 };
226 // Started, never awaited: a subagent's hand-back must not wait on a Node startup. A lost flush is
227 // not retried; doctor's mod-inactive and COLLECTION GAP checks surface it.
228 $.process.run(["node", SINK, "append"], { stdin: JSON.stringify({ run: seen.run, session: seen.sessionId, cwd: seen.cwd, record }) })
229 .catch(() => {});
230}
231
232export function register(on, options) {
233 const st = newState(options);
234
235 // Every main-loop turn re-derives the run and the joined answer; the version and the child marker
236 // are read once per load.
237 on("turn.start", async ($, e, next) => {
238 refresh(st, $);
239 return next(e);
240 }).catch(($, e, next) => next(e));
241
242 // /clear starts a conversation under a new session id, which has not joined anything yet.
243 on("session.end", async ($, e, next) => {
244 const result = await next(e);
245 if (e.reason === "clear") {
246 st.joinedKey = null;
247 st.scope = null;
248 }
249 return result;
250 }).catch(($, e, next) => next(e));
251
252 // /devcycle:continue appends this session's row mid-turn, after turn.start asked and was told
253 // not-joined, so a spawn while inactive re-derives the scope before the agent runs: one sink check
254 // per spawn, and only until the session has joined (spec §4.3 amendment, 2026-10-07).
255 on("agent.spawn", async ($, e, next) => {
256 if (!(await active(st, $))) refresh(st, $);
257 const result = await next(e);
258 if (result.agentId && (await active(st, $)))
259 Object.assign(entry(st, result.agentId), {
260 agentType: e.subagentType, requestedModel: e.model ?? null, resolvedModel: result.model ?? null,
261 parentAgentId: e.parentAgentId ?? null, background: e.background === true, fork: e.fork === true,
262 isTeammate: e.isTeammate === true,
263 });
264 return result;
265 }).catch(($, e, next) => next(e));
266
267 // Tracking starts at an agent's first step, not its spawn, so state lost to a module reload costs
268 // the spawn's fields, never the record. A streaming hook: its bookkeeping catches its own errors
269 // once the stream has been relayed.
270 on("turn.step", async function* ($, e, next) {
271 const stepping = next(e);
272 for await (const chunk of stepping) yield chunk;
273 const result = await stepping.result;
274 try {
275 if (e.agentId !== undefined && (await active(st, $)) && (await subagentLoop(st, $, e.agentId))) {
276 const a = entry(st, e.agentId);
277 a.tracked = true;
278 const depth = contextDepth(result.usage);
279 if (depth !== null) {
280 a.steps += 1;
281 a.lastDepth = depth;
282 a.peakDepth = Math.max(a.peakDepth, depth);
283 a.model = result.usage.model ?? e.model;
284 }
285 }
286 } catch { /* observation only: a failed count never touches the step */ }
287 return result;
288 });
289
290 // The module's one tool.call hook: the loader refuses a second unmatched registration.
291 on("tool.call", async ($, e, next) =>
292 (e.agentId === undefined ? mainLoopToolCall(st, $, e, next) : subagentToolCall(st, $, e, next)))
293 .catch(($, e, next) => next(e));
294
295 on("turn.complete", async ($, e, next) => {
296 const result = await next(e);
297 const a = e.agentId === undefined ? undefined : st.agents.get(e.agentId);
298 if (a?.tracked) {
299 const seen = await current(st, $);
300 if (seen.active) flush($, seen, e.agentId, a, e);
301 Object.assign(a, turnCounters());
302 }
303 return result;
304 }).catch(($, e, next) => next(e));
305
306 on("session.measure", async ($, e, next) => {
307 const result = await next(e);
308 if (typeof e.context?.percent === "number") st.meter.percent = Math.round(e.context.percent);
309 return result;
310 }).catch(($, e, next) => next(e));
311}
312scripts/depth-bands.mjs 70 lines1// The context-depth arithmetic, with no Node import, so the hooks module (hooks/devcycle-mod.mjs) — which runs without Node — measures a subagent with the same functions depth-probe.mjs and doctor.mjs use. It imports only pricing.mjs.
2import { PRICING, parseModelId, priceFor, provisionalPriceFor } from "./pricing.mjs";
3
4// An unpriced model with no priced family is most likely a newer one, and every current Claude 5
5// model runs a 1M window; the label, not the number, is what tells the reader to fix pricing.mjs.
6export const ASSUMED_WINDOW = 1_000_000;
7
8// A step the engine reports with no usage block has no depth to measure — never zero.
9export function contextDepth(usage) {
10 if (!usage) return null;
11 return (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
12}
13
14// Fractions of the running model's context window. The underlying measurement is absolute —
15// cost per 1k output tokens bottoms at 15.1k in the 100-150k band and climbs to 40.7k past
16// 300k — taken on 1M-window sessions, so these fractions are those absolutes divided by 1M.
17// Expressing them as fractions is a deliberate approximation that lets them adapt to smaller
18// windows; cache-read cost actually scales with absolute tokens, not with the fraction used.
19// Doctor's own per-model band data is what should confirm or correct them once smaller-window
20// sessions have been measured.
21export const OVER_BUDGET = 0.15;
22export const HARD_STOP = 0.2;
23
24export function budgetBand(depth, window) {
25 const f = depth / window;
26 if (f >= HARD_STOP) return "hard-stop";
27 if (f >= OVER_BUDGET) return "over-budget";
28 return "ok";
29}
30
31// The id pricing.mjs would key the model under: strips the wrappers a host reports around it
32// (Claude Code's `[1m]`, Bedrock's `<region>.anthropic.` prefix and `-v<n>:<n>` suffix, Vertex's
33// `@<date>`) and reorders the older `claude-3-5-sonnet-...` naming to family-first.
34function canonicalModelId(model) {
35 return model
36 .replace(/\[1m\]$/i, "")
37 .replace(/^(?:[a-z]+\.)?anthropic\./, "")
38 .replace(/-v\d+:\d+$/, "")
39 .replace(/@\d{8}$/, "")
40 .replace(/^claude-(\d+(?:-\d+)*)-(opus|sonnet|haiku)(-\d{8})?$/, "claude-$2-$1$3");
41}
42
43const PRICED_VERSIONS = Object.keys(PRICING.models).map((id) => ({ id, ...parseModelId(id) })).filter((p) => p.family);
44const sameVersion = (a, b) => a.family === b.family && a.version.join("-") === b.version.join("-");
45
46// Null when no window is knowable: an unpriced version older than its family's newest priced one
47// (Sonnet 4.5, Opus 4.1) may have run a smaller window than that sibling, so the assumed 1M would
48// read its depth several times too shallow. An id that does not parse is assumed, not unknown.
49export function windowFor(model) {
50 const id = typeof model === "string" ? canonicalModelId(model) : model;
51 const exact = priceFor(id);
52 if (exact) return { window: exact.window };
53 const provisional = provisionalPriceFor(id);
54 if (provisional) return { window: provisional.price.window, windowProvisionalAs: provisional.basedOn };
55 const parsed = typeof id === "string" ? parseModelId(id) : null;
56 if (parsed) {
57 // A dated snapshot of a priced version is that version.
58 const snapshotOf = PRICED_VERSIONS.find((p) => sameVersion(p, parsed));
59 if (snapshotOf) return { window: PRICING.models[snapshotOf.id].window, windowProvisionalAs: snapshotOf.id };
60 // provisionalPriceFor declines a parsed id of a priced family only when it is older.
61 if (PRICED_VERSIONS.some((p) => p.family === parsed.family)) return null;
62 }
63 return { window: ASSUMED_WINDOW, windowAssumed: true };
64}
65
66// The coordinator's per-stage counters, references/delegation.md § The stage budget; the stage meter
67// displays them and golden-path holds them equal to that section.
68export const STAGE_TOOL_CALLS = 30;
69export const STAGE_FILES_READ = 15;
70hooks/lib/run-scope.mjs 15 lines1// Which state file names an active devcycle run: a 16-hex `run:` and a stage from the run record's
2// stage enum. Node-free, so the hooks module applies the same rule in-process that
3// hooks/dispatch-sensor.mjs and hooks/mod-sink.mjs apply on disk.
4import { field } from "../../scripts/md-field-core.mjs";
5
6export const STAGES = new Set(["scoping", "audit", "diagnosis", "brainstorm", "planning", "execution",
7 "branch-review", "on-device", "fast-path", "sweep", "finish", "maintain"]);
8export const RUN_ID = /^[0-9a-f]{16}$/;
9
10export function activeRun(stateText) {
11 const run = field(stateText, "run");
12 const stage = field(stateText, "stage");
13 return RUN_ID.test(run ?? "") && STAGES.has(stage) ? { run, stage } : null;
14}
15scripts/pricing.mjs 84 lines1// The one place model prices and context windows live. Both ride together because the
2// context-depth gate needs the window and a second table would be a second thing to let drift.
3// Prices are US dollars per million tokens.
4//
5// A row may carry `cacheRead`, the listed cache-read price. A row without it is "unlisted,
6// assumed": it reads at CACHE_READ_DEFAULT_MULTIPLIER x its input price, the rate doctor applied
7// to every model before cache reads were priced per model. `claude-fable-5` and the rows whose
8// reference entry names no cache-read price are in that state until one is listed.
9
10export const CACHE_READ_DEFAULT_MULTIPLIER = 0.1;
11
12export const PRICING = Object.freeze({
13 // The date the claude-api reference these prices came from was cached.
14 asOf: "2026-09-25",
15 models: Object.freeze({
16 "claude-opus-5-5": Object.freeze({ in: 4, out: 20, cacheRead: 0.2, window: 1_000_000 }),
17 "claude-opus-5": Object.freeze({ in: 5, out: 25, window: 1_000_000 }),
18 "claude-opus-4-8": Object.freeze({ in: 5, out: 25, window: 1_000_000 }),
19 "claude-fable-5": Object.freeze({ in: 10, out: 50, window: 1_000_000 }),
20 "claude-fable-5-1": Object.freeze({ in: 10, out: 50, cacheRead: 0.25, window: 1_000_000 }),
21 "claude-sonnet-5-5": Object.freeze({ in: 2, out: 10, cacheRead: 0.2, window: 1_000_000 }),
22 // List price per the claude-api reference as of asOf.
23 "claude-sonnet-5": Object.freeze({ in: 2, out: 10, window: 1_000_000 }),
24 "claude-haiku-4-5-20251001": Object.freeze({ in: 1, out: 5, window: 200_000 }),
25 }),
26});
27
28// The exact id, or null. Strict on purpose: the routing advisories, the impact ledger and
29// dispatch-cost present what they compute as measured, so none of them may receive a price
30// that was inferred for a model the table has never heard of.
31export function priceFor(model) {
32 if (typeof model !== "string" || model === "") return null;
33 return PRICING.models[model] ?? null;
34}
35
36// Cache-read cost for `tokens`, in per-million-weighted units (doctor divides by 1e6). The
37// unlisted branch keeps the operation order the flat rate used, so every figure for a row with
38// no listed cache-read price is bit-identical to what it was before.
39export function cacheReadDollars(tokens, price) {
40 return price.cacheRead !== undefined
41 ? tokens * price.cacheRead
42 : tokens * price.in * CACHE_READ_DEFAULT_MULTIPLIER;
43}
44
45// `claude-<family>-<n>[-<n>...][-<8-digit date>]`. Anything else (a family after the version, a
46// non-numeric suffix, a family the table does not price) does not parse and gets no fallback.
47const MODEL_ID = /^claude-(opus|sonnet|haiku|fable)-(\d+(?:-\d+)*?)(?:-\d{8})?$/;
48
49export function parseModelId(id) {
50 const m = MODEL_ID.exec(id);
51 if (!m) return null;
52 const version = m[2].split("-").map(Number);
53 // A lone date stamp is not a version: no real model line has a segment this large.
54 if (version.some((n) => n > 99)) return null;
55 return { family: m[1], version };
56}
57
58function compareSegments(a, b) {
59 for (let i = 0; i < Math.max(a.length, b.length); i++) {
60 const x = a[i] ?? 0, y = b[i] ?? 0;
61 if (x !== y) return x - y;
62 }
63 return 0;
64}
65
66// A provisional price for a model the table has no row for — only when the model is NEWER than
67// every priced model of its family, so a future `claude-opus-5-6` prices as Opus 5.5. An older
68// model is deliberately not covered: its price may differ from the newest sibling's (Sonnet 4.6
69// is $3/$15 against Sonnet 5.5's $2/$10), and a plausible wrong figure is worse than an exclusion.
70// Doctor reports what this returns on its own line and never folds it into a measured figure.
71export function provisionalPriceFor(model) {
72 if (typeof model !== "string" || priceFor(model)) return null;
73 const parsed = parseModelId(model);
74 if (!parsed) return null;
75 let best = null;
76 for (const [id, price] of Object.entries(PRICING.models)) {
77 const known = parseModelId(id);
78 if (!known || known.family !== parsed.family) continue;
79 if (!best || compareSegments(known.version, best.version) > 0) best = { id, price, version: known.version };
80 }
81 if (!best || compareSegments(parsed.version, best.version) < 0) return null;
82 return { price: best.price, basedOn: best.id };
83}
84scripts/md-field-core.mjs 11 lines1// The "- key: value" markdown-field-line parser, with no import at all, so the hooks module
2// (hooks/devcycle-mod.mjs), which runs without Node, reads the state file by the rule every script
3// uses; scripts/md-field.mjs re-exports it beside the readers that need the filesystem.
4// `[ \t]*` — never `\s*` — stops the capture at the field's own newline, so a field left blank on
5// its own line cannot read the following "- key:" line back as its value. A miss returns null, the
6// honest sentinel: "" is a legitimate present-but-blank value.
7export function field(text, key) {
8 const m = String(text).match(new RegExp(`^- ${key}:[ \\t]*(.*)$`, "m"));
9 return m ? m[1].trim() : null;
10}
11