runcastle 1.0.7 → 1.0.9

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,9 @@ 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()
103
109
  }));
104
110
  });
105
111
 
@@ -135,6 +141,11 @@ function loadConfig(env = process.env) {
135
141
  if (env.RUNCASTLE_BURN_CONCURRENCY) {
136
142
  overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
137
143
  }
144
+ if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
145
+ overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
146
+ }
147
+ if (env.RUNCASTLE_SETUP_COMMAND)
148
+ overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
138
149
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
139
150
  return RuncastleConfig.parse({ ...base, ...overrides });
140
151
  }
@@ -2115,11 +2126,14 @@ async function reattachWorktree(path, branch) {
2115
2126
  await git(path).checkout(branch);
2116
2127
  } catch {}
2117
2128
  }
2129
+ function tempBranchSlugSegment(slug) {
2130
+ return slug.slice(0, TEMP_BRANCH_SLUG_MAX).replace(/-+$/, "");
2131
+ }
2118
2132
  function researchBranchName(slug, waypointSeq, unique) {
2119
- return `${RESEARCH_BRANCH_PREFIX}${slug}/${waypointSeq}-${unique}`;
2133
+ return `${RESEARCH_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${waypointSeq}-${unique}`;
2120
2134
  }
2121
2135
  function ticketBranchName(slug, ticketSeq, unique) {
2122
- return `${TICKET_BRANCH_PREFIX}${slug}/${ticketSeq}-${unique}`;
2136
+ return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2123
2137
  }
2124
2138
  async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2125
2139
  const g = git(repoPath);
@@ -2187,17 +2201,23 @@ async function cleanupTempBranches(repoPath) {
2187
2201
  const prefix = tempBranchPrefix(name);
2188
2202
  if (!prefix)
2189
2203
  continue;
2190
- const slug = name.slice(prefix.length).split("/")[0];
2191
- const target = featureBranch(slug);
2204
+ const seg = name.slice(prefix.length).split("/")[0];
2205
+ const targets = seg ? all.filter((b) => {
2206
+ if (!b.startsWith("feature/"))
2207
+ return false;
2208
+ const featureSlug = b.slice("feature/".length);
2209
+ return featureSlug === seg || tempBranchSlugSegment(featureSlug) === seg;
2210
+ }) : [];
2192
2211
  let merged = false;
2193
- if (slug && all.includes(target)) {
2212
+ for (const target of targets) {
2194
2213
  try {
2195
2214
  const tip = (await g.revparse([name])).trim();
2196
2215
  const base = (await g.raw(["merge-base", name, target])).trim();
2197
- merged = tip.length > 0 && tip === base;
2198
- } catch {
2199
- merged = false;
2200
- }
2216
+ if (tip.length > 0 && tip === base) {
2217
+ merged = true;
2218
+ break;
2219
+ }
2220
+ } catch {}
2201
2221
  }
2202
2222
  if (merged && await deleteBranchDetachingWorktrees(g, repoPath, name)) {
2203
2223
  deleted.push(name);
@@ -2344,7 +2364,7 @@ async function mergeInProgress(g) {
2344
2364
  return false;
2345
2365
  }
2346
2366
  }
2347
- var DOCS_PATHSPEC = "docs/features", DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", TEMP_BRANCH_PREFIXES, testDriveState;
2367
+ var DOCS_PATHSPEC = "docs/features", DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
2348
2368
  var init_git = __esm(() => {
2349
2369
  init_paths();
2350
2370
  init_errors();
@@ -2482,6 +2502,46 @@ var init_skills_root = __esm(() => {
2482
2502
  SKILLS_MARKER = join7("packs", "runcastle");
2483
2503
  });
2484
2504
 
2505
+ // src/services/agent-stream.ts
2506
+ function beginTranscript(ticketId) {
2507
+ transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
2508
+ }
2509
+ function appendTranscript(ticketId, chunk) {
2510
+ const t = transcripts.get(ticketId);
2511
+ if (!t)
2512
+ return;
2513
+ const full = { ...chunk, i: t.nextIndex, ts: chunk.ts ?? Date.now() };
2514
+ t.nextIndex += 1;
2515
+ t.chunks.push(full);
2516
+ t.bytes += full.text.length;
2517
+ while (t.chunks.length > MAX_CHUNKS || t.bytes > MAX_BYTES && t.chunks.length > 1) {
2518
+ const dropped = t.chunks.shift();
2519
+ if (!dropped)
2520
+ break;
2521
+ t.bytes -= dropped.text.length;
2522
+ }
2523
+ }
2524
+ function endTranscript(ticketId) {
2525
+ const t = transcripts.get(ticketId);
2526
+ if (t)
2527
+ t.live = false;
2528
+ }
2529
+ function readTranscript(ticketId, after = -1) {
2530
+ const t = transcripts.get(ticketId);
2531
+ if (!t)
2532
+ return { live: false, chunks: [], firstIndex: 0, nextIndex: 0 };
2533
+ return {
2534
+ live: t.live,
2535
+ chunks: after < 0 ? [...t.chunks] : t.chunks.filter((c) => c.i > after),
2536
+ firstIndex: t.chunks[0]?.i ?? t.nextIndex,
2537
+ nextIndex: t.nextIndex
2538
+ };
2539
+ }
2540
+ var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
2541
+ var init_agent_stream = __esm(() => {
2542
+ transcripts = new Map;
2543
+ });
2544
+
2485
2545
  // src/workflows/ticket-burner.ts
2486
2546
  import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
2487
2547
  import { dirname as dirname3, join as join8 } from "path";
@@ -2594,6 +2654,64 @@ function parseEnvFile(content) {
2594
2654
  }
2595
2655
  return out;
2596
2656
  }
2657
+ function detectPackageManager(tc) {
2658
+ const pinned = tc.packageManagerField?.split("@")[0];
2659
+ if (pinned && PACKAGE_MANAGERS.includes(pinned)) {
2660
+ return pinned;
2661
+ }
2662
+ if (tc.lockfiles.bun)
2663
+ return "bun";
2664
+ if (tc.lockfiles.pnpm)
2665
+ return "pnpm";
2666
+ if (tc.lockfiles.yarn)
2667
+ return "yarn";
2668
+ if (tc.lockfiles.npm)
2669
+ return "npm";
2670
+ return tc.hasPackageJson ? "npm" : undefined;
2671
+ }
2672
+ function resolveSetupCommand(tc, override) {
2673
+ const trimmed = override?.trim();
2674
+ if (trimmed)
2675
+ return trimmed;
2676
+ const pm = detectPackageManager(tc);
2677
+ if (!pm)
2678
+ return;
2679
+ switch (pm) {
2680
+ case "bun":
2681
+ return tc.lockfiles.bun ? "bun install --frozen-lockfile" : "bun install";
2682
+ case "pnpm":
2683
+ return tc.lockfiles.pnpm ? "corepack pnpm install --frozen-lockfile" : "corepack pnpm install";
2684
+ case "yarn":
2685
+ return tc.lockfiles.yarn ? "corepack yarn install --frozen-lockfile" : "corepack yarn install";
2686
+ case "npm":
2687
+ return tc.lockfiles.npm ? "npm ci" : "npm install";
2688
+ }
2689
+ }
2690
+ function cacheMountFor(pm, hostPath) {
2691
+ return { hostPath, sandboxPath: PM_CACHE_SANDBOX_PATHS[pm] };
2692
+ }
2693
+ function readRepoToolchain(repoPath) {
2694
+ const pkgPath = join8(repoPath, "package.json");
2695
+ const hasPackageJson = existsSync10(pkgPath);
2696
+ let packageManagerField;
2697
+ if (hasPackageJson) {
2698
+ try {
2699
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
2700
+ if (typeof pkg.packageManager === "string")
2701
+ packageManagerField = pkg.packageManager;
2702
+ } catch {}
2703
+ }
2704
+ return {
2705
+ hasPackageJson,
2706
+ ...packageManagerField !== undefined ? { packageManagerField } : {},
2707
+ lockfiles: {
2708
+ bun: existsSync10(join8(repoPath, "bun.lock")) || existsSync10(join8(repoPath, "bun.lockb")),
2709
+ pnpm: existsSync10(join8(repoPath, "pnpm-lock.yaml")),
2710
+ yarn: existsSync10(join8(repoPath, "yarn.lock")),
2711
+ npm: existsSync10(join8(repoPath, "package-lock.json"))
2712
+ }
2713
+ };
2714
+ }
2597
2715
  function createStreamThrottle(emit2, opts = {}) {
2598
2716
  const intervalMs = opts.intervalMs ?? 2000;
2599
2717
  const maxChars = opts.maxChars ?? 500;
@@ -2652,10 +2770,11 @@ function createSerialQueue() {
2652
2770
  return next;
2653
2771
  };
2654
2772
  }
2655
- function firstLine(s) {
2656
- const i = s.indexOf(`
2657
- `);
2658
- return (i === -1 ? s : s.slice(0, i)).trim();
2773
+ function errorHeadline(s) {
2774
+ const lines = s.split(`
2775
+ `).map((l) => l.trim()).filter(Boolean);
2776
+ const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
2777
+ return causes.at(-1) ?? lines[0] ?? "";
2659
2778
  }
2660
2779
  async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
2661
2780
  const width = Math.max(1, Math.floor(concurrency));
@@ -2710,7 +2829,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
2710
2829
  failTicket(seq, outcome.error, outcome.event);
2711
2830
  ctx.emitEvent({
2712
2831
  type: "ticket.failed",
2713
- message: `ticket ${t.seq} failed: ${firstLine(outcome.error)}`,
2832
+ message: `ticket ${t.seq} failed: ${errorHeadline(outcome.error)}`,
2714
2833
  ticketId: t.id,
2715
2834
  data: { error: outcome.error }
2716
2835
  });
@@ -2844,8 +2963,11 @@ function buildBurnAgent(config2, token, model) {
2844
2963
  }
2845
2964
  return agent;
2846
2965
  }
2847
- function selectSandbox(config2) {
2848
- const imageOpts = { imageName: resolveSandboxImage(config2) };
2966
+ function selectSandbox(config2, mounts = []) {
2967
+ const imageOpts = {
2968
+ imageName: resolveSandboxImage(config2),
2969
+ ...mounts.length > 0 ? { mounts } : {}
2970
+ };
2849
2971
  switch (config2.sandbox) {
2850
2972
  case "docker":
2851
2973
  return docker(imageOpts);
@@ -2865,24 +2987,62 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2865
2987
  DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
2866
2988
  COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`
2867
2989
  });
2990
+ const toolchain = readRepoToolchain(project.repoPath);
2991
+ const pm = detectPackageManager(toolchain);
2992
+ const setupCommand = resolveSetupCommand(toolchain, config2.setupCommand);
2993
+ const mounts = [];
2994
+ if (config2.sandbox !== "noSandbox" && pm) {
2995
+ const hostCache = burnCacheDir(pm);
2996
+ mkdirSync4(hostCache, { recursive: true });
2997
+ mounts.push(cacheMountFor(pm, hostCache));
2998
+ }
2999
+ if (setupCommand) {
3000
+ ctx.emitEvent({
3001
+ type: "burn.setup",
3002
+ message: `installing deps before agent start: ${setupCommand}`,
3003
+ ticketId: ticket.id
3004
+ });
3005
+ }
2868
3006
  mkdirSync4(logsDir(), { recursive: true });
2869
3007
  const logFilePath = join8(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
2870
3008
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3009
+ beginTranscript(ticket.id);
3010
+ const onStreamEvent = (event) => {
3011
+ throttle.onEvent(event);
3012
+ if (event.type === "text") {
3013
+ appendTranscript(ticket.id, { kind: "text", text: event.message });
3014
+ } else if (event.type === "toolCall") {
3015
+ appendTranscript(ticket.id, {
3016
+ kind: "tool",
3017
+ text: event.formattedArgs ?? "",
3018
+ name: event.name
3019
+ });
3020
+ }
3021
+ };
2871
3022
  const runOptions = {
2872
3023
  agent: buildBurnAgent(config2, token, model),
2873
- sandbox: selectSandbox(config2),
3024
+ sandbox: selectSandbox(config2, mounts),
2874
3025
  cwd: project.repoPath,
2875
3026
  prompt,
2876
3027
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
2877
3028
  signal: ctx.signal,
2878
3029
  name: `ticket-${ticket.seq}`,
2879
- logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
3030
+ maxIterations: config2.burnMaxIterations,
3031
+ ...setupCommand ? {
3032
+ hooks: {
3033
+ sandbox: {
3034
+ onSandboxReady: [{ command: setupCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
3035
+ }
3036
+ }
3037
+ } : {},
3038
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
2880
3039
  };
2881
3040
  let result;
2882
3041
  try {
2883
3042
  result = await run(runOptions);
2884
3043
  } catch (err) {
2885
3044
  throttle.flush();
3045
+ endTranscript(ticket.id);
2886
3046
  if (ctx.signal.aborted)
2887
3047
  throw err;
2888
3048
  const msg = err instanceof Error ? err.message : String(err);
@@ -2899,6 +3059,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2899
3059
  return { status: "failed", error: msg };
2900
3060
  }
2901
3061
  throttle.flush();
3062
+ endTranscript(ticket.id);
2902
3063
  const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
2903
3064
  const outcome = interpretRunResult(result, blocked);
2904
3065
  if (outcome.status !== "done")
@@ -2918,7 +3079,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2918
3079
  }
2919
3080
  return {
2920
3081
  status: "failed",
2921
- error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${firstLine(detail)} \u2014 the branch is preserved for manual recovery`
3082
+ error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
2922
3083
  };
2923
3084
  }
2924
3085
  return outcome;
@@ -2947,14 +3108,23 @@ function readTokenFromEnvFile(path) {
2947
3108
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
2948
3109
  return token && token.length > 0 ? token : undefined;
2949
3110
  }
2950
- var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
3111
+ 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, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
2951
3112
  var init_ticket_burner = __esm(() => {
2952
3113
  init_src();
2953
3114
  init_config_load();
2954
3115
  init_paths();
2955
3116
  init_skills_root();
3117
+ init_agent_stream();
2956
3118
  init_git();
2957
3119
  PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
3120
+ PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3121
+ PM_CACHE_SANDBOX_PATHS = {
3122
+ bun: "~/.bun/install/cache",
3123
+ pnpm: "~/.local/share/pnpm/store",
3124
+ yarn: "~/.cache/yarn",
3125
+ npm: "~/.npm"
3126
+ };
3127
+ SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
2958
3128
  ticketBurner = {
2959
3129
  id: "ticket-burner",
2960
3130
  run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
@@ -3003,15 +3173,16 @@ async function researchRun(ctx, deps) {
3003
3173
  }
3004
3174
  ctx.emitEvent({
3005
3175
  type: "research.failed",
3006
- message: `waypoint ${waypoint.seq} research failed: ${firstLine2(outcome.error)}`,
3176
+ message: `waypoint ${waypoint.seq} research failed: ${errorHeadline2(outcome.error)}`,
3007
3177
  data: { waypointId: waypoint.id, error: outcome.error }
3008
3178
  });
3009
3179
  return { status: "failed", summary: outcome.error };
3010
3180
  }
3011
- function firstLine2(s) {
3012
- const i = s.indexOf(`
3013
- `);
3014
- return (i === -1 ? s : s.slice(0, i)).trim();
3181
+ function errorHeadline2(s) {
3182
+ const lines = s.split(`
3183
+ `).map((l) => l.trim()).filter(Boolean);
3184
+ const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
3185
+ return causes.at(-1) ?? lines[0] ?? "";
3015
3186
  }
3016
3187
  function createResearchStreamThrottle(emit2, opts = {}) {
3017
3188
  return createStreamThrottle((e) => emit2({ ...e, type: e.type.replace(/^burn\./, "research.") }), opts);
@@ -3088,7 +3259,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
3088
3259
  const detail = merge.error ?? "merge failed";
3089
3260
  ctx.emitEvent({
3090
3261
  type: merge.conflict ? "merge.conflict.needs-human" : "research.merge_failed",
3091
- message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${firstLine2(detail)} \u2014 the branch is preserved for manual recovery`,
3262
+ message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${errorHeadline2(detail)} \u2014 the branch is preserved for manual recovery`,
3092
3263
  data: { tempBranch, featureBranch: feature.branch, conflict: merge.conflict ?? false, error: detail }
3093
3264
  });
3094
3265
  return {
@@ -4832,11 +5003,13 @@ var init_project = __esm(() => {
4832
5003
  import * as z8 from "zod";
4833
5004
  var runRouter;
4834
5005
  var init_run = __esm(() => {
5006
+ init_agent_stream();
4835
5007
  init_repo();
4836
5008
  init_runner();
4837
5009
  init_context();
4838
5010
  runRouter = router({
4839
5011
  get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
5012
+ agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
4840
5013
  cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
4841
5014
  cancelRun(input.runId);
4842
5015
  return { ok: true };
@@ -5072,6 +5245,22 @@ var init_settings = __esm(() => {
5072
5245
  valueSchema: z9.number().int().min(1).max(8),
5073
5246
  parseEnv: (raw) => Number(raw)
5074
5247
  },
5248
+ {
5249
+ key: "burnMaxIterations",
5250
+ configKey: "burnMaxIterations",
5251
+ envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
5252
+ restartRequired: false,
5253
+ valueSchema: z9.number().int().min(1).max(10),
5254
+ parseEnv: (raw) => Number(raw)
5255
+ },
5256
+ {
5257
+ key: "setupCommand",
5258
+ configKey: "setupCommand",
5259
+ envVar: "RUNCASTLE_SETUP_COMMAND",
5260
+ restartRequired: false,
5261
+ valueSchema: z9.string().min(1),
5262
+ parseEnv: idEnv
5263
+ },
5075
5264
  {
5076
5265
  key: "mainBranch",
5077
5266
  configKey: "mainBranch",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,6 +4,14 @@
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
+
7
15
  ## The ticket
8
16
 
9
17
  ```json
@@ -39,4 +47,4 @@ You are a single agent in a sandbox on branch `feature/<slug>`. You have **one t
39
47
 
40
48
  - **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
49
  - **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.
50
+ - **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.