runcastle 1.0.28 → 1.1.1

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/bin/runcastle.js CHANGED
@@ -151,7 +151,6 @@ var init_config = __esm(() => {
151
151
  burnMaxIterations: z.number().int().min(1).max(10).default(3),
152
152
  burnAttempts: z.number().int().min(1).max(5).default(3),
153
153
  burnConflictAttempts: z.number().int().min(0).max(3).default(2),
154
- autoPrepare: z.boolean().default(true),
155
154
  setupCommand: z.string().optional(),
156
155
  verifyCommands: z.string().optional(),
157
156
  knownFailures: z.string().optional(),
@@ -247,7 +246,7 @@ var init_ids = () => {};
247
246
 
248
247
  // ../core/src/schemas.ts
249
248
  import * as z2 from "zod";
250
- var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, PREPARED_KEYS, PreparedKey, FindingSource, Project, ProjectFinding, PrepStatus, PrepRun, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
249
+ var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, PREPARED_KEYS, PreparedKey, FindingSource, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
251
250
  var init_schemas = __esm(() => {
252
251
  Phase = z2.enum([
253
252
  "ideation",
@@ -344,16 +343,6 @@ var init_schemas = __esm(() => {
344
343
  establishedSha: z2.string().optional(),
345
344
  staleCommits: z2.number().optional()
346
345
  });
347
- PrepStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
348
- PrepRun = z2.object({
349
- id: z2.string(),
350
- projectId: z2.string(),
351
- status: PrepStatus,
352
- startedAt: z2.number(),
353
- endedAt: z2.number().optional(),
354
- summary: z2.string().optional(),
355
- headSha: z2.string().optional()
356
- });
357
346
  Feature = z2.object({
358
347
  id: z2.string(),
359
348
  projectId: z2.string(),
@@ -555,7 +544,7 @@ var init_pipeline = __esm(() => {
555
544
 
556
545
  // ../core/src/db-schema.ts
557
546
  import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
558
- var projects, projectFindings, projectPreps, features, sessions, tickets, waypoints, runs, events, gateOverrides;
547
+ var projects, projectFindings, features, sessions, tickets, waypoints, runs, events, gateOverrides;
559
548
  var init_db_schema = __esm(() => {
560
549
  projects = sqliteTable("projects", {
561
550
  id: text("id").primaryKey(),
@@ -582,15 +571,6 @@ var init_db_schema = __esm(() => {
582
571
  establishedAt: integer("established_at").notNull(),
583
572
  establishedSha: text("established_sha")
584
573
  }, (t) => [primaryKey({ columns: [t.projectId, t.key] })]);
585
- projectPreps = sqliteTable("project_preps", {
586
- id: text("id").primaryKey(),
587
- projectId: text("project_id").notNull(),
588
- status: text("status").notNull().$type(),
589
- startedAt: integer("started_at").notNull(),
590
- endedAt: integer("ended_at"),
591
- summary: text("summary"),
592
- headSha: text("head_sha")
593
- });
594
574
  features = sqliteTable("features", {
595
575
  id: text("id").primaryKey(),
596
576
  projectId: text("project_id").notNull(),
@@ -699,8 +679,7 @@ var init_schema = __esm(() => {
699
679
  runs,
700
680
  events,
701
681
  gateOverrides,
702
- projectFindings,
703
- projectPreps
682
+ projectFindings
704
683
  };
705
684
  });
706
685
 
@@ -2245,21 +2224,6 @@ async function reattachWorktree(path, branch) {
2245
2224
  await git(path).checkout(branch);
2246
2225
  } catch {}
2247
2226
  }
2248
- function prepBranchName(unique) {
2249
- return `${PREP_BRANCH_PREFIX}${unique}`;
2250
- }
2251
- async function readFileAtRef(repoPath, ref, path) {
2252
- try {
2253
- return await git(repoPath).raw(["show", `${ref}:${path}`]);
2254
- } catch {
2255
- return;
2256
- }
2257
- }
2258
- async function deletePrepBranch(repoPath, branch) {
2259
- try {
2260
- await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
2261
- } catch {}
2262
- }
2263
2227
  function tempBranchSlugSegment(slug) {
2264
2228
  return slug.slice(0, TEMP_BRANCH_SLUG_MAX).replace(/-+$/, "");
2265
2229
  }
@@ -2306,14 +2270,6 @@ async function branchCommitsAhead(repoPath, base, branch) {
2306
2270
  return [];
2307
2271
  }
2308
2272
  }
2309
- async function headSha(repoPath, ref) {
2310
- try {
2311
- const out = (await git(repoPath).revparse([ref])).trim();
2312
- return out.length > 0 ? out : undefined;
2313
- } catch {
2314
- return;
2315
- }
2316
- }
2317
2273
  async function commitsSince(repoPath, sha, branch) {
2318
2274
  try {
2319
2275
  const out = (await git(repoPath).raw(["rev-list", "--count", `${sha}..${branch}`])).trim();
@@ -2816,7 +2772,7 @@ async function mergeInProgress(g) {
2816
2772
  return false;
2817
2773
  }
2818
2774
  }
2819
- var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, 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/", PREP_BRANCH_PREFIX = "runcastle/prep/", PROJECT_BRANCH = "runcastle/project", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
2775
+ var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, 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/", PROJECT_BRANCH = "runcastle/project", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
2820
2776
  var init_git = __esm(() => {
2821
2777
  init_paths();
2822
2778
  init_errors();
@@ -3090,6 +3046,9 @@ function activeProjectSession(ctx, projectId, kind) {
3090
3046
  const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), inArray(sessions.status, ["launching", "live"]))).orderBy(desc3(sql2`rowid`)).limit(1).get();
3091
3047
  return row ? rowToSession(row) : null;
3092
3048
  }
3049
+ function hasCompletedProjectSession(ctx, projectId, kind) {
3050
+ return ctx.db.select({ id: sessions.id }).from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), eq4(sessions.status, "ended"))).limit(1).get() !== undefined;
3051
+ }
3093
3052
  function mostRecentResumableProjectSession(ctx, projectId, kind) {
3094
3053
  const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId))).orderBy(desc3(sql2`rowid`)).limit(1).get();
3095
3054
  return row ? rowToSession(row) : null;
@@ -3156,655 +3115,1083 @@ var init_reconcile = __esm(() => {
3156
3115
  init_sessions();
3157
3116
  });
3158
3117
 
3159
- // src/launcher/skills-root.ts
3118
+ // src/launcher/runtime.ts
3119
+ function setRuntimeCtx(ctx) {
3120
+ current = ctx;
3121
+ }
3122
+ async function getRuntimeCtx() {
3123
+ if (current)
3124
+ return current;
3125
+ const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
3126
+ Promise.resolve().then(() => (init_client(), exports_client)),
3127
+ Promise.resolve().then(() => (init_paths(), exports_paths)),
3128
+ Promise.resolve().then(() => (init_config_load(), exports_config_load))
3129
+ ]);
3130
+ const db = createDb2(dbPath2());
3131
+ current = { db, config: loadConfig2() };
3132
+ return current;
3133
+ }
3134
+ var current = null;
3135
+
3136
+ // src/services/feature-docs.ts
3160
3137
  import { existsSync as existsSync6 } from "fs";
3161
- import { dirname as dirname2, join as join6, resolve as resolve5 } from "path";
3162
- function resolveSkillsRoot(fromDir) {
3163
- const override = process.env[SKILLS_DIR_ENV];
3164
- if (override) {
3165
- const root = resolve5(override);
3166
- if (existsSync6(join6(root, SKILLS_MARKER)))
3167
- return root;
3168
- throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
3169
- }
3170
- const searched = [];
3171
- let dir = fromDir;
3172
- for (let i = 0;i < 8; i += 1) {
3173
- const candidate = join6(dir, "packages", "skills");
3174
- searched.push(candidate);
3175
- if (existsSync6(join6(candidate, SKILLS_MARKER)))
3176
- return candidate;
3177
- const parent = dirname2(dir);
3178
- if (parent === dir)
3179
- break;
3180
- dir = parent;
3181
- }
3182
- throw new Error(`runcastle skills root not found; searched:
3183
- ${searched.join(`
3184
- `)}`);
3138
+ import { join as join6 } from "path";
3139
+ function featureDocsDir(project, feature) {
3140
+ const relSegments = featureDocsRel(feature.slug).split("/");
3141
+ const worktree = worktreeDir(project.id, feature.slug);
3142
+ const base = existsSync6(worktree) ? worktree : project.repoPath;
3143
+ return join6(base, ...relSegments);
3185
3144
  }
3186
- var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
3187
- var init_skills_root = __esm(() => {
3188
- SKILLS_MARKER = join6("packs", "runcastle");
3145
+ function featureDocPath(project, feature, fileName) {
3146
+ return join6(featureDocsDir(project, feature), fileName);
3147
+ }
3148
+ var init_feature_docs = __esm(() => {
3149
+ init_paths();
3189
3150
  });
3190
3151
 
3191
- // src/services/agent-stream.ts
3192
- function beginTranscript(ticketId) {
3193
- transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
3194
- publishLive({ kind: "transcript", ticketId });
3152
+ // src/services/tickets.ts
3153
+ import { and as and5, asc as asc3, eq as eq5 } from "drizzle-orm";
3154
+ function rowToTicket(row) {
3155
+ return {
3156
+ id: row.id,
3157
+ featureId: row.featureId,
3158
+ seq: row.seq,
3159
+ title: row.title,
3160
+ goal: row.goal,
3161
+ context: row.context,
3162
+ acceptanceCriteria: row.acceptanceCriteria,
3163
+ seams: row.seams,
3164
+ blockedBy: row.blockedBy,
3165
+ lap: row.lap,
3166
+ status: row.status,
3167
+ commits: row.commits,
3168
+ error: row.error ?? undefined,
3169
+ attemptBranch: row.attemptBranch ?? undefined,
3170
+ conflictFiles: row.conflictFiles ?? undefined
3171
+ };
3195
3172
  }
3196
- function appendTranscript(ticketId, chunk) {
3197
- const t = transcripts.get(ticketId);
3198
- if (!t)
3199
- return;
3200
- const full = { ...chunk, i: t.nextIndex, ts: chunk.ts ?? Date.now() };
3201
- t.nextIndex += 1;
3202
- t.chunks.push(full);
3203
- t.bytes += full.text.length;
3204
- while (t.chunks.length > MAX_CHUNKS || t.bytes > MAX_BYTES && t.chunks.length > 1) {
3205
- const dropped = t.chunks.shift();
3206
- if (!dropped)
3207
- break;
3208
- t.bytes -= dropped.text.length;
3173
+ function resolveBlocking(inputs, startSeq) {
3174
+ try {
3175
+ return resolveBatchBlocking(inputs, { startSeq });
3176
+ } catch (e) {
3177
+ if (e instanceof BlockingEdgeError)
3178
+ throw new InvalidInputError(e.message);
3179
+ throw e;
3209
3180
  }
3210
- publishLive({ kind: "transcript", ticketId });
3211
3181
  }
3212
- function endTranscript(ticketId) {
3213
- const t = transcripts.get(ticketId);
3214
- if (t)
3215
- t.live = false;
3216
- publishLive({ kind: "transcript", ticketId });
3182
+ function listByFeature2(ctx, featureId) {
3183
+ return ctx.db.select().from(tickets).where(eq5(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
3217
3184
  }
3218
- function readTranscript(ticketId, after = -1) {
3219
- const t = transcripts.get(ticketId);
3220
- if (!t)
3221
- return { live: false, chunks: [], firstIndex: 0, nextIndex: 0 };
3222
- return {
3223
- live: t.live,
3224
- chunks: after < 0 ? [...t.chunks] : t.chunks.filter((c) => c.i > after),
3225
- firstIndex: t.chunks[0]?.i ?? t.nextIndex,
3226
- nextIndex: t.nextIndex
3227
- };
3185
+ function storeTickets(ctx, featureId, inputs) {
3186
+ if (inputs.length === 0)
3187
+ return [];
3188
+ const { lap } = getFeatureRow(ctx, featureId);
3189
+ const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq5(tickets.featureId, featureId)).all();
3190
+ const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
3191
+ const resolved = resolveBlocking(inputs, startSeq);
3192
+ const rows = inputs.map((t, i) => ({
3193
+ id: newId("tkt"),
3194
+ featureId,
3195
+ seq: resolved[i].seq,
3196
+ title: t.title,
3197
+ goal: t.goal,
3198
+ context: t.context,
3199
+ acceptanceCriteria: t.acceptanceCriteria,
3200
+ seams: t.seams,
3201
+ blockedBy: resolved[i].blockedBy,
3202
+ lap,
3203
+ status: "pending",
3204
+ commits: [],
3205
+ error: null,
3206
+ attemptBranch: null,
3207
+ conflictFiles: null
3208
+ }));
3209
+ ctx.db.insert(tickets).values(rows).run();
3210
+ emit(ctx, featureId, {
3211
+ type: "tickets.stored",
3212
+ message: `${rows.length} ticket(s) stored`,
3213
+ data: { count: rows.length, seqs: rows.map((r) => r.seq) }
3214
+ });
3215
+ return rows.map(rowToTicket);
3228
3216
  }
3229
- var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
3230
- var init_agent_stream = __esm(() => {
3231
- init_bus();
3232
- transcripts = new Map;
3233
- });
3234
-
3235
- // src/workflows/burn-guard.ts
3236
- function renderGuardScript(rules = GUARD_RULES) {
3237
- const lines = [
3238
- "#!/bin/sh",
3239
- "# runcastle burn guard (PreToolUse). Generated \u2014 edit burn-guard.ts, not this.",
3240
- "set -u",
3241
- "payload=$(cat 2>/dev/null || true)",
3242
- '[ -n "$payload" ] || exit 0',
3243
- "command -v jq >/dev/null 2>&1 || exit 0",
3244
- 'tool=$(printf %s "$payload" | jq -r ".tool_name // empty" 2>/dev/null || true)',
3245
- '[ "$tool" = "Bash" ] || exit 0',
3246
- 'cmd=$(printf %s "$payload" | jq -r ".tool_input.command // empty" 2>/dev/null || true)',
3247
- '[ -n "$cmd" ] || exit 0',
3248
- "",
3249
- "# Match against the command with quoted spans blanked, so an argument is",
3250
- '# never read as a command: `grep -rn "git stash" docs/` searches for a',
3251
- "# string, it does not run one.",
3252
- `cmd=$(printf %s "$cmd" | sed "s/'[^']*'/ /g; s/\\"[^\\"]*\\"/ /g")`,
3253
- "",
3254
- "deny() {",
3255
- ` printf %s "$1" | jq -R -s '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:.}}'`,
3256
- " exit 0",
3257
- "}",
3258
- ""
3259
- ];
3260
- for (const rule of rules) {
3261
- lines.push(`# ${rule.id}`, `if printf %s "$cmd" | grep -Eq -- ${shSingleQuote(rule.pattern)}; then`, ` deny ${shSingleQuote(rule.reason)}`, "fi", "");
3217
+ function getTicket(ctx, id) {
3218
+ const row = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
3219
+ if (!row)
3220
+ throw new NotFoundError(`ticket ${id} not found`);
3221
+ return rowToTicket(row);
3222
+ }
3223
+ function assertMutable(t, verb) {
3224
+ if (!MUTABLE_STATUSES.has(t.status)) {
3225
+ throw new InvalidInputError(`cannot ${verb} ticket ${t.seq} \u2014 it is ${t.status}; only pending or failed tickets can be ${verb}${verb.endsWith("l") ? "led" : "ed"}`);
3262
3226
  }
3263
- lines.push("exit 0", "");
3264
- return lines.join(`
3265
- `);
3266
3227
  }
3267
- function shSingleQuote(value) {
3268
- return `'${value.split("'").join(`'\\''`)}'`;
3228
+ function editTicket(ctx, id, patch) {
3229
+ const current2 = getTicket(ctx, id);
3230
+ assertMutable(current2, "edit");
3231
+ const set = {};
3232
+ if (patch.title !== undefined)
3233
+ set.title = patch.title;
3234
+ if (patch.goal !== undefined)
3235
+ set.goal = patch.goal;
3236
+ if (patch.context !== undefined)
3237
+ set.context = patch.context;
3238
+ if (patch.acceptanceCriteria !== undefined)
3239
+ set.acceptanceCriteria = patch.acceptanceCriteria;
3240
+ if (patch.seams !== undefined)
3241
+ set.seams = patch.seams;
3242
+ if (Object.keys(set).length === 0) {
3243
+ throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
3244
+ }
3245
+ ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
3246
+ emit(ctx, current2.featureId, {
3247
+ type: "ticket.edited",
3248
+ message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
3249
+ ticketId: id,
3250
+ data: { fields: Object.keys(set) }
3251
+ });
3252
+ return getTicket(ctx, id);
3269
3253
  }
3270
- function renderGuardSettings() {
3271
- return {
3272
- hooks: {
3273
- PreToolUse: [
3274
- {
3275
- matcher: "Bash",
3276
- hooks: [{ type: "command", command: `sh ${GUARD_SCRIPT_PATH}`, timeout: 5 }]
3277
- }
3278
- ]
3279
- }
3280
- };
3254
+ function cancelTicket(ctx, id, reason) {
3255
+ const current2 = getTicket(ctx, id);
3256
+ assertMutable(current2, "cancel");
3257
+ ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq5(tickets.id, id)).run();
3258
+ emit(ctx, current2.featureId, {
3259
+ type: "ticket.cancelled",
3260
+ message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
3261
+ ticketId: id,
3262
+ data: { reason: reason ?? null }
3263
+ });
3264
+ return getTicket(ctx, id);
3281
3265
  }
3282
- function buildGuardInstallCommand() {
3283
- const script = Buffer.from(renderGuardScript(), "utf8").toString("base64");
3284
- const settings = Buffer.from(JSON.stringify(renderGuardSettings(), null, 2), "utf8").toString("base64");
3285
- return [
3286
- `mkdir -p "$HOME/.claude/hooks"`,
3287
- `printf %s '${script}' | base64 -d > "${GUARD_SCRIPT_PATH}"`,
3288
- `chmod +x "${GUARD_SCRIPT_PATH}"`,
3289
- `printf %s '${settings}' | base64 -d > "${GUARD_SETTINGS_PATH}"`
3290
- ].join(" && ");
3266
+ function sweepOrphanedBurning(ctx, featureId, reason) {
3267
+ const orphaned = ctx.db.select().from(tickets).where(and5(eq5(tickets.featureId, featureId), eq5(tickets.status, "burning"))).all().map(rowToTicket);
3268
+ for (const t of orphaned) {
3269
+ updateTicket(ctx, t.id, { status: "failed", error: reason });
3270
+ emit(ctx, featureId, {
3271
+ type: "ticket.failed",
3272
+ message: `ticket ${t.seq} failed: ${reason}`,
3273
+ ticketId: t.id,
3274
+ data: { error: reason, orphaned: true }
3275
+ });
3276
+ }
3277
+ return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
3291
3278
  }
3292
- var CMD_START = "(^|[;&|(]|&&)[[:space:]]*", GUARD_RULES, GUARD_SCRIPT_PATH = "$HOME/.claude/hooks/burn-guard.sh", GUARD_SETTINGS_PATH = "$HOME/.claude/settings.json";
3293
- var init_burn_guard = __esm(() => {
3294
- GUARD_RULES = [
3295
- {
3296
- id: "no-stash",
3297
- pattern: `${CMD_START}git[[:space:]]+stash`,
3298
- reason: "git stash is blocked in burns: stashed work is invisible to the orchestrator and unrecoverable if this process dies, and only commits survive. To compare against the pre-change state use `git show HEAD:<path>`, or the pre-existing-failure baseline in your prompt."
3299
- },
3300
- {
3301
- id: "no-serialised-tests",
3302
- pattern: "--maxWorkers|--runInBand|--pool=|--shard=|singleFork|--poolOptions",
3303
- reason: `Do not override the test runner's concurrency. Measured in this sandbox, a suite that runs in ~55s at its configured concurrency takes 10-20 minutes serialised, so this "safe" flag is the single most expensive habit in a burn. Run the repo's test command as configured. If it is killed for memory, run only the test files your change touches and say so in your final message.`
3304
- },
3305
- {
3306
- id: "no-interpreter-heredoc-edit",
3307
- pattern: `${CMD_START}(python3?|node|bun|perl|ruby)[[:space:]]+-[[:space:]]*<<`,
3308
- reason: "Do not rewrite files by piping a heredoc into an interpreter \u2014 individual such calls have been measured at 29s, 57s, 120s and 761s, and a half-written file survives if this process dies mid-command. Use the Edit tool, which is faster, atomic, and fails loudly when its target text is not found."
3309
- },
3310
- {
3311
- id: "no-cat-heredoc-edit",
3312
- pattern: `${CMD_START}cat[[:space:]]+>>?[[:space:]]*[^[:space:]|]+[[:space:]]*<<`,
3313
- reason: "Do not write files with `cat > file <<EOF`. Use the Write tool for a new file, or Edit to change part of an existing one."
3314
- }
3315
- ];
3279
+ function updateTicket(ctx, id, patch) {
3280
+ const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
3281
+ if (!current2)
3282
+ throw new NotFoundError(`ticket ${id} not found`);
3283
+ const set = {};
3284
+ if (patch.status !== undefined)
3285
+ set.status = patch.status;
3286
+ if (patch.commits !== undefined)
3287
+ set.commits = patch.commits;
3288
+ if (patch.error !== undefined)
3289
+ set.error = patch.error;
3290
+ if (patch.attemptBranch !== undefined)
3291
+ set.attemptBranch = patch.attemptBranch;
3292
+ if (patch.conflictFiles !== undefined)
3293
+ set.conflictFiles = patch.conflictFiles;
3294
+ ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
3295
+ emit(ctx, current2.featureId, {
3296
+ type: "ticket.updated",
3297
+ message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
3298
+ ticketId: id,
3299
+ data: patch
3300
+ });
3301
+ const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
3302
+ return rowToTicket(updated);
3303
+ }
3304
+ var MUTABLE_STATUSES;
3305
+ var init_tickets = __esm(() => {
3306
+ init_src();
3307
+ init_schema();
3308
+ init_errors();
3309
+ init_events();
3310
+ init_repo();
3311
+ MUTABLE_STATUSES = new Set(["pending", "failed"]);
3316
3312
  });
3317
3313
 
3318
- // src/workflows/ticket-burner.ts
3319
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
3320
- import { dirname as dirname3, join as join7 } from "path";
3321
- import { fileURLToPath as fileURLToPath2 } from "url";
3322
- import { claudeCode, run } from "@ai-hero/sandcastle";
3323
- import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
3324
- import { podman } from "@ai-hero/sandcastle/sandboxes/podman";
3325
- import { noSandbox } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
3326
- function indexBySeq(tickets2) {
3327
- return new Map(tickets2.map((t) => [t.seq, t]));
3328
- }
3329
- function detectCycle(tickets2) {
3330
- const bySeq = indexBySeq(tickets2);
3331
- const WHITE = 0;
3332
- const GRAY = 1;
3333
- const BLACK = 2;
3334
- const color = new Map;
3335
- const stack = [];
3336
- const dfs = (seq) => {
3337
- color.set(seq, GRAY);
3338
- stack.push(seq);
3339
- const t = bySeq.get(seq);
3340
- if (t) {
3341
- for (const b of t.blockedBy) {
3342
- if (!bySeq.has(b))
3343
- continue;
3344
- const c = color.get(b) ?? WHITE;
3345
- if (c === GRAY) {
3346
- const idx = stack.indexOf(b);
3347
- return [...stack.slice(idx), b];
3348
- }
3349
- if (c === WHITE) {
3350
- const found = dfs(b);
3351
- if (found)
3352
- return found;
3353
- }
3314
+ // src/services/gates.ts
3315
+ import { existsSync as existsSync7 } from "fs";
3316
+ function checkGate(ctx, check, feature) {
3317
+ switch (check) {
3318
+ case "decisions-file-exists":
3319
+ return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
3320
+ case "all-waypoints-terminal": {
3321
+ const wps = listByFeature(ctx, feature.id);
3322
+ if (wps.length === 0) {
3323
+ return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
3354
3324
  }
3325
+ const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
3326
+ return open.length === 0 ? { satisfied: true } : {
3327
+ satisfied: false,
3328
+ reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
3329
+ };
3355
3330
  }
3356
- color.set(seq, BLACK);
3357
- stack.pop();
3358
- return null;
3359
- };
3360
- for (const t of tickets2) {
3361
- if ((color.get(t.seq) ?? WHITE) === WHITE) {
3362
- const found = dfs(t.seq);
3363
- if (found)
3364
- return found;
3331
+ case "spec-file-exists":
3332
+ return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
3333
+ case "tickets-approved": {
3334
+ const count = listByFeature2(ctx, feature.id).filter((t) => t.lap === feature.lap && t.status !== "cancelled").length;
3335
+ return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
3365
3336
  }
3337
+ case "all-tickets-terminal": {
3338
+ const tickets2 = listByFeature2(ctx, feature.id);
3339
+ if (tickets2.length === 0)
3340
+ return { satisfied: false, reason: "no tickets have run yet" };
3341
+ const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
3342
+ return open.length === 0 ? { satisfied: true } : {
3343
+ satisfied: false,
3344
+ reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
3345
+ };
3346
+ }
3347
+ case "human-merge":
3348
+ return { satisfied: false, reason: "use the Merge button to ship" };
3366
3349
  }
3367
- return null;
3368
- }
3369
- function renderTemplate2(template, values) {
3370
- let out = template;
3371
- for (const [key, value] of Object.entries(values)) {
3372
- out = out.split(`{{${key}}}`).join(value);
3373
- }
3374
- return out;
3375
3350
  }
3376
- function renderTicketPrompt(template, values) {
3377
- return renderTemplate2(template, values);
3351
+ function notYetTerminal(noun, statuses, order) {
3352
+ const counts = new Map;
3353
+ for (const s of statuses)
3354
+ counts.set(s, (counts.get(s) ?? 0) + 1);
3355
+ const ordered = [
3356
+ ...order.filter((s) => counts.has(s)),
3357
+ ...[...counts.keys()].filter((s) => !order.includes(s))
3358
+ ];
3359
+ const breakdown = ordered.map((s) => `${counts.get(s)} ${s}`).join(", ");
3360
+ return `${statuses.length} ${statuses.length === 1 ? noun : `${noun}s`} not yet terminal (${breakdown})`;
3378
3361
  }
3379
- function buildTicketJson(ticket) {
3380
- return JSON.stringify({
3381
- seq: ticket.seq,
3382
- title: ticket.title,
3383
- goal: ticket.goal,
3384
- context: ticket.context,
3385
- acceptanceCriteria: ticket.acceptanceCriteria,
3386
- seams: ticket.seams,
3387
- blockedBy: ticket.blockedBy
3388
- }, null, 2);
3362
+ function fileGate(ctx, feature, fileName, reason) {
3363
+ const project = projectForFeature(ctx, feature);
3364
+ return existsSync7(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
3389
3365
  }
3390
- function buildFeatureBrief(feature) {
3391
- return [
3392
- `**${feature.title}** (\`${feature.slug}\`)`,
3393
- "",
3394
- feature.oneLiner,
3395
- "",
3396
- `Working branch: \`${feature.branch}\``
3397
- ].join(`
3398
- `);
3366
+ function overrideGate(ctx, featureId, gate, reason) {
3367
+ const feature = getFeatureRow(ctx, featureId);
3368
+ ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
3369
+ emit(ctx, featureId, {
3370
+ type: "gate.overridden",
3371
+ message: `gate ${gate} overridden: ${reason}`,
3372
+ data: { gate, reason }
3373
+ });
3374
+ const next = nextPhase(feature);
3375
+ if (next)
3376
+ return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
3377
+ return feature;
3399
3378
  }
3400
- function buildDocsDigest(files) {
3401
- if (files.length === 0) {
3402
- return "_No feature docs found \u2014 work from the ticket context and the code._";
3403
- }
3404
- return files.map((f) => `### ${f.name}
3405
-
3406
- ${f.content.trim()}`).join(`
3407
-
3408
- ---
3379
+ var init_gates = __esm(() => {
3380
+ init_src();
3381
+ init_schema();
3382
+ init_events();
3383
+ init_feature_docs();
3384
+ init_repo();
3385
+ init_tickets();
3386
+ init_waypoints();
3387
+ });
3409
3388
 
3389
+ // src/services/knowledge.ts
3390
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "fs";
3391
+ import { join as join7, relative, resolve as resolve5, sep } from "path";
3392
+ function scaffoldDocs(ctx, feature, opts = {}) {
3393
+ const project = projectForFeature(ctx, feature);
3394
+ const dir = featureDocsDir(project, feature);
3395
+ mkdirSync3(dir, { recursive: true });
3396
+ const briefPath = featureDocPath(project, feature, "brief.md");
3397
+ if (!existsSync8(briefPath)) {
3398
+ const override = opts.brief?.trim();
3399
+ const created = new Date().toISOString();
3400
+ const brief = override ? `${override}
3401
+ ` : [
3402
+ `# ${feature.title}`,
3403
+ "",
3404
+ feature.oneLiner,
3405
+ "",
3406
+ `- Slug: ${feature.slug}`,
3407
+ `- Created: ${created}`,
3408
+ ""
3409
+ ].join(`
3410
3410
  `);
3411
- }
3412
- function buildConflictFilesBlock(files) {
3413
- if (files.length === 0) {
3414
- return "_git did not report the paths \u2014 run `git status` after starting the merge to see them._";
3411
+ writeFileSync(briefPath, brief, "utf8");
3415
3412
  }
3416
- return files.map((f) => `- \`${f}\``).join(`
3417
- `);
3418
- }
3419
- function buildOtherSideBlock(summaries) {
3420
- if (summaries.length === 0) {
3421
- return "_No commit summaries available \u2014 inspect `git log` on the feature branch._";
3413
+ if (feature.mapped) {
3414
+ scaffoldMapDoc(project, feature);
3422
3415
  }
3423
- return summaries.map((s) => `- ${s}`).join(`
3424
- `);
3416
+ emit(ctx, feature.id, {
3417
+ type: "docs.scaffolded",
3418
+ message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
3419
+ });
3425
3420
  }
3426
- function resolveMergeCommand(mode, featureBranch2) {
3427
- return mode === "mounted" ? `git merge --no-edit ${featureBranch2}` : `git fetch origin ${featureBranch2} && git merge --no-edit FETCH_HEAD`;
3428
- }
3429
- function parseEnvFile(content) {
3430
- const out = {};
3431
- for (const rawLine of content.split(/\r?\n/)) {
3432
- const line = rawLine.trim();
3433
- if (line.length === 0 || line.startsWith("#"))
3434
- continue;
3435
- const body = line.startsWith("export ") ? line.slice(7).trim() : line;
3436
- const eq5 = body.indexOf("=");
3437
- if (eq5 === -1)
3438
- continue;
3439
- const key = body.slice(0, eq5).trim();
3440
- if (key.length === 0)
3441
- continue;
3442
- let value = body.slice(eq5 + 1).trim();
3443
- if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
3444
- value = value.slice(1, -1);
3445
- }
3446
- out[key] = value;
3447
- }
3448
- return out;
3421
+ function scaffoldMapDoc(project, feature, seed) {
3422
+ mkdirSync3(featureDocsDir(project, feature), { recursive: true });
3423
+ const mapPath = featureDocPath(project, feature, "map.md");
3424
+ if (existsSync8(mapPath))
3425
+ return;
3426
+ writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
3449
3427
  }
3450
- function detectPackageManager(tc) {
3451
- const pinned = tc.packageManagerField?.split("@")[0];
3452
- if (pinned && PACKAGE_MANAGERS.includes(pinned)) {
3453
- return pinned;
3428
+ function mapDocBody(feature, seed) {
3429
+ const seeded = {
3430
+ Destination: seed?.destination?.trim(),
3431
+ Notes: seed?.notes?.trim()
3432
+ };
3433
+ const lines = [`# ${feature.title} \u2014 map`, ""];
3434
+ for (const section of MAP_SECTIONS) {
3435
+ lines.push(`## ${section}`, "");
3436
+ const body = seeded[section];
3437
+ if (body)
3438
+ lines.push(body, "");
3454
3439
  }
3455
- if (tc.lockfiles.bun)
3456
- return "bun";
3457
- if (tc.lockfiles.pnpm)
3458
- return "pnpm";
3459
- if (tc.lockfiles.yarn)
3460
- return "yarn";
3461
- if (tc.lockfiles.npm)
3462
- return "npm";
3463
- return tc.hasPackageJson ? "npm" : undefined;
3464
- }
3465
- function orFallback(strict, permissive) {
3466
- return `( ${strict} || ${permissive} )`;
3440
+ return lines.join(`
3441
+ `);
3467
3442
  }
3468
- function resolveSetupCommand(tc, override) {
3469
- const trimmed = override?.trim();
3470
- if (trimmed)
3471
- return trimmed;
3472
- const pm = detectPackageManager(tc);
3473
- if (!pm)
3443
+ function readCharter(repoRoot) {
3444
+ const path = join7(repoRoot, CHARTER_FILE);
3445
+ if (!existsSync8(path))
3446
+ return;
3447
+ try {
3448
+ return readFileSync3(path, "utf8");
3449
+ } catch {
3474
3450
  return;
3475
- switch (pm) {
3476
- case "bun":
3477
- return tc.lockfiles.bun ? orFallback("bun install --frozen-lockfile", "bun install") : "bun install";
3478
- case "pnpm":
3479
- return tc.lockfiles.pnpm ? orFallback("corepack pnpm install --frozen-lockfile", "corepack pnpm install") : "corepack pnpm install";
3480
- case "yarn":
3481
- return tc.lockfiles.yarn ? orFallback("corepack yarn install --frozen-lockfile", "corepack yarn install") : "corepack yarn install";
3482
- case "npm":
3483
- return tc.lockfiles.npm ? orFallback("npm ci", "npm install") : "npm install";
3484
3451
  }
3485
3452
  }
3486
- function cacheMountFor(pm, hostPath) {
3487
- const sandboxPath = PM_CACHE_SANDBOX_PATHS[pm];
3488
- return sandboxPath ? { hostPath, sandboxPath } : undefined;
3453
+ function listLiveAdrs(repoRoot) {
3454
+ const dir = join7(repoRoot, ADR_DIR_REL);
3455
+ if (!existsSync8(dir))
3456
+ return [];
3457
+ const docs = [];
3458
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
3459
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
3460
+ continue;
3461
+ let content;
3462
+ try {
3463
+ content = readFileSync3(join7(dir, entry.name), "utf8");
3464
+ } catch {
3465
+ continue;
3466
+ }
3467
+ if (SUPERSEDED_RE.test(content))
3468
+ continue;
3469
+ docs.push({ relPath: `${ADR_DIR_REL}/${entry.name}`, content });
3470
+ }
3471
+ return docs.sort((a, b) => a.relPath.localeCompare(b.relPath));
3489
3472
  }
3490
- function resolveBurnWorkspaceMode(config2, platform = process.platform) {
3491
- if (config2.sandbox === "noSandbox")
3492
- return "mounted";
3493
- if (config2.burnWorkspace === "auto")
3494
- return platform === "linux" ? "mounted" : "isolated";
3495
- return config2.burnWorkspace;
3473
+ function listDocs(ctx, feature) {
3474
+ const project = projectForFeature(ctx, feature);
3475
+ const dir = featureDocsDir(project, feature);
3476
+ if (!existsSync8(dir))
3477
+ return [];
3478
+ return readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => ({ relPath: e.name, title: readTitle(join7(dir, e.name), e.name) })).sort((a, b) => a.relPath.localeCompare(b.relPath));
3496
3479
  }
3497
- function buildIsolatedSetupCommand(tempBranch, setupCommand, pm) {
3498
- const hookFile = `${ISOLATED_REPO_PATH}/.git/hooks/post-commit`;
3499
- const parts = [
3500
- `git config --global --add safe.directory '*'`,
3501
- `git clone ${SANDBOX_WORKSPACE_PATH} ${ISOLATED_REPO_PATH}`,
3502
- `printf '#!/bin/sh\\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE\\ngit push --quiet origin HEAD:%s && exec git -C ${SANDBOX_WORKSPACE_PATH} reset --hard --quiet %s\\n' '${tempBranch}' '${tempBranch}' > ${hookFile}`,
3503
- `chmod +x ${hookFile}`
3504
- ];
3505
- if (pm === "pnpm" || pm === "yarn") {
3506
- const shim = `$HOME/.local/bin/${pm}`;
3507
- parts.push(`mkdir -p "$HOME/.local/bin"`, `printf '#!/bin/sh\\nexec corepack ${pm} "$@"\\n' > "${shim}"`, `chmod +x "${shim}"`);
3480
+ function readDoc(ctx, feature, relPath) {
3481
+ const project = projectForFeature(ctx, feature);
3482
+ const dir = featureDocsDir(project, feature);
3483
+ const root = resolve5(dir);
3484
+ const target = resolve5(dir, relPath);
3485
+ const rel = relative(root, target);
3486
+ if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
3487
+ throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
3508
3488
  }
3509
- if (setupCommand)
3510
- parts.push(`cd ${ISOLATED_REPO_PATH} && ${setupCommand}`);
3511
- parts.push(`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks`);
3512
- return parts.join(" && ");
3489
+ if (!existsSync8(target))
3490
+ throw new NotFoundError(`doc not found: ${relPath}`);
3491
+ return { content: readFileSync3(target, "utf8") };
3513
3492
  }
3514
- function buildWorkspaceNotes(mode) {
3515
- if (mode === "mounted") {
3516
- return "Work in the current directory \u2014 it is the repo checkout on your branch.";
3493
+ function readTitle(path, fallback) {
3494
+ try {
3495
+ const firstLine = readFileSync3(path, "utf8").split(`
3496
+ `, 1)[0] ?? "";
3497
+ const heading = firstLine.match(/^#\s+(.+)$/);
3498
+ return heading ? heading[1].trim() : fallback;
3499
+ } catch {
3500
+ return fallback;
3517
3501
  }
3518
- return [
3519
- `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.`,
3520
- "",
3521
- `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. If you re-run the dependency install and it reconfigures git hooks (husky), run \`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks\` afterwards so the sync hook stays armed.`,
3522
- "",
3523
- `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.`
3524
- ].join(`
3525
- `);
3526
3502
  }
3527
- function buildVerifyNotes(config2) {
3528
- const commands = config2.verifyCommands?.trim();
3529
- const failures = config2.knownFailures?.trim();
3530
- const out = [];
3531
- if (commands) {
3532
- out.push("Verify with exactly these commands \u2014 they are correct for this repo, so do not go looking for alternatives or guess at workspace/package filter names:", "", "```", commands, "```");
3503
+ var MAP_SECTIONS, CHARTER_FILE = "CONTEXT.md", ADR_DIR_REL = "docs/adr", SUPERSEDED_RE;
3504
+ var init_knowledge = __esm(() => {
3505
+ init_errors();
3506
+ init_events();
3507
+ init_feature_docs();
3508
+ init_repo();
3509
+ MAP_SECTIONS = [
3510
+ "Destination",
3511
+ "Notes",
3512
+ "Not yet specified",
3513
+ "Out of scope"
3514
+ ];
3515
+ SUPERSEDED_RE = /superseded by ADR-\d+/i;
3516
+ });
3517
+
3518
+ // src/pty/end-session.ts
3519
+ function endSession(ctx, sessionId) {
3520
+ const registry = ptyRegistry();
3521
+ const killed = registry.kill(sessionId);
3522
+ registry.remove(sessionId);
3523
+ const session = markSessionEnded(ctx, sessionId);
3524
+ if (session) {
3525
+ releaseForSession(ctx, sessionId);
3526
+ landProjectSession(ctx, session);
3527
+ emitForSession(ctx, session, {
3528
+ type: "session.ended",
3529
+ message: "session ended by user",
3530
+ data: { sessionId, killed }
3531
+ });
3533
3532
  } else {
3534
- out.push("No verification commands are configured, so derive them ONCE from the repo (root `package.json` scripts, the workspace's own manifest, CI config) before your first test run \u2014 reading a manifest is free, discovering a filter name by running the wrong suite is not. Write the working commands into your notes and reuse them verbatim for the rest of the ticket.");
3533
+ const existing = getSessionRow(ctx, sessionId);
3534
+ if (!existing)
3535
+ return { sessionId, ok: false, killed };
3535
3536
  }
3536
- out.push("");
3537
- if (failures) {
3538
- out.push("These tests ALREADY fail on this repo before you touch anything:", "", "```", failures, "```", "", "Treat that as your baseline \u2014 do NOT spend a run establishing it yourself. Only a failure outside this set is yours to fix; if you hit one that looks pre-existing but is not listed, confirm it on a single targeted run of that one test, never a whole-suite re-run.");
3539
- } else {
3540
- out.push("No pre-existing-failure baseline is configured. If the suite is already red, capture the baseline ONCE \u2014 the first full run you do, before your changes land, is the baseline \u2014 and compare every later run against those saved results. Never re-run a whole suite just to re-establish what was already failing.");
3537
+ return { sessionId, ok: true, killed };
3538
+ }
3539
+ var init_end_session = __esm(() => {
3540
+ init_sessions();
3541
+ init_events();
3542
+ init_waypoints();
3543
+ init_registry();
3544
+ });
3545
+
3546
+ // src/launcher/skills-root.ts
3547
+ import { existsSync as existsSync9 } from "fs";
3548
+ import { dirname as dirname2, join as join8, resolve as resolve6 } from "path";
3549
+ function resolveSkillsRoot(fromDir) {
3550
+ const override = process.env[SKILLS_DIR_ENV];
3551
+ if (override) {
3552
+ const root = resolve6(override);
3553
+ if (existsSync9(join8(root, SKILLS_MARKER)))
3554
+ return root;
3555
+ throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
3541
3556
  }
3542
- return out.join(`
3543
- `);
3557
+ const searched = [];
3558
+ let dir = fromDir;
3559
+ for (let i = 0;i < 8; i += 1) {
3560
+ const candidate = join8(dir, "packages", "skills");
3561
+ searched.push(candidate);
3562
+ if (existsSync9(join8(candidate, SKILLS_MARKER)))
3563
+ return candidate;
3564
+ const parent = dirname2(dir);
3565
+ if (parent === dir)
3566
+ break;
3567
+ dir = parent;
3568
+ }
3569
+ throw new Error(`runcastle skills root not found; searched:
3570
+ ${searched.join(`
3571
+ `)}`);
3544
3572
  }
3545
- function readRepoToolchain(repoPath) {
3546
- const pkgPath = join7(repoPath, "package.json");
3547
- const hasPackageJson = existsSync7(pkgPath);
3548
- let packageManagerField;
3549
- if (hasPackageJson) {
3550
- try {
3551
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
3552
- if (typeof pkg.packageManager === "string")
3553
- packageManagerField = pkg.packageManager;
3554
- } catch {}
3573
+ var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
3574
+ var init_skills_root = __esm(() => {
3575
+ SKILLS_MARKER = join8("packs", "runcastle");
3576
+ });
3577
+
3578
+ // src/services/agent-stream.ts
3579
+ function beginTranscript(ticketId) {
3580
+ transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
3581
+ publishLive({ kind: "transcript", ticketId });
3582
+ }
3583
+ function appendTranscript(ticketId, chunk) {
3584
+ const t = transcripts.get(ticketId);
3585
+ if (!t)
3586
+ return;
3587
+ const full = { ...chunk, i: t.nextIndex, ts: chunk.ts ?? Date.now() };
3588
+ t.nextIndex += 1;
3589
+ t.chunks.push(full);
3590
+ t.bytes += full.text.length;
3591
+ while (t.chunks.length > MAX_CHUNKS || t.bytes > MAX_BYTES && t.chunks.length > 1) {
3592
+ const dropped = t.chunks.shift();
3593
+ if (!dropped)
3594
+ break;
3595
+ t.bytes -= dropped.text.length;
3555
3596
  }
3556
- return {
3557
- hasPackageJson,
3558
- ...packageManagerField !== undefined ? { packageManagerField } : {},
3559
- lockfiles: {
3560
- bun: existsSync7(join7(repoPath, "bun.lock")) || existsSync7(join7(repoPath, "bun.lockb")),
3561
- pnpm: existsSync7(join7(repoPath, "pnpm-lock.yaml")),
3562
- yarn: existsSync7(join7(repoPath, "yarn.lock")),
3563
- npm: existsSync7(join7(repoPath, "package-lock.json"))
3564
- }
3565
- };
3597
+ publishLive({ kind: "transcript", ticketId });
3566
3598
  }
3567
- function createStreamThrottle(emit3, opts = {}) {
3568
- const intervalMs = opts.intervalMs ?? 2000;
3569
- const maxChars = opts.maxChars ?? 500;
3570
- const now = opts.now ?? Date.now;
3571
- let buffer = "";
3572
- let lastIteration = 0;
3573
- let lastFlush = now();
3574
- const flushText = () => {
3575
- if (buffer.length === 0)
3576
- return;
3577
- emit3({ type: "burn.text", message: buffer, data: { iteration: lastIteration } });
3578
- buffer = "";
3579
- lastFlush = now();
3580
- };
3581
- const onEvent = (event) => {
3582
- if (event.type === "text") {
3583
- lastIteration = event.iteration;
3584
- buffer += event.message;
3585
- if (buffer.length >= maxChars || now() - lastFlush >= intervalMs)
3586
- flushText();
3587
- } else if (event.type === "toolCall") {
3588
- lastIteration = event.iteration;
3589
- flushText();
3590
- const rawArgs = event.formattedArgs ?? "";
3591
- const args = rawArgs.length > 200 ? `${rawArgs.slice(0, 200)}\u2026` : rawArgs;
3592
- emit3({
3593
- type: "burn.tool",
3594
- message: args.length > 0 ? `${event.name} ${args}` : event.name,
3595
- data: { name: event.name, args: rawArgs, iteration: event.iteration }
3596
- });
3597
- }
3598
- };
3599
- return { onEvent, flush: flushText };
3599
+ function endTranscript(ticketId) {
3600
+ const t = transcripts.get(ticketId);
3601
+ if (t)
3602
+ t.live = false;
3603
+ publishLive({ kind: "transcript", ticketId });
3600
3604
  }
3601
- function normalizeCommandForClassification(command) {
3602
- return command.replace(/<<\s*['"]?(\w+)['"]?[\s\S]*?(^|\n)\s*\1\b/gm, "<<$1 ").replace(/<<\s*['"]?(\w+)['"]?[\s\S]*$/, "<<$1 ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
3605
+ function readTranscript(ticketId, after = -1) {
3606
+ const t = transcripts.get(ticketId);
3607
+ if (!t)
3608
+ return { live: false, chunks: [], firstIndex: 0, nextIndex: 0 };
3609
+ return {
3610
+ live: t.live,
3611
+ chunks: after < 0 ? [...t.chunks] : t.chunks.filter((c) => c.i > after),
3612
+ firstIndex: t.chunks[0]?.i ?? t.nextIndex,
3613
+ nextIndex: t.nextIndex
3614
+ };
3603
3615
  }
3604
- function classifyToolCall(name, args) {
3605
- const byName = TOOL_NAME_CATEGORY[name];
3606
- if (byName)
3607
- return byName;
3608
- if (name !== "Bash")
3609
- return "other";
3610
- const normalized = normalizeCommandForClassification(args);
3611
- for (const [category, pattern] of BASH_PATTERNS) {
3612
- if (pattern.test(normalized))
3613
- return category;
3616
+ var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
3617
+ var init_agent_stream = __esm(() => {
3618
+ init_bus();
3619
+ transcripts = new Map;
3620
+ });
3621
+
3622
+ // src/workflows/burn-guard.ts
3623
+ function renderGuardScript(rules = GUARD_RULES) {
3624
+ const lines = [
3625
+ "#!/bin/sh",
3626
+ "# runcastle burn guard (PreToolUse). Generated \u2014 edit burn-guard.ts, not this.",
3627
+ "set -u",
3628
+ "payload=$(cat 2>/dev/null || true)",
3629
+ '[ -n "$payload" ] || exit 0',
3630
+ "command -v jq >/dev/null 2>&1 || exit 0",
3631
+ 'tool=$(printf %s "$payload" | jq -r ".tool_name // empty" 2>/dev/null || true)',
3632
+ '[ "$tool" = "Bash" ] || exit 0',
3633
+ 'cmd=$(printf %s "$payload" | jq -r ".tool_input.command // empty" 2>/dev/null || true)',
3634
+ '[ -n "$cmd" ] || exit 0',
3635
+ "",
3636
+ "# Match against the command with quoted spans blanked, so an argument is",
3637
+ '# never read as a command: `grep -rn "git stash" docs/` searches for a',
3638
+ "# string, it does not run one.",
3639
+ `cmd=$(printf %s "$cmd" | sed "s/'[^']*'/ /g; s/\\"[^\\"]*\\"/ /g")`,
3640
+ "",
3641
+ "deny() {",
3642
+ ` printf %s "$1" | jq -R -s '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:.}}'`,
3643
+ " exit 0",
3644
+ "}",
3645
+ ""
3646
+ ];
3647
+ for (const rule of rules) {
3648
+ lines.push(`# ${rule.id}`, `if printf %s "$cmd" | grep -Eq -- ${shSingleQuote(rule.pattern)}; then`, ` deny ${shSingleQuote(rule.reason)}`, "fi", "");
3614
3649
  }
3615
- return "other";
3650
+ lines.push("exit 0", "");
3651
+ return lines.join(`
3652
+ `);
3616
3653
  }
3617
- function createToolTimer() {
3618
- const byCategory = {};
3619
- let calls = 0;
3620
- let totalMs = 0;
3621
- let pending = null;
3622
- let iteration = null;
3623
- const charge = (category, ms) => {
3624
- const slot = byCategory[category] ??= { calls: 0, ms: 0 };
3625
- slot.ms += ms;
3626
- totalMs += ms;
3654
+ function shSingleQuote(value) {
3655
+ return `'${value.split("'").join(`'\\''`)}'`;
3656
+ }
3657
+ function renderGuardSettings() {
3658
+ return {
3659
+ hooks: {
3660
+ PreToolUse: [
3661
+ {
3662
+ matcher: "Bash",
3663
+ hooks: [{ type: "command", command: `sh ${GUARD_SCRIPT_PATH}`, timeout: 5 }]
3664
+ }
3665
+ ]
3666
+ }
3627
3667
  };
3628
- const onEvent = (event) => {
3629
- if (event.type === "raw")
3630
- return;
3631
- const at = event.timestamp.getTime();
3632
- if (iteration !== null && event.iteration !== iteration)
3633
- pending = null;
3634
- iteration = event.iteration;
3635
- if (pending) {
3636
- const gap = at - pending.at;
3637
- if (gap > 0 && gap <= MAX_ATTRIBUTABLE_GAP_MS)
3638
- charge(pending.category, gap);
3668
+ }
3669
+ function buildGuardInstallCommand() {
3670
+ const script = Buffer.from(renderGuardScript(), "utf8").toString("base64");
3671
+ const settings = Buffer.from(JSON.stringify(renderGuardSettings(), null, 2), "utf8").toString("base64");
3672
+ return [
3673
+ `mkdir -p "$HOME/.claude/hooks"`,
3674
+ `printf %s '${script}' | base64 -d > "${GUARD_SCRIPT_PATH}"`,
3675
+ `chmod +x "${GUARD_SCRIPT_PATH}"`,
3676
+ `printf %s '${settings}' | base64 -d > "${GUARD_SETTINGS_PATH}"`
3677
+ ].join(" && ");
3678
+ }
3679
+ var CMD_START = "(^|[;&|(]|&&)[[:space:]]*", GUARD_RULES, GUARD_SCRIPT_PATH = "$HOME/.claude/hooks/burn-guard.sh", GUARD_SETTINGS_PATH = "$HOME/.claude/settings.json";
3680
+ var init_burn_guard = __esm(() => {
3681
+ GUARD_RULES = [
3682
+ {
3683
+ id: "no-stash",
3684
+ pattern: `${CMD_START}git[[:space:]]+stash`,
3685
+ reason: "git stash is blocked in burns: stashed work is invisible to the orchestrator and unrecoverable if this process dies, and only commits survive. To compare against the pre-change state use `git show HEAD:<path>`, or the pre-existing-failure baseline in your prompt."
3686
+ },
3687
+ {
3688
+ id: "no-serialised-tests",
3689
+ pattern: "--maxWorkers|--runInBand|--pool=|--shard=|singleFork|--poolOptions",
3690
+ reason: `Do not override the test runner's concurrency. Measured in this sandbox, a suite that runs in ~55s at its configured concurrency takes 10-20 minutes serialised, so this "safe" flag is the single most expensive habit in a burn. Run the repo's test command as configured. If it is killed for memory, run only the test files your change touches and say so in your final message.`
3691
+ },
3692
+ {
3693
+ id: "no-interpreter-heredoc-edit",
3694
+ pattern: `${CMD_START}(python3?|node|bun|perl|ruby)[[:space:]]+-[[:space:]]*<<`,
3695
+ reason: "Do not rewrite files by piping a heredoc into an interpreter \u2014 individual such calls have been measured at 29s, 57s, 120s and 761s, and a half-written file survives if this process dies mid-command. Use the Edit tool, which is faster, atomic, and fails loudly when its target text is not found."
3696
+ },
3697
+ {
3698
+ id: "no-cat-heredoc-edit",
3699
+ pattern: `${CMD_START}cat[[:space:]]+>>?[[:space:]]*[^[:space:]|]+[[:space:]]*<<`,
3700
+ reason: "Do not write files with `cat > file <<EOF`. Use the Write tool for a new file, or Edit to change part of an existing one."
3639
3701
  }
3640
- if (event.type === "toolCall") {
3641
- const category = classifyToolCall(event.name, event.formattedArgs ?? "");
3642
- const slot = byCategory[category] ??= { calls: 0, ms: 0 };
3643
- slot.calls += 1;
3644
- calls += 1;
3645
- pending = { category, at };
3646
- } else {
3647
- pending = { category: "model", at };
3702
+ ];
3703
+ });
3704
+
3705
+ // src/workflows/ticket-burner.ts
3706
+ import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
3707
+ import { dirname as dirname3, join as join9 } from "path";
3708
+ import { fileURLToPath as fileURLToPath2 } from "url";
3709
+ import { claudeCode, run } from "@ai-hero/sandcastle";
3710
+ import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
3711
+ import { podman } from "@ai-hero/sandcastle/sandboxes/podman";
3712
+ import { noSandbox } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
3713
+ function indexBySeq(tickets2) {
3714
+ return new Map(tickets2.map((t) => [t.seq, t]));
3715
+ }
3716
+ function detectCycle(tickets2) {
3717
+ const bySeq = indexBySeq(tickets2);
3718
+ const WHITE = 0;
3719
+ const GRAY = 1;
3720
+ const BLACK = 2;
3721
+ const color = new Map;
3722
+ const stack = [];
3723
+ const dfs = (seq) => {
3724
+ color.set(seq, GRAY);
3725
+ stack.push(seq);
3726
+ const t = bySeq.get(seq);
3727
+ if (t) {
3728
+ for (const b of t.blockedBy) {
3729
+ if (!bySeq.has(b))
3730
+ continue;
3731
+ const c = color.get(b) ?? WHITE;
3732
+ if (c === GRAY) {
3733
+ const idx = stack.indexOf(b);
3734
+ return [...stack.slice(idx), b];
3735
+ }
3736
+ if (c === WHITE) {
3737
+ const found = dfs(b);
3738
+ if (found)
3739
+ return found;
3740
+ }
3741
+ }
3648
3742
  }
3743
+ color.set(seq, BLACK);
3744
+ stack.pop();
3745
+ return null;
3649
3746
  };
3650
- return { onEvent, summary: () => ({ totalMs, calls, byCategory }) };
3651
- }
3652
- function formatTimingSummary(s) {
3653
- if (s.totalMs === 0)
3654
- return `${s.calls} tool call(s), no measurable time`;
3655
- const parts = Object.entries(s.byCategory).sort((a, b) => (b[1]?.ms ?? 0) - (a[1]?.ms ?? 0)).slice(0, 5).map(([c, t]) => `${c} ${Math.round((t?.ms ?? 0) / s.totalMs * 100)}%`);
3656
- return `${Math.round(s.totalMs / 60000)}min across ${s.calls} tool call(s) \u2014 ${parts.join(", ")}`;
3747
+ for (const t of tickets2) {
3748
+ if ((color.get(t.seq) ?? WHITE) === WHITE) {
3749
+ const found = dfs(t.seq);
3750
+ if (found)
3751
+ return found;
3752
+ }
3753
+ }
3754
+ return null;
3657
3755
  }
3658
- function interpretRunResult(result, blockedContent) {
3659
- const commits = result.commits.map((c) => c.sha);
3660
- if (commits.length > 0)
3661
- return { status: "done", commits };
3662
- if (blockedContent !== undefined && blockedContent.trim().length > 0) {
3663
- return { status: "failed", error: `agent reported BLOCKED:
3664
- ${blockedContent.trim()}` };
3756
+ function renderTemplate2(template, values) {
3757
+ let out = template;
3758
+ for (const [key, value] of Object.entries(values)) {
3759
+ out = out.split(`{{${key}}}`).join(value);
3665
3760
  }
3666
- return { status: "failed", error: "agent made no commits" };
3761
+ return out;
3667
3762
  }
3668
- function isWorktreeTeardownError(err) {
3669
- const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
3670
- if (!BURN_WORKTREE_PATH.test(msg))
3671
- return false;
3672
- return WORKTREE_REMOVAL_FAILURES.some((p) => p.test(msg));
3763
+ function renderTicketPrompt(template, values) {
3764
+ return renderTemplate2(template, values);
3673
3765
  }
3674
- function isMergeConflictError(err) {
3675
- const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
3676
- return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
3766
+ function buildTicketJson(ticket) {
3767
+ return JSON.stringify({
3768
+ seq: ticket.seq,
3769
+ title: ticket.title,
3770
+ goal: ticket.goal,
3771
+ context: ticket.context,
3772
+ acceptanceCriteria: ticket.acceptanceCriteria,
3773
+ seams: ticket.seams,
3774
+ blockedBy: ticket.blockedBy
3775
+ }, null, 2);
3677
3776
  }
3678
- function classifyTicketRunError(err) {
3679
- const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
3680
- if (FATAL_ERROR_PATTERNS.some((p) => p.test(msg)))
3681
- return "fatal";
3682
- if (RETRYABLE_ERROR_PATTERNS.some((p) => p.test(msg)))
3683
- return "retryable";
3684
- return "fatal";
3685
- }
3686
- function retryDelayMs(attempt) {
3687
- return Math.min(30000, 5000 * 2 ** (attempt - 1));
3688
- }
3689
- function buildRetryNotes(input) {
3690
- const cause = input.error ? ` (${input.error})` : "";
3691
- const commits = input.commitCount > 0 ? `${input.commitCount} commit(s) from the previous attempt(s) are already on your branch \u2014 completed work, not noise.` : "The previous attempt had not committed anything yet, so you are effectively starting clean.";
3777
+ function buildFeatureBrief(feature) {
3692
3778
  return [
3693
- "## Recovery context \u2014 a previous attempt was interrupted",
3694
- "",
3695
- `A previous agent working THIS SAME ticket was killed by a transient infrastructure error${cause} \u2014 not by anything it did wrong, and not by a human decision. You are picking up where it left off.`,
3779
+ `**${feature.title}** (\`${feature.slug}\`)`,
3696
3780
  "",
3697
- commits,
3781
+ feature.oneLiner,
3698
3782
  "",
3699
- "Before doing anything else, run `git log --oneline -15` and `git status` to see what was already completed. Build on that work \u2014 do NOT revert or redo existing commits. Uncommitted changes from the previous attempt were lost; only commits survived. If the ticket turns out to be fully implemented already, verify the acceptance criteria and finish normally."
3783
+ `Working branch: \`${feature.branch}\``
3700
3784
  ].join(`
3701
3785
  `);
3702
3786
  }
3703
- function delayUnlessAborted(ms, signal) {
3704
- return new Promise((resolve6) => {
3705
- if (signal.aborted) {
3706
- resolve6();
3707
- return;
3708
- }
3709
- const onAbort = () => {
3710
- clearTimeout(t);
3711
- resolve6();
3712
- };
3713
- const t = setTimeout(() => {
3714
- signal.removeEventListener("abort", onAbort);
3715
- resolve6();
3716
- }, ms);
3717
- signal.addEventListener("abort", onAbort, { once: true });
3718
- });
3787
+ function buildDocsDigest(files) {
3788
+ if (files.length === 0) {
3789
+ return "_No feature docs found \u2014 work from the ticket context and the code._";
3790
+ }
3791
+ return files.map((f) => `### ${f.name}
3792
+
3793
+ ${f.content.trim()}`).join(`
3794
+
3795
+ ---
3796
+
3797
+ `);
3719
3798
  }
3720
- function stopTicketRun(ticketId) {
3721
- const controller = activeTicketAborts.get(ticketId);
3722
- if (!controller)
3723
- return false;
3724
- controller.abort(new Error("ticket stopped by user"));
3725
- return true;
3799
+ function buildConflictFilesBlock(files) {
3800
+ if (files.length === 0) {
3801
+ return "_git did not report the paths \u2014 run `git status` after starting the merge to see them._";
3802
+ }
3803
+ return files.map((f) => `- \`${f}\``).join(`
3804
+ `);
3726
3805
  }
3727
- function createSerialQueue() {
3728
- let tail = Promise.resolve();
3729
- return (task) => {
3730
- const next = tail.then(task);
3731
- tail = next.catch(() => {
3732
- return;
3733
- });
3734
- return next;
3735
- };
3806
+ function buildOtherSideBlock(summaries) {
3807
+ if (summaries.length === 0) {
3808
+ return "_No commit summaries available \u2014 inspect `git log` on the feature branch._";
3809
+ }
3810
+ return summaries.map((s) => `- ${s}`).join(`
3811
+ `);
3736
3812
  }
3737
- async function landWithResolve(branch, deps) {
3738
- let current = branch;
3739
- for (let attempt = 1;; attempt++) {
3740
- const merge = await deps.merge(current);
3741
- if (merge.ok)
3742
- return { status: "landed", branch: current };
3743
- const detail = merge.error ?? "merge failed";
3744
- if (!merge.conflict)
3745
- return { status: "failed", branch: current, error: detail };
3746
- const files = merge.files ?? [];
3747
- if (attempt > deps.maxResolveAttempts) {
3748
- return { status: "conflict", branch: current, files, error: detail };
3749
- }
3750
- const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
3751
- deps.emit({
3752
- type: "merge.conflict.resolving",
3753
- message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
3754
- data: { branch: current, files, attempt, maxAttempts: deps.maxResolveAttempts }
3755
- });
3756
- const resolved = await deps.resolve({
3757
- branch: current,
3758
- files,
3759
- attempt,
3760
- maxAttempts: deps.maxResolveAttempts
3761
- });
3762
- current = resolved.branch;
3763
- if (!resolved.ok) {
3764
- return { status: "conflict", branch: current, files, error: resolved.error ?? detail };
3813
+ function resolveMergeCommand(mode, featureBranch2) {
3814
+ return mode === "mounted" ? `git merge --no-edit ${featureBranch2}` : `git fetch origin ${featureBranch2} && git merge --no-edit FETCH_HEAD`;
3815
+ }
3816
+ function parseEnvFile(content) {
3817
+ const out = {};
3818
+ for (const rawLine of content.split(/\r?\n/)) {
3819
+ const line = rawLine.trim();
3820
+ if (line.length === 0 || line.startsWith("#"))
3821
+ continue;
3822
+ const body = line.startsWith("export ") ? line.slice(7).trim() : line;
3823
+ const eq6 = body.indexOf("=");
3824
+ if (eq6 === -1)
3825
+ continue;
3826
+ const key = body.slice(0, eq6).trim();
3827
+ if (key.length === 0)
3828
+ continue;
3829
+ let value = body.slice(eq6 + 1).trim();
3830
+ if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
3831
+ value = value.slice(1, -1);
3765
3832
  }
3766
- deps.emit({
3767
- type: "merge.conflict.resolved",
3768
- message: `${deps.label}: conflict resolved on ${current} \u2014 landing`,
3769
- data: { branch: current, files, attempt }
3770
- });
3833
+ out[key] = value;
3771
3834
  }
3835
+ return out;
3772
3836
  }
3773
- function errorHeadline(s) {
3774
- const lines = s.split(`
3775
- `).map((l) => l.trim()).filter(Boolean);
3776
- const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
3777
- return causes.at(-1) ?? lines[0] ?? "";
3837
+ function detectPackageManager(tc) {
3838
+ const pinned = tc.packageManagerField?.split("@")[0];
3839
+ if (pinned && PACKAGE_MANAGERS.includes(pinned)) {
3840
+ return pinned;
3841
+ }
3842
+ if (tc.lockfiles.bun)
3843
+ return "bun";
3844
+ if (tc.lockfiles.pnpm)
3845
+ return "pnpm";
3846
+ if (tc.lockfiles.yarn)
3847
+ return "yarn";
3848
+ if (tc.lockfiles.npm)
3849
+ return "npm";
3850
+ return tc.hasPackageJson ? "npm" : undefined;
3778
3851
  }
3779
- async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
3780
- const width = Math.max(1, Math.floor(concurrency));
3781
- const bySeq = indexBySeq(tickets2);
3782
- const status = new Map(tickets2.map((t) => [t.seq, t.status]));
3783
- const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
3784
- const inFlight = new Map;
3785
- const satisfied = (s) => s === "done" || s === "cancelled";
3786
- const readyState = (seq) => {
3787
- const t = bySeq.get(seq);
3788
- if (!t)
3789
- return "wait";
3790
- for (const b of t.blockedBy) {
3791
- const present = bySeq.has(b);
3792
- const bs = present ? status.get(b) : undefined;
3793
- if (!present || bs === "failed")
3794
- return { blockedBy: b, present };
3852
+ function orFallback(strict, permissive) {
3853
+ return `( ${strict} || ${permissive} )`;
3854
+ }
3855
+ function resolveSetupCommand(tc, override) {
3856
+ const trimmed = override?.trim();
3857
+ if (trimmed)
3858
+ return trimmed;
3859
+ const pm = detectPackageManager(tc);
3860
+ if (!pm)
3861
+ return;
3862
+ switch (pm) {
3863
+ case "bun":
3864
+ return tc.lockfiles.bun ? orFallback("bun install --frozen-lockfile", "bun install") : "bun install";
3865
+ case "pnpm":
3866
+ return tc.lockfiles.pnpm ? orFallback("corepack pnpm install --frozen-lockfile", "corepack pnpm install") : "corepack pnpm install";
3867
+ case "yarn":
3868
+ return tc.lockfiles.yarn ? orFallback("corepack yarn install --frozen-lockfile", "corepack yarn install") : "corepack yarn install";
3869
+ case "npm":
3870
+ return tc.lockfiles.npm ? orFallback("npm ci", "npm install") : "npm install";
3871
+ }
3872
+ }
3873
+ function cacheMountFor(pm, hostPath) {
3874
+ const sandboxPath = PM_CACHE_SANDBOX_PATHS[pm];
3875
+ return sandboxPath ? { hostPath, sandboxPath } : undefined;
3876
+ }
3877
+ function resolveBurnWorkspaceMode(config2, platform = process.platform) {
3878
+ if (config2.sandbox === "noSandbox")
3879
+ return "mounted";
3880
+ if (config2.burnWorkspace === "auto")
3881
+ return platform === "linux" ? "mounted" : "isolated";
3882
+ return config2.burnWorkspace;
3883
+ }
3884
+ function buildIsolatedSetupCommand(tempBranch, setupCommand, pm) {
3885
+ const hookFile = `${ISOLATED_REPO_PATH}/.git/hooks/post-commit`;
3886
+ const parts = [
3887
+ `git config --global --add safe.directory '*'`,
3888
+ `git clone ${SANDBOX_WORKSPACE_PATH} ${ISOLATED_REPO_PATH}`,
3889
+ `printf '#!/bin/sh\\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE\\ngit push --quiet origin HEAD:%s && exec git -C ${SANDBOX_WORKSPACE_PATH} reset --hard --quiet %s\\n' '${tempBranch}' '${tempBranch}' > ${hookFile}`,
3890
+ `chmod +x ${hookFile}`
3891
+ ];
3892
+ if (pm === "pnpm" || pm === "yarn") {
3893
+ const shim = `$HOME/.local/bin/${pm}`;
3894
+ parts.push(`mkdir -p "$HOME/.local/bin"`, `printf '#!/bin/sh\\nexec corepack ${pm} "$@"\\n' > "${shim}"`, `chmod +x "${shim}"`);
3895
+ }
3896
+ if (setupCommand)
3897
+ parts.push(`cd ${ISOLATED_REPO_PATH} && ${setupCommand}`);
3898
+ parts.push(`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks`);
3899
+ return parts.join(" && ");
3900
+ }
3901
+ function buildWorkspaceNotes(mode) {
3902
+ if (mode === "mounted") {
3903
+ return "Work in the current directory \u2014 it is the repo checkout on your branch.";
3904
+ }
3905
+ return [
3906
+ `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.`,
3907
+ "",
3908
+ `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. If you re-run the dependency install and it reconfigures git hooks (husky), run \`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks\` afterwards so the sync hook stays armed.`,
3909
+ "",
3910
+ `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.`
3911
+ ].join(`
3912
+ `);
3913
+ }
3914
+ function buildVerifyNotes(config2) {
3915
+ const commands = config2.verifyCommands?.trim();
3916
+ const failures = config2.knownFailures?.trim();
3917
+ const out = [];
3918
+ if (commands) {
3919
+ out.push("Verify with exactly these commands \u2014 they are correct for this repo, so do not go looking for alternatives or guess at workspace/package filter names:", "", "```", commands, "```");
3920
+ } else {
3921
+ out.push("No verification commands are configured, so derive them ONCE from the repo (root `package.json` scripts, the workspace's own manifest, CI config) before your first test run \u2014 reading a manifest is free, discovering a filter name by running the wrong suite is not. Write the working commands into your notes and reuse them verbatim for the rest of the ticket.");
3922
+ }
3923
+ out.push("");
3924
+ if (failures) {
3925
+ out.push("These tests ALREADY fail on this repo before you touch anything:", "", "```", failures, "```", "", "Treat that as your baseline \u2014 do NOT spend a run establishing it yourself. Only a failure outside this set is yours to fix; if you hit one that looks pre-existing but is not listed, confirm it on a single targeted run of that one test, never a whole-suite re-run.");
3926
+ } else {
3927
+ out.push("No pre-existing-failure baseline is configured. If the suite is already red, capture the baseline ONCE \u2014 the first full run you do, before your changes land, is the baseline \u2014 and compare every later run against those saved results. Never re-run a whole suite just to re-establish what was already failing.");
3928
+ }
3929
+ return out.join(`
3930
+ `);
3931
+ }
3932
+ function readRepoToolchain(repoPath) {
3933
+ const pkgPath = join9(repoPath, "package.json");
3934
+ const hasPackageJson = existsSync10(pkgPath);
3935
+ let packageManagerField;
3936
+ if (hasPackageJson) {
3937
+ try {
3938
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
3939
+ if (typeof pkg.packageManager === "string")
3940
+ packageManagerField = pkg.packageManager;
3941
+ } catch {}
3942
+ }
3943
+ return {
3944
+ hasPackageJson,
3945
+ ...packageManagerField !== undefined ? { packageManagerField } : {},
3946
+ lockfiles: {
3947
+ bun: existsSync10(join9(repoPath, "bun.lock")) || existsSync10(join9(repoPath, "bun.lockb")),
3948
+ pnpm: existsSync10(join9(repoPath, "pnpm-lock.yaml")),
3949
+ yarn: existsSync10(join9(repoPath, "yarn.lock")),
3950
+ npm: existsSync10(join9(repoPath, "package-lock.json"))
3795
3951
  }
3796
- return t.blockedBy.every((b) => satisfied(status.get(b))) ? "ready" : "wait";
3797
3952
  };
3798
- const failTicket = (seq, error, extra) => {
3799
- const t = bySeq.get(seq);
3800
- status.set(seq, "failed");
3801
- if (!t)
3953
+ }
3954
+ function createStreamThrottle(emit3, opts = {}) {
3955
+ const intervalMs = opts.intervalMs ?? 2000;
3956
+ const maxChars = opts.maxChars ?? 500;
3957
+ const now = opts.now ?? Date.now;
3958
+ let buffer = "";
3959
+ let lastIteration = 0;
3960
+ let lastFlush = now();
3961
+ const flushText = () => {
3962
+ if (buffer.length === 0)
3802
3963
  return;
3803
- ctx.updateTicket(t.id, { status: "failed", error });
3804
- if (extra)
3805
- ctx.emitEvent({ ...extra, ticketId: t.id });
3806
- };
3807
- const runOne = async (seq) => {
3964
+ emit3({ type: "burn.text", message: buffer, data: { iteration: lastIteration } });
3965
+ buffer = "";
3966
+ lastFlush = now();
3967
+ };
3968
+ const onEvent = (event) => {
3969
+ if (event.type === "text") {
3970
+ lastIteration = event.iteration;
3971
+ buffer += event.message;
3972
+ if (buffer.length >= maxChars || now() - lastFlush >= intervalMs)
3973
+ flushText();
3974
+ } else if (event.type === "toolCall") {
3975
+ lastIteration = event.iteration;
3976
+ flushText();
3977
+ const rawArgs = event.formattedArgs ?? "";
3978
+ const args = rawArgs.length > 200 ? `${rawArgs.slice(0, 200)}\u2026` : rawArgs;
3979
+ emit3({
3980
+ type: "burn.tool",
3981
+ message: args.length > 0 ? `${event.name} ${args}` : event.name,
3982
+ data: { name: event.name, args: rawArgs, iteration: event.iteration }
3983
+ });
3984
+ }
3985
+ };
3986
+ return { onEvent, flush: flushText };
3987
+ }
3988
+ function normalizeCommandForClassification(command) {
3989
+ return command.replace(/<<\s*['"]?(\w+)['"]?[\s\S]*?(^|\n)\s*\1\b/gm, "<<$1 ").replace(/<<\s*['"]?(\w+)['"]?[\s\S]*$/, "<<$1 ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
3990
+ }
3991
+ function classifyToolCall(name, args) {
3992
+ const byName = TOOL_NAME_CATEGORY[name];
3993
+ if (byName)
3994
+ return byName;
3995
+ if (name !== "Bash")
3996
+ return "other";
3997
+ const normalized = normalizeCommandForClassification(args);
3998
+ for (const [category, pattern] of BASH_PATTERNS) {
3999
+ if (pattern.test(normalized))
4000
+ return category;
4001
+ }
4002
+ return "other";
4003
+ }
4004
+ function createToolTimer() {
4005
+ const byCategory = {};
4006
+ let calls = 0;
4007
+ let totalMs = 0;
4008
+ let pending = null;
4009
+ let iteration = null;
4010
+ const charge = (category, ms) => {
4011
+ const slot = byCategory[category] ??= { calls: 0, ms: 0 };
4012
+ slot.ms += ms;
4013
+ totalMs += ms;
4014
+ };
4015
+ const onEvent = (event) => {
4016
+ if (event.type === "raw")
4017
+ return;
4018
+ const at = event.timestamp.getTime();
4019
+ if (iteration !== null && event.iteration !== iteration)
4020
+ pending = null;
4021
+ iteration = event.iteration;
4022
+ if (pending) {
4023
+ const gap = at - pending.at;
4024
+ if (gap > 0 && gap <= MAX_ATTRIBUTABLE_GAP_MS)
4025
+ charge(pending.category, gap);
4026
+ }
4027
+ if (event.type === "toolCall") {
4028
+ const category = classifyToolCall(event.name, event.formattedArgs ?? "");
4029
+ const slot = byCategory[category] ??= { calls: 0, ms: 0 };
4030
+ slot.calls += 1;
4031
+ calls += 1;
4032
+ pending = { category, at };
4033
+ } else {
4034
+ pending = { category: "model", at };
4035
+ }
4036
+ };
4037
+ return { onEvent, summary: () => ({ totalMs, calls, byCategory }) };
4038
+ }
4039
+ function formatTimingSummary(s) {
4040
+ if (s.totalMs === 0)
4041
+ return `${s.calls} tool call(s), no measurable time`;
4042
+ const parts = Object.entries(s.byCategory).sort((a, b) => (b[1]?.ms ?? 0) - (a[1]?.ms ?? 0)).slice(0, 5).map(([c, t]) => `${c} ${Math.round((t?.ms ?? 0) / s.totalMs * 100)}%`);
4043
+ return `${Math.round(s.totalMs / 60000)}min across ${s.calls} tool call(s) \u2014 ${parts.join(", ")}`;
4044
+ }
4045
+ function interpretRunResult(result, blockedContent) {
4046
+ const commits = result.commits.map((c) => c.sha);
4047
+ if (commits.length > 0)
4048
+ return { status: "done", commits };
4049
+ if (blockedContent !== undefined && blockedContent.trim().length > 0) {
4050
+ return { status: "failed", error: `agent reported BLOCKED:
4051
+ ${blockedContent.trim()}` };
4052
+ }
4053
+ return { status: "failed", error: "agent made no commits" };
4054
+ }
4055
+ function isWorktreeTeardownError(err) {
4056
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
4057
+ if (!BURN_WORKTREE_PATH.test(msg))
4058
+ return false;
4059
+ return WORKTREE_REMOVAL_FAILURES.some((p) => p.test(msg));
4060
+ }
4061
+ function isMergeConflictError(err) {
4062
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
4063
+ return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
4064
+ }
4065
+ function classifyTicketRunError(err) {
4066
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
4067
+ if (FATAL_ERROR_PATTERNS.some((p) => p.test(msg)))
4068
+ return "fatal";
4069
+ if (RETRYABLE_ERROR_PATTERNS.some((p) => p.test(msg)))
4070
+ return "retryable";
4071
+ return "fatal";
4072
+ }
4073
+ function retryDelayMs(attempt) {
4074
+ return Math.min(30000, 5000 * 2 ** (attempt - 1));
4075
+ }
4076
+ function buildRetryNotes(input) {
4077
+ const cause = input.error ? ` (${input.error})` : "";
4078
+ const commits = input.commitCount > 0 ? `${input.commitCount} commit(s) from the previous attempt(s) are already on your branch \u2014 completed work, not noise.` : "The previous attempt had not committed anything yet, so you are effectively starting clean.";
4079
+ return [
4080
+ "## Recovery context \u2014 a previous attempt was interrupted",
4081
+ "",
4082
+ `A previous agent working THIS SAME ticket was killed by a transient infrastructure error${cause} \u2014 not by anything it did wrong, and not by a human decision. You are picking up where it left off.`,
4083
+ "",
4084
+ commits,
4085
+ "",
4086
+ "Before doing anything else, run `git log --oneline -15` and `git status` to see what was already completed. Build on that work \u2014 do NOT revert or redo existing commits. Uncommitted changes from the previous attempt were lost; only commits survived. If the ticket turns out to be fully implemented already, verify the acceptance criteria and finish normally."
4087
+ ].join(`
4088
+ `);
4089
+ }
4090
+ function delayUnlessAborted(ms, signal) {
4091
+ return new Promise((resolve7) => {
4092
+ if (signal.aborted) {
4093
+ resolve7();
4094
+ return;
4095
+ }
4096
+ const onAbort = () => {
4097
+ clearTimeout(t);
4098
+ resolve7();
4099
+ };
4100
+ const t = setTimeout(() => {
4101
+ signal.removeEventListener("abort", onAbort);
4102
+ resolve7();
4103
+ }, ms);
4104
+ signal.addEventListener("abort", onAbort, { once: true });
4105
+ });
4106
+ }
4107
+ function stopTicketRun(ticketId) {
4108
+ const controller = activeTicketAborts.get(ticketId);
4109
+ if (!controller)
4110
+ return false;
4111
+ controller.abort(new Error("ticket stopped by user"));
4112
+ return true;
4113
+ }
4114
+ function createSerialQueue() {
4115
+ let tail = Promise.resolve();
4116
+ return (task) => {
4117
+ const next = tail.then(task);
4118
+ tail = next.catch(() => {
4119
+ return;
4120
+ });
4121
+ return next;
4122
+ };
4123
+ }
4124
+ async function landWithResolve(branch, deps) {
4125
+ let current2 = branch;
4126
+ for (let attempt = 1;; attempt++) {
4127
+ const merge = await deps.merge(current2);
4128
+ if (merge.ok)
4129
+ return { status: "landed", branch: current2 };
4130
+ const detail = merge.error ?? "merge failed";
4131
+ if (!merge.conflict)
4132
+ return { status: "failed", branch: current2, error: detail };
4133
+ const files = merge.files ?? [];
4134
+ if (attempt > deps.maxResolveAttempts) {
4135
+ return { status: "conflict", branch: current2, files, error: detail };
4136
+ }
4137
+ const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
4138
+ deps.emit({
4139
+ type: "merge.conflict.resolving",
4140
+ message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
4141
+ data: { branch: current2, files, attempt, maxAttempts: deps.maxResolveAttempts }
4142
+ });
4143
+ const resolved = await deps.resolve({
4144
+ branch: current2,
4145
+ files,
4146
+ attempt,
4147
+ maxAttempts: deps.maxResolveAttempts
4148
+ });
4149
+ current2 = resolved.branch;
4150
+ if (!resolved.ok) {
4151
+ return { status: "conflict", branch: current2, files, error: resolved.error ?? detail };
4152
+ }
4153
+ deps.emit({
4154
+ type: "merge.conflict.resolved",
4155
+ message: `${deps.label}: conflict resolved on ${current2} \u2014 landing`,
4156
+ data: { branch: current2, files, attempt }
4157
+ });
4158
+ }
4159
+ }
4160
+ function errorHeadline(s) {
4161
+ const lines = s.split(`
4162
+ `).map((l) => l.trim()).filter(Boolean);
4163
+ const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
4164
+ return causes.at(-1) ?? lines[0] ?? "";
4165
+ }
4166
+ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4167
+ const width = Math.max(1, Math.floor(concurrency));
4168
+ const bySeq = indexBySeq(tickets2);
4169
+ const status = new Map(tickets2.map((t) => [t.seq, t.status]));
4170
+ const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
4171
+ const inFlight = new Map;
4172
+ const satisfied = (s) => s === "done" || s === "cancelled";
4173
+ const readyState = (seq) => {
4174
+ const t = bySeq.get(seq);
4175
+ if (!t)
4176
+ return "wait";
4177
+ for (const b of t.blockedBy) {
4178
+ const present = bySeq.has(b);
4179
+ const bs = present ? status.get(b) : undefined;
4180
+ if (!present || bs === "failed")
4181
+ return { blockedBy: b, present };
4182
+ }
4183
+ return t.blockedBy.every((b) => satisfied(status.get(b))) ? "ready" : "wait";
4184
+ };
4185
+ const failTicket = (seq, error, extra) => {
4186
+ const t = bySeq.get(seq);
4187
+ status.set(seq, "failed");
4188
+ if (!t)
4189
+ return;
4190
+ ctx.updateTicket(t.id, { status: "failed", error });
4191
+ if (extra)
4192
+ ctx.emitEvent({ ...extra, ticketId: t.id });
4193
+ };
4194
+ const runOne = async (seq) => {
3808
4195
  const t = bySeq.get(seq);
3809
4196
  if (!t)
3810
4197
  return;
@@ -3924,26 +4311,26 @@ function resolverTemplatePath() {
3924
4311
  }
3925
4312
  function burnerAssetPath(file) {
3926
4313
  const here = dirname3(fileURLToPath2(import.meta.url));
3927
- return join7(resolveSkillsRoot(here), "burner", file);
4314
+ return join9(resolveSkillsRoot(here), "burner", file);
3928
4315
  }
3929
4316
  function readDocsDigestFromDisk(projectId, slug) {
3930
4317
  const worktree = worktreeDir(projectId, slug);
3931
- if (!existsSync7(worktree))
4318
+ if (!existsSync10(worktree))
3932
4319
  return "_No talk worktree on disk \u2014 docs digest skipped._";
3933
- const docsDir = join7(worktree, ...featureDocsRel(slug).split("/"));
3934
- if (!existsSync7(docsDir))
4320
+ const docsDir = join9(worktree, ...featureDocsRel(slug).split("/"));
4321
+ if (!existsSync10(docsDir))
3935
4322
  return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
3936
- const files = readdirSync2(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync3(join7(docsDir, e.name), "utf8") }));
4323
+ const files = readdirSync3(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync4(join9(docsDir, e.name), "utf8") }));
3937
4324
  return buildDocsDigest(files);
3938
4325
  }
3939
4326
  function readBlockedFile(dirs) {
3940
4327
  for (const dir of dirs) {
3941
4328
  if (!dir)
3942
4329
  continue;
3943
- const p = join7(dir, "BLOCKED.md");
3944
- if (existsSync7(p)) {
4330
+ const p = join9(dir, "BLOCKED.md");
4331
+ if (existsSync10(p)) {
3945
4332
  try {
3946
- return readFileSync3(p, "utf8");
4333
+ return readFileSync4(p, "utf8");
3947
4334
  } catch {}
3948
4335
  }
3949
4336
  }
@@ -3998,7 +4385,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3998
4385
  const workspaceNotes = buildWorkspaceNotes(workspaceMode);
3999
4386
  const prepared = resolvePreparedSettings(config2, project);
4000
4387
  const verifyNotes = buildVerifyNotes(prepared);
4001
- const basePrompt = renderTicketPrompt(readFileSync3(burnerTemplatePath(), "utf8"), {
4388
+ const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
4002
4389
  TICKET_JSON: ticketJson,
4003
4390
  FEATURE_BRIEF: featureBrief,
4004
4391
  DOCS_DIGEST: docsDigest,
@@ -4013,14 +4400,14 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
4013
4400
  if (config2.sandbox !== "noSandbox" && pm) {
4014
4401
  const mount = cacheMountFor(pm, burnCacheDir(pm));
4015
4402
  if (mount) {
4016
- mkdirSync3(mount.hostPath, { recursive: true });
4403
+ mkdirSync4(mount.hostPath, { recursive: true });
4017
4404
  mounts.push(mount);
4018
4405
  }
4019
4406
  }
4020
4407
  const guardInstall = config2.burnGuard && config2.sandbox !== "noSandbox" ? buildGuardInstallCommand() : undefined;
4021
4408
  const withGuard = (setup) => guardInstall === undefined ? setup : setup ? `${guardInstall} && ${setup}` : guardInstall;
4022
- mkdirSync3(logsDir(), { recursive: true });
4023
- const logFilePath = join7(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
4409
+ mkdirSync4(logsDir(), { recursive: true });
4410
+ const logFilePath = join9(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
4024
4411
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
4025
4412
  beginTranscript(ticket.id);
4026
4413
  const timer = createToolTimer();
@@ -4066,7 +4453,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
4066
4453
  const runResolver = async (input) => {
4067
4454
  const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
4068
4455
  const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
4069
- const prompt = renderTemplate2(readFileSync3(resolverTemplatePath(), "utf8"), {
4456
+ const prompt = renderTemplate2(readFileSync4(resolverTemplatePath(), "utf8"), {
4070
4457
  TICKET_JSON: ticketJson,
4071
4458
  FEATURE_BRIEF: featureBrief,
4072
4459
  DOCS_DIGEST: docsDigest,
@@ -4295,1118 +4682,166 @@ ${retryNotes}` : basePrompt,
4295
4682
  appendTranscript(ticket.id, {
4296
4683
  kind: "text",
4297
4684
  text: `
4298
- \u2014 attempt ${attempt} interrupted (${headline}); starting attempt ${attempt + 1}${salvaged.length > 0 ? " with committed work preserved" : ""} \u2014
4299
- `
4300
- });
4301
- await delayUnlessAborted(retryDelayMs(attempt), signal);
4302
- ctx.signal.throwIfAborted();
4303
- if (ticketAbort.signal.aborted) {
4304
- if (salvaged.length > 0)
4305
- preserveChain(tempBranch);
4306
- return {
4307
- status: "failed",
4308
- error: `stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved; retry to continue from them` : ""}`,
4309
- event: {
4310
- type: "ticket.stopped",
4311
- message: `ticket ${ticket.seq} stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved for retry` : ""}`
4312
- }
4313
- };
4314
- }
4315
- continue;
4316
- }
4317
- if (salvaged.length > 0)
4318
- preserveChain(tempBranch);
4319
- return { status: "failed", error: msg };
4320
- }
4321
- }
4322
- if (!result)
4323
- return { status: "failed", error: "internal: run loop produced no result" };
4324
- const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
4325
- const chain = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
4326
- let outcome;
4327
- if (result.commits.length === 0 && blocked !== undefined && blocked.trim().length > 0) {
4328
- outcome = { status: "failed", error: `agent reported BLOCKED:
4329
- ${blocked.trim()}` };
4330
- } else if (chain.length > 0) {
4331
- outcome = { status: "done", commits: chain };
4332
- } else {
4333
- outcome = interpretRunResult(result, blocked);
4334
- }
4335
- if (outcome.status !== "done") {
4336
- if (chain.length > 0)
4337
- preserveChain(tempBranch);
4338
- return outcome;
4339
- }
4340
- return await landChain(tempBranch, outcome.commits);
4341
- } finally {
4342
- activeTicketAborts.delete(ticket.id);
4343
- throttle.flush();
4344
- endTranscript(ticket.id);
4345
- const timing = timer.summary();
4346
- if (timing.calls > 0) {
4347
- ctx.emitEvent({
4348
- type: "ticket.timing",
4349
- message: `ticket ${ticket.seq} spent ${formatTimingSummary(timing)}`,
4350
- ticketId: ticket.id,
4351
- data: timing
4352
- });
4353
- }
4354
- }
4355
- }
4356
- function resolveBurnDeps(ctx) {
4357
- const config2 = loadConfig();
4358
- const token = readTokenFromEnvFile(envPath());
4359
- const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
4360
- const land = createSerialQueue();
4361
- let pushTargetReady;
4362
- const ensureIsolatedPushTarget = () => pushTargetReady ??= allowPushToCheckedOutBranches(ctx.project.repoPath);
4363
- return {
4364
- config: config2,
4365
- hasAuthToken: token !== undefined,
4366
- concurrency: config2.burnConcurrency,
4367
- executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land, ensureIsolatedPushTarget)
4368
- };
4369
- }
4370
- function readTokenFromEnvFile(path) {
4371
- let fromFile;
4372
- if (existsSync7(path)) {
4373
- try {
4374
- fromFile = parseEnvFile(readFileSync3(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
4375
- } catch {
4376
- fromFile = undefined;
4377
- }
4378
- }
4379
- const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
4380
- return token && token.length > 0 ? token : undefined;
4381
- }
4382
- var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
4383
- var init_ticket_burner = __esm(() => {
4384
- init_src();
4385
- init_config_load();
4386
- init_paths();
4387
- init_skills_root();
4388
- init_agent_stream();
4389
- init_git();
4390
- init_burn_guard();
4391
- PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
4392
- PM_CACHE_SANDBOX_PATHS = {
4393
- bun: "~/.bun/install/cache",
4394
- yarn: "~/.cache/yarn",
4395
- npm: "~/.npm"
4396
- };
4397
- RUN = String.raw`(?![\w./-])`;
4398
- BASH_PATTERNS = [
4399
- ["tests", new RegExp(String.raw`\b(vitest|jest|pytest|go test|cargo test|bun test|playwright|maestro)${RUN}|\b(pnpm|npm|yarn|bun|npx)\b[^&;|]*?\s(run\s+)?test${RUN}|--testPathPatterns`)],
4400
- ["typecheck", /\btsc\b|\btypecheck\b|\bmypy\b|\bcargo check\b/],
4401
- ["build", new RegExp(String.raw`\bbuild${RUN}|\bprisma\s+(generate|migrate)\b|\bcodegen${RUN}`)],
4402
- ["lint", new RegExp(String.raw`\b(eslint|prettier|ruff|biome|lint|format)${RUN}`)],
4403
- ["install", /\b(pnpm|npm|yarn|bun|corepack)\s+\w*\s*install\b|\bnpm ci\b/],
4404
- ["git", /\bgit\b/],
4405
- ["search", /\b(grep|rg|ag|find)\b/],
4406
- ["file-read", /\b(cat|sed|head|tail|less|wc|ls)\b/],
4407
- ["file-edit", /<<\s*['"]?(PY|EOF|SH|JS|TS)\b|\bpython3?\s+-\s*<</]
4408
- ];
4409
- TOOL_NAME_CATEGORY = {
4410
- Read: "file-read",
4411
- NotebookRead: "file-read",
4412
- Grep: "search",
4413
- Glob: "search",
4414
- Edit: "file-edit",
4415
- Write: "file-edit",
4416
- NotebookEdit: "file-edit"
4417
- };
4418
- MAX_ATTRIBUTABLE_GAP_MS = 20 * 60000;
4419
- BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
4420
- WORKTREE_REMOVAL_FAILURES = [
4421
- /failed to delete/i,
4422
- /directory not empty|enotempty/i,
4423
- /unable to (unlink|delete)/i,
4424
- /is not a working tree/i,
4425
- /resource busy|ebusy|eperm|eacces/i
4426
- ];
4427
- FATAL_ERROR_PATTERNS = [
4428
- /invalid (api key|x-api-key)/i,
4429
- /authentication|unauthorized|permission denied/i,
4430
- /credit balance|billing/i,
4431
- /oauth token|setup-token/i,
4432
- /issue with the selected model|model not found|unknown model/i,
4433
- /does not support resumeSession|resumeSession .* not found/i
4434
- ];
4435
- RETRYABLE_ERROR_PATTERNS = [
4436
- /exited with code \d+/i,
4437
- /idle timeout|AgentIdleTimeout/i,
4438
- /connection (closed|error|refused|reset)|socket hang up|fetch failed/i,
4439
- /econnreset|etimedout|econnrefused|epipe|eai_again/i,
4440
- /overloaded|rate.?limit|too many requests/i,
4441
- /internal server error|service unavailable|bad gateway|gateway timeout/i,
4442
- /\bapi error\b/i,
4443
- /session capture failed/i
4444
- ];
4445
- activeTicketAborts = new Map;
4446
- SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
4447
- ticketBurner = {
4448
- id: "ticket-burner",
4449
- run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
4450
- };
4451
- });
4452
-
4453
- // src/workflows/project-prep.ts
4454
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync4 } from "fs";
4455
- import { dirname as dirname4, join as join8 } from "path";
4456
- import { fileURLToPath as fileURLToPath3 } from "url";
4457
- import { run as run2 } from "@ai-hero/sandcastle";
4458
- import * as z3 from "zod";
4459
- function parsePrepFindings(raw) {
4460
- let json;
4461
- try {
4462
- json = JSON.parse(raw);
4463
- } catch (e) {
4464
- throw new Error(`findings file is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
4465
- }
4466
- const outer = RawFindings.safeParse(json);
4467
- if (!outer.success)
4468
- throw new Error("findings file is not a JSON object");
4469
- const warnings = [];
4470
- const values = {};
4471
- const known = new Set(PREPARED_KEYS);
4472
- for (const [key, rawValue] of Object.entries(outer.data)) {
4473
- if (key === "notes")
4474
- continue;
4475
- if (!known.has(key)) {
4476
- warnings.push(`ignored unknown key "${key}"`);
4477
- continue;
4478
- }
4479
- const parsed = RawFinding.safeParse(rawValue);
4480
- if (!parsed.success) {
4481
- warnings.push(`ignored "${key}": not a string or {value, evidence} object`);
4482
- continue;
4483
- }
4484
- const entry = parsed.data;
4485
- const value = (typeof entry === "string" ? entry : entry.value ?? "").trim();
4486
- if (value === "")
4487
- continue;
4488
- const evidence = typeof entry === "string" ? undefined : entry.evidence?.trim();
4489
- values[key] = { value, ...evidence ? { evidence } : {} };
4490
- }
4491
- const notes = outer.data.notes?.trim();
4492
- return { findings: { values, ...notes ? { notes } : {} }, warnings };
4493
- }
4494
- function renderPrepPrompt(template, values) {
4495
- let out = template;
4496
- for (const key of PLACEHOLDERS) {
4497
- out = out.split(`{{${key}}}`).join(values[key]);
4498
- }
4499
- return out;
4500
- }
4501
- function buildRequestedKeysBlock(keys) {
4502
- if (keys.length === 0) {
4503
- return "_Everything is already established. Report an empty findings object and stop._";
4504
- }
4505
- const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
4506
- return [...keys].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)).map((k) => `- ${KEY_BRIEF[k]}`).join(`
4507
- `);
4508
- }
4509
- async function prepRun(ctx, deps) {
4510
- if (ctx.keys.length === 0) {
4511
- ctx.emitEvent({
4512
- type: "prep.skipped",
4513
- message: "nothing to prepare \u2014 every field is already set"
4514
- });
4515
- return { status: "succeeded", summary: "nothing to prepare" };
4516
- }
4517
- if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
4518
- ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
4519
- return { status: "failed", summary: "preparation aborted: auth token missing" };
4520
- }
4521
- ctx.emitEvent({
4522
- type: "prep.started",
4523
- message: `preparing ${ctx.project.name} \u2014 establishing ${ctx.keys.join(", ")}`,
4524
- data: { keys: [...ctx.keys] }
4525
- });
4526
- const outcome = await deps.executePrepRun(ctx);
4527
- if (outcome.status === "failed") {
4528
- ctx.emitEvent({
4529
- type: "prep.failed",
4530
- message: `preparation failed: ${errorHeadline2(outcome.error)}`,
4531
- data: { error: outcome.error }
4532
- });
4533
- return { status: "failed", summary: outcome.error };
4534
- }
4535
- for (const warning of outcome.warnings) {
4536
- ctx.emitEvent({ type: "prep.warning", message: `preparation: ${warning}` });
4537
- }
4538
- const found = Object.keys(outcome.findings.values);
4539
- const missed = ctx.keys.filter((k) => !found.includes(k));
4540
- const summary = found.length === 0 ? "preparation established nothing" : `established ${found.join(", ")}${missed.length > 0 ? ` (could not establish ${missed.join(", ")})` : ""}`;
4541
- return { status: "succeeded", summary, findings: outcome.findings };
4542
- }
4543
- function errorHeadline2(s) {
4544
- const lines = s.split(`
4545
- `).map((l) => l.trim()).filter(Boolean);
4546
- const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
4547
- return causes.at(-1) ?? lines[0] ?? "";
4548
- }
4549
- function createPrepStreamThrottle(emit3, opts = {}) {
4550
- return createStreamThrottle((e) => emit3({ ...e, type: e.type.replace(/^burn\./, "prep.") }), opts);
4551
- }
4552
- function nonFatalSetup(setupCommand) {
4553
- return `( ${setupCommand} || true )`;
4554
- }
4555
- function prepTemplatePath() {
4556
- const here = dirname4(fileURLToPath3(import.meta.url));
4557
- return join8(resolveSkillsRoot(here), "burner", "prepare-project.md");
4558
- }
4559
- async function realExecutePrepRun(ctx, config2, token, model) {
4560
- const { project } = ctx;
4561
- const branch = prepBranchName(newId("b").slice(2, 10));
4562
- const toolchain = readRepoToolchain(project.repoPath);
4563
- const pm = detectPackageManager(toolchain);
4564
- const prepared = resolvePreparedSettings(config2, project);
4565
- const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
4566
- const workspaceMode = resolveBurnWorkspaceMode(config2);
4567
- const mounts = [];
4568
- if (config2.sandbox !== "noSandbox" && pm) {
4569
- const mount = cacheMountFor(pm, burnCacheDir(pm));
4570
- if (mount) {
4571
- mkdirSync4(mount.hostPath, { recursive: true });
4572
- mounts.push(mount);
4573
- }
4574
- }
4575
- const template = readFileSync4(prepTemplatePath(), "utf8");
4576
- const prompt = renderPrepPrompt(template, {
4577
- REQUESTED_KEYS: buildRequestedKeysBlock(ctx.keys),
4578
- SETUP_COMMAND: setupCommand ?? "(no install step detected)"
4579
- });
4580
- mkdirSync4(logsDir(), { recursive: true });
4581
- const logFilePath = join8(logsDir(), `prep-${project.id}.log`);
4582
- const throttle = createPrepStreamThrottle((e) => ctx.emitEvent(e));
4583
- if (setupCommand) {
4584
- ctx.emitEvent({
4585
- type: "prep.setup",
4586
- message: `attempting a dependency install before the agent starts (best-effort): ${setupCommand}`
4587
- });
4588
- }
4589
- const prepSetupCommand = setupCommand ? nonFatalSetup(setupCommand) : undefined;
4590
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, prepSetupCommand, pm) : prepSetupCommand;
4591
- const runOptions = {
4592
- agent: buildBurnAgent(config2, token, model),
4593
- sandbox: selectSandbox(config2, mounts),
4594
- cwd: project.repoPath,
4595
- prompt,
4596
- branchStrategy: { type: "branch", branch, baseBranch: project.mainBranch },
4597
- signal: ctx.signal,
4598
- name: "prepare",
4599
- maxIterations: 1,
4600
- ...hookCommand ? {
4601
- hooks: {
4602
- sandbox: {
4603
- onSandboxReady: [{ command: hookCommand, timeoutMs: PREP_SETUP_TIMEOUT_MS }]
4604
- }
4605
- }
4606
- } : {},
4607
- logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
4608
- };
4609
- try {
4610
- let result;
4611
- try {
4612
- result = await run2(runOptions);
4613
- } catch (err) {
4614
- throttle.flush();
4615
- if (ctx.signal.aborted)
4616
- throw err;
4617
- const msg = err instanceof Error ? err.message : String(err);
4618
- const salvaged = await readFindings(project.repoPath, branch);
4619
- if (!salvaged)
4620
- return { status: "failed", error: msg };
4621
- ctx.emitEvent({
4622
- type: "prep.salvaged",
4623
- message: `the agent finished but sandcastle errored on teardown (${errorHeadline2(msg)}) \u2014 reading the findings it committed anyway`
4624
- });
4625
- return salvaged;
4626
- }
4627
- throttle.flush();
4628
- if (result.commits.length === 0) {
4629
- return {
4630
- status: "failed",
4631
- error: `agent produced no commits \u2014 no ${PREP_FINDINGS_PATH} landed`
4632
- };
4633
- }
4634
- const read = await readFindings(project.repoPath, branch);
4635
- return read ?? {
4636
- status: "failed",
4637
- error: `agent committed, but ${PREP_FINDINGS_PATH} is missing from ${branch}`
4638
- };
4639
- } finally {
4640
- await deletePrepBranch(project.repoPath, branch);
4641
- }
4642
- }
4643
- async function readFindings(repoPath, branch) {
4644
- const raw = await readFileAtRef(repoPath, branch, PREP_FINDINGS_PATH);
4645
- if (raw === undefined)
4646
- return;
4647
- try {
4648
- const { findings, warnings } = parsePrepFindings(raw);
4649
- return { status: "done", findings, warnings };
4650
- } catch (e) {
4651
- return { status: "failed", error: e instanceof Error ? e.message : String(e) };
4652
- }
4653
- }
4654
- function resolvePrepDeps(project) {
4655
- const config2 = loadConfig();
4656
- const token = readTokenFromEnvFile(envPath());
4657
- const model = resolveModel("prepare", config2, project);
4658
- return {
4659
- config: config2,
4660
- hasAuthToken: token !== undefined,
4661
- executePrepRun: (c) => realExecutePrepRun(c, config2, token, model)
4662
- };
4663
- }
4664
- async function prepHeadSha(project) {
4665
- return existsSync8(project.repoPath) ? headSha(project.repoPath, project.mainBranch) : undefined;
4666
- }
4667
- var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PREP_FINDINGS_PATH = ".runcastle/prep.json", PREP_SETUP_TIMEOUT_MS, RawFinding, RawFindings, PLACEHOLDERS, KEY_BRIEF;
4668
- var init_project_prep = __esm(() => {
4669
- init_src();
4670
- init_config_load();
4671
- init_paths();
4672
- init_skills_root();
4673
- init_git();
4674
- init_ticket_burner();
4675
- PREP_SETUP_TIMEOUT_MS = 15 * 60000;
4676
- RawFinding = z3.union([
4677
- z3.string(),
4678
- z3.object({
4679
- value: z3.string().nullish(),
4680
- evidence: z3.string().nullish()
4681
- })
4682
- ]);
4683
- RawFindings = z3.object({ notes: z3.string().nullish() }).catchall(z3.unknown());
4684
- PLACEHOLDERS = ["REQUESTED_KEYS", "SETUP_COMMAND"];
4685
- KEY_BRIEF = {
4686
- setupCommand: "**`setupCommand`** \u2014 the command that takes a clean checkout to a state where the code builds and tests run: dependency install, plus any codegen or contract build that every task would otherwise discover it needed mid-flight (ORM client generation, protobuf, a `contracts:build` step). Run it and report what worked.",
4687
- verifyCommands: "**`verifyCommands`** \u2014 the exact typecheck / test / lint commands for this repo, one per line. In a monorepo these must include the correct workspace filter names. Run each one and confirm it executes the thing it claims to.",
4688
- knownFailures: '**`knownFailures`** \u2014 which tests are ALREADY failing on this checkout, before anything is changed. Run the full suite once and report the count plus the failing suite names. "0 known failures" is a real, useful answer.',
4689
- devCommand: "**`devCommand`** \u2014 the command that starts this project's dev server on a developer's own machine. Read it from config; do not run it here.",
4690
- driveSetupCommand: "**`driveSetupCommand`** \u2014 a single shell command that takes this repo from a fresh checkout to \"the dev server would actually work\": backing services up, schema applied, whatever this project needs. Chain steps with `&&`. Read it from the repo's own config (compose file, Makefile, package scripts, README); do not run it here. Omit it if the dev command alone is enough \u2014 an empty answer is better than a guessed one, because this runs on a developer's real machine.",
4691
- driveStopCommand: "**`driveStopCommand`** \u2014 the matching teardown for `driveSetupCommand`, if this project has one (`docker compose down`, a stop script). Read it from config; do not run it here. Omit it if setup leaves nothing that needs stopping.",
4692
- driveEnv: "**`driveEnv`** \u2014 `KEY=VALUE` lines (one per line) overlaid on the dev server's environment during a test drive, with `{{slug}}`, `{{branch}}` and `{{id}}` (an identifier-safe slug) rendered per drive. Its purpose is giving a branch its own database, e.g. `DATABASE_URL=postgres://localhost:5432/myapp_{{id}}` paired with a `driveSetupCommand` that creates it. Report it ONLY if you can read the real connection variable's name and shape out of the repo's own config or `.env.example`; a connection string you assembled from defaults points at the wrong database convincingly, which is worse than omitting the key. Omit it if the project has no database or you had to guess the host, port or credentials.",
4693
- dbResetCommand: "**`dbResetCommand`** \u2014 the command that rebuilds the dev database from the migrations currently in the working tree. Read it from config; do not run it here. Omit it entirely if this repo has no database."
4694
- };
4695
- });
4696
-
4697
- // src/services/findings.ts
4698
- import { and as and5, eq as eq5 } from "drizzle-orm";
4699
- function isPreparedKey(key) {
4700
- return PREPARED_SET.has(key);
4701
- }
4702
- function findingRow(ctx, projectId, key) {
4703
- return ctx.db.select({
4704
- source: projectFindings.source,
4705
- evidence: projectFindings.evidence,
4706
- establishedAt: projectFindings.establishedAt,
4707
- establishedSha: projectFindings.establishedSha
4708
- }).from(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).get();
4709
- }
4710
- function isOverwritable(ctx, projectId, key) {
4711
- return findingRow(ctx, projectId, key)?.source !== "human";
4712
- }
4713
- function recordFinding(ctx, projectId, input) {
4714
- const { key, value, source, evidence, establishedSha } = input;
4715
- ctx.db.transaction((tx) => {
4716
- tx.update(projects).set({ [COLUMN_NAME[key]]: value }).where(eq5(projects.id, projectId)).run();
4717
- if (value === null) {
4718
- tx.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
4719
- return;
4720
- }
4721
- tx.insert(projectFindings).values({
4722
- projectId,
4723
- key,
4724
- source,
4725
- evidence: evidence ?? null,
4726
- establishedAt: Date.now(),
4727
- establishedSha: establishedSha ?? null
4728
- }).onConflictDoUpdate({
4729
- target: [projectFindings.projectId, projectFindings.key],
4730
- set: {
4731
- source,
4732
- evidence: evidence ?? null,
4733
- establishedAt: Date.now(),
4734
- establishedSha: establishedSha ?? null
4735
- }
4736
- }).run();
4737
- });
4738
- }
4739
- function recordHuman(ctx, projectId, key, value) {
4740
- if (!isPreparedKey(key))
4741
- return;
4742
- const existing = findingRow(ctx, projectId, key);
4743
- if (value === null) {
4744
- if (existing) {
4745
- ctx.db.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
4746
- }
4747
- return;
4748
- }
4749
- ctx.db.insert(projectFindings).values({
4750
- projectId,
4751
- key,
4752
- source: "human",
4753
- evidence: null,
4754
- establishedAt: Date.now(),
4755
- establishedSha: null
4756
- }).onConflictDoUpdate({
4757
- target: [projectFindings.projectId, projectFindings.key],
4758
- set: { source: "human", evidence: null, establishedAt: Date.now(), establishedSha: null }
4759
- }).run();
4760
- }
4761
- async function listFindings(ctx, project) {
4762
- const rows = ctx.db.select().from(projectFindings).where(eq5(projectFindings.projectId, project.id)).all();
4763
- const distances = new Map;
4764
- for (const row of rows) {
4765
- const sha = row.establishedSha;
4766
- if (!sha || distances.has(sha))
4767
- continue;
4768
- distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
4769
- }
4770
- const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
4771
- return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
4772
- const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
4773
- return {
4774
- key: row.key,
4775
- source: row.source,
4776
- ...row.evidence ? { evidence: row.evidence } : {},
4777
- establishedAt: row.establishedAt,
4778
- ...row.establishedSha ? { establishedSha: row.establishedSha } : {},
4779
- ...staleCommits !== undefined ? { staleCommits } : {}
4780
- };
4781
- });
4782
- }
4783
- function unsetPreparedKeys(project) {
4784
- return PREPARED_KEYS.filter((key) => {
4785
- const value = project[key];
4786
- return value === undefined || value.trim() === "";
4787
- });
4788
- }
4789
- var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET;
4790
- var init_findings = __esm(() => {
4791
- init_src();
4792
- init_schema();
4793
- init_git();
4794
- VALUE_COLUMN = {
4795
- setupCommand: projects.setupCommand,
4796
- verifyCommands: projects.verifyCommands,
4797
- knownFailures: projects.knownFailures,
4798
- devCommand: projects.devCommand,
4799
- driveSetupCommand: projects.driveSetupCommand,
4800
- driveStopCommand: projects.driveStopCommand,
4801
- driveEnv: projects.driveEnv,
4802
- dbResetCommand: projects.dbResetCommand
4803
- };
4804
- COLUMN_NAME = {
4805
- setupCommand: "setupCommand",
4806
- verifyCommands: "verifyCommands",
4807
- knownFailures: "knownFailures",
4808
- devCommand: "devCommand",
4809
- driveSetupCommand: "driveSetupCommand",
4810
- driveStopCommand: "driveStopCommand",
4811
- driveEnv: "driveEnv",
4812
- dbResetCommand: "dbResetCommand"
4813
- };
4814
- PREPARED_SET = new Set(PREPARED_KEYS);
4815
- });
4816
-
4817
- // src/services/prep.ts
4818
- import { and as and6, desc as desc4, eq as eq6 } from "drizzle-orm";
4819
- function isPreparing(projectId) {
4820
- return controllers.has(projectId);
4821
- }
4822
- function keysToPrepare(ctx, project, opts = {}) {
4823
- const unset = new Set(unsetPreparedKeys(project));
4824
- const candidates = opts.keys ?? (opts.refresh ? PREPARED_KEYS : [...unset]);
4825
- return candidates.filter((key) => (opts.refresh || unset.has(key)) && isOverwritable(ctx, project.id, key));
4826
- }
4827
- function shouldAutoPrepare(ctx, project) {
4828
- if (!ctx.config.autoPrepare)
4829
- return false;
4830
- if (latestPrep(ctx, project.id))
4831
- return false;
4832
- return keysToPrepare(ctx, project).length > 0;
4833
- }
4834
- async function startPrep(ctx, projectId, opts = {}) {
4835
- const project = requireProjectById(ctx, projectId);
4836
- if (controllers.has(projectId)) {
4837
- throw new InvalidInputError(`${project.name} is already being prepared`);
4838
- }
4839
- const keys = keysToPrepare(ctx, project, opts);
4840
- const prepId = newId("prep");
4841
- const headSha2 = await prepHeadSha(project);
4842
- ctx.db.insert(projectPreps).values({
4843
- id: prepId,
4844
- projectId,
4845
- status: "running",
4846
- startedAt: Date.now(),
4847
- endedAt: null,
4848
- summary: null,
4849
- headSha: headSha2 ?? null
4850
- }).run();
4851
- emitProject(ctx, projectId, {
4852
- type: "prep.run.started",
4853
- message: keys.length === 0 ? `preparing ${project.name} \u2014 nothing left to establish` : `preparing ${project.name} \u2014 ${keys.length} field(s) to establish`,
4854
- data: { prepId, keys }
4855
- });
4856
- const controller = new AbortController;
4857
- controllers.set(projectId, { runId: prepId, controller });
4858
- const prepCtx = {
4859
- project,
4860
- keys,
4861
- emitEvent: (e) => {
4862
- emitProject(ctx, projectId, { type: e.type, message: e.message, data: e.data });
4863
- },
4864
- signal: controller.signal
4865
- };
4866
- const deps = opts.deps ?? resolvePrepDeps(project);
4867
- const done = finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2);
4868
- return { prepId, keys, done };
4869
- }
4870
- function cancelPrep(projectId) {
4871
- controllers.get(projectId)?.controller.abort();
4872
- }
4873
- async function finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2) {
4874
- let status = "failed";
4875
- let summary = "preparation failed";
4876
- let findings;
4877
- try {
4878
- const result = await prepRun(prepCtx, deps);
4879
- status = result.status === "succeeded" ? "succeeded" : "failed";
4880
- summary = result.summary;
4881
- findings = result.findings;
4882
- } catch (e) {
4883
- if (controller.signal.aborted) {
4884
- status = "cancelled";
4885
- summary = "preparation cancelled";
4886
- } else {
4887
- status = "failed";
4888
- summary = e instanceof Error ? e.message : "preparation failed";
4889
- }
4890
- } finally {
4891
- controllers.delete(project.id);
4892
- }
4893
- const applied = findings ? applyFindings(ctx, project, findings, headSha2) : [];
4894
- ctx.db.update(projectPreps).set({ status, endedAt: Date.now(), summary }).where(eq6(projectPreps.id, prepId)).run();
4895
- emitProject(ctx, project.id, {
4896
- type: "prep.run.finished",
4897
- message: `preparation ${status}: ${summary}`,
4898
- data: { prepId, status, summary, applied }
4899
- });
4900
- }
4901
- function applyFindings(ctx, project, findings, headSha2) {
4902
- const applied = [];
4903
- for (const [key, finding] of Object.entries(findings.values)) {
4904
- if (!isOverwritable(ctx, project.id, key)) {
4905
- emitProject(ctx, project.id, {
4906
- type: "prep.skipped_field",
4907
- message: `kept your ${key} \u2014 preparation proposed "${truncate(finding.value)}" but you set this by hand`,
4908
- data: { key, proposed: finding.value }
4909
- });
4910
- continue;
4911
- }
4912
- recordFinding(ctx, project.id, {
4913
- key,
4914
- value: finding.value,
4915
- source: "prep",
4916
- ...finding.evidence ? { evidence: finding.evidence } : {},
4917
- ...headSha2 ? { establishedSha: headSha2 } : {}
4918
- });
4919
- applied.push(key);
4920
- emitProject(ctx, project.id, {
4921
- type: "prep.established",
4922
- message: `${key}: ${truncate(finding.value)}`,
4923
- data: { key, value: finding.value, evidence: finding.evidence }
4924
- });
4925
- }
4926
- if (findings.notes) {
4927
- emitProject(ctx, project.id, {
4928
- type: "prep.notes",
4929
- message: `preparation notes: ${findings.notes}`,
4930
- data: { notes: findings.notes }
4931
- });
4932
- }
4933
- return applied;
4934
- }
4935
- function truncate(value, max = 120) {
4936
- const oneLine = value.replace(/\s*\n\s*/g, " \xB7 ").trim();
4937
- return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
4938
- }
4939
- function latestPrep(ctx, projectId) {
4940
- const row = ctx.db.select().from(projectPreps).where(eq6(projectPreps.projectId, projectId)).orderBy(desc4(projectPreps.startedAt)).get();
4941
- if (!row)
4942
- return null;
4943
- return {
4944
- id: row.id,
4945
- projectId: row.projectId,
4946
- status: row.status,
4947
- startedAt: row.startedAt,
4948
- ...row.endedAt !== null ? { endedAt: row.endedAt } : {},
4949
- ...row.summary !== null ? { summary: row.summary } : {},
4950
- ...row.headSha !== null ? { headSha: row.headSha } : {}
4951
- };
4952
- }
4953
- function latestPrepNotes(ctx, projectId) {
4954
- const row = ctx.db.select({ data: events.data }).from(events).where(and6(eq6(events.projectId, projectId), eq6(events.type, "prep.notes"))).orderBy(desc4(events.id)).get();
4955
- const notes = row?.data?.notes;
4956
- return typeof notes === "string" && notes.trim() !== "" ? notes : undefined;
4957
- }
4958
- function reconcilePreps(ctx) {
4959
- const stale = ctx.db.select({ id: projectPreps.id, projectId: projectPreps.projectId }).from(projectPreps).where(eq6(projectPreps.status, "running")).all().filter((row) => !controllers.has(row.projectId));
4960
- for (const row of stale) {
4961
- ctx.db.update(projectPreps).set({ status: "failed", endedAt: Date.now(), summary: "server stopped while preparing" }).where(eq6(projectPreps.id, row.id)).run();
4962
- }
4963
- return stale.length;
4964
- }
4965
- var controllers;
4966
- var init_prep = __esm(() => {
4967
- init_src();
4968
- init_schema();
4969
- init_errors();
4970
- init_project_prep();
4971
- init_events();
4972
- init_findings();
4973
- init_repo();
4974
- controllers = new Map;
4975
- });
4976
-
4977
- // src/launcher/runtime.ts
4978
- function setRuntimeCtx(ctx) {
4979
- current = ctx;
4980
- }
4981
- async function getRuntimeCtx() {
4982
- if (current)
4983
- return current;
4984
- const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
4985
- Promise.resolve().then(() => (init_client(), exports_client)),
4986
- Promise.resolve().then(() => (init_paths(), exports_paths)),
4987
- Promise.resolve().then(() => (init_config_load(), exports_config_load))
4988
- ]);
4989
- const db = createDb2(dbPath2());
4990
- current = { db, config: loadConfig2() };
4991
- return current;
4992
- }
4993
- var current = null;
4994
-
4995
- // src/services/feature-docs.ts
4996
- import { existsSync as existsSync9 } from "fs";
4997
- import { join as join9 } from "path";
4998
- function featureDocsDir(project, feature) {
4999
- const relSegments = featureDocsRel(feature.slug).split("/");
5000
- const worktree = worktreeDir(project.id, feature.slug);
5001
- const base = existsSync9(worktree) ? worktree : project.repoPath;
5002
- return join9(base, ...relSegments);
5003
- }
5004
- function featureDocPath(project, feature, fileName) {
5005
- return join9(featureDocsDir(project, feature), fileName);
5006
- }
5007
- var init_feature_docs = __esm(() => {
5008
- init_paths();
5009
- });
5010
-
5011
- // src/services/tickets.ts
5012
- import { and as and7, asc as asc3, eq as eq7 } from "drizzle-orm";
5013
- function rowToTicket(row) {
5014
- return {
5015
- id: row.id,
5016
- featureId: row.featureId,
5017
- seq: row.seq,
5018
- title: row.title,
5019
- goal: row.goal,
5020
- context: row.context,
5021
- acceptanceCriteria: row.acceptanceCriteria,
5022
- seams: row.seams,
5023
- blockedBy: row.blockedBy,
5024
- lap: row.lap,
5025
- status: row.status,
5026
- commits: row.commits,
5027
- error: row.error ?? undefined,
5028
- attemptBranch: row.attemptBranch ?? undefined,
5029
- conflictFiles: row.conflictFiles ?? undefined
5030
- };
5031
- }
5032
- function resolveBlocking(inputs, startSeq) {
5033
- try {
5034
- return resolveBatchBlocking(inputs, { startSeq });
5035
- } catch (e) {
5036
- if (e instanceof BlockingEdgeError)
5037
- throw new InvalidInputError(e.message);
5038
- throw e;
5039
- }
5040
- }
5041
- function listByFeature2(ctx, featureId) {
5042
- return ctx.db.select().from(tickets).where(eq7(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
5043
- }
5044
- function storeTickets(ctx, featureId, inputs) {
5045
- if (inputs.length === 0)
5046
- return [];
5047
- const { lap } = getFeatureRow(ctx, featureId);
5048
- const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq7(tickets.featureId, featureId)).all();
5049
- const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
5050
- const resolved = resolveBlocking(inputs, startSeq);
5051
- const rows = inputs.map((t, i) => ({
5052
- id: newId("tkt"),
5053
- featureId,
5054
- seq: resolved[i].seq,
5055
- title: t.title,
5056
- goal: t.goal,
5057
- context: t.context,
5058
- acceptanceCriteria: t.acceptanceCriteria,
5059
- seams: t.seams,
5060
- blockedBy: resolved[i].blockedBy,
5061
- lap,
5062
- status: "pending",
5063
- commits: [],
5064
- error: null,
5065
- attemptBranch: null,
5066
- conflictFiles: null
5067
- }));
5068
- ctx.db.insert(tickets).values(rows).run();
5069
- emit(ctx, featureId, {
5070
- type: "tickets.stored",
5071
- message: `${rows.length} ticket(s) stored`,
5072
- data: { count: rows.length, seqs: rows.map((r) => r.seq) }
5073
- });
5074
- return rows.map(rowToTicket);
5075
- }
5076
- function getTicket(ctx, id) {
5077
- const row = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
5078
- if (!row)
5079
- throw new NotFoundError(`ticket ${id} not found`);
5080
- return rowToTicket(row);
5081
- }
5082
- function assertMutable(t, verb) {
5083
- if (!MUTABLE_STATUSES.has(t.status)) {
5084
- throw new InvalidInputError(`cannot ${verb} ticket ${t.seq} \u2014 it is ${t.status}; only pending or failed tickets can be ${verb}${verb.endsWith("l") ? "led" : "ed"}`);
5085
- }
5086
- }
5087
- function editTicket(ctx, id, patch) {
5088
- const current2 = getTicket(ctx, id);
5089
- assertMutable(current2, "edit");
5090
- const set = {};
5091
- if (patch.title !== undefined)
5092
- set.title = patch.title;
5093
- if (patch.goal !== undefined)
5094
- set.goal = patch.goal;
5095
- if (patch.context !== undefined)
5096
- set.context = patch.context;
5097
- if (patch.acceptanceCriteria !== undefined)
5098
- set.acceptanceCriteria = patch.acceptanceCriteria;
5099
- if (patch.seams !== undefined)
5100
- set.seams = patch.seams;
5101
- if (Object.keys(set).length === 0) {
5102
- throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
5103
- }
5104
- ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
5105
- emit(ctx, current2.featureId, {
5106
- type: "ticket.edited",
5107
- message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
5108
- ticketId: id,
5109
- data: { fields: Object.keys(set) }
5110
- });
5111
- return getTicket(ctx, id);
5112
- }
5113
- function cancelTicket(ctx, id, reason) {
5114
- const current2 = getTicket(ctx, id);
5115
- assertMutable(current2, "cancel");
5116
- ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq7(tickets.id, id)).run();
5117
- emit(ctx, current2.featureId, {
5118
- type: "ticket.cancelled",
5119
- message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
5120
- ticketId: id,
5121
- data: { reason: reason ?? null }
5122
- });
5123
- return getTicket(ctx, id);
5124
- }
5125
- function sweepOrphanedBurning(ctx, featureId, reason) {
5126
- const orphaned = ctx.db.select().from(tickets).where(and7(eq7(tickets.featureId, featureId), eq7(tickets.status, "burning"))).all().map(rowToTicket);
5127
- for (const t of orphaned) {
5128
- updateTicket(ctx, t.id, { status: "failed", error: reason });
5129
- emit(ctx, featureId, {
5130
- type: "ticket.failed",
5131
- message: `ticket ${t.seq} failed: ${reason}`,
5132
- ticketId: t.id,
5133
- data: { error: reason, orphaned: true }
5134
- });
5135
- }
5136
- return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
5137
- }
5138
- function updateTicket(ctx, id, patch) {
5139
- const current2 = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
5140
- if (!current2)
5141
- throw new NotFoundError(`ticket ${id} not found`);
5142
- const set = {};
5143
- if (patch.status !== undefined)
5144
- set.status = patch.status;
5145
- if (patch.commits !== undefined)
5146
- set.commits = patch.commits;
5147
- if (patch.error !== undefined)
5148
- set.error = patch.error;
5149
- if (patch.attemptBranch !== undefined)
5150
- set.attemptBranch = patch.attemptBranch;
5151
- if (patch.conflictFiles !== undefined)
5152
- set.conflictFiles = patch.conflictFiles;
5153
- ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
5154
- emit(ctx, current2.featureId, {
5155
- type: "ticket.updated",
5156
- message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
5157
- ticketId: id,
5158
- data: patch
5159
- });
5160
- const updated = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
5161
- return rowToTicket(updated);
5162
- }
5163
- var MUTABLE_STATUSES;
5164
- var init_tickets = __esm(() => {
5165
- init_src();
5166
- init_schema();
5167
- init_errors();
5168
- init_events();
5169
- init_repo();
5170
- MUTABLE_STATUSES = new Set(["pending", "failed"]);
5171
- });
5172
-
5173
- // src/services/gates.ts
5174
- import { existsSync as existsSync10 } from "fs";
5175
- function checkGate(ctx, check, feature) {
5176
- switch (check) {
5177
- case "decisions-file-exists":
5178
- return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
5179
- case "all-waypoints-terminal": {
5180
- const wps = listByFeature(ctx, feature.id);
5181
- if (wps.length === 0) {
5182
- return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
4685
+ \u2014 attempt ${attempt} interrupted (${headline}); starting attempt ${attempt + 1}${salvaged.length > 0 ? " with committed work preserved" : ""} \u2014
4686
+ `
4687
+ });
4688
+ await delayUnlessAborted(retryDelayMs(attempt), signal);
4689
+ ctx.signal.throwIfAborted();
4690
+ if (ticketAbort.signal.aborted) {
4691
+ if (salvaged.length > 0)
4692
+ preserveChain(tempBranch);
4693
+ return {
4694
+ status: "failed",
4695
+ error: `stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved; retry to continue from them` : ""}`,
4696
+ event: {
4697
+ type: "ticket.stopped",
4698
+ message: `ticket ${ticket.seq} stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved for retry` : ""}`
4699
+ }
4700
+ };
4701
+ }
4702
+ continue;
4703
+ }
4704
+ if (salvaged.length > 0)
4705
+ preserveChain(tempBranch);
4706
+ return { status: "failed", error: msg };
5183
4707
  }
5184
- const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
5185
- return open.length === 0 ? { satisfied: true } : {
5186
- satisfied: false,
5187
- reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
5188
- };
5189
4708
  }
5190
- case "spec-file-exists":
5191
- return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
5192
- case "tickets-approved": {
5193
- const count = listByFeature2(ctx, feature.id).filter((t) => t.lap === feature.lap && t.status !== "cancelled").length;
5194
- return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
4709
+ if (!result)
4710
+ return { status: "failed", error: "internal: run loop produced no result" };
4711
+ const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
4712
+ const chain = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
4713
+ let outcome;
4714
+ if (result.commits.length === 0 && blocked !== undefined && blocked.trim().length > 0) {
4715
+ outcome = { status: "failed", error: `agent reported BLOCKED:
4716
+ ${blocked.trim()}` };
4717
+ } else if (chain.length > 0) {
4718
+ outcome = { status: "done", commits: chain };
4719
+ } else {
4720
+ outcome = interpretRunResult(result, blocked);
5195
4721
  }
5196
- case "all-tickets-terminal": {
5197
- const tickets2 = listByFeature2(ctx, feature.id);
5198
- if (tickets2.length === 0)
5199
- return { satisfied: false, reason: "no tickets have run yet" };
5200
- const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
5201
- return open.length === 0 ? { satisfied: true } : {
5202
- satisfied: false,
5203
- reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
5204
- };
4722
+ if (outcome.status !== "done") {
4723
+ if (chain.length > 0)
4724
+ preserveChain(tempBranch);
4725
+ return outcome;
4726
+ }
4727
+ return await landChain(tempBranch, outcome.commits);
4728
+ } finally {
4729
+ activeTicketAborts.delete(ticket.id);
4730
+ throttle.flush();
4731
+ endTranscript(ticket.id);
4732
+ const timing = timer.summary();
4733
+ if (timing.calls > 0) {
4734
+ ctx.emitEvent({
4735
+ type: "ticket.timing",
4736
+ message: `ticket ${ticket.seq} spent ${formatTimingSummary(timing)}`,
4737
+ ticketId: ticket.id,
4738
+ data: timing
4739
+ });
5205
4740
  }
5206
- case "human-merge":
5207
- return { satisfied: false, reason: "use the Merge button to ship" };
5208
- }
5209
- }
5210
- function notYetTerminal(noun, statuses, order) {
5211
- const counts = new Map;
5212
- for (const s of statuses)
5213
- counts.set(s, (counts.get(s) ?? 0) + 1);
5214
- const ordered = [
5215
- ...order.filter((s) => counts.has(s)),
5216
- ...[...counts.keys()].filter((s) => !order.includes(s))
5217
- ];
5218
- const breakdown = ordered.map((s) => `${counts.get(s)} ${s}`).join(", ");
5219
- return `${statuses.length} ${statuses.length === 1 ? noun : `${noun}s`} not yet terminal (${breakdown})`;
5220
- }
5221
- function fileGate(ctx, feature, fileName, reason) {
5222
- const project = projectForFeature(ctx, feature);
5223
- return existsSync10(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
5224
- }
5225
- function overrideGate(ctx, featureId, gate, reason) {
5226
- const feature = getFeatureRow(ctx, featureId);
5227
- ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
5228
- emit(ctx, featureId, {
5229
- type: "gate.overridden",
5230
- message: `gate ${gate} overridden: ${reason}`,
5231
- data: { gate, reason }
5232
- });
5233
- const next = nextPhase(feature);
5234
- if (next)
5235
- return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
5236
- return feature;
5237
- }
5238
- var init_gates = __esm(() => {
5239
- init_src();
5240
- init_schema();
5241
- init_events();
5242
- init_feature_docs();
5243
- init_repo();
5244
- init_tickets();
5245
- init_waypoints();
5246
- });
5247
-
5248
- // src/services/knowledge.ts
5249
- import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync } from "fs";
5250
- import { join as join10, relative, resolve as resolve6, sep } from "path";
5251
- function scaffoldDocs(ctx, feature, opts = {}) {
5252
- const project = projectForFeature(ctx, feature);
5253
- const dir = featureDocsDir(project, feature);
5254
- mkdirSync5(dir, { recursive: true });
5255
- const briefPath = featureDocPath(project, feature, "brief.md");
5256
- if (!existsSync11(briefPath)) {
5257
- const override = opts.brief?.trim();
5258
- const created = new Date().toISOString();
5259
- const brief = override ? `${override}
5260
- ` : [
5261
- `# ${feature.title}`,
5262
- "",
5263
- feature.oneLiner,
5264
- "",
5265
- `- Slug: ${feature.slug}`,
5266
- `- Created: ${created}`,
5267
- ""
5268
- ].join(`
5269
- `);
5270
- writeFileSync(briefPath, brief, "utf8");
5271
- }
5272
- if (feature.mapped) {
5273
- scaffoldMapDoc(project, feature);
5274
4741
  }
5275
- emit(ctx, feature.id, {
5276
- type: "docs.scaffolded",
5277
- message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
5278
- });
5279
- }
5280
- function scaffoldMapDoc(project, feature, seed) {
5281
- mkdirSync5(featureDocsDir(project, feature), { recursive: true });
5282
- const mapPath = featureDocPath(project, feature, "map.md");
5283
- if (existsSync11(mapPath))
5284
- return;
5285
- writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
5286
4742
  }
5287
- function mapDocBody(feature, seed) {
5288
- const seeded = {
5289
- Destination: seed?.destination?.trim(),
5290
- Notes: seed?.notes?.trim()
4743
+ function resolveBurnDeps(ctx) {
4744
+ const config2 = loadConfig();
4745
+ const token = readTokenFromEnvFile(envPath());
4746
+ const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
4747
+ const land = createSerialQueue();
4748
+ let pushTargetReady;
4749
+ const ensureIsolatedPushTarget = () => pushTargetReady ??= allowPushToCheckedOutBranches(ctx.project.repoPath);
4750
+ return {
4751
+ config: config2,
4752
+ hasAuthToken: token !== undefined,
4753
+ concurrency: config2.burnConcurrency,
4754
+ executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land, ensureIsolatedPushTarget)
5291
4755
  };
5292
- const lines = [`# ${feature.title} \u2014 map`, ""];
5293
- for (const section of MAP_SECTIONS) {
5294
- lines.push(`## ${section}`, "");
5295
- const body = seeded[section];
5296
- if (body)
5297
- lines.push(body, "");
5298
- }
5299
- return lines.join(`
5300
- `);
5301
4756
  }
5302
- function readCharter(repoRoot) {
5303
- const path = join10(repoRoot, CHARTER_FILE);
5304
- if (!existsSync11(path))
5305
- return;
5306
- try {
5307
- return readFileSync5(path, "utf8");
5308
- } catch {
5309
- return;
5310
- }
5311
- }
5312
- function listLiveAdrs(repoRoot) {
5313
- const dir = join10(repoRoot, ADR_DIR_REL);
5314
- if (!existsSync11(dir))
5315
- return [];
5316
- const docs = [];
5317
- for (const entry of readdirSync3(dir, { withFileTypes: true })) {
5318
- if (!entry.isFile() || !entry.name.endsWith(".md"))
5319
- continue;
5320
- let content;
4757
+ function readTokenFromEnvFile(path) {
4758
+ let fromFile;
4759
+ if (existsSync10(path)) {
5321
4760
  try {
5322
- content = readFileSync5(join10(dir, entry.name), "utf8");
4761
+ fromFile = parseEnvFile(readFileSync4(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
5323
4762
  } catch {
5324
- continue;
4763
+ fromFile = undefined;
5325
4764
  }
5326
- if (SUPERSEDED_RE.test(content))
5327
- continue;
5328
- docs.push({ relPath: `${ADR_DIR_REL}/${entry.name}`, content });
5329
- }
5330
- return docs.sort((a, b) => a.relPath.localeCompare(b.relPath));
5331
- }
5332
- function listDocs(ctx, feature) {
5333
- const project = projectForFeature(ctx, feature);
5334
- const dir = featureDocsDir(project, feature);
5335
- if (!existsSync11(dir))
5336
- return [];
5337
- return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => ({ relPath: e.name, title: readTitle(join10(dir, e.name), e.name) })).sort((a, b) => a.relPath.localeCompare(b.relPath));
5338
- }
5339
- function readDoc(ctx, feature, relPath) {
5340
- const project = projectForFeature(ctx, feature);
5341
- const dir = featureDocsDir(project, feature);
5342
- const root = resolve6(dir);
5343
- const target = resolve6(dir, relPath);
5344
- const rel = relative(root, target);
5345
- if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
5346
- throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
5347
- }
5348
- if (!existsSync11(target))
5349
- throw new NotFoundError(`doc not found: ${relPath}`);
5350
- return { content: readFileSync5(target, "utf8") };
5351
- }
5352
- function readTitle(path, fallback) {
5353
- try {
5354
- const firstLine = readFileSync5(path, "utf8").split(`
5355
- `, 1)[0] ?? "";
5356
- const heading = firstLine.match(/^#\s+(.+)$/);
5357
- return heading ? heading[1].trim() : fallback;
5358
- } catch {
5359
- return fallback;
5360
4765
  }
4766
+ const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
4767
+ return token && token.length > 0 ? token : undefined;
5361
4768
  }
5362
- var MAP_SECTIONS, CHARTER_FILE = "CONTEXT.md", ADR_DIR_REL = "docs/adr", SUPERSEDED_RE;
5363
- var init_knowledge = __esm(() => {
5364
- init_errors();
5365
- init_events();
5366
- init_feature_docs();
5367
- init_repo();
5368
- MAP_SECTIONS = [
5369
- "Destination",
5370
- "Notes",
5371
- "Not yet specified",
5372
- "Out of scope"
4769
+ var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
4770
+ var init_ticket_burner = __esm(() => {
4771
+ init_src();
4772
+ init_config_load();
4773
+ init_paths();
4774
+ init_skills_root();
4775
+ init_agent_stream();
4776
+ init_git();
4777
+ init_burn_guard();
4778
+ PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
4779
+ PM_CACHE_SANDBOX_PATHS = {
4780
+ bun: "~/.bun/install/cache",
4781
+ yarn: "~/.cache/yarn",
4782
+ npm: "~/.npm"
4783
+ };
4784
+ RUN = String.raw`(?![\w./-])`;
4785
+ BASH_PATTERNS = [
4786
+ ["tests", new RegExp(String.raw`\b(vitest|jest|pytest|go test|cargo test|bun test|playwright|maestro)${RUN}|\b(pnpm|npm|yarn|bun|npx)\b[^&;|]*?\s(run\s+)?test${RUN}|--testPathPatterns`)],
4787
+ ["typecheck", /\btsc\b|\btypecheck\b|\bmypy\b|\bcargo check\b/],
4788
+ ["build", new RegExp(String.raw`\bbuild${RUN}|\bprisma\s+(generate|migrate)\b|\bcodegen${RUN}`)],
4789
+ ["lint", new RegExp(String.raw`\b(eslint|prettier|ruff|biome|lint|format)${RUN}`)],
4790
+ ["install", /\b(pnpm|npm|yarn|bun|corepack)\s+\w*\s*install\b|\bnpm ci\b/],
4791
+ ["git", /\bgit\b/],
4792
+ ["search", /\b(grep|rg|ag|find)\b/],
4793
+ ["file-read", /\b(cat|sed|head|tail|less|wc|ls)\b/],
4794
+ ["file-edit", /<<\s*['"]?(PY|EOF|SH|JS|TS)\b|\bpython3?\s+-\s*<</]
4795
+ ];
4796
+ TOOL_NAME_CATEGORY = {
4797
+ Read: "file-read",
4798
+ NotebookRead: "file-read",
4799
+ Grep: "search",
4800
+ Glob: "search",
4801
+ Edit: "file-edit",
4802
+ Write: "file-edit",
4803
+ NotebookEdit: "file-edit"
4804
+ };
4805
+ MAX_ATTRIBUTABLE_GAP_MS = 20 * 60000;
4806
+ BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
4807
+ WORKTREE_REMOVAL_FAILURES = [
4808
+ /failed to delete/i,
4809
+ /directory not empty|enotempty/i,
4810
+ /unable to (unlink|delete)/i,
4811
+ /is not a working tree/i,
4812
+ /resource busy|ebusy|eperm|eacces/i
5373
4813
  ];
5374
- SUPERSEDED_RE = /superseded by ADR-\d+/i;
5375
- });
5376
-
5377
- // src/pty/end-session.ts
5378
- function endSession(ctx, sessionId) {
5379
- const registry = ptyRegistry();
5380
- const killed = registry.kill(sessionId);
5381
- registry.remove(sessionId);
5382
- const session = markSessionEnded(ctx, sessionId);
5383
- if (session) {
5384
- releaseForSession(ctx, sessionId);
5385
- landProjectSession(ctx, session);
5386
- emitForSession(ctx, session, {
5387
- type: "session.ended",
5388
- message: "session ended by user",
5389
- data: { sessionId, killed }
5390
- });
5391
- } else {
5392
- const existing = getSessionRow(ctx, sessionId);
5393
- if (!existing)
5394
- return { sessionId, ok: false, killed };
5395
- }
5396
- return { sessionId, ok: true, killed };
5397
- }
5398
- var init_end_session = __esm(() => {
5399
- init_sessions();
5400
- init_events();
5401
- init_waypoints();
5402
- init_registry();
4814
+ FATAL_ERROR_PATTERNS = [
4815
+ /invalid (api key|x-api-key)/i,
4816
+ /authentication|unauthorized|permission denied/i,
4817
+ /credit balance|billing/i,
4818
+ /oauth token|setup-token/i,
4819
+ /issue with the selected model|model not found|unknown model/i,
4820
+ /does not support resumeSession|resumeSession .* not found/i
4821
+ ];
4822
+ RETRYABLE_ERROR_PATTERNS = [
4823
+ /exited with code \d+/i,
4824
+ /idle timeout|AgentIdleTimeout/i,
4825
+ /connection (closed|error|refused|reset)|socket hang up|fetch failed/i,
4826
+ /econnreset|etimedout|econnrefused|epipe|eai_again/i,
4827
+ /overloaded|rate.?limit|too many requests/i,
4828
+ /internal server error|service unavailable|bad gateway|gateway timeout/i,
4829
+ /\bapi error\b/i,
4830
+ /session capture failed/i
4831
+ ];
4832
+ activeTicketAborts = new Map;
4833
+ SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
4834
+ ticketBurner = {
4835
+ id: "ticket-burner",
4836
+ run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
4837
+ };
5403
4838
  });
5404
4839
 
5405
4840
  // src/workflows/research.ts
5406
- import { existsSync as existsSync12, mkdirSync as mkdirSync6, readFileSync as readFileSync6, readdirSync as readdirSync4 } from "fs";
5407
- import { dirname as dirname5, join as join11 } from "path";
5408
- import { fileURLToPath as fileURLToPath4 } from "url";
5409
- import { run as run3 } from "@ai-hero/sandcastle";
4841
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync4 } from "fs";
4842
+ import { dirname as dirname4, join as join10 } from "path";
4843
+ import { fileURLToPath as fileURLToPath3 } from "url";
4844
+ import { run as run2 } from "@ai-hero/sandcastle";
5410
4845
  import { docker as docker2 } from "@ai-hero/sandcastle/sandboxes/docker";
5411
4846
  import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
5412
4847
  function waypointSlug(waypoint) {
@@ -5423,7 +4858,7 @@ async function researchRun(ctx, deps) {
5423
4858
  return { status: "failed", summary: "no waypoint to research" };
5424
4859
  }
5425
4860
  if (deps.config.sandbox === "docker" && !deps.hasAuthToken) {
5426
- ctx.emitEvent({ type: AUTH_MISSING_EVENT3, message: AUTH_MISSING_MESSAGE3 });
4861
+ ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
5427
4862
  return { status: "failed", summary: "research aborted: auth token missing" };
5428
4863
  }
5429
4864
  ctx.emitEvent({
@@ -5444,12 +4879,12 @@ async function researchRun(ctx, deps) {
5444
4879
  }
5445
4880
  ctx.emitEvent({
5446
4881
  type: "research.failed",
5447
- message: `waypoint ${waypoint.seq} research failed: ${errorHeadline3(outcome.error)}`,
4882
+ message: `waypoint ${waypoint.seq} research failed: ${errorHeadline2(outcome.error)}`,
5448
4883
  data: { waypointId: waypoint.id, error: outcome.error }
5449
4884
  });
5450
4885
  return { status: "failed", summary: outcome.error };
5451
4886
  }
5452
- function errorHeadline3(s) {
4887
+ function errorHeadline2(s) {
5453
4888
  const lines = s.split(`
5454
4889
  `).map((l) => l.trim()).filter(Boolean);
5455
4890
  const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
@@ -5460,7 +4895,7 @@ function createResearchStreamThrottle(emit3, opts = {}) {
5460
4895
  }
5461
4896
  function renderResearchPrompt(template, values) {
5462
4897
  let out = template;
5463
- for (const key of PLACEHOLDERS2) {
4898
+ for (const key of PLACEHOLDERS) {
5464
4899
  out = out.split(`{{${key}}}`).join(values[key]);
5465
4900
  }
5466
4901
  return out;
@@ -5475,32 +4910,32 @@ function interpretResearchResult(result, docRelPath) {
5475
4910
  return { status: "failed", error: "agent produced no commits \u2014 no research summary landed" };
5476
4911
  }
5477
4912
  function researchTemplatePath() {
5478
- const here = dirname5(fileURLToPath4(import.meta.url));
5479
- return join11(resolveSkillsRoot(here), "burner", "research-waypoint.md");
4913
+ const here = dirname4(fileURLToPath3(import.meta.url));
4914
+ return join10(resolveSkillsRoot(here), "burner", "research-waypoint.md");
5480
4915
  }
5481
4916
  function readDocsDigestFromDisk2(projectId, slug) {
5482
4917
  const worktree = worktreeDir(projectId, slug);
5483
- if (!existsSync12(worktree))
4918
+ if (!existsSync11(worktree))
5484
4919
  return "_No talk worktree on disk \u2014 docs digest skipped._";
5485
- const docsDir = join11(worktree, ...featureDocsRel(slug).split("/"));
5486
- if (!existsSync12(docsDir))
4920
+ const docsDir = join10(worktree, ...featureDocsRel(slug).split("/"));
4921
+ if (!existsSync11(docsDir))
5487
4922
  return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
5488
- const files = readdirSync4(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync6(join11(docsDir, e.name), "utf8") }));
4923
+ const files = readdirSync4(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync5(join10(docsDir, e.name), "utf8") }));
5489
4924
  return buildDocsDigest(files);
5490
4925
  }
5491
4926
  async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
5492
4927
  const { project, feature } = ctx;
5493
4928
  const docRelPath = researchDocRel(feature.slug, waypoint);
5494
4929
  const tempBranch = researchBranchName(feature.slug, waypoint.seq, newId("b").slice(2, 10));
5495
- const template = readFileSync6(researchTemplatePath(), "utf8");
4930
+ const template = readFileSync5(researchTemplatePath(), "utf8");
5496
4931
  const prompt = renderResearchPrompt(template, {
5497
4932
  WAYPOINT_JSON: buildWaypointJson(waypoint),
5498
4933
  FEATURE_BRIEF: buildFeatureBrief(feature),
5499
4934
  DOCS_DIGEST: readDocsDigestFromDisk2(project.id, feature.slug),
5500
4935
  RESEARCH_DOC_PATH: docRelPath
5501
4936
  });
5502
- mkdirSync6(logsDir(), { recursive: true });
5503
- const logFilePath = join11(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
4937
+ mkdirSync5(logsDir(), { recursive: true });
4938
+ const logFilePath = join10(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
5504
4939
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
5505
4940
  const runOptions = {
5506
4941
  agent: buildBurnAgent(config2, token, model),
@@ -5515,7 +4950,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
5515
4950
  let result;
5516
4951
  let salvaged = [];
5517
4952
  try {
5518
- result = await run3(runOptions);
4953
+ result = await run2(runOptions);
5519
4954
  } catch (err) {
5520
4955
  throttle.flush();
5521
4956
  if (ctx.signal.aborted)
@@ -5527,7 +4962,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
5527
4962
  const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
5528
4963
  ctx.emitEvent({
5529
4964
  type: "burn.worktree.teardown-failed",
5530
- message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline3(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
4965
+ message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline2(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
5531
4966
  data: { tempBranch, error: msg, cleanedUp: removed }
5532
4967
  });
5533
4968
  }
@@ -5540,7 +4975,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
5540
4975
  const detail = merge.error ?? "merge failed";
5541
4976
  ctx.emitEvent({
5542
4977
  type: merge.conflict ? "merge.conflict.needs-human" : "research.merge_failed",
5543
- 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}: ${errorHeadline3(detail)} \u2014 the branch is preserved for manual recovery`,
4978
+ 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`,
5544
4979
  data: { tempBranch, featureBranch: feature.branch, conflict: merge.conflict ?? false, error: detail }
5545
4980
  });
5546
4981
  return {
@@ -5562,9 +4997,9 @@ function resolveResearchDeps(ctx) {
5562
4997
  }
5563
4998
  function readTokenFromEnvFile2(path) {
5564
4999
  let fromFile;
5565
- if (existsSync12(path)) {
5000
+ if (existsSync11(path)) {
5566
5001
  try {
5567
- fromFile = parseEnvFile(readFileSync6(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
5002
+ fromFile = parseEnvFile(readFileSync5(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
5568
5003
  } catch {
5569
5004
  fromFile = undefined;
5570
5005
  }
@@ -5572,7 +5007,7 @@ function readTokenFromEnvFile2(path) {
5572
5007
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
5573
5008
  return token && token.length > 0 ? token : undefined;
5574
5009
  }
5575
- var AUTH_MISSING_EVENT3 = "auth.missing", AUTH_MISSING_MESSAGE3 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS2, research;
5010
+ var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, research;
5576
5011
  var init_research = __esm(() => {
5577
5012
  init_src();
5578
5013
  init_config_load();
@@ -5580,7 +5015,7 @@ var init_research = __esm(() => {
5580
5015
  init_skills_root();
5581
5016
  init_git();
5582
5017
  init_ticket_burner();
5583
- PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
5018
+ PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
5584
5019
  research = {
5585
5020
  id: "research",
5586
5021
  run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
@@ -5602,12 +5037,12 @@ var init_registry2 = __esm(() => {
5602
5037
  });
5603
5038
 
5604
5039
  // src/workflows/runner.ts
5605
- import { eq as eq8 } from "drizzle-orm";
5040
+ import { eq as eq6 } from "drizzle-orm";
5606
5041
  function workflowClaimsFeatureBranch(workflowId) {
5607
5042
  return BRANCH_CLAIMING.has(workflowId);
5608
5043
  }
5609
5044
  function isRunActive(runId) {
5610
- return controllers2.has(runId);
5045
+ return controllers.has(runId);
5611
5046
  }
5612
5047
  async function startRun(ctx, featureId, workflowId, opts = {}) {
5613
5048
  const feature = getFeatureRow(ctx, featureId);
@@ -5631,7 +5066,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
5631
5066
  claim(ctx, opts.claimWaypointId, runId);
5632
5067
  } catch (e) {
5633
5068
  const summary = e instanceof Error ? e.message : "claim failed";
5634
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
5069
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
5635
5070
  throw e;
5636
5071
  }
5637
5072
  }
@@ -5642,7 +5077,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
5642
5077
  data: { workflow: workflowId }
5643
5078
  });
5644
5079
  const controller = new AbortController;
5645
- controllers2.set(runId, controller);
5080
+ controllers.set(runId, controller);
5646
5081
  const talkWorktree = worktreeDir(project.id, feature.slug);
5647
5082
  const talkDetached = workflowClaimsFeatureBranch(workflowId) ? await detachWorktree(talkWorktree) : false;
5648
5083
  const wctx = {
@@ -5675,7 +5110,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
5675
5110
  return { runId, done };
5676
5111
  }
5677
5112
  function cancelRun(runId) {
5678
- controllers2.get(runId)?.abort();
5113
+ controllers.get(runId)?.abort();
5679
5114
  }
5680
5115
  async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
5681
5116
  let status = "failed";
@@ -5694,9 +5129,9 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
5694
5129
  }
5695
5130
  emit(ctx, featureId, { type: "run.error", message: summary, runId });
5696
5131
  } finally {
5697
- controllers2.delete(runId);
5132
+ controllers.delete(runId);
5698
5133
  }
5699
- ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
5134
+ ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
5700
5135
  releaseForSession(ctx, runId);
5701
5136
  if (workflowClaimsFeatureBranch(workflow2)) {
5702
5137
  sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
@@ -5726,7 +5161,7 @@ function maybeAutoAdvance(ctx, featureId) {
5726
5161
  if (next)
5727
5162
  setPhase(ctx, featureId, next, "phase.advanced", "auto-advanced to review after successful run");
5728
5163
  }
5729
- var controllers2, BRANCH_CLAIMING;
5164
+ var controllers, BRANCH_CLAIMING;
5730
5165
  var init_runner = __esm(() => {
5731
5166
  init_src();
5732
5167
  init_paths();
@@ -5739,12 +5174,12 @@ var init_runner = __esm(() => {
5739
5174
  init_tickets();
5740
5175
  init_waypoints();
5741
5176
  init_registry2();
5742
- controllers2 = new Map;
5177
+ controllers = new Map;
5743
5178
  BRANCH_CLAIMING = new Set(["ticket-burner"]);
5744
5179
  });
5745
5180
 
5746
5181
  // src/services/features.ts
5747
- import { desc as desc5, eq as eq9 } from "drizzle-orm";
5182
+ import { desc as desc4, eq as eq7 } from "drizzle-orm";
5748
5183
  import { rmSync as rmSync2 } from "fs";
5749
5184
  async function createFeature(ctx, input) {
5750
5185
  const project = requireProjectById(ctx, input.projectId);
@@ -5868,7 +5303,7 @@ function getFeatureFull(ctx, id) {
5868
5303
  };
5869
5304
  }
5870
5305
  function list(ctx, projectId) {
5871
- const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc5(features.createdAt)).all();
5306
+ const rows = ctx.db.select().from(features).where(eq7(features.projectId, projectId)).orderBy(desc4(features.createdAt)).all();
5872
5307
  return rows.map((row) => {
5873
5308
  const feature = rowToFeature(row);
5874
5309
  const tickets2 = listByFeature2(ctx, feature.id);
@@ -5956,7 +5391,7 @@ function rethink(ctx, featureId) {
5956
5391
  throw new GateError(`a ${live[0].kind} session is already live for ${feature.slug} \u2014 only one terminal per feature; end or resume it first`);
5957
5392
  }
5958
5393
  const lap = feature.lap + 1;
5959
- ctx.db.update(features).set({ lap }).where(eq9(features.id, featureId)).run();
5394
+ ctx.db.update(features).set({ lap }).where(eq7(features.id, featureId)).run();
5960
5395
  return setPhase(ctx, featureId, RETHINK_LOOP_BACK.to, "lap.started", `rethink \u2014 lap ${lap}`);
5961
5396
  }
5962
5397
  async function retryTicket(ctx, ticketId, opts = {}) {
@@ -6030,7 +5465,7 @@ function escalateToMap(ctx, featureId, input) {
6030
5465
  return { ok: true, warning: `feature ${feature.slug} is already mapped \u2014 no changes made` };
6031
5466
  }
6032
5467
  const project = projectForFeature(ctx, feature);
6033
- ctx.db.update(features).set({ mapped: true }).where(eq9(features.id, featureId)).run();
5468
+ ctx.db.update(features).set({ mapped: true }).where(eq7(features.id, featureId)).run();
6034
5469
  scaffoldMapDoc(project, { ...feature, mapped: true }, input);
6035
5470
  emit(ctx, featureId, {
6036
5471
  type: "feature.escalated",
@@ -6047,7 +5482,7 @@ function archiveFeature(ctx, featureId) {
6047
5482
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
6048
5483
  if (live)
6049
5484
  endSession(ctx, live.id);
6050
- ctx.db.update(features).set({ status: "archived" }).where(eq9(features.id, featureId)).run();
5485
+ ctx.db.update(features).set({ status: "archived" }).where(eq7(features.id, featureId)).run();
6051
5486
  emit(ctx, featureId, {
6052
5487
  type: "feature.archived",
6053
5488
  message: `feature ${feature.slug} archived`,
@@ -6061,7 +5496,7 @@ function unarchiveFeature(ctx, featureId) {
6061
5496
  throw new GateError(`feature ${feature.slug} is not archived`);
6062
5497
  }
6063
5498
  const restored = feature.phase === "shipped" ? "shipped" : "active";
6064
- ctx.db.update(features).set({ status: restored }).where(eq9(features.id, featureId)).run();
5499
+ ctx.db.update(features).set({ status: restored }).where(eq7(features.id, featureId)).run();
6065
5500
  emit(ctx, featureId, {
6066
5501
  type: "feature.unarchived",
6067
5502
  message: `feature ${feature.slug} unarchived (${restored})`,
@@ -6075,9 +5510,9 @@ async function deleteFeature(ctx, featureId) {
6075
5510
  throw new GateError(`feature ${feature.slug} is shipped \u2014 archive it instead (delete is for non-shipped features)`);
6076
5511
  }
6077
5512
  const project = projectForFeature(ctx, feature);
6078
- for (const run4 of listRunsByFeature(ctx, featureId)) {
6079
- if (run4.status === "running")
6080
- cancelRun(run4.id);
5513
+ for (const run3 of listRunsByFeature(ctx, featureId)) {
5514
+ if (run3.status === "running")
5515
+ cancelRun(run3.id);
6081
5516
  }
6082
5517
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
6083
5518
  if (live)
@@ -6101,13 +5536,13 @@ async function deleteFeature(ctx, featureId) {
6101
5536
  return { ok: true, slug: feature.slug };
6102
5537
  }
6103
5538
  function deleteFeatureRows(ctx, featureId) {
6104
- ctx.db.delete(tickets).where(eq9(tickets.featureId, featureId)).run();
6105
- ctx.db.delete(sessions).where(eq9(sessions.featureId, featureId)).run();
6106
- ctx.db.delete(runs).where(eq9(runs.featureId, featureId)).run();
6107
- ctx.db.delete(events).where(eq9(events.featureId, featureId)).run();
6108
- ctx.db.delete(gateOverrides).where(eq9(gateOverrides.featureId, featureId)).run();
6109
- ctx.db.delete(waypoints).where(eq9(waypoints.featureId, featureId)).run();
6110
- ctx.db.delete(features).where(eq9(features.id, featureId)).run();
5539
+ ctx.db.delete(tickets).where(eq7(tickets.featureId, featureId)).run();
5540
+ ctx.db.delete(sessions).where(eq7(sessions.featureId, featureId)).run();
5541
+ ctx.db.delete(runs).where(eq7(runs.featureId, featureId)).run();
5542
+ ctx.db.delete(events).where(eq7(events.featureId, featureId)).run();
5543
+ ctx.db.delete(gateOverrides).where(eq7(gateOverrides.featureId, featureId)).run();
5544
+ ctx.db.delete(waypoints).where(eq7(waypoints.featureId, featureId)).run();
5545
+ ctx.db.delete(features).where(eq7(features.id, featureId)).run();
6111
5546
  }
6112
5547
  function gateState(ctx, feature) {
6113
5548
  const gate = nextGate(feature);
@@ -6118,7 +5553,7 @@ function gateState(ctx, feature) {
6118
5553
  }
6119
5554
  function uniqueSlug(ctx, projectId, title) {
6120
5555
  const base = slugify(title);
6121
- const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq9(features.projectId, projectId)).all().map((r) => r.slug));
5556
+ const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq7(features.projectId, projectId)).all().map((r) => r.slug));
6122
5557
  if (!existing.has(base))
6123
5558
  return base;
6124
5559
  let n = 2;
@@ -6147,11 +5582,131 @@ var init_features = __esm(() => {
6147
5582
  init_runner();
6148
5583
  });
6149
5584
 
5585
+ // src/services/findings.ts
5586
+ import { and as and6, eq as eq8 } from "drizzle-orm";
5587
+ function isPreparedKey(key) {
5588
+ return PREPARED_SET.has(key);
5589
+ }
5590
+ function findingRow(ctx, projectId, key) {
5591
+ return ctx.db.select({
5592
+ source: projectFindings.source,
5593
+ evidence: projectFindings.evidence,
5594
+ establishedAt: projectFindings.establishedAt,
5595
+ establishedSha: projectFindings.establishedSha
5596
+ }).from(projectFindings).where(and6(eq8(projectFindings.projectId, projectId), eq8(projectFindings.key, key))).get();
5597
+ }
5598
+ function isOverwritable(ctx, projectId, key) {
5599
+ return findingRow(ctx, projectId, key)?.source !== "human";
5600
+ }
5601
+ function recordFinding(ctx, projectId, input) {
5602
+ const { key, value, source, evidence, establishedSha } = input;
5603
+ ctx.db.transaction((tx) => {
5604
+ tx.update(projects).set({ [COLUMN_NAME[key]]: value }).where(eq8(projects.id, projectId)).run();
5605
+ if (value === null) {
5606
+ tx.delete(projectFindings).where(and6(eq8(projectFindings.projectId, projectId), eq8(projectFindings.key, key))).run();
5607
+ return;
5608
+ }
5609
+ tx.insert(projectFindings).values({
5610
+ projectId,
5611
+ key,
5612
+ source,
5613
+ evidence: evidence ?? null,
5614
+ establishedAt: Date.now(),
5615
+ establishedSha: establishedSha ?? null
5616
+ }).onConflictDoUpdate({
5617
+ target: [projectFindings.projectId, projectFindings.key],
5618
+ set: {
5619
+ source,
5620
+ evidence: evidence ?? null,
5621
+ establishedAt: Date.now(),
5622
+ establishedSha: establishedSha ?? null
5623
+ }
5624
+ }).run();
5625
+ });
5626
+ }
5627
+ function recordHuman(ctx, projectId, key, value) {
5628
+ if (!isPreparedKey(key))
5629
+ return;
5630
+ const existing = findingRow(ctx, projectId, key);
5631
+ if (value === null) {
5632
+ if (existing) {
5633
+ ctx.db.delete(projectFindings).where(and6(eq8(projectFindings.projectId, projectId), eq8(projectFindings.key, key))).run();
5634
+ }
5635
+ return;
5636
+ }
5637
+ ctx.db.insert(projectFindings).values({
5638
+ projectId,
5639
+ key,
5640
+ source: "human",
5641
+ evidence: null,
5642
+ establishedAt: Date.now(),
5643
+ establishedSha: null
5644
+ }).onConflictDoUpdate({
5645
+ target: [projectFindings.projectId, projectFindings.key],
5646
+ set: { source: "human", evidence: null, establishedAt: Date.now(), establishedSha: null }
5647
+ }).run();
5648
+ }
5649
+ async function listFindings(ctx, project) {
5650
+ const rows = ctx.db.select().from(projectFindings).where(eq8(projectFindings.projectId, project.id)).all();
5651
+ const distances = new Map;
5652
+ for (const row of rows) {
5653
+ const sha = row.establishedSha;
5654
+ if (!sha || distances.has(sha))
5655
+ continue;
5656
+ distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
5657
+ }
5658
+ const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
5659
+ return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
5660
+ const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
5661
+ return {
5662
+ key: row.key,
5663
+ source: row.source,
5664
+ ...row.evidence ? { evidence: row.evidence } : {},
5665
+ establishedAt: row.establishedAt,
5666
+ ...row.establishedSha ? { establishedSha: row.establishedSha } : {},
5667
+ ...staleCommits !== undefined ? { staleCommits } : {}
5668
+ };
5669
+ });
5670
+ }
5671
+ function unsetPreparedKeys(project) {
5672
+ return PREPARED_KEYS.filter((key) => {
5673
+ const value = project[key];
5674
+ return value === undefined || value.trim() === "";
5675
+ });
5676
+ }
5677
+ var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET;
5678
+ var init_findings = __esm(() => {
5679
+ init_src();
5680
+ init_schema();
5681
+ init_git();
5682
+ VALUE_COLUMN = {
5683
+ setupCommand: projects.setupCommand,
5684
+ verifyCommands: projects.verifyCommands,
5685
+ knownFailures: projects.knownFailures,
5686
+ devCommand: projects.devCommand,
5687
+ driveSetupCommand: projects.driveSetupCommand,
5688
+ driveStopCommand: projects.driveStopCommand,
5689
+ driveEnv: projects.driveEnv,
5690
+ dbResetCommand: projects.dbResetCommand
5691
+ };
5692
+ COLUMN_NAME = {
5693
+ setupCommand: "setupCommand",
5694
+ verifyCommands: "verifyCommands",
5695
+ knownFailures: "knownFailures",
5696
+ devCommand: "devCommand",
5697
+ driveSetupCommand: "driveSetupCommand",
5698
+ driveStopCommand: "driveStopCommand",
5699
+ driveEnv: "driveEnv",
5700
+ dbResetCommand: "dbResetCommand"
5701
+ };
5702
+ PREPARED_SET = new Set(PREPARED_KEYS);
5703
+ });
5704
+
6150
5705
  // src/mcp/server.ts
6151
5706
  import { StreamableHTTPTransport } from "@hono/mcp";
6152
5707
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6153
5708
  import { Hono } from "hono";
6154
- import * as z4 from "zod";
5709
+ import * as z3 from "zod";
6155
5710
  function headerSessionId(extra) {
6156
5711
  const headers = extra.requestInfo?.headers;
6157
5712
  if (!headers)
@@ -6422,9 +5977,9 @@ function buildMcpServer() {
6422
5977
  description: "Store one established project setting from a preparation conversation. " + "Set userSupplied: true ONLY when the human gave you this value or confirmed it verbatim \u2014 " + "that marks it as theirs and stops any future automatic run from overwriting it. " + "Anything you worked out yourself must leave it false, even if they were watching. " + "Always include evidence: what you ran, or what the human told you.",
6423
5978
  inputSchema: {
6424
5979
  key: PreparedKey,
6425
- value: z4.string(),
6426
- evidence: z4.string().optional(),
6427
- userSupplied: z4.boolean().optional()
5980
+ value: z3.string(),
5981
+ evidence: z3.string().optional(),
5982
+ userSupplied: z3.boolean().optional()
6428
5983
  }
6429
5984
  }, async (args, extra) => {
6430
5985
  const rs = await resolveCtxSession(extra);
@@ -6436,11 +5991,11 @@ function buildMcpServer() {
6436
5991
  title: "Create a feature",
6437
5992
  description: "Create a feature from the project session \u2014 the end of intake. Pass `brief` with the " + "reasoning you just worked out with the human (why this feature exists, what it must NOT " + "swallow): it becomes brief.md verbatim, and without it that reasoning is lost. Pass " + "`ticket: { prose }` for a quick change \u2014 work too small to deserve a grill \u2014 which " + "creates the feature at the implementation phase with that one ticket, and whose brief.md " + "is the prose itself (so `brief` is unused there). This does NOT open " + "a terminal on what it creates; the new card in the rail is the feedback, and the human " + "decides what to work on next.",
6438
5993
  inputSchema: {
6439
- title: z4.string().min(1),
6440
- oneLiner: z4.string(),
6441
- baseBranch: z4.string().optional(),
6442
- brief: z4.string().optional(),
6443
- ticket: z4.object({ prose: z4.string().min(1) }).optional()
5994
+ title: z3.string().min(1),
5995
+ oneLiner: z3.string(),
5996
+ baseBranch: z3.string().optional(),
5997
+ brief: z3.string().optional(),
5998
+ ticket: z3.object({ prose: z3.string().min(1) }).optional()
6444
5999
  }
6445
6000
  }, async (args, extra) => {
6446
6001
  const rs = await resolveCtxSession(extra);
@@ -6461,7 +6016,7 @@ function buildMcpServer() {
6461
6016
  server.registerTool("get_work_record", {
6462
6017
  title: "Get work record",
6463
6018
  description: "What features actually DID \u2014 facts only: per feature its status, ship date, run " + "summaries and tickets as { seq, title, status, seams, commits, error? }. Never a " + "ticket's goal or acceptance criteria: those are intent from before the code existed. " + 'Query by `featureSlug` ("what did X do?") or by `seam` ("who has touched this area, and ' + 'what happened?") \u2014 the seam match is a case-insensitive substring, because seams are ' + "free prose that differs between features. At least one argument is required.",
6464
- inputSchema: { featureSlug: z4.string().optional(), seam: z4.string().optional() }
6019
+ inputSchema: { featureSlug: z3.string().optional(), seam: z3.string().optional() }
6465
6020
  }, async (args, extra) => {
6466
6021
  const rs = await resolveCtxSession(extra);
6467
6022
  if (!rs)
@@ -6481,7 +6036,7 @@ function buildMcpServer() {
6481
6036
  server.registerTool("emit_tickets", {
6482
6037
  title: "Emit tickets",
6483
6038
  description: "Store the ideation session's ticket batch. Each ticket: title, goal, context, acceptanceCriteria[], seams[], blockedBy[] (1-based positions within THIS batch).",
6484
- inputSchema: { tickets: z4.array(TicketInput) }
6039
+ inputSchema: { tickets: z3.array(TicketInput) }
6485
6040
  }, async (args, extra) => {
6486
6041
  const rs = await resolveCtxSession(extra);
6487
6042
  if (!rs)
@@ -6494,12 +6049,12 @@ function buildMcpServer() {
6494
6049
  title: "Update ticket",
6495
6050
  description: "Rewrite a stored ticket's content \u2014 title, goal, context, acceptanceCriteria, seams (any subset). Only pending or failed tickets can be edited; done/cancelled tickets are history and burning tickets are already running. Use during a revisit when a decision change makes a ticket stale. Get ids from get_feature_context.",
6496
6051
  inputSchema: {
6497
- id: z4.string(),
6498
- title: z4.string().optional(),
6499
- goal: z4.string().optional(),
6500
- context: z4.string().optional(),
6501
- acceptanceCriteria: z4.array(z4.string()).optional(),
6502
- seams: z4.array(z4.string()).optional()
6052
+ id: z3.string(),
6053
+ title: z3.string().optional(),
6054
+ goal: z3.string().optional(),
6055
+ context: z3.string().optional(),
6056
+ acceptanceCriteria: z3.array(z3.string()).optional(),
6057
+ seams: z3.array(z3.string()).optional()
6503
6058
  }
6504
6059
  }, async (args, extra) => {
6505
6060
  const rs = await resolveCtxSession(extra);
@@ -6512,7 +6067,7 @@ function buildMcpServer() {
6512
6067
  server.registerTool("cancel_ticket", {
6513
6068
  title: "Cancel ticket",
6514
6069
  description: "Cancel a ticket that is no longer needed (terminal state; only pending or failed tickets). The burner skips cancelled tickets, and tickets blocked by a cancelled one still burn \u2014 cancellation counts as satisfied. Pass a short reason so the timeline explains why.",
6515
- inputSchema: { id: z4.string(), reason: z4.string().optional() }
6070
+ inputSchema: { id: z3.string(), reason: z3.string().optional() }
6516
6071
  }, async (args, extra) => {
6517
6072
  const rs = await resolveCtxSession(extra);
6518
6073
  if (!rs)
@@ -6524,7 +6079,7 @@ function buildMcpServer() {
6524
6079
  server.registerTool("escalate_to_map", {
6525
6080
  title: "Escalate to map",
6526
6081
  description: "Escalate this grilling session into a map when the feature outgrows one context window. Flips the feature to mapped and scaffolds docs/features/<slug>/map.md, seeding Destination + Notes from your arguments (Not-yet-specified and Out-of-scope start empty). Idempotent: calling it on an already-mapped feature warns and changes nothing.",
6527
- inputSchema: { destination: z4.string(), notes: z4.string().optional() }
6082
+ inputSchema: { destination: z3.string(), notes: z3.string().optional() }
6528
6083
  }, async (args, extra) => {
6529
6084
  const rs = await resolveCtxSession(extra);
6530
6085
  if (!rs)
@@ -6538,7 +6093,7 @@ function buildMcpServer() {
6538
6093
  server.registerTool("emit_waypoints", {
6539
6094
  title: "Emit waypoints",
6540
6095
  description: "Batch-create waypoints on the map (the feature must already be mapped \u2014 call escalate_to_map first). Each waypoint: title, type (grilling|research|prototype|task), question, blockedBy[] (1-based positions within THIS batch, and/or ids of already-stored waypoints). Available from any session once mapped \u2014 any session may branch the map.",
6541
- inputSchema: { waypoints: z4.array(WaypointInput) }
6096
+ inputSchema: { waypoints: z3.array(WaypointInput) }
6542
6097
  }, async (args, extra) => {
6543
6098
  const rs = await resolveCtxSession(extra);
6544
6099
  if (!rs)
@@ -6548,7 +6103,7 @@ function buildMcpServer() {
6548
6103
  server.registerTool("resolve_waypoint", {
6549
6104
  title: "Resolve waypoint",
6550
6105
  description: "End the current waypoint: `resolved` (its question is answered) or `dropped` (no longer needed). Write the decision prose to decisions.md (or the gist to map.md Out-of-scope for a drop) FIRST \u2014 this tool flips machinery only (marks the waypoint terminal, frees any dependents on the frontier). `summary` is the one-line gist shown in the UI. Call this exactly once, as the last thing you do.",
6551
- inputSchema: { id: z4.string(), disposition: WaypointDisposition, summary: z4.string() }
6106
+ inputSchema: { id: z3.string(), disposition: WaypointDisposition, summary: z3.string() }
6552
6107
  }, async (args, extra) => {
6553
6108
  const rs = await resolveCtxSession(extra);
6554
6109
  if (!rs)
@@ -6560,7 +6115,7 @@ function buildMcpServer() {
6560
6115
  server.registerTool("record_event", {
6561
6116
  title: "Record event",
6562
6117
  description: "Add a note to the timeline (decisions recorded, spec saved, milestones) \u2014 the feature's " + "timeline from a feature session, the project's from a project-scoped one.",
6563
- inputSchema: { type: z4.string(), message: z4.string() }
6118
+ inputSchema: { type: z3.string(), message: z3.string() }
6564
6119
  }, async (args, extra) => {
6565
6120
  const rs = await resolveCtxSession(extra);
6566
6121
  if (!rs)
@@ -6680,6 +6235,27 @@ var init_ws = __esm(() => {
6680
6235
  };
6681
6236
  });
6682
6237
 
6238
+ // src/services/prep.ts
6239
+ function keysToPrepare(ctx, project) {
6240
+ return unsetPreparedKeys(project).filter((key) => isOverwritable(ctx, project.id, key));
6241
+ }
6242
+ function isPrepared(ctx, project) {
6243
+ if (keysToPrepare(ctx, project).length === 0)
6244
+ return true;
6245
+ return hasCompletedProjectSession(ctx, project.id, "prepare");
6246
+ }
6247
+ async function prepView(ctx, project) {
6248
+ return {
6249
+ pendingKeys: keysToPrepare(ctx, project),
6250
+ findings: await listFindings(ctx, project),
6251
+ prepared: isPrepared(ctx, project)
6252
+ };
6253
+ }
6254
+ var init_prep = __esm(() => {
6255
+ init_sessions();
6256
+ init_findings();
6257
+ });
6258
+
6683
6259
  // src/routes/hooks.ts
6684
6260
  import { Hono as Hono2 } from "hono";
6685
6261
  function handleSessionStart(ctx, sessionId, feature, payload) {
@@ -6900,9 +6476,9 @@ var init_stream = __esm(() => {
6900
6476
  });
6901
6477
 
6902
6478
  // src/routes/web.ts
6903
- import { existsSync as existsSync13, statSync } from "fs";
6479
+ import { existsSync as existsSync12, statSync } from "fs";
6904
6480
  import { readFile } from "fs/promises";
6905
- import { extname, join as join12, normalize, resolve as resolve7, sep as sep2 } from "path";
6481
+ import { extname, join as join11, normalize, resolve as resolve7, sep as sep2 } from "path";
6906
6482
  function contentType(path) {
6907
6483
  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
6908
6484
  }
@@ -6920,7 +6496,7 @@ function safeJoin(root, pathname) {
6920
6496
  } catch {
6921
6497
  return null;
6922
6498
  }
6923
- const joined = normalize(join12(root, decoded));
6499
+ const joined = normalize(join11(root, decoded));
6924
6500
  if (joined !== root && !joined.startsWith(root + sep2))
6925
6501
  return null;
6926
6502
  return joined;
@@ -6933,7 +6509,7 @@ function resolveWebDist() {
6933
6509
  }
6934
6510
  function mountWebApp(app, distDir) {
6935
6511
  const root = resolve7(distDir);
6936
- const indexHtml = join12(root, "index.html");
6512
+ const indexHtml = join11(root, "index.html");
6937
6513
  app.get("*", async (c, next) => {
6938
6514
  const { pathname } = new URL(c.req.url);
6939
6515
  if (/^\/(api|mcp|ws|health)(\/|$)/.test(pathname))
@@ -6953,7 +6529,7 @@ function mountWebApp(app, distDir) {
6953
6529
  }
6954
6530
  function mountWebAppIfBuilt(app) {
6955
6531
  const dist = resolveWebDist();
6956
- if (existsSync13(join12(dist, "index.html")))
6532
+ if (existsSync12(join11(dist, "index.html")))
6957
6533
  mountWebApp(app, dist);
6958
6534
  }
6959
6535
  var MIME;
@@ -7000,14 +6576,14 @@ var init_context = __esm(() => {
7000
6576
  });
7001
6577
 
7002
6578
  // src/trpc/routers/docs.ts
7003
- import * as z5 from "zod";
6579
+ import * as z4 from "zod";
7004
6580
  var docsRouter;
7005
6581
  var init_docs = __esm(() => {
7006
6582
  init_knowledge();
7007
6583
  init_repo();
7008
6584
  init_context();
7009
6585
  docsRouter = router({
7010
- read: publicProcedure.input(z5.object({ featureId: z5.string(), relPath: z5.string() })).query(({ ctx, input }) => {
6586
+ read: publicProcedure.input(z4.object({ featureId: z4.string(), relPath: z4.string() })).query(({ ctx, input }) => {
7011
6587
  const feature = getFeatureRow(ctx, input.featureId);
7012
6588
  return readDoc(ctx, feature, input.relPath);
7013
6589
  })
@@ -7015,23 +6591,23 @@ var init_docs = __esm(() => {
7015
6591
  });
7016
6592
 
7017
6593
  // src/trpc/routers/events.ts
7018
- import * as z6 from "zod";
6594
+ import * as z5 from "zod";
7019
6595
  var eventsRouter;
7020
6596
  var init_events2 = __esm(() => {
7021
6597
  init_events();
7022
6598
  init_context();
7023
6599
  eventsRouter = router({
7024
- list: publicProcedure.input(z6.object({ featureId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listAfter(ctx, input.featureId, input.afterId)),
7025
- listByProject: publicProcedure.input(z6.object({ projectId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listByProject(ctx, input.projectId, input.afterId))
6600
+ list: publicProcedure.input(z5.object({ featureId: z5.string(), afterId: z5.number().optional() })).query(({ ctx, input }) => listAfter(ctx, input.featureId, input.afterId)),
6601
+ listByProject: publicProcedure.input(z5.object({ projectId: z5.string(), afterId: z5.number().optional() })).query(({ ctx, input }) => listByProject(ctx, input.projectId, input.afterId))
7026
6602
  });
7027
6603
  });
7028
6604
 
7029
6605
  // src/launcher/artifacts.ts
7030
- import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync2 } from "fs";
7031
- import { fileURLToPath as fileURLToPath5 } from "url";
7032
- import { join as join13 } from "path";
6606
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync2 } from "fs";
6607
+ import { fileURLToPath as fileURLToPath4 } from "url";
6608
+ import { join as join12 } from "path";
7033
6609
  function hookClientPath() {
7034
- return resolveAsset(ASSET_ENV.hookClient, fileURLToPath5(new URL("./hook-client.ts", import.meta.url)));
6610
+ return resolveAsset(ASSET_ENV.hookClient, fileURLToPath4(new URL("./hook-client.ts", import.meta.url)));
7035
6611
  }
7036
6612
  function serverUrlFor(config2) {
7037
6613
  return `http://localhost:${config2.serverPort}`;
@@ -7224,7 +6800,7 @@ function renderRevisitPrompt(feature) {
7224
6800
  `);
7225
6801
  }
7226
6802
  function renderPreparePrompt(brief) {
7227
- const { project, remainingKeys, established, notes } = brief;
6803
+ const { project, remainingKeys, established } = brief;
7228
6804
  return [
7229
6805
  `# runcastle \u2014 preparing ${project.name}`,
7230
6806
  "",
@@ -7234,11 +6810,10 @@ function renderPreparePrompt(brief) {
7234
6810
  "",
7235
6811
  "## Where this runs \u2014 and why that matters",
7236
6812
  `You are on the developer's own machine, in \`${project.repoPath}\`, NOT in a sandbox.`,
7237
- "A headless preparation run measures the repo in a throwaway container, which means it",
7238
- "can never verify anything about *this* machine: the dev server, the local database,",
7239
- "docker, credentials. Those keys \u2014 `devCommand`, `driveSetupCommand`, `driveStopCommand`,",
7240
- "`driveEnv`, `dbResetCommand` \u2014 are exactly what it has to leave as proposals.",
7241
- "You can actually run them. That is the point of this session.",
6813
+ "That is the point of this session. A throwaway container can never verify anything about",
6814
+ "*this* machine: the dev server, the local database, docker, credentials. Those keys \u2014",
6815
+ "`devCommand`, `driveSetupCommand`, `driveStopCommand`, `driveEnv`, `dbResetCommand` \u2014",
6816
+ "can only be settled here. Run what you propose; do not guess it from config.",
7242
6817
  "",
7243
6818
  "The same access is why you must **ask before you act**. Anything that starts or stops a",
7244
6819
  "service, creates or migrates a database, or writes outside the repo needs the human to",
@@ -7252,14 +6827,6 @@ function renderPreparePrompt(brief) {
7252
6827
  ...established.map((f) => `- \`${f.key}\` (${f.source})${f.evidence ? ` \u2014 ${f.evidence}` : ""}`),
7253
6828
  ""
7254
6829
  ] : [],
7255
- ...notes ? [
7256
- "## What the last run could not settle",
7257
- "Its own words. Where it says it declined to guess, it was right to \u2014 that is the",
7258
- "gap you are here to close, and closing it usually means asking one direct question.",
7259
- "",
7260
- notes,
7261
- ""
7262
- ] : [],
7263
6830
  "## Still open",
7264
6831
  remainingKeys.length > 0 ? remainingKeys.map((k) => `- \`${k}\``).join(`
7265
6832
  `) : "_Nothing is unset. Confirm the existing values still hold, then say so and stop._",
@@ -7376,10 +6943,10 @@ function renderMcpConfig(session, config2) {
7376
6943
  async function writeSessionArtifacts(input) {
7377
6944
  const { session, feature, config: config2, waypoint, prepare, projectBrief } = input;
7378
6945
  const dir = sessionDir(session.id);
7379
- mkdirSync7(dir, { recursive: true });
7380
- const systemPromptPath = join13(dir, "system-prompt.md");
7381
- const settingsPath = join13(dir, "settings.json");
7382
- const mcpConfigPath = join13(dir, "mcp.json");
6946
+ mkdirSync6(dir, { recursive: true });
6947
+ const systemPromptPath = join12(dir, "system-prompt.md");
6948
+ const settingsPath = join12(dir, "settings.json");
6949
+ const mcpConfigPath = join12(dir, "mcp.json");
7383
6950
  const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
7384
6951
  throw new Error(`session ${session.id} has no feature and no project-session brief`);
7385
6952
  })();
@@ -7427,10 +6994,10 @@ var init_artifacts = __esm(() => {
7427
6994
  });
7428
6995
 
7429
6996
  // src/launcher/launcher.ts
7430
- import { existsSync as existsSync14 } from "fs";
7431
- import { dirname as dirname6, join as join14, resolve as resolve8 } from "path";
7432
- import { fileURLToPath as fileURLToPath6 } from "url";
7433
- import { and as and8, eq as eq10 } from "drizzle-orm";
6997
+ import { existsSync as existsSync13 } from "fs";
6998
+ import { dirname as dirname5, join as join13, resolve as resolve8 } from "path";
6999
+ import { fileURLToPath as fileURLToPath5 } from "url";
7000
+ import { and as and7, eq as eq9 } from "drizzle-orm";
7434
7001
  function ptyExitMessage(exitCode) {
7435
7002
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
7436
7003
  return `terminal exited (code ${label})`;
@@ -7461,23 +7028,23 @@ function resolveClaudeExecutable() {
7461
7028
  function claudeSpawnTarget(claudeArgs) {
7462
7029
  return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
7463
7030
  }
7464
- function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
7465
- const rel = join14("packages", "skills", "packs", "runcastle");
7031
+ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
7032
+ const rel = join13("packages", "skills", "packs", "runcastle");
7466
7033
  const override = process.env[SKILLS_DIR_ENV];
7467
7034
  if (override) {
7468
- const dir2 = join14(resolve8(override), "packs", "runcastle");
7469
- if (existsSync14(dir2))
7035
+ const dir2 = join13(resolve8(override), "packs", "runcastle");
7036
+ if (existsSync13(dir2))
7470
7037
  return dir2;
7471
7038
  throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
7472
7039
  }
7473
7040
  const searched = [];
7474
7041
  let dir = fromDir;
7475
7042
  for (let i = 0;i < 8; i += 1) {
7476
- const candidate = join14(dir, rel);
7043
+ const candidate = join13(dir, rel);
7477
7044
  searched.push(candidate);
7478
- if (existsSync14(candidate))
7045
+ if (existsSync13(candidate))
7479
7046
  return candidate;
7480
- const parent = dirname6(dir);
7047
+ const parent = dirname5(dir);
7481
7048
  if (parent === dir)
7482
7049
  break;
7483
7050
  dir = parent;
@@ -7487,7 +7054,7 @@ function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
7487
7054
  `)}`);
7488
7055
  }
7489
7056
  function activeRunFor(ctx, featureId) {
7490
- const row = ctx.db.select().from(runs).where(and8(eq10(runs.featureId, featureId), eq10(runs.status, "running"))).limit(1).get();
7057
+ const row = ctx.db.select().from(runs).where(and7(eq9(runs.featureId, featureId), eq9(runs.status, "running"))).limit(1).get();
7491
7058
  return row ? rowToRun(row) : null;
7492
7059
  }
7493
7060
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -7771,7 +7338,6 @@ async function launchProjectSession(ctx, input, opts = {}) {
7771
7338
  }
7772
7339
  async function buildPrepareBrief(ctx, project) {
7773
7340
  const findings = await listFindings(ctx, project);
7774
- const notes = latestPrepNotes(ctx, project.id);
7775
7341
  return {
7776
7342
  project,
7777
7343
  remainingKeys: keysToPrepare(ctx, project),
@@ -7779,8 +7345,7 @@ async function buildPrepareBrief(ctx, project) {
7779
7345
  key: f.key,
7780
7346
  source: f.source,
7781
7347
  ...f.evidence ? { evidence: f.evidence } : {}
7782
- })),
7783
- ...notes ? { notes } : {}
7348
+ }))
7784
7349
  };
7785
7350
  }
7786
7351
  async function workWaypoint(ctx, input, opts = {}) {
@@ -7943,7 +7508,7 @@ var init_launcher = __esm(() => {
7943
7508
  });
7944
7509
 
7945
7510
  // src/trpc/routers/feature.ts
7946
- import * as z7 from "zod";
7511
+ import * as z6 from "zod";
7947
7512
  var gateId, featureRouter;
7948
7513
  var init_feature = __esm(() => {
7949
7514
  init_src();
@@ -7955,30 +7520,30 @@ var init_feature = __esm(() => {
7955
7520
  init_git();
7956
7521
  init_repo();
7957
7522
  init_context();
7958
- gateId = z7.enum(["G1", "G2", "G3", "G4", "G5"]);
7523
+ gateId = z6.enum(["G1", "G2", "G3", "G4", "G5"]);
7959
7524
  featureRouter = router({
7960
- create: publicProcedure.input(z7.object({
7961
- projectId: z7.string(),
7962
- title: z7.string().min(1),
7963
- oneLiner: z7.string(),
7964
- baseBranch: z7.string().optional()
7525
+ create: publicProcedure.input(z6.object({
7526
+ projectId: z6.string(),
7527
+ title: z6.string().min(1),
7528
+ oneLiner: z6.string(),
7529
+ baseBranch: z6.string().optional()
7965
7530
  })).mutation(({ ctx, input }) => createFeature(ctx, input)),
7966
- quickChange: publicProcedure.input(z7.object({
7967
- projectId: z7.string(),
7968
- title: z7.string().min(1),
7969
- prose: z7.string().min(1),
7970
- baseBranch: z7.string().optional()
7531
+ quickChange: publicProcedure.input(z6.object({
7532
+ projectId: z6.string(),
7533
+ title: z6.string().min(1),
7534
+ prose: z6.string().min(1),
7535
+ baseBranch: z6.string().optional()
7971
7536
  })).mutation(({ ctx, input }) => quickChange(ctx, input)),
7972
- list: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
7973
- get: publicProcedure.input(z7.object({ id: z7.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
7974
- launchSession: publicProcedure.input(z7.object({
7975
- featureId: z7.string(),
7537
+ list: publicProcedure.input(z6.object({ projectId: z6.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
7538
+ get: publicProcedure.input(z6.object({ id: z6.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
7539
+ launchSession: publicProcedure.input(z6.object({
7540
+ featureId: z6.string(),
7976
7541
  kind: SessionKind,
7977
- kickoffLine: z7.string().min(1).optional()
7542
+ kickoffLine: z6.string().min(1).optional()
7978
7543
  })).mutation(({ ctx, input }) => launchSession(ctx, input)),
7979
- workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string(), endLive: z7.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
7980
- converge: publicProcedure.input(z7.object({ featureId: z7.string(), overrideReason: z7.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
7981
- rethink: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => {
7544
+ workWaypoint: publicProcedure.input(z6.object({ featureId: z6.string(), waypointId: z6.string(), endLive: z6.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
7545
+ converge: publicProcedure.input(z6.object({ featureId: z6.string(), overrideReason: z6.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
7546
+ rethink: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => {
7982
7547
  const feature = rethink(ctx, input.featureId);
7983
7548
  return launchSession(ctx, {
7984
7549
  featureId: input.featureId,
@@ -7986,21 +7551,21 @@ var init_feature = __esm(() => {
7986
7551
  kickoffLine: lapKickoff(feature.lap)
7987
7552
  });
7988
7553
  }),
7989
- resendKickoff: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
7990
- endSession: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
7991
- advance: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
7992
- overrideGate: publicProcedure.input(z7.object({ featureId: z7.string(), gate: gateId, reason: z7.string().min(1) })).mutation(({ ctx, input }) => overrideGate(ctx, input.featureId, input.gate, input.reason)),
7993
- archive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
7994
- unarchive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
7995
- delete: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
7996
- burn: publicProcedure.input(z7.object({ featureId: z7.string(), model: z7.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
7997
- testDrive: publicProcedure.input(z7.object({ featureId: z7.string(), action: z7.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
7554
+ resendKickoff: publicProcedure.input(z6.object({ sessionId: z6.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
7555
+ endSession: publicProcedure.input(z6.object({ sessionId: z6.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
7556
+ advance: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
7557
+ overrideGate: publicProcedure.input(z6.object({ featureId: z6.string(), gate: gateId, reason: z6.string().min(1) })).mutation(({ ctx, input }) => overrideGate(ctx, input.featureId, input.gate, input.reason)),
7558
+ archive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
7559
+ unarchive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
7560
+ delete: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
7561
+ burn: publicProcedure.input(z6.object({ featureId: z6.string(), model: z6.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
7562
+ testDrive: publicProcedure.input(z6.object({ featureId: z6.string(), action: z6.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
7998
7563
  const feature = getFeatureRow(ctx, input.featureId);
7999
7564
  const project = projectForFeature(ctx, feature);
8000
7565
  return testDrive(ctx, project, feature, input.action);
8001
7566
  }),
8002
7567
  driveInfo: publicProcedure.query(() => activeDriveInfo()),
8003
- merge: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(async ({ ctx, input }) => {
7568
+ merge: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(async ({ ctx, input }) => {
8004
7569
  const feature = getFeatureRow(ctx, input.featureId);
8005
7570
  const project = projectForFeature(ctx, feature);
8006
7571
  if (activeTestDriveFeatureId() === feature.id) {
@@ -8023,9 +7588,9 @@ var init_feature = __esm(() => {
8023
7588
  });
8024
7589
 
8025
7590
  // src/services/fsbrowse.ts
8026
- import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
7591
+ import { existsSync as existsSync14, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
8027
7592
  import { homedir as homedir2 } from "os";
8028
- import { basename, dirname as dirname7, isAbsolute, join as join15, resolve as resolve9, sep as sep3 } from "path";
7593
+ import { basename, dirname as dirname6, isAbsolute, join as join14, resolve as resolve9, sep as sep3 } from "path";
8029
7594
  function expandPath(input, home = homedir2()) {
8030
7595
  const trimmed = input.trim();
8031
7596
  if (trimmed === "")
@@ -8034,12 +7599,12 @@ function expandPath(input, home = homedir2()) {
8034
7599
  if (out === "~")
8035
7600
  out = home;
8036
7601
  else if (out.startsWith("~/") || out.startsWith("~\\"))
8037
- out = join15(home, out.slice(2));
7602
+ out = join14(home, out.slice(2));
8038
7603
  return resolve9(out);
8039
7604
  }
8040
7605
  function looksLikeRepo(dir) {
8041
7606
  try {
8042
- return existsSync15(join15(dir, ".git"));
7607
+ return existsSync14(join14(dir, ".git"));
8043
7608
  } catch {
8044
7609
  return false;
8045
7610
  }
@@ -8050,20 +7615,20 @@ function listRoots(home = homedir2()) {
8050
7615
  for (let code = 67;code <= 90; code++) {
8051
7616
  const drive = `${String.fromCharCode(code)}:${sep3}`;
8052
7617
  try {
8053
- if (existsSync15(drive))
7618
+ if (existsSync14(drive))
8054
7619
  roots.push({ label: drive.replace(/\\$/, ""), path: drive, kind: "drive" });
8055
7620
  } catch {}
8056
7621
  }
8057
7622
  } else {
8058
7623
  roots.push({ label: "/", path: "/", kind: "drive" });
8059
7624
  }
8060
- if (existsSync15(home))
7625
+ if (existsSync14(home))
8061
7626
  roots.push({ label: "Home", path: home, kind: "home" });
8062
7627
  const seen = new Set(roots.map(pathKey));
8063
7628
  for (const name of COMMON_DIRS) {
8064
- const candidate = join15(home, name);
7629
+ const candidate = join14(home, name);
8065
7630
  const key = pathKey({ path: candidate });
8066
- if (seen.has(key) || !existsSync15(candidate))
7631
+ if (seen.has(key) || !existsSync14(candidate))
8067
7632
  continue;
8068
7633
  seen.add(key);
8069
7634
  roots.push({ label: name, path: candidate, kind: "common" });
@@ -8078,7 +7643,7 @@ function crumbsFor(dir) {
8078
7643
  let cur = dir;
8079
7644
  for (;; ) {
8080
7645
  out.unshift({ name: basename(cur) || cur, path: cur });
8081
- const parent = dirname7(cur);
7646
+ const parent = dirname6(cur);
8082
7647
  if (parent === cur)
8083
7648
  break;
8084
7649
  cur = parent;
@@ -8086,7 +7651,7 @@ function crumbsFor(dir) {
8086
7651
  return out;
8087
7652
  }
8088
7653
  function parentOf(dir) {
8089
- const parent = dirname7(dir);
7654
+ const parent = dirname6(dir);
8090
7655
  return parent === dir ? null : parent;
8091
7656
  }
8092
7657
  function browseDir(input, showHidden = false, home = homedir2()) {
@@ -8094,13 +7659,13 @@ function browseDir(input, showHidden = false, home = homedir2()) {
8094
7659
  if (!isAbsolute(target)) {
8095
7660
  throw new InvalidInputError(`path is not absolute: ${target}`);
8096
7661
  }
8097
- if (!existsSync15(target)) {
7662
+ if (!existsSync14(target)) {
8098
7663
  throw new InvalidInputError(`path does not exist: ${target}`);
8099
7664
  }
8100
7665
  let dir = target;
8101
7666
  try {
8102
7667
  if (!statSync2(target).isDirectory())
8103
- dir = dirname7(target);
7668
+ dir = dirname6(target);
8104
7669
  } catch (e) {
8105
7670
  throw new InvalidInputError(`cannot read path: ${target} (${errMsg2(e)})`);
8106
7671
  }
@@ -8122,7 +7687,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
8122
7687
  continue;
8123
7688
  if (!showHidden && name.startsWith("."))
8124
7689
  continue;
8125
- const path = join15(dir, name);
7690
+ const path = join14(dir, name);
8126
7691
  const isSymlink = dirent.isSymbolicLink();
8127
7692
  let isDirectory = dirent.isDirectory();
8128
7693
  if (isSymlink) {
@@ -8157,9 +7722,9 @@ var init_fsbrowse = __esm(() => {
8157
7722
  });
8158
7723
 
8159
7724
  // src/services/projects.ts
8160
- import { existsSync as existsSync16 } from "fs";
8161
- import { basename as basename2, join as join16 } from "path";
8162
- import { eq as eq11, isNull } from "drizzle-orm";
7725
+ import { existsSync as existsSync15 } from "fs";
7726
+ import { basename as basename2, join as join15 } from "path";
7727
+ import { eq as eq10, isNull } from "drizzle-orm";
8163
7728
  function listProjects(ctx) {
8164
7729
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
8165
7730
  }
@@ -8181,14 +7746,13 @@ async function openProject(ctx, rawPath) {
8181
7746
  const name = basename2(repoPath) || repoPath;
8182
7747
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
8183
7748
  if (existing) {
8184
- ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq11(projects.id, existing.id)).run();
7749
+ ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq10(projects.id, existing.id)).run();
8185
7750
  const reopened = { ...existing, mainBranch, repoPath };
8186
7751
  emitProject(ctx, existing.id, {
8187
7752
  type: "project.opened",
8188
7753
  message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
8189
7754
  });
8190
7755
  warnIfTranslatedMount(ctx, existing.id, repoPath);
8191
- maybeAutoPrepare(ctx, reopened);
8192
7756
  return reopened;
8193
7757
  }
8194
7758
  const row = {
@@ -8206,25 +7770,14 @@ async function openProject(ctx, rawPath) {
8206
7770
  message: `project ${name} at ${repoPath} (${mainBranch})`
8207
7771
  });
8208
7772
  warnIfTranslatedMount(ctx, project.id, repoPath);
8209
- maybeAutoPrepare(ctx, project);
8210
7773
  return project;
8211
7774
  }
8212
- function maybeAutoPrepare(ctx, project) {
8213
- if (!shouldAutoPrepare(ctx, project))
8214
- return;
8215
- startPrep(ctx, project.id).catch((e) => {
8216
- emitProject(ctx, project.id, {
8217
- type: "prep.run.failed",
8218
- message: `could not start preparation: ${e instanceof Error ? e.message : String(e)}`
8219
- });
8220
- });
8221
- }
8222
7775
  function closeProject(ctx, projectId) {
8223
7776
  const project = requireProjectById(ctx, projectId);
8224
7777
  if (projectHasActiveRun(ctx, projectId)) {
8225
7778
  throw new InvalidInputError(`cannot close ${project.name}: a run is in flight \u2014 wait for it to finish or cancel it first`);
8226
7779
  }
8227
- ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq11(projects.id, projectId)).run();
7780
+ ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq10(projects.id, projectId)).run();
8228
7781
  emitProject(ctx, projectId, {
8229
7782
  type: "project.closed",
8230
7783
  message: `project ${project.name} closed`
@@ -8233,7 +7786,7 @@ function closeProject(ctx, projectId) {
8233
7786
  }
8234
7787
  function renameProject(ctx, projectId, name) {
8235
7788
  const project = requireProjectById(ctx, projectId);
8236
- ctx.db.update(projects).set({ name }).where(eq11(projects.id, projectId)).run();
7789
+ ctx.db.update(projects).set({ name }).where(eq10(projects.id, projectId)).run();
8237
7790
  emitProject(ctx, projectId, {
8238
7791
  type: "project.renamed",
8239
7792
  message: `project renamed ${project.name} \u2192 ${name}`,
@@ -8251,7 +7804,7 @@ async function assertRepoTolerant(ctx, repoPath) {
8251
7804
  } catch (e) {
8252
7805
  if (!isNotImplemented(e))
8253
7806
  throw e;
8254
- if (!existsSync16(join16(repoPath, ".git"))) {
7807
+ if (!existsSync15(join15(repoPath, ".git"))) {
8255
7808
  throw new InvalidInputError(`not a git repository: ${repoPath}`);
8256
7809
  }
8257
7810
  }
@@ -8272,16 +7825,13 @@ var init_projects = __esm(() => {
8272
7825
  init_events();
8273
7826
  init_fsbrowse();
8274
7827
  init_git();
8275
- init_prep();
8276
7828
  init_repo();
8277
7829
  });
8278
7830
 
8279
7831
  // src/trpc/routers/project.ts
8280
- import * as z8 from "zod";
7832
+ import * as z7 from "zod";
8281
7833
  var projectRouter;
8282
7834
  var init_project = __esm(() => {
8283
- init_src();
8284
- init_findings();
8285
7835
  init_fsbrowse();
8286
7836
  init_git();
8287
7837
  init_prep();
@@ -8293,44 +7843,21 @@ var init_project = __esm(() => {
8293
7843
  projectRouter = router({
8294
7844
  list: publicProcedure.query(({ ctx }) => listProjects(ctx)),
8295
7845
  roots: publicProcedure.query(() => listRoots()),
8296
- browse: publicProcedure.input(z8.object({ dir: z8.string().optional(), showHidden: z8.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
8297
- branches: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
8298
- open: publicProcedure.input(z8.object({ repoPath: z8.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
8299
- close: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
8300
- rename: publicProcedure.input(z8.object({ projectId: z8.string(), name: z8.string().min(1) })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
8301
- prepare: publicProcedure.input(z8.object({
8302
- projectId: z8.string(),
8303
- refresh: z8.boolean().optional(),
8304
- keys: z8.array(PreparedKey).optional()
8305
- })).mutation(async ({ ctx, input }) => {
8306
- const { prepId, keys } = await startPrep(ctx, input.projectId, {
8307
- ...input.refresh !== undefined ? { refresh: input.refresh } : {},
8308
- ...input.keys ? { keys: input.keys } : {}
8309
- });
8310
- return { prepId, keys };
8311
- }),
8312
- talkToPrep: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId })),
8313
- prepSession: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
8314
- talkToProject: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => launchProjectSession(ctx, { projectId: input.projectId })),
8315
- projectSession: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "project")),
8316
- cancelPrepare: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ input }) => {
8317
- cancelPrep(input.projectId);
8318
- return { ok: true };
8319
- }),
8320
- prep: publicProcedure.input(z8.object({ projectId: z8.string() })).query(async ({ ctx, input }) => {
8321
- const project = requireProjectById(ctx, input.projectId);
8322
- return {
8323
- latest: latestPrep(ctx, project.id),
8324
- running: isPreparing(project.id),
8325
- pendingKeys: keysToPrepare(ctx, project),
8326
- findings: await listFindings(ctx, project)
8327
- };
8328
- })
7846
+ browse: publicProcedure.input(z7.object({ dir: z7.string().optional(), showHidden: z7.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
7847
+ branches: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
7848
+ open: publicProcedure.input(z7.object({ repoPath: z7.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
7849
+ close: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
7850
+ rename: publicProcedure.input(z7.object({ projectId: z7.string(), name: z7.string().min(1) })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
7851
+ talkToPrep: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId })),
7852
+ prepSession: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
7853
+ talkToProject: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => launchProjectSession(ctx, { projectId: input.projectId })),
7854
+ projectSession: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "project")),
7855
+ prep: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => prepView(ctx, requireProjectById(ctx, input.projectId)))
8329
7856
  });
8330
7857
  });
8331
7858
 
8332
7859
  // src/trpc/routers/run.ts
8333
- import * as z9 from "zod";
7860
+ import * as z8 from "zod";
8334
7861
  var runRouter;
8335
7862
  var init_run = __esm(() => {
8336
7863
  init_agent_stream();
@@ -8338,9 +7865,9 @@ var init_run = __esm(() => {
8338
7865
  init_runner();
8339
7866
  init_context();
8340
7867
  runRouter = router({
8341
- get: publicProcedure.input(z9.object({ runId: z9.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
8342
- agentTranscript: publicProcedure.input(z9.object({ ticketId: z9.string(), after: z9.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
8343
- cancel: publicProcedure.input(z9.object({ runId: z9.string() })).mutation(({ input }) => {
7868
+ get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
7869
+ agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
7870
+ cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
8344
7871
  cancelRun(input.runId);
8345
7872
  return { ok: true };
8346
7873
  })
@@ -8348,15 +7875,15 @@ var init_run = __esm(() => {
8348
7875
  });
8349
7876
 
8350
7877
  // src/services/settings.ts
8351
- import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
8352
- import { dirname as dirname8 } from "path";
8353
- import * as z10 from "zod";
8354
- import { eq as eq12 } from "drizzle-orm";
7878
+ import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
7879
+ import { dirname as dirname7 } from "path";
7880
+ import * as z9 from "zod";
7881
+ import { eq as eq11 } from "drizzle-orm";
8355
7882
  function readRawConfig(configFile) {
8356
- if (!existsSync17(configFile))
7883
+ if (!existsSync16(configFile))
8357
7884
  return {};
8358
7885
  try {
8359
- const parsed = JSON.parse(readFileSync7(configFile, "utf8"));
7886
+ const parsed = JSON.parse(readFileSync6(configFile, "utf8"));
8360
7887
  if (typeof parsed !== "object" || parsed === null)
8361
7888
  return {};
8362
7889
  return foldLegacyModelConfig(parsed);
@@ -8391,7 +7918,7 @@ function projectOverrides(ctx, projectId) {
8391
7918
  driveSetupCommand: projects.driveSetupCommand,
8392
7919
  driveStopCommand: projects.driveStopCommand,
8393
7920
  driveEnv: projects.driveEnv
8394
- }).from(projects).where(eq12(projects.id, projectId)).get();
7921
+ }).from(projects).where(eq11(projects.id, projectId)).get();
8395
7922
  return {
8396
7923
  model: row?.model ?? null,
8397
7924
  sandbox: row?.sandbox ?? null,
@@ -8405,26 +7932,26 @@ function projectOverrides(ctx, projectId) {
8405
7932
  driveEnv: row?.driveEnv ?? null
8406
7933
  };
8407
7934
  }
8408
- function resolveField(desc6, layers) {
7935
+ function resolveField(desc5, layers) {
8409
7936
  const scoped = layers.overrides !== null;
8410
- const scope = scoped && desc6.projectColumn ? "project" : "global";
8411
- const base = { key: desc6.key, restartRequired: desc6.restartRequired, scope };
8412
- if (desc6.envVar) {
8413
- const raw = layers.env[desc6.envVar];
7937
+ const scope = scoped && desc5.projectColumn ? "project" : "global";
7938
+ const base = { key: desc5.key, restartRequired: desc5.restartRequired, scope };
7939
+ if (desc5.envVar) {
7940
+ const raw = layers.env[desc5.envVar];
8414
7941
  if (raw !== undefined && raw !== "") {
8415
- return { ...base, value: desc6.parseEnv(raw), source: "env", editable: false };
7942
+ return { ...base, value: desc5.parseEnv(raw), source: "env", editable: false };
8416
7943
  }
8417
7944
  }
8418
- if (layers.overrides && desc6.projectColumn) {
8419
- const ov = layers.overrides[desc6.projectColumn];
7945
+ if (layers.overrides && desc5.projectColumn) {
7946
+ const ov = layers.overrides[desc5.projectColumn];
8420
7947
  if (ov !== null && ov !== "") {
8421
7948
  return { ...base, value: ov, source: "project", editable: true };
8422
7949
  }
8423
7950
  }
8424
- if (desc6.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc6.configKey)) {
8425
- return { ...base, value: layers.fileRaw[desc6.configKey], source: "file", editable: true };
7951
+ if (desc5.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc5.configKey)) {
7952
+ return { ...base, value: layers.fileRaw[desc5.configKey], source: "file", editable: true };
8426
7953
  }
8427
- const value = desc6.configKey ? DEFAULTS[desc6.configKey] ?? null : null;
7954
+ const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
8428
7955
  return { ...base, value, source: "default", editable: true };
8429
7956
  }
8430
7957
  function getSettings(ctx, projectId, io = {}) {
@@ -8441,61 +7968,61 @@ function updateSettings(ctx, input, io = {}) {
8441
7968
  if (input.key.startsWith("stepModels.")) {
8442
7969
  return updateStepModel(ctx, input, configFile, io);
8443
7970
  }
8444
- const desc6 = DESCRIPTORS.find((d) => d.key === input.key);
8445
- if (!desc6)
7971
+ const desc5 = DESCRIPTORS.find((d) => d.key === input.key);
7972
+ if (!desc5)
8446
7973
  throw new InvalidInputError(`unknown setting: ${input.key}`);
8447
- if (desc6.envVar) {
8448
- const raw = env[desc6.envVar];
7974
+ if (desc5.envVar) {
7975
+ const raw = env[desc5.envVar];
8449
7976
  if (raw !== undefined && raw !== "") {
8450
- throw new InvalidInputError(`${desc6.key} is set by environment variable ${desc6.envVar}; unset it to edit`);
7977
+ throw new InvalidInputError(`${desc5.key} is set by environment variable ${desc5.envVar}; unset it to edit`);
8451
7978
  }
8452
7979
  }
8453
- const toProject = input.projectId !== undefined && desc6.projectColumn !== undefined;
7980
+ const toProject = input.projectId !== undefined && desc5.projectColumn !== undefined;
8454
7981
  if (input.value === null) {
8455
7982
  if (!toProject)
8456
- throw new InvalidInputError(`${desc6.key} cannot be cleared`);
7983
+ throw new InvalidInputError(`${desc5.key} cannot be cleared`);
8457
7984
  const project = requireProjectById(ctx, input.projectId);
8458
- ctx.db.update(projects).set({ [desc6.projectColumn]: null }).where(eq12(projects.id, project.id)).run();
8459
- recordHuman(ctx, project.id, desc6.key, null);
7985
+ ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq11(projects.id, project.id)).run();
7986
+ recordHuman(ctx, project.id, desc5.key, null);
8460
7987
  emitProject(ctx, project.id, {
8461
7988
  type: "settings.updated",
8462
- message: `${desc6.key} override cleared`,
8463
- data: { key: desc6.key, scope: "project", value: null }
7989
+ message: `${desc5.key} override cleared`,
7990
+ data: { key: desc5.key, scope: "project", value: null }
8464
7991
  });
8465
- return field(getSettings(ctx, input.projectId, io), desc6.key);
7992
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
8466
7993
  }
8467
- const parsed = desc6.valueSchema.safeParse(input.value);
7994
+ const parsed = desc5.valueSchema.safeParse(input.value);
8468
7995
  if (!parsed.success) {
8469
- throw new InvalidInputError(`invalid value for ${desc6.key}: ${parsed.error.issues[0]?.message}`);
7996
+ throw new InvalidInputError(`invalid value for ${desc5.key}: ${parsed.error.issues[0]?.message}`);
8470
7997
  }
8471
7998
  const value = parsed.data;
8472
7999
  if (toProject) {
8473
8000
  const project = requireProjectById(ctx, input.projectId);
8474
- ctx.db.update(projects).set({ [desc6.projectColumn]: String(value) }).where(eq12(projects.id, project.id)).run();
8475
- recordHuman(ctx, project.id, desc6.key, String(value));
8001
+ ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq11(projects.id, project.id)).run();
8002
+ recordHuman(ctx, project.id, desc5.key, String(value));
8476
8003
  emitProject(ctx, project.id, {
8477
8004
  type: "settings.updated",
8478
- message: `${desc6.key} override set to ${String(value)}`,
8479
- data: { key: desc6.key, scope: "project", value }
8005
+ message: `${desc5.key} override set to ${String(value)}`,
8006
+ data: { key: desc5.key, scope: "project", value }
8480
8007
  });
8481
- return field(getSettings(ctx, input.projectId, io), desc6.key);
8008
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
8482
8009
  }
8483
- if (!desc6.configKey) {
8484
- throw new InvalidInputError(`${desc6.key} is a per-project setting; provide a projectId`);
8010
+ if (!desc5.configKey) {
8011
+ throw new InvalidInputError(`${desc5.key} is a per-project setting; provide a projectId`);
8485
8012
  }
8486
- writeGlobal(configFile, desc6.configKey, value);
8487
- ctx.config[desc6.configKey] = value;
8013
+ writeGlobal(configFile, desc5.configKey, value);
8014
+ ctx.config[desc5.configKey] = value;
8488
8015
  emitProject(ctx, GLOBAL_EVENT_KEY, {
8489
8016
  type: "settings.updated",
8490
- message: `${desc6.key} set to ${String(value)}`,
8491
- data: { key: desc6.key, scope: "global", value }
8017
+ message: `${desc5.key} set to ${String(value)}`,
8018
+ data: { key: desc5.key, scope: "global", value }
8492
8019
  });
8493
- return field(getSettings(ctx, input.projectId, io), desc6.key);
8020
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
8494
8021
  }
8495
8022
  function writeGlobal(configFile, configKey, value) {
8496
8023
  const raw = readRawConfig(configFile);
8497
8024
  raw[configKey] = value;
8498
- mkdirSync8(dirname8(configFile), { recursive: true });
8025
+ mkdirSync7(dirname7(configFile), { recursive: true });
8499
8026
  writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
8500
8027
  `);
8501
8028
  }
@@ -8506,7 +8033,7 @@ function updateStepModel(ctx, input, configFile, io) {
8506
8033
  const modelStep = step;
8507
8034
  let value = null;
8508
8035
  if (input.value !== null) {
8509
- const parsed = z10.string().min(1).safeParse(input.value);
8036
+ const parsed = z9.string().min(1).safeParse(input.value);
8510
8037
  if (!parsed.success) {
8511
8038
  throw new InvalidInputError(`invalid value for ${input.key}: ${parsed.error.issues[0]?.message}`);
8512
8039
  }
@@ -8534,7 +8061,7 @@ function writeStepModel(configFile, step, value) {
8534
8061
  else
8535
8062
  stepModels[step] = value;
8536
8063
  raw.stepModels = stepModels;
8537
- mkdirSync8(dirname8(configFile), { recursive: true });
8064
+ mkdirSync7(dirname7(configFile), { recursive: true });
8538
8065
  writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
8539
8066
  `);
8540
8067
  }
@@ -8559,7 +8086,7 @@ var init_settings = __esm(() => {
8559
8086
  configKey: "serverPort",
8560
8087
  envVar: "RUNCASTLE_SERVER_PORT",
8561
8088
  restartRequired: true,
8562
- valueSchema: z10.number().int().positive(),
8089
+ valueSchema: z9.number().int().positive(),
8563
8090
  parseEnv: (raw) => Number(raw)
8564
8091
  },
8565
8092
  {
@@ -8568,7 +8095,7 @@ var init_settings = __esm(() => {
8568
8095
  envVar: "RUNCASTLE_MODEL",
8569
8096
  projectColumn: "model",
8570
8097
  restartRequired: false,
8571
- valueSchema: z10.string().min(1),
8098
+ valueSchema: z9.string().min(1),
8572
8099
  parseEnv: idEnv
8573
8100
  },
8574
8101
  {
@@ -8577,7 +8104,7 @@ var init_settings = __esm(() => {
8577
8104
  envVar: "RUNCASTLE_SANDBOX",
8578
8105
  projectColumn: "sandbox",
8579
8106
  restartRequired: false,
8580
- valueSchema: z10.enum(["docker", "podman", "noSandbox"]),
8107
+ valueSchema: z9.enum(["docker", "podman", "noSandbox"]),
8581
8108
  parseEnv: idEnv
8582
8109
  },
8583
8110
  {
@@ -8585,7 +8112,7 @@ var init_settings = __esm(() => {
8585
8112
  configKey: "sandboxImage",
8586
8113
  envVar: "RUNCASTLE_SANDBOX_IMAGE",
8587
8114
  restartRequired: false,
8588
- valueSchema: z10.string().min(1),
8115
+ valueSchema: z9.string().min(1),
8589
8116
  parseEnv: idEnv
8590
8117
  },
8591
8118
  {
@@ -8593,7 +8120,7 @@ var init_settings = __esm(() => {
8593
8120
  configKey: "sessionMcp",
8594
8121
  envVar: "RUNCASTLE_SESSION_MCP",
8595
8122
  restartRequired: false,
8596
- valueSchema: z10.enum(["inherit", "runcastleOnly"]),
8123
+ valueSchema: z9.enum(["inherit", "runcastleOnly"]),
8597
8124
  parseEnv: idEnv
8598
8125
  },
8599
8126
  {
@@ -8601,7 +8128,7 @@ var init_settings = __esm(() => {
8601
8128
  configKey: "burnConcurrency",
8602
8129
  envVar: "RUNCASTLE_BURN_CONCURRENCY",
8603
8130
  restartRequired: false,
8604
- valueSchema: z10.number().int().min(1).max(8),
8131
+ valueSchema: z9.number().int().min(1).max(8),
8605
8132
  parseEnv: (raw) => Number(raw)
8606
8133
  },
8607
8134
  {
@@ -8609,7 +8136,7 @@ var init_settings = __esm(() => {
8609
8136
  configKey: "burnMaxIterations",
8610
8137
  envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
8611
8138
  restartRequired: false,
8612
- valueSchema: z10.number().int().min(1).max(10),
8139
+ valueSchema: z9.number().int().min(1).max(10),
8613
8140
  parseEnv: (raw) => Number(raw)
8614
8141
  },
8615
8142
  {
@@ -8617,7 +8144,7 @@ var init_settings = __esm(() => {
8617
8144
  configKey: "burnAttempts",
8618
8145
  envVar: "RUNCASTLE_BURN_ATTEMPTS",
8619
8146
  restartRequired: false,
8620
- valueSchema: z10.number().int().min(1).max(5),
8147
+ valueSchema: z9.number().int().min(1).max(5),
8621
8148
  parseEnv: (raw) => Number(raw)
8622
8149
  },
8623
8150
  {
@@ -8625,7 +8152,7 @@ var init_settings = __esm(() => {
8625
8152
  configKey: "burnConflictAttempts",
8626
8153
  envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
8627
8154
  restartRequired: false,
8628
- valueSchema: z10.number().int().min(0).max(3),
8155
+ valueSchema: z9.number().int().min(0).max(3),
8629
8156
  parseEnv: (raw) => Number(raw)
8630
8157
  },
8631
8158
  {
@@ -8633,24 +8160,16 @@ var init_settings = __esm(() => {
8633
8160
  configKey: "burnCpus",
8634
8161
  envVar: "RUNCASTLE_BURN_CPUS",
8635
8162
  restartRequired: false,
8636
- valueSchema: z10.number().positive().max(256),
8163
+ valueSchema: z9.number().positive().max(256),
8637
8164
  parseEnv: (raw) => Number(raw)
8638
8165
  },
8639
- {
8640
- key: "autoPrepare",
8641
- configKey: "autoPrepare",
8642
- envVar: "RUNCASTLE_AUTO_PREPARE",
8643
- restartRequired: false,
8644
- valueSchema: z10.boolean(),
8645
- parseEnv: (raw) => raw !== "0" && raw.toLowerCase() !== "false"
8646
- },
8647
8166
  {
8648
8167
  key: "setupCommand",
8649
8168
  configKey: "setupCommand",
8650
8169
  envVar: "RUNCASTLE_SETUP_COMMAND",
8651
8170
  projectColumn: "setupCommand",
8652
8171
  restartRequired: false,
8653
- valueSchema: z10.string().min(1),
8172
+ valueSchema: z9.string().min(1),
8654
8173
  parseEnv: idEnv
8655
8174
  },
8656
8175
  {
@@ -8659,7 +8178,7 @@ var init_settings = __esm(() => {
8659
8178
  envVar: "RUNCASTLE_VERIFY_COMMANDS",
8660
8179
  projectColumn: "verifyCommands",
8661
8180
  restartRequired: false,
8662
- valueSchema: z10.string().min(1),
8181
+ valueSchema: z9.string().min(1),
8663
8182
  parseEnv: idEnv
8664
8183
  },
8665
8184
  {
@@ -8668,7 +8187,7 @@ var init_settings = __esm(() => {
8668
8187
  envVar: "RUNCASTLE_KNOWN_FAILURES",
8669
8188
  projectColumn: "knownFailures",
8670
8189
  restartRequired: false,
8671
- valueSchema: z10.string().min(1),
8190
+ valueSchema: z9.string().min(1),
8672
8191
  parseEnv: idEnv
8673
8192
  },
8674
8193
  {
@@ -8676,42 +8195,42 @@ var init_settings = __esm(() => {
8676
8195
  configKey: "mainBranch",
8677
8196
  envVar: "RUNCASTLE_MAIN_BRANCH",
8678
8197
  restartRequired: false,
8679
- valueSchema: z10.string().min(1),
8198
+ valueSchema: z9.string().min(1),
8680
8199
  parseEnv: idEnv
8681
8200
  },
8682
8201
  {
8683
8202
  key: "devCommand",
8684
8203
  projectColumn: "devCommand",
8685
8204
  restartRequired: false,
8686
- valueSchema: z10.string().min(1),
8205
+ valueSchema: z9.string().min(1),
8687
8206
  parseEnv: idEnv
8688
8207
  },
8689
8208
  {
8690
8209
  key: "dbResetCommand",
8691
8210
  projectColumn: "dbResetCommand",
8692
8211
  restartRequired: false,
8693
- valueSchema: z10.string().min(1),
8212
+ valueSchema: z9.string().min(1),
8694
8213
  parseEnv: idEnv
8695
8214
  },
8696
8215
  {
8697
8216
  key: "driveSetupCommand",
8698
8217
  projectColumn: "driveSetupCommand",
8699
8218
  restartRequired: false,
8700
- valueSchema: z10.string().min(1),
8219
+ valueSchema: z9.string().min(1),
8701
8220
  parseEnv: idEnv
8702
8221
  },
8703
8222
  {
8704
8223
  key: "driveStopCommand",
8705
8224
  projectColumn: "driveStopCommand",
8706
8225
  restartRequired: false,
8707
- valueSchema: z10.string().min(1),
8226
+ valueSchema: z9.string().min(1),
8708
8227
  parseEnv: idEnv
8709
8228
  },
8710
8229
  {
8711
8230
  key: "driveEnv",
8712
8231
  projectColumn: "driveEnv",
8713
8232
  restartRequired: false,
8714
- valueSchema: z10.string().min(1),
8233
+ valueSchema: z9.string().min(1),
8715
8234
  parseEnv: idEnv
8716
8235
  }
8717
8236
  ];
@@ -8720,14 +8239,14 @@ var init_settings = __esm(() => {
8720
8239
  });
8721
8240
 
8722
8241
  // src/trpc/routers/settings.ts
8723
- import * as z11 from "zod";
8242
+ import * as z10 from "zod";
8724
8243
  var settingsRouter;
8725
8244
  var init_settings2 = __esm(() => {
8726
8245
  init_src();
8727
8246
  init_settings();
8728
8247
  init_context();
8729
8248
  settingsRouter = router({
8730
- get: publicProcedure.input(z11.object({ projectId: z11.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
8249
+ get: publicProcedure.input(z10.object({ projectId: z10.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
8731
8250
  update: publicProcedure.input(SettingsUpdateInput).mutation(({ ctx, input }) => updateSettings(ctx, input))
8732
8251
  });
8733
8252
  });
@@ -8920,9 +8439,9 @@ var init_system_exec = __esm(() => {
8920
8439
  });
8921
8440
 
8922
8441
  // src/services/setup.ts
8923
- import { copyFileSync, existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
8924
- import { dirname as dirname9, join as join17 } from "path";
8925
- import { fileURLToPath as fileURLToPath7 } from "url";
8442
+ import { copyFileSync, existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
8443
+ import { dirname as dirname8, join as join16 } from "path";
8444
+ import { fileURLToPath as fileURLToPath6 } from "url";
8926
8445
  async function writeGitIdentity(exec, identity) {
8927
8446
  const name = identity.name.trim();
8928
8447
  const email = identity.email.trim();
@@ -8997,16 +8516,16 @@ async function resolveRuntime(exec, preferred) {
8997
8516
  function resolveSandcastleBin() {
8998
8517
  try {
8999
8518
  const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
9000
- let dir = dirname9(fileURLToPath7(mainUrl));
8519
+ let dir = dirname8(fileURLToPath6(mainUrl));
9001
8520
  for (let hops = 0;hops < 6; hops++) {
9002
- const manifestPath = join17(dir, "package.json");
9003
- if (existsSync18(manifestPath)) {
9004
- const manifest = JSON.parse(readFileSync8(manifestPath, "utf8"));
8521
+ const manifestPath = join16(dir, "package.json");
8522
+ if (existsSync17(manifestPath)) {
8523
+ const manifest = JSON.parse(readFileSync7(manifestPath, "utf8"));
9005
8524
  if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
9006
- return join17(dir, manifest.bin.sandcastle);
8525
+ return join16(dir, manifest.bin.sandcastle);
9007
8526
  }
9008
8527
  }
9009
- const parent = dirname9(dir);
8528
+ const parent = dirname8(dir);
9010
8529
  if (parent === dir)
9011
8530
  break;
9012
8531
  dir = parent;
@@ -9028,21 +8547,21 @@ function terminalSpec(kind, opts) {
9028
8547
  };
9029
8548
  }
9030
8549
  function sandcastleTemplateDir() {
9031
- return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath7(new URL("../assets/sandcastle", import.meta.url)));
8550
+ return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath6(new URL("../assets/sandcastle", import.meta.url)));
9032
8551
  }
9033
8552
  function scaffoldSandcastleConfig(templateDir, targetDir) {
9034
- const dir = join17(targetDir, ".sandcastle");
9035
- if (existsSync18(dir))
8553
+ const dir = join16(targetDir, ".sandcastle");
8554
+ if (existsSync17(dir))
9036
8555
  return { scaffolded: false, dir };
9037
- mkdirSync9(dir, { recursive: true });
8556
+ mkdirSync8(dir, { recursive: true });
9038
8557
  for (const name of readdirSync6(templateDir)) {
9039
- copyFileSync(join17(templateDir, name), join17(dir, name));
8558
+ copyFileSync(join16(templateDir, name), join16(dir, name));
9040
8559
  }
9041
8560
  return { scaffolded: true, dir };
9042
8561
  }
9043
8562
  function prepareSandboxBuildContext() {
9044
8563
  const target = sandboxBuildDir();
9045
- mkdirSync9(target, { recursive: true });
8564
+ mkdirSync8(target, { recursive: true });
9046
8565
  scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
9047
8566
  return target;
9048
8567
  }
@@ -9078,9 +8597,9 @@ function createTokenVerifier(exec) {
9078
8597
  function fileAfkTokenIo(exec) {
9079
8598
  const path = envPath();
9080
8599
  return {
9081
- read: () => existsSync18(path) ? readFileSync8(path, "utf8") : "",
8600
+ read: () => existsSync17(path) ? readFileSync7(path, "utf8") : "",
9082
8601
  write: (content) => {
9083
- mkdirSync9(dirname9(path), { recursive: true });
8602
+ mkdirSync8(dirname8(path), { recursive: true });
9084
8603
  writeFileSync4(path, content, "utf8");
9085
8604
  },
9086
8605
  verify: createTokenVerifier(exec)
@@ -9095,7 +8614,7 @@ var init_setup = __esm(() => {
9095
8614
  });
9096
8615
 
9097
8616
  // src/trpc/routers/setup.ts
9098
- import * as z12 from "zod";
8617
+ import * as z11 from "zod";
9099
8618
  var setupRouter;
9100
8619
  var init_setup2 = __esm(() => {
9101
8620
  init_src();
@@ -9111,9 +8630,9 @@ var init_setup2 = __esm(() => {
9111
8630
  ...ctx.config.sandboxImage ? { imageName: ctx.config.sandboxImage } : {}
9112
8631
  })),
9113
8632
  runtimeGuide: publicProcedure.query(() => runtimeInstallGuide(process.platform)),
9114
- gitIdentity: publicProcedure.input(z12.object({ name: z12.string(), email: z12.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
9115
- afkToken: publicProcedure.input(z12.object({ token: z12.string() })).mutation(({ input }) => saveAfkToken(fileAfkTokenIo(createSystemExec()), input.token)),
9116
- startTerminal: publicProcedure.input(z12.object({ kind: z12.enum(["setup-token", "build-image"]) })).mutation(async ({ ctx, input }) => {
8633
+ gitIdentity: publicProcedure.input(z11.object({ name: z11.string(), email: z11.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
8634
+ afkToken: publicProcedure.input(z11.object({ token: z11.string() })).mutation(({ input }) => saveAfkToken(fileAfkTokenIo(createSystemExec()), input.token)),
8635
+ startTerminal: publicProcedure.input(z11.object({ kind: z11.enum(["setup-token", "build-image"]) })).mutation(async ({ ctx, input }) => {
9117
8636
  const exec = createSystemExec();
9118
8637
  const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
9119
8638
  const runtime = await resolveRuntime(exec, preferred);
@@ -9213,16 +8732,16 @@ var init_update_check = __esm(() => {
9213
8732
  });
9214
8733
 
9215
8734
  // src/version.ts
9216
- import { readFileSync as readFileSync9 } from "fs";
9217
- import { dirname as dirname10, join as join18 } from "path";
9218
- import { fileURLToPath as fileURLToPath8 } from "url";
8735
+ import { readFileSync as readFileSync8 } from "fs";
8736
+ import { dirname as dirname9, join as join17 } from "path";
8737
+ import { fileURLToPath as fileURLToPath7 } from "url";
9219
8738
  function runcastleVersion() {
9220
8739
  if (cached2)
9221
8740
  return cached2;
9222
- const here = dirname10(fileURLToPath8(import.meta.url));
9223
- for (const pkgPath of [join18(here, "..", "package.json"), join18(here, "package.json")]) {
8741
+ const here = dirname9(fileURLToPath7(import.meta.url));
8742
+ for (const pkgPath of [join17(here, "..", "package.json"), join17(here, "package.json")]) {
9224
8743
  try {
9225
- const pkg = JSON.parse(readFileSync9(pkgPath, "utf8"));
8744
+ const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
9226
8745
  if (typeof pkg.version === "string") {
9227
8746
  cached2 = pkg.version;
9228
8747
  return cached2;
@@ -9248,7 +8767,7 @@ var init_system = __esm(() => {
9248
8767
  });
9249
8768
 
9250
8769
  // src/trpc/routers/ticket.ts
9251
- import * as z13 from "zod";
8770
+ import * as z12 from "zod";
9252
8771
  var ticketRouter;
9253
8772
  var init_ticket = __esm(() => {
9254
8773
  init_features();
@@ -9257,8 +8776,8 @@ var init_ticket = __esm(() => {
9257
8776
  init_ticket_burner();
9258
8777
  init_context();
9259
8778
  ticketRouter = router({
9260
- retry: publicProcedure.input(z13.object({ ticketId: z13.string(), fresh: z13.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
9261
- stop: publicProcedure.input(z13.object({ ticketId: z13.string() })).mutation(({ ctx, input }) => {
8779
+ retry: publicProcedure.input(z12.object({ ticketId: z12.string(), fresh: z12.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
8780
+ stop: publicProcedure.input(z12.object({ ticketId: z12.string() })).mutation(({ ctx, input }) => {
9262
8781
  if (stopTicketRun(input.ticketId))
9263
8782
  return { stopped: true, swept: false };
9264
8783
  const ticket = getTicket(ctx, input.ticketId);
@@ -9268,13 +8787,13 @@ var init_ticket = __esm(() => {
9268
8787
  }
9269
8788
  return { stopped: false, swept: false };
9270
8789
  }),
9271
- cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason)),
9272
- edit: publicProcedure.input(z13.object({
9273
- ticketId: z13.string(),
9274
- title: z13.string().min(1).optional(),
9275
- goal: z13.string().min(1).optional(),
9276
- context: z13.string().optional(),
9277
- acceptanceCriteria: z13.array(z13.string().min(1)).optional()
8790
+ cancel: publicProcedure.input(z12.object({ ticketId: z12.string(), reason: z12.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason)),
8791
+ edit: publicProcedure.input(z12.object({
8792
+ ticketId: z12.string(),
8793
+ title: z12.string().min(1).optional(),
8794
+ goal: z12.string().min(1).optional(),
8795
+ context: z12.string().optional(),
8796
+ acceptanceCriteria: z12.array(z12.string().min(1)).optional()
9278
8797
  })).mutation(({ ctx, input }) => {
9279
8798
  const { ticketId, ...patch } = input;
9280
8799
  return editTicket(ctx, ticketId, patch);
@@ -9309,18 +8828,18 @@ var init_router = __esm(() => {
9309
8828
  });
9310
8829
 
9311
8830
  // src/workflows/reconcile-runs.ts
9312
- import { eq as eq13 } from "drizzle-orm";
8831
+ import { eq as eq12 } from "drizzle-orm";
9313
8832
  async function reconcileStaleRuns(ctx) {
9314
- const stale = ctx.db.select().from(runs).where(eq13(runs.status, "running")).all().map(rowToRun);
8833
+ const stale = ctx.db.select().from(runs).where(eq12(runs.status, "running")).all().map(rowToRun);
9315
8834
  const reconciled = [];
9316
- for (const run4 of stale) {
9317
- if (isRunActive(run4.id))
8835
+ for (const run3 of stale) {
8836
+ if (isRunActive(run3.id))
9318
8837
  continue;
9319
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq13(runs.id, run4.id)).run();
9320
- const released = releaseForSession(ctx, run4.id);
9321
- const swept = workflowClaimsFeatureBranch(run4.workflow) ? sweepOrphanedBurning(ctx, run4.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
9322
- if (workflowClaimsFeatureBranch(run4.workflow)) {
9323
- const feature = tryGetFeature(ctx, run4.featureId);
8838
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq12(runs.id, run3.id)).run();
8839
+ const released = releaseForSession(ctx, run3.id);
8840
+ const swept = workflowClaimsFeatureBranch(run3.workflow) ? sweepOrphanedBurning(ctx, run3.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
8841
+ if (workflowClaimsFeatureBranch(run3.workflow)) {
8842
+ const feature = tryGetFeature(ctx, run3.featureId);
9324
8843
  const project = feature ? getProjectById(ctx, feature.projectId) : null;
9325
8844
  if (feature && project) {
9326
8845
  try {
@@ -9328,18 +8847,18 @@ async function reconcileStaleRuns(ctx) {
9328
8847
  } catch {}
9329
8848
  }
9330
8849
  }
9331
- emit(ctx, run4.featureId, {
8850
+ emit(ctx, run3.featureId, {
9332
8851
  type: "run.reconciled",
9333
- message: `run marked failed at boot \u2014 the server restarted while it was running (${run4.workflow})`,
9334
- runId: run4.id,
8852
+ message: `run marked failed at boot \u2014 the server restarted while it was running (${run3.workflow})`,
8853
+ runId: run3.id,
9335
8854
  data: {
9336
- runId: run4.id,
9337
- workflow: run4.workflow,
8855
+ runId: run3.id,
8856
+ workflow: run3.workflow,
9338
8857
  releasedWaypointIds: released.map((w) => w.id),
9339
8858
  sweptTicketSeqs: swept.map((t2) => t2.seq)
9340
8859
  }
9341
8860
  });
9342
- reconciled.push(run4);
8861
+ reconciled.push(run3);
9343
8862
  }
9344
8863
  for (const project of allProjects(ctx)) {
9345
8864
  try {
@@ -9402,10 +8921,6 @@ async function startServer() {
9402
8921
  if (staleRuns.length > 0) {
9403
8922
  console.log(`reconciled ${staleRuns.length} stale run(s) from a previous server run`);
9404
8923
  }
9405
- const stalePreps = reconcilePreps(ctx);
9406
- if (stalePreps > 0) {
9407
- console.log(`reconciled ${stalePreps} stale preparation run(s) from a previous server run`);
9408
- }
9409
8924
  const server = Bun.serve({
9410
8925
  port: config2.serverPort,
9411
8926
  fetch(req, srv) {
@@ -9435,7 +8950,6 @@ var init_src2 = __esm(() => {
9435
8950
  init_client();
9436
8951
  init_migrate();
9437
8952
  init_reconcile();
9438
- init_prep();
9439
8953
  init_server();
9440
8954
  init_registry();
9441
8955
  init_ws();
@@ -9500,7 +9014,7 @@ __export(exports_cli, {
9500
9014
  resolveDoctorEnv: () => resolveDoctorEnv,
9501
9015
  parseMode: () => parseMode
9502
9016
  });
9503
- import { existsSync as existsSync19, readFileSync as readFileSync10 } from "fs";
9017
+ import { existsSync as existsSync18, readFileSync as readFileSync9 } from "fs";
9504
9018
  function parseMode(argv) {
9505
9019
  return argv.includes("--gate") || argv.includes("--boot") ? "gate" : "diagnostic";
9506
9020
  }
@@ -9508,8 +9022,8 @@ function envWithToken() {
9508
9022
  const merged = { ...process.env };
9509
9023
  try {
9510
9024
  const path = envPath();
9511
- if (existsSync19(path)) {
9512
- const fromFile = parseEnvFile(readFileSync10(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
9025
+ if (existsSync18(path)) {
9026
+ const fromFile = parseEnvFile(readFileSync9(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
9513
9027
  if (fromFile && fromFile.length > 0)
9514
9028
  merged.CLAUDE_CODE_OAUTH_TOKEN = fromFile;
9515
9029
  }
@@ -9551,8 +9065,8 @@ var init_cli = __esm(() => {
9551
9065
  // src/bin/runcastle.ts
9552
9066
  init_asset_paths();
9553
9067
  init_version();
9554
- import { dirname as dirname11, resolve as resolve10 } from "path";
9555
- import { fileURLToPath as fileURLToPath9 } from "url";
9068
+ import { dirname as dirname10, resolve as resolve10 } from "path";
9069
+ import { fileURLToPath as fileURLToPath8 } from "url";
9556
9070
  function parseCommand(argv) {
9557
9071
  const [first, ...rest] = argv;
9558
9072
  if (first === undefined)
@@ -9576,7 +9090,7 @@ Usage:
9576
9090
  runcastle --help Show this help
9577
9091
  `;
9578
9092
  async function main(argv) {
9579
- applyInstalledAssetEnv(resolve10(dirname11(fileURLToPath9(import.meta.url)), ".."));
9093
+ applyInstalledAssetEnv(resolve10(dirname10(fileURLToPath8(import.meta.url)), ".."));
9580
9094
  const { command, args } = parseCommand(argv);
9581
9095
  switch (command) {
9582
9096
  case "version":