lobstah 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -39
- package/dist/main.js +901 -176
- 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 +3 -2
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,30 +1196,38 @@ function spawnRunner(st, opts) {
|
|
|
1131
1196
|
processStartTime: child.pid ? processStartTime(child.pid) : void 0,
|
|
1132
1197
|
attempts: opts.attempts
|
|
1133
1198
|
};
|
|
1134
|
-
|
|
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);
|
|
1153
1218
|
const lastVerb = statusLog.at(-1)?.verb;
|
|
1154
1219
|
const alive = st.runner ? pidAlive(st.runner.pid, st.runner.processStartTime) : void 0;
|
|
1155
|
-
if (cancelRequested(st.id, st.lane)
|
|
1156
|
-
|
|
1157
|
-
|
|
1220
|
+
if (cancelRequested(st.id, st.lane)) {
|
|
1221
|
+
if (st.runner && alive) {
|
|
1222
|
+
log(`${st.id}: cancel requested, killing group ${st.runner.pid}`);
|
|
1223
|
+
killGroup(st.runner.pid);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (lastVerb !== "done" && lastVerb !== "failed") {
|
|
1227
|
+
appendStatus(st.id, st.lane, "failed", "cancelled by request; work preserved");
|
|
1228
|
+
}
|
|
1229
|
+
finalize(st);
|
|
1230
|
+
log(`${st.id}: cancelled, finalized`);
|
|
1158
1231
|
return;
|
|
1159
1232
|
}
|
|
1160
1233
|
const cls = classify({
|
|
@@ -1216,22 +1289,22 @@ function writeHeartbeat(cfg) {
|
|
|
1216
1289
|
const payload = {
|
|
1217
1290
|
machineId: os3.hostname(),
|
|
1218
1291
|
repos: Object.keys(cfg.repos),
|
|
1219
|
-
harnesses:
|
|
1292
|
+
harnesses: detectHarnesses(),
|
|
1220
1293
|
maxConcurrent: cfg.limits.maxConcurrent,
|
|
1221
|
-
version:
|
|
1294
|
+
version: lobstahVersion(),
|
|
1222
1295
|
heartbeat: (/* @__PURE__ */ new Date()).toISOString()
|
|
1223
1296
|
};
|
|
1224
1297
|
const tmp = `${executorPath()}.tmp`;
|
|
1225
|
-
|
|
1226
|
-
|
|
1298
|
+
fs11.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
1299
|
+
fs11.renameSync(tmp, executorPath());
|
|
1227
1300
|
}
|
|
1228
1301
|
function pruneChores(cfg) {
|
|
1229
1302
|
const dir = laneDirs("chore").done;
|
|
1230
1303
|
const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
|
|
1231
|
-
for (const id of
|
|
1232
|
-
const p =
|
|
1233
|
-
if (
|
|
1234
|
-
|
|
1304
|
+
for (const id of fs11.readdirSync(dir)) {
|
|
1305
|
+
const p = path7.join(dir, id);
|
|
1306
|
+
if (fs11.statSync(p).mtimeMs < cutoff)
|
|
1307
|
+
fs11.rmSync(p, { recursive: true, force: true });
|
|
1235
1308
|
}
|
|
1236
1309
|
}
|
|
1237
1310
|
var daemonStartedAt = Date.now();
|
|
@@ -1271,9 +1344,9 @@ function tick(log = () => {
|
|
|
1271
1344
|
pruneChores(loadConfig());
|
|
1272
1345
|
}
|
|
1273
1346
|
function acquireDaemonLock() {
|
|
1274
|
-
const lockPath =
|
|
1347
|
+
const lockPath = path7.join(lobstahHome(), "daemon.lock");
|
|
1275
1348
|
try {
|
|
1276
|
-
const prior = JSON.parse(
|
|
1349
|
+
const prior = JSON.parse(fs11.readFileSync(lockPath, "utf8"));
|
|
1277
1350
|
if (pidAlive(prior.pid, prior.processStartTime)) {
|
|
1278
1351
|
throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
|
|
1279
1352
|
}
|
|
@@ -1281,10 +1354,10 @@ function acquireDaemonLock() {
|
|
|
1281
1354
|
if (err instanceof Error && err.message.includes("already supervises"))
|
|
1282
1355
|
throw err;
|
|
1283
1356
|
}
|
|
1284
|
-
|
|
1357
|
+
fs11.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
|
|
1285
1358
|
const release = () => {
|
|
1286
1359
|
try {
|
|
1287
|
-
|
|
1360
|
+
fs11.unlinkSync(lockPath);
|
|
1288
1361
|
} catch {
|
|
1289
1362
|
}
|
|
1290
1363
|
};
|
|
@@ -1314,7 +1387,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
|
|
|
1314
1387
|
import { spawn as spawn3 } from "node:child_process";
|
|
1315
1388
|
|
|
1316
1389
|
// apps/pick/dist/config.js
|
|
1317
|
-
import * as
|
|
1390
|
+
import * as fs12 from "node:fs";
|
|
1318
1391
|
import { execSync } from "node:child_process";
|
|
1319
1392
|
import * as os4 from "node:os";
|
|
1320
1393
|
|
|
@@ -1333,6 +1406,11 @@ function marker(uuid) {
|
|
|
1333
1406
|
}
|
|
1334
1407
|
|
|
1335
1408
|
// apps/pick/dist/config.js
|
|
1409
|
+
function githubRepoFromOrigin(origin) {
|
|
1410
|
+
const m = /^git@github\.com:(.+?)(?:\.git)?$/.exec(origin) ?? /^ssh:\/\/git@github\.com\/(.+?)(?:\.git)?$/.exec(origin) ?? /^https:\/\/github\.com\/(.+?)(?:\.git)?\/?$/.exec(origin);
|
|
1411
|
+
const repo = m?.[1];
|
|
1412
|
+
return repo && repo.split("/").length === 2 ? repo : void 0;
|
|
1413
|
+
}
|
|
1336
1414
|
function resolveTokenSource(section, label) {
|
|
1337
1415
|
const command = section.tokenCommand ? String(section.tokenCommand) : void 0;
|
|
1338
1416
|
const file = section.tokenFile ? String(section.tokenFile) : void 0;
|
|
@@ -1351,7 +1429,7 @@ function resolveTokenSource(section, label) {
|
|
|
1351
1429
|
}
|
|
1352
1430
|
if (file) {
|
|
1353
1431
|
return () => {
|
|
1354
|
-
const token =
|
|
1432
|
+
const token = fs12.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
|
|
1355
1433
|
if (!token)
|
|
1356
1434
|
throw new Error(`${label}: tokenFile ${file} is empty`);
|
|
1357
1435
|
return token;
|
|
@@ -1369,36 +1447,70 @@ function resolveTokenSource(section, label) {
|
|
|
1369
1447
|
}
|
|
1370
1448
|
function loadPickupConfig() {
|
|
1371
1449
|
const file = configPath();
|
|
1372
|
-
const raw =
|
|
1450
|
+
const raw = fs12.existsSync(file) ? parse(fs12.readFileSync(file, "utf8")) : {};
|
|
1373
1451
|
const p = raw.pickup ?? {};
|
|
1374
1452
|
const out = {
|
|
1375
1453
|
pollSecs: Number(p.pollSecs ?? 45),
|
|
1376
|
-
notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0
|
|
1454
|
+
notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0,
|
|
1455
|
+
github: []
|
|
1377
1456
|
};
|
|
1378
1457
|
const gh = p.github;
|
|
1379
1458
|
if (gh) {
|
|
1380
1459
|
const token = resolveTokenSource({ tokenEnv: "GITHUB_TOKEN", ...gh }, "pickup.github");
|
|
1381
1460
|
token();
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1461
|
+
const identity = String(gh.identity ?? "");
|
|
1462
|
+
if (!identity)
|
|
1463
|
+
throw new Error("pickup.github requires identity");
|
|
1464
|
+
const shared = {
|
|
1465
|
+
identity,
|
|
1386
1466
|
token,
|
|
1387
1467
|
startLabel: String(gh.startLabel ?? "lobstah"),
|
|
1388
|
-
claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed")
|
|
1389
|
-
merge: { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} }
|
|
1468
|
+
claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed")
|
|
1390
1469
|
};
|
|
1391
|
-
|
|
1392
|
-
|
|
1470
|
+
const mergeBase = { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} };
|
|
1471
|
+
const overrides = gh.overrides ?? {};
|
|
1472
|
+
if (gh.repo || gh.key) {
|
|
1473
|
+
const single = { ...shared, repo: String(gh.repo ?? ""), key: String(gh.key ?? ""), merge: mergeBase };
|
|
1474
|
+
if (!single.repo || !single.key)
|
|
1475
|
+
throw new Error("pickup.github requires repo, key, and identity");
|
|
1476
|
+
out.github.push(single);
|
|
1477
|
+
} else {
|
|
1478
|
+
for (const [key, repo] of Object.entries(loadConfig().repos)) {
|
|
1479
|
+
if (!repo.pickup)
|
|
1480
|
+
continue;
|
|
1481
|
+
if (!repo.origin)
|
|
1482
|
+
throw new Error(`repos.${key} has pickup = true but no origin to derive the GitHub repo from`);
|
|
1483
|
+
const forgeRepo = githubRepoFromOrigin(repo.origin);
|
|
1484
|
+
if (!forgeRepo)
|
|
1485
|
+
throw new Error(`repos.${key} has pickup = true but origin "${repo.origin}" is not a GitHub URL`);
|
|
1486
|
+
const o = overrides[key] ?? {};
|
|
1487
|
+
out.github.push({
|
|
1488
|
+
...shared,
|
|
1489
|
+
repo: forgeRepo,
|
|
1490
|
+
key,
|
|
1491
|
+
startLabel: o.startLabel ? String(o.startLabel) : shared.startLabel,
|
|
1492
|
+
claimedLabel: o.claimedLabel ? String(o.claimedLabel) : shared.claimedLabel,
|
|
1493
|
+
merge: { ...mergeBase, ...o.merge ?? {} }
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
if (out.github.length === 0) {
|
|
1497
|
+
throw new Error("pickup.github has no repo \u2014 multi-repo mode needs at least one [repos.<key>] with pickup = true and a GitHub origin");
|
|
1498
|
+
}
|
|
1393
1499
|
}
|
|
1394
1500
|
}
|
|
1395
1501
|
const ln = p.linear;
|
|
1396
1502
|
if (ln) {
|
|
1397
1503
|
const token = resolveTokenSource({ tokenEnv: "LINEAR_TOKEN", ...ln }, "pickup.linear");
|
|
1398
1504
|
token();
|
|
1505
|
+
const assignField = ln.assignField ? String(ln.assignField) : "assignee";
|
|
1506
|
+
if (assignField !== "assignee" && assignField !== "delegate") {
|
|
1507
|
+
throw new Error(`pickup.linear.assignField must be "assignee" or "delegate", got "${assignField}"`);
|
|
1508
|
+
}
|
|
1399
1509
|
out.linear = {
|
|
1400
1510
|
token,
|
|
1511
|
+
assignField,
|
|
1401
1512
|
startState: String(ln.startState ?? "Todo"),
|
|
1513
|
+
startStateTypes: Array.isArray(ln.startStateTypes) ? ln.startStateTypes.map(String) : void 0,
|
|
1402
1514
|
claimedState: String(ln.claimedState ?? "In Progress"),
|
|
1403
1515
|
doneState: String(ln.doneState ?? "In Review"),
|
|
1404
1516
|
route: ln.route ?? {}
|
|
@@ -1408,23 +1520,23 @@ function loadPickupConfig() {
|
|
|
1408
1520
|
}
|
|
1409
1521
|
|
|
1410
1522
|
// apps/pick/dist/state.js
|
|
1411
|
-
import * as
|
|
1412
|
-
import * as
|
|
1523
|
+
import * as fs13 from "node:fs";
|
|
1524
|
+
import * as path8 from "node:path";
|
|
1413
1525
|
var PickupState = class {
|
|
1414
1526
|
file;
|
|
1415
1527
|
data;
|
|
1416
|
-
constructor(dir =
|
|
1417
|
-
|
|
1418
|
-
this.file =
|
|
1419
|
-
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: {} };
|
|
1420
1532
|
this.data.map ??= {};
|
|
1421
1533
|
this.data.consumedApprovals ??= {};
|
|
1422
1534
|
this.data.rebases ??= {};
|
|
1423
1535
|
}
|
|
1424
1536
|
save() {
|
|
1425
1537
|
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
1426
|
-
|
|
1427
|
-
|
|
1538
|
+
fs13.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
|
|
1539
|
+
fs13.renameSync(tmp, this.file);
|
|
1428
1540
|
}
|
|
1429
1541
|
get(key) {
|
|
1430
1542
|
return this.data.map[key];
|
|
@@ -1624,6 +1736,15 @@ When the change is complete, commit it, push the branch, and open a PR against $
|
|
|
1624
1736
|
const pr = await this.api("GET", `/repos/${this.cfg.repo}/pulls/${n}`);
|
|
1625
1737
|
return pr.state === "open" ? this.toCandidate(pr) : void 0;
|
|
1626
1738
|
}
|
|
1739
|
+
async disposition(n) {
|
|
1740
|
+
const pr = await this.api("GET", `/repos/${this.cfg.repo}/pulls/${n}`);
|
|
1741
|
+
if (pr.state === "open")
|
|
1742
|
+
return "open";
|
|
1743
|
+
return pr.merged_at ? "merged" : "closed";
|
|
1744
|
+
}
|
|
1745
|
+
forgeRepo() {
|
|
1746
|
+
return this.cfg.repo;
|
|
1747
|
+
}
|
|
1627
1748
|
async updateBranch(n) {
|
|
1628
1749
|
await this.api("PUT", `/repos/${this.cfg.repo}/pulls/${n}/update-branch`, {});
|
|
1629
1750
|
}
|
|
@@ -1669,12 +1790,14 @@ var LinearSource = class {
|
|
|
1669
1790
|
}
|
|
1670
1791
|
return json.data;
|
|
1671
1792
|
}
|
|
1672
|
-
async assigned(stateName) {
|
|
1673
|
-
const
|
|
1674
|
-
|
|
1675
|
-
|
|
1793
|
+
async assigned(stateName, byTypes) {
|
|
1794
|
+
const field = this.cfg.assignField ?? "assignee";
|
|
1795
|
+
const stateFilter = byTypes?.length ? { type: { in: byTypes } } : { name: { eq: stateName } };
|
|
1796
|
+
const data = await this.gql(`query($filter: IssueFilter!) {
|
|
1797
|
+
issues(filter: $filter, first: 50) {
|
|
1798
|
+
nodes { id identifier title description state { name type } team { key id } }
|
|
1676
1799
|
}
|
|
1677
|
-
}`, { state:
|
|
1800
|
+
}`, { filter: { [field]: { isMe: { eq: true } }, state: stateFilter } });
|
|
1678
1801
|
return data.issues.nodes;
|
|
1679
1802
|
}
|
|
1680
1803
|
async stateId(teamId, name) {
|
|
@@ -1692,7 +1815,7 @@ var LinearSource = class {
|
|
|
1692
1815
|
}
|
|
1693
1816
|
async issueByKey(key) {
|
|
1694
1817
|
const identifier = key.replace(/^linear:/, "");
|
|
1695
|
-
const data = await this.gql(`query($id: String!) { issue(id: $id) { id identifier title description state { name } team { key id } } }`, { id: identifier });
|
|
1818
|
+
const data = await this.gql(`query($id: String!) { issue(id: $id) { id identifier title description state { name type } team { key id } } }`, { id: identifier });
|
|
1696
1819
|
return data.issue;
|
|
1697
1820
|
}
|
|
1698
1821
|
async moveTo(issue, stateName) {
|
|
@@ -1703,7 +1826,7 @@ var LinearSource = class {
|
|
|
1703
1826
|
await this.gql(`mutation($id: String!, $body: String!) { commentCreate(input: { issueId: $id, body: $body }) { success } }`, { id: issue.id, body });
|
|
1704
1827
|
}
|
|
1705
1828
|
async poll() {
|
|
1706
|
-
const issues = await this.assigned(this.cfg.startState);
|
|
1829
|
+
const issues = await this.assigned(this.cfg.startState, this.cfg.startStateTypes);
|
|
1707
1830
|
return issues.flatMap((issue) => {
|
|
1708
1831
|
const repoKey = this.cfg.route[issue.team.key];
|
|
1709
1832
|
if (!repoKey)
|
|
@@ -1725,7 +1848,8 @@ When the change is complete, commit it, push the branch, and open a PR whose des
|
|
|
1725
1848
|
}
|
|
1726
1849
|
async claim(item) {
|
|
1727
1850
|
const issue = await this.issueByKey(item.key);
|
|
1728
|
-
|
|
1851
|
+
const stillWaiting = this.cfg.startStateTypes?.length ? this.cfg.startStateTypes.includes(issue.state.type) : issue.state.name === this.cfg.startState;
|
|
1852
|
+
if (!stillWaiting)
|
|
1729
1853
|
return false;
|
|
1730
1854
|
await this.moveTo(issue, this.cfg.claimedState);
|
|
1731
1855
|
return true;
|
|
@@ -1796,12 +1920,12 @@ async function dispatchLoop(source, state, log = () => {
|
|
|
1796
1920
|
}
|
|
1797
1921
|
|
|
1798
1922
|
// apps/pick/dist/loops/report.js
|
|
1799
|
-
import * as
|
|
1800
|
-
import * as
|
|
1923
|
+
import * as fs14 from "node:fs";
|
|
1924
|
+
import * as path9 from "node:path";
|
|
1801
1925
|
function dispatchLane(uuid) {
|
|
1802
1926
|
for (const lane of ["work", "chore"]) {
|
|
1803
1927
|
const d = laneDirs(lane);
|
|
1804
|
-
if (
|
|
1928
|
+
if (fs14.existsSync(path9.join(d.active, uuid)) || fs14.existsSync(path9.join(d.queue, `${uuid}.json`)) || fs14.existsSync(path9.join(d.done, uuid)) || fs14.existsSync(path9.join(d.state, `${uuid}.status`))) {
|
|
1805
1929
|
return lane;
|
|
1806
1930
|
}
|
|
1807
1931
|
}
|
|
@@ -1842,12 +1966,12 @@ async function reportLoop(source, state, log = () => {
|
|
|
1842
1966
|
}
|
|
1843
1967
|
|
|
1844
1968
|
// apps/pick/dist/loops/reconcile.js
|
|
1845
|
-
import * as
|
|
1846
|
-
import * as
|
|
1969
|
+
import * as fs15 from "node:fs";
|
|
1970
|
+
import * as path10 from "node:path";
|
|
1847
1971
|
function dispatchActive(uuid) {
|
|
1848
1972
|
for (const lane of ["work", "chore"]) {
|
|
1849
1973
|
const d = laneDirs(lane);
|
|
1850
|
-
if (
|
|
1974
|
+
if (fs15.existsSync(path10.join(d.active, uuid)) || fs15.existsSync(path10.join(d.queue, `${uuid}.json`))) {
|
|
1851
1975
|
return lane;
|
|
1852
1976
|
}
|
|
1853
1977
|
}
|
|
@@ -1887,8 +2011,57 @@ async function reconcileLoop(source, state, log = () => {
|
|
|
1887
2011
|
|
|
1888
2012
|
// apps/pick/dist/loops/merge.js
|
|
1889
2013
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1890
|
-
import * as
|
|
1891
|
-
import * as
|
|
2014
|
+
import * as fs17 from "node:fs";
|
|
2015
|
+
import * as path12 from "node:path";
|
|
2016
|
+
|
|
2017
|
+
// apps/pick/dist/merge-view.js
|
|
2018
|
+
import * as fs16 from "node:fs";
|
|
2019
|
+
import * as path11 from "node:path";
|
|
2020
|
+
function viewPath() {
|
|
2021
|
+
return path11.join(lobstahHome(), "pickup", "merge-view.json");
|
|
2022
|
+
}
|
|
2023
|
+
function readMergeView() {
|
|
2024
|
+
try {
|
|
2025
|
+
return JSON.parse(fs16.readFileSync(viewPath(), "utf8"));
|
|
2026
|
+
} catch {
|
|
2027
|
+
return void 0;
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
function writeMergeView(view) {
|
|
2031
|
+
const file = viewPath();
|
|
2032
|
+
fs16.mkdirSync(path11.dirname(file), { recursive: true });
|
|
2033
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
2034
|
+
fs16.writeFileSync(tmp, JSON.stringify(view, null, 2));
|
|
2035
|
+
fs16.renameSync(tmp, file);
|
|
2036
|
+
}
|
|
2037
|
+
var RECENT_RETENTION_MS = 48 * 36e5;
|
|
2038
|
+
async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
|
|
2039
|
+
const prev = readMergeView();
|
|
2040
|
+
const recent = (prev?.recent ?? []).filter((r) => now.getTime() - Date.parse(r.at) < RECENT_RETENTION_MS);
|
|
2041
|
+
const stillOpen = new Set(open.map((p) => p.number));
|
|
2042
|
+
for (const was of prev?.open ?? []) {
|
|
2043
|
+
if (stillOpen.has(was.number))
|
|
2044
|
+
continue;
|
|
2045
|
+
try {
|
|
2046
|
+
const d = await disposition(was.number);
|
|
2047
|
+
if (d === "open")
|
|
2048
|
+
continue;
|
|
2049
|
+
recent.push({ number: was.number, url: was.url, disposition: d, at: now.toISOString() });
|
|
2050
|
+
} catch {
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
writeMergeView({ updatedAt: now.toISOString(), repo, open, recent });
|
|
2054
|
+
}
|
|
2055
|
+
function readPickupMap() {
|
|
2056
|
+
try {
|
|
2057
|
+
const raw = JSON.parse(fs16.readFileSync(path11.join(lobstahHome(), "pickup", "state.json"), "utf8"));
|
|
2058
|
+
return raw.map ?? {};
|
|
2059
|
+
} catch {
|
|
2060
|
+
return {};
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// apps/pick/dist/loops/merge.js
|
|
1892
2065
|
function qualifyingSet(policy, pr) {
|
|
1893
2066
|
const restricted = pr.labels.some((l) => policy.restrictedLabels.includes(l));
|
|
1894
2067
|
if (restricted || !policy.assigneeApproves)
|
|
@@ -1915,7 +2088,7 @@ function rebaseBrief(pr) {
|
|
|
1915
2088
|
}
|
|
1916
2089
|
function choreVerdict(uuid) {
|
|
1917
2090
|
const d = laneDirs("chore");
|
|
1918
|
-
if (
|
|
2091
|
+
if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`)))
|
|
1919
2092
|
return "active";
|
|
1920
2093
|
const last = readStatusLog(uuid, "chore").at(-1)?.verb;
|
|
1921
2094
|
return last === "done" ? "done" : "failed";
|
|
@@ -1924,54 +2097,78 @@ async function mergeLoop(ms, policy, state, log = () => {
|
|
|
1924
2097
|
}) {
|
|
1925
2098
|
if (!policy.enabled)
|
|
1926
2099
|
return;
|
|
2100
|
+
const observed = [];
|
|
2101
|
+
const observe = (pr, gate) => {
|
|
2102
|
+
const m = /^lobstah\/([0-9a-f-]{36})$/.exec(pr.headRef);
|
|
2103
|
+
observed.push({
|
|
2104
|
+
number: pr.number,
|
|
2105
|
+
url: pr.url,
|
|
2106
|
+
headRef: pr.headRef,
|
|
2107
|
+
headSha: pr.headSha,
|
|
2108
|
+
mergeableState: pr.mergeableState,
|
|
2109
|
+
gate,
|
|
2110
|
+
uuid: m?.[1]
|
|
2111
|
+
});
|
|
2112
|
+
};
|
|
1927
2113
|
for (const candidate of await ms.mergeCandidates()) {
|
|
1928
2114
|
const prKey = `${ms.name}#${candidate.number}`;
|
|
1929
2115
|
const rebase = state.rebase(prKey);
|
|
1930
2116
|
if (rebase) {
|
|
1931
|
-
if (rebase.failed)
|
|
2117
|
+
if (rebase.failed) {
|
|
2118
|
+
observe(candidate, "rebase-failed");
|
|
1932
2119
|
continue;
|
|
2120
|
+
}
|
|
1933
2121
|
const verdict = choreVerdict(rebase.uuid);
|
|
1934
|
-
if (verdict === "active")
|
|
2122
|
+
if (verdict === "active") {
|
|
2123
|
+
observe(candidate, `conflict-chore:${rebase.uuid}`);
|
|
1935
2124
|
continue;
|
|
2125
|
+
}
|
|
1936
2126
|
if (verdict === "failed") {
|
|
1937
2127
|
await ms.comment(candidate.number, `Automated rebase failed (dispatch ${rebase.uuid}) \u2014 needs a human.`);
|
|
1938
2128
|
await ms.addLabel(candidate.number, "needs-human");
|
|
1939
2129
|
state.failRebase(prKey);
|
|
1940
2130
|
log(`${prKey}: rebase chore failed, flagged for a human`);
|
|
2131
|
+
observe(candidate, "rebase-failed");
|
|
1941
2132
|
continue;
|
|
1942
2133
|
}
|
|
1943
2134
|
state.clearRebase(prKey);
|
|
1944
2135
|
}
|
|
1945
2136
|
const provisional = qualifiedApproval(policy, candidate);
|
|
1946
|
-
if (!provisional)
|
|
1947
|
-
|
|
1948
|
-
if (state.approvalConsumed(approvalDedupKey(candidate, provisional)))
|
|
2137
|
+
if (!provisional || state.approvalConsumed(approvalDedupKey(candidate, provisional))) {
|
|
2138
|
+
observe(candidate, "waiting-approval");
|
|
1949
2139
|
continue;
|
|
2140
|
+
}
|
|
1950
2141
|
const pr = await ms.refresh(candidate.number);
|
|
1951
2142
|
if (!pr)
|
|
1952
2143
|
continue;
|
|
1953
2144
|
const approval = qualifiedApproval(policy, pr);
|
|
1954
2145
|
if (!approval) {
|
|
1955
2146
|
log(`${prKey}: gate no longer holds on fresh fetch \u2014 skipping`);
|
|
2147
|
+
observe(pr, "waiting-approval");
|
|
1956
2148
|
continue;
|
|
1957
2149
|
}
|
|
1958
2150
|
const dedupKey = approvalDedupKey(pr, approval);
|
|
1959
|
-
if (state.approvalConsumed(dedupKey))
|
|
2151
|
+
if (state.approvalConsumed(dedupKey)) {
|
|
2152
|
+
observe(pr, "waiting-approval");
|
|
1960
2153
|
continue;
|
|
2154
|
+
}
|
|
1961
2155
|
switch (pr.mergeableState) {
|
|
1962
2156
|
case "blocked":
|
|
1963
2157
|
case "draft":
|
|
1964
2158
|
log(`${prKey}: ${pr.mergeableState} \u2014 not merging`);
|
|
2159
|
+
observe(pr, pr.mergeableState);
|
|
1965
2160
|
break;
|
|
1966
2161
|
case "behind":
|
|
1967
2162
|
await ms.updateBranch(pr.number);
|
|
1968
2163
|
log(`${prKey}: behind base, updated branch \u2014 gate re-enters next tick`);
|
|
2164
|
+
observe(pr, "behind-updated");
|
|
1969
2165
|
break;
|
|
1970
2166
|
case "dirty": {
|
|
1971
2167
|
const uuid = randomUUID2();
|
|
1972
2168
|
enqueue({ id: uuid, repo: ms.repoKey(), brief: rebaseBrief(pr) }, "chore");
|
|
1973
2169
|
state.setRebase(prKey, uuid);
|
|
1974
2170
|
log(`${prKey}: real conflict \u2014 wrote rebase chore ${uuid}`);
|
|
2171
|
+
observe(pr, `conflict-chore:${uuid}`);
|
|
1975
2172
|
break;
|
|
1976
2173
|
}
|
|
1977
2174
|
default: {
|
|
@@ -1981,6 +2178,7 @@ async function mergeLoop(ms, policy, state, log = () => {
|
|
|
1981
2178
|
}
|
|
1982
2179
|
}
|
|
1983
2180
|
}
|
|
2181
|
+
await recordMergeView(ms.forgeRepo(), observed, (n) => ms.disposition(n));
|
|
1984
2182
|
}
|
|
1985
2183
|
|
|
1986
2184
|
// apps/pick/dist/run.js
|
|
@@ -2031,10 +2229,10 @@ async function runPickup(mode = "daemon") {
|
|
|
2031
2229
|
const log = (m) => console.log(`[pickup] ${m}`);
|
|
2032
2230
|
const sources = [];
|
|
2033
2231
|
const merges = [];
|
|
2034
|
-
|
|
2035
|
-
const gh = new GithubSource(
|
|
2232
|
+
for (const ghCfg of cfg.github) {
|
|
2233
|
+
const gh = new GithubSource(ghCfg);
|
|
2036
2234
|
sources.push(gh);
|
|
2037
|
-
merges.push({ source: gh, policy:
|
|
2235
|
+
merges.push({ source: gh, policy: ghCfg.merge });
|
|
2038
2236
|
}
|
|
2039
2237
|
if (cfg.linear)
|
|
2040
2238
|
sources.push(new LinearSource(cfg.linear));
|
|
@@ -2066,14 +2264,179 @@ function mergeHaulHook(existing) {
|
|
|
2066
2264
|
return { settings, changed: true };
|
|
2067
2265
|
}
|
|
2068
2266
|
|
|
2267
|
+
// apps/cli/src/tend.ts
|
|
2268
|
+
import * as fs18 from "node:fs";
|
|
2269
|
+
import * as path13 from "node:path";
|
|
2270
|
+
var HEARTBEAT_STALE_MS = 9e4;
|
|
2271
|
+
var CLAIM_STALE_MS = 12e4;
|
|
2272
|
+
var DAY_MS = 24 * 36e5;
|
|
2273
|
+
function readJson(file) {
|
|
2274
|
+
try {
|
|
2275
|
+
return JSON.parse(fs18.readFileSync(file, "utf8"));
|
|
2276
|
+
} catch {
|
|
2277
|
+
return void 0;
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
function doneIds(lane) {
|
|
2281
|
+
try {
|
|
2282
|
+
return fs18.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
|
|
2283
|
+
} catch {
|
|
2284
|
+
return [];
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
function describeDispatch(id, lane, bucket) {
|
|
2288
|
+
const log = readStatusLog(id, lane);
|
|
2289
|
+
const last = log.at(-1);
|
|
2290
|
+
const state = bucket === "queued" ? "queued" : reconcile({ log, lastEventAt: lastEventAt(id, lane) });
|
|
2291
|
+
const evidence = readEvidence(id, lane);
|
|
2292
|
+
return { id, lane, bucket, state, note: last?.note, at: last?.at, prUrl: evidence.prUrl };
|
|
2293
|
+
}
|
|
2294
|
+
function followUps(uuid) {
|
|
2295
|
+
const out = [];
|
|
2296
|
+
const d = laneDirs("work");
|
|
2297
|
+
const scan = (id, bucket, file) => {
|
|
2298
|
+
const desc = readJson(file);
|
|
2299
|
+
if (desc?.followUp === uuid) out.push({ id, bucket });
|
|
2300
|
+
};
|
|
2301
|
+
for (const id of pendingIds("work")) scan(id, "queued", path13.join(d.queue, `${id}.json`));
|
|
2302
|
+
for (const id of activeIds("work")) scan(id, "active", path13.join(d.active, id, "descriptor.json"));
|
|
2303
|
+
for (const id of doneIds("work")) scan(id, "done", path13.join(d.done, id, "descriptor.json"));
|
|
2304
|
+
return out;
|
|
2305
|
+
}
|
|
2306
|
+
function bucketOf(uuid) {
|
|
2307
|
+
const d = laneDirs("work");
|
|
2308
|
+
if (fs18.existsSync(path13.join(d.active, uuid))) return "active";
|
|
2309
|
+
if (fs18.existsSync(path13.join(d.done, uuid))) return "done";
|
|
2310
|
+
if (fs18.existsSync(path13.join(d.queue, `${uuid}.json`))) return "queued";
|
|
2311
|
+
return void 0;
|
|
2312
|
+
}
|
|
2313
|
+
function buildTendReport(now = Date.now()) {
|
|
2314
|
+
const cfg = loadConfig();
|
|
2315
|
+
const heartbeat = readJson(executorPath())?.heartbeat;
|
|
2316
|
+
const daemonUp = heartbeat !== void 0 && now - Date.parse(heartbeat) < HEARTBEAT_STALE_MS;
|
|
2317
|
+
const queued = pendingIds("work");
|
|
2318
|
+
const active = activeIds("work");
|
|
2319
|
+
const choresActive = activeIds("chore").length + pendingIds("chore").length;
|
|
2320
|
+
let done24h = 0;
|
|
2321
|
+
let failed24h = 0;
|
|
2322
|
+
for (const lane of ["work", "chore"]) {
|
|
2323
|
+
for (const id of doneIds(lane)) {
|
|
2324
|
+
const last = readStatusLog(id, lane).at(-1);
|
|
2325
|
+
if (!last || now - Date.parse(last.at) > DAY_MS) continue;
|
|
2326
|
+
if (last.verb === "failed") failed24h += 1;
|
|
2327
|
+
else if (last.verb === "done") done24h += 1;
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
const remindMs = (cfg.remindSecs ?? 900) * 1e3;
|
|
2331
|
+
const attention = attentionNow(false, remindMs, now).map((ev) => ({
|
|
2332
|
+
id: ev.id,
|
|
2333
|
+
lane: ev.lane,
|
|
2334
|
+
verb: ev.entry.verb,
|
|
2335
|
+
ageSecs: Math.max(0, Math.round((now - Date.parse(ev.entry.at)) / 1e3)),
|
|
2336
|
+
note: ev.entry.note
|
|
2337
|
+
}));
|
|
2338
|
+
const oldestQueuedAge = queued.reduce((max, id) => {
|
|
2339
|
+
const st = fs18.statSync(path13.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
|
|
2340
|
+
return st ? Math.max(max, now - st.mtimeMs) : max;
|
|
2341
|
+
}, 0);
|
|
2342
|
+
const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
|
|
2343
|
+
const merge = readMergeView();
|
|
2344
|
+
const gateFor = (uuid, prUrl) => {
|
|
2345
|
+
const open = merge?.open.find((p) => p.uuid === uuid || prUrl !== void 0 && p.url === prUrl);
|
|
2346
|
+
if (open) return open.gate;
|
|
2347
|
+
const recent = merge?.recent.find((r) => prUrl !== void 0 && r.url === prUrl);
|
|
2348
|
+
return recent?.disposition;
|
|
2349
|
+
};
|
|
2350
|
+
const storied = /* @__PURE__ */ new Set();
|
|
2351
|
+
const stories = [];
|
|
2352
|
+
for (const [key, entry] of Object.entries(readPickupMap())) {
|
|
2353
|
+
const bucket = bucketOf(entry.uuid);
|
|
2354
|
+
if (!bucket) continue;
|
|
2355
|
+
const chain = [describeDispatch(entry.uuid, "work", bucket)];
|
|
2356
|
+
for (const f of followUps(entry.uuid)) chain.push(describeDispatch(f.id, "work", f.bucket));
|
|
2357
|
+
for (const d of chain) storied.add(d.id);
|
|
2358
|
+
const fresh = chain.some((d) => d.bucket !== "done" || d.at !== void 0 && now - Date.parse(d.at) < DAY_MS);
|
|
2359
|
+
if (!fresh) continue;
|
|
2360
|
+
const prUrl = chain.map((d) => d.prUrl).find((u) => u !== void 0);
|
|
2361
|
+
stories.push({ key, dispatches: chain, prUrl, gate: gateFor(entry.uuid, prUrl) });
|
|
2362
|
+
}
|
|
2363
|
+
for (const id of [...active, ...queued]) {
|
|
2364
|
+
if (storied.has(id)) continue;
|
|
2365
|
+
const d = describeDispatch(id, "work", bucketOf(id) ?? "active");
|
|
2366
|
+
stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl) });
|
|
2367
|
+
}
|
|
2368
|
+
const verdict = !daemonUp ? "daemon-down" : stalled ? "stalled" : attention.length > 0 ? "needs-attention" : active.length + queued.length > 0 ? "working" : "idle";
|
|
2369
|
+
return {
|
|
2370
|
+
verdict,
|
|
2371
|
+
daemon: { up: daemonUp, lastHeartbeat: heartbeat },
|
|
2372
|
+
counts: { queued: queued.length, active: active.length, choresActive, done24h, failed24h },
|
|
2373
|
+
attention,
|
|
2374
|
+
stories,
|
|
2375
|
+
merge
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
function renderTend(r) {
|
|
2379
|
+
const lines = [];
|
|
2380
|
+
lines.push(
|
|
2381
|
+
toonKV({
|
|
2382
|
+
verdict: r.verdict,
|
|
2383
|
+
daemon: r.daemon.up ? "up" : `down (last heartbeat ${r.daemon.lastHeartbeat ?? "never"})`,
|
|
2384
|
+
queued: r.counts.queued,
|
|
2385
|
+
active: r.counts.active,
|
|
2386
|
+
chores: r.counts.choresActive,
|
|
2387
|
+
done24h: r.counts.done24h,
|
|
2388
|
+
failed24h: r.counts.failed24h
|
|
2389
|
+
})
|
|
2390
|
+
);
|
|
2391
|
+
if (r.attention.length > 0) {
|
|
2392
|
+
lines.push("");
|
|
2393
|
+
lines.push(
|
|
2394
|
+
toonTable(
|
|
2395
|
+
"attention",
|
|
2396
|
+
r.attention.map((a) => ({ id: a.id, verb: a.verb, waitingMins: Math.round(a.ageSecs / 60), note: a.note ?? "" })),
|
|
2397
|
+
["id", "verb", "waitingMins", "note"]
|
|
2398
|
+
)
|
|
2399
|
+
);
|
|
2400
|
+
}
|
|
2401
|
+
if (r.stories.length > 0) {
|
|
2402
|
+
lines.push("");
|
|
2403
|
+
lines.push(
|
|
2404
|
+
toonTable(
|
|
2405
|
+
"work",
|
|
2406
|
+
r.stories.map((s) => ({
|
|
2407
|
+
key: s.key,
|
|
2408
|
+
dispatches: s.dispatches.map((d) => `${d.id.slice(0, 8)}:${d.state}`).join(" \u2192 "),
|
|
2409
|
+
pr: s.prUrl ?? "",
|
|
2410
|
+
gate: s.gate ?? ""
|
|
2411
|
+
})),
|
|
2412
|
+
["key", "dispatches", "pr", "gate"]
|
|
2413
|
+
)
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2416
|
+
if (r.merge && (r.merge.open.length > 0 || r.merge.recent.length > 0)) {
|
|
2417
|
+
lines.push("");
|
|
2418
|
+
lines.push(
|
|
2419
|
+
toonTable(
|
|
2420
|
+
`prs (${r.merge.repo}, observed ${r.merge.updatedAt})`,
|
|
2421
|
+
[
|
|
2422
|
+
...r.merge.open.map((p) => ({ pr: `#${p.number}`, state: p.mergeableState, gate: p.gate, url: p.url })),
|
|
2423
|
+
...r.merge.recent.map((p) => ({ pr: `#${p.number}`, state: p.disposition, gate: "", url: p.url }))
|
|
2424
|
+
],
|
|
2425
|
+
["pr", "state", "gate", "url"]
|
|
2426
|
+
)
|
|
2427
|
+
);
|
|
2428
|
+
}
|
|
2429
|
+
return lines.join("\n");
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2069
2432
|
// apps/cli/src/cull.ts
|
|
2070
|
-
import * as
|
|
2071
|
-
import * as
|
|
2433
|
+
import * as fs19 from "node:fs";
|
|
2434
|
+
import * as path14 from "node:path";
|
|
2072
2435
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2073
2436
|
var DAY = 864e5;
|
|
2074
2437
|
function idsIn(dir) {
|
|
2075
2438
|
try {
|
|
2076
|
-
return new Set(
|
|
2439
|
+
return new Set(fs19.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
|
|
2077
2440
|
} catch {
|
|
2078
2441
|
return /* @__PURE__ */ new Set();
|
|
2079
2442
|
}
|
|
@@ -2088,39 +2451,39 @@ function planCull(olderThanDays, now = Date.now()) {
|
|
|
2088
2451
|
for (const id of idsIn(d.queue)) live.add(id);
|
|
2089
2452
|
for (const id of idsIn(d.active)) live.add(id);
|
|
2090
2453
|
for (const id of idsIn(d.done)) {
|
|
2091
|
-
const p =
|
|
2092
|
-
const m =
|
|
2454
|
+
const p = path14.join(d.done, id);
|
|
2455
|
+
const m = fs19.statSync(p).mtimeMs;
|
|
2093
2456
|
doneMtimes.set(id, m);
|
|
2094
2457
|
if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
|
|
2095
2458
|
}
|
|
2096
2459
|
}
|
|
2097
|
-
const wtRoot =
|
|
2460
|
+
const wtRoot = path14.join(lobstahHome(), "worktrees");
|
|
2098
2461
|
for (const id of idsIn(wtRoot)) {
|
|
2099
2462
|
if (live.has(id)) continue;
|
|
2100
2463
|
const doneAt = doneMtimes.get(id);
|
|
2101
2464
|
if (doneAt !== void 0 && doneAt >= cutoff) continue;
|
|
2102
|
-
const p =
|
|
2103
|
-
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) });
|
|
2104
2467
|
}
|
|
2105
2468
|
for (const lane of ["work", "chore"]) {
|
|
2106
2469
|
const d = laneDirs(lane);
|
|
2107
2470
|
const seen = /* @__PURE__ */ new Set();
|
|
2108
|
-
for (const f of
|
|
2471
|
+
for (const f of fs19.readdirSync(d.state)) {
|
|
2109
2472
|
const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
|
|
2110
2473
|
if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
|
|
2111
|
-
const m =
|
|
2474
|
+
const m = fs19.statSync(path14.join(d.state, f)).mtimeMs;
|
|
2112
2475
|
if (m >= cutoff) continue;
|
|
2113
2476
|
seen.add(id);
|
|
2114
|
-
items.push({ kind: "state", id, target:
|
|
2477
|
+
items.push({ kind: "state", id, target: path14.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
|
|
2115
2478
|
}
|
|
2116
2479
|
}
|
|
2117
2480
|
return items;
|
|
2118
2481
|
}
|
|
2119
2482
|
function removeWorktree(id, dir) {
|
|
2120
2483
|
for (const lane of ["work", "chore"]) {
|
|
2121
|
-
const descFile =
|
|
2484
|
+
const descFile = path14.join(laneDirs(lane).done, id, "descriptor.json");
|
|
2122
2485
|
try {
|
|
2123
|
-
const desc = JSON.parse(
|
|
2486
|
+
const desc = JSON.parse(fs19.readFileSync(descFile, "utf8"));
|
|
2124
2487
|
const repo = loadConfig().repos[desc.repo];
|
|
2125
2488
|
if (repo) {
|
|
2126
2489
|
execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
|
|
@@ -2129,15 +2492,15 @@ function removeWorktree(id, dir) {
|
|
|
2129
2492
|
} catch {
|
|
2130
2493
|
}
|
|
2131
2494
|
}
|
|
2132
|
-
|
|
2495
|
+
fs19.rmSync(dir, { recursive: true, force: true });
|
|
2133
2496
|
}
|
|
2134
2497
|
function applyCull(items) {
|
|
2135
2498
|
for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
|
|
2136
|
-
for (const item of items.filter((i) => i.kind === "done"))
|
|
2499
|
+
for (const item of items.filter((i) => i.kind === "done")) fs19.rmSync(item.target, { recursive: true, force: true });
|
|
2137
2500
|
for (const item of items.filter((i) => i.kind === "state")) {
|
|
2138
|
-
const dir =
|
|
2139
|
-
for (const f of
|
|
2140
|
-
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 });
|
|
2141
2504
|
}
|
|
2142
2505
|
}
|
|
2143
2506
|
}
|
|
@@ -2160,6 +2523,9 @@ your working set:
|
|
|
2160
2523
|
lobstah catch <id> land the catch (evidence)
|
|
2161
2524
|
lobstah cancel <id> cut one away
|
|
2162
2525
|
lobstah cull [--apply] sweep aged catch and lost gear
|
|
2526
|
+
lobstah man tend [--json] tend the string: fleet verdict,
|
|
2527
|
+
unanswered questions, each work
|
|
2528
|
+
item's chain + PR + merge gate
|
|
2163
2529
|
|
|
2164
2530
|
getting woken instead of asking:
|
|
2165
2531
|
lobstah man wait block until a dispatch needs attention (arm it as a
|
|
@@ -2178,8 +2544,270 @@ paste into your liaison instructions:
|
|
|
2178
2544
|
|
|
2179
2545
|
full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
|
|
2180
2546
|
|
|
2547
|
+
// apps/cli/src/doctor.ts
|
|
2548
|
+
import * as fs20 from "node:fs";
|
|
2549
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
2550
|
+
import { spawnSync } from "node:child_process";
|
|
2551
|
+
var HEARTBEAT_STALE_MS2 = 9e4;
|
|
2552
|
+
function git(repoPath, ...args) {
|
|
2553
|
+
const res = spawnSync("git", ["-C", repoPath, ...args], { encoding: "utf8" });
|
|
2554
|
+
return { ok: res.status === 0, out: (res.stdout ?? "").trim() || (res.stderr ?? "").trim() };
|
|
2555
|
+
}
|
|
2556
|
+
function runDoctor(now = Date.now()) {
|
|
2557
|
+
const rows = [];
|
|
2558
|
+
const push = (check, status, detail) => rows.push({ check, status, detail });
|
|
2559
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
2560
|
+
push("node", major >= 20 ? "ok" : "fail", `v${process.versions.node}${major >= 20 ? "" : " \u2014 lobstah needs >=20"}`);
|
|
2561
|
+
push("git", onPath("git") ? "ok" : "fail", onPath("git") ? "on PATH" : "not on PATH");
|
|
2562
|
+
const claude = onPath("claude");
|
|
2563
|
+
push("harness claude", claude ? "ok" : "warn", claude ? "on PATH" : "claude not on PATH \u2014 claude dispatches will fail");
|
|
2564
|
+
const codexPath = onPath("codex");
|
|
2565
|
+
let codexSdk = false;
|
|
2566
|
+
try {
|
|
2567
|
+
createRequire3(import.meta.url).resolve("@openai/codex-sdk");
|
|
2568
|
+
codexSdk = true;
|
|
2569
|
+
} catch {
|
|
2570
|
+
}
|
|
2571
|
+
push(
|
|
2572
|
+
"harness codex",
|
|
2573
|
+
codexPath || codexSdk ? "ok" : "warn",
|
|
2574
|
+
codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
|
|
2575
|
+
);
|
|
2576
|
+
const cfgFile = configPath();
|
|
2577
|
+
if (!fs20.existsSync(cfgFile)) {
|
|
2578
|
+
push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
|
|
2579
|
+
return rows;
|
|
2580
|
+
}
|
|
2581
|
+
let parsed;
|
|
2582
|
+
try {
|
|
2583
|
+
parsed = parse(fs20.readFileSync(cfgFile, "utf8"));
|
|
2584
|
+
push("config", "ok", cfgFile);
|
|
2585
|
+
} catch (err) {
|
|
2586
|
+
push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2587
|
+
return rows;
|
|
2588
|
+
}
|
|
2589
|
+
const cfg = loadConfig();
|
|
2590
|
+
const repoKeys = Object.keys(cfg.repos);
|
|
2591
|
+
if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
|
|
2592
|
+
for (const [key, repo] of Object.entries(cfg.repos)) {
|
|
2593
|
+
const label = `repo ${key}`;
|
|
2594
|
+
if (!fs20.existsSync(repo.path)) {
|
|
2595
|
+
push(label, "fail", `path ${repo.path} does not exist`);
|
|
2596
|
+
continue;
|
|
2597
|
+
}
|
|
2598
|
+
if (!git(repo.path, "rev-parse", "--is-inside-work-tree").ok) {
|
|
2599
|
+
push(label, "fail", `${repo.path} is not a git repository`);
|
|
2600
|
+
continue;
|
|
2601
|
+
}
|
|
2602
|
+
const trunk = git(repo.path, "rev-parse", "--verify", "--quiet", `origin/${repo.trunk}`);
|
|
2603
|
+
push(
|
|
2604
|
+
label,
|
|
2605
|
+
trunk.ok ? "ok" : "warn",
|
|
2606
|
+
trunk.ok ? `${repo.path} (trunk origin/${repo.trunk})` : `origin/${repo.trunk} not found \u2014 check trunk or \`git fetch\``
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
if (parsed.pickup) {
|
|
2610
|
+
try {
|
|
2611
|
+
const pk = loadPickupConfig();
|
|
2612
|
+
const sources = [
|
|
2613
|
+
...pk.github.map((g) => `gh:${g.repo}\u2192${g.key}`),
|
|
2614
|
+
...pk.linear ? ["linear"] : []
|
|
2615
|
+
];
|
|
2616
|
+
push("pickup", sources.length > 0 ? "ok" : "warn", sources.join(", ") || "section present but no sources");
|
|
2617
|
+
} catch (err) {
|
|
2618
|
+
push("pickup", "fail", err instanceof Error ? err.message : String(err));
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
const hb = executorPath();
|
|
2622
|
+
if (!fs20.existsSync(hb)) {
|
|
2623
|
+
push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
|
|
2624
|
+
} else {
|
|
2625
|
+
try {
|
|
2626
|
+
const payload = JSON.parse(fs20.readFileSync(hb, "utf8"));
|
|
2627
|
+
const age = now - new Date(payload.heartbeat ?? 0).getTime();
|
|
2628
|
+
push(
|
|
2629
|
+
"daemon",
|
|
2630
|
+
age < HEARTBEAT_STALE_MS2 ? "ok" : "warn",
|
|
2631
|
+
age < HEARTBEAT_STALE_MS2 ? `heartbeat ${Math.round(age / 1e3)}s ago (v${payload.version ?? "?"})` : `heartbeat stale (${Math.round(age / 1e3)}s) \u2014 daemon down or wedged`
|
|
2632
|
+
);
|
|
2633
|
+
} catch {
|
|
2634
|
+
push("daemon", "warn", "heartbeat unreadable");
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
push("lobstah", "ok", `v${lobstahVersion()} at ${process.argv[1] ?? "?"}`);
|
|
2638
|
+
return rows;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
// apps/cli/src/service.ts
|
|
2642
|
+
import * as fs21 from "node:fs";
|
|
2643
|
+
import * as os5 from "node:os";
|
|
2644
|
+
import * as path15 from "node:path";
|
|
2645
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
2646
|
+
function xml(s) {
|
|
2647
|
+
return s.replace(/&/g, "&").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
|
+
|
|
2181
2809
|
// apps/cli/src/main.ts
|
|
2182
|
-
var HELP = `lobstah \u2014
|
|
2810
|
+
var HELP = `lobstah \u2014 supervision framework for coding agents
|
|
2183
2811
|
|
|
2184
2812
|
work (humans and agents):
|
|
2185
2813
|
dispatch --repo <key> (--brief <file> | --brief-text <text>) (alias: set --bait)
|
|
@@ -2206,22 +2834,33 @@ work (humans and agents):
|
|
|
2206
2834
|
Dry run by default (14 days).
|
|
2207
2835
|
cancel <uuid> request cancellation
|
|
2208
2836
|
|
|
2209
|
-
host processes
|
|
2837
|
+
host processes:
|
|
2210
2838
|
daemon [--interval <ms>] the supervisor: claims, worktrees, liveness,
|
|
2211
2839
|
restart ladder, notifyCommand. One per home.
|
|
2212
2840
|
pick [once] tracker loops: poll Linear/GitHub, dispatch
|
|
2213
2841
|
assigned work, report back, reconcile, merge
|
|
2842
|
+
daemon install|uninstall write + load the launchd agent / systemd user
|
|
2843
|
+
pick install|uninstall unit for this host, with resolved node and
|
|
2844
|
+
lobstah paths (launchd gets no shell env)
|
|
2214
2845
|
|
|
2215
2846
|
lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual):
|
|
2847
|
+
man tend [--json] tend the whole string: fleet verdict (daemon
|
|
2848
|
+
up, stalled vs idle), unanswered questions
|
|
2849
|
+
with ages, each work item's dispatch chain,
|
|
2850
|
+
PR, and merge-gate status from pick's last
|
|
2851
|
+
observation. Pure disk read \u2014 no forge calls.
|
|
2216
2852
|
man wait [--timeout <secs>] [--peek]
|
|
2217
2853
|
block until a dispatch needs attention, then
|
|
2218
2854
|
print the event and what to do next; exit 2
|
|
2219
2855
|
on timeout. Unanswered questions re-fire
|
|
2220
2856
|
every remindSecs until answered.
|
|
2221
|
-
man init [--shared] [--marker]
|
|
2222
|
-
project's
|
|
2223
|
-
|
|
2224
|
-
.
|
|
2857
|
+
man init [--shared|--global] [--marker]
|
|
2858
|
+
install the haul Stop hook: this project's
|
|
2859
|
+
.claude/settings.local.json by default,
|
|
2860
|
+
settings.json with --shared, or once into
|
|
2861
|
+
~/.claude/settings.json with --global (any
|
|
2862
|
+
directory with a .lobstah-man file then
|
|
2863
|
+
parks); --marker touches .lobstah-man.
|
|
2225
2864
|
man haul [--timeout <secs>] Stop-hook entry point: park the session while
|
|
2226
2865
|
work is in flight; prints hook-decision JSON
|
|
2227
2866
|
on an event, silent exit 0 otherwise. Gate:
|
|
@@ -2233,7 +2872,16 @@ workers (dispatched agents; injected into every brief):
|
|
|
2233
2872
|
(${VERBS.join(" | ")})
|
|
2234
2873
|
|
|
2235
2874
|
setup:
|
|
2236
|
-
init
|
|
2875
|
+
init [--scan <dir>... [--pickup]]
|
|
2876
|
+
create ~/.lobstah + example config; --scan
|
|
2877
|
+
detects git repos under the given roots and
|
|
2878
|
+
appends a [repos.*] block per repo (--pickup
|
|
2879
|
+
marks them pickable by [pickup.github])
|
|
2880
|
+
repos [add <path> [--pickup] [--key <k>]]
|
|
2881
|
+
list configured repos, or detect + append one
|
|
2882
|
+
doctor check binaries, config, repos, harnesses, and
|
|
2883
|
+
the daemon heartbeat; exit 1 on failures
|
|
2884
|
+
version | --version the installed lobstah version
|
|
2237
2885
|
|
|
2238
2886
|
Everything except daemon and pick works with both stopped: writes are files,
|
|
2239
2887
|
reads are files. Output is TOON; agents can drive this CLI directly.
|
|
@@ -2245,7 +2893,7 @@ function arg(args, flag) {
|
|
|
2245
2893
|
function findLane(id) {
|
|
2246
2894
|
for (const lane of ["work", "chore"]) {
|
|
2247
2895
|
const d = laneDirs(lane);
|
|
2248
|
-
if (
|
|
2896
|
+
if (fs23.existsSync(path17.join(d.active, id)) || fs23.existsSync(path17.join(d.queue, `${id}.json`)) || fs23.existsSync(path17.join(d.done, id)) || fs23.existsSync(path17.join(d.state, `${id}.status`))) {
|
|
2249
2897
|
return lane;
|
|
2250
2898
|
}
|
|
2251
2899
|
}
|
|
@@ -2253,7 +2901,7 @@ function findLane(id) {
|
|
|
2253
2901
|
}
|
|
2254
2902
|
function rowsFor(lane, bucket) {
|
|
2255
2903
|
const dir = laneDirs(lane)[bucket];
|
|
2256
|
-
const entries =
|
|
2904
|
+
const entries = fs23.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs23.statSync(path17.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
|
|
2257
2905
|
const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
|
|
2258
2906
|
return sliced.map(({ f, m }) => {
|
|
2259
2907
|
const id = f.replace(/\.json$/, "");
|
|
@@ -2281,7 +2929,7 @@ async function mainCli() {
|
|
|
2281
2929
|
const d = {
|
|
2282
2930
|
id: arg(args, "--id") ?? randomUUID3(),
|
|
2283
2931
|
repo,
|
|
2284
|
-
brief: briefText ??
|
|
2932
|
+
brief: briefText ?? fs23.readFileSync(briefFile, "utf8"),
|
|
2285
2933
|
harness: arg(args, "--harness"),
|
|
2286
2934
|
model: arg(args, "--model"),
|
|
2287
2935
|
effort: arg(args, "--effort"),
|
|
@@ -2318,17 +2966,17 @@ async function mainCli() {
|
|
|
2318
2966
|
if (!id) throw new Error("logs requires a dispatch id");
|
|
2319
2967
|
const lane = findLane(id);
|
|
2320
2968
|
const file = eventsPath(id, lane);
|
|
2321
|
-
if (
|
|
2969
|
+
if (fs23.existsSync(file)) process.stdout.write(fs23.readFileSync(file, "utf8"));
|
|
2322
2970
|
if (args.includes("--follow")) {
|
|
2323
|
-
let size =
|
|
2971
|
+
let size = fs23.existsSync(file) ? fs23.statSync(file).size : 0;
|
|
2324
2972
|
setInterval(() => {
|
|
2325
|
-
if (!
|
|
2326
|
-
const now =
|
|
2973
|
+
if (!fs23.existsSync(file)) return;
|
|
2974
|
+
const now = fs23.statSync(file).size;
|
|
2327
2975
|
if (now > size) {
|
|
2328
|
-
const fd =
|
|
2976
|
+
const fd = fs23.openSync(file, "r");
|
|
2329
2977
|
const buf = Buffer.alloc(now - size);
|
|
2330
|
-
|
|
2331
|
-
|
|
2978
|
+
fs23.readSync(fd, buf, 0, buf.length, size);
|
|
2979
|
+
fs23.closeSync(fd);
|
|
2332
2980
|
process.stdout.write(buf.toString("utf8"));
|
|
2333
2981
|
size = now;
|
|
2334
2982
|
}
|
|
@@ -2384,22 +3032,23 @@ async function mainCli() {
|
|
|
2384
3032
|
);
|
|
2385
3033
|
}
|
|
2386
3034
|
const dirs = laneDirs(lane);
|
|
2387
|
-
const descriptorFile = ["active", "done"].map((b) =>
|
|
2388
|
-
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";
|
|
2389
3037
|
let sessionId;
|
|
2390
3038
|
try {
|
|
2391
|
-
sessionId = JSON.parse(
|
|
3039
|
+
sessionId = JSON.parse(fs23.readFileSync(path17.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
|
|
2392
3040
|
} catch {
|
|
2393
3041
|
}
|
|
2394
3042
|
if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
|
|
2395
|
-
const worktree =
|
|
2396
|
-
const cwd =
|
|
2397
|
-
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");
|
|
2398
3047
|
if (args.includes("--print")) {
|
|
2399
3048
|
console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
|
|
2400
3049
|
break;
|
|
2401
3050
|
}
|
|
2402
|
-
const res =
|
|
3051
|
+
const res = spawnSync4(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
|
|
2403
3052
|
if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
|
|
2404
3053
|
break;
|
|
2405
3054
|
}
|
|
@@ -2407,39 +3056,39 @@ async function mainCli() {
|
|
|
2407
3056
|
const id = args[0];
|
|
2408
3057
|
if (!id) throw new Error("swap requires a dispatch id");
|
|
2409
3058
|
const lane = findLane(id);
|
|
2410
|
-
const activeDir =
|
|
2411
|
-
if (!
|
|
2412
|
-
const descFile =
|
|
2413
|
-
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"));
|
|
2414
3063
|
const fromHarness = descriptor.harness ?? "default";
|
|
2415
3064
|
for (const key of ["harness", "model", "effort"]) {
|
|
2416
3065
|
const v = arg(args, `--${key}`);
|
|
2417
3066
|
if (v) descriptor[key] = v;
|
|
2418
3067
|
}
|
|
2419
|
-
|
|
2420
|
-
const worktree =
|
|
3068
|
+
fs23.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
|
|
3069
|
+
const worktree = path17.join(lobstahHome(), "worktrees", id);
|
|
2421
3070
|
let progress = "No worktree progress recorded.";
|
|
2422
|
-
if (
|
|
2423
|
-
const
|
|
2424
|
-
const commits =
|
|
2425
|
-
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");
|
|
2426
3075
|
progress = `Commits so far:
|
|
2427
3076
|
${commits || "(none)"}
|
|
2428
3077
|
|
|
2429
3078
|
Uncommitted changes:
|
|
2430
3079
|
${status || "(clean)"}`;
|
|
2431
3080
|
}
|
|
2432
|
-
|
|
2433
|
-
|
|
3081
|
+
fs23.writeFileSync(
|
|
3082
|
+
path17.join(activeDir, "handoff"),
|
|
2434
3083
|
`You are taking over this dispatch from a previous agent session (harness: ${fromHarness}). Its conversation is not available \u2014 the worktree state below is the ground truth. Review it, then continue the brief from where it stops.
|
|
2435
3084
|
|
|
2436
3085
|
${progress}`
|
|
2437
3086
|
);
|
|
2438
|
-
const runnerFile =
|
|
2439
|
-
if (
|
|
2440
|
-
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"));
|
|
2441
3090
|
if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
|
|
2442
|
-
|
|
3091
|
+
fs23.unlinkSync(runnerFile);
|
|
2443
3092
|
}
|
|
2444
3093
|
appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
|
|
2445
3094
|
console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
|
|
@@ -2490,6 +3139,11 @@ ${progress}`
|
|
|
2490
3139
|
console.log(MANUAL);
|
|
2491
3140
|
break;
|
|
2492
3141
|
}
|
|
3142
|
+
case "man:tend": {
|
|
3143
|
+
const report = buildTendReport();
|
|
3144
|
+
console.log(args.includes("--json") ? JSON.stringify(report, null, 2) : renderTend(report));
|
|
3145
|
+
break;
|
|
3146
|
+
}
|
|
2493
3147
|
case "cancel": {
|
|
2494
3148
|
const id = args[0];
|
|
2495
3149
|
if (!id) throw new Error("cancel requires a dispatch id");
|
|
@@ -2529,35 +3183,35 @@ ${progress}`
|
|
|
2529
3183
|
break;
|
|
2530
3184
|
}
|
|
2531
3185
|
case "man:init": {
|
|
2532
|
-
const file =
|
|
3186
|
+
const file = args.includes("--global") ? path17.join(os6.homedir(), ".claude", "settings.json") : path17.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
|
|
2533
3187
|
let existing;
|
|
2534
3188
|
try {
|
|
2535
|
-
existing = JSON.parse(
|
|
3189
|
+
existing = JSON.parse(fs23.readFileSync(file, "utf8"));
|
|
2536
3190
|
} catch {
|
|
2537
3191
|
existing = void 0;
|
|
2538
3192
|
}
|
|
2539
3193
|
const { settings, changed } = mergeHaulHook(existing);
|
|
2540
3194
|
if (changed) {
|
|
2541
|
-
|
|
2542
|
-
|
|
3195
|
+
fs23.mkdirSync(path17.dirname(file), { recursive: true });
|
|
3196
|
+
fs23.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
2543
3197
|
`);
|
|
2544
3198
|
}
|
|
2545
|
-
if (args.includes("--marker"))
|
|
3199
|
+
if (args.includes("--marker")) fs23.writeFileSync(".lobstah-man", "");
|
|
2546
3200
|
console.log(
|
|
2547
3201
|
toonKV({
|
|
2548
3202
|
hook: "lobstah man haul",
|
|
2549
3203
|
file,
|
|
2550
3204
|
installed: changed || "already present",
|
|
2551
|
-
gate: args.includes("--marker") ? ".lobstah-man (this directory)" : "launch with LOBSTAH_MAN=1 claude"
|
|
3205
|
+
gate: args.includes("--marker") ? ".lobstah-man (this directory)" : args.includes("--global") ? "touch .lobstah-man in a project (or LOBSTAH_MAN=1) to arm it there" : "launch with LOBSTAH_MAN=1 claude"
|
|
2552
3206
|
})
|
|
2553
3207
|
);
|
|
2554
3208
|
break;
|
|
2555
3209
|
}
|
|
2556
3210
|
case "man:haul": {
|
|
2557
3211
|
try {
|
|
2558
|
-
if (process.env.LOBSTAH_MAN !== "1" && !
|
|
3212
|
+
if (process.env.LOBSTAH_MAN !== "1" && !fs23.existsSync(".lobstah-man")) break;
|
|
2559
3213
|
const anyActive = ["work", "chore"].some(
|
|
2560
|
-
(l) =>
|
|
3214
|
+
(l) => fs23.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
|
|
2561
3215
|
);
|
|
2562
3216
|
if (!anyActive) break;
|
|
2563
3217
|
const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
|
|
@@ -2588,31 +3242,74 @@ ${progress}`
|
|
|
2588
3242
|
}
|
|
2589
3243
|
break;
|
|
2590
3244
|
}
|
|
2591
|
-
case "daemon":
|
|
2592
|
-
|
|
2593
|
-
|
|
3245
|
+
case "daemon":
|
|
3246
|
+
case "pick": {
|
|
3247
|
+
const kind = cmd;
|
|
3248
|
+
if (args[0] === "install") {
|
|
3249
|
+
const res = installService(kind);
|
|
3250
|
+
console.log(toonKV({ service: kind, file: res.file, loaded: res.loaded, detail: res.detail }));
|
|
3251
|
+
break;
|
|
3252
|
+
}
|
|
3253
|
+
if (args[0] === "uninstall") {
|
|
3254
|
+
const res = uninstallService(kind);
|
|
3255
|
+
console.log(toonKV({ service: kind, file: res.file, removed: res.removed }));
|
|
3256
|
+
break;
|
|
3257
|
+
}
|
|
3258
|
+
if (kind === "daemon") await daemon(Number(arg(args, "--interval") ?? "5000"));
|
|
3259
|
+
else await runPickup(args[0] === "once" ? "once" : "daemon");
|
|
3260
|
+
break;
|
|
3261
|
+
}
|
|
3262
|
+
case "doctor": {
|
|
3263
|
+
const rows = runDoctor();
|
|
3264
|
+
console.log(toonTable("doctor", rows, ["check", "status", "detail"]));
|
|
3265
|
+
if (rows.some((r) => r.status === "fail")) process.exitCode = 1;
|
|
2594
3266
|
break;
|
|
2595
3267
|
}
|
|
2596
|
-
case "
|
|
2597
|
-
|
|
3268
|
+
case "repos": {
|
|
3269
|
+
if (args[0] === "add") {
|
|
3270
|
+
const target = args[1];
|
|
3271
|
+
if (!target) throw new Error("repos add requires a path");
|
|
3272
|
+
const detected = detectRepo(target);
|
|
3273
|
+
if (!detected) throw new Error(`${target} is not the root of a git repository`);
|
|
3274
|
+
const key = arg(args, "--key") ?? detected.key;
|
|
3275
|
+
if (configuredRepoKeys().has(key)) throw new Error(`repos.${key} already configured \u2014 edit ${configPath()} directly`);
|
|
3276
|
+
appendRepoBlock({ ...detected, key }, { pickup: args.includes("--pickup") });
|
|
3277
|
+
console.log(toonKV({ key, path: detected.path, trunk: detected.trunk, origin: detected.origin, pickup: args.includes("--pickup") }));
|
|
3278
|
+
break;
|
|
3279
|
+
}
|
|
3280
|
+
const repos = loadConfig().repos;
|
|
3281
|
+
const rows = Object.entries(repos).map(([key, r]) => ({
|
|
3282
|
+
key,
|
|
3283
|
+
path: r.path,
|
|
3284
|
+
trunk: r.trunk,
|
|
3285
|
+
pickup: r.pickup ?? false,
|
|
3286
|
+
exists: fs23.existsSync(r.path)
|
|
3287
|
+
}));
|
|
3288
|
+
console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
|
|
3289
|
+
break;
|
|
3290
|
+
}
|
|
3291
|
+
case "version": {
|
|
3292
|
+
console.log(lobstahVersion());
|
|
2598
3293
|
break;
|
|
2599
3294
|
}
|
|
2600
3295
|
case "init": {
|
|
2601
|
-
if (!
|
|
2602
|
-
|
|
3296
|
+
if (!fs23.existsSync(configPath())) {
|
|
3297
|
+
const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
|
|
3298
|
+
path = "~/src/example"
|
|
3299
|
+
trunk = "main"
|
|
3300
|
+
# origin = "git@github.com:you/example.git"
|
|
3301
|
+
# setup = ["pnpm install"]
|
|
3302
|
+
# pickup = true # opt into [pickup.github] multi-repo tracker pickup
|
|
3303
|
+
|
|
3304
|
+
`;
|
|
3305
|
+
fs23.writeFileSync(
|
|
2603
3306
|
configPath(),
|
|
2604
3307
|
`# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
|
|
2605
3308
|
# Top-level keys must come before any [section].
|
|
2606
3309
|
# notifyCommand = "ntfy pub my-topic "$LOBSTAH_VERB $LOBSTAH_ID: $LOBSTAH_NOTE""
|
|
2607
3310
|
# notifyVerbs = ["needs-decision", "blocked", "done", "failed"] # the default
|
|
2608
3311
|
|
|
2609
|
-
[
|
|
2610
|
-
path = "~/src/example"
|
|
2611
|
-
trunk = "main"
|
|
2612
|
-
# origin = "git@github.com:you/example.git"
|
|
2613
|
-
# setup = ["pnpm install"]
|
|
2614
|
-
|
|
2615
|
-
[harness]
|
|
3312
|
+
${exampleRepo}[harness]
|
|
2616
3313
|
default = "claude"
|
|
2617
3314
|
|
|
2618
3315
|
[limits]
|
|
@@ -2624,16 +3321,44 @@ wallClockSecs = 3600
|
|
|
2624
3321
|
`
|
|
2625
3322
|
);
|
|
2626
3323
|
}
|
|
3324
|
+
const scanIdx = args.indexOf("--scan");
|
|
3325
|
+
const added = [];
|
|
3326
|
+
const unmarked = [];
|
|
3327
|
+
if (scanIdx >= 0) {
|
|
3328
|
+
const roots = args.slice(scanIdx + 1).filter((a) => !a.startsWith("--"));
|
|
3329
|
+
if (roots.length === 0) throw new Error("--scan requires at least one directory");
|
|
3330
|
+
const known = configuredRepoKeys();
|
|
3331
|
+
const pickup = args.includes("--pickup");
|
|
3332
|
+
for (const repo of scanForRepos(roots)) {
|
|
3333
|
+
if (known.has(repo.key)) continue;
|
|
3334
|
+
known.add(repo.key);
|
|
3335
|
+
appendRepoBlock(repo, { pickup });
|
|
3336
|
+
added.push(repo.key);
|
|
3337
|
+
if (!pickup) unmarked.push(repo.key);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
|
|
3341
|
+
try {
|
|
3342
|
+
const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
|
|
3343
|
+
if (fs23.existsSync(local)) reference = local;
|
|
3344
|
+
} catch {
|
|
3345
|
+
}
|
|
2627
3346
|
console.log(
|
|
2628
3347
|
toonKV({
|
|
2629
|
-
home:
|
|
3348
|
+
home: path17.dirname(configPath()),
|
|
2630
3349
|
config: configPath(),
|
|
2631
|
-
|
|
3350
|
+
...added.length > 0 ? { added: added.join(", ") } : {},
|
|
3351
|
+
...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
|
|
3352
|
+
reference,
|
|
2632
3353
|
initialized: true
|
|
2633
3354
|
})
|
|
2634
3355
|
);
|
|
2635
3356
|
break;
|
|
2636
3357
|
}
|
|
3358
|
+
case "--version":
|
|
3359
|
+
case "-v":
|
|
3360
|
+
console.log(lobstahVersion());
|
|
3361
|
+
break;
|
|
2637
3362
|
case void 0:
|
|
2638
3363
|
case "--help":
|
|
2639
3364
|
case "help":
|