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.
Files changed (3) hide show
  1. package/dist/main.js +271 -172
  2. package/docs/pickup.md +25 -7
  3. 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 fs27 from "node:fs";
4
+ import * as fs28 from "node:fs";
5
5
  import * as os6 from "node:os";
6
- import * as path21 from "node:path";
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
- if (!m)
2071
+ const uuid = m?.[1] ?? evidence.get(prUrlKey(pr.html_url));
2072
+ if (!uuid)
2039
2073
  continue;
2040
- const reviews = await this.latestReviews(pr.number);
2041
- const changes = reviews.find((r) => r.state === "CHANGES_REQUESTED" && r.commit_id === pr.head.sha);
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: `gh:${this.cfg.repo}#pr${pr.number}@${pr.head.sha}`,
2079
+ key: `${subject}@${feedback}`,
2080
+ subject,
2046
2081
  kind: "review",
2047
2082
  repoKey: this.cfg.key,
2048
2083
  title: pr.title,
2049
- followUp: m[1],
2050
- 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.`
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 fs16 from "node:fs";
2326
- import * as path11 from "node:path";
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 (fs16.existsSync(path11.join(d.active, uuid)) || fs16.existsSync(path11.join(d.queue, `${uuid}.json`)) || fs16.existsSync(path11.join(d.done, uuid)) || fs16.existsSync(path11.join(d.state, `${uuid}.status`))) {
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 fs17 from "node:fs";
2372
- import * as path12 from "node:path";
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 (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`))) {
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 fs19 from "node:fs";
2417
- import * as path14 from "node:path";
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 fs18 from "node:fs";
2421
- import * as path13 from "node:path";
2519
+ import * as fs19 from "node:fs";
2520
+ import * as path14 from "node:path";
2422
2521
  function viewPath() {
2423
- return path13.join(lobstahHome(), "pickup", "merge-view.json");
2522
+ return path14.join(lobstahHome(), "pickup", "merge-view.json");
2424
2523
  }
2425
2524
  function readMergeView() {
2426
2525
  try {
2427
- return JSON.parse(fs18.readFileSync(viewPath(), "utf8"));
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
- fs18.mkdirSync(path13.dirname(file), { recursive: true });
2533
+ fs19.mkdirSync(path14.dirname(file), { recursive: true });
2435
2534
  const tmp = `${file}.tmp-${process.pid}`;
2436
- fs18.writeFileSync(tmp, JSON.stringify(view, null, 2));
2437
- fs18.renameSync(tmp, file);
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(fs18.readFileSync(path13.join(lobstahHome(), "pickup", "state.json"), "utf8"));
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 (fs19.existsSync(path14.join(d.active, uuid)) || fs19.existsSync(path14.join(d.queue, `${uuid}.json`)))
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 fs20 from "node:fs";
2588
- import * as path15 from "node:path";
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 (fs20.existsSync(path15.join(d.active, id)) || fs20.existsSync(path15.join(d.queue, `${id}.json`)) || fs20.existsSync(path15.join(d.done, id)) || fs20.existsSync(path15.join(d.state, `${id}.status`))) {
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 = path15.join(d[bucket], id, "descriptor.json");
2608
- if (fs20.existsSync(f))
2609
- return JSON.parse(fs20.readFileSync(f, "utf8"));
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 = path15.join(d.queue, `${id}.json`);
2612
- if (fs20.existsSync(q))
2613
- return JSON.parse(fs20.readFileSync(q, "utf8"));
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 fs21 from "node:fs";
2847
- import * as path16 from "node:path";
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(fs21.readFileSync(file, "utf8"));
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 fs21.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
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", path16.join(d.queue, `${id}.json`));
2880
- for (const id of activeIds("work")) scan(id, "active", path16.join(d.active, id, "descriptor.json"));
2881
- for (const id of doneIds("work")) scan(id, "done", path16.join(d.done, id, "descriptor.json"));
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 (fs21.existsSync(path16.join(d.active, uuid))) return "active";
2887
- if (fs21.existsSync(path16.join(d.done, uuid))) return "done";
2888
- if (fs21.existsSync(path16.join(d.queue, `${uuid}.json`))) return "queued";
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 = fs21.statSync(path16.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
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 fs22 from "node:fs";
3086
- import * as path17 from "node:path";
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(fs22.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
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 = path17.join(d.done, id);
3107
- const m = fs22.statSync(p).mtimeMs;
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 = path17.join(lobstahHome(), "worktrees");
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 = path17.join(wtRoot, id);
3118
- items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs22.statSync(p).mtimeMs) / DAY) });
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 fs22.readdirSync(d.state)) {
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 = fs22.statSync(path17.join(d.state, f)).mtimeMs;
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: path17.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
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 = path17.join(laneDirs(lane).done, id, "descriptor.json");
3235
+ const descFile = path18.join(laneDirs(lane).done, id, "descriptor.json");
3137
3236
  try {
3138
- const desc = JSON.parse(fs22.readFileSync(descFile, "utf8"));
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
- fs22.rmSync(dir, { recursive: true, force: true });
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")) fs22.rmSync(item.target, { recursive: true, force: true });
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 = path17.dirname(item.target);
3154
- for (const f of fs22.readdirSync(dir)) {
3155
- if (f.startsWith(`${item.id}.`)) fs22.rmSync(path17.join(dir, f), { force: true });
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 fs23 from "node:fs";
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 (!fs23.existsSync(cfgFile)) {
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(fs23.readFileSync(cfgFile, "utf8"));
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 (!fs23.existsSync(repo.path)) {
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 (!fs23.existsSync(hb)) {
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(fs23.readFileSync(hb, "utf8"));
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 fs24 from "node:fs";
3396
+ import * as fs25 from "node:fs";
3298
3397
  import * as os5 from "node:os";
3299
- import * as path18 from "node:path";
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -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(path18.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
3324
- <key>StandardErrorPath</key><string>${xml(path18.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
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:${path18.posix.join(spec.logDir, `${spec.kind}.log`)}
3340
- StandardError=append:${path18.posix.join(spec.logDir, `${spec.kind}.err`)}
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
- path18.posix.dirname(nodePath),
3349
- path18.posix.join(home, ".local", "bin"),
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" ? path18.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path18.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
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 = fs24.realpathSync(process.argv[1]);
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: path18.join(lobstahHome(), "logs")
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
- fs24.mkdirSync(spec.logDir, { recursive: true });
3479
+ fs25.mkdirSync(spec.logDir, { recursive: true });
3381
3480
  const file = serviceFile(kind);
3382
- fs24.mkdirSync(path18.dirname(file), { recursive: true });
3481
+ fs25.mkdirSync(path19.dirname(file), { recursive: true });
3383
3482
  if (process.platform === "darwin") {
3384
- if (fs24.existsSync(file)) run("launchctl", ["unload", file]);
3385
- fs24.writeFileSync(file, renderLaunchdPlist(spec));
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
- fs24.writeFileSync(file, renderSystemdUnit(spec));
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 (fs24.existsSync(file)) run("launchctl", ["unload", file]);
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 = fs24.existsSync(file);
3402
- if (removed) fs24.unlinkSync(file);
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 fs25 from "node:fs";
3409
- import * as path19 from "node:path";
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 = path19.resolve(dir);
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 = path19.resolve(fs25.realpathSync.native(p));
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]) => fs25.existsSync(path19.join(abs, f)))?.[1];
3437
- return { key: path19.basename(abs), path: abs, trunk, origin, setup };
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 = path19.resolve(root);
3443
- if (!fs25.existsSync(abs)) continue;
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 fs25.readdirSync(abs, { withFileTypes: true })) {
3548
+ for (const entry of fs26.readdirSync(abs, { withFileTypes: true })) {
3450
3549
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
3451
- const child = detectRepo(path19.join(abs, entry.name));
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 (!fs25.existsSync(file)) return /* @__PURE__ */ new Set();
3459
- const raw = parse(fs25.readFileSync(file, "utf8"));
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
- fs25.appendFileSync(file, `${lines.join("\n")}
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 fs26 from "node:fs";
3481
- import * as path20 from "node:path";
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 = path20.resolve(fs26.realpathSync.native(p));
3588
+ const real = path21.resolve(fs27.realpathSync.native(p));
3490
3589
  return process.platform === "win32" ? real.toLowerCase() : real;
3491
3590
  } catch {
3492
- return path20.resolve(p);
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) => path20.isAbsolute(p) ? p : path20.join(cwd, 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(path20.dirname(abs(commonDir)));
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 = fs26.readFileSync(0, "utf8");
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 (fs27.existsSync(path21.join(d.active, id)) || fs27.existsSync(path21.join(d.queue, `${id}.json`)) || fs27.existsSync(path21.join(d.done, id)) || fs27.existsSync(path21.join(d.state, `${id}.status`))) {
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 = fs27.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs27.statSync(path21.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
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 ?? fs27.readFileSync(briefFile, "utf8"),
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 (fs27.existsSync(file)) {
3946
- const raw = fs27.readFileSync(file, "utf8");
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 = fs27.existsSync(file) ? fs27.statSync(file).size : 0;
4058
+ let size = fs28.existsSync(file) ? fs28.statSync(file).size : 0;
3960
4059
  setInterval(() => {
3961
- if (!fs27.existsSync(file)) return;
3962
- const now = fs27.statSync(file).size;
4060
+ if (!fs28.existsSync(file)) return;
4061
+ const now = fs28.statSync(file).size;
3963
4062
  if (now > size) {
3964
- const fd = fs27.openSync(file, "r");
4063
+ const fd = fs28.openSync(file, "r");
3965
4064
  const buf = Buffer.alloc(now - size);
3966
- fs27.readSync(fd, buf, 0, buf.length, size);
3967
- fs27.closeSync(fd);
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) => path21.join(dirs[b], id, "descriptor.json")).find((f) => fs27.existsSync(f));
4022
- const harness = descriptorFile ? JSON.parse(fs27.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
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(fs27.readFileSync(path21.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
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 = path21.join(lobstahHome(), "worktrees", id);
4030
- const cwd = fs27.existsSync(worktree) ? worktree : process.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 = path21.join(laneDirs(lane).active, id);
4046
- if (!fs27.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
4047
- const descFile = path21.join(activeDir, "descriptor.json");
4048
- const descriptor = JSON.parse(fs27.readFileSync(descFile, "utf8"));
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
- fs27.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
4055
- const worktree = path21.join(lobstahHome(), "worktrees", id);
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 (fs27.existsSync(worktree)) {
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
- fs27.writeFileSync(
4068
- path21.join(activeDir, "handoff"),
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 = path21.join(activeDir, "runner.json");
4074
- if (fs27.existsSync(runnerFile)) {
4075
- const runner = JSON.parse(fs27.readFileSync(runnerFile, "utf8"));
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
- fs27.unlinkSync(runnerFile);
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") ? path21.join(os6.homedir(), ".claude", "settings.json") : path21.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
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(fs27.readFileSync(file, "utf8"));
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
- fs27.mkdirSync(path21.dirname(file), { recursive: true });
4192
- fs27.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
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")) fs27.writeFileSync(".lobstah-man", "");
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" && !fs27.existsSync(".lobstah-man")) break;
4313
+ if (process.env.LOBSTAH_MAN !== "1" && !fs28.existsSync(".lobstah-man")) break;
4215
4314
  const anyActive = ["work", "chore"].some(
4216
- (l) => fs27.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
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: fs27.existsSync(r.path)
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 (!fs27.existsSync(configPath())) {
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
- fs27.writeFileSync(
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 (fs27.existsSync(local)) reference = local;
4574
+ if (fs28.existsSync(local)) reference = local;
4476
4575
  } catch {
4477
4576
  }
4478
4577
  console.log(
4479
4578
  toonKV({
4480
- home: path21.dirname(configPath()),
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
- | Review pickup | Open PR authored by the configured identity with `CHANGES_REQUESTED`, or a human review newer than HEAD | Address-review brief from the feedback |
96
-
97
- A review dispatch sets `followUp` to the implementation dispatch's UUID,
98
- forking that session so the feedback lands on the context that made the
99
- choices. A rebase chore starts cold on purpose — the conflict is about commits
100
- the original session never saw.
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, review rule |
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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobstah",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Harness-agnostic, token-efficient supervision framework for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "aequitas labs LLC",