Fourth cold review, scoped to round 4. It found no Blocker and no High, and confirmed the retraction sweep was complete, the ci-cd.md correction accurate, the notice string safe and test-compatible, and the fixture edits behaviour-preserving. THE DETECTOR IS NOW ITS THIRD VERSION, AND THE THIRD VERSION IS THE DUMBEST ONE ON PURPOSE. Round 4 matched `"jq -e" in line`. Round 5 replaced that with a parse of the LEADING option tokens, stopping at the first non-option because everything after is the jq program. Review named two false negatives that reasoning bought: `jq --argjson e 1 -e '.'` (the walk stops at the bare `e` argument) and `jq '.a' -e` (jq accepts options after the filter). That is three defects in one string-matching predicate, which is this repo's documented signal to stop adding cases and remove the cleverness. It now scans EVERY token after each `jq` with no attempt to locate the program. That over-approximates, and the over-approximation is the safe direction for a guard over one 36-line function we own: a false positive costs a rewrite, a false negative restores the jq-1.6 divergence inside the code that writes a required status. Verified no false positive against the real body — its six jq reads carry no `-`-prefixed token but `-r`. Proved on the regression review called MOST LIKELY rather than only the obvious one: every read in that function is already `jq -r`, so the natural way `-e` returns is clustered onto one of them. `jq -re 'length'` reddens the guard; so does `jq '.a' -e`. THE RETRACTION REGENERATED ITSELF ONE PARAGRAPH BELOW ITS OWN FIX. Round 4 qualified the self-trigger claim in the paragraph at the head of `count_pr_mutations` and in the notice, and left the paragraph two lines further down asserting "the termination argument transfers UNCHANGED from the base axis: a push fires `synchronize`" — the exact unqualified form, in the paragraph a reader goes to for the safety property. Fixed, with the first-push window named inline rather than by reference. Also: the head-fence notice said the PR "needs a re-trigger (any edit will do)", which is false — a label or a comment fires none of this workflow's `types:`, so a reader following that advice would conclude the gate is broken. It now names a push or a title/body/base edit. The brace-counting extraction could not distinguish "captured the function" from "stopped at a balanced prefix", so a `jq -e` past an early depth-0 was invisible while the guard reported success. It now pins a line from the END of the body. Two doc nits: `docs/ci-cd.md` still singled out `null` in its tail after the edit that made both shapes symmetric (and then read "an empty first page is anomalous rather than empty"); the record's rename note parsed ambiguously as "and then". PROCESS NOTE, because it nearly cost the round: restoring a mutated file with `git checkout -- <file>` restored it from the INDEX, silently discarding every uncommitted edit of this round in that file. Caught by grepping for the edits rather than trusting the restore. Mutation loops here back up with `cp` and restore with `cp`. Verification: 1129 passed, 2 skipped; ruff clean; decisions-validate OK; YAML parses, and every mutant parsed before being measured.
docs/ — task-signal map
Purpose: route a fresh contributor/agent to the minimal set of docs for the task at hand, instead of a mandatory front-to-back read. Update this doc in the same PR that adds, removes, or retitles a doc below, or that changes which sections a task signal points to.
Start here, always
CLAUDE.md(repo root) — project intro: architecture, layout, dev commands, conventions, Task Completion Protocol.docs/contributing.md— established code patterns (CQRS/MediatR, LanguageExt, the ChicoryTV SPA, EF Core dual-provider migrations, FFmpeg pipeline, analyzers, testing). Read before any non-trivial change.
Task signal → minimal sections
| Signal | Read |
|---|---|
| Session startup / "what's next" (no issue named) | docs/handoffs/chicorytv-issue-queue.md (standing kickoff — two concurrent tracks: orientation ‖ scripts/select-queue.sh 5) |
| Named-issue pickup | Skip queue selection; go straight to focused retrieval — see "Knowledge retrieval" below, then the issue body |
Adding/changing a /api/* endpoint |
docs/api-conventions.md checklist + docs/endpoint-index.md |
| Adding a ChicoryTV SPA screen | docs/spa-conventions.md |
| Explaining a consequential settings field in the SPA (summary → hover/tap panel → docs link) | docs/spa-conventions.md §15 — use the shared FieldHelp trigger and put the copy in the screen's own FIELD_HELP record; the icon, the gesture and the a11y contract are fixed |
Graphics element / overlay work (text bug, On Now / Next, watermark-vs-[vge]) |
docs/graphics-elements.md, then decisions catalog rows keyed graphics.* |
| Scheduling / playout engine work | docs/domain-model.md + decisions catalog rows keyed sched.* (docs/decisions/README.md) |
Adding or changing a paged list handler (a page plus a TotalCount) |
Resolve api.paged-count-matches-page-query via docs/decisions/README.md — for an EF-backed filtered list, count the SAME query you page, with includes appended to the page chain only; where the count and the page are separate methods, a test pins their agreement. Then api.paging-zero-based for the pageNum/pageSize contract |
| Concurrency / optimistic-locking work | docs/api-conventions.md §7a/b/c + docs/decisions/optimistic-concurrency.md |
| Auth / security-surface work | docs/decisions/api-auth-security.md |
| CI / release pipeline work | docs/ci-cd.md + docs/decisions/release-ci-governance.md |
| Proposing a new guard / CI check / regression test convention | docs/defect-shapes-773.md §4 (detector menu + the classes where no detector is plausible), then the rules every guard must satisfy: docs/decisions/records/testing/guard-derives-population-from-source.md, …/guard-ships-with-mutation-proof.md and …/mutation-claims-are-executed.md (a MUTATION grade carries a DECLARED clause mutation that is re-run every suite) — plus …/verification-code-needs-its-own-proof.md, which extends the same obligation BEYOND guards to the harness, wrapper or checker doing the checking, and says where its proof lives when the checker holds no row |
| Adding or bounding a consequential numeric config field (an FFmpeg profile tunable, a pipeline knob) | docs/api-conventions.md §3d — reject out of range with a 422 naming the bound and its consequence, never accept-then-rewrite; validate against the constants the renderer reads, keep the render-time clamp for pre-existing rows, and let an UNCHANGED legacy value through on update. Then api.ffmpeg-profile-numeric-bounds |
| Testing a surface gated by config / an env var / a credential | docs/decisions/records/testing/deny-path-at-production-config-value.md — cover the setting absent, at its production value, and each opt-out, and assert the DENY branch |
| Touching a full-replace write path or a hand-built request object | docs/decisions/records/testing/full-replace-asserts-field-list.md — derive the field list from the DTO and assert set equality; reconcile by id where child state exists. In the SPA the same rule is enforced by the type system: docs/spa-conventions.md §4b — build the body as Complete<T>, annotating BOTH the wrapper parameter and every construction site |
| Writing or editing any doc, or answering a review finding in prose | docs/decisions/records/docs/no-session-narrative.md — the doc records the END STATE; the path to it goes in the commit message. Apply the who-benefits test, and read the carve-out before you cut (dated measurements, stated snapshot boundaries and tested-and-rejected results stay) |
| Adding, renaming or removing a workflow JOB | docs/ci-cd.md → "Per-job declarations" — every job declares env.CI_JOB_ROLE (and, in docker-build.yml, env.CI_EXECUTION_CLASS); a missing or unknown value fails scripts/tests/test_workflow_job_guards.py / …/test_ci_image_pin_population.py, and a guard or report-only job also needs a row in docs/guard-inventory.md → "Workflow-job guards". Rationale: docs/decisions/records/testing/workflow-declares-its-own-job-metadata.md |
| Adding / changing / deleting a guard file | docs/guard-inventory.md — every guard's row is machine-checked by scripts/tests/test_guard_inventory.py, so a new guard must acquire a row before the suite goes green, and a row graded MUTATION must also acquire a declared clause in scripts/tests/mutation_manifest.py |
| Writing code that reads live Gitea/remote state and then acts on it | docs/decisions/records/process/check-and-use-pins-a-version.md, then docs/remote-state-inventory.md — a new executable under scripts/ (excluding scripts/tests/), .claude/hooks/, .husky/ or .gitea/workflows/ must acquire a row there before scripts/tests/test_remote_state_inventory.py goes green |
| Finding every site that references a symbol (multi-site fix/sweep) | docs/local-lsp-tooling.md — which of the three surfaces answers, and why a delegated agent must be pointed at an MCP server (csharp-lsp, or serena after an activate_project) rather than the LSP tool, which no subagent has been observed to reach |
| Live local run / Playwright-MCP verification | docs/e2e-local.md + scripts/e2e-local.sh |
| Adding/changing a UI-E2E browser flow | docs/e2e-local.md → "UI-E2E harness" + scripts/e2e-ui.sh |
| What does a test suite cover | docs/testing.md |
| Legacy Blazor route lookup | docs/blazor-route-parity.md (historical #91 phase (b) inventory) |
| "Why do we do X this way" / challenging a convention | Catalog-first: docs/decisions/README.md (active rows) → follow the row's link to docs/decisions/records/<area>/<topic>.md for full rationale. docs/decisions/archive/<area>/ only for "what did the rule used to be." |
Knowledge retrieval (MemPalace + catalog + Gitea)
These four rules are the seam agreed with server-management#642 (the Gitea→MemPalace exporter). They apply whether the question comes up via MemPalace, a grep, or a stale comment:
- Current conventions/decisions → catalog-first. Start at
docs/decisions/README.md; discover via theErsatzTV-Decisionswing (active) /ErsatzTV-Decisions-Archive(superseded/retired). Resolve by topic/key, never by chasing a file path. - Issue history → evidence, not authority. The
Gitea-ErsatzTVwing is historical narrative that may be stale; it never overrides current Markdown. - The breadcrumb rule (the crux behavior change). A file path named inside a historical issue
comment (e.g. "grep
docs/decisions.md2026-07-17", "see …") is a breadcrumb, not a live pointer. Find the current rule via the catalog / active wing by concept; do not treat the named path as current. (Why it's safe: still-current → in the active wing, breadcrumb resolves; superseded → the active wing returns the successor and a literal follow lands on a record that announces its ownstatus: superseded; retired → the active wing returns nothing, which is itself the signal. The validator-enforced move-to-archive/is what prevents the catastrophic "superseded rule read as current" case.) - Fallback when MemPalace is stale/down:
docs/decisions/README.mdcatalog, thenrg '^`key: <dotted.key>`' docs/decisions/. MemPalace is never authority nor sole fallback.
MemPalace is candidate discovery only — every passage is verified against its cited Markdown/Gitea
source before use. Never derive live queue state from MemPalace, #237, or historical comments; queue
state is live Gitea state, retrieved via scripts/select-queue.sh (see
docs/handoffs/chicorytv-issue-queue.md). Full retrieval contract (altitude/precedence, staleness
bounds, what's mined per issue): docs/handoffs/chicorytv-issue-queue.md → "Knowledge retrieval".
Also present in docs/
docs/domain-model.md— what the app IS: entity glossary, channel→playout→schedule/block concept map, where each concept is edited in the SPA.docs/api-conventions.md— checklist for adding/changing a/api/*endpoint (controllers, DTOs, error mapping, auth, OpenAPI regen, tests).docs/spa-conventions.md— playbook for adding a screen to the ChicoryTV React SPA.docs/e2e-local.md(+scripts/e2e-local.sh) — how to run a live local instance for manual or Playwright-MCP verification.docs/local-lsp-tooling.md— the code-intelligence surfaces (theLSPtool's three servers, thecsharp-lspMCP server, andserena): how each is configured, which ones a subagent can actually reach, the traps (a cold server answers the first query with a confidently partial result; serena needs anactivate_projectper directory), andscripts/check-local-lsp.shto verify the preconditions. Read before briefing an agent to find every site referencing a symbol.docs/testing.md— testing map: what each*.Testsproject /websuite covers, golden-file nets, the timezone-independence rule, how to run subsets, the per-PR verification gate.docs/blazor-route-parity.md— historical record of the completed #91 phase (b) cutover: the Blazor Server UI is removed and every legacy route now 302-redirects to its SPA equivalent (or falls through to the catch-all →/app). Read it for the full legacy→SPA route inventory.docs/decisions/records/<area>/<topic>.md— one active decision record per file, YAML frontmatter (key/title/status/since/supersedes/superseded-by, plus optionalstale-after/sources— ersatztv#603), rationale prose in the body. The filename is the key, so one-active-record-per-key is a filesystem property (ersatztv#610).docs/decisions.mdand the topic files remain as the lifecycle-schema narrative plus a "Records formerly in this file" index, which is what keeps older date-based pointers resolvable. Generated active view:docs/decisions/README.md(catalog / task router) — start there. Superseded/retired records live indocs/decisions/archive/and are read only for history, never for "what is the current rule."docs/ci-cd.md— build/test/release pipeline, versioning, dependency management.docs/rest-api.md— REST API design doc for ersatztv#2 (goals, conventions, per-slice plan). Largely superseded day-to-day bydocs/api-conventions.md; read this for the original rationale.docs/mcp.md— theErsatzTV.Mcpstdio JSON-RPC MCP server (#58): how it wraps/api/v1as read + cautious-write tools, its config/env vars, auth, security posture, and the tool catalog.docs/graphics-elements.md— graphics element (overlay) schema reference: how elements are discovered and attached, the YAML parsing traps (an unknown key disables the element outright), the full text-element field table including the #732 background box, and why[vge]in a filter graph does not imply a graphics element is bound.docs/channels.md— Channel entity field reference.docs/m3u-xmltv.md— M3U/XMLTV generation overview (ChannelPlaylist,GetChannelGuideHandler).docs/fork-strategy.md— divergence policy vs upstream ErsatzTV.docs/design-sync.md— Claude Design ↔ repo screen workflow (#92).docs/endpoint-index.md— generated REST endpoint index (method/path/operationId/summary per OpenAPI tag). Do not edit by hand; regenerated byscripts/generate-endpoint-index.py/scripts/update-openapi.sh.docs/handoffs/chicorytv-issue-queue.md— static session kickoff prompt + workflow lore. Queue state is live Gitea state, retrieved each session viascripts/select-queue.sh— see that file's standing kickoff for the two concurrent tracks (orientation ‖ selection). ersatztv#237 is a closed, archival historical tracker (superseded bystartup.parallel-orientationindocs/decisions.md) — not a live pointer.docs/defect-shapes-773.md— root-cause analysis of the recurring defect shapes across the whole closed-issue corpus (#773): the measured class ranking, the four families they consolidate into, the cheapest mechanical detector per class, the classes where no detector is plausible, and an audit of which configured hooks/MCP servers/LSPs are actually invoked. Read it before proposing a new guard or CI check — §4 is the detector menu, and it argues against enumerating cases one incident at a time.docs/remote-state-inventory.md— every executable inscripts/(excludingscripts/tests/),.claude/hooks/,.husky/and.gitea/workflows/that reads live remote state and acts on that read, classifiedPINNED/CAS/UNSAFE-KNOWN/N/Awith the window and what bounds it. Code outside those directories — C#/TypeScript guards,web/, and the test suites themselves — is out of scope, and the doc states that rather than implying coverage. The population is derived fromgit ls-filesand compared for set equality byscripts/tests/test_remote_state_inventory.py, so a new script that talks to a remote service cannot ship unclassified. Read it withprocess.check-and-use-pins-a-version; it is that record's detector, since the class has no plausible linter (docs/defect-shapes-773.md§4 detector D).docs/guard-inventory.md— every executable guard file, what it blocks, whether it is aGUARDorTOOLING, and whether it ships a mutation proof (MUTATION/BEHAVIOUR-ONLY/NONE) with afile::functionref. The population is derived from the filesystem and the workflow/hook call sites and compared for set equality byscripts/tests/test_guard_inventory.py, so a new guard cannot ship unclassified and a renamed test cannot leave a row claiming coverage it has lost. Guards implemented inline in workflow YAML are deliberately outside that population — the doc states the limit rather than implying coverage.docs/tracker-retrofit-triage-237.md— audit trail for the #524 triage of ersatztv#237's 111 comments (method, per-comment classification, totals). Evidence for thedocs.tracker-comment-retrofitdecision; read it only when triaging another over-cap tracker.docs/handoffs/rest-api.md— original handoff prompt for kicking off the REST API work (#2).