runcastle 1.2.9 → 1.2.11

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
@@ -48,6 +48,12 @@ import * as z from "zod";
48
48
  function resolveSandboxImage(config) {
49
49
  return config.sandboxImage ?? DEFAULT_SANDBOX_IMAGE;
50
50
  }
51
+ function resolveBurnCacheMode(config) {
52
+ return BURN_CACHE_SANDBOXES.includes(config.sandbox) ? config.burnCache : "off";
53
+ }
54
+ function resolveDefaultBurnConcurrency(logicalCpus) {
55
+ return Number.isFinite(logicalCpus) && logicalCpus > SMALL_HOST_LOGICAL_CPUS ? 3 : 1;
56
+ }
51
57
  function resolvePreparedSettings(config, project) {
52
58
  const pick = (a, b) => {
53
59
  const own = a?.trim();
@@ -99,7 +105,7 @@ var MODEL_STEPS, ModelStep, AGENT_RUNTIMES, AgentRuntime, DEFAULT_RUNTIME = "cla
99
105
  }
100
106
  delete obj.smokeModel;
101
107
  return obj;
102
- }, RuncastleConfig, DEFAULT_SANDBOX_IMAGE = "sandcastle:runcastle";
108
+ }, RuncastleConfig, DEFAULT_SANDBOX_IMAGE = "sandcastle:runcastle", BURN_CACHE_SANDBOXES, SMALL_HOST_LOGICAL_CPUS = 8;
103
109
  var init_config = __esm(() => {
104
110
  MODEL_STEPS = [
105
111
  "ideation",
@@ -109,6 +115,7 @@ var init_config = __esm(() => {
109
115
  "revisit",
110
116
  "research",
111
117
  "implement",
118
+ "review",
112
119
  "prepare",
113
120
  "project",
114
121
  "smoke"
@@ -146,7 +153,6 @@ var init_config = __esm(() => {
146
153
  models: z.array(ModelEntry).default([]),
147
154
  sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
148
155
  sessionMcp: z.enum(["inherit", "runcastleOnly"]).default("inherit"),
149
- mainBranch: z.string().default("main"),
150
156
  sandboxImage: z.string().optional(),
151
157
  burnConcurrency: z.number().int().min(1).max(8).default(3),
152
158
  burnCpus: z.number().positive().max(256).optional(),
@@ -157,8 +163,10 @@ var init_config = __esm(() => {
157
163
  setupCommand: z.string().optional(),
158
164
  verifyCommands: z.string().optional(),
159
165
  knownFailures: z.string().optional(),
160
- burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
166
+ burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto"),
167
+ burnCache: z.enum(["volume", "off"]).default("volume")
161
168
  }));
169
+ BURN_CACHE_SANDBOXES = ["docker", "podman"];
162
170
  });
163
171
 
164
172
  // ../core/src/schemas.ts
@@ -166,7 +174,7 @@ import * as z2 from "zod";
166
174
  function isProjectSessionKind(kind) {
167
175
  return PROJECT_SESSION_KINDS.includes(kind);
168
176
  }
169
- var Phase, TicketStatus, SessionKind, PROJECT_SESSION_KINDS, SessionPurpose, MergeBranchPair, RunStatus, FeatureStatus, SessionStatus, TicketKind, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, TestNoteStatus, TestNoteAuthor, TestNote, PREPARED_KEYS, PreparedKey, DRIVE_LOOP_KEYS, FindingSource, PROJECT_NAME_MAX = 80, ProjectName, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
177
+ var Phase, TicketStatus, SessionKind, PROJECT_SESSION_KINDS, SessionPurpose, MergeBranchPair, RunStatus, FeatureStatus, SessionStatus, TicketKind, FindingKind, FindingSeverity, FindingStatus, FindingOpenReason, ReviewFindingInput, ReviewFinding, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, TestNoteStatus, TestNoteAuthor, TestNote, PREPARED_KEYS, PreparedKey, DRIVE_LOOP_KEYS, FindingSource, PROJECT_NAME_MAX = 80, ProjectName, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
170
178
  var init_schemas = __esm(() => {
171
179
  init_config();
172
180
  Phase = z2.enum([
@@ -198,6 +206,35 @@ var init_schemas = __esm(() => {
198
206
  FeatureStatus = z2.enum(["draft", "active", "shipped", "archived"]);
199
207
  SessionStatus = z2.enum(["launching", "live", "ended"]);
200
208
  TicketKind = z2.enum(["implementation", "review"]);
209
+ FindingKind = z2.enum(["defect", "observation"]);
210
+ FindingSeverity = z2.enum(["high", "medium", "low"]);
211
+ FindingStatus = z2.enum(["open", "fixing", "fixed", "failed", "dismissed"]);
212
+ FindingOpenReason = z2.enum(["over-cap", "fix-failed"]);
213
+ ReviewFindingInput = z2.object({
214
+ kind: FindingKind,
215
+ severity: FindingSeverity,
216
+ title: z2.string().min(1),
217
+ location: z2.string().min(1),
218
+ citation: z2.string().min(1),
219
+ detail: z2.string().min(1),
220
+ reproStep: z2.string().optional()
221
+ }).superRefine((finding, ctx) => {
222
+ if (finding.kind === "defect" && !finding.reproStep?.trim()) {
223
+ ctx.addIssue({ code: "custom", path: ["reproStep"], message: "defects require a repro step" });
224
+ }
225
+ });
226
+ ReviewFinding = ReviewFindingInput.safeExtend({
227
+ id: z2.string(),
228
+ featureId: z2.string(),
229
+ lap: z2.number(),
230
+ reviewTicketId: z2.string(),
231
+ reproStep: z2.string(),
232
+ status: FindingStatus,
233
+ openReason: FindingOpenReason.nullable(),
234
+ failureReason: z2.string().nullable(),
235
+ fixTicketId: z2.string().nullable(),
236
+ createdAt: z2.number()
237
+ });
201
238
  TicketInput = z2.object({
202
239
  title: z2.string(),
203
240
  goal: z2.string(),
@@ -206,7 +243,8 @@ var init_schemas = __esm(() => {
206
243
  seams: z2.array(z2.string()),
207
244
  blockedBy: z2.array(z2.number()),
208
245
  kind: TicketKind.default("implementation"),
209
- model: z2.string().optional()
246
+ model: z2.string().optional(),
247
+ originFindingId: z2.string().optional()
210
248
  });
211
249
  Ticket = TicketInput.extend({
212
250
  id: z2.string(),
@@ -276,7 +314,7 @@ var init_schemas = __esm(() => {
276
314
  id: z2.string(),
277
315
  name: z2.string(),
278
316
  repoPath: z2.string(),
279
- mainBranch: z2.string(),
317
+ sessionBranch: z2.string().optional(),
280
318
  devCommand: z2.string().optional(),
281
319
  model: z2.string().optional(),
282
320
  setupCommand: z2.string().optional(),
@@ -537,13 +575,13 @@ var init_pipeline = __esm(() => {
537
575
 
538
576
  // ../core/src/db-schema.ts
539
577
  import { index, integer, primaryKey, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
540
- var projects, projectFindings, features, sessions, tickets, testNotes, waypoints, runs, events, gateOverrides;
578
+ var projects, projectFindings, features, sessions, tickets, reviewFindings, testNotes, waypoints, runs, events, gateOverrides;
541
579
  var init_db_schema = __esm(() => {
542
580
  projects = sqliteTable("projects", {
543
581
  id: text("id").primaryKey(),
544
582
  name: text("name").notNull(),
545
583
  repoPath: text("repo_path").notNull(),
546
- mainBranch: text("main_branch").notNull(),
584
+ sessionBranch: text("session_branch"),
547
585
  devCommand: text("dev_command"),
548
586
  model: text("model"),
549
587
  sandbox: text("sandbox"),
@@ -616,7 +654,26 @@ var init_db_schema = __esm(() => {
616
654
  error: text("error"),
617
655
  attemptBranch: text("attempt_branch"),
618
656
  conflictFiles: text("conflict_files", { mode: "json" }).$type(),
619
- digest: text("digest")
657
+ digest: text("digest"),
658
+ originFindingId: text("origin_finding_id")
659
+ });
660
+ reviewFindings = sqliteTable("review_findings", {
661
+ id: text("id").primaryKey(),
662
+ featureId: text("feature_id").notNull(),
663
+ lap: integer("lap").notNull(),
664
+ reviewTicketId: text("review_ticket_id").notNull(),
665
+ kind: text("kind").notNull().$type(),
666
+ severity: text("severity").notNull().$type(),
667
+ title: text("title").notNull(),
668
+ location: text("location").notNull(),
669
+ citation: text("citation").notNull(),
670
+ detail: text("detail").notNull(),
671
+ reproStep: text("repro_step").notNull(),
672
+ status: text("status").notNull().$type(),
673
+ openReason: text("open_reason").$type(),
674
+ failureReason: text("failure_reason"),
675
+ fixTicketId: text("fix_ticket_id"),
676
+ createdAt: integer("created_at").notNull()
620
677
  });
621
678
  testNotes = sqliteTable("test_notes", {
622
679
  id: text("id").primaryKey(),
@@ -789,10 +846,12 @@ var init_paths = () => {};
789
846
  // ../core/src/config-load.ts
790
847
  var exports_config_load = {};
791
848
  __export(exports_config_load, {
792
- loadConfig: () => loadConfig
849
+ loadConfig: () => loadConfig,
850
+ hostLogicalCpus: () => hostLogicalCpus
793
851
  });
794
852
  import { existsSync, readFileSync } from "fs";
795
- function loadConfig(env = process.env) {
853
+ import { availableParallelism } from "os";
854
+ function loadConfig(env = process.env, logicalCpus = hostLogicalCpus()) {
796
855
  let fileConfig = {};
797
856
  const path = configPath();
798
857
  if (existsSync(path)) {
@@ -816,8 +875,6 @@ function loadConfig(env = process.env) {
816
875
  overrides.sandbox = env.RUNCASTLE_SANDBOX;
817
876
  if (env.RUNCASTLE_SESSION_MCP)
818
877
  overrides.sessionMcp = env.RUNCASTLE_SESSION_MCP;
819
- if (env.RUNCASTLE_MAIN_BRANCH)
820
- overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
821
878
  if (env.RUNCASTLE_SANDBOX_IMAGE)
822
879
  overrides.sandboxImage = env.RUNCASTLE_SANDBOX_IMAGE;
823
880
  num("burnConcurrency", env.RUNCASTLE_BURN_CONCURRENCY);
@@ -836,8 +893,17 @@ function loadConfig(env = process.env) {
836
893
  overrides.knownFailures = env.RUNCASTLE_KNOWN_FAILURES;
837
894
  if (env.RUNCASTLE_BURN_WORKSPACE)
838
895
  overrides.burnWorkspace = env.RUNCASTLE_BURN_WORKSPACE;
896
+ if (env.RUNCASTLE_BURN_CACHE)
897
+ overrides.burnCache = env.RUNCASTLE_BURN_CACHE;
839
898
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
840
- return RuncastleConfig.parse({ ...base, ...overrides });
899
+ const merged = { ...base, ...overrides };
900
+ if (merged.burnConcurrency === undefined) {
901
+ merged.burnConcurrency = resolveDefaultBurnConcurrency(logicalCpus);
902
+ }
903
+ return RuncastleConfig.parse(merged);
904
+ }
905
+ function hostLogicalCpus() {
906
+ return availableParallelism();
841
907
  }
842
908
  var init_config_load = __esm(() => {
843
909
  init_config();
@@ -866,6 +932,7 @@ var init_schema = __esm(() => {
866
932
  init_src();
867
933
  schema = {
868
934
  projects,
935
+ reviewFindings,
869
936
  features,
870
937
  sessions,
871
938
  tickets,
@@ -1574,6 +1641,198 @@ var init_registry = __esm(() => {
1574
1641
  REGISTRY_KEY = Symbol.for("runcastle.pty.registry");
1575
1642
  });
1576
1643
 
1644
+ // src/workflows/burn-cache.ts
1645
+ function burnCacheVolumeName(projectId) {
1646
+ return `runcastle-${projectId}`;
1647
+ }
1648
+ function slotRepoPath(slot) {
1649
+ return `${slotDirPath(slot)}/repo`;
1650
+ }
1651
+ function slotDirPath(slot) {
1652
+ return `${BURN_CACHE_MOUNT}/slots/${slot}`;
1653
+ }
1654
+ function slotStampPath(slot) {
1655
+ return `${slotDirPath(slot)}/.runcastle-stamp`;
1656
+ }
1657
+ function storePath(pm) {
1658
+ return `${BURN_CACHE_MOUNT}/store/${pm}`;
1659
+ }
1660
+ function burnCacheEnv(pm) {
1661
+ const store = storePath(pm);
1662
+ return {
1663
+ npm_config_store_dir: store,
1664
+ pnpm_config_store_dir: store,
1665
+ BUN_INSTALL_CACHE_DIR: store,
1666
+ npm_config_cache: store,
1667
+ YARN_GLOBAL_FOLDER: store,
1668
+ TMPDIR: `${BURN_CACHE_MOUNT}/tmp`,
1669
+ NODE_COMPILE_CACHE: `${BURN_CACHE_MOUNT}/node-compile`
1670
+ };
1671
+ }
1672
+ async function execOrThrow(exec, engine, args) {
1673
+ const out = await exec(engine, args);
1674
+ if (!out.ok || out.code !== 0) {
1675
+ throw new Error(`${engine} ${args.join(" ")} failed: ${out.stderr.trim() || `exit ${out.code}`}`);
1676
+ }
1677
+ }
1678
+ async function ensureBurnCacheVolume(opts) {
1679
+ const { engine, exec } = opts;
1680
+ const name = burnCacheVolumeName(opts.projectId);
1681
+ const inspect = await exec(engine, ["volume", "inspect", name]);
1682
+ const existed = inspect.ok && inspect.code === 0;
1683
+ await execOrThrow(exec, engine, ["volume", "create", name]);
1684
+ if (existed)
1685
+ return;
1686
+ await execOrThrow(exec, engine, [
1687
+ "run",
1688
+ "--rm",
1689
+ "--user",
1690
+ "root",
1691
+ "-v",
1692
+ `${name}:${BURN_CACHE_MOUNT}`,
1693
+ opts.imageName,
1694
+ "chown",
1695
+ "-R",
1696
+ CONTAINER_USER,
1697
+ BURN_CACHE_MOUNT
1698
+ ]);
1699
+ }
1700
+ async function removeBurnCacheVolume(opts) {
1701
+ if (opts.slots.length > 0)
1702
+ throw new BurnCacheBusyError([...opts.slots].sort((a, b) => a - b));
1703
+ await execOrThrow(opts.exec, opts.engine, ["volume", "rm", burnCacheVolumeName(opts.projectId)]);
1704
+ }
1705
+ async function burnCacheVolumeSize(opts) {
1706
+ const out = await opts.exec(opts.engine, ["system", "df", "-v", "--format", "json"]);
1707
+ if (!out.ok || out.code !== 0)
1708
+ return null;
1709
+ return findVolumeSize(out.stdout, burnCacheVolumeName(opts.projectId));
1710
+ }
1711
+ function parseJsonValues(stdout) {
1712
+ const values = [];
1713
+ const whole = tryParseJson(stdout);
1714
+ if (whole !== undefined)
1715
+ return [whole];
1716
+ for (const line of stdout.split(`
1717
+ `)) {
1718
+ const value = tryParseJson(line);
1719
+ if (value !== undefined)
1720
+ values.push(value);
1721
+ }
1722
+ return values;
1723
+ }
1724
+ function tryParseJson(text2) {
1725
+ const trimmed = text2.trim();
1726
+ if (!trimmed)
1727
+ return;
1728
+ try {
1729
+ return JSON.parse(trimmed);
1730
+ } catch {
1731
+ return;
1732
+ }
1733
+ }
1734
+ function findVolumeSize(stdout, name) {
1735
+ const pending = parseJsonValues(stdout);
1736
+ while (pending.length > 0) {
1737
+ const node = pending.shift();
1738
+ if (Array.isArray(node)) {
1739
+ pending.push(...node);
1740
+ continue;
1741
+ }
1742
+ if (typeof node !== "object" || node === null)
1743
+ continue;
1744
+ const record = node;
1745
+ if (VOLUME_NAME_KEYS.some((key) => record[key] === name)) {
1746
+ const size = readSize(record);
1747
+ if (size !== null)
1748
+ return size;
1749
+ }
1750
+ pending.push(...Object.values(record));
1751
+ }
1752
+ return null;
1753
+ }
1754
+ function readSize(record) {
1755
+ const usage = record.UsageData;
1756
+ const nested = typeof usage === "object" && usage !== null ? usage.Size : undefined;
1757
+ return parseSize(record.Size) ?? parseSize(nested);
1758
+ }
1759
+ function parseSize(value) {
1760
+ if (typeof value === "number")
1761
+ return Number.isFinite(value) ? value : null;
1762
+ if (typeof value !== "string")
1763
+ return null;
1764
+ const match = /^\s*(-?[\d.]+)\s*([a-z]*)\s*$/i.exec(value);
1765
+ if (!match)
1766
+ return null;
1767
+ const amount = Number(match[1]);
1768
+ const unit = SIZE_UNITS[(match[2] || "b").toLowerCase()];
1769
+ if (!Number.isFinite(amount) || unit === undefined)
1770
+ return null;
1771
+ return Math.round(amount * unit);
1772
+ }
1773
+ function createSlotAllocator(capacity) {
1774
+ const heldSlots = new Set;
1775
+ let width = capacity;
1776
+ return {
1777
+ claim() {
1778
+ for (let slot = 1;slot <= width; slot++) {
1779
+ if (!heldSlots.has(slot)) {
1780
+ heldSlots.add(slot);
1781
+ return slot;
1782
+ }
1783
+ }
1784
+ throw new BurnSlotsExhaustedError(width);
1785
+ },
1786
+ release(slot) {
1787
+ heldSlots.delete(slot);
1788
+ },
1789
+ held() {
1790
+ return [...heldSlots].sort((a, b) => a - b);
1791
+ },
1792
+ resize(next) {
1793
+ width = next;
1794
+ }
1795
+ };
1796
+ }
1797
+ function getBurnSlotAllocator(capacity) {
1798
+ if (!sharedAllocator)
1799
+ sharedAllocator = createSlotAllocator(capacity);
1800
+ else
1801
+ sharedAllocator.resize(capacity);
1802
+ return sharedAllocator;
1803
+ }
1804
+ var BURN_CACHE_MOUNT = "/home/agent/cache", BurnCacheBusyError, CONTAINER_USER = "1000:1000", VOLUME_NAME_KEYS, SIZE_UNITS, BurnSlotsExhaustedError, sharedAllocator;
1805
+ var init_burn_cache = __esm(() => {
1806
+ BurnCacheBusyError = class BurnCacheBusyError extends Error {
1807
+ slots;
1808
+ constructor(slots) {
1809
+ super(`burn cache is in use \u2014 slots ${slots.join(", ")} are held; stop those burns before clearing it`);
1810
+ this.name = "BurnCacheBusyError";
1811
+ this.slots = slots;
1812
+ }
1813
+ };
1814
+ VOLUME_NAME_KEYS = ["Name", "VolumeName"];
1815
+ SIZE_UNITS = {
1816
+ b: 1,
1817
+ kb: 1000,
1818
+ mb: 1e6,
1819
+ gb: 1e9,
1820
+ tb: 1000000000000,
1821
+ pb: 1000000000000000,
1822
+ kib: 1024,
1823
+ mib: 1024 ** 2,
1824
+ gib: 1024 ** 3,
1825
+ tib: 1024 ** 4,
1826
+ pib: 1024 ** 5
1827
+ };
1828
+ BurnSlotsExhaustedError = class BurnSlotsExhaustedError extends Error {
1829
+ constructor(capacity) {
1830
+ super(`all ${capacity} burn cache slots are in use`);
1831
+ this.name = "BurnSlotsExhaustedError";
1832
+ }
1833
+ };
1834
+ });
1835
+
1577
1836
  // src/errors.ts
1578
1837
  import { TRPCError } from "@trpc/server";
1579
1838
  function isNotImplemented(e) {
@@ -1588,6 +1847,8 @@ function toTRPCError(e) {
1588
1847
  return new TRPCError({ code: "NOT_FOUND", message: e.message, cause: e });
1589
1848
  if (e instanceof GateError)
1590
1849
  return new TRPCError({ code: "PRECONDITION_FAILED", message: e.message, cause: e });
1850
+ if (e instanceof BurnCacheBusyError)
1851
+ return new TRPCError({ code: "PRECONDITION_FAILED", message: e.message, cause: e });
1591
1852
  if (e instanceof InvalidInputError)
1592
1853
  return new TRPCError({ code: "BAD_REQUEST", message: e.message, cause: e });
1593
1854
  if (e instanceof Error)
@@ -1596,6 +1857,7 @@ function toTRPCError(e) {
1596
1857
  }
1597
1858
  var NotImplementedError, GateError, InvalidInputError, NotFoundError;
1598
1859
  var init_errors = __esm(() => {
1860
+ init_burn_cache();
1599
1861
  NotImplementedError = class NotImplementedError extends Error {
1600
1862
  wave;
1601
1863
  constructor(wave) {
@@ -1759,7 +2021,7 @@ function rowToProject(row) {
1759
2021
  id: row.id,
1760
2022
  name: row.name,
1761
2023
  repoPath: row.repoPath,
1762
- mainBranch: row.mainBranch,
2024
+ sessionBranch: row.sessionBranch ?? undefined,
1763
2025
  devCommand: row.devCommand ?? undefined,
1764
2026
  model: row.model ?? undefined,
1765
2027
  setupCommand: row.setupCommand ?? undefined,
@@ -2497,11 +2759,12 @@ function markVerified(ctx, projectId, keys, sha) {
2497
2759
  async function listFindings(ctx, project) {
2498
2760
  const rows = ctx.db.select().from(projectFindings).where(eq4(projectFindings.projectId, project.id)).all();
2499
2761
  const distances = new Map;
2500
- for (const row of rows) {
2501
- const sha = row.establishedSha;
2502
- if (!sha || distances.has(sha))
2503
- continue;
2504
- distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
2762
+ const shas = new Set(rows.flatMap((row) => row.establishedSha ? [row.establishedSha] : []));
2763
+ if (shas.size > 0) {
2764
+ const mainLine = await detectMainBranch(project.repoPath);
2765
+ for (const sha of shas) {
2766
+ distances.set(sha, await commitsSince(project.repoPath, sha, mainLine));
2767
+ }
2505
2768
  }
2506
2769
  const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
2507
2770
  return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
@@ -2570,8 +2833,11 @@ function migrationPaths(paths) {
2570
2833
  function featureBranch(slug) {
2571
2834
  return `feature/${slug}`;
2572
2835
  }
2573
- function mergeTarget(project, feature) {
2574
- return feature.baseBranch ?? project.mainBranch;
2836
+ function featureBase(feature) {
2837
+ if (!feature.baseBranch) {
2838
+ throw new GateError(`feature ${feature.slug} has no recorded base branch \u2014 it has not been cut from one yet`);
2839
+ }
2840
+ return feature.baseBranch;
2575
2841
  }
2576
2842
  function git(repoPath) {
2577
2843
  return simpleGit(repoPath);
@@ -2621,9 +2887,31 @@ async function detectMainBranch(repoPath) {
2621
2887
  } catch {}
2622
2888
  return "main";
2623
2889
  }
2890
+ async function resolveSessionBranch(project) {
2891
+ const stored = project.sessionBranch?.trim();
2892
+ if (!stored)
2893
+ return detectMainBranch(project.repoPath);
2894
+ const local = await git(project.repoPath).branchLocal();
2895
+ if (!local.all.includes(stored)) {
2896
+ throw new GateError(`the configured session landing branch \`${stored}\` no longer exists in ${project.repoPath} \u2014 pick where this chat's work should land`);
2897
+ }
2898
+ return stored;
2899
+ }
2900
+ async function sessionBranchView(project) {
2901
+ const stored = project.sessionBranch?.trim() || null;
2902
+ const detected = await detectMainBranch(project.repoPath);
2903
+ return { stored, effective: stored ?? detected, detected };
2904
+ }
2905
+ async function currentCheckoutBranch(project) {
2906
+ const local = await git(project.repoPath).branchLocal();
2907
+ if (local.detached || !local.current) {
2908
+ throw new InvalidInputError(`${project.repoPath} has no branch checked out (detached HEAD) \u2014 name the branch to fork from`);
2909
+ }
2910
+ return local.current;
2911
+ }
2624
2912
  async function createFeatureBranch(project, slug, base) {
2625
2913
  const branch = featureBranch(slug);
2626
- const from = base?.trim() || project.mainBranch;
2914
+ const from = base.trim();
2627
2915
  const g = git(project.repoPath);
2628
2916
  const branches = await g.branchLocal();
2629
2917
  if (branches.all.includes(branch))
@@ -2637,7 +2925,9 @@ async function createFeatureBranch(project, slug, base) {
2637
2925
  async function listBranches(project) {
2638
2926
  const g = git(project.repoPath);
2639
2927
  const local = await g.branchLocal();
2640
- const branches = local.all.filter((name) => !name.startsWith("feature/"));
2928
+ const detachedRef = local.detached ? local.current : null;
2929
+ const current = local.detached ? "" : local.current;
2930
+ const branches = local.all.filter((name) => !name.startsWith("feature/") && name !== detachedRef);
2641
2931
  const localSet = new Set(local.all);
2642
2932
  let remoteBranches = [];
2643
2933
  try {
@@ -2647,7 +2937,7 @@ async function listBranches(project) {
2647
2937
  return !short.startsWith("feature/") && !localSet.has(short);
2648
2938
  });
2649
2939
  } catch {}
2650
- return { current: local.current, mainBranch: project.mainBranch, branches, remoteBranches };
2940
+ return { current, branches, remoteBranches };
2651
2941
  }
2652
2942
  async function resolveBaseBranch(project, base) {
2653
2943
  const g = git(project.repoPath);
@@ -2673,7 +2963,7 @@ async function ensureTalkWorktree(project, feature) {
2673
2963
  const worktreePath = worktreeDir(project.id, feature.slug);
2674
2964
  const branch = featureBranch(feature.slug);
2675
2965
  const g = git(project.repoPath);
2676
- await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2966
+ await ensureBranchExists(g, branch, featureBase(feature));
2677
2967
  if (await worktreeIsValid(g, worktreePath, branch))
2678
2968
  return worktreePath;
2679
2969
  if (existsSync6(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
@@ -2755,8 +3045,8 @@ function slash(path) {
2755
3045
  async function ensureProjectWorktree(project, onLanded) {
2756
3046
  const worktreePath = worktreeDir(project.id, PROJECT_WORKTREE_SLUG);
2757
3047
  const g = git(project.repoPath);
2758
- const base = project.mainBranch;
2759
- const landed = await landProjectBranch(project);
3048
+ const base = await resolveSessionBranch(project);
3049
+ const landed = await landProjectBranch(project, base);
2760
3050
  if (landed)
2761
3051
  onLanded?.(landed);
2762
3052
  const stillAhead = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
@@ -2765,12 +3055,13 @@ async function ensureProjectWorktree(project, onLanded) {
2765
3055
  await g.raw(["branch", PROJECT_BRANCH, base]);
2766
3056
  }
2767
3057
  if (await worktreeIsValid(g, worktreePath, PROJECT_BRANCH))
2768
- return worktreePath;
3058
+ return { worktreePath, base };
2769
3059
  if (existsSync6(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2770
3060
  if (await checkoutInWorktree(worktreePath, PROJECT_BRANCH))
2771
- return worktreePath;
3061
+ return { worktreePath, base };
2772
3062
  }
2773
- return addWorktree(g, worktreePath, PROJECT_BRANCH, "project worktree");
3063
+ const added = await addWorktree(g, worktreePath, PROJECT_BRANCH, "project worktree");
3064
+ return { worktreePath: added, base };
2774
3065
  }
2775
3066
  async function checkoutInWorktree(worktreePath, branch) {
2776
3067
  const gw = git(worktreePath);
@@ -2934,7 +3225,7 @@ async function branchCommitsAhead(repoPath, base, branch) {
2934
3225
  }
2935
3226
  }
2936
3227
  async function reviewCommitCount(project, feature) {
2937
- const base = mergeTarget(project, feature);
3228
+ const base = featureBase(feature);
2938
3229
  const branch = featureBranch(feature.slug);
2939
3230
  try {
2940
3231
  const out = (await git(project.repoPath).raw(["rev-list", "--count", `${base}..${branch}`])).trim();
@@ -2945,7 +3236,7 @@ async function reviewCommitCount(project, feature) {
2945
3236
  }
2946
3237
  }
2947
3238
  async function featureBranchDelta(project, feature) {
2948
- const base = mergeTarget(project, feature);
3239
+ const base = featureBase(feature);
2949
3240
  const branch = featureBranch(feature.slug);
2950
3241
  try {
2951
3242
  const out = await git(project.repoPath).raw(["diff", "--stat", `${base}...${branch}`]);
@@ -3120,12 +3411,13 @@ async function deleteBranchDetachingWorktrees(g, repoPath, branch) {
3120
3411
  return false;
3121
3412
  }
3122
3413
  }
3123
- async function landProjectBranch(project) {
3124
- const ahead = await branchCommitsAhead(project.repoPath, project.mainBranch, PROJECT_BRANCH);
3414
+ async function landProjectBranch(project, base) {
3415
+ const ahead = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
3125
3416
  if (ahead.length === 0)
3126
3417
  return null;
3127
- const res = await mergeTempBranch(project.repoPath, project.mainBranch, PROJECT_BRANCH);
3418
+ const res = await mergeTempBranch(project.repoPath, base, PROJECT_BRANCH);
3128
3419
  return {
3420
+ base,
3129
3421
  commits: ahead.length,
3130
3422
  landed: res.ok,
3131
3423
  ...res.conflict ? { conflict: true } : {},
@@ -3226,6 +3518,11 @@ async function commitDocs(worktreePath, message) {
3226
3518
  return;
3227
3519
  await g.commit(message, [DOCS_PATHSPEC]);
3228
3520
  }
3521
+ async function commitPipelineDocs(repoPath) {
3522
+ try {
3523
+ await commitDocs(repoPath, "runcastle: docs the pipeline left uncommitted");
3524
+ } catch {}
3525
+ }
3229
3526
  function activeTestDriveFeatureId() {
3230
3527
  return testDriveState?.kind === "feature" ? testDriveState.featureId : undefined;
3231
3528
  }
@@ -3325,6 +3622,7 @@ async function testDrive(ctx, project, feature, action, purpose = "human") {
3325
3622
  ...teardown?.failure ? { hookFailure: teardown.failure } : {}
3326
3623
  };
3327
3624
  }
3625
+ await commitPipelineDocs(project.repoPath);
3328
3626
  const porcelain = (await g.raw(["status", "--porcelain"])).trim();
3329
3627
  if (porcelain !== "")
3330
3628
  return { ok: false, deniedReason: DENY_DIRTY };
@@ -3506,7 +3804,7 @@ async function stopDryRun(ctx, project, state) {
3506
3804
  });
3507
3805
  const { participating, failure } = dryRunVerdict(project, state);
3508
3806
  if (!failure && participating.length > 0) {
3509
- const sha = await headSha(project.repoPath, project.mainBranch) ?? null;
3807
+ const sha = await headSha(project.repoPath, await detectMainBranch(project.repoPath)) ?? null;
3510
3808
  markVerified(ctx, project.id, participating, sha);
3511
3809
  emitProject(ctx, project.id, {
3512
3810
  type: "prep.dryrun.verified",
@@ -3668,7 +3966,7 @@ async function detectDbDrift(ctx, project, feature, previousBranch, driveBranch)
3668
3966
  return drift;
3669
3967
  }
3670
3968
  async function mergeFeature(project, feature) {
3671
- const target = mergeTarget(project, feature);
3969
+ const target = featureBase(feature);
3672
3970
  if (testDriveState) {
3673
3971
  throw new GateError("Cannot merge while a test drive is active \u2014 stop it first");
3674
3972
  }
@@ -4272,7 +4570,7 @@ function renderDriveFixPrompt(brief) {
4272
4570
  `);
4273
4571
  }
4274
4572
  function renderProjectPrompt(brief, runtime = DEFAULT_RUNTIME) {
4275
- const { project, branch, worktreePath } = brief;
4573
+ const { project, branch, worktreePath, base } = brief;
4276
4574
  return [
4277
4575
  `# runcastle \u2014 ${project.name} (project session)`,
4278
4576
  "",
@@ -4298,8 +4596,8 @@ function renderProjectPrompt(brief, runtime = DEFAULT_RUNTIME) {
4298
4596
  "## Where you are working \u2014 and where your writes go",
4299
4597
  `- Working directory: \`${worktreePath}\` \u2014 a runcastle-owned worktree, NOT the human's`,
4300
4598
  ` checkout at \`${project.repoPath}\`.`,
4301
- `- Branch: \`${branch}\`, cut from \`${project.mainBranch}\` when this session launched.`,
4302
- `- **Your commits land on \`${project.mainBranch}\` when this terminal closes**, arriving`,
4599
+ `- Branch: \`${branch}\`, cut from \`${base}\` when this session launched.`,
4600
+ `- **Your commits land on \`${base}\` when this terminal closes**, arriving`,
4303
4601
  " in the human's checkout the way a `git pull` does. Write real code and real docs \u2014",
4304
4602
  " there is no sandbox here \u2014 and commit everything you write before you finish.",
4305
4603
  " A session that edits without committing leaves a dirty tree, which is exactly what",
@@ -4586,10 +4884,24 @@ var init_claude = __esm(() => {
4586
4884
  };
4587
4885
  });
4588
4886
 
4589
- // src/doctor/doctor.ts
4887
+ // src/services/codex-auth.ts
4590
4888
  import { existsSync as existsSync8 } from "fs";
4591
4889
  import { homedir as homedir2 } from "os";
4592
4890
  import { join as join9 } from "path";
4891
+ function codexHomeDir(env = process.env) {
4892
+ return env.CODEX_HOME ?? join9(env.HOME ?? env.USERPROFILE ?? homedir2(), ".codex");
4893
+ }
4894
+ function codexAuthFile(env = process.env) {
4895
+ return join9(codexHomeDir(env), "auth.json");
4896
+ }
4897
+ function codexLoggedIn(env = process.env, fileExists = existsSync8) {
4898
+ return fileExists(codexAuthFile(env));
4899
+ }
4900
+ var init_codex_auth = () => {};
4901
+
4902
+ // src/doctor/doctor.ts
4903
+ import { existsSync as existsSync9, statSync } from "fs";
4904
+ import { fileURLToPath as fileURLToPath4 } from "url";
4593
4905
  async function versionProbe(exec, id, label, bin, fix) {
4594
4906
  const out = await exec(bin, ["--version"]);
4595
4907
  if (out.ok && out.code === 0) {
@@ -4621,9 +4933,6 @@ async function nodeProbe(exec) {
4621
4933
  async function gitProbe(exec) {
4622
4934
  return versionProbe(exec, "git", "Git", "git", "Install Git: winget install Git.Git / brew install git / apt install git");
4623
4935
  }
4624
- function codexAuthFile(env) {
4625
- return join9(env.CODEX_HOME ?? join9(env.HOME ?? env.USERPROFILE ?? homedir2(), ".codex"), "auth.json");
4626
- }
4627
4936
  function statusUnsupported(out) {
4628
4937
  return /unknown command|unrecognized subcommand|unexpected argument|invalid subcommand/i.test(`${out.stderr} ${out.stdout}`);
4629
4938
  }
@@ -4655,23 +4964,38 @@ async function runtimeAuthProbe(spec, exec, env, fileExists, severity) {
4655
4964
  fix: spec.installFix
4656
4965
  };
4657
4966
  }
4967
+ const authFile = spec.authFile?.(env);
4968
+ if (authFile) {
4969
+ const present = fileExists(authFile);
4970
+ const statusCommand = `${spec.bin} ${spec.authStatusArgs.join(" ")}`;
4971
+ let coda;
4972
+ if (statusUnsupported(out)) {
4973
+ coda = ` \u2014 this ${spec.bin} cannot report login state`;
4974
+ } else if (out.code === 0 === present) {
4975
+ coda = ` \u2014 \`${statusCommand}\` agrees`;
4976
+ } else if (present) {
4977
+ coda = `, but \`${statusCommand}\` reports logged out \u2014 burns borrow the file, so this host counts as signed in`;
4978
+ } else {
4979
+ coda = `, but \`${statusCommand}\` reports a login \u2014 a burn borrows the file, and it is not there`;
4980
+ }
4981
+ return present ? { ...base, status: "ok", detail: `credentials found at ${authFile}${coda}` } : { ...loggedOut, detail: `no credentials at ${authFile}${coda}` };
4982
+ }
4658
4983
  if (out.code === 0)
4659
4984
  return { ...base, status: "ok", detail: `${spec.bin} reports a login` };
4660
4985
  if (!statusUnsupported(out))
4661
4986
  return loggedOut;
4662
- const authFile = spec.authFile?.(env);
4663
- if (!authFile) {
4664
- return {
4665
- ...base,
4666
- status: "ok",
4667
- detail: `this ${spec.bin} cannot report login state \u2014 it prompts at launch`
4668
- };
4669
- }
4670
- return fileExists(authFile) ? { ...base, status: "ok", detail: `credentials found at ${authFile}` } : { ...loggedOut, detail: `no credentials at ${authFile}` };
4987
+ return {
4988
+ ...base,
4989
+ status: "ok",
4990
+ detail: `this ${spec.bin} cannot report login state \u2014 it prompts at launch`
4991
+ };
4671
4992
  }
4672
4993
  function runtimeAfkKeyProbe(spec, env, severity = "error") {
4994
+ const id = spec.ids.afkKey;
4995
+ if (!id)
4996
+ return;
4673
4997
  const base = {
4674
- id: spec.ids.afkKey,
4998
+ id,
4675
4999
  label: `${spec.label} AFK ${spec.afkNoun} (${spec.afkKey})`,
4676
5000
  tier: 2,
4677
5001
  severity,
@@ -4690,11 +5014,12 @@ function runtimeAfkKeyProbe(spec, env, severity = "error") {
4690
5014
  };
4691
5015
  }
4692
5016
  async function runtimeProbes(spec, deps) {
4693
- const fileExists = deps.fileExists ?? existsSync8;
5017
+ const fileExists = deps.fileExists ?? existsSync9;
5018
+ const afkKeyProbe = runtimeAfkKeyProbe(spec, deps.env, deps.severity);
4694
5019
  return [
4695
5020
  await runtimeBinaryProbe(spec, deps.exec, deps.severity),
4696
5021
  await runtimeAuthProbe(spec, deps.exec, deps.env, fileExists, deps.severity),
4697
- runtimeAfkKeyProbe(spec, deps.env, deps.severity)
5022
+ ...afkKeyProbe ? [afkKeyProbe] : []
4698
5023
  ];
4699
5024
  }
4700
5025
  async function gitIdentityProbe(exec, cwd) {
@@ -4783,15 +5108,29 @@ async function containerRuntimeProbe(exec) {
4783
5108
  fix: "Install Docker Desktop or Podman (see docs/research/PREREQS-NOTES.md \xA74). Not needed for interactive-only use."
4784
5109
  };
4785
5110
  }
4786
- async function sandcastleImageProbe(exec, imageName) {
5111
+ async function sandcastleImageProbe(exec, imageName, burnerDockerfile, fileMtime) {
4787
5112
  const id = "sandcastle-image";
4788
5113
  const label = "Sandcastle container image";
4789
5114
  for (const runtime of ["docker", "podman"]) {
4790
5115
  const present = await exec(runtime, ["--version"]);
4791
5116
  if (!(present.ok && present.code === 0))
4792
5117
  continue;
4793
- const inspect = await exec(runtime, ["image", "inspect", imageName]);
5118
+ const inspect = await exec(runtime, ["image", "inspect", "--format", "{{.Created}}", imageName]);
4794
5119
  if (inspect.ok && inspect.code === 0) {
5120
+ const created = new Date(inspect.stdout.trim());
5121
+ const dockerfileChanged = fileMtime(burnerDockerfile);
5122
+ if (dockerfileChanged > created) {
5123
+ const date = (value) => value.toISOString().slice(0, 10);
5124
+ return {
5125
+ id,
5126
+ label,
5127
+ tier: 2,
5128
+ status: "stale",
5129
+ severity: "error",
5130
+ detail: `${imageName} built ${date(created)}, burner Dockerfile changed ${date(dockerfileChanged)} \u2014 rebuild`,
5131
+ fix: 'Open Settings \u2192 AFK burns and click "Rebuild image".'
5132
+ };
5133
+ }
4795
5134
  return { id, label, tier: 2, status: "ok", severity: "error", detail: `${imageName} present` };
4796
5135
  }
4797
5136
  return {
@@ -4818,6 +5157,8 @@ async function runDoctor(env) {
4818
5157
  const { exec } = env;
4819
5158
  const processEnv = env.env ?? process.env;
4820
5159
  const imageName = env.imageName ?? DEFAULT_SANDBOX_IMAGE;
5160
+ const burnerDockerfile = env.burnerDockerfile ?? fileURLToPath4(new URL("../assets/sandcastle/Dockerfile", import.meta.url));
5161
+ const fileMtime = env.fileMtime ?? ((path) => statSync(path).mtime);
4821
5162
  const required = new Set(env.runtimes ?? [DEFAULT_RUNTIME]);
4822
5163
  const results = [
4823
5164
  await bunProbe(exec),
@@ -4832,7 +5173,7 @@ async function runDoctor(env) {
4832
5173
  severity: required.has(runtime) ? "error" : "info"
4833
5174
  }));
4834
5175
  }
4835
- results.push(await gitIdentityProbe(exec, env.cwd), await containerRuntimeProbe(exec), await sandcastleImageProbe(exec, imageName));
5176
+ results.push(await gitIdentityProbe(exec, env.cwd), await containerRuntimeProbe(exec), await sandcastleImageProbe(exec, imageName, burnerDockerfile, fileMtime));
4836
5177
  const counts = (r) => r.severity === "error";
4837
5178
  return {
4838
5179
  results,
@@ -4848,6 +5189,7 @@ function exitCodeFor(report, mode) {
4848
5189
  var RUNTIME_SPECS;
4849
5190
  var init_doctor = __esm(() => {
4850
5191
  init_src();
5192
+ init_codex_auth();
4851
5193
  RUNTIME_SPECS = {
4852
5194
  "claude-code": {
4853
5195
  runtime: "claude-code",
@@ -4867,13 +5209,13 @@ var init_doctor = __esm(() => {
4867
5209
  runtime: "codex",
4868
5210
  label: "Codex",
4869
5211
  bin: "codex",
4870
- ids: { binary: "codex", auth: "codex-auth", afkKey: "codex-api-key" },
5212
+ ids: { binary: "codex", auth: "codex-auth" },
4871
5213
  authStatusArgs: ["login", "status"],
4872
5214
  loginCommand: "codex login",
4873
5215
  loginArgs: ["login"],
4874
5216
  afkKey: "CODEX_API_KEY",
4875
5217
  afkNoun: "API key",
4876
- afkFix: "Create an OpenAI API key (platform.openai.com/api-keys) and put CODEX_API_KEY in ~/.runcastle/.env",
5218
+ afkFix: "Set CODEX_API_KEY in ~/.runcastle/.env to bill an OpenAI API key instead of your ChatGPT login",
4877
5219
  installFix: "Install Codex: npm install -g @openai/codex (or: brew install codex)",
4878
5220
  binOverrideEnv: "RUNCASTLE_CODEX_BIN",
4879
5221
  authFile: codexAuthFile
@@ -4884,31 +5226,33 @@ var init_doctor = __esm(() => {
4884
5226
  // src/launcher/runtimes/codex.ts
4885
5227
  import {
4886
5228
  copyFileSync,
4887
- existsSync as existsSync9,
5229
+ existsSync as existsSync10,
4888
5230
  mkdirSync as mkdirSync4,
4889
5231
  readFileSync as readFileSync4,
4890
5232
  readdirSync as readdirSync2,
4891
- statSync,
5233
+ statSync as statSync2,
4892
5234
  writeFileSync as writeFileSync3
4893
5235
  } from "fs";
4894
- import { homedir as homedir3 } from "os";
4895
5236
  import { basename, dirname as dirname3, join as join10, resolve as resolve7 } from "path";
4896
- function codexHomeDir(sessionId) {
5237
+ function codexHomeDir2(sessionId) {
4897
5238
  return join10(sessionDir(sessionId), "codex-home");
4898
5239
  }
4899
5240
  function realCodexAuthFile(env = process.env) {
4900
- return CODEX.authFile?.(env) ?? join10(homedir3(), ".codex", "auth.json");
5241
+ return codexAuthFile(env);
4901
5242
  }
4902
5243
  function toml(value) {
4903
5244
  return JSON.stringify(value);
4904
5245
  }
5246
+ function approvalPolicyFor(permissionMode) {
5247
+ return permissionMode === undefined || permissionMode === "acceptEdits" ? "never" : "untrusted";
5248
+ }
4905
5249
  function renderCodexConfig(input) {
4906
- const { session, config: config2, model, worktreePath, inheritedMcpServers } = input;
5250
+ const { session, config: config2, model, worktreePath, inheritedMcpServers, permissionMode } = input;
4907
5251
  return [
4908
5252
  `# Generated by runcastle for session ${session.id} \u2014 rewritten at every launch.`,
4909
5253
  `model = ${toml(model)}`,
4910
5254
  'sandbox_mode = "workspace-write"',
4911
- 'approval_policy = "never"',
5255
+ `approval_policy = ${toml(approvalPolicyFor(permissionMode))}`,
4912
5256
  "",
4913
5257
  `[projects.${toml(worktreePath)}]`,
4914
5258
  'trust_level = "trusted"',
@@ -4940,7 +5284,7 @@ function mcpServerTables(userConfigToml) {
4940
5284
  }
4941
5285
  function readInheritedMcpServers(env = process.env) {
4942
5286
  const path = join10(dirname3(realCodexAuthFile(env)), "config.toml");
4943
- if (!existsSync9(path))
5287
+ if (!existsSync10(path))
4944
5288
  return "";
4945
5289
  try {
4946
5290
  return mcpServerTables(readFileSync4(path, "utf8"));
@@ -4964,15 +5308,15 @@ function renderCodexHooks(hookClient, kind, platform = process.platform) {
4964
5308
  };
4965
5309
  }
4966
5310
  function writeCodexSkills(worktreePath, packDir) {
4967
- if (!existsSync9(worktreePath))
5311
+ if (!existsSync10(worktreePath))
4968
5312
  return [];
4969
5313
  const source = join10(packDir, "skills");
4970
- if (!existsSync9(source))
5314
+ if (!existsSync10(source))
4971
5315
  return [];
4972
5316
  const written = [];
4973
5317
  for (const name of readdirSync2(source)) {
4974
5318
  const skill = join10(source, name, "SKILL.md");
4975
- if (!existsSync9(skill))
5319
+ if (!existsSync10(skill))
4976
5320
  continue;
4977
5321
  const target = join10(worktreePath, CODEX_SKILLS_REL, name);
4978
5322
  mkdirSync4(target, { recursive: true });
@@ -4991,7 +5335,7 @@ function excludeFromGit(worktreePath, entry) {
4991
5335
  return;
4992
5336
  mkdirSync4(info, { recursive: true });
4993
5337
  const path = join10(info, "exclude");
4994
- const existing = existsSync9(path) ? readFileSync4(path, "utf8") : "";
5338
+ const existing = existsSync10(path) ? readFileSync4(path, "utf8") : "";
4995
5339
  if (existing.split(/\r?\n/).some((line) => line.trim() === entry))
4996
5340
  return;
4997
5341
  const head = existing.length > 0 && !existing.endsWith(`
@@ -5003,9 +5347,9 @@ function excludeFromGit(worktreePath, entry) {
5003
5347
  }
5004
5348
  function gitInfoDir(worktreePath) {
5005
5349
  const dotGit = join10(worktreePath, ".git");
5006
- if (!existsSync9(dotGit))
5350
+ if (!existsSync10(dotGit))
5007
5351
  return null;
5008
- if (statSync(dotGit).isDirectory())
5352
+ if (statSync2(dotGit).isDirectory())
5009
5353
  return join10(dotGit, "info");
5010
5354
  const named = /^gitdir:\s*(.+)$/m.exec(readFileSync4(dotGit, "utf8"));
5011
5355
  if (!named)
@@ -5024,6 +5368,7 @@ var CODEX_BIN = "codex", CODEX, CODEX_SKILLS_REL, CODEX_SKILLS_IGNORE = ".agents
5024
5368
  var init_codex = __esm(() => {
5025
5369
  init_paths();
5026
5370
  init_doctor();
5371
+ init_codex_auth();
5027
5372
  init_resolve_executable();
5028
5373
  init_artifacts();
5029
5374
  init_edit_guard();
@@ -5046,7 +5391,7 @@ var init_codex = __esm(() => {
5046
5391
  doctorHint: 'Run `runcastle doctor` and fix its "Codex CLI" probe, then restart runcastle from a ' + "terminal where `codex --version` works."
5047
5392
  };
5048
5393
  }
5049
- if (!existsSync9(realCodexAuthFile())) {
5394
+ if (!existsSync10(realCodexAuthFile())) {
5050
5395
  return {
5051
5396
  ok: false,
5052
5397
  reason: "Codex is installed but not logged in \u2014 runcastle copies your own credentials into " + `each session's home, and there are none at ${realCodexAuthFile()}`,
@@ -5056,7 +5401,7 @@ var init_codex = __esm(() => {
5056
5401
  return { ok: true };
5057
5402
  },
5058
5403
  async writeArtifacts(input) {
5059
- const home = codexHomeDir(input.session.id);
5404
+ const home = codexHomeDir2(input.session.id);
5060
5405
  mkdirSync4(home, { recursive: true });
5061
5406
  const configPath2 = join10(home, "config.toml");
5062
5407
  const hooksPath = join10(home, "hooks.json");
@@ -5066,13 +5411,14 @@ var init_codex = __esm(() => {
5066
5411
  config: input.config,
5067
5412
  model: input.model,
5068
5413
  worktreePath: input.worktreePath,
5414
+ ...input.permissionMode ? { permissionMode: input.permissionMode } : {},
5069
5415
  ...input.config.sessionMcp === "inherit" ? { inheritedMcpServers: readInheritedMcpServers() } : {}
5070
5416
  }), "utf8");
5071
5417
  writeFileSync3(hooksPath, JSON.stringify(renderCodexHooks(hookClientPath(), input.session.kind), null, 2), "utf8");
5072
5418
  writeFileSync3(agentsPath, renderSessionPrompt({ ...input, runtime: this.id }), "utf8");
5073
5419
  const files = [configPath2, hooksPath, agentsPath];
5074
5420
  const auth = realCodexAuthFile();
5075
- if (existsSync9(auth)) {
5421
+ if (existsSync10(auth)) {
5076
5422
  const copied = join10(home, "auth.json");
5077
5423
  copyFileSync(auth, copied);
5078
5424
  files.push(copied);
@@ -5115,7 +5461,7 @@ var init_runtimes = __esm(() => {
5115
5461
  });
5116
5462
 
5117
5463
  // src/launcher/sessions.ts
5118
- import { statSync as statSync2 } from "fs";
5464
+ import { statSync as statSync3 } from "fs";
5119
5465
  import { and as and5, desc as desc3, eq as eq5, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
5120
5466
  function createSessionRow(ctx, input) {
5121
5467
  const row = ctx.db.insert(sessions).values({
@@ -5374,20 +5720,18 @@ function landProjectSession(ctx, session) {
5374
5720
  const project = getProjectById(ctx, session.projectId);
5375
5721
  if (!project)
5376
5722
  return;
5377
- const landing = landProjectBranch(project).then((res) => {
5378
- if (res)
5379
- reportProjectLanding(ctx, project, res, { sessionId: session.id });
5380
- }).catch((e) => {
5723
+ const landFailed = (base, e) => {
5724
+ const error = e instanceof Error ? e.message : String(e);
5381
5725
  emitProject(ctx, project.id, {
5382
5726
  type: "project.land_failed",
5383
- message: `could not land the project session's work onto ${project.mainBranch}: ${e instanceof Error ? e.message : String(e)} \u2014 ${LANDING_KEPT}`,
5384
- data: {
5385
- sessionId: session.id,
5386
- branch: PROJECT_BRANCH,
5387
- error: e instanceof Error ? e.message : String(e)
5388
- }
5727
+ message: `could not land the project session's work${base ? ` onto ${base}` : ""}: ${error} \u2014 ${LANDING_KEPT}`,
5728
+ data: { sessionId: session.id, branch: PROJECT_BRANCH, error }
5389
5729
  });
5390
- });
5730
+ };
5731
+ const landing = resolveSessionBranch(project).then((base) => landProjectBranch(project, base).then((res) => {
5732
+ if (res)
5733
+ reportProjectLanding(ctx, project, res, { sessionId: session.id });
5734
+ }, (e) => landFailed(base, e)), (e) => landFailed(null, e));
5391
5735
  inFlightLandings.add(landing);
5392
5736
  landing.finally(() => inFlightLandings.delete(landing));
5393
5737
  }
@@ -5400,11 +5744,11 @@ function reportProjectLanding(ctx, project, res, data = {}) {
5400
5744
  });
5401
5745
  };
5402
5746
  if (res.landed) {
5403
- report("project.landed", `landed ${res.commits} commit(s) from the project session onto ${project.mainBranch}`, { commits: res.commits });
5747
+ report("project.landed", `landed ${res.commits} commit(s) from the project session onto ${res.base}`, { commits: res.commits });
5404
5748
  } else if (res.conflict) {
5405
- report("project.land_conflict", `the project session's ${res.commits} commit(s) conflict with ${project.mainBranch} \u2014 nothing was overwritten; ${LANDING_KEPT}`, { commits: res.commits, files: res.files ?? [] });
5749
+ report("project.land_conflict", `the project session's ${res.commits} commit(s) conflict with ${res.base} \u2014 nothing was overwritten; ${LANDING_KEPT}`, { commits: res.commits, files: res.files ?? [] });
5406
5750
  } else {
5407
- report("project.land_failed", `could not land the project session's ${res.commits} commit(s) onto ${project.mainBranch}: ${res.error ?? "unknown error"} \u2014 ${LANDING_KEPT}`, { commits: res.commits, error: res.error ?? null });
5751
+ report("project.land_failed", `could not land the project session's ${res.commits} commit(s) onto ${res.base}: ${res.error ?? "unknown error"} \u2014 ${LANDING_KEPT}`, { commits: res.commits, error: res.error ?? null });
5408
5752
  }
5409
5753
  }
5410
5754
  function markSessionEnded(ctx, id) {
@@ -5468,7 +5812,7 @@ function transcriptBytes(session) {
5468
5812
  if (!session?.transcriptPath)
5469
5813
  return;
5470
5814
  try {
5471
- return statSync2(session.transcriptPath).size;
5815
+ return statSync3(session.transcriptPath).size;
5472
5816
  } catch {
5473
5817
  return;
5474
5818
  }
@@ -5583,7 +5927,8 @@ function rowToTicket(row) {
5583
5927
  error: row.error ?? undefined,
5584
5928
  attemptBranch: row.attemptBranch ?? undefined,
5585
5929
  conflictFiles: row.conflictFiles ?? undefined,
5586
- digest: row.digest ?? undefined
5930
+ digest: row.digest ?? undefined,
5931
+ originFindingId: row.originFindingId ?? undefined
5587
5932
  });
5588
5933
  }
5589
5934
  function resolveBlocking(inputs, startSeq) {
@@ -5608,13 +5953,13 @@ function normalizeModel(ctx, value) {
5608
5953
  function listByFeature2(ctx, featureId) {
5609
5954
  return ctx.db.select().from(tickets).where(eq6(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
5610
5955
  }
5611
- function storeTickets(ctx, featureId, inputs) {
5956
+ function storeTickets(ctx, featureId, inputs, options = {}) {
5612
5957
  if (inputs.length === 0)
5613
5958
  return [];
5614
5959
  const { lap } = getFeatureRow(ctx, featureId);
5615
5960
  const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq6(tickets.featureId, featureId)).all();
5616
5961
  const startSeq = existing.reduce((max2, r) => Math.max(max2, r.seq), 0) + 1;
5617
- const resolved = resolveBlocking(inputs, startSeq);
5962
+ const resolved = options.blockedByAreGlobal ? inputs.map((input, index2) => ({ seq: startSeq + index2, blockedBy: input.blockedBy })) : resolveBlocking(inputs, startSeq);
5618
5963
  const rows = inputs.map((t, i) => ({
5619
5964
  id: newId("tkt"),
5620
5965
  featureId,
@@ -5633,7 +5978,8 @@ function storeTickets(ctx, featureId, inputs) {
5633
5978
  error: null,
5634
5979
  attemptBranch: null,
5635
5980
  conflictFiles: null,
5636
- digest: null
5981
+ digest: null,
5982
+ originFindingId: t.originFindingId ?? null
5637
5983
  }));
5638
5984
  ctx.db.insert(tickets).values(rows).run();
5639
5985
  emit(ctx, featureId, {
@@ -5745,7 +6091,7 @@ var init_tickets = __esm(() => {
5745
6091
  });
5746
6092
 
5747
6093
  // src/services/gates.ts
5748
- import { existsSync as existsSync10 } from "fs";
6094
+ import { existsSync as existsSync11 } from "fs";
5749
6095
  import { and as and7, desc as desc4, eq as eq7, inArray as inArray3 } from "drizzle-orm";
5750
6096
  function checkGate(ctx, check, feature) {
5751
6097
  switch (check) {
@@ -5795,7 +6141,7 @@ function notYetTerminal(noun, statuses, order) {
5795
6141
  }
5796
6142
  function fileGate(ctx, feature, fileName, reason) {
5797
6143
  const project = projectForFeature(ctx, feature);
5798
- return existsSync10(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
6144
+ return existsSync11(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
5799
6145
  }
5800
6146
  function docGate(ctx, feature, fileName, reason) {
5801
6147
  const file = fileGate(ctx, feature, fileName, reason);
@@ -5859,14 +6205,14 @@ var init_gates = __esm(() => {
5859
6205
  });
5860
6206
 
5861
6207
  // src/services/knowledge.ts
5862
- import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync as writeFileSync4 } from "fs";
6208
+ import { existsSync as existsSync12, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync as writeFileSync4 } from "fs";
5863
6209
  import { join as join11, relative as relative2, resolve as resolve8, sep } from "path";
5864
6210
  function scaffoldDocs(ctx, feature, opts = {}) {
5865
6211
  const project = projectForFeature(ctx, feature);
5866
6212
  const dir = featureDocsDir(project, feature);
5867
6213
  mkdirSync5(dir, { recursive: true });
5868
6214
  const briefPath = featureDocPath(project, feature, "brief.md");
5869
- if (!existsSync11(briefPath)) {
6215
+ if (!existsSync12(briefPath)) {
5870
6216
  const override = opts.brief?.trim();
5871
6217
  const created = new Date().toISOString();
5872
6218
  const brief = override ? `${override}
@@ -5893,7 +6239,7 @@ function scaffoldDocs(ctx, feature, opts = {}) {
5893
6239
  function scaffoldMapDoc(project, feature, seed) {
5894
6240
  mkdirSync5(featureDocsDir(project, feature), { recursive: true });
5895
6241
  const mapPath = featureDocPath(project, feature, "map.md");
5896
- if (existsSync11(mapPath))
6242
+ if (existsSync12(mapPath))
5897
6243
  return;
5898
6244
  writeFileSync4(mapPath, mapDocBody(feature, seed), "utf8");
5899
6245
  }
@@ -5914,7 +6260,7 @@ function mapDocBody(feature, seed) {
5914
6260
  }
5915
6261
  function readCharter(repoRoot) {
5916
6262
  const path = join11(repoRoot, CHARTER_FILE);
5917
- if (!existsSync11(path))
6263
+ if (!existsSync12(path))
5918
6264
  return;
5919
6265
  try {
5920
6266
  return readFileSync5(path, "utf8");
@@ -5924,7 +6270,7 @@ function readCharter(repoRoot) {
5924
6270
  }
5925
6271
  function listLiveAdrs(repoRoot) {
5926
6272
  const dir = join11(repoRoot, ADR_DIR_REL);
5927
- if (!existsSync11(dir))
6273
+ if (!existsSync12(dir))
5928
6274
  return [];
5929
6275
  const docs2 = [];
5930
6276
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
@@ -5945,7 +6291,7 @@ function listLiveAdrs(repoRoot) {
5945
6291
  function listDocs(ctx, feature) {
5946
6292
  const project = projectForFeature(ctx, feature);
5947
6293
  const dir = featureDocsDir(project, feature);
5948
- if (!existsSync11(dir))
6294
+ if (!existsSync12(dir))
5949
6295
  return [];
5950
6296
  return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => ({ relPath: e.name, title: readTitle(join11(dir, e.name), e.name) })).sort((a, b) => a.relPath.localeCompare(b.relPath));
5951
6297
  }
@@ -5958,7 +6304,7 @@ function readDoc(ctx, feature, relPath) {
5958
6304
  if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
5959
6305
  throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
5960
6306
  }
5961
- if (!existsSync11(target))
6307
+ if (!existsSync12(target))
5962
6308
  throw new NotFoundError(`doc not found: ${relPath}`);
5963
6309
  return { content: readFileSync5(target, "utf8") };
5964
6310
  }
@@ -6015,13 +6361,197 @@ var init_end_session = __esm(() => {
6015
6361
  init_registry();
6016
6362
  });
6017
6363
 
6364
+ // src/services/review-findings.ts
6365
+ import { and as and8, asc as asc4, eq as eq8, isNotNull as isNotNull2 } from "drizzle-orm";
6366
+ function rowToFinding(row) {
6367
+ return ReviewFinding.parse(row);
6368
+ }
6369
+ function getFinding(ctx, id) {
6370
+ const row = ctx.db.select().from(reviewFindings).where(eq8(reviewFindings.id, id)).get();
6371
+ if (!row)
6372
+ throw new NotFoundError(`review finding ${id} not found`);
6373
+ return rowToFinding(row);
6374
+ }
6375
+ function listByFeature3(ctx, featureId) {
6376
+ return ctx.db.select().from(reviewFindings).where(eq8(reviewFindings.featureId, featureId)).orderBy(asc4(reviewFindings.createdAt), asc4(reviewFindings.id)).all().map(rowToFinding);
6377
+ }
6378
+ function buildFixTicket(finding) {
6379
+ return {
6380
+ title: finding.title,
6381
+ goal: `Fix: ${finding.title}`,
6382
+ context: [
6383
+ `Location: ${finding.location}`,
6384
+ `Citation: ${finding.citation}`,
6385
+ `Detail: ${finding.detail}`,
6386
+ `Repro step: ${finding.reproStep}`
6387
+ ].join(`
6388
+
6389
+ `),
6390
+ acceptanceCriteria: [
6391
+ `The repro step no longer reproduces / the cited criterion holds: ${finding.reproStep}`
6392
+ ],
6393
+ seams: [],
6394
+ blockedBy: [],
6395
+ kind: "implementation",
6396
+ originFindingId: finding.id
6397
+ };
6398
+ }
6399
+ function reportFinding(ctx, args) {
6400
+ const { featureId, reviewTicket, input } = args;
6401
+ const feature = getFeatureRow(ctx, featureId);
6402
+ const id = newId("finding");
6403
+ const ticketCount = ctx.db.select({ id: reviewFindings.id }).from(reviewFindings).where(and8(eq8(reviewFindings.reviewTicketId, reviewTicket.id), isNotNull2(reviewFindings.fixTicketId))).all().length;
6404
+ const overCap = input.kind === "defect" && ticketCount >= AUTO_FIX_CAP;
6405
+ ctx.db.insert(reviewFindings).values({
6406
+ id,
6407
+ featureId,
6408
+ lap: feature.lap,
6409
+ reviewTicketId: reviewTicket.id,
6410
+ ...input,
6411
+ reproStep: input.reproStep ?? "",
6412
+ status: "open",
6413
+ openReason: overCap ? "over-cap" : null,
6414
+ failureReason: null,
6415
+ fixTicketId: null,
6416
+ createdAt: Date.now()
6417
+ }).run();
6418
+ let fixTicket = null;
6419
+ if (input.kind === "defect" && !overCap) {
6420
+ const finding2 = getFinding(ctx, id);
6421
+ fixTicket = storeTickets(ctx, featureId, [
6422
+ { ...buildFixTicket(finding2), blockedBy: [reviewTicket.seq] }
6423
+ ], { blockedByAreGlobal: true })[0];
6424
+ ctx.db.update(reviewFindings).set({ fixTicketId: fixTicket.id }).where(eq8(reviewFindings.id, id)).run();
6425
+ }
6426
+ const finding = getFinding(ctx, id);
6427
+ emit(ctx, featureId, {
6428
+ type: "finding.reported",
6429
+ message: `${input.kind} reported: ${input.title}`,
6430
+ ticketId: fixTicket?.id,
6431
+ data: { findingId: id, fixTicketId: fixTicket?.id, overCap }
6432
+ });
6433
+ return { finding, fixTicket, overCap };
6434
+ }
6435
+ function updateStatus(ctx, findingId, patch) {
6436
+ const current2 = getFinding(ctx, findingId);
6437
+ ctx.db.update(reviewFindings).set(patch).where(eq8(reviewFindings.id, findingId)).run();
6438
+ emit(ctx, current2.featureId, {
6439
+ type: "finding.updated",
6440
+ message: `finding ${findingId} marked ${patch.status}`,
6441
+ data: { findingId, status: patch.status, reason: patch.failureReason }
6442
+ });
6443
+ return getFinding(ctx, findingId);
6444
+ }
6445
+ function markFixing(ctx, findingId) {
6446
+ return updateStatus(ctx, findingId, {
6447
+ status: "fixing",
6448
+ openReason: null,
6449
+ failureReason: null
6450
+ });
6451
+ }
6452
+ function markFixed(ctx, findingId) {
6453
+ return updateStatus(ctx, findingId, {
6454
+ status: "fixed",
6455
+ openReason: null,
6456
+ failureReason: null
6457
+ });
6458
+ }
6459
+ function markFailed(ctx, findingId, reason) {
6460
+ return updateStatus(ctx, findingId, {
6461
+ status: "failed",
6462
+ openReason: "fix-failed",
6463
+ failureReason: reason ?? null
6464
+ });
6465
+ }
6466
+ function markFixProgress(ctx, findingId, progress, reason) {
6467
+ if (progress === "fixing")
6468
+ return markFixing(ctx, findingId);
6469
+ if (progress === "fixed")
6470
+ return markFixed(ctx, findingId);
6471
+ return markFailed(ctx, findingId, reason);
6472
+ }
6473
+ function dismiss(ctx, findingId) {
6474
+ return updateStatus(ctx, findingId, {
6475
+ status: "dismissed",
6476
+ openReason: null,
6477
+ failureReason: null
6478
+ });
6479
+ }
6480
+ function defectState(finding, fixTicket) {
6481
+ if (finding.status === "dismissed")
6482
+ return "dismissed";
6483
+ if (finding.status === "fixed" || fixTicket?.status === "done")
6484
+ return "fixed";
6485
+ if (fixTicket && fixTicket.status !== "failed" && fixTicket.status !== "cancelled") {
6486
+ return "fixing";
6487
+ }
6488
+ return finding.status === "open" || finding.status === "failed" ? "open" : "fixing";
6489
+ }
6490
+ function viewByFeature(ctx, featureId) {
6491
+ const findings = listByFeature3(ctx, featureId);
6492
+ const tickets2 = listByFeature2(ctx, featureId);
6493
+ const summary = { found: 0, fixed: 0, open: 0, observations: 0 };
6494
+ const openDefects = [];
6495
+ for (const finding of findings) {
6496
+ if (finding.kind === "observation") {
6497
+ summary.observations += 1;
6498
+ continue;
6499
+ }
6500
+ summary.found += 1;
6501
+ const state = defectState(finding, fixTicketOf(finding, tickets2));
6502
+ if (state === "fixed")
6503
+ summary.fixed += 1;
6504
+ else if (state === "open") {
6505
+ summary.open += 1;
6506
+ openDefects.push(finding);
6507
+ }
6508
+ }
6509
+ return { findings, openDefects, summary };
6510
+ }
6511
+ function fixTicketOf(finding, tickets2) {
6512
+ return finding.fixTicketId ? tickets2.find((t) => t.id === finding.fixTicketId) : undefined;
6513
+ }
6514
+ function promoteOpenDefects(ctx, featureId) {
6515
+ const defects = viewByFeature(ctx, featureId).openDefects;
6516
+ if (defects.length === 0)
6517
+ throw new InvalidInputError("no open defects to fix");
6518
+ const tickets2 = storeTickets(ctx, featureId, defects.map(buildFixTicket));
6519
+ const findings = defects.map((defect, index2) => {
6520
+ ctx.db.update(reviewFindings).set({
6521
+ status: "fixing",
6522
+ openReason: null,
6523
+ failureReason: null,
6524
+ fixTicketId: tickets2[index2].id
6525
+ }).where(eq8(reviewFindings.id, defect.id)).run();
6526
+ return getFinding(ctx, defect.id);
6527
+ });
6528
+ emit(ctx, featureId, {
6529
+ type: "finding.fixing",
6530
+ message: `${findings.length} open defect${findings.length === 1 ? "" : "s"} promoted to fix ticket${tickets2.length === 1 ? ` ${tickets2[0].seq}` : `s ${tickets2.map((t) => t.seq).join(", ")}`}`,
6531
+ data: { findingIds: findings.map((f) => f.id), seqs: tickets2.map((t) => t.seq) }
6532
+ });
6533
+ return { findings, tickets: tickets2 };
6534
+ }
6535
+ var AUTO_FIX_CAP = 8;
6536
+ var init_review_findings = __esm(() => {
6537
+ init_src();
6538
+ init_schema();
6539
+ init_errors();
6540
+ init_events();
6541
+ init_repo();
6542
+ init_tickets();
6543
+ });
6544
+
6018
6545
  // src/services/settings.ts
6019
- import { existsSync as existsSync12, mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
6546
+ import { existsSync as existsSync13, mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
6020
6547
  import { dirname as dirname4 } from "path";
6021
6548
  import * as z3 from "zod";
6022
- import { eq as eq8 } from "drizzle-orm";
6549
+ import { eq as eq9 } from "drizzle-orm";
6550
+ function hostDefaults(logicalCpus) {
6551
+ return { ...DEFAULTS, burnConcurrency: resolveDefaultBurnConcurrency(logicalCpus) };
6552
+ }
6023
6553
  function readRawConfig(configFile) {
6024
- if (!existsSync12(configFile))
6554
+ if (!existsSync13(configFile))
6025
6555
  return {};
6026
6556
  try {
6027
6557
  const parsed = JSON.parse(readFileSync6(configFile, "utf8"));
@@ -6051,6 +6581,7 @@ function projectOverrides(ctx, projectId) {
6051
6581
  const row = ctx.db.select({
6052
6582
  model: projects.model,
6053
6583
  sandbox: projects.sandbox,
6584
+ sessionBranch: projects.sessionBranch,
6054
6585
  devCommand: projects.devCommand,
6055
6586
  setupCommand: projects.setupCommand,
6056
6587
  verifyCommands: projects.verifyCommands,
@@ -6058,10 +6589,11 @@ function projectOverrides(ctx, projectId) {
6058
6589
  dbResetCommand: projects.dbResetCommand,
6059
6590
  driveSetupCommand: projects.driveSetupCommand,
6060
6591
  driveStopCommand: projects.driveStopCommand
6061
- }).from(projects).where(eq8(projects.id, projectId)).get();
6592
+ }).from(projects).where(eq9(projects.id, projectId)).get();
6062
6593
  return {
6063
6594
  model: row?.model ?? null,
6064
6595
  sandbox: row?.sandbox ?? null,
6596
+ sessionBranch: row?.sessionBranch ?? null,
6065
6597
  devCommand: row?.devCommand ?? null,
6066
6598
  setupCommand: row?.setupCommand ?? null,
6067
6599
  verifyCommands: row?.verifyCommands ?? null,
@@ -6090,15 +6622,16 @@ function resolveField(desc5, layers) {
6090
6622
  if (desc5.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc5.configKey)) {
6091
6623
  return { ...base, value: layers.fileRaw[desc5.configKey], source: "file", editable: true };
6092
6624
  }
6093
- const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
6625
+ const value = desc5.configKey ? layers.defaults[desc5.configKey] ?? null : null;
6094
6626
  return { ...base, value, source: "default", editable: true };
6095
6627
  }
6096
6628
  function getSettings(ctx, projectId, io = {}) {
6097
6629
  const env = io.env ?? process.env;
6098
6630
  const fileRaw = readRawConfig(io.configFile ?? configPath());
6099
6631
  const overrides = projectId !== undefined ? projectOverrides(ctx, projectId) : null;
6632
+ const defaults = hostDefaults(io.logicalCpus ?? hostLogicalCpus());
6100
6633
  const visible = DESCRIPTORS.filter((d) => projectId !== undefined || d.configKey !== undefined);
6101
- const fields = visible.map((d) => resolveField(d, { env, fileRaw, overrides }));
6634
+ const fields = visible.map((d) => resolveField(d, { env, fileRaw, overrides, defaults }));
6102
6635
  return { projectId, fields: [...fields, ...stepModelFields(fileRaw)] };
6103
6636
  }
6104
6637
  function updateSettings(ctx, input, io = {}) {
@@ -6121,7 +6654,7 @@ function updateSettings(ctx, input, io = {}) {
6121
6654
  if (!toProject)
6122
6655
  throw new InvalidInputError(`${desc5.key} cannot be cleared`);
6123
6656
  const project = requireProjectById(ctx, input.projectId);
6124
- ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq8(projects.id, project.id)).run();
6657
+ ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq9(projects.id, project.id)).run();
6125
6658
  recordHuman(ctx, project.id, desc5.key, null);
6126
6659
  emitProject(ctx, project.id, {
6127
6660
  type: "settings.updated",
@@ -6137,7 +6670,7 @@ function updateSettings(ctx, input, io = {}) {
6137
6670
  const value = parsed.data;
6138
6671
  if (toProject) {
6139
6672
  const project = requireProjectById(ctx, input.projectId);
6140
- ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq8(projects.id, project.id)).run();
6673
+ ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq9(projects.id, project.id)).run();
6141
6674
  recordHuman(ctx, project.id, desc5.key, String(value));
6142
6675
  emitProject(ctx, project.id, {
6143
6676
  type: "settings.updated",
@@ -6216,6 +6749,7 @@ function field(view, key) {
6216
6749
  var GLOBAL_EVENT_KEY = "global", idEnv = (raw) => raw, DESCRIPTORS, DEFAULTS, STEP_SET;
6217
6750
  var init_settings = __esm(() => {
6218
6751
  init_src();
6752
+ init_config_load();
6219
6753
  init_paths();
6220
6754
  init_schema();
6221
6755
  init_errors();
@@ -6340,16 +6874,15 @@ var init_settings = __esm(() => {
6340
6874
  parseEnv: idEnv
6341
6875
  },
6342
6876
  {
6343
- key: "mainBranch",
6344
- configKey: "mainBranch",
6345
- envVar: "RUNCASTLE_MAIN_BRANCH",
6877
+ key: "devCommand",
6878
+ projectColumn: "devCommand",
6346
6879
  restartRequired: false,
6347
6880
  valueSchema: z3.string().min(1),
6348
6881
  parseEnv: idEnv
6349
6882
  },
6350
6883
  {
6351
- key: "devCommand",
6352
- projectColumn: "devCommand",
6884
+ key: "sessionBranch",
6885
+ projectColumn: "sessionBranch",
6353
6886
  restartRequired: false,
6354
6887
  valueSchema: z3.string().min(1),
6355
6888
  parseEnv: idEnv
@@ -6381,9 +6914,9 @@ var init_settings = __esm(() => {
6381
6914
  });
6382
6915
 
6383
6916
  // src/services/setup.ts
6384
- import { copyFileSync as copyFileSync2, existsSync as existsSync13, mkdirSync as mkdirSync7, readFileSync as readFileSync7, readdirSync as readdirSync4, writeFileSync as writeFileSync6 } from "fs";
6917
+ import { copyFileSync as copyFileSync2, existsSync as existsSync14, mkdirSync as mkdirSync7, readFileSync as readFileSync7, readdirSync as readdirSync4, writeFileSync as writeFileSync6 } from "fs";
6385
6918
  import { dirname as dirname5, join as join12 } from "path";
6386
- import { fileURLToPath as fileURLToPath4 } from "url";
6919
+ import { fileURLToPath as fileURLToPath5 } from "url";
6387
6920
  async function writeGitIdentity(exec, identity) {
6388
6921
  const name = identity.name.trim();
6389
6922
  const email = identity.email.trim();
@@ -6486,10 +7019,10 @@ async function resolveRuntime(exec, preferred) {
6486
7019
  function resolveSandcastleBin() {
6487
7020
  try {
6488
7021
  const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
6489
- let dir = dirname5(fileURLToPath4(mainUrl));
7022
+ let dir = dirname5(fileURLToPath5(mainUrl));
6490
7023
  for (let hops = 0;hops < 6; hops++) {
6491
7024
  const manifestPath = join12(dir, "package.json");
6492
- if (existsSync13(manifestPath)) {
7025
+ if (existsSync14(manifestPath)) {
6493
7026
  const manifest = JSON.parse(readFileSync7(manifestPath, "utf8"));
6494
7027
  if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
6495
7028
  return join12(dir, manifest.bin.sandcastle);
@@ -6521,22 +7054,19 @@ function terminalSpec(kind, opts) {
6521
7054
  };
6522
7055
  }
6523
7056
  function sandcastleTemplateDir() {
6524
- return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath4(new URL("../assets/sandcastle", import.meta.url)));
7057
+ return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath5(new URL("../assets/sandcastle", import.meta.url)));
6525
7058
  }
6526
- function scaffoldSandcastleConfig(templateDir, targetDir) {
7059
+ function refreshSandcastleConfig(templateDir, targetDir) {
6527
7060
  const dir = join12(targetDir, ".sandcastle");
6528
- if (existsSync13(dir))
6529
- return { scaffolded: false, dir };
6530
7061
  mkdirSync7(dir, { recursive: true });
6531
7062
  for (const name of readdirSync4(templateDir)) {
6532
7063
  copyFileSync2(join12(templateDir, name), join12(dir, name));
6533
7064
  }
6534
- return { scaffolded: true, dir };
7065
+ return dir;
6535
7066
  }
6536
- function prepareSandboxBuildContext() {
6537
- const target = sandboxBuildDir();
7067
+ function prepareSandboxBuildContext(templateDir = sandcastleTemplateDir(), target = sandboxBuildDir()) {
6538
7068
  mkdirSync7(target, { recursive: true });
6539
- scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
7069
+ refreshSandcastleConfig(templateDir, target);
6540
7070
  return target;
6541
7071
  }
6542
7072
  function createCredentialVerifier(exec, runtime = DEFAULT_RUNTIME) {
@@ -6572,7 +7102,7 @@ function createCredentialVerifier(exec, runtime = DEFAULT_RUNTIME) {
6572
7102
  function fileAfkTokenIo(exec, runtime = DEFAULT_RUNTIME) {
6573
7103
  const path = envPath();
6574
7104
  return {
6575
- read: () => existsSync13(path) ? readFileSync7(path, "utf8") : "",
7105
+ read: () => existsSync14(path) ? readFileSync7(path, "utf8") : "",
6576
7106
  write: (content) => {
6577
7107
  mkdirSync7(dirname5(path), { recursive: true });
6578
7108
  writeFileSync6(path, content, "utf8");
@@ -6596,7 +7126,7 @@ var init_setup = __esm(() => {
6596
7126
  };
6597
7127
  RUNTIME_AUTH_SETUP_HINT = {
6598
7128
  "claude-code": RUNTIME_SPECS["claude-code"].afkFix,
6599
- codex: RUNTIME_SPECS.codex.afkFix
7129
+ codex: `Run \`${RUNTIME_SPECS.codex.loginCommand}\` on this host, then burn again`
6600
7130
  };
6601
7131
  LOGIN_TERMINAL_KIND = {
6602
7132
  "claude-code": "claude-login",
@@ -6604,6 +7134,32 @@ var init_setup = __esm(() => {
6604
7134
  };
6605
7135
  });
6606
7136
 
7137
+ // src/doctor/system-exec.ts
7138
+ import { spawn as spawn4 } from "child_process";
7139
+ function createSystemExec(opts = {}) {
7140
+ return (command, args) => new Promise((resolve9) => {
7141
+ const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
7142
+ let stdout = "";
7143
+ let stderr = "";
7144
+ const child = spawn4(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
7145
+ child.stdout?.on("data", (d) => {
7146
+ stdout += d.toString();
7147
+ });
7148
+ child.stderr?.on("data", (d) => {
7149
+ stderr += d.toString();
7150
+ });
7151
+ child.on("error", (err) => {
7152
+ resolve9({ ok: false, code: null, stdout, stderr: stderr || String(err) });
7153
+ });
7154
+ child.on("close", (code) => {
7155
+ resolve9({ ok: true, code: code ?? 0, stdout, stderr });
7156
+ });
7157
+ });
7158
+ }
7159
+ var init_system_exec = __esm(() => {
7160
+ init_resolve_executable();
7161
+ });
7162
+
6607
7163
  // src/services/agent-stream.ts
6608
7164
  function beginTranscript(ticketId) {
6609
7165
  transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
@@ -6659,13 +7215,15 @@ function renderGuardScript(rules = GUARD_RULES) {
6659
7215
  "command -v jq >/dev/null 2>&1 || exit 0",
6660
7216
  'tool=$(printf %s "$payload" | jq -r ".tool_name // empty" 2>/dev/null || true)',
6661
7217
  '[ "$tool" = "Bash" ] || exit 0',
6662
- 'cmd=$(printf %s "$payload" | jq -r ".tool_input.command // empty" 2>/dev/null || true)',
6663
- '[ -n "$cmd" ] || exit 0',
7218
+ 'raw_cmd=$(printf %s "$payload" | jq -r ".tool_input.command // empty" 2>/dev/null || true)',
7219
+ '[ -n "$raw_cmd" ] || exit 0',
6664
7220
  "",
6665
7221
  "# Match against the command with quoted spans blanked, so an argument is",
6666
7222
  '# never read as a command: `grep -rn "git stash" docs/` searches for a',
6667
- "# string, it does not run one.",
6668
- `cmd=$(printf %s "$cmd" | sed "s/'[^']*'/ /g; s/\\"[^\\"]*\\"/ /g")`,
7223
+ "# string, it does not run one. Flatten newlines so one grep sees a whole",
7224
+ "# multiline loop.",
7225
+ "cmd=$raw_cmd",
7226
+ `cmd=$(printf %s "$cmd" | sed "s/'[^']*'/ /g; s/\\"[^\\"]*\\"/ /g" | tr '\\n' ' ')`,
6669
7227
  "",
6670
7228
  "deny() {",
6671
7229
  ` printf %s "$1" | jq -R -s '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:.}}'`,
@@ -6674,7 +7232,8 @@ function renderGuardScript(rules = GUARD_RULES) {
6674
7232
  ""
6675
7233
  ];
6676
7234
  for (const rule of rules) {
6677
- lines.push(`# ${rule.id}`, `if printf %s "$cmd" | grep -Eq -- ${shSingleQuote(rule.pattern)}; then`, ` deny ${shSingleQuote(rule.reason)}`, "fi", "");
7235
+ const commandVariable = rule.inspectQuotedArguments ? "$raw_cmd" : "$cmd";
7236
+ lines.push(`# ${rule.id}`, `if printf %s "${commandVariable}" | grep -Eq -- ${shSingleQuote(rule.pattern)}; then`, ` deny ${shSingleQuote(rule.reason)}`, "fi", "");
6678
7237
  }
6679
7238
  lines.push("exit 0", "");
6680
7239
  return lines.join(`
@@ -6724,11 +7283,37 @@ var init_burn_guard = __esm(() => {
6724
7283
  pattern: "--maxWorkers|--runInBand|--pool=|--shard=|singleFork|--poolOptions",
6725
7284
  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.`
6726
7285
  },
7286
+ {
7287
+ id: "no-long-sleep",
7288
+ pattern: `${CMD_START}sleep[[:space:]]+((3[1-9]|[4-9][0-9]|[1-9][0-9]{2,})s?|[1-9][0-9]*(m|h|d))([^A-Za-z0-9_]|$)`,
7289
+ reason: "Polling and long sleeps are never needed in a burn. Run the command directly and read its output."
7290
+ },
7291
+ {
7292
+ id: "no-verification-polling-loop",
7293
+ pattern: `${CMD_START}(until|while)[[:space:]].*(tsc|typecheck|vitest|jest|pytest|mocha|(npm|pnpm|yarn|bun)[[:space:]]+(run[[:space:]]+)?test|turbo[[:space:]]+run[[:space:]]+test).*[;[:space:]][[:space:]]*done`,
7294
+ reason: "Polling and long sleeps are never needed in a burn. Run the command directly and read its output."
7295
+ },
6727
7296
  {
6728
7297
  id: "no-interpreter-heredoc-edit",
6729
7298
  pattern: `${CMD_START}(python3?|node|bun|perl|ruby)[[:space:]]+-[[:space:]]*<<`,
6730
7299
  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."
6731
7300
  },
7301
+ {
7302
+ id: "no-interpreter-inline-edit",
7303
+ pattern: `${CMD_START}node[[:space:]]+-e([[:space:]]|$)`,
7304
+ reason: "Do not rewrite files with a `node -e` program. Use the Edit tool, which is faster, atomic, and fails loudly when its target text is not found."
7305
+ },
7306
+ {
7307
+ id: "no-perl-in-place-edit",
7308
+ pattern: `${CMD_START}perl[[:space:]]+-[0-9p]*i[^[:space:]]*`,
7309
+ reason: "Do not rewrite files with Perl in-place editing. Use the Edit tool, which is faster, atomic, and fails loudly when its target text is not found."
7310
+ },
7311
+ {
7312
+ id: "no-sed-multi-range-edit",
7313
+ pattern: `${CMD_START}sed[[:space:]]+-[^[:space:]]*i[^[:space:]]*[[:space:]]+[^;]*,[^;]*d;[^;]*,[^;]*d`,
7314
+ inspectQuotedArguments: true,
7315
+ reason: "Do not perform multi-range file surgery with `sed -i`. Use the Edit tool, which is atomic and fails loudly when its target text is not found."
7316
+ },
6732
7317
  {
6733
7318
  id: "no-cat-heredoc-edit",
6734
7319
  pattern: `${CMD_START}cat[[:space:]]+>>?[[:space:]]*[^[:space:]|]+[[:space:]]*<<`,
@@ -6753,7 +7338,7 @@ var init_burn_guard = __esm(() => {
6753
7338
  });
6754
7339
 
6755
7340
  // src/workflows/review-ticket.ts
6756
- import { existsSync as existsSync14, mkdirSync as mkdirSync8, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
7341
+ import { existsSync as existsSync15, mkdirSync as mkdirSync8, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
6757
7342
  import { delimiter, join as join13 } from "path";
6758
7343
  import { run } from "@ai-hero/sandcastle";
6759
7344
  import { noSandbox } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
@@ -6769,12 +7354,43 @@ function findOnPath(bin, env = process.env, platform = process.platform) {
6769
7354
  for (const dir of dirs) {
6770
7355
  for (const suffix of suffixes) {
6771
7356
  const candidate = join13(dir, `${bin}${suffix}`);
6772
- if (existsSync14(candidate))
7357
+ if (existsSync15(candidate))
6773
7358
  return candidate;
6774
7359
  }
6775
7360
  }
6776
7361
  return;
6777
7362
  }
7363
+ function buildDriveAvailability(browserPath, devCommand) {
7364
+ const missing = [];
7365
+ if (!browserPath) {
7366
+ missing.push(`\`${AGENT_BROWSER_BIN}\` is not on this machine's PATH, so there is no browser to walk the app with`);
7367
+ }
7368
+ if (!devCommand?.trim()) {
7369
+ missing.push("this project has no dev command configured, so a drive has no app to boot");
7370
+ }
7371
+ if (missing.length === 0) {
7372
+ return `A drive **is** available: \`${AGENT_BROWSER_BIN}\` is on this machine's PATH and the ` + "project has a dev command, so `review_drive` can boot the app and you can walk it. Drive " + "mode is open to you \u2014 take it if, and only if, this lap has a surface a human could operate.";
7373
+ }
7374
+ return `A drive is **not** available: ${missing.join(", and ")}. So the mode is already decided \u2014 ` + "run Gates mode, whatever this lap touched, and do not call `review_drive`. This is not a " + "degraded review; it is the whole review this lap gets.";
7375
+ }
7376
+ function buildGateNotes(config2) {
7377
+ const commands = config2.verifyCommands?.trim();
7378
+ const failures = config2.knownFailures?.trim();
7379
+ const out = [];
7380
+ if (commands) {
7381
+ out.push("Run exactly these, once each \u2014 they are this project's own verify commands, so do not go looking for alternatives, add concurrency flags, or re-run one to re-read its output (redirect to a file and read that instead):", "", "```", commands, "```");
7382
+ } else {
7383
+ out.push("This project has no verify commands configured, so there are no gates to run. Do not go hunting for them \u2014 say so in one line of your summary note and spend the whole mode on the diff.");
7384
+ }
7385
+ out.push("");
7386
+ if (failures) {
7387
+ out.push("These already fail on this repo without this lap's help:", "", "```", failures, "```", "", "Subtract that baseline: a failure inside it is not this lap's and is not a finding. A failure outside it is one this lap introduced, and it is the finding worth the human's attention above every other.");
7388
+ } else {
7389
+ out.push("No pre-existing-failure baseline is configured, so a red gate may well predate this lap. Run it once, then check whether the failure touches the diff before writing it up as this lap's \u2014 and never re-run a suite to establish what was already red.");
7390
+ }
7391
+ return out.join(`
7392
+ `);
7393
+ }
6778
7394
  function writeReviewArtifacts(ticket, runId, config2) {
6779
7395
  const dir = reviewDir(ticket.id);
6780
7396
  rmSync2(dir, { recursive: true, force: true });
@@ -6796,9 +7412,18 @@ async function releaseDriveQuietly() {
6796
7412
  } catch {}
6797
7413
  }
6798
7414
  async function executeReviewTicket(ctx, ticket, deps) {
7415
+ const startedAt = Date.now();
7416
+ const timer = createToolTimer();
7417
+ try {
7418
+ return await reviewTicketOutcome(ctx, ticket, deps, timer);
7419
+ } finally {
7420
+ emitTicketTiming(ctx, ticket, buildTicketTiming(timer.summary(), startedAt, Date.now()));
7421
+ }
7422
+ }
7423
+ async function reviewTicketOutcome(ctx, ticket, deps, timer) {
6799
7424
  const { project, feature } = ctx;
6800
- if (!findOnPath(AGENT_BROWSER_BIN)) {
6801
- return couldNotReview(ticket, `\`${AGENT_BROWSER_BIN}\` is not on this machine's PATH, so there is no way to drive the app. Install it and re-burn this ticket.`);
7425
+ if (!feature.baseBranch) {
7426
+ return couldNotReview(ticket, `feature ${feature.slug} has no recorded base branch, so there is nothing to diff \`${feature.branch}\` against.`);
6802
7427
  }
6803
7428
  const artifacts = writeReviewArtifacts(ticket, ctx.runId, deps.config);
6804
7429
  const prompt = renderReviewPrompt(readFileSync8(reviewTemplatePath(), "utf8"), {
@@ -6807,7 +7432,9 @@ async function executeReviewTicket(ctx, ticket, deps) {
6807
7432
  DOCS_DIGEST: deps.docsDigest,
6808
7433
  LAP_DIGESTS: buildLapDigestsBlock(deps.lapDigests),
6809
7434
  FEATURE_BRANCH: feature.branch,
6810
- BASE_BRANCH: project.mainBranch,
7435
+ BASE_BRANCH: feature.baseBranch,
7436
+ DRIVE_AVAILABILITY: buildDriveAvailability(findOnPath(AGENT_BROWSER_BIN), project.devCommand),
7437
+ GATE_NOTES: buildGateNotes(deps.config),
6811
7438
  DIGEST_PATH: artifacts.digestPath,
6812
7439
  BLOCKED_PATH: artifacts.blockedPath,
6813
7440
  WALKTHROUGH_PATH: artifacts.walkthroughPath
@@ -6817,6 +7444,7 @@ async function executeReviewTicket(ctx, ticket, deps) {
6817
7444
  beginTranscript(ticket.id);
6818
7445
  const onStreamEvent = (event) => {
6819
7446
  throttle.onEvent(event);
7447
+ timer.onEvent(event);
6820
7448
  if (event.type === "text") {
6821
7449
  appendTranscript(ticket.id, { kind: "text", text: event.message });
6822
7450
  } else if (event.type === "toolCall") {
@@ -6889,13 +7517,22 @@ var init_review_ticket = __esm(() => {
6889
7517
  });
6890
7518
 
6891
7519
  // src/workflows/ticket-burner.ts
6892
- import { existsSync as existsSync15, mkdirSync as mkdirSync9, readFileSync as readFileSync9, readdirSync as readdirSync5, rmSync as rmSync3 } from "fs";
7520
+ import { existsSync as existsSync16, mkdirSync as mkdirSync9, readFileSync as readFileSync9, readdirSync as readdirSync5, rmSync as rmSync3 } from "fs";
6893
7521
  import { dirname as dirname6, join as join14, posix as posixPath, win32 as winPath } from "path";
6894
- import { fileURLToPath as fileURLToPath5 } from "url";
7522
+ import { fileURLToPath as fileURLToPath6 } from "url";
6895
7523
  import { claudeCode, codex, run as run2 } from "@ai-hero/sandcastle";
6896
7524
  import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
6897
7525
  import { podman } from "@ai-hero/sandcastle/sandboxes/podman";
6898
7526
  import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
7527
+ function missingImageRuntimeMessage(runtime, image) {
7528
+ const binary = RUNTIME_BINARY[runtime];
7529
+ return `${binary} is not installed in image ${image} \u2014 the image predates the burner Dockerfile. Rebuild it from Settings \u2192 AFK burns (Rebuild image).`;
7530
+ }
7531
+ function burnAuthReady(runtime, token, loggedIn = codexLoggedIn) {
7532
+ if (token !== undefined)
7533
+ return true;
7534
+ return runtime === "codex" && loggedIn();
7535
+ }
6899
7536
  function isReviewTicket(ticket) {
6900
7537
  return ticket.kind === "review";
6901
7538
  }
@@ -7036,10 +7673,10 @@ _(The "${DROPPED_MAP_SECTIONS.join('" and "')}" sections are omitted here: they
7036
7673
  }
7037
7674
  function buildProjectStandards(repoPath) {
7038
7675
  const present = [];
7039
- if (existsSync15(join14(repoPath, AGENT_CONVENTIONS_FILE))) {
7676
+ if (existsSync16(join14(repoPath, AGENT_CONVENTIONS_FILE))) {
7040
7677
  present.push("`CLAUDE.md` \u2014 this repo's agent-facing conventions, and the highest authority on them");
7041
7678
  }
7042
- if (existsSync15(join14(repoPath, CHARTER_FILE))) {
7679
+ if (existsSync16(join14(repoPath, CHARTER_FILE))) {
7043
7680
  present.push(`\`${CHARTER_FILE}\` \u2014 the project charter: what this codebase is for`);
7044
7681
  }
7045
7682
  const adrs = listLiveAdrs(repoPath).map((a) => a.relPath);
@@ -7070,7 +7707,7 @@ function buildDriveNotes(settings) {
7070
7707
  add("stop", settings.driveStopCommand);
7071
7708
  add("dev server", settings.devCommand);
7072
7709
  add("db reset", settings.dbResetCommand);
7073
- const hasDir = existsSync15(join14(settings.repoPath, ".runcastle"));
7710
+ const hasDir = existsSync16(join14(settings.repoPath, ".runcastle"));
7074
7711
  if (configured.length === 0 && !hasDir) {
7075
7712
  return [
7076
7713
  "This project has no test-drive machinery configured, so there is nothing here for you to keep in step \u2014 do not invent any.",
@@ -7093,7 +7730,9 @@ function buildDriveNotes(settings) {
7093
7730
  `);
7094
7731
  }
7095
7732
  function buildGuardNotes(guardInstalled) {
7096
- return guardInstalled ? "Three of the rules below are enforced by a tool hook, not merely stated: `git stash`, test-runner concurrency flags, and rewriting files through interpreter heredocs are **denied before they run**. A denial is policy, not a broken environment \u2014 read its reason, take the alternative it names, and carry on. Do not try to route around it." : "Nothing below is machine-enforced in this burn \u2014 the deny hook is not installed, so the rules hold on your discipline alone. They are not style preferences: each one is a measured way burns lose work.";
7733
+ const introduction = guardInstalled ? "The following commands are **denied before they run** by a tool hook. Each denial and its prescribed alternative comes verbatim from the guard that enforces it. A denial is policy, not a broken environment \u2014 follow the alternative and do not try to route around it." : "The deny hook is not installed in this burn, so the following guard rules are not machine-enforced. Follow them on discipline alone; each denial and its prescribed alternative comes verbatim from the guard used in protected burns.";
7734
+ return [introduction, "", ...GUARD_RULES.map((rule) => `- ${rule.reason}`)].join(`
7735
+ `);
7097
7736
  }
7098
7737
  function buildBlockersBlock(blockedBy, digests) {
7099
7738
  if (blockedBy.length === 0) {
@@ -7116,6 +7755,16 @@ ${d.digest.trim()}`;
7116
7755
  return [head, "", "In their own words:", "", ...bodies].join(`
7117
7756
  `);
7118
7757
  }
7758
+ function buildFixNotes(ticket) {
7759
+ if (!ticket.originFindingId)
7760
+ return "";
7761
+ return [
7762
+ "### This ticket fixes a review finding",
7763
+ "",
7764
+ "It was minted from a defect the review of this same lap reported, and its `context` carries the reviewer's repro step verbatim. **Before you declare done, re-run that repro step exactly, and say in your digest that you re-ran it and what happened.** Nothing reviews your change afterwards \u2014 that repro step is the check, and the verify commands run on top of it."
7765
+ ].join(`
7766
+ `);
7767
+ }
7119
7768
  function buildLapDigestsBlock(digests) {
7120
7769
  if (digests.length === 0) {
7121
7770
  return "_No implementation ticket in this burn left a digest \u2014 the diff and the feature docs are all you have._";
@@ -7152,13 +7801,13 @@ function parseEnvFile2(content) {
7152
7801
  if (line.length === 0 || line.startsWith("#"))
7153
7802
  continue;
7154
7803
  const body = line.startsWith("export ") ? line.slice(7).trim() : line;
7155
- const eq9 = body.indexOf("=");
7156
- if (eq9 === -1)
7804
+ const eq10 = body.indexOf("=");
7805
+ if (eq10 === -1)
7157
7806
  continue;
7158
- const key = body.slice(0, eq9).trim();
7807
+ const key = body.slice(0, eq10).trim();
7159
7808
  if (key.length === 0)
7160
7809
  continue;
7161
- let value = body.slice(eq9 + 1).trim();
7810
+ let value = body.slice(eq10 + 1).trim();
7162
7811
  if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
7163
7812
  value = value.slice(1, -1);
7164
7813
  }
@@ -7206,7 +7855,31 @@ function cacheMountFor(pm, hostPath) {
7206
7855
  const sandboxPath = PM_CACHE_SANDBOX_PATHS[pm];
7207
7856
  return sandboxPath ? { hostPath, sandboxPath } : undefined;
7208
7857
  }
7858
+ function buildBurnCacheMounts(slot, projectId, sandbox, pm) {
7859
+ if (slot !== undefined) {
7860
+ return {
7861
+ mounts: [{ volume: burnCacheVolumeName(projectId), sandboxPath: BURN_CACHE_MOUNT }],
7862
+ env: pm ? burnCacheEnv(pm) : {}
7863
+ };
7864
+ }
7865
+ const mount = sandbox !== "noSandbox" && pm ? cacheMountFor(pm, burnCacheDir(pm)) : undefined;
7866
+ return { mounts: mount ? [mount] : [], env: {} };
7867
+ }
7868
+ function codexAuthMountFor(runtime, sandbox, env = process.env, loggedIn = codexLoggedIn) {
7869
+ if (runtime !== "codex" || sandbox === "noSandbox" || !loggedIn(env))
7870
+ return;
7871
+ return { hostPath: codexHomeDir(env), sandboxPath: CODEX_HOST_MOUNT_PATH, readonly: true };
7872
+ }
7873
+ function buildCodexAuthCopyCommand() {
7874
+ return `mkdir -p "$HOME/.codex" && cp "${CODEX_HOST_MOUNT_PATH}/auth.json" "$HOME/.codex/auth.json"`;
7875
+ }
7876
+ function chainSetupCommands(...steps) {
7877
+ const present = steps.filter((step) => !!step);
7878
+ return present.length > 0 ? present.join(" && ") : undefined;
7879
+ }
7209
7880
  function resolveBurnWorkspaceMode(config2, platform = process.platform) {
7881
+ if (resolveBurnCacheMode(config2) === "volume")
7882
+ return "slot";
7210
7883
  if (config2.sandbox === "noSandbox")
7211
7884
  return "mounted";
7212
7885
  if (config2.burnWorkspace === "auto")
@@ -7214,26 +7887,84 @@ function resolveBurnWorkspaceMode(config2, platform = process.platform) {
7214
7887
  return config2.burnWorkspace;
7215
7888
  }
7216
7889
  function buildIsolatedSetupCommand(tempBranch, setupCommand, pm) {
7217
- const hookFile = `${ISOLATED_REPO_PATH}/.git/hooks/post-commit`;
7218
- const attachmentsDir = `${SANDBOX_WORKSPACE_PATH}/${ATTACHMENTS_DIR}`;
7219
- const cloneAttachmentsDir = `${ISOLATED_REPO_PATH}/${ATTACHMENTS_DIR}`;
7220
- const parts = [
7890
+ const steps = buildRepoSetupSteps(ISOLATED_REPO_PATH, tempBranch, setupCommand, pm);
7891
+ return [
7221
7892
  `git config --global --add safe.directory '*'`,
7222
7893
  `git clone ${SANDBOX_WORKSPACE_PATH} ${ISOLATED_REPO_PATH}`,
7223
- `if [ -d "${attachmentsDir}" ]; then mkdir -p "${cloneAttachmentsDir}" && cp -r "${attachmentsDir}/." "${cloneAttachmentsDir}/" && mkdir -p "${ISOLATED_REPO_PATH}/.git/info" && printf '%s\\n' '${ATTACHMENTS_DIR}/' >> "${ISOLATED_REPO_PATH}/.git/info/exclude"; fi`,
7224
- `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}`,
7894
+ ...steps.pre,
7895
+ ...steps.install,
7896
+ ...steps.post
7897
+ ].join(" && ");
7898
+ }
7899
+ function buildRepoSetupSteps(repoPath, tempBranch, setupCommand, pm) {
7900
+ const hookFile = `${repoPath}/.git/hooks/post-commit`;
7901
+ const attachmentsDir = `${SANDBOX_WORKSPACE_PATH}/${ATTACHMENTS_DIR}`;
7902
+ const repoAttachmentsDir = `${repoPath}/${ATTACHMENTS_DIR}`;
7903
+ const excludeFile = `${repoPath}/.git/info/exclude`;
7904
+ const pre = [
7905
+ `if [ -d "${attachmentsDir}" ]; then mkdir -p "${repoAttachmentsDir}" && cp -r "${attachmentsDir}/." "${repoAttachmentsDir}/" && mkdir -p "${repoPath}/.git/info" && { grep -qxF '${ATTACHMENTS_DIR}/' "${excludeFile}" 2>/dev/null || printf '%s\\n' '${ATTACHMENTS_DIR}/' >> "${excludeFile}"; }; fi`,
7906
+ `printf '#!/bin/sh\\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE\\ngit push --quiet origin HEAD:%s && exit 0\\nsleep 2\\ngit push --quiet origin HEAD:%s && exit 0\\necho "runcastle: commit sync failed (will retry on your next commit); do not re-commit" >&2\\nexit 0\\n' '${tempBranch}' '${tempBranch}' > ${hookFile}`,
7225
7907
  `chmod +x ${hookFile}`
7226
7908
  ];
7227
7909
  if (pm === "pnpm" || pm === "yarn") {
7228
7910
  const shim = `$HOME/.local/bin/${pm}`;
7229
- parts.push(`mkdir -p "$HOME/.local/bin"`, `printf '#!/bin/sh\\nexec corepack ${pm} "$@"\\n' > "${shim}"`, `chmod +x "${shim}"`);
7911
+ pre.push(`mkdir -p "$HOME/.local/bin"`, `printf '#!/bin/sh\\nexec corepack ${pm} "$@"\\n' > "${shim}"`, `chmod +x "${shim}"`);
7912
+ }
7913
+ return {
7914
+ pre,
7915
+ install: setupCommand ? [`cd ${repoPath} && ${setupCommand}`] : [],
7916
+ post: [`git -C ${repoPath} config core.hooksPath ${repoPath}/.git/hooks`]
7917
+ };
7918
+ }
7919
+ function parseSetupMarker(text2) {
7920
+ const m = new RegExp(String.raw`${SETUP_MARKER} cold=([01]) sync_ms=(\d+) install_ms=(\d+)`).exec(text2);
7921
+ if (!m)
7922
+ return;
7923
+ return { cold: m[1] === "1", syncMs: Number(m[2]), installMs: Number(m[3]) };
7924
+ }
7925
+ function takeSetupMarker(worktreePath) {
7926
+ const file = join14(worktreePath, SETUP_MARKER_FILE);
7927
+ let text2;
7928
+ try {
7929
+ text2 = readFileSync9(file, "utf8");
7930
+ } catch {
7931
+ return;
7230
7932
  }
7231
- if (setupCommand)
7232
- parts.push(`cd ${ISOLATED_REPO_PATH} && ${setupCommand}`);
7233
- parts.push(`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks`);
7234
- return parts.join(" && ");
7933
+ rmSync3(file, { force: true });
7934
+ return parseSetupMarker(text2);
7935
+ }
7936
+ function fmtSeconds(ms) {
7937
+ return `${(ms / 1000).toFixed(1)}s`;
7938
+ }
7939
+ function buildSlotStamp(imageName, packageManagerField) {
7940
+ const pm = packageManagerField?.split("@")[0] ?? "none";
7941
+ const major = packageManagerField?.split("@")[1]?.split(".")[0] ?? "any";
7942
+ return `${imageName} node=$(node --version 2>/dev/null) pm=${pm}@${major}`;
7235
7943
  }
7236
- function buildWorkspaceNotes(mode) {
7944
+ function buildSlotSetupCommand(slot, tempBranch, setupCommand, pm, stamp) {
7945
+ const repo = slotRepoPath(slot);
7946
+ const stampFile = slotStampPath(slot);
7947
+ const steps = buildRepoSetupSteps(repo, tempBranch, setupCommand, pm);
7948
+ return [
7949
+ `git config --global --add safe.directory '*'`,
7950
+ `RC_COLD=0`,
7951
+ `RC_STAMP="${stamp}"`,
7952
+ `RC_SYNC_START=$(date +%s%3N)`,
7953
+ `mkdir -p ${slotDirPath(slot)}`,
7954
+ `rm -f ${repo}/.git/*.lock`,
7955
+ `if ! git -C ${repo} rev-parse --git-dir >/dev/null 2>&1; then rm -rf ${repo} && git clone ${SANDBOX_WORKSPACE_PATH} ${repo} && RC_COLD=1; else git -C ${repo} fetch ${SANDBOX_WORKSPACE_PATH} ${tempBranch} && git -C ${repo} reset --hard FETCH_HEAD && git -C ${repo} checkout -B ${tempBranch}; fi`,
7956
+ `git -C ${repo} clean -fd`,
7957
+ `if [ "$(cat ${stampFile} 2>/dev/null)" != "$RC_STAMP" ]; then rm -rf ${repo}/node_modules && git -C ${repo} clean -fdX && RC_COLD=1 && printf '%s\\n' "$RC_STAMP" > ${stampFile}; fi`,
7958
+ ...steps.pre,
7959
+ `RC_SYNC_MS=$(( $(date +%s%3N) - RC_SYNC_START ))`,
7960
+ `RC_INSTALL_START=$(date +%s%3N)`,
7961
+ ...steps.install,
7962
+ `RC_INSTALL_MS=$(( $(date +%s%3N) - RC_INSTALL_START ))`,
7963
+ ...steps.post,
7964
+ `printf '${SETUP_MARKER} cold=%s sync_ms=%s install_ms=%s\\n' "$RC_COLD" "$RC_SYNC_MS" "$RC_INSTALL_MS" > ${SANDBOX_WORKSPACE_PATH}/${SETUP_MARKER_FILE}`
7965
+ ].join(" && ");
7966
+ }
7967
+ function buildWorkspaceNotes(mode, repoPath = ISOLATED_REPO_PATH) {
7237
7968
  if (mode === "mounted") {
7238
7969
  return [
7239
7970
  "Work in the current directory \u2014 it is the repo checkout on your branch.",
@@ -7245,13 +7976,13 @@ function buildWorkspaceNotes(mode) {
7245
7976
  `);
7246
7977
  }
7247
7978
  return [
7248
- `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.`,
7979
+ `Your working repository is \`${repoPath}\` \u2014 a clone on the container's fast native filesystem, with dependencies already installed. Do ALL work there: \`cd ${repoPath}\` first; every file you read, edit, test, and commit lives under it.`,
7249
7980
  "",
7250
- `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.`,
7981
+ `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 ${repoPath} config core.hooksPath ${repoPath}/.git/hooks\` afterwards so the sync hook stays armed.`,
7251
7982
  "",
7252
- `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.`,
7983
+ `If you are blocked and write \`BLOCKED.md\`, write it at \`${repoPath}/BLOCKED.md\` AND copy it to \`${SANDBOX_WORKSPACE_PATH}/BLOCKED.md\` so the orchestrator can see it.`,
7253
7984
  "",
7254
- `Write \`DIGEST.md\` at \`${SANDBOX_WORKSPACE_PATH}/DIGEST.md\` \u2014 the mounted mirror, so the host can see it \u2014 and NOT inside \`${ISOLATED_REPO_PATH}\`, where it would be committed with your work.`,
7985
+ `Write \`DIGEST.md\` at \`${SANDBOX_WORKSPACE_PATH}/DIGEST.md\` \u2014 the mounted mirror, so the host can see it \u2014 and NOT inside \`${repoPath}\`, where it would be committed with your work.`,
7255
7986
  "",
7256
7987
  "**Those paths are the only authoritative ones** \u2014 nothing later in this prompt overrides them."
7257
7988
  ].join(`
@@ -7268,7 +7999,7 @@ function attachedNoteIds(context) {
7268
7999
  return ids2;
7269
8000
  }
7270
8001
  function attachmentSources(context) {
7271
- return attachedNoteIds(context).map(annotationPath).filter((p) => existsSync15(p));
8002
+ return attachedNoteIds(context).map(annotationPath).filter((p) => existsSync16(p));
7272
8003
  }
7273
8004
  function buildAttachmentCopyCommands(sources, platform = process.platform) {
7274
8005
  if (sources.length === 0)
@@ -7311,7 +8042,7 @@ function buildVerifyNotes(config2) {
7311
8042
  }
7312
8043
  function readRepoToolchain(repoPath) {
7313
8044
  const pkgPath = join14(repoPath, "package.json");
7314
- const hasPackageJson = existsSync15(pkgPath);
8045
+ const hasPackageJson = existsSync16(pkgPath);
7315
8046
  let packageManagerField;
7316
8047
  if (hasPackageJson) {
7317
8048
  try {
@@ -7324,10 +8055,10 @@ function readRepoToolchain(repoPath) {
7324
8055
  hasPackageJson,
7325
8056
  ...packageManagerField !== undefined ? { packageManagerField } : {},
7326
8057
  lockfiles: {
7327
- bun: existsSync15(join14(repoPath, "bun.lock")) || existsSync15(join14(repoPath, "bun.lockb")),
7328
- pnpm: existsSync15(join14(repoPath, "pnpm-lock.yaml")),
7329
- yarn: existsSync15(join14(repoPath, "yarn.lock")),
7330
- npm: existsSync15(join14(repoPath, "package-lock.json"))
8058
+ bun: existsSync16(join14(repoPath, "bun.lock")) || existsSync16(join14(repoPath, "bun.lockb")),
8059
+ pnpm: existsSync16(join14(repoPath, "pnpm-lock.yaml")),
8060
+ yarn: existsSync16(join14(repoPath, "yarn.lock")),
8061
+ npm: existsSync16(join14(repoPath, "package-lock.json"))
7331
8062
  }
7332
8063
  };
7333
8064
  }
@@ -7396,8 +8127,9 @@ function createToolTimer() {
7396
8127
  if (event.type === "raw")
7397
8128
  return;
7398
8129
  const at = event.timestamp.getTime();
7399
- if (iteration !== null && event.iteration !== iteration)
7400
- pending = null;
8130
+ if (iteration !== null && event.iteration !== iteration && pending) {
8131
+ pending = { category: "setup", at: pending.at };
8132
+ }
7401
8133
  iteration = event.iteration;
7402
8134
  if (pending) {
7403
8135
  const gap = at - pending.at;
@@ -7414,7 +8146,11 @@ function createToolTimer() {
7414
8146
  pending = { category: "model", at };
7415
8147
  }
7416
8148
  };
7417
- return { onEvent, summary: () => ({ totalMs, calls, byCategory }) };
8149
+ const beginSetup = (at = Date.now()) => {
8150
+ pending = { category: "setup", at };
8151
+ iteration = null;
8152
+ };
8153
+ return { onEvent, beginSetup, summary: () => ({ totalMs, calls, byCategory }) };
7418
8154
  }
7419
8155
  function formatTimingSummary(s) {
7420
8156
  if (s.totalMs === 0)
@@ -7422,6 +8158,22 @@ function formatTimingSummary(s) {
7422
8158
  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)}%`);
7423
8159
  return `${Math.round(s.totalMs / 60000)}min across ${s.calls} tool call(s) \u2014 ${parts.join(", ")}`;
7424
8160
  }
8161
+ function buildTicketTiming(summary, startedAt, endedAt) {
8162
+ return { ...summary, startedAt, endedAt, wallMs: Math.max(0, endedAt - startedAt) };
8163
+ }
8164
+ function formatTicketTiming(t) {
8165
+ const clock = fmtClock(Math.round(t.wallMs / 1000));
8166
+ const bare = t.calls === 0 && t.totalMs === 0;
8167
+ return bare ? clock : `${clock} (${formatTimingSummary(t)})`;
8168
+ }
8169
+ function emitTicketTiming(ctx, ticket, timing) {
8170
+ ctx.emitEvent({
8171
+ type: "ticket.timing",
8172
+ message: `ticket ${ticket.seq} spent ${formatTicketTiming(timing)}`,
8173
+ ticketId: ticket.id,
8174
+ data: timing
8175
+ });
8176
+ }
7425
8177
  function interpretRunResult(result, blockedContent) {
7426
8178
  const commits = result.commits.map((c) => c.sha);
7427
8179
  if (commits.length > 0)
@@ -7442,10 +8194,20 @@ function isMergeConflictError(err) {
7442
8194
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
7443
8195
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
7444
8196
  }
8197
+ function missingAgentBinaryMessage(err, runtime, image) {
8198
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
8199
+ const binary = AGENT_BINARY[runtime];
8200
+ const missing = new RegExp(`\\b${binary}\\b[^\\n]*(?:exited with code 127|command not found|not found|no such file or directory)`, "i");
8201
+ if (!missing.test(msg))
8202
+ return;
8203
+ return `${binary} is not installed in image ${image} \u2014 the image predates the burner Dockerfile. Rebuild it from Settings \u2192 AFK burns (Rebuild image).`;
8204
+ }
7445
8205
  function classifyTicketRunError(err, runtime) {
7446
8206
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
7447
8207
  const runtimes = runtime ? [runtime] : ["claude-code", "codex"];
7448
8208
  const forRuntimes = (table) => runtimes.flatMap((r) => table[r]);
8209
+ if (runtimes.some((r) => missingAgentBinaryMessage(msg, r, "image")))
8210
+ return "fatal";
7449
8211
  if ([...FATAL_ERROR_PATTERNS, ...forRuntimes(RUNTIME_FATAL_ERROR_PATTERNS)].some((p) => p.test(msg)))
7450
8212
  return "fatal";
7451
8213
  if ([...RETRYABLE_ERROR_PATTERNS, ...forRuntimes(RUNTIME_RETRYABLE_ERROR_PATTERNS)].some((p) => p.test(msg)))
@@ -7559,13 +8321,42 @@ function failedBlockerNote(seqs) {
7559
8321
  }
7560
8322
  async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7561
8323
  const width = Math.max(1, Math.floor(concurrency));
7562
- const bySeq = indexBySeq(tickets2);
7563
- const status = new Map(tickets2.map((t) => [t.seq, t.status]));
7564
- const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
8324
+ const scheduled = [...tickets2];
8325
+ const bySeq = indexBySeq(scheduled);
8326
+ const status = new Map(scheduled.map((t) => [t.seq, t.status]));
8327
+ const pending = new Set(scheduled.filter((t) => t.status === "pending").map((t) => t.seq));
7565
8328
  const inFlight = new Map;
7566
8329
  const digests = [];
8330
+ let admitted = 0;
7567
8331
  const satisfied = (t, s) => s === "done" || s === "cancelled" || isReviewTicket(t) && s === "failed";
7568
- const implementationsSettled = () => tickets2.every((t) => isReviewTicket(t) || !pending.has(t.seq) && !inFlight.has(t.seq));
8332
+ const implementationsSettled = () => scheduled.every((t) => isReviewTicket(t) || !pending.has(t.seq) && !inFlight.has(t.seq));
8333
+ const admitNewTickets = () => {
8334
+ const added = [];
8335
+ for (const t of ctx.listTickets?.() ?? []) {
8336
+ if (bySeq.has(t.seq))
8337
+ continue;
8338
+ scheduled.push(t);
8339
+ bySeq.set(t.seq, t);
8340
+ status.set(t.seq, t.status);
8341
+ if (t.status !== "pending")
8342
+ continue;
8343
+ pending.add(t.seq);
8344
+ added.push(t.seq);
8345
+ }
8346
+ if (added.length === 0)
8347
+ return;
8348
+ admitted += added.length;
8349
+ ctx.emitEvent({
8350
+ type: "burn.admitted",
8351
+ message: `${added.length} ticket(s) minted during this run joined it: ${added.join(", ")}`,
8352
+ data: { seqs: added }
8353
+ });
8354
+ };
8355
+ const mirrorFinding = (t, progress, reason) => {
8356
+ if (!t.originFindingId)
8357
+ return;
8358
+ ctx.updateFinding?.(t.originFindingId, progress, reason);
8359
+ };
7569
8360
  const readyState = (seq) => {
7570
8361
  const t = bySeq.get(seq);
7571
8362
  if (!t)
@@ -7581,7 +8372,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7581
8372
  return t.blockedBy.every((b) => satisfied(t, status.get(b))) ? "ready" : "wait";
7582
8373
  };
7583
8374
  const harvestDigest2 = (t, digest) => {
7584
- const failed = isReviewTicket(t) ? tickets2.filter((x) => !isReviewTicket(x) && status.get(x.seq) === "failed").map((x) => x.seq) : [];
8375
+ const failed = isReviewTicket(t) ? scheduled.filter((x) => !isReviewTicket(x) && status.get(x.seq) === "failed").map((x) => x.seq) : [];
7585
8376
  const body = [failed.length > 0 ? failedBlockerNote(failed) : undefined, digest].filter(Boolean).join(`
7586
8377
 
7587
8378
  `);
@@ -7594,6 +8385,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7594
8385
  if (!t)
7595
8386
  return;
7596
8387
  ctx.updateTicket(t.id, { status: "failed", error, ...digest ? { digest } : {} });
8388
+ mirrorFinding(t, "failed", error);
7597
8389
  harvestDigest2(t, digest);
7598
8390
  if (extra)
7599
8391
  ctx.emitEvent({ ...extra, ticketId: t.id });
@@ -7604,6 +8396,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7604
8396
  return;
7605
8397
  status.set(seq, "burning");
7606
8398
  ctx.updateTicket(t.id, { status: "burning" });
8399
+ mirrorFinding(t, "fixing");
7607
8400
  ctx.emitEvent({
7608
8401
  type: "ticket.burning",
7609
8402
  message: `burning ticket ${t.seq}: ${t.title}`,
@@ -7613,6 +8406,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7613
8406
  if (outcome.status === "done") {
7614
8407
  status.set(seq, "done");
7615
8408
  ctx.updateTicket(t.id, { status: "done", commits: outcome.commits, digest: outcome.digest });
8409
+ mirrorFinding(t, "fixed");
7616
8410
  ctx.emitEvent({
7617
8411
  type: "ticket.done",
7618
8412
  message: `ticket ${t.seq} done \u2014 ${outcome.commits.length} commit(s)`,
@@ -7636,6 +8430,8 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7636
8430
  data: { error: outcome.error }
7637
8431
  });
7638
8432
  }
8433
+ if (isReviewTicket(t))
8434
+ admitNewTickets();
7639
8435
  };
7640
8436
  while (pending.size > 0 || inFlight.size > 0) {
7641
8437
  ctx.signal.throwIfAborted();
@@ -7692,7 +8488,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7692
8488
  for (const s of status.values())
7693
8489
  if (s === "done")
7694
8490
  done += 1;
7695
- return { done, digests };
8491
+ return { done, admitted, digests };
7696
8492
  }
7697
8493
  function composeRunDigest(entries) {
7698
8494
  if (entries.length === 0)
@@ -7703,15 +8499,44 @@ ${e.digest.trim()}`).join(`
7703
8499
 
7704
8500
  `);
7705
8501
  }
8502
+ function gateTicketAuth(deps) {
8503
+ const authMissing = deps.ticketAuthMissing;
8504
+ if (!authMissing || deps.config.sandbox === "noSandbox")
8505
+ return deps.executeTicketRun;
8506
+ return async (ctx, ticket, run3) => {
8507
+ const runtime = authMissing(ticket);
8508
+ if (!runtime)
8509
+ return deps.executeTicketRun(ctx, ticket, run3);
8510
+ const hint = `${runtime} is not authenticated \u2014 ${RUNTIME_AUTH_SETUP_HINT[runtime]}`;
8511
+ ctx.emitEvent({ type: AUTH_MISSING_EVENT, message: hint, ticketId: ticket.id });
8512
+ return { status: "failed", error: hint };
8513
+ };
8514
+ }
7706
8515
  async function burnRun(ctx, deps) {
7707
8516
  const tickets2 = ctx.tickets;
7708
8517
  const burnable = tickets2.filter((t) => t.status !== "cancelled");
7709
8518
  const cancelled = tickets2.length - burnable.length;
7710
- const total = burnable.length;
7711
8519
  if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
7712
8520
  ctx.emitEvent({ type: AUTH_MISSING_EVENT, message: RUNTIME_AUTH_SETUP_HINT[deps.runtime] });
7713
8521
  return { status: "failed", summary: "burn aborted: auth token missing" };
7714
8522
  }
8523
+ if (deps.config.sandbox !== "noSandbox" && deps.exec) {
8524
+ const image = resolveSandboxImage(deps.config);
8525
+ const binary = RUNTIME_BINARY[deps.runtime];
8526
+ const probe = await deps.exec(deps.config.sandbox, [
8527
+ "run",
8528
+ "--rm",
8529
+ "--entrypoint",
8530
+ binary,
8531
+ image,
8532
+ "--version"
8533
+ ]);
8534
+ if (!probe.ok || probe.code !== 0) {
8535
+ const message = missingImageRuntimeMessage(deps.runtime, image);
8536
+ ctx.emitEvent({ type: IMAGE_RUNTIME_MISSING_EVENT, message });
8537
+ return { status: "failed", summary: message };
8538
+ }
8539
+ }
7715
8540
  const cycle = detectCycle(burnable);
7716
8541
  if (cycle) {
7717
8542
  const path = cycle.join(" \u2192 ");
@@ -7722,7 +8547,8 @@ async function burnRun(ctx, deps) {
7722
8547
  });
7723
8548
  return { status: "failed", summary: `dependency cycle: ${path}` };
7724
8549
  }
7725
- const { done, digests } = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
8550
+ const { done, admitted, digests } = await burnTickets(ctx, tickets2, gateTicketAuth(deps), deps.concurrency);
8551
+ const total = burnable.length + admitted;
7726
8552
  const summary = cancelled > 0 ? `${done}/${total} tickets done (${cancelled} cancelled)` : `${done}/${total} tickets done`;
7727
8553
  ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total, cancelled } });
7728
8554
  const digest = composeRunDigest(digests);
@@ -7735,7 +8561,7 @@ function resolverTemplatePath() {
7735
8561
  return burnerAssetPath("resolve-conflict.md");
7736
8562
  }
7737
8563
  function burnerAssetPath(file) {
7738
- const here = dirname6(fileURLToPath5(import.meta.url));
8564
+ const here = dirname6(fileURLToPath6(import.meta.url));
7739
8565
  return join14(resolveSkillsRoot(here), "burner", file);
7740
8566
  }
7741
8567
  function listFeatureDocs(docsDir, prefix = "") {
@@ -7759,11 +8585,11 @@ function readDocsDigest(projectId, slug) {
7759
8585
  ...missing ? { missing } : {}
7760
8586
  });
7761
8587
  const worktree = worktreeDir(projectId, slug);
7762
- if (!existsSync15(worktree)) {
8588
+ if (!existsSync16(worktree)) {
7763
8589
  return done("_No talk worktree on disk \u2014 docs digest skipped._", "no-worktree");
7764
8590
  }
7765
8591
  const docsDir = join14(worktree, ...docsRel.split("/"));
7766
- if (!existsSync15(docsDir)) {
8592
+ if (!existsSync16(docsDir)) {
7767
8593
  return done("_No docs/features dir in the talk worktree \u2014 docs digest skipped._", "no-docs-dir");
7768
8594
  }
7769
8595
  const names = listFeatureDocs(docsDir).sort((a, b) => agentDigestDocOrder(a) - agentDigestDocOrder(b) || a.localeCompare(b));
@@ -7826,7 +8652,7 @@ function readAgentFile(dirs, file) {
7826
8652
  if (!dir)
7827
8653
  continue;
7828
8654
  const p = join14(dir, file);
7829
- if (existsSync15(p)) {
8655
+ if (existsSync16(p)) {
7830
8656
  try {
7831
8657
  return readFileSync9(p, "utf8");
7832
8658
  } catch {}
@@ -7896,8 +8722,8 @@ function buildAgentExtraArgs(runtime, options) {
7896
8722
  function quoteArg(value) {
7897
8723
  return `"${value}"`;
7898
8724
  }
7899
- function selectSandbox(config2, mounts = []) {
7900
- const imageOpts = buildSandboxOptions(config2, mounts);
8725
+ function selectSandbox(config2, mounts = [], env = {}) {
8726
+ const imageOpts = buildSandboxOptions(config2, mounts, env);
7901
8727
  switch (config2.sandbox) {
7902
8728
  case "docker":
7903
8729
  return docker(imageOpts);
@@ -7909,17 +8735,46 @@ function selectSandbox(config2, mounts = []) {
7909
8735
  throw new Error(`no sandbox provider for sandbox "${config2.sandbox}" \u2014 refusing to run the agent unsandboxed`);
7910
8736
  }
7911
8737
  }
7912
- function buildSandboxOptions(config2, mounts = []) {
8738
+ function buildSandboxOptions(config2, mounts = [], env = {}) {
7913
8739
  return {
7914
8740
  imageName: resolveSandboxImage(config2),
7915
8741
  ...mounts.length > 0 ? { mounts } : {},
7916
- ...config2.burnCpus !== undefined ? { cpus: config2.burnCpus } : {}
8742
+ ...config2.burnCpus !== undefined ? { cpus: config2.burnCpus } : {},
8743
+ ...Object.keys(env).length > 0 ? { env } : {}
7917
8744
  };
7918
8745
  }
7919
- async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget, blocks, runCtx) {
8746
+ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget, ensureCacheVolume, blocks, runCtx) {
8747
+ const allocator = resolveBurnCacheMode(config2) === "volume" ? getBurnSlotAllocator(config2.burnConcurrency) : undefined;
8748
+ return withBurnCacheSlot(allocator, async (slot) => {
8749
+ if (slot !== undefined)
8750
+ await ensureCacheVolume();
8751
+ return burnTicket(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget, blocks, runCtx, slot);
8752
+ });
8753
+ }
8754
+ async function withBurnCacheSlot(allocator, body) {
8755
+ if (!allocator)
8756
+ return body(undefined);
8757
+ let slot;
8758
+ try {
8759
+ slot = allocator.claim();
8760
+ } catch (err) {
8761
+ if (!(err instanceof BurnSlotsExhaustedError))
8762
+ throw err;
8763
+ }
8764
+ if (slot === undefined)
8765
+ return body(undefined);
8766
+ try {
8767
+ return await body(slot);
8768
+ } finally {
8769
+ allocator.release(slot);
8770
+ }
8771
+ }
8772
+ async function burnTicket(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget, blocks, runCtx, slot) {
7920
8773
  const { project, feature } = ctx;
7921
- const workspaceMode = resolveBurnWorkspaceMode(config2);
7922
- if (workspaceMode === "isolated")
8774
+ const startedAt = Date.now();
8775
+ const workspaceMode = resolveBurnWorkspaceMode(slot === undefined ? { ...config2, burnCache: "off" } : config2);
8776
+ const agentRepoPath = slot !== undefined ? slotRepoPath(slot) : ISOLATED_REPO_PATH;
8777
+ if (workspaceMode !== "mounted")
7923
8778
  await ensureIsolatedPushTarget();
7924
8779
  const attachments = attachmentSources(ticket.context);
7925
8780
  const attachmentCommands = buildAttachmentCopyCommands(attachments);
@@ -7935,7 +8790,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
7935
8790
  const ticketJson = buildTicketJson(ticket);
7936
8791
  const featureBrief = buildFeatureBrief(feature);
7937
8792
  const docsDigest = blocks.docsDigest;
7938
- const workspaceNotes = buildWorkspaceNotes(workspaceMode);
8793
+ const workspaceNotes = buildWorkspaceNotes(workspaceMode, agentRepoPath);
7939
8794
  const guardInstalled = config2.burnGuard && config2.sandbox !== "noSandbox";
7940
8795
  const prepared = resolvePreparedSettings(config2, project);
7941
8796
  const verifyNotes = buildVerifyNotes(prepared);
@@ -7948,30 +8803,56 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
7948
8803
  DRIVE_NOTES: blocks.driveNotes,
7949
8804
  GUARD_NOTES: buildGuardNotes(guardInstalled),
7950
8805
  TICKET_JSON: ticketJson,
7951
- BLOCKERS: buildBlockersBlock(ticket.blockedBy, runCtx.digests)
8806
+ BLOCKERS: buildBlockersBlock(ticket.blockedBy, runCtx.digests),
8807
+ FIX_NOTES: buildFixNotes(ticket)
7952
8808
  });
7953
8809
  const toolchain = readRepoToolchain(project.repoPath);
7954
8810
  const pm = detectPackageManager(toolchain);
7955
8811
  const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
7956
- const mounts = [];
7957
- if (config2.sandbox !== "noSandbox" && pm) {
7958
- const mount = cacheMountFor(pm, burnCacheDir(pm));
7959
- if (mount) {
8812
+ const cache = buildBurnCacheMounts(slot, project.id, config2.sandbox, pm);
8813
+ const sandboxEnv = cache.env;
8814
+ const mounts = [...cache.mounts];
8815
+ for (const mount of cache.mounts) {
8816
+ if ("hostPath" in mount)
7960
8817
  mkdirSync9(mount.hostPath, { recursive: true });
7961
- mounts.push(mount);
7962
- }
7963
8818
  }
8819
+ const codexAuthMount = codexAuthMountFor(model.runtime, config2.sandbox);
8820
+ if (codexAuthMount)
8821
+ mounts.push(codexAuthMount);
7964
8822
  const guardInstall = guardInstalled ? buildGuardInstallCommand(model.runtime) : undefined;
7965
- const withGuard = (setup) => guardInstall === undefined ? setup : setup ? `${guardInstall} && ${setup}` : guardInstall;
8823
+ const withPrelude = (setup) => chainSetupCommands(codexAuthMount ? buildCodexAuthCopyCommand() : undefined, guardInstall, setup);
7966
8824
  const agentOptions = guardInstalled ? { bypassHookTrust: true } : {};
8825
+ const slotStamp = buildSlotStamp(resolveSandboxImage(config2), toolchain.packageManagerField);
8826
+ const setupHookFor = (branch) => withPrelude(slot !== undefined ? buildSlotSetupCommand(slot, branch, setupCommand, pm, slotStamp) : workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, setupCommand, pm) : setupCommand);
7967
8827
  mkdirSync9(logsDir(), { recursive: true });
7968
8828
  const logFilePath = join14(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
7969
8829
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
7970
8830
  beginTranscript(ticket.id);
7971
8831
  const timer = createToolTimer();
8832
+ const setupRun = { worktree: undefined, iteration: null };
8833
+ const consumeSetupMarker = (iteration) => {
8834
+ if (slot === undefined || !setupRun.worktree || setupRun.iteration === iteration)
8835
+ return;
8836
+ setupRun.iteration = iteration;
8837
+ const marker = takeSetupMarker(setupRun.worktree);
8838
+ if (!marker)
8839
+ return;
8840
+ ctx.emitEvent({
8841
+ type: "burn.setup",
8842
+ message: `ticket ${ticket.seq}: cache slot ${slot} ${marker.cold ? "cold" : "warm"} \u2014 synced in ${fmtSeconds(marker.syncMs)}, installed in ${fmtSeconds(marker.installMs)}`,
8843
+ ticketId: ticket.id,
8844
+ data: { slot, cold: marker.cold, syncMs: marker.syncMs, installMs: marker.installMs }
8845
+ });
8846
+ };
8847
+ const beginSetupSpan = (branch) => {
8848
+ setupRun.worktree = burnWorktreePath(project.repoPath, branch);
8849
+ setupRun.iteration = null;
8850
+ timer.beginSetup();
8851
+ };
7972
8852
  const onStreamEvent = (event) => {
7973
8853
  throttle.onEvent(event);
7974
8854
  timer.onEvent(event);
8855
+ consumeSetupMarker(event.iteration);
7975
8856
  if (event.type === "text") {
7976
8857
  appendTranscript(ticket.id, { kind: "text", text: event.message });
7977
8858
  } else if (event.type === "toolCall") {
@@ -8007,6 +8888,12 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8007
8888
  const preserveChain = (branch) => {
8008
8889
  ctx.updateTicket(ticket.id, { attemptBranch: branch });
8009
8890
  };
8891
+ const discards = new Map;
8892
+ const discardWorktree = (branch) => {
8893
+ const pending = discards.get(branch) ?? cleanupBurnWorktree(project.repoPath, branch);
8894
+ discards.set(branch, pending);
8895
+ return pending;
8896
+ };
8010
8897
  const runResolver = async (input) => {
8011
8898
  const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
8012
8899
  const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
@@ -8022,7 +8909,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8022
8909
  OTHER_SIDE: buildOtherSideBlock(otherSide),
8023
8910
  MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
8024
8911
  });
8025
- const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand);
8912
+ const hookCommand = setupHookFor(resolveBranch);
8026
8913
  appendTranscript(ticket.id, {
8027
8914
  kind: "text",
8028
8915
  text: `
@@ -8035,9 +8922,10 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8035
8922
  };
8036
8923
  let result;
8037
8924
  try {
8925
+ beginSetupSpan(resolveBranch);
8038
8926
  result = await run2({
8039
8927
  agent: buildBurnAgent(config2, token, model, agentOptions),
8040
- sandbox: selectSandbox(config2, mounts),
8928
+ sandbox: selectSandbox(config2, mounts, sandboxEnv),
8041
8929
  cwd: project.repoPath,
8042
8930
  prompt,
8043
8931
  branchStrategy: { type: "branch", branch: resolveBranch, baseBranch: input.branch },
@@ -8054,6 +8942,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8054
8942
  logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
8055
8943
  });
8056
8944
  } catch (err) {
8945
+ await discardWorktree(resolveBranch);
8057
8946
  if (ctx.signal.aborted)
8058
8947
  throw err;
8059
8948
  const tip = await bestTip();
@@ -8065,9 +8954,9 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8065
8954
  error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
8066
8955
  };
8067
8956
  }
8068
- await cleanupBurnWorktree(project.repoPath, resolveBranch);
8069
8957
  }
8070
8958
  const blocked = readAgentFile([result?.preservedWorktreePath, project.repoPath], "BLOCKED.md");
8959
+ await discardWorktree(resolveBranch);
8071
8960
  if (blocked !== undefined && blocked.trim().length > 0) {
8072
8961
  return {
8073
8962
  ok: false,
@@ -8157,17 +9046,18 @@ ${blocked.trim()}`
8157
9046
  let tempBranch = "";
8158
9047
  for (let attempt = 1;; attempt++) {
8159
9048
  tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
8160
- const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand);
9049
+ const hookCommand = setupHookFor(tempBranch);
8161
9050
  if (hookCommand && attempt === 1) {
8162
9051
  ctx.emitEvent({
8163
9052
  type: "burn.setup",
8164
- message: workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
8165
- ticketId: ticket.id
9053
+ message: slot !== undefined ? `preparing cache slot ${slot}${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
9054
+ ticketId: ticket.id,
9055
+ ...slot !== undefined ? { data: { slot } } : {}
8166
9056
  });
8167
9057
  }
8168
9058
  const runOptions = {
8169
9059
  agent: buildBurnAgent(config2, token, model, agentOptions),
8170
- sandbox: selectSandbox(config2, mounts),
9060
+ sandbox: selectSandbox(config2, mounts, sandboxEnv),
8171
9061
  cwd: project.repoPath,
8172
9062
  prompt: retryNotes ? `${basePrompt}
8173
9063
 
@@ -8192,14 +9082,17 @@ ${retryNotes}` : basePrompt,
8192
9082
  };
8193
9083
  try {
8194
9084
  await excludeAttachments();
9085
+ beginSetupSpan(tempBranch);
8195
9086
  result = await run2(runOptions);
8196
9087
  await clearAttachmentsFor(tempBranch);
8197
9088
  break;
8198
9089
  } catch (err) {
8199
9090
  await clearAttachmentsFor(tempBranch);
9091
+ await discardWorktree(tempBranch);
8200
9092
  if (ctx.signal.aborted)
8201
9093
  throw err;
8202
9094
  const msg = err instanceof Error ? err.message : String(err);
9095
+ const missingBinary = missingAgentBinaryMessage(err, model.runtime, resolveSandboxImage(config2));
8203
9096
  const salvaged = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
8204
9097
  if (salvaged.length > 0)
8205
9098
  baseBranch = tempBranch;
@@ -8216,7 +9109,7 @@ ${retryNotes}` : basePrompt,
8216
9109
  };
8217
9110
  }
8218
9111
  if (isWorktreeTeardownError(err)) {
8219
- const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
9112
+ const removed = await discardWorktree(tempBranch);
8220
9113
  if (salvaged.length > 0) {
8221
9114
  ctx.emitEvent({
8222
9115
  type: "burn.worktree.teardown-failed",
@@ -8241,6 +9134,11 @@ ${retryNotes}` : basePrompt,
8241
9134
  }
8242
9135
  };
8243
9136
  }
9137
+ if (missingBinary) {
9138
+ if (salvaged.length > 0)
9139
+ preserveChain(tempBranch);
9140
+ return { status: "failed", error: missingBinary };
9141
+ }
8244
9142
  if (classifyTicketRunError(err, model.runtime) === "retryable" && attempt < maxAttempts) {
8245
9143
  const headline = errorHeadline(msg);
8246
9144
  retryNotes = buildRetryNotes({ error: headline, commitCount: salvaged.length });
@@ -8282,6 +9180,7 @@ ${retryNotes}` : basePrompt,
8282
9180
  const agentFileDirs = [result.preservedWorktreePath, project.repoPath];
8283
9181
  const blocked = readAgentFile(agentFileDirs, "BLOCKED.md");
8284
9182
  harvestedDigest = harvestDigest(agentFileDirs);
9183
+ await discardWorktree(tempBranch);
8285
9184
  const chain = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
8286
9185
  let outcome;
8287
9186
  if (result.commits.length === 0 && blocked !== undefined && blocked.trim().length > 0) {
@@ -8302,27 +9201,43 @@ ${blocked.trim()}` };
8302
9201
  releaseTicketAbort(ticket.id);
8303
9202
  throttle.flush();
8304
9203
  endTranscript(ticket.id);
8305
- const timing = timer.summary();
8306
- if (timing.calls > 0) {
8307
- ctx.emitEvent({
8308
- type: "ticket.timing",
8309
- message: `ticket ${ticket.seq} spent ${formatTimingSummary(timing)}`,
8310
- ticketId: ticket.id,
8311
- data: timing
8312
- });
8313
- }
9204
+ emitTicketTiming(ctx, ticket, buildTicketTiming(timer.summary(), startedAt, Date.now()));
8314
9205
  }
8315
9206
  }
8316
9207
  function resolveTicketModel(config2, project, runOverride, ticket) {
8317
- return resolveModelEntry("implement", config2, project, ticket.model ?? runOverride);
9208
+ const step = isReviewTicket(ticket) ? "review" : "implement";
9209
+ return resolveModelEntry(step, config2, project, ticket.model ?? runOverride);
8318
9210
  }
8319
9211
  function resolveBurnDeps(ctx) {
8320
9212
  const config2 = loadConfig();
8321
9213
  const model = resolveModelEntry("implement", config2, ctx.project, ctx.modelOverride);
8322
9214
  const token = readTokenFromEnvFile(envPath(), model.runtime);
9215
+ const exec = createSystemExec({ cwd: ctx.project.repoPath });
9216
+ const imageProbeCache = new Map;
9217
+ const cachedExec = (command, args) => {
9218
+ const key = `${resolveSandboxImage(config2)}\x00${model.runtime}`;
9219
+ let result = imageProbeCache.get(key);
9220
+ if (!result) {
9221
+ result = exec(command, args);
9222
+ imageProbeCache.set(key, result);
9223
+ }
9224
+ return result;
9225
+ };
8323
9226
  const land = createSerialQueue();
8324
9227
  let pushTargetReady;
8325
9228
  const ensureIsolatedPushTarget = () => pushTargetReady ??= allowPushToCheckedOutBranches(ctx.project.repoPath);
9229
+ let cacheVolumeReady;
9230
+ const ensureCacheVolume = () => {
9231
+ if (resolveBurnCacheMode(config2) !== "volume")
9232
+ return Promise.resolve();
9233
+ const engine = config2.sandbox === "podman" ? "podman" : "docker";
9234
+ return cacheVolumeReady ??= ensureBurnCacheVolume({
9235
+ engine,
9236
+ imageName: resolveSandboxImage(config2),
9237
+ projectId: ctx.project.id,
9238
+ exec
9239
+ });
9240
+ };
8326
9241
  const docs2 = readDocsDigest(ctx.project.id, ctx.feature.slug);
8327
9242
  emitDocsDigestEvent(ctx, docs2);
8328
9243
  const blocks = {
@@ -8336,28 +9251,39 @@ function resolveBurnDeps(ctx) {
8336
9251
  dbResetCommand: ctx.project.dbResetCommand
8337
9252
  })
8338
9253
  };
9254
+ const ticketCredentials = (ticket) => {
9255
+ const ticketModel = resolveTicketModel(config2, ctx.project, ctx.modelOverride, ticket);
9256
+ return {
9257
+ model: ticketModel,
9258
+ token: ticketModel.runtime === model.runtime ? token : readTokenFromEnvFile(envPath(), ticketModel.runtime)
9259
+ };
9260
+ };
8339
9261
  return {
8340
9262
  config: config2,
8341
9263
  runtime: model.runtime,
8342
- hasAuthToken: token !== undefined,
9264
+ hasAuthToken: burnAuthReady(model.runtime, token),
9265
+ exec: cachedExec,
9266
+ ticketAuthMissing: (ticket) => {
9267
+ const { model: ticketModel, token: ticketToken } = ticketCredentials(ticket);
9268
+ return burnAuthReady(ticketModel.runtime, ticketToken) ? undefined : ticketModel.runtime;
9269
+ },
8343
9270
  concurrency: config2.burnConcurrency,
8344
9271
  executeTicketRun: (c, ticket, run3) => {
8345
- const ticketModel = resolveTicketModel(config2, ctx.project, ctx.modelOverride, ticket);
8346
- const ticketToken = ticketModel.runtime === model.runtime ? token : readTokenFromEnvFile(envPath(), ticketModel.runtime);
9272
+ const { model: ticketModel, token: ticketToken } = ticketCredentials(ticket);
8347
9273
  return isReviewTicket(ticket) ? executeReviewTicket(c, ticket, {
8348
9274
  config: config2,
8349
9275
  token: ticketToken,
8350
9276
  model: ticketModel,
8351
9277
  docsDigest: docs2.text,
8352
9278
  lapDigests: run3.digests
8353
- }) : realExecuteTicketRun(c, ticket, config2, ticketToken, ticketModel, land, ensureIsolatedPushTarget, blocks, run3);
9279
+ }) : realExecuteTicketRun(c, ticket, config2, ticketToken, ticketModel, land, ensureIsolatedPushTarget, ensureCacheVolume, blocks, run3);
8354
9280
  }
8355
9281
  };
8356
9282
  }
8357
9283
  function readTokenFromEnvFile(path, runtime) {
8358
9284
  const key = RUNTIME_AUTH_KEY[runtime];
8359
9285
  let fromFile;
8360
- if (existsSync15(path)) {
9286
+ if (existsSync16(path)) {
8361
9287
  try {
8362
9288
  fromFile = parseEnvFile2(readFileSync9(path, "utf8"))[key];
8363
9289
  } catch {
@@ -8367,18 +9293,25 @@ function readTokenFromEnvFile(path, runtime) {
8367
9293
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env[key];
8368
9294
  return token && token.length > 0 ? token : undefined;
8369
9295
  }
8370
- var AUTH_MISSING_EVENT = "auth.missing", DROPPED_MAP_SECTIONS, AGENT_CONVENTIONS_FILE = "CLAUDE.md", 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, RUNTIME_FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, RUNTIME_RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
9296
+ var AUTH_MISSING_EVENT = "auth.missing", IMAGE_RUNTIME_MISSING_EVENT = "burn.image_runtime_missing", RUNTIME_BINARY, DROPPED_MAP_SECTIONS, AGENT_CONVENTIONS_FILE = "CLAUDE.md", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, CODEX_HOST_MOUNT_PATH = "/mnt/host-codex", SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", SETUP_MARKER_FILE = ".runcastle-setup", SETUP_MARKER = "RUNCASTLE_SETUP", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RUNTIME_FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, RUNTIME_RETRYABLE_ERROR_PATTERNS, AGENT_BINARY, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
8371
9297
  var init_ticket_burner = __esm(() => {
8372
9298
  init_src();
8373
9299
  init_config_load();
8374
9300
  init_paths();
8375
9301
  init_skills_root();
9302
+ init_codex_auth();
9303
+ init_system_exec();
8376
9304
  init_knowledge();
8377
9305
  init_setup();
8378
9306
  init_agent_stream();
8379
9307
  init_git();
8380
9308
  init_burn_guard();
9309
+ init_burn_cache();
8381
9310
  init_review_ticket();
9311
+ RUNTIME_BINARY = {
9312
+ "claude-code": "claude",
9313
+ codex: "codex"
9314
+ };
8382
9315
  DROPPED_MAP_SECTIONS = ["Not yet specified", "Out of scope"];
8383
9316
  PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
8384
9317
  PM_CACHE_SANDBOX_PATHS = {
@@ -8431,7 +9364,10 @@ var init_ticket_burner = __esm(() => {
8431
9364
  /\b401\b|\b403\b/,
8432
9365
  /insufficient_quota|exceeded your current quota/i,
8433
9366
  /model_not_found|does not exist or you do not have access/i,
8434
- /CODEX_API_KEY/
9367
+ /CODEX_API_KEY/,
9368
+ /not logged in/i,
9369
+ /\bauth (failed|required)\b/i,
9370
+ /refresh token/i
8435
9371
  ]
8436
9372
  };
8437
9373
  RETRYABLE_ERROR_PATTERNS = [
@@ -8448,6 +9384,10 @@ var init_ticket_burner = __esm(() => {
8448
9384
  "claude-code": [],
8449
9385
  codex: [/rate_limit_exceeded/i, /\b429\b/, /server_error/i, /stream (disconnected|interrupted)/i]
8450
9386
  };
9387
+ AGENT_BINARY = {
9388
+ "claude-code": "claude",
9389
+ codex: "codex"
9390
+ };
8451
9391
  activeTicketAborts = new Map;
8452
9392
  SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
8453
9393
  ticketBurner = {
@@ -8459,7 +9399,7 @@ var init_ticket_burner = __esm(() => {
8459
9399
  // src/workflows/research.ts
8460
9400
  import { mkdirSync as mkdirSync10, readFileSync as readFileSync10 } from "fs";
8461
9401
  import { dirname as dirname7, join as join15 } from "path";
8462
- import { fileURLToPath as fileURLToPath6 } from "url";
9402
+ import { fileURLToPath as fileURLToPath7 } from "url";
8463
9403
  import { run as run3 } from "@ai-hero/sandcastle";
8464
9404
  function waypointSlug(waypoint) {
8465
9405
  const base = waypoint.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -8527,7 +9467,7 @@ function interpretResearchResult(result, docRelPath) {
8527
9467
  return { status: "failed", error: "agent produced no commits \u2014 no research summary landed" };
8528
9468
  }
8529
9469
  function researchTemplatePath() {
8530
- const here = dirname7(fileURLToPath6(import.meta.url));
9470
+ const here = dirname7(fileURLToPath7(import.meta.url));
8531
9471
  return join15(resolveSkillsRoot(here), "burner", "research-waypoint.md");
8532
9472
  }
8533
9473
  async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
@@ -8544,14 +9484,16 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
8544
9484
  mkdirSync10(logsDir(), { recursive: true });
8545
9485
  const logFilePath = join15(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
8546
9486
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
9487
+ const codexAuthMount = codexAuthMountFor(model.runtime, config2.sandbox);
8547
9488
  const runOptions = {
8548
9489
  agent: buildBurnAgent(config2, token, model),
8549
- sandbox: selectSandbox(config2),
9490
+ sandbox: selectSandbox(config2, codexAuthMount ? [codexAuthMount] : []),
8550
9491
  cwd: project.repoPath,
8551
9492
  prompt,
8552
9493
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
8553
9494
  signal: ctx.signal,
8554
9495
  name: `research-${waypoint.seq}`,
9496
+ ...codexAuthMount ? { hooks: { sandbox: { onSandboxReady: [{ command: buildCodexAuthCopyCommand() }] } } } : {},
8555
9497
  logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
8556
9498
  };
8557
9499
  let result;
@@ -8599,7 +9541,7 @@ function resolveResearchDeps(ctx) {
8599
9541
  return {
8600
9542
  config: config2,
8601
9543
  runtime: model.runtime,
8602
- hasAuthToken: token !== undefined,
9544
+ hasAuthToken: burnAuthReady(model.runtime, token),
8603
9545
  executeResearchRun: (c, waypoint) => realExecuteResearchRun(c, waypoint, config2, token, model)
8604
9546
  };
8605
9547
  }
@@ -8634,7 +9576,7 @@ var init_registry2 = __esm(() => {
8634
9576
  });
8635
9577
 
8636
9578
  // src/workflows/runner.ts
8637
- import { eq as eq9 } from "drizzle-orm";
9579
+ import { eq as eq10 } from "drizzle-orm";
8638
9580
  function workflowClaimsFeatureBranch(workflowId) {
8639
9581
  return BRANCH_CLAIMING.has(workflowId);
8640
9582
  }
@@ -8663,7 +9605,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
8663
9605
  claim(ctx, opts.claimWaypointId, runId);
8664
9606
  } catch (e) {
8665
9607
  const summary = e instanceof Error ? e.message : "claim failed";
8666
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq9(runs.id, runId)).run();
9608
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq10(runs.id, runId)).run();
8667
9609
  throw e;
8668
9610
  }
8669
9611
  }
@@ -8694,6 +9636,10 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
8694
9636
  updateTicket: (id, patch) => {
8695
9637
  updateTicket(ctx, id, patch);
8696
9638
  },
9639
+ listTickets: () => listByFeature2(ctx, featureId),
9640
+ updateFinding: (findingId, progress, reason) => {
9641
+ markFixProgress(ctx, findingId, progress, reason);
9642
+ },
8697
9643
  input: opts.input,
8698
9644
  modelOverride: opts.modelOverride,
8699
9645
  resolveWaypoint: (id, disposition, summary) => {
@@ -8731,7 +9677,7 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
8731
9677
  } finally {
8732
9678
  controllers.delete(runId);
8733
9679
  }
8734
- ctx.db.update(runs).set({ status, endedAt: Date.now(), summary, ...digest ? { digest } : {} }).where(eq9(runs.id, runId)).run();
9680
+ ctx.db.update(runs).set({ status, endedAt: Date.now(), summary, ...digest ? { digest } : {} }).where(eq10(runs.id, runId)).run();
8735
9681
  releaseForSession(ctx, runId);
8736
9682
  if (workflowClaimsFeatureBranch(workflow2)) {
8737
9683
  sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
@@ -8771,6 +9717,7 @@ var init_runner = __esm(() => {
8771
9717
  init_gates();
8772
9718
  init_git();
8773
9719
  init_repo();
9720
+ init_review_findings();
8774
9721
  init_tickets();
8775
9722
  init_waypoints();
8776
9723
  init_registry2();
@@ -8779,14 +9726,13 @@ var init_runner = __esm(() => {
8779
9726
  });
8780
9727
 
8781
9728
  // src/services/features.ts
8782
- import { desc as desc5, eq as eq10 } from "drizzle-orm";
9729
+ import { desc as desc5, eq as eq11 } from "drizzle-orm";
8783
9730
  import { rmSync as rmSync4 } from "fs";
8784
9731
  async function createFeature(ctx, input) {
8785
9732
  const project = requireProjectById(ctx, input.projectId);
8786
9733
  const slug = uniqueSlug(ctx, project.id, input.title);
8787
9734
  const branch = `feature/${slug}`;
8788
- const requestedBase = input.baseBranch?.trim() || project.mainBranch;
8789
- const cut = input.draft ? null : await ensureFeatureBranch(project, slug, requestedBase);
9735
+ const cut = input.draft ? null : await ensureFeatureBranch(project, slug, await requestedBase(project, input.baseBranch));
8790
9736
  const row = {
8791
9737
  id: newId("feat"),
8792
9738
  projectId: project.id,
@@ -8817,12 +9763,7 @@ async function createFeature(ctx, input) {
8817
9763
  });
8818
9764
  if (!cut)
8819
9765
  return feature;
8820
- scaffoldDocs(ctx, feature, { brief: input.brief });
8821
- if (cut.branchReady) {
8822
- try {
8823
- await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
8824
- } catch {}
8825
- }
9766
+ await scaffoldDocsOnFeatureBranch(ctx, project, feature, { brief: input.brief });
8826
9767
  return feature;
8827
9768
  }
8828
9769
  function requireNotDraft(feature) {
@@ -8836,21 +9777,16 @@ async function startDraft(ctx, featureId, opts = {}) {
8836
9777
  throw new GateError(`feature ${feature.slug} is not a draft \u2014 it has already been started`);
8837
9778
  }
8838
9779
  const project = projectForFeature(ctx, feature);
8839
- const requestedBase = opts.baseBranch?.trim() || project.mainBranch;
8840
- const { branchReady, baseBranch } = await ensureFeatureBranch(project, feature.slug, requestedBase);
8841
- ctx.db.update(features).set({ status: "active", baseBranch }).where(eq10(features.id, featureId)).run();
9780
+ const base = await requestedBase(project, opts.baseBranch);
9781
+ const { branchReady, baseBranch } = await ensureFeatureBranch(project, feature.slug, base);
9782
+ ctx.db.update(features).set({ status: "active", baseBranch }).where(eq11(features.id, featureId)).run();
8842
9783
  const started = { ...feature, status: "active", baseBranch };
8843
9784
  emit(ctx, featureId, {
8844
9785
  type: "feature.started",
8845
9786
  message: branchReady ? `feature ${feature.slug} started (${feature.branch} \u2190 ${baseBranch})` : `feature ${feature.slug} started (branch pending)`,
8846
9787
  data: { branch: feature.branch, baseBranch, branchReady }
8847
9788
  });
8848
- scaffoldDocs(ctx, started, { brief: feature.brief });
8849
- if (branchReady) {
8850
- try {
8851
- await commitDocs(project.repoPath, `runcastle: scaffold ${feature.slug} docs`);
8852
- } catch {}
8853
- }
9789
+ await scaffoldDocsOnFeatureBranch(ctx, project, started, { brief: feature.brief });
8854
9790
  return started;
8855
9791
  }
8856
9792
  function quickTicketTitle(prose) {
@@ -8876,10 +9812,10 @@ ${body}
8876
9812
  function quickReviewTicket(proses) {
8877
9813
  return {
8878
9814
  title: "Review the integrated change",
8879
- goal: "Review the integrated feature branch and report what you find. Code-review the branch's " + "diff against its base \u2014 always, whatever this change turned out to be \u2014 and additionally " + "drive the app when the diff touches something a human can operate. Write one note per " + "finding; finding bugs is a successful review, and the notes are the deliverable.",
8880
- context: "This feature came through the quick-change door, so there was no grill session and there " + "is no spec.md or decisions.md to review it against: the whole statement of intent is the " + `${proses.length === 1 ? "sentence" : `${proses.length} sentences`} the human typed, which ` + "are the other tickets in this batch and are reproduced verbatim in brief.md. Nobody " + "prescribed a walkthrough for you either \u2014 judge from the diff whether there is anything " + "drivable, and if there is not, say so and let the code review stand alone. Your digest is " + "the lap's prose summary of what landed; the review page leads with it.",
9815
+ goal: "Review the integrated feature branch and report what you find, in exactly one mode: " + "drive the app in a browser against the criteria below when this change touched something " + "a human can operate and a drive is available, and otherwise run the verify gates and " + "code-review the branch's diff against its base. Write one note per finding; finding bugs " + "is a successful review, and the notes are the deliverable.",
9816
+ context: "This feature came through the quick-change door, so there was no grill session and there " + "is no spec.md or decisions.md to review it against: the whole statement of intent is the " + `${proses.length === 1 ? "sentence" : `${proses.length} sentences`} the human typed, which ` + "are the other tickets in this batch and are reproduced verbatim in brief.md. Nobody " + "prescribed a walkthrough for you either \u2014 judge from the diff whether there is anything " + "drivable, and if there is not, take the gates-and-diff mode. Your digest is " + "the lap's prose summary of what landed, and its first line names the mode you ran; the " + "review page leads with it.",
8881
9817
  acceptanceCriteria: [
8882
- "The branch's diff against its base is code-reviewed on both axes \u2014 the repo's own " + "standards, and the change against what was asked for.",
9818
+ "Reviewed in one mode \u2014 either walked in a browser, or put through the verify gates and " + "code-reviewed on both axes (the repo's own standards, and the change against what was " + "asked for).",
8883
9819
  ...proses.map((prose) => `Landed and does what it says: ${prose}`)
8884
9820
  ],
8885
9821
  seams: [],
@@ -8897,8 +9833,8 @@ async function quickChange(ctx, input) {
8897
9833
  throw new InvalidInputError("a quick change needs a sentence describing the change");
8898
9834
  const slug = uniqueSlug(ctx, project.id, title);
8899
9835
  const branch = `feature/${slug}`;
8900
- const requestedBase = input.baseBranch?.trim() || project.mainBranch;
8901
- const { branchReady, baseBranch } = await ensureFeatureBranch(project, slug, requestedBase);
9836
+ const base = await requestedBase(project, input.baseBranch);
9837
+ const { branchReady, baseBranch } = await ensureFeatureBranch(project, slug, base);
8902
9838
  const inserted = ctx.db.insert(features).values({
8903
9839
  id: newId("feat"),
8904
9840
  projectId: project.id,
@@ -8920,7 +9856,7 @@ async function quickChange(ctx, input) {
8920
9856
  message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
8921
9857
  data: { slug, branch, baseBranch, branchReady }
8922
9858
  });
8923
- scaffoldDocs(ctx, feature, { brief: quickBrief(title, proses) });
9859
+ await scaffoldDocsOnFeatureBranch(ctx, project, feature, { brief: quickBrief(title, proses) });
8924
9860
  const stored = storeTickets(ctx, feature.id, [
8925
9861
  ...proses.map((prose) => ({
8926
9862
  title: quickTicketTitle(prose),
@@ -8940,24 +9876,42 @@ async function quickChange(ctx, input) {
8940
9876
  message: `quick change \u2014 born at implementation on lap 1 with ${tally} (${typed.map((t) => `#${t.seq}`).join(", ")}) plus a review ticket (#${review.seq}); no grill session, no spec.md`,
8941
9877
  data: { slug, ticketSeqs: stored.map((t) => t.seq), phase: "implementation" }
8942
9878
  });
8943
- if (branchReady) {
8944
- try {
8945
- await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
8946
- } catch {}
8947
- }
8948
9879
  return feature;
8949
9880
  }
8950
- async function ensureFeatureBranch(project, slug, requestedBase) {
9881
+ async function requestedBase(project, picked) {
9882
+ return picked?.trim() || await currentCheckoutBranch(project);
9883
+ }
9884
+ async function ensureFeatureBranch(project, slug, base) {
8951
9885
  try {
8952
- const baseBranch = await resolveBaseBranch(project, requestedBase);
9886
+ const baseBranch = await resolveBaseBranch(project, base);
8953
9887
  await createFeatureBranch(project, slug, baseBranch);
8954
9888
  return { branchReady: true, baseBranch };
8955
9889
  } catch (e) {
8956
9890
  if (isNotImplemented(e))
8957
- return { branchReady: false, baseBranch: requestedBase };
9891
+ return { branchReady: false, baseBranch: base };
8958
9892
  throw e;
8959
9893
  }
8960
9894
  }
9895
+ async function scaffoldDocsOnFeatureBranch(ctx, project, feature, opts) {
9896
+ let worktreePath;
9897
+ try {
9898
+ worktreePath = await ensureTalkWorktree(project, feature);
9899
+ } catch (e) {
9900
+ if (isNotImplemented(e)) {
9901
+ scaffoldDocs(ctx, feature, opts);
9902
+ return;
9903
+ }
9904
+ emit(ctx, feature.id, {
9905
+ type: "docs.scaffold_failed",
9906
+ message: `docs not scaffolded onto ${feature.branch}: ${e instanceof Error ? e.message : String(e)}`
9907
+ });
9908
+ return;
9909
+ }
9910
+ scaffoldDocs(ctx, feature, opts);
9911
+ try {
9912
+ await commitDocs(worktreePath, `runcastle: scaffold ${feature.slug} docs`);
9913
+ } catch {}
9914
+ }
8961
9915
  function getFeatureFull(ctx, id) {
8962
9916
  const feature = getFeatureRow(ctx, id);
8963
9917
  const waypoints2 = feature.mapped ? listByFeature(ctx, id) : [];
@@ -8974,7 +9928,7 @@ function getFeatureFull(ctx, id) {
8974
9928
  };
8975
9929
  }
8976
9930
  function list(ctx, projectId) {
8977
- const rows = ctx.db.select().from(features).where(eq10(features.projectId, projectId)).orderBy(desc5(features.createdAt)).all();
9931
+ const rows = ctx.db.select().from(features).where(eq11(features.projectId, projectId)).orderBy(desc5(features.createdAt)).all();
8978
9932
  const lastActivity = latestTsByFeature(ctx, projectId);
8979
9933
  return rows.map((row) => {
8980
9934
  const feature = rowToFeature(row);
@@ -9089,7 +10043,7 @@ function rethink(ctx, featureId) {
9089
10043
  throw new GateError(`${feature.slug} is being test-driven \u2014 stop the test drive first, it holds the feature branch`);
9090
10044
  }
9091
10045
  const lap = feature.lap + 1;
9092
- ctx.db.update(features).set({ lap }).where(eq10(features.id, featureId)).run();
10046
+ ctx.db.update(features).set({ lap }).where(eq11(features.id, featureId)).run();
9093
10047
  return setPhase(ctx, featureId, RETHINK_LOOP_BACK.to, "lap.started", `rethink \u2014 lap ${lap}`);
9094
10048
  }
9095
10049
  async function rethinkAndLaunch(ctx, featureId, launch) {
@@ -9098,7 +10052,7 @@ async function rethinkAndLaunch(ctx, featureId, launch) {
9098
10052
  try {
9099
10053
  return await launch(feature);
9100
10054
  } catch (e) {
9101
- ctx.db.update(features).set({ lap: before.lap }).where(eq10(features.id, featureId)).run();
10055
+ ctx.db.update(features).set({ lap: before.lap }).where(eq11(features.id, featureId)).run();
9102
10056
  setPhase(ctx, featureId, before.phase, "lap.aborted", `lap ${feature.lap} aborted \u2014 its terminal could not be opened (${errMsg2(e)}); back at ${before.phase} on lap ${before.lap}`);
9103
10057
  throw e;
9104
10058
  }
@@ -9177,7 +10131,7 @@ function escalateToMap(ctx, featureId, input) {
9177
10131
  return { ok: true, warning: `feature ${feature.slug} is already mapped \u2014 no changes made` };
9178
10132
  }
9179
10133
  const project = projectForFeature(ctx, feature);
9180
- ctx.db.update(features).set({ mapped: true }).where(eq10(features.id, featureId)).run();
10134
+ ctx.db.update(features).set({ mapped: true }).where(eq11(features.id, featureId)).run();
9181
10135
  scaffoldMapDoc(project, { ...feature, mapped: true }, input);
9182
10136
  emit(ctx, featureId, {
9183
10137
  type: "feature.escalated",
@@ -9195,7 +10149,7 @@ function archiveFeature(ctx, featureId) {
9195
10149
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
9196
10150
  if (live)
9197
10151
  endSession(ctx, live.id);
9198
- ctx.db.update(features).set({ status: "archived" }).where(eq10(features.id, featureId)).run();
10152
+ ctx.db.update(features).set({ status: "archived" }).where(eq11(features.id, featureId)).run();
9199
10153
  emit(ctx, featureId, {
9200
10154
  type: "feature.archived",
9201
10155
  message: `feature ${feature.slug} archived`,
@@ -9209,7 +10163,7 @@ function unarchiveFeature(ctx, featureId) {
9209
10163
  throw new GateError(`feature ${feature.slug} is not archived`);
9210
10164
  }
9211
10165
  const restored = feature.phase === "shipped" ? "shipped" : "active";
9212
- ctx.db.update(features).set({ status: restored }).where(eq10(features.id, featureId)).run();
10166
+ ctx.db.update(features).set({ status: restored }).where(eq11(features.id, featureId)).run();
9213
10167
  emit(ctx, featureId, {
9214
10168
  type: "feature.unarchived",
9215
10169
  message: `feature ${feature.slug} unarchived (${restored})`,
@@ -9249,13 +10203,13 @@ async function deleteFeature(ctx, featureId) {
9249
10203
  return { ok: true, slug: feature.slug };
9250
10204
  }
9251
10205
  function deleteFeatureRows(ctx, featureId) {
9252
- ctx.db.delete(tickets).where(eq10(tickets.featureId, featureId)).run();
9253
- ctx.db.delete(sessions).where(eq10(sessions.featureId, featureId)).run();
9254
- ctx.db.delete(runs).where(eq10(runs.featureId, featureId)).run();
9255
- ctx.db.delete(events).where(eq10(events.featureId, featureId)).run();
9256
- ctx.db.delete(gateOverrides).where(eq10(gateOverrides.featureId, featureId)).run();
9257
- ctx.db.delete(waypoints).where(eq10(waypoints.featureId, featureId)).run();
9258
- ctx.db.delete(features).where(eq10(features.id, featureId)).run();
10206
+ ctx.db.delete(tickets).where(eq11(tickets.featureId, featureId)).run();
10207
+ ctx.db.delete(sessions).where(eq11(sessions.featureId, featureId)).run();
10208
+ ctx.db.delete(runs).where(eq11(runs.featureId, featureId)).run();
10209
+ ctx.db.delete(events).where(eq11(events.featureId, featureId)).run();
10210
+ ctx.db.delete(gateOverrides).where(eq11(gateOverrides.featureId, featureId)).run();
10211
+ ctx.db.delete(waypoints).where(eq11(waypoints.featureId, featureId)).run();
10212
+ ctx.db.delete(features).where(eq11(features.id, featureId)).run();
9259
10213
  }
9260
10214
  function gateState(ctx, feature) {
9261
10215
  const gate = nextGate(feature);
@@ -9266,7 +10220,7 @@ function gateState(ctx, feature) {
9266
10220
  }
9267
10221
  function uniqueSlug(ctx, projectId, title) {
9268
10222
  const base = slugify(title);
9269
- const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq10(features.projectId, projectId)).all().map((r) => r.slug));
10223
+ const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq11(features.projectId, projectId)).all().map((r) => r.slug));
9270
10224
  if (!existing.has(base))
9271
10225
  return base;
9272
10226
  let n = 2;
@@ -9297,9 +10251,9 @@ var init_features = __esm(() => {
9297
10251
  });
9298
10252
 
9299
10253
  // src/services/test-notes.ts
9300
- import { existsSync as existsSync17, mkdirSync as mkdirSync11, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
10254
+ import { existsSync as existsSync18, mkdirSync as mkdirSync11, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
9301
10255
  import { dirname as dirname8 } from "path";
9302
- import { asc as asc4, eq as eq11 } from "drizzle-orm";
10256
+ import { asc as asc5, eq as eq12 } from "drizzle-orm";
9303
10257
  function rowToNote(row) {
9304
10258
  return TestNote.parse({
9305
10259
  id: row.id,
@@ -9310,13 +10264,13 @@ function rowToNote(row) {
9310
10264
  author: row.author,
9311
10265
  ticketId: row.ticketId ?? undefined,
9312
10266
  videoTimestamp: row.videoTimestamp ?? undefined,
9313
- screenshotUrl: existsSync17(annotationPath(row.id)) ? noteScreenshotUrl(row.id) : undefined,
10267
+ screenshotUrl: existsSync18(annotationPath(row.id)) ? noteScreenshotUrl(row.id) : undefined,
9314
10268
  createdAt: row.createdAt,
9315
10269
  updatedAt: row.updatedAt
9316
10270
  });
9317
10271
  }
9318
10272
  function getNote(ctx, id) {
9319
- const row = ctx.db.select().from(testNotes).where(eq11(testNotes.id, id)).get();
10273
+ const row = ctx.db.select().from(testNotes).where(eq12(testNotes.id, id)).get();
9320
10274
  if (!row)
9321
10275
  throw new NotFoundError(`test note ${id} not found`);
9322
10276
  return rowToNote(row);
@@ -9332,8 +10286,8 @@ function assertOpen(note, action) {
9332
10286
  throw new InvalidInputError(`cannot ${action} note ${note.id} \u2014 it is ${note.status}; only open notes can be changed`);
9333
10287
  }
9334
10288
  }
9335
- function listByFeature3(ctx, featureId) {
9336
- return ctx.db.select().from(testNotes).where(eq11(testNotes.featureId, featureId)).orderBy(asc4(testNotes.createdAt), asc4(testNotes.id)).all().map(rowToNote);
10289
+ function listByFeature4(ctx, featureId) {
10290
+ return ctx.db.select().from(testNotes).where(eq12(testNotes.featureId, featureId)).orderBy(asc5(testNotes.createdAt), asc5(testNotes.id)).all().map(rowToNote);
9337
10291
  }
9338
10292
  function addNote(ctx, featureId, text2, author = "human", videoTimestamp) {
9339
10293
  const body = cleanText(text2);
@@ -9364,7 +10318,7 @@ function editNote(ctx, noteId, text2) {
9364
10318
  const current2 = getNote(ctx, noteId);
9365
10319
  assertOpen(current2, "edit");
9366
10320
  const body = cleanText(text2);
9367
- ctx.db.update(testNotes).set({ text: body, updatedAt: Date.now() }).where(eq11(testNotes.id, noteId)).run();
10321
+ ctx.db.update(testNotes).set({ text: body, updatedAt: Date.now() }).where(eq12(testNotes.id, noteId)).run();
9368
10322
  emit(ctx, current2.featureId, {
9369
10323
  type: "note.edited",
9370
10324
  message: "note edited",
@@ -9376,7 +10330,7 @@ function editNote(ctx, noteId, text2) {
9376
10330
  function deleteNote(ctx, noteId) {
9377
10331
  const current2 = getNote(ctx, noteId);
9378
10332
  assertOpen(current2, "delete");
9379
- ctx.db.delete(testNotes).where(eq11(testNotes.id, noteId)).run();
10333
+ ctx.db.delete(testNotes).where(eq12(testNotes.id, noteId)).run();
9380
10334
  rmSync5(annotationPath(noteId), { force: true });
9381
10335
  emit(ctx, current2.featureId, {
9382
10336
  type: "note.deleted",
@@ -9403,7 +10357,7 @@ function toggleNote(ctx, noteId) {
9403
10357
  throw new InvalidInputError(`cannot toggle note ${noteId} \u2014 it is promoted, and promoted notes are frozen`);
9404
10358
  }
9405
10359
  const status = current2.status === "open" ? "done" : "open";
9406
- ctx.db.update(testNotes).set({ status, updatedAt: Date.now() }).where(eq11(testNotes.id, noteId)).run();
10360
+ ctx.db.update(testNotes).set({ status, updatedAt: Date.now() }).where(eq12(testNotes.id, noteId)).run();
9407
10361
  emit(ctx, current2.featureId, {
9408
10362
  type: "note.toggled",
9409
10363
  message: `note ${current2.status} \u2192 ${status}`,
@@ -9453,7 +10407,7 @@ function freezeAsTickets(ctx, noteIds) {
9453
10407
  const tickets2 = storeTickets(ctx, featureId, selected.map((note) => promotionTicket(feature, note)));
9454
10408
  const now = Date.now();
9455
10409
  selected.forEach((note, i) => {
9456
- ctx.db.update(testNotes).set({ status: "promoted", ticketId: tickets2[i].id, updatedAt: now }).where(eq11(testNotes.id, note.id)).run();
10410
+ ctx.db.update(testNotes).set({ status: "promoted", ticketId: tickets2[i].id, updatedAt: now }).where(eq12(testNotes.id, note.id)).run();
9457
10411
  });
9458
10412
  return { feature, notes: selected.map((n) => getNote(ctx, n.id)), tickets: tickets2 };
9459
10413
  }
@@ -9491,7 +10445,7 @@ function noteLine(ctx, note) {
9491
10445
  return `- [x] ${note.text}${ticket}${shot}`;
9492
10446
  }
9493
10447
  function renderTestNotes(ctx, feature) {
9494
- const notes = listByFeature3(ctx, feature.id);
10448
+ const notes = listByFeature4(ctx, feature.id);
9495
10449
  const laps = [...new Set(notes.map((n) => n.lap))].sort((a, b) => a - b);
9496
10450
  const lines = ["# Test notes"];
9497
10451
  for (const lap of laps) {
@@ -9518,7 +10472,7 @@ var init_test_notes = __esm(() => {
9518
10472
  });
9519
10473
 
9520
10474
  // src/mcp/server.ts
9521
- import { existsSync as existsSync18, readFileSync as readFileSync11 } from "fs";
10475
+ import { existsSync as existsSync19, readFileSync as readFileSync11 } from "fs";
9522
10476
  import { join as join16, relative as relative3, resolve as resolve9, sep as sep2 } from "path";
9523
10477
  import { StreamableHTTPTransport } from "@hono/mcp";
9524
10478
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -9817,6 +10771,23 @@ function toolAddTestNote(ctx, caller, input) {
9817
10771
  const identity = requireRunIdentity(ctx, caller, "add_test_note");
9818
10772
  return addNote(ctx, identity.featureId, input.text, "agent");
9819
10773
  }
10774
+ function toolReportFinding(ctx, caller, input) {
10775
+ const identity = requireRunIdentity(ctx, caller, "report_finding");
10776
+ const reviewTicket = listByFeature2(ctx, identity.featureId).find((ticket) => ticket.kind === "review" && ticket.status === "burning");
10777
+ if (!reviewTicket) {
10778
+ throw new GateError("report_finding requires a burning review ticket on this run\u2019s feature");
10779
+ }
10780
+ const result = reportFinding(ctx, {
10781
+ featureId: identity.featureId,
10782
+ reviewTicket,
10783
+ input
10784
+ });
10785
+ return {
10786
+ findingId: result.finding.id,
10787
+ fixTicketSeq: result.fixTicket?.seq ?? null,
10788
+ overCap: result.overCap
10789
+ };
10790
+ }
9820
10791
  async function toolRetryDrive(ctx, session) {
9821
10792
  if (session.kind !== "drive-fix") {
9822
10793
  throw new GateError(`retrying a drive belongs to a drive-fix session, and this is a ${session.kind} session. ` + "It stops and restarts a test drive on the human's machine \u2014 only the session opened on " + "that failed drive may do that; everyone else asks the human to drive from the review panel.");
@@ -9872,7 +10843,7 @@ async function toolCreateFeature(ctx, session, input) {
9872
10843
  phase: feature.phase
9873
10844
  };
9874
10845
  }
9875
- function toolGetProjectContext(ctx, session) {
10846
+ async function toolGetProjectContext(ctx, session) {
9876
10847
  const project = requireProject(ctx, session);
9877
10848
  const charter = readCharter(session.worktreePath);
9878
10849
  return {
@@ -9880,7 +10851,18 @@ function toolGetProjectContext(ctx, session) {
9880
10851
  ...charter !== undefined ? { charter } : {},
9881
10852
  adrs: listLiveAdrs(session.worktreePath).map(adrRef),
9882
10853
  adrsNote: ADRS_NOTE,
9883
- featureIndex: list(ctx, project.id).map(featureIndexLine)
10854
+ featureIndex: list(ctx, project.id).map(featureIndexLine),
10855
+ baseBranches: await readBaseBranches(project)
10856
+ };
10857
+ }
10858
+ async function readBaseBranches(project) {
10859
+ const { current: current2, branches, remoteBranches } = await listBranches(project);
10860
+ const selectable = [...branches, ...remoteBranches];
10861
+ return {
10862
+ current: current2,
10863
+ currentIsSelectable: selectable.includes(current2),
10864
+ selectable,
10865
+ detectedMain: await detectMainBranch(project.repoPath)
9884
10866
  };
9885
10867
  }
9886
10868
  function adrRef(adr) {
@@ -9902,7 +10884,7 @@ function toolReadAdr(ctx, session, input) {
9902
10884
  if (rel === "" || rel.startsWith("..") || rel.split(sep2).includes("..")) {
9903
10885
  throw new InvalidInputError(`adr path escapes ${ADR_DIR_REL}: ${input.relPath}`);
9904
10886
  }
9905
- if (!existsSync18(target))
10887
+ if (!existsSync19(target))
9906
10888
  throw new NotFoundError(`adr not found: ${input.relPath}`);
9907
10889
  return { relPath: `${ADR_DIR_REL}/${name}`, content: readFileSync11(target, "utf8") };
9908
10890
  }
@@ -10064,6 +11046,16 @@ function buildMcpServer(audience) {
10064
11046
  return ok(toolAddTestNote(rc.ctx, rc.caller, args));
10065
11047
  });
10066
11048
  }
11049
+ if (wants("report_finding")) {
11050
+ server.registerTool("report_finding", {
11051
+ title: "Report a structured review finding",
11052
+ description: "Report one typed finding for the feature under review. Actionable defects mint fix " + "tickets automatically up to the review cap; observations are retained for the digest.",
11053
+ inputSchema: ReviewFindingInput.shape
11054
+ }, async (args, extra) => {
11055
+ const rc = await resolveRunCaller(extra);
11056
+ return ok(toolReportFinding(rc.ctx, rc.caller, ReviewFindingInput.parse(args)));
11057
+ });
11058
+ }
10067
11059
  if (wants("retry_drive")) {
10068
11060
  server.registerTool("retry_drive", {
10069
11061
  title: "Retry this feature\u2019s test drive",
@@ -10099,7 +11091,7 @@ function buildMcpServer(audience) {
10099
11091
  inputSchema: {
10100
11092
  title: z4.string().min(1).describe("The feature\u2019s name; its slug and branch derive from this."),
10101
11093
  oneLiner: z4.string().optional().describe("One line saying what this feature is. Required for every shape EXCEPT `tickets`, " + "where the first ticket\u2019s prose becomes the one-liner and anything sent here is " + "ignored."),
10102
- baseBranch: z4.string().optional().describe("Branch to cut from; defaults to the project\u2019s main branch."),
11094
+ baseBranch: z4.string().optional().describe("Branch to cut from \u2014 always pass it explicitly, naming the base you told the " + "human you would use (see `get_project_context`\u2019s `baseBranches`). Omitted, it " + "falls back to whatever the project checkout happens to be on. Drafts pass none: " + "a parked feature picks its base at Start."),
10103
11095
  brief: z4.string().optional().describe("The reasoning you just worked out with the human \u2014 why this feature exists and " + "what it must NOT swallow. Becomes brief.md verbatim, and without it that " + "reasoning is lost. Unused by the `tickets` shape, whose brief IS the proses."),
10104
11096
  draft: z4.boolean().optional().describe("PARK it instead of starting it: a row and its brief, no branch and no files, " + "until the human clicks Start. Ask them per feature whether to start or park."),
10105
11097
  tickets: z4.array(z4.string().min(1)).min(1).optional().describe("The quick-change shape: work too small to deserve a grill. Creates ONE feature at " + "the implementation phase carrying a ticket per prose (plus the review ticket " + "every batch closes with). Send every sentence of one quick change in a SINGLE " + "call \u2014 calling this once per ticket makes one feature per ticket.")
@@ -10114,13 +11106,13 @@ function buildMcpServer(audience) {
10114
11106
  if (wants("get_project_context")) {
10115
11107
  server.registerTool("get_project_context", {
10116
11108
  title: "Get project context",
10117
- description: "The project as it stands: the project row, the charter (CONTEXT.md) in full, an INDEX " + "of every live ADR (superseded ones omitted) and a one-line index of every feature. " + "ADR bodies are not inlined \u2014 read the ones your work touches with `read_adr`. Shipped " + "features carry their one-liner and docs path (readable on disk); in-flight ones carry " + "slug, phase, lap and ticket counts but no docs path, because their docs live on an " + "unmerged branch. Use the slug with `get_work_record` to see what one actually did.",
11109
+ description: "The project as it stands: the project row, the charter (CONTEXT.md) in full, an INDEX " + "of every live ADR (superseded ones omitted) and a one-line index of every feature. " + "ADR bodies are not inlined \u2014 read the ones your work touches with `read_adr`. Shipped " + "features carry their one-liner and docs path (readable on disk); in-flight ones carry " + "slug, phase, lap and ticket counts but no docs path, because their docs live on an " + "unmerged branch. Use the slug with `get_work_record` to see what one actually did. " + "Plus `baseBranches` \u2014 the branch a new feature would cut from: the checkout\u2019s `current` " + "branch, `currentIsSelectable` (false mid test drive or on a detached HEAD, the one case " + "with no default), every `selectable` base, and the `detectedMain` line to suggest then.",
10118
11110
  inputSchema: {}
10119
11111
  }, async (_args, extra) => {
10120
11112
  const rs = await resolveCtxSession(extra);
10121
11113
  if (!rs)
10122
11114
  return noSession();
10123
- return ok(toolGetProjectContext(rs.ctx, rs.session));
11115
+ return ok(await toolGetProjectContext(rs.ctx, rs.session));
10124
11116
  });
10125
11117
  }
10126
11118
  if (wants("read_adr")) {
@@ -10359,6 +11351,7 @@ var init_server = __esm(() => {
10359
11351
  init_features();
10360
11352
  init_events();
10361
11353
  init_findings();
11354
+ init_review_findings();
10362
11355
  init_git();
10363
11356
  init_knowledge();
10364
11357
  init_repo();
@@ -10398,7 +11391,8 @@ var init_server = __esm(() => {
10398
11391
  dry_run_drive: ["prepare"],
10399
11392
  retry_drive: ["drive-fix"],
10400
11393
  review_drive: ["run"],
10401
- add_test_note: ["run"],
11394
+ report_finding: ["run"],
11395
+ add_test_note: [],
10402
11396
  record_event: [...FEATURE_KINDS, ...PROJECT_KINDS]
10403
11397
  };
10404
11398
  CompletablePhase = Phase.exclude(["shipped"]);
@@ -10488,7 +11482,7 @@ var init_ws = __esm(() => {
10488
11482
  });
10489
11483
 
10490
11484
  // src/services/prep.ts
10491
- import { and as and8, desc as desc6, eq as eq12, inArray as inArray4 } from "drizzle-orm";
11485
+ import { and as and9, desc as desc6, eq as eq13, inArray as inArray4 } from "drizzle-orm";
10492
11486
  function keysToPrepare(ctx, project) {
10493
11487
  return unsetPreparedKeys(project).filter((key) => isOverwritable(ctx, project.id, key));
10494
11488
  }
@@ -10498,10 +11492,10 @@ function isPrepared(ctx, project) {
10498
11492
  return hasCompletedProjectSession(ctx, project.id, "prepare");
10499
11493
  }
10500
11494
  function preparedAt(ctx, projectId) {
10501
- const prepareSessions = new Set(ctx.db.select({ id: sessions.id }).from(sessions).where(and8(eq12(sessions.projectId, projectId), eq12(sessions.kind, "prepare"))).all().map((r) => r.id));
11495
+ const prepareSessions = new Set(ctx.db.select({ id: sessions.id }).from(sessions).where(and9(eq13(sessions.projectId, projectId), eq13(sessions.kind, "prepare"))).all().map((r) => r.id));
10502
11496
  if (prepareSessions.size === 0)
10503
11497
  return null;
10504
- const ends = ctx.db.select({ ts: events.ts, data: events.data }).from(events).where(and8(eq12(events.projectId, projectId), inArray4(events.type, ["session.ended", "session.reconciled"]))).orderBy(desc6(events.ts)).all();
11498
+ const ends = ctx.db.select({ ts: events.ts, data: events.data }).from(events).where(and9(eq13(events.projectId, projectId), inArray4(events.type, ["session.ended", "session.reconciled"]))).orderBy(desc6(events.ts)).all();
10505
11499
  for (const end of ends) {
10506
11500
  const sessionId = end.data?.sessionId;
10507
11501
  if (sessionId && prepareSessions.has(sessionId))
@@ -10756,7 +11750,7 @@ var init_hooks = __esm(() => {
10756
11750
  });
10757
11751
 
10758
11752
  // src/routes/reviews.ts
10759
- import { createReadStream, statSync as statSync3 } from "fs";
11753
+ import { createReadStream, statSync as statSync4 } from "fs";
10760
11754
  import { Readable } from "stream";
10761
11755
  import { Hono as Hono3 } from "hono";
10762
11756
  function walkthroughUrl(ticketId) {
@@ -10764,7 +11758,7 @@ function walkthroughUrl(ticketId) {
10764
11758
  }
10765
11759
  function fileSize(path) {
10766
11760
  try {
10767
- const stat = statSync3(path);
11761
+ const stat = statSync4(path);
10768
11762
  return stat.isFile() ? stat.size : undefined;
10769
11763
  } catch {
10770
11764
  return;
@@ -10942,7 +11936,7 @@ var init_stream = __esm(() => {
10942
11936
  });
10943
11937
 
10944
11938
  // src/routes/web.ts
10945
- import { existsSync as existsSync19, statSync as statSync4 } from "fs";
11939
+ import { existsSync as existsSync20, statSync as statSync5 } from "fs";
10946
11940
  import { readFile } from "fs/promises";
10947
11941
  import { extname, join as join17, normalize, resolve as resolve10, sep as sep3 } from "path";
10948
11942
  function contentType(path) {
@@ -10950,7 +11944,7 @@ function contentType(path) {
10950
11944
  }
10951
11945
  function isFile(path) {
10952
11946
  try {
10953
- return statSync4(path).isFile();
11947
+ return statSync5(path).isFile();
10954
11948
  } catch {
10955
11949
  return false;
10956
11950
  }
@@ -10995,7 +11989,7 @@ function mountWebApp(app, distDir) {
10995
11989
  }
10996
11990
  function mountWebAppIfBuilt(app) {
10997
11991
  const dist = resolveWebDist();
10998
- if (existsSync19(join17(dist, "index.html")))
11992
+ if (existsSync20(join17(dist, "index.html")))
10999
11993
  mountWebApp(app, dist);
11000
11994
  }
11001
11995
  var MIME;
@@ -11024,11 +12018,11 @@ var init_web = __esm(() => {
11024
12018
  // src/version.ts
11025
12019
  import { readFileSync as readFileSync12 } from "fs";
11026
12020
  import { dirname as dirname9, join as join18 } from "path";
11027
- import { fileURLToPath as fileURLToPath7 } from "url";
12021
+ import { fileURLToPath as fileURLToPath8 } from "url";
11028
12022
  function runcastleVersion() {
11029
12023
  if (cached2)
11030
12024
  return cached2;
11031
- const here = dirname9(fileURLToPath7(import.meta.url));
12025
+ const here = dirname9(fileURLToPath8(import.meta.url));
11032
12026
  for (const pkgPath of [join18(here, "..", "package.json"), join18(here, "package.json")]) {
11033
12027
  try {
11034
12028
  const pkg = JSON.parse(readFileSync12(pkgPath, "utf8"));
@@ -11046,12 +12040,12 @@ var init_version = () => {};
11046
12040
 
11047
12041
  // src/services/install-id.ts
11048
12042
  import { randomUUID } from "crypto";
11049
- import { existsSync as existsSync20, mkdirSync as mkdirSync12, readFileSync as readFileSync13, writeFileSync as writeFileSync9 } from "fs";
12043
+ import { existsSync as existsSync21, mkdirSync as mkdirSync12, readFileSync as readFileSync13, writeFileSync as writeFileSync9 } from "fs";
11050
12044
  import { dirname as dirname10 } from "path";
11051
12045
  function getInstallId() {
11052
12046
  const file = installIdPath();
11053
12047
  try {
11054
- if (existsSync20(file)) {
12048
+ if (existsSync21(file)) {
11055
12049
  const existing = readFileSync13(file, "utf8").trim();
11056
12050
  if (UUID_RE.test(existing))
11057
12051
  return existing;
@@ -11251,9 +12245,9 @@ var init_events2 = __esm(() => {
11251
12245
  });
11252
12246
 
11253
12247
  // src/launcher/launcher.ts
11254
- import { existsSync as existsSync21 } from "fs";
12248
+ import { existsSync as existsSync22 } from "fs";
11255
12249
  import { join as join19 } from "path";
11256
- import { and as and9, eq as eq13 } from "drizzle-orm";
12250
+ import { and as and10, eq as eq14 } from "drizzle-orm";
11257
12251
  function ptyExitMessage(exitCode) {
11258
12252
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
11259
12253
  return `terminal exited (code ${label})`;
@@ -11273,7 +12267,7 @@ function renderCommand(runtime, spec) {
11273
12267
  return [...env, runtime.binary, ...spec.argv].join(" ");
11274
12268
  }
11275
12269
  function activeRunFor(ctx, featureId) {
11276
- const row = ctx.db.select().from(runs).where(and9(eq13(runs.featureId, featureId), eq13(runs.status, "running"))).limit(1).get();
12270
+ const row = ctx.db.select().from(runs).where(and10(eq14(runs.featureId, featureId), eq14(runs.status, "running"))).limit(1).get();
11277
12271
  return row ? rowToRun(row) : null;
11278
12272
  }
11279
12273
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -11637,7 +12631,7 @@ async function launchProjectSession(ctx, input, opts = {}) {
11637
12631
  const model = resolveModelEntry("project", ctx.config, project);
11638
12632
  const runtime = runtimeAdapterFor(model.runtime);
11639
12633
  assertRuntimeReady(runtime, opts);
11640
- const worktreePath = await ensureProjectWorktree(project, (res) => reportProjectLanding(ctx, project, res, { retried: true }));
12634
+ const { worktreePath, base } = await ensureProjectWorktree(project, (res) => reportProjectLanding(ctx, project, res, { retried: true }));
11641
12635
  const session = createSessionRow(ctx, {
11642
12636
  projectId: project.id,
11643
12637
  kind: "project",
@@ -11690,7 +12684,7 @@ async function launchProjectSession(ctx, input, opts = {}) {
11690
12684
  session,
11691
12685
  project,
11692
12686
  config: ctx.config,
11693
- projectBrief: { project, branch: PROJECT_BRANCH, worktreePath },
12687
+ projectBrief: { project, branch: PROJECT_BRANCH, worktreePath, base },
11694
12688
  worktreePath,
11695
12689
  serverUrl: serverUrlFor(ctx.config),
11696
12690
  model: model.id,
@@ -11724,7 +12718,7 @@ async function buildPrepareBrief(ctx, project) {
11724
12718
  };
11725
12719
  }
11726
12720
  function probePrepareHost(repoPath) {
11727
- const has = (rel) => existsSync21(join19(repoPath, rel));
12721
+ const has = (rel) => existsSync22(join19(repoPath, rel));
11728
12722
  const lock = LOCKFILES.find(([file]) => has(file));
11729
12723
  return {
11730
12724
  platform: process.platform,
@@ -12074,10 +13068,10 @@ var init_feature = __esm(() => {
12074
13068
  });
12075
13069
 
12076
13070
  // src/services/fsbrowse.ts
12077
- import { existsSync as existsSync22, readdirSync as readdirSync6, statSync as statSync5 } from "fs";
12078
- import { homedir as homedir4 } from "os";
13071
+ import { existsSync as existsSync23, readdirSync as readdirSync6, statSync as statSync6 } from "fs";
13072
+ import { homedir as homedir3 } from "os";
12079
13073
  import { basename as basename3, dirname as dirname11, isAbsolute as isAbsolute2, join as join21, resolve as resolve11, sep as sep4 } from "path";
12080
- function expandPath(input, home = homedir4()) {
13074
+ function expandPath(input, home = homedir3()) {
12081
13075
  const trimmed = input.trim();
12082
13076
  if (trimmed === "")
12083
13077
  return trimmed;
@@ -12090,31 +13084,31 @@ function expandPath(input, home = homedir4()) {
12090
13084
  }
12091
13085
  function looksLikeRepo(dir) {
12092
13086
  try {
12093
- return existsSync22(join21(dir, ".git"));
13087
+ return existsSync23(join21(dir, ".git"));
12094
13088
  } catch {
12095
13089
  return false;
12096
13090
  }
12097
13091
  }
12098
- function listRoots(home = homedir4()) {
13092
+ function listRoots(home = homedir3()) {
12099
13093
  const roots = [];
12100
13094
  if (process.platform === "win32") {
12101
13095
  for (let code = 67;code <= 90; code++) {
12102
13096
  const drive = `${String.fromCharCode(code)}:${sep4}`;
12103
13097
  try {
12104
- if (existsSync22(drive))
13098
+ if (existsSync23(drive))
12105
13099
  roots.push({ label: drive.replace(/\\$/, ""), path: drive, kind: "drive" });
12106
13100
  } catch {}
12107
13101
  }
12108
13102
  } else {
12109
13103
  roots.push({ label: "/", path: "/", kind: "drive" });
12110
13104
  }
12111
- if (existsSync22(home))
13105
+ if (existsSync23(home))
12112
13106
  roots.push({ label: "Home", path: home, kind: "home" });
12113
13107
  const seen = new Set(roots.map(pathKey));
12114
13108
  for (const name of COMMON_DIRS) {
12115
13109
  const candidate = join21(home, name);
12116
13110
  const key = pathKey({ path: candidate });
12117
- if (seen.has(key) || !existsSync22(candidate))
13111
+ if (seen.has(key) || !existsSync23(candidate))
12118
13112
  continue;
12119
13113
  seen.add(key);
12120
13114
  roots.push({ label: name, path: candidate, kind: "common" });
@@ -12140,17 +13134,17 @@ function parentOf(dir) {
12140
13134
  const parent = dirname11(dir);
12141
13135
  return parent === dir ? null : parent;
12142
13136
  }
12143
- function browseDir(input, showHidden = false, home = homedir4()) {
13137
+ function browseDir(input, showHidden = false, home = homedir3()) {
12144
13138
  const target = input && input.trim() !== "" ? expandPath(input, home) : home;
12145
13139
  if (!isAbsolute2(target)) {
12146
13140
  throw new InvalidInputError(`path is not absolute: ${target}`);
12147
13141
  }
12148
- if (!existsSync22(target)) {
13142
+ if (!existsSync23(target)) {
12149
13143
  throw new InvalidInputError(`path does not exist: ${target}`);
12150
13144
  }
12151
13145
  let dir = target;
12152
13146
  try {
12153
- if (!statSync5(target).isDirectory())
13147
+ if (!statSync6(target).isDirectory())
12154
13148
  dir = dirname11(target);
12155
13149
  } catch (e) {
12156
13150
  throw new InvalidInputError(`cannot read path: ${target} (${errMsg4(e)})`);
@@ -12180,7 +13174,7 @@ function browseDir(input, showHidden = false, home = homedir4()) {
12180
13174
  let isDirectory = dirent.isDirectory();
12181
13175
  if (isSymlink) {
12182
13176
  try {
12183
- isDirectory = statSync5(path).isDirectory();
13177
+ isDirectory = statSync6(path).isDirectory();
12184
13178
  } catch {
12185
13179
  continue;
12186
13180
  }
@@ -12370,9 +13364,9 @@ var init_conversations = __esm(() => {
12370
13364
  });
12371
13365
 
12372
13366
  // src/services/projects.ts
12373
- import { existsSync as existsSync23 } from "fs";
13367
+ import { existsSync as existsSync24 } from "fs";
12374
13368
  import { basename as basename4, join as join22 } from "path";
12375
- import { eq as eq14, isNull } from "drizzle-orm";
13369
+ import { eq as eq15, isNull } from "drizzle-orm";
12376
13370
  function listProjects(ctx) {
12377
13371
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
12378
13372
  }
@@ -12390,15 +13384,14 @@ function warnIfTranslatedMount(ctx, projectId, repoPath) {
12390
13384
  async function openProject(ctx, rawPath) {
12391
13385
  const repoPath = expandPath(rawPath);
12392
13386
  await assertRepoTolerant(ctx, repoPath);
12393
- const mainBranch = await detectMainBranchTolerant(ctx, repoPath);
12394
13387
  const name = basename4(repoPath) || repoPath;
12395
13388
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
12396
13389
  if (existing) {
12397
- ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq14(projects.id, existing.id)).run();
12398
- const reopened = { ...existing, mainBranch, repoPath, closedAt: undefined };
13390
+ ctx.db.update(projects).set({ repoPath, closedAt: null }).where(eq15(projects.id, existing.id)).run();
13391
+ const reopened = { ...existing, repoPath, closedAt: undefined };
12399
13392
  emitProject(ctx, existing.id, {
12400
13393
  type: "project.opened",
12401
- message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
13394
+ message: `project ${existing.name} re-opened at ${repoPath}`
12402
13395
  });
12403
13396
  warnIfTranslatedMount(ctx, existing.id, repoPath);
12404
13397
  return reopened;
@@ -12407,7 +13400,6 @@ async function openProject(ctx, rawPath) {
12407
13400
  id: newId("proj"),
12408
13401
  name,
12409
13402
  repoPath,
12410
- mainBranch,
12411
13403
  devCommand: null,
12412
13404
  closedAt: null
12413
13405
  };
@@ -12415,7 +13407,7 @@ async function openProject(ctx, rawPath) {
12415
13407
  const project = rowToProject(inserted);
12416
13408
  emitProject(ctx, project.id, {
12417
13409
  type: "project.opened",
12418
- message: `project ${name} at ${repoPath} (${mainBranch})`
13410
+ message: `project ${name} at ${repoPath}`
12419
13411
  });
12420
13412
  warnIfTranslatedMount(ctx, project.id, repoPath);
12421
13413
  return project;
@@ -12425,7 +13417,7 @@ function closeProject(ctx, projectId) {
12425
13417
  if (projectHasActiveRun(ctx, projectId)) {
12426
13418
  throw new InvalidInputError(`cannot close ${project.name}: a run is in flight \u2014 wait for it to finish or cancel it first`);
12427
13419
  }
12428
- ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq14(projects.id, projectId)).run();
13420
+ ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq15(projects.id, projectId)).run();
12429
13421
  emitProject(ctx, projectId, {
12430
13422
  type: "project.closed",
12431
13423
  message: `project ${project.name} closed`
@@ -12434,7 +13426,7 @@ function closeProject(ctx, projectId) {
12434
13426
  }
12435
13427
  function renameProject(ctx, projectId, name) {
12436
13428
  const project = requireProjectById(ctx, projectId);
12437
- ctx.db.update(projects).set({ name }).where(eq14(projects.id, projectId)).run();
13429
+ ctx.db.update(projects).set({ name }).where(eq15(projects.id, projectId)).run();
12438
13430
  emitProject(ctx, projectId, {
12439
13431
  type: "project.renamed",
12440
13432
  message: `project renamed ${project.name} \u2192 ${name}`,
@@ -12452,20 +13444,11 @@ async function assertRepoTolerant(ctx, repoPath) {
12452
13444
  } catch (e) {
12453
13445
  if (!isNotImplemented(e))
12454
13446
  throw e;
12455
- if (!existsSync23(join22(repoPath, ".git"))) {
13447
+ if (!existsSync24(join22(repoPath, ".git"))) {
12456
13448
  throw new InvalidInputError(`not a git repository: ${repoPath}`);
12457
13449
  }
12458
13450
  }
12459
13451
  }
12460
- async function detectMainBranchTolerant(ctx, repoPath) {
12461
- try {
12462
- return await detectMainBranch(repoPath);
12463
- } catch (e) {
12464
- if (!isNotImplemented(e))
12465
- throw e;
12466
- return ctx.config.mainBranch;
12467
- }
12468
- }
12469
13452
  var init_projects = __esm(() => {
12470
13453
  init_src();
12471
13454
  init_schema();
@@ -12495,6 +13478,7 @@ var init_project = __esm(() => {
12495
13478
  roots: publicProcedure.query(() => listRoots()),
12496
13479
  browse: publicProcedure.input(z8.object({ dir: z8.string().optional(), showHidden: z8.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
12497
13480
  branches: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
13481
+ sessionBranch: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => sessionBranchView(requireProjectById(ctx, input.projectId))),
12498
13482
  open: publicProcedure.input(z8.object({ repoPath: z8.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
12499
13483
  close: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
12500
13484
  rename: publicProcedure.input(z8.object({ projectId: z8.string(), name: ProjectName })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
@@ -12513,8 +13497,26 @@ var init_project = __esm(() => {
12513
13497
  });
12514
13498
  });
12515
13499
 
12516
- // src/trpc/routers/run.ts
13500
+ // src/trpc/routers/review-findings.ts
12517
13501
  import * as z9 from "zod";
13502
+ var reviewFindingsRouter;
13503
+ var init_review_findings2 = __esm(() => {
13504
+ init_features();
13505
+ init_review_findings();
13506
+ init_context();
13507
+ reviewFindingsRouter = router({
13508
+ listByFeature: publicProcedure.input(z9.object({ featureId: z9.string() })).query(({ ctx, input }) => viewByFeature(ctx, input.featureId)),
13509
+ dismiss: publicProcedure.input(z9.object({ findingId: z9.string() })).mutation(({ ctx, input }) => dismiss(ctx, input.findingId)),
13510
+ fixOpenDefects: publicProcedure.input(z9.object({ featureId: z9.string() })).mutation(async ({ ctx, input }) => {
13511
+ const promoted = promoteOpenDefects(ctx, input.featureId);
13512
+ const { runId } = await burn(ctx, input.featureId);
13513
+ return { ...promoted, runId };
13514
+ })
13515
+ });
13516
+ });
13517
+
13518
+ // src/trpc/routers/run.ts
13519
+ import * as z10 from "zod";
12518
13520
  var runRouter;
12519
13521
  var init_run = __esm(() => {
12520
13522
  init_agent_stream();
@@ -12522,9 +13524,9 @@ var init_run = __esm(() => {
12522
13524
  init_runner();
12523
13525
  init_context();
12524
13526
  runRouter = router({
12525
- get: publicProcedure.input(z9.object({ runId: z9.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
12526
- agentTranscript: publicProcedure.input(z9.object({ ticketId: z9.string(), after: z9.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
12527
- cancel: publicProcedure.input(z9.object({ runId: z9.string() })).mutation(({ ctx, input }) => {
13527
+ get: publicProcedure.input(z10.object({ runId: z10.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
13528
+ agentTranscript: publicProcedure.input(z10.object({ ticketId: z10.string(), after: z10.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
13529
+ cancel: publicProcedure.input(z10.object({ runId: z10.string() })).mutation(({ ctx, input }) => {
12528
13530
  getRunRow(ctx, input.runId);
12529
13531
  cancelRun(input.runId);
12530
13532
  return { ok: true };
@@ -12533,47 +13535,21 @@ var init_run = __esm(() => {
12533
13535
  });
12534
13536
 
12535
13537
  // src/trpc/routers/settings.ts
12536
- import * as z10 from "zod";
13538
+ import * as z11 from "zod";
12537
13539
  var settingsRouter;
12538
13540
  var init_settings2 = __esm(() => {
12539
13541
  init_src();
12540
13542
  init_settings();
12541
13543
  init_context();
12542
13544
  settingsRouter = router({
12543
- get: publicProcedure.input(z10.object({ projectId: z10.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
13545
+ get: publicProcedure.input(z11.object({ projectId: z11.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
12544
13546
  update: publicProcedure.input(SettingsUpdateInput).mutation(({ ctx, input }) => updateSettings(ctx, input))
12545
13547
  });
12546
13548
  });
12547
13549
 
12548
- // src/doctor/system-exec.ts
12549
- import { spawn as spawn4 } from "child_process";
12550
- function createSystemExec(opts = {}) {
12551
- return (command, args) => new Promise((resolve12) => {
12552
- const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
12553
- let stdout = "";
12554
- let stderr = "";
12555
- const child = spawn4(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
12556
- child.stdout?.on("data", (d) => {
12557
- stdout += d.toString();
12558
- });
12559
- child.stderr?.on("data", (d) => {
12560
- stderr += d.toString();
12561
- });
12562
- child.on("error", (err) => {
12563
- resolve12({ ok: false, code: null, stdout, stderr: stderr || String(err) });
12564
- });
12565
- child.on("close", (code) => {
12566
- resolve12({ ok: true, code: code ?? 0, stdout, stderr });
12567
- });
12568
- });
12569
- }
12570
- var init_system_exec = __esm(() => {
12571
- init_resolve_executable();
12572
- });
12573
-
12574
13550
  // src/trpc/routers/setup.ts
12575
- import { isNotNull as isNotNull2 } from "drizzle-orm";
12576
- import * as z11 from "zod";
13551
+ import { isNotNull as isNotNull3 } from "drizzle-orm";
13552
+ import * as z12 from "zod";
12577
13553
  var setupRouter;
12578
13554
  var init_setup2 = __esm(() => {
12579
13555
  init_src();
@@ -12586,7 +13562,7 @@ var init_setup2 = __esm(() => {
12586
13562
  init_context();
12587
13563
  setupRouter = router({
12588
13564
  doctor: publicProcedure.query(({ ctx }) => {
12589
- const projectModels = ctx.db.select({ model: projects.model }).from(projects).where(isNotNull2(projects.model)).all().map((p) => p.model);
13565
+ const projectModels = ctx.db.select({ model: projects.model }).from(projects).where(isNotNull3(projects.model)).all().map((p) => p.model);
12590
13566
  return runDoctor({
12591
13567
  exec: createSystemExec(),
12592
13568
  runtimes: configuredRuntimes(ctx.config, projectModels),
@@ -12594,10 +13570,10 @@ var init_setup2 = __esm(() => {
12594
13570
  });
12595
13571
  }),
12596
13572
  runtimeGuide: publicProcedure.query(() => runtimeInstallGuide(process.platform)),
12597
- gitIdentity: publicProcedure.input(z11.object({ name: z11.string(), email: z11.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
12598
- afkToken: publicProcedure.input(z11.object({ token: z11.string(), runtime: AgentRuntime.default("claude-code") })).mutation(({ input }) => saveAfkCredential(fileAfkTokenIo(createSystemExec(), input.runtime), input.token, input.runtime)),
12599
- seedModelDefaults: publicProcedure.input(z11.object({ runtimes: z11.array(AgentRuntime) })).mutation(({ ctx, input }) => seedModelDefaults(ctx, input.runtimes)),
12600
- startTerminal: publicProcedure.input(z11.object({ kind: z11.enum(["setup-token", "build-image", "claude-login", "codex-login"]) })).mutation(async ({ ctx, input }) => {
13573
+ gitIdentity: publicProcedure.input(z12.object({ name: z12.string(), email: z12.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
13574
+ afkToken: publicProcedure.input(z12.object({ token: z12.string(), runtime: AgentRuntime.default("claude-code") })).mutation(({ input }) => saveAfkCredential(fileAfkTokenIo(createSystemExec(), input.runtime), input.token, input.runtime)),
13575
+ seedModelDefaults: publicProcedure.input(z12.object({ runtimes: z12.array(AgentRuntime) })).mutation(({ ctx, input }) => seedModelDefaults(ctx, input.runtimes)),
13576
+ startTerminal: publicProcedure.input(z12.object({ kind: z12.enum(["setup-token", "build-image", "claude-login", "codex-login"]) })).mutation(async ({ ctx, input }) => {
12601
13577
  const exec = createSystemExec();
12602
13578
  const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
12603
13579
  const runtime = await resolveRuntime(exec, preferred);
@@ -12619,40 +13595,80 @@ var init_setup2 = __esm(() => {
12619
13595
  });
12620
13596
 
12621
13597
  // src/trpc/routers/system.ts
12622
- var systemRouter;
13598
+ import * as z13 from "zod";
13599
+ function burnCacheEngine(sandbox) {
13600
+ return sandbox === "docker" || sandbox === "podman" ? sandbox : null;
13601
+ }
13602
+ var projectInput, systemRouter;
12623
13603
  var init_system = __esm(() => {
13604
+ init_src();
13605
+ init_system_exec();
13606
+ init_errors();
13607
+ init_events();
12624
13608
  init_update_check();
13609
+ init_burn_cache();
12625
13610
  init_version();
12626
13611
  init_context();
13612
+ projectInput = z13.object({ projectId: z13.string() });
12627
13613
  systemRouter = router({
12628
13614
  version: publicProcedure.query(() => ({ version: runcastleVersion() })),
12629
- checkUpdate: publicProcedure.query(() => getUpdateInfo(runcastleVersion()))
13615
+ checkUpdate: publicProcedure.query(() => getUpdateInfo(runcastleVersion())),
13616
+ burnCache: router({
13617
+ status: publicProcedure.input(projectInput).query(async ({ ctx, input }) => {
13618
+ const mode = resolveBurnCacheMode(ctx.config);
13619
+ const engine = burnCacheEngine(ctx.config.sandbox);
13620
+ const sizeBytes = mode === "volume" && engine ? await burnCacheVolumeSize({
13621
+ engine,
13622
+ projectId: input.projectId,
13623
+ exec: createSystemExec()
13624
+ }) : null;
13625
+ return { mode, engine, volumeName: burnCacheVolumeName(input.projectId), sizeBytes };
13626
+ }),
13627
+ clear: publicProcedure.input(projectInput).mutation(async ({ ctx, input }) => {
13628
+ const engine = burnCacheEngine(ctx.config.sandbox);
13629
+ if (!engine) {
13630
+ throw new InvalidInputError(`the burn cache needs a docker or podman sandbox; this one is "${ctx.config.sandbox}"`);
13631
+ }
13632
+ const volumeName = burnCacheVolumeName(input.projectId);
13633
+ await removeBurnCacheVolume({
13634
+ engine,
13635
+ projectId: input.projectId,
13636
+ exec: createSystemExec(),
13637
+ slots: getBurnSlotAllocator(ctx.config.burnConcurrency).held()
13638
+ });
13639
+ emitProject(ctx, input.projectId, {
13640
+ type: "burn-cache.cleared",
13641
+ message: `cleared the burn cache volume ${volumeName}`
13642
+ });
13643
+ return { volumeName };
13644
+ })
13645
+ })
12630
13646
  });
12631
13647
  });
12632
13648
 
12633
13649
  // src/trpc/routers/test-notes.ts
12634
- import * as z12 from "zod";
13650
+ import * as z14 from "zod";
12635
13651
  var testNotesRouter;
12636
13652
  var init_test_notes2 = __esm(() => {
12637
13653
  init_test_notes();
12638
13654
  init_context();
12639
13655
  testNotesRouter = router({
12640
- list: publicProcedure.input(z12.object({ featureId: z12.string() })).query(({ ctx, input }) => listByFeature3(ctx, input.featureId)),
12641
- add: publicProcedure.input(z12.object({
12642
- featureId: z12.string(),
12643
- text: z12.string().min(1),
12644
- videoTimestamp: z12.number().nonnegative().optional()
13656
+ list: publicProcedure.input(z14.object({ featureId: z14.string() })).query(({ ctx, input }) => listByFeature4(ctx, input.featureId)),
13657
+ add: publicProcedure.input(z14.object({
13658
+ featureId: z14.string(),
13659
+ text: z14.string().min(1),
13660
+ videoTimestamp: z14.number().nonnegative().optional()
12645
13661
  })).mutation(({ ctx, input }) => addNote(ctx, input.featureId, input.text, "human", input.videoTimestamp)),
12646
- edit: publicProcedure.input(z12.object({ noteId: z12.string(), text: z12.string().min(1) })).mutation(({ ctx, input }) => editNote(ctx, input.noteId, input.text)),
12647
- remove: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => deleteNote(ctx, input.noteId)),
12648
- toggle: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => toggleNote(ctx, input.noteId)),
12649
- promote: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => promoteNote(ctx, input.noteId)),
12650
- promoteMany: publicProcedure.input(z12.object({ noteIds: z12.array(z12.string()).min(1) })).mutation(({ ctx, input }) => promoteMany(ctx, input.noteIds))
13662
+ edit: publicProcedure.input(z14.object({ noteId: z14.string(), text: z14.string().min(1) })).mutation(({ ctx, input }) => editNote(ctx, input.noteId, input.text)),
13663
+ remove: publicProcedure.input(z14.object({ noteId: z14.string() })).mutation(({ ctx, input }) => deleteNote(ctx, input.noteId)),
13664
+ toggle: publicProcedure.input(z14.object({ noteId: z14.string() })).mutation(({ ctx, input }) => toggleNote(ctx, input.noteId)),
13665
+ promote: publicProcedure.input(z14.object({ noteId: z14.string() })).mutation(({ ctx, input }) => promoteNote(ctx, input.noteId)),
13666
+ promoteMany: publicProcedure.input(z14.object({ noteIds: z14.array(z14.string()).min(1) })).mutation(({ ctx, input }) => promoteMany(ctx, input.noteIds))
12651
13667
  });
12652
13668
  });
12653
13669
 
12654
13670
  // src/trpc/routers/ticket.ts
12655
- import * as z13 from "zod";
13671
+ import * as z15 from "zod";
12656
13672
  var ticketRouter;
12657
13673
  var init_ticket = __esm(() => {
12658
13674
  init_features();
@@ -12661,8 +13677,8 @@ var init_ticket = __esm(() => {
12661
13677
  init_ticket_burner();
12662
13678
  init_context();
12663
13679
  ticketRouter = router({
12664
- retry: publicProcedure.input(z13.object({ ticketId: z13.string(), fresh: z13.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
12665
- stop: publicProcedure.input(z13.object({ ticketId: z13.string() })).mutation(({ ctx, input }) => {
13680
+ retry: publicProcedure.input(z15.object({ ticketId: z15.string(), fresh: z15.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
13681
+ stop: publicProcedure.input(z15.object({ ticketId: z15.string() })).mutation(({ ctx, input }) => {
12666
13682
  if (stopTicketRun(input.ticketId))
12667
13683
  return { stopped: true, swept: false };
12668
13684
  const ticket = getTicket(ctx, input.ticketId);
@@ -12672,14 +13688,14 @@ var init_ticket = __esm(() => {
12672
13688
  }
12673
13689
  return { stopped: false, swept: false };
12674
13690
  }),
12675
- cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason)),
12676
- edit: publicProcedure.input(z13.object({
12677
- ticketId: z13.string(),
12678
- title: z13.string().min(1).optional(),
12679
- goal: z13.string().min(1).optional(),
12680
- context: z13.string().optional(),
12681
- acceptanceCriteria: z13.array(z13.string().min(1)).optional(),
12682
- model: z13.string().optional()
13691
+ cancel: publicProcedure.input(z15.object({ ticketId: z15.string(), reason: z15.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason)),
13692
+ edit: publicProcedure.input(z15.object({
13693
+ ticketId: z15.string(),
13694
+ title: z15.string().min(1).optional(),
13695
+ goal: z15.string().min(1).optional(),
13696
+ context: z15.string().optional(),
13697
+ acceptanceCriteria: z15.array(z15.string().min(1)).optional(),
13698
+ model: z15.string().optional()
12683
13699
  })).mutation(({ ctx, input }) => {
12684
13700
  const { ticketId, ...patch } = input;
12685
13701
  return editTicket(ctx, ticketId, patch);
@@ -12695,6 +13711,7 @@ var init_router = __esm(() => {
12695
13711
  init_events2();
12696
13712
  init_feature();
12697
13713
  init_project();
13714
+ init_review_findings2();
12698
13715
  init_run();
12699
13716
  init_settings2();
12700
13717
  init_setup2();
@@ -12707,6 +13724,7 @@ var init_router = __esm(() => {
12707
13724
  run: runRouter,
12708
13725
  ticket: ticketRouter,
12709
13726
  notes: testNotesRouter,
13727
+ findings: reviewFindingsRouter,
12710
13728
  events: eventsRouter,
12711
13729
  docs: docsRouter,
12712
13730
  settings: settingsRouter,
@@ -12716,14 +13734,14 @@ var init_router = __esm(() => {
12716
13734
  });
12717
13735
 
12718
13736
  // src/workflows/reconcile-runs.ts
12719
- import { eq as eq15 } from "drizzle-orm";
13737
+ import { eq as eq16 } from "drizzle-orm";
12720
13738
  async function reconcileStaleRuns(ctx) {
12721
- const stale = ctx.db.select().from(runs).where(eq15(runs.status, "running")).all().map(rowToRun);
13739
+ const stale = ctx.db.select().from(runs).where(eq16(runs.status, "running")).all().map(rowToRun);
12722
13740
  const reconciled = [];
12723
13741
  for (const run4 of stale) {
12724
13742
  if (isRunActive(run4.id))
12725
13743
  continue;
12726
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq15(runs.id, run4.id)).run();
13744
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq16(runs.id, run4.id)).run();
12727
13745
  const released = releaseForSession(ctx, run4.id);
12728
13746
  const swept = workflowClaimsFeatureBranch(run4.workflow) ? sweepOrphanedBurning(ctx, run4.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
12729
13747
  if (workflowClaimsFeatureBranch(run4.workflow)) {