lobstah 0.1.0 β†’ 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +49 -36
  2. package/dist/main.js +355 -77
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,32 +1,41 @@
1
- # lobstah
1
+ # lobstah 🦞✨
2
+
3
+ *Nobody stares at the water.*
2
4
 
3
5
  [![CI](https://github.com/aequitas-labs/lobstah/actions/workflows/ci.yml/badge.svg)](https://github.com/aequitas-labs/lobstah/actions/workflows/ci.yml)
4
6
  ![License](https://img.shields.io/badge/license-MIT-green)
5
7
  ![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)
6
8
  ![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue)
9
+ [![npm](https://img.shields.io/npm/v/lobstah)](https://www.npmjs.com/package/lobstah)
7
10
  ![Harnesses](https://img.shields.io/badge/harnesses-Claude%20Code%20%7C%20Codex-8A2BE2)
8
11
 
9
- Harness-agnostic, token-efficient supervisor for local coding agents.
12
+ Harness-agnostic, token-efficient supervision framework for coding agents.
10
13
 
11
- One coding agent in a terminal is fine. Three is tab-juggling: which one is
12
- stuck, which one is waiting on a question nobody saw, which one died forty
13
- minutes ago? And the cloud sandboxes can't help when the work needs your
14
- actual machine β€” private toolchains, local services, unpushed branches,
15
- credentials that never leave the laptop.
14
+ You were promised agents that do the work. Somehow, watching them became the
15
+ work: which one is stuck, which one is waiting on an answer nobody saw, which
16
+ one died forty minutes ago. Watching is why you run two agents instead of
17
+ ten.
16
18
 
17
- Lobstah runs coding agents like services instead of terminal tabs. Each
18
- dispatch gets an isolated git worktree; a daemon tells a dead process from a
19
- wedged one and treats them differently (auto-restart vs. a bounded
20
- fork-with-nudge ladder); status and evidence land on disk. Supervision spends
21
- zero tokens β€” no model watches the models.
19
+ A trap doesn't fish faster for being watched, and an agent doesn't code
20
+ faster either. You **lob** bait into a trap and walk away. Each dispatch runs
21
+ in its own git worktree; the daemon tells a dead process from a wedged one
22
+ and recovers each; status and evidence land on disk. Supervision costs
23
+ nothing β€” no tokens, no attention β€” so the whole fleet fits in one
24
+ conversation, and **your agents just bring home the lobstahs**.
22
25
 
23
- ## Requirements
26
+ ## Requirements πŸ“‹
24
27
 
25
28
  - Node 20+, git, pnpm
26
29
  - An authenticated harness CLI: `claude` (Claude Code) and/or Codex. Lobstah
27
30
  never handles harness login β€” you authenticate your own CLI; lobstah invokes it.
28
31
 
29
- ## Install
32
+ ## Install βš“
33
+
34
+ ```bash
35
+ npm i -g lobstah
36
+ ```
37
+
38
+ Or from source:
30
39
 
31
40
  ```bash
32
41
  git clone https://github.com/aequitas-labs/lobstah
@@ -35,7 +44,7 @@ pnpm install && pnpm build
35
44
  ln -s "$PWD/bin/lobstah" /usr/local/bin/lobstah # Windows: add .\bin to PATH
36
45
  ```
37
46
 
38
- ## Quick start
47
+ ## Quick start πŸͺ
39
48
 
40
49
  ```bash
41
50
  lobstah init # ~/.lobstah + example config
@@ -65,29 +74,31 @@ lobstah swap <uuid> --harness codex # hand an active dispatch to a fresh sessi
65
74
  lobstah catch <uuid> # the evidence: branch, commits, PR, session
66
75
  lobstah cancel <uuid>
67
76
  lobstah cull --apply # sweep aged results and orphaned worktrees
77
+ lobstah man tend # the whole string: fleet verdict, waiting
78
+ # questions, each item's chain + PR + gate
68
79
  ```
69
80
 
70
- (Prefer the water? `set --bait`, `buoys`, and `buoy` alias `dispatch`, `ls`,
71
- and `status`.)
81
+ Prefer the water? 🌊 `set --bait`, `buoys`, and `buoy` alias `dispatch`,
82
+ `ls`, and `status`.
72
83
 
73
- Dispatches report six verbs β€” `working`, `needs-decision`, `blocked`,
74
- `paused`, `done`, `failed` β€” validated at the write path. `done` means the
75
- brief is fulfilled; merging is never the dispatch's job.
84
+ Dispatches report six verbs: `working`, `needs-decision`, `blocked`,
85
+ `paused`, `done`, `failed`. The write path rejects anything else. `done`
86
+ means the brief is fulfilled. Merging is never the dispatch's job.
76
87
 
77
- Everything lives under `$LOBSTAH_HOME` (default `~/.lobstah`); multiple
78
- isolated instances are just multiple homes, one daemon per home (enforced).
88
+ Everything lives under `$LOBSTAH_HOME` (default `~/.lobstah`). To run more
89
+ instances, use more homes. One daemon per home, enforced.
79
90
 
80
- ## When something needs you
91
+ ## When something needs you πŸ›ŽοΈ
81
92
 
82
- Set one config line and the daemon pings you on `needs-decision`, `blocked`,
83
- `done`, and `failed` β€” `notifyCommand = "ntfy pub my-topic ..."` β€” no model in
84
- the loop. Driving lobstah from an agent session instead? `lobstah man` prints
85
- the lobsterman's manual: `man wait` blocks until a dispatch needs attention
86
- (unanswered questions re-fire until answered), and `man init` installs a
87
- Claude Code Stop hook that parks the session on the fleet and continues the
88
- turn the moment something does.
93
+ Set one config line β€” `notifyCommand = "ntfy pub my-topic ..."` β€” and the
94
+ daemon pings you on `needs-decision`, `blocked`, `done`, and `failed`. No
95
+ model in the loop. Driving lobstah from an agent session instead? `lobstah
96
+ man` prints the lobsterman's manual. `man wait` blocks until a dispatch needs
97
+ attention, and unanswered questions re-fire until answered. `man init`
98
+ installs a Claude Code Stop hook that parks the session on the fleet and
99
+ continues the turn the moment something needs it.
89
100
 
90
- ## Add-ons
101
+ ## Add-ons 🎣
91
102
 
92
103
  - **Tracker pickup** β€” `lobstah pick` polls Linear and GitHub outbound (no
93
104
  webhooks, no tunnel), dispatches assigned work, streams status back as
@@ -100,7 +111,7 @@ turn the moment something does.
100
111
  supervises, and hands you outcomes, woken by the `lobstah man` commands.
101
112
  [docs/lobsterman.md](https://github.com/aequitas-labs/lobstah/blob/main/docs/lobsterman.md)
102
113
 
103
- ## How it holds together
114
+ ## How it holds together 🧭
104
115
 
105
116
  ```
106
117
  CLI / pick / OpenClaw plugin / anything ──writes──> ~/.lobstah/queue/
@@ -111,10 +122,12 @@ daemon ──spawn──> runner (one per dispatch) ──SDK──> harness CLI
111
122
  └── dead/wedged classification Β· bounded restart ladder Β· zero tokens
112
123
  ```
113
124
 
114
- Design: [docs/design.md](https://github.com/aequitas-labs/lobstah/blob/main/docs/design.md).
115
- Verified by CI on Linux and Windows, live end-to-end runs on both harnesses,
116
- and fault-injection drills (SIGKILL'd runners respawn and complete; SIGSTOP'd
117
- ones are classified wedged, killed cleanly, and recover with a nudge).
125
+ Design: [docs/design.md](https://github.com/aequitas-labs/lobstah/blob/main/docs/design.md). Every verb, verdict, and gate in
126
+ one place: [docs/vocabulary.md](https://github.com/aequitas-labs/lobstah/blob/main/docs/vocabulary.md).
127
+ Verified by CI on Linux and Windows, by live end-to-end runs on both
128
+ harnesses, and by fault-injection drills. A SIGKILL'd runner respawns and
129
+ completes. A SIGSTOP'd one is classified wedged, killed cleanly, and recovers
130
+ with a nudge.
118
131
 
119
132
  ## Non-goals
120
133
 
package/dist/main.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // apps/cli/src/main.ts
4
- import * as fs16 from "node:fs";
5
- import * as path12 from "node:path";
4
+ import * as fs18 from "node:fs";
5
+ import * as path14 from "node:path";
6
6
  import { randomUUID as randomUUID3 } from "node:crypto";
7
7
  import { spawnSync } from "node:child_process";
8
8
 
@@ -1152,9 +1152,17 @@ function reconcileOne(st, cfg, log) {
1152
1152
  const statusLog = readStatusLog(st.id, st.lane);
1153
1153
  const lastVerb = statusLog.at(-1)?.verb;
1154
1154
  const alive = st.runner ? pidAlive(st.runner.pid, st.runner.processStartTime) : void 0;
1155
- if (cancelRequested(st.id, st.lane) && st.runner && alive) {
1156
- log(`${st.id}: cancel requested, killing group ${st.runner.pid}`);
1157
- killGroup(st.runner.pid);
1155
+ if (cancelRequested(st.id, st.lane)) {
1156
+ if (st.runner && alive) {
1157
+ log(`${st.id}: cancel requested, killing group ${st.runner.pid}`);
1158
+ killGroup(st.runner.pid);
1159
+ return;
1160
+ }
1161
+ if (lastVerb !== "done" && lastVerb !== "failed") {
1162
+ appendStatus(st.id, st.lane, "failed", "cancelled by request; work preserved");
1163
+ }
1164
+ finalize(st);
1165
+ log(`${st.id}: cancelled, finalized`);
1158
1166
  return;
1159
1167
  }
1160
1168
  const cls = classify({
@@ -1396,9 +1404,15 @@ function loadPickupConfig() {
1396
1404
  if (ln) {
1397
1405
  const token = resolveTokenSource({ tokenEnv: "LINEAR_TOKEN", ...ln }, "pickup.linear");
1398
1406
  token();
1407
+ const assignField = ln.assignField ? String(ln.assignField) : "assignee";
1408
+ if (assignField !== "assignee" && assignField !== "delegate") {
1409
+ throw new Error(`pickup.linear.assignField must be "assignee" or "delegate", got "${assignField}"`);
1410
+ }
1399
1411
  out.linear = {
1400
1412
  token,
1413
+ assignField,
1401
1414
  startState: String(ln.startState ?? "Todo"),
1415
+ startStateTypes: Array.isArray(ln.startStateTypes) ? ln.startStateTypes.map(String) : void 0,
1402
1416
  claimedState: String(ln.claimedState ?? "In Progress"),
1403
1417
  doneState: String(ln.doneState ?? "In Review"),
1404
1418
  route: ln.route ?? {}
@@ -1624,6 +1638,15 @@ When the change is complete, commit it, push the branch, and open a PR against $
1624
1638
  const pr = await this.api("GET", `/repos/${this.cfg.repo}/pulls/${n}`);
1625
1639
  return pr.state === "open" ? this.toCandidate(pr) : void 0;
1626
1640
  }
1641
+ async disposition(n) {
1642
+ const pr = await this.api("GET", `/repos/${this.cfg.repo}/pulls/${n}`);
1643
+ if (pr.state === "open")
1644
+ return "open";
1645
+ return pr.merged_at ? "merged" : "closed";
1646
+ }
1647
+ forgeRepo() {
1648
+ return this.cfg.repo;
1649
+ }
1627
1650
  async updateBranch(n) {
1628
1651
  await this.api("PUT", `/repos/${this.cfg.repo}/pulls/${n}/update-branch`, {});
1629
1652
  }
@@ -1669,12 +1692,14 @@ var LinearSource = class {
1669
1692
  }
1670
1693
  return json.data;
1671
1694
  }
1672
- async assigned(stateName) {
1673
- const data = await this.gql(`query($state: String!) {
1674
- issues(filter: { assignee: { isMe: { eq: true } }, state: { name: { eq: $state } } }, first: 50) {
1675
- nodes { id identifier title description state { name } team { key id } }
1695
+ async assigned(stateName, byTypes) {
1696
+ const field = this.cfg.assignField ?? "assignee";
1697
+ const stateFilter = byTypes?.length ? { type: { in: byTypes } } : { name: { eq: stateName } };
1698
+ const data = await this.gql(`query($filter: IssueFilter!) {
1699
+ issues(filter: $filter, first: 50) {
1700
+ nodes { id identifier title description state { name type } team { key id } }
1676
1701
  }
1677
- }`, { state: stateName });
1702
+ }`, { filter: { [field]: { isMe: { eq: true } }, state: stateFilter } });
1678
1703
  return data.issues.nodes;
1679
1704
  }
1680
1705
  async stateId(teamId, name) {
@@ -1692,7 +1717,7 @@ var LinearSource = class {
1692
1717
  }
1693
1718
  async issueByKey(key) {
1694
1719
  const identifier = key.replace(/^linear:/, "");
1695
- const data = await this.gql(`query($id: String!) { issue(id: $id) { id identifier title description state { name } team { key id } } }`, { id: identifier });
1720
+ const data = await this.gql(`query($id: String!) { issue(id: $id) { id identifier title description state { name type } team { key id } } }`, { id: identifier });
1696
1721
  return data.issue;
1697
1722
  }
1698
1723
  async moveTo(issue, stateName) {
@@ -1703,7 +1728,7 @@ var LinearSource = class {
1703
1728
  await this.gql(`mutation($id: String!, $body: String!) { commentCreate(input: { issueId: $id, body: $body }) { success } }`, { id: issue.id, body });
1704
1729
  }
1705
1730
  async poll() {
1706
- const issues = await this.assigned(this.cfg.startState);
1731
+ const issues = await this.assigned(this.cfg.startState, this.cfg.startStateTypes);
1707
1732
  return issues.flatMap((issue) => {
1708
1733
  const repoKey = this.cfg.route[issue.team.key];
1709
1734
  if (!repoKey)
@@ -1725,7 +1750,8 @@ When the change is complete, commit it, push the branch, and open a PR whose des
1725
1750
  }
1726
1751
  async claim(item) {
1727
1752
  const issue = await this.issueByKey(item.key);
1728
- if (issue.state.name !== this.cfg.startState)
1753
+ const stillWaiting = this.cfg.startStateTypes?.length ? this.cfg.startStateTypes.includes(issue.state.type) : issue.state.name === this.cfg.startState;
1754
+ if (!stillWaiting)
1729
1755
  return false;
1730
1756
  await this.moveTo(issue, this.cfg.claimedState);
1731
1757
  return true;
@@ -1887,8 +1913,57 @@ async function reconcileLoop(source, state, log = () => {
1887
1913
 
1888
1914
  // apps/pick/dist/loops/merge.js
1889
1915
  import { randomUUID as randomUUID2 } from "node:crypto";
1916
+ import * as fs15 from "node:fs";
1917
+ import * as path11 from "node:path";
1918
+
1919
+ // apps/pick/dist/merge-view.js
1890
1920
  import * as fs14 from "node:fs";
1891
1921
  import * as path10 from "node:path";
1922
+ function viewPath() {
1923
+ return path10.join(lobstahHome(), "pickup", "merge-view.json");
1924
+ }
1925
+ function readMergeView() {
1926
+ try {
1927
+ return JSON.parse(fs14.readFileSync(viewPath(), "utf8"));
1928
+ } catch {
1929
+ return void 0;
1930
+ }
1931
+ }
1932
+ function writeMergeView(view) {
1933
+ const file = viewPath();
1934
+ fs14.mkdirSync(path10.dirname(file), { recursive: true });
1935
+ const tmp = `${file}.tmp-${process.pid}`;
1936
+ fs14.writeFileSync(tmp, JSON.stringify(view, null, 2));
1937
+ fs14.renameSync(tmp, file);
1938
+ }
1939
+ var RECENT_RETENTION_MS = 48 * 36e5;
1940
+ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
1941
+ const prev = readMergeView();
1942
+ const recent = (prev?.recent ?? []).filter((r) => now.getTime() - Date.parse(r.at) < RECENT_RETENTION_MS);
1943
+ const stillOpen = new Set(open.map((p) => p.number));
1944
+ for (const was of prev?.open ?? []) {
1945
+ if (stillOpen.has(was.number))
1946
+ continue;
1947
+ try {
1948
+ const d = await disposition(was.number);
1949
+ if (d === "open")
1950
+ continue;
1951
+ recent.push({ number: was.number, url: was.url, disposition: d, at: now.toISOString() });
1952
+ } catch {
1953
+ }
1954
+ }
1955
+ writeMergeView({ updatedAt: now.toISOString(), repo, open, recent });
1956
+ }
1957
+ function readPickupMap() {
1958
+ try {
1959
+ const raw = JSON.parse(fs14.readFileSync(path10.join(lobstahHome(), "pickup", "state.json"), "utf8"));
1960
+ return raw.map ?? {};
1961
+ } catch {
1962
+ return {};
1963
+ }
1964
+ }
1965
+
1966
+ // apps/pick/dist/loops/merge.js
1892
1967
  function qualifyingSet(policy, pr) {
1893
1968
  const restricted = pr.labels.some((l) => policy.restrictedLabels.includes(l));
1894
1969
  if (restricted || !policy.assigneeApproves)
@@ -1915,7 +1990,7 @@ function rebaseBrief(pr) {
1915
1990
  }
1916
1991
  function choreVerdict(uuid) {
1917
1992
  const d = laneDirs("chore");
1918
- if (fs14.existsSync(path10.join(d.active, uuid)) || fs14.existsSync(path10.join(d.queue, `${uuid}.json`)))
1993
+ if (fs15.existsSync(path11.join(d.active, uuid)) || fs15.existsSync(path11.join(d.queue, `${uuid}.json`)))
1919
1994
  return "active";
1920
1995
  const last = readStatusLog(uuid, "chore").at(-1)?.verb;
1921
1996
  return last === "done" ? "done" : "failed";
@@ -1924,54 +1999,78 @@ async function mergeLoop(ms, policy, state, log = () => {
1924
1999
  }) {
1925
2000
  if (!policy.enabled)
1926
2001
  return;
2002
+ const observed = [];
2003
+ const observe = (pr, gate) => {
2004
+ const m = /^lobstah\/([0-9a-f-]{36})$/.exec(pr.headRef);
2005
+ observed.push({
2006
+ number: pr.number,
2007
+ url: pr.url,
2008
+ headRef: pr.headRef,
2009
+ headSha: pr.headSha,
2010
+ mergeableState: pr.mergeableState,
2011
+ gate,
2012
+ uuid: m?.[1]
2013
+ });
2014
+ };
1927
2015
  for (const candidate of await ms.mergeCandidates()) {
1928
2016
  const prKey = `${ms.name}#${candidate.number}`;
1929
2017
  const rebase = state.rebase(prKey);
1930
2018
  if (rebase) {
1931
- if (rebase.failed)
2019
+ if (rebase.failed) {
2020
+ observe(candidate, "rebase-failed");
1932
2021
  continue;
2022
+ }
1933
2023
  const verdict = choreVerdict(rebase.uuid);
1934
- if (verdict === "active")
2024
+ if (verdict === "active") {
2025
+ observe(candidate, `conflict-chore:${rebase.uuid}`);
1935
2026
  continue;
2027
+ }
1936
2028
  if (verdict === "failed") {
1937
2029
  await ms.comment(candidate.number, `Automated rebase failed (dispatch ${rebase.uuid}) \u2014 needs a human.`);
1938
2030
  await ms.addLabel(candidate.number, "needs-human");
1939
2031
  state.failRebase(prKey);
1940
2032
  log(`${prKey}: rebase chore failed, flagged for a human`);
2033
+ observe(candidate, "rebase-failed");
1941
2034
  continue;
1942
2035
  }
1943
2036
  state.clearRebase(prKey);
1944
2037
  }
1945
2038
  const provisional = qualifiedApproval(policy, candidate);
1946
- if (!provisional)
1947
- continue;
1948
- if (state.approvalConsumed(approvalDedupKey(candidate, provisional)))
2039
+ if (!provisional || state.approvalConsumed(approvalDedupKey(candidate, provisional))) {
2040
+ observe(candidate, "waiting-approval");
1949
2041
  continue;
2042
+ }
1950
2043
  const pr = await ms.refresh(candidate.number);
1951
2044
  if (!pr)
1952
2045
  continue;
1953
2046
  const approval = qualifiedApproval(policy, pr);
1954
2047
  if (!approval) {
1955
2048
  log(`${prKey}: gate no longer holds on fresh fetch \u2014 skipping`);
2049
+ observe(pr, "waiting-approval");
1956
2050
  continue;
1957
2051
  }
1958
2052
  const dedupKey = approvalDedupKey(pr, approval);
1959
- if (state.approvalConsumed(dedupKey))
2053
+ if (state.approvalConsumed(dedupKey)) {
2054
+ observe(pr, "waiting-approval");
1960
2055
  continue;
2056
+ }
1961
2057
  switch (pr.mergeableState) {
1962
2058
  case "blocked":
1963
2059
  case "draft":
1964
2060
  log(`${prKey}: ${pr.mergeableState} \u2014 not merging`);
2061
+ observe(pr, pr.mergeableState);
1965
2062
  break;
1966
2063
  case "behind":
1967
2064
  await ms.updateBranch(pr.number);
1968
2065
  log(`${prKey}: behind base, updated branch \u2014 gate re-enters next tick`);
2066
+ observe(pr, "behind-updated");
1969
2067
  break;
1970
2068
  case "dirty": {
1971
2069
  const uuid = randomUUID2();
1972
2070
  enqueue({ id: uuid, repo: ms.repoKey(), brief: rebaseBrief(pr) }, "chore");
1973
2071
  state.setRebase(prKey, uuid);
1974
2072
  log(`${prKey}: real conflict \u2014 wrote rebase chore ${uuid}`);
2073
+ observe(pr, `conflict-chore:${uuid}`);
1975
2074
  break;
1976
2075
  }
1977
2076
  default: {
@@ -1981,6 +2080,7 @@ async function mergeLoop(ms, policy, state, log = () => {
1981
2080
  }
1982
2081
  }
1983
2082
  }
2083
+ await recordMergeView(ms.forgeRepo(), observed, (n) => ms.disposition(n));
1984
2084
  }
1985
2085
 
1986
2086
  // apps/pick/dist/run.js
@@ -2066,14 +2166,179 @@ function mergeHaulHook(existing) {
2066
2166
  return { settings, changed: true };
2067
2167
  }
2068
2168
 
2169
+ // apps/cli/src/tend.ts
2170
+ import * as fs16 from "node:fs";
2171
+ import * as path12 from "node:path";
2172
+ var HEARTBEAT_STALE_MS = 9e4;
2173
+ var CLAIM_STALE_MS = 12e4;
2174
+ var DAY_MS = 24 * 36e5;
2175
+ function readJson(file) {
2176
+ try {
2177
+ return JSON.parse(fs16.readFileSync(file, "utf8"));
2178
+ } catch {
2179
+ return void 0;
2180
+ }
2181
+ }
2182
+ function doneIds(lane) {
2183
+ try {
2184
+ return fs16.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2185
+ } catch {
2186
+ return [];
2187
+ }
2188
+ }
2189
+ function describeDispatch(id, lane, bucket) {
2190
+ const log = readStatusLog(id, lane);
2191
+ const last = log.at(-1);
2192
+ const state = bucket === "queued" ? "queued" : reconcile({ log, lastEventAt: lastEventAt(id, lane) });
2193
+ const evidence = readEvidence(id, lane);
2194
+ return { id, lane, bucket, state, note: last?.note, at: last?.at, prUrl: evidence.prUrl };
2195
+ }
2196
+ function followUps(uuid) {
2197
+ const out = [];
2198
+ const d = laneDirs("work");
2199
+ const scan = (id, bucket, file) => {
2200
+ const desc = readJson(file);
2201
+ if (desc?.followUp === uuid) out.push({ id, bucket });
2202
+ };
2203
+ for (const id of pendingIds("work")) scan(id, "queued", path12.join(d.queue, `${id}.json`));
2204
+ for (const id of activeIds("work")) scan(id, "active", path12.join(d.active, id, "descriptor.json"));
2205
+ for (const id of doneIds("work")) scan(id, "done", path12.join(d.done, id, "descriptor.json"));
2206
+ return out;
2207
+ }
2208
+ function bucketOf(uuid) {
2209
+ const d = laneDirs("work");
2210
+ if (fs16.existsSync(path12.join(d.active, uuid))) return "active";
2211
+ if (fs16.existsSync(path12.join(d.done, uuid))) return "done";
2212
+ if (fs16.existsSync(path12.join(d.queue, `${uuid}.json`))) return "queued";
2213
+ return void 0;
2214
+ }
2215
+ function buildTendReport(now = Date.now()) {
2216
+ const cfg = loadConfig();
2217
+ const heartbeat = readJson(executorPath())?.heartbeat;
2218
+ const daemonUp = heartbeat !== void 0 && now - Date.parse(heartbeat) < HEARTBEAT_STALE_MS;
2219
+ const queued = pendingIds("work");
2220
+ const active = activeIds("work");
2221
+ const choresActive = activeIds("chore").length + pendingIds("chore").length;
2222
+ let done24h = 0;
2223
+ let failed24h = 0;
2224
+ for (const lane of ["work", "chore"]) {
2225
+ for (const id of doneIds(lane)) {
2226
+ const last = readStatusLog(id, lane).at(-1);
2227
+ if (!last || now - Date.parse(last.at) > DAY_MS) continue;
2228
+ if (last.verb === "failed") failed24h += 1;
2229
+ else if (last.verb === "done") done24h += 1;
2230
+ }
2231
+ }
2232
+ const remindMs = (cfg.remindSecs ?? 900) * 1e3;
2233
+ const attention = attentionNow(false, remindMs, now).map((ev) => ({
2234
+ id: ev.id,
2235
+ lane: ev.lane,
2236
+ verb: ev.entry.verb,
2237
+ ageSecs: Math.max(0, Math.round((now - Date.parse(ev.entry.at)) / 1e3)),
2238
+ note: ev.entry.note
2239
+ }));
2240
+ const oldestQueuedAge = queued.reduce((max, id) => {
2241
+ const st = fs16.statSync(path12.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2242
+ return st ? Math.max(max, now - st.mtimeMs) : max;
2243
+ }, 0);
2244
+ const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
2245
+ const merge = readMergeView();
2246
+ const gateFor = (uuid, prUrl) => {
2247
+ const open = merge?.open.find((p) => p.uuid === uuid || prUrl !== void 0 && p.url === prUrl);
2248
+ if (open) return open.gate;
2249
+ const recent = merge?.recent.find((r) => prUrl !== void 0 && r.url === prUrl);
2250
+ return recent?.disposition;
2251
+ };
2252
+ const storied = /* @__PURE__ */ new Set();
2253
+ const stories = [];
2254
+ for (const [key, entry] of Object.entries(readPickupMap())) {
2255
+ const bucket = bucketOf(entry.uuid);
2256
+ if (!bucket) continue;
2257
+ const chain = [describeDispatch(entry.uuid, "work", bucket)];
2258
+ for (const f of followUps(entry.uuid)) chain.push(describeDispatch(f.id, "work", f.bucket));
2259
+ for (const d of chain) storied.add(d.id);
2260
+ const fresh = chain.some((d) => d.bucket !== "done" || d.at !== void 0 && now - Date.parse(d.at) < DAY_MS);
2261
+ if (!fresh) continue;
2262
+ const prUrl = chain.map((d) => d.prUrl).find((u) => u !== void 0);
2263
+ stories.push({ key, dispatches: chain, prUrl, gate: gateFor(entry.uuid, prUrl) });
2264
+ }
2265
+ for (const id of [...active, ...queued]) {
2266
+ if (storied.has(id)) continue;
2267
+ const d = describeDispatch(id, "work", bucketOf(id) ?? "active");
2268
+ stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl) });
2269
+ }
2270
+ const verdict = !daemonUp ? "daemon-down" : stalled ? "stalled" : attention.length > 0 ? "needs-attention" : active.length + queued.length > 0 ? "working" : "idle";
2271
+ return {
2272
+ verdict,
2273
+ daemon: { up: daemonUp, lastHeartbeat: heartbeat },
2274
+ counts: { queued: queued.length, active: active.length, choresActive, done24h, failed24h },
2275
+ attention,
2276
+ stories,
2277
+ merge
2278
+ };
2279
+ }
2280
+ function renderTend(r) {
2281
+ const lines = [];
2282
+ lines.push(
2283
+ toonKV({
2284
+ verdict: r.verdict,
2285
+ daemon: r.daemon.up ? "up" : `down (last heartbeat ${r.daemon.lastHeartbeat ?? "never"})`,
2286
+ queued: r.counts.queued,
2287
+ active: r.counts.active,
2288
+ chores: r.counts.choresActive,
2289
+ done24h: r.counts.done24h,
2290
+ failed24h: r.counts.failed24h
2291
+ })
2292
+ );
2293
+ if (r.attention.length > 0) {
2294
+ lines.push("");
2295
+ lines.push(
2296
+ toonTable(
2297
+ "attention",
2298
+ r.attention.map((a) => ({ id: a.id, verb: a.verb, waitingMins: Math.round(a.ageSecs / 60), note: a.note ?? "" })),
2299
+ ["id", "verb", "waitingMins", "note"]
2300
+ )
2301
+ );
2302
+ }
2303
+ if (r.stories.length > 0) {
2304
+ lines.push("");
2305
+ lines.push(
2306
+ toonTable(
2307
+ "work",
2308
+ r.stories.map((s) => ({
2309
+ key: s.key,
2310
+ dispatches: s.dispatches.map((d) => `${d.id.slice(0, 8)}:${d.state}`).join(" \u2192 "),
2311
+ pr: s.prUrl ?? "",
2312
+ gate: s.gate ?? ""
2313
+ })),
2314
+ ["key", "dispatches", "pr", "gate"]
2315
+ )
2316
+ );
2317
+ }
2318
+ if (r.merge && (r.merge.open.length > 0 || r.merge.recent.length > 0)) {
2319
+ lines.push("");
2320
+ lines.push(
2321
+ toonTable(
2322
+ `prs (${r.merge.repo}, observed ${r.merge.updatedAt})`,
2323
+ [
2324
+ ...r.merge.open.map((p) => ({ pr: `#${p.number}`, state: p.mergeableState, gate: p.gate, url: p.url })),
2325
+ ...r.merge.recent.map((p) => ({ pr: `#${p.number}`, state: p.disposition, gate: "", url: p.url }))
2326
+ ],
2327
+ ["pr", "state", "gate", "url"]
2328
+ )
2329
+ );
2330
+ }
2331
+ return lines.join("\n");
2332
+ }
2333
+
2069
2334
  // apps/cli/src/cull.ts
2070
- import * as fs15 from "node:fs";
2071
- import * as path11 from "node:path";
2335
+ import * as fs17 from "node:fs";
2336
+ import * as path13 from "node:path";
2072
2337
  import { execFileSync as execFileSync2 } from "node:child_process";
2073
2338
  var DAY = 864e5;
2074
2339
  function idsIn(dir) {
2075
2340
  try {
2076
- return new Set(fs15.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
2341
+ return new Set(fs17.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
2077
2342
  } catch {
2078
2343
  return /* @__PURE__ */ new Set();
2079
2344
  }
@@ -2088,39 +2353,39 @@ function planCull(olderThanDays, now = Date.now()) {
2088
2353
  for (const id of idsIn(d.queue)) live.add(id);
2089
2354
  for (const id of idsIn(d.active)) live.add(id);
2090
2355
  for (const id of idsIn(d.done)) {
2091
- const p = path11.join(d.done, id);
2092
- const m = fs15.statSync(p).mtimeMs;
2356
+ const p = path13.join(d.done, id);
2357
+ const m = fs17.statSync(p).mtimeMs;
2093
2358
  doneMtimes.set(id, m);
2094
2359
  if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
2095
2360
  }
2096
2361
  }
2097
- const wtRoot = path11.join(lobstahHome(), "worktrees");
2362
+ const wtRoot = path13.join(lobstahHome(), "worktrees");
2098
2363
  for (const id of idsIn(wtRoot)) {
2099
2364
  if (live.has(id)) continue;
2100
2365
  const doneAt = doneMtimes.get(id);
2101
2366
  if (doneAt !== void 0 && doneAt >= cutoff) continue;
2102
- const p = path11.join(wtRoot, id);
2103
- items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs15.statSync(p).mtimeMs) / DAY) });
2367
+ const p = path13.join(wtRoot, id);
2368
+ items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs17.statSync(p).mtimeMs) / DAY) });
2104
2369
  }
2105
2370
  for (const lane of ["work", "chore"]) {
2106
2371
  const d = laneDirs(lane);
2107
2372
  const seen = /* @__PURE__ */ new Set();
2108
- for (const f of fs15.readdirSync(d.state)) {
2373
+ for (const f of fs17.readdirSync(d.state)) {
2109
2374
  const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
2110
2375
  if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
2111
- const m = fs15.statSync(path11.join(d.state, f)).mtimeMs;
2376
+ const m = fs17.statSync(path13.join(d.state, f)).mtimeMs;
2112
2377
  if (m >= cutoff) continue;
2113
2378
  seen.add(id);
2114
- items.push({ kind: "state", id, target: path11.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
2379
+ items.push({ kind: "state", id, target: path13.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
2115
2380
  }
2116
2381
  }
2117
2382
  return items;
2118
2383
  }
2119
2384
  function removeWorktree(id, dir) {
2120
2385
  for (const lane of ["work", "chore"]) {
2121
- const descFile = path11.join(laneDirs(lane).done, id, "descriptor.json");
2386
+ const descFile = path13.join(laneDirs(lane).done, id, "descriptor.json");
2122
2387
  try {
2123
- const desc = JSON.parse(fs15.readFileSync(descFile, "utf8"));
2388
+ const desc = JSON.parse(fs17.readFileSync(descFile, "utf8"));
2124
2389
  const repo = loadConfig().repos[desc.repo];
2125
2390
  if (repo) {
2126
2391
  execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
@@ -2129,15 +2394,15 @@ function removeWorktree(id, dir) {
2129
2394
  } catch {
2130
2395
  }
2131
2396
  }
2132
- fs15.rmSync(dir, { recursive: true, force: true });
2397
+ fs17.rmSync(dir, { recursive: true, force: true });
2133
2398
  }
2134
2399
  function applyCull(items) {
2135
2400
  for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
2136
- for (const item of items.filter((i) => i.kind === "done")) fs15.rmSync(item.target, { recursive: true, force: true });
2401
+ for (const item of items.filter((i) => i.kind === "done")) fs17.rmSync(item.target, { recursive: true, force: true });
2137
2402
  for (const item of items.filter((i) => i.kind === "state")) {
2138
- const dir = path11.dirname(item.target);
2139
- for (const f of fs15.readdirSync(dir)) {
2140
- if (f.startsWith(`${item.id}.`)) fs15.rmSync(path11.join(dir, f), { force: true });
2403
+ const dir = path13.dirname(item.target);
2404
+ for (const f of fs17.readdirSync(dir)) {
2405
+ if (f.startsWith(`${item.id}.`)) fs17.rmSync(path13.join(dir, f), { force: true });
2141
2406
  }
2142
2407
  }
2143
2408
  }
@@ -2160,6 +2425,9 @@ your working set:
2160
2425
  lobstah catch <id> land the catch (evidence)
2161
2426
  lobstah cancel <id> cut one away
2162
2427
  lobstah cull [--apply] sweep aged catch and lost gear
2428
+ lobstah man tend [--json] tend the string: fleet verdict,
2429
+ unanswered questions, each work
2430
+ item's chain + PR + merge gate
2163
2431
 
2164
2432
  getting woken instead of asking:
2165
2433
  lobstah man wait block until a dispatch needs attention (arm it as a
@@ -2179,7 +2447,7 @@ paste into your liaison instructions:
2179
2447
  full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
2180
2448
 
2181
2449
  // apps/cli/src/main.ts
2182
- var HELP = `lobstah \u2014 supervisor for local coding agents
2450
+ var HELP = `lobstah \u2014 supervision framework for coding agents
2183
2451
 
2184
2452
  work (humans and agents):
2185
2453
  dispatch --repo <key> (--brief <file> | --brief-text <text>) (alias: set --bait)
@@ -2213,6 +2481,11 @@ host processes (run under launchd/systemd/pm2):
2213
2481
  assigned work, report back, reconcile, merge
2214
2482
 
2215
2483
  lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual):
2484
+ man tend [--json] tend the whole string: fleet verdict (daemon
2485
+ up, stalled vs idle), unanswered questions
2486
+ with ages, each work item's dispatch chain,
2487
+ PR, and merge-gate status from pick's last
2488
+ observation. Pure disk read \u2014 no forge calls.
2216
2489
  man wait [--timeout <secs>] [--peek]
2217
2490
  block until a dispatch needs attention, then
2218
2491
  print the event and what to do next; exit 2
@@ -2245,7 +2518,7 @@ function arg(args, flag) {
2245
2518
  function findLane(id) {
2246
2519
  for (const lane of ["work", "chore"]) {
2247
2520
  const d = laneDirs(lane);
2248
- if (fs16.existsSync(path12.join(d.active, id)) || fs16.existsSync(path12.join(d.queue, `${id}.json`)) || fs16.existsSync(path12.join(d.done, id)) || fs16.existsSync(path12.join(d.state, `${id}.status`))) {
2521
+ if (fs18.existsSync(path14.join(d.active, id)) || fs18.existsSync(path14.join(d.queue, `${id}.json`)) || fs18.existsSync(path14.join(d.done, id)) || fs18.existsSync(path14.join(d.state, `${id}.status`))) {
2249
2522
  return lane;
2250
2523
  }
2251
2524
  }
@@ -2253,7 +2526,7 @@ function findLane(id) {
2253
2526
  }
2254
2527
  function rowsFor(lane, bucket) {
2255
2528
  const dir = laneDirs(lane)[bucket];
2256
- const entries = fs16.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs16.statSync(path12.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
2529
+ const entries = fs18.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs18.statSync(path14.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
2257
2530
  const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
2258
2531
  return sliced.map(({ f, m }) => {
2259
2532
  const id = f.replace(/\.json$/, "");
@@ -2281,7 +2554,7 @@ async function mainCli() {
2281
2554
  const d = {
2282
2555
  id: arg(args, "--id") ?? randomUUID3(),
2283
2556
  repo,
2284
- brief: briefText ?? fs16.readFileSync(briefFile, "utf8"),
2557
+ brief: briefText ?? fs18.readFileSync(briefFile, "utf8"),
2285
2558
  harness: arg(args, "--harness"),
2286
2559
  model: arg(args, "--model"),
2287
2560
  effort: arg(args, "--effort"),
@@ -2318,17 +2591,17 @@ async function mainCli() {
2318
2591
  if (!id) throw new Error("logs requires a dispatch id");
2319
2592
  const lane = findLane(id);
2320
2593
  const file = eventsPath(id, lane);
2321
- if (fs16.existsSync(file)) process.stdout.write(fs16.readFileSync(file, "utf8"));
2594
+ if (fs18.existsSync(file)) process.stdout.write(fs18.readFileSync(file, "utf8"));
2322
2595
  if (args.includes("--follow")) {
2323
- let size = fs16.existsSync(file) ? fs16.statSync(file).size : 0;
2596
+ let size = fs18.existsSync(file) ? fs18.statSync(file).size : 0;
2324
2597
  setInterval(() => {
2325
- if (!fs16.existsSync(file)) return;
2326
- const now = fs16.statSync(file).size;
2598
+ if (!fs18.existsSync(file)) return;
2599
+ const now = fs18.statSync(file).size;
2327
2600
  if (now > size) {
2328
- const fd = fs16.openSync(file, "r");
2601
+ const fd = fs18.openSync(file, "r");
2329
2602
  const buf = Buffer.alloc(now - size);
2330
- fs16.readSync(fd, buf, 0, buf.length, size);
2331
- fs16.closeSync(fd);
2603
+ fs18.readSync(fd, buf, 0, buf.length, size);
2604
+ fs18.closeSync(fd);
2332
2605
  process.stdout.write(buf.toString("utf8"));
2333
2606
  size = now;
2334
2607
  }
@@ -2384,16 +2657,16 @@ async function mainCli() {
2384
2657
  );
2385
2658
  }
2386
2659
  const dirs = laneDirs(lane);
2387
- const descriptorFile = ["active", "done"].map((b) => path12.join(dirs[b], id, "descriptor.json")).find((f) => fs16.existsSync(f));
2388
- const harness = descriptorFile ? JSON.parse(fs16.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
2660
+ const descriptorFile = ["active", "done"].map((b) => path14.join(dirs[b], id, "descriptor.json")).find((f) => fs18.existsSync(f));
2661
+ const harness = descriptorFile ? JSON.parse(fs18.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
2389
2662
  let sessionId;
2390
2663
  try {
2391
- sessionId = JSON.parse(fs16.readFileSync(path12.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
2664
+ sessionId = JSON.parse(fs18.readFileSync(path14.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
2392
2665
  } catch {
2393
2666
  }
2394
2667
  if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
2395
- const worktree = path12.join(lobstahHome(), "worktrees", id);
2396
- const cwd = fs16.existsSync(worktree) ? worktree : process.cwd();
2668
+ const worktree = path14.join(lobstahHome(), "worktrees", id);
2669
+ const cwd = fs18.existsSync(worktree) ? worktree : process.cwd();
2397
2670
  const invocation = harness === "codex" ? { file: "codex", argv: ["resume", sessionId] } : { file: "claude", argv: ["--resume", sessionId] };
2398
2671
  if (args.includes("--print")) {
2399
2672
  console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
@@ -2407,19 +2680,19 @@ async function mainCli() {
2407
2680
  const id = args[0];
2408
2681
  if (!id) throw new Error("swap requires a dispatch id");
2409
2682
  const lane = findLane(id);
2410
- const activeDir = path12.join(laneDirs(lane).active, id);
2411
- if (!fs16.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
2412
- const descFile = path12.join(activeDir, "descriptor.json");
2413
- const descriptor = JSON.parse(fs16.readFileSync(descFile, "utf8"));
2683
+ const activeDir = path14.join(laneDirs(lane).active, id);
2684
+ if (!fs18.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
2685
+ const descFile = path14.join(activeDir, "descriptor.json");
2686
+ const descriptor = JSON.parse(fs18.readFileSync(descFile, "utf8"));
2414
2687
  const fromHarness = descriptor.harness ?? "default";
2415
2688
  for (const key of ["harness", "model", "effort"]) {
2416
2689
  const v = arg(args, `--${key}`);
2417
2690
  if (v) descriptor[key] = v;
2418
2691
  }
2419
- fs16.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
2420
- const worktree = path12.join(lobstahHome(), "worktrees", id);
2692
+ fs18.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
2693
+ const worktree = path14.join(lobstahHome(), "worktrees", id);
2421
2694
  let progress = "No worktree progress recorded.";
2422
- if (fs16.existsSync(worktree)) {
2695
+ if (fs18.existsSync(worktree)) {
2423
2696
  const git = (...a) => spawnSync("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
2424
2697
  const commits = git("log", "--oneline", "-15");
2425
2698
  const status = git("status", "--short");
@@ -2429,17 +2702,17 @@ ${commits || "(none)"}
2429
2702
  Uncommitted changes:
2430
2703
  ${status || "(clean)"}`;
2431
2704
  }
2432
- fs16.writeFileSync(
2433
- path12.join(activeDir, "handoff"),
2705
+ fs18.writeFileSync(
2706
+ path14.join(activeDir, "handoff"),
2434
2707
  `You are taking over this dispatch from a previous agent session (harness: ${fromHarness}). Its conversation is not available \u2014 the worktree state below is the ground truth. Review it, then continue the brief from where it stops.
2435
2708
 
2436
2709
  ${progress}`
2437
2710
  );
2438
- const runnerFile = path12.join(activeDir, "runner.json");
2439
- if (fs16.existsSync(runnerFile)) {
2440
- const runner = JSON.parse(fs16.readFileSync(runnerFile, "utf8"));
2711
+ const runnerFile = path14.join(activeDir, "runner.json");
2712
+ if (fs18.existsSync(runnerFile)) {
2713
+ const runner = JSON.parse(fs18.readFileSync(runnerFile, "utf8"));
2441
2714
  if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
2442
- fs16.unlinkSync(runnerFile);
2715
+ fs18.unlinkSync(runnerFile);
2443
2716
  }
2444
2717
  appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
2445
2718
  console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
@@ -2490,6 +2763,11 @@ ${progress}`
2490
2763
  console.log(MANUAL);
2491
2764
  break;
2492
2765
  }
2766
+ case "man:tend": {
2767
+ const report = buildTendReport();
2768
+ console.log(args.includes("--json") ? JSON.stringify(report, null, 2) : renderTend(report));
2769
+ break;
2770
+ }
2493
2771
  case "cancel": {
2494
2772
  const id = args[0];
2495
2773
  if (!id) throw new Error("cancel requires a dispatch id");
@@ -2529,20 +2807,20 @@ ${progress}`
2529
2807
  break;
2530
2808
  }
2531
2809
  case "man:init": {
2532
- const file = path12.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
2810
+ const file = path14.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
2533
2811
  let existing;
2534
2812
  try {
2535
- existing = JSON.parse(fs16.readFileSync(file, "utf8"));
2813
+ existing = JSON.parse(fs18.readFileSync(file, "utf8"));
2536
2814
  } catch {
2537
2815
  existing = void 0;
2538
2816
  }
2539
2817
  const { settings, changed } = mergeHaulHook(existing);
2540
2818
  if (changed) {
2541
- fs16.mkdirSync(".claude", { recursive: true });
2542
- fs16.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
2819
+ fs18.mkdirSync(".claude", { recursive: true });
2820
+ fs18.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
2543
2821
  `);
2544
2822
  }
2545
- if (args.includes("--marker")) fs16.writeFileSync(".lobstah-man", "");
2823
+ if (args.includes("--marker")) fs18.writeFileSync(".lobstah-man", "");
2546
2824
  console.log(
2547
2825
  toonKV({
2548
2826
  hook: "lobstah man haul",
@@ -2555,9 +2833,9 @@ ${progress}`
2555
2833
  }
2556
2834
  case "man:haul": {
2557
2835
  try {
2558
- if (process.env.LOBSTAH_MAN !== "1" && !fs16.existsSync(".lobstah-man")) break;
2836
+ if (process.env.LOBSTAH_MAN !== "1" && !fs18.existsSync(".lobstah-man")) break;
2559
2837
  const anyActive = ["work", "chore"].some(
2560
- (l) => fs16.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
2838
+ (l) => fs18.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
2561
2839
  );
2562
2840
  if (!anyActive) break;
2563
2841
  const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
@@ -2598,8 +2876,8 @@ ${progress}`
2598
2876
  break;
2599
2877
  }
2600
2878
  case "init": {
2601
- if (!fs16.existsSync(configPath())) {
2602
- fs16.writeFileSync(
2879
+ if (!fs18.existsSync(configPath())) {
2880
+ fs18.writeFileSync(
2603
2881
  configPath(),
2604
2882
  `# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
2605
2883
  # Top-level keys must come before any [section].
@@ -2626,7 +2904,7 @@ wallClockSecs = 3600
2626
2904
  }
2627
2905
  console.log(
2628
2906
  toonKV({
2629
- home: path12.dirname(configPath()),
2907
+ home: path14.dirname(configPath()),
2630
2908
  config: configPath(),
2631
2909
  reference: "docs/configuration.md",
2632
2910
  initialized: true
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lobstah",
3
- "version": "0.1.0",
4
- "description": "Harness-agnostic, token-efficient supervisor for local coding agents",
3
+ "version": "0.1.1",
4
+ "description": "Harness-agnostic, token-efficient supervision framework for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "aequitas labs LLC",
7
7
  "type": "module",