lobstah 0.2.1 → 0.3.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/README.md +16 -5
- package/dist/main.js +966 -238
- package/dist/runner.js +5 -0
- package/docs/assets/lob-star.png +0 -0
- package/docs/assets/social-preview.png +0 -0
- package/docs/configuration.md +7 -0
- package/docs/lobsterman.md +38 -8
- package/docs/pickup.md +25 -7
- package/docs/vocabulary.md +36 -0
- package/package.json +1 -1
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
|
|
4
|
+
import * as fs28 from "node:fs";
|
|
5
5
|
import * as os6 from "node:os";
|
|
6
|
-
import * as
|
|
6
|
+
import * as path22 from "node:path";
|
|
7
7
|
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
8
|
-
import { spawnSync as
|
|
8
|
+
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
9
9
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
10
10
|
|
|
11
11
|
// packages/core/dist/types.js
|
|
@@ -39,6 +39,10 @@ function ensureLayout() {
|
|
|
39
39
|
}
|
|
40
40
|
fs.mkdirSync(path.join(lobstahHome(), "worktrees"), { recursive: true });
|
|
41
41
|
fs.mkdirSync(path.join(lobstahHome(), "watches"), { recursive: true });
|
|
42
|
+
fs.mkdirSync(path.join(lobstahHome(), "soaking"), { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
function soakingDir() {
|
|
45
|
+
return path.join(lobstahHome(), "soaking");
|
|
42
46
|
}
|
|
43
47
|
function statusPath(id, lane) {
|
|
44
48
|
return path.join(laneDirs(lane).state, `${id}.status`);
|
|
@@ -71,9 +75,21 @@ function pendingIds(lane) {
|
|
|
71
75
|
const dir = laneDirs(lane).queue;
|
|
72
76
|
return fs2.readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => ({ f, m: fs2.statSync(path2.join(dir, f)).mtimeMs })).sort((a, b) => a.m - b.m).map(({ f }) => f.slice(0, -".json".length));
|
|
73
77
|
}
|
|
74
|
-
function
|
|
78
|
+
function queuedDescriptor(id, lane) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(fs2.readFileSync(path2.join(laneDirs(lane).queue, `${id}.json`), "utf8"));
|
|
81
|
+
} catch {
|
|
82
|
+
return void 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function claimNext(lane, skip) {
|
|
75
86
|
const dirs = laneDirs(lane);
|
|
76
87
|
for (const id of pendingIds(lane)) {
|
|
88
|
+
if (skip) {
|
|
89
|
+
const d = queuedDescriptor(id, lane);
|
|
90
|
+
if (!d || skip(d))
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
77
93
|
const activeDir = path2.join(dirs.active, id);
|
|
78
94
|
try {
|
|
79
95
|
fs2.mkdirSync(activeDir);
|
|
@@ -90,9 +106,19 @@ function claimNext(lane) {
|
|
|
90
106
|
}
|
|
91
107
|
return null;
|
|
92
108
|
}
|
|
109
|
+
function requeue(id, lane) {
|
|
110
|
+
const dirs = laneDirs(lane);
|
|
111
|
+
const activeDir = path2.join(dirs.active, id);
|
|
112
|
+
fs2.renameSync(path2.join(activeDir, "descriptor.json"), path2.join(dirs.queue, `${id}.json`));
|
|
113
|
+
fs2.rmSync(activeDir, { recursive: true, force: true });
|
|
114
|
+
}
|
|
93
115
|
function activeIds(lane) {
|
|
94
116
|
return fs2.readdirSync(laneDirs(lane).active).filter((f) => !f.startsWith("."));
|
|
95
117
|
}
|
|
118
|
+
function complete(id, lane) {
|
|
119
|
+
const dirs = laneDirs(lane);
|
|
120
|
+
fs2.renameSync(path2.join(dirs.active, id), path2.join(dirs.done, id));
|
|
121
|
+
}
|
|
96
122
|
function requestCancel(id, lane) {
|
|
97
123
|
fs2.writeFileSync(path2.join(laneDirs(lane).active, id, "cancel"), (/* @__PURE__ */ new Date()).toISOString());
|
|
98
124
|
}
|
|
@@ -845,6 +871,10 @@ function parse(toml, { maxDepth = 1e3, integersAsBigInt } = {}) {
|
|
|
845
871
|
}
|
|
846
872
|
|
|
847
873
|
// packages/core/dist/config.js
|
|
874
|
+
var DEFAULT_SOAK = {
|
|
875
|
+
deferSecs: 90,
|
|
876
|
+
ttlSecs: 1800
|
|
877
|
+
};
|
|
848
878
|
var DEFAULT_LIMITS = {
|
|
849
879
|
maxConcurrent: 2,
|
|
850
880
|
choreConcurrent: 1,
|
|
@@ -879,6 +909,7 @@ function loadConfig() {
|
|
|
879
909
|
repos,
|
|
880
910
|
harness: raw.harness ?? {},
|
|
881
911
|
limits: { ...DEFAULT_LIMITS, ...raw.limits ?? {} },
|
|
912
|
+
soak: { ...DEFAULT_SOAK, ...raw.soak ?? {} },
|
|
882
913
|
notifyCommand: raw.notifyCommand ? String(raw.notifyCommand) : void 0,
|
|
883
914
|
notifyVerbs: Array.isArray(raw.notifyVerbs) ? raw.notifyVerbs.map(String) : void 0,
|
|
884
915
|
remindSecs: raw.remindSecs !== void 0 ? Number(raw.remindSecs) : void 0
|
|
@@ -901,6 +932,9 @@ function toonTable(name, rows, cols) {
|
|
|
901
932
|
return header;
|
|
902
933
|
return [header, ...rows.map((r) => ` ${cols.map((c) => cell(r[c])).join(",")}`)].join("\n");
|
|
903
934
|
}
|
|
935
|
+
function toonHelp(lines) {
|
|
936
|
+
return [`help[${lines.length}]:`, ...lines.map((l) => ` ${l}`)].join("\n");
|
|
937
|
+
}
|
|
904
938
|
|
|
905
939
|
// packages/core/dist/version.js
|
|
906
940
|
import * as fs8 from "node:fs";
|
|
@@ -1142,6 +1176,158 @@ function markFollowUp(key, followUpId, deliveredThrough) {
|
|
|
1142
1176
|
writeWatch(w);
|
|
1143
1177
|
}
|
|
1144
1178
|
|
|
1179
|
+
// packages/core/dist/soak.js
|
|
1180
|
+
import * as fs11 from "node:fs";
|
|
1181
|
+
import * as path7 from "node:path";
|
|
1182
|
+
function regPath(sessionId) {
|
|
1183
|
+
return path7.join(soakingDir(), `${sessionId}.json`);
|
|
1184
|
+
}
|
|
1185
|
+
function atomicWrite2(file, content) {
|
|
1186
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
1187
|
+
fs11.writeFileSync(tmp, content);
|
|
1188
|
+
fs11.renameSync(tmp, file);
|
|
1189
|
+
}
|
|
1190
|
+
function signOnSoak(reg) {
|
|
1191
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1192
|
+
const prior = readSoak(reg.sessionId);
|
|
1193
|
+
const full = {
|
|
1194
|
+
...reg,
|
|
1195
|
+
signedOnAt: prior?.signedOnAt ?? now,
|
|
1196
|
+
heartbeatAt: now,
|
|
1197
|
+
claimed: prior?.claimed
|
|
1198
|
+
};
|
|
1199
|
+
atomicWrite2(regPath(reg.sessionId), JSON.stringify(full, null, 2));
|
|
1200
|
+
return full;
|
|
1201
|
+
}
|
|
1202
|
+
function readSoak(sessionId) {
|
|
1203
|
+
try {
|
|
1204
|
+
return JSON.parse(fs11.readFileSync(regPath(sessionId), "utf8"));
|
|
1205
|
+
} catch {
|
|
1206
|
+
return void 0;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
function listSoaking() {
|
|
1210
|
+
try {
|
|
1211
|
+
return fs11.readdirSync(soakingDir()).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(fs11.readFileSync(path7.join(soakingDir(), f), "utf8")));
|
|
1212
|
+
} catch {
|
|
1213
|
+
return [];
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
function stowSoak(sessionId) {
|
|
1217
|
+
const reg = readSoak(sessionId);
|
|
1218
|
+
if (!reg)
|
|
1219
|
+
return void 0;
|
|
1220
|
+
fs11.rmSync(regPath(sessionId), { force: true });
|
|
1221
|
+
return reg;
|
|
1222
|
+
}
|
|
1223
|
+
function heartbeatSoak(sessionId, patch = {}) {
|
|
1224
|
+
const reg = readSoak(sessionId);
|
|
1225
|
+
if (!reg)
|
|
1226
|
+
return void 0;
|
|
1227
|
+
const next = { ...reg, ...patch, heartbeatAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1228
|
+
atomicWrite2(regPath(sessionId), JSON.stringify(next, null, 2));
|
|
1229
|
+
return next;
|
|
1230
|
+
}
|
|
1231
|
+
function claimPath(id, lane) {
|
|
1232
|
+
return path7.join(laneDirs(lane).active, id, "claim.json");
|
|
1233
|
+
}
|
|
1234
|
+
function readSessionClaim(id, lane) {
|
|
1235
|
+
try {
|
|
1236
|
+
return JSON.parse(fs11.readFileSync(claimPath(id, lane), "utf8"));
|
|
1237
|
+
} catch {
|
|
1238
|
+
return void 0;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
function addressedSession(d) {
|
|
1242
|
+
return d.for?.startsWith("session:") ? d.for.slice("session:".length) : void 0;
|
|
1243
|
+
}
|
|
1244
|
+
function hasOpenCatch(reg) {
|
|
1245
|
+
if (!reg.claimed)
|
|
1246
|
+
return false;
|
|
1247
|
+
if (!fs11.existsSync(path7.join(laneDirs("work").active, reg.claimed)))
|
|
1248
|
+
return false;
|
|
1249
|
+
const last = readStatusLog(reg.claimed, "work").at(-1)?.verb;
|
|
1250
|
+
return last === void 0 || !TERMINAL_VERBS.includes(last);
|
|
1251
|
+
}
|
|
1252
|
+
function releaseCatch(reg) {
|
|
1253
|
+
if (!hasOpenCatch(reg))
|
|
1254
|
+
return {};
|
|
1255
|
+
const id = reg.claimed;
|
|
1256
|
+
fs11.rmSync(path7.join(laneDirs("work").active, id, "claim.json"), { force: true });
|
|
1257
|
+
if (cancelRequested(id, "work")) {
|
|
1258
|
+
appendStatus(id, "work", "failed", "cancelled by request; claimant gone, work preserved");
|
|
1259
|
+
complete(id, "work");
|
|
1260
|
+
return { finalized: id };
|
|
1261
|
+
}
|
|
1262
|
+
requeue(id, "work");
|
|
1263
|
+
return { requeued: id };
|
|
1264
|
+
}
|
|
1265
|
+
function claimBait(reg) {
|
|
1266
|
+
if (hasOpenCatch(reg))
|
|
1267
|
+
return null;
|
|
1268
|
+
const mine = `session:${reg.sessionId}`;
|
|
1269
|
+
const takeable = (d) => {
|
|
1270
|
+
if (d.for === mine)
|
|
1271
|
+
return true;
|
|
1272
|
+
if (d.for !== void 0)
|
|
1273
|
+
return false;
|
|
1274
|
+
return reg.repo !== void 0 && d.repo === reg.repo;
|
|
1275
|
+
};
|
|
1276
|
+
for (const pass of [(d) => d.for === mine, takeable]) {
|
|
1277
|
+
const id = claimNext("work", (d) => !pass(d));
|
|
1278
|
+
if (!id)
|
|
1279
|
+
continue;
|
|
1280
|
+
const descriptor = JSON.parse(fs11.readFileSync(path7.join(laneDirs("work").active, id, "descriptor.json"), "utf8"));
|
|
1281
|
+
const claim = { by: mine, harness: reg.harness, worktree: reg.worktree, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1282
|
+
atomicWrite2(claimPath(id, "work"), JSON.stringify(claim, null, 2));
|
|
1283
|
+
mergeEvidence(id, "work", { sessionId: reg.sessionId });
|
|
1284
|
+
heartbeatSoak(reg.sessionId, { claimed: id });
|
|
1285
|
+
return { id, descriptor };
|
|
1286
|
+
}
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1289
|
+
var msOf = (iso) => Date.parse(iso) || 0;
|
|
1290
|
+
function soakReady(reg, deferMs, now = Date.now()) {
|
|
1291
|
+
return now - msOf(reg.heartbeatAt) <= deferMs && !hasOpenCatch(reg);
|
|
1292
|
+
}
|
|
1293
|
+
function soakSkip(soaking, deferMs, now = Date.now()) {
|
|
1294
|
+
return (d) => {
|
|
1295
|
+
const to = addressedSession(d);
|
|
1296
|
+
if (to !== void 0)
|
|
1297
|
+
return soaking.some((r) => r.sessionId === to);
|
|
1298
|
+
return soaking.some((r) => r.repo === d.repo && soakReady(r, deferMs, now));
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
function sweepGhostTraps(ttlMs, now = Date.now()) {
|
|
1302
|
+
const actions = [];
|
|
1303
|
+
for (const reg of listSoaking()) {
|
|
1304
|
+
if (now - msOf(reg.heartbeatAt) <= ttlMs)
|
|
1305
|
+
continue;
|
|
1306
|
+
if (hasOpenCatch(reg)) {
|
|
1307
|
+
const lastReport = readStatusLog(reg.claimed, "work").at(-1)?.at;
|
|
1308
|
+
if (lastReport && now - msOf(lastReport) <= ttlMs)
|
|
1309
|
+
continue;
|
|
1310
|
+
const released = releaseCatch(reg);
|
|
1311
|
+
actions.push({ sessionId: reg.sessionId, requeued: released.requeued ?? released.finalized });
|
|
1312
|
+
} else {
|
|
1313
|
+
actions.push({ sessionId: reg.sessionId });
|
|
1314
|
+
}
|
|
1315
|
+
fs11.rmSync(regPath(reg.sessionId), { force: true });
|
|
1316
|
+
}
|
|
1317
|
+
return actions;
|
|
1318
|
+
}
|
|
1319
|
+
function baitBrief(id, d) {
|
|
1320
|
+
return [
|
|
1321
|
+
`You are a soaking trap and caught bait: dispatch ${id}.`,
|
|
1322
|
+
"",
|
|
1323
|
+
"Work it in THIS worktree on a fresh branch (branch first, never on the checked-out state directly).",
|
|
1324
|
+
`Report progress with \`lobstah report ${id} working "<note>"\` at milestones, check \`lobstah inbox ${id}\` at natural checkpoints, and finish with \`lobstah report ${id} done "<note>" [--pr <url>]\` (or \`failed\`).`,
|
|
1325
|
+
"The brief:",
|
|
1326
|
+
"",
|
|
1327
|
+
d.brief
|
|
1328
|
+
].join("\n");
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1145
1331
|
// packages/supervisor/dist/liveness.js
|
|
1146
1332
|
import { execFileSync } from "node:child_process";
|
|
1147
1333
|
function classify(input) {
|
|
@@ -1200,26 +1386,26 @@ function killGroup(pid, signal = "SIGTERM") {
|
|
|
1200
1386
|
}
|
|
1201
1387
|
|
|
1202
1388
|
// packages/supervisor/dist/daemon.js
|
|
1203
|
-
import * as
|
|
1389
|
+
import * as fs13 from "node:fs";
|
|
1204
1390
|
import * as os3 from "node:os";
|
|
1205
|
-
import * as
|
|
1391
|
+
import * as path9 from "node:path";
|
|
1206
1392
|
import { spawn as spawn2 } from "node:child_process";
|
|
1207
1393
|
import { createRequire as createRequire2 } from "node:module";
|
|
1208
1394
|
import { fileURLToPath } from "node:url";
|
|
1209
1395
|
|
|
1210
1396
|
// packages/supervisor/dist/notify.js
|
|
1211
|
-
import * as
|
|
1212
|
-
import * as
|
|
1397
|
+
import * as fs12 from "node:fs";
|
|
1398
|
+
import * as path8 from "node:path";
|
|
1213
1399
|
import { spawn } from "node:child_process";
|
|
1214
1400
|
var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
|
|
1215
1401
|
function cursorPath(id, lane) {
|
|
1216
|
-
return
|
|
1402
|
+
return path8.join(laneDirs(lane).state, `${id}.notified`);
|
|
1217
1403
|
}
|
|
1218
1404
|
function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
|
|
1219
1405
|
const log = readStatusLog(id, lane);
|
|
1220
1406
|
let cursor;
|
|
1221
1407
|
try {
|
|
1222
|
-
cursor = Number(
|
|
1408
|
+
cursor = Number(fs12.readFileSync(cursorPath(id, lane), "utf8")) || 0;
|
|
1223
1409
|
} catch {
|
|
1224
1410
|
cursor = void 0;
|
|
1225
1411
|
}
|
|
@@ -1229,13 +1415,13 @@ function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs =
|
|
|
1229
1415
|
if (cursor === void 0 && sinceMs > 0) {
|
|
1230
1416
|
fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
|
|
1231
1417
|
}
|
|
1232
|
-
|
|
1418
|
+
fs12.writeFileSync(cursorPath(id, lane), String(log.length));
|
|
1233
1419
|
return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
|
|
1234
1420
|
}
|
|
1235
1421
|
function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
|
|
1236
1422
|
const dir = laneDirs(lane).state;
|
|
1237
1423
|
const cutoff = Date.now() - maxAgeMs;
|
|
1238
|
-
return
|
|
1424
|
+
return fs12.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs12.statSync(path8.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
|
|
1239
1425
|
}
|
|
1240
1426
|
function execNotify(command, ev, log = () => {
|
|
1241
1427
|
}) {
|
|
@@ -1265,11 +1451,11 @@ function captureWaitBaseline() {
|
|
|
1265
1451
|
return lengths;
|
|
1266
1452
|
}
|
|
1267
1453
|
function attnCursorPath(id, lane) {
|
|
1268
|
-
return
|
|
1454
|
+
return path8.join(laneDirs(lane).state, `${id}.attn`);
|
|
1269
1455
|
}
|
|
1270
1456
|
function readAttnCursor(file) {
|
|
1271
1457
|
try {
|
|
1272
|
-
const parsed = JSON.parse(
|
|
1458
|
+
const parsed = JSON.parse(fs12.readFileSync(file, "utf8"));
|
|
1273
1459
|
return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
|
|
1274
1460
|
} catch {
|
|
1275
1461
|
return { seen: 0, at: 0 };
|
|
@@ -1295,7 +1481,7 @@ function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
|
|
|
1295
1481
|
continue;
|
|
1296
1482
|
out.push({ id, lane, entry });
|
|
1297
1483
|
if (consume)
|
|
1298
|
-
|
|
1484
|
+
fs12.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
|
|
1299
1485
|
}
|
|
1300
1486
|
}
|
|
1301
1487
|
return out;
|
|
@@ -1330,33 +1516,33 @@ function runnerEntry() {
|
|
|
1330
1516
|
}
|
|
1331
1517
|
function readRunnerInfo(dir) {
|
|
1332
1518
|
try {
|
|
1333
|
-
return JSON.parse(
|
|
1519
|
+
return JSON.parse(fs13.readFileSync(path9.join(dir, "runner.json"), "utf8"));
|
|
1334
1520
|
} catch {
|
|
1335
1521
|
return void 0;
|
|
1336
1522
|
}
|
|
1337
1523
|
}
|
|
1338
1524
|
function listActive(lane) {
|
|
1339
1525
|
const dir = laneDirs(lane).active;
|
|
1340
|
-
return
|
|
1341
|
-
const d =
|
|
1526
|
+
return fs13.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
|
|
1527
|
+
const d = path9.join(dir, id);
|
|
1342
1528
|
return { id, lane, dir: d, runner: readRunnerInfo(d) };
|
|
1343
1529
|
});
|
|
1344
1530
|
}
|
|
1345
1531
|
function finalize(st) {
|
|
1346
1532
|
try {
|
|
1347
|
-
|
|
1533
|
+
fs13.renameSync(st.dir, path9.join(laneDirs(st.lane).done, st.id));
|
|
1348
1534
|
} catch {
|
|
1349
1535
|
}
|
|
1350
1536
|
}
|
|
1351
1537
|
function spawnRunner(st, opts) {
|
|
1352
1538
|
let nudge = opts.nudge;
|
|
1353
|
-
const handoffFile =
|
|
1354
|
-
if (!nudge &&
|
|
1355
|
-
nudge =
|
|
1356
|
-
|
|
1539
|
+
const handoffFile = path9.join(st.dir, "handoff");
|
|
1540
|
+
if (!nudge && fs13.existsSync(handoffFile)) {
|
|
1541
|
+
nudge = fs13.readFileSync(handoffFile, "utf8");
|
|
1542
|
+
fs13.unlinkSync(handoffFile);
|
|
1357
1543
|
}
|
|
1358
|
-
const logPath =
|
|
1359
|
-
const log =
|
|
1544
|
+
const logPath = path9.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
|
|
1545
|
+
const log = fs13.openSync(logPath, "a");
|
|
1360
1546
|
const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
|
|
1361
1547
|
detached: true,
|
|
1362
1548
|
stdio: ["ignore", log, log],
|
|
@@ -1367,7 +1553,7 @@ function spawnRunner(st, opts) {
|
|
|
1367
1553
|
...nudge ? { LOBSTAH_NUDGE: nudge } : {}
|
|
1368
1554
|
}
|
|
1369
1555
|
});
|
|
1370
|
-
|
|
1556
|
+
fs13.closeSync(log);
|
|
1371
1557
|
child.unref();
|
|
1372
1558
|
const info = {
|
|
1373
1559
|
pid: child.pid ?? -1,
|
|
@@ -1375,27 +1561,35 @@ function spawnRunner(st, opts) {
|
|
|
1375
1561
|
processStartTime: child.pid ? processStartTime(child.pid) : void 0,
|
|
1376
1562
|
attempts: opts.attempts
|
|
1377
1563
|
};
|
|
1378
|
-
|
|
1564
|
+
fs13.writeFileSync(path9.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
|
|
1379
1565
|
}
|
|
1380
1566
|
function sessionOf(st) {
|
|
1381
1567
|
try {
|
|
1382
|
-
const ev = JSON.parse(
|
|
1568
|
+
const ev = JSON.parse(fs13.readFileSync(path9.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
|
|
1383
1569
|
return ev.sessionId;
|
|
1384
1570
|
} catch {
|
|
1385
1571
|
return void 0;
|
|
1386
1572
|
}
|
|
1387
1573
|
}
|
|
1388
1574
|
function reconcileOne(st, cfg, log) {
|
|
1389
|
-
const hasDescriptor =
|
|
1575
|
+
const hasDescriptor = fs13.existsSync(path9.join(st.dir, "descriptor.json"));
|
|
1390
1576
|
if (!hasDescriptor) {
|
|
1391
|
-
const age = Date.now() -
|
|
1577
|
+
const age = Date.now() - fs13.statSync(st.dir).mtimeMs;
|
|
1392
1578
|
if (age > 5 * 6e4)
|
|
1393
|
-
|
|
1579
|
+
fs13.rmSync(st.dir, { recursive: true, force: true });
|
|
1394
1580
|
return;
|
|
1395
1581
|
}
|
|
1396
1582
|
const statusLog = readStatusLog(st.id, st.lane);
|
|
1397
1583
|
const lastVerb = statusLog.at(-1)?.verb;
|
|
1398
1584
|
const alive = st.runner ? pidAlive(st.runner.pid, st.runner.processStartTime) : void 0;
|
|
1585
|
+
const sessionClaim = readSessionClaim(st.id, st.lane);
|
|
1586
|
+
if (sessionClaim) {
|
|
1587
|
+
if (lastVerb === "done" || lastVerb === "failed") {
|
|
1588
|
+
finalize(st);
|
|
1589
|
+
log(`${st.id}: ${lastVerb} (claimed by ${sessionClaim.by}), finalized`);
|
|
1590
|
+
}
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1399
1593
|
if (cancelRequested(st.id, st.lane)) {
|
|
1400
1594
|
if (st.runner && alive) {
|
|
1401
1595
|
log(`${st.id}: cancel requested, killing group ${st.runner.pid}`);
|
|
@@ -1474,16 +1668,16 @@ function writeHeartbeat(cfg) {
|
|
|
1474
1668
|
heartbeat: (/* @__PURE__ */ new Date()).toISOString()
|
|
1475
1669
|
};
|
|
1476
1670
|
const tmp = `${executorPath()}.tmp`;
|
|
1477
|
-
|
|
1478
|
-
|
|
1671
|
+
fs13.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
1672
|
+
fs13.renameSync(tmp, executorPath());
|
|
1479
1673
|
}
|
|
1480
1674
|
function pruneChores(cfg) {
|
|
1481
1675
|
const dir = laneDirs("chore").done;
|
|
1482
1676
|
const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
|
|
1483
|
-
for (const id of
|
|
1484
|
-
const p =
|
|
1485
|
-
if (
|
|
1486
|
-
|
|
1677
|
+
for (const id of fs13.readdirSync(dir)) {
|
|
1678
|
+
const p = path9.join(dir, id);
|
|
1679
|
+
if (fs13.statSync(p).mtimeMs < cutoff)
|
|
1680
|
+
fs13.rmSync(p, { recursive: true, force: true });
|
|
1487
1681
|
}
|
|
1488
1682
|
}
|
|
1489
1683
|
var daemonStartedAt = Date.now();
|
|
@@ -1492,6 +1686,10 @@ function tick(log = () => {
|
|
|
1492
1686
|
const cfg = loadConfig();
|
|
1493
1687
|
ensureLayout();
|
|
1494
1688
|
writeHeartbeat(cfg);
|
|
1689
|
+
for (const action of sweepGhostTraps(cfg.soak.ttlSecs * 1e3)) {
|
|
1690
|
+
log(`ghost trap ${action.sessionId.slice(0, 8)} swept` + (action.requeued ? ` \u2014 bait ${action.requeued} back in the queue` : ""));
|
|
1691
|
+
}
|
|
1692
|
+
const workSkip = soakSkip(listSoaking(), cfg.soak.deferSecs * 1e3);
|
|
1495
1693
|
for (const lane of ["chore", "work"]) {
|
|
1496
1694
|
const active = listActive(lane);
|
|
1497
1695
|
for (const st of active)
|
|
@@ -1499,7 +1697,7 @@ function tick(log = () => {
|
|
|
1499
1697
|
const ceiling = lane === "work" ? cfg.limits.maxConcurrent : cfg.limits.choreConcurrent;
|
|
1500
1698
|
let inFlight = listActive(lane).length;
|
|
1501
1699
|
while (inFlight < ceiling) {
|
|
1502
|
-
const id = claimNext(lane);
|
|
1700
|
+
const id = claimNext(lane, lane === "work" ? workSkip : void 0);
|
|
1503
1701
|
if (!id)
|
|
1504
1702
|
break;
|
|
1505
1703
|
log(`${id}: claimed (${lane})`);
|
|
@@ -1523,9 +1721,9 @@ function tick(log = () => {
|
|
|
1523
1721
|
pruneChores(loadConfig());
|
|
1524
1722
|
}
|
|
1525
1723
|
function acquireDaemonLock() {
|
|
1526
|
-
const lockPath =
|
|
1724
|
+
const lockPath = path9.join(lobstahHome(), "daemon.lock");
|
|
1527
1725
|
try {
|
|
1528
|
-
const prior = JSON.parse(
|
|
1726
|
+
const prior = JSON.parse(fs13.readFileSync(lockPath, "utf8"));
|
|
1529
1727
|
if (pidAlive(prior.pid, prior.processStartTime)) {
|
|
1530
1728
|
throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
|
|
1531
1729
|
}
|
|
@@ -1533,10 +1731,10 @@ function acquireDaemonLock() {
|
|
|
1533
1731
|
if (err instanceof Error && err.message.includes("already supervises"))
|
|
1534
1732
|
throw err;
|
|
1535
1733
|
}
|
|
1536
|
-
|
|
1734
|
+
fs13.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
|
|
1537
1735
|
const release = () => {
|
|
1538
1736
|
try {
|
|
1539
|
-
|
|
1737
|
+
fs13.unlinkSync(lockPath);
|
|
1540
1738
|
} catch {
|
|
1541
1739
|
}
|
|
1542
1740
|
};
|
|
@@ -1552,7 +1750,7 @@ function watchQueues(onChange) {
|
|
|
1552
1750
|
const watchers = [];
|
|
1553
1751
|
for (const lane of ["work", "chore"]) {
|
|
1554
1752
|
try {
|
|
1555
|
-
watchers.push(
|
|
1753
|
+
watchers.push(fs13.watch(laneDirs(lane).queue, onChange));
|
|
1556
1754
|
} catch {
|
|
1557
1755
|
}
|
|
1558
1756
|
}
|
|
@@ -1573,15 +1771,15 @@ async function daemon(intervalMs = 5e3, log = console.log) {
|
|
|
1573
1771
|
} catch (err) {
|
|
1574
1772
|
log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1575
1773
|
}
|
|
1576
|
-
await new Promise((
|
|
1774
|
+
await new Promise((resolve3) => {
|
|
1577
1775
|
const timer = setTimeout(() => {
|
|
1578
1776
|
wake = void 0;
|
|
1579
|
-
|
|
1777
|
+
resolve3();
|
|
1580
1778
|
}, intervalMs);
|
|
1581
1779
|
wake = () => {
|
|
1582
1780
|
wake = void 0;
|
|
1583
1781
|
clearTimeout(timer);
|
|
1584
|
-
setTimeout(
|
|
1782
|
+
setTimeout(resolve3, 50);
|
|
1585
1783
|
};
|
|
1586
1784
|
});
|
|
1587
1785
|
}
|
|
@@ -1591,7 +1789,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
|
|
|
1591
1789
|
import { spawn as spawn3 } from "node:child_process";
|
|
1592
1790
|
|
|
1593
1791
|
// apps/pick/dist/config.js
|
|
1594
|
-
import * as
|
|
1792
|
+
import * as fs14 from "node:fs";
|
|
1595
1793
|
import { execSync } from "node:child_process";
|
|
1596
1794
|
import * as os4 from "node:os";
|
|
1597
1795
|
|
|
@@ -1633,7 +1831,7 @@ function resolveTokenSource(section, label) {
|
|
|
1633
1831
|
}
|
|
1634
1832
|
if (file) {
|
|
1635
1833
|
return () => {
|
|
1636
|
-
const token =
|
|
1834
|
+
const token = fs14.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
|
|
1637
1835
|
if (!token)
|
|
1638
1836
|
throw new Error(`${label}: tokenFile ${file} is empty`);
|
|
1639
1837
|
return token;
|
|
@@ -1651,7 +1849,7 @@ function resolveTokenSource(section, label) {
|
|
|
1651
1849
|
}
|
|
1652
1850
|
function loadPickupConfig() {
|
|
1653
1851
|
const file = configPath();
|
|
1654
|
-
const raw =
|
|
1852
|
+
const raw = fs14.existsSync(file) ? parse(fs14.readFileSync(file, "utf8")) : {};
|
|
1655
1853
|
const p = raw.pickup ?? {};
|
|
1656
1854
|
const out = {
|
|
1657
1855
|
pollSecs: Number(p.pollSecs ?? 45),
|
|
@@ -1724,23 +1922,23 @@ function loadPickupConfig() {
|
|
|
1724
1922
|
}
|
|
1725
1923
|
|
|
1726
1924
|
// apps/pick/dist/state.js
|
|
1727
|
-
import * as
|
|
1728
|
-
import * as
|
|
1925
|
+
import * as fs15 from "node:fs";
|
|
1926
|
+
import * as path10 from "node:path";
|
|
1729
1927
|
var PickupState = class {
|
|
1730
1928
|
file;
|
|
1731
1929
|
data;
|
|
1732
|
-
constructor(dir =
|
|
1733
|
-
|
|
1734
|
-
this.file =
|
|
1735
|
-
this.data =
|
|
1930
|
+
constructor(dir = path10.join(lobstahHome(), "pickup")) {
|
|
1931
|
+
fs15.mkdirSync(dir, { recursive: true });
|
|
1932
|
+
this.file = path10.join(dir, "state.json");
|
|
1933
|
+
this.data = fs15.existsSync(this.file) ? JSON.parse(fs15.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
|
|
1736
1934
|
this.data.map ??= {};
|
|
1737
1935
|
this.data.consumedApprovals ??= {};
|
|
1738
1936
|
this.data.rebases ??= {};
|
|
1739
1937
|
}
|
|
1740
1938
|
save() {
|
|
1741
1939
|
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
1742
|
-
|
|
1743
|
-
|
|
1940
|
+
fs15.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
|
|
1941
|
+
fs15.renameSync(tmp, this.file);
|
|
1744
1942
|
}
|
|
1745
1943
|
get(key) {
|
|
1746
1944
|
return this.data.map[key];
|
|
@@ -1786,6 +1984,38 @@ var PickupState = class {
|
|
|
1786
1984
|
}
|
|
1787
1985
|
};
|
|
1788
1986
|
|
|
1987
|
+
// apps/pick/dist/pr-evidence.js
|
|
1988
|
+
import * as fs16 from "node:fs";
|
|
1989
|
+
import * as path11 from "node:path";
|
|
1990
|
+
function prUrlKey(url) {
|
|
1991
|
+
return url.replace(/\/+$/, "");
|
|
1992
|
+
}
|
|
1993
|
+
function prEvidenceIndex() {
|
|
1994
|
+
const byUrl = /* @__PURE__ */ new Map();
|
|
1995
|
+
const stateDir = laneDirs("work").state;
|
|
1996
|
+
let files;
|
|
1997
|
+
try {
|
|
1998
|
+
files = fs16.readdirSync(stateDir).filter((f) => f.endsWith(".evidence"));
|
|
1999
|
+
} catch {
|
|
2000
|
+
return /* @__PURE__ */ new Map();
|
|
2001
|
+
}
|
|
2002
|
+
for (const f of files) {
|
|
2003
|
+
const file = path11.join(stateDir, f);
|
|
2004
|
+
try {
|
|
2005
|
+
const prUrl = JSON.parse(fs16.readFileSync(file, "utf8")).prUrl;
|
|
2006
|
+
if (!prUrl)
|
|
2007
|
+
continue;
|
|
2008
|
+
const key = prUrlKey(prUrl);
|
|
2009
|
+
const mtime = fs16.statSync(file).mtimeMs;
|
|
2010
|
+
const cur = byUrl.get(key);
|
|
2011
|
+
if (!cur || mtime > cur.mtime)
|
|
2012
|
+
byUrl.set(key, { uuid: f.slice(0, -".evidence".length), mtime });
|
|
2013
|
+
} catch {
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
return new Map([...byUrl].map(([url, v]) => [url, v.uuid]));
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1789
2019
|
// apps/pick/dist/sources/github.js
|
|
1790
2020
|
var API = "https://api.github.com";
|
|
1791
2021
|
var GithubSource = class {
|
|
@@ -1834,26 +2064,70 @@ ${issue.body ?? ""}
|
|
|
1834
2064
|
When the change is complete, commit it, push the branch, and open a PR against ${this.cfg.repo} referencing #${issue.number}.`
|
|
1835
2065
|
});
|
|
1836
2066
|
}
|
|
2067
|
+
const evidence = prEvidenceIndex();
|
|
1837
2068
|
const pulls = await this.api("GET", `/repos/${this.cfg.repo}/pulls?state=open&per_page=50`);
|
|
1838
2069
|
for (const pr of pulls) {
|
|
1839
2070
|
const m = /^lobstah\/([0-9a-f-]{36})$/.exec(pr.head.ref);
|
|
1840
|
-
|
|
2071
|
+
const uuid = m?.[1] ?? evidence.get(prUrlKey(pr.html_url));
|
|
2072
|
+
if (!uuid)
|
|
1841
2073
|
continue;
|
|
1842
|
-
const
|
|
1843
|
-
|
|
1844
|
-
if (!changes)
|
|
2074
|
+
const feedback = await this.latestFeedback(pr.number);
|
|
2075
|
+
if (!feedback)
|
|
1845
2076
|
continue;
|
|
2077
|
+
const subject = `gh:${this.cfg.repo}#pr${pr.number}`;
|
|
1846
2078
|
items.push({
|
|
1847
|
-
key:
|
|
2079
|
+
key: `${subject}@${feedback}`,
|
|
2080
|
+
subject,
|
|
1848
2081
|
kind: "review",
|
|
1849
2082
|
repoKey: this.cfg.key,
|
|
1850
2083
|
title: pr.title,
|
|
1851
|
-
followUp:
|
|
1852
|
-
brief: `Address the review feedback on ${pr.html_url} (branch ${pr.head.ref}). Check out that branch in this worktree, read the review
|
|
2084
|
+
followUp: uuid,
|
|
2085
|
+
brief: `Address the review feedback on ${pr.html_url} (branch ${pr.head.ref}). Check out that branch in this worktree, read the full review discussion with \`gh pr view ${pr.number} --comments\`, implement what the feedback asks, and push. Reply to review threads where a reply is warranted. When the feedback is addressed, report status done.`
|
|
1853
2086
|
});
|
|
1854
2087
|
}
|
|
1855
2088
|
return items;
|
|
1856
2089
|
}
|
|
2090
|
+
/**
|
|
2091
|
+
* The newest human feedback event on a PR, as an opaque round tag —
|
|
2092
|
+
* `rv<id>` for a submitted review, `rc<id>` for an inline review-thread
|
|
2093
|
+
* comment, `ic<id>` for a conversation comment. Feedback means: a
|
|
2094
|
+
* CHANGES_REQUESTED review on any sha (a review of a slightly stale head
|
|
2095
|
+
* still asks for changes), a COMMENTED review with a body, or any comment
|
|
2096
|
+
* — always by someone other than the configured identity, and never a
|
|
2097
|
+
* marker comment. APPROVED reviews are the merge loop's signal, not
|
|
2098
|
+
* feedback. Returns undefined when no human has spoken.
|
|
2099
|
+
*/
|
|
2100
|
+
async latestFeedback(n) {
|
|
2101
|
+
const events = [];
|
|
2102
|
+
const human = (login) => login !== this.cfg.identity;
|
|
2103
|
+
const reviews = await this.paged(`/repos/${this.cfg.repo}/pulls/${n}/reviews`);
|
|
2104
|
+
for (const r of reviews) {
|
|
2105
|
+
if (!human(r.user.login))
|
|
2106
|
+
continue;
|
|
2107
|
+
const actionable = r.state === "CHANGES_REQUESTED" || r.state === "COMMENTED" && (r.body ?? "").trim() !== "";
|
|
2108
|
+
if (actionable)
|
|
2109
|
+
events.push({ at: r.submitted_at, tag: `rv${r.id}` });
|
|
2110
|
+
}
|
|
2111
|
+
for (const c of await this.paged(`/repos/${this.cfg.repo}/pulls/${n}/comments`)) {
|
|
2112
|
+
if (human(c.user.login))
|
|
2113
|
+
events.push({ at: c.created_at, tag: `rc${c.id}` });
|
|
2114
|
+
}
|
|
2115
|
+
for (const c of await this.paged(`/repos/${this.cfg.repo}/issues/${n}/comments`)) {
|
|
2116
|
+
if (human(c.user.login) && !MARKER.test(c.body))
|
|
2117
|
+
events.push({ at: c.created_at, tag: `ic${c.id}` });
|
|
2118
|
+
}
|
|
2119
|
+
events.sort((a, b) => a.at === b.at ? a.tag.localeCompare(b.tag) : a.at.localeCompare(b.at));
|
|
2120
|
+
return events.at(-1)?.tag;
|
|
2121
|
+
}
|
|
2122
|
+
async paged(url) {
|
|
2123
|
+
const all = [];
|
|
2124
|
+
for (let page = 1; ; page++) {
|
|
2125
|
+
const batch = await this.api("GET", `${url}?per_page=100&page=${page}`);
|
|
2126
|
+
all.push(...batch);
|
|
2127
|
+
if (batch.length < 100)
|
|
2128
|
+
return all;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
1857
2131
|
async claim(item) {
|
|
1858
2132
|
if (item.kind === "review")
|
|
1859
2133
|
return true;
|
|
@@ -2100,36 +2374,14 @@ When the change is complete, commit it, push the branch, and open a PR whose des
|
|
|
2100
2374
|
|
|
2101
2375
|
// apps/pick/dist/loops/dispatch.js
|
|
2102
2376
|
import { randomUUID } from "node:crypto";
|
|
2103
|
-
async function dispatchLoop(source, state, log = () => {
|
|
2104
|
-
}) {
|
|
2105
|
-
const items = await source.poll();
|
|
2106
|
-
for (const item of items) {
|
|
2107
|
-
if (state.get(item.key))
|
|
2108
|
-
continue;
|
|
2109
|
-
if (!await source.claim(item)) {
|
|
2110
|
-
log(`${item.key}: claim lost \u2014 another machine has it`);
|
|
2111
|
-
continue;
|
|
2112
|
-
}
|
|
2113
|
-
const id = randomUUID();
|
|
2114
|
-
enqueue({
|
|
2115
|
-
id,
|
|
2116
|
-
repo: item.repoKey,
|
|
2117
|
-
brief: item.brief,
|
|
2118
|
-
// A review dispatch forks the implementation session; issues start cold.
|
|
2119
|
-
...item.kind === "review" && item.followUp ? { followUp: item.followUp } : {}
|
|
2120
|
-
}, "work");
|
|
2121
|
-
state.set(item.key, { uuid: id, kind: item.kind, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2122
|
-
log(`${item.key}: dispatched as ${id}`);
|
|
2123
|
-
}
|
|
2124
|
-
}
|
|
2125
2377
|
|
|
2126
2378
|
// apps/pick/dist/loops/report.js
|
|
2127
|
-
import * as
|
|
2128
|
-
import * as
|
|
2379
|
+
import * as fs17 from "node:fs";
|
|
2380
|
+
import * as path12 from "node:path";
|
|
2129
2381
|
function dispatchLane(uuid) {
|
|
2130
2382
|
for (const lane of ["work", "chore"]) {
|
|
2131
2383
|
const d = laneDirs(lane);
|
|
2132
|
-
if (
|
|
2384
|
+
if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`)) || fs17.existsSync(path12.join(d.done, uuid)) || fs17.existsSync(path12.join(d.state, `${uuid}.status`))) {
|
|
2133
2385
|
return lane;
|
|
2134
2386
|
}
|
|
2135
2387
|
}
|
|
@@ -2169,13 +2421,58 @@ async function reportLoop(source, state, log = () => {
|
|
|
2169
2421
|
}
|
|
2170
2422
|
}
|
|
2171
2423
|
|
|
2424
|
+
// apps/pick/dist/loops/dispatch.js
|
|
2425
|
+
function terminal(uuid) {
|
|
2426
|
+
const lane = dispatchLane(uuid);
|
|
2427
|
+
if (!lane)
|
|
2428
|
+
return true;
|
|
2429
|
+
const verb = reconcile({ log: readStatusLog(uuid, lane), lastEventAt: lastEventAt(uuid, lane) });
|
|
2430
|
+
return verb === "done" || verb === "failed";
|
|
2431
|
+
}
|
|
2432
|
+
function roundPlan(item, state) {
|
|
2433
|
+
const rounds = item.subject ? state.entries().filter(([k]) => k.startsWith(`${item.subject}@`)).map(([, e]) => e) : [];
|
|
2434
|
+
const chain = [...rounds.map((e) => e.uuid), ...item.followUp ? [item.followUp] : []];
|
|
2435
|
+
if (chain.some((uuid) => !terminal(uuid)))
|
|
2436
|
+
return { hold: true };
|
|
2437
|
+
const forkable = rounds.filter((e) => dispatchLane(e.uuid)).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).at(-1)?.uuid;
|
|
2438
|
+
const target = forkable ?? item.followUp;
|
|
2439
|
+
return { hold: false, followUp: target && dispatchLane(target) ? target : void 0 };
|
|
2440
|
+
}
|
|
2441
|
+
async function dispatchLoop(source, state, log = () => {
|
|
2442
|
+
}) {
|
|
2443
|
+
const items = await source.poll();
|
|
2444
|
+
for (const item of items) {
|
|
2445
|
+
if (state.get(item.key))
|
|
2446
|
+
continue;
|
|
2447
|
+
const plan = item.kind === "review" ? roundPlan(item, state) : { hold: false, followUp: void 0 };
|
|
2448
|
+
if (plan.hold) {
|
|
2449
|
+
log(`${item.key}: a prior round is still in flight \u2014 holding`);
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2452
|
+
if (!await source.claim(item)) {
|
|
2453
|
+
log(`${item.key}: claim lost \u2014 another machine has it`);
|
|
2454
|
+
continue;
|
|
2455
|
+
}
|
|
2456
|
+
const id = randomUUID();
|
|
2457
|
+
enqueue({
|
|
2458
|
+
id,
|
|
2459
|
+
repo: item.repoKey,
|
|
2460
|
+
brief: item.brief,
|
|
2461
|
+
// A review dispatch forks the latest session in its chain; issues start cold.
|
|
2462
|
+
...plan.followUp ? { followUp: plan.followUp } : {}
|
|
2463
|
+
}, "work");
|
|
2464
|
+
state.set(item.key, { uuid: id, kind: item.kind, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2465
|
+
log(`${item.key}: dispatched as ${id}`);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2172
2469
|
// apps/pick/dist/loops/reconcile.js
|
|
2173
|
-
import * as
|
|
2174
|
-
import * as
|
|
2470
|
+
import * as fs18 from "node:fs";
|
|
2471
|
+
import * as path13 from "node:path";
|
|
2175
2472
|
function dispatchActive(uuid) {
|
|
2176
2473
|
for (const lane of ["work", "chore"]) {
|
|
2177
2474
|
const d = laneDirs(lane);
|
|
2178
|
-
if (
|
|
2475
|
+
if (fs18.existsSync(path13.join(d.active, uuid)) || fs18.existsSync(path13.join(d.queue, `${uuid}.json`))) {
|
|
2179
2476
|
return lane;
|
|
2180
2477
|
}
|
|
2181
2478
|
}
|
|
@@ -2215,28 +2512,28 @@ async function reconcileLoop(source, state, log = () => {
|
|
|
2215
2512
|
|
|
2216
2513
|
// apps/pick/dist/loops/merge.js
|
|
2217
2514
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2218
|
-
import * as
|
|
2219
|
-
import * as
|
|
2515
|
+
import * as fs20 from "node:fs";
|
|
2516
|
+
import * as path15 from "node:path";
|
|
2220
2517
|
|
|
2221
2518
|
// apps/pick/dist/merge-view.js
|
|
2222
|
-
import * as
|
|
2223
|
-
import * as
|
|
2519
|
+
import * as fs19 from "node:fs";
|
|
2520
|
+
import * as path14 from "node:path";
|
|
2224
2521
|
function viewPath() {
|
|
2225
|
-
return
|
|
2522
|
+
return path14.join(lobstahHome(), "pickup", "merge-view.json");
|
|
2226
2523
|
}
|
|
2227
2524
|
function readMergeView() {
|
|
2228
2525
|
try {
|
|
2229
|
-
return JSON.parse(
|
|
2526
|
+
return JSON.parse(fs19.readFileSync(viewPath(), "utf8"));
|
|
2230
2527
|
} catch {
|
|
2231
2528
|
return void 0;
|
|
2232
2529
|
}
|
|
2233
2530
|
}
|
|
2234
2531
|
function writeMergeView(view) {
|
|
2235
2532
|
const file = viewPath();
|
|
2236
|
-
|
|
2533
|
+
fs19.mkdirSync(path14.dirname(file), { recursive: true });
|
|
2237
2534
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
2238
|
-
|
|
2239
|
-
|
|
2535
|
+
fs19.writeFileSync(tmp, JSON.stringify(view, null, 2));
|
|
2536
|
+
fs19.renameSync(tmp, file);
|
|
2240
2537
|
}
|
|
2241
2538
|
var RECENT_RETENTION_MS = 48 * 36e5;
|
|
2242
2539
|
async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
|
|
@@ -2258,7 +2555,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
|
|
|
2258
2555
|
}
|
|
2259
2556
|
function readPickupMap() {
|
|
2260
2557
|
try {
|
|
2261
|
-
const raw = JSON.parse(
|
|
2558
|
+
const raw = JSON.parse(fs19.readFileSync(path14.join(lobstahHome(), "pickup", "state.json"), "utf8"));
|
|
2262
2559
|
return raw.map ?? {};
|
|
2263
2560
|
} catch {
|
|
2264
2561
|
return {};
|
|
@@ -2292,7 +2589,7 @@ function rebaseBrief(pr) {
|
|
|
2292
2589
|
}
|
|
2293
2590
|
function choreVerdict(uuid) {
|
|
2294
2591
|
const d = laneDirs("chore");
|
|
2295
|
-
if (
|
|
2592
|
+
if (fs20.existsSync(path15.join(d.active, uuid)) || fs20.existsSync(path15.join(d.queue, `${uuid}.json`)))
|
|
2296
2593
|
return "active";
|
|
2297
2594
|
const last = readStatusLog(uuid, "chore").at(-1)?.verb;
|
|
2298
2595
|
return last === "done" ? "done" : "failed";
|
|
@@ -2386,8 +2683,8 @@ async function mergeLoop(ms, policy, state, log = () => {
|
|
|
2386
2683
|
}
|
|
2387
2684
|
|
|
2388
2685
|
// apps/pick/dist/loops/watch.js
|
|
2389
|
-
import * as
|
|
2390
|
-
import * as
|
|
2686
|
+
import * as fs21 from "node:fs";
|
|
2687
|
+
import * as path16 from "node:path";
|
|
2391
2688
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2392
2689
|
var DEFAULT_BRIEF = `You are resuming earlier work. The watched source {key} produced new events:
|
|
2393
2690
|
|
|
@@ -2397,7 +2694,7 @@ Read them, act on what they ask, and report the outcome.`;
|
|
|
2397
2694
|
function laneOf(id) {
|
|
2398
2695
|
for (const lane of ["work", "chore"]) {
|
|
2399
2696
|
const d = laneDirs(lane);
|
|
2400
|
-
if (
|
|
2697
|
+
if (fs21.existsSync(path16.join(d.active, id)) || fs21.existsSync(path16.join(d.queue, `${id}.json`)) || fs21.existsSync(path16.join(d.done, id)) || fs21.existsSync(path16.join(d.state, `${id}.status`))) {
|
|
2401
2698
|
return lane;
|
|
2402
2699
|
}
|
|
2403
2700
|
}
|
|
@@ -2406,13 +2703,13 @@ function laneOf(id) {
|
|
|
2406
2703
|
function descriptorOf(id, lane) {
|
|
2407
2704
|
const d = laneDirs(lane);
|
|
2408
2705
|
for (const bucket of ["active", "done"]) {
|
|
2409
|
-
const f =
|
|
2410
|
-
if (
|
|
2411
|
-
return JSON.parse(
|
|
2706
|
+
const f = path16.join(d[bucket], id, "descriptor.json");
|
|
2707
|
+
if (fs21.existsSync(f))
|
|
2708
|
+
return JSON.parse(fs21.readFileSync(f, "utf8"));
|
|
2412
2709
|
}
|
|
2413
|
-
const q =
|
|
2414
|
-
if (
|
|
2415
|
-
return JSON.parse(
|
|
2710
|
+
const q = path16.join(d.queue, `${id}.json`);
|
|
2711
|
+
if (fs21.existsSync(q))
|
|
2712
|
+
return JSON.parse(fs21.readFileSync(q, "utf8"));
|
|
2416
2713
|
return void 0;
|
|
2417
2714
|
}
|
|
2418
2715
|
function isTerminal(id) {
|
|
@@ -2439,9 +2736,12 @@ function spawnContinuation(w, pending, log) {
|
|
|
2439
2736
|
}
|
|
2440
2737
|
const id = randomUUID3();
|
|
2441
2738
|
const brief = (w.brief ?? DEFAULT_BRIEF).replaceAll("{key}", w.key).replaceAll("{events}", JSON.stringify(pending, null, 2));
|
|
2442
|
-
|
|
2739
|
+
const claim = readSessionClaim(target, targetLane);
|
|
2740
|
+
const claimant = claim?.by.startsWith("session:") ? claim.by.slice("session:".length) : void 0;
|
|
2741
|
+
const live = claimant !== void 0 && readSoak(claimant) !== void 0;
|
|
2742
|
+
enqueue({ id, repo: descriptor.repo, brief, followUp: target, ...live ? { for: claim.by } : {} }, "work");
|
|
2443
2743
|
markFollowUp(w.key, id, readWatchEvents(w.key).length);
|
|
2444
|
-
log(`watch ${w.key}: ${pending.length} event(s) \u2192 continuation ${id} (forks ${target})`);
|
|
2744
|
+
log(`watch ${w.key}: ${pending.length} event(s) \u2192 continuation ${id} ` + (live ? `(addressed to soaking ${claimant.slice(0, 8)})` : `(forks ${target})`));
|
|
2445
2745
|
}
|
|
2446
2746
|
function notifyMan(watch2, fresh, notify) {
|
|
2447
2747
|
if (fresh.length === 0 || watch2.owner !== "man")
|
|
@@ -2642,21 +2942,21 @@ function mergeHaulHook(existing) {
|
|
|
2642
2942
|
}
|
|
2643
2943
|
|
|
2644
2944
|
// apps/cli/src/tend.ts
|
|
2645
|
-
import * as
|
|
2646
|
-
import * as
|
|
2945
|
+
import * as fs22 from "node:fs";
|
|
2946
|
+
import * as path17 from "node:path";
|
|
2647
2947
|
var HEARTBEAT_STALE_MS = 9e4;
|
|
2648
2948
|
var CLAIM_STALE_MS = 12e4;
|
|
2649
2949
|
var DAY_MS = 24 * 36e5;
|
|
2650
2950
|
function readJson(file) {
|
|
2651
2951
|
try {
|
|
2652
|
-
return JSON.parse(
|
|
2952
|
+
return JSON.parse(fs22.readFileSync(file, "utf8"));
|
|
2653
2953
|
} catch {
|
|
2654
2954
|
return void 0;
|
|
2655
2955
|
}
|
|
2656
2956
|
}
|
|
2657
2957
|
function doneIds(lane) {
|
|
2658
2958
|
try {
|
|
2659
|
-
return
|
|
2959
|
+
return fs22.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
|
|
2660
2960
|
} catch {
|
|
2661
2961
|
return [];
|
|
2662
2962
|
}
|
|
@@ -2675,16 +2975,16 @@ function followUps(uuid) {
|
|
|
2675
2975
|
const desc = readJson(file);
|
|
2676
2976
|
if (desc?.followUp === uuid) out.push({ id, bucket });
|
|
2677
2977
|
};
|
|
2678
|
-
for (const id of pendingIds("work")) scan(id, "queued",
|
|
2679
|
-
for (const id of activeIds("work")) scan(id, "active",
|
|
2680
|
-
for (const id of doneIds("work")) scan(id, "done",
|
|
2978
|
+
for (const id of pendingIds("work")) scan(id, "queued", path17.join(d.queue, `${id}.json`));
|
|
2979
|
+
for (const id of activeIds("work")) scan(id, "active", path17.join(d.active, id, "descriptor.json"));
|
|
2980
|
+
for (const id of doneIds("work")) scan(id, "done", path17.join(d.done, id, "descriptor.json"));
|
|
2681
2981
|
return out;
|
|
2682
2982
|
}
|
|
2683
2983
|
function bucketOf(uuid) {
|
|
2684
2984
|
const d = laneDirs("work");
|
|
2685
|
-
if (
|
|
2686
|
-
if (
|
|
2687
|
-
if (
|
|
2985
|
+
if (fs22.existsSync(path17.join(d.active, uuid))) return "active";
|
|
2986
|
+
if (fs22.existsSync(path17.join(d.done, uuid))) return "done";
|
|
2987
|
+
if (fs22.existsSync(path17.join(d.queue, `${uuid}.json`))) return "queued";
|
|
2688
2988
|
return void 0;
|
|
2689
2989
|
}
|
|
2690
2990
|
function buildTendReport(now = Date.now()) {
|
|
@@ -2744,7 +3044,7 @@ function buildTendReport(now = Date.now()) {
|
|
|
2744
3044
|
}
|
|
2745
3045
|
}
|
|
2746
3046
|
const oldestQueuedAge = queued.reduce((max, id) => {
|
|
2747
|
-
const st =
|
|
3047
|
+
const st = fs22.statSync(path17.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
|
|
2748
3048
|
return st ? Math.max(max, now - st.mtimeMs) : max;
|
|
2749
3049
|
}, 0);
|
|
2750
3050
|
const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
|
|
@@ -2775,6 +3075,13 @@ function buildTendReport(now = Date.now()) {
|
|
|
2775
3075
|
stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl), watch: watchByDispatch.get(id) });
|
|
2776
3076
|
}
|
|
2777
3077
|
const verdict = !daemonUp ? "daemon-down" : stalled ? "stalled" : attention.length > 0 ? "needs-attention" : active.length + queued.length > 0 ? "working" : "idle";
|
|
3078
|
+
const soaking = listSoaking().map((r) => ({
|
|
3079
|
+
session: r.sessionId.slice(0, 8),
|
|
3080
|
+
repo: r.repo ?? "(addressed only)",
|
|
3081
|
+
worktree: r.worktree,
|
|
3082
|
+
claimed: r.claimed,
|
|
3083
|
+
heartbeatAgeSecs: Math.max(0, Math.round((now - (Date.parse(r.heartbeatAt) || 0)) / 1e3))
|
|
3084
|
+
}));
|
|
2778
3085
|
return {
|
|
2779
3086
|
verdict,
|
|
2780
3087
|
daemon: { up: daemonUp, lastHeartbeat: heartbeat },
|
|
@@ -2782,6 +3089,7 @@ function buildTendReport(now = Date.now()) {
|
|
|
2782
3089
|
attention,
|
|
2783
3090
|
stories,
|
|
2784
3091
|
watches,
|
|
3092
|
+
soaking,
|
|
2785
3093
|
merge
|
|
2786
3094
|
};
|
|
2787
3095
|
}
|
|
@@ -2840,6 +3148,22 @@ function renderTend(r) {
|
|
|
2840
3148
|
)
|
|
2841
3149
|
);
|
|
2842
3150
|
}
|
|
3151
|
+
if (r.soaking.length > 0) {
|
|
3152
|
+
lines.push("");
|
|
3153
|
+
lines.push(
|
|
3154
|
+
toonTable(
|
|
3155
|
+
"soaking",
|
|
3156
|
+
r.soaking.map((s) => ({
|
|
3157
|
+
session: s.session,
|
|
3158
|
+
repo: s.repo,
|
|
3159
|
+
claimed: s.claimed ? s.claimed.slice(0, 8) : "",
|
|
3160
|
+
heartbeat: `${s.heartbeatAgeSecs}s ago`,
|
|
3161
|
+
worktree: s.worktree
|
|
3162
|
+
})),
|
|
3163
|
+
["session", "repo", "claimed", "heartbeat", "worktree"]
|
|
3164
|
+
)
|
|
3165
|
+
);
|
|
3166
|
+
}
|
|
2843
3167
|
if (r.merge && (r.merge.open.length > 0 || r.merge.recent.length > 0)) {
|
|
2844
3168
|
lines.push("");
|
|
2845
3169
|
lines.push(
|
|
@@ -2857,13 +3181,13 @@ function renderTend(r) {
|
|
|
2857
3181
|
}
|
|
2858
3182
|
|
|
2859
3183
|
// apps/cli/src/cull.ts
|
|
2860
|
-
import * as
|
|
2861
|
-
import * as
|
|
3184
|
+
import * as fs23 from "node:fs";
|
|
3185
|
+
import * as path18 from "node:path";
|
|
2862
3186
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2863
3187
|
var DAY = 864e5;
|
|
2864
3188
|
function idsIn(dir) {
|
|
2865
3189
|
try {
|
|
2866
|
-
return new Set(
|
|
3190
|
+
return new Set(fs23.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
|
|
2867
3191
|
} catch {
|
|
2868
3192
|
return /* @__PURE__ */ new Set();
|
|
2869
3193
|
}
|
|
@@ -2878,39 +3202,39 @@ function planCull(olderThanDays, now = Date.now()) {
|
|
|
2878
3202
|
for (const id of idsIn(d.queue)) live.add(id);
|
|
2879
3203
|
for (const id of idsIn(d.active)) live.add(id);
|
|
2880
3204
|
for (const id of idsIn(d.done)) {
|
|
2881
|
-
const p =
|
|
2882
|
-
const m =
|
|
3205
|
+
const p = path18.join(d.done, id);
|
|
3206
|
+
const m = fs23.statSync(p).mtimeMs;
|
|
2883
3207
|
doneMtimes.set(id, m);
|
|
2884
3208
|
if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
|
|
2885
3209
|
}
|
|
2886
3210
|
}
|
|
2887
|
-
const wtRoot =
|
|
3211
|
+
const wtRoot = path18.join(lobstahHome(), "worktrees");
|
|
2888
3212
|
for (const id of idsIn(wtRoot)) {
|
|
2889
3213
|
if (live.has(id)) continue;
|
|
2890
3214
|
const doneAt = doneMtimes.get(id);
|
|
2891
3215
|
if (doneAt !== void 0 && doneAt >= cutoff) continue;
|
|
2892
|
-
const p =
|
|
2893
|
-
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now -
|
|
3216
|
+
const p = path18.join(wtRoot, id);
|
|
3217
|
+
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs23.statSync(p).mtimeMs) / DAY) });
|
|
2894
3218
|
}
|
|
2895
3219
|
for (const lane of ["work", "chore"]) {
|
|
2896
3220
|
const d = laneDirs(lane);
|
|
2897
3221
|
const seen = /* @__PURE__ */ new Set();
|
|
2898
|
-
for (const f of
|
|
3222
|
+
for (const f of fs23.readdirSync(d.state)) {
|
|
2899
3223
|
const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
|
|
2900
3224
|
if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
|
|
2901
|
-
const m =
|
|
3225
|
+
const m = fs23.statSync(path18.join(d.state, f)).mtimeMs;
|
|
2902
3226
|
if (m >= cutoff) continue;
|
|
2903
3227
|
seen.add(id);
|
|
2904
|
-
items.push({ kind: "state", id, target:
|
|
3228
|
+
items.push({ kind: "state", id, target: path18.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
|
|
2905
3229
|
}
|
|
2906
3230
|
}
|
|
2907
3231
|
return items;
|
|
2908
3232
|
}
|
|
2909
3233
|
function removeWorktree(id, dir) {
|
|
2910
3234
|
for (const lane of ["work", "chore"]) {
|
|
2911
|
-
const descFile =
|
|
3235
|
+
const descFile = path18.join(laneDirs(lane).done, id, "descriptor.json");
|
|
2912
3236
|
try {
|
|
2913
|
-
const desc = JSON.parse(
|
|
3237
|
+
const desc = JSON.parse(fs23.readFileSync(descFile, "utf8"));
|
|
2914
3238
|
const repo = loadConfig().repos[desc.repo];
|
|
2915
3239
|
if (repo) {
|
|
2916
3240
|
execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
|
|
@@ -2919,15 +3243,15 @@ function removeWorktree(id, dir) {
|
|
|
2919
3243
|
} catch {
|
|
2920
3244
|
}
|
|
2921
3245
|
}
|
|
2922
|
-
|
|
3246
|
+
fs23.rmSync(dir, { recursive: true, force: true });
|
|
2923
3247
|
}
|
|
2924
3248
|
function applyCull(items) {
|
|
2925
3249
|
for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
|
|
2926
|
-
for (const item of items.filter((i) => i.kind === "done"))
|
|
3250
|
+
for (const item of items.filter((i) => i.kind === "done")) fs23.rmSync(item.target, { recursive: true, force: true });
|
|
2927
3251
|
for (const item of items.filter((i) => i.kind === "state")) {
|
|
2928
|
-
const dir =
|
|
2929
|
-
for (const f of
|
|
2930
|
-
if (f.startsWith(`${item.id}.`))
|
|
3252
|
+
const dir = path18.dirname(item.target);
|
|
3253
|
+
for (const f of fs23.readdirSync(dir)) {
|
|
3254
|
+
if (f.startsWith(`${item.id}.`)) fs23.rmSync(path18.join(dir, f), { force: true });
|
|
2931
3255
|
}
|
|
2932
3256
|
}
|
|
2933
3257
|
}
|
|
@@ -2957,6 +3281,11 @@ your working set:
|
|
|
2957
3281
|
external (a review session, a
|
|
2958
3282
|
CI run) \u2014 its events wake you
|
|
2959
3283
|
like any dispatch would
|
|
3284
|
+
lobstah set ... --for session:<id> drop bait into a soaking trap:
|
|
3285
|
+
a live session that volunteered
|
|
3286
|
+
(\`lobstah soak\`) works it in
|
|
3287
|
+
its own worktree instead of a
|
|
3288
|
+
fresh headless spawn
|
|
2960
3289
|
|
|
2961
3290
|
getting woken instead of asking:
|
|
2962
3291
|
lobstah man wait block until a dispatch needs attention (arm it as a
|
|
@@ -2976,7 +3305,7 @@ paste into your liaison instructions:
|
|
|
2976
3305
|
full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
|
|
2977
3306
|
|
|
2978
3307
|
// apps/cli/src/doctor.ts
|
|
2979
|
-
import * as
|
|
3308
|
+
import * as fs24 from "node:fs";
|
|
2980
3309
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
2981
3310
|
var HEARTBEAT_STALE_MS2 = 9e4;
|
|
2982
3311
|
function git(repoPath, ...args) {
|
|
@@ -2999,13 +3328,13 @@ function runDoctor(now = Date.now()) {
|
|
|
2999
3328
|
codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
|
|
3000
3329
|
);
|
|
3001
3330
|
const cfgFile = configPath();
|
|
3002
|
-
if (!
|
|
3331
|
+
if (!fs24.existsSync(cfgFile)) {
|
|
3003
3332
|
push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
|
|
3004
3333
|
return rows;
|
|
3005
3334
|
}
|
|
3006
3335
|
let parsed;
|
|
3007
3336
|
try {
|
|
3008
|
-
parsed = parse(
|
|
3337
|
+
parsed = parse(fs24.readFileSync(cfgFile, "utf8"));
|
|
3009
3338
|
push("config", "ok", cfgFile);
|
|
3010
3339
|
} catch (err) {
|
|
3011
3340
|
push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -3016,7 +3345,7 @@ function runDoctor(now = Date.now()) {
|
|
|
3016
3345
|
if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
|
|
3017
3346
|
for (const [key, repo] of Object.entries(cfg.repos)) {
|
|
3018
3347
|
const label = `repo ${key}`;
|
|
3019
|
-
if (!
|
|
3348
|
+
if (!fs24.existsSync(repo.path)) {
|
|
3020
3349
|
push(label, "fail", `path ${repo.path} does not exist`);
|
|
3021
3350
|
continue;
|
|
3022
3351
|
}
|
|
@@ -3044,11 +3373,11 @@ function runDoctor(now = Date.now()) {
|
|
|
3044
3373
|
}
|
|
3045
3374
|
}
|
|
3046
3375
|
const hb = executorPath();
|
|
3047
|
-
if (!
|
|
3376
|
+
if (!fs24.existsSync(hb)) {
|
|
3048
3377
|
push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
|
|
3049
3378
|
} else {
|
|
3050
3379
|
try {
|
|
3051
|
-
const payload = JSON.parse(
|
|
3380
|
+
const payload = JSON.parse(fs24.readFileSync(hb, "utf8"));
|
|
3052
3381
|
const age = now - new Date(payload.heartbeat ?? 0).getTime();
|
|
3053
3382
|
push(
|
|
3054
3383
|
"daemon",
|
|
@@ -3064,9 +3393,9 @@ function runDoctor(now = Date.now()) {
|
|
|
3064
3393
|
}
|
|
3065
3394
|
|
|
3066
3395
|
// apps/cli/src/service.ts
|
|
3067
|
-
import * as
|
|
3396
|
+
import * as fs25 from "node:fs";
|
|
3068
3397
|
import * as os5 from "node:os";
|
|
3069
|
-
import * as
|
|
3398
|
+
import * as path19 from "node:path";
|
|
3070
3399
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
3071
3400
|
function xml(s) {
|
|
3072
3401
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -3090,8 +3419,8 @@ function renderLaunchdPlist(spec) {
|
|
|
3090
3419
|
</dict>
|
|
3091
3420
|
<key>RunAtLoad</key><true/>
|
|
3092
3421
|
<key>KeepAlive</key><true/>
|
|
3093
|
-
<key>StandardOutPath</key><string>${xml(
|
|
3094
|
-
<key>StandardErrorPath</key><string>${xml(
|
|
3422
|
+
<key>StandardOutPath</key><string>${xml(path19.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
|
|
3423
|
+
<key>StandardErrorPath</key><string>${xml(path19.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
|
|
3095
3424
|
</dict>
|
|
3096
3425
|
</plist>
|
|
3097
3426
|
`;
|
|
@@ -3106,8 +3435,8 @@ Restart=always
|
|
|
3106
3435
|
RestartSec=5
|
|
3107
3436
|
Environment=PATH=${spec.pathEnv}
|
|
3108
3437
|
Environment=LOBSTAH_HOME=${spec.home}
|
|
3109
|
-
StandardOutput=append:${
|
|
3110
|
-
StandardError=append:${
|
|
3438
|
+
StandardOutput=append:${path19.posix.join(spec.logDir, `${spec.kind}.log`)}
|
|
3439
|
+
StandardError=append:${path19.posix.join(spec.logDir, `${spec.kind}.err`)}
|
|
3111
3440
|
|
|
3112
3441
|
[Install]
|
|
3113
3442
|
WantedBy=default.target
|
|
@@ -3115,8 +3444,8 @@ WantedBy=default.target
|
|
|
3115
3444
|
}
|
|
3116
3445
|
function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
3117
3446
|
const dirs = [
|
|
3118
|
-
|
|
3119
|
-
|
|
3447
|
+
path19.posix.dirname(nodePath),
|
|
3448
|
+
path19.posix.join(home, ".local", "bin"),
|
|
3120
3449
|
"/opt/homebrew/bin",
|
|
3121
3450
|
"/usr/local/bin",
|
|
3122
3451
|
"/usr/bin",
|
|
@@ -3125,7 +3454,7 @@ function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
|
3125
3454
|
return [...new Set(dirs)].join(":");
|
|
3126
3455
|
}
|
|
3127
3456
|
function serviceFile(kind) {
|
|
3128
|
-
return process.platform === "darwin" ?
|
|
3457
|
+
return process.platform === "darwin" ? path19.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path19.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
|
|
3129
3458
|
}
|
|
3130
3459
|
function run(cmd, args) {
|
|
3131
3460
|
const res = spawnSync3(cmd, args, { encoding: "utf8" });
|
|
@@ -3135,28 +3464,28 @@ function installService(kind) {
|
|
|
3135
3464
|
if (process.platform === "win32") {
|
|
3136
3465
|
throw new Error(`no service manager support on Windows \u2014 run \`lobstah ${kind}\` under your process manager of choice`);
|
|
3137
3466
|
}
|
|
3138
|
-
const entry =
|
|
3467
|
+
const entry = fs25.realpathSync(process.argv[1]);
|
|
3139
3468
|
const spec = {
|
|
3140
3469
|
kind,
|
|
3141
3470
|
nodePath: process.execPath,
|
|
3142
3471
|
entry,
|
|
3143
3472
|
pathEnv: servicePathEnv(process.execPath),
|
|
3144
3473
|
home: lobstahHome(),
|
|
3145
|
-
logDir:
|
|
3474
|
+
logDir: path19.join(lobstahHome(), "logs")
|
|
3146
3475
|
};
|
|
3147
3476
|
if (!onPath("git", spec.pathEnv)) {
|
|
3148
3477
|
throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
|
|
3149
3478
|
}
|
|
3150
|
-
|
|
3479
|
+
fs25.mkdirSync(spec.logDir, { recursive: true });
|
|
3151
3480
|
const file = serviceFile(kind);
|
|
3152
|
-
|
|
3481
|
+
fs25.mkdirSync(path19.dirname(file), { recursive: true });
|
|
3153
3482
|
if (process.platform === "darwin") {
|
|
3154
|
-
if (
|
|
3155
|
-
|
|
3483
|
+
if (fs25.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3484
|
+
fs25.writeFileSync(file, renderLaunchdPlist(spec));
|
|
3156
3485
|
const load = run("launchctl", ["load", file]);
|
|
3157
3486
|
return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
|
|
3158
3487
|
}
|
|
3159
|
-
|
|
3488
|
+
fs25.writeFileSync(file, renderSystemdUnit(spec));
|
|
3160
3489
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
3161
3490
|
const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
|
|
3162
3491
|
return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
|
|
@@ -3164,19 +3493,19 @@ function installService(kind) {
|
|
|
3164
3493
|
function uninstallService(kind) {
|
|
3165
3494
|
const file = serviceFile(kind);
|
|
3166
3495
|
if (process.platform === "darwin") {
|
|
3167
|
-
if (
|
|
3496
|
+
if (fs25.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3168
3497
|
} else {
|
|
3169
3498
|
run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
|
|
3170
3499
|
}
|
|
3171
|
-
const removed =
|
|
3172
|
-
if (removed)
|
|
3500
|
+
const removed = fs25.existsSync(file);
|
|
3501
|
+
if (removed) fs25.unlinkSync(file);
|
|
3173
3502
|
if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
|
|
3174
3503
|
return { file, removed };
|
|
3175
3504
|
}
|
|
3176
3505
|
|
|
3177
3506
|
// apps/cli/src/repos.ts
|
|
3178
|
-
import * as
|
|
3179
|
-
import * as
|
|
3507
|
+
import * as fs26 from "node:fs";
|
|
3508
|
+
import * as path20 from "node:path";
|
|
3180
3509
|
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
3181
3510
|
function git2(dir, ...args) {
|
|
3182
3511
|
const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
@@ -3192,41 +3521,41 @@ var LOCKFILE_SETUP = [
|
|
|
3192
3521
|
["poetry.lock", ["poetry install"]]
|
|
3193
3522
|
];
|
|
3194
3523
|
function detectRepo(dir) {
|
|
3195
|
-
const abs =
|
|
3524
|
+
const abs = path20.resolve(dir);
|
|
3196
3525
|
if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
|
|
3197
3526
|
const top = git2(abs, "rev-parse", "--show-toplevel");
|
|
3198
|
-
const
|
|
3199
|
-
const real =
|
|
3527
|
+
const canon2 = (p) => {
|
|
3528
|
+
const real = path20.resolve(fs26.realpathSync.native(p));
|
|
3200
3529
|
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
3201
3530
|
};
|
|
3202
|
-
if (!top ||
|
|
3531
|
+
if (!top || canon2(top) !== canon2(abs)) return void 0;
|
|
3203
3532
|
const origin = git2(abs, "remote", "get-url", "origin");
|
|
3204
3533
|
const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
3205
3534
|
const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
|
|
3206
|
-
const setup = LOCKFILE_SETUP.find(([f]) =>
|
|
3207
|
-
return { key:
|
|
3535
|
+
const setup = LOCKFILE_SETUP.find(([f]) => fs26.existsSync(path20.join(abs, f)))?.[1];
|
|
3536
|
+
return { key: path20.basename(abs), path: abs, trunk, origin, setup };
|
|
3208
3537
|
}
|
|
3209
3538
|
function scanForRepos(roots) {
|
|
3210
3539
|
const found = [];
|
|
3211
3540
|
for (const root of roots) {
|
|
3212
|
-
const abs =
|
|
3213
|
-
if (!
|
|
3541
|
+
const abs = path20.resolve(root);
|
|
3542
|
+
if (!fs26.existsSync(abs)) continue;
|
|
3214
3543
|
const self = detectRepo(abs);
|
|
3215
3544
|
if (self) {
|
|
3216
3545
|
found.push(self);
|
|
3217
3546
|
continue;
|
|
3218
3547
|
}
|
|
3219
|
-
for (const entry of
|
|
3548
|
+
for (const entry of fs26.readdirSync(abs, { withFileTypes: true })) {
|
|
3220
3549
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
3221
|
-
const child = detectRepo(
|
|
3550
|
+
const child = detectRepo(path20.join(abs, entry.name));
|
|
3222
3551
|
if (child) found.push(child);
|
|
3223
3552
|
}
|
|
3224
3553
|
}
|
|
3225
3554
|
return found;
|
|
3226
3555
|
}
|
|
3227
3556
|
function configuredRepoKeys(file = configPath()) {
|
|
3228
|
-
if (!
|
|
3229
|
-
const raw = parse(
|
|
3557
|
+
if (!fs26.existsSync(file)) return /* @__PURE__ */ new Set();
|
|
3558
|
+
const raw = parse(fs26.readFileSync(file, "utf8"));
|
|
3230
3559
|
return new Set(Object.keys(raw.repos ?? {}));
|
|
3231
3560
|
}
|
|
3232
3561
|
function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
@@ -3234,7 +3563,7 @@ function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
|
3234
3563
|
if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
|
|
3235
3564
|
if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
|
|
3236
3565
|
if (opts.pickup) lines.push(`pickup = true`);
|
|
3237
|
-
|
|
3566
|
+
fs26.appendFileSync(file, `${lines.join("\n")}
|
|
3238
3567
|
`);
|
|
3239
3568
|
}
|
|
3240
3569
|
|
|
@@ -3246,17 +3575,220 @@ function parseReportArgs(rest) {
|
|
|
3246
3575
|
return { note, prUrl };
|
|
3247
3576
|
}
|
|
3248
3577
|
|
|
3578
|
+
// apps/cli/src/soak-site.ts
|
|
3579
|
+
import * as fs27 from "node:fs";
|
|
3580
|
+
import * as path21 from "node:path";
|
|
3581
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
3582
|
+
function git3(dir, ...args) {
|
|
3583
|
+
const res = spawnSync5("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
3584
|
+
return res.status === 0 ? res.stdout.trim() : void 0;
|
|
3585
|
+
}
|
|
3586
|
+
function canon(p) {
|
|
3587
|
+
try {
|
|
3588
|
+
const real = path21.resolve(fs27.realpathSync.native(p));
|
|
3589
|
+
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
3590
|
+
} catch {
|
|
3591
|
+
return path21.resolve(p);
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
function inspectSoakSite(cwd, repos) {
|
|
3595
|
+
const top = git3(cwd, "rev-parse", "--show-toplevel");
|
|
3596
|
+
if (!top) return void 0;
|
|
3597
|
+
const gitDir = git3(cwd, "rev-parse", "--git-dir");
|
|
3598
|
+
const commonDir = git3(cwd, "rev-parse", "--git-common-dir");
|
|
3599
|
+
if (!gitDir || !commonDir) return void 0;
|
|
3600
|
+
const abs = (p) => path21.isAbsolute(p) ? p : path21.join(cwd, p);
|
|
3601
|
+
const primary = canon(abs(gitDir)) === canon(abs(commonDir));
|
|
3602
|
+
const primaryRoot = canon(path21.dirname(abs(commonDir)));
|
|
3603
|
+
const repoKey = Object.entries(repos).find(([, r]) => canon(r.path) === primaryRoot)?.[0];
|
|
3604
|
+
return { worktree: canon(top), primary, repoKey };
|
|
3605
|
+
}
|
|
3606
|
+
function readHookStdin() {
|
|
3607
|
+
if (process.stdin.isTTY) return void 0;
|
|
3608
|
+
try {
|
|
3609
|
+
const raw = fs27.readFileSync(0, "utf8");
|
|
3610
|
+
if (!raw.trim()) return void 0;
|
|
3611
|
+
const parsed = JSON.parse(raw);
|
|
3612
|
+
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
3613
|
+
} catch {
|
|
3614
|
+
return void 0;
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
// apps/cli/src/usage.ts
|
|
3619
|
+
var HARNESS = "claude|codex";
|
|
3620
|
+
var COMMANDS = {
|
|
3621
|
+
dispatch: {
|
|
3622
|
+
flags: {
|
|
3623
|
+
"--repo": { value: "<key>", required: true },
|
|
3624
|
+
"--brief": { value: "<file>" },
|
|
3625
|
+
"--bait": { value: "<file>" },
|
|
3626
|
+
"--brief-text": { value: "<text>" },
|
|
3627
|
+
"--harness": { value: HARNESS },
|
|
3628
|
+
"--model": { value: "<m>" },
|
|
3629
|
+
"--effort": { value: "<e>" },
|
|
3630
|
+
"--follow-up": { value: "<uuid>" },
|
|
3631
|
+
"--for": { value: "session:<id>" },
|
|
3632
|
+
"--chore": {},
|
|
3633
|
+
"--id": { value: "<uuid>" }
|
|
3634
|
+
}
|
|
3635
|
+
},
|
|
3636
|
+
ls: { flags: { "--all": {} } },
|
|
3637
|
+
status: { flags: {}, positionals: "[<uuid>]" },
|
|
3638
|
+
logs: { flags: { "--follow": {}, "--full": {} }, positionals: "<uuid>" },
|
|
3639
|
+
send: { flags: {}, positionals: "<uuid> <message...>", tailAfter: 1 },
|
|
3640
|
+
inbox: { flags: {}, positionals: "<uuid>" },
|
|
3641
|
+
attach: { flags: { "--print": {}, "--force": {} }, positionals: "<uuid>" },
|
|
3642
|
+
swap: {
|
|
3643
|
+
flags: { "--harness": { value: HARNESS }, "--model": { value: "<m>" }, "--effort": { value: "<e>" } },
|
|
3644
|
+
positionals: "<uuid>"
|
|
3645
|
+
},
|
|
3646
|
+
catch: { flags: {}, positionals: "<uuid>" },
|
|
3647
|
+
cull: { flags: { "--older-than": { value: "<days>" }, "--apply": {} } },
|
|
3648
|
+
cancel: { flags: {}, positionals: "<uuid>" },
|
|
3649
|
+
report: { flags: {}, positionals: "<uuid> <verb> [note...] [--pr <url>]", tailAfter: 2 },
|
|
3650
|
+
watch: {
|
|
3651
|
+
subverbs: ["add", "rm", "ls"],
|
|
3652
|
+
flags: {
|
|
3653
|
+
"--check": { value: "<cmd>" },
|
|
3654
|
+
"--for": { value: "<uuid>" },
|
|
3655
|
+
"--cursor": { value: "<c>" },
|
|
3656
|
+
"--every": { value: "<s>" },
|
|
3657
|
+
"--brief": { value: "<template>" },
|
|
3658
|
+
"--stream": { value: "<cmd>" }
|
|
3659
|
+
},
|
|
3660
|
+
positionals: "[<key>]"
|
|
3661
|
+
},
|
|
3662
|
+
soak: { flags: { "--session": { value: "<id>", required: true }, "--one": {}, "--harness": { value: HARNESS } } },
|
|
3663
|
+
stow: { flags: { "--session": { value: "<id>" }, "--quiet": {} } },
|
|
3664
|
+
daemon: { subverbs: ["install", "uninstall"], flags: { "--interval": { value: "<ms>" } } },
|
|
3665
|
+
pick: { subverbs: ["once", "install", "uninstall"], flags: {} },
|
|
3666
|
+
doctor: { flags: {} },
|
|
3667
|
+
repos: { subverbs: ["add"], flags: { "--pickup": {}, "--key": { value: "<k>" } }, positionals: "[<path>]" },
|
|
3668
|
+
init: { flags: { "--scan": {}, "--pickup": {} }, positionals: "[<dir>...]" },
|
|
3669
|
+
version: { flags: {} },
|
|
3670
|
+
"man:manual": { flags: {} },
|
|
3671
|
+
"man:tend": { flags: { "--json": {} } },
|
|
3672
|
+
"man:wait": { flags: { "--timeout": { value: "<secs>" }, "--peek": {} } },
|
|
3673
|
+
"man:init": { flags: { "--shared": {}, "--global": {}, "--marker": {} } },
|
|
3674
|
+
"man:haul": { flags: { "--timeout": { value: "<secs>" } } },
|
|
3675
|
+
"man:brief": { flags: {} }
|
|
3676
|
+
};
|
|
3677
|
+
var PROSE = {
|
|
3678
|
+
dispatch: `Queue a supervised dispatch; prints the id. --for addresses the bait to a
|
|
3679
|
+
soaking session instead of a fresh headless worker. Alias: set --bait.`,
|
|
3680
|
+
ls: `Queue, active, and recent done dispatches (--all includes chores). Alias: buoys.`,
|
|
3681
|
+
status: `Reconciled state for one dispatch, or all active without an id. Alias: buoy.`,
|
|
3682
|
+
logs: `The dispatch's normalized event stream \u2014 last 50 events by default,
|
|
3683
|
+
--full for everything, --follow to tail.`,
|
|
3684
|
+
send: `Deliver an instruction to a running dispatch between its turns.`,
|
|
3685
|
+
inbox: `Read and acknowledge pending messages (workers: check at natural checkpoints).`,
|
|
3686
|
+
attach: `Open the dispatch's own harness session in its worktree. Refused while
|
|
3687
|
+
working unless --force; --print shows the command instead of running it.`,
|
|
3688
|
+
swap: `Hand an active dispatch to a fresh session \u2014 same worktree and brief plus a
|
|
3689
|
+
git progress note.`,
|
|
3690
|
+
catch: `The evidence: branch, commits, PR, session.`,
|
|
3691
|
+
cull: `Sweep aged done entries, orphaned worktrees, and stale state. Dry run
|
|
3692
|
+
without --apply (default 14 days).`,
|
|
3693
|
+
cancel: `Request cancellation; the daemon (or the claiming session) winds it down.`,
|
|
3694
|
+
report: `The validated status write path: working | needs-decision | blocked |
|
|
3695
|
+
paused | done | failed.`,
|
|
3696
|
+
watch: `Stand watch on something external; bare \`watch\` (or \`watch ls\`) lists.
|
|
3697
|
+
The check command answers "anything since {cursor}?" in JSON.`,
|
|
3698
|
+
soak: `Volunteer this session as a worker: it parks at turn end and takes matching
|
|
3699
|
+
bait. Refused from a primary checkout \u2014 soak from a worktree. --one stows
|
|
3700
|
+
after the first catch.`,
|
|
3701
|
+
stow: `Sign a soaking session off; an open catch goes back to the queue.`,
|
|
3702
|
+
daemon: `The supervisor process (claims, worktrees, liveness, restarts). install
|
|
3703
|
+
writes + loads a launchd agent / systemd user unit.`,
|
|
3704
|
+
pick: `Tracker loops: poll Linear/GitHub, dispatch assigned work, report back,
|
|
3705
|
+
reconcile, merge.`,
|
|
3706
|
+
doctor: `Check binaries, config, repos, harnesses, and the daemon heartbeat; exit 1
|
|
3707
|
+
on failures.`,
|
|
3708
|
+
repos: `List configured repos, or detect one and append its [repos.*] block.`,
|
|
3709
|
+
init: `Create ~/.lobstah + config; --scan appends a [repos.*] block per repo found
|
|
3710
|
+
under the given directories.`,
|
|
3711
|
+
version: `The installed lobstah version.`,
|
|
3712
|
+
"man:manual": `The lobsterman's manual.`,
|
|
3713
|
+
"man:tend": `The whole-fleet pass: verdict, unanswered questions, each work item's chain,
|
|
3714
|
+
PR, and merge gate. Pure disk read.`,
|
|
3715
|
+
"man:wait": `Block until a dispatch or watched source needs attention; exit 3 on timeout.
|
|
3716
|
+
--peek surfaces standing events without consuming them.`,
|
|
3717
|
+
"man:init": `Install the haul Stop hook into Claude settings; --marker touches
|
|
3718
|
+
.lobstah-man to arm this directory.`,
|
|
3719
|
+
"man:haul": `Stop-hook entry point: park the session while work is in flight (lobsterman
|
|
3720
|
+
or soaking worker); prints hook-decision JSON on an event.`,
|
|
3721
|
+
"man:brief": `SessionStart-hook entry point: announce the session id and fleet state into
|
|
3722
|
+
the conversation.`
|
|
3723
|
+
};
|
|
3724
|
+
function synopsis(cmd) {
|
|
3725
|
+
const spec = COMMANDS[cmd];
|
|
3726
|
+
if (!spec) return `lobstah ${cmd.replace(":", " ")}`;
|
|
3727
|
+
const parts = [`lobstah ${cmd.replace(":", " ")}`];
|
|
3728
|
+
if (spec.subverbs) parts.push(`[${spec.subverbs.join("|")}]`);
|
|
3729
|
+
if (spec.positionals) parts.push(spec.positionals);
|
|
3730
|
+
for (const [flag, f] of Object.entries(spec.flags)) {
|
|
3731
|
+
const body = f.value ? `${flag} ${f.value}` : flag;
|
|
3732
|
+
parts.push(f.required ? body : `[${body}]`);
|
|
3733
|
+
}
|
|
3734
|
+
const lines = [];
|
|
3735
|
+
let line = "";
|
|
3736
|
+
for (const part of parts) {
|
|
3737
|
+
const candidate = line === "" ? part : `${line} ${part}`;
|
|
3738
|
+
if (candidate.length > 78 && line !== "") {
|
|
3739
|
+
lines.push(line);
|
|
3740
|
+
line = ` ${part}`;
|
|
3741
|
+
} else {
|
|
3742
|
+
line = candidate;
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
lines.push(line);
|
|
3746
|
+
return lines.join("\n");
|
|
3747
|
+
}
|
|
3748
|
+
function usageFor(cmd) {
|
|
3749
|
+
if (!COMMANDS[cmd]) return void 0;
|
|
3750
|
+
const prose = PROSE[cmd];
|
|
3751
|
+
return prose ? `${synopsis(cmd)}
|
|
3752
|
+
${prose}` : synopsis(cmd);
|
|
3753
|
+
}
|
|
3754
|
+
var UsageError = class extends Error {
|
|
3755
|
+
};
|
|
3756
|
+
function validateArgs(cmd, args) {
|
|
3757
|
+
const spec = COMMANDS[cmd];
|
|
3758
|
+
if (!spec) return void 0;
|
|
3759
|
+
let positionals = 0;
|
|
3760
|
+
for (let i = 0; i < args.length; i++) {
|
|
3761
|
+
const tok = args[i];
|
|
3762
|
+
if (spec.tailAfter !== void 0 && positionals >= spec.tailAfter) break;
|
|
3763
|
+
if (!tok.startsWith("--")) {
|
|
3764
|
+
if (positionals === 0 && spec.subverbs && !spec.subverbs.includes(tok)) {
|
|
3765
|
+
return { error: `unknown ${cmd} subcommand "${tok}" (expected ${spec.subverbs.join(" | ")})` };
|
|
3766
|
+
}
|
|
3767
|
+
positionals++;
|
|
3768
|
+
continue;
|
|
3769
|
+
}
|
|
3770
|
+
if (tok === "--help") return { help: true };
|
|
3771
|
+
const f = spec.flags[tok];
|
|
3772
|
+
if (!f) return { error: `unknown flag ${tok} for ${cmd.replace(":", " ")}` };
|
|
3773
|
+
if (f.value) i++;
|
|
3774
|
+
}
|
|
3775
|
+
return {};
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3249
3778
|
// apps/cli/src/main.ts
|
|
3250
3779
|
var HELP = `lobstah \u2014 supervision framework for coding agents
|
|
3251
3780
|
|
|
3252
3781
|
work (humans and agents):
|
|
3253
3782
|
dispatch --repo <key> (--brief <file> | --brief-text <text>) (alias: set --bait)
|
|
3254
3783
|
[--harness claude|codex] [--model <m>] [--effort <e>]
|
|
3255
|
-
[--follow-up <uuid>] [--chore] [--id <uuid>]
|
|
3256
|
-
queue a supervised dispatch; prints the id
|
|
3784
|
+
[--follow-up <uuid>] [--for session:<id>] [--chore] [--id <uuid>]
|
|
3785
|
+
queue a supervised dispatch; prints the id.
|
|
3786
|
+
--for addresses the bait to a soaking
|
|
3787
|
+
session instead of a fresh headless worker
|
|
3257
3788
|
ls [--all] queue, active, recent done (alias: buoys)
|
|
3258
3789
|
status [<uuid>] reconciled state (alias: buoy)
|
|
3259
|
-
logs <uuid> [--follow]
|
|
3790
|
+
logs <uuid> [--follow|--full] the normalized event stream (last 50 events
|
|
3791
|
+
by default; --full for everything)
|
|
3260
3792
|
send <uuid> <message> deliver an instruction between agent turns
|
|
3261
3793
|
inbox <uuid> read and acknowledge pending messages
|
|
3262
3794
|
(workers: check at natural checkpoints)
|
|
@@ -3302,7 +3834,7 @@ lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual)
|
|
|
3302
3834
|
man wait [--timeout <secs>] [--peek]
|
|
3303
3835
|
block until a dispatch or watched source
|
|
3304
3836
|
needs attention, then print the event and
|
|
3305
|
-
what to do next; exit
|
|
3837
|
+
what to do next; exit 3 on timeout. Runs due
|
|
3306
3838
|
watch checks itself when no pick process is.
|
|
3307
3839
|
Unanswered questions re-fire every
|
|
3308
3840
|
remindSecs until answered.
|
|
@@ -3323,6 +3855,19 @@ workers (dispatched agents; injected into every brief):
|
|
|
3323
3855
|
the validated status write path
|
|
3324
3856
|
(${VERBS.join(" | ")})
|
|
3325
3857
|
|
|
3858
|
+
soaking (interactive sessions volunteering as workers):
|
|
3859
|
+
soak --session <id> [--one] [--harness claude|codex]
|
|
3860
|
+
put this session in the water: it parks at
|
|
3861
|
+
turn end and takes matching bait from the
|
|
3862
|
+
work queue. Refused from a primary checkout
|
|
3863
|
+
\u2014 soak from a worktree. --one stows after
|
|
3864
|
+
the first catch. The session id comes from
|
|
3865
|
+
the plugin's session-start brief.
|
|
3866
|
+
stow [--session <id>] [--quiet] take the trap out: sign the session off; an
|
|
3867
|
+
open catch goes back to the queue. A stale
|
|
3868
|
+
registration (ghost trap) is swept
|
|
3869
|
+
automatically after [soak].ttlSecs.
|
|
3870
|
+
|
|
3326
3871
|
setup:
|
|
3327
3872
|
init [--scan <dir>... [--pickup]]
|
|
3328
3873
|
create ~/.lobstah + example config; --scan
|
|
@@ -3343,6 +3888,40 @@ function arg(args, flag) {
|
|
|
3343
3888
|
return i >= 0 ? args[i + 1] : void 0;
|
|
3344
3889
|
}
|
|
3345
3890
|
var WATCH_EVERY_SECS = 45;
|
|
3891
|
+
async function soakPark(sessionId, args) {
|
|
3892
|
+
const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
|
|
3893
|
+
const deadline = Date.now() + timeoutSecs * 1e3;
|
|
3894
|
+
const block = (reason) => console.log(JSON.stringify({ decision: "block", reason }));
|
|
3895
|
+
while (true) {
|
|
3896
|
+
const reg = heartbeatSoak(sessionId);
|
|
3897
|
+
if (!reg) return;
|
|
3898
|
+
if (hasOpenCatch(reg)) {
|
|
3899
|
+
const id = reg.claimed;
|
|
3900
|
+
if (cancelRequested(id, "work")) {
|
|
3901
|
+
block(
|
|
3902
|
+
`Your catch ${id} was cancelled. Stop working it, leave the worktree as it is, and run \`lobstah report ${id} failed "cancelled by request"\`.`
|
|
3903
|
+
);
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3906
|
+
if (unhandled(id, "work").length > 0) {
|
|
3907
|
+
block(`New instruction for your catch ${id} \u2014 read it with \`lobstah inbox ${id}\`, act on it, and keep reporting.`);
|
|
3908
|
+
return;
|
|
3909
|
+
}
|
|
3910
|
+
} else {
|
|
3911
|
+
if (reg.one && reg.claimed) {
|
|
3912
|
+
stowSoak(sessionId);
|
|
3913
|
+
return;
|
|
3914
|
+
}
|
|
3915
|
+
const caught = claimBait(reg);
|
|
3916
|
+
if (caught) {
|
|
3917
|
+
block(baitBrief(caught.id, caught.descriptor));
|
|
3918
|
+
return;
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
if (Date.now() >= deadline) return;
|
|
3922
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3346
3925
|
function runDueManWatches() {
|
|
3347
3926
|
for (const w of listWatches()) {
|
|
3348
3927
|
if (w.owner === "man" && watchDue(w, WATCH_EVERY_SECS)) runWatchCheck(w);
|
|
@@ -3361,7 +3940,7 @@ function emitWatchAttention(attns) {
|
|
|
3361
3940
|
function findLane(id) {
|
|
3362
3941
|
for (const lane of ["work", "chore"]) {
|
|
3363
3942
|
const d = laneDirs(lane);
|
|
3364
|
-
if (
|
|
3943
|
+
if (fs28.existsSync(path22.join(d.active, id)) || fs28.existsSync(path22.join(d.queue, `${id}.json`)) || fs28.existsSync(path22.join(d.done, id)) || fs28.existsSync(path22.join(d.state, `${id}.status`))) {
|
|
3365
3944
|
return lane;
|
|
3366
3945
|
}
|
|
3367
3946
|
}
|
|
@@ -3369,7 +3948,7 @@ function findLane(id) {
|
|
|
3369
3948
|
}
|
|
3370
3949
|
function rowsFor(lane, bucket) {
|
|
3371
3950
|
const dir = laneDirs(lane)[bucket];
|
|
3372
|
-
const entries =
|
|
3951
|
+
const entries = fs28.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs28.statSync(path22.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
|
|
3373
3952
|
const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
|
|
3374
3953
|
return sliced.map(({ f, m }) => {
|
|
3375
3954
|
const id = f.replace(/\.json$/, "");
|
|
@@ -3382,10 +3961,20 @@ async function mainCli() {
|
|
|
3382
3961
|
const ALIASES = { set: "dispatch", buoys: "ls", buoy: "status" };
|
|
3383
3962
|
cmd = cmd !== void 0 ? ALIASES[cmd] ?? cmd : cmd;
|
|
3384
3963
|
if (cmd === "man") {
|
|
3385
|
-
cmd = args.length > 0 ? `man:${args[0]}` : "man:manual";
|
|
3964
|
+
cmd = args.length > 0 && args[0] !== "--help" ? `man:${args[0]}` : "man:manual";
|
|
3386
3965
|
args = args.slice(1);
|
|
3387
3966
|
}
|
|
3388
3967
|
ensureLayout();
|
|
3968
|
+
if (cmd !== void 0) {
|
|
3969
|
+
const v = validateArgs(cmd, args);
|
|
3970
|
+
if (v?.help) {
|
|
3971
|
+
console.log(usageFor(cmd));
|
|
3972
|
+
return;
|
|
3973
|
+
}
|
|
3974
|
+
if (v?.error) throw new UsageError(`${v.error}
|
|
3975
|
+
|
|
3976
|
+
${usageFor(cmd)}`);
|
|
3977
|
+
}
|
|
3389
3978
|
switch (cmd) {
|
|
3390
3979
|
case "dispatch": {
|
|
3391
3980
|
const repo = arg(args, "--repo");
|
|
@@ -3394,18 +3983,30 @@ async function mainCli() {
|
|
|
3394
3983
|
if (!repo || !briefFile && !briefText) {
|
|
3395
3984
|
throw new Error("dispatch requires --repo and --brief <file> (or --brief-text)");
|
|
3396
3985
|
}
|
|
3986
|
+
const address = arg(args, "--for");
|
|
3987
|
+
if (address && !address.startsWith("session:")) {
|
|
3988
|
+
throw new Error("dispatch --for takes a session address: --for session:<id>");
|
|
3989
|
+
}
|
|
3397
3990
|
const d = {
|
|
3398
3991
|
id: arg(args, "--id") ?? randomUUID4(),
|
|
3399
3992
|
repo,
|
|
3400
|
-
brief: briefText ??
|
|
3993
|
+
brief: briefText ?? fs28.readFileSync(briefFile, "utf8"),
|
|
3401
3994
|
harness: arg(args, "--harness"),
|
|
3402
3995
|
model: arg(args, "--model"),
|
|
3403
3996
|
effort: arg(args, "--effort"),
|
|
3404
|
-
followUp: arg(args, "--follow-up")
|
|
3997
|
+
followUp: arg(args, "--follow-up"),
|
|
3998
|
+
for: address
|
|
3405
3999
|
};
|
|
3406
4000
|
const lane = args.includes("--chore") ? "chore" : "work";
|
|
3407
4001
|
enqueue(d, lane);
|
|
3408
4002
|
console.log(toonKV({ id: d.id, repo, lane, queued: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
4003
|
+
console.log(
|
|
4004
|
+
toonHelp([
|
|
4005
|
+
`lobstah status ${d.id}`,
|
|
4006
|
+
`lobstah send ${d.id} "<instruction>"`,
|
|
4007
|
+
"lobstah man wait (block until something needs you)"
|
|
4008
|
+
])
|
|
4009
|
+
);
|
|
3409
4010
|
break;
|
|
3410
4011
|
}
|
|
3411
4012
|
case "ls": {
|
|
@@ -3414,6 +4015,7 @@ async function mainCli() {
|
|
|
3414
4015
|
(lane) => ["queue", "active", "done"].flatMap((b) => rowsFor(lane, b))
|
|
3415
4016
|
);
|
|
3416
4017
|
console.log(toonTable("dispatches", rows, ["id", "lane", "bucket", "state", "updated"]));
|
|
4018
|
+
console.log(toonHelp(["lobstah status <id>", "lobstah man tend (verdict + stories + gates)"]));
|
|
3417
4019
|
break;
|
|
3418
4020
|
}
|
|
3419
4021
|
case "status": {
|
|
@@ -3427,6 +4029,11 @@ async function mainCli() {
|
|
|
3427
4029
|
const log = readStatusLog(id, lane);
|
|
3428
4030
|
const state = reconcile({ log, lastEventAt: lastEventAt(id, lane) });
|
|
3429
4031
|
console.log(toonKV({ id, lane, state, lastNote: log.at(-1)?.note, entries: log.length }));
|
|
4032
|
+
console.log(
|
|
4033
|
+
toonHelp(
|
|
4034
|
+
state === "needs-decision" || state === "blocked" ? [`lobstah send ${id} "<answer>"`, `lobstah logs ${id} --follow`] : state === "done" || state === "failed" ? [`lobstah catch ${id} (branch, commits, PR)`] : [`lobstah logs ${id} --follow`, `lobstah send ${id} "<instruction>"`]
|
|
4035
|
+
)
|
|
4036
|
+
);
|
|
3430
4037
|
break;
|
|
3431
4038
|
}
|
|
3432
4039
|
case "logs": {
|
|
@@ -3434,17 +4041,29 @@ async function mainCli() {
|
|
|
3434
4041
|
if (!id) throw new Error("logs requires a dispatch id");
|
|
3435
4042
|
const lane = findLane(id);
|
|
3436
4043
|
const file = eventsPath(id, lane);
|
|
3437
|
-
if (
|
|
4044
|
+
if (fs28.existsSync(file)) {
|
|
4045
|
+
const raw = fs28.readFileSync(file, "utf8");
|
|
4046
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
4047
|
+
const LIMIT = 50;
|
|
4048
|
+
if (!args.includes("--full") && lines.length > LIMIT) {
|
|
4049
|
+
console.log(
|
|
4050
|
+
`(truncated: last ${LIMIT} of ${lines.length} events \u2014 \`lobstah logs ${id} --full\` for all)`
|
|
4051
|
+
);
|
|
4052
|
+
for (const l of lines.slice(-LIMIT)) console.log(l);
|
|
4053
|
+
} else {
|
|
4054
|
+
process.stdout.write(raw);
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
3438
4057
|
if (args.includes("--follow")) {
|
|
3439
|
-
let size =
|
|
4058
|
+
let size = fs28.existsSync(file) ? fs28.statSync(file).size : 0;
|
|
3440
4059
|
setInterval(() => {
|
|
3441
|
-
if (!
|
|
3442
|
-
const now =
|
|
4060
|
+
if (!fs28.existsSync(file)) return;
|
|
4061
|
+
const now = fs28.statSync(file).size;
|
|
3443
4062
|
if (now > size) {
|
|
3444
|
-
const fd =
|
|
4063
|
+
const fd = fs28.openSync(file, "r");
|
|
3445
4064
|
const buf = Buffer.alloc(now - size);
|
|
3446
|
-
|
|
3447
|
-
|
|
4065
|
+
fs28.readSync(fd, buf, 0, buf.length, size);
|
|
4066
|
+
fs28.closeSync(fd);
|
|
3448
4067
|
process.stdout.write(buf.toString("utf8"));
|
|
3449
4068
|
size = now;
|
|
3450
4069
|
}
|
|
@@ -3498,23 +4117,23 @@ async function mainCli() {
|
|
|
3498
4117
|
);
|
|
3499
4118
|
}
|
|
3500
4119
|
const dirs = laneDirs(lane);
|
|
3501
|
-
const descriptorFile = ["active", "done"].map((b) =>
|
|
3502
|
-
const harness = descriptorFile ? JSON.parse(
|
|
4120
|
+
const descriptorFile = ["active", "done"].map((b) => path22.join(dirs[b], id, "descriptor.json")).find((f) => fs28.existsSync(f));
|
|
4121
|
+
const harness = descriptorFile ? JSON.parse(fs28.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
|
|
3503
4122
|
let sessionId;
|
|
3504
4123
|
try {
|
|
3505
|
-
sessionId = JSON.parse(
|
|
4124
|
+
sessionId = JSON.parse(fs28.readFileSync(path22.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
|
|
3506
4125
|
} catch {
|
|
3507
4126
|
}
|
|
3508
4127
|
if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
|
|
3509
|
-
const worktree =
|
|
3510
|
-
const cwd =
|
|
4128
|
+
const worktree = path22.join(lobstahHome(), "worktrees", id);
|
|
4129
|
+
const cwd = fs28.existsSync(worktree) ? worktree : process.cwd();
|
|
3511
4130
|
const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
|
|
3512
4131
|
if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
|
|
3513
4132
|
if (args.includes("--print")) {
|
|
3514
4133
|
console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
|
|
3515
4134
|
break;
|
|
3516
4135
|
}
|
|
3517
|
-
const res =
|
|
4136
|
+
const res = spawnSync6(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
|
|
3518
4137
|
if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
|
|
3519
4138
|
break;
|
|
3520
4139
|
}
|
|
@@ -3522,39 +4141,39 @@ async function mainCli() {
|
|
|
3522
4141
|
const id = args[0];
|
|
3523
4142
|
if (!id) throw new Error("swap requires a dispatch id");
|
|
3524
4143
|
const lane = findLane(id);
|
|
3525
|
-
const activeDir =
|
|
3526
|
-
if (!
|
|
3527
|
-
const descFile =
|
|
3528
|
-
const descriptor = JSON.parse(
|
|
4144
|
+
const activeDir = path22.join(laneDirs(lane).active, id);
|
|
4145
|
+
if (!fs28.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
|
|
4146
|
+
const descFile = path22.join(activeDir, "descriptor.json");
|
|
4147
|
+
const descriptor = JSON.parse(fs28.readFileSync(descFile, "utf8"));
|
|
3529
4148
|
const fromHarness = descriptor.harness ?? "default";
|
|
3530
4149
|
for (const key of ["harness", "model", "effort"]) {
|
|
3531
4150
|
const v = arg(args, `--${key}`);
|
|
3532
4151
|
if (v) descriptor[key] = v;
|
|
3533
4152
|
}
|
|
3534
|
-
|
|
3535
|
-
const worktree =
|
|
4153
|
+
fs28.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
|
|
4154
|
+
const worktree = path22.join(lobstahHome(), "worktrees", id);
|
|
3536
4155
|
let progress = "No worktree progress recorded.";
|
|
3537
|
-
if (
|
|
3538
|
-
const
|
|
3539
|
-
const commits =
|
|
3540
|
-
const status =
|
|
4156
|
+
if (fs28.existsSync(worktree)) {
|
|
4157
|
+
const git4 = (...a) => spawnSync6("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
4158
|
+
const commits = git4("log", "--oneline", "-15");
|
|
4159
|
+
const status = git4("status", "--short");
|
|
3541
4160
|
progress = `Commits so far:
|
|
3542
4161
|
${commits || "(none)"}
|
|
3543
4162
|
|
|
3544
4163
|
Uncommitted changes:
|
|
3545
4164
|
${status || "(clean)"}`;
|
|
3546
4165
|
}
|
|
3547
|
-
|
|
3548
|
-
|
|
4166
|
+
fs28.writeFileSync(
|
|
4167
|
+
path22.join(activeDir, "handoff"),
|
|
3549
4168
|
`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.
|
|
3550
4169
|
|
|
3551
4170
|
${progress}`
|
|
3552
4171
|
);
|
|
3553
|
-
const runnerFile =
|
|
3554
|
-
if (
|
|
3555
|
-
const runner = JSON.parse(
|
|
4172
|
+
const runnerFile = path22.join(activeDir, "runner.json");
|
|
4173
|
+
if (fs28.existsSync(runnerFile)) {
|
|
4174
|
+
const runner = JSON.parse(fs28.readFileSync(runnerFile, "utf8"));
|
|
3556
4175
|
if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
|
|
3557
|
-
|
|
4176
|
+
fs28.unlinkSync(runnerFile);
|
|
3558
4177
|
}
|
|
3559
4178
|
appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
|
|
3560
4179
|
console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
|
|
@@ -3655,24 +4274,24 @@ ${progress}`
|
|
|
3655
4274
|
}
|
|
3656
4275
|
}
|
|
3657
4276
|
console.log(toonKV({ timeout: true, waitedSecs: timeoutSecs }));
|
|
3658
|
-
process.exitCode =
|
|
4277
|
+
process.exitCode = 3;
|
|
3659
4278
|
break;
|
|
3660
4279
|
}
|
|
3661
4280
|
case "man:init": {
|
|
3662
|
-
const file = args.includes("--global") ?
|
|
4281
|
+
const file = args.includes("--global") ? path22.join(os6.homedir(), ".claude", "settings.json") : path22.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
|
|
3663
4282
|
let existing;
|
|
3664
4283
|
try {
|
|
3665
|
-
existing = JSON.parse(
|
|
4284
|
+
existing = JSON.parse(fs28.readFileSync(file, "utf8"));
|
|
3666
4285
|
} catch {
|
|
3667
4286
|
existing = void 0;
|
|
3668
4287
|
}
|
|
3669
4288
|
const { settings, changed } = mergeHaulHook(existing);
|
|
3670
4289
|
if (changed) {
|
|
3671
|
-
|
|
3672
|
-
|
|
4290
|
+
fs28.mkdirSync(path22.dirname(file), { recursive: true });
|
|
4291
|
+
fs28.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
3673
4292
|
`);
|
|
3674
4293
|
}
|
|
3675
|
-
if (args.includes("--marker"))
|
|
4294
|
+
if (args.includes("--marker")) fs28.writeFileSync(".lobstah-man", "");
|
|
3676
4295
|
console.log(
|
|
3677
4296
|
toonKV({
|
|
3678
4297
|
hook: "lobstah man haul",
|
|
@@ -3685,9 +4304,15 @@ ${progress}`
|
|
|
3685
4304
|
}
|
|
3686
4305
|
case "man:haul": {
|
|
3687
4306
|
try {
|
|
3688
|
-
|
|
4307
|
+
const hook = readHookStdin();
|
|
4308
|
+
const soakReg = hook?.session_id ? readSoak(hook.session_id) : void 0;
|
|
4309
|
+
if (soakReg) {
|
|
4310
|
+
await soakPark(soakReg.sessionId, args);
|
|
4311
|
+
break;
|
|
4312
|
+
}
|
|
4313
|
+
if (process.env.LOBSTAH_MAN !== "1" && !fs28.existsSync(".lobstah-man")) break;
|
|
3689
4314
|
const anyActive = ["work", "chore"].some(
|
|
3690
|
-
(l) =>
|
|
4315
|
+
(l) => fs28.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
|
|
3691
4316
|
);
|
|
3692
4317
|
const anyWatch = listWatches().some((w) => w.owner === "man");
|
|
3693
4318
|
if (!anyActive && !anyWatch) break;
|
|
@@ -3727,6 +4352,87 @@ ${progress}`
|
|
|
3727
4352
|
}
|
|
3728
4353
|
break;
|
|
3729
4354
|
}
|
|
4355
|
+
case "man:brief": {
|
|
4356
|
+
const hook = readHookStdin();
|
|
4357
|
+
if (!hook?.session_id) break;
|
|
4358
|
+
let fleet = "";
|
|
4359
|
+
try {
|
|
4360
|
+
const r = buildTendReport();
|
|
4361
|
+
fleet = ` Fleet: ${r.verdict} (${r.counts.queued} queued, ${r.counts.active} active` + (r.attention.length > 0 ? `, ${r.attention.length} awaiting a human` : "") + ") \u2014 `lobstah` for the live view.";
|
|
4362
|
+
} catch {
|
|
4363
|
+
}
|
|
4364
|
+
const soaking = readSoak(hook.session_id) !== void 0;
|
|
4365
|
+
const context = soaking ? `lobstah: session id ${hook.session_id} \u2014 this session is soaking (a volunteered worker); \`lobstah stow --session ${hook.session_id}\` signs it off.${fleet}` : `lobstah: session id ${hook.session_id} (for \`lobstah soak|stow --session <id>\`).${fleet}`;
|
|
4366
|
+
console.log(
|
|
4367
|
+
JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } })
|
|
4368
|
+
);
|
|
4369
|
+
break;
|
|
4370
|
+
}
|
|
4371
|
+
case "soak": {
|
|
4372
|
+
const sessionId = arg(args, "--session");
|
|
4373
|
+
if (!sessionId) {
|
|
4374
|
+
throw new Error(
|
|
4375
|
+
"soak requires --session <id> \u2014 the harness session id, announced at session start by the lobstah plugin (`lobstah man brief`)"
|
|
4376
|
+
);
|
|
4377
|
+
}
|
|
4378
|
+
const cfg = loadConfig();
|
|
4379
|
+
const site = inspectSoakSite(process.cwd(), cfg.repos);
|
|
4380
|
+
if (!site) throw new Error("soak must run inside a git checkout \u2014 the trap is the worktree");
|
|
4381
|
+
if (site.primary) {
|
|
4382
|
+
throw new Error(
|
|
4383
|
+
"this is the repo's primary checkout \u2014 never claimable. Create a worktree (`git worktree add ../<name> -b <branch>`) and soak from there."
|
|
4384
|
+
);
|
|
4385
|
+
}
|
|
4386
|
+
const rival = listSoaking().find((r) => r.sessionId !== sessionId && r.worktree === site.worktree);
|
|
4387
|
+
if (rival) {
|
|
4388
|
+
throw new Error(
|
|
4389
|
+
`session ${rival.sessionId.slice(0, 8)} already soaks this worktree \u2014 one trap per worktree. Stow it first (\`lobstah stow --session ${rival.sessionId}\`) or soak from another worktree.`
|
|
4390
|
+
);
|
|
4391
|
+
}
|
|
4392
|
+
const reg = signOnSoak({
|
|
4393
|
+
sessionId,
|
|
4394
|
+
harness: arg(args, "--harness") ?? "claude",
|
|
4395
|
+
repo: site.repoKey,
|
|
4396
|
+
worktree: site.worktree,
|
|
4397
|
+
cwd: process.cwd(),
|
|
4398
|
+
one: args.includes("--one") || void 0
|
|
4399
|
+
});
|
|
4400
|
+
console.log(
|
|
4401
|
+
toonKV({
|
|
4402
|
+
soaking: sessionId,
|
|
4403
|
+
repo: reg.repo ?? "(none configured \u2014 addressed bait only)",
|
|
4404
|
+
worktree: reg.worktree,
|
|
4405
|
+
...reg.one ? { one: true } : {},
|
|
4406
|
+
note: "parks at turn end via the Stop hook (lobstah plugin or `man init --global`); bait arrives as a wake"
|
|
4407
|
+
})
|
|
4408
|
+
);
|
|
4409
|
+
console.log(toonHelp([`lobstah stow --session ${sessionId} (sign off)`]));
|
|
4410
|
+
break;
|
|
4411
|
+
}
|
|
4412
|
+
case "stow": {
|
|
4413
|
+
const sessionId = arg(args, "--session") ?? readHookStdin()?.session_id;
|
|
4414
|
+
const quiet = args.includes("--quiet");
|
|
4415
|
+
if (!sessionId) {
|
|
4416
|
+
if (quiet) break;
|
|
4417
|
+
throw new Error("stow requires --session <id> (or hook input on stdin)");
|
|
4418
|
+
}
|
|
4419
|
+
const reg = stowSoak(sessionId);
|
|
4420
|
+
if (!reg) {
|
|
4421
|
+
if (!quiet) console.log(toonKV({ session: sessionId, soaking: false }));
|
|
4422
|
+
break;
|
|
4423
|
+
}
|
|
4424
|
+
const released = releaseCatch(reg);
|
|
4425
|
+
if (!quiet) {
|
|
4426
|
+
console.log(
|
|
4427
|
+
toonKV({
|
|
4428
|
+
stowed: sessionId,
|
|
4429
|
+
...released.requeued ? { requeued: released.requeued } : {},
|
|
4430
|
+
...released.finalized ? { finalized: released.finalized } : {}
|
|
4431
|
+
})
|
|
4432
|
+
);
|
|
4433
|
+
}
|
|
4434
|
+
break;
|
|
4435
|
+
}
|
|
3730
4436
|
case "daemon":
|
|
3731
4437
|
case "pick": {
|
|
3732
4438
|
const kind = cmd;
|
|
@@ -3768,7 +4474,7 @@ ${progress}`
|
|
|
3768
4474
|
path: r.path,
|
|
3769
4475
|
trunk: r.trunk,
|
|
3770
4476
|
pickup: r.pickup ?? false,
|
|
3771
|
-
exists:
|
|
4477
|
+
exists: fs28.existsSync(r.path)
|
|
3772
4478
|
}));
|
|
3773
4479
|
console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
|
|
3774
4480
|
break;
|
|
@@ -3793,6 +4499,11 @@ ${progress}`
|
|
|
3793
4499
|
stream: arg(args, "--stream")
|
|
3794
4500
|
});
|
|
3795
4501
|
console.log(toonKV({ key: w.key, owner: w.owner, cursor: w.cursor, registered: true }));
|
|
4502
|
+
console.log(
|
|
4503
|
+
toonHelp(
|
|
4504
|
+
w.owner === "man" ? ["lobstah man wait (its events wake you)", "lobstah watch (list watches)"] : ["lobstah watch (list watches; events fork the owning chain)"]
|
|
4505
|
+
)
|
|
4506
|
+
);
|
|
3796
4507
|
break;
|
|
3797
4508
|
}
|
|
3798
4509
|
if (sub === "rm") {
|
|
@@ -3813,7 +4524,7 @@ ${progress}`
|
|
|
3813
4524
|
break;
|
|
3814
4525
|
}
|
|
3815
4526
|
case "init": {
|
|
3816
|
-
if (!
|
|
4527
|
+
if (!fs28.existsSync(configPath())) {
|
|
3817
4528
|
const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
|
|
3818
4529
|
path = "~/src/example"
|
|
3819
4530
|
trunk = "main"
|
|
@@ -3822,7 +4533,7 @@ trunk = "main"
|
|
|
3822
4533
|
# pickup = true # opt into [pickup.github] multi-repo tracker pickup
|
|
3823
4534
|
|
|
3824
4535
|
`;
|
|
3825
|
-
|
|
4536
|
+
fs28.writeFileSync(
|
|
3826
4537
|
configPath(),
|
|
3827
4538
|
`# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
|
|
3828
4539
|
# Top-level keys must come before any [section].
|
|
@@ -3860,12 +4571,12 @@ wallClockSecs = 3600
|
|
|
3860
4571
|
let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
|
|
3861
4572
|
try {
|
|
3862
4573
|
const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
|
|
3863
|
-
if (
|
|
4574
|
+
if (fs28.existsSync(local)) reference = local;
|
|
3864
4575
|
} catch {
|
|
3865
4576
|
}
|
|
3866
4577
|
console.log(
|
|
3867
4578
|
toonKV({
|
|
3868
|
-
home:
|
|
4579
|
+
home: path22.dirname(configPath()),
|
|
3869
4580
|
config: configPath(),
|
|
3870
4581
|
...added.length > 0 ? { added: added.join(", ") } : {},
|
|
3871
4582
|
...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
|
|
@@ -3879,18 +4590,35 @@ wallClockSecs = 3600
|
|
|
3879
4590
|
case "-v":
|
|
3880
4591
|
console.log(lobstahVersion());
|
|
3881
4592
|
break;
|
|
3882
|
-
case void 0:
|
|
4593
|
+
case void 0: {
|
|
4594
|
+
console.log(`lobstah ${lobstahVersion()} \u2014 supervision for coding agents (home: ${lobstahHome()})`);
|
|
4595
|
+
console.log("");
|
|
4596
|
+
console.log(renderTend(buildTendReport()));
|
|
4597
|
+
console.log("");
|
|
4598
|
+
console.log(
|
|
4599
|
+
toonHelp([
|
|
4600
|
+
'lobstah dispatch --repo <key> --brief-text "<brief>" queue work',
|
|
4601
|
+
"lobstah status <id> one dispatch",
|
|
4602
|
+
"lobstah man tend full fleet pass",
|
|
4603
|
+
"lobstah help every command"
|
|
4604
|
+
])
|
|
4605
|
+
);
|
|
4606
|
+
break;
|
|
4607
|
+
}
|
|
3883
4608
|
case "--help":
|
|
3884
4609
|
case "help":
|
|
3885
4610
|
console.log(HELP);
|
|
3886
4611
|
break;
|
|
3887
4612
|
default:
|
|
3888
|
-
throw new
|
|
4613
|
+
throw new UsageError(`unknown command "${cmd}" \u2014 run \`lobstah help\``);
|
|
3889
4614
|
}
|
|
3890
4615
|
}
|
|
3891
4616
|
mainCli().catch((err) => {
|
|
3892
|
-
|
|
3893
|
-
|
|
4617
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4618
|
+
const [first, ...rest] = msg.split("\n");
|
|
4619
|
+
console.log(toonKV({ error: first }));
|
|
4620
|
+
if (rest.length > 0) console.log(rest.join("\n").trimStart());
|
|
4621
|
+
process.exit(err instanceof UsageError ? 2 : 1);
|
|
3894
4622
|
});
|
|
3895
4623
|
/*! Bundled license information:
|
|
3896
4624
|
|