runcastle 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -26,7 +26,8 @@ __export(exports_paths, {
26
26
  envPath: () => envPath,
27
27
  dbPath: () => dbPath,
28
28
  dataDir: () => dataDir,
29
- configPath: () => configPath
29
+ configPath: () => configPath,
30
+ burnCacheDir: () => burnCacheDir
30
31
  });
31
32
  import { homedir } from "os";
32
33
  import { join } from "path";
@@ -51,6 +52,9 @@ function sessionDir(id) {
51
52
  function worktreeDir(projectId, slug) {
52
53
  return join(dataDir(), "worktrees", projectId, slug);
53
54
  }
55
+ function burnCacheDir(pm) {
56
+ return join(dataDir(), "cache", pm);
57
+ }
54
58
  function sandboxBuildDir() {
55
59
  return join(dataDir(), "sandbox-build");
56
60
  }
@@ -99,7 +103,10 @@ var init_config = __esm(() => {
99
103
  sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
100
104
  mainBranch: z.string().default("main"),
101
105
  sandboxImage: z.string().optional(),
102
- burnConcurrency: z.number().int().min(1).max(8).default(3)
106
+ burnConcurrency: z.number().int().min(1).max(8).default(3),
107
+ burnMaxIterations: z.number().int().min(1).max(10).default(3),
108
+ setupCommand: z.string().optional(),
109
+ burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
103
110
  }));
104
111
  });
105
112
 
@@ -135,6 +142,13 @@ function loadConfig(env = process.env) {
135
142
  if (env.RUNCASTLE_BURN_CONCURRENCY) {
136
143
  overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
137
144
  }
145
+ if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
146
+ overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
147
+ }
148
+ if (env.RUNCASTLE_SETUP_COMMAND)
149
+ overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
150
+ if (env.RUNCASTLE_BURN_WORKSPACE)
151
+ overrides.burnWorkspace = env.RUNCASTLE_BURN_WORKSPACE;
138
152
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
139
153
  return RuncastleConfig.parse({ ...base, ...overrides });
140
154
  }
@@ -2491,6 +2505,46 @@ var init_skills_root = __esm(() => {
2491
2505
  SKILLS_MARKER = join7("packs", "runcastle");
2492
2506
  });
2493
2507
 
2508
+ // src/services/agent-stream.ts
2509
+ function beginTranscript(ticketId) {
2510
+ transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
2511
+ }
2512
+ function appendTranscript(ticketId, chunk) {
2513
+ const t = transcripts.get(ticketId);
2514
+ if (!t)
2515
+ return;
2516
+ const full = { ...chunk, i: t.nextIndex, ts: chunk.ts ?? Date.now() };
2517
+ t.nextIndex += 1;
2518
+ t.chunks.push(full);
2519
+ t.bytes += full.text.length;
2520
+ while (t.chunks.length > MAX_CHUNKS || t.bytes > MAX_BYTES && t.chunks.length > 1) {
2521
+ const dropped = t.chunks.shift();
2522
+ if (!dropped)
2523
+ break;
2524
+ t.bytes -= dropped.text.length;
2525
+ }
2526
+ }
2527
+ function endTranscript(ticketId) {
2528
+ const t = transcripts.get(ticketId);
2529
+ if (t)
2530
+ t.live = false;
2531
+ }
2532
+ function readTranscript(ticketId, after = -1) {
2533
+ const t = transcripts.get(ticketId);
2534
+ if (!t)
2535
+ return { live: false, chunks: [], firstIndex: 0, nextIndex: 0 };
2536
+ return {
2537
+ live: t.live,
2538
+ chunks: after < 0 ? [...t.chunks] : t.chunks.filter((c) => c.i > after),
2539
+ firstIndex: t.chunks[0]?.i ?? t.nextIndex,
2540
+ nextIndex: t.nextIndex
2541
+ };
2542
+ }
2543
+ var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
2544
+ var init_agent_stream = __esm(() => {
2545
+ transcripts = new Map;
2546
+ });
2547
+
2494
2548
  // src/workflows/ticket-burner.ts
2495
2549
  import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
2496
2550
  import { dirname as dirname3, join as join8 } from "path";
@@ -2603,6 +2657,97 @@ function parseEnvFile(content) {
2603
2657
  }
2604
2658
  return out;
2605
2659
  }
2660
+ function detectPackageManager(tc) {
2661
+ const pinned = tc.packageManagerField?.split("@")[0];
2662
+ if (pinned && PACKAGE_MANAGERS.includes(pinned)) {
2663
+ return pinned;
2664
+ }
2665
+ if (tc.lockfiles.bun)
2666
+ return "bun";
2667
+ if (tc.lockfiles.pnpm)
2668
+ return "pnpm";
2669
+ if (tc.lockfiles.yarn)
2670
+ return "yarn";
2671
+ if (tc.lockfiles.npm)
2672
+ return "npm";
2673
+ return tc.hasPackageJson ? "npm" : undefined;
2674
+ }
2675
+ function resolveSetupCommand(tc, override) {
2676
+ const trimmed = override?.trim();
2677
+ if (trimmed)
2678
+ return trimmed;
2679
+ const pm = detectPackageManager(tc);
2680
+ if (!pm)
2681
+ return;
2682
+ switch (pm) {
2683
+ case "bun":
2684
+ return tc.lockfiles.bun ? "bun install --frozen-lockfile" : "bun install";
2685
+ case "pnpm":
2686
+ return tc.lockfiles.pnpm ? "corepack pnpm install --frozen-lockfile" : "corepack pnpm install";
2687
+ case "yarn":
2688
+ return tc.lockfiles.yarn ? "corepack yarn install --frozen-lockfile" : "corepack yarn install";
2689
+ case "npm":
2690
+ return tc.lockfiles.npm ? "npm ci" : "npm install";
2691
+ }
2692
+ }
2693
+ function cacheMountFor(pm, hostPath) {
2694
+ const sandboxPath = PM_CACHE_SANDBOX_PATHS[pm];
2695
+ return sandboxPath ? { hostPath, sandboxPath } : undefined;
2696
+ }
2697
+ function resolveBurnWorkspaceMode(config2, platform = process.platform) {
2698
+ if (config2.sandbox === "noSandbox")
2699
+ return "mounted";
2700
+ if (config2.burnWorkspace === "auto")
2701
+ return platform === "linux" ? "mounted" : "isolated";
2702
+ return config2.burnWorkspace;
2703
+ }
2704
+ function buildIsolatedSetupCommand(tempBranch, setupCommand) {
2705
+ const hookFile = `${ISOLATED_REPO_PATH}/.git/hooks/post-commit`;
2706
+ const parts = [
2707
+ `git -C ${SANDBOX_WORKSPACE_PATH} config receive.denyCurrentBranch updateInstead`,
2708
+ `git clone ${SANDBOX_WORKSPACE_PATH} ${ISOLATED_REPO_PATH}`,
2709
+ `printf '#!/bin/sh\\nexec git push --quiet origin HEAD:%s\\n' '${tempBranch}' > ${hookFile}`,
2710
+ `chmod +x ${hookFile}`
2711
+ ];
2712
+ if (setupCommand)
2713
+ parts.push(`cd ${ISOLATED_REPO_PATH} && ${setupCommand}`);
2714
+ return parts.join(" && ");
2715
+ }
2716
+ function buildWorkspaceNotes(mode) {
2717
+ if (mode === "mounted") {
2718
+ return "Work in the current directory \u2014 it is the repo checkout on your branch.";
2719
+ }
2720
+ return [
2721
+ `Your working repository is \`${ISOLATED_REPO_PATH}\` \u2014 a clone on the container's fast native filesystem, with dependencies already installed. Do ALL work there: \`cd ${ISOLATED_REPO_PATH}\` first; every file you read, edit, test, and commit lives under it.`,
2722
+ "",
2723
+ `The directory you start in (\`${SANDBOX_WORKSPACE_PATH}\`) is a slow mounted mirror used only to collect your commits \u2014 never edit files, install, or run tests there. Your commits sync back automatically (a post-commit hook pushes them); just commit as normal.`,
2724
+ "",
2725
+ `If you are blocked and write \`BLOCKED.md\`, write it at \`${ISOLATED_REPO_PATH}/BLOCKED.md\` AND copy it to \`${SANDBOX_WORKSPACE_PATH}/BLOCKED.md\` so the orchestrator can see it.`
2726
+ ].join(`
2727
+ `);
2728
+ }
2729
+ function readRepoToolchain(repoPath) {
2730
+ const pkgPath = join8(repoPath, "package.json");
2731
+ const hasPackageJson = existsSync10(pkgPath);
2732
+ let packageManagerField;
2733
+ if (hasPackageJson) {
2734
+ try {
2735
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
2736
+ if (typeof pkg.packageManager === "string")
2737
+ packageManagerField = pkg.packageManager;
2738
+ } catch {}
2739
+ }
2740
+ return {
2741
+ hasPackageJson,
2742
+ ...packageManagerField !== undefined ? { packageManagerField } : {},
2743
+ lockfiles: {
2744
+ bun: existsSync10(join8(repoPath, "bun.lock")) || existsSync10(join8(repoPath, "bun.lockb")),
2745
+ pnpm: existsSync10(join8(repoPath, "pnpm-lock.yaml")),
2746
+ yarn: existsSync10(join8(repoPath, "yarn.lock")),
2747
+ npm: existsSync10(join8(repoPath, "package-lock.json"))
2748
+ }
2749
+ };
2750
+ }
2606
2751
  function createStreamThrottle(emit2, opts = {}) {
2607
2752
  const intervalMs = opts.intervalMs ?? 2000;
2608
2753
  const maxChars = opts.maxChars ?? 500;
@@ -2854,8 +2999,11 @@ function buildBurnAgent(config2, token, model) {
2854
2999
  }
2855
3000
  return agent;
2856
3001
  }
2857
- function selectSandbox(config2) {
2858
- const imageOpts = { imageName: resolveSandboxImage(config2) };
3002
+ function selectSandbox(config2, mounts = []) {
3003
+ const imageOpts = {
3004
+ imageName: resolveSandboxImage(config2),
3005
+ ...mounts.length > 0 ? { mounts } : {}
3006
+ };
2859
3007
  switch (config2.sandbox) {
2860
3008
  case "docker":
2861
3009
  return docker(imageOpts);
@@ -2868,31 +3016,74 @@ function selectSandbox(config2) {
2868
3016
  async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2869
3017
  const { project, feature } = ctx;
2870
3018
  const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
3019
+ const workspaceMode = resolveBurnWorkspaceMode(config2);
2871
3020
  const template = readFileSync4(burnerTemplatePath(), "utf8");
2872
3021
  const prompt = renderTicketPrompt(template, {
2873
3022
  TICKET_JSON: buildTicketJson(ticket),
2874
3023
  FEATURE_BRIEF: buildFeatureBrief(feature),
2875
3024
  DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
2876
- COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`
3025
+ COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
3026
+ WORKSPACE_NOTES: buildWorkspaceNotes(workspaceMode)
2877
3027
  });
3028
+ const toolchain = readRepoToolchain(project.repoPath);
3029
+ const pm = detectPackageManager(toolchain);
3030
+ const setupCommand = resolveSetupCommand(toolchain, config2.setupCommand);
3031
+ const mounts = [];
3032
+ if (config2.sandbox !== "noSandbox" && pm) {
3033
+ const mount = cacheMountFor(pm, burnCacheDir(pm));
3034
+ if (mount) {
3035
+ mkdirSync4(mount.hostPath, { recursive: true });
3036
+ mounts.push(mount);
3037
+ }
3038
+ }
3039
+ const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand) : setupCommand;
3040
+ if (hookCommand) {
3041
+ ctx.emitEvent({
3042
+ type: "burn.setup",
3043
+ message: workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
3044
+ ticketId: ticket.id
3045
+ });
3046
+ }
2878
3047
  mkdirSync4(logsDir(), { recursive: true });
2879
3048
  const logFilePath = join8(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
2880
3049
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3050
+ beginTranscript(ticket.id);
3051
+ const onStreamEvent = (event) => {
3052
+ throttle.onEvent(event);
3053
+ if (event.type === "text") {
3054
+ appendTranscript(ticket.id, { kind: "text", text: event.message });
3055
+ } else if (event.type === "toolCall") {
3056
+ appendTranscript(ticket.id, {
3057
+ kind: "tool",
3058
+ text: event.formattedArgs ?? "",
3059
+ name: event.name
3060
+ });
3061
+ }
3062
+ };
2881
3063
  const runOptions = {
2882
3064
  agent: buildBurnAgent(config2, token, model),
2883
- sandbox: selectSandbox(config2),
3065
+ sandbox: selectSandbox(config2, mounts),
2884
3066
  cwd: project.repoPath,
2885
3067
  prompt,
2886
3068
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
2887
3069
  signal: ctx.signal,
2888
3070
  name: `ticket-${ticket.seq}`,
2889
- logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
3071
+ maxIterations: config2.burnMaxIterations,
3072
+ ...hookCommand ? {
3073
+ hooks: {
3074
+ sandbox: {
3075
+ onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
3076
+ }
3077
+ }
3078
+ } : {},
3079
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
2890
3080
  };
2891
3081
  let result;
2892
3082
  try {
2893
3083
  result = await run(runOptions);
2894
3084
  } catch (err) {
2895
3085
  throttle.flush();
3086
+ endTranscript(ticket.id);
2896
3087
  if (ctx.signal.aborted)
2897
3088
  throw err;
2898
3089
  const msg = err instanceof Error ? err.message : String(err);
@@ -2909,6 +3100,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2909
3100
  return { status: "failed", error: msg };
2910
3101
  }
2911
3102
  throttle.flush();
3103
+ endTranscript(ticket.id);
2912
3104
  const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
2913
3105
  const outcome = interpretRunResult(result, blocked);
2914
3106
  if (outcome.status !== "done")
@@ -2957,14 +3149,28 @@ function readTokenFromEnvFile(path) {
2957
3149
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
2958
3150
  return token && token.length > 0 ? token : undefined;
2959
3151
  }
2960
- var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
3152
+ var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", SETUP_HOOK_TIMEOUT_MS, ticketBurner;
2961
3153
  var init_ticket_burner = __esm(() => {
2962
3154
  init_src();
2963
3155
  init_config_load();
2964
3156
  init_paths();
2965
3157
  init_skills_root();
3158
+ init_agent_stream();
2966
3159
  init_git();
2967
- PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
3160
+ PLACEHOLDERS = [
3161
+ "TICKET_JSON",
3162
+ "FEATURE_BRIEF",
3163
+ "DOCS_DIGEST",
3164
+ "COMMIT_CONVENTION",
3165
+ "WORKSPACE_NOTES"
3166
+ ];
3167
+ PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3168
+ PM_CACHE_SANDBOX_PATHS = {
3169
+ bun: "~/.bun/install/cache",
3170
+ yarn: "~/.cache/yarn",
3171
+ npm: "~/.npm"
3172
+ };
3173
+ SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
2968
3174
  ticketBurner = {
2969
3175
  id: "ticket-burner",
2970
3176
  run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
@@ -4741,6 +4947,17 @@ import { eq as eq9, isNull } from "drizzle-orm";
4741
4947
  function listProjects(ctx) {
4742
4948
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
4743
4949
  }
4950
+ function isTranslatedWindowsMount(repoPath, platform = process.platform) {
4951
+ return platform === "linux" && /^\/mnt\/[a-z](\/|$)/i.test(repoPath);
4952
+ }
4953
+ function warnIfTranslatedMount(ctx, projectId, repoPath) {
4954
+ if (!isTranslatedWindowsMount(repoPath))
4955
+ return;
4956
+ emitProject(ctx, projectId, {
4957
+ type: "project.slow-path",
4958
+ message: `${repoPath} is a Windows drive mounted into Linux (/mnt) \u2014 git and burns pay a per-file translation tax here; clone the repo into the Linux filesystem (e.g. ~/projects) for native speed`
4959
+ });
4960
+ }
4744
4961
  async function openProject(ctx, repoPath) {
4745
4962
  await assertRepoTolerant(ctx, repoPath);
4746
4963
  const mainBranch = await detectMainBranchTolerant(ctx, repoPath);
@@ -4753,6 +4970,7 @@ async function openProject(ctx, repoPath) {
4753
4970
  type: "project.opened",
4754
4971
  message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
4755
4972
  });
4973
+ warnIfTranslatedMount(ctx, existing.id, repoPath);
4756
4974
  return reopened;
4757
4975
  }
4758
4976
  const row = {
@@ -4769,6 +4987,7 @@ async function openProject(ctx, repoPath) {
4769
4987
  type: "project.opened",
4770
4988
  message: `project ${name} at ${repoPath} (${mainBranch})`
4771
4989
  });
4990
+ warnIfTranslatedMount(ctx, project.id, repoPath);
4772
4991
  return project;
4773
4992
  }
4774
4993
  function closeProject(ctx, projectId) {
@@ -4843,11 +5062,13 @@ var init_project = __esm(() => {
4843
5062
  import * as z8 from "zod";
4844
5063
  var runRouter;
4845
5064
  var init_run = __esm(() => {
5065
+ init_agent_stream();
4846
5066
  init_repo();
4847
5067
  init_runner();
4848
5068
  init_context();
4849
5069
  runRouter = router({
4850
5070
  get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
5071
+ agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
4851
5072
  cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
4852
5073
  cancelRun(input.runId);
4853
5074
  return { ok: true };
@@ -5083,6 +5304,22 @@ var init_settings = __esm(() => {
5083
5304
  valueSchema: z9.number().int().min(1).max(8),
5084
5305
  parseEnv: (raw) => Number(raw)
5085
5306
  },
5307
+ {
5308
+ key: "burnMaxIterations",
5309
+ configKey: "burnMaxIterations",
5310
+ envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
5311
+ restartRequired: false,
5312
+ valueSchema: z9.number().int().min(1).max(10),
5313
+ parseEnv: (raw) => Number(raw)
5314
+ },
5315
+ {
5316
+ key: "setupCommand",
5317
+ configKey: "setupCommand",
5318
+ envVar: "RUNCASTLE_SETUP_COMMAND",
5319
+ restartRequired: false,
5320
+ valueSchema: z9.string().min(1),
5321
+ parseEnv: idEnv
5322
+ },
5086
5323
  {
5087
5324
  key: "mainBranch",
5088
5325
  configKey: "mainBranch",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,6 +4,18 @@
4
4
 
5
5
  You are a single agent in a sandbox on branch `feature/<slug>`. You have **one ticket**. There is **no human to ask** — no follow-up questions are possible. Everything you need is in this prompt and in the repo. Work carefully, commit only green work, and stop when the ticket is done.
6
6
 
7
+ ## How you run
8
+
9
+ You run **non-interactively** (`claude --print`), for up to a few fresh iterations against the same worktree:
10
+
11
+ - **Ending your turn ends your process.** There are no background-task completion notifications in print mode — a "the notification will re-invoke me" plan never fires. Never end your turn to wait on a background command; run long commands (dependency installs, full test suites) in the foreground with a generous timeout, or poll a backgrounded command to completion *within* the same turn.
12
+ - **You may not be the first iteration.** A previous iteration may have left commits or uncommitted work on this branch. Check `git status` and `git log` before starting; continue the work, don't redo it.
13
+ - **Signal completion.** When the ticket is fully done — every acceptance criterion verified, self-review finished, all work committed — print exactly `<promise>COMPLETE</promise>` as the last line of your final message. Do the same after writing `BLOCKED.md` (see hard rules). Without the signal, the harness assumes you were cut off and spends another iteration.
14
+
15
+ ## Where to work
16
+
17
+ {{WORKSPACE_NOTES}}
18
+
7
19
  ## The ticket
8
20
 
9
21
  ```json
@@ -30,6 +42,8 @@ You are a single agent in a sandbox on branch `feature/<slug>`. You have **one t
30
42
 
31
43
  `{{COMMIT_CONVENTION}}`
32
44
 
45
+ Run any full suite **once**, capturing output to a file (`<test command> > /tmp/test-run.log 2>&1`; check the exit code), then grep the file for failures and details — never re-run the whole suite just to re-read or filter its output.
46
+
33
47
  5. **Self-review before you finish (forked code-review — two axes).** When all acceptance criteria pass, review your own diff along both axes, then fix what you find and re-run typecheck + tests:
34
48
  - **Standards** — does the diff follow the conventions of the surrounding code? Watch for the smells: duplicated logic (extract it), mysterious names (rename), primitive obsession (give the concept a type), speculative generality (delete anything the ticket did not ask for), feature envy, data clumps.
35
49
  - **Spec = this ticket** — is every acceptance criterion actually met, and is there **nothing in the diff the ticket did not ask for**? Missing and extra both count.
@@ -39,4 +53,4 @@ You are a single agent in a sandbox on branch `feature/<slug>`. You have **one t
39
53
 
40
54
  - **Never expand scope beyond the ticket.** If you notice adjacent work, worthwhile refactors, or another ticket's territory, leave it. Note it in your final commit body if it matters; do not do it.
41
55
  - **No questions, no guessing into the void.** Resolve ambiguity from the ticket context and the code. If two readings both satisfy the acceptance criteria, take the smaller one.
42
- - **If genuinely blocked** (a dependency ticket's output is not there, the environment fails, or a requirement truly cannot be resolved from context + code): **commit nothing for the blocked part**, write `BLOCKED.md` at the repo root stating precisely what blocked you and what is needed to unblock, and stop. Green, complete parts may still be committed; the blocked part must not be. The orchestrator reads your exit state from the commits — a part with no commits is read as not done.
56
+ - **If genuinely blocked** (a dependency ticket's output is not there, the environment fails, or a requirement truly cannot be resolved from context + code): **commit nothing for the blocked part**, write `BLOCKED.md` at the repo root stating precisely what blocked you and what is needed to unblock, print `<promise>COMPLETE</promise>`, and stop. Green, complete parts may still be committed; the blocked part must not be. The orchestrator reads your exit state from the commits — a part with no commits is read as not done.