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