lobstah 0.3.2 → 0.5.5

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.
Binary file
Binary file
Binary file
Binary file
@@ -14,6 +14,7 @@ keys of that section.
14
14
  |---|---|---|
15
15
  | `notifyCommand` | — | Exec'd by the daemon on wake-worthy status transitions with `LOBSTAH_ID`, `LOBSTAH_LANE`, `LOBSTAH_VERB`, `LOBSTAH_NOTE`, `LOBSTAH_AT` in the environment. Fire-and-forget; point it at ntfy, a Slack helper, anything. |
16
16
  | `notifyVerbs` | `["needs-decision", "blocked", "done", "failed"]` | Which verbs fire `notifyCommand`. |
17
+ | `attentionKinds` | `["question", "pr:draft", "pr:review", "pr:checks", "pr:ready"]` | Which attention kinds `man tend` lists — and so what the desktop pet and the glass walk across the screen. Valid kinds: `question`, `landed` (opt-in), `pr:draft`, `pr:review`, `pr:checks`, `pr:ready`; an unknown kind is a config error naming the valid set. Notify is edge-triggered and fires once per transition; attention is level-triggered and stands until its clear condition ([vocabulary.md](vocabulary.md#attention-contract)). |
17
18
  | `remindSecs` | `900` | An unanswered `needs-decision`/`blocked` re-fires to `man wait`/`man haul` on this interval until answered. `0` = report once only. |
18
19
 
19
20
  ## `[repos.<key>]` — workspace definitions
@@ -52,6 +53,7 @@ Same three keys as the per-repo block. Precedence for every harness setting:
52
53
  | `maxRestartAttempts` | `2` | Bounded restart ladder for dead and wedged runners. |
53
54
  | `wallClockSecs` | `3600` | Hard per-dispatch ceiling, enforced by the runner. |
54
55
  | `choreRetentionDays` | `7` | Completed chores age out of `chores/done/`. |
56
+ | `attachmentMaxBytes` | `26214400` (25 MiB) | Maximum size of each file supplied with repeatable `dispatch --attach` or `send --attach`. |
55
57
 
56
58
  ## `[soak]` — soaking sessions (`lobstah soak`)
57
59
 
@@ -60,6 +62,28 @@ Same three keys as the per-repo block. Precedence for every harness setting:
60
62
  | `deferSecs` | `90` | A soaking session whose park heartbeat is this fresh holds unaddressed matching bait — the daemon waits instead of spawning. Addressed bait (`--for session:<id>`) waits regardless, until the registration is gone. |
61
63
  | `ttlSecs` | `1800` | Heartbeat age past which a registration is a ghost trap: the sweep removes it and requeues its open catch (or finalizes a cancelled one as failed). A fresh `lobstah report` on the catch counts as liveness too. |
62
64
 
65
+ ## `[helm]` — the orchestrator seat (`lobstah man helm`)
66
+
67
+ | Key | Default | Meaning |
68
+ |---|---|---|
69
+ | `ttlSecs` | `1800` | Heartbeat age past which a helm registration is stale: the next `man helm` claims it without `--take`. The park and `man brief` heartbeat it. |
70
+ | `reportSecs` | `900` | Minimum seconds between park-delivered digests for a helm session. The digest is also change-gated — quiet grounds deliver nothing regardless of cadence. |
71
+
72
+ ## `[grounds.*]` — helm territories
73
+
74
+ One helm per grounds; a repo belongs to at most one grounds (`man helm`
75
+ refuses on overlap or an unknown repo key). With no `[grounds.*]` configured
76
+ there is one implicit `fleet` grounds covering every repo — the partition
77
+ only exists when asked for.
78
+
79
+ ```toml
80
+ [grounds.base]
81
+ repos = ["homebase", "matcha"]
82
+
83
+ [grounds.aequitas]
84
+ repos = ["lobstah", "lavish"]
85
+ ```
86
+
63
87
  ## `[pickup]` — tracker loops (`lobstah pick`)
64
88
 
65
89
  | Key | Default | Meaning |
package/docs/design.md CHANGED
@@ -444,7 +444,13 @@ task's diff. That contaminates evidence and falsely serializes merges.
444
444
 
445
445
  The daemon spawns one runner per dispatch with `setsid`, then records its pid and
446
446
  process start time. The runner drives the adapter; the daemon supervises the
447
- runner and never touches the harness directly.
447
+ runner and never touches the harness directly. A turn that ends with the
448
+ worker's last report on `needs-decision`, `blocked`, or `paused` does not end
449
+ the session: a headless worker waiting on a question stays alive until
450
+ answered, cancelled, or the wall clock — the runner polls the inbox, touches the
451
+ event stream each poll so the wedge detector sees a live wait, delivers the
452
+ answer into the next turn, and stamps `working`. Only a turn ending on `working`
453
+ (or no report) with an empty inbox ends the session and is stamped `done`.
448
454
 
449
455
  ```ts
450
456
  // inside the runner
@@ -18,6 +18,17 @@ The liaison never watches the workers — the daemon does that with no model in
18
18
  the loop. The liaison reads `lobstah status` when you ask, which is the
19
19
  token-efficiency point: supervision is a filesystem read, not a conversation.
20
20
 
21
+ **The helm is harness-agnostic: drive the fleet from whichever session you
22
+ prefer.** The contract is the CLI, not the harness — a Claude Code session,
23
+ a Codex session (hooks since v0.114), or anything with a terminal via the
24
+ foreground loop (`man wait` for the lobsterman, `soak --wait` for workers)
25
+ holds the same seat with the same verbs. Workers are equally mixed:
26
+ dispatches pick their harness per item (`--harness claude|codex`), so a
27
+ Codex helm can run Claude workers and the reverse. Sign-on records which
28
+ harness took the helm, and `swap` moves an in-flight dispatch across
29
+ harnesses mid-stream — the worktree, not the conversation, is the durable
30
+ layer.
31
+
21
32
  ## Set it up
22
33
 
23
34
  1. Install lobstah, configure your repos, start the daemon
@@ -95,6 +106,106 @@ view](pickup.md#merge-view) pickup persists each tick, so PR state is at most
95
106
  one poll interval stale without tend making a single network call. `--json`
96
107
  emits the full report for dashboards and scripts to render.
97
108
 
109
+ ### PR state after done
110
+
111
+ A dispatch reports `done` when its PR opens; `report done --pr <url>`
112
+ registers a `pr:` watch for the chain so the PR stays observed (see the
113
+ PR preset in [vocabulary.md](vocabulary.md#the-pr-preset); `--no-watch`
114
+ opts out). What you get depends on what runs:
115
+
116
+ - **Only the helm park or `man wait`** (no service): PR state badges in
117
+ `man tend`, `lobstah catch`, and the glass (`merged`, `draft`, `review`,
118
+ `checks 1/2 failed`, `green`), and a `pr-merged` / `pr-closed` notice
119
+ when the PR lands. The inline poller observes at `[pickup].pollSecs`
120
+ (default 45 s), one `gh pr view` per PR per cycle, and forks nothing.
121
+ - **With `lobstah pick`** (watch-only mode needs no tracker; `lobstah pick
122
+ install` runs it as a service for a steady cadence): all of the above,
123
+ plus CI-fix continuations — a failing check forks the chain with a brief
124
+ naming the PR, the check, its details URL, and the head sha — and
125
+ review-decision continuations for repos `[pickup.github]` doesn't cover.
126
+
127
+ `gh` must be on PATH and authenticated; if it isn't, the done report still
128
+ succeeds and the watch's check records `lastError`.
129
+
130
+ Watching a PR nobody dispatched — `lobstah watch add pr:<owner>/<repo>#<n>`
131
+ with no `--for` — is how a helm follows a human's PR, or one whose
132
+ dispatch chain was culled. Every observation lands in a PR record keyed by
133
+ the PR, so it shows in the glass PRs tab and stacks and in tend's `pr:*`
134
+ attention kinds exactly like a dispatched PR (its dispatch chain column is
135
+ empty). It stays quiet while it's fine: only a failing check or a changes
136
+ request surfaces as a watch event; a merge or close arrives as a notice.
137
+
138
+ An observed PR joins tend's attention list by kind — `pr:draft`,
139
+ `pr:review` (unresolved threads or changes requested), `pr:checks` (a red
140
+ head), `pr:ready` (approved or all green) — so it crawls in the glass and
141
+ the desktop pet until its clear condition holds; clicking it opens the PR.
142
+ `pr:review` and `pr:checks` stay off the screen while a worker already owns
143
+ them (a pickup feedback round or the watch's fix continuation in flight).
144
+ `attentionKinds` in `config.toml` picks the kinds; `landed` is opt-in. See
145
+ the [attention contract](vocabulary.md#attention-contract). A PR is
146
+ something to look at, not a stall: it never flips the verdict to
147
+ `needs-attention` and stays out of the digest.
148
+
149
+ ### The spyglass
150
+
151
+ `lobstah glass [--port <n>]` serves tend as a live web page on 127.0.0.1
152
+ (default port 4949): the fleet verdict and attention questions, every
153
+ dispatch with its full brief, status log, inbox, and evidence, each trap
154
+ with its lifecycle notices, message history, and catches, the notices
155
+ tail, the merge view, and watches — with filters, a table/cards toggle,
156
+ and the helm identified by name. It is strictly read-only and consumes no
157
+ cursor: looking through the glass changes nothing, so it needs no helm and
158
+ threatens nothing. Links out are copyable commands (`lobstah attach`,
159
+ `claude --resume`), never click-to-exec — localhost HTTP is reachable by
160
+ any webpage, so the glass exposes no endpoint that acts. The ⚙ popover's two
161
+ preferences — table or cards, and whether lobsters crawl the page — are
162
+ per-browser, kept in that browser's localStorage and never on disk.
163
+
164
+ This is where "is the agent alive?" belongs: the helm's heartbeat age on a
165
+ page, not periodic proof-of-life turns in a transcript.
166
+
167
+ ### The periodic report
168
+
169
+ `lobstah man tend` is the full picture on demand; `lobstah man report` is the
170
+ **delta** since the last acknowledged report — catches landed (with their
171
+ notes and PRs), attention newly arisen, what still waits, and the fleet
172
+ verdict. It advances a "reported through" cursor when it prints — the
173
+ explicit acknowledgment — so nothing is ever reported twice, and it says
174
+ `no change` when the delta is empty rather than re-dumping state. Standing
175
+ unanswered questions appear under `still-waiting` without counting as
176
+ change — reminders (`remindSecs`) own re-firing those.
177
+
178
+ Delivery is at-least-once by construction: the carriers that might not be
179
+ read (a `man wait` timeout in a background task) only **peek** at the delta,
180
+ so a digest lost with a dead task re-surfaces on the next timeout; only
181
+ `man report` (or a hook-delivered park digest, which lands in-context by
182
+ construction) marks it handled.
183
+
184
+ Every carrier shares the cursor (per grounds, for a helm):
185
+
186
+ - **The wait loop.** A `man wait` timeout (exit 3) prints the delta when
187
+ something changed, so a looping session gets periodic fleet reports for
188
+ free — see the loop idiom below.
189
+ - **The park.** A helm session's Stop-hook park delivers the digest as a wake
190
+ at `[helm].reportSecs` cadence — including the landed-then-idle case, where
191
+ the last catches finish and nothing is left in flight to wake for.
192
+ - **Direct call.** Anything with a clock — a gateway heartbeat, a cron — runs
193
+ `lobstah man report` and forwards the output when it is not `no change`.
194
+ Escalating a report to a human (a phone push) is the gateway layer's job,
195
+ not lobstah's.
196
+
197
+ The loop idiom needs no harness scheduler, because the timer is lobstah's own
198
+ blocking wait:
199
+
200
+ ```
201
+ lobstah man wait --timeout 900
202
+ # exit 0 → an event printed; handle it, then loop
203
+ # exit 3 → timeout; the delta digest printed above it when something changed
204
+ ```
205
+
206
+ A session running this loop reports into its own transcript whether or not a
207
+ human is watching — come back later and the transcript is the report.
208
+
98
209
  ## Getting woken instead of asked
99
210
 
100
211
  Three escalation tiers, least to most invasive. All are built on
@@ -153,7 +264,7 @@ trapline; every orchestrator-facing command lives under `lobstah man`).
153
264
  Install it from the project you'll run the lobsterman in:
154
265
 
155
266
  ```bash
156
- # Easiest: the plugin ships the hooks + the lobsterman skill, no settings
267
+ # Easiest: the plugin ships the hooks + the lobsterman and trap skills, no settings
157
268
  # edits — /plugin marketplace add aequitas-labs/lobstah, then
158
269
  # /plugin install lobstah@lobstah (Claude Code and Codex v0.114+; Codex asks
159
270
  # for a one-time hook trust review). Or wire the Claude hook by hand:
@@ -173,14 +284,16 @@ are preserved verbatim. What it writes:
173
284
 
174
285
  `haul` gates itself twice: only a designated session parks (launch it with
175
286
  `LOBSTAH_MAN=1 claude`, or `touch .lobstah-man` for a per-directory gate), and
176
- only while dispatches are in flight — conversational turns end free. On an
177
- event it blocks the stop with the event as context and tells the agent the
178
- session re-parks automatically; on timeout or any error it silently allows
179
- the stop, leaving tier 1 as the backstop past the horizon.
287
+ only while dispatches are in flight — queued dispatches count. Conversational
288
+ turns end free. On an event it blocks the stop with the event as context and
289
+ tells the agent the session re-parks automatically; on timeout or any error
290
+ it silently allows the stop, leaving tier 1 as the backstop past the horizon.
180
291
 
181
292
  Two habits worth adding to a lobsterman session's instructions: run
182
293
  `lobstah man wait --peek` at session start (a wake consumed by a session that
183
- died mid-handling is still standing state — peek resurfaces it), and treat
294
+ died mid-handling is still standing state — peek resurfaces it without
295
+ consuming it, and returns at once with `standing: none` and exit 0 when
296
+ nothing is; it never blocks, so it takes no `--timeout`), and treat
184
297
  the haul context as the work order for that turn.
185
298
 
186
299
  The trade-offs, honestly. While parked, the turn never ends, so the terminal
@@ -196,8 +309,23 @@ Tier 3 is for a dedicated, long-lived liaison session.
196
309
  unanswered question is reported immediately. While it still stands, it
197
310
  re-fires as a reminder every `remindSecs` (top-level config, default 900). So
198
311
  a wake consumed by a session that died mid-handling resurfaces on its own.
199
- Answering ends the reminders naturally, because the answer produces a new
200
- status entry. Set `remindSecs = 0` for pure at-most-once.
312
+ Answering ends the reminders: a message to the dispatch newer than its
313
+ question — `lobstah send`, a forwarded tracker comment, anything written
314
+ through the inbox with provenance — means the question no longer stands, even
315
+ before the worker reads it; `man tend` then shows the dispatch as
316
+ `needs-decision (answered <n>m ago)` instead of listing it under attention.
317
+ A newer `needs-decision` from the worker stands again. Set `remindSecs = 0`
318
+ for pure at-most-once.
319
+
320
+ **Acknowledging, display-only.** Clicking a desktop pet opens its target and
321
+ runs `lobstah attention ack <item-key> --by pet`, so that pet stops walking
322
+ the item until its state changes (a new status entry, head, failed check, or
323
+ thread count). The ack changes only what the pet and the glass lobs display:
324
+ `man tend --json` still lists the item (marked `acked`), and `man wait`, the
325
+ park, and reminders ignore acks entirely — a human having seen a question
326
+ must never hide it from the orchestrator that has to answer it. The glass,
327
+ which has no write endpoint, hides a clicked lob per browser in localStorage
328
+ instead.
201
329
 
202
330
  **Any-harness fallback — the wrapper loop.** Tiers 2 and 3 lean on Claude
203
331
  Code features (background-task notifications, the Stop hook). For any other
@@ -214,6 +342,48 @@ Zero tokens between events and works anywhere a shell does; the cost is that
214
342
  each event gets a fresh context rather than a continuing liaison
215
343
  conversation.
216
344
 
345
+ ## The helm: signing on as the orchestrator
346
+
347
+ Soak enlists workers; `lobstah man helm` enlists the one who dispatches to
348
+ them. Sign-on prints the **charter** — the persona and scope fences, written
349
+ in Standard Technical English: triage and dispatch but never do the work,
350
+ leave running catches to the daemon, judge the catch not the keystrokes, stay
351
+ inside your grounds, report deltas not dumps. `man brief` re-injects the
352
+ charter at every session start, so it survives restarts and compaction
353
+ without anyone re-running anything. A helm registration also arms the
354
+ Stop-hook park by itself — no `.lobstah-man` marker, no env var — and gates
355
+ the park-delivered digest above.
356
+
357
+ **One helm per grounds, enforced.** A **grounds** is a named territory: the
358
+ subset of configured repos one orchestrator oversees (`[grounds.*]`; with
359
+ none configured, one implicit `fleet` grounds covers every repo). A repo
360
+ belongs to at most one grounds, so two orchestrators can never dispatch into
361
+ the same territory. The registration is one file per grounds — the data model
362
+ cannot hold two:
363
+
364
+ - Sign-on against a **live** foreign holder refuses with guidance;
365
+ `--take` is the only force path, and it is deliberate: the displaced
366
+ session finds a stand-down notice at its next park and stops orchestrating.
367
+ - A **stale** holder (heartbeat past `[helm].ttlSecs`) is claimable outright —
368
+ a dead orchestrator holds nothing.
369
+ - `lobstah man relieve` steps down; a relieved session never re-takes on its
370
+ own.
371
+ - The rule is strict: once a helm is claimed, `man wait` and `man report`
372
+ are reserved for the helm session (identified by `--session`, else hook
373
+ stdin, else `$CLAUDE_CODE_SESSION_ID` — inside Claude Code no flag is
374
+ needed; a refusal names the id it resolved and from where), and no
375
+ other session parks as a lobsterman — those verbs consume the helm's
376
+ wakes and cursor. A grounds-scoped call is stricter still: it belongs to
377
+ that grounds' own helm, never a neighboring one. `man tend`/`man brief`
378
+ stay open to everyone; a stale helm reserves nothing. Workers never run
379
+ `man` verbs at all — their hookless park is `soak --wait`.
380
+ - An identified `man wait` heartbeats the helm while it waits (waiting is
381
+ liveness) and defaults its digest to the helm's own grounds.
382
+ - Consumption is grounds-partitioned: a helm's `man wait` and park consume
383
+ only events and notices for its own grounds' repos (events whose repo is
384
+ unknowable stay visible to all helms). Two helms never eat each other's
385
+ wakes.
386
+
217
387
  ## Soaking: a live session volunteers as a worker
218
388
 
219
389
  Workers are usually traps lobstah sets itself — fresh headless sessions in
@@ -223,25 +393,50 @@ visible terminal, and whatever authenticated tooling a headless spawn can't
223
393
  get.
224
394
 
225
395
  ```bash
226
- lobstah soak --session <id> # from a worktree — the primary checkout is
396
+ lobstah soak # from a worktree — the primary checkout is
227
397
  # never claimable, so sign on from a linked
228
398
  # worktree (git worktree add ../side -b side)
229
- lobstah stow --session <id> # sign off; an open catch requeues
399
+ # (the id: --session, else hook stdin, else
400
+ # $CLAUDE_CODE_SESSION_ID)
401
+ lobstah soak --wait # hookless sessions: listen in the foreground
402
+ # (re-runs need no flags — identity is the
403
+ # worktree); exit 3 = quiet, run it again
404
+ lobstah stow # sign off; an open catch requeues, unread
405
+ # messages bounce back to the helm
230
406
  ```
231
407
 
232
- The session id comes from the plugin's session-start brief (`lobstah man
233
- brief` announces it into the conversation). Once soaking, the same Stop hook
234
- that parks a lobsterman parks the worker: at turn end it waits for bait,
235
- claims it, and wakes with the brief. While it works a catch, the park wakes
236
- it for `lobstah send` messages and cancels instead.
237
-
238
- Routing follows ownership: `dispatch --for session:<id>` targets one trap;
239
- unaddressed bait for a matching repo prefers a parked trap for
240
- `[soak].deferSecs` before the daemon spawns headless; a watch continuation
241
- for a chain a soaking session claimed is addressed back to that session. A
242
- registration whose heartbeat lapses past `[soak].ttlSecs` is a **ghost
243
- trap** — swept, its catch requeued. Nobody is conscripted: only a session
244
- that ran `soak` ever receives work.
408
+ **Identity is the worktree.** Sign-on anchors a short trap id in
409
+ `.lobstah-trap` and prints the trap's address (`wt:<id>`); the address
410
+ survives session restarts — a new session in the same worktree resumes the
411
+ same trap (a *live* foreign session is refused: the session lock). The
412
+ session id (from the plugin's session-start brief) lives inside the
413
+ registration as the liveness principal. The harness (claude or codex) is
414
+ inferred — from `CLAUDE*` / `CODEX*` in the environment, and when both are
415
+ set (one harness launched inside the other) from the session id's format:
416
+ Codex thread ids are UUIDv7, Claude Code session ids UUIDv4 — and
417
+ `--harness` overrides; an undecidable case refuses rather than guessing.
418
+ Once soaking, the same Stop hook
419
+ that parks a lobsterman parks the worker: at turn end it delivers messages
420
+ first, then claims bait and wakes with the brief. While it works a catch,
421
+ the park wakes it for `lobstah send` messages and cancels instead.
422
+
423
+ Routing follows ownership, and **addressed work is sticky**:
424
+ `dispatch --for wt:<trap>` waits for that trap and never falls back to a
425
+ headless spawn — if the trap ghosts, the orphan surfaces as a helm notice
426
+ (re-address, release, or cancel; `cancel` finalizes unclaimed queue items
427
+ with an audit record). Delivery stamps a receipt into evidence. Unaddressed
428
+ bait for a matching repo prefers a parked trap for `[soak].deferSecs`, then
429
+ the daemon spawns headless. Conversational steering goes through
430
+ `send wt:<trap> "..."` — a message, not bait: no branch, no catch, sender
431
+ stamped, bounced to the helm when undeliverable.
432
+
433
+ Liveness has two failure shapes with two remedies: a registration that
434
+ parked before and went quiet past `[soak].ttlSecs` is a **ghost trap** —
435
+ swept, catch requeued, noticed; one that **never parked** is a **defective
436
+ enlistment** — noticed with its diagnosis (usually a missing Stop hook →
437
+ `soak --wait`) and left standing so the address keeps protecting its work.
438
+ Nobody is conscripted: only a worktree whose session ran `soak` ever
439
+ receives work.
245
440
 
246
441
  ## What the daemon gives your liaison for free
247
442
 
@@ -14,7 +14,7 @@ anything else. The status log is append-only; the last entry wins.
14
14
  | Verb | Meaning | Who acts next |
15
15
  | --- | --- | --- |
16
16
  | `working` | Making progress; nothing needed. | Nobody. |
17
- | `needs-decision` | Blocked on a judgment call only a human (or the orchestrator) can make. The note carries the question. | Human — re-fires every `remindSecs` until answered. |
17
+ | `needs-decision` | Blocked on a judgment call only a human (or the orchestrator) can make. The note carries the question. A headless worker waiting on a question stays alive until answered (`lobstah send`), cancelled, or the wall clock. | Human — re-fires every `remindSecs` until answered. |
18
18
  | `blocked` | Cannot proceed for an external reason (missing access, broken dependency). | Human. |
19
19
  | `paused` | Intentionally idle; resume is expected. | Whoever paused it. |
20
20
  | `done` | The brief is fulfilled. Terminal. Merging is never the dispatch's job. | Merge loop / reviewer. |
@@ -106,9 +106,13 @@ Work moves through two **lanes** — `work` (human-originated) and `chore`
106
106
  tracker) — and three **buckets** within a lane: `queued`, `active`, `done`.
107
107
  Bucket transitions are atomic renames; the directory *is* the state.
108
108
 
109
+ An **attachment** is a dispatch descriptor entry `{ name, path, bytes, type }`
110
+ pointing to a copied, dispatch-owned file under `state/<id>/attachments/`;
111
+ follow-ups reuse those paths, and messages may carry their own attachments.
112
+
109
113
  ## Doctor statuses
110
114
 
111
- `lobstah doctor` grades each check with one of three words. **Owner:**
115
+ `lobstah doctor` grades each check with one of four words. **Owner:**
112
116
  `apps/cli/src/doctor.ts`. **Enforcement:** any `fail` row exits 1.
113
117
 
114
118
  | Status | Meaning |
@@ -116,6 +120,7 @@ Bucket transitions are atomic renames; the directory *is* the state.
116
120
  | `ok` | Works as configured. |
117
121
  | `warn` | Degraded or optional — dispatches may still run (e.g. one harness missing, daemon not running). |
118
122
  | `fail` | Broken configuration or missing requirement — fix before relying on lobstah. |
123
+ | `skip` | The check does not apply on this host (e.g. no harness plugin installed). Never fails the run. |
119
124
 
120
125
  ## Watch contract
121
126
 
@@ -138,28 +143,155 @@ events stand until the owner consumes them. One continuation dispatch in
138
143
  flight per watch; later events buffer and fork from the latest session in
139
144
  the chain.
140
145
 
146
+ ### The PR preset
147
+
148
+ `lobstah watch add pr:<owner>/<repo>#<n>` (or a github.com PR URL,
149
+ normalized to that key) installs the shipped check, `lobstah watch
150
+ check-pr`: one read-only `gh pr view` per cycle, diffed against the
151
+ previous observation that the cursor carries, plus — while the PR is open —
152
+ one read-only `gh api graphql` query for `reviewThreads { isResolved }`,
153
+ which `gh pr view --json` cannot return (no bodies are requested). `report <id> done --pr
154
+ <url>` registers the same watch owned by `dispatch:<id>` (idempotent;
155
+ `--no-watch` opts out). **Owner:** `packages/core/src/pr.ts` (derivation,
156
+ badge) and `apps/cli/src/pr-watch.ts` (check, registration, evidence).
157
+
158
+ | Word | Meaning |
159
+ | ---- | ------- |
160
+ | `pr:` key | `pr:<owner>/<repo>#<n>` — one watch per PR. |
161
+ | cursor | The last observation (head sha, per-check conclusions, review decision, merge state, draft, state), base64url-encoded. An unchanged PR re-emits nothing and returns the same cursor. A new head sha resets check memory. |
162
+ | `check-completed` | A check reached a conclusion on the current head (`name`, `conclusion`, `detailsUrl`). Failing → work; passing → evidence only. |
163
+ | `review-decision` | The review decision changed (`value`). Work, unless `[pickup.github]` covers the repo — then pickup's feedback rule owns it ([pickup.md](pickup.md), "Feedback pickup"). |
164
+ | `merge-state` | `mergeStateStatus` changed (`value`). Evidence only. |
165
+ | `draft` | Draft flipped (`value`). Evidence only. |
166
+ | `merged` / `closed` | Terminal; the check sets `done`, and the watch retires once delivered. |
167
+ | evidence `pr` | `{ url, number, state, draft, reviewDecision, mergeStateStatus, headSha, checks: { total, passed, failed, pending }, review: { unresolvedThreads, changesRequested, lastReviewAt }, observedAt }`. `review.changesRequested` comes from `reviewDecision` or any reviewer's latest decisive review; `unresolvedThreads` from the GraphQL query, omitted for an observation where that query failed. Comment bodies are never stored. The object is merged into the owning dispatch's evidence on every observation. `prBadge` derives the one-word state that tend, `catch`, and the glass show. |
168
+ | PR record | `~/.lobstah/prs/<owner>__<repo>__<n>.json` — the PR's latest observation keyed by the PR, not by a dispatch: the evidence `pr` object plus `key`, `repo` (`<owner>/<repo>`), and `dispatches` (the ids whose watch observed it; empty for a human's or a culled PR). **Owner:** `packages/core/src/prs.ts` (`upsertPr`, `readPrs`); the one writer is the preset's observation path (`observePr`), on every observation, man-owned or dispatch-owned — a dispatch-owned one also stamps that dispatch's evidence, which stays the per-dispatch view. Tend's `pr:*` kinds and `pr:ready` stack suppression, the glass PRs tab and stacks, the merged/closed notice, and PR acks read records first and fall back to dispatch evidence only for a PR with no record yet. `cull` removes records merged or closed longer than its window, never open ones. |
169
+
170
+ Every event carries `headSha`. A dispatch-owned PR watch emits only work
171
+ events (a failing check; a review decision pickup doesn't own), so the
172
+ `owner` row is unchanged: its events always fork. The rest is evidence.
173
+ Merged and closed reach the helm as a `pr-merged` / `pr-closed` notice,
174
+ posted once by whichever process first records the open → terminal
175
+ transition on the **PR record** — not by event routing.
176
+
177
+ A man-owned PR watch (no `--for`) delivers as attention only what needs a
178
+ human (`manEvents`, beside `workEvents` in `apps/cli/src/pr-watch.ts`): a
179
+ failing `check-completed`, and a `review-decision` turning to
180
+ `CHANGES_REQUESTED`. Green checks, draft toggles, merge-state changes, and
181
+ approvals go to the PR record only — they show as `pr:*` kinds and in the
182
+ glass, never as `watch` attention.
183
+
184
+ One carrier per event kind:
185
+
186
+ | Event | Carrier |
187
+ | ----- | ------- |
188
+ | `check-completed`, failing | dispatch-owned: a continuation (pick); man-owned: a `watch` attention event |
189
+ | `review-decision` | dispatch-owned: a continuation unless pickup owns review feedback; man-owned: a `watch` event only for `CHANGES_REQUESTED` |
190
+ | `check-completed` green, `draft`, `merge-state`, approvals | the PR record (and the owner's evidence) only |
191
+ | `merged`, `closed` | a `pr-merged` / `pr-closed` notice from the record transition only |
192
+
193
+ Pick stays the single writer of watch progress. The inline poller (`man
194
+ wait`, the helm park) runs PR checks for man-owned watches as usual, and
195
+ only **observes** dispatch-owned ones: it writes the PR record and the
196
+ evidence `pr` object (and so the merged notice) and never advances their cursor or consumes
197
+ their events, so pick still sees and forks every one.
198
+
141
199
  ## Soaking contract
142
200
 
143
- A **soaking trap** is a live interactive session that volunteered as a worker
144
- through `lobstah soak` — the validated write path; nothing else touches
145
- `soaking/`. Sub-agent workers are the traps lobstah sets itself; a soaking
146
- session is a trap already in the water, and dispatch drops bait into it
147
- before building a new one. **Owner:** `packages/core/src/soak.ts`.
148
- **Enforcement:** sign-on is refused from a repo's primary checkout (never
149
- claimable) and when another session already soaks the same worktree.
201
+ A **trap** is a worktree that volunteered as a worker seat through
202
+ `lobstah soak` — the validated write path; nothing else touches `soaking/`.
203
+ Identity is **worktree-anchored**: `.lobstah-trap` in the worktree root
204
+ holds a short stable id, the registration keys on it, and the address
205
+ (`wt:<id>`) survives session restarts. The session id inside the
206
+ registration is the liveness principal. **Owner:**
207
+ `packages/core/src/soak.ts`. **Enforcement:** sign-on is refused from a
208
+ repo's primary checkout, and a live foreign session in an owned worktree is
209
+ refused (the session lock); a stale one is adopted.
150
210
 
151
211
  | Word | Meaning |
152
212
  | ---- | ------- |
153
- | `soak` | Sign a session on: it parks at turn end (Stop hook) and takes matching bait from the work queue. `--one` stows after the first catch. |
154
- | `stow` | Sign a session off; an open catch goes back to the queue (a cancelled one finalizes as failed). |
155
- | bait address | `--for session:<id>` on a dispatch targets one soaking session. Addressed bait waits for its trap until the registration is gone; unaddressed bait defers to a parked matching trap for `[soak].deferSecs`, then the daemon spawns headless. |
156
- | catch | The active dispatch a soaking session claimed (`claim.json` in the active dir). One catch per trap; one active item per worktree. The daemon never spawns or restarts it — the session's reports are its liveness. |
157
- | ghost trap | A registration whose heartbeat lapsed past `[soak].ttlSecs` — a lost trap that keeps fishing. The sweep hauls it out and requeues its catch. A fresh report on the catch keeps a mid-turn session out of the sweep. |
213
+ | `soak` | Sign the worktree's trap on: it parks at turn end (Stop hook) and takes matching work. `--session` only on first sign-on; re-runs infer everything from the anchor file. `--one` stows after the first catch. `--wait` parks in the foreground — the hookless path: work prints plain, a quiet timeout exits 3, re-running re-arms. Workers never run `man` verbs. |
214
+ | `stow` | Sign the trap off (run it in the worktree); an open catch requeues (a cancelled one finalizes as failed) and unread messages bounce to the helm. A trap always stows itself freely; stowing someone else's (`--wt`) is steering — the claimed helm's alone. Stow closes the seat, never the session: an opted-in session can only be asked to stop, and a still-looping worker re-enlists visibly (`trap-signed-on`). |
215
+ | address | `--for wt:<trap>` targets one trap; `session:<id>` is an alias resolved to the trap at dispatch time. **Sticky:** addressed work is never the daemon's — it waits for its trap; an orphan (trap gone) surfaces as a `bait-orphaned` notice for the helm to re-address, release, or cancel. Delivery stamps a receipt (`deliveredTo`/`deliveredAt`) into evidence. Unaddressed work defers to a parked matching trap for `[soak].deferSecs`, then the daemon spawns headless. |
216
+ | message | `send wt:<trap> "<text>"` — a conversational continuation, not work: no branch, no catch, no report obligation. Delivered before bait at the trap's next park, stamped with its sender (`helm` / `session:<id>` / `terminal`); undeliverable messages bounce to the helm as notices. |
217
+ | catch | The active dispatch a trap claimed (`claim.json`, `by: wt:<id>`). One catch per trap; one active item per worktree. The daemon never spawns or restarts it — the session's reports are its liveness. |
218
+ | ghost trap | A registration whose heartbeat lapsed past `[soak].ttlSecs` **after having parked at least once**. The sweep removes it, requeues its catch, and posts a `trap-ghosted` notice; re-soaking the worktree restores the same address. A fresh report on the catch keeps a mid-turn session out of the sweep. |
219
+ | defective enlistment | A stale registration that **never parked** — signed on but never listened (usually no Stop hook). Not swept: the helm gets a `trap-defective` notice with the remedy (`soak --wait`), and the registration stays so the address keeps protecting its work. |
220
+ | notice | The helm's attention channel for non-status events (`~/.lobstah/notices/`): sign-ons, first parks, sign-offs, ghosts, defective enlistments, orphaned work, bounced messages. A trap leaves the registry only through a `trap-stowed` or `trap-ghosted` notice — the end-state is always explicit. Consumed by `man wait`/the park; tend always shows the recent tail. |
158
221
 
159
222
  Delivery routes by ownership, same as watches: a continuation for a chain
160
- claimed by a live soaking session is addressed back to that session; once it
161
- ghosts, the same bait forks headless. Sessions are never conscripted — a
162
- thread works bait only after opting in.
223
+ claimed by a live trap is addressed back to that trap and stays sticky.
224
+ Sessions are never conscripted — a thread works bait only after opting in,
225
+ and nothing addressed is ever silently rerouted.
226
+
227
+ ## Helm contract
228
+
229
+ The **helm** is the orchestrator seat: one interactive session signed on as
230
+ the lobsterman for its grounds through `lobstah man helm` — the validated
231
+ write path; nothing else touches `helm/`. Soak enlists workers; helm enlists
232
+ the one who dispatches to them. **Owner:** `packages/core/src/helm.ts`.
233
+ **Enforcement:** one registration file per grounds — the data model cannot
234
+ hold two; a live foreign holder refuses sign-on without `--take`. The rule
235
+ is strict: once a helm is claimed, the orchestrator verbs that consume helm
236
+ state (`man wait`, `man report`, the lobsterman park) are reserved for the
237
+ helm session — any other caller is refused with guidance (or, for the hook,
238
+ silently ignored). Read verbs (`man tend`, `man brief`) and the enlistment
239
+ verbs stay open. A stale helm reserves nothing.
240
+
241
+ | Word | Meaning |
242
+ | ---- | ------- |
243
+ | `helm` | Sign a session on as the orchestrator for one grounds. Prints the charter, arms the Stop-hook park without a marker file, and gates the periodic digest. Re-running from the same session is an idempotent re-sign. |
244
+ | `relieve` | Step down. `--take` on another session's `helm` is the only force path: it displaces a live holder deliberately and leaves them a stand-down notice, delivered once at their next park. A holder whose heartbeat lapsed past `[helm].ttlSecs` is stale and claimable without `--take`. |
245
+ | grounds | A named territory: the subset of configured repos one helm oversees, from `[grounds.*]`. A repo belongs to at most one grounds (config error otherwise). No grounds configured means one implicit `fleet` grounds covering every repo. |
246
+ | charter | The helm's persona and scope fences, in Standard Technical English. Printed at sign-on and re-injected by `man brief` at every session start, so it survives restarts and compaction. |
247
+ | digest | The delta since the reported-through cursor: catches landed, attention arisen, still-waiting, fleet verdict. Carried by `man report`, a `man wait` timeout, and — for a helm session, at `[helm].reportSecs` cadence — the park itself. Change-gated: an empty delta is never delivered. |
248
+
249
+ ## Attention contract
250
+
251
+ **Attention** is what stands waiting for a human to look: `man tend`'s
252
+ `attention` list, which the desktop pet and the glass walk across the
253
+ screen. It is derived in one place (`apps/cli/src/tend.ts`) and nowhere
254
+ else. **Owner:** `apps/cli/src/tend.ts`. **Enforcement:** `attentionKinds`
255
+ in `config.toml` selects the kinds (an unknown kind is a config error);
256
+ every kind is level-triggered — it stands until its own clear condition,
257
+ never until someone acknowledges it.
258
+
259
+ | Kind | Stands while | Clears when |
260
+ | ---- | ------------ | ----------- |
261
+ | `question` | The dispatch's last status is `needs-decision` or `blocked`. | Any newer status entry. |
262
+ | `landed` | The dispatch is `done` or `failed` after its grounds' reported-through cursor (the grounds listing the repo, else `fleet`; at most 24 h back). Opt-in. | `man report` (or the helm park's digest) advances the cursor. |
263
+ | `pr:draft` | Evidence `pr` is open and draft. | Ready for review, merged, or closed. |
264
+ | `pr:review` | Evidence `pr` is open with `review.unresolvedThreads > 0` or `review.changesRequested`. | Every thread resolved and no changes requested, or merged / closed. |
265
+ | `pr:checks` | Evidence `pr` is open with a failed check on the observed head. | Green on the head, or merged / closed. |
266
+ | `pr:ready` | Evidence `pr` is open, not draft, no `pr:review` condition holds, and it is approved — or every check passed with none pending. | Merged or closed (or a review condition arises). |
267
+
268
+ `pr:*` kinds read only the `pr:` watch's evidence — never a forge call —
269
+ and carry `prUrl`, `number`, and the fields they derive from. Unconsumed
270
+ man-owned watch events also list, as `watch`: machinery wakes, always on.
271
+ Only `question` and `watch` make the verdict `needs-attention` or arise in
272
+ the digest; the rest are things to look at, not stalls.
273
+
274
+ **The on-the-hook rule.** A `pr:review` or `pr:checks` item is suppressed
275
+ while a worker owns the problem: a queued or active dispatch in the PR's
276
+ chain (the evidence owner and its `followUp` descendants) that is a pickup
277
+ feedback round — pickup's map records it with kind `review` — or the `pr:`
278
+ watch's fix continuation — the watch records it as `lastFollowUpId`. It
279
+ reappears when that dispatch finishes without clearing the condition.
280
+ `question`, `landed`, `pr:draft`, and `pr:ready` are never suppressed.
281
+
282
+ **Answered questions.** A `question` stands only while no message to the
283
+ dispatch is newer than its latest `needs-decision` / `blocked` entry. A
284
+ message counts when it carries provenance: `sendMessage` writes a
285
+ `NNN.meta.json` sidecar `{ from, at }` beside each `NNN.msg` (`from` is
286
+ `helm`, `session:<8>`, `terminal`, `tracker:<source>`, or `node`), and
287
+ `acknowledge` moves it into `handled/` with its message. Any sender counts;
288
+ a record without a sidecar (written before provenance existed) never does;
289
+ the sidecar's `at` decides, never file mtime. `man wait`, the park, and the
290
+ reminder loop apply the same predicate.
291
+
292
+ | Word | Meaning |
293
+ | ---- | ------- |
294
+ | ack | `~/.lobstah/acks/<item-key>.json` — `{ key, kind, stateHash, at, by }`, written only by `lobstah attention ack` (removed by `unack`, by the CLI's `man tend` / `attention` when its `stateHash` goes stale, and by `cull` when the item is gone). **Display-only**: it hides the item from the desktop pet and the glass lobs while the item's `stateHash` is unchanged; `man tend --json` keeps the item with `acked: { at, by }`, and `man wait`, the park, reminders, and `notifyCommand` never read acks. Item keys: `<lane>:<uuid>` (question, landed), `pr:<owner>/<repo>#<n>` (all of a PR's `pr:*` kinds — one ack covers them), `watch:<key>`. `stateHash` covers the status entry (question, landed) or the PR's head sha plus every evidence field a `pr:*` kind stands on (not `observedAt`). |
163
295
 
164
296
  ## Exit codes
165
297
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobstah",
3
- "version": "0.3.2",
3
+ "version": "0.5.5",
4
4
  "description": "Harness-agnostic, token-efficient supervision framework for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "aequitas labs LLC",