@glrs-dev/harness-plugin-opencode 0.3.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,23 +3,64 @@ import {
3
3
  createAgents,
4
4
  validateModelOverride
5
5
  } from "./chunk-CZMAJISX.js";
6
+ import {
7
+ getSessionsPath,
8
+ registerSession,
9
+ unregisterSession
10
+ } from "./chunk-RNRCXQ65.js";
6
11
  import {
7
12
  install,
8
13
  requirePlugin
9
14
  } from "./chunk-WBBN7OVN.js";
10
15
  import "./chunk-VJUETC6A.js";
16
+ import {
17
+ getPilotDir,
18
+ getPlanDir,
19
+ getPlansDir,
20
+ getRunDir,
21
+ getStateDbPath,
22
+ getTaskJsonlPath,
23
+ getWorkerJsonlPath,
24
+ migratePlans,
25
+ resolveBaseDir
26
+ } from "./chunk-BKTFWXLG.js";
27
+ import {
28
+ createRun,
29
+ getRun,
30
+ markRunFinished,
31
+ markRunResumed,
32
+ markRunRunning
33
+ } from "./chunk-5TAMY7P6.js";
34
+ import {
35
+ countByStatus,
36
+ getTask,
37
+ listTasks,
38
+ markAborted,
39
+ markBlocked,
40
+ markFailed,
41
+ markReady,
42
+ markRunning,
43
+ markSucceeded,
44
+ resetTasksForResume,
45
+ setCostUsd,
46
+ upsertFromPlan
47
+ } from "./chunk-57EOY72Y.js";
48
+ import {
49
+ checkCwdSafety,
50
+ headSha
51
+ } from "./chunk-KB7M7JXU.js";
11
52
 
12
53
  // src/cli.ts
13
54
  import {
14
55
  binary,
15
- command as command11,
16
- flag as flag8,
17
- option as option9,
18
- optional as optional10,
19
- positional as positional6,
56
+ command as command9,
57
+ flag as flag7,
58
+ option as option7,
59
+ optional as optional8,
60
+ positional as positional5,
20
61
  restPositionals,
21
- string as string10,
22
- subcommands as subcommands3,
62
+ string as string8,
63
+ subcommands as subcommands2,
23
64
  run
24
65
  } from "cmd-ts";
25
66
 
@@ -115,9 +156,9 @@ function getOpencodeConfigPath2() {
115
156
  const configHome = process.env["XDG_CONFIG_HOME"] ?? path2.join(os2.homedir(), ".config");
116
157
  return path2.join(configHome, "opencode", "opencode.json");
117
158
  }
118
- function cmd(command12) {
159
+ function cmd(command10) {
119
160
  try {
120
- return execSync(command12, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
161
+ return execSync(command10, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
121
162
  } catch {
122
163
  return null;
123
164
  }
@@ -328,132 +369,17 @@ function planCheck(args) {
328
369
  }
329
370
  }
330
371
 
331
- // src/plan-paths.ts
332
- import { execFile } from "child_process";
333
- import * as fs3 from "fs/promises";
334
- import * as os3 from "os";
335
- import * as path3 from "path";
336
- function execFileP(file, args, opts = {}) {
337
- const { cwd, timeoutMs = 5e3 } = opts;
338
- return new Promise((resolve5, reject) => {
339
- const controller = new AbortController();
340
- const timer = setTimeout(() => controller.abort(), timeoutMs);
341
- execFile(
342
- file,
343
- args,
344
- { signal: controller.signal, cwd, encoding: "utf8" },
345
- (err, stdout) => {
346
- clearTimeout(timer);
347
- if (err) {
348
- reject(err);
349
- return;
350
- }
351
- resolve5(stdout ?? "");
352
- }
353
- );
354
- });
355
- }
356
- function expandTilde(p) {
357
- if (p === "~") return os3.homedir();
358
- if (p.startsWith("~/")) return path3.join(os3.homedir(), p.slice(2));
359
- return p;
360
- }
361
- async function getRepoFolder(worktreeDir) {
362
- let stdout;
363
- try {
364
- stdout = await execFileP(
365
- "git",
366
- ["rev-parse", "--git-common-dir"],
367
- { cwd: worktreeDir }
368
- );
369
- } catch (err) {
370
- const msg = err instanceof Error ? err.message : "unknown error running `git rev-parse --git-common-dir`";
371
- throw new Error(
372
- `getRepoFolder: failed to resolve git-common-dir for ${worktreeDir}: ${msg}`
373
- );
374
- }
375
- const gitCommonDir = stdout.trim();
376
- if (!gitCommonDir) {
377
- throw new Error(
378
- `getRepoFolder: \`git rev-parse --git-common-dir\` returned empty for ${worktreeDir}`
379
- );
380
- }
381
- const absCommonDir = path3.isAbsolute(gitCommonDir) ? gitCommonDir : path3.resolve(worktreeDir, gitCommonDir);
382
- const repoRoot = path3.dirname(absCommonDir);
383
- return path3.basename(repoRoot);
384
- }
385
- async function getPlanDir(worktreeDir) {
386
- const override = process.env.GLORIOUS_PLAN_DIR;
387
- const base = override ? expandTilde(override) : path3.join(os3.homedir(), ".glorious", "opencode");
388
- const repoFolder = await getRepoFolder(worktreeDir);
389
- const planDir = path3.join(base, repoFolder, "plans");
390
- await fs3.mkdir(planDir, { recursive: true });
391
- return planDir;
392
- }
393
- async function migratePlans(worktreeDir, planDir) {
394
- const oldDir = path3.join(worktreeDir, ".agent", "plans");
395
- const marker = path3.join(oldDir, ".migrated");
396
- try {
397
- await fs3.stat(oldDir);
398
- } catch {
399
- return;
400
- }
401
- try {
402
- await fs3.stat(marker);
403
- return;
404
- } catch {
405
- }
406
- let entries;
407
- try {
408
- entries = await fs3.readdir(oldDir);
409
- } catch {
410
- return;
411
- }
412
- const planFiles = entries.filter(
413
- (name) => name.endsWith(".md") && !name.startsWith(".")
414
- );
415
- await fs3.mkdir(planDir, { recursive: true });
416
- for (const name of planFiles) {
417
- const src = path3.join(oldDir, name);
418
- const dst = path3.join(planDir, name);
419
- let dstExists = false;
420
- try {
421
- await fs3.stat(dst);
422
- dstExists = true;
423
- } catch {
424
- dstExists = false;
425
- }
426
- if (!dstExists) {
427
- await fs3.rename(src, dst);
428
- continue;
429
- }
430
- const [srcBuf, dstBuf] = await Promise.all([
431
- fs3.readFile(src),
432
- fs3.readFile(dst)
433
- ]);
434
- if (srcBuf.equals(dstBuf)) {
435
- await fs3.unlink(src);
436
- continue;
437
- }
438
- process.stderr.write(
439
- `[harness-opencode] migratePlans: conflict on ${name} \u2014 destination ${dst} exists with different content; leaving source ${src} in place. Resolve manually.
440
- `
441
- );
442
- }
443
- await fs3.writeFile(marker, "");
444
- }
445
-
446
372
  // src/pilot/cli/index.ts
447
- import { subcommands as subcommands2 } from "cmd-ts";
373
+ import { subcommands } from "cmd-ts";
448
374
 
449
375
  // src/pilot/cli/validate.ts
450
376
  import { command, optional, positional, string, flag } from "cmd-ts";
451
- import { promises as fs6 } from "fs";
452
- import * as path6 from "path";
453
-
454
- // src/pilot/plan/load.ts
455
377
  import { promises as fs4 } from "fs";
456
378
  import * as path4 from "path";
379
+
380
+ // src/pilot/plan/load.ts
381
+ import { promises as fs3 } from "fs";
382
+ import * as path3 from "path";
457
383
  import { parse as parseYaml, YAMLParseError } from "yaml";
458
384
 
459
385
  // src/pilot/plan/schema.ts
@@ -496,6 +422,7 @@ var TaskSchema = z.object({
496
422
  prompt: z.string().min(1, "task prompt must be non-empty"),
497
423
  context: z.string().optional(),
498
424
  touches: TouchesSchema.default([]),
425
+ tolerate: TouchesSchema.default([]),
499
426
  verify: z.array(VerifyCommandSchema).default([]),
500
427
  depends_on: z.array(z.string().regex(TASK_ID_PATTERN, "depends_on entries must be valid task IDs")).default([]),
501
428
  agent: z.string().min(1).optional(),
@@ -514,9 +441,34 @@ var PlanSchema = z.object({
514
441
  branch_prefix: z.string().min(1).optional(),
515
442
  defaults: DefaultsSchema,
516
443
  milestones: z.array(MilestoneSchema).default([]),
517
- setup: z.array(VerifyCommandSchema).default([]),
518
444
  tasks: z.array(TaskSchema).min(1, "plan must declare at least one task")
519
- }).strict();
445
+ }).passthrough().superRefine((val, ctx) => {
446
+ const known = /* @__PURE__ */ new Set([
447
+ "name",
448
+ "branch_prefix",
449
+ "defaults",
450
+ "milestones",
451
+ "tasks"
452
+ ]);
453
+ if (typeof val !== "object" || val === null) return;
454
+ const v = val;
455
+ for (const key of Object.keys(v)) {
456
+ if (known.has(key)) continue;
457
+ if (key === "setup") {
458
+ ctx.addIssue({
459
+ code: z.ZodIssueCode.custom,
460
+ path: ["setup"],
461
+ message: "The 'setup:' field was removed in the cwd-mode rollback. Run setup commands manually before 'pilot build' \u2014 see src/pilot/AGENTS.md for the new contract."
462
+ });
463
+ } else {
464
+ ctx.addIssue({
465
+ code: z.ZodIssueCode.custom,
466
+ path: [key],
467
+ message: `Unrecognized key: ${JSON.stringify(key)}`
468
+ });
469
+ }
470
+ }
471
+ });
520
472
  function parsePlan(input) {
521
473
  const result = PlanSchema.safeParse(input);
522
474
  if (result.success) {
@@ -547,10 +499,10 @@ async function loadPlan(absPath) {
547
499
  if (typeof absPath !== "string") {
548
500
  throw new TypeError(`loadPlan: expected string path, got ${typeof absPath}`);
549
501
  }
550
- const resolved = path4.resolve(absPath);
502
+ const resolved = path3.resolve(absPath);
551
503
  let raw;
552
504
  try {
553
- raw = await fs4.readFile(resolved, "utf8");
505
+ raw = await fs3.readFile(resolved, "utf8");
554
506
  } catch (err) {
555
507
  return {
556
508
  ok: false,
@@ -804,98 +756,6 @@ function findTouchConflicts(tasks) {
804
756
  return conflicts;
805
757
  }
806
758
 
807
- // src/pilot/paths.ts
808
- import { promises as fs5 } from "fs";
809
- import * as os4 from "os";
810
- import * as path5 from "path";
811
- function expandTilde2(p) {
812
- if (p === "~") return os4.homedir();
813
- if (p.startsWith("~/")) return path5.join(os4.homedir(), p.slice(2));
814
- return p;
815
- }
816
- function resolveBaseDir() {
817
- const pilotEnv = process.env.GLORIOUS_PILOT_DIR;
818
- if (pilotEnv) return expandTilde2(pilotEnv);
819
- const planEnv = process.env.GLORIOUS_PLAN_DIR;
820
- if (planEnv) {
821
- return path5.dirname(expandTilde2(planEnv));
822
- }
823
- return path5.join(os4.homedir(), ".glorious", "opencode");
824
- }
825
- function padWorker(n) {
826
- if (!Number.isInteger(n) || n < 0) {
827
- throw new RangeError(`worker index must be a non-negative integer, got ${n}`);
828
- }
829
- return n.toString().padStart(2, "0");
830
- }
831
- async function getPilotDir(cwd) {
832
- const base = resolveBaseDir();
833
- const repoFolder = await getRepoFolder(cwd);
834
- const dir = path5.join(base, repoFolder, "pilot");
835
- await fs5.mkdir(dir, { recursive: true });
836
- return dir;
837
- }
838
- async function getPlansDir(cwd) {
839
- const pilot = await getPilotDir(cwd);
840
- const dir = path5.join(pilot, "plans");
841
- await fs5.mkdir(dir, { recursive: true });
842
- return dir;
843
- }
844
- async function getRunDir(cwd, runId) {
845
- if (!isSafeRunId(runId)) {
846
- throw new Error(
847
- `getRunDir: runId ${JSON.stringify(runId)} is not a safe filesystem segment`
848
- );
849
- }
850
- const pilot = await getPilotDir(cwd);
851
- const dir = path5.join(pilot, "runs", runId);
852
- await fs5.mkdir(dir, { recursive: true });
853
- return dir;
854
- }
855
- async function getWorktreeDir(cwd, runId, n) {
856
- if (!isSafeRunId(runId)) {
857
- throw new Error(
858
- `getWorktreeDir: runId ${JSON.stringify(runId)} is not a safe filesystem segment`
859
- );
860
- }
861
- const pilot = await getPilotDir(cwd);
862
- const parent = path5.join(pilot, "worktrees", runId);
863
- await fs5.mkdir(parent, { recursive: true });
864
- return path5.join(parent, padWorker(n));
865
- }
866
- async function getStateDbPath(cwd, runId) {
867
- const runDir = await getRunDir(cwd, runId);
868
- return path5.join(runDir, "state.db");
869
- }
870
- async function getWorkerJsonlPath(cwd, runId, n) {
871
- const runDir = await getRunDir(cwd, runId);
872
- const workersDir = path5.join(runDir, "workers");
873
- await fs5.mkdir(workersDir, { recursive: true });
874
- return path5.join(workersDir, `${padWorker(n)}.jsonl`);
875
- }
876
- async function getTaskJsonlPath(cwd, runId, taskId) {
877
- if (!isSafeRunId(runId)) {
878
- throw new Error(
879
- `getTaskJsonlPath: runId ${JSON.stringify(runId)} is not a safe filesystem segment`
880
- );
881
- }
882
- if (!isSafeTaskId(taskId)) {
883
- throw new Error(
884
- `getTaskJsonlPath: taskId ${JSON.stringify(taskId)} is not a safe filesystem segment`
885
- );
886
- }
887
- const runDir = await getRunDir(cwd, runId);
888
- const taskDir = path5.join(runDir, "tasks", taskId);
889
- await fs5.mkdir(taskDir, { recursive: true });
890
- return path5.join(taskDir, "session.jsonl");
891
- }
892
- function isSafeRunId(runId) {
893
- return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(runId);
894
- }
895
- function isSafeTaskId(taskId) {
896
- return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(taskId);
897
- }
898
-
899
759
  // src/pilot/cli/validate.ts
900
760
  var validateCmd = command({
901
761
  name: "validate",
@@ -982,17 +842,17 @@ async function runValidate(opts) {
982
842
  }
983
843
  async function resolvePlanPath(input) {
984
844
  if (input !== void 0 && input.length > 0) {
985
- const resolved = path6.resolve(input);
986
- let stat2;
845
+ const resolved = path4.resolve(input);
846
+ let stat;
987
847
  try {
988
- stat2 = await fs6.stat(resolved);
848
+ stat = await fs4.stat(resolved);
989
849
  } catch (err) {
990
850
  throw new Error(
991
851
  `cannot stat ${JSON.stringify(resolved)}: ${err instanceof Error ? err.message : String(err)}`
992
852
  );
993
853
  }
994
- if (stat2.isFile()) return resolved;
995
- if (stat2.isDirectory()) return findNewestYaml(resolved);
854
+ if (stat.isFile()) return resolved;
855
+ if (stat.isDirectory()) return findNewestYaml(resolved);
996
856
  throw new Error(
997
857
  `${JSON.stringify(resolved)} is neither a file nor a directory`
998
858
  );
@@ -1003,7 +863,7 @@ async function resolvePlanPath(input) {
1003
863
  async function findNewestYaml(dir) {
1004
864
  let entries;
1005
865
  try {
1006
- entries = await fs6.readdir(dir);
866
+ entries = await fs4.readdir(dir);
1007
867
  } catch (err) {
1008
868
  throw new Error(
1009
869
  `cannot read directory ${JSON.stringify(dir)}: ${err instanceof Error ? err.message : String(err)}`
@@ -1017,14 +877,14 @@ async function findNewestYaml(dir) {
1017
877
  }
1018
878
  let newest = null;
1019
879
  for (const name of yamls) {
1020
- const full = path6.join(dir, name);
1021
- let stat2;
880
+ const full = path4.join(dir, name);
881
+ let stat;
1022
882
  try {
1023
- stat2 = await fs6.stat(full);
883
+ stat = await fs4.stat(full);
1024
884
  } catch {
1025
885
  continue;
1026
886
  }
1027
- const mtime = stat2.mtimeMs;
887
+ const mtime = stat.mtimeMs;
1028
888
  if (newest === null || mtime > newest.mtime) {
1029
889
  newest = { name, mtime };
1030
890
  }
@@ -1032,14 +892,14 @@ async function findNewestYaml(dir) {
1032
892
  if (newest === null) {
1033
893
  throw new Error(`no readable *.yaml files in ${JSON.stringify(dir)}`);
1034
894
  }
1035
- return path6.join(dir, newest.name);
895
+ return path4.join(dir, newest.name);
1036
896
  }
1037
897
 
1038
898
  // src/pilot/cli/plan.ts
1039
899
  import { command as command2, optional as optional2, positional as positional2, string as string2, option } from "cmd-ts";
1040
900
  import { spawn } from "child_process";
1041
- import { promises as fs7 } from "fs";
1042
- import * as path7 from "path";
901
+ import { promises as fs5 } from "fs";
902
+ import * as path5 from "path";
1043
903
  var PLANNER_AGENT = "pilot-planner";
1044
904
  var planCmd = command2({
1045
905
  name: "plan",
@@ -1133,15 +993,15 @@ async function snapshotYamls(dir) {
1133
993
  const out = /* @__PURE__ */ new Map();
1134
994
  let entries;
1135
995
  try {
1136
- entries = await fs7.readdir(dir);
996
+ entries = await fs5.readdir(dir);
1137
997
  } catch {
1138
998
  return out;
1139
999
  }
1140
1000
  for (const name of entries) {
1141
1001
  if (!name.endsWith(".yaml") && !name.endsWith(".yml")) continue;
1142
- const full = path7.join(dir, name);
1002
+ const full = path5.join(dir, name);
1143
1003
  try {
1144
- const st = await fs7.stat(full);
1004
+ const st = await fs5.stat(full);
1145
1005
  out.set(full, st.mtimeMs);
1146
1006
  } catch {
1147
1007
  }
@@ -1165,18 +1025,18 @@ function pickNewestNew(before, after) {
1165
1025
  return { path: pool[0].path, mtimeMs: pool[0].mtimeMs };
1166
1026
  }
1167
1027
  function spawnTui(args) {
1168
- return new Promise((resolve5) => {
1028
+ return new Promise((resolve6) => {
1169
1029
  const child = spawn(args.bin, args.args, {
1170
1030
  cwd: args.cwd,
1171
1031
  stdio: "inherit"
1172
1032
  });
1173
- child.on("exit", (code) => resolve5(code ?? 1));
1033
+ child.on("exit", (code) => resolve6(code ?? 1));
1174
1034
  child.on("error", (err) => {
1175
1035
  process.stderr.write(
1176
1036
  `pilot plan: failed to spawn ${args.bin}: ${err.message}
1177
1037
  `
1178
1038
  );
1179
- resolve5(1);
1039
+ resolve6(1);
1180
1040
  });
1181
1041
  });
1182
1042
  }
@@ -1191,7 +1051,7 @@ import {
1191
1051
  string as string3,
1192
1052
  number as cmdNumber
1193
1053
  } from "cmd-ts";
1194
- import * as path9 from "path";
1054
+ import * as path7 from "path";
1195
1055
 
1196
1056
  // src/pilot/plan/slug.ts
1197
1057
  var MAX_SLUG_LENGTH = 50;
@@ -1378,18 +1238,18 @@ function splitStatements(sql) {
1378
1238
  }
1379
1239
 
1380
1240
  // src/pilot/state/db.ts
1381
- function openStateDb(path13) {
1382
- const db = new Database(path13, { create: true });
1241
+ function openStateDb(path11) {
1242
+ const db = new Database(path11, { create: true });
1383
1243
  try {
1384
1244
  db.run("PRAGMA foreign_keys = ON");
1385
- if (path13 !== ":memory:") {
1245
+ if (path11 !== ":memory:") {
1386
1246
  db.run("PRAGMA journal_mode = WAL");
1387
1247
  db.run("PRAGMA synchronous = NORMAL");
1388
1248
  }
1389
1249
  } catch (err) {
1390
1250
  db.close();
1391
1251
  throw new Error(
1392
- `openStateDb: failed to set PRAGMAs on ${JSON.stringify(path13)}: ${err instanceof Error ? err.message : String(err)}`
1252
+ `openStateDb: failed to set PRAGMAs on ${JSON.stringify(path11)}: ${err instanceof Error ? err.message : String(err)}`
1393
1253
  );
1394
1254
  }
1395
1255
  let newlyApplied;
@@ -1406,180 +1266,6 @@ function openStateDb(path13) {
1406
1266
  };
1407
1267
  }
1408
1268
 
1409
- // src/pilot/state/runs.ts
1410
- import { ulid } from "ulid";
1411
- function createRun(db, args) {
1412
- const id = ulid();
1413
- const now = args.now ?? Date.now();
1414
- db.run(
1415
- `INSERT INTO runs (id, plan_path, plan_slug, started_at, status)
1416
- VALUES (?, ?, ?, ?, 'pending')`,
1417
- [id, args.planPath, args.slug, now]
1418
- );
1419
- void args.plan;
1420
- return id;
1421
- }
1422
- function markRunRunning(db, runId) {
1423
- const cur = getRun(db, runId);
1424
- if (!cur) throw new Error(`markRunRunning: run ${JSON.stringify(runId)} not found`);
1425
- if (cur.status === "running") return;
1426
- if (cur.status !== "pending") {
1427
- throw new Error(
1428
- `markRunRunning: cannot move run ${JSON.stringify(runId)} from ${cur.status} to running`
1429
- );
1430
- }
1431
- db.run("UPDATE runs SET status='running' WHERE id=?", [runId]);
1432
- }
1433
- function markRunFinished(db, runId, status, now = Date.now()) {
1434
- if (status !== "completed" && status !== "aborted" && status !== "failed") {
1435
- throw new Error(
1436
- `markRunFinished: ${JSON.stringify(status)} is not a terminal status`
1437
- );
1438
- }
1439
- const cur = getRun(db, runId);
1440
- if (!cur) {
1441
- throw new Error(`markRunFinished: run ${JSON.stringify(runId)} not found`);
1442
- }
1443
- db.run("UPDATE runs SET status=?, finished_at=? WHERE id=?", [status, now, runId]);
1444
- }
1445
- function getRun(db, runId) {
1446
- const row = db.query("SELECT * FROM runs WHERE id=?").get(runId);
1447
- return row;
1448
- }
1449
-
1450
- // src/pilot/state/tasks.ts
1451
- function upsertFromPlan(db, runId, plan) {
1452
- const stmt = db.prepare(
1453
- `INSERT OR IGNORE INTO tasks (run_id, task_id, status) VALUES (?, ?, 'pending')`
1454
- );
1455
- const tx = db.transaction(() => {
1456
- for (const t of plan.tasks) {
1457
- stmt.run(runId, t.id);
1458
- }
1459
- });
1460
- tx();
1461
- }
1462
- function markReady(db, runId, taskId) {
1463
- requireStatus(db, runId, taskId, ["pending"], "ready");
1464
- db.run(
1465
- "UPDATE tasks SET status='ready' WHERE run_id=? AND task_id=?",
1466
- [runId, taskId]
1467
- );
1468
- }
1469
- function markRunning(db, args) {
1470
- requireStatus(db, args.runId, args.taskId, ["ready"], "running");
1471
- const now = args.now ?? Date.now();
1472
- db.run(
1473
- `UPDATE tasks
1474
- SET status='running',
1475
- attempts = attempts + 1,
1476
- session_id = ?,
1477
- branch = ?,
1478
- worktree_path = ?,
1479
- started_at = COALESCE(started_at, ?)
1480
- WHERE run_id=? AND task_id=?`,
1481
- [args.sessionId, args.branch, args.worktreePath, now, args.runId, args.taskId]
1482
- );
1483
- }
1484
- function markSucceeded(db, runId, taskId, now = Date.now()) {
1485
- requireStatus(db, runId, taskId, ["running"], "succeeded");
1486
- db.run(
1487
- `UPDATE tasks
1488
- SET status='succeeded', finished_at=?, last_error=NULL
1489
- WHERE run_id=? AND task_id=?`,
1490
- [now, runId, taskId]
1491
- );
1492
- }
1493
- function markFailed(db, runId, taskId, reason, now = Date.now()) {
1494
- requireStatus(db, runId, taskId, ["running", "ready"], "failed");
1495
- db.run(
1496
- `UPDATE tasks
1497
- SET status='failed', finished_at=?, last_error=?
1498
- WHERE run_id=? AND task_id=?`,
1499
- [now, reason, runId, taskId]
1500
- );
1501
- }
1502
- function markBlocked(db, runId, taskId, reason) {
1503
- requireStatus(db, runId, taskId, ["pending", "ready"], "blocked");
1504
- db.run(
1505
- `UPDATE tasks
1506
- SET status='blocked', last_error=?
1507
- WHERE run_id=? AND task_id=?`,
1508
- [reason, runId, taskId]
1509
- );
1510
- }
1511
- function markAborted(db, runId, taskId, reason, now = Date.now()) {
1512
- requireStatus(db, runId, taskId, ["running", "ready"], "aborted");
1513
- db.run(
1514
- `UPDATE tasks
1515
- SET status='aborted', finished_at=?, last_error=?
1516
- WHERE run_id=? AND task_id=?`,
1517
- [now, reason, runId, taskId]
1518
- );
1519
- }
1520
- function markPending(db, runId, taskId) {
1521
- const cur = getTask(db, runId, taskId);
1522
- if (!cur) {
1523
- throw new Error(
1524
- `markPending: task ${JSON.stringify(taskId)} not found in run ${JSON.stringify(runId)}`
1525
- );
1526
- }
1527
- db.run(
1528
- `UPDATE tasks
1529
- SET status='pending',
1530
- session_id=NULL,
1531
- branch=NULL,
1532
- worktree_path=NULL,
1533
- started_at=NULL,
1534
- finished_at=NULL,
1535
- last_error=NULL
1536
- WHERE run_id=? AND task_id=?`,
1537
- [runId, taskId]
1538
- );
1539
- }
1540
- function setCostUsd(db, runId, taskId, costUsd) {
1541
- if (!Number.isFinite(costUsd) || costUsd < 0) {
1542
- throw new RangeError(`setCostUsd: invalid cost ${costUsd}`);
1543
- }
1544
- db.run(
1545
- "UPDATE tasks SET cost_usd=? WHERE run_id=? AND task_id=?",
1546
- [costUsd, runId, taskId]
1547
- );
1548
- }
1549
- function getTask(db, runId, taskId) {
1550
- return db.query("SELECT * FROM tasks WHERE run_id=? AND task_id=?").get(runId, taskId);
1551
- }
1552
- function listTasks(db, runId) {
1553
- return db.query("SELECT * FROM tasks WHERE run_id=? ORDER BY task_id").all(runId);
1554
- }
1555
- function countByStatus(db, runId) {
1556
- const rows = db.query("SELECT status, COUNT(*) as n FROM tasks WHERE run_id=? GROUP BY status").all(runId);
1557
- const out = {
1558
- pending: 0,
1559
- ready: 0,
1560
- running: 0,
1561
- succeeded: 0,
1562
- failed: 0,
1563
- blocked: 0,
1564
- aborted: 0
1565
- };
1566
- for (const r of rows) out[r.status] = r.n;
1567
- return out;
1568
- }
1569
- function requireStatus(db, runId, taskId, expected, intended) {
1570
- const row = getTask(db, runId, taskId);
1571
- if (!row) {
1572
- throw new Error(
1573
- `task ${JSON.stringify(taskId)} not found in run ${JSON.stringify(runId)}`
1574
- );
1575
- }
1576
- if (!expected.includes(row.status)) {
1577
- throw new Error(
1578
- `cannot move task ${JSON.stringify(taskId)} from ${row.status} to ${intended} (expected one of: ${expected.join(", ")})`
1579
- );
1580
- }
1581
- }
1582
-
1583
1269
  // src/pilot/state/events.ts
1584
1270
  function appendEvent(db, args) {
1585
1271
  const ts = args.now ?? Date.now();
@@ -1644,9 +1330,10 @@ function tryParseJson(s) {
1644
1330
  }
1645
1331
 
1646
1332
  // src/pilot/opencode/server.ts
1647
- import { execFile as execFile2 } from "child_process";
1648
- import * as fs8 from "fs";
1649
- import * as path8 from "path";
1333
+ import { execFile } from "child_process";
1334
+ import * as fs6 from "fs";
1335
+ import * as path6 from "path";
1336
+ import { fileURLToPath as fileURLToPath2 } from "url";
1650
1337
  import {
1651
1338
  createOpencodeServer,
1652
1339
  createOpencodeClient
@@ -1658,7 +1345,7 @@ async function startOpencodeServer(options = {}) {
1658
1345
  const port = options.port ?? DEFAULT_PORT;
1659
1346
  const hostname = options.hostname ?? "127.0.0.1";
1660
1347
  await ensureOpencodeOnPath();
1661
- const serverConfig = buildPilotServerConfig();
1348
+ const serverConfig = buildPilotServerConfig(options.runContext);
1662
1349
  void options.cwd;
1663
1350
  let server;
1664
1351
  try {
@@ -1675,8 +1362,8 @@ async function startOpencodeServer(options = {}) {
1675
1362
  }
1676
1363
  if (options.serverLogPath) {
1677
1364
  try {
1678
- fs8.mkdirSync(path8.dirname(options.serverLogPath), { recursive: true });
1679
- fs8.writeFileSync(
1365
+ fs6.mkdirSync(path6.dirname(options.serverLogPath), { recursive: true });
1366
+ fs6.writeFileSync(
1680
1367
  options.serverLogPath,
1681
1368
  `# pilot opencode server spawn ${(/* @__PURE__ */ new Date()).toISOString()}
1682
1369
  # url=${server.url} hostname=${hostname} port=${port} timeoutMs=${timeoutMs}
@@ -1702,15 +1389,38 @@ async function startOpencodeServer(options = {}) {
1702
1389
  };
1703
1390
  return { url: server.url, client, shutdown };
1704
1391
  }
1705
- function buildPilotServerConfig() {
1392
+ function buildPilotServerConfig(runContext) {
1706
1393
  const agents = createAgents();
1707
1394
  const pilotAgents = {};
1708
1395
  for (const name of ["pilot-builder", "pilot-planner"]) {
1709
1396
  if (name in agents) pilotAgents[name] = agents[name];
1710
1397
  }
1711
- return {
1398
+ const config = {
1712
1399
  agent: pilotAgents
1713
1400
  };
1401
+ if (runContext) {
1402
+ const sessionsPath = getSessionsPath(runContext.runDir);
1403
+ const distDir = path6.dirname(fileURLToPath2(import.meta.url));
1404
+ const statusServerPath = path6.resolve(
1405
+ distDir,
1406
+ "pilot",
1407
+ "mcp",
1408
+ "status-server.js"
1409
+ );
1410
+ config.mcp = {
1411
+ pilot_status: {
1412
+ type: "local",
1413
+ command: ["bun", "run", statusServerPath],
1414
+ env: {
1415
+ PILOT_SESSIONS_PATH: sessionsPath,
1416
+ PILOT_STATE_DB_PATH: runContext.dbPath,
1417
+ PILOT_RUN_ID: runContext.runId
1418
+ },
1419
+ enabled: true
1420
+ }
1421
+ };
1422
+ }
1423
+ return config;
1714
1424
  }
1715
1425
  function resolveTimeoutMs(explicit) {
1716
1426
  if (typeof explicit === "number" && explicit > 0) return explicit;
@@ -1726,10 +1436,10 @@ function resolveTimeoutMs(explicit) {
1726
1436
  return DEFAULT_STARTUP_TIMEOUT_MS;
1727
1437
  }
1728
1438
  async function ensureOpencodeOnPath() {
1729
- await new Promise((resolve5, reject) => {
1439
+ await new Promise((resolve6, reject) => {
1730
1440
  const controller = new AbortController();
1731
1441
  const timer = setTimeout(() => controller.abort(), 5e3);
1732
- execFile2(
1442
+ execFile(
1733
1443
  "opencode",
1734
1444
  ["--version"],
1735
1445
  { signal: controller.signal, encoding: "utf8" },
@@ -1743,7 +1453,7 @@ async function ensureOpencodeOnPath() {
1743
1453
  );
1744
1454
  return;
1745
1455
  }
1746
- resolve5();
1456
+ resolve6();
1747
1457
  }
1748
1458
  );
1749
1459
  });
@@ -1823,7 +1533,7 @@ var EventBus = class {
1823
1533
  waitForIdle(sessionId, options = {}) {
1824
1534
  const stallMs = options.stallMs ?? 60 * 60 * 1e3;
1825
1535
  const errorIsFatal = options.errorIsFatal ?? true;
1826
- return new Promise((resolve5) => {
1536
+ return new Promise((resolve6) => {
1827
1537
  let settled = false;
1828
1538
  let stallTimer = null;
1829
1539
  let unsubscribe = () => {
@@ -1836,7 +1546,7 @@ var EventBus = class {
1836
1546
  if (stallTimer) clearTimeout(stallTimer);
1837
1547
  unsubscribe();
1838
1548
  removeAbortListener();
1839
- resolve5(result);
1549
+ resolve6(result);
1840
1550
  };
1841
1551
  const armStallTimer = () => {
1842
1552
  if (stallTimer) clearTimeout(stallTimer);
@@ -1962,416 +1672,17 @@ function isEventLike(v) {
1962
1672
  return typeof o.type === "string" && typeof o.properties === "object" && o.properties !== null;
1963
1673
  }
1964
1674
 
1965
- // src/pilot/worktree/git.ts
1966
- import { execFile as execFile3 } from "child_process";
1967
- function execFileP2(file, args, opts = {}) {
1968
- const { cwd, timeoutMs = 3e4, env } = opts;
1969
- return new Promise((resolve5, reject) => {
1970
- const controller = new AbortController();
1971
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1972
- execFile3(
1973
- file,
1974
- args,
1975
- {
1976
- signal: controller.signal,
1977
- cwd,
1978
- encoding: "utf8",
1979
- env,
1980
- // Increase maxBuffer — git diff/log output can exceed the
1981
- // 1MB default on large repos.
1982
- maxBuffer: 16 * 1024 * 1024
1983
- },
1984
- (err, stdout, stderr) => {
1985
- clearTimeout(timer);
1986
- if (err) {
1987
- const msg = `${err.message}${stderr ? `
1988
- stderr:
1989
- ${stderr}` : ""}`;
1990
- reject(new Error(msg));
1991
- return;
1992
- }
1993
- resolve5({ stdout: stdout ?? "", stderr: stderr ?? "" });
1994
- }
1995
- );
1996
- });
1997
- }
1998
- function assertSafeArg(s, label) {
1999
- if (typeof s !== "string" || s.length === 0) {
2000
- throw new TypeError(`${label}: expected non-empty string, got ${JSON.stringify(s)}`);
2001
- }
2002
- if (s.includes("\0")) {
2003
- throw new TypeError(`${label}: contains null byte: ${JSON.stringify(s)}`);
2004
- }
2005
- }
2006
- async function headSha(repoOrWorktree) {
2007
- assertSafeArg(repoOrWorktree, "headSha repo");
2008
- const { stdout } = await execFileP2("git", [
2009
- "-C",
2010
- repoOrWorktree,
2011
- "rev-parse",
2012
- "HEAD"
2013
- ]);
2014
- return stdout.trim();
2015
- }
2016
- async function gitWorktreeAdd(args) {
2017
- assertSafeArg(args.repoPath, "repoPath");
2018
- assertSafeArg(args.worktreePath, "worktreePath");
2019
- assertSafeArg(args.commitIsh, "commitIsh");
2020
- const cmd2 = ["-C", args.repoPath, "worktree", "add"];
2021
- if (args.branch !== void 0) {
2022
- assertSafeArg(args.branch, "branch");
2023
- cmd2.push("-B", args.branch);
2024
- }
2025
- cmd2.push(args.worktreePath, args.commitIsh);
2026
- await execFileP2("git", cmd2);
2027
- }
2028
- async function gitWorktreeRemove(args) {
2029
- assertSafeArg(args.repoPath, "repoPath");
2030
- assertSafeArg(args.worktreePath, "worktreePath");
2031
- try {
2032
- await execFileP2("git", [
2033
- "-C",
2034
- args.repoPath,
2035
- "worktree",
2036
- "remove",
2037
- "--force",
2038
- args.worktreePath
2039
- ]);
2040
- } catch (err) {
2041
- const msg = err instanceof Error ? err.message : String(err);
2042
- if (/is not a working tree|worktree.*does not exist/i.test(msg)) {
2043
- return;
2044
- }
2045
- throw err;
2046
- }
2047
- }
2048
- async function gitWorktreeList(repoPath) {
2049
- assertSafeArg(repoPath, "repoPath");
2050
- const { stdout } = await execFileP2("git", [
2051
- "-C",
2052
- repoPath,
2053
- "worktree",
2054
- "list",
2055
- "--porcelain"
2056
- ]);
2057
- const records = [];
2058
- let cur = null;
2059
- for (const line of stdout.split("\n")) {
2060
- if (line.length === 0) {
2061
- if (cur && cur.path) records.push(finalizeWorktreeInfo(cur));
2062
- cur = null;
2063
- continue;
2064
- }
2065
- if (cur === null) cur = {};
2066
- const [keyRaw, ...rest] = line.split(" ");
2067
- const value = rest.join(" ");
2068
- switch (keyRaw) {
2069
- case "worktree":
2070
- cur.path = value;
2071
- break;
2072
- case "HEAD":
2073
- cur.head = value;
2074
- break;
2075
- case "branch":
2076
- cur.branch = value.startsWith("refs/heads/") ? value.slice("refs/heads/".length) : value;
2077
- break;
2078
- case "detached":
2079
- cur.branch = null;
2080
- break;
2081
- case "bare":
2082
- cur.bare = true;
2083
- break;
2084
- default:
2085
- break;
2086
- }
2087
- }
2088
- if (cur && cur.path) records.push(finalizeWorktreeInfo(cur));
2089
- return records;
2090
- }
2091
- function finalizeWorktreeInfo(p) {
2092
- return {
2093
- path: p.path,
2094
- head: p.head ?? "",
2095
- branch: p.branch ?? null,
2096
- bare: p.bare ?? false
2097
- };
2098
- }
2099
- async function checkoutFreshBranch(args) {
2100
- assertSafeArg(args.worktree, "worktree");
2101
- assertSafeArg(args.branch, "branch");
2102
- assertSafeArg(args.base, "base");
2103
- await execFileP2("git", [
2104
- "-C",
2105
- args.worktree,
2106
- "checkout",
2107
- "-B",
2108
- args.branch,
2109
- args.base
2110
- ]);
2111
- }
2112
- async function cleanWorktree(worktree) {
2113
- assertSafeArg(worktree, "worktree");
2114
- await execFileP2("git", ["-C", worktree, "reset", "--hard"]);
2115
- await execFileP2("git", ["-C", worktree, "clean", "-fdx"]);
2116
- }
2117
- async function commitAll(args) {
2118
- assertSafeArg(args.worktree, "worktree");
2119
- if (typeof args.message !== "string" || args.message.length === 0) {
2120
- throw new TypeError("commitAll: message must be non-empty");
2121
- }
2122
- await execFileP2("git", ["-C", args.worktree, "add", "-A"]);
2123
- const env = { ...process.env };
2124
- if (args.authorName) env.GIT_AUTHOR_NAME = args.authorName;
2125
- if (args.authorEmail) env.GIT_AUTHOR_EMAIL = args.authorEmail;
2126
- if (args.authorName) env.GIT_COMMITTER_NAME = args.authorName;
2127
- if (args.authorEmail) env.GIT_COMMITTER_EMAIL = args.authorEmail;
2128
- await execFileP2("git", ["-C", args.worktree, "commit", "-m", args.message], {
2129
- env
2130
- });
2131
- return headSha(args.worktree);
2132
- }
2133
- async function diffNamesSince(worktree, sinceSha) {
2134
- assertSafeArg(worktree, "worktree");
2135
- assertSafeArg(sinceSha, "sinceSha");
2136
- const sets = await Promise.all([
2137
- runDiffNames(worktree, ["diff", "--name-only", `${sinceSha}..HEAD`]),
2138
- runDiffNames(worktree, ["diff", "--name-only", "--cached"]),
2139
- runDiffNames(worktree, ["diff", "--name-only"]),
2140
- runDiffNames(worktree, [
2141
- "ls-files",
2142
- "--others",
2143
- "--exclude-standard"
2144
- ])
2145
- ]);
2146
- const all = /* @__PURE__ */ new Set();
2147
- for (const s of sets) for (const p of s) all.add(p);
2148
- return [...all].sort();
2149
- }
2150
- async function runDiffNames(worktree, args) {
2151
- const { stdout } = await execFileP2("git", ["-C", worktree, ...args]);
2152
- return stdout.split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
2153
- }
2154
-
2155
- // src/pilot/worktree/pool.ts
2156
- import { promises as fs9 } from "fs";
2157
- var WorktreePool = class {
2158
- repoPath;
2159
- worktreeDirOf;
2160
- slots = /* @__PURE__ */ new Map();
2161
- /**
2162
- * Slots that were preserved on failure. No longer reachable via
2163
- * `acquire` — they stay here for `shutdown` (so `keepPreserved=false`
2164
- * can still clean them up) and `inspect` (so debug tooling sees them).
2165
- *
2166
- * When a slot is preserved and a subsequent `acquire(n)` happens, the
2167
- * current live slot at index `n` is MOVED here, and `slots` gets a
2168
- * fresh stub at `n` with a bumped `retryCounter`. This is what
2169
- * prevents a single failed task from poisoning every downstream task
2170
- * (the pre-v0.2 bug: one preserve → all subsequent `prepare` calls
2171
- * threw "slot N is preserved").
2172
- */
2173
- retiredSlots = [];
2174
- /**
2175
- * Per-index retry counter. Bumps every time `acquire` retires a
2176
- * preserved slot. Read by `prepare` to decide whether the worktree
2177
- * path needs a `-<counter>` suffix (for retried slots) or the bare
2178
- * `worktreeDirOf(n)` path (first-ever use — back-compat with the
2179
- * existing on-disk layout).
2180
- */
2181
- retryCounter = /* @__PURE__ */ new Map();
2182
- workerCount;
2183
- /**
2184
- * Set of workers currently held by an `acquire`. v0.1 only ever holds
2185
- * one at a time, but the structure scales to v0.3.
2186
- */
2187
- busy = /* @__PURE__ */ new Set();
2188
- constructor(opts) {
2189
- this.repoPath = opts.repoPath;
2190
- this.worktreeDirOf = opts.worktreeDir;
2191
- const requested = opts.workerCount ?? 1;
2192
- if (requested > 1) {
2193
- process.stderr.write(
2194
- `[pilot] WorktreePool: workerCount=${requested} requested, but v0.1 supports only 1 \u2014 clamping.
2195
- `
2196
- );
2197
- }
2198
- this.workerCount = 1;
2199
- }
2200
- /**
2201
- * Acquire a worker slot. Returns the live slot for the given worker
2202
- * index, or a fresh stub if the current live slot was preserved on
2203
- * failure.
2204
- *
2205
- * v0.1 always uses slot 0. First call returns a fresh stub. If that
2206
- * slot is later `preserveOnFailure`'d, the next `acquire()` retires
2207
- * the preserved slot into `retiredSlots`, bumps the retry counter,
2208
- * and mints a new stub at index 0. The old slot stays on disk (for
2209
- * operator inspection) but is no longer the pool's live slot.
2210
- */
2211
- acquire() {
2212
- for (let n = 0; n < this.workerCount; n++) {
2213
- if (this.busy.has(n)) continue;
2214
- this.busy.add(n);
2215
- const existing = this.slots.get(n);
2216
- if (existing && existing.preserved) {
2217
- this.retiredSlots.push(existing);
2218
- this.slots.delete(n);
2219
- this.retryCounter.set(n, (this.retryCounter.get(n) ?? 0) + 1);
2220
- } else if (existing) {
2221
- return existing;
2222
- }
2223
- const stub = {
2224
- index: n,
2225
- path: "",
2226
- // filled by prepare
2227
- prepared: false,
2228
- preserved: false,
2229
- setupCompleted: false
2230
- };
2231
- this.slots.set(n, stub);
2232
- return stub;
2233
- }
2234
- throw new Error(
2235
- `WorktreePool.acquire: no free worker slots (workerCount=${this.workerCount}, busy=${[...this.busy].join(",")})`
2236
- );
2237
- }
2238
- /**
2239
- * Prepare a worktree for the given task. Idempotent: on first call,
2240
- * runs `git worktree add`; on subsequent calls, recycles the existing
2241
- * worktree (clean + checkout fresh branch).
2242
- *
2243
- * Returns the SHA at HEAD post-prepare. The worker records this as
2244
- * `sinceSha` for the post-task `enforceTouches` diff.
2245
- *
2246
- * `branchPrefix` typically = `pilot/<plan-slug>`; the actual branch
2247
- * is `<branchPrefix>/<taskId>`. `base` is the commit-ish the branch
2248
- * is created from — usually the main branch's HEAD or a specific
2249
- * sha if reproducibility matters.
2250
- *
2251
- * For retried slots (i.e. `retryCounter[n] > 0`), the resolved path
2252
- * gets a `-<counter>` suffix so retries don't collide with the
2253
- * preserved predecessor on disk.
2254
- */
2255
- async prepare(args) {
2256
- if (args.slot.preserved) {
2257
- throw new Error(
2258
- `WorktreePool.prepare: slot ${args.slot.index} is preserved (failed task awaiting cleanup); cannot reuse`
2259
- );
2260
- }
2261
- const branch = `${args.branchPrefix}/${args.taskId}`;
2262
- if (!args.slot.prepared) {
2263
- const basePath = await this.worktreeDirOf(args.slot.index);
2264
- const counter = this.retryCounter.get(args.slot.index) ?? 0;
2265
- const wtPath = counter > 0 ? `${basePath}-${counter}` : basePath;
2266
- args.slot.path = wtPath;
2267
- try {
2268
- await fs9.stat(wtPath);
2269
- await gitWorktreeRemove({
2270
- repoPath: this.repoPath,
2271
- worktreePath: wtPath
2272
- });
2273
- await fs9.rm(wtPath, { recursive: true, force: true });
2274
- } catch {
2275
- }
2276
- await gitWorktreeAdd({
2277
- repoPath: this.repoPath,
2278
- worktreePath: wtPath,
2279
- commitIsh: args.base,
2280
- branch
2281
- });
2282
- args.slot.prepared = true;
2283
- } else {
2284
- await cleanWorktree(args.slot.path);
2285
- await checkoutFreshBranch({
2286
- worktree: args.slot.path,
2287
- branch,
2288
- base: args.base
2289
- });
2290
- }
2291
- const sinceSha = await headSha(args.slot.path);
2292
- return { sinceSha, branch, path: args.slot.path };
2293
- }
2294
- /**
2295
- * Release a slot back to the pool — slot becomes available for
2296
- * `acquire` again. Call after a clean task completion (commit
2297
- * succeeded, no preserved state needed).
2298
- *
2299
- * Does NOT clean the worktree — the next `prepare` call will reset
2300
- * it. If you want eager cleanup (e.g. before a long idle), call
2301
- * `cleanWorktree(slot.path)` separately.
2302
- */
2303
- release(slot) {
2304
- if (!this.busy.has(slot.index)) {
2305
- throw new Error(
2306
- `WorktreePool.release: slot ${slot.index} is not held`
2307
- );
2308
- }
2309
- this.busy.delete(slot.index);
2310
- }
2311
- /**
2312
- * Preserve a slot's state on failure. The slot is marked preserved
2313
- * and removed from the busy set. Unlike pre-v0.2 behaviour, the
2314
- * next `acquire()` call retires this slot into `retiredSlots` and
2315
- * mints a fresh stub — so a single failure doesn't cascade-block
2316
- * the rest of the run.
2317
- *
2318
- * The CLI's `pilot worktrees prune` (Phase G6) remains the path to
2319
- * permanently remove preserved slots from disk.
2320
- */
2321
- preserveOnFailure(slot) {
2322
- slot.preserved = true;
2323
- this.busy.delete(slot.index);
2324
- }
2325
- /**
2326
- * Tear down all worktrees managed by this pool — BOTH live and
2327
- * retired. Called at end of `pilot build` (whether success or
2328
- * failure). Preserved slots are skipped when `keepPreserved` is
2329
- * true (the default) — those are the user's to inspect.
2330
- */
2331
- async shutdown(args = {}) {
2332
- const keepPreserved = args.keepPreserved ?? true;
2333
- const errors = [];
2334
- const all = [...this.slots.values(), ...this.retiredSlots];
2335
- for (const slot of all) {
2336
- if (slot.preserved && keepPreserved) continue;
2337
- if (!slot.prepared || slot.path === "") continue;
2338
- try {
2339
- await gitWorktreeRemove({
2340
- repoPath: this.repoPath,
2341
- worktreePath: slot.path
2342
- });
2343
- } catch (err) {
2344
- errors.push(err instanceof Error ? err : new Error(String(err)));
2345
- }
2346
- }
2347
- if (errors.length > 0) {
2348
- throw new Error(
2349
- `WorktreePool.shutdown: ${errors.length} worktree removal(s) failed:
2350
- ` + errors.map((e) => e.message).join("\n---\n")
2351
- );
2352
- }
2353
- }
2354
- /**
2355
- * Inspect current slots (for tests / `pilot worktrees list`). Returns
2356
- * live slots followed by retired slots, in insertion order within
2357
- * each group.
2358
- */
2359
- inspect() {
2360
- return [...this.slots.values(), ...this.retiredSlots];
2361
- }
2362
- };
2363
-
2364
- // src/pilot/scheduler/ready-set.ts
2365
- function makeScheduler(args) {
2366
- const { db, runId, plan } = args;
2367
- const planById = /* @__PURE__ */ new Map();
2368
- for (const t of plan.tasks) planById.set(t.id, t);
2369
- const dependentsOf = /* @__PURE__ */ new Map();
2370
- for (const t of plan.tasks) {
2371
- for (const dep of t.depends_on) {
2372
- const list = dependentsOf.get(dep);
2373
- if (list) list.push(t.id);
2374
- else dependentsOf.set(dep, [t.id]);
1675
+ // src/pilot/scheduler/ready-set.ts
1676
+ function makeScheduler(args) {
1677
+ const { db, runId, plan } = args;
1678
+ const planById = /* @__PURE__ */ new Map();
1679
+ for (const t of plan.tasks) planById.set(t.id, t);
1680
+ const dependentsOf = /* @__PURE__ */ new Map();
1681
+ for (const t of plan.tasks) {
1682
+ for (const dep of t.depends_on) {
1683
+ const list = dependentsOf.get(dep);
1684
+ if (list) list.push(t.id);
1685
+ else dependentsOf.set(dep, [t.id]);
2375
1686
  }
2376
1687
  }
2377
1688
  return {
@@ -2437,6 +1748,8 @@ function depsSatisfied(db, runId, task) {
2437
1748
 
2438
1749
  // src/pilot/worker/worker.ts
2439
1750
  import * as fsSync from "fs";
1751
+ import { execFile as execFileCb } from "child_process";
1752
+ import { promisify as promisifyUtil } from "util";
2440
1753
 
2441
1754
  // src/pilot/opencode/prompts.ts
2442
1755
  function kickoffPrompt(task, ctx) {
@@ -2491,6 +1804,18 @@ function kickoffPrompt(task, ctx) {
2491
1804
  );
2492
1805
  }
2493
1806
  }
1807
+ sections.push(``, `## Progress updates`, ``);
1808
+ sections.push(
1809
+ `A \`provide_status_update\` tool is available. Use it to emit one-sentence progress updates during long-running work.`,
1810
+ ``,
1811
+ `Guidelines:`,
1812
+ `- Keep messages under 200 characters`,
1813
+ `- One sentence only \u2014 describe what you're currently working on`,
1814
+ `- Rate limited to once per 60 seconds per task`,
1815
+ `- Call it sparingly; the user sees these interleaved with the task log`,
1816
+ ``,
1817
+ `Example: "Writing the route handler for /api/users" or "Running typecheck after edits"`
1818
+ );
2494
1819
  if (task.context !== void 0 && task.context.trim().length > 0) {
2495
1820
  sections.push(``, `## Context`, ``, task.context.trim());
2496
1821
  }
@@ -2547,8 +1872,8 @@ var DEFAULT_OUTPUT_CAP_BYTES = 256 * 1024;
2547
1872
  var TRUNCATION_NOTICE = "\n[pilot] verify output truncated\n";
2548
1873
  async function runVerify(commands, options) {
2549
1874
  const results = [];
2550
- for (const command12 of commands) {
2551
- const result = await runOne(command12, options);
1875
+ for (const command10 of commands) {
1876
+ const result = await runOne(command10, options);
2552
1877
  results.push(result);
2553
1878
  if (!result.ok) {
2554
1879
  return { ok: false, results, failure: result };
@@ -2559,8 +1884,8 @@ async function runVerify(commands, options) {
2559
1884
  results
2560
1885
  };
2561
1886
  }
2562
- async function runOne(command12, options) {
2563
- if (typeof command12 !== "string" || command12.length === 0) {
1887
+ async function runOne(command10, options) {
1888
+ if (typeof command10 !== "string" || command10.length === 0) {
2564
1889
  throw new TypeError(`runOne: command must be a non-empty string`);
2565
1890
  }
2566
1891
  if (typeof options.cwd !== "string" || options.cwd.length === 0) {
@@ -2576,7 +1901,7 @@ async function runOne(command12, options) {
2576
1901
  stdout: { partial: "" },
2577
1902
  stderr: { partial: "" }
2578
1903
  };
2579
- const child = spawn2("bash", ["-c", command12], {
1904
+ const child = spawn2("bash", ["-c", command10], {
2580
1905
  cwd: options.cwd,
2581
1906
  env: options.env ?? process.env,
2582
1907
  stdio: ["ignore", "pipe", "pipe"]
@@ -2621,18 +1946,18 @@ async function runOne(command12, options) {
2621
1946
  const lines = combined.split("\n");
2622
1947
  state.partial = lines.pop();
2623
1948
  for (const line of lines) {
2624
- options.onLine({ stream, line, command: command12 });
1949
+ options.onLine({ stream, line, command: command10 });
2625
1950
  }
2626
1951
  }
2627
1952
  };
2628
1953
  child.stdout?.on("data", (c2) => handleChunk("stdout", c2));
2629
1954
  child.stderr?.on("data", (c2) => handleChunk("stderr", c2));
2630
- const { code, signal } = await new Promise((resolve5) => {
1955
+ const { code, signal } = await new Promise((resolve6) => {
2631
1956
  let resolved = false;
2632
1957
  const finalize = (code2, signal2) => {
2633
1958
  if (resolved) return;
2634
1959
  resolved = true;
2635
- resolve5({ code: code2, signal: signal2 });
1960
+ resolve6({ code: code2, signal: signal2 });
2636
1961
  };
2637
1962
  child.on("error", (err) => {
2638
1963
  if (!truncated) {
@@ -2652,7 +1977,7 @@ async function runOne(command12, options) {
2652
1977
  for (const stream of ["stdout", "stderr"]) {
2653
1978
  const partial = streamState[stream].partial;
2654
1979
  if (partial.length > 0) {
2655
- options.onLine({ stream, line: partial, command: command12 });
1980
+ options.onLine({ stream, line: partial, command: command10 });
2656
1981
  }
2657
1982
  }
2658
1983
  }
@@ -2661,7 +1986,7 @@ async function runOne(command12, options) {
2661
1986
  if (code === 0 && !timedOut && !aborted) {
2662
1987
  return {
2663
1988
  ok: true,
2664
- command: command12,
1989
+ command: command10,
2665
1990
  exitCode: 0,
2666
1991
  output,
2667
1992
  durationMs
@@ -2669,7 +1994,7 @@ async function runOne(command12, options) {
2669
1994
  }
2670
1995
  return {
2671
1996
  ok: false,
2672
- command: command12,
1997
+ command: command10,
2673
1998
  exitCode: code ?? -1,
2674
1999
  signal,
2675
2000
  timedOut,
@@ -2697,15 +2022,66 @@ function killTree(child) {
2697
2022
 
2698
2023
  // src/pilot/verify/touches.ts
2699
2024
  import picomatch2 from "picomatch";
2025
+ import { execFile as execFile2 } from "child_process";
2026
+ import { promisify } from "util";
2027
+ var execFileP = promisify(execFile2);
2028
+ var DEFAULT_TOLERATE = [
2029
+ // Next.js — `next build` regenerates this every run.
2030
+ "**/next-env.d.ts",
2031
+ // Next.js app-router generated types (routes.d.ts, etc.)
2032
+ "**/.next/types/**",
2033
+ "**/.next/dev/types/**",
2034
+ // TypeScript project-reference build info — tsc writes these.
2035
+ "**/*.tsbuildinfo",
2036
+ // Snapshot test updates — `vitest -u` / `jest -u` rewrites these
2037
+ // when assertions match; allowing them lets snapshot-driven tasks
2038
+ // pass without the agent authoring every snapshot path.
2039
+ "**/__snapshots__/**",
2040
+ "**/*.snap"
2041
+ ];
2042
+ async function diffNamesSince(cwd, sinceSha) {
2043
+ const run2 = async (args) => {
2044
+ for (const a of args) {
2045
+ if (a.includes("\0")) {
2046
+ throw new Error(`git arg contains null byte: ${JSON.stringify(a)}`);
2047
+ }
2048
+ }
2049
+ const { stdout } = await execFileP("git", ["-C", cwd, ...args], {
2050
+ timeout: 3e4,
2051
+ maxBuffer: 16 * 1024 * 1024
2052
+ });
2053
+ return stdout.toString().split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
2054
+ };
2055
+ const sets = await Promise.all([
2056
+ run2(["diff", "--name-only", `${sinceSha}..HEAD`]),
2057
+ run2(["diff", "--cached", "--name-only"]),
2058
+ run2(["diff", "--name-only"]),
2059
+ run2(["ls-files", "--others", "--exclude-standard"])
2060
+ ]);
2061
+ const all = /* @__PURE__ */ new Set();
2062
+ for (const s of sets) for (const p of s) all.add(p);
2063
+ return [...all].sort();
2064
+ }
2700
2065
  async function enforceTouches(args) {
2701
- const changed = await diffNamesSince(args.worktree, args.sinceSha);
2066
+ const changed = await diffNamesSince(args.cwd, args.sinceSha);
2702
2067
  if (changed.length === 0) {
2703
2068
  return { ok: true, changed: [] };
2704
2069
  }
2070
+ const combined = [
2071
+ ...args.allowed,
2072
+ ...args.tolerate ?? [],
2073
+ ...DEFAULT_TOLERATE
2074
+ ];
2705
2075
  if (args.allowed.length === 0) {
2706
- return { ok: false, changed, violators: [...changed] };
2076
+ const matchPassthrough = picomatch2(
2077
+ [...args.tolerate ?? [], ...DEFAULT_TOLERATE],
2078
+ { dot: true }
2079
+ );
2080
+ const violators2 = changed.filter((p) => !matchPassthrough(p));
2081
+ if (violators2.length === 0) return { ok: true, changed };
2082
+ return { ok: false, changed, violators: violators2 };
2707
2083
  }
2708
- const matchAllowed = picomatch2([...args.allowed], { dot: true });
2084
+ const matchAllowed = picomatch2(combined, { dot: true });
2709
2085
  const violators = changed.filter((p) => !matchAllowed(p));
2710
2086
  if (violators.length === 0) return { ok: true, changed };
2711
2087
  return { ok: false, changed, violators };
@@ -2860,12 +2236,71 @@ function isTextPart(v) {
2860
2236
  }
2861
2237
 
2862
2238
  // src/pilot/worker/worker.ts
2239
+ var execFileWorker = promisifyUtil(execFileCb);
2240
+ async function commitAll(cwd, subject, authorName, authorEmail) {
2241
+ const env = { ...process.env };
2242
+ if (authorName) env.GIT_AUTHOR_NAME = authorName;
2243
+ if (authorEmail) env.GIT_AUTHOR_EMAIL = authorEmail;
2244
+ if (authorName) env.GIT_COMMITTER_NAME = authorName;
2245
+ if (authorEmail) env.GIT_COMMITTER_EMAIL = authorEmail;
2246
+ await execFileWorker("git", ["add", "-A"], { cwd, timeout: 1e4 });
2247
+ await execFileWorker("git", ["commit", "-m", subject], {
2248
+ cwd,
2249
+ timeout: 3e4,
2250
+ env
2251
+ });
2252
+ const { stdout } = await execFileWorker("git", ["rev-parse", "HEAD"], {
2253
+ cwd,
2254
+ timeout: 1e4
2255
+ });
2256
+ return stdout.toString().trim();
2257
+ }
2258
+ async function resetTree(cwd) {
2259
+ try {
2260
+ await execFileWorker("git", ["reset", "--hard", "HEAD"], {
2261
+ cwd,
2262
+ timeout: 3e4
2263
+ });
2264
+ await execFileWorker("git", ["clean", "-fd"], {
2265
+ cwd,
2266
+ timeout: 3e4
2267
+ });
2268
+ return true;
2269
+ } catch (err) {
2270
+ const e = err;
2271
+ process.stderr.write(
2272
+ `[pilot] tree cleanup failed: ${(e.stderr ?? e.message ?? "").toString()}
2273
+ `
2274
+ );
2275
+ return false;
2276
+ }
2277
+ }
2863
2278
  async function runWorker(deps) {
2864
2279
  const attempted = [];
2865
- const maxAttempts = deps.maxAttempts ?? 3;
2280
+ const maxAttempts = deps.maxAttempts ?? 5;
2866
2281
  const stallMs = deps.stallMs ?? 60 * 60 * 1e3;
2867
- let setupAborted = false;
2868
- const depsWithAbort = deps;
2282
+ const cwd = deps.cwd ?? process.cwd();
2283
+ const gate = await checkCwdSafety(cwd);
2284
+ if (!gate.ok) {
2285
+ process.stderr.write(`[pilot] ${gate.reason}
2286
+ `);
2287
+ return { aborted: true, attempted: [] };
2288
+ }
2289
+ for (const w of gate.warnings) {
2290
+ process.stderr.write(`[pilot] ${w}
2291
+ `);
2292
+ }
2293
+ const { loadPilotConfig } = await import("./pilot-config-7LJZ23YK.js");
2294
+ let pilotConfig;
2295
+ try {
2296
+ pilotConfig = await loadPilotConfig(cwd);
2297
+ } catch (err) {
2298
+ process.stderr.write(
2299
+ `[pilot] ${err instanceof Error ? err.message : String(err)}
2300
+ `
2301
+ );
2302
+ return { aborted: true, attempted: [] };
2303
+ }
2869
2304
  while (true) {
2870
2305
  if (deps.abortSignal?.aborted) {
2871
2306
  return { aborted: true, attempted };
@@ -2875,9 +2310,13 @@ async function runWorker(deps) {
2875
2310
  return { aborted: false, attempted };
2876
2311
  }
2877
2312
  attempted.push(pick.task.id);
2878
- await runOneTask(depsWithAbort, pick.task, { maxAttempts, stallMs });
2879
- if (depsWithAbort.setupAborted) {
2880
- return { aborted: false, attempted };
2313
+ await runOneTask(deps, pick.task, { maxAttempts, stallMs, cwd, pilotConfig });
2314
+ if (deps.treeCleanupFailed) {
2315
+ process.stderr.write(
2316
+ `[pilot] halting run: tree cleanup failed after task ${pick.task.id}; subsequent tasks cannot safely run on a dirty tree
2317
+ `
2318
+ );
2319
+ return { aborted: true, attempted };
2881
2320
  }
2882
2321
  const row = getTask(deps.db, deps.runId, pick.task.id);
2883
2322
  if (row && (row.status === "failed" || row.status === "aborted")) {
@@ -2929,118 +2368,50 @@ function openForensics(args) {
2929
2368
  };
2930
2369
  }
2931
2370
  async function runOneTask(deps, task, opts) {
2932
- const cwd = process.cwd();
2371
+ try {
2372
+ await runOneTaskImpl(deps, task, opts);
2373
+ } finally {
2374
+ const ok = await resetTree(opts.cwd);
2375
+ if (!ok) {
2376
+ deps.treeCleanupFailed = true;
2377
+ appendEvent(deps.db, {
2378
+ runId: deps.runId,
2379
+ taskId: task.id,
2380
+ kind: "run.cleanup.failed",
2381
+ payload: {
2382
+ reason: "git reset --hard HEAD && git clean -fd failed after task; subsequent tasks aborted"
2383
+ }
2384
+ });
2385
+ }
2386
+ }
2387
+ }
2388
+ async function runOneTaskImpl(deps, task, opts) {
2389
+ const cwd = opts.cwd;
2933
2390
  appendEvent(deps.db, {
2934
2391
  runId: deps.runId,
2935
2392
  taskId: task.id,
2936
2393
  kind: "task.started",
2937
2394
  payload: {}
2938
2395
  });
2939
- let slot;
2940
- let prepared;
2396
+ let sinceSha;
2941
2397
  try {
2942
- slot = deps.pool.acquire();
2943
- prepared = await deps.pool.prepare({
2944
- slot,
2945
- taskId: task.id,
2946
- branchPrefix: deps.branchPrefix,
2947
- base: deps.base
2948
- });
2398
+ sinceSha = await headSha(cwd);
2949
2399
  } catch (err) {
2950
- const reason2 = `worktree prepare failed: ${errorMessage2(err)}`;
2951
- try {
2952
- const row = getTask(deps.db, deps.runId, task.id);
2953
- if (row?.status === "pending") {
2954
- deps.scheduler.next();
2955
- }
2956
- markFailed(deps.db, deps.runId, task.id, reason2);
2957
- } catch {
2958
- }
2400
+ const reason2 = `headSha failed: ${errorMessage2(err)}`;
2401
+ markFailedSafe(deps.db, deps.runId, task.id, reason2);
2959
2402
  appendEvent(deps.db, {
2960
2403
  runId: deps.runId,
2961
2404
  taskId: task.id,
2962
2405
  kind: "task.failed",
2963
- payload: { phase: "prepare", reason: reason2 }
2406
+ payload: { phase: "headSha", reason: reason2 }
2964
2407
  });
2965
2408
  return;
2966
2409
  }
2967
- const setupCommands = deps.plan.setup ?? [];
2968
- if (setupCommands.length > 0 && !slot.setupCompleted) {
2969
- const setupStart = Date.now();
2970
- appendEvent(deps.db, {
2971
- runId: deps.runId,
2972
- taskId: task.id,
2973
- kind: "slot.setup.started",
2974
- payload: {
2975
- slotIndex: slot.index,
2976
- commands: deps.plan.setup,
2977
- taskId: task.id
2978
- }
2979
- });
2980
- const setupResult = await runVerify(setupCommands, {
2981
- cwd: prepared.path,
2982
- abortSignal: deps.abortSignal,
2983
- onLine: deps.onVerifyLine
2984
- });
2985
- if (!setupResult.ok) {
2986
- const durationMs = Date.now() - setupStart;
2987
- const failure = setupResult.failure;
2988
- const reason2 = `setup failed: ${failure.command} \u2192 exit ${failure.exitCode}`;
2989
- appendEvent(deps.db, {
2990
- runId: deps.runId,
2991
- taskId: task.id,
2992
- kind: "slot.setup.failed",
2993
- payload: {
2994
- slotIndex: slot.index,
2995
- command: failure.command,
2996
- exitCode: failure.exitCode,
2997
- output: failure.output.slice(0, 4096),
2998
- // truncate
2999
- durationMs
3000
- }
3001
- });
3002
- deps.pool.preserveOnFailure(slot);
3003
- markFailedSafe(deps.db, deps.runId, task.id, reason2);
3004
- const blocked = new Set(
3005
- deps.scheduler.cascadeFail(task.id, reason2)
3006
- );
3007
- for (const row of listTasks(deps.db, deps.runId)) {
3008
- if (row.task_id === task.id) continue;
3009
- if (blocked.has(row.task_id)) continue;
3010
- if (row.status !== "pending" && row.status !== "ready") continue;
3011
- try {
3012
- markBlocked(deps.db, deps.runId, row.task_id, reason2);
3013
- blocked.add(row.task_id);
3014
- } catch {
3015
- }
3016
- }
3017
- for (const blockedId of blocked) {
3018
- appendEvent(deps.db, {
3019
- runId: deps.runId,
3020
- taskId: blockedId,
3021
- kind: "task.blocked",
3022
- payload: { reason: reason2, failedDep: task.id }
3023
- });
3024
- }
3025
- deps.setupAborted = true;
3026
- return;
3027
- }
3028
- slot.setupCompleted = true;
3029
- appendEvent(deps.db, {
3030
- runId: deps.runId,
3031
- taskId: task.id,
3032
- kind: "slot.setup.completed",
3033
- payload: {
3034
- slotIndex: slot.index,
3035
- durationMs: Date.now() - setupStart
3036
- }
3037
- });
3038
- }
3039
2410
  let sessionId;
3040
2411
  try {
3041
2412
  const created = await deps.client.session.create({
3042
2413
  body: { title: `pilot/${deps.runId}/${task.id}` },
3043
- query: { directory: prepared.path }
2414
+ query: { directory: cwd }
3044
2415
  });
3045
2416
  if (!created.data?.id) {
3046
2417
  throw new Error(`session.create returned no id`);
@@ -3048,7 +2419,6 @@ async function runOneTask(deps, task, opts) {
3048
2419
  sessionId = created.data.id;
3049
2420
  } catch (err) {
3050
2421
  const reason2 = `session.create failed: ${errorMessage2(err)}`;
3051
- deps.pool.preserveOnFailure(slot);
3052
2422
  markFailedSafe(deps.db, deps.runId, task.id, reason2);
3053
2423
  appendEvent(deps.db, {
3054
2424
  runId: deps.runId,
@@ -3058,7 +2428,14 @@ async function runOneTask(deps, task, opts) {
3058
2428
  });
3059
2429
  return;
3060
2430
  }
3061
- const bus = deps.busFactory(prepared.path);
2431
+ const runDir = await getRunDir(process.cwd(), deps.runId);
2432
+ await registerSession({
2433
+ runDir,
2434
+ sessionId,
2435
+ runId: deps.runId,
2436
+ taskId: task.id
2437
+ });
2438
+ const bus = deps.busFactory(cwd);
3062
2439
  await new Promise((r) => setTimeout(r, 200));
3063
2440
  const disposeBus = async () => {
3064
2441
  try {
@@ -3077,18 +2454,23 @@ async function runOneTask(deps, task, opts) {
3077
2454
  if (forensics) forensics.dispose();
3078
2455
  void disposeBus();
3079
2456
  };
2457
+ const unregisterSessionSafe = async () => {
2458
+ try {
2459
+ await unregisterSession({ runDir, sessionId });
2460
+ } catch {
2461
+ }
2462
+ };
3080
2463
  const forensicsCounters = () => forensics ? forensics.counters() : { lastEventTs: null, eventCount: 0 };
3081
2464
  try {
3082
2465
  markRunning(deps.db, {
3083
2466
  runId: deps.runId,
3084
2467
  taskId: task.id,
3085
2468
  sessionId,
3086
- branch: prepared.branch,
3087
- worktreePath: prepared.path
2469
+ branch: "",
2470
+ worktreePath: cwd
3088
2471
  });
3089
2472
  } catch (err) {
3090
2473
  disposeForensics();
3091
- deps.pool.preserveOnFailure(slot);
3092
2474
  const reason2 = `markRunning failed: ${errorMessage2(err)}`;
3093
2475
  markFailedSafe(deps.db, deps.runId, task.id, reason2);
3094
2476
  appendEvent(deps.db, {
@@ -3103,12 +2485,12 @@ async function runOneTask(deps, task, opts) {
3103
2485
  runId: deps.runId,
3104
2486
  taskId: task.id,
3105
2487
  kind: "task.session.created",
3106
- payload: { sessionId, branch: prepared.branch, worktreePath: prepared.path }
2488
+ payload: { sessionId, branch: "", worktreePath: cwd }
3107
2489
  });
3108
2490
  const ctx = {
3109
2491
  planName: deps.plan.name,
3110
- branch: prepared.branch,
3111
- worktreePath: prepared.path,
2492
+ branch: "",
2493
+ worktreePath: cwd,
3112
2494
  milestone: task.milestone,
3113
2495
  verifyAfterEach: deps.plan.defaults.verify_after_each,
3114
2496
  verifyMilestone: task.milestone !== void 0 ? deps.plan.milestones.find((m) => m.name === task.milestone)?.verify ?? [] : []
@@ -3116,8 +2498,50 @@ async function runOneTask(deps, task, opts) {
3116
2498
  const allVerify = [
3117
2499
  ...task.verify,
3118
2500
  ...deps.plan.defaults.verify_after_each,
3119
- ...ctx.verifyMilestone
2501
+ ...ctx.verifyMilestone,
2502
+ ...opts.pilotConfig.after_each
2503
+ ];
2504
+ const baselineVerify = [
2505
+ ...deps.plan.defaults.verify_after_each,
2506
+ ...ctx.verifyMilestone,
2507
+ ...opts.pilotConfig.after_each,
2508
+ ...opts.pilotConfig.baseline.filter(
2509
+ (c2) => !deps.plan.defaults.verify_after_each.includes(c2) && !ctx.verifyMilestone.includes(c2) && !opts.pilotConfig.after_each.includes(c2)
2510
+ )
3120
2511
  ];
2512
+ if (baselineVerify.length > 0) {
2513
+ const baselineResult = await runVerify(baselineVerify, {
2514
+ cwd,
2515
+ abortSignal: deps.abortSignal,
2516
+ onLine: deps.onVerifyLine,
2517
+ env: process.env
2518
+ });
2519
+ if (!baselineResult.ok) {
2520
+ const f = baselineResult.failure;
2521
+ const reason2 = `baseline verify failed: ${f.command} \u2192 exit ${f.exitCode}. This command fails on the clean tree BEFORE the agent starts \u2014 fix your environment or narrow the verify scope.`;
2522
+ disposeForensics();
2523
+ markFailedSafe(deps.db, deps.runId, task.id, reason2);
2524
+ appendEvent(deps.db, {
2525
+ runId: deps.runId,
2526
+ taskId: task.id,
2527
+ kind: "task.baseline.failed",
2528
+ payload: {
2529
+ phase: "baseline",
2530
+ command: f.command,
2531
+ exitCode: f.exitCode,
2532
+ output: f.output.slice(0, 4096),
2533
+ reason: reason2
2534
+ }
2535
+ });
2536
+ return;
2537
+ }
2538
+ appendEvent(deps.db, {
2539
+ runId: deps.runId,
2540
+ taskId: task.id,
2541
+ kind: "task.baseline.passed",
2542
+ payload: { commands: allVerify.length }
2543
+ });
2544
+ }
3121
2545
  let lastFailure = null;
3122
2546
  let stopReason = null;
3123
2547
  for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
@@ -3125,7 +2549,7 @@ async function runOneTask(deps, task, opts) {
3125
2549
  await abortSession(deps, sessionId);
3126
2550
  markAbortedSafe(deps.db, deps.runId, task.id, "abort signal");
3127
2551
  disposeForensics();
3128
- deps.pool.preserveOnFailure(slot);
2552
+ await unregisterSessionSafe();
3129
2553
  appendEvent(deps.db, {
3130
2554
  runId: deps.runId,
3131
2555
  taskId: task.id,
@@ -3155,7 +2579,7 @@ async function runOneTask(deps, task, opts) {
3155
2579
  try {
3156
2580
  await deps.client.session.promptAsync({
3157
2581
  path: { id: sessionId },
3158
- query: { directory: prepared.path },
2582
+ query: { directory: cwd },
3159
2583
  body: {
3160
2584
  agent: task.agent ?? deps.plan.defaults.agent,
3161
2585
  parts: [{ type: "text", text: promptText }]
@@ -3165,7 +2589,7 @@ async function runOneTask(deps, task, opts) {
3165
2589
  unsubStop();
3166
2590
  const reason2 = `promptAsync failed: ${errorMessage2(err)}`;
3167
2591
  disposeForensics();
3168
- deps.pool.preserveOnFailure(slot);
2592
+ await unregisterSessionSafe();
3169
2593
  markFailedSafe(deps.db, deps.runId, task.id, reason2);
3170
2594
  appendEvent(deps.db, {
3171
2595
  runId: deps.runId,
@@ -3185,7 +2609,7 @@ async function runOneTask(deps, task, opts) {
3185
2609
  await abortSession(deps, sessionId);
3186
2610
  markAbortedSafe(deps.db, deps.runId, task.id, "abort signal");
3187
2611
  disposeForensics();
3188
- deps.pool.preserveOnFailure(slot);
2612
+ await unregisterSessionSafe();
3189
2613
  appendEvent(deps.db, {
3190
2614
  runId: deps.runId,
3191
2615
  taskId: task.id,
@@ -3203,8 +2627,8 @@ async function runOneTask(deps, task, opts) {
3203
2627
  } catch {
3204
2628
  }
3205
2629
  disposeForensics();
2630
+ await unregisterSessionSafe();
3206
2631
  markFailedSafe(deps.db, deps.runId, task.id, reason2);
3207
- deps.pool.preserveOnFailure(slot);
3208
2632
  appendEvent(deps.db, {
3209
2633
  runId: deps.runId,
3210
2634
  taskId: task.id,
@@ -3222,8 +2646,8 @@ async function runOneTask(deps, task, opts) {
3222
2646
  if (idleResult.kind === "session-error") {
3223
2647
  const reason2 = `session error: ${JSON.stringify(idleResult.properties)}`;
3224
2648
  disposeForensics();
2649
+ await unregisterSessionSafe();
3225
2650
  markFailedSafe(deps.db, deps.runId, task.id, reason2);
3226
- deps.pool.preserveOnFailure(slot);
3227
2651
  appendEvent(deps.db, {
3228
2652
  runId: deps.runId,
3229
2653
  taskId: task.id,
@@ -3238,8 +2662,8 @@ async function runOneTask(deps, task, opts) {
3238
2662
  }
3239
2663
  if (stopReason !== null) {
3240
2664
  disposeForensics();
2665
+ await unregisterSessionSafe();
3241
2666
  markFailedSafe(deps.db, deps.runId, task.id, stopReason);
3242
- deps.pool.preserveOnFailure(slot);
3243
2667
  appendEvent(deps.db, {
3244
2668
  runId: deps.runId,
3245
2669
  taskId: task.id,
@@ -3249,9 +2673,10 @@ async function runOneTask(deps, task, opts) {
3249
2673
  return;
3250
2674
  }
3251
2675
  const verifyResult = await runVerify(allVerify, {
3252
- cwd: prepared.path,
2676
+ cwd,
3253
2677
  abortSignal: deps.abortSignal,
3254
- onLine: deps.onVerifyLine
2678
+ onLine: deps.onVerifyLine,
2679
+ env: process.env
3255
2680
  });
3256
2681
  if (!verifyResult.ok) {
3257
2682
  lastFailure = {
@@ -3269,20 +2694,21 @@ async function runOneTask(deps, task, opts) {
3269
2694
  command: lastFailure.command,
3270
2695
  exitCode: lastFailure.exitCode,
3271
2696
  timedOut: verifyResult.failure.timedOut,
3272
- aborted: verifyResult.failure.aborted
2697
+ aborted: verifyResult.failure.aborted,
2698
+ output: verifyResult.failure.output.slice(-2048)
3273
2699
  }
3274
2700
  });
3275
2701
  if (verifyResult.failure.aborted) {
3276
2702
  disposeForensics();
2703
+ await unregisterSessionSafe();
3277
2704
  markAbortedSafe(deps.db, deps.runId, task.id, "abort signal during verify");
3278
- deps.pool.preserveOnFailure(slot);
3279
2705
  return;
3280
2706
  }
3281
2707
  if (attempt < opts.maxAttempts) continue;
3282
2708
  const reason2 = `verify failed after ${opts.maxAttempts} attempts: ${lastFailure.command} \u2192 exit ${lastFailure.exitCode}`;
3283
2709
  disposeForensics();
2710
+ await unregisterSessionSafe();
3284
2711
  markFailedSafe(deps.db, deps.runId, task.id, reason2);
3285
- deps.pool.preserveOnFailure(slot);
3286
2712
  appendEvent(deps.db, {
3287
2713
  runId: deps.runId,
3288
2714
  taskId: task.id,
@@ -3298,9 +2724,10 @@ async function runOneTask(deps, task, opts) {
3298
2724
  payload: { attempt }
3299
2725
  });
3300
2726
  const touches = await enforceTouches({
3301
- worktree: prepared.path,
3302
- sinceSha: prepared.sinceSha,
3303
- allowed: task.touches
2727
+ cwd,
2728
+ sinceSha,
2729
+ allowed: task.touches,
2730
+ tolerate: task.tolerate
3304
2731
  });
3305
2732
  if (!touches.ok) {
3306
2733
  lastFailure = {
@@ -3318,8 +2745,8 @@ async function runOneTask(deps, task, opts) {
3318
2745
  if (attempt < opts.maxAttempts) continue;
3319
2746
  const reason2 = `touches violation after ${opts.maxAttempts} attempts: ${touches.violators.join(", ")}`;
3320
2747
  disposeForensics();
2748
+ await unregisterSessionSafe();
3321
2749
  markFailedSafe(deps.db, deps.runId, task.id, reason2);
3322
- deps.pool.preserveOnFailure(slot);
3323
2750
  appendEvent(deps.db, {
3324
2751
  runId: deps.runId,
3325
2752
  taskId: task.id,
@@ -3330,8 +2757,8 @@ async function runOneTask(deps, task, opts) {
3330
2757
  }
3331
2758
  if (touches.changed.length === 0) {
3332
2759
  disposeForensics();
2760
+ await unregisterSessionSafe();
3333
2761
  markSucceeded(deps.db, deps.runId, task.id);
3334
- deps.pool.release(slot);
3335
2762
  appendEvent(deps.db, {
3336
2763
  runId: deps.runId,
3337
2764
  taskId: task.id,
@@ -3342,15 +2769,15 @@ async function runOneTask(deps, task, opts) {
3342
2769
  }
3343
2770
  try {
3344
2771
  const commitMessage = `${task.id}: ${task.title}`;
3345
- const sha = await commitAll({
3346
- worktree: prepared.path,
3347
- message: commitMessage,
3348
- authorName: deps.authorName,
3349
- authorEmail: deps.authorEmail
3350
- });
2772
+ const sha = await commitAll(
2773
+ cwd,
2774
+ commitMessage,
2775
+ deps.authorName,
2776
+ deps.authorEmail
2777
+ );
3351
2778
  disposeForensics();
2779
+ await unregisterSessionSafe();
3352
2780
  markSucceeded(deps.db, deps.runId, task.id);
3353
- deps.pool.release(slot);
3354
2781
  appendEvent(deps.db, {
3355
2782
  runId: deps.runId,
3356
2783
  taskId: task.id,
@@ -3359,23 +2786,36 @@ async function runOneTask(deps, task, opts) {
3359
2786
  });
3360
2787
  return;
3361
2788
  } catch (err) {
3362
- const reason2 = `commit failed: ${errorMessage2(err)}`;
2789
+ const errMsg = errorMessage2(err);
2790
+ lastFailure = {
2791
+ command: "git commit (pre-commit hook)",
2792
+ exitCode: 1,
2793
+ output: errMsg.slice(0, 8192)
2794
+ };
2795
+ appendEvent(deps.db, {
2796
+ runId: deps.runId,
2797
+ taskId: task.id,
2798
+ kind: "task.commit.failed",
2799
+ payload: { attempt, error: errMsg.slice(0, 4096) }
2800
+ });
2801
+ if (attempt < opts.maxAttempts) continue;
2802
+ const reason2 = `commit failed after ${opts.maxAttempts} attempts: ${errMsg.slice(0, 500)}`;
3363
2803
  disposeForensics();
2804
+ await unregisterSessionSafe();
3364
2805
  markFailedSafe(deps.db, deps.runId, task.id, reason2);
3365
- deps.pool.preserveOnFailure(slot);
3366
2806
  appendEvent(deps.db, {
3367
2807
  runId: deps.runId,
3368
2808
  taskId: task.id,
3369
2809
  kind: "task.failed",
3370
- payload: { phase: "commit", reason: reason2 }
2810
+ payload: { phase: "commit", reason: reason2, attempts: opts.maxAttempts }
3371
2811
  });
3372
2812
  return;
3373
2813
  }
3374
2814
  }
3375
2815
  const reason = "worker loop exited unexpectedly";
3376
2816
  disposeForensics();
2817
+ await unregisterSessionSafe();
3377
2818
  markFailedSafe(deps.db, deps.runId, task.id, reason);
3378
- deps.pool.preserveOnFailure(slot);
3379
2819
  appendEvent(deps.db, {
3380
2820
  runId: deps.runId,
3381
2821
  taskId: task.id,
@@ -3447,7 +2887,7 @@ function errorMessage2(err) {
3447
2887
  }
3448
2888
 
3449
2889
  // src/pilot/cli/build.ts
3450
- import { promises as fs10 } from "fs";
2890
+ import { promises as fs7 } from "fs";
3451
2891
  var buildCmd = command3({
3452
2892
  name: "build",
3453
2893
  description: "Execute a pilot.yaml plan via the worker loop.",
@@ -3552,8 +2992,8 @@ async function runBuild(opts) {
3552
2992
  const opened = openStateDb(":memory:");
3553
2993
  opened.close();
3554
2994
  const cleanup = [];
3555
- const { ulid: ulid2 } = await import("ulid");
3556
- const runId = ulid2();
2995
+ const { ulid } = await import("ulid");
2996
+ const runId = ulid();
3557
2997
  const dbPath = await getStateDbPath(cwd, runId);
3558
2998
  const runDir = await getRunDir(cwd, runId);
3559
2999
  const branchPrefix = deriveBranchPrefix(plan.branch_prefix, slug, runId);
@@ -3572,6 +3012,49 @@ async function runBuild(opts) {
3572
3012
  kind: "run.started",
3573
3013
  payload: { planPath, slug, runDir, branchPrefix }
3574
3014
  });
3015
+ const { runSetupHook, SETUP_HOOK_RELATIVE_PATH } = await import("./setup-hook-FHTXMAQL.js");
3016
+ const hookResult = await runSetupHook({
3017
+ cwd,
3018
+ onLine: (c2) => stderrWriter(c2)
3019
+ });
3020
+ switch (hookResult.kind) {
3021
+ case "skipped":
3022
+ break;
3023
+ case "ok":
3024
+ stderrWriter(
3025
+ `[pilot] setup hook ${SETUP_HOOK_RELATIVE_PATH} passed (${Math.round(hookResult.durationMs / 1e3)}s)
3026
+ `
3027
+ );
3028
+ break;
3029
+ case "not-executable":
3030
+ stderrWriter(
3031
+ `[pilot] setup hook ${hookResult.hookPath} is not executable. Run \`chmod +x ${SETUP_HOOK_RELATIVE_PATH}\` and re-run pilot.
3032
+ `
3033
+ );
3034
+ await runCleanup(cleanup);
3035
+ return 1;
3036
+ case "timed-out":
3037
+ stderrWriter(
3038
+ `[pilot] setup hook ${hookResult.hookPath} timed out after ${Math.round(hookResult.timeoutMs / 1e3)}s
3039
+ `
3040
+ );
3041
+ await runCleanup(cleanup);
3042
+ return 1;
3043
+ case "failed":
3044
+ stderrWriter(
3045
+ `[pilot] setup hook ${hookResult.hookPath} exited ${hookResult.exitCode} (after ${Math.round(hookResult.durationMs / 1e3)}s). Fix the environment and re-run pilot.
3046
+ `
3047
+ );
3048
+ await runCleanup(cleanup);
3049
+ return 1;
3050
+ case "spawn-error":
3051
+ stderrWriter(
3052
+ `[pilot] setup hook ${hookResult.hookPath} failed to spawn: ${hookResult.error}
3053
+ `
3054
+ );
3055
+ await runCleanup(cleanup);
3056
+ return 1;
3057
+ }
3575
3058
  return executeRun({
3576
3059
  db: real,
3577
3060
  runId,
@@ -3589,10 +3072,17 @@ async function executeRun(args) {
3589
3072
  const { db, runId, plan, planPath, runDir, branchPrefix, cleanup } = args;
3590
3073
  const cwd = process.cwd();
3591
3074
  const stderrWriter = args.stderrWriter ?? ((s) => void process.stderr.write(s));
3075
+ const runDirForMcp = await getRunDir(cwd, runId);
3076
+ const dbPathForMcp = await getStateDbPath(cwd, runId);
3592
3077
  let server;
3593
3078
  try {
3594
3079
  server = await startOpencodeServer({
3595
- port: args.opencodePort ?? 0
3080
+ port: args.opencodePort ?? 0,
3081
+ runContext: {
3082
+ runDir: runDirForMcp,
3083
+ dbPath: dbPathForMcp,
3084
+ runId
3085
+ }
3596
3086
  });
3597
3087
  } catch (err) {
3598
3088
  const reason = err instanceof Error ? err.message : String(err);
@@ -3609,28 +3099,7 @@ async function executeRun(args) {
3609
3099
  }
3610
3100
  cleanup.push(() => server.shutdown());
3611
3101
  const busFactory = (directory) => new EventBus(server.client, directory);
3612
- const pool = new WorktreePool({
3613
- repoPath: cwd,
3614
- worktreeDir: async (n) => getWorktreeDir(cwd, runId, n)
3615
- });
3616
- cleanup.push(() => pool.shutdown({ keepPreserved: true }));
3617
3102
  const scheduler = makeScheduler({ db: db.db, runId, plan });
3618
- let base;
3619
- try {
3620
- base = await headSha(cwd);
3621
- } catch (err) {
3622
- const reason = err instanceof Error ? err.message : String(err);
3623
- process.stderr.write(`pilot: cannot resolve HEAD sha: ${reason}
3624
- `);
3625
- appendEvent(db.db, {
3626
- runId,
3627
- kind: "run.error",
3628
- payload: { phase: "head-sha", reason }
3629
- });
3630
- markRunFinished(db.db, runId, "failed");
3631
- await runCleanup(cleanup);
3632
- return 1;
3633
- }
3634
3103
  const aborter = new AbortController();
3635
3104
  const sigintHandler = () => aborter.abort("SIGINT");
3636
3105
  process.once("SIGINT", sigintHandler);
@@ -3638,28 +3107,26 @@ async function executeRun(args) {
3638
3107
  process.off("SIGINT", sigintHandler);
3639
3108
  });
3640
3109
  if (args.quiet !== true) {
3110
+ stderrWriter(
3111
+ `pilot build: run ${runId} started (${plan.tasks.length} tasks)
3112
+ `
3113
+ );
3641
3114
  const unsubLogger = startStreamingLogger({
3642
3115
  stderrWriter,
3643
3116
  runId,
3644
3117
  totalTasks: plan.tasks.length,
3645
- subscribe: subscribeToEvents
3118
+ subscribe: subscribeToEvents,
3119
+ db: db.db
3646
3120
  });
3647
3121
  cleanup.push(() => unsubLogger());
3648
- stderrWriter(
3649
- `pilot build: run ${runId} started (${plan.tasks.length} tasks)
3650
- `
3651
- );
3652
3122
  }
3653
3123
  const result = await runWorker({
3654
3124
  db: db.db,
3655
3125
  runId,
3656
3126
  plan,
3657
3127
  scheduler,
3658
- pool,
3659
3128
  client: server.client,
3660
3129
  busFactory,
3661
- branchPrefix,
3662
- base,
3663
3130
  abortSignal: aborter.signal
3664
3131
  });
3665
3132
  const counts = countByStatus(db.db, runId);
@@ -3678,7 +3145,7 @@ async function executeRun(args) {
3678
3145
  }
3679
3146
  async function resolvePlanPathSmart(input, cwd, readPlanSelection) {
3680
3147
  if (input.flag !== void 0 && input.flag.length > 0) {
3681
- const resolved = path9.isAbsolute(input.flag) ? input.flag : path9.resolve(cwd, input.flag);
3148
+ const resolved = path7.isAbsolute(input.flag) ? input.flag : path7.resolve(cwd, input.flag);
3682
3149
  if (await isFile(resolved)) {
3683
3150
  return { kind: "ok", path: resolved };
3684
3151
  }
@@ -3690,14 +3157,14 @@ async function resolvePlanPathSmart(input, cwd, readPlanSelection) {
3690
3157
  if (input.positional !== void 0 && input.positional.length > 0) {
3691
3158
  const plansDir2 = await getPlansDir(cwd);
3692
3159
  const candidates = [];
3693
- if (path9.isAbsolute(input.positional)) {
3160
+ if (path7.isAbsolute(input.positional)) {
3694
3161
  candidates.push(input.positional);
3695
3162
  } else {
3696
- candidates.push(path9.resolve(cwd, input.positional));
3697
- candidates.push(path9.join(plansDir2, input.positional));
3163
+ candidates.push(path7.resolve(cwd, input.positional));
3164
+ candidates.push(path7.join(plansDir2, input.positional));
3698
3165
  if (!/\.(ya?ml)$/i.test(input.positional)) {
3699
- candidates.push(path9.join(plansDir2, `${input.positional}.yaml`));
3700
- candidates.push(path9.join(plansDir2, `${input.positional}.yml`));
3166
+ candidates.push(path7.join(plansDir2, `${input.positional}.yaml`));
3167
+ candidates.push(path7.join(plansDir2, `${input.positional}.yml`));
3701
3168
  }
3702
3169
  }
3703
3170
  for (const c2 of candidates) {
@@ -3731,7 +3198,7 @@ async function resolvePlanPathSmart(input, cwd, readPlanSelection) {
3731
3198
  }
3732
3199
  async function isFile(p) {
3733
3200
  try {
3734
- const st = await fs10.stat(p);
3201
+ const st = await fs7.stat(p);
3735
3202
  return st.isFile();
3736
3203
  } catch {
3737
3204
  return false;
@@ -3740,7 +3207,7 @@ async function isFile(p) {
3740
3207
  async function findNewestYaml2(dir) {
3741
3208
  let entries;
3742
3209
  try {
3743
- entries = await fs10.readdir(dir);
3210
+ entries = await fs7.readdir(dir);
3744
3211
  } catch {
3745
3212
  return null;
3746
3213
  }
@@ -3751,7 +3218,7 @@ async function findNewestYaml2(dir) {
3751
3218
  let newest = null;
3752
3219
  for (const name of yamls) {
3753
3220
  try {
3754
- const st = await fs10.stat(path9.join(dir, name));
3221
+ const st = await fs7.stat(path7.join(dir, name));
3755
3222
  if (newest === null || st.mtimeMs > newest.mtime) {
3756
3223
  newest = { name, mtime: st.mtimeMs };
3757
3224
  }
@@ -3759,13 +3226,13 @@ async function findNewestYaml2(dir) {
3759
3226
  continue;
3760
3227
  }
3761
3228
  }
3762
- return newest ? path9.join(dir, newest.name) : null;
3229
+ return newest ? path7.join(dir, newest.name) : null;
3763
3230
  }
3764
3231
  async function defaultReadPlanSelection(cwd) {
3765
3232
  const plansDir = await getPlansDir(cwd);
3766
3233
  let entries;
3767
3234
  try {
3768
- entries = await fs10.readdir(plansDir);
3235
+ entries = await fs7.readdir(plansDir);
3769
3236
  } catch {
3770
3237
  return void 0;
3771
3238
  }
@@ -3775,9 +3242,9 @@ async function defaultReadPlanSelection(cwd) {
3775
3242
  if (yamls.length === 0) return void 0;
3776
3243
  const stats = await Promise.all(
3777
3244
  yamls.map(async (name) => {
3778
- const full = path9.join(plansDir, name);
3245
+ const full = path7.join(plansDir, name);
3779
3246
  try {
3780
- const st = await fs10.stat(full);
3247
+ const st = await fs7.stat(full);
3781
3248
  return { name, full, mtime: st.mtimeMs };
3782
3249
  } catch {
3783
3250
  return null;
@@ -3828,6 +3295,13 @@ function relativeTimeFromNow(thenMs) {
3828
3295
  const d = Math.round(h / 24);
3829
3296
  return `${d}d ago`;
3830
3297
  }
3298
+ function formatDuration(ms) {
3299
+ const totalSeconds = Math.max(0, Math.round(ms / 1e3));
3300
+ if (totalSeconds < 60) return `${totalSeconds}s`;
3301
+ const minutes = Math.floor(totalSeconds / 60);
3302
+ const seconds = totalSeconds % 60;
3303
+ return `${minutes}m ${seconds}s`;
3304
+ }
3831
3305
  function isExitPromptError2(err) {
3832
3306
  return err !== null && typeof err === "object" && "name" in err && err.name === "ExitPromptError";
3833
3307
  }
@@ -3851,8 +3325,9 @@ function startStreamingLogger(args) {
3851
3325
  return `${hh}:${mm}:${ss}`;
3852
3326
  };
3853
3327
  const write = (line) => {
3854
- stderrWriter(`[${formatTs3(clock())}] ${line}
3855
- `);
3328
+ const msg = `[${formatTs3(clock())}] ${line}
3329
+ `;
3330
+ stderrWriter(msg);
3856
3331
  };
3857
3332
  const writeRaw = (line) => {
3858
3333
  stderrWriter(`${line}
@@ -3885,6 +3360,11 @@ function startStreamingLogger(args) {
3885
3360
  write(
3886
3361
  `task.verify.failed ${id ?? "?"} attempt ${p.attempt}/${p.of} (${p.command} \u2192 exit ${p.exitCode}${timedOutSuffix})`
3887
3362
  );
3363
+ const output = typeof p.output === "string" ? p.output : null;
3364
+ if (output !== null && output.length > 0) {
3365
+ const tail = output.trim().split("\n").slice(-3).map((l) => ` ${l}`).join("\n");
3366
+ writeRaw(tail);
3367
+ }
3888
3368
  } else {
3889
3369
  write(`task.verify.failed ${id ?? "?"}`);
3890
3370
  }
@@ -3893,14 +3373,14 @@ function startStreamingLogger(args) {
3893
3373
  case "task.succeeded": {
3894
3374
  succeeded += 1;
3895
3375
  const ms = id !== null ? event.ts - (taskStart.get(id) ?? event.ts) : 0;
3896
- write(`task.succeeded ${id ?? "?"} in ${Math.round(ms / 1e3)}s`);
3376
+ write(`task.succeeded ${id ?? "?"} in ${formatDuration(ms)}`);
3897
3377
  write(`run.progress ${succeeded}/${totalTasks} succeeded`);
3898
3378
  break;
3899
3379
  }
3900
3380
  case "task.failed": {
3901
3381
  failed += 1;
3902
3382
  const ms = id !== null ? event.ts - (taskStart.get(id) ?? event.ts) : 0;
3903
- write(`task.failed ${id ?? "?"} in ${Math.round(ms / 1e3)}s`);
3383
+ write(`task.failed ${id ?? "?"} in ${formatDuration(ms)}`);
3904
3384
  const detail = extractPhaseReason(event.payload);
3905
3385
  if (detail !== null) {
3906
3386
  writeRaw(` \u2192 ${detail.phase}: ${truncate(detail.reason, 200)}`);
@@ -3965,6 +3445,12 @@ function startStreamingLogger(args) {
3965
3445
  case "task.touches.violation":
3966
3446
  write(`task.touches.violation ${id ?? "?"}`);
3967
3447
  break;
3448
+ case "task.progress": {
3449
+ const p = event.payload;
3450
+ const message = p?.message ?? "(no message)";
3451
+ write(`${id ?? "?"} > ${message}`);
3452
+ break;
3453
+ }
3968
3454
  // Other kinds (task.session.created, run.*) are intentionally
3969
3455
  // suppressed — too chatty for stdout. `pilot logs` carries the
3970
3456
  // full trace.
@@ -3972,8 +3458,39 @@ function startStreamingLogger(args) {
3972
3458
  break;
3973
3459
  }
3974
3460
  });
3461
+ let progressPollTimer = null;
3462
+ let lastProgressId = 0;
3463
+ if (args.db) {
3464
+ const pollDb = args.db;
3465
+ const pollMs = args.progressPollMs ?? 2e3;
3466
+ try {
3467
+ const row = pollDb.query(
3468
+ `SELECT MAX(id) as maxId FROM events WHERE run_id=? AND kind='task.progress'`
3469
+ ).get(runId);
3470
+ lastProgressId = row?.maxId ?? 0;
3471
+ } catch {
3472
+ }
3473
+ progressPollTimer = setInterval(() => {
3474
+ try {
3475
+ const rows = pollDb.query(
3476
+ `SELECT id, task_id, ts, payload FROM events WHERE run_id=? AND kind='task.progress' AND id > ? ORDER BY id`
3477
+ ).all(runId, lastProgressId);
3478
+ for (const row of rows) {
3479
+ lastProgressId = row.id;
3480
+ try {
3481
+ const p = JSON.parse(row.payload);
3482
+ const message = p?.message ?? "(no message)";
3483
+ write(`${row.task_id ?? "?"} > ${message}`);
3484
+ } catch {
3485
+ }
3486
+ }
3487
+ } catch {
3488
+ }
3489
+ }, pollMs);
3490
+ }
3975
3491
  return () => {
3976
3492
  flushBlockedSummary();
3493
+ if (progressPollTimer) clearInterval(progressPollTimer);
3977
3494
  unsub();
3978
3495
  };
3979
3496
  }
@@ -3994,11 +3511,11 @@ function deriveBranchPrefix(planBranchPrefix, slug, runId) {
3994
3511
  return `${base}/${runId}`;
3995
3512
  }
3996
3513
  async function deriveUniqueSlug(plan, planPath, cwd) {
3997
- const base = path9.basename(planPath, path9.extname(planPath)) || deriveSlug(plan.name);
3514
+ const base = path7.basename(planPath, path7.extname(planPath)) || deriveSlug(plan.name);
3998
3515
  const dir = await getPlansDir(cwd);
3999
- const entries = await fs10.readdir(dir).catch(() => []);
3516
+ const entries = await fs7.readdir(dir).catch(() => []);
4000
3517
  const existingSlugs = new Set(
4001
- entries.filter((n) => n.endsWith(".yaml") || n.endsWith(".yml")).map((n) => path9.basename(n, path9.extname(n)))
3518
+ entries.filter((n) => n.endsWith(".yaml") || n.endsWith(".yml")).map((n) => path7.basename(n, path7.extname(n)))
4002
3519
  );
4003
3520
  existingSlugs.delete(base);
4004
3521
  return resolveUniqueSlug(base, existingSlugs);
@@ -4036,14 +3553,14 @@ Failed tasks (${failed.length}):
4036
3553
  const { phase, reason } = resolveFailureDetail(db, runId, t);
4037
3554
  const session = t.session_id ?? "(none \u2014 failed before session.create)";
4038
3555
  const worktree = t.worktree_path ?? "(none)";
4039
- const elapsed = t.started_at !== null && t.finished_at !== null ? Math.round((t.finished_at - t.started_at) / 1e3) : 0;
3556
+ const elapsed = t.started_at !== null && t.finished_at !== null ? formatDuration(t.finished_at - t.started_at) : "0s";
4040
3557
  process.stdout.write(
4041
3558
  ` ${t.task_id}
4042
3559
  phase: ${phase}
4043
3560
  reason: ${truncateSummary(reason, 300)}
4044
3561
  session: ${session}
4045
3562
  worktree: ${worktree}
4046
- elapsed: ${elapsed}s attempts: ${t.attempts}
3563
+ elapsed: ${elapsed} attempts: ${t.attempts}
4047
3564
 
4048
3565
  `
4049
3566
  );
@@ -4089,12 +3606,16 @@ async function runCleanup(cleanup) {
4089
3606
  }
4090
3607
  }
4091
3608
 
4092
- // src/pilot/cli/status.ts
4093
- import { command as command4, flag as flag3, option as option3, optional as optional4, string as string4 } from "cmd-ts";
3609
+ // src/pilot/cli/build-resume.ts
3610
+ import { command as command4, flag as flag3, option as option3, optional as optional4, string as string4, number as cmdNumber2 } from "cmd-ts";
3611
+ import { execFile as execFileCb2 } from "child_process";
3612
+ import { promises as fs9 } from "fs";
3613
+ import * as path9 from "path";
3614
+ import { promisify as promisify2 } from "util";
4094
3615
 
4095
3616
  // src/pilot/cli/discover.ts
4096
- import { promises as fs11 } from "fs";
4097
- import * as path10 from "path";
3617
+ import { promises as fs8 } from "fs";
3618
+ import * as path8 from "path";
4098
3619
  async function discoverRun(args) {
4099
3620
  const cwd = args.cwd;
4100
3621
  if (args.runId !== void 0 && args.runId.length > 0) {
@@ -4102,7 +3623,7 @@ async function discoverRun(args) {
4102
3623
  const cwdDbPath = await getStateDbPath(cwd, args.runId);
4103
3624
  tried.push(cwdDbPath);
4104
3625
  try {
4105
- await fs11.stat(cwdDbPath);
3626
+ await fs8.stat(cwdDbPath);
4106
3627
  const runDir = await getRunDir(cwd, args.runId);
4107
3628
  return { runId: args.runId, dbPath: cwdDbPath, runDir };
4108
3629
  } catch {
@@ -4110,14 +3631,14 @@ async function discoverRun(args) {
4110
3631
  const base = resolveBaseDir();
4111
3632
  let repoFolders;
4112
3633
  try {
4113
- repoFolders = await fs11.readdir(base);
3634
+ repoFolders = await fs8.readdir(base);
4114
3635
  } catch {
4115
3636
  throw new Error(
4116
3637
  `pilot: no state.db for run ${JSON.stringify(args.runId)} (looked at ${tried.join(", ")}; base ${base} does not exist)`
4117
3638
  );
4118
3639
  }
4119
3640
  for (const folder of repoFolders) {
4120
- const candidateDbPath = path10.join(
3641
+ const candidateDbPath = path8.join(
4121
3642
  base,
4122
3643
  folder,
4123
3644
  "pilot",
@@ -4127,15 +3648,15 @@ async function discoverRun(args) {
4127
3648
  );
4128
3649
  if (tried.includes(candidateDbPath)) continue;
4129
3650
  try {
4130
- const stat2 = await fs11.stat(path10.join(base, folder));
4131
- if (!stat2.isDirectory()) continue;
3651
+ const stat = await fs8.stat(path8.join(base, folder));
3652
+ if (!stat.isDirectory()) continue;
4132
3653
  } catch {
4133
3654
  continue;
4134
3655
  }
4135
3656
  tried.push(candidateDbPath);
4136
3657
  try {
4137
- await fs11.stat(candidateDbPath);
4138
- const candidateRunDir = path10.join(
3658
+ await fs8.stat(candidateDbPath);
3659
+ const candidateRunDir = path8.join(
4139
3660
  base,
4140
3661
  folder,
4141
3662
  "pilot",
@@ -4155,10 +3676,10 @@ async function discoverRun(args) {
4155
3676
  );
4156
3677
  }
4157
3678
  const pilot = await getPilotDir(cwd);
4158
- const runsDir = path10.join(pilot, "runs");
3679
+ const runsDir = path8.join(pilot, "runs");
4159
3680
  let entries;
4160
3681
  try {
4161
- entries = await fs11.readdir(runsDir);
3682
+ entries = await fs8.readdir(runsDir);
4162
3683
  } catch {
4163
3684
  throw new Error(
4164
3685
  `pilot: no runs found at ${runsDir} (run \`pilot build\` first)`
@@ -4166,10 +3687,10 @@ async function discoverRun(args) {
4166
3687
  }
4167
3688
  let newest = null;
4168
3689
  for (const id of entries) {
4169
- const dbPath = path10.join(runsDir, id, "state.db");
3690
+ const dbPath = path8.join(runsDir, id, "state.db");
4170
3691
  let st;
4171
3692
  try {
4172
- st = await fs11.stat(dbPath);
3693
+ st = await fs8.stat(dbPath);
4173
3694
  } catch {
4174
3695
  continue;
4175
3696
  }
@@ -4185,21 +3706,338 @@ async function discoverRun(args) {
4185
3706
  return {
4186
3707
  runId: newest.id,
4187
3708
  dbPath: newest.dbPath,
4188
- runDir: path10.join(runsDir, newest.id)
3709
+ runDir: path8.join(runsDir, newest.id)
4189
3710
  };
4190
3711
  }
4191
3712
 
3713
+ // src/pilot/cli/build-resume.ts
3714
+ var execFileP2 = promisify2(execFileCb2);
3715
+ var buildResumeCmd = command4({
3716
+ name: "build-resume",
3717
+ description: "Resume a partially-completed pilot run from where it left off.",
3718
+ args: {
3719
+ run: option3({
3720
+ long: "run",
3721
+ type: optional4(string4),
3722
+ description: "Run ID to resume. Defaults to the newest resumable run matching --plan (or interactive picker if multiple exist)."
3723
+ }),
3724
+ plan: option3({
3725
+ long: "plan",
3726
+ type: optional4(string4),
3727
+ description: "Filter to runs that used this plan path (absolute, cwd-relative, or bare filename resolved against the plans dir). Disambiguates when multiple worktrees share a state dir."
3728
+ }),
3729
+ opencodePort: option3({
3730
+ long: "opencode-port",
3731
+ type: optional4(cmdNumber2),
3732
+ description: "Port for the spawned opencode server (default: 0 = random)."
3733
+ }),
3734
+ quiet: flag3({
3735
+ long: "quiet",
3736
+ description: "Suppress per-task progress lines on stderr. Summary still prints."
3737
+ })
3738
+ },
3739
+ handler: async (args) => {
3740
+ await requirePlugin();
3741
+ const code = await runBuildResume(args);
3742
+ process.exit(code);
3743
+ }
3744
+ });
3745
+ async function runBuildResume(opts) {
3746
+ const cwd = process.cwd();
3747
+ const stderrWriter = opts.stderrWriter ?? ((s) => void process.stderr.write(s));
3748
+ let runId;
3749
+ let dbPath;
3750
+ let runDir;
3751
+ try {
3752
+ if (opts.run !== void 0 && opts.run.length > 0) {
3753
+ const d = await discoverRun({ cwd, runId: opts.run });
3754
+ runId = d.runId;
3755
+ dbPath = d.dbPath;
3756
+ runDir = d.runDir;
3757
+ } else {
3758
+ const planFilter = opts.plan !== void 0 && opts.plan.length > 0 ? await resolvePlanFilter(cwd, opts.plan) : void 0;
3759
+ const resumable = await findLatestResumableRun(cwd, planFilter);
3760
+ if (resumable === null) {
3761
+ const suffix = planFilter ? ` matching plan "${path9.basename(planFilter)}"` : "";
3762
+ process.stderr.write(
3763
+ `pilot build-resume: no resumable runs found in this repo${suffix} (no run has non-succeeded tasks)
3764
+ `
3765
+ );
3766
+ return 2;
3767
+ }
3768
+ runId = resumable.runId;
3769
+ dbPath = resumable.dbPath;
3770
+ runDir = resumable.runDir;
3771
+ }
3772
+ } catch (err) {
3773
+ process.stderr.write(
3774
+ `pilot build-resume: ${err instanceof Error ? err.message : String(err)}
3775
+ `
3776
+ );
3777
+ return 1;
3778
+ }
3779
+ const opened = openStateDb(dbPath);
3780
+ const cleanup = [];
3781
+ cleanup.push(() => opened.close());
3782
+ const run2 = getRun(opened.db, runId);
3783
+ if (run2 === null) {
3784
+ process.stderr.write(
3785
+ `pilot build-resume: state.db exists at ${dbPath} but has no row for ${runId}
3786
+ `
3787
+ );
3788
+ await runCleanup2(cleanup);
3789
+ return 1;
3790
+ }
3791
+ const counts = countByStatus(opened.db, runId);
3792
+ const remaining = counts.pending + counts.ready + counts.running + counts.failed + counts.blocked + counts.aborted;
3793
+ if (remaining === 0) {
3794
+ process.stderr.write(
3795
+ `pilot build-resume: run ${runId} has no tasks to resume (all ${counts.succeeded} succeeded)
3796
+ `
3797
+ );
3798
+ await runCleanup2(cleanup);
3799
+ return 2;
3800
+ }
3801
+ const planPath = run2.plan_path;
3802
+ const loaded = await loadPlan(planPath);
3803
+ if (!loaded.ok) {
3804
+ process.stderr.write(
3805
+ `pilot build-resume: plan at ${planPath} failed to load:
3806
+ ` + loaded.errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n") + `
3807
+ `
3808
+ );
3809
+ await runCleanup2(cleanup);
3810
+ return 1;
3811
+ }
3812
+ const plan = loaded.plan;
3813
+ const preflight = await runResumePreflight({ cwd, opened, runId });
3814
+ if (!preflight.ok) {
3815
+ process.stderr.write(`pilot build-resume: ${preflight.reason}
3816
+ `);
3817
+ await runCleanup2(cleanup);
3818
+ return 1;
3819
+ }
3820
+ for (const w of preflight.warnings) {
3821
+ stderrWriter(`[pilot] ${w}
3822
+ `);
3823
+ }
3824
+ const { runSetupHook, SETUP_HOOK_RELATIVE_PATH } = await import("./setup-hook-FHTXMAQL.js");
3825
+ const hookResult = await runSetupHook({
3826
+ cwd,
3827
+ onLine: (c2) => stderrWriter(c2)
3828
+ });
3829
+ switch (hookResult.kind) {
3830
+ case "skipped":
3831
+ break;
3832
+ case "ok":
3833
+ stderrWriter(
3834
+ `[pilot] setup hook ${SETUP_HOOK_RELATIVE_PATH} passed (${Math.round(hookResult.durationMs / 1e3)}s)
3835
+ `
3836
+ );
3837
+ break;
3838
+ case "not-executable":
3839
+ stderrWriter(
3840
+ `[pilot] setup hook ${hookResult.hookPath} is not executable. Run \`chmod +x ${SETUP_HOOK_RELATIVE_PATH}\` and re-run pilot.
3841
+ `
3842
+ );
3843
+ await runCleanup2(cleanup);
3844
+ return 1;
3845
+ case "timed-out":
3846
+ stderrWriter(
3847
+ `[pilot] setup hook ${hookResult.hookPath} timed out after ${Math.round(hookResult.timeoutMs / 1e3)}s
3848
+ `
3849
+ );
3850
+ await runCleanup2(cleanup);
3851
+ return 1;
3852
+ case "failed":
3853
+ stderrWriter(
3854
+ `[pilot] setup hook ${hookResult.hookPath} exited ${hookResult.exitCode} (after ${Math.round(hookResult.durationMs / 1e3)}s). Fix the environment and re-run pilot.
3855
+ `
3856
+ );
3857
+ await runCleanup2(cleanup);
3858
+ return 1;
3859
+ case "spawn-error":
3860
+ stderrWriter(
3861
+ `[pilot] setup hook ${hookResult.hookPath} failed to spawn: ${hookResult.error}
3862
+ `
3863
+ );
3864
+ await runCleanup2(cleanup);
3865
+ return 1;
3866
+ }
3867
+ const resetIds = resetTasksForResume(opened.db, runId);
3868
+ try {
3869
+ markRunResumed(opened.db, runId);
3870
+ } catch (err) {
3871
+ process.stderr.write(
3872
+ `pilot build-resume: cannot mark run as running: ${err instanceof Error ? err.message : String(err)}
3873
+ `
3874
+ );
3875
+ await runCleanup2(cleanup);
3876
+ return 1;
3877
+ }
3878
+ appendEvent(opened.db, {
3879
+ runId,
3880
+ kind: "run.resumed",
3881
+ payload: {
3882
+ resetTaskIds: resetIds,
3883
+ skippedSucceeded: counts.succeeded
3884
+ }
3885
+ });
3886
+ if (opts.quiet !== true) {
3887
+ stderrWriter(
3888
+ `pilot build-resume: resuming run ${runId} \u2014 ${resetIds.length} task(s) reset (skipping ${counts.succeeded} succeeded)
3889
+ `
3890
+ );
3891
+ }
3892
+ const branchPrefix = deriveBranchPrefix(plan.branch_prefix, run2.plan_slug, runId);
3893
+ return executeRun({
3894
+ db: opened,
3895
+ runId,
3896
+ plan,
3897
+ planPath,
3898
+ runDir,
3899
+ branchPrefix,
3900
+ cleanup,
3901
+ opencodePort: opts.opencodePort,
3902
+ quiet: opts.quiet,
3903
+ stderrWriter
3904
+ });
3905
+ }
3906
+ async function runResumePreflight(args) {
3907
+ const { cwd, opened, runId } = args;
3908
+ const { checkCwdSafety: checkCwdSafety2 } = await import("./safety-gate-WM3EWOCY.js");
3909
+ const gate = await checkCwdSafety2(cwd);
3910
+ if (!gate.ok) {
3911
+ return { ok: false, reason: gate.reason };
3912
+ }
3913
+ const { listTasks: listTasks2 } = await import("./tasks-KJ3WN2KY.js");
3914
+ const tasks = listTasks2(opened.db, runId);
3915
+ const withBranch = tasks.filter(
3916
+ (t) => t.branch !== null && t.branch.length > 0
3917
+ );
3918
+ if (withBranch.length > 0) {
3919
+ const recordedBranch = withBranch[0].branch;
3920
+ const current = await currentBranch(cwd);
3921
+ if (current !== recordedBranch) {
3922
+ return {
3923
+ ok: false,
3924
+ reason: `branch mismatch: run ${runId} was started on "${recordedBranch}", but cwd is currently on "${current}". Switch branches: \`git checkout ${recordedBranch}\``
3925
+ };
3926
+ }
3927
+ }
3928
+ return { ok: true, warnings: gate.warnings };
3929
+ }
3930
+ async function currentBranch(cwd) {
3931
+ try {
3932
+ const { stdout } = await execFileP2(
3933
+ "git",
3934
+ ["rev-parse", "--abbrev-ref", "HEAD"],
3935
+ { cwd, timeout: 1e4 }
3936
+ );
3937
+ return stdout.toString().trim();
3938
+ } catch {
3939
+ return "";
3940
+ }
3941
+ }
3942
+ async function findLatestResumableRun(cwd, planFilter) {
3943
+ const { getPilotDir: getPilotDir2 } = await import("./paths-LT3QQKCF.js");
3944
+ const pilot = await getPilotDir2(cwd);
3945
+ const runsDir = path9.join(pilot, "runs");
3946
+ let entries;
3947
+ try {
3948
+ entries = await fs9.readdir(runsDir);
3949
+ } catch {
3950
+ return null;
3951
+ }
3952
+ const candidates = [];
3953
+ for (const id of entries) {
3954
+ const dbPath = path9.join(runsDir, id, "state.db");
3955
+ let st;
3956
+ try {
3957
+ st = await fs9.stat(dbPath);
3958
+ } catch {
3959
+ continue;
3960
+ }
3961
+ candidates.push({
3962
+ runId: id,
3963
+ dbPath,
3964
+ runDir: path9.join(runsDir, id),
3965
+ mtime: st.mtimeMs
3966
+ });
3967
+ }
3968
+ candidates.sort((a, b) => b.mtime - a.mtime);
3969
+ for (const c2 of candidates) {
3970
+ const opened = openStateDb(c2.dbPath);
3971
+ try {
3972
+ if (planFilter !== void 0) {
3973
+ const { getRun: getRun2 } = await import("./runs-QWPL3TKV.js");
3974
+ const run2 = getRun2(opened.db, c2.runId);
3975
+ if (!run2) continue;
3976
+ const matches = run2.plan_path === planFilter || path9.basename(run2.plan_path) === path9.basename(planFilter);
3977
+ if (!matches) continue;
3978
+ }
3979
+ const counts = countByStatus(opened.db, c2.runId);
3980
+ const nonSucceeded = counts.pending + counts.ready + counts.running + counts.failed + counts.blocked + counts.aborted;
3981
+ if (nonSucceeded > 0) {
3982
+ return c2;
3983
+ }
3984
+ } finally {
3985
+ opened.close();
3986
+ }
3987
+ }
3988
+ void resolveBaseDir;
3989
+ return null;
3990
+ }
3991
+ async function resolvePlanFilter(cwd, input) {
3992
+ const { getPlansDir: getPlansDir2 } = await import("./paths-LT3QQKCF.js");
3993
+ if (path9.isAbsolute(input)) return input;
3994
+ const cwdRel = path9.resolve(cwd, input);
3995
+ try {
3996
+ await fs9.stat(cwdRel);
3997
+ return cwdRel;
3998
+ } catch {
3999
+ }
4000
+ const plansDir = await getPlansDir2(cwd);
4001
+ const plansDirRel = path9.join(plansDir, input);
4002
+ try {
4003
+ await fs9.stat(plansDirRel);
4004
+ return plansDirRel;
4005
+ } catch {
4006
+ }
4007
+ if (!/\.(ya?ml)$/i.test(input)) {
4008
+ for (const ext of [".yaml", ".yml"]) {
4009
+ const withExt = path9.join(plansDir, `${input}${ext}`);
4010
+ try {
4011
+ await fs9.stat(withExt);
4012
+ return withExt;
4013
+ } catch {
4014
+ }
4015
+ }
4016
+ }
4017
+ return input;
4018
+ }
4019
+ async function runCleanup2(cleanup) {
4020
+ while (cleanup.length > 0) {
4021
+ const fn = cleanup.pop();
4022
+ try {
4023
+ await fn();
4024
+ } catch {
4025
+ }
4026
+ }
4027
+ }
4028
+
4192
4029
  // src/pilot/cli/status.ts
4193
- var statusCmd = command4({
4030
+ import { command as command5, flag as flag4, option as option4, optional as optional5, string as string5 } from "cmd-ts";
4031
+ var statusCmd = command5({
4194
4032
  name: "status",
4195
4033
  description: "Print the run + task status for a pilot run.",
4196
4034
  args: {
4197
- run: option3({
4035
+ run: option4({
4198
4036
  long: "run",
4199
- type: optional4(string4),
4037
+ type: optional5(string5),
4200
4038
  description: "Run ID. Defaults to the newest run with a state.db."
4201
4039
  }),
4202
- json: flag3({
4040
+ json: flag4({
4203
4041
  long: "json",
4204
4042
  description: "Emit JSON instead of human-readable text."
4205
4043
  })
@@ -4289,199 +4127,19 @@ function wrap(text, width) {
4289
4127
  return out;
4290
4128
  }
4291
4129
 
4292
- // src/pilot/cli/resume.ts
4293
- import { command as command5, option as option4, optional as optional5, string as string5 } from "cmd-ts";
4294
- var resumeCmd = command5({
4295
- name: "resume",
4296
- description: "Continue a partially-completed pilot run.",
4297
- args: {
4298
- run: option4({
4299
- long: "run",
4300
- type: optional5(string5),
4301
- description: "Run ID. Defaults to the newest run."
4302
- })
4303
- },
4304
- handler: async ({ run: run2 }) => {
4305
- await requirePlugin();
4306
- const code = await runResume({ runId: run2 });
4307
- process.exit(code);
4308
- }
4309
- });
4310
- async function runResume(opts) {
4311
- let discovered;
4312
- try {
4313
- discovered = await discoverRun({
4314
- cwd: process.cwd(),
4315
- runId: opts.runId
4316
- });
4317
- } catch (err) {
4318
- process.stderr.write(
4319
- `${err instanceof Error ? err.message : String(err)}
4320
- `
4321
- );
4322
- return 1;
4323
- }
4324
- const opened = openStateDb(discovered.dbPath);
4325
- const cleanup = [
4326
- () => opened.close()
4327
- ];
4328
- const run2 = getRun(opened.db, discovered.runId);
4329
- if (run2 === null) {
4330
- process.stderr.write(
4331
- `pilot resume: run ${discovered.runId} missing from DB
4332
- `
4333
- );
4334
- await runCleanup2(cleanup);
4335
- return 1;
4336
- }
4337
- const loaded = await loadPlan(run2.plan_path);
4338
- if (!loaded.ok) {
4339
- process.stderr.write(
4340
- `pilot resume: cannot reload plan at ${run2.plan_path} (${loaded.kind})
4341
- `
4342
- );
4343
- for (const e of loaded.errors) {
4344
- process.stderr.write(` ${e.path}: ${e.message}
4345
- `);
4346
- }
4347
- await runCleanup2(cleanup);
4348
- return 1;
4349
- }
4350
- if (run2.status === "pending") {
4351
- markRunRunning(opened.db, discovered.runId);
4352
- } else if (run2.status === "running") {
4353
- } else {
4354
- opened.db.run(
4355
- `UPDATE runs SET status='running', finished_at=NULL WHERE id=?`,
4356
- [discovered.runId]
4357
- );
4358
- }
4359
- appendEvent(opened.db, {
4360
- runId: discovered.runId,
4361
- kind: "run.resumed",
4362
- payload: { previousStatus: run2.status }
4363
- });
4364
- const runDir = await getRunDir(process.cwd(), discovered.runId);
4365
- return executeRun({
4366
- db: opened,
4367
- runId: discovered.runId,
4368
- plan: loaded.plan,
4369
- planPath: run2.plan_path,
4370
- runDir,
4371
- // Reconstruct the same branch prefix the original `pilot build` used.
4372
- // The runId segment is what makes branches unique per run; resume MUST
4373
- // match the original to find the existing worktrees.
4374
- branchPrefix: deriveBranchPrefix(
4375
- loaded.plan.branch_prefix,
4376
- run2.plan_slug,
4377
- discovered.runId
4378
- ),
4379
- cleanup
4380
- });
4381
- }
4382
- async function runCleanup2(cleanup) {
4383
- while (cleanup.length > 0) {
4384
- const fn = cleanup.pop();
4385
- try {
4386
- await fn();
4387
- } catch {
4388
- }
4389
- }
4390
- }
4391
-
4392
- // src/pilot/cli/retry.ts
4393
- import { command as command6, option as option5, optional as optional6, positional as positional4, string as string6, flag as flag4 } from "cmd-ts";
4394
- var retryCmd = command6({
4395
- name: "retry",
4396
- description: "Reset a single task to pending. Optionally also re-run it.",
4397
- args: {
4398
- taskId: positional4({
4399
- type: string6,
4400
- displayName: "task-id",
4401
- description: "Task id to reset (e.g. T1)."
4402
- }),
4403
- run: option5({
4404
- long: "run",
4405
- type: optional6(string6),
4406
- description: "Run ID. Defaults to the newest run."
4407
- }),
4408
- runNow: flag4({
4409
- long: "run-now",
4410
- description: "After resetting, immediately run `pilot resume` on the same DB."
4411
- })
4412
- },
4413
- handler: async ({ taskId, run: run2, runNow }) => {
4414
- await requirePlugin();
4415
- const code = await runRetry({ taskId, runId: run2, runNow });
4416
- process.exit(code);
4417
- }
4418
- });
4419
- async function runRetry(opts) {
4420
- let discovered;
4421
- try {
4422
- discovered = await discoverRun({
4423
- cwd: process.cwd(),
4424
- runId: opts.runId
4425
- });
4426
- } catch (err) {
4427
- process.stderr.write(
4428
- `${err instanceof Error ? err.message : String(err)}
4429
- `
4430
- );
4431
- return 1;
4432
- }
4433
- const opened = openStateDb(discovered.dbPath);
4434
- try {
4435
- const task = getTask(opened.db, discovered.runId, opts.taskId);
4436
- if (task === null) {
4437
- process.stderr.write(
4438
- `pilot retry: task ${JSON.stringify(opts.taskId)} not found in run ${discovered.runId}
4439
- `
4440
- );
4441
- return 1;
4442
- }
4443
- const previousStatus = task.status;
4444
- try {
4445
- markPending(opened.db, discovered.runId, opts.taskId);
4446
- } catch (err) {
4447
- process.stderr.write(
4448
- `pilot retry: ${err instanceof Error ? err.message : String(err)}
4449
- `
4450
- );
4451
- return 1;
4452
- }
4453
- appendEvent(opened.db, {
4454
- runId: discovered.runId,
4455
- taskId: opts.taskId,
4456
- kind: "task.retry",
4457
- payload: { previousStatus }
4458
- });
4459
- process.stdout.write(
4460
- `pilot retry: ${opts.taskId} reset to pending (was ${previousStatus})
4461
- `
4462
- );
4463
- } finally {
4464
- opened.close();
4465
- }
4466
- if (opts.runNow) {
4467
- return runResume({ runId: discovered.runId });
4468
- }
4469
- return 0;
4470
- }
4471
-
4472
4130
  // src/pilot/cli/logs.ts
4473
- import { command as command7, flag as flag5, option as option6, optional as optional7, positional as positional5, string as string7 } from "cmd-ts";
4474
- var logsCmd = command7({
4131
+ import { command as command6, flag as flag5, option as option5, optional as optional6, positional as positional4, string as string6 } from "cmd-ts";
4132
+ var logsCmd = command6({
4475
4133
  name: "logs",
4476
4134
  description: "Print structured events for a task.",
4477
4135
  args: {
4478
- taskId: positional5({
4479
- type: string7,
4136
+ taskId: positional4({
4137
+ type: string6,
4480
4138
  displayName: "task-id"
4481
4139
  }),
4482
- run: option6({
4140
+ run: option5({
4483
4141
  long: "run",
4484
- type: optional7(string7),
4142
+ type: optional6(string6),
4485
4143
  description: "Run ID. Defaults to the newest run."
4486
4144
  }),
4487
4145
  json: flag5({
@@ -4601,172 +4259,18 @@ function summarizePayload(kind, payload) {
4601
4259
  return s;
4602
4260
  }
4603
4261
 
4604
- // src/pilot/cli/worktrees.ts
4605
- import { command as command8, flag as flag6, option as option7, optional as optional8, string as string8, subcommands } from "cmd-ts";
4606
- import { promises as fs12 } from "fs";
4607
- import * as path11 from "path";
4608
- var listSubcmd = command8({
4609
- name: "list",
4610
- description: "List worktrees registered with the repo (filter to pilot ones).",
4611
- args: {
4612
- run: option7({
4613
- long: "run",
4614
- type: optional8(string8),
4615
- description: "Run ID for context. Defaults to the newest run."
4616
- })
4617
- },
4618
- handler: async ({ run: run2 }) => {
4619
- const code = await runWorktreesList({ runId: run2 });
4620
- process.exit(code);
4621
- }
4622
- });
4623
- var pruneSubcmd = command8({
4624
- name: "prune",
4625
- description: "Remove worktrees from succeeded tasks (default) or all (--all).",
4626
- args: {
4627
- run: option7({
4628
- long: "run",
4629
- type: optional8(string8),
4630
- description: "Run ID. Defaults to the newest run."
4631
- }),
4632
- all: flag6({
4633
- long: "all",
4634
- description: "Remove every pilot worktree for this run, even failed/aborted ones."
4635
- }),
4636
- dryRun: flag6({
4637
- long: "dry-run",
4638
- description: "Print what would be removed without removing."
4639
- })
4640
- },
4641
- handler: async ({ run: run2, all, dryRun }) => {
4642
- const code = await runWorktreesPrune({ runId: run2, all, dryRun });
4643
- process.exit(code);
4644
- }
4645
- });
4646
- var worktreesCmd = subcommands({
4647
- name: "worktrees",
4648
- description: "Inspect and prune pilot-managed git worktrees.",
4649
- cmds: {
4650
- list: listSubcmd,
4651
- prune: pruneSubcmd
4652
- }
4653
- });
4654
- async function runWorktreesList(opts) {
4655
- let discovered;
4656
- try {
4657
- discovered = await discoverRun({
4658
- cwd: process.cwd(),
4659
- runId: opts.runId
4660
- });
4661
- } catch (err) {
4662
- process.stderr.write(
4663
- `${err instanceof Error ? err.message : String(err)}
4664
- `
4665
- );
4666
- return 1;
4667
- }
4668
- const all = await gitWorktreeList(process.cwd());
4669
- const wtBase = path11.join(discovered.runDir, "..");
4670
- const pilotDir = path11.dirname(path11.dirname(discovered.runDir));
4671
- const wtPrefix = path11.join(pilotDir, "worktrees", discovered.runId);
4672
- const filtered = all.filter((w) => w.path.startsWith(wtPrefix));
4673
- void wtBase;
4674
- if (filtered.length === 0) {
4675
- process.stdout.write(
4676
- `pilot worktrees list: no pilot worktrees for run ${discovered.runId}
4677
- `
4678
- );
4679
- return 0;
4680
- }
4681
- for (const w of filtered) {
4682
- process.stdout.write(
4683
- `${w.path} ${w.head.slice(0, 7)} ${w.branch ?? "(detached)"}
4684
- `
4685
- );
4686
- }
4687
- return 0;
4688
- }
4689
- async function runWorktreesPrune(opts) {
4690
- let discovered;
4691
- try {
4692
- discovered = await discoverRun({
4693
- cwd: process.cwd(),
4694
- runId: opts.runId
4695
- });
4696
- } catch (err) {
4697
- process.stderr.write(
4698
- `${err instanceof Error ? err.message : String(err)}
4699
- `
4700
- );
4701
- return 1;
4702
- }
4703
- const opened = openStateDb(discovered.dbPath);
4704
- let candidates;
4705
- try {
4706
- const tasks = listTasks(opened.db, discovered.runId);
4707
- const run2 = getRun(opened.db, discovered.runId);
4708
- if (opts.all) {
4709
- candidates = tasks.map((t) => t.worktree_path).filter((p) => p !== null);
4710
- } else {
4711
- const safeStatuses = run2?.status === "completed";
4712
- candidates = tasks.filter((t) => safeStatuses && t.status === "succeeded").map((t) => t.worktree_path).filter((p) => p !== null);
4713
- }
4714
- } finally {
4715
- opened.close();
4716
- }
4717
- const uniq = [...new Set(candidates)];
4718
- if (uniq.length === 0) {
4719
- process.stdout.write(
4720
- `pilot worktrees prune: nothing to prune for run ${discovered.runId}` + (opts.all ? "" : " (use --all to force)") + "\n"
4721
- );
4722
- return 0;
4723
- }
4724
- if (opts.dryRun) {
4725
- process.stdout.write("Would remove:\n");
4726
- for (const p of uniq) process.stdout.write(` ${p}
4727
- `);
4728
- return 0;
4729
- }
4730
- let removed = 0;
4731
- let errors = 0;
4732
- for (const p of uniq) {
4733
- try {
4734
- await gitWorktreeRemove({
4735
- repoPath: process.cwd(),
4736
- worktreePath: p
4737
- });
4738
- try {
4739
- await fs12.rm(p, { recursive: true, force: true });
4740
- } catch {
4741
- }
4742
- removed++;
4743
- } catch (err) {
4744
- errors++;
4745
- process.stderr.write(
4746
- `pilot worktrees prune: failed to remove ${p}: ${err instanceof Error ? err.message : String(err)}
4747
- `
4748
- );
4749
- }
4750
- }
4751
- process.stdout.write(
4752
- `pilot worktrees prune: removed ${removed}/${uniq.length} (${errors} errors)
4753
- `
4754
- );
4755
- return errors > 0 ? 1 : 0;
4756
- }
4757
-
4758
4262
  // src/pilot/cli/cost.ts
4759
- import { command as command9, flag as flag7, option as option8, optional as optional9, string as string9 } from "cmd-ts";
4760
- var costCmd = command9({
4263
+ import { command as command7, flag as flag6, option as option6, optional as optional7, string as string7 } from "cmd-ts";
4264
+ var costCmd = command7({
4761
4265
  name: "cost",
4762
4266
  description: "Print per-task and total cost for a run.",
4763
4267
  args: {
4764
- run: option8({
4268
+ run: option6({
4765
4269
  long: "run",
4766
- type: optional9(string9),
4270
+ type: optional7(string7),
4767
4271
  description: "Run ID. Defaults to the newest run."
4768
4272
  }),
4769
- json: flag7({
4273
+ json: flag6({
4770
4274
  long: "json",
4771
4275
  description: "Emit JSON instead of human-readable text."
4772
4276
  })
@@ -4828,8 +4332,8 @@ async function runCost(opts) {
4828
4332
  }
4829
4333
 
4830
4334
  // src/pilot/cli/plan-dir.ts
4831
- import { command as command10 } from "cmd-ts";
4832
- var planDirCmd = command10({
4335
+ import { command as command8 } from "cmd-ts";
4336
+ var planDirCmd = command8({
4833
4337
  name: "plan-dir",
4834
4338
  description: "Print the pilot plans directory for the current worktree (creates it if missing).",
4835
4339
  args: {},
@@ -4849,29 +4353,27 @@ var planDirCmd = command10({
4849
4353
  });
4850
4354
 
4851
4355
  // src/pilot/cli/index.ts
4852
- var pilotSubcommand = subcommands2({
4356
+ var pilotSubcommand = subcommands({
4853
4357
  name: "pilot",
4854
4358
  description: "Pilot subsystem \u2014 plan, validate, build, and manage unattended task runs.",
4855
4359
  cmds: {
4856
4360
  validate: validateCmd,
4857
4361
  plan: planCmd,
4858
4362
  build: buildCmd,
4363
+ "build-resume": buildResumeCmd,
4859
4364
  status: statusCmd,
4860
- resume: resumeCmd,
4861
- retry: retryCmd,
4862
4365
  logs: logsCmd,
4863
- worktrees: worktreesCmd,
4864
4366
  cost: costCmd,
4865
4367
  "plan-dir": planDirCmd
4866
4368
  }
4867
4369
  });
4868
4370
 
4869
4371
  // src/cli/cli-update.ts
4870
- import * as fs13 from "fs";
4871
- import * as path12 from "path";
4872
- import * as os5 from "os";
4372
+ import * as fs10 from "fs";
4373
+ import * as path10 from "path";
4374
+ import * as os3 from "os";
4873
4375
  import { spawn as spawn3 } from "child_process";
4874
- import { fileURLToPath as fileURLToPath2 } from "url";
4376
+ import { fileURLToPath as fileURLToPath3 } from "url";
4875
4377
  var PACKAGE_NAME = "@glrs-dev/harness-plugin-opencode";
4876
4378
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
4877
4379
  var c = {
@@ -4896,12 +4398,12 @@ function isMajorBump(current, latest) {
4896
4398
  return latest.major > current.major;
4897
4399
  }
4898
4400
  function getStateFilePath() {
4899
- const cacheHome = process.env["XDG_CACHE_HOME"] ?? path12.join(os5.homedir(), ".cache");
4900
- return path12.join(cacheHome, "harness-opencode", "cli-update.json");
4401
+ const cacheHome = process.env["XDG_CACHE_HOME"] ?? path10.join(os3.homedir(), ".cache");
4402
+ return path10.join(cacheHome, "harness-opencode", "cli-update.json");
4901
4403
  }
4902
4404
  function readState() {
4903
4405
  try {
4904
- const raw = fs13.readFileSync(getStateFilePath(), "utf8");
4406
+ const raw = fs10.readFileSync(getStateFilePath(), "utf8");
4905
4407
  return JSON.parse(raw);
4906
4408
  } catch {
4907
4409
  return null;
@@ -4910,21 +4412,21 @@ function readState() {
4910
4412
  function writeState(state) {
4911
4413
  try {
4912
4414
  const statePath = getStateFilePath();
4913
- fs13.mkdirSync(path12.dirname(statePath), { recursive: true });
4914
- fs13.writeFileSync(statePath, JSON.stringify(state));
4415
+ fs10.mkdirSync(path10.dirname(statePath), { recursive: true });
4416
+ fs10.writeFileSync(statePath, JSON.stringify(state));
4915
4417
  } catch {
4916
4418
  }
4917
4419
  }
4918
4420
  function readInstalledVersion() {
4919
- const here = path12.dirname(fileURLToPath2(import.meta.url));
4421
+ const here = path10.dirname(fileURLToPath3(import.meta.url));
4920
4422
  const candidates = [
4921
- path12.join(here, "..", "package.json"),
4922
- path12.join(here, "..", "..", "package.json"),
4923
- path12.join(here, "package.json")
4423
+ path10.join(here, "..", "package.json"),
4424
+ path10.join(here, "..", "..", "package.json"),
4425
+ path10.join(here, "package.json")
4924
4426
  ];
4925
4427
  for (const candidate of candidates) {
4926
4428
  try {
4927
- const raw = fs13.readFileSync(candidate, "utf8");
4429
+ const raw = fs10.readFileSync(candidate, "utf8");
4928
4430
  const parsed = JSON.parse(raw);
4929
4431
  if (parsed.name === PACKAGE_NAME && typeof parsed.version === "string") {
4930
4432
  return parsed.version;
@@ -5050,15 +4552,15 @@ Upgrade Node or run via a compatible Bun runtime. See the "engines" field in pac
5050
4552
  }
5051
4553
  }
5052
4554
  var VERSION = "0.1.0";
5053
- var installCmd = command11({
4555
+ var installCmd = command9({
5054
4556
  name: "install",
5055
4557
  description: 'Add "@glrs-dev/harness-plugin-opencode" to your opencode.json plugin array.',
5056
4558
  args: {
5057
- dryRun: flag8({
4559
+ dryRun: flag7({
5058
4560
  long: "dry-run",
5059
4561
  description: "Preview changes without writing."
5060
4562
  }),
5061
- pin: flag8({
4563
+ pin: flag7({
5062
4564
  long: "pin",
5063
4565
  description: "Pin to the current exact version (e.g. @0.1.0)."
5064
4566
  })
@@ -5067,11 +4569,11 @@ var installCmd = command11({
5067
4569
  await install({ dryRun, pin });
5068
4570
  }
5069
4571
  });
5070
- var uninstallCmd = command11({
4572
+ var uninstallCmd = command9({
5071
4573
  name: "uninstall",
5072
4574
  description: 'Remove "@glrs-dev/harness-plugin-opencode" from your opencode.json plugin array.',
5073
4575
  args: {
5074
- dryRun: flag8({
4576
+ dryRun: flag7({
5075
4577
  long: "dry-run",
5076
4578
  description: "Preview changes without writing."
5077
4579
  })
@@ -5080,7 +4582,7 @@ var uninstallCmd = command11({
5080
4582
  uninstall({ dryRun });
5081
4583
  }
5082
4584
  });
5083
- var doctorCmd = command11({
4585
+ var doctorCmd = command9({
5084
4586
  name: "doctor",
5085
4587
  description: "Check installation health (OpenCode CLI, plugin registration, MCP backends).",
5086
4588
  args: {},
@@ -5088,22 +4590,22 @@ var doctorCmd = command11({
5088
4590
  doctor();
5089
4591
  }
5090
4592
  });
5091
- var planCheckCmd = command11({
4593
+ var planCheckCmd = command9({
5092
4594
  name: "plan-check",
5093
4595
  description: "Parse a plan file's plan-state fence (legacy markdown plans).",
5094
4596
  args: {
5095
- run: option9({
4597
+ run: option7({
5096
4598
  long: "run",
5097
- type: optional10(string10),
4599
+ type: optional8(string8),
5098
4600
  description: "Print verify commands for pending items, one per line."
5099
4601
  }),
5100
- check: option9({
4602
+ check: option7({
5101
4603
  long: "check",
5102
- type: optional10(string10),
4604
+ type: optional8(string8),
5103
4605
  description: "Structural validation; exits 1 if any item is invalid."
5104
4606
  }),
5105
4607
  rest: restPositionals({
5106
- type: string10,
4608
+ type: string8,
5107
4609
  displayName: "plan-path",
5108
4610
  description: "Path to a plan markdown file. Required unless --run / --check is given."
5109
4611
  })
@@ -5120,7 +4622,7 @@ var planCheckCmd = command11({
5120
4622
  planCheck(legacy);
5121
4623
  }
5122
4624
  });
5123
- var planDirCmd2 = command11({
4625
+ var planDirCmd2 = command9({
5124
4626
  name: "plan-dir",
5125
4627
  description: "Print the repo-shared plan directory for the current worktree (resolves + creates + migrates legacy).",
5126
4628
  args: {},
@@ -5139,15 +4641,15 @@ var planDirCmd2 = command11({
5139
4641
  }
5140
4642
  }
5141
4643
  });
5142
- var installPluginCmd = command11({
4644
+ var installPluginCmd = command9({
5143
4645
  name: "install-plugin",
5144
4646
  description: 'Add "@glrs-dev/harness-plugin-opencode" to your opencode.json plugin array.',
5145
4647
  args: {
5146
- dryRun: flag8({
4648
+ dryRun: flag7({
5147
4649
  long: "dry-run",
5148
4650
  description: "Preview changes without writing."
5149
4651
  }),
5150
- pin: flag8({
4652
+ pin: flag7({
5151
4653
  long: "pin",
5152
4654
  description: "Pin to the current exact version (e.g. @0.1.0)."
5153
4655
  })
@@ -5156,7 +4658,7 @@ var installPluginCmd = command11({
5156
4658
  await install({ dryRun, pin });
5157
4659
  }
5158
4660
  });
5159
- var cli = subcommands3({
4661
+ var cli = subcommands2({
5160
4662
  name: "glrs-oc",
5161
4663
  description: "OpenCode agent harness CLI.",
5162
4664
  version: VERSION,