SLOPSHOPPER

devcycle

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

newguardstatusprocessagents
★ 5v0.25.0MITupdated 2026-10-16KonstantinRoehrl/devcycle
A shopper browsing a rack in a slop shop
Preview could not run: TypeError: Invalid URL: null/home/runner/work/slopshopper/slopshopper/.cache/src/konstantinroehrl-devcycle/hooks/./mod-sink.mjs at new URLShim (inner.js:204:26) at anonymous (file:///inner.js:
README

devcycle

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.

Where to start

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

Install

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

Use

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

Troubleshooting

  • devcycle shows "failed to load" in 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.
  • Two copies of superpowers listed — you had superpowers installed from its own marketplace before installing devcycle, and the dependency pulled in the official-directory copy as well. Both work; keep one, e.g. claude plugin uninstall superpowers@superpowers-marketplace.
  • A literal ${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).
  • A command fails on a path that starts with /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.
  • Source edits don't show up after reinstalling — the plugin cache is keyed by version, and reinstalling the same version does not refresh it. Bump the version or uninstall and reinstall.

Go deeper

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.

  • Design rationale and architecture: docs/design/
  • Decision log: docs/decisions/
  • How the coordinator delegates and keeps its own context small: references/delegation.md
  • Open defects: docs/known-issues.md
  • Release history: CHANGELOG.md
  • Contributing, including the golden-path fixture that holds the pipeline's wiring together: CONTRIBUTING.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.
  • Developing devcycle itself: 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.
  • The evidence-and-verification contract's gates: planning runs 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

Source 5 files
hooks/devcycle-mod.mjs 312 lines
1// 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}
312
scripts/depth-bands.mjs 70 lines
1// 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;
70
hooks/lib/run-scope.mjs 15 lines
1// 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}
15
scripts/pricing.mjs 84 lines
1// 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}
84
scripts/md-field-core.mjs 11 lines
1// 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