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.
- package/README.md +49 -36
- package/dist/main.js +355 -77
- 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
|
[](https://github.com/aequitas-labs/lobstah/actions/workflows/ci.yml)
|
|
4
6
|

|
|
5
7
|

|
|
6
8
|

|
|
9
|
+
[](https://www.npmjs.com/package/lobstah)
|
|
7
10
|

|
|
8
11
|
|
|
9
|
-
Harness-agnostic, token-efficient
|
|
12
|
+
Harness-agnostic, token-efficient supervision framework for coding agents.
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
stuck, which one is waiting on
|
|
13
|
-
minutes ago
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
`paused`, `done`, `failed
|
|
75
|
-
brief is fulfilled
|
|
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`)
|
|
78
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
the loop. Driving lobstah from an agent session instead? `lobstah
|
|
85
|
-
the lobsterman's manual
|
|
86
|
-
|
|
87
|
-
Claude Code Stop hook that parks the session on the fleet and
|
|
88
|
-
turn the moment something
|
|
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
|
-
|
|
116
|
-
and
|
|
117
|
-
|
|
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
|
|
5
|
-
import * as
|
|
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)
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
|
1674
|
-
|
|
1675
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
2071
|
-
import * as
|
|
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(
|
|
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 =
|
|
2092
|
-
const m =
|
|
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 =
|
|
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 =
|
|
2103
|
-
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now -
|
|
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
|
|
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 =
|
|
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:
|
|
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 =
|
|
2386
|
+
const descFile = path13.join(laneDirs(lane).done, id, "descriptor.json");
|
|
2122
2387
|
try {
|
|
2123
|
-
const desc = JSON.parse(
|
|
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
|
-
|
|
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"))
|
|
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 =
|
|
2139
|
-
for (const f of
|
|
2140
|
-
if (f.startsWith(`${item.id}.`))
|
|
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
|
|
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 (
|
|
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 =
|
|
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 ??
|
|
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 (
|
|
2594
|
+
if (fs18.existsSync(file)) process.stdout.write(fs18.readFileSync(file, "utf8"));
|
|
2322
2595
|
if (args.includes("--follow")) {
|
|
2323
|
-
let size =
|
|
2596
|
+
let size = fs18.existsSync(file) ? fs18.statSync(file).size : 0;
|
|
2324
2597
|
setInterval(() => {
|
|
2325
|
-
if (!
|
|
2326
|
-
const now =
|
|
2598
|
+
if (!fs18.existsSync(file)) return;
|
|
2599
|
+
const now = fs18.statSync(file).size;
|
|
2327
2600
|
if (now > size) {
|
|
2328
|
-
const fd =
|
|
2601
|
+
const fd = fs18.openSync(file, "r");
|
|
2329
2602
|
const buf = Buffer.alloc(now - size);
|
|
2330
|
-
|
|
2331
|
-
|
|
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) =>
|
|
2388
|
-
const harness = descriptorFile ? JSON.parse(
|
|
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(
|
|
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 =
|
|
2396
|
-
const 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 =
|
|
2411
|
-
if (!
|
|
2412
|
-
const descFile =
|
|
2413
|
-
const descriptor = JSON.parse(
|
|
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
|
-
|
|
2420
|
-
const worktree =
|
|
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 (
|
|
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
|
-
|
|
2433
|
-
|
|
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 =
|
|
2439
|
-
if (
|
|
2440
|
-
const runner = JSON.parse(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
2542
|
-
|
|
2819
|
+
fs18.mkdirSync(".claude", { recursive: true });
|
|
2820
|
+
fs18.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
2543
2821
|
`);
|
|
2544
2822
|
}
|
|
2545
|
-
if (args.includes("--marker"))
|
|
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" && !
|
|
2836
|
+
if (process.env.LOBSTAH_MAN !== "1" && !fs18.existsSync(".lobstah-man")) break;
|
|
2559
2837
|
const anyActive = ["work", "chore"].some(
|
|
2560
|
-
(l) =>
|
|
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 (!
|
|
2602
|
-
|
|
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:
|
|
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.
|
|
4
|
-
"description": "Harness-agnostic, token-efficient
|
|
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",
|