lobstah 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -39
- package/dist/main.js +901 -176
- package/dist/runner.js +2 -1
- package/docs/configuration.md +122 -0
- package/docs/design.md +669 -0
- package/docs/lobsterman.md +201 -0
- package/docs/openclaw.md +76 -0
- package/docs/pickup.md +319 -0
- package/docs/vocabulary.md +118 -0
- package/package.json +3 -2
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# The lobsterman: a single-liaison session on lobstah
|
|
2
|
+
|
|
3
|
+
The pattern: you talk to **one** interactive agent — the lobsterman — and it
|
|
4
|
+
runs the fleet: dispatching work, supervising, escalating only real decisions.
|
|
5
|
+
Lobstah supplies the machinery for doing that locally without the liaison
|
|
6
|
+
burning tokens on supervision.
|
|
7
|
+
|
|
8
|
+
## The shape
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
you ⇄ liaison (interactive Claude Code / Codex session)
|
|
12
|
+
│ lobstah dispatch / status / send / cancel (CLI, TOON output)
|
|
13
|
+
▼
|
|
14
|
+
lobstah daemon ── worktree-isolated dispatches, supervised for free
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The liaison never watches the workers — the daemon does that with no model in
|
|
18
|
+
the loop. The liaison reads `lobstah status` when you ask, which is the
|
|
19
|
+
token-efficiency point: supervision is a filesystem read, not a conversation.
|
|
20
|
+
|
|
21
|
+
## Set it up
|
|
22
|
+
|
|
23
|
+
1. Install lobstah, configure your repos, start the daemon
|
|
24
|
+
([README](../README.md#install)).
|
|
25
|
+
2. Start an interactive session anywhere and paste this into the project's
|
|
26
|
+
agent instructions (`AGENTS.md` / `CLAUDE.md`), or just say it:
|
|
27
|
+
|
|
28
|
+
```markdown
|
|
29
|
+
You are my liaison for delegated coding work. For any task that should run in
|
|
30
|
+
the background, dispatch it with the `lobstah` CLI instead of doing it inline:
|
|
31
|
+
|
|
32
|
+
- `lobstah dispatch --repo <key> --brief-text "<full brief>"` — returns an id.
|
|
33
|
+
Write briefs that stand alone; the worker has no other context.
|
|
34
|
+
- `lobstah status [<id>]`, `lobstah ls` — check progress when I ask, not on a loop.
|
|
35
|
+
- `lobstah send <id> "<instruction>"` — steer a running dispatch.
|
|
36
|
+
- `lobstah cancel <id>` — stop one.
|
|
37
|
+
- A dispatch reporting `needs-decision` is waiting on ME — surface its question
|
|
38
|
+
immediately, then `lobstah send` my answer.
|
|
39
|
+
- `done` means brief fulfilled with a branch + commits; report the evidence
|
|
40
|
+
(`~/.lobstah/state/<id>.evidence`) and never merge anything yourself.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That's the whole integration — the CLI is self-documenting (`lobstah help`)
|
|
44
|
+
and its TOON output is built to be read by agents.
|
|
45
|
+
|
|
46
|
+
## Taking over a worker
|
|
47
|
+
|
|
48
|
+
Every dispatch **is** a real harness session, running under the same CLI you
|
|
49
|
+
use by hand. So the crew is inspectable with tools you already have:
|
|
50
|
+
|
|
51
|
+
- `lobstah attach <id>` — opens the worker's own session, in its worktree:
|
|
52
|
+
`claude --resume <sessionId>` or `codex resume <threadId>` under the hood.
|
|
53
|
+
Full conversation context survives — ask it "what did you do?", redirect it,
|
|
54
|
+
or keep working in the worktree yourself.
|
|
55
|
+
- `lobstah logs <id> --follow` — the normalized event stream, live.
|
|
56
|
+
- Session pickers in the harness's own tooling (`claude --resume` with no id,
|
|
57
|
+
the Claude/Codex desktop apps' session lists) show dispatch sessions too —
|
|
58
|
+
they're stored where the harness always stores them.
|
|
59
|
+
|
|
60
|
+
Attach refuses while a dispatch is `working` (two writers, one session);
|
|
61
|
+
follow the logs or `send` instead, or cancel and then attach.
|
|
62
|
+
|
|
63
|
+
`lobstah swap <id> [--harness codex] [--model ...]` hands an in-flight
|
|
64
|
+
dispatch to a fresh session: same worktree, same brief, plus an auto-generated
|
|
65
|
+
progress note with the commits so far and any uncommitted changes.
|
|
66
|
+
Conversations do not cross harnesses. The worktree is the durable layer, so
|
|
67
|
+
the handoff carries everything that matters. Use swap to move work between
|
|
68
|
+
subscriptions, escape a rate limit, or re-roll a session that went sideways.
|
|
69
|
+
|
|
70
|
+
## Tending the string
|
|
71
|
+
|
|
72
|
+
`lobstah man tend` is the whole-fleet pass — the lobsterman working every trap
|
|
73
|
+
in one sweep. It prints a verdict and the story of each piece of work, from a
|
|
74
|
+
pure disk read: no forge calls, no tokens.
|
|
75
|
+
|
|
76
|
+
The verdict distinguishes states that look identical from the outside:
|
|
77
|
+
|
|
78
|
+
| Verdict | Meaning |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `daemon-down` | No fresh heartbeat — nothing is being supervised. |
|
|
81
|
+
| `stalled` | Work queued, capacity free, daemon alive, nothing claiming — actually broken. |
|
|
82
|
+
| `needs-attention` | An unanswered `needs-decision`/`blocked` is standing, with its age. |
|
|
83
|
+
| `working` | Dispatches active or queued, nothing waiting on a human. |
|
|
84
|
+
| `idle` | Everything drained; the quiet is real. |
|
|
85
|
+
|
|
86
|
+
Below the verdict: counts (queued, active, chores, done/failed last 24h), the
|
|
87
|
+
unanswered questions with how long they have waited, and one row per work
|
|
88
|
+
item — tracker key, its dispatch chain (original → swaps → review follow-ups),
|
|
89
|
+
its PR, and the PR's merge-gate status. Gate status comes from the [merge
|
|
90
|
+
view](pickup.md#merge-view) pickup persists each tick, so PR state is at most
|
|
91
|
+
one poll interval stale without tend making a single network call. `--json`
|
|
92
|
+
emits the full report for dashboards and scripts to render.
|
|
93
|
+
|
|
94
|
+
## Getting woken instead of asked
|
|
95
|
+
|
|
96
|
+
Three escalation tiers, least to most invasive. All are built on
|
|
97
|
+
`lobstah man wait`: block until a dispatch needs attention, print the event and
|
|
98
|
+
what to do next, exit. It is **level-triggered for attention states** — if a
|
|
99
|
+
`needs-decision` is already standing when it starts, it returns immediately —
|
|
100
|
+
so a gap between one watcher exiting and the next arming can never lose an
|
|
101
|
+
event.
|
|
102
|
+
|
|
103
|
+
**Tier 1 — a push for the human.** Set the daemon's hook and forget it:
|
|
104
|
+
|
|
105
|
+
```toml
|
|
106
|
+
notifyCommand = "ntfy pub my-topic \"$LOBSTAH_VERB $LOBSTAH_ID: $LOBSTAH_NOTE\""
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Tier 2 — a background watcher in the liaison session.** The liaison runs
|
|
110
|
+
`lobstah man wait` as a background task; when it exits, the harness's task
|
|
111
|
+
notification wakes the session, and the printed `next:` line tells the agent
|
|
112
|
+
exactly what to do — including re-arming. One watcher per wake is inherent to
|
|
113
|
+
background tasks; the level-trigger makes the re-arm race harmless. Add to the
|
|
114
|
+
liaison instructions:
|
|
115
|
+
|
|
116
|
+
```markdown
|
|
117
|
+
After dispatching work, run `lobstah man wait` as a background task. When it
|
|
118
|
+
completes, follow its `next:` instruction, then re-arm it.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Tier 3 — park the session on a Stop hook (Claude Code only).** A Stop hook
|
|
122
|
+
that blocks on `lobstah man wait`, so the session never
|
|
123
|
+
really idles — it parks for free and continues the moment something needs it.
|
|
124
|
+
What this buys over tier 2 is not the wake. Both wake on events, and both
|
|
125
|
+
cost a turn per wake. The difference: the re-arm is **structural instead of
|
|
126
|
+
instructed**. Tier 2 works only as long as the model remembers to re-arm the
|
|
127
|
+
watcher after every wake. A forgotten re-arm, a crashed watcher, or an
|
|
128
|
+
interrupted turn leaves the session deaf until a human speaks. The Stop hook
|
|
129
|
+
fires at every turn end, no matter what the model did. Supervision cannot
|
|
130
|
+
lapse through instruction drift, and a blind stop mid-shift is impossible.
|
|
131
|
+
|
|
132
|
+
The hook is a CLI command — `lobstah man haul` (the lobsterman hauls the
|
|
133
|
+
trapline; every orchestrator-facing command lives under `lobstah man`).
|
|
134
|
+
Install it from the project you'll run the lobsterman in:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
lobstah man init # merges the Stop hook into .claude/settings.local.json
|
|
138
|
+
lobstah man init --shared # …or the committed .claude/settings.json
|
|
139
|
+
lobstah man init --global # …or once into ~/.claude/settings.json — any
|
|
140
|
+
# directory with a .lobstah-man file then parks
|
|
141
|
+
lobstah man init --marker # also touch .lobstah-man (per-directory gate)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Idempotent, and it only appends to `hooks.Stop` — existing hooks and settings
|
|
145
|
+
are preserved verbatim. What it writes:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{ "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "lobstah man haul", "timeout": 14400 }] }] } }
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`haul` gates itself twice: only a designated session parks (launch it with
|
|
152
|
+
`LOBSTAH_MAN=1 claude`, or `touch .lobstah-man` for a per-directory gate), and
|
|
153
|
+
only while dispatches are in flight — conversational turns end free. On an
|
|
154
|
+
event it blocks the stop with the event as context and tells the agent the
|
|
155
|
+
session re-parks automatically; on timeout or any error it silently allows
|
|
156
|
+
the stop, leaving tier 1 as the backstop past the horizon.
|
|
157
|
+
|
|
158
|
+
Two habits worth adding to a lobsterman session's instructions: run
|
|
159
|
+
`lobstah man wait --peek` at session start (a wake consumed by a session that
|
|
160
|
+
died mid-handling is still standing state — peek resurfaces it), and treat
|
|
161
|
+
the haul context as the work order for that turn.
|
|
162
|
+
|
|
163
|
+
The trade-offs, honestly. While parked, the turn never ends, so the terminal
|
|
164
|
+
shows a running hook. Each wake appends a turn to the context, and long
|
|
165
|
+
shifts eventually compact. Without the gate, the hook parks every session in
|
|
166
|
+
the project. And parking is Claude-specific: it needs a turn-end hook that
|
|
167
|
+
can block and inject a continuation. Claude Code's Stop hook can. Codex's
|
|
168
|
+
fire-and-forget notify hook cannot. The workers can still be any harness —
|
|
169
|
+
only the liaison must be Claude Code. Tier 2 is the right default. Tier 3 is
|
|
170
|
+
for a dedicated, long-lived liaison session.
|
|
171
|
+
|
|
172
|
+
**Delivery guarantee.** Attention wakes are at-least-once with backoff. An
|
|
173
|
+
unanswered question is reported immediately. While it still stands, it
|
|
174
|
+
re-fires as a reminder every `remindSecs` (top-level config, default 900). So
|
|
175
|
+
a wake consumed by a session that died mid-handling resurfaces on its own.
|
|
176
|
+
Answering ends the reminders naturally, because the answer produces a new
|
|
177
|
+
status entry. Set `remindSecs = 0` for pure at-most-once.
|
|
178
|
+
|
|
179
|
+
**Any-harness fallback — the wrapper loop.** Tiers 2 and 3 lean on Claude
|
|
180
|
+
Code features (background-task notifications, the Stop hook). For any other
|
|
181
|
+
harness — or no interactive session at all — an outer loop blocking on `wait`
|
|
182
|
+
spawns one fresh headless turn per event:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
while out=$(lobstah man wait); do
|
|
186
|
+
codex exec "A dispatch needs attention: $out — handle it with the lobstah CLI."
|
|
187
|
+
done
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Zero tokens between events and works anywhere a shell does; the cost is that
|
|
191
|
+
each event gets a fresh context rather than a continuing liaison
|
|
192
|
+
conversation.
|
|
193
|
+
|
|
194
|
+
## What the daemon gives your liaison for free
|
|
195
|
+
|
|
196
|
+
- Parallel work that can't collide — worktree per dispatch.
|
|
197
|
+
- A crew that survives crashes: dead runners respawn (bounded), wedged ones
|
|
198
|
+
are killed and forked with a nudge, and everything reconciles from disk
|
|
199
|
+
after a reboot.
|
|
200
|
+
- An honest six-verb status contract, validated at the write path, so the
|
|
201
|
+
liaison never has to parse prose to know where things stand.
|
package/docs/openclaw.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# OpenClaw integration
|
|
2
|
+
|
|
3
|
+
Lobstah plugs into an [OpenClaw](https://github.com/openclaw/openclaw) fleet at
|
|
4
|
+
two seams: a **gateway plugin** that gives agents typed dispatch tools, and the
|
|
5
|
+
**pickup loops** that replace hand-rolled tracker polling scripts. Core knows
|
|
6
|
+
about neither — both just write the same descriptors into the same directory.
|
|
7
|
+
|
|
8
|
+
## Install the plugin
|
|
9
|
+
|
|
10
|
+
On the gateway host (which should also run `lobstah daemon`):
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
git clone https://github.com/aequitas-labs/lobstah
|
|
14
|
+
cd lobstah && pnpm install && pnpm build
|
|
15
|
+
openclaw plugins install ./apps/node
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Once lobstah is on npm, this becomes a one-liner with no clone:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
openclaw plugins install lobstah-openclaw-plugin
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What agents get
|
|
25
|
+
|
|
26
|
+
Four tools, registered for every fleet agent:
|
|
27
|
+
|
|
28
|
+
| Tool | Does |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `lobstah_dispatch` | Queue a supervised dispatch (repo key, brief, optional harness/model/followUp) — returns the id |
|
|
31
|
+
| `lobstah_status` | Reconciled state of one dispatch or a table of everything active |
|
|
32
|
+
| `lobstah_send` | Drop an instruction into a running dispatch's inbox |
|
|
33
|
+
| `lobstah_cancel` | Request cancellation |
|
|
34
|
+
|
|
35
|
+
Operators get `/lobstah [id]` as a chat command — status without waking a model.
|
|
36
|
+
|
|
37
|
+
An agent asked in chat to "fix the flaky login test in myapp" hands the work
|
|
38
|
+
to lobstah and stays responsive. Later it answers "how's it going?" from
|
|
39
|
+
`lobstah_status`. The daemon does the actual supervision the whole time, for
|
|
40
|
+
zero tokens.
|
|
41
|
+
|
|
42
|
+
## Replacing scheduler scripts with pickup
|
|
43
|
+
|
|
44
|
+
A typical fleet setup polls the tracker from cron/launchd scripts, dispatches
|
|
45
|
+
a headless agent, re-arms check-ins, and merges approved PRs from more
|
|
46
|
+
scripts. `lobstah pick` replaces that whole layer with one process — see
|
|
47
|
+
[pickup.md](pickup.md) for the loops and [the config](pickup.md#dispatch-loop).
|
|
48
|
+
|
|
49
|
+
The division of labor that falls out:
|
|
50
|
+
|
|
51
|
+
- **Pickup** owns issue/review dispatch, tracker status comments, drift
|
|
52
|
+
reconciliation, and (opt-in) merges.
|
|
53
|
+
- **The daemon** owns worktrees, liveness, wedge detection, restarts.
|
|
54
|
+
- **Fleet agents** keep the judgment work: triage, escalation policy,
|
|
55
|
+
answering humans — and reach lobstah through the plugin tools when a request
|
|
56
|
+
becomes a dispatch.
|
|
57
|
+
- **Notifications** ride `notifyCommand` — point it at your existing Slack
|
|
58
|
+
helper; lobstah stays vendor-free.
|
|
59
|
+
|
|
60
|
+
Token minting for GitHub Apps fits the `tokenCommand` source directly
|
|
61
|
+
(installation tokens expire hourly):
|
|
62
|
+
|
|
63
|
+
```toml
|
|
64
|
+
[pickup.github]
|
|
65
|
+
tokenCommand = "gh-app-token.sh my-app"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Keeping both alive
|
|
69
|
+
|
|
70
|
+
Two long-running commands on the host, under whatever supervises processes
|
|
71
|
+
there already (launchd on macOS, systemd on Linux):
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
lobstah daemon # supervisor — holds no credentials
|
|
75
|
+
lobstah pick # tracker loops — holds the tokens
|
|
76
|
+
```
|
package/docs/pickup.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# Pickup — tracker add-on
|
|
2
|
+
|
|
3
|
+
**Local work pickup from Linear and GitHub, without webhooks.**
|
|
4
|
+
|
|
5
|
+
Pickup is a poll loop that runs beside the daemon. It polls trackers outbound,
|
|
6
|
+
translates matching items into queue descriptors, translates state files back
|
|
7
|
+
into tracker updates, and — where enabled — merges approved PRs. It is the
|
|
8
|
+
fourth caller: core never learns it exists.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
Linear / GitHub <──poll── apps/pick ──writes──> queue/
|
|
12
|
+
<──report── <──reads─── state/ evidence events
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Why polling is the product, not the compromise
|
|
18
|
+
|
|
19
|
+
Every existing tracker-driven agent needs inbound networking — OAuth apps plus
|
|
20
|
+
a tunnel, often sold as the hosted tier's headline feature. A webhook target
|
|
21
|
+
cannot run on a laptop behind NAT that sleeps.
|
|
22
|
+
|
|
23
|
+
A poll loop has zero inbound surface. No listener, no tunnel, no public
|
|
24
|
+
exposure. Offline means it doesn't poll; wake means it catches up. Latency is
|
|
25
|
+
the poll interval — 30–60 seconds against tasks measured in minutes.
|
|
26
|
+
|
|
27
|
+
This is the queue contract's own rule applied one layer up: watch as an
|
|
28
|
+
optimization, poll as the guarantee.
|
|
29
|
+
|
|
30
|
+
## Boundary
|
|
31
|
+
|
|
32
|
+
Pickup holds tracker credentials, so it lives in `apps/`, never `packages/core`
|
|
33
|
+
— the same rule that governs the bridge and the node plugin. Core stays free of
|
|
34
|
+
network, OAuth, and tracker vocabulary.
|
|
35
|
+
|
|
36
|
+
**Secrets never live in the config file.** Each source configures a token
|
|
37
|
+
*source*, in precedence order: `tokenCommand` (exec'd and cached ~5 minutes —
|
|
38
|
+
the fit for hourly-expiring GitHub App installation tokens, e.g. a
|
|
39
|
+
`gh-app-token.sh`-style minting script), `tokenFile` (read per call, so
|
|
40
|
+
rotation just works), or `tokenEnv` (read per call, so a wrapper can refresh
|
|
41
|
+
it). This mirrors OpenClaw's own secret-reference pattern: config carries a
|
|
42
|
+
reference, never the secret.
|
|
43
|
+
|
|
44
|
+
**Notifications are a hook, not a vendor.** `notifyCommand` under `[pickup]`
|
|
45
|
+
is exec'd on every verb transition with `LOBSTAH_KEY`, `LOBSTAH_UUID`,
|
|
46
|
+
`LOBSTAH_VERB`, `LOBSTAH_NOTE`, and `LOBSTAH_PR_URL` in the environment —
|
|
47
|
+
fire-and-forget, never blocking the loop. Point it at whatever the host
|
|
48
|
+
already has (a Slack helper, `openclaw message send`); lobstah stays free of
|
|
49
|
+
messaging vendors.
|
|
50
|
+
|
|
51
|
+
**No LLM in the loop.** Issue-to-descriptor translation is mechanical. Judgment
|
|
52
|
+
about an ambiguous issue belongs to the dispatched agent, which reports
|
|
53
|
+
`needs-decision` — not to pickup. Fleet setups that wake an LLM to poll a
|
|
54
|
+
tracker pay tokens for a mechanical translation; a deterministic program is
|
|
55
|
+
the right tool, and it keeps token cost proportional to real work.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Architecture
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
apps/pick/
|
|
63
|
+
sources/
|
|
64
|
+
linear/ # API key or OAuth app actor token
|
|
65
|
+
github/ # PAT or GitHub App installation token
|
|
66
|
+
loops/
|
|
67
|
+
dispatch/ # issues + review feedback → descriptors
|
|
68
|
+
reconcile/ # tracker state ⟷ lobstah state, both directions
|
|
69
|
+
merge/ # opt-in: merge approved PRs, dispatch rebases on conflict
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
One source interface, four methods:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
interface Source {
|
|
76
|
+
poll(): WorkItem[] // items matching the pickup rules
|
|
77
|
+
claim(item: WorkItem): boolean // tracker-side state transition
|
|
78
|
+
report(id: string, verb: Verb, evidence: Evidence): void
|
|
79
|
+
inbound(id: string): Message[] // new human comments since last poll
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A source translates tracker vocabulary to lobstah vocabulary and nothing else.
|
|
84
|
+
The three loops are tracker-agnostic and drive whichever sources are configured.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Dispatch loop
|
|
89
|
+
|
|
90
|
+
### Pickup rules
|
|
91
|
+
|
|
92
|
+
| Rule | Trigger | Descriptor |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| Issue pickup | Assigned to the configured identity, in the configured start state | Implementation brief from the issue |
|
|
95
|
+
| Review pickup | Open PR authored by the configured identity with `CHANGES_REQUESTED`, or a human review newer than HEAD | Address-review brief from the feedback |
|
|
96
|
+
|
|
97
|
+
A review dispatch sets `followUp` to the implementation dispatch's UUID,
|
|
98
|
+
forking that session so the feedback lands on the context that made the
|
|
99
|
+
choices. A rebase chore starts cold on purpose — the conflict is about commits
|
|
100
|
+
the original session never saw.
|
|
101
|
+
|
|
102
|
+
### Claiming
|
|
103
|
+
|
|
104
|
+
The tracker-side state transition is the cross-machine mutex. Moving the issue
|
|
105
|
+
to In Progress (or applying a claim label) is pickup's atomic rename: two
|
|
106
|
+
machines polling the same workspace cannot double-dispatch, because exactly one
|
|
107
|
+
claim succeeds.
|
|
108
|
+
|
|
109
|
+
Pickup owns the mapping from tracker item to dispatch UUID, in its own state
|
|
110
|
+
directory. This is the design's rule — whoever dispatched holds the mapping — and
|
|
111
|
+
it is what lets `report` and `reconcile` correlate without anything on the
|
|
112
|
+
tracker knowing lobstah's identifiers.
|
|
113
|
+
|
|
114
|
+
### Routing and briefs
|
|
115
|
+
|
|
116
|
+
The descriptor's `repo` key resolves from config. The tracker never carries
|
|
117
|
+
machine detail:
|
|
118
|
+
|
|
119
|
+
```toml
|
|
120
|
+
[pickup.linear]
|
|
121
|
+
assignField = "delegate" # agent token: Linear assigns agents via delegate
|
|
122
|
+
startState = "Todo"
|
|
123
|
+
route = { ENG = "myapp" } # team key → repo key
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
GitHub routing needs no map: with no `repo` named in `[pickup.github]`, every
|
|
127
|
+
`[repos.<key>]` that opted in with `pickup = true` and has a GitHub `origin`
|
|
128
|
+
is polled, and the repo key doubles as the routing key. Opt-in is explicit —
|
|
129
|
+
being configured for dispatch never makes a repo pickable by itself.
|
|
130
|
+
|
|
131
|
+
The brief is assembled from the item — title, description, comments to date —
|
|
132
|
+
through an optional per-repo template. The issue is the durable instruction's
|
|
133
|
+
source; `brief.md` in `active/<uuid>/` remains the durable instruction itself.
|
|
134
|
+
|
|
135
|
+
### Reporting
|
|
136
|
+
|
|
137
|
+
Six verbs map to tracker vocabulary. The mapping is per-source and total — a
|
|
138
|
+
verb with no mapping is a config error at startup, not a silent drop.
|
|
139
|
+
|
|
140
|
+
| Verb | Linear (default) | GitHub (default) |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `working` | In Progress + progress comment | comment |
|
|
143
|
+
| `needs-decision` | comment + `needs-human` label | comment + label |
|
|
144
|
+
| `blocked` | comment + `blocked` label | comment + label |
|
|
145
|
+
| `paused` | comment | comment |
|
|
146
|
+
| `done` | attach PR link, move to In Review | comment with PR link |
|
|
147
|
+
| `failed` | comment with evidence, back to Todo | comment with evidence |
|
|
148
|
+
|
|
149
|
+
### Inbox bridging
|
|
150
|
+
|
|
151
|
+
`inbound()` turns new human comments on a claimed item into
|
|
152
|
+
`inbox/<uuid>/NNN.msg` records. The tracker becomes the steering surface: a
|
|
153
|
+
comment on the Linear issue reaches the running agent through the same
|
|
154
|
+
between-turns delivery as any other inbox message — tracker-native steering
|
|
155
|
+
with none of the webhook plumbing.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Merge loop
|
|
160
|
+
|
|
161
|
+
Opt-in, per repo, off by default. Merging is policy, not mechanics, so it ships
|
|
162
|
+
disabled and its config is explicit about who qualifies.
|
|
163
|
+
|
|
164
|
+
```toml
|
|
165
|
+
[pickup.github.merge]
|
|
166
|
+
enabled = true
|
|
167
|
+
method = "squash"
|
|
168
|
+
approvers = ["alice"] # the floor: always qualify, on every PR
|
|
169
|
+
assigneeApproves = true # PR assignees also qualify…
|
|
170
|
+
restrictedLabels = ["risk:high"] # …except on PRs carrying any of these labels
|
|
171
|
+
scope = "own" # only PRs authored by the configured identity
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Who qualifies is monotone by construction.** The qualifying set is
|
|
175
|
+
`approvers`, plus the PR's assignees when `assigneeApproves` is on and no
|
|
176
|
+
restricted label is present. A restricted label collapses the set to
|
|
177
|
+
`approvers` — labels revoke the assignee relaxation, they never grant, replace,
|
|
178
|
+
or subtract from the floor. Multiple restricted labels therefore compose
|
|
179
|
+
trivially (any one collapses), and no label combination can make a PR
|
|
180
|
+
unmergeable by everyone. If a label ever needs its own named approvers, that is
|
|
181
|
+
a future *additive* grant key, not a replacement — replacement is how a policy
|
|
182
|
+
locks itself out.
|
|
183
|
+
|
|
184
|
+
The loop's doctrine, in full:
|
|
185
|
+
|
|
186
|
+
- **Re-validate at the moment of merge, not at the poll tick.** State shifts
|
|
187
|
+
between observation and action; the gate check runs against a fresh fetch
|
|
188
|
+
immediately before the merge call. Any failure aborts and reports — never
|
|
189
|
+
merge on stale data.
|
|
190
|
+
- **Trust the forge's merge-state rollup.** GitHub's `mergeStateStatus` already
|
|
191
|
+
encodes required-check semantics — `BLOCKED` means a required check failed,
|
|
192
|
+
`UNSTABLE` means only non-required checks failed and GitHub itself would
|
|
193
|
+
allow the merge. Re-implementing check-pass logic client-side is how you
|
|
194
|
+
drift from the forge's own rules.
|
|
195
|
+
- **Dedup by approval, not by PR.** A specific approval merges at most once; a
|
|
196
|
+
new push invalidates it and the gate waits for a fresh one.
|
|
197
|
+
- **Stacks merge through the forge's stack-aware path.** A PR in a native
|
|
198
|
+
stack goes through GitHub's asynchronous stack merge API; a standalone PR
|
|
199
|
+
through the ordinary merge call. `method` is per repo and applies to both.
|
|
200
|
+
- **`scope = "own"` is the safety default.** Pickup merges work it dispatched.
|
|
201
|
+
Widening to human-authored PRs is a deliberate config change.
|
|
202
|
+
|
|
203
|
+
### Conflicts dispatch, cleanly-behind updates
|
|
204
|
+
|
|
205
|
+
A PR behind its base splits deterministically:
|
|
206
|
+
|
|
207
|
+
| Condition | Action |
|
|
208
|
+
|---|---|
|
|
209
|
+
| Behind, no conflict | Update the branch through the forge API, re-enter the gate next tick |
|
|
210
|
+
| Behind, real conflict | Write a rebase chore — brief: rebase onto base, resolve, push — and re-enter the gate when it completes |
|
|
211
|
+
|
|
212
|
+
Rebase chores go through the **chore lane** (`~/.lobstah/chores/`, defined in
|
|
213
|
+
the [design's queue contract](design.md#queue-contract)), never the primary queue. Same descriptor schema,
|
|
214
|
+
same daemon, same supervision — but their own directories and their own
|
|
215
|
+
concurrency budget, so machine-generated maintenance can't crowd the work
|
|
216
|
+
queue, and `lobstah ls` stays a list of things a human asked for.
|
|
217
|
+
|
|
218
|
+
Chores report to no tracker. The merge loop consumes the chore's status file
|
|
219
|
+
directly, holds its own PR-to-chore mapping, and bounds the attempt at one: a
|
|
220
|
+
failed rebase comments on the PR, applies the `needs-human` label, and stops.
|
|
221
|
+
The doctrine stays whole — the deterministic program handles everything
|
|
222
|
+
mechanical, and the moment resolution requires judgment it becomes a
|
|
223
|
+
supervised dispatch. It just doesn't become *work*.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Reconciliation loop
|
|
228
|
+
|
|
229
|
+
The daemon owns dead and wedged *processes*. It cannot own tracker drift — an
|
|
230
|
+
item that claims In Progress while nothing anywhere backs it is invisible to a
|
|
231
|
+
component that, by design, has no tracker knowledge. The classification rule
|
|
232
|
+
holds one layer up: absence of signal never means fine.
|
|
233
|
+
|
|
234
|
+
Every poll, diff both directions:
|
|
235
|
+
|
|
236
|
+
| Drift | Detection | Action |
|
|
237
|
+
|---|---|---|
|
|
238
|
+
| Orphaned item | In Progress, attributed to pickup, no live or completed UUID behind it | Comment, reset to the start state |
|
|
239
|
+
| Orphaned dispatch | UUID active for an item now closed, reassigned, or de-scoped | Cancel the dispatch, note it in evidence |
|
|
240
|
+
| Lost report | Dispatch `done`/`failed`, tracker never updated | Replay the report — the state file is the durable record, the tracker write is the retryable notification |
|
|
241
|
+
|
|
242
|
+
An orphan verdict requires a trustworthy mapping — see
|
|
243
|
+
[Pickup state](#pickup-state): a missing table entry is `unknown` and triggers
|
|
244
|
+
a rebuild, never a reset.
|
|
245
|
+
|
|
246
|
+
**Residual, by honesty:** same-machine reconciliation cannot detect
|
|
247
|
+
whole-machine death — the reconciler dies with the laptop. The stale
|
|
248
|
+
`executor.json` heartbeat covers that case, but only for a remote reader — a
|
|
249
|
+
second machine's pickup, or a remote dispatcher. This is parity with the
|
|
250
|
+
cron-script fleets it replaces, whose watchers also died with their host.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Merge view
|
|
255
|
+
|
|
256
|
+
The merge loop is already fetching the forge's view of every candidate PR each
|
|
257
|
+
tick — so it persists what it saw (`pickup/merge-view.json`): per open PR the
|
|
258
|
+
head sha, mergeable state, and gate verdict (`waiting-approval`,
|
|
259
|
+
`behind-updated`, `conflict-chore:<uuid>`, `rebase-failed`, `blocked`,
|
|
260
|
+
`draft`), and per PR that *left* the open set, its disposition — one extra
|
|
261
|
+
lookup answers whether it merged or closed, so "merged in the last 24h" is
|
|
262
|
+
complete even when a human pressed the button.
|
|
263
|
+
|
|
264
|
+
This is what lets a status view report PR state without its own forge calls:
|
|
265
|
+
`lobstah man tend` and any dashboard read the file, at most one poll interval
|
|
266
|
+
stale. Observational, not load-bearing — deleting it loses nothing but
|
|
267
|
+
history. It exists only where the merge loop runs (merge enabled).
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Pickup state
|
|
272
|
+
|
|
273
|
+
Pickup's own state — the tracker-item-to-UUID mapping the loops correlate
|
|
274
|
+
through, the merge loop's PR-to-chore table, per-source poll cursors — lives
|
|
275
|
+
under `~/.lobstah/pickup/`, written with the same atomic-rename discipline as
|
|
276
|
+
everything else on disk.
|
|
277
|
+
|
|
278
|
+
The mapping is load-bearing for all three loops, so it gets durability by
|
|
279
|
+
design rather than by trust:
|
|
280
|
+
|
|
281
|
+
- **Reconstructible.** Every report comment pickup writes to a tracker embeds
|
|
282
|
+
the dispatch UUID. A lost table rebuilds from the tracker trail plus
|
|
283
|
+
`state/` — the durable-record, retryable-notification rule applied to
|
|
284
|
+
pickup's own memory.
|
|
285
|
+
- **Missing is `unknown`, never orphaned.** Reconciliation must not reset an
|
|
286
|
+
In Progress item because the table lacks an entry for it. A missing mapping
|
|
287
|
+
triggers a rebuild, and only a rebuilt table may declare an orphan. Losing a
|
|
288
|
+
file must never cancel live work.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## What pickup replaces
|
|
293
|
+
|
|
294
|
+
The typical hand-rolled fleet is a pile of cron/launchd scripts. One polls
|
|
295
|
+
the tracker and dispatches a headless agent. One watches PRs for review
|
|
296
|
+
feedback. One merges approved PRs. One hunts for issues marked in-progress
|
|
297
|
+
that nothing is working on. Each dispatch also re-arms its own cron check.
|
|
298
|
+
Pickup's three loops and the daemon absorb that whole layer:
|
|
299
|
+
|
|
300
|
+
| Fleet-script job | Fate |
|
|
301
|
+
|---|---|
|
|
302
|
+
| Poll tracker, dispatch assigned issues | Dispatch loop, issue rule |
|
|
303
|
+
| Watch PRs for review feedback | Dispatch loop, review rule |
|
|
304
|
+
| Per-dispatch outcome checks and cron re-arms | Daemon supervision + `report` |
|
|
305
|
+
| Merge approved PRs | Merge loop |
|
|
306
|
+
| Detect in-progress issues nothing backs | Dead/wedged half → daemon; tracker-drift half → reconciliation loop |
|
|
307
|
+
| Triage, escalation policy, answering humans | Not pickup's. Judgment stays agent-side |
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Non-goals
|
|
312
|
+
|
|
313
|
+
- No webhooks, no inbound listener, no tunnel — ever. Inbound networking is the
|
|
314
|
+
category's tax; not paying it is the point.
|
|
315
|
+
- No LLM in any loop. The moment a loop needs judgment, it dispatches.
|
|
316
|
+
- No triage. Pickup acts on items already assigned and staged; deciding what
|
|
317
|
+
deserves an agent is upstream of it.
|
|
318
|
+
- No cross-tracker abstraction leakage into core. Sources normalize at the
|
|
319
|
+
pickup boundary; the queue contract stays tracker-free.
|