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.
@@ -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.
@@ -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.