lobstah 0.1.0 → 0.1.2

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/main.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // apps/cli/src/main.ts
4
- import * as fs16 from "node:fs";
5
- import * as path12 from "node:path";
4
+ import * as fs23 from "node:fs";
5
+ import * as os6 from "node:os";
6
+ import * as path17 from "node:path";
6
7
  import { randomUUID as randomUUID3 } from "node:crypto";
7
- import { spawnSync } from "node:child_process";
8
+ import { spawnSync as spawnSync4 } from "node:child_process";
9
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
8
10
 
9
11
  // packages/core/dist/types.js
10
12
  var VERBS = ["working", "needs-decision", "blocked", "paused", "done", "failed"];
@@ -868,7 +870,8 @@ function loadConfig() {
868
870
  trunk: String(r.trunk ?? "main"),
869
871
  setup: Array.isArray(r.setup) ? r.setup.map(String) : void 0,
870
872
  env: r.env ?? void 0,
871
- harness: r.harness ?? void 0
873
+ harness: r.harness ?? void 0,
874
+ pickup: r.pickup === void 0 ? void 0 : Boolean(r.pickup)
872
875
  };
873
876
  }
874
877
  return {
@@ -898,6 +901,68 @@ function toonTable(name, rows, cols) {
898
901
  return [header, ...rows.map((r) => ` ${cols.map((c) => cell(r[c])).join(",")}`)].join("\n");
899
902
  }
900
903
 
904
+ // packages/core/dist/version.js
905
+ import * as fs8 from "node:fs";
906
+ function lobstahVersion() {
907
+ try {
908
+ const pkg = JSON.parse(fs8.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
909
+ if (pkg.name === "lobstah" && typeof pkg.version === "string")
910
+ return pkg.version;
911
+ } catch {
912
+ }
913
+ return "0.0.0-dev";
914
+ }
915
+
916
+ // packages/core/dist/harness.js
917
+ import * as fs9 from "node:fs";
918
+ import * as path5 from "node:path";
919
+ import { createRequire } from "node:module";
920
+ function onPath(bin, pathVar = process.env.PATH) {
921
+ if (!pathVar)
922
+ return false;
923
+ const names = process.platform === "win32" ? [bin, `${bin}.exe`, `${bin}.cmd`, `${bin}.bat`] : [bin];
924
+ for (const dir of pathVar.split(path5.delimiter)) {
925
+ if (!dir)
926
+ continue;
927
+ for (const name of names) {
928
+ const candidate = path5.join(dir, name);
929
+ try {
930
+ fs9.accessSync(candidate, fs9.constants.X_OK);
931
+ if (fs9.statSync(candidate).isFile())
932
+ return true;
933
+ } catch {
934
+ }
935
+ }
936
+ }
937
+ return false;
938
+ }
939
+ function resolvable(specifier) {
940
+ try {
941
+ createRequire(import.meta.url).resolve(specifier);
942
+ return true;
943
+ } catch {
944
+ return false;
945
+ }
946
+ }
947
+ function detectHarnesses() {
948
+ const out = [];
949
+ if (onPath("claude") || resolvable("@anthropic-ai/claude-agent-sdk"))
950
+ out.push("claude");
951
+ if (onPath("codex") || resolvable("@openai/codex-sdk"))
952
+ out.push("codex");
953
+ return out;
954
+ }
955
+ function codexInvocation(argv) {
956
+ if (onPath("codex"))
957
+ return { file: "codex", argv };
958
+ try {
959
+ const cli = createRequire(import.meta.url).resolve("@openai/codex/bin/codex.js");
960
+ return { file: process.execPath, argv: [cli, ...argv] };
961
+ } catch {
962
+ return void 0;
963
+ }
964
+ }
965
+
901
966
  // packages/supervisor/dist/liveness.js
902
967
  import { execFileSync } from "node:child_process";
903
968
  function classify(input) {
@@ -956,26 +1021,26 @@ function killGroup(pid, signal = "SIGTERM") {
956
1021
  }
957
1022
 
958
1023
  // packages/supervisor/dist/daemon.js
959
- import * as fs9 from "node:fs";
1024
+ import * as fs11 from "node:fs";
960
1025
  import * as os3 from "node:os";
961
- import * as path6 from "node:path";
1026
+ import * as path7 from "node:path";
962
1027
  import { spawn as spawn2 } from "node:child_process";
963
- import { createRequire } from "node:module";
1028
+ import { createRequire as createRequire2 } from "node:module";
964
1029
  import { fileURLToPath } from "node:url";
965
1030
 
966
1031
  // packages/supervisor/dist/notify.js
967
- import * as fs8 from "node:fs";
968
- import * as path5 from "node:path";
1032
+ import * as fs10 from "node:fs";
1033
+ import * as path6 from "node:path";
969
1034
  import { spawn } from "node:child_process";
970
1035
  var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
971
1036
  function cursorPath(id, lane) {
972
- return path5.join(laneDirs(lane).state, `${id}.notified`);
1037
+ return path6.join(laneDirs(lane).state, `${id}.notified`);
973
1038
  }
974
1039
  function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
975
1040
  const log = readStatusLog(id, lane);
976
1041
  let cursor;
977
1042
  try {
978
- cursor = Number(fs8.readFileSync(cursorPath(id, lane), "utf8")) || 0;
1043
+ cursor = Number(fs10.readFileSync(cursorPath(id, lane), "utf8")) || 0;
979
1044
  } catch {
980
1045
  cursor = void 0;
981
1046
  }
@@ -985,13 +1050,13 @@ function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs =
985
1050
  if (cursor === void 0 && sinceMs > 0) {
986
1051
  fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
987
1052
  }
988
- fs8.writeFileSync(cursorPath(id, lane), String(log.length));
1053
+ fs10.writeFileSync(cursorPath(id, lane), String(log.length));
989
1054
  return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
990
1055
  }
991
1056
  function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
992
1057
  const dir = laneDirs(lane).state;
993
1058
  const cutoff = Date.now() - maxAgeMs;
994
- return fs8.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs8.statSync(path5.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
1059
+ return fs10.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs10.statSync(path6.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
995
1060
  }
996
1061
  function execNotify(command, ev, log = () => {
997
1062
  }) {
@@ -1021,11 +1086,11 @@ function captureWaitBaseline() {
1021
1086
  return lengths;
1022
1087
  }
1023
1088
  function attnCursorPath(id, lane) {
1024
- return path5.join(laneDirs(lane).state, `${id}.attn`);
1089
+ return path6.join(laneDirs(lane).state, `${id}.attn`);
1025
1090
  }
1026
1091
  function readAttnCursor(file) {
1027
1092
  try {
1028
- const parsed = JSON.parse(fs8.readFileSync(file, "utf8"));
1093
+ const parsed = JSON.parse(fs10.readFileSync(file, "utf8"));
1029
1094
  return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
1030
1095
  } catch {
1031
1096
  return { seen: 0, at: 0 };
@@ -1051,7 +1116,7 @@ function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
1051
1116
  continue;
1052
1117
  out.push({ id, lane, entry });
1053
1118
  if (consume)
1054
- fs8.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
1119
+ fs10.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
1055
1120
  }
1056
1121
  }
1057
1122
  return out;
@@ -1076,7 +1141,7 @@ function freshWakeEvents(baseline, verbs = DEFAULT_NOTIFY_VERBS) {
1076
1141
  }
1077
1142
 
1078
1143
  // packages/supervisor/dist/daemon.js
1079
- var require_ = createRequire(import.meta.url);
1144
+ var require_ = createRequire2(import.meta.url);
1080
1145
  function runnerEntry() {
1081
1146
  try {
1082
1147
  return require_.resolve("@lobstah/runner");
@@ -1086,33 +1151,33 @@ function runnerEntry() {
1086
1151
  }
1087
1152
  function readRunnerInfo(dir) {
1088
1153
  try {
1089
- return JSON.parse(fs9.readFileSync(path6.join(dir, "runner.json"), "utf8"));
1154
+ return JSON.parse(fs11.readFileSync(path7.join(dir, "runner.json"), "utf8"));
1090
1155
  } catch {
1091
1156
  return void 0;
1092
1157
  }
1093
1158
  }
1094
1159
  function listActive(lane) {
1095
1160
  const dir = laneDirs(lane).active;
1096
- return fs9.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
1097
- const d = path6.join(dir, id);
1161
+ return fs11.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
1162
+ const d = path7.join(dir, id);
1098
1163
  return { id, lane, dir: d, runner: readRunnerInfo(d) };
1099
1164
  });
1100
1165
  }
1101
1166
  function finalize(st) {
1102
1167
  try {
1103
- fs9.renameSync(st.dir, path6.join(laneDirs(st.lane).done, st.id));
1168
+ fs11.renameSync(st.dir, path7.join(laneDirs(st.lane).done, st.id));
1104
1169
  } catch {
1105
1170
  }
1106
1171
  }
1107
1172
  function spawnRunner(st, opts) {
1108
1173
  let nudge = opts.nudge;
1109
- const handoffFile = path6.join(st.dir, "handoff");
1110
- if (!nudge && fs9.existsSync(handoffFile)) {
1111
- nudge = fs9.readFileSync(handoffFile, "utf8");
1112
- fs9.unlinkSync(handoffFile);
1174
+ const handoffFile = path7.join(st.dir, "handoff");
1175
+ if (!nudge && fs11.existsSync(handoffFile)) {
1176
+ nudge = fs11.readFileSync(handoffFile, "utf8");
1177
+ fs11.unlinkSync(handoffFile);
1113
1178
  }
1114
- const logPath = path6.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
1115
- const log = fs9.openSync(logPath, "a");
1179
+ const logPath = path7.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
1180
+ const log = fs11.openSync(logPath, "a");
1116
1181
  const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
1117
1182
  detached: true,
1118
1183
  stdio: ["ignore", log, log],
@@ -1123,7 +1188,7 @@ function spawnRunner(st, opts) {
1123
1188
  ...nudge ? { LOBSTAH_NUDGE: nudge } : {}
1124
1189
  }
1125
1190
  });
1126
- fs9.closeSync(log);
1191
+ fs11.closeSync(log);
1127
1192
  child.unref();
1128
1193
  const info = {
1129
1194
  pid: child.pid ?? -1,
@@ -1131,30 +1196,38 @@ function spawnRunner(st, opts) {
1131
1196
  processStartTime: child.pid ? processStartTime(child.pid) : void 0,
1132
1197
  attempts: opts.attempts
1133
1198
  };
1134
- fs9.writeFileSync(path6.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
1199
+ fs11.writeFileSync(path7.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
1135
1200
  }
1136
1201
  function sessionOf(st) {
1137
1202
  try {
1138
- const ev = JSON.parse(fs9.readFileSync(path6.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
1203
+ const ev = JSON.parse(fs11.readFileSync(path7.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
1139
1204
  return ev.sessionId;
1140
1205
  } catch {
1141
1206
  return void 0;
1142
1207
  }
1143
1208
  }
1144
1209
  function reconcileOne(st, cfg, log) {
1145
- const hasDescriptor = fs9.existsSync(path6.join(st.dir, "descriptor.json"));
1210
+ const hasDescriptor = fs11.existsSync(path7.join(st.dir, "descriptor.json"));
1146
1211
  if (!hasDescriptor) {
1147
- const age = Date.now() - fs9.statSync(st.dir).mtimeMs;
1212
+ const age = Date.now() - fs11.statSync(st.dir).mtimeMs;
1148
1213
  if (age > 5 * 6e4)
1149
- fs9.rmSync(st.dir, { recursive: true, force: true });
1214
+ fs11.rmSync(st.dir, { recursive: true, force: true });
1150
1215
  return;
1151
1216
  }
1152
1217
  const statusLog = readStatusLog(st.id, st.lane);
1153
1218
  const lastVerb = statusLog.at(-1)?.verb;
1154
1219
  const alive = st.runner ? pidAlive(st.runner.pid, st.runner.processStartTime) : void 0;
1155
- if (cancelRequested(st.id, st.lane) && st.runner && alive) {
1156
- log(`${st.id}: cancel requested, killing group ${st.runner.pid}`);
1157
- killGroup(st.runner.pid);
1220
+ if (cancelRequested(st.id, st.lane)) {
1221
+ if (st.runner && alive) {
1222
+ log(`${st.id}: cancel requested, killing group ${st.runner.pid}`);
1223
+ killGroup(st.runner.pid);
1224
+ return;
1225
+ }
1226
+ if (lastVerb !== "done" && lastVerb !== "failed") {
1227
+ appendStatus(st.id, st.lane, "failed", "cancelled by request; work preserved");
1228
+ }
1229
+ finalize(st);
1230
+ log(`${st.id}: cancelled, finalized`);
1158
1231
  return;
1159
1232
  }
1160
1233
  const cls = classify({
@@ -1216,22 +1289,22 @@ function writeHeartbeat(cfg) {
1216
1289
  const payload = {
1217
1290
  machineId: os3.hostname(),
1218
1291
  repos: Object.keys(cfg.repos),
1219
- harnesses: ["claude", "codex"],
1292
+ harnesses: detectHarnesses(),
1220
1293
  maxConcurrent: cfg.limits.maxConcurrent,
1221
- version: "0.1.0",
1294
+ version: lobstahVersion(),
1222
1295
  heartbeat: (/* @__PURE__ */ new Date()).toISOString()
1223
1296
  };
1224
1297
  const tmp = `${executorPath()}.tmp`;
1225
- fs9.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1226
- fs9.renameSync(tmp, executorPath());
1298
+ fs11.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1299
+ fs11.renameSync(tmp, executorPath());
1227
1300
  }
1228
1301
  function pruneChores(cfg) {
1229
1302
  const dir = laneDirs("chore").done;
1230
1303
  const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
1231
- for (const id of fs9.readdirSync(dir)) {
1232
- const p = path6.join(dir, id);
1233
- if (fs9.statSync(p).mtimeMs < cutoff)
1234
- fs9.rmSync(p, { recursive: true, force: true });
1304
+ for (const id of fs11.readdirSync(dir)) {
1305
+ const p = path7.join(dir, id);
1306
+ if (fs11.statSync(p).mtimeMs < cutoff)
1307
+ fs11.rmSync(p, { recursive: true, force: true });
1235
1308
  }
1236
1309
  }
1237
1310
  var daemonStartedAt = Date.now();
@@ -1271,9 +1344,9 @@ function tick(log = () => {
1271
1344
  pruneChores(loadConfig());
1272
1345
  }
1273
1346
  function acquireDaemonLock() {
1274
- const lockPath = path6.join(lobstahHome(), "daemon.lock");
1347
+ const lockPath = path7.join(lobstahHome(), "daemon.lock");
1275
1348
  try {
1276
- const prior = JSON.parse(fs9.readFileSync(lockPath, "utf8"));
1349
+ const prior = JSON.parse(fs11.readFileSync(lockPath, "utf8"));
1277
1350
  if (pidAlive(prior.pid, prior.processStartTime)) {
1278
1351
  throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
1279
1352
  }
@@ -1281,10 +1354,10 @@ function acquireDaemonLock() {
1281
1354
  if (err instanceof Error && err.message.includes("already supervises"))
1282
1355
  throw err;
1283
1356
  }
1284
- fs9.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
1357
+ fs11.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
1285
1358
  const release = () => {
1286
1359
  try {
1287
- fs9.unlinkSync(lockPath);
1360
+ fs11.unlinkSync(lockPath);
1288
1361
  } catch {
1289
1362
  }
1290
1363
  };
@@ -1314,7 +1387,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
1314
1387
  import { spawn as spawn3 } from "node:child_process";
1315
1388
 
1316
1389
  // apps/pick/dist/config.js
1317
- import * as fs10 from "node:fs";
1390
+ import * as fs12 from "node:fs";
1318
1391
  import { execSync } from "node:child_process";
1319
1392
  import * as os4 from "node:os";
1320
1393
 
@@ -1333,6 +1406,11 @@ function marker(uuid) {
1333
1406
  }
1334
1407
 
1335
1408
  // apps/pick/dist/config.js
1409
+ function githubRepoFromOrigin(origin) {
1410
+ const m = /^git@github\.com:(.+?)(?:\.git)?$/.exec(origin) ?? /^ssh:\/\/git@github\.com\/(.+?)(?:\.git)?$/.exec(origin) ?? /^https:\/\/github\.com\/(.+?)(?:\.git)?\/?$/.exec(origin);
1411
+ const repo = m?.[1];
1412
+ return repo && repo.split("/").length === 2 ? repo : void 0;
1413
+ }
1336
1414
  function resolveTokenSource(section, label) {
1337
1415
  const command = section.tokenCommand ? String(section.tokenCommand) : void 0;
1338
1416
  const file = section.tokenFile ? String(section.tokenFile) : void 0;
@@ -1351,7 +1429,7 @@ function resolveTokenSource(section, label) {
1351
1429
  }
1352
1430
  if (file) {
1353
1431
  return () => {
1354
- const token = fs10.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
1432
+ const token = fs12.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
1355
1433
  if (!token)
1356
1434
  throw new Error(`${label}: tokenFile ${file} is empty`);
1357
1435
  return token;
@@ -1369,36 +1447,70 @@ function resolveTokenSource(section, label) {
1369
1447
  }
1370
1448
  function loadPickupConfig() {
1371
1449
  const file = configPath();
1372
- const raw = fs10.existsSync(file) ? parse(fs10.readFileSync(file, "utf8")) : {};
1450
+ const raw = fs12.existsSync(file) ? parse(fs12.readFileSync(file, "utf8")) : {};
1373
1451
  const p = raw.pickup ?? {};
1374
1452
  const out = {
1375
1453
  pollSecs: Number(p.pollSecs ?? 45),
1376
- notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0
1454
+ notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0,
1455
+ github: []
1377
1456
  };
1378
1457
  const gh = p.github;
1379
1458
  if (gh) {
1380
1459
  const token = resolveTokenSource({ tokenEnv: "GITHUB_TOKEN", ...gh }, "pickup.github");
1381
1460
  token();
1382
- out.github = {
1383
- repo: String(gh.repo ?? ""),
1384
- key: String(gh.key ?? ""),
1385
- identity: String(gh.identity ?? ""),
1461
+ const identity = String(gh.identity ?? "");
1462
+ if (!identity)
1463
+ throw new Error("pickup.github requires identity");
1464
+ const shared = {
1465
+ identity,
1386
1466
  token,
1387
1467
  startLabel: String(gh.startLabel ?? "lobstah"),
1388
- claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed"),
1389
- merge: { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} }
1468
+ claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed")
1390
1469
  };
1391
- if (!out.github.repo || !out.github.key || !out.github.identity) {
1392
- throw new Error("pickup.github requires repo, key, and identity");
1470
+ const mergeBase = { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} };
1471
+ const overrides = gh.overrides ?? {};
1472
+ if (gh.repo || gh.key) {
1473
+ const single = { ...shared, repo: String(gh.repo ?? ""), key: String(gh.key ?? ""), merge: mergeBase };
1474
+ if (!single.repo || !single.key)
1475
+ throw new Error("pickup.github requires repo, key, and identity");
1476
+ out.github.push(single);
1477
+ } else {
1478
+ for (const [key, repo] of Object.entries(loadConfig().repos)) {
1479
+ if (!repo.pickup)
1480
+ continue;
1481
+ if (!repo.origin)
1482
+ throw new Error(`repos.${key} has pickup = true but no origin to derive the GitHub repo from`);
1483
+ const forgeRepo = githubRepoFromOrigin(repo.origin);
1484
+ if (!forgeRepo)
1485
+ throw new Error(`repos.${key} has pickup = true but origin "${repo.origin}" is not a GitHub URL`);
1486
+ const o = overrides[key] ?? {};
1487
+ out.github.push({
1488
+ ...shared,
1489
+ repo: forgeRepo,
1490
+ key,
1491
+ startLabel: o.startLabel ? String(o.startLabel) : shared.startLabel,
1492
+ claimedLabel: o.claimedLabel ? String(o.claimedLabel) : shared.claimedLabel,
1493
+ merge: { ...mergeBase, ...o.merge ?? {} }
1494
+ });
1495
+ }
1496
+ if (out.github.length === 0) {
1497
+ throw new Error("pickup.github has no repo \u2014 multi-repo mode needs at least one [repos.<key>] with pickup = true and a GitHub origin");
1498
+ }
1393
1499
  }
1394
1500
  }
1395
1501
  const ln = p.linear;
1396
1502
  if (ln) {
1397
1503
  const token = resolveTokenSource({ tokenEnv: "LINEAR_TOKEN", ...ln }, "pickup.linear");
1398
1504
  token();
1505
+ const assignField = ln.assignField ? String(ln.assignField) : "assignee";
1506
+ if (assignField !== "assignee" && assignField !== "delegate") {
1507
+ throw new Error(`pickup.linear.assignField must be "assignee" or "delegate", got "${assignField}"`);
1508
+ }
1399
1509
  out.linear = {
1400
1510
  token,
1511
+ assignField,
1401
1512
  startState: String(ln.startState ?? "Todo"),
1513
+ startStateTypes: Array.isArray(ln.startStateTypes) ? ln.startStateTypes.map(String) : void 0,
1402
1514
  claimedState: String(ln.claimedState ?? "In Progress"),
1403
1515
  doneState: String(ln.doneState ?? "In Review"),
1404
1516
  route: ln.route ?? {}
@@ -1408,23 +1520,23 @@ function loadPickupConfig() {
1408
1520
  }
1409
1521
 
1410
1522
  // apps/pick/dist/state.js
1411
- import * as fs11 from "node:fs";
1412
- import * as path7 from "node:path";
1523
+ import * as fs13 from "node:fs";
1524
+ import * as path8 from "node:path";
1413
1525
  var PickupState = class {
1414
1526
  file;
1415
1527
  data;
1416
- constructor(dir = path7.join(lobstahHome(), "pickup")) {
1417
- fs11.mkdirSync(dir, { recursive: true });
1418
- this.file = path7.join(dir, "state.json");
1419
- this.data = fs11.existsSync(this.file) ? JSON.parse(fs11.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
1528
+ constructor(dir = path8.join(lobstahHome(), "pickup")) {
1529
+ fs13.mkdirSync(dir, { recursive: true });
1530
+ this.file = path8.join(dir, "state.json");
1531
+ this.data = fs13.existsSync(this.file) ? JSON.parse(fs13.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
1420
1532
  this.data.map ??= {};
1421
1533
  this.data.consumedApprovals ??= {};
1422
1534
  this.data.rebases ??= {};
1423
1535
  }
1424
1536
  save() {
1425
1537
  const tmp = `${this.file}.tmp-${process.pid}`;
1426
- fs11.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1427
- fs11.renameSync(tmp, this.file);
1538
+ fs13.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1539
+ fs13.renameSync(tmp, this.file);
1428
1540
  }
1429
1541
  get(key) {
1430
1542
  return this.data.map[key];
@@ -1624,6 +1736,15 @@ When the change is complete, commit it, push the branch, and open a PR against $
1624
1736
  const pr = await this.api("GET", `/repos/${this.cfg.repo}/pulls/${n}`);
1625
1737
  return pr.state === "open" ? this.toCandidate(pr) : void 0;
1626
1738
  }
1739
+ async disposition(n) {
1740
+ const pr = await this.api("GET", `/repos/${this.cfg.repo}/pulls/${n}`);
1741
+ if (pr.state === "open")
1742
+ return "open";
1743
+ return pr.merged_at ? "merged" : "closed";
1744
+ }
1745
+ forgeRepo() {
1746
+ return this.cfg.repo;
1747
+ }
1627
1748
  async updateBranch(n) {
1628
1749
  await this.api("PUT", `/repos/${this.cfg.repo}/pulls/${n}/update-branch`, {});
1629
1750
  }
@@ -1669,12 +1790,14 @@ var LinearSource = class {
1669
1790
  }
1670
1791
  return json.data;
1671
1792
  }
1672
- async assigned(stateName) {
1673
- const data = await this.gql(`query($state: String!) {
1674
- issues(filter: { assignee: { isMe: { eq: true } }, state: { name: { eq: $state } } }, first: 50) {
1675
- nodes { id identifier title description state { name } team { key id } }
1793
+ async assigned(stateName, byTypes) {
1794
+ const field = this.cfg.assignField ?? "assignee";
1795
+ const stateFilter = byTypes?.length ? { type: { in: byTypes } } : { name: { eq: stateName } };
1796
+ const data = await this.gql(`query($filter: IssueFilter!) {
1797
+ issues(filter: $filter, first: 50) {
1798
+ nodes { id identifier title description state { name type } team { key id } }
1676
1799
  }
1677
- }`, { state: stateName });
1800
+ }`, { filter: { [field]: { isMe: { eq: true } }, state: stateFilter } });
1678
1801
  return data.issues.nodes;
1679
1802
  }
1680
1803
  async stateId(teamId, name) {
@@ -1692,7 +1815,7 @@ var LinearSource = class {
1692
1815
  }
1693
1816
  async issueByKey(key) {
1694
1817
  const identifier = key.replace(/^linear:/, "");
1695
- const data = await this.gql(`query($id: String!) { issue(id: $id) { id identifier title description state { name } team { key id } } }`, { id: identifier });
1818
+ const data = await this.gql(`query($id: String!) { issue(id: $id) { id identifier title description state { name type } team { key id } } }`, { id: identifier });
1696
1819
  return data.issue;
1697
1820
  }
1698
1821
  async moveTo(issue, stateName) {
@@ -1703,7 +1826,7 @@ var LinearSource = class {
1703
1826
  await this.gql(`mutation($id: String!, $body: String!) { commentCreate(input: { issueId: $id, body: $body }) { success } }`, { id: issue.id, body });
1704
1827
  }
1705
1828
  async poll() {
1706
- const issues = await this.assigned(this.cfg.startState);
1829
+ const issues = await this.assigned(this.cfg.startState, this.cfg.startStateTypes);
1707
1830
  return issues.flatMap((issue) => {
1708
1831
  const repoKey = this.cfg.route[issue.team.key];
1709
1832
  if (!repoKey)
@@ -1725,7 +1848,8 @@ When the change is complete, commit it, push the branch, and open a PR whose des
1725
1848
  }
1726
1849
  async claim(item) {
1727
1850
  const issue = await this.issueByKey(item.key);
1728
- if (issue.state.name !== this.cfg.startState)
1851
+ const stillWaiting = this.cfg.startStateTypes?.length ? this.cfg.startStateTypes.includes(issue.state.type) : issue.state.name === this.cfg.startState;
1852
+ if (!stillWaiting)
1729
1853
  return false;
1730
1854
  await this.moveTo(issue, this.cfg.claimedState);
1731
1855
  return true;
@@ -1796,12 +1920,12 @@ async function dispatchLoop(source, state, log = () => {
1796
1920
  }
1797
1921
 
1798
1922
  // apps/pick/dist/loops/report.js
1799
- import * as fs12 from "node:fs";
1800
- import * as path8 from "node:path";
1923
+ import * as fs14 from "node:fs";
1924
+ import * as path9 from "node:path";
1801
1925
  function dispatchLane(uuid) {
1802
1926
  for (const lane of ["work", "chore"]) {
1803
1927
  const d = laneDirs(lane);
1804
- if (fs12.existsSync(path8.join(d.active, uuid)) || fs12.existsSync(path8.join(d.queue, `${uuid}.json`)) || fs12.existsSync(path8.join(d.done, uuid)) || fs12.existsSync(path8.join(d.state, `${uuid}.status`))) {
1928
+ if (fs14.existsSync(path9.join(d.active, uuid)) || fs14.existsSync(path9.join(d.queue, `${uuid}.json`)) || fs14.existsSync(path9.join(d.done, uuid)) || fs14.existsSync(path9.join(d.state, `${uuid}.status`))) {
1805
1929
  return lane;
1806
1930
  }
1807
1931
  }
@@ -1842,12 +1966,12 @@ async function reportLoop(source, state, log = () => {
1842
1966
  }
1843
1967
 
1844
1968
  // apps/pick/dist/loops/reconcile.js
1845
- import * as fs13 from "node:fs";
1846
- import * as path9 from "node:path";
1969
+ import * as fs15 from "node:fs";
1970
+ import * as path10 from "node:path";
1847
1971
  function dispatchActive(uuid) {
1848
1972
  for (const lane of ["work", "chore"]) {
1849
1973
  const d = laneDirs(lane);
1850
- if (fs13.existsSync(path9.join(d.active, uuid)) || fs13.existsSync(path9.join(d.queue, `${uuid}.json`))) {
1974
+ if (fs15.existsSync(path10.join(d.active, uuid)) || fs15.existsSync(path10.join(d.queue, `${uuid}.json`))) {
1851
1975
  return lane;
1852
1976
  }
1853
1977
  }
@@ -1887,8 +2011,57 @@ async function reconcileLoop(source, state, log = () => {
1887
2011
 
1888
2012
  // apps/pick/dist/loops/merge.js
1889
2013
  import { randomUUID as randomUUID2 } from "node:crypto";
1890
- import * as fs14 from "node:fs";
1891
- import * as path10 from "node:path";
2014
+ import * as fs17 from "node:fs";
2015
+ import * as path12 from "node:path";
2016
+
2017
+ // apps/pick/dist/merge-view.js
2018
+ import * as fs16 from "node:fs";
2019
+ import * as path11 from "node:path";
2020
+ function viewPath() {
2021
+ return path11.join(lobstahHome(), "pickup", "merge-view.json");
2022
+ }
2023
+ function readMergeView() {
2024
+ try {
2025
+ return JSON.parse(fs16.readFileSync(viewPath(), "utf8"));
2026
+ } catch {
2027
+ return void 0;
2028
+ }
2029
+ }
2030
+ function writeMergeView(view) {
2031
+ const file = viewPath();
2032
+ fs16.mkdirSync(path11.dirname(file), { recursive: true });
2033
+ const tmp = `${file}.tmp-${process.pid}`;
2034
+ fs16.writeFileSync(tmp, JSON.stringify(view, null, 2));
2035
+ fs16.renameSync(tmp, file);
2036
+ }
2037
+ var RECENT_RETENTION_MS = 48 * 36e5;
2038
+ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
2039
+ const prev = readMergeView();
2040
+ const recent = (prev?.recent ?? []).filter((r) => now.getTime() - Date.parse(r.at) < RECENT_RETENTION_MS);
2041
+ const stillOpen = new Set(open.map((p) => p.number));
2042
+ for (const was of prev?.open ?? []) {
2043
+ if (stillOpen.has(was.number))
2044
+ continue;
2045
+ try {
2046
+ const d = await disposition(was.number);
2047
+ if (d === "open")
2048
+ continue;
2049
+ recent.push({ number: was.number, url: was.url, disposition: d, at: now.toISOString() });
2050
+ } catch {
2051
+ }
2052
+ }
2053
+ writeMergeView({ updatedAt: now.toISOString(), repo, open, recent });
2054
+ }
2055
+ function readPickupMap() {
2056
+ try {
2057
+ const raw = JSON.parse(fs16.readFileSync(path11.join(lobstahHome(), "pickup", "state.json"), "utf8"));
2058
+ return raw.map ?? {};
2059
+ } catch {
2060
+ return {};
2061
+ }
2062
+ }
2063
+
2064
+ // apps/pick/dist/loops/merge.js
1892
2065
  function qualifyingSet(policy, pr) {
1893
2066
  const restricted = pr.labels.some((l) => policy.restrictedLabels.includes(l));
1894
2067
  if (restricted || !policy.assigneeApproves)
@@ -1915,7 +2088,7 @@ function rebaseBrief(pr) {
1915
2088
  }
1916
2089
  function choreVerdict(uuid) {
1917
2090
  const d = laneDirs("chore");
1918
- if (fs14.existsSync(path10.join(d.active, uuid)) || fs14.existsSync(path10.join(d.queue, `${uuid}.json`)))
2091
+ if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`)))
1919
2092
  return "active";
1920
2093
  const last = readStatusLog(uuid, "chore").at(-1)?.verb;
1921
2094
  return last === "done" ? "done" : "failed";
@@ -1924,54 +2097,78 @@ async function mergeLoop(ms, policy, state, log = () => {
1924
2097
  }) {
1925
2098
  if (!policy.enabled)
1926
2099
  return;
2100
+ const observed = [];
2101
+ const observe = (pr, gate) => {
2102
+ const m = /^lobstah\/([0-9a-f-]{36})$/.exec(pr.headRef);
2103
+ observed.push({
2104
+ number: pr.number,
2105
+ url: pr.url,
2106
+ headRef: pr.headRef,
2107
+ headSha: pr.headSha,
2108
+ mergeableState: pr.mergeableState,
2109
+ gate,
2110
+ uuid: m?.[1]
2111
+ });
2112
+ };
1927
2113
  for (const candidate of await ms.mergeCandidates()) {
1928
2114
  const prKey = `${ms.name}#${candidate.number}`;
1929
2115
  const rebase = state.rebase(prKey);
1930
2116
  if (rebase) {
1931
- if (rebase.failed)
2117
+ if (rebase.failed) {
2118
+ observe(candidate, "rebase-failed");
1932
2119
  continue;
2120
+ }
1933
2121
  const verdict = choreVerdict(rebase.uuid);
1934
- if (verdict === "active")
2122
+ if (verdict === "active") {
2123
+ observe(candidate, `conflict-chore:${rebase.uuid}`);
1935
2124
  continue;
2125
+ }
1936
2126
  if (verdict === "failed") {
1937
2127
  await ms.comment(candidate.number, `Automated rebase failed (dispatch ${rebase.uuid}) \u2014 needs a human.`);
1938
2128
  await ms.addLabel(candidate.number, "needs-human");
1939
2129
  state.failRebase(prKey);
1940
2130
  log(`${prKey}: rebase chore failed, flagged for a human`);
2131
+ observe(candidate, "rebase-failed");
1941
2132
  continue;
1942
2133
  }
1943
2134
  state.clearRebase(prKey);
1944
2135
  }
1945
2136
  const provisional = qualifiedApproval(policy, candidate);
1946
- if (!provisional)
1947
- continue;
1948
- if (state.approvalConsumed(approvalDedupKey(candidate, provisional)))
2137
+ if (!provisional || state.approvalConsumed(approvalDedupKey(candidate, provisional))) {
2138
+ observe(candidate, "waiting-approval");
1949
2139
  continue;
2140
+ }
1950
2141
  const pr = await ms.refresh(candidate.number);
1951
2142
  if (!pr)
1952
2143
  continue;
1953
2144
  const approval = qualifiedApproval(policy, pr);
1954
2145
  if (!approval) {
1955
2146
  log(`${prKey}: gate no longer holds on fresh fetch \u2014 skipping`);
2147
+ observe(pr, "waiting-approval");
1956
2148
  continue;
1957
2149
  }
1958
2150
  const dedupKey = approvalDedupKey(pr, approval);
1959
- if (state.approvalConsumed(dedupKey))
2151
+ if (state.approvalConsumed(dedupKey)) {
2152
+ observe(pr, "waiting-approval");
1960
2153
  continue;
2154
+ }
1961
2155
  switch (pr.mergeableState) {
1962
2156
  case "blocked":
1963
2157
  case "draft":
1964
2158
  log(`${prKey}: ${pr.mergeableState} \u2014 not merging`);
2159
+ observe(pr, pr.mergeableState);
1965
2160
  break;
1966
2161
  case "behind":
1967
2162
  await ms.updateBranch(pr.number);
1968
2163
  log(`${prKey}: behind base, updated branch \u2014 gate re-enters next tick`);
2164
+ observe(pr, "behind-updated");
1969
2165
  break;
1970
2166
  case "dirty": {
1971
2167
  const uuid = randomUUID2();
1972
2168
  enqueue({ id: uuid, repo: ms.repoKey(), brief: rebaseBrief(pr) }, "chore");
1973
2169
  state.setRebase(prKey, uuid);
1974
2170
  log(`${prKey}: real conflict \u2014 wrote rebase chore ${uuid}`);
2171
+ observe(pr, `conflict-chore:${uuid}`);
1975
2172
  break;
1976
2173
  }
1977
2174
  default: {
@@ -1981,6 +2178,7 @@ async function mergeLoop(ms, policy, state, log = () => {
1981
2178
  }
1982
2179
  }
1983
2180
  }
2181
+ await recordMergeView(ms.forgeRepo(), observed, (n) => ms.disposition(n));
1984
2182
  }
1985
2183
 
1986
2184
  // apps/pick/dist/run.js
@@ -2031,10 +2229,10 @@ async function runPickup(mode = "daemon") {
2031
2229
  const log = (m) => console.log(`[pickup] ${m}`);
2032
2230
  const sources = [];
2033
2231
  const merges = [];
2034
- if (cfg.github) {
2035
- const gh = new GithubSource(cfg.github);
2232
+ for (const ghCfg of cfg.github) {
2233
+ const gh = new GithubSource(ghCfg);
2036
2234
  sources.push(gh);
2037
- merges.push({ source: gh, policy: cfg.github.merge });
2235
+ merges.push({ source: gh, policy: ghCfg.merge });
2038
2236
  }
2039
2237
  if (cfg.linear)
2040
2238
  sources.push(new LinearSource(cfg.linear));
@@ -2066,14 +2264,179 @@ function mergeHaulHook(existing) {
2066
2264
  return { settings, changed: true };
2067
2265
  }
2068
2266
 
2267
+ // apps/cli/src/tend.ts
2268
+ import * as fs18 from "node:fs";
2269
+ import * as path13 from "node:path";
2270
+ var HEARTBEAT_STALE_MS = 9e4;
2271
+ var CLAIM_STALE_MS = 12e4;
2272
+ var DAY_MS = 24 * 36e5;
2273
+ function readJson(file) {
2274
+ try {
2275
+ return JSON.parse(fs18.readFileSync(file, "utf8"));
2276
+ } catch {
2277
+ return void 0;
2278
+ }
2279
+ }
2280
+ function doneIds(lane) {
2281
+ try {
2282
+ return fs18.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2283
+ } catch {
2284
+ return [];
2285
+ }
2286
+ }
2287
+ function describeDispatch(id, lane, bucket) {
2288
+ const log = readStatusLog(id, lane);
2289
+ const last = log.at(-1);
2290
+ const state = bucket === "queued" ? "queued" : reconcile({ log, lastEventAt: lastEventAt(id, lane) });
2291
+ const evidence = readEvidence(id, lane);
2292
+ return { id, lane, bucket, state, note: last?.note, at: last?.at, prUrl: evidence.prUrl };
2293
+ }
2294
+ function followUps(uuid) {
2295
+ const out = [];
2296
+ const d = laneDirs("work");
2297
+ const scan = (id, bucket, file) => {
2298
+ const desc = readJson(file);
2299
+ if (desc?.followUp === uuid) out.push({ id, bucket });
2300
+ };
2301
+ for (const id of pendingIds("work")) scan(id, "queued", path13.join(d.queue, `${id}.json`));
2302
+ for (const id of activeIds("work")) scan(id, "active", path13.join(d.active, id, "descriptor.json"));
2303
+ for (const id of doneIds("work")) scan(id, "done", path13.join(d.done, id, "descriptor.json"));
2304
+ return out;
2305
+ }
2306
+ function bucketOf(uuid) {
2307
+ const d = laneDirs("work");
2308
+ if (fs18.existsSync(path13.join(d.active, uuid))) return "active";
2309
+ if (fs18.existsSync(path13.join(d.done, uuid))) return "done";
2310
+ if (fs18.existsSync(path13.join(d.queue, `${uuid}.json`))) return "queued";
2311
+ return void 0;
2312
+ }
2313
+ function buildTendReport(now = Date.now()) {
2314
+ const cfg = loadConfig();
2315
+ const heartbeat = readJson(executorPath())?.heartbeat;
2316
+ const daemonUp = heartbeat !== void 0 && now - Date.parse(heartbeat) < HEARTBEAT_STALE_MS;
2317
+ const queued = pendingIds("work");
2318
+ const active = activeIds("work");
2319
+ const choresActive = activeIds("chore").length + pendingIds("chore").length;
2320
+ let done24h = 0;
2321
+ let failed24h = 0;
2322
+ for (const lane of ["work", "chore"]) {
2323
+ for (const id of doneIds(lane)) {
2324
+ const last = readStatusLog(id, lane).at(-1);
2325
+ if (!last || now - Date.parse(last.at) > DAY_MS) continue;
2326
+ if (last.verb === "failed") failed24h += 1;
2327
+ else if (last.verb === "done") done24h += 1;
2328
+ }
2329
+ }
2330
+ const remindMs = (cfg.remindSecs ?? 900) * 1e3;
2331
+ const attention = attentionNow(false, remindMs, now).map((ev) => ({
2332
+ id: ev.id,
2333
+ lane: ev.lane,
2334
+ verb: ev.entry.verb,
2335
+ ageSecs: Math.max(0, Math.round((now - Date.parse(ev.entry.at)) / 1e3)),
2336
+ note: ev.entry.note
2337
+ }));
2338
+ const oldestQueuedAge = queued.reduce((max, id) => {
2339
+ const st = fs18.statSync(path13.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2340
+ return st ? Math.max(max, now - st.mtimeMs) : max;
2341
+ }, 0);
2342
+ const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
2343
+ const merge = readMergeView();
2344
+ const gateFor = (uuid, prUrl) => {
2345
+ const open = merge?.open.find((p) => p.uuid === uuid || prUrl !== void 0 && p.url === prUrl);
2346
+ if (open) return open.gate;
2347
+ const recent = merge?.recent.find((r) => prUrl !== void 0 && r.url === prUrl);
2348
+ return recent?.disposition;
2349
+ };
2350
+ const storied = /* @__PURE__ */ new Set();
2351
+ const stories = [];
2352
+ for (const [key, entry] of Object.entries(readPickupMap())) {
2353
+ const bucket = bucketOf(entry.uuid);
2354
+ if (!bucket) continue;
2355
+ const chain = [describeDispatch(entry.uuid, "work", bucket)];
2356
+ for (const f of followUps(entry.uuid)) chain.push(describeDispatch(f.id, "work", f.bucket));
2357
+ for (const d of chain) storied.add(d.id);
2358
+ const fresh = chain.some((d) => d.bucket !== "done" || d.at !== void 0 && now - Date.parse(d.at) < DAY_MS);
2359
+ if (!fresh) continue;
2360
+ const prUrl = chain.map((d) => d.prUrl).find((u) => u !== void 0);
2361
+ stories.push({ key, dispatches: chain, prUrl, gate: gateFor(entry.uuid, prUrl) });
2362
+ }
2363
+ for (const id of [...active, ...queued]) {
2364
+ if (storied.has(id)) continue;
2365
+ const d = describeDispatch(id, "work", bucketOf(id) ?? "active");
2366
+ stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl) });
2367
+ }
2368
+ const verdict = !daemonUp ? "daemon-down" : stalled ? "stalled" : attention.length > 0 ? "needs-attention" : active.length + queued.length > 0 ? "working" : "idle";
2369
+ return {
2370
+ verdict,
2371
+ daemon: { up: daemonUp, lastHeartbeat: heartbeat },
2372
+ counts: { queued: queued.length, active: active.length, choresActive, done24h, failed24h },
2373
+ attention,
2374
+ stories,
2375
+ merge
2376
+ };
2377
+ }
2378
+ function renderTend(r) {
2379
+ const lines = [];
2380
+ lines.push(
2381
+ toonKV({
2382
+ verdict: r.verdict,
2383
+ daemon: r.daemon.up ? "up" : `down (last heartbeat ${r.daemon.lastHeartbeat ?? "never"})`,
2384
+ queued: r.counts.queued,
2385
+ active: r.counts.active,
2386
+ chores: r.counts.choresActive,
2387
+ done24h: r.counts.done24h,
2388
+ failed24h: r.counts.failed24h
2389
+ })
2390
+ );
2391
+ if (r.attention.length > 0) {
2392
+ lines.push("");
2393
+ lines.push(
2394
+ toonTable(
2395
+ "attention",
2396
+ r.attention.map((a) => ({ id: a.id, verb: a.verb, waitingMins: Math.round(a.ageSecs / 60), note: a.note ?? "" })),
2397
+ ["id", "verb", "waitingMins", "note"]
2398
+ )
2399
+ );
2400
+ }
2401
+ if (r.stories.length > 0) {
2402
+ lines.push("");
2403
+ lines.push(
2404
+ toonTable(
2405
+ "work",
2406
+ r.stories.map((s) => ({
2407
+ key: s.key,
2408
+ dispatches: s.dispatches.map((d) => `${d.id.slice(0, 8)}:${d.state}`).join(" \u2192 "),
2409
+ pr: s.prUrl ?? "",
2410
+ gate: s.gate ?? ""
2411
+ })),
2412
+ ["key", "dispatches", "pr", "gate"]
2413
+ )
2414
+ );
2415
+ }
2416
+ if (r.merge && (r.merge.open.length > 0 || r.merge.recent.length > 0)) {
2417
+ lines.push("");
2418
+ lines.push(
2419
+ toonTable(
2420
+ `prs (${r.merge.repo}, observed ${r.merge.updatedAt})`,
2421
+ [
2422
+ ...r.merge.open.map((p) => ({ pr: `#${p.number}`, state: p.mergeableState, gate: p.gate, url: p.url })),
2423
+ ...r.merge.recent.map((p) => ({ pr: `#${p.number}`, state: p.disposition, gate: "", url: p.url }))
2424
+ ],
2425
+ ["pr", "state", "gate", "url"]
2426
+ )
2427
+ );
2428
+ }
2429
+ return lines.join("\n");
2430
+ }
2431
+
2069
2432
  // apps/cli/src/cull.ts
2070
- import * as fs15 from "node:fs";
2071
- import * as path11 from "node:path";
2433
+ import * as fs19 from "node:fs";
2434
+ import * as path14 from "node:path";
2072
2435
  import { execFileSync as execFileSync2 } from "node:child_process";
2073
2436
  var DAY = 864e5;
2074
2437
  function idsIn(dir) {
2075
2438
  try {
2076
- return new Set(fs15.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
2439
+ return new Set(fs19.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
2077
2440
  } catch {
2078
2441
  return /* @__PURE__ */ new Set();
2079
2442
  }
@@ -2088,39 +2451,39 @@ function planCull(olderThanDays, now = Date.now()) {
2088
2451
  for (const id of idsIn(d.queue)) live.add(id);
2089
2452
  for (const id of idsIn(d.active)) live.add(id);
2090
2453
  for (const id of idsIn(d.done)) {
2091
- const p = path11.join(d.done, id);
2092
- const m = fs15.statSync(p).mtimeMs;
2454
+ const p = path14.join(d.done, id);
2455
+ const m = fs19.statSync(p).mtimeMs;
2093
2456
  doneMtimes.set(id, m);
2094
2457
  if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
2095
2458
  }
2096
2459
  }
2097
- const wtRoot = path11.join(lobstahHome(), "worktrees");
2460
+ const wtRoot = path14.join(lobstahHome(), "worktrees");
2098
2461
  for (const id of idsIn(wtRoot)) {
2099
2462
  if (live.has(id)) continue;
2100
2463
  const doneAt = doneMtimes.get(id);
2101
2464
  if (doneAt !== void 0 && doneAt >= cutoff) continue;
2102
- const p = path11.join(wtRoot, id);
2103
- items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs15.statSync(p).mtimeMs) / DAY) });
2465
+ const p = path14.join(wtRoot, id);
2466
+ items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs19.statSync(p).mtimeMs) / DAY) });
2104
2467
  }
2105
2468
  for (const lane of ["work", "chore"]) {
2106
2469
  const d = laneDirs(lane);
2107
2470
  const seen = /* @__PURE__ */ new Set();
2108
- for (const f of fs15.readdirSync(d.state)) {
2471
+ for (const f of fs19.readdirSync(d.state)) {
2109
2472
  const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
2110
2473
  if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
2111
- const m = fs15.statSync(path11.join(d.state, f)).mtimeMs;
2474
+ const m = fs19.statSync(path14.join(d.state, f)).mtimeMs;
2112
2475
  if (m >= cutoff) continue;
2113
2476
  seen.add(id);
2114
- items.push({ kind: "state", id, target: path11.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
2477
+ items.push({ kind: "state", id, target: path14.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
2115
2478
  }
2116
2479
  }
2117
2480
  return items;
2118
2481
  }
2119
2482
  function removeWorktree(id, dir) {
2120
2483
  for (const lane of ["work", "chore"]) {
2121
- const descFile = path11.join(laneDirs(lane).done, id, "descriptor.json");
2484
+ const descFile = path14.join(laneDirs(lane).done, id, "descriptor.json");
2122
2485
  try {
2123
- const desc = JSON.parse(fs15.readFileSync(descFile, "utf8"));
2486
+ const desc = JSON.parse(fs19.readFileSync(descFile, "utf8"));
2124
2487
  const repo = loadConfig().repos[desc.repo];
2125
2488
  if (repo) {
2126
2489
  execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
@@ -2129,15 +2492,15 @@ function removeWorktree(id, dir) {
2129
2492
  } catch {
2130
2493
  }
2131
2494
  }
2132
- fs15.rmSync(dir, { recursive: true, force: true });
2495
+ fs19.rmSync(dir, { recursive: true, force: true });
2133
2496
  }
2134
2497
  function applyCull(items) {
2135
2498
  for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
2136
- for (const item of items.filter((i) => i.kind === "done")) fs15.rmSync(item.target, { recursive: true, force: true });
2499
+ for (const item of items.filter((i) => i.kind === "done")) fs19.rmSync(item.target, { recursive: true, force: true });
2137
2500
  for (const item of items.filter((i) => i.kind === "state")) {
2138
- const dir = path11.dirname(item.target);
2139
- for (const f of fs15.readdirSync(dir)) {
2140
- if (f.startsWith(`${item.id}.`)) fs15.rmSync(path11.join(dir, f), { force: true });
2501
+ const dir = path14.dirname(item.target);
2502
+ for (const f of fs19.readdirSync(dir)) {
2503
+ if (f.startsWith(`${item.id}.`)) fs19.rmSync(path14.join(dir, f), { force: true });
2141
2504
  }
2142
2505
  }
2143
2506
  }
@@ -2160,6 +2523,9 @@ your working set:
2160
2523
  lobstah catch <id> land the catch (evidence)
2161
2524
  lobstah cancel <id> cut one away
2162
2525
  lobstah cull [--apply] sweep aged catch and lost gear
2526
+ lobstah man tend [--json] tend the string: fleet verdict,
2527
+ unanswered questions, each work
2528
+ item's chain + PR + merge gate
2163
2529
 
2164
2530
  getting woken instead of asking:
2165
2531
  lobstah man wait block until a dispatch needs attention (arm it as a
@@ -2178,8 +2544,270 @@ paste into your liaison instructions:
2178
2544
 
2179
2545
  full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
2180
2546
 
2547
+ // apps/cli/src/doctor.ts
2548
+ import * as fs20 from "node:fs";
2549
+ import { createRequire as createRequire3 } from "node:module";
2550
+ import { spawnSync } from "node:child_process";
2551
+ var HEARTBEAT_STALE_MS2 = 9e4;
2552
+ function git(repoPath, ...args) {
2553
+ const res = spawnSync("git", ["-C", repoPath, ...args], { encoding: "utf8" });
2554
+ return { ok: res.status === 0, out: (res.stdout ?? "").trim() || (res.stderr ?? "").trim() };
2555
+ }
2556
+ function runDoctor(now = Date.now()) {
2557
+ const rows = [];
2558
+ const push = (check, status, detail) => rows.push({ check, status, detail });
2559
+ const major = Number(process.versions.node.split(".")[0]);
2560
+ push("node", major >= 20 ? "ok" : "fail", `v${process.versions.node}${major >= 20 ? "" : " \u2014 lobstah needs >=20"}`);
2561
+ push("git", onPath("git") ? "ok" : "fail", onPath("git") ? "on PATH" : "not on PATH");
2562
+ const claude = onPath("claude");
2563
+ push("harness claude", claude ? "ok" : "warn", claude ? "on PATH" : "claude not on PATH \u2014 claude dispatches will fail");
2564
+ const codexPath = onPath("codex");
2565
+ let codexSdk = false;
2566
+ try {
2567
+ createRequire3(import.meta.url).resolve("@openai/codex-sdk");
2568
+ codexSdk = true;
2569
+ } catch {
2570
+ }
2571
+ push(
2572
+ "harness codex",
2573
+ codexPath || codexSdk ? "ok" : "warn",
2574
+ codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
2575
+ );
2576
+ const cfgFile = configPath();
2577
+ if (!fs20.existsSync(cfgFile)) {
2578
+ push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
2579
+ return rows;
2580
+ }
2581
+ let parsed;
2582
+ try {
2583
+ parsed = parse(fs20.readFileSync(cfgFile, "utf8"));
2584
+ push("config", "ok", cfgFile);
2585
+ } catch (err) {
2586
+ push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
2587
+ return rows;
2588
+ }
2589
+ const cfg = loadConfig();
2590
+ const repoKeys = Object.keys(cfg.repos);
2591
+ if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
2592
+ for (const [key, repo] of Object.entries(cfg.repos)) {
2593
+ const label = `repo ${key}`;
2594
+ if (!fs20.existsSync(repo.path)) {
2595
+ push(label, "fail", `path ${repo.path} does not exist`);
2596
+ continue;
2597
+ }
2598
+ if (!git(repo.path, "rev-parse", "--is-inside-work-tree").ok) {
2599
+ push(label, "fail", `${repo.path} is not a git repository`);
2600
+ continue;
2601
+ }
2602
+ const trunk = git(repo.path, "rev-parse", "--verify", "--quiet", `origin/${repo.trunk}`);
2603
+ push(
2604
+ label,
2605
+ trunk.ok ? "ok" : "warn",
2606
+ trunk.ok ? `${repo.path} (trunk origin/${repo.trunk})` : `origin/${repo.trunk} not found \u2014 check trunk or \`git fetch\``
2607
+ );
2608
+ }
2609
+ if (parsed.pickup) {
2610
+ try {
2611
+ const pk = loadPickupConfig();
2612
+ const sources = [
2613
+ ...pk.github.map((g) => `gh:${g.repo}\u2192${g.key}`),
2614
+ ...pk.linear ? ["linear"] : []
2615
+ ];
2616
+ push("pickup", sources.length > 0 ? "ok" : "warn", sources.join(", ") || "section present but no sources");
2617
+ } catch (err) {
2618
+ push("pickup", "fail", err instanceof Error ? err.message : String(err));
2619
+ }
2620
+ }
2621
+ const hb = executorPath();
2622
+ if (!fs20.existsSync(hb)) {
2623
+ push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
2624
+ } else {
2625
+ try {
2626
+ const payload = JSON.parse(fs20.readFileSync(hb, "utf8"));
2627
+ const age = now - new Date(payload.heartbeat ?? 0).getTime();
2628
+ push(
2629
+ "daemon",
2630
+ age < HEARTBEAT_STALE_MS2 ? "ok" : "warn",
2631
+ age < HEARTBEAT_STALE_MS2 ? `heartbeat ${Math.round(age / 1e3)}s ago (v${payload.version ?? "?"})` : `heartbeat stale (${Math.round(age / 1e3)}s) \u2014 daemon down or wedged`
2632
+ );
2633
+ } catch {
2634
+ push("daemon", "warn", "heartbeat unreadable");
2635
+ }
2636
+ }
2637
+ push("lobstah", "ok", `v${lobstahVersion()} at ${process.argv[1] ?? "?"}`);
2638
+ return rows;
2639
+ }
2640
+
2641
+ // apps/cli/src/service.ts
2642
+ import * as fs21 from "node:fs";
2643
+ import * as os5 from "node:os";
2644
+ import * as path15 from "node:path";
2645
+ import { spawnSync as spawnSync2 } from "node:child_process";
2646
+ function xml(s) {
2647
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2648
+ }
2649
+ function renderLaunchdPlist(spec) {
2650
+ return `<?xml version="1.0" encoding="UTF-8"?>
2651
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2652
+ <plist version="1.0">
2653
+ <dict>
2654
+ <key>Label</key><string>lobstah.${spec.kind}</string>
2655
+ <key>ProgramArguments</key>
2656
+ <array>
2657
+ <string>${xml(spec.nodePath)}</string>
2658
+ <string>${xml(spec.entry)}</string>
2659
+ <string>${spec.kind}</string>
2660
+ </array>
2661
+ <key>EnvironmentVariables</key>
2662
+ <dict>
2663
+ <key>PATH</key><string>${xml(spec.pathEnv)}</string>
2664
+ <key>LOBSTAH_HOME</key><string>${xml(spec.home)}</string>
2665
+ </dict>
2666
+ <key>RunAtLoad</key><true/>
2667
+ <key>KeepAlive</key><true/>
2668
+ <key>StandardOutPath</key><string>${xml(path15.join(spec.logDir, `${spec.kind}.log`))}</string>
2669
+ <key>StandardErrorPath</key><string>${xml(path15.join(spec.logDir, `${spec.kind}.err`))}</string>
2670
+ </dict>
2671
+ </plist>
2672
+ `;
2673
+ }
2674
+ function renderSystemdUnit(spec) {
2675
+ return `[Unit]
2676
+ Description=lobstah ${spec.kind}
2677
+
2678
+ [Service]
2679
+ ExecStart=${spec.nodePath} ${spec.entry} ${spec.kind}
2680
+ Restart=always
2681
+ RestartSec=5
2682
+ Environment=PATH=${spec.pathEnv}
2683
+ Environment=LOBSTAH_HOME=${spec.home}
2684
+ StandardOutput=append:${path15.join(spec.logDir, `${spec.kind}.log`)}
2685
+ StandardError=append:${path15.join(spec.logDir, `${spec.kind}.err`)}
2686
+
2687
+ [Install]
2688
+ WantedBy=default.target
2689
+ `;
2690
+ }
2691
+ function servicePathEnv(nodePath, home = os5.homedir()) {
2692
+ const dirs = [
2693
+ path15.dirname(nodePath),
2694
+ path15.join(home, ".local", "bin"),
2695
+ "/opt/homebrew/bin",
2696
+ "/usr/local/bin",
2697
+ "/usr/bin",
2698
+ "/bin"
2699
+ ];
2700
+ return [...new Set(dirs)].join(path15.delimiter);
2701
+ }
2702
+ function serviceFile(kind) {
2703
+ return process.platform === "darwin" ? path15.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path15.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
2704
+ }
2705
+ function run(cmd, args) {
2706
+ const res = spawnSync2(cmd, args, { encoding: "utf8" });
2707
+ return { ok: res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
2708
+ }
2709
+ function installService(kind) {
2710
+ const entry = fs21.realpathSync(process.argv[1]);
2711
+ const spec = {
2712
+ kind,
2713
+ nodePath: process.execPath,
2714
+ entry,
2715
+ pathEnv: servicePathEnv(process.execPath),
2716
+ home: lobstahHome(),
2717
+ logDir: path15.join(lobstahHome(), "logs")
2718
+ };
2719
+ if (!onPath("git", spec.pathEnv)) {
2720
+ throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
2721
+ }
2722
+ fs21.mkdirSync(spec.logDir, { recursive: true });
2723
+ const file = serviceFile(kind);
2724
+ fs21.mkdirSync(path15.dirname(file), { recursive: true });
2725
+ if (process.platform === "darwin") {
2726
+ if (fs21.existsSync(file)) run("launchctl", ["unload", file]);
2727
+ fs21.writeFileSync(file, renderLaunchdPlist(spec));
2728
+ const load = run("launchctl", ["load", file]);
2729
+ return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
2730
+ }
2731
+ fs21.writeFileSync(file, renderSystemdUnit(spec));
2732
+ run("systemctl", ["--user", "daemon-reload"]);
2733
+ const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
2734
+ return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
2735
+ }
2736
+ function uninstallService(kind) {
2737
+ const file = serviceFile(kind);
2738
+ if (process.platform === "darwin") {
2739
+ if (fs21.existsSync(file)) run("launchctl", ["unload", file]);
2740
+ } else {
2741
+ run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
2742
+ }
2743
+ const removed = fs21.existsSync(file);
2744
+ if (removed) fs21.unlinkSync(file);
2745
+ if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
2746
+ return { file, removed };
2747
+ }
2748
+
2749
+ // apps/cli/src/repos.ts
2750
+ import * as fs22 from "node:fs";
2751
+ import * as path16 from "node:path";
2752
+ import { spawnSync as spawnSync3 } from "node:child_process";
2753
+ function git2(dir, ...args) {
2754
+ const res = spawnSync3("git", ["-C", dir, ...args], { encoding: "utf8" });
2755
+ return res.status === 0 ? res.stdout.trim() : void 0;
2756
+ }
2757
+ var LOCKFILE_SETUP = [
2758
+ ["pnpm-lock.yaml", ["pnpm install"]],
2759
+ ["package-lock.json", ["npm install"]],
2760
+ ["yarn.lock", ["yarn install"]],
2761
+ ["bun.lockb", ["bun install"]],
2762
+ ["bun.lock", ["bun install"]],
2763
+ ["uv.lock", ["uv sync"]],
2764
+ ["poetry.lock", ["poetry install"]]
2765
+ ];
2766
+ function detectRepo(dir) {
2767
+ const abs = path16.resolve(dir);
2768
+ if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
2769
+ const top = git2(abs, "rev-parse", "--show-toplevel");
2770
+ if (!top || fs22.realpathSync(top) !== fs22.realpathSync(abs)) return void 0;
2771
+ const origin = git2(abs, "remote", "get-url", "origin");
2772
+ const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
2773
+ const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
2774
+ const setup = LOCKFILE_SETUP.find(([f]) => fs22.existsSync(path16.join(abs, f)))?.[1];
2775
+ return { key: path16.basename(abs), path: abs, trunk, origin, setup };
2776
+ }
2777
+ function scanForRepos(roots) {
2778
+ const found = [];
2779
+ for (const root of roots) {
2780
+ const abs = path16.resolve(root);
2781
+ if (!fs22.existsSync(abs)) continue;
2782
+ const self = detectRepo(abs);
2783
+ if (self) {
2784
+ found.push(self);
2785
+ continue;
2786
+ }
2787
+ for (const entry of fs22.readdirSync(abs, { withFileTypes: true })) {
2788
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
2789
+ const child = detectRepo(path16.join(abs, entry.name));
2790
+ if (child) found.push(child);
2791
+ }
2792
+ }
2793
+ return found;
2794
+ }
2795
+ function configuredRepoKeys(file = configPath()) {
2796
+ if (!fs22.existsSync(file)) return /* @__PURE__ */ new Set();
2797
+ const raw = parse(fs22.readFileSync(file, "utf8"));
2798
+ return new Set(Object.keys(raw.repos ?? {}));
2799
+ }
2800
+ function appendRepoBlock(repo, opts = {}, file = configPath()) {
2801
+ const lines = [``, `[repos.${repo.key}]`, `path = ${JSON.stringify(repo.path)}`, `trunk = ${JSON.stringify(repo.trunk)}`];
2802
+ if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
2803
+ if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
2804
+ if (opts.pickup) lines.push(`pickup = true`);
2805
+ fs22.appendFileSync(file, `${lines.join("\n")}
2806
+ `);
2807
+ }
2808
+
2181
2809
  // apps/cli/src/main.ts
2182
- var HELP = `lobstah \u2014 supervisor for local coding agents
2810
+ var HELP = `lobstah \u2014 supervision framework for coding agents
2183
2811
 
2184
2812
  work (humans and agents):
2185
2813
  dispatch --repo <key> (--brief <file> | --brief-text <text>) (alias: set --bait)
@@ -2206,22 +2834,33 @@ work (humans and agents):
2206
2834
  Dry run by default (14 days).
2207
2835
  cancel <uuid> request cancellation
2208
2836
 
2209
- host processes (run under launchd/systemd/pm2):
2837
+ host processes:
2210
2838
  daemon [--interval <ms>] the supervisor: claims, worktrees, liveness,
2211
2839
  restart ladder, notifyCommand. One per home.
2212
2840
  pick [once] tracker loops: poll Linear/GitHub, dispatch
2213
2841
  assigned work, report back, reconcile, merge
2842
+ daemon install|uninstall write + load the launchd agent / systemd user
2843
+ pick install|uninstall unit for this host, with resolved node and
2844
+ lobstah paths (launchd gets no shell env)
2214
2845
 
2215
2846
  lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual):
2847
+ man tend [--json] tend the whole string: fleet verdict (daemon
2848
+ up, stalled vs idle), unanswered questions
2849
+ with ages, each work item's dispatch chain,
2850
+ PR, and merge-gate status from pick's last
2851
+ observation. Pure disk read \u2014 no forge calls.
2216
2852
  man wait [--timeout <secs>] [--peek]
2217
2853
  block until a dispatch needs attention, then
2218
2854
  print the event and what to do next; exit 2
2219
2855
  on timeout. Unanswered questions re-fire
2220
2856
  every remindSecs until answered.
2221
- man init [--shared] [--marker] install the haul Stop hook into this
2222
- project's .claude/settings.local.json
2223
- (--shared: settings.json); --marker touches
2224
- .lobstah-man. Idempotent.
2857
+ man init [--shared|--global] [--marker]
2858
+ install the haul Stop hook: this project's
2859
+ .claude/settings.local.json by default,
2860
+ settings.json with --shared, or once into
2861
+ ~/.claude/settings.json with --global (any
2862
+ directory with a .lobstah-man file then
2863
+ parks); --marker touches .lobstah-man.
2225
2864
  man haul [--timeout <secs>] Stop-hook entry point: park the session while
2226
2865
  work is in flight; prints hook-decision JSON
2227
2866
  on an event, silent exit 0 otherwise. Gate:
@@ -2233,7 +2872,16 @@ workers (dispatched agents; injected into every brief):
2233
2872
  (${VERBS.join(" | ")})
2234
2873
 
2235
2874
  setup:
2236
- init create ~/.lobstah + example config
2875
+ init [--scan <dir>... [--pickup]]
2876
+ create ~/.lobstah + example config; --scan
2877
+ detects git repos under the given roots and
2878
+ appends a [repos.*] block per repo (--pickup
2879
+ marks them pickable by [pickup.github])
2880
+ repos [add <path> [--pickup] [--key <k>]]
2881
+ list configured repos, or detect + append one
2882
+ doctor check binaries, config, repos, harnesses, and
2883
+ the daemon heartbeat; exit 1 on failures
2884
+ version | --version the installed lobstah version
2237
2885
 
2238
2886
  Everything except daemon and pick works with both stopped: writes are files,
2239
2887
  reads are files. Output is TOON; agents can drive this CLI directly.
@@ -2245,7 +2893,7 @@ function arg(args, flag) {
2245
2893
  function findLane(id) {
2246
2894
  for (const lane of ["work", "chore"]) {
2247
2895
  const d = laneDirs(lane);
2248
- if (fs16.existsSync(path12.join(d.active, id)) || fs16.existsSync(path12.join(d.queue, `${id}.json`)) || fs16.existsSync(path12.join(d.done, id)) || fs16.existsSync(path12.join(d.state, `${id}.status`))) {
2896
+ if (fs23.existsSync(path17.join(d.active, id)) || fs23.existsSync(path17.join(d.queue, `${id}.json`)) || fs23.existsSync(path17.join(d.done, id)) || fs23.existsSync(path17.join(d.state, `${id}.status`))) {
2249
2897
  return lane;
2250
2898
  }
2251
2899
  }
@@ -2253,7 +2901,7 @@ function findLane(id) {
2253
2901
  }
2254
2902
  function rowsFor(lane, bucket) {
2255
2903
  const dir = laneDirs(lane)[bucket];
2256
- const entries = fs16.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs16.statSync(path12.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
2904
+ const entries = fs23.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs23.statSync(path17.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
2257
2905
  const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
2258
2906
  return sliced.map(({ f, m }) => {
2259
2907
  const id = f.replace(/\.json$/, "");
@@ -2281,7 +2929,7 @@ async function mainCli() {
2281
2929
  const d = {
2282
2930
  id: arg(args, "--id") ?? randomUUID3(),
2283
2931
  repo,
2284
- brief: briefText ?? fs16.readFileSync(briefFile, "utf8"),
2932
+ brief: briefText ?? fs23.readFileSync(briefFile, "utf8"),
2285
2933
  harness: arg(args, "--harness"),
2286
2934
  model: arg(args, "--model"),
2287
2935
  effort: arg(args, "--effort"),
@@ -2318,17 +2966,17 @@ async function mainCli() {
2318
2966
  if (!id) throw new Error("logs requires a dispatch id");
2319
2967
  const lane = findLane(id);
2320
2968
  const file = eventsPath(id, lane);
2321
- if (fs16.existsSync(file)) process.stdout.write(fs16.readFileSync(file, "utf8"));
2969
+ if (fs23.existsSync(file)) process.stdout.write(fs23.readFileSync(file, "utf8"));
2322
2970
  if (args.includes("--follow")) {
2323
- let size = fs16.existsSync(file) ? fs16.statSync(file).size : 0;
2971
+ let size = fs23.existsSync(file) ? fs23.statSync(file).size : 0;
2324
2972
  setInterval(() => {
2325
- if (!fs16.existsSync(file)) return;
2326
- const now = fs16.statSync(file).size;
2973
+ if (!fs23.existsSync(file)) return;
2974
+ const now = fs23.statSync(file).size;
2327
2975
  if (now > size) {
2328
- const fd = fs16.openSync(file, "r");
2976
+ const fd = fs23.openSync(file, "r");
2329
2977
  const buf = Buffer.alloc(now - size);
2330
- fs16.readSync(fd, buf, 0, buf.length, size);
2331
- fs16.closeSync(fd);
2978
+ fs23.readSync(fd, buf, 0, buf.length, size);
2979
+ fs23.closeSync(fd);
2332
2980
  process.stdout.write(buf.toString("utf8"));
2333
2981
  size = now;
2334
2982
  }
@@ -2384,22 +3032,23 @@ async function mainCli() {
2384
3032
  );
2385
3033
  }
2386
3034
  const dirs = laneDirs(lane);
2387
- const descriptorFile = ["active", "done"].map((b) => path12.join(dirs[b], id, "descriptor.json")).find((f) => fs16.existsSync(f));
2388
- const harness = descriptorFile ? JSON.parse(fs16.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
3035
+ const descriptorFile = ["active", "done"].map((b) => path17.join(dirs[b], id, "descriptor.json")).find((f) => fs23.existsSync(f));
3036
+ const harness = descriptorFile ? JSON.parse(fs23.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
2389
3037
  let sessionId;
2390
3038
  try {
2391
- sessionId = JSON.parse(fs16.readFileSync(path12.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
3039
+ sessionId = JSON.parse(fs23.readFileSync(path17.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
2392
3040
  } catch {
2393
3041
  }
2394
3042
  if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
2395
- const worktree = path12.join(lobstahHome(), "worktrees", id);
2396
- const cwd = fs16.existsSync(worktree) ? worktree : process.cwd();
2397
- const invocation = harness === "codex" ? { file: "codex", argv: ["resume", sessionId] } : { file: "claude", argv: ["--resume", sessionId] };
3043
+ const worktree = path17.join(lobstahHome(), "worktrees", id);
3044
+ const cwd = fs23.existsSync(worktree) ? worktree : process.cwd();
3045
+ const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
3046
+ if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
2398
3047
  if (args.includes("--print")) {
2399
3048
  console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
2400
3049
  break;
2401
3050
  }
2402
- const res = spawnSync(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
3051
+ const res = spawnSync4(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
2403
3052
  if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
2404
3053
  break;
2405
3054
  }
@@ -2407,39 +3056,39 @@ async function mainCli() {
2407
3056
  const id = args[0];
2408
3057
  if (!id) throw new Error("swap requires a dispatch id");
2409
3058
  const lane = findLane(id);
2410
- const activeDir = path12.join(laneDirs(lane).active, id);
2411
- if (!fs16.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
2412
- const descFile = path12.join(activeDir, "descriptor.json");
2413
- const descriptor = JSON.parse(fs16.readFileSync(descFile, "utf8"));
3059
+ const activeDir = path17.join(laneDirs(lane).active, id);
3060
+ if (!fs23.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
3061
+ const descFile = path17.join(activeDir, "descriptor.json");
3062
+ const descriptor = JSON.parse(fs23.readFileSync(descFile, "utf8"));
2414
3063
  const fromHarness = descriptor.harness ?? "default";
2415
3064
  for (const key of ["harness", "model", "effort"]) {
2416
3065
  const v = arg(args, `--${key}`);
2417
3066
  if (v) descriptor[key] = v;
2418
3067
  }
2419
- fs16.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
2420
- const worktree = path12.join(lobstahHome(), "worktrees", id);
3068
+ fs23.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
3069
+ const worktree = path17.join(lobstahHome(), "worktrees", id);
2421
3070
  let progress = "No worktree progress recorded.";
2422
- if (fs16.existsSync(worktree)) {
2423
- const git = (...a) => spawnSync("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
2424
- const commits = git("log", "--oneline", "-15");
2425
- const status = git("status", "--short");
3071
+ if (fs23.existsSync(worktree)) {
3072
+ const git3 = (...a) => spawnSync4("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
3073
+ const commits = git3("log", "--oneline", "-15");
3074
+ const status = git3("status", "--short");
2426
3075
  progress = `Commits so far:
2427
3076
  ${commits || "(none)"}
2428
3077
 
2429
3078
  Uncommitted changes:
2430
3079
  ${status || "(clean)"}`;
2431
3080
  }
2432
- fs16.writeFileSync(
2433
- path12.join(activeDir, "handoff"),
3081
+ fs23.writeFileSync(
3082
+ path17.join(activeDir, "handoff"),
2434
3083
  `You are taking over this dispatch from a previous agent session (harness: ${fromHarness}). Its conversation is not available \u2014 the worktree state below is the ground truth. Review it, then continue the brief from where it stops.
2435
3084
 
2436
3085
  ${progress}`
2437
3086
  );
2438
- const runnerFile = path12.join(activeDir, "runner.json");
2439
- if (fs16.existsSync(runnerFile)) {
2440
- const runner = JSON.parse(fs16.readFileSync(runnerFile, "utf8"));
3087
+ const runnerFile = path17.join(activeDir, "runner.json");
3088
+ if (fs23.existsSync(runnerFile)) {
3089
+ const runner = JSON.parse(fs23.readFileSync(runnerFile, "utf8"));
2441
3090
  if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
2442
- fs16.unlinkSync(runnerFile);
3091
+ fs23.unlinkSync(runnerFile);
2443
3092
  }
2444
3093
  appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
2445
3094
  console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
@@ -2490,6 +3139,11 @@ ${progress}`
2490
3139
  console.log(MANUAL);
2491
3140
  break;
2492
3141
  }
3142
+ case "man:tend": {
3143
+ const report = buildTendReport();
3144
+ console.log(args.includes("--json") ? JSON.stringify(report, null, 2) : renderTend(report));
3145
+ break;
3146
+ }
2493
3147
  case "cancel": {
2494
3148
  const id = args[0];
2495
3149
  if (!id) throw new Error("cancel requires a dispatch id");
@@ -2529,35 +3183,35 @@ ${progress}`
2529
3183
  break;
2530
3184
  }
2531
3185
  case "man:init": {
2532
- const file = path12.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
3186
+ const file = args.includes("--global") ? path17.join(os6.homedir(), ".claude", "settings.json") : path17.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
2533
3187
  let existing;
2534
3188
  try {
2535
- existing = JSON.parse(fs16.readFileSync(file, "utf8"));
3189
+ existing = JSON.parse(fs23.readFileSync(file, "utf8"));
2536
3190
  } catch {
2537
3191
  existing = void 0;
2538
3192
  }
2539
3193
  const { settings, changed } = mergeHaulHook(existing);
2540
3194
  if (changed) {
2541
- fs16.mkdirSync(".claude", { recursive: true });
2542
- fs16.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
3195
+ fs23.mkdirSync(path17.dirname(file), { recursive: true });
3196
+ fs23.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
2543
3197
  `);
2544
3198
  }
2545
- if (args.includes("--marker")) fs16.writeFileSync(".lobstah-man", "");
3199
+ if (args.includes("--marker")) fs23.writeFileSync(".lobstah-man", "");
2546
3200
  console.log(
2547
3201
  toonKV({
2548
3202
  hook: "lobstah man haul",
2549
3203
  file,
2550
3204
  installed: changed || "already present",
2551
- gate: args.includes("--marker") ? ".lobstah-man (this directory)" : "launch with LOBSTAH_MAN=1 claude"
3205
+ gate: args.includes("--marker") ? ".lobstah-man (this directory)" : args.includes("--global") ? "touch .lobstah-man in a project (or LOBSTAH_MAN=1) to arm it there" : "launch with LOBSTAH_MAN=1 claude"
2552
3206
  })
2553
3207
  );
2554
3208
  break;
2555
3209
  }
2556
3210
  case "man:haul": {
2557
3211
  try {
2558
- if (process.env.LOBSTAH_MAN !== "1" && !fs16.existsSync(".lobstah-man")) break;
3212
+ if (process.env.LOBSTAH_MAN !== "1" && !fs23.existsSync(".lobstah-man")) break;
2559
3213
  const anyActive = ["work", "chore"].some(
2560
- (l) => fs16.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
3214
+ (l) => fs23.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
2561
3215
  );
2562
3216
  if (!anyActive) break;
2563
3217
  const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
@@ -2588,31 +3242,74 @@ ${progress}`
2588
3242
  }
2589
3243
  break;
2590
3244
  }
2591
- case "daemon": {
2592
- const interval = Number(arg(args, "--interval") ?? "5000");
2593
- await daemon(interval);
3245
+ case "daemon":
3246
+ case "pick": {
3247
+ const kind = cmd;
3248
+ if (args[0] === "install") {
3249
+ const res = installService(kind);
3250
+ console.log(toonKV({ service: kind, file: res.file, loaded: res.loaded, detail: res.detail }));
3251
+ break;
3252
+ }
3253
+ if (args[0] === "uninstall") {
3254
+ const res = uninstallService(kind);
3255
+ console.log(toonKV({ service: kind, file: res.file, removed: res.removed }));
3256
+ break;
3257
+ }
3258
+ if (kind === "daemon") await daemon(Number(arg(args, "--interval") ?? "5000"));
3259
+ else await runPickup(args[0] === "once" ? "once" : "daemon");
3260
+ break;
3261
+ }
3262
+ case "doctor": {
3263
+ const rows = runDoctor();
3264
+ console.log(toonTable("doctor", rows, ["check", "status", "detail"]));
3265
+ if (rows.some((r) => r.status === "fail")) process.exitCode = 1;
2594
3266
  break;
2595
3267
  }
2596
- case "pick": {
2597
- await runPickup(args[0] === "once" ? "once" : "daemon");
3268
+ case "repos": {
3269
+ if (args[0] === "add") {
3270
+ const target = args[1];
3271
+ if (!target) throw new Error("repos add requires a path");
3272
+ const detected = detectRepo(target);
3273
+ if (!detected) throw new Error(`${target} is not the root of a git repository`);
3274
+ const key = arg(args, "--key") ?? detected.key;
3275
+ if (configuredRepoKeys().has(key)) throw new Error(`repos.${key} already configured \u2014 edit ${configPath()} directly`);
3276
+ appendRepoBlock({ ...detected, key }, { pickup: args.includes("--pickup") });
3277
+ console.log(toonKV({ key, path: detected.path, trunk: detected.trunk, origin: detected.origin, pickup: args.includes("--pickup") }));
3278
+ break;
3279
+ }
3280
+ const repos = loadConfig().repos;
3281
+ const rows = Object.entries(repos).map(([key, r]) => ({
3282
+ key,
3283
+ path: r.path,
3284
+ trunk: r.trunk,
3285
+ pickup: r.pickup ?? false,
3286
+ exists: fs23.existsSync(r.path)
3287
+ }));
3288
+ console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
3289
+ break;
3290
+ }
3291
+ case "version": {
3292
+ console.log(lobstahVersion());
2598
3293
  break;
2599
3294
  }
2600
3295
  case "init": {
2601
- if (!fs16.existsSync(configPath())) {
2602
- fs16.writeFileSync(
3296
+ if (!fs23.existsSync(configPath())) {
3297
+ const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
3298
+ path = "~/src/example"
3299
+ trunk = "main"
3300
+ # origin = "git@github.com:you/example.git"
3301
+ # setup = ["pnpm install"]
3302
+ # pickup = true # opt into [pickup.github] multi-repo tracker pickup
3303
+
3304
+ `;
3305
+ fs23.writeFileSync(
2603
3306
  configPath(),
2604
3307
  `# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
2605
3308
  # Top-level keys must come before any [section].
2606
3309
  # notifyCommand = "ntfy pub my-topic "$LOBSTAH_VERB $LOBSTAH_ID: $LOBSTAH_NOTE""
2607
3310
  # notifyVerbs = ["needs-decision", "blocked", "done", "failed"] # the default
2608
3311
 
2609
- [repos.example]
2610
- path = "~/src/example"
2611
- trunk = "main"
2612
- # origin = "git@github.com:you/example.git"
2613
- # setup = ["pnpm install"]
2614
-
2615
- [harness]
3312
+ ${exampleRepo}[harness]
2616
3313
  default = "claude"
2617
3314
 
2618
3315
  [limits]
@@ -2624,16 +3321,44 @@ wallClockSecs = 3600
2624
3321
  `
2625
3322
  );
2626
3323
  }
3324
+ const scanIdx = args.indexOf("--scan");
3325
+ const added = [];
3326
+ const unmarked = [];
3327
+ if (scanIdx >= 0) {
3328
+ const roots = args.slice(scanIdx + 1).filter((a) => !a.startsWith("--"));
3329
+ if (roots.length === 0) throw new Error("--scan requires at least one directory");
3330
+ const known = configuredRepoKeys();
3331
+ const pickup = args.includes("--pickup");
3332
+ for (const repo of scanForRepos(roots)) {
3333
+ if (known.has(repo.key)) continue;
3334
+ known.add(repo.key);
3335
+ appendRepoBlock(repo, { pickup });
3336
+ added.push(repo.key);
3337
+ if (!pickup) unmarked.push(repo.key);
3338
+ }
3339
+ }
3340
+ let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
3341
+ try {
3342
+ const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
3343
+ if (fs23.existsSync(local)) reference = local;
3344
+ } catch {
3345
+ }
2627
3346
  console.log(
2628
3347
  toonKV({
2629
- home: path12.dirname(configPath()),
3348
+ home: path17.dirname(configPath()),
2630
3349
  config: configPath(),
2631
- reference: "docs/configuration.md",
3350
+ ...added.length > 0 ? { added: added.join(", ") } : {},
3351
+ ...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
3352
+ reference,
2632
3353
  initialized: true
2633
3354
  })
2634
3355
  );
2635
3356
  break;
2636
3357
  }
3358
+ case "--version":
3359
+ case "-v":
3360
+ console.log(lobstahVersion());
3361
+ break;
2637
3362
  case void 0:
2638
3363
  case "--help":
2639
3364
  case "help":