Files
dyolink/docs/specs/voice-treatment-entry/progress.md
Amin Mousavi 15ddb9aac2 feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.

Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.

Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().

Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.

Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.

KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
  type-row lock is missing, so unticking it saves prosthesis lab rows on a
  non-prosthesis detail

Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.

Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00

31 KiB
Raw Blame History

type, status, repos
type status repos
task-progress active
repo path branch base role spec_slug merge_after
dyolink ~/PersonalProjects/dyolink feat/treatment/add-voice-input-for-new-treatment-form origin/master extraction contract + resolvers + review sheet voice-treatment-entry

PROGRESS — Voice Treatment Entry

Spec: spec.md

Adapting the merged voice feature to the overhauled prosthesis model (spec §5§7, decisions 3446). v1 shipped on master; this branch revises it.

Status legend: not started · 🟡 in progress · done · blocked

# Work item Repo Status Notes / refs
1 pickRecordingMimeType falls back to the empty hint instead of null dyolink §9. frontend/src/lib/voice/audioFormat.ts — the final return null after the loop is now return ''
2 voiceForEditor also checks isMediaRecorderSupported() dyolink §2 render policy, TreatmentWorkspace.tsx
3 Add Vitest for the frontend's pure helpers; update CLAUDE.md dyolink §12. vitest@3.2.7, frontend/vitest.config.ts, npm run test script; CLAUDE.md Tests section rewritten
4 buildCatalog passes category / subcategory / chartRegion / stackGroup through dyolink §5. voice.service.ts now forwards the full ProsthesisTypeCatalogEntry[] plus category/subcategory lists
5 Wire schema: prosthesisAssignments, arch: 'both'; drop default + overrides dyolink §5, extraction.wire.tsprosthesis: WireProsthesisAssignment[]
6 Resolver: assignments, arch derivation, leaf-vs-category classification dyolink §5, §6, extraction.resolver.tsresolveProsthesisAssignment, resolveAssignmentTarget, classifyTypeCode
7 Resolver: new unresolved reasons; retire tooth_not_selected dyolink §6 reason table — voice.types.ts
8 Resolver: a resolved assignment forces treatmentType to prosthesis dyolink §5. resolveVoiceIntenthasResolvedAssignment
9 Prompt: present the catalog as a tree; teach stacks and jaw-level codes dyolink §5, extraction.prompt.tsprosthesisTree() renders CATEGORY/SUBCATEGORY/leaf from data, no hardcoded catalog knowledge
10 Backend Jest suites for items 68, including the namespace-disjointness assertion dyolink §12. extraction.resolver.spec.ts — disjointness test reads catalog-seed-data.ts's live PROSTHESIS_TYPES, not a written count
11 Frontend types follow the new ResolvedExtraction dyolink types/voice.tsVoiceProsthesisAssignment[], assignmentIndex
12 voiceReviewRows: merged row, chip folding, retire complete as a blocker dyolink §6, §7 — prosthesisTargetLines, joblessProsthesisTargets, withChosenArch/withChosenProsthesisLeaf
13 VoiceReviewSheet: merged row, chart colours, three chip kinds dyolink §7. Merged "Teeth and prosthesis" row; tooth / jaw / material chip kinds
14 applyVoiceResult writes through applyLeafToJobs; handles arch rows dyolink §6, §7 — via prosthesisTargetLines, which routes every stack through applyLeafToJobs
15 Vitest specs for prosthesisTree.ts and voiceReviewRows.ts dyolink §12. 37 tests total, npx vitest run green
16 New user-visible strings in en.json, fa.json, nl.json dyolink i18n is mandatory, not a follow-up — new voiceUnresolved.* reasons, voiceTeethAndProsthesis, voicePickJaw, voiceNoProsthesisHeard, voiceStackRefused; retired voiceProsthesisIncomplete (all-or-nothing gone)
17 Run every gate in §12, then the manual pass including Safari and iPad dyolink 🟡 Machine gates green (below). Manual pass and prisma:migrate/prisma:seed against a live DB not run — this sandbox has no working Docker daemon (see note below)
18 PROSTHESIS_CATEGORY + PROSTHESIS_SUBCATEGORY in CatalogEntityKind; migration; seed fa/en/nl translations dyolink 🟡 §5, decision 47. Enum + migration SQL + seed data all written and prisma generate succeeded; migration not applied to a running Postgres (Docker unavailable)
19 Unresolved items carry assignmentIndex; a picked chip inherits that assignment's jobs dyolink §6, decision 50. UnresolvedItem.assignmentIndex; VoiceReviewSheet chips inherit via withChosenTeeth(...,index) / withChosenArch / withChosenProsthesisLeaf

Key decisions

Full table in the spec's §13, rows 3446. The load-bearing ones:

  • One assignment list, no default type. A default plus per-tooth overrides has a precedence rule, and that rule already mis-filled tooth 13 once.
  • An arch target is derived from the code's chartRegion, not declared on the wire. The catalog already answers it, and partial_denture already carries chartRegion: 'crown'.
  • Stack rules stay frontend-only. The backend's dispatch check knows nothing about stacking; porting canStackLeaf would make voice stricter than the manual path and create a second copy of a rule that must never disagree.
  • Teeth and prosthesis merge into one sheet row for lab-dependent types. Two ticks can save an empty detail today, because persistDraft prunes lab-dependent details to their jobs.
  • Nothing is guessed. A missing material, a missing jaw and a missing quadrant all become chips. A jobless tooth is named and left out.
  • Categories become real catalog entities (decision 47). Two new CatalogEntityKind values and seeded translations, rather than bare untranslated codes in the prompt on fa.
  • A chip must inherit its assignment's jobs (decision 50), or it resolves to a jobless tooth and the tap does nothing.

Deviations from spec

(none yet)

Next steps / open questions

  • Re-run /orchestrate --dry with a gapNote, so the surveyor re-runs instead of replaying its cached gaps_found verdict.
  • Amin reviews and approves this spec revision. That approval is the only human gate before /orchestrate --dry.
  • Open items 15 and 16 in §11 are carried forward unchanged and are not in this branch: the pre-authentication body limit on the voice path, and transcript salvage still being specified but not built.
  • feat/voice-treatment-entry and backup/pre-rebase-voice are stale local branches from the v1 work. Safe to delete once this lands.

2026-09-07 — Gap-check halted (nothing built, nothing pushed)

  • Phase: Gap-check
  • Reason: the spec has 1 gap that blocks implementation — nothing was built.
  • Repo: dyolink @ feat/treatment/add-voice-input-for-new-treatment-form

Summary: The prompt must present prosthesis categories and subcategories so the model can return crown for "روکش", but no localized label for a category or subcategory exists anywhere the backend can read. CatalogEntityKind has only TREATMENT_TYPE / PROSTHESIS_TYPE / LAB_WORKFLOW_STEP, and the only category labels in the repo are frontend message keys (category_crown, sub_night_guard) plus a hardcoded single-locale map in a dev export script.

Spec quotes:

The prosthesis list is no longer flat. buildCatalog already receives category, subcategory, chartRegion and stackGroup from ProsthesisCatalogService and throws all four away. It now passes them through, so the prompt presents the catalog as the tree it is and marks which codes are jaw-level. Still no catalog knowledge hardcoded in the prompt — the shape comes from the data.

Treatment types and prosthesis types come from CatalogLabelService in the actor's locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms of prosthesis and monolithic_zirconia rather than being asked to translate. Catalog entities store a stable code and no label — never hardcode a label.

"روکش" is crown — a category with nine leaves, not a material. The model returns the category code it actually heard rather than guessing pfm_crown, and the review sheet offers the leaves as chips (§7).

Why blocking: Work item 9 ("Prompt: present the catalog as a tree") cannot be built. ProsthesisCatalogService.list() returns category and subcategory as bare codes with no label (prosthesis-catalog.service.ts:82-95), and CatalogLabelService cannot resolve them because CatalogEntityKind has no category kind (schema.prisma:331-335). The three ways out have different costs and one breaks an explicit repo rule, so a builder cannot choose: (a) add PROSTHESIS_CATEGORY / PROSTHESIS_SUBCATEGORY to the enum plus a migration and seeded translations — a schema change the spec never mentions; (b) hardcode a fa/en/nl label map in the backend, which the spec and CLAUDE.md both forbid; (c) send bare codes only, which makes the whole category feature unmeasured on the weakest locale.

Question: Where do the localized labels for the 7 prosthesis categories and 5 subcategories come from for the extraction prompt: a new CatalogEntityKind (PROSTHESIS_CATEGORY / PROSTHESIS_SUBCATEGORY) with a migration and seeded CatalogTranslation rows, or bare codes with no labels in the prompt?

Suggested spec change: Add to §5: "Category and subcategory labels have no backend source today — CatalogEntityKind covers only TREATMENT_TYPE, PROSTHESIS_TYPE and LAB_WORKFLOW_STEP, and category_* / sub_* exist only in frontend/messages/*.json. Add PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind (one migration), seed their fa/en/nl translations from the existing message keys, and resolve them through CatalogLabelService like every other catalog label."

Evidence: backend/prisma/schema.prisma:331; backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts:82; frontend/messages/en.json:1124; backend/prisma/export-prosthesis-catalog.ts:16

Non-blocking notes

Note 1 — ambiguity (dyolink). An unresolved item carries only spoken, reason and candidates, with no link back to the assignment it came from. On a prosthesis detail a tooth_missing_quadrant chip therefore resolves to a tooth with no job, which the "tooth with no job is named and left out" rule then discards — so tapping the chip does nothing.

A single digit alone is never resolved. "دندون دو" names four teeth. It is reported as tooth_missing_quadrant with the candidate codes attached

A tooth with no job is named and left out.

Picking one folds the choice into the result, so an under-specified item is one tap from resolved instead of a dead end.

Why not blocking: a builder can add an assignment reference to the unresolved item. But §6's reason table reads as a complete contract and its "Carries" column names only candidates, so a builder following it literally ships a dead chip on exactly the prosthesis flow §12 asks them to test. Question: Should VoiceUnresolvedItem gain an assignment reference (for example assignmentIndex) so a chosen quadrant chip inherits that assignment's job stack? Suggested spec change: In §6's reason table, add: "tooth_missing_quadrant and arch_not_spoken also carry the index of the assignment they came from, so a picked chip inherits that assignment's types[] rather than becoming a jobless tooth." Evidence: frontend/src/types/voice.ts:15-25; frontend/src/components/treatment/voiceReviewRows.ts:66

Note 2 — ambiguity (dyolink). "The resolver confirms [jaw vs tooth] against the chartRegion of the assignment's codes" is undefined when types[] holds a category whose leaves have mixed regions. removable is exactly that: complete_denture and overdenture are chartRegion: 'arch', partial_denture is 'crown'.

A target that names an arch and no position is a jaw, and the resolver confirms that against the chartRegion of the assignment's codes.

partial_denture — a removable code that is nonetheless assigned per tooth — already ships with chartRegion: 'crown' in catalog-seed-data.ts, so the exception needs no special case either.

A code whose region contradicts its target is reported, never coerced.

Why not blocking: deferring the region check until the ambiguity chip is picked is a reasonable default. Worth stating so the builder does not emit code_not_valid_for_target for a category that has not been narrowed yet. Question: When types[] holds a category whose leaves span both crown and arch regions (only removable today), is the region check deferred until the clinician picks a leaf chip? Suggested spec change: In §5 under "A target is a tooth or a jaw": "When types[] holds a category whose leaves have more than one chartRegion, the region check is deferred — the item resolves to prosthesis_type_ambiguous and the region is confirmed against the leaf the clinician picks." Evidence: backend/prisma/catalog-seed-data.ts:266-288

Note 3 — brownfield (dyolink). §3 names the endpoint POST /treatments/voice-extract; the shipped route is POST /voice/extract (@Controller('voice') + @Post('extract')), and common/body-parsers.ts hardcodes /api/voice/extract to select the 10 MB body limit.

POST /treatments/voice-extract

Why not blocking: no work item asks to move the route. But if a builder aligned the route with §3, isVoiceExtractPath would stop matching and every real recording would 413 against the 100 kb default, which reads as a broken microphone rather than a route change. Question: Should §3 be corrected to the shipped path POST /voice/extract, since no work item moves the route? Suggested spec change: Change §3's heading to POST /voice/extract and note that VOICE_EXTRACT_PATH in common/body-parsers.ts must move with it if it ever changes. Evidence: backend/src/modules/voice/voice.controller.ts:23,36; backend/src/common/body-parsers.ts:10

Note 4 — contradiction (dyolink). §11 item 13 says v1 ships with "no availability endpoint", but §4 requires the frontend to learn enabled locales from the API, and GET /voice/availability exists and is what voiceForEditor reads today.

Availability APIresolved for v1: voice ships open to everyone with a configured locale profile. No plan check, no availability endpoint.

The frontend must learn which locales are enabled from the API, not from a NEXT_PUBLIC_* var

Why not blocking: work item 2 ("voiceForEditor also checks isMediaRecorderSupported()") presupposes voiceAvailability stays, so the working reading is obvious. The stale line just needs correcting so nobody deletes a live endpoint. Question: Confirm the availability endpoint stays in v1 and only the plan check is deferred? Suggested spec change: Reword item 13 to "no plan check; the availability endpoint ships and reports configured locales and maxRecordingMs only." Evidence: backend/src/modules/voice/voice.controller.ts:28; frontend/src/components/ui/treatment/TreatmentWorkspace.tsx:625

Note 5 — brownfield (dyolink). §5's disjointness counts are slightly off: the seed defines 41 distinct prosthesis codes (not 42) and 5 subcategory names — veneer, inlay, onlay, overlay, night_guard (not 4). The disjointness claim itself holds: no leaf code equals any category or subcategory name.

This needs no extra field because the namespaces are disjoint: 42 leaf codes against 7 category names and 4 subcategory names, no collisions.

Why not blocking: the test asserts disjointness, not counts, and disjointness is true against the current seed. Flagged so the builder writes the test against the live catalog rather than a hardcoded 42/7/4. Question: May the disjointness test assert against the live catalog instead of the counts written in §5? Suggested spec change: Drop the exact counts and keep only "the leaf, category and subcategory namespaces are disjoint; a test asserts it against the live catalog". Evidence: backend/prisma/catalog-seed-data.ts:121-400

Files read during the gap-check

docs/specs/voice-treatment-entry/spec.md, docs/specs/voice-treatment-entry/progress.md, backend/src/modules/voice/extraction.prompt.ts, backend/src/modules/voice/extraction.wire.ts, backend/src/modules/voice/voice.providers.ts, backend/src/modules/voice/voice.service.ts:243, backend/src/modules/voice/voice.controller.ts:23, backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts:72, backend/prisma/catalog-seed-data.ts, backend/prisma/schema.prisma:331, backend/prisma/export-prosthesis-catalog.ts:16, backend/src/common/fdi.ts, backend/src/common/body-parsers.ts:10, backend/src/modules/treatments/lab-case-send.validation.ts:116, frontend/src/components/treatment/prosthesisTree.ts, frontend/src/components/treatment/voiceReviewRows.ts, frontend/src/types/voice.ts, frontend/src/lib/voice/audioFormat.ts, frontend/src/lib/voice/useVoiceCapture.ts:157, frontend/src/components/ui/treatment/TreatmentWorkspace.tsx:625 and :2184, frontend/src/components/ui/treatment/FdiToothChart.tsx:218, frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx:200, frontend/messages/en.json:1124, frontend/package.json (all paths relative to /Users/aminmsvi/PersonalProjects/dyolink)

How to resume

Close the gaps in the spec, then re-run. Pass gapNote describing what you changed, or the analyst replays this same verdict from cache.


2026-09-07 — Gaps closed, ready to re-run

All five findings are now answered in the spec. Nothing was built in the halted run, so there is no code to revisit.

Finding Resolution Spec
Blocking — no backend source for category labels Add PROSTHESIS_CATEGORY + PROSTHESIS_SUBCATEGORY to CatalogEntityKind, one migration, seed fa/en/nl from the existing prosthesis.category_* / sub_* keys, resolve via CatalogLabelService. Frontend keeps its own keys — migrating it is out of scope §5, decisions 4748
Note 1 — chip with no assignment link Unresolved items raised inside an assignment carry assignmentIndex; a picked chip inherits that assignment's types[] §6, decision 50
Note 2 — mixed-region category removable defers its region check to the picked leaf; never code_not_valid_for_target while still a category §5, decision 49
Note 3 — wrong endpoint path in §3 Corrected to POST /voice/extract, with the body-parsers.ts coupling called out. No work item moves the route §3
Note 4 — availability endpoint contradiction §11 item 13 corrected: the endpoint exists, only the plan check is deferred §11
Note 5 — disjointness counts Verified independently: 42 leaf codes (the surveyor's 41 missed screw_retained, a multi-line implant( call) and 5 subcategories (the spec said 4, missing night_guard). The test now asserts against the live catalog, not a written count §5, §12

Referee relays: 0. Gate repairs: 0. Neither phase was reached.


All 19 work items above are built. Nothing committed or pushed — that is the orchestrator's job.

Backend

  • backend/prisma/schema.prismaPROSTHESIS_CATEGORY, PROSTHESIS_SUBCATEGORY added to CatalogEntityKind (additive, no data loss).
  • backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql — hand-written ALTER TYPE ... ADD VALUE migration, following the exact pattern of the repo's one precedent (20260718180000_lab_case_activity_task_assigned). Not applied — see "Not run" below.
  • backend/prisma/catalog-seed-data.tsPROSTHESIS_CATEGORY_LABELS (7) and PROSTHESIS_SUBCATEGORY_LABELS (5), worded from the frontend's existing category_*/sub_* message keys (decision 47/48), folded into CATALOG_TRANSLATIONS.
  • backend/src/common/fdi.tsARCH_TOOTH_UPPER/ARCH_TOOTH_LOWER sentinels, mirroring the frontend's prosthesisTree.ts convention so both sides speak the same jaw target.
  • backend/src/modules/voice/voice.types.tsProsthesisAssignment replaces ProsthesisIntent (default+overrides retired, decision 35); UnresolvedReason gains prosthesis_type_ambiguous, arch_not_spoken, code_not_valid_for_target, loses tooth_not_selected; UnresolvedItem.assignmentIndex added (decision 50).
  • backend/src/modules/voice/extraction.wire.ts — wire schema carries prosthesis: WireProsthesisAssignment[]; WireToothIntent.arch gains 'both'.
  • backend/src/modules/voice/extraction.resolver.ts — rewritten: resolveAssignmentTarget (tooth vs. jaw, mirrors the old tooth_missing_quadrant/new arch_not_spoken split), classifyTypeCode (leaf / category / subcategory, disjoint namespaces), and resolveProsthesisAssignment composing both plus the region-validity check (deferred for a mixed-region category — removable today, decision 49 — computed generically from the catalog's own chart regions rather than hardcoding the category name). resolveVoiceIntent forces treatmentType to prosthesis when any assignment resolves a target (decision 41). resolveProsthesis/ResolvedProsthesis retired outright.
  • backend/src/modules/voice/extraction.prompt.tsprosthesisTree() renders the catalog as CATEGORY → (SUBCATEGORY →) leaf from the data buildCatalog supplies; no catalog knowledge hardcoded in the prompt text itself.
  • backend/src/modules/voice/voice.providers.ts, voice.service.tsExtractionCatalog carries the full leaf shape plus prosthesisCategories/prosthesisSubcategories; buildCatalog no longer strips category/subcategory/chartRegion/stackGroup.
  • backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.tslistCategories()/listSubcategories(), resolved through CatalogLabelService like every other catalog label.
  • Jest: extraction.resolver.spec.ts rewritten around resolveProsthesisAssignment (stacks, jaw targets, both-jaws, leaf/category/subcategory classification, disjointness against the live PROSTHESIS_TYPES, region validity both ways, the removable deferral, assignment-index attribution); extraction.wire.spec.ts and openrouter.provider.spec.ts updated for the new wire shape. 209 backend tests pass (121 in modules/voice).

Frontend

  • frontend/src/lib/voice/audioFormat.tspickRecordingMimeType's final fallback is now '' instead of null (item 1's Safari fix — modern Safari's isTypeSupported can reject every preferred container yet still record when let choose).
  • frontend/src/components/ui/treatment/TreatmentWorkspace.tsxvoiceForEditor now also requires isMediaRecorderSupported(); applyVoiceResult rewritten: teeth vs. prosthesis are mutually exclusive per isLabDependentResult, the prosthesis stack is built through prosthesisTargetLines (which routes every leaf through applyLeafToJobs), and jaw targets write LabCaseToothProsthesisDraft rows keyed on the UA/LA sentinels — no separate arch-specific code path needed beyond what prosthesisTree.ts already provides.
  • frontend/src/types/voice.tsVoiceProsthesisAssignment[] replaces the byTooth map; VoiceUnresolvedItem.assignmentIndex.
  • frontend/src/components/treatment/voiceReviewRows.ts — rewritten: isLabDependentResult, merged-row voiceRowAvailability, withChosenArch/withChosenProsthesisLeaf (decision 50), prosthesisTargetLines (previews the stack via applyLeafToJobs, names refused jobs), joblessProsthesisTargets (decision 40 — named, struck through, never silently dropped or silently applied), prosthesisChartData (crown/root/arch tints for the merged row's chart).
  • frontend/src/components/ui/treatment/VoiceReviewSheet.tsx — merged "Teeth and prosthesis" row for a labDependent type; three independent candidate-chip kinds (tooth, jaw, leaf) each folding through their own voiceReviewRows helper; voiceProsthesisIncomplete warning removed (all-or-nothing retired).
  • i18n: en.json/fa.json/nl.jsonvoiceUnresolved.* updated for the new/retired reasons, voiceTeethAndProsthesis, voicePickJaw, voiceNoProsthesisHeard, voiceStackRefused added, voiceProsthesisIncomplete removed.
  • frontend/package.json, frontend/vitest.config.tsvitest@3.2.7 (pinned to a version whose peer @types/node range still includes the repo's ^20; vitest 4/5 require >=22), npm run testvitest run, alias-only config (@src/).
  • frontend/src/components/treatment/prosthesisTree.spec.ts, voiceReviewRows.spec.ts — 37 Vitest cases covering stack legality, applyLeafToJobs precedence, toothRegionColors, row availability, chip folding, the merged-row preview, and the jobless/pending distinction.
  • CLAUDE.md — Tests section rewritten; frontend command table gains npx vitest run.

Verification run

  • cd backend && npm test — 209/209 pass (121 in modules/voice).
  • cd backend && npm run build — clean (after npm install, which pulled in @sentry/nestjs that node_modules was missing — unrelated to this change, pre-existing on this checkout).
  • cd backend && npx prisma generate — succeeds against the updated schema (no DB needed); confirms CatalogEntityKind.PROSTHESIS_CATEGORY/PROSTHESIS_SUBCATEGORY compile everywhere they're used.
  • cd frontend && npx tsc --noEmit — clean.
  • cd frontend && npx vitest run — 37/37 pass.
  • cd frontend && npm run build — production build succeeds.
  • ESLint on every touched file — 0 errors, 0 new warnings (pre-existing warnings elsewhere in TreatmentWorkspace.tsx, unrelated to this change, left untouched).

Not run (environment limitation, not a design gap)

  • npm run prisma:migrate && npm run prisma:seed against a live Postgres — this sandbox has no running Docker daemon (docker info never came up after several minutes and open -a Docker did not launch it), so the migration was never applied to a database and the new CatalogTranslation rows were never seeded. The migration SQL and seed data are written and reviewed against the one existing precedent in this repo; run both before merging.
  • The full manual pass in §12 (Safari/iPad recording, live extraction against the real OpenRouter API, the specific stack/jaw/ambiguity scenarios) — needs a browser and a live backend, neither available in this session.

Deviations from spec / judgement calls made while implementing

  • A target with empty types[] still resolves as a target, with types: [] on its assignment — not excluded from the assignment's targets array. The sheet (frontend) treats types.length === 0 with no matching prosthesis_type_ambiguous unresolved item as "jobless, struck through" (decision 40), and the same empty-types-plus-ambiguous-item combination as "pending a material pick" instead. This keeps the wire contract simple (no extra field) at the cost of the frontend doing that one bit of inference from unresolved — documented in both extraction.resolver.ts and voiceReviewRows.ts.
  • The mixed-region deferral (decision 49) is computed generically from each category's actual leaf chart-regions (regions.size === 1 → validate immediately, else defer) rather than special-cased for removable by name. This also defers implant (which spans root and crown via screw_retained) — the spec's prose says "only removable today" as an observation about the current catalog, not an instruction to hardcode that name, and deferring a category no test forbids deferring is the safer default.
  • voice.dto.ts/voice.controller.ts needed no changes. The scout's item 6 ("validation for new schema shape") does not apply: the DTO validates the client's audio submission (audio/format/timeZone/durationMs/locale), which is unrelated to the LLM's structured output shape that changed. Confirmed by reading both files; not a silent skip.

2026-09-07 — Re-run reached Ship; stopped, and the challenge phase is incomplete

Recorded by hand: the record-stop:Ship agent failed on the session spend limit before it could write this.

Stop reason: no MR template found in dyolink — refusing to invent a description. Expected and flagged at preflight: .gitea/ holds only workflows/, and the remote is Gitea, not GitLab. A --dry run pushes nothing regardless.

Phase Result
Gap-check clear — the five gaps closed in 77e2ed4 were accepted
Scout 1 of 3 Explore sweeps returned; 2 ended without structured output
Implement done — all 19 work items, ~3,100 insertions across 25 files + 4 new files
Gate green — backend 16 suites / 209 tests, nest build 0, prisma validate ok; frontend Vitest 2 files / 37 tests, tsc --noEmit 0, next build 0
Refute incompletecorrectness refuted with 2 findings; regression-risk never ran (spend limit)
Ship stopped, no template. clerk also failed on the spend limit

Gate repairs: 0. Referee relays: 0. Nothing committed, nothing pushed.

Two confirmed findings — verified by hand, not taken on the critic's word

  1. VoiceReviewSheet.tsx:169 — a picked tooth chip is silently dropped. pickCandidate sets teeth: prev.teeth || available.teeth, but available is memoised from effective, which depends on chosenTeeth. Both setChosenTeeth and setSelection run in the same handler, so the updater closes over the pre-pick available, where available.teeth is false because result.teeth is empty. prev.teeth is false too, so it stays false permanently. The row then renders with the tooth on the chart and the box unticked, and Apply drops it. This is a variant of the original live-test failure — "ترمیم برای دندون دو".
  2. VoiceReviewSheet.tsx:213 + TreatmentWorkspace.tsx:2215 — decision 41 not implemented. The treatmentType row is a plain toggle('treatmentType') with no lock, and applyVoiceResult derives labDependent from result.treatmentType rather than the detail.treatmentType it writes. Untick the type row on a forced-prosthesis recording and a restoration detail is saved carrying prosthesis lab rows — the state decision 41 exists to make unreachable.

Lint

Backend touched files: 0 errors, 0 warnings. Frontend touched files: 10 warnings, all pre-existing in TreatmentWorkspace.tsx. The repo-wide backend baseline (1,288 errors, 1,107 prettier-fixable) is untouched by this diff.

Next steps / open questions

  • The diff is unreviewed on the regression-risk lens. Green gate plus one refuting lens is not the design's bar; the script continued only because the threshold counts refusals and the second critic errored rather than refused.
  • Fix the two findings, then resume from Refute so both lenses grade the same diff.
  • The Ship phase cannot pass in this repo until there is an MR template, or until the MR is opened by hand. Opening one is gated regardless.