lobstah 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/main.js +624 -177
- package/dist/runner.js +2 -1
- package/docs/configuration.md +122 -0
- package/docs/design.md +669 -0
- package/docs/lobsterman.md +201 -0
- package/docs/openclaw.md +76 -0
- package/docs/pickup.md +319 -0
- package/docs/vocabulary.md +118 -0
- package/package.json +2 -1
package/dist/main.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// apps/cli/src/main.ts
|
|
4
|
-
import * as
|
|
5
|
-
import * as
|
|
4
|
+
import * as fs23 from "node:fs";
|
|
5
|
+
import * as os6 from "node:os";
|
|
6
|
+
import * as path17 from "node:path";
|
|
6
7
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
7
|
-
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
9
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
8
10
|
|
|
9
11
|
// packages/core/dist/types.js
|
|
10
12
|
var VERBS = ["working", "needs-decision", "blocked", "paused", "done", "failed"];
|
|
@@ -868,7 +870,8 @@ function loadConfig() {
|
|
|
868
870
|
trunk: String(r.trunk ?? "main"),
|
|
869
871
|
setup: Array.isArray(r.setup) ? r.setup.map(String) : void 0,
|
|
870
872
|
env: r.env ?? void 0,
|
|
871
|
-
harness: r.harness ?? void 0
|
|
873
|
+
harness: r.harness ?? void 0,
|
|
874
|
+
pickup: r.pickup === void 0 ? void 0 : Boolean(r.pickup)
|
|
872
875
|
};
|
|
873
876
|
}
|
|
874
877
|
return {
|
|
@@ -898,6 +901,68 @@ function toonTable(name, rows, cols) {
|
|
|
898
901
|
return [header, ...rows.map((r) => ` ${cols.map((c) => cell(r[c])).join(",")}`)].join("\n");
|
|
899
902
|
}
|
|
900
903
|
|
|
904
|
+
// packages/core/dist/version.js
|
|
905
|
+
import * as fs8 from "node:fs";
|
|
906
|
+
function lobstahVersion() {
|
|
907
|
+
try {
|
|
908
|
+
const pkg = JSON.parse(fs8.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
909
|
+
if (pkg.name === "lobstah" && typeof pkg.version === "string")
|
|
910
|
+
return pkg.version;
|
|
911
|
+
} catch {
|
|
912
|
+
}
|
|
913
|
+
return "0.0.0-dev";
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// packages/core/dist/harness.js
|
|
917
|
+
import * as fs9 from "node:fs";
|
|
918
|
+
import * as path5 from "node:path";
|
|
919
|
+
import { createRequire } from "node:module";
|
|
920
|
+
function onPath(bin, pathVar = process.env.PATH) {
|
|
921
|
+
if (!pathVar)
|
|
922
|
+
return false;
|
|
923
|
+
const names = process.platform === "win32" ? [bin, `${bin}.exe`, `${bin}.cmd`, `${bin}.bat`] : [bin];
|
|
924
|
+
for (const dir of pathVar.split(path5.delimiter)) {
|
|
925
|
+
if (!dir)
|
|
926
|
+
continue;
|
|
927
|
+
for (const name of names) {
|
|
928
|
+
const candidate = path5.join(dir, name);
|
|
929
|
+
try {
|
|
930
|
+
fs9.accessSync(candidate, fs9.constants.X_OK);
|
|
931
|
+
if (fs9.statSync(candidate).isFile())
|
|
932
|
+
return true;
|
|
933
|
+
} catch {
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
function resolvable(specifier) {
|
|
940
|
+
try {
|
|
941
|
+
createRequire(import.meta.url).resolve(specifier);
|
|
942
|
+
return true;
|
|
943
|
+
} catch {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
function detectHarnesses() {
|
|
948
|
+
const out = [];
|
|
949
|
+
if (onPath("claude") || resolvable("@anthropic-ai/claude-agent-sdk"))
|
|
950
|
+
out.push("claude");
|
|
951
|
+
if (onPath("codex") || resolvable("@openai/codex-sdk"))
|
|
952
|
+
out.push("codex");
|
|
953
|
+
return out;
|
|
954
|
+
}
|
|
955
|
+
function codexInvocation(argv) {
|
|
956
|
+
if (onPath("codex"))
|
|
957
|
+
return { file: "codex", argv };
|
|
958
|
+
try {
|
|
959
|
+
const cli = createRequire(import.meta.url).resolve("@openai/codex/bin/codex.js");
|
|
960
|
+
return { file: process.execPath, argv: [cli, ...argv] };
|
|
961
|
+
} catch {
|
|
962
|
+
return void 0;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
901
966
|
// packages/supervisor/dist/liveness.js
|
|
902
967
|
import { execFileSync } from "node:child_process";
|
|
903
968
|
function classify(input) {
|
|
@@ -956,26 +1021,26 @@ function killGroup(pid, signal = "SIGTERM") {
|
|
|
956
1021
|
}
|
|
957
1022
|
|
|
958
1023
|
// packages/supervisor/dist/daemon.js
|
|
959
|
-
import * as
|
|
1024
|
+
import * as fs11 from "node:fs";
|
|
960
1025
|
import * as os3 from "node:os";
|
|
961
|
-
import * as
|
|
1026
|
+
import * as path7 from "node:path";
|
|
962
1027
|
import { spawn as spawn2 } from "node:child_process";
|
|
963
|
-
import { createRequire } from "node:module";
|
|
1028
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
964
1029
|
import { fileURLToPath } from "node:url";
|
|
965
1030
|
|
|
966
1031
|
// packages/supervisor/dist/notify.js
|
|
967
|
-
import * as
|
|
968
|
-
import * as
|
|
1032
|
+
import * as fs10 from "node:fs";
|
|
1033
|
+
import * as path6 from "node:path";
|
|
969
1034
|
import { spawn } from "node:child_process";
|
|
970
1035
|
var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
|
|
971
1036
|
function cursorPath(id, lane) {
|
|
972
|
-
return
|
|
1037
|
+
return path6.join(laneDirs(lane).state, `${id}.notified`);
|
|
973
1038
|
}
|
|
974
1039
|
function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
|
|
975
1040
|
const log = readStatusLog(id, lane);
|
|
976
1041
|
let cursor;
|
|
977
1042
|
try {
|
|
978
|
-
cursor = Number(
|
|
1043
|
+
cursor = Number(fs10.readFileSync(cursorPath(id, lane), "utf8")) || 0;
|
|
979
1044
|
} catch {
|
|
980
1045
|
cursor = void 0;
|
|
981
1046
|
}
|
|
@@ -985,13 +1050,13 @@ function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs =
|
|
|
985
1050
|
if (cursor === void 0 && sinceMs > 0) {
|
|
986
1051
|
fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
|
|
987
1052
|
}
|
|
988
|
-
|
|
1053
|
+
fs10.writeFileSync(cursorPath(id, lane), String(log.length));
|
|
989
1054
|
return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
|
|
990
1055
|
}
|
|
991
1056
|
function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
|
|
992
1057
|
const dir = laneDirs(lane).state;
|
|
993
1058
|
const cutoff = Date.now() - maxAgeMs;
|
|
994
|
-
return
|
|
1059
|
+
return fs10.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs10.statSync(path6.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
|
|
995
1060
|
}
|
|
996
1061
|
function execNotify(command, ev, log = () => {
|
|
997
1062
|
}) {
|
|
@@ -1021,11 +1086,11 @@ function captureWaitBaseline() {
|
|
|
1021
1086
|
return lengths;
|
|
1022
1087
|
}
|
|
1023
1088
|
function attnCursorPath(id, lane) {
|
|
1024
|
-
return
|
|
1089
|
+
return path6.join(laneDirs(lane).state, `${id}.attn`);
|
|
1025
1090
|
}
|
|
1026
1091
|
function readAttnCursor(file) {
|
|
1027
1092
|
try {
|
|
1028
|
-
const parsed = JSON.parse(
|
|
1093
|
+
const parsed = JSON.parse(fs10.readFileSync(file, "utf8"));
|
|
1029
1094
|
return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
|
|
1030
1095
|
} catch {
|
|
1031
1096
|
return { seen: 0, at: 0 };
|
|
@@ -1051,7 +1116,7 @@ function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
|
|
|
1051
1116
|
continue;
|
|
1052
1117
|
out.push({ id, lane, entry });
|
|
1053
1118
|
if (consume)
|
|
1054
|
-
|
|
1119
|
+
fs10.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
|
|
1055
1120
|
}
|
|
1056
1121
|
}
|
|
1057
1122
|
return out;
|
|
@@ -1076,7 +1141,7 @@ function freshWakeEvents(baseline, verbs = DEFAULT_NOTIFY_VERBS) {
|
|
|
1076
1141
|
}
|
|
1077
1142
|
|
|
1078
1143
|
// packages/supervisor/dist/daemon.js
|
|
1079
|
-
var require_ =
|
|
1144
|
+
var require_ = createRequire2(import.meta.url);
|
|
1080
1145
|
function runnerEntry() {
|
|
1081
1146
|
try {
|
|
1082
1147
|
return require_.resolve("@lobstah/runner");
|
|
@@ -1086,33 +1151,33 @@ function runnerEntry() {
|
|
|
1086
1151
|
}
|
|
1087
1152
|
function readRunnerInfo(dir) {
|
|
1088
1153
|
try {
|
|
1089
|
-
return JSON.parse(
|
|
1154
|
+
return JSON.parse(fs11.readFileSync(path7.join(dir, "runner.json"), "utf8"));
|
|
1090
1155
|
} catch {
|
|
1091
1156
|
return void 0;
|
|
1092
1157
|
}
|
|
1093
1158
|
}
|
|
1094
1159
|
function listActive(lane) {
|
|
1095
1160
|
const dir = laneDirs(lane).active;
|
|
1096
|
-
return
|
|
1097
|
-
const d =
|
|
1161
|
+
return fs11.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
|
|
1162
|
+
const d = path7.join(dir, id);
|
|
1098
1163
|
return { id, lane, dir: d, runner: readRunnerInfo(d) };
|
|
1099
1164
|
});
|
|
1100
1165
|
}
|
|
1101
1166
|
function finalize(st) {
|
|
1102
1167
|
try {
|
|
1103
|
-
|
|
1168
|
+
fs11.renameSync(st.dir, path7.join(laneDirs(st.lane).done, st.id));
|
|
1104
1169
|
} catch {
|
|
1105
1170
|
}
|
|
1106
1171
|
}
|
|
1107
1172
|
function spawnRunner(st, opts) {
|
|
1108
1173
|
let nudge = opts.nudge;
|
|
1109
|
-
const handoffFile =
|
|
1110
|
-
if (!nudge &&
|
|
1111
|
-
nudge =
|
|
1112
|
-
|
|
1174
|
+
const handoffFile = path7.join(st.dir, "handoff");
|
|
1175
|
+
if (!nudge && fs11.existsSync(handoffFile)) {
|
|
1176
|
+
nudge = fs11.readFileSync(handoffFile, "utf8");
|
|
1177
|
+
fs11.unlinkSync(handoffFile);
|
|
1113
1178
|
}
|
|
1114
|
-
const logPath =
|
|
1115
|
-
const log =
|
|
1179
|
+
const logPath = path7.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
|
|
1180
|
+
const log = fs11.openSync(logPath, "a");
|
|
1116
1181
|
const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
|
|
1117
1182
|
detached: true,
|
|
1118
1183
|
stdio: ["ignore", log, log],
|
|
@@ -1123,7 +1188,7 @@ function spawnRunner(st, opts) {
|
|
|
1123
1188
|
...nudge ? { LOBSTAH_NUDGE: nudge } : {}
|
|
1124
1189
|
}
|
|
1125
1190
|
});
|
|
1126
|
-
|
|
1191
|
+
fs11.closeSync(log);
|
|
1127
1192
|
child.unref();
|
|
1128
1193
|
const info = {
|
|
1129
1194
|
pid: child.pid ?? -1,
|
|
@@ -1131,22 +1196,22 @@ function spawnRunner(st, opts) {
|
|
|
1131
1196
|
processStartTime: child.pid ? processStartTime(child.pid) : void 0,
|
|
1132
1197
|
attempts: opts.attempts
|
|
1133
1198
|
};
|
|
1134
|
-
|
|
1199
|
+
fs11.writeFileSync(path7.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
|
|
1135
1200
|
}
|
|
1136
1201
|
function sessionOf(st) {
|
|
1137
1202
|
try {
|
|
1138
|
-
const ev = JSON.parse(
|
|
1203
|
+
const ev = JSON.parse(fs11.readFileSync(path7.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
|
|
1139
1204
|
return ev.sessionId;
|
|
1140
1205
|
} catch {
|
|
1141
1206
|
return void 0;
|
|
1142
1207
|
}
|
|
1143
1208
|
}
|
|
1144
1209
|
function reconcileOne(st, cfg, log) {
|
|
1145
|
-
const hasDescriptor =
|
|
1210
|
+
const hasDescriptor = fs11.existsSync(path7.join(st.dir, "descriptor.json"));
|
|
1146
1211
|
if (!hasDescriptor) {
|
|
1147
|
-
const age = Date.now() -
|
|
1212
|
+
const age = Date.now() - fs11.statSync(st.dir).mtimeMs;
|
|
1148
1213
|
if (age > 5 * 6e4)
|
|
1149
|
-
|
|
1214
|
+
fs11.rmSync(st.dir, { recursive: true, force: true });
|
|
1150
1215
|
return;
|
|
1151
1216
|
}
|
|
1152
1217
|
const statusLog = readStatusLog(st.id, st.lane);
|
|
@@ -1224,22 +1289,22 @@ function writeHeartbeat(cfg) {
|
|
|
1224
1289
|
const payload = {
|
|
1225
1290
|
machineId: os3.hostname(),
|
|
1226
1291
|
repos: Object.keys(cfg.repos),
|
|
1227
|
-
harnesses:
|
|
1292
|
+
harnesses: detectHarnesses(),
|
|
1228
1293
|
maxConcurrent: cfg.limits.maxConcurrent,
|
|
1229
|
-
version:
|
|
1294
|
+
version: lobstahVersion(),
|
|
1230
1295
|
heartbeat: (/* @__PURE__ */ new Date()).toISOString()
|
|
1231
1296
|
};
|
|
1232
1297
|
const tmp = `${executorPath()}.tmp`;
|
|
1233
|
-
|
|
1234
|
-
|
|
1298
|
+
fs11.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
1299
|
+
fs11.renameSync(tmp, executorPath());
|
|
1235
1300
|
}
|
|
1236
1301
|
function pruneChores(cfg) {
|
|
1237
1302
|
const dir = laneDirs("chore").done;
|
|
1238
1303
|
const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
|
|
1239
|
-
for (const id of
|
|
1240
|
-
const p =
|
|
1241
|
-
if (
|
|
1242
|
-
|
|
1304
|
+
for (const id of fs11.readdirSync(dir)) {
|
|
1305
|
+
const p = path7.join(dir, id);
|
|
1306
|
+
if (fs11.statSync(p).mtimeMs < cutoff)
|
|
1307
|
+
fs11.rmSync(p, { recursive: true, force: true });
|
|
1243
1308
|
}
|
|
1244
1309
|
}
|
|
1245
1310
|
var daemonStartedAt = Date.now();
|
|
@@ -1279,9 +1344,9 @@ function tick(log = () => {
|
|
|
1279
1344
|
pruneChores(loadConfig());
|
|
1280
1345
|
}
|
|
1281
1346
|
function acquireDaemonLock() {
|
|
1282
|
-
const lockPath =
|
|
1347
|
+
const lockPath = path7.join(lobstahHome(), "daemon.lock");
|
|
1283
1348
|
try {
|
|
1284
|
-
const prior = JSON.parse(
|
|
1349
|
+
const prior = JSON.parse(fs11.readFileSync(lockPath, "utf8"));
|
|
1285
1350
|
if (pidAlive(prior.pid, prior.processStartTime)) {
|
|
1286
1351
|
throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
|
|
1287
1352
|
}
|
|
@@ -1289,10 +1354,10 @@ function acquireDaemonLock() {
|
|
|
1289
1354
|
if (err instanceof Error && err.message.includes("already supervises"))
|
|
1290
1355
|
throw err;
|
|
1291
1356
|
}
|
|
1292
|
-
|
|
1357
|
+
fs11.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
|
|
1293
1358
|
const release = () => {
|
|
1294
1359
|
try {
|
|
1295
|
-
|
|
1360
|
+
fs11.unlinkSync(lockPath);
|
|
1296
1361
|
} catch {
|
|
1297
1362
|
}
|
|
1298
1363
|
};
|
|
@@ -1322,7 +1387,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
|
|
|
1322
1387
|
import { spawn as spawn3 } from "node:child_process";
|
|
1323
1388
|
|
|
1324
1389
|
// apps/pick/dist/config.js
|
|
1325
|
-
import * as
|
|
1390
|
+
import * as fs12 from "node:fs";
|
|
1326
1391
|
import { execSync } from "node:child_process";
|
|
1327
1392
|
import * as os4 from "node:os";
|
|
1328
1393
|
|
|
@@ -1341,6 +1406,11 @@ function marker(uuid) {
|
|
|
1341
1406
|
}
|
|
1342
1407
|
|
|
1343
1408
|
// apps/pick/dist/config.js
|
|
1409
|
+
function githubRepoFromOrigin(origin) {
|
|
1410
|
+
const m = /^git@github\.com:(.+?)(?:\.git)?$/.exec(origin) ?? /^ssh:\/\/git@github\.com\/(.+?)(?:\.git)?$/.exec(origin) ?? /^https:\/\/github\.com\/(.+?)(?:\.git)?\/?$/.exec(origin);
|
|
1411
|
+
const repo = m?.[1];
|
|
1412
|
+
return repo && repo.split("/").length === 2 ? repo : void 0;
|
|
1413
|
+
}
|
|
1344
1414
|
function resolveTokenSource(section, label) {
|
|
1345
1415
|
const command = section.tokenCommand ? String(section.tokenCommand) : void 0;
|
|
1346
1416
|
const file = section.tokenFile ? String(section.tokenFile) : void 0;
|
|
@@ -1359,7 +1429,7 @@ function resolveTokenSource(section, label) {
|
|
|
1359
1429
|
}
|
|
1360
1430
|
if (file) {
|
|
1361
1431
|
return () => {
|
|
1362
|
-
const token =
|
|
1432
|
+
const token = fs12.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
|
|
1363
1433
|
if (!token)
|
|
1364
1434
|
throw new Error(`${label}: tokenFile ${file} is empty`);
|
|
1365
1435
|
return token;
|
|
@@ -1377,27 +1447,55 @@ function resolveTokenSource(section, label) {
|
|
|
1377
1447
|
}
|
|
1378
1448
|
function loadPickupConfig() {
|
|
1379
1449
|
const file = configPath();
|
|
1380
|
-
const raw =
|
|
1450
|
+
const raw = fs12.existsSync(file) ? parse(fs12.readFileSync(file, "utf8")) : {};
|
|
1381
1451
|
const p = raw.pickup ?? {};
|
|
1382
1452
|
const out = {
|
|
1383
1453
|
pollSecs: Number(p.pollSecs ?? 45),
|
|
1384
|
-
notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0
|
|
1454
|
+
notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0,
|
|
1455
|
+
github: []
|
|
1385
1456
|
};
|
|
1386
1457
|
const gh = p.github;
|
|
1387
1458
|
if (gh) {
|
|
1388
1459
|
const token = resolveTokenSource({ tokenEnv: "GITHUB_TOKEN", ...gh }, "pickup.github");
|
|
1389
1460
|
token();
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1461
|
+
const identity = String(gh.identity ?? "");
|
|
1462
|
+
if (!identity)
|
|
1463
|
+
throw new Error("pickup.github requires identity");
|
|
1464
|
+
const shared = {
|
|
1465
|
+
identity,
|
|
1394
1466
|
token,
|
|
1395
1467
|
startLabel: String(gh.startLabel ?? "lobstah"),
|
|
1396
|
-
claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed")
|
|
1397
|
-
merge: { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} }
|
|
1468
|
+
claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed")
|
|
1398
1469
|
};
|
|
1399
|
-
|
|
1400
|
-
|
|
1470
|
+
const mergeBase = { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} };
|
|
1471
|
+
const overrides = gh.overrides ?? {};
|
|
1472
|
+
if (gh.repo || gh.key) {
|
|
1473
|
+
const single = { ...shared, repo: String(gh.repo ?? ""), key: String(gh.key ?? ""), merge: mergeBase };
|
|
1474
|
+
if (!single.repo || !single.key)
|
|
1475
|
+
throw new Error("pickup.github requires repo, key, and identity");
|
|
1476
|
+
out.github.push(single);
|
|
1477
|
+
} else {
|
|
1478
|
+
for (const [key, repo] of Object.entries(loadConfig().repos)) {
|
|
1479
|
+
if (!repo.pickup)
|
|
1480
|
+
continue;
|
|
1481
|
+
if (!repo.origin)
|
|
1482
|
+
throw new Error(`repos.${key} has pickup = true but no origin to derive the GitHub repo from`);
|
|
1483
|
+
const forgeRepo = githubRepoFromOrigin(repo.origin);
|
|
1484
|
+
if (!forgeRepo)
|
|
1485
|
+
throw new Error(`repos.${key} has pickup = true but origin "${repo.origin}" is not a GitHub URL`);
|
|
1486
|
+
const o = overrides[key] ?? {};
|
|
1487
|
+
out.github.push({
|
|
1488
|
+
...shared,
|
|
1489
|
+
repo: forgeRepo,
|
|
1490
|
+
key,
|
|
1491
|
+
startLabel: o.startLabel ? String(o.startLabel) : shared.startLabel,
|
|
1492
|
+
claimedLabel: o.claimedLabel ? String(o.claimedLabel) : shared.claimedLabel,
|
|
1493
|
+
merge: { ...mergeBase, ...o.merge ?? {} }
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
if (out.github.length === 0) {
|
|
1497
|
+
throw new Error("pickup.github has no repo \u2014 multi-repo mode needs at least one [repos.<key>] with pickup = true and a GitHub origin");
|
|
1498
|
+
}
|
|
1401
1499
|
}
|
|
1402
1500
|
}
|
|
1403
1501
|
const ln = p.linear;
|
|
@@ -1422,23 +1520,23 @@ function loadPickupConfig() {
|
|
|
1422
1520
|
}
|
|
1423
1521
|
|
|
1424
1522
|
// apps/pick/dist/state.js
|
|
1425
|
-
import * as
|
|
1426
|
-
import * as
|
|
1523
|
+
import * as fs13 from "node:fs";
|
|
1524
|
+
import * as path8 from "node:path";
|
|
1427
1525
|
var PickupState = class {
|
|
1428
1526
|
file;
|
|
1429
1527
|
data;
|
|
1430
|
-
constructor(dir =
|
|
1431
|
-
|
|
1432
|
-
this.file =
|
|
1433
|
-
this.data =
|
|
1528
|
+
constructor(dir = path8.join(lobstahHome(), "pickup")) {
|
|
1529
|
+
fs13.mkdirSync(dir, { recursive: true });
|
|
1530
|
+
this.file = path8.join(dir, "state.json");
|
|
1531
|
+
this.data = fs13.existsSync(this.file) ? JSON.parse(fs13.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
|
|
1434
1532
|
this.data.map ??= {};
|
|
1435
1533
|
this.data.consumedApprovals ??= {};
|
|
1436
1534
|
this.data.rebases ??= {};
|
|
1437
1535
|
}
|
|
1438
1536
|
save() {
|
|
1439
1537
|
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
1440
|
-
|
|
1441
|
-
|
|
1538
|
+
fs13.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
|
|
1539
|
+
fs13.renameSync(tmp, this.file);
|
|
1442
1540
|
}
|
|
1443
1541
|
get(key) {
|
|
1444
1542
|
return this.data.map[key];
|
|
@@ -1822,12 +1920,12 @@ async function dispatchLoop(source, state, log = () => {
|
|
|
1822
1920
|
}
|
|
1823
1921
|
|
|
1824
1922
|
// apps/pick/dist/loops/report.js
|
|
1825
|
-
import * as
|
|
1826
|
-
import * as
|
|
1923
|
+
import * as fs14 from "node:fs";
|
|
1924
|
+
import * as path9 from "node:path";
|
|
1827
1925
|
function dispatchLane(uuid) {
|
|
1828
1926
|
for (const lane of ["work", "chore"]) {
|
|
1829
1927
|
const d = laneDirs(lane);
|
|
1830
|
-
if (
|
|
1928
|
+
if (fs14.existsSync(path9.join(d.active, uuid)) || fs14.existsSync(path9.join(d.queue, `${uuid}.json`)) || fs14.existsSync(path9.join(d.done, uuid)) || fs14.existsSync(path9.join(d.state, `${uuid}.status`))) {
|
|
1831
1929
|
return lane;
|
|
1832
1930
|
}
|
|
1833
1931
|
}
|
|
@@ -1868,12 +1966,12 @@ async function reportLoop(source, state, log = () => {
|
|
|
1868
1966
|
}
|
|
1869
1967
|
|
|
1870
1968
|
// apps/pick/dist/loops/reconcile.js
|
|
1871
|
-
import * as
|
|
1872
|
-
import * as
|
|
1969
|
+
import * as fs15 from "node:fs";
|
|
1970
|
+
import * as path10 from "node:path";
|
|
1873
1971
|
function dispatchActive(uuid) {
|
|
1874
1972
|
for (const lane of ["work", "chore"]) {
|
|
1875
1973
|
const d = laneDirs(lane);
|
|
1876
|
-
if (
|
|
1974
|
+
if (fs15.existsSync(path10.join(d.active, uuid)) || fs15.existsSync(path10.join(d.queue, `${uuid}.json`))) {
|
|
1877
1975
|
return lane;
|
|
1878
1976
|
}
|
|
1879
1977
|
}
|
|
@@ -1913,28 +2011,28 @@ async function reconcileLoop(source, state, log = () => {
|
|
|
1913
2011
|
|
|
1914
2012
|
// apps/pick/dist/loops/merge.js
|
|
1915
2013
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1916
|
-
import * as
|
|
1917
|
-
import * as
|
|
2014
|
+
import * as fs17 from "node:fs";
|
|
2015
|
+
import * as path12 from "node:path";
|
|
1918
2016
|
|
|
1919
2017
|
// apps/pick/dist/merge-view.js
|
|
1920
|
-
import * as
|
|
1921
|
-
import * as
|
|
2018
|
+
import * as fs16 from "node:fs";
|
|
2019
|
+
import * as path11 from "node:path";
|
|
1922
2020
|
function viewPath() {
|
|
1923
|
-
return
|
|
2021
|
+
return path11.join(lobstahHome(), "pickup", "merge-view.json");
|
|
1924
2022
|
}
|
|
1925
2023
|
function readMergeView() {
|
|
1926
2024
|
try {
|
|
1927
|
-
return JSON.parse(
|
|
2025
|
+
return JSON.parse(fs16.readFileSync(viewPath(), "utf8"));
|
|
1928
2026
|
} catch {
|
|
1929
2027
|
return void 0;
|
|
1930
2028
|
}
|
|
1931
2029
|
}
|
|
1932
2030
|
function writeMergeView(view) {
|
|
1933
2031
|
const file = viewPath();
|
|
1934
|
-
|
|
2032
|
+
fs16.mkdirSync(path11.dirname(file), { recursive: true });
|
|
1935
2033
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
1936
|
-
|
|
1937
|
-
|
|
2034
|
+
fs16.writeFileSync(tmp, JSON.stringify(view, null, 2));
|
|
2035
|
+
fs16.renameSync(tmp, file);
|
|
1938
2036
|
}
|
|
1939
2037
|
var RECENT_RETENTION_MS = 48 * 36e5;
|
|
1940
2038
|
async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
|
|
@@ -1956,7 +2054,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
|
|
|
1956
2054
|
}
|
|
1957
2055
|
function readPickupMap() {
|
|
1958
2056
|
try {
|
|
1959
|
-
const raw = JSON.parse(
|
|
2057
|
+
const raw = JSON.parse(fs16.readFileSync(path11.join(lobstahHome(), "pickup", "state.json"), "utf8"));
|
|
1960
2058
|
return raw.map ?? {};
|
|
1961
2059
|
} catch {
|
|
1962
2060
|
return {};
|
|
@@ -1990,7 +2088,7 @@ function rebaseBrief(pr) {
|
|
|
1990
2088
|
}
|
|
1991
2089
|
function choreVerdict(uuid) {
|
|
1992
2090
|
const d = laneDirs("chore");
|
|
1993
|
-
if (
|
|
2091
|
+
if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`)))
|
|
1994
2092
|
return "active";
|
|
1995
2093
|
const last = readStatusLog(uuid, "chore").at(-1)?.verb;
|
|
1996
2094
|
return last === "done" ? "done" : "failed";
|
|
@@ -2131,10 +2229,10 @@ async function runPickup(mode = "daemon") {
|
|
|
2131
2229
|
const log = (m) => console.log(`[pickup] ${m}`);
|
|
2132
2230
|
const sources = [];
|
|
2133
2231
|
const merges = [];
|
|
2134
|
-
|
|
2135
|
-
const gh = new GithubSource(
|
|
2232
|
+
for (const ghCfg of cfg.github) {
|
|
2233
|
+
const gh = new GithubSource(ghCfg);
|
|
2136
2234
|
sources.push(gh);
|
|
2137
|
-
merges.push({ source: gh, policy:
|
|
2235
|
+
merges.push({ source: gh, policy: ghCfg.merge });
|
|
2138
2236
|
}
|
|
2139
2237
|
if (cfg.linear)
|
|
2140
2238
|
sources.push(new LinearSource(cfg.linear));
|
|
@@ -2167,21 +2265,21 @@ function mergeHaulHook(existing) {
|
|
|
2167
2265
|
}
|
|
2168
2266
|
|
|
2169
2267
|
// apps/cli/src/tend.ts
|
|
2170
|
-
import * as
|
|
2171
|
-
import * as
|
|
2268
|
+
import * as fs18 from "node:fs";
|
|
2269
|
+
import * as path13 from "node:path";
|
|
2172
2270
|
var HEARTBEAT_STALE_MS = 9e4;
|
|
2173
2271
|
var CLAIM_STALE_MS = 12e4;
|
|
2174
2272
|
var DAY_MS = 24 * 36e5;
|
|
2175
2273
|
function readJson(file) {
|
|
2176
2274
|
try {
|
|
2177
|
-
return JSON.parse(
|
|
2275
|
+
return JSON.parse(fs18.readFileSync(file, "utf8"));
|
|
2178
2276
|
} catch {
|
|
2179
2277
|
return void 0;
|
|
2180
2278
|
}
|
|
2181
2279
|
}
|
|
2182
2280
|
function doneIds(lane) {
|
|
2183
2281
|
try {
|
|
2184
|
-
return
|
|
2282
|
+
return fs18.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
|
|
2185
2283
|
} catch {
|
|
2186
2284
|
return [];
|
|
2187
2285
|
}
|
|
@@ -2200,16 +2298,16 @@ function followUps(uuid) {
|
|
|
2200
2298
|
const desc = readJson(file);
|
|
2201
2299
|
if (desc?.followUp === uuid) out.push({ id, bucket });
|
|
2202
2300
|
};
|
|
2203
|
-
for (const id of pendingIds("work")) scan(id, "queued",
|
|
2204
|
-
for (const id of activeIds("work")) scan(id, "active",
|
|
2205
|
-
for (const id of doneIds("work")) scan(id, "done",
|
|
2301
|
+
for (const id of pendingIds("work")) scan(id, "queued", path13.join(d.queue, `${id}.json`));
|
|
2302
|
+
for (const id of activeIds("work")) scan(id, "active", path13.join(d.active, id, "descriptor.json"));
|
|
2303
|
+
for (const id of doneIds("work")) scan(id, "done", path13.join(d.done, id, "descriptor.json"));
|
|
2206
2304
|
return out;
|
|
2207
2305
|
}
|
|
2208
2306
|
function bucketOf(uuid) {
|
|
2209
2307
|
const d = laneDirs("work");
|
|
2210
|
-
if (
|
|
2211
|
-
if (
|
|
2212
|
-
if (
|
|
2308
|
+
if (fs18.existsSync(path13.join(d.active, uuid))) return "active";
|
|
2309
|
+
if (fs18.existsSync(path13.join(d.done, uuid))) return "done";
|
|
2310
|
+
if (fs18.existsSync(path13.join(d.queue, `${uuid}.json`))) return "queued";
|
|
2213
2311
|
return void 0;
|
|
2214
2312
|
}
|
|
2215
2313
|
function buildTendReport(now = Date.now()) {
|
|
@@ -2238,7 +2336,7 @@ function buildTendReport(now = Date.now()) {
|
|
|
2238
2336
|
note: ev.entry.note
|
|
2239
2337
|
}));
|
|
2240
2338
|
const oldestQueuedAge = queued.reduce((max, id) => {
|
|
2241
|
-
const st =
|
|
2339
|
+
const st = fs18.statSync(path13.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
|
|
2242
2340
|
return st ? Math.max(max, now - st.mtimeMs) : max;
|
|
2243
2341
|
}, 0);
|
|
2244
2342
|
const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
|
|
@@ -2332,13 +2430,13 @@ function renderTend(r) {
|
|
|
2332
2430
|
}
|
|
2333
2431
|
|
|
2334
2432
|
// apps/cli/src/cull.ts
|
|
2335
|
-
import * as
|
|
2336
|
-
import * as
|
|
2433
|
+
import * as fs19 from "node:fs";
|
|
2434
|
+
import * as path14 from "node:path";
|
|
2337
2435
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2338
2436
|
var DAY = 864e5;
|
|
2339
2437
|
function idsIn(dir) {
|
|
2340
2438
|
try {
|
|
2341
|
-
return new Set(
|
|
2439
|
+
return new Set(fs19.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
|
|
2342
2440
|
} catch {
|
|
2343
2441
|
return /* @__PURE__ */ new Set();
|
|
2344
2442
|
}
|
|
@@ -2353,39 +2451,39 @@ function planCull(olderThanDays, now = Date.now()) {
|
|
|
2353
2451
|
for (const id of idsIn(d.queue)) live.add(id);
|
|
2354
2452
|
for (const id of idsIn(d.active)) live.add(id);
|
|
2355
2453
|
for (const id of idsIn(d.done)) {
|
|
2356
|
-
const p =
|
|
2357
|
-
const m =
|
|
2454
|
+
const p = path14.join(d.done, id);
|
|
2455
|
+
const m = fs19.statSync(p).mtimeMs;
|
|
2358
2456
|
doneMtimes.set(id, m);
|
|
2359
2457
|
if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
|
|
2360
2458
|
}
|
|
2361
2459
|
}
|
|
2362
|
-
const wtRoot =
|
|
2460
|
+
const wtRoot = path14.join(lobstahHome(), "worktrees");
|
|
2363
2461
|
for (const id of idsIn(wtRoot)) {
|
|
2364
2462
|
if (live.has(id)) continue;
|
|
2365
2463
|
const doneAt = doneMtimes.get(id);
|
|
2366
2464
|
if (doneAt !== void 0 && doneAt >= cutoff) continue;
|
|
2367
|
-
const p =
|
|
2368
|
-
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now -
|
|
2465
|
+
const p = path14.join(wtRoot, id);
|
|
2466
|
+
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs19.statSync(p).mtimeMs) / DAY) });
|
|
2369
2467
|
}
|
|
2370
2468
|
for (const lane of ["work", "chore"]) {
|
|
2371
2469
|
const d = laneDirs(lane);
|
|
2372
2470
|
const seen = /* @__PURE__ */ new Set();
|
|
2373
|
-
for (const f of
|
|
2471
|
+
for (const f of fs19.readdirSync(d.state)) {
|
|
2374
2472
|
const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
|
|
2375
2473
|
if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
|
|
2376
|
-
const m =
|
|
2474
|
+
const m = fs19.statSync(path14.join(d.state, f)).mtimeMs;
|
|
2377
2475
|
if (m >= cutoff) continue;
|
|
2378
2476
|
seen.add(id);
|
|
2379
|
-
items.push({ kind: "state", id, target:
|
|
2477
|
+
items.push({ kind: "state", id, target: path14.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
|
|
2380
2478
|
}
|
|
2381
2479
|
}
|
|
2382
2480
|
return items;
|
|
2383
2481
|
}
|
|
2384
2482
|
function removeWorktree(id, dir) {
|
|
2385
2483
|
for (const lane of ["work", "chore"]) {
|
|
2386
|
-
const descFile =
|
|
2484
|
+
const descFile = path14.join(laneDirs(lane).done, id, "descriptor.json");
|
|
2387
2485
|
try {
|
|
2388
|
-
const desc = JSON.parse(
|
|
2486
|
+
const desc = JSON.parse(fs19.readFileSync(descFile, "utf8"));
|
|
2389
2487
|
const repo = loadConfig().repos[desc.repo];
|
|
2390
2488
|
if (repo) {
|
|
2391
2489
|
execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
|
|
@@ -2394,15 +2492,15 @@ function removeWorktree(id, dir) {
|
|
|
2394
2492
|
} catch {
|
|
2395
2493
|
}
|
|
2396
2494
|
}
|
|
2397
|
-
|
|
2495
|
+
fs19.rmSync(dir, { recursive: true, force: true });
|
|
2398
2496
|
}
|
|
2399
2497
|
function applyCull(items) {
|
|
2400
2498
|
for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
|
|
2401
|
-
for (const item of items.filter((i) => i.kind === "done"))
|
|
2499
|
+
for (const item of items.filter((i) => i.kind === "done")) fs19.rmSync(item.target, { recursive: true, force: true });
|
|
2402
2500
|
for (const item of items.filter((i) => i.kind === "state")) {
|
|
2403
|
-
const dir =
|
|
2404
|
-
for (const f of
|
|
2405
|
-
if (f.startsWith(`${item.id}.`))
|
|
2501
|
+
const dir = path14.dirname(item.target);
|
|
2502
|
+
for (const f of fs19.readdirSync(dir)) {
|
|
2503
|
+
if (f.startsWith(`${item.id}.`)) fs19.rmSync(path14.join(dir, f), { force: true });
|
|
2406
2504
|
}
|
|
2407
2505
|
}
|
|
2408
2506
|
}
|
|
@@ -2446,6 +2544,268 @@ paste into your liaison instructions:
|
|
|
2446
2544
|
|
|
2447
2545
|
full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
|
|
2448
2546
|
|
|
2547
|
+
// apps/cli/src/doctor.ts
|
|
2548
|
+
import * as fs20 from "node:fs";
|
|
2549
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
2550
|
+
import { spawnSync } from "node:child_process";
|
|
2551
|
+
var HEARTBEAT_STALE_MS2 = 9e4;
|
|
2552
|
+
function git(repoPath, ...args) {
|
|
2553
|
+
const res = spawnSync("git", ["-C", repoPath, ...args], { encoding: "utf8" });
|
|
2554
|
+
return { ok: res.status === 0, out: (res.stdout ?? "").trim() || (res.stderr ?? "").trim() };
|
|
2555
|
+
}
|
|
2556
|
+
function runDoctor(now = Date.now()) {
|
|
2557
|
+
const rows = [];
|
|
2558
|
+
const push = (check, status, detail) => rows.push({ check, status, detail });
|
|
2559
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
2560
|
+
push("node", major >= 20 ? "ok" : "fail", `v${process.versions.node}${major >= 20 ? "" : " \u2014 lobstah needs >=20"}`);
|
|
2561
|
+
push("git", onPath("git") ? "ok" : "fail", onPath("git") ? "on PATH" : "not on PATH");
|
|
2562
|
+
const claude = onPath("claude");
|
|
2563
|
+
push("harness claude", claude ? "ok" : "warn", claude ? "on PATH" : "claude not on PATH \u2014 claude dispatches will fail");
|
|
2564
|
+
const codexPath = onPath("codex");
|
|
2565
|
+
let codexSdk = false;
|
|
2566
|
+
try {
|
|
2567
|
+
createRequire3(import.meta.url).resolve("@openai/codex-sdk");
|
|
2568
|
+
codexSdk = true;
|
|
2569
|
+
} catch {
|
|
2570
|
+
}
|
|
2571
|
+
push(
|
|
2572
|
+
"harness codex",
|
|
2573
|
+
codexPath || codexSdk ? "ok" : "warn",
|
|
2574
|
+
codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
|
|
2575
|
+
);
|
|
2576
|
+
const cfgFile = configPath();
|
|
2577
|
+
if (!fs20.existsSync(cfgFile)) {
|
|
2578
|
+
push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
|
|
2579
|
+
return rows;
|
|
2580
|
+
}
|
|
2581
|
+
let parsed;
|
|
2582
|
+
try {
|
|
2583
|
+
parsed = parse(fs20.readFileSync(cfgFile, "utf8"));
|
|
2584
|
+
push("config", "ok", cfgFile);
|
|
2585
|
+
} catch (err) {
|
|
2586
|
+
push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2587
|
+
return rows;
|
|
2588
|
+
}
|
|
2589
|
+
const cfg = loadConfig();
|
|
2590
|
+
const repoKeys = Object.keys(cfg.repos);
|
|
2591
|
+
if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
|
|
2592
|
+
for (const [key, repo] of Object.entries(cfg.repos)) {
|
|
2593
|
+
const label = `repo ${key}`;
|
|
2594
|
+
if (!fs20.existsSync(repo.path)) {
|
|
2595
|
+
push(label, "fail", `path ${repo.path} does not exist`);
|
|
2596
|
+
continue;
|
|
2597
|
+
}
|
|
2598
|
+
if (!git(repo.path, "rev-parse", "--is-inside-work-tree").ok) {
|
|
2599
|
+
push(label, "fail", `${repo.path} is not a git repository`);
|
|
2600
|
+
continue;
|
|
2601
|
+
}
|
|
2602
|
+
const trunk = git(repo.path, "rev-parse", "--verify", "--quiet", `origin/${repo.trunk}`);
|
|
2603
|
+
push(
|
|
2604
|
+
label,
|
|
2605
|
+
trunk.ok ? "ok" : "warn",
|
|
2606
|
+
trunk.ok ? `${repo.path} (trunk origin/${repo.trunk})` : `origin/${repo.trunk} not found \u2014 check trunk or \`git fetch\``
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
if (parsed.pickup) {
|
|
2610
|
+
try {
|
|
2611
|
+
const pk = loadPickupConfig();
|
|
2612
|
+
const sources = [
|
|
2613
|
+
...pk.github.map((g) => `gh:${g.repo}\u2192${g.key}`),
|
|
2614
|
+
...pk.linear ? ["linear"] : []
|
|
2615
|
+
];
|
|
2616
|
+
push("pickup", sources.length > 0 ? "ok" : "warn", sources.join(", ") || "section present but no sources");
|
|
2617
|
+
} catch (err) {
|
|
2618
|
+
push("pickup", "fail", err instanceof Error ? err.message : String(err));
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
const hb = executorPath();
|
|
2622
|
+
if (!fs20.existsSync(hb)) {
|
|
2623
|
+
push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
|
|
2624
|
+
} else {
|
|
2625
|
+
try {
|
|
2626
|
+
const payload = JSON.parse(fs20.readFileSync(hb, "utf8"));
|
|
2627
|
+
const age = now - new Date(payload.heartbeat ?? 0).getTime();
|
|
2628
|
+
push(
|
|
2629
|
+
"daemon",
|
|
2630
|
+
age < HEARTBEAT_STALE_MS2 ? "ok" : "warn",
|
|
2631
|
+
age < HEARTBEAT_STALE_MS2 ? `heartbeat ${Math.round(age / 1e3)}s ago (v${payload.version ?? "?"})` : `heartbeat stale (${Math.round(age / 1e3)}s) \u2014 daemon down or wedged`
|
|
2632
|
+
);
|
|
2633
|
+
} catch {
|
|
2634
|
+
push("daemon", "warn", "heartbeat unreadable");
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
push("lobstah", "ok", `v${lobstahVersion()} at ${process.argv[1] ?? "?"}`);
|
|
2638
|
+
return rows;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
// apps/cli/src/service.ts
|
|
2642
|
+
import * as fs21 from "node:fs";
|
|
2643
|
+
import * as os5 from "node:os";
|
|
2644
|
+
import * as path15 from "node:path";
|
|
2645
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
2646
|
+
function xml(s) {
|
|
2647
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2648
|
+
}
|
|
2649
|
+
function renderLaunchdPlist(spec) {
|
|
2650
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2651
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2652
|
+
<plist version="1.0">
|
|
2653
|
+
<dict>
|
|
2654
|
+
<key>Label</key><string>lobstah.${spec.kind}</string>
|
|
2655
|
+
<key>ProgramArguments</key>
|
|
2656
|
+
<array>
|
|
2657
|
+
<string>${xml(spec.nodePath)}</string>
|
|
2658
|
+
<string>${xml(spec.entry)}</string>
|
|
2659
|
+
<string>${spec.kind}</string>
|
|
2660
|
+
</array>
|
|
2661
|
+
<key>EnvironmentVariables</key>
|
|
2662
|
+
<dict>
|
|
2663
|
+
<key>PATH</key><string>${xml(spec.pathEnv)}</string>
|
|
2664
|
+
<key>LOBSTAH_HOME</key><string>${xml(spec.home)}</string>
|
|
2665
|
+
</dict>
|
|
2666
|
+
<key>RunAtLoad</key><true/>
|
|
2667
|
+
<key>KeepAlive</key><true/>
|
|
2668
|
+
<key>StandardOutPath</key><string>${xml(path15.join(spec.logDir, `${spec.kind}.log`))}</string>
|
|
2669
|
+
<key>StandardErrorPath</key><string>${xml(path15.join(spec.logDir, `${spec.kind}.err`))}</string>
|
|
2670
|
+
</dict>
|
|
2671
|
+
</plist>
|
|
2672
|
+
`;
|
|
2673
|
+
}
|
|
2674
|
+
function renderSystemdUnit(spec) {
|
|
2675
|
+
return `[Unit]
|
|
2676
|
+
Description=lobstah ${spec.kind}
|
|
2677
|
+
|
|
2678
|
+
[Service]
|
|
2679
|
+
ExecStart=${spec.nodePath} ${spec.entry} ${spec.kind}
|
|
2680
|
+
Restart=always
|
|
2681
|
+
RestartSec=5
|
|
2682
|
+
Environment=PATH=${spec.pathEnv}
|
|
2683
|
+
Environment=LOBSTAH_HOME=${spec.home}
|
|
2684
|
+
StandardOutput=append:${path15.join(spec.logDir, `${spec.kind}.log`)}
|
|
2685
|
+
StandardError=append:${path15.join(spec.logDir, `${spec.kind}.err`)}
|
|
2686
|
+
|
|
2687
|
+
[Install]
|
|
2688
|
+
WantedBy=default.target
|
|
2689
|
+
`;
|
|
2690
|
+
}
|
|
2691
|
+
function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
2692
|
+
const dirs = [
|
|
2693
|
+
path15.dirname(nodePath),
|
|
2694
|
+
path15.join(home, ".local", "bin"),
|
|
2695
|
+
"/opt/homebrew/bin",
|
|
2696
|
+
"/usr/local/bin",
|
|
2697
|
+
"/usr/bin",
|
|
2698
|
+
"/bin"
|
|
2699
|
+
];
|
|
2700
|
+
return [...new Set(dirs)].join(path15.delimiter);
|
|
2701
|
+
}
|
|
2702
|
+
function serviceFile(kind) {
|
|
2703
|
+
return process.platform === "darwin" ? path15.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path15.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
|
|
2704
|
+
}
|
|
2705
|
+
function run(cmd, args) {
|
|
2706
|
+
const res = spawnSync2(cmd, args, { encoding: "utf8" });
|
|
2707
|
+
return { ok: res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
|
|
2708
|
+
}
|
|
2709
|
+
function installService(kind) {
|
|
2710
|
+
const entry = fs21.realpathSync(process.argv[1]);
|
|
2711
|
+
const spec = {
|
|
2712
|
+
kind,
|
|
2713
|
+
nodePath: process.execPath,
|
|
2714
|
+
entry,
|
|
2715
|
+
pathEnv: servicePathEnv(process.execPath),
|
|
2716
|
+
home: lobstahHome(),
|
|
2717
|
+
logDir: path15.join(lobstahHome(), "logs")
|
|
2718
|
+
};
|
|
2719
|
+
if (!onPath("git", spec.pathEnv)) {
|
|
2720
|
+
throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
|
|
2721
|
+
}
|
|
2722
|
+
fs21.mkdirSync(spec.logDir, { recursive: true });
|
|
2723
|
+
const file = serviceFile(kind);
|
|
2724
|
+
fs21.mkdirSync(path15.dirname(file), { recursive: true });
|
|
2725
|
+
if (process.platform === "darwin") {
|
|
2726
|
+
if (fs21.existsSync(file)) run("launchctl", ["unload", file]);
|
|
2727
|
+
fs21.writeFileSync(file, renderLaunchdPlist(spec));
|
|
2728
|
+
const load = run("launchctl", ["load", file]);
|
|
2729
|
+
return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
|
|
2730
|
+
}
|
|
2731
|
+
fs21.writeFileSync(file, renderSystemdUnit(spec));
|
|
2732
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
2733
|
+
const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
|
|
2734
|
+
return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
|
|
2735
|
+
}
|
|
2736
|
+
function uninstallService(kind) {
|
|
2737
|
+
const file = serviceFile(kind);
|
|
2738
|
+
if (process.platform === "darwin") {
|
|
2739
|
+
if (fs21.existsSync(file)) run("launchctl", ["unload", file]);
|
|
2740
|
+
} else {
|
|
2741
|
+
run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
|
|
2742
|
+
}
|
|
2743
|
+
const removed = fs21.existsSync(file);
|
|
2744
|
+
if (removed) fs21.unlinkSync(file);
|
|
2745
|
+
if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
|
|
2746
|
+
return { file, removed };
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
// apps/cli/src/repos.ts
|
|
2750
|
+
import * as fs22 from "node:fs";
|
|
2751
|
+
import * as path16 from "node:path";
|
|
2752
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
2753
|
+
function git2(dir, ...args) {
|
|
2754
|
+
const res = spawnSync3("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
2755
|
+
return res.status === 0 ? res.stdout.trim() : void 0;
|
|
2756
|
+
}
|
|
2757
|
+
var LOCKFILE_SETUP = [
|
|
2758
|
+
["pnpm-lock.yaml", ["pnpm install"]],
|
|
2759
|
+
["package-lock.json", ["npm install"]],
|
|
2760
|
+
["yarn.lock", ["yarn install"]],
|
|
2761
|
+
["bun.lockb", ["bun install"]],
|
|
2762
|
+
["bun.lock", ["bun install"]],
|
|
2763
|
+
["uv.lock", ["uv sync"]],
|
|
2764
|
+
["poetry.lock", ["poetry install"]]
|
|
2765
|
+
];
|
|
2766
|
+
function detectRepo(dir) {
|
|
2767
|
+
const abs = path16.resolve(dir);
|
|
2768
|
+
if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
|
|
2769
|
+
const top = git2(abs, "rev-parse", "--show-toplevel");
|
|
2770
|
+
if (!top || fs22.realpathSync(top) !== fs22.realpathSync(abs)) return void 0;
|
|
2771
|
+
const origin = git2(abs, "remote", "get-url", "origin");
|
|
2772
|
+
const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
2773
|
+
const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
|
|
2774
|
+
const setup = LOCKFILE_SETUP.find(([f]) => fs22.existsSync(path16.join(abs, f)))?.[1];
|
|
2775
|
+
return { key: path16.basename(abs), path: abs, trunk, origin, setup };
|
|
2776
|
+
}
|
|
2777
|
+
function scanForRepos(roots) {
|
|
2778
|
+
const found = [];
|
|
2779
|
+
for (const root of roots) {
|
|
2780
|
+
const abs = path16.resolve(root);
|
|
2781
|
+
if (!fs22.existsSync(abs)) continue;
|
|
2782
|
+
const self = detectRepo(abs);
|
|
2783
|
+
if (self) {
|
|
2784
|
+
found.push(self);
|
|
2785
|
+
continue;
|
|
2786
|
+
}
|
|
2787
|
+
for (const entry of fs22.readdirSync(abs, { withFileTypes: true })) {
|
|
2788
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
2789
|
+
const child = detectRepo(path16.join(abs, entry.name));
|
|
2790
|
+
if (child) found.push(child);
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
return found;
|
|
2794
|
+
}
|
|
2795
|
+
function configuredRepoKeys(file = configPath()) {
|
|
2796
|
+
if (!fs22.existsSync(file)) return /* @__PURE__ */ new Set();
|
|
2797
|
+
const raw = parse(fs22.readFileSync(file, "utf8"));
|
|
2798
|
+
return new Set(Object.keys(raw.repos ?? {}));
|
|
2799
|
+
}
|
|
2800
|
+
function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
2801
|
+
const lines = [``, `[repos.${repo.key}]`, `path = ${JSON.stringify(repo.path)}`, `trunk = ${JSON.stringify(repo.trunk)}`];
|
|
2802
|
+
if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
|
|
2803
|
+
if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
|
|
2804
|
+
if (opts.pickup) lines.push(`pickup = true`);
|
|
2805
|
+
fs22.appendFileSync(file, `${lines.join("\n")}
|
|
2806
|
+
`);
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2449
2809
|
// apps/cli/src/main.ts
|
|
2450
2810
|
var HELP = `lobstah \u2014 supervision framework for coding agents
|
|
2451
2811
|
|
|
@@ -2474,11 +2834,14 @@ work (humans and agents):
|
|
|
2474
2834
|
Dry run by default (14 days).
|
|
2475
2835
|
cancel <uuid> request cancellation
|
|
2476
2836
|
|
|
2477
|
-
host processes
|
|
2837
|
+
host processes:
|
|
2478
2838
|
daemon [--interval <ms>] the supervisor: claims, worktrees, liveness,
|
|
2479
2839
|
restart ladder, notifyCommand. One per home.
|
|
2480
2840
|
pick [once] tracker loops: poll Linear/GitHub, dispatch
|
|
2481
2841
|
assigned work, report back, reconcile, merge
|
|
2842
|
+
daemon install|uninstall write + load the launchd agent / systemd user
|
|
2843
|
+
pick install|uninstall unit for this host, with resolved node and
|
|
2844
|
+
lobstah paths (launchd gets no shell env)
|
|
2482
2845
|
|
|
2483
2846
|
lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual):
|
|
2484
2847
|
man tend [--json] tend the whole string: fleet verdict (daemon
|
|
@@ -2491,10 +2854,13 @@ lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual)
|
|
|
2491
2854
|
print the event and what to do next; exit 2
|
|
2492
2855
|
on timeout. Unanswered questions re-fire
|
|
2493
2856
|
every remindSecs until answered.
|
|
2494
|
-
man init [--shared] [--marker]
|
|
2495
|
-
project's
|
|
2496
|
-
|
|
2497
|
-
.
|
|
2857
|
+
man init [--shared|--global] [--marker]
|
|
2858
|
+
install the haul Stop hook: this project's
|
|
2859
|
+
.claude/settings.local.json by default,
|
|
2860
|
+
settings.json with --shared, or once into
|
|
2861
|
+
~/.claude/settings.json with --global (any
|
|
2862
|
+
directory with a .lobstah-man file then
|
|
2863
|
+
parks); --marker touches .lobstah-man.
|
|
2498
2864
|
man haul [--timeout <secs>] Stop-hook entry point: park the session while
|
|
2499
2865
|
work is in flight; prints hook-decision JSON
|
|
2500
2866
|
on an event, silent exit 0 otherwise. Gate:
|
|
@@ -2506,7 +2872,16 @@ workers (dispatched agents; injected into every brief):
|
|
|
2506
2872
|
(${VERBS.join(" | ")})
|
|
2507
2873
|
|
|
2508
2874
|
setup:
|
|
2509
|
-
init
|
|
2875
|
+
init [--scan <dir>... [--pickup]]
|
|
2876
|
+
create ~/.lobstah + example config; --scan
|
|
2877
|
+
detects git repos under the given roots and
|
|
2878
|
+
appends a [repos.*] block per repo (--pickup
|
|
2879
|
+
marks them pickable by [pickup.github])
|
|
2880
|
+
repos [add <path> [--pickup] [--key <k>]]
|
|
2881
|
+
list configured repos, or detect + append one
|
|
2882
|
+
doctor check binaries, config, repos, harnesses, and
|
|
2883
|
+
the daemon heartbeat; exit 1 on failures
|
|
2884
|
+
version | --version the installed lobstah version
|
|
2510
2885
|
|
|
2511
2886
|
Everything except daemon and pick works with both stopped: writes are files,
|
|
2512
2887
|
reads are files. Output is TOON; agents can drive this CLI directly.
|
|
@@ -2518,7 +2893,7 @@ function arg(args, flag) {
|
|
|
2518
2893
|
function findLane(id) {
|
|
2519
2894
|
for (const lane of ["work", "chore"]) {
|
|
2520
2895
|
const d = laneDirs(lane);
|
|
2521
|
-
if (
|
|
2896
|
+
if (fs23.existsSync(path17.join(d.active, id)) || fs23.existsSync(path17.join(d.queue, `${id}.json`)) || fs23.existsSync(path17.join(d.done, id)) || fs23.existsSync(path17.join(d.state, `${id}.status`))) {
|
|
2522
2897
|
return lane;
|
|
2523
2898
|
}
|
|
2524
2899
|
}
|
|
@@ -2526,7 +2901,7 @@ function findLane(id) {
|
|
|
2526
2901
|
}
|
|
2527
2902
|
function rowsFor(lane, bucket) {
|
|
2528
2903
|
const dir = laneDirs(lane)[bucket];
|
|
2529
|
-
const entries =
|
|
2904
|
+
const entries = fs23.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs23.statSync(path17.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
|
|
2530
2905
|
const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
|
|
2531
2906
|
return sliced.map(({ f, m }) => {
|
|
2532
2907
|
const id = f.replace(/\.json$/, "");
|
|
@@ -2554,7 +2929,7 @@ async function mainCli() {
|
|
|
2554
2929
|
const d = {
|
|
2555
2930
|
id: arg(args, "--id") ?? randomUUID3(),
|
|
2556
2931
|
repo,
|
|
2557
|
-
brief: briefText ??
|
|
2932
|
+
brief: briefText ?? fs23.readFileSync(briefFile, "utf8"),
|
|
2558
2933
|
harness: arg(args, "--harness"),
|
|
2559
2934
|
model: arg(args, "--model"),
|
|
2560
2935
|
effort: arg(args, "--effort"),
|
|
@@ -2591,17 +2966,17 @@ async function mainCli() {
|
|
|
2591
2966
|
if (!id) throw new Error("logs requires a dispatch id");
|
|
2592
2967
|
const lane = findLane(id);
|
|
2593
2968
|
const file = eventsPath(id, lane);
|
|
2594
|
-
if (
|
|
2969
|
+
if (fs23.existsSync(file)) process.stdout.write(fs23.readFileSync(file, "utf8"));
|
|
2595
2970
|
if (args.includes("--follow")) {
|
|
2596
|
-
let size =
|
|
2971
|
+
let size = fs23.existsSync(file) ? fs23.statSync(file).size : 0;
|
|
2597
2972
|
setInterval(() => {
|
|
2598
|
-
if (!
|
|
2599
|
-
const now =
|
|
2973
|
+
if (!fs23.existsSync(file)) return;
|
|
2974
|
+
const now = fs23.statSync(file).size;
|
|
2600
2975
|
if (now > size) {
|
|
2601
|
-
const fd =
|
|
2976
|
+
const fd = fs23.openSync(file, "r");
|
|
2602
2977
|
const buf = Buffer.alloc(now - size);
|
|
2603
|
-
|
|
2604
|
-
|
|
2978
|
+
fs23.readSync(fd, buf, 0, buf.length, size);
|
|
2979
|
+
fs23.closeSync(fd);
|
|
2605
2980
|
process.stdout.write(buf.toString("utf8"));
|
|
2606
2981
|
size = now;
|
|
2607
2982
|
}
|
|
@@ -2657,22 +3032,23 @@ async function mainCli() {
|
|
|
2657
3032
|
);
|
|
2658
3033
|
}
|
|
2659
3034
|
const dirs = laneDirs(lane);
|
|
2660
|
-
const descriptorFile = ["active", "done"].map((b) =>
|
|
2661
|
-
const harness = descriptorFile ? JSON.parse(
|
|
3035
|
+
const descriptorFile = ["active", "done"].map((b) => path17.join(dirs[b], id, "descriptor.json")).find((f) => fs23.existsSync(f));
|
|
3036
|
+
const harness = descriptorFile ? JSON.parse(fs23.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
|
|
2662
3037
|
let sessionId;
|
|
2663
3038
|
try {
|
|
2664
|
-
sessionId = JSON.parse(
|
|
3039
|
+
sessionId = JSON.parse(fs23.readFileSync(path17.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
|
|
2665
3040
|
} catch {
|
|
2666
3041
|
}
|
|
2667
3042
|
if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
|
|
2668
|
-
const worktree =
|
|
2669
|
-
const cwd =
|
|
2670
|
-
const invocation = harness === "codex" ?
|
|
3043
|
+
const worktree = path17.join(lobstahHome(), "worktrees", id);
|
|
3044
|
+
const cwd = fs23.existsSync(worktree) ? worktree : process.cwd();
|
|
3045
|
+
const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
|
|
3046
|
+
if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
|
|
2671
3047
|
if (args.includes("--print")) {
|
|
2672
3048
|
console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
|
|
2673
3049
|
break;
|
|
2674
3050
|
}
|
|
2675
|
-
const res =
|
|
3051
|
+
const res = spawnSync4(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
|
|
2676
3052
|
if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
|
|
2677
3053
|
break;
|
|
2678
3054
|
}
|
|
@@ -2680,39 +3056,39 @@ async function mainCli() {
|
|
|
2680
3056
|
const id = args[0];
|
|
2681
3057
|
if (!id) throw new Error("swap requires a dispatch id");
|
|
2682
3058
|
const lane = findLane(id);
|
|
2683
|
-
const activeDir =
|
|
2684
|
-
if (!
|
|
2685
|
-
const descFile =
|
|
2686
|
-
const descriptor = JSON.parse(
|
|
3059
|
+
const activeDir = path17.join(laneDirs(lane).active, id);
|
|
3060
|
+
if (!fs23.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
|
|
3061
|
+
const descFile = path17.join(activeDir, "descriptor.json");
|
|
3062
|
+
const descriptor = JSON.parse(fs23.readFileSync(descFile, "utf8"));
|
|
2687
3063
|
const fromHarness = descriptor.harness ?? "default";
|
|
2688
3064
|
for (const key of ["harness", "model", "effort"]) {
|
|
2689
3065
|
const v = arg(args, `--${key}`);
|
|
2690
3066
|
if (v) descriptor[key] = v;
|
|
2691
3067
|
}
|
|
2692
|
-
|
|
2693
|
-
const worktree =
|
|
3068
|
+
fs23.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
|
|
3069
|
+
const worktree = path17.join(lobstahHome(), "worktrees", id);
|
|
2694
3070
|
let progress = "No worktree progress recorded.";
|
|
2695
|
-
if (
|
|
2696
|
-
const
|
|
2697
|
-
const commits =
|
|
2698
|
-
const status =
|
|
3071
|
+
if (fs23.existsSync(worktree)) {
|
|
3072
|
+
const git3 = (...a) => spawnSync4("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
3073
|
+
const commits = git3("log", "--oneline", "-15");
|
|
3074
|
+
const status = git3("status", "--short");
|
|
2699
3075
|
progress = `Commits so far:
|
|
2700
3076
|
${commits || "(none)"}
|
|
2701
3077
|
|
|
2702
3078
|
Uncommitted changes:
|
|
2703
3079
|
${status || "(clean)"}`;
|
|
2704
3080
|
}
|
|
2705
|
-
|
|
2706
|
-
|
|
3081
|
+
fs23.writeFileSync(
|
|
3082
|
+
path17.join(activeDir, "handoff"),
|
|
2707
3083
|
`You are taking over this dispatch from a previous agent session (harness: ${fromHarness}). Its conversation is not available \u2014 the worktree state below is the ground truth. Review it, then continue the brief from where it stops.
|
|
2708
3084
|
|
|
2709
3085
|
${progress}`
|
|
2710
3086
|
);
|
|
2711
|
-
const runnerFile =
|
|
2712
|
-
if (
|
|
2713
|
-
const runner = JSON.parse(
|
|
3087
|
+
const runnerFile = path17.join(activeDir, "runner.json");
|
|
3088
|
+
if (fs23.existsSync(runnerFile)) {
|
|
3089
|
+
const runner = JSON.parse(fs23.readFileSync(runnerFile, "utf8"));
|
|
2714
3090
|
if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
|
|
2715
|
-
|
|
3091
|
+
fs23.unlinkSync(runnerFile);
|
|
2716
3092
|
}
|
|
2717
3093
|
appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
|
|
2718
3094
|
console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
|
|
@@ -2807,35 +3183,35 @@ ${progress}`
|
|
|
2807
3183
|
break;
|
|
2808
3184
|
}
|
|
2809
3185
|
case "man:init": {
|
|
2810
|
-
const file =
|
|
3186
|
+
const file = args.includes("--global") ? path17.join(os6.homedir(), ".claude", "settings.json") : path17.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
|
|
2811
3187
|
let existing;
|
|
2812
3188
|
try {
|
|
2813
|
-
existing = JSON.parse(
|
|
3189
|
+
existing = JSON.parse(fs23.readFileSync(file, "utf8"));
|
|
2814
3190
|
} catch {
|
|
2815
3191
|
existing = void 0;
|
|
2816
3192
|
}
|
|
2817
3193
|
const { settings, changed } = mergeHaulHook(existing);
|
|
2818
3194
|
if (changed) {
|
|
2819
|
-
|
|
2820
|
-
|
|
3195
|
+
fs23.mkdirSync(path17.dirname(file), { recursive: true });
|
|
3196
|
+
fs23.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
2821
3197
|
`);
|
|
2822
3198
|
}
|
|
2823
|
-
if (args.includes("--marker"))
|
|
3199
|
+
if (args.includes("--marker")) fs23.writeFileSync(".lobstah-man", "");
|
|
2824
3200
|
console.log(
|
|
2825
3201
|
toonKV({
|
|
2826
3202
|
hook: "lobstah man haul",
|
|
2827
3203
|
file,
|
|
2828
3204
|
installed: changed || "already present",
|
|
2829
|
-
gate: args.includes("--marker") ? ".lobstah-man (this directory)" : "launch with LOBSTAH_MAN=1 claude"
|
|
3205
|
+
gate: args.includes("--marker") ? ".lobstah-man (this directory)" : args.includes("--global") ? "touch .lobstah-man in a project (or LOBSTAH_MAN=1) to arm it there" : "launch with LOBSTAH_MAN=1 claude"
|
|
2830
3206
|
})
|
|
2831
3207
|
);
|
|
2832
3208
|
break;
|
|
2833
3209
|
}
|
|
2834
3210
|
case "man:haul": {
|
|
2835
3211
|
try {
|
|
2836
|
-
if (process.env.LOBSTAH_MAN !== "1" && !
|
|
3212
|
+
if (process.env.LOBSTAH_MAN !== "1" && !fs23.existsSync(".lobstah-man")) break;
|
|
2837
3213
|
const anyActive = ["work", "chore"].some(
|
|
2838
|
-
(l) =>
|
|
3214
|
+
(l) => fs23.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
|
|
2839
3215
|
);
|
|
2840
3216
|
if (!anyActive) break;
|
|
2841
3217
|
const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
|
|
@@ -2866,31 +3242,74 @@ ${progress}`
|
|
|
2866
3242
|
}
|
|
2867
3243
|
break;
|
|
2868
3244
|
}
|
|
2869
|
-
case "daemon":
|
|
2870
|
-
|
|
2871
|
-
|
|
3245
|
+
case "daemon":
|
|
3246
|
+
case "pick": {
|
|
3247
|
+
const kind = cmd;
|
|
3248
|
+
if (args[0] === "install") {
|
|
3249
|
+
const res = installService(kind);
|
|
3250
|
+
console.log(toonKV({ service: kind, file: res.file, loaded: res.loaded, detail: res.detail }));
|
|
3251
|
+
break;
|
|
3252
|
+
}
|
|
3253
|
+
if (args[0] === "uninstall") {
|
|
3254
|
+
const res = uninstallService(kind);
|
|
3255
|
+
console.log(toonKV({ service: kind, file: res.file, removed: res.removed }));
|
|
3256
|
+
break;
|
|
3257
|
+
}
|
|
3258
|
+
if (kind === "daemon") await daemon(Number(arg(args, "--interval") ?? "5000"));
|
|
3259
|
+
else await runPickup(args[0] === "once" ? "once" : "daemon");
|
|
2872
3260
|
break;
|
|
2873
3261
|
}
|
|
2874
|
-
case "
|
|
2875
|
-
|
|
3262
|
+
case "doctor": {
|
|
3263
|
+
const rows = runDoctor();
|
|
3264
|
+
console.log(toonTable("doctor", rows, ["check", "status", "detail"]));
|
|
3265
|
+
if (rows.some((r) => r.status === "fail")) process.exitCode = 1;
|
|
3266
|
+
break;
|
|
3267
|
+
}
|
|
3268
|
+
case "repos": {
|
|
3269
|
+
if (args[0] === "add") {
|
|
3270
|
+
const target = args[1];
|
|
3271
|
+
if (!target) throw new Error("repos add requires a path");
|
|
3272
|
+
const detected = detectRepo(target);
|
|
3273
|
+
if (!detected) throw new Error(`${target} is not the root of a git repository`);
|
|
3274
|
+
const key = arg(args, "--key") ?? detected.key;
|
|
3275
|
+
if (configuredRepoKeys().has(key)) throw new Error(`repos.${key} already configured \u2014 edit ${configPath()} directly`);
|
|
3276
|
+
appendRepoBlock({ ...detected, key }, { pickup: args.includes("--pickup") });
|
|
3277
|
+
console.log(toonKV({ key, path: detected.path, trunk: detected.trunk, origin: detected.origin, pickup: args.includes("--pickup") }));
|
|
3278
|
+
break;
|
|
3279
|
+
}
|
|
3280
|
+
const repos = loadConfig().repos;
|
|
3281
|
+
const rows = Object.entries(repos).map(([key, r]) => ({
|
|
3282
|
+
key,
|
|
3283
|
+
path: r.path,
|
|
3284
|
+
trunk: r.trunk,
|
|
3285
|
+
pickup: r.pickup ?? false,
|
|
3286
|
+
exists: fs23.existsSync(r.path)
|
|
3287
|
+
}));
|
|
3288
|
+
console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
|
|
3289
|
+
break;
|
|
3290
|
+
}
|
|
3291
|
+
case "version": {
|
|
3292
|
+
console.log(lobstahVersion());
|
|
2876
3293
|
break;
|
|
2877
3294
|
}
|
|
2878
3295
|
case "init": {
|
|
2879
|
-
if (!
|
|
2880
|
-
|
|
3296
|
+
if (!fs23.existsSync(configPath())) {
|
|
3297
|
+
const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
|
|
3298
|
+
path = "~/src/example"
|
|
3299
|
+
trunk = "main"
|
|
3300
|
+
# origin = "git@github.com:you/example.git"
|
|
3301
|
+
# setup = ["pnpm install"]
|
|
3302
|
+
# pickup = true # opt into [pickup.github] multi-repo tracker pickup
|
|
3303
|
+
|
|
3304
|
+
`;
|
|
3305
|
+
fs23.writeFileSync(
|
|
2881
3306
|
configPath(),
|
|
2882
3307
|
`# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
|
|
2883
3308
|
# Top-level keys must come before any [section].
|
|
2884
3309
|
# notifyCommand = "ntfy pub my-topic "$LOBSTAH_VERB $LOBSTAH_ID: $LOBSTAH_NOTE""
|
|
2885
3310
|
# notifyVerbs = ["needs-decision", "blocked", "done", "failed"] # the default
|
|
2886
3311
|
|
|
2887
|
-
[
|
|
2888
|
-
path = "~/src/example"
|
|
2889
|
-
trunk = "main"
|
|
2890
|
-
# origin = "git@github.com:you/example.git"
|
|
2891
|
-
# setup = ["pnpm install"]
|
|
2892
|
-
|
|
2893
|
-
[harness]
|
|
3312
|
+
${exampleRepo}[harness]
|
|
2894
3313
|
default = "claude"
|
|
2895
3314
|
|
|
2896
3315
|
[limits]
|
|
@@ -2902,16 +3321,44 @@ wallClockSecs = 3600
|
|
|
2902
3321
|
`
|
|
2903
3322
|
);
|
|
2904
3323
|
}
|
|
3324
|
+
const scanIdx = args.indexOf("--scan");
|
|
3325
|
+
const added = [];
|
|
3326
|
+
const unmarked = [];
|
|
3327
|
+
if (scanIdx >= 0) {
|
|
3328
|
+
const roots = args.slice(scanIdx + 1).filter((a) => !a.startsWith("--"));
|
|
3329
|
+
if (roots.length === 0) throw new Error("--scan requires at least one directory");
|
|
3330
|
+
const known = configuredRepoKeys();
|
|
3331
|
+
const pickup = args.includes("--pickup");
|
|
3332
|
+
for (const repo of scanForRepos(roots)) {
|
|
3333
|
+
if (known.has(repo.key)) continue;
|
|
3334
|
+
known.add(repo.key);
|
|
3335
|
+
appendRepoBlock(repo, { pickup });
|
|
3336
|
+
added.push(repo.key);
|
|
3337
|
+
if (!pickup) unmarked.push(repo.key);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
|
|
3341
|
+
try {
|
|
3342
|
+
const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
|
|
3343
|
+
if (fs23.existsSync(local)) reference = local;
|
|
3344
|
+
} catch {
|
|
3345
|
+
}
|
|
2905
3346
|
console.log(
|
|
2906
3347
|
toonKV({
|
|
2907
|
-
home:
|
|
3348
|
+
home: path17.dirname(configPath()),
|
|
2908
3349
|
config: configPath(),
|
|
2909
|
-
|
|
3350
|
+
...added.length > 0 ? { added: added.join(", ") } : {},
|
|
3351
|
+
...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
|
|
3352
|
+
reference,
|
|
2910
3353
|
initialized: true
|
|
2911
3354
|
})
|
|
2912
3355
|
);
|
|
2913
3356
|
break;
|
|
2914
3357
|
}
|
|
3358
|
+
case "--version":
|
|
3359
|
+
case "-v":
|
|
3360
|
+
console.log(lobstahVersion());
|
|
3361
|
+
break;
|
|
2915
3362
|
case void 0:
|
|
2916
3363
|
case "--help":
|
|
2917
3364
|
case "help":
|