# portfolio-optimiser ## Kontekst Generisk, åpent Python-rammeverk på **Microsoft Agent Framework (MAF)** som finner kostnadsbesparelser INNI hvert prosjekt i en portefølje av uavhengige prosjekter. Multi-agent samarbeid genererer kandidat-tiltak; en obligatorisk deterministisk validator avgjør verdiene; fageksperter vurderer via HITL, og systemet lærer av dommene. Publiseres på Forgejo for alle som har MAF. Bakgrunn og beslutninger: [research](docs/research/2026-06-23-prior-art-platform.md) (§15 implementeringsregister), [plan](docs/plan/2026-06-23-incremental-plan.md). Løpende state: `STATE.md` (local-only). ## Stack Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To backend-profiler: Azure/Foundry (full) + lokal (fallback). ## Konvensjoner - Type hints overalt (`mypy` der mulig). Pydantic for validering/IR. - `ruff` for lint+format. `pytest` for test. - Modell-valg som konfig (modell-map rolle→Foundry-deployment), ikke spredt i kode. - Metode kodifiseres som **Agent Skill** (`agentskills.io`: `SKILL.md` + `scripts/` + `references/`). - Datatilgang: in-process `FunctionTool` er default-sømmen i kjørestien; **MCP er extension point**, demonstrert via `build_mcp_server` (`datasource.py`) men ikke wiret inn i kjørestien. Data-source-konfig JSON-Schema-validert, fail-fast. - `shared/` er en **git subtree** av [`portfolio-optimiser-commons`](https://git.fromaitochitta.com/ktg/portfolio-optimiser-commons) (source of truth, R1 realisert 2026-07-03; remote `commons`). Synk er **pull-only**: endringer committes i commons og hentes med `git subtree pull --prefix=shared commons main --squash`. ALDRI `git subtree push` fra konsument — re-split lekker hele konsument-historikken inn i commons (observert + opprydd 2026-07-03). Se `shared/README.md`. ## Kommandoer - Sync: `uv sync` - Test: `uv run pytest` - Lint: `uv run ruff check .` + `uv run ruff format .` - Type: `uv run mypy src` ## Arbeidsflyt (invarianter) - **Rent teknisk rammeverk:** deployer eier DPIA/ROS/behandlingsformål. Bygg IKKE compliance-funksjoner — kun tekniske forutsetninger (lokal-only, provenance, ingen stille egress) + disclaimer. - **90%-prinsipp:** bygg den generiske kjernen + tydelige extension points; jakt IKKE de siste 10 %. - **Deterministisk validator er obligatorisk og blokkerende** — aldri valgfri plugin. - **Framework-nøytral kontekst-søm:** OKF-bundle-navigasjon (`okf.py`) og den delte `shared/`-kjernen er ren stdlib — null `agent_framework`/`mcp`-import, så samme bundles konsumeres uendret av begge stacker (D7-portabel). Håndhevet av `tests/test_okf.py::test_okf_is_maf_free`; importér aldri MAF inn i kontekst-laget. - **OKF-navigert bundle-kontekst (ikke stuffing):** på bundle-stien bygges agent-lese-konteksten ved å NAVIGERE bundelen (`okf.bundle_context`: index + frontmatter + cross-links, progressiv disclosure) — aldri keyword-chunk-stuffing (målbilde §2/§4). `type: verdict`-laget ekskluderes fra denne konteksten: tidligere dommer når hypotese-prompten KUN via den gatede ExpeL-folden. Load-bearing: `test_bundle_context_excludes_verdict_layer` + empty-store-kontrollen i `test_step1_expel_loadbearing.py` (realiseringssignalet lekker aldri inn via kontekst). - **Navigasjons-kontrakten er hierarkisk, og escape — ikke dybde — er forbudt** (`method-spec` §3 Steg 1): `navigate_bundle` følger cross-links REKURSIVT, dybde-først i først-sett-rekkefølge; ledende `/` betyr **bundle-rot** (aldri filsystem-absolutt), alt annet er relativt til den LENKENDE filas katalog; dedup skjer på **resolvert** sti (så `./a.md` == `a.md`, og sykler termineres). `safe_resolve` er den ENESTE inn-/ut-av-bundle-testen (fail-closed) — den erstattet den pensjonerte «separator = utenfor bundelen»-heuristikken, som forvekslet dybde med escape. Manglende `index.md` er feil KUN i bundle-rota (navigasjon følger lenker, aldri katalog-enumerering). Rendering er FLAT uansett dybde; nestede `index.md` er navigasjon, ikke innhold. Gaten er commons-eide nav-goldens (`shared/examples/nav-golden-*/expected-read-context.md`, byte-nivå fasit): `test_nav_golden_hierarchy_*` (positiv) + `test_nav_golden_escape_*` (negativ — en gate som bare kan bli grønn beviser ingenting). - **Kuraterte skrivere kan ikke forfalske ingest-stempelet** (`ingest-spec` §3): `write_concept_file` er repoets ene authoring-primitiv som materialiserer en konseptfil fra CALLER-oppgitt frontmatter, og avviser derfor det KOMPLETTE eierskaps-stempelet (`generated: true` + `ingest_manifest`) med `IngestStampError` — mens hver halvdel alene er lovlig (kuratert innhold kan bære ett provenance-felt). Validering, ALDRI reparasjon: ingenting skrives. Uten dette kunne en kuratert fil bli stille slettet av en senere re-materialisering, som fjerner nøyaktig det som bærer stempelet. - **`IngestError` må overleve anyio-task-gruppene (kø-(x), 2026-08-03):** `stdio_client` og `ClientSession` er hver sin task group, og anyio pakker ALT som forlater en av dem i en `BaseExceptionGroup`. Derfor nådde `stdio_call_tool`s egne feil (`mcp_tool_error`, `mcp_non_text_content`) kalleren som en exception group — aldri som `IngestError`, som er typen hele Door A fanger og switcher på via `code`. `_unwrap_ingest_error` pakker ut og re-raiser den eide feilen; alt annet re-raises URØRT (innsnevring, aldri blanket re-raise). Duck-typet på `.exceptions`, fordi `except*`/`ExceptionGroup` er 3.11+ og repoet er `>=3.10`. **Ingen canned-tool-test kunne fanget dette** — de går aldri inn i en task group; defekten dukket opp første gang koden faktisk ble kjørt. En MCP-server på ingest-stien må eksponere en **null-argument-tool** (URL-en bærer begge koordinatene, tool kalles med `{}`), så `datasource.build_mcp_server` kan IKKE serve den — `retrieve_cost_docs(query)` har et påkrevd argument og returnerer et error-result. De to er separate sømmer med vilje. Load-bearing MÅLT (`tests/test_ingest_golden_mcp.py`), fem mutasjoner alle røde: detach unwrappingen · detach `initialize()` · gjør feilkoden generisk · detach `isError`-grenen · endre ett byte av bodyen. - **Stoppkriterier + budsjett-tak påkrevd ved oppstart** (fail-fast, aldri ubegrenset loop). - **Group Chat maker-checker** som debatt-default (IKKE Magentic, som er eksperimentell). - **To falsifiserere, samme kandidat (Steg 3/4, målbilde §2/§6):** den deterministiske validatoren gater *tallene* (blokkerende), checkeren gater *resonnementet*. Checkeren avslutter turen med en `VERDICT: APPROVE` / `VERDICT: REJECT — `-linje; et eksplisitt avslag blokkerer et ellers validert forslag (`run_project` overflater begge debatt-deltakere via `output_from=agents` og overstyrer utfallet til en checker-kilde-`Rejection`). Gaten er opt-in-reject (fail-open ved manglende markør), og `provenance.validator_decision` forblir ærlig — den speiler KUN validatoren, aldri checkeren (de to falsifisererne blandes aldri). Load-bearing: `tests/test_checker_gate_loadbearing.py` blir rød ved BEGGE detach-punkt (revert `output_from`, eller fjern override). Checkeren «må faktisk gate, ELLER vi slutter å kalle det maker-checker». - **Informert forbedring, bundet (Steg 5, målbilde §5/§7):** `generate_via_llm`s ytre `max_attempts`-løkke er ikke lenger blind — validatorens *forrige* `Rejection.reason` mates inn i neste forsøks prompt (`_build_messages(prior_rejection=...)`), så proposeren korrigerer i stedet for å gjenta. Kun den mest-nylige falsifiseringen (`last`, ikke akkumulert), kun grunnen (aldri forrige proposal-JSON), under EKSISTERENDE tak (`meter.tick_round` + `max_attempts` — ingen ny løkke; «forbedre til god nok» uten tak er forbudt). Eneste *per-forsøk*-falsifiserer her er validatoren; å seede generering med checker-*kritikken* er run-nivå og separat scoped (IKKE bygget her) — så koden påstår ikke mer enn den gjør. Load-bearing: `tests/test_step5_refine_loadbearing.py` blir rød når reason-injeksjonen detaches (utfallet flipper aldri + reason-verbatim-asserten faller); kontrollen beviser at løkka forblir bundet. - **Lang/async fil-løkke (Steg 7, målbilde §3/§7):** `run_project(verdict_dir=...)` er den lange tilbakemeldings-tidsskalaen — en ekspert/persona dropper en verdict-fil (vanlig JSON, RAW-laget per §10 R2) i en inbox-mappe ETTER en kjøring, og en separat, senere kjøring `load_verdicts_from_dir` → `store.add` **merger** den inn FØR Steg-1-folden (ingen endring i folden), så dommen når neste hypotese. **Rolledeling (§3, ufravikelig):** systemet LESER mappa; eksperten/personaen SKRIVER den — `run_project` persisterer ALDRI sin egen fangede dom tilbake (det er outbox/Steg 8). Merge, aldri erstatt (`run_portfolio`-tråding intakt); tolerant last (manglende mappe / fremmede / halvskrevne filer hoppes over, ikke raises — RAW-lag, kontrast `okf.load_ir_projection`s fail-fast); `id` leses verbatim, re-mintes aldri. `write_verdict` er den offentlige authoring-primitiven (persona/test + framtidig Steg 8), men wires IKKE inn i `run_project`. Load-bearing: `tests/test_step7_async_loop_loadbearing.py` — en dom droppet etter Run A MÅ nå Run B's prompt (Run B bruker FERSK store → overføringen er fil-løkka, ikke in-memory-carryover); tom-inbox-kontroll beviser kausalitet. Markør = realiseringsverdi som finnes ingen steder i bundelen (ikke frøets 0.82). - **Gated wiki-promotering (Steg 8, målbilde §3/§6/§7):** når en ekspert/persona GODKJENNER et utfall, løfter `verdicts.promote_verdict` det fra RAW output-laget inn i kontekst-laget (OKF-bundelen) som en `type: verdict`-konseptfil, navigerbar av neste kjørings `seed_store_from_bundle`. **Gaten er fail-closed:** en ikke-godkjent dom (`decision ∉ {approved, approved_with_adjustment}`) raiser `PromotionRefused` og skriver/linker INGENTING — kun menneske/persona-godkjent kunnskap når wikien, aldri rå agent-output (selv-forurensning). Provenance-stemplet (hvem/eksperiment/når; `timestamp` er påkrevd keyword, ingen wall-clock-default → deterministisk). **OKF-skriveren bor i `okf.py` og er ren stdlib** (D7-portabel, MAF-fri — håndhevet av `test_okf_is_maf_free`); navigasjon følger KUN index-cross-links, så `promote_verdict` linker filen i `index.md` via en NØYTRAL label (ellers lekker signalet inn i `index_summary`→`bundle_context` utenom gaten). **R4 = valgfri+gated:** `promote_verdict` er en offentlig opt-in-primitiv, wires IKKE inn i `run_project` (speiler `write_verdict` — systemet leser; gaten/personaen promoterer). Ærlighets-grenser: promotert fil er MINIMAL (læringssignal kun som `description`/body-prosa, reproduserer ikke seedens strukturerte `realization_rate` o.l.); id = læringsnøkkel, så to godkjenninger om samme kandidat deler filnavn (last-write-wins, som `write_verdict`) — wikien vokser én kuratert fil per distinkt kandidat, ikke per dom-hendelse. Load-bearing-trio (`tests/test_step8_promotion_loadbearing.py`): gaten avviser ikke-godkjent dom (RØD uten gate); godkjent dom er navigerbar (RØD når `link_in_index` detaches); promotert signal holdes ute av `bundle_context` (RØD når en beskrivende index-label lekker det inn). Index-RMW er ikke-atomisk (enprosess-MVP). - **En dom nøkles på SIN kandidat, ikke bundelens ene IR-projeksjon (S3.2):** `seed_store_from_bundle` leser hver `type: verdict`-fils EGNE strukturelle felt fra frontmatter (`affected_codes` / `measure_type` / `claimed_saving_nok`); mangler de, faller nøklingen tilbake til `bundle_candidate_features` — så hver pre-S3.2-seed står uendret (fallbacken er BÆRENDE: fjernes den, brekker step1-suiten ved collection). Uten dette kollapset en bundle med dommer om flere kandidater dem på ÉN nøkkel, og en dom om kandidat B scoret perfekt strukturell match mot kandidat As query. **ALLE TRE felt eller ingen:** en delvis erklæring raiser `VerdictFrontmatterError` i stedet for å slås sammen med bundle-kandidaten — sammenslåingen ville myntet en nøkkel som tilhører INGEN av kandidatene. Validering, ALDRI reparasjon (speiler `write_concept_file`); den tolerante hopp-over-regelen hører til RAW-innboks-laget. `claimed_saving_nok` parses med `json.loads` — SAMME literal-regel IR-projeksjonen gikk gjennom — og skrives tilbake som `str()` av råverdien, fordi `_mint_id` hasher den (`30000` ≠ `30000.0`; en normaliserende skriver ville splittet én kandidats signal på to id-er). `promote_verdict` skriver de tre feltene, så en promotert dom ikke utgir seg for målbundelens kandidat; nøkkelen er signal-fri, så Steg-8s no-leak-egenskap står. **Semantikken er bestemt HER** — commons' seeding-regel (`method-spec` §3 Steg 1) hadde ikke kommet; D7-speiling forblir ÅPEN. Load-bearing MÅLT (`tests/test_step32_multicandidate_loadbearing.py` + `test_step8_promotion_loadbearing.py`), fem mutasjoner alle røde: detach per-dom-nøklingen · detach feltene `promote_verdict` skriver · gjør en delvis/uparsebar nøkkel tolerant · normaliser magnituden ved skriving · fjern fallbacken (kontroll). - **Den deterministiske gaten er FORANKRET i prosjektets faktiske kostbaseline (S4.0, F3/F8):** før S4.0 resonnerte HVER stage kun om tall forslaget selv oppga, så en internt konsistent hallusinasjon klarerte hele gaten. `validate_proposal(..., baseline=...)` avstemmer nå hvert `affected_item` mot `CostBaseline` (`ir.py`) i en **stage 0 — FØR løseren** (billigst, og den eneste som skiller en oppdiktet linje fra en ekte; å bruke en CBC-solve på tall som ikke tilhører prosjektet er arbeid på et krav som uansett ikke kan valideres). To uavhengige avvisninger: ukjent kostkode, og ekte kode med `quantity`/`unit_cost` utenfor `tolerance` (default 5 %, **konfig**) relativt til BASELINE-verdien. Validering, ALDRI reparasjon — forslaget avvises, aldri stilltiende korrigert til baselinen. **Argumentet er VALGFRITT** (`None` = pre-S4.0-oppførsel), men begge run-stier SETTER det: road-stien fra `project.cost_items` (alltid — estimatet ER prosjektet), bundle-stien KUN når bundelen shipper `cost-baseline.json` (`load_optional_cost_baseline`) — en pre-amendment-bundle er legitimt uforankret, og det er dét som holder commons-goldenene byte-identiske. **Toleransen stopper ved fravær:** en baseline som FINNES men er malformed raiser på BEGGE loaderne (å lese korrupt som «ingen baseline» ville gitt en uforankret gate i forkledning — samme resonnement som `read_spend`). **F8:** metode-cap-en slås opp i `METHOD_CAPS`-registeret (måletype→brøk, injiserbart), ikke mot `energy_efficiency`-literalen — en andre metode er nå data, ikke en redigering av validatoren. Format- og toleranse-semantikken er bestemt LOKALT (commons-amendmentet D-A pkt. 2 kom aldri, som i S3.2); **D7-speiling ÅPEN.** Load-bearing MÅLT (`tests/test_s40_cost_baseline_loadbearing.py`), seks mutasjoner alle røde: detach avstemmings-stagen · detach magnitude-toleransen · detach road-wiringen · detach bundle-wiringen · ignorer det injiserte cap-registeret · gjør den valgfrie loaderen tolerant. - **Globalt token-tak håndheves FØR kall, aldri bare etterpå (S3.4, F10):** `PortfolioBudget` + `PortfolioMeter` er ÉN ledger over hele porteføljepasset (og — seedet av `read_spend` — på tvers av pass), mens per-run `Budget`/`TokenMeter` er uendret. Taket har tre tenner, med hver sin jobb: (1) **oppstartsnekt** — en rest som ikke kan finansiere én kjøring raiser `BudgetRefused` FØR noe lastes (et pass som har råd til null prosjekter er en caller-feil, ikke et resultat); (2) **wave-assembly** — et prosjekt som ikke kan finansieres blir ALDRI STARTET, og passet stopper strukturert (`budget_stop` + `stopped_early`, fullførte runs bevart). Aldri-startet er poenget: et ufinansiert prosjekt som bare avbrytes har allerede kostet kall. Fordi hele bølgen sjekkes mot SAMME før-bølge-rest, **reserverer** admission hver members krav — ellers overforplikter en bølge av k taket med inntil k kjøringer; (3) **pre-call-guard** i `BudgetMiddleware` — et kall resten ikke kan betale for NEKTES i stedet for å gjøres (post-charge-sjekken består: ekte usage kjennes først etterpå, så guarden stopper NESTE kall, aldri det som er i lufta). `budget_stop` er et EGET felt, aldri `stop_reason`: et mål-stopp er suksess, dette er ressurs-utmattelse — å slå dem sammen ville gjort «vi stoppet» uleselig. `record`/`check` er SPLITTET i `PortfolioMeter` fordi tokens leverandøren allerede har fakturert må nå ledgeren selv når samme charge bryter run-taket. Spend-fila er vår EGEN regnskapstilstand: `read_spend` raiser på korrupt innhold (kontrast det tolerante RAW-inbox-laget — å lese korrupt som null ville gitt tilbake et allerede brukt budsjett), og `write_spend` tar et PÅKREVD `stamp` uten wall-clock-default (byte-determinisme, speiler `promote_verdict`). `portfolio_meter` og `meter_factory` er gjensidig utelukkende — en factory-meter er ubundet, så begge sammen ville gitt et pass som SER capped ut uten å være det. Load-bearing MÅLT (`tests/test_portfolio_budget_loadbearing.py` + `tests/test_budget.py`), seks mutasjoner alle røde: detach wave-sjekken · detach pre-call-guarden · detach bølge-reservasjonen · sjekk run-taket før global kreditering · detach oppstartsnekten · gjør `read_spend` tolerant. - **`BudgetExceeded`s tre felt beskriver ÉN og samme ledger (kø-(y)):** `kind`/`limit`/`observed` er ett strukturert stopp-event, og `observed` var det udefenderte tredje feltet — MÅLT: fire av fem raise-steder (`TokenMeter.charge`, `tick_round`, og BEGGE armene i `exhausted()`) kunne rapportere hvilken som helst verdi uten at 621 tester merket det; bare `PortfolioMeter.check` var dekket. **Fellen som skjulte det:** `spikes/_harness.py:41` har sin EGEN kopi av `BudgetExceeded`/`TokenMeter`, så `spikes/test_harness.py`s `observed`-assert dekker IKKE den shippede modulen — produksjonens `tick_round` hadde null direkte test. `exhausted()` er det ENESTE stedet som VELGER ledger (S3.4-guarden), så en refusal som sier `portfolio_tokens` mens den rapporterer runets eget forbruk ville villedet enhver leser. **Testene bygges med `observed != limit`**: ved nøyaktig-uttømt sammenfaller de to, og en test skrevet der kan ikke skille dem — den ville passert på en implementasjon som ekkoet taket tilbake som forbruket. Ingen defekt funnet i verdiene selv (kontrast (x)/(p)): trippelen var koherent alle fem steder, gapet var rent dekning. Load-bearing MÅLT (`tests/test_budget.py`), ni mutasjoner alle røde: fem `observed`-mutasjoner (inkl. kontrollen) + fire ekko-mutasjoner. - **Pengetall kvantiseres i ÉN orden, fra ÉN kilde (kø-(p)):** `ledger.to_ore` er rammeverkets ENE NOK→øre-konvertering (Decimal, ROUND_HALF_UP), og den brukes **per pengebeløp — deretter summeres HELTALL**. `run.py` importerer den; aldri en egen kopi (to kopier av en penge-konvertering drifter, og en driftet kopi setter de to sidene av en mål-sammenligning på hver sin skala — S4.0s `REPLIES`-presedens). Før dette summerte `run.py`s mål-baseline `Project.total_cost`-FLOATS og kvantiserte totalen ÉN gang, mens `SavingsLedger` summerte per-kandidat-heltall — og de to ordenene møttes i nøyaktig ETT punkt: `_goal_limit_if_reached`, der et prosent-mål avgjør om et porteføljepass stopper tidlig. **Målt divergens:** tre linjer à `60000.005` NOK er `18000003` øre kvantisert først, men `18000001` summert først (float-drift til `180000.01499999998`) — nok til å vippe et mål. **Kvantiser-først valgt fordi hver `CostItem` ER et beløp** (S4.0 gjorde per-linje `quantity`/`unit_cost` til validatorens grunnsannhet), og fordi heltallsaddisjon er assosiativ → rekkefølge-uavhengig under D-D-bølgemodellen, som float-folden ikke er. **To kallsteder, ikke ett:** portefølje- og per-prosjekt-baselinen er separate, og en fiks på bare den ene OVERLEVDE hele suiten (målt). Load-bearing MÅLT (`tests/test_money_quantization_loadbearing.py`), fem mutasjoner alle røde: detach portefølje-baselinen · detach per-prosjekt-baselinen · gjeninnfør en privat kopi i `run.py` · endre avrundingsmodus · la `realize` gå utenom `to_ore`. **Ærlighets-grense:** `sum_claimed_saving_nok` (`run.py:_aggregate`) er BEVISST urørt — et float-NOK-rapportfelt som aldri kvantiseres og aldri sammenlignes mot ledgeren, altså utenfor ordens-defekten. - **Kostnadsdisiplin:** utvikle primært på lokal profil (gratis); Foundry/Azure (privat tenant finnes) kun til målrettet, minimal verifisering; billigste modeller + små syntetiske data + harde token-tak. Ingen tunge test-kjøringer. - **Offline simulering = primært metode-bevis (kostnadsdrevet, erstatter §11.8):** operatøren kjører IKKE MAF mot ekte modell (verken Azure/Foundry eller Ollama — API for begge repoene er for kostbart privat). `portfolio_optimiser.simulation` driver `run_project` med en SKRIPTET syntetisk chat-klient (`ScriptedChatClient` på `OpenAIChatCompletionClient` — IKKE bare `BaseChatClient`, ellers no-op-er `BudgetMiddleware`) over to kjøringer adskilt av en promotering, og viser at læringssløyfa lukkes: Run A's godkjente persona-dom (markør fraværende fra bundelen) → `promote_verdict` → re-seed → Run B's hypotese-prompt bærer markøren (tom-wiki-kontroll på Run A beviser kausalitet). **Ærlighet (§1, ufravikelig):** beviser plumbing + deterministisk ryggrad + at dataflyten lukkes — IKKE at en levende LLM ville produsert forslaget/dommen (skriptede stand-ins). Den genuine modell-atferd-sammenligningen lever på Claude-SDK-siden (minimal API-kjøring). Skriptet klient = MAF-side stillas, IKKE delt (`shared/` forblir framework-nøytralt). Kjøres `uv run python -m portfolio_optimiser.simulation`. Load-bearing: `tests/test_simulation_loadbearing.py` blir RØD når promoteringen detaches. - **Delt ekspert-persona som Agent Skill (§8, framework-nøytral):** ekspert-reviewer-personaen bor i `shared/skills/expert-reviewer/` (`SKILL.md` + `references/example-verdict.json`) og er den ENE delte artefakten begge stacker instansierer reviewer-en fra. `shared/` forblir REN DATA — MAF-siden leser den via `portfolio_optimiser.persona.load_persona_example` (call-time, fail-fast), Claude-SDK- søskenet med sin egen loader mot samme JSON. Dette AV-STUBBER simuleringen: persona-dommen (decision + rationale + sporet markør) hentes nå fra artefaktet ved call-time, ikke en inline-literal — så personaen er genuint konsumert og kan ikke råtne stille. **Decision er binær** (`approved`/`rejected` — `FeedbackContract` run-stien tar; `approved_with_adjustment` avvises der, bor kun i bundle-seedens frontmatter + promoterings-gaten); realiseringskorreksjonen lever i rationale-prosaen, ikke et tredje enum. SKILL.md-prosaen nevner ALDRI en konkret framework (maf-guarden er import-formet). Load-bearing- trio (`tests/test_persona_skill_loadbearing.py`): struktur+framework-nøytralitet (RØD på framework- import), eksempelet er gyldig pipeline-input inkl. `FeedbackContract` (RØD på skjema-/kontrakt-drift, på en throwaway-kopi — aldri den git-tracked fixturen), og sim-ens markør følger artefakt-fila (RØD i det øyeblikk personaen re-inlines). - **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet. - Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase. ## Communication patterns When linking to local files in responses, use named markdown links — `[Human-friendly name](file:///absolute/path)`, never bare `file://` URLs or autolinks ``, always absolute paths (never `~/` or relative), one bullet per file when there are several. (Bare `file://` URLs render only the first as clickable across multiple lines; named links stay independently clickable.)