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>
31 KiB
type, status, repos
| type | status | repos | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| task-progress | active |
|
PROGRESS — Voice Treatment Entry
Spec: spec.md
Adapting the merged voice feature to the overhauled prosthesis model (spec §5–§7,
decisions 34–46). 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.ts — prosthesis: WireProsthesisAssignment[] |
| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | dyolink |
✅ | §5, §6, extraction.resolver.ts — resolveProsthesisAssignment, 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. resolveVoiceIntent — hasResolvedAssignment |
| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | dyolink |
✅ | §5, extraction.prompt.ts — prosthesisTree() renders CATEGORY/SUBCATEGORY/leaf from data, no hardcoded catalog knowledge |
| 10 | Backend Jest suites for items 6–8, 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.ts — VoiceProsthesisAssignment[], 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 34–46. 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, andpartial_denturealready carrieschartRegion: 'crown'. - Stack rules stay frontend-only. The backend's dispatch check knows nothing about
stacking; porting
canStackLeafwould 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
persistDraftprunes 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
CatalogEntityKindvalues and seeded translations, rather than bare untranslated codes in the prompt onfa. - 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 --drywith agapNote, so the surveyor re-runs instead of replaying its cachedgaps_foundverdict. - 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-entryandbackup/pre-rebase-voiceare 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
Blocking gap 1 — missing_constraint (dyolink)
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.
buildCatalogalready receivescategory,subcategory,chartRegionandstackGroupfromProsthesisCatalogServiceand 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
CatalogLabelServicein the actor's locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms ofprosthesisandmonolithic_zirconiarather than being asked to translate. Catalog entities store a stablecodeand 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_quadrantwith 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
chartRegionof the assignment's codes.
partial_denture— aremovablecode that is nonetheless assigned per tooth — already ships withchartRegion: 'crown'incatalog-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 API— resolved 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 47–48 |
| 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.
2026-09-07 — Implementation (dyolink, extraction contract + resolvers + review sheet)
All 19 work items above are built. Nothing committed or pushed — that is the orchestrator's job.
Backend
backend/prisma/schema.prisma—PROSTHESIS_CATEGORY,PROSTHESIS_SUBCATEGORYadded toCatalogEntityKind(additive, no data loss).backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql— hand-writtenALTER TYPE ... ADD VALUEmigration, 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.ts—PROSTHESIS_CATEGORY_LABELS(7) andPROSTHESIS_SUBCATEGORY_LABELS(5), worded from the frontend's existingcategory_*/sub_*message keys (decision 47/48), folded intoCATALOG_TRANSLATIONS.backend/src/common/fdi.ts—ARCH_TOOTH_UPPER/ARCH_TOOTH_LOWERsentinels, mirroring the frontend'sprosthesisTree.tsconvention so both sides speak the same jaw target.backend/src/modules/voice/voice.types.ts—ProsthesisAssignmentreplacesProsthesisIntent(default+overrides retired, decision 35);UnresolvedReasongainsprosthesis_type_ambiguous,arch_not_spoken,code_not_valid_for_target, losestooth_not_selected;UnresolvedItem.assignmentIndexadded (decision 50).backend/src/modules/voice/extraction.wire.ts— wire schema carriesprosthesis: WireProsthesisAssignment[];WireToothIntent.archgains'both'.backend/src/modules/voice/extraction.resolver.ts— rewritten:resolveAssignmentTarget(tooth vs. jaw, mirrors the oldtooth_missing_quadrant/newarch_not_spokensplit),classifyTypeCode(leaf / category / subcategory, disjoint namespaces), andresolveProsthesisAssignmentcomposing both plus the region-validity check (deferred for a mixed-region category —removabletoday, decision 49 — computed generically from the catalog's own chart regions rather than hardcoding the category name).resolveVoiceIntentforcestreatmentTypetoprosthesiswhen any assignment resolves a target (decision 41).resolveProsthesis/ResolvedProsthesisretired outright.backend/src/modules/voice/extraction.prompt.ts—prosthesisTree()renders the catalog as CATEGORY → (SUBCATEGORY →) leaf from the databuildCatalogsupplies; no catalog knowledge hardcoded in the prompt text itself.backend/src/modules/voice/voice.providers.ts,voice.service.ts—ExtractionCatalogcarries the full leaf shape plusprosthesisCategories/prosthesisSubcategories;buildCatalogno longer stripscategory/subcategory/chartRegion/stackGroup.backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts—listCategories()/listSubcategories(), resolved throughCatalogLabelServicelike every other catalog label.- Jest:
extraction.resolver.spec.tsrewritten aroundresolveProsthesisAssignment(stacks, jaw targets, both-jaws, leaf/category/subcategory classification, disjointness against the livePROSTHESIS_TYPES, region validity both ways, theremovabledeferral, assignment-index attribution);extraction.wire.spec.tsandopenrouter.provider.spec.tsupdated for the new wire shape. 209 backend tests pass (121 inmodules/voice).
Frontend
frontend/src/lib/voice/audioFormat.ts—pickRecordingMimeType's final fallback is now''instead ofnull(item 1's Safari fix — modern Safari'sisTypeSupportedcan reject every preferred container yet still record when let choose).frontend/src/components/ui/treatment/TreatmentWorkspace.tsx—voiceForEditornow also requiresisMediaRecorderSupported();applyVoiceResultrewritten: teeth vs. prosthesis are mutually exclusive perisLabDependentResult, the prosthesis stack is built throughprosthesisTargetLines(which routes every leaf throughapplyLeafToJobs), and jaw targets writeLabCaseToothProsthesisDraftrows keyed on theUA/LAsentinels — no separate arch-specific code path needed beyond whatprosthesisTree.tsalready provides.frontend/src/types/voice.ts—VoiceProsthesisAssignment[]replaces the byTooth map;VoiceUnresolvedItem.assignmentIndex.frontend/src/components/treatment/voiceReviewRows.ts— rewritten:isLabDependentResult, merged-rowvoiceRowAvailability,withChosenArch/withChosenProsthesisLeaf(decision 50),prosthesisTargetLines(previews the stack viaapplyLeafToJobs, 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 ownvoiceReviewRowshelper;voiceProsthesisIncompletewarning removed (all-or-nothing retired).- i18n:
en.json/fa.json/nl.json—voiceUnresolved.*updated for the new/retired reasons,voiceTeethAndProsthesis,voicePickJaw,voiceNoProsthesisHeard,voiceStackRefusedadded,voiceProsthesisIncompleteremoved. frontend/package.json,frontend/vitest.config.ts—vitest@3.2.7(pinned to a version whose peer@types/noderange still includes the repo's^20; vitest 4/5 require>=22),npm run test→vitest run, alias-only config (@→src/).frontend/src/components/treatment/prosthesisTree.spec.ts,voiceReviewRows.spec.ts— 37 Vitest cases covering stack legality,applyLeafToJobsprecedence,toothRegionColors, row availability, chip folding, the merged-row preview, and the jobless/pending distinction.CLAUDE.md— Tests section rewritten; frontend command table gainsnpx vitest run.
Verification run
cd backend && npm test— 209/209 pass (121 inmodules/voice).cd backend && npm run build— clean (afternpm install, which pulled in@sentry/nestjsthatnode_moduleswas missing — unrelated to this change, pre-existing on this checkout).cd backend && npx prisma generate— succeeds against the updated schema (no DB needed); confirmsCatalogEntityKind.PROSTHESIS_CATEGORY/PROSTHESIS_SUBCATEGORYcompile 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:seedagainst a live Postgres — this sandbox has no running Docker daemon (docker infonever came up after several minutes andopen -a Dockerdid not launch it), so the migration was never applied to a database and the newCatalogTranslationrows 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, withtypes: []on its assignment — not excluded from the assignment'stargetsarray. The sheet (frontend) treatstypes.length === 0with no matchingprosthesis_type_ambiguousunresolved 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 fromunresolved— documented in bothextraction.resolver.tsandvoiceReviewRows.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 forremovableby name. This also defersimplant(which spansrootandcrownviascrew_retained) — the spec's prose says "onlyremovabletoday" 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.tsneeded 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 | incomplete — correctness 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
VoiceReviewSheet.tsx:169— a picked tooth chip is silently dropped.pickCandidatesetsteeth: prev.teeth || available.teeth, butavailableis memoised fromeffective, which depends onchosenTeeth. BothsetChosenTeethandsetSelectionrun in the same handler, so the updater closes over the pre-pickavailable, whereavailable.teethisfalsebecauseresult.teethis empty.prev.teethis 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 — "ترمیم برای دندون دو".VoiceReviewSheet.tsx:213+TreatmentWorkspace.tsx:2215— decision 41 not implemented. The treatmentType row is a plaintoggle('treatmentType')with no lock, andapplyVoiceResultderiveslabDependentfromresult.treatmentTyperather than thedetail.treatmentTypeit writes. Untick the type row on a forced-prosthesis recording and arestorationdetail 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-risklens. 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.