lobstah 0.5.0 → 0.5.7

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.
@@ -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:conflict", "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:conflict`, `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
 
package/docs/design.md CHANGED
@@ -444,7 +444,27 @@ 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`.
454
+
455
+ **The session's own harness resumes it.** A session only resumes under the
456
+ harness that wrote it: a Codex thread id handed to Claude Code is "No
457
+ conversation found". The descriptor's `harness` records what was asked, not
458
+ what ran (a trap claims unaddressed work with whatever harness it is), so every
459
+ run stamps `harness` into evidence beside `sessionId` (the adapter's for a
460
+ headless run, the trap's for a claim), and one resolver reads it back for a
461
+ follow-up's fork, a daemon restart, `lobstah swap`, and `attach`. It falls back
462
+ to the claiming trap, then the session id's UUID version, then the descriptor.
463
+ A follow-up whose `--harness` names something the chain never asked for is a
464
+ swap: cold on that harness with the brief plus a progress note. A resume the
465
+ harness refuses before doing any work falls back the same way and records
466
+ `resume-fallback: <reason>` in the status note and evidence. It never fails
467
+ the dispatch. The first status note says which of these happened.
448
468
 
449
469
  ```ts
450
470
  // inside the runner
@@ -106,6 +106,47 @@ view](pickup.md#merge-view) pickup persists each tick, so PR state is at most
106
106
  one poll interval stale without tend making a single network call. `--json`
107
107
  emits the full report for dashboards and scripts to render.
108
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`, `conflicts`, `behind`, `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:conflict` (GitHub reports it conflicting with its base),
141
+ `pr:ready` (approved or all green, and GitHub says it can merge) — so it crawls in the glass and
142
+ the desktop pet until its clear condition holds; clicking it opens the PR.
143
+ `pr:review` and `pr:checks` stay off the screen while a worker already owns
144
+ them (a pickup feedback round or the watch's fix continuation in flight).
145
+ `attentionKinds` in `config.toml` picks the kinds; `landed` is opt-in. See
146
+ the [attention contract](vocabulary.md#attention-contract). A PR is
147
+ something to look at, not a stall: it never flips the verdict to
148
+ `needs-attention` and stays out of the digest.
149
+
109
150
  ### The spyglass
110
151
 
111
152
  `lobstah glass [--port <n>]` serves tend as a live web page on 127.0.0.1
@@ -117,7 +158,9 @@ and the helm identified by name. It is strictly read-only and consumes no
117
158
  cursor: looking through the glass changes nothing, so it needs no helm and
118
159
  threatens nothing. Links out are copyable commands (`lobstah attach`,
119
160
  `claude --resume`), never click-to-exec — localhost HTTP is reachable by
120
- any webpage, so the glass exposes no endpoint that acts.
161
+ any webpage, so the glass exposes no endpoint that acts. The ⚙ popover's two
162
+ preferences — table or cards, and whether lobsters crawl the page — are
163
+ per-browser, kept in that browser's localStorage and never on disk.
121
164
 
122
165
  This is where "is the agent alive?" belongs: the helm's heartbeat age on a
123
166
  page, not periodic proof-of-life turns in a transcript.
@@ -144,7 +187,7 @@ Every carrier shares the cursor (per grounds, for a helm):
144
187
  - **The wait loop.** A `man wait` timeout (exit 3) prints the delta when
145
188
  something changed, so a looping session gets periodic fleet reports for
146
189
  free — see the loop idiom below.
147
- - **The park.** A helm session's Stop-hook park delivers the digest as a wake
190
+ - **The blocking park.** A helm session's Stop-hook park delivers the digest as a wake
148
191
  at `[helm].reportSecs` cadence — including the landed-then-idle case, where
149
192
  the last catches finish and nothing is left in flight to wake for.
150
193
  - **Direct call.** Anything with a clock — a gateway heartbeat, a cron — runs
@@ -166,70 +209,27 @@ human is watching — come back later and the transcript is the report.
166
209
 
167
210
  ## Getting woken instead of asked
168
211
 
169
- Three escalation tiers, least to most invasive. All are built on
170
- `lobstah man wait`: block until a dispatch — or a watched external source
171
- (`lobstah watch add`, e.g. a ume review session) — needs attention, print the
172
- event and what to do next, exit. It is **level-triggered for attention
173
- states** — if a `needs-decision` or an unconsumed watch event is already
174
- standing when it starts, it returns immediately — so a gap between one
175
- watcher exiting and the next arming can never lose an event. When no pick
176
- process is running, `man wait` runs due watch checks itself, so watching
177
- works with every service stopped.
178
-
179
- **Tier 1 — a push for the human.** Set the daemon's hook and forget it:
180
-
181
- ```toml
182
- notifyCommand = "ntfy pub my-topic \"$LOBSTAH_VERB $LOBSTAH_ID: $LOBSTAH_NOTE\""
183
- ```
184
-
185
- **Tier 2 — a background watcher in the liaison session.** The liaison runs
186
- `lobstah man wait` as a background task; when it exits, the harness's task
187
- notification wakes the session, and the printed `next:` line tells the agent
188
- exactly what to do — including re-arming. One watcher per wake is inherent to
189
- background tasks; the level-trigger makes the re-arm race harmless. Add to the
190
- liaison instructions:
191
-
192
- ```markdown
193
- After dispatching work, run `lobstah man wait` as a background task. When it
194
- completes, follow its `next:` instruction, then re-arm it.
195
- ```
196
-
197
- **Arm it long-lived.** The default (`--timeout 0`) waits forever and returns
198
- only when something happens — that is the right idiom, not a compromise.
199
- Every wake costs the session a turn, so a short timeout used as a "check in
200
- periodically" mechanism buys nothing but noise: attention is level-triggered
201
- and stands on disk until consumed, so a wait that dies mid-shift drops zero
202
- events — the next wait delivers everything that accumulated. There is no
203
- report to miss either; a quiet fleet has nothing to say. Pass `--timeout`
204
- only as a dead-man's check on the channel itself (an hour, not minutes): a
205
- quiet timeout prints one line, exits 3, and the re-arm proves the line was
206
- alive. Liveness reassurance for the human belongs on a status surface
207
- (`lobstah man tend`), never in the transcript.
208
-
209
- **Tier 3 — park the session on a Stop hook (Claude Code only).** A Stop hook
210
- that blocks on `lobstah man wait`, so the session never
211
- really idles — it parks for free and continues the moment something needs it.
212
- What this buys over tier 2 is not the wake. Both wake on events, and both
213
- cost a turn per wake. The difference: the re-arm is **structural instead of
214
- instructed**. Tier 2 works only as long as the model remembers to re-arm the
215
- watcher after every wake. A forgotten re-arm, a crashed watcher, or an
216
- interrupted turn leaves the session deaf until a human speaks. The Stop hook
217
- fires at every turn end, no matter what the model did. Supervision cannot
218
- lapse through instruction drift, and a blind stop mid-shift is impossible.
212
+ After `man helm`, run `lobstah man wait --session <id> --timeout 900` as a
213
+ background task and re-arm it after each completion. The wait registers a
214
+ heartbeating watcher for the session and exits with an event or a timeout
215
+ (exit 3). At turn end, `man haul` blocks with standing attention; in arm mode,
216
+ work in flight allows a stop with a live watcher and otherwise blocks with
217
+ the arm command. `man haul --park` or `[helm].park = "block"` makes the hook
218
+ wait for attention itself. Without a Stop hook, run `man wait` in the foreground.
219
219
 
220
220
  The hook is a CLI command — `lobstah man haul` (the lobsterman hauls the
221
221
  trapline; every orchestrator-facing command lives under `lobstah man`).
222
222
  Install it from the project you'll run the lobsterman in:
223
223
 
224
224
  ```bash
225
- # Easiest: the plugin ships the hooks + the lobsterman skill, no settings
225
+ # Easiest: the plugin ships the hooks + the lobsterman and trap skills, no settings
226
226
  # edits — /plugin marketplace add aequitas-labs/lobstah, then
227
227
  # /plugin install lobstah@lobstah (Claude Code and Codex v0.114+; Codex asks
228
228
  # for a one-time hook trust review). Or wire the Claude hook by hand:
229
229
  lobstah man init # merges the Stop hook into .claude/settings.local.json
230
230
  lobstah man init --shared # …or the committed .claude/settings.json
231
231
  lobstah man init --global # …or once into ~/.claude/settings.json — any
232
- # directory with a .lobstah-man file then parks
232
+ # directory with a .lobstah-man file then uses the hook
233
233
  lobstah man init --marker # also touch .lobstah-man (per-directory gate)
234
234
  ```
235
235
 
@@ -240,38 +240,33 @@ are preserved verbatim. What it writes:
240
240
  { "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "lobstah man haul", "timeout": 14400 }] }] } }
241
241
  ```
242
242
 
243
- `haul` gates itself twice: only a designated session parks (launch it with
244
- `LOBSTAH_MAN=1 claude`, or `touch .lobstah-man` for a per-directory gate), and
245
- only while dispatches are in flight — conversational turns end free. On an
246
- event it blocks the stop with the event as context and tells the agent the
247
- session re-parks automatically; on timeout or any error it silently allows
248
- the stop, leaving tier 1 as the backstop past the horizon.
249
-
250
- Two habits worth adding to a lobsterman session's instructions: run
251
- `lobstah man wait --peek` at session start (a wake consumed by a session that
252
- died mid-handling is still standing state — peek resurfaces it), and treat
253
- the haul context as the work order for that turn.
254
-
255
- The trade-offs, honestly. While parked, the turn never ends, so the terminal
256
- shows a running hook. Each wake appends a turn to the context, and long
257
- shifts eventually compact. Without the gate, the hook parks every session in
258
- the project. And parking needs a turn-end hook that
259
- can block and inject a continuation. Claude Code's Stop hook can, and so can
260
- Codex's since its hooks system landed (v0.114+; older Codex only has the
261
- fire-and-forget notify hook, which cannot). Tier 2 is the right default.
262
- Tier 3 is for a dedicated, long-lived liaison session.
243
+ `haul` applies to a signed-on helm or trap, or a session opted in with
244
+ `LOBSTAH_MAN=1` or `.lobstah-man`. Queued dispatches count as work in flight.
263
245
 
264
246
  **Delivery guarantee.** Attention wakes are at-least-once with backoff. An
265
247
  unanswered question is reported immediately. While it still stands, it
266
248
  re-fires as a reminder every `remindSecs` (top-level config, default 900). So
267
249
  a wake consumed by a session that died mid-handling resurfaces on its own.
268
- Answering ends the reminders naturally, because the answer produces a new
269
- status entry. Set `remindSecs = 0` for pure at-most-once.
270
-
271
- **Any-harness fallback — the wrapper loop.** Tiers 2 and 3 lean on Claude
272
- Code features (background-task notifications, the Stop hook). For any other
273
- harness — or no interactive session at all — an outer loop blocking on `wait`
274
- spawns one fresh headless turn per event:
250
+ Answering ends the reminders: a message to the dispatch newer than its
251
+ question — `lobstah send`, a forwarded tracker comment, anything written
252
+ through the inbox with provenance — means the question no longer stands, even
253
+ before the worker reads it; `man tend` then shows the dispatch as
254
+ `needs-decision (answered <n>m ago)` instead of listing it under attention.
255
+ A newer `needs-decision` from the worker stands again. Set `remindSecs = 0`
256
+ for pure at-most-once.
257
+
258
+ **Acknowledging, display-only.** Clicking a desktop pet opens its target and
259
+ runs `lobstah attention ack <item-key> --by pet`, so that pet stops walking
260
+ the item until its state changes (a new status entry, head, failed check, or
261
+ thread count). The ack changes only what the pet and the glass lobs display:
262
+ `man tend --json` still lists the item (marked `acked`), and `man wait`, the
263
+ park, and reminders ignore acks entirely — a human having seen a question
264
+ must never hide it from the orchestrator that has to answer it. The glass,
265
+ which has no write endpoint, hides a clicked lob per browser in localStorage
266
+ instead.
267
+
268
+ **Wrapper loop.** An outer loop blocking on `wait` can spawn one fresh
269
+ headless turn per event:
275
270
 
276
271
  ```sh
277
272
  while out=$(lobstah man wait); do
@@ -291,9 +286,9 @@ in Standard Technical English: triage and dispatch but never do the work,
291
286
  leave running catches to the daemon, judge the catch not the keystrokes, stay
292
287
  inside your grounds, report deltas not dumps. `man brief` re-injects the
293
288
  charter at every session start, so it survives restarts and compaction
294
- without anyone re-running anything. A helm registration also arms the
295
- Stop-hook park by itself — no `.lobstah-man` marker, no env var — and gates
296
- the park-delivered digest above.
289
+ without anyone re-running anything. A helm registration enables the
290
+ Stop hook by itself — no `.lobstah-man` marker, no env var — and
291
+ gates the digest above.
297
292
 
298
293
  **One helm per grounds, enforced.** A **grounds** is a named territory: the
299
294
  subset of configured repos one orchestrator oversees (`[grounds.*]`; with
@@ -310,7 +305,9 @@ cannot hold two:
310
305
  - `lobstah man relieve` steps down; a relieved session never re-takes on its
311
306
  own.
312
307
  - The rule is strict: once a helm is claimed, `man wait` and `man report`
313
- are reserved for the helm session (identify with `--session`), and no
308
+ are reserved for the helm session (identified by `--session`, else hook
309
+ stdin, else `$CLAUDE_CODE_SESSION_ID` — inside Claude Code no flag is
310
+ needed; a refusal names the id it resolved and from where), and no
314
311
  other session parks as a lobsterman — those verbs consume the helm's
315
312
  wakes and cursor. A grounds-scoped call is stricter still: it belongs to
316
313
  that grounds' own helm, never a neighboring one. `man tend`/`man brief`
@@ -332,9 +329,11 @@ visible terminal, and whatever authenticated tooling a headless spawn can't
332
329
  get.
333
330
 
334
331
  ```bash
335
- lobstah soak --session <id> # from a worktree — the primary checkout is
332
+ lobstah soak # from a worktree — the primary checkout is
336
333
  # never claimable, so sign on from a linked
337
334
  # worktree (git worktree add ../side -b side)
335
+ # (the id: --session, else hook stdin, else
336
+ # $CLAUDE_CODE_SESSION_ID)
338
337
  lobstah soak --wait # hookless sessions: listen in the foreground
339
338
  # (re-runs need no flags — identity is the
340
339
  # worktree); exit 3 = quiet, run it again
@@ -347,7 +346,12 @@ lobstah stow # sign off; an open catch requeues, unread
347
346
  survives session restarts — a new session in the same worktree resumes the
348
347
  same trap (a *live* foreign session is refused: the session lock). The
349
348
  session id (from the plugin's session-start brief) lives inside the
350
- registration as the liveness principal. Once soaking, the same Stop hook
349
+ registration as the liveness principal. The harness (claude or codex) is
350
+ inferred — from `CLAUDE*` / `CODEX*` in the environment, and when both are
351
+ set (one harness launched inside the other) from the session id's format:
352
+ Codex thread ids are UUIDv7, Claude Code session ids UUIDv4 — and
353
+ `--harness` overrides; an undecidable case refuses rather than guessing.
354
+ Once soaking, the same Stop hook
351
355
  that parks a lobsterman parks the worker: at turn end it delivers messages
352
356
  first, then claims bait and wakes with the brief. While it works a catch,
353
357
  the park wakes it for `lobstah send` messages and cancels instead.
@@ -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,26 @@ 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
+
113
+ ## Follow-up and swap
114
+
115
+ How a dispatch picks up a session it did not start. One resolver
116
+ (`resolveSessionHarness`, `packages/core/src/resume.ts`) names the harness
117
+ that owns a session: evidence `harness`, else the claiming trap, else the
118
+ session id's UUID version (v7 codex, v4 claude), else the descriptor.
119
+
120
+ | Word | Meaning |
121
+ | ---- | ------- |
122
+ | follow-up | `--follow-up <id>` forks the origin's session **under the origin's harness**, whatever the follow-up's own `--harness` says, unless that `--harness` names something the chain never asked for and the session is not, which makes it a swap. Pickup review rounds and watch continuations are follow-ups. |
123
+ | swap | Start cold on another harness with the brief plus a progress note (commits so far, uncommitted changes, and the origin's branch/PR/commits for a follow-up). `lobstah swap` does it to an active dispatch; a follow-up does it when it asks for a new harness. |
124
+ | `resume-fallback` | Status note and evidence field (`resumeFallback`) recorded when the harness refuses a resume (not found, culled, foreign) before doing any work: the runner starts cold with the progress note and the dispatch proceeds. |
125
+
109
126
  ## Doctor statuses
110
127
 
111
- `lobstah doctor` grades each check with one of three words. **Owner:**
128
+ `lobstah doctor` grades each check with one of four words. **Owner:**
112
129
  `apps/cli/src/doctor.ts`. **Enforcement:** any `fail` row exits 1.
113
130
 
114
131
  | Status | Meaning |
@@ -116,6 +133,7 @@ Bucket transitions are atomic renames; the directory *is* the state.
116
133
  | `ok` | Works as configured. |
117
134
  | `warn` | Degraded or optional — dispatches may still run (e.g. one harness missing, daemon not running). |
118
135
  | `fail` | Broken configuration or missing requirement — fix before relying on lobstah. |
136
+ | `skip` | The check does not apply on this host (e.g. no harness plugin installed). Never fails the run. |
119
137
 
120
138
  ## Watch contract
121
139
 
@@ -138,6 +156,59 @@ events stand until the owner consumes them. One continuation dispatch in
138
156
  flight per watch; later events buffer and fork from the latest session in
139
157
  the chain.
140
158
 
159
+ ### The PR preset
160
+
161
+ `lobstah watch add pr:<owner>/<repo>#<n>` (or a github.com PR URL,
162
+ normalized to that key) installs the shipped check, `lobstah watch
163
+ check-pr`: one read-only `gh pr view` per cycle, diffed against the
164
+ previous observation that the cursor carries, plus — while the PR is open —
165
+ one read-only `gh api graphql` query for `reviewThreads { isResolved }`,
166
+ which `gh pr view --json` cannot return (no bodies are requested). `report <id> done --pr
167
+ <url>` registers the same watch owned by `dispatch:<id>` (idempotent;
168
+ `--no-watch` opts out). **Owner:** `packages/core/src/pr.ts` (derivation,
169
+ badge) and `apps/cli/src/pr-watch.ts` (check, registration, evidence).
170
+
171
+ | Word | Meaning |
172
+ | ---- | ------- |
173
+ | `pr:` key | `pr:<owner>/<repo>#<n>` — one watch per PR. |
174
+ | 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. |
175
+ | `check-completed` | A check reached a conclusion on the current head (`name`, `conclusion`, `detailsUrl`). Failing → work; passing → evidence only. |
176
+ | `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"). |
177
+ | `merge-state` | `mergeStateStatus` changed (`value`). Evidence only. |
178
+ | `draft` | Draft flipped (`value`). Evidence only. |
179
+ | `merged` / `closed` | Terminal; the check sets `done`, and the watch retires once delivered. |
180
+ | 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: `merged`, `closed`, `draft`, `conflicts` (`DIRTY`, filled GitHub red, ahead of checks and review), `checks n/m failed`, `changes requested`, `n unresolved`, `checks n/m` (pending), `behind` (`BEHIND`, grey; not an attention kind), `review`, `green` (only for a mergeable merge state), `blocked`, `merge unknown`. |
181
+ | 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. |
182
+
183
+ Every event carries `headSha`. A dispatch-owned PR watch emits only work
184
+ events (a failing check; a review decision pickup doesn't own), so the
185
+ `owner` row is unchanged: its events always fork. The rest is evidence.
186
+ Merged and closed reach the helm as a `pr-merged` / `pr-closed` notice,
187
+ posted once by whichever process first records the open → terminal
188
+ transition on the **PR record** — not by event routing.
189
+
190
+ A man-owned PR watch (no `--for`) delivers as attention only what needs a
191
+ human (`manEvents`, beside `workEvents` in `apps/cli/src/pr-watch.ts`): a
192
+ failing `check-completed`, and a `review-decision` turning to
193
+ `CHANGES_REQUESTED`. Green checks, draft toggles, merge-state changes, and
194
+ approvals go to the PR record only — they show as `pr:*` kinds and in the
195
+ glass, never as `watch` attention.
196
+
197
+ One carrier per event kind:
198
+
199
+ | Event | Carrier |
200
+ | ----- | ------- |
201
+ | `check-completed`, failing | dispatch-owned: a continuation (pick); man-owned: a `watch` attention event |
202
+ | `review-decision` | dispatch-owned: a continuation unless pickup owns review feedback; man-owned: a `watch` event only for `CHANGES_REQUESTED` |
203
+ | `check-completed` green, `draft`, `merge-state`, approvals | the PR record (and the owner's evidence) only |
204
+ | `merged`, `closed` | a `pr-merged` / `pr-closed` notice from the record transition only |
205
+
206
+ Pick stays the single writer of watch progress. The inline poller (`man
207
+ wait`, the helm park) runs PR checks for man-owned watches as usual, and
208
+ only **observes** dispatch-owned ones: it writes the PR record and the
209
+ evidence `pr` object (and so the merged notice) and never advances their cursor or consumes
210
+ their events, so pick still sees and forks every one.
211
+
141
212
  ## Soaking contract
142
213
 
143
214
  A **trap** is a worktree that volunteered as a worker seat through
@@ -152,7 +223,7 @@ refused (the session lock); a stale one is adopted.
152
223
 
153
224
  | Word | Meaning |
154
225
  | ---- | ------- |
155
- | `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. |
226
+ | `soak` | Sign the worktree's trap on and take matching work. `--session` is needed only on first sign-on. `--one` stows after the first catch. `--wait` registers a watcher and waits for work; a quiet timeout exits 3. Workers never run `man` verbs. |
156
227
  | `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`). |
157
228
  | 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. |
158
229
  | 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. |
@@ -182,11 +253,63 @@ verbs stay open. A stale helm reserves nothing.
182
253
 
183
254
  | Word | Meaning |
184
255
  | ---- | ------- |
185
- | `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. |
256
+ | `helm` | Sign a session on as the orchestrator for one grounds. Prints the charter and enables the Stop hook without a marker file. Re-running from the same session is an idempotent re-sign. |
257
+ | watcher registration | `~/.lobstah/watchers/<session-id>.json` stores a `man wait` or `soak --wait` process's PID and heartbeat. The waiter refreshes it while running and removes it on exit; a heartbeat older than five seconds is stale, and a second waiter for the same session refuses. |
258
+ | `man haul --park` | Make the Stop hook wait for attention while work is in flight. `[helm].park = "block"` selects the same mode. In arm mode, the hook allows a stop with a live watcher or blocks with the arm command. |
186
259
  | `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`. |
187
260
  | 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. |
188
261
  | 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. |
189
- | 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. |
262
+ | 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 — in blocking park mode, at `[helm].reportSecs` cadence — the park itself. Change-gated: an empty delta is never delivered. |
263
+
264
+ ## Attention contract
265
+
266
+ **Attention** is what stands waiting for a human to look: `man tend`'s
267
+ `attention` list, which the desktop pet and the glass walk across the
268
+ screen. It is derived in one place (`apps/cli/src/tend.ts`) and nowhere
269
+ else. **Owner:** `apps/cli/src/tend.ts`. **Enforcement:** `attentionKinds`
270
+ in `config.toml` selects the kinds (an unknown kind is a config error);
271
+ every kind is level-triggered — it stands until its own clear condition,
272
+ never until someone acknowledges it.
273
+
274
+ | Kind | Stands while | Clears when |
275
+ | ---- | ------------ | ----------- |
276
+ | `question` | The dispatch's last status is `needs-decision` or `blocked`. | Any newer status entry. |
277
+ | `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. |
278
+ | `pr:draft` | Evidence `pr` is open and draft. | Ready for review, merged, or closed. |
279
+ | `pr:review` | Evidence `pr` is open with `review.unresolvedThreads > 0` or `review.changesRequested`. | Every thread resolved and no changes requested, or merged / closed. |
280
+ | `pr:checks` | Evidence `pr` is open with a failed check on the observed head. | Green on the head, or merged / closed. |
281
+ | `pr:conflict` | Evidence `pr` is open and `mergeStateStatus` is `DIRTY` (GitHub: conflicting with its base). | The merge state leaves `DIRTY` (rebased or merged clean), or merged / closed. |
282
+ | `pr:ready` | Evidence `pr` is open, not draft, no `pr:review` condition holds, `mergeStateStatus` is mergeable (`CLEAN`, `HAS_HOOKS`, or `UNSTABLE` — the last only means non-required checks are red, which `pr:checks` already carries), and it is approved — or every check passed with none pending. `DIRTY`, `BEHIND`, `BLOCKED`, and `UNKNOWN` never yield ready. | Merged or closed (or a review condition arises, or the merge state stops being mergeable). |
283
+
284
+ `pr:*` kinds read only the `pr:` watch's evidence — never a forge call —
285
+ and carry `prUrl`, `number`, and the fields they derive from. Unconsumed
286
+ man-owned watch events also list, as `watch`: machinery wakes, always on.
287
+ Only `question` and `watch` make the verdict `needs-attention` or arise in
288
+ the digest; the rest are things to look at, not stalls.
289
+
290
+ **The on-the-hook rule.** A `pr:review` or `pr:checks` item is suppressed
291
+ while a worker owns the problem: a queued or active dispatch in the PR's
292
+ chain (the evidence owner and its `followUp` descendants) that is a pickup
293
+ feedback round — pickup's map records it with kind `review` — or the `pr:`
294
+ watch's fix continuation — the watch records it as `lastFollowUpId`. It
295
+ reappears when that dispatch finishes without clearing the condition.
296
+ `question`, `landed`, `pr:draft`, `pr:conflict`, and `pr:ready` are never
297
+ suppressed — a rebase is the human's or the helm's call, never assumed to be
298
+ the fix continuation's.
299
+
300
+ **Answered questions.** A `question` stands only while no message to the
301
+ dispatch is newer than its latest `needs-decision` / `blocked` entry. A
302
+ message counts when it carries provenance: `sendMessage` writes a
303
+ `NNN.meta.json` sidecar `{ from, at }` beside each `NNN.msg` (`from` is
304
+ `helm`, `session:<8>`, `terminal`, `tracker:<source>`, or `node`), and
305
+ `acknowledge` moves it into `handled/` with its message. Any sender counts;
306
+ a record without a sidecar (written before provenance existed) never does;
307
+ the sidecar's `at` decides, never file mtime. `man wait`, the park, and the
308
+ reminder loop apply the same predicate.
309
+
310
+ | Word | Meaning |
311
+ | ---- | ------- |
312
+ | 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`). |
190
313
 
191
314
  ## Exit codes
192
315
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobstah",
3
- "version": "0.5.0",
3
+ "version": "0.5.7",
4
4
  "description": "Harness-agnostic, token-efficient supervision framework for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "aequitas labs LLC",