docs(dn): site-decision implementation plan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-09 06:57:29 +02:00
parent 01239c6633
commit 4a8edf1f7e

View file

@ -0,0 +1,68 @@
# Delivery-note site decisions — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When an agent cannot decide a delivery document, it leaves a plain-language question for the site. The question shows in the work queue and on the slip page; the site manager answers it there (with the slip beside the PO and the existing matching section to enter received quantities), and the answer is journaled.
**Architecture:** The question is a `checks` entry with `id:'site_decision'` on the `dn_batch_notes` record — no schema change. A failing `site_decision` check blocks readiness (unknown checks that have not passed already block in `evaluateCandidateReadiness`) and is surfaced first by `firstBlockingCheck`, which feeds the work-queue reason. One library module asks and answers; MCP tools and one API route call it; one small panel renders it on the document page.
**Tech Stack:** Next.js 16 App Router, TypeScript, PocketBase, Zod, Vitest, React Testing Library (existing `*.test.tsx` setup).
**Spec:** `docs/superpowers/specs/2026-10-09-delivery-agent-tools-design.md` (agents flag what needs a person) + David's approval on 2026-10-09: undecidable documents go to the site with the real question; site captures what was received with the PO shown.
## Global Constraints
- No deletes (`.delete(` never appears); no PocketBase schema changes.
- Every write records a `dn_review_events` row (actor, action, reason, before, after).
- Reuse: `runOperation` (`src/lib/purchasing/operations.ts`), `assertCandidateOpen` (`src/lib/purchasing/delivery-candidates.ts`), `PurchasingError`, `memoryDatabase()` (`src/lib/purchasing/test-support.ts`), `requireApiUser`/`requireSiteAccess` (`src/lib/auth/guard.ts`), `pbAdmin` (`src/lib/pb.ts`).
- Verify: `npx vitest run <paths>`, `./node_modules/.bin/tsc --noEmit --project tsconfig.json`, `npx eslint <changed files>`.
---
### Task 1: Ask and answer (library, MCP tools, readiness, board)
**Files:**
- Create: `src/lib/purchasing/site-decision.ts`, `src/lib/purchasing/site-decision.test.ts`
- Modify: `src/mcp/purchasing-write-tools.ts` (register 2 tools)
- Modify: `src/lib/dn-pipeline/readiness.ts` (`firstBlockingCheck`) + its test file
- Modify: `src/lib/purchasing/inbox-board.ts` (`next`) + its test
**Interfaces (produced):**
```ts
export const RequestSiteDecisionSchema // MutationSchema.extend({batchId:RecordId,id:RecordId,question:z.string().trim().min(5).max(1000),suggestions:z.array(z.string().trim().min(1).max(200)).max(5).default([]),reason:z.string().trim().max(2000).optional()}).strict()
export const AnswerSiteDecisionSchema // MutationSchema.extend({batchId:RecordId,id:RecordId,answer:z.string().trim().min(1).max(2000)}).strict()
export async function requestSiteDecision(ctx:PurchasingContext,input:Record<string,unknown>):Promise<OperationResult> // result {id, question}
export async function answerSiteDecision(ctx:PurchasingContext,input:Record<string,unknown>):Promise<OperationResult> // result {id, answer}
export function openSiteDecision(checks:unknown):{question:string;suggestions:string[];askedBy:string;askedAt:string}|null
```
Behaviour:
- `requestSiteDecision` (runOperation, scope `batch:<batchId>`): load note; `note.batch!==batchId` → `candidate_batch_mismatch`; `assertCandidateOpen(note)`. Replace any existing `site_decision` check with `{id:'site_decision', passed:false, reason:question, detail:{question, suggestions, askedBy:ctx.principal.id, askedAt:new Date().toISOString()}}`; also set `status:'blocked'`, `stage:'needs_review'`. Review event `action:'request_site_decision'`, `after:{question,suggestions}`.
- `answerSiteDecision`: same refusals; `openSiteDecision(note.checks)` null → `PurchasingError('no_open_site_decision',409)`. Replace the check with `{id:'site_decision', passed:true, reason:`Answered: ${answer}`, detail:{...previousDetail, answer, answeredBy:ctx.principal.id, answeredAt}}`. Review event `action:'answer_site_decision'`, `before:{question}`, `after:{answer}`. Do not change status/stage (re-processing or matching decides readiness).
- `openSiteDecision`: last check with `id==='site_decision' && passed===false`, mapped to its detail (question falls back to `reason`).
- MCP: `request_site_decision` — "Ask the site a plain-language question about a delivery document you cannot decide (for example an unclear quantity or a quantity beyond the PO). The document waits in Needs attention with your question until someone answers it." `answer_site_decision` — "Record the site's answer to an open site-decision question." Both `permission:'write'`.
- `firstBlockingCheck` (`readiness.ts`): before the existing loop, if `checks` contains `id==='site_decision' && passed===false`, return `{id:'site_decision', reason:<its reason>, failed:true}`.
- `inbox_board` `next`: new first rule — open site decision → `"waiting for site: <question>"` (truncate question to 160 chars).
- [ ] **Step 1: Tests** (`site-decision.test.ts`, memoryDatabase): (a) request writes the failing check with question/suggestions, sets blocked/needs_review, writes review event; a second request replaces (only one `site_decision` check); (b) answer flips it to passed with `Answered: …` and keeps the question in detail; (c) answer without open question → `no_open_site_decision`; (d) request on a promoted note → `candidate_closed`. Readiness test: `firstBlockingCheck([{id:'po_resolved',passed:true},{id:'site_decision',passed:false,reason:'How long is each pipe?'}])` returns that reason. Board test: a note with an open site decision gets `next` starting `waiting for site:`.
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** implement.
- [ ] **Step 4:** `npx vitest run src/lib/purchasing src/lib/dn-pipeline src/mcp`, tsc, eslint → clean.
- [ ] **Step 5:** commit `feat(dn): agents can ask the site a question about a delivery document`.
---
### Task 2: Answer from the slip page
**Files:**
- Create: `src/app/api/dn-batches/[id]/notes/[noteId]/site-decision/route.ts`
- Create: `src/app/dashboard/financial/delivery-notes/document/[id]/site-decision-panel.tsx`, `site-decision-panel.test.tsx`
- Modify: `src/app/dashboard/financial/delivery-notes/document/[id]/document-detail-view.tsx` (render the panel at the top of the returned layout, right under the header block)
Route `POST` body `{answer:string}`: `requireApiUser('financial')`; validate ids `/^[a-z0-9]{15}$/i`; load batch, `requireSiteAccess(user, batch.site)`; call `answerSiteDecision({pb: await pbAdmin(), principal:{id:user.id, permissions:new Set(['read','write'])}}, {operationId: crypto.randomUUID(), batchId:id, id:noteId, answer})`; map `PurchasingError` to `{error:code}` with its status, others 409 `{error:'answer_not_saved', detail:message}`; success `{ok:true}`. Follow the style of `src/app/api/dn-batches/[id]/notes/[noteId]/post-line/route.ts`.
Panel `SiteDecisionPanel({note})` (client component): uses `openSiteDecision(note.checks)`; renders nothing when null. Otherwise an amber bordered box: heading "Site decision needed", the question, suggestion buttons that fill the answer box, a textarea ("Your answer"), helper text "The slip and its PO are below. If quantities change, enter what you received in the matching section, then confirm the delivery.", and a "Save answer" button → `fetch('/api/dn-batches/'+note.batchId+'/notes/'+note.id+'/site-decision',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({answer})})`, then `router.refresh()`; show the error text on failure. Also show the last answered decision (passed check with `detail.answer`) as a small grey "Answered: …" line when there is no open one.
- [ ] **Step 1: Tests** (`site-decision-panel.test.tsx`, follow `processing-preview.test.tsx` setup): renders the question and suggestions for an open decision; clicking a suggestion fills the textarea; renders nothing without a site_decision check; shows "Answered:" for a passed one. Mock `next/navigation` `useRouter`.
- [ ] **Step 2:** run → FAIL. **Step 3:** implement route, panel, wire into the view. **Step 4:** `npx vitest run src/app/dashboard/financial/delivery-notes src/lib/purchasing`, tsc, eslint → clean.
- [ ] **Step 5:** commit `feat(dn): answer site-decision questions on the slip page`.