diff --git a/docs/contract-assert-policy.md b/docs/contract-assert-policy.md new file mode 100644 index 00000000..f80a0f24 --- /dev/null +++ b/docs/contract-assert-policy.md @@ -0,0 +1,35 @@ +# Contract assert policy + +**What this is:** a machine-checkable **lint** for mupot machine contracts +(JSON + TS module + vitest suite source text). It is **not** enforcement — +a motivated builder can step around it in one or two lines (omit a mirror +declaration with its assertion, comment out an assertion token, or alias a +lifecycle literal). Do not describe it as a production gate. + +**Owner of `src/contracts/contract-assert-policy.ts`:** this branch +(`cursor/contract-assert-policy`). Consumer lanes (owner-experience, +brain-learning-ranker, …) must copy the blob verbatim and must not diverge. +Divergent edits land without merge conflicts and silently weaken other +consumers — pick this owner before a third adopter. + +## Rules + +1. **Lifecycle fields** (`status`, `mode`, `phase`): the policy spec must declare `allowedValues` and `whoMayFlip`. Contract JSON values must be members of `allowedValues`. Tests must **not** use `expect(contract.status).toBe('literal')` — assert membership in the declared set instead. `whoMayFlip` length-only checks are decorative unless the consumer also drift-locks the contents against a design doc / frozen TS constant. + +2. **JSON↔TS mirrors**: mirrored JSON paths must match exported TS constants. Tests must assert `expect(contract.path).toEqual([...TS_CONSTANT])` or `.toBe(TS_CONSTANT)` — never retype literals (`['talk', 'know']`) in the test. Missing TS export is a finding. Finding code `json_ts_mirror_assertion_token_absent` means the suite **source text** lacks the assertion token — commented-out / `it.skip` / dead-branch text still counts. Rename honesty: this is token presence, not "assertion runs". + +## Usage + +```typescript +import { runContractAssertPolicy } from '../src/contracts/contract-assert-policy' + +const findings = runContractAssertPolicy({ + spec: { lifecycleFields, jsonTsMirrors }, + contractJson, + tsExports, + testSource, +}) +expect(findings).toEqual([]) +``` + +Implementation: `src/contracts/contract-assert-policy.ts`. Self-test (all finding codes + mutation battery): `tests/contract-assert-policy.test.ts`. diff --git a/docs/owner-experience-v1.json b/docs/owner-experience-v1.json new file mode 100644 index 00000000..de7a77c9 --- /dev/null +++ b/docs/owner-experience-v1.json @@ -0,0 +1,162 @@ +{ + "id": "owner-experience/v1", + "status": "design", + "spec": "docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md", + "thesis": "Owner owns the goal and the gate; the mubot owns execution and adaptation; one relationship with Talk, Know, and Watch facets bound to a single project outcome.", + "artifactRef": "4cb8d84f-b5b9-4a55-a47c-3af193161b19", + "facets": ["talk", "know", "watch"], + "homeRequiredFacets": ["know", "watch"], + "talkOptionalUntil": "tier1-persistent-mubot-505-revived", + "loop": [ + "goal", + "sense", + "rank", + "act", + "receipt", + "learn", + "adapt", + "gate" + ], + "principles": [ + "one-face", + "goals-not-tasks", + "honest-by-construction", + "earned-autonomy", + "visible-learning", + "owner-owns-goals-and-gates" + ], + "invariants": [ + "owner-owns-goal-mubot-cannot-redefine", + "owner-owns-gate-mubot-cannot-self-verdict", + "receipts-resolved-by-id-not-label", + "progress-never-fake-green", + "earned-autonomy-one-step-with-verified-wins", + "earned-autonomy-wins-consumed-after-widen", + "earned-autonomy-scope-bound-to-project-and-agent", + "lesson-requires-receipt-id", + "talk-v1-tier1-mubot-only-when-present", + "learn-adapt-instinct-requires-port4-prerequisite", + "brain-remains-rank-not-act", + "mechanism-lock-ne-trust-lock" + ], + "talk": { + "v1Runtime": "tier1_persistent_mubot", + "tier1Status": "closed-unmerged-pr-505", + "tier2Status": "dyad-gate-blocked", + "tier2AllowedOnlyAfter": "tier2-user-chat/v1-pass", + "homePolicy": "optional-facet-until-tier1-revived" + }, + "trustLevels": [ + { "level": 0, "autonomy": "suggest" }, + { "level": 1, "autonomy": "draft" }, + { "level": 2, "autonomy": "execute_with_approval" }, + { "level": 3, "autonomy": "execute" } + ], + "earnedAutonomy": { + "maxStepsPerWiden": 1, + "defaultRequiredWins": 3, + "minRequiredWins": 3, + "winVerification": "branded-VerifiedWinRef-from-WinReceiptResolver-WeakSet-registry", + "winConstructor": "verifiedWinRefFromResolver", + "trustBoundary": "injected-WinReceiptResolver-plus-WeakSet-like-ranker-VerifiedReceiptRef", + "winConsumptionPolicy": "mark_consumed_after_successful_widen", + "dedupByReceiptId": true, + "scopeKeysRequired": ["projectId", "agentId"], + "decider": "owner_or_admin_human", + "mubotSelfWiden": false, + "note": "Same three receipts cannot climb the ladder — consumeReceiptIds must be persisted; top of ladder (execute) is NOT auto-gated per autonomyImpliesGate" + }, + "progress": { + "allowedKinds": ["measured", "unmeasured", "unavailable"], + "forbidFakeGreen": true, + "v1Mode": "unmeasured_until_project_kpi", + "metricSourceIdType": "KpiSourceId", + "sourceWiredCallerBoolean": false, + "projectScopedKpiSourceIds": [], + "measuredUnreachableInV1": true + }, + "unmetDependencies": [ + { + "id": "tier1-persistent-mubot-505", + "status": "closed-unmerged", + "blocks": ["talk-facet"], + "decision": "talk-optional-on-owner-home-until-revived" + }, + { + "id": "tier2-user-chat", + "status": "dyad-gate-blocked", + "blocks": ["talk-facet-every-member"] + }, + { + "id": "brainport-default-adapter", + "status": "type-only-sealed", + "blocks": ["rank-via-BrainPort"], + "interim": "rankTasks" + }, + { + "id": "project-scoped-kpi-source", + "status": "not-built", + "blocks": ["measured-progress"], + "v1": "unmeasured_until_project_kpi" + }, + { + "id": "model-port-v2-tool-calling", + "status": "not-built", + "blocks": ["tier2-tool-loops"] + }, + { + "id": "pot-monthly-model-ledger", + "status": "display-only", + "blocks": ["honest-talk-budget-story"] + }, + { + "id": "per-project-docs-ui", + "status": "design-only", + "blocks": ["know-facet-full-editor"] + }, + { + "id": "port-4-instinct-memory", + "status": "dormant-branch", + "blocks": ["learn-adapt-instinct"] + }, + { + "id": "learning-ranker", + "status": "under-dyad-gate", + "blocks": ["learn-adapt-instinct"] + }, + { + "id": "structured-project-outcome", + "status": "not-on-projects-table", + "blocks": ["outcome-strip"] + } + ], + "nonGoals": [ + "route-in-this-design-commit", + "migration-applied-in-this-design-commit", + "ui-in-this-design-commit", + "merge-port4-or-ranker-from-this-worktree", + "pretend-modelport-v2-exists", + "cite-dormant-surfaces-as-reuse", + "cite-BrainPort-as-reuse" + ], + "buildSlices": [ + "outcome-model", + "owner-home-shell", + "trust-strip-gate-queue", + "earned-autonomy", + "visible-learning", + "talk-facet-tier2-after-pass", + "know-facet-docs-507", + "learn-adapt-instinct-after-port4" + ], + "suggestedBuildOrder": [ + "trust-strip-gate-queue", + "visible-learning", + "outcome-model", + "earned-autonomy", + "owner-home-shell", + "know-facet-docs-507", + "talk-facet-tier2-after-pass", + "learn-adapt-instinct-after-port4" + ] +} diff --git a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md new file mode 100644 index 00000000..13a51266 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md @@ -0,0 +1,391 @@ +# Owner Experience — Unify Chat + Docs + Board into One Goal-Directed Surface + +**Status:** Design, drafted 2026-07-27. Design + contract only — no route, no +migration, no UI, no ModelPort change. Awaiting dyad-gate (Kasra-core +correctness + diverse second-eye) before any build slice starts. Unassigned = +backlog, not active until pulled. + +**Thesis owner:** Hadi, 2026-07-23 mubot/owner-experience session — artifact +`4cb8d84f-b5b9-4a55-a47c-3af193161b19` (*"Your mupot gets better at your +business every week"*). The felt product an owner lives: declare an OUTCOME → +mubot drives the loop → owner sees goal-progress + earned-trust + what it +learned. + +**Builds on:** owner-experience artifact (2026-07-23), +[project-centered workspace](./2026-07-17-project-centered-workspace-design.md), +[project lifecycle](./2026-07-23-project-lifecycle-control-loop-design.md) (PR #500), +[BYOA harness matrix](./2026-07-23-byoa-harness-support-matrix-design.md) (PR #503), +[per-project docs RBAC](./2026-07-23-per-project-docs-rbac-design.md) (PR #507 / #522–526), +Tier-2 chat design (`2026-07-27-tier2-stateless-user-chat-design.md`, currently +dyad-gate **BLOCK**), Tier-1 kayhermes panel (PR #505, **CLOSED unmerged, no +successor — Talk is optional**), `ProjectSituation` +(`src/projects/situation.ts`), agent `Autonomy` (`src/types.ts`), +`workflow_receipts` + completion/lessons receipts, `rankTasks` +(`src/tasks/ranking.ts`) interim ranking — **not** BrainPort v1, which is +type-only-sealed with zero callers. + +**Machine contract:** [`docs/owner-experience-v1.json`](../../owner-experience-v1.json) ++ pure TS [`src/owner/owner-experience-contract.ts`](../../../src/owner/owner-experience-contract.ts). + +## 0. Problem + +Today an owner meets **three separate products** that happen to share a pot: + +| Surface | Intent | Current state (verified 2026-07-27 on this worktree) | +|---|---|---| +| **Chat** (talk to mubot) | Intent in, clarification out | Tier-1 kayhermes panel — PR **#505 CLOSED unmerged, no successor**; Talk is **optional** on owner home until revived or replaced. Tier-2 every-member chat is **design-blocked** (ModelPort has no tools; pot monthly budget display-only; result fan-in unsettled). | +| **Docs** (read/edit knowledge) | Shared memory the mubot acts on | Spec #507 drafted; **no dashboard Docs surface** on main. `project_remember` / `project_recall` exist as MCP. | +| **Board** (watch it work) | Living picture of work | **Wired:** project detail + `ProjectSituation` (health, blockers, reviews, `next_action`), lifecycle loop (start-gate / circuit-breaker / completion / stall). | + +Missing is the **relationship**, not another panel: one goal-directed home where +the owner declares an OUTCOME (north-star metric), the mubot drives Sense → Rank +→ Act → Receipt → Learn → Adapt, and the owner always sees **goal-progress + +earned-trust level + what the mubot learned** — honest (receipts, not +fake-green), with trust that widens only on verified wins. + +## 1. Thesis (one line) + +**Owner owns the goal and the gate; the mubot owns execution and adaptation; +the surface is one relationship with three facets — Talk, Know, Watch — +bound to a single project outcome.** + +``` + ┌────────── owner home (per project) ──────────┐ + │ OUTCOME strip (north-star + progress truth) │ + │ TRUST strip (autonomy + what needs a yes) │ + ├──────────┬──────────────┬────────────────────┤ + │ Talk │ Know │ Watch │ + │ (chat) │ (docs) │ (board/situation) │ + └────┬─────┴──────┬───────┴──────────┬─────────┘ + │ │ │ + ▼ ▼ ▼ + intent / Q&A same memory store situation + tasks + │ │ │ + └────────────┴────────┬─────────┘ + ▼ + closed loop (mubot drives; owner gates) + Goal → Sense → Rank → Act → Receipt → Learn → Adapt ↺ + │ + ▼ + Gate (owner holds) +``` + +## 2. Seven moments (product arc — from the artifact) + +These are acceptance *feelings*, not build slices. Every slice must preserve them. + +1. **Declare an outcome** — not a task list. Structured north-star + how it is measured. +2. **Meet the mubot** — one teammate restates the goal, proposes a first plan, asks before acting. +3. **Work where you can see it** — one living picture: goal, progress, now, blocked, next. +4. **Learn from what didn't work** — misses are receipted and visible; hidden misses cannot teach. +5. **Adapt toward the goal** — dead ends die; verified movers get doubled down. +6. **Trust compounds** — autonomy widens only with verified wins (earned, never blanket). +7. **Owner always owns goal + gate** — mubot never redefines the target or self-signs risky work. + +## 3. Substrate inventory (existence ≠ enforcement) + +### 3.1 Wired on main — reuse, do not rebuild + +| Piece | Where | Role in owner-experience | +|---|---|---| +| Project row + free-text `goal` | `projects` / `src/projects/service.ts` | Carrier for outcome text today; must grow a **structured** north-star (slice 1) | +| `ProjectSituation` + `next_action` | `src/projects/situation.ts` | Watch facet truth (health, blockers, reviews, flights) | +| Project lifecycle loop | `src/projects/loop.ts`, start-gate, circuit-breaker, completion-gate, stall | Drive/finish guarantees behind Watch | +| Board tasks + flights | `src/tasks/*`, `src/flight/*` | Act surface; chat may only dispatch here | +| `workflow_receipts` + verdicts + evidence projections | `writeReceiptToD1`, `src/projects/projections.ts` | Honest win/miss store (resolve by **receipt id**, never free-string label) | +| `lessons_capture` receipt | `src/projects/completion-gate.ts` | First visible-learning feed (project terminal) | +| Agent `Autonomy` enum | `src/types.ts` `suggest\|draft\|execute\|execute_with_approval` | Trust strip vocabulary — **static dial today** | +| `autonomyImpliesGate` | `src/org/service.ts` | Gate implication already enforced on task create | +| `grant_agent_capability` | `src/mcp/provision.ts` | Manual capability path; earned-autonomy proposes, owner confirms | +| Live task ranking | `src/tasks/ranking.ts` `rankTasks` (callers: mcp/index, tasks/index) | Rank facet interim surface until BrainPort default adapter exists | +| Project memory MCP | `project_remember` / `project_recall` / `project_context` | Know facet store (same store docs must use — #507) | + +### 3.2 Explicit unmet dependencies (not cited as reuse) + +| Dependency | Status | Consequence if ignored | +|---|---|---| +| **Tier-1 persistent mubot chat (#505)** | PR **CLOSED unmerged**; no `src/chat`; no successor | Talk facet has **zero** implementation. **Decision: Talk is OPTIONAL on owner home** until #505 is revived or replaced. Slice 2 ships Know+Watch; `assertOwnerHomeFacets` requires only those two. | +| **Tier-2 chat** (`tier2-user-chat/v1`) | Dyad-gate **BLOCK** | Talk for every member cannot ship on Tier-2 until that design amends. | +| **BrainPort default adapter** | Type-only SEALED; zero implementations / zero callers | Do **not** cite BrainPort under reuse. Rank step maps to live `rankTasks` interim, or waits for ranker's `brainport-default-adapter` slice. | +| **Project-scoped KPI source** | Not built (`task_counter`/`github_prs` are agent/tenant scoped) | v1 Outcome progress is **`unmeasured_until_project_kpi`** — never render measured from wrong-scope sources. | +| **ModelPort v2 (tool-calling)** | Named breaking change in `types.ts`; **not built** | Do not plan owner-chat tool loops against ModelPort v1. | +| **Pot monthly model ledger** | Plan field is display-only; live meter is per-agent/day | Any Talk budget story must name a real debit path or stay out of this epic. | +| **Per-project Docs UI (#507)** | Design only | Know facet starts as memory read/write via existing MCP + a thin viewer; full MDX editor is a later docs slice. | +| **Port-4 instinct memory** | Dormant branch (`cursor/task-b143e73d`); zero live callers on main; migration number collision risk | **Learn→Adapt that injects instincts is blocked.** Name Port-4 + learning-ranker as **slice 0 of the learning epic**, gated separately — do not merge that branch from this worktree. | +| **Learning ranker design** | Under dyad-gate / hold | Adapt-by-instinct is out of scope until that gate PASSes and Port-4 is live. Until then Adapt = board plan changes + situation `next_action` only. | +| **Structured project Outcome / KpiSpec** | Not on `projects` (only free-text `goal`) | Slice 1 must add it; do not pretend `projects.goal` is a measurable north-star. | +| **Automatic earned-autonomy widener** | Not built (`Autonomy` is a static column) | Slice 4 introduces propose-widen from verified wins; never auto-blanket keys. | +| **Project concierge** | Existing surface | Remains a deep-link into owner home facets — not a fourth panel; no parallel UX. | + +> **Generalizable rule (from Tier-2 dyad-gate):** a "reuse" table entry must have a +> *caller on an enforcement path*. Existence, a type, or a dashboard display is +> not enforcement. Dormant branches are prerequisites with owners and gates, not +> dependencies you can lean on. + +## 4. Design — one home, three facets, one loop + +### 4.1 Owner home composition (UI non-goals for this commit) + +First viewport of `/projects/:id` (or a dedicated `/home` that deep-links a +primary project) becomes **one composition**, not a dashboard of widgets: + +1. **Outcome strip** — north-star statement + measured progress (or explicit + `unmeasured` — never a fake 100%). +2. **Trust strip** — current autonomy level + "needs your yes" count from + pending gated reviews. +3. **Three facets** (same page, same project scope): + - **Talk** — Tier-1 mubot chat for the pot's named PM (kayhermes / tenant + mubot). Tier-2 member chat is a later facet once its gate PASSes. + - **Know** — RBAC-filtered project memory / docs list (same store as + `project_recall`). + - **Watch** — existing situation card + task/flight lists (already live). + +No cards-in-hero. Brand/product remains the pot; the mubot is the teammate +voice inside Talk. Stats clusters and secondary marketing blocks stay out of +the first viewport. + +### 4.2 Outcome model (north-star) + +Replace "goal is a sentence" with **Outcome**: + +Pure TS contract (`src/owner/owner-experience-contract.ts`) — the shipped shape: + +``` +Outcome { // machine contract + statement: string // owner's words ("20 booked calls / month") + metric: OutcomeMetricSpec | null // { sourceId: KpiSourceId; target: number } | null + measurementMode: 'unmeasured_until_project_kpi' | 'measured' +} +``` + +DB row additionally carries `owner_principal` / `set_at` / `version` (§5) — +those are persistence metadata, not part of the pure-function contract type. + +`KpiSourceId = 'task_counter' | 'github_prs'` — both are **agent/tenant +scoped**, not project scoped (§3.2). v1 Outcome measurement is therefore +always `measurementMode: 'unmeasured_until_project_kpi'`; `metric` may still +name a `KpiSourceId` for display/debugging, but it can never flip +`measurementMode` to `'measured'`. + +`measurementMode: 'measured'` is gated on `metric.sourceId` being a member of +`PROJECT_SCOPED_KPI_SOURCE_IDS`, which is the **empty array in v1**. This +makes `'measured'` **unreachable by construction** — no caller-asserted +`measurementMode` field, no allowlisted `KpiSourceId`, and no combination of +`task_counter` / `github_prs` can ever produce a `{ kind: 'measured' }` +progress bar. The type documents the future: once a real project-scoped KPI +source ships, it is added to that allowlist and `'measured'` becomes +reachable for that source only. **Unknown, unwired, or non-project-scoped +source ⇒ progress renders `unmeasured`**, never a guessed green bar. + +Only `owner` / `admin` principals may create or change Outcome. Mubot / agent +tokens may **propose** a restatement (Talk) but the write path requires the +owner principal — encoded in the contract as `mayMubotRedefineGoal === false`. + +### 4.3 Closed loop mapping + +| Step | Owner-experience meaning | Substrate | +|---|---|---| +| **Goal** | Outcome strip | New structured fields (slice 1) | +| **Sense** | Watch situation + metric read | `loadProjectSituation` + KPI source | +| **Rank** | What moves the number next | Live `rankTasks` interim (`src/tasks/ranking.ts`, rank-not-act) — BrainPort default adapter is type-only-sealed with zero callers, not cited as reuse; learning bias **only after** Port-4+ranker PASS | +| **Act** | Board tasks / flights through technicians | Existing board + BYOA | +| **Receipt** | Win or miss, by receipt id | `workflow_receipts` / verdicts / evidence | +| **Learn** | Distill miss→guardrail, win→playbook | v1: surface `lessons_capture` + gate-fail receipts; v2: Port-4 distill | +| **Adapt** | Plan bends | v1: new board work + kill dead tasks via lifecycle breaker; v2: instinct-biased rank | +| **Gate** | Owner holds risky/irreversible | Existing gate_owner + autonomyImpliesGate + earned widen | + +### 4.4 Earned autonomy (trust widens with verified wins) + +Trust levels map 1:1 onto existing `Autonomy` (no parallel enum): + +| Trust level | Autonomy | Owner feeling | +|---|---|---| +| L0 observe | `suggest` | mubot only proposes | +| L1 draft | `draft` | mubot drafts; owner starts work | +| L2 gated execute | `execute_with_approval` | mubot runs; gate holds the line | +| L3 execute | `execute` | mubot runs within capability envelope | + +**Widen rule (contract-enforced shape):** + +- Input requires **projectId** and **agentId** (scope keys on the gate itself — + not a sibling table convention). Fail closed on mismatch. +- Wins are **branded `VerifiedWinRef`** minted only by + `verifiedWinRefFromResolver(resolver, receiptId, scope)`, registered in a + runtime **WeakSet**, and frozen. The resolver is the trust boundary (Port-4 + InstinctChat pattern); the WeakSet is the provenance check (same class as + ranker's `VerifiedReceiptRef`). A caller-written + `{ verification: 'resolved_by_id' }` label **or** a forged `{ _brand: + 'VerifiedWinRef', ... }` literal is rejected — mechanism-lock ≠ trust-lock. + The brand carries resolved **content** (polarity must be `win`, projectId, + agentId, resolvedAt). +- Pure gate passes only when: + 1. `proposed` is exactly one step above `current` (no skip-to-blanket), + 2. `actingPrincipal` is an owner/admin human (never the mubot agent id), + 3. `verifiedWinCount >= requiredWinsForStep` (v1 default: 3), + 4. every win is a branded ref matching the input scope, + 5. none of the win receipt ids appear in `consumedReceiptIds`. +- On success the decision returns `consumeReceiptIds`; the caller must persist + them before the next widen. **Rate/consumption story (named):** + `mark_consumed_after_successful_widen` — the same three receipts cannot walk + suggest → draft → execute_with_approval → execute. Outer defense remains the + audit trail plus mandatory owner review of each widen. +- Worth knowing: per existing `autonomyImpliesGate`, `execute` is NOT + auto-gated (only `execute_with_approval` is). The top of this ladder is the + state where tasks stop being auto-gated. +- Narrowing (after a miss / gate FAIL storm) is always allowed to owner; mubot + may *recommend* narrow, never self-narrow to escape accountability. + +This deliberately does **not** call Port-4 for instincts. Verification binds to +`workflow_receipts` via the injected resolver at the build-slice wiring layer. +### 4.5 Honest progress (no fake-green) + +`decideProgressDisplay`: + +- If `outcome.measurementMode === 'unmeasured_until_project_kpi'` → `{ kind: 'unmeasured' }` + (v1 default: `task_counter` / `github_prs` are agent/tenant scoped, not + project scoped — they cannot honestly measure a project Outcome). +- If `metric === null`, or `metric.sourceId` is not a valid `KpiSourceId`, or + `metric.sourceId` is not a member of `PROJECT_SCOPED_KPI_SOURCE_IDS` + (**empty in v1** — so `measurementMode: 'measured'` is unreachable by + construction) → `{ kind: 'unmeasured' }` +- If KPI signal compute fails, or the signal's `sourceId` does not match the + metric's → `{ kind: 'unavailable', reason }` (not 0%, not 100%) +- If signal ok and the source is project-scoped → `{ kind: 'measured', value, target, ratio }` + with ratio clamped to \[0, 1\] for display but raw values retained for audit +- **Forbidden:** rendering `kind: 'measured'` from `task_counter` or + `github_prs` under any circumstances in v1 — both are agent/tenant scoped, + never project scoped, and are excluded from `PROJECT_SCOPED_KPI_SOURCE_IDS` + by construction. There is no caller-asserted `measurementMode` string that + can produce a green bar from an agent-scoped source. + +### 4.6 Visible learning + +Watch (or a Learning sub-facet) lists lessons with: + +- `receipt_id` (required), +- `polarity: 'win' | 'miss'`, +- `summary` (short, owner-facing), +- `source_schema` (e.g. `mupot.lessons_capture/v1`, gate fail schema). + +Entries without a receipt id are rejected by the contract helper. No +"mubot says it learned X" without a backing receipt. + +## 5. Data model (draft — validate in dyad-gate; no migration in this commit) + +Next free migration number as of 2026-07-27 (highest on `migrations/` is +`0071_agent_connections.sql`): **`0072`** — slice 1 owns and names it. + +``` +-- additive on projects (shape only; slice 1 owns migration 0072) +outcome_statement TEXT NOT NULL DEFAULT '' -- may mirror goal initially +outcome_metric_json TEXT NULL -- KpiSpec or null +outcome_owner TEXT NULL +outcome_set_at TEXT NULL +outcome_version INTEGER NOT NULL DEFAULT 0 + +-- earned autonomy audit (new table) +autonomy_widen_events ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + from_autonomy TEXT NOT NULL, + to_autonomy TEXT NOT NULL, + win_receipt_ids TEXT NOT NULL, -- JSON array of receipt ids + decided_by TEXT NOT NULL, -- owner principal + created_at TEXT NOT NULL +) +``` + +No second memory store. Docs continue to target the #507 unification with +`project_remember` / `project_recall`. + +## 6. Boundaries / non-negotiables + +1. Mubot **cannot** redefine Outcome; owner/admin write only. +2. Mubot **cannot** self-verdict gates or widen its own autonomy. +3. Wins/misses/lessons resolve by **receipt id** against pot stores — never by + caller-supplied free-string provenance labels. +4. Progress never fake-greens: unmeasured/unavailable are first-class. +5. Talk v1 = Tier-1 mubot only; Tier-2 is a prerequisite with its own gate. +6. Learn→Adapt via instincts requires Port-4 + learning-ranker PASS as an + explicit prerequisite epic — not silently assumed. +7. Brain remains rank-not-act; owner-experience must not add proposal verbs. +8. Branch-only builds; no merge/deploy/publish from this epic's slices without + dyad-gate + Hadi-go. +9. Do not merge dormant Port-4 / ranker branches from this worktree to "unblock" + learning — they need their own gates (D1 migrations included). + +## 7. Acceptance criteria (design locked when) + +1. Spec + `owner-experience/v1` contract encode the thesis, three facets, loop + steps, and the six principles from the artifact. +2. Pure module encodes: owner-owns-goal, owner-owns-gate, earned-autonomy + one-step widen with verified receipt ids, honest progress, lesson-requires- + receipt, Talk-v1=Tier-1-only. +3. Focused vitest covers those invariants; `tsc --noEmit` clean. +4. Build slices listed; **none implemented** in this commit (no route / + migration / UI). +5. Unmet dependencies named with status — no dormant surface cited as reuse. + +## 8. Build slices (backlog — dyad-gate each; do not start until this design PASSes) + +**Suggested build order (`docs/owner-experience-v1.json` → `suggestedBuildOrder`): +3 → 5 → 1 → 4 → 2 → 7 → 6 → 8** — trust-strip-gate-queue and visible-learning +ship first because they only read existing substrate (situation reviews, +`lessons_capture`); outcome-model and earned-autonomy follow once those +surfaces exist to hang off; owner-home-shell composes what is already built; +the two gated slices (Tier-2 talk, instinct learn/adapt) stay last behind +their own prerequisite gates. The numbered list below is dependency order for +reading, not build order — do not build 1 → 2 → 3 in sequence. + +1. **Outcome model** — structured north-star on `projects` + progress helper. + v1 mode is `unmeasured_until_project_kpi` by default; `measured` stays + unreachable until a source lands in `PROJECT_SCOPED_KPI_SOURCE_IDS`. The + first project-scoped allowlist entry MUST ship WITH the four downstream + branch tests for `decideProgressDisplay` once `measured` is reachable + (`signal_missing`, signal `ok:false`, `source_id_mismatch`, + `invalid_target` / measured ratio) — those branches are dead under the + empty v1 allowlist and must not be left untested when the gate opens. + Next free migration number: **0072**. +2. **Owner home shell** — compose Outcome + Trust + Talk/Know/Watch facets on + project detail (Watch = existing situation; Talk = Tier-1 embed; Know = + memory list). No new cognition. +3. **Trust strip + gate queue** — surface current autonomy + pending + owner-yes items from situation reviews / approvals. +4. **Earned autonomy** — `autonomy_widen_events` + propose/confirm path using + contract gate; capability grants still go through existing + `grant_agent_capability`. +5. **Visible learning** — lesson list from `lessons_capture` + gate-fail + receipts by id on Watch. +6. **Talk facet Tier-2** — only after Tier-2 design amends and PASSes (ModelPort + v2 or constrained prompt→text path explicitly chosen; real budget ledger; + link-table fan-in). +7. **Know facet full docs** — #507 slices (one policy point → memory + unification → editor). +8. **Learn/Adapt v2 (instinct)** — **prerequisite slice 0:** gate+land Port-4 + (migration renumbered) then learning-ranker; only then wire Adapt bias into + Rank. Until then this slice stays backlog. + +Each slice: Kasra-core + diverse second-eye before merge. Unassigned until +pulled from backlog. + +## 9. Open questions (for dyad-gate) + +1. Is the primary owner home `/projects/:id` or a pot-level `/home` that picks + a default project? Lean: enhance `/projects/:id`, add pot home later. +2. Required win count per autonomy step — default 3; product may tune per plan. +3. Should Outcome live only on root projects (not children)? Lean: root-only in + v1 to match two-level hierarchy. +4. Metric connectors beyond `task_counter` / `github_prs` (GHL booked-calls, + PostHog conversions) — each is its own connector slice; owner-experience + must not invent numbers without a source. + +## 10. Related arc + +- Artifact 2026-07-23: chat · docs · board as one relationship. +- #500 lifecycle / #503 BYOA = labor behind Watch. +- #507 / #522–526 = Know. +- #505 = Talk for the few (Tier-1); Tier-2 = Talk for the many (blocked). +- Port-4 + learning ranker = Learn/Adapt depth (explicit unmet). +- This spec is the **unifying owner surface**; it does not replace those epics — + it sequences them behind one coherent experience. diff --git a/src/contracts/contract-assert-policy.ts b/src/contracts/contract-assert-policy.ts new file mode 100644 index 00000000..3a3d3c9a --- /dev/null +++ b/src/contracts/contract-assert-policy.ts @@ -0,0 +1,257 @@ +// Machine-checkable contract policy (Kasra rules): +// 1. Lifecycle fields (status/mode/phase) — membership in declared allowedValues + whoMayFlip; never single-literal equality in tests. +// 2. JSON↔TS mirrors — assert JSON === TS export; never retyped literals in tests. + +export type LifecycleFieldName = 'status' | 'mode' | 'phase' + +export interface LifecycleFieldDeclaration { + field: LifecycleFieldName + allowedValues: readonly string[] + whoMayFlip: readonly string[] +} + +export interface JsonTsMirrorDeclaration { + jsonPath: readonly string[] + tsExportName: string +} + +export interface ContractAssertPolicySpec { + lifecycleFields: readonly LifecycleFieldDeclaration[] + jsonTsMirrors: readonly JsonTsMirrorDeclaration[] +} + +export type PolicyFindingCode = + | 'lifecycle_missing_allowed_values' + | 'lifecycle_missing_who_may_flip' + | 'lifecycle_value_not_in_allowed_set' + | 'lifecycle_test_literal_equality' + | 'json_ts_mirror_missing_ts_constant' + | 'json_ts_mirror_value_mismatch' + | 'json_ts_mirror_literal_in_test' + // Token-presence lint only: regex over suite source. Commented-out / + // it.skip / dead-branch text still "count". Not a behavioral enforcement. + | 'json_ts_mirror_assertion_token_absent' + +export interface PolicyFinding { + code: PolicyFindingCode + message: string + jsonPath?: string + tsExportName?: string +} + +function jsonPathKey(path: readonly string[]): string { + return path.join('.') +} + +function contractAccessPath(jsonPath: readonly string[]): string { + if (jsonPath.length === 0) { + return 'contract' + } + return `contract.${jsonPath.join('.')}` +} + +export function getJsonValue( + root: Record, + path: readonly string[], +): unknown { + let current: unknown = root + for (const key of path) { + if (typeof current !== 'object' || current === null) { + return undefined + } + const bag = current as Record + if (!(key in bag)) { + return undefined + } + current = bag[key] + } + return current +} + +function stableEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +export function checkLifecycleDeclarations( + spec: ContractAssertPolicySpec, +): PolicyFinding[] { + const findings: PolicyFinding[] = [] + for (const row of spec.lifecycleFields) { + if (row.allowedValues.length === 0) { + findings.push({ + code: 'lifecycle_missing_allowed_values', + message: `lifecycle field "${row.field}" must declare a non-empty allowedValues set`, + jsonPath: row.field, + }) + } + if (row.whoMayFlip.length === 0) { + findings.push({ + code: 'lifecycle_missing_who_may_flip', + message: `lifecycle field "${row.field}" must declare whoMayFlip`, + jsonPath: row.field, + }) + } + } + return findings +} + +export function checkLifecycleValues( + spec: ContractAssertPolicySpec, + contractJson: Record, +): PolicyFinding[] { + const findings: PolicyFinding[] = [] + for (const row of spec.lifecycleFields) { + const value = contractJson[row.field] + if (typeof value !== 'string') { + findings.push({ + code: 'lifecycle_value_not_in_allowed_set', + message: `lifecycle field "${row.field}" must be a string present in allowedValues`, + jsonPath: row.field, + }) + continue + } + if (!(row.allowedValues as readonly string[]).includes(value)) { + findings.push({ + code: 'lifecycle_value_not_in_allowed_set', + message: `lifecycle value "${value}" for "${row.field}" is not in declared allowedValues`, + jsonPath: row.field, + }) + } + } + return findings +} + +export function checkJsonTsMirrors( + spec: ContractAssertPolicySpec, + contractJson: Record, + tsExports: Record, +): PolicyFinding[] { + const findings: PolicyFinding[] = [] + for (const mirror of spec.jsonTsMirrors) { + const pathLabel = jsonPathKey(mirror.jsonPath) + if (!(mirror.tsExportName in tsExports)) { + findings.push({ + code: 'json_ts_mirror_missing_ts_constant', + message: `mirror "${pathLabel}" requires TS export "${mirror.tsExportName}"`, + jsonPath: pathLabel, + tsExportName: mirror.tsExportName, + }) + continue + } + const jsonValue = getJsonValue(contractJson, mirror.jsonPath) + const tsValue = tsExports[mirror.tsExportName] + const normalizedTs = + Array.isArray(tsValue) && Array.isArray(jsonValue) + ? [...tsValue] + : tsValue + if (!stableEqual(jsonValue, normalizedTs)) { + findings.push({ + code: 'json_ts_mirror_value_mismatch', + message: `mirror "${pathLabel}" JSON value does not match TS export "${mirror.tsExportName}"`, + jsonPath: pathLabel, + tsExportName: mirror.tsExportName, + }) + } + } + return findings +} + +function lifecycleLiteralEqualityPattern(field: LifecycleFieldName): RegExp { + return new RegExp( + String.raw`expect\s*\(\s*contract\.${field}\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*['"\`]`, + 'g', + ) +} + +export function checkTestSourceLifecycle( + testSource: string, + lifecycleFields: readonly LifecycleFieldDeclaration[], +): PolicyFinding[] { + const findings: PolicyFinding[] = [] + for (const row of lifecycleFields) { + const matches = testSource.match(lifecycleLiteralEqualityPattern(row.field)) + if (matches !== null && matches.length > 0) { + findings.push({ + code: 'lifecycle_test_literal_equality', + message: `test asserts contract.${row.field} via single-literal equality; use membership in declared allowedValues instead`, + jsonPath: row.field, + }) + } + } + return findings +} + +function mirrorLiteralPattern(accessPath: string): RegExp { + const escaped = accessPath.replace(/\./g, '\\.') + return new RegExp( + String.raw`expect\s*\(\s*${escaped}\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*(?!\[\.\.\.)(?:['"\`]|(?:\[))`, + 'g', + ) +} + +function mirrorUsesTsConstant(testSource: string, accessPath: string, tsExportName: string): boolean { + const escapedPath = accessPath.replace(/\./g, '\\.') + const spreadPattern = new RegExp( + String.raw`expect\s*\(\s*${escapedPath}\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*\[\.\.\.${tsExportName}\]`, + ) + const directPattern = new RegExp( + String.raw`expect\s*\(\s*${escapedPath}\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*${tsExportName}\b`, + ) + return spreadPattern.test(testSource) || directPattern.test(testSource) +} + +export function checkTestSourceMirrors( + testSource: string, + mirrors: readonly JsonTsMirrorDeclaration[], +): PolicyFinding[] { + const findings: PolicyFinding[] = [] + for (const mirror of mirrors) { + const accessPath = contractAccessPath(mirror.jsonPath) + const pathLabel = jsonPathKey(mirror.jsonPath) + if (!mirrorUsesTsConstant(testSource, accessPath, mirror.tsExportName)) { + findings.push({ + code: 'json_ts_mirror_assertion_token_absent', + message: + `suite source text lacks an assertion-token for ${accessPath} vs "${mirror.tsExportName}" ` + + `(spread/direct .toBe/.toEqual). This is a text-presence lint — comments and ` + + `it.skip still match; it does not prove the assertion runs.`, + jsonPath: pathLabel, + tsExportName: mirror.tsExportName, + }) + } + const literalHits = testSource.match(mirrorLiteralPattern(accessPath)) + if (literalHits !== null && literalHits.length > 0) { + findings.push({ + code: 'json_ts_mirror_literal_in_test', + message: `test retypes a literal for ${accessPath}; assert against "${mirror.tsExportName}" instead`, + jsonPath: pathLabel, + tsExportName: mirror.tsExportName, + }) + } + } + return findings +} + +export interface ContractAssertPolicyInput { + spec: ContractAssertPolicySpec + contractJson: Record + tsExports: Record + testSource: string +} + +export function runContractAssertPolicy(input: ContractAssertPolicyInput): PolicyFinding[] { + return [ + ...checkLifecycleDeclarations(input.spec), + ...checkLifecycleValues(input.spec, input.contractJson), + ...checkJsonTsMirrors(input.spec, input.contractJson, input.tsExports), + ...checkTestSourceLifecycle(input.testSource, input.spec.lifecycleFields), + ...checkTestSourceMirrors(input.testSource, input.spec.jsonTsMirrors), + ] +} + +export function policyFindingsInclude( + findings: readonly PolicyFinding[], + code: PolicyFindingCode, +): boolean { + return findings.some((row) => row.code === code) +} diff --git a/src/owner/owner-experience-contract.ts b/src/owner/owner-experience-contract.ts new file mode 100644 index 00000000..112d2b08 --- /dev/null +++ b/src/owner/owner-experience-contract.ts @@ -0,0 +1,496 @@ +// mupot — Owner-experience unified surface contract (pure). +// +// Design: docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md +// Machine contract: docs/owner-experience-v1.json +// +// Design/spec commit only — no HTTP route, no D1 migration, no UI. +// Locks: owner owns goal+gate, honest receipts-by-id, earned autonomy, +// no fake-green progress, Talk v1 = Tier-1 only, instinct Adapt blocked +// until Port-4 is an explicit prerequisite. + +import type { Autonomy } from '../types' +import { isAutonomy } from '../types' + +/** Facets of the single owner relationship (not three apps). */ +export const OWNER_EXPERIENCE_FACETS = ['talk', 'know', 'watch'] as const + +export type OwnerExperienceFacet = (typeof OWNER_EXPERIENCE_FACETS)[number] + +/** Closed loop the mubot drives; owner holds goal + gate. */ +export const OWNER_EXPERIENCE_LOOP = [ + 'goal', + 'sense', + 'rank', + 'act', + 'receipt', + 'learn', + 'adapt', + 'gate', +] as const + +export type OwnerExperienceLoopStep = (typeof OWNER_EXPERIENCE_LOOP)[number] + +export const OWNER_EXPERIENCE_PRINCIPLES = [ + 'one-face', + 'goals-not-tasks', + 'honest-by-construction', + 'earned-autonomy', + 'visible-learning', + 'owner-owns-goals-and-gates', +] as const + +/** Autonomy ladder used as earned-trust levels (no parallel enum). */ +export const OWNER_TRUST_LADDER: readonly Autonomy[] = [ + 'suggest', + 'draft', + 'execute_with_approval', + 'execute', +] + +export const DEFAULT_REQUIRED_WINS_PER_WIDEN = 3 +export const MIN_REQUIRED_WINS_PER_WIDEN = 3 + +export type KpiSourceId = 'task_counter' | 'github_prs' +export const KPI_SOURCE_IDS: readonly KpiSourceId[] = Object.freeze([ + 'task_counter', + 'github_prs', +] as const satisfies readonly KpiSourceId[]) +export function isKpiSourceId(v: unknown): v is KpiSourceId { + return typeof v === 'string' && (KPI_SOURCE_IDS as readonly string[]).includes(v) +} + +/** + * Sources allowed to produce `measurementMode: 'measured'`. Empty in v1 by + * construction: `task_counter` / `github_prs` are agent/tenant scoped, not + * project scoped, so neither can honestly measure a project Outcome. Adding + * a real project-scoped source later means adding it here — not flipping a + * caller-asserted boolean. See BLOCK-B (owner-experience dyad-gate). + * Frozen so "unreachable by construction" is not defeasible by array push. + */ +export const PROJECT_SCOPED_KPI_SOURCE_IDS: readonly KpiSourceId[] = Object.freeze( + [] as const satisfies readonly KpiSourceId[], +) +export function isProjectScopedKpiSourceId(v: KpiSourceId): boolean { + return (PROJECT_SCOPED_KPI_SOURCE_IDS as readonly string[]).includes(v) +} + +/** + * Rate/consumption story (named, not implied): + * Successful widen returns consumeReceiptIds; those ids must be passed back in + * consumedReceiptIds on later calls so the same three wins cannot walk the + * full ladder. Audit trail + mandatory owner review remain the outer defense; + * consumption is the in-function fence against reuse. + */ +export const WIN_CONSUMPTION_POLICY = + 'mark_consumed_after_successful_widen' as const + +export type TalkRuntimeKind = 'tier1_persistent_mubot' | 'tier2_stateless_user_chat' + +export type PermissionCallerKind = 'owner_or_admin_human' | 'mubot_or_agent' | 'other' + +/** Record a real workflow_receipts resolver must return. Trust claim ≠ trust object. */ +export interface WinReceiptRecord { + receiptId: string + projectId: string + agentId: string + polarity: 'win' | 'miss' + resolvedAt: string +} + +/** + * Injected trust boundary (Port-4 InstinctChat pattern). Production wires + * workflow_receipts; tests inject fakes. Callers never pass a self-describing tag. + */ +export type WinReceiptResolver = (receiptId: string) => WinReceiptRecord | null + +/** + * Opaque trust object — carries resolved content (polarity, scope, time). + * Only `verifiedWinRefFromResolver` may construct it, and only refs in the + * runtime WeakSet registry are accepted by decideEarnedAutonomyWiden. + */ +export type VerifiedWinRef = { + readonly _brand: 'VerifiedWinRef' + readonly receiptId: string + readonly projectId: string + readonly agentId: string + readonly polarity: 'win' + readonly resolvedAt: string +} + +/** Runtime provenance — shape/_brand alone is not enough (same class as ranker). */ +const verifiedWinRegistry = new WeakSet() + +export type WinRefResolution = + | VerifiedWinRef + | { + ok: false + reason: + | 'unverified_win_label' + | 'scope_mismatch' + | 'not_a_win' + | 'missing_receipt_id' + | 'project_id_required' + | 'agent_id_required' + } + +/** + * Sole constructor for VerifiedWinRef. A plain + * `{ verification: 'resolved_by_id' }` bag is not accepted — that was the + * label pattern (mechanism-lock ≠ trust-lock). Forged `_brand` literals are + * refused at the gate via WeakSet. + */ +export function verifiedWinRefFromResolver( + resolver: WinReceiptResolver, + receiptId: string, + scope: { projectId: string; agentId: string }, +): WinRefResolution { + const projectId = String(scope.projectId || '').trim() + const agentId = String(scope.agentId || '').trim() + if (!projectId) return { ok: false, reason: 'project_id_required' } + if (!agentId) return { ok: false, reason: 'agent_id_required' } + const id = String(receiptId || '').trim() + if (!id) return { ok: false, reason: 'missing_receipt_id' } + + const record = resolver(id) + if (!record) return { ok: false, reason: 'unverified_win_label' } + if (String(record.receiptId).trim() !== id) { + return { ok: false, reason: 'unverified_win_label' } + } + if ( + String(record.projectId).trim() !== projectId || + String(record.agentId).trim() !== agentId + ) { + return { ok: false, reason: 'scope_mismatch' } + } + if (record.polarity !== 'win') return { ok: false, reason: 'not_a_win' } + const resolvedAt = String(record.resolvedAt || '').trim() + if (!resolvedAt) return { ok: false, reason: 'unverified_win_label' } + + const ref: VerifiedWinRef = Object.freeze({ + _brand: 'VerifiedWinRef' as const, + receiptId: id, + projectId, + agentId, + polarity: 'win' as const, + resolvedAt, + }) + verifiedWinRegistry.add(ref) + return ref +} + +/** @deprecated Label-only shape — rejected by decideEarnedAutonomyWiden. */ +export interface LabeledWinRef { + receiptId: string + verification: 'resolved_by_id' | 'unverified_label' +} + +export interface EarnedAutonomyWidenInput { + /** Project whose binding is being widened — required scope key. */ + projectId: string + /** Agent whose autonomy is being widened — required scope key. */ + agentId: string + current: Autonomy + proposed: Autonomy + actingPrincipalKind: PermissionCallerKind + wins: readonly (VerifiedWinRef | LabeledWinRef)[] + requiredWins: number + /** Receipt ids already consumed by prior successful widens. */ + consumedReceiptIds: readonly string[] +} + +export type EarnedAutonomyWidenDecision = + | { + ok: true + from: Autonomy + to: Autonomy + verifiedWinCount: number + /** Caller must persist these as consumed before the next widen. */ + consumeReceiptIds: readonly string[] + } + | { + ok: false + reason: + | 'mubot_cannot_self_widen' + | 'invalid_autonomy' + | 'skip_not_allowed' + | 'not_a_widen' + | 'insufficient_verified_wins' + | 'unverified_win_label' + | 'required_wins_invalid' + | 'project_id_required' + | 'agent_id_required' + | 'scope_mismatch' + | 'not_a_win' + | 'win_already_consumed' + } + +export type ProgressDisplay = + | { kind: 'measured'; value: number; target: number; ratio: number } + | { kind: 'unmeasured' } + | { kind: 'unavailable'; reason: string } + +export interface OutcomeMetricSpec { + sourceId: KpiSourceId + target: number +} + +/** Structured north-star — not free-text projects.goal. */ +export interface Outcome { + statement: string + metric: OutcomeMetricSpec | null + /** v1: existing agent KPI sources cannot measure a project → unmeasured. */ + measurementMode: 'unmeasured_until_project_kpi' | 'measured' +} + +export interface LessonDraft { + receiptId: string + polarity: 'win' | 'miss' + summary: string + sourceSchema: string +} + +export type LessonValidation = + | { ok: true; lesson: LessonDraft } + | { + ok: false + reason: 'receipt_id_required' | 'summary_required' | 'source_schema_required' | 'polarity_invalid' + } + +export type InstinctAdaptEligibility = + | { ok: true } + | { + ok: false + reason: 'port4_not_live' | 'learning_ranker_not_passed' | 'prerequisite_missing' + } + +/** + * Mubot may propose wording; it may never write the Outcome. + * CallerKind is required so a later slice has a real branch point (not a + * zero-arg constant posing as a permission check). + */ +export function mayMubotRedefineGoal(callerKind: PermissionCallerKind): boolean { + if (callerKind !== 'owner_or_admin_human' && callerKind !== 'mubot_or_agent' && callerKind !== 'other') { + throw new Error('owner_experience_unknown_caller_kind') + } + return false +} + +/** + * Risky / irreversible acts wait for the owner — mubot never self-verdicts. + * CallerKind required for the same reason as mayMubotRedefineGoal. + */ +export function mayMubotSelfVerdictGate(callerKind: PermissionCallerKind): boolean { + if (callerKind !== 'owner_or_admin_human' && callerKind !== 'mubot_or_agent' && callerKind !== 'other') { + throw new Error('owner_experience_unknown_caller_kind') + } + return false +} + +/** Talk facet v1 is Tier-1 only while Tier-2 remains dyad-gate blocked. */ +export function isTalkV1Runtime(kind: TalkRuntimeKind): boolean { + return kind === 'tier1_persistent_mubot' +} + +export function assertTalkV1Runtime(kind: TalkRuntimeKind): void { + if (!isTalkV1Runtime(kind)) { + throw new Error('owner_experience_talk_v1_tier1_only') + } +} + +export function trustLevelForAutonomy(autonomy: Autonomy): number { + const idx = OWNER_TRUST_LADDER.indexOf(autonomy) + if (idx < 0) throw new Error('owner_experience_unknown_autonomy') + return idx +} + +export function autonomyForTrustLevel(level: number): Autonomy { + if (!Number.isInteger(level) || level < 0 || level >= OWNER_TRUST_LADDER.length) { + throw new Error('owner_experience_trust_level_out_of_range') + } + return OWNER_TRUST_LADDER[level] as Autonomy +} + +function isBrandedWinRef(win: VerifiedWinRef | LabeledWinRef): win is VerifiedWinRef { + return ( + typeof win === 'object' && + win !== null && + verifiedWinRegistry.has(win as object) && + '_brand' in win && + (win as VerifiedWinRef)._brand === 'VerifiedWinRef' + ) +} + +/** + * One-step earned widen. Wins must be branded VerifiedWinRef from resolver + * (not free-string labels). Scope keys on the input are required and checked + * against each win. Decider must be an owner/admin human — never the mubot. + * Consumed receipt ids cannot be reused to climb further steps. + */ +export function decideEarnedAutonomyWiden( + input: EarnedAutonomyWidenInput, +): EarnedAutonomyWidenDecision { + const projectId = String(input.projectId || '').trim() + const agentId = String(input.agentId || '').trim() + if (!projectId) return { ok: false, reason: 'project_id_required' } + if (!agentId) return { ok: false, reason: 'agent_id_required' } + + if (input.actingPrincipalKind !== 'owner_or_admin_human') { + return { ok: false, reason: 'mubot_cannot_self_widen' } + } + if (!isAutonomy(input.current) || !isAutonomy(input.proposed)) { + return { ok: false, reason: 'invalid_autonomy' } + } + if (!Number.isInteger(input.requiredWins) || input.requiredWins < MIN_REQUIRED_WINS_PER_WIDEN) { + return { ok: false, reason: 'required_wins_invalid' } + } + + const fromLevel = trustLevelForAutonomy(input.current) + const toLevel = trustLevelForAutonomy(input.proposed) + if (toLevel <= fromLevel) return { ok: false, reason: 'not_a_widen' } + if (toLevel !== fromLevel + 1) return { ok: false, reason: 'skip_not_allowed' } + + const consumed = new Set( + (input.consumedReceiptIds || []).map((id) => String(id).trim()).filter(Boolean), + ) + const accepted: VerifiedWinRef[] = [] + const seenIds = new Set() + + for (const win of input.wins) { + if (!isBrandedWinRef(win)) { + return { ok: false, reason: 'unverified_win_label' } + } + if (!win.receiptId.trim()) { + return { ok: false, reason: 'unverified_win_label' } + } + if (seenIds.has(win.receiptId)) { + continue // dedup — one receipt counts once + } + if (win.projectId !== projectId || win.agentId !== agentId) { + return { ok: false, reason: 'scope_mismatch' } + } + if (win.polarity !== 'win') { + return { ok: false, reason: 'not_a_win' } + } + if (consumed.has(win.receiptId)) { + return { ok: false, reason: 'win_already_consumed' } + } + seenIds.add(win.receiptId) + accepted.push(win) + } + + if (accepted.length < input.requiredWins) { + return { ok: false, reason: 'insufficient_verified_wins' } + } + + const consumeReceiptIds = accepted.slice(0, input.requiredWins).map((w) => w.receiptId) + return { + ok: true, + from: input.current, + to: input.proposed, + verifiedWinCount: accepted.length, + consumeReceiptIds, + } +} + +/** + * Honest progress display. Unwired / missing / non-allowlisted metric ⇒ unmeasured. + * Never invents a green measured bar from absent data. + * Caller `sourceWired` boolean REMOVED — wired-ness derived from KpiSourceId allowlist. + * v1 `unmeasured_until_project_kpi` always returns unmeasured (existing KPI sources + * are agent-scoped, not project-owner UX — BLOCK-B). + * `measurementMode: 'measured'` is additionally gated on + * `PROJECT_SCOPED_KPI_SOURCE_IDS` membership, which is empty in v1 — so + * `'measured'` is unreachable by construction, not just by convention. No + * caller-asserted `measurementMode` string can produce a green bar from + * `task_counter` / `github_prs`. + */ +export function decideProgressDisplay(input: { + outcome: Outcome + signal: + | { ok: true; value: number; sourceId: KpiSourceId } + | { ok: false; reason: string } + | null +}): ProgressDisplay { + if (input.outcome.measurementMode === 'unmeasured_until_project_kpi') { + return { kind: 'unmeasured' } + } + if (input.outcome.metric === null) return { kind: 'unmeasured' } + if (!isKpiSourceId(input.outcome.metric.sourceId)) return { kind: 'unmeasured' } + if (!isProjectScopedKpiSourceId(input.outcome.metric.sourceId)) return { kind: 'unmeasured' } + if (input.signal === null) { + return { kind: 'unavailable', reason: 'signal_missing' } + } + if (!input.signal.ok) { + return { kind: 'unavailable', reason: input.signal.reason } + } + if (input.signal.sourceId !== input.outcome.metric.sourceId) { + return { kind: 'unavailable', reason: 'source_id_mismatch' } + } + const target = input.outcome.metric.target + if (!(target > 0)) { + return { kind: 'unavailable', reason: 'invalid_target' } + } + const value = input.signal.value + const ratio = Math.min(1, Math.max(0, value / target)) + return { kind: 'measured', value, target, ratio } +} + +/** Lessons are visible learning — receipt id is mandatory. */ +export function validateLessonDraft(draft: { + receiptId: string + polarity: string + summary: string + sourceSchema: string +}): LessonValidation { + const receiptId = draft.receiptId.trim() + if (!receiptId) return { ok: false, reason: 'receipt_id_required' } + const summary = draft.summary.trim() + if (!summary) return { ok: false, reason: 'summary_required' } + const sourceSchema = draft.sourceSchema.trim() + if (!sourceSchema) return { ok: false, reason: 'source_schema_required' } + if (draft.polarity !== 'win' && draft.polarity !== 'miss') { + return { ok: false, reason: 'polarity_invalid' } + } + return { + ok: true, + lesson: { + receiptId, + polarity: draft.polarity, + summary, + sourceSchema, + }, + } +} + +/** + * Instinct-biased Adapt is illegal until Port-4 is live AND the learning + * ranker has passed its dyad-gate. Naming the prerequisite beats citing a + * dormant branch as reuse. + */ +export function mayEnableInstinctAdapt(prereq: { + port4LiveOnMain: boolean + learningRankerDyadPassed: boolean +}): InstinctAdaptEligibility { + if (!prereq.port4LiveOnMain) return { ok: false, reason: 'port4_not_live' } + if (!prereq.learningRankerDyadPassed) { + return { ok: false, reason: 'learning_ranker_not_passed' } + } + return { ok: true } +} + +/** Facets Know+Watch are required for v1 home; Talk is OPTIONAL until #505 is revived. */ +export const OWNER_HOME_REQUIRED_FACETS = ['know', 'watch'] as const + +/** Owner home must expose Know+Watch. Talk is optional while Tier-1 #505 is closed-unmerged. */ +export function assertOwnerHomeFacets(facets: readonly string[]): void { + for (const required of OWNER_HOME_REQUIRED_FACETS) { + if (!facets.includes(required)) { + throw new Error(`owner_experience_facet_missing: ${required}`) + } + } +} + +/** Rank step must not gain new act verbs via this epic. */ +export function brainRemainsRankNotAct(): boolean { + return true +} diff --git a/src/types.ts b/src/types.ts index cc118011..227f5828 100644 --- a/src/types.ts +++ b/src/types.ts @@ -188,7 +188,14 @@ export type Autonomy = 'suggest' | 'draft' | 'execute' | 'execute_with_approval' export type BudgetWindow = 'day' | 'week' const EFFORTS: readonly Effort[] = ['low', 'standard', 'high', 'sprint'] -const AUTONOMIES: readonly Autonomy[] = ['suggest', 'draft', 'execute', 'execute_with_approval'] +// Exported so consumers (e.g. owner-experience trust ladder) can drift-lock +// against the canonical set instead of hand-maintaining a parallel list. +export const AUTONOMIES: readonly Autonomy[] = Object.freeze([ + 'suggest', + 'draft', + 'execute', + 'execute_with_approval', +] as const satisfies readonly Autonomy[]) const BUDGET_WINDOWS: readonly BudgetWindow[] = ['day', 'week'] export function isEffort(v: unknown): v is Effort { diff --git a/tests/contract-assert-policy.test.ts b/tests/contract-assert-policy.test.ts new file mode 100644 index 00000000..2a78db55 --- /dev/null +++ b/tests/contract-assert-policy.test.ts @@ -0,0 +1,288 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + checkJsonTsMirrors, + checkLifecycleDeclarations, + checkLifecycleValues, + checkTestSourceLifecycle, + checkTestSourceMirrors, + getJsonValue, + policyFindingsInclude, + runContractAssertPolicy, + type ContractAssertPolicySpec, + type PolicyFindingCode, +} from '../src/contracts/contract-assert-policy' + +const ownerFixtureJson = JSON.parse( + readFileSync(new URL('./fixtures/contract-assert-policy/owner-experience-v1.json', import.meta.url), 'utf8'), +) as Record + +const OWNER_EXPERIENCE_FACETS = ['talk', 'know', 'watch'] as const +const OWNER_EXPERIENCE_LOOP = [ + 'goal', + 'sense', + 'rank', + 'act', + 'receipt', + 'learn', + 'adapt', + 'gate', +] as const +const OWNER_EXPERIENCE_PRINCIPLES = [ + 'one-face', + 'goals-not-tasks', + 'honest-by-construction', + 'earned-autonomy', + 'visible-learning', + 'owner-owns-goals-and-gates', +] as const +const OWNER_TRUST_LADDER = ['suggest', 'draft', 'execute_with_approval', 'execute'] as const +const DEFAULT_REQUIRED_WINS_PER_WIDEN = 3 +const WIN_CONSUMPTION_POLICY = 'mark_consumed_after_successful_widen' as const + +const ownerSpec: ContractAssertPolicySpec = { + lifecycleFields: [ + { + field: 'status', + allowedValues: ['design', 'dyad-gate', 'live'], + whoMayFlip: ['loom', 'kasra', 'athena-gate'], + }, + ], + jsonTsMirrors: [ + { jsonPath: ['facets'], tsExportName: 'OWNER_EXPERIENCE_FACETS' }, + { jsonPath: ['loop'], tsExportName: 'OWNER_EXPERIENCE_LOOP' }, + { jsonPath: ['principles'], tsExportName: 'OWNER_EXPERIENCE_PRINCIPLES' }, + { + jsonPath: ['earnedAutonomy', 'defaultRequiredWins'], + tsExportName: 'DEFAULT_REQUIRED_WINS_PER_WIDEN', + }, + { + jsonPath: ['earnedAutonomy', 'winConsumptionPolicy'], + tsExportName: 'WIN_CONSUMPTION_POLICY', + }, + ], +} + +const ownerTsExports: Record = { + OWNER_EXPERIENCE_FACETS, + OWNER_EXPERIENCE_LOOP, + OWNER_EXPERIENCE_PRINCIPLES, + DEFAULT_REQUIRED_WINS_PER_WIDEN, + WIN_CONSUMPTION_POLICY, +} + +const compliantOwnerTestSource = ` +describe('owner-experience/v1 contract doc', () => { + it('matches the pure module facets, loop, and trust ladder', () => { + const allowedStatuses = ['design', 'dyad-gate', 'live'] + expect(allowedStatuses).toContain(contract.status) + expect(contract.facets).toEqual([...OWNER_EXPERIENCE_FACETS]) + expect(contract.loop).toEqual([...OWNER_EXPERIENCE_LOOP]) + expect(contract.principles).toEqual([...OWNER_EXPERIENCE_PRINCIPLES]) + expect(contract.earnedAutonomy.defaultRequiredWins).toBe(DEFAULT_REQUIRED_WINS_PER_WIDEN) + expect(contract.earnedAutonomy.winConsumptionPolicy).toBe(WIN_CONSUMPTION_POLICY) + expect(contract.trustLevels.map((row) => row.autonomy)).toEqual([...OWNER_TRUST_LADDER]) + }) +}) +` + +function expectFinding( + findings: readonly { code: PolicyFindingCode }[], + code: PolicyFindingCode, +): void { + expect(policyFindingsInclude(findings, code)).toBe(true) +} + +describe('contract-assert-policy helpers', () => { + it('reads nested JSON paths', () => { + expect(getJsonValue(ownerFixtureJson, ['earnedAutonomy', 'winConsumptionPolicy'])).toBe( + WIN_CONSUMPTION_POLICY, + ) + }) + + it('passes on compliant owner-experience fixture + test source', () => { + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: ownerFixtureJson, + tsExports: ownerTsExports, + testSource: compliantOwnerTestSource, + }) + expect(findings).toEqual([]) + }) +}) + +describe('rule 1 — lifecycle membership, not literal equality', () => { + it('flags missing allowedValues or whoMayFlip in spec', () => { + expectFinding( + checkLifecycleDeclarations({ + lifecycleFields: [ + { + field: 'status', + allowedValues: [], + whoMayFlip: ['loom'], + }, + ], + jsonTsMirrors: [], + }), + 'lifecycle_missing_allowed_values', + ) + expectFinding( + checkLifecycleDeclarations({ + lifecycleFields: [ + { + field: 'phase', + allowedValues: ['design'], + whoMayFlip: [], + }, + ], + jsonTsMirrors: [], + }), + 'lifecycle_missing_who_may_flip', + ) + }) + + it('flags contract lifecycle values outside declared allowed set', () => { + const findings = checkLifecycleValues( + { + lifecycleFields: [ + { + field: 'status', + allowedValues: ['design', 'live'], + whoMayFlip: ['loom'], + }, + ], + jsonTsMirrors: [], + }, + { ...ownerFixtureJson, status: 'shipped-without-gate' }, + ) + expectFinding(findings, 'lifecycle_value_not_in_allowed_set') + }) + + it('flags single-literal lifecycle assertions in test source', () => { + const badTest = ` + it('bad', () => { + expect(contract.status).toBe('design') + }) + ` + const findings = checkTestSourceLifecycle(badTest, ownerSpec.lifecycleFields) + expectFinding(findings, 'lifecycle_test_literal_equality') + }) +}) + +describe('rule 2 — JSON↔TS mirrors via TS exports', () => { + it('flags missing TS export for a declared mirror', () => { + const findings = checkJsonTsMirrors( + { + lifecycleFields: [], + jsonTsMirrors: [{ jsonPath: ['facets'], tsExportName: 'MISSING_FACETS' }], + }, + ownerFixtureJson, + ownerTsExports, + ) + expectFinding(findings, 'json_ts_mirror_missing_ts_constant') + }) + + it('flags JSON value drift from TS export', () => { + const findings = checkJsonTsMirrors(ownerSpec, ownerFixtureJson, { + ...ownerTsExports, + WIN_CONSUMPTION_POLICY: 'never_consume_wins', + }) + expectFinding(findings, 'json_ts_mirror_value_mismatch') + }) + + it('flags retyped literals and missing TS assertion in tests', () => { + const literalTest = ` + expect(contract.facets).toEqual(['talk', 'know', 'watch']) + ` + const findings = checkTestSourceMirrors(literalTest, ownerSpec.jsonTsMirrors) + expectFinding(findings, 'json_ts_mirror_literal_in_test') + expectFinding(findings, 'json_ts_mirror_assertion_token_absent') + + const missingMirrorTest = ` + expect(contract.loop).toEqual(['goal', 'sense']) + ` + const missingFindings = checkTestSourceMirrors(missingMirrorTest, [ + { jsonPath: ['loop'], tsExportName: 'OWNER_EXPERIENCE_LOOP' }, + ]) + expectFinding(missingFindings, 'json_ts_mirror_literal_in_test') + }) +}) + +describe('mutation — deliberate fixture breaks must fire the checker', () => { + it('mutation A: lifecycle literal equality in test', () => { + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: ownerFixtureJson, + tsExports: ownerTsExports, + testSource: `expect(contract.status).toBe('design')`, + }) + expectFinding(findings, 'lifecycle_test_literal_equality') + }) + + it('mutation B: lifecycle value outside allowed set', () => { + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: { ...ownerFixtureJson, status: 'production' }, + tsExports: ownerTsExports, + testSource: compliantOwnerTestSource, + }) + expectFinding(findings, 'lifecycle_value_not_in_allowed_set') + }) + + it('mutation C: missing whoMayFlip declaration', () => { + const findings = runContractAssertPolicy({ + spec: { + ...ownerSpec, + lifecycleFields: [ + { + field: 'status', + allowedValues: ['design'], + whoMayFlip: [], + }, + ], + }, + contractJson: ownerFixtureJson, + tsExports: ownerTsExports, + testSource: compliantOwnerTestSource, + }) + expectFinding(findings, 'lifecycle_missing_who_may_flip') + }) + + it('mutation D: missing TS constant for mirror', () => { + const { OWNER_EXPERIENCE_FACETS: _drop, ...partialExports } = ownerTsExports + void _drop + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: ownerFixtureJson, + tsExports: partialExports, + testSource: compliantOwnerTestSource, + }) + expectFinding(findings, 'json_ts_mirror_missing_ts_constant') + }) + + it('mutation E: retyped literal mirror assertion in test', () => { + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: ownerFixtureJson, + tsExports: ownerTsExports, + testSource: ` + expect(['design']).toContain(contract.status) + expect(contract.facets).toEqual(['talk', 'know', 'watch']) + `, + }) + expectFinding(findings, 'json_ts_mirror_literal_in_test') + }) + + it('mutation F: JSON/TS drift on mirrored constant', () => { + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: ownerFixtureJson, + tsExports: { + ...ownerTsExports, + DEFAULT_REQUIRED_WINS_PER_WIDEN: 1, + }, + testSource: compliantOwnerTestSource, + }) + expectFinding(findings, 'json_ts_mirror_value_mismatch') + }) +}) diff --git a/tests/fixtures/contract-assert-policy/owner-experience-v1.json b/tests/fixtures/contract-assert-policy/owner-experience-v1.json new file mode 100644 index 00000000..5e4f55fc --- /dev/null +++ b/tests/fixtures/contract-assert-policy/owner-experience-v1.json @@ -0,0 +1,160 @@ +{ + "id": "owner-experience/v1", + "status": "design", + "spec": "docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md", + "thesis": "Owner owns the goal and the gate; the mubot owns execution and adaptation; one relationship with Talk, Know, and Watch facets bound to a single project outcome.", + "artifactRef": "4cb8d84f-b5b9-4a55-a47c-3af193161b19", + "facets": ["talk", "know", "watch"], + "homeRequiredFacets": ["know", "watch"], + "talkOptionalUntil": "tier1-persistent-mubot-505-revived", + "loop": [ + "goal", + "sense", + "rank", + "act", + "receipt", + "learn", + "adapt", + "gate" + ], + "principles": [ + "one-face", + "goals-not-tasks", + "honest-by-construction", + "earned-autonomy", + "visible-learning", + "owner-owns-goals-and-gates" + ], + "invariants": [ + "owner-owns-goal-mubot-cannot-redefine", + "owner-owns-gate-mubot-cannot-self-verdict", + "receipts-resolved-by-id-not-label", + "progress-never-fake-green", + "earned-autonomy-one-step-with-verified-wins", + "earned-autonomy-wins-consumed-after-widen", + "earned-autonomy-scope-bound-to-project-and-agent", + "lesson-requires-receipt-id", + "talk-v1-tier1-mubot-only-when-present", + "learn-adapt-instinct-requires-port4-prerequisite", + "brain-remains-rank-not-act", + "mechanism-lock-ne-trust-lock" + ], + "talk": { + "v1Runtime": "tier1_persistent_mubot", + "tier1Status": "closed-unmerged-pr-505", + "tier2Status": "dyad-gate-blocked", + "tier2AllowedOnlyAfter": "tier2-user-chat/v1-pass", + "homePolicy": "optional-facet-until-tier1-revived" + }, + "trustLevels": [ + { "level": 0, "autonomy": "suggest" }, + { "level": 1, "autonomy": "draft" }, + { "level": 2, "autonomy": "execute_with_approval" }, + { "level": 3, "autonomy": "execute" } + ], + "earnedAutonomy": { + "maxStepsPerWiden": 1, + "defaultRequiredWins": 3, + "minRequiredWins": 3, + "winVerification": "branded-VerifiedWinRef-from-WinReceiptResolver-WeakSet-registry", + "winConstructor": "verifiedWinRefFromResolver", + "trustBoundary": "injected-WinReceiptResolver-plus-WeakSet-like-ranker-VerifiedReceiptRef", + "winConsumptionPolicy": "mark_consumed_after_successful_widen", + "dedupByReceiptId": true, + "scopeKeysRequired": ["projectId", "agentId"], + "decider": "owner_or_admin_human", + "mubotSelfWiden": false, + "note": "Same three receipts cannot climb the ladder — consumeReceiptIds must be persisted; top of ladder (execute) is NOT auto-gated per autonomyImpliesGate" + }, + "progress": { + "allowedKinds": ["measured", "unmeasured", "unavailable"], + "forbidFakeGreen": true, + "v1Mode": "unmeasured_until_project_kpi", + "metricSourceIdType": "KpiSourceId", + "sourceWiredCallerBoolean": false + }, + "unmetDependencies": [ + { + "id": "tier1-persistent-mubot-505", + "status": "closed-unmerged", + "blocks": ["talk-facet"], + "decision": "talk-optional-on-owner-home-until-revived" + }, + { + "id": "tier2-user-chat", + "status": "dyad-gate-blocked", + "blocks": ["talk-facet-every-member"] + }, + { + "id": "brainport-default-adapter", + "status": "type-only-sealed", + "blocks": ["rank-via-BrainPort"], + "interim": "rankTasks" + }, + { + "id": "project-scoped-kpi-source", + "status": "not-built", + "blocks": ["measured-progress"], + "v1": "unmeasured_until_project_kpi" + }, + { + "id": "model-port-v2-tool-calling", + "status": "not-built", + "blocks": ["tier2-tool-loops"] + }, + { + "id": "pot-monthly-model-ledger", + "status": "display-only", + "blocks": ["honest-talk-budget-story"] + }, + { + "id": "per-project-docs-ui", + "status": "design-only", + "blocks": ["know-facet-full-editor"] + }, + { + "id": "port-4-instinct-memory", + "status": "dormant-branch", + "blocks": ["learn-adapt-instinct"] + }, + { + "id": "learning-ranker", + "status": "under-dyad-gate", + "blocks": ["learn-adapt-instinct"] + }, + { + "id": "structured-project-outcome", + "status": "not-on-projects-table", + "blocks": ["outcome-strip"] + } + ], + "nonGoals": [ + "route-in-this-design-commit", + "migration-applied-in-this-design-commit", + "ui-in-this-design-commit", + "merge-port4-or-ranker-from-this-worktree", + "pretend-modelport-v2-exists", + "cite-dormant-surfaces-as-reuse", + "cite-BrainPort-as-reuse" + ], + "buildSlices": [ + "outcome-model", + "owner-home-shell", + "trust-strip-gate-queue", + "earned-autonomy", + "visible-learning", + "talk-facet-tier2-after-pass", + "know-facet-docs-507", + "learn-adapt-instinct-after-port4" + ], + "suggestedBuildOrder": [ + "trust-strip-gate-queue", + "visible-learning", + "outcome-model", + "earned-autonomy", + "owner-home-shell", + "know-facet-docs-507", + "talk-facet-tier2-after-pass", + "learn-adapt-instinct-after-port4" + ] +} diff --git a/tests/owner-experience-contract.test.ts b/tests/owner-experience-contract.test.ts new file mode 100644 index 00000000..8ce7aa14 --- /dev/null +++ b/tests/owner-experience-contract.test.ts @@ -0,0 +1,521 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { AUTONOMIES } from '../src/types' +import { + DEFAULT_REQUIRED_WINS_PER_WIDEN, + MIN_REQUIRED_WINS_PER_WIDEN, + OWNER_EXPERIENCE_FACETS, + OWNER_EXPERIENCE_LOOP, + OWNER_EXPERIENCE_PRINCIPLES, + OWNER_HOME_REQUIRED_FACETS, + OWNER_TRUST_LADDER, + KPI_SOURCE_IDS, + PROJECT_SCOPED_KPI_SOURCE_IDS, + WIN_CONSUMPTION_POLICY, + isKpiSourceId, + assertOwnerHomeFacets, + assertTalkV1Runtime, + autonomyForTrustLevel, + brainRemainsRankNotAct, + decideEarnedAutonomyWiden, + decideProgressDisplay, + isTalkV1Runtime, + mayEnableInstinctAdapt, + mayMubotRedefineGoal, + mayMubotSelfVerdictGate, + trustLevelForAutonomy, + validateLessonDraft, + verifiedWinRefFromResolver, + type KpiSourceId, + type PermissionCallerKind, + type WinReceiptResolver, +} from '../src/owner/owner-experience-contract' +import { runContractAssertPolicy } from '../src/contracts/contract-assert-policy' + +/** Design-lifecycle statuses this contract doc may legitimately carry. */ +const ALLOWED_CONTRACT_STATUSES = ['design', 'reviewing', 'ready-for-build', 'approved'] as const + +const contract = JSON.parse( + readFileSync(new URL('../docs/owner-experience-v1.json', import.meta.url), 'utf8'), +) as { + id: string + status: string + homeRequiredFacets: string[] + facets: string[] + loop: string[] + principles: string[] + invariants: string[] + talk: { v1Runtime: string; tier2Status: string; homePolicy?: string } + trustLevels: Array<{ level: number; autonomy: string }> + earnedAutonomy: { + maxStepsPerWiden: number + defaultRequiredWins: number + minRequiredWins: number + winVerification: string + winConstructor: string + mubotSelfWiden: boolean + winConsumptionPolicy: string + } + progress: { + allowedKinds: string[] + forbidFakeGreen: boolean + projectScopedKpiSourceIds: string[] + measuredUnreachableInV1: boolean + v1Mode: string + } + unmetDependencies: Array<{ id: string; status: string }> + nonGoals: string[] + buildSlices: string[] +} + +function mintWins( + resolver: WinReceiptResolver, + ids: readonly string[], + scope: { projectId: string; agentId: string }, +) { + return ids.map((id) => { + const ref = verifiedWinRefFromResolver(resolver, id, scope) + if (!('_brand' in ref)) throw new Error(`mint failed: ${JSON.stringify(ref)}`) + return ref + }) +} + +const scope = { projectId: 'proj-a', agentId: 'agent-a' } + +const store: WinReceiptResolver = (id) => { + const known: Record = { + 'wr-1': { polarity: 'win', projectId: 'proj-a', agentId: 'agent-a' }, + 'wr-2': { polarity: 'win', projectId: 'proj-a', agentId: 'agent-a' }, + 'wr-3': { polarity: 'win', projectId: 'proj-a', agentId: 'agent-a' }, + 'wr-miss': { polarity: 'miss', projectId: 'proj-a', agentId: 'agent-a' }, + 'wr-other-proj': { polarity: 'win', projectId: 'sandbox', agentId: 'agent-a' }, + 'wr-other-agent': { polarity: 'win', projectId: 'proj-a', agentId: 'agent-b' }, + } + const row = known[id] + if (!row) return null + return { + receiptId: id, + projectId: row.projectId, + agentId: row.agentId, + polarity: row.polarity, + resolvedAt: '2026-07-27T12:00:00.000Z', + } +} + +describe('owner-experience/v1 contract doc', () => { + it('is the design-status owner-experience contract', () => { + expect(contract.id).toBe('owner-experience/v1') + expect(ALLOWED_CONTRACT_STATUSES).toContain(contract.status) + expect(contract.talk.v1Runtime).toBe('tier1_persistent_mubot') + expect(contract.talk.tier2Status).toBe('dyad-gate-blocked') + expect(contract.earnedAutonomy.mubotSelfWiden).toBe(false) + expect(contract.earnedAutonomy.winVerification).toBe( + 'branded-VerifiedWinRef-from-WinReceiptResolver-WeakSet-registry', + ) + expect(contract.earnedAutonomy.winConsumptionPolicy).toBe(WIN_CONSUMPTION_POLICY) + expect(contract.progress.forbidFakeGreen).toBe(true) + expect(contract.talk.homePolicy).toBe('optional-facet-until-tier1-revived') + expect(contract.nonGoals).toContain('pretend-modelport-v2-exists') + expect(contract.nonGoals).toContain('cite-dormant-surfaces-as-reuse') + expect(contract.nonGoals).toContain('merge-port4-or-ranker-from-this-worktree') + expect(contract.nonGoals).toContain('cite-BrainPort-as-reuse') + }) + + it('matches the pure module facets, loop, and trust ladder', () => { + expect(contract.facets).toEqual([...OWNER_EXPERIENCE_FACETS]) + expect(contract.homeRequiredFacets).toEqual([...OWNER_HOME_REQUIRED_FACETS]) + expect(contract.loop).toEqual([...OWNER_EXPERIENCE_LOOP]) + expect(contract.principles).toEqual([...OWNER_EXPERIENCE_PRINCIPLES]) + expect(contract.earnedAutonomy.defaultRequiredWins).toBe(DEFAULT_REQUIRED_WINS_PER_WIDEN) + expect(contract.earnedAutonomy.minRequiredWins).toBe(MIN_REQUIRED_WINS_PER_WIDEN) + expect(contract.earnedAutonomy.maxStepsPerWiden).toBe(1) + expect(contract.earnedAutonomy.winConstructor).toBe(verifiedWinRefFromResolver.name) + expect(contract.trustLevels.map((row) => row.autonomy)).toEqual([...OWNER_TRUST_LADDER]) + }) + + it('locks measured-unreachable-in-v1 JSON fields against the TS allowlist constant', () => { + expect(contract.progress.projectScopedKpiSourceIds).toEqual([...PROJECT_SCOPED_KPI_SOURCE_IDS]) + expect(PROJECT_SCOPED_KPI_SOURCE_IDS).toEqual([]) + expect(Object.isFrozen(KPI_SOURCE_IDS)).toBe(true) + expect(Object.isFrozen(PROJECT_SCOPED_KPI_SOURCE_IDS)).toBe(true) + expect(Object.isFrozen(AUTONOMIES)).toBe(true) + expect(contract.progress.measuredUnreachableInV1).toBe(true) + expect(contract.progress.v1Mode).toBe('unmeasured_until_project_kpi') + }) + + it('enforces contract-assert-policy against the real JSON + suite (not a fixture)', () => { + const testSource = readFileSync(new URL(import.meta.url), 'utf8') + const findings = runContractAssertPolicy({ + spec: { + lifecycleFields: [ + { + field: 'status', + allowedValues: ALLOWED_CONTRACT_STATUSES, + whoMayFlip: ['loom', 'kasra', 'athena-gate'], + }, + ], + jsonTsMirrors: [ + { jsonPath: ['facets'], tsExportName: 'OWNER_EXPERIENCE_FACETS' }, + { jsonPath: ['homeRequiredFacets'], tsExportName: 'OWNER_HOME_REQUIRED_FACETS' }, + { jsonPath: ['loop'], tsExportName: 'OWNER_EXPERIENCE_LOOP' }, + { jsonPath: ['principles'], tsExportName: 'OWNER_EXPERIENCE_PRINCIPLES' }, + { + jsonPath: ['earnedAutonomy', 'defaultRequiredWins'], + tsExportName: 'DEFAULT_REQUIRED_WINS_PER_WIDEN', + }, + { + jsonPath: ['earnedAutonomy', 'minRequiredWins'], + tsExportName: 'MIN_REQUIRED_WINS_PER_WIDEN', + }, + { + jsonPath: ['earnedAutonomy', 'winConsumptionPolicy'], + tsExportName: 'WIN_CONSUMPTION_POLICY', + }, + { + jsonPath: ['progress', 'projectScopedKpiSourceIds'], + tsExportName: 'PROJECT_SCOPED_KPI_SOURCE_IDS', + }, + ], + }, + contractJson: contract as unknown as Record, + tsExports: { + OWNER_EXPERIENCE_FACETS, + OWNER_HOME_REQUIRED_FACETS, + OWNER_EXPERIENCE_LOOP, + OWNER_EXPERIENCE_PRINCIPLES, + DEFAULT_REQUIRED_WINS_PER_WIDEN, + MIN_REQUIRED_WINS_PER_WIDEN, + WIN_CONSUMPTION_POLICY, + PROJECT_SCOPED_KPI_SOURCE_IDS, + }, + testSource, + }) + expect(findings).toEqual([]) + }) + + it('locks the load-bearing invariants and names unmet dependencies', () => { + for (const inv of [ + 'owner-owns-goal-mubot-cannot-redefine', + 'owner-owns-gate-mubot-cannot-self-verdict', + 'receipts-resolved-by-id-not-label', + 'progress-never-fake-green', + 'earned-autonomy-one-step-with-verified-wins', + 'earned-autonomy-wins-consumed-after-widen', + 'earned-autonomy-scope-bound-to-project-and-agent', + 'lesson-requires-receipt-id', + 'talk-v1-tier1-mubot-only-when-present', + 'learn-adapt-instinct-requires-port4-prerequisite', + 'brain-remains-rank-not-act', + 'mechanism-lock-ne-trust-lock', + ]) { + expect(contract.invariants).toContain(inv) + } + const unmetIds = contract.unmetDependencies.map((row) => row.id) + expect(unmetIds).toContain('tier1-persistent-mubot-505') + expect(unmetIds).toContain('port-4-instinct-memory') + expect(unmetIds).toContain('tier2-user-chat') + expect(unmetIds).toContain('model-port-v2-tool-calling') + expect(unmetIds).toContain('brainport-default-adapter') + expect(contract.buildSlices[0]).toBe('outcome-model') + }) +}) + +describe('owner goal and gate ownership', () => { + it('forbids mubot from redefining the outcome or self-verdicting (parametrized)', () => { + expect(mayMubotRedefineGoal('mubot_or_agent')).toBe(false) + expect(mayMubotRedefineGoal('owner_or_admin_human')).toBe(false) + expect(mayMubotSelfVerdictGate('mubot_or_agent')).toBe(false) + expect(mayMubotSelfVerdictGate('owner_or_admin_human')).toBe(false) + }) + + it('throws on an invalid callerKind instead of silently returning false (mutation-proven branch)', () => { + const invalidCallerKind = 'superadmin' as unknown as PermissionCallerKind + expect(() => mayMubotRedefineGoal(invalidCallerKind)).toThrow( + /owner_experience_unknown_caller_kind/, + ) + expect(() => mayMubotSelfVerdictGate(invalidCallerKind)).toThrow( + /owner_experience_unknown_caller_kind/, + ) + }) + + it('allows Talk v1 only for Tier-1 mubot runtime', () => { + expect(isTalkV1Runtime('tier1_persistent_mubot')).toBe(true) + expect(isTalkV1Runtime('tier2_stateless_user_chat')).toBe(false) + expect(() => assertTalkV1Runtime('tier1_persistent_mubot')).not.toThrow() + expect(() => assertTalkV1Runtime('tier2_stateless_user_chat')).toThrow( + /owner_experience_talk_v1_tier1_only/, + ) + }) +}) + +describe('earned autonomy trust-locks', () => { + it('maps trust levels onto the existing Autonomy ladder', () => { + expect(trustLevelForAutonomy('suggest')).toBe(0) + expect(trustLevelForAutonomy('execute')).toBe(3) + expect(autonomyForTrustLevel(2)).toBe('execute_with_approval') + }) + + it('drift-locks OWNER_TRUST_LADDER against the canonical AUTONOMIES set (order may differ, membership must not)', () => { + expect(OWNER_TRUST_LADDER.length).toBe(AUTONOMIES.length) + expect(new Set(OWNER_TRUST_LADDER)).toEqual(new Set(AUTONOMIES)) + }) + + it('refuses caller-written resolved_by_id labels and forged brands (WeakSet)', () => { + expect( + decideEarnedAutonomyWiden({ + projectId: 'proj-a', + agentId: 'agent-a', + current: 'suggest', + proposed: 'draft', + actingPrincipalKind: 'owner_or_admin_human', + wins: [ + { receiptId: 'wr-1', verification: 'resolved_by_id' }, + { receiptId: 'wr-2', verification: 'resolved_by_id' }, + { receiptId: 'wr-3', verification: 'resolved_by_id' }, + ], + requiredWins: 3, + consumedReceiptIds: [], + }), + ).toEqual({ ok: false, reason: 'unverified_win_label' }) + + // Cosmetic _brand without registry — same class as ranker forge. + const forged = { + _brand: 'VerifiedWinRef' as const, + receiptId: 'never-resolved', + projectId: 'proj-a', + agentId: 'agent-a', + polarity: 'win' as const, + resolvedAt: '2026-07-27T12:00:00.000Z', + } + expect( + decideEarnedAutonomyWiden({ + projectId: 'proj-a', + agentId: 'agent-a', + current: 'suggest', + proposed: 'draft', + actingPrincipalKind: 'owner_or_admin_human', + wins: [forged, forged, forged], + requiredWins: 3, + consumedReceiptIds: [], + }), + ).toEqual({ ok: false, reason: 'unverified_win_label' }) + }) + + it('widens one step only from resolver-minted wins matching scope', () => { + const wins = mintWins(store, ['wr-1', 'wr-2', 'wr-3'], scope) + expect( + decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, + current: 'suggest', + proposed: 'draft', + actingPrincipalKind: 'owner_or_admin_human', + wins, + requiredWins: 3, + consumedReceiptIds: [], + }), + ).toEqual({ + ok: true, + from: 'suggest', + to: 'draft', + verifiedWinCount: 3, + consumeReceiptIds: ['wr-1', 'wr-2', 'wr-3'], + }) + }) + + it('fails closed on self-widen, skip, miss polarity, scope laundering, and consumption', () => { + expect( + decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, + current: 'suggest', + proposed: 'draft', + actingPrincipalKind: 'mubot_or_agent', + wins: mintWins(store, ['wr-1'], scope), + requiredWins: 1, + consumedReceiptIds: [], + }), + ).toEqual({ ok: false, reason: 'mubot_cannot_self_widen' }) + + expect( + decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, + current: 'suggest', + proposed: 'execute', + actingPrincipalKind: 'owner_or_admin_human', + wins: mintWins(store, ['wr-1', 'wr-2', 'wr-3'], scope), + requiredWins: 3, + consumedReceiptIds: [], + }), + ).toEqual({ ok: false, reason: 'skip_not_allowed' }) + + expect(verifiedWinRefFromResolver(store, 'wr-miss', scope)).toEqual({ + ok: false, + reason: 'not_a_win', + }) + expect(verifiedWinRefFromResolver(store, 'wr-other-proj', scope)).toEqual({ + ok: false, + reason: 'scope_mismatch', + }) + expect(verifiedWinRefFromResolver(store, 'wr-other-agent', scope)).toEqual({ + ok: false, + reason: 'scope_mismatch', + }) + + // Win laundering: mint against sandbox, attempt widen on customer project. + const sandboxWins = mintWins( + store, + ['wr-other-proj'], + { projectId: 'sandbox', agentId: 'agent-a' }, + ) + expect( + decideEarnedAutonomyWiden({ + projectId: 'proj-a', + agentId: 'agent-a', + current: 'suggest', + proposed: 'draft', + actingPrincipalKind: 'owner_or_admin_human', + wins: sandboxWins, + requiredWins: 3, + consumedReceiptIds: [], + }), + ).toEqual({ ok: false, reason: 'scope_mismatch' }) + + const wins = mintWins(store, ['wr-1', 'wr-2', 'wr-3'], scope) + expect( + decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, + current: 'draft', + proposed: 'execute_with_approval', + actingPrincipalKind: 'owner_or_admin_human', + wins, + requiredWins: 3, + consumedReceiptIds: ['wr-1', 'wr-2', 'wr-3'], + }), + ).toEqual({ ok: false, reason: 'win_already_consumed' }) + + // Dedup: same receipt thrice does not count as three wins. + const one = mintWins(store, ['wr-1'], scope)[0] + expect( + decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, + current: 'suggest', + proposed: 'draft', + actingPrincipalKind: 'owner_or_admin_human', + wins: [one, one, one], + requiredWins: 3, + consumedReceiptIds: [], + }), + ).toEqual({ ok: false, reason: 'insufficient_verified_wins' }) + + // Floor: requiredWins < 3 rejected. + expect( + decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, + current: 'execute_with_approval', + proposed: 'execute', + actingPrincipalKind: 'owner_or_admin_human', + wins: mintWins(store, ['wr-1'], scope), + requiredWins: 1, + consumedReceiptIds: [], + }), + ).toEqual({ ok: false, reason: 'required_wins_invalid' }) + }) +}) + +describe('honest progress and visible learning', () => { + it('v1 ships unmeasured — agent KPI sources cannot measure a project', () => { + expect( + decideProgressDisplay({ + outcome: { + statement: 'Booked calls', + metric: { sourceId: 'task_counter', target: 20 }, + measurementMode: 'unmeasured_until_project_kpi', + }, + signal: { ok: true, value: 20, sourceId: 'task_counter' }, + }), + ).toEqual({ kind: 'unmeasured' }) + }) + + it('measurementMode "measured" is unreachable in v1 by construction — PROJECT_SCOPED_KPI_SOURCE_IDS is empty', () => { + expect(PROJECT_SCOPED_KPI_SOURCE_IDS.length).toBe(0) + // Every allowlisted KpiSourceId is still agent/tenant scoped, not project + // scoped — a matching signal on an allowlisted source must NOT produce a + // green bar. This is the exact wrong-scope case BLOCK-B was about. + for (const sourceId of ['github_prs', 'task_counter'] as const) { + expect( + decideProgressDisplay({ + outcome: { + statement: 'PRs', + metric: { sourceId, target: 10 }, + measurementMode: 'measured', + }, + signal: { ok: true, value: 5, sourceId }, + }), + ).toEqual({ kind: 'unmeasured' }) + } + }) + + it('isKpiSourceId locks allowlist contents (mutation-detectable)', () => { + expect(isKpiSourceId('ghl_booked_calls')).toBe(false) + expect([...KPI_SOURCE_IDS]).toEqual(['task_counter', 'github_prs']) + }) + + it('decideProgressDisplay returns unmeasured for a cast non-allowlisted sourceId', () => { + expect( + decideProgressDisplay({ + outcome: { + statement: 'Booked calls', + metric: { sourceId: 'ghl_booked_calls' as unknown as KpiSourceId, target: 10 }, + measurementMode: 'measured', + }, + signal: null, + }), + ).toEqual({ kind: 'unmeasured' }) + }) + + it('requires receipt id on lessons', () => { + expect( + validateLessonDraft({ + receiptId: '', + polarity: 'miss', + summary: 'Long form killed sign-ups', + sourceSchema: 'mupot.lessons_capture/v1', + }), + ).toEqual({ ok: false, reason: 'receipt_id_required' }) + + const good = validateLessonDraft({ + receiptId: 'rcpt-9', + polarity: 'miss', + summary: 'Long form killed sign-ups', + sourceSchema: 'mupot.lessons_capture/v1', + }) + expect(good.ok).toBe(true) + if (good.ok) expect(good.lesson.receiptId).toBe('rcpt-9') + }) +}) + +describe('prerequisites and home composition', () => { + it('blocks instinct Adapt until Port-4 is live and ranker has passed', () => { + expect( + mayEnableInstinctAdapt({ port4LiveOnMain: false, learningRankerDyadPassed: false }), + ).toEqual({ ok: false, reason: 'port4_not_live' }) + expect( + mayEnableInstinctAdapt({ port4LiveOnMain: true, learningRankerDyadPassed: false }), + ).toEqual({ ok: false, reason: 'learning_ranker_not_passed' }) + expect( + mayEnableInstinctAdapt({ port4LiveOnMain: true, learningRankerDyadPassed: true }), + ).toEqual({ ok: true }) + }) + + it('requires Know+Watch; Talk optional while #505 is closed-unmerged', () => { + expect(() => assertOwnerHomeFacets(['know', 'watch'])).not.toThrow() + expect(() => assertOwnerHomeFacets(['talk', 'know', 'watch'])).not.toThrow() + expect(() => assertOwnerHomeFacets(['talk', 'watch'])).toThrow(/owner_experience_facet_missing/) + expect(brainRemainsRankNotAct()).toBe(true) + }) +})