lobstah 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
@@ -1957,6 +1957,12 @@ var PickupState = class {
1957
1957
  this.data.map[key] = { ...cur, ...patch };
1958
1958
  this.save();
1959
1959
  }
1960
+ releaseIssue(key) {
1961
+ const entry = this.get(key);
1962
+ if (entry?.kind !== "issue" || entry.lastReported !== "failed")
1963
+ return;
1964
+ this.update(key, { released: true, attempts: entry.attempts ?? 1 });
1965
+ }
1960
1966
  approvalConsumed(dedupKey) {
1961
1967
  return dedupKey in this.data.consumedApprovals;
1962
1968
  }
@@ -1984,6 +1990,38 @@ var PickupState = class {
1984
1990
  }
1985
1991
  };
1986
1992
 
1993
+ // apps/pick/dist/pr-evidence.js
1994
+ import * as fs16 from "node:fs";
1995
+ import * as path11 from "node:path";
1996
+ function prUrlKey(url) {
1997
+ return url.replace(/\/+$/, "");
1998
+ }
1999
+ function prEvidenceIndex() {
2000
+ const byUrl = /* @__PURE__ */ new Map();
2001
+ const stateDir = laneDirs("work").state;
2002
+ let files;
2003
+ try {
2004
+ files = fs16.readdirSync(stateDir).filter((f) => f.endsWith(".evidence"));
2005
+ } catch {
2006
+ return /* @__PURE__ */ new Map();
2007
+ }
2008
+ for (const f of files) {
2009
+ const file = path11.join(stateDir, f);
2010
+ try {
2011
+ const prUrl = JSON.parse(fs16.readFileSync(file, "utf8")).prUrl;
2012
+ if (!prUrl)
2013
+ continue;
2014
+ const key = prUrlKey(prUrl);
2015
+ const mtime = fs16.statSync(file).mtimeMs;
2016
+ const cur = byUrl.get(key);
2017
+ if (!cur || mtime > cur.mtime)
2018
+ byUrl.set(key, { uuid: f.slice(0, -".evidence".length), mtime });
2019
+ } catch {
2020
+ }
2021
+ }
2022
+ return new Map([...byUrl].map(([url, v]) => [url, v.uuid]));
2023
+ }
2024
+
1987
2025
  // apps/pick/dist/sources/github.js
1988
2026
  var API = "https://api.github.com";
1989
2027
  var GithubSource = class {
@@ -2032,26 +2070,70 @@ ${issue.body ?? ""}
2032
2070
  When the change is complete, commit it, push the branch, and open a PR against ${this.cfg.repo} referencing #${issue.number}.`
2033
2071
  });
2034
2072
  }
2073
+ const evidence = prEvidenceIndex();
2035
2074
  const pulls = await this.api("GET", `/repos/${this.cfg.repo}/pulls?state=open&per_page=50`);
2036
2075
  for (const pr of pulls) {
2037
2076
  const m = /^lobstah\/([0-9a-f-]{36})$/.exec(pr.head.ref);
2038
- if (!m)
2077
+ const uuid = m?.[1] ?? evidence.get(prUrlKey(pr.html_url));
2078
+ if (!uuid)
2039
2079
  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)
2080
+ const feedback = await this.latestFeedback(pr.number);
2081
+ if (!feedback)
2043
2082
  continue;
2083
+ const subject = `gh:${this.cfg.repo}#pr${pr.number}`;
2044
2084
  items.push({
2045
- key: `gh:${this.cfg.repo}#pr${pr.number}@${pr.head.sha}`,
2085
+ key: `${subject}@${feedback}`,
2086
+ subject,
2046
2087
  kind: "review",
2047
2088
  repoKey: this.cfg.key,
2048
2089
  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.`
2090
+ followUp: uuid,
2091
+ 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
2092
  });
2052
2093
  }
2053
2094
  return items;
2054
2095
  }
2096
+ /**
2097
+ * The newest human feedback event on a PR, as an opaque round tag —
2098
+ * `rv<id>` for a submitted review, `rc<id>` for an inline review-thread
2099
+ * comment, `ic<id>` for a conversation comment. Feedback means: a
2100
+ * CHANGES_REQUESTED review on any sha (a review of a slightly stale head
2101
+ * still asks for changes), a COMMENTED review with a body, or any comment
2102
+ * — always by someone other than the configured identity, and never a
2103
+ * marker comment. APPROVED reviews are the merge loop's signal, not
2104
+ * feedback. Returns undefined when no human has spoken.
2105
+ */
2106
+ async latestFeedback(n) {
2107
+ const events = [];
2108
+ const human = (login) => login !== this.cfg.identity;
2109
+ const reviews = await this.paged(`/repos/${this.cfg.repo}/pulls/${n}/reviews`);
2110
+ for (const r of reviews) {
2111
+ if (!human(r.user.login))
2112
+ continue;
2113
+ const actionable = r.state === "CHANGES_REQUESTED" || r.state === "COMMENTED" && (r.body ?? "").trim() !== "";
2114
+ if (actionable)
2115
+ events.push({ at: r.submitted_at, tag: `rv${r.id}` });
2116
+ }
2117
+ for (const c of await this.paged(`/repos/${this.cfg.repo}/pulls/${n}/comments`)) {
2118
+ if (human(c.user.login))
2119
+ events.push({ at: c.created_at, tag: `rc${c.id}` });
2120
+ }
2121
+ for (const c of await this.paged(`/repos/${this.cfg.repo}/issues/${n}/comments`)) {
2122
+ if (human(c.user.login) && !MARKER.test(c.body))
2123
+ events.push({ at: c.created_at, tag: `ic${c.id}` });
2124
+ }
2125
+ events.sort((a, b) => a.at === b.at ? a.tag.localeCompare(b.tag) : a.at.localeCompare(b.at));
2126
+ return events.at(-1)?.tag;
2127
+ }
2128
+ async paged(url) {
2129
+ const all = [];
2130
+ for (let page = 1; ; page++) {
2131
+ const batch = await this.api("GET", `${url}?per_page=100&page=${page}`);
2132
+ all.push(...batch);
2133
+ if (batch.length < 100)
2134
+ return all;
2135
+ }
2136
+ }
2055
2137
  async claim(item) {
2056
2138
  if (item.kind === "review")
2057
2139
  return true;
@@ -2166,6 +2248,7 @@ When the change is complete, commit it, push the branch, and open a PR against $
2166
2248
 
2167
2249
  // apps/pick/dist/sources/linear.js
2168
2250
  var API2 = "https://api.linear.app/graphql";
2251
+ var CLOSED_TYPES = ["completed", "canceled"];
2169
2252
  function stateFor(cfg, verb) {
2170
2253
  if (verb === "done")
2171
2254
  return cfg.doneState;
@@ -2192,15 +2275,22 @@ var LinearSource = class {
2192
2275
  }
2193
2276
  return json.data;
2194
2277
  }
2195
- async assigned(stateName, byTypes) {
2278
+ async assigned(filter) {
2196
2279
  const field = this.cfg.assignField ?? "assignee";
2197
- const stateFilter = byTypes?.length ? { type: { in: byTypes } } : { name: { eq: stateName } };
2198
- const data = await this.gql(`query($filter: IssueFilter!) {
2199
- issues(filter: $filter, first: 50) {
2200
- nodes { id identifier title description state { name type } team { key id } }
2201
- }
2202
- }`, { filter: { [field]: { isMe: { eq: true } }, state: stateFilter } });
2203
- return data.issues.nodes;
2280
+ const issues = [];
2281
+ let after = null;
2282
+ for (; ; ) {
2283
+ const data = await this.gql(`query($filter: IssueFilter!, $after: String) {
2284
+ issues(filter: $filter, first: 50, after: $after) {
2285
+ nodes { id identifier title description state { name type } team { key id } }
2286
+ pageInfo { hasNextPage endCursor }
2287
+ }
2288
+ }`, { filter: { [field]: { isMe: { eq: true } }, ...filter }, after });
2289
+ issues.push(...data.issues.nodes);
2290
+ if (!data.issues.pageInfo.hasNextPage)
2291
+ return issues;
2292
+ after = data.issues.pageInfo.endCursor;
2293
+ }
2204
2294
  }
2205
2295
  async stateId(teamId, name) {
2206
2296
  const cacheKey = `${teamId}:${name}`;
@@ -2228,7 +2318,8 @@ var LinearSource = class {
2228
2318
  await this.gql(`mutation($id: String!, $body: String!) { commentCreate(input: { issueId: $id, body: $body }) { success } }`, { id: issue.id, body });
2229
2319
  }
2230
2320
  async poll() {
2231
- const issues = await this.assigned(this.cfg.startState, this.cfg.startStateTypes);
2321
+ const state = this.cfg.startStateTypes?.length ? { type: { in: this.cfg.startStateTypes } } : { name: { eq: this.cfg.startState } };
2322
+ const issues = await this.assigned({ state });
2232
2323
  return issues.flatMap((issue) => {
2233
2324
  const repoKey = this.cfg.route[issue.team.key];
2234
2325
  if (!repoKey)
@@ -2267,7 +2358,7 @@ When the change is complete, commit it, push the branch, and open a PR whose des
2267
2358
  lines.push(evidence.note);
2268
2359
  await this.commentOn(issue, lines.join("\n"));
2269
2360
  const target = stateFor(this.cfg, verb);
2270
- if (target)
2361
+ if (target && !CLOSED_TYPES.includes(issue.state.type))
2271
2362
  await this.moveTo(issue, target);
2272
2363
  }
2273
2364
  async inbound(key, since) {
@@ -2276,8 +2367,10 @@ When the change is complete, commit it, push the branch, and open a PR whose des
2276
2367
  return data.issue.comments.nodes.filter((c) => !c.user?.isMe && !MARKER.test(c.body)).filter((c) => !since || c.createdAt > since).map((c) => c.body);
2277
2368
  }
2278
2369
  async inProgress() {
2279
- const issues = await this.assigned(this.cfg.claimedState);
2280
- return issues.map((i) => ({ key: `linear:${i.identifier}`, open: true }));
2370
+ const issues = await this.assigned({
2371
+ or: [{ state: { name: { eq: this.cfg.claimedState } } }, { state: { type: { in: CLOSED_TYPES } } }]
2372
+ });
2373
+ return issues.map((i) => ({ key: `linear:${i.identifier}`, open: !CLOSED_TYPES.includes(i.state.type) }));
2281
2374
  }
2282
2375
  async recoverUuid(key) {
2283
2376
  const identifier = key.replace(/^linear:/, "");
@@ -2298,36 +2391,14 @@ When the change is complete, commit it, push the branch, and open a PR whose des
2298
2391
 
2299
2392
  // apps/pick/dist/loops/dispatch.js
2300
2393
  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
2394
 
2324
2395
  // apps/pick/dist/loops/report.js
2325
- import * as fs16 from "node:fs";
2326
- import * as path11 from "node:path";
2396
+ import * as fs17 from "node:fs";
2397
+ import * as path12 from "node:path";
2327
2398
  function dispatchLane(uuid) {
2328
2399
  for (const lane of ["work", "chore"]) {
2329
2400
  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`))) {
2401
+ 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
2402
  return lane;
2332
2403
  }
2333
2404
  }
@@ -2337,6 +2408,8 @@ async function reportLoop(source, state, log = () => {
2337
2408
  }, notify = () => {
2338
2409
  }) {
2339
2410
  for (const [key, entry] of state.entries()) {
2411
+ if (entry.released)
2412
+ continue;
2340
2413
  const lane = dispatchLane(entry.uuid);
2341
2414
  if (!lane)
2342
2415
  continue;
@@ -2357,6 +2430,11 @@ async function reportLoop(source, state, log = () => {
2357
2430
  prUrl: evidence.prUrl
2358
2431
  });
2359
2432
  }
2433
+ if (entry.kind === "issue" && verb === "failed" && fs17.existsSync(path12.join(laneDirs(lane).done, entry.uuid))) {
2434
+ state.releaseIssue(key);
2435
+ log(`${key}: finalized failure released for a bounded retry`);
2436
+ continue;
2437
+ }
2360
2438
  const msgs = await source.inbound(key, entry.lastInboundAt);
2361
2439
  if (msgs.length > 0) {
2362
2440
  for (const m of msgs)
@@ -2367,13 +2445,71 @@ async function reportLoop(source, state, log = () => {
2367
2445
  }
2368
2446
  }
2369
2447
 
2448
+ // apps/pick/dist/loops/dispatch.js
2449
+ function terminal(uuid) {
2450
+ const lane = dispatchLane(uuid);
2451
+ if (!lane)
2452
+ return true;
2453
+ const verb = reconcile({ log: readStatusLog(uuid, lane), lastEventAt: lastEventAt(uuid, lane) });
2454
+ return verb === "done" || verb === "failed";
2455
+ }
2456
+ function roundPlan(item, state) {
2457
+ const rounds = item.subject ? state.entries().filter(([k]) => k.startsWith(`${item.subject}@`)).map(([, e]) => e) : [];
2458
+ const chain = [...rounds.map((e) => e.uuid), ...item.followUp ? [item.followUp] : []];
2459
+ if (chain.some((uuid) => !terminal(uuid)))
2460
+ return { hold: true };
2461
+ const forkable = rounds.filter((e) => dispatchLane(e.uuid)).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).at(-1)?.uuid;
2462
+ const target = forkable ?? item.followUp;
2463
+ return { hold: false, followUp: target && dispatchLane(target) ? target : void 0 };
2464
+ }
2465
+ async function dispatchLoop(source, state, log = () => {
2466
+ }) {
2467
+ const items = await source.poll();
2468
+ const maxAttempts = 1 + loadConfig().limits.maxRestartAttempts;
2469
+ for (const item of items) {
2470
+ const previous = state.get(item.key);
2471
+ if (previous) {
2472
+ if (item.kind !== "issue" || previous.kind !== "issue" || !previous.released)
2473
+ continue;
2474
+ if ((previous.attempts ?? 1) >= maxAttempts) {
2475
+ log(`${item.key}: issue attempt limit (${maxAttempts}) reached \u2014 holding for a human`);
2476
+ continue;
2477
+ }
2478
+ }
2479
+ const plan = item.kind === "review" ? roundPlan(item, state) : { hold: false, followUp: void 0 };
2480
+ if (plan.hold) {
2481
+ log(`${item.key}: a prior round is still in flight \u2014 holding`);
2482
+ continue;
2483
+ }
2484
+ if (!await source.claim(item)) {
2485
+ log(`${item.key}: claim lost \u2014 another machine has it`);
2486
+ continue;
2487
+ }
2488
+ const id = randomUUID();
2489
+ enqueue({
2490
+ id,
2491
+ repo: item.repoKey,
2492
+ brief: item.brief,
2493
+ // A review dispatch forks the latest session in its chain; issues start cold.
2494
+ ...plan.followUp ? { followUp: plan.followUp } : {}
2495
+ }, "work");
2496
+ state.set(item.key, {
2497
+ uuid: id,
2498
+ kind: item.kind,
2499
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2500
+ ...item.kind === "issue" ? { attempts: previous ? (previous.attempts ?? 1) + 1 : 1 } : {}
2501
+ });
2502
+ log(`${item.key}: dispatched as ${id}`);
2503
+ }
2504
+ }
2505
+
2370
2506
  // apps/pick/dist/loops/reconcile.js
2371
- import * as fs17 from "node:fs";
2372
- import * as path12 from "node:path";
2507
+ import * as fs18 from "node:fs";
2508
+ import * as path13 from "node:path";
2373
2509
  function dispatchActive(uuid) {
2374
2510
  for (const lane of ["work", "chore"]) {
2375
2511
  const d = laneDirs(lane);
2376
- if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`))) {
2512
+ if (fs18.existsSync(path13.join(d.active, uuid)) || fs18.existsSync(path13.join(d.queue, `${uuid}.json`))) {
2377
2513
  return lane;
2378
2514
  }
2379
2515
  }
@@ -2413,28 +2549,28 @@ async function reconcileLoop(source, state, log = () => {
2413
2549
 
2414
2550
  // apps/pick/dist/loops/merge.js
2415
2551
  import { randomUUID as randomUUID2 } from "node:crypto";
2416
- import * as fs19 from "node:fs";
2417
- import * as path14 from "node:path";
2552
+ import * as fs20 from "node:fs";
2553
+ import * as path15 from "node:path";
2418
2554
 
2419
2555
  // apps/pick/dist/merge-view.js
2420
- import * as fs18 from "node:fs";
2421
- import * as path13 from "node:path";
2556
+ import * as fs19 from "node:fs";
2557
+ import * as path14 from "node:path";
2422
2558
  function viewPath() {
2423
- return path13.join(lobstahHome(), "pickup", "merge-view.json");
2559
+ return path14.join(lobstahHome(), "pickup", "merge-view.json");
2424
2560
  }
2425
2561
  function readMergeView() {
2426
2562
  try {
2427
- return JSON.parse(fs18.readFileSync(viewPath(), "utf8"));
2563
+ return JSON.parse(fs19.readFileSync(viewPath(), "utf8"));
2428
2564
  } catch {
2429
2565
  return void 0;
2430
2566
  }
2431
2567
  }
2432
2568
  function writeMergeView(view) {
2433
2569
  const file = viewPath();
2434
- fs18.mkdirSync(path13.dirname(file), { recursive: true });
2570
+ fs19.mkdirSync(path14.dirname(file), { recursive: true });
2435
2571
  const tmp = `${file}.tmp-${process.pid}`;
2436
- fs18.writeFileSync(tmp, JSON.stringify(view, null, 2));
2437
- fs18.renameSync(tmp, file);
2572
+ fs19.writeFileSync(tmp, JSON.stringify(view, null, 2));
2573
+ fs19.renameSync(tmp, file);
2438
2574
  }
2439
2575
  var RECENT_RETENTION_MS = 48 * 36e5;
2440
2576
  async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
@@ -2456,7 +2592,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
2456
2592
  }
2457
2593
  function readPickupMap() {
2458
2594
  try {
2459
- const raw = JSON.parse(fs18.readFileSync(path13.join(lobstahHome(), "pickup", "state.json"), "utf8"));
2595
+ const raw = JSON.parse(fs19.readFileSync(path14.join(lobstahHome(), "pickup", "state.json"), "utf8"));
2460
2596
  return raw.map ?? {};
2461
2597
  } catch {
2462
2598
  return {};
@@ -2490,7 +2626,7 @@ function rebaseBrief(pr) {
2490
2626
  }
2491
2627
  function choreVerdict(uuid) {
2492
2628
  const d = laneDirs("chore");
2493
- if (fs19.existsSync(path14.join(d.active, uuid)) || fs19.existsSync(path14.join(d.queue, `${uuid}.json`)))
2629
+ if (fs20.existsSync(path15.join(d.active, uuid)) || fs20.existsSync(path15.join(d.queue, `${uuid}.json`)))
2494
2630
  return "active";
2495
2631
  const last = readStatusLog(uuid, "chore").at(-1)?.verb;
2496
2632
  return last === "done" ? "done" : "failed";
@@ -2584,8 +2720,8 @@ async function mergeLoop(ms, policy, state, log = () => {
2584
2720
  }
2585
2721
 
2586
2722
  // apps/pick/dist/loops/watch.js
2587
- import * as fs20 from "node:fs";
2588
- import * as path15 from "node:path";
2723
+ import * as fs21 from "node:fs";
2724
+ import * as path16 from "node:path";
2589
2725
  import { randomUUID as randomUUID3 } from "node:crypto";
2590
2726
  var DEFAULT_BRIEF = `You are resuming earlier work. The watched source {key} produced new events:
2591
2727
 
@@ -2595,7 +2731,7 @@ Read them, act on what they ask, and report the outcome.`;
2595
2731
  function laneOf(id) {
2596
2732
  for (const lane of ["work", "chore"]) {
2597
2733
  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`))) {
2734
+ 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
2735
  return lane;
2600
2736
  }
2601
2737
  }
@@ -2604,13 +2740,13 @@ function laneOf(id) {
2604
2740
  function descriptorOf(id, lane) {
2605
2741
  const d = laneDirs(lane);
2606
2742
  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"));
2743
+ const f = path16.join(d[bucket], id, "descriptor.json");
2744
+ if (fs21.existsSync(f))
2745
+ return JSON.parse(fs21.readFileSync(f, "utf8"));
2610
2746
  }
2611
- const q = path15.join(d.queue, `${id}.json`);
2612
- if (fs20.existsSync(q))
2613
- return JSON.parse(fs20.readFileSync(q, "utf8"));
2747
+ const q = path16.join(d.queue, `${id}.json`);
2748
+ if (fs21.existsSync(q))
2749
+ return JSON.parse(fs21.readFileSync(q, "utf8"));
2614
2750
  return void 0;
2615
2751
  }
2616
2752
  function isTerminal(id) {
@@ -2843,21 +2979,21 @@ function mergeHaulHook(existing) {
2843
2979
  }
2844
2980
 
2845
2981
  // apps/cli/src/tend.ts
2846
- import * as fs21 from "node:fs";
2847
- import * as path16 from "node:path";
2982
+ import * as fs22 from "node:fs";
2983
+ import * as path17 from "node:path";
2848
2984
  var HEARTBEAT_STALE_MS = 9e4;
2849
2985
  var CLAIM_STALE_MS = 12e4;
2850
2986
  var DAY_MS = 24 * 36e5;
2851
2987
  function readJson(file) {
2852
2988
  try {
2853
- return JSON.parse(fs21.readFileSync(file, "utf8"));
2989
+ return JSON.parse(fs22.readFileSync(file, "utf8"));
2854
2990
  } catch {
2855
2991
  return void 0;
2856
2992
  }
2857
2993
  }
2858
2994
  function doneIds(lane) {
2859
2995
  try {
2860
- return fs21.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2996
+ return fs22.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2861
2997
  } catch {
2862
2998
  return [];
2863
2999
  }
@@ -2876,16 +3012,16 @@ function followUps(uuid) {
2876
3012
  const desc = readJson(file);
2877
3013
  if (desc?.followUp === uuid) out.push({ id, bucket });
2878
3014
  };
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"));
3015
+ for (const id of pendingIds("work")) scan(id, "queued", path17.join(d.queue, `${id}.json`));
3016
+ for (const id of activeIds("work")) scan(id, "active", path17.join(d.active, id, "descriptor.json"));
3017
+ for (const id of doneIds("work")) scan(id, "done", path17.join(d.done, id, "descriptor.json"));
2882
3018
  return out;
2883
3019
  }
2884
3020
  function bucketOf(uuid) {
2885
3021
  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";
3022
+ if (fs22.existsSync(path17.join(d.active, uuid))) return "active";
3023
+ if (fs22.existsSync(path17.join(d.done, uuid))) return "done";
3024
+ if (fs22.existsSync(path17.join(d.queue, `${uuid}.json`))) return "queued";
2889
3025
  return void 0;
2890
3026
  }
2891
3027
  function buildTendReport(now = Date.now()) {
@@ -2945,7 +3081,7 @@ function buildTendReport(now = Date.now()) {
2945
3081
  }
2946
3082
  }
2947
3083
  const oldestQueuedAge = queued.reduce((max, id) => {
2948
- const st = fs21.statSync(path16.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
3084
+ const st = fs22.statSync(path17.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2949
3085
  return st ? Math.max(max, now - st.mtimeMs) : max;
2950
3086
  }, 0);
2951
3087
  const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
@@ -3082,13 +3218,13 @@ function renderTend(r) {
3082
3218
  }
3083
3219
 
3084
3220
  // apps/cli/src/cull.ts
3085
- import * as fs22 from "node:fs";
3086
- import * as path17 from "node:path";
3221
+ import * as fs23 from "node:fs";
3222
+ import * as path18 from "node:path";
3087
3223
  import { execFileSync as execFileSync2 } from "node:child_process";
3088
3224
  var DAY = 864e5;
3089
3225
  function idsIn(dir) {
3090
3226
  try {
3091
- return new Set(fs22.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
3227
+ return new Set(fs23.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
3092
3228
  } catch {
3093
3229
  return /* @__PURE__ */ new Set();
3094
3230
  }
@@ -3103,39 +3239,39 @@ function planCull(olderThanDays, now = Date.now()) {
3103
3239
  for (const id of idsIn(d.queue)) live.add(id);
3104
3240
  for (const id of idsIn(d.active)) live.add(id);
3105
3241
  for (const id of idsIn(d.done)) {
3106
- const p = path17.join(d.done, id);
3107
- const m = fs22.statSync(p).mtimeMs;
3242
+ const p = path18.join(d.done, id);
3243
+ const m = fs23.statSync(p).mtimeMs;
3108
3244
  doneMtimes.set(id, m);
3109
3245
  if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
3110
3246
  }
3111
3247
  }
3112
- const wtRoot = path17.join(lobstahHome(), "worktrees");
3248
+ const wtRoot = path18.join(lobstahHome(), "worktrees");
3113
3249
  for (const id of idsIn(wtRoot)) {
3114
3250
  if (live.has(id)) continue;
3115
3251
  const doneAt = doneMtimes.get(id);
3116
3252
  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) });
3253
+ const p = path18.join(wtRoot, id);
3254
+ items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs23.statSync(p).mtimeMs) / DAY) });
3119
3255
  }
3120
3256
  for (const lane of ["work", "chore"]) {
3121
3257
  const d = laneDirs(lane);
3122
3258
  const seen = /* @__PURE__ */ new Set();
3123
- for (const f of fs22.readdirSync(d.state)) {
3259
+ for (const f of fs23.readdirSync(d.state)) {
3124
3260
  const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
3125
3261
  if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
3126
- const m = fs22.statSync(path17.join(d.state, f)).mtimeMs;
3262
+ const m = fs23.statSync(path18.join(d.state, f)).mtimeMs;
3127
3263
  if (m >= cutoff) continue;
3128
3264
  seen.add(id);
3129
- items.push({ kind: "state", id, target: path17.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
3265
+ items.push({ kind: "state", id, target: path18.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
3130
3266
  }
3131
3267
  }
3132
3268
  return items;
3133
3269
  }
3134
3270
  function removeWorktree(id, dir) {
3135
3271
  for (const lane of ["work", "chore"]) {
3136
- const descFile = path17.join(laneDirs(lane).done, id, "descriptor.json");
3272
+ const descFile = path18.join(laneDirs(lane).done, id, "descriptor.json");
3137
3273
  try {
3138
- const desc = JSON.parse(fs22.readFileSync(descFile, "utf8"));
3274
+ const desc = JSON.parse(fs23.readFileSync(descFile, "utf8"));
3139
3275
  const repo = loadConfig().repos[desc.repo];
3140
3276
  if (repo) {
3141
3277
  execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
@@ -3144,15 +3280,15 @@ function removeWorktree(id, dir) {
3144
3280
  } catch {
3145
3281
  }
3146
3282
  }
3147
- fs22.rmSync(dir, { recursive: true, force: true });
3283
+ fs23.rmSync(dir, { recursive: true, force: true });
3148
3284
  }
3149
3285
  function applyCull(items) {
3150
3286
  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 });
3287
+ for (const item of items.filter((i) => i.kind === "done")) fs23.rmSync(item.target, { recursive: true, force: true });
3152
3288
  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 });
3289
+ const dir = path18.dirname(item.target);
3290
+ for (const f of fs23.readdirSync(dir)) {
3291
+ if (f.startsWith(`${item.id}.`)) fs23.rmSync(path18.join(dir, f), { force: true });
3156
3292
  }
3157
3293
  }
3158
3294
  }
@@ -3206,7 +3342,7 @@ paste into your liaison instructions:
3206
3342
  full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
3207
3343
 
3208
3344
  // apps/cli/src/doctor.ts
3209
- import * as fs23 from "node:fs";
3345
+ import * as fs24 from "node:fs";
3210
3346
  import { spawnSync as spawnSync2 } from "node:child_process";
3211
3347
  var HEARTBEAT_STALE_MS2 = 9e4;
3212
3348
  function git(repoPath, ...args) {
@@ -3229,13 +3365,13 @@ function runDoctor(now = Date.now()) {
3229
3365
  codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
3230
3366
  );
3231
3367
  const cfgFile = configPath();
3232
- if (!fs23.existsSync(cfgFile)) {
3368
+ if (!fs24.existsSync(cfgFile)) {
3233
3369
  push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
3234
3370
  return rows;
3235
3371
  }
3236
3372
  let parsed;
3237
3373
  try {
3238
- parsed = parse(fs23.readFileSync(cfgFile, "utf8"));
3374
+ parsed = parse(fs24.readFileSync(cfgFile, "utf8"));
3239
3375
  push("config", "ok", cfgFile);
3240
3376
  } catch (err) {
3241
3377
  push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
@@ -3246,7 +3382,7 @@ function runDoctor(now = Date.now()) {
3246
3382
  if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
3247
3383
  for (const [key, repo] of Object.entries(cfg.repos)) {
3248
3384
  const label = `repo ${key}`;
3249
- if (!fs23.existsSync(repo.path)) {
3385
+ if (!fs24.existsSync(repo.path)) {
3250
3386
  push(label, "fail", `path ${repo.path} does not exist`);
3251
3387
  continue;
3252
3388
  }
@@ -3274,11 +3410,11 @@ function runDoctor(now = Date.now()) {
3274
3410
  }
3275
3411
  }
3276
3412
  const hb = executorPath();
3277
- if (!fs23.existsSync(hb)) {
3413
+ if (!fs24.existsSync(hb)) {
3278
3414
  push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
3279
3415
  } else {
3280
3416
  try {
3281
- const payload = JSON.parse(fs23.readFileSync(hb, "utf8"));
3417
+ const payload = JSON.parse(fs24.readFileSync(hb, "utf8"));
3282
3418
  const age = now - new Date(payload.heartbeat ?? 0).getTime();
3283
3419
  push(
3284
3420
  "daemon",
@@ -3294,9 +3430,9 @@ function runDoctor(now = Date.now()) {
3294
3430
  }
3295
3431
 
3296
3432
  // apps/cli/src/service.ts
3297
- import * as fs24 from "node:fs";
3433
+ import * as fs25 from "node:fs";
3298
3434
  import * as os5 from "node:os";
3299
- import * as path18 from "node:path";
3435
+ import * as path19 from "node:path";
3300
3436
  import { spawnSync as spawnSync3 } from "node:child_process";
3301
3437
  function xml(s) {
3302
3438
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -3320,8 +3456,8 @@ function renderLaunchdPlist(spec) {
3320
3456
  </dict>
3321
3457
  <key>RunAtLoad</key><true/>
3322
3458
  <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>
3459
+ <key>StandardOutPath</key><string>${xml(path19.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
3460
+ <key>StandardErrorPath</key><string>${xml(path19.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
3325
3461
  </dict>
3326
3462
  </plist>
3327
3463
  `;
@@ -3336,8 +3472,8 @@ Restart=always
3336
3472
  RestartSec=5
3337
3473
  Environment=PATH=${spec.pathEnv}
3338
3474
  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`)}
3475
+ StandardOutput=append:${path19.posix.join(spec.logDir, `${spec.kind}.log`)}
3476
+ StandardError=append:${path19.posix.join(spec.logDir, `${spec.kind}.err`)}
3341
3477
 
3342
3478
  [Install]
3343
3479
  WantedBy=default.target
@@ -3345,8 +3481,8 @@ WantedBy=default.target
3345
3481
  }
3346
3482
  function servicePathEnv(nodePath, home = os5.homedir()) {
3347
3483
  const dirs = [
3348
- path18.posix.dirname(nodePath),
3349
- path18.posix.join(home, ".local", "bin"),
3484
+ path19.posix.dirname(nodePath),
3485
+ path19.posix.join(home, ".local", "bin"),
3350
3486
  "/opt/homebrew/bin",
3351
3487
  "/usr/local/bin",
3352
3488
  "/usr/bin",
@@ -3355,7 +3491,7 @@ function servicePathEnv(nodePath, home = os5.homedir()) {
3355
3491
  return [...new Set(dirs)].join(":");
3356
3492
  }
3357
3493
  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`);
3494
+ return process.platform === "darwin" ? path19.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path19.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
3359
3495
  }
3360
3496
  function run(cmd, args) {
3361
3497
  const res = spawnSync3(cmd, args, { encoding: "utf8" });
@@ -3365,28 +3501,28 @@ function installService(kind) {
3365
3501
  if (process.platform === "win32") {
3366
3502
  throw new Error(`no service manager support on Windows \u2014 run \`lobstah ${kind}\` under your process manager of choice`);
3367
3503
  }
3368
- const entry = fs24.realpathSync(process.argv[1]);
3504
+ const entry = fs25.realpathSync(process.argv[1]);
3369
3505
  const spec = {
3370
3506
  kind,
3371
3507
  nodePath: process.execPath,
3372
3508
  entry,
3373
3509
  pathEnv: servicePathEnv(process.execPath),
3374
3510
  home: lobstahHome(),
3375
- logDir: path18.join(lobstahHome(), "logs")
3511
+ logDir: path19.join(lobstahHome(), "logs")
3376
3512
  };
3377
3513
  if (!onPath("git", spec.pathEnv)) {
3378
3514
  throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
3379
3515
  }
3380
- fs24.mkdirSync(spec.logDir, { recursive: true });
3516
+ fs25.mkdirSync(spec.logDir, { recursive: true });
3381
3517
  const file = serviceFile(kind);
3382
- fs24.mkdirSync(path18.dirname(file), { recursive: true });
3518
+ fs25.mkdirSync(path19.dirname(file), { recursive: true });
3383
3519
  if (process.platform === "darwin") {
3384
- if (fs24.existsSync(file)) run("launchctl", ["unload", file]);
3385
- fs24.writeFileSync(file, renderLaunchdPlist(spec));
3520
+ if (fs25.existsSync(file)) run("launchctl", ["unload", file]);
3521
+ fs25.writeFileSync(file, renderLaunchdPlist(spec));
3386
3522
  const load = run("launchctl", ["load", file]);
3387
3523
  return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
3388
3524
  }
3389
- fs24.writeFileSync(file, renderSystemdUnit(spec));
3525
+ fs25.writeFileSync(file, renderSystemdUnit(spec));
3390
3526
  run("systemctl", ["--user", "daemon-reload"]);
3391
3527
  const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
3392
3528
  return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
@@ -3394,19 +3530,19 @@ function installService(kind) {
3394
3530
  function uninstallService(kind) {
3395
3531
  const file = serviceFile(kind);
3396
3532
  if (process.platform === "darwin") {
3397
- if (fs24.existsSync(file)) run("launchctl", ["unload", file]);
3533
+ if (fs25.existsSync(file)) run("launchctl", ["unload", file]);
3398
3534
  } else {
3399
3535
  run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
3400
3536
  }
3401
- const removed = fs24.existsSync(file);
3402
- if (removed) fs24.unlinkSync(file);
3537
+ const removed = fs25.existsSync(file);
3538
+ if (removed) fs25.unlinkSync(file);
3403
3539
  if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
3404
3540
  return { file, removed };
3405
3541
  }
3406
3542
 
3407
3543
  // apps/cli/src/repos.ts
3408
- import * as fs25 from "node:fs";
3409
- import * as path19 from "node:path";
3544
+ import * as fs26 from "node:fs";
3545
+ import * as path20 from "node:path";
3410
3546
  import { spawnSync as spawnSync4 } from "node:child_process";
3411
3547
  function git2(dir, ...args) {
3412
3548
  const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
@@ -3422,41 +3558,41 @@ var LOCKFILE_SETUP = [
3422
3558
  ["poetry.lock", ["poetry install"]]
3423
3559
  ];
3424
3560
  function detectRepo(dir) {
3425
- const abs = path19.resolve(dir);
3561
+ const abs = path20.resolve(dir);
3426
3562
  if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
3427
3563
  const top = git2(abs, "rev-parse", "--show-toplevel");
3428
3564
  const canon2 = (p) => {
3429
- const real = path19.resolve(fs25.realpathSync.native(p));
3565
+ const real = path20.resolve(fs26.realpathSync.native(p));
3430
3566
  return process.platform === "win32" ? real.toLowerCase() : real;
3431
3567
  };
3432
3568
  if (!top || canon2(top) !== canon2(abs)) return void 0;
3433
3569
  const origin = git2(abs, "remote", "get-url", "origin");
3434
3570
  const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
3435
3571
  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 };
3572
+ const setup = LOCKFILE_SETUP.find(([f]) => fs26.existsSync(path20.join(abs, f)))?.[1];
3573
+ return { key: path20.basename(abs), path: abs, trunk, origin, setup };
3438
3574
  }
3439
3575
  function scanForRepos(roots) {
3440
3576
  const found = [];
3441
3577
  for (const root of roots) {
3442
- const abs = path19.resolve(root);
3443
- if (!fs25.existsSync(abs)) continue;
3578
+ const abs = path20.resolve(root);
3579
+ if (!fs26.existsSync(abs)) continue;
3444
3580
  const self = detectRepo(abs);
3445
3581
  if (self) {
3446
3582
  found.push(self);
3447
3583
  continue;
3448
3584
  }
3449
- for (const entry of fs25.readdirSync(abs, { withFileTypes: true })) {
3585
+ for (const entry of fs26.readdirSync(abs, { withFileTypes: true })) {
3450
3586
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
3451
- const child = detectRepo(path19.join(abs, entry.name));
3587
+ const child = detectRepo(path20.join(abs, entry.name));
3452
3588
  if (child) found.push(child);
3453
3589
  }
3454
3590
  }
3455
3591
  return found;
3456
3592
  }
3457
3593
  function configuredRepoKeys(file = configPath()) {
3458
- if (!fs25.existsSync(file)) return /* @__PURE__ */ new Set();
3459
- const raw = parse(fs25.readFileSync(file, "utf8"));
3594
+ if (!fs26.existsSync(file)) return /* @__PURE__ */ new Set();
3595
+ const raw = parse(fs26.readFileSync(file, "utf8"));
3460
3596
  return new Set(Object.keys(raw.repos ?? {}));
3461
3597
  }
3462
3598
  function appendRepoBlock(repo, opts = {}, file = configPath()) {
@@ -3464,7 +3600,7 @@ function appendRepoBlock(repo, opts = {}, file = configPath()) {
3464
3600
  if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
3465
3601
  if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
3466
3602
  if (opts.pickup) lines.push(`pickup = true`);
3467
- fs25.appendFileSync(file, `${lines.join("\n")}
3603
+ fs26.appendFileSync(file, `${lines.join("\n")}
3468
3604
  `);
3469
3605
  }
3470
3606
 
@@ -3477,8 +3613,8 @@ function parseReportArgs(rest) {
3477
3613
  }
3478
3614
 
3479
3615
  // apps/cli/src/soak-site.ts
3480
- import * as fs26 from "node:fs";
3481
- import * as path20 from "node:path";
3616
+ import * as fs27 from "node:fs";
3617
+ import * as path21 from "node:path";
3482
3618
  import { spawnSync as spawnSync5 } from "node:child_process";
3483
3619
  function git3(dir, ...args) {
3484
3620
  const res = spawnSync5("git", ["-C", dir, ...args], { encoding: "utf8" });
@@ -3486,10 +3622,10 @@ function git3(dir, ...args) {
3486
3622
  }
3487
3623
  function canon(p) {
3488
3624
  try {
3489
- const real = path20.resolve(fs26.realpathSync.native(p));
3625
+ const real = path21.resolve(fs27.realpathSync.native(p));
3490
3626
  return process.platform === "win32" ? real.toLowerCase() : real;
3491
3627
  } catch {
3492
- return path20.resolve(p);
3628
+ return path21.resolve(p);
3493
3629
  }
3494
3630
  }
3495
3631
  function inspectSoakSite(cwd, repos) {
@@ -3498,16 +3634,16 @@ function inspectSoakSite(cwd, repos) {
3498
3634
  const gitDir = git3(cwd, "rev-parse", "--git-dir");
3499
3635
  const commonDir = git3(cwd, "rev-parse", "--git-common-dir");
3500
3636
  if (!gitDir || !commonDir) return void 0;
3501
- const abs = (p) => path20.isAbsolute(p) ? p : path20.join(cwd, p);
3637
+ const abs = (p) => path21.isAbsolute(p) ? p : path21.join(cwd, p);
3502
3638
  const primary = canon(abs(gitDir)) === canon(abs(commonDir));
3503
- const primaryRoot = canon(path20.dirname(abs(commonDir)));
3639
+ const primaryRoot = canon(path21.dirname(abs(commonDir)));
3504
3640
  const repoKey = Object.entries(repos).find(([, r]) => canon(r.path) === primaryRoot)?.[0];
3505
3641
  return { worktree: canon(top), primary, repoKey };
3506
3642
  }
3507
3643
  function readHookStdin() {
3508
3644
  if (process.stdin.isTTY) return void 0;
3509
3645
  try {
3510
- const raw = fs26.readFileSync(0, "utf8");
3646
+ const raw = fs27.readFileSync(0, "utf8");
3511
3647
  if (!raw.trim()) return void 0;
3512
3648
  const parsed = JSON.parse(raw);
3513
3649
  return typeof parsed === "object" && parsed !== null ? parsed : void 0;
@@ -3841,7 +3977,7 @@ function emitWatchAttention(attns) {
3841
3977
  function findLane(id) {
3842
3978
  for (const lane of ["work", "chore"]) {
3843
3979
  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`))) {
3980
+ 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
3981
  return lane;
3846
3982
  }
3847
3983
  }
@@ -3849,7 +3985,7 @@ function findLane(id) {
3849
3985
  }
3850
3986
  function rowsFor(lane, bucket) {
3851
3987
  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);
3988
+ 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
3989
  const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
3854
3990
  return sliced.map(({ f, m }) => {
3855
3991
  const id = f.replace(/\.json$/, "");
@@ -3891,7 +4027,7 @@ ${usageFor(cmd)}`);
3891
4027
  const d = {
3892
4028
  id: arg(args, "--id") ?? randomUUID4(),
3893
4029
  repo,
3894
- brief: briefText ?? fs27.readFileSync(briefFile, "utf8"),
4030
+ brief: briefText ?? fs28.readFileSync(briefFile, "utf8"),
3895
4031
  harness: arg(args, "--harness"),
3896
4032
  model: arg(args, "--model"),
3897
4033
  effort: arg(args, "--effort"),
@@ -3942,8 +4078,8 @@ ${usageFor(cmd)}`);
3942
4078
  if (!id) throw new Error("logs requires a dispatch id");
3943
4079
  const lane = findLane(id);
3944
4080
  const file = eventsPath(id, lane);
3945
- if (fs27.existsSync(file)) {
3946
- const raw = fs27.readFileSync(file, "utf8");
4081
+ if (fs28.existsSync(file)) {
4082
+ const raw = fs28.readFileSync(file, "utf8");
3947
4083
  const lines = raw.split("\n").filter((l) => l.length > 0);
3948
4084
  const LIMIT = 50;
3949
4085
  if (!args.includes("--full") && lines.length > LIMIT) {
@@ -3956,15 +4092,15 @@ ${usageFor(cmd)}`);
3956
4092
  }
3957
4093
  }
3958
4094
  if (args.includes("--follow")) {
3959
- let size = fs27.existsSync(file) ? fs27.statSync(file).size : 0;
4095
+ let size = fs28.existsSync(file) ? fs28.statSync(file).size : 0;
3960
4096
  setInterval(() => {
3961
- if (!fs27.existsSync(file)) return;
3962
- const now = fs27.statSync(file).size;
4097
+ if (!fs28.existsSync(file)) return;
4098
+ const now = fs28.statSync(file).size;
3963
4099
  if (now > size) {
3964
- const fd = fs27.openSync(file, "r");
4100
+ const fd = fs28.openSync(file, "r");
3965
4101
  const buf = Buffer.alloc(now - size);
3966
- fs27.readSync(fd, buf, 0, buf.length, size);
3967
- fs27.closeSync(fd);
4102
+ fs28.readSync(fd, buf, 0, buf.length, size);
4103
+ fs28.closeSync(fd);
3968
4104
  process.stdout.write(buf.toString("utf8"));
3969
4105
  size = now;
3970
4106
  }
@@ -4018,16 +4154,16 @@ ${usageFor(cmd)}`);
4018
4154
  );
4019
4155
  }
4020
4156
  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";
4157
+ const descriptorFile = ["active", "done"].map((b) => path22.join(dirs[b], id, "descriptor.json")).find((f) => fs28.existsSync(f));
4158
+ const harness = descriptorFile ? JSON.parse(fs28.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
4023
4159
  let sessionId;
4024
4160
  try {
4025
- sessionId = JSON.parse(fs27.readFileSync(path21.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
4161
+ sessionId = JSON.parse(fs28.readFileSync(path22.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
4026
4162
  } catch {
4027
4163
  }
4028
4164
  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();
4165
+ const worktree = path22.join(lobstahHome(), "worktrees", id);
4166
+ const cwd = fs28.existsSync(worktree) ? worktree : process.cwd();
4031
4167
  const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
4032
4168
  if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
4033
4169
  if (args.includes("--print")) {
@@ -4042,19 +4178,19 @@ ${usageFor(cmd)}`);
4042
4178
  const id = args[0];
4043
4179
  if (!id) throw new Error("swap requires a dispatch id");
4044
4180
  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"));
4181
+ const activeDir = path22.join(laneDirs(lane).active, id);
4182
+ if (!fs28.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
4183
+ const descFile = path22.join(activeDir, "descriptor.json");
4184
+ const descriptor = JSON.parse(fs28.readFileSync(descFile, "utf8"));
4049
4185
  const fromHarness = descriptor.harness ?? "default";
4050
4186
  for (const key of ["harness", "model", "effort"]) {
4051
4187
  const v = arg(args, `--${key}`);
4052
4188
  if (v) descriptor[key] = v;
4053
4189
  }
4054
- fs27.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
4055
- const worktree = path21.join(lobstahHome(), "worktrees", id);
4190
+ fs28.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
4191
+ const worktree = path22.join(lobstahHome(), "worktrees", id);
4056
4192
  let progress = "No worktree progress recorded.";
4057
- if (fs27.existsSync(worktree)) {
4193
+ if (fs28.existsSync(worktree)) {
4058
4194
  const git4 = (...a) => spawnSync6("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
4059
4195
  const commits = git4("log", "--oneline", "-15");
4060
4196
  const status = git4("status", "--short");
@@ -4064,17 +4200,17 @@ ${commits || "(none)"}
4064
4200
  Uncommitted changes:
4065
4201
  ${status || "(clean)"}`;
4066
4202
  }
4067
- fs27.writeFileSync(
4068
- path21.join(activeDir, "handoff"),
4203
+ fs28.writeFileSync(
4204
+ path22.join(activeDir, "handoff"),
4069
4205
  `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
4206
 
4071
4207
  ${progress}`
4072
4208
  );
4073
- const runnerFile = path21.join(activeDir, "runner.json");
4074
- if (fs27.existsSync(runnerFile)) {
4075
- const runner = JSON.parse(fs27.readFileSync(runnerFile, "utf8"));
4209
+ const runnerFile = path22.join(activeDir, "runner.json");
4210
+ if (fs28.existsSync(runnerFile)) {
4211
+ const runner = JSON.parse(fs28.readFileSync(runnerFile, "utf8"));
4076
4212
  if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
4077
- fs27.unlinkSync(runnerFile);
4213
+ fs28.unlinkSync(runnerFile);
4078
4214
  }
4079
4215
  appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
4080
4216
  console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
@@ -4179,20 +4315,20 @@ ${progress}`
4179
4315
  break;
4180
4316
  }
4181
4317
  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");
4318
+ const file = args.includes("--global") ? path22.join(os6.homedir(), ".claude", "settings.json") : path22.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
4183
4319
  let existing;
4184
4320
  try {
4185
- existing = JSON.parse(fs27.readFileSync(file, "utf8"));
4321
+ existing = JSON.parse(fs28.readFileSync(file, "utf8"));
4186
4322
  } catch {
4187
4323
  existing = void 0;
4188
4324
  }
4189
4325
  const { settings, changed } = mergeHaulHook(existing);
4190
4326
  if (changed) {
4191
- fs27.mkdirSync(path21.dirname(file), { recursive: true });
4192
- fs27.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
4327
+ fs28.mkdirSync(path22.dirname(file), { recursive: true });
4328
+ fs28.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
4193
4329
  `);
4194
4330
  }
4195
- if (args.includes("--marker")) fs27.writeFileSync(".lobstah-man", "");
4331
+ if (args.includes("--marker")) fs28.writeFileSync(".lobstah-man", "");
4196
4332
  console.log(
4197
4333
  toonKV({
4198
4334
  hook: "lobstah man haul",
@@ -4211,9 +4347,9 @@ ${progress}`
4211
4347
  await soakPark(soakReg.sessionId, args);
4212
4348
  break;
4213
4349
  }
4214
- if (process.env.LOBSTAH_MAN !== "1" && !fs27.existsSync(".lobstah-man")) break;
4350
+ if (process.env.LOBSTAH_MAN !== "1" && !fs28.existsSync(".lobstah-man")) break;
4215
4351
  const anyActive = ["work", "chore"].some(
4216
- (l) => fs27.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
4352
+ (l) => fs28.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
4217
4353
  );
4218
4354
  const anyWatch = listWatches().some((w) => w.owner === "man");
4219
4355
  if (!anyActive && !anyWatch) break;
@@ -4375,7 +4511,7 @@ ${progress}`
4375
4511
  path: r.path,
4376
4512
  trunk: r.trunk,
4377
4513
  pickup: r.pickup ?? false,
4378
- exists: fs27.existsSync(r.path)
4514
+ exists: fs28.existsSync(r.path)
4379
4515
  }));
4380
4516
  console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
4381
4517
  break;
@@ -4425,7 +4561,7 @@ ${progress}`
4425
4561
  break;
4426
4562
  }
4427
4563
  case "init": {
4428
- if (!fs27.existsSync(configPath())) {
4564
+ if (!fs28.existsSync(configPath())) {
4429
4565
  const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
4430
4566
  path = "~/src/example"
4431
4567
  trunk = "main"
@@ -4434,7 +4570,7 @@ trunk = "main"
4434
4570
  # pickup = true # opt into [pickup.github] multi-repo tracker pickup
4435
4571
 
4436
4572
  `;
4437
- fs27.writeFileSync(
4573
+ fs28.writeFileSync(
4438
4574
  configPath(),
4439
4575
  `# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
4440
4576
  # Top-level keys must come before any [section].
@@ -4472,12 +4608,12 @@ wallClockSecs = 3600
4472
4608
  let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
4473
4609
  try {
4474
4610
  const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
4475
- if (fs27.existsSync(local)) reference = local;
4611
+ if (fs28.existsSync(local)) reference = local;
4476
4612
  } catch {
4477
4613
  }
4478
4614
  console.log(
4479
4615
  toonKV({
4480
- home: path21.dirname(configPath()),
4616
+ home: path22.dirname(configPath()),
4481
4617
  config: configPath(),
4482
4618
  ...added.length > 0 ? { added: added.join(", ") } : {},
4483
4619
  ...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
@@ -125,6 +125,18 @@ After dispatching work, run `lobstah man wait` as a background task. When it
125
125
  completes, follow its `next:` instruction, then re-arm it.
126
126
  ```
127
127
 
128
+ **Arm it long-lived.** The default (`--timeout 0`) waits forever and returns
129
+ only when something happens — that is the right idiom, not a compromise.
130
+ Every wake costs the session a turn, so a short timeout used as a "check in
131
+ periodically" mechanism buys nothing but noise: attention is level-triggered
132
+ and stands on disk until consumed, so a wait that dies mid-shift drops zero
133
+ events — the next wait delivers everything that accumulated. There is no
134
+ report to miss either; a quiet fleet has nothing to say. Pass `--timeout`
135
+ only as a dead-man's check on the channel itself (an hour, not minutes): a
136
+ quiet timeout prints one line, exits 3, and the re-arm proves the line was
137
+ alive. Liveness reassurance for the human belongs on a status surface
138
+ (`lobstah man tend`), never in the transcript.
139
+
128
140
  **Tier 3 — park the session on a Stop hook (Claude Code only).** A Stop hook
129
141
  that blocks on `lobstah man wait`, so the session never
130
142
  really idles — it parks for free and continues the moment something needs it.
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
 
@@ -282,6 +300,19 @@ An orphan verdict requires a trustworthy mapping — see
282
300
  [Pickup state](#pickup-state): a missing table entry is `unknown` and triggers
283
301
  a rebuild, never a reset.
284
302
 
303
+ Linear reconciliation includes assigned/delegated issues in the claimed state
304
+ and terminal (`completed`/`canceled`) states, following every results page.
305
+ A failure report leaves an already closed Linear issue closed.
306
+
307
+ After an issue dispatch finalizes as `failed` and its tracker report succeeds,
308
+ pickup releases its key for another claim when the tracker offers it in the
309
+ start state. The ledger retains the last UUID and an attempt count across
310
+ restarts: one initial attempt plus `[limits].maxRestartAttempts` retries
311
+ (default two). Legacy entries count as one attempt. At the cap, the issue stays
312
+ in the start state for human attention; polling does not claim it again.
313
+ Successful dispatches and review-round keys remain deduplicated. A `done`
314
+ report alone is not a failure verdict, regardless of commit count.
315
+
285
316
  **Residual, by honesty:** same-machine reconciliation cannot detect
286
317
  whole-machine death — the reconciler dies with the laptop. The stale
287
318
  `executor.json` heartbeat covers that case, but only for a remote reader — a
@@ -339,7 +370,7 @@ Pickup's three loops and the daemon absorb that whole layer:
339
370
  | Fleet-script job | Fate |
340
371
  |---|---|
341
372
  | Poll tracker, dispatch assigned issues | Dispatch loop, issue rule |
342
- | Watch PRs for review feedback | Dispatch loop, review rule |
373
+ | Watch PRs for review feedback | Dispatch loop, feedback rule |
343
374
  | Per-dispatch outcome checks and cron re-arms | Daemon supervision + `report` |
344
375
  | Merge approved PRs | Merge loop |
345
376
  | 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.2",
4
4
  "description": "Harness-agnostic, token-efficient supervision framework for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "aequitas labs LLC",