lobstah 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // apps/cli/src/main.ts
4
- import * as fs23 from "node:fs";
4
+ import * as fs25 from "node:fs";
5
5
  import * as os6 from "node:os";
6
- import * as path17 from "node:path";
7
- import { randomUUID as randomUUID3 } from "node:crypto";
8
- import { spawnSync as spawnSync4 } from "node:child_process";
6
+ import * as path19 from "node:path";
7
+ import { randomUUID as randomUUID4 } from "node:crypto";
8
+ import { spawnSync as spawnSync5 } from "node:child_process";
9
9
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10
10
 
11
11
  // packages/core/dist/types.js
@@ -38,6 +38,7 @@ function ensureLayout() {
38
38
  fs.mkdirSync(dir, { recursive: true });
39
39
  }
40
40
  fs.mkdirSync(path.join(lobstahHome(), "worktrees"), { recursive: true });
41
+ fs.mkdirSync(path.join(lobstahHome(), "watches"), { recursive: true });
41
42
  }
42
43
  function statusPath(id, lane) {
43
44
  return path.join(laneDirs(lane).state, `${id}.status`);
@@ -936,32 +937,210 @@ function onPath(bin, pathVar = process.env.PATH) {
936
937
  }
937
938
  return false;
938
939
  }
939
- function resolvable(specifier) {
940
+ function packagePresent(specifier, fromFile = import.meta.url) {
941
+ const require_2 = createRequire(fromFile);
940
942
  try {
941
- createRequire(import.meta.url).resolve(specifier);
943
+ require_2.resolve(specifier);
942
944
  return true;
943
945
  } catch {
944
- return false;
945
946
  }
947
+ for (const dir of require_2.resolve.paths(specifier) ?? []) {
948
+ if (fs9.existsSync(path5.join(dir, specifier, "package.json")))
949
+ return true;
950
+ }
951
+ return false;
946
952
  }
947
953
  function detectHarnesses() {
948
954
  const out = [];
949
- if (onPath("claude") || resolvable("@anthropic-ai/claude-agent-sdk"))
955
+ if (onPath("claude") || packagePresent("@anthropic-ai/claude-agent-sdk"))
950
956
  out.push("claude");
951
- if (onPath("codex") || resolvable("@openai/codex-sdk"))
957
+ if (onPath("codex") || packagePresent("@openai/codex-sdk"))
952
958
  out.push("codex");
953
959
  return out;
954
960
  }
955
961
  function codexInvocation(argv) {
956
962
  if (onPath("codex"))
957
963
  return { file: "codex", argv };
964
+ const require_2 = createRequire(import.meta.url);
958
965
  try {
959
- const cli = createRequire(import.meta.url).resolve("@openai/codex/bin/codex.js");
966
+ const cli = require_2.resolve("@openai/codex/bin/codex.js");
960
967
  return { file: process.execPath, argv: [cli, ...argv] };
968
+ } catch {
969
+ }
970
+ for (const dir of require_2.resolve.paths("@openai/codex") ?? []) {
971
+ const cli = path5.join(dir, "@openai", "codex", "bin", "codex.js");
972
+ if (fs9.existsSync(cli))
973
+ return { file: process.execPath, argv: [cli, ...argv] };
974
+ }
975
+ return void 0;
976
+ }
977
+
978
+ // packages/core/dist/watch.js
979
+ import * as fs10 from "node:fs";
980
+ import * as path6 from "node:path";
981
+ import { spawnSync } from "node:child_process";
982
+ function watchesDir() {
983
+ return path6.join(lobstahHome(), "watches");
984
+ }
985
+ function slug(key) {
986
+ return key.replace(/[^A-Za-z0-9._-]+/g, "-");
987
+ }
988
+ function watchPath(key) {
989
+ return path6.join(watchesDir(), `${slug(key)}.json`);
990
+ }
991
+ function eventsPath2(key) {
992
+ return path6.join(watchesDir(), `${slug(key)}.events`);
993
+ }
994
+ function writeWatch(w) {
995
+ fs10.mkdirSync(watchesDir(), { recursive: true });
996
+ const file = watchPath(w.key);
997
+ const tmp = `${file}.tmp`;
998
+ fs10.writeFileSync(tmp, JSON.stringify(w, null, 2));
999
+ fs10.renameSync(tmp, file);
1000
+ }
1001
+ function readWatch(key) {
1002
+ try {
1003
+ return JSON.parse(fs10.readFileSync(watchPath(key), "utf8"));
961
1004
  } catch {
962
1005
  return void 0;
963
1006
  }
964
1007
  }
1008
+ function listWatches() {
1009
+ if (!fs10.existsSync(watchesDir()))
1010
+ return [];
1011
+ return fs10.readdirSync(watchesDir()).filter((f) => f.endsWith(".json")).map((f) => {
1012
+ try {
1013
+ return JSON.parse(fs10.readFileSync(path6.join(watchesDir(), f), "utf8"));
1014
+ } catch {
1015
+ return void 0;
1016
+ }
1017
+ }).filter((w) => w !== void 0);
1018
+ }
1019
+ function addWatch(key, check, opts = {}) {
1020
+ if (!key || !check)
1021
+ throw new Error("watch add requires a key and a --check command");
1022
+ const existing = readWatch(key);
1023
+ const w = {
1024
+ key,
1025
+ owner: opts.owner ?? existing?.owner ?? "man",
1026
+ check,
1027
+ stream: opts.stream ?? existing?.stream,
1028
+ cursor: existing?.cursor ?? opts.cursor ?? "0",
1029
+ everySecs: opts.everySecs ?? existing?.everySecs,
1030
+ brief: opts.brief ?? existing?.brief,
1031
+ createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1032
+ lastCheckedAt: existing?.lastCheckedAt,
1033
+ seen: existing?.seen ?? 0,
1034
+ seenAt: existing?.seenAt ?? 0,
1035
+ lastFollowUpId: existing?.lastFollowUpId
1036
+ };
1037
+ writeWatch(w);
1038
+ return w;
1039
+ }
1040
+ function removeWatch(key) {
1041
+ const existed = fs10.existsSync(watchPath(key));
1042
+ fs10.rmSync(watchPath(key), { force: true });
1043
+ fs10.rmSync(eventsPath2(key), { force: true });
1044
+ return existed;
1045
+ }
1046
+ function readWatchEvents(key) {
1047
+ try {
1048
+ return fs10.readFileSync(eventsPath2(key), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
1049
+ } catch {
1050
+ return [];
1051
+ }
1052
+ }
1053
+ function appendWatchEvents(key, events) {
1054
+ const seen = new Set(readWatchEvents(key).map((e) => String(e.seq)));
1055
+ const fresh = events.filter((e) => !seen.has(String(e.seq)));
1056
+ if (fresh.length > 0) {
1057
+ fs10.appendFileSync(eventsPath2(key), fresh.map((e) => JSON.stringify(e)).join("\n") + "\n");
1058
+ }
1059
+ return fresh;
1060
+ }
1061
+ function setWatchCursor(key, cursor) {
1062
+ const w = readWatch(key);
1063
+ if (!w || w.cursor === cursor)
1064
+ return;
1065
+ w.cursor = cursor;
1066
+ writeWatch(w);
1067
+ }
1068
+ function watchDue(w, defaultEverySecs, now = Date.now()) {
1069
+ const every = (w.everySecs ?? defaultEverySecs) * 1e3;
1070
+ const last = w.lastCheckedAt ? Date.parse(w.lastCheckedAt) : 0;
1071
+ return now - last >= every;
1072
+ }
1073
+ var CHECK_TIMEOUT_MS = 9e4;
1074
+ function runWatchCheck(w, now = /* @__PURE__ */ new Date()) {
1075
+ w.lastCheckedAt = now.toISOString();
1076
+ writeWatch(w);
1077
+ const cmd = w.check.replaceAll("{cursor}", w.cursor);
1078
+ const res = spawnSync(cmd, { shell: true, encoding: "utf8", timeout: CHECK_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 });
1079
+ if (res.status !== 0 || res.error) {
1080
+ w.lastError = (res.error?.message || res.stderr?.trim() || `exit ${res.status}`).slice(0, 500);
1081
+ writeWatch(w);
1082
+ return { watch: w, fresh: [] };
1083
+ }
1084
+ let parsed;
1085
+ try {
1086
+ parsed = JSON.parse(res.stdout);
1087
+ } catch {
1088
+ w.lastError = `unparseable check output: ${res.stdout.slice(0, 200).trim()}`;
1089
+ writeWatch(w);
1090
+ return { watch: w, fresh: [] };
1091
+ }
1092
+ w.lastError = void 0;
1093
+ const events = Array.isArray(parsed.events) ? parsed.events.map((e) => ({
1094
+ seq: e.seq ?? w.cursor,
1095
+ summary: e.summary !== void 0 ? String(e.summary) : void 0,
1096
+ ...e,
1097
+ at: now.toISOString()
1098
+ })) : [];
1099
+ const fresh = appendWatchEvents(w.key, events);
1100
+ if (parsed.cursor !== void 0)
1101
+ w.cursor = String(parsed.cursor);
1102
+ if (parsed.done === true)
1103
+ w.done = true;
1104
+ writeWatch(w);
1105
+ return { watch: w, fresh };
1106
+ }
1107
+ function pendingWatchEvents(consume, owner = "man", now = Date.now()) {
1108
+ const out = [];
1109
+ for (const w of listWatches()) {
1110
+ const isMan = w.owner === "man";
1111
+ if (owner === "man" !== isMan)
1112
+ continue;
1113
+ const events = readWatchEvents(w.key);
1114
+ const fresh = events.slice(w.seen);
1115
+ if (fresh.length === 0) {
1116
+ if (w.done && consume)
1117
+ removeWatch(w.key);
1118
+ continue;
1119
+ }
1120
+ out.push({ watch: w, events: fresh });
1121
+ if (consume) {
1122
+ w.seen = events.length;
1123
+ w.seenAt = now;
1124
+ if (w.done)
1125
+ removeWatch(w.key);
1126
+ else
1127
+ writeWatch(w);
1128
+ }
1129
+ }
1130
+ return out;
1131
+ }
1132
+ function markFollowUp(key, followUpId, deliveredThrough) {
1133
+ const w = readWatch(key);
1134
+ if (!w)
1135
+ return;
1136
+ w.lastFollowUpId = followUpId;
1137
+ w.seen = deliveredThrough;
1138
+ w.seenAt = Date.now();
1139
+ if (w.done)
1140
+ removeWatch(w.key);
1141
+ else
1142
+ writeWatch(w);
1143
+ }
965
1144
 
966
1145
  // packages/supervisor/dist/liveness.js
967
1146
  import { execFileSync } from "node:child_process";
@@ -1021,26 +1200,26 @@ function killGroup(pid, signal = "SIGTERM") {
1021
1200
  }
1022
1201
 
1023
1202
  // packages/supervisor/dist/daemon.js
1024
- import * as fs11 from "node:fs";
1203
+ import * as fs12 from "node:fs";
1025
1204
  import * as os3 from "node:os";
1026
- import * as path7 from "node:path";
1205
+ import * as path8 from "node:path";
1027
1206
  import { spawn as spawn2 } from "node:child_process";
1028
1207
  import { createRequire as createRequire2 } from "node:module";
1029
1208
  import { fileURLToPath } from "node:url";
1030
1209
 
1031
1210
  // packages/supervisor/dist/notify.js
1032
- import * as fs10 from "node:fs";
1033
- import * as path6 from "node:path";
1211
+ import * as fs11 from "node:fs";
1212
+ import * as path7 from "node:path";
1034
1213
  import { spawn } from "node:child_process";
1035
1214
  var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
1036
1215
  function cursorPath(id, lane) {
1037
- return path6.join(laneDirs(lane).state, `${id}.notified`);
1216
+ return path7.join(laneDirs(lane).state, `${id}.notified`);
1038
1217
  }
1039
1218
  function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
1040
1219
  const log = readStatusLog(id, lane);
1041
1220
  let cursor;
1042
1221
  try {
1043
- cursor = Number(fs10.readFileSync(cursorPath(id, lane), "utf8")) || 0;
1222
+ cursor = Number(fs11.readFileSync(cursorPath(id, lane), "utf8")) || 0;
1044
1223
  } catch {
1045
1224
  cursor = void 0;
1046
1225
  }
@@ -1050,13 +1229,13 @@ function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs =
1050
1229
  if (cursor === void 0 && sinceMs > 0) {
1051
1230
  fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
1052
1231
  }
1053
- fs10.writeFileSync(cursorPath(id, lane), String(log.length));
1232
+ fs11.writeFileSync(cursorPath(id, lane), String(log.length));
1054
1233
  return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
1055
1234
  }
1056
1235
  function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
1057
1236
  const dir = laneDirs(lane).state;
1058
1237
  const cutoff = Date.now() - maxAgeMs;
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));
1238
+ return fs11.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs11.statSync(path7.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
1060
1239
  }
1061
1240
  function execNotify(command, ev, log = () => {
1062
1241
  }) {
@@ -1086,11 +1265,11 @@ function captureWaitBaseline() {
1086
1265
  return lengths;
1087
1266
  }
1088
1267
  function attnCursorPath(id, lane) {
1089
- return path6.join(laneDirs(lane).state, `${id}.attn`);
1268
+ return path7.join(laneDirs(lane).state, `${id}.attn`);
1090
1269
  }
1091
1270
  function readAttnCursor(file) {
1092
1271
  try {
1093
- const parsed = JSON.parse(fs10.readFileSync(file, "utf8"));
1272
+ const parsed = JSON.parse(fs11.readFileSync(file, "utf8"));
1094
1273
  return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
1095
1274
  } catch {
1096
1275
  return { seen: 0, at: 0 };
@@ -1116,7 +1295,7 @@ function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
1116
1295
  continue;
1117
1296
  out.push({ id, lane, entry });
1118
1297
  if (consume)
1119
- fs10.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
1298
+ fs11.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
1120
1299
  }
1121
1300
  }
1122
1301
  return out;
@@ -1151,33 +1330,33 @@ function runnerEntry() {
1151
1330
  }
1152
1331
  function readRunnerInfo(dir) {
1153
1332
  try {
1154
- return JSON.parse(fs11.readFileSync(path7.join(dir, "runner.json"), "utf8"));
1333
+ return JSON.parse(fs12.readFileSync(path8.join(dir, "runner.json"), "utf8"));
1155
1334
  } catch {
1156
1335
  return void 0;
1157
1336
  }
1158
1337
  }
1159
1338
  function listActive(lane) {
1160
1339
  const dir = laneDirs(lane).active;
1161
- return fs11.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
1162
- const d = path7.join(dir, id);
1340
+ return fs12.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
1341
+ const d = path8.join(dir, id);
1163
1342
  return { id, lane, dir: d, runner: readRunnerInfo(d) };
1164
1343
  });
1165
1344
  }
1166
1345
  function finalize(st) {
1167
1346
  try {
1168
- fs11.renameSync(st.dir, path7.join(laneDirs(st.lane).done, st.id));
1347
+ fs12.renameSync(st.dir, path8.join(laneDirs(st.lane).done, st.id));
1169
1348
  } catch {
1170
1349
  }
1171
1350
  }
1172
1351
  function spawnRunner(st, opts) {
1173
1352
  let nudge = opts.nudge;
1174
- const handoffFile = path7.join(st.dir, "handoff");
1175
- if (!nudge && fs11.existsSync(handoffFile)) {
1176
- nudge = fs11.readFileSync(handoffFile, "utf8");
1177
- fs11.unlinkSync(handoffFile);
1353
+ const handoffFile = path8.join(st.dir, "handoff");
1354
+ if (!nudge && fs12.existsSync(handoffFile)) {
1355
+ nudge = fs12.readFileSync(handoffFile, "utf8");
1356
+ fs12.unlinkSync(handoffFile);
1178
1357
  }
1179
- const logPath = path7.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
1180
- const log = fs11.openSync(logPath, "a");
1358
+ const logPath = path8.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
1359
+ const log = fs12.openSync(logPath, "a");
1181
1360
  const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
1182
1361
  detached: true,
1183
1362
  stdio: ["ignore", log, log],
@@ -1188,7 +1367,7 @@ function spawnRunner(st, opts) {
1188
1367
  ...nudge ? { LOBSTAH_NUDGE: nudge } : {}
1189
1368
  }
1190
1369
  });
1191
- fs11.closeSync(log);
1370
+ fs12.closeSync(log);
1192
1371
  child.unref();
1193
1372
  const info = {
1194
1373
  pid: child.pid ?? -1,
@@ -1196,22 +1375,22 @@ function spawnRunner(st, opts) {
1196
1375
  processStartTime: child.pid ? processStartTime(child.pid) : void 0,
1197
1376
  attempts: opts.attempts
1198
1377
  };
1199
- fs11.writeFileSync(path7.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
1378
+ fs12.writeFileSync(path8.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
1200
1379
  }
1201
1380
  function sessionOf(st) {
1202
1381
  try {
1203
- const ev = JSON.parse(fs11.readFileSync(path7.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
1382
+ const ev = JSON.parse(fs12.readFileSync(path8.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
1204
1383
  return ev.sessionId;
1205
1384
  } catch {
1206
1385
  return void 0;
1207
1386
  }
1208
1387
  }
1209
1388
  function reconcileOne(st, cfg, log) {
1210
- const hasDescriptor = fs11.existsSync(path7.join(st.dir, "descriptor.json"));
1389
+ const hasDescriptor = fs12.existsSync(path8.join(st.dir, "descriptor.json"));
1211
1390
  if (!hasDescriptor) {
1212
- const age = Date.now() - fs11.statSync(st.dir).mtimeMs;
1391
+ const age = Date.now() - fs12.statSync(st.dir).mtimeMs;
1213
1392
  if (age > 5 * 6e4)
1214
- fs11.rmSync(st.dir, { recursive: true, force: true });
1393
+ fs12.rmSync(st.dir, { recursive: true, force: true });
1215
1394
  return;
1216
1395
  }
1217
1396
  const statusLog = readStatusLog(st.id, st.lane);
@@ -1295,16 +1474,16 @@ function writeHeartbeat(cfg) {
1295
1474
  heartbeat: (/* @__PURE__ */ new Date()).toISOString()
1296
1475
  };
1297
1476
  const tmp = `${executorPath()}.tmp`;
1298
- fs11.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1299
- fs11.renameSync(tmp, executorPath());
1477
+ fs12.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1478
+ fs12.renameSync(tmp, executorPath());
1300
1479
  }
1301
1480
  function pruneChores(cfg) {
1302
1481
  const dir = laneDirs("chore").done;
1303
1482
  const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
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 });
1483
+ for (const id of fs12.readdirSync(dir)) {
1484
+ const p = path8.join(dir, id);
1485
+ if (fs12.statSync(p).mtimeMs < cutoff)
1486
+ fs12.rmSync(p, { recursive: true, force: true });
1308
1487
  }
1309
1488
  }
1310
1489
  var daemonStartedAt = Date.now();
@@ -1344,9 +1523,9 @@ function tick(log = () => {
1344
1523
  pruneChores(loadConfig());
1345
1524
  }
1346
1525
  function acquireDaemonLock() {
1347
- const lockPath = path7.join(lobstahHome(), "daemon.lock");
1526
+ const lockPath = path8.join(lobstahHome(), "daemon.lock");
1348
1527
  try {
1349
- const prior = JSON.parse(fs11.readFileSync(lockPath, "utf8"));
1528
+ const prior = JSON.parse(fs12.readFileSync(lockPath, "utf8"));
1350
1529
  if (pidAlive(prior.pid, prior.processStartTime)) {
1351
1530
  throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
1352
1531
  }
@@ -1354,10 +1533,10 @@ function acquireDaemonLock() {
1354
1533
  if (err instanceof Error && err.message.includes("already supervises"))
1355
1534
  throw err;
1356
1535
  }
1357
- fs11.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
1536
+ fs12.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
1358
1537
  const release = () => {
1359
1538
  try {
1360
- fs11.unlinkSync(lockPath);
1539
+ fs12.unlinkSync(lockPath);
1361
1540
  } catch {
1362
1541
  }
1363
1542
  };
@@ -1369,17 +1548,42 @@ function acquireDaemonLock() {
1369
1548
  });
1370
1549
  }
1371
1550
  }
1551
+ function watchQueues(onChange) {
1552
+ const watchers = [];
1553
+ for (const lane of ["work", "chore"]) {
1554
+ try {
1555
+ watchers.push(fs12.watch(laneDirs(lane).queue, onChange));
1556
+ } catch {
1557
+ }
1558
+ }
1559
+ return () => {
1560
+ for (const w of watchers)
1561
+ w.close();
1562
+ };
1563
+ }
1372
1564
  async function daemon(intervalMs = 5e3, log = console.log) {
1373
1565
  ensureLayout();
1374
1566
  acquireDaemonLock();
1375
1567
  log(`lobstah daemon: watching ${laneDirs("work").queue} every ${intervalMs}ms`);
1568
+ let wake;
1569
+ watchQueues(() => wake?.());
1376
1570
  while (true) {
1377
1571
  try {
1378
1572
  tick(log);
1379
1573
  } catch (err) {
1380
1574
  log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
1381
1575
  }
1382
- await new Promise((r) => setTimeout(r, intervalMs));
1576
+ await new Promise((resolve2) => {
1577
+ const timer = setTimeout(() => {
1578
+ wake = void 0;
1579
+ resolve2();
1580
+ }, intervalMs);
1581
+ wake = () => {
1582
+ wake = void 0;
1583
+ clearTimeout(timer);
1584
+ setTimeout(resolve2, 50);
1585
+ };
1586
+ });
1383
1587
  }
1384
1588
  }
1385
1589
 
@@ -1387,7 +1591,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
1387
1591
  import { spawn as spawn3 } from "node:child_process";
1388
1592
 
1389
1593
  // apps/pick/dist/config.js
1390
- import * as fs12 from "node:fs";
1594
+ import * as fs13 from "node:fs";
1391
1595
  import { execSync } from "node:child_process";
1392
1596
  import * as os4 from "node:os";
1393
1597
 
@@ -1429,7 +1633,7 @@ function resolveTokenSource(section, label) {
1429
1633
  }
1430
1634
  if (file) {
1431
1635
  return () => {
1432
- const token = fs12.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
1636
+ const token = fs13.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
1433
1637
  if (!token)
1434
1638
  throw new Error(`${label}: tokenFile ${file} is empty`);
1435
1639
  return token;
@@ -1447,7 +1651,7 @@ function resolveTokenSource(section, label) {
1447
1651
  }
1448
1652
  function loadPickupConfig() {
1449
1653
  const file = configPath();
1450
- const raw = fs12.existsSync(file) ? parse(fs12.readFileSync(file, "utf8")) : {};
1654
+ const raw = fs13.existsSync(file) ? parse(fs13.readFileSync(file, "utf8")) : {};
1451
1655
  const p = raw.pickup ?? {};
1452
1656
  const out = {
1453
1657
  pollSecs: Number(p.pollSecs ?? 45),
@@ -1520,23 +1724,23 @@ function loadPickupConfig() {
1520
1724
  }
1521
1725
 
1522
1726
  // apps/pick/dist/state.js
1523
- import * as fs13 from "node:fs";
1524
- import * as path8 from "node:path";
1727
+ import * as fs14 from "node:fs";
1728
+ import * as path9 from "node:path";
1525
1729
  var PickupState = class {
1526
1730
  file;
1527
1731
  data;
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: {} };
1732
+ constructor(dir = path9.join(lobstahHome(), "pickup")) {
1733
+ fs14.mkdirSync(dir, { recursive: true });
1734
+ this.file = path9.join(dir, "state.json");
1735
+ this.data = fs14.existsSync(this.file) ? JSON.parse(fs14.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
1532
1736
  this.data.map ??= {};
1533
1737
  this.data.consumedApprovals ??= {};
1534
1738
  this.data.rebases ??= {};
1535
1739
  }
1536
1740
  save() {
1537
1741
  const tmp = `${this.file}.tmp-${process.pid}`;
1538
- fs13.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1539
- fs13.renameSync(tmp, this.file);
1742
+ fs14.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1743
+ fs14.renameSync(tmp, this.file);
1540
1744
  }
1541
1745
  get(key) {
1542
1746
  return this.data.map[key];
@@ -1920,12 +2124,12 @@ async function dispatchLoop(source, state, log = () => {
1920
2124
  }
1921
2125
 
1922
2126
  // apps/pick/dist/loops/report.js
1923
- import * as fs14 from "node:fs";
1924
- import * as path9 from "node:path";
2127
+ import * as fs15 from "node:fs";
2128
+ import * as path10 from "node:path";
1925
2129
  function dispatchLane(uuid) {
1926
2130
  for (const lane of ["work", "chore"]) {
1927
2131
  const d = laneDirs(lane);
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`))) {
2132
+ if (fs15.existsSync(path10.join(d.active, uuid)) || fs15.existsSync(path10.join(d.queue, `${uuid}.json`)) || fs15.existsSync(path10.join(d.done, uuid)) || fs15.existsSync(path10.join(d.state, `${uuid}.status`))) {
1929
2133
  return lane;
1930
2134
  }
1931
2135
  }
@@ -1966,12 +2170,12 @@ async function reportLoop(source, state, log = () => {
1966
2170
  }
1967
2171
 
1968
2172
  // apps/pick/dist/loops/reconcile.js
1969
- import * as fs15 from "node:fs";
1970
- import * as path10 from "node:path";
2173
+ import * as fs16 from "node:fs";
2174
+ import * as path11 from "node:path";
1971
2175
  function dispatchActive(uuid) {
1972
2176
  for (const lane of ["work", "chore"]) {
1973
2177
  const d = laneDirs(lane);
1974
- if (fs15.existsSync(path10.join(d.active, uuid)) || fs15.existsSync(path10.join(d.queue, `${uuid}.json`))) {
2178
+ if (fs16.existsSync(path11.join(d.active, uuid)) || fs16.existsSync(path11.join(d.queue, `${uuid}.json`))) {
1975
2179
  return lane;
1976
2180
  }
1977
2181
  }
@@ -2011,28 +2215,28 @@ async function reconcileLoop(source, state, log = () => {
2011
2215
 
2012
2216
  // apps/pick/dist/loops/merge.js
2013
2217
  import { randomUUID as randomUUID2 } from "node:crypto";
2014
- import * as fs17 from "node:fs";
2015
- import * as path12 from "node:path";
2218
+ import * as fs18 from "node:fs";
2219
+ import * as path13 from "node:path";
2016
2220
 
2017
2221
  // apps/pick/dist/merge-view.js
2018
- import * as fs16 from "node:fs";
2019
- import * as path11 from "node:path";
2222
+ import * as fs17 from "node:fs";
2223
+ import * as path12 from "node:path";
2020
2224
  function viewPath() {
2021
- return path11.join(lobstahHome(), "pickup", "merge-view.json");
2225
+ return path12.join(lobstahHome(), "pickup", "merge-view.json");
2022
2226
  }
2023
2227
  function readMergeView() {
2024
2228
  try {
2025
- return JSON.parse(fs16.readFileSync(viewPath(), "utf8"));
2229
+ return JSON.parse(fs17.readFileSync(viewPath(), "utf8"));
2026
2230
  } catch {
2027
2231
  return void 0;
2028
2232
  }
2029
2233
  }
2030
2234
  function writeMergeView(view) {
2031
2235
  const file = viewPath();
2032
- fs16.mkdirSync(path11.dirname(file), { recursive: true });
2236
+ fs17.mkdirSync(path12.dirname(file), { recursive: true });
2033
2237
  const tmp = `${file}.tmp-${process.pid}`;
2034
- fs16.writeFileSync(tmp, JSON.stringify(view, null, 2));
2035
- fs16.renameSync(tmp, file);
2238
+ fs17.writeFileSync(tmp, JSON.stringify(view, null, 2));
2239
+ fs17.renameSync(tmp, file);
2036
2240
  }
2037
2241
  var RECENT_RETENTION_MS = 48 * 36e5;
2038
2242
  async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
@@ -2054,7 +2258,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
2054
2258
  }
2055
2259
  function readPickupMap() {
2056
2260
  try {
2057
- const raw = JSON.parse(fs16.readFileSync(path11.join(lobstahHome(), "pickup", "state.json"), "utf8"));
2261
+ const raw = JSON.parse(fs17.readFileSync(path12.join(lobstahHome(), "pickup", "state.json"), "utf8"));
2058
2262
  return raw.map ?? {};
2059
2263
  } catch {
2060
2264
  return {};
@@ -2088,7 +2292,7 @@ function rebaseBrief(pr) {
2088
2292
  }
2089
2293
  function choreVerdict(uuid) {
2090
2294
  const d = laneDirs("chore");
2091
- if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`)))
2295
+ if (fs18.existsSync(path13.join(d.active, uuid)) || fs18.existsSync(path13.join(d.queue, `${uuid}.json`)))
2092
2296
  return "active";
2093
2297
  const last = readStatusLog(uuid, "chore").at(-1)?.verb;
2094
2298
  return last === "done" ? "done" : "failed";
@@ -2181,6 +2385,126 @@ async function mergeLoop(ms, policy, state, log = () => {
2181
2385
  await recordMergeView(ms.forgeRepo(), observed, (n) => ms.disposition(n));
2182
2386
  }
2183
2387
 
2388
+ // apps/pick/dist/loops/watch.js
2389
+ import * as fs19 from "node:fs";
2390
+ import * as path14 from "node:path";
2391
+ import { randomUUID as randomUUID3 } from "node:crypto";
2392
+ var DEFAULT_BRIEF = `You are resuming earlier work. The watched source {key} produced new events:
2393
+
2394
+ {events}
2395
+
2396
+ Read them, act on what they ask, and report the outcome.`;
2397
+ function laneOf(id) {
2398
+ for (const lane of ["work", "chore"]) {
2399
+ const d = laneDirs(lane);
2400
+ if (fs19.existsSync(path14.join(d.active, id)) || fs19.existsSync(path14.join(d.queue, `${id}.json`)) || fs19.existsSync(path14.join(d.done, id)) || fs19.existsSync(path14.join(d.state, `${id}.status`))) {
2401
+ return lane;
2402
+ }
2403
+ }
2404
+ return void 0;
2405
+ }
2406
+ function descriptorOf(id, lane) {
2407
+ const d = laneDirs(lane);
2408
+ for (const bucket of ["active", "done"]) {
2409
+ const f = path14.join(d[bucket], id, "descriptor.json");
2410
+ if (fs19.existsSync(f))
2411
+ return JSON.parse(fs19.readFileSync(f, "utf8"));
2412
+ }
2413
+ const q = path14.join(d.queue, `${id}.json`);
2414
+ if (fs19.existsSync(q))
2415
+ return JSON.parse(fs19.readFileSync(q, "utf8"));
2416
+ return void 0;
2417
+ }
2418
+ function isTerminal(id) {
2419
+ const lane = laneOf(id);
2420
+ if (!lane)
2421
+ return true;
2422
+ const state = reconcile({ log: readStatusLog(id, lane), lastEventAt: lastEventAt(id, lane) });
2423
+ return state === "done" || state === "failed";
2424
+ }
2425
+ function spawnContinuation(w, pending, log) {
2426
+ const owner = w.owner.slice("dispatch:".length);
2427
+ const target = w.lastFollowUpId && !laneOf(w.lastFollowUpId) ? owner : w.lastFollowUpId ?? owner;
2428
+ const targetLane = laneOf(target);
2429
+ if (!targetLane) {
2430
+ log(`watch ${w.key}: owner dispatch ${target} is gone \u2014 dropping watch`);
2431
+ removeWatch(w.key);
2432
+ return;
2433
+ }
2434
+ const descriptor = descriptorOf(target, targetLane);
2435
+ if (!descriptor) {
2436
+ log(`watch ${w.key}: no descriptor for ${target} \u2014 dropping watch`);
2437
+ removeWatch(w.key);
2438
+ return;
2439
+ }
2440
+ const id = randomUUID3();
2441
+ const brief = (w.brief ?? DEFAULT_BRIEF).replaceAll("{key}", w.key).replaceAll("{events}", JSON.stringify(pending, null, 2));
2442
+ enqueue({ id, repo: descriptor.repo, brief, followUp: target }, "work");
2443
+ markFollowUp(w.key, id, readWatchEvents(w.key).length);
2444
+ log(`watch ${w.key}: ${pending.length} event(s) \u2192 continuation ${id} (forks ${target})`);
2445
+ }
2446
+ function notifyMan(watch2, fresh, notify) {
2447
+ if (fresh.length === 0 || watch2.owner !== "man")
2448
+ return;
2449
+ notify({
2450
+ key: watch2.key,
2451
+ uuid: "watch",
2452
+ verb: "watch",
2453
+ note: fresh[0]?.summary ?? `${fresh.length} new event(s)`
2454
+ });
2455
+ }
2456
+ function deliverDispatchOwned(log) {
2457
+ for (const { watch: watch2, events } of pendingWatchEvents(false, "dispatch")) {
2458
+ if (watch2.lastFollowUpId && !isTerminal(watch2.lastFollowUpId))
2459
+ continue;
2460
+ spawnContinuation(watch2, events, log);
2461
+ }
2462
+ for (const w of listWatches()) {
2463
+ if (w.done && w.owner !== "man" && readWatchEvents(w.key).length <= w.seen)
2464
+ removeWatch(w.key);
2465
+ }
2466
+ }
2467
+ async function watchLoop(defaultEverySecs, log, notify = () => {
2468
+ }) {
2469
+ for (const w of listWatches()) {
2470
+ if (watchDue(w, defaultEverySecs)) {
2471
+ const { watch: watch2, fresh } = runWatchCheck(w);
2472
+ if (watch2.lastError)
2473
+ log(`watch ${w.key}: check failed \u2014 ${watch2.lastError}`);
2474
+ notifyMan(watch2, fresh, notify);
2475
+ }
2476
+ }
2477
+ deliverDispatchOwned(log);
2478
+ }
2479
+ function handleStreamLine(key, line, log, notify) {
2480
+ const watch2 = readWatch(key);
2481
+ if (!watch2)
2482
+ return;
2483
+ let parsed;
2484
+ try {
2485
+ parsed = JSON.parse(line);
2486
+ } catch {
2487
+ log(`watch ${key}: unparseable stream line \u2014 ${line.slice(0, 120)}`);
2488
+ return;
2489
+ }
2490
+ if (parsed.seq === void 0) {
2491
+ if (parsed.cursor !== void 0)
2492
+ setWatchCursor(key, String(parsed.cursor));
2493
+ return;
2494
+ }
2495
+ const event = {
2496
+ seq: parsed.seq,
2497
+ summary: parsed.summary !== void 0 ? String(parsed.summary) : void 0,
2498
+ ...parsed,
2499
+ at: (/* @__PURE__ */ new Date()).toISOString()
2500
+ };
2501
+ const fresh = appendWatchEvents(key, event.seq !== void 0 ? [event] : []);
2502
+ setWatchCursor(key, String(parsed.cursor ?? event.seq));
2503
+ notifyMan(watch2, fresh, notify);
2504
+ if (fresh.length > 0)
2505
+ deliverDispatchOwned(log);
2506
+ }
2507
+
2184
2508
  // apps/pick/dist/run.js
2185
2509
  function makeNotifier(command, log) {
2186
2510
  if (!command)
@@ -2204,7 +2528,49 @@ function makeNotifier(command, log) {
2204
2528
  child.unref();
2205
2529
  };
2206
2530
  }
2207
- async function cycle(sources, merges, state, log, notify) {
2531
+ var SingleFlight = class {
2532
+ chain = Promise.resolve();
2533
+ run(job) {
2534
+ const next = this.chain.then(job, job);
2535
+ this.chain = next.then(() => {
2536
+ }, () => {
2537
+ });
2538
+ return next;
2539
+ }
2540
+ };
2541
+ function syncStreams(children, flight, log, notify) {
2542
+ for (const w of listWatches()) {
2543
+ if (!w.stream || children.has(w.key))
2544
+ continue;
2545
+ const cmd = w.stream.replaceAll("{cursor}", w.cursor);
2546
+ const child = spawn3(cmd, { shell: true, stdio: ["ignore", "pipe", "ignore"] });
2547
+ children.set(w.key, child);
2548
+ log(`watch ${w.key}: stream attached`);
2549
+ let buffer = "";
2550
+ child.stdout.on("data", (chunk) => {
2551
+ buffer += chunk.toString("utf8");
2552
+ const lines = buffer.split("\n");
2553
+ buffer = lines.pop() ?? "";
2554
+ for (const line of lines) {
2555
+ if (!line.trim())
2556
+ continue;
2557
+ void flight.run(() => handleStreamLine(w.key, line, log, notify));
2558
+ }
2559
+ });
2560
+ child.on("exit", () => {
2561
+ children.delete(w.key);
2562
+ log(`watch ${w.key}: stream ended \u2014 cadence covers until resync`);
2563
+ });
2564
+ child.on("error", () => children.delete(w.key));
2565
+ }
2566
+ for (const [key, child] of children) {
2567
+ if (!readWatch(key)?.stream) {
2568
+ child.kill();
2569
+ children.delete(key);
2570
+ }
2571
+ }
2572
+ }
2573
+ async function cycle(sources, merges, state, pollSecs, log, notify) {
2208
2574
  for (const source of sources) {
2209
2575
  try {
2210
2576
  await dispatchLoop(source, state, log);
@@ -2221,6 +2587,11 @@ async function cycle(sources, merges, state, log, notify) {
2221
2587
  log(`${source.name} merge: ${err instanceof Error ? err.message : String(err)}`);
2222
2588
  }
2223
2589
  }
2590
+ try {
2591
+ await watchLoop(pollSecs, log, notify);
2592
+ } catch (err) {
2593
+ log(`watches: ${err instanceof Error ? err.message : String(err)}`);
2594
+ }
2224
2595
  }
2225
2596
  async function runPickup(mode = "daemon") {
2226
2597
  ensureLayout();
@@ -2237,16 +2608,22 @@ async function runPickup(mode = "daemon") {
2237
2608
  if (cfg.linear)
2238
2609
  sources.push(new LinearSource(cfg.linear));
2239
2610
  if (sources.length === 0) {
2240
- throw new Error("no [pickup.github] or [pickup.linear] configured \u2014 nothing to poll");
2611
+ if (listWatches().length === 0) {
2612
+ throw new Error("no [pickup.github], [pickup.linear], or registered watches \u2014 nothing to poll");
2613
+ }
2614
+ log("no tracker sources configured \u2014 watch-only mode");
2241
2615
  }
2242
2616
  const notify = makeNotifier(cfg.notifyCommand, log);
2243
2617
  if (mode === "once") {
2244
- await cycle(sources, merges, state, log, notify);
2618
+ await cycle(sources, merges, state, cfg.pollSecs, log, notify);
2245
2619
  return;
2246
2620
  }
2247
2621
  log(`polling every ${cfg.pollSecs}s \u2014 no webhooks, no inbound surface`);
2622
+ const flight = new SingleFlight();
2623
+ const streamChildren = /* @__PURE__ */ new Map();
2248
2624
  while (true) {
2249
- await cycle(sources, merges, state, log, notify);
2625
+ await flight.run(() => cycle(sources, merges, state, cfg.pollSecs, log, notify));
2626
+ syncStreams(streamChildren, flight, log, notify);
2250
2627
  await new Promise((r) => setTimeout(r, cfg.pollSecs * 1e3));
2251
2628
  }
2252
2629
  }
@@ -2265,21 +2642,21 @@ function mergeHaulHook(existing) {
2265
2642
  }
2266
2643
 
2267
2644
  // apps/cli/src/tend.ts
2268
- import * as fs18 from "node:fs";
2269
- import * as path13 from "node:path";
2645
+ import * as fs20 from "node:fs";
2646
+ import * as path15 from "node:path";
2270
2647
  var HEARTBEAT_STALE_MS = 9e4;
2271
2648
  var CLAIM_STALE_MS = 12e4;
2272
2649
  var DAY_MS = 24 * 36e5;
2273
2650
  function readJson(file) {
2274
2651
  try {
2275
- return JSON.parse(fs18.readFileSync(file, "utf8"));
2652
+ return JSON.parse(fs20.readFileSync(file, "utf8"));
2276
2653
  } catch {
2277
2654
  return void 0;
2278
2655
  }
2279
2656
  }
2280
2657
  function doneIds(lane) {
2281
2658
  try {
2282
- return fs18.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2659
+ return fs20.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2283
2660
  } catch {
2284
2661
  return [];
2285
2662
  }
@@ -2298,16 +2675,16 @@ function followUps(uuid) {
2298
2675
  const desc = readJson(file);
2299
2676
  if (desc?.followUp === uuid) out.push({ id, bucket });
2300
2677
  };
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"));
2678
+ for (const id of pendingIds("work")) scan(id, "queued", path15.join(d.queue, `${id}.json`));
2679
+ for (const id of activeIds("work")) scan(id, "active", path15.join(d.active, id, "descriptor.json"));
2680
+ for (const id of doneIds("work")) scan(id, "done", path15.join(d.done, id, "descriptor.json"));
2304
2681
  return out;
2305
2682
  }
2306
2683
  function bucketOf(uuid) {
2307
2684
  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";
2685
+ if (fs20.existsSync(path15.join(d.active, uuid))) return "active";
2686
+ if (fs20.existsSync(path15.join(d.done, uuid))) return "done";
2687
+ if (fs20.existsSync(path15.join(d.queue, `${uuid}.json`))) return "queued";
2311
2688
  return void 0;
2312
2689
  }
2313
2690
  function buildTendReport(now = Date.now()) {
@@ -2335,8 +2712,39 @@ function buildTendReport(now = Date.now()) {
2335
2712
  ageSecs: Math.max(0, Math.round((now - Date.parse(ev.entry.at)) / 1e3)),
2336
2713
  note: ev.entry.note
2337
2714
  }));
2715
+ const watches = [];
2716
+ const watchByDispatch = /* @__PURE__ */ new Map();
2717
+ for (const w of listWatches()) {
2718
+ const events = readWatchEvents(w.key);
2719
+ const pending = events.slice(w.seen);
2720
+ const last = events.at(-1);
2721
+ watches.push({
2722
+ key: w.key,
2723
+ owner: w.owner,
2724
+ cursor: w.cursor,
2725
+ pendingEvents: pending.length,
2726
+ lastSummary: last?.summary,
2727
+ lastAt: last?.at,
2728
+ error: w.lastError
2729
+ });
2730
+ if (w.owner.startsWith("dispatch:")) {
2731
+ const label = `${w.key}${pending.length > 0 ? ` (${pending.length} pending)` : ""}`;
2732
+ watchByDispatch.set(w.owner.slice("dispatch:".length), label);
2733
+ if (w.lastFollowUpId) watchByDispatch.set(w.lastFollowUpId, label);
2734
+ }
2735
+ if (w.owner === "man" && pending.length > 0) {
2736
+ const oldest = pending[0];
2737
+ attention.push({
2738
+ id: w.key,
2739
+ lane: "work",
2740
+ verb: "watch",
2741
+ ageSecs: oldest?.at ? Math.max(0, Math.round((now - Date.parse(oldest.at)) / 1e3)) : 0,
2742
+ note: pending.at(-1)?.summary
2743
+ });
2744
+ }
2745
+ }
2338
2746
  const oldestQueuedAge = queued.reduce((max, id) => {
2339
- const st = fs18.statSync(path13.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2747
+ const st = fs20.statSync(path15.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2340
2748
  return st ? Math.max(max, now - st.mtimeMs) : max;
2341
2749
  }, 0);
2342
2750
  const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
@@ -2358,12 +2766,13 @@ function buildTendReport(now = Date.now()) {
2358
2766
  const fresh = chain.some((d) => d.bucket !== "done" || d.at !== void 0 && now - Date.parse(d.at) < DAY_MS);
2359
2767
  if (!fresh) continue;
2360
2768
  const prUrl = chain.map((d) => d.prUrl).find((u) => u !== void 0);
2361
- stories.push({ key, dispatches: chain, prUrl, gate: gateFor(entry.uuid, prUrl) });
2769
+ const watch2 = chain.map((d) => watchByDispatch.get(d.id)).find((w) => w !== void 0);
2770
+ stories.push({ key, dispatches: chain, prUrl, gate: gateFor(entry.uuid, prUrl), watch: watch2 });
2362
2771
  }
2363
2772
  for (const id of [...active, ...queued]) {
2364
2773
  if (storied.has(id)) continue;
2365
2774
  const d = describeDispatch(id, "work", bucketOf(id) ?? "active");
2366
- stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl) });
2775
+ stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl), watch: watchByDispatch.get(id) });
2367
2776
  }
2368
2777
  const verdict = !daemonUp ? "daemon-down" : stalled ? "stalled" : attention.length > 0 ? "needs-attention" : active.length + queued.length > 0 ? "working" : "idle";
2369
2778
  return {
@@ -2372,6 +2781,7 @@ function buildTendReport(now = Date.now()) {
2372
2781
  counts: { queued: queued.length, active: active.length, choresActive, done24h, failed24h },
2373
2782
  attention,
2374
2783
  stories,
2784
+ watches,
2375
2785
  merge
2376
2786
  };
2377
2787
  }
@@ -2407,9 +2817,26 @@ function renderTend(r) {
2407
2817
  key: s.key,
2408
2818
  dispatches: s.dispatches.map((d) => `${d.id.slice(0, 8)}:${d.state}`).join(" \u2192 "),
2409
2819
  pr: s.prUrl ?? "",
2410
- gate: s.gate ?? ""
2820
+ gate: s.gate ?? "",
2821
+ watch: s.watch ?? ""
2411
2822
  })),
2412
- ["key", "dispatches", "pr", "gate"]
2823
+ ["key", "dispatches", "pr", "gate", "watch"]
2824
+ )
2825
+ );
2826
+ }
2827
+ if (r.watches.length > 0) {
2828
+ lines.push("");
2829
+ lines.push(
2830
+ toonTable(
2831
+ "watches",
2832
+ r.watches.map((w) => ({
2833
+ key: w.key,
2834
+ owner: w.owner,
2835
+ pending: w.pendingEvents,
2836
+ last: w.lastSummary ?? "",
2837
+ error: w.error ?? ""
2838
+ })),
2839
+ ["key", "owner", "pending", "last", "error"]
2413
2840
  )
2414
2841
  );
2415
2842
  }
@@ -2430,13 +2857,13 @@ function renderTend(r) {
2430
2857
  }
2431
2858
 
2432
2859
  // apps/cli/src/cull.ts
2433
- import * as fs19 from "node:fs";
2434
- import * as path14 from "node:path";
2860
+ import * as fs21 from "node:fs";
2861
+ import * as path16 from "node:path";
2435
2862
  import { execFileSync as execFileSync2 } from "node:child_process";
2436
2863
  var DAY = 864e5;
2437
2864
  function idsIn(dir) {
2438
2865
  try {
2439
- return new Set(fs19.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
2866
+ return new Set(fs21.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
2440
2867
  } catch {
2441
2868
  return /* @__PURE__ */ new Set();
2442
2869
  }
@@ -2451,39 +2878,39 @@ function planCull(olderThanDays, now = Date.now()) {
2451
2878
  for (const id of idsIn(d.queue)) live.add(id);
2452
2879
  for (const id of idsIn(d.active)) live.add(id);
2453
2880
  for (const id of idsIn(d.done)) {
2454
- const p = path14.join(d.done, id);
2455
- const m = fs19.statSync(p).mtimeMs;
2881
+ const p = path16.join(d.done, id);
2882
+ const m = fs21.statSync(p).mtimeMs;
2456
2883
  doneMtimes.set(id, m);
2457
2884
  if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
2458
2885
  }
2459
2886
  }
2460
- const wtRoot = path14.join(lobstahHome(), "worktrees");
2887
+ const wtRoot = path16.join(lobstahHome(), "worktrees");
2461
2888
  for (const id of idsIn(wtRoot)) {
2462
2889
  if (live.has(id)) continue;
2463
2890
  const doneAt = doneMtimes.get(id);
2464
2891
  if (doneAt !== void 0 && doneAt >= cutoff) continue;
2465
- const p = path14.join(wtRoot, id);
2466
- items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs19.statSync(p).mtimeMs) / DAY) });
2892
+ const p = path16.join(wtRoot, id);
2893
+ items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs21.statSync(p).mtimeMs) / DAY) });
2467
2894
  }
2468
2895
  for (const lane of ["work", "chore"]) {
2469
2896
  const d = laneDirs(lane);
2470
2897
  const seen = /* @__PURE__ */ new Set();
2471
- for (const f of fs19.readdirSync(d.state)) {
2898
+ for (const f of fs21.readdirSync(d.state)) {
2472
2899
  const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
2473
2900
  if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
2474
- const m = fs19.statSync(path14.join(d.state, f)).mtimeMs;
2901
+ const m = fs21.statSync(path16.join(d.state, f)).mtimeMs;
2475
2902
  if (m >= cutoff) continue;
2476
2903
  seen.add(id);
2477
- items.push({ kind: "state", id, target: path14.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
2904
+ items.push({ kind: "state", id, target: path16.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
2478
2905
  }
2479
2906
  }
2480
2907
  return items;
2481
2908
  }
2482
2909
  function removeWorktree(id, dir) {
2483
2910
  for (const lane of ["work", "chore"]) {
2484
- const descFile = path14.join(laneDirs(lane).done, id, "descriptor.json");
2911
+ const descFile = path16.join(laneDirs(lane).done, id, "descriptor.json");
2485
2912
  try {
2486
- const desc = JSON.parse(fs19.readFileSync(descFile, "utf8"));
2913
+ const desc = JSON.parse(fs21.readFileSync(descFile, "utf8"));
2487
2914
  const repo = loadConfig().repos[desc.repo];
2488
2915
  if (repo) {
2489
2916
  execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
@@ -2492,15 +2919,15 @@ function removeWorktree(id, dir) {
2492
2919
  } catch {
2493
2920
  }
2494
2921
  }
2495
- fs19.rmSync(dir, { recursive: true, force: true });
2922
+ fs21.rmSync(dir, { recursive: true, force: true });
2496
2923
  }
2497
2924
  function applyCull(items) {
2498
2925
  for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
2499
- for (const item of items.filter((i) => i.kind === "done")) fs19.rmSync(item.target, { recursive: true, force: true });
2926
+ for (const item of items.filter((i) => i.kind === "done")) fs21.rmSync(item.target, { recursive: true, force: true });
2500
2927
  for (const item of items.filter((i) => i.kind === "state")) {
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 });
2928
+ const dir = path16.dirname(item.target);
2929
+ for (const f of fs21.readdirSync(dir)) {
2930
+ if (f.startsWith(`${item.id}.`)) fs21.rmSync(path16.join(dir, f), { force: true });
2504
2931
  }
2505
2932
  }
2506
2933
  }
@@ -2526,6 +2953,10 @@ your working set:
2526
2953
  lobstah man tend [--json] tend the string: fleet verdict,
2527
2954
  unanswered questions, each work
2528
2955
  item's chain + PR + merge gate
2956
+ lobstah watch add <key> --check <cmd> stand watch on something
2957
+ external (a review session, a
2958
+ CI run) \u2014 its events wake you
2959
+ like any dispatch would
2529
2960
 
2530
2961
  getting woken instead of asking:
2531
2962
  lobstah man wait block until a dispatch needs attention (arm it as a
@@ -2545,12 +2976,11 @@ paste into your liaison instructions:
2545
2976
  full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
2546
2977
 
2547
2978
  // 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";
2979
+ import * as fs22 from "node:fs";
2980
+ import { spawnSync as spawnSync2 } from "node:child_process";
2551
2981
  var HEARTBEAT_STALE_MS2 = 9e4;
2552
2982
  function git(repoPath, ...args) {
2553
- const res = spawnSync("git", ["-C", repoPath, ...args], { encoding: "utf8" });
2983
+ const res = spawnSync2("git", ["-C", repoPath, ...args], { encoding: "utf8" });
2554
2984
  return { ok: res.status === 0, out: (res.stdout ?? "").trim() || (res.stderr ?? "").trim() };
2555
2985
  }
2556
2986
  function runDoctor(now = Date.now()) {
@@ -2562,25 +2992,20 @@ function runDoctor(now = Date.now()) {
2562
2992
  const claude = onPath("claude");
2563
2993
  push("harness claude", claude ? "ok" : "warn", claude ? "on PATH" : "claude not on PATH \u2014 claude dispatches will fail");
2564
2994
  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
- }
2995
+ const codexSdk = packagePresent("@openai/codex-sdk");
2571
2996
  push(
2572
2997
  "harness codex",
2573
2998
  codexPath || codexSdk ? "ok" : "warn",
2574
2999
  codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
2575
3000
  );
2576
3001
  const cfgFile = configPath();
2577
- if (!fs20.existsSync(cfgFile)) {
3002
+ if (!fs22.existsSync(cfgFile)) {
2578
3003
  push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
2579
3004
  return rows;
2580
3005
  }
2581
3006
  let parsed;
2582
3007
  try {
2583
- parsed = parse(fs20.readFileSync(cfgFile, "utf8"));
3008
+ parsed = parse(fs22.readFileSync(cfgFile, "utf8"));
2584
3009
  push("config", "ok", cfgFile);
2585
3010
  } catch (err) {
2586
3011
  push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
@@ -2591,7 +3016,7 @@ function runDoctor(now = Date.now()) {
2591
3016
  if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
2592
3017
  for (const [key, repo] of Object.entries(cfg.repos)) {
2593
3018
  const label = `repo ${key}`;
2594
- if (!fs20.existsSync(repo.path)) {
3019
+ if (!fs22.existsSync(repo.path)) {
2595
3020
  push(label, "fail", `path ${repo.path} does not exist`);
2596
3021
  continue;
2597
3022
  }
@@ -2619,11 +3044,11 @@ function runDoctor(now = Date.now()) {
2619
3044
  }
2620
3045
  }
2621
3046
  const hb = executorPath();
2622
- if (!fs20.existsSync(hb)) {
3047
+ if (!fs22.existsSync(hb)) {
2623
3048
  push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
2624
3049
  } else {
2625
3050
  try {
2626
- const payload = JSON.parse(fs20.readFileSync(hb, "utf8"));
3051
+ const payload = JSON.parse(fs22.readFileSync(hb, "utf8"));
2627
3052
  const age = now - new Date(payload.heartbeat ?? 0).getTime();
2628
3053
  push(
2629
3054
  "daemon",
@@ -2639,10 +3064,10 @@ function runDoctor(now = Date.now()) {
2639
3064
  }
2640
3065
 
2641
3066
  // apps/cli/src/service.ts
2642
- import * as fs21 from "node:fs";
3067
+ import * as fs23 from "node:fs";
2643
3068
  import * as os5 from "node:os";
2644
- import * as path15 from "node:path";
2645
- import { spawnSync as spawnSync2 } from "node:child_process";
3069
+ import * as path17 from "node:path";
3070
+ import { spawnSync as spawnSync3 } from "node:child_process";
2646
3071
  function xml(s) {
2647
3072
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2648
3073
  }
@@ -2665,8 +3090,8 @@ function renderLaunchdPlist(spec) {
2665
3090
  </dict>
2666
3091
  <key>RunAtLoad</key><true/>
2667
3092
  <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>
3093
+ <key>StandardOutPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
3094
+ <key>StandardErrorPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
2670
3095
  </dict>
2671
3096
  </plist>
2672
3097
  `;
@@ -2681,8 +3106,8 @@ Restart=always
2681
3106
  RestartSec=5
2682
3107
  Environment=PATH=${spec.pathEnv}
2683
3108
  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`)}
3109
+ StandardOutput=append:${path17.posix.join(spec.logDir, `${spec.kind}.log`)}
3110
+ StandardError=append:${path17.posix.join(spec.logDir, `${spec.kind}.err`)}
2686
3111
 
2687
3112
  [Install]
2688
3113
  WantedBy=default.target
@@ -2690,45 +3115,48 @@ WantedBy=default.target
2690
3115
  }
2691
3116
  function servicePathEnv(nodePath, home = os5.homedir()) {
2692
3117
  const dirs = [
2693
- path15.dirname(nodePath),
2694
- path15.join(home, ".local", "bin"),
3118
+ path17.posix.dirname(nodePath),
3119
+ path17.posix.join(home, ".local", "bin"),
2695
3120
  "/opt/homebrew/bin",
2696
3121
  "/usr/local/bin",
2697
3122
  "/usr/bin",
2698
3123
  "/bin"
2699
3124
  ];
2700
- return [...new Set(dirs)].join(path15.delimiter);
3125
+ return [...new Set(dirs)].join(":");
2701
3126
  }
2702
3127
  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`);
3128
+ return process.platform === "darwin" ? path17.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path17.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
2704
3129
  }
2705
3130
  function run(cmd, args) {
2706
- const res = spawnSync2(cmd, args, { encoding: "utf8" });
3131
+ const res = spawnSync3(cmd, args, { encoding: "utf8" });
2707
3132
  return { ok: res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
2708
3133
  }
2709
3134
  function installService(kind) {
2710
- const entry = fs21.realpathSync(process.argv[1]);
3135
+ if (process.platform === "win32") {
3136
+ throw new Error(`no service manager support on Windows \u2014 run \`lobstah ${kind}\` under your process manager of choice`);
3137
+ }
3138
+ const entry = fs23.realpathSync(process.argv[1]);
2711
3139
  const spec = {
2712
3140
  kind,
2713
3141
  nodePath: process.execPath,
2714
3142
  entry,
2715
3143
  pathEnv: servicePathEnv(process.execPath),
2716
3144
  home: lobstahHome(),
2717
- logDir: path15.join(lobstahHome(), "logs")
3145
+ logDir: path17.join(lobstahHome(), "logs")
2718
3146
  };
2719
3147
  if (!onPath("git", spec.pathEnv)) {
2720
3148
  throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
2721
3149
  }
2722
- fs21.mkdirSync(spec.logDir, { recursive: true });
3150
+ fs23.mkdirSync(spec.logDir, { recursive: true });
2723
3151
  const file = serviceFile(kind);
2724
- fs21.mkdirSync(path15.dirname(file), { recursive: true });
3152
+ fs23.mkdirSync(path17.dirname(file), { recursive: true });
2725
3153
  if (process.platform === "darwin") {
2726
- if (fs21.existsSync(file)) run("launchctl", ["unload", file]);
2727
- fs21.writeFileSync(file, renderLaunchdPlist(spec));
3154
+ if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
3155
+ fs23.writeFileSync(file, renderLaunchdPlist(spec));
2728
3156
  const load = run("launchctl", ["load", file]);
2729
3157
  return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
2730
3158
  }
2731
- fs21.writeFileSync(file, renderSystemdUnit(spec));
3159
+ fs23.writeFileSync(file, renderSystemdUnit(spec));
2732
3160
  run("systemctl", ["--user", "daemon-reload"]);
2733
3161
  const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
2734
3162
  return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
@@ -2736,22 +3164,22 @@ function installService(kind) {
2736
3164
  function uninstallService(kind) {
2737
3165
  const file = serviceFile(kind);
2738
3166
  if (process.platform === "darwin") {
2739
- if (fs21.existsSync(file)) run("launchctl", ["unload", file]);
3167
+ if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
2740
3168
  } else {
2741
3169
  run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
2742
3170
  }
2743
- const removed = fs21.existsSync(file);
2744
- if (removed) fs21.unlinkSync(file);
3171
+ const removed = fs23.existsSync(file);
3172
+ if (removed) fs23.unlinkSync(file);
2745
3173
  if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
2746
3174
  return { file, removed };
2747
3175
  }
2748
3176
 
2749
3177
  // 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";
3178
+ import * as fs24 from "node:fs";
3179
+ import * as path18 from "node:path";
3180
+ import { spawnSync as spawnSync4 } from "node:child_process";
2753
3181
  function git2(dir, ...args) {
2754
- const res = spawnSync3("git", ["-C", dir, ...args], { encoding: "utf8" });
3182
+ const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
2755
3183
  return res.status === 0 ? res.stdout.trim() : void 0;
2756
3184
  }
2757
3185
  var LOCKFILE_SETUP = [
@@ -2764,37 +3192,41 @@ var LOCKFILE_SETUP = [
2764
3192
  ["poetry.lock", ["poetry install"]]
2765
3193
  ];
2766
3194
  function detectRepo(dir) {
2767
- const abs = path16.resolve(dir);
3195
+ const abs = path18.resolve(dir);
2768
3196
  if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
2769
3197
  const top = git2(abs, "rev-parse", "--show-toplevel");
2770
- if (!top || fs22.realpathSync(top) !== fs22.realpathSync(abs)) return void 0;
3198
+ const canon = (p) => {
3199
+ const real = path18.resolve(fs24.realpathSync.native(p));
3200
+ return process.platform === "win32" ? real.toLowerCase() : real;
3201
+ };
3202
+ if (!top || canon(top) !== canon(abs)) return void 0;
2771
3203
  const origin = git2(abs, "remote", "get-url", "origin");
2772
3204
  const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
2773
3205
  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 };
3206
+ const setup = LOCKFILE_SETUP.find(([f]) => fs24.existsSync(path18.join(abs, f)))?.[1];
3207
+ return { key: path18.basename(abs), path: abs, trunk, origin, setup };
2776
3208
  }
2777
3209
  function scanForRepos(roots) {
2778
3210
  const found = [];
2779
3211
  for (const root of roots) {
2780
- const abs = path16.resolve(root);
2781
- if (!fs22.existsSync(abs)) continue;
3212
+ const abs = path18.resolve(root);
3213
+ if (!fs24.existsSync(abs)) continue;
2782
3214
  const self = detectRepo(abs);
2783
3215
  if (self) {
2784
3216
  found.push(self);
2785
3217
  continue;
2786
3218
  }
2787
- for (const entry of fs22.readdirSync(abs, { withFileTypes: true })) {
3219
+ for (const entry of fs24.readdirSync(abs, { withFileTypes: true })) {
2788
3220
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
2789
- const child = detectRepo(path16.join(abs, entry.name));
3221
+ const child = detectRepo(path18.join(abs, entry.name));
2790
3222
  if (child) found.push(child);
2791
3223
  }
2792
3224
  }
2793
3225
  return found;
2794
3226
  }
2795
3227
  function configuredRepoKeys(file = configPath()) {
2796
- if (!fs22.existsSync(file)) return /* @__PURE__ */ new Set();
2797
- const raw = parse(fs22.readFileSync(file, "utf8"));
3228
+ if (!fs24.existsSync(file)) return /* @__PURE__ */ new Set();
3229
+ const raw = parse(fs24.readFileSync(file, "utf8"));
2798
3230
  return new Set(Object.keys(raw.repos ?? {}));
2799
3231
  }
2800
3232
  function appendRepoBlock(repo, opts = {}, file = configPath()) {
@@ -2802,10 +3234,18 @@ function appendRepoBlock(repo, opts = {}, file = configPath()) {
2802
3234
  if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
2803
3235
  if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
2804
3236
  if (opts.pickup) lines.push(`pickup = true`);
2805
- fs22.appendFileSync(file, `${lines.join("\n")}
3237
+ fs24.appendFileSync(file, `${lines.join("\n")}
2806
3238
  `);
2807
3239
  }
2808
3240
 
3241
+ // apps/cli/src/report-args.ts
3242
+ function parseReportArgs(rest) {
3243
+ const prIndex = rest.indexOf("--pr");
3244
+ const prUrl = prIndex >= 0 ? rest[prIndex + 1] : void 0;
3245
+ const note = rest.filter((_, i) => prIndex < 0 || i !== prIndex && i !== prIndex + 1).join(" ") || void 0;
3246
+ return { note, prUrl };
3247
+ }
3248
+
2809
3249
  // apps/cli/src/main.ts
2810
3250
  var HELP = `lobstah \u2014 supervision framework for coding agents
2811
3251
 
@@ -2833,6 +3273,16 @@ work (humans and agents):
2833
3273
  entries, orphaned worktrees, stale state.
2834
3274
  Dry run by default (14 days).
2835
3275
  cancel <uuid> request cancellation
3276
+ watch [add <key> --check <cmd> [--for <uuid>] [--cursor <c>] [--every <s>]
3277
+ [--brief <template>] [--stream <cmd>] | rm <key>]
3278
+ stand watch on something external: the check
3279
+ command answers "anything since {cursor}?"
3280
+ in JSON. Events wake man wait/haul (default)
3281
+ or fork a continuation of --for's dispatch
3282
+ chain. --stream holds a long-lived NDJSON
3283
+ child under pick for ms-latency delivery
3284
+ (the check stays the guarantee). Bare
3285
+ \`watch\` lists.
2836
3286
 
2837
3287
  host processes:
2838
3288
  daemon [--interval <ms>] the supervisor: claims, worktrees, liveness,
@@ -2850,10 +3300,12 @@ lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual)
2850
3300
  PR, and merge-gate status from pick's last
2851
3301
  observation. Pure disk read \u2014 no forge calls.
2852
3302
  man wait [--timeout <secs>] [--peek]
2853
- block until a dispatch needs attention, then
2854
- print the event and what to do next; exit 2
2855
- on timeout. Unanswered questions re-fire
2856
- every remindSecs until answered.
3303
+ block until a dispatch or watched source
3304
+ needs attention, then print the event and
3305
+ what to do next; exit 2 on timeout. Runs due
3306
+ watch checks itself when no pick process is.
3307
+ Unanswered questions re-fire every
3308
+ remindSecs until answered.
2857
3309
  man init [--shared|--global] [--marker]
2858
3310
  install the haul Stop hook: this project's
2859
3311
  .claude/settings.local.json by default,
@@ -2890,10 +3342,26 @@ function arg(args, flag) {
2890
3342
  const i = args.indexOf(flag);
2891
3343
  return i >= 0 ? args[i + 1] : void 0;
2892
3344
  }
3345
+ var WATCH_EVERY_SECS = 45;
3346
+ function runDueManWatches() {
3347
+ for (const w of listWatches()) {
3348
+ if (w.owner === "man" && watchDue(w, WATCH_EVERY_SECS)) runWatchCheck(w);
3349
+ }
3350
+ }
3351
+ function emitWatchAttention(attns) {
3352
+ for (const a of attns) {
3353
+ for (const e of a.events) {
3354
+ console.log(toonKV({ watch: a.watch.key, seq: e.seq, summary: e.summary }));
3355
+ }
3356
+ }
3357
+ console.log(
3358
+ "next: handle the watched update now (a review round, a finished run \u2014 `lobstah watch ls` for context), then re-arm a background `lobstah man wait`."
3359
+ );
3360
+ }
2893
3361
  function findLane(id) {
2894
3362
  for (const lane of ["work", "chore"]) {
2895
3363
  const d = laneDirs(lane);
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`))) {
3364
+ if (fs25.existsSync(path19.join(d.active, id)) || fs25.existsSync(path19.join(d.queue, `${id}.json`)) || fs25.existsSync(path19.join(d.done, id)) || fs25.existsSync(path19.join(d.state, `${id}.status`))) {
2897
3365
  return lane;
2898
3366
  }
2899
3367
  }
@@ -2901,7 +3369,7 @@ function findLane(id) {
2901
3369
  }
2902
3370
  function rowsFor(lane, bucket) {
2903
3371
  const dir = laneDirs(lane)[bucket];
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);
3372
+ const entries = fs25.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs25.statSync(path19.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
2905
3373
  const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
2906
3374
  return sliced.map(({ f, m }) => {
2907
3375
  const id = f.replace(/\.json$/, "");
@@ -2927,9 +3395,9 @@ async function mainCli() {
2927
3395
  throw new Error("dispatch requires --repo and --brief <file> (or --brief-text)");
2928
3396
  }
2929
3397
  const d = {
2930
- id: arg(args, "--id") ?? randomUUID3(),
3398
+ id: arg(args, "--id") ?? randomUUID4(),
2931
3399
  repo,
2932
- brief: briefText ?? fs23.readFileSync(briefFile, "utf8"),
3400
+ brief: briefText ?? fs25.readFileSync(briefFile, "utf8"),
2933
3401
  harness: arg(args, "--harness"),
2934
3402
  model: arg(args, "--model"),
2935
3403
  effort: arg(args, "--effort"),
@@ -2966,17 +3434,17 @@ async function mainCli() {
2966
3434
  if (!id) throw new Error("logs requires a dispatch id");
2967
3435
  const lane = findLane(id);
2968
3436
  const file = eventsPath(id, lane);
2969
- if (fs23.existsSync(file)) process.stdout.write(fs23.readFileSync(file, "utf8"));
3437
+ if (fs25.existsSync(file)) process.stdout.write(fs25.readFileSync(file, "utf8"));
2970
3438
  if (args.includes("--follow")) {
2971
- let size = fs23.existsSync(file) ? fs23.statSync(file).size : 0;
3439
+ let size = fs25.existsSync(file) ? fs25.statSync(file).size : 0;
2972
3440
  setInterval(() => {
2973
- if (!fs23.existsSync(file)) return;
2974
- const now = fs23.statSync(file).size;
3441
+ if (!fs25.existsSync(file)) return;
3442
+ const now = fs25.statSync(file).size;
2975
3443
  if (now > size) {
2976
- const fd = fs23.openSync(file, "r");
3444
+ const fd = fs25.openSync(file, "r");
2977
3445
  const buf = Buffer.alloc(now - size);
2978
- fs23.readSync(fd, buf, 0, buf.length, size);
2979
- fs23.closeSync(fd);
3446
+ fs25.readSync(fd, buf, 0, buf.length, size);
3447
+ fs25.closeSync(fd);
2980
3448
  process.stdout.write(buf.toString("utf8"));
2981
3449
  size = now;
2982
3450
  }
@@ -2997,9 +3465,7 @@ async function mainCli() {
2997
3465
  const [id, verb, ...rest] = args;
2998
3466
  if (!id || !verb) throw new Error(`report requires an id and a verb (${VERBS.join("|")})`);
2999
3467
  const lane = findLane(id);
3000
- const prIndex = rest.indexOf("--pr");
3001
- const prUrl = prIndex >= 0 ? rest[prIndex + 1] : void 0;
3002
- const note = rest.filter((_, i) => i !== prIndex && i !== prIndex + 1).join(" ") || void 0;
3468
+ const { note, prUrl } = parseReportArgs(rest);
3003
3469
  const entry = appendStatus(id, lane, verb, note);
3004
3470
  if (prUrl) mergeEvidence(id, lane, { prUrl });
3005
3471
  console.log(toonKV({ id, verb: entry.verb, at: entry.at, ...prUrl ? { prUrl } : {} }));
@@ -3032,23 +3498,23 @@ async function mainCli() {
3032
3498
  );
3033
3499
  }
3034
3500
  const dirs = laneDirs(lane);
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";
3501
+ const descriptorFile = ["active", "done"].map((b) => path19.join(dirs[b], id, "descriptor.json")).find((f) => fs25.existsSync(f));
3502
+ const harness = descriptorFile ? JSON.parse(fs25.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
3037
3503
  let sessionId;
3038
3504
  try {
3039
- sessionId = JSON.parse(fs23.readFileSync(path17.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
3505
+ sessionId = JSON.parse(fs25.readFileSync(path19.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
3040
3506
  } catch {
3041
3507
  }
3042
3508
  if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
3043
- const worktree = path17.join(lobstahHome(), "worktrees", id);
3044
- const cwd = fs23.existsSync(worktree) ? worktree : process.cwd();
3509
+ const worktree = path19.join(lobstahHome(), "worktrees", id);
3510
+ const cwd = fs25.existsSync(worktree) ? worktree : process.cwd();
3045
3511
  const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
3046
3512
  if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
3047
3513
  if (args.includes("--print")) {
3048
3514
  console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
3049
3515
  break;
3050
3516
  }
3051
- const res = spawnSync4(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
3517
+ const res = spawnSync5(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
3052
3518
  if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
3053
3519
  break;
3054
3520
  }
@@ -3056,20 +3522,20 @@ async function mainCli() {
3056
3522
  const id = args[0];
3057
3523
  if (!id) throw new Error("swap requires a dispatch id");
3058
3524
  const lane = findLane(id);
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"));
3525
+ const activeDir = path19.join(laneDirs(lane).active, id);
3526
+ if (!fs25.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
3527
+ const descFile = path19.join(activeDir, "descriptor.json");
3528
+ const descriptor = JSON.parse(fs25.readFileSync(descFile, "utf8"));
3063
3529
  const fromHarness = descriptor.harness ?? "default";
3064
3530
  for (const key of ["harness", "model", "effort"]) {
3065
3531
  const v = arg(args, `--${key}`);
3066
3532
  if (v) descriptor[key] = v;
3067
3533
  }
3068
- fs23.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
3069
- const worktree = path17.join(lobstahHome(), "worktrees", id);
3534
+ fs25.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
3535
+ const worktree = path19.join(lobstahHome(), "worktrees", id);
3070
3536
  let progress = "No worktree progress recorded.";
3071
- if (fs23.existsSync(worktree)) {
3072
- const git3 = (...a) => spawnSync4("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
3537
+ if (fs25.existsSync(worktree)) {
3538
+ const git3 = (...a) => spawnSync5("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
3073
3539
  const commits = git3("log", "--oneline", "-15");
3074
3540
  const status = git3("status", "--short");
3075
3541
  progress = `Commits so far:
@@ -3078,17 +3544,17 @@ ${commits || "(none)"}
3078
3544
  Uncommitted changes:
3079
3545
  ${status || "(clean)"}`;
3080
3546
  }
3081
- fs23.writeFileSync(
3082
- path17.join(activeDir, "handoff"),
3547
+ fs25.writeFileSync(
3548
+ path19.join(activeDir, "handoff"),
3083
3549
  `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.
3084
3550
 
3085
3551
  ${progress}`
3086
3552
  );
3087
- const runnerFile = path17.join(activeDir, "runner.json");
3088
- if (fs23.existsSync(runnerFile)) {
3089
- const runner = JSON.parse(fs23.readFileSync(runnerFile, "utf8"));
3553
+ const runnerFile = path19.join(activeDir, "runner.json");
3554
+ if (fs25.existsSync(runnerFile)) {
3555
+ const runner = JSON.parse(fs25.readFileSync(runnerFile, "utf8"));
3090
3556
  if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
3091
- fs23.unlinkSync(runnerFile);
3557
+ fs25.unlinkSync(runnerFile);
3092
3558
  }
3093
3559
  appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
3094
3560
  console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
@@ -3164,9 +3630,13 @@ ${progress}`
3164
3630
  );
3165
3631
  };
3166
3632
  const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
3167
- const standing = attentionNow(!args.includes("--peek"), remindMs);
3168
- if (standing.length > 0) {
3169
- emit(standing);
3633
+ const consume = !args.includes("--peek");
3634
+ runDueManWatches();
3635
+ const standing = attentionNow(consume, remindMs);
3636
+ const standingWatches = pendingWatchEvents(consume);
3637
+ if (standing.length > 0 || standingWatches.length > 0) {
3638
+ if (standing.length > 0) emit(standing);
3639
+ if (standingWatches.length > 0) emitWatchAttention(standingWatches);
3170
3640
  break;
3171
3641
  }
3172
3642
  const baseline = captureWaitBaseline();
@@ -3177,26 +3647,32 @@ ${progress}`
3177
3647
  emit(fresh);
3178
3648
  return;
3179
3649
  }
3650
+ runDueManWatches();
3651
+ const watched = pendingWatchEvents(true);
3652
+ if (watched.length > 0) {
3653
+ emitWatchAttention(watched);
3654
+ return;
3655
+ }
3180
3656
  }
3181
3657
  console.log(toonKV({ timeout: true, waitedSecs: timeoutSecs }));
3182
3658
  process.exitCode = 2;
3183
3659
  break;
3184
3660
  }
3185
3661
  case "man:init": {
3186
- const file = args.includes("--global") ? path17.join(os6.homedir(), ".claude", "settings.json") : path17.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
3662
+ const file = args.includes("--global") ? path19.join(os6.homedir(), ".claude", "settings.json") : path19.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
3187
3663
  let existing;
3188
3664
  try {
3189
- existing = JSON.parse(fs23.readFileSync(file, "utf8"));
3665
+ existing = JSON.parse(fs25.readFileSync(file, "utf8"));
3190
3666
  } catch {
3191
3667
  existing = void 0;
3192
3668
  }
3193
3669
  const { settings, changed } = mergeHaulHook(existing);
3194
3670
  if (changed) {
3195
- fs23.mkdirSync(path17.dirname(file), { recursive: true });
3196
- fs23.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
3671
+ fs25.mkdirSync(path19.dirname(file), { recursive: true });
3672
+ fs25.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
3197
3673
  `);
3198
3674
  }
3199
- if (args.includes("--marker")) fs23.writeFileSync(".lobstah-man", "");
3675
+ if (args.includes("--marker")) fs25.writeFileSync(".lobstah-man", "");
3200
3676
  console.log(
3201
3677
  toonKV({
3202
3678
  hook: "lobstah man haul",
@@ -3209,32 +3685,41 @@ ${progress}`
3209
3685
  }
3210
3686
  case "man:haul": {
3211
3687
  try {
3212
- if (process.env.LOBSTAH_MAN !== "1" && !fs23.existsSync(".lobstah-man")) break;
3688
+ if (process.env.LOBSTAH_MAN !== "1" && !fs25.existsSync(".lobstah-man")) break;
3213
3689
  const anyActive = ["work", "chore"].some(
3214
- (l) => fs23.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
3690
+ (l) => fs25.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
3215
3691
  );
3216
- if (!anyActive) break;
3692
+ const anyWatch = listWatches().some((w) => w.owner === "man");
3693
+ if (!anyActive && !anyWatch) break;
3217
3694
  const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
3218
3695
  const deadline = Date.now() + timeoutSecs * 1e3;
3219
3696
  const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
3697
+ runDueManWatches();
3220
3698
  let evs = attentionNow(true, remindMs);
3221
- if (evs.length === 0) {
3699
+ let watched = pendingWatchEvents(true);
3700
+ if (evs.length === 0 && watched.length === 0) {
3222
3701
  const baseline = captureWaitBaseline();
3223
3702
  while (Date.now() < deadline) {
3224
3703
  await new Promise((r) => setTimeout(r, 1500));
3225
3704
  evs = freshWakeEvents(baseline);
3226
3705
  if (evs.length === 0) evs = attentionNow(true, remindMs);
3227
- if (evs.length > 0) break;
3706
+ runDueManWatches();
3707
+ watched = pendingWatchEvents(true);
3708
+ if (evs.length > 0 || watched.length > 0) break;
3228
3709
  }
3229
3710
  }
3230
- if (evs.length === 0) break;
3231
- const lines = evs.map(
3232
- (ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`
3233
- );
3711
+ if (evs.length === 0 && watched.length === 0) break;
3712
+ const lines = [
3713
+ ...evs.map((ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`),
3714
+ ...watched.flatMap(
3715
+ (a) => a.events.map((e) => `- watch ${a.watch.key}${e.summary ? ` \u2014 ${e.summary}` : ` (seq ${e.seq})`}`)
3716
+ )
3717
+ ];
3234
3718
  const reason = [
3235
- "A lobstah dispatch needs attention:",
3719
+ "A lobstah dispatch or watched source needs attention:",
3236
3720
  ...lines,
3237
- 'Check with `lobstah status <id>`; answer a needs-decision with `lobstah send <id> "<answer>"`.',
3721
+ 'Check a dispatch with `lobstah status <id>`; answer a needs-decision with `lobstah send <id> "<answer>"`.',
3722
+ "A watch line means an external source updated (e.g. a review round) \u2014 handle it directly.",
3238
3723
  "Handle it now. This session re-parks automatically at turn end \u2014 do not arm any watcher."
3239
3724
  ].join("\n");
3240
3725
  console.log(JSON.stringify({ decision: "block", reason }));
@@ -3283,7 +3768,7 @@ ${progress}`
3283
3768
  path: r.path,
3284
3769
  trunk: r.trunk,
3285
3770
  pickup: r.pickup ?? false,
3286
- exists: fs23.existsSync(r.path)
3771
+ exists: fs25.existsSync(r.path)
3287
3772
  }));
3288
3773
  console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
3289
3774
  break;
@@ -3292,8 +3777,43 @@ ${progress}`
3292
3777
  console.log(lobstahVersion());
3293
3778
  break;
3294
3779
  }
3780
+ case "watch": {
3781
+ const sub = args[0];
3782
+ if (sub === "add") {
3783
+ const key = args[1];
3784
+ const check = arg(args, "--check");
3785
+ if (!key || key.startsWith("--") || !check) throw new Error("watch add requires a key and --check <command>");
3786
+ const forId = arg(args, "--for");
3787
+ const every = arg(args, "--every");
3788
+ const w = addWatch(key, check, {
3789
+ owner: forId ? `dispatch:${forId}` : "man",
3790
+ cursor: arg(args, "--cursor"),
3791
+ everySecs: every ? Number(every) : void 0,
3792
+ brief: arg(args, "--brief"),
3793
+ stream: arg(args, "--stream")
3794
+ });
3795
+ console.log(toonKV({ key: w.key, owner: w.owner, cursor: w.cursor, registered: true }));
3796
+ break;
3797
+ }
3798
+ if (sub === "rm") {
3799
+ const key = args[1];
3800
+ if (!key) throw new Error("watch rm requires a key");
3801
+ console.log(toonKV({ key, removed: removeWatch(key) }));
3802
+ break;
3803
+ }
3804
+ const rows = listWatches().map((w) => ({
3805
+ key: w.key,
3806
+ owner: w.owner,
3807
+ cursor: w.cursor,
3808
+ pending: readWatchEvents(w.key).length - w.seen,
3809
+ lastChecked: w.lastCheckedAt ?? "-",
3810
+ error: w.lastError ?? ""
3811
+ }));
3812
+ console.log(toonTable("watches", rows, ["key", "owner", "cursor", "pending", "lastChecked", "error"]));
3813
+ break;
3814
+ }
3295
3815
  case "init": {
3296
- if (!fs23.existsSync(configPath())) {
3816
+ if (!fs25.existsSync(configPath())) {
3297
3817
  const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
3298
3818
  path = "~/src/example"
3299
3819
  trunk = "main"
@@ -3302,7 +3822,7 @@ trunk = "main"
3302
3822
  # pickup = true # opt into [pickup.github] multi-repo tracker pickup
3303
3823
 
3304
3824
  `;
3305
- fs23.writeFileSync(
3825
+ fs25.writeFileSync(
3306
3826
  configPath(),
3307
3827
  `# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
3308
3828
  # Top-level keys must come before any [section].
@@ -3340,12 +3860,12 @@ wallClockSecs = 3600
3340
3860
  let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
3341
3861
  try {
3342
3862
  const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
3343
- if (fs23.existsSync(local)) reference = local;
3863
+ if (fs25.existsSync(local)) reference = local;
3344
3864
  } catch {
3345
3865
  }
3346
3866
  console.log(
3347
3867
  toonKV({
3348
- home: path17.dirname(configPath()),
3868
+ home: path19.dirname(configPath()),
3349
3869
  config: configPath(),
3350
3870
  ...added.length > 0 ? { added: added.join(", ") } : {},
3351
3871
  ...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},