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