lobstah 0.1.1 → 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 fs18 from "node:fs";
5
- import * as path14 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,22 +1196,22 @@ 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);
@@ -1224,22 +1289,22 @@ function writeHeartbeat(cfg) {
1224
1289
  const payload = {
1225
1290
  machineId: os3.hostname(),
1226
1291
  repos: Object.keys(cfg.repos),
1227
- harnesses: ["claude", "codex"],
1292
+ harnesses: detectHarnesses(),
1228
1293
  maxConcurrent: cfg.limits.maxConcurrent,
1229
- version: "0.1.0",
1294
+ version: lobstahVersion(),
1230
1295
  heartbeat: (/* @__PURE__ */ new Date()).toISOString()
1231
1296
  };
1232
1297
  const tmp = `${executorPath()}.tmp`;
1233
- fs9.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1234
- fs9.renameSync(tmp, executorPath());
1298
+ fs11.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1299
+ fs11.renameSync(tmp, executorPath());
1235
1300
  }
1236
1301
  function pruneChores(cfg) {
1237
1302
  const dir = laneDirs("chore").done;
1238
1303
  const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
1239
- for (const id of fs9.readdirSync(dir)) {
1240
- const p = path6.join(dir, id);
1241
- if (fs9.statSync(p).mtimeMs < cutoff)
1242
- 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 });
1243
1308
  }
1244
1309
  }
1245
1310
  var daemonStartedAt = Date.now();
@@ -1279,9 +1344,9 @@ function tick(log = () => {
1279
1344
  pruneChores(loadConfig());
1280
1345
  }
1281
1346
  function acquireDaemonLock() {
1282
- const lockPath = path6.join(lobstahHome(), "daemon.lock");
1347
+ const lockPath = path7.join(lobstahHome(), "daemon.lock");
1283
1348
  try {
1284
- const prior = JSON.parse(fs9.readFileSync(lockPath, "utf8"));
1349
+ const prior = JSON.parse(fs11.readFileSync(lockPath, "utf8"));
1285
1350
  if (pidAlive(prior.pid, prior.processStartTime)) {
1286
1351
  throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
1287
1352
  }
@@ -1289,10 +1354,10 @@ function acquireDaemonLock() {
1289
1354
  if (err instanceof Error && err.message.includes("already supervises"))
1290
1355
  throw err;
1291
1356
  }
1292
- 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));
1293
1358
  const release = () => {
1294
1359
  try {
1295
- fs9.unlinkSync(lockPath);
1360
+ fs11.unlinkSync(lockPath);
1296
1361
  } catch {
1297
1362
  }
1298
1363
  };
@@ -1322,7 +1387,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
1322
1387
  import { spawn as spawn3 } from "node:child_process";
1323
1388
 
1324
1389
  // apps/pick/dist/config.js
1325
- import * as fs10 from "node:fs";
1390
+ import * as fs12 from "node:fs";
1326
1391
  import { execSync } from "node:child_process";
1327
1392
  import * as os4 from "node:os";
1328
1393
 
@@ -1341,6 +1406,11 @@ function marker(uuid) {
1341
1406
  }
1342
1407
 
1343
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
+ }
1344
1414
  function resolveTokenSource(section, label) {
1345
1415
  const command = section.tokenCommand ? String(section.tokenCommand) : void 0;
1346
1416
  const file = section.tokenFile ? String(section.tokenFile) : void 0;
@@ -1359,7 +1429,7 @@ function resolveTokenSource(section, label) {
1359
1429
  }
1360
1430
  if (file) {
1361
1431
  return () => {
1362
- 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();
1363
1433
  if (!token)
1364
1434
  throw new Error(`${label}: tokenFile ${file} is empty`);
1365
1435
  return token;
@@ -1377,27 +1447,55 @@ function resolveTokenSource(section, label) {
1377
1447
  }
1378
1448
  function loadPickupConfig() {
1379
1449
  const file = configPath();
1380
- const raw = fs10.existsSync(file) ? parse(fs10.readFileSync(file, "utf8")) : {};
1450
+ const raw = fs12.existsSync(file) ? parse(fs12.readFileSync(file, "utf8")) : {};
1381
1451
  const p = raw.pickup ?? {};
1382
1452
  const out = {
1383
1453
  pollSecs: Number(p.pollSecs ?? 45),
1384
- notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0
1454
+ notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0,
1455
+ github: []
1385
1456
  };
1386
1457
  const gh = p.github;
1387
1458
  if (gh) {
1388
1459
  const token = resolveTokenSource({ tokenEnv: "GITHUB_TOKEN", ...gh }, "pickup.github");
1389
1460
  token();
1390
- out.github = {
1391
- repo: String(gh.repo ?? ""),
1392
- key: String(gh.key ?? ""),
1393
- 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,
1394
1466
  token,
1395
1467
  startLabel: String(gh.startLabel ?? "lobstah"),
1396
- claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed"),
1397
- merge: { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} }
1468
+ claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed")
1398
1469
  };
1399
- if (!out.github.repo || !out.github.key || !out.github.identity) {
1400
- 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
+ }
1401
1499
  }
1402
1500
  }
1403
1501
  const ln = p.linear;
@@ -1422,23 +1520,23 @@ function loadPickupConfig() {
1422
1520
  }
1423
1521
 
1424
1522
  // apps/pick/dist/state.js
1425
- import * as fs11 from "node:fs";
1426
- import * as path7 from "node:path";
1523
+ import * as fs13 from "node:fs";
1524
+ import * as path8 from "node:path";
1427
1525
  var PickupState = class {
1428
1526
  file;
1429
1527
  data;
1430
- constructor(dir = path7.join(lobstahHome(), "pickup")) {
1431
- fs11.mkdirSync(dir, { recursive: true });
1432
- this.file = path7.join(dir, "state.json");
1433
- 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: {} };
1434
1532
  this.data.map ??= {};
1435
1533
  this.data.consumedApprovals ??= {};
1436
1534
  this.data.rebases ??= {};
1437
1535
  }
1438
1536
  save() {
1439
1537
  const tmp = `${this.file}.tmp-${process.pid}`;
1440
- fs11.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1441
- fs11.renameSync(tmp, this.file);
1538
+ fs13.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1539
+ fs13.renameSync(tmp, this.file);
1442
1540
  }
1443
1541
  get(key) {
1444
1542
  return this.data.map[key];
@@ -1822,12 +1920,12 @@ async function dispatchLoop(source, state, log = () => {
1822
1920
  }
1823
1921
 
1824
1922
  // apps/pick/dist/loops/report.js
1825
- import * as fs12 from "node:fs";
1826
- import * as path8 from "node:path";
1923
+ import * as fs14 from "node:fs";
1924
+ import * as path9 from "node:path";
1827
1925
  function dispatchLane(uuid) {
1828
1926
  for (const lane of ["work", "chore"]) {
1829
1927
  const d = laneDirs(lane);
1830
- 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`))) {
1831
1929
  return lane;
1832
1930
  }
1833
1931
  }
@@ -1868,12 +1966,12 @@ async function reportLoop(source, state, log = () => {
1868
1966
  }
1869
1967
 
1870
1968
  // apps/pick/dist/loops/reconcile.js
1871
- import * as fs13 from "node:fs";
1872
- import * as path9 from "node:path";
1969
+ import * as fs15 from "node:fs";
1970
+ import * as path10 from "node:path";
1873
1971
  function dispatchActive(uuid) {
1874
1972
  for (const lane of ["work", "chore"]) {
1875
1973
  const d = laneDirs(lane);
1876
- 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`))) {
1877
1975
  return lane;
1878
1976
  }
1879
1977
  }
@@ -1913,28 +2011,28 @@ async function reconcileLoop(source, state, log = () => {
1913
2011
 
1914
2012
  // apps/pick/dist/loops/merge.js
1915
2013
  import { randomUUID as randomUUID2 } from "node:crypto";
1916
- import * as fs15 from "node:fs";
1917
- import * as path11 from "node:path";
2014
+ import * as fs17 from "node:fs";
2015
+ import * as path12 from "node:path";
1918
2016
 
1919
2017
  // apps/pick/dist/merge-view.js
1920
- import * as fs14 from "node:fs";
1921
- import * as path10 from "node:path";
2018
+ import * as fs16 from "node:fs";
2019
+ import * as path11 from "node:path";
1922
2020
  function viewPath() {
1923
- return path10.join(lobstahHome(), "pickup", "merge-view.json");
2021
+ return path11.join(lobstahHome(), "pickup", "merge-view.json");
1924
2022
  }
1925
2023
  function readMergeView() {
1926
2024
  try {
1927
- return JSON.parse(fs14.readFileSync(viewPath(), "utf8"));
2025
+ return JSON.parse(fs16.readFileSync(viewPath(), "utf8"));
1928
2026
  } catch {
1929
2027
  return void 0;
1930
2028
  }
1931
2029
  }
1932
2030
  function writeMergeView(view) {
1933
2031
  const file = viewPath();
1934
- fs14.mkdirSync(path10.dirname(file), { recursive: true });
2032
+ fs16.mkdirSync(path11.dirname(file), { recursive: true });
1935
2033
  const tmp = `${file}.tmp-${process.pid}`;
1936
- fs14.writeFileSync(tmp, JSON.stringify(view, null, 2));
1937
- fs14.renameSync(tmp, file);
2034
+ fs16.writeFileSync(tmp, JSON.stringify(view, null, 2));
2035
+ fs16.renameSync(tmp, file);
1938
2036
  }
1939
2037
  var RECENT_RETENTION_MS = 48 * 36e5;
1940
2038
  async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
@@ -1956,7 +2054,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
1956
2054
  }
1957
2055
  function readPickupMap() {
1958
2056
  try {
1959
- const raw = JSON.parse(fs14.readFileSync(path10.join(lobstahHome(), "pickup", "state.json"), "utf8"));
2057
+ const raw = JSON.parse(fs16.readFileSync(path11.join(lobstahHome(), "pickup", "state.json"), "utf8"));
1960
2058
  return raw.map ?? {};
1961
2059
  } catch {
1962
2060
  return {};
@@ -1990,7 +2088,7 @@ function rebaseBrief(pr) {
1990
2088
  }
1991
2089
  function choreVerdict(uuid) {
1992
2090
  const d = laneDirs("chore");
1993
- if (fs15.existsSync(path11.join(d.active, uuid)) || fs15.existsSync(path11.join(d.queue, `${uuid}.json`)))
2091
+ if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`)))
1994
2092
  return "active";
1995
2093
  const last = readStatusLog(uuid, "chore").at(-1)?.verb;
1996
2094
  return last === "done" ? "done" : "failed";
@@ -2131,10 +2229,10 @@ async function runPickup(mode = "daemon") {
2131
2229
  const log = (m) => console.log(`[pickup] ${m}`);
2132
2230
  const sources = [];
2133
2231
  const merges = [];
2134
- if (cfg.github) {
2135
- const gh = new GithubSource(cfg.github);
2232
+ for (const ghCfg of cfg.github) {
2233
+ const gh = new GithubSource(ghCfg);
2136
2234
  sources.push(gh);
2137
- merges.push({ source: gh, policy: cfg.github.merge });
2235
+ merges.push({ source: gh, policy: ghCfg.merge });
2138
2236
  }
2139
2237
  if (cfg.linear)
2140
2238
  sources.push(new LinearSource(cfg.linear));
@@ -2167,21 +2265,21 @@ function mergeHaulHook(existing) {
2167
2265
  }
2168
2266
 
2169
2267
  // apps/cli/src/tend.ts
2170
- import * as fs16 from "node:fs";
2171
- import * as path12 from "node:path";
2268
+ import * as fs18 from "node:fs";
2269
+ import * as path13 from "node:path";
2172
2270
  var HEARTBEAT_STALE_MS = 9e4;
2173
2271
  var CLAIM_STALE_MS = 12e4;
2174
2272
  var DAY_MS = 24 * 36e5;
2175
2273
  function readJson(file) {
2176
2274
  try {
2177
- return JSON.parse(fs16.readFileSync(file, "utf8"));
2275
+ return JSON.parse(fs18.readFileSync(file, "utf8"));
2178
2276
  } catch {
2179
2277
  return void 0;
2180
2278
  }
2181
2279
  }
2182
2280
  function doneIds(lane) {
2183
2281
  try {
2184
- return fs16.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2282
+ return fs18.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2185
2283
  } catch {
2186
2284
  return [];
2187
2285
  }
@@ -2200,16 +2298,16 @@ function followUps(uuid) {
2200
2298
  const desc = readJson(file);
2201
2299
  if (desc?.followUp === uuid) out.push({ id, bucket });
2202
2300
  };
2203
- for (const id of pendingIds("work")) scan(id, "queued", path12.join(d.queue, `${id}.json`));
2204
- for (const id of activeIds("work")) scan(id, "active", path12.join(d.active, id, "descriptor.json"));
2205
- for (const id of doneIds("work")) scan(id, "done", path12.join(d.done, id, "descriptor.json"));
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"));
2206
2304
  return out;
2207
2305
  }
2208
2306
  function bucketOf(uuid) {
2209
2307
  const d = laneDirs("work");
2210
- if (fs16.existsSync(path12.join(d.active, uuid))) return "active";
2211
- if (fs16.existsSync(path12.join(d.done, uuid))) return "done";
2212
- if (fs16.existsSync(path12.join(d.queue, `${uuid}.json`))) return "queued";
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";
2213
2311
  return void 0;
2214
2312
  }
2215
2313
  function buildTendReport(now = Date.now()) {
@@ -2238,7 +2336,7 @@ function buildTendReport(now = Date.now()) {
2238
2336
  note: ev.entry.note
2239
2337
  }));
2240
2338
  const oldestQueuedAge = queued.reduce((max, id) => {
2241
- const st = fs16.statSync(path12.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2339
+ const st = fs18.statSync(path13.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2242
2340
  return st ? Math.max(max, now - st.mtimeMs) : max;
2243
2341
  }, 0);
2244
2342
  const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
@@ -2332,13 +2430,13 @@ function renderTend(r) {
2332
2430
  }
2333
2431
 
2334
2432
  // apps/cli/src/cull.ts
2335
- import * as fs17 from "node:fs";
2336
- import * as path13 from "node:path";
2433
+ import * as fs19 from "node:fs";
2434
+ import * as path14 from "node:path";
2337
2435
  import { execFileSync as execFileSync2 } from "node:child_process";
2338
2436
  var DAY = 864e5;
2339
2437
  function idsIn(dir) {
2340
2438
  try {
2341
- return new Set(fs17.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$/, "")));
2342
2440
  } catch {
2343
2441
  return /* @__PURE__ */ new Set();
2344
2442
  }
@@ -2353,39 +2451,39 @@ function planCull(olderThanDays, now = Date.now()) {
2353
2451
  for (const id of idsIn(d.queue)) live.add(id);
2354
2452
  for (const id of idsIn(d.active)) live.add(id);
2355
2453
  for (const id of idsIn(d.done)) {
2356
- const p = path13.join(d.done, id);
2357
- const m = fs17.statSync(p).mtimeMs;
2454
+ const p = path14.join(d.done, id);
2455
+ const m = fs19.statSync(p).mtimeMs;
2358
2456
  doneMtimes.set(id, m);
2359
2457
  if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
2360
2458
  }
2361
2459
  }
2362
- const wtRoot = path13.join(lobstahHome(), "worktrees");
2460
+ const wtRoot = path14.join(lobstahHome(), "worktrees");
2363
2461
  for (const id of idsIn(wtRoot)) {
2364
2462
  if (live.has(id)) continue;
2365
2463
  const doneAt = doneMtimes.get(id);
2366
2464
  if (doneAt !== void 0 && doneAt >= cutoff) continue;
2367
- const p = path13.join(wtRoot, id);
2368
- items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs17.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) });
2369
2467
  }
2370
2468
  for (const lane of ["work", "chore"]) {
2371
2469
  const d = laneDirs(lane);
2372
2470
  const seen = /* @__PURE__ */ new Set();
2373
- for (const f of fs17.readdirSync(d.state)) {
2471
+ for (const f of fs19.readdirSync(d.state)) {
2374
2472
  const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
2375
2473
  if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
2376
- const m = fs17.statSync(path13.join(d.state, f)).mtimeMs;
2474
+ const m = fs19.statSync(path14.join(d.state, f)).mtimeMs;
2377
2475
  if (m >= cutoff) continue;
2378
2476
  seen.add(id);
2379
- items.push({ kind: "state", id, target: path13.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) });
2380
2478
  }
2381
2479
  }
2382
2480
  return items;
2383
2481
  }
2384
2482
  function removeWorktree(id, dir) {
2385
2483
  for (const lane of ["work", "chore"]) {
2386
- const descFile = path13.join(laneDirs(lane).done, id, "descriptor.json");
2484
+ const descFile = path14.join(laneDirs(lane).done, id, "descriptor.json");
2387
2485
  try {
2388
- const desc = JSON.parse(fs17.readFileSync(descFile, "utf8"));
2486
+ const desc = JSON.parse(fs19.readFileSync(descFile, "utf8"));
2389
2487
  const repo = loadConfig().repos[desc.repo];
2390
2488
  if (repo) {
2391
2489
  execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
@@ -2394,15 +2492,15 @@ function removeWorktree(id, dir) {
2394
2492
  } catch {
2395
2493
  }
2396
2494
  }
2397
- fs17.rmSync(dir, { recursive: true, force: true });
2495
+ fs19.rmSync(dir, { recursive: true, force: true });
2398
2496
  }
2399
2497
  function applyCull(items) {
2400
2498
  for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
2401
- for (const item of items.filter((i) => i.kind === "done")) fs17.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 });
2402
2500
  for (const item of items.filter((i) => i.kind === "state")) {
2403
- const dir = path13.dirname(item.target);
2404
- for (const f of fs17.readdirSync(dir)) {
2405
- if (f.startsWith(`${item.id}.`)) fs17.rmSync(path13.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 });
2406
2504
  }
2407
2505
  }
2408
2506
  }
@@ -2446,6 +2544,268 @@ paste into your liaison instructions:
2446
2544
 
2447
2545
  full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
2448
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
+
2449
2809
  // apps/cli/src/main.ts
2450
2810
  var HELP = `lobstah \u2014 supervision framework for coding agents
2451
2811
 
@@ -2474,11 +2834,14 @@ work (humans and agents):
2474
2834
  Dry run by default (14 days).
2475
2835
  cancel <uuid> request cancellation
2476
2836
 
2477
- host processes (run under launchd/systemd/pm2):
2837
+ host processes:
2478
2838
  daemon [--interval <ms>] the supervisor: claims, worktrees, liveness,
2479
2839
  restart ladder, notifyCommand. One per home.
2480
2840
  pick [once] tracker loops: poll Linear/GitHub, dispatch
2481
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)
2482
2845
 
2483
2846
  lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual):
2484
2847
  man tend [--json] tend the whole string: fleet verdict (daemon
@@ -2491,10 +2854,13 @@ lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual)
2491
2854
  print the event and what to do next; exit 2
2492
2855
  on timeout. Unanswered questions re-fire
2493
2856
  every remindSecs until answered.
2494
- man init [--shared] [--marker] install the haul Stop hook into this
2495
- project's .claude/settings.local.json
2496
- (--shared: settings.json); --marker touches
2497
- .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.
2498
2864
  man haul [--timeout <secs>] Stop-hook entry point: park the session while
2499
2865
  work is in flight; prints hook-decision JSON
2500
2866
  on an event, silent exit 0 otherwise. Gate:
@@ -2506,7 +2872,16 @@ workers (dispatched agents; injected into every brief):
2506
2872
  (${VERBS.join(" | ")})
2507
2873
 
2508
2874
  setup:
2509
- 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
2510
2885
 
2511
2886
  Everything except daemon and pick works with both stopped: writes are files,
2512
2887
  reads are files. Output is TOON; agents can drive this CLI directly.
@@ -2518,7 +2893,7 @@ function arg(args, flag) {
2518
2893
  function findLane(id) {
2519
2894
  for (const lane of ["work", "chore"]) {
2520
2895
  const d = laneDirs(lane);
2521
- if (fs18.existsSync(path14.join(d.active, id)) || fs18.existsSync(path14.join(d.queue, `${id}.json`)) || fs18.existsSync(path14.join(d.done, id)) || fs18.existsSync(path14.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`))) {
2522
2897
  return lane;
2523
2898
  }
2524
2899
  }
@@ -2526,7 +2901,7 @@ function findLane(id) {
2526
2901
  }
2527
2902
  function rowsFor(lane, bucket) {
2528
2903
  const dir = laneDirs(lane)[bucket];
2529
- const entries = fs18.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs18.statSync(path14.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);
2530
2905
  const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
2531
2906
  return sliced.map(({ f, m }) => {
2532
2907
  const id = f.replace(/\.json$/, "");
@@ -2554,7 +2929,7 @@ async function mainCli() {
2554
2929
  const d = {
2555
2930
  id: arg(args, "--id") ?? randomUUID3(),
2556
2931
  repo,
2557
- brief: briefText ?? fs18.readFileSync(briefFile, "utf8"),
2932
+ brief: briefText ?? fs23.readFileSync(briefFile, "utf8"),
2558
2933
  harness: arg(args, "--harness"),
2559
2934
  model: arg(args, "--model"),
2560
2935
  effort: arg(args, "--effort"),
@@ -2591,17 +2966,17 @@ async function mainCli() {
2591
2966
  if (!id) throw new Error("logs requires a dispatch id");
2592
2967
  const lane = findLane(id);
2593
2968
  const file = eventsPath(id, lane);
2594
- if (fs18.existsSync(file)) process.stdout.write(fs18.readFileSync(file, "utf8"));
2969
+ if (fs23.existsSync(file)) process.stdout.write(fs23.readFileSync(file, "utf8"));
2595
2970
  if (args.includes("--follow")) {
2596
- let size = fs18.existsSync(file) ? fs18.statSync(file).size : 0;
2971
+ let size = fs23.existsSync(file) ? fs23.statSync(file).size : 0;
2597
2972
  setInterval(() => {
2598
- if (!fs18.existsSync(file)) return;
2599
- const now = fs18.statSync(file).size;
2973
+ if (!fs23.existsSync(file)) return;
2974
+ const now = fs23.statSync(file).size;
2600
2975
  if (now > size) {
2601
- const fd = fs18.openSync(file, "r");
2976
+ const fd = fs23.openSync(file, "r");
2602
2977
  const buf = Buffer.alloc(now - size);
2603
- fs18.readSync(fd, buf, 0, buf.length, size);
2604
- fs18.closeSync(fd);
2978
+ fs23.readSync(fd, buf, 0, buf.length, size);
2979
+ fs23.closeSync(fd);
2605
2980
  process.stdout.write(buf.toString("utf8"));
2606
2981
  size = now;
2607
2982
  }
@@ -2657,22 +3032,23 @@ async function mainCli() {
2657
3032
  );
2658
3033
  }
2659
3034
  const dirs = laneDirs(lane);
2660
- const descriptorFile = ["active", "done"].map((b) => path14.join(dirs[b], id, "descriptor.json")).find((f) => fs18.existsSync(f));
2661
- const harness = descriptorFile ? JSON.parse(fs18.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";
2662
3037
  let sessionId;
2663
3038
  try {
2664
- sessionId = JSON.parse(fs18.readFileSync(path14.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
3039
+ sessionId = JSON.parse(fs23.readFileSync(path17.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
2665
3040
  } catch {
2666
3041
  }
2667
3042
  if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
2668
- const worktree = path14.join(lobstahHome(), "worktrees", id);
2669
- const cwd = fs18.existsSync(worktree) ? worktree : process.cwd();
2670
- 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");
2671
3047
  if (args.includes("--print")) {
2672
3048
  console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
2673
3049
  break;
2674
3050
  }
2675
- const res = spawnSync(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
3051
+ const res = spawnSync4(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
2676
3052
  if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
2677
3053
  break;
2678
3054
  }
@@ -2680,39 +3056,39 @@ async function mainCli() {
2680
3056
  const id = args[0];
2681
3057
  if (!id) throw new Error("swap requires a dispatch id");
2682
3058
  const lane = findLane(id);
2683
- const activeDir = path14.join(laneDirs(lane).active, id);
2684
- if (!fs18.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
2685
- const descFile = path14.join(activeDir, "descriptor.json");
2686
- const descriptor = JSON.parse(fs18.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"));
2687
3063
  const fromHarness = descriptor.harness ?? "default";
2688
3064
  for (const key of ["harness", "model", "effort"]) {
2689
3065
  const v = arg(args, `--${key}`);
2690
3066
  if (v) descriptor[key] = v;
2691
3067
  }
2692
- fs18.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
2693
- const worktree = path14.join(lobstahHome(), "worktrees", id);
3068
+ fs23.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
3069
+ const worktree = path17.join(lobstahHome(), "worktrees", id);
2694
3070
  let progress = "No worktree progress recorded.";
2695
- if (fs18.existsSync(worktree)) {
2696
- const git = (...a) => spawnSync("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
2697
- const commits = git("log", "--oneline", "-15");
2698
- 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");
2699
3075
  progress = `Commits so far:
2700
3076
  ${commits || "(none)"}
2701
3077
 
2702
3078
  Uncommitted changes:
2703
3079
  ${status || "(clean)"}`;
2704
3080
  }
2705
- fs18.writeFileSync(
2706
- path14.join(activeDir, "handoff"),
3081
+ fs23.writeFileSync(
3082
+ path17.join(activeDir, "handoff"),
2707
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.
2708
3084
 
2709
3085
  ${progress}`
2710
3086
  );
2711
- const runnerFile = path14.join(activeDir, "runner.json");
2712
- if (fs18.existsSync(runnerFile)) {
2713
- const runner = JSON.parse(fs18.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"));
2714
3090
  if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
2715
- fs18.unlinkSync(runnerFile);
3091
+ fs23.unlinkSync(runnerFile);
2716
3092
  }
2717
3093
  appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
2718
3094
  console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
@@ -2807,35 +3183,35 @@ ${progress}`
2807
3183
  break;
2808
3184
  }
2809
3185
  case "man:init": {
2810
- const file = path14.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");
2811
3187
  let existing;
2812
3188
  try {
2813
- existing = JSON.parse(fs18.readFileSync(file, "utf8"));
3189
+ existing = JSON.parse(fs23.readFileSync(file, "utf8"));
2814
3190
  } catch {
2815
3191
  existing = void 0;
2816
3192
  }
2817
3193
  const { settings, changed } = mergeHaulHook(existing);
2818
3194
  if (changed) {
2819
- fs18.mkdirSync(".claude", { recursive: true });
2820
- fs18.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
3195
+ fs23.mkdirSync(path17.dirname(file), { recursive: true });
3196
+ fs23.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
2821
3197
  `);
2822
3198
  }
2823
- if (args.includes("--marker")) fs18.writeFileSync(".lobstah-man", "");
3199
+ if (args.includes("--marker")) fs23.writeFileSync(".lobstah-man", "");
2824
3200
  console.log(
2825
3201
  toonKV({
2826
3202
  hook: "lobstah man haul",
2827
3203
  file,
2828
3204
  installed: changed || "already present",
2829
- 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"
2830
3206
  })
2831
3207
  );
2832
3208
  break;
2833
3209
  }
2834
3210
  case "man:haul": {
2835
3211
  try {
2836
- if (process.env.LOBSTAH_MAN !== "1" && !fs18.existsSync(".lobstah-man")) break;
3212
+ if (process.env.LOBSTAH_MAN !== "1" && !fs23.existsSync(".lobstah-man")) break;
2837
3213
  const anyActive = ["work", "chore"].some(
2838
- (l) => fs18.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
3214
+ (l) => fs23.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
2839
3215
  );
2840
3216
  if (!anyActive) break;
2841
3217
  const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
@@ -2866,31 +3242,74 @@ ${progress}`
2866
3242
  }
2867
3243
  break;
2868
3244
  }
2869
- case "daemon": {
2870
- const interval = Number(arg(args, "--interval") ?? "5000");
2871
- 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");
2872
3260
  break;
2873
3261
  }
2874
- case "pick": {
2875
- await runPickup(args[0] === "once" ? "once" : "daemon");
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;
3266
+ break;
3267
+ }
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());
2876
3293
  break;
2877
3294
  }
2878
3295
  case "init": {
2879
- if (!fs18.existsSync(configPath())) {
2880
- fs18.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(
2881
3306
  configPath(),
2882
3307
  `# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
2883
3308
  # Top-level keys must come before any [section].
2884
3309
  # notifyCommand = "ntfy pub my-topic "$LOBSTAH_VERB $LOBSTAH_ID: $LOBSTAH_NOTE""
2885
3310
  # notifyVerbs = ["needs-decision", "blocked", "done", "failed"] # the default
2886
3311
 
2887
- [repos.example]
2888
- path = "~/src/example"
2889
- trunk = "main"
2890
- # origin = "git@github.com:you/example.git"
2891
- # setup = ["pnpm install"]
2892
-
2893
- [harness]
3312
+ ${exampleRepo}[harness]
2894
3313
  default = "claude"
2895
3314
 
2896
3315
  [limits]
@@ -2902,16 +3321,44 @@ wallClockSecs = 3600
2902
3321
  `
2903
3322
  );
2904
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
+ }
2905
3346
  console.log(
2906
3347
  toonKV({
2907
- home: path14.dirname(configPath()),
3348
+ home: path17.dirname(configPath()),
2908
3349
  config: configPath(),
2909
- 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,
2910
3353
  initialized: true
2911
3354
  })
2912
3355
  );
2913
3356
  break;
2914
3357
  }
3358
+ case "--version":
3359
+ case "-v":
3360
+ console.log(lobstahVersion());
3361
+ break;
2915
3362
  case void 0:
2916
3363
  case "--help":
2917
3364
  case "help":