lobstah 0.6.2 → 0.6.4

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/runner.js CHANGED
@@ -739,6 +739,18 @@ function parse(toml, { maxDepth = 1e3, integersAsBigInt } = {}) {
739
739
  }
740
740
 
741
741
  // packages/core/dist/config.js
742
+ var BRIEF_KINDS = ["conflict", "checks", "review", "ciFix", "rebase"];
743
+ function parseBriefHooks(raw) {
744
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
745
+ return void 0;
746
+ const out = {};
747
+ for (const kind of [...BRIEF_KINDS, "all"]) {
748
+ const text = raw[kind];
749
+ if (typeof text === "string" && text.trim())
750
+ out[kind] = text.trim();
751
+ }
752
+ return Object.keys(out).length ? out : void 0;
753
+ }
742
754
  var ATTENTION_KINDS = ["question", "decision", "landed", "pr:draft", "pr:review", "pr:checks", "pr:conflict", "pr:ready", "report"];
743
755
  var DEFAULT_ATTENTION_KINDS = ["question", "decision", "pr:ready", "pr:review", "pr:conflict", "pr:checks"];
744
756
  function parseAttentionKinds(raw) {
@@ -766,7 +778,7 @@ var DEFAULT_HELM = {
766
778
  armGraceSecs: 5
767
779
  };
768
780
  var DEFAULT_GLASS = { port: 4949 };
769
- var DEFAULT_WATCH = { maxForksPerCycle: 3, autoRepair: true, conflicts: true, checks: true, maxRepairsPerPr: 2, repairSettleSecs: 600, repairTrapWaitSecs: 600 };
781
+ var DEFAULT_WATCH = { maxForksPerCycle: 3, autoRepair: true, conflicts: true, checks: true, maxRepairsPerPr: 2, maxRepairsWithoutProgress: 2, repairSettleSecs: 600, repairTrapWaitSecs: 600 };
770
782
  var DEFAULT_LIMITS = {
771
783
  maxConcurrent: 2,
772
784
  choreConcurrent: 1,
@@ -809,7 +821,8 @@ function loadConfig() {
809
821
  pushEarly: r.pushEarly === void 0 ? void 0 : Boolean(r.pushEarly),
810
822
  draftPr: r.draftPr === void 0 ? void 0 : Boolean(r.draftPr),
811
823
  checkpointOnStop: r.checkpointOnStop === void 0 ? void 0 : Boolean(r.checkpointOnStop),
812
- humanGateChecks: Array.isArray(r.humanGateChecks) ? r.humanGateChecks.map(String).filter(Boolean) : void 0
824
+ humanGateChecks: Array.isArray(r.humanGateChecks) ? r.humanGateChecks.map(String).filter(Boolean) : void 0,
825
+ briefHooks: parseBriefHooks(r.briefHooks)
813
826
  };
814
827
  }
815
828
  const groundsRaw = raw.grounds ?? {};
@@ -857,6 +870,26 @@ var GB = 1024 ** 3;
857
870
  // packages/core/dist/watch.js
858
871
  import * as fs3 from "node:fs";
859
872
  import * as path3 from "node:path";
873
+
874
+ // packages/core/dist/gh-errors.js
875
+ function firstMeaningfulLine(...outputs) {
876
+ for (const out of outputs) {
877
+ for (const raw of (out ?? "").split("\n")) {
878
+ let line = raw.trim();
879
+ if (!line || /^(warning:|hint:|\(node:|\{|\})/i.test(line))
880
+ continue;
881
+ const m = /^error:\s*(.*)$/.exec(line);
882
+ if (m)
883
+ line = m[1].replace(/^"(.*)"$/, "$1").trim();
884
+ line = line.replace(/^(gh|GraphQL):\s*/i, "");
885
+ if (line)
886
+ return line;
887
+ }
888
+ }
889
+ return void 0;
890
+ }
891
+
892
+ // packages/core/dist/watch.js
860
893
  function watchesDir() {
861
894
  return path3.join(lobstahHome(), "watches");
862
895
  }
@@ -1577,6 +1610,7 @@ var COMPILED_BINARY = (() => {
1577
1610
  })();
1578
1611
 
1579
1612
  // packages/core/dist/pr.js
1613
+ import { spawnSync } from "node:child_process";
1580
1614
  function parsePrRef(s) {
1581
1615
  const m = /^(?:pr:)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)$/.exec(s.trim()) ?? /^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)(?:[/?#].*)?$/.exec(s.trim());
1582
1616
  if (!m)
@@ -1587,6 +1621,59 @@ function parsePrRef(s) {
1587
1621
  }
1588
1622
  var PR_VIEW_FIELDS = "title,state,isDraft,headRefOid,baseRefName,baseRefOid,headRefName,mergeStateStatus,reviewDecision,statusCheckRollup,mergedAt,closedAt,updatedAt,reviews";
1589
1623
  var PR_VIEW_FIELDS_NO_CHECKS = PR_VIEW_FIELDS.split(",").filter((f) => f !== "statusCheckRollup").join(",");
1624
+ var FORBIDDEN = /resource not accessible by (integration|personal access token)/i;
1625
+ function ghPrView(ref) {
1626
+ const view1 = (fields2) => spawnSync("gh", ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", fields2], {
1627
+ encoding: "utf8",
1628
+ timeout: 6e4
1629
+ });
1630
+ const reason = (r) => firstMeaningfulLine(r.stderr) ?? `gh exited ${r.status}`;
1631
+ const unknownBase = (r) => r.status !== 0 && /unknown json field.*baseRefOid/i.test(r.stderr ?? "");
1632
+ const withoutBase = (fields2) => fields2.split(",").filter((f) => f !== "baseRefOid").join(",");
1633
+ let fields = PR_VIEW_FIELDS;
1634
+ let res = view1(fields);
1635
+ if (unknownBase(res))
1636
+ res = view1(fields = withoutBase(fields));
1637
+ if (res.error)
1638
+ throw new Error(`gh: ${res.error.message}`);
1639
+ let checksError;
1640
+ if (res.status !== 0 && FORBIDDEN.test(res.stderr ?? "")) {
1641
+ checksError = reason(res);
1642
+ const retry = view1(fields === PR_VIEW_FIELDS ? PR_VIEW_FIELDS_NO_CHECKS : withoutBase(PR_VIEW_FIELDS_NO_CHECKS));
1643
+ if (retry.error)
1644
+ throw new Error(`gh: ${retry.error.message}`);
1645
+ if (retry.status !== 0) {
1646
+ throw new Error(FORBIDDEN.test(retry.stderr ?? "") ? `${reason(retry)} (fails even without check results)` : reason(retry));
1647
+ }
1648
+ res = retry;
1649
+ }
1650
+ if (res.status !== 0)
1651
+ throw new Error(reason(res));
1652
+ const view = JSON.parse(res.stdout);
1653
+ if (checksError)
1654
+ view.checksError = checksError;
1655
+ if (view.state === "OPEN") {
1656
+ const threads = ghUnresolvedThreads(ref);
1657
+ if (threads !== void 0)
1658
+ view.unresolvedThreads = threads;
1659
+ }
1660
+ return view;
1661
+ }
1662
+ var THREADS_QUERY = "query($owner:String!,$repo:String!,$n:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$n){reviewThreads(first:100){nodes{isResolved}}}}}";
1663
+ function ghUnresolvedThreads(ref) {
1664
+ const res = spawnSync("gh", ["api", "graphql", "-f", `query=${THREADS_QUERY}`, "-f", `owner=${ref.owner}`, "-f", `repo=${ref.repo}`, "-F", `n=${ref.number}`], { encoding: "utf8", timeout: 6e4 });
1665
+ if (res.error || res.status !== 0)
1666
+ return void 0;
1667
+ return parseUnresolvedThreads(res.stdout);
1668
+ }
1669
+ function parseUnresolvedThreads(stdout) {
1670
+ try {
1671
+ const nodes = JSON.parse(stdout).data?.repository?.pullRequest?.reviewThreads?.nodes;
1672
+ return Array.isArray(nodes) ? nodes.filter((t) => t.isResolved === false).length : void 0;
1673
+ } catch {
1674
+ return void 0;
1675
+ }
1676
+ }
1590
1677
 
1591
1678
  // packages/core/dist/prs.js
1592
1679
  import * as fs14 from "node:fs";
@@ -1594,6 +1681,20 @@ import * as path11 from "node:path";
1594
1681
  function prsDir() {
1595
1682
  return path11.join(lobstahHome(), "prs");
1596
1683
  }
1684
+ function prRecordFile(key) {
1685
+ const ref = parsePrRef(key);
1686
+ if (!ref)
1687
+ throw new Error(`not a PR key: ${key}`);
1688
+ const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_");
1689
+ return path11.join(prsDir(), `${safe(ref.owner)}__${safe(ref.repo)}__${ref.number}.json`);
1690
+ }
1691
+ function readPr(key) {
1692
+ try {
1693
+ return JSON.parse(fs14.readFileSync(prRecordFile(key), "utf8"));
1694
+ } catch {
1695
+ return void 0;
1696
+ }
1697
+ }
1597
1698
  function readPrs() {
1598
1699
  let files;
1599
1700
  try {
@@ -1640,11 +1741,11 @@ function chainPr(id, lane) {
1640
1741
  // packages/core/dist/pr-repair.js
1641
1742
  var PUSH_RETRIES = 3;
1642
1743
  var PUSH_REJECTED = "push rejected:";
1643
- function pushRule(branch, id = "<dispatch id>") {
1744
+ function pushRule(branch, id = "<dispatch id>", mode = "rebase") {
1644
1745
  const b = branch ?? "the PR's head branch";
1645
1746
  return [
1646
1747
  `Push only to the existing branch ${b}.`,
1647
- `If the push is rejected as non-fast-forward because ${b} moved, fetch ${b}, rebase your commits onto the moved head again, and push with \`--force-with-lease=${b}:<the head you just fetched>\`. Retry at most ${PUSH_RETRIES} times.`,
1748
+ mode === "merge" ? `If the push is rejected as non-fast-forward because ${b} moved, fetch ${b}, merge the moved head into your branch, and push again with a normal push. Retry at most ${PUSH_RETRIES} times. Never force-push.` : `If the push is rejected as non-fast-forward because ${b} moved, fetch ${b}, rebase your commits onto the moved head again, and push with \`--force-with-lease=${b}:<the head you just fetched>\`. Retry at most ${PUSH_RETRIES} times.`,
1648
1749
  "If a push hook fails with a real test or type error, do not retry the push: fix the error, commit, and push again.",
1649
1750
  `If you still cannot push, report \`lobstah report ${id} failed "${PUSH_REJECTED} <rejection text>; moved head <full sha of the head you fetched>"\` and leave the PR as it was.`,
1650
1751
  "Never push to another branch. Never open a new PR."
@@ -1655,7 +1756,7 @@ function pushRule(branch, id = "<dispatch id>") {
1655
1756
  import * as fs15 from "node:fs";
1656
1757
  import * as os3 from "node:os";
1657
1758
  import * as path12 from "node:path";
1658
- import { spawnSync } from "node:child_process";
1759
+ import { spawnSync as spawnSync2 } from "node:child_process";
1659
1760
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-([0-9a-f])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1660
1761
  function harnessFromSessionId(sessionId) {
1661
1762
  const version = sessionId ? UUID.exec(sessionId)?.[1] : void 0;
@@ -1717,7 +1818,7 @@ function isUnresumable(error) {
1717
1818
  function worktreeProgress(worktree, trunk) {
1718
1819
  if (!worktree || !fs15.existsSync(worktree))
1719
1820
  return "No worktree progress recorded.";
1720
- const git2 = (...a) => spawnSync("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
1821
+ const git2 = (...a) => spawnSync2("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
1721
1822
  const commits = trunk ? git2("log", "--oneline", `origin/${trunk}..HEAD`) : git2("log", "--oneline", "-15");
1722
1823
  const status = git2("status", "--short");
1723
1824
  return `Commits so far:
@@ -1827,7 +1928,7 @@ function isFinished(id, lane) {
1827
1928
  // packages/runner/src/run.ts
1828
1929
  import * as fs19 from "node:fs";
1829
1930
  import * as path16 from "node:path";
1830
- import { execFileSync } from "node:child_process";
1931
+ import { execFileSync as execFileSync2 } from "node:child_process";
1831
1932
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1832
1933
 
1833
1934
  // packages/adapters/dist/claude.js
@@ -2346,7 +2447,7 @@ function loadAdapter(name) {
2346
2447
  import * as fs17 from "node:fs";
2347
2448
  import * as path14 from "node:path";
2348
2449
  import { createHash } from "node:crypto";
2349
- import { exec, execFile } from "node:child_process";
2450
+ import { exec, execFile, execFileSync } from "node:child_process";
2350
2451
  import { promisify } from "node:util";
2351
2452
  var run = promisify(execFile);
2352
2453
  var shell = promisify(exec);
@@ -2372,7 +2473,12 @@ var LOCK_CONTENTION = [
2372
2473
  // held by another process ("fetching ref <ref> failed: reference already
2373
2474
  // exists"). The files backend maps the lock's EEXIST to this reason; older
2374
2475
  // git printed "Unable to create '<ref>.lock': File exists" instead.
2375
- /reference already exists/
2476
+ /reference already exists/,
2477
+ // Windows: two fetches unpack the same loose object, and one cannot write
2478
+ // the file the other holds open ("unable to write file
2479
+ // .git/objects/<xx>/<rest>: Permission denied", then "unpack-objects
2480
+ // failed"). Only an object path matches, not any denied write.
2481
+ /unable to write file \S*objects[\\/][0-9a-f]{2}[\\/][0-9a-f]{38,}: Permission denied/
2376
2482
  ];
2377
2483
  function gitStderr(err) {
2378
2484
  const stderr = err.stderr;
@@ -2399,6 +2505,112 @@ ${gitStderr(err)}`, { cause: err });
2399
2505
  }
2400
2506
  }
2401
2507
  }
2508
+ var REPO_LOCK_WAIT_MS = 5 * 6e4;
2509
+ var repoQueues = /* @__PURE__ */ new Map();
2510
+ function pidAlive(pid) {
2511
+ try {
2512
+ process.kill(pid, 0);
2513
+ return true;
2514
+ } catch (err) {
2515
+ return err.code === "EPERM";
2516
+ }
2517
+ }
2518
+ var BUSY = /* @__PURE__ */ new Set(["EPERM", "EACCES", "EBUSY"]);
2519
+ function takeRepoLock(file, mine) {
2520
+ try {
2521
+ fs17.writeFileSync(file, mine, { flag: "wx" });
2522
+ return { state: "held" };
2523
+ } catch (err) {
2524
+ const code = err.code ?? "";
2525
+ if (code !== "EEXIST" && !BUSY.has(code))
2526
+ throw err;
2527
+ }
2528
+ let raw = "";
2529
+ let age = 0;
2530
+ try {
2531
+ raw = fs17.readFileSync(file, "utf8");
2532
+ age = Date.now() - fs17.statSync(file).mtimeMs;
2533
+ } catch {
2534
+ return { state: "retry" };
2535
+ }
2536
+ let holder;
2537
+ try {
2538
+ holder = JSON.parse(raw);
2539
+ } catch {
2540
+ holder = void 0;
2541
+ }
2542
+ const stale = age > REPO_LOCK_WAIT_MS || (holder ? !pidAlive(holder.pid) : age > 5e3);
2543
+ if (stale) {
2544
+ try {
2545
+ if (fs17.readFileSync(file, "utf8") === raw)
2546
+ fs17.rmSync(file, { force: true });
2547
+ } catch {
2548
+ }
2549
+ return { state: "retry" };
2550
+ }
2551
+ return { state: "wait", ...holder ? { holder: holder.pid } : {} };
2552
+ }
2553
+ function releaseRepoLock(file, mine) {
2554
+ try {
2555
+ if (fs17.readFileSync(file, "utf8") === mine)
2556
+ fs17.rmSync(file, { force: true, recursive: true, maxRetries: 5 });
2557
+ } catch {
2558
+ }
2559
+ }
2560
+ async function withRepoLock(common, fn) {
2561
+ const file = path14.join(common, "lobstah-git.lock");
2562
+ const mine = JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() });
2563
+ const deadline = Date.now() + REPO_LOCK_WAIT_MS;
2564
+ let held = false;
2565
+ while (Date.now() < deadline) {
2566
+ const took = takeRepoLock(file, mine);
2567
+ if (took.state === "held") {
2568
+ held = true;
2569
+ break;
2570
+ }
2571
+ if (took.state === "wait")
2572
+ await new Promise((r) => setTimeout(r, 50 + Math.random() * 50));
2573
+ }
2574
+ try {
2575
+ await fn();
2576
+ } finally {
2577
+ if (held)
2578
+ releaseRepoLock(file, mine);
2579
+ }
2580
+ }
2581
+ async function oneAtATime(cwd, fn) {
2582
+ const common = await git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir");
2583
+ const key = realpath(common);
2584
+ const run2 = (repoQueues.get(key) ?? Promise.resolve()).then(() => withRepoLock(common, () => fn(common)));
2585
+ const tail = run2.catch(() => {
2586
+ });
2587
+ repoQueues.set(key, tail);
2588
+ try {
2589
+ await run2;
2590
+ } finally {
2591
+ if (repoQueues.get(key) === tail)
2592
+ repoQueues.delete(key);
2593
+ }
2594
+ }
2595
+ function fetchStamps(common) {
2596
+ try {
2597
+ return JSON.parse(fs17.readFileSync(path14.join(common, "lobstah-fetch.json"), "utf8"));
2598
+ } catch {
2599
+ return {};
2600
+ }
2601
+ }
2602
+ async function fetchShared(cwd, remote, ref) {
2603
+ const asked = Date.now();
2604
+ const key = `${remote} ${ref}`;
2605
+ await oneAtATime(cwd, async (common) => {
2606
+ if ((fetchStamps(common)[key] ?? -Infinity) >= asked)
2607
+ return;
2608
+ const started = Date.now();
2609
+ await gitShared(cwd, "fetch", remote, ref);
2610
+ const stamps = { ...fetchStamps(common), [key]: started };
2611
+ fs17.writeFileSync(path14.join(common, "lobstah-fetch.json"), JSON.stringify(stamps, null, 2));
2612
+ });
2613
+ }
2402
2614
  async function allocate(repo, id, fromRemoteBranch = repo.trunk) {
2403
2615
  const dir = worktreePath(id);
2404
2616
  if (fs17.existsSync(dir)) {
@@ -2409,8 +2621,10 @@ async function allocate(repo, id, fromRemoteBranch = repo.trunk) {
2409
2621
  throw new Error(`repo path ${repo.path} missing and no origin configured`);
2410
2622
  await run("git", ["clone", repo.origin, repo.path], { env: process.env });
2411
2623
  }
2412
- await gitShared(repo.path, "fetch", "origin", fromRemoteBranch);
2413
- await gitShared(repo.path, "worktree", "add", "--no-track", dir, "-b", `lobstah/${id}`, `origin/${fromRemoteBranch}`);
2624
+ await fetchShared(repo.path, "origin", fromRemoteBranch);
2625
+ await oneAtATime(repo.path, async () => {
2626
+ await gitShared(repo.path, "worktree", "add", "--no-track", dir, "-b", `lobstah/${id}`, `origin/${fromRemoteBranch}`);
2627
+ });
2414
2628
  await runSetup(repo, dir);
2415
2629
  return dir;
2416
2630
  }
@@ -2544,7 +2758,7 @@ async function chooseWorktree(input) {
2544
2758
  return { reuse: true, path: found.path, owner: found.owner, from: found.from };
2545
2759
  }
2546
2760
  async function prepareReuse(repo, dir) {
2547
- await gitShared(dir, "fetch", "origin", repo.trunk);
2761
+ await fetchShared(dir, "origin", repo.trunk);
2548
2762
  if (!repo.setup?.length)
2549
2763
  return { setupRan: false };
2550
2764
  if (recordedSetupHash(dir) === setupHash(repo, dir))
@@ -2874,6 +3088,7 @@ function planStart(input) {
2874
3088
  function startWallClock(opts) {
2875
3089
  const now2 = opts.now ?? Date.now;
2876
3090
  const tickMs = Math.max(1, Math.min(opts.tickMs ?? 5e3, opts.limitMs));
3091
+ const sleepGapMs = opts.sleepGapMs ?? Math.max(6e4, tickMs * 6);
2877
3092
  let elapsed = opts.initialElapsedMs ?? 0;
2878
3093
  const maxMs = Math.max(opts.limitMs, opts.maxMs ?? opts.limitMs);
2879
3094
  let windowMs = Math.min(maxMs, Math.max(opts.limitMs, opts.initialWindowMs ?? opts.limitMs));
@@ -2887,7 +3102,8 @@ function startWallClock(opts) {
2887
3102
  } catch {
2888
3103
  paused = false;
2889
3104
  }
2890
- if (!paused) elapsed += t - last;
3105
+ const step = Math.max(0, t - last);
3106
+ if (!paused) elapsed += step > sleepGapMs ? Math.min(step, tickMs) : step;
2891
3107
  last = t;
2892
3108
  if (!done && elapsed >= windowMs && elapsed < maxMs) {
2893
3109
  let advanced = false;
@@ -3094,13 +3310,23 @@ function keepRemote(opts) {
3094
3310
  }
3095
3311
 
3096
3312
  // packages/runner/src/run.ts
3313
+ function livePrState(url) {
3314
+ const ref = parsePrRef(url);
3315
+ if (!ref) return void 0;
3316
+ try {
3317
+ return ghPrView(ref).state;
3318
+ } catch {
3319
+ return void 0;
3320
+ }
3321
+ }
3097
3322
  var defaultDeps = {
3098
3323
  loadAdapter,
3099
3324
  allocate,
3100
3325
  chooseWorktree,
3101
3326
  prepareReuse,
3102
3327
  collectEvidence,
3103
- reap: async () => 0
3328
+ reap: async () => 0,
3329
+ prState: livePrState
3104
3330
  };
3105
3331
  var DONE_WAIT_MS = 2e3;
3106
3332
  function within(p, ms, fallback) {
@@ -3141,6 +3367,14 @@ async function main(activeDir, lane, seams = {}) {
3141
3367
  attempts
3142
3368
  };
3143
3369
  fs19.writeFileSync(path16.join(activeDir, "runner.json"), JSON.stringify(runnerInfo, null, 2));
3370
+ if (descriptor.systemRepair && descriptor.pr?.url) {
3371
+ const state = deps.prState(descriptor.pr.url) ?? readPr(parsePrRef(descriptor.pr.url)?.key ?? "")?.state;
3372
+ if (state === "MERGED" || state === "CLOSED") {
3373
+ status("failed", `cancelled: ${descriptor.pr.url} is ${state.toLowerCase()}; nothing to repair`);
3374
+ complete(id, lane);
3375
+ return;
3376
+ }
3377
+ }
3144
3378
  const plan = planStart({
3145
3379
  id,
3146
3380
  lane,
@@ -3240,7 +3474,7 @@ async function main(activeDir, lane, seams = {}) {
3240
3474
  })();
3241
3475
  const head = () => {
3242
3476
  try {
3243
- return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3477
+ return execFileSync2("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3244
3478
  } catch {
3245
3479
  return void 0;
3246
3480
  }
@@ -3412,10 +3646,10 @@ async function main(activeDir, lane, seams = {}) {
3412
3646
  }
3413
3647
 
3414
3648
  // packages/runner/src/reap.ts
3415
- import { execFileSync as execFileSync2 } from "node:child_process";
3649
+ import { execFileSync as execFileSync3 } from "node:child_process";
3416
3650
  function listProcesses() {
3417
3651
  if (process.platform === "win32") {
3418
- const out2 = execFileSync2(
3652
+ const out2 = execFileSync3(
3419
3653
  "powershell.exe",
3420
3654
  [
3421
3655
  "-NoProfile",
@@ -3427,7 +3661,7 @@ function listProcesses() {
3427
3661
  );
3428
3662
  return out2.split(/\r?\n/).map((l) => l.trim().split(/\s+/).map(Number)).filter(([pid, ppid]) => Number.isInteger(pid) && Number.isInteger(ppid)).map(([pid, ppid]) => ({ pid, ppid }));
3429
3663
  }
3430
- const out = execFileSync2("ps", ["-A", "-o", "pid=,ppid=,pgid="], {
3664
+ const out = execFileSync3("ps", ["-A", "-o", "pid=,ppid=,pgid="], {
3431
3665
  encoding: "utf8",
3432
3666
  timeout: 3e4,
3433
3667
  stdio: ["ignore", "pipe", "ignore"]
@@ -3470,7 +3704,7 @@ async function stopPids(pids, graceMs = 2e3) {
3470
3704
  if (process.platform === "win32") {
3471
3705
  for (const pid of pids) {
3472
3706
  try {
3473
- execFileSync2("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: 3e4 });
3707
+ execFileSync3("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: 3e4 });
3474
3708
  } catch {
3475
3709
  }
3476
3710
  }
@@ -42,6 +42,25 @@ rather than relying on the default — left unset, a Claude session takes
42
42
  `effortLevel` from the host's settings files, including the user's own
43
43
  `~/.claude/settings.json`.
44
44
 
45
+ `[repos.<key>.briefHooks]` — markdown appended to the briefs lobstah
46
+ writes for this repo, one key per kind. Each value is a string; lobstah
47
+ appends it as written and does not read it.
48
+
49
+ | Key | Appended to |
50
+ |---|---|
51
+ | `conflict` | A daemon repair of a PR with a merge conflict. |
52
+ | `checks` | A daemon repair of a PR with failing checks. |
53
+ | `review` | A daemon repair of a PR with requested changes, and a pickup review-feedback round. |
54
+ | `ciFix` | A CI-fix continuation a PR watch forks. |
55
+ | `rebase` | A rebase chore from pickup's merge loop. |
56
+ | `all` | Every kind above, after that kind's own text. |
57
+
58
+ ```toml
59
+ [repos.web.briefHooks]
60
+ all = "Before you report done, run the repo's PR refresh (`/pr-refresh`)."
61
+ checks = "Run `pnpm test:ci` before you push."
62
+ ```
63
+
45
64
  `lobstah repos add <path> [--pickup]` detects and appends a block (origin,
46
65
  default branch from `origin/HEAD`, setup from the lockfile); `lobstah init
47
66
  --scan <dir>...` does the same for every git repo found under the given
@@ -113,7 +132,8 @@ send a continuation.
113
132
  | `conflicts` | `true` | Repair conflicts when `autoRepair` is on. Set `false` to show conflict attention without a repair. |
114
133
  | `checks` | `true` | Repair failed current checks when `autoRepair` is on. Set `false` to show check attention without a repair. |
115
134
  | `maxRepairsPerPr` | `2` | Maximum repair follow-ups for one PR head SHA. When the limit is reached and the issue remains, `pr:conflict`, `pr:checks`, or `pr:review` attention names the limit. A repair that waits is not an attempt. Below this limit, each failing check gets at most one repair round per PR and head SHA: a check that had its round and still fails on the same head waits for a new commit, with `repair.status: waiting` and `heldBy: repaired`, and raises `pr:checks` attention. A CI-fix continuation from `lobstah pick` (`autoRepair = false`) follows the same rule. Human gates (`[repos.<key>].humanGateChecks`, `report --human-gate`) get no round. |
116
- | `repairSettleSecs` | `600` | A repair is queued only after the PR's head, its base branch's head, and its failing checks have been unchanged for this many seconds. Until then the PR record shows `repair.status: waiting` with `heldBy: settle` and `until`. |
135
+ | `repairSettleSecs` | `600` | A repair is queued only after the PR's head, its base branch's head, and its failing checks have been unchanged for this many seconds. A conflict repair also waits this many seconds after the PR's latest approval. Until then the PR record shows `repair.status: waiting` with `heldBy: settle` and `until`. |
136
+ | `maxRepairsWithoutProgress` | `2` | Maximum repairs in a row on one PR that end `done` while the PR stays at the head the repair pushed and does not merge. Past the cap, lobstah stops repairing that PR: the PR record shows `repair.status: gave-up`, `pr:conflict` (or `pr:checks`, `pr:review`) attention names the cap, and one `repair-stopped` notice is posted. The count resets when the PR merges or closes, when a push that is not the repair's moves the head, and on `lobstah watch release <key>` (or `--all`). |
117
137
  | `repairTrapWaitSecs` | `600` | A daemon repair chore addressed to the PR-owning trap waits this many seconds for that trap. If still queued, it becomes headless. A person's addressed work never falls back. |
118
138
 
119
139
  ## `[grounds.*]` — helm territories
package/docs/man.md CHANGED
@@ -192,20 +192,47 @@ with `paused --waiting-on pr` or `--waiting-on review` merges, the daemon
192
192
  finishes that dispatch `done` with the note `the PR merged: <url>`. When
193
193
  the PR closes without merge, it finishes it `failed` with `the PR closed
194
194
  without merge: <url>`. The PR waited on is the `--link` when it names a
195
- GitHub PR, else the dispatch's own PR, else its chain's PR. Every paused
196
- dispatch waiting on that PR is finished, in the whole chain. The daemon
197
- does this after it observes PR watches and before its cull pass, so
198
- `[limits].releaseOnMerge` releases the chain's worktrees in the same pass.
199
- `report paused --waiting-on pr|review` registers the watch of the
200
- dispatch's own PR when it has none, so the merge is observed.
195
+ GitHub PR that is not one of the dispatch's own. Otherwise the dispatch
196
+ waits on all of its own PRs (its evidence, which `lobstah catch` shows),
197
+ else its chain's PR, else the PRs of the `pr:` watches it owns. A
198
+ dispatch with several PRs finishes only when every one of them has merged
199
+ or closed: `done` when at least one merged (`the PRs merged: <urls>`, or
200
+ `the PRs ended — merged: <urls>; closed without merge: <urls>`), `failed`
201
+ when all closed without merge. Every paused dispatch waiting on those PRs
202
+ is finished, in the whole chain. The daemon does this after it observes PR watches and before its
203
+ cull pass, so `[limits].releaseOnMerge` releases the chain's worktrees in
204
+ the same pass. `report paused --waiting-on pr|review` registers the watch
205
+ of the dispatch's own PR when it has none. The daemon also reads, at
206
+ `[pickup].pollSecs`, each waited-on PR that no live dispatch-owned watch
207
+ observes (a linked PR with no watch, or a helm-owned watch), so its merge
208
+ or close is seen without `lobstah pick`. When lobstah knows no PR for the
209
+ wait, the report prints a `warning` and a merge will not finish the
210
+ dispatch: report again with `--link <PR url>`.
201
211
 
202
212
  ### PR state after done
203
213
 
204
214
  A dispatch reports its PR with `report <id> <verb> --pr <url>`, which
205
215
  registers a `pr:` watch for the chain so the PR stays observed (see the
206
216
  PR preset in [vocabulary.md](vocabulary.md#the-pr-preset); `--no-watch`
207
- opts out). A trap's PR is tracked from its first push. What you get
208
- depends on what runs:
217
+ opts out). A trap's PR is tracked from its first push.
218
+
219
+ A dispatch can own several PRs. `--pr` is repeatable: each PR is recorded
220
+ and gets its own `pr:` watch. The first is the dispatch's `prUrl`; all of
221
+ them are its `prUrls`. When a reported PR is part of a stack, the other PRs
222
+ of the stack are recorded too:
223
+
224
+ - a gh stack: the `gh-stack` state in the dispatch's checkout (from
225
+ `gh stack init` or `gh stack submit`) lists a stack that holds the PR;
226
+ - a base chain: the dispatch pushed two or more branches, and
227
+ `gh pr view <branch>` links their PRs to the reported one by base
228
+ branch.
229
+
230
+ The report prints `prs` with every PR it recorded. `lobstah catch` and
231
+ `lobstah status <id>` list each PR (`prs`), `man tend` lists each with its
232
+ state in the story's `pr` column, and the glass links each by number. Each
233
+ PR has its own record, so `pr:*` attention and the merged/closed notice are
234
+ per PR. `[limits].releaseOnMerge` keeps the worktree while another PR of
235
+ the dispatch is open. What you get depends on what runs:
209
236
 
210
237
  - **Only the helm park or `man wait`** (no service): PR state badges in
211
238
  `man tend`, `lobstah catch`, and the glass (`merged`, `draft`, `review`,
@@ -223,16 +250,21 @@ succeeds and the watch's check records `lastError`.
223
250
 
224
251
  Which commands register a watch. Only these write points register one:
225
252
 
226
- - `lobstah report <id> <verb> --pr <url>` registers the watch for the PR the
227
- worker opened. Any verb but `failed` does this. A PR already watched is
228
- not registered again.
253
+ - `lobstah report <id> <verb> --pr <url> [--pr <url> ...]` registers the
254
+ watch for each PR the worker opened, and for the other PRs of a stack
255
+ that holds one of them. Any verb but `failed` does this. A PR already
256
+ watched is not registered again.
229
257
  - `lobstah report <id> paused --waiting-on pr|review` registers the watch
230
- for the dispatch's own PR. A `--link` to another PR registers nothing.
258
+ for the dispatch's own PR. A `--link` to another PR registers nothing; the
259
+ daemon reads that PR while the dispatch waits on it.
231
260
  - `lobstah soak beat` registers the watch for a trap's PR from its first
232
261
  push. At most once a minute, it reads the trap's branch. When the branch
233
- is not trunk and has an upstream, it asks `gh pr view <branch>` for the PR.
234
- It records a new PR in the dispatch's evidence, where `lobstah catch` and
235
- `lobstah status <id>` show it.
262
+ is not trunk, has an upstream, and its head commit is newer than the
263
+ dispatch's claim, it asks `gh pr view <branch>` for the PR. A trap that
264
+ takes new work on the branch of its last dispatch does not give that PR
265
+ to the new dispatch until the new dispatch commits. It records a new PR in
266
+ the dispatch's evidence, where `lobstah catch` and `lobstah status <id>`
267
+ show it.
236
268
  - `lobstah watch add <key>` registers the watch you name.
237
269
  - `lobstah watch backfill --apply` registers watches for PRs in old dispatch
238
270
  history, and fetches the title of each PR record that has none (one
@@ -304,6 +336,8 @@ and the glass show the repair's PR, chore lane, worker, and trap wait.
304
336
  PR whose head branch is its base branch.
305
337
  - The PR's head, its base branch's head, or its failing checks changed less
306
338
  than `[watch].repairSettleSecs` ago (default 600).
339
+ - For a conflict repair: the PR was approved less than
340
+ `[watch].repairSettleSecs` ago.
307
341
  - For a checks repair: a fresh read of the latest run of each failing check
308
342
  shows that run in progress or passed.
309
343
  - The PR's watch is held. `lobstah cancel` on a repair dispatch holds its
@@ -322,13 +356,29 @@ in the `repairs waiting` table, the PR badge ends in `repair waits: <heldBy>`,
322
356
  the glass PR modal shows the reason, and `lobstah doctor` lists it. When the
323
357
  wait ends, the normal rules apply again.
324
358
 
359
+ **Repairs stop** on a PR after `[watch].maxRepairsWithoutProgress`
360
+ (default 2) repairs in a row that each ended `done` while the PR stayed at
361
+ the head the repair pushed and did not merge. lobstah then queues no repair
362
+ for that PR, marks its repair `gave-up`, raises `pr:conflict` (or
363
+ `pr:checks`, `pr:review`) attention that names the cap, and posts one
364
+ `repair-stopped` notice. The count resets when the PR merges or closes,
365
+ when a push that is not the repair's moves the head, and on
366
+ `lobstah watch release <key>`, which prints `repairsResumed` with the PRs it
367
+ resumed.
368
+
325
369
  **A repair pushes** to its PR's head branch, and so does a rebase chore
326
370
  from pickup's merge loop. The descriptor of each names its PR (`pr`), so
327
371
  the runner pushes no branch and opens no PR for it. The brief gives the
328
- worker the push rule: push only to the PR's head branch. On a
329
- non-fast-forward rejection, fetch the branch, rebase the commits onto the
330
- moved head again, and push with `--force-with-lease` on the head just
331
- fetched, at most three times. A push hook that fails with a real test or
372
+ worker the push rule: push only to the PR's head branch. A conflict
373
+ repair of a **standalone** PR (its base is the repo's `trunk`) merges the
374
+ base into the PR branch with a merge commit and pushes normally; on a
375
+ non-fast-forward rejection it fetches the moved head, merges it, and
376
+ pushes again, never with force. A conflict repair of a **stacked** PR (its
377
+ base is another PR's branch) rebases onto that base and pushes with
378
+ `--force-with-lease`; on a rejection it rebases onto the moved head again
379
+ and pushes with a lease on the head just fetched. A checks or review
380
+ repair follows the rebase rule on a rejection. Each retries at most three
381
+ times. A push hook that fails with a real test or
332
382
  type error is not retried: the worker fixes the error and pushes again.
333
383
  For code already on main, the repair keeps main's version and only this
334
384
  PR's own changes. It does not change behavior. If a conflict resolution
@@ -509,7 +559,9 @@ the same rule.
509
559
  `attachments/`. The claimed helm alone may ask.
510
560
  - **Standing.** A decision stands until it is answered or the helm withdraws
511
561
  it with `lobstah man ask --withdraw <key>`, which removes its directory. A
512
- newer `man ask` on the same dispatch replaces the older decision.
562
+ newer `man ask` on the same dispatch keeps the older decisions: each shows
563
+ as its own card. `lobstah man ask ... --replace <key>` replaces that one
564
+ standing decision; a key that is not standing is refused.
513
565
  - **Attention.** A standing decision is attention of kind `decision`, in
514
566
  the default `attentionKinds`. It makes the verdict `needs-attention`. A
515
567
  worker's raw `needs-decision` or `blocked` stays a `question` until the