From d543534c9cd0744bea84162639cc53e9799a5638 Mon Sep 17 00:00:00 2001 From: Matthew P Munger Date: Wed, 26 Aug 2026 11:58:10 -0500 Subject: [PATCH 1/2] feat: put an owner and a start date on the case, and retire the task board (S5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option 7a: two fields on the case, plus Copy as ticket. No plan object and no task object. `start` stamps `owner` and `startedAt` and nothing else writes either. Both are written inside `applyAction`, beside the checkpoints `mark_fixed` schedules, so there is no second door into `in_progress` that could reach the state without the stamp — and the registry allows `start` only from `todo`, so neither field can be overwritten by a later move. Neither is editable by hand: a field that can drift from the history entry beside it is a second answer to "who has this", which is the defect the case object exists to remove. `checklist` is gone; `notes` is free text with no schema. The owner is the identity `attributionOf` gives the caller, not the caller. F4 split the two on purpose — the class is a permission and this is the record of who — and `start` is person-only, so the class here is known and constant; a field typed to admit `{ kind: "system" }` is a field that eventually holds one. Going through `attributionOf` also means the owner and the attribution column in history are one answer to one question (rule 20), and that the two identities F4 declines to render are the two this declines to store: a system caller, and the migrated person nobody can name. Both leave the field absent, and the row then says so. The Fix queue draws itself, like Watch. To do above In progress, in the order `queue.fix.holds` already states, impact-ordered within each group with unmeasured findings last (rule 18). In progress rows carry the owner's initials, name and start date; past thirty days that date renders amber and that is the whole of it — no escalation, no email, no second threshold. The queue says so at the top, because a reader who has been told there is no escalation can leave a case for thirty-one days on purpose. A legacy record that arrives already in progress never passed through `start`, so it has no owner. The row says so rather than inventing one. Copy as ticket is plain markdown on the clipboard and the entire integration surface. A tracker connection would be a second home for the work's state, and a second home is where it disagrees with the first. The saving comes from `formatCaseImpact`, which is the call the case detail makes, so the ticket's figure is the case's figure to the byte; an unmeasured finding says "Not measured" and never 0. The link is `casePath` — `/issues/{id}`, no `/issues/case/` segment to get wrong. Deleted: the `/tasks` redirect stub (C1a made it redundant), the standalone `api/agent-audits/verify` endpoint and its client — W1's checkpoint evaluator replaces it — and the "Add to tasks" / "Add workaround to tasks" affordance with the dead Tasks-board plumbing behind it. `EFFORT_LABEL` moved to `impact-format.ts` and `enteredAt` generalised out of `fixedAtOf`, both because S5 gave them a third reader that is not a component. --- src/app/(app)/issues/page.tsx | 23 +- src/app/(app)/pages/pages-content.tsx | 12 +- src/app/(app)/tasks/page.tsx | 15 - src/app/api/agent-audits/verify/route.ts | 27 -- src/components/case-detail.tsx | 195 ++++++-- src/components/copy-ticket-button.tsx | 102 ++++ src/components/fix-queue.tsx | 96 ++++ src/components/fix-row.tsx | 170 +++++++ src/components/issue-row.tsx | 23 +- src/components/store.tsx | 119 +---- src/lib/__tests__/fix-work.test.ts | 437 ++++++++++++++++++ src/lib/__tests__/webflow-performance.test.ts | 4 - src/lib/agentAuditServer.ts | 28 -- src/lib/checkpoint-evaluation.ts | 8 +- src/lib/fix-copy.ts | 157 +++++++ src/lib/fix-ticket.ts | 99 ++++ src/lib/impact-format.ts | 57 ++- src/lib/issue-case.ts | 101 +++- src/lib/webflowPerformance.ts | 4 - 19 files changed, 1415 insertions(+), 262 deletions(-) delete mode 100644 src/app/(app)/tasks/page.tsx delete mode 100644 src/app/api/agent-audits/verify/route.ts create mode 100644 src/components/copy-ticket-button.tsx create mode 100644 src/components/fix-queue.tsx create mode 100644 src/components/fix-row.tsx create mode 100644 src/lib/__tests__/fix-work.test.ts create mode 100644 src/lib/fix-copy.ts create mode 100644 src/lib/fix-ticket.ts diff --git a/src/app/(app)/issues/page.tsx b/src/app/(app)/issues/page.tsx index 81819a4..2651b38 100644 --- a/src/app/(app)/issues/page.tsx +++ b/src/app/(app)/issues/page.tsx @@ -31,6 +31,7 @@ import { } from "@/components/issue-empty"; import { SelectMenu } from "@/components/select-menu"; import { WatchQueue } from "@/components/watch-queue"; +import { FixQueue } from "@/components/fix-queue"; import { WATCH_EMPTY } from "@/lib/watch-copy"; /** @@ -155,11 +156,21 @@ export default function IssuesPage() { return ; } - // Watch is not grouped by remediation and not sorted by impact: it is a run - // of fixes waiting on evidence, ordered by what is heard from next. It reads - // the queue's cases directly rather than the folded groups. + /** + * Two of the four queues draw themselves, and both for the same reason. + * + * Decide and Show all are lists of undecided things, so they are grouped by + * remediation, folded at the savings gate and sorted however the reader asks — + * every one of those is a way of deciding. Fix and Watch hold things that have + * already been decided, and neither question left has a sort control as its + * answer: Fix asks which to do next, Watch asks what is heard from when. Both + * read the queue's cases directly rather than the folded groups, because a + * fold is a triage affordance and there is no triage left to do. + */ + const isFix = queue === "fix"; const isWatch = queue === "watch"; - const hasRows = isWatch ? view.inQueue.length > 0 : view.groups.length > 0 || view.tail.length > 0; + const ownQueue = isFix || isWatch; + const hasRows = ownQueue ? view.inQueue.length > 0 : view.groups.length > 0 || view.tail.length > 0; return (
@@ -167,9 +178,11 @@ export default function IssuesPage() { linkTo({ queue: next })} /> + {hasRows && isFix ? : null} + {hasRows && isWatch ? : null} - {hasRows && !isWatch ? ( + {hasRows && !ownQueue ? ( <>
- ) : topRibbonRec ? ( - <> - {topRibbonEvidence && } - - + ) : topRibbonRec && topRibbonEvidence ? ( + ) : null} {`Open ${QUEUE_LABEL.decide}`} diff --git a/src/app/(app)/tasks/page.tsx b/src/app/(app)/tasks/page.tsx deleted file mode 100644 index e761bcc..0000000 --- a/src/app/(app)/tasks/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { redirect } from "next/navigation"; -import { getEnv } from "@/lib/env"; -import { normalizeBasePath, withBasePath } from "@/lib/paths"; -import { DESTINATION_PATH } from "@/lib/vocabulary"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Retired destination. What lived here is now the Fix queue on the issues - * list; this route only exists to keep old links from 404ing. - */ -export default function RedirectToFixQueue() { - redirect(withBasePath(normalizeBasePath(getEnv("BASE_URL")), `${DESTINATION_PATH.issues}?queue=fix`)); -} diff --git a/src/app/api/agent-audits/verify/route.ts b/src/app/api/agent-audits/verify/route.ts deleted file mode 100644 index fc55ce7..0000000 --- a/src/app/api/agent-audits/verify/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { relayAgentAuditResponse, requestAgentAuditVerify } from "@/lib/agentAuditServer"; -import { authorizedProjectForRequest } from "@/lib/projects"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Re-run the provider checks recorded on one implemented agent task. - * - * Only the task key travels. The collector resolves which checks to run from - * stored state, so an arbitrary check set can never be submitted. - */ -export async function POST(request: Request): Promise { - const body = (await request.json().catch(() => ({}))) as { recKey?: unknown }; - if (typeof body.recKey !== "string" || !body.recKey) { - return Response.json({ error: "recKey is required" }, { status: 400 }); - } - try { - const project = await authorizedProjectForRequest(request, "admin"); - return relayAgentAuditResponse(await requestAgentAuditVerify(project.tenant, body.recKey)); - } catch (error) { - return Response.json( - { error: error instanceof Error ? error.message : "Verification is unavailable" }, - { status: 503 }, - ); - } -} diff --git a/src/components/case-detail.tsx b/src/components/case-detail.tsx index 0372c41..86804e3 100644 --- a/src/components/case-detail.tsx +++ b/src/components/case-detail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { attributionOf } from "@/lib/caller"; import { RESOLVING_INTERVAL, @@ -10,8 +10,10 @@ import { primaryActionFor, type IssueCase, } from "@/lib/issue-case"; -import { runOf } from "@/lib/checkpoint-evaluation"; -import { formatImpact } from "@/lib/impact-format"; +import { fixedAtOf, runOf } from "@/lib/checkpoint-evaluation"; +import { formatCaseImpact } from "@/lib/impact-format"; +import { ticketMarkdown } from "@/lib/fix-ticket"; +import { FIX_NOTES_PLACEHOLDER } from "@/lib/fix-copy"; import { CONFIDENCE_LABEL, DESTINATION_LABEL, @@ -19,6 +21,7 @@ import { EVIDENCE_SOURCE_LABEL, ISSUE_ACTION_LABEL, WORK_STATE_LABEL, + queueHoldsState, type ExclusionReason, type IssueAction, } from "@/lib/vocabulary"; @@ -40,6 +43,7 @@ import { ObjectDetailHeader } from "@/components/object-detail-header"; import { StatusChip } from "@/components/status-chip"; import { CasePages } from "@/components/case-pages"; import { CheckpointTrack } from "@/components/checkpoint-track"; +import { CopyTicketButton } from "@/components/copy-ticket-button"; import { daysOf, formatDate } from "@/lib/watch-copy"; /** @@ -51,9 +55,10 @@ import { daysOf, formatDate } from "@/lib/watch-copy"; * 2 impact/effort what it costs and what it takes — the decision inputs * 3 taxonomy category, severity, confidence * 4 affected pages which pages, and which of them count - * 5 evidence who measured it, never averaged - * 6 checkpoints only once there is something to check - * 7 history what happened, oldest last + * 5 notes free text, for whoever picks this up + * 6 evidence who measured it, never averaged + * 7 checkpoints only once there is something to check + * 8 history what happened, oldest last * * Taxonomy never comes first. A row of chips above the diagnosis asks the * reader to classify a problem they have not been told about yet — the @@ -77,6 +82,22 @@ export interface CaseDetailProps { onAction?: (action: IssueAction, issue: IssueCase) => void; onExclude?: (pageId: string, reason: ExclusionReason) => void; onInclude?: (pageId: string) => void; + /** + * Persist the case's notes. + * + * Withheld the same way `onExclude` is, and for the same reason: a notes box + * that accepts what you type, shows it, and loses it on reload is worse than + * no notes box. The section renders read-only when there is a note and no + * handler, and not at all when there is neither. + */ + onNotesChange?: (notes: string) => void; + /** + * The deployment's public URL, for the link in a copied ticket. + * + * Absent yields the root-relative `/issues/{id}`, which is wrong in a ticket + * visibly rather than silently. See `absoluteUrl`. + */ + appUrl?: string; now?: Date; locale?: string; } @@ -92,6 +113,96 @@ function Section({ title, children }: { title: string; children: React.ReactNode ); } +/** + * Free text, for whoever picks this up. + * + * No schema, no required fields, no length limit and nothing derived from what + * is written here. That is the point of it: the other eight sections of a case + * are shapes the app imposed on a finding, and every one of them is occasionally + * the wrong shape for the thing somebody needs to say. This is where that goes. + * + * Three states, and the middle one is the one worth explaining. With a handler + * it is editable. With a note and no handler it is shown but not editable — a + * box that takes what you type and loses it on reload is worse than a box that + * does not take it, and hiding a note that exists because nothing can save a new + * one would be hiding evidence. With neither it is not rendered at all. + * + * Committed on blur rather than on every keystroke. A case is derived from + * stored records, so a write is a round trip; per-keystroke saving would put one + * in flight per character and reorder them under any latency at all. + */ +function CaseNotes({ + notes, + onNotesChange, +}: { + notes?: string; + onNotesChange?: (notes: string) => void; +}) { + const [draft, setDraft] = useState(notes ?? ""); + const [seen, setSeen] = useState(notes); + + // A case re-derived by a later run brings its own note, and the draft follows + // it so an edit made elsewhere is not overwritten by a stale buffer. + // + // Adjusted during render rather than in an effect. An effect would paint the + // old note first and correct it on the next pass, which is a visible flash of + // the wrong text; React re-runs this component before committing anything, so + // the reader only ever sees the new one. + if (notes !== seen) { + setSeen(notes); + setDraft(notes ?? ""); + } + + if (!onNotesChange) { + if (!notes) return null; + return ( +
+

+ {notes} +

+
+ ); + } + + return ( +
+