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