diff --git a/docs/seed-gate/role-cards-seed-diff.md b/docs/seed-gate/role-cards-seed-diff.md new file mode 100644 index 00000000..21c8b0e0 --- /dev/null +++ b/docs/seed-gate/role-cards-seed-diff.md @@ -0,0 +1,249 @@ +# Seed gate — the four role cards, diffed for signature (D3, #806) + +**Status: SIGNED 2026-09-05 (accept-seed, by chat directive "do as you rec" — see the Signature section; the fixture publications remain held under the re-authoring decision).** Prepared by the implementing cast (slice 2, worktree `slice2-role-cards`); Aaron performed and signed. + +This is the human diff the seed gate requires (spec `spec-20260905-063000` D3, +issue #806, obligation O2). The four director-cast role cards — +**hypothesizer, experimenter, analyzer, implementer** — were seeded into the +repo **verbatim** from the live deployed staging artifacts on the seed +machine (erlich's server staging root), because those artifacts were the only +live copies: the director's casts have run on them since 2026-08-18 (this +campaign's own implementer briefing came from there), while the #758 +"worker-card" authoring at the same repo paths never deployed. Deployed +artifacts are plausibly per-machine drifted — garbage-in must never be +enshrined as the tested baseline — so **nothing the diff flags as divergent is +pinned by the parity suite until this document is signed.** The flagged +anchors are held in `role_cards_parity.test.ts` as named skips that cite this +document; a silent pass does not exist anywhere in the suite. + +## The seed of record + +- Provenance (machine, paths, per-card sha256, capture times): + `packages/extension/agents/.seed-provenance.json` — the parity suite + enforces that the repo cards stay byte-identical to these hashes, or that + any drift carries a recorded, signed amendment. +- Seed machine: **erlich** (the canonical server — the vault-visible machine + the pin-behind-HEAD check also runs on). +- The diff baseline for each card below is the **engine-neutral definition** + in the private amicissimo vault's agent records, pinned at amicissimo + revision `5c6a1cd0d5bd240e07f6bef6467986e2fb41d7a7` (the pin record + `test/fixtures/vault-agents/pin.json` carries this revision and the + per-definition digests AT that revision — provenance without content). +- **Review B1 (PR #811) hold:** the full-definition fixtures are NOT + published in this repo pending the signature — the committed copies failed + the amended content policy's per-line usage-vs-internals test (the + engineer definition's src/ module tree, how-to-extend recipe, and + internals sections; the experimenter definition's literal internal host + paths; see the two bold fixture flags below). The nightly pin check + verifies the pin against the vault revision directly, so the hold costs + nothing in coverage; the fixture publications convert with the signature. + +## Overlap map + +| Seeded card (opencode binding) | Engine-neutral definition | Verdict | +|---|---|---| +| implementer | `vault/agents/engineer.md` | **OVERLAPS** — the card self-describes as "the Engineer role's binding for the opencode engine" | +| experimenter | `vault/agents/experimenter.md` | **OVERLAPS** (same role name, two eras — see flags) | +| hypothesizer | — | **NO VAULT COUNTERPART** (nearest kin: `researcher.md`, a different role — see notes) | +| analyzer | — | **NO VAULT COUNTERPART** (nearest kin: `librarian.md`, a different role — see notes) | + +--- + +## 1. implementer ↔ `vault/agents/engineer.md` (293 lines @ pin) + +### Coherent overlap (unflagged — pinned by the parity suite now) + +Both texts carry the same operative semantics on: + +- **Delegated TDD leaf** — card: "invoke the **`implement-issue`** skill with + `--orchestrated`"; vault (develop mode): "delegates to the + `/implement-issue` leaf… run `/implement-issue {issue} --orchestrated`". +- **Worktree/branch discipline** — card: "Work ONLY on the caller-provided + worktree branch"; vault: "All changes on branches, never on main", and in + develop mode "Do NOT `git checkout` a different branch… stay on the + harness branch". +- **Test protection** — card: "never delete, skip, or mark tests broken to + force green"; vault: "NEVER delete test files or remove test cases" / + "Never skip tests or mark them as `@test_broken` to make the suite pass". +- **The structured return contract** — both return the same yaml keys + (`issue, status, branch, commit_shas, ac_results, notes`); the vault def + §4a says the Engineer returns "the leaf's structured contract verbatim". +- **Bounded retries, then escalate** — card: "a RED that won't go green after + its retry cycles is a `failed` return"; vault §5.1: "up to 2 retry cycles… + report `status: tests-failed`". + +### Flagged for signature (divergent — parity pins BLOCK until signed) + +Each flag below carries the parity suite's key (`role_cards_parity.test.ts` +pins the skips against these keys verbatim — a flag that leaves this document +breaks the suite, and a pin without a flag here cannot exist). + +1. **`implementer ↔ engineer: merge/PR governance` — the sharpest divergence.** + The seeded card never + opens PRs, never merges, never pushes ("the walk owns all lifecycle"), + matching the orchestrated walk and the campaign invariant that merges of + non-green work are human-only. The vault definition's frontmatter says + "**Auto-merges when all quality gates pass**" and §3.4 has the engineer + pushing branches and opening PRs per task (its experiment-mode workflow). + *Direction per the spec's constraint of record:* repo wins for the + shipped binding — the vault def's auto-merge line predates the + orchestrated walk and reads stale against it. Signature should either + accept the shipped never-merge binding as the coherent one (recommended — + the vault def then needs a vault-side follow-up edit, out of this slice's + scope) or amend the card. +2. **`implementer ↔ engineer: scope perimeter`.** The card is one-issue-slice-per-cast, worktree-bound; + the vault def additionally carries the standalone experiment-mode + engineering brief (layer skills, multi-package changes, PR lifecycle). + Same role, two modes of operation; the vault def is the wider contract. + Not a contradiction — flagging so the signature sees it. +3. **Card-only clauses (no vault counterpart clause):** the `EXHAUSTED:` + step-exhaustion protocol and the checkout-conflict STOP rule exist only + in the shipped card. Coherent by addition; nothing to reconcile. + +--- + +## 2. experimenter ↔ `vault/agents/experimenter.md` (624 lines @ pin) + +### Coherent overlap (unflagged — pinned now) + +- **Brief-driven execution** — card: executes ONE reviewed experiment from + the briefing; vault: "receive an experiment brief…, write a Julia + optimization script, execute it… report back". +- **Numbers-grounded reporting** — card: "Debrief with NUMBERS ONLY: what + ran, the key values"; vault: parses the run's output markers + (`AMICO_RESULT_*`) and reports them back. + +### Flagged for signature (divergent — parity pins BLOCK until signed) + +1. **`experimenter ↔ experimenter: self-grading and self-promotion` — the core divergence.** The vault + definition has the experimenter *grade its own result* ("Determine result + status by comparing fidelity to the catalog incumbent") and *promote its + own pulses* ("If the result is a new best: save the pulse to the catalog + with incremented version"). The seeded card forbids exactly that: "You do + NOT declare confirm/refute — the parent, the gates, and the analyzer do + that"; "NEVER grade your own result, and never polish a number". The + shipped governance is the anti-gaming loop of record (verdicts from gates, + promotion human-only); the vault def is the pre-loop Phase-2-era contract. + *Direction:* repo wins for the binding; the vault def is stale on this + point and should be re-authored vault-side (follow-up, not this slice). +2. **`experimenter ↔ experimenter: environment/checkout discipline`.** The card runs in an assigned + isolated env per `sessions/CHECKOUTS.md` and refuses silent checkout + switches; the vault def writes into `amico/scratchpad/{session_id}` with + no checkout registry. The shipped card is the current fleet reality. +3. **`experimenter ↔ experimenter: artifact contract`.** The card writes raw artifacts + its own + experiment note with house frontmatter (setup, raw numbers, gotchas, + honest deviations); the vault def appends TSV rows and saves catalog + pulses. Different downstream contracts — the analyzer (raw artifacts + only) depends on the shipped one. +4. **Card-only clauses:** ledger-write prohibition (discipline, not + permission), the smoke-run-before-long-run rule, the anti-gaming contract + (CRN pairing, FD sanity gates), and `EXHAUSTED:`. Coherent by addition. + +--- + +## 3. hypothesizer — NO vault counterpart + +The amicissimo vault carries no hypothesizer definition. The nearest kin is +`vault/agents/researcher.md` — but the researcher is the **decider** ("the +scientific decision-maker: you decide what to optimize next") who returns ONE +engineering brief, while the seeded hypothesizer is a **read-only proposer** +(ranked hypotheses + spec-card drafts for the director to pick from, and +"ideas are your only product"). Different governance, different output, and +the vault def is itself Claude-Code-era (frontmatter tool lists, model pins). +No pin is possible without inventing a mapping — so the parity suite records +the no-counterpart state explicitly rather than pinning a false overlap. +*For the signature* — suite flag: **`hypothesizer ↔ vault counterpart`**: +confirm no-counterpart (default), or name researcher.md +as the pin target with the governance delta above adjudicated first. + +## 4. analyzer — NO vault counterpart + +No analyzer definition in the vault. The nearest kin is `librarian.md` — both +"classify failures, extract insights" — but the librarian WRITES curated notes +and catalog entries, while the seeded analyzer is **read-only by permission**, +grounded in RAW artifacts (never the experimenter's transcript), and PROPOSES +ledger verdicts the gates + parent commit. The shipped analyzer's +raw-artifacts-only evidence rule has no vault-side precedent to diff against. +*For the signature* — suite flag: **`analyzer ↔ vault counterpart`**: +confirm no-counterpart (default), or name librarian.md +as the pin target with the write/propose delta adjudicated first. + +--- + +## 5. The fixture publications themselves — held pending signature (review B1) + +The two overlapping definitions above were initially committed verbatim as +parity fixtures. The independent review of PR #811 found both committed +copies fail the amended content policy's per-line usage-vs-internals test +(ADR-0011's categories) on a PUBLIC repo, so **both fixture publications are +held pending-signature alongside the flagged content** — removed from the +repo, their parity pins held as named skips in the same gate mechanism, and +nothing from the vault definitions is published-verbatim or pinned before +this signature decides: + +1. **`fixture publication: engineer.md (full vault definition)`** — the + "Important Paths" src/ module tree, the how-to-extend recipe ("follow the + pattern of `TransmonSystem`…"), the "Complex Internals" architecture + section, and the roadmap lines ("later Legato", "Legato is a stub") are + package-internals categories, not usage. +2. **`fixture publication: experimenter.md (full vault definition)`** — + literal internal host paths (`/home/aaron/harmoniqs/…`) are + cloud-infrastructure/roadmap-adjacent operational detail, not usage. + +**Recommended follow-up for the signature's morning decision (vault-side +work, its own issue + PR under the amicissimo gate — never edited from this +slice):** re-author both definitions at their source in amicissimo — move +the package-internals categories out of the engine-neutral contracts (they +belong in the layer/dev skills' vault-side homes) — then re-take the +fixtures + the pin record at the NEW revision and publish. The nightly +pin-behind-HEAD check guards the interim: it verifies the current pin +against the vault revision directly (no published copy needed) and files a +chore issue on drift. + +--- + +## What the signature authorizes + +Signing **accept-seed** (or accept-with-amendments) does two things: + +1. The parity suite's pending-signature skips convert to live pins — the + divergences above are adjudicated as the coherent overlap of record, and + any card amendment lands with `amended: true` + `amendment_signed_by` in + `.seed-provenance.json` (the seed-integrity test enforces the record; an + unsigned tree cannot claim an amendment). +2. The fixture publications decide (section 5): either republish the + definitions at the pinned revision as-signed, or — recommended — run the + vault re-authoring follow-up first and re-take fixtures + pin at the new + revision. Until one of those lands, the fixture halves of the parity pins + stay held (the pin record stays `pending-signature`). +3. The campaign record (this campaign's ledger §obligations, O2) closes with + the seed machine's provenance hash and this signature. + +Until then: the seeded cards ARE live repo sources (staging, upgrade, and the +doctor treat them exactly like the director cards), machines carrying older +deployed copies read stale until upgraded, and the flagged parity anchors stay +skipped with reasons that cite this file — never silently passed, never +silently pinned. + +## Signature + +- [x] **Signed: Aaron Trowbridge** — by chat directive ("do as you rec", + 2026-09-05, this campaign's session ledger; the PI read the rendered + packet and delegated to the recorded recommendations; the directive is + the human decision and this record says so plainly). Date: 2026-09-05 +- Decision: **`accept-seed`** (no amendments — the cards stand as seeded; + `.seed-provenance.json` stays `amended: false`) +- Per-card adjudications (only where flagged): + - implementer ↔ engineer: merge/PR governance — shipped never-merge binding is the coherent overlap? **YES** (repo wins; the vault def's auto-merge line reads stale against the orchestrated walk — vault re-authoring follow-up) + - implementer ↔ engineer: scope perimeter acknowledged? **YES** (two modes of operation, one role) + - experimenter ↔ experimenter: shipped never-grade/never-promote binding is the coherent overlap? **YES** (repo wins; the vault def is the pre-loop Phase-2-era contract — vault re-authoring follow-up) + - hypothesizer: no-counterpart confirmed (or pin target named)? **CONFIRMED no-counterpart** (researcher.md is the decider, not a read-only proposer — no false overlap pinned) + - analyzer: no-counterpart confirmed (or pin target named)? **CONFIRMED no-counterpart** (librarian.md writes; the analyzer is read-only — no false overlap pinned) +- Fixture publications (section 5, review B1): republish as-signed, or run + the vault re-authoring follow-up first and re-take fixtures + pin? + **VAULT RE-AUTHORING FIRST** — the fixture hold CONTINUES by decision + (not by unsignedness): the pin's `fixture_publication` reads + `pending-re-authoring`; the amicissimo re-authoring issue (its own gate) + moves the package-internals categories out of the two engine-neutral + definitions at their source, then the fixtures re-take at the new + revision and publish. The nightly pin check covers the interim. diff --git a/ops/README.md b/ops/README.md index 17e0377b..0bf096b8 100644 --- a/ops/README.md +++ b/ops/README.md @@ -27,16 +27,20 @@ rename(2) over the running executable — atomic, no stop, ever. | `fleet-alert.sh` | every 15 min (`co.harmoniqs.fleet-alert`) | `fleet-status.json`, state file | **Slack `#fleet`** — device transitions only (noise-gated; always-on hosts `mini erlich` notify, laptops never do); down->24h re-reminds once daily | | `papers-digest/daily.sh` | daily ~09:00 (`co.harmoniqs.amicode-papers-digest`) | the frozen bundle | **Slack `#papers`** — top-5 quant-ph digest; appends to `papers-digest/log.txt` | | `skill-freshness/run-skill-freshness.sh` | daily ~04:30 (`co.harmoniqs.skill-freshness`) | the three skill surfaces (repo public library, armonissima vault library, server staging tree), Julia package checkouts, the `#586` lint CLI | receipt line appended to `~/.amico/server/upgrade-receipts/upgrade-receipts.jsonl`; on drift, the tracking issue "Skill freshness report (nightly)" in `harmoniqs/armonissima` (created once, then commented); reports under `skill-freshness/reports/` | +| `role-parity/run-role-parity-check.sh` | daily ~04:45 (**erlich** — the vault-visible machine; systemd pair `co.harmoniqs.role-parity.{service,timer}` under `ops/systemd/`. The launchd plist is the macOS-**secondary** declaration) | the role-card parity pin record (`packages/extension/test/fixtures/vault-agents/pin.json` — the engine-neutral role definitions at their pinned amicissimo revision, provenance without content per review B1) vs the amicissimo vault checkout, via the `#806` check CLI with `--fetch` (the drift compare never runs against stale remote-tracking refs; a fetch failure is the named unknown `vault-unfetchable`, never a green receipt) | receipt line (kind `role-parity`) appended to `~/.amico/server/upgrade-receipts/upgrade-receipts.jsonl`; on drift (a pinned definition changed past the pin, a fixture mismatched its record, or the pin orphaned), the chore issue "Role-card parity pin behind the vault (nightly)" in `harmoniqs/amicode` (created once, then commented); a CLI pre-flight/runtime failure appends a named `check-failed` receipt, never an empty status | The launchd plists themselves are versioned alongside (`ops/launchd/`) — reference copies; installing them is a one-time `launchctl load` on the mini (paths inside are -absolute to `/Users/aaron`). +absolute to `/Users/aaron`). The linux-side cadence units are versioned at +`ops/systemd/` — one-time install on erlich: copy the `.service` + `.timer` into +`~/.config/systemd/user/`, then `systemctl --user daemon-reload && systemctl --user +enable --now .timer`. Never edit the installed copies; this repo is the source. ## Runtime state (NOT in this repo, never overwritten by deploy) `fleet-status.json`, `fleet-status.guard-state`, `fleet-alert.state`, `fleet-alert.launchd.{out,err}`, `papers-digest/{log.txt,launchd.*}`, -`skill-freshness/{reports/,launchd.*}` — all live under +`skill-freshness/{reports/,launchd.*}`, `role-parity/{launchd.*,systemd.*}` — all live under `~/.amico/ops/` on the mini and belong to the running system. `install.sh` touches none of them. diff --git a/ops/install.sh b/ops/install.sh index 074427b3..d835b9a3 100755 --- a/ops/install.sh +++ b/ops/install.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash -# install.sh — deploy the versioned ops scripts to ~/.amico/ops/ on the mini. -# Idempotent; copies scripts ONLY (never launchd plists, never runtime state, -# never the papers-digest frozen bundle — see ops/README.md for those). +# install.sh — deploy the versioned ops scripts to ~/.amico/ops/ (the mini +# and the linux fleet hosts — the role-parity cadence runs on erlich). +# Idempotent; copies scripts ONLY (never launchd plists, never the systemd +# units, never runtime state, never the papers-digest frozen bundle — see +# ops/README.md for those and for the one-time unit installation). # hunt.sh is additionally a FLEET-HOST deploy (erlich): copy it over — see the # hunts section of ops/README.md. set -euo pipefail @@ -9,7 +11,7 @@ set -euo pipefail DEST="$HOME/.amico/ops" SRC="$(cd "$(dirname "$0")" && pwd)" -mkdir -p "$DEST/papers-digest" "$DEST/skill-freshness" +mkdir -p "$DEST/papers-digest" "$DEST/skill-freshness" "$DEST/role-parity" install -m 0755 "$SRC/fleet-status.sh" "$DEST/fleet-status.sh" install -m 0755 "$SRC/fleet-alert.sh" "$DEST/fleet-alert.sh" @@ -18,12 +20,14 @@ install -m 0755 "$SRC/hub-upgrade-smoke.sh" "$DEST/hub-upgrade-smoke.sh" install -m 0755 "$SRC/papers-digest/daily.sh" "$DEST/papers-digest/daily.sh" install -m 0755 "$SRC/hunt.sh" "$DEST/hunt.sh" install -m 0755 "$SRC/skill-freshness/run-skill-freshness.sh" "$DEST/skill-freshness/run-skill-freshness.sh" +install -m 0755 "$SRC/role-parity/run-role-parity-check.sh" "$DEST/role-parity/run-role-parity-check.sh" echo "deployed to $DEST:" echo " fleet-status.sh (launchd co.harmoniqs.fleet-status, every 5 min)" echo " fleet-alert.sh (launchd co.harmoniqs.fleet-alert, every 15 min)" echo " papers-digest/daily.sh (launchd co.harmoniqs.amicode-papers-digest, daily ~09:00)" echo " skill-freshness/run-skill-freshness.sh (launchd co.harmoniqs.skill-freshness, daily ~04:30)" +echo " role-parity/run-role-parity-check.sh (erlich: systemd co.harmoniqs.role-parity.timer, daily ~04:45 — see ops/README.md; macOS machines: the launchd plist)" echo " hunt.sh (on demand — the hardened hunt wrapper; also copy to erlich, see ops/README.md)" -echo "state files, plists, and the frozen bundle were left untouched." -echo "to activate before the next interval: launchctl kickstart -k gui/$(id -u)/" +echo "state files, plists, systemd units, and the frozen bundle were left untouched." +echo "to activate before the next interval: launchctl kickstart -k gui/$(id -u)/ (or on erlich: systemctl --user start co.harmoniqs.role-parity.service)" diff --git a/ops/launchd/co.harmoniqs.role-parity.plist b/ops/launchd/co.harmoniqs.role-parity.plist new file mode 100644 index 00000000..5272e1d6 --- /dev/null +++ b/ops/launchd/co.harmoniqs.role-parity.plist @@ -0,0 +1,15 @@ + + + + Labelco.harmoniqs.role-parity + ProgramArguments + /bin/bash + /Users/aaron/.amico/ops/role-parity/run-role-parity-check.sh + + StartCalendarInterval + Hour4 + Minute45 + + StandardOutPath/Users/aaron/.amico/ops/role-parity/launchd.out + StandardErrorPath/Users/aaron/.amico/ops/role-parity/launchd.err + diff --git a/ops/role-parity/run-role-parity-check.sh b/ops/role-parity/run-role-parity-check.sh new file mode 100755 index 00000000..a1bd3096 --- /dev/null +++ b/ops/role-parity/run-role-parity-check.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# run-role-parity-check.sh — the nightly role-parity pin cadence (amicode#806, +# obligation O8: the pin-behind-HEAD check rides the doctor's fleet cadence +# on the vault-visible machine and files a chore issue on drift — a pin is +# only loud if something runs it). +# +# Runs the pin check CLI (packages/extension/scripts/role_parity_check.mts) +# against the role-card parity fixtures' pin record +# (packages/extension/test/fixtures/vault-agents/pin.json — the engine-neutral +# role definitions the parity suite pinned, at the amicissimo vault revision +# it pinned) and the local amicissimo vault checkout: +# +# current the pinned definitions are unchanged at the vault ref +# behind-head a pinned definition changed past the pinned revision +# fixture-mismatch a committed fixture no longer matches its record +# pin-orphaned the pinned revision left the vault's history +# vault-absent this machine cannot see the vault checkout (honest +# skip, exit 0 — the fixtures remain the self-contained +# pin; only the vault-visible machine can check freshness) +# +# Drift tracking (real runs only): behind-head / fixture-mismatch / +# pin-orphaned → exactly one chore issue in the amicode repo, created on +# first drift (exact title "Role-card parity pin behind the vault (nightly)") +# and updated (commented) on subsequent drifts — searched before creating, +# never duplicated. A gh failure notes issue_update_failed in the receipt and +# does NOT change the exit code. The exact-title search covers OPEN issues +# only: a human-closed tracker is history — the next drift opens a fresh epoch. +# +# FRESHNESS REQUIRES THE FETCH (review B2): the wrapper passes --fetch to the +# check CLI, which fetches the ref's remote before comparing — local +# remote-tracking refs only move when something fetches, so without this the +# nightly compare would run against stale knowledge. A fetch failure reads +# as the named unknown vault-unfetchable — never a green receipt. +# +# WHERE IT RUNS: the vault-visible machine — erlich (linux, systemd pair at +# ops/systemd/co.harmoniqs.role-parity.{service,timer}); the launchd plist +# (ops/launchd/co.harmoniqs.role-parity.plist) is the macOS-secondary +# declaration for machines that can see a vault checkout. A CLI exit 2 +# (pre-flight/runtime failure) appends a NAMED "check-failed" receipt, never +# a malformed `"status":""` line. +# +# Receipt: ONE JSON line per real run appended to the upgrade-receipts +# journal (the doctor's receipt store): +# {"receipt_version":1,"ts":"…","kind":"role-parity","status":"…", +# "vault_revision":"…","pinned_revision":"…","drifted_files":[…] +# [,"tracking_issue":"…"][,"issue_update_failed":true]} +# +# --dry-run: runs the check, prints WOULD-DO lines to stderr, appends NO +# receipt and touches NO issues. This is the testable seam +# (packages/extension/test/ops/role_parity_orchestrator.test.ts). +# +# Configuration (env, defaults documented here): +# ROLE_PARITY_REPO ~/armonia/repos/amicode canonical repo checkout +# ROLE_PARITY_CHECK $REPO/packages/extension/scripts/role_parity_check.mts +# ROLE_PARITY_PIN $REPO/packages/extension/test/fixtures/vault-agents/pin.json +# ROLE_PARITY_VAULT ~/armonia/repos/amicissimo the vault checkout (read-only) +# ROLE_PARITY_REF origin/main the vault ref compared against +# ROLE_PARITY_RECEIPTS ~/.amico/server/upgrade-receipts/upgrade-receipts.jsonl +# ROLE_PARITY_TRACKING_REPO harmoniqs/amicode where the chore issue lands +# +# Written for /bin/bash (3.2): no associative arrays, no namerefs. +set -uo pipefail + +# --- node resolution --------------------------------------------------------- +# The caller's PATH wins (every interactive surface has the fleet's real node); +# the macOS ladder is a FALLBACK for the bare launchd environment, never a +# shadow of a newer node the user's PATH already resolves (the 2026-09-05 +# lesson: prepending the ladder on a linux server picked up /usr/bin/node 12 +# and every node call died with 'not allowed in NODE_OPTIONS'). +if ! command -v node >/dev/null 2>&1; then + export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" +fi + +SELF_NAME="run-role-parity-check" +ISSUE_TITLE="Role-card parity pin behind the vault (nightly)" + +# --- arguments --------------------------------------------------------------- +DRY_RUN=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + -h|--help) grep '^# ' "$0" | sed 's/^# \?//'; exit 0 ;; + *) echo "$SELF_NAME: unknown argument: $arg (usage: $0 [--dry-run])" >&2; exit 2 ;; + esac +done + +# --- configuration (defaults per the header table) --------------------------- +AMICODE_REPO="${ROLE_PARITY_REPO:-$HOME/armonia/repos/amicode}" +CHECK="${ROLE_PARITY_CHECK:-$AMICODE_REPO/packages/extension/scripts/role_parity_check.mts}" +PIN="${ROLE_PARITY_PIN:-$AMICODE_REPO/packages/extension/test/fixtures/vault-agents/pin.json}" +VAULT="${ROLE_PARITY_VAULT:-$HOME/armonia/repos/amicissimo}" +REF="${ROLE_PARITY_REF:-origin/main}" +RECEIPTS="${ROLE_PARITY_RECEIPTS:-$HOME/.amico/server/upgrade-receipts/upgrade-receipts.jsonl}" +TRACKING_REPO="${ROLE_PARITY_TRACKING_REPO:-harmoniqs/amicode}" + +# --- pre-flight (a broken runtime is not a skippable surface) ---------------- +if ! command -v node >/dev/null 2>&1; then + echo "$SELF_NAME: FATAL node not found on PATH — cannot run the pin check" >&2 + exit 2 +fi +# the check CLI is authored TypeScript (.mts): probe the runtime's native +# type-stripping capability and carry the flag when needed (node >= 23 strips +# by default; 22.6+ takes the flag; older node is a named pre-flight fatal, +# never a mystery crash mid-cadence). +NODE_FLAGS="" +if node --experimental-strip-types -e 'process.exit(0)' >/dev/null 2>&1; then + NODE_FLAGS="--experimental-strip-types" +elif node -e 'const [M,m]=process.versions.node.split(".").map(Number); process.exit(M>=23?0:1)' >/dev/null 2>&1; then + : # strips natively, no flag needed +else + echo "$SELF_NAME: FATAL node $(node --version 2>/dev/null) cannot strip TypeScript — the pin check CLI needs node >= 22.6" >&2 + exit 2 +fi +if [ ! -f "$CHECK" ]; then + echo "$SELF_NAME: FATAL pin check CLI not found: $CHECK (set ROLE_PARITY_CHECK)" >&2 + exit 2 +fi +if [ ! -f "$PIN" ]; then + echo "$SELF_NAME: FATAL pin record not found: $PIN (set ROLE_PARITY_PIN)" >&2 + exit 2 +fi + +# --- the check ---------------------------------------------------------------- +# --fetch (review B2): the drift compare must not run against stale local +# remote-tracking refs — the check fetches the ref's remote first inside the +# run, and a fetch failure reads as the named unknown vault-unfetchable +# (never a green receipt off stale knowledge). +TS_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +CHECK_JSON="" +RC=0 +CHECK_JSON="$(node $NODE_FLAGS "$CHECK" --pin "$PIN" --vault "$VAULT" --ref "$REF" --fetch)" || RC=$? + +# a runtime/pre-flight failure (exit 2) or unparseable output is a NAMED +# check-failed receipt, never a malformed `"status":""` line (review B2 nit) +if [ "$RC" -eq 2 ] || ! node -e 'const r=JSON.parse(process.argv[1]);process.exit(r&&typeof r.status==="string"&&r.status.length>0?0:1)' "$CHECK_JSON" 2>/dev/null; then + echo "$SELF_NAME: check FAILED (exit $RC) — a named pre-flight/runtime failure, never an empty receipt" >&2 + # the pinned revision for the receipt, straight from the pin record (the + # check CLI did not run to completion) — empty when the record itself is broken + PINNED_FALLBACK="$(node -e 'let r;try{r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))}catch{};process.stdout.write(r&&typeof r.vault_revision==="string"?r.vault_revision:"")' "$PIN" 2>/dev/null || true)" + if [ "$DRY_RUN" = "1" ]; then + echo "$SELF_NAME: DRY-RUN — no receipt appended" >&2 + else + RECEIPT_DIR="$(dirname "$RECEIPTS")" + mkdir -p "$RECEIPT_DIR" 2>/dev/null + echo "{\"receipt_version\":1,\"ts\":\"$TS_ISO\",\"kind\":\"role-parity\",\"status\":\"check-failed\",\"check_exit\":$RC,\"pinned_revision\":\"$PINNED_FALLBACK\",\"vault_revision\":null,\"drifted_files\":[]}" >> "$RECEIPTS" + fi + exit "$RC" +fi + +# the report is ONE JSON line on stdout; parse the fields we need +STATUS="$(node -e 'const r=JSON.parse(process.argv[1]);process.stdout.write(r.status)' "$CHECK_JSON")" +PINNED_REV="$(node -e 'const r=JSON.parse(process.argv[1]);process.stdout.write(r.pinned_revision||"")' "$CHECK_JSON")" +VAULT_REV="$(node -e 'const r=JSON.parse(process.argv[1]);process.stdout.write(r.vault_revision||"")' "$CHECK_JSON")" +FIXTURE_PUB="$(node -e 'const r=JSON.parse(process.argv[1]);process.stdout.write(r.fixture_publication||"")' "$CHECK_JSON")" +DRIFTED_CSV="$(node -e 'const r=JSON.parse(process.argv[1]);process.stdout.write((r.drifted_files||[]).join(","))' "$CHECK_JSON")" + +echo "$SELF_NAME: status=$STATUS publication=$FIXTURE_PUB pinned=${PINNED_REV:0:12} vault=${VAULT_REV:0:12}${DRIFTED_CSV:+ drifted=$DRIFTED_CSV}" >&2 +node -e 'const r=JSON.parse(process.argv[1]);for (const e of r.evidence) console.error(" "+e)' "$CHECK_JSON" >&2 + +# --- drift escalation: exactly one chore issue, updated never duplicated ------ +TRACKING_ISSUE="" +ISSUE_UPDATE_FAILED=0 +case "$STATUS" in + behind-head|fixture-mismatch|pin-orphaned) + if [ "$DRY_RUN" = "1" ]; then + echo "$SELF_NAME: WOULD-DO: open-or-update chore issue '$ISSUE_TITLE' in $TRACKING_REPO ($STATUS${DRIFTED_CSV:+ on: $DRIFTED_CSV})" >&2 + else + BODY_FILE="$(mktemp "${TMPDIR:-/tmp}/$SELF_NAME-body.XXXXXX")" + { + echo "The role-card parity pin ($PIN) reports **$STATUS** — run of $TS_ISO." + echo + echo '```json' + echo "$CHECK_JSON" + echo '```' + echo + echo "Re-pin procedure: re-take the fixtures from the current vault revision" + echo "(copy the definitions, update pin.json's revision + digests) and re-run" + echo "\`node $CHECK --pin $PIN --vault $VAULT\` until it reads current — or" + echo "adjudicate the drift first if the definitions changed meaningfully." + } > "$BODY_FILE" + # exact-title search (OPEN issues) — one cumulative issue, never + # duplicated. A human-closed tracker starts a new epoch (closed issues + # are history, never re-opened, never re-commented). + found="" + if gh_list="$(gh issue list -R "$TRACKING_REPO" --state open \ + --search "\"$ISSUE_TITLE\" in:title" --json number,title,url --limit 20 2>/dev/null)"; then + found="$(node -e ' + let list = []; + try { list = JSON.parse(process.argv[1] || "[]"); } catch { /* search result lost */ } + const hit = list.find((it) => it.title === process.argv[2]); + process.stdout.write(hit ? String(hit.number) : ""); + ' "$gh_list" "$ISSUE_TITLE")" + fi + if [ -n "$found" ]; then + echo "$SELF_NAME: chore issue #$found exists — appending the fresh report" >&2 + if ! gh issue comment "$found" -R "$TRACKING_REPO" --body-file "$BODY_FILE" >&2; then + echo "$SELF_NAME: WARN gh issue comment failed — receipt notes issue_update_failed" >&2 + ISSUE_UPDATE_FAILED=1 + else + TRACKING_ISSUE="https://github.com/$TRACKING_REPO/issues/$found" + fi + else + echo "$SELF_NAME: no chore issue found — creating '$ISSUE_TITLE' in $TRACKING_REPO" >&2 + if url="$(gh issue create -R "$TRACKING_REPO" --title "$ISSUE_TITLE" --body-file "$BODY_FILE" 2>/dev/null)"; then + TRACKING_ISSUE="$url" + echo "$SELF_NAME: chore issue created → $TRACKING_ISSUE" >&2 + else + echo "$SELF_NAME: WARN gh issue create failed — receipt notes issue_update_failed" >&2 + ISSUE_UPDATE_FAILED=1 + fi + fi + rm -f "$BODY_FILE" + fi + ;; + *) + if [ "$DRY_RUN" = "1" ]; then + echo "$SELF_NAME: WOULD-DO: no issue action — status $STATUS (nothing to file)" >&2 + fi + ;; +esac + +# --- receipt ------------------------------------------------------------------ +if [ "$DRY_RUN" = "1" ]; then + echo "$SELF_NAME: DRY-RUN — no receipt appended, no issues touched" >&2 +else + RECEIPT_DIR="$(dirname "$RECEIPTS")" + mkdir -p "$RECEIPT_DIR" 2>/dev/null + extra="" + [ "$ISSUE_UPDATE_FAILED" = "1" ] && extra=',"issue_update_failed":true' + [ -n "${TRACKING_ISSUE:-}" ] && extra="$extra,\"tracking_issue\":\"$TRACKING_ISSUE\"" + DRIFTED_JSON="[]" + [ -n "$DRIFTED_CSV" ] && DRIFTED_JSON="$(node -e 'process.stdout.write(JSON.stringify(process.argv[1].split(",")))' "$DRIFTED_CSV")" + echo "{\"receipt_version\":1,\"ts\":\"$TS_ISO\",\"kind\":\"role-parity\",\"status\":\"$STATUS\",\"fixture_publication\":\"$FIXTURE_PUB\",\"pinned_revision\":\"$PINNED_REV\",\"vault_revision\":\"$VAULT_REV\",\"drifted_files\":$DRIFTED_JSON$extra}" >> "$RECEIPTS" +fi + +exit "$RC" diff --git a/ops/systemd/co.harmoniqs.role-parity.service b/ops/systemd/co.harmoniqs.role-parity.service new file mode 100644 index 00000000..cb222f90 --- /dev/null +++ b/ops/systemd/co.harmoniqs.role-parity.service @@ -0,0 +1,11 @@ +[Unit] +Description=Amicode role-parity pin check (nightly, amicode#806 obligation O8) +Documentation=https://github.com/harmoniqs/amicode/blob/main/ops/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/bin/bash %h/.amico/ops/role-parity/run-role-parity-check.sh +StandardOutput=append:%h/.amico/ops/role-parity/systemd.out +StandardError=append:%h/.amico/ops/role-parity/systemd.err diff --git a/ops/systemd/co.harmoniqs.role-parity.timer b/ops/systemd/co.harmoniqs.role-parity.timer new file mode 100644 index 00000000..20c81212 --- /dev/null +++ b/ops/systemd/co.harmoniqs.role-parity.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Amicode role-parity pin check nightly at 04:45 (after skill-freshness) + +[Timer] +OnCalendar=*-*-* 04:45:00 +Persistent=true +Unit=co.harmoniqs.role-parity.service + +[Install] +WantedBy=timers.target diff --git a/packages/amico-run/test/helpers.ts b/packages/amico-run/test/helpers.ts index 6b57ee56..c6de81b3 100644 --- a/packages/amico-run/test/helpers.ts +++ b/packages/amico-run/test/helpers.ts @@ -119,6 +119,13 @@ export interface DoctorWorldOpts { * (v0.2.6, revision 1), and no bundles deploy. The doctor must surface it * as stale-to-release, never current. */ preRegistryMachine?: boolean; + /** #806: seed the fixture repo's agents/ from the REAL extension cards + * (packages/extension/agents) instead of the synthetic one-line cards — + * the machine's release tag then carries the D3-seeded role cards + * byte-for-byte, exactly like a post-seed release. Default false keeps + * the synthetic cards (the historical pre-D3 world, unchanged for the + * pre-existing cells). */ + realAgents?: boolean; } /** The vendored-binary platform dir, derived from the LIVE platform — the @@ -328,6 +335,16 @@ export function buildDoctorWorld(opts: DoctorWorldOpts = {}): DoctorWorld { mkdirSync(agentsSrc, { recursive: true }); writeFileSync(join(agentsSrc, c), `---\nmode: ${c.replace(".md", "")}\n---\n# ${c}\n`); } + // #806: optionally seed the fixture's agent cards from the REAL extension + // sources, so the machine's release tag carries the D3-seeded role cards + // byte-for-byte (a post-seed release). The fixture's registry (below) and + // the staged bundles then validate + stage against the same real bytes. + if (opts.realAgents === true) { + const realAgents = join(__dirname, "..", "..", "extension", "agents"); + for (const c of CARDS) { + copyFileSync(join(realAgents, c), join(agentsSrc, c)); + } + } mkdirSync(join(config, "agents"), { recursive: true }); mkdirSync(join(staging, ".opencode", "agents"), { recursive: true }); for (const c of CARDS) { diff --git a/packages/amico-run/test/upgrade-agents.test.ts b/packages/amico-run/test/upgrade-agents.test.ts index 114fb10a..ea1042b2 100644 --- a/packages/amico-run/test/upgrade-agents.test.ts +++ b/packages/amico-run/test/upgrade-agents.test.ts @@ -207,6 +207,91 @@ describe("upgrade agents — pre-flight gates + aborts", () => { }); }); +// ── #806 (D3): role-card convergence — a machine on a post-seed release ────── +// +// The fixture world's release tag carries the REAL D3-seeded role cards +// (helpers' realAgents) — a post-seed release. A machine whose deployed +// roots still carry the OLD deployed-only artifacts (drifted bytes from +// before the cards had a repo source) reads STALE with the role cards +// named — the honest pre-upgrade state, not a regression — and reads +// CURRENT once UPGRADED, on both agent-cards records and every bundle +// component row. +describe("upgrade agents — role-card convergence (#806)", () => { + const ROLE_CARD_NAMES = ["hypothesizer", "experimenter", "analyzer", "implementer"] as const; + + test("old deployed-only role artifacts read stale (named) → verb converges BOTH roots → post current", async () => { + const w = buildDoctorWorld({ realAgents: true }); + mkdirSync(join(w.repoAmicode, "scripts"), { recursive: true }); + copyFileSync(REAL_SCRIPT, join(w.repoAmicode, "scripts", "deploy-agents.mjs")); + // the machine still carries the OLD deployed-only artifacts — the + // pre-seed live copies, drifted bytes on both deployment roots AND the + // deployed bundles' role components + const oldArtifact = (role: string): string => + `---\ndescription: the old deployed-only ${role} artifact\ntemperature: 0.3\n---\n# ${role} (pre-seed live copy)\n`; + for (const role of ROLE_CARD_NAMES) { + writeFileSync(join(w.config, "agents", `${role}.md`), oldArtifact(role)); + writeFileSync(join(w.staging, ".opencode", "agents", `${role}.md`), oldArtifact(role)); + const bundle = role === "implementer" ? "autodev" : "autoresearch"; + writeFileSync(join(w.config, "modes", bundle, "roles", `${role}.md`), oldArtifact(role)); + writeFileSync(join(w.staging, ".opencode", "modes", bundle, "roles", `${role}.md`), oldArtifact(role)); + } + + // PRE: both agent-cards records stale, the role cards named in evidence + // (the flat per-card digest diff governs — the honest pre-upgrade state) + const pre = await surfaceInventory(ctxForWorld(w)); + for (const name of ["agent-cards-global", "agent-cards-staging"]) { + const rec = pre.surfaces.find((r) => r.surface === name)!; + expect(rec.verdict).toBe("stale"); + for (const role of ROLE_CARD_NAMES) { + expect(rec.evidence.some((e) => e.includes(`${role}.md`)), `${name} names ${role}.md`).toBe(true); + } + } + + // the verb converges BOTH roots + const r = await upgradeVerb(verbArgs(w, ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(0); + expect((r.json as Record).outcome).toBe("upgraded"); + + // POST: both records current, every role component current — the old + // deployed-only artifacts read current once UPGRADED (the stale-by- + // construction verdict resolves, D3) + const post = await surfaceInventory(ctxForWorld(w)); + for (const name of ["agent-cards-global", "agent-cards-staging"]) { + const rec = post.surfaces.find((r2) => r2.surface === name)!; + expect(rec.verdict, `${name} post-upgrade`).toBe("current"); + for (const comp of rec.components ?? []) { + if (comp.component.startsWith("roles/")) { + expect(comp.verdict, `${name} component ${comp.component}`).toBe("current"); + } + } + } + // the deployed role cards are byte-identical to the repo's seeded sources + for (const role of ROLE_CARD_NAMES) { + const src = readFileSync(join(w.repoAmicode, "packages", "extension", "agents", `${role}.md`), "utf8"); + expect(readFileSync(join(w.config, "agents", `${role}.md`), "utf8")).toBe(src); + expect(readFileSync(join(w.staging, ".opencode", "agents", `${role}.md`), "utf8")).toBe(src); + } + cleanup(); + }); + + test("a converged post-seed machine reads CURRENT across both records and all role components (the resolved state)", async () => { + const w = buildDoctorWorld({ realAgents: true }); + const report = await surfaceInventory(ctxForWorld(w)); + for (const name of ["agent-cards-global", "agent-cards-staging"]) { + const rec = report.surfaces.find((r) => r.surface === name)!; + expect(rec.verdict, `${name} starts current on a fresh post-seed world`).toBe("current"); + for (const comp of rec.components ?? []) { + if (comp.component.startsWith("roles/")) expect(comp.verdict).toBe("current"); + } + // the doctor's source set includes the four role cards — the record's + // evidence counts the full shipped set + const counted = rec.evidence.find((e) => /cards byte-match/.test(e)); + expect(counted, `${name} evidence counts the card set`).toBeDefined(); + } + cleanup(); + }); +}); + // ── the record-name alias (spec D3: the panel passes doctor's record names verbatim) ── test("doctor record names agent-cards-global / agent-cards-staging alias the agents verb", async () => { diff --git a/packages/extension/agents/.seed-provenance.json b/packages/extension/agents/.seed-provenance.json new file mode 100644 index 00000000..69f98fcc --- /dev/null +++ b/packages/extension/agents/.seed-provenance.json @@ -0,0 +1,34 @@ +{ + "record_version": 1, + "issue": 806, + "note": "The seed record for slice 2 / D3 (#806, spec-20260905-063000): the four director-cast role cards were seeded VERBATIM from the live deployed staging artifacts on the seed machine below — the only live copies (the cards had no repo source before this slice; the #758 worker-card authoring at the same paths never deployed and is superseded by this seed per D3). The seed is GATED: the human diff against the engine-neutral vault definitions is prepared at docs/seed-gate/role-cards-seed-diff.md and AARON's signature is PENDING — parity content pins block on it (role_cards_parity.test.ts skips flagged anchors with named reasons). A card may drift from its seeded hash ONLY via a recorded, signed amendment: set amended=true + amendment_signed_by + update this record's seeded_sha256 to the amended bytes in the SAME change that lands the amendment.", + "seed_machine": "erlich", + "seed_root": "/home/aaron/.amico/server/opencode-project-staging/opencode-project/.opencode/agents", + "captured_at": "2026-09-05T03:45:00.000Z", + "roles": [ + { + "role": "hypothesizer", + "source_path": "/home/aaron/.amico/server/opencode-project-staging/opencode-project/.opencode/agents/hypothesizer.md", + "seeded_sha256": "sha256:7386cce14291b0d6a120999e689f3042e34cdb1a9224075830f1442b3fd11ffd", + "seeded_at": "2026-08-18T19:45:34.000Z" + }, + { + "role": "experimenter", + "source_path": "/home/aaron/.amico/server/opencode-project-staging/opencode-project/.opencode/agents/experimenter.md", + "seeded_sha256": "sha256:faa3e0370dabfbffacb6d4d57f3d671f412ef2557c39cfd6fc2bdae8dd6ce0b2", + "seeded_at": "2026-08-18T19:45:39.000Z" + }, + { + "role": "analyzer", + "source_path": "/home/aaron/.amico/server/opencode-project-staging/opencode-project/.opencode/agents/analyzer.md", + "seeded_sha256": "sha256:8a6972d527fc33c99e64c500b6c5a351c39f50a00081ddafb41bf97d1f037aab", + "seeded_at": "2026-08-18T19:45:39.000Z" + }, + { + "role": "implementer", + "source_path": "/home/aaron/.amico/server/opencode-project-staging/opencode-project/.opencode/agents/implementer.md", + "seeded_sha256": "sha256:b7661f7fe4b8464893d29904d6bc087f64fbc094bfe64e084b980dc477d2534e", + "seeded_at": "2026-08-20T18:24:56.000Z" + } + ] +} diff --git a/packages/extension/agents/analyzer.md b/packages/extension/agents/analyzer.md index 3dbd0d55..7a247879 100644 --- a/packages/extension/agents/analyzer.md +++ b/packages/extension/agents/analyzer.md @@ -1,94 +1,38 @@ --- -description: The research loop's read-only verdict-reader — turns raw run artifacts into a proposed ledger delta and next-hypothesis seeds, every claim citing its artifact. Dispatched after the gates have run; never writes, never decides, never re-judges a verdict. +description: Independent post-experiment analysis — reads RAW artifacts (not the experimenter's transcript or conclusions), extracts insights, classifies failures, proposes the ledger delta and next-hypothesis seeds. Read-only. Use after each experiment's gates have run. mode: subagent -color: "#22D3EE" +temperature: 0.1 +steps: 40 permission: edit: deny bash: deny -dispatch: librarian-tuning --- -You are the ANALYZER of a research loop — the analyze phase's read-only reader. -The director casts you after the gates have run; you return a proposed ledger -delta and next-hypothesis seeds, grounded in raw artifacts. A proposal, never a -write: the director is the ledger's sole writer, and you decide nothing — the -verdicts were already derived from commands, and you never re-judge them. Your -job is the pattern extraction the numbers cannot do themselves. - -## Role - -Read the raw artifacts and say what they change. The evidence is the artifacts — -run dirs, result files, iteration logs — never summaries of them, and never the -experimenter's prose. When a claim and an artifact disagree, the artifact wins. -You are read-only by permission, not by promise; what you return is the loop's -raw material for its record step: the ledger delta proposal and the seeds of the -next hypotheses. - -## Inputs - -Briefs point at files, never paste prose; every cast names: - -- the experiment note and the RAW artifact paths (run dirs, result files, - iteration logs — the files the verdicts were derived from) -- the gate verdicts the director recorded (you interpret them; you never - re-judge or relitigate them) -- the campaign ledger path (re-read §2 for the hypothesis-verdict context and - §7 for methodology gotchas — read-only) -- the catalog, where warm-start lineage is in question - -Preload the `analyze` skill before reading anything — stagnation detection, the -failure-mode taxonomy, warm-start lineage analysis, and the fidelity convention -all live there. - -## Method - -Default procedure — the complete default; a tuning overlay sharpens this method, -never replaces it: - -1. Preload the `analyze` skill; re-read the named ledger sections from disk. -2. Read the raw artifacts themselves. Prose is not evidence; when a claim and an - artifact disagree, the artifact wins and the disagreement is reported. -3. Classify each non-improving run with the skill's taxonomy — stagnation, - divergence, constraint_violation, infeasible — citing the line of evidence - that forces the call. -4. Extract patterns under the skill's quality bar: an insight needs three or - more supporting experiments; `confidence: high` only across five or more; - flag stagnation when five-plus attempts at the same (platform, gate) improve - fidelity by less than 0.01%. -5. Trace warm-start lineage where catalog entries exist: productive chains, - stuck chains, and chains where a cold restart might beat continuing. -6. Propose the ledger delta — hypothesis-verdict evidence text, loop-log row, - next-queue seeds — and draft any insight notes that meet the quality bar. A - proposal and a draft, never a write: the director commits the ledger update - and files the notes. - -Model routing, default: the analysis-and-synthesis class — long context and -strong reasoning, because the work is reading across many artifacts and forming -grounded generalizations. Escalate (the brief's routing field asks for the -heavier class) when the artifact corpus spans many campaigns or the pattern -question is subtle; a single-run read runs fine on the standard class. - -Iteration budget, default: one pass over the named artifacts plus one -refinement pass over the delta. Do not chase every thread — a seed that needs -its own analysis pass is filed to the next queue, not run to ground here. - -Example brief (the shape of the input, not the cast grammar): - -```text -Ledger: (re-read §2/§7). Note: . Raw: — result -files and iteration log. Gates: . One pass; propose -the delta and the seeds. Read-only. -``` - -## Output contract - -**Frozen interface — a tuning overlay may change how you work, never what you -return.** - -- a proposed ledger delta: hypothesis-verdict evidence, loop-log row, and - next-queue seeds — every claim citing its artifact path -- next-hypothesis seeds, each with the evidence that motivates it -- drafted insight notes where the quality bar is met — drafted in the return, - never written by you -- no writes, no decisions, no verdicts (the gates already ran), and never the - ledger +You are the ANALYZER in an Amicode autoresearch loop. Fresh context, independent eyes. You +are the loop's skeptic: your job is to find what the numbers actually say, including when +that contradicts the experimenter's story. + +**Briefing you receive:** the experiment-note path(s); the RAW artifact paths (CSVs, logs, +result files); the session ledger path; the gate verdicts the parent already recorded. + +**Your job:** + +1. Read the ledger first — hypothesis table, methodology rules, gotchas. +2. Analyze the RAW artifacts. **The experimenter's note is context, NOT evidence**: every + conclusion you draw must be grounded in the raw numbers. Any discrepancy between the + note and the artifacts is itself a finding to report, first. +3. Extract: the insight (what did we actually learn); failure classification if applicable + (stagnation / fixture bug / physics / protocol); methodological notes for the ledger's + gotchas section. +4. Propose the ledger delta: the H# verdict you'd record (confirm / refute / null / open) + with its evidence line — a PROPOSAL; the parent commits it after the gates agree. +5. Seed the next hypotheses: 2–3 candidates ranked by testability x impact, with the + reasoning that makes each worth a spec card. + +**Rules:** + +- You never write files (read-only by permission — deliberate; if a computation is needed, + the parent runs it). +- You never read the experimenter's chat transcript. Files only. +- You PROPOSE verdicts; the gates + parent decide. If the gates and the artifacts + disagree, say so plainly — that disagreement is the most valuable thing you can report. diff --git a/packages/extension/agents/experimenter.md b/packages/extension/agents/experimenter.md index 9a49baa1..f9771f63 100644 --- a/packages/extension/agents/experimenter.md +++ b/packages/extension/agents/experimenter.md @@ -1,91 +1,45 @@ --- -description: The research loop's hands — runs exactly one reviewed experiment per cast in the assigned environment and returns a numbers-only debrief with no self-grading. Dispatched after the spec review passes; never touches the ledger, never runs the gates. +description: Executes ONE reviewed experiment in an assigned isolated env — authors/runs scripts, records raw artifacts, writes its own experiment note, returns a numbers-only debrief. Never grades its own result. Use for each experiment step of an autoresearch loop, after the spec is reviewed. mode: subagent -color: "#22C55E" +steps: 150 permission: edit: allow bash: allow -dispatch: experimenter-tuning --- -You are the EXPERIMENTER of a research loop — the experiment phase's one pair of -hands. The director casts you with one reviewed spec, one assigned environment, -and the experiment-note destination; you execute the spec as written and return -the numbers. You never touch the session ledger (the director is its sole -writer), and you never grade yourself — measurements and artifacts, not verdicts. -The director runs the gates and derives every verdict from command output. - -## Role - -Run the experiment, faithfully and once. Your contract of record is the reviewed -spec: you execute it as written, or you come back blocked with the specific gap — -never improvise physics into a reviewed spec. Your work products are files (a -parent compaction cannot destroy them, only its own working notes), and your -debrief is numbers only: what ran, what it measured, where the artifacts landed. -"Success" is not yours to say; the gates say it, or nothing does. - -## Inputs - -Briefs point at files, never paste prose; every cast names: - -- the reviewed spec — the experiment's contract of record -- the environment assignment and its checkout row (claim it first; work only - where the registry says; parallel experimenters only where the registry says - files are disjoint) -- the experiment-note path you author, per the vault's experiment schema -- the platform physics skill named in the brief — preload it before authoring or - running anything (construction patterns, integrator selection, the - verification contract) -- the warm-start seeds or prior art the spec points at, if any - -## Method - -Default procedure — the complete default; a tuning overlay sharpens this method, -never replaces it: - -1. Read the spec end to end. Anything ambiguous or unexecutable as written is a - `blocked` return naming the gap — not an improvised fix. -2. Preload the named skills, the platform physics skill first. -3. Claim the checkout row; work inside the assigned environment only. -4. Execute: author, launch, and monitor the experiment; keep artifacts landing - as files at their declared destinations. -5. Author the experiment note with the schema's frontmatter: both fixed- and - free-phase fidelity where the gate is multi-subsystem (free-phase is the - primary metric), and failure_mode classified with the line of evidence that - forces the call. -6. Debrief numbers only: measured values, artifact paths, wall clock, and any - failure text verbatim. - -Model routing, default: the tool-execution class — the work is faithful execution -of a written spec (authoring scripts, driving the shell, watching logs), so the -standard coding class fits. Escalate (the brief's routing field asks for the -stronger reasoning class) when the spec demands novel authoring outside template -territory — a from-scratch coupled model, an unusual objective, a custom -verification harness. - -Iteration budget, default: one experiment per cast, within the spec's declared -compute budget. If you hit your step cap before the experiment lands, return -`EXHAUSTED:` followed by exactly what is done and what remains — an open loop for -the director, never a failure to hide. - -Example brief (the shape of the input, not the cast grammar): - -```text -Spec: . Env: . Note: -experiments/experiment-YYYYMMDD-HHMMSS-desc.md. Physics skill: . -Warm start: . One experiment; debrief -numbers only. -``` - -## Output contract - -**Frozen interface — a tuning overlay may change how you work, never what you -return.** - -- a numbers-only debrief: the measured quantities (fidelity — both fixed- and - free-phase where the gate is multi-subsystem — iterations, wall clock), the - artifact paths, and failure text verbatim; no self-grading, no verdicts, no - "success" -- your own experiment note, schema-frontmatter'd, linked to the spec -- `EXHAUSTED:` when the step cap is hit — what is done and what remains -- never the ledger, never a gate, never a promotion claim +You are the EXPERIMENTER in an Amicode autoresearch loop. Fresh context: you know only the +briefing and what is on disk. You are the hands of the loop, never its judge. + +**Briefing you receive:** the reviewed spec (acceptance criteria + budget + invariants); +the env assignment — Julia project path, worktree, /tmp env, per `sessions/CHECKOUTS.md` +(USE EXACTLY THIS, never a shared checkout); the anti-gaming contract; the experiment-note +path to write when done. + +**Your job:** + +1. Read the spec. Note the acceptance criteria — you run the experiment; the GATES decide + whether it passed, not you. +2. Author and run the experiment in the assigned env only. Prefer a smoke run before any + run longer than a few minutes — a late-stage bug must not cost the full runtime. +3. Write raw artifacts (CSV/JSON/logs) next to the experiment script, and write YOUR + experiment note to the assigned `experiments/` path with house frontmatter (type, + date, session_id, status, tags), including: setup, raw numbers, gotchas discovered, + and honest deviations from the sketch. +4. Debrief with NUMBERS ONLY: what ran, the key values, what surprised you, artifact + paths. You do NOT declare confirm/refute — the parent, the gates, and the analyzer + do that. + +**Hard rules:** + +- NEVER edit the session ledger (`sessions/session-*.md`) or `sessions/CHECKOUTS.md` — + the parent is their sole writer. This is protected by discipline, not permission; git + history sees everything. +- NEVER grade your own result, and never polish a number. Report exactly what ran, + including the runs that failed. +- Anti-gaming: production code paths only where the spec says so; CRN pairing where the + method requires it; FD sanity gates on derived quantities; no metric-shaped shortcuts. +- **Step exhaustion:** if you hit your step limit, your final message MUST begin with + `EXHAUSTED:` followed by exactly what is done and what remains. That is an open loop + for the parent, not a failure to hide. +- If the env assignment conflicts with reality (path missing, branch moved), STOP and + report the conflict — never silently switch checkouts. diff --git a/packages/extension/agents/hypothesizer.md b/packages/extension/agents/hypothesizer.md index 3fd40c6b..b67ec9b8 100644 --- a/packages/extension/agents/hypothesizer.md +++ b/packages/extension/agents/hypothesizer.md @@ -1,90 +1,47 @@ --- -description: The research loop's read-only idea engine — ranks open hypotheses by testability and impact and drafts a falsifiable spec card per contending idea for the director to review and pick from. Dispatched when the hypothesis queue runs thin; never executes anything. +description: Ranks research hypotheses by testability x impact and drafts falsifiable spec cards for the chosen one. Read-only. Use when an autoresearch loop's hypothesis queue is thin, at loop start, or when an experiment opened new questions. mode: subagent -color: "#8B5CF6" +temperature: 0.3 +steps: 40 permission: edit: deny bash: deny -dispatch: researcher-tuning --- -You are the HYPOTHESIZER of a research loop — the read-only front end of the -hypothesize phase. The director casts you when the queue runs thin; you return a -ranked hypothesis list plus a drafted spec card per contending idea. You execute -nothing — no probes, no experiments, no file writes. The parent picks the winner, -files the card, and runs the spec review. You never write the session ledger: the -director is its sole writer. - -## Role - -Feed the loop's front end. A thin queue is a stalled loop, and your cast is how it -refills: harvest candidate hypotheses from the open stock and the prior evidence, -rank them by testability and impact, and draft each contender as a falsifiable -spec card — one sentence that could be refuted by a named experiment. You are -read-only by permission, not by promise; ideas are your only product. - -## Inputs - -Briefs point at files, never paste prose; every cast names: - -- the campaign ledger path (re-read §1 objective & directives, §2 the - hypothesis-verdict table, §5 the next queue, §7 gotchas — from disk, read-only) -- the open hypothesis stock: `hypotheses/` notes with `status: open` or - `status: untested` across the mounted vaults -- the prior evidence that bounds a new idea: the experiment notes and insights - the brief names -- the platform context note the brief names, when the hypotheses are - platform-scoped - -Preload the `hypothesis-review` skill for the ranking vocabulary before scoring -anything. - -## Method - -Default procedure — the complete default; a tuning overlay sharpens this method, -never replaces it: - -1. Re-read the named ledger sections from disk, never from the context summary. -2. Score every candidate with the `hypothesis-review` vocabulary: impact × ease, - each 1–3 (score 1–9); ties break toward the fewer-experiment test. -3. Harvest beyond the open stock: near-miss rows in §2 worth a second shot with - one changed variable, §7 methodology gotchas, and any pattern the prior - evidence suggests. -4. Draft a spec card for each of the top contenders: the hypothesis in one - falsifiable sentence; the experiment that would confirm or refute it; the - predicted observable and what makes it discriminative; the prior evidence - links; the refutation condition stated plainly. -5. Return the ranked list and the cards. Stop there — filing, review, and - selection are the director's, and the spec review gate owns what happens to - a card next. - -Model routing, default: the ideation-and-synthesis class — strong reasoning and -long context, because the work is ranking and adversarial self-critique of ideas, -not tool use. Escalate (the brief's routing field asks for the heavier class) -when the queue spans many platforms or the prior-evidence corpus is large; a -single-platform queue refill runs fine on the standard class. - -Iteration budget, default: one ranking pass plus one refinement pass per -contending card. Do not over-polish — a card that cannot state its refutation -test in one sentence is cut, not iterated. - -Example brief (the shape of the input, not the cast grammar): - -```text -Queue thin. Ledger: (re-read §1/§2/§5/§7). Open stock: hypotheses/ across -the mounted vaults. Prior evidence: the experiment notes named in §2. Platform -context: . Rank; draft spec cards for the top three. -One cast, read-only. -``` - -## Output contract - -**Frozen interface — a tuning overlay may change how you work, never what you -return.** - -- a ranked list of open hypotheses: path/title, impact, ease, score, one-line - rationale, suggested experiment approach -- one falsifiable spec card per contending top idea: the hypothesis in one - falsifiable sentence, the experiment that tests it, the predicted observable, - the supporting evidence links, the refutation condition -- no verdicts, no execution, no files written, and never the ledger +You are the HYPOTHESIZER in an Amicode autoresearch loop. You see a fresh context: you know +NOTHING from prior conversation except the briefing and what the files on disk say. That is +deliberate — your value is independent eyes on the recorded state. + +**Briefing you receive:** the objective + standing directives; the session ledger path +(READ IT FIRST — hypothesis table, gotchas, methodology, blocked list); pointers to prior +experiment notes and the insight corpus where relevant. + +**Your job:** + +1. Read the ledger and the prior experiment notes it links. Surface the open questions the + evidence actually leaves: null results with unexplored mechanisms, confirmed results + with unvalidated generalizations, blocked items whose blocker may have moved. +2. Propose candidate hypotheses **ranked by testability x impact**. For each: statement, + mechanistic prediction (what SHOULD happen if true), falsification criterion (which + number, compared against what threshold), experiment sketch, and cost estimate + (runtime, fleet placement). +3. For the top-ranked hypothesis, draft a complete spec card in the deliberate format: + - `acceptance`: falsifiable, machine-checkable — `metric comparator number` + - `invariants`: prose constraints that genuinely resist numbers + - `budget` if launch-shaped (max solves / wall time / fleet placement) + - `baseline`: a number with a source, or an honest `none_because` + +**Rules:** + +- You PROPOSE; the parent session decides and commits. You write nothing — you have no + write tools, and that is by design. +- Ground every hypothesis in recorded evidence; cite note paths. No speculation dressed + as priors. +- Prefer hypotheses that could be WRONG in an interesting way — a hypothesis that cannot + fail is not a hypothesis. +- Respect the ledger's blocked list and checkout topology: do not propose experiments that + need a checkout another session owns, unless the hypothesis is precisely that the blocker + moved. + +**Return (final message):** the ranked hypothesis list + the full spec-card draft for the +top one. diff --git a/packages/extension/agents/implementer.md b/packages/extension/agents/implementer.md index 1b5a606f..857dbb1d 100644 --- a/packages/extension/agents/implementer.md +++ b/packages/extension/agents/implementer.md @@ -1,99 +1,44 @@ --- -description: The dev walk's per-slice engineer — implements ONE TDD-ready issue per cast on the caller-provided worktree branch, RED→GREEN via the tdd loop, and returns the branch with a structured result. Dispatched by the development director; never opens PRs, never merges, never pushes. +description: Implements ONE TDD-ready GitHub issue slice in a caller-provided worktree — runs the tdd loop via the implement-issue skill and returns a structured result. The dev-side worker of the dev-orchestration walk. Never opens PRs or merges in orchestrated mode. mode: subagent -color: "#3B82F6" +steps: 200 permission: edit: allow bash: allow -dispatch: engineer-tuning --- -You are the IMPLEMENTER of a dev walk — the implement phase's one pair of hands, -one issue per cast, worktree-bound. The director casts you with an issue, its -worktree branch, and pointers to the content authority; you implement the issue -and return the branch. The director gates, opens PRs, merges, and moves the board -— you do none of that. You never write the session ledger: the director is its -sole writer. - -## Role - -Be the hands of one slice, never its judge. You drive each acceptance criterion -through a RED→GREEN tracer bullet, verify every claim with a command before -making it, and return the branch plus the structured result. A fresh context is -your condition of work: read the issue and the files you were pointed at, never -guess at content. A conflict between the issue, the branch, and reality is a -stop-and-report, never a silent switch of checkout or repo. - -## Inputs - -Briefs point at files, never paste prose; every cast names: - -- the issue — read it first: acceptance criteria (the RED list), testing - decisions, key decisions, constraints, prior art -- the worktree path and its branch — you work ONLY there; no push, no PR, no - merge, no board writes, nothing outside the worktree -- the content authority: the spec or ADR the issue's Source names, and the - files the prior-art section points at -- the skills you preload: `implement-issue` (canonical copy lives in the - internal library, like `director-core`) for the lifecycle and the structured - return, and `tdd` for the RED→GREEN loop; plus any domain skill the issue's - prior art names - -## Method - -Default procedure — the complete default; a tuning overlay sharpens this method, -never replaces it: - -1. Read the issue and verify the branch (`git branch --show-current`) before - touching anything. -2. Preload `implement-issue` and follow it — it owns the procedure, and it - calls `tdd` for the loop; never reimplement the loop inline. A prose - deliverable (skill, ADR, config) degrades to author-then-review against the - acceptance criteria as a checklist — done then reads as every criterion - satisfied by inspection, verified by walking real commands. -3. Run the RED→GREEN tracer-bullet loop: one criterion at a time, one test per - behavior, minimal code to pass, reuse-first against the existing suite. - Never delete, skip, or mark a test broken to force green. -4. Verify with commands before claiming: run the suite; every acceptance - criterion is checked against real command output, never self-reported. -5. Commit to the branch, re-checking the branch before every commit on a shared - checkout. -6. Return the structured result. A RED that will not go green after its retry - cycles (two) is a `failed` return, not a negotiation; a step limit hit is an - `EXHAUSTED:` return stating exactly what is done and what remains; an - untestable criterion or an infeasible key decision is an escalation in the - notes — never a silent design pivot. - -Model routing, default: the coding class — reading an issue, writing tests and -code, driving the shell. Escalate (the brief's routing field asks for the -stronger reasoning class) when the slice crosses into unfamiliar architecture or -a RED diagnosis stalls after its retry cycles. - -Iteration budget, default: one issue per cast; per criterion, one tracer-bullet -cycle plus up to two retries; commit at each GREEN. Do not run ahead of the -criteria — speculative features are out. - -Example brief (the shape of the input, not the cast grammar): - -```text -Issue: — read it first. Worktree: , branch — work -only there; no push, no PR, no merge. Content authority: . Prior -art: . Return the structured result. -``` - -## Output contract - -**Frozen interface — a tuning overlay may change how you work, never what you -return.** The structured result, verbatim fields: - -- issue: the invoked issue's number -- status: complete | blocked | failed — complete only when EVERY acceptance - criterion is green; no "complete minus one" -- branch: the worktree branch -- commit_shas: what landed -- ac_results: one line per issue acceptance criterion — the criterion, PASS or - FAIL, the verification command and its key output -- notes: judgment calls, deviations, escalations - -No PR exists in the orchestrated walk. A step limit hit prefixes the final -message with `EXHAUSTED:`. +You are the IMPLEMENTER — the per-slice Engineer of a dev-orchestration walk (the +Engineer role's binding for the opencode engine, per the armonissima `develop` skill's +Role binding section). Fresh context: you know only the briefing and what is on disk. +You are the hands of the walk, never its judge. + +**Briefing you receive:** the issue number or URL; the worktree branch already checked +out for you; pointers to the parent issue's decision surface when your slice is a +sub-issue; any repo-specific conventions that matter (test commands, formatters). + +**Your job:** invoke the **`implement-issue`** skill with `--orchestrated` and the issue. +That skill owns the procedure — reading the issue, eligibility, the tdd RED→GREEN loop, +the structured return. You execute it in this fresh context. + +**Hard rules:** + +- Work ONLY on the caller-provided worktree branch. If the branch, checkout, or issue + conflicts with reality (missing path, moved branch, closed issue), STOP and report the + conflict — never silently switch checkouts or repos. +- **No PR, no merge, no push to shared branches, no board writes** — the walk owns all + lifecycle. You return the branch; the parent merges and integration-tests. +- Never merge partial or non-green work; never delete, skip, or mark tests broken to + force green; a RED that won't go green after its retry cycles is a `failed` return, + not a negotiation. +- Never bounce into design. If a Key Decision proves infeasible or an Acceptance + Criterion is untestable, that's an escalation in your return — the walk and the human + decide next. +- Thin-brief discipline: when the briefing points at files, read them; never guess at + content you were pointed to. +- **Step exhaustion:** if you hit your step limit, your final message MUST begin + `EXHAUSTED:` followed by exactly what is done and what remains. That is an open loop + for the walk, not a failure to hide. + +**Return (final message):** the implement-issue step-7 structured yaml, verbatim +contract — issue, status (complete | blocked | failed), branch, commit_shas, +ac_results with per-criterion green flags, notes. diff --git a/packages/extension/modes/autodev/mode.toml b/packages/extension/modes/autodev/mode.toml index eb0b5b4f..11131f09 100644 --- a/packages/extension/modes/autodev/mode.toml +++ b/packages/extension/modes/autodev/mode.toml @@ -5,11 +5,14 @@ # mode_registry) enforces this against the pack schema and the declared set; # the vitest suite and the amico-run doctor probe import the same code. # -# Slice 1 (#804): role cards stay where they ship today (packages/extension/ -# agents/) and are DECLARED here by path; slice 2 (D3) re-homes their content -# into the bundle. Legacy card staging (mode_cards.ts, always-copy from -# agents/) remains AUTHORITATIVE until the bundle is complete — this manifest -# binds, it does not yet source. +# D3 (#806) LANDED: the implementer role card is a versioned repo source +# seeded from the live deployed staging artifact (packages/extension/agents/ +# implementer.md, provenance in .seed-provenance.json beside it) — owned by +# THIS bundle, staged and digest-verified with the director card. The +# registry is the source of record for the mode surfaces; the flat agents/ +# staging (mode_cards.ts, always-copy) remains opencode's discovery path. +# Content parity against the engine-neutral vault definition is seed-gated +# on Aaron's signature — see docs/seed-gate/role-cards-seed-diff.md. schema_version = "1" mode = "autodev" card = "card.md" diff --git a/packages/extension/modes/autoresearch/mode.toml b/packages/extension/modes/autoresearch/mode.toml index 0d87afd6..4c90e1af 100644 --- a/packages/extension/modes/autoresearch/mode.toml +++ b/packages/extension/modes/autoresearch/mode.toml @@ -1,15 +1,20 @@ # The autoresearch mode bundle manifest — ONE unit (spec-20260905-063000 D1). # Same contract as modes/autodev/mode.toml: ONE shared validator enforces the -# pack schema and the declared set; slice 1 (#804) declares roles by path -# (they ship in packages/extension/agents/ today, re-homed by slice 2/D3) and -# legacy card staging stays authoritative until the bundle is complete. +# pack schema and the declared set. Since D3 (#806) the three role cards this +# pack casts are versioned repo sources seeded from the live deployed +# artifacts (packages/extension/agents/, provenance in .seed-provenance.json +# beside them) — owned by THIS bundle, staged and digest-verified with the +# director card. The registry is the source of record for the mode surfaces; +# the flat agents/ staging (mode_cards.ts) remains opencode's discovery path. schema_version = "1" mode = "autoresearch" card = "card.md" pack = "pack.toml" # Declared roles — the research pack casts the hypothesizer/experimenter/ -# analyzer trio, one phase each. +# analyzer trio, one phase each. Seeded from the deployed staging artifacts +# (D3, #806); content parity against the engine-neutral vault definitions is +# seed-gated on Aaron's signature — see docs/seed-gate/role-cards-seed-diff.md. protocol_skills = [ "director-core", "autoresearch", diff --git a/packages/extension/scripts/role_parity_check.mts b/packages/extension/scripts/role_parity_check.mts new file mode 100644 index 00000000..075ad0c4 --- /dev/null +++ b/packages/extension/scripts/role_parity_check.mts @@ -0,0 +1,293 @@ +#!/usr/bin/env node +// Role-card parity pin check CLI (amicode#806, obligation O8 — the +// pin-behind-HEAD check that rides the doctor's fleet cadence on the +// vault-visible machine). Deterministic, no LLM: verdicts derive from git +// revisions and content digests, never prose. +// +// node scripts/role_parity_check.mts --pin --vault +// [--ref ; + no_counterpart?: Array<{ role_card: string }>; +} + +interface Report { + status: Status; + pinned_revision: string | null; + vault_revision: string | null; + /** "pending-signature" while the B1 hold stands (no full-definition + * fixture is published); "published" once the fixtures return. */ + fixture_publication: "published" | "pending-signature" | "pending-re-authoring"; + drifted_files: string[]; + evidence: string[]; +} + +const EXIT = { ok: 0, drift: 1, usage: 2 } as const; + +function usage(msg: string): never { + process.stderr.write(`role-parity-check: ${msg}\n`); + process.stderr.write( + "usage: node scripts/role_parity_check.mts --pin --vault [--ref ]\n", + ); + process.exit(EXIT.usage); +} + +// ── args ───────────────────────────────────────────────────────────────────── +let pinPath: string | null = null; +let vaultPath: string | null = null; +let ref = "origin/main"; +let doFetch = false; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]!; + if (a === "--pin") pinPath = process.argv[++i] ?? usage("--pin requires a path"); + else if (a === "--vault") vaultPath = process.argv[++i] ?? usage("--vault requires a path"); + else if (a === "--ref") ref = process.argv[++i] ?? usage("--ref requires a gitish"); + else if (a === "--fetch") doFetch = true; + else usage(`unknown argument: ${a}`); +} +if (pinPath === null) usage("--pin is required"); +if (vaultPath === null) usage("--vault is required (pass the vault repo path; the cadence owns its default)"); + +const sha256 = (buf: Buffer | string): string => "sha256:" + createHash("sha256").update(buf).digest("hex"); + +function git(repo: string, args: string[]): { code: number; stdout: string; stderr: string } { + const r = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", timeout: 30_000 }); + return { code: r.status ?? -1, stdout: String(r.stdout ?? ""), stderr: String(r.stderr ?? "") }; +} + +const report = (status: Status, evidence: string[], extra: Partial = {}): never => { + const out: Report = { + status, + pinned_revision: pin.vault_revision, + vault_revision: null, + fixture_publication: + pin.fixture_publication ?? (pin.pinned.every((p) => p.fixture !== undefined) ? "published" : "pending-signature"), + drifted_files: [], + evidence, + ...extra, + }; + process.stdout.write(JSON.stringify(out) + "\n"); + // drift OR integrity failure → 1; current and the honest named + // non-verdicts (absent/unprobeable vault) → 0 + process.exit( + status === "behind-head" || status === "fixture-mismatch" || status === "pin-orphaned" + ? EXIT.drift + : EXIT.ok, + ); +}; + +// ── 1. the pin record (pre-flight: a broken record is never skippable) ────── +if (!existsSync(pinPath)) usage(`pin record not found: ${pinPath}`); +let pin: PinRecord; +try { + pin = JSON.parse(readFileSync(pinPath, "utf8")) as PinRecord; +} catch (e) { + usage(`pin record unparseable: ${(e as Error).message}`); +} +if ( + !((pin.record_version === 1 && pin.pinned.every((p) => typeof p.fixture === "string")) || + (pin.record_version === 2 && + (pin.fixture_publication === "published" || + pin.fixture_publication === "pending-signature" || + // SIGNED 2026-09-05 with the vault-re-authoring decision: the hold + // continues by decision — the third publication state. + pin.fixture_publication === "pending-re-authoring"))) || + typeof pin.vault_revision !== "string" || !Array.isArray(pin.pinned) || pin.pinned.length === 0 +) { + usage("pin record malformed: record_version must be 1 (fixtures published) or 2 (with fixture_publication), with vault_revision and a non-empty pinned set"); +} +const publication: "published" | "pending-signature" | "pending-re-authoring" = pin.fixture_publication + ?? (pin.record_version === 1 ? "published" : "pending-signature"); + +// ── 2. fixture integrity (self-contained: the committed fixtures vs the record) +// B1 hold: while the publication is pending-signature, the full-definition +// fixtures are deliberately ABSENT from this repo — nothing from the vault +// definitions is published-verbatim before the seed gate's signature. The +// hold is named evidence, never a failure (the pin remains verifiable +// against the vault revision directly, below). +const pinDir = dirname(resolve(pinPath)); +const evidence: string[] = []; +if (publication === "pending-signature") { + evidence.push("fixture publications held pending the seed-gate signature (review B1) — no full-definition fixture is published; pin genuineness is verified against the vault revision directly"); +} else if (publication === "pending-re-authoring") { + // SIGNED 2026-09-05 with the VAULT RE-AUTHORING decision: the hold + // continues by decision, not unsignedness — the fixtures re-take at the + // re-authored revision when the amicissimo follow-up lands. + evidence.push("fixture publications held pending the vault re-authoring (the signed seed-gate decision) — no full-definition fixture is published; pin genuineness is verified against the vault revision directly"); +} else { + for (const p of pin.pinned) { + const fixturePath = isAbsolute(p.fixture!) ? p.fixture! : join(pinDir, p.fixture!); + if (!existsSync(fixturePath) || !statSync(fixturePath).isFile()) { + report("fixture-mismatch", [`pinned fixture missing: ${p.fixture} (for role card ${p.role_card})`], { + pinned_revision: pin.vault_revision, + }); + } + const actual = sha256(readFileSync(fixturePath)); + if (actual !== p.sha256) { + evidence.push(`fixture ${p.fixture} (role card ${p.role_card}) drifted from its recorded digest (${actual.slice(0, 19)} ≠ ${p.sha256.slice(0, 19)})`); + } else { + evidence.push(`fixture ${p.fixture} byte-matches its recorded digest`); + } + } + if (evidence.some((e) => e.includes("drifted from its recorded digest"))) { + report("fixture-mismatch", evidence, { pinned_revision: pin.vault_revision }); + } +} + +// ── 3. the vault repo (absent/unprobeable are honest named non-verdicts) ───── +if (!existsSync(vaultPath)) { + report("vault-absent", [ + ...evidence, + `vault checkout absent at ${vaultPath} — the pin cannot be checked on this machine; the fixtures remain the self-contained pin`, + ], { + pinned_revision: pin.vault_revision, + }); +} +const revParse = git(vaultPath, ["rev-parse", "--verify", "HEAD"]); +if (revParse.code !== 0) { + report("vault-unprobeable", [`vault repo unprobeable at ${vaultPath}: git rev-parse HEAD failed (${revParse.stderr.trim()})`], { + pinned_revision: pin.vault_revision, + }); +} + +// ── the fetch (B2): a remote-prefixed ref under --fetch is FETCHED first ───── +// +// Local remote-tracking refs only move when something fetches; the nightly +// wrapper passes --fetch so the compare never runs against stale knowledge. +// A local-only ref (main, HEAD) needs no fetch. A fetch failure is the NAMED +// unknown vault-unfetchable — never a verdict, never a green receipt. +const remoteOfRef = ref.includes("/") && !ref.startsWith("HEAD") ? ref.split("/")[0] : null; +if (doFetch && remoteOfRef !== null) { + const fetch = git(vaultPath, ["fetch", "--quiet", remoteOfRef]); + if (fetch.code !== 0) { + report("vault-unfetchable", [ + ...evidence, + `git fetch ${remoteOfRef} failed in the vault repo (${fetch.stderr.trim()}) — the freshness comparison would run against stale remote-tracking state; a named unknown, never a green verdict`, + ]); + } else { + evidence.push(`fetched ${remoteOfRef} before the freshness compare (the nightly wiring)`); + } +} + +// the comparison ref: origin/main, falling back to HEAD when there is no +// origin (a fixture repo) — recorded, never silent. +let vaultRevision: string; +const refParse = git(vaultPath, ["rev-parse", "--verify", `${ref}^{commit}`]); +if (refParse.code === 0) { + vaultRevision = refParse.stdout.trim(); + evidence.push(`vault ref ${ref} → ${vaultRevision}`); +} else { + const headParse = git(vaultPath, ["rev-parse", "--verify", "HEAD"]); + vaultRevision = headParse.stdout.trim(); + evidence.push(`vault ref ${ref} unresolvable (${refParse.stderr.trim()}) — comparing against local HEAD ${vaultRevision}`); +} + +// ── 4. pin genuineness — the pinned revision is real history carrying the bytes +const pinExists = git(vaultPath, ["cat-file", "-e", `${pin.vault_revision}^{commit}`]); +if (pinExists.code !== 0) { + report("pin-orphaned", [`pinned revision ${pin.vault_revision} is absent from the vault repo's history (rewritten or never fetched) — the pin no longer points at real history`], { + vault_revision: vaultRevision, + }); +} +for (const p of pin.pinned) { + const show = git(vaultPath, ["show", `${pin.vault_revision}:${p.vault_path}`]); + if (show.code !== 0) { + report("pin-orphaned", [`pinned revision ${pin.vault_revision} does not carry ${p.vault_path} — the pin record and the vault history disagree`], { + vault_revision: vaultRevision, + }); + } + const atPin = sha256(show.stdout); + if (atPin !== p.sha256) { + report("fixture-mismatch", [ + ...evidence, + `${p.vault_path} at the pinned revision ${pin.vault_revision.slice(0, 12)} does not match the recorded digest (${atPin.slice(0, 19)} ≠ ${p.sha256.slice(0, 19)}) — the pin was not taken from these bytes`, + ], { vault_revision: vaultRevision }); + } + evidence.push(`${p.vault_path} at the pinned revision byte-matches the recorded digest`); +} + +// ── 5. pin freshness — did any pinned definition move past the pin? ───────── +const vaultPaths = pin.pinned.map((p) => p.vault_path); +const diff = git(vaultPath, ["diff", "--name-only", `${pin.vault_revision}..${vaultRevision}`, "--", ...vaultPaths]); +if (diff.code !== 0) { + report("vault-unprobeable", [`git diff ${pin.vault_revision.slice(0, 12)}..${vaultRevision} failed (${diff.stderr.trim()})`], { + vault_revision: vaultRevision, + }); +} +const drifted = diff.stdout.split("\n").map((l) => l.trim()).filter(Boolean); +if (drifted.length > 0) { + report("behind-head", [ + ...evidence, + ...drifted.map((f) => `pinned definition ${f} changed past the pinned revision ${pin.vault_revision.slice(0, 12)} — the parity fixtures are behind the vault`), + ], { vault_revision: vaultRevision, drifted_files: drifted }); +} +report("current", [...evidence, vaultRevision === pin.vault_revision + ? `the vault ref is exactly the pinned revision ${pin.vault_revision.slice(0, 12)} — the pin is current` + : `the vault ref moved to ${vaultRevision.slice(0, 12)} but no pinned definition changed — the pinned CONTENT is still current (re-pin at convenience, no drift)`], { + vault_revision: vaultRevision, +}); diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index bb870eb4..228a5f8d 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -240,7 +240,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine(`[pasqal] connector staging failed: ${(e as Error).message}`); } - // #533/#761: stage every mode card (two directors + five workers) into the + // #533/#761: stage every mode card (two directors, the four D3-seeded role + // cards, and the worker) into the // global opencode agents directory so they appear in the agent picker. // Same always-copy semantics as Pasqal staging: extension-owned, // overwrite-on-activate, never blocks activation. When the premium diff --git a/packages/extension/src/mode_cards.ts b/packages/extension/src/mode_cards.ts index 53ef5633..54c6b179 100644 --- a/packages/extension/src/mode_cards.ts +++ b/packages/extension/src/mode_cards.ts @@ -7,8 +7,9 @@ * agent discovery never globs it. * * This module stages EVERY card in the package's agents directory (the two - * directors + the five workers) into the global config agents directory on - * every activation — the same always-copy semantics as `pasqal_assets.ts`. + * directors, the four D3-seeded role cards, and the worker) into the global + * config agents directory on every activation — the same always-copy + * semantics as `pasqal_assets.ts`. * When the premium entitlement is present and an overlay source resolves, * method-class overlay fields are merged into the base cards before staging * (precedence: public base < entitled overlay); provenance lands in a diff --git a/packages/extension/test/fixtures/overlays/root/vault/agents/overlays/librarian-tuning.json b/packages/extension/test/fixtures/overlays/root/vault/agents/overlays/librarian-tuning.json index 15d6a6dd..9f6930c9 100644 --- a/packages/extension/test/fixtures/overlays/root/vault/agents/overlays/librarian-tuning.json +++ b/packages/extension/test/fixtures/overlays/root/vault/agents/overlays/librarian-tuning.json @@ -2,6 +2,9 @@ "overlay_version": 1, "id": "librarian-tuning", "fields": { - "model_routing": "the heavy reasoning class for curation casts that span campaigns; the standard class for single-note filing — the tuned default splits by cast weight, not by habit." + "prompt_body": "Tuned procedure — the librarian tuning overlay sharpens the default, never replaces it:\n\n1. Preload `amico-vault` and `amico-schema-check`; read the mount stack from disk, never from the brief's summary.\n2. Classify from the folder responsibility table; a claim with a local-only mechanism is TWO notes, authored up front.\n3. Validate the frontmatter before returning the filing receipt; a note that fails validation is fixed, not filed as-is.", + "model_routing": "the heavy reasoning class for curation casts that span campaigns; the standard class for single-note filing — the tuned default splits by cast weight, not by habit.", + "iteration_budget": "two curation passes and one validation pass per note cluster; a third pass needs the director's say-so named in the brief.", + "example_brief": "File the note, tuned: Vault: (mount stack from disk). Route per the vault skill; split local-only mechanisms into linked pairs. Schema-check every note; return one receipt per note." } } diff --git a/packages/extension/test/fixtures/overlays/root/vault/agents/overlays/researcher-tuning.json b/packages/extension/test/fixtures/overlays/root/vault/agents/overlays/researcher-tuning.json deleted file mode 100644 index 8b4f567a..00000000 --- a/packages/extension/test/fixtures/overlays/root/vault/agents/overlays/researcher-tuning.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "overlay_version": 1, - "id": "researcher-tuning", - "fields": { - "prompt_body": "Tuned procedure — the researcher tuning overlay sharpens the default, never replaces it:\n\n1. Re-read the named ledger sections from disk twice before scoring anything.\n2. Score every candidate on the review vocabulary: impact × ease, each 1–3; ties break toward the fewer-experiment test.\n3. Draft refutation cards for the top three contenders only — one falsifiable sentence, one named experiment, one refutation condition each.\n4. Return the ranked list with one-line rationales. Stop there — selection is the director's.", - "model_routing": "the heaviest reasoning class on the machine, every cast — ideation across a large prior-evidence corpus is the exact case the heavy class exists for; never downgrade a ranking pass to the standard class.", - "iteration_budget": "two ranking passes and up to two refinement passes per contending card; a third refinement pass needs the director's say-so named in the brief.", - "example_brief": "Queue thin, tuned: Ledger: (re-read §1/§2/§5/§7 twice). Open stock: hypotheses/ across the mounted vaults. Prior evidence: the notes named in §2. Rank on the review vocabulary; draft refutation cards for the top three. One cast, read-only." - } -} diff --git a/packages/extension/test/fixtures/vault-agents/pin.json b/packages/extension/test/fixtures/vault-agents/pin.json new file mode 100644 index 00000000..89e109ec --- /dev/null +++ b/packages/extension/test/fixtures/vault-agents/pin.json @@ -0,0 +1,32 @@ +{ + "record_version": 2, + "note": "The role-card parity fixtures' pin record (slice 2 / D3, #806, obligations O2/O8): the engine-neutral role definitions this pin targets, at the amicissimo vault revision they were pinned at, with the per-definition digests AT that revision — provenance WITHOUT content. Review B1 (PR #811): the full-definition fixtures are NOT published pending the seed-gate signature — the committed copies failed the amended content policy's per-line usage-vs-internals test (ADR-0011's categories: the engineer def's src/ module tree, how-to-extend recipe, and internals sections; the experimenter def's literal internal host paths), so nothing from the vault definitions is published-verbatim before Aaron decides. The nightly pin-behind-HEAD check (ops/role-parity) verifies the pin against the live amicissimo checkout directly — pin genuineness via git show at the recorded revision, freshness via the vault ref — and files a chore issue on drift. When the signature lands: either republish the fixtures (post-signature) or, recommended, run the vault re-authoring follow-up first (move package-internals out of the defs at their source in amicissimo, its own issue+PR), then re-take fixtures + this record at the new revision.", + "vault_repo": "harmoniqs/amicissimo", + "vault_revision": "5c6a1cd0d5bd240e07f6bef6467986e2fb41d7a7", + "fixture_publication": "pending-re-authoring", + "captured_at": "2026-09-05T03:45:00.000Z", + "pinned": [ + { + "role_card": "implementer", + "vault_path": "vault/agents/engineer.md", + "sha256": "sha256:4f10693c58f325e6a014cc3273e457bbf9753ebfafa191e0c5573492a607cc92" + }, + { + "role_card": "experimenter", + "vault_path": "vault/agents/experimenter.md", + "sha256": "sha256:9ed13551d22063d176cff0bf3fd33aa4b9f0937c4c941eaf2bf21fecd03ce2d5" + } + ], + "no_counterpart": [ + { + "role_card": "hypothesizer", + "nearest_kin": "vault/agents/researcher.md", + "reason": "the researcher is a decider returning one brief; the hypothesizer is a read-only proposer of ranked hypotheses — different governance and output; no overlap pin without inventing a mapping" + }, + { + "role_card": "analyzer", + "nearest_kin": "vault/agents/librarian.md", + "reason": "the librarian writes curated notes; the analyzer is read-only, raw-artifacts-only, and proposes ledger verdicts — no overlap pin without inventing a mapping" + } + ] +} diff --git a/packages/extension/test/mode_cards.test.ts b/packages/extension/test/mode_cards.test.ts index 799a5a04..b4441a85 100644 --- a/packages/extension/test/mode_cards.test.ts +++ b/packages/extension/test/mode_cards.test.ts @@ -231,16 +231,17 @@ describe("mode cards — blocklist (open-protocol vocabulary)", () => { // ── #761: worker base cards — contract floor + default-method-body floor ──── // -// The five worker cards (analyzer, experimenter, hypothesizer, implementer, -// librarian) are public base cards: complete products with no entitlement. -// The floor tests pin what the overlay architecture depends on — the frozen -// output contract, the four pinned sections, and a complete default for -// every method-class dimension an overlay tunes. +// D3 (#806) re-homed the four director-cast role cards (hypothesizer, +// experimenter, analyzer, implementer) into the mode bundles as seeded +// registry artifacts — they left the #758 overlay-tunable worker-card +// architecture (their structural floor now lives in role_cards.test.ts, +// and their content is seed-gated per spec-20260905-063000 D3). The overlay +// architecture's shipped surface is the LIBRARIAN: the one worker card that +// still declares a dispatch target and carries the merge anchors. These +// floor tests pin what that architecture depends on — the frozen output +// contract, the four pinned sections, and a complete default for every +// method-class dimension an overlay tunes. const WORKER_CARDS = [ - "analyzer.md", - "experimenter.md", - "hypothesizer.md", - "implementer.md", "librarian.md", ] as const; type WorkerCard = (typeof WORKER_CARDS)[number]; diff --git a/packages/extension/test/mode_cards_staging.test.ts b/packages/extension/test/mode_cards_staging.test.ts index d25726c0..c9e123b9 100644 --- a/packages/extension/test/mode_cards_staging.test.ts +++ b/packages/extension/test/mode_cards_staging.test.ts @@ -121,10 +121,13 @@ describe("stageModCards", () => { // // The overlay source fixture mirrors the premium checkout's overlay layout // (/vault/agents/overlays/.json); the real overlay content arrives -// with the premium slice. researcher-tuning targets hypothesizer (all four -// method-class fields); librarian-tuning targets analyzer + librarian (one -// field, two cards); experimenter-tuning and engineer-tuning are DELIBERATELY -// absent — those cards stage alone, no missing-target errors. +// with the premium slice. Since D3 (#806) re-homed the four role cards into +// the mode bundles (no dispatch targets — they are director-cast registry +// artifacts, not overlay-tunable worker cards), the SHIPPED overlay surface +// is librarian-tuning → librarian.md (all four method-class fields). The +// multi-card and absent-target behaviors are product behaviors of the merge +// machinery itself — pinned on synthetic fixtures below, never by forcing a +// shipped card back into a shape D3 moved it out of. const OVERLAY_ROOT = join(__dirname, "fixtures", "overlays", "root"); const ENTITLED = ["amicissimo"]; @@ -157,7 +160,7 @@ describe("stageModCards — entitlement gate", () => { entitlementConfigDir: configDir, overlaySource: OVERLAY_ROOT, }); - const staged = readFileSync(join(destDir, "hypothesizer.md"), "utf8"); + const staged = readFileSync(join(destDir, "librarian.md"), "utf8"); expect(staged).toContain("Model routing, tuned:"); }); @@ -195,13 +198,13 @@ describe("stageModCards — overlay merge (entitlement + overlays present)", () it("merges method-class fields into the dispatched card; defaults replaced", () => { const { destDir } = stageEntitled(); - const staged = readFileSync(join(destDir, "hypothesizer.md"), "utf8"); - const base = readFileSync(join(AGENTS_SRC, "hypothesizer.md"), "utf8"); + const staged = readFileSync(join(destDir, "librarian.md"), "utf8"); + const base = readFileSync(join(AGENTS_SRC, "librarian.md"), "utf8"); // all four method-class dimensions merged - expect(staged).toContain("Tuned procedure — the researcher tuning overlay sharpens the default"); - expect(staged).toContain("Model routing, tuned: the heaviest reasoning class on the machine"); - expect(staged).toContain("Iteration budget, tuned: two ranking passes"); - expect(staged).toContain("Queue thin, tuned: Ledger: "); + expect(staged).toContain("Tuned procedure — the librarian tuning overlay sharpens the default"); + expect(staged).toContain("Model routing, tuned: the heavy reasoning class for curation casts"); + expect(staged).toContain("Iteration budget, tuned: two curation passes"); + expect(staged).toContain("File the note, tuned: Vault: "); // the base defaults they replace are GONE from the staged card expect(staged).not.toContain("Model routing, default:"); expect(staged).not.toContain("Iteration budget, default:"); @@ -212,50 +215,75 @@ describe("stageModCards — overlay merge (entitlement + overlays present)", () expect(fm(staged)).toBe(fm(base)); }); - it("one overlay covers two cards (librarian-tuning → analyzer + librarian)", () => { - const { destDir, receipt } = stageEntitled(); - for (const card of ["analyzer.md", "librarian.md"]) { - const staged = readFileSync(join(destDir, card), "utf8"); - expect(staged).toContain("Model routing, tuned: the heavy reasoning class for curation casts"); + it("one overlay covers two cards — pinned on synthetic fixtures (D3 moved the shipped role cards out of the overlay architecture)", () => { + // the product behavior (an overlay id may be named by more than one + // card; the registry resolves per card) survives D3 — carried here by + // two synthetic cards sharing one dispatch target, never by forcing a + // shipped card back into the worker-card shape. + const fakeExt = mkdtempSync(join(tmpdir(), "mode-cards-twocards-")); + mkdirSync(join(fakeExt, "agents"), { recursive: true }); + for (const name of ["alpha.md", "beta.md"]) { + writeFileSync(join(fakeExt, "agents", name), fixtureCard("text").replace("fixture-tuning", "shared-tuning")); + } + const root = mkdtempSync(join(tmpdir(), "mode-cards-twocards-src-")); + const dir = join(root, "vault", "agents", "overlays"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "shared-tuning.json"), + JSON.stringify({ overlay_version: 1, id: "shared-tuning", fields: { model_routing: "TUNED-SHARED-ROUTING" } }), + ); + const destDir = mkdtempSync(join(tmpdir(), "mode-cards-twocards-dest-")); + const r = stageModCards(fakeExt, destDir, { entitlements: ENTITLED, overlaySource: root }); + for (const name of ["alpha.md", "beta.md"]) { + const staged = readFileSync(join(destDir, name), "utf8"); + expect(staged).toContain("Model routing, tuned: TUNED-SHARED-ROUTING"); expect(staged).not.toContain("Model routing, default:"); - const rec = receipt.cards.find((c: { card: string }) => c.card === card); - expect(rec.overlay_id).toBe("librarian-tuning"); + const rec = (JSON.parse(readFileSync(r.receiptPath, "utf8")) as { cards: Array<{ card: string; overlay_id: string | null; merged_fields: string[] }> }) + .cards.find((c) => c.card === name); + expect(rec.overlay_id).toBe("shared-tuning"); expect(rec.merged_fields).toEqual(["model_routing"]); } }); - it("cards whose dispatch target is absent from the registry stage alone, no errors", () => { + it("cards with NO dispatch target stage alone, no errors — role cards, directors, and an absent-target synthetic", () => { const { destDir, receipt, result } = stageEntitled(); - for (const card of ["experimenter.md", "implementer.md"]) { + // D3 (#806): the four seeded role cards carry no dispatch target — + // bundle-owned registry artifacts, never overlay-tuned + for (const card of ["hypothesizer.md", "experimenter.md", "analyzer.md", "implementer.md", "autodev.md", "autoresearch.md"]) { expect(readFileSync(join(destDir, card), "utf8")).toBe( readFileSync(join(AGENTS_SRC, card), "utf8"), ); const rec = receipt.cards.find((c: { card: string }) => c.card === card); expect(rec.overlay_id).toBeNull(); } - // directors carry no dispatch field at all — base, never overlay - for (const card of ["autodev.md", "autoresearch.md"]) { - expect(readFileSync(join(destDir, card), "utf8")).toBe( - readFileSync(join(AGENTS_SRC, card), "utf8"), - ); - } expect(result.rejections).toEqual([]); + // the absent-target behavior itself (a card whose dispatch target is not + // in the registry) — on a synthetic card, since no shipped card is in + // that state after D3 + const fakeExt = mkdtempSync(join(tmpdir(), "mode-cards-absent-")); + mkdirSync(join(fakeExt, "agents"), { recursive: true }); + writeFileSync(join(fakeExt, "agents", "absent.md"), fixtureCard("text")); + const destDir2 = mkdtempSync(join(tmpdir(), "mode-cards-absent-dest-")); + const r2 = stageModCards(fakeExt, destDir2, { entitlements: ENTITLED, overlaySource: OVERLAY_ROOT }); + expect(readFileSync(join(destDir2, "absent.md"), "utf8")).toBe(fixtureCard("text")); + expect(r2.merges).toEqual([]); + expect(r2.rejections).toEqual([]); }); it("provenance lands in the merge record — never in the staged card", () => { const { destDir, receipt } = stageEntitled(); - const rec = receipt.cards.find((c: { card: string }) => c.card === "hypothesizer.md"); + const rec = receipt.cards.find((c: { card: string }) => c.card === "librarian.md"); // merge record: base card name, base content hash, overlay id, timestamp - expect(rec.card).toBe("hypothesizer.md"); + expect(rec.card).toBe("librarian.md"); const digest = createHash("sha256") - .update(readFileSync(join(AGENTS_SRC, "hypothesizer.md"))) + .update(readFileSync(join(AGENTS_SRC, "librarian.md"))) .digest("hex"); expect(rec.base_sha256).toBe(`sha256:${digest}`); - expect(rec.overlay_id).toBe("researcher-tuning"); + expect(rec.overlay_id).toBe("librarian-tuning"); expect(rec.merged_fields).toEqual(["prompt_body", "model_routing", "iteration_budget", "example_brief"]); expect(rec.merged_at).toBe(fixedNow); // the STAGED card carries no provenance: no hash, no merge timestamp - const staged = readFileSync(join(destDir, "hypothesizer.md"), "utf8"); + const staged = readFileSync(join(destDir, "librarian.md"), "utf8"); expect(staged).not.toContain("sha256:"); expect(staged).not.toContain(fixedNow); expect(staged).not.toContain("merged_fields"); @@ -285,26 +313,26 @@ describe("overlay field classification (the freeze table)", () => { }); describe("mergeOverlayIntoCard — freeze enforcement (table-driven)", () => { - const BASE = readFileSync(join(AGENTS_SRC, "hypothesizer.md"), "utf8"); + const BASE = readFileSync(join(AGENTS_SRC, "librarian.md"), "utf8"); // one offending field per interface-class entry, each named in the error it.each([...INTERFACE_CLASS_FIELDS])("rejects interface-class field %s", (field) => { - const overlay = { id: "researcher-tuning", fields: { [field]: "BOGUS_INTERFACE_OVERRIDE" } }; - expect(() => mergeOverlayIntoCard(BASE, overlay, "hypothesizer.md")).toThrow( + const overlay = { id: "librarian-tuning", fields: { [field]: "BOGUS_INTERFACE_OVERRIDE" } }; + expect(() => mergeOverlayIntoCard(BASE, overlay, "librarian.md")).toThrow( new RegExp(`"${field}" is interface-class`), ); }); it.each(["banana_split", "role", "tools"])("rejects unclassified field %s", (field) => { - const overlay = { id: "researcher-tuning", fields: { [field]: "BOGUS_UNCLASSIFIED_OVERRIDE" } }; - expect(() => mergeOverlayIntoCard(BASE, overlay, "hypothesizer.md")).toThrow( + const overlay = { id: "librarian-tuning", fields: { [field]: "BOGUS_UNCLASSIFIED_OVERRIDE" } }; + expect(() => mergeOverlayIntoCard(BASE, overlay, "librarian.md")).toThrow( new RegExp(`"${field}" is unclassified-class`), ); }); }); describe("stageModCards — freeze violations stage the base alone, rejection recorded", () => { - const BASE_HYPOTHESIZER = () => readFileSync(join(AGENTS_SRC, "hypothesizer.md"), "utf8"); + const BASE_LIBRARIAN = () => readFileSync(join(AGENTS_SRC, "librarian.md"), "utf8"); function rootWithOverlay(overlayId: string, fields: Record): string { const root = mkdtempSync(join(tmpdir(), "mode-cards-freeze-")); @@ -320,12 +348,12 @@ describe("stageModCards — freeze violations stage the base alone, rejection re const destDir = mkdtempSync(join(tmpdir(), "mode-cards-frz-")); const r = stageModCards(EXTENSION_PATH, destDir, { entitlements: ENTITLED, - overlaySource: rootWithOverlay("researcher-tuning", { [field]: "BOGUS" }), + overlaySource: rootWithOverlay("librarian-tuning", { [field]: "BOGUS" }), }); - expect(readFileSync(join(destDir, "hypothesizer.md"), "utf8")).toBe(BASE_HYPOTHESIZER()); - const rej = r.rejections.find((x) => x.card === "hypothesizer.md"); + expect(readFileSync(join(destDir, "librarian.md"), "utf8")).toBe(BASE_LIBRARIAN()); + const rej = r.rejections.find((x) => x.card === "librarian.md"); expect(rej).toBeDefined(); - expect(rej.overlay_id).toBe("researcher-tuning"); + expect(rej.overlay_id).toBe("librarian-tuning"); expect(rej.reason).toContain(`"${field}"`); }, ); @@ -360,33 +388,28 @@ describe("stageModCards — freeze violations stage the base alone, rejection re // ── #761: the dispatch-target validator ─────────────────────────────────── describe("dispatch-target validator", () => { - it("every worker card's dispatch target is a well-formed slug; directors carry none", () => { + it("every shipped card with a dispatch target is a well-formed slug; the rest carry none", () => { for (const f of expectedCards()) { const text = readFileSync(join(AGENTS_SRC, f), "utf8"); const target = cardDispatch(text); - if (f === "autodev.md" || f === "autoresearch.md") { - expect(target, `${f} — directors dispatch no overlay`).toBeUndefined(); - } else { - expect(target, `${f} declares a dispatch target`).toBeDefined(); + if (f === "librarian.md") { + expect(target, "librarian — the surviving worker card — declares a dispatch target").toBeDefined(); expect(() => validateDispatchTarget(f, target!)).not.toThrow(); + } else { + // directors + the four D3-seeded role cards dispatch no overlay + expect(target, `${f} dispatches no overlay`).toBeUndefined(); } } }); - it("the five workers name the four tuned targets (librarian-tuning covers two)", () => { + it("after D3 the only shipped dispatch target is librarian-tuning, on librarian alone", () => { const targets = new Map(); for (const f of expectedCards()) { const target = cardDispatch(readFileSync(join(AGENTS_SRC, f), "utf8")); if (target === undefined) continue; targets.set(target, [...(targets.get(target) ?? []), f]); } - expect([...targets.keys()].sort()).toEqual([ - "engineer-tuning", - "experimenter-tuning", - "librarian-tuning", - "researcher-tuning", - ]); - expect(targets.get("librarian-tuning")?.sort()).toEqual(["analyzer.md", "librarian.md"]); + expect([...targets.entries()]).toEqual([["librarian-tuning", ["librarian.md"]]]); }); it("a malformed dispatch target throws (loud — a base-card defect)", () => { diff --git a/packages/extension/test/ops/role_parity_orchestrator.test.ts b/packages/extension/test/ops/role_parity_orchestrator.test.ts new file mode 100644 index 00000000..c7562379 --- /dev/null +++ b/packages/extension/test/ops/role_parity_orchestrator.test.ts @@ -0,0 +1,334 @@ +// Nightly role-parity pin cadence (amicode#806, obligation O8) — the +// orchestrator's contract, exercised on fixture pin records and fixture +// vault repos (hermetic: temp git repos, never the real amicissimo +// checkout, never the network). +// +// --dry-run is the testable seam for the drift/issue path: it runs the REAL +// check CLI (scripts/role_parity_check.mts) and prints WOULD-DO lines to +// stderr, but appends NO receipt and touches NO issues. The receipt-append +// behavior is covered by the REAL-run cells (fetch-failure unknown and the +// check-failed receipt — neither takes the issue path); the GitHub-issue +// path runs only on real drift outside --dry-run and is verified by the +// documented manual run on the vault-visible machine (the skill-freshness +// precedent). +// +// Review B1 (PR #811): the shipped pin record is pending-signature — the +// full-definition fixtures are deliberately absent, and the cells mirror +// that state; one published-mode pair exercises the post-signature fixture +// integrity path. Review B2: the wrapper fetches the ref's remote before +// the freshness compare — the fetch-discovery cell proves a remote drift is +// invisible without the fetch and caught with it, and a fetch failure is a +// named unknown receipt, never green. +// +// Gating: the orchestrator execs the .mts check through `node`, which needs +// native TS type-stripping (node >= 22.6, enableable via NODE_OPTIONS — the +// wrapper's internal `node` calls inherit it). Probed by capability, not by +// this process's own flags (vitest's node doesn't carry them); the suite +// skips cleanly on older node. +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; + +const STRIP_PROBE = spawnSync( + process.execPath, + ["-e", 'process.stdout.write(process.features.typescript === "strip" ? "yes" : "no")'], + { encoding: "utf8", env: { ...process.env, NODE_OPTIONS: "--experimental-strip-types" } }, +); +const NODE_STRIPS_TYPES = STRIP_PROBE.status === 0 && STRIP_PROBE.stdout.trim() === "yes"; +(NODE_STRIPS_TYPES ? describe : describe.skip)("role-parity orchestrator (fixtures)", () => { + const EXT_ROOT = path.resolve(__dirname, "..", ".."); + const OPS_SCRIPT = path.resolve(EXT_ROOT, "..", "..", "ops", "role-parity", "run-role-parity-check.sh"); + const CHECK_CLI = path.join(EXT_ROOT, "scripts", "role_parity_check.mts"); + const REAL_PIN = path.join(EXT_ROOT, "test", "fixtures", "vault-agents", "pin.json"); + + const tmpDirs: string[] = []; + function tmpRoot(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "role-parity-")); + tmpDirs.push(dir); + return dir; + } + afterEach(() => { + for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + const sha256 = (p: string): string => + "sha256:" + createHash("sha256").update(fs.readFileSync(p)).digest("hex"); + + /** git with a stable identity (fixture repos never need real config). */ + function git(dir: string, args: string[], env: Record = {}): string { + const r = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`); + return String(r.stdout ?? ""); + } + const GIT_ID = { + GIT_AUTHOR_NAME: "fixture", + GIT_AUTHOR_EMAIL: "fixture@example.test", + GIT_COMMITTER_NAME: "fixture", + GIT_COMMITTER_EMAIL: "fixture@example.test", + }; + + const DEFINITION_AT_PIN = [ + "# Engineer Agent (fixture definition)", + "", + "The engine-neutral engineer: works on branches, never deletes tests,", + "delegates to the implement-issue leaf in develop mode.", + "", + ].join("\n"); + + interface WorldOpts { + /** "pending-signature" (the shipped B1 hold: NO fixture files) or + * "published" (the post-signature shape: fixture files + paths). */ + publication?: "published" | "pending-signature"; + } + + /** A fixture world: a v2 pin record + a vault git repo whose main + * carries the definitions at the pinned revision. */ + function fixtureWorld(over: WorldOpts = {}): { + root: string; + pinPath: string; + vault: string; + definitionPath: string; + pinnedRevision: string; + } { + const publication = over.publication ?? "pending-signature"; + const root = tmpRoot(); + const vault = path.join(root, "amicissimo"); + const definitionPath = path.join(vault, "vault", "agents", "engineer.md"); + fs.mkdirSync(path.join(vault, "vault", "agents"), { recursive: true }); + fs.writeFileSync(definitionPath, DEFINITION_AT_PIN); + if (publication === "published") { + fs.mkdirSync(path.join(root, "fixtures"), { recursive: true }); + fs.writeFileSync(path.join(root, "fixtures", "engineer.md"), DEFINITION_AT_PIN); + } + git(vault, ["init", "-b", "main"]); + git(vault, ["add", "-A"]); + git(vault, ["-c", "user.name=fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "definitions"], GIT_ID); + const pinnedRevision = git(vault, ["rev-parse", "HEAD"]).trim(); + // the v2 pin record: the revision + digests AT the revision (provenance + // without content); fixture paths only when published + fs.writeFileSync( + path.join(root, "pin.json"), + JSON.stringify( + { + record_version: 2, + vault_repo: "harmoniqs/amicissimo", + vault_revision: pinnedRevision, + fixture_publication: publication, + pinned: [ + publication === "published" + ? { + role_card: "implementer", + vault_path: "vault/agents/engineer.md", + fixture: "fixtures/engineer.md", + sha256: sha256(path.join(root, "fixtures", "engineer.md")), + } + : { + role_card: "implementer", + vault_path: "vault/agents/engineer.md", + sha256: sha256(definitionPath), + }, + ], + }, + null, + 2, + ) + "\n", + ); + return { root, pinPath: path.join(root, "pin.json"), vault, definitionPath, pinnedRevision }; + } + + function runWrapper( + args: string[], + root: string, + pinPath: string, + vault: string, + extra: Record = {}, + ) { + return spawnSync("/bin/bash", [OPS_SCRIPT, ...args], { + encoding: "utf8", + env: { + ...process.env, + ROLE_PARITY_CHECK: CHECK_CLI, + ROLE_PARITY_PIN: pinPath, + ROLE_PARITY_VAULT: vault, + ROLE_PARITY_REF: "main", // fixture repos have no origin (local ref) + ROLE_PARITY_RECEIPTS: path.join(root, "receipts", "upgrade-receipts.jsonl"), + ROLE_PARITY_TRACKING_REPO: "harmoniqs/amicode", + ...extra, + }, + }); + } + + const runDryRun = (root: string, pinPath: string, vault: string, extra: Record = {}) => + runWrapper(["--dry-run"], root, pinPath, vault, extra); + const runReal = (root: string, pinPath: string, vault: string, extra: Record = {}) => + runWrapper([], root, pinPath, vault, extra); + + const receipts = (root: string): Array> => + fs + .readFileSync(path.join(root, "receipts", "upgrade-receipts.jsonl"), "utf8") + .split("\n") + .filter((l) => l.trim()) + .map((l) => JSON.parse(l) as Record); + + it("current (pending-signature hold): status current, the B1 hold named in evidence, WOULD-DO nothing, no receipt", () => { + const w = fixtureWorld(); + const r = runDryRun(w.root, w.pinPath, w.vault); + expect(r.status).toBe(0); + expect(r.stderr).toMatch(/status=current/); + expect(r.stderr).toMatch(/publication=pending-signature/); + expect(r.stderr).toMatch(/fixture publications held pending the seed-gate signature/); + expect(r.stderr).toMatch(/WOULD-DO: no issue action/); + expect(fs.existsSync(path.join(w.root, "receipts"))).toBe(false); + }); + + it("drift: a pinned definition changed past the pin → exit 1, behind-head, the file named, WOULD-DO the chore issue", () => { + const w = fixtureWorld(); + fs.writeFileSync(w.definitionPath, DEFINITION_AT_PIN + "\n(Amended engine-neutral semantics.)\n"); + git(w.vault, ["add", "-A"]); + git(w.vault, ["-c", "user.name=fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "amend definitions"], GIT_ID); + const r = runDryRun(w.root, w.pinPath, w.vault); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/status=behind-head/); + expect(r.stderr).toContain("vault/agents/engineer.md"); + expect(r.stderr).toMatch(/WOULD-DO: open-or-update chore issue 'Role-card parity pin behind the vault \(nightly\)'/); + expect(fs.existsSync(path.join(w.root, "receipts"))).toBe(false); + }); + + it("revision churn WITHOUT touching a pinned definition → exit 0 (low-noise: the pin's content is what drift means)", () => { + const w = fixtureWorld(); + fs.writeFileSync(path.join(w.vault, "vault", "unrelated.md"), "churn\n"); + git(w.vault, ["add", "-A"]); + git(w.vault, ["-c", "user.name=fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "unrelated churn"], GIT_ID); + const r = runDryRun(w.root, w.pinPath, w.vault); + expect(r.status).toBe(0); + expect(r.stderr).toMatch(/no pinned definition changed/); + }); + + it("pin orphaned: the pinned revision absent from the vault's history → exit 1, pin-orphaned named", () => { + const w = fixtureWorld(); + // a DIFFERENT vault repo that never carried the pinned revision + const otherVault = path.join(w.root, "other-amicissimo"); + fs.mkdirSync(path.join(otherVault, "vault", "agents"), { recursive: true }); + fs.writeFileSync(path.join(otherVault, "vault", "agents", "engineer.md"), "different history\n"); + git(otherVault, ["init", "-b", "main"]); + git(otherVault, ["add", "-A"]); + git(otherVault, ["-c", "user.name=fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "other"], GIT_ID); + const r = runDryRun(w.root, w.pinPath, otherVault); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/status=pin-orphaned/); + }); + + it("vault absent: an honest named skip → exit 0 (a pin is only loud where something can run)", () => { + const w = fixtureWorld(); + const r = runDryRun(w.root, w.pinPath, path.join(w.root, "no-such-vault")); + expect(r.status).toBe(0); + expect(r.stderr).toMatch(/status=vault-absent/); + expect(r.stderr).toMatch(/cannot be checked on this machine/); + }); + + it("published mode (post-signature shape): fixtures byte-match → integrity green; a tampered fixture → exit 1 fixture-mismatch named", () => { + const w = fixtureWorld({ publication: "published" }); + const clean = runDryRun(w.root, w.pinPath, w.vault); + expect(clean.status).toBe(0); + expect(clean.stderr).toMatch(/publication=published/); + expect(clean.stderr).toMatch(/fixture fixtures\/engineer\.md byte-matches its recorded digest/); + + fs.writeFileSync(path.join(w.root, "fixtures", "engineer.md"), DEFINITION_AT_PIN + "\n(drifted fixture bytes)\n"); + const r = runDryRun(w.root, w.pinPath, w.vault); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/status=fixture-mismatch/); + expect(r.stderr).toMatch(/drifted from its recorded digest/); + }); + + it("the REAL committed pin record: pending-signature pre-flight green, the B1 hold named, honest vault-absent skip (hermetic)", () => { + const root = tmpRoot(); + const r = spawnSync("/bin/bash", [OPS_SCRIPT, "--dry-run"], { + encoding: "utf8", + env: { + ...process.env, + ROLE_PARITY_CHECK: CHECK_CLI, + ROLE_PARITY_PIN: REAL_PIN, + ROLE_PARITY_VAULT: path.join(root, "no-such-vault"), + ROLE_PARITY_REF: "origin/main", + ROLE_PARITY_RECEIPTS: path.join(root, "receipts", "upgrade-receipts.jsonl"), + ROLE_PARITY_TRACKING_REPO: "harmoniqs/amicode", + }, + }); + expect(r.status).toBe(0); + expect(r.stderr).toMatch(/status=vault-absent/); + // the real record is the SIGNED state's hold: accepted 2026-09-05 with + // the vault-re-authoring decision — the fixtures stay unpublished + // (by decision now, not unsignedness) until the amicissimo follow-up + // lands; the hold is named, nothing published-verbatim + expect(r.stderr).toMatch(/publication=pending-re-authoring/); + expect(r.stderr).toMatch(/fixture publications held pending the vault re-authoring/); + }); + + // B2 — the fetch: a remote drift is INVISIBLE without the fetch and CAUGHT + // with it (the wrapper passes --fetch; the stale-local control proves the + // fetch is load-bearing, not decorative) + it("B2 fetch discovery: a drifted vault REMOTE reads behind-head through the wrapper's --fetch; without the fetch the stale local ref would lie current", () => { + const w = fixtureWorld(); + // give the vault an origin: a bare remote, with the drift landing ONLY + // on the remote (the local checkout stays at the pinned revision) + const bare = path.join(w.root, "amicissimo.git"); + spawnSync("git", ["init", "--bare", "-b", "main", bare], { encoding: "utf8" }); + git(w.vault, ["remote", "add", "origin", bare]); + git(w.vault, ["push", "-u", "origin", "main"]); + const clone = path.join(w.root, "drift-clone"); + spawnSync("git", ["clone", "--branch", "main", bare, clone], { encoding: "utf8" }); + fs.writeFileSync(path.join(clone, "vault", "agents", "engineer.md"), DEFINITION_AT_PIN + "\n(Remote-only amendment.)\n"); + git(clone, ["add", "-A"]); + git(clone, ["-c", "user.name=fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "remote drift"], GIT_ID); + git(clone, ["push", "origin", "main"]); + + // control: the CLI WITHOUT --fetch compares against the stale local + // origin/main and reads current — the lie the fetch exists to prevent + const noFetch = spawnSync(process.execPath, [ + "--experimental-strip-types", CHECK_CLI, + "--pin", w.pinPath, "--vault", w.vault, "--ref", "origin/main", + ], { encoding: "utf8" }); + expect(noFetch.status).toBe(0); + expect(JSON.parse(noFetch.stdout).status).toBe("current"); + + // the wrapper (--fetch wired): the remote drift is discovered + const r = runDryRun(w.root, w.pinPath, w.vault, { ROLE_PARITY_REF: "origin/main" }); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/status=behind-head/); + expect(r.stderr).toMatch(/fetched origin before the freshness compare/); + }); + + // B2 — a fetch failure is a named unknown receipt, never green + it("B2 fetch failure: an unreachable remote → status vault-unfetchable, exit 0, and the REAL receipt carries the named unknown (never green)", () => { + const w = fixtureWorld(); + // a remote that cannot be fetched: a bogus URL + git(w.vault, ["remote", "add", "origin", "https://no-such-host.example.invalid/amicissimo.git"]); + const r = runReal(w.root, w.pinPath, w.vault, { ROLE_PARITY_REF: "origin/main" }); + expect(r.status).toBe(0); + expect(r.stderr).toMatch(/status=vault-unfetchable/); + expect(r.stderr).toMatch(/never a green verdict/); + const lines = receipts(w.root); + expect(lines).toHaveLength(1); + expect(lines[0]!.status).toBe("vault-unfetchable"); + expect(lines[0]!.fixture_publication).toBe("pending-signature"); + }); + + // B2 nit — a CLI exit 2 (pre-flight/runtime failure) appends a NAMED + // check-failed receipt, never a malformed `"status":""` line + it("B2 check-failed receipt: a broken pin record → exit 2, the receipt names check-failed (no empty status, no issue action)", () => { + const w = fixtureWorld(); + fs.writeFileSync(w.pinPath, "{ not json"); + const r = runReal(w.root, w.pinPath, w.vault); + expect(r.status).toBe(2); + expect(r.stderr).toMatch(/check FAILED/); + const lines = receipts(w.root); + expect(lines).toHaveLength(1); + expect(lines[0]!.status).toBe("check-failed"); + expect(String(lines[0]!.status).length).toBeGreaterThan(0); + expect(lines[0]!.check_exit).toBe(2); + expect(lines[0]!.tracking_issue).toBeUndefined(); + }); +}); diff --git a/packages/extension/test/role_cards.test.ts b/packages/extension/test/role_cards.test.ts new file mode 100644 index 00000000..8974113f --- /dev/null +++ b/packages/extension/test/role_cards.test.ts @@ -0,0 +1,229 @@ +// role_cards.test.ts — slice 2 / D3 (#806, spec-20260905-063000): the four +// director-cast role cards (hypothesizer, experimenter, analyzer, implementer) +// are VERSIONED repo sources owned by their mode bundles, SEEDED verbatim +// from the live deployed staging artifacts on the seed machine (erlich's +// server staging root) — the seed gate's integrity half. +// +// The seed is GATED (spec D3): the deployed artifacts are plausibly +// per-machine drifted, so garbage-in must never be enshrined as the tested +// baseline. This file pins the RECORD side of that gate, mechanically: +// +// - SEED INTEGRITY — each repo card's bytes match the sha256 recorded in +// packages/extension/agents/.seed-provenance.json (the record of what the +// implementing cast seeded). The repo carries either the seed VERBATIM or +// a RECORDED amendment (the human-signed diff): a card that drifts from +// its recorded bytes with no recorded amendment is a failure here, never +// a silent pass. +// - OWNERSHIP — the bundles' manifests declare the roles they cast +// (research: hypothesizer/experimenter/analyzer; dev: implementer), and +// the shared validator is green over the whole registry (the one +// validator both the vitest suite and the doctor import). +// - STAGING — the atomic bundle stager materializes each role card into +// its bundle's roles/ dir, byte-identical, with a digest row on the +// deploy receipt — staged and digest-verified with the director cards. +// - STRUCTURAL FLOOR — frontmatter shape, the blocklist (open-protocol +// vocabulary), and the cast-card section structure. Structural only, +// pre-signature: the seed-diff gate owns content parity +// (role_cards_parity.test.ts), and this floor never pins flagged +// content. +import { describe, it, expect } from "vitest"; +import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, cpSync, readdirSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + parseModeManifest, + validateModeRegistry, + declaredComponents, + stageModeBundles, +} from "@amicode/schema"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXT = resolve(HERE, ".."); +const AGENTS_DIR = join(EXT, "agents"); +const MODES_DIR = join(EXT, "modes"); +const PROVENANCE_PATH = join(AGENTS_DIR, ".seed-provenance.json"); + +/** The four role cards this slice owns, by bundle. */ +const ROLE_CARDS = { + autoresearch: ["hypothesizer", "experimenter", "analyzer"] as const, + autodev: ["implementer"] as const, +} as const; +const ALL_ROLES = [...ROLE_CARDS.autoresearch, ...ROLE_CARDS.autodev] as const; +type Role = (typeof ALL_ROLES)[number]; + +const cardPath = (role: Role): string => join(AGENTS_DIR, `${role}.md`); +const cardText = (role: Role): string => readFileSync(cardPath(role), "utf8"); +const sha256 = (p: string): string => + "sha256:" + createHash("sha256").update(readFileSync(p)).digest("hex"); + +// D4's proper-noun blocklist, from the fixture of record — the same +// floor the director cards and the worker cards carry. +const BLOCKLIST = JSON.parse(readFileSync(join(EXT, "protocol-blocklist.json"), "utf8")) as { + proprietary_strings: string[]; + banned_names: string[]; +}; +const BLOCKED = [...BLOCKLIST.proprietary_strings, ...BLOCKLIST.banned_names]; + +// Guards: the blocklist fixtures of record carry the real lists — an empty +// blocklist would make this floor vacuously green. +expect(BLOCKLIST.proprietary_strings.length).toBeGreaterThanOrEqual(5); +expect(BLOCKLIST.banned_names.length).toBeGreaterThanOrEqual(2); + +interface ProvenanceEntry { + role: string; + source_path: string; + seeded_sha256: string; + seeded_at: string; +} +interface SeedProvenance { + record_version: number; + issue: number; + seed_machine: string; + seed_root: string; + captured_at: string; + /** Absent/false until the human gate signs an amendment (the seed gate). */ + amended?: boolean; + amendment_signed_by?: string; + roles: ProvenanceEntry[]; +} + +describe("seed provenance — the record of what was seeded (the gate's mechanical half)", () => { + it("the committed record exists and names the seed machine, root, and all four roles", () => { + expect(existsSync(PROVENANCE_PATH), ".seed-provenance.json must be committed").toBe(true); + const record = JSON.parse(readFileSync(PROVENANCE_PATH, "utf8")) as SeedProvenance; + expect(record.record_version).toBe(1); + expect(record.issue).toBe(806); + expect(record.seed_machine).toMatch(/^\S+$/); + expect(record.seed_root).toMatch(/agents$/); + expect(record.roles.map((r) => r.role).sort()).toEqual([...ALL_ROLES].sort()); + }); + + it("SEED INTEGRITY: every role card in the repo is byte-identical to its recorded seed hash", () => { + const record = JSON.parse(readFileSync(PROVENANCE_PATH, "utf8")) as SeedProvenance; + for (const entry of record.roles) { + const actual = sha256(cardPath(entry.role as Role)); + if (actual === entry.seeded_sha256) continue; + // drifted from the seed: only a RECORDED, SIGNED amendment may carry it + expect( + record.amended === true && typeof record.amendment_signed_by === "string" && record.amendment_signed_by.length > 0, + `${entry.role} no longer matches its recorded seed hash, and the provenance record carries no signed amendment — a silent content change to a seeded role card is never a pass`, + ).toBe(true); + } + }); + + it("each entry's source_path points inside the seed root it records", () => { + const record = JSON.parse(readFileSync(PROVENANCE_PATH, "utf8")) as SeedProvenance; + for (const entry of record.roles) { + expect(entry.source_path.startsWith(record.seed_root)).toBe(true); + } + }); +}); + +describe("bundle ownership — the manifests declare the roles they cast (D3)", () => { + it("the registry validates through the ONE shared validator", () => { + expect(validateModeRegistry(MODES_DIR, EXT).ok).toBe(true); + }); + + it("each bundle declares exactly its slice's roles, and the declared set materializes as roles/.md", () => { + for (const [mode, roles] of Object.entries(ROLE_CARDS)) { + const manifest = parseModeManifest(readFileSync(join(MODES_DIR, mode, "mode.toml"), "utf8")); + expect(manifest.roles.map((r) => r.name).sort()).toEqual([...roles].sort()); + const inBundle = declaredComponents(manifest) + .filter((c) => c.inBundle.startsWith("roles/")) + .map((c) => c.inBundle); + expect(inBundle.sort()).toEqual([...roles].map((r) => `roles/${r}.md`).sort()); + } + }); + + it("no other shipped card is a bundle-declared role (librarian and the directors are not role cards)", () => { + const declared = new Set( + Object.values(ROLE_CARDS) + .flat() + .map((r) => `${r}.md`), + ); + const shipped = readdirSync(AGENTS_DIR).filter((f) => f.endsWith(".md")).sort(); + expect(shipped.filter((f) => declared.has(f)).sort()).toEqual([...declared].sort()); + }); +}); + +describe("staging — the seeded role cards ride the bundle, digest-verified (H2)", () => { + it("stageModeBundles materializes each seeded card into its bundle's roles/, byte-identical, with receipt digest rows", () => { + // hermetic source: the REAL registry copied to a temp extension root + const src = mkdtempSync(join(tmpdir(), "role-cards-src-")); + cpSync(MODES_DIR, join(src, "modes"), { recursive: true }); + cpSync(AGENTS_DIR, join(src, "agents"), { recursive: true }); + cpSync(join(EXT, "handoff-seeds"), join(src, "handoff-seeds"), { recursive: true }); + const dest = mkdtempSync(join(tmpdir(), "role-cards-dest-")); + + const r = stageModeBundles(src, dest); + expect(r.outcome).toBe("staged"); + const receipt = JSON.parse(readFileSync(r.receiptPath!, "utf8")) as { + modes: Array<{ mode: string; files: Array<{ path: string; sha256: string }> }>; + }; + for (const [mode, roles] of Object.entries(ROLE_CARDS)) { + const files = receipt.modes.find((m) => m.mode === mode)!.files; + for (const role of roles) { + const row = files.find((f) => f.path === `roles/${role}.md`); + expect(row, `${mode}/roles/${role}.md on the deploy receipt`).toBeDefined(); + // digest-verified against the SEEDED bytes + expect(row!.sha256).toBe(sha256(cardPath(role))); + // byte-identical materialization inside the deployed bundle + expect(readFileSync(join(dest, "modes", mode, "roles", `${role}.md`), "utf8")).toBe( + cardText(role), + ); + } + } + rmSync(src, { recursive: true, force: true }); + rmSync(dest, { recursive: true, force: true }); + }); + + it("tamper repair: a tampered deployed role component is repaired as a unit by the next pass", () => { + const src = mkdtempSync(join(tmpdir(), "role-cards-src2-")); + cpSync(MODES_DIR, join(src, "modes"), { recursive: true }); + cpSync(AGENTS_DIR, join(src, "agents"), { recursive: true }); + cpSync(join(EXT, "handoff-seeds"), join(src, "handoff-seeds"), { recursive: true }); + const dest = mkdtempSync(join(tmpdir(), "role-cards-dest2-")); + stageModeBundles(src, dest); + const deployedRole = join(dest, "modes", "autodev", "roles", "implementer.md"); + writeFileSync(deployedRole, "# TAMPERED ROLE\n"); + const r = stageModeBundles(src, dest); + expect(r.outcome).toBe("staged"); + expect(readFileSync(deployedRole, "utf8")).toBe(cardText("implementer")); + rmSync(src, { recursive: true, force: true }); + rmSync(dest, { recursive: true, force: true }); + }); +}); + +describe("role-card structural floor (pre-signature: shape, never flagged content)", () => { + for (const role of ALL_ROLES) { + it(`${role}: frontmatter — description, subagent mode, permission scoping, and NO overlay dispatch`, () => { + const text = cardText(role); + expect(text.startsWith("---\n")).toBe(true); + const end = text.indexOf("\n---\n", 4); + expect(end).toBeGreaterThan(-1); + const fm = text.slice(4, end); + expect(fm).toMatch(/^description:\s*\S/m); + expect(fm).toMatch(/^mode:\s*subagent/m); + expect(fm).toMatch(/^permission:\s*$/m); + // role cards are bundle-owned registry artifacts, not overlay-tuned + // worker cards (#758's architecture survives on librarian alone) + expect(fm, `${role} must not carry a dispatch target`).not.toMatch(/^dispatch:/m); + }); + + it(`${role}: zero blocklisted proprietary strings / banned names`, () => { + const text = cardText(role); + for (const s of BLOCKED) { + expect(text.toLowerCase().includes(s.toLowerCase()), `${role} must not contain "${s}"`).toBe(false); + } + }); + + it(`${role}: the cast-card section structure — briefing, job, and rules`, () => { + const text = cardText(role); + expect(text).toMatch(/\*\*Briefing you receive:?\*\*/i); + expect(text).toMatch(/\*\*Your job:?\*\*/i); + expect(text).toMatch(/\*\*(Hard )?[Rr]ules:?\*\*/); + }); + } +}); diff --git a/packages/extension/test/role_cards_parity.test.ts b/packages/extension/test/role_cards_parity.test.ts new file mode 100644 index 00000000..8caf5462 --- /dev/null +++ b/packages/extension/test/role_cards_parity.test.ts @@ -0,0 +1,359 @@ +// role_cards_parity.test.ts — the parity half of the seed gate (slice 2 / +// D3, #806, AC3 + AC4, spec-20260905-063000 D3): the shipped role cards are +// the opencode bindings; the amicissimo vault's agent records are the +// engine-neutral contracts; THIS suite keeps the overlap coherent. +// +// The pin record (test/fixtures/vault-agents/pin.json) is REVISION-PINNED: +// it carries the amicissimo vault revision the parity baseline was taken at +// plus the per-definition digests AT that revision — provenance without +// content, self-contained on machines without the vault checkout. The +// nightly pin-behind-HEAD check (ops/role-parity, riding the doctor's fleet +// cadence on the vault-visible machine) re-checks the pin against the live +// amicissimo checkout and files a chore issue on drift (obligation O8). +// +// THE GATE, honored mechanically (never prose): +// - COHERENT anchors (both texts agree) are pinned against the SHIPPED +// CARD now. The fixture halves of those pins — and the full-definition +// fixture publications themselves — are PENDING SIGNATURE (review B1, +// PR #811): the vault definitions as committed fixtures failed the +// amended content policy's per-line usage-vs-internals test (the +// engineer def's src/ module tree, how-to-extend recipe, and internals +// sections; the experimenter def's literal internal host paths), so +// NOTHING from the vault definitions is published-verbatim or pinned +// before Aaron's signature decides. The holds are named skips citing +// the diff document — reversible at signature, never silent. +// - FLAGGED content (the prepared human diff, +// docs/seed-gate/role-cards-seed-diff.md) is pinned only after AARON +// signs. Pre-signature those anchors are skips whose reasons name the +// flag and cite the diff document. A two-directional guard keeps doc +// and suite in lockstep: every suite flag appears in the doc, and +// every doc flag key is carried by the suite (a flag on either side +// alone fails the guard). +// - The doc's signature status, the suite's SEED_GATE_SIGNED switch, and +// the provenance record's amendment claim are COUPLED: an unsigned +// tree cannot carry a signed amendment, and the switch never flips +// alone (flip BOTH, in the same change, with the signature). +import { describe, it, expect } from "vitest"; +import { readFileSync, existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXT = join(HERE, ".."); +const AGENTS_DIR = join(EXT, "agents"); +const FIXTURES = join(HERE, "fixtures", "vault-agents"); +const PIN_PATH = join(FIXTURES, "pin.json"); +const DIFF_DOC = join(HERE, "..", "..", "..", "docs", "seed-gate", "role-cards-seed-diff.md"); +const PROVENANCE_PATH = join(AGENTS_DIR, ".seed-provenance.json"); + +/** The seed gate's switch of record. Flip to true ONLY in the same change + * that lands Aaron's signature in docs/seed-gate/role-cards-seed-diff.md + * (the coupling test below fails otherwise — never flip it alone). + * Flipped 2026-09-05 with the signature (accept-seed, by chat directive + * "do as you rec" — recorded in the doc's Signature section). The fixture + * publications remain HELD by the re-authoring decision, not by + * unsignedness: pin.fixture_publication reads "pending-re-authoring". */ +const SEED_GATE_SIGNED = true; + +interface PinEntry { + role_card: string; + vault_path: string; + sha256: string; + /** Present only when the full-definition fixture is PUBLISHED (the + * post-signature state); absent pre-signature (review B1). */ + fixture?: string; +} +interface PinRecord { + record_version: number; + vault_repo: string; + vault_revision: string; + /** "pending-signature" pre-signature; "pending-re-authoring" once signed + * with the VAULT RE-AUTHORING decision (B1: no full-definition fixture + * is published until the amicissimo re-authoring lands and the fixtures + * re-take at the new revision); "published" once they return. */ + fixture_publication: "published" | "pending-signature" | "pending-re-authoring"; + pinned: PinEntry[]; + no_counterpart: Array<{ role_card: string; nearest_kin: string; reason: string }>; +} + +const pin = JSON.parse(readFileSync(PIN_PATH, "utf8")) as PinRecord; +const cardText = (name: string): string => readFileSync(join(AGENTS_DIR, `${name}.md`), "utf8"); +const sha256 = (p: string): string => + "sha256:" + createHash("sha256").update(readFileSync(p)).digest("hex"); + +// ── the revision-pinned record (AC3) ──────────────────────────────────────── + +describe("the parity pin record is revision-pinned (it carries the vault revision)", () => { + it("pin.json carries a real amicissimo revision and both overlapping definitions", () => { + expect(pin.record_version).toBe(2); + expect(pin.vault_repo).toBe("harmoniqs/amicissimo"); + expect(pin.vault_revision).toMatch(/^[0-9a-f]{40}$/); + expect(pin.pinned.map((p) => p.role_card).sort()).toEqual(["experimenter", "implementer"]); + }); + + it("the no-counterpart roles are RECORDED with reasons, never silently unpinned", () => { + expect(pin.no_counterpart.map((n) => n.role_card).sort()).toEqual(["analyzer", "hypothesizer"]); + for (const n of pin.no_counterpart) { + expect(n.reason.length).toBeGreaterThan(20); + } + }); +}); + +// ── B1 (review, PR #811): the fixture publications are held pending signature ─ + +describe("fixture publications held pending signature (review B1 — nothing published-verbatim pre-signature)", () => { + it("the full-definition fixtures are ABSENT from the repo while the re-authoring hold runs (the B1 hold, post-signature form)", () => { + // Signed 2026-09-05 (accept-seed) with the VAULT RE-AUTHORING decision: + // the hold now continues BY DECISION, not by unsignedness — the pin's + // publication field says so, the fixtures stay unpublished until the + // amicissimo re-authoring lands and the fixtures re-take at the new + // revision. + for (const p of pin.pinned) { + if (p.fixture === undefined) continue; + expect(existsSync(join(FIXTURES, p.fixture)), `${p.fixture} must not be published under the re-authoring hold`).toBe(false); + } + // and the record says so — the hold's REASON changed with the signature + expect(pin.fixture_publication).toBe("pending-re-authoring"); + // no pinned entry carries a committed fixture path while held + for (const p of pin.pinned) { + expect(p.fixture, `${p.role_card}: no committed fixture path under the re-authoring hold`).toBeUndefined(); + } + }); + + it.skip("PENDING SIGNATURE — fixture integrity: every published fixture byte-matches its recorded digest (fixture publication: engineer.md (full vault definition)) — see docs/seed-gate/role-cards-seed-diff.md", () => { + for (const p of pin.pinned) { + if (p.fixture === undefined) continue; + expect(sha256(join(FIXTURES, p.fixture))).toBe(p.sha256); + } + }); + + it.skip("PENDING SIGNATURE — fixture integrity: every published fixture byte-matches its recorded digest (fixture publication: experimenter.md (full vault definition)) — see docs/seed-gate/role-cards-seed-diff.md", () => { + for (const p of pin.pinned) { + if (p.fixture === undefined) continue; + expect(sha256(join(FIXTURES, p.fixture))).toBe(p.sha256); + } + }); +}); + +// ── the seed-gate record (AC4) + the coupling invariants (review A1) ──────── + +describe("the prepared human diff + the signature state (coupled)", () => { + const doc = readFileSync(DIFF_DOC, "utf8"); + + it("the diff document exists and covers all four cards, the provenance record, and the pin revision", () => { + expect(existsSync(DIFF_DOC)).toBe(true); + for (const role of ["hypothesizer", "experimenter", "analyzer", "implementer"]) { + expect(doc).toContain(role); + } + expect(doc).toContain(".seed-provenance.json"); + expect(doc).toContain(pin.vault_revision); + }); + + it("the doc's signature status matches the suite's gate switch (flip both together, or neither)", () => { + if (SEED_GATE_SIGNED) { + expect(doc).not.toMatch(/Status:\s*PENDING SIGNATURE/i); + } else { + expect(doc).toMatch(/Status:\s*PENDING SIGNATURE/i); + } + }); + + it("an UNSIGNED tree cannot claim an amendment (review A1): !SEED_GATE_SIGNED ⇒ provenance.amended !== true", () => { + if (!SEED_GATE_SIGNED) { + const provenance = JSON.parse(readFileSync(PROVENANCE_PATH, "utf8")) as { amended?: boolean; amendment_signed_by?: string }; + expect(provenance.amended, "an unsigned tree must not carry an amendment claim in .seed-provenance.json").not.toBe(true); + } + }); +}); + +// ── coherent overlap anchors — the SHIPPED-CARD halves are pinned now; the ──── +// ── fixture halves are held pending signature (B1) ─────────────────────────── + +/** One anchor of the overlap: the card-side regex pins NOW (live repo + * source, unflagged content); the fixture-side half is a named skip that + * converts with the signature + the re-published fixtures. */ +const overlapAnchor = (label: string, card: string, cardRe: RegExp) => { + it(`coherent (shipped card): ${label}`, () => { + expect(cardRe.test(cardText(card)), `${card} carries the anchor: ${cardRe}`).toBe(true); + }); +}; + +describe("implementer ↔ engineer (engine-neutral) — coherent overlap, shipped-card halves pinned", () => { + overlapAnchor("the delegated TDD leaf (implement-issue --orchestrated)", "implementer", /implement-issue/); + overlapAnchor("the orchestrated worktree binding", "implementer", /--orchestrated/); + overlapAnchor("branch discipline — never off the assigned branch", "implementer", /caller-provided worktree branch/); + overlapAnchor("test protection — never force green", "implementer", /never delete, skip, or mark tests broken to\s+force green/); + overlapAnchor("the structured return contract", "implementer", /commit_shas/); + overlapAnchor("bounded retries, then escalate — never negotiate a RED", "implementer", /is a `failed` return,\s+not a negotiation/); +}); + +describe("experimenter ↔ experimenter (engine-neutral) — coherent overlap, shipped-card halves pinned", () => { + overlapAnchor("brief-driven execution — parse the briefing first", "experimenter", /Briefing you receive:/); + overlapAnchor("numbers-grounded reporting from the run's own output", "experimenter", /Debrief with NUMBERS ONLY/); +}); + +describe("coherent overlap — the fixture halves, held pending signature (B1)", () => { + const FIXTURE_HALVES = [ + { key: "fixture publication: engineer.md (full vault definition)", file: "engineer.md", re: /NEVER delete test files or remove test cases/ }, + { key: "fixture publication: experimenter.md (full vault definition)", file: "experimenter.md", re: /AMICO_RESULT_/ }, + ]; + for (const f of FIXTURE_HALVES) { + it.skip(`PENDING SIGNATURE — the pinned ${f.file} carries the coherent anchor ${f.re} (${f.key}) — see docs/seed-gate/role-cards-seed-diff.md`, () => { + expect(f.re.test(readFileSync(join(FIXTURES, f.file), "utf8"))).toBe(true); + }); + } +}); + +// ── flagged content — pending-signature skips (never a silent pass) ───────── + +/** The flags the prepared diff marks divergent — PLUS the B1 fixture + * publication holds. Keys must appear verbatim in + * docs/seed-gate/role-cards-seed-diff.md (the guard below enforces BOTH + * directions: a suite flag missing from the doc, or a doc flag key with no + * suite handling, fails). */ +const FLAGGED = [ + { + key: "implementer ↔ engineer: merge/PR governance", + card: "implementer", + fixture: "engineer.md", + flag: + "the shipped card never opens PRs, never merges, never pushes (the walk owns the lifecycle); the vault def frontmatter says 'Auto-merges when all quality gates pass'", + }, + { + key: "implementer ↔ engineer: scope perimeter", + card: "implementer", + fixture: "engineer.md", + flag: + "one issue slice per cast (shipped) vs the standalone experiment-mode engineering brief with layer skills and per-task PRs (vault)", + }, + { + key: "experimenter ↔ experimenter: self-grading and self-promotion", + card: "experimenter", + fixture: "experimenter.md", + flag: + "the shipped card never grades its own result and never promotes (gates + parent + analyzer do); the vault def compares against the incumbent and saves to the catalog itself", + }, + { + key: "experimenter ↔ experimenter: environment/checkout discipline", + card: "experimenter", + fixture: "experimenter.md", + flag: + "assigned isolated env per sessions/CHECKOUTS.md (shipped) vs scratchpad paths with no checkout registry (vault)", + }, + { + key: "experimenter ↔ experimenter: artifact contract", + card: "experimenter", + fixture: "experimenter.md", + flag: + "raw artifacts + house-frontmatter experiment note (shipped) vs TSV rows + catalog saves (vault)", + }, + { + key: "hypothesizer ↔ vault counterpart", + card: "hypothesizer", + fixture: "researcher.md", + flag: + "no engine-neutral counterpart; nearest kin researcher.md is a decider with a different output — the signature confirms no-counterpart or names the pin target", + }, + { + key: "analyzer ↔ vault counterpart", + card: "analyzer", + fixture: "librarian.md", + flag: + "no engine-neutral counterpart; nearest kin librarian.md writes curated notes where the analyzer is read-only and proposes verdicts — the signature confirms no-counterpart or names the pin target", + }, + { + key: "fixture publication: engineer.md (full vault definition)", + card: "implementer", + fixture: "engineer.md", + flag: + "the engineer definition as a committed fixture failed the amended content policy's per-line test (src/ module tree, how-to-extend recipe, Complex Internals section, roadmap lines) — unpublished pending signature; the vault re-authoring is the recorded follow-up", + }, + { + key: "fixture publication: experimenter.md (full vault definition)", + card: "experimenter", + fixture: "experimenter.md", + flag: + "the experimenter definition as a committed fixture failed the per-line test (literal internal host paths) — unpublished pending signature; the vault re-authoring is the recorded follow-up", + }, +] as const; + +describe("flagged content is pinned only after Aaron signs (the seed gate)", () => { + // The skips are the gate's honest pre-signature state: each names its flag + // and cites the diff document. When the signature lands (SEED_GATE_SIGNED + // flipped together with the doc's signature block), convert each to a + // live pin asserting the ADJUDICATED direction per the spec's constraint + // of record (repo wins for shipped bindings; the vault def keeps the + // engine-neutral semantics it still owns). + for (const f of FLAGGED) { + it.skip(`PENDING SIGNATURE — ${f.key}: ${f.flag} (see docs/seed-gate/role-cards-seed-diff.md)`, () => { + expect(true).toBe(true); + }); + } + + it("no silent passes, direction 1: every flag the suite skips appears verbatim in the prepared diff document", () => { + const doc = readFileSync(DIFF_DOC, "utf8"); + for (const f of FLAGGED) { + expect(doc, `the diff doc flags: ${f.key}`).toContain(f.key); + } + }); + + it("no silent passes, direction 2 (review A2): every bold flag key in the doc's Flagged-for-signature sections has a suite FLAGGED entry", () => { + const doc = readFileSync(DIFF_DOC, "utf8"); + // the doc's flag keys live ONLY in the flag sections: "Flagged for + // signature", the held-publication section, and the NO-counterpart + // sections (their "suite flag:" markers). Scoping matters — the + // coherent-anchor sections also carry bold-backtick quotes of card + // text, and those are NOT flags. + const FLAG_SECTION = /Flagged for signature|held pending signature|NO vault counterpart/; + const sections = doc.split(/^#{2,3} /m); + const docKeys: string[] = []; + for (const section of sections) { + const heading = section.slice(0, section.indexOf("\n")); + if (!FLAG_SECTION.test(heading)) continue; + docKeys.push(...[...section.matchAll(/\*\*`([^`]+)`/g)].map((m) => m[1]!)); + } + expect(docKeys.length, "the doc's flag sections carry bold flag keys").toBeGreaterThanOrEqual(FLAGGED.length); + const suiteKeys = new Set(FLAGGED.map((f) => f.key)); + for (const key of docKeys) { + expect(suiteKeys.has(key), `the suite carries a FLAGGED entry for the doc's flag key: ${key}`).toBe(true); + } + }); +}); + +// ── post-signature pins (dormant until the signature flips the switch) ────── + +describe.skipIf(!SEED_GATE_SIGNED)("post-signature: the adjudicated overlap of record (repo wins for shipped bindings)", () => { + it("the shipped implementer stays merge-free: no PR/merge/push authorization enters the binding", () => { + // (the shipped card's own words: the description's never-opens-PRs + // clause + the No-PR-no-merge-no-push rule; this cell was authored + // dormant against a paraphrase — pinned to the card's actual text + // when the signature activated it, semantics unchanged) + expect(cardText("implementer")).toMatch(/Never opens PRs or merges in orchestrated mode/); + expect(cardText("implementer")).toMatch(/No PR, no merge, no push to shared branches/); + expect(cardText("implementer")).not.toMatch(/auto-merge/i); + }); + it("the shipped experimenter stays grade-free: verdicts belong to the gates + parent + analyzer", () => { + expect(cardText("experimenter")).toMatch(/do NOT declare confirm\/refute/); + expect(cardText("experimenter")).not.toMatch(/save the pulse to the catalog/); + }); + it("the no-counterpart verdicts the signature confirmed are recorded in pin.json", () => { + const confirmed = JSON.parse(readFileSync(PIN_PATH, "utf8")) as PinRecord; + expect(confirmed.no_counterpart.map((n) => n.role_card).sort()).toEqual(["analyzer", "hypothesizer"]); + }); + + // review A3: the three previously unenforced flags convert with the + // switch too — each carries the repo-wins direction inline, so flipping + // SEED_GATE_SIGNED converts ALL of the flags, not four of seven. + it("A3 scope perimeter: the shipped implementer stays one-slice-per-cast; no layer-skill/multi-package scope enters the binding", () => { + expect(cardText("implementer")).toMatch(/Implements ONE TDD-ready GitHub issue slice/); + expect(cardText("implementer")).not.toMatch(/layer skill|multi-package/i); + }); + it("A3 checkout discipline: the shipped experimenter keeps the assigned-env/CHECKOUTS.md rule; no scratchpad free-for-all", () => { + expect(cardText("experimenter")).toMatch(/USE EXACTLY THIS, never a shared checkout/); + expect(cardText("experimenter")).not.toMatch(/scratchpad/); + }); + it("A3 artifact contract: the shipped experimenter writes its own experiment note + raw artifacts, never catalog writes", () => { + expect(cardText("experimenter")).toMatch(/write YOUR\s+own experiment note|Write raw artifacts/); + expect(cardText("experimenter")).not.toMatch(/save the pulse to the catalog/); + }); +}); diff --git a/packages/schema/src/mode_staging.ts b/packages/schema/src/mode_staging.ts index d064b1b4..6cc9126c 100644 --- a/packages/schema/src/mode_staging.ts +++ b/packages/schema/src/mode_staging.ts @@ -1,9 +1,12 @@ // mode_staging.ts — the atomic mode-bundle stager (#804, spec-20260905-063000 // D1, H2): stages every director-mode bundle (card, pack, roles, // handoff-seed schemas, manifest) into /modes// as ONE -// locked, receipt-audited unit. Legacy card staging (mode_cards.ts) remains -// AUTHORITATIVE — this module never touches the legacy agents destination; -// the source flip lands with the role-cards slice (D3). +// locked, receipt-audited unit. Since D3 (#806) the bundles are COMPLETE — +// their role cards are versioned repo sources (seeded from the deployed +// artifacts, .seed-provenance.json) — and THIS module is the source of record +// for the deployed mode surfaces. The flat agents/ staging (mode_cards.ts) +// stays as opencode's card-DISCOVERY path for the same versioned files; it +// never stages into modes/. // // Staging semantics (reconciled with the #761/#614 entitlement staging gate, // recorded as the pre-flight outcome of #804): ALWAYS-COPY base artifacts, diff --git a/scripts/deploy-agents.mjs b/scripts/deploy-agents.mjs index 1bd926a3..408dbf3b 100644 --- a/scripts/deploy-agents.mjs +++ b/scripts/deploy-agents.mjs @@ -16,9 +16,9 @@ // global: ~/.config/opencode/agents // // Behavior: -// - copies packages/extension/agents/*.md (the two directors + the five -// worker cards) to each EXISTING destination directory (missing -// destination dirs are skipped, not created); +// - copies packages/extension/agents/*.md (the two directors, the four +// D3-seeded role cards, and the worker) to each EXISTING destination +// directory (missing destination dirs are skipped, not created); // - verifies each written file byte-matches its repo source (sha256); // - writes .deploy-receipt.json next to the sources (timestamp, source // digests, destinations, verified flags) — unless --dry-run; @@ -37,9 +37,10 @@ const SOURCE_DIR = path.join(REPO_ROOT, "packages", "extension", "agents"); const RECEIPT_PATH = path.join(SOURCE_DIR, ".deploy-receipt.json"); // #761: the deploy surface matches the staging surface — every card shipped -// in the extension's agents dir (two directors + five workers). Explicit on -// purpose: this is a MANUAL operator action, and the list is the human's -// decision surface; staging itself discovers the dir at runtime. +// in the extension's agents dir (two directors, the four D3-seeded role +// cards, and the worker). Explicit on purpose: this is a MANUAL operator +// action, and the list is the human's decision surface; staging itself +// discovers the dir at runtime. const CARDS = [ "analyzer.md", "autodev.md",