runcastle 1.2.10 → 1.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -48,6 +48,12 @@ import * as z from "zod";
48
48
  function resolveSandboxImage(config) {
49
49
  return config.sandboxImage ?? DEFAULT_SANDBOX_IMAGE;
50
50
  }
51
+ function resolveBurnCacheMode(config) {
52
+ return BURN_CACHE_SANDBOXES.includes(config.sandbox) ? config.burnCache : "off";
53
+ }
54
+ function resolveDefaultBurnConcurrency(logicalCpus) {
55
+ return Number.isFinite(logicalCpus) && logicalCpus > SMALL_HOST_LOGICAL_CPUS ? 3 : 1;
56
+ }
51
57
  function resolvePreparedSettings(config, project) {
52
58
  const pick = (a, b) => {
53
59
  const own = a?.trim();
@@ -99,7 +105,7 @@ var MODEL_STEPS, ModelStep, AGENT_RUNTIMES, AgentRuntime, DEFAULT_RUNTIME = "cla
99
105
  }
100
106
  delete obj.smokeModel;
101
107
  return obj;
102
- }, RuncastleConfig, DEFAULT_SANDBOX_IMAGE = "sandcastle:runcastle";
108
+ }, RuncastleConfig, DEFAULT_SANDBOX_IMAGE = "sandcastle:runcastle", BURN_CACHE_SANDBOXES, SMALL_HOST_LOGICAL_CPUS = 8;
103
109
  var init_config = __esm(() => {
104
110
  MODEL_STEPS = [
105
111
  "ideation",
@@ -109,6 +115,7 @@ var init_config = __esm(() => {
109
115
  "revisit",
110
116
  "research",
111
117
  "implement",
118
+ "review",
112
119
  "prepare",
113
120
  "project",
114
121
  "smoke"
@@ -156,8 +163,10 @@ var init_config = __esm(() => {
156
163
  setupCommand: z.string().optional(),
157
164
  verifyCommands: z.string().optional(),
158
165
  knownFailures: z.string().optional(),
159
- burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
166
+ burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto"),
167
+ burnCache: z.enum(["volume", "off"]).default("volume")
160
168
  }));
169
+ BURN_CACHE_SANDBOXES = ["docker", "podman"];
161
170
  });
162
171
 
163
172
  // ../core/src/schemas.ts
@@ -165,7 +174,7 @@ import * as z2 from "zod";
165
174
  function isProjectSessionKind(kind) {
166
175
  return PROJECT_SESSION_KINDS.includes(kind);
167
176
  }
168
- var Phase, TicketStatus, SessionKind, PROJECT_SESSION_KINDS, SessionPurpose, MergeBranchPair, RunStatus, FeatureStatus, SessionStatus, TicketKind, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, TestNoteStatus, TestNoteAuthor, TestNote, PREPARED_KEYS, PreparedKey, DRIVE_LOOP_KEYS, FindingSource, PROJECT_NAME_MAX = 80, ProjectName, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
177
+ var Phase, TicketStatus, SessionKind, PROJECT_SESSION_KINDS, SessionPurpose, MergeBranchPair, RunStatus, FeatureStatus, SessionStatus, TicketKind, FindingKind, FindingSeverity, FindingStatus, FindingOpenReason, ReviewFindingInput, ReviewFinding, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, TestNoteStatus, TestNoteAuthor, TestNote, PREPARED_KEYS, PreparedKey, DRIVE_LOOP_KEYS, FindingSource, PROJECT_NAME_MAX = 80, ProjectName, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
169
178
  var init_schemas = __esm(() => {
170
179
  init_config();
171
180
  Phase = z2.enum([
@@ -197,6 +206,35 @@ var init_schemas = __esm(() => {
197
206
  FeatureStatus = z2.enum(["draft", "active", "shipped", "archived"]);
198
207
  SessionStatus = z2.enum(["launching", "live", "ended"]);
199
208
  TicketKind = z2.enum(["implementation", "review"]);
209
+ FindingKind = z2.enum(["defect", "observation"]);
210
+ FindingSeverity = z2.enum(["high", "medium", "low"]);
211
+ FindingStatus = z2.enum(["open", "fixing", "fixed", "failed", "dismissed"]);
212
+ FindingOpenReason = z2.enum(["over-cap", "fix-failed"]);
213
+ ReviewFindingInput = z2.object({
214
+ kind: FindingKind,
215
+ severity: FindingSeverity,
216
+ title: z2.string().min(1),
217
+ location: z2.string().min(1),
218
+ citation: z2.string().min(1),
219
+ detail: z2.string().min(1),
220
+ reproStep: z2.string().optional()
221
+ }).superRefine((finding, ctx) => {
222
+ if (finding.kind === "defect" && !finding.reproStep?.trim()) {
223
+ ctx.addIssue({ code: "custom", path: ["reproStep"], message: "defects require a repro step" });
224
+ }
225
+ });
226
+ ReviewFinding = ReviewFindingInput.safeExtend({
227
+ id: z2.string(),
228
+ featureId: z2.string(),
229
+ lap: z2.number(),
230
+ reviewTicketId: z2.string(),
231
+ reproStep: z2.string(),
232
+ status: FindingStatus,
233
+ openReason: FindingOpenReason.nullable(),
234
+ failureReason: z2.string().nullable(),
235
+ fixTicketId: z2.string().nullable(),
236
+ createdAt: z2.number()
237
+ });
200
238
  TicketInput = z2.object({
201
239
  title: z2.string(),
202
240
  goal: z2.string(),
@@ -205,7 +243,8 @@ var init_schemas = __esm(() => {
205
243
  seams: z2.array(z2.string()),
206
244
  blockedBy: z2.array(z2.number()),
207
245
  kind: TicketKind.default("implementation"),
208
- model: z2.string().optional()
246
+ model: z2.string().optional(),
247
+ originFindingId: z2.string().optional()
209
248
  });
210
249
  Ticket = TicketInput.extend({
211
250
  id: z2.string(),
@@ -536,7 +575,7 @@ var init_pipeline = __esm(() => {
536
575
 
537
576
  // ../core/src/db-schema.ts
538
577
  import { index, integer, primaryKey, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
539
- var projects, projectFindings, features, sessions, tickets, testNotes, waypoints, runs, events, gateOverrides;
578
+ var projects, projectFindings, features, sessions, tickets, reviewFindings, testNotes, waypoints, runs, events, gateOverrides;
540
579
  var init_db_schema = __esm(() => {
541
580
  projects = sqliteTable("projects", {
542
581
  id: text("id").primaryKey(),
@@ -615,7 +654,26 @@ var init_db_schema = __esm(() => {
615
654
  error: text("error"),
616
655
  attemptBranch: text("attempt_branch"),
617
656
  conflictFiles: text("conflict_files", { mode: "json" }).$type(),
618
- digest: text("digest")
657
+ digest: text("digest"),
658
+ originFindingId: text("origin_finding_id")
659
+ });
660
+ reviewFindings = sqliteTable("review_findings", {
661
+ id: text("id").primaryKey(),
662
+ featureId: text("feature_id").notNull(),
663
+ lap: integer("lap").notNull(),
664
+ reviewTicketId: text("review_ticket_id").notNull(),
665
+ kind: text("kind").notNull().$type(),
666
+ severity: text("severity").notNull().$type(),
667
+ title: text("title").notNull(),
668
+ location: text("location").notNull(),
669
+ citation: text("citation").notNull(),
670
+ detail: text("detail").notNull(),
671
+ reproStep: text("repro_step").notNull(),
672
+ status: text("status").notNull().$type(),
673
+ openReason: text("open_reason").$type(),
674
+ failureReason: text("failure_reason"),
675
+ fixTicketId: text("fix_ticket_id"),
676
+ createdAt: integer("created_at").notNull()
619
677
  });
620
678
  testNotes = sqliteTable("test_notes", {
621
679
  id: text("id").primaryKey(),
@@ -788,10 +846,12 @@ var init_paths = () => {};
788
846
  // ../core/src/config-load.ts
789
847
  var exports_config_load = {};
790
848
  __export(exports_config_load, {
791
- loadConfig: () => loadConfig
849
+ loadConfig: () => loadConfig,
850
+ hostLogicalCpus: () => hostLogicalCpus
792
851
  });
793
852
  import { existsSync, readFileSync } from "fs";
794
- function loadConfig(env = process.env) {
853
+ import { availableParallelism } from "os";
854
+ function loadConfig(env = process.env, logicalCpus = hostLogicalCpus()) {
795
855
  let fileConfig = {};
796
856
  const path = configPath();
797
857
  if (existsSync(path)) {
@@ -833,8 +893,17 @@ function loadConfig(env = process.env) {
833
893
  overrides.knownFailures = env.RUNCASTLE_KNOWN_FAILURES;
834
894
  if (env.RUNCASTLE_BURN_WORKSPACE)
835
895
  overrides.burnWorkspace = env.RUNCASTLE_BURN_WORKSPACE;
896
+ if (env.RUNCASTLE_BURN_CACHE)
897
+ overrides.burnCache = env.RUNCASTLE_BURN_CACHE;
836
898
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
837
- return RuncastleConfig.parse({ ...base, ...overrides });
899
+ const merged = { ...base, ...overrides };
900
+ if (merged.burnConcurrency === undefined) {
901
+ merged.burnConcurrency = resolveDefaultBurnConcurrency(logicalCpus);
902
+ }
903
+ return RuncastleConfig.parse(merged);
904
+ }
905
+ function hostLogicalCpus() {
906
+ return availableParallelism();
838
907
  }
839
908
  var init_config_load = __esm(() => {
840
909
  init_config();
@@ -863,6 +932,7 @@ var init_schema = __esm(() => {
863
932
  init_src();
864
933
  schema = {
865
934
  projects,
935
+ reviewFindings,
866
936
  features,
867
937
  sessions,
868
938
  tickets,
@@ -1571,6 +1641,198 @@ var init_registry = __esm(() => {
1571
1641
  REGISTRY_KEY = Symbol.for("runcastle.pty.registry");
1572
1642
  });
1573
1643
 
1644
+ // src/workflows/burn-cache.ts
1645
+ function burnCacheVolumeName(projectId) {
1646
+ return `runcastle-${projectId}`;
1647
+ }
1648
+ function slotRepoPath(slot) {
1649
+ return `${slotDirPath(slot)}/repo`;
1650
+ }
1651
+ function slotDirPath(slot) {
1652
+ return `${BURN_CACHE_MOUNT}/slots/${slot}`;
1653
+ }
1654
+ function slotStampPath(slot) {
1655
+ return `${slotDirPath(slot)}/.runcastle-stamp`;
1656
+ }
1657
+ function storePath(pm) {
1658
+ return `${BURN_CACHE_MOUNT}/store/${pm}`;
1659
+ }
1660
+ function burnCacheEnv(pm) {
1661
+ const store = storePath(pm);
1662
+ return {
1663
+ npm_config_store_dir: store,
1664
+ pnpm_config_store_dir: store,
1665
+ BUN_INSTALL_CACHE_DIR: store,
1666
+ npm_config_cache: store,
1667
+ YARN_GLOBAL_FOLDER: store,
1668
+ TMPDIR: `${BURN_CACHE_MOUNT}/tmp`,
1669
+ NODE_COMPILE_CACHE: `${BURN_CACHE_MOUNT}/node-compile`
1670
+ };
1671
+ }
1672
+ async function execOrThrow(exec, engine, args) {
1673
+ const out = await exec(engine, args);
1674
+ if (!out.ok || out.code !== 0) {
1675
+ throw new Error(`${engine} ${args.join(" ")} failed: ${out.stderr.trim() || `exit ${out.code}`}`);
1676
+ }
1677
+ }
1678
+ async function ensureBurnCacheVolume(opts) {
1679
+ const { engine, exec } = opts;
1680
+ const name = burnCacheVolumeName(opts.projectId);
1681
+ const inspect = await exec(engine, ["volume", "inspect", name]);
1682
+ const existed = inspect.ok && inspect.code === 0;
1683
+ await execOrThrow(exec, engine, ["volume", "create", name]);
1684
+ if (existed)
1685
+ return;
1686
+ await execOrThrow(exec, engine, [
1687
+ "run",
1688
+ "--rm",
1689
+ "--user",
1690
+ "root",
1691
+ "-v",
1692
+ `${name}:${BURN_CACHE_MOUNT}`,
1693
+ opts.imageName,
1694
+ "chown",
1695
+ "-R",
1696
+ CONTAINER_USER,
1697
+ BURN_CACHE_MOUNT
1698
+ ]);
1699
+ }
1700
+ async function removeBurnCacheVolume(opts) {
1701
+ if (opts.slots.length > 0)
1702
+ throw new BurnCacheBusyError([...opts.slots].sort((a, b) => a - b));
1703
+ await execOrThrow(opts.exec, opts.engine, ["volume", "rm", burnCacheVolumeName(opts.projectId)]);
1704
+ }
1705
+ async function burnCacheVolumeSize(opts) {
1706
+ const out = await opts.exec(opts.engine, ["system", "df", "-v", "--format", "json"]);
1707
+ if (!out.ok || out.code !== 0)
1708
+ return null;
1709
+ return findVolumeSize(out.stdout, burnCacheVolumeName(opts.projectId));
1710
+ }
1711
+ function parseJsonValues(stdout) {
1712
+ const values = [];
1713
+ const whole = tryParseJson(stdout);
1714
+ if (whole !== undefined)
1715
+ return [whole];
1716
+ for (const line of stdout.split(`
1717
+ `)) {
1718
+ const value = tryParseJson(line);
1719
+ if (value !== undefined)
1720
+ values.push(value);
1721
+ }
1722
+ return values;
1723
+ }
1724
+ function tryParseJson(text2) {
1725
+ const trimmed = text2.trim();
1726
+ if (!trimmed)
1727
+ return;
1728
+ try {
1729
+ return JSON.parse(trimmed);
1730
+ } catch {
1731
+ return;
1732
+ }
1733
+ }
1734
+ function findVolumeSize(stdout, name) {
1735
+ const pending = parseJsonValues(stdout);
1736
+ while (pending.length > 0) {
1737
+ const node = pending.shift();
1738
+ if (Array.isArray(node)) {
1739
+ pending.push(...node);
1740
+ continue;
1741
+ }
1742
+ if (typeof node !== "object" || node === null)
1743
+ continue;
1744
+ const record = node;
1745
+ if (VOLUME_NAME_KEYS.some((key) => record[key] === name)) {
1746
+ const size = readSize(record);
1747
+ if (size !== null)
1748
+ return size;
1749
+ }
1750
+ pending.push(...Object.values(record));
1751
+ }
1752
+ return null;
1753
+ }
1754
+ function readSize(record) {
1755
+ const usage = record.UsageData;
1756
+ const nested = typeof usage === "object" && usage !== null ? usage.Size : undefined;
1757
+ return parseSize(record.Size) ?? parseSize(nested);
1758
+ }
1759
+ function parseSize(value) {
1760
+ if (typeof value === "number")
1761
+ return Number.isFinite(value) ? value : null;
1762
+ if (typeof value !== "string")
1763
+ return null;
1764
+ const match = /^\s*(-?[\d.]+)\s*([a-z]*)\s*$/i.exec(value);
1765
+ if (!match)
1766
+ return null;
1767
+ const amount = Number(match[1]);
1768
+ const unit = SIZE_UNITS[(match[2] || "b").toLowerCase()];
1769
+ if (!Number.isFinite(amount) || unit === undefined)
1770
+ return null;
1771
+ return Math.round(amount * unit);
1772
+ }
1773
+ function createSlotAllocator(capacity) {
1774
+ const heldSlots = new Set;
1775
+ let width = capacity;
1776
+ return {
1777
+ claim() {
1778
+ for (let slot = 1;slot <= width; slot++) {
1779
+ if (!heldSlots.has(slot)) {
1780
+ heldSlots.add(slot);
1781
+ return slot;
1782
+ }
1783
+ }
1784
+ throw new BurnSlotsExhaustedError(width);
1785
+ },
1786
+ release(slot) {
1787
+ heldSlots.delete(slot);
1788
+ },
1789
+ held() {
1790
+ return [...heldSlots].sort((a, b) => a - b);
1791
+ },
1792
+ resize(next) {
1793
+ width = next;
1794
+ }
1795
+ };
1796
+ }
1797
+ function getBurnSlotAllocator(capacity) {
1798
+ if (!sharedAllocator)
1799
+ sharedAllocator = createSlotAllocator(capacity);
1800
+ else
1801
+ sharedAllocator.resize(capacity);
1802
+ return sharedAllocator;
1803
+ }
1804
+ var BURN_CACHE_MOUNT = "/home/agent/cache", BurnCacheBusyError, CONTAINER_USER = "1000:1000", VOLUME_NAME_KEYS, SIZE_UNITS, BurnSlotsExhaustedError, sharedAllocator;
1805
+ var init_burn_cache = __esm(() => {
1806
+ BurnCacheBusyError = class BurnCacheBusyError extends Error {
1807
+ slots;
1808
+ constructor(slots) {
1809
+ super(`burn cache is in use \u2014 slots ${slots.join(", ")} are held; stop those burns before clearing it`);
1810
+ this.name = "BurnCacheBusyError";
1811
+ this.slots = slots;
1812
+ }
1813
+ };
1814
+ VOLUME_NAME_KEYS = ["Name", "VolumeName"];
1815
+ SIZE_UNITS = {
1816
+ b: 1,
1817
+ kb: 1000,
1818
+ mb: 1e6,
1819
+ gb: 1e9,
1820
+ tb: 1000000000000,
1821
+ pb: 1000000000000000,
1822
+ kib: 1024,
1823
+ mib: 1024 ** 2,
1824
+ gib: 1024 ** 3,
1825
+ tib: 1024 ** 4,
1826
+ pib: 1024 ** 5
1827
+ };
1828
+ BurnSlotsExhaustedError = class BurnSlotsExhaustedError extends Error {
1829
+ constructor(capacity) {
1830
+ super(`all ${capacity} burn cache slots are in use`);
1831
+ this.name = "BurnSlotsExhaustedError";
1832
+ }
1833
+ };
1834
+ });
1835
+
1574
1836
  // src/errors.ts
1575
1837
  import { TRPCError } from "@trpc/server";
1576
1838
  function isNotImplemented(e) {
@@ -1585,6 +1847,8 @@ function toTRPCError(e) {
1585
1847
  return new TRPCError({ code: "NOT_FOUND", message: e.message, cause: e });
1586
1848
  if (e instanceof GateError)
1587
1849
  return new TRPCError({ code: "PRECONDITION_FAILED", message: e.message, cause: e });
1850
+ if (e instanceof BurnCacheBusyError)
1851
+ return new TRPCError({ code: "PRECONDITION_FAILED", message: e.message, cause: e });
1588
1852
  if (e instanceof InvalidInputError)
1589
1853
  return new TRPCError({ code: "BAD_REQUEST", message: e.message, cause: e });
1590
1854
  if (e instanceof Error)
@@ -1593,6 +1857,7 @@ function toTRPCError(e) {
1593
1857
  }
1594
1858
  var NotImplementedError, GateError, InvalidInputError, NotFoundError;
1595
1859
  var init_errors = __esm(() => {
1860
+ init_burn_cache();
1596
1861
  NotImplementedError = class NotImplementedError extends Error {
1597
1862
  wave;
1598
1863
  constructor(wave) {
@@ -3253,6 +3518,11 @@ async function commitDocs(worktreePath, message) {
3253
3518
  return;
3254
3519
  await g.commit(message, [DOCS_PATHSPEC]);
3255
3520
  }
3521
+ async function commitPipelineDocs(repoPath) {
3522
+ try {
3523
+ await commitDocs(repoPath, "runcastle: docs the pipeline left uncommitted");
3524
+ } catch {}
3525
+ }
3256
3526
  function activeTestDriveFeatureId() {
3257
3527
  return testDriveState?.kind === "feature" ? testDriveState.featureId : undefined;
3258
3528
  }
@@ -3352,6 +3622,7 @@ async function testDrive(ctx, project, feature, action, purpose = "human") {
3352
3622
  ...teardown?.failure ? { hookFailure: teardown.failure } : {}
3353
3623
  };
3354
3624
  }
3625
+ await commitPipelineDocs(project.repoPath);
3355
3626
  const porcelain = (await g.raw(["status", "--porcelain"])).trim();
3356
3627
  if (porcelain !== "")
3357
3628
  return { ok: false, deniedReason: DENY_DIRTY };
@@ -4629,7 +4900,8 @@ function codexLoggedIn(env = process.env, fileExists = existsSync8) {
4629
4900
  var init_codex_auth = () => {};
4630
4901
 
4631
4902
  // src/doctor/doctor.ts
4632
- import { existsSync as existsSync9 } from "fs";
4903
+ import { existsSync as existsSync9, statSync } from "fs";
4904
+ import { fileURLToPath as fileURLToPath4 } from "url";
4633
4905
  async function versionProbe(exec, id, label, bin, fix) {
4634
4906
  const out = await exec(bin, ["--version"]);
4635
4907
  if (out.ok && out.code === 0) {
@@ -4836,15 +5108,29 @@ async function containerRuntimeProbe(exec) {
4836
5108
  fix: "Install Docker Desktop or Podman (see docs/research/PREREQS-NOTES.md \xA74). Not needed for interactive-only use."
4837
5109
  };
4838
5110
  }
4839
- async function sandcastleImageProbe(exec, imageName) {
5111
+ async function sandcastleImageProbe(exec, imageName, burnerDockerfile, fileMtime) {
4840
5112
  const id = "sandcastle-image";
4841
5113
  const label = "Sandcastle container image";
4842
5114
  for (const runtime of ["docker", "podman"]) {
4843
5115
  const present = await exec(runtime, ["--version"]);
4844
5116
  if (!(present.ok && present.code === 0))
4845
5117
  continue;
4846
- const inspect = await exec(runtime, ["image", "inspect", imageName]);
5118
+ const inspect = await exec(runtime, ["image", "inspect", "--format", "{{.Created}}", imageName]);
4847
5119
  if (inspect.ok && inspect.code === 0) {
5120
+ const created = new Date(inspect.stdout.trim());
5121
+ const dockerfileChanged = fileMtime(burnerDockerfile);
5122
+ if (dockerfileChanged > created) {
5123
+ const date = (value) => value.toISOString().slice(0, 10);
5124
+ return {
5125
+ id,
5126
+ label,
5127
+ tier: 2,
5128
+ status: "stale",
5129
+ severity: "error",
5130
+ detail: `${imageName} built ${date(created)}, burner Dockerfile changed ${date(dockerfileChanged)} \u2014 rebuild`,
5131
+ fix: 'Open Settings \u2192 AFK burns and click "Rebuild image".'
5132
+ };
5133
+ }
4848
5134
  return { id, label, tier: 2, status: "ok", severity: "error", detail: `${imageName} present` };
4849
5135
  }
4850
5136
  return {
@@ -4871,6 +5157,8 @@ async function runDoctor(env) {
4871
5157
  const { exec } = env;
4872
5158
  const processEnv = env.env ?? process.env;
4873
5159
  const imageName = env.imageName ?? DEFAULT_SANDBOX_IMAGE;
5160
+ const burnerDockerfile = env.burnerDockerfile ?? fileURLToPath4(new URL("../assets/sandcastle/Dockerfile", import.meta.url));
5161
+ const fileMtime = env.fileMtime ?? ((path) => statSync(path).mtime);
4874
5162
  const required = new Set(env.runtimes ?? [DEFAULT_RUNTIME]);
4875
5163
  const results = [
4876
5164
  await bunProbe(exec),
@@ -4885,7 +5173,7 @@ async function runDoctor(env) {
4885
5173
  severity: required.has(runtime) ? "error" : "info"
4886
5174
  }));
4887
5175
  }
4888
- results.push(await gitIdentityProbe(exec, env.cwd), await containerRuntimeProbe(exec), await sandcastleImageProbe(exec, imageName));
5176
+ results.push(await gitIdentityProbe(exec, env.cwd), await containerRuntimeProbe(exec), await sandcastleImageProbe(exec, imageName, burnerDockerfile, fileMtime));
4889
5177
  const counts = (r) => r.severity === "error";
4890
5178
  return {
4891
5179
  results,
@@ -4942,7 +5230,7 @@ import {
4942
5230
  mkdirSync as mkdirSync4,
4943
5231
  readFileSync as readFileSync4,
4944
5232
  readdirSync as readdirSync2,
4945
- statSync,
5233
+ statSync as statSync2,
4946
5234
  writeFileSync as writeFileSync3
4947
5235
  } from "fs";
4948
5236
  import { basename, dirname as dirname3, join as join10, resolve as resolve7 } from "path";
@@ -5061,7 +5349,7 @@ function gitInfoDir(worktreePath) {
5061
5349
  const dotGit = join10(worktreePath, ".git");
5062
5350
  if (!existsSync10(dotGit))
5063
5351
  return null;
5064
- if (statSync(dotGit).isDirectory())
5352
+ if (statSync2(dotGit).isDirectory())
5065
5353
  return join10(dotGit, "info");
5066
5354
  const named = /^gitdir:\s*(.+)$/m.exec(readFileSync4(dotGit, "utf8"));
5067
5355
  if (!named)
@@ -5173,7 +5461,7 @@ var init_runtimes = __esm(() => {
5173
5461
  });
5174
5462
 
5175
5463
  // src/launcher/sessions.ts
5176
- import { statSync as statSync2 } from "fs";
5464
+ import { statSync as statSync3 } from "fs";
5177
5465
  import { and as and5, desc as desc3, eq as eq5, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
5178
5466
  function createSessionRow(ctx, input) {
5179
5467
  const row = ctx.db.insert(sessions).values({
@@ -5524,7 +5812,7 @@ function transcriptBytes(session) {
5524
5812
  if (!session?.transcriptPath)
5525
5813
  return;
5526
5814
  try {
5527
- return statSync2(session.transcriptPath).size;
5815
+ return statSync3(session.transcriptPath).size;
5528
5816
  } catch {
5529
5817
  return;
5530
5818
  }
@@ -5639,7 +5927,8 @@ function rowToTicket(row) {
5639
5927
  error: row.error ?? undefined,
5640
5928
  attemptBranch: row.attemptBranch ?? undefined,
5641
5929
  conflictFiles: row.conflictFiles ?? undefined,
5642
- digest: row.digest ?? undefined
5930
+ digest: row.digest ?? undefined,
5931
+ originFindingId: row.originFindingId ?? undefined
5643
5932
  });
5644
5933
  }
5645
5934
  function resolveBlocking(inputs, startSeq) {
@@ -5664,13 +5953,13 @@ function normalizeModel(ctx, value) {
5664
5953
  function listByFeature2(ctx, featureId) {
5665
5954
  return ctx.db.select().from(tickets).where(eq6(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
5666
5955
  }
5667
- function storeTickets(ctx, featureId, inputs) {
5956
+ function storeTickets(ctx, featureId, inputs, options = {}) {
5668
5957
  if (inputs.length === 0)
5669
5958
  return [];
5670
5959
  const { lap } = getFeatureRow(ctx, featureId);
5671
5960
  const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq6(tickets.featureId, featureId)).all();
5672
5961
  const startSeq = existing.reduce((max2, r) => Math.max(max2, r.seq), 0) + 1;
5673
- const resolved = resolveBlocking(inputs, startSeq);
5962
+ const resolved = options.blockedByAreGlobal ? inputs.map((input, index2) => ({ seq: startSeq + index2, blockedBy: input.blockedBy })) : resolveBlocking(inputs, startSeq);
5674
5963
  const rows = inputs.map((t, i) => ({
5675
5964
  id: newId("tkt"),
5676
5965
  featureId,
@@ -5689,7 +5978,8 @@ function storeTickets(ctx, featureId, inputs) {
5689
5978
  error: null,
5690
5979
  attemptBranch: null,
5691
5980
  conflictFiles: null,
5692
- digest: null
5981
+ digest: null,
5982
+ originFindingId: t.originFindingId ?? null
5693
5983
  }));
5694
5984
  ctx.db.insert(tickets).values(rows).run();
5695
5985
  emit(ctx, featureId, {
@@ -6071,11 +6361,195 @@ var init_end_session = __esm(() => {
6071
6361
  init_registry();
6072
6362
  });
6073
6363
 
6364
+ // src/services/review-findings.ts
6365
+ import { and as and8, asc as asc4, eq as eq8, isNotNull as isNotNull2 } from "drizzle-orm";
6366
+ function rowToFinding(row) {
6367
+ return ReviewFinding.parse(row);
6368
+ }
6369
+ function getFinding(ctx, id) {
6370
+ const row = ctx.db.select().from(reviewFindings).where(eq8(reviewFindings.id, id)).get();
6371
+ if (!row)
6372
+ throw new NotFoundError(`review finding ${id} not found`);
6373
+ return rowToFinding(row);
6374
+ }
6375
+ function listByFeature3(ctx, featureId) {
6376
+ return ctx.db.select().from(reviewFindings).where(eq8(reviewFindings.featureId, featureId)).orderBy(asc4(reviewFindings.createdAt), asc4(reviewFindings.id)).all().map(rowToFinding);
6377
+ }
6378
+ function buildFixTicket(finding) {
6379
+ return {
6380
+ title: finding.title,
6381
+ goal: `Fix: ${finding.title}`,
6382
+ context: [
6383
+ `Location: ${finding.location}`,
6384
+ `Citation: ${finding.citation}`,
6385
+ `Detail: ${finding.detail}`,
6386
+ `Repro step: ${finding.reproStep}`
6387
+ ].join(`
6388
+
6389
+ `),
6390
+ acceptanceCriteria: [
6391
+ `The repro step no longer reproduces / the cited criterion holds: ${finding.reproStep}`
6392
+ ],
6393
+ seams: [],
6394
+ blockedBy: [],
6395
+ kind: "implementation",
6396
+ originFindingId: finding.id
6397
+ };
6398
+ }
6399
+ function reportFinding(ctx, args) {
6400
+ const { featureId, reviewTicket, input } = args;
6401
+ const feature = getFeatureRow(ctx, featureId);
6402
+ const id = newId("finding");
6403
+ const ticketCount = ctx.db.select({ id: reviewFindings.id }).from(reviewFindings).where(and8(eq8(reviewFindings.reviewTicketId, reviewTicket.id), isNotNull2(reviewFindings.fixTicketId))).all().length;
6404
+ const overCap = input.kind === "defect" && ticketCount >= AUTO_FIX_CAP;
6405
+ ctx.db.insert(reviewFindings).values({
6406
+ id,
6407
+ featureId,
6408
+ lap: feature.lap,
6409
+ reviewTicketId: reviewTicket.id,
6410
+ ...input,
6411
+ reproStep: input.reproStep ?? "",
6412
+ status: "open",
6413
+ openReason: overCap ? "over-cap" : null,
6414
+ failureReason: null,
6415
+ fixTicketId: null,
6416
+ createdAt: Date.now()
6417
+ }).run();
6418
+ let fixTicket = null;
6419
+ if (input.kind === "defect" && !overCap) {
6420
+ const finding2 = getFinding(ctx, id);
6421
+ fixTicket = storeTickets(ctx, featureId, [
6422
+ { ...buildFixTicket(finding2), blockedBy: [reviewTicket.seq] }
6423
+ ], { blockedByAreGlobal: true })[0];
6424
+ ctx.db.update(reviewFindings).set({ fixTicketId: fixTicket.id }).where(eq8(reviewFindings.id, id)).run();
6425
+ }
6426
+ const finding = getFinding(ctx, id);
6427
+ emit(ctx, featureId, {
6428
+ type: "finding.reported",
6429
+ message: `${input.kind} reported: ${input.title}`,
6430
+ ticketId: fixTicket?.id,
6431
+ data: { findingId: id, fixTicketId: fixTicket?.id, overCap }
6432
+ });
6433
+ return { finding, fixTicket, overCap };
6434
+ }
6435
+ function updateStatus(ctx, findingId, patch) {
6436
+ const current2 = getFinding(ctx, findingId);
6437
+ ctx.db.update(reviewFindings).set(patch).where(eq8(reviewFindings.id, findingId)).run();
6438
+ emit(ctx, current2.featureId, {
6439
+ type: "finding.updated",
6440
+ message: `finding ${findingId} marked ${patch.status}`,
6441
+ data: { findingId, status: patch.status, reason: patch.failureReason }
6442
+ });
6443
+ return getFinding(ctx, findingId);
6444
+ }
6445
+ function markFixing(ctx, findingId) {
6446
+ return updateStatus(ctx, findingId, {
6447
+ status: "fixing",
6448
+ openReason: null,
6449
+ failureReason: null
6450
+ });
6451
+ }
6452
+ function markFixed(ctx, findingId) {
6453
+ return updateStatus(ctx, findingId, {
6454
+ status: "fixed",
6455
+ openReason: null,
6456
+ failureReason: null
6457
+ });
6458
+ }
6459
+ function markFailed(ctx, findingId, reason) {
6460
+ return updateStatus(ctx, findingId, {
6461
+ status: "failed",
6462
+ openReason: "fix-failed",
6463
+ failureReason: reason ?? null
6464
+ });
6465
+ }
6466
+ function markFixProgress(ctx, findingId, progress, reason) {
6467
+ if (progress === "fixing")
6468
+ return markFixing(ctx, findingId);
6469
+ if (progress === "fixed")
6470
+ return markFixed(ctx, findingId);
6471
+ return markFailed(ctx, findingId, reason);
6472
+ }
6473
+ function dismiss(ctx, findingId) {
6474
+ return updateStatus(ctx, findingId, {
6475
+ status: "dismissed",
6476
+ openReason: null,
6477
+ failureReason: null
6478
+ });
6479
+ }
6480
+ function defectState(finding, fixTicket) {
6481
+ if (finding.status === "dismissed")
6482
+ return "dismissed";
6483
+ if (finding.status === "fixed" || fixTicket?.status === "done")
6484
+ return "fixed";
6485
+ if (fixTicket && fixTicket.status !== "failed" && fixTicket.status !== "cancelled") {
6486
+ return "fixing";
6487
+ }
6488
+ return finding.status === "open" || finding.status === "failed" ? "open" : "fixing";
6489
+ }
6490
+ function viewByFeature(ctx, featureId) {
6491
+ const findings = listByFeature3(ctx, featureId);
6492
+ const tickets2 = listByFeature2(ctx, featureId);
6493
+ const summary = { found: 0, fixed: 0, open: 0, observations: 0 };
6494
+ const openDefects = [];
6495
+ for (const finding of findings) {
6496
+ if (finding.kind === "observation") {
6497
+ summary.observations += 1;
6498
+ continue;
6499
+ }
6500
+ summary.found += 1;
6501
+ const state = defectState(finding, fixTicketOf(finding, tickets2));
6502
+ if (state === "fixed")
6503
+ summary.fixed += 1;
6504
+ else if (state === "open") {
6505
+ summary.open += 1;
6506
+ openDefects.push(finding);
6507
+ }
6508
+ }
6509
+ return { findings, openDefects, summary };
6510
+ }
6511
+ function fixTicketOf(finding, tickets2) {
6512
+ return finding.fixTicketId ? tickets2.find((t) => t.id === finding.fixTicketId) : undefined;
6513
+ }
6514
+ function promoteOpenDefects(ctx, featureId) {
6515
+ const defects = viewByFeature(ctx, featureId).openDefects;
6516
+ if (defects.length === 0)
6517
+ throw new InvalidInputError("no open defects to fix");
6518
+ const tickets2 = storeTickets(ctx, featureId, defects.map(buildFixTicket));
6519
+ const findings = defects.map((defect, index2) => {
6520
+ ctx.db.update(reviewFindings).set({
6521
+ status: "fixing",
6522
+ openReason: null,
6523
+ failureReason: null,
6524
+ fixTicketId: tickets2[index2].id
6525
+ }).where(eq8(reviewFindings.id, defect.id)).run();
6526
+ return getFinding(ctx, defect.id);
6527
+ });
6528
+ emit(ctx, featureId, {
6529
+ type: "finding.fixing",
6530
+ message: `${findings.length} open defect${findings.length === 1 ? "" : "s"} promoted to fix ticket${tickets2.length === 1 ? ` ${tickets2[0].seq}` : `s ${tickets2.map((t) => t.seq).join(", ")}`}`,
6531
+ data: { findingIds: findings.map((f) => f.id), seqs: tickets2.map((t) => t.seq) }
6532
+ });
6533
+ return { findings, tickets: tickets2 };
6534
+ }
6535
+ var AUTO_FIX_CAP = 8;
6536
+ var init_review_findings = __esm(() => {
6537
+ init_src();
6538
+ init_schema();
6539
+ init_errors();
6540
+ init_events();
6541
+ init_repo();
6542
+ init_tickets();
6543
+ });
6544
+
6074
6545
  // src/services/settings.ts
6075
6546
  import { existsSync as existsSync13, mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
6076
6547
  import { dirname as dirname4 } from "path";
6077
6548
  import * as z3 from "zod";
6078
- import { eq as eq8 } from "drizzle-orm";
6549
+ import { eq as eq9 } from "drizzle-orm";
6550
+ function hostDefaults(logicalCpus) {
6551
+ return { ...DEFAULTS, burnConcurrency: resolveDefaultBurnConcurrency(logicalCpus) };
6552
+ }
6079
6553
  function readRawConfig(configFile) {
6080
6554
  if (!existsSync13(configFile))
6081
6555
  return {};
@@ -6115,7 +6589,7 @@ function projectOverrides(ctx, projectId) {
6115
6589
  dbResetCommand: projects.dbResetCommand,
6116
6590
  driveSetupCommand: projects.driveSetupCommand,
6117
6591
  driveStopCommand: projects.driveStopCommand
6118
- }).from(projects).where(eq8(projects.id, projectId)).get();
6592
+ }).from(projects).where(eq9(projects.id, projectId)).get();
6119
6593
  return {
6120
6594
  model: row?.model ?? null,
6121
6595
  sandbox: row?.sandbox ?? null,
@@ -6148,15 +6622,16 @@ function resolveField(desc5, layers) {
6148
6622
  if (desc5.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc5.configKey)) {
6149
6623
  return { ...base, value: layers.fileRaw[desc5.configKey], source: "file", editable: true };
6150
6624
  }
6151
- const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
6625
+ const value = desc5.configKey ? layers.defaults[desc5.configKey] ?? null : null;
6152
6626
  return { ...base, value, source: "default", editable: true };
6153
6627
  }
6154
6628
  function getSettings(ctx, projectId, io = {}) {
6155
6629
  const env = io.env ?? process.env;
6156
6630
  const fileRaw = readRawConfig(io.configFile ?? configPath());
6157
6631
  const overrides = projectId !== undefined ? projectOverrides(ctx, projectId) : null;
6632
+ const defaults = hostDefaults(io.logicalCpus ?? hostLogicalCpus());
6158
6633
  const visible = DESCRIPTORS.filter((d) => projectId !== undefined || d.configKey !== undefined);
6159
- const fields = visible.map((d) => resolveField(d, { env, fileRaw, overrides }));
6634
+ const fields = visible.map((d) => resolveField(d, { env, fileRaw, overrides, defaults }));
6160
6635
  return { projectId, fields: [...fields, ...stepModelFields(fileRaw)] };
6161
6636
  }
6162
6637
  function updateSettings(ctx, input, io = {}) {
@@ -6179,7 +6654,7 @@ function updateSettings(ctx, input, io = {}) {
6179
6654
  if (!toProject)
6180
6655
  throw new InvalidInputError(`${desc5.key} cannot be cleared`);
6181
6656
  const project = requireProjectById(ctx, input.projectId);
6182
- ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq8(projects.id, project.id)).run();
6657
+ ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq9(projects.id, project.id)).run();
6183
6658
  recordHuman(ctx, project.id, desc5.key, null);
6184
6659
  emitProject(ctx, project.id, {
6185
6660
  type: "settings.updated",
@@ -6195,7 +6670,7 @@ function updateSettings(ctx, input, io = {}) {
6195
6670
  const value = parsed.data;
6196
6671
  if (toProject) {
6197
6672
  const project = requireProjectById(ctx, input.projectId);
6198
- ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq8(projects.id, project.id)).run();
6673
+ ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq9(projects.id, project.id)).run();
6199
6674
  recordHuman(ctx, project.id, desc5.key, String(value));
6200
6675
  emitProject(ctx, project.id, {
6201
6676
  type: "settings.updated",
@@ -6274,6 +6749,7 @@ function field(view, key) {
6274
6749
  var GLOBAL_EVENT_KEY = "global", idEnv = (raw) => raw, DESCRIPTORS, DEFAULTS, STEP_SET;
6275
6750
  var init_settings = __esm(() => {
6276
6751
  init_src();
6752
+ init_config_load();
6277
6753
  init_paths();
6278
6754
  init_schema();
6279
6755
  init_errors();
@@ -6440,7 +6916,7 @@ var init_settings = __esm(() => {
6440
6916
  // src/services/setup.ts
6441
6917
  import { copyFileSync as copyFileSync2, existsSync as existsSync14, mkdirSync as mkdirSync7, readFileSync as readFileSync7, readdirSync as readdirSync4, writeFileSync as writeFileSync6 } from "fs";
6442
6918
  import { dirname as dirname5, join as join12 } from "path";
6443
- import { fileURLToPath as fileURLToPath4 } from "url";
6919
+ import { fileURLToPath as fileURLToPath5 } from "url";
6444
6920
  async function writeGitIdentity(exec, identity) {
6445
6921
  const name = identity.name.trim();
6446
6922
  const email = identity.email.trim();
@@ -6543,7 +7019,7 @@ async function resolveRuntime(exec, preferred) {
6543
7019
  function resolveSandcastleBin() {
6544
7020
  try {
6545
7021
  const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
6546
- let dir = dirname5(fileURLToPath4(mainUrl));
7022
+ let dir = dirname5(fileURLToPath5(mainUrl));
6547
7023
  for (let hops = 0;hops < 6; hops++) {
6548
7024
  const manifestPath = join12(dir, "package.json");
6549
7025
  if (existsSync14(manifestPath)) {
@@ -6578,22 +7054,19 @@ function terminalSpec(kind, opts) {
6578
7054
  };
6579
7055
  }
6580
7056
  function sandcastleTemplateDir() {
6581
- return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath4(new URL("../assets/sandcastle", import.meta.url)));
7057
+ return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath5(new URL("../assets/sandcastle", import.meta.url)));
6582
7058
  }
6583
- function scaffoldSandcastleConfig(templateDir, targetDir) {
7059
+ function refreshSandcastleConfig(templateDir, targetDir) {
6584
7060
  const dir = join12(targetDir, ".sandcastle");
6585
- if (existsSync14(dir))
6586
- return { scaffolded: false, dir };
6587
7061
  mkdirSync7(dir, { recursive: true });
6588
7062
  for (const name of readdirSync4(templateDir)) {
6589
7063
  copyFileSync2(join12(templateDir, name), join12(dir, name));
6590
7064
  }
6591
- return { scaffolded: true, dir };
7065
+ return dir;
6592
7066
  }
6593
- function prepareSandboxBuildContext() {
6594
- const target = sandboxBuildDir();
7067
+ function prepareSandboxBuildContext(templateDir = sandcastleTemplateDir(), target = sandboxBuildDir()) {
6595
7068
  mkdirSync7(target, { recursive: true });
6596
- scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
7069
+ refreshSandcastleConfig(templateDir, target);
6597
7070
  return target;
6598
7071
  }
6599
7072
  function createCredentialVerifier(exec, runtime = DEFAULT_RUNTIME) {
@@ -6661,6 +7134,32 @@ var init_setup = __esm(() => {
6661
7134
  };
6662
7135
  });
6663
7136
 
7137
+ // src/doctor/system-exec.ts
7138
+ import { spawn as spawn4 } from "child_process";
7139
+ function createSystemExec(opts = {}) {
7140
+ return (command, args) => new Promise((resolve9) => {
7141
+ const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
7142
+ let stdout = "";
7143
+ let stderr = "";
7144
+ const child = spawn4(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
7145
+ child.stdout?.on("data", (d) => {
7146
+ stdout += d.toString();
7147
+ });
7148
+ child.stderr?.on("data", (d) => {
7149
+ stderr += d.toString();
7150
+ });
7151
+ child.on("error", (err) => {
7152
+ resolve9({ ok: false, code: null, stdout, stderr: stderr || String(err) });
7153
+ });
7154
+ child.on("close", (code) => {
7155
+ resolve9({ ok: true, code: code ?? 0, stdout, stderr });
7156
+ });
7157
+ });
7158
+ }
7159
+ var init_system_exec = __esm(() => {
7160
+ init_resolve_executable();
7161
+ });
7162
+
6664
7163
  // src/services/agent-stream.ts
6665
7164
  function beginTranscript(ticketId) {
6666
7165
  transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
@@ -6716,13 +7215,15 @@ function renderGuardScript(rules = GUARD_RULES) {
6716
7215
  "command -v jq >/dev/null 2>&1 || exit 0",
6717
7216
  'tool=$(printf %s "$payload" | jq -r ".tool_name // empty" 2>/dev/null || true)',
6718
7217
  '[ "$tool" = "Bash" ] || exit 0',
6719
- 'cmd=$(printf %s "$payload" | jq -r ".tool_input.command // empty" 2>/dev/null || true)',
6720
- '[ -n "$cmd" ] || exit 0',
7218
+ 'raw_cmd=$(printf %s "$payload" | jq -r ".tool_input.command // empty" 2>/dev/null || true)',
7219
+ '[ -n "$raw_cmd" ] || exit 0',
6721
7220
  "",
6722
7221
  "# Match against the command with quoted spans blanked, so an argument is",
6723
7222
  '# never read as a command: `grep -rn "git stash" docs/` searches for a',
6724
- "# string, it does not run one.",
6725
- `cmd=$(printf %s "$cmd" | sed "s/'[^']*'/ /g; s/\\"[^\\"]*\\"/ /g")`,
7223
+ "# string, it does not run one. Flatten newlines so one grep sees a whole",
7224
+ "# multiline loop.",
7225
+ "cmd=$raw_cmd",
7226
+ `cmd=$(printf %s "$cmd" | sed "s/'[^']*'/ /g; s/\\"[^\\"]*\\"/ /g" | tr '\\n' ' ')`,
6726
7227
  "",
6727
7228
  "deny() {",
6728
7229
  ` printf %s "$1" | jq -R -s '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:.}}'`,
@@ -6731,7 +7232,8 @@ function renderGuardScript(rules = GUARD_RULES) {
6731
7232
  ""
6732
7233
  ];
6733
7234
  for (const rule of rules) {
6734
- lines.push(`# ${rule.id}`, `if printf %s "$cmd" | grep -Eq -- ${shSingleQuote(rule.pattern)}; then`, ` deny ${shSingleQuote(rule.reason)}`, "fi", "");
7235
+ const commandVariable = rule.inspectQuotedArguments ? "$raw_cmd" : "$cmd";
7236
+ lines.push(`# ${rule.id}`, `if printf %s "${commandVariable}" | grep -Eq -- ${shSingleQuote(rule.pattern)}; then`, ` deny ${shSingleQuote(rule.reason)}`, "fi", "");
6735
7237
  }
6736
7238
  lines.push("exit 0", "");
6737
7239
  return lines.join(`
@@ -6781,11 +7283,37 @@ var init_burn_guard = __esm(() => {
6781
7283
  pattern: "--maxWorkers|--runInBand|--pool=|--shard=|singleFork|--poolOptions",
6782
7284
  reason: `Do not override the test runner's concurrency. Measured in this sandbox, a suite that runs in ~55s at its configured concurrency takes 10-20 minutes serialised, so this "safe" flag is the single most expensive habit in a burn. Run the repo's test command as configured. If it is killed for memory, run only the test files your change touches and say so in your final message.`
6783
7285
  },
7286
+ {
7287
+ id: "no-long-sleep",
7288
+ pattern: `${CMD_START}sleep[[:space:]]+((3[1-9]|[4-9][0-9]|[1-9][0-9]{2,})s?|[1-9][0-9]*(m|h|d))([^A-Za-z0-9_]|$)`,
7289
+ reason: "Polling and long sleeps are never needed in a burn. Run the command directly and read its output."
7290
+ },
7291
+ {
7292
+ id: "no-verification-polling-loop",
7293
+ pattern: `${CMD_START}(until|while)[[:space:]].*(tsc|typecheck|vitest|jest|pytest|mocha|(npm|pnpm|yarn|bun)[[:space:]]+(run[[:space:]]+)?test|turbo[[:space:]]+run[[:space:]]+test).*[;[:space:]][[:space:]]*done`,
7294
+ reason: "Polling and long sleeps are never needed in a burn. Run the command directly and read its output."
7295
+ },
6784
7296
  {
6785
7297
  id: "no-interpreter-heredoc-edit",
6786
7298
  pattern: `${CMD_START}(python3?|node|bun|perl|ruby)[[:space:]]+-[[:space:]]*<<`,
6787
7299
  reason: "Do not rewrite files by piping a heredoc into an interpreter \u2014 individual such calls have been measured at 29s, 57s, 120s and 761s, and a half-written file survives if this process dies mid-command. Use the Edit tool, which is faster, atomic, and fails loudly when its target text is not found."
6788
7300
  },
7301
+ {
7302
+ id: "no-interpreter-inline-edit",
7303
+ pattern: `${CMD_START}node[[:space:]]+-e([[:space:]]|$)`,
7304
+ reason: "Do not rewrite files with a `node -e` program. Use the Edit tool, which is faster, atomic, and fails loudly when its target text is not found."
7305
+ },
7306
+ {
7307
+ id: "no-perl-in-place-edit",
7308
+ pattern: `${CMD_START}perl[[:space:]]+-[0-9p]*i[^[:space:]]*`,
7309
+ reason: "Do not rewrite files with Perl in-place editing. Use the Edit tool, which is faster, atomic, and fails loudly when its target text is not found."
7310
+ },
7311
+ {
7312
+ id: "no-sed-multi-range-edit",
7313
+ pattern: `${CMD_START}sed[[:space:]]+-[^[:space:]]*i[^[:space:]]*[[:space:]]+[^;]*,[^;]*d;[^;]*,[^;]*d`,
7314
+ inspectQuotedArguments: true,
7315
+ reason: "Do not perform multi-range file surgery with `sed -i`. Use the Edit tool, which is atomic and fails loudly when its target text is not found."
7316
+ },
6789
7317
  {
6790
7318
  id: "no-cat-heredoc-edit",
6791
7319
  pattern: `${CMD_START}cat[[:space:]]+>>?[[:space:]]*[^[:space:]|]+[[:space:]]*<<`,
@@ -6832,6 +7360,37 @@ function findOnPath(bin, env = process.env, platform = process.platform) {
6832
7360
  }
6833
7361
  return;
6834
7362
  }
7363
+ function buildDriveAvailability(browserPath, devCommand) {
7364
+ const missing = [];
7365
+ if (!browserPath) {
7366
+ missing.push(`\`${AGENT_BROWSER_BIN}\` is not on this machine's PATH, so there is no browser to walk the app with`);
7367
+ }
7368
+ if (!devCommand?.trim()) {
7369
+ missing.push("this project has no dev command configured, so a drive has no app to boot");
7370
+ }
7371
+ if (missing.length === 0) {
7372
+ return `A drive **is** available: \`${AGENT_BROWSER_BIN}\` is on this machine's PATH and the ` + "project has a dev command, so `review_drive` can boot the app and you can walk it. Drive " + "mode is open to you \u2014 take it if, and only if, this lap has a surface a human could operate.";
7373
+ }
7374
+ return `A drive is **not** available: ${missing.join(", and ")}. So the mode is already decided \u2014 ` + "run Gates mode, whatever this lap touched, and do not call `review_drive`. This is not a " + "degraded review; it is the whole review this lap gets.";
7375
+ }
7376
+ function buildGateNotes(config2) {
7377
+ const commands = config2.verifyCommands?.trim();
7378
+ const failures = config2.knownFailures?.trim();
7379
+ const out = [];
7380
+ if (commands) {
7381
+ out.push("Run exactly these, once each \u2014 they are this project's own verify commands, so do not go looking for alternatives, add concurrency flags, or re-run one to re-read its output (redirect to a file and read that instead):", "", "```", commands, "```");
7382
+ } else {
7383
+ out.push("This project has no verify commands configured, so there are no gates to run. Do not go hunting for them \u2014 say so in one line of your summary note and spend the whole mode on the diff.");
7384
+ }
7385
+ out.push("");
7386
+ if (failures) {
7387
+ out.push("These already fail on this repo without this lap's help:", "", "```", failures, "```", "", "Subtract that baseline: a failure inside it is not this lap's and is not a finding. A failure outside it is one this lap introduced, and it is the finding worth the human's attention above every other.");
7388
+ } else {
7389
+ out.push("No pre-existing-failure baseline is configured, so a red gate may well predate this lap. Run it once, then check whether the failure touches the diff before writing it up as this lap's \u2014 and never re-run a suite to establish what was already red.");
7390
+ }
7391
+ return out.join(`
7392
+ `);
7393
+ }
6835
7394
  function writeReviewArtifacts(ticket, runId, config2) {
6836
7395
  const dir = reviewDir(ticket.id);
6837
7396
  rmSync2(dir, { recursive: true, force: true });
@@ -6853,13 +7412,19 @@ async function releaseDriveQuietly() {
6853
7412
  } catch {}
6854
7413
  }
6855
7414
  async function executeReviewTicket(ctx, ticket, deps) {
7415
+ const startedAt = Date.now();
7416
+ const timer = createToolTimer();
7417
+ try {
7418
+ return await reviewTicketOutcome(ctx, ticket, deps, timer);
7419
+ } finally {
7420
+ emitTicketTiming(ctx, ticket, buildTicketTiming(timer.summary(), startedAt, Date.now()));
7421
+ }
7422
+ }
7423
+ async function reviewTicketOutcome(ctx, ticket, deps, timer) {
6856
7424
  const { project, feature } = ctx;
6857
7425
  if (!feature.baseBranch) {
6858
7426
  return couldNotReview(ticket, `feature ${feature.slug} has no recorded base branch, so there is nothing to diff \`${feature.branch}\` against.`);
6859
7427
  }
6860
- if (!findOnPath(AGENT_BROWSER_BIN)) {
6861
- 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.`);
6862
- }
6863
7428
  const artifacts = writeReviewArtifacts(ticket, ctx.runId, deps.config);
6864
7429
  const prompt = renderReviewPrompt(readFileSync8(reviewTemplatePath(), "utf8"), {
6865
7430
  TICKET_JSON: buildTicketJson(ticket),
@@ -6868,6 +7433,8 @@ async function executeReviewTicket(ctx, ticket, deps) {
6868
7433
  LAP_DIGESTS: buildLapDigestsBlock(deps.lapDigests),
6869
7434
  FEATURE_BRANCH: feature.branch,
6870
7435
  BASE_BRANCH: feature.baseBranch,
7436
+ DRIVE_AVAILABILITY: buildDriveAvailability(findOnPath(AGENT_BROWSER_BIN), project.devCommand),
7437
+ GATE_NOTES: buildGateNotes(deps.config),
6871
7438
  DIGEST_PATH: artifacts.digestPath,
6872
7439
  BLOCKED_PATH: artifacts.blockedPath,
6873
7440
  WALKTHROUGH_PATH: artifacts.walkthroughPath
@@ -6877,6 +7444,7 @@ async function executeReviewTicket(ctx, ticket, deps) {
6877
7444
  beginTranscript(ticket.id);
6878
7445
  const onStreamEvent = (event) => {
6879
7446
  throttle.onEvent(event);
7447
+ timer.onEvent(event);
6880
7448
  if (event.type === "text") {
6881
7449
  appendTranscript(ticket.id, { kind: "text", text: event.message });
6882
7450
  } else if (event.type === "toolCall") {
@@ -6951,11 +7519,15 @@ var init_review_ticket = __esm(() => {
6951
7519
  // src/workflows/ticket-burner.ts
6952
7520
  import { existsSync as existsSync16, mkdirSync as mkdirSync9, readFileSync as readFileSync9, readdirSync as readdirSync5, rmSync as rmSync3 } from "fs";
6953
7521
  import { dirname as dirname6, join as join14, posix as posixPath, win32 as winPath } from "path";
6954
- import { fileURLToPath as fileURLToPath5 } from "url";
7522
+ import { fileURLToPath as fileURLToPath6 } from "url";
6955
7523
  import { claudeCode, codex, run as run2 } from "@ai-hero/sandcastle";
6956
7524
  import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
6957
7525
  import { podman } from "@ai-hero/sandcastle/sandboxes/podman";
6958
7526
  import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
7527
+ function missingImageRuntimeMessage(runtime, image) {
7528
+ const binary = RUNTIME_BINARY[runtime];
7529
+ return `${binary} is not installed in image ${image} \u2014 the image predates the burner Dockerfile. Rebuild it from Settings \u2192 AFK burns (Rebuild image).`;
7530
+ }
6959
7531
  function burnAuthReady(runtime, token, loggedIn = codexLoggedIn) {
6960
7532
  if (token !== undefined)
6961
7533
  return true;
@@ -7158,7 +7730,9 @@ function buildDriveNotes(settings) {
7158
7730
  `);
7159
7731
  }
7160
7732
  function buildGuardNotes(guardInstalled) {
7161
- return guardInstalled ? "Three of the rules below are enforced by a tool hook, not merely stated: `git stash`, test-runner concurrency flags, and rewriting files through interpreter heredocs are **denied before they run**. A denial is policy, not a broken environment \u2014 read its reason, take the alternative it names, and carry on. Do not try to route around it." : "Nothing below is machine-enforced in this burn \u2014 the deny hook is not installed, so the rules hold on your discipline alone. They are not style preferences: each one is a measured way burns lose work.";
7733
+ const introduction = guardInstalled ? "The following commands are **denied before they run** by a tool hook. Each denial and its prescribed alternative comes verbatim from the guard that enforces it. A denial is policy, not a broken environment \u2014 follow the alternative and do not try to route around it." : "The deny hook is not installed in this burn, so the following guard rules are not machine-enforced. Follow them on discipline alone; each denial and its prescribed alternative comes verbatim from the guard used in protected burns.";
7734
+ return [introduction, "", ...GUARD_RULES.map((rule) => `- ${rule.reason}`)].join(`
7735
+ `);
7162
7736
  }
7163
7737
  function buildBlockersBlock(blockedBy, digests) {
7164
7738
  if (blockedBy.length === 0) {
@@ -7181,6 +7755,16 @@ ${d.digest.trim()}`;
7181
7755
  return [head, "", "In their own words:", "", ...bodies].join(`
7182
7756
  `);
7183
7757
  }
7758
+ function buildFixNotes(ticket) {
7759
+ if (!ticket.originFindingId)
7760
+ return "";
7761
+ return [
7762
+ "### This ticket fixes a review finding",
7763
+ "",
7764
+ "It was minted from a defect the review of this same lap reported, and its `context` carries the reviewer's repro step verbatim. **Before you declare done, re-run that repro step exactly, and say in your digest that you re-ran it and what happened.** Nothing reviews your change afterwards \u2014 that repro step is the check, and the verify commands run on top of it."
7765
+ ].join(`
7766
+ `);
7767
+ }
7184
7768
  function buildLapDigestsBlock(digests) {
7185
7769
  if (digests.length === 0) {
7186
7770
  return "_No implementation ticket in this burn left a digest \u2014 the diff and the feature docs are all you have._";
@@ -7217,13 +7801,13 @@ function parseEnvFile2(content) {
7217
7801
  if (line.length === 0 || line.startsWith("#"))
7218
7802
  continue;
7219
7803
  const body = line.startsWith("export ") ? line.slice(7).trim() : line;
7220
- const eq9 = body.indexOf("=");
7221
- if (eq9 === -1)
7804
+ const eq10 = body.indexOf("=");
7805
+ if (eq10 === -1)
7222
7806
  continue;
7223
- const key = body.slice(0, eq9).trim();
7807
+ const key = body.slice(0, eq10).trim();
7224
7808
  if (key.length === 0)
7225
7809
  continue;
7226
- let value = body.slice(eq9 + 1).trim();
7810
+ let value = body.slice(eq10 + 1).trim();
7227
7811
  if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
7228
7812
  value = value.slice(1, -1);
7229
7813
  }
@@ -7271,6 +7855,16 @@ function cacheMountFor(pm, hostPath) {
7271
7855
  const sandboxPath = PM_CACHE_SANDBOX_PATHS[pm];
7272
7856
  return sandboxPath ? { hostPath, sandboxPath } : undefined;
7273
7857
  }
7858
+ function buildBurnCacheMounts(slot, projectId, sandbox, pm) {
7859
+ if (slot !== undefined) {
7860
+ return {
7861
+ mounts: [{ volume: burnCacheVolumeName(projectId), sandboxPath: BURN_CACHE_MOUNT }],
7862
+ env: pm ? burnCacheEnv(pm) : {}
7863
+ };
7864
+ }
7865
+ const mount = sandbox !== "noSandbox" && pm ? cacheMountFor(pm, burnCacheDir(pm)) : undefined;
7866
+ return { mounts: mount ? [mount] : [], env: {} };
7867
+ }
7274
7868
  function codexAuthMountFor(runtime, sandbox, env = process.env, loggedIn = codexLoggedIn) {
7275
7869
  if (runtime !== "codex" || sandbox === "noSandbox" || !loggedIn(env))
7276
7870
  return;
@@ -7284,6 +7878,8 @@ function chainSetupCommands(...steps) {
7284
7878
  return present.length > 0 ? present.join(" && ") : undefined;
7285
7879
  }
7286
7880
  function resolveBurnWorkspaceMode(config2, platform = process.platform) {
7881
+ if (resolveBurnCacheMode(config2) === "volume")
7882
+ return "slot";
7287
7883
  if (config2.sandbox === "noSandbox")
7288
7884
  return "mounted";
7289
7885
  if (config2.burnWorkspace === "auto")
@@ -7291,26 +7887,84 @@ function resolveBurnWorkspaceMode(config2, platform = process.platform) {
7291
7887
  return config2.burnWorkspace;
7292
7888
  }
7293
7889
  function buildIsolatedSetupCommand(tempBranch, setupCommand, pm) {
7294
- const hookFile = `${ISOLATED_REPO_PATH}/.git/hooks/post-commit`;
7295
- const attachmentsDir = `${SANDBOX_WORKSPACE_PATH}/${ATTACHMENTS_DIR}`;
7296
- const cloneAttachmentsDir = `${ISOLATED_REPO_PATH}/${ATTACHMENTS_DIR}`;
7297
- const parts = [
7890
+ const steps = buildRepoSetupSteps(ISOLATED_REPO_PATH, tempBranch, setupCommand, pm);
7891
+ return [
7298
7892
  `git config --global --add safe.directory '*'`,
7299
7893
  `git clone ${SANDBOX_WORKSPACE_PATH} ${ISOLATED_REPO_PATH}`,
7300
- `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`,
7301
- `printf '#!/bin/sh\\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE\\ngit push --quiet origin HEAD:%s && exec git -C ${SANDBOX_WORKSPACE_PATH} reset --hard --quiet %s\\n' '${tempBranch}' '${tempBranch}' > ${hookFile}`,
7894
+ ...steps.pre,
7895
+ ...steps.install,
7896
+ ...steps.post
7897
+ ].join(" && ");
7898
+ }
7899
+ function buildRepoSetupSteps(repoPath, tempBranch, setupCommand, pm) {
7900
+ const hookFile = `${repoPath}/.git/hooks/post-commit`;
7901
+ const attachmentsDir = `${SANDBOX_WORKSPACE_PATH}/${ATTACHMENTS_DIR}`;
7902
+ const repoAttachmentsDir = `${repoPath}/${ATTACHMENTS_DIR}`;
7903
+ const excludeFile = `${repoPath}/.git/info/exclude`;
7904
+ const pre = [
7905
+ `if [ -d "${attachmentsDir}" ]; then mkdir -p "${repoAttachmentsDir}" && cp -r "${attachmentsDir}/." "${repoAttachmentsDir}/" && mkdir -p "${repoPath}/.git/info" && { grep -qxF '${ATTACHMENTS_DIR}/' "${excludeFile}" 2>/dev/null || printf '%s\\n' '${ATTACHMENTS_DIR}/' >> "${excludeFile}"; }; fi`,
7906
+ `printf '#!/bin/sh\\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE\\ngit push --quiet origin HEAD:%s && exit 0\\nsleep 2\\ngit push --quiet origin HEAD:%s && exit 0\\necho "runcastle: commit sync failed (will retry on your next commit); do not re-commit" >&2\\nexit 0\\n' '${tempBranch}' '${tempBranch}' > ${hookFile}`,
7302
7907
  `chmod +x ${hookFile}`
7303
7908
  ];
7304
7909
  if (pm === "pnpm" || pm === "yarn") {
7305
7910
  const shim = `$HOME/.local/bin/${pm}`;
7306
- parts.push(`mkdir -p "$HOME/.local/bin"`, `printf '#!/bin/sh\\nexec corepack ${pm} "$@"\\n' > "${shim}"`, `chmod +x "${shim}"`);
7911
+ pre.push(`mkdir -p "$HOME/.local/bin"`, `printf '#!/bin/sh\\nexec corepack ${pm} "$@"\\n' > "${shim}"`, `chmod +x "${shim}"`);
7912
+ }
7913
+ return {
7914
+ pre,
7915
+ install: setupCommand ? [`cd ${repoPath} && ${setupCommand}`] : [],
7916
+ post: [`git -C ${repoPath} config core.hooksPath ${repoPath}/.git/hooks`]
7917
+ };
7918
+ }
7919
+ function parseSetupMarker(text2) {
7920
+ const m = new RegExp(String.raw`${SETUP_MARKER} cold=([01]) sync_ms=(\d+) install_ms=(\d+)`).exec(text2);
7921
+ if (!m)
7922
+ return;
7923
+ return { cold: m[1] === "1", syncMs: Number(m[2]), installMs: Number(m[3]) };
7924
+ }
7925
+ function takeSetupMarker(worktreePath) {
7926
+ const file = join14(worktreePath, SETUP_MARKER_FILE);
7927
+ let text2;
7928
+ try {
7929
+ text2 = readFileSync9(file, "utf8");
7930
+ } catch {
7931
+ return;
7307
7932
  }
7308
- if (setupCommand)
7309
- parts.push(`cd ${ISOLATED_REPO_PATH} && ${setupCommand}`);
7310
- parts.push(`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks`);
7311
- return parts.join(" && ");
7933
+ rmSync3(file, { force: true });
7934
+ return parseSetupMarker(text2);
7312
7935
  }
7313
- function buildWorkspaceNotes(mode) {
7936
+ function fmtSeconds(ms) {
7937
+ return `${(ms / 1000).toFixed(1)}s`;
7938
+ }
7939
+ function buildSlotStamp(imageName, packageManagerField) {
7940
+ const pm = packageManagerField?.split("@")[0] ?? "none";
7941
+ const major = packageManagerField?.split("@")[1]?.split(".")[0] ?? "any";
7942
+ return `${imageName} node=$(node --version 2>/dev/null) pm=${pm}@${major}`;
7943
+ }
7944
+ function buildSlotSetupCommand(slot, tempBranch, setupCommand, pm, stamp) {
7945
+ const repo = slotRepoPath(slot);
7946
+ const stampFile = slotStampPath(slot);
7947
+ const steps = buildRepoSetupSteps(repo, tempBranch, setupCommand, pm);
7948
+ return [
7949
+ `git config --global --add safe.directory '*'`,
7950
+ `RC_COLD=0`,
7951
+ `RC_STAMP="${stamp}"`,
7952
+ `RC_SYNC_START=$(date +%s%3N)`,
7953
+ `mkdir -p ${slotDirPath(slot)}`,
7954
+ `rm -f ${repo}/.git/*.lock`,
7955
+ `if ! git -C ${repo} rev-parse --git-dir >/dev/null 2>&1; then rm -rf ${repo} && git clone ${SANDBOX_WORKSPACE_PATH} ${repo} && RC_COLD=1; else git -C ${repo} fetch ${SANDBOX_WORKSPACE_PATH} ${tempBranch} && git -C ${repo} reset --hard FETCH_HEAD && git -C ${repo} checkout -B ${tempBranch}; fi`,
7956
+ `git -C ${repo} clean -fd`,
7957
+ `if [ "$(cat ${stampFile} 2>/dev/null)" != "$RC_STAMP" ]; then rm -rf ${repo}/node_modules && git -C ${repo} clean -fdX && RC_COLD=1 && printf '%s\\n' "$RC_STAMP" > ${stampFile}; fi`,
7958
+ ...steps.pre,
7959
+ `RC_SYNC_MS=$(( $(date +%s%3N) - RC_SYNC_START ))`,
7960
+ `RC_INSTALL_START=$(date +%s%3N)`,
7961
+ ...steps.install,
7962
+ `RC_INSTALL_MS=$(( $(date +%s%3N) - RC_INSTALL_START ))`,
7963
+ ...steps.post,
7964
+ `printf '${SETUP_MARKER} cold=%s sync_ms=%s install_ms=%s\\n' "$RC_COLD" "$RC_SYNC_MS" "$RC_INSTALL_MS" > ${SANDBOX_WORKSPACE_PATH}/${SETUP_MARKER_FILE}`
7965
+ ].join(" && ");
7966
+ }
7967
+ function buildWorkspaceNotes(mode, repoPath = ISOLATED_REPO_PATH) {
7314
7968
  if (mode === "mounted") {
7315
7969
  return [
7316
7970
  "Work in the current directory \u2014 it is the repo checkout on your branch.",
@@ -7322,13 +7976,13 @@ function buildWorkspaceNotes(mode) {
7322
7976
  `);
7323
7977
  }
7324
7978
  return [
7325
- `Your working repository is \`${ISOLATED_REPO_PATH}\` \u2014 a clone on the container's fast native filesystem, with dependencies already installed. Do ALL work there: \`cd ${ISOLATED_REPO_PATH}\` first; every file you read, edit, test, and commit lives under it.`,
7979
+ `Your working repository is \`${repoPath}\` \u2014 a clone on the container's fast native filesystem, with dependencies already installed. Do ALL work there: \`cd ${repoPath}\` first; every file you read, edit, test, and commit lives under it.`,
7326
7980
  "",
7327
- `The directory you start in (\`${SANDBOX_WORKSPACE_PATH}\`) is a slow mounted mirror used only to collect your commits \u2014 never edit files, install, or run tests there. Your commits sync back automatically (a post-commit hook pushes them); just commit as normal. If you re-run the dependency install and it reconfigures git hooks (husky), run \`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks\` afterwards so the sync hook stays armed.`,
7981
+ `The directory you start in (\`${SANDBOX_WORKSPACE_PATH}\`) is a slow mounted mirror used only to collect your commits \u2014 never edit files, install, or run tests there. Your commits sync back automatically (a post-commit hook pushes them); just commit as normal. If you re-run the dependency install and it reconfigures git hooks (husky), run \`git -C ${repoPath} config core.hooksPath ${repoPath}/.git/hooks\` afterwards so the sync hook stays armed.`,
7328
7982
  "",
7329
- `If you are blocked and write \`BLOCKED.md\`, write it at \`${ISOLATED_REPO_PATH}/BLOCKED.md\` AND copy it to \`${SANDBOX_WORKSPACE_PATH}/BLOCKED.md\` so the orchestrator can see it.`,
7983
+ `If you are blocked and write \`BLOCKED.md\`, write it at \`${repoPath}/BLOCKED.md\` AND copy it to \`${SANDBOX_WORKSPACE_PATH}/BLOCKED.md\` so the orchestrator can see it.`,
7330
7984
  "",
7331
- `Write \`DIGEST.md\` at \`${SANDBOX_WORKSPACE_PATH}/DIGEST.md\` \u2014 the mounted mirror, so the host can see it \u2014 and NOT inside \`${ISOLATED_REPO_PATH}\`, where it would be committed with your work.`,
7985
+ `Write \`DIGEST.md\` at \`${SANDBOX_WORKSPACE_PATH}/DIGEST.md\` \u2014 the mounted mirror, so the host can see it \u2014 and NOT inside \`${repoPath}\`, where it would be committed with your work.`,
7332
7986
  "",
7333
7987
  "**Those paths are the only authoritative ones** \u2014 nothing later in this prompt overrides them."
7334
7988
  ].join(`
@@ -7473,8 +8127,9 @@ function createToolTimer() {
7473
8127
  if (event.type === "raw")
7474
8128
  return;
7475
8129
  const at = event.timestamp.getTime();
7476
- if (iteration !== null && event.iteration !== iteration)
7477
- pending = null;
8130
+ if (iteration !== null && event.iteration !== iteration && pending) {
8131
+ pending = { category: "setup", at: pending.at };
8132
+ }
7478
8133
  iteration = event.iteration;
7479
8134
  if (pending) {
7480
8135
  const gap = at - pending.at;
@@ -7491,7 +8146,11 @@ function createToolTimer() {
7491
8146
  pending = { category: "model", at };
7492
8147
  }
7493
8148
  };
7494
- return { onEvent, summary: () => ({ totalMs, calls, byCategory }) };
8149
+ const beginSetup = (at = Date.now()) => {
8150
+ pending = { category: "setup", at };
8151
+ iteration = null;
8152
+ };
8153
+ return { onEvent, beginSetup, summary: () => ({ totalMs, calls, byCategory }) };
7495
8154
  }
7496
8155
  function formatTimingSummary(s) {
7497
8156
  if (s.totalMs === 0)
@@ -7499,6 +8158,22 @@ function formatTimingSummary(s) {
7499
8158
  const parts = Object.entries(s.byCategory).sort((a, b) => (b[1]?.ms ?? 0) - (a[1]?.ms ?? 0)).slice(0, 5).map(([c, t]) => `${c} ${Math.round((t?.ms ?? 0) / s.totalMs * 100)}%`);
7500
8159
  return `${Math.round(s.totalMs / 60000)}min across ${s.calls} tool call(s) \u2014 ${parts.join(", ")}`;
7501
8160
  }
8161
+ function buildTicketTiming(summary, startedAt, endedAt) {
8162
+ return { ...summary, startedAt, endedAt, wallMs: Math.max(0, endedAt - startedAt) };
8163
+ }
8164
+ function formatTicketTiming(t) {
8165
+ const clock = fmtClock(Math.round(t.wallMs / 1000));
8166
+ const bare = t.calls === 0 && t.totalMs === 0;
8167
+ return bare ? clock : `${clock} (${formatTimingSummary(t)})`;
8168
+ }
8169
+ function emitTicketTiming(ctx, ticket, timing) {
8170
+ ctx.emitEvent({
8171
+ type: "ticket.timing",
8172
+ message: `ticket ${ticket.seq} spent ${formatTicketTiming(timing)}`,
8173
+ ticketId: ticket.id,
8174
+ data: timing
8175
+ });
8176
+ }
7502
8177
  function interpretRunResult(result, blockedContent) {
7503
8178
  const commits = result.commits.map((c) => c.sha);
7504
8179
  if (commits.length > 0)
@@ -7519,10 +8194,20 @@ function isMergeConflictError(err) {
7519
8194
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
7520
8195
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
7521
8196
  }
8197
+ function missingAgentBinaryMessage(err, runtime, image) {
8198
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
8199
+ const binary = AGENT_BINARY[runtime];
8200
+ const missing = new RegExp(`\\b${binary}\\b[^\\n]*(?:exited with code 127|command not found|not found|no such file or directory)`, "i");
8201
+ if (!missing.test(msg))
8202
+ return;
8203
+ return `${binary} is not installed in image ${image} \u2014 the image predates the burner Dockerfile. Rebuild it from Settings \u2192 AFK burns (Rebuild image).`;
8204
+ }
7522
8205
  function classifyTicketRunError(err, runtime) {
7523
8206
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
7524
8207
  const runtimes = runtime ? [runtime] : ["claude-code", "codex"];
7525
8208
  const forRuntimes = (table) => runtimes.flatMap((r) => table[r]);
8209
+ if (runtimes.some((r) => missingAgentBinaryMessage(msg, r, "image")))
8210
+ return "fatal";
7526
8211
  if ([...FATAL_ERROR_PATTERNS, ...forRuntimes(RUNTIME_FATAL_ERROR_PATTERNS)].some((p) => p.test(msg)))
7527
8212
  return "fatal";
7528
8213
  if ([...RETRYABLE_ERROR_PATTERNS, ...forRuntimes(RUNTIME_RETRYABLE_ERROR_PATTERNS)].some((p) => p.test(msg)))
@@ -7636,13 +8321,42 @@ function failedBlockerNote(seqs) {
7636
8321
  }
7637
8322
  async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7638
8323
  const width = Math.max(1, Math.floor(concurrency));
7639
- const bySeq = indexBySeq(tickets2);
7640
- const status = new Map(tickets2.map((t) => [t.seq, t.status]));
7641
- const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
8324
+ const scheduled = [...tickets2];
8325
+ const bySeq = indexBySeq(scheduled);
8326
+ const status = new Map(scheduled.map((t) => [t.seq, t.status]));
8327
+ const pending = new Set(scheduled.filter((t) => t.status === "pending").map((t) => t.seq));
7642
8328
  const inFlight = new Map;
7643
8329
  const digests = [];
8330
+ let admitted = 0;
7644
8331
  const satisfied = (t, s) => s === "done" || s === "cancelled" || isReviewTicket(t) && s === "failed";
7645
- const implementationsSettled = () => tickets2.every((t) => isReviewTicket(t) || !pending.has(t.seq) && !inFlight.has(t.seq));
8332
+ const implementationsSettled = () => scheduled.every((t) => isReviewTicket(t) || !pending.has(t.seq) && !inFlight.has(t.seq));
8333
+ const admitNewTickets = () => {
8334
+ const added = [];
8335
+ for (const t of ctx.listTickets?.() ?? []) {
8336
+ if (bySeq.has(t.seq))
8337
+ continue;
8338
+ scheduled.push(t);
8339
+ bySeq.set(t.seq, t);
8340
+ status.set(t.seq, t.status);
8341
+ if (t.status !== "pending")
8342
+ continue;
8343
+ pending.add(t.seq);
8344
+ added.push(t.seq);
8345
+ }
8346
+ if (added.length === 0)
8347
+ return;
8348
+ admitted += added.length;
8349
+ ctx.emitEvent({
8350
+ type: "burn.admitted",
8351
+ message: `${added.length} ticket(s) minted during this run joined it: ${added.join(", ")}`,
8352
+ data: { seqs: added }
8353
+ });
8354
+ };
8355
+ const mirrorFinding = (t, progress, reason) => {
8356
+ if (!t.originFindingId)
8357
+ return;
8358
+ ctx.updateFinding?.(t.originFindingId, progress, reason);
8359
+ };
7646
8360
  const readyState = (seq) => {
7647
8361
  const t = bySeq.get(seq);
7648
8362
  if (!t)
@@ -7658,7 +8372,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7658
8372
  return t.blockedBy.every((b) => satisfied(t, status.get(b))) ? "ready" : "wait";
7659
8373
  };
7660
8374
  const harvestDigest2 = (t, digest) => {
7661
- const failed = isReviewTicket(t) ? tickets2.filter((x) => !isReviewTicket(x) && status.get(x.seq) === "failed").map((x) => x.seq) : [];
8375
+ const failed = isReviewTicket(t) ? scheduled.filter((x) => !isReviewTicket(x) && status.get(x.seq) === "failed").map((x) => x.seq) : [];
7662
8376
  const body = [failed.length > 0 ? failedBlockerNote(failed) : undefined, digest].filter(Boolean).join(`
7663
8377
 
7664
8378
  `);
@@ -7671,6 +8385,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7671
8385
  if (!t)
7672
8386
  return;
7673
8387
  ctx.updateTicket(t.id, { status: "failed", error, ...digest ? { digest } : {} });
8388
+ mirrorFinding(t, "failed", error);
7674
8389
  harvestDigest2(t, digest);
7675
8390
  if (extra)
7676
8391
  ctx.emitEvent({ ...extra, ticketId: t.id });
@@ -7681,6 +8396,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7681
8396
  return;
7682
8397
  status.set(seq, "burning");
7683
8398
  ctx.updateTicket(t.id, { status: "burning" });
8399
+ mirrorFinding(t, "fixing");
7684
8400
  ctx.emitEvent({
7685
8401
  type: "ticket.burning",
7686
8402
  message: `burning ticket ${t.seq}: ${t.title}`,
@@ -7690,6 +8406,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7690
8406
  if (outcome.status === "done") {
7691
8407
  status.set(seq, "done");
7692
8408
  ctx.updateTicket(t.id, { status: "done", commits: outcome.commits, digest: outcome.digest });
8409
+ mirrorFinding(t, "fixed");
7693
8410
  ctx.emitEvent({
7694
8411
  type: "ticket.done",
7695
8412
  message: `ticket ${t.seq} done \u2014 ${outcome.commits.length} commit(s)`,
@@ -7713,6 +8430,8 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7713
8430
  data: { error: outcome.error }
7714
8431
  });
7715
8432
  }
8433
+ if (isReviewTicket(t))
8434
+ admitNewTickets();
7716
8435
  };
7717
8436
  while (pending.size > 0 || inFlight.size > 0) {
7718
8437
  ctx.signal.throwIfAborted();
@@ -7769,7 +8488,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
7769
8488
  for (const s of status.values())
7770
8489
  if (s === "done")
7771
8490
  done += 1;
7772
- return { done, digests };
8491
+ return { done, admitted, digests };
7773
8492
  }
7774
8493
  function composeRunDigest(entries) {
7775
8494
  if (entries.length === 0)
@@ -7797,11 +8516,27 @@ async function burnRun(ctx, deps) {
7797
8516
  const tickets2 = ctx.tickets;
7798
8517
  const burnable = tickets2.filter((t) => t.status !== "cancelled");
7799
8518
  const cancelled = tickets2.length - burnable.length;
7800
- const total = burnable.length;
7801
8519
  if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
7802
8520
  ctx.emitEvent({ type: AUTH_MISSING_EVENT, message: RUNTIME_AUTH_SETUP_HINT[deps.runtime] });
7803
8521
  return { status: "failed", summary: "burn aborted: auth token missing" };
7804
8522
  }
8523
+ if (deps.config.sandbox !== "noSandbox" && deps.exec) {
8524
+ const image = resolveSandboxImage(deps.config);
8525
+ const binary = RUNTIME_BINARY[deps.runtime];
8526
+ const probe = await deps.exec(deps.config.sandbox, [
8527
+ "run",
8528
+ "--rm",
8529
+ "--entrypoint",
8530
+ binary,
8531
+ image,
8532
+ "--version"
8533
+ ]);
8534
+ if (!probe.ok || probe.code !== 0) {
8535
+ const message = missingImageRuntimeMessage(deps.runtime, image);
8536
+ ctx.emitEvent({ type: IMAGE_RUNTIME_MISSING_EVENT, message });
8537
+ return { status: "failed", summary: message };
8538
+ }
8539
+ }
7805
8540
  const cycle = detectCycle(burnable);
7806
8541
  if (cycle) {
7807
8542
  const path = cycle.join(" \u2192 ");
@@ -7812,7 +8547,8 @@ async function burnRun(ctx, deps) {
7812
8547
  });
7813
8548
  return { status: "failed", summary: `dependency cycle: ${path}` };
7814
8549
  }
7815
- const { done, digests } = await burnTickets(ctx, tickets2, gateTicketAuth(deps), deps.concurrency);
8550
+ const { done, admitted, digests } = await burnTickets(ctx, tickets2, gateTicketAuth(deps), deps.concurrency);
8551
+ const total = burnable.length + admitted;
7816
8552
  const summary = cancelled > 0 ? `${done}/${total} tickets done (${cancelled} cancelled)` : `${done}/${total} tickets done`;
7817
8553
  ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total, cancelled } });
7818
8554
  const digest = composeRunDigest(digests);
@@ -7825,7 +8561,7 @@ function resolverTemplatePath() {
7825
8561
  return burnerAssetPath("resolve-conflict.md");
7826
8562
  }
7827
8563
  function burnerAssetPath(file) {
7828
- const here = dirname6(fileURLToPath5(import.meta.url));
8564
+ const here = dirname6(fileURLToPath6(import.meta.url));
7829
8565
  return join14(resolveSkillsRoot(here), "burner", file);
7830
8566
  }
7831
8567
  function listFeatureDocs(docsDir, prefix = "") {
@@ -7986,8 +8722,8 @@ function buildAgentExtraArgs(runtime, options) {
7986
8722
  function quoteArg(value) {
7987
8723
  return `"${value}"`;
7988
8724
  }
7989
- function selectSandbox(config2, mounts = []) {
7990
- const imageOpts = buildSandboxOptions(config2, mounts);
8725
+ function selectSandbox(config2, mounts = [], env = {}) {
8726
+ const imageOpts = buildSandboxOptions(config2, mounts, env);
7991
8727
  switch (config2.sandbox) {
7992
8728
  case "docker":
7993
8729
  return docker(imageOpts);
@@ -7999,17 +8735,46 @@ function selectSandbox(config2, mounts = []) {
7999
8735
  throw new Error(`no sandbox provider for sandbox "${config2.sandbox}" \u2014 refusing to run the agent unsandboxed`);
8000
8736
  }
8001
8737
  }
8002
- function buildSandboxOptions(config2, mounts = []) {
8738
+ function buildSandboxOptions(config2, mounts = [], env = {}) {
8003
8739
  return {
8004
8740
  imageName: resolveSandboxImage(config2),
8005
8741
  ...mounts.length > 0 ? { mounts } : {},
8006
- ...config2.burnCpus !== undefined ? { cpus: config2.burnCpus } : {}
8742
+ ...config2.burnCpus !== undefined ? { cpus: config2.burnCpus } : {},
8743
+ ...Object.keys(env).length > 0 ? { env } : {}
8007
8744
  };
8008
8745
  }
8009
- async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget, blocks, runCtx) {
8746
+ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget, ensureCacheVolume, blocks, runCtx) {
8747
+ const allocator = resolveBurnCacheMode(config2) === "volume" ? getBurnSlotAllocator(config2.burnConcurrency) : undefined;
8748
+ return withBurnCacheSlot(allocator, async (slot) => {
8749
+ if (slot !== undefined)
8750
+ await ensureCacheVolume();
8751
+ return burnTicket(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget, blocks, runCtx, slot);
8752
+ });
8753
+ }
8754
+ async function withBurnCacheSlot(allocator, body) {
8755
+ if (!allocator)
8756
+ return body(undefined);
8757
+ let slot;
8758
+ try {
8759
+ slot = allocator.claim();
8760
+ } catch (err) {
8761
+ if (!(err instanceof BurnSlotsExhaustedError))
8762
+ throw err;
8763
+ }
8764
+ if (slot === undefined)
8765
+ return body(undefined);
8766
+ try {
8767
+ return await body(slot);
8768
+ } finally {
8769
+ allocator.release(slot);
8770
+ }
8771
+ }
8772
+ async function burnTicket(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget, blocks, runCtx, slot) {
8010
8773
  const { project, feature } = ctx;
8011
- const workspaceMode = resolveBurnWorkspaceMode(config2);
8012
- if (workspaceMode === "isolated")
8774
+ const startedAt = Date.now();
8775
+ const workspaceMode = resolveBurnWorkspaceMode(slot === undefined ? { ...config2, burnCache: "off" } : config2);
8776
+ const agentRepoPath = slot !== undefined ? slotRepoPath(slot) : ISOLATED_REPO_PATH;
8777
+ if (workspaceMode !== "mounted")
8013
8778
  await ensureIsolatedPushTarget();
8014
8779
  const attachments = attachmentSources(ticket.context);
8015
8780
  const attachmentCommands = buildAttachmentCopyCommands(attachments);
@@ -8025,7 +8790,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8025
8790
  const ticketJson = buildTicketJson(ticket);
8026
8791
  const featureBrief = buildFeatureBrief(feature);
8027
8792
  const docsDigest = blocks.docsDigest;
8028
- const workspaceNotes = buildWorkspaceNotes(workspaceMode);
8793
+ const workspaceNotes = buildWorkspaceNotes(workspaceMode, agentRepoPath);
8029
8794
  const guardInstalled = config2.burnGuard && config2.sandbox !== "noSandbox";
8030
8795
  const prepared = resolvePreparedSettings(config2, project);
8031
8796
  const verifyNotes = buildVerifyNotes(prepared);
@@ -8038,18 +8803,18 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8038
8803
  DRIVE_NOTES: blocks.driveNotes,
8039
8804
  GUARD_NOTES: buildGuardNotes(guardInstalled),
8040
8805
  TICKET_JSON: ticketJson,
8041
- BLOCKERS: buildBlockersBlock(ticket.blockedBy, runCtx.digests)
8806
+ BLOCKERS: buildBlockersBlock(ticket.blockedBy, runCtx.digests),
8807
+ FIX_NOTES: buildFixNotes(ticket)
8042
8808
  });
8043
8809
  const toolchain = readRepoToolchain(project.repoPath);
8044
8810
  const pm = detectPackageManager(toolchain);
8045
8811
  const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
8046
- const mounts = [];
8047
- if (config2.sandbox !== "noSandbox" && pm) {
8048
- const mount = cacheMountFor(pm, burnCacheDir(pm));
8049
- if (mount) {
8812
+ const cache = buildBurnCacheMounts(slot, project.id, config2.sandbox, pm);
8813
+ const sandboxEnv = cache.env;
8814
+ const mounts = [...cache.mounts];
8815
+ for (const mount of cache.mounts) {
8816
+ if ("hostPath" in mount)
8050
8817
  mkdirSync9(mount.hostPath, { recursive: true });
8051
- mounts.push(mount);
8052
- }
8053
8818
  }
8054
8819
  const codexAuthMount = codexAuthMountFor(model.runtime, config2.sandbox);
8055
8820
  if (codexAuthMount)
@@ -8057,14 +8822,37 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8057
8822
  const guardInstall = guardInstalled ? buildGuardInstallCommand(model.runtime) : undefined;
8058
8823
  const withPrelude = (setup) => chainSetupCommands(codexAuthMount ? buildCodexAuthCopyCommand() : undefined, guardInstall, setup);
8059
8824
  const agentOptions = guardInstalled ? { bypassHookTrust: true } : {};
8825
+ const slotStamp = buildSlotStamp(resolveSandboxImage(config2), toolchain.packageManagerField);
8826
+ const setupHookFor = (branch) => withPrelude(slot !== undefined ? buildSlotSetupCommand(slot, branch, setupCommand, pm, slotStamp) : workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, setupCommand, pm) : setupCommand);
8060
8827
  mkdirSync9(logsDir(), { recursive: true });
8061
8828
  const logFilePath = join14(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
8062
8829
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
8063
8830
  beginTranscript(ticket.id);
8064
8831
  const timer = createToolTimer();
8832
+ const setupRun = { worktree: undefined, iteration: null };
8833
+ const consumeSetupMarker = (iteration) => {
8834
+ if (slot === undefined || !setupRun.worktree || setupRun.iteration === iteration)
8835
+ return;
8836
+ setupRun.iteration = iteration;
8837
+ const marker = takeSetupMarker(setupRun.worktree);
8838
+ if (!marker)
8839
+ return;
8840
+ ctx.emitEvent({
8841
+ type: "burn.setup",
8842
+ message: `ticket ${ticket.seq}: cache slot ${slot} ${marker.cold ? "cold" : "warm"} \u2014 synced in ${fmtSeconds(marker.syncMs)}, installed in ${fmtSeconds(marker.installMs)}`,
8843
+ ticketId: ticket.id,
8844
+ data: { slot, cold: marker.cold, syncMs: marker.syncMs, installMs: marker.installMs }
8845
+ });
8846
+ };
8847
+ const beginSetupSpan = (branch) => {
8848
+ setupRun.worktree = burnWorktreePath(project.repoPath, branch);
8849
+ setupRun.iteration = null;
8850
+ timer.beginSetup();
8851
+ };
8065
8852
  const onStreamEvent = (event) => {
8066
8853
  throttle.onEvent(event);
8067
8854
  timer.onEvent(event);
8855
+ consumeSetupMarker(event.iteration);
8068
8856
  if (event.type === "text") {
8069
8857
  appendTranscript(ticket.id, { kind: "text", text: event.message });
8070
8858
  } else if (event.type === "toolCall") {
@@ -8100,6 +8888,12 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8100
8888
  const preserveChain = (branch) => {
8101
8889
  ctx.updateTicket(ticket.id, { attemptBranch: branch });
8102
8890
  };
8891
+ const discards = new Map;
8892
+ const discardWorktree = (branch) => {
8893
+ const pending = discards.get(branch) ?? cleanupBurnWorktree(project.repoPath, branch);
8894
+ discards.set(branch, pending);
8895
+ return pending;
8896
+ };
8103
8897
  const runResolver = async (input) => {
8104
8898
  const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
8105
8899
  const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
@@ -8115,7 +8909,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8115
8909
  OTHER_SIDE: buildOtherSideBlock(otherSide),
8116
8910
  MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
8117
8911
  });
8118
- const hookCommand = withPrelude(workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand);
8912
+ const hookCommand = setupHookFor(resolveBranch);
8119
8913
  appendTranscript(ticket.id, {
8120
8914
  kind: "text",
8121
8915
  text: `
@@ -8128,9 +8922,10 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8128
8922
  };
8129
8923
  let result;
8130
8924
  try {
8925
+ beginSetupSpan(resolveBranch);
8131
8926
  result = await run2({
8132
8927
  agent: buildBurnAgent(config2, token, model, agentOptions),
8133
- sandbox: selectSandbox(config2, mounts),
8928
+ sandbox: selectSandbox(config2, mounts, sandboxEnv),
8134
8929
  cwd: project.repoPath,
8135
8930
  prompt,
8136
8931
  branchStrategy: { type: "branch", branch: resolveBranch, baseBranch: input.branch },
@@ -8147,6 +8942,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8147
8942
  logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
8148
8943
  });
8149
8944
  } catch (err) {
8945
+ await discardWorktree(resolveBranch);
8150
8946
  if (ctx.signal.aborted)
8151
8947
  throw err;
8152
8948
  const tip = await bestTip();
@@ -8158,9 +8954,9 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
8158
8954
  error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
8159
8955
  };
8160
8956
  }
8161
- await cleanupBurnWorktree(project.repoPath, resolveBranch);
8162
8957
  }
8163
8958
  const blocked = readAgentFile([result?.preservedWorktreePath, project.repoPath], "BLOCKED.md");
8959
+ await discardWorktree(resolveBranch);
8164
8960
  if (blocked !== undefined && blocked.trim().length > 0) {
8165
8961
  return {
8166
8962
  ok: false,
@@ -8250,17 +9046,18 @@ ${blocked.trim()}`
8250
9046
  let tempBranch = "";
8251
9047
  for (let attempt = 1;; attempt++) {
8252
9048
  tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
8253
- const hookCommand = withPrelude(workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand);
9049
+ const hookCommand = setupHookFor(tempBranch);
8254
9050
  if (hookCommand && attempt === 1) {
8255
9051
  ctx.emitEvent({
8256
9052
  type: "burn.setup",
8257
- message: workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
8258
- ticketId: ticket.id
9053
+ message: slot !== undefined ? `preparing cache slot ${slot}${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
9054
+ ticketId: ticket.id,
9055
+ ...slot !== undefined ? { data: { slot } } : {}
8259
9056
  });
8260
9057
  }
8261
9058
  const runOptions = {
8262
9059
  agent: buildBurnAgent(config2, token, model, agentOptions),
8263
- sandbox: selectSandbox(config2, mounts),
9060
+ sandbox: selectSandbox(config2, mounts, sandboxEnv),
8264
9061
  cwd: project.repoPath,
8265
9062
  prompt: retryNotes ? `${basePrompt}
8266
9063
 
@@ -8285,14 +9082,17 @@ ${retryNotes}` : basePrompt,
8285
9082
  };
8286
9083
  try {
8287
9084
  await excludeAttachments();
9085
+ beginSetupSpan(tempBranch);
8288
9086
  result = await run2(runOptions);
8289
9087
  await clearAttachmentsFor(tempBranch);
8290
9088
  break;
8291
9089
  } catch (err) {
8292
9090
  await clearAttachmentsFor(tempBranch);
9091
+ await discardWorktree(tempBranch);
8293
9092
  if (ctx.signal.aborted)
8294
9093
  throw err;
8295
9094
  const msg = err instanceof Error ? err.message : String(err);
9095
+ const missingBinary = missingAgentBinaryMessage(err, model.runtime, resolveSandboxImage(config2));
8296
9096
  const salvaged = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
8297
9097
  if (salvaged.length > 0)
8298
9098
  baseBranch = tempBranch;
@@ -8309,7 +9109,7 @@ ${retryNotes}` : basePrompt,
8309
9109
  };
8310
9110
  }
8311
9111
  if (isWorktreeTeardownError(err)) {
8312
- const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
9112
+ const removed = await discardWorktree(tempBranch);
8313
9113
  if (salvaged.length > 0) {
8314
9114
  ctx.emitEvent({
8315
9115
  type: "burn.worktree.teardown-failed",
@@ -8334,6 +9134,11 @@ ${retryNotes}` : basePrompt,
8334
9134
  }
8335
9135
  };
8336
9136
  }
9137
+ if (missingBinary) {
9138
+ if (salvaged.length > 0)
9139
+ preserveChain(tempBranch);
9140
+ return { status: "failed", error: missingBinary };
9141
+ }
8337
9142
  if (classifyTicketRunError(err, model.runtime) === "retryable" && attempt < maxAttempts) {
8338
9143
  const headline = errorHeadline(msg);
8339
9144
  retryNotes = buildRetryNotes({ error: headline, commitCount: salvaged.length });
@@ -8375,6 +9180,7 @@ ${retryNotes}` : basePrompt,
8375
9180
  const agentFileDirs = [result.preservedWorktreePath, project.repoPath];
8376
9181
  const blocked = readAgentFile(agentFileDirs, "BLOCKED.md");
8377
9182
  harvestedDigest = harvestDigest(agentFileDirs);
9183
+ await discardWorktree(tempBranch);
8378
9184
  const chain = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
8379
9185
  let outcome;
8380
9186
  if (result.commits.length === 0 && blocked !== undefined && blocked.trim().length > 0) {
@@ -8395,27 +9201,43 @@ ${blocked.trim()}` };
8395
9201
  releaseTicketAbort(ticket.id);
8396
9202
  throttle.flush();
8397
9203
  endTranscript(ticket.id);
8398
- const timing = timer.summary();
8399
- if (timing.calls > 0) {
8400
- ctx.emitEvent({
8401
- type: "ticket.timing",
8402
- message: `ticket ${ticket.seq} spent ${formatTimingSummary(timing)}`,
8403
- ticketId: ticket.id,
8404
- data: timing
8405
- });
8406
- }
9204
+ emitTicketTiming(ctx, ticket, buildTicketTiming(timer.summary(), startedAt, Date.now()));
8407
9205
  }
8408
9206
  }
8409
9207
  function resolveTicketModel(config2, project, runOverride, ticket) {
8410
- return resolveModelEntry("implement", config2, project, ticket.model ?? runOverride);
9208
+ const step = isReviewTicket(ticket) ? "review" : "implement";
9209
+ return resolveModelEntry(step, config2, project, ticket.model ?? runOverride);
8411
9210
  }
8412
9211
  function resolveBurnDeps(ctx) {
8413
9212
  const config2 = loadConfig();
8414
9213
  const model = resolveModelEntry("implement", config2, ctx.project, ctx.modelOverride);
8415
9214
  const token = readTokenFromEnvFile(envPath(), model.runtime);
9215
+ const exec = createSystemExec({ cwd: ctx.project.repoPath });
9216
+ const imageProbeCache = new Map;
9217
+ const cachedExec = (command, args) => {
9218
+ const key = `${resolveSandboxImage(config2)}\x00${model.runtime}`;
9219
+ let result = imageProbeCache.get(key);
9220
+ if (!result) {
9221
+ result = exec(command, args);
9222
+ imageProbeCache.set(key, result);
9223
+ }
9224
+ return result;
9225
+ };
8416
9226
  const land = createSerialQueue();
8417
9227
  let pushTargetReady;
8418
9228
  const ensureIsolatedPushTarget = () => pushTargetReady ??= allowPushToCheckedOutBranches(ctx.project.repoPath);
9229
+ let cacheVolumeReady;
9230
+ const ensureCacheVolume = () => {
9231
+ if (resolveBurnCacheMode(config2) !== "volume")
9232
+ return Promise.resolve();
9233
+ const engine = config2.sandbox === "podman" ? "podman" : "docker";
9234
+ return cacheVolumeReady ??= ensureBurnCacheVolume({
9235
+ engine,
9236
+ imageName: resolveSandboxImage(config2),
9237
+ projectId: ctx.project.id,
9238
+ exec
9239
+ });
9240
+ };
8419
9241
  const docs2 = readDocsDigest(ctx.project.id, ctx.feature.slug);
8420
9242
  emitDocsDigestEvent(ctx, docs2);
8421
9243
  const blocks = {
@@ -8440,6 +9262,7 @@ function resolveBurnDeps(ctx) {
8440
9262
  config: config2,
8441
9263
  runtime: model.runtime,
8442
9264
  hasAuthToken: burnAuthReady(model.runtime, token),
9265
+ exec: cachedExec,
8443
9266
  ticketAuthMissing: (ticket) => {
8444
9267
  const { model: ticketModel, token: ticketToken } = ticketCredentials(ticket);
8445
9268
  return burnAuthReady(ticketModel.runtime, ticketToken) ? undefined : ticketModel.runtime;
@@ -8453,7 +9276,7 @@ function resolveBurnDeps(ctx) {
8453
9276
  model: ticketModel,
8454
9277
  docsDigest: docs2.text,
8455
9278
  lapDigests: run3.digests
8456
- }) : realExecuteTicketRun(c, ticket, config2, ticketToken, ticketModel, land, ensureIsolatedPushTarget, blocks, run3);
9279
+ }) : realExecuteTicketRun(c, ticket, config2, ticketToken, ticketModel, land, ensureIsolatedPushTarget, ensureCacheVolume, blocks, run3);
8457
9280
  }
8458
9281
  };
8459
9282
  }
@@ -8470,19 +9293,25 @@ function readTokenFromEnvFile(path, runtime) {
8470
9293
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env[key];
8471
9294
  return token && token.length > 0 ? token : undefined;
8472
9295
  }
8473
- var AUTH_MISSING_EVENT = "auth.missing", 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", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RUNTIME_FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, RUNTIME_RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
9296
+ var AUTH_MISSING_EVENT = "auth.missing", IMAGE_RUNTIME_MISSING_EVENT = "burn.image_runtime_missing", RUNTIME_BINARY, DROPPED_MAP_SECTIONS, AGENT_CONVENTIONS_FILE = "CLAUDE.md", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, CODEX_HOST_MOUNT_PATH = "/mnt/host-codex", SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", SETUP_MARKER_FILE = ".runcastle-setup", SETUP_MARKER = "RUNCASTLE_SETUP", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RUNTIME_FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, RUNTIME_RETRYABLE_ERROR_PATTERNS, AGENT_BINARY, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
8474
9297
  var init_ticket_burner = __esm(() => {
8475
9298
  init_src();
8476
9299
  init_config_load();
8477
9300
  init_paths();
8478
9301
  init_skills_root();
8479
9302
  init_codex_auth();
9303
+ init_system_exec();
8480
9304
  init_knowledge();
8481
9305
  init_setup();
8482
9306
  init_agent_stream();
8483
9307
  init_git();
8484
9308
  init_burn_guard();
9309
+ init_burn_cache();
8485
9310
  init_review_ticket();
9311
+ RUNTIME_BINARY = {
9312
+ "claude-code": "claude",
9313
+ codex: "codex"
9314
+ };
8486
9315
  DROPPED_MAP_SECTIONS = ["Not yet specified", "Out of scope"];
8487
9316
  PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
8488
9317
  PM_CACHE_SANDBOX_PATHS = {
@@ -8555,6 +9384,10 @@ var init_ticket_burner = __esm(() => {
8555
9384
  "claude-code": [],
8556
9385
  codex: [/rate_limit_exceeded/i, /\b429\b/, /server_error/i, /stream (disconnected|interrupted)/i]
8557
9386
  };
9387
+ AGENT_BINARY = {
9388
+ "claude-code": "claude",
9389
+ codex: "codex"
9390
+ };
8558
9391
  activeTicketAborts = new Map;
8559
9392
  SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
8560
9393
  ticketBurner = {
@@ -8566,7 +9399,7 @@ var init_ticket_burner = __esm(() => {
8566
9399
  // src/workflows/research.ts
8567
9400
  import { mkdirSync as mkdirSync10, readFileSync as readFileSync10 } from "fs";
8568
9401
  import { dirname as dirname7, join as join15 } from "path";
8569
- import { fileURLToPath as fileURLToPath6 } from "url";
9402
+ import { fileURLToPath as fileURLToPath7 } from "url";
8570
9403
  import { run as run3 } from "@ai-hero/sandcastle";
8571
9404
  function waypointSlug(waypoint) {
8572
9405
  const base = waypoint.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -8634,7 +9467,7 @@ function interpretResearchResult(result, docRelPath) {
8634
9467
  return { status: "failed", error: "agent produced no commits \u2014 no research summary landed" };
8635
9468
  }
8636
9469
  function researchTemplatePath() {
8637
- const here = dirname7(fileURLToPath6(import.meta.url));
9470
+ const here = dirname7(fileURLToPath7(import.meta.url));
8638
9471
  return join15(resolveSkillsRoot(here), "burner", "research-waypoint.md");
8639
9472
  }
8640
9473
  async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
@@ -8743,7 +9576,7 @@ var init_registry2 = __esm(() => {
8743
9576
  });
8744
9577
 
8745
9578
  // src/workflows/runner.ts
8746
- import { eq as eq9 } from "drizzle-orm";
9579
+ import { eq as eq10 } from "drizzle-orm";
8747
9580
  function workflowClaimsFeatureBranch(workflowId) {
8748
9581
  return BRANCH_CLAIMING.has(workflowId);
8749
9582
  }
@@ -8772,7 +9605,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
8772
9605
  claim(ctx, opts.claimWaypointId, runId);
8773
9606
  } catch (e) {
8774
9607
  const summary = e instanceof Error ? e.message : "claim failed";
8775
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq9(runs.id, runId)).run();
9608
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq10(runs.id, runId)).run();
8776
9609
  throw e;
8777
9610
  }
8778
9611
  }
@@ -8803,6 +9636,10 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
8803
9636
  updateTicket: (id, patch) => {
8804
9637
  updateTicket(ctx, id, patch);
8805
9638
  },
9639
+ listTickets: () => listByFeature2(ctx, featureId),
9640
+ updateFinding: (findingId, progress, reason) => {
9641
+ markFixProgress(ctx, findingId, progress, reason);
9642
+ },
8806
9643
  input: opts.input,
8807
9644
  modelOverride: opts.modelOverride,
8808
9645
  resolveWaypoint: (id, disposition, summary) => {
@@ -8840,7 +9677,7 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
8840
9677
  } finally {
8841
9678
  controllers.delete(runId);
8842
9679
  }
8843
- ctx.db.update(runs).set({ status, endedAt: Date.now(), summary, ...digest ? { digest } : {} }).where(eq9(runs.id, runId)).run();
9680
+ ctx.db.update(runs).set({ status, endedAt: Date.now(), summary, ...digest ? { digest } : {} }).where(eq10(runs.id, runId)).run();
8844
9681
  releaseForSession(ctx, runId);
8845
9682
  if (workflowClaimsFeatureBranch(workflow2)) {
8846
9683
  sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
@@ -8880,6 +9717,7 @@ var init_runner = __esm(() => {
8880
9717
  init_gates();
8881
9718
  init_git();
8882
9719
  init_repo();
9720
+ init_review_findings();
8883
9721
  init_tickets();
8884
9722
  init_waypoints();
8885
9723
  init_registry2();
@@ -8888,7 +9726,7 @@ var init_runner = __esm(() => {
8888
9726
  });
8889
9727
 
8890
9728
  // src/services/features.ts
8891
- import { desc as desc5, eq as eq10 } from "drizzle-orm";
9729
+ import { desc as desc5, eq as eq11 } from "drizzle-orm";
8892
9730
  import { rmSync as rmSync4 } from "fs";
8893
9731
  async function createFeature(ctx, input) {
8894
9732
  const project = requireProjectById(ctx, input.projectId);
@@ -8925,12 +9763,7 @@ async function createFeature(ctx, input) {
8925
9763
  });
8926
9764
  if (!cut)
8927
9765
  return feature;
8928
- scaffoldDocs(ctx, feature, { brief: input.brief });
8929
- if (cut.branchReady) {
8930
- try {
8931
- await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
8932
- } catch {}
8933
- }
9766
+ await scaffoldDocsOnFeatureBranch(ctx, project, feature, { brief: input.brief });
8934
9767
  return feature;
8935
9768
  }
8936
9769
  function requireNotDraft(feature) {
@@ -8946,19 +9779,14 @@ async function startDraft(ctx, featureId, opts = {}) {
8946
9779
  const project = projectForFeature(ctx, feature);
8947
9780
  const base = await requestedBase(project, opts.baseBranch);
8948
9781
  const { branchReady, baseBranch } = await ensureFeatureBranch(project, feature.slug, base);
8949
- ctx.db.update(features).set({ status: "active", baseBranch }).where(eq10(features.id, featureId)).run();
9782
+ ctx.db.update(features).set({ status: "active", baseBranch }).where(eq11(features.id, featureId)).run();
8950
9783
  const started = { ...feature, status: "active", baseBranch };
8951
9784
  emit(ctx, featureId, {
8952
9785
  type: "feature.started",
8953
9786
  message: branchReady ? `feature ${feature.slug} started (${feature.branch} \u2190 ${baseBranch})` : `feature ${feature.slug} started (branch pending)`,
8954
9787
  data: { branch: feature.branch, baseBranch, branchReady }
8955
9788
  });
8956
- scaffoldDocs(ctx, started, { brief: feature.brief });
8957
- if (branchReady) {
8958
- try {
8959
- await commitDocs(project.repoPath, `runcastle: scaffold ${feature.slug} docs`);
8960
- } catch {}
8961
- }
9789
+ await scaffoldDocsOnFeatureBranch(ctx, project, started, { brief: feature.brief });
8962
9790
  return started;
8963
9791
  }
8964
9792
  function quickTicketTitle(prose) {
@@ -8984,10 +9812,10 @@ ${body}
8984
9812
  function quickReviewTicket(proses) {
8985
9813
  return {
8986
9814
  title: "Review the integrated change",
8987
- 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.",
8988
- context: "This feature came through the quick-change door, so there was no grill session and there " + "is no spec.md or decisions.md to review it against: the whole statement of intent is the " + `${proses.length === 1 ? "sentence" : `${proses.length} sentences`} the human typed, which ` + "are the other tickets in this batch and are reproduced verbatim in brief.md. Nobody " + "prescribed a walkthrough for you either \u2014 judge from the diff whether there is anything " + "drivable, and if there is not, say so and let the code review stand alone. Your digest is " + "the lap's prose summary of what landed; the review page leads with it.",
9815
+ goal: "Review the integrated feature branch and report what you find, in exactly one mode: " + "drive the app in a browser against the criteria below when this change touched something " + "a human can operate and a drive is available, and otherwise run the verify gates and " + "code-review the branch's diff against its base. Write one note per finding; finding bugs " + "is a successful review, and the notes are the deliverable.",
9816
+ context: "This feature came through the quick-change door, so there was no grill session and there " + "is no spec.md or decisions.md to review it against: the whole statement of intent is the " + `${proses.length === 1 ? "sentence" : `${proses.length} sentences`} the human typed, which ` + "are the other tickets in this batch and are reproduced verbatim in brief.md. Nobody " + "prescribed a walkthrough for you either \u2014 judge from the diff whether there is anything " + "drivable, and if there is not, take the gates-and-diff mode. Your digest is " + "the lap's prose summary of what landed, and its first line names the mode you ran; the " + "review page leads with it.",
8989
9817
  acceptanceCriteria: [
8990
- "The branch's diff against its base is code-reviewed on both axes \u2014 the repo's own " + "standards, and the change against what was asked for.",
9818
+ "Reviewed in one mode \u2014 either walked in a browser, or put through the verify gates and " + "code-reviewed on both axes (the repo's own standards, and the change against what was " + "asked for).",
8991
9819
  ...proses.map((prose) => `Landed and does what it says: ${prose}`)
8992
9820
  ],
8993
9821
  seams: [],
@@ -9028,7 +9856,7 @@ async function quickChange(ctx, input) {
9028
9856
  message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
9029
9857
  data: { slug, branch, baseBranch, branchReady }
9030
9858
  });
9031
- scaffoldDocs(ctx, feature, { brief: quickBrief(title, proses) });
9859
+ await scaffoldDocsOnFeatureBranch(ctx, project, feature, { brief: quickBrief(title, proses) });
9032
9860
  const stored = storeTickets(ctx, feature.id, [
9033
9861
  ...proses.map((prose) => ({
9034
9862
  title: quickTicketTitle(prose),
@@ -9048,11 +9876,6 @@ async function quickChange(ctx, input) {
9048
9876
  message: `quick change \u2014 born at implementation on lap 1 with ${tally} (${typed.map((t) => `#${t.seq}`).join(", ")}) plus a review ticket (#${review.seq}); no grill session, no spec.md`,
9049
9877
  data: { slug, ticketSeqs: stored.map((t) => t.seq), phase: "implementation" }
9050
9878
  });
9051
- if (branchReady) {
9052
- try {
9053
- await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
9054
- } catch {}
9055
- }
9056
9879
  return feature;
9057
9880
  }
9058
9881
  async function requestedBase(project, picked) {
@@ -9069,6 +9892,26 @@ async function ensureFeatureBranch(project, slug, base) {
9069
9892
  throw e;
9070
9893
  }
9071
9894
  }
9895
+ async function scaffoldDocsOnFeatureBranch(ctx, project, feature, opts) {
9896
+ let worktreePath;
9897
+ try {
9898
+ worktreePath = await ensureTalkWorktree(project, feature);
9899
+ } catch (e) {
9900
+ if (isNotImplemented(e)) {
9901
+ scaffoldDocs(ctx, feature, opts);
9902
+ return;
9903
+ }
9904
+ emit(ctx, feature.id, {
9905
+ type: "docs.scaffold_failed",
9906
+ message: `docs not scaffolded onto ${feature.branch}: ${e instanceof Error ? e.message : String(e)}`
9907
+ });
9908
+ return;
9909
+ }
9910
+ scaffoldDocs(ctx, feature, opts);
9911
+ try {
9912
+ await commitDocs(worktreePath, `runcastle: scaffold ${feature.slug} docs`);
9913
+ } catch {}
9914
+ }
9072
9915
  function getFeatureFull(ctx, id) {
9073
9916
  const feature = getFeatureRow(ctx, id);
9074
9917
  const waypoints2 = feature.mapped ? listByFeature(ctx, id) : [];
@@ -9085,7 +9928,7 @@ function getFeatureFull(ctx, id) {
9085
9928
  };
9086
9929
  }
9087
9930
  function list(ctx, projectId) {
9088
- const rows = ctx.db.select().from(features).where(eq10(features.projectId, projectId)).orderBy(desc5(features.createdAt)).all();
9931
+ const rows = ctx.db.select().from(features).where(eq11(features.projectId, projectId)).orderBy(desc5(features.createdAt)).all();
9089
9932
  const lastActivity = latestTsByFeature(ctx, projectId);
9090
9933
  return rows.map((row) => {
9091
9934
  const feature = rowToFeature(row);
@@ -9200,7 +10043,7 @@ function rethink(ctx, featureId) {
9200
10043
  throw new GateError(`${feature.slug} is being test-driven \u2014 stop the test drive first, it holds the feature branch`);
9201
10044
  }
9202
10045
  const lap = feature.lap + 1;
9203
- ctx.db.update(features).set({ lap }).where(eq10(features.id, featureId)).run();
10046
+ ctx.db.update(features).set({ lap }).where(eq11(features.id, featureId)).run();
9204
10047
  return setPhase(ctx, featureId, RETHINK_LOOP_BACK.to, "lap.started", `rethink \u2014 lap ${lap}`);
9205
10048
  }
9206
10049
  async function rethinkAndLaunch(ctx, featureId, launch) {
@@ -9209,7 +10052,7 @@ async function rethinkAndLaunch(ctx, featureId, launch) {
9209
10052
  try {
9210
10053
  return await launch(feature);
9211
10054
  } catch (e) {
9212
- ctx.db.update(features).set({ lap: before.lap }).where(eq10(features.id, featureId)).run();
10055
+ ctx.db.update(features).set({ lap: before.lap }).where(eq11(features.id, featureId)).run();
9213
10056
  setPhase(ctx, featureId, before.phase, "lap.aborted", `lap ${feature.lap} aborted \u2014 its terminal could not be opened (${errMsg2(e)}); back at ${before.phase} on lap ${before.lap}`);
9214
10057
  throw e;
9215
10058
  }
@@ -9288,7 +10131,7 @@ function escalateToMap(ctx, featureId, input) {
9288
10131
  return { ok: true, warning: `feature ${feature.slug} is already mapped \u2014 no changes made` };
9289
10132
  }
9290
10133
  const project = projectForFeature(ctx, feature);
9291
- ctx.db.update(features).set({ mapped: true }).where(eq10(features.id, featureId)).run();
10134
+ ctx.db.update(features).set({ mapped: true }).where(eq11(features.id, featureId)).run();
9292
10135
  scaffoldMapDoc(project, { ...feature, mapped: true }, input);
9293
10136
  emit(ctx, featureId, {
9294
10137
  type: "feature.escalated",
@@ -9306,7 +10149,7 @@ function archiveFeature(ctx, featureId) {
9306
10149
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
9307
10150
  if (live)
9308
10151
  endSession(ctx, live.id);
9309
- ctx.db.update(features).set({ status: "archived" }).where(eq10(features.id, featureId)).run();
10152
+ ctx.db.update(features).set({ status: "archived" }).where(eq11(features.id, featureId)).run();
9310
10153
  emit(ctx, featureId, {
9311
10154
  type: "feature.archived",
9312
10155
  message: `feature ${feature.slug} archived`,
@@ -9320,7 +10163,7 @@ function unarchiveFeature(ctx, featureId) {
9320
10163
  throw new GateError(`feature ${feature.slug} is not archived`);
9321
10164
  }
9322
10165
  const restored = feature.phase === "shipped" ? "shipped" : "active";
9323
- ctx.db.update(features).set({ status: restored }).where(eq10(features.id, featureId)).run();
10166
+ ctx.db.update(features).set({ status: restored }).where(eq11(features.id, featureId)).run();
9324
10167
  emit(ctx, featureId, {
9325
10168
  type: "feature.unarchived",
9326
10169
  message: `feature ${feature.slug} unarchived (${restored})`,
@@ -9360,13 +10203,13 @@ async function deleteFeature(ctx, featureId) {
9360
10203
  return { ok: true, slug: feature.slug };
9361
10204
  }
9362
10205
  function deleteFeatureRows(ctx, featureId) {
9363
- ctx.db.delete(tickets).where(eq10(tickets.featureId, featureId)).run();
9364
- ctx.db.delete(sessions).where(eq10(sessions.featureId, featureId)).run();
9365
- ctx.db.delete(runs).where(eq10(runs.featureId, featureId)).run();
9366
- ctx.db.delete(events).where(eq10(events.featureId, featureId)).run();
9367
- ctx.db.delete(gateOverrides).where(eq10(gateOverrides.featureId, featureId)).run();
9368
- ctx.db.delete(waypoints).where(eq10(waypoints.featureId, featureId)).run();
9369
- ctx.db.delete(features).where(eq10(features.id, featureId)).run();
10206
+ ctx.db.delete(tickets).where(eq11(tickets.featureId, featureId)).run();
10207
+ ctx.db.delete(sessions).where(eq11(sessions.featureId, featureId)).run();
10208
+ ctx.db.delete(runs).where(eq11(runs.featureId, featureId)).run();
10209
+ ctx.db.delete(events).where(eq11(events.featureId, featureId)).run();
10210
+ ctx.db.delete(gateOverrides).where(eq11(gateOverrides.featureId, featureId)).run();
10211
+ ctx.db.delete(waypoints).where(eq11(waypoints.featureId, featureId)).run();
10212
+ ctx.db.delete(features).where(eq11(features.id, featureId)).run();
9370
10213
  }
9371
10214
  function gateState(ctx, feature) {
9372
10215
  const gate = nextGate(feature);
@@ -9377,7 +10220,7 @@ function gateState(ctx, feature) {
9377
10220
  }
9378
10221
  function uniqueSlug(ctx, projectId, title) {
9379
10222
  const base = slugify(title);
9380
- const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq10(features.projectId, projectId)).all().map((r) => r.slug));
10223
+ const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq11(features.projectId, projectId)).all().map((r) => r.slug));
9381
10224
  if (!existing.has(base))
9382
10225
  return base;
9383
10226
  let n = 2;
@@ -9410,7 +10253,7 @@ var init_features = __esm(() => {
9410
10253
  // src/services/test-notes.ts
9411
10254
  import { existsSync as existsSync18, mkdirSync as mkdirSync11, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
9412
10255
  import { dirname as dirname8 } from "path";
9413
- import { asc as asc4, eq as eq11 } from "drizzle-orm";
10256
+ import { asc as asc5, eq as eq12 } from "drizzle-orm";
9414
10257
  function rowToNote(row) {
9415
10258
  return TestNote.parse({
9416
10259
  id: row.id,
@@ -9427,7 +10270,7 @@ function rowToNote(row) {
9427
10270
  });
9428
10271
  }
9429
10272
  function getNote(ctx, id) {
9430
- const row = ctx.db.select().from(testNotes).where(eq11(testNotes.id, id)).get();
10273
+ const row = ctx.db.select().from(testNotes).where(eq12(testNotes.id, id)).get();
9431
10274
  if (!row)
9432
10275
  throw new NotFoundError(`test note ${id} not found`);
9433
10276
  return rowToNote(row);
@@ -9443,8 +10286,8 @@ function assertOpen(note, action) {
9443
10286
  throw new InvalidInputError(`cannot ${action} note ${note.id} \u2014 it is ${note.status}; only open notes can be changed`);
9444
10287
  }
9445
10288
  }
9446
- function listByFeature3(ctx, featureId) {
9447
- return ctx.db.select().from(testNotes).where(eq11(testNotes.featureId, featureId)).orderBy(asc4(testNotes.createdAt), asc4(testNotes.id)).all().map(rowToNote);
10289
+ function listByFeature4(ctx, featureId) {
10290
+ return ctx.db.select().from(testNotes).where(eq12(testNotes.featureId, featureId)).orderBy(asc5(testNotes.createdAt), asc5(testNotes.id)).all().map(rowToNote);
9448
10291
  }
9449
10292
  function addNote(ctx, featureId, text2, author = "human", videoTimestamp) {
9450
10293
  const body = cleanText(text2);
@@ -9475,7 +10318,7 @@ function editNote(ctx, noteId, text2) {
9475
10318
  const current2 = getNote(ctx, noteId);
9476
10319
  assertOpen(current2, "edit");
9477
10320
  const body = cleanText(text2);
9478
- ctx.db.update(testNotes).set({ text: body, updatedAt: Date.now() }).where(eq11(testNotes.id, noteId)).run();
10321
+ ctx.db.update(testNotes).set({ text: body, updatedAt: Date.now() }).where(eq12(testNotes.id, noteId)).run();
9479
10322
  emit(ctx, current2.featureId, {
9480
10323
  type: "note.edited",
9481
10324
  message: "note edited",
@@ -9487,7 +10330,7 @@ function editNote(ctx, noteId, text2) {
9487
10330
  function deleteNote(ctx, noteId) {
9488
10331
  const current2 = getNote(ctx, noteId);
9489
10332
  assertOpen(current2, "delete");
9490
- ctx.db.delete(testNotes).where(eq11(testNotes.id, noteId)).run();
10333
+ ctx.db.delete(testNotes).where(eq12(testNotes.id, noteId)).run();
9491
10334
  rmSync5(annotationPath(noteId), { force: true });
9492
10335
  emit(ctx, current2.featureId, {
9493
10336
  type: "note.deleted",
@@ -9514,7 +10357,7 @@ function toggleNote(ctx, noteId) {
9514
10357
  throw new InvalidInputError(`cannot toggle note ${noteId} \u2014 it is promoted, and promoted notes are frozen`);
9515
10358
  }
9516
10359
  const status = current2.status === "open" ? "done" : "open";
9517
- ctx.db.update(testNotes).set({ status, updatedAt: Date.now() }).where(eq11(testNotes.id, noteId)).run();
10360
+ ctx.db.update(testNotes).set({ status, updatedAt: Date.now() }).where(eq12(testNotes.id, noteId)).run();
9518
10361
  emit(ctx, current2.featureId, {
9519
10362
  type: "note.toggled",
9520
10363
  message: `note ${current2.status} \u2192 ${status}`,
@@ -9564,7 +10407,7 @@ function freezeAsTickets(ctx, noteIds) {
9564
10407
  const tickets2 = storeTickets(ctx, featureId, selected.map((note) => promotionTicket(feature, note)));
9565
10408
  const now = Date.now();
9566
10409
  selected.forEach((note, i) => {
9567
- ctx.db.update(testNotes).set({ status: "promoted", ticketId: tickets2[i].id, updatedAt: now }).where(eq11(testNotes.id, note.id)).run();
10410
+ ctx.db.update(testNotes).set({ status: "promoted", ticketId: tickets2[i].id, updatedAt: now }).where(eq12(testNotes.id, note.id)).run();
9568
10411
  });
9569
10412
  return { feature, notes: selected.map((n) => getNote(ctx, n.id)), tickets: tickets2 };
9570
10413
  }
@@ -9602,7 +10445,7 @@ function noteLine(ctx, note) {
9602
10445
  return `- [x] ${note.text}${ticket}${shot}`;
9603
10446
  }
9604
10447
  function renderTestNotes(ctx, feature) {
9605
- const notes = listByFeature3(ctx, feature.id);
10448
+ const notes = listByFeature4(ctx, feature.id);
9606
10449
  const laps = [...new Set(notes.map((n) => n.lap))].sort((a, b) => a - b);
9607
10450
  const lines = ["# Test notes"];
9608
10451
  for (const lap of laps) {
@@ -9928,6 +10771,23 @@ function toolAddTestNote(ctx, caller, input) {
9928
10771
  const identity = requireRunIdentity(ctx, caller, "add_test_note");
9929
10772
  return addNote(ctx, identity.featureId, input.text, "agent");
9930
10773
  }
10774
+ function toolReportFinding(ctx, caller, input) {
10775
+ const identity = requireRunIdentity(ctx, caller, "report_finding");
10776
+ const reviewTicket = listByFeature2(ctx, identity.featureId).find((ticket) => ticket.kind === "review" && ticket.status === "burning");
10777
+ if (!reviewTicket) {
10778
+ throw new GateError("report_finding requires a burning review ticket on this run\u2019s feature");
10779
+ }
10780
+ const result = reportFinding(ctx, {
10781
+ featureId: identity.featureId,
10782
+ reviewTicket,
10783
+ input
10784
+ });
10785
+ return {
10786
+ findingId: result.finding.id,
10787
+ fixTicketSeq: result.fixTicket?.seq ?? null,
10788
+ overCap: result.overCap
10789
+ };
10790
+ }
9931
10791
  async function toolRetryDrive(ctx, session) {
9932
10792
  if (session.kind !== "drive-fix") {
9933
10793
  throw new GateError(`retrying a drive belongs to a drive-fix session, and this is a ${session.kind} session. ` + "It stops and restarts a test drive on the human's machine \u2014 only the session opened on " + "that failed drive may do that; everyone else asks the human to drive from the review panel.");
@@ -10186,6 +11046,16 @@ function buildMcpServer(audience) {
10186
11046
  return ok(toolAddTestNote(rc.ctx, rc.caller, args));
10187
11047
  });
10188
11048
  }
11049
+ if (wants("report_finding")) {
11050
+ server.registerTool("report_finding", {
11051
+ title: "Report a structured review finding",
11052
+ description: "Report one typed finding for the feature under review. Actionable defects mint fix " + "tickets automatically up to the review cap; observations are retained for the digest.",
11053
+ inputSchema: ReviewFindingInput.shape
11054
+ }, async (args, extra) => {
11055
+ const rc = await resolveRunCaller(extra);
11056
+ return ok(toolReportFinding(rc.ctx, rc.caller, ReviewFindingInput.parse(args)));
11057
+ });
11058
+ }
10189
11059
  if (wants("retry_drive")) {
10190
11060
  server.registerTool("retry_drive", {
10191
11061
  title: "Retry this feature\u2019s test drive",
@@ -10481,6 +11351,7 @@ var init_server = __esm(() => {
10481
11351
  init_features();
10482
11352
  init_events();
10483
11353
  init_findings();
11354
+ init_review_findings();
10484
11355
  init_git();
10485
11356
  init_knowledge();
10486
11357
  init_repo();
@@ -10520,7 +11391,8 @@ var init_server = __esm(() => {
10520
11391
  dry_run_drive: ["prepare"],
10521
11392
  retry_drive: ["drive-fix"],
10522
11393
  review_drive: ["run"],
10523
- add_test_note: ["run"],
11394
+ report_finding: ["run"],
11395
+ add_test_note: [],
10524
11396
  record_event: [...FEATURE_KINDS, ...PROJECT_KINDS]
10525
11397
  };
10526
11398
  CompletablePhase = Phase.exclude(["shipped"]);
@@ -10610,7 +11482,7 @@ var init_ws = __esm(() => {
10610
11482
  });
10611
11483
 
10612
11484
  // src/services/prep.ts
10613
- import { and as and8, desc as desc6, eq as eq12, inArray as inArray4 } from "drizzle-orm";
11485
+ import { and as and9, desc as desc6, eq as eq13, inArray as inArray4 } from "drizzle-orm";
10614
11486
  function keysToPrepare(ctx, project) {
10615
11487
  return unsetPreparedKeys(project).filter((key) => isOverwritable(ctx, project.id, key));
10616
11488
  }
@@ -10620,10 +11492,10 @@ function isPrepared(ctx, project) {
10620
11492
  return hasCompletedProjectSession(ctx, project.id, "prepare");
10621
11493
  }
10622
11494
  function preparedAt(ctx, projectId) {
10623
- const prepareSessions = new Set(ctx.db.select({ id: sessions.id }).from(sessions).where(and8(eq12(sessions.projectId, projectId), eq12(sessions.kind, "prepare"))).all().map((r) => r.id));
11495
+ const prepareSessions = new Set(ctx.db.select({ id: sessions.id }).from(sessions).where(and9(eq13(sessions.projectId, projectId), eq13(sessions.kind, "prepare"))).all().map((r) => r.id));
10624
11496
  if (prepareSessions.size === 0)
10625
11497
  return null;
10626
- const ends = ctx.db.select({ ts: events.ts, data: events.data }).from(events).where(and8(eq12(events.projectId, projectId), inArray4(events.type, ["session.ended", "session.reconciled"]))).orderBy(desc6(events.ts)).all();
11498
+ const ends = ctx.db.select({ ts: events.ts, data: events.data }).from(events).where(and9(eq13(events.projectId, projectId), inArray4(events.type, ["session.ended", "session.reconciled"]))).orderBy(desc6(events.ts)).all();
10627
11499
  for (const end of ends) {
10628
11500
  const sessionId = end.data?.sessionId;
10629
11501
  if (sessionId && prepareSessions.has(sessionId))
@@ -10878,7 +11750,7 @@ var init_hooks = __esm(() => {
10878
11750
  });
10879
11751
 
10880
11752
  // src/routes/reviews.ts
10881
- import { createReadStream, statSync as statSync3 } from "fs";
11753
+ import { createReadStream, statSync as statSync4 } from "fs";
10882
11754
  import { Readable } from "stream";
10883
11755
  import { Hono as Hono3 } from "hono";
10884
11756
  function walkthroughUrl(ticketId) {
@@ -10886,7 +11758,7 @@ function walkthroughUrl(ticketId) {
10886
11758
  }
10887
11759
  function fileSize(path) {
10888
11760
  try {
10889
- const stat = statSync3(path);
11761
+ const stat = statSync4(path);
10890
11762
  return stat.isFile() ? stat.size : undefined;
10891
11763
  } catch {
10892
11764
  return;
@@ -11064,7 +11936,7 @@ var init_stream = __esm(() => {
11064
11936
  });
11065
11937
 
11066
11938
  // src/routes/web.ts
11067
- import { existsSync as existsSync20, statSync as statSync4 } from "fs";
11939
+ import { existsSync as existsSync20, statSync as statSync5 } from "fs";
11068
11940
  import { readFile } from "fs/promises";
11069
11941
  import { extname, join as join17, normalize, resolve as resolve10, sep as sep3 } from "path";
11070
11942
  function contentType(path) {
@@ -11072,7 +11944,7 @@ function contentType(path) {
11072
11944
  }
11073
11945
  function isFile(path) {
11074
11946
  try {
11075
- return statSync4(path).isFile();
11947
+ return statSync5(path).isFile();
11076
11948
  } catch {
11077
11949
  return false;
11078
11950
  }
@@ -11146,11 +12018,11 @@ var init_web = __esm(() => {
11146
12018
  // src/version.ts
11147
12019
  import { readFileSync as readFileSync12 } from "fs";
11148
12020
  import { dirname as dirname9, join as join18 } from "path";
11149
- import { fileURLToPath as fileURLToPath7 } from "url";
12021
+ import { fileURLToPath as fileURLToPath8 } from "url";
11150
12022
  function runcastleVersion() {
11151
12023
  if (cached2)
11152
12024
  return cached2;
11153
- const here = dirname9(fileURLToPath7(import.meta.url));
12025
+ const here = dirname9(fileURLToPath8(import.meta.url));
11154
12026
  for (const pkgPath of [join18(here, "..", "package.json"), join18(here, "package.json")]) {
11155
12027
  try {
11156
12028
  const pkg = JSON.parse(readFileSync12(pkgPath, "utf8"));
@@ -11375,7 +12247,7 @@ var init_events2 = __esm(() => {
11375
12247
  // src/launcher/launcher.ts
11376
12248
  import { existsSync as existsSync22 } from "fs";
11377
12249
  import { join as join19 } from "path";
11378
- import { and as and9, eq as eq13 } from "drizzle-orm";
12250
+ import { and as and10, eq as eq14 } from "drizzle-orm";
11379
12251
  function ptyExitMessage(exitCode) {
11380
12252
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
11381
12253
  return `terminal exited (code ${label})`;
@@ -11395,7 +12267,7 @@ function renderCommand(runtime, spec) {
11395
12267
  return [...env, runtime.binary, ...spec.argv].join(" ");
11396
12268
  }
11397
12269
  function activeRunFor(ctx, featureId) {
11398
- const row = ctx.db.select().from(runs).where(and9(eq13(runs.featureId, featureId), eq13(runs.status, "running"))).limit(1).get();
12270
+ const row = ctx.db.select().from(runs).where(and10(eq14(runs.featureId, featureId), eq14(runs.status, "running"))).limit(1).get();
11399
12271
  return row ? rowToRun(row) : null;
11400
12272
  }
11401
12273
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -12196,7 +13068,7 @@ var init_feature = __esm(() => {
12196
13068
  });
12197
13069
 
12198
13070
  // src/services/fsbrowse.ts
12199
- import { existsSync as existsSync23, readdirSync as readdirSync6, statSync as statSync5 } from "fs";
13071
+ import { existsSync as existsSync23, readdirSync as readdirSync6, statSync as statSync6 } from "fs";
12200
13072
  import { homedir as homedir3 } from "os";
12201
13073
  import { basename as basename3, dirname as dirname11, isAbsolute as isAbsolute2, join as join21, resolve as resolve11, sep as sep4 } from "path";
12202
13074
  function expandPath(input, home = homedir3()) {
@@ -12272,7 +13144,7 @@ function browseDir(input, showHidden = false, home = homedir3()) {
12272
13144
  }
12273
13145
  let dir = target;
12274
13146
  try {
12275
- if (!statSync5(target).isDirectory())
13147
+ if (!statSync6(target).isDirectory())
12276
13148
  dir = dirname11(target);
12277
13149
  } catch (e) {
12278
13150
  throw new InvalidInputError(`cannot read path: ${target} (${errMsg4(e)})`);
@@ -12302,7 +13174,7 @@ function browseDir(input, showHidden = false, home = homedir3()) {
12302
13174
  let isDirectory = dirent.isDirectory();
12303
13175
  if (isSymlink) {
12304
13176
  try {
12305
- isDirectory = statSync5(path).isDirectory();
13177
+ isDirectory = statSync6(path).isDirectory();
12306
13178
  } catch {
12307
13179
  continue;
12308
13180
  }
@@ -12494,7 +13366,7 @@ var init_conversations = __esm(() => {
12494
13366
  // src/services/projects.ts
12495
13367
  import { existsSync as existsSync24 } from "fs";
12496
13368
  import { basename as basename4, join as join22 } from "path";
12497
- import { eq as eq14, isNull } from "drizzle-orm";
13369
+ import { eq as eq15, isNull } from "drizzle-orm";
12498
13370
  function listProjects(ctx) {
12499
13371
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
12500
13372
  }
@@ -12515,7 +13387,7 @@ async function openProject(ctx, rawPath) {
12515
13387
  const name = basename4(repoPath) || repoPath;
12516
13388
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
12517
13389
  if (existing) {
12518
- ctx.db.update(projects).set({ repoPath, closedAt: null }).where(eq14(projects.id, existing.id)).run();
13390
+ ctx.db.update(projects).set({ repoPath, closedAt: null }).where(eq15(projects.id, existing.id)).run();
12519
13391
  const reopened = { ...existing, repoPath, closedAt: undefined };
12520
13392
  emitProject(ctx, existing.id, {
12521
13393
  type: "project.opened",
@@ -12545,7 +13417,7 @@ function closeProject(ctx, projectId) {
12545
13417
  if (projectHasActiveRun(ctx, projectId)) {
12546
13418
  throw new InvalidInputError(`cannot close ${project.name}: a run is in flight \u2014 wait for it to finish or cancel it first`);
12547
13419
  }
12548
- ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq14(projects.id, projectId)).run();
13420
+ ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq15(projects.id, projectId)).run();
12549
13421
  emitProject(ctx, projectId, {
12550
13422
  type: "project.closed",
12551
13423
  message: `project ${project.name} closed`
@@ -12554,7 +13426,7 @@ function closeProject(ctx, projectId) {
12554
13426
  }
12555
13427
  function renameProject(ctx, projectId, name) {
12556
13428
  const project = requireProjectById(ctx, projectId);
12557
- ctx.db.update(projects).set({ name }).where(eq14(projects.id, projectId)).run();
13429
+ ctx.db.update(projects).set({ name }).where(eq15(projects.id, projectId)).run();
12558
13430
  emitProject(ctx, projectId, {
12559
13431
  type: "project.renamed",
12560
13432
  message: `project renamed ${project.name} \u2192 ${name}`,
@@ -12625,8 +13497,26 @@ var init_project = __esm(() => {
12625
13497
  });
12626
13498
  });
12627
13499
 
12628
- // src/trpc/routers/run.ts
13500
+ // src/trpc/routers/review-findings.ts
12629
13501
  import * as z9 from "zod";
13502
+ var reviewFindingsRouter;
13503
+ var init_review_findings2 = __esm(() => {
13504
+ init_features();
13505
+ init_review_findings();
13506
+ init_context();
13507
+ reviewFindingsRouter = router({
13508
+ listByFeature: publicProcedure.input(z9.object({ featureId: z9.string() })).query(({ ctx, input }) => viewByFeature(ctx, input.featureId)),
13509
+ dismiss: publicProcedure.input(z9.object({ findingId: z9.string() })).mutation(({ ctx, input }) => dismiss(ctx, input.findingId)),
13510
+ fixOpenDefects: publicProcedure.input(z9.object({ featureId: z9.string() })).mutation(async ({ ctx, input }) => {
13511
+ const promoted = promoteOpenDefects(ctx, input.featureId);
13512
+ const { runId } = await burn(ctx, input.featureId);
13513
+ return { ...promoted, runId };
13514
+ })
13515
+ });
13516
+ });
13517
+
13518
+ // src/trpc/routers/run.ts
13519
+ import * as z10 from "zod";
12630
13520
  var runRouter;
12631
13521
  var init_run = __esm(() => {
12632
13522
  init_agent_stream();
@@ -12634,9 +13524,9 @@ var init_run = __esm(() => {
12634
13524
  init_runner();
12635
13525
  init_context();
12636
13526
  runRouter = router({
12637
- get: publicProcedure.input(z9.object({ runId: z9.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
12638
- agentTranscript: publicProcedure.input(z9.object({ ticketId: z9.string(), after: z9.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
12639
- cancel: publicProcedure.input(z9.object({ runId: z9.string() })).mutation(({ ctx, input }) => {
13527
+ get: publicProcedure.input(z10.object({ runId: z10.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
13528
+ agentTranscript: publicProcedure.input(z10.object({ ticketId: z10.string(), after: z10.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
13529
+ cancel: publicProcedure.input(z10.object({ runId: z10.string() })).mutation(({ ctx, input }) => {
12640
13530
  getRunRow(ctx, input.runId);
12641
13531
  cancelRun(input.runId);
12642
13532
  return { ok: true };
@@ -12645,47 +13535,21 @@ var init_run = __esm(() => {
12645
13535
  });
12646
13536
 
12647
13537
  // src/trpc/routers/settings.ts
12648
- import * as z10 from "zod";
13538
+ import * as z11 from "zod";
12649
13539
  var settingsRouter;
12650
13540
  var init_settings2 = __esm(() => {
12651
13541
  init_src();
12652
13542
  init_settings();
12653
13543
  init_context();
12654
13544
  settingsRouter = router({
12655
- get: publicProcedure.input(z10.object({ projectId: z10.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
13545
+ get: publicProcedure.input(z11.object({ projectId: z11.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
12656
13546
  update: publicProcedure.input(SettingsUpdateInput).mutation(({ ctx, input }) => updateSettings(ctx, input))
12657
13547
  });
12658
13548
  });
12659
13549
 
12660
- // src/doctor/system-exec.ts
12661
- import { spawn as spawn4 } from "child_process";
12662
- function createSystemExec(opts = {}) {
12663
- return (command, args) => new Promise((resolve12) => {
12664
- const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
12665
- let stdout = "";
12666
- let stderr = "";
12667
- const child = spawn4(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
12668
- child.stdout?.on("data", (d) => {
12669
- stdout += d.toString();
12670
- });
12671
- child.stderr?.on("data", (d) => {
12672
- stderr += d.toString();
12673
- });
12674
- child.on("error", (err) => {
12675
- resolve12({ ok: false, code: null, stdout, stderr: stderr || String(err) });
12676
- });
12677
- child.on("close", (code) => {
12678
- resolve12({ ok: true, code: code ?? 0, stdout, stderr });
12679
- });
12680
- });
12681
- }
12682
- var init_system_exec = __esm(() => {
12683
- init_resolve_executable();
12684
- });
12685
-
12686
13550
  // src/trpc/routers/setup.ts
12687
- import { isNotNull as isNotNull2 } from "drizzle-orm";
12688
- import * as z11 from "zod";
13551
+ import { isNotNull as isNotNull3 } from "drizzle-orm";
13552
+ import * as z12 from "zod";
12689
13553
  var setupRouter;
12690
13554
  var init_setup2 = __esm(() => {
12691
13555
  init_src();
@@ -12698,7 +13562,7 @@ var init_setup2 = __esm(() => {
12698
13562
  init_context();
12699
13563
  setupRouter = router({
12700
13564
  doctor: publicProcedure.query(({ ctx }) => {
12701
- const projectModels = ctx.db.select({ model: projects.model }).from(projects).where(isNotNull2(projects.model)).all().map((p) => p.model);
13565
+ const projectModels = ctx.db.select({ model: projects.model }).from(projects).where(isNotNull3(projects.model)).all().map((p) => p.model);
12702
13566
  return runDoctor({
12703
13567
  exec: createSystemExec(),
12704
13568
  runtimes: configuredRuntimes(ctx.config, projectModels),
@@ -12706,10 +13570,10 @@ var init_setup2 = __esm(() => {
12706
13570
  });
12707
13571
  }),
12708
13572
  runtimeGuide: publicProcedure.query(() => runtimeInstallGuide(process.platform)),
12709
- gitIdentity: publicProcedure.input(z11.object({ name: z11.string(), email: z11.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
12710
- afkToken: publicProcedure.input(z11.object({ token: z11.string(), runtime: AgentRuntime.default("claude-code") })).mutation(({ input }) => saveAfkCredential(fileAfkTokenIo(createSystemExec(), input.runtime), input.token, input.runtime)),
12711
- seedModelDefaults: publicProcedure.input(z11.object({ runtimes: z11.array(AgentRuntime) })).mutation(({ ctx, input }) => seedModelDefaults(ctx, input.runtimes)),
12712
- startTerminal: publicProcedure.input(z11.object({ kind: z11.enum(["setup-token", "build-image", "claude-login", "codex-login"]) })).mutation(async ({ ctx, input }) => {
13573
+ gitIdentity: publicProcedure.input(z12.object({ name: z12.string(), email: z12.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
13574
+ afkToken: publicProcedure.input(z12.object({ token: z12.string(), runtime: AgentRuntime.default("claude-code") })).mutation(({ input }) => saveAfkCredential(fileAfkTokenIo(createSystemExec(), input.runtime), input.token, input.runtime)),
13575
+ seedModelDefaults: publicProcedure.input(z12.object({ runtimes: z12.array(AgentRuntime) })).mutation(({ ctx, input }) => seedModelDefaults(ctx, input.runtimes)),
13576
+ startTerminal: publicProcedure.input(z12.object({ kind: z12.enum(["setup-token", "build-image", "claude-login", "codex-login"]) })).mutation(async ({ ctx, input }) => {
12713
13577
  const exec = createSystemExec();
12714
13578
  const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
12715
13579
  const runtime = await resolveRuntime(exec, preferred);
@@ -12731,40 +13595,80 @@ var init_setup2 = __esm(() => {
12731
13595
  });
12732
13596
 
12733
13597
  // src/trpc/routers/system.ts
12734
- var systemRouter;
13598
+ import * as z13 from "zod";
13599
+ function burnCacheEngine(sandbox) {
13600
+ return sandbox === "docker" || sandbox === "podman" ? sandbox : null;
13601
+ }
13602
+ var projectInput, systemRouter;
12735
13603
  var init_system = __esm(() => {
13604
+ init_src();
13605
+ init_system_exec();
13606
+ init_errors();
13607
+ init_events();
12736
13608
  init_update_check();
13609
+ init_burn_cache();
12737
13610
  init_version();
12738
13611
  init_context();
13612
+ projectInput = z13.object({ projectId: z13.string() });
12739
13613
  systemRouter = router({
12740
13614
  version: publicProcedure.query(() => ({ version: runcastleVersion() })),
12741
- checkUpdate: publicProcedure.query(() => getUpdateInfo(runcastleVersion()))
13615
+ checkUpdate: publicProcedure.query(() => getUpdateInfo(runcastleVersion())),
13616
+ burnCache: router({
13617
+ status: publicProcedure.input(projectInput).query(async ({ ctx, input }) => {
13618
+ const mode = resolveBurnCacheMode(ctx.config);
13619
+ const engine = burnCacheEngine(ctx.config.sandbox);
13620
+ const sizeBytes = mode === "volume" && engine ? await burnCacheVolumeSize({
13621
+ engine,
13622
+ projectId: input.projectId,
13623
+ exec: createSystemExec()
13624
+ }) : null;
13625
+ return { mode, engine, volumeName: burnCacheVolumeName(input.projectId), sizeBytes };
13626
+ }),
13627
+ clear: publicProcedure.input(projectInput).mutation(async ({ ctx, input }) => {
13628
+ const engine = burnCacheEngine(ctx.config.sandbox);
13629
+ if (!engine) {
13630
+ throw new InvalidInputError(`the burn cache needs a docker or podman sandbox; this one is "${ctx.config.sandbox}"`);
13631
+ }
13632
+ const volumeName = burnCacheVolumeName(input.projectId);
13633
+ await removeBurnCacheVolume({
13634
+ engine,
13635
+ projectId: input.projectId,
13636
+ exec: createSystemExec(),
13637
+ slots: getBurnSlotAllocator(ctx.config.burnConcurrency).held()
13638
+ });
13639
+ emitProject(ctx, input.projectId, {
13640
+ type: "burn-cache.cleared",
13641
+ message: `cleared the burn cache volume ${volumeName}`
13642
+ });
13643
+ return { volumeName };
13644
+ })
13645
+ })
12742
13646
  });
12743
13647
  });
12744
13648
 
12745
13649
  // src/trpc/routers/test-notes.ts
12746
- import * as z12 from "zod";
13650
+ import * as z14 from "zod";
12747
13651
  var testNotesRouter;
12748
13652
  var init_test_notes2 = __esm(() => {
12749
13653
  init_test_notes();
12750
13654
  init_context();
12751
13655
  testNotesRouter = router({
12752
- list: publicProcedure.input(z12.object({ featureId: z12.string() })).query(({ ctx, input }) => listByFeature3(ctx, input.featureId)),
12753
- add: publicProcedure.input(z12.object({
12754
- featureId: z12.string(),
12755
- text: z12.string().min(1),
12756
- videoTimestamp: z12.number().nonnegative().optional()
13656
+ list: publicProcedure.input(z14.object({ featureId: z14.string() })).query(({ ctx, input }) => listByFeature4(ctx, input.featureId)),
13657
+ add: publicProcedure.input(z14.object({
13658
+ featureId: z14.string(),
13659
+ text: z14.string().min(1),
13660
+ videoTimestamp: z14.number().nonnegative().optional()
12757
13661
  })).mutation(({ ctx, input }) => addNote(ctx, input.featureId, input.text, "human", input.videoTimestamp)),
12758
- edit: publicProcedure.input(z12.object({ noteId: z12.string(), text: z12.string().min(1) })).mutation(({ ctx, input }) => editNote(ctx, input.noteId, input.text)),
12759
- remove: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => deleteNote(ctx, input.noteId)),
12760
- toggle: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => toggleNote(ctx, input.noteId)),
12761
- promote: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => promoteNote(ctx, input.noteId)),
12762
- promoteMany: publicProcedure.input(z12.object({ noteIds: z12.array(z12.string()).min(1) })).mutation(({ ctx, input }) => promoteMany(ctx, input.noteIds))
13662
+ edit: publicProcedure.input(z14.object({ noteId: z14.string(), text: z14.string().min(1) })).mutation(({ ctx, input }) => editNote(ctx, input.noteId, input.text)),
13663
+ remove: publicProcedure.input(z14.object({ noteId: z14.string() })).mutation(({ ctx, input }) => deleteNote(ctx, input.noteId)),
13664
+ toggle: publicProcedure.input(z14.object({ noteId: z14.string() })).mutation(({ ctx, input }) => toggleNote(ctx, input.noteId)),
13665
+ promote: publicProcedure.input(z14.object({ noteId: z14.string() })).mutation(({ ctx, input }) => promoteNote(ctx, input.noteId)),
13666
+ promoteMany: publicProcedure.input(z14.object({ noteIds: z14.array(z14.string()).min(1) })).mutation(({ ctx, input }) => promoteMany(ctx, input.noteIds))
12763
13667
  });
12764
13668
  });
12765
13669
 
12766
13670
  // src/trpc/routers/ticket.ts
12767
- import * as z13 from "zod";
13671
+ import * as z15 from "zod";
12768
13672
  var ticketRouter;
12769
13673
  var init_ticket = __esm(() => {
12770
13674
  init_features();
@@ -12773,8 +13677,8 @@ var init_ticket = __esm(() => {
12773
13677
  init_ticket_burner();
12774
13678
  init_context();
12775
13679
  ticketRouter = router({
12776
- retry: publicProcedure.input(z13.object({ ticketId: z13.string(), fresh: z13.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
12777
- stop: publicProcedure.input(z13.object({ ticketId: z13.string() })).mutation(({ ctx, input }) => {
13680
+ retry: publicProcedure.input(z15.object({ ticketId: z15.string(), fresh: z15.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
13681
+ stop: publicProcedure.input(z15.object({ ticketId: z15.string() })).mutation(({ ctx, input }) => {
12778
13682
  if (stopTicketRun(input.ticketId))
12779
13683
  return { stopped: true, swept: false };
12780
13684
  const ticket = getTicket(ctx, input.ticketId);
@@ -12784,14 +13688,14 @@ var init_ticket = __esm(() => {
12784
13688
  }
12785
13689
  return { stopped: false, swept: false };
12786
13690
  }),
12787
- cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason)),
12788
- edit: publicProcedure.input(z13.object({
12789
- ticketId: z13.string(),
12790
- title: z13.string().min(1).optional(),
12791
- goal: z13.string().min(1).optional(),
12792
- context: z13.string().optional(),
12793
- acceptanceCriteria: z13.array(z13.string().min(1)).optional(),
12794
- model: z13.string().optional()
13691
+ cancel: publicProcedure.input(z15.object({ ticketId: z15.string(), reason: z15.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason)),
13692
+ edit: publicProcedure.input(z15.object({
13693
+ ticketId: z15.string(),
13694
+ title: z15.string().min(1).optional(),
13695
+ goal: z15.string().min(1).optional(),
13696
+ context: z15.string().optional(),
13697
+ acceptanceCriteria: z15.array(z15.string().min(1)).optional(),
13698
+ model: z15.string().optional()
12795
13699
  })).mutation(({ ctx, input }) => {
12796
13700
  const { ticketId, ...patch } = input;
12797
13701
  return editTicket(ctx, ticketId, patch);
@@ -12807,6 +13711,7 @@ var init_router = __esm(() => {
12807
13711
  init_events2();
12808
13712
  init_feature();
12809
13713
  init_project();
13714
+ init_review_findings2();
12810
13715
  init_run();
12811
13716
  init_settings2();
12812
13717
  init_setup2();
@@ -12819,6 +13724,7 @@ var init_router = __esm(() => {
12819
13724
  run: runRouter,
12820
13725
  ticket: ticketRouter,
12821
13726
  notes: testNotesRouter,
13727
+ findings: reviewFindingsRouter,
12822
13728
  events: eventsRouter,
12823
13729
  docs: docsRouter,
12824
13730
  settings: settingsRouter,
@@ -12828,14 +13734,14 @@ var init_router = __esm(() => {
12828
13734
  });
12829
13735
 
12830
13736
  // src/workflows/reconcile-runs.ts
12831
- import { eq as eq15 } from "drizzle-orm";
13737
+ import { eq as eq16 } from "drizzle-orm";
12832
13738
  async function reconcileStaleRuns(ctx) {
12833
- const stale = ctx.db.select().from(runs).where(eq15(runs.status, "running")).all().map(rowToRun);
13739
+ const stale = ctx.db.select().from(runs).where(eq16(runs.status, "running")).all().map(rowToRun);
12834
13740
  const reconciled = [];
12835
13741
  for (const run4 of stale) {
12836
13742
  if (isRunActive(run4.id))
12837
13743
  continue;
12838
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq15(runs.id, run4.id)).run();
13744
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq16(runs.id, run4.id)).run();
12839
13745
  const released = releaseForSession(ctx, run4.id);
12840
13746
  const swept = workflowClaimsFeatureBranch(run4.workflow) ? sweepOrphanedBurning(ctx, run4.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
12841
13747
  if (workflowClaimsFeatureBranch(run4.workflow)) {