lobstah 0.1.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/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/main.js +2688 -0
- package/dist/runner.js +1319 -0
- package/package.json +36 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,2688 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// apps/cli/src/main.ts
|
|
4
|
+
import * as fs16 from "node:fs";
|
|
5
|
+
import * as path12 from "node:path";
|
|
6
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
// packages/core/dist/types.js
|
|
10
|
+
var VERBS = ["working", "needs-decision", "blocked", "paused", "done", "failed"];
|
|
11
|
+
var TERMINAL_VERBS = ["done", "failed"];
|
|
12
|
+
|
|
13
|
+
// packages/core/dist/paths.js
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
function lobstahHome() {
|
|
18
|
+
return process.env.LOBSTAH_HOME ?? path.join(os.homedir(), ".lobstah");
|
|
19
|
+
}
|
|
20
|
+
function laneRoot(lane) {
|
|
21
|
+
return lane === "work" ? lobstahHome() : path.join(lobstahHome(), "chores");
|
|
22
|
+
}
|
|
23
|
+
function laneDirs(lane) {
|
|
24
|
+
const root = laneRoot(lane);
|
|
25
|
+
return {
|
|
26
|
+
queue: path.join(root, "queue"),
|
|
27
|
+
active: path.join(root, "active"),
|
|
28
|
+
done: path.join(root, "done"),
|
|
29
|
+
state: path.join(root, "state"),
|
|
30
|
+
inbox: path.join(root, "inbox")
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function ensureLayout() {
|
|
34
|
+
for (const lane of ["work", "chore"]) {
|
|
35
|
+
for (const dir of Object.values(laneDirs(lane)))
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
fs.mkdirSync(path.join(lobstahHome(), "worktrees"), { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
function statusPath(id, lane) {
|
|
41
|
+
return path.join(laneDirs(lane).state, `${id}.status`);
|
|
42
|
+
}
|
|
43
|
+
function eventsPath(id, lane) {
|
|
44
|
+
return path.join(laneDirs(lane).state, `${id}.events`);
|
|
45
|
+
}
|
|
46
|
+
function evidencePath(id, lane) {
|
|
47
|
+
return path.join(laneDirs(lane).state, `${id}.evidence`);
|
|
48
|
+
}
|
|
49
|
+
function executorPath() {
|
|
50
|
+
return path.join(lobstahHome(), "executor.json");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// packages/core/dist/queue.js
|
|
54
|
+
import * as fs2 from "node:fs";
|
|
55
|
+
import * as path2 from "node:path";
|
|
56
|
+
function atomicWrite(file, content) {
|
|
57
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
58
|
+
fs2.writeFileSync(tmp, content);
|
|
59
|
+
fs2.renameSync(tmp, file);
|
|
60
|
+
}
|
|
61
|
+
function enqueue(d, lane = "work") {
|
|
62
|
+
if (!d.id || !d.repo || !d.brief) {
|
|
63
|
+
throw new Error("descriptor requires id, repo, and brief");
|
|
64
|
+
}
|
|
65
|
+
atomicWrite(path2.join(laneDirs(lane).queue, `${d.id}.json`), JSON.stringify(d, null, 2));
|
|
66
|
+
}
|
|
67
|
+
function pendingIds(lane) {
|
|
68
|
+
const dir = laneDirs(lane).queue;
|
|
69
|
+
return fs2.readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => ({ f, m: fs2.statSync(path2.join(dir, f)).mtimeMs })).sort((a, b) => a.m - b.m).map(({ f }) => f.slice(0, -".json".length));
|
|
70
|
+
}
|
|
71
|
+
function claimNext(lane) {
|
|
72
|
+
const dirs = laneDirs(lane);
|
|
73
|
+
for (const id of pendingIds(lane)) {
|
|
74
|
+
const activeDir = path2.join(dirs.active, id);
|
|
75
|
+
try {
|
|
76
|
+
fs2.mkdirSync(activeDir);
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
fs2.renameSync(path2.join(dirs.queue, `${id}.json`), path2.join(activeDir, "descriptor.json"));
|
|
82
|
+
return id;
|
|
83
|
+
} catch {
|
|
84
|
+
fs2.rmdirSync(activeDir, { recursive: true });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
function activeIds(lane) {
|
|
91
|
+
return fs2.readdirSync(laneDirs(lane).active).filter((f) => !f.startsWith("."));
|
|
92
|
+
}
|
|
93
|
+
function requestCancel(id, lane) {
|
|
94
|
+
fs2.writeFileSync(path2.join(laneDirs(lane).active, id, "cancel"), (/* @__PURE__ */ new Date()).toISOString());
|
|
95
|
+
}
|
|
96
|
+
function cancelRequested(id, lane) {
|
|
97
|
+
return fs2.existsSync(path2.join(laneDirs(lane).active, id, "cancel"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// packages/core/dist/status.js
|
|
101
|
+
import * as fs3 from "node:fs";
|
|
102
|
+
function isVerb(v) {
|
|
103
|
+
return VERBS.includes(v);
|
|
104
|
+
}
|
|
105
|
+
function appendStatus(id, lane, verb, note) {
|
|
106
|
+
if (!isVerb(verb)) {
|
|
107
|
+
throw new Error(`invalid status verb "${verb}" \u2014 must be one of: ${VERBS.join(", ")}`);
|
|
108
|
+
}
|
|
109
|
+
const entry = { at: (/* @__PURE__ */ new Date()).toISOString(), verb, ...note ? { note } : {} };
|
|
110
|
+
fs3.appendFileSync(statusPath(id, lane), `${JSON.stringify(entry)}
|
|
111
|
+
`);
|
|
112
|
+
return entry;
|
|
113
|
+
}
|
|
114
|
+
function readStatusLog(id, lane) {
|
|
115
|
+
const file = statusPath(id, lane);
|
|
116
|
+
if (!fs3.existsSync(file))
|
|
117
|
+
return [];
|
|
118
|
+
return fs3.readFileSync(file, "utf8").split("\n").filter(Boolean).flatMap((line) => {
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(line);
|
|
121
|
+
return isVerb(parsed.verb) ? [parsed] : [];
|
|
122
|
+
} catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function reconcile({ log, lastEventAt: lastEventAt2, now = Date.now(), busyThresholdMs = 12e4 }) {
|
|
128
|
+
const last = log[log.length - 1];
|
|
129
|
+
if (last && TERMINAL_VERBS.includes(last.verb))
|
|
130
|
+
return last.verb;
|
|
131
|
+
const busy = lastEventAt2 !== void 0 && now - lastEventAt2 < busyThresholdMs;
|
|
132
|
+
if (busy)
|
|
133
|
+
return last && last.verb !== "working" ? last.verb : "working";
|
|
134
|
+
if (last)
|
|
135
|
+
return last.verb;
|
|
136
|
+
return "unknown";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// packages/core/dist/events.js
|
|
140
|
+
import * as fs4 from "node:fs";
|
|
141
|
+
function lastEventAt(id, lane) {
|
|
142
|
+
try {
|
|
143
|
+
return fs4.statSync(eventsPath(id, lane)).mtimeMs;
|
|
144
|
+
} catch {
|
|
145
|
+
return void 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// packages/core/dist/evidence.js
|
|
150
|
+
import * as fs5 from "node:fs";
|
|
151
|
+
function readEvidence(id, lane) {
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(fs5.readFileSync(evidencePath(id, lane), "utf8"));
|
|
154
|
+
} catch {
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function mergeEvidence(id, lane, patch) {
|
|
159
|
+
const file = evidencePath(id, lane);
|
|
160
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
161
|
+
fs5.writeFileSync(tmp, JSON.stringify({ ...readEvidence(id, lane), ...patch }, null, 2));
|
|
162
|
+
fs5.renameSync(tmp, file);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// packages/core/dist/inbox.js
|
|
166
|
+
import * as fs6 from "node:fs";
|
|
167
|
+
import * as path3 from "node:path";
|
|
168
|
+
function inboxDir(id, lane) {
|
|
169
|
+
return path3.join(laneDirs(lane).inbox, id);
|
|
170
|
+
}
|
|
171
|
+
function sendMessage(id, lane, text) {
|
|
172
|
+
const dir = inboxDir(id, lane);
|
|
173
|
+
fs6.mkdirSync(path3.join(dir, "handled"), { recursive: true });
|
|
174
|
+
const existing = fs6.readdirSync(dir).filter((f) => f.endsWith(".msg")).length;
|
|
175
|
+
const handled = fs6.readdirSync(path3.join(dir, "handled")).length;
|
|
176
|
+
const name = `${String(existing + handled + 1).padStart(3, "0")}.msg`;
|
|
177
|
+
const tmp = path3.join(dir, `.tmp-${process.pid}-${Date.now()}`);
|
|
178
|
+
fs6.writeFileSync(tmp, text);
|
|
179
|
+
fs6.renameSync(tmp, path3.join(dir, name));
|
|
180
|
+
return name;
|
|
181
|
+
}
|
|
182
|
+
function unhandled(id, lane) {
|
|
183
|
+
const dir = inboxDir(id, lane);
|
|
184
|
+
if (!fs6.existsSync(dir))
|
|
185
|
+
return [];
|
|
186
|
+
return fs6.readdirSync(dir).filter((f) => f.endsWith(".msg")).sort().map((f) => ({ file: f, text: fs6.readFileSync(path3.join(dir, f), "utf8") }));
|
|
187
|
+
}
|
|
188
|
+
function acknowledge(id, lane, file) {
|
|
189
|
+
const dir = inboxDir(id, lane);
|
|
190
|
+
fs6.mkdirSync(path3.join(dir, "handled"), { recursive: true });
|
|
191
|
+
fs6.renameSync(path3.join(dir, file), path3.join(dir, "handled", file));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// packages/core/dist/config.js
|
|
195
|
+
import * as fs7 from "node:fs";
|
|
196
|
+
import * as os2 from "node:os";
|
|
197
|
+
import * as path4 from "node:path";
|
|
198
|
+
|
|
199
|
+
// node_modules/.pnpm/smol-toml@1.8.0/node_modules/smol-toml/dist/date.js
|
|
200
|
+
var DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})?[T ]?(?:(\d{2}):\d{2}(?::\d{2}(?:\.\d+)?)?)?(Z|[-+]\d{2}:\d{2})?$/i;
|
|
201
|
+
var TomlDate = class _TomlDate extends Date {
|
|
202
|
+
#hasDate = false;
|
|
203
|
+
#hasTime = false;
|
|
204
|
+
#offset = null;
|
|
205
|
+
constructor(date) {
|
|
206
|
+
let hasDate = true;
|
|
207
|
+
let hasTime = true;
|
|
208
|
+
let offset = "Z";
|
|
209
|
+
if (typeof date === "string") {
|
|
210
|
+
let match = date.match(DATE_TIME_RE);
|
|
211
|
+
if (match) {
|
|
212
|
+
if (!match[1]) {
|
|
213
|
+
hasDate = false;
|
|
214
|
+
date = `0000-01-01T${date}`;
|
|
215
|
+
}
|
|
216
|
+
hasTime = !!match[2];
|
|
217
|
+
hasTime && date[10] === " " && (date = date.replace(" ", "T"));
|
|
218
|
+
if (match[2] && +match[2] > 23) {
|
|
219
|
+
date = "";
|
|
220
|
+
} else {
|
|
221
|
+
offset = match[3] || null;
|
|
222
|
+
date = date.toUpperCase();
|
|
223
|
+
if (!offset && hasTime)
|
|
224
|
+
date += "Z";
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
date = "";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
super(date);
|
|
231
|
+
if (!isNaN(this.getTime())) {
|
|
232
|
+
this.#hasDate = hasDate;
|
|
233
|
+
this.#hasTime = hasTime;
|
|
234
|
+
this.#offset = offset;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
isDateTime() {
|
|
238
|
+
return this.#hasDate && this.#hasTime;
|
|
239
|
+
}
|
|
240
|
+
isLocal() {
|
|
241
|
+
return !this.#hasDate || !this.#hasTime || !this.#offset;
|
|
242
|
+
}
|
|
243
|
+
isDate() {
|
|
244
|
+
return this.#hasDate && !this.#hasTime;
|
|
245
|
+
}
|
|
246
|
+
isTime() {
|
|
247
|
+
return this.#hasTime && !this.#hasDate;
|
|
248
|
+
}
|
|
249
|
+
isValid() {
|
|
250
|
+
return this.#hasDate || this.#hasTime;
|
|
251
|
+
}
|
|
252
|
+
toISOString() {
|
|
253
|
+
let iso = super.toISOString();
|
|
254
|
+
if (this.isDate())
|
|
255
|
+
return iso.slice(0, 10);
|
|
256
|
+
if (this.isTime())
|
|
257
|
+
return iso.slice(11, 23);
|
|
258
|
+
if (this.#offset === null)
|
|
259
|
+
return iso.slice(0, -1);
|
|
260
|
+
if (this.#offset === "Z")
|
|
261
|
+
return iso;
|
|
262
|
+
let offset = +this.#offset.slice(1, 3) * 60 + +this.#offset.slice(4, 6);
|
|
263
|
+
offset = this.#offset[0] === "-" ? offset : -offset;
|
|
264
|
+
let offsetDate = new Date(this.getTime() - offset * 6e4);
|
|
265
|
+
return offsetDate.toISOString().slice(0, -1) + this.#offset;
|
|
266
|
+
}
|
|
267
|
+
static wrapAsOffsetDateTime(jsDate, offset = "Z") {
|
|
268
|
+
let date = new _TomlDate(jsDate);
|
|
269
|
+
date.#offset = offset;
|
|
270
|
+
return date;
|
|
271
|
+
}
|
|
272
|
+
static wrapAsLocalDateTime(jsDate) {
|
|
273
|
+
let date = new _TomlDate(jsDate);
|
|
274
|
+
date.#offset = null;
|
|
275
|
+
return date;
|
|
276
|
+
}
|
|
277
|
+
static wrapAsLocalDate(jsDate) {
|
|
278
|
+
let date = new _TomlDate(jsDate);
|
|
279
|
+
date.#hasTime = false;
|
|
280
|
+
date.#offset = null;
|
|
281
|
+
return date;
|
|
282
|
+
}
|
|
283
|
+
static wrapAsLocalTime(jsDate) {
|
|
284
|
+
let date = new _TomlDate(jsDate);
|
|
285
|
+
date.#hasDate = false;
|
|
286
|
+
date.#offset = null;
|
|
287
|
+
return date;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// node_modules/.pnpm/smol-toml@1.8.0/node_modules/smol-toml/dist/error.js
|
|
292
|
+
function getLineColFromPtr(string, ptr) {
|
|
293
|
+
let lines = string.slice(0, ptr).split(/\r\n|\n|\r/g);
|
|
294
|
+
return [lines.length, lines.pop().length + 1];
|
|
295
|
+
}
|
|
296
|
+
function makeCodeBlock(string, line, column) {
|
|
297
|
+
let lines = string.split(/\r\n|\n|\r/g);
|
|
298
|
+
let codeblock = "";
|
|
299
|
+
let numberLen = (Math.log10(line + 1) | 0) + 1;
|
|
300
|
+
for (let i = line - 1; i <= line + 1; i++) {
|
|
301
|
+
let l = lines[i - 1];
|
|
302
|
+
if (!l)
|
|
303
|
+
continue;
|
|
304
|
+
codeblock += i.toString().padEnd(numberLen, " ");
|
|
305
|
+
codeblock += ": ";
|
|
306
|
+
codeblock += l;
|
|
307
|
+
codeblock += "\n";
|
|
308
|
+
if (i === line) {
|
|
309
|
+
codeblock += " ".repeat(numberLen + column + 2);
|
|
310
|
+
codeblock += "^\n";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return codeblock;
|
|
314
|
+
}
|
|
315
|
+
var TomlError = class extends Error {
|
|
316
|
+
line;
|
|
317
|
+
column;
|
|
318
|
+
codeblock;
|
|
319
|
+
constructor(message, options) {
|
|
320
|
+
const [line, column] = getLineColFromPtr(options.toml, options.ptr);
|
|
321
|
+
const codeblock = makeCodeBlock(options.toml, line, column);
|
|
322
|
+
super(`Invalid TOML document: ${message}
|
|
323
|
+
|
|
324
|
+
${codeblock}`, options);
|
|
325
|
+
this.line = line;
|
|
326
|
+
this.column = column;
|
|
327
|
+
this.codeblock = codeblock;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// node_modules/.pnpm/smol-toml@1.8.0/node_modules/smol-toml/dist/util.js
|
|
332
|
+
function indexOfNewline(str, start = 0) {
|
|
333
|
+
let idx = str.indexOf("\n", start);
|
|
334
|
+
if (str.charCodeAt(idx - 1) === 13)
|
|
335
|
+
idx--;
|
|
336
|
+
return idx;
|
|
337
|
+
}
|
|
338
|
+
function skipComment(ctx) {
|
|
339
|
+
for (; ctx.p < ctx.s.length; ctx.p++) {
|
|
340
|
+
let c = ctx.s.charCodeAt(ctx.p);
|
|
341
|
+
if (c === 10)
|
|
342
|
+
break;
|
|
343
|
+
if (c === 13 && ctx.s.charCodeAt(ctx.p + 1) === 10) {
|
|
344
|
+
ctx.p++;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
if (c < 32 && c !== 9 || c === 127) {
|
|
348
|
+
throw new TomlError("control characters are not allowed in comments", {
|
|
349
|
+
toml: ctx.s,
|
|
350
|
+
ptr: ctx.p
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function skipVoid(ctx, banNewLines, banComments) {
|
|
356
|
+
let c;
|
|
357
|
+
while (1) {
|
|
358
|
+
while ((c = ctx.s.charCodeAt(ctx.p)) === 32 || c === 9 || !banNewLines && (c === 10 || c === 13 && ctx.s.charCodeAt(ctx.p + 1) === 10))
|
|
359
|
+
ctx.p++;
|
|
360
|
+
if (banComments || c !== 35)
|
|
361
|
+
break;
|
|
362
|
+
skipComment(ctx);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function skipUntil(ctx, sep, end) {
|
|
366
|
+
let ptr = ctx.p;
|
|
367
|
+
if (!end) {
|
|
368
|
+
ptr = indexOfNewline(ctx.s, ptr);
|
|
369
|
+
ctx.p = ptr < 0 ? ctx.s.length : ptr;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
for (; ctx.p < ctx.s.length; ctx.p++) {
|
|
373
|
+
let c = ctx.s.charCodeAt(ctx.p);
|
|
374
|
+
if (c === 35) {
|
|
375
|
+
skipComment(ctx);
|
|
376
|
+
} else if (c === end || c === sep) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
throw new TomlError("cannot find end of structure", {
|
|
381
|
+
toml: ctx.s,
|
|
382
|
+
ptr
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// node_modules/.pnpm/smol-toml@1.8.0/node_modules/smol-toml/dist/primitive.js
|
|
387
|
+
var INT_REGEX = /^((0x[0-9a-fA-F](_?[0-9a-fA-F])*)|(([+-]|0[ob])?\d(_?\d)*))$/;
|
|
388
|
+
var FLOAT_REGEX = /^[+-]?\d(_?\d)*(\.\d(_?\d)*)?([eE][+-]?\d(_?\d)*)?$/;
|
|
389
|
+
var LEADING_ZERO = /^[+-]?0[0-9_]/;
|
|
390
|
+
function parseString(ctx) {
|
|
391
|
+
let start = ctx.p;
|
|
392
|
+
let c = ctx.s.charCodeAt(ctx.p++);
|
|
393
|
+
let first = c;
|
|
394
|
+
let isLiteral = c === 39;
|
|
395
|
+
let isMultiline = c === ctx.s.charCodeAt(ctx.p) && c === ctx.s.charCodeAt(ctx.p + 1);
|
|
396
|
+
if (isMultiline) {
|
|
397
|
+
if ((c = ctx.s.charCodeAt(ctx.p += 2)) === 10)
|
|
398
|
+
ctx.p++;
|
|
399
|
+
else if (c === 13 && ctx.s.charCodeAt(ctx.p + 1) === 10)
|
|
400
|
+
ctx.p += 2;
|
|
401
|
+
}
|
|
402
|
+
let parsed = "";
|
|
403
|
+
let sliceStart = ctx.p;
|
|
404
|
+
let state = 0;
|
|
405
|
+
for (; ctx.p < ctx.s.length; ctx.p++) {
|
|
406
|
+
c = ctx.s.charCodeAt(ctx.p);
|
|
407
|
+
if (isMultiline && (c === 10 || c === 13 && ctx.s.charCodeAt(ctx.p + 1) === 10)) {
|
|
408
|
+
state = state && 3;
|
|
409
|
+
} else if (c < 32 && c !== 9 || c === 127) {
|
|
410
|
+
throw new TomlError("control characters are not allowed in strings", {
|
|
411
|
+
toml: ctx.s,
|
|
412
|
+
ptr: ctx.p
|
|
413
|
+
});
|
|
414
|
+
} else if ((!state || state === 3) && c === first && (!isMultiline || ctx.s.charCodeAt(ctx.p + 1) === first && ctx.s.charCodeAt(ctx.p + 2) === first)) {
|
|
415
|
+
if (isMultiline) {
|
|
416
|
+
if (ctx.s.charCodeAt(ctx.p + 3) === first)
|
|
417
|
+
ctx.p++;
|
|
418
|
+
if (ctx.s.charCodeAt(ctx.p + 3) === first)
|
|
419
|
+
ctx.p++;
|
|
420
|
+
}
|
|
421
|
+
if (!state)
|
|
422
|
+
parsed += ctx.s.slice(sliceStart, ctx.p);
|
|
423
|
+
ctx.p += isMultiline ? 3 : 1;
|
|
424
|
+
return parsed;
|
|
425
|
+
} else if (!state) {
|
|
426
|
+
if (!isLiteral && c === 92) {
|
|
427
|
+
parsed += ctx.s.slice(sliceStart, sliceStart = ctx.p);
|
|
428
|
+
state = 1;
|
|
429
|
+
}
|
|
430
|
+
} else if (state === 1) {
|
|
431
|
+
if (c === 120 || c === 117 || c === 85) {
|
|
432
|
+
let value = 0;
|
|
433
|
+
let len = c === 120 ? 2 : c === 117 ? 4 : 8;
|
|
434
|
+
for (let j = 0; j < len; j++, ctx.p++) {
|
|
435
|
+
let hex = ctx.s.charCodeAt(ctx.p + 1);
|
|
436
|
+
let digit = (
|
|
437
|
+
/* 0-9 */
|
|
438
|
+
hex >= 48 && hex <= 57 ? hex - 48 : (
|
|
439
|
+
/* A-F */
|
|
440
|
+
hex >= 65 && hex <= 70 ? hex - 65 + 10 : (
|
|
441
|
+
/* a-f */
|
|
442
|
+
hex >= 97 && hex <= 102 ? hex - 97 + 10 : -1
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
);
|
|
446
|
+
if (digit < 0)
|
|
447
|
+
throw new TomlError("invalid non-hex character in unicode escape", { toml: ctx.s, ptr: ctx.p + 1 });
|
|
448
|
+
value = value << 4 | digit;
|
|
449
|
+
}
|
|
450
|
+
if (value < 0 || value > 1114111 || value >= 55296 && value <= 57343) {
|
|
451
|
+
throw new TomlError("invalid unicode escape", { toml: ctx.s, ptr: ctx.p });
|
|
452
|
+
}
|
|
453
|
+
parsed += String.fromCodePoint(value);
|
|
454
|
+
sliceStart = ctx.p + 1;
|
|
455
|
+
state = 0;
|
|
456
|
+
} else if (c === 32 || c === 9) {
|
|
457
|
+
state = 2;
|
|
458
|
+
} else {
|
|
459
|
+
if (c === 98)
|
|
460
|
+
parsed += "\b";
|
|
461
|
+
else if (c === 116)
|
|
462
|
+
parsed += " ";
|
|
463
|
+
else if (c === 110)
|
|
464
|
+
parsed += "\n";
|
|
465
|
+
else if (c === 102)
|
|
466
|
+
parsed += "\f";
|
|
467
|
+
else if (c === 114)
|
|
468
|
+
parsed += "\r";
|
|
469
|
+
else if (c === 101)
|
|
470
|
+
parsed += "\x1B";
|
|
471
|
+
else if (c === 34)
|
|
472
|
+
parsed += '"';
|
|
473
|
+
else if (c === 92)
|
|
474
|
+
parsed += "\\";
|
|
475
|
+
else
|
|
476
|
+
throw new TomlError("unrecognized escape sequence", { toml: ctx.s, ptr: ctx.p });
|
|
477
|
+
sliceStart = ctx.p + 1;
|
|
478
|
+
state = 0;
|
|
479
|
+
}
|
|
480
|
+
} else if (c !== 32 && c !== 9) {
|
|
481
|
+
if (state === 2) {
|
|
482
|
+
throw new TomlError("invalid escape: only line-ending whitespace may be escaped", {
|
|
483
|
+
toml: ctx.s,
|
|
484
|
+
ptr: sliceStart
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
state = !isLiteral && c === 92 ? 1 : 0;
|
|
488
|
+
sliceStart = ctx.p;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
throw new TomlError("unfinished string", { toml: ctx.s, ptr: start });
|
|
492
|
+
}
|
|
493
|
+
function sliceAndTrimEndOf(ctx, start, end) {
|
|
494
|
+
let value = ctx.s.slice(start, end);
|
|
495
|
+
let commentIdx = value.indexOf("#");
|
|
496
|
+
if (commentIdx > 0) {
|
|
497
|
+
skipComment({ s: value, p: commentIdx, d: 0 });
|
|
498
|
+
value = value.slice(0, commentIdx);
|
|
499
|
+
}
|
|
500
|
+
return value.trimEnd();
|
|
501
|
+
}
|
|
502
|
+
function parseValue(ctx, integersAsBigInt, end) {
|
|
503
|
+
let ptr = ctx.p;
|
|
504
|
+
let err = { toml: ctx.s, ptr };
|
|
505
|
+
skipUntil(ctx, 44, end);
|
|
506
|
+
let value = sliceAndTrimEndOf(ctx, ptr, ctx.p);
|
|
507
|
+
if (!value)
|
|
508
|
+
throw new TomlError("incomplete declaration: value expected", err);
|
|
509
|
+
if (value === "-inf")
|
|
510
|
+
return -Infinity;
|
|
511
|
+
if (value === "inf" || value === "+inf")
|
|
512
|
+
return Infinity;
|
|
513
|
+
if (value === "nan" || value === "+nan" || value === "-nan")
|
|
514
|
+
return NaN;
|
|
515
|
+
if (value === "-0")
|
|
516
|
+
return integersAsBigInt ? 0n : 0;
|
|
517
|
+
let isInt = INT_REGEX.test(value);
|
|
518
|
+
if (isInt || FLOAT_REGEX.test(value)) {
|
|
519
|
+
if (LEADING_ZERO.test(value)) {
|
|
520
|
+
throw new TomlError("leading zeroes are not allowed", err);
|
|
521
|
+
}
|
|
522
|
+
value = value.replace(/_/g, "");
|
|
523
|
+
let numeric = +value;
|
|
524
|
+
if (isNaN(numeric)) {
|
|
525
|
+
throw new TomlError("invalid number", err);
|
|
526
|
+
}
|
|
527
|
+
if (isInt) {
|
|
528
|
+
if ((isInt = !Number.isSafeInteger(numeric)) && !integersAsBigInt) {
|
|
529
|
+
throw new TomlError("integer value cannot be represented losslessly", err);
|
|
530
|
+
}
|
|
531
|
+
if (isInt || integersAsBigInt === true)
|
|
532
|
+
numeric = BigInt(value);
|
|
533
|
+
}
|
|
534
|
+
return numeric;
|
|
535
|
+
}
|
|
536
|
+
const date = new TomlDate(value);
|
|
537
|
+
if (!date.isValid())
|
|
538
|
+
throw new TomlError("invalid value", err);
|
|
539
|
+
return date;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// node_modules/.pnpm/smol-toml@1.8.0/node_modules/smol-toml/dist/extract.js
|
|
543
|
+
function extractValue(ctx, end, integersAsBigInt) {
|
|
544
|
+
let ptr = ctx.p;
|
|
545
|
+
let c = ctx.s.charCodeAt(ptr);
|
|
546
|
+
if (c === 91 || c === 123) {
|
|
547
|
+
if (!ctx.d--) {
|
|
548
|
+
throw new TomlError("document contains excessively nested structures. aborting.", {
|
|
549
|
+
toml: ctx.s,
|
|
550
|
+
ptr
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
let value = c === 91 ? parseArray(ctx, integersAsBigInt) : parseInlineTable(ctx, integersAsBigInt);
|
|
554
|
+
ctx.d++;
|
|
555
|
+
return value;
|
|
556
|
+
}
|
|
557
|
+
if (c === 34 || c === 39) {
|
|
558
|
+
return parseString(ctx);
|
|
559
|
+
}
|
|
560
|
+
if (c === 116) {
|
|
561
|
+
if (ctx.s.charCodeAt(++ctx.p) !== 114 || ctx.s.charCodeAt(++ctx.p) !== 117 || ctx.s.charCodeAt(++ctx.p) !== 101)
|
|
562
|
+
throw new TomlError("invalid value", { toml: ctx.s, ptr });
|
|
563
|
+
ctx.p++;
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
if (c === 102) {
|
|
567
|
+
if (ctx.s.charCodeAt(++ctx.p) !== 97 || ctx.s.charCodeAt(++ctx.p) !== 108 || ctx.s.charCodeAt(++ctx.p) !== 115 || ctx.s.charCodeAt(++ctx.p) !== 101)
|
|
568
|
+
throw new TomlError("invalid value", { toml: ctx.s, ptr });
|
|
569
|
+
ctx.p++;
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
return parseValue(ctx, integersAsBigInt, end);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// node_modules/.pnpm/smol-toml@1.8.0/node_modules/smol-toml/dist/struct.js
|
|
576
|
+
var KEY_PART_RE = /^[a-zA-Z0-9-_]+[ \t]*$/;
|
|
577
|
+
function parseKey(ctx, end = "=") {
|
|
578
|
+
let start = ctx.p;
|
|
579
|
+
let dot = start - 1;
|
|
580
|
+
let parsed = [];
|
|
581
|
+
let endPtr = ctx.s.indexOf(end, start);
|
|
582
|
+
if (endPtr < 0) {
|
|
583
|
+
throw new TomlError("incomplete key-value: cannot find end of key", {
|
|
584
|
+
toml: ctx.s,
|
|
585
|
+
ptr: start
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
do {
|
|
589
|
+
let c = ctx.s.charCodeAt(ctx.p = ++dot);
|
|
590
|
+
if (c !== 32 && c !== 9) {
|
|
591
|
+
if (c === 34 || c === 39) {
|
|
592
|
+
if (c === ctx.s.charCodeAt(ctx.p + 1) && c === ctx.s.charCodeAt(ctx.p + 2)) {
|
|
593
|
+
throw new TomlError("multiline strings are not allowed in keys", {
|
|
594
|
+
toml: ctx.s,
|
|
595
|
+
ptr: ctx.p
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
let part = parseString(ctx);
|
|
599
|
+
dot = ctx.s.indexOf(".", ctx.p);
|
|
600
|
+
let strEnd = ctx.s.slice(ctx.p, dot < 0 || dot > endPtr ? endPtr : dot);
|
|
601
|
+
let newLine = indexOfNewline(strEnd);
|
|
602
|
+
if (newLine > -1) {
|
|
603
|
+
throw new TomlError("newlines are not allowed in keys", {
|
|
604
|
+
toml: ctx.s,
|
|
605
|
+
ptr: newLine
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
if (strEnd.trimStart()) {
|
|
609
|
+
throw new TomlError("found extra tokens after the string part", {
|
|
610
|
+
toml: ctx.s,
|
|
611
|
+
ptr: ctx.p
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (endPtr < ctx.p) {
|
|
615
|
+
endPtr = ctx.s.indexOf(end, ctx.p);
|
|
616
|
+
if (endPtr < 0) {
|
|
617
|
+
throw new TomlError("incomplete key-value: cannot find end of key", {
|
|
618
|
+
toml: ctx.s,
|
|
619
|
+
ptr: start
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
parsed.push(part);
|
|
624
|
+
} else {
|
|
625
|
+
dot = ctx.s.indexOf(".", ctx.p);
|
|
626
|
+
let part = ctx.s.slice(ctx.p, dot < 0 || dot > endPtr ? endPtr : dot);
|
|
627
|
+
if (!KEY_PART_RE.test(part)) {
|
|
628
|
+
throw new TomlError("only letter, numbers, dashes and underscores are allowed in keys", {
|
|
629
|
+
toml: ctx.s,
|
|
630
|
+
ptr: ctx.p
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
parsed.push(part.trimEnd());
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
} while (dot + 1 && dot < endPtr);
|
|
637
|
+
ctx.p = endPtr + 1;
|
|
638
|
+
skipVoid(ctx, true, true);
|
|
639
|
+
return parsed;
|
|
640
|
+
}
|
|
641
|
+
function parseInlineTable(ctx, integersAsBigInt) {
|
|
642
|
+
let res = {};
|
|
643
|
+
let seen = /* @__PURE__ */ new Set();
|
|
644
|
+
let c;
|
|
645
|
+
ctx.p++;
|
|
646
|
+
while (ctx.p < ctx.s.length) {
|
|
647
|
+
skipVoid(ctx);
|
|
648
|
+
if ((c = ctx.s.charCodeAt(ctx.p)) === 125) {
|
|
649
|
+
ctx.p++;
|
|
650
|
+
return res;
|
|
651
|
+
}
|
|
652
|
+
let k;
|
|
653
|
+
let t = res;
|
|
654
|
+
let hasOwn = false;
|
|
655
|
+
let p = ctx.p;
|
|
656
|
+
let key = parseKey(ctx);
|
|
657
|
+
for (let i = 0; i < key.length; i++) {
|
|
658
|
+
if (i)
|
|
659
|
+
t = hasOwn ? t[k] : t[k] = {};
|
|
660
|
+
k = key[i];
|
|
661
|
+
if ((hasOwn = Object.hasOwn(t, k)) && (typeof t[k] !== "object" || seen.has(t[k]))) {
|
|
662
|
+
throw new TomlError("trying to redefine an already defined value", {
|
|
663
|
+
toml: ctx.s,
|
|
664
|
+
ptr: p
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
if (!hasOwn && k === "__proto__") {
|
|
668
|
+
Object.defineProperty(t, k, { enumerable: true, configurable: true, writable: true });
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (hasOwn) {
|
|
672
|
+
throw new TomlError("trying to redefine an already defined value", {
|
|
673
|
+
toml: ctx.s,
|
|
674
|
+
ptr: ctx.p
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
let value = extractValue(ctx, 125, integersAsBigInt);
|
|
678
|
+
seen.add(t[k] = value);
|
|
679
|
+
skipVoid(ctx);
|
|
680
|
+
if ((c = ctx.s.charCodeAt(ctx.p++)) === 125) {
|
|
681
|
+
return res;
|
|
682
|
+
}
|
|
683
|
+
if (c !== 44) {
|
|
684
|
+
throw new TomlError("expected comma or end of structure", { toml: ctx.s, ptr: ctx.p - 1 });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
throw new TomlError("unfinished table encountered", {
|
|
688
|
+
toml: ctx.s,
|
|
689
|
+
ptr: ctx.p
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
function parseArray(ctx, integersAsBigInt) {
|
|
693
|
+
let res = [];
|
|
694
|
+
let c;
|
|
695
|
+
ctx.p++;
|
|
696
|
+
while (ctx.p < ctx.s.length) {
|
|
697
|
+
skipVoid(ctx);
|
|
698
|
+
if ((c = ctx.s.charCodeAt(ctx.p)) === 93) {
|
|
699
|
+
ctx.p++;
|
|
700
|
+
return res;
|
|
701
|
+
}
|
|
702
|
+
res.push(extractValue(ctx, 93, integersAsBigInt));
|
|
703
|
+
skipVoid(ctx);
|
|
704
|
+
if ((c = ctx.s.charCodeAt(ctx.p++)) === 93) {
|
|
705
|
+
return res;
|
|
706
|
+
}
|
|
707
|
+
if (c !== 44) {
|
|
708
|
+
throw new TomlError("expected comma or end of structure", { toml: ctx.s, ptr: ctx.p - 1 });
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
throw new TomlError("unfinished array encountered", {
|
|
712
|
+
toml: ctx.s,
|
|
713
|
+
ptr: ctx.p
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// node_modules/.pnpm/smol-toml@1.8.0/node_modules/smol-toml/dist/parse.js
|
|
718
|
+
function peekTable(key, table, meta, type) {
|
|
719
|
+
let t = table;
|
|
720
|
+
let m = meta;
|
|
721
|
+
let k;
|
|
722
|
+
let hasOwn = false;
|
|
723
|
+
let state;
|
|
724
|
+
for (let i = 0; i < key.length; i++) {
|
|
725
|
+
if (i) {
|
|
726
|
+
t = hasOwn ? t[k] : t[k] = {};
|
|
727
|
+
m = (state = m[k]).c;
|
|
728
|
+
if (type === 0 && (state.t === 1 || state.t === 2)) {
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
if (state.t === 2) {
|
|
732
|
+
let l = t.length - 1;
|
|
733
|
+
t = t[l];
|
|
734
|
+
m = m[l].c;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
k = key[i];
|
|
738
|
+
if ((hasOwn = Object.hasOwn(t, k)) && m[k]?.t === 0 && m[k]?.d) {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
if (!hasOwn) {
|
|
742
|
+
if (k === "__proto__") {
|
|
743
|
+
Object.defineProperty(t, k, { enumerable: true, configurable: true, writable: true });
|
|
744
|
+
Object.defineProperty(m, k, { enumerable: true, configurable: true, writable: true });
|
|
745
|
+
}
|
|
746
|
+
m[k] = {
|
|
747
|
+
t: i < key.length - 1 && type === 2 ? 3 : type,
|
|
748
|
+
d: false,
|
|
749
|
+
i: 0,
|
|
750
|
+
c: {}
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
state = m[k];
|
|
755
|
+
if (state.t !== type && !(type === 1 && state.t === 3)) {
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
if (type === 2) {
|
|
759
|
+
if (!state.d) {
|
|
760
|
+
state.d = true;
|
|
761
|
+
t[k] = [];
|
|
762
|
+
}
|
|
763
|
+
t[k].push(t = {});
|
|
764
|
+
state.c[state.i++] = state = { t: 1, d: false, i: 0, c: {} };
|
|
765
|
+
}
|
|
766
|
+
if (state.d) {
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
state.d = true;
|
|
770
|
+
if (type === 1) {
|
|
771
|
+
t = hasOwn ? t[k] : t[k] = {};
|
|
772
|
+
} else if (type === 0 && hasOwn) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
return [k, t, state.c];
|
|
776
|
+
}
|
|
777
|
+
function parse(toml, { maxDepth = 1e3, integersAsBigInt } = {}) {
|
|
778
|
+
let ctx = { s: toml, p: 0, d: maxDepth };
|
|
779
|
+
let res = {};
|
|
780
|
+
let meta = {};
|
|
781
|
+
let tmp;
|
|
782
|
+
let tbl = res;
|
|
783
|
+
let m = meta;
|
|
784
|
+
skipVoid(ctx);
|
|
785
|
+
while (ctx.p < toml.length) {
|
|
786
|
+
if (toml.charCodeAt(ctx.p) === 91) {
|
|
787
|
+
let isTableArray = toml.charCodeAt(++ctx.p) === 91;
|
|
788
|
+
tmp = ctx.p += +isTableArray;
|
|
789
|
+
let k = parseKey(ctx, "]");
|
|
790
|
+
if (isTableArray) {
|
|
791
|
+
if (toml.charCodeAt(ctx.p - 1) !== 93) {
|
|
792
|
+
throw new TomlError("expected end of table declaration", {
|
|
793
|
+
toml,
|
|
794
|
+
ptr: ctx.p - 1
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
ctx.p++;
|
|
798
|
+
}
|
|
799
|
+
let p = peekTable(
|
|
800
|
+
k,
|
|
801
|
+
res,
|
|
802
|
+
meta,
|
|
803
|
+
isTableArray ? 2 : 1
|
|
804
|
+
/* Type.EXPLICIT */
|
|
805
|
+
);
|
|
806
|
+
if (!p) {
|
|
807
|
+
throw new TomlError("trying to redefine an already defined table or value", {
|
|
808
|
+
toml,
|
|
809
|
+
ptr: tmp
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
m = p[2];
|
|
813
|
+
tbl = p[1];
|
|
814
|
+
} else {
|
|
815
|
+
tmp = ctx.p;
|
|
816
|
+
let k = parseKey(ctx);
|
|
817
|
+
let p = peekTable(
|
|
818
|
+
k,
|
|
819
|
+
tbl,
|
|
820
|
+
m,
|
|
821
|
+
0
|
|
822
|
+
/* Type.DOTTED */
|
|
823
|
+
);
|
|
824
|
+
if (!p) {
|
|
825
|
+
throw new TomlError("trying to redefine an already defined table or value", {
|
|
826
|
+
toml,
|
|
827
|
+
ptr: tmp
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
p[1][p[0]] = extractValue(ctx, void 0, integersAsBigInt);
|
|
831
|
+
}
|
|
832
|
+
skipVoid(ctx, true);
|
|
833
|
+
if (ctx.p < toml.length && (tmp = toml.charCodeAt(ctx.p)) !== 10 && tmp !== 13) {
|
|
834
|
+
throw new TomlError("each key-value declaration must be followed by an end-of-line", {
|
|
835
|
+
toml,
|
|
836
|
+
ptr: ctx.p
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
skipVoid(ctx);
|
|
840
|
+
}
|
|
841
|
+
return res;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// packages/core/dist/config.js
|
|
845
|
+
var DEFAULT_LIMITS = {
|
|
846
|
+
maxConcurrent: 2,
|
|
847
|
+
choreConcurrent: 1,
|
|
848
|
+
wedgeThresholdSecs: 600,
|
|
849
|
+
maxRestartAttempts: 2,
|
|
850
|
+
wallClockSecs: 3600,
|
|
851
|
+
choreRetentionDays: 7
|
|
852
|
+
};
|
|
853
|
+
function configPath() {
|
|
854
|
+
return path4.join(lobstahHome(), "config.toml");
|
|
855
|
+
}
|
|
856
|
+
function expandHome(p) {
|
|
857
|
+
return p.startsWith("~/") ? path4.join(os2.homedir(), p.slice(2)) : p;
|
|
858
|
+
}
|
|
859
|
+
function loadConfig() {
|
|
860
|
+
const file = configPath();
|
|
861
|
+
const raw = fs7.existsSync(file) ? parse(fs7.readFileSync(file, "utf8")) : {};
|
|
862
|
+
const reposRaw = raw.repos ?? {};
|
|
863
|
+
const repos = {};
|
|
864
|
+
for (const [key, r] of Object.entries(reposRaw)) {
|
|
865
|
+
repos[key] = {
|
|
866
|
+
path: expandHome(String(r.path ?? "")),
|
|
867
|
+
origin: r.origin ? String(r.origin) : void 0,
|
|
868
|
+
trunk: String(r.trunk ?? "main"),
|
|
869
|
+
setup: Array.isArray(r.setup) ? r.setup.map(String) : void 0,
|
|
870
|
+
env: r.env ?? void 0,
|
|
871
|
+
harness: r.harness ?? void 0
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
repos,
|
|
876
|
+
harness: raw.harness ?? {},
|
|
877
|
+
limits: { ...DEFAULT_LIMITS, ...raw.limits ?? {} },
|
|
878
|
+
notifyCommand: raw.notifyCommand ? String(raw.notifyCommand) : void 0,
|
|
879
|
+
notifyVerbs: Array.isArray(raw.notifyVerbs) ? raw.notifyVerbs.map(String) : void 0,
|
|
880
|
+
remindSecs: raw.remindSecs !== void 0 ? Number(raw.remindSecs) : void 0
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// packages/core/dist/toon.js
|
|
885
|
+
function cell(v) {
|
|
886
|
+
if (v === void 0 || v === null)
|
|
887
|
+
return "";
|
|
888
|
+
const s = String(v);
|
|
889
|
+
return s.includes(",") || s.includes("\n") ? JSON.stringify(s) : s;
|
|
890
|
+
}
|
|
891
|
+
function toonKV(obj) {
|
|
892
|
+
return Object.entries(obj).filter(([, v]) => v !== void 0).map(([k, v]) => `${k}: ${cell(v)}`).join("\n");
|
|
893
|
+
}
|
|
894
|
+
function toonTable(name, rows, cols) {
|
|
895
|
+
const header = `${name}[${rows.length}]{${cols.join(",")}}:`;
|
|
896
|
+
if (rows.length === 0)
|
|
897
|
+
return header;
|
|
898
|
+
return [header, ...rows.map((r) => ` ${cols.map((c) => cell(r[c])).join(",")}`)].join("\n");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// packages/supervisor/dist/liveness.js
|
|
902
|
+
import { execFileSync } from "node:child_process";
|
|
903
|
+
function classify(input) {
|
|
904
|
+
const { hasRunner, alive, lastVerb, lastEventAt: lastEventAt2, startedAt, now, wedgeThresholdMs } = input;
|
|
905
|
+
if (lastVerb && TERMINAL_VERBS.includes(lastVerb))
|
|
906
|
+
return "terminal";
|
|
907
|
+
if (!hasRunner)
|
|
908
|
+
return "unclaimed";
|
|
909
|
+
if (alive === void 0)
|
|
910
|
+
return "unknown";
|
|
911
|
+
if (!alive)
|
|
912
|
+
return "dead";
|
|
913
|
+
const baseline = lastEventAt2 ?? startedAt;
|
|
914
|
+
if (baseline === void 0)
|
|
915
|
+
return "unknown";
|
|
916
|
+
return now - baseline > wedgeThresholdMs ? "wedged" : "busy";
|
|
917
|
+
}
|
|
918
|
+
function processStartTime(pid) {
|
|
919
|
+
try {
|
|
920
|
+
if (process.platform === "win32") {
|
|
921
|
+
const out = execFileSync("powershell.exe", ["-NoProfile", "-Command", `(Get-Process -Id ${pid}).StartTime.Ticks`], { encoding: "utf8" }).trim();
|
|
922
|
+
return out || void 0;
|
|
923
|
+
}
|
|
924
|
+
return execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8" }).trim() || void 0;
|
|
925
|
+
} catch {
|
|
926
|
+
return void 0;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
function pidAlive(pid, recordedStart) {
|
|
930
|
+
try {
|
|
931
|
+
process.kill(pid, 0);
|
|
932
|
+
} catch {
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
if (!recordedStart)
|
|
936
|
+
return true;
|
|
937
|
+
const current = processStartTime(pid);
|
|
938
|
+
return current !== void 0 && current === recordedStart;
|
|
939
|
+
}
|
|
940
|
+
function killGroup(pid, signal = "SIGTERM") {
|
|
941
|
+
if (process.platform === "win32") {
|
|
942
|
+
try {
|
|
943
|
+
execFileSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
944
|
+
} catch {
|
|
945
|
+
}
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
process.kill(-pid, signal);
|
|
950
|
+
} catch {
|
|
951
|
+
try {
|
|
952
|
+
process.kill(pid, signal);
|
|
953
|
+
} catch {
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// packages/supervisor/dist/daemon.js
|
|
959
|
+
import * as fs9 from "node:fs";
|
|
960
|
+
import * as os3 from "node:os";
|
|
961
|
+
import * as path6 from "node:path";
|
|
962
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
963
|
+
import { createRequire } from "node:module";
|
|
964
|
+
import { fileURLToPath } from "node:url";
|
|
965
|
+
|
|
966
|
+
// packages/supervisor/dist/notify.js
|
|
967
|
+
import * as fs8 from "node:fs";
|
|
968
|
+
import * as path5 from "node:path";
|
|
969
|
+
import { spawn } from "node:child_process";
|
|
970
|
+
var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
|
|
971
|
+
function cursorPath(id, lane) {
|
|
972
|
+
return path5.join(laneDirs(lane).state, `${id}.notified`);
|
|
973
|
+
}
|
|
974
|
+
function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
|
|
975
|
+
const log = readStatusLog(id, lane);
|
|
976
|
+
let cursor;
|
|
977
|
+
try {
|
|
978
|
+
cursor = Number(fs8.readFileSync(cursorPath(id, lane), "utf8")) || 0;
|
|
979
|
+
} catch {
|
|
980
|
+
cursor = void 0;
|
|
981
|
+
}
|
|
982
|
+
if (log.length <= (cursor ?? 0))
|
|
983
|
+
return [];
|
|
984
|
+
let fresh = log.slice(cursor ?? 0);
|
|
985
|
+
if (cursor === void 0 && sinceMs > 0) {
|
|
986
|
+
fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
|
|
987
|
+
}
|
|
988
|
+
fs8.writeFileSync(cursorPath(id, lane), String(log.length));
|
|
989
|
+
return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
|
|
990
|
+
}
|
|
991
|
+
function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
|
|
992
|
+
const dir = laneDirs(lane).state;
|
|
993
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
994
|
+
return fs8.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs8.statSync(path5.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
|
|
995
|
+
}
|
|
996
|
+
function execNotify(command, ev, log = () => {
|
|
997
|
+
}) {
|
|
998
|
+
const child = spawn(command, {
|
|
999
|
+
shell: true,
|
|
1000
|
+
detached: true,
|
|
1001
|
+
stdio: "ignore",
|
|
1002
|
+
env: {
|
|
1003
|
+
...process.env,
|
|
1004
|
+
LOBSTAH_ID: ev.id,
|
|
1005
|
+
LOBSTAH_LANE: ev.lane,
|
|
1006
|
+
LOBSTAH_VERB: ev.entry.verb,
|
|
1007
|
+
LOBSTAH_NOTE: ev.entry.note ?? "",
|
|
1008
|
+
LOBSTAH_AT: ev.entry.at
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
child.on("error", (err) => log(`notify: ${err.message}`));
|
|
1012
|
+
child.unref();
|
|
1013
|
+
}
|
|
1014
|
+
function captureWaitBaseline() {
|
|
1015
|
+
const lengths = {};
|
|
1016
|
+
for (const lane of ["work", "chore"]) {
|
|
1017
|
+
for (const id of notifiableIds(lane, Number.POSITIVE_INFINITY)) {
|
|
1018
|
+
lengths[`${lane}:${id}`] = readStatusLog(id, lane).length;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return lengths;
|
|
1022
|
+
}
|
|
1023
|
+
function attnCursorPath(id, lane) {
|
|
1024
|
+
return path5.join(laneDirs(lane).state, `${id}.attn`);
|
|
1025
|
+
}
|
|
1026
|
+
function readAttnCursor(file) {
|
|
1027
|
+
try {
|
|
1028
|
+
const parsed = JSON.parse(fs8.readFileSync(file, "utf8"));
|
|
1029
|
+
return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
|
|
1030
|
+
} catch {
|
|
1031
|
+
return { seen: 0, at: 0 };
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
|
|
1035
|
+
const out = [];
|
|
1036
|
+
for (const lane of ["work", "chore"]) {
|
|
1037
|
+
for (const id of activeIds(lane)) {
|
|
1038
|
+
const log = readStatusLog(id, lane);
|
|
1039
|
+
const state = reconcile({ log, lastEventAt: lastEventAt(id, lane) });
|
|
1040
|
+
if (state !== "needs-decision" && state !== "blocked")
|
|
1041
|
+
continue;
|
|
1042
|
+
const index = log.length - 1 - [...log].reverse().findIndex((e) => e.verb === state);
|
|
1043
|
+
const entry = log[index];
|
|
1044
|
+
if (!entry)
|
|
1045
|
+
continue;
|
|
1046
|
+
const file = attnCursorPath(id, lane);
|
|
1047
|
+
const cursor = readAttnCursor(file);
|
|
1048
|
+
const fresh = index >= cursor.seen;
|
|
1049
|
+
const remind = !fresh && remindMs > 0 && now - cursor.at > remindMs;
|
|
1050
|
+
if (!fresh && !remind)
|
|
1051
|
+
continue;
|
|
1052
|
+
out.push({ id, lane, entry });
|
|
1053
|
+
if (consume)
|
|
1054
|
+
fs8.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return out;
|
|
1058
|
+
}
|
|
1059
|
+
function freshWakeEvents(baseline, verbs = DEFAULT_NOTIFY_VERBS) {
|
|
1060
|
+
const out = [];
|
|
1061
|
+
for (const lane of ["work", "chore"]) {
|
|
1062
|
+
for (const id of notifiableIds(lane, Number.POSITIVE_INFINITY)) {
|
|
1063
|
+
const key = `${lane}:${id}`;
|
|
1064
|
+
const log = readStatusLog(id, lane);
|
|
1065
|
+
const seen = baseline[key] ?? 0;
|
|
1066
|
+
if (log.length > seen) {
|
|
1067
|
+
baseline[key] = log.length;
|
|
1068
|
+
for (const entry of log.slice(seen)) {
|
|
1069
|
+
if (verbs.includes(entry.verb))
|
|
1070
|
+
out.push({ id, lane, entry });
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return out;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// packages/supervisor/dist/daemon.js
|
|
1079
|
+
var require_ = createRequire(import.meta.url);
|
|
1080
|
+
function runnerEntry() {
|
|
1081
|
+
try {
|
|
1082
|
+
return require_.resolve("@lobstah/runner");
|
|
1083
|
+
} catch {
|
|
1084
|
+
return fileURLToPath(new URL("./runner.js", import.meta.url));
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
function readRunnerInfo(dir) {
|
|
1088
|
+
try {
|
|
1089
|
+
return JSON.parse(fs9.readFileSync(path6.join(dir, "runner.json"), "utf8"));
|
|
1090
|
+
} catch {
|
|
1091
|
+
return void 0;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
function listActive(lane) {
|
|
1095
|
+
const dir = laneDirs(lane).active;
|
|
1096
|
+
return fs9.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
|
|
1097
|
+
const d = path6.join(dir, id);
|
|
1098
|
+
return { id, lane, dir: d, runner: readRunnerInfo(d) };
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
function finalize(st) {
|
|
1102
|
+
try {
|
|
1103
|
+
fs9.renameSync(st.dir, path6.join(laneDirs(st.lane).done, st.id));
|
|
1104
|
+
} catch {
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
function spawnRunner(st, opts) {
|
|
1108
|
+
let nudge = opts.nudge;
|
|
1109
|
+
const handoffFile = path6.join(st.dir, "handoff");
|
|
1110
|
+
if (!nudge && fs9.existsSync(handoffFile)) {
|
|
1111
|
+
nudge = fs9.readFileSync(handoffFile, "utf8");
|
|
1112
|
+
fs9.unlinkSync(handoffFile);
|
|
1113
|
+
}
|
|
1114
|
+
const logPath = path6.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
|
|
1115
|
+
const log = fs9.openSync(logPath, "a");
|
|
1116
|
+
const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
|
|
1117
|
+
detached: true,
|
|
1118
|
+
stdio: ["ignore", log, log],
|
|
1119
|
+
env: {
|
|
1120
|
+
...process.env,
|
|
1121
|
+
LOBSTAH_ATTEMPTS: String(opts.attempts),
|
|
1122
|
+
...opts.resume ? { LOBSTAH_RESUME: opts.resume } : {},
|
|
1123
|
+
...nudge ? { LOBSTAH_NUDGE: nudge } : {}
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
fs9.closeSync(log);
|
|
1127
|
+
child.unref();
|
|
1128
|
+
const info = {
|
|
1129
|
+
pid: child.pid ?? -1,
|
|
1130
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1131
|
+
processStartTime: child.pid ? processStartTime(child.pid) : void 0,
|
|
1132
|
+
attempts: opts.attempts
|
|
1133
|
+
};
|
|
1134
|
+
fs9.writeFileSync(path6.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
|
|
1135
|
+
}
|
|
1136
|
+
function sessionOf(st) {
|
|
1137
|
+
try {
|
|
1138
|
+
const ev = JSON.parse(fs9.readFileSync(path6.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
|
|
1139
|
+
return ev.sessionId;
|
|
1140
|
+
} catch {
|
|
1141
|
+
return void 0;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
function reconcileOne(st, cfg, log) {
|
|
1145
|
+
const hasDescriptor = fs9.existsSync(path6.join(st.dir, "descriptor.json"));
|
|
1146
|
+
if (!hasDescriptor) {
|
|
1147
|
+
const age = Date.now() - fs9.statSync(st.dir).mtimeMs;
|
|
1148
|
+
if (age > 5 * 6e4)
|
|
1149
|
+
fs9.rmSync(st.dir, { recursive: true, force: true });
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const statusLog = readStatusLog(st.id, st.lane);
|
|
1153
|
+
const lastVerb = statusLog.at(-1)?.verb;
|
|
1154
|
+
const alive = st.runner ? pidAlive(st.runner.pid, st.runner.processStartTime) : void 0;
|
|
1155
|
+
if (cancelRequested(st.id, st.lane) && st.runner && alive) {
|
|
1156
|
+
log(`${st.id}: cancel requested, killing group ${st.runner.pid}`);
|
|
1157
|
+
killGroup(st.runner.pid);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
const cls = classify({
|
|
1161
|
+
hasRunner: st.runner !== void 0,
|
|
1162
|
+
alive,
|
|
1163
|
+
lastVerb,
|
|
1164
|
+
lastEventAt: lastEventAt(st.id, st.lane),
|
|
1165
|
+
startedAt: st.runner ? Date.parse(st.runner.startedAt) : void 0,
|
|
1166
|
+
now: Date.now(),
|
|
1167
|
+
wedgeThresholdMs: cfg.limits.wedgeThresholdSecs * 1e3
|
|
1168
|
+
});
|
|
1169
|
+
switch (cls) {
|
|
1170
|
+
case "unclaimed":
|
|
1171
|
+
spawnRunner(st, { attempts: 1 });
|
|
1172
|
+
log(`${st.id}: spawned runner`);
|
|
1173
|
+
break;
|
|
1174
|
+
case "terminal":
|
|
1175
|
+
if (!alive)
|
|
1176
|
+
finalize(st);
|
|
1177
|
+
break;
|
|
1178
|
+
case "busy":
|
|
1179
|
+
break;
|
|
1180
|
+
case "dead": {
|
|
1181
|
+
const attempts = (st.runner?.attempts ?? 0) + 1;
|
|
1182
|
+
if (attempts <= cfg.limits.maxRestartAttempts + 1) {
|
|
1183
|
+
log(`${st.id}: runner died, respawning (attempt ${attempts})`);
|
|
1184
|
+
spawnRunner(st, { attempts, resume: sessionOf(st) });
|
|
1185
|
+
} else {
|
|
1186
|
+
appendStatus(st.id, st.lane, "failed", "runner died repeatedly; work preserved");
|
|
1187
|
+
finalize(st);
|
|
1188
|
+
log(`${st.id}: failed after ${attempts - 1} restarts`);
|
|
1189
|
+
}
|
|
1190
|
+
break;
|
|
1191
|
+
}
|
|
1192
|
+
case "wedged": {
|
|
1193
|
+
const attempts = (st.runner?.attempts ?? 0) + 1;
|
|
1194
|
+
if (st.runner)
|
|
1195
|
+
killGroup(st.runner.pid, "SIGKILL");
|
|
1196
|
+
if (attempts <= cfg.limits.maxRestartAttempts + 1) {
|
|
1197
|
+
log(`${st.id}: wedged (no activity), forking session with a nudge (attempt ${attempts})`);
|
|
1198
|
+
spawnRunner(st, {
|
|
1199
|
+
attempts,
|
|
1200
|
+
resume: sessionOf(st),
|
|
1201
|
+
nudge: "The previous attempt stalled with no tool activity. Review git log and git status in this worktree, then continue the brief from where it stopped."
|
|
1202
|
+
});
|
|
1203
|
+
} else {
|
|
1204
|
+
appendStatus(st.id, st.lane, "failed", "wedged repeatedly; work preserved");
|
|
1205
|
+
finalize(st);
|
|
1206
|
+
log(`${st.id}: failed after repeated wedges`);
|
|
1207
|
+
}
|
|
1208
|
+
break;
|
|
1209
|
+
}
|
|
1210
|
+
case "unknown":
|
|
1211
|
+
log(`${st.id}: state unknown \u2014 leaving untouched`);
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
function writeHeartbeat(cfg) {
|
|
1216
|
+
const payload = {
|
|
1217
|
+
machineId: os3.hostname(),
|
|
1218
|
+
repos: Object.keys(cfg.repos),
|
|
1219
|
+
harnesses: ["claude", "codex"],
|
|
1220
|
+
maxConcurrent: cfg.limits.maxConcurrent,
|
|
1221
|
+
version: "0.1.0",
|
|
1222
|
+
heartbeat: (/* @__PURE__ */ new Date()).toISOString()
|
|
1223
|
+
};
|
|
1224
|
+
const tmp = `${executorPath()}.tmp`;
|
|
1225
|
+
fs9.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
1226
|
+
fs9.renameSync(tmp, executorPath());
|
|
1227
|
+
}
|
|
1228
|
+
function pruneChores(cfg) {
|
|
1229
|
+
const dir = laneDirs("chore").done;
|
|
1230
|
+
const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
|
|
1231
|
+
for (const id of fs9.readdirSync(dir)) {
|
|
1232
|
+
const p = path6.join(dir, id);
|
|
1233
|
+
if (fs9.statSync(p).mtimeMs < cutoff)
|
|
1234
|
+
fs9.rmSync(p, { recursive: true, force: true });
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
var daemonStartedAt = Date.now();
|
|
1238
|
+
function tick(log = () => {
|
|
1239
|
+
}) {
|
|
1240
|
+
const cfg = loadConfig();
|
|
1241
|
+
ensureLayout();
|
|
1242
|
+
writeHeartbeat(cfg);
|
|
1243
|
+
for (const lane of ["chore", "work"]) {
|
|
1244
|
+
const active = listActive(lane);
|
|
1245
|
+
for (const st of active)
|
|
1246
|
+
reconcileOne(st, cfg, log);
|
|
1247
|
+
const ceiling = lane === "work" ? cfg.limits.maxConcurrent : cfg.limits.choreConcurrent;
|
|
1248
|
+
let inFlight = listActive(lane).length;
|
|
1249
|
+
while (inFlight < ceiling) {
|
|
1250
|
+
const id = claimNext(lane);
|
|
1251
|
+
if (!id)
|
|
1252
|
+
break;
|
|
1253
|
+
log(`${id}: claimed (${lane})`);
|
|
1254
|
+
inFlight++;
|
|
1255
|
+
const st = listActive(lane).find((s) => s.id === id);
|
|
1256
|
+
if (st)
|
|
1257
|
+
reconcileOne(st, cfg, log);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
if (cfg.notifyCommand) {
|
|
1261
|
+
const verbs = cfg.notifyVerbs ?? DEFAULT_NOTIFY_VERBS;
|
|
1262
|
+
for (const lane of ["work", "chore"]) {
|
|
1263
|
+
for (const id of notifiableIds(lane)) {
|
|
1264
|
+
for (const ev of pendingNotifications(id, lane, verbs, daemonStartedAt)) {
|
|
1265
|
+
execNotify(cfg.notifyCommand, ev, log);
|
|
1266
|
+
log(`${id}: notified ${ev.entry.verb}`);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
pruneChores(loadConfig());
|
|
1272
|
+
}
|
|
1273
|
+
function acquireDaemonLock() {
|
|
1274
|
+
const lockPath = path6.join(lobstahHome(), "daemon.lock");
|
|
1275
|
+
try {
|
|
1276
|
+
const prior = JSON.parse(fs9.readFileSync(lockPath, "utf8"));
|
|
1277
|
+
if (pidAlive(prior.pid, prior.processStartTime)) {
|
|
1278
|
+
throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
|
|
1279
|
+
}
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
if (err instanceof Error && err.message.includes("already supervises"))
|
|
1282
|
+
throw err;
|
|
1283
|
+
}
|
|
1284
|
+
fs9.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
|
|
1285
|
+
const release = () => {
|
|
1286
|
+
try {
|
|
1287
|
+
fs9.unlinkSync(lockPath);
|
|
1288
|
+
} catch {
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
process.on("exit", release);
|
|
1292
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
1293
|
+
process.on(sig, () => {
|
|
1294
|
+
release();
|
|
1295
|
+
process.exit(0);
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
async function daemon(intervalMs = 5e3, log = console.log) {
|
|
1300
|
+
ensureLayout();
|
|
1301
|
+
acquireDaemonLock();
|
|
1302
|
+
log(`lobstah daemon: watching ${laneDirs("work").queue} every ${intervalMs}ms`);
|
|
1303
|
+
while (true) {
|
|
1304
|
+
try {
|
|
1305
|
+
tick(log);
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1308
|
+
}
|
|
1309
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// apps/pick/dist/run.js
|
|
1314
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
1315
|
+
|
|
1316
|
+
// apps/pick/dist/config.js
|
|
1317
|
+
import * as fs10 from "node:fs";
|
|
1318
|
+
import { execSync } from "node:child_process";
|
|
1319
|
+
import * as os4 from "node:os";
|
|
1320
|
+
|
|
1321
|
+
// apps/pick/dist/types.js
|
|
1322
|
+
var DEFAULT_MERGE_POLICY = {
|
|
1323
|
+
enabled: false,
|
|
1324
|
+
method: "squash",
|
|
1325
|
+
approvers: [],
|
|
1326
|
+
assigneeApproves: true,
|
|
1327
|
+
restrictedLabels: [],
|
|
1328
|
+
scope: "own"
|
|
1329
|
+
};
|
|
1330
|
+
var MARKER = /\[lobstah:([0-9a-f-]{36})\]/;
|
|
1331
|
+
function marker(uuid) {
|
|
1332
|
+
return `[lobstah:${uuid}]`;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// apps/pick/dist/config.js
|
|
1336
|
+
function resolveTokenSource(section, label) {
|
|
1337
|
+
const command = section.tokenCommand ? String(section.tokenCommand) : void 0;
|
|
1338
|
+
const file = section.tokenFile ? String(section.tokenFile) : void 0;
|
|
1339
|
+
const envName = section.tokenEnv ? String(section.tokenEnv) : void 0;
|
|
1340
|
+
if (command) {
|
|
1341
|
+
let cached;
|
|
1342
|
+
return () => {
|
|
1343
|
+
if (cached && Date.now() - cached.at < 5 * 6e4)
|
|
1344
|
+
return cached.token;
|
|
1345
|
+
const token = execSync(command, { encoding: "utf8" }).trim();
|
|
1346
|
+
if (!token)
|
|
1347
|
+
throw new Error(`${label}: tokenCommand produced no output`);
|
|
1348
|
+
cached = { token, at: Date.now() };
|
|
1349
|
+
return token;
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
if (file) {
|
|
1353
|
+
return () => {
|
|
1354
|
+
const token = fs10.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
|
|
1355
|
+
if (!token)
|
|
1356
|
+
throw new Error(`${label}: tokenFile ${file} is empty`);
|
|
1357
|
+
return token;
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
if (envName) {
|
|
1361
|
+
return () => {
|
|
1362
|
+
const token = process.env[envName];
|
|
1363
|
+
if (!token)
|
|
1364
|
+
throw new Error(`${label}: no token in $${envName}`);
|
|
1365
|
+
return token;
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
throw new Error(`${label}: configure one of tokenCommand, tokenFile, tokenEnv`);
|
|
1369
|
+
}
|
|
1370
|
+
function loadPickupConfig() {
|
|
1371
|
+
const file = configPath();
|
|
1372
|
+
const raw = fs10.existsSync(file) ? parse(fs10.readFileSync(file, "utf8")) : {};
|
|
1373
|
+
const p = raw.pickup ?? {};
|
|
1374
|
+
const out = {
|
|
1375
|
+
pollSecs: Number(p.pollSecs ?? 45),
|
|
1376
|
+
notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0
|
|
1377
|
+
};
|
|
1378
|
+
const gh = p.github;
|
|
1379
|
+
if (gh) {
|
|
1380
|
+
const token = resolveTokenSource({ tokenEnv: "GITHUB_TOKEN", ...gh }, "pickup.github");
|
|
1381
|
+
token();
|
|
1382
|
+
out.github = {
|
|
1383
|
+
repo: String(gh.repo ?? ""),
|
|
1384
|
+
key: String(gh.key ?? ""),
|
|
1385
|
+
identity: String(gh.identity ?? ""),
|
|
1386
|
+
token,
|
|
1387
|
+
startLabel: String(gh.startLabel ?? "lobstah"),
|
|
1388
|
+
claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed"),
|
|
1389
|
+
merge: { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} }
|
|
1390
|
+
};
|
|
1391
|
+
if (!out.github.repo || !out.github.key || !out.github.identity) {
|
|
1392
|
+
throw new Error("pickup.github requires repo, key, and identity");
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
const ln = p.linear;
|
|
1396
|
+
if (ln) {
|
|
1397
|
+
const token = resolveTokenSource({ tokenEnv: "LINEAR_TOKEN", ...ln }, "pickup.linear");
|
|
1398
|
+
token();
|
|
1399
|
+
out.linear = {
|
|
1400
|
+
token,
|
|
1401
|
+
startState: String(ln.startState ?? "Todo"),
|
|
1402
|
+
claimedState: String(ln.claimedState ?? "In Progress"),
|
|
1403
|
+
doneState: String(ln.doneState ?? "In Review"),
|
|
1404
|
+
route: ln.route ?? {}
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
return out;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// apps/pick/dist/state.js
|
|
1411
|
+
import * as fs11 from "node:fs";
|
|
1412
|
+
import * as path7 from "node:path";
|
|
1413
|
+
var PickupState = class {
|
|
1414
|
+
file;
|
|
1415
|
+
data;
|
|
1416
|
+
constructor(dir = path7.join(lobstahHome(), "pickup")) {
|
|
1417
|
+
fs11.mkdirSync(dir, { recursive: true });
|
|
1418
|
+
this.file = path7.join(dir, "state.json");
|
|
1419
|
+
this.data = fs11.existsSync(this.file) ? JSON.parse(fs11.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
|
|
1420
|
+
this.data.map ??= {};
|
|
1421
|
+
this.data.consumedApprovals ??= {};
|
|
1422
|
+
this.data.rebases ??= {};
|
|
1423
|
+
}
|
|
1424
|
+
save() {
|
|
1425
|
+
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
1426
|
+
fs11.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
|
|
1427
|
+
fs11.renameSync(tmp, this.file);
|
|
1428
|
+
}
|
|
1429
|
+
get(key) {
|
|
1430
|
+
return this.data.map[key];
|
|
1431
|
+
}
|
|
1432
|
+
entries() {
|
|
1433
|
+
return Object.entries(this.data.map);
|
|
1434
|
+
}
|
|
1435
|
+
set(key, entry) {
|
|
1436
|
+
this.data.map[key] = entry;
|
|
1437
|
+
this.save();
|
|
1438
|
+
}
|
|
1439
|
+
update(key, patch) {
|
|
1440
|
+
const cur = this.data.map[key];
|
|
1441
|
+
if (!cur)
|
|
1442
|
+
return;
|
|
1443
|
+
this.data.map[key] = { ...cur, ...patch };
|
|
1444
|
+
this.save();
|
|
1445
|
+
}
|
|
1446
|
+
approvalConsumed(dedupKey) {
|
|
1447
|
+
return dedupKey in this.data.consumedApprovals;
|
|
1448
|
+
}
|
|
1449
|
+
consumeApproval(dedupKey) {
|
|
1450
|
+
this.data.consumedApprovals[dedupKey] = (/* @__PURE__ */ new Date()).toISOString();
|
|
1451
|
+
this.save();
|
|
1452
|
+
}
|
|
1453
|
+
rebase(prKey) {
|
|
1454
|
+
return this.data.rebases[prKey];
|
|
1455
|
+
}
|
|
1456
|
+
setRebase(prKey, uuid) {
|
|
1457
|
+
this.data.rebases[prKey] = { uuid };
|
|
1458
|
+
this.save();
|
|
1459
|
+
}
|
|
1460
|
+
failRebase(prKey) {
|
|
1461
|
+
const cur = this.data.rebases[prKey];
|
|
1462
|
+
if (cur) {
|
|
1463
|
+
cur.failed = true;
|
|
1464
|
+
this.save();
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
clearRebase(prKey) {
|
|
1468
|
+
delete this.data.rebases[prKey];
|
|
1469
|
+
this.save();
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
// apps/pick/dist/sources/github.js
|
|
1474
|
+
var API = "https://api.github.com";
|
|
1475
|
+
var GithubSource = class {
|
|
1476
|
+
cfg;
|
|
1477
|
+
name;
|
|
1478
|
+
constructor(cfg) {
|
|
1479
|
+
this.cfg = cfg;
|
|
1480
|
+
this.name = `gh:${cfg.repo}`;
|
|
1481
|
+
}
|
|
1482
|
+
async api(method, url, body) {
|
|
1483
|
+
const res = await fetch(`${API}${url}`, {
|
|
1484
|
+
method,
|
|
1485
|
+
headers: {
|
|
1486
|
+
authorization: `Bearer ${this.cfg.token()}`,
|
|
1487
|
+
accept: "application/vnd.github+json",
|
|
1488
|
+
"user-agent": "lobstah-pick",
|
|
1489
|
+
...body ? { "content-type": "application/json" } : {}
|
|
1490
|
+
},
|
|
1491
|
+
body: body ? JSON.stringify(body) : void 0
|
|
1492
|
+
});
|
|
1493
|
+
if (!res.ok)
|
|
1494
|
+
throw new Error(`github ${method} ${url}: ${res.status} ${await res.text()}`);
|
|
1495
|
+
return res.status === 204 ? void 0 : await res.json();
|
|
1496
|
+
}
|
|
1497
|
+
issueKey(n) {
|
|
1498
|
+
return `gh:${this.cfg.repo}#${n}`;
|
|
1499
|
+
}
|
|
1500
|
+
numberOf(key) {
|
|
1501
|
+
return Number(key.split("#").pop());
|
|
1502
|
+
}
|
|
1503
|
+
async poll() {
|
|
1504
|
+
const items = [];
|
|
1505
|
+
const issues = await this.api("GET", `/repos/${this.cfg.repo}/issues?labels=${encodeURIComponent(this.cfg.startLabel)}&assignee=${this.cfg.identity}&state=open&per_page=50`);
|
|
1506
|
+
for (const issue of issues) {
|
|
1507
|
+
if (issue.pull_request)
|
|
1508
|
+
continue;
|
|
1509
|
+
items.push({
|
|
1510
|
+
key: this.issueKey(issue.number),
|
|
1511
|
+
kind: "issue",
|
|
1512
|
+
repoKey: this.cfg.key,
|
|
1513
|
+
title: issue.title,
|
|
1514
|
+
brief: `${issue.title}
|
|
1515
|
+
|
|
1516
|
+
${issue.body ?? ""}
|
|
1517
|
+
|
|
1518
|
+
When the change is complete, commit it, push the branch, and open a PR against ${this.cfg.repo} referencing #${issue.number}.`
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
const pulls = await this.api("GET", `/repos/${this.cfg.repo}/pulls?state=open&per_page=50`);
|
|
1522
|
+
for (const pr of pulls) {
|
|
1523
|
+
const m = /^lobstah\/([0-9a-f-]{36})$/.exec(pr.head.ref);
|
|
1524
|
+
if (!m)
|
|
1525
|
+
continue;
|
|
1526
|
+
const reviews = await this.latestReviews(pr.number);
|
|
1527
|
+
const changes = reviews.find((r) => r.state === "CHANGES_REQUESTED" && r.commit_id === pr.head.sha);
|
|
1528
|
+
if (!changes)
|
|
1529
|
+
continue;
|
|
1530
|
+
items.push({
|
|
1531
|
+
key: `gh:${this.cfg.repo}#pr${pr.number}@${pr.head.sha}`,
|
|
1532
|
+
kind: "review",
|
|
1533
|
+
repoKey: this.cfg.key,
|
|
1534
|
+
title: pr.title,
|
|
1535
|
+
followUp: m[1],
|
|
1536
|
+
brief: `Address the review feedback on ${pr.html_url} (branch ${pr.head.ref}). Check out that branch in this worktree, read the review comments with \`gh pr view ${pr.number} --comments\`, implement the requested changes, and push. Reply to review threads where a reply is warranted.`
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
return items;
|
|
1540
|
+
}
|
|
1541
|
+
async claim(item) {
|
|
1542
|
+
if (item.kind === "review")
|
|
1543
|
+
return true;
|
|
1544
|
+
const n = this.numberOf(item.key);
|
|
1545
|
+
const issue = await this.api("GET", `/repos/${this.cfg.repo}/issues/${n}`);
|
|
1546
|
+
if (issue.labels.some((l) => l.name === this.cfg.claimedLabel))
|
|
1547
|
+
return false;
|
|
1548
|
+
await this.api("POST", `/repos/${this.cfg.repo}/issues/${n}/labels`, { labels: [this.cfg.claimedLabel] });
|
|
1549
|
+
await this.api("DELETE", `/repos/${this.cfg.repo}/issues/${n}/labels/${encodeURIComponent(this.cfg.startLabel)}`).catch(() => {
|
|
1550
|
+
});
|
|
1551
|
+
return true;
|
|
1552
|
+
}
|
|
1553
|
+
async report(key, verb, evidence) {
|
|
1554
|
+
const n = this.numberOf(key.replace(/@.*$/, "").replace("#pr", "#"));
|
|
1555
|
+
const lines = [`${marker(evidence.uuid)} **${verb}**`];
|
|
1556
|
+
if (evidence.prUrl)
|
|
1557
|
+
lines.push(`PR: ${evidence.prUrl}`);
|
|
1558
|
+
if (evidence.branch)
|
|
1559
|
+
lines.push(`branch: \`${evidence.branch}\``);
|
|
1560
|
+
if (evidence.note)
|
|
1561
|
+
lines.push(evidence.note);
|
|
1562
|
+
await this.api("POST", `/repos/${this.cfg.repo}/issues/${n}/comments`, { body: lines.join("\n") });
|
|
1563
|
+
}
|
|
1564
|
+
async inbound(key, since) {
|
|
1565
|
+
if (key.includes("#pr"))
|
|
1566
|
+
return [];
|
|
1567
|
+
const n = this.numberOf(key);
|
|
1568
|
+
const url = `/repos/${this.cfg.repo}/issues/${n}/comments${since ? `?since=${encodeURIComponent(since)}` : ""}`;
|
|
1569
|
+
const comments = await this.api("GET", url);
|
|
1570
|
+
return comments.filter((c) => c.user.login !== this.cfg.identity && !MARKER.test(c.body)).map((c) => c.body);
|
|
1571
|
+
}
|
|
1572
|
+
async inProgress() {
|
|
1573
|
+
const issues = await this.api("GET", `/repos/${this.cfg.repo}/issues?labels=${encodeURIComponent(this.cfg.claimedLabel)}&state=all&per_page=100`);
|
|
1574
|
+
return issues.filter((i) => !i.pull_request).map((i) => ({ key: this.issueKey(i.number), open: i.state === "open" }));
|
|
1575
|
+
}
|
|
1576
|
+
async recoverUuid(key) {
|
|
1577
|
+
const n = this.numberOf(key);
|
|
1578
|
+
const comments = await this.api("GET", `/repos/${this.cfg.repo}/issues/${n}/comments?per_page=100`);
|
|
1579
|
+
for (const c of comments.reverse()) {
|
|
1580
|
+
const m = MARKER.exec(c.body);
|
|
1581
|
+
if (m)
|
|
1582
|
+
return m[1];
|
|
1583
|
+
}
|
|
1584
|
+
return void 0;
|
|
1585
|
+
}
|
|
1586
|
+
async reset(key, note) {
|
|
1587
|
+
const n = this.numberOf(key);
|
|
1588
|
+
await this.api("POST", `/repos/${this.cfg.repo}/issues/${n}/comments`, { body: note });
|
|
1589
|
+
await this.api("POST", `/repos/${this.cfg.repo}/issues/${n}/labels`, { labels: [this.cfg.startLabel] });
|
|
1590
|
+
await this.api("DELETE", `/repos/${this.cfg.repo}/issues/${n}/labels/${encodeURIComponent(this.cfg.claimedLabel)}`).catch(() => {
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
// ── MergeSource ──────────────────────────────────────────────────────────
|
|
1594
|
+
async latestReviews(n) {
|
|
1595
|
+
const all = await this.api("GET", `/repos/${this.cfg.repo}/pulls/${n}/reviews?per_page=100`);
|
|
1596
|
+
const latest = /* @__PURE__ */ new Map();
|
|
1597
|
+
for (const r of all) {
|
|
1598
|
+
if (r.state !== "APPROVED" && r.state !== "CHANGES_REQUESTED")
|
|
1599
|
+
continue;
|
|
1600
|
+
latest.set(r.user.login, r);
|
|
1601
|
+
}
|
|
1602
|
+
return [...latest.values()];
|
|
1603
|
+
}
|
|
1604
|
+
async toCandidate(pr) {
|
|
1605
|
+
const reviews = await this.latestReviews(pr.number);
|
|
1606
|
+
return {
|
|
1607
|
+
number: pr.number,
|
|
1608
|
+
url: pr.html_url,
|
|
1609
|
+
author: pr.user.login,
|
|
1610
|
+
headSha: pr.head.sha,
|
|
1611
|
+
headRef: pr.head.ref,
|
|
1612
|
+
labels: pr.labels.map((l) => l.name),
|
|
1613
|
+
assignees: pr.assignees.map((a) => a.login),
|
|
1614
|
+
reviews: reviews.map((r) => ({ id: r.id, author: r.user.login, state: r.state, sha: r.commit_id })),
|
|
1615
|
+
mergeableState: pr.draft ? "draft" : pr.mergeable_state ?? "unknown"
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
async mergeCandidates() {
|
|
1619
|
+
const pulls = await this.api("GET", `/repos/${this.cfg.repo}/pulls?state=open&per_page=50`);
|
|
1620
|
+
const own = pulls.filter((p) => p.user.login === this.cfg.identity || p.head.ref.startsWith("lobstah/"));
|
|
1621
|
+
return Promise.all(own.map((p) => this.toCandidate(p)));
|
|
1622
|
+
}
|
|
1623
|
+
async refresh(n) {
|
|
1624
|
+
const pr = await this.api("GET", `/repos/${this.cfg.repo}/pulls/${n}`);
|
|
1625
|
+
return pr.state === "open" ? this.toCandidate(pr) : void 0;
|
|
1626
|
+
}
|
|
1627
|
+
async updateBranch(n) {
|
|
1628
|
+
await this.api("PUT", `/repos/${this.cfg.repo}/pulls/${n}/update-branch`, {});
|
|
1629
|
+
}
|
|
1630
|
+
async merge(n, method) {
|
|
1631
|
+
await this.api("PUT", `/repos/${this.cfg.repo}/pulls/${n}/merge`, { merge_method: method });
|
|
1632
|
+
}
|
|
1633
|
+
async comment(n, text) {
|
|
1634
|
+
await this.api("POST", `/repos/${this.cfg.repo}/issues/${n}/comments`, { body: text });
|
|
1635
|
+
}
|
|
1636
|
+
async addLabel(n, label) {
|
|
1637
|
+
await this.api("POST", `/repos/${this.cfg.repo}/issues/${n}/labels`, { labels: [label] });
|
|
1638
|
+
}
|
|
1639
|
+
repoKey() {
|
|
1640
|
+
return this.cfg.key;
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
|
|
1644
|
+
// apps/pick/dist/sources/linear.js
|
|
1645
|
+
var API2 = "https://api.linear.app/graphql";
|
|
1646
|
+
function stateFor(cfg, verb) {
|
|
1647
|
+
if (verb === "done")
|
|
1648
|
+
return cfg.doneState;
|
|
1649
|
+
if (verb === "failed")
|
|
1650
|
+
return cfg.startState;
|
|
1651
|
+
return void 0;
|
|
1652
|
+
}
|
|
1653
|
+
var LinearSource = class {
|
|
1654
|
+
cfg;
|
|
1655
|
+
name = "linear";
|
|
1656
|
+
stateIds = /* @__PURE__ */ new Map();
|
|
1657
|
+
constructor(cfg) {
|
|
1658
|
+
this.cfg = cfg;
|
|
1659
|
+
}
|
|
1660
|
+
async gql(query, variables = {}) {
|
|
1661
|
+
const res = await fetch(API2, {
|
|
1662
|
+
method: "POST",
|
|
1663
|
+
headers: { authorization: this.cfg.token(), "content-type": "application/json" },
|
|
1664
|
+
body: JSON.stringify({ query, variables })
|
|
1665
|
+
});
|
|
1666
|
+
const json = await res.json();
|
|
1667
|
+
if (!res.ok || json.errors?.length) {
|
|
1668
|
+
throw new Error(`linear: ${json.errors?.map((e) => e.message).join("; ") ?? res.status}`);
|
|
1669
|
+
}
|
|
1670
|
+
return json.data;
|
|
1671
|
+
}
|
|
1672
|
+
async assigned(stateName) {
|
|
1673
|
+
const data = await this.gql(`query($state: String!) {
|
|
1674
|
+
issues(filter: { assignee: { isMe: { eq: true } }, state: { name: { eq: $state } } }, first: 50) {
|
|
1675
|
+
nodes { id identifier title description state { name } team { key id } }
|
|
1676
|
+
}
|
|
1677
|
+
}`, { state: stateName });
|
|
1678
|
+
return data.issues.nodes;
|
|
1679
|
+
}
|
|
1680
|
+
async stateId(teamId, name) {
|
|
1681
|
+
const cacheKey = `${teamId}:${name}`;
|
|
1682
|
+
const cached = this.stateIds.get(cacheKey);
|
|
1683
|
+
if (cached)
|
|
1684
|
+
return cached;
|
|
1685
|
+
const data = await this.gql(`query($team: String!) { team(id: $team) { states { nodes { id name } } } }`, { team: teamId });
|
|
1686
|
+
for (const s of data.team.states.nodes)
|
|
1687
|
+
this.stateIds.set(`${teamId}:${s.name}`, s.id);
|
|
1688
|
+
const id = this.stateIds.get(cacheKey);
|
|
1689
|
+
if (!id)
|
|
1690
|
+
throw new Error(`linear: team has no state named "${name}"`);
|
|
1691
|
+
return id;
|
|
1692
|
+
}
|
|
1693
|
+
async issueByKey(key) {
|
|
1694
|
+
const identifier = key.replace(/^linear:/, "");
|
|
1695
|
+
const data = await this.gql(`query($id: String!) { issue(id: $id) { id identifier title description state { name } team { key id } } }`, { id: identifier });
|
|
1696
|
+
return data.issue;
|
|
1697
|
+
}
|
|
1698
|
+
async moveTo(issue, stateName) {
|
|
1699
|
+
const stateId = await this.stateId(issue.team.id, stateName);
|
|
1700
|
+
await this.gql(`mutation($id: String!, $state: String!) { issueUpdate(id: $id, input: { stateId: $state }) { success } }`, { id: issue.id, state: stateId });
|
|
1701
|
+
}
|
|
1702
|
+
async commentOn(issue, body) {
|
|
1703
|
+
await this.gql(`mutation($id: String!, $body: String!) { commentCreate(input: { issueId: $id, body: $body }) { success } }`, { id: issue.id, body });
|
|
1704
|
+
}
|
|
1705
|
+
async poll() {
|
|
1706
|
+
const issues = await this.assigned(this.cfg.startState);
|
|
1707
|
+
return issues.flatMap((issue) => {
|
|
1708
|
+
const repoKey = this.cfg.route[issue.team.key];
|
|
1709
|
+
if (!repoKey)
|
|
1710
|
+
return [];
|
|
1711
|
+
return [
|
|
1712
|
+
{
|
|
1713
|
+
key: `linear:${issue.identifier}`,
|
|
1714
|
+
kind: "issue",
|
|
1715
|
+
repoKey,
|
|
1716
|
+
title: issue.title,
|
|
1717
|
+
brief: `${issue.identifier}: ${issue.title}
|
|
1718
|
+
|
|
1719
|
+
${issue.description ?? ""}
|
|
1720
|
+
|
|
1721
|
+
When the change is complete, commit it, push the branch, and open a PR whose description includes "Fixes ${issue.identifier}".`
|
|
1722
|
+
}
|
|
1723
|
+
];
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
async claim(item) {
|
|
1727
|
+
const issue = await this.issueByKey(item.key);
|
|
1728
|
+
if (issue.state.name !== this.cfg.startState)
|
|
1729
|
+
return false;
|
|
1730
|
+
await this.moveTo(issue, this.cfg.claimedState);
|
|
1731
|
+
return true;
|
|
1732
|
+
}
|
|
1733
|
+
async report(key, verb, evidence) {
|
|
1734
|
+
const issue = await this.issueByKey(key);
|
|
1735
|
+
const lines = [`${marker(evidence.uuid)} **${verb}**`];
|
|
1736
|
+
if (evidence.prUrl)
|
|
1737
|
+
lines.push(`PR: ${evidence.prUrl}`);
|
|
1738
|
+
if (evidence.branch)
|
|
1739
|
+
lines.push(`branch: \`${evidence.branch}\``);
|
|
1740
|
+
if (evidence.note)
|
|
1741
|
+
lines.push(evidence.note);
|
|
1742
|
+
await this.commentOn(issue, lines.join("\n"));
|
|
1743
|
+
const target = stateFor(this.cfg, verb);
|
|
1744
|
+
if (target)
|
|
1745
|
+
await this.moveTo(issue, target);
|
|
1746
|
+
}
|
|
1747
|
+
async inbound(key, since) {
|
|
1748
|
+
const identifier = key.replace(/^linear:/, "");
|
|
1749
|
+
const data = await this.gql(`query($id: String!) { issue(id: $id) { comments(first: 50) { nodes { body createdAt user { isMe } } } } }`, { id: identifier });
|
|
1750
|
+
return data.issue.comments.nodes.filter((c) => !c.user?.isMe && !MARKER.test(c.body)).filter((c) => !since || c.createdAt > since).map((c) => c.body);
|
|
1751
|
+
}
|
|
1752
|
+
async inProgress() {
|
|
1753
|
+
const issues = await this.assigned(this.cfg.claimedState);
|
|
1754
|
+
return issues.map((i) => ({ key: `linear:${i.identifier}`, open: true }));
|
|
1755
|
+
}
|
|
1756
|
+
async recoverUuid(key) {
|
|
1757
|
+
const identifier = key.replace(/^linear:/, "");
|
|
1758
|
+
const data = await this.gql(`query($id: String!) { issue(id: $id) { comments(first: 100) { nodes { body } } } }`, { id: identifier });
|
|
1759
|
+
for (const c of [...data.issue.comments.nodes].reverse()) {
|
|
1760
|
+
const m = MARKER.exec(c.body);
|
|
1761
|
+
if (m)
|
|
1762
|
+
return m[1];
|
|
1763
|
+
}
|
|
1764
|
+
return void 0;
|
|
1765
|
+
}
|
|
1766
|
+
async reset(key, note) {
|
|
1767
|
+
const issue = await this.issueByKey(key);
|
|
1768
|
+
await this.commentOn(issue, note);
|
|
1769
|
+
await this.moveTo(issue, this.cfg.startState);
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
|
|
1773
|
+
// apps/pick/dist/loops/dispatch.js
|
|
1774
|
+
import { randomUUID } from "node:crypto";
|
|
1775
|
+
async function dispatchLoop(source, state, log = () => {
|
|
1776
|
+
}) {
|
|
1777
|
+
const items = await source.poll();
|
|
1778
|
+
for (const item of items) {
|
|
1779
|
+
if (state.get(item.key))
|
|
1780
|
+
continue;
|
|
1781
|
+
if (!await source.claim(item)) {
|
|
1782
|
+
log(`${item.key}: claim lost \u2014 another machine has it`);
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
const id = randomUUID();
|
|
1786
|
+
enqueue({
|
|
1787
|
+
id,
|
|
1788
|
+
repo: item.repoKey,
|
|
1789
|
+
brief: item.brief,
|
|
1790
|
+
// A review dispatch forks the implementation session; issues start cold.
|
|
1791
|
+
...item.kind === "review" && item.followUp ? { followUp: item.followUp } : {}
|
|
1792
|
+
}, "work");
|
|
1793
|
+
state.set(item.key, { uuid: id, kind: item.kind, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1794
|
+
log(`${item.key}: dispatched as ${id}`);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// apps/pick/dist/loops/report.js
|
|
1799
|
+
import * as fs12 from "node:fs";
|
|
1800
|
+
import * as path8 from "node:path";
|
|
1801
|
+
function dispatchLane(uuid) {
|
|
1802
|
+
for (const lane of ["work", "chore"]) {
|
|
1803
|
+
const d = laneDirs(lane);
|
|
1804
|
+
if (fs12.existsSync(path8.join(d.active, uuid)) || fs12.existsSync(path8.join(d.queue, `${uuid}.json`)) || fs12.existsSync(path8.join(d.done, uuid)) || fs12.existsSync(path8.join(d.state, `${uuid}.status`))) {
|
|
1805
|
+
return lane;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
return void 0;
|
|
1809
|
+
}
|
|
1810
|
+
async function reportLoop(source, state, log = () => {
|
|
1811
|
+
}, notify = () => {
|
|
1812
|
+
}) {
|
|
1813
|
+
for (const [key, entry] of state.entries()) {
|
|
1814
|
+
const lane = dispatchLane(entry.uuid);
|
|
1815
|
+
if (!lane)
|
|
1816
|
+
continue;
|
|
1817
|
+
const verb = reconcile({
|
|
1818
|
+
log: readStatusLog(entry.uuid, lane),
|
|
1819
|
+
lastEventAt: lastEventAt(entry.uuid, lane)
|
|
1820
|
+
});
|
|
1821
|
+
if (verb !== "unknown" && verb !== entry.lastReported) {
|
|
1822
|
+
const evidence = readEvidence(entry.uuid, lane);
|
|
1823
|
+
await source.report(key, verb, { ...evidence, uuid: entry.uuid });
|
|
1824
|
+
state.update(key, { lastReported: verb });
|
|
1825
|
+
log(`${key}: reported ${verb}`);
|
|
1826
|
+
notify({
|
|
1827
|
+
key,
|
|
1828
|
+
uuid: entry.uuid,
|
|
1829
|
+
verb,
|
|
1830
|
+
note: readStatusLog(entry.uuid, lane).at(-1)?.note,
|
|
1831
|
+
prUrl: evidence.prUrl
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
const msgs = await source.inbound(key, entry.lastInboundAt);
|
|
1835
|
+
if (msgs.length > 0) {
|
|
1836
|
+
for (const m of msgs)
|
|
1837
|
+
sendMessage(entry.uuid, lane, m);
|
|
1838
|
+
state.update(key, { lastInboundAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1839
|
+
log(`${key}: forwarded ${msgs.length} comment(s) to inbox`);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// apps/pick/dist/loops/reconcile.js
|
|
1845
|
+
import * as fs13 from "node:fs";
|
|
1846
|
+
import * as path9 from "node:path";
|
|
1847
|
+
function dispatchActive(uuid) {
|
|
1848
|
+
for (const lane of ["work", "chore"]) {
|
|
1849
|
+
const d = laneDirs(lane);
|
|
1850
|
+
if (fs13.existsSync(path9.join(d.active, uuid)) || fs13.existsSync(path9.join(d.queue, `${uuid}.json`))) {
|
|
1851
|
+
return lane;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
return void 0;
|
|
1855
|
+
}
|
|
1856
|
+
async function reconcileLoop(source, state, log = () => {
|
|
1857
|
+
}) {
|
|
1858
|
+
for (const { key, open } of await source.inProgress()) {
|
|
1859
|
+
let entry = state.get(key);
|
|
1860
|
+
if (!entry) {
|
|
1861
|
+
const recovered = await source.recoverUuid(key);
|
|
1862
|
+
if (recovered) {
|
|
1863
|
+
state.set(key, { uuid: recovered, kind: "issue", createdAt: (/* @__PURE__ */ new Date()).toISOString(), recovered: true });
|
|
1864
|
+
entry = state.get(key);
|
|
1865
|
+
log(`${key}: rebuilt mapping to ${recovered} from the tracker trail`);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
if (!entry) {
|
|
1869
|
+
if (open) {
|
|
1870
|
+
await source.reset(key, "no dispatch backs this item \u2014 resetting to the start state");
|
|
1871
|
+
log(`${key}: orphaned item, reset`);
|
|
1872
|
+
}
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
if (!open) {
|
|
1876
|
+
const lane = dispatchActive(entry.uuid);
|
|
1877
|
+
if (lane && !cancelRequested(entry.uuid, lane)) {
|
|
1878
|
+
try {
|
|
1879
|
+
requestCancel(entry.uuid, lane);
|
|
1880
|
+
log(`${key}: item closed, cancelled orphaned dispatch ${entry.uuid}`);
|
|
1881
|
+
} catch {
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// apps/pick/dist/loops/merge.js
|
|
1889
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1890
|
+
import * as fs14 from "node:fs";
|
|
1891
|
+
import * as path10 from "node:path";
|
|
1892
|
+
function qualifyingSet(policy, pr) {
|
|
1893
|
+
const restricted = pr.labels.some((l) => policy.restrictedLabels.includes(l));
|
|
1894
|
+
if (restricted || !policy.assigneeApproves)
|
|
1895
|
+
return [...policy.approvers];
|
|
1896
|
+
return [.../* @__PURE__ */ new Set([...policy.approvers, ...pr.assignees])];
|
|
1897
|
+
}
|
|
1898
|
+
function qualifiedApproval(policy, pr) {
|
|
1899
|
+
const outstanding = pr.reviews.some((r) => r.state === "CHANGES_REQUESTED");
|
|
1900
|
+
if (outstanding)
|
|
1901
|
+
return void 0;
|
|
1902
|
+
const set = qualifyingSet(policy, pr);
|
|
1903
|
+
return pr.reviews.find((r) => r.state === "APPROVED" && r.sha === pr.headSha && set.includes(r.author));
|
|
1904
|
+
}
|
|
1905
|
+
function approvalDedupKey(pr, review) {
|
|
1906
|
+
return `${pr.number}#${review.id}@${pr.headSha}`;
|
|
1907
|
+
}
|
|
1908
|
+
function rebaseBrief(pr) {
|
|
1909
|
+
return [
|
|
1910
|
+
`Rebase the branch ${pr.headRef} of ${pr.url} onto its base branch, resolving any conflicts`,
|
|
1911
|
+
`in a way that preserves the intent of both sides. Then push the rebased branch with`,
|
|
1912
|
+
`\`git push --force-with-lease\`. Do not merge the PR. Do not change anything beyond conflict`,
|
|
1913
|
+
`resolution. When pushed, report status done.`
|
|
1914
|
+
].join(" ");
|
|
1915
|
+
}
|
|
1916
|
+
function choreVerdict(uuid) {
|
|
1917
|
+
const d = laneDirs("chore");
|
|
1918
|
+
if (fs14.existsSync(path10.join(d.active, uuid)) || fs14.existsSync(path10.join(d.queue, `${uuid}.json`)))
|
|
1919
|
+
return "active";
|
|
1920
|
+
const last = readStatusLog(uuid, "chore").at(-1)?.verb;
|
|
1921
|
+
return last === "done" ? "done" : "failed";
|
|
1922
|
+
}
|
|
1923
|
+
async function mergeLoop(ms, policy, state, log = () => {
|
|
1924
|
+
}) {
|
|
1925
|
+
if (!policy.enabled)
|
|
1926
|
+
return;
|
|
1927
|
+
for (const candidate of await ms.mergeCandidates()) {
|
|
1928
|
+
const prKey = `${ms.name}#${candidate.number}`;
|
|
1929
|
+
const rebase = state.rebase(prKey);
|
|
1930
|
+
if (rebase) {
|
|
1931
|
+
if (rebase.failed)
|
|
1932
|
+
continue;
|
|
1933
|
+
const verdict = choreVerdict(rebase.uuid);
|
|
1934
|
+
if (verdict === "active")
|
|
1935
|
+
continue;
|
|
1936
|
+
if (verdict === "failed") {
|
|
1937
|
+
await ms.comment(candidate.number, `Automated rebase failed (dispatch ${rebase.uuid}) \u2014 needs a human.`);
|
|
1938
|
+
await ms.addLabel(candidate.number, "needs-human");
|
|
1939
|
+
state.failRebase(prKey);
|
|
1940
|
+
log(`${prKey}: rebase chore failed, flagged for a human`);
|
|
1941
|
+
continue;
|
|
1942
|
+
}
|
|
1943
|
+
state.clearRebase(prKey);
|
|
1944
|
+
}
|
|
1945
|
+
const provisional = qualifiedApproval(policy, candidate);
|
|
1946
|
+
if (!provisional)
|
|
1947
|
+
continue;
|
|
1948
|
+
if (state.approvalConsumed(approvalDedupKey(candidate, provisional)))
|
|
1949
|
+
continue;
|
|
1950
|
+
const pr = await ms.refresh(candidate.number);
|
|
1951
|
+
if (!pr)
|
|
1952
|
+
continue;
|
|
1953
|
+
const approval = qualifiedApproval(policy, pr);
|
|
1954
|
+
if (!approval) {
|
|
1955
|
+
log(`${prKey}: gate no longer holds on fresh fetch \u2014 skipping`);
|
|
1956
|
+
continue;
|
|
1957
|
+
}
|
|
1958
|
+
const dedupKey = approvalDedupKey(pr, approval);
|
|
1959
|
+
if (state.approvalConsumed(dedupKey))
|
|
1960
|
+
continue;
|
|
1961
|
+
switch (pr.mergeableState) {
|
|
1962
|
+
case "blocked":
|
|
1963
|
+
case "draft":
|
|
1964
|
+
log(`${prKey}: ${pr.mergeableState} \u2014 not merging`);
|
|
1965
|
+
break;
|
|
1966
|
+
case "behind":
|
|
1967
|
+
await ms.updateBranch(pr.number);
|
|
1968
|
+
log(`${prKey}: behind base, updated branch \u2014 gate re-enters next tick`);
|
|
1969
|
+
break;
|
|
1970
|
+
case "dirty": {
|
|
1971
|
+
const uuid = randomUUID2();
|
|
1972
|
+
enqueue({ id: uuid, repo: ms.repoKey(), brief: rebaseBrief(pr) }, "chore");
|
|
1973
|
+
state.setRebase(prKey, uuid);
|
|
1974
|
+
log(`${prKey}: real conflict \u2014 wrote rebase chore ${uuid}`);
|
|
1975
|
+
break;
|
|
1976
|
+
}
|
|
1977
|
+
default: {
|
|
1978
|
+
await ms.merge(pr.number, policy.method);
|
|
1979
|
+
state.consumeApproval(dedupKey);
|
|
1980
|
+
log(`${prKey}: merged (${policy.method}) on approval by ${approval.author}`);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// apps/pick/dist/run.js
|
|
1987
|
+
function makeNotifier(command, log) {
|
|
1988
|
+
if (!command)
|
|
1989
|
+
return () => {
|
|
1990
|
+
};
|
|
1991
|
+
return (n) => {
|
|
1992
|
+
const child = spawn3(command, {
|
|
1993
|
+
shell: true,
|
|
1994
|
+
env: {
|
|
1995
|
+
...process.env,
|
|
1996
|
+
LOBSTAH_KEY: n.key,
|
|
1997
|
+
LOBSTAH_UUID: n.uuid,
|
|
1998
|
+
LOBSTAH_VERB: n.verb,
|
|
1999
|
+
LOBSTAH_NOTE: n.note ?? "",
|
|
2000
|
+
LOBSTAH_PR_URL: n.prUrl ?? ""
|
|
2001
|
+
},
|
|
2002
|
+
stdio: "ignore",
|
|
2003
|
+
detached: true
|
|
2004
|
+
});
|
|
2005
|
+
child.on("error", (err) => log(`notify: ${err.message}`));
|
|
2006
|
+
child.unref();
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
async function cycle(sources, merges, state, log, notify) {
|
|
2010
|
+
for (const source of sources) {
|
|
2011
|
+
try {
|
|
2012
|
+
await dispatchLoop(source, state, log);
|
|
2013
|
+
await reportLoop(source, state, log, notify);
|
|
2014
|
+
await reconcileLoop(source, state, log);
|
|
2015
|
+
} catch (err) {
|
|
2016
|
+
log(`${source.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
for (const { source, policy } of merges) {
|
|
2020
|
+
try {
|
|
2021
|
+
await mergeLoop(source, policy, state, log);
|
|
2022
|
+
} catch (err) {
|
|
2023
|
+
log(`${source.name} merge: ${err instanceof Error ? err.message : String(err)}`);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
async function runPickup(mode = "daemon") {
|
|
2028
|
+
ensureLayout();
|
|
2029
|
+
const cfg = loadPickupConfig();
|
|
2030
|
+
const state = new PickupState();
|
|
2031
|
+
const log = (m) => console.log(`[pickup] ${m}`);
|
|
2032
|
+
const sources = [];
|
|
2033
|
+
const merges = [];
|
|
2034
|
+
if (cfg.github) {
|
|
2035
|
+
const gh = new GithubSource(cfg.github);
|
|
2036
|
+
sources.push(gh);
|
|
2037
|
+
merges.push({ source: gh, policy: cfg.github.merge });
|
|
2038
|
+
}
|
|
2039
|
+
if (cfg.linear)
|
|
2040
|
+
sources.push(new LinearSource(cfg.linear));
|
|
2041
|
+
if (sources.length === 0) {
|
|
2042
|
+
throw new Error("no [pickup.github] or [pickup.linear] configured \u2014 nothing to poll");
|
|
2043
|
+
}
|
|
2044
|
+
const notify = makeNotifier(cfg.notifyCommand, log);
|
|
2045
|
+
if (mode === "once") {
|
|
2046
|
+
await cycle(sources, merges, state, log, notify);
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
log(`polling every ${cfg.pollSecs}s \u2014 no webhooks, no inbound surface`);
|
|
2050
|
+
while (true) {
|
|
2051
|
+
await cycle(sources, merges, state, log, notify);
|
|
2052
|
+
await new Promise((r) => setTimeout(r, cfg.pollSecs * 1e3));
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
// apps/cli/src/hooks.ts
|
|
2057
|
+
var HAUL_HOOK = { type: "command", command: "lobstah man haul", timeout: 14400 };
|
|
2058
|
+
function mergeHaulHook(existing) {
|
|
2059
|
+
const settings = existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {};
|
|
2060
|
+
settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
|
|
2061
|
+
const stop = Array.isArray(settings.hooks.Stop) ? settings.hooks.Stop : [];
|
|
2062
|
+
const present = stop.some((m) => (m.hooks ?? []).some((h) => h.command?.includes("lobstah man haul")));
|
|
2063
|
+
if (present) return { settings, changed: false };
|
|
2064
|
+
stop.push({ hooks: [{ ...HAUL_HOOK }] });
|
|
2065
|
+
settings.hooks.Stop = stop;
|
|
2066
|
+
return { settings, changed: true };
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// apps/cli/src/cull.ts
|
|
2070
|
+
import * as fs15 from "node:fs";
|
|
2071
|
+
import * as path11 from "node:path";
|
|
2072
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2073
|
+
var DAY = 864e5;
|
|
2074
|
+
function idsIn(dir) {
|
|
2075
|
+
try {
|
|
2076
|
+
return new Set(fs15.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
|
|
2077
|
+
} catch {
|
|
2078
|
+
return /* @__PURE__ */ new Set();
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
function planCull(olderThanDays, now = Date.now()) {
|
|
2082
|
+
const cutoff = now - olderThanDays * DAY;
|
|
2083
|
+
const items = [];
|
|
2084
|
+
const live = /* @__PURE__ */ new Set();
|
|
2085
|
+
const doneMtimes = /* @__PURE__ */ new Map();
|
|
2086
|
+
for (const lane of ["work", "chore"]) {
|
|
2087
|
+
const d = laneDirs(lane);
|
|
2088
|
+
for (const id of idsIn(d.queue)) live.add(id);
|
|
2089
|
+
for (const id of idsIn(d.active)) live.add(id);
|
|
2090
|
+
for (const id of idsIn(d.done)) {
|
|
2091
|
+
const p = path11.join(d.done, id);
|
|
2092
|
+
const m = fs15.statSync(p).mtimeMs;
|
|
2093
|
+
doneMtimes.set(id, m);
|
|
2094
|
+
if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
const wtRoot = path11.join(lobstahHome(), "worktrees");
|
|
2098
|
+
for (const id of idsIn(wtRoot)) {
|
|
2099
|
+
if (live.has(id)) continue;
|
|
2100
|
+
const doneAt = doneMtimes.get(id);
|
|
2101
|
+
if (doneAt !== void 0 && doneAt >= cutoff) continue;
|
|
2102
|
+
const p = path11.join(wtRoot, id);
|
|
2103
|
+
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs15.statSync(p).mtimeMs) / DAY) });
|
|
2104
|
+
}
|
|
2105
|
+
for (const lane of ["work", "chore"]) {
|
|
2106
|
+
const d = laneDirs(lane);
|
|
2107
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2108
|
+
for (const f of fs15.readdirSync(d.state)) {
|
|
2109
|
+
const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
|
|
2110
|
+
if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
|
|
2111
|
+
const m = fs15.statSync(path11.join(d.state, f)).mtimeMs;
|
|
2112
|
+
if (m >= cutoff) continue;
|
|
2113
|
+
seen.add(id);
|
|
2114
|
+
items.push({ kind: "state", id, target: path11.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
return items;
|
|
2118
|
+
}
|
|
2119
|
+
function removeWorktree(id, dir) {
|
|
2120
|
+
for (const lane of ["work", "chore"]) {
|
|
2121
|
+
const descFile = path11.join(laneDirs(lane).done, id, "descriptor.json");
|
|
2122
|
+
try {
|
|
2123
|
+
const desc = JSON.parse(fs15.readFileSync(descFile, "utf8"));
|
|
2124
|
+
const repo = loadConfig().repos[desc.repo];
|
|
2125
|
+
if (repo) {
|
|
2126
|
+
execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
} catch {
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
fs15.rmSync(dir, { recursive: true, force: true });
|
|
2133
|
+
}
|
|
2134
|
+
function applyCull(items) {
|
|
2135
|
+
for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
|
|
2136
|
+
for (const item of items.filter((i) => i.kind === "done")) fs15.rmSync(item.target, { recursive: true, force: true });
|
|
2137
|
+
for (const item of items.filter((i) => i.kind === "state")) {
|
|
2138
|
+
const dir = path11.dirname(item.target);
|
|
2139
|
+
for (const f of fs15.readdirSync(dir)) {
|
|
2140
|
+
if (f.startsWith(`${item.id}.`)) fs15.rmSync(path11.join(dir, f), { force: true });
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// apps/cli/src/manual.ts
|
|
2146
|
+
var MANUAL = `the lobsterman's manual
|
|
2147
|
+
|
|
2148
|
+
You are the lobsterman: one interactive session that sets traps (dispatches),
|
|
2149
|
+
reads buoys (status), and hauls when something needs you. The boat (daemon)
|
|
2150
|
+
does the supervision \u2014 you never watch a trap work.
|
|
2151
|
+
|
|
2152
|
+
your working set:
|
|
2153
|
+
lobstah set --repo <key> --bait <brief.md> set a trap (alias of dispatch)
|
|
2154
|
+
lobstah buoys scan the water (alias of ls)
|
|
2155
|
+
lobstah buoy <id> check one buoy (alias of status)
|
|
2156
|
+
lobstah logs <id> --follow listen on one line
|
|
2157
|
+
lobstah send <id> "<instruction>" more bait, delivered between turns
|
|
2158
|
+
lobstah attach <id> bring one trap alongside
|
|
2159
|
+
lobstah swap <id> --harness codex re-rig on the same spot
|
|
2160
|
+
lobstah catch <id> land the catch (evidence)
|
|
2161
|
+
lobstah cancel <id> cut one away
|
|
2162
|
+
lobstah cull [--apply] sweep aged catch and lost gear
|
|
2163
|
+
|
|
2164
|
+
getting woken instead of asking:
|
|
2165
|
+
lobstah man wait block until a dispatch needs attention (arm it as a
|
|
2166
|
+
background task; re-arm after each wake)
|
|
2167
|
+
lobstah man init install the Stop-hook park, then launch the
|
|
2168
|
+
designated session with: LOBSTAH_MAN=1 claude
|
|
2169
|
+
lobstah man wait --peek at session start \u2014 resurface anything standing
|
|
2170
|
+
|
|
2171
|
+
paste into your liaison instructions:
|
|
2172
|
+
For any task that should run in the background, dispatch it with the lobstah
|
|
2173
|
+
CLI instead of doing it inline. Write briefs that stand alone \u2014 the worker
|
|
2174
|
+
has no other context. Check progress when asked, not on a loop. A dispatch
|
|
2175
|
+
reporting needs-decision is waiting on the human: surface its question
|
|
2176
|
+
immediately, then \`lobstah send\` the answer. done means brief fulfilled \u2014
|
|
2177
|
+
report the catch (\`lobstah catch <id>\`) and never merge anything yourself.
|
|
2178
|
+
|
|
2179
|
+
full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
|
|
2180
|
+
|
|
2181
|
+
// apps/cli/src/main.ts
|
|
2182
|
+
var HELP = `lobstah \u2014 supervisor for local coding agents
|
|
2183
|
+
|
|
2184
|
+
work (humans and agents):
|
|
2185
|
+
dispatch --repo <key> (--brief <file> | --brief-text <text>) (alias: set --bait)
|
|
2186
|
+
[--harness claude|codex] [--model <m>] [--effort <e>]
|
|
2187
|
+
[--follow-up <uuid>] [--chore] [--id <uuid>]
|
|
2188
|
+
queue a supervised dispatch; prints the id
|
|
2189
|
+
ls [--all] queue, active, recent done (alias: buoys)
|
|
2190
|
+
status [<uuid>] reconciled state (alias: buoy)
|
|
2191
|
+
logs <uuid> [--follow] the normalized event stream
|
|
2192
|
+
send <uuid> <message> deliver an instruction between agent turns
|
|
2193
|
+
inbox <uuid> read and acknowledge pending messages
|
|
2194
|
+
(workers: check at natural checkpoints)
|
|
2195
|
+
attach <uuid> [--print] [--force]
|
|
2196
|
+
open the dispatch's own harness session in
|
|
2197
|
+
its worktree (claude --resume / codex resume)
|
|
2198
|
+
swap <uuid> [--harness claude|codex] [--model <m>] [--effort <e>]
|
|
2199
|
+
hand an active dispatch to a fresh session \u2014
|
|
2200
|
+
same worktree and brief plus a git progress
|
|
2201
|
+
note; conversations don't cross harnesses
|
|
2202
|
+
catch <uuid> the evidence: branch, commits, PR, session
|
|
2203
|
+
cull [--older-than <days>] [--apply]
|
|
2204
|
+
sweep aged catch and lost gear \u2014 old done/
|
|
2205
|
+
entries, orphaned worktrees, stale state.
|
|
2206
|
+
Dry run by default (14 days).
|
|
2207
|
+
cancel <uuid> request cancellation
|
|
2208
|
+
|
|
2209
|
+
host processes (run under launchd/systemd/pm2):
|
|
2210
|
+
daemon [--interval <ms>] the supervisor: claims, worktrees, liveness,
|
|
2211
|
+
restart ladder, notifyCommand. One per home.
|
|
2212
|
+
pick [once] tracker loops: poll Linear/GitHub, dispatch
|
|
2213
|
+
assigned work, report back, reconcile, merge
|
|
2214
|
+
|
|
2215
|
+
lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual):
|
|
2216
|
+
man wait [--timeout <secs>] [--peek]
|
|
2217
|
+
block until a dispatch needs attention, then
|
|
2218
|
+
print the event and what to do next; exit 2
|
|
2219
|
+
on timeout. Unanswered questions re-fire
|
|
2220
|
+
every remindSecs until answered.
|
|
2221
|
+
man init [--shared] [--marker] install the haul Stop hook into this
|
|
2222
|
+
project's .claude/settings.local.json
|
|
2223
|
+
(--shared: settings.json); --marker touches
|
|
2224
|
+
.lobstah-man. Idempotent.
|
|
2225
|
+
man haul [--timeout <secs>] Stop-hook entry point: park the session while
|
|
2226
|
+
work is in flight; prints hook-decision JSON
|
|
2227
|
+
on an event, silent exit 0 otherwise. Gate:
|
|
2228
|
+
LOBSTAH_MAN=1 or a .lobstah-man file.
|
|
2229
|
+
|
|
2230
|
+
workers (dispatched agents; injected into every brief):
|
|
2231
|
+
report <uuid> <verb> [note] [--pr <url>]
|
|
2232
|
+
the validated status write path
|
|
2233
|
+
(${VERBS.join(" | ")})
|
|
2234
|
+
|
|
2235
|
+
setup:
|
|
2236
|
+
init create ~/.lobstah + example config
|
|
2237
|
+
|
|
2238
|
+
Everything except daemon and pick works with both stopped: writes are files,
|
|
2239
|
+
reads are files. Output is TOON; agents can drive this CLI directly.
|
|
2240
|
+
Home: $LOBSTAH_HOME (default ~/.lobstah) \u2014 one daemon per home, enforced.`;
|
|
2241
|
+
function arg(args, flag) {
|
|
2242
|
+
const i = args.indexOf(flag);
|
|
2243
|
+
return i >= 0 ? args[i + 1] : void 0;
|
|
2244
|
+
}
|
|
2245
|
+
function findLane(id) {
|
|
2246
|
+
for (const lane of ["work", "chore"]) {
|
|
2247
|
+
const d = laneDirs(lane);
|
|
2248
|
+
if (fs16.existsSync(path12.join(d.active, id)) || fs16.existsSync(path12.join(d.queue, `${id}.json`)) || fs16.existsSync(path12.join(d.done, id)) || fs16.existsSync(path12.join(d.state, `${id}.status`))) {
|
|
2249
|
+
return lane;
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
throw new Error(`unknown dispatch ${id}`);
|
|
2253
|
+
}
|
|
2254
|
+
function rowsFor(lane, bucket) {
|
|
2255
|
+
const dir = laneDirs(lane)[bucket];
|
|
2256
|
+
const entries = fs16.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs16.statSync(path12.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
|
|
2257
|
+
const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
|
|
2258
|
+
return sliced.map(({ f, m }) => {
|
|
2259
|
+
const id = f.replace(/\.json$/, "");
|
|
2260
|
+
const state = reconcile({ log: readStatusLog(id, lane), lastEventAt: lastEventAt(id, lane) });
|
|
2261
|
+
return { id, lane, bucket, state, updated: new Date(m).toISOString() };
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
async function mainCli() {
|
|
2265
|
+
let [cmd, ...args] = process.argv.slice(2);
|
|
2266
|
+
const ALIASES = { set: "dispatch", buoys: "ls", buoy: "status" };
|
|
2267
|
+
cmd = cmd !== void 0 ? ALIASES[cmd] ?? cmd : cmd;
|
|
2268
|
+
if (cmd === "man") {
|
|
2269
|
+
cmd = args.length > 0 ? `man:${args[0]}` : "man:manual";
|
|
2270
|
+
args = args.slice(1);
|
|
2271
|
+
}
|
|
2272
|
+
ensureLayout();
|
|
2273
|
+
switch (cmd) {
|
|
2274
|
+
case "dispatch": {
|
|
2275
|
+
const repo = arg(args, "--repo");
|
|
2276
|
+
const briefFile = arg(args, "--brief") ?? arg(args, "--bait");
|
|
2277
|
+
const briefText = arg(args, "--brief-text");
|
|
2278
|
+
if (!repo || !briefFile && !briefText) {
|
|
2279
|
+
throw new Error("dispatch requires --repo and --brief <file> (or --brief-text)");
|
|
2280
|
+
}
|
|
2281
|
+
const d = {
|
|
2282
|
+
id: arg(args, "--id") ?? randomUUID3(),
|
|
2283
|
+
repo,
|
|
2284
|
+
brief: briefText ?? fs16.readFileSync(briefFile, "utf8"),
|
|
2285
|
+
harness: arg(args, "--harness"),
|
|
2286
|
+
model: arg(args, "--model"),
|
|
2287
|
+
effort: arg(args, "--effort"),
|
|
2288
|
+
followUp: arg(args, "--follow-up")
|
|
2289
|
+
};
|
|
2290
|
+
const lane = args.includes("--chore") ? "chore" : "work";
|
|
2291
|
+
enqueue(d, lane);
|
|
2292
|
+
console.log(toonKV({ id: d.id, repo, lane, queued: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
2293
|
+
break;
|
|
2294
|
+
}
|
|
2295
|
+
case "ls": {
|
|
2296
|
+
const lanes = args.includes("--all") ? ["work", "chore"] : ["work"];
|
|
2297
|
+
const rows = lanes.flatMap(
|
|
2298
|
+
(lane) => ["queue", "active", "done"].flatMap((b) => rowsFor(lane, b))
|
|
2299
|
+
);
|
|
2300
|
+
console.log(toonTable("dispatches", rows, ["id", "lane", "bucket", "state", "updated"]));
|
|
2301
|
+
break;
|
|
2302
|
+
}
|
|
2303
|
+
case "status": {
|
|
2304
|
+
const id = args[0];
|
|
2305
|
+
if (!id) {
|
|
2306
|
+
const rows = ["work", "chore"].flatMap((lane2) => rowsFor(lane2, "active"));
|
|
2307
|
+
console.log(toonTable("active", rows, ["id", "lane", "state", "updated"]));
|
|
2308
|
+
break;
|
|
2309
|
+
}
|
|
2310
|
+
const lane = findLane(id);
|
|
2311
|
+
const log = readStatusLog(id, lane);
|
|
2312
|
+
const state = reconcile({ log, lastEventAt: lastEventAt(id, lane) });
|
|
2313
|
+
console.log(toonKV({ id, lane, state, lastNote: log.at(-1)?.note, entries: log.length }));
|
|
2314
|
+
break;
|
|
2315
|
+
}
|
|
2316
|
+
case "logs": {
|
|
2317
|
+
const id = args[0];
|
|
2318
|
+
if (!id) throw new Error("logs requires a dispatch id");
|
|
2319
|
+
const lane = findLane(id);
|
|
2320
|
+
const file = eventsPath(id, lane);
|
|
2321
|
+
if (fs16.existsSync(file)) process.stdout.write(fs16.readFileSync(file, "utf8"));
|
|
2322
|
+
if (args.includes("--follow")) {
|
|
2323
|
+
let size = fs16.existsSync(file) ? fs16.statSync(file).size : 0;
|
|
2324
|
+
setInterval(() => {
|
|
2325
|
+
if (!fs16.existsSync(file)) return;
|
|
2326
|
+
const now = fs16.statSync(file).size;
|
|
2327
|
+
if (now > size) {
|
|
2328
|
+
const fd = fs16.openSync(file, "r");
|
|
2329
|
+
const buf = Buffer.alloc(now - size);
|
|
2330
|
+
fs16.readSync(fd, buf, 0, buf.length, size);
|
|
2331
|
+
fs16.closeSync(fd);
|
|
2332
|
+
process.stdout.write(buf.toString("utf8"));
|
|
2333
|
+
size = now;
|
|
2334
|
+
}
|
|
2335
|
+
}, 1e3);
|
|
2336
|
+
await new Promise(() => {
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
break;
|
|
2340
|
+
}
|
|
2341
|
+
case "send": {
|
|
2342
|
+
const [id, ...rest] = args;
|
|
2343
|
+
if (!id || rest.length === 0) throw new Error("send requires an id and a message");
|
|
2344
|
+
const name = sendMessage(id, findLane(id), rest.join(" "));
|
|
2345
|
+
console.log(toonKV({ id, queued: name }));
|
|
2346
|
+
break;
|
|
2347
|
+
}
|
|
2348
|
+
case "report": {
|
|
2349
|
+
const [id, verb, ...rest] = args;
|
|
2350
|
+
if (!id || !verb) throw new Error(`report requires an id and a verb (${VERBS.join("|")})`);
|
|
2351
|
+
const lane = findLane(id);
|
|
2352
|
+
const prIndex = rest.indexOf("--pr");
|
|
2353
|
+
const prUrl = prIndex >= 0 ? rest[prIndex + 1] : void 0;
|
|
2354
|
+
const note = rest.filter((_, i) => i !== prIndex && i !== prIndex + 1).join(" ") || void 0;
|
|
2355
|
+
const entry = appendStatus(id, lane, verb, note);
|
|
2356
|
+
if (prUrl) mergeEvidence(id, lane, { prUrl });
|
|
2357
|
+
console.log(toonKV({ id, verb: entry.verb, at: entry.at, ...prUrl ? { prUrl } : {} }));
|
|
2358
|
+
break;
|
|
2359
|
+
}
|
|
2360
|
+
case "inbox": {
|
|
2361
|
+
const id = args[0];
|
|
2362
|
+
if (!id) throw new Error("inbox requires a dispatch id");
|
|
2363
|
+
const lane = findLane(id);
|
|
2364
|
+
const msgs = unhandled(id, lane);
|
|
2365
|
+
if (msgs.length === 0) {
|
|
2366
|
+
console.log(toonKV({ id, inbox: "empty" }));
|
|
2367
|
+
break;
|
|
2368
|
+
}
|
|
2369
|
+
for (const m of msgs) {
|
|
2370
|
+
console.log(`--- message ${m.file}`);
|
|
2371
|
+
console.log(m.text);
|
|
2372
|
+
acknowledge(id, lane, m.file);
|
|
2373
|
+
}
|
|
2374
|
+
break;
|
|
2375
|
+
}
|
|
2376
|
+
case "attach": {
|
|
2377
|
+
const id = args[0];
|
|
2378
|
+
if (!id) throw new Error("attach requires a dispatch id");
|
|
2379
|
+
const lane = findLane(id);
|
|
2380
|
+
const state = reconcile({ log: readStatusLog(id, lane), lastEventAt: lastEventAt(id, lane) });
|
|
2381
|
+
if (state === "working" && !args.includes("--force")) {
|
|
2382
|
+
throw new Error(
|
|
2383
|
+
`${id} is still working \u2014 attaching would put two writers on one session. Follow it with \`lobstah logs ${id} --follow\`, steer it with \`lobstah send\`, or pass --force after cancelling.`
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
const dirs = laneDirs(lane);
|
|
2387
|
+
const descriptorFile = ["active", "done"].map((b) => path12.join(dirs[b], id, "descriptor.json")).find((f) => fs16.existsSync(f));
|
|
2388
|
+
const harness = descriptorFile ? JSON.parse(fs16.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
|
|
2389
|
+
let sessionId;
|
|
2390
|
+
try {
|
|
2391
|
+
sessionId = JSON.parse(fs16.readFileSync(path12.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
|
|
2392
|
+
} catch {
|
|
2393
|
+
}
|
|
2394
|
+
if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
|
|
2395
|
+
const worktree = path12.join(lobstahHome(), "worktrees", id);
|
|
2396
|
+
const cwd = fs16.existsSync(worktree) ? worktree : process.cwd();
|
|
2397
|
+
const invocation = harness === "codex" ? { file: "codex", argv: ["resume", sessionId] } : { file: "claude", argv: ["--resume", sessionId] };
|
|
2398
|
+
if (args.includes("--print")) {
|
|
2399
|
+
console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
|
|
2400
|
+
break;
|
|
2401
|
+
}
|
|
2402
|
+
const res = spawnSync(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
|
|
2403
|
+
if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
|
|
2404
|
+
break;
|
|
2405
|
+
}
|
|
2406
|
+
case "swap": {
|
|
2407
|
+
const id = args[0];
|
|
2408
|
+
if (!id) throw new Error("swap requires a dispatch id");
|
|
2409
|
+
const lane = findLane(id);
|
|
2410
|
+
const activeDir = path12.join(laneDirs(lane).active, id);
|
|
2411
|
+
if (!fs16.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
|
|
2412
|
+
const descFile = path12.join(activeDir, "descriptor.json");
|
|
2413
|
+
const descriptor = JSON.parse(fs16.readFileSync(descFile, "utf8"));
|
|
2414
|
+
const fromHarness = descriptor.harness ?? "default";
|
|
2415
|
+
for (const key of ["harness", "model", "effort"]) {
|
|
2416
|
+
const v = arg(args, `--${key}`);
|
|
2417
|
+
if (v) descriptor[key] = v;
|
|
2418
|
+
}
|
|
2419
|
+
fs16.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
|
|
2420
|
+
const worktree = path12.join(lobstahHome(), "worktrees", id);
|
|
2421
|
+
let progress = "No worktree progress recorded.";
|
|
2422
|
+
if (fs16.existsSync(worktree)) {
|
|
2423
|
+
const git = (...a) => spawnSync("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
2424
|
+
const commits = git("log", "--oneline", "-15");
|
|
2425
|
+
const status = git("status", "--short");
|
|
2426
|
+
progress = `Commits so far:
|
|
2427
|
+
${commits || "(none)"}
|
|
2428
|
+
|
|
2429
|
+
Uncommitted changes:
|
|
2430
|
+
${status || "(clean)"}`;
|
|
2431
|
+
}
|
|
2432
|
+
fs16.writeFileSync(
|
|
2433
|
+
path12.join(activeDir, "handoff"),
|
|
2434
|
+
`You are taking over this dispatch from a previous agent session (harness: ${fromHarness}). Its conversation is not available \u2014 the worktree state below is the ground truth. Review it, then continue the brief from where it stops.
|
|
2435
|
+
|
|
2436
|
+
${progress}`
|
|
2437
|
+
);
|
|
2438
|
+
const runnerFile = path12.join(activeDir, "runner.json");
|
|
2439
|
+
if (fs16.existsSync(runnerFile)) {
|
|
2440
|
+
const runner = JSON.parse(fs16.readFileSync(runnerFile, "utf8"));
|
|
2441
|
+
if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
|
|
2442
|
+
fs16.unlinkSync(runnerFile);
|
|
2443
|
+
}
|
|
2444
|
+
appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
|
|
2445
|
+
console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
|
|
2446
|
+
break;
|
|
2447
|
+
}
|
|
2448
|
+
case "catch": {
|
|
2449
|
+
const id = args[0];
|
|
2450
|
+
if (!id) throw new Error("catch requires a dispatch id");
|
|
2451
|
+
const lane = findLane(id);
|
|
2452
|
+
const log = readStatusLog(id, lane);
|
|
2453
|
+
const ev = readEvidence(id, lane);
|
|
2454
|
+
console.log(
|
|
2455
|
+
toonKV({
|
|
2456
|
+
id,
|
|
2457
|
+
state: reconcile({ log, lastEventAt: lastEventAt(id, lane) }),
|
|
2458
|
+
branch: ev.branch,
|
|
2459
|
+
prUrl: ev.prUrl,
|
|
2460
|
+
sessionId: ev.sessionId,
|
|
2461
|
+
note: log.at(-1)?.note
|
|
2462
|
+
})
|
|
2463
|
+
);
|
|
2464
|
+
if (ev.commits?.length) {
|
|
2465
|
+
console.log(`commits[${ev.commits.length}]:`);
|
|
2466
|
+
for (const c of ev.commits) console.log(` ${c}`);
|
|
2467
|
+
}
|
|
2468
|
+
break;
|
|
2469
|
+
}
|
|
2470
|
+
case "cull": {
|
|
2471
|
+
const days = Number(arg(args, "--older-than") ?? "14");
|
|
2472
|
+
const plan = planCull(days);
|
|
2473
|
+
console.log(
|
|
2474
|
+
toonTable(
|
|
2475
|
+
"cull",
|
|
2476
|
+
plan.map((i) => ({ kind: i.kind, id: i.id, ageDays: i.ageDays })),
|
|
2477
|
+
["kind", "id", "ageDays"]
|
|
2478
|
+
)
|
|
2479
|
+
);
|
|
2480
|
+
if (plan.length === 0) break;
|
|
2481
|
+
if (args.includes("--apply")) {
|
|
2482
|
+
applyCull(plan);
|
|
2483
|
+
console.log(`applied: ${plan.length} removed`);
|
|
2484
|
+
} else {
|
|
2485
|
+
console.log("dry run \u2014 pass --apply to remove");
|
|
2486
|
+
}
|
|
2487
|
+
break;
|
|
2488
|
+
}
|
|
2489
|
+
case "man:manual": {
|
|
2490
|
+
console.log(MANUAL);
|
|
2491
|
+
break;
|
|
2492
|
+
}
|
|
2493
|
+
case "cancel": {
|
|
2494
|
+
const id = args[0];
|
|
2495
|
+
if (!id) throw new Error("cancel requires a dispatch id");
|
|
2496
|
+
requestCancel(id, findLane(id));
|
|
2497
|
+
console.log(toonKV({ id, cancel: "requested" }));
|
|
2498
|
+
break;
|
|
2499
|
+
}
|
|
2500
|
+
case "man:wait": {
|
|
2501
|
+
const timeoutSecs = Number(arg(args, "--timeout") ?? "0");
|
|
2502
|
+
const deadline = timeoutSecs > 0 ? Date.now() + timeoutSecs * 1e3 : Number.POSITIVE_INFINITY;
|
|
2503
|
+
const emit = (evs) => {
|
|
2504
|
+
for (const e of evs) {
|
|
2505
|
+
console.log(toonKV({ id: e.id, lane: e.lane, verb: e.entry.verb, note: e.entry.note, at: e.entry.at }));
|
|
2506
|
+
}
|
|
2507
|
+
const ev = evs[0];
|
|
2508
|
+
console.log(
|
|
2509
|
+
`next: run \`lobstah status ${ev.id}\` for full state` + (ev.entry.verb === "needs-decision" || ev.entry.verb === "blocked" ? `, answer with \`lobstah send ${ev.id} "<answer>"\`` : ", collect the evidence and report the outcome") + ", then re-arm a background `lobstah man wait`."
|
|
2510
|
+
);
|
|
2511
|
+
};
|
|
2512
|
+
const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
|
|
2513
|
+
const standing = attentionNow(!args.includes("--peek"), remindMs);
|
|
2514
|
+
if (standing.length > 0) {
|
|
2515
|
+
emit(standing);
|
|
2516
|
+
break;
|
|
2517
|
+
}
|
|
2518
|
+
const baseline = captureWaitBaseline();
|
|
2519
|
+
while (Date.now() < deadline) {
|
|
2520
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
2521
|
+
const fresh = freshWakeEvents(baseline);
|
|
2522
|
+
if (fresh.length > 0) {
|
|
2523
|
+
emit(fresh);
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
console.log(toonKV({ timeout: true, waitedSecs: timeoutSecs }));
|
|
2528
|
+
process.exitCode = 2;
|
|
2529
|
+
break;
|
|
2530
|
+
}
|
|
2531
|
+
case "man:init": {
|
|
2532
|
+
const file = path12.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
|
|
2533
|
+
let existing;
|
|
2534
|
+
try {
|
|
2535
|
+
existing = JSON.parse(fs16.readFileSync(file, "utf8"));
|
|
2536
|
+
} catch {
|
|
2537
|
+
existing = void 0;
|
|
2538
|
+
}
|
|
2539
|
+
const { settings, changed } = mergeHaulHook(existing);
|
|
2540
|
+
if (changed) {
|
|
2541
|
+
fs16.mkdirSync(".claude", { recursive: true });
|
|
2542
|
+
fs16.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
2543
|
+
`);
|
|
2544
|
+
}
|
|
2545
|
+
if (args.includes("--marker")) fs16.writeFileSync(".lobstah-man", "");
|
|
2546
|
+
console.log(
|
|
2547
|
+
toonKV({
|
|
2548
|
+
hook: "lobstah man haul",
|
|
2549
|
+
file,
|
|
2550
|
+
installed: changed || "already present",
|
|
2551
|
+
gate: args.includes("--marker") ? ".lobstah-man (this directory)" : "launch with LOBSTAH_MAN=1 claude"
|
|
2552
|
+
})
|
|
2553
|
+
);
|
|
2554
|
+
break;
|
|
2555
|
+
}
|
|
2556
|
+
case "man:haul": {
|
|
2557
|
+
try {
|
|
2558
|
+
if (process.env.LOBSTAH_MAN !== "1" && !fs16.existsSync(".lobstah-man")) break;
|
|
2559
|
+
const anyActive = ["work", "chore"].some(
|
|
2560
|
+
(l) => fs16.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
|
|
2561
|
+
);
|
|
2562
|
+
if (!anyActive) break;
|
|
2563
|
+
const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
|
|
2564
|
+
const deadline = Date.now() + timeoutSecs * 1e3;
|
|
2565
|
+
const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
|
|
2566
|
+
let evs = attentionNow(true, remindMs);
|
|
2567
|
+
if (evs.length === 0) {
|
|
2568
|
+
const baseline = captureWaitBaseline();
|
|
2569
|
+
while (Date.now() < deadline) {
|
|
2570
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
2571
|
+
evs = freshWakeEvents(baseline);
|
|
2572
|
+
if (evs.length === 0) evs = attentionNow(true, remindMs);
|
|
2573
|
+
if (evs.length > 0) break;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
if (evs.length === 0) break;
|
|
2577
|
+
const lines = evs.map(
|
|
2578
|
+
(ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`
|
|
2579
|
+
);
|
|
2580
|
+
const reason = [
|
|
2581
|
+
"A lobstah dispatch needs attention:",
|
|
2582
|
+
...lines,
|
|
2583
|
+
'Check with `lobstah status <id>`; answer a needs-decision with `lobstah send <id> "<answer>"`.',
|
|
2584
|
+
"Handle it now. This session re-parks automatically at turn end \u2014 do not arm any watcher."
|
|
2585
|
+
].join("\n");
|
|
2586
|
+
console.log(JSON.stringify({ decision: "block", reason }));
|
|
2587
|
+
} catch {
|
|
2588
|
+
}
|
|
2589
|
+
break;
|
|
2590
|
+
}
|
|
2591
|
+
case "daemon": {
|
|
2592
|
+
const interval = Number(arg(args, "--interval") ?? "5000");
|
|
2593
|
+
await daemon(interval);
|
|
2594
|
+
break;
|
|
2595
|
+
}
|
|
2596
|
+
case "pick": {
|
|
2597
|
+
await runPickup(args[0] === "once" ? "once" : "daemon");
|
|
2598
|
+
break;
|
|
2599
|
+
}
|
|
2600
|
+
case "init": {
|
|
2601
|
+
if (!fs16.existsSync(configPath())) {
|
|
2602
|
+
fs16.writeFileSync(
|
|
2603
|
+
configPath(),
|
|
2604
|
+
`# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
|
|
2605
|
+
# Top-level keys must come before any [section].
|
|
2606
|
+
# notifyCommand = "ntfy pub my-topic "$LOBSTAH_VERB $LOBSTAH_ID: $LOBSTAH_NOTE""
|
|
2607
|
+
# notifyVerbs = ["needs-decision", "blocked", "done", "failed"] # the default
|
|
2608
|
+
|
|
2609
|
+
[repos.example]
|
|
2610
|
+
path = "~/src/example"
|
|
2611
|
+
trunk = "main"
|
|
2612
|
+
# origin = "git@github.com:you/example.git"
|
|
2613
|
+
# setup = ["pnpm install"]
|
|
2614
|
+
|
|
2615
|
+
[harness]
|
|
2616
|
+
default = "claude"
|
|
2617
|
+
|
|
2618
|
+
[limits]
|
|
2619
|
+
maxConcurrent = 2
|
|
2620
|
+
choreConcurrent = 1
|
|
2621
|
+
wedgeThresholdSecs = 600
|
|
2622
|
+
maxRestartAttempts = 2
|
|
2623
|
+
wallClockSecs = 3600
|
|
2624
|
+
`
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
console.log(
|
|
2628
|
+
toonKV({
|
|
2629
|
+
home: path12.dirname(configPath()),
|
|
2630
|
+
config: configPath(),
|
|
2631
|
+
reference: "docs/configuration.md",
|
|
2632
|
+
initialized: true
|
|
2633
|
+
})
|
|
2634
|
+
);
|
|
2635
|
+
break;
|
|
2636
|
+
}
|
|
2637
|
+
case void 0:
|
|
2638
|
+
case "--help":
|
|
2639
|
+
case "help":
|
|
2640
|
+
console.log(HELP);
|
|
2641
|
+
break;
|
|
2642
|
+
default:
|
|
2643
|
+
throw new Error(`unknown command "${cmd}" \u2014 run lobstah help`);
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
mainCli().catch((err) => {
|
|
2647
|
+
console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
2648
|
+
process.exit(1);
|
|
2649
|
+
});
|
|
2650
|
+
/*! Bundled license information:
|
|
2651
|
+
|
|
2652
|
+
smol-toml/dist/date.js:
|
|
2653
|
+
smol-toml/dist/error.js:
|
|
2654
|
+
smol-toml/dist/util.js:
|
|
2655
|
+
smol-toml/dist/primitive.js:
|
|
2656
|
+
smol-toml/dist/extract.js:
|
|
2657
|
+
smol-toml/dist/struct.js:
|
|
2658
|
+
smol-toml/dist/parse.js:
|
|
2659
|
+
smol-toml/dist/stringify.js:
|
|
2660
|
+
smol-toml/dist/index.js:
|
|
2661
|
+
(*!
|
|
2662
|
+
* Copyright (c) Squirrel Chat et al., All rights reserved.
|
|
2663
|
+
* SPDX-License-Identifier: BSD-3-Clause
|
|
2664
|
+
*
|
|
2665
|
+
* Redistribution and use in source and binary forms, with or without
|
|
2666
|
+
* modification, are permitted provided that the following conditions are met:
|
|
2667
|
+
*
|
|
2668
|
+
* 1. Redistributions of source code must retain the above copyright notice, this
|
|
2669
|
+
* list of conditions and the following disclaimer.
|
|
2670
|
+
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
2671
|
+
* this list of conditions and the following disclaimer in the
|
|
2672
|
+
* documentation and/or other materials provided with the distribution.
|
|
2673
|
+
* 3. Neither the name of the copyright holder nor the names of its contributors
|
|
2674
|
+
* may be used to endorse or promote products derived from this software without
|
|
2675
|
+
* specific prior written permission.
|
|
2676
|
+
*
|
|
2677
|
+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
2678
|
+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
2679
|
+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
2680
|
+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
2681
|
+
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
2682
|
+
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
2683
|
+
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
2684
|
+
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
2685
|
+
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
2686
|
+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
2687
|
+
*)
|
|
2688
|
+
*/
|