# Request intake, dedup, incidents, and agent association

audience: AI coding agents first.

status: ACTIVE
task IDs: —
source request: 2026-08-14 owner rulings, in order: (1) a hook that turns EVERY prompt into a request is "a huge mess — I ask a lot of dumb questions that don't qualify"; an LLM classifier in the prompt path is rejected (slow, hallucinates). (2) What classifies: skill invocations (`/brainstorm`, `/plan`, `/factory`, …), agent judgement, and an explicit slash command — all three write. (3) "There must be a deduplication mechanism, it's a huge gap. I keep asking for the same thing. The agent must just pick the task and tell me the status of what I am asking instead of accepting to rebuild it." (4) `/fire` for agents to register infra fixes "instead of having 4 agents with a failed deploy go and try to fix the same thing at the same time". (5) `/request` to add a ticket manually. (6) Agent-to-task association must be visible in Overdeck.

## Ruling: incidents and tasks are ONE store

Same object — work with a state, an owner, and proof. Two stores means split-brain: an incident that
is really a task, a task nobody realises is an incident, two boards to check. One `requests` table,
an `origin` field (`owner` | `agent-incident`), views filter. The board's **Blocked — needs you**
column already carries anything needing the owner; incidents that do not need the owner never reach
the owner's eyes.

## S1 — Three writers, no classifier

NEVER classify prompts with a model or a heuristic over free text. Rows are created by:

1. **Skill invocation** — `/brainstorm`, `/plan`, `/factory`, `/ship`, `/run-plan` and peers. Invoking
   one IS the declaration that this is work. Zero ambiguity; the prompt text is the title.
2. **Agent judgement** — an agent claiming a worktree or writing a plan file creates the row. This
   sits on a path the worktree guard already makes mandatory, so it cannot be skipped.
3. **`/request`** — owner-explicit capture for an ask that matters before either fires.

Consequence, and it is the point: an ordinary question produces NOTHING. No row, no noise.
Known trade-off: a row appears when work STARTS, not when the owner asks. Accepted — the thing the
owner forgets is what is in progress.

Title is the OWNER'S WORDS, trimmed. NEVER an agent paraphrase.

## S2 — Dedup at intake, BEFORE work is accepted

A board that only records repeats makes the problem visible without solving it. The check runs at
the same seam that creates the row, and it BLOCKS the accept path on a confident match.

- Match across ALL states including `shipped` — the worst repeats are things already delivered (the
  DiffViewer was asked four times AFTER it landed). A shipped match answers with the proof link,
  which ends the conversation immediately.
- Cheap and deterministic first: normalized title overlap, shared `plan_ref`, same touched
  files/module, near-duplicate wording. NO model in the hot path.
- A free model is permitted ONLY to adjudicate a weak candidate, because that path is already
  off-hot (an agent is about to spend an hour) and its failure mode is a question to the owner, not
  a wrong action. Route through `model-test-fixture` policy; never a raw CLI.
- Three outcomes: **confident** → agent reports status and does NOT start ("asked Aug 9, in flight,
  worker debian2, last event 20 min ago, plan …; still want a second one?"); **weak** → agent starts
  but names the possible twin in its first message; **none** → row created, work proceeds silently.
- Threshold starts STRICT and loosens on measurement. Rollout: silent-log → advisory → enforcing.
- Blast radius: a false positive stalls real work behind one question (recoverable); a false
  negative is today's status quo.

## S3 — `/fire`: agent-registered incidents with an atomic claim

Problem observed 2026-08-14: three lanes independently discovered the same broken deploy; two nearly
fixed the same gateway config simultaneously.

- Agents invoke `/fire` with a SIGNATURE, not prose: failing unit + error class + path, e.g.
  `deploy-local:actions-gateway-config-missing`. Machine failures produce identical strings, so this
  is dedup by exact key — far more reliable than the similarity matching S2 needs.
- Open incident with that signature exists → the agent is told "claimed by lane X, started 6 min ago,
  status Y" and does NOT start; it waits or works around. None → create AND ATOMICALLY CLAIM, using
  the same claim discipline the land queue already implements.
- The incident carries its proof path, so waiters learn from the row instead of rediscovering.
- `/fire` NEVER bypasses runtime-first doctrine — `od-emergency` still owns HOW a fire is fixed.
  `/fire` is registration + claim only, never a new repair path.
- FAIL OPEN: incident store unreachable → the agent proceeds. A dedup mechanism that can halt all
  repair during an outage is worse than duplicate repairs.
- Durable version (the anti-rot form): the shared checker's failure-triggered hint injects "this is
  incident #N, claimed by X" automatically when a command dies with a known signature — no agent
  discipline required. Skills that depend on being remembered are how trains rotted.

## S4 — Agent association, visible

The `worker` field exists and is always `—` today. Fill it at claim time from identity that already
exists: session id, tmux pane, and the containment ledger record naming the buildbox
(`~/.overdeck/containment.jsonl`). Nothing new to invent — it must be WRITTEN to the row.

Board shows, on the card face: worker name + where it runs (laptop / debian1 / debian2), the lane
footer the approved mockup already draws, and a link into that agent's activity story (landed
2026-08-14). Clicking a card answers "who is doing this and what have they done".

## Multi-project enrolment

The store already has `project`; the board already has filter chips — multi-project is designed in,
not retrofitted. Per project: a plan registry (`docs/plans/INDEX.md`), a seed run tagging rows with
the project, intake writers deriving `project` from the session cwd/repo, and land-queue enrolment
for shipped/landing events. First targets: Press.zone backend and Press.zone plugins (SEPARATE
projects, one per GitHub repo — the plugins monorepo already has an index to seed from), then
zync.is.

## Constraints

- No LLM in any hot path (prompt submit, PreToolUse). Deterministic matching only.
- Every writer fails open and silent: intake failure must never delay or annoy the owner's prompt.
- Owner-visible surfaces get owner language — no jargon, no internal stage names on a card face.
- Honest data: unknown renders as unknown; never fabricate a worker, stage, or twin.

## Current receipt

2026-08-14: design registered from owner rulings. S1/S2 built in `wt/intake-writes`: store has
`origin` (owner|agent-incident, default owner, additive migration), deterministic dedup
(`collector/src/requests/requests-dedup.ts`, same-project only, exact-title/plan_ref/token-overlap,
matches across ALL states incl. shipped) runs inside `POST /requests` — confident match blocks with
409 + the existing row, weak match is advisory on 201, `skipDedup` escapes it. Two of the three S1
writers landed: **skill invocation** (`PostToolUse` on the `Skill` matcher, `skill-intake-writer.mjs`,
fires only for `brainstorm|plan|factory|ship|run-plan`, title = the skill's own `args` verbatim) and
**`/request`** (`.claude/commands/request.md` → `od-requests add`, reports the owner a confident
twin's status instead of creating a duplicate). **Agent-judgement writer (worktree claim /
plan-file write) NOT built this slice** — `od-worktree add` is a bash script with no reliable access
to the owner's verbatim words at that seam; wiring it there would mean an agent-paraphrased title,
which the plan explicitly forbids. Needs its own seam design (most likely: the `plan` skill's write
already covers "agent judgement" for planned work; unplanned agent-initiated work is the open case).
S3 (`/fire`), S4 (agent association), and multi-project enrolment not started.

## Related

[2026-08-15-plan-status-self-reconciling.md](2026-08-15-plan-status-self-reconciling.md)
is the same underlying defect applied to `docs/plans/INDEX.md` instead of the requests
board: written intent that nothing keeps honest against reality. The dedup mechanism
here and the evidence-derived status rule there are two instances of one cure.

2026-08-15: S3 (`/fire`) built in `wt/intake-finish`, LANDED (`ca1e80c2f`) and DEPLOYED.
Atomic claim reuses the store's existing PRIMARY KEY discipline instead of adding a new unique
column: `fireIncidentId(project, signature)` (`collector/src/requests/requests-fire.ts`) derives a
deterministic id from project+signature, and `RequestsStore.claimFire`
(`collector/src/requests/requests-store.ts`) does a single `INSERT ... ON CONFLICT(id) DO UPDATE
... WHERE state = 'shipped' RETURNING *` — no read-then-write race. Three outcomes in one
statement: no existing row → inserted and claimed; existing OPEN row (any state but `shipped`) →
conflict, `WHERE` false, nothing returned, caller told who already owns it (`claimed: false` +
the existing row, so worker/asked_at are visible); existing `shipped` row (a fixed failure recurring)
→ conflict, `WHERE` true, row reopened to `asked` with new worker/detail in the SAME statement, so a
signature is never permanently blocked by its own history. Route: `POST /requests/fire`
(`collector/src/server.ts`, registered before the generic `/requests/:id` handler). CLI: `od-requests
fire <signature...> [--project P] [--title T] [--worker W] [--detail D]`
(`modules/workstation/claude/bin/od-requests`) — FAILS OPEN: any network/non-{201,409} failure is
caught, printed, and exits 0 so the caller's own recovery is never blocked. Skill:
`modules/workstation/claude/commands/fire.md`, mirrors `/request`'s proven shape (signature not
prose, three read-the-CLI-output branches, explicit "never a substitute for /od-emergency").

**Verification status — landed, deployed, live-proven.**
`collector/src/requests/requests-store.test.ts` (6/6 pass, run directly via `/usr/bin/bun test`,
bypassing the PATH shim) proves the atomic-claim state machine at the store layer: fresh claim,
open-duplicate blocked with one row preserved, shipped-incident reopened on recurrence. Landed via
`ship.sh land` (`ca1e80c2f`, rebased past one plan-file receipt conflict — resolved by keeping both
sides' prose) and deployed via the coalesced local deploy queue. LIVE PROOF against the running
collector (`http://127.0.0.1:31338`): two identical `POST /requests/fire` calls with the same
signature — first returned 201 (`claimed: true`, one new `agent-incident` row), second returned 409
(`claimed: false`, same row, told who already holds it) — then that proof row was marked `shipped`
to leave the board clean. NOT run: `routes.test.ts` in-process (needs `zod`, and no `node_modules`
exists anywhere in this repo tree — confirmed missing at repo root, worktree, and main checkout; all
prior `bun test` runs in this project were executing REMOTELY on a buildbox via the `bun`/`pnpm` PATH
shim's forced remote-only dispatch, never locally) and the mock-server CLI test
(`modules/workstation/claude/tests/od-requests.test.sh`) — the live curl proof above covers the
same route contract end to end. UPDATE: once the fork ceiling recovered, the mock-server CLI test
was run against the deployed `~/.claude/bin/od-requests` (`readlink -f` confirms it resolves into
`~/.local/share/overdeck/deploy/...`, refreshed by the deploy watcher after landing): 13/13 pass,
including all 4 new `fire` cases (fresh claim, open-duplicate reported not duplicated, missing
signature exits 2, fails open on an unreachable collector). Named, not this slice's fault: for
roughly the first 40 minutes of this session the buildbox fleet was unreachable (`bb-status`: "no
reachable buildbox in the registry") AND the laptop was intermittently unable to fork
(`fork: retry: Resource temporarily unavailable` from bash/bun/git, `/proc/loadavg` showing ~6900
running tasks at the worst point) — both cleared before landing. Flagging for the owner/orchestrator:
the dispatch brief asserted "machine fault is FIXED (CPU cap removed)" — that was not true for the
first part of this session; whatever was spawning ~6900 processes needs its own look if it recurs.
S4 (agent association), A5 (duplicate-ask surfacing in the hook), and multi-project enrolment remain
not started — next executable action is S4.

2026-08-15 (second pass, `wt/intake-s4`): discovered the `/requests` board itself was already
built and landed by another lane (`d04b8b263`, prior to this session) — sidebar wired
(`sidebar-nav.ts`, `/plans` correctly absent), collector-backed, `RequestsBoard.tsx` already
computes counts and worker display live from `props.requests` (the API response), never from
`docs/plans/INDEX.md`. The owner's "Updated 15h ago / In flight 34 counting plans marked ACTIVE"
complaint describes `/plans` (still present, still live per its own retirement ruling — "may keep
serving deep links until the board replaces it" — but no longer advertised), not `/requests`. Did
NOT touch `/plans`; not in this plan's scope.

**A5 built and verified.** `skill-intake-writer.mjs` now emits `PreToolUse` hook output
(`permissionDecision: "allow"` — never blocks, S1's fail-soft rule holds — with
`permissionDecisionReason` carrying the verdict) whenever the dedup check 409s, e.g. `already
tracked: #<id> — asked <date>, state <state>, worker <worker>, proof <url>. Tell the owner before
doing this work again.` This is the exact mechanism Claude Code surfaces into the agent's own
context. `test-skill-intake-writer.mjs`: 6/6 pass (added case asserts the JSON shape and reason
text against a mock 409). Live proof still needed: invoking `/plan` twice with identical args in a
real session and observing the second invocation's reply cite the ticket — NOT done this slice
(would require a second live Claude Code turn outside this agent's own control to observe; the
hook-level proof above is the verifiable unit).

**S4 built, partially wired.** `deriveWorkerLabel` (`modules/workstation/claude/hooks/lib/worker-
identity.mjs`) produces `<short-session-id>@<host>` — reuses list-sessions' own short-id
convention (first 8 chars, never the full UUID) for the session half, and reads
`~/.overdeck/containment.jsonl`'s `host` field (same vocabulary S3 already established) for the
buildbox half, falling back to `"laptop"`. `od-requests claim <id> [--session S] [--worker W]`
(new CLI verb) sets `state=in_flight` + `worker` via the store's existing generic
`POST /requests/:id` — no store schema change needed. Card face
(`RequestsBoard.tsx`): `Worker: {row.worker ?? 'unknown'}` (was `'—'`, which does not read as
"unknown" per the honest-data rule) already renders whatever short label is stored — never a raw
UUID, by construction of `deriveWorkerLabel`. Freshness label reworded from the ambiguous
`Updated {time}` to `Newest board change {time}` — states what it measures. Tests: CLI mock-server
17/17 pass (4 new `claim` cases), `RequestsBoard.test.tsx` updated for the wording change, full
`pnpm --filter web test` 459/459 pass, `pnpm --filter web typecheck` (astro check) 0 errors.

**NOT done — named gap:** `od-requests claim` is a working, tested primitive but nothing calls it
automatically yet. No code path today transitions a row from `asked` to `in_flight`, so `worker`
stays `unknown` on every real row until something invokes `claim`. The natural next seam (not
built this slice, to avoid re-litigating S1's "skill invocation = asked" semantics without owner
sign-off): decide whether `/factory` and `/ship` — which launch execution immediately, unlike
`/plan`/`/brainstorm` — should set `in_flight` + worker at intake instead of `asked`, or whether
claiming belongs at the actual dispatch point (buildbox handoff, `od-worktree add`). This is the
next executable action, and it is a product decision (which skills count as "started") more than a
plumbing one.

**Board accuracy — no code change needed beyond the wording fix above; counts were already
live-derived.** `summaryCounts()` in `request-board.ts` filters `props.requests` (the live API
response) by `state`; nothing here ever read `docs/plans/INDEX.md`.

**Multi-project enrolment: still not started.** Next after S4's claim-wiring decision.

**Landing status: LANDED on `origin/main` (`5a35cf812`) AND DEPLOYED.** The deploy clone cleared
(another session's WIP resolved) and the watcher deployed `main` automatically. Live proof against
the deployed entrypoint (`~/.local/share/overdeck/deploy`, `http://127.0.0.1:31338`): created a
disposable row, ran `od-requests claim <id> --session testsession12345678`, confirmed via
`GET /requests` that the row read back `state: in_flight`, `worker: "testsess@laptop"` — the exact
short-label format `RequestsBoard.tsx`'s `InFlightCard` renders, never a raw session id — then
marked it `shipped` to leave the board clean.

2026-08-15 (third pass, `wt/intake-autoclaim`): the claim-wiring decision is DECIDED (owner
delegated the mechanics via the coordinator) — wired both seams:

1. **`/factory` and `/ship` auto-claim their own fresh row at invocation**, in
   `skill-intake-writer.mjs`: after a successful CREATE (status 201 only — a 409 dedup match is
   never auto-claimed, so this hook can never steal another lane's existing claim), it POSTs a
   claim (`state: in_flight`, `worker` from `deriveWorkerLabel({ sessionId: inp.session_id })`) to
   the row it just created. `/plan`, `/brainstorm`, `/run-plan` do NOT auto-claim — invoking them
   is exploratory/queued, not "work visibly started" (owner's own framing). `postRequest` in
   `requests-client.mjs` widened to accept HTTP 200 as success (it previously only recognized
   201/409 — needed for the claim PATCH's 200 response). 10/10 hook tests pass (4 new: factory
   auto-claims, ship auto-claims, plan does not, a 409 duplicate is never auto-claimed).
2. **`od-worktree add` associates, never creates**: a new `associate_request` bash function runs
   after worktree creation, best-effort (never fails the worktree command). It calls
   `od-requests list --project <basename of MAIN_ROOT> --state asked --json` (new `--json` flag
   on `od-requests list`, 18/18 CLI tests pass including this addition), filters to rows with no
   `worker`, and claims ONLY when exactly one such row exists — zero or multiple candidates is a
   silent no-op (never guesses, never creates). New test
   `modules/workstation/claude/tests/od-worktree-associate.test.sh`, 5/5 pass: one-match claims,
   zero-match no-ops, ambiguous-match no-ops, already-claimed row is never re-claimed, and a
   missing `od-requests` on `PATH` never blocks worktree creation. Existing
   `od-worktree-registry.test.sh` re-run unchanged, still 10/10 (its restricted `PATH` never
   exposes `od-requests`, so `associate_request` silently no-ops there, as designed).

**Landed (`ba9d74653`) and deployed. Live-proven against the deployed hook + collector**: piped a
synthetic `PreToolUse`/`Skill` payload (`skill: "factory"`, a real session id) straight into the
deployed `skill-intake-writer.mjs`, then read the row back via `GET /requests` —
`state: "in_flight"`, `worker: "liveproo@debian1"` (it picked up a REAL recent buildbox host from
the live containment ledger, not the "laptop" fallback — proves the containment lookup path too).
First attempt (skill `"ship"`) hit the 800ms `postRequest` timeout on the follow-up claim call
under transient load (`AbortError`, logged to the intake log, fails open exactly as designed —
the CREATE still succeeded, only the claim step timed out); a second attempt with `"factory"`
succeeded cleanly. Flagging: 800ms may be tight for a claim POST issued immediately after a
create POST on the same connection under load — worth widening if this recurs, not fixed this
pass (no evidence yet of it being a real problem vs. one slow moment). Both proof rows marked
`shipped` and cleaned up. NOT verified: `od-worktree add` triggering a real association against
the LIVE collector (covered by the fake-`od-requests` integration test instead, which exercises
the real bash logic end-to-end minus the network hop) — and a real `/ship`/`/factory` invocation
from an actual live Claude Code turn (the piped-payload proof above exercises the identical code
path Claude Code's own PreToolUse dispatch would trigger).

**Next: multi-project enrolment** (Press.zone backend, Press.zone plugins, then zync.is) — board
and store already support arbitrary `project` values; no schema change needed, only seeding
project-scoped rows through the existing writers with the target repo's basename as `project`. No
code changes in those repos.

2026-08-15 (fourth pass, `wt/intake-multiproject`): **capability confirmed live, actual enrolment
NOT done — named ambiguity, not a code gap.**

- `skill-intake-writer.mjs` is registered GLOBALLY in `~/.claude/settings.json` (a PreToolUse
  hook on the `Skill` matcher, not scoped to overdeck), and `deriveProject(cwd)` is already
  repo-agnostic (basename of the nearest `.git` root, `.worktrees/<slug>` suffix stripped). This
  means invoking `/plan`, `/factory`, `/ship`, etc. inside Press.zone backend, Press.zone
  plugins, or zync.is ALREADY writes rows tagged with THAT repo's own project name today, with
  zero code change — enrolment was designed in, exactly as the plan's "Multi-project enrolment"
  section states.
- Live-proved the whole path end to end against the deployed collector: created a row with
  `project: "press-zone-backend"`, confirmed via `GET /requests` it appears and the distinct-
  project list already includes an unrelated pre-existing `"user"` project alongside
  `"overdeck"` (i.e. multi-project is not hypothetical — something is already tagging rows with a
  third project today), then marked the proof row `shipped` and cleaned it up. The board's filter
  chips (`distinctProjects()`) need no change — confirmed generic in the S4 pass.
2026-08-15 (sixth pass, `wt/agent-judgement-writer`): **S1's third writer — agent judgement —
built and landed.** The plan's own S4 receipt named the open case: a worktree claim could only
ASSOCIATE with an existing owner-ask row, never CREATE one, because a bash script has no access
to owner's-verbatim-words. Resolved by writing a row FROM the invocation payload instead of a
paraphrase: `od-worktree add`'s `associate_request()` now branches on the real `asked`-row count
for the project (not the filtered-unclaimed count, which conflated "no open ask" with "an open
ask someone else already claimed" — caught and fixed by an advisor review before landing) — one
unclaimed row → associate as before; any other open row (claimed or not) → no-op, never
duplicate; zero open rows → `od-requests add "Agent started work on: <slug with hyphens spaced
out>" --project P --origin agent-judgement`, then claim the row it just created. Title is built
only from the worktree slug (the one concrete payload this seam has) and the literal string
"Agent started work on:" — never conversation text. `od-requests add` already runs S2's
server-side dedup across every state before creating, so a genuine duplicate 409s and the branch
no-ops (caught by `|| return 0` under `set -euo pipefail`, mirroring the existing `claim` line).

Schema: added a third `RequestOrigin` value, `agent-judgement`, alongside `owner` and
`agent-incident` (`agent-incident` stays `/fire`'s alone — a worktree claim is not an incident,
conflating the two would violate the honest-data rule). Confirmed against the LIVE deployed
sqlite (`~/.local/state/overdeck/requests.sqlite`, `.schema requests`) that the `origin` column
already carries no CHECK constraint (added via the migrated `ALTER TABLE` path, not the
CHECK-bearing `CREATE TABLE IF NOT EXISTS`) — so the new value is additive on the live store with
no table rebuild. Widened at all four sites: `RequestOrigin` union + `ORIGINS` array
(`collector/src/requests/requests-store.ts`), `RequestOriginSchema` (`collector/src/server.ts`),
CLI `--origin` validation (`modules/workstation/claude/bin/od-requests`).

Board (requirement 2): `apps/web/src/lib/request-types.ts` widened with the same `RequestOrigin`
type (previously `origin` was in the collector's JSON but invisible to the web TS layer — the
type had no field for it at all). `RequestsBoard.tsx` adds one shared `AgentInitiatedBadge`
(`origin !== 'owner'` → `<Badge variant="info" label="Started by an agent" />`, no jargon, no
ID), rendered on all four card shapes (`AskedCard`, `InFlightCard`, `BlockedCard`, `ShippedCard`)
since an agent-initiated row can land in any column — an incident row (`agent-incident`) gets the
same badge as a judgement row, both being "not the owner's own ask," which is exactly what
requirement (2) asks the owner to be able to tell at a glance.

Tests: `od-worktree-associate.test.sh` 7/7 (2 new cases: zero-match creates + claims an
agent-judgement row; a dedup 409 on the create attempt never claims a row it didn't just make;
existing ambiguous/already-claimed/one-match/no-od-requests cases re-verified unchanged).
`od-requests.test.sh` 18/18 unchanged. `requests-store.test.ts` 7/7 (1 new: accepts
`agent-judgement`, rejects an invalid origin string). Full collector `bun test`: 24/24 in
`src/requests/`, whole-repo run has exactly one pre-existing unrelated failure
(`buildAdapters > default config enables every wave-1 adapter`, untouched by this slice, present
before these edits). `pnpm --filter web test` 464/464. `pnpm --filter web typecheck` 0 errors (2
pre-existing unrelated warnings in `FileIncidentForm.tsx`, not touched). `pnpm --filter web
build` clean.

Landed via `ship.sh` (no `.claude/scripts/ship.sh` existed in this worktree — regenerated via
`ship-init.sh --mode merge-to-main --delivery local-script --delivery-cmd "bash
packaging/deploy-local.sh"` per this project's trunk-based CLAUDE.md doctrine, not the tool's
own PR-mode heuristic, which had no visibility into that doctrine).

**LANDED (`4f2b39b0f2c1dbdbef8905c1e52e85a463c0f06b` on `origin/main`) AND DEPLOYED.** Queue
depth was 8 at submission; ran `packaging/deploy-local.sh` by hand after landing to be sure
(idempotent, safe) — deployed `30ce47cb` (a few landings ahead of mine by the time the queue
drained; confirmed my commit is an ancestor of the deployed sha before trusting it).
**Live-proven against the real deployed `od-worktree`** (`readlink -f $(command -v
od-worktree)` resolves into the deploy clone, not this worktree): ran `od-worktree add` in a
disposable scratch repo with an empty request board (project `proof-repo`), read the row back
from the live collector (`GET /requests`) — `origin: "agent-judgement"`, `state: "in_flight"`,
`worker: "agent@laptop"`, title `"Agent started work on: agentjudgeproof123"` (built only from
the worktree slug, never paraphrased). Confirmed the deployed web client bundle
(`.releases/.build-30ce47cb…/client/_astro/*.js`) contains the exact string `"Started by an
agent"` — the badge code shipped. Proof row marked `shipped` and the scratch repo deleted
immediately after. Also confirmed the seam is fail-safe when there's nothing to do: a second,
real `od-worktree add` for THIS receipt-writing worktree (project `overdeck`) created no
spurious row and left the one existing unrelated `asked` row for `overdeck` untouched — the
associate-vs-create branch correctly did neither when there was nothing new to say.

**NOT verified:** a real browser rendering of `/requests` with the badge visibly on the card
face. The board is a client-hydrated React island; a static `curl` of the page shows no
client-rendered content, and a true rendered check needs a headless browser, which this
workstation blocks outright (`install-headless-guard`, exit 97) unless routed through
`e2e-remote` onto a buildbox. Substituted two narrower proofs instead: (a) the exact JSON the
board's own `fetchRequests` call would receive, confirmed via the live collector API above, has
`origin: "agent-judgement"` on the row; (b) `RequestsBoard.test.tsx` (8/8 passing, run this
session) renders that exact same row shape through the exact same component and asserts the
"Started by an agent" text is present. Between the two, the only unverified link is React
actually hydrating in a real browser — standard framework behavior, not new code, and not
practical to prove without the buildbox hop this slice did not spend on a purely-cosmetic check.
If the owner wants the literal pixel proof, it is one `e2e-remote` call away.

2026-08-15 (fifth pass): repo-path ambiguity resolved by the coordinator (verified against disk,
not guessed) — **all three targets enrolled, live-proven, zero code changes anywhere.**

- **Press.zone backend** = `/home/user/Projects/Press.zone/press-zone-core` (own `.git`, remote
  `github.com/alexcodeplace/press-zone-core`) → `deriveProject()` yields `project: "press-zone-core"`.
- **Press.zone plugins** = the WordPress tree rooted at
  `/home/user/Projects/Press.zone/wordpress/wp-content` (own `.git`, remote
  `github.com/avi-ezra/Press.Zone-Works.git`; established Claude project base with its own
  memory) → `deriveProject()` yields `project: "wp-content"`. The flagship plugin inside it is
  `international-press-zone` (confirmed present at
  `wp-content/plugins/international-press-zone`) — that plugin name is a component of this
  project, not the project id.
- **zync.is** = `/home/user/Projects/zync.is` (own `.git`, remote
  `github.com/alexcodeplace/zync.is.git`) → `project: "zync.is"`. Board enrolment ONLY, per the
  coordinator: a separate lane owns zync.is's LANDING-cycle enrolment (git config + wrapper) —
  untouched, no files under that lane's ownership were read or written.
- **Live proof, same pattern as `press-zone-backend`'s capability check**: for each of the three
  repos, piped a synthetic `PreToolUse`/`Skill` payload with `cwd` set to that repo's real path
  straight into the DEPLOYED `skill-intake-writer.mjs` (the actual global hook, not a simulation
  of it), then read the created row back via `GET /requests` on the live collector and confirmed
  `project` matched exactly: `press-zone-core`, `wp-content`, `zync.is`. All three rows (plus the
  earlier capability-check row) marked `shipped` and cleaned off the board afterward — this
  session created no permanent rows.
- **No code changes anywhere** — confirms the plan's own claim: "multi-project is designed in,
  not retrofitted." The global `PreToolUse` hook registration already covers every repo on the
  machine; `deriveProject()` was already repo-agnostic; the board's `distinctProjects()` filter
  chips were already generic (confirmed in the S4 pass). Enrolment was a verification exercise,
  not a build.
- **Not done, deliberately out of scope:** no `docs/plans/INDEX.md` was created inside
  `press-zone-core` or `wp-content` — the coordinator's instruction was "no code changes in those
  repos," and a plan-registry file is a source-tree change in someone else's repo. If the owner
  wants a durable plan registry in those repos too, that is a separate, explicit ask.

2026-08-16 (seventh pass, `wt/task-board-sync`, board row `manual-dcd84e81acbe8bcb`): **fourth
writer — the session task list — mirrored to the board mechanically, plus a root-cause fix to
the sixth pass's own writer.**

- **New writer: `task-board-sync.mjs`**, a PostToolUse[`TaskCreate|TaskUpdate`] dispatcher entry
  (`lib/dispatcher-registry.mjs` + `lib/dispatcher-manifest-posttooluse.mjs`, same pattern
  `edit-inspector.mjs` uses — the most recently added PostToolUse gate at the time, ahead of the
  standalone-settings.json pattern `skill-intake-writer.mjs` used). Mirrors every session's
  structured task list (`~/.claude/tasks/<session-id>/<n>.json`, subject/description/status,
  survives compaction) onto the board: `TaskCreate` → `POST /requests` with
  `origin: agent-judgement`, `project` from `deriveProject(cwd)`, id
  `manual-${fingerprint(project+title)}` (same scheme `od-requests add` itself uses);
  `TaskUpdate` → `in_progress → in_flight`, `completed → shipped`.
- **Named deviation from the brief:** asked to use "the CLI's update mechanism." `od-requests`'
  only state-writing subcommand is `claim`, hardcoded to `state:"in_flight"` — there is no CLI
  path to `shipped` at all, and writing `in_flight` onto a completed task would be actively
  wrong data on the owner's board (independent technical cause to deviate, not a style choice).
  Went straight to `requests-client.mjs`'s `postRequest()` instead — the exact same
  `POST /requests(/:id)` seam `od-requests`' own `cmdClaim` and `skill-intake-writer.mjs`
  already use, just not funneled back through the `od-requests` binary. `requests-store.ts`'s
  `state CHECK (state IN ('asked','in_flight','blocked_needs_owner','shipped'))` confirms the
  collector endpoint itself accepts all four; only the CLI wrapper around it is narrow.
- **Idempotent, edge-triggered, fail-open, no timers:** mapping file
  `~/.local/state/overdeck/task-board-sync/<session-id>.json` (or `$OVERDECK_STATE_DIR`
  equivalent), `{ [taskId]: { requestId, state } }`, atomic tmp+rename. A task already mirrored
  is never re-added (checked against the mapping); an unchanged state is never re-sent (checked
  against the mapping's recorded state, not re-derived); a 409 dedup match on create still
  records the mapping (prevents a lost-mapping retry from flipping an already-`shipped` row back
  to `asked` via `requests-store.ts`'s upsert). Subagents are silenced via `isSidechain()`, same
  guard `skill-intake-writer.mjs` uses and for the same reason (a subagent shares the parent
  session's task dir and would otherwise flood the board every turn). Noise-prefix guard: a task
  subject already carrying the `[dismissed] ` marker `todo-tasks.mjs`'s `dismiss` command writes
  is never mirrored on create — this is the only recognized noise prefix the task tooling has;
  `dismiss` itself never calls the native `TaskUpdate` tool (it writes the task file directly),
  so a mirrored-then-dismissed task cannot currently be reflected as dismissed on the board —
  named gap, not silently swallowed.
- Tests: `test-task-board-sync.mjs`, 12/12 (real run this pass), same fake-HTTP-collector
  pattern `test-skill-intake-writer.mjs` uses (never a real od-requests/collector write from a
  test) — create mirrors once; a second identical event no-ops; `in_progress`/`completed` map to
  `in_flight`/`shipped` and an unchanged state is never re-sent; a `TaskUpdate` for a
  never-mirrored task id is silent; empty subject and `[dismissed] `-prefixed subject are never
  mirrored; a non-task tool and a sidechain call are silent; collector-unreachable and malformed
  stdin both exit 0 with no output. `dispatcher-settings-sync.test.mjs` and the rest of
  `hooks/test/` re-verified green (9 files, 88 tests, 86 pass — the 2 failures are
  `model-test-fixture-gate.test.mjs`, confirmed pre-existing and unrelated by running the same
  suite on `git stash` before these edits, same 2 failures).

- **Root-cause fix to the sixth pass's `associate_request()`:** its "any other open row for
  this project → no-op" branch was meant to protect an obvious single match, but conflated "no
  open ask" with "an open ask that merely happens to exist" — in a project that permanently
  carries dozens of open rows (overdeck itself), that condition was always true, so the branch
  NEVER created a row, ever. Fixed by removing the client-side row-count gate on the create
  path: the one-unclaimed-row → associate-directly branch is kept unchanged (it is the one case
  genuinely decidable without a network round trip); every other shape (zero unclaimed rows,
  multiple unclaimed rows, every open row already claimed by someone else) now falls through to
  `od-requests add` and lets the server's own S2 dedup be the tie-breaker — a genuine duplicate
  title still 409s and no-ops via the same `|| return 0` the zero-rows path already had, never a
  client-side pre-filter substituting for it.
- Tests: `od-worktree-associate.test.sh` extended to 10/10 (2 new cases per the coordinator's
  ask: many unrelated open rows, some ambiguous/some already claimed by others → still creates
  and claims a fresh agent-judgement row; the same many-open-rows shape but the create attempt
  itself hits a genuine server-side 409 → still no-ops, never claims). The two prior
  ambiguous-match/already-claimed cases were updated in place (their old assertion — "never
  claims anything" — was exactly the darkness being fixed; they now assert "creates and claims
  its OWN new row, never an unrelated existing one").
- **Not verified:** a real invocation against the live deployed collector (both fixes were
  proven against fake-HTTP/fake-`od-requests` shims only, per this task's explicit "never write
  real board rows from tests" instruction — the sixth pass's own live-collector proof pattern
  was not repeated here). The landing lane owns that verification step.

**DESIGN ONLY — owner-ratified writer roadmap, NOT implemented in this pass** (board row
`manual-53544f9dc7637248` tracks the implementation; recorded here per this plan's own receipt
convention so the ranking and rulings are not re-litigated by the next lane):

Writers ranked by signal quality, strongest first: plan-doc creation > worktree claim (this
plan's sixth/seventh passes) > `TaskCreate` (this pass) > `cdx exec` > anything prompt-shaped.

- **Plan-doc creation** (`docs/plans/`, `specs/`) is the strongest trigger: the slug filename is
  the dedup key, title derived from the slug, never prompt text. Plan-doc EDITING never fires —
  receipt appends (like this one) are noise, and status flips belong to registry reconciliation,
  not a per-edit hook. Exclusions: `INDEX.md`, `INDEX-archive.md`, archive moves.
- Coverage caveat, named up front: a PostToolUse hook misses codex-offload-authored plans — they
  arrive as fetched git commits, never a `Write` tool event. Needs a land/fetch-time leg or a
  reconciliation pass, not a pure PostToolUse hook.
- **Phase 2** (only after these two fixes soak — see sequencing below): `ship.sh submit`/land-
  queue ticket as a state-transition writer (flips an existing row via its branch/ticket key,
  never creates); factory-run start (`adw_id` + request-spec path as the dedup keys); then
  `cdx exec`/`ask-gpt` dispatch writers, with a probe-model carve-out — spark and the
  `model-test-fixture` allowlist models NEVER write rows, so a test/probe run never pollutes the
  board; a direct-land push seam (the git PATH shim already intercepts this point, so the writer
  rides an existing interception, not a new one); session-death salvage rows
  (`origin: agent-incident`) and `od-wip restore` association.
- **Universal law for every writer, present and future:** associate-don't-create; server-side
  409 dedup on a STABLE key (slug, branch, `adw_id`, ticket — never free prose) is the ONLY
  convergence mechanism, never a client-side row count (exactly the bug this pass just fixed);
  every writer fails open. Rejected trigger classes, explicitly: git commits, telegram messages,
  memory writes — none of these become board rows.
- **Sequencing, owner-set:** land these two fixes first, observe whether dispatches still go
  dark for a day, and add phase-2 writers only where darkness remains — not preemptively.

2026-08-16 (eighth pass, same branch — `work_key`, owner-ratified, SUPERSEDES the seventh
pass's title-similarity approach for both writers): **first-class exact-key dedup landed,
`od-requests` and both writers updated to it; the CLAUDE.md doctrine section below is written
but explicitly NOT active yet.**

- **Extended the existing external-id seam instead of adding a parallel one, per instruction:**
  `plan_ref` was already schema-present in source (the ORIGINAL `CREATE TABLE`, not one of the
  `ALTER TABLE`-migrated columns like `origin`/`announced_at`) and already ran exact-match
  BEFORE title-fuzzy in `requests-dedup.ts`'s `findMatch`. Confirmed present on the LIVE sqlite
  too, not just inferred from source (`PRAGMA table_info(requests)` against
  `~/.local/state/overdeck/requests.sqlite` lists `plan_ref` — same verification-not-guessing
  precedent the sixth pass set checking `.schema` directly). No new column, **no migration
  needed** — the "check the `origin` column's `ALTER TABLE` precedent" test requirement is
  answered by "there is none needed here, confirmed against the live store." `od-requests add` gains `--work-key` as the CLI-facing name
  (writes the same `plan_ref` field; `--plan-ref` still works and wins if both are given).
- **Dedup semantics changed, not just plumbed through:** a KEYED candidate (one that passed a
  `plan_ref`/`work_key`) now NEVER falls back to title-jaccard — it either matches an existing
  row's key exactly (confident, returns immediately) or gets `null` (create). Before this pass,
  a keyed candidate that missed the key check still fell through to fuzzy title matching against
  every row, which is exactly the class of bug that made the seventh pass's od-worktree fix
  risky in the first place (every agent-judgement row shares the literal prefix "Agent started
  work on:", so two unrelated worktree slugs can jaccard-overlap on that alone). Keyless
  candidates are completely unchanged — the fallback is still there for every writer that has no
  stable identity to key off of (`skill-intake-writer.mjs` today).
- **Both writers updated, `list`-free:**
  - `od-worktree`'s `associate_request()` (superseding the seventh pass's list-then-count
    version entirely): `od-requests add --work-key "$slug" ...` — 201 creates and claims a fresh
    row; 409 (exact key match) associates/claims the row the id is parsed from
    (`already tracked: #<id>`). Never lists rows first. **Named behavior change:** this writer
    no longer associates with a pre-existing OWNER-authored row that merely resembles the
    worktree slug by title — an owner row has no worktree-slug key, and a keyed candidate no
    longer falls back to title similarity. Deterministic key-or-create is the whole contract now,
    exactly as instructed ("never pre-check by listing rows").
  - `task-board-sync.mjs`'s `TaskCreate` bridge: `deriveWorkKey()` uses the worktree slug when
    `cwd` resolves inside a `.worktrees/<slug>` checkout (the identical key
    `od-worktree`'s writer passes for that same worktree — the two writers now converge on ONE
    row for the same piece of work instead of ever creating two), else falls back to a
    session/task-scoped key (`task-<session8>-<taskId>`) so even a keyless-context row is still
    deterministic per task.
- **Correctness fix caught while wiring the real payload, not part of the ask but load-bearing:**
  `TaskCreate`'s `tool_response` is `{ task: { id, subject } }` (verified against a real recorded
  session's `.live.jsonl` transcript — its `toolUseResult` field IS the raw hook payload's
  `tool_response`), never the "Task #N created successfully: ..." string a transcript's
  *rendered* tool-result content shows. The seventh pass's id-extraction regex was written
  against the wrong (display-only) shape and would have matched zero real `TaskCreate` calls in
  production while all 12 of that pass's own tests stayed green (the tests supplied the wrong
  shape too). Fixed to read `tool_response.task.id` directly, with the string-regex kept only as
  a defensive fallback for a future response-shape change, never the primary path.
- Tests: `requests-dedup.test.ts` +5 (exact key wins over a very different title; a keyed
  candidate with a DIFFERENT key never falls back on a near-identical title; a keyed candidate
  against a keyless row never falls back; keyless candidates unchanged) — 11/11.
  `od-requests.test.sh` +2 (`--work-key` creates a row; the value lands in `plan_ref`) — 20/20.
  `od-worktree-associate.test.sh` rewritten for the list-free contract (fresh key creates+claims;
  an expected-existing key 409s and associates the EXISTING row, never creates a duplicate;
  missing `od-requests` never blocks) — 5/5. Also fixed a real leak this rewrite exposed: the
  prior test's "missing od-requests on PATH" case built its `PATH` by prepending to the ambient
  `$PATH`, which still resolves the REAL deployed `od-requests` from `~/.claude/bin` further
  down the list — every run of that case was capable of writing a live row to the real board.
  Replaced with a curated `PATH` (`$TMP/bin:/usr/bin:/bin`) that structurally cannot resolve it.

  `task-board-sync.mjs`: rewrote every test fixture to the real `{task:{id,subject}}` response
  shape, added 2 work_key tests (worktree-slug key; session-scoped fallback) and 1 defensive
  string-fallback test — 15/15. Full collector `bun test src/requests/`: 37/37. Full
  `hooks/test/`: 86/88 (the 2 `model-test-fixture-gate.test.mjs` failures are the same
  pre-existing, unrelated ones named in the seventh pass).
- **Not verified:** a real invocation against the live deployed collector (same as the seventh
  pass — every proof here is against fake-HTTP/fake-`od-requests` shims, per this task's explicit
  instruction never to write real board rows from tests).
- **Named tradeoff — one row per WORKTREE, not per task:** `deriveWorkKey()` returns the same
  worktree slug for EVERY task created inside that worktree. A second task's create 409s on the
  exact key and gets mapped to the FIRST task's `requestId` — both tasks then drive the same
  board row's state, so `completed` on task 1 flips the row to `shipped` while task 2 is still
  open in the session's own task list. Nothing crashes or duplicates (the local mapping keeps
  the hook itself idempotent either way), but the row is coarser than the task list — it tracks
  "this worktree has open work," not "this specific task is done." This is arguably the intended
  convergence with `od-worktree`'s own writer (same key, same row, by design), but it was not
  stated anywhere before this line and the owner will see a `shipped` row for a worktree that
  still has unfinished tasks in it if the FIRST task created happens to finish before the rest.

**PENDING DOC TEXT — LANDS AFTER work_key IS LIVE, not active yet, recorded here only so the
next lane does not re-litigate it or land it prematurely** (owner-explicit sequencing: this
schema+dedup change lands before any CLAUDE.md doctrine section — docs must never describe a
contract the server does not yet enforce in the version actually deployed):

```markdown
## Requests board — MANDATORY
Every real work item lives on the board (`od-requests`). Rows are created by the designated
writers (skill invocation, `/request`, `/fire`, worktree claim, task bridge) — never by
hand-rolled calls.
**Subagents NEVER create rows.** Your work is a sub-task of an existing row; your brief carries
its work key (plan slug / ticket / worktree slug). Associate or update that row only. No key in
your brief → report the gap, do not create.
One row per work item. Writers pass the stable key; the server's exact-key dedup is the only
convergence mechanism. Never pre-check by listing rows — attempt the write and let 409 decide.
```

Subagent rule (item 4 of the roadmap) is NOT implemented in this pass — no writer in this repo
is invoked from a subagent context that would need it enforced today (`isSidechain()` already
silences `task-board-sync.mjs` and `skill-intake-writer.mjs` for subagents entirely, which is
the current, stricter interim behavior: subagents mirror nothing at all, not "associate only").
Implementing the brief-carries-a-key contract is deliberately left to the phase-2 lane the
roadmap names, once a subagent-facing writer actually exists to enforce it on.

2026-08-16 (sol review of the phase-2 roadmap — gates before any phase-2 writer builds):

- BLOCKER: session-death salvage and od-wip-restore writers are structurally blocked, not
  soaking — they need the lifecycle's `orphaned` terminal state, which requires the
  table-rebuild migration gated in docs/plans/2026-08-16-task-lifecycle.md S4. These are
  the SAME event as lifecycle S4 (one event, one state, one writer) — build them together
  there, never as a second origin:agent-incident path here.
- BLOCKER: ship-submit's work key is unresolved — "branch/ticket key" must pick ONE.
  Ruling recorded: the queue ticket id is the key (it survives rebase; branch names do
  not), with the branch stored as evidence only.
- BLOCKER (landed-code defect, board row manual-242f2bfe7f5099a2): findMatch's exact-key
  409 is check-then-insert, not transactionally enforced — concurrent keyed adds can both
  create. Fix in the store (unique constraint or insert-with-conflict) BEFORE adding more
  writers that rely on 409 convergence.
- CORRECTION: every phase-2 writer that changes ownership/completion calls lifecycle
  transitions — claim-only and direct-state semantics are retired vocabulary.
- CORRECTION: the landed TaskUpdate writer itself still performs direct-state updates and
  its comments claim the collector accepts arbitrary states — migrate it to transitions
  in the same pass as the first phase-2 writer.
- CORRECTION: pi's independent model selection is a hole in the probe carve-out — the
  carve-out list becomes: spark, the model-test-fixture allowlist, AND any pi test
  dispatch (pi tests are luna/low per standing owner rule and never write rows).

2026-08-16 (ninth pass, exact-key race repair): `plan_ref` is now protected by the store, not
only by the request route's optimistic `findMatch` check. Startup verifies that no non-null
`plan_ref` occurs more than once, then creates the partial unique index
`requests_plan_ref_unique ON requests(plan_ref) WHERE plan_ref IS NOT NULL`. The migration
**fails loudly** when legacy duplicates exist, naming each duplicate key and count; it does not
delete rows or guess which historical request should win. A post-check insert conflict loads the
existing row and returns the same confident duplicate 409 payload as the fast path. Keyless
title-similarity matching and `/requests/fire`'s id-conflict claim remain independent.
