From 229cfb644d3805c9b75afae762b9981cb474b73b Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Thu, 22 Oct 2026 19:27:24 -0500 Subject: [PATCH 1/4] fix(vibe): keep shared capabilities in one owner - Require shared plans to name the owning parent and all consumers - Block repeated page-level capability composition - Compare every consumer with its approved prototype route Testing: Node tests, pytest, Ruff, Pyright, instruction word gate Risks: Static import matching may require an explicit separate-composition ruling --- CHANGELOG.md | 12 ++++ plugins/vibe/.claude-plugin/plugin.json | 2 +- plugins/vibe/.codex-plugin/plugin.json | 2 +- plugins/vibe/README.md | 2 +- plugins/vibe/agents/vibe-change-worker.md | 22 +++---- .../vibe/agents/vibe-guardrails-reviewer.md | 22 +++---- plugins/vibe/agents/vibe-verify-worker.md | 6 +- plugins/vibe/skills/handoff/SKILL.md | 4 +- .../skills/handoff/scripts/diff-rules.mjs | 60 +++++++++++++++++++ .../handoff/scripts/diff-rules.test.mjs | 46 ++++++++++++++ .../skills/vibe/references/design-pass.md | 37 ++++++------ .../skills/vibe/references/quality-loop.md | 6 +- .../python/test_vibe_1079_lessons_contract.py | 38 ++++++++++++ 13 files changed, 210 insertions(+), 49 deletions(-) create mode 100644 plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 65766a9b..6812757d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to the claude-plugins project will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`. +### vibe v1.0.79 + +Shared-scope work now proves one owner, every inheriting consumer and page-by-page prototype parity. + +#### Added +- Handoff's `shared-capability-owner` diff rule rejects a capability-level design-system control newly composed by another page adapter when multiple page adapters already compose it directly. A broad acceptance criterion cannot waive the finding; only a ticket or operator ruling that explicitly requires separate compositions can. + +#### Changed +- When a ticket or PRD says "across all", or the person says "shared", the vibe plan names one owning parent component, every consumer that inherits it and each consumer's approved prototype route. The guardrails reviewer checks that inventory against the import graph. +- Running-app and visual QA compare each consumer page with that page's approved prototype route and flag controls present in the prototype but missing in production, alongside rendered controls that lack a sourced requirement. +- Instruction text is trimmed so the release adds no words: Markdown under the vibe plugin is about 7 words shorter than in 1.0.78. + ### vibe v1.0.78 The fleet runs from one fleet home, and the open 2026-10-22 lessons each get a check where one fits. diff --git a/plugins/vibe/.claude-plugin/plugin.json b/plugins/vibe/.claude-plugin/plugin.json index 481f27ce..81355259 100644 --- a/plugins/vibe/.claude-plugin/plugin.json +++ b/plugins/vibe/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vibe", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", - "version": "1.0.78", + "version": "1.0.79", "dependencies": ["closedloop-core"], "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"} } diff --git a/plugins/vibe/.codex-plugin/plugin.json b/plugins/vibe/.codex-plugin/plugin.json index 078468f9..96bf8b70 100644 --- a/plugins/vibe/.codex-plugin/plugin.json +++ b/plugins/vibe/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibe", - "version": "1.0.78", + "version": "1.0.79", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"}, "homepage": "https://github.com/closedloop-ai/claude-plugins", diff --git a/plugins/vibe/README.md b/plugins/vibe/README.md index 83e4ac1f..55cb16b9 100644 --- a/plugins/vibe/README.md +++ b/plugins/vibe/README.md @@ -523,7 +523,7 @@ harness switch instead of another worker doing its work. ## Runtime Files -Version `1.0.78` contains 12 skills, 16 agents, and one hook manifest +Version `1.0.79` contains 12 skills, 16 agents, and one hook manifest (`hooks/hooks.json`). It has no standalone commands, root-level shell scripts, or production Python tools under `tools/python/`; that directory contains the skill-contract test modules and diff --git a/plugins/vibe/agents/vibe-change-worker.md b/plugins/vibe/agents/vibe-change-worker.md index c569c1b7..ba6c9bd2 100644 --- a/plugins/vibe/agents/vibe-change-worker.md +++ b/plugins/vibe/agents/vibe-change-worker.md @@ -55,17 +55,17 @@ a needed vibe tooling fix returns `BLOCKED`. Run the prep step in `design-pass.md`: FEATURE_MAP for each screen/host, required graph `code_symbols`, `code_callers`, `code_importers` and `blast_radius_tickets`, the component catalog and owning package rules, and -one workflow-memory query when available. Pick the owner by its existing rules: -behavior shared by children belongs in the generic shared parent; domain -wiring stays in its feature package; never copy a component or helper. Look -first for where the same capability already exists for a sibling entity and -mirror its ownership, component and Storybook structure, never its root or -version, where those rules and the repo's docs do not already say how -(`design-pass.md`, Prep step 5). Before you write a new hook, helper, query -key or inline query, `rg` the repo's shared app hooks and helpers (its auth -helpers included) and the existing `use-*` hooks for its endpoint, query key -and subject, and use what exists. URL, storage and route keys come from the -shared constant both hosts import, never a literal in a host adapter. +one workflow-memory query when available. Apply its owner rules: behavior +shared by children belongs in the generic shared parent; domain wiring stays +in its feature package. Where no rule or repo doc decides, mirror a sibling's +ownership and Storybook structure, never its root or version +(`design-pass.md`, Prep step 5). Before a new hook, helper, query or key, `rg` +the repo's shared app hooks and helpers (its auth helpers included) and the +existing `use-*` hooks, then reuse the match. Both hosts import URL, storage +and route keys from one constant. +When a ticket or PRD says "across all" or the person says "shared", the plan +must name one owning parent component and every consumer that inherits it, +plus each consumer's approved prototype route. Before you delete a feature flag's read, key constant or gate, read the comments on every ticket linked to it (the live ticket, its PRD, and the diff --git a/plugins/vibe/agents/vibe-guardrails-reviewer.md b/plugins/vibe/agents/vibe-guardrails-reviewer.md index b85f74d7..a4506a13 100644 --- a/plugins/vibe/agents/vibe-guardrails-reviewer.md +++ b/plugins/vibe/agents/vibe-guardrails-reviewer.md @@ -104,16 +104,18 @@ path merely because another rule describes how its content should look. 8. Shared owner and red flags: every item in `design-pass.md` "Review". Read the session change log (`$(git -C rev-parse --absolute-git-dir)/vibe-changes.md`) for each - request's `Owner` and `Rule`. Flag each red flag (shallow module, - information leakage, temporal decomposition, pass-through, copy) with - `file:line` for every site, and name the parent and the generic slot or - extension point the behavior belongs in. Any component that composes or - inherits from a shared parent keeps only what is specific to it: two - children of one parent that each implement the same behavior is blocking, - and so is a copy - of an existing shared component or domain code inside a design-system - slot. Code that is not in the `Owner` its change log entry names is - advisory. Run these checks and quote what each prints: + request's `Owner` and `Rule`. Report red flags with `file:line` and owner. + Any component that composes or inherits from a shared parent keeps only + what is specific to it: two children of one parent that each implement the + same behavior is blocking. Code outside its logged `Owner` is advisory. + Run and quote: + - Same capability component: treat every `shared-capability-owner` diff + finding as blocking when two or more page-level adapters compose it + instead of one shared parent. Compare the plan's Owner and Consumers + inventory with the import graph; an exception passes only when its cited + ruling explicitly requires separate compositions. + - Red flags: shallow module, information leakage, temporal decomposition, + pass-through and copy. - Slot without an owner: for each capability whose state the children share (selection, grouping, collapse, paging), `rg` its state and handlers (`useState`, a reducer, `selected`, `onSelect`, `toggle`) in diff --git a/plugins/vibe/agents/vibe-verify-worker.md b/plugins/vibe/agents/vibe-verify-worker.md index a74a98a3..9d1d4999 100644 --- a/plugins/vibe/agents/vibe-verify-worker.md +++ b/plugins/vibe/agents/vibe-verify-worker.md @@ -269,8 +269,10 @@ session touched: `pnpm control console --level pageerror` and `pnpm control network`. Add `pnpm control api GET ` for the record the screen shows, and `pnpm control ipc ` read calls for a Desktop screen, - where relevant. Inventory the rendered controls against the sourced - requirements and report every control with no source on that surface. + where relevant. For shared scope, compare each consumer page against that + page's approved prototype route; report every control present in the + prototype but missing in production, and report every control with no + source on that surface. 4. When a baseline exists (an earlier control capture of the same entry on the merge-base or current main, never a commit that is not an ancestor), return its artifact paths beside the new ones and say what changed. When the session recorded reference images diff --git a/plugins/vibe/skills/handoff/SKILL.md b/plugins/vibe/skills/handoff/SKILL.md index c5cd7c26..1aba9ff0 100644 --- a/plugins/vibe/skills/handoff/SKILL.md +++ b/plugins/vibe/skills/handoff/SKILL.md @@ -479,7 +479,9 @@ change, against the session's acceptance criteria when the ticket or its PRD has them and otherwise as the design pack's best-practice review (`../vibe/references/quality-loop.md`, "Visual QA during the session", "What it checks"); its rendered-control inventory flags anything with no -sourced requirement. Run logical QA (`/logical-qa`, `$logical-qa`) for a data or metric +sourced requirement. For shared scope it compares each consumer page against +that page's approved prototype route and flags every control present in the +prototype but missing in production. Run logical QA (`/logical-qa`, `$logical-qa`) for a data or metric surface, then product-contract QA (`/metal-andrew`, `$metal-andrew`) once that evidence is visible. Each skill dispatches its own read-only agents. Confirmed findings resume the SAME writer in fix mode for every touched surface, even when diff --git a/plugins/vibe/skills/handoff/scripts/diff-rules.mjs b/plugins/vibe/skills/handoff/scripts/diff-rules.mjs index 1ce408fb..78c846de 100644 --- a/plugins/vibe/skills/handoff/scripts/diff-rules.mjs +++ b/plugins/vibe/skills/handoff/scripts/diff-rules.mjs @@ -84,6 +84,10 @@ const DEEPEN_TIMEOUT_MS = 10 * 60 * 1000; const CONTROL_PROP = /\b((?:show|enable|allow|with|render|on)[A-Z][A-Za-z0-9]*)\s*(?::|=)/; const CONTROL_TERM = /selectall|checkbox|button|menu|action|toolbar|filter|search|picker|popover|dialog|drawer|toggle|favorite|star|bulk|control|disclosure|pagination/i; const CONTROL_MOUNT = /<([A-Z][A-Za-z0-9]*)\b/g; +const IMPORT_DECLARATION = /\bimport\s+(?!["'])([\s\S]*?)\s+from\s+["']([^"']+)["'];?/g; +const DESIGN_SYSTEM_CONTROL = /^@repo\/design-system\/components\/ui\//; +const CAPABILITY_COMPONENT = /search|filter|sort|range|toolbar|pagination|control|picker|menu|popover/i; +const PAGE_COMPOSER = /(?:^|\/)[^/]*(?:page|route|toolbar|list|shell|surface|adapter)[^/]*\.[jt]sx$/i; /** Parses `git diff --unified=0` output into per-file added and removed lines. */ export function parseUnifiedDiff(text) { @@ -164,6 +168,7 @@ export function evaluateDiff(files, changeLog, repo) { checkLostStoryIds(files, repo, add); checkCopyProvenance(live, files, repo, logged, add); checkNewControls(live, changeLog, add); + checkSharedCapabilityOwners(live, changeLog, repo, add); for (const problem of visualBaselineLedgerProblems(changeLog, live.filter((file) => isVisualBaseline(file.path)).map((file) => file.path))) { const file = problem.slice(0, problem.indexOf(":")); add("visual-baseline-ledger", file, 1, problem.slice(problem.indexOf(":") + 2), "compare the baseline diff with the requirement inventory; record its counted `Baseline inventory:` and one cited `Baseline element:` line per new element"); @@ -208,6 +213,61 @@ function checkNewControls(files, changeLog, add) { } } +/** Import declarations with their original exported names and one-based line range. */ +function componentImports(source) { + const imports = []; + for (const match of source.matchAll(IMPORT_DECLARATION)) { + const clause = match[1].trim(); + if (clause.startsWith("type ")) continue; + const names = []; + const named = /\{([\s\S]*?)\}/.exec(clause)?.[1]; + if (named) { + for (const item of named.split(",")) { + const original = item.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]; + if (original) names.push(original); + } + } + const defaultName = clause.split(/[,{]/)[0].trim(); + if (defaultName && !defaultName.startsWith("*")) names.push(defaultName); + const start = source.slice(0, match.index).split("\n").length; + const end = start + match[0].split("\n").length - 1; + imports.push({ source: match[2], names, start, end }); + } + return imports; +} + +/** + * A low-level capability newly composed by a second page adapter is visible + * in the import graph. It belongs behind one parent unless a human explicitly + * rules that the pages need separate compositions. + */ +function checkSharedCapabilityOwners(files, changeLog, repo, add) { + const checked = new Set(); + for (const file of files.filter((entry) => PAGE_COMPOSER.test(entry.path) && !isTestFile(entry.path) && !STORY_FILE.test(entry.path))) { + const addedLines = new Set(file.added.map((entry) => entry.line)); + for (const imported of componentImports(repo.head(file.path) ?? "").filter((entry) => DESIGN_SYSTEM_CONTROL.test(entry.source))) { + const changedImport = [...addedLines].some((line) => line >= imported.start && line <= imported.end); + const changedNames = imported.names.filter((name) => + CAPABILITY_COMPONENT.test(name) && (changedImport || file.added.some((entry) => new RegExp(`<${escapeRegExp(name)}\\b`).test(entry.text))) + ); + for (const name of changedNames) { + const key = `${imported.source}:${name}`; + if (checked.has(key)) continue; + checked.add(key); + const composers = repo.wordFiles(name).filter((candidate) => + PAGE_COMPOSER.test(candidate) && !isTestFile(candidate) && !STORY_FILE.test(candidate) && + componentImports(repo.head(candidate) ?? "").some((entry) => entry.source === imported.source && entry.names.includes(name)) + ); + if (composers.length < 2) continue; + const exception = new RegExp(String.raw`^\s*(?:[-*]\s+)?Shared capability exception:\s*${escapeRegExp(name)}:\s*(?:(?:ISS|PRD|PLN|FEA)-\d+\s+ruling\b|operator ruling\b)`, "im"); + if (exception.test(changeLog)) continue; + const line = file.added.find((entry) => entry.line >= imported.start && entry.line <= imported.end)?.line ?? imported.start; + add("shared-capability-owner", file.path, line, `${name} is composed directly by ${composers.length} page-level adapters instead of one shared parent`, `move ${name} into one shared parent and make every consumer inherit it; only an explicit ruling for separate compositions may record \`Shared capability exception: ${name}: \``); + } + } + } +} + function checkMigrations(files, repo, logged, add, requiredChecks) { let touched = false; for (const file of files) { diff --git a/plugins/vibe/skills/handoff/scripts/diff-rules.test.mjs b/plugins/vibe/skills/handoff/scripts/diff-rules.test.mjs index 3e681d34..e49ab54b 100644 --- a/plugins/vibe/skills/handoff/scripts/diff-rules.test.mjs +++ b/plugins/vibe/skills/handoff/scripts/diff-rules.test.mjs @@ -335,6 +335,52 @@ test("a #8962-shaped prop that enables a control needs a source naming it on tha assert.deepEqual(run(t, { base, head, changeLog: cited }).found.filter(([rule]) => rule === "control-provenance"), []); }); +test("a #8922-shaped capability composed by sibling list adapters needs one shared owner", (t) => { + const component = "packages/design-system/components/ui/filter-popover.tsx"; + const sessions = "packages/app/agents/components/sessions/sessions-list-toolbar-v2.tsx"; + const artifacts = "packages/app/shared/components/generic-artifact-surface/artifact-list-shell-sections.tsx"; + const projects = "packages/app/projects/components/project-list.tsx"; + const base = { + [component]: "export function FilterPopover() { return null; }\n", + [artifacts]: 'import { FilterPopover } from "@repo/design-system/components/ui/filter-popover";\nexport const ArtifactToolbar = () => ;\n', + [projects]: 'import { FilterPopover } from "@repo/design-system/components/ui/filter-popover";\nexport const ProjectList = () => ;\n', + }; + const head = { + [sessions]: 'import { FilterPopover } from "@repo/design-system/components/ui/filter-popover";\nexport const SessionsToolbar = () => ;\n', + }; + + const result = run(t, { base, head }); + assert.deepEqual( + result.blocking.filter((finding) => finding.rule === "shared-capability-owner").map((finding) => finding.file), + [sessions] + ); + assert.deepEqual( + run(t, { base, head, changeLog: "Shared capability exception: FilterPopover: PRD-721 AC-1\n" }) + .blocking.filter((finding) => finding.rule === "shared-capability-owner").map((finding) => finding.file), + [sessions], + "a broad acceptance criterion cannot authorize separate page compositions" + ); + assert.deepEqual( + run(t, { base, head, changeLog: "Shared capability exception: FilterPopover: ISS-123 ruling for separate compositions\n" }) + .blocking.filter((finding) => finding.rule === "shared-capability-owner"), + [] + ); + + const owner = "packages/app/shared/components/generic-artifact-list-shell.tsx"; + const inherited = { + [owner]: 'import { FilterPopover } from "@repo/design-system/components/ui/filter-popover";\nexport const GenericArtifactListShell = () => ;\n', + [sessions]: 'import { GenericArtifactListShell } from "@repo/app/shared/components/generic-artifact-list-shell";\nexport const SessionsPage = GenericArtifactListShell;\n', + [artifacts]: 'import { GenericArtifactListShell } from "@repo/app/shared/components/generic-artifact-list-shell";\nexport const DocumentsPage = GenericArtifactListShell;\n', + [projects]: 'import { GenericArtifactListShell } from "@repo/app/shared/components/generic-artifact-list-shell";\nexport const ProjectsPage = GenericArtifactListShell;\n', + }; + assert.deepEqual( + run(t, { base: { [component]: base[component] }, head: inherited }) + .blocking.filter((finding) => finding.rule === "shared-capability-owner"), + [], + "one parent may own the control while every page inherits it" + ); +}); + test("a changed screenshot baseline needs its counted element ledger before handoff", (t) => { const file = "e2e/__screenshots__/visual-regression.spec.ts/branches-desktop.png"; assert.deepEqual(run(t, { base: { [file]: "old\n" }, head: { [file]: "new\n" } }).found, [["visual-baseline-ledger", file]]); diff --git a/plugins/vibe/skills/vibe/references/design-pass.md b/plugins/vibe/skills/vibe/references/design-pass.md index c7abecbf..dfb43d9f 100644 --- a/plugins/vibe/skills/vibe/references/design-pass.md +++ b/plugins/vibe/skills/vibe/references/design-pass.md @@ -1,21 +1,16 @@ # Design pass -Vibe requests arrive in the person's own words. The prep picks ownership by -rule; `quality-loop.md` then keeps the local technical plan and the lean build -loop internal, with independent reviews at handoff. Existing tests run at -handoff; no tests are authored in vibe or handoff unless the person asks for a -pull request, and the writer only repairs existing tests its change broke. -Nothing here is shown to or asked of the person. +Vibe keeps the lean build loop internal, with independent reviews at handoff. +Existing tests run at handoff; no tests are authored in vibe or handoff unless +the person asks for a pull request, and the writer only repairs existing tests +its change broke. Nothing here is shown to or asked of the person. ## Owner rules -The rule covers any component that composes or inherits from a shared parent -(a screen built on a shared surface, a variant of a shared control, a hook or -service built on a shared one): behavior the children share goes in the -parent, and a child keeps only what is specific to that child. Applied in -order; the first that fits decides. Nobody asks the person where code goes, -and nobody asks the person to confirm the approach. Independent technical -review follows `quality-loop.md` internally. +For any component that composes or inherits from a shared parent, shared +behavior goes in the parent and a child keeps only what is specific to that +child. The first fitting rule decides without asking the person. +Nobody asks the person where code goes. 1. Behavior that the children of a shared parent share goes into that parent as a generic, domain-free slot or extension point, and each child opts in @@ -40,14 +35,13 @@ review follows `quality-loop.md` internally. in `packages/app` (route shells stay thin), on the surface that already owns the entity (`guardrails.md`, "Where code goes"). -An opt-in slot that other children do not pass changes nothing on them, so it -is not the "everywhere or just here" question; that question is only for a -change that alters what other children already show. +An opt-in slot changes nothing for children that do not pass it; only altering +what they show raises the "everywhere or just here" question. ## Prep (build loop, one or two minutes) -The change worker's plan dispatch does this before any edit. It is a lookup, -not a design session: no second design, no questions, no approval. +The change worker does this lookup before editing. It is not a design session: +no second design, no questions, no approval. 1. Screens and gates: the FEATURE_MAP entry for each screen the request touches (`pnpm control feature show `, or the entry in @@ -100,13 +94,16 @@ dispatch gets them verbatim: Prep: - Screens: - Gates: -- Shared parent: +- Shared parent: +- Shared capability: +- Owner: +- Consumers: +- Prototype routes: approved route/component, or none> - Existing components: - Earlier work: - Memory: - Precedent: - Placement: -- Owner: - Rule: Graph: - diff --git a/plugins/vibe/skills/vibe/references/quality-loop.md b/plugins/vibe/skills/vibe/references/quality-loop.md index 094ec676..074370df 100644 --- a/plugins/vibe/skills/vibe/references/quality-loop.md +++ b/plugins/vibe/skills/vibe/references/quality-loop.md @@ -823,8 +823,10 @@ and `$design` route production review through. the change against them: the run gets the session decision table's `## Acceptance Criteria` list (those criteria, plus any the person's requests added) and reports each visual and UX criterion as met or not, - with what it saw. It also inventories rendered controls and flags any with - no requirement naming that control on that surface. + with what it saw. For shared scope it compares each consumer page against + that page's approved prototype route, flags every control present in the + prototype but missing in production, and inventories rendered controls for + any with no requirement naming that control on that surface. - When neither has any, it falls back to the design pack's best-practice review, against `.claude/design/soul.md`, as the skill does when there is no spec. diff --git a/plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py b/plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py new file mode 100644 index 00000000..a0a9749a --- /dev/null +++ b/plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py @@ -0,0 +1,38 @@ +"""Contract coverage for the vibe 1.0.79 shared-owner lesson.""" + +from pathlib import Path + +from vibe_contract_text import flat + + +PLUGIN = Path(__file__).resolve().parents[2] +VIBE = PLUGIN / "skills" / "vibe" + + +def test_shared_scope_plans_name_one_owner_and_every_inheriting_consumer() -> None: + worker = flat((PLUGIN / "agents" / "vibe-change-worker.md").read_text()) + design = flat((VIBE / "references" / "design-pass.md").read_text()) + assert '"across all"' in worker and 'the person says "shared"' in worker + assert "one owning parent component" in worker + assert "every consumer that inherits it" in worker + for field in ("Shared capability:", "Owner:", "Consumers:", "Prototype routes:"): + assert field in design, field + + +def test_guardrails_reject_parallel_page_composition_of_one_capability() -> None: + reviewer = flat((PLUGIN / "agents" / "vibe-guardrails-reviewer.md").read_text()).lower() + rules = (PLUGIN / "skills" / "handoff" / "scripts" / "diff-rules.mjs").read_text() + assert "shared-capability-owner" in rules + assert "same capability component" in reviewer + assert "two or more page-level adapters" in reviewer + assert "one shared parent" in reviewer + + +def test_handoff_compares_each_consumer_with_its_prototype_route() -> None: + verifier = flat((PLUGIN / "agents" / "vibe-verify-worker.md").read_text()) + handoff = flat((PLUGIN / "skills" / "handoff" / "SKILL.md").read_text()) + quality = flat((VIBE / "references" / "quality-loop.md").read_text()) + required = "each consumer page against that page's approved prototype route" + for text in (verifier, handoff, quality): + assert required in text + assert "present in the prototype but missing in production" in text From 790d5dbbecd7c98618115be76baefe6a04f7074e Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Thu, 22 Oct 2026 19:42:25 -0500 Subject: [PATCH 2/4] fix(vibe): block direct Claude worker launches - Refuse internal vibe worker subtypes outside a recorded vibe session - Route fleet Claude writers through the vibe skill and its full lifecycle - Cover denied, allowed and in-session Agent launches Testing: Node tests, pytest, Ruff, Pyright, instruction word gate Risks: The guard matches only scoped internal worker agent types --- CHANGELOG.md | 1 + plugins/vibe/hooks/agent-guard.mjs | 31 ++++++++++++++++++++++++ plugins/vibe/hooks/agent-guard.test.mjs | 32 +++++++++++++++++++++++++ plugins/vibe/hooks/hooks.json | 9 +++++++ 4 files changed, 73 insertions(+) create mode 100644 plugins/vibe/hooks/agent-guard.mjs create mode 100644 plugins/vibe/hooks/agent-guard.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6812757d..f9ad2a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Shared-scope work now proves one owner, every inheriting consumer and page-by-pa #### Added - Handoff's `shared-capability-owner` diff rule rejects a capability-level design-system control newly composed by another page adapter when multiple page adapters already compose it directly. A broad acceptance criterion cannot waive the finding; only a ticket or operator ruling that explicitly requires separate compositions can. +- Claude's Agent pre-tool guard refuses a fleet orchestrator's direct launch of an internal `vibe:vibe-*-worker` outside a vibe session, preserving the session record, environment setup, guardrails and adversarial review, PR preflight and SendMessage path supplied by `vibe:vibe`. #### Changed - When a ticket or PRD says "across all", or the person says "shared", the vibe plan names one owning parent component, every consumer that inherits it and each consumer's approved prototype route. The guardrails reviewer checks that inventory against the import graph. diff --git a/plugins/vibe/hooks/agent-guard.mjs b/plugins/vibe/hooks/agent-guard.mjs new file mode 100644 index 00000000..22a507f8 --- /dev/null +++ b/plugins/vibe/hooks/agent-guard.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +// PreToolUse guard for Agent: an orchestrator starts a Claude vibe writer by +// invoking the vibe:vibe skill, never by dispatching one of its internal +// workers. Direct dispatch skips the session record, environment setup, +// guardrails and adversarial review, PR preflight and SendMessage. + +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { inVibeSession } from "./foreground-guard.mjs"; + +const VIBE_WORKER = /^vibe:vibe-[a-z0-9-]+-worker$/; + +/** The refusal for a direct internal vibe-worker launch, or null. */ +export function agentLaunchProblem(input) { + const subagent = input?.subagent_type; + if (typeof subagent !== "string" || !VIBE_WORKER.test(subagent)) return null; + return `do not launch ${subagent} directly: invoke \`vibe:vibe\` in a Claude agent so the writer gets its session record, environment setup, guardrails and adversarial review, PR preflight and SendMessage path`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const event = JSON.parse(readFileSync(0, "utf8") || "{}"); + const problem = event?.tool_name === "Agent" ? agentLaunchProblem(event.tool_input) : null; + const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd(); + if (problem && !inVibeSession(cwd)) { + process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: problem } })}\n`); + } + } catch (error) { + process.stderr.write(`agent-guard: ${error instanceof Error ? error.message : String(error)}\n`); + } +} diff --git a/plugins/vibe/hooks/agent-guard.test.mjs b/plugins/vibe/hooks/agent-guard.test.mjs new file mode 100644 index 00000000..e320c2a3 --- /dev/null +++ b/plugins/vibe/hooks/agent-guard.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { agentLaunchProblem } from "./agent-guard.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const GUARD = path.join(HERE, "agent-guard.mjs"); + +test("an orchestrator cannot launch a vibe worker directly outside a vibe session", (t) => { + const dir = mkdtempSync(path.join(os.tmpdir(), "vibe-agent-guard-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const run = (subagentType, cwd = dir) => spawnSync(process.execPath, [GUARD], { + input: JSON.stringify({ tool_name: "Agent", cwd, tool_input: { subagent_type: subagentType, prompt: "Implement the ticket" } }), + encoding: "utf8", + }); + + assert.match(agentLaunchProblem({ subagent_type: "vibe:vibe-change-worker" }), /invoke `vibe:vibe`/); + const denied = JSON.parse(run("vibe:vibe-change-worker").stdout).hookSpecificOutput; + assert.equal(denied.permissionDecision, "deny"); + assert.match(denied.permissionDecisionReason, /session record, environment setup, guardrails and adversarial review, PR preflight and SendMessage path/); + assert.equal(run("general-purpose").stdout, "", "a Claude agent may invoke the vibe skill"); + + execFileSync("git", ["init", "--quiet", "--initial-branch=main", dir]); + const gitDir = execFileSync("git", ["-C", dir, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" }).trim(); + writeFileSync(path.join(gitDir, "vibe-session.json"), "{}\n"); + assert.equal(run("vibe:vibe-change-worker", dir).stdout, "", "the vibe skill may dispatch its own worker inside a session"); + assert.ok(readFileSync(path.join(HERE, "hooks.json"), "utf8").includes('"matcher": "Agent"'), "the Agent hook is registered"); +}); diff --git a/plugins/vibe/hooks/hooks.json b/plugins/vibe/hooks/hooks.json index 22673f44..5ff755fb 100644 --- a/plugins/vibe/hooks/hooks.json +++ b/plugins/vibe/hooks/hooks.json @@ -17,6 +17,15 @@ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/merge-guard.mjs\"" } ] + }, + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/agent-guard.mjs\"" + } + ] } ], "UserPromptSubmit": [ From 51b8691e97aaa7c6124b8c6a1f054d2f40fab9b4 Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Thu, 22 Oct 2026 20:58:27 -0500 Subject: [PATCH 3/4] Support vibe workers across runtimes - Normalize Claude and Codex MCP tool spellings at the launcher boundary - Preserve change worker collaboration tools and both manifest layouts - Cover every worker tool contract with dual runtime fixtures Testing: Full Node, worker, and Python suites; Ruff; Pyright; package typecheck and build Risks: Low; aliases widen accepted spellings while role capability checks remain enforced --- CHANGELOG.md | 4 +- plugins/vibe/agents/vibe-change-worker.md | 6 +- plugins/vibe/agents/vibe-seed-pr-worker.md | 2 +- plugins/vibe/evals/codex/run.py | 14 +- plugins/vibe/evals/codex/test_run.py | 8 + .../vibe/scripts/dist/claude-worker.mjs | 384 ++++++++++-------- .../skills/vibe/scripts/dist/main-sync.mjs | 11 +- .../skills/vibe/scripts/dist/writer-state.mjs | 246 ++++++----- tools/vibe-workers/src/claude-preflight.ts | 9 +- .../src/claude-worker-preflight.test.ts | 8 + tools/vibe-workers/src/claude-worker.test.ts | 5 +- tools/vibe-workers/src/claude-worker.ts | 7 +- tools/vibe-workers/src/contracts.ts | 22 +- tools/vibe-workers/src/definition.ts | 6 +- tools/vibe-workers/src/plugin-root.ts | 20 +- .../vibe-workers/src/runtime-contract.test.ts | 110 +++++ tools/vibe-workers/src/test-fixtures.ts | 9 +- 17 files changed, 563 insertions(+), 308 deletions(-) create mode 100644 tools/vibe-workers/src/runtime-contract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ad2a55..6d5a692d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,13 @@ Shared-scope work now proves one owner, every inheriting consumer and page-by-pa #### Added - Handoff's `shared-capability-owner` diff rule rejects a capability-level design-system control newly composed by another page adapter when multiple page adapters already compose it directly. A broad acceptance criterion cannot waive the finding; only a ticket or operator ruling that explicitly requires separate compositions can. - Claude's Agent pre-tool guard refuses a fleet orchestrator's direct launch of an internal `vibe:vibe-*-worker` outside a vibe session, preserving the session record, environment setup, guardrails and adversarial review, PR preflight and SendMessage path supplied by `vibe:vibe`. +- A cross-runtime worker contract enumerates every worker's built-ins and owned MCP operations against Codex and Claude fixtures, including `$vibe` versus `vibe:vibe` invocation and both plugin manifest layouts. #### Changed - When a ticket or PRD says "across all", or the person says "shared", the vibe plan names one owning parent component, every consumer that inherits it and each consumer's approved prototype route. The guardrails reviewer checks that inventory against the import graph. - Running-app and visual QA compare each consumer page with that page's approved prototype route and flag controls present in the prototype but missing in production, alongside rendered controls that lack a sourced requirement. -- Instruction text is trimmed so the release adds no words: Markdown under the vibe plugin is about 7 words shorter than in 1.0.78. +- The owned launcher accepts underscore or hyphen MCP operation names, binds both runtime aliases, and compares loaded tools by canonical identity. The Claude change worker keeps `SendMessage` and `Agent` for progress and read-only helpers, while the sole-writer rule still forbids nested writers. +- Instruction text is trimmed so the release adds no words: Markdown under the vibe plugin is about 2 words shorter than in 1.0.78. ### vibe v1.0.78 diff --git a/plugins/vibe/agents/vibe-change-worker.md b/plugins/vibe/agents/vibe-change-worker.md index ba6c9bd2..6a94a912 100644 --- a/plugins/vibe/agents/vibe-change-worker.md +++ b/plugins/vibe/agents/vibe-change-worker.md @@ -2,15 +2,15 @@ name: vibe-change-worker description: The one persistent implementation writer for a vibe session. Keeps the same context, worktree and branch while planning, implementing all frontend/backend/prototype/component work, fixing reviewed issues, repairing existing tests its change broke and listing the missing tests for engineering at handoff; it writes tests only when the person asks for a pull request, in any plain words. Uses graph-first owner and product-decision research, local canonical plans, Storybook and existing checks. Never commits or opens a PR. model: sonnet -tools: Read, Write, Edit, Grep, Glob, Bash, Skill +tools: Read, Write, Edit, Grep, Glob, Bash, Skill, SendMessage, Agent --- You are the sole implementation writer for this session. Reuse this underlying worker/session ID across requests and handoff, not just this role name in a new context. Work only in the supplied session worktree and branch. Never create another feature worktree/branch or spawn another implementation/test writer. -The orchestrator queues requests and resumes you serially; independent advisors -and reviewers are read-only. +The orchestrator queues requests serially. Agent may spawn read-only helpers, +never writers. Use SendMessage for parent progress. ## Inputs and continuations diff --git a/plugins/vibe/agents/vibe-seed-pr-worker.md b/plugins/vibe/agents/vibe-seed-pr-worker.md index 346f6421..0bc19c94 100644 --- a/plugins/vibe/agents/vibe-seed-pr-worker.md +++ b/plugins/vibe/agents/vibe-seed-pr-worker.md @@ -1,6 +1,6 @@ --- name: vibe-seed-pr-worker -description: Handles the git and GitHub side of a vibe-seed-refresh pull request in the target repository for its orchestrator. Modes: open (push, PR from the template), push (push a fix the orchestrator committed and reply on its review threads), status (one-line PR state), sync (stage a merge of main for the orchestrator to commit), enqueue (arm auto-merge into the merge queue), cleanup (remove the fix worktree). Never commits. Returns a short status. +description: "Handles the git and GitHub side of a vibe-seed-refresh pull request in the target repository for its orchestrator. Modes: open (push, PR from the template), push (push a fix the orchestrator committed and reply on its review threads), status (one-line PR state), sync (stage a merge of main for the orchestrator to commit), enqueue (arm auto-merge into the merge queue), cleanup (remove the fix worktree). Never commits. Returns a short status." model: sonnet tools: Read, Write, Grep, Glob, Bash --- diff --git a/plugins/vibe/evals/codex/run.py b/plugins/vibe/evals/codex/run.py index b65644f9..4db806f5 100644 --- a/plugins/vibe/evals/codex/run.py +++ b/plugins/vibe/evals/codex/run.py @@ -22,6 +22,7 @@ EVALS = Path(__file__).resolve().parents[1] PLUGIN = EVALS.parent +MANIFESTS = (".codex-plugin/plugin.json", ".claude-plugin/plugin.json") def document(path: Path) -> tuple[dict[str, Any], str]: @@ -30,6 +31,15 @@ def document(path: Path) -> tuple[dict[str, Any], str]: return yaml.safe_load(header), body.strip() +def plugin_version(plugin: Path) -> str: + """Read the version from either supported runtime's plugin manifest.""" + for relative in MANIFESTS: + manifest = plugin / relative + if manifest.is_file(): + return str(json.loads(manifest.read_text())["version"]) + raise FileNotFoundError("vibe has no Claude Code or Codex plugin manifest") + + def role_text(text: str) -> str: """Replace fixture identities with roles without changing the scenario intent.""" text = re.sub(r"\b[\w.]+@[\w.]+\.com\b", "product-manager@example.invalid", text) @@ -667,9 +677,7 @@ def main() -> int: (args.plugin / "skills/vibe/SKILL.md").read_bytes() ).hexdigest(), plugin=str(args.plugin.resolve()), - version=json.loads( - (args.plugin / ".claude-plugin/plugin.json").read_text() - )["version"], + version=plugin_version(args.plugin), model=args.model, grader_model=args.grader_model, concurrency=args.concurrency, diff --git a/plugins/vibe/evals/codex/test_run.py b/plugins/vibe/evals/codex/test_run.py index 22c098c2..2391782d 100644 --- a/plugins/vibe/evals/codex/test_run.py +++ b/plugins/vibe/evals/codex/test_run.py @@ -33,6 +33,14 @@ def test_one_case_per_claude_prompt_and_role_names() -> None: ) +@pytest.mark.parametrize("layout", [".codex-plugin", ".claude-plugin"]) +def test_plugin_version_accepts_either_runtime_manifest(tmp_path: Path, layout: str) -> None: + manifest = tmp_path / layout / "plugin.json" + manifest.parent.mkdir() + manifest.write_text(json.dumps({"version": "1.2.3"})) + assert runner.plugin_version(tmp_path) == "1.2.3" + + def test_every_model_grader_has_a_deterministic_or_wording_route() -> None: deterministic = { "explains-both-endings", diff --git a/plugins/vibe/skills/vibe/scripts/dist/claude-worker.mjs b/plugins/vibe/skills/vibe/scripts/dist/claude-worker.mjs index ba377690..2f93b90d 100644 --- a/plugins/vibe/skills/vibe/scripts/dist/claude-worker.mjs +++ b/plugins/vibe/skills/vibe/scripts/dist/claude-worker.mjs @@ -11421,86 +11421,6 @@ var NEVER = INVALID; // src/claude-preflight.ts import { spawnSync } from "node:child_process"; import { homedir } from "node:os"; -var LaunchPrecheckError = class extends Error { - constructor(code, message, personAction) { - super(message); - this.code = code; - this.personAction = personAction; - } - code; - personAction; -}; -var SIGN_IN_PERSON_ACTION = "Your Claude sign-in on this Mac has run out, so I can't start the helpers that build your change. Open the Terminal app (press Cmd and Space, type Terminal, press Return), type claude auth login and press Return, then finish signing in in the browser window that opens. Tell me when you're done."; -var AUTH_FAILURE = /oauth (?:session|token)[^\n]*expired|failed to authenticate|please run \/login|invalid api key|not logged in|authentication_error/i; -function isAuthFailure(text) { - return AUTH_FAILURE.test(text); -} -function checkClaudeSignIn(command = "claude") { - const status = spawnSync(command, ["auth", "status", "--json"], { - cwd: homedir(), - encoding: "utf8", - timeout: 2e4, - stdio: ["ignore", "pipe", "pipe"] - }); - if (status.error && "code" in status.error && status.error.code === "ENOENT") { - throw new LaunchPrecheckError("CLAUDE_CLI_MISSING", "The claude command is not on this shell's PATH"); - } - let parsed; - try { - parsed = JSON.parse(status.stdout ?? ""); - } catch { - return; - } - if (parsed && typeof parsed === "object" && parsed.loggedIn === false) { - throw new LaunchPrecheckError("CLAUDE_SIGN_IN_NEEDED", "The standalone Claude CLI is signed out", SIGN_IN_PERSON_ACTION); - } -} -var requiredLiveReads = { - "vibe-requirements-worker": ["get_document"], - "vibe-ticket-worker": ["get_document"] -}; -function checkRoleCapabilities(agentName, capabilities) { - const missing = (requiredLiveReads[agentName] ?? []).filter((operation) => !capabilities.some((capability) => capability.service === "closedloop" && capability.access === "read" && capability.operation === operation)); - if (missing.length) { - throw new LaunchPrecheckError( - "MISSING_CAPABILITIES", - `${agentName} needs ClosedLoop read capabilities: ${missing.join(", ")}; discover and pass them in the parent` - ); - } -} -function missingMcpTools(initTools, granted) { - const available = new Set(initTools); - return granted.filter((name) => name.startsWith("mcp__") && !available.has(name)); -} -function missingServers(missing) { - return [...new Set(missing.map((name) => name.slice("mcp__".length, name.lastIndexOf("__"))))]; -} - -// src/cli.ts -import { realpathSync } from "node:fs"; -import { fileURLToPath, pathToFileURL as pathToFileURL2 } from "node:url"; - -// ../shared/cli.ts -import { pathToFileURL } from "node:url"; -function runWhenMain(metaUrl, main2) { - const entry = process.argv[1]; - if (!entry) return; - if (metaUrl !== pathToFileURL(entry).href) return; - Promise.resolve(main2(process.argv.slice(2))).then( - (code) => process.exit(code), - (err) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); - } - ); -} - -// src/cli.ts -function runFromCanonicalEntry(metaUrl, main2) { - const entry = process.argv[1]; - if (!entry || realpathSync(fileURLToPath(metaUrl)) !== realpathSync(entry)) return; - runWhenMain(pathToFileURL2(entry).href, main2); -} // src/contracts.ts var graphOperations = /* @__PURE__ */ new Set([ @@ -11549,7 +11469,19 @@ var liveReadOperations = /* @__PURE__ */ new Set([ "list_templates", "get_template" ]); -var capabilityName = /^mcp__[A-Za-z0-9_.:-]+__[A-Za-z0-9_]+$/; +var capabilityName = /^mcp__[A-Za-z0-9_.:-]+__[A-Za-z0-9_-]+$/; +function mcpToolIdentity(name) { + if (!capabilityName.test(name)) return null; + const boundary = name.lastIndexOf("__"); + return `${name.slice(0, boundary)}__${name.slice(boundary + 2).replaceAll("-", "_")}`; +} +function mcpToolAliases(name) { + const identity = mcpToolIdentity(name); + if (!identity) return [name]; + const boundary = identity.lastIndexOf("__"); + const hyphenated = `${identity.slice(0, boundary)}__${identity.slice(boundary + 2).replaceAll("_", "-")}`; + return [.../* @__PURE__ */ new Set([name, identity, hyphenated])]; +} var capabilitySchema = external_exports.object({ name: external_exports.string().regex(capabilityName), service: external_exports.enum(["graph", "closedloop"]), @@ -11557,12 +11489,12 @@ var capabilitySchema = external_exports.object({ access: external_exports.enum(["read", "write"]) }).strict().superRefine((value, ctx) => { const validOperation = value.service === "graph" ? value.access === "read" && graphOperations.has(value.operation) : value.access === "read" ? liveReadOperations.has(value.operation) : recordOperations.has(value.operation); - if (!validOperation || !value.name.endsWith(`__${value.operation}`)) { + if (!validOperation || mcpToolIdentity(value.name)?.split("__").at(-1) !== value.operation) { ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: "Unsupported capability binding" }); } }); var capabilitiesSchema = external_exports.array(capabilitySchema).max(48).refine( - (values) => new Set(values.map((value) => value.name)).size === values.length, + (values) => new Set(values.map((value) => mcpToolIdentity(value.name))).size === values.length, "Duplicate capability binding" ); var identifierSchema = external_exports.string().min(1).max(160).regex(/^[A-Za-z0-9_.:-]+$/); @@ -11676,11 +11608,164 @@ async function readInput(stream = process.stdin) { return JSON.parse(Buffer.concat(buffers).toString("utf8")); } +// src/claude-preflight.ts +var LaunchPrecheckError = class extends Error { + constructor(code, message, personAction) { + super(message); + this.code = code; + this.personAction = personAction; + } + code; + personAction; +}; +var SIGN_IN_PERSON_ACTION = "Your Claude sign-in on this Mac has run out, so I can't start the helpers that build your change. Open the Terminal app (press Cmd and Space, type Terminal, press Return), type claude auth login and press Return, then finish signing in in the browser window that opens. Tell me when you're done."; +var AUTH_FAILURE = /oauth (?:session|token)[^\n]*expired|failed to authenticate|please run \/login|invalid api key|not logged in|authentication_error/i; +function isAuthFailure(text) { + return AUTH_FAILURE.test(text); +} +function checkClaudeSignIn(command = "claude") { + const status = spawnSync(command, ["auth", "status", "--json"], { + cwd: homedir(), + encoding: "utf8", + timeout: 2e4, + stdio: ["ignore", "pipe", "pipe"] + }); + if (status.error && "code" in status.error && status.error.code === "ENOENT") { + throw new LaunchPrecheckError("CLAUDE_CLI_MISSING", "The claude command is not on this shell's PATH"); + } + let parsed; + try { + parsed = JSON.parse(status.stdout ?? ""); + } catch { + return; + } + if (parsed && typeof parsed === "object" && parsed.loggedIn === false) { + throw new LaunchPrecheckError("CLAUDE_SIGN_IN_NEEDED", "The standalone Claude CLI is signed out", SIGN_IN_PERSON_ACTION); + } +} +var requiredLiveReads = { + "vibe-requirements-worker": ["get_document"], + "vibe-ticket-worker": ["get_document"] +}; +function checkRoleCapabilities(agentName, capabilities) { + const missing = (requiredLiveReads[agentName] ?? []).filter((operation) => !capabilities.some((capability) => capability.service === "closedloop" && capability.access === "read" && capability.operation === operation)); + if (missing.length) { + throw new LaunchPrecheckError( + "MISSING_CAPABILITIES", + `${agentName} needs ClosedLoop read capabilities: ${missing.join(", ")}; discover and pass them in the parent` + ); + } +} +function missingMcpTools(initTools, granted) { + const available = new Set(initTools.map(mcpToolIdentity).filter((name) => name !== null)); + return granted.filter((name) => { + const identity = mcpToolIdentity(name); + return identity !== null && !available.has(identity); + }); +} +function missingServers(missing) { + return [...new Set(missing.map((name) => name.slice("mcp__".length, name.lastIndexOf("__"))))]; +} + +// src/cli.ts +import { realpathSync } from "node:fs"; +import { fileURLToPath, pathToFileURL as pathToFileURL2 } from "node:url"; + +// ../shared/cli.ts +import { pathToFileURL } from "node:url"; +function runWhenMain(metaUrl, main2) { + const entry = process.argv[1]; + if (!entry) return; + if (metaUrl !== pathToFileURL(entry).href) return; + Promise.resolve(main2(process.argv.slice(2))).then( + (code) => process.exit(code), + (err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + ); +} + +// src/cli.ts +function runFromCanonicalEntry(metaUrl, main2) { + const entry = process.argv[1]; + if (!entry || realpathSync(fileURLToPath(metaUrl)) !== realpathSync(entry)) return; + runWhenMain(pathToFileURL2(entry).href, main2); +} + // src/definition.ts var import_yaml = __toESM(require_dist(), 1); import { createHash } from "node:crypto"; -import { readFileSync, realpathSync as realpathSync2 } from "node:fs"; -import { join } from "node:path"; +import { readFileSync as readFileSync2, realpathSync as realpathSync3 } from "node:fs"; +import { join as join2 } from "node:path"; + +// src/plugin-root.ts +import { existsSync, readFileSync, realpathSync as realpathSync2 } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath as fileURLToPath2 } from "node:url"; +var AgentRootError = class extends Error { + constructor(code, message) { + super(message); + this.code = code; + } + code; +}; +var manifestPaths = [".claude-plugin/plugin.json", ".codex-plugin/plugin.json"]; +function readPluginManifest(root) { + for (const relative of manifestPaths) { + try { + return JSON.parse(readFileSync(join(root, relative), "utf8")); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } + } + throw Object.assign(new Error("No vibe plugin manifest exists"), { code: "ENOENT" }); +} +function installedPluginRoot(metaUrl) { + return realpathSync2(resolve(dirname(fileURLToPath2(metaUrl)), "../../../..")); +} +function resolveAgentRoot(supplied, installed, recorded) { + const fallback = recorded && existsSync(recorded) ? recorded : installed; + if (supplied === void 0) return fallback; + let root; + try { + root = realpathSync2(supplied); + } catch { + throw new AgentRootError("AGENT_ROOT_INVALID", "The supplied agent root does not exist; omit agentRoot to use the installed plugin"); + } + if (basename(root) === "agents" && isPluginRoot(dirname(root))) root = dirname(root); + if (!isPluginRoot(root)) { + throw new AgentRootError("AGENT_ROOT_INVALID", "The supplied agent root is not a vibe plugin root; omit agentRoot to use the installed plugin"); + } + if (root === installed || root === recorded) return root; + const suppliedVersion = pluginVersion(root); + const installedVersion = pluginVersion(installed); + if (suppliedVersion && installedVersion && suppliedVersion !== installedVersion) { + throw new AgentRootError( + "STALE_PLUGIN_ROOT", + `The supplied agent root is vibe ${suppliedVersion}, but this launcher belongs to vibe ${installedVersion}; omit agentRoot` + ); + } + return root; +} +function isPluginRoot(root) { + if (!existsSync(join(root, "agents"))) return false; + try { + return readPluginManifest(root).name === "vibe"; + } catch { + return false; + } +} +function pluginVersion(root) { + try { + const version = readPluginManifest(root).version; + return typeof version === "string" ? version : void 0; + } catch { + return void 0; + } +} + +// src/definition.ts var frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/; var metadataSchema = external_exports.object({ name: external_exports.string(), @@ -11689,16 +11774,29 @@ var metadataSchema = external_exports.object({ tools: external_exports.union([external_exports.string(), external_exports.array(external_exports.string())]), skills: external_exports.union([external_exports.string(), external_exports.array(external_exports.string())]).optional() }).passthrough(); -var builtins = /* @__PURE__ */ new Set(["Read", "Write", "Edit", "Grep", "Glob", "Bash", "Skill", "ToolSearch", "WebSearch", "WebFetch"]); +var builtins = /* @__PURE__ */ new Set([ + "Read", + "Write", + "Edit", + "Grep", + "Glob", + "Bash", + "Skill", + "ToolSearch", + "WebSearch", + "WebFetch", + "SendMessage", + "Agent" +]); function readDefinition(agentRoot, agentName, capabilityInput) { if (!/^vibe-[a-z0-9-]+$/.test(agentName)) throw new Error("Only own-plugin vibe agents are supported"); - const root = realpathSync2(agentRoot); - const file = realpathSync2(join(root, "agents", `${agentName}.md`)); - if (file !== join(root, "agents", `${agentName}.md`)) throw new Error("Agent escapes its owning plugin"); + const root = realpathSync3(agentRoot); + const file = realpathSync3(join2(root, "agents", `${agentName}.md`)); + if (file !== join2(root, "agents", `${agentName}.md`)) throw new Error("Agent escapes its owning plugin"); const manifest = external_exports.object({ name: external_exports.literal("vibe"), version: external_exports.string().regex(/^\d+\.\d+\.\d+$/).optional() }).passthrough().parse( - JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")) + readPluginManifest(root) ); - const source = readFileSync(file, "utf8"); + const source = readFileSync2(file, "utf8"); const match = source.match(frontmatter); if (!match) throw new Error("Agent has no canonical frontmatter"); const metadata = metadataSchema.parse((0, import_yaml.parse)(match[1] ?? "")); @@ -11734,12 +11832,12 @@ function observeDefinitionDigest(agentRoot, agentName, capabilities) { // src/ledger.ts import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, realpathSync as realpathSync3, renameSync, rmSync, writeFileSync } from "node:fs"; -import { basename, dirname as dirname2, isAbsolute, join as join3 } from "node:path"; +import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync4, realpathSync as realpathSync4, renameSync, rmSync, writeFileSync } from "node:fs"; +import { basename as basename2, dirname as dirname3, isAbsolute, join as join4 } from "node:path"; // src/reference-images.ts -import { existsSync, readFileSync as readFileSync2, statSync } from "node:fs"; -import { dirname, join as join2, resolve } from "node:path"; +import { existsSync as existsSync2, readFileSync as readFileSync3, statSync } from "node:fs"; +import { dirname as dirname2, join as join3, resolve as resolve2 } from "node:path"; var REFERENCE_DIR = "vibe-reference-images"; var referenceSchema = external_exports.object({ path: external_exports.string().min(1), @@ -11748,17 +11846,17 @@ var referenceSchema = external_exports.object({ }).passthrough(); var recordSchema = external_exports.object({ referenceImages: external_exports.array(external_exports.unknown()).optional() }).passthrough(); function readReferenceImages(metadataDir) { - const file = join2(metadataDir, "vibe-session.json"); - if (!existsSync(file)) return []; - const record = recordSchema.safeParse(JSON.parse(readFileSync2(file, "utf8"))); + const file = join3(metadataDir, "vibe-session.json"); + if (!existsSync2(file)) return []; + const record = recordSchema.safeParse(JSON.parse(readFileSync3(file, "utf8"))); if (!record.success) return []; - const owned = join2(metadataDir, REFERENCE_DIR); + const owned = join3(metadataDir, REFERENCE_DIR); const images = []; for (const raw of record.data.referenceImages ?? []) { const entry = referenceSchema.safeParse(raw); if (!entry.success) continue; - const path = resolve(entry.data.path); - if (dirname(path) !== owned || !existsSync(path) || !statSync(path).isFile()) continue; + const path = resolve2(entry.data.path); + if (dirname2(path) !== owned || !existsSync2(path) || !statSync(path).isFile()) continue; images.push({ path, mediaType: entry.data.mediaType, ...entry.data.requestId ? { requestId: entry.data.requestId } : {} }); } return images; @@ -11766,31 +11864,31 @@ function readReferenceImages(metadataDir) { // src/ledger.ts function checkoutLocation(worktree) { - const root = realpathSync3(git(worktree, ["rev-parse", "--show-toplevel"])); - if (realpathSync3(worktree) !== root) throw new Error("Use the session's exact worktree root"); + const root = realpathSync4(git(worktree, ["rev-parse", "--show-toplevel"])); + if (realpathSync4(worktree) !== root) throw new Error("Use the session's exact worktree root"); const metadata = git(root, ["rev-parse", "--absolute-git-dir", "--abbrev-ref", "HEAD"]); const separator = metadata.lastIndexOf("\n"); if (separator < 0) throw new Error("Missing native checkout metadata boundary"); const [dir, branch] = external_exports.tuple([external_exports.string().min(1).refine(isAbsolute), external_exports.string().min(1)]).parse([metadata.slice(0, separator).trim(), metadata.slice(separator + 1).trim()]); - return { root, dir, branch, file: join3(dir, "vibe-writer.json"), lock: join3(dir, "vibe-writer.lock") }; + return { root, dir, branch, file: join4(dir, "vibe-writer.json"), lock: join4(dir, "vibe-writer.lock") }; } function location(worktree) { const place = checkoutLocation(worktree); const record = external_exports.object({ worktree: external_exports.string().optional(), branch: external_exports.string() }).passthrough().parse( - JSON.parse(readFileSync3(join3(place.dir, "vibe-session.json"), "utf8")) + JSON.parse(readFileSync4(join4(place.dir, "vibe-session.json"), "utf8")) ); - if (record.worktree && realpathSync3(record.worktree) !== place.root || record.branch !== place.branch) throw new Error("Session worktree or branch changed"); + if (record.worktree && realpathSync4(record.worktree) !== place.root || record.branch !== place.branch) throw new Error("Session worktree or branch changed"); return place; } function readWriterSummary(worktree) { const place = location(worktree); - if (!existsSync2(place.file)) return void 0; + if (!existsSync3(place.file)) return void 0; return summary(readLedger(place)); } function recordedWriterRoot(worktree) { try { const place = location(worktree); - return existsSync2(place.file) ? readLedger(place).binding.agentRoot : void 0; + return existsSync3(place.file) ? readLedger(place).binding.agentRoot : void 0; } catch { return void 0; } @@ -11802,7 +11900,7 @@ function registerWriter(input) { if (data.runtime === "codex" && !data.workerId) throw new Error("Native writer registration requires its worker ID"); const place = location(data.worktree); return locked(place, () => { - if (existsSync2(place.file)) { + if (existsSync3(place.file)) { const ledger2 = readLedger(place); if (ledger2.runtime !== data.runtime || data.workerId && ledger2.workerId !== data.workerId || JSON.stringify(ledger2.binding) !== JSON.stringify(definition.binding)) { throw new Error("Persistent writer identity or capability binding changed"); @@ -11841,7 +11939,7 @@ function changeWriter(action, input) { if (ledger.pending.length >= 32) throw new Error("Pending queue is full; keep the new input with the parent"); ledger.pending.push({ id: requestId, input: data.input }); } else if (action === "claim") { - if (existsSync2(join3(place.dir, "vibe-record-turn.lock"))) throw new Error("A record turn is already running; retain the queued writer request"); + if (existsSync3(join4(place.dir, "vibe-record-turn.lock"))) throw new Error("A record turn is already running; retain the queued writer request"); if (ledger.active?.lease) throw new Error("Writer already has an owned running turn"); if (ledger.active) { if (ledger.active.id !== requestId || !data.continuation) throw new Error("Continue the active request before the next queued request"); @@ -11889,8 +11987,8 @@ function changeWriter(action, input) { } function acquireRecordTurnAt(place, owner) { return locked(place, () => { - const file = join3(place.dir, "vibe-record-turn.lock"); - if (existsSync2(place.file)) { + const file = join4(place.dir, "vibe-record-turn.lock"); + if (existsSync3(place.file)) { const ledger = readLedger(place); if (owner && !ledger.active?.lease) { throw new Error("Record continuation requires the exact current source lease"); @@ -11936,7 +12034,7 @@ function git(cwd, args) { }).trim(); } function readLedger(place) { - const ledger = ledgerSchema.parse(JSON.parse(readFileSync3(place.file, "utf8"))); + const ledger = ledgerSchema.parse(JSON.parse(readFileSync4(place.file, "utf8"))); if (ledger.worktree !== place.root || ledger.branch !== place.branch) throw new Error("Writer ledger does not belong to this session"); return ledger; } @@ -11992,62 +12090,6 @@ function proveGroupStopped(processGroupId) { throw new Error("Owned process group is still running; retain the lease"); } -// src/plugin-root.ts -import { existsSync as existsSync3, readFileSync as readFileSync4, realpathSync as realpathSync4 } from "node:fs"; -import { basename as basename2, dirname as dirname3, join as join4, resolve as resolve2 } from "node:path"; -import { fileURLToPath as fileURLToPath2 } from "node:url"; -var AgentRootError = class extends Error { - constructor(code, message) { - super(message); - this.code = code; - } - code; -}; -function installedPluginRoot(metaUrl) { - return realpathSync4(resolve2(dirname3(fileURLToPath2(metaUrl)), "../../../..")); -} -function resolveAgentRoot(supplied, installed, recorded) { - const fallback = recorded && existsSync3(recorded) ? recorded : installed; - if (supplied === void 0) return fallback; - let root; - try { - root = realpathSync4(supplied); - } catch { - throw new AgentRootError("AGENT_ROOT_INVALID", "The supplied agent root does not exist; omit agentRoot to use the installed plugin"); - } - if (basename2(root) === "agents" && isPluginRoot(dirname3(root))) root = dirname3(root); - if (!isPluginRoot(root)) { - throw new AgentRootError("AGENT_ROOT_INVALID", "The supplied agent root is not a vibe plugin root; omit agentRoot to use the installed plugin"); - } - if (root === installed || root === recorded) return root; - const suppliedVersion = pluginVersion(root); - const installedVersion = pluginVersion(installed); - if (suppliedVersion && installedVersion && suppliedVersion !== installedVersion) { - throw new AgentRootError( - "STALE_PLUGIN_ROOT", - `The supplied agent root is vibe ${suppliedVersion}, but this launcher belongs to vibe ${installedVersion}; omit agentRoot` - ); - } - return root; -} -function isPluginRoot(root) { - const manifest = join4(root, ".claude-plugin", "plugin.json"); - if (!existsSync3(manifest) || !existsSync3(join4(root, "agents"))) return false; - try { - return JSON.parse(readFileSync4(manifest, "utf8")).name === "vibe"; - } catch { - return false; - } -} -function pluginVersion(root) { - try { - const version = JSON.parse(readFileSync4(join4(root, ".claude-plugin", "plugin.json"), "utf8")).version; - return typeof version === "string" ? version : void 0; - } catch { - return void 0; - } -} - // src/record-context.ts import { existsSync as existsSync4, readFileSync as readFileSync5, realpathSync as realpathSync5 } from "node:fs"; import { join as join5, sep } from "node:path"; @@ -12624,7 +12666,7 @@ function boundDefinition(definition, input) { throw new Error("Parent-discovered read-only graph capabilities are required"); } const roleTools = prototypeAdvice ? definition.tools.filter((tool) => tool !== "Write" && tool !== "Edit") : definition.tools; - const tools = [.../* @__PURE__ */ new Set([...roleTools, "ToolSearch", structuredOutputTool, ...input.capabilities.filter((capability) => capability.access === "read" || permittedWrites.includes(capability.operation)).map((capability) => capability.name)])]; + const tools = [.../* @__PURE__ */ new Set([...roleTools, "ToolSearch", structuredOutputTool, ...input.capabilities.filter((capability) => capability.access === "read" || permittedWrites.includes(capability.operation)).flatMap((capability) => mcpToolAliases(capability.name))])]; const persistent = input.agentName === "vibe-change-worker"; const phasePolicy = persistent ? "Current phase, test-authoring and test-repair authority come only from the orchestrator's top-level turn envelope, separate from raw input text. Only an explicit pull-request turn in that envelope, sent because the person asked for a pull request, may author tests. Never author tests in plan, request, fix, handoff, record or build turns, or infer authority from the person's words. A request, fix or handoff turn with test-repair authority may only repair existing tests and fixtures your own change broke (imports, renamed or retired symbols, setup, type errors) without changing what any test asserts; never add a test file. Delete a test file or case only when it covers nothing but code the request retires, recorded with retired-tests.mjs and the ticket or PRD that retires it; a test of live behavior is only repaired. " : `This is mode ${input.mode}. Never write or edit tests. `; const prompt = `${definition.prompt} @@ -12743,7 +12785,7 @@ async function runWorker(rawInput) { "--tools", bound.tools.join(","), "--disallowedTools", - bound.reader ? "Agent,Task,Write,Edit,NotebookEdit,EnterWorktree,ExitWorktree" : "Agent,Task,EnterWorktree,ExitWorktree,NotebookEdit", + bound.reader ? "Agent,Task,Write,Edit,NotebookEdit,EnterWorktree,ExitWorktree" : bound.tools.includes("Agent") ? "Task,EnterWorktree,ExitWorktree,NotebookEdit" : "Agent,Task,EnterWorktree,ExitWorktree,NotebookEdit", "--permission-mode", "default", "--allowedTools", diff --git a/plugins/vibe/skills/vibe/scripts/dist/main-sync.mjs b/plugins/vibe/skills/vibe/scripts/dist/main-sync.mjs index 9f333741..731cfdb9 100644 --- a/plugins/vibe/skills/vibe/scripts/dist/main-sync.mjs +++ b/plugins/vibe/skills/vibe/scripts/dist/main-sync.mjs @@ -224048,7 +224048,12 @@ var liveReadOperations = /* @__PURE__ */ new Set([ "list_templates", "get_template" ]); -var capabilityName = /^mcp__[A-Za-z0-9_.:-]+__[A-Za-z0-9_]+$/; +var capabilityName = /^mcp__[A-Za-z0-9_.:-]+__[A-Za-z0-9_-]+$/; +function mcpToolIdentity(name) { + if (!capabilityName.test(name)) return null; + const boundary = name.lastIndexOf("__"); + return `${name.slice(0, boundary)}__${name.slice(boundary + 2).replaceAll("-", "_")}`; +} var capabilitySchema = external_exports.object({ name: external_exports.string().regex(capabilityName), service: external_exports.enum(["graph", "closedloop"]), @@ -224056,12 +224061,12 @@ var capabilitySchema = external_exports.object({ access: external_exports.enum(["read", "write"]) }).strict().superRefine((value, ctx) => { const validOperation = value.service === "graph" ? value.access === "read" && graphOperations.has(value.operation) : value.access === "read" ? liveReadOperations.has(value.operation) : recordOperations.has(value.operation); - if (!validOperation || !value.name.endsWith(`__${value.operation}`)) { + if (!validOperation || mcpToolIdentity(value.name)?.split("__").at(-1) !== value.operation) { ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: "Unsupported capability binding" }); } }); var capabilitiesSchema = external_exports.array(capabilitySchema).max(48).refine( - (values) => new Set(values.map((value) => value.name)).size === values.length, + (values) => new Set(values.map((value) => mcpToolIdentity(value.name))).size === values.length, "Duplicate capability binding" ); var identifierSchema = external_exports.string().min(1).max(160).regex(/^[A-Za-z0-9_.:-]+$/); diff --git a/plugins/vibe/skills/vibe/scripts/dist/writer-state.mjs b/plugins/vibe/skills/vibe/scripts/dist/writer-state.mjs index 16223c24..41b2ee45 100644 --- a/plugins/vibe/skills/vibe/scripts/dist/writer-state.mjs +++ b/plugins/vibe/skills/vibe/scripts/dist/writer-state.mjs @@ -11489,7 +11489,12 @@ var liveReadOperations = /* @__PURE__ */ new Set([ "list_templates", "get_template" ]); -var capabilityName = /^mcp__[A-Za-z0-9_.:-]+__[A-Za-z0-9_]+$/; +var capabilityName = /^mcp__[A-Za-z0-9_.:-]+__[A-Za-z0-9_-]+$/; +function mcpToolIdentity(name) { + if (!capabilityName.test(name)) return null; + const boundary = name.lastIndexOf("__"); + return `${name.slice(0, boundary)}__${name.slice(boundary + 2).replaceAll("-", "_")}`; +} var capabilitySchema = external_exports.object({ name: external_exports.string().regex(capabilityName), service: external_exports.enum(["graph", "closedloop"]), @@ -11497,12 +11502,12 @@ var capabilitySchema = external_exports.object({ access: external_exports.enum(["read", "write"]) }).strict().superRefine((value, ctx) => { const validOperation = value.service === "graph" ? value.access === "read" && graphOperations.has(value.operation) : value.access === "read" ? liveReadOperations.has(value.operation) : recordOperations.has(value.operation); - if (!validOperation || !value.name.endsWith(`__${value.operation}`)) { + if (!validOperation || mcpToolIdentity(value.name)?.split("__").at(-1) !== value.operation) { ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: "Unsupported capability binding" }); } }); var capabilitiesSchema = external_exports.array(capabilitySchema).max(48).refine( - (values) => new Set(values.map((value) => value.name)).size === values.length, + (values) => new Set(values.map((value) => mcpToolIdentity(value.name))).size === values.length, "Duplicate capability binding" ); var identifierSchema = external_exports.string().min(1).max(160).regex(/^[A-Za-z0-9_.:-]+$/); @@ -11619,14 +11624,82 @@ async function readInput(stream = process.stdin) { // src/ledger.ts import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, realpathSync as realpathSync3, renameSync, rmSync, writeFileSync } from "node:fs"; -import { basename, dirname as dirname2, isAbsolute, join as join3 } from "node:path"; +import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync4, realpathSync as realpathSync4, renameSync, rmSync, writeFileSync } from "node:fs"; +import { basename as basename2, dirname as dirname3, isAbsolute, join as join4 } from "node:path"; // src/definition.ts var import_yaml = __toESM(require_dist(), 1); import { createHash } from "node:crypto"; -import { readFileSync, realpathSync as realpathSync2 } from "node:fs"; -import { join } from "node:path"; +import { readFileSync as readFileSync2, realpathSync as realpathSync3 } from "node:fs"; +import { join as join2 } from "node:path"; + +// src/plugin-root.ts +import { existsSync, readFileSync, realpathSync as realpathSync2 } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath as fileURLToPath2 } from "node:url"; +var AgentRootError = class extends Error { + constructor(code, message) { + super(message); + this.code = code; + } + code; +}; +var manifestPaths = [".claude-plugin/plugin.json", ".codex-plugin/plugin.json"]; +function readPluginManifest(root) { + for (const relative of manifestPaths) { + try { + return JSON.parse(readFileSync(join(root, relative), "utf8")); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } + } + throw Object.assign(new Error("No vibe plugin manifest exists"), { code: "ENOENT" }); +} +function installedPluginRoot(metaUrl) { + return realpathSync2(resolve(dirname(fileURLToPath2(metaUrl)), "../../../..")); +} +function resolveAgentRoot(supplied, installed, recorded) { + const fallback = recorded && existsSync(recorded) ? recorded : installed; + if (supplied === void 0) return fallback; + let root; + try { + root = realpathSync2(supplied); + } catch { + throw new AgentRootError("AGENT_ROOT_INVALID", "The supplied agent root does not exist; omit agentRoot to use the installed plugin"); + } + if (basename(root) === "agents" && isPluginRoot(dirname(root))) root = dirname(root); + if (!isPluginRoot(root)) { + throw new AgentRootError("AGENT_ROOT_INVALID", "The supplied agent root is not a vibe plugin root; omit agentRoot to use the installed plugin"); + } + if (root === installed || root === recorded) return root; + const suppliedVersion = pluginVersion(root); + const installedVersion = pluginVersion(installed); + if (suppliedVersion && installedVersion && suppliedVersion !== installedVersion) { + throw new AgentRootError( + "STALE_PLUGIN_ROOT", + `The supplied agent root is vibe ${suppliedVersion}, but this launcher belongs to vibe ${installedVersion}; omit agentRoot` + ); + } + return root; +} +function isPluginRoot(root) { + if (!existsSync(join(root, "agents"))) return false; + try { + return readPluginManifest(root).name === "vibe"; + } catch { + return false; + } +} +function pluginVersion(root) { + try { + const version = readPluginManifest(root).version; + return typeof version === "string" ? version : void 0; + } catch { + return void 0; + } +} + +// src/definition.ts var frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/; var metadataSchema = external_exports.object({ name: external_exports.string(), @@ -11635,16 +11708,29 @@ var metadataSchema = external_exports.object({ tools: external_exports.union([external_exports.string(), external_exports.array(external_exports.string())]), skills: external_exports.union([external_exports.string(), external_exports.array(external_exports.string())]).optional() }).passthrough(); -var builtins = /* @__PURE__ */ new Set(["Read", "Write", "Edit", "Grep", "Glob", "Bash", "Skill", "ToolSearch", "WebSearch", "WebFetch"]); +var builtins = /* @__PURE__ */ new Set([ + "Read", + "Write", + "Edit", + "Grep", + "Glob", + "Bash", + "Skill", + "ToolSearch", + "WebSearch", + "WebFetch", + "SendMessage", + "Agent" +]); function readDefinition(agentRoot, agentName, capabilityInput) { if (!/^vibe-[a-z0-9-]+$/.test(agentName)) throw new Error("Only own-plugin vibe agents are supported"); - const root = realpathSync2(agentRoot); - const file = realpathSync2(join(root, "agents", `${agentName}.md`)); - if (file !== join(root, "agents", `${agentName}.md`)) throw new Error("Agent escapes its owning plugin"); + const root = realpathSync3(agentRoot); + const file = realpathSync3(join2(root, "agents", `${agentName}.md`)); + if (file !== join2(root, "agents", `${agentName}.md`)) throw new Error("Agent escapes its owning plugin"); const manifest = external_exports.object({ name: external_exports.literal("vibe"), version: external_exports.string().regex(/^\d+\.\d+\.\d+$/).optional() }).passthrough().parse( - JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")) + readPluginManifest(root) ); - const source = readFileSync(file, "utf8"); + const source = readFileSync2(file, "utf8"); const match = source.match(frontmatter); if (!match) throw new Error("Agent has no canonical frontmatter"); const metadata = metadataSchema.parse((0, import_yaml.parse)(match[1] ?? "")); @@ -11678,8 +11764,8 @@ function observeDefinitionDigest(agentRoot, agentName, capabilities) { } // src/reference-images.ts -import { existsSync, readFileSync as readFileSync2, statSync } from "node:fs"; -import { dirname, join as join2, resolve } from "node:path"; +import { existsSync as existsSync2, readFileSync as readFileSync3, statSync } from "node:fs"; +import { dirname as dirname2, join as join3, resolve as resolve2 } from "node:path"; var REFERENCE_DIR = "vibe-reference-images"; var referenceSchema = external_exports.object({ path: external_exports.string().min(1), @@ -11688,17 +11774,17 @@ var referenceSchema = external_exports.object({ }).passthrough(); var recordSchema = external_exports.object({ referenceImages: external_exports.array(external_exports.unknown()).optional() }).passthrough(); function readReferenceImages(metadataDir) { - const file = join2(metadataDir, "vibe-session.json"); - if (!existsSync(file)) return []; - const record = recordSchema.safeParse(JSON.parse(readFileSync2(file, "utf8"))); + const file = join3(metadataDir, "vibe-session.json"); + if (!existsSync2(file)) return []; + const record = recordSchema.safeParse(JSON.parse(readFileSync3(file, "utf8"))); if (!record.success) return []; - const owned = join2(metadataDir, REFERENCE_DIR); + const owned = join3(metadataDir, REFERENCE_DIR); const images = []; for (const raw of record.data.referenceImages ?? []) { const entry = referenceSchema.safeParse(raw); if (!entry.success) continue; - const path = resolve(entry.data.path); - if (dirname(path) !== owned || !existsSync(path) || !statSync(path).isFile()) continue; + const path = resolve2(entry.data.path); + if (dirname2(path) !== owned || !existsSync2(path) || !statSync(path).isFile()) continue; images.push({ path, mediaType: entry.data.mediaType, ...entry.data.requestId ? { requestId: entry.data.requestId } : {} }); } return images; @@ -11706,31 +11792,31 @@ function readReferenceImages(metadataDir) { // src/ledger.ts function checkoutLocation(worktree) { - const root = realpathSync3(git(worktree, ["rev-parse", "--show-toplevel"])); - if (realpathSync3(worktree) !== root) throw new Error("Use the session's exact worktree root"); + const root = realpathSync4(git(worktree, ["rev-parse", "--show-toplevel"])); + if (realpathSync4(worktree) !== root) throw new Error("Use the session's exact worktree root"); const metadata = git(root, ["rev-parse", "--absolute-git-dir", "--abbrev-ref", "HEAD"]); const separator = metadata.lastIndexOf("\n"); if (separator < 0) throw new Error("Missing native checkout metadata boundary"); const [dir, branch] = external_exports.tuple([external_exports.string().min(1).refine(isAbsolute), external_exports.string().min(1)]).parse([metadata.slice(0, separator).trim(), metadata.slice(separator + 1).trim()]); - return { root, dir, branch, file: join3(dir, "vibe-writer.json"), lock: join3(dir, "vibe-writer.lock") }; + return { root, dir, branch, file: join4(dir, "vibe-writer.json"), lock: join4(dir, "vibe-writer.lock") }; } function location(worktree) { const place = checkoutLocation(worktree); const record = external_exports.object({ worktree: external_exports.string().optional(), branch: external_exports.string() }).passthrough().parse( - JSON.parse(readFileSync3(join3(place.dir, "vibe-session.json"), "utf8")) + JSON.parse(readFileSync4(join4(place.dir, "vibe-session.json"), "utf8")) ); - if (record.worktree && realpathSync3(record.worktree) !== place.root || record.branch !== place.branch) throw new Error("Session worktree or branch changed"); + if (record.worktree && realpathSync4(record.worktree) !== place.root || record.branch !== place.branch) throw new Error("Session worktree or branch changed"); return place; } function readWriterSummary(worktree) { const place = location(worktree); - if (!existsSync2(place.file)) return void 0; + if (!existsSync3(place.file)) return void 0; return summary(readLedger(place)); } function recordedWriterRoot(worktree) { try { const place = location(worktree); - return existsSync2(place.file) ? readLedger(place).binding.agentRoot : void 0; + return existsSync3(place.file) ? readLedger(place).binding.agentRoot : void 0; } catch { return void 0; } @@ -11758,7 +11844,7 @@ function registerWriter(input) { if (data.runtime === "codex" && !data.workerId) throw new Error("Native writer registration requires its worker ID"); const place = location(data.worktree); return locked(place, () => { - if (existsSync2(place.file)) { + if (existsSync3(place.file)) { const ledger2 = readLedger(place); if (ledger2.runtime !== data.runtime || data.workerId && ledger2.workerId !== data.workerId || JSON.stringify(ledger2.binding) !== JSON.stringify(definition.binding)) { throw new Error("Persistent writer identity or capability binding changed"); @@ -11784,7 +11870,7 @@ function registerWriter(input) { function migrateWriter(input, currentPluginRoot, verifyRetained) { const data = migrateSchema.parse(input); const definition = readDefinition(currentPluginRoot, "vibe-change-worker", data.capabilities); - if (realpathSync3(data.agentRoot) !== definition.root || data.agentName !== "vibe-change-worker") { + if (realpathSync4(data.agentRoot) !== definition.root || data.agentName !== "vibe-change-worker") { throw new Error("Migration must use this command's current canonical plugin definition"); } const currentVersion = definition.version; @@ -11793,8 +11879,8 @@ function migrateWriter(input, currentPluginRoot, verifyRetained) { return locked(place, () => { const ledger = readLedger(place); if (ledger.runtime !== data.runtime || ledger.workerId !== data.previousWorkerId || ledger.binding.digest !== data.previousDigest) throw new Error("Migration requires the exact recorded writer and digest"); - const lock = join3(place.dir, "vibe-record-turn.lock"); - const retained = existsSync2(lock); + const lock = join4(place.dir, "vibe-record-turn.lock"); + const retained = existsSync3(lock); if (ledger.active?.lease || retained && !(data.retainedRecord && verifyRetained)) { throw new Error("Wait for owned source and record turns to stop before migration"); } @@ -11848,7 +11934,7 @@ function migrateWriter(input, currentPluginRoot, verifyRetained) { function provenDefinitionChange(ledger, observedDigest, currentVersion) { if (observedDigest === ledger.binding.digest) throw new Error("Recorded definition is present and matching; resume the existing writer"); if (observedDigest === null) return "definition-missing"; - const oldVersion = basename(dirname2(ledger.binding.agentRoot)) === "vibe" ? basename(ledger.binding.agentRoot) : void 0; + const oldVersion = basename2(dirname3(ledger.binding.agentRoot)) === "vibe" ? basename2(ledger.binding.agentRoot) : void 0; if (!oldVersion || !isNewerRelease(currentVersion, oldVersion)) { throw new Error("Digest mismatch has no proven plugin version upgrade; retain the original writer"); } @@ -11885,7 +11971,7 @@ function changeWriter(action, input) { if (ledger.pending.length >= 32) throw new Error("Pending queue is full; keep the new input with the parent"); ledger.pending.push({ id: requestId, input: data.input }); } else if (action === "claim") { - if (existsSync2(join3(place.dir, "vibe-record-turn.lock"))) throw new Error("A record turn is already running; retain the queued writer request"); + if (existsSync3(join4(place.dir, "vibe-record-turn.lock"))) throw new Error("A record turn is already running; retain the queued writer request"); if (ledger.active?.lease) throw new Error("Writer already has an owned running turn"); if (ledger.active) { if (ledger.active.id !== requestId || !data.continuation) throw new Error("Continue the active request before the next queued request"); @@ -11933,8 +12019,8 @@ function changeWriter(action, input) { } function acquireRecordTurnAt(place, owner) { return locked(place, () => { - const file = join3(place.dir, "vibe-record-turn.lock"); - if (existsSync2(place.file)) { + const file = join4(place.dir, "vibe-record-turn.lock"); + if (existsSync3(place.file)) { const ledger = readLedger(place); if (owner && !ledger.active?.lease) { throw new Error("Record continuation requires the exact current source lease"); @@ -11949,7 +12035,7 @@ function acquireRecordTurnAt(place, owner) { } function releaseRecordTurnAt(place, verify) { return locked(place, () => { - const lock = join3(place.dir, "vibe-record-turn.lock"); + const lock = join4(place.dir, "vibe-record-turn.lock"); verify(lock); rmSync(lock, { recursive: true }); }); @@ -11963,7 +12049,7 @@ function git(cwd, args) { }).trim(); } function readLedger(place) { - const ledger = ledgerSchema.parse(JSON.parse(readFileSync3(place.file, "utf8"))); + const ledger = ledgerSchema.parse(JSON.parse(readFileSync4(place.file, "utf8"))); if (ledger.worktree !== place.root || ledger.branch !== place.branch) throw new Error("Writer ledger does not belong to this session"); return ledger; } @@ -12021,12 +12107,12 @@ function proveGroupStopped(processGroupId) { // src/native-record.ts import { randomUUID as randomUUID2 } from "node:crypto"; -import { readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs"; -import { join as join5 } from "node:path"; +import { readFileSync as readFileSync6, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs"; +import { join as join6 } from "node:path"; // src/record-context.ts -import { existsSync as existsSync3, readFileSync as readFileSync4, realpathSync as realpathSync4 } from "node:fs"; -import { join as join4, sep } from "node:path"; +import { existsSync as existsSync4, readFileSync as readFileSync5, realpathSync as realpathSync5 } from "node:fs"; +import { join as join5, sep } from "node:path"; // ../../plugins/vibe/skills/vibe/scripts/session-record.mjs function sessionLiveTicket(record) { @@ -12170,7 +12256,7 @@ function resolveRecordContext(input) { const cancel = input.sessionless.kind === "discarded" && input.agentName === "vibe-ticket-worker" && input.mode === "record" && input.recordAction === "cancel" && input.exclusiveRecordTurn; if (!startup && !cancel) throw new Error("This role/action has no sessionless operation"); place = checkoutLocation(input.worktree); - if (existsSync3(join4(place.dir, "vibe-session.json"))) throw new Error("A private session exists; use its strict owned operation"); + if (existsSync4(join5(place.dir, "vibe-session.json"))) throw new Error("A private session exists; use its strict owned operation"); } else place = location(input.worktree); return { place, target: discardLocation(input, place) }; } @@ -12185,12 +12271,12 @@ function discardLocation(input, stable) { handoffTicket: external_exports.string().nullable().optional(), operator: external_exports.object({ id: external_exports.string().min(1), email: external_exports.string().email() }).passthrough() }).passthrough().parse( - JSON.parse(readFileSync4(join4(target.dir, "vibe-session.json"), "utf8")) + JSON.parse(readFileSync5(join5(target.dir, "vibe-session.json"), "utf8")) ); - if (target.root === stable.root || stable.root.startsWith(`${target.root}${sep}`) || !existsSync3(join4(target.dir, "commondir"))) throw new Error("Discard must retain its execution checkout"); - const common = readFileSync4(join4(target.dir, "commondir"), "utf8").trim(); - const stableCommon = existsSync3(join4(stable.dir, "commondir")) ? realpathSync4(join4(stable.dir, readFileSync4(join4(stable.dir, "commondir"), "utf8").trim())) : realpathSync4(stable.dir); - if (realpathSync4(join4(target.dir, common)) !== stableCommon || record.branch !== evidence.branch || record.operator.id !== evidence.operatorId || record.operator.email !== evidence.operatorEmail || (sessionLiveTicket(record) ?? void 0) !== evidence.liveTicket) { + if (target.root === stable.root || stable.root.startsWith(`${target.root}${sep}`) || !existsSync4(join5(target.dir, "commondir"))) throw new Error("Discard must retain its execution checkout"); + const common = readFileSync5(join5(target.dir, "commondir"), "utf8").trim(); + const stableCommon = existsSync4(join5(stable.dir, "commondir")) ? realpathSync5(join5(stable.dir, readFileSync5(join5(stable.dir, "commondir"), "utf8").trim())) : realpathSync5(stable.dir); + if (realpathSync5(join5(target.dir, common)) !== stableCommon || record.branch !== evidence.branch || record.operator.id !== evidence.operatorId || record.operator.email !== evidence.operatorEmail || (sessionLiveTicket(record) ?? void 0) !== evidence.liveTicket) { throw new Error("Discard target does not match the parent's owned session evidence"); } return target; @@ -12234,7 +12320,7 @@ function acquireNativeRecord(input) { if (target) targetLock = acquireRecordTurnAt(target); lock = acquireRecordTurnAt(place, grant.primaryOwner); const lease = randomUUID2(); - writeFileSync2(join5(lock, ownerFile), JSON.stringify({ + writeFileSync2(join6(lock, ownerFile), JSON.stringify({ grant, lease, branch: place.branch, @@ -12256,16 +12342,16 @@ function releaseNativeRecord(input) { const place = grant.sessionless ? checkoutLocation(grant.worktree) : location(grant.worktree); grant.worktree = place.root; releaseRecordTurnAt(place, (lock) => { - const owner = nativeRecordOwnerSchema.parse(JSON.parse(readFileSync5(join5(lock, ownerFile), "utf8"))); + const owner = nativeRecordOwnerSchema.parse(JSON.parse(readFileSync6(join6(lock, ownerFile), "utf8"))); if (owner.lease !== lease || owner.branch !== place.branch || JSON.stringify(owner.grant) !== JSON.stringify(grant) || stoppedTurn.workerId !== grant.workerId || stoppedTurn.requestId !== grant.requestId || stoppedTurn.lease !== lease) { throw new Error("Native record release requires the exact owner's stopped-turn completion evidence"); } - if (owner.targetDir) rmSync2(join5(owner.targetDir, "vibe-record-turn.lock"), { recursive: true, force: true }); + if (owner.targetDir) rmSync2(join6(owner.targetDir, "vibe-record-turn.lock"), { recursive: true, force: true }); }); return { workerId: grant.workerId, requestId: grant.requestId, released: true }; } function verifyStrandedRecordGrant(lock, ledger, retained) { - const owner = nativeRecordOwnerSchema.parse(JSON.parse(readFileSync5(join5(lock, ownerFile), "utf8"))); + const owner = nativeRecordOwnerSchema.parse(JSON.parse(readFileSync6(join6(lock, ownerFile), "utf8"))); const { grant } = owner; const { stoppedTurn } = retained; const ownRequest = ledger.active ? ledger.active.id === grant.requestId : ledger.completed.includes(grant.requestId); @@ -12278,62 +12364,6 @@ function verifyStrandedRecordGrant(lock, ledger, retained) { return { requestId: grant.requestId, recordAction: grant.recordAction, state: stoppedTurn.state }; } -// src/plugin-root.ts -import { existsSync as existsSync4, readFileSync as readFileSync6, realpathSync as realpathSync5 } from "node:fs"; -import { basename as basename2, dirname as dirname3, join as join6, resolve as resolve2 } from "node:path"; -import { fileURLToPath as fileURLToPath2 } from "node:url"; -var AgentRootError = class extends Error { - constructor(code, message) { - super(message); - this.code = code; - } - code; -}; -function installedPluginRoot(metaUrl) { - return realpathSync5(resolve2(dirname3(fileURLToPath2(metaUrl)), "../../../..")); -} -function resolveAgentRoot(supplied, installed, recorded) { - const fallback = recorded && existsSync4(recorded) ? recorded : installed; - if (supplied === void 0) return fallback; - let root; - try { - root = realpathSync5(supplied); - } catch { - throw new AgentRootError("AGENT_ROOT_INVALID", "The supplied agent root does not exist; omit agentRoot to use the installed plugin"); - } - if (basename2(root) === "agents" && isPluginRoot(dirname3(root))) root = dirname3(root); - if (!isPluginRoot(root)) { - throw new AgentRootError("AGENT_ROOT_INVALID", "The supplied agent root is not a vibe plugin root; omit agentRoot to use the installed plugin"); - } - if (root === installed || root === recorded) return root; - const suppliedVersion = pluginVersion(root); - const installedVersion = pluginVersion(installed); - if (suppliedVersion && installedVersion && suppliedVersion !== installedVersion) { - throw new AgentRootError( - "STALE_PLUGIN_ROOT", - `The supplied agent root is vibe ${suppliedVersion}, but this launcher belongs to vibe ${installedVersion}; omit agentRoot` - ); - } - return root; -} -function isPluginRoot(root) { - const manifest = join6(root, ".claude-plugin", "plugin.json"); - if (!existsSync4(manifest) || !existsSync4(join6(root, "agents"))) return false; - try { - return JSON.parse(readFileSync6(manifest, "utf8")).name === "vibe"; - } catch { - return false; - } -} -function pluginVersion(root) { - try { - const version = JSON.parse(readFileSync6(join6(root, ".claude-plugin", "plugin.json"), "utf8")).version; - return typeof version === "string" ? version : void 0; - } catch { - return void 0; - } -} - // src/writer-state.ts async function main(argv) { const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} }); diff --git a/tools/vibe-workers/src/claude-preflight.ts b/tools/vibe-workers/src/claude-preflight.ts index 3d78c4e2..f65de5da 100644 --- a/tools/vibe-workers/src/claude-preflight.ts +++ b/tools/vibe-workers/src/claude-preflight.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; import { homedir } from "node:os"; -import type { Capability } from "./contracts.js"; +import { mcpToolIdentity, type Capability } from "./contracts.js"; /** A launch refused before or at worker start, carrying the one plain step a person can take when there is one. */ export class LaunchPrecheckError extends Error { @@ -57,8 +57,11 @@ export function checkRoleCapabilities(agentName: string, capabilities: readonly /** The granted MCP tools the started worker does not actually have, from its own init event. */ export function missingMcpTools(initTools: readonly string[], granted: readonly string[]): string[] { - const available = new Set(initTools); - return granted.filter((name) => name.startsWith("mcp__") && !available.has(name)); + const available = new Set(initTools.map(mcpToolIdentity).filter((name): name is string => name !== null)); + return granted.filter((name) => { + const identity = mcpToolIdentity(name); + return identity !== null && !available.has(identity); + }); } /** Names the connections behind missing tools, for the parent's diagnosis (never shown to the person). */ diff --git a/tools/vibe-workers/src/claude-worker-preflight.test.ts b/tools/vibe-workers/src/claude-worker-preflight.test.ts index 70be3809..42335df8 100644 --- a/tools/vibe-workers/src/claude-worker-preflight.test.ts +++ b/tools/vibe-workers/src/claude-worker-preflight.test.ts @@ -120,6 +120,14 @@ describe("launcher sign-in and connection prechecks (operator ruling, 2026-10-15 // The brief is already piped at spawn, so the fixture may have recorded it; the process itself must be gone. for (const proof of proofCount(value) ? readProof(value.metadata) : []) expect(() => process.kill(proof.pid, 0)).toThrow(); }, 20000); + it("launches when Claude reports the granted MCP operations with hyphens", () => { + const value = setup(); + const result = launch(value, { agentRoot: value.agentRoot, agentName: "vibe-requirements-worker", + input: "Read scope", capabilities: [...graph, ...liveReads] }, { FAKE_CLAUDE_HYPHENATE_MCP: "1" }); + expect(result.status, JSON.stringify(result.json)).toBe(0); + const proof = readProof(value.metadata)[0]; + expect(proof?.definition["vibe:vibe-requirements-worker"]?.tools).toContain("mcp__live_workspace__get-document"); + }, 20000); it("refuses a requirements or ticket launch without the ClosedLoop reads it needs", () => { const value = setup(); const startup = { kind: "startup" }; diff --git a/tools/vibe-workers/src/claude-worker.test.ts b/tools/vibe-workers/src/claude-worker.test.ts index 871e6a20..78fe45b1 100644 --- a/tools/vibe-workers/src/claude-worker.test.ts +++ b/tools/vibe-workers/src/claude-worker.test.ts @@ -102,6 +102,8 @@ describe("production Claude CLI adapter", () => { expect(proof.every((item) => item.args[item.args.indexOf("--agent") + 1] === "vibe:vibe-change-worker")).toBe(true); expect(proof[0]?.definition["vibe:vibe-change-worker"]?.model).toBe("sonnet"); expect(proof[0]?.definition["vibe:vibe-change-worker"]?.tools).toContain(graph[0]?.name); + expect(proof[0]?.definition["vibe:vibe-change-worker"]?.tools).toEqual(expect.arrayContaining(["SendMessage", "Agent"])); + expect(proof[0]?.args[proof[0].args.indexOf("--disallowedTools") + 1]?.split(",")).not.toContain("Agent"); expect(proof[0]?.definition["vibe:vibe-change-worker"]?.prompt).toContain("Canonical fixture prompt"); expect(proof[0]?.args[proof[0].args.indexOf("--allowedTools") + 1]?.split(",")).toEqual( proof[0]?.definition["vibe:vibe-change-worker"]?.tools); @@ -411,7 +413,8 @@ describe("production Claude CLI adapter", () => { const proof = readProof(value.metadata)[0]; expect(proof?.args[proof.args.indexOf("--permission-mode") + 1]).toBe("default"); expect(proof?.args[proof.args.indexOf("--disallowedTools") + 1]).toMatch(/Write,Edit/); - expect(proof?.definition["vibe:vibe-guardrails-reviewer"]?.tools).toEqual(["Read", "Grep", "Glob", "Bash", "Skill", "ToolSearch", "StructuredOutput", graph[0]?.name]); + expect(proof?.definition["vibe:vibe-guardrails-reviewer"]?.tools).toEqual(["Read", "Grep", "Glob", "Bash", "Skill", "ToolSearch", "StructuredOutput", graph[0]?.name, + "mcp__custom_graph__sync-status"]); expect(run(value, { agentName: "vibe-guardrails-reviewer", input: "inspect", capabilities: [...graph, recordWrite] }).status).toBe(1); expect(readProof(value.metadata)).toHaveLength(1); }); diff --git a/tools/vibe-workers/src/claude-worker.ts b/tools/vibe-workers/src/claude-worker.ts index 3da4371c..36920172 100644 --- a/tools/vibe-workers/src/claude-worker.ts +++ b/tools/vibe-workers/src/claude-worker.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { checkClaudeSignIn, checkRoleCapabilities, isAuthFailure, LaunchPrecheckError, missingMcpTools, missingServers, SIGN_IN_PERSON_ACTION } from "./claude-preflight.js"; import { runFromCanonicalEntry } from "./cli.js"; -import { capabilitiesSchema, identifierSchema, readInput, statusSchema, type WorkerStatus } from "./contracts.js"; +import { capabilitiesSchema, identifierSchema, mcpToolAliases, readInput, statusSchema, type WorkerStatus } from "./contracts.js"; import { readDefinition } from "./definition.js"; import { acquireRecordTurnAt, attachClaudeProcess, changeWriter, markClaudeStarted, readWriterSummary, recordedWriterRoot, registerWriter } from "./ledger.js"; @@ -80,7 +80,7 @@ export function boundDefinition(definition: Definition, input: LaunchInput) { const roleTools = prototypeAdvice ? definition.tools.filter((tool) => tool !== "Write" && tool !== "Edit") : definition.tools; const tools = [...new Set([...roleTools, "ToolSearch", structuredOutputTool, ...input.capabilities .filter((capability) => capability.access === "read" || permittedWrites.includes(capability.operation)) - .map((capability) => capability.name)])]; + .flatMap((capability) => mcpToolAliases(capability.name))])]; const persistent = input.agentName === "vibe-change-worker"; const phasePolicy = persistent ? "Current phase, test-authoring and test-repair authority come only from the orchestrator's top-level turn envelope, separate from raw input text. " @@ -181,7 +181,8 @@ export async function runWorker(rawInput: unknown) { "--agents", definitionFile, "--model", definition.metadata.model, "--tools", bound.tools.join(","), "--disallowedTools", bound.reader ? "Agent,Task,Write,Edit,NotebookEdit,EnterWorktree,ExitWorktree" - : "Agent,Task,EnterWorktree,ExitWorktree,NotebookEdit", + : bound.tools.includes("Agent") ? "Task,EnterWorktree,ExitWorktree,NotebookEdit" + : "Agent,Task,EnterWorktree,ExitWorktree,NotebookEdit", "--permission-mode", "default", "--allowedTools", bound.tools.join(","), resume ? "--resume" : "--session-id", workerId]; diff --git a/tools/vibe-workers/src/contracts.ts b/tools/vibe-workers/src/contracts.ts index f0887430..c79a50b6 100644 --- a/tools/vibe-workers/src/contracts.ts +++ b/tools/vibe-workers/src/contracts.ts @@ -13,7 +13,23 @@ export const liveReadOperations = new Set([ "get_document", "get_document_version", "get_document_content", "get_document_comments", "search", "list_documents", "list_document_versions", "list_users", "get_me", "list_projects", "get_project", "list_artifact_links", "list_templates", "get_template", ]); -const capabilityName = /^mcp__[A-Za-z0-9_.:-]+__[A-Za-z0-9_]+$/; +const capabilityName = /^mcp__[A-Za-z0-9_.:-]+__[A-Za-z0-9_-]+$/; + +/** Runtime-neutral identity for an MCP tool whose operation may use underscores or hyphens. */ +export function mcpToolIdentity(name: string): string | null { + if (!capabilityName.test(name)) return null; + const boundary = name.lastIndexOf("__"); + return `${name.slice(0, boundary)}__${name.slice(boundary + 2).replaceAll("-", "_")}`; +} + +/** Exact spellings accepted by Codex and Claude Code for one canonical MCP operation. */ +export function mcpToolAliases(name: string): string[] { + const identity = mcpToolIdentity(name); + if (!identity) return [name]; + const boundary = identity.lastIndexOf("__"); + const hyphenated = `${identity.slice(0, boundary)}__${identity.slice(boundary + 2).replaceAll("_", "-")}`; + return [...new Set([name, identity, hyphenated])]; +} export const capabilitySchema = z.object({ name: z.string().regex(capabilityName), service: z.enum(["graph", "closedloop"]), @@ -23,13 +39,13 @@ export const capabilitySchema = z.object({ const validOperation = value.service === "graph" ? value.access === "read" && graphOperations.has(value.operation) : value.access === "read" ? liveReadOperations.has(value.operation) : recordOperations.has(value.operation); - if (!validOperation || !value.name.endsWith(`__${value.operation}`)) { + if (!validOperation || mcpToolIdentity(value.name)?.split("__").at(-1) !== value.operation) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Unsupported capability binding" }); } }); export type Capability = z.infer; export const capabilitiesSchema = z.array(capabilitySchema).max(48).refine( - (values) => new Set(values.map((value) => value.name)).size === values.length, + (values) => new Set(values.map((value) => mcpToolIdentity(value.name))).size === values.length, "Duplicate capability binding", ); export const identifierSchema = z.string().min(1).max(160).regex(/^[A-Za-z0-9_.:-]+$/); diff --git a/tools/vibe-workers/src/definition.ts b/tools/vibe-workers/src/definition.ts index 3ef4eb1d..869c441d 100644 --- a/tools/vibe-workers/src/definition.ts +++ b/tools/vibe-workers/src/definition.ts @@ -4,13 +4,15 @@ import { join } from "node:path"; import { parse } from "yaml"; import { z } from "zod"; import { capabilitiesSchema, type Capability } from "./contracts.js"; +import { readPluginManifest } from "./plugin-root.js"; const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/; const metadataSchema = z.object({ name: z.string(), description: z.string().min(1), model: z.string().min(1), tools: z.union([z.string(), z.array(z.string())]), skills: z.union([z.string(), z.array(z.string())]).optional(), }).passthrough(); -const builtins = new Set(["Read", "Write", "Edit", "Grep", "Glob", "Bash", "Skill", "ToolSearch", "WebSearch", "WebFetch"]); +const builtins = new Set(["Read", "Write", "Edit", "Grep", "Glob", "Bash", "Skill", "ToolSearch", "WebSearch", "WebFetch", + "SendMessage", "Agent"]); /** Reads the canonical own-plugin agent once, preserving its prompt, model and declared built-ins. */ export function readDefinition(agentRoot: string, agentName: string, capabilityInput: Capability[]) { @@ -19,7 +21,7 @@ export function readDefinition(agentRoot: string, agentName: string, capabilityI const file = realpathSync(join(root, "agents", `${agentName}.md`)); if (file !== join(root, "agents", `${agentName}.md`)) throw new Error("Agent escapes its owning plugin"); const manifest = z.object({ name: z.literal("vibe"), version: z.string().regex(/^\d+\.\d+\.\d+$/).optional() }).passthrough().parse( - JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")), + readPluginManifest(root), ); const source = readFileSync(file, "utf8"); const match = source.match(frontmatter); diff --git a/tools/vibe-workers/src/plugin-root.ts b/tools/vibe-workers/src/plugin-root.ts index 8c66e5d1..aa9da9c6 100644 --- a/tools/vibe-workers/src/plugin-root.ts +++ b/tools/vibe-workers/src/plugin-root.ts @@ -7,6 +7,19 @@ export class AgentRootError extends Error { constructor(readonly code: "AGENT_ROOT_INVALID" | "STALE_PLUGIN_ROOT", message: string) { super(message); } } +const manifestPaths = [".claude-plugin/plugin.json", ".codex-plugin/plugin.json"] as const; + +/** Reads the plugin manifest from either supported runtime layout. */ +export function readPluginManifest(root: string): Record { + for (const relative of manifestPaths) { + try { return JSON.parse(readFileSync(join(root, relative), "utf8")) as Record; } + catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } + } + throw Object.assign(new Error("No vibe plugin manifest exists"), { code: "ENOENT" }); +} + /** The installed plugin root that owns a bundled entry (`/skills/vibe/scripts/dist/.mjs`). */ export function installedPluginRoot(metaUrl: string): string { return realpathSync(resolve(dirname(fileURLToPath(metaUrl)), "../../../..")); @@ -39,15 +52,14 @@ export function resolveAgentRoot(supplied: string | undefined, installed: string } function isPluginRoot(root: string): boolean { - const manifest = join(root, ".claude-plugin", "plugin.json"); - if (!existsSync(manifest) || !existsSync(join(root, "agents"))) return false; - try { return (JSON.parse(readFileSync(manifest, "utf8")) as { name?: unknown }).name === "vibe"; } + if (!existsSync(join(root, "agents"))) return false; + try { return readPluginManifest(root).name === "vibe"; } catch { return false; } } function pluginVersion(root: string): string | undefined { try { - const version = (JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")) as { version?: unknown }).version; + const version = readPluginManifest(root).version; return typeof version === "string" ? version : undefined; } catch { return undefined; } } diff --git a/tools/vibe-workers/src/runtime-contract.test.ts b/tools/vibe-workers/src/runtime-contract.test.ts new file mode 100644 index 00000000..de714572 --- /dev/null +++ b/tools/vibe-workers/src/runtime-contract.test.ts @@ -0,0 +1,110 @@ +import { readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { capabilitiesSchema, type Capability } from "./contracts.js"; +import { missingMcpTools } from "./claude-preflight.js"; +import { readDefinition } from "./definition.js"; +import { recordModes } from "./record-context.js"; +import { bundleDirectory, fixture } from "./test-fixtures.js"; + +const pluginRoot = resolve(bundleDirectory, "../../../.."); +const graphOperation = "sync_status"; +const requiredReads: Readonly> = { + "vibe-requirements-worker": ["get_document"], + "vibe-ticket-worker": ["get_document"], +}; +const workers = [ + "vibe-adversarial-reviewer", "vibe-backend-worker", "vibe-change-worker", "vibe-environment-worker", + "vibe-guardrails-reviewer", "vibe-handoff-summarizer", "vibe-primitive-worker", "vibe-prototype-worker", + "vibe-requirements-worker", "vibe-setup-worker", "vibe-storybook-decomposer", "vibe-ticket-worker", + "vibe-verify-worker", +] as const; +const runtimes = { + codex: (operation: string) => operation, + claude: (operation: string) => operation.replaceAll("_", "-"), +}; +const builtinFixtures = { + claude: new Set(["Read", "Write", "Edit", "Grep", "Glob", "Bash", "Skill", "WebSearch", "WebFetch", "SendMessage", "Agent"]), + codex: new Set(["exec_command", "apply_patch", "web.run", "send_message", "spawn_agent"]), +}; +const codexBuiltin: Readonly> = { + Read: "exec_command", Write: "apply_patch", Edit: "apply_patch", Grep: "exec_command", Glob: "exec_command", + Bash: "exec_command", Skill: "exec_command", WebSearch: "web.run", WebFetch: "web.run", + SendMessage: "send_message", Agent: "spawn_agent", +}; + +function toolName(server: string, operation: string, runtime: keyof typeof runtimes): string { + return `mcp__${server}__${runtimes[runtime](operation)}`; +} + +function capabilitiesFor(worker: typeof workers[number], runtime: keyof typeof runtimes): Capability[] { + const capabilities: Capability[] = [{ name: toolName("closedloop-graph", graphOperation, runtime), + service: "graph", operation: graphOperation, access: "read" }]; + for (const operation of requiredReads[worker] ?? []) capabilities.push({ + name: toolName("closedloop", operation, runtime), service: "closedloop", operation, access: "read", + }); + const writes = new Set(Object.values(recordModes[worker] ?? {}).flat()); + for (const operation of writes) capabilities.push({ + name: toolName("closedloop", operation, runtime), service: "closedloop", operation, access: "write", + }); + return capabilities; +} + +describe("vibe worker cross-runtime tool contract", () => { + it("enumerates every worker's built-ins and resolves them in both runtime fixtures", () => { + const agentNames = readdirSync(join(pluginRoot, "agents")).filter((name) => name.endsWith(".md")) + .map((name) => name.slice(0, -3)).sort(); + expect(workers).toEqual(agentNames.filter((name) => !name.startsWith("vibe-seed-")).sort()); + for (const agentName of agentNames) { + const definition = readDefinition(pluginRoot, agentName, []); + for (const tool of definition.tools) { + expect(builtinFixtures.claude.has(tool), `${agentName} needs unknown Claude tool ${tool}`).toBe(true); + expect(builtinFixtures.codex.has(codexBuiltin[tool] ?? ""), `${agentName} needs unmapped Codex tool ${tool}`).toBe(true); + } + } + }); + + it("resolves every owned worker capability under Codex and Claude MCP naming", () => { + for (const worker of workers) { + for (const runtime of Object.keys(runtimes) as (keyof typeof runtimes)[]) { + expect(capabilitiesSchema.safeParse(capabilitiesFor(worker, runtime)).success, + `${worker} must accept ${runtime} MCP names`).toBe(true); + } + } + }); + + it("treats hyphenated and underscored operation aliases as the same granted MCP tool", () => { + expect(missingMcpTools(["mcp__closedloop__create-document"], + ["mcp__closedloop__create_document"])).toEqual([]); + expect(capabilitiesSchema.safeParse([ + { name: "mcp__closedloop__get_document", service: "closedloop", operation: "get_document", access: "read" }, + { name: "mcp__closedloop__get-document", service: "closedloop", operation: "get_document", access: "read" }, + ]).success).toBe(false); + }); + + it("loads a canonical definition from either runtime manifest", () => { + const value = fixture(); + try { + renameSync(join(value.agentRoot, ".claude-plugin"), join(value.agentRoot, ".codex-plugin")); + expect(readDefinition(value.agentRoot, "vibe-change-worker", capabilitiesFor("vibe-change-worker", "codex")).name) + .toBe("vibe:vibe-change-worker"); + } finally { value.cleanup(); } + }); + + it("keeps the change worker's Claude collaboration tools in its canonical definition", () => { + const value = fixture(); + try { + writeFileSync(join(value.agentRoot, "agents", "vibe-change-worker.md"), `---\nname: vibe-change-worker\ndescription: Fixture role\nmodel: sonnet\ntools: Read, Write, Edit, Grep, Glob, Bash, Skill, SendMessage, Agent\n---\nCanonical fixture prompt.\n`); + expect(readDefinition(value.agentRoot, "vibe-change-worker", []).tools).toEqual(expect.arrayContaining(["SendMessage", "Agent"])); + } finally { value.cleanup(); } + }); + + it("keeps each runtime's public vibe invocation distinct", () => { + const skillText = readFileSync(join(pluginRoot, "skills", "vibe", "SKILL.md"), "utf8"); + const templateText = readFileSync(join(pluginRoot, "skills", "fleet-orchestrator", "references", "templates.md"), "utf8"); + expect(skillText).toContain("Codex: invoke as `$vibe`"); + expect(skillText).toContain("Claude Code: invoke as `/vibe:vibe`"); + expect(templateText).toContain("A Codex writer's prompt starts with `$vibe"); + expect(templateText).toContain("Invoke vibe:vibe first:"); + }); +}); diff --git a/tools/vibe-workers/src/test-fixtures.ts b/tools/vibe-workers/src/test-fixtures.ts index 22499bf9..967fdb74 100644 --- a/tools/vibe-workers/src/test-fixtures.ts +++ b/tools/vibe-workers/src/test-fixtures.ts @@ -27,7 +27,7 @@ export function fixture() { mkdirSync(join(agentRoot, "agents"), { recursive: true }); mkdirSync(join(agentRoot, ".claude-plugin")); writeFileSync(join(agentRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "vibe" })); - writeAgent(agentRoot, "vibe-change-worker", "Read, Write, Edit, Grep, Glob, Bash, Skill"); + writeAgent(agentRoot, "vibe-change-worker", "Read, Write, Edit, Grep, Glob, Bash, Skill, SendMessage, Agent"); writeAgent(agentRoot, "vibe-guardrails-reviewer", "Read, Grep, Glob, Bash, Skill"); writeAgent(agentRoot, "vibe-prototype-worker", "Read, Write, Grep, Glob, Bash, Skill"); writeAgent(agentRoot, "vibe-requirements-worker", "Read, Grep, Glob, Bash"); @@ -47,7 +47,12 @@ if (args[0] === 'auth' && args[1] === 'status') { const option = key => args[args.indexOf(key) + 1]; const id = option(args.includes('--resume') ? '--resume' : '--session-id'); const dropped = (process.env.FAKE_CLAUDE_DROP_SERVER || '').split(',').filter(Boolean); -const initTools = (option('--tools') || '').split(',').filter(tool => !dropped.some(server => tool.startsWith('mcp__' + server + '__'))); +let initTools = (option('--tools') || '').split(',').filter(tool => !dropped.some(server => tool.startsWith('mcp__' + server + '__'))); +if (process.env.FAKE_CLAUDE_HYPHENATE_MCP === '1') { + initTools = [...new Set(initTools.map(tool => tool.startsWith('mcp__') + ? tool.slice(0, tool.lastIndexOf('__') + 2) + tool.slice(tool.lastIndexOf('__') + 2).replaceAll('_', '-') + : tool))]; +} process.stdout.write(JSON.stringify({ type: 'system', subtype: 'init', session_id: id, tools: initTools }) + '\n'); let input = ''; process.stdin.setEncoding('utf8'); From b4ee1ec2b63a3d0cfd8444d7c0f55bec10bd5dcd Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Fri, 23 Oct 2026 08:31:46 -0500 Subject: [PATCH 4/4] Guard worker evidence and watcher events - Block credential workarounds and force pushes before Bash runs - Surface file events from watchers without SendMessage - Document and test runtime binding and main sync fallbacks Testing: Full Node, worker, and Python suites; Ruff; Pyright; manifest tests; package typecheck and build Risks: Shell matching is deliberately conservative around credential and force-push commands --- CHANGELOG.md | 8 +- plugins/vibe/hooks/hooks.json | 4 + plugins/vibe/hooks/shell-safety-guard.mjs | 40 ++++++++++ .../vibe/hooks/shell-safety-guard.test.mjs | 48 +++++++++++ .../codex-fleet-monitor/scripts/watch.sh | 13 ++- .../scripts/watcher.test.mjs | 23 +++++- .../vibe/skills/fleet-orchestrator/SKILL.md | 2 +- .../references/templates.md | 3 +- plugins/vibe/skills/vibe/SKILL.md | 79 +++++++------------ .../skills/vibe/references/environment.md | 5 ++ .../vibe/scripts/commit-worktree.test.mjs | 21 +++++ .../python/test_vibe_1079_lessons_contract.py | 31 +++++++- .../vibe-workers/src/runtime-contract.test.ts | 10 +++ 13 files changed, 230 insertions(+), 57 deletions(-) create mode 100644 plugins/vibe/hooks/shell-safety-guard.mjs create mode 100644 plugins/vibe/hooks/shell-safety-guard.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5a692d..d2522b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### vibe v1.0.79 -Shared-scope work now proves one owner, every inheriting consumer and page-by-page prototype parity. +Worker launches, shared ownership and operational evidence now have runtime checks instead of relying on prose alone. #### Added - Handoff's `shared-capability-owner` diff rule rejects a capability-level design-system control newly composed by another page adapter when multiple page adapters already compose it directly. A broad acceptance criterion cannot waive the finding; only a ticket or operator ruling that explicitly requires separate compositions can. - Claude's Agent pre-tool guard refuses a fleet orchestrator's direct launch of an internal `vibe:vibe-*-worker` outside a vibe session, preserving the session record, environment setup, guardrails and adversarial review, PR preflight and SendMessage path supplied by `vibe:vibe`. - A cross-runtime worker contract enumerates every worker's built-ins and owned MCP operations against Codex and Claude fixtures, including `$vibe` versus `vibe:vibe` invocation and both plugin manifest layouts. +- A Bash pre-tool guard blocks credential workarounds used to obtain evidence and force pushes to any remote, including commands sent through another machine. Proof and scratch work use fresh refs instead of rewriting one. +- The fleet watcher includes atomic Markdown events in its snapshot, so an agent without `SendMessage` can still wake the orchestrator when green status moves. #### Changed - When a ticket or PRD says "across all", or the person says "shared", the vibe plan names one owning parent component, every consumer that inherits it and each consumer's approved prototype route. The guardrails reviewer checks that inventory against the import graph. - Running-app and visual QA compare each consumer page with that page's approved prototype route and flag controls present in the prototype but missing in production, alongside rendered controls that lack a sourced requirement. - The owned launcher accepts underscore or hyphen MCP operation names, binds both runtime aliases, and compares loaded tools by canonical identity. The Claude change worker keeps `SendMessage` and `Agent` for progress and read-only helpers, while the sole-writer rule still forbids nested writers. -- Instruction text is trimmed so the release adds no words: Markdown under the vibe plugin is about 2 words shorter than in 1.0.78. +- Worker briefs stop at the first authentication failure and use CI logs or an API for the same evidence, or ask. The no-force-push rule covers every remote and requires unique proof refs. +- When launcher tooling cannot mint a supported publisher grant, main sync may stage a plain noncommitting merge of `origin/main`; the root finishes it through the guarded commit script, and publication still waits for a real grant. +- Instruction text is trimmed so the release adds no words: Markdown under the vibe plugin is about 103 words shorter than in 1.0.78. ### vibe v1.0.78 diff --git a/plugins/vibe/hooks/hooks.json b/plugins/vibe/hooks/hooks.json index 5ff755fb..c4e02673 100644 --- a/plugins/vibe/hooks/hooks.json +++ b/plugins/vibe/hooks/hooks.json @@ -15,6 +15,10 @@ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/merge-guard.mjs\"" + }, + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shell-safety-guard.mjs\"" } ] }, diff --git a/plugins/vibe/hooks/shell-safety-guard.mjs b/plugins/vibe/hooks/shell-safety-guard.mjs new file mode 100644 index 00000000..82ead021 --- /dev/null +++ b/plugins/vibe/hooks/shell-safety-guard.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// PreToolUse guard for Bash: workers may not invent credential routes to fetch +// evidence or force-push any remote. Both mistakes can escape a written rule, +// especially through an ssh command aimed at another machine. + +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const TOKEN = /(?:\b(?:gh_token|github_token|[a-z][a-z0-9_]*token)\b|\$\{?[a-z][a-z0-9_]*token\}?|--password(?:-stdin)?\b|(?:^|\s)-p(?:\s|$)|["']?auth["']?\s*:)/i; +const CREDENTIAL_ROUTE = /(?:\bDOCKER_CONFIG\b|\bdocker\s+login\b|["']?auths["']?\s*:)/i; +const FORCE_PUSH = /\bgit\b(?:(?![;&|\n]).)*?\bpush\b(?:(?![;&|\n]).)*(?:\s-f(?:\s|$)|--force(?:-with-lease)?\b|(?:^|\s)\+[^\s]+)/i; + +/** The credential-safety refusal for a shell command, or null. */ +export function credentialProblem(command) { + if (!CREDENTIAL_ROUTE.test(command) || !TOKEN.test(command)) return null; + return "credential workaround refused: after the first authentication failure, use CI logs or the API for the same evidence, or stop and ask; never write, export or route around the operator's credentials"; +} + +/** The force-push refusal for a shell command, or null. */ +export function forcePushProblem(command) { + if (!FORCE_PUSH.test(command)) return null; + return "force push refused: never rewrite any remote, including another machine or a scratch ref; create a fresh proof/- ref and push normally"; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const event = JSON.parse(readFileSync(0, "utf8") || "{}"); + const command = event?.tool_input?.command; + if (event?.tool_name === "Bash" && typeof command === "string") { + const problem = credentialProblem(command) ?? forcePushProblem(command); + if (problem) { + process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { + hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: problem, + } })}\n`); + } + } + } catch (error) { + process.stderr.write(`shell-safety-guard: ${error instanceof Error ? error.message : String(error)}\n`); + } +} diff --git a/plugins/vibe/hooks/shell-safety-guard.test.mjs b/plugins/vibe/hooks/shell-safety-guard.test.mjs new file mode 100644 index 00000000..e5dce0be --- /dev/null +++ b/plugins/vibe/hooks/shell-safety-guard.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { credentialProblem, forcePushProblem } from "./shell-safety-guard.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const GUARD = path.join(HERE, "shell-safety-guard.mjs"); + +test("credential workarounds are refused at the first auth failure", () => { + const refused = [ + "GH_TOKEN=$token DOCKER_CONFIG=$(mktemp -d) docker pull ghcr.io/acme/app:sha", + "printf '%s' \"$GH_TOKEN\" | docker login ghcr.io -u operator --password-stdin", + "echo '{\"auths\":{\"ghcr.io\":{\"auth\":\"'$TOKEN'\"}}}' > /tmp/config.json", + "ssh primary 'DOCKER_CONFIG=/tmp/evidence GITHUB_TOKEN=$GITHUB_TOKEN docker pull ghcr.io/acme/app:sha'", + ]; + for (const command of refused) assert.match(credentialProblem(command) ?? "", /credential/i, command); + for (const command of ["gh run view 123 --log", "gh api repos/acme/app/actions/runs/123", "docker pull alpine:latest", "DOCKER_CONFIG=/tmp/test docker version"]) { + assert.equal(credentialProblem(command), null, command); + } +}); + +test("force push is refused for every remote, including a remote laptop and proof refs", () => { + const refused = [ + "git push -f origin fix/work", + "git push --force-with-lease fork HEAD:fix/work", + "ssh primary 'git -C repo push --force local HEAD:proof/abc-1'", + "git push scratch +HEAD:refs/heads/proof/abc-1", + ]; + for (const command of refused) assert.match(forcePushProblem(command) ?? "", /force.push/i, command); + assert.equal(forcePushProblem("git push primary HEAD:proof/abc-2"), null); + assert.equal(forcePushProblem("git push origin fix/work"), null); +}); + +test("the PreToolUse hook denies unsafe Bash and is registered", () => { + const run = (command) => spawnSync(process.execPath, [GUARD], { + input: JSON.stringify({ tool_name: "Bash", tool_input: { command } }), encoding: "utf8", + }); + for (const command of ["GH_TOKEN=$token DOCKER_CONFIG=/tmp/x docker pull ghcr.io/acme/app:sha", "git push -f primary HEAD:proof/a-1"]) { + const result = run(command); + assert.equal(result.status, 0); + assert.equal(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision, "deny"); + } + assert.equal(run("gh run view 123 --log").stdout, ""); + assert.ok(readFileSync(path.join(HERE, "hooks.json"), "utf8").includes("hooks/shell-safety-guard.mjs")); +}); diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh b/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh index 33e4db3e..b28a0d3d 100755 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh @@ -7,6 +7,15 @@ K=${0:A:h}; I=${2:-120}; H=${HEARTBEAT_SECONDS:-} env_lines=$(node $K/work-dir.mjs sh ${1:?workDir}) || exit 2; eval "$env_lines" H=${H:-${HEARTBEAT_SECONDS:-1800}}; T=$(( H / I )); (( T < 1 )) && T=1 S=${WATCH_SNAPSHOT:-$K/snapshot.sh} +snapshot() { + local recover=${1:-0} event digest current + if (( recover )); then current=$(RECOVER=1 zsh $S $W); else current=$(zsh $S $W); fi + print -r -- "$current" + for event in $W/events/*(.N); do + digest=$(cksum < "$event") + print -r -- "event ${event:t} ${digest%% *}" + done +} # One watcher per work dir. A second copy (one started with `&` in a foreground shell, then started again) # double-reports every event and outlives the turn that started it, so it is refused, not run. lock=$W/watch.lock @@ -29,11 +38,11 @@ beat() { } beat # Compare against the last snapshot the previous run saw, so a change between runs is still reported. -if [ -s $W/last-snapshot.txt ]; then base=$(cat $W/last-snapshot.txt); else base=$(zsh $S $W); echo "$base" > $W/last-snapshot.txt; fi +if [ -s $W/last-snapshot.txt ]; then base=$(cat $W/last-snapshot.txt); else base=$(snapshot); echo "$base" > $W/last-snapshot.txt; fi for _ in $(seq 1 $T); do sleep $I # Each tick runs a recovery pass first (recover.mjs), so a transient failure is resumed without you. - now=$(RECOVER=1 zsh $S $W) + now=$(snapshot 1) beat if [ -n "$now" ] && [ "$now" != "$base" ]; then echo "$now" > $W/last-snapshot.txt; beat ",\"exitedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\""; echo EVENT; diff <(echo "$base") <(echo "$now") | grep '^[<>]'; exit 0 diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.test.mjs b/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.test.mjs index ce4f28b0..083b95e2 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.test.mjs +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -148,3 +148,24 @@ test('an armed watcher with no change exits with HEARTBEAT after HEARTBEAT_SECON assert.match(run.stdout, /^HEARTBEAT no change for 2s: read every teammate message/m); assert.ok(JSON.parse(readFileSync(join(dir, 'watcher.json'), 'utf8')).exitedAt, 'the heartbeat exit is recorded, so watcher.mjs says to re-arm'); }); + +test('a watcher without SendMessage surfaces a new event file', { timeout: 30_000 }, async (t) => { + const dir = workDir(t); + const snapshot = join(dir, 'fake-snapshot.sh'); + writeFileSync(snapshot, 'print "session local a RUN"\n'); + const child = spawn('zsh', [WATCH, dir, '1'], { + env: { ...process.env, WATCH_SNAPSHOT: snapshot }, stdio: ['ignore', 'pipe', 'pipe'], + }); + t.after(() => child.kill('SIGKILL')); + let stdout = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + for (let i = 0; i < 100 && !existsSync(join(dir, 'last-snapshot.txt')); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + mkdirSync(join(dir, 'events'), { recursive: true }); + writeFileSync(join(dir, 'events', 'green-moved.md'), '# Green moved\n'); + const status = await new Promise((resolve) => child.on('exit', resolve)); + assert.equal(status, 0); + assert.match(stdout, /^EVENT$/m); + assert.match(stdout, /green-moved\.md/); +}); diff --git a/plugins/vibe/skills/fleet-orchestrator/SKILL.md b/plugins/vibe/skills/fleet-orchestrator/SKILL.md index 6a8cd6aa..281ea247 100644 --- a/plugins/vibe/skills/fleet-orchestrator/SKILL.md +++ b/plugins/vibe/skills/fleet-orchestrator/SKILL.md @@ -169,7 +169,7 @@ Pushback applies principle 2; more patterns: [references/judgment.md](./referenc ## Program practices -- Green watch: `board.mjs` requires a `green-watch` owner while PRs are watched (`MISSING-DUTY`), a Sonnet watcher: `green-watch.mjs --repo ` reports each `NEW-RED`, with `mock-gap.mjs` checking each mock block, and starts an Opus fixer only for attribution or a fix: a vibe writer (Claude `vibe:vibe`, Codex `$vibe`) briefed by `GREEN_WATCH_AGENT`, stopped after merge. Its head passes lanes, `pr-preflight.mjs` and guardrails; attribute from `gh pr view --json author,headRefName`, never a worker's word. For every changed visual baseline, compare the image diff and record its counted `Baseline inventory:` plus one `Baseline element:` citation per new element; "plausibly intended" never passes. File another author's red only once that PR's ticket, PRD and design rulings show the change was not asked for (a requested change is accepted and cited). After core work, run the full e2e suites on main yourself on the CI-identical runner; an e2e fix auto-merges only after its specs and latest reds pass. +- Green watch: `board.mjs` requires a `green-watch` owner while PRs are watched (`MISSING-DUTY`); a Sonnet watcher runs `green-watch.mjs --repo ` for each `NEW-RED`, checks each mock block with `mock-gap.mjs`, and starts an Opus fixer for attribution or a fix: a vibe writer (Claude `vibe:vibe`, Codex `$vibe`) briefed by `GREEN_WATCH_AGENT`, stopped after merge. A watcher that cannot SendMessage writes an atomic Markdown event under `/events/`; `watch.sh` surfaces it. The fix passes lanes, `pr-preflight.mjs` and guardrails. Attribute from `gh pr view --json author,headRefName`, never a worker's word. For a visual baseline, compare the diff and record its counted `Baseline inventory:` plus one cited `Baseline element:` per new element; "plausibly intended" never passes. File another author's red only once that PR's ticket, PRD and design rulings show the change was not asked for (a requested change is accepted and cited). After core work, run the full e2e suites on main yourself on the CI-identical runner; a fix auto-merges only after its specs and latest reds pass. - Repo knowledge: a repo fact a worker learns goes into the team's repo memory (`REPO_MEMORY_CMD`), and into the nearest `AGENTS.md` through that worker's PR when it binds code. - Deadline mode, only when the operator rules it (record it in `rulings.md` with its end): no pre-merge e2e (`pre-pr-lanes.mjs --skip --skip-ruling `), follow-up pushes run focused tests plus the affected typecheck and leave full lanes to CI, and extras ship in follow-up PRs. - Runtime choice: judgment-heavy work and takeovers go to Claude writers, which are faster; bulk mechanical work goes to Codex on `CODEX_MODEL`, which is cheaper. Move work on usage and speed. diff --git a/plugins/vibe/skills/fleet-orchestrator/references/templates.md b/plugins/vibe/skills/fleet-orchestrator/references/templates.md index 97a0d3a1..9bb6f062 100644 --- a/plugins/vibe/skills/fleet-orchestrator/references/templates.md +++ b/plugins/vibe/skills/fleet-orchestrator/references/templates.md @@ -33,7 +33,8 @@ Tests: after any change, fix or merge (a conflict resolution included), run exac Heavy checks go through ; never run windowed e2e on the operator's daily machine. Commands expected to take more than a few minutes run in the background (never a foreground wait over 5 minutes), and you end the turn while they run, so messages reach you. Check the worker inbox between steps so orchestration rulings are applied before more work. Questions to the orchestrator end with `If no answer by : ` and an id, recorded with `board.mjs question --id --text `; acknowledge each answer by its id (`board.mjs ack --id `) in your next status; keep working meanwhile and past it proceed on the default (never for a flag, copy, destructive, credential or scope question). A question never holds your PR: ship what is done, arm auto-merge, put the answer-dependent part in a follow-up. -Git: commit messages per the repo's conventions; regular push only, never force or amend after push. +Credentials: after the first authentication failure, use CI logs or the API for the same fact, or stop and ask. Never write, export or route around credentials. +Git: commit messages per repo convention; regular push only, never force-push. This covers every remote. Proof and scratch commits use a fresh `proof/-` ref; never amend after push. Review threads: reply on the thread naming the fixing SHA and resolve it only after the fix is verified in code. Artifacts for people (tickets, PR bodies, comments, replies) never name a path on the operator's machine; embed evidence and attach plans. Fleet board: /board.md shows every writer's PR, head, status and dependencies. Message another writer directly when your work touches theirs: Codex writers through the app-server-orchestrator skill's send, Claude workers and QA guides through SendMessage. diff --git a/plugins/vibe/skills/vibe/SKILL.md b/plugins/vibe/skills/vibe/SKILL.md index 799b19b0..87fa25d1 100644 --- a/plugins/vibe/skills/vibe/SKILL.md +++ b/plugins/vibe/skills/vibe/SKILL.md @@ -5,19 +5,12 @@ description: Start or resume a vibe-coding session in symphony-alpha for a non-e # Vibe -You are the orchestrator of a vibe session. You pair with a person who is not -an engineer and does not use git. They describe what they want in chat or by -annotating the app on their Vercel environment; workers you dispatch make the -change in code, and each change deploys on its own so the person can look at it. -Everything produced is reviewed by design and engineering, whether the person -takes it to a pull request themselves or passes it on, so it must already look -like code the team would write. +You orchestrate a session for a person who does not use git. Workers make and +deploy each requested change; design and engineering review every result. ## Your role: orchestrate, never do the work -A session can run for hours. Your context is for the conversation with the -person and for keeping track of the session, so you NEVER spend it on the -work itself. Under all circumstances: +Keep your context for the conversation and session state; NEVER do the work: - You never read source files, search the codebase, explore the repo, edit or create files, read tickets in full, or run builds, tests, linters, @@ -25,57 +18,45 @@ work itself. Under all circumstances: - You commit; no worker ever does, because a commit runs the repository's commit hooks. You commit only through `scripts/commit-worktree.mjs`, which returns short JSON. -- You may: talk to the person; run this skill's own scripts +- You may talk to the person; run this skill's scripts (`scripts/vibe-preflight.sh`, `scripts/vibe-sessions.mjs`, `scripts/commit-worktree.mjs`, `scripts/local-plans.mjs`, `scripts/session-questions.mjs`, `scripts/run-bounded.mjs`, `scripts/dist/writer-state.mjs`, `scripts/dist/claude-worker.mjs`, - `scripts/plugin-version.mjs`, `scripts/precedent-check.mjs`), whose output - is short JSON; run the update commands `plugin-version.mjs` prints, only - when the person chose to update (section 1, "Plugin version"); call - ClosedLoop `get-me` and closedloop-graph `sync_status` to check the - connectors; open, reload, and inspect the in-app browser, including the - live-Asana precedent check (`references/quality-loop.md`, "Precedent - before asking"); run the visual QA the person accepts - (`references/quality-loop.md`, "Visual QA during the session"); and - dispatch workers. + `scripts/plugin-version.mjs`, `scripts/precedent-check.mjs`); run the update + commands `plugin-version.mjs` prints, only when the person chose to update; + call ClosedLoop `get-me` + and closedloop-graph `sync_status`; inspect the in-app browser for live-Asana + precedent or accepted visual QA (`references/quality-loop.md`); and dispatch. - You and every worker leave plugins, plugin marketplaces and plugin caches alone, apart from the update the person chooses at startup (section 1, "Plugin version"), and never clone the plugin's repository to run other tooling (`references/guardrails.md`, "Plugins and tooling"); a needed tooling fix stops the session as that section says. -- Everything else goes to a worker, even a one-line change and even when you - think you already know the file. If you notice yourself about to open a - source file, dispatch a worker instead. -- Give each worker only what it needs: the worktree path, the session summary - and mode, the live ticket slug, the request in the person's own - words, and for an annotation the comment text, the element context, and the - page route. Workers return a short result; do not ask them for file - contents. - -Every word the person sees follows `references/plain-words.md`: its table -of engineering terms and their plain replacements is the one list, for your -own messages and for any worker text you relay. - -Follow `references/quality-loop.md` for the one persistent writer, serial -queue, internal planning/review and communication. The person never sees a technical plan or -technical question, and needs no upfront summary. Updates say only which -feature is complete, which of its acceptance criteria are not done yet, and -what is being worked on next. The only other messages are: - -- the notices in section 5, "Notices that never wait" (a reference product's - behavior list, new Storybook components and the endings line in a new - session's first reply), none of which waits; +- Everything else goes to a worker, including one-line changes. Never open a + source file yourself. +- Give workers only the worktree, session summary and mode, ticket, request, + and an annotation's text, element and route. Ask for short results, not files. +- Every worker brief says: after the first authentication failure, use CI logs + or the API for the same fact, or stop and ask; never write, export or route + around credentials. Never force-push; this covers every remote. Proof and + scratch commits use a fresh `proof/-` ref. + +Every word the person sees follows `references/plain-words.md`, including +worker text you relay. +Follow `references/quality-loop.md` for the persistent writer, queue, review +and communication. Never show technical plans or questions. Updates name the +completed feature, unmet criteria and current work. Other messages are only: + +- section 5's non-waiting notices, including the endings line in a new + session's first reply; - the startup version check (section 1, "Plugin version"); - the visual QA offer and its results (section 6, step 5). -Research product questions in the single order in -`references/quality-loop.md`, "Communication and product questions"; -interrupt only for an absolutely necessary -unresolved product question that neither settles, one at a time with full -context. Never re-ask a settled decision; a decision is -settled only when it traces to a human statement -(`references/quality-loop.md`, "Communication and product questions"). +Research product questions in the single order in `references/quality-loop.md`. +Interrupt only for one necessary unresolved question with full context. Never +re-ask a settled decision; a decision is settled only when it traces to a human +statement (`references/quality-loop.md`, "Communication and product questions"). This skill only works in a `closedloop-ai/symphony-alpha` checkout. diff --git a/plugins/vibe/skills/vibe/references/environment.md b/plugins/vibe/skills/vibe/references/environment.md index 42269e1f..b43d014b 100644 --- a/plugins/vibe/skills/vibe/references/environment.md +++ b/plugins/vibe/skills/vibe/references/environment.md @@ -299,6 +299,11 @@ not silently included or discarded. A clean result needs no extra local commit. (`mainMerge: "deferred"`), conflicts included, and the branch's own base stands in as its main; a failing check is fixed on that base. It never commits or pushes. Retain the returned `transactionId`, `mainSha`, original base and imported-file facts. + If the launcher cannot mint a supported publisher grant, ROOT does not + invent one: fetch `origin main`, stage `git merge --no-commit --no-ff + origin/main`, keep conflicts with the same writer, then finish through + `commit-worktree.mjs`. This fallback prepares only the merge; publication + still waits for a real grant. 2. End that helper turn using actual stopped/completed evidence before any source continuation. The SAME registered writer extends its existing session table/local canonical plan before source fixes and checks frozen diff --git a/plugins/vibe/skills/vibe/scripts/commit-worktree.test.mjs b/plugins/vibe/skills/vibe/scripts/commit-worktree.test.mjs index 355c4fb8..572da3f5 100644 --- a/plugins/vibe/skills/vibe/scripts/commit-worktree.test.mjs +++ b/plugins/vibe/skills/vibe/scripts/commit-worktree.test.mjs @@ -186,3 +186,24 @@ test("concludes a merge a worker staged without committing", (t) => { assert.equal(result.json.merge, true); assert.equal(git(checkout, ["rev-list", "--parents", "-n", "1", "HEAD"], home).split(" ").length, 3); }); + +test("concludes an origin/main merge staged by the no-grant fallback", (t) => { + const { home, checkout } = setup(t); + git(checkout, ["checkout", "-q", "-b", "fix/fallback"], home); + write(checkout, "apps/api/fallback.ts", "fallback\n"); + git(checkout, ["add", "-A"], home); + git(checkout, ["commit", "-q", "-m", "Fallback change"], home); + git(checkout, ["checkout", "-q", "main"], home); + write(checkout, "README.md", "origin moved\n"); + git(checkout, ["commit", "-q", "-am", "Origin change"], home); + git(checkout, ["branch", "-f", "origin/main", "main"], home); + git(checkout, ["checkout", "-q", "fix/fallback"], home); + git(checkout, ["merge", "--no-commit", "--no-ff", "origin/main"], home); + + const result = runNode(SCRIPT, ["--worktree", checkout, "--subject", "Merge origin/main into fix/fallback"], home); + + assert.equal(result.status, 0, JSON.stringify(result.json)); + assert.equal(result.json.committed, true); + assert.equal(result.json.merge, true); + assert.equal(git(checkout, ["rev-list", "--parents", "-n", "1", "HEAD"], home).split(" ").length, 3); +}); diff --git a/plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py b/plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py index a0a9749a..346c364f 100644 --- a/plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py +++ b/plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py @@ -1,4 +1,4 @@ -"""Contract coverage for the vibe 1.0.79 shared-owner lesson.""" +"""Contract coverage for the vibe 1.0.79 prevention lessons.""" from pathlib import Path @@ -36,3 +36,32 @@ def test_handoff_compares_each_consumer_with_its_prototype_route() -> None: for text in (verifier, handoff, quality): assert required in text assert "present in the prototype but missing in production" in text + + +def test_worker_rules_forbid_credential_workarounds_and_force_push_everywhere() -> None: + hook_manifest = (PLUGIN / "hooks" / "hooks.json").read_text() + worker_preamble = flat( + (PLUGIN / "skills" / "fleet-orchestrator" / "references" / "templates.md").read_text() + ) + vibe = flat((VIBE / "SKILL.md").read_text()) + assert "shell-safety-guard.mjs" in hook_manifest + for text in (worker_preamble, vibe): + assert "first authentication failure" in text + assert "CI logs or the API" in text + assert "every remote" in text + assert "proof/-" in text + + +def test_message_less_watchers_use_the_watched_event_mailbox() -> None: + fleet = flat((PLUGIN / "skills" / "fleet-orchestrator" / "SKILL.md").read_text()) + watch = (PLUGIN / "skills" / "codex-fleet-monitor" / "scripts" / "watch.sh").read_text() + assert "cannot SendMessage" in fleet + assert "/events/" in fleet + assert "events" in watch and "event" in watch + + +def test_main_sync_has_a_bounded_no_grant_fallback() -> None: + environment = flat((VIBE / "references" / "environment.md").read_text()) + assert "cannot mint a supported publisher grant" in environment + assert "git merge --no-commit --no-ff origin/main" in environment + assert "commit-worktree.mjs" in environment diff --git a/tools/vibe-workers/src/runtime-contract.test.ts b/tools/vibe-workers/src/runtime-contract.test.ts index de714572..fa1b91c9 100644 --- a/tools/vibe-workers/src/runtime-contract.test.ts +++ b/tools/vibe-workers/src/runtime-contract.test.ts @@ -82,6 +82,16 @@ describe("vibe worker cross-runtime tool contract", () => { ]).success).toBe(false); }); + it("binds the ticket worker's exact Claude document tools", () => { + const capabilities = capabilitiesFor("vibe-ticket-worker", "claude"); + expect(capabilities).toEqual(expect.arrayContaining([ + { name: "mcp__closedloop__create-document", service: "closedloop", operation: "create_document", access: "write" }, + { name: "mcp__closedloop__update-document", service: "closedloop", operation: "update_document", access: "write" }, + { name: "mcp__closedloop__create-artifact-link", service: "closedloop", operation: "create_artifact_link", access: "write" }, + ])); + expect(missingMcpTools(capabilities.map(({ name }) => name), ["mcp__closedloop__create_document"])).toEqual([]); + }); + it("loads a canonical definition from either runtime manifest", () => { const value = fixture(); try {