From 4a8edf1f7ef62a7285032ba095b9b0465484ebfa Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 9 Oct 2026 06:57:29 +0200 Subject: [PATCH] docs(dn): site-decision implementation plan Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-09-dn-site-decision.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-09-dn-site-decision.md diff --git a/docs/superpowers/plans/2026-10-09-dn-site-decision.md b/docs/superpowers/plans/2026-10-09-dn-site-decision.md new file mode 100644 index 0000000..3f6b7a7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-dn-site-decision.md @@ -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 `, `./node_modules/.bin/tsc --noEmit --project tsconfig.json`, `npx eslint `. + +--- + +### 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):Promise // result {id, question} +export async function answerSiteDecision(ctx:PurchasingContext,input:Record):Promise // result {id, answer} +export function openSiteDecision(checks:unknown):{question:string;suggestions:string[];askedBy:string;askedAt:string}|null +``` + +Behaviour: +- `requestSiteDecision` (runOperation, scope `batch:`): 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:, failed:true}`. +- `inbox_board` `next`: new first rule — open site decision → `"waiting for site: "` (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`.