lobstah 0.1.1 → 0.2.0
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 +17 -3
- package/dist/main.js +1160 -204
- package/dist/runner.js +2 -1
- package/docs/configuration.md +122 -0
- package/docs/design.md +669 -0
- package/docs/lobsterman.md +211 -0
- package/docs/openclaw.md +76 -0
- package/docs/pickup.md +358 -0
- package/docs/vocabulary.md +139 -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
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
4
|
+
import * as fs25 from "node:fs";
|
|
5
|
+
import * as os6 from "node:os";
|
|
6
|
+
import * as path19 from "node:path";
|
|
7
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
8
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
9
|
+
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"];
|
|
@@ -36,6 +38,7 @@ function ensureLayout() {
|
|
|
36
38
|
fs.mkdirSync(dir, { recursive: true });
|
|
37
39
|
}
|
|
38
40
|
fs.mkdirSync(path.join(lobstahHome(), "worktrees"), { recursive: true });
|
|
41
|
+
fs.mkdirSync(path.join(lobstahHome(), "watches"), { recursive: true });
|
|
39
42
|
}
|
|
40
43
|
function statusPath(id, lane) {
|
|
41
44
|
return path.join(laneDirs(lane).state, `${id}.status`);
|
|
@@ -868,7 +871,8 @@ function loadConfig() {
|
|
|
868
871
|
trunk: String(r.trunk ?? "main"),
|
|
869
872
|
setup: Array.isArray(r.setup) ? r.setup.map(String) : void 0,
|
|
870
873
|
env: r.env ?? void 0,
|
|
871
|
-
harness: r.harness ?? void 0
|
|
874
|
+
harness: r.harness ?? void 0,
|
|
875
|
+
pickup: r.pickup === void 0 ? void 0 : Boolean(r.pickup)
|
|
872
876
|
};
|
|
873
877
|
}
|
|
874
878
|
return {
|
|
@@ -898,6 +902,235 @@ function toonTable(name, rows, cols) {
|
|
|
898
902
|
return [header, ...rows.map((r) => ` ${cols.map((c) => cell(r[c])).join(",")}`)].join("\n");
|
|
899
903
|
}
|
|
900
904
|
|
|
905
|
+
// packages/core/dist/version.js
|
|
906
|
+
import * as fs8 from "node:fs";
|
|
907
|
+
function lobstahVersion() {
|
|
908
|
+
try {
|
|
909
|
+
const pkg = JSON.parse(fs8.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
910
|
+
if (pkg.name === "lobstah" && typeof pkg.version === "string")
|
|
911
|
+
return pkg.version;
|
|
912
|
+
} catch {
|
|
913
|
+
}
|
|
914
|
+
return "0.0.0-dev";
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// packages/core/dist/harness.js
|
|
918
|
+
import * as fs9 from "node:fs";
|
|
919
|
+
import * as path5 from "node:path";
|
|
920
|
+
import { createRequire } from "node:module";
|
|
921
|
+
function onPath(bin, pathVar = process.env.PATH) {
|
|
922
|
+
if (!pathVar)
|
|
923
|
+
return false;
|
|
924
|
+
const names = process.platform === "win32" ? [bin, `${bin}.exe`, `${bin}.cmd`, `${bin}.bat`] : [bin];
|
|
925
|
+
for (const dir of pathVar.split(path5.delimiter)) {
|
|
926
|
+
if (!dir)
|
|
927
|
+
continue;
|
|
928
|
+
for (const name of names) {
|
|
929
|
+
const candidate = path5.join(dir, name);
|
|
930
|
+
try {
|
|
931
|
+
fs9.accessSync(candidate, fs9.constants.X_OK);
|
|
932
|
+
if (fs9.statSync(candidate).isFile())
|
|
933
|
+
return true;
|
|
934
|
+
} catch {
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
function resolvable(specifier) {
|
|
941
|
+
try {
|
|
942
|
+
createRequire(import.meta.url).resolve(specifier);
|
|
943
|
+
return true;
|
|
944
|
+
} catch {
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
function detectHarnesses() {
|
|
949
|
+
const out = [];
|
|
950
|
+
if (onPath("claude") || resolvable("@anthropic-ai/claude-agent-sdk"))
|
|
951
|
+
out.push("claude");
|
|
952
|
+
if (onPath("codex") || resolvable("@openai/codex-sdk"))
|
|
953
|
+
out.push("codex");
|
|
954
|
+
return out;
|
|
955
|
+
}
|
|
956
|
+
function codexInvocation(argv) {
|
|
957
|
+
if (onPath("codex"))
|
|
958
|
+
return { file: "codex", argv };
|
|
959
|
+
try {
|
|
960
|
+
const cli = createRequire(import.meta.url).resolve("@openai/codex/bin/codex.js");
|
|
961
|
+
return { file: process.execPath, argv: [cli, ...argv] };
|
|
962
|
+
} catch {
|
|
963
|
+
return void 0;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// packages/core/dist/watch.js
|
|
968
|
+
import * as fs10 from "node:fs";
|
|
969
|
+
import * as path6 from "node:path";
|
|
970
|
+
import { spawnSync } from "node:child_process";
|
|
971
|
+
function watchesDir() {
|
|
972
|
+
return path6.join(lobstahHome(), "watches");
|
|
973
|
+
}
|
|
974
|
+
function slug(key) {
|
|
975
|
+
return key.replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
976
|
+
}
|
|
977
|
+
function watchPath(key) {
|
|
978
|
+
return path6.join(watchesDir(), `${slug(key)}.json`);
|
|
979
|
+
}
|
|
980
|
+
function eventsPath2(key) {
|
|
981
|
+
return path6.join(watchesDir(), `${slug(key)}.events`);
|
|
982
|
+
}
|
|
983
|
+
function writeWatch(w) {
|
|
984
|
+
fs10.mkdirSync(watchesDir(), { recursive: true });
|
|
985
|
+
const file = watchPath(w.key);
|
|
986
|
+
const tmp = `${file}.tmp`;
|
|
987
|
+
fs10.writeFileSync(tmp, JSON.stringify(w, null, 2));
|
|
988
|
+
fs10.renameSync(tmp, file);
|
|
989
|
+
}
|
|
990
|
+
function readWatch(key) {
|
|
991
|
+
try {
|
|
992
|
+
return JSON.parse(fs10.readFileSync(watchPath(key), "utf8"));
|
|
993
|
+
} catch {
|
|
994
|
+
return void 0;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
function listWatches() {
|
|
998
|
+
if (!fs10.existsSync(watchesDir()))
|
|
999
|
+
return [];
|
|
1000
|
+
return fs10.readdirSync(watchesDir()).filter((f) => f.endsWith(".json")).map((f) => {
|
|
1001
|
+
try {
|
|
1002
|
+
return JSON.parse(fs10.readFileSync(path6.join(watchesDir(), f), "utf8"));
|
|
1003
|
+
} catch {
|
|
1004
|
+
return void 0;
|
|
1005
|
+
}
|
|
1006
|
+
}).filter((w) => w !== void 0);
|
|
1007
|
+
}
|
|
1008
|
+
function addWatch(key, check, opts = {}) {
|
|
1009
|
+
if (!key || !check)
|
|
1010
|
+
throw new Error("watch add requires a key and a --check command");
|
|
1011
|
+
const existing = readWatch(key);
|
|
1012
|
+
const w = {
|
|
1013
|
+
key,
|
|
1014
|
+
owner: opts.owner ?? existing?.owner ?? "man",
|
|
1015
|
+
check,
|
|
1016
|
+
stream: opts.stream ?? existing?.stream,
|
|
1017
|
+
cursor: existing?.cursor ?? opts.cursor ?? "0",
|
|
1018
|
+
everySecs: opts.everySecs ?? existing?.everySecs,
|
|
1019
|
+
brief: opts.brief ?? existing?.brief,
|
|
1020
|
+
createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1021
|
+
lastCheckedAt: existing?.lastCheckedAt,
|
|
1022
|
+
seen: existing?.seen ?? 0,
|
|
1023
|
+
seenAt: existing?.seenAt ?? 0,
|
|
1024
|
+
lastFollowUpId: existing?.lastFollowUpId
|
|
1025
|
+
};
|
|
1026
|
+
writeWatch(w);
|
|
1027
|
+
return w;
|
|
1028
|
+
}
|
|
1029
|
+
function removeWatch(key) {
|
|
1030
|
+
const existed = fs10.existsSync(watchPath(key));
|
|
1031
|
+
fs10.rmSync(watchPath(key), { force: true });
|
|
1032
|
+
fs10.rmSync(eventsPath2(key), { force: true });
|
|
1033
|
+
return existed;
|
|
1034
|
+
}
|
|
1035
|
+
function readWatchEvents(key) {
|
|
1036
|
+
try {
|
|
1037
|
+
return fs10.readFileSync(eventsPath2(key), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
1038
|
+
} catch {
|
|
1039
|
+
return [];
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
function appendWatchEvents(key, events) {
|
|
1043
|
+
const seen = new Set(readWatchEvents(key).map((e) => String(e.seq)));
|
|
1044
|
+
const fresh = events.filter((e) => !seen.has(String(e.seq)));
|
|
1045
|
+
if (fresh.length > 0) {
|
|
1046
|
+
fs10.appendFileSync(eventsPath2(key), fresh.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
1047
|
+
}
|
|
1048
|
+
return fresh;
|
|
1049
|
+
}
|
|
1050
|
+
function setWatchCursor(key, cursor) {
|
|
1051
|
+
const w = readWatch(key);
|
|
1052
|
+
if (!w || w.cursor === cursor)
|
|
1053
|
+
return;
|
|
1054
|
+
w.cursor = cursor;
|
|
1055
|
+
writeWatch(w);
|
|
1056
|
+
}
|
|
1057
|
+
function watchDue(w, defaultEverySecs, now = Date.now()) {
|
|
1058
|
+
const every = (w.everySecs ?? defaultEverySecs) * 1e3;
|
|
1059
|
+
const last = w.lastCheckedAt ? Date.parse(w.lastCheckedAt) : 0;
|
|
1060
|
+
return now - last >= every;
|
|
1061
|
+
}
|
|
1062
|
+
var CHECK_TIMEOUT_MS = 9e4;
|
|
1063
|
+
function runWatchCheck(w, now = /* @__PURE__ */ new Date()) {
|
|
1064
|
+
w.lastCheckedAt = now.toISOString();
|
|
1065
|
+
writeWatch(w);
|
|
1066
|
+
const cmd = w.check.replaceAll("{cursor}", w.cursor);
|
|
1067
|
+
const res = spawnSync(cmd, { shell: true, encoding: "utf8", timeout: CHECK_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 });
|
|
1068
|
+
if (res.status !== 0 || res.error) {
|
|
1069
|
+
w.lastError = (res.error?.message || res.stderr?.trim() || `exit ${res.status}`).slice(0, 500);
|
|
1070
|
+
writeWatch(w);
|
|
1071
|
+
return { watch: w, fresh: [] };
|
|
1072
|
+
}
|
|
1073
|
+
let parsed;
|
|
1074
|
+
try {
|
|
1075
|
+
parsed = JSON.parse(res.stdout);
|
|
1076
|
+
} catch {
|
|
1077
|
+
w.lastError = `unparseable check output: ${res.stdout.slice(0, 200).trim()}`;
|
|
1078
|
+
writeWatch(w);
|
|
1079
|
+
return { watch: w, fresh: [] };
|
|
1080
|
+
}
|
|
1081
|
+
w.lastError = void 0;
|
|
1082
|
+
const events = Array.isArray(parsed.events) ? parsed.events.map((e) => ({
|
|
1083
|
+
seq: e.seq ?? w.cursor,
|
|
1084
|
+
summary: e.summary !== void 0 ? String(e.summary) : void 0,
|
|
1085
|
+
...e,
|
|
1086
|
+
at: now.toISOString()
|
|
1087
|
+
})) : [];
|
|
1088
|
+
const fresh = appendWatchEvents(w.key, events);
|
|
1089
|
+
if (parsed.cursor !== void 0)
|
|
1090
|
+
w.cursor = String(parsed.cursor);
|
|
1091
|
+
if (parsed.done === true)
|
|
1092
|
+
w.done = true;
|
|
1093
|
+
writeWatch(w);
|
|
1094
|
+
return { watch: w, fresh };
|
|
1095
|
+
}
|
|
1096
|
+
function pendingWatchEvents(consume, owner = "man", now = Date.now()) {
|
|
1097
|
+
const out = [];
|
|
1098
|
+
for (const w of listWatches()) {
|
|
1099
|
+
const isMan = w.owner === "man";
|
|
1100
|
+
if (owner === "man" !== isMan)
|
|
1101
|
+
continue;
|
|
1102
|
+
const events = readWatchEvents(w.key);
|
|
1103
|
+
const fresh = events.slice(w.seen);
|
|
1104
|
+
if (fresh.length === 0) {
|
|
1105
|
+
if (w.done && consume)
|
|
1106
|
+
removeWatch(w.key);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
out.push({ watch: w, events: fresh });
|
|
1110
|
+
if (consume) {
|
|
1111
|
+
w.seen = events.length;
|
|
1112
|
+
w.seenAt = now;
|
|
1113
|
+
if (w.done)
|
|
1114
|
+
removeWatch(w.key);
|
|
1115
|
+
else
|
|
1116
|
+
writeWatch(w);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return out;
|
|
1120
|
+
}
|
|
1121
|
+
function markFollowUp(key, followUpId, deliveredThrough) {
|
|
1122
|
+
const w = readWatch(key);
|
|
1123
|
+
if (!w)
|
|
1124
|
+
return;
|
|
1125
|
+
w.lastFollowUpId = followUpId;
|
|
1126
|
+
w.seen = deliveredThrough;
|
|
1127
|
+
w.seenAt = Date.now();
|
|
1128
|
+
if (w.done)
|
|
1129
|
+
removeWatch(w.key);
|
|
1130
|
+
else
|
|
1131
|
+
writeWatch(w);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
901
1134
|
// packages/supervisor/dist/liveness.js
|
|
902
1135
|
import { execFileSync } from "node:child_process";
|
|
903
1136
|
function classify(input) {
|
|
@@ -956,26 +1189,26 @@ function killGroup(pid, signal = "SIGTERM") {
|
|
|
956
1189
|
}
|
|
957
1190
|
|
|
958
1191
|
// packages/supervisor/dist/daemon.js
|
|
959
|
-
import * as
|
|
1192
|
+
import * as fs12 from "node:fs";
|
|
960
1193
|
import * as os3 from "node:os";
|
|
961
|
-
import * as
|
|
1194
|
+
import * as path8 from "node:path";
|
|
962
1195
|
import { spawn as spawn2 } from "node:child_process";
|
|
963
|
-
import { createRequire } from "node:module";
|
|
1196
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
964
1197
|
import { fileURLToPath } from "node:url";
|
|
965
1198
|
|
|
966
1199
|
// packages/supervisor/dist/notify.js
|
|
967
|
-
import * as
|
|
968
|
-
import * as
|
|
1200
|
+
import * as fs11 from "node:fs";
|
|
1201
|
+
import * as path7 from "node:path";
|
|
969
1202
|
import { spawn } from "node:child_process";
|
|
970
1203
|
var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
|
|
971
1204
|
function cursorPath(id, lane) {
|
|
972
|
-
return
|
|
1205
|
+
return path7.join(laneDirs(lane).state, `${id}.notified`);
|
|
973
1206
|
}
|
|
974
1207
|
function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
|
|
975
1208
|
const log = readStatusLog(id, lane);
|
|
976
1209
|
let cursor;
|
|
977
1210
|
try {
|
|
978
|
-
cursor = Number(
|
|
1211
|
+
cursor = Number(fs11.readFileSync(cursorPath(id, lane), "utf8")) || 0;
|
|
979
1212
|
} catch {
|
|
980
1213
|
cursor = void 0;
|
|
981
1214
|
}
|
|
@@ -985,13 +1218,13 @@ function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs =
|
|
|
985
1218
|
if (cursor === void 0 && sinceMs > 0) {
|
|
986
1219
|
fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
|
|
987
1220
|
}
|
|
988
|
-
|
|
1221
|
+
fs11.writeFileSync(cursorPath(id, lane), String(log.length));
|
|
989
1222
|
return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
|
|
990
1223
|
}
|
|
991
1224
|
function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
|
|
992
1225
|
const dir = laneDirs(lane).state;
|
|
993
1226
|
const cutoff = Date.now() - maxAgeMs;
|
|
994
|
-
return
|
|
1227
|
+
return fs11.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs11.statSync(path7.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
|
|
995
1228
|
}
|
|
996
1229
|
function execNotify(command, ev, log = () => {
|
|
997
1230
|
}) {
|
|
@@ -1021,11 +1254,11 @@ function captureWaitBaseline() {
|
|
|
1021
1254
|
return lengths;
|
|
1022
1255
|
}
|
|
1023
1256
|
function attnCursorPath(id, lane) {
|
|
1024
|
-
return
|
|
1257
|
+
return path7.join(laneDirs(lane).state, `${id}.attn`);
|
|
1025
1258
|
}
|
|
1026
1259
|
function readAttnCursor(file) {
|
|
1027
1260
|
try {
|
|
1028
|
-
const parsed = JSON.parse(
|
|
1261
|
+
const parsed = JSON.parse(fs11.readFileSync(file, "utf8"));
|
|
1029
1262
|
return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
|
|
1030
1263
|
} catch {
|
|
1031
1264
|
return { seen: 0, at: 0 };
|
|
@@ -1051,7 +1284,7 @@ function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
|
|
|
1051
1284
|
continue;
|
|
1052
1285
|
out.push({ id, lane, entry });
|
|
1053
1286
|
if (consume)
|
|
1054
|
-
|
|
1287
|
+
fs11.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
|
|
1055
1288
|
}
|
|
1056
1289
|
}
|
|
1057
1290
|
return out;
|
|
@@ -1076,7 +1309,7 @@ function freshWakeEvents(baseline, verbs = DEFAULT_NOTIFY_VERBS) {
|
|
|
1076
1309
|
}
|
|
1077
1310
|
|
|
1078
1311
|
// packages/supervisor/dist/daemon.js
|
|
1079
|
-
var require_ =
|
|
1312
|
+
var require_ = createRequire2(import.meta.url);
|
|
1080
1313
|
function runnerEntry() {
|
|
1081
1314
|
try {
|
|
1082
1315
|
return require_.resolve("@lobstah/runner");
|
|
@@ -1086,33 +1319,33 @@ function runnerEntry() {
|
|
|
1086
1319
|
}
|
|
1087
1320
|
function readRunnerInfo(dir) {
|
|
1088
1321
|
try {
|
|
1089
|
-
return JSON.parse(
|
|
1322
|
+
return JSON.parse(fs12.readFileSync(path8.join(dir, "runner.json"), "utf8"));
|
|
1090
1323
|
} catch {
|
|
1091
1324
|
return void 0;
|
|
1092
1325
|
}
|
|
1093
1326
|
}
|
|
1094
1327
|
function listActive(lane) {
|
|
1095
1328
|
const dir = laneDirs(lane).active;
|
|
1096
|
-
return
|
|
1097
|
-
const d =
|
|
1329
|
+
return fs12.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
|
|
1330
|
+
const d = path8.join(dir, id);
|
|
1098
1331
|
return { id, lane, dir: d, runner: readRunnerInfo(d) };
|
|
1099
1332
|
});
|
|
1100
1333
|
}
|
|
1101
1334
|
function finalize(st) {
|
|
1102
1335
|
try {
|
|
1103
|
-
|
|
1336
|
+
fs12.renameSync(st.dir, path8.join(laneDirs(st.lane).done, st.id));
|
|
1104
1337
|
} catch {
|
|
1105
1338
|
}
|
|
1106
1339
|
}
|
|
1107
1340
|
function spawnRunner(st, opts) {
|
|
1108
1341
|
let nudge = opts.nudge;
|
|
1109
|
-
const handoffFile =
|
|
1110
|
-
if (!nudge &&
|
|
1111
|
-
nudge =
|
|
1112
|
-
|
|
1342
|
+
const handoffFile = path8.join(st.dir, "handoff");
|
|
1343
|
+
if (!nudge && fs12.existsSync(handoffFile)) {
|
|
1344
|
+
nudge = fs12.readFileSync(handoffFile, "utf8");
|
|
1345
|
+
fs12.unlinkSync(handoffFile);
|
|
1113
1346
|
}
|
|
1114
|
-
const logPath =
|
|
1115
|
-
const log =
|
|
1347
|
+
const logPath = path8.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
|
|
1348
|
+
const log = fs12.openSync(logPath, "a");
|
|
1116
1349
|
const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
|
|
1117
1350
|
detached: true,
|
|
1118
1351
|
stdio: ["ignore", log, log],
|
|
@@ -1123,7 +1356,7 @@ function spawnRunner(st, opts) {
|
|
|
1123
1356
|
...nudge ? { LOBSTAH_NUDGE: nudge } : {}
|
|
1124
1357
|
}
|
|
1125
1358
|
});
|
|
1126
|
-
|
|
1359
|
+
fs12.closeSync(log);
|
|
1127
1360
|
child.unref();
|
|
1128
1361
|
const info = {
|
|
1129
1362
|
pid: child.pid ?? -1,
|
|
@@ -1131,22 +1364,22 @@ function spawnRunner(st, opts) {
|
|
|
1131
1364
|
processStartTime: child.pid ? processStartTime(child.pid) : void 0,
|
|
1132
1365
|
attempts: opts.attempts
|
|
1133
1366
|
};
|
|
1134
|
-
|
|
1367
|
+
fs12.writeFileSync(path8.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
|
|
1135
1368
|
}
|
|
1136
1369
|
function sessionOf(st) {
|
|
1137
1370
|
try {
|
|
1138
|
-
const ev = JSON.parse(
|
|
1371
|
+
const ev = JSON.parse(fs12.readFileSync(path8.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
|
|
1139
1372
|
return ev.sessionId;
|
|
1140
1373
|
} catch {
|
|
1141
1374
|
return void 0;
|
|
1142
1375
|
}
|
|
1143
1376
|
}
|
|
1144
1377
|
function reconcileOne(st, cfg, log) {
|
|
1145
|
-
const hasDescriptor =
|
|
1378
|
+
const hasDescriptor = fs12.existsSync(path8.join(st.dir, "descriptor.json"));
|
|
1146
1379
|
if (!hasDescriptor) {
|
|
1147
|
-
const age = Date.now() -
|
|
1380
|
+
const age = Date.now() - fs12.statSync(st.dir).mtimeMs;
|
|
1148
1381
|
if (age > 5 * 6e4)
|
|
1149
|
-
|
|
1382
|
+
fs12.rmSync(st.dir, { recursive: true, force: true });
|
|
1150
1383
|
return;
|
|
1151
1384
|
}
|
|
1152
1385
|
const statusLog = readStatusLog(st.id, st.lane);
|
|
@@ -1224,22 +1457,22 @@ function writeHeartbeat(cfg) {
|
|
|
1224
1457
|
const payload = {
|
|
1225
1458
|
machineId: os3.hostname(),
|
|
1226
1459
|
repos: Object.keys(cfg.repos),
|
|
1227
|
-
harnesses:
|
|
1460
|
+
harnesses: detectHarnesses(),
|
|
1228
1461
|
maxConcurrent: cfg.limits.maxConcurrent,
|
|
1229
|
-
version:
|
|
1462
|
+
version: lobstahVersion(),
|
|
1230
1463
|
heartbeat: (/* @__PURE__ */ new Date()).toISOString()
|
|
1231
1464
|
};
|
|
1232
1465
|
const tmp = `${executorPath()}.tmp`;
|
|
1233
|
-
|
|
1234
|
-
|
|
1466
|
+
fs12.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
1467
|
+
fs12.renameSync(tmp, executorPath());
|
|
1235
1468
|
}
|
|
1236
1469
|
function pruneChores(cfg) {
|
|
1237
1470
|
const dir = laneDirs("chore").done;
|
|
1238
1471
|
const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
|
|
1239
|
-
for (const id of
|
|
1240
|
-
const p =
|
|
1241
|
-
if (
|
|
1242
|
-
|
|
1472
|
+
for (const id of fs12.readdirSync(dir)) {
|
|
1473
|
+
const p = path8.join(dir, id);
|
|
1474
|
+
if (fs12.statSync(p).mtimeMs < cutoff)
|
|
1475
|
+
fs12.rmSync(p, { recursive: true, force: true });
|
|
1243
1476
|
}
|
|
1244
1477
|
}
|
|
1245
1478
|
var daemonStartedAt = Date.now();
|
|
@@ -1279,9 +1512,9 @@ function tick(log = () => {
|
|
|
1279
1512
|
pruneChores(loadConfig());
|
|
1280
1513
|
}
|
|
1281
1514
|
function acquireDaemonLock() {
|
|
1282
|
-
const lockPath =
|
|
1515
|
+
const lockPath = path8.join(lobstahHome(), "daemon.lock");
|
|
1283
1516
|
try {
|
|
1284
|
-
const prior = JSON.parse(
|
|
1517
|
+
const prior = JSON.parse(fs12.readFileSync(lockPath, "utf8"));
|
|
1285
1518
|
if (pidAlive(prior.pid, prior.processStartTime)) {
|
|
1286
1519
|
throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
|
|
1287
1520
|
}
|
|
@@ -1289,10 +1522,10 @@ function acquireDaemonLock() {
|
|
|
1289
1522
|
if (err instanceof Error && err.message.includes("already supervises"))
|
|
1290
1523
|
throw err;
|
|
1291
1524
|
}
|
|
1292
|
-
|
|
1525
|
+
fs12.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
|
|
1293
1526
|
const release = () => {
|
|
1294
1527
|
try {
|
|
1295
|
-
|
|
1528
|
+
fs12.unlinkSync(lockPath);
|
|
1296
1529
|
} catch {
|
|
1297
1530
|
}
|
|
1298
1531
|
};
|
|
@@ -1304,17 +1537,42 @@ function acquireDaemonLock() {
|
|
|
1304
1537
|
});
|
|
1305
1538
|
}
|
|
1306
1539
|
}
|
|
1540
|
+
function watchQueues(onChange) {
|
|
1541
|
+
const watchers = [];
|
|
1542
|
+
for (const lane of ["work", "chore"]) {
|
|
1543
|
+
try {
|
|
1544
|
+
watchers.push(fs12.watch(laneDirs(lane).queue, onChange));
|
|
1545
|
+
} catch {
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return () => {
|
|
1549
|
+
for (const w of watchers)
|
|
1550
|
+
w.close();
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1307
1553
|
async function daemon(intervalMs = 5e3, log = console.log) {
|
|
1308
1554
|
ensureLayout();
|
|
1309
1555
|
acquireDaemonLock();
|
|
1310
1556
|
log(`lobstah daemon: watching ${laneDirs("work").queue} every ${intervalMs}ms`);
|
|
1557
|
+
let wake;
|
|
1558
|
+
watchQueues(() => wake?.());
|
|
1311
1559
|
while (true) {
|
|
1312
1560
|
try {
|
|
1313
1561
|
tick(log);
|
|
1314
1562
|
} catch (err) {
|
|
1315
1563
|
log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1316
1564
|
}
|
|
1317
|
-
await new Promise((
|
|
1565
|
+
await new Promise((resolve2) => {
|
|
1566
|
+
const timer = setTimeout(() => {
|
|
1567
|
+
wake = void 0;
|
|
1568
|
+
resolve2();
|
|
1569
|
+
}, intervalMs);
|
|
1570
|
+
wake = () => {
|
|
1571
|
+
wake = void 0;
|
|
1572
|
+
clearTimeout(timer);
|
|
1573
|
+
setTimeout(resolve2, 50);
|
|
1574
|
+
};
|
|
1575
|
+
});
|
|
1318
1576
|
}
|
|
1319
1577
|
}
|
|
1320
1578
|
|
|
@@ -1322,7 +1580,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
|
|
|
1322
1580
|
import { spawn as spawn3 } from "node:child_process";
|
|
1323
1581
|
|
|
1324
1582
|
// apps/pick/dist/config.js
|
|
1325
|
-
import * as
|
|
1583
|
+
import * as fs13 from "node:fs";
|
|
1326
1584
|
import { execSync } from "node:child_process";
|
|
1327
1585
|
import * as os4 from "node:os";
|
|
1328
1586
|
|
|
@@ -1341,6 +1599,11 @@ function marker(uuid) {
|
|
|
1341
1599
|
}
|
|
1342
1600
|
|
|
1343
1601
|
// apps/pick/dist/config.js
|
|
1602
|
+
function githubRepoFromOrigin(origin) {
|
|
1603
|
+
const m = /^git@github\.com:(.+?)(?:\.git)?$/.exec(origin) ?? /^ssh:\/\/git@github\.com\/(.+?)(?:\.git)?$/.exec(origin) ?? /^https:\/\/github\.com\/(.+?)(?:\.git)?\/?$/.exec(origin);
|
|
1604
|
+
const repo = m?.[1];
|
|
1605
|
+
return repo && repo.split("/").length === 2 ? repo : void 0;
|
|
1606
|
+
}
|
|
1344
1607
|
function resolveTokenSource(section, label) {
|
|
1345
1608
|
const command = section.tokenCommand ? String(section.tokenCommand) : void 0;
|
|
1346
1609
|
const file = section.tokenFile ? String(section.tokenFile) : void 0;
|
|
@@ -1359,7 +1622,7 @@ function resolveTokenSource(section, label) {
|
|
|
1359
1622
|
}
|
|
1360
1623
|
if (file) {
|
|
1361
1624
|
return () => {
|
|
1362
|
-
const token =
|
|
1625
|
+
const token = fs13.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
|
|
1363
1626
|
if (!token)
|
|
1364
1627
|
throw new Error(`${label}: tokenFile ${file} is empty`);
|
|
1365
1628
|
return token;
|
|
@@ -1377,27 +1640,55 @@ function resolveTokenSource(section, label) {
|
|
|
1377
1640
|
}
|
|
1378
1641
|
function loadPickupConfig() {
|
|
1379
1642
|
const file = configPath();
|
|
1380
|
-
const raw =
|
|
1643
|
+
const raw = fs13.existsSync(file) ? parse(fs13.readFileSync(file, "utf8")) : {};
|
|
1381
1644
|
const p = raw.pickup ?? {};
|
|
1382
1645
|
const out = {
|
|
1383
1646
|
pollSecs: Number(p.pollSecs ?? 45),
|
|
1384
|
-
notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0
|
|
1647
|
+
notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0,
|
|
1648
|
+
github: []
|
|
1385
1649
|
};
|
|
1386
1650
|
const gh = p.github;
|
|
1387
1651
|
if (gh) {
|
|
1388
1652
|
const token = resolveTokenSource({ tokenEnv: "GITHUB_TOKEN", ...gh }, "pickup.github");
|
|
1389
1653
|
token();
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1654
|
+
const identity = String(gh.identity ?? "");
|
|
1655
|
+
if (!identity)
|
|
1656
|
+
throw new Error("pickup.github requires identity");
|
|
1657
|
+
const shared = {
|
|
1658
|
+
identity,
|
|
1394
1659
|
token,
|
|
1395
1660
|
startLabel: String(gh.startLabel ?? "lobstah"),
|
|
1396
|
-
claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed")
|
|
1397
|
-
merge: { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} }
|
|
1661
|
+
claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed")
|
|
1398
1662
|
};
|
|
1399
|
-
|
|
1400
|
-
|
|
1663
|
+
const mergeBase = { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} };
|
|
1664
|
+
const overrides = gh.overrides ?? {};
|
|
1665
|
+
if (gh.repo || gh.key) {
|
|
1666
|
+
const single = { ...shared, repo: String(gh.repo ?? ""), key: String(gh.key ?? ""), merge: mergeBase };
|
|
1667
|
+
if (!single.repo || !single.key)
|
|
1668
|
+
throw new Error("pickup.github requires repo, key, and identity");
|
|
1669
|
+
out.github.push(single);
|
|
1670
|
+
} else {
|
|
1671
|
+
for (const [key, repo] of Object.entries(loadConfig().repos)) {
|
|
1672
|
+
if (!repo.pickup)
|
|
1673
|
+
continue;
|
|
1674
|
+
if (!repo.origin)
|
|
1675
|
+
throw new Error(`repos.${key} has pickup = true but no origin to derive the GitHub repo from`);
|
|
1676
|
+
const forgeRepo = githubRepoFromOrigin(repo.origin);
|
|
1677
|
+
if (!forgeRepo)
|
|
1678
|
+
throw new Error(`repos.${key} has pickup = true but origin "${repo.origin}" is not a GitHub URL`);
|
|
1679
|
+
const o = overrides[key] ?? {};
|
|
1680
|
+
out.github.push({
|
|
1681
|
+
...shared,
|
|
1682
|
+
repo: forgeRepo,
|
|
1683
|
+
key,
|
|
1684
|
+
startLabel: o.startLabel ? String(o.startLabel) : shared.startLabel,
|
|
1685
|
+
claimedLabel: o.claimedLabel ? String(o.claimedLabel) : shared.claimedLabel,
|
|
1686
|
+
merge: { ...mergeBase, ...o.merge ?? {} }
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
if (out.github.length === 0) {
|
|
1690
|
+
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");
|
|
1691
|
+
}
|
|
1401
1692
|
}
|
|
1402
1693
|
}
|
|
1403
1694
|
const ln = p.linear;
|
|
@@ -1422,23 +1713,23 @@ function loadPickupConfig() {
|
|
|
1422
1713
|
}
|
|
1423
1714
|
|
|
1424
1715
|
// apps/pick/dist/state.js
|
|
1425
|
-
import * as
|
|
1426
|
-
import * as
|
|
1716
|
+
import * as fs14 from "node:fs";
|
|
1717
|
+
import * as path9 from "node:path";
|
|
1427
1718
|
var PickupState = class {
|
|
1428
1719
|
file;
|
|
1429
1720
|
data;
|
|
1430
|
-
constructor(dir =
|
|
1431
|
-
|
|
1432
|
-
this.file =
|
|
1433
|
-
this.data =
|
|
1721
|
+
constructor(dir = path9.join(lobstahHome(), "pickup")) {
|
|
1722
|
+
fs14.mkdirSync(dir, { recursive: true });
|
|
1723
|
+
this.file = path9.join(dir, "state.json");
|
|
1724
|
+
this.data = fs14.existsSync(this.file) ? JSON.parse(fs14.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
|
|
1434
1725
|
this.data.map ??= {};
|
|
1435
1726
|
this.data.consumedApprovals ??= {};
|
|
1436
1727
|
this.data.rebases ??= {};
|
|
1437
1728
|
}
|
|
1438
1729
|
save() {
|
|
1439
1730
|
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
1440
|
-
|
|
1441
|
-
|
|
1731
|
+
fs14.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
|
|
1732
|
+
fs14.renameSync(tmp, this.file);
|
|
1442
1733
|
}
|
|
1443
1734
|
get(key) {
|
|
1444
1735
|
return this.data.map[key];
|
|
@@ -1822,12 +2113,12 @@ async function dispatchLoop(source, state, log = () => {
|
|
|
1822
2113
|
}
|
|
1823
2114
|
|
|
1824
2115
|
// apps/pick/dist/loops/report.js
|
|
1825
|
-
import * as
|
|
1826
|
-
import * as
|
|
2116
|
+
import * as fs15 from "node:fs";
|
|
2117
|
+
import * as path10 from "node:path";
|
|
1827
2118
|
function dispatchLane(uuid) {
|
|
1828
2119
|
for (const lane of ["work", "chore"]) {
|
|
1829
2120
|
const d = laneDirs(lane);
|
|
1830
|
-
if (
|
|
2121
|
+
if (fs15.existsSync(path10.join(d.active, uuid)) || fs15.existsSync(path10.join(d.queue, `${uuid}.json`)) || fs15.existsSync(path10.join(d.done, uuid)) || fs15.existsSync(path10.join(d.state, `${uuid}.status`))) {
|
|
1831
2122
|
return lane;
|
|
1832
2123
|
}
|
|
1833
2124
|
}
|
|
@@ -1868,12 +2159,12 @@ async function reportLoop(source, state, log = () => {
|
|
|
1868
2159
|
}
|
|
1869
2160
|
|
|
1870
2161
|
// apps/pick/dist/loops/reconcile.js
|
|
1871
|
-
import * as
|
|
1872
|
-
import * as
|
|
2162
|
+
import * as fs16 from "node:fs";
|
|
2163
|
+
import * as path11 from "node:path";
|
|
1873
2164
|
function dispatchActive(uuid) {
|
|
1874
2165
|
for (const lane of ["work", "chore"]) {
|
|
1875
2166
|
const d = laneDirs(lane);
|
|
1876
|
-
if (
|
|
2167
|
+
if (fs16.existsSync(path11.join(d.active, uuid)) || fs16.existsSync(path11.join(d.queue, `${uuid}.json`))) {
|
|
1877
2168
|
return lane;
|
|
1878
2169
|
}
|
|
1879
2170
|
}
|
|
@@ -1913,28 +2204,28 @@ async function reconcileLoop(source, state, log = () => {
|
|
|
1913
2204
|
|
|
1914
2205
|
// apps/pick/dist/loops/merge.js
|
|
1915
2206
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1916
|
-
import * as
|
|
1917
|
-
import * as
|
|
2207
|
+
import * as fs18 from "node:fs";
|
|
2208
|
+
import * as path13 from "node:path";
|
|
1918
2209
|
|
|
1919
2210
|
// apps/pick/dist/merge-view.js
|
|
1920
|
-
import * as
|
|
1921
|
-
import * as
|
|
2211
|
+
import * as fs17 from "node:fs";
|
|
2212
|
+
import * as path12 from "node:path";
|
|
1922
2213
|
function viewPath() {
|
|
1923
|
-
return
|
|
2214
|
+
return path12.join(lobstahHome(), "pickup", "merge-view.json");
|
|
1924
2215
|
}
|
|
1925
2216
|
function readMergeView() {
|
|
1926
2217
|
try {
|
|
1927
|
-
return JSON.parse(
|
|
2218
|
+
return JSON.parse(fs17.readFileSync(viewPath(), "utf8"));
|
|
1928
2219
|
} catch {
|
|
1929
2220
|
return void 0;
|
|
1930
2221
|
}
|
|
1931
2222
|
}
|
|
1932
2223
|
function writeMergeView(view) {
|
|
1933
2224
|
const file = viewPath();
|
|
1934
|
-
|
|
2225
|
+
fs17.mkdirSync(path12.dirname(file), { recursive: true });
|
|
1935
2226
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
1936
|
-
|
|
1937
|
-
|
|
2227
|
+
fs17.writeFileSync(tmp, JSON.stringify(view, null, 2));
|
|
2228
|
+
fs17.renameSync(tmp, file);
|
|
1938
2229
|
}
|
|
1939
2230
|
var RECENT_RETENTION_MS = 48 * 36e5;
|
|
1940
2231
|
async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
|
|
@@ -1956,7 +2247,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
|
|
|
1956
2247
|
}
|
|
1957
2248
|
function readPickupMap() {
|
|
1958
2249
|
try {
|
|
1959
|
-
const raw = JSON.parse(
|
|
2250
|
+
const raw = JSON.parse(fs17.readFileSync(path12.join(lobstahHome(), "pickup", "state.json"), "utf8"));
|
|
1960
2251
|
return raw.map ?? {};
|
|
1961
2252
|
} catch {
|
|
1962
2253
|
return {};
|
|
@@ -1990,7 +2281,7 @@ function rebaseBrief(pr) {
|
|
|
1990
2281
|
}
|
|
1991
2282
|
function choreVerdict(uuid) {
|
|
1992
2283
|
const d = laneDirs("chore");
|
|
1993
|
-
if (
|
|
2284
|
+
if (fs18.existsSync(path13.join(d.active, uuid)) || fs18.existsSync(path13.join(d.queue, `${uuid}.json`)))
|
|
1994
2285
|
return "active";
|
|
1995
2286
|
const last = readStatusLog(uuid, "chore").at(-1)?.verb;
|
|
1996
2287
|
return last === "done" ? "done" : "failed";
|
|
@@ -2083,6 +2374,126 @@ async function mergeLoop(ms, policy, state, log = () => {
|
|
|
2083
2374
|
await recordMergeView(ms.forgeRepo(), observed, (n) => ms.disposition(n));
|
|
2084
2375
|
}
|
|
2085
2376
|
|
|
2377
|
+
// apps/pick/dist/loops/watch.js
|
|
2378
|
+
import * as fs19 from "node:fs";
|
|
2379
|
+
import * as path14 from "node:path";
|
|
2380
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2381
|
+
var DEFAULT_BRIEF = `You are resuming earlier work. The watched source {key} produced new events:
|
|
2382
|
+
|
|
2383
|
+
{events}
|
|
2384
|
+
|
|
2385
|
+
Read them, act on what they ask, and report the outcome.`;
|
|
2386
|
+
function laneOf(id) {
|
|
2387
|
+
for (const lane of ["work", "chore"]) {
|
|
2388
|
+
const d = laneDirs(lane);
|
|
2389
|
+
if (fs19.existsSync(path14.join(d.active, id)) || fs19.existsSync(path14.join(d.queue, `${id}.json`)) || fs19.existsSync(path14.join(d.done, id)) || fs19.existsSync(path14.join(d.state, `${id}.status`))) {
|
|
2390
|
+
return lane;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
return void 0;
|
|
2394
|
+
}
|
|
2395
|
+
function descriptorOf(id, lane) {
|
|
2396
|
+
const d = laneDirs(lane);
|
|
2397
|
+
for (const bucket of ["active", "done"]) {
|
|
2398
|
+
const f = path14.join(d[bucket], id, "descriptor.json");
|
|
2399
|
+
if (fs19.existsSync(f))
|
|
2400
|
+
return JSON.parse(fs19.readFileSync(f, "utf8"));
|
|
2401
|
+
}
|
|
2402
|
+
const q = path14.join(d.queue, `${id}.json`);
|
|
2403
|
+
if (fs19.existsSync(q))
|
|
2404
|
+
return JSON.parse(fs19.readFileSync(q, "utf8"));
|
|
2405
|
+
return void 0;
|
|
2406
|
+
}
|
|
2407
|
+
function isTerminal(id) {
|
|
2408
|
+
const lane = laneOf(id);
|
|
2409
|
+
if (!lane)
|
|
2410
|
+
return true;
|
|
2411
|
+
const state = reconcile({ log: readStatusLog(id, lane), lastEventAt: lastEventAt(id, lane) });
|
|
2412
|
+
return state === "done" || state === "failed";
|
|
2413
|
+
}
|
|
2414
|
+
function spawnContinuation(w, pending, log) {
|
|
2415
|
+
const owner = w.owner.slice("dispatch:".length);
|
|
2416
|
+
const target = w.lastFollowUpId && !laneOf(w.lastFollowUpId) ? owner : w.lastFollowUpId ?? owner;
|
|
2417
|
+
const targetLane = laneOf(target);
|
|
2418
|
+
if (!targetLane) {
|
|
2419
|
+
log(`watch ${w.key}: owner dispatch ${target} is gone \u2014 dropping watch`);
|
|
2420
|
+
removeWatch(w.key);
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
const descriptor = descriptorOf(target, targetLane);
|
|
2424
|
+
if (!descriptor) {
|
|
2425
|
+
log(`watch ${w.key}: no descriptor for ${target} \u2014 dropping watch`);
|
|
2426
|
+
removeWatch(w.key);
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
const id = randomUUID3();
|
|
2430
|
+
const brief = (w.brief ?? DEFAULT_BRIEF).replaceAll("{key}", w.key).replaceAll("{events}", JSON.stringify(pending, null, 2));
|
|
2431
|
+
enqueue({ id, repo: descriptor.repo, brief, followUp: target }, "work");
|
|
2432
|
+
markFollowUp(w.key, id, readWatchEvents(w.key).length);
|
|
2433
|
+
log(`watch ${w.key}: ${pending.length} event(s) \u2192 continuation ${id} (forks ${target})`);
|
|
2434
|
+
}
|
|
2435
|
+
function notifyMan(watch2, fresh, notify) {
|
|
2436
|
+
if (fresh.length === 0 || watch2.owner !== "man")
|
|
2437
|
+
return;
|
|
2438
|
+
notify({
|
|
2439
|
+
key: watch2.key,
|
|
2440
|
+
uuid: "watch",
|
|
2441
|
+
verb: "watch",
|
|
2442
|
+
note: fresh[0]?.summary ?? `${fresh.length} new event(s)`
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
function deliverDispatchOwned(log) {
|
|
2446
|
+
for (const { watch: watch2, events } of pendingWatchEvents(false, "dispatch")) {
|
|
2447
|
+
if (watch2.lastFollowUpId && !isTerminal(watch2.lastFollowUpId))
|
|
2448
|
+
continue;
|
|
2449
|
+
spawnContinuation(watch2, events, log);
|
|
2450
|
+
}
|
|
2451
|
+
for (const w of listWatches()) {
|
|
2452
|
+
if (w.done && w.owner !== "man" && readWatchEvents(w.key).length <= w.seen)
|
|
2453
|
+
removeWatch(w.key);
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
async function watchLoop(defaultEverySecs, log, notify = () => {
|
|
2457
|
+
}) {
|
|
2458
|
+
for (const w of listWatches()) {
|
|
2459
|
+
if (watchDue(w, defaultEverySecs)) {
|
|
2460
|
+
const { watch: watch2, fresh } = runWatchCheck(w);
|
|
2461
|
+
if (watch2.lastError)
|
|
2462
|
+
log(`watch ${w.key}: check failed \u2014 ${watch2.lastError}`);
|
|
2463
|
+
notifyMan(watch2, fresh, notify);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
deliverDispatchOwned(log);
|
|
2467
|
+
}
|
|
2468
|
+
function handleStreamLine(key, line, log, notify) {
|
|
2469
|
+
const watch2 = readWatch(key);
|
|
2470
|
+
if (!watch2)
|
|
2471
|
+
return;
|
|
2472
|
+
let parsed;
|
|
2473
|
+
try {
|
|
2474
|
+
parsed = JSON.parse(line);
|
|
2475
|
+
} catch {
|
|
2476
|
+
log(`watch ${key}: unparseable stream line \u2014 ${line.slice(0, 120)}`);
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2479
|
+
if (parsed.seq === void 0) {
|
|
2480
|
+
if (parsed.cursor !== void 0)
|
|
2481
|
+
setWatchCursor(key, String(parsed.cursor));
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
const event = {
|
|
2485
|
+
seq: parsed.seq,
|
|
2486
|
+
summary: parsed.summary !== void 0 ? String(parsed.summary) : void 0,
|
|
2487
|
+
...parsed,
|
|
2488
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2489
|
+
};
|
|
2490
|
+
const fresh = appendWatchEvents(key, event.seq !== void 0 ? [event] : []);
|
|
2491
|
+
setWatchCursor(key, String(parsed.cursor ?? event.seq));
|
|
2492
|
+
notifyMan(watch2, fresh, notify);
|
|
2493
|
+
if (fresh.length > 0)
|
|
2494
|
+
deliverDispatchOwned(log);
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2086
2497
|
// apps/pick/dist/run.js
|
|
2087
2498
|
function makeNotifier(command, log) {
|
|
2088
2499
|
if (!command)
|
|
@@ -2106,7 +2517,49 @@ function makeNotifier(command, log) {
|
|
|
2106
2517
|
child.unref();
|
|
2107
2518
|
};
|
|
2108
2519
|
}
|
|
2109
|
-
|
|
2520
|
+
var SingleFlight = class {
|
|
2521
|
+
chain = Promise.resolve();
|
|
2522
|
+
run(job) {
|
|
2523
|
+
const next = this.chain.then(job, job);
|
|
2524
|
+
this.chain = next.then(() => {
|
|
2525
|
+
}, () => {
|
|
2526
|
+
});
|
|
2527
|
+
return next;
|
|
2528
|
+
}
|
|
2529
|
+
};
|
|
2530
|
+
function syncStreams(children, flight, log, notify) {
|
|
2531
|
+
for (const w of listWatches()) {
|
|
2532
|
+
if (!w.stream || children.has(w.key))
|
|
2533
|
+
continue;
|
|
2534
|
+
const cmd = w.stream.replaceAll("{cursor}", w.cursor);
|
|
2535
|
+
const child = spawn3(cmd, { shell: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
2536
|
+
children.set(w.key, child);
|
|
2537
|
+
log(`watch ${w.key}: stream attached`);
|
|
2538
|
+
let buffer = "";
|
|
2539
|
+
child.stdout.on("data", (chunk) => {
|
|
2540
|
+
buffer += chunk.toString("utf8");
|
|
2541
|
+
const lines = buffer.split("\n");
|
|
2542
|
+
buffer = lines.pop() ?? "";
|
|
2543
|
+
for (const line of lines) {
|
|
2544
|
+
if (!line.trim())
|
|
2545
|
+
continue;
|
|
2546
|
+
void flight.run(() => handleStreamLine(w.key, line, log, notify));
|
|
2547
|
+
}
|
|
2548
|
+
});
|
|
2549
|
+
child.on("exit", () => {
|
|
2550
|
+
children.delete(w.key);
|
|
2551
|
+
log(`watch ${w.key}: stream ended \u2014 cadence covers until resync`);
|
|
2552
|
+
});
|
|
2553
|
+
child.on("error", () => children.delete(w.key));
|
|
2554
|
+
}
|
|
2555
|
+
for (const [key, child] of children) {
|
|
2556
|
+
if (!readWatch(key)?.stream) {
|
|
2557
|
+
child.kill();
|
|
2558
|
+
children.delete(key);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
async function cycle(sources, merges, state, pollSecs, log, notify) {
|
|
2110
2563
|
for (const source of sources) {
|
|
2111
2564
|
try {
|
|
2112
2565
|
await dispatchLoop(source, state, log);
|
|
@@ -2123,6 +2576,11 @@ async function cycle(sources, merges, state, log, notify) {
|
|
|
2123
2576
|
log(`${source.name} merge: ${err instanceof Error ? err.message : String(err)}`);
|
|
2124
2577
|
}
|
|
2125
2578
|
}
|
|
2579
|
+
try {
|
|
2580
|
+
await watchLoop(pollSecs, log, notify);
|
|
2581
|
+
} catch (err) {
|
|
2582
|
+
log(`watches: ${err instanceof Error ? err.message : String(err)}`);
|
|
2583
|
+
}
|
|
2126
2584
|
}
|
|
2127
2585
|
async function runPickup(mode = "daemon") {
|
|
2128
2586
|
ensureLayout();
|
|
@@ -2131,24 +2589,30 @@ async function runPickup(mode = "daemon") {
|
|
|
2131
2589
|
const log = (m) => console.log(`[pickup] ${m}`);
|
|
2132
2590
|
const sources = [];
|
|
2133
2591
|
const merges = [];
|
|
2134
|
-
|
|
2135
|
-
const gh = new GithubSource(
|
|
2592
|
+
for (const ghCfg of cfg.github) {
|
|
2593
|
+
const gh = new GithubSource(ghCfg);
|
|
2136
2594
|
sources.push(gh);
|
|
2137
|
-
merges.push({ source: gh, policy:
|
|
2595
|
+
merges.push({ source: gh, policy: ghCfg.merge });
|
|
2138
2596
|
}
|
|
2139
2597
|
if (cfg.linear)
|
|
2140
2598
|
sources.push(new LinearSource(cfg.linear));
|
|
2141
2599
|
if (sources.length === 0) {
|
|
2142
|
-
|
|
2600
|
+
if (listWatches().length === 0) {
|
|
2601
|
+
throw new Error("no [pickup.github], [pickup.linear], or registered watches \u2014 nothing to poll");
|
|
2602
|
+
}
|
|
2603
|
+
log("no tracker sources configured \u2014 watch-only mode");
|
|
2143
2604
|
}
|
|
2144
2605
|
const notify = makeNotifier(cfg.notifyCommand, log);
|
|
2145
2606
|
if (mode === "once") {
|
|
2146
|
-
await cycle(sources, merges, state, log, notify);
|
|
2607
|
+
await cycle(sources, merges, state, cfg.pollSecs, log, notify);
|
|
2147
2608
|
return;
|
|
2148
2609
|
}
|
|
2149
2610
|
log(`polling every ${cfg.pollSecs}s \u2014 no webhooks, no inbound surface`);
|
|
2611
|
+
const flight = new SingleFlight();
|
|
2612
|
+
const streamChildren = /* @__PURE__ */ new Map();
|
|
2150
2613
|
while (true) {
|
|
2151
|
-
await cycle(sources, merges, state, log, notify);
|
|
2614
|
+
await flight.run(() => cycle(sources, merges, state, cfg.pollSecs, log, notify));
|
|
2615
|
+
syncStreams(streamChildren, flight, log, notify);
|
|
2152
2616
|
await new Promise((r) => setTimeout(r, cfg.pollSecs * 1e3));
|
|
2153
2617
|
}
|
|
2154
2618
|
}
|
|
@@ -2167,21 +2631,21 @@ function mergeHaulHook(existing) {
|
|
|
2167
2631
|
}
|
|
2168
2632
|
|
|
2169
2633
|
// apps/cli/src/tend.ts
|
|
2170
|
-
import * as
|
|
2171
|
-
import * as
|
|
2634
|
+
import * as fs20 from "node:fs";
|
|
2635
|
+
import * as path15 from "node:path";
|
|
2172
2636
|
var HEARTBEAT_STALE_MS = 9e4;
|
|
2173
2637
|
var CLAIM_STALE_MS = 12e4;
|
|
2174
2638
|
var DAY_MS = 24 * 36e5;
|
|
2175
2639
|
function readJson(file) {
|
|
2176
2640
|
try {
|
|
2177
|
-
return JSON.parse(
|
|
2641
|
+
return JSON.parse(fs20.readFileSync(file, "utf8"));
|
|
2178
2642
|
} catch {
|
|
2179
2643
|
return void 0;
|
|
2180
2644
|
}
|
|
2181
2645
|
}
|
|
2182
2646
|
function doneIds(lane) {
|
|
2183
2647
|
try {
|
|
2184
|
-
return
|
|
2648
|
+
return fs20.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
|
|
2185
2649
|
} catch {
|
|
2186
2650
|
return [];
|
|
2187
2651
|
}
|
|
@@ -2200,16 +2664,16 @@ function followUps(uuid) {
|
|
|
2200
2664
|
const desc = readJson(file);
|
|
2201
2665
|
if (desc?.followUp === uuid) out.push({ id, bucket });
|
|
2202
2666
|
};
|
|
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",
|
|
2667
|
+
for (const id of pendingIds("work")) scan(id, "queued", path15.join(d.queue, `${id}.json`));
|
|
2668
|
+
for (const id of activeIds("work")) scan(id, "active", path15.join(d.active, id, "descriptor.json"));
|
|
2669
|
+
for (const id of doneIds("work")) scan(id, "done", path15.join(d.done, id, "descriptor.json"));
|
|
2206
2670
|
return out;
|
|
2207
2671
|
}
|
|
2208
2672
|
function bucketOf(uuid) {
|
|
2209
2673
|
const d = laneDirs("work");
|
|
2210
|
-
if (
|
|
2211
|
-
if (
|
|
2212
|
-
if (
|
|
2674
|
+
if (fs20.existsSync(path15.join(d.active, uuid))) return "active";
|
|
2675
|
+
if (fs20.existsSync(path15.join(d.done, uuid))) return "done";
|
|
2676
|
+
if (fs20.existsSync(path15.join(d.queue, `${uuid}.json`))) return "queued";
|
|
2213
2677
|
return void 0;
|
|
2214
2678
|
}
|
|
2215
2679
|
function buildTendReport(now = Date.now()) {
|
|
@@ -2237,8 +2701,39 @@ function buildTendReport(now = Date.now()) {
|
|
|
2237
2701
|
ageSecs: Math.max(0, Math.round((now - Date.parse(ev.entry.at)) / 1e3)),
|
|
2238
2702
|
note: ev.entry.note
|
|
2239
2703
|
}));
|
|
2704
|
+
const watches = [];
|
|
2705
|
+
const watchByDispatch = /* @__PURE__ */ new Map();
|
|
2706
|
+
for (const w of listWatches()) {
|
|
2707
|
+
const events = readWatchEvents(w.key);
|
|
2708
|
+
const pending = events.slice(w.seen);
|
|
2709
|
+
const last = events.at(-1);
|
|
2710
|
+
watches.push({
|
|
2711
|
+
key: w.key,
|
|
2712
|
+
owner: w.owner,
|
|
2713
|
+
cursor: w.cursor,
|
|
2714
|
+
pendingEvents: pending.length,
|
|
2715
|
+
lastSummary: last?.summary,
|
|
2716
|
+
lastAt: last?.at,
|
|
2717
|
+
error: w.lastError
|
|
2718
|
+
});
|
|
2719
|
+
if (w.owner.startsWith("dispatch:")) {
|
|
2720
|
+
const label = `${w.key}${pending.length > 0 ? ` (${pending.length} pending)` : ""}`;
|
|
2721
|
+
watchByDispatch.set(w.owner.slice("dispatch:".length), label);
|
|
2722
|
+
if (w.lastFollowUpId) watchByDispatch.set(w.lastFollowUpId, label);
|
|
2723
|
+
}
|
|
2724
|
+
if (w.owner === "man" && pending.length > 0) {
|
|
2725
|
+
const oldest = pending[0];
|
|
2726
|
+
attention.push({
|
|
2727
|
+
id: w.key,
|
|
2728
|
+
lane: "work",
|
|
2729
|
+
verb: "watch",
|
|
2730
|
+
ageSecs: oldest?.at ? Math.max(0, Math.round((now - Date.parse(oldest.at)) / 1e3)) : 0,
|
|
2731
|
+
note: pending.at(-1)?.summary
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2240
2735
|
const oldestQueuedAge = queued.reduce((max, id) => {
|
|
2241
|
-
const st =
|
|
2736
|
+
const st = fs20.statSync(path15.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
|
|
2242
2737
|
return st ? Math.max(max, now - st.mtimeMs) : max;
|
|
2243
2738
|
}, 0);
|
|
2244
2739
|
const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
|
|
@@ -2260,12 +2755,13 @@ function buildTendReport(now = Date.now()) {
|
|
|
2260
2755
|
const fresh = chain.some((d) => d.bucket !== "done" || d.at !== void 0 && now - Date.parse(d.at) < DAY_MS);
|
|
2261
2756
|
if (!fresh) continue;
|
|
2262
2757
|
const prUrl = chain.map((d) => d.prUrl).find((u) => u !== void 0);
|
|
2263
|
-
|
|
2758
|
+
const watch2 = chain.map((d) => watchByDispatch.get(d.id)).find((w) => w !== void 0);
|
|
2759
|
+
stories.push({ key, dispatches: chain, prUrl, gate: gateFor(entry.uuid, prUrl), watch: watch2 });
|
|
2264
2760
|
}
|
|
2265
2761
|
for (const id of [...active, ...queued]) {
|
|
2266
2762
|
if (storied.has(id)) continue;
|
|
2267
2763
|
const d = describeDispatch(id, "work", bucketOf(id) ?? "active");
|
|
2268
|
-
stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl) });
|
|
2764
|
+
stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl), watch: watchByDispatch.get(id) });
|
|
2269
2765
|
}
|
|
2270
2766
|
const verdict = !daemonUp ? "daemon-down" : stalled ? "stalled" : attention.length > 0 ? "needs-attention" : active.length + queued.length > 0 ? "working" : "idle";
|
|
2271
2767
|
return {
|
|
@@ -2274,6 +2770,7 @@ function buildTendReport(now = Date.now()) {
|
|
|
2274
2770
|
counts: { queued: queued.length, active: active.length, choresActive, done24h, failed24h },
|
|
2275
2771
|
attention,
|
|
2276
2772
|
stories,
|
|
2773
|
+
watches,
|
|
2277
2774
|
merge
|
|
2278
2775
|
};
|
|
2279
2776
|
}
|
|
@@ -2309,9 +2806,26 @@ function renderTend(r) {
|
|
|
2309
2806
|
key: s.key,
|
|
2310
2807
|
dispatches: s.dispatches.map((d) => `${d.id.slice(0, 8)}:${d.state}`).join(" \u2192 "),
|
|
2311
2808
|
pr: s.prUrl ?? "",
|
|
2312
|
-
gate: s.gate ?? ""
|
|
2809
|
+
gate: s.gate ?? "",
|
|
2810
|
+
watch: s.watch ?? ""
|
|
2811
|
+
})),
|
|
2812
|
+
["key", "dispatches", "pr", "gate", "watch"]
|
|
2813
|
+
)
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
if (r.watches.length > 0) {
|
|
2817
|
+
lines.push("");
|
|
2818
|
+
lines.push(
|
|
2819
|
+
toonTable(
|
|
2820
|
+
"watches",
|
|
2821
|
+
r.watches.map((w) => ({
|
|
2822
|
+
key: w.key,
|
|
2823
|
+
owner: w.owner,
|
|
2824
|
+
pending: w.pendingEvents,
|
|
2825
|
+
last: w.lastSummary ?? "",
|
|
2826
|
+
error: w.error ?? ""
|
|
2313
2827
|
})),
|
|
2314
|
-
["key", "
|
|
2828
|
+
["key", "owner", "pending", "last", "error"]
|
|
2315
2829
|
)
|
|
2316
2830
|
);
|
|
2317
2831
|
}
|
|
@@ -2332,13 +2846,13 @@ function renderTend(r) {
|
|
|
2332
2846
|
}
|
|
2333
2847
|
|
|
2334
2848
|
// apps/cli/src/cull.ts
|
|
2335
|
-
import * as
|
|
2336
|
-
import * as
|
|
2849
|
+
import * as fs21 from "node:fs";
|
|
2850
|
+
import * as path16 from "node:path";
|
|
2337
2851
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2338
2852
|
var DAY = 864e5;
|
|
2339
2853
|
function idsIn(dir) {
|
|
2340
2854
|
try {
|
|
2341
|
-
return new Set(
|
|
2855
|
+
return new Set(fs21.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
|
|
2342
2856
|
} catch {
|
|
2343
2857
|
return /* @__PURE__ */ new Set();
|
|
2344
2858
|
}
|
|
@@ -2353,39 +2867,39 @@ function planCull(olderThanDays, now = Date.now()) {
|
|
|
2353
2867
|
for (const id of idsIn(d.queue)) live.add(id);
|
|
2354
2868
|
for (const id of idsIn(d.active)) live.add(id);
|
|
2355
2869
|
for (const id of idsIn(d.done)) {
|
|
2356
|
-
const p =
|
|
2357
|
-
const m =
|
|
2870
|
+
const p = path16.join(d.done, id);
|
|
2871
|
+
const m = fs21.statSync(p).mtimeMs;
|
|
2358
2872
|
doneMtimes.set(id, m);
|
|
2359
2873
|
if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
|
|
2360
2874
|
}
|
|
2361
2875
|
}
|
|
2362
|
-
const wtRoot =
|
|
2876
|
+
const wtRoot = path16.join(lobstahHome(), "worktrees");
|
|
2363
2877
|
for (const id of idsIn(wtRoot)) {
|
|
2364
2878
|
if (live.has(id)) continue;
|
|
2365
2879
|
const doneAt = doneMtimes.get(id);
|
|
2366
2880
|
if (doneAt !== void 0 && doneAt >= cutoff) continue;
|
|
2367
|
-
const p =
|
|
2368
|
-
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now -
|
|
2881
|
+
const p = path16.join(wtRoot, id);
|
|
2882
|
+
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs21.statSync(p).mtimeMs) / DAY) });
|
|
2369
2883
|
}
|
|
2370
2884
|
for (const lane of ["work", "chore"]) {
|
|
2371
2885
|
const d = laneDirs(lane);
|
|
2372
2886
|
const seen = /* @__PURE__ */ new Set();
|
|
2373
|
-
for (const f of
|
|
2887
|
+
for (const f of fs21.readdirSync(d.state)) {
|
|
2374
2888
|
const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
|
|
2375
2889
|
if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
|
|
2376
|
-
const m =
|
|
2890
|
+
const m = fs21.statSync(path16.join(d.state, f)).mtimeMs;
|
|
2377
2891
|
if (m >= cutoff) continue;
|
|
2378
2892
|
seen.add(id);
|
|
2379
|
-
items.push({ kind: "state", id, target:
|
|
2893
|
+
items.push({ kind: "state", id, target: path16.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
|
|
2380
2894
|
}
|
|
2381
2895
|
}
|
|
2382
2896
|
return items;
|
|
2383
2897
|
}
|
|
2384
2898
|
function removeWorktree(id, dir) {
|
|
2385
2899
|
for (const lane of ["work", "chore"]) {
|
|
2386
|
-
const descFile =
|
|
2900
|
+
const descFile = path16.join(laneDirs(lane).done, id, "descriptor.json");
|
|
2387
2901
|
try {
|
|
2388
|
-
const desc = JSON.parse(
|
|
2902
|
+
const desc = JSON.parse(fs21.readFileSync(descFile, "utf8"));
|
|
2389
2903
|
const repo = loadConfig().repos[desc.repo];
|
|
2390
2904
|
if (repo) {
|
|
2391
2905
|
execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
|
|
@@ -2394,15 +2908,15 @@ function removeWorktree(id, dir) {
|
|
|
2394
2908
|
} catch {
|
|
2395
2909
|
}
|
|
2396
2910
|
}
|
|
2397
|
-
|
|
2911
|
+
fs21.rmSync(dir, { recursive: true, force: true });
|
|
2398
2912
|
}
|
|
2399
2913
|
function applyCull(items) {
|
|
2400
2914
|
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"))
|
|
2915
|
+
for (const item of items.filter((i) => i.kind === "done")) fs21.rmSync(item.target, { recursive: true, force: true });
|
|
2402
2916
|
for (const item of items.filter((i) => i.kind === "state")) {
|
|
2403
|
-
const dir =
|
|
2404
|
-
for (const f of
|
|
2405
|
-
if (f.startsWith(`${item.id}.`))
|
|
2917
|
+
const dir = path16.dirname(item.target);
|
|
2918
|
+
for (const f of fs21.readdirSync(dir)) {
|
|
2919
|
+
if (f.startsWith(`${item.id}.`)) fs21.rmSync(path16.join(dir, f), { force: true });
|
|
2406
2920
|
}
|
|
2407
2921
|
}
|
|
2408
2922
|
}
|
|
@@ -2428,6 +2942,10 @@ your working set:
|
|
|
2428
2942
|
lobstah man tend [--json] tend the string: fleet verdict,
|
|
2429
2943
|
unanswered questions, each work
|
|
2430
2944
|
item's chain + PR + merge gate
|
|
2945
|
+
lobstah watch add <key> --check <cmd> stand watch on something
|
|
2946
|
+
external (a review session, a
|
|
2947
|
+
CI run) \u2014 its events wake you
|
|
2948
|
+
like any dispatch would
|
|
2431
2949
|
|
|
2432
2950
|
getting woken instead of asking:
|
|
2433
2951
|
lobstah man wait block until a dispatch needs attention (arm it as a
|
|
@@ -2446,6 +2964,275 @@ paste into your liaison instructions:
|
|
|
2446
2964
|
|
|
2447
2965
|
full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
|
|
2448
2966
|
|
|
2967
|
+
// apps/cli/src/doctor.ts
|
|
2968
|
+
import * as fs22 from "node:fs";
|
|
2969
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
2970
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
2971
|
+
var HEARTBEAT_STALE_MS2 = 9e4;
|
|
2972
|
+
function git(repoPath, ...args) {
|
|
2973
|
+
const res = spawnSync2("git", ["-C", repoPath, ...args], { encoding: "utf8" });
|
|
2974
|
+
return { ok: res.status === 0, out: (res.stdout ?? "").trim() || (res.stderr ?? "").trim() };
|
|
2975
|
+
}
|
|
2976
|
+
function runDoctor(now = Date.now()) {
|
|
2977
|
+
const rows = [];
|
|
2978
|
+
const push = (check, status, detail) => rows.push({ check, status, detail });
|
|
2979
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
2980
|
+
push("node", major >= 20 ? "ok" : "fail", `v${process.versions.node}${major >= 20 ? "" : " \u2014 lobstah needs >=20"}`);
|
|
2981
|
+
push("git", onPath("git") ? "ok" : "fail", onPath("git") ? "on PATH" : "not on PATH");
|
|
2982
|
+
const claude = onPath("claude");
|
|
2983
|
+
push("harness claude", claude ? "ok" : "warn", claude ? "on PATH" : "claude not on PATH \u2014 claude dispatches will fail");
|
|
2984
|
+
const codexPath = onPath("codex");
|
|
2985
|
+
let codexSdk = false;
|
|
2986
|
+
try {
|
|
2987
|
+
createRequire3(import.meta.url).resolve("@openai/codex-sdk");
|
|
2988
|
+
codexSdk = true;
|
|
2989
|
+
} catch {
|
|
2990
|
+
}
|
|
2991
|
+
push(
|
|
2992
|
+
"harness codex",
|
|
2993
|
+
codexPath || codexSdk ? "ok" : "warn",
|
|
2994
|
+
codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
|
|
2995
|
+
);
|
|
2996
|
+
const cfgFile = configPath();
|
|
2997
|
+
if (!fs22.existsSync(cfgFile)) {
|
|
2998
|
+
push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
|
|
2999
|
+
return rows;
|
|
3000
|
+
}
|
|
3001
|
+
let parsed;
|
|
3002
|
+
try {
|
|
3003
|
+
parsed = parse(fs22.readFileSync(cfgFile, "utf8"));
|
|
3004
|
+
push("config", "ok", cfgFile);
|
|
3005
|
+
} catch (err) {
|
|
3006
|
+
push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3007
|
+
return rows;
|
|
3008
|
+
}
|
|
3009
|
+
const cfg = loadConfig();
|
|
3010
|
+
const repoKeys = Object.keys(cfg.repos);
|
|
3011
|
+
if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
|
|
3012
|
+
for (const [key, repo] of Object.entries(cfg.repos)) {
|
|
3013
|
+
const label = `repo ${key}`;
|
|
3014
|
+
if (!fs22.existsSync(repo.path)) {
|
|
3015
|
+
push(label, "fail", `path ${repo.path} does not exist`);
|
|
3016
|
+
continue;
|
|
3017
|
+
}
|
|
3018
|
+
if (!git(repo.path, "rev-parse", "--is-inside-work-tree").ok) {
|
|
3019
|
+
push(label, "fail", `${repo.path} is not a git repository`);
|
|
3020
|
+
continue;
|
|
3021
|
+
}
|
|
3022
|
+
const trunk = git(repo.path, "rev-parse", "--verify", "--quiet", `origin/${repo.trunk}`);
|
|
3023
|
+
push(
|
|
3024
|
+
label,
|
|
3025
|
+
trunk.ok ? "ok" : "warn",
|
|
3026
|
+
trunk.ok ? `${repo.path} (trunk origin/${repo.trunk})` : `origin/${repo.trunk} not found \u2014 check trunk or \`git fetch\``
|
|
3027
|
+
);
|
|
3028
|
+
}
|
|
3029
|
+
if (parsed.pickup) {
|
|
3030
|
+
try {
|
|
3031
|
+
const pk = loadPickupConfig();
|
|
3032
|
+
const sources = [
|
|
3033
|
+
...pk.github.map((g) => `gh:${g.repo}\u2192${g.key}`),
|
|
3034
|
+
...pk.linear ? ["linear"] : []
|
|
3035
|
+
];
|
|
3036
|
+
push("pickup", sources.length > 0 ? "ok" : "warn", sources.join(", ") || "section present but no sources");
|
|
3037
|
+
} catch (err) {
|
|
3038
|
+
push("pickup", "fail", err instanceof Error ? err.message : String(err));
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
const hb = executorPath();
|
|
3042
|
+
if (!fs22.existsSync(hb)) {
|
|
3043
|
+
push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
|
|
3044
|
+
} else {
|
|
3045
|
+
try {
|
|
3046
|
+
const payload = JSON.parse(fs22.readFileSync(hb, "utf8"));
|
|
3047
|
+
const age = now - new Date(payload.heartbeat ?? 0).getTime();
|
|
3048
|
+
push(
|
|
3049
|
+
"daemon",
|
|
3050
|
+
age < HEARTBEAT_STALE_MS2 ? "ok" : "warn",
|
|
3051
|
+
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`
|
|
3052
|
+
);
|
|
3053
|
+
} catch {
|
|
3054
|
+
push("daemon", "warn", "heartbeat unreadable");
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
push("lobstah", "ok", `v${lobstahVersion()} at ${process.argv[1] ?? "?"}`);
|
|
3058
|
+
return rows;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// apps/cli/src/service.ts
|
|
3062
|
+
import * as fs23 from "node:fs";
|
|
3063
|
+
import * as os5 from "node:os";
|
|
3064
|
+
import * as path17 from "node:path";
|
|
3065
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
3066
|
+
function xml(s) {
|
|
3067
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
3068
|
+
}
|
|
3069
|
+
function renderLaunchdPlist(spec) {
|
|
3070
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
3071
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3072
|
+
<plist version="1.0">
|
|
3073
|
+
<dict>
|
|
3074
|
+
<key>Label</key><string>lobstah.${spec.kind}</string>
|
|
3075
|
+
<key>ProgramArguments</key>
|
|
3076
|
+
<array>
|
|
3077
|
+
<string>${xml(spec.nodePath)}</string>
|
|
3078
|
+
<string>${xml(spec.entry)}</string>
|
|
3079
|
+
<string>${spec.kind}</string>
|
|
3080
|
+
</array>
|
|
3081
|
+
<key>EnvironmentVariables</key>
|
|
3082
|
+
<dict>
|
|
3083
|
+
<key>PATH</key><string>${xml(spec.pathEnv)}</string>
|
|
3084
|
+
<key>LOBSTAH_HOME</key><string>${xml(spec.home)}</string>
|
|
3085
|
+
</dict>
|
|
3086
|
+
<key>RunAtLoad</key><true/>
|
|
3087
|
+
<key>KeepAlive</key><true/>
|
|
3088
|
+
<key>StandardOutPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
|
|
3089
|
+
<key>StandardErrorPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
|
|
3090
|
+
</dict>
|
|
3091
|
+
</plist>
|
|
3092
|
+
`;
|
|
3093
|
+
}
|
|
3094
|
+
function renderSystemdUnit(spec) {
|
|
3095
|
+
return `[Unit]
|
|
3096
|
+
Description=lobstah ${spec.kind}
|
|
3097
|
+
|
|
3098
|
+
[Service]
|
|
3099
|
+
ExecStart=${spec.nodePath} ${spec.entry} ${spec.kind}
|
|
3100
|
+
Restart=always
|
|
3101
|
+
RestartSec=5
|
|
3102
|
+
Environment=PATH=${spec.pathEnv}
|
|
3103
|
+
Environment=LOBSTAH_HOME=${spec.home}
|
|
3104
|
+
StandardOutput=append:${path17.posix.join(spec.logDir, `${spec.kind}.log`)}
|
|
3105
|
+
StandardError=append:${path17.posix.join(spec.logDir, `${spec.kind}.err`)}
|
|
3106
|
+
|
|
3107
|
+
[Install]
|
|
3108
|
+
WantedBy=default.target
|
|
3109
|
+
`;
|
|
3110
|
+
}
|
|
3111
|
+
function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
3112
|
+
const dirs = [
|
|
3113
|
+
path17.posix.dirname(nodePath),
|
|
3114
|
+
path17.posix.join(home, ".local", "bin"),
|
|
3115
|
+
"/opt/homebrew/bin",
|
|
3116
|
+
"/usr/local/bin",
|
|
3117
|
+
"/usr/bin",
|
|
3118
|
+
"/bin"
|
|
3119
|
+
];
|
|
3120
|
+
return [...new Set(dirs)].join(":");
|
|
3121
|
+
}
|
|
3122
|
+
function serviceFile(kind) {
|
|
3123
|
+
return process.platform === "darwin" ? path17.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path17.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
|
|
3124
|
+
}
|
|
3125
|
+
function run(cmd, args) {
|
|
3126
|
+
const res = spawnSync3(cmd, args, { encoding: "utf8" });
|
|
3127
|
+
return { ok: res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
|
|
3128
|
+
}
|
|
3129
|
+
function installService(kind) {
|
|
3130
|
+
if (process.platform === "win32") {
|
|
3131
|
+
throw new Error(`no service manager support on Windows \u2014 run \`lobstah ${kind}\` under your process manager of choice`);
|
|
3132
|
+
}
|
|
3133
|
+
const entry = fs23.realpathSync(process.argv[1]);
|
|
3134
|
+
const spec = {
|
|
3135
|
+
kind,
|
|
3136
|
+
nodePath: process.execPath,
|
|
3137
|
+
entry,
|
|
3138
|
+
pathEnv: servicePathEnv(process.execPath),
|
|
3139
|
+
home: lobstahHome(),
|
|
3140
|
+
logDir: path17.join(lobstahHome(), "logs")
|
|
3141
|
+
};
|
|
3142
|
+
if (!onPath("git", spec.pathEnv)) {
|
|
3143
|
+
throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
|
|
3144
|
+
}
|
|
3145
|
+
fs23.mkdirSync(spec.logDir, { recursive: true });
|
|
3146
|
+
const file = serviceFile(kind);
|
|
3147
|
+
fs23.mkdirSync(path17.dirname(file), { recursive: true });
|
|
3148
|
+
if (process.platform === "darwin") {
|
|
3149
|
+
if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3150
|
+
fs23.writeFileSync(file, renderLaunchdPlist(spec));
|
|
3151
|
+
const load = run("launchctl", ["load", file]);
|
|
3152
|
+
return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
|
|
3153
|
+
}
|
|
3154
|
+
fs23.writeFileSync(file, renderSystemdUnit(spec));
|
|
3155
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
3156
|
+
const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
|
|
3157
|
+
return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
|
|
3158
|
+
}
|
|
3159
|
+
function uninstallService(kind) {
|
|
3160
|
+
const file = serviceFile(kind);
|
|
3161
|
+
if (process.platform === "darwin") {
|
|
3162
|
+
if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3163
|
+
} else {
|
|
3164
|
+
run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
|
|
3165
|
+
}
|
|
3166
|
+
const removed = fs23.existsSync(file);
|
|
3167
|
+
if (removed) fs23.unlinkSync(file);
|
|
3168
|
+
if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
|
|
3169
|
+
return { file, removed };
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
// apps/cli/src/repos.ts
|
|
3173
|
+
import * as fs24 from "node:fs";
|
|
3174
|
+
import * as path18 from "node:path";
|
|
3175
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
3176
|
+
function git2(dir, ...args) {
|
|
3177
|
+
const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
3178
|
+
return res.status === 0 ? res.stdout.trim() : void 0;
|
|
3179
|
+
}
|
|
3180
|
+
var LOCKFILE_SETUP = [
|
|
3181
|
+
["pnpm-lock.yaml", ["pnpm install"]],
|
|
3182
|
+
["package-lock.json", ["npm install"]],
|
|
3183
|
+
["yarn.lock", ["yarn install"]],
|
|
3184
|
+
["bun.lockb", ["bun install"]],
|
|
3185
|
+
["bun.lock", ["bun install"]],
|
|
3186
|
+
["uv.lock", ["uv sync"]],
|
|
3187
|
+
["poetry.lock", ["poetry install"]]
|
|
3188
|
+
];
|
|
3189
|
+
function detectRepo(dir) {
|
|
3190
|
+
const abs = path18.resolve(dir);
|
|
3191
|
+
if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
|
|
3192
|
+
const top = git2(abs, "rev-parse", "--show-toplevel");
|
|
3193
|
+
const canon = (p) => {
|
|
3194
|
+
const real = path18.resolve(fs24.realpathSync.native(p));
|
|
3195
|
+
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
3196
|
+
};
|
|
3197
|
+
if (!top || canon(top) !== canon(abs)) return void 0;
|
|
3198
|
+
const origin = git2(abs, "remote", "get-url", "origin");
|
|
3199
|
+
const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
3200
|
+
const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
|
|
3201
|
+
const setup = LOCKFILE_SETUP.find(([f]) => fs24.existsSync(path18.join(abs, f)))?.[1];
|
|
3202
|
+
return { key: path18.basename(abs), path: abs, trunk, origin, setup };
|
|
3203
|
+
}
|
|
3204
|
+
function scanForRepos(roots) {
|
|
3205
|
+
const found = [];
|
|
3206
|
+
for (const root of roots) {
|
|
3207
|
+
const abs = path18.resolve(root);
|
|
3208
|
+
if (!fs24.existsSync(abs)) continue;
|
|
3209
|
+
const self = detectRepo(abs);
|
|
3210
|
+
if (self) {
|
|
3211
|
+
found.push(self);
|
|
3212
|
+
continue;
|
|
3213
|
+
}
|
|
3214
|
+
for (const entry of fs24.readdirSync(abs, { withFileTypes: true })) {
|
|
3215
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
3216
|
+
const child = detectRepo(path18.join(abs, entry.name));
|
|
3217
|
+
if (child) found.push(child);
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
return found;
|
|
3221
|
+
}
|
|
3222
|
+
function configuredRepoKeys(file = configPath()) {
|
|
3223
|
+
if (!fs24.existsSync(file)) return /* @__PURE__ */ new Set();
|
|
3224
|
+
const raw = parse(fs24.readFileSync(file, "utf8"));
|
|
3225
|
+
return new Set(Object.keys(raw.repos ?? {}));
|
|
3226
|
+
}
|
|
3227
|
+
function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
3228
|
+
const lines = [``, `[repos.${repo.key}]`, `path = ${JSON.stringify(repo.path)}`, `trunk = ${JSON.stringify(repo.trunk)}`];
|
|
3229
|
+
if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
|
|
3230
|
+
if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
|
|
3231
|
+
if (opts.pickup) lines.push(`pickup = true`);
|
|
3232
|
+
fs24.appendFileSync(file, `${lines.join("\n")}
|
|
3233
|
+
`);
|
|
3234
|
+
}
|
|
3235
|
+
|
|
2449
3236
|
// apps/cli/src/main.ts
|
|
2450
3237
|
var HELP = `lobstah \u2014 supervision framework for coding agents
|
|
2451
3238
|
|
|
@@ -2473,12 +3260,25 @@ work (humans and agents):
|
|
|
2473
3260
|
entries, orphaned worktrees, stale state.
|
|
2474
3261
|
Dry run by default (14 days).
|
|
2475
3262
|
cancel <uuid> request cancellation
|
|
3263
|
+
watch [add <key> --check <cmd> [--for <uuid>] [--cursor <c>] [--every <s>]
|
|
3264
|
+
[--brief <template>] [--stream <cmd>] | rm <key>]
|
|
3265
|
+
stand watch on something external: the check
|
|
3266
|
+
command answers "anything since {cursor}?"
|
|
3267
|
+
in JSON. Events wake man wait/haul (default)
|
|
3268
|
+
or fork a continuation of --for's dispatch
|
|
3269
|
+
chain. --stream holds a long-lived NDJSON
|
|
3270
|
+
child under pick for ms-latency delivery
|
|
3271
|
+
(the check stays the guarantee). Bare
|
|
3272
|
+
\`watch\` lists.
|
|
2476
3273
|
|
|
2477
|
-
host processes
|
|
3274
|
+
host processes:
|
|
2478
3275
|
daemon [--interval <ms>] the supervisor: claims, worktrees, liveness,
|
|
2479
3276
|
restart ladder, notifyCommand. One per home.
|
|
2480
3277
|
pick [once] tracker loops: poll Linear/GitHub, dispatch
|
|
2481
3278
|
assigned work, report back, reconcile, merge
|
|
3279
|
+
daemon install|uninstall write + load the launchd agent / systemd user
|
|
3280
|
+
pick install|uninstall unit for this host, with resolved node and
|
|
3281
|
+
lobstah paths (launchd gets no shell env)
|
|
2482
3282
|
|
|
2483
3283
|
lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual):
|
|
2484
3284
|
man tend [--json] tend the whole string: fleet verdict (daemon
|
|
@@ -2487,14 +3287,19 @@ lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual)
|
|
|
2487
3287
|
PR, and merge-gate status from pick's last
|
|
2488
3288
|
observation. Pure disk read \u2014 no forge calls.
|
|
2489
3289
|
man wait [--timeout <secs>] [--peek]
|
|
2490
|
-
block until a dispatch
|
|
2491
|
-
print the event and
|
|
2492
|
-
on timeout.
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
3290
|
+
block until a dispatch or watched source
|
|
3291
|
+
needs attention, then print the event and
|
|
3292
|
+
what to do next; exit 2 on timeout. Runs due
|
|
3293
|
+
watch checks itself when no pick process is.
|
|
3294
|
+
Unanswered questions re-fire every
|
|
3295
|
+
remindSecs until answered.
|
|
3296
|
+
man init [--shared|--global] [--marker]
|
|
3297
|
+
install the haul Stop hook: this project's
|
|
3298
|
+
.claude/settings.local.json by default,
|
|
3299
|
+
settings.json with --shared, or once into
|
|
3300
|
+
~/.claude/settings.json with --global (any
|
|
3301
|
+
directory with a .lobstah-man file then
|
|
3302
|
+
parks); --marker touches .lobstah-man.
|
|
2498
3303
|
man haul [--timeout <secs>] Stop-hook entry point: park the session while
|
|
2499
3304
|
work is in flight; prints hook-decision JSON
|
|
2500
3305
|
on an event, silent exit 0 otherwise. Gate:
|
|
@@ -2506,7 +3311,16 @@ workers (dispatched agents; injected into every brief):
|
|
|
2506
3311
|
(${VERBS.join(" | ")})
|
|
2507
3312
|
|
|
2508
3313
|
setup:
|
|
2509
|
-
init
|
|
3314
|
+
init [--scan <dir>... [--pickup]]
|
|
3315
|
+
create ~/.lobstah + example config; --scan
|
|
3316
|
+
detects git repos under the given roots and
|
|
3317
|
+
appends a [repos.*] block per repo (--pickup
|
|
3318
|
+
marks them pickable by [pickup.github])
|
|
3319
|
+
repos [add <path> [--pickup] [--key <k>]]
|
|
3320
|
+
list configured repos, or detect + append one
|
|
3321
|
+
doctor check binaries, config, repos, harnesses, and
|
|
3322
|
+
the daemon heartbeat; exit 1 on failures
|
|
3323
|
+
version | --version the installed lobstah version
|
|
2510
3324
|
|
|
2511
3325
|
Everything except daemon and pick works with both stopped: writes are files,
|
|
2512
3326
|
reads are files. Output is TOON; agents can drive this CLI directly.
|
|
@@ -2515,10 +3329,26 @@ function arg(args, flag) {
|
|
|
2515
3329
|
const i = args.indexOf(flag);
|
|
2516
3330
|
return i >= 0 ? args[i + 1] : void 0;
|
|
2517
3331
|
}
|
|
3332
|
+
var WATCH_EVERY_SECS = 45;
|
|
3333
|
+
function runDueManWatches() {
|
|
3334
|
+
for (const w of listWatches()) {
|
|
3335
|
+
if (w.owner === "man" && watchDue(w, WATCH_EVERY_SECS)) runWatchCheck(w);
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
function emitWatchAttention(attns) {
|
|
3339
|
+
for (const a of attns) {
|
|
3340
|
+
for (const e of a.events) {
|
|
3341
|
+
console.log(toonKV({ watch: a.watch.key, seq: e.seq, summary: e.summary }));
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
console.log(
|
|
3345
|
+
"next: handle the watched update now (a review round, a finished run \u2014 `lobstah watch ls` for context), then re-arm a background `lobstah man wait`."
|
|
3346
|
+
);
|
|
3347
|
+
}
|
|
2518
3348
|
function findLane(id) {
|
|
2519
3349
|
for (const lane of ["work", "chore"]) {
|
|
2520
3350
|
const d = laneDirs(lane);
|
|
2521
|
-
if (
|
|
3351
|
+
if (fs25.existsSync(path19.join(d.active, id)) || fs25.existsSync(path19.join(d.queue, `${id}.json`)) || fs25.existsSync(path19.join(d.done, id)) || fs25.existsSync(path19.join(d.state, `${id}.status`))) {
|
|
2522
3352
|
return lane;
|
|
2523
3353
|
}
|
|
2524
3354
|
}
|
|
@@ -2526,7 +3356,7 @@ function findLane(id) {
|
|
|
2526
3356
|
}
|
|
2527
3357
|
function rowsFor(lane, bucket) {
|
|
2528
3358
|
const dir = laneDirs(lane)[bucket];
|
|
2529
|
-
const entries =
|
|
3359
|
+
const entries = fs25.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs25.statSync(path19.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
|
|
2530
3360
|
const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
|
|
2531
3361
|
return sliced.map(({ f, m }) => {
|
|
2532
3362
|
const id = f.replace(/\.json$/, "");
|
|
@@ -2552,9 +3382,9 @@ async function mainCli() {
|
|
|
2552
3382
|
throw new Error("dispatch requires --repo and --brief <file> (or --brief-text)");
|
|
2553
3383
|
}
|
|
2554
3384
|
const d = {
|
|
2555
|
-
id: arg(args, "--id") ??
|
|
3385
|
+
id: arg(args, "--id") ?? randomUUID4(),
|
|
2556
3386
|
repo,
|
|
2557
|
-
brief: briefText ??
|
|
3387
|
+
brief: briefText ?? fs25.readFileSync(briefFile, "utf8"),
|
|
2558
3388
|
harness: arg(args, "--harness"),
|
|
2559
3389
|
model: arg(args, "--model"),
|
|
2560
3390
|
effort: arg(args, "--effort"),
|
|
@@ -2591,17 +3421,17 @@ async function mainCli() {
|
|
|
2591
3421
|
if (!id) throw new Error("logs requires a dispatch id");
|
|
2592
3422
|
const lane = findLane(id);
|
|
2593
3423
|
const file = eventsPath(id, lane);
|
|
2594
|
-
if (
|
|
3424
|
+
if (fs25.existsSync(file)) process.stdout.write(fs25.readFileSync(file, "utf8"));
|
|
2595
3425
|
if (args.includes("--follow")) {
|
|
2596
|
-
let size =
|
|
3426
|
+
let size = fs25.existsSync(file) ? fs25.statSync(file).size : 0;
|
|
2597
3427
|
setInterval(() => {
|
|
2598
|
-
if (!
|
|
2599
|
-
const now =
|
|
3428
|
+
if (!fs25.existsSync(file)) return;
|
|
3429
|
+
const now = fs25.statSync(file).size;
|
|
2600
3430
|
if (now > size) {
|
|
2601
|
-
const fd =
|
|
3431
|
+
const fd = fs25.openSync(file, "r");
|
|
2602
3432
|
const buf = Buffer.alloc(now - size);
|
|
2603
|
-
|
|
2604
|
-
|
|
3433
|
+
fs25.readSync(fd, buf, 0, buf.length, size);
|
|
3434
|
+
fs25.closeSync(fd);
|
|
2605
3435
|
process.stdout.write(buf.toString("utf8"));
|
|
2606
3436
|
size = now;
|
|
2607
3437
|
}
|
|
@@ -2657,22 +3487,23 @@ async function mainCli() {
|
|
|
2657
3487
|
);
|
|
2658
3488
|
}
|
|
2659
3489
|
const dirs = laneDirs(lane);
|
|
2660
|
-
const descriptorFile = ["active", "done"].map((b) =>
|
|
2661
|
-
const harness = descriptorFile ? JSON.parse(
|
|
3490
|
+
const descriptorFile = ["active", "done"].map((b) => path19.join(dirs[b], id, "descriptor.json")).find((f) => fs25.existsSync(f));
|
|
3491
|
+
const harness = descriptorFile ? JSON.parse(fs25.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
|
|
2662
3492
|
let sessionId;
|
|
2663
3493
|
try {
|
|
2664
|
-
sessionId = JSON.parse(
|
|
3494
|
+
sessionId = JSON.parse(fs25.readFileSync(path19.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
|
|
2665
3495
|
} catch {
|
|
2666
3496
|
}
|
|
2667
3497
|
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" ?
|
|
3498
|
+
const worktree = path19.join(lobstahHome(), "worktrees", id);
|
|
3499
|
+
const cwd = fs25.existsSync(worktree) ? worktree : process.cwd();
|
|
3500
|
+
const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
|
|
3501
|
+
if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
|
|
2671
3502
|
if (args.includes("--print")) {
|
|
2672
3503
|
console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
|
|
2673
3504
|
break;
|
|
2674
3505
|
}
|
|
2675
|
-
const res =
|
|
3506
|
+
const res = spawnSync5(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
|
|
2676
3507
|
if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
|
|
2677
3508
|
break;
|
|
2678
3509
|
}
|
|
@@ -2680,39 +3511,39 @@ async function mainCli() {
|
|
|
2680
3511
|
const id = args[0];
|
|
2681
3512
|
if (!id) throw new Error("swap requires a dispatch id");
|
|
2682
3513
|
const lane = findLane(id);
|
|
2683
|
-
const activeDir =
|
|
2684
|
-
if (!
|
|
2685
|
-
const descFile =
|
|
2686
|
-
const descriptor = JSON.parse(
|
|
3514
|
+
const activeDir = path19.join(laneDirs(lane).active, id);
|
|
3515
|
+
if (!fs25.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
|
|
3516
|
+
const descFile = path19.join(activeDir, "descriptor.json");
|
|
3517
|
+
const descriptor = JSON.parse(fs25.readFileSync(descFile, "utf8"));
|
|
2687
3518
|
const fromHarness = descriptor.harness ?? "default";
|
|
2688
3519
|
for (const key of ["harness", "model", "effort"]) {
|
|
2689
3520
|
const v = arg(args, `--${key}`);
|
|
2690
3521
|
if (v) descriptor[key] = v;
|
|
2691
3522
|
}
|
|
2692
|
-
|
|
2693
|
-
const worktree =
|
|
3523
|
+
fs25.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
|
|
3524
|
+
const worktree = path19.join(lobstahHome(), "worktrees", id);
|
|
2694
3525
|
let progress = "No worktree progress recorded.";
|
|
2695
|
-
if (
|
|
2696
|
-
const
|
|
2697
|
-
const commits =
|
|
2698
|
-
const status =
|
|
3526
|
+
if (fs25.existsSync(worktree)) {
|
|
3527
|
+
const git3 = (...a) => spawnSync5("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
3528
|
+
const commits = git3("log", "--oneline", "-15");
|
|
3529
|
+
const status = git3("status", "--short");
|
|
2699
3530
|
progress = `Commits so far:
|
|
2700
3531
|
${commits || "(none)"}
|
|
2701
3532
|
|
|
2702
3533
|
Uncommitted changes:
|
|
2703
3534
|
${status || "(clean)"}`;
|
|
2704
3535
|
}
|
|
2705
|
-
|
|
2706
|
-
|
|
3536
|
+
fs25.writeFileSync(
|
|
3537
|
+
path19.join(activeDir, "handoff"),
|
|
2707
3538
|
`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
3539
|
|
|
2709
3540
|
${progress}`
|
|
2710
3541
|
);
|
|
2711
|
-
const runnerFile =
|
|
2712
|
-
if (
|
|
2713
|
-
const runner = JSON.parse(
|
|
3542
|
+
const runnerFile = path19.join(activeDir, "runner.json");
|
|
3543
|
+
if (fs25.existsSync(runnerFile)) {
|
|
3544
|
+
const runner = JSON.parse(fs25.readFileSync(runnerFile, "utf8"));
|
|
2714
3545
|
if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
|
|
2715
|
-
|
|
3546
|
+
fs25.unlinkSync(runnerFile);
|
|
2716
3547
|
}
|
|
2717
3548
|
appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
|
|
2718
3549
|
console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
|
|
@@ -2788,9 +3619,13 @@ ${progress}`
|
|
|
2788
3619
|
);
|
|
2789
3620
|
};
|
|
2790
3621
|
const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
|
|
2791
|
-
const
|
|
2792
|
-
|
|
2793
|
-
|
|
3622
|
+
const consume = !args.includes("--peek");
|
|
3623
|
+
runDueManWatches();
|
|
3624
|
+
const standing = attentionNow(consume, remindMs);
|
|
3625
|
+
const standingWatches = pendingWatchEvents(consume);
|
|
3626
|
+
if (standing.length > 0 || standingWatches.length > 0) {
|
|
3627
|
+
if (standing.length > 0) emit(standing);
|
|
3628
|
+
if (standingWatches.length > 0) emitWatchAttention(standingWatches);
|
|
2794
3629
|
break;
|
|
2795
3630
|
}
|
|
2796
3631
|
const baseline = captureWaitBaseline();
|
|
@@ -2801,64 +3636,79 @@ ${progress}`
|
|
|
2801
3636
|
emit(fresh);
|
|
2802
3637
|
return;
|
|
2803
3638
|
}
|
|
3639
|
+
runDueManWatches();
|
|
3640
|
+
const watched = pendingWatchEvents(true);
|
|
3641
|
+
if (watched.length > 0) {
|
|
3642
|
+
emitWatchAttention(watched);
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
2804
3645
|
}
|
|
2805
3646
|
console.log(toonKV({ timeout: true, waitedSecs: timeoutSecs }));
|
|
2806
3647
|
process.exitCode = 2;
|
|
2807
3648
|
break;
|
|
2808
3649
|
}
|
|
2809
3650
|
case "man:init": {
|
|
2810
|
-
const file =
|
|
3651
|
+
const file = args.includes("--global") ? path19.join(os6.homedir(), ".claude", "settings.json") : path19.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
|
|
2811
3652
|
let existing;
|
|
2812
3653
|
try {
|
|
2813
|
-
existing = JSON.parse(
|
|
3654
|
+
existing = JSON.parse(fs25.readFileSync(file, "utf8"));
|
|
2814
3655
|
} catch {
|
|
2815
3656
|
existing = void 0;
|
|
2816
3657
|
}
|
|
2817
3658
|
const { settings, changed } = mergeHaulHook(existing);
|
|
2818
3659
|
if (changed) {
|
|
2819
|
-
|
|
2820
|
-
|
|
3660
|
+
fs25.mkdirSync(path19.dirname(file), { recursive: true });
|
|
3661
|
+
fs25.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
2821
3662
|
`);
|
|
2822
3663
|
}
|
|
2823
|
-
if (args.includes("--marker"))
|
|
3664
|
+
if (args.includes("--marker")) fs25.writeFileSync(".lobstah-man", "");
|
|
2824
3665
|
console.log(
|
|
2825
3666
|
toonKV({
|
|
2826
3667
|
hook: "lobstah man haul",
|
|
2827
3668
|
file,
|
|
2828
3669
|
installed: changed || "already present",
|
|
2829
|
-
gate: args.includes("--marker") ? ".lobstah-man (this directory)" : "launch with LOBSTAH_MAN=1 claude"
|
|
3670
|
+
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
3671
|
})
|
|
2831
3672
|
);
|
|
2832
3673
|
break;
|
|
2833
3674
|
}
|
|
2834
3675
|
case "man:haul": {
|
|
2835
3676
|
try {
|
|
2836
|
-
if (process.env.LOBSTAH_MAN !== "1" && !
|
|
3677
|
+
if (process.env.LOBSTAH_MAN !== "1" && !fs25.existsSync(".lobstah-man")) break;
|
|
2837
3678
|
const anyActive = ["work", "chore"].some(
|
|
2838
|
-
(l) =>
|
|
3679
|
+
(l) => fs25.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
|
|
2839
3680
|
);
|
|
2840
|
-
|
|
3681
|
+
const anyWatch = listWatches().some((w) => w.owner === "man");
|
|
3682
|
+
if (!anyActive && !anyWatch) break;
|
|
2841
3683
|
const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
|
|
2842
3684
|
const deadline = Date.now() + timeoutSecs * 1e3;
|
|
2843
3685
|
const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
|
|
3686
|
+
runDueManWatches();
|
|
2844
3687
|
let evs = attentionNow(true, remindMs);
|
|
2845
|
-
|
|
3688
|
+
let watched = pendingWatchEvents(true);
|
|
3689
|
+
if (evs.length === 0 && watched.length === 0) {
|
|
2846
3690
|
const baseline = captureWaitBaseline();
|
|
2847
3691
|
while (Date.now() < deadline) {
|
|
2848
3692
|
await new Promise((r) => setTimeout(r, 1500));
|
|
2849
3693
|
evs = freshWakeEvents(baseline);
|
|
2850
3694
|
if (evs.length === 0) evs = attentionNow(true, remindMs);
|
|
2851
|
-
|
|
3695
|
+
runDueManWatches();
|
|
3696
|
+
watched = pendingWatchEvents(true);
|
|
3697
|
+
if (evs.length > 0 || watched.length > 0) break;
|
|
2852
3698
|
}
|
|
2853
3699
|
}
|
|
2854
|
-
if (evs.length === 0) break;
|
|
2855
|
-
const lines =
|
|
2856
|
-
(ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`
|
|
2857
|
-
|
|
3700
|
+
if (evs.length === 0 && watched.length === 0) break;
|
|
3701
|
+
const lines = [
|
|
3702
|
+
...evs.map((ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`),
|
|
3703
|
+
...watched.flatMap(
|
|
3704
|
+
(a) => a.events.map((e) => `- watch ${a.watch.key}${e.summary ? ` \u2014 ${e.summary}` : ` (seq ${e.seq})`}`)
|
|
3705
|
+
)
|
|
3706
|
+
];
|
|
2858
3707
|
const reason = [
|
|
2859
|
-
"A lobstah dispatch needs attention:",
|
|
3708
|
+
"A lobstah dispatch or watched source needs attention:",
|
|
2860
3709
|
...lines,
|
|
2861
|
-
'Check with `lobstah status <id>`; answer a needs-decision with `lobstah send <id> "<answer>"`.',
|
|
3710
|
+
'Check a dispatch with `lobstah status <id>`; answer a needs-decision with `lobstah send <id> "<answer>"`.',
|
|
3711
|
+
"A watch line means an external source updated (e.g. a review round) \u2014 handle it directly.",
|
|
2862
3712
|
"Handle it now. This session re-parks automatically at turn end \u2014 do not arm any watcher."
|
|
2863
3713
|
].join("\n");
|
|
2864
3714
|
console.log(JSON.stringify({ decision: "block", reason }));
|
|
@@ -2866,31 +3716,109 @@ ${progress}`
|
|
|
2866
3716
|
}
|
|
2867
3717
|
break;
|
|
2868
3718
|
}
|
|
2869
|
-
case "daemon":
|
|
2870
|
-
|
|
2871
|
-
|
|
3719
|
+
case "daemon":
|
|
3720
|
+
case "pick": {
|
|
3721
|
+
const kind = cmd;
|
|
3722
|
+
if (args[0] === "install") {
|
|
3723
|
+
const res = installService(kind);
|
|
3724
|
+
console.log(toonKV({ service: kind, file: res.file, loaded: res.loaded, detail: res.detail }));
|
|
3725
|
+
break;
|
|
3726
|
+
}
|
|
3727
|
+
if (args[0] === "uninstall") {
|
|
3728
|
+
const res = uninstallService(kind);
|
|
3729
|
+
console.log(toonKV({ service: kind, file: res.file, removed: res.removed }));
|
|
3730
|
+
break;
|
|
3731
|
+
}
|
|
3732
|
+
if (kind === "daemon") await daemon(Number(arg(args, "--interval") ?? "5000"));
|
|
3733
|
+
else await runPickup(args[0] === "once" ? "once" : "daemon");
|
|
3734
|
+
break;
|
|
3735
|
+
}
|
|
3736
|
+
case "doctor": {
|
|
3737
|
+
const rows = runDoctor();
|
|
3738
|
+
console.log(toonTable("doctor", rows, ["check", "status", "detail"]));
|
|
3739
|
+
if (rows.some((r) => r.status === "fail")) process.exitCode = 1;
|
|
2872
3740
|
break;
|
|
2873
3741
|
}
|
|
2874
|
-
case "
|
|
2875
|
-
|
|
3742
|
+
case "repos": {
|
|
3743
|
+
if (args[0] === "add") {
|
|
3744
|
+
const target = args[1];
|
|
3745
|
+
if (!target) throw new Error("repos add requires a path");
|
|
3746
|
+
const detected = detectRepo(target);
|
|
3747
|
+
if (!detected) throw new Error(`${target} is not the root of a git repository`);
|
|
3748
|
+
const key = arg(args, "--key") ?? detected.key;
|
|
3749
|
+
if (configuredRepoKeys().has(key)) throw new Error(`repos.${key} already configured \u2014 edit ${configPath()} directly`);
|
|
3750
|
+
appendRepoBlock({ ...detected, key }, { pickup: args.includes("--pickup") });
|
|
3751
|
+
console.log(toonKV({ key, path: detected.path, trunk: detected.trunk, origin: detected.origin, pickup: args.includes("--pickup") }));
|
|
3752
|
+
break;
|
|
3753
|
+
}
|
|
3754
|
+
const repos = loadConfig().repos;
|
|
3755
|
+
const rows = Object.entries(repos).map(([key, r]) => ({
|
|
3756
|
+
key,
|
|
3757
|
+
path: r.path,
|
|
3758
|
+
trunk: r.trunk,
|
|
3759
|
+
pickup: r.pickup ?? false,
|
|
3760
|
+
exists: fs25.existsSync(r.path)
|
|
3761
|
+
}));
|
|
3762
|
+
console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
|
|
3763
|
+
break;
|
|
3764
|
+
}
|
|
3765
|
+
case "version": {
|
|
3766
|
+
console.log(lobstahVersion());
|
|
3767
|
+
break;
|
|
3768
|
+
}
|
|
3769
|
+
case "watch": {
|
|
3770
|
+
const sub = args[0];
|
|
3771
|
+
if (sub === "add") {
|
|
3772
|
+
const key = args[1];
|
|
3773
|
+
const check = arg(args, "--check");
|
|
3774
|
+
if (!key || key.startsWith("--") || !check) throw new Error("watch add requires a key and --check <command>");
|
|
3775
|
+
const forId = arg(args, "--for");
|
|
3776
|
+
const every = arg(args, "--every");
|
|
3777
|
+
const w = addWatch(key, check, {
|
|
3778
|
+
owner: forId ? `dispatch:${forId}` : "man",
|
|
3779
|
+
cursor: arg(args, "--cursor"),
|
|
3780
|
+
everySecs: every ? Number(every) : void 0,
|
|
3781
|
+
brief: arg(args, "--brief"),
|
|
3782
|
+
stream: arg(args, "--stream")
|
|
3783
|
+
});
|
|
3784
|
+
console.log(toonKV({ key: w.key, owner: w.owner, cursor: w.cursor, registered: true }));
|
|
3785
|
+
break;
|
|
3786
|
+
}
|
|
3787
|
+
if (sub === "rm") {
|
|
3788
|
+
const key = args[1];
|
|
3789
|
+
if (!key) throw new Error("watch rm requires a key");
|
|
3790
|
+
console.log(toonKV({ key, removed: removeWatch(key) }));
|
|
3791
|
+
break;
|
|
3792
|
+
}
|
|
3793
|
+
const rows = listWatches().map((w) => ({
|
|
3794
|
+
key: w.key,
|
|
3795
|
+
owner: w.owner,
|
|
3796
|
+
cursor: w.cursor,
|
|
3797
|
+
pending: readWatchEvents(w.key).length - w.seen,
|
|
3798
|
+
lastChecked: w.lastCheckedAt ?? "-",
|
|
3799
|
+
error: w.lastError ?? ""
|
|
3800
|
+
}));
|
|
3801
|
+
console.log(toonTable("watches", rows, ["key", "owner", "cursor", "pending", "lastChecked", "error"]));
|
|
2876
3802
|
break;
|
|
2877
3803
|
}
|
|
2878
3804
|
case "init": {
|
|
2879
|
-
if (!
|
|
2880
|
-
|
|
3805
|
+
if (!fs25.existsSync(configPath())) {
|
|
3806
|
+
const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
|
|
3807
|
+
path = "~/src/example"
|
|
3808
|
+
trunk = "main"
|
|
3809
|
+
# origin = "git@github.com:you/example.git"
|
|
3810
|
+
# setup = ["pnpm install"]
|
|
3811
|
+
# pickup = true # opt into [pickup.github] multi-repo tracker pickup
|
|
3812
|
+
|
|
3813
|
+
`;
|
|
3814
|
+
fs25.writeFileSync(
|
|
2881
3815
|
configPath(),
|
|
2882
3816
|
`# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
|
|
2883
3817
|
# Top-level keys must come before any [section].
|
|
2884
3818
|
# notifyCommand = "ntfy pub my-topic "$LOBSTAH_VERB $LOBSTAH_ID: $LOBSTAH_NOTE""
|
|
2885
3819
|
# notifyVerbs = ["needs-decision", "blocked", "done", "failed"] # the default
|
|
2886
3820
|
|
|
2887
|
-
[
|
|
2888
|
-
path = "~/src/example"
|
|
2889
|
-
trunk = "main"
|
|
2890
|
-
# origin = "git@github.com:you/example.git"
|
|
2891
|
-
# setup = ["pnpm install"]
|
|
2892
|
-
|
|
2893
|
-
[harness]
|
|
3821
|
+
${exampleRepo}[harness]
|
|
2894
3822
|
default = "claude"
|
|
2895
3823
|
|
|
2896
3824
|
[limits]
|
|
@@ -2902,16 +3830,44 @@ wallClockSecs = 3600
|
|
|
2902
3830
|
`
|
|
2903
3831
|
);
|
|
2904
3832
|
}
|
|
3833
|
+
const scanIdx = args.indexOf("--scan");
|
|
3834
|
+
const added = [];
|
|
3835
|
+
const unmarked = [];
|
|
3836
|
+
if (scanIdx >= 0) {
|
|
3837
|
+
const roots = args.slice(scanIdx + 1).filter((a) => !a.startsWith("--"));
|
|
3838
|
+
if (roots.length === 0) throw new Error("--scan requires at least one directory");
|
|
3839
|
+
const known = configuredRepoKeys();
|
|
3840
|
+
const pickup = args.includes("--pickup");
|
|
3841
|
+
for (const repo of scanForRepos(roots)) {
|
|
3842
|
+
if (known.has(repo.key)) continue;
|
|
3843
|
+
known.add(repo.key);
|
|
3844
|
+
appendRepoBlock(repo, { pickup });
|
|
3845
|
+
added.push(repo.key);
|
|
3846
|
+
if (!pickup) unmarked.push(repo.key);
|
|
3847
|
+
}
|
|
3848
|
+
}
|
|
3849
|
+
let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
|
|
3850
|
+
try {
|
|
3851
|
+
const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
|
|
3852
|
+
if (fs25.existsSync(local)) reference = local;
|
|
3853
|
+
} catch {
|
|
3854
|
+
}
|
|
2905
3855
|
console.log(
|
|
2906
3856
|
toonKV({
|
|
2907
|
-
home:
|
|
3857
|
+
home: path19.dirname(configPath()),
|
|
2908
3858
|
config: configPath(),
|
|
2909
|
-
|
|
3859
|
+
...added.length > 0 ? { added: added.join(", ") } : {},
|
|
3860
|
+
...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
|
|
3861
|
+
reference,
|
|
2910
3862
|
initialized: true
|
|
2911
3863
|
})
|
|
2912
3864
|
);
|
|
2913
3865
|
break;
|
|
2914
3866
|
}
|
|
3867
|
+
case "--version":
|
|
3868
|
+
case "-v":
|
|
3869
|
+
console.log(lobstahVersion());
|
|
3870
|
+
break;
|
|
2915
3871
|
case void 0:
|
|
2916
3872
|
case "--help":
|
|
2917
3873
|
case "help":
|