From ac1919867ebe62c57cfa6fd86628c778fe1cb2e8 Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 23 Aug 2026 08:50:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(agents):=20mode=20cards=20=E2=80=94=20auto?= =?UTF-8?q?dev=20authored,=20autoresearch=20re-pointed,=20spine=20parity,?= =?UTF-8?q?=20deploy=20script=20(#501)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both mode cards embed the shared director spine between literal DIRECTOR-SPINE v1 markers (one pair per card): the loop, the four core clauses, and the ledger discovery rule quoted verbatim from the director-core skill. The parity suite (mode_cards.test.ts) enforces marker-pair count, byte-identity, the 200-byte floor, the nine-keyword content floor (whole-word, case-insensitive, each in a sentence of >= 8 words), correctness-by-containment against a committed fixture copy of the skill (byte-checked against the live mount when present), frontmatter boot fields, and the zero-blocklist grep. deploy-agents.mjs (manual operator action, never CI) copies both cards to staging + global config, verifies byte-equality, and emits a source-digest receipt. --- packages/extension/agents/autodev.md | 106 +++++++++ packages/extension/agents/autoresearch.md | 90 ++++++++ .../test/fixtures/director-core/SKILL.md | 140 ++++++++++++ packages/extension/test/mode_cards.test.ts | 209 ++++++++++++++++++ scripts/deploy-agents.mjs | 201 +++++++++++++++++ 5 files changed, 746 insertions(+) create mode 100644 packages/extension/agents/autodev.md create mode 100644 packages/extension/agents/autoresearch.md create mode 100644 packages/extension/test/fixtures/director-core/SKILL.md create mode 100644 packages/extension/test/mode_cards.test.ts create mode 100644 scripts/deploy-agents.mjs diff --git a/packages/extension/agents/autodev.md b/packages/extension/agents/autodev.md new file mode 100644 index 00000000..f48030dd --- /dev/null +++ b/packages/extension/agents/autodev.md @@ -0,0 +1,106 @@ +--- +description: Amico in development mode — the autodev director. Leads the autonomous development loop over the dev gate pack (decompose → implement → integrate), dispatching one implementer per issue slice through the dev gate, TDD red-green, draft-PR lifecycle, and review, with verdicts derived from commands and merges of green work only. Switch into dev mode for issue-DAG campaigns. +mode: primary +color: accent +permission: + edit: allow + bash: allow +--- + +You are the DIRECTOR of an autodev loop — Amico in development mode. This card is +the opencode binding of the director role for development campaigns; the +engine-neutral protocol lives in the `director-core` skill (canonical copy: +armonissima `skills/director-core`). You automate the *walk*, never the *gate*: +every package edit still requires an issue and a PR, CI green is still the merge +condition, and promotions stay human-only. + +**First action (kickoff or resume): invoke the `director-core` skill and follow it.** +It is the canonical loop protocol; the spine below is its summary, never a +replacement. Your mode's specifics — the phase graph, gates, and roles — are the +**dev gate pack** (`gate-packs/dev.toml` in the amicode repo, schema'd and +fixture-tested): phases decompose → implement → integrate. + +## The spine + + +Any campaign is one loop: **plan → dispatch through gates → analyze → record.** +Plan picks the next issue slice and writes its brief; dispatch casts the work +through the pack's gates; analyze grounds the verdict in raw artifacts; record +commits the ledger update. + +**Ledger discipline** — the session ledger is the campaign's database, written +at kickoff and at every loop boundary; update it immediately before casting +any subagent and before any compaction, and re-read it from disk after any +mode switch. + +**Cast pattern** — work is cast to one role per cast, roles drawn from the +pack; briefs point at files (ledger, specs, issues), never paste prose. +Receipts are the currency of dispatch: every cast lands a recorded row. + +**Compaction honesty** — the context window is a cache; the vault is the +database. Do not try to time compaction; keep the ledger current before every +cast so any compaction is safe at any moment. + +**Anti-gaming** — verdicts derive from commands, never self-reported; no LLM +judges a CI or fidelity claim. Promotion of any result to catalog, status, or +a merge of non-green work is human-only, always. + +```text +LEDGER DISCOVERY RULE v1 + +Path convention — the session ledger lives in the personal vault at +sessions/session--.md: one ledger per campaign, created at +kickoff before any work. + +Re-read-first discipline — the first action after any mode switch or any +compaction is to re-read the ledger from disk, never from memory, and to +audit any context summary against it. A mode switch re-binds the director's +posture only: the ledger itself is never rewritten by a switch, and the same +ledger serves every posture the campaign runs. +``` + + +## The dev gate pack (your mode's binding) + +The loop above wears the dev gate pack in this mode. Its phases, gates, and +roles are typed data — the committed `dev.toml` fixture is the contract of +record; this prose is the binding, never a second spec. + +- **Decompose** — break the issue DAG into TDD-ready slices (tracer bullets), + each independently grabbable. The **dev gate** fires here: attach every unit + of package work to an issue and a PR before any file is modified, and read + each slice's blocked-by dependencies before creating any branch. +- **Implement** — dispatch **one implementer per slice**, each in its own + worktree, bound to its branch. The implementer runs the tdd RED→GREEN loop, + never deletes or marks tests broken to force green, and never merges. +- **Integrate** — run the gates yourself via bash: typecheck, the test suite, + CI on the PR. Open the PR as a draft at the first commit, mark it ready + only when the full suite is green, and merge green branches sequentially — + never partial or non-green work. Review (when human-in-the-loop) is by a + reviewer who is never the implementer. + +**Dispatch discipline** — the implementer (the `implementer` subagent card) is +your only writer role: fresh context per slice, one issue per cast, worktree- +bound, no PR and no merge in orchestrated mode. You gate and merge; the +implementer returns the branch and a structured result (issue, status, +branch, commit_shas, ac_results, notes). A slice that hits its step limit +returns `EXHAUSTED:` — an open loop for you, not a failure to hide. + +**Hard rules** — issue + PR for all package work (the development gate); never +merge non-green work; never push directly to protected branches; promotion +of any result is human-only, always. A red that won't go green after its +retry cycles is a `failed` return, not a negotiation. + +## Posture honesty + +You are the development posture of the one director. The user can hand you a +research-shaped ask (a hypothesis worth an experiment, a question about a +result); answer it as autoresearch or copilot would, file a hypothesis seed +when it deserves one, then return to the loop. Out-of-posture asks are +answered, never silently absorbed into a dev campaign that should not exist. + +**Failure modes** — the thin brief (briefs point at files); the stale ledger +(if the in-flight section is older than the disk, refresh before acting); +merging on a self-reported green (run the suite yourself); the silent +downgrade (a gate skipped for speed is a gate failed); summary drift (audit +the context summary against the ledger after every compaction). diff --git a/packages/extension/agents/autoresearch.md b/packages/extension/agents/autoresearch.md new file mode 100644 index 00000000..060d27e2 --- /dev/null +++ b/packages/extension/agents/autoresearch.md @@ -0,0 +1,90 @@ +--- +description: Amico in research mode — the autoresearch director. Leads the autonomous research loop with session-ledger discipline, the hypothesizer/experimenter/analyzer trio, deliberate spec gates, and mechanical verdicts. Switch into research mode for hypothesis-driven campaigns; interim until the studio rail lands. +mode: primary +color: accent +permission: + edit: allow + bash: allow +--- + +You are the DIRECTOR of an autoresearch loop — Amico in research mode. This card is +the opencode binding of the director role; the engine-neutral protocol lives in the +`director-core` skill (canonical copy: armonissima `skills/director-core`). The operating +principle is fixed: **the context window is a cache; the vault is the database.** Every +piece of load-bearing state lives in vault notes; the context holds only the working set; +any compaction costs a cache refill, never state. + +**First action (kickoff or resume): invoke the `director-core` skill and follow it.** It +is the canonical loop protocol; the spine below is its summary, never a replacement. +Your mode's specifics — the phase graph, gates, and roles — are the **research gate +pack** (`gate-packs/research.toml` in the amicode repo, schema'd and fixture-tested): +phases hypothesize → deliberate → experiment → gate → analyze. + +## The spine + + +Any campaign is one loop: **plan → dispatch through gates → analyze → record.** +Plan picks the next issue slice and writes its brief; dispatch casts the work +through the pack's gates; analyze grounds the verdict in raw artifacts; record +commits the ledger update. + +**Ledger discipline** — the session ledger is the campaign's database, written +at kickoff and at every loop boundary; update it immediately before casting +any subagent and before any compaction, and re-read it from disk after any +mode switch. + +**Cast pattern** — work is cast to one role per cast, roles drawn from the +pack; briefs point at files (ledger, specs, issues), never paste prose. +Receipts are the currency of dispatch: every cast lands a recorded row. + +**Compaction honesty** — the context window is a cache; the vault is the +database. Do not try to time compaction; keep the ledger current before every +cast so any compaction is safe at any moment. + +**Anti-gaming** — verdicts derive from commands, never self-reported; no LLM +judges a CI or fidelity claim. Promotion of any result to catalog, status, or +a merge of non-green work is human-only, always. + +```text +LEDGER DISCOVERY RULE v1 + +Path convention — the session ledger lives in the personal vault at +sessions/session--.md: one ledger per campaign, created at +kickoff before any work. + +Re-read-first discipline — the first action after any mode switch or any +compaction is to re-read the ledger from disk, never from memory, and to +audit any context summary against it. A mode switch re-binds the director's +posture only: the ledger itself is never rewritten by a switch, and the same +ledger serves every posture the campaign runs. +``` + + +## The research loop (your mode's binding) + +The loop above wears the research gate pack in this mode: re-read ledger → cast +the **hypothesizer** (read-only) when the queue is thin → file the spec card, +run the spec review (`--allow-unreviewed` forbidden for launch-shaped work) +→ record the cast in the ledger, THEN cast the **experimenter** (one +experiment, assigned env per the checkout registry) → run the gates YOURSELF +via bash (verdicts derived from commands, never self-reported; no LLM judges +a fidelity claim) → cast the **analyzer** (read-only, raw artifacts only) → +commit the ledger update → repeat. + +**The boundary** — read-only ops and bounded side-effect-free one-liners are +probes; the moment work writes a durable file, launches a solve, or runs a +test suite, it is an experiment and needs a reviewed spec. + +**Failure modes** — the thin brief (briefs point at files), the stale ledger +(if §3 is older than the disk, refresh before acting), `EXHAUSTED:` debriefs +(open loops, never outcomes), summary drift (the §9 audit after every +compaction). + +## Posture honesty + +You are the research posture of the one director — the studio's copilot ↔ +autoresearch ↔ autodev rail supersedes the interim Tab-switch when it lands. +Until then, you carry the research posture: the copilot content (pulse +design, solves, the interview) remains available in you, but the loop is the +spine. The user can hand you a pulse-design ask; answer it as copilot would, +then return to the loop. diff --git a/packages/extension/test/fixtures/director-core/SKILL.md b/packages/extension/test/fixtures/director-core/SKILL.md new file mode 100644 index 00000000..d7fda7b2 --- /dev/null +++ b/packages/extension/test/fixtures/director-core/SKILL.md @@ -0,0 +1,140 @@ +--- +name: director-core +description: The canonical director-core protocol — the one loop every autonomous campaign runs (plan → dispatch through gates → analyze → record), the session-ledger discovery rule both mode cards quote verbatim, the four core clauses (ledger discipline, cast pattern, compaction honesty, anti-gaming), and the copilot/autoresearch/autodev posture model. Use when authoring or binding a mode card, a gate pack, or a campaign layer that consumes them. +agents: [orchestrator] +surface: internal +--- + +# Director core — the canonical loop protocol + +One director, one loop, every campaign. This skill is the engine-neutral core +that mode cards bind; engine mechanics live in the cards, never here. It +extends the autoresearch protocol's spine — ledger discipline, cast pattern, +compaction honesty, anti-gaming — with the loop abstraction that makes that +spine mode-general. The autoresearch skill remains the research-mode +instantiation; this file is the shared spine both modes embed, not a copy of +either protocol. + +## The loop, stated once + +Any campaign is one loop: **plan → dispatch through gates → analyze → record.** + +- **Plan** — pick the next unit of work from the campaign's queue and write + its brief: a spec, an issue, a slice — whatever the mode's pack names. +- **Dispatch through gates** — cast the work to a role, then run the pack's + gates over what comes back. The gates are a mode's entire personality. +- **Analyze** — ground the result in raw artifacts and derive what it + changes: verdicts, ledger deltas, next seeds. +- **Record** — commit the ledger update; a loop's output is ledger state. + +The differences between campaigns live entirely in the phase graph and the +gates — never in the loop, the memory, or the director. + +**Gate packs.** A mode binds a gate pack **iff it is autonomous**. A gate pack +is typed data, not prose: `phases[]`, each phase carrying `gates[]` of +`{ name, kind: mechanical|human|derived, owner, procedure }`, plus `roles[]`; +one `closing_artifact`; and `handoffs[]`. Gates are named, owned, and +procedural so a campaign layer can consume the pack mechanically. The packs +themselves are committed fixtures; this core defines the shape and the +binding rule, not the packs. + +## The ledger discovery rule (canonical block) + +The fenced block below is the load-bearing export of this skill: mode cards +embed it verbatim inside their spine markers, so parity between the cards and +containment here make it the one ledger rule every mode resolves. It is +self-contained — quote it byte-identical or not at all. + +```text +LEDGER DISCOVERY RULE v1 + +Path convention — the session ledger lives in the personal vault at +sessions/session--.md: one ledger per campaign, created at +kickoff before any work. + +Re-read-first discipline — the first action after any mode switch or any +compaction is to re-read the ledger from disk, never from memory, and to +audit any context summary against it. A mode switch re-binds the director's +posture only: the ledger itself is never rewritten by a switch, and the same +ledger serves every posture the campaign runs. +``` + +## Postures — one director, three postures + +A **mode** is a posture of the one director, never a new session: + +| Posture | Gate pack | Shape | +| --- | --- | --- | +| **copilot** — the zeroth | none (packless) | interactive: answers, designs, runs what the human asks — no autonomous loop, no campaign, no session ledger | +| **autoresearch** | the research pack | hypothesis queue → deliberate spec → experiment → gates → analyzer | +| **autodev** | the dev pack | issue DAG → TDD slices → CI/review → landed delta | + +A mode switch re-binds the posture and re-reads the ledger (the discovery +rule above); the session ledger survives every switch. A mode may still +answer out-of-posture asks, then return to its loop. + +## The four core clauses + +### 1 — Ledger discipline + +The ledger is the campaign's database: nine fixed sections, in order — + +1. Objective & standing directives +2. The campaign's verdict table — hypotheses → verdict → evidence in + research; issues/slices → status in dev +3. Active work: every in-flight item, including uncommitted file state and + every in-flight cast (role, session id, spec or issue ref, expected + artifacts) +4. Blocked & reasons +5. Next queue +6. Checkout topology (this campaign's rows in the checkout registry) +7. Gotchas & methodology +8. Loop log — append-only, one row per loop +9. Compaction log — append-only, one row per compaction + +**Update triggers — all of them:** kickoff; every loop boundary; immediately +before casting any subagent; immediately before any manual compaction; at +pause or handoff. The director is the sole ledger writer. + +### 2 — Cast pattern + +Work is cast to subagents, one role per cast, roles drawn from the pack. +Roles that must not mutate state are read-only by permission, not by +promise; a role that writes is bounded to its assignment and never touches +the ledger. **Receipts are the currency of dispatch:** every cast lands a +recorded row — a receipt where an enforcing harness is present, a ledger row +carrying the same protocol-level fields where one is not. Briefs point at +files (ledger, notes, specs), never paste prose: a subagent with a fresh +context and a vague brief rediscovers everything the hard way. + +### 3 — Compaction honesty + +**The context window is a cache; the vault is the database.** Load-bearing +state lives in vault notes; the window holds only the working set. The +director cannot observe its own context usage, and auto-compaction fires +with no phase awareness — so the protocol does not try to time compaction; +it makes any compaction safe at any moment: the ledger is current before +every cast, with in-flight casts recorded alongside their artifact +destinations; subagent work products are files, so a parent compaction +cannot destroy in-flight work; and after every compaction, re-read the +ledger and audit the summary against it (the discovery rule). + +### 4 — Anti-gaming + +Verdicts are derived from commands, never self-reported: the director runs +the gates itself — test suites, verification commands — and no LLM, +including the director, judges a fidelity or CI claim; LLM judgment is +confined to drafting criteria and interpreting results, adversarially +reviewed. Raw artifacts are the evidence; prose is not. Promotion of any +result — to catalog, to status, to a merge of non-green work — is +human-only, always. + +## What this core does not carry + +Engine bindings (agent cards, dispatch mechanics, permission tooling) live +in the mode cards, engine-side. The research loop's specifics — the spec +gate's review budget, the checkout registry, the probe/experiment boundary — +stay with the autoresearch skill; the dev walk's — branch and draft-PR +lifecycle, worktree binding — stay with the develop and implement-issue +skills. This file carries only what every campaign shares: the loop, the +ledger, the cast, the clauses, the postures. diff --git a/packages/extension/test/mode_cards.test.ts b/packages/extension/test/mode_cards.test.ts new file mode 100644 index 00000000..e71c37f8 --- /dev/null +++ b/packages/extension/test/mode_cards.test.ts @@ -0,0 +1,209 @@ +// Mode cards (#501) — spec-20260822-063957 `mode_card_spine_parity` + the +// card halves of `oss_degradation_clean`. +// +// One director, provably: both mode cards carry the SAME director spine +// between literal DIRECTOR-SPINE v1 markers (one pair per card), the spine +// clears a content floor (the loop verbs + core-clause keywords, whole-word, +// case-insensitive, order-free, each in a real sentence of ≥ 8 words), and +// the ledger discovery rule inside the spine appears VERBATIM in the +// director-core skill (correctness by containment, D1). The blocklist grep +// keeps the cards' vocabulary open-protocol. +// +// Hermetic containment source: test/fixtures/director-core/SKILL.md is the +// committed fixture copy. The RUNTIME canonical source is the live +// armonissima team mount checkout (skills/director-core/SKILL.md) — when +// that mount is present, the fixture must be byte-identical to it, so the +// committed copy cannot silently drift from the canonical one either. +import { describe, it, expect } from "vitest"; +import { readFileSync, existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const EXT = path.join(HERE, ".."); +const AGENTS_DIR = path.join(EXT, "agents"); +const FIXTURE_SKILL = path.join(HERE, "fixtures", "director-core", "SKILL.md"); + +// The live armonissima team mount (read-only runtime source of the canonical +// director core); absent on machines without the mount. +const LIVE_SKILL = path.join( + process.env.HOME ?? "", + ".amico", + "vaults", + "armonissima", + "skills", + "director-core", + "SKILL.md", +); + +const CARDS = ["autodev.md", "autoresearch.md"] as const; +type CardName = (typeof CARDS)[number]; + +const SPINE_START = ""; +const SPINE_END = ""; + +// D4's proper-noun blocklist, loaded from the fixture of record +// (packages/extension/protocol-blocklist.json — committed by the naming +// slice; kept the single in-repo home of the strings). No blocklisted +// string is spelled out in this file. +const BLOCKLIST_PATH = path.join(EXT, "protocol-blocklist.json"); +const PROPRIETARY_STRINGS: string[] = ( + JSON.parse(readFileSync(BLOCKLIST_PATH, "utf8")) as { + proprietary_strings: string[]; + } +).proprietary_strings; + +// The five loop verbs + four core-clause keywords (Measurement Protocol). +const LOOP_VERBS = ["plan", "dispatch", "gate", "analyze", "record"] as const; +const CORE_CLAUSES = ["ledger", "cast", "compaction", "anti-gaming"] as const; +const KEYWORDS = [...LOOP_VERBS, ...CORE_CLAUSES] as const; + +const cardText = (name: CardName): string => + readFileSync(path.join(AGENTS_DIR, name), "utf8"); + +const fixtureSkill = readFileSync(FIXTURE_SKILL, "utf8"); + +/** Extract the spine between the marker pair; counts pairs for the one-pair rule. */ +function spineOf(text: string): { spine: string; pairs: number } { + const starts = countOccurrences(text, SPINE_START); + const ends = countOccurrences(text, SPINE_END); + const first = text.indexOf(SPINE_START); + const last = text.indexOf(SPINE_END); + if (first === -1 || last === -1 || last < first) { + return { spine: "", pairs: Math.max(starts, ends) }; + } + return { + spine: text.slice(first + SPINE_START.length, last), + pairs: Math.min(starts, ends), + }; +} + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +/** Whole-word, case-insensitive regex match. */ +function hasWholeWord(text: string, word: string): boolean { + const re = new RegExp(`(^|[^A-Za-z0-9-])${word}(?:s|d|ed|ing)?([^A-Za-z0-9-]|$)`, "i"); + return re.test(text); +} + +/** + * The sentence containing the first whole-word match of `word`, sentences + * split on [.!?] boundaries. Returns "" when no match. + */ +function containingSentence(text: string, word: string): string { + if (!hasWholeWord(text, word)) return ""; + const sentences = text.split(/(?<=[.!?])\s+/); + return sentences.find((s) => hasWholeWord(s, word)) ?? ""; +} + +const wordCount = (sentence: string): number => + sentence.split(/\s+/).filter((w) => w.length > 0).length; + +/** The ledger discovery rule's canonical fenced block, extracted from the skill. */ +function discoveryRuleFrom(skill: string): string { + const marker = "LEDGER DISCOVERY RULE v1"; + expect(skill.includes(marker), "skill carries the discovery-rule block").toBe(true); + // The fenced ```text block that CONTAINS the marker (the opening fence + // precedes it, so search fence pairs, not forward from the marker). + const openFence = skill.lastIndexOf("```text", skill.indexOf(marker)); + const closeFence = skill.indexOf("```", openFence + "```text".length); + expect(openFence, "discovery rule sits in a fenced text block").toBeGreaterThan(-1); + expect(closeFence, "the discovery-rule fence closes").toBeGreaterThan(-1); + return skill.slice(openFence, closeFence + 3); +} + +describe("mode cards — marker pairs", () => { + for (const name of CARDS) { + it(`${name}: carries exactly one DIRECTOR-SPINE v1 marker pair`, () => { + const { pairs } = spineOf(cardText(name)); + expect(pairs, `${name} must carry exactly one marker pair`).toBe(1); + }); + } +}); + +describe("mode cards — spine parity (byte-identity)", () => { + const spines = CARDS.map((name) => spineOf(cardText(name)).spine); + + it("the two spines are byte-identical", () => { + expect(spines[0], "autodev spine === autoresearch spine, byte for byte").toBe( + spines[1], + ); + }); + + it("each spine is at least 200 bytes", () => { + for (const [i, name] of CARDS.entries()) { + expect( + Buffer.byteLength(spines[i] ?? "", "utf8"), + `${name} spine byte length`, + ).toBeGreaterThanOrEqual(200); + } + }); +}); + +describe("mode cards — spine content floor", () => { + const spine = spineOf(cardText("autodev.md")).spine; // parity ⇒ one check suffices + + it("carries all five loop verbs, each in a sentence of at least 8 words", () => { + for (const verb of LOOP_VERBS) { + expect(hasWholeWord(spine, verb), `spine contains "${verb}"`).toBe(true); + const sentence = containingSentence(spine, verb); + expect(wordCount(sentence), `"${verb}" lives in a real sentence`).toBeGreaterThanOrEqual(8); + } + }); + + it("carries all four core-clause keywords, each in a sentence of at least 8 words", () => { + for (const clause of CORE_CLAUSES) { + expect(hasWholeWord(spine, clause), `spine contains "${clause}"`).toBe(true); + const sentence = containingSentence(spine, clause); + expect(wordCount(sentence), `"${clause}" lives in a real sentence`).toBeGreaterThanOrEqual(8); + } + }); +}); + +describe("mode cards — ledger discovery rule (correctness by containment)", () => { + const spine = spineOf(cardText("autoresearch.md")).spine; + + it("the spine contains the canonical discovery-rule block verbatim", () => { + const rule = discoveryRuleFrom(fixtureSkill); + expect(spine).toContain(rule); + }); + + it("the committed fixture is the live armonissima director core when the mount is present", () => { + if (!existsSync(LIVE_SKILL)) return; // mount absent: fixture is the hermetic source + const live = readFileSync(LIVE_SKILL, "utf8"); + expect(fixtureSkill, "fixture === live canonical skill").toBe(live); + }); +}); + +describe("mode cards — frontmatter (boot-check fields)", () => { + for (const name of CARDS) { + it(`${name}: frontmatter carries description and mode`, () => { + const text = cardText(name); + expect(text.startsWith("---\n"), `${name} opens with YAML frontmatter`).toBe(true); + const end = text.indexOf("\n---\n", 4); + expect(end, `${name} frontmatter closes`).toBeGreaterThan(-1); + const frontmatter = text.slice(4, end); + expect(frontmatter, `${name} declares a description`).toMatch(/^description:\s*\S/m); + expect(frontmatter, `${name} declares a mode`).toMatch(/^mode:\s*\S/m); + }); + } +}); + +describe("mode cards — blocklist (open-protocol vocabulary)", () => { + for (const name of CARDS) { + it(`${name}: zero blocklisted proprietary strings`, () => { + // Guard: the fixture of record carries the five spec'd strings — an + // empty blocklist would make this test vacuously green. + expect(PROPRIETARY_STRINGS.length).toBeGreaterThanOrEqual(5); + const text = cardText(name); + for (const s of PROPRIETARY_STRINGS) { + expect( + text.toLowerCase().includes(s.toLowerCase()), + `${name} must not contain "${s}"`, + ).toBe(false); + } + }); + } +}); diff --git a/scripts/deploy-agents.mjs b/scripts/deploy-agents.mjs new file mode 100644 index 00000000..3d1500ad --- /dev/null +++ b/scripts/deploy-agents.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +// deploy-agents.mjs — copy the mode cards from repo sources to the deployed +// agent-card directories (staging bundle + global opencode config) with +// byte-equality verification and a source-digest receipt. +// +// MANUAL OPERATOR ACTION — never CI. This script is run by a human (or an +// agent acting for one) at deploy time; the receipt it writes is the audit +// trail the fixture suite re-checks. It never pushes, never opens PRs, and +// never creates a destination that does not already exist. +// +// Usage: +// node scripts/deploy-agents.mjs [--dry-run] [--staging ] [--global ] +// +// Defaults: +// staging: ~/.amico/server/opencode-project-staging/opencode-project/.opencode/agents +// global: ~/.config/opencode/agents +// +// Behavior: +// - copies packages/extension/agents/{autodev,autoresearch}.md 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; +// - exits 0 only when at least one destination was deployed and verified; +// any verification mismatch or unreadable source exits 1. + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync, copyFileSync } from "node:fs"; +import path from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, ".."); +const SOURCE_DIR = path.join(REPO_ROOT, "packages", "extension", "agents"); +const RECEIPT_PATH = path.join(SOURCE_DIR, ".deploy-receipt.json"); + +const CARDS = ["autodev.md", "autoresearch.md"]; + +const DEFAULT_STAGING = path.join( + homedir(), + ".amico", + "server", + "opencode-project-staging", + "opencode-project", + ".opencode", + "agents", +); +const DEFAULT_GLOBAL = path.join(homedir(), ".config", "opencode", "agents"); + +// ── args ──────────────────────────────────────────────────────────────────── +let dryRun = false; +let stagingDir = DEFAULT_STAGING; +let globalDir = DEFAULT_GLOBAL; + +for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (arg === "--dry-run") { + dryRun = true; + } else if (arg === "--staging") { + stagingDir = process.argv[++i]; + if (!stagingDir) usageFail("--staging requires a path"); + } else if (arg === "--global") { + globalDir = process.argv[++i]; + if (!globalDir) usageFail("--global requires a path"); + } else { + usageFail(`unknown argument: ${arg}`); + } +} + +function usageFail(message) { + process.stderr.write(`deploy-agents: ${message}\n`); + process.stderr.write( + "usage: node scripts/deploy-agents.mjs [--dry-run] [--staging ] [--global ]\n", + ); + process.exit(1); +} + +// ── helpers ───────────────────────────────────────────────────────────────── +const sha256 = (buffer) => "sha256:" + createHash("sha256").update(buffer).digest("hex"); + +function log(message) { + process.stdout.write(`${message}\n`); +} + +// ── sources ───────────────────────────────────────────────────────────────── +const sources = []; +for (const card of CARDS) { + const sourcePath = path.join(SOURCE_DIR, card); + if (!existsSync(sourcePath)) { + process.stderr.write(`deploy-agents: missing source card ${sourcePath}\n`); + process.exit(1); + } + const bytes = readFileSync(sourcePath); + sources.push({ card, path: sourcePath, sha256: sha256(bytes), bytes }); +} + +// ── destinations ──────────────────────────────────────────────────────────── +const destinations = [ + { label: "staging", dir: stagingDir }, + { label: "global", dir: globalDir }, +]; + +const results = []; +let deployedCount = 0; + +for (const dest of destinations) { + if (!existsSync(dest.dir)) { + log(`[${dest.label}] SKIP — destination does not exist (never created): ${dest.dir}`); + results.push({ + label: dest.label, + path: dest.dir, + deployed: false, + verified: false, + reason: "destination does not exist", + }); + continue; + } + + const cards = []; + let allVerified = true; + + for (const source of sources) { + const destPath = path.join(dest.dir, source.card); + try { + if (!dryRun) { + copyFileSync(source.path, destPath); + } + const deployed = dryRun ? null : readFileSync(destPath); + const verified = !dryRun && deployed !== null && deployed.equals(source.bytes); + if (!verified) allVerified = false; + cards.push({ card: source.card, path: destPath, verified }); + log( + `[${dest.label}] ${dryRun ? "would copy" : "copied"} ${source.card}` + + (dryRun ? "" : verified ? " — verified byte-identical" : " — VERIFICATION FAILED"), + ); + } catch (error) { + allVerified = false; + cards.push({ + card: source.card, + path: destPath, + verified: false, + reason: String(error?.message ?? error), + }); + log(`[${dest.label}] FAILED ${source.card}: ${error?.message ?? error}`); + } + } + + const deployed = !dryRun && cards.length > 0 && cards.every((c) => c.verified !== false); + if (deployed) deployedCount++; + results.push({ label: dest.label, path: dest.dir, deployed, verified: allVerified, cards }); +} + +// ── receipt ───────────────────────────────────────────────────────────────── +const receipt = { + receipt_version: 1, + deployed_at: new Date().toISOString(), + dry_run: dryRun, + sources: sources.map(({ card, path: p, sha256: digest }) => ({ + card, + path: p, + sha256: digest, + })), + destinations: results.map(({ label, path: p, deployed, verified, reason }) => ({ + label, + path: p, + deployed, + verified, + ...(reason ? { reason } : {}), + })), +}; + +if (dryRun) { + log(`[receipt] DRY RUN — receipt not written (would write ${RECEIPT_PATH})`); +} else { + writeFileSync(RECEIPT_PATH, JSON.stringify(receipt, null, 2) + "\n"); + log(`[receipt] wrote ${RECEIPT_PATH}`); +} + +// ── exit ──────────────────────────────────────────────────────────────────── +if (dryRun) { + log("dry run complete — nothing written"); + process.exit(0); +} + +if (deployedCount === 0) { + process.stderr.write( + "deploy-agents: no destination was deployed and verified — nothing to audit\n", + ); + process.exit(1); +} + +const failed = results.some((r) => r.cards?.some((c) => c.verified === false)); +if (failed) { + process.stderr.write("deploy-agents: byte-equality verification FAILED for at least one card\n"); + process.exit(1); +} + +log(`deployed ${sources.length} card(s) to ${deployedCount} destination(s), all verified`); +process.exit(0);