lobstah 0.1.1 → 0.2.0

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.
@@ -0,0 +1,211 @@
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, the PR's merge-gate status, and any external source the chain is
90
+ watching (a ume review session, a CI run). Watches join the same way the
91
+ merge view does — from disk. A man-owned watch with unconsumed events counts
92
+ as `needs-attention` with its age; a dispatch-owned one annotates its story
93
+ (the wake is machinery's job, not the human's). Gate status comes from the [merge
94
+ view](pickup.md#merge-view) pickup persists each tick, so PR state is at most
95
+ one poll interval stale without tend making a single network call. `--json`
96
+ emits the full report for dashboards and scripts to render.
97
+
98
+ ## Getting woken instead of asked
99
+
100
+ Three escalation tiers, least to most invasive. All are built on
101
+ `lobstah man wait`: block until a dispatch — or a watched external source
102
+ (`lobstah watch add`, e.g. a ume review session) — needs attention, print the
103
+ event and what to do next, exit. It is **level-triggered for attention
104
+ states** — if a `needs-decision` or an unconsumed watch event is already
105
+ standing when it starts, it returns immediately — so a gap between one
106
+ watcher exiting and the next arming can never lose an event. When no pick
107
+ process is running, `man wait` runs due watch checks itself, so watching
108
+ works with every service stopped.
109
+
110
+ **Tier 1 — a push for the human.** Set the daemon's hook and forget it:
111
+
112
+ ```toml
113
+ notifyCommand = "ntfy pub my-topic \"$LOBSTAH_VERB $LOBSTAH_ID: $LOBSTAH_NOTE\""
114
+ ```
115
+
116
+ **Tier 2 — a background watcher in the liaison session.** The liaison runs
117
+ `lobstah man wait` as a background task; when it exits, the harness's task
118
+ notification wakes the session, and the printed `next:` line tells the agent
119
+ exactly what to do — including re-arming. One watcher per wake is inherent to
120
+ background tasks; the level-trigger makes the re-arm race harmless. Add to the
121
+ liaison instructions:
122
+
123
+ ```markdown
124
+ After dispatching work, run `lobstah man wait` as a background task. When it
125
+ completes, follow its `next:` instruction, then re-arm it.
126
+ ```
127
+
128
+ **Tier 3 — park the session on a Stop hook (Claude Code only).** A Stop hook
129
+ that blocks on `lobstah man wait`, so the session never
130
+ really idles — it parks for free and continues the moment something needs it.
131
+ What this buys over tier 2 is not the wake. Both wake on events, and both
132
+ cost a turn per wake. The difference: the re-arm is **structural instead of
133
+ instructed**. Tier 2 works only as long as the model remembers to re-arm the
134
+ watcher after every wake. A forgotten re-arm, a crashed watcher, or an
135
+ interrupted turn leaves the session deaf until a human speaks. The Stop hook
136
+ fires at every turn end, no matter what the model did. Supervision cannot
137
+ lapse through instruction drift, and a blind stop mid-shift is impossible.
138
+
139
+ The hook is a CLI command — `lobstah man haul` (the lobsterman hauls the
140
+ trapline; every orchestrator-facing command lives under `lobstah man`).
141
+ Install it from the project you'll run the lobsterman in:
142
+
143
+ ```bash
144
+ # Easiest: the Claude Code plugin ships the hook + the lobsterman skill +
145
+ # /lobstah, no settings edits — /plugin marketplace add aequitas-labs/lobstah,
146
+ # /plugin install lobstah@lobstah. Or wire the hook by hand:
147
+ lobstah man init # merges the Stop hook into .claude/settings.local.json
148
+ lobstah man init --shared # …or the committed .claude/settings.json
149
+ lobstah man init --global # …or once into ~/.claude/settings.json — any
150
+ # directory with a .lobstah-man file then parks
151
+ lobstah man init --marker # also touch .lobstah-man (per-directory gate)
152
+ ```
153
+
154
+ Idempotent, and it only appends to `hooks.Stop` — existing hooks and settings
155
+ are preserved verbatim. What it writes:
156
+
157
+ ```json
158
+ { "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "lobstah man haul", "timeout": 14400 }] }] } }
159
+ ```
160
+
161
+ `haul` gates itself twice: only a designated session parks (launch it with
162
+ `LOBSTAH_MAN=1 claude`, or `touch .lobstah-man` for a per-directory gate), and
163
+ only while dispatches are in flight — conversational turns end free. On an
164
+ event it blocks the stop with the event as context and tells the agent the
165
+ session re-parks automatically; on timeout or any error it silently allows
166
+ the stop, leaving tier 1 as the backstop past the horizon.
167
+
168
+ Two habits worth adding to a lobsterman session's instructions: run
169
+ `lobstah man wait --peek` at session start (a wake consumed by a session that
170
+ died mid-handling is still standing state — peek resurfaces it), and treat
171
+ the haul context as the work order for that turn.
172
+
173
+ The trade-offs, honestly. While parked, the turn never ends, so the terminal
174
+ shows a running hook. Each wake appends a turn to the context, and long
175
+ shifts eventually compact. Without the gate, the hook parks every session in
176
+ the project. And parking is Claude-specific: it needs a turn-end hook that
177
+ can block and inject a continuation. Claude Code's Stop hook can. Codex's
178
+ fire-and-forget notify hook cannot. The workers can still be any harness —
179
+ only the liaison must be Claude Code. Tier 2 is the right default. Tier 3 is
180
+ for a dedicated, long-lived liaison session.
181
+
182
+ **Delivery guarantee.** Attention wakes are at-least-once with backoff. An
183
+ unanswered question is reported immediately. While it still stands, it
184
+ re-fires as a reminder every `remindSecs` (top-level config, default 900). So
185
+ a wake consumed by a session that died mid-handling resurfaces on its own.
186
+ Answering ends the reminders naturally, because the answer produces a new
187
+ status entry. Set `remindSecs = 0` for pure at-most-once.
188
+
189
+ **Any-harness fallback — the wrapper loop.** Tiers 2 and 3 lean on Claude
190
+ Code features (background-task notifications, the Stop hook). For any other
191
+ harness — or no interactive session at all — an outer loop blocking on `wait`
192
+ spawns one fresh headless turn per event:
193
+
194
+ ```sh
195
+ while out=$(lobstah man wait); do
196
+ codex exec "A dispatch needs attention: $out — handle it with the lobstah CLI."
197
+ done
198
+ ```
199
+
200
+ Zero tokens between events and works anywhere a shell does; the cost is that
201
+ each event gets a fresh context rather than a continuing liaison
202
+ conversation.
203
+
204
+ ## What the daemon gives your liaison for free
205
+
206
+ - Parallel work that can't collide — worktree per dispatch.
207
+ - A crew that survives crashes: dead runners respawn (bounded), wedged ones
208
+ are killed and forked with a nudge, and everything reconciles from disk
209
+ after a reboot.
210
+ - An honest six-verb status contract, validated at the write path, so the
211
+ liaison never has to parse prose to know where things stand.
@@ -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,358 @@
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
+ ## Watch loop
160
+
161
+ Pick's third loop family: standing outbound polls on anything external with
162
+ a CLI that can answer "anything new since cursor N?" — a ume review session,
163
+ a CI run. Registration goes through `lobstah watch add <key> --check <cmd>`
164
+ (the validated write path; the check contract and word set live in
165
+ [vocabulary.md](vocabulary.md#watch-contract)):
166
+
167
+ ```bash
168
+ # an interactive session pushes a plan for review, then stops blocking on it
169
+ lobstah watch add ume:9f2c --check 'ume events 9f2c --since {cursor} --json'
170
+
171
+ # a worker pauses on a review round; events fork a continuation of its chain
172
+ lobstah watch add ume:9f2c --check '...' --for <dispatch-uuid>
173
+ ```
174
+
175
+ Each cycle: run due checks (cadence = `[pickup].pollSecs`, or `--every` per
176
+ watch), append events, deliver by owner. Man-owned events surface through
177
+ `man wait`/`man haul` and fire `notifyCommand` with verb `watch`;
178
+ dispatch-owned events enqueue a continuation that forks the latest session
179
+ in the owning chain — one in flight per watch, later rounds buffer. With no
180
+ tracker sources configured, `lobstah pick` still runs in watch-only mode.
181
+ `man wait` runs due checks itself when no pick process is stamping them, so
182
+ watches work with every service stopped.
183
+
184
+ **Streams — the latency optimization.** `--stream <cmd>` names a long-lived
185
+ process (spawned with `{cursor}` substituted) that emits the same event
186
+ objects as NDJSON lines, plus bare `{"cursor": "N"}` checkpoints. Pick holds
187
+ one child per streaming watch and delivers each line the moment it arrives —
188
+ milliseconds instead of the poll interval. The queue contract's rule applies
189
+ one layer up: **watch as an optimization, poll as the guarantee** — appends
190
+ dedupe by `seq`, so the cadence check re-seeing a streamed event is a no-op,
191
+ and a dead stream just means cadence-only until the next cycle respawns it.
192
+ Stream and cadence events feed one serialized executor, so pick's state stays
193
+ single-writer. The daemon completes the fast path: it fs.watches the queue
194
+ directories, so a continuation enqueued by a stream event is claimed in
195
+ milliseconds too — end to end, an external event reaches a spawning session
196
+ in roughly harness start-up time.
197
+
198
+ ## Merge loop
199
+
200
+ Opt-in, per repo, off by default. Merging is policy, not mechanics, so it ships
201
+ disabled and its config is explicit about who qualifies.
202
+
203
+ ```toml
204
+ [pickup.github.merge]
205
+ enabled = true
206
+ method = "squash"
207
+ approvers = ["alice"] # the floor: always qualify, on every PR
208
+ assigneeApproves = true # PR assignees also qualify…
209
+ restrictedLabels = ["risk:high"] # …except on PRs carrying any of these labels
210
+ scope = "own" # only PRs authored by the configured identity
211
+ ```
212
+
213
+ **Who qualifies is monotone by construction.** The qualifying set is
214
+ `approvers`, plus the PR's assignees when `assigneeApproves` is on and no
215
+ restricted label is present. A restricted label collapses the set to
216
+ `approvers` — labels revoke the assignee relaxation, they never grant, replace,
217
+ or subtract from the floor. Multiple restricted labels therefore compose
218
+ trivially (any one collapses), and no label combination can make a PR
219
+ unmergeable by everyone. If a label ever needs its own named approvers, that is
220
+ a future *additive* grant key, not a replacement — replacement is how a policy
221
+ locks itself out.
222
+
223
+ The loop's doctrine, in full:
224
+
225
+ - **Re-validate at the moment of merge, not at the poll tick.** State shifts
226
+ between observation and action; the gate check runs against a fresh fetch
227
+ immediately before the merge call. Any failure aborts and reports — never
228
+ merge on stale data.
229
+ - **Trust the forge's merge-state rollup.** GitHub's `mergeStateStatus` already
230
+ encodes required-check semantics — `BLOCKED` means a required check failed,
231
+ `UNSTABLE` means only non-required checks failed and GitHub itself would
232
+ allow the merge. Re-implementing check-pass logic client-side is how you
233
+ drift from the forge's own rules.
234
+ - **Dedup by approval, not by PR.** A specific approval merges at most once; a
235
+ new push invalidates it and the gate waits for a fresh one.
236
+ - **Stacks merge through the forge's stack-aware path.** A PR in a native
237
+ stack goes through GitHub's asynchronous stack merge API; a standalone PR
238
+ through the ordinary merge call. `method` is per repo and applies to both.
239
+ - **`scope = "own"` is the safety default.** Pickup merges work it dispatched.
240
+ Widening to human-authored PRs is a deliberate config change.
241
+
242
+ ### Conflicts dispatch, cleanly-behind updates
243
+
244
+ A PR behind its base splits deterministically:
245
+
246
+ | Condition | Action |
247
+ |---|---|
248
+ | Behind, no conflict | Update the branch through the forge API, re-enter the gate next tick |
249
+ | Behind, real conflict | Write a rebase chore — brief: rebase onto base, resolve, push — and re-enter the gate when it completes |
250
+
251
+ Rebase chores go through the **chore lane** (`~/.lobstah/chores/`, defined in
252
+ the [design's queue contract](design.md#queue-contract)), never the primary queue. Same descriptor schema,
253
+ same daemon, same supervision — but their own directories and their own
254
+ concurrency budget, so machine-generated maintenance can't crowd the work
255
+ queue, and `lobstah ls` stays a list of things a human asked for.
256
+
257
+ Chores report to no tracker. The merge loop consumes the chore's status file
258
+ directly, holds its own PR-to-chore mapping, and bounds the attempt at one: a
259
+ failed rebase comments on the PR, applies the `needs-human` label, and stops.
260
+ The doctrine stays whole — the deterministic program handles everything
261
+ mechanical, and the moment resolution requires judgment it becomes a
262
+ supervised dispatch. It just doesn't become *work*.
263
+
264
+ ---
265
+
266
+ ## Reconciliation loop
267
+
268
+ The daemon owns dead and wedged *processes*. It cannot own tracker drift — an
269
+ item that claims In Progress while nothing anywhere backs it is invisible to a
270
+ component that, by design, has no tracker knowledge. The classification rule
271
+ holds one layer up: absence of signal never means fine.
272
+
273
+ Every poll, diff both directions:
274
+
275
+ | Drift | Detection | Action |
276
+ |---|---|---|
277
+ | Orphaned item | In Progress, attributed to pickup, no live or completed UUID behind it | Comment, reset to the start state |
278
+ | Orphaned dispatch | UUID active for an item now closed, reassigned, or de-scoped | Cancel the dispatch, note it in evidence |
279
+ | 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 |
280
+
281
+ An orphan verdict requires a trustworthy mapping — see
282
+ [Pickup state](#pickup-state): a missing table entry is `unknown` and triggers
283
+ a rebuild, never a reset.
284
+
285
+ **Residual, by honesty:** same-machine reconciliation cannot detect
286
+ whole-machine death — the reconciler dies with the laptop. The stale
287
+ `executor.json` heartbeat covers that case, but only for a remote reader — a
288
+ second machine's pickup, or a remote dispatcher. This is parity with the
289
+ cron-script fleets it replaces, whose watchers also died with their host.
290
+
291
+ ---
292
+
293
+ ## Merge view
294
+
295
+ The merge loop is already fetching the forge's view of every candidate PR each
296
+ tick — so it persists what it saw (`pickup/merge-view.json`): per open PR the
297
+ head sha, mergeable state, and gate verdict (`waiting-approval`,
298
+ `behind-updated`, `conflict-chore:<uuid>`, `rebase-failed`, `blocked`,
299
+ `draft`), and per PR that *left* the open set, its disposition — one extra
300
+ lookup answers whether it merged or closed, so "merged in the last 24h" is
301
+ complete even when a human pressed the button.
302
+
303
+ This is what lets a status view report PR state without its own forge calls:
304
+ `lobstah man tend` and any dashboard read the file, at most one poll interval
305
+ stale. Observational, not load-bearing — deleting it loses nothing but
306
+ history. It exists only where the merge loop runs (merge enabled).
307
+
308
+ ---
309
+
310
+ ## Pickup state
311
+
312
+ Pickup's own state — the tracker-item-to-UUID mapping the loops correlate
313
+ through, the merge loop's PR-to-chore table, per-source poll cursors — lives
314
+ under `~/.lobstah/pickup/`, written with the same atomic-rename discipline as
315
+ everything else on disk.
316
+
317
+ The mapping is load-bearing for all three loops, so it gets durability by
318
+ design rather than by trust:
319
+
320
+ - **Reconstructible.** Every report comment pickup writes to a tracker embeds
321
+ the dispatch UUID. A lost table rebuilds from the tracker trail plus
322
+ `state/` — the durable-record, retryable-notification rule applied to
323
+ pickup's own memory.
324
+ - **Missing is `unknown`, never orphaned.** Reconciliation must not reset an
325
+ In Progress item because the table lacks an entry for it. A missing mapping
326
+ triggers a rebuild, and only a rebuilt table may declare an orphan. Losing a
327
+ file must never cancel live work.
328
+
329
+ ---
330
+
331
+ ## What pickup replaces
332
+
333
+ The typical hand-rolled fleet is a pile of cron/launchd scripts. One polls
334
+ the tracker and dispatches a headless agent. One watches PRs for review
335
+ feedback. One merges approved PRs. One hunts for issues marked in-progress
336
+ that nothing is working on. Each dispatch also re-arms its own cron check.
337
+ Pickup's three loops and the daemon absorb that whole layer:
338
+
339
+ | Fleet-script job | Fate |
340
+ |---|---|
341
+ | Poll tracker, dispatch assigned issues | Dispatch loop, issue rule |
342
+ | Watch PRs for review feedback | Dispatch loop, review rule |
343
+ | Per-dispatch outcome checks and cron re-arms | Daemon supervision + `report` |
344
+ | Merge approved PRs | Merge loop |
345
+ | Detect in-progress issues nothing backs | Dead/wedged half → daemon; tracker-drift half → reconciliation loop |
346
+ | Triage, escalation policy, answering humans | Not pickup's. Judgment stays agent-side |
347
+
348
+ ---
349
+
350
+ ## Non-goals
351
+
352
+ - No webhooks, no inbound listener, no tunnel — ever. Inbound networking is the
353
+ category's tax; not paying it is the point.
354
+ - No LLM in any loop. The moment a loop needs judgment, it dispatches.
355
+ - No triage. Pickup acts on items already assigned and staged; deciding what
356
+ deserves an agent is upstream of it.
357
+ - No cross-tracker abstraction leakage into core. Sources normalize at the
358
+ pickup boundary; the queue contract stays tracker-free.