lobstah 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +271 -172
- package/docs/pickup.md +25 -7
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// apps/cli/src/main.ts
|
|
4
|
-
import * as
|
|
4
|
+
import * as fs28 from "node:fs";
|
|
5
5
|
import * as os6 from "node:os";
|
|
6
|
-
import * as
|
|
6
|
+
import * as path22 from "node:path";
|
|
7
7
|
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
8
8
|
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
9
9
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
@@ -1984,6 +1984,38 @@ var PickupState = class {
|
|
|
1984
1984
|
}
|
|
1985
1985
|
};
|
|
1986
1986
|
|
|
1987
|
+
// apps/pick/dist/pr-evidence.js
|
|
1988
|
+
import * as fs16 from "node:fs";
|
|
1989
|
+
import * as path11 from "node:path";
|
|
1990
|
+
function prUrlKey(url) {
|
|
1991
|
+
return url.replace(/\/+$/, "");
|
|
1992
|
+
}
|
|
1993
|
+
function prEvidenceIndex() {
|
|
1994
|
+
const byUrl = /* @__PURE__ */ new Map();
|
|
1995
|
+
const stateDir = laneDirs("work").state;
|
|
1996
|
+
let files;
|
|
1997
|
+
try {
|
|
1998
|
+
files = fs16.readdirSync(stateDir).filter((f) => f.endsWith(".evidence"));
|
|
1999
|
+
} catch {
|
|
2000
|
+
return /* @__PURE__ */ new Map();
|
|
2001
|
+
}
|
|
2002
|
+
for (const f of files) {
|
|
2003
|
+
const file = path11.join(stateDir, f);
|
|
2004
|
+
try {
|
|
2005
|
+
const prUrl = JSON.parse(fs16.readFileSync(file, "utf8")).prUrl;
|
|
2006
|
+
if (!prUrl)
|
|
2007
|
+
continue;
|
|
2008
|
+
const key = prUrlKey(prUrl);
|
|
2009
|
+
const mtime = fs16.statSync(file).mtimeMs;
|
|
2010
|
+
const cur = byUrl.get(key);
|
|
2011
|
+
if (!cur || mtime > cur.mtime)
|
|
2012
|
+
byUrl.set(key, { uuid: f.slice(0, -".evidence".length), mtime });
|
|
2013
|
+
} catch {
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
return new Map([...byUrl].map(([url, v]) => [url, v.uuid]));
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1987
2019
|
// apps/pick/dist/sources/github.js
|
|
1988
2020
|
var API = "https://api.github.com";
|
|
1989
2021
|
var GithubSource = class {
|
|
@@ -2032,26 +2064,70 @@ ${issue.body ?? ""}
|
|
|
2032
2064
|
When the change is complete, commit it, push the branch, and open a PR against ${this.cfg.repo} referencing #${issue.number}.`
|
|
2033
2065
|
});
|
|
2034
2066
|
}
|
|
2067
|
+
const evidence = prEvidenceIndex();
|
|
2035
2068
|
const pulls = await this.api("GET", `/repos/${this.cfg.repo}/pulls?state=open&per_page=50`);
|
|
2036
2069
|
for (const pr of pulls) {
|
|
2037
2070
|
const m = /^lobstah\/([0-9a-f-]{36})$/.exec(pr.head.ref);
|
|
2038
|
-
|
|
2071
|
+
const uuid = m?.[1] ?? evidence.get(prUrlKey(pr.html_url));
|
|
2072
|
+
if (!uuid)
|
|
2039
2073
|
continue;
|
|
2040
|
-
const
|
|
2041
|
-
|
|
2042
|
-
if (!changes)
|
|
2074
|
+
const feedback = await this.latestFeedback(pr.number);
|
|
2075
|
+
if (!feedback)
|
|
2043
2076
|
continue;
|
|
2077
|
+
const subject = `gh:${this.cfg.repo}#pr${pr.number}`;
|
|
2044
2078
|
items.push({
|
|
2045
|
-
key:
|
|
2079
|
+
key: `${subject}@${feedback}`,
|
|
2080
|
+
subject,
|
|
2046
2081
|
kind: "review",
|
|
2047
2082
|
repoKey: this.cfg.key,
|
|
2048
2083
|
title: pr.title,
|
|
2049
|
-
followUp:
|
|
2050
|
-
brief: `Address the review feedback on ${pr.html_url} (branch ${pr.head.ref}). Check out that branch in this worktree, read the review
|
|
2084
|
+
followUp: uuid,
|
|
2085
|
+
brief: `Address the review feedback on ${pr.html_url} (branch ${pr.head.ref}). Check out that branch in this worktree, read the full review discussion with \`gh pr view ${pr.number} --comments\`, implement what the feedback asks, and push. Reply to review threads where a reply is warranted. When the feedback is addressed, report status done.`
|
|
2051
2086
|
});
|
|
2052
2087
|
}
|
|
2053
2088
|
return items;
|
|
2054
2089
|
}
|
|
2090
|
+
/**
|
|
2091
|
+
* The newest human feedback event on a PR, as an opaque round tag —
|
|
2092
|
+
* `rv<id>` for a submitted review, `rc<id>` for an inline review-thread
|
|
2093
|
+
* comment, `ic<id>` for a conversation comment. Feedback means: a
|
|
2094
|
+
* CHANGES_REQUESTED review on any sha (a review of a slightly stale head
|
|
2095
|
+
* still asks for changes), a COMMENTED review with a body, or any comment
|
|
2096
|
+
* — always by someone other than the configured identity, and never a
|
|
2097
|
+
* marker comment. APPROVED reviews are the merge loop's signal, not
|
|
2098
|
+
* feedback. Returns undefined when no human has spoken.
|
|
2099
|
+
*/
|
|
2100
|
+
async latestFeedback(n) {
|
|
2101
|
+
const events = [];
|
|
2102
|
+
const human = (login) => login !== this.cfg.identity;
|
|
2103
|
+
const reviews = await this.paged(`/repos/${this.cfg.repo}/pulls/${n}/reviews`);
|
|
2104
|
+
for (const r of reviews) {
|
|
2105
|
+
if (!human(r.user.login))
|
|
2106
|
+
continue;
|
|
2107
|
+
const actionable = r.state === "CHANGES_REQUESTED" || r.state === "COMMENTED" && (r.body ?? "").trim() !== "";
|
|
2108
|
+
if (actionable)
|
|
2109
|
+
events.push({ at: r.submitted_at, tag: `rv${r.id}` });
|
|
2110
|
+
}
|
|
2111
|
+
for (const c of await this.paged(`/repos/${this.cfg.repo}/pulls/${n}/comments`)) {
|
|
2112
|
+
if (human(c.user.login))
|
|
2113
|
+
events.push({ at: c.created_at, tag: `rc${c.id}` });
|
|
2114
|
+
}
|
|
2115
|
+
for (const c of await this.paged(`/repos/${this.cfg.repo}/issues/${n}/comments`)) {
|
|
2116
|
+
if (human(c.user.login) && !MARKER.test(c.body))
|
|
2117
|
+
events.push({ at: c.created_at, tag: `ic${c.id}` });
|
|
2118
|
+
}
|
|
2119
|
+
events.sort((a, b) => a.at === b.at ? a.tag.localeCompare(b.tag) : a.at.localeCompare(b.at));
|
|
2120
|
+
return events.at(-1)?.tag;
|
|
2121
|
+
}
|
|
2122
|
+
async paged(url) {
|
|
2123
|
+
const all = [];
|
|
2124
|
+
for (let page = 1; ; page++) {
|
|
2125
|
+
const batch = await this.api("GET", `${url}?per_page=100&page=${page}`);
|
|
2126
|
+
all.push(...batch);
|
|
2127
|
+
if (batch.length < 100)
|
|
2128
|
+
return all;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2055
2131
|
async claim(item) {
|
|
2056
2132
|
if (item.kind === "review")
|
|
2057
2133
|
return true;
|
|
@@ -2298,36 +2374,14 @@ When the change is complete, commit it, push the branch, and open a PR whose des
|
|
|
2298
2374
|
|
|
2299
2375
|
// apps/pick/dist/loops/dispatch.js
|
|
2300
2376
|
import { randomUUID } from "node:crypto";
|
|
2301
|
-
async function dispatchLoop(source, state, log = () => {
|
|
2302
|
-
}) {
|
|
2303
|
-
const items = await source.poll();
|
|
2304
|
-
for (const item of items) {
|
|
2305
|
-
if (state.get(item.key))
|
|
2306
|
-
continue;
|
|
2307
|
-
if (!await source.claim(item)) {
|
|
2308
|
-
log(`${item.key}: claim lost \u2014 another machine has it`);
|
|
2309
|
-
continue;
|
|
2310
|
-
}
|
|
2311
|
-
const id = randomUUID();
|
|
2312
|
-
enqueue({
|
|
2313
|
-
id,
|
|
2314
|
-
repo: item.repoKey,
|
|
2315
|
-
brief: item.brief,
|
|
2316
|
-
// A review dispatch forks the implementation session; issues start cold.
|
|
2317
|
-
...item.kind === "review" && item.followUp ? { followUp: item.followUp } : {}
|
|
2318
|
-
}, "work");
|
|
2319
|
-
state.set(item.key, { uuid: id, kind: item.kind, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2320
|
-
log(`${item.key}: dispatched as ${id}`);
|
|
2321
|
-
}
|
|
2322
|
-
}
|
|
2323
2377
|
|
|
2324
2378
|
// apps/pick/dist/loops/report.js
|
|
2325
|
-
import * as
|
|
2326
|
-
import * as
|
|
2379
|
+
import * as fs17 from "node:fs";
|
|
2380
|
+
import * as path12 from "node:path";
|
|
2327
2381
|
function dispatchLane(uuid) {
|
|
2328
2382
|
for (const lane of ["work", "chore"]) {
|
|
2329
2383
|
const d = laneDirs(lane);
|
|
2330
|
-
if (
|
|
2384
|
+
if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`)) || fs17.existsSync(path12.join(d.done, uuid)) || fs17.existsSync(path12.join(d.state, `${uuid}.status`))) {
|
|
2331
2385
|
return lane;
|
|
2332
2386
|
}
|
|
2333
2387
|
}
|
|
@@ -2367,13 +2421,58 @@ async function reportLoop(source, state, log = () => {
|
|
|
2367
2421
|
}
|
|
2368
2422
|
}
|
|
2369
2423
|
|
|
2424
|
+
// apps/pick/dist/loops/dispatch.js
|
|
2425
|
+
function terminal(uuid) {
|
|
2426
|
+
const lane = dispatchLane(uuid);
|
|
2427
|
+
if (!lane)
|
|
2428
|
+
return true;
|
|
2429
|
+
const verb = reconcile({ log: readStatusLog(uuid, lane), lastEventAt: lastEventAt(uuid, lane) });
|
|
2430
|
+
return verb === "done" || verb === "failed";
|
|
2431
|
+
}
|
|
2432
|
+
function roundPlan(item, state) {
|
|
2433
|
+
const rounds = item.subject ? state.entries().filter(([k]) => k.startsWith(`${item.subject}@`)).map(([, e]) => e) : [];
|
|
2434
|
+
const chain = [...rounds.map((e) => e.uuid), ...item.followUp ? [item.followUp] : []];
|
|
2435
|
+
if (chain.some((uuid) => !terminal(uuid)))
|
|
2436
|
+
return { hold: true };
|
|
2437
|
+
const forkable = rounds.filter((e) => dispatchLane(e.uuid)).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).at(-1)?.uuid;
|
|
2438
|
+
const target = forkable ?? item.followUp;
|
|
2439
|
+
return { hold: false, followUp: target && dispatchLane(target) ? target : void 0 };
|
|
2440
|
+
}
|
|
2441
|
+
async function dispatchLoop(source, state, log = () => {
|
|
2442
|
+
}) {
|
|
2443
|
+
const items = await source.poll();
|
|
2444
|
+
for (const item of items) {
|
|
2445
|
+
if (state.get(item.key))
|
|
2446
|
+
continue;
|
|
2447
|
+
const plan = item.kind === "review" ? roundPlan(item, state) : { hold: false, followUp: void 0 };
|
|
2448
|
+
if (plan.hold) {
|
|
2449
|
+
log(`${item.key}: a prior round is still in flight \u2014 holding`);
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2452
|
+
if (!await source.claim(item)) {
|
|
2453
|
+
log(`${item.key}: claim lost \u2014 another machine has it`);
|
|
2454
|
+
continue;
|
|
2455
|
+
}
|
|
2456
|
+
const id = randomUUID();
|
|
2457
|
+
enqueue({
|
|
2458
|
+
id,
|
|
2459
|
+
repo: item.repoKey,
|
|
2460
|
+
brief: item.brief,
|
|
2461
|
+
// A review dispatch forks the latest session in its chain; issues start cold.
|
|
2462
|
+
...plan.followUp ? { followUp: plan.followUp } : {}
|
|
2463
|
+
}, "work");
|
|
2464
|
+
state.set(item.key, { uuid: id, kind: item.kind, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2465
|
+
log(`${item.key}: dispatched as ${id}`);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2370
2469
|
// apps/pick/dist/loops/reconcile.js
|
|
2371
|
-
import * as
|
|
2372
|
-
import * as
|
|
2470
|
+
import * as fs18 from "node:fs";
|
|
2471
|
+
import * as path13 from "node:path";
|
|
2373
2472
|
function dispatchActive(uuid) {
|
|
2374
2473
|
for (const lane of ["work", "chore"]) {
|
|
2375
2474
|
const d = laneDirs(lane);
|
|
2376
|
-
if (
|
|
2475
|
+
if (fs18.existsSync(path13.join(d.active, uuid)) || fs18.existsSync(path13.join(d.queue, `${uuid}.json`))) {
|
|
2377
2476
|
return lane;
|
|
2378
2477
|
}
|
|
2379
2478
|
}
|
|
@@ -2413,28 +2512,28 @@ async function reconcileLoop(source, state, log = () => {
|
|
|
2413
2512
|
|
|
2414
2513
|
// apps/pick/dist/loops/merge.js
|
|
2415
2514
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2416
|
-
import * as
|
|
2417
|
-
import * as
|
|
2515
|
+
import * as fs20 from "node:fs";
|
|
2516
|
+
import * as path15 from "node:path";
|
|
2418
2517
|
|
|
2419
2518
|
// apps/pick/dist/merge-view.js
|
|
2420
|
-
import * as
|
|
2421
|
-
import * as
|
|
2519
|
+
import * as fs19 from "node:fs";
|
|
2520
|
+
import * as path14 from "node:path";
|
|
2422
2521
|
function viewPath() {
|
|
2423
|
-
return
|
|
2522
|
+
return path14.join(lobstahHome(), "pickup", "merge-view.json");
|
|
2424
2523
|
}
|
|
2425
2524
|
function readMergeView() {
|
|
2426
2525
|
try {
|
|
2427
|
-
return JSON.parse(
|
|
2526
|
+
return JSON.parse(fs19.readFileSync(viewPath(), "utf8"));
|
|
2428
2527
|
} catch {
|
|
2429
2528
|
return void 0;
|
|
2430
2529
|
}
|
|
2431
2530
|
}
|
|
2432
2531
|
function writeMergeView(view) {
|
|
2433
2532
|
const file = viewPath();
|
|
2434
|
-
|
|
2533
|
+
fs19.mkdirSync(path14.dirname(file), { recursive: true });
|
|
2435
2534
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
2436
|
-
|
|
2437
|
-
|
|
2535
|
+
fs19.writeFileSync(tmp, JSON.stringify(view, null, 2));
|
|
2536
|
+
fs19.renameSync(tmp, file);
|
|
2438
2537
|
}
|
|
2439
2538
|
var RECENT_RETENTION_MS = 48 * 36e5;
|
|
2440
2539
|
async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
|
|
@@ -2456,7 +2555,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
|
|
|
2456
2555
|
}
|
|
2457
2556
|
function readPickupMap() {
|
|
2458
2557
|
try {
|
|
2459
|
-
const raw = JSON.parse(
|
|
2558
|
+
const raw = JSON.parse(fs19.readFileSync(path14.join(lobstahHome(), "pickup", "state.json"), "utf8"));
|
|
2460
2559
|
return raw.map ?? {};
|
|
2461
2560
|
} catch {
|
|
2462
2561
|
return {};
|
|
@@ -2490,7 +2589,7 @@ function rebaseBrief(pr) {
|
|
|
2490
2589
|
}
|
|
2491
2590
|
function choreVerdict(uuid) {
|
|
2492
2591
|
const d = laneDirs("chore");
|
|
2493
|
-
if (
|
|
2592
|
+
if (fs20.existsSync(path15.join(d.active, uuid)) || fs20.existsSync(path15.join(d.queue, `${uuid}.json`)))
|
|
2494
2593
|
return "active";
|
|
2495
2594
|
const last = readStatusLog(uuid, "chore").at(-1)?.verb;
|
|
2496
2595
|
return last === "done" ? "done" : "failed";
|
|
@@ -2584,8 +2683,8 @@ async function mergeLoop(ms, policy, state, log = () => {
|
|
|
2584
2683
|
}
|
|
2585
2684
|
|
|
2586
2685
|
// apps/pick/dist/loops/watch.js
|
|
2587
|
-
import * as
|
|
2588
|
-
import * as
|
|
2686
|
+
import * as fs21 from "node:fs";
|
|
2687
|
+
import * as path16 from "node:path";
|
|
2589
2688
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2590
2689
|
var DEFAULT_BRIEF = `You are resuming earlier work. The watched source {key} produced new events:
|
|
2591
2690
|
|
|
@@ -2595,7 +2694,7 @@ Read them, act on what they ask, and report the outcome.`;
|
|
|
2595
2694
|
function laneOf(id) {
|
|
2596
2695
|
for (const lane of ["work", "chore"]) {
|
|
2597
2696
|
const d = laneDirs(lane);
|
|
2598
|
-
if (
|
|
2697
|
+
if (fs21.existsSync(path16.join(d.active, id)) || fs21.existsSync(path16.join(d.queue, `${id}.json`)) || fs21.existsSync(path16.join(d.done, id)) || fs21.existsSync(path16.join(d.state, `${id}.status`))) {
|
|
2599
2698
|
return lane;
|
|
2600
2699
|
}
|
|
2601
2700
|
}
|
|
@@ -2604,13 +2703,13 @@ function laneOf(id) {
|
|
|
2604
2703
|
function descriptorOf(id, lane) {
|
|
2605
2704
|
const d = laneDirs(lane);
|
|
2606
2705
|
for (const bucket of ["active", "done"]) {
|
|
2607
|
-
const f =
|
|
2608
|
-
if (
|
|
2609
|
-
return JSON.parse(
|
|
2706
|
+
const f = path16.join(d[bucket], id, "descriptor.json");
|
|
2707
|
+
if (fs21.existsSync(f))
|
|
2708
|
+
return JSON.parse(fs21.readFileSync(f, "utf8"));
|
|
2610
2709
|
}
|
|
2611
|
-
const q =
|
|
2612
|
-
if (
|
|
2613
|
-
return JSON.parse(
|
|
2710
|
+
const q = path16.join(d.queue, `${id}.json`);
|
|
2711
|
+
if (fs21.existsSync(q))
|
|
2712
|
+
return JSON.parse(fs21.readFileSync(q, "utf8"));
|
|
2614
2713
|
return void 0;
|
|
2615
2714
|
}
|
|
2616
2715
|
function isTerminal(id) {
|
|
@@ -2843,21 +2942,21 @@ function mergeHaulHook(existing) {
|
|
|
2843
2942
|
}
|
|
2844
2943
|
|
|
2845
2944
|
// apps/cli/src/tend.ts
|
|
2846
|
-
import * as
|
|
2847
|
-
import * as
|
|
2945
|
+
import * as fs22 from "node:fs";
|
|
2946
|
+
import * as path17 from "node:path";
|
|
2848
2947
|
var HEARTBEAT_STALE_MS = 9e4;
|
|
2849
2948
|
var CLAIM_STALE_MS = 12e4;
|
|
2850
2949
|
var DAY_MS = 24 * 36e5;
|
|
2851
2950
|
function readJson(file) {
|
|
2852
2951
|
try {
|
|
2853
|
-
return JSON.parse(
|
|
2952
|
+
return JSON.parse(fs22.readFileSync(file, "utf8"));
|
|
2854
2953
|
} catch {
|
|
2855
2954
|
return void 0;
|
|
2856
2955
|
}
|
|
2857
2956
|
}
|
|
2858
2957
|
function doneIds(lane) {
|
|
2859
2958
|
try {
|
|
2860
|
-
return
|
|
2959
|
+
return fs22.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
|
|
2861
2960
|
} catch {
|
|
2862
2961
|
return [];
|
|
2863
2962
|
}
|
|
@@ -2876,16 +2975,16 @@ function followUps(uuid) {
|
|
|
2876
2975
|
const desc = readJson(file);
|
|
2877
2976
|
if (desc?.followUp === uuid) out.push({ id, bucket });
|
|
2878
2977
|
};
|
|
2879
|
-
for (const id of pendingIds("work")) scan(id, "queued",
|
|
2880
|
-
for (const id of activeIds("work")) scan(id, "active",
|
|
2881
|
-
for (const id of doneIds("work")) scan(id, "done",
|
|
2978
|
+
for (const id of pendingIds("work")) scan(id, "queued", path17.join(d.queue, `${id}.json`));
|
|
2979
|
+
for (const id of activeIds("work")) scan(id, "active", path17.join(d.active, id, "descriptor.json"));
|
|
2980
|
+
for (const id of doneIds("work")) scan(id, "done", path17.join(d.done, id, "descriptor.json"));
|
|
2882
2981
|
return out;
|
|
2883
2982
|
}
|
|
2884
2983
|
function bucketOf(uuid) {
|
|
2885
2984
|
const d = laneDirs("work");
|
|
2886
|
-
if (
|
|
2887
|
-
if (
|
|
2888
|
-
if (
|
|
2985
|
+
if (fs22.existsSync(path17.join(d.active, uuid))) return "active";
|
|
2986
|
+
if (fs22.existsSync(path17.join(d.done, uuid))) return "done";
|
|
2987
|
+
if (fs22.existsSync(path17.join(d.queue, `${uuid}.json`))) return "queued";
|
|
2889
2988
|
return void 0;
|
|
2890
2989
|
}
|
|
2891
2990
|
function buildTendReport(now = Date.now()) {
|
|
@@ -2945,7 +3044,7 @@ function buildTendReport(now = Date.now()) {
|
|
|
2945
3044
|
}
|
|
2946
3045
|
}
|
|
2947
3046
|
const oldestQueuedAge = queued.reduce((max, id) => {
|
|
2948
|
-
const st =
|
|
3047
|
+
const st = fs22.statSync(path17.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
|
|
2949
3048
|
return st ? Math.max(max, now - st.mtimeMs) : max;
|
|
2950
3049
|
}, 0);
|
|
2951
3050
|
const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
|
|
@@ -3082,13 +3181,13 @@ function renderTend(r) {
|
|
|
3082
3181
|
}
|
|
3083
3182
|
|
|
3084
3183
|
// apps/cli/src/cull.ts
|
|
3085
|
-
import * as
|
|
3086
|
-
import * as
|
|
3184
|
+
import * as fs23 from "node:fs";
|
|
3185
|
+
import * as path18 from "node:path";
|
|
3087
3186
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
3088
3187
|
var DAY = 864e5;
|
|
3089
3188
|
function idsIn(dir) {
|
|
3090
3189
|
try {
|
|
3091
|
-
return new Set(
|
|
3190
|
+
return new Set(fs23.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
|
|
3092
3191
|
} catch {
|
|
3093
3192
|
return /* @__PURE__ */ new Set();
|
|
3094
3193
|
}
|
|
@@ -3103,39 +3202,39 @@ function planCull(olderThanDays, now = Date.now()) {
|
|
|
3103
3202
|
for (const id of idsIn(d.queue)) live.add(id);
|
|
3104
3203
|
for (const id of idsIn(d.active)) live.add(id);
|
|
3105
3204
|
for (const id of idsIn(d.done)) {
|
|
3106
|
-
const p =
|
|
3107
|
-
const m =
|
|
3205
|
+
const p = path18.join(d.done, id);
|
|
3206
|
+
const m = fs23.statSync(p).mtimeMs;
|
|
3108
3207
|
doneMtimes.set(id, m);
|
|
3109
3208
|
if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
|
|
3110
3209
|
}
|
|
3111
3210
|
}
|
|
3112
|
-
const wtRoot =
|
|
3211
|
+
const wtRoot = path18.join(lobstahHome(), "worktrees");
|
|
3113
3212
|
for (const id of idsIn(wtRoot)) {
|
|
3114
3213
|
if (live.has(id)) continue;
|
|
3115
3214
|
const doneAt = doneMtimes.get(id);
|
|
3116
3215
|
if (doneAt !== void 0 && doneAt >= cutoff) continue;
|
|
3117
|
-
const p =
|
|
3118
|
-
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now -
|
|
3216
|
+
const p = path18.join(wtRoot, id);
|
|
3217
|
+
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs23.statSync(p).mtimeMs) / DAY) });
|
|
3119
3218
|
}
|
|
3120
3219
|
for (const lane of ["work", "chore"]) {
|
|
3121
3220
|
const d = laneDirs(lane);
|
|
3122
3221
|
const seen = /* @__PURE__ */ new Set();
|
|
3123
|
-
for (const f of
|
|
3222
|
+
for (const f of fs23.readdirSync(d.state)) {
|
|
3124
3223
|
const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
|
|
3125
3224
|
if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
|
|
3126
|
-
const m =
|
|
3225
|
+
const m = fs23.statSync(path18.join(d.state, f)).mtimeMs;
|
|
3127
3226
|
if (m >= cutoff) continue;
|
|
3128
3227
|
seen.add(id);
|
|
3129
|
-
items.push({ kind: "state", id, target:
|
|
3228
|
+
items.push({ kind: "state", id, target: path18.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
|
|
3130
3229
|
}
|
|
3131
3230
|
}
|
|
3132
3231
|
return items;
|
|
3133
3232
|
}
|
|
3134
3233
|
function removeWorktree(id, dir) {
|
|
3135
3234
|
for (const lane of ["work", "chore"]) {
|
|
3136
|
-
const descFile =
|
|
3235
|
+
const descFile = path18.join(laneDirs(lane).done, id, "descriptor.json");
|
|
3137
3236
|
try {
|
|
3138
|
-
const desc = JSON.parse(
|
|
3237
|
+
const desc = JSON.parse(fs23.readFileSync(descFile, "utf8"));
|
|
3139
3238
|
const repo = loadConfig().repos[desc.repo];
|
|
3140
3239
|
if (repo) {
|
|
3141
3240
|
execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
|
|
@@ -3144,15 +3243,15 @@ function removeWorktree(id, dir) {
|
|
|
3144
3243
|
} catch {
|
|
3145
3244
|
}
|
|
3146
3245
|
}
|
|
3147
|
-
|
|
3246
|
+
fs23.rmSync(dir, { recursive: true, force: true });
|
|
3148
3247
|
}
|
|
3149
3248
|
function applyCull(items) {
|
|
3150
3249
|
for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
|
|
3151
|
-
for (const item of items.filter((i) => i.kind === "done"))
|
|
3250
|
+
for (const item of items.filter((i) => i.kind === "done")) fs23.rmSync(item.target, { recursive: true, force: true });
|
|
3152
3251
|
for (const item of items.filter((i) => i.kind === "state")) {
|
|
3153
|
-
const dir =
|
|
3154
|
-
for (const f of
|
|
3155
|
-
if (f.startsWith(`${item.id}.`))
|
|
3252
|
+
const dir = path18.dirname(item.target);
|
|
3253
|
+
for (const f of fs23.readdirSync(dir)) {
|
|
3254
|
+
if (f.startsWith(`${item.id}.`)) fs23.rmSync(path18.join(dir, f), { force: true });
|
|
3156
3255
|
}
|
|
3157
3256
|
}
|
|
3158
3257
|
}
|
|
@@ -3206,7 +3305,7 @@ paste into your liaison instructions:
|
|
|
3206
3305
|
full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
|
|
3207
3306
|
|
|
3208
3307
|
// apps/cli/src/doctor.ts
|
|
3209
|
-
import * as
|
|
3308
|
+
import * as fs24 from "node:fs";
|
|
3210
3309
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
3211
3310
|
var HEARTBEAT_STALE_MS2 = 9e4;
|
|
3212
3311
|
function git(repoPath, ...args) {
|
|
@@ -3229,13 +3328,13 @@ function runDoctor(now = Date.now()) {
|
|
|
3229
3328
|
codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
|
|
3230
3329
|
);
|
|
3231
3330
|
const cfgFile = configPath();
|
|
3232
|
-
if (!
|
|
3331
|
+
if (!fs24.existsSync(cfgFile)) {
|
|
3233
3332
|
push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
|
|
3234
3333
|
return rows;
|
|
3235
3334
|
}
|
|
3236
3335
|
let parsed;
|
|
3237
3336
|
try {
|
|
3238
|
-
parsed = parse(
|
|
3337
|
+
parsed = parse(fs24.readFileSync(cfgFile, "utf8"));
|
|
3239
3338
|
push("config", "ok", cfgFile);
|
|
3240
3339
|
} catch (err) {
|
|
3241
3340
|
push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -3246,7 +3345,7 @@ function runDoctor(now = Date.now()) {
|
|
|
3246
3345
|
if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
|
|
3247
3346
|
for (const [key, repo] of Object.entries(cfg.repos)) {
|
|
3248
3347
|
const label = `repo ${key}`;
|
|
3249
|
-
if (!
|
|
3348
|
+
if (!fs24.existsSync(repo.path)) {
|
|
3250
3349
|
push(label, "fail", `path ${repo.path} does not exist`);
|
|
3251
3350
|
continue;
|
|
3252
3351
|
}
|
|
@@ -3274,11 +3373,11 @@ function runDoctor(now = Date.now()) {
|
|
|
3274
3373
|
}
|
|
3275
3374
|
}
|
|
3276
3375
|
const hb = executorPath();
|
|
3277
|
-
if (!
|
|
3376
|
+
if (!fs24.existsSync(hb)) {
|
|
3278
3377
|
push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
|
|
3279
3378
|
} else {
|
|
3280
3379
|
try {
|
|
3281
|
-
const payload = JSON.parse(
|
|
3380
|
+
const payload = JSON.parse(fs24.readFileSync(hb, "utf8"));
|
|
3282
3381
|
const age = now - new Date(payload.heartbeat ?? 0).getTime();
|
|
3283
3382
|
push(
|
|
3284
3383
|
"daemon",
|
|
@@ -3294,9 +3393,9 @@ function runDoctor(now = Date.now()) {
|
|
|
3294
3393
|
}
|
|
3295
3394
|
|
|
3296
3395
|
// apps/cli/src/service.ts
|
|
3297
|
-
import * as
|
|
3396
|
+
import * as fs25 from "node:fs";
|
|
3298
3397
|
import * as os5 from "node:os";
|
|
3299
|
-
import * as
|
|
3398
|
+
import * as path19 from "node:path";
|
|
3300
3399
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
3301
3400
|
function xml(s) {
|
|
3302
3401
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -3320,8 +3419,8 @@ function renderLaunchdPlist(spec) {
|
|
|
3320
3419
|
</dict>
|
|
3321
3420
|
<key>RunAtLoad</key><true/>
|
|
3322
3421
|
<key>KeepAlive</key><true/>
|
|
3323
|
-
<key>StandardOutPath</key><string>${xml(
|
|
3324
|
-
<key>StandardErrorPath</key><string>${xml(
|
|
3422
|
+
<key>StandardOutPath</key><string>${xml(path19.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
|
|
3423
|
+
<key>StandardErrorPath</key><string>${xml(path19.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
|
|
3325
3424
|
</dict>
|
|
3326
3425
|
</plist>
|
|
3327
3426
|
`;
|
|
@@ -3336,8 +3435,8 @@ Restart=always
|
|
|
3336
3435
|
RestartSec=5
|
|
3337
3436
|
Environment=PATH=${spec.pathEnv}
|
|
3338
3437
|
Environment=LOBSTAH_HOME=${spec.home}
|
|
3339
|
-
StandardOutput=append:${
|
|
3340
|
-
StandardError=append:${
|
|
3438
|
+
StandardOutput=append:${path19.posix.join(spec.logDir, `${spec.kind}.log`)}
|
|
3439
|
+
StandardError=append:${path19.posix.join(spec.logDir, `${spec.kind}.err`)}
|
|
3341
3440
|
|
|
3342
3441
|
[Install]
|
|
3343
3442
|
WantedBy=default.target
|
|
@@ -3345,8 +3444,8 @@ WantedBy=default.target
|
|
|
3345
3444
|
}
|
|
3346
3445
|
function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
3347
3446
|
const dirs = [
|
|
3348
|
-
|
|
3349
|
-
|
|
3447
|
+
path19.posix.dirname(nodePath),
|
|
3448
|
+
path19.posix.join(home, ".local", "bin"),
|
|
3350
3449
|
"/opt/homebrew/bin",
|
|
3351
3450
|
"/usr/local/bin",
|
|
3352
3451
|
"/usr/bin",
|
|
@@ -3355,7 +3454,7 @@ function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
|
3355
3454
|
return [...new Set(dirs)].join(":");
|
|
3356
3455
|
}
|
|
3357
3456
|
function serviceFile(kind) {
|
|
3358
|
-
return process.platform === "darwin" ?
|
|
3457
|
+
return process.platform === "darwin" ? path19.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path19.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
|
|
3359
3458
|
}
|
|
3360
3459
|
function run(cmd, args) {
|
|
3361
3460
|
const res = spawnSync3(cmd, args, { encoding: "utf8" });
|
|
@@ -3365,28 +3464,28 @@ function installService(kind) {
|
|
|
3365
3464
|
if (process.platform === "win32") {
|
|
3366
3465
|
throw new Error(`no service manager support on Windows \u2014 run \`lobstah ${kind}\` under your process manager of choice`);
|
|
3367
3466
|
}
|
|
3368
|
-
const entry =
|
|
3467
|
+
const entry = fs25.realpathSync(process.argv[1]);
|
|
3369
3468
|
const spec = {
|
|
3370
3469
|
kind,
|
|
3371
3470
|
nodePath: process.execPath,
|
|
3372
3471
|
entry,
|
|
3373
3472
|
pathEnv: servicePathEnv(process.execPath),
|
|
3374
3473
|
home: lobstahHome(),
|
|
3375
|
-
logDir:
|
|
3474
|
+
logDir: path19.join(lobstahHome(), "logs")
|
|
3376
3475
|
};
|
|
3377
3476
|
if (!onPath("git", spec.pathEnv)) {
|
|
3378
3477
|
throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
|
|
3379
3478
|
}
|
|
3380
|
-
|
|
3479
|
+
fs25.mkdirSync(spec.logDir, { recursive: true });
|
|
3381
3480
|
const file = serviceFile(kind);
|
|
3382
|
-
|
|
3481
|
+
fs25.mkdirSync(path19.dirname(file), { recursive: true });
|
|
3383
3482
|
if (process.platform === "darwin") {
|
|
3384
|
-
if (
|
|
3385
|
-
|
|
3483
|
+
if (fs25.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3484
|
+
fs25.writeFileSync(file, renderLaunchdPlist(spec));
|
|
3386
3485
|
const load = run("launchctl", ["load", file]);
|
|
3387
3486
|
return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
|
|
3388
3487
|
}
|
|
3389
|
-
|
|
3488
|
+
fs25.writeFileSync(file, renderSystemdUnit(spec));
|
|
3390
3489
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
3391
3490
|
const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
|
|
3392
3491
|
return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
|
|
@@ -3394,19 +3493,19 @@ function installService(kind) {
|
|
|
3394
3493
|
function uninstallService(kind) {
|
|
3395
3494
|
const file = serviceFile(kind);
|
|
3396
3495
|
if (process.platform === "darwin") {
|
|
3397
|
-
if (
|
|
3496
|
+
if (fs25.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3398
3497
|
} else {
|
|
3399
3498
|
run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
|
|
3400
3499
|
}
|
|
3401
|
-
const removed =
|
|
3402
|
-
if (removed)
|
|
3500
|
+
const removed = fs25.existsSync(file);
|
|
3501
|
+
if (removed) fs25.unlinkSync(file);
|
|
3403
3502
|
if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
|
|
3404
3503
|
return { file, removed };
|
|
3405
3504
|
}
|
|
3406
3505
|
|
|
3407
3506
|
// apps/cli/src/repos.ts
|
|
3408
|
-
import * as
|
|
3409
|
-
import * as
|
|
3507
|
+
import * as fs26 from "node:fs";
|
|
3508
|
+
import * as path20 from "node:path";
|
|
3410
3509
|
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
3411
3510
|
function git2(dir, ...args) {
|
|
3412
3511
|
const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
@@ -3422,41 +3521,41 @@ var LOCKFILE_SETUP = [
|
|
|
3422
3521
|
["poetry.lock", ["poetry install"]]
|
|
3423
3522
|
];
|
|
3424
3523
|
function detectRepo(dir) {
|
|
3425
|
-
const abs =
|
|
3524
|
+
const abs = path20.resolve(dir);
|
|
3426
3525
|
if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
|
|
3427
3526
|
const top = git2(abs, "rev-parse", "--show-toplevel");
|
|
3428
3527
|
const canon2 = (p) => {
|
|
3429
|
-
const real =
|
|
3528
|
+
const real = path20.resolve(fs26.realpathSync.native(p));
|
|
3430
3529
|
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
3431
3530
|
};
|
|
3432
3531
|
if (!top || canon2(top) !== canon2(abs)) return void 0;
|
|
3433
3532
|
const origin = git2(abs, "remote", "get-url", "origin");
|
|
3434
3533
|
const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
3435
3534
|
const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
|
|
3436
|
-
const setup = LOCKFILE_SETUP.find(([f]) =>
|
|
3437
|
-
return { key:
|
|
3535
|
+
const setup = LOCKFILE_SETUP.find(([f]) => fs26.existsSync(path20.join(abs, f)))?.[1];
|
|
3536
|
+
return { key: path20.basename(abs), path: abs, trunk, origin, setup };
|
|
3438
3537
|
}
|
|
3439
3538
|
function scanForRepos(roots) {
|
|
3440
3539
|
const found = [];
|
|
3441
3540
|
for (const root of roots) {
|
|
3442
|
-
const abs =
|
|
3443
|
-
if (!
|
|
3541
|
+
const abs = path20.resolve(root);
|
|
3542
|
+
if (!fs26.existsSync(abs)) continue;
|
|
3444
3543
|
const self = detectRepo(abs);
|
|
3445
3544
|
if (self) {
|
|
3446
3545
|
found.push(self);
|
|
3447
3546
|
continue;
|
|
3448
3547
|
}
|
|
3449
|
-
for (const entry of
|
|
3548
|
+
for (const entry of fs26.readdirSync(abs, { withFileTypes: true })) {
|
|
3450
3549
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
3451
|
-
const child = detectRepo(
|
|
3550
|
+
const child = detectRepo(path20.join(abs, entry.name));
|
|
3452
3551
|
if (child) found.push(child);
|
|
3453
3552
|
}
|
|
3454
3553
|
}
|
|
3455
3554
|
return found;
|
|
3456
3555
|
}
|
|
3457
3556
|
function configuredRepoKeys(file = configPath()) {
|
|
3458
|
-
if (!
|
|
3459
|
-
const raw = parse(
|
|
3557
|
+
if (!fs26.existsSync(file)) return /* @__PURE__ */ new Set();
|
|
3558
|
+
const raw = parse(fs26.readFileSync(file, "utf8"));
|
|
3460
3559
|
return new Set(Object.keys(raw.repos ?? {}));
|
|
3461
3560
|
}
|
|
3462
3561
|
function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
@@ -3464,7 +3563,7 @@ function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
|
3464
3563
|
if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
|
|
3465
3564
|
if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
|
|
3466
3565
|
if (opts.pickup) lines.push(`pickup = true`);
|
|
3467
|
-
|
|
3566
|
+
fs26.appendFileSync(file, `${lines.join("\n")}
|
|
3468
3567
|
`);
|
|
3469
3568
|
}
|
|
3470
3569
|
|
|
@@ -3477,8 +3576,8 @@ function parseReportArgs(rest) {
|
|
|
3477
3576
|
}
|
|
3478
3577
|
|
|
3479
3578
|
// apps/cli/src/soak-site.ts
|
|
3480
|
-
import * as
|
|
3481
|
-
import * as
|
|
3579
|
+
import * as fs27 from "node:fs";
|
|
3580
|
+
import * as path21 from "node:path";
|
|
3482
3581
|
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
3483
3582
|
function git3(dir, ...args) {
|
|
3484
3583
|
const res = spawnSync5("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
@@ -3486,10 +3585,10 @@ function git3(dir, ...args) {
|
|
|
3486
3585
|
}
|
|
3487
3586
|
function canon(p) {
|
|
3488
3587
|
try {
|
|
3489
|
-
const real =
|
|
3588
|
+
const real = path21.resolve(fs27.realpathSync.native(p));
|
|
3490
3589
|
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
3491
3590
|
} catch {
|
|
3492
|
-
return
|
|
3591
|
+
return path21.resolve(p);
|
|
3493
3592
|
}
|
|
3494
3593
|
}
|
|
3495
3594
|
function inspectSoakSite(cwd, repos) {
|
|
@@ -3498,16 +3597,16 @@ function inspectSoakSite(cwd, repos) {
|
|
|
3498
3597
|
const gitDir = git3(cwd, "rev-parse", "--git-dir");
|
|
3499
3598
|
const commonDir = git3(cwd, "rev-parse", "--git-common-dir");
|
|
3500
3599
|
if (!gitDir || !commonDir) return void 0;
|
|
3501
|
-
const abs = (p) =>
|
|
3600
|
+
const abs = (p) => path21.isAbsolute(p) ? p : path21.join(cwd, p);
|
|
3502
3601
|
const primary = canon(abs(gitDir)) === canon(abs(commonDir));
|
|
3503
|
-
const primaryRoot = canon(
|
|
3602
|
+
const primaryRoot = canon(path21.dirname(abs(commonDir)));
|
|
3504
3603
|
const repoKey = Object.entries(repos).find(([, r]) => canon(r.path) === primaryRoot)?.[0];
|
|
3505
3604
|
return { worktree: canon(top), primary, repoKey };
|
|
3506
3605
|
}
|
|
3507
3606
|
function readHookStdin() {
|
|
3508
3607
|
if (process.stdin.isTTY) return void 0;
|
|
3509
3608
|
try {
|
|
3510
|
-
const raw =
|
|
3609
|
+
const raw = fs27.readFileSync(0, "utf8");
|
|
3511
3610
|
if (!raw.trim()) return void 0;
|
|
3512
3611
|
const parsed = JSON.parse(raw);
|
|
3513
3612
|
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
@@ -3841,7 +3940,7 @@ function emitWatchAttention(attns) {
|
|
|
3841
3940
|
function findLane(id) {
|
|
3842
3941
|
for (const lane of ["work", "chore"]) {
|
|
3843
3942
|
const d = laneDirs(lane);
|
|
3844
|
-
if (
|
|
3943
|
+
if (fs28.existsSync(path22.join(d.active, id)) || fs28.existsSync(path22.join(d.queue, `${id}.json`)) || fs28.existsSync(path22.join(d.done, id)) || fs28.existsSync(path22.join(d.state, `${id}.status`))) {
|
|
3845
3944
|
return lane;
|
|
3846
3945
|
}
|
|
3847
3946
|
}
|
|
@@ -3849,7 +3948,7 @@ function findLane(id) {
|
|
|
3849
3948
|
}
|
|
3850
3949
|
function rowsFor(lane, bucket) {
|
|
3851
3950
|
const dir = laneDirs(lane)[bucket];
|
|
3852
|
-
const entries =
|
|
3951
|
+
const entries = fs28.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs28.statSync(path22.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
|
|
3853
3952
|
const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
|
|
3854
3953
|
return sliced.map(({ f, m }) => {
|
|
3855
3954
|
const id = f.replace(/\.json$/, "");
|
|
@@ -3891,7 +3990,7 @@ ${usageFor(cmd)}`);
|
|
|
3891
3990
|
const d = {
|
|
3892
3991
|
id: arg(args, "--id") ?? randomUUID4(),
|
|
3893
3992
|
repo,
|
|
3894
|
-
brief: briefText ??
|
|
3993
|
+
brief: briefText ?? fs28.readFileSync(briefFile, "utf8"),
|
|
3895
3994
|
harness: arg(args, "--harness"),
|
|
3896
3995
|
model: arg(args, "--model"),
|
|
3897
3996
|
effort: arg(args, "--effort"),
|
|
@@ -3942,8 +4041,8 @@ ${usageFor(cmd)}`);
|
|
|
3942
4041
|
if (!id) throw new Error("logs requires a dispatch id");
|
|
3943
4042
|
const lane = findLane(id);
|
|
3944
4043
|
const file = eventsPath(id, lane);
|
|
3945
|
-
if (
|
|
3946
|
-
const raw =
|
|
4044
|
+
if (fs28.existsSync(file)) {
|
|
4045
|
+
const raw = fs28.readFileSync(file, "utf8");
|
|
3947
4046
|
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
3948
4047
|
const LIMIT = 50;
|
|
3949
4048
|
if (!args.includes("--full") && lines.length > LIMIT) {
|
|
@@ -3956,15 +4055,15 @@ ${usageFor(cmd)}`);
|
|
|
3956
4055
|
}
|
|
3957
4056
|
}
|
|
3958
4057
|
if (args.includes("--follow")) {
|
|
3959
|
-
let size =
|
|
4058
|
+
let size = fs28.existsSync(file) ? fs28.statSync(file).size : 0;
|
|
3960
4059
|
setInterval(() => {
|
|
3961
|
-
if (!
|
|
3962
|
-
const now =
|
|
4060
|
+
if (!fs28.existsSync(file)) return;
|
|
4061
|
+
const now = fs28.statSync(file).size;
|
|
3963
4062
|
if (now > size) {
|
|
3964
|
-
const fd =
|
|
4063
|
+
const fd = fs28.openSync(file, "r");
|
|
3965
4064
|
const buf = Buffer.alloc(now - size);
|
|
3966
|
-
|
|
3967
|
-
|
|
4065
|
+
fs28.readSync(fd, buf, 0, buf.length, size);
|
|
4066
|
+
fs28.closeSync(fd);
|
|
3968
4067
|
process.stdout.write(buf.toString("utf8"));
|
|
3969
4068
|
size = now;
|
|
3970
4069
|
}
|
|
@@ -4018,16 +4117,16 @@ ${usageFor(cmd)}`);
|
|
|
4018
4117
|
);
|
|
4019
4118
|
}
|
|
4020
4119
|
const dirs = laneDirs(lane);
|
|
4021
|
-
const descriptorFile = ["active", "done"].map((b) =>
|
|
4022
|
-
const harness = descriptorFile ? JSON.parse(
|
|
4120
|
+
const descriptorFile = ["active", "done"].map((b) => path22.join(dirs[b], id, "descriptor.json")).find((f) => fs28.existsSync(f));
|
|
4121
|
+
const harness = descriptorFile ? JSON.parse(fs28.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
|
|
4023
4122
|
let sessionId;
|
|
4024
4123
|
try {
|
|
4025
|
-
sessionId = JSON.parse(
|
|
4124
|
+
sessionId = JSON.parse(fs28.readFileSync(path22.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
|
|
4026
4125
|
} catch {
|
|
4027
4126
|
}
|
|
4028
4127
|
if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
|
|
4029
|
-
const worktree =
|
|
4030
|
-
const cwd =
|
|
4128
|
+
const worktree = path22.join(lobstahHome(), "worktrees", id);
|
|
4129
|
+
const cwd = fs28.existsSync(worktree) ? worktree : process.cwd();
|
|
4031
4130
|
const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
|
|
4032
4131
|
if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
|
|
4033
4132
|
if (args.includes("--print")) {
|
|
@@ -4042,19 +4141,19 @@ ${usageFor(cmd)}`);
|
|
|
4042
4141
|
const id = args[0];
|
|
4043
4142
|
if (!id) throw new Error("swap requires a dispatch id");
|
|
4044
4143
|
const lane = findLane(id);
|
|
4045
|
-
const activeDir =
|
|
4046
|
-
if (!
|
|
4047
|
-
const descFile =
|
|
4048
|
-
const descriptor = JSON.parse(
|
|
4144
|
+
const activeDir = path22.join(laneDirs(lane).active, id);
|
|
4145
|
+
if (!fs28.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
|
|
4146
|
+
const descFile = path22.join(activeDir, "descriptor.json");
|
|
4147
|
+
const descriptor = JSON.parse(fs28.readFileSync(descFile, "utf8"));
|
|
4049
4148
|
const fromHarness = descriptor.harness ?? "default";
|
|
4050
4149
|
for (const key of ["harness", "model", "effort"]) {
|
|
4051
4150
|
const v = arg(args, `--${key}`);
|
|
4052
4151
|
if (v) descriptor[key] = v;
|
|
4053
4152
|
}
|
|
4054
|
-
|
|
4055
|
-
const worktree =
|
|
4153
|
+
fs28.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
|
|
4154
|
+
const worktree = path22.join(lobstahHome(), "worktrees", id);
|
|
4056
4155
|
let progress = "No worktree progress recorded.";
|
|
4057
|
-
if (
|
|
4156
|
+
if (fs28.existsSync(worktree)) {
|
|
4058
4157
|
const git4 = (...a) => spawnSync6("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
4059
4158
|
const commits = git4("log", "--oneline", "-15");
|
|
4060
4159
|
const status = git4("status", "--short");
|
|
@@ -4064,17 +4163,17 @@ ${commits || "(none)"}
|
|
|
4064
4163
|
Uncommitted changes:
|
|
4065
4164
|
${status || "(clean)"}`;
|
|
4066
4165
|
}
|
|
4067
|
-
|
|
4068
|
-
|
|
4166
|
+
fs28.writeFileSync(
|
|
4167
|
+
path22.join(activeDir, "handoff"),
|
|
4069
4168
|
`You are taking over this dispatch from a previous agent session (harness: ${fromHarness}). Its conversation is not available \u2014 the worktree state below is the ground truth. Review it, then continue the brief from where it stops.
|
|
4070
4169
|
|
|
4071
4170
|
${progress}`
|
|
4072
4171
|
);
|
|
4073
|
-
const runnerFile =
|
|
4074
|
-
if (
|
|
4075
|
-
const runner = JSON.parse(
|
|
4172
|
+
const runnerFile = path22.join(activeDir, "runner.json");
|
|
4173
|
+
if (fs28.existsSync(runnerFile)) {
|
|
4174
|
+
const runner = JSON.parse(fs28.readFileSync(runnerFile, "utf8"));
|
|
4076
4175
|
if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
|
|
4077
|
-
|
|
4176
|
+
fs28.unlinkSync(runnerFile);
|
|
4078
4177
|
}
|
|
4079
4178
|
appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
|
|
4080
4179
|
console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
|
|
@@ -4179,20 +4278,20 @@ ${progress}`
|
|
|
4179
4278
|
break;
|
|
4180
4279
|
}
|
|
4181
4280
|
case "man:init": {
|
|
4182
|
-
const file = args.includes("--global") ?
|
|
4281
|
+
const file = args.includes("--global") ? path22.join(os6.homedir(), ".claude", "settings.json") : path22.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
|
|
4183
4282
|
let existing;
|
|
4184
4283
|
try {
|
|
4185
|
-
existing = JSON.parse(
|
|
4284
|
+
existing = JSON.parse(fs28.readFileSync(file, "utf8"));
|
|
4186
4285
|
} catch {
|
|
4187
4286
|
existing = void 0;
|
|
4188
4287
|
}
|
|
4189
4288
|
const { settings, changed } = mergeHaulHook(existing);
|
|
4190
4289
|
if (changed) {
|
|
4191
|
-
|
|
4192
|
-
|
|
4290
|
+
fs28.mkdirSync(path22.dirname(file), { recursive: true });
|
|
4291
|
+
fs28.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
4193
4292
|
`);
|
|
4194
4293
|
}
|
|
4195
|
-
if (args.includes("--marker"))
|
|
4294
|
+
if (args.includes("--marker")) fs28.writeFileSync(".lobstah-man", "");
|
|
4196
4295
|
console.log(
|
|
4197
4296
|
toonKV({
|
|
4198
4297
|
hook: "lobstah man haul",
|
|
@@ -4211,9 +4310,9 @@ ${progress}`
|
|
|
4211
4310
|
await soakPark(soakReg.sessionId, args);
|
|
4212
4311
|
break;
|
|
4213
4312
|
}
|
|
4214
|
-
if (process.env.LOBSTAH_MAN !== "1" && !
|
|
4313
|
+
if (process.env.LOBSTAH_MAN !== "1" && !fs28.existsSync(".lobstah-man")) break;
|
|
4215
4314
|
const anyActive = ["work", "chore"].some(
|
|
4216
|
-
(l) =>
|
|
4315
|
+
(l) => fs28.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
|
|
4217
4316
|
);
|
|
4218
4317
|
const anyWatch = listWatches().some((w) => w.owner === "man");
|
|
4219
4318
|
if (!anyActive && !anyWatch) break;
|
|
@@ -4375,7 +4474,7 @@ ${progress}`
|
|
|
4375
4474
|
path: r.path,
|
|
4376
4475
|
trunk: r.trunk,
|
|
4377
4476
|
pickup: r.pickup ?? false,
|
|
4378
|
-
exists:
|
|
4477
|
+
exists: fs28.existsSync(r.path)
|
|
4379
4478
|
}));
|
|
4380
4479
|
console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
|
|
4381
4480
|
break;
|
|
@@ -4425,7 +4524,7 @@ ${progress}`
|
|
|
4425
4524
|
break;
|
|
4426
4525
|
}
|
|
4427
4526
|
case "init": {
|
|
4428
|
-
if (!
|
|
4527
|
+
if (!fs28.existsSync(configPath())) {
|
|
4429
4528
|
const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
|
|
4430
4529
|
path = "~/src/example"
|
|
4431
4530
|
trunk = "main"
|
|
@@ -4434,7 +4533,7 @@ trunk = "main"
|
|
|
4434
4533
|
# pickup = true # opt into [pickup.github] multi-repo tracker pickup
|
|
4435
4534
|
|
|
4436
4535
|
`;
|
|
4437
|
-
|
|
4536
|
+
fs28.writeFileSync(
|
|
4438
4537
|
configPath(),
|
|
4439
4538
|
`# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
|
|
4440
4539
|
# Top-level keys must come before any [section].
|
|
@@ -4472,12 +4571,12 @@ wallClockSecs = 3600
|
|
|
4472
4571
|
let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
|
|
4473
4572
|
try {
|
|
4474
4573
|
const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
|
|
4475
|
-
if (
|
|
4574
|
+
if (fs28.existsSync(local)) reference = local;
|
|
4476
4575
|
} catch {
|
|
4477
4576
|
}
|
|
4478
4577
|
console.log(
|
|
4479
4578
|
toonKV({
|
|
4480
|
-
home:
|
|
4579
|
+
home: path22.dirname(configPath()),
|
|
4481
4580
|
config: configPath(),
|
|
4482
4581
|
...added.length > 0 ? { added: added.join(", ") } : {},
|
|
4483
4582
|
...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
|
package/docs/pickup.md
CHANGED
|
@@ -92,12 +92,30 @@ The three loops are tracker-agnostic and drive whichever sources are configured.
|
|
|
92
92
|
| Rule | Trigger | Descriptor |
|
|
93
93
|
|---|---|---|
|
|
94
94
|
| Issue pickup | Assigned to the configured identity, in the configured start state | Implementation brief from the issue |
|
|
95
|
-
|
|
|
96
|
-
|
|
97
|
-
A
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
| Feedback pickup | Human feedback on an open PR that maps back to a dispatch | Address-feedback brief; the agent reads the live thread |
|
|
96
|
+
|
|
97
|
+
A dispatch reports `done` when its PR opens, so the reviewer's side of the
|
|
98
|
+
conversation has to re-enter the queue as its own work. Feedback pickup
|
|
99
|
+
covers all of it: a `CHANGES_REQUESTED` review on any sha (a review of a
|
|
100
|
+
slightly stale head still asks for changes), a comment review with a body,
|
|
101
|
+
an inline review-thread comment, or a conversation comment — always from
|
|
102
|
+
someone other than the configured identity, never a lobstah marker comment.
|
|
103
|
+
`APPROVED` reviews are the merge loop's signal, not feedback.
|
|
104
|
+
|
|
105
|
+
The PR maps back to a dispatch two ways: a `lobstah/<uuid>` branch names it
|
|
106
|
+
directly, and any other branch — a soaked session's PR — resolves through
|
|
107
|
+
the PR URL its worker reported as evidence (`report done --pr <url>`). A PR
|
|
108
|
+
that maps to neither is not lobstah's to answer.
|
|
109
|
+
|
|
110
|
+
Each feedback **round** is keyed by the newest feedback event, so a new
|
|
111
|
+
comment after a round completes opens the next round, while the same standing
|
|
112
|
+
feedback never double-dispatches. Rounds on one PR serialize — a round that
|
|
113
|
+
arrives while the previous one runs buffers until it finishes — and each
|
|
114
|
+
round sets `followUp` to the latest session in the chain (the previous
|
|
115
|
+
round, else the implementation dispatch), so feedback lands on the context
|
|
116
|
+
that made the choices; a fully culled chain starts cold and reads the thread
|
|
117
|
+
like anyone else. A rebase chore starts cold on purpose — the conflict is
|
|
118
|
+
about commits the original session never saw.
|
|
101
119
|
|
|
102
120
|
### Claiming
|
|
103
121
|
|
|
@@ -339,7 +357,7 @@ Pickup's three loops and the daemon absorb that whole layer:
|
|
|
339
357
|
| Fleet-script job | Fate |
|
|
340
358
|
|---|---|
|
|
341
359
|
| Poll tracker, dispatch assigned issues | Dispatch loop, issue rule |
|
|
342
|
-
| Watch PRs for review feedback | Dispatch loop,
|
|
360
|
+
| Watch PRs for review feedback | Dispatch loop, feedback rule |
|
|
343
361
|
| Per-dispatch outcome checks and cron re-arms | Daemon supervision + `report` |
|
|
344
362
|
| Merge approved PRs | Merge loop |
|
|
345
363
|
| Detect in-progress issues nothing backs | Dead/wedged half → daemon; tracker-drift half → reconciliation loop |
|