diff --git a/eslint.config.mjs b/eslint.config.mjs index b710c79..debec69 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,17 +17,22 @@ const eslintConfig = defineConfig([ plugins: { vocabulary }, rules: { "vocabulary/no-banned-vocabulary": "error" }, }, - { - // Files with copy that predates the vocabulary decision. The list lives in - // vocabulary.json under banned_global.allowlist, where each entry names the - // chunk that clears it — this just consumes it, so there is one place to - // look and no second list to keep in sync. - // - // It may only shrink; `vocabulary.test.ts` asserts that. A new file with - // retired vocabulary in it fails the rule, which is the point. - files: ALLOWLISTED_FILES.map(escapeGlob), - rules: { "vocabulary/no-banned-vocabulary": "off" }, - }, + // Files with copy that predates the vocabulary decision. The list lives in + // vocabulary.json under banned_global.allowlist, where each entry names the + // chunk that clears it — this just consumes it, so there is one place to + // look and no second list to keep in sync. + // + // It may only shrink; `vocabulary.test.ts` asserts that. A new file with + // retired vocabulary in it fails the rule, which is the point. When the list + // is empty the block is omitted: ESLint rejects `files: []`. + ...(ALLOWLISTED_FILES.length > 0 + ? [ + { + files: ALLOWLISTED_FILES.map(escapeGlob), + rules: { "vocabulary/no-banned-vocabulary": "off" }, + }, + ] + : []), // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/src/app/(app)/tasks/page.tsx b/src/app/(app)/tasks/page.tsx new file mode 100644 index 0000000..6b598d2 --- /dev/null +++ b/src/app/(app)/tasks/page.tsx @@ -0,0 +1,16 @@ +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. The app is organised around one object — the issue case + * — so this route only exists to keep old links, bookmarks, and alert + * notifications from 404ing. + */ +export default function RedirectToIssues() { + redirect(withBasePath(normalizeBasePath(getEnv("BASE_URL")), DESTINATION_PATH.issues)); +} diff --git a/src/app/api/pages/[id]/agent-issues/route.ts b/src/app/api/pages/[id]/agent-issues/route.ts deleted file mode 100644 index ba4e066..0000000 --- a/src/app/api/pages/[id]/agent-issues/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { NextResponse } from "next/server"; -import { addAgentIssueTask } from "@/lib/mutations"; -import { projectStore } from "@/lib/projects"; -import { assembleAgentIssueCases } from "@/lib/agentIssueCases"; -import { externalAuditForPage } from "@/lib/externalAgentEvidence"; -import { normalizeAgentIgnoreSettings } from "@/lib/agentScoring"; -import { normalizeOraTarget } from "@/lib/ora"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Promote an agent-access issue to a task. - * - * The client sends only the issue key. The case itself is re-assembled - * server-side from stored evidence, so a caller cannot invent remediation - * steps, success criteria, or verification check ids. - */ -export async function POST( - request: Request, - context: { params: Promise<{ id: string }> }, -): Promise { - const { id } = await context.params; - const body = (await request.json().catch(() => ({}))) as { caseKey?: unknown }; - if (typeof body.caseKey !== "string" || !body.caseKey) { - return NextResponse.json({ error: "caseKey is required" }, { status: 400 }); - } - try { - const dataStore = await projectStore(request); - const state = await dataStore.getState(); - const page = state.pages.find((item) => item.id === id); - if (!page) return NextResponse.json({ error: "page not found" }, { status: 404 }); - - const audits = await dataStore.getExternalAgentAudits().catch(() => []); - const latest = [...page.history].reverse().find((night) => night.agent?.length); - const cases = assembleAgentIssueCases({ - checks: latest?.agent ?? page.agent, - ...(latest?.agentCapturedAt ? { checksObservedAt: latest.agentCapturedAt } : {}), - ignores: normalizeAgentIgnoreSettings(page.agentIgnores), - ignoreDefaults: normalizeAgentIgnoreSettings(state.agentIgnoreDefaults), - ignoreRestores: normalizeAgentIgnoreSettings(page.agentIgnoreRestores), - audit: externalAuditForPage(audits, page.url), - }); - const issue = cases.find((item) => item.key === body.caseKey); - if (!issue) return NextResponse.json({ error: "issue not found" }, { status: 404 }); - - let origin: string | undefined; - try { - origin = normalizeOraTarget(page.url).origin; - } catch { - // A page whose origin cannot be audited still supports a local task. - } - return NextResponse.json({ state: await addAgentIssueTask(id, issue, origin, dataStore) }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/src/components/bits.tsx b/src/components/bits.tsx index 24e677c..4ae9e9e 100644 --- a/src/components/bits.tsx +++ b/src/components/bits.tsx @@ -72,7 +72,7 @@ export function FieldRecommendationStatusBadge({ rec }: { rec: Pick = { - regressed: { state: "reopened", title: "Returned after a confirmed resolution" }, + regressed: { state: "reopened", title: "Came back after a confirmed resolution" }, resolved: { state: "resolved", title: "Gone from the last two nightly tests" }, verifying: { state: "fixed", title: "Gone once; one more clean night confirms it" }, active: { state: "new", title: "Found in the latest nightly test" }, diff --git a/src/components/store.tsx b/src/components/store.tsx index f1a4bd6..e48f147 100644 --- a/src/components/store.tsx +++ b/src/components/store.tsx @@ -23,7 +23,7 @@ import { import { issueCasesFrom, lastRunAtOf } from "@/lib/issue-cases"; import type { CaseDecision, CaseDecisionRequest } from "@/lib/case-decisions"; import { partitionByImpact } from "@/lib/impact-format"; -import { APPLICABILITY_LABEL, COUNTED_QUEUES, ISSUE_ACTION_LABEL, QUEUE_LABEL, type ExclusionReason, type Queue } from "@/lib/vocabulary"; +import { APPLICABILITY_LABEL, COUNTED_QUEUES, ISSUE_ACTION_LABEL, type ExclusionReason, type Queue } from "@/lib/vocabulary"; import { normalizeNativeElementControls } from "@/lib/nativeElements"; import { localISODate } from "@/lib/ui"; import { withBasePath } from "@/lib/paths"; @@ -31,7 +31,6 @@ import { defaultNewPageFlag, flagCapacityError } from "@/lib/watchCapacity"; import { applyWatchlistPageOrder, changePageFlagOrder } from "@/lib/watchlistOrder"; import { isTaskMarker } from "@/lib/taskMarkers"; import { pageTrend } from "@/lib/scoring"; -import { normalizeState } from "@/lib/store/normalize"; import type { Project } from "@/lib/projects"; import { LAST_PROJECT_KEY } from "@/lib/projectSelection"; import { APPEARANCE_STORAGE_KEY, isAppearance, resolveSurface, type Appearance } from "./appearance"; @@ -155,7 +154,6 @@ interface StoreValue extends AppState { setVisitorExperienceVisible: (visible: boolean) => void; setExternalAgentAuditEnabled: (enabled: boolean) => void; refreshExternalAgentAudit: (pageId: string) => void; - addAgentIssueTask: (pageId: string, caseKey: string) => void; externalAgentAuditRefreshing: boolean; removePage: (id: string) => void; ignoreRec: (key: string) => void; @@ -876,33 +874,6 @@ export function StoreProvider({ [flash, pathFor], ); - const addAgentIssueTask = useCallback( - (pageId: string, caseKey: string) => { - // No optimistic apply: the server re-assembles the case from stored - // evidence, so the authoritative task is whatever it returns. - void (async () => { - try { - const response = await fetch(pathFor(`/api/pages/${encodeURIComponent(pageId)}/agent-issues`), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ caseKey }), - cache: "no-store", - }); - const body = (await response.json().catch(() => null)) as { state?: AppState } | null; - if (!response.ok || !body?.state) { - flash(`Couldn't add this to ${QUEUE_LABEL.fix} — try again`); - return; - } - apply(normalizeState(body.state)); - flash(`Added to ${QUEUE_LABEL.fix} with its verification target`); - } catch { - flash(`Couldn't add this to ${QUEUE_LABEL.fix} — try again`); - } - })(); - }, - [apply, flash, pathFor], - ); - const setExternalAgentAuditEnabled = useCallback( (enabled: boolean) => { const cur = dataRef.current; @@ -960,7 +931,7 @@ export function StoreProvider({ mutate( { ...cur, recs: cur.recs.map((r) => (r.key === key ? { ...r, status: "ignored" } : r)) }, { url: `/api/recs`, body: { key, action: "ignore" } }, - { success: "Ignored — cleared from Inbox, still listed on the page", failure: "Couldn't ignore — try again" }, + { success: "Cleared from Decide — still listed on the page", failure: "Couldn't clear this — try again" }, ); }, [mutate], @@ -1213,7 +1184,6 @@ export function StoreProvider({ updateCollectionSchedule, setExternalAgentAuditEnabled, refreshExternalAgentAudit, - addAgentIssueTask, externalAgentAuditRefreshing, updateAlertWebhookUrl, setVisitorExperienceVisible, diff --git a/src/lib/__tests__/agent-issue-tasks.test.ts b/src/lib/__tests__/agent-issue-tasks.test.ts deleted file mode 100644 index d8d72d9..0000000 --- a/src/lib/__tests__/agent-issue-tasks.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - agentIssueRec, - agentIssueRecId, - agentTasksAwaitingVerification, - applyAgentVerificationResults, - beginAgentVerification, - promoteAgentIssueToTask, - recordAgentVerificationFailure, - reconcileAgentIssueRecsInState, - reopenReturnedAgentTask, - verificationTargetsFor, -} from "../agentIssueTasks"; -import type { AgentIssueCase } from "../agentIssueCases"; -import type { AgentIssueVerificationResult, AppState, Rec, WatchPage } from "../types"; - -const NOW = new Date("2026-08-24T06:00:00.000Z"); - -function page(id = "home"): WatchPage { - return { - id, - title: "Homepage", - url: "https://example.com", - flag: "watching", - status: "pending", - current: { - mobile: { perf: 0, a11y: 0, bp: 0, seo: 0 }, - desktop: { perf: 0, a11y: 0, bp: 0, seo: 0 }, - }, - history: [], - markers: [], - agent: [], - } as unknown as WatchPage; -} - -function issue(overrides: Partial = {}): AgentIssueCase { - return { - key: "agent-api:openapi", - title: "Agents cannot reliably discover machine-readable API documentation", - consequence: "Without a published contract an agent has to infer endpoints.", - scope: "origin", - half: "comprehension", - status: "failed", - tier: "essential", - confidence: "corroborated", - sources: [ - { system: "page-watch", label: "API Catalog", result: "failed", scope: "page", observedAt: "2026-08-24T05:40:00.000Z" }, - { system: "ora", label: "OpenAPI spec published", result: "failed", scope: "origin", observedAt: "2026-08-24T04:00:00.000Z", providerCheckId: "openapi-spec" }, - ], - remediation: ["Publish an OpenAPI document.", "Link it from the API catalog."], - successCriteria: "An OpenAPI document is reachable.", - verificationCheckIds: ["openapi-spec", "api-catalog-rfc9727"], - ...overrides, - }; -} - -function state(recs: Rec[] = [], pages: WatchPage[] = [page()]): AppState { - return { pages, recs, jobs: [], followUps: [] }; -} - -function casesFor(cases: AgentIssueCase[], pageId = "home") { - return new Map([[pageId, { cases, origin: "https://example.com" }]]); -} - -function result( - checkId: string, - value: AgentIssueVerificationResult["result"], -): AgentIssueVerificationResult { - return { checkId, result: value, observedAt: "2026-08-24T07:00:00.000Z" }; -} - -describe("creating a task from an issue case", () => { - it("retains the identifiers a later verification needs", () => { - const rec = agentIssueRec(page(), issue(), NOW, "https://example.com"); - expect(rec.id).toBe(agentIssueRecId("agent-api:openapi")); - expect(rec.source).toBe("agent-readiness"); - expect(rec.category).toBe("Agent access"); - expect(rec.agentIssue).toMatchObject({ - caseKey: "agent-api:openapi", - scope: "origin", - origin: "https://example.com", - successCriteria: "An OpenAPI document is reachable.", - verificationCheckIds: ["openapi-spec", "api-catalog-rfc9727"], - // The newest source timestamp, kept as the audit trail. - capturedAt: "2026-08-24T05:40:00.000Z", - }); - // Page Watch's own steps travel with the task. - expect(rec.agentIssue?.remediation).toEqual([ - "Publish an OpenAPI document.", - "Link it from the API catalog.", - ]); - }); - - it("summarizes the consequence and how many sources agree", () => { - expect(agentIssueRec(page(), issue(), NOW).aiSummary) - .toBe("Without a published contract an agent has to infer endpoints. Reported independently by 2 sources."); - const single = issue({ - sources: [{ system: "ora", label: "OpenAPI", result: "failed", scope: "origin", providerCheckId: "openapi-spec" }], - }); - expect(agentIssueRec(page(), single, NOW).aiSummary).toContain("Reported by one source."); - }); - - it("promotes any issue on request, as a task rather than an inbox item", () => { - const draft = state(); - const rec = promoteAgentIssueToTask(draft, "home", issue({ tier: "recommended" }), NOW); - expect(rec.status).toBe("task"); - expect(draft.recs).toHaveLength(1); - }); - - it("does not duplicate a task when promoted twice", () => { - const draft = state(); - promoteAgentIssueToTask(draft, "home", issue(), NOW); - promoteAgentIssueToTask(draft, "home", issue(), NOW); - expect(draft.recs).toHaveLength(1); - }); - - it("preserves verification state when re-promoted", () => { - const draft = state(); - const rec = promoteAgentIssueToTask(draft, "home", issue(), NOW); - beginAgentVerification(rec, NOW); - promoteAgentIssueToTask(draft, "home", issue(), NOW); - expect(draft.recs[0].agentIssue?.verification?.status).toBe("verifying"); - }); -}); - -describe("auto-filing essential blockers", () => { - it("files a failing essential issue into the inbox", () => { - const draft = state(); - const counts = reconcileAgentIssueRecsInState(draft, casesFor([issue()]), NOW); - expect(counts.created).toBe(1); - expect(draft.recs[0].status).toBe("inbox"); - expect(draft.recs[0].agentIssue?.caseKey).toBe("agent-api:openapi"); - }); - - it("leaves non-essential and non-failing issues for the user to promote", () => { - const draft = state(); - reconcileAgentIssueRecsInState(draft, casesFor([ - issue({ key: "agent-api:rate-limits", tier: "recommended" }), - issue({ key: "agent-content:no-js", status: "partial" }), - issue({ key: "agent-mcp:resources", status: "not-applicable" }), - issue({ key: "agent-discoverability:dns", status: "ignored" }), - ]), NOW); - expect(draft.recs).toEqual([]); - }); - - it("files each blocker once, however often reconciliation runs", () => { - const draft = state(); - reconcileAgentIssueRecsInState(draft, casesFor([issue()]), NOW); - const second = reconcileAgentIssueRecsInState(draft, casesFor([issue()]), NOW); - expect(second.created).toBe(0); - expect(draft.recs).toHaveLength(1); - }); - - it("never reopens work the user already triaged", () => { - const draft = state(); - reconcileAgentIssueRecsInState(draft, casesFor([issue()]), NOW); - draft.recs[0].status = "ignored"; - reconcileAgentIssueRecsInState(draft, casesFor([issue()]), NOW); - expect(draft.recs).toHaveLength(1); - expect(draft.recs[0].status).toBe("ignored"); - }); - - it("refreshes evidence on an existing task without disturbing its status", () => { - const draft = state(); - reconcileAgentIssueRecsInState(draft, casesFor([issue()]), NOW); - draft.recs[0].status = "task"; - draft.recs[0].taskStatus = "in-progress"; - const counts = reconcileAgentIssueRecsInState(draft, casesFor([ - issue({ verificationCheckIds: ["openapi-spec"] }), - ]), NOW); - expect(counts.updated).toBe(1); - expect(draft.recs[0].agentIssue?.verificationCheckIds).toEqual(["openapi-spec"]); - expect(draft.recs[0].status).toBe("task"); - expect(draft.recs[0].taskStatus).toBe("in-progress"); - }); - - it("ignores pages with no assembled cases", () => { - const draft = state(); - expect(reconcileAgentIssueRecsInState(draft, new Map(), NOW)).toEqual({ created: 0, updated: 0 }); - }); -}); - -describe("verification lifecycle", () => { - function taskAwaitingVerification(): { draft: AppState; rec: Rec } { - const draft = state(); - const rec = promoteAgentIssueToTask(draft, "home", issue(), NOW); - rec.taskStatus = "done"; - rec.doneDate = "Aug 24"; - beginAgentVerification(rec, NOW); - return { draft, rec }; - } - - it("lists only completed agent tasks that a provider can actually confirm", () => { - const { draft, rec } = taskAwaitingVerification(); - expect(agentTasksAwaitingVerification(draft)).toEqual([rec]); - - // A task with no provider coverage is never left waiting forever. - const uncovered = state(); - const bare = promoteAgentIssueToTask(uncovered, "home", issue({ - key: "agent-discoverability:dns", - verificationCheckIds: [], - }), NOW); - bare.taskStatus = "done"; - expect(agentTasksAwaitingVerification(uncovered)).toEqual([]); - }); - - it("resolves only when every selected check is clean", () => { - const { rec } = taskAwaitingVerification(); - const verification = applyAgentVerificationResults(rec, [ - result("openapi-spec", "pass"), - result("api-catalog-rfc9727", "pass"), - ], NOW); - expect(verification.status).toBe("resolved"); - expect(rec.agentIssue?.verification?.status).toBe("resolved"); - }); - - it("accepts a correctly not-applicable check as resolved", () => { - const { rec } = taskAwaitingVerification(); - expect(applyAgentVerificationResults(rec, [ - result("openapi-spec", "pass"), - result("api-catalog-rfc9727", "not-applicable"), - ], NOW).status).toBe("resolved"); - }); - - it("returns the issue when any selected check is still failing or partial", () => { - for (const outcome of ["failed", "partial"] as const) { - const { rec } = taskAwaitingVerification(); - expect(applyAgentVerificationResults(rec, [ - result("openapi-spec", "pass"), - result("api-catalog-rfc9727", outcome), - ], NOW).status).toBe("returned"); - } - }); - - it("stays verifying when the provider answered for only some targets", () => { - const { rec } = taskAwaitingVerification(); - // A clean partial answer is not enough to declare the fix proven. - expect(applyAgentVerificationResults(rec, [result("openapi-spec", "pass")], NOW).status) - .toBe("verifying"); - }); - - it("leaves the issue verifying when the provider could not answer", () => { - const { rec } = taskAwaitingVerification(); - const verification = applyAgentVerificationResults(rec, [ - result("openapi-spec", "unavailable"), - result("api-catalog-rfc9727", "unavailable"), - ], NOW); - // Provider silence is never evidence that a remediation failed. - expect(verification.status).toBe("verifying"); - }); - - it("ignores results for checks this task never targeted", () => { - const { rec } = taskAwaitingVerification(); - const verification = applyAgentVerificationResults(rec, [ - result("openapi-spec", "pass"), - result("api-catalog-rfc9727", "pass"), - result("something-else", "failed"), - ], NOW); - expect(verification.status).toBe("resolved"); - expect(verification.results?.map((item) => item.checkId)) - .toEqual(["openapi-spec", "api-catalog-rfc9727"]); - }); - - it("keeps a provider failure retryable and clears it on the next answer", () => { - const { rec } = taskAwaitingVerification(); - recordAgentVerificationFailure(rec, { code: "RATE_LIMITED", message: "Daily limit" }, NOW); - expect(rec.agentIssue?.verification).toMatchObject({ - status: "verifying", - errorCode: "RATE_LIMITED", - }); - applyAgentVerificationResults(rec, [ - result("openapi-spec", "pass"), - result("api-catalog-rfc9727", "pass"), - ], NOW); - expect(rec.agentIssue?.verification?.status).toBe("resolved"); - expect(rec.agentIssue?.verification?.errorCode).toBeUndefined(); - }); - - it("reopens a returned task so it comes back into open work", () => { - const { rec } = taskAwaitingVerification(); - applyAgentVerificationResults(rec, [result("openapi-spec", "failed")], NOW); - expect(rec.agentIssue?.verification?.status).toBe("returned"); - expect(reopenReturnedAgentTask(rec)).toBe(true); - expect(rec.taskStatus).toBe("in-progress"); - expect(rec.doneDate).toBeNull(); - }); - - it("does not reopen a resolved or still-verifying task", () => { - const { rec } = taskAwaitingVerification(); - applyAgentVerificationResults(rec, [ - result("openapi-spec", "pass"), - result("api-catalog-rfc9727", "pass"), - ], NOW); - expect(reopenReturnedAgentTask(rec)).toBe(false); - expect(rec.taskStatus).toBe("done"); - }); - - it("deduplicates verification targets", () => { - const rec = agentIssueRec(page(), issue({ - verificationCheckIds: ["openapi-spec", "openapi-spec", "api-catalog-rfc9727"], - }), NOW); - expect(verificationTargetsFor(rec)).toEqual(["openapi-spec", "api-catalog-rfc9727"]); - }); -}); diff --git a/src/lib/__tests__/fix-work.test.ts b/src/lib/__tests__/fix-work.test.ts index 8716973..2be7f30 100644 --- a/src/lib/__tests__/fix-work.test.ts +++ b/src/lib/__tests__/fix-work.test.ts @@ -417,11 +417,19 @@ describe("S5 — marking fixed hands off to the checkpoints", () => { }); describe("S5 — what the fix queue replaces", () => { - it("leaves no route or component named task", () => { + it("leaves no task UI — only the retired redirect may keep the name", () => { + // S10 restored /tasks as a redirect so old links stop 404ing. That file is + // allowed; anything else named task would be a second destination. const named = sourceFiles() .map((file) => path.relative(srcDir, file).replace(/\\/g, "/")) - .filter((file) => /(^|\/)tasks?(\/|\.|-)/i.test(file)); + .filter((file) => /(^|\/)tasks?(\/|\.|-)/i.test(file)) + .filter((file) => file !== "app/(app)/tasks/page.tsx"); expect(named).toEqual([]); + const redirect = readFileSync(path.join(srcDir, "app/(app)/tasks/page.tsx"), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + expect(redirect).toContain("DESTINATION_PATH.issues"); + expect(redirect).toContain("redirect("); }); it("leaves no add-to-tasks affordance", () => { diff --git a/src/lib/__tests__/plain-language.test.ts b/src/lib/__tests__/plain-language.test.ts index 1d8c64a..a4ea575 100644 --- a/src/lib/__tests__/plain-language.test.ts +++ b/src/lib/__tests__/plain-language.test.ts @@ -261,8 +261,8 @@ describe("the glossary retired with its definitions", () => { const retired = ["Verifying", "Acknowledged", "Suppressed", "Action Center"]; const definitions = ALL_SOURCE.flatMap(({ file, text }) => { // A definition, not a mention: the retired word followed by prose saying - // what it means. `bits.tsx` still RENDERS "Verifying recovery", which is - // F2's allowlist entry to clear, and is not a definition of the word. + // what it means. Banned words that still appear in titles or toasts are + // allowlist debts to clear, and are not definitions of the word. const stripped = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); return retired .filter((term) => new RegExp(`(shortDefinition|appMeaning|term)\\s*:\\s*"[^"]*${term}`).test(stripped)) @@ -271,15 +271,21 @@ describe("the glossary retired with its definitions", () => { expect(definitions).toEqual([]); }); - it("sends /guide to the issues list, like every other retired route", () => { - const route = ALL_SOURCE.find(({ file }) => file.endsWith(path.join("guide", "page.tsx"))); - expect(route, "the /guide route must still exist, or old links 404").toBeDefined(); - // Comments stripped: the route explains at length why it no longer aims at - // a Settings anchor, and a check that tripped over its own justification - // would only teach the next editor to delete the paragraph. - const code = route!.text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); - // Asserts the destination it resolves, not a literal path (rule 21). - expect(code).toContain("DESTINATION_PATH.issues"); - expect(code).not.toContain("#reference"); + it("sends every retired route to the issues list", () => { + // /dashboard, /inbox, /tasks, /guide — four redirects, and the fourth is the + // one that went missing before. Asserting them together means a deleted + // route fails this test rather than a 404 in production. + const retired = ["dashboard", "inbox", "tasks", "guide"]; + for (const name of retired) { + const route = ALL_SOURCE.find(({ file }) => file.endsWith(path.join(name, "page.tsx"))); + expect(route, `the /${name} route must still exist, or old links 404`).toBeDefined(); + // Comments stripped: each route explains why it still exists, and a check + // that tripped over its own justification would only teach the next editor + // to delete the paragraph. + const code = route!.text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + // Asserts the destination it resolves, not a literal path (rule 21). + expect(code, `/${name} must redirect via DESTINATION_PATH.issues`).toContain("DESTINATION_PATH.issues"); + expect(code, `/${name} must not aim at a Settings anchor`).not.toContain("#reference"); + } }); }); diff --git a/src/lib/__tests__/vocabulary.test.ts b/src/lib/__tests__/vocabulary.test.ts index 2f63782..ce198b8 100644 --- a/src/lib/__tests__/vocabulary.test.ts +++ b/src/lib/__tests__/vocabulary.test.ts @@ -87,10 +87,20 @@ interface RegistryConcept { banned_as_label: string[]; } +interface AllowlistEntry { + owner: string; + added: number; + reason: string; +} + interface Registry { version: number; concepts: Record; - banned_global: { terms: string[]; allowlist: Record }; + banned_global: { + terms: string[]; + allowlist: Record; + }; + rules: string[]; } const registryPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../vocabulary.json"); @@ -446,9 +456,9 @@ describe("applicability", () => { describe("banned vocabulary", () => { /** - * The allowlist may only shrink. This is the recorded set as of C1a; a chunk - * that cleans a file removes its entry, and this test fails if anything new - * is ever added instead of fixed. + * The allowlist may only shrink (rule 25). This is the recorded set as of C1a; + * a chunk that cleans a file removes its entry, and this test fails if + * anything new is ever added instead of fixed. */ const ALLOWLIST_BASELINE = new Set([ "src/lib/guide.ts", @@ -459,21 +469,59 @@ describe("banned vocabulary", () => { "src/components/agent-access.tsx", ]); + /** Rule 25: a chunk branch cannot lower the registry by merging. */ + const VERSION_FLOOR = 11; + const allowlistedFiles = () => Object.keys(registry.banned_global.allowlist).filter((key) => !key.startsWith("$")); + const allowlistEntry = (file: string): AllowlistEntry => { + const entry = registry.banned_global.allowlist[file]; + if (!entry || typeof entry === "string") { + throw new Error(`${file} allowlist entry must be { owner, added, reason }`); + } + return entry; + }; + it("enforces exactly the fifteen globally banned terms", () => { expect(registry.banned_global.terms).toHaveLength(15); }); + it("never lowers the registry version (rule 25)", () => { + expect(registry.version).toBeGreaterThanOrEqual(VERSION_FLOOR); + }); + it("never grows the allowlist — a new violation must be fixed, not excused", () => { const added = allowlistedFiles().filter((file) => !ALLOWLIST_BASELINE.has(file)); expect(added, `these were added to the allowlist rather than fixed: ${added.join(", ")}`).toEqual([]); }); - it("gives every allowlisted file the chunk that clears it", () => { + it("gives every allowlisted file an owner, added version, and reason (rule 24)", () => { for (const file of allowlistedFiles()) { - expect(registry.banned_global.allowlist[file], `${file} has no owning chunk`).toMatch(/\S/); + const entry = allowlistEntry(file); + expect(entry.owner, `${file} has no owning chunk`).toMatch(/\S/); + expect(entry.reason, `${file} has no reason`).toMatch(/\S/); + expect(Number.isInteger(entry.added), `${file} added must be an integer`).toBe(true); + } + }); + + it("expires allowlist entries more than one version behind current (rule 24)", () => { + const stale = allowlistedFiles().filter((file) => allowlistEntry(file).added < registry.version - 1); + expect( + stale, + `these allowlist entries are more than one version behind v${registry.version}: ${stale.join(", ")}`, + ).toEqual([]); + }); + + it("only exempts files that still contain a banned term (rule 24)", () => { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + const escape = (term: string) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + for (const file of allowlistedFiles()) { + const text = readFileSync(path.join(root, file), "utf8"); + const hits = registry.banned_global.terms.filter((term) => + new RegExp(`\\b${escape(term)}\\b`).test(text), + ); + expect(hits, `${file} is allowlisted but contains no banned term`).not.toEqual([]); } }); @@ -484,6 +532,14 @@ describe("banned vocabulary", () => { expect(registry.banned_global.terms).not.toContain(ordinary); } }); + + it("carries rules 22–25 from the v11 review rulings", () => { + expect(registry.rules).toHaveLength(25); + expect(registry.rules[21]).toContain("states the sha each claim was verified at"); + expect(registry.rules[22]).toContain("generated when the document is written"); + expect(registry.rules[23]).toContain("checked in both directions"); + expect(registry.rules[24]).toContain("cannot lower the registry"); + }); }); describe("vocabulary helpers", () => { diff --git a/src/lib/agentIssueTasks.ts b/src/lib/agentIssueTasks.ts index 70a21e4..508127f 100644 --- a/src/lib/agentIssueTasks.ts +++ b/src/lib/agentIssueTasks.ts @@ -1,17 +1,13 @@ /** - * Turning agent issue cases into tasks, and closing the loop after a fix. + * Turning agent issue cases into recommendations, and closing the loop after a + * fix. Essential blockers still enter automatically via + * `reconcileAgentIssueRecsInState`; there is no separate "promote to task" + * path — an agent finding is already a case (`agentIssueCases`), and decisions + * live in the append-only case store. * - * Two entry points into Tasks, deliberately different: - * - Essential blockers enter the Inbox automatically, because a failing - * essential check means agents cannot use the site and that should not wait - * for someone to notice it on a tab. - * - Everything else is promoted only when a user asks. Auto-filing every - * provider finding would bury the Inbox, which is the overload problem the - * UX audit already identifies. - * - * A task keeps the provider check ids and success criteria that were true when - * it was created, so a later verification re-runs exactly the right checks even - * if the issue has since been re-assembled from newer evidence. + * A recommendation keeps the provider check ids and success criteria that were + * true when it was created, so a later verification re-runs exactly the right + * checks even if the issue has since been re-assembled from newer evidence. */ import { costBand } from "./cost"; @@ -93,33 +89,6 @@ export function agentIssueRec( }; } -/** Promote any issue case to a task on explicit request. */ -export function promoteAgentIssueToTask( - state: AppState, - pageId: string, - issue: AgentIssueCase, - now: Date, - origin?: string, -): Rec { - const page = state.pages.find((item) => item.id === pageId); - if (!page) throw new Error(`promoteAgentIssueToTask: page ${pageId} not found`); - const candidate = agentIssueRec(page, issue, now, origin); - const existing = state.recs.find((item) => item.key === candidate.key); - if (existing) { - // Refresh the evidence but never reopen work the user already triaged. - existing.agentIssue = { - ...candidate.agentIssue!, - ...(existing.agentIssue?.verification - ? { verification: existing.agentIssue.verification } - : {}), - }; - if (existing.status === "inbox") existing.status = "task"; - return existing; - } - state.recs.push({ ...candidate, status: "task" }); - return state.recs[state.recs.length - 1]; -} - export interface AgentIssueReconciliation { created: number; updated: number; diff --git a/src/lib/mutations.ts b/src/lib/mutations.ts index d1ad79f..7f8c37c 100644 --- a/src/lib/mutations.ts +++ b/src/lib/mutations.ts @@ -14,9 +14,7 @@ import type { AgentIgnoreOverrideMode, AgentIgnoreScope, AppState, CollectionSch import { defaultNewPageFlag, flagCapacityError } from "./watchCapacity"; import { applyWatchlistPageOrder, changePageFlagOrder, sortWatchlistPages } from "./watchlistOrder"; import { removeTaskMarker } from "./taskMarkers"; -import { promoteAgentIssueToTask } from "./agentIssueTasks"; import { appendConsentEntry } from "./agentConsent"; -import type { AgentIssueCase } from "./agentIssueCases"; import { isKnownNativeElementId, normalizeNativeElementControls } from "./nativeElements"; import { narrowNativeElementExclusionReason } from "./nativeElements"; import { narrowAgentCheckExclusionReason } from "./settings-exclusions"; @@ -286,24 +284,7 @@ export function setVisitorExperienceVisible( * retained, since it is a historical reading rather than a live permission. */ /** - * Promote one agent-access issue case into a task, retaining the provider check - * ids and success criteria so the fix can be verified later. - */ -export function addAgentIssueTask( - pageId: string, - issue: AgentIssueCase, - origin?: string, - dataStore: DataStore = getStore(), - now: Date = new Date(), -): Promise { - return withState((state) => { - promoteAgentIssueToTask(state, pageId, issue, now, origin); - }, dataStore); -} - -/** - * Change the project's consent, and record who changed it. - * + * Change the project's consent, and record who changed it. * * The boolean is the live answer the gate reads; the history is the record of * how it got there. They are written in one `withState` and there is no path * that writes either alone — a flipped boolean with no entry would leave the diff --git a/vocabulary.json b/vocabulary.json index 46f81e9..dd92d75 100644 --- a/vocabulary.json +++ b/vocabulary.json @@ -1,6 +1,6 @@ { - "$comment": "Page Watch vocabulary registry. Source of truth for user-facing status words. Decided in Chunk F1. CHANGELOG: v4 closed three gaps C1a surfaced. v5 closed the gaps the F1/F2/F3/C1 build report surfaced — Resolved had no legal entry, checkpoints had no vocabulary or evaluation rule, and two measuring systems shared one evidence slot. v6 added rules 18 and 19 from S1. v7 added rules 20 and 21 from R1, and extended rule 18 with the withhold-versus-fail distinction after a wrong ruling on F3. v8 added concepts.action.actor_note. v9 is v8's content with tightened prose on rules 18, 20 and 21 and this changelog — renumbered because a registry that changes its content without changing its number is the drift it exists to prevent, and two files numbered 8 already differed. v10 renames evidence_source.kitesurf's label from the codename Kitesurf to Rendered page, from S9: a name only the team could read was the one name that system had on screen, and S9's rule is that no term may appear as jargon alone. The key is unchanged — a data key is not copy.", - "version": 10, + "$comment": "Page Watch vocabulary registry. Source of truth for user-facing status words. Decided in Chunk F1. CHANGELOG: v4 closed three gaps C1a surfaced. v5 closed the gaps the F1/F2/F3/C1 build report surfaced — Resolved had no legal entry, checkpoints had no vocabulary or evaluation rule, and two measuring systems shared one evidence slot. v6 added rules 18 and 19 from S1. v7 (90b84ba2e4ad3b37069776273aa3f6b22f5f43fc) added rules 20 and 21 from R1. v8 (7570e0fb6fd7ddcd87fc260d2bec7aba950af7bc) amended rule 18 with the withhold-versus-fail distinction after a wrong ruling on F3. v9 (9eeec84009c2f7d2633f2578155d0483ebb70035) added concepts.action.actor_note, and is v8's content with tightened prose on rules 18, 20 and 21 and this changelog — renumbered because a registry that changes its content without changing its number is the drift it exists to prevent, and two files numbered 8 already differed. v10 renames evidence_source.kitesurf's label from the codename Kitesurf to Rendered page, from S9: a name only the team could read was the one name that system had on screen, and S9's rule is that no term may appear as jargon alone. The key is unchanged — a data key is not copy. v11 adds rules 22–25 from the review rulings: a document states the sha each claim was verified at (22), a figure a command can produce is generated or left out (23), every allowlist entry is checked both ways and expires after one registry bump (24), and a chunk branch cannot lower the registry version or grow the allowlist (25). Allowlist entries become { owner, added, reason } objects so rule 24 has a clock that lives in the file.", + "version": 11, "decided": "2026-08-24", "concepts": { "work_state": { @@ -296,8 +296,7 @@ "Blended", "Consensus", "Overall" - ] - , + ], "note": "v5 gives Ora its own slot. One slot for two systems meant confidenceFrom counted them as a single voice, so a disagreement between Page Watch and Ora could never surface — the exact failure the ledger was built to catch. is-agentic is removed: it had no producer, and an empty slot reads to the user as a reading that found nothing (rule 15)." }, "trend": { @@ -585,10 +584,14 @@ "An evidence slot with no producer is not a slot. Define it when something writes to it: an empty slot reads to the user as a reading that found nothing.", "A transition the system fires still writes history in the words the user reads. 'Resolved — the 30-day check agreed', never 'auto_resolved'.", "When a case offers no action, it says why in one sentence. An action-less card with no explanation reads as a broken product, not as an honest one.", - "An absent measurement is not a small measurement. A finding with no reading is never folded, filtered or ranked as though its value were zero — it sorts last within its group and its row says 'not measured', never 0. Same principle as checkpoint.unavailable: no reading is neither good news nor bad. The response to absence is to WITHHOLD the claim, not to fail \u2014 a conclusion that depends on a reading nobody took is simply not stated, while a broken invariant, a shape that should have been impossible, fails loudly and names what was malformed. Confusing the two trades a false claim for a crashed screen. And withholding good news must never swallow bad news: a claim withheld for want of data still reports every drop it did measure.", + "An absent measurement is not a small measurement. A finding with no reading is never folded, filtered or ranked as though its value were zero — it sorts last within its group and its row says 'not measured', never 0. Same principle as checkpoint.unavailable: no reading is neither good news nor bad. The response to absence is to WITHHOLD the claim, not to fail — a conclusion that depends on a reading nobody took is simply not stated, while a broken invariant, a shape that should have been impossible, fails loudly and names what was malformed. Confusing the two trades a false claim for a crashed screen. And withholding good news must never swallow bad news: a claim withheld for want of data still reports every drop it did measure.", "A number shown for a group is the same statistic as the number shown for one member: the worst observed reading, never a sum. Adding measurements across findings invents a figure no run produced, and a group total that cannot be reconciled with the rows beneath it is worse than no total.", - "A fact stated twice is a defect waiting, so state it once. Where a second statement is genuinely unavoidable \u2014 a pre-paint script that cannot import, a type that must not reach a provider module \u2014 the second copy carries a test that executes or type-checks the two against each other. A comment asking the next editor to keep them in step is not a mechanism; every drift found in R1 had exactly such a comment and nothing else.", - "A test asserts the decision, not the code. Asserting a literal that vocabulary.json also names is asserting a mirror against a mirror: it proves two copies agree, never that either is right. Assert against the registry, or against the other half of the decision, so the test fails the moment the halves disagree." + "A fact stated twice is a defect waiting, so state it once. Where a second statement is genuinely unavoidable — a pre-paint script that cannot import, a type that must not reach a provider module — the second copy carries a test that executes or type-checks the two against each other. A comment asking the next editor to keep them in step is not a mechanism; every drift found in R1 had exactly such a comment and nothing else.", + "A test asserts the decision, not the code. Asserting a literal that vocabulary.json also names is asserting a mirror against a mirror: it proves two copies agree, never that either is right. Assert against the registry, or against the other half of the decision, so the test fails the moment the halves disagree.", + "Shipped, deleted and merged describe main. A document assembled from session reports states the sha each claim was verified at, or marks the claim unverified. A claim about a branch names the branch. A reader who cannot tell which tree a sentence describes cannot check it, and a document that cannot be checked is a claim of good faith rather than a record.", + "Any figure a command can produce — chunks merged, allowlist entries, glossary terms, sites repaired — is either generated when the document is written, with the command shown, or left out. Rule 20 says state a fact once; a transcribed count states it a second time in the one place nothing can check.", + "Every allowlist entry is checked in both directions. The file must still contain a banned term — an exemption covering a clean file is deleted. And the entry must name a chunk whose work is outstanding: when the named chunk has merged, the entry fails the check rather than waiting for a reader to notice. The list may only shrink was never enough, because entries outlive the chunks that owed them.", + "vocabulary.json on a chunk branch is a read-only copy; landing takes main's. A check rejects any state where the version has decreased or the allowlist has gained an entry, so a stale branch cannot lower the registry by merging." ], "banned_global": { "$comment": "Hard-gated: these must not appear in user-facing copy anywhere in src/. This is the list the eslint rule enforces. A concept's banned_as_label list is narrower — it means 'not a valid label for THIS concept', not 'never write this word'.", @@ -610,10 +613,8 @@ "Watching outcomes" ], "allowlist": { - "$comment": "Pre-existing violations in files C1a does not own. Each entry names the chunk that clears it. The list may only shrink. webflow-connection.tsx was cleared in C1a and removed in v5 — the rename it covered is done and the entry was dead. pages/[id]/page.tsx was cleared in S3 and removed: the tabs it named are gone and the native-element dispositions are now the applicability and work_state concepts below. watchlist/page.tsx was cleared in S8 and removed: the settings mode that carried the Ignore/Suppress copy moved to /settings and became the applicability concept below, and the watchlist itself never used those words. agent-access.tsx was cleared in S4 and removed: the two route references were the Tasks button, which now names the Fix queue. guide.ts was cleared in S9 and removed: the glossary is deleted, and the retired-term definitions it carried went with the file rather than moving anywhere.", - "src/components/bits.tsx": "F2 — Verifying/Returned belong to the lifecycles F2 deletes", - "src/components/store.tsx": "S2 — route references to the retired destinations" + "$comment": "Pre-existing violations in files C1a does not own. Each entry is { owner, added, reason }: owner names the chunk that clears it, added is the registry version the entry was recorded at (rule 24 — expires when more than one version behind current), reason says why the exemption exists. The list may only shrink. webflow-connection.tsx was cleared in C1a and removed in v5 — the rename it covered is done and the entry was dead. pages/[id]/page.tsx was cleared in S3 and removed: the tabs it named are gone and the native-element dispositions are now the applicability and work_state concepts below. watchlist/page.tsx was cleared in S8 and removed: the settings mode that carried the Ignore/Suppress copy moved to /settings and became the applicability concept below, and the watchlist itself never used those words. agent-access.tsx was cleared in S4 and removed: the two route references were the Tasks button, which now names the Fix queue. guide.ts was cleared in S9 and removed: the glossary is deleted, and the retired-term definitions it carried went with the file rather than moving anywhere. bits.tsx was cleared in R4 and removed: Verifying/Returned no longer appear in its titles. store.tsx was cleared in R4 and removed: the toast that named Inbox no longer does. The list is empty — an exemption block that consumes it must not declare files: []." } }, - "revised": "2026-08-26" + "revised": "2026-08-27" }