diff --git a/CHANGELOG.md b/CHANGELOG.md index 8accc2a8..096d3f6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ 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.48 + +#### Added +- The handoff guardrails reviewer checks each new poller, interval, background lane, socket, cross-process channel, route, table, cursor, or write into another tool's files: the existing mechanism that could do the job and why it was not used, what its cost scales with at 10x and 100x, and its per-device idle cost. Idle-scaled cost when an activity-scaled design meets the outcome, or a write into another tool's data with no cited human decision, is blocking; a ticket's Decisions section counts as that decision only when it traces to a human statement, and an untraced one is an `engineering decision` finding. It checks the session plan's Cost Scaling table against the diff. +- An `Engineering decision: ; built: <...>; evidence: <...>` item on the live ticket's Engineering checklist, with placement rules in the ticket template and the ticket worker. +- A closedloop-graph route for channels that already connect two surfaces, with an existing-channels `code_grep` in the change and backend workers' required calls when the work is periodic or cross-surface. +- `test_vibe_mechanism_provenance_contract.py` pins these rules. +- `tools/python/vibe_contract_text.py` holds the `flat`, `section`, `paragraph`, and `agent` helpers every vibe contract test now imports instead of keeping its own copy. `section` stops at the next heading of the same or a higher level and matches its heading at the start of a line, preferring an exact line; `paragraph` also splits list items; `agent` returns the raw text. + +#### Changed +- The change worker treats a mechanism a ticket or PRD names as a proposal: when it is dominated, it builds the better option that meets the outcome and logs the swap as an `Engineering decision`, never as a question for the person. Plans that add periodic or cross-surface work carry a Cost Scaling table per cl-execute's `planning-and-gates.md`. +- The requirements worker tags ticket text that names a mechanism `mechanism (proposal)` instead of an acceptance criterion. +- A handoff finding that a mechanism, a one-way door, or an unratified or conflicting decision needs a human decision is never resolved, downgraded, or absorbed by the writer; it goes on the Engineering checklist. +- `quality-loop.md` defines a settled decision as one that traces to a human statement (who, where, which question). An unratified or conflicting one goes through the product necessity gate or becomes an `Engineering decision`, and conflicts are never resolved by recency. Every "never re-ask a settled decision" points to this definition. +- Frozen rows describe user-visible behavior, never mechanism. + ### vibe v1.0.47 #### Added diff --git a/plugins/vibe/.claude-plugin/plugin.json b/plugins/vibe/.claude-plugin/plugin.json index 66fdbe25..c1660f7d 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.47", + "version": "1.0.48", "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 fecc8dcd..28a34512 100644 --- a/plugins/vibe/.codex-plugin/plugin.json +++ b/plugins/vibe/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibe", - "version": "1.0.47", + "version": "1.0.48", "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 f50711ee..c0f32e93 100644 --- a/plugins/vibe/README.md +++ b/plugins/vibe/README.md @@ -318,7 +318,8 @@ claude plugin eval . --eval-dir vibe/evals --tag visual-cues --scaffold --allow- Version `1.0.14` contains 10 skills and 16 agents. It has no standalone commands, hooks, root-level shell scripts, or production Python tools under -`tools/python/`; that directory contains two skill-contract test modules. +`tools/python/`; that directory contains the skill-contract test modules and +`vibe_contract_text.py`, the text helpers they share. Runtime helpers and tests live alongside their owning skills, including vibe session and prototype records, local-plan publication guards, bundled persistent writer state, Claude launch/resume and shared main-sync publication helpers, diff --git a/plugins/vibe/agents/vibe-change-worker.md b/plugins/vibe/agents/vibe-change-worker.md index 814e69b1..58e16d8c 100644 --- a/plugins/vibe/agents/vibe-change-worker.md +++ b/plugins/vibe/agents/vibe-change-worker.md @@ -78,9 +78,13 @@ Load core `plan-structure` by name (`$plan-structure` in Codex or write the exact-template local plan under `.closedloop-ai/vibe-plans/`. Never upload it, paste it into the ticket, commit it or deploy it. Name scope, owners, dependencies and checks, including every NEW Storybook component the -request needs (or none). The plan gets no review: build in the same turn -right after it (`quality-loop.md`, "Lean build loop"). No technical plan goes -to the person running the session for approval. +request needs (or none). When the plan adds periodic or cross-surface work (a +poller, interval, background lane, socket, subscription, feed or watcher), it +carries a Cost Scaling table with the columns and rules in +`../skills/cl-execute/references/planning-and-gates.md`, "Cost Scaling". +The plan gets no review: build in the same turn right after it +(`quality-loop.md`, "Lean build loop"). No technical plan goes to the person +running the session for approval. Always load named core decision-table (`$decision-table` in Codex or `/closedloop-core:decision-table` in Claude Code) and create or extend the @@ -102,10 +106,25 @@ is never an engineering escalation or a question for the person. Any other unverifiable binding remains BLOCKED internally. Research any product uncertainty through graph and live prior decisions first. -Apply a settled ruling, never ask it again. Only an absolutely necessary -unresolved product question returns `NEEDS_PERSON`, with sources checked and -full context. Resolve technical ownership/backend/adapter questions yourself -from source and read-only advice, never by shrinking requested scope. +Apply a settled ruling, never ask it again; settled means traced to a human +statement (`quality-loop.md`, "Communication and product questions"). Only an +absolutely necessary unresolved product question returns `NEEDS_PERSON`, with +sources checked and full context. Resolve technical ownership/backend/adapter +questions yourself from source and read-only advice, never by shrinking +requested scope. + +Only the outcome a ticket or PRD asks for binds the session. A mechanism it +names (a poller, feed, lane, socket, route, table, cursor, or a write into +another tool's data) is a proposal held to the same test as one you invent. +If it is dominated (its cost scales with installs or idle time while an +alternative scales with activity, or a supported interface exists for a write +into another tool's data), build the better option that meets the outcome and +log the swap in the change log as `Engineering decision: ; built: +; evidence: ` for the live +ticket's Engineering checklist (`ticket-template.md`, "Engineering +decisions"). It is never `NEEDS_PERSON`. Raising and recording it is neither +instruction inflation nor manufactured ambiguity: those rules protect settled +rulings, and an unexamined mechanism is not one. When part of a request needs a change in a path you may never edit (`../skills/vibe/references/guardrails.md`, "What may change": the Never diff --git a/plugins/vibe/agents/vibe-guardrails-reviewer.md b/plugins/vibe/agents/vibe-guardrails-reviewer.md index 847eded0..c77b0065 100644 --- a/plugins/vibe/agents/vibe-guardrails-reviewer.md +++ b/plugins/vibe/agents/vibe-guardrails-reviewer.md @@ -1,6 +1,6 @@ --- name: vibe-guardrails-reviewer -description: Reviews a vibe session's symphony-alpha diff (its redeploy commits and uncommitted work) against the vibe guardrails that need judgment rather than a path check (component reuse, design tokens, code placement, user-visible copy provenance, accessibility, fake data, repo conventions, shared-owner placement and its red flags, Storybook taxonomy placement and precedent placement, test files the session wrote or edited and whether each test repair left every assertion unchanged, checks made to pass by changing them). Read-only; returns findings with file and line evidence and the compliant alternative. Runs only at handoff, never during the build loop. +description: Reviews a vibe session's symphony-alpha diff (its redeploy commits and uncommitted work) against the vibe guardrails that need judgment rather than a path check (component reuse, design tokens, code placement, user-visible copy provenance, accessibility, fake data, repo conventions, shared-owner placement and its red flags, Storybook taxonomy placement and precedent placement, test files the session wrote or edited and whether each test repair left every assertion unchanged, checks made to pass by changing them, and each new mechanism's reuse and cost scaling). Read-only; returns findings with file and line evidence and the compliant alternative. Runs only at handoff, never during the build loop. model: sonnet tools: Read, Grep, Glob, Bash, Skill --- @@ -135,6 +135,25 @@ closedloop-graph is required, per `../skills/vibe/references/closedloop-graph.md This is not a mechanical check: stories for one shared capability can live both beside the shared component and in page sections, so only the reported precedents tell whether a page-scoped story mirrors them. +12. Mechanisms: for each new poller, interval, background lane, socket, + cross-process channel, route, table or cursor in the diff, and each write + into files another tool owns, name the existing mechanism that does or + could do the job (`closedloop-graph.md`, the row for channels that + already connect two surfaces) and why it was not used. State what its + steady-state cost scales with (installs or idle clients, connected + clients, active sessions, events) at 10x and 100x, and its per-device idle + cost (timer wakeups, network and disk while nothing is active, on + battery). Never argue from current fleet size. Cost that scales with idle + clients when an activity-scaled design meets the outcome is blocking, and + so is a write into another tool's data with no cited human decision. A + decision recorded in a ticket or plan counts only when it traces to a + human statement (who, where, which question; `quality-loop.md`, + "Communication and product questions"): a Decisions section alone is a + record, not a ruling, and an untraced one is an `engineering decision` + finding. A mechanism the ticket named is held to the same test + (`vibe-change-worker.md`, "Plan before code"). When the session's local + plan has a Cost Scaling table, check it against the diff; a row that does + not match what was built is a finding. For publication, verify the SAME source actor's `main-sync-inputs` evidence before capture and after the committed merge. Canonical private diagnostics @@ -148,6 +167,10 @@ and the Root validation witness confer no test-authoring or E2E authority. Return a list. Each item: `severity` (blocking or advisory), `file:line`, the problem in one sentence, the evidence (quote the line), and the compliant fix. Blocking means engineering would have to rewrite it or a repo gate will -fail. Return "No findings" when there are none. Do not pad the list. End +fail. Mark a finding that needs a human decision (a mechanism choice, a +one-way door, or an unratified or conflicting decision) `engineering +decision`; handoff records it for engineering +(`../skills/handoff/SKILL.md`, section 6), never for the person. Return "No +findings" when there are none. Do not pad the list. End with the Graph block. Your findings go to the orchestrator, which routes the fixes to a worker; the person is not involved. diff --git a/plugins/vibe/agents/vibe-requirements-worker.md b/plugins/vibe/agents/vibe-requirements-worker.md index ae90ab9d..f06d41f0 100644 --- a/plugins/vibe/agents/vibe-requirements-worker.md +++ b/plugins/vibe/agents/vibe-requirements-worker.md @@ -26,7 +26,8 @@ do not fabricate a table or claim it existed before code that already landed. Read `../skills/vibe/references/quality-loop.md`. Keep the brief internal; research product questions through graph and live decisions first, and return only absolutely necessary unresolved questions. Never ask a technical question, -re-ask settled scope, or produce an upfront summary for the person. +re-ask settled scope (`quality-loop.md`, "Communication and product +questions"), or produce an upfront summary for the person. ## Do @@ -36,7 +37,13 @@ re-ask settled scope, or produce an upfront summary for the person. of the ticket AND of its PRD, each quoted exactly with its source slug, any design or prototype links, and any decisions in comments. These seed the session's acceptance criteria list (`quality-loop.md`, "Acceptance - criteria before ready"). + criteria before ready"). Tag ticket text that names an implementation + mechanism (a poller, feed, lane, socket, route, table, cursor, or a write + into another tool's data) `mechanism (proposal)`, never an acceptance + criterion, so the session checks the outcome and not the named mechanism. A + recorded decision is settled only when it traces to a human statement + (`quality-loop.md`, "Communication and product questions"); report each + with its source, and an unratified or conflicting one as not settled. 2. Description: `query_collisions` and `search_nodes` with the description, and `fts_search` on its key terms, to find an existing ticket or PRD that already covers it. @@ -92,8 +99,9 @@ as our copy. End with the Graph block. `DONE` with: a two or three sentence summary in plain words, which the orchestrator keeps internal and which never names a screen to start on; the requirements as a short list quoted from their source (never -invented); every acceptance criterion of the ticket and its PRD, quoted -exactly with its source slug, or "none stated"; the route and FEATURE_MAP id where the relevant code lives, for +invented), each named mechanism tagged `mechanism (proposal)`; every +acceptance criterion of the ticket and its PRD, quoted exactly with its source +slug, or "none stated"; the route and FEATURE_MAP id where the relevant code lives, for change workers (not shown to the person); overlapping or in-flight tickets (slug and title); and whether this is a change to an existing screen or a net-new screen. Or diff --git a/plugins/vibe/agents/vibe-ticket-worker.md b/plugins/vibe/agents/vibe-ticket-worker.md index 61c38b20..e785f820 100644 --- a/plugins/vibe/agents/vibe-ticket-worker.md +++ b/plugins/vibe/agents/vibe-ticket-worker.md @@ -217,6 +217,9 @@ historical inspections accurately. Preserve the existing grading block. entry in the change log once, as an unchecked item at the end of the Engineering checklist in its logged shape (`../skills/vibe/references/ticket-template.md`, "Engineering needed"). + Place each `Engineering decision` entry in the change log once, as an + unchecked item at the end of the Engineering checklist in its logged shape + (`../skills/vibe/references/ticket-template.md`, "Engineering decisions"). Place each file in the inventory's `compileOnlyV1Repairs` once, as an unchecked item at the end of the Engineering checklist in the template's shape (`../skills/vibe/references/ticket-template.md`, "Compile-only v1.0 diff --git a/plugins/vibe/skills/handoff/SKILL.md b/plugins/vibe/skills/handoff/SKILL.md index df3d4be0..adbe4538 100644 --- a/plugins/vibe/skills/handoff/SKILL.md +++ b/plugins/vibe/skills/handoff/SKILL.md @@ -320,7 +320,8 @@ helper edits source. A `forbidden` or outside-allowed path the request genuinely needed is removed and logged as `Engineering needed (forbidden path)` (`../vibe/references/guardrails.md`, "What may change"), and the ticket worker puts it on the Engineering -checklist. A removal never silently undoes a visible change the person +checklist. A finding marked `engineering decision` follows step 6's rule +for decisions. A removal never silently undoes a visible change the person already saw (operator ruling, 2026-10-15: an approved bold heading needed a font generator outside the allowed paths and handoff reverted it without a word). When it does, tell the person in one plain line which part will @@ -426,6 +427,15 @@ which verifies and corrects them; recheck its current result and repeat validation. A coverage gap goes on the missing tests list; nobody writes a test here. +A finding from any handoff reviewer, step 3's guardrails check included, +that a mechanism, a one-way door, or an unratified or conflicting decision +needs a human decision is never the writer's to resolve, downgrade or +absorb, including after verification. The SAME writer logs it in the change +log as `Engineering decision: ; built: ; +evidence: `, and the ticket worker puts it on the +live ticket's Engineering checklist (`../vibe/references/ticket-template.md`, +"Engineering decisions"), never to the person. + ### Browser E2E and the repository's QA passes These run here and never in the build loop (operator ruling, diff --git a/plugins/vibe/skills/vibe/SKILL.md b/plugins/vibe/skills/vibe/SKILL.md index 7627881b..5d533fd3 100644 --- a/plugins/vibe/skills/vibe/SKILL.md +++ b/plugins/vibe/skills/vibe/SKILL.md @@ -59,7 +59,9 @@ new Storybook components and the endings line in a new session's first reply), are the only other messages, and none of them waits. Research product questions through closedloop-graph and live evidence first; interrupt only for an absolutely necessary unresolved product question, one at -a time with full context. Never re-ask a settled decision. +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"). This skill only works in a `closedloop-ai/symphony-alpha` checkout. diff --git a/plugins/vibe/skills/vibe/references/closedloop-graph.md b/plugins/vibe/skills/vibe/references/closedloop-graph.md index e64cf064..e690b16a 100644 --- a/plugins/vibe/skills/vibe/references/closedloop-graph.md +++ b/plugins/vibe/skills/vibe/references/closedloop-graph.md @@ -25,6 +25,7 @@ Its tools appear as `closedloop-graph` server tools (in Claude Code, | Which open tickets touch this file (someone else's work in flight)? | `blast_radius_tickets` | | Show me the code of one symbol | `code_snippet` | | Where does a non-symbol string live (a route, flag, label, event name)? | `code_grep` | +| Which channels already connect two surfaces (sockets, signals, command dispatchers, interval lanes)? | `code_grep` with the repo's channel primitives (socket emit and subscribe calls, signal emitters, command dispatchers, `setInterval`-based lanes) joined in one regex alternation; `rg` when the server is unreachable | | Was this tried before, or reverted? | `blast_radius_tickets` on the files and `fts_search` on the behavior, then `git log -S` | ## Required calls @@ -35,8 +36,8 @@ one element), at least: | Worker | Calls | |---|---| | `vibe-requirements-worker` | `code_symbols` on the screen's main component, `blast_radius_tickets` on its route and main component files, and for a description `query_collisions` and `search_nodes` | -| `vibe-change-worker` (same persistent writer, prep/request/fix/handoff) | `code_symbols` on each component or hook it will edit or extend, then `code_callers` and `code_importers` on each (this is how it finds the shared parent and its other children), and `blast_radius_tickets` on each file it edits | -| `vibe-backend-worker` (read-only advice) | `code_symbols` on the closest route, service, and model, `code_callers` and `blast_radius_tickets` on the planned files | +| `vibe-change-worker` (same persistent writer, prep/request/fix/handoff) | `code_symbols` on each component or hook it will edit or extend, then `code_callers` and `code_importers` on each (this is how it finds the shared parent and its other children), and `blast_radius_tickets` on each file it edits; when the plan adds periodic or cross-surface work, the existing-channels `code_grep` | +| `vibe-backend-worker` (read-only advice) | `code_symbols` on the closest route, service, and model, `code_callers` and `blast_radius_tickets` on the planned files; when the planned work is periodic or cross-surface, the existing-channels `code_grep` | | `vibe-guardrails-reviewer` | `code_symbols` and `search_nodes` for an existing component a hand-rolled one duplicates, and `code_callers` and `code_importers` on each changed shared component | | `vibe-adversarial-reviewer` | `code_callers` and `code_importers` on each changed component or hook, `code_tests_for` on changed files, and `blast_radius_tickets` on them | | `vibe-storybook-decomposer` | `code_symbols` for existing stories, similar components and the capability's precedent on sibling entities, `code_importers` on the precedent's providing component, and `code_callers` before extracting anything | @@ -82,7 +83,8 @@ connection before the next change. Every product question first follows `quality-loop.md`'s graph and live-decision research gate. Check whether it was already answered, verify the material ruling with current evidence, and ask only an absolutely necessary unresolved question. -Never send a technical question or re-ask a settled decision. +Never send a technical question or re-ask a settled decision +(`quality-loop.md`, "Communication and product questions"). - Start with `code_symbols` for any named component, then `code_callers` and `code_importers` before changing anything shared: a change to a component diff --git a/plugins/vibe/skills/vibe/references/quality-loop.md b/plugins/vibe/skills/vibe/references/quality-loop.md index 684c7c46..3ccd7f83 100644 --- a/plugins/vibe/skills/vibe/references/quality-loop.md +++ b/plugins/vibe/skills/vibe/references/quality-loop.md @@ -23,6 +23,15 @@ when relevant. An empty graph result is not proof that nobody answered it. Apply settled decisions; never ask them again. Return the sources checked and why an unresolved answer is absolutely necessary. +A ticket's or plan's Decisions section is a record, not a ruling. A decision +is settled only when it traces to a human statement: who made it, where, and +which question it answered. An unratified or conflicting decision is not +settled: a product one goes through this section's research and necessity +gate, and a technical one becomes an `Engineering decision` item +(`ticket-template.md`, "Engineering decisions"). Never resolve a conflict by +recency or by assuming one author meant to change an earlier record. Every +"settled" in this skill means this. + The orchestrator sends an unresearched question back. Interrupt the person only for an absolutely necessary unresolved product question, one at a time with full context. Technical choices are resolved internally. Preserve existing @@ -295,6 +304,11 @@ fix source gaps now, append findings/fixes and reverify. At handoff, independent reviewers read the same table and source evidence. A reviewer proposal is not a product ruling: verify it, record its disposition, and never expand scope or remove a live capability because a hypothetical fix suggests it. +A finding that a mechanism, a one-way door, or an unratified or conflicting +decision needs a human decision is never the writer's to resolve, downgrade +or absorb: it becomes an `Engineering decision` item on the live ticket's +Engineering checklist (`ticket-template.md`, "Engineering decisions"), never +a question for the person. Keep implementation evidence, executed existing-test evidence and missing tests separate: planned tests are not coverage. `Required Tests` maps stable @@ -335,7 +349,9 @@ listing the stable row ID of every row the person accepted, each with its request provenance. A row is accepted when it describes behavior the person saw delivered in this session and is handing off, or behavior a handoff behavior answer settled (`built` or `already met`). Superseded rows and rows -behind a remaining Open Question are not frozen. The writer rewrites this one +behind a remaining Open Question are not frozen. Frozen rows describe +user-visible behavior, never mechanism: no intervals, transports or storage +choices. The writer rewrites this one section whenever a later handoff answer changes the table, so it always names the final accepted set; every other section keeps the rules above. diff --git a/plugins/vibe/skills/vibe/references/ticket-template.md b/plugins/vibe/skills/vibe/references/ticket-template.md index f2fef4c5..2813f3c7 100644 --- a/plugins/vibe/skills/vibe/references/ticket-template.md +++ b/plugins/vibe/skills/vibe/references/ticket-template.md @@ -58,6 +58,13 @@ publication, and the same ticket worker verifies completeness and assignment. `- [ ] Engineering needed (forbidden path): ; why: `; the same path or area logged again is the same item. Never in the Handoff section, and never routed to the person running the session. +- Engineering decisions: each `Engineering decision` entry the change log + records (a named mechanism the session swapped, or a handoff finding that a + mechanism, a one-way door, or an unratified or conflicting decision needs a + human decision) goes once at the end of the Engineering checklist as + `- [ ] Engineering decision: ; built: ; evidence: `. + Never in the Handoff section, and never routed to the person running the + session. - Compile-only v1.0 repairs: each file in the handoff inventory's `compileOnlyV1Repairs` goes once at the end of the Engineering checklist as `- [ ] Check compile-only repair to v1.0: ; confirm only prop, import or type changes and no visual change` @@ -75,7 +82,7 @@ publication, and the same ticket worker verifies completeness and assignment. | Handoff | `vibe-ticket-worker` | handoff | | Design Review | `vibe-ticket-worker` | handoff, from verified final code/publication and actual design evidence | | Grading | `vibe-ticket-worker` | handoff, copied unchanged from this template | -| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs and compile-only v1.0 repairs | +| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs, compile-only v1.0 repairs and engineering decisions | ## Body diff --git a/plugins/vibe/tools/python/test_handoff_skill_contract.py b/plugins/vibe/tools/python/test_handoff_skill_contract.py index 522c2b06..ba397db8 100644 --- a/plugins/vibe/tools/python/test_handoff_skill_contract.py +++ b/plugins/vibe/tools/python/test_handoff_skill_contract.py @@ -11,6 +11,8 @@ import re from pathlib import Path +from vibe_contract_text import section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] HANDOFF_SKILL = PLUGIN_ROOT / "skills" / "handoff" / "SKILL.md" @@ -29,14 +31,6 @@ def test_final_publication_keeps_input_readiness_distinct_from_coverage() -> Non assert required in upload, required -def section(text: str, heading: str) -> str: - """The section under `heading`, with line wrapping collapsed to single spaces.""" - start = text.index(heading) - rest = text[start + len(heading) :] - end = rest.find("\n## ") - return re.sub(r"\s+", " ", rest if end == -1 else rest[:end]) - - def test_behavior_answers_go_to_the_change_worker_before_the_ticket() -> None: answers = section(HANDOFF_SKILL.read_text(), "## Answers from the person") diff --git a/plugins/vibe/tools/python/test_vibe_acceptance_criteria_contract.py b/plugins/vibe/tools/python/test_vibe_acceptance_criteria_contract.py index 879fb345..c78fe5f8 100644 --- a/plugins/vibe/tools/python/test_vibe_acceptance_criteria_contract.py +++ b/plugins/vibe/tools/python/test_vibe_acceptance_criteria_contract.py @@ -18,9 +18,10 @@ from __future__ import annotations -import re from pathlib import Path +from vibe_contract_text import agent, flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -33,27 +34,10 @@ POINTER = '"Acceptance criteria before ready"' -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - def rule() -> str: return section(QUALITY.read_text(), HEADING) -def agent(name: str) -> str: - return flat((AGENTS / f"{name}.md").read_text()) - - def test_the_rule_is_dated_and_canonical() -> None: text = rule() assert "operator ruling, 2026-10-14" in text @@ -161,14 +145,14 @@ def test_the_vibe_skill_forwards_criteria_and_refuses_an_unchecked_done() -> Non def test_workers_point_at_the_rule() -> None: - writer = agent("vibe-change-worker") + writer = flat(agent("vibe-change-worker")) assert f'`quality-loop.md`, {POINTER}' in writer assert "always carries the Acceptance criteria block" in writer assert "At handoff, start the whole-table alignment from that list." in writer - requirements = agent("vibe-requirements-worker") + requirements = flat(agent("vibe-requirements-worker")) assert "acceptance criteria of the ticket AND of its PRD, each quoted exactly with its source slug" in requirements assert f'(`quality-loop.md`, {POINTER})' in requirements - assert "and its acceptance criterion checks" in agent("vibe-verify-worker") + assert "and its acceptance criterion checks" in flat(agent("vibe-verify-worker")) assert POINTER in flat(GUARDRAILS.read_text()) diff --git a/plugins/vibe/tools/python/test_vibe_codex_reading_contract.py b/plugins/vibe/tools/python/test_vibe_codex_reading_contract.py index 94eefc00..8acdfc39 100644 --- a/plugins/vibe/tools/python/test_vibe_codex_reading_contract.py +++ b/plugins/vibe/tools/python/test_vibe_codex_reading_contract.py @@ -26,9 +26,10 @@ from __future__ import annotations -import re from pathlib import Path +from vibe_contract_text import flat + PLUGIN_ROOT = Path(__file__).resolve().parents[2] VIBE_SKILL = PLUGIN_ROOT / "skills" / "vibe" / "SKILL.md" QUALITY = PLUGIN_ROOT / "skills" / "vibe" / "references" / "quality-loop.md" @@ -40,10 +41,6 @@ KEPT_HEAD_CHARACTERS = 12_000 -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - def harness_notes() -> str: text = VIBE_SKILL.read_text() start = text.index("## Harness notes\n") diff --git a/plugins/vibe/tools/python/test_vibe_design_pass_contract.py b/plugins/vibe/tools/python/test_vibe_design_pass_contract.py index 9d5c16b5..76b6d6f9 100644 --- a/plugins/vibe/tools/python/test_vibe_design_pass_contract.py +++ b/plugins/vibe/tools/python/test_vibe_design_pass_contract.py @@ -15,11 +15,12 @@ from __future__ import annotations -import re from pathlib import Path import yaml +from vibe_contract_text import agent, flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -64,23 +65,6 @@ NON_GENERIC_NAMES = ("Sessions", "Branches", "GridTableListSurface", "bulk select", "ISS-12137") -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same level, flattened.""" - level = heading.split(" ", 1)[0] - start = text.index(heading) - rest = text[start + len(heading) :] - end = rest.find(f"\n{level} ") - return flat(rest if end == -1 else rest[:end]) - - -def agent(name: str) -> str: - return (AGENTS / f"{name}.md").read_text() - - def fenced_block(text: str, first_line: str) -> str: start = text.index(f"```\n{first_line}") return text[start : text.index("```", start + 3)] diff --git a/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py b/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py index 4d0c5e23..35d4658b 100644 --- a/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py +++ b/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py @@ -16,9 +16,10 @@ from __future__ import annotations -import re from pathlib import Path +from vibe_contract_text import agent, flat, paragraph, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -33,31 +34,6 @@ TAG = "`Engineering needed (forbidden path)`" -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - -def paragraph(text: str, marker: str) -> str: - """The blank-line-delimited paragraph or list item block containing `marker`, flattened.""" - for block in re.split(r"\n\s*\n|\n(?=\d+\. )|\n(?=- )", text): - if marker in flat(block): - return flat(block) - raise AssertionError(marker) - - -def agent(name: str) -> str: - return (AGENTS / f"{name}.md").read_text() - - def routing_rule() -> str: return paragraph(GUARDRAILS.read_text(), "genuinely needs a change in a Never path") @@ -106,7 +82,7 @@ def test_the_template_places_each_need_once_as_an_unchecked_engineering_item() - "Never in the Handoff section, and never routed to the person running the session.", ): assert required in rule, required - assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs and compile-only v1.0 repairs |" in template + assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs, compile-only v1.0 repairs and engineering decisions |" in template assert "Engineering needed" not in section(template, "## Handoff") diff --git a/plugins/vibe/tools/python/test_vibe_handoff_docs_tests_control_contract.py b/plugins/vibe/tools/python/test_vibe_handoff_docs_tests_control_contract.py index 6cc62ce9..8377cbfa 100644 --- a/plugins/vibe/tools/python/test_vibe_handoff_docs_tests_control_contract.py +++ b/plugins/vibe/tools/python/test_vibe_handoff_docs_tests_control_contract.py @@ -18,6 +18,8 @@ import re from pathlib import Path +from vibe_contract_text import agent, flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -49,23 +51,6 @@ CONTROL_COMMANDS = {"feature", "up", "doctor", "goto", "wait", "capture", "console", "network", "api", "ipc", "down"} -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - -def agent(name: str) -> str: - return (AGENTS / f"{name}.md").read_text() - - def plugin_prose() -> dict[str, str]: paths = [*AGENTS.glob("*.md"), *(PLUGIN_ROOT / "skills").rglob("*.md"), PLUGIN_ROOT / "README.md"] return {str(path.relative_to(PLUGIN_ROOT)): path.read_text() for path in paths} diff --git a/plugins/vibe/tools/python/test_vibe_mechanism_provenance_contract.py b/plugins/vibe/tools/python/test_vibe_mechanism_provenance_contract.py new file mode 100644 index 00000000..6bc00691 --- /dev/null +++ b/plugins/vibe/tools/python/test_vibe_mechanism_provenance_contract.py @@ -0,0 +1,150 @@ +"""Contract checks for named mechanisms and decision provenance in vibe sessions. + +1. Only the outcome a ticket or PRD asks for binds the session. A mechanism it + names is a proposal held to the same test as the writer's own; a dominated + one is swapped for the better option and logged as an `Engineering + decision` item, never routed to the person. +2. At handoff the guardrails reviewer checks each new mechanism: the existing + mechanism it could reuse, what its cost scales with at 10x and 100x, and + its per-device idle cost. +3. A finding that needs a human decision is never the writer's to resolve, + downgrade or absorb; it goes on the live ticket's Engineering checklist. +4. A recorded decision is settled only when it traces to a human statement. +5. Frozen rows describe user-visible behavior, never mechanism. +""" + +from __future__ import annotations + +from pathlib import Path + +from vibe_contract_text import agent, flat, paragraph, section + + +PLUGIN_ROOT = Path(__file__).resolve().parents[2] +AGENTS = PLUGIN_ROOT / "agents" +VIBE = PLUGIN_ROOT / "skills" / "vibe" +REFERENCES = VIBE / "references" +VIBE_SKILL = VIBE / "SKILL.md" +QUALITY = REFERENCES / "quality-loop.md" +TICKET_TEMPLATE = REFERENCES / "ticket-template.md" +GRAPH = REFERENCES / "closedloop-graph.md" +HANDOFF_SKILL = PLUGIN_ROOT / "skills" / "handoff" / "SKILL.md" +CL_EXECUTE_GATES = PLUGIN_ROOT / "skills" / "cl-execute" / "references" / "planning-and-gates.md" +LOG_SHAPE = "`Engineering decision: ; built: ; evidence: `" +CHECKLIST_SHAPE = "`- [ ] Engineering decision: ; built: ; evidence: `" +PROVENANCE = "(`quality-loop.md`, \"Communication and product questions\")" + + +def test_the_guardrails_reviewer_checks_each_new_mechanism() -> None: + item = paragraph(agent("vibe-guardrails-reviewer"), "12. Mechanisms:") + for required in ( + "each new poller, interval, background lane, socket, cross-process channel, route, table or cursor in the diff", + "each write into files another tool owns", + "name the existing mechanism that does or could do the job", + "and why it was not used", + "(installs or idle clients, connected clients, active sessions, events) at 10x and 100x", + "its per-device idle cost (timer wakeups, network and disk while nothing is active, on battery)", + "Never argue from current fleet size.", + "Cost that scales with idle clients when an activity-scaled design meets the outcome is blocking", + "a write into another tool's data with no cited human decision", + "A decision recorded in a ticket or plan counts only when it traces to a human statement (who, where, which question;", + "a Decisions section alone is a record, not a ruling, and an untraced one is an `engineering decision` finding.", + "A mechanism the ticket named is held to the same test", + "When the session's local plan has a Cost Scaling table, check it against the diff", + ): + assert required in item, required + output = section(agent("vibe-guardrails-reviewer"), "## Output") + assert "Mark a finding that needs a human decision" in output + assert "never for the person" in output + + +def test_the_writer_treats_a_named_mechanism_as_a_proposal() -> None: + plan = section(agent("vibe-change-worker"), "## Plan before code") + rule = paragraph(agent("vibe-change-worker"), "Only the outcome a ticket or PRD asks for binds the session.") + assert rule in plan + for required in ( + "is a proposal held to the same test as one you invent.", + "its cost scales with installs or idle time while an alternative scales with activity", + "a supported interface exists for a write into another tool's data", + "build the better option that meets the outcome", + "`Engineering decision: ; built: ; evidence: `", + "It is never `NEEDS_PERSON`.", + "neither instruction inflation nor manufactured ambiguity", + "an unexamined mechanism is not one.", + ): + assert required in rule, required + + +def test_periodic_or_cross_surface_plans_carry_a_cost_scaling_table() -> None: + plan = section(agent("vibe-change-worker"), "## Plan before code") + assert "When the plan adds periodic or cross-surface work" in plan + assert "`../skills/cl-execute/references/planning-and-gates.md`, \"Cost Scaling\"" in plan + assert "## Cost Scaling" in CL_EXECUTE_GATES.read_text() + + +def test_requirements_tag_named_mechanisms_as_proposals() -> None: + step = paragraph(agent("vibe-requirements-worker"), "1. Ticket:") + for required in ( + "`mechanism (proposal)`, never an acceptance criterion", + "the session checks the outcome and not the named mechanism", + f"A recorded decision is settled only when it traces to a human statement {PROVENANCE}", + ): + assert required in step, required + assert "each named mechanism tagged `mechanism (proposal)`" in section(agent("vibe-requirements-worker"), "## Return") + + +def test_decision_findings_go_to_the_engineering_checklist_never_the_person() -> None: + review = section(HANDOFF_SKILL.read_text(), "## 6. Independent integrated review") + for required in ( + "that a mechanism, a one-way door, or an unratified or conflicting decision needs a human decision", + "is never the writer's to resolve, downgrade or absorb, including after verification.", + f"logs it in the change log as {LOG_SHAPE}", + "never to the person.", + ): + assert required in review, required + assert "follows step 6's rule for decisions" in section(HANDOFF_SKILL.read_text(), "## 3. Guardrail check") + loop = paragraph(QUALITY.read_text(), "A reviewer proposal is not a product ruling") + assert "is never the writer's to resolve, downgrade or absorb" in loop + assert "never a question for the person" in loop + + +def test_the_template_and_ticket_worker_place_each_decision_once() -> None: + template = TICKET_TEMPLATE.read_text() + rule = paragraph(template, "- Engineering decisions:") + assert f"goes once at the end of the Engineering checklist as {CHECKLIST_SHAPE}" in rule + assert "never routed to the person running the session." in rule + assert "Engineering decision" not in section(template, "## Handoff") + ticket = flat(agent("vibe-ticket-worker")) + assert ( + "Place each `Engineering decision` entry in the change log once, as an unchecked item at the end of the " + "Engineering checklist in its logged shape (`../skills/vibe/references/ticket-template.md`, \"Engineering decisions\")." + ) in ticket + + +def test_the_graph_has_a_route_for_existing_channels() -> None: + graph = GRAPH.read_text() + row = next(line for line in graph.splitlines() if line.startswith("| Which channels already connect two surfaces")) + for required in ("`code_grep`", "joined in one regex alternation", "`rg`"): + assert required in row, required + change_row = next(line for line in graph.splitlines() if line.startswith("| `vibe-change-worker`")) + assert "the existing-channels `code_grep`" in change_row + + +def test_settled_means_traced_to_a_human_statement() -> None: + communication = section(QUALITY.read_text(), "## Communication and product questions") + for required in ( + "Apply settled decisions; never ask them again.", + "A ticket's or plan's Decisions section is a record, not a ruling.", + "A decision is settled only when it traces to a human statement: who made it, where, and which question it answered.", + "a technical one becomes an `Engineering decision` item", + "Never resolve a conflict by recency or by assuming one author meant to change an earlier record.", + ): + assert required in communication, required + assert "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\")." in flat(VIBE_SKILL.read_text()) + assert f"Apply a settled ruling, never ask it again; settled means traced to a human statement {PROVENANCE}." in flat(agent("vibe-change-worker")) + assert f"re-ask settled scope {PROVENANCE}" in flat(agent("vibe-requirements-worker")) + + +def test_frozen_rows_never_freeze_a_mechanism() -> None: + frozen = section(QUALITY.read_text(), "### Frozen behavior at handoff") + assert "Frozen rows describe user-visible behavior, never mechanism: no intervals, transports or storage choices." in frozen diff --git a/plugins/vibe/tools/python/test_vibe_no_plugin_changes_contract.py b/plugins/vibe/tools/python/test_vibe_no_plugin_changes_contract.py index 22c111e1..ddbc28f3 100644 --- a/plugins/vibe/tools/python/test_vibe_no_plugin_changes_contract.py +++ b/plugins/vibe/tools/python/test_vibe_no_plugin_changes_contract.py @@ -15,9 +15,10 @@ from __future__ import annotations -import re from pathlib import Path +from vibe_contract_text import flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] VIBE = PLUGIN_ROOT / "skills" / "vibe" @@ -36,19 +37,6 @@ ) -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - def rule() -> str: return section(GUARDRAILS.read_text(), "## Plugins and tooling") diff --git a/plugins/vibe/tools/python/test_vibe_notices_docs_frozen_contract.py b/plugins/vibe/tools/python/test_vibe_notices_docs_frozen_contract.py index ba96a59e..8446ff2e 100644 --- a/plugins/vibe/tools/python/test_vibe_notices_docs_frozen_contract.py +++ b/plugins/vibe/tools/python/test_vibe_notices_docs_frozen_contract.py @@ -13,11 +13,12 @@ from __future__ import annotations -import re from pathlib import Path import yaml +from vibe_contract_text import agent, flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -35,23 +36,6 @@ NOTICES = '### Notices that never wait' -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - -def agent(name: str) -> str: - return (AGENTS / f"{name}.md").read_text() - - def tools(name: str) -> set[str]: frontmatter = yaml.safe_load(agent(name).split("---", 2)[1]) return {tool.strip() for tool in frontmatter["tools"].split(",")} diff --git a/plugins/vibe/tools/python/test_vibe_retired_tests_auto_deploy_contract.py b/plugins/vibe/tools/python/test_vibe_retired_tests_auto_deploy_contract.py index 0024c4b3..842a0db6 100644 --- a/plugins/vibe/tools/python/test_vibe_retired_tests_auto_deploy_contract.py +++ b/plugins/vibe/tools/python/test_vibe_retired_tests_auto_deploy_contract.py @@ -14,9 +14,10 @@ from __future__ import annotations -import re from pathlib import Path +from vibe_contract_text import agent, flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -36,23 +37,6 @@ LAUNCHER_DELETION = "Delete a test file or case only when it covers nothing but code the request retires" -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - -def agent(name: str) -> str: - return (AGENTS / f"{name}.md").read_text() - - def test_only_tests_of_retired_code_may_be_deleted() -> None: rule = section(GUARDRAILS.read_text(), "### Retired-only tests may be deleted") for required in ("may delete a test file or test case that covers ONLY code the request itself retires", diff --git a/plugins/vibe/tools/python/test_vibe_seed_walk_linux_contract.py b/plugins/vibe/tools/python/test_vibe_seed_walk_linux_contract.py index 87b2a6bc..038b410b 100644 --- a/plugins/vibe/tools/python/test_vibe_seed_walk_linux_contract.py +++ b/plugins/vibe/tools/python/test_vibe_seed_walk_linux_contract.py @@ -13,9 +13,10 @@ from __future__ import annotations -import re from pathlib import Path +from vibe_contract_text import section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] FIX_WORKER = PLUGIN_ROOT / "agents" / "vibe-seed-fix-worker.md" @@ -24,19 +25,6 @@ RESULT_FILE = ".control/vibe/seed-walk-result.json" -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - def walk_rule() -> str: return section(FIX_WORKER.read_text(), "## The screen walk") diff --git a/plugins/vibe/tools/python/test_vibe_session_endings_contract.py b/plugins/vibe/tools/python/test_vibe_session_endings_contract.py index d4cbfb58..ccf9aa31 100644 --- a/plugins/vibe/tools/python/test_vibe_session_endings_contract.py +++ b/plugins/vibe/tools/python/test_vibe_session_endings_contract.py @@ -22,6 +22,8 @@ import re from pathlib import Path +from vibe_contract_text import agent, flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -43,23 +45,6 @@ HANDOFF_WORD = re.compile(r"hand ?off", re.IGNORECASE) -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - -def agent(name: str) -> str: - return (AGENTS / f"{name}.md").read_text() - - def plugin_texts() -> dict[str, str]: files = [*PLUGIN_ROOT.glob("skills/**/*.md"), *AGENTS.glob("*.md"), PLUGIN_ROOT / "README.md"] return {str(path.relative_to(PLUGIN_ROOT)): path.read_text() for path in files} diff --git a/plugins/vibe/tools/python/test_vibe_session_friction_contract.py b/plugins/vibe/tools/python/test_vibe_session_friction_contract.py index 2c3c4184..9dad1fcb 100644 --- a/plugins/vibe/tools/python/test_vibe_session_friction_contract.py +++ b/plugins/vibe/tools/python/test_vibe_session_friction_contract.py @@ -16,6 +16,8 @@ import re from pathlib import Path +from vibe_contract_text import flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] VIBE = PLUGIN_ROOT / "skills" / "vibe" @@ -30,17 +32,6 @@ GNU_TIMEOUT = re.compile(r"(?:^|[\s;&|(`$])g?timeout\s+(?:-[A-Za-z]+\s+\S+\s+)*\d") -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str, level: str = "## ") -> str: - start = text.index(heading) - rest = text[start + len(heading) :] - end = rest.find(f"\n{level}") - return flat(rest if end == -1 else rest[:end]) - - def banned_terms() -> list[str]: rows = [line for line in PLAIN_WORDS.read_text().splitlines() if line.startswith("| ") and "---" not in line] return [term.strip().strip("`") for row in rows[1:] for term in row.split("|")[1].split(",")] @@ -63,7 +54,7 @@ def test_both_skills_and_the_person_facing_workers_reference_the_list() -> None: def test_every_question_is_recorded_and_never_asked_twice() -> None: - rule = section(VIBE_SKILL.read_text(), "### Questions are asked once", level="#") + rule = section(VIBE_SKILL.read_text(), "### Questions are asked once") for required in ( "No question reaches the person twice (operator ruling, 2026-10-15).", "node scripts/session-questions.mjs ask --key ", @@ -80,7 +71,7 @@ def test_every_question_is_recorded_and_never_asked_twice() -> None: def test_the_sample_data_question_is_plain_gated_and_never_uses_the_email() -> None: skill = VIBE_SKILL.read_text() - starting = section(skill, "### Starting new", level="#") + starting = section(skill, "### Starting new") assert "same necessity gate as any other question" in starting assert "In every other case record `seeded` and ask nothing." in starting assert ("\"Should your copy of the app start with an example company that already has people and " diff --git a/plugins/vibe/tools/python/test_vibe_start_ask_before_search_contract.py b/plugins/vibe/tools/python/test_vibe_start_ask_before_search_contract.py index 41829b32..5b72c884 100644 --- a/plugins/vibe/tools/python/test_vibe_start_ask_before_search_contract.py +++ b/plugins/vibe/tools/python/test_vibe_start_ask_before_search_contract.py @@ -18,6 +18,8 @@ import re from pathlib import Path +from vibe_contract_text import flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] VIBE_SKILL = PLUGIN_ROOT / "skills" / "vibe" / "SKILL.md" @@ -29,17 +31,6 @@ SEARCH = "vibe-sessions.mjs list" -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str, level: str = "## ") -> str: - start = text.index(heading) - rest = text[start + len(heading) :] - end = rest.find(f"\n{level}") - return flat(rest if end == -1 else rest[:end]) - - def start_section() -> str: return section(VIBE_SKILL.read_text(), START) @@ -96,7 +87,7 @@ def test_a_new_session_skips_the_search_entirely() -> None: def test_the_search_runs_only_after_resume_was_chosen() -> None: skill = VIBE_SKILL.read_text() - resume = section(skill, RESUME, level="#") + resume = section(skill, RESUME) assert resume.index("Only after the person chose resume") < resume.index(f"run `node scripts/{SEARCH}`") assert "Never resume on a guess" in resume assert "offer the matching session first" in resume diff --git a/plugins/vibe/tools/python/test_vibe_start_here_unversioned_contract.py b/plugins/vibe/tools/python/test_vibe_start_here_unversioned_contract.py index 0376b6be..83e756c7 100644 --- a/plugins/vibe/tools/python/test_vibe_start_here_unversioned_contract.py +++ b/plugins/vibe/tools/python/test_vibe_start_here_unversioned_contract.py @@ -12,9 +12,10 @@ from __future__ import annotations -import re from pathlib import Path +from vibe_contract_text import flat, paragraph, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] GUARDRAILS = PLUGIN_ROOT / "skills" / "vibe" / "references" / "guardrails.md" @@ -28,27 +29,6 @@ ) -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - -def paragraph(text: str, marker: str) -> str: - """The blank-line-delimited paragraph or list item block containing `marker`, flattened.""" - for block in re.split(r"\n\s*\n|\n(?=\d+\. )|\n(?=- )", text): - if marker in flat(block): - return flat(block) - raise AssertionError(marker) - - def test_guardrails_state_the_exemption_beside_the_freeze() -> None: taxonomy = section(GUARDRAILS.read_text(), "## Storybook taxonomy") assert EXEMPTION in taxonomy diff --git a/plugins/vibe/tools/python/test_vibe_storybook_existing_component_contract.py b/plugins/vibe/tools/python/test_vibe_storybook_existing_component_contract.py index 3354b1f4..292f50dc 100644 --- a/plugins/vibe/tools/python/test_vibe_storybook_existing_component_contract.py +++ b/plugins/vibe/tools/python/test_vibe_storybook_existing_component_contract.py @@ -24,6 +24,8 @@ import re from pathlib import Path +from vibe_contract_text import agent, flat, paragraph + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -39,22 +41,6 @@ FROZEN = "A v1.0 story file is frozen and never edited" -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def paragraph(text: str, marker: str) -> str: - """The blank-line-delimited paragraph or list item block containing `marker`, flattened.""" - for block in re.split(r"\n\s*\n|\n(?=\d+\. )|\n(?=- )", text): - if marker in flat(block): - return flat(block) - raise AssertionError(marker) - - -def agent(name: str) -> str: - return (AGENTS / f"{name}.md").read_text() - - def test_existing_components_come_first_in_the_placement_owners_decision_order() -> None: order = paragraph(agent("vibe-storybook-decomposer"), "4. Decide in this order") for required in ( diff --git a/plugins/vibe/tools/python/test_vibe_storybook_links_contract.py b/plugins/vibe/tools/python/test_vibe_storybook_links_contract.py index 0bb5eecc..73470f82 100644 --- a/plugins/vibe/tools/python/test_vibe_storybook_links_contract.py +++ b/plugins/vibe/tools/python/test_vibe_storybook_links_contract.py @@ -14,6 +14,8 @@ import re from pathlib import Path +from vibe_contract_text import flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -34,19 +36,6 @@ STORY_PATH = re.compile(r"(.{0,12})/story/") -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - def plugin_text_files() -> list[Path]: return [ path diff --git a/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py b/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py index 92e2e0ef..4120723c 100644 --- a/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py +++ b/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py @@ -25,6 +25,8 @@ import re from pathlib import Path +from vibe_contract_text import agent, flat, paragraph, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = PLUGIN_ROOT.parents[1] @@ -53,31 +55,6 @@ ) -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - -def paragraph(text: str, marker: str) -> str: - """The blank-line-delimited paragraph or list item block containing `marker`, flattened.""" - for block in re.split(r"\n\s*\n|\n(?=\d+\. )", text): - if marker in flat(block): - return flat(block) - raise AssertionError(marker) - - -def agent(name: str) -> str: - return (AGENTS / f"{name}.md").read_text() - - def frontmatter(text: str) -> str: return text.split("---")[1] @@ -342,7 +319,7 @@ def test_the_template_routes_design_conflicts_to_design_review_and_the_rest_to_e assert required in rule, required review = section(template, "## Design Review") assert "- Design conflicts: " in review - assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs and compile-only v1.0 repairs |" in template + assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs, compile-only v1.0 repairs and engineering decisions |" in template def test_the_handoff_section_no_longer_collects_conflicts() -> None: diff --git a/plugins/vibe/tools/python/test_vibe_storybook_taxonomy_contract.py b/plugins/vibe/tools/python/test_vibe_storybook_taxonomy_contract.py index c072f5b5..3d50e8a6 100644 --- a/plugins/vibe/tools/python/test_vibe_storybook_taxonomy_contract.py +++ b/plugins/vibe/tools/python/test_vibe_storybook_taxonomy_contract.py @@ -12,9 +12,10 @@ from __future__ import annotations -import re from pathlib import Path +from vibe_contract_text import agent, flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -25,23 +26,6 @@ POINTER = '"Storybook taxonomy"' -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - -def agent(name: str) -> str: - return flat((AGENTS / f"{name}.md").read_text()) - - def test_the_rule_defers_to_the_repo_taxonomy_doc_and_states_only_the_minimum() -> None: text = section(GUARDRAILS.read_text(), "## Storybook taxonomy") for required in ( @@ -61,13 +45,13 @@ def test_the_rule_defers_to_the_repo_taxonomy_doc_and_states_only_the_minimum() def test_every_story_author_and_advisor_points_at_the_doc_and_the_rule() -> None: - writer = agent("vibe-change-worker") + writer = flat(agent("vibe-change-worker")) assert f"Read the repo's {TAXONOMY_DOC} and follow it" in writer assert f"(`../skills/vibe/references/guardrails.md`, {POINTER})" in writer - decomposer = agent("vibe-storybook-decomposer") + decomposer = flat(agent("vibe-storybook-decomposer")) assert TAXONOMY_DOC in decomposer assert f"(`../skills/vibe/references/guardrails.md`, {POINTER})" in decomposer - primitive = agent("vibe-primitive-worker") + primitive = flat(agent("vibe-primitive-worker")) assert f"Place them per the repo's {TAXONOMY_DOC}" in primitive assert f"(`guardrails.md`, {POINTER})" in primitive primitives = section(GUARDRAILS.read_text(), "## Missing primitives") @@ -75,7 +59,7 @@ def test_every_story_author_and_advisor_points_at_the_doc_and_the_rule() -> None def test_the_guardrails_reviewer_blocks_v1_placement() -> None: - reviewer = agent("vibe-guardrails-reviewer") + reviewer = flat(agent("vibe-guardrails-reviewer")) for required in ( f"10. Storybook taxonomy (`guardrails.md`, {POINTER})", "titled under or tagged for a frozen v1.0 root, or any edit or move of a story file that is v1.0 on main " diff --git a/plugins/vibe/tools/python/test_vibe_stranded_writer_recovery_contract.py b/plugins/vibe/tools/python/test_vibe_stranded_writer_recovery_contract.py index e9890134..32a50acf 100644 --- a/plugins/vibe/tools/python/test_vibe_stranded_writer_recovery_contract.py +++ b/plugins/vibe/tools/python/test_vibe_stranded_writer_recovery_contract.py @@ -11,9 +11,10 @@ from __future__ import annotations -import re from pathlib import Path +from vibe_contract_text import flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] VIBE = PLUGIN_ROOT / "skills" / "vibe" @@ -26,19 +27,6 @@ BLOCK_REASON = "recorded-definition-removed" -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - def migration() -> str: return section(QUALITY_LOOP.read_text(), "### Recorded definition migration") diff --git a/plugins/vibe/tools/python/test_vibe_test_repair_lean_loop_contract.py b/plugins/vibe/tools/python/test_vibe_test_repair_lean_loop_contract.py index 7c9505c5..ed945f80 100644 --- a/plugins/vibe/tools/python/test_vibe_test_repair_lean_loop_contract.py +++ b/plugins/vibe/tools/python/test_vibe_test_repair_lean_loop_contract.py @@ -16,6 +16,8 @@ import re from pathlib import Path +from vibe_contract_text import agent, flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -34,23 +36,6 @@ QA_SKILLS = ("/visual-qa", "/logical-qa", "/metal-andrew") -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - -def agent(name: str) -> str: - return (AGENTS / f"{name}.md").read_text() - - def repair_modes(source: str) -> set[str]: match = re.search(r"TEST_REPAIR_MODES(?::[^=]+)? = (?:/\* @__PURE__ \*/ )?new Set\(\[([^\]]*)\]\)", source) assert match, "TEST_REPAIR_MODES is missing" diff --git a/plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py b/plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py index 61fa143a..ad92693a 100644 --- a/plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py +++ b/plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py @@ -17,9 +17,10 @@ from __future__ import annotations -import re from pathlib import Path +from vibe_contract_text import agent, flat, paragraph, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] AGENTS = PLUGIN_ROOT / "agents" @@ -41,31 +42,6 @@ ) -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading) - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - -def paragraph(text: str, marker: str) -> str: - """The blank-line-delimited paragraph or list item block containing `marker`, flattened.""" - for block in re.split(r"\n\s*\n|\n(?=\d+\. )|\n(?=- )", text): - if marker in flat(block): - return flat(block) - raise AssertionError(marker) - - -def agent(name: str) -> str: - return (AGENTS / f"{name}.md").read_text() - - def test_the_placement_owner_states_the_exception_in_full_once() -> None: order = paragraph(agent("vibe-storybook-decomposer"), "4. Decide in this order") assert EXCEPTION in order @@ -134,7 +110,7 @@ def test_the_ticket_worker_places_each_repair_once_on_the_engineering_checklist( ) in rule assert ( "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, " - "forbidden-path needs and compile-only v1.0 repairs |" + "forbidden-path needs, compile-only v1.0 repairs and engineering decisions |" ) in template assert "compile-only" not in section(template, "## Handoff") handoff_mode = section(agent("vibe-ticket-worker"), "## Handoff mode") diff --git a/plugins/vibe/tools/python/test_vibe_visual_cues_contract.py b/plugins/vibe/tools/python/test_vibe_visual_cues_contract.py index fc2f0fa3..51d03b73 100644 --- a/plugins/vibe/tools/python/test_vibe_visual_cues_contract.py +++ b/plugins/vibe/tools/python/test_vibe_visual_cues_contract.py @@ -28,9 +28,10 @@ from __future__ import annotations -import re from pathlib import Path +from vibe_contract_text import agent, flat, section + PLUGIN_ROOT = Path(__file__).resolve().parents[2] VIBE = PLUGIN_ROOT / "skills" / "vibe" AGENTS = PLUGIN_ROOT / "agents" @@ -45,19 +46,6 @@ PROPERTIES = "shadow and elevation, font size, weight, color, spacing, radius, borders, alignment and icons" -def flat(text: str) -> str: - return re.sub(r"\s+", " ", text) - - -def section(text: str, heading: str) -> str: - """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" - level = len(heading.split(" ", 1)[0]) - start = text.index(heading + "\n") - rest = text[start + len(heading) :] - ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] - return flat(rest[: ends[0]] if ends else rest) - - def rule() -> str: return section(QUALITY.read_text(), HEADING) @@ -66,10 +54,6 @@ def check() -> str: return section(QUALITY.read_text(), CHECK) -def agent(name: str) -> str: - return flat((AGENTS / f"{name}.md").read_text()) - - def assert_all(text: str, required: tuple[str, ...]) -> None: for phrase in required: assert phrase in text, phrase @@ -124,7 +108,7 @@ def test_reading_the_reference_is_a_required_recorded_step() -> None: "A plan or criterion written before that line exists is incomplete", "The orchestrator's text description of an image never stands in for that read", )) - assert_all(agent("vibe-change-worker"), ( + assert_all(flat(agent("vibe-change-worker")), ( "Open every reference image path as an image before planning", "Never build from a description of a reference in place of the image.", "append a `Reference read` line per image", @@ -177,12 +161,12 @@ def test_the_check_runs_with_a_reference_image_and_never_without_one() -> None: assert "The reference check before ready is the one exception to this rule (operator ruling, 2026-10-15), and it runs only with a reference image." in lean assert "- Reference check: only when the person gave a reference image" in lean assert "the one exception to its no-automated-visual-QA rule" in rule() - assert "With no reference image there is no render or compare." in agent("vibe-change-worker") + assert "With no reference image there is no render or compare." in flat(agent("vibe-change-worker")) assert "With no reference image there is no render or compare" in section(VIBE_SKILL.read_text(), "## 5. Build loop") capture = section(ENVIRONMENT.read_text(), "## Capturing for the reference check") assert "Only when the session has a reference image" in capture assert "with none, nothing here runs" in capture - for text in (rule(), agent("vibe-change-worker"), flat(VIBE_SKILL.read_text())): + for text in (rule(), flat(agent("vibe-change-worker")), flat(VIBE_SKILL.read_text())): assert "sanity look" not in text @@ -197,7 +181,7 @@ def test_the_check_compares_the_reference_properties_and_regressions_then_recapt "`DONE` on such a change carries a Reference check line per touched screen", "without it the `DONE` is incomplete", )) - assert "plus regressions on the touched element (properties that were right before this change)" in agent("vibe-change-worker") + assert "plus regressions on the touched element (properties that were right before this change)" in flat(agent("vibe-change-worker")) def test_the_check_is_not_a_design_review() -> None: @@ -232,7 +216,7 @@ def test_capture_works_in_both_harnesses_with_named_fallbacks() -> None: "in Claude Code, the Chrome extension's screenshot when it is connected", "A Claude Code session without it has no preview screenshot", )) - assert "When the session recorded reference images (`referenceImages`), open each as an image" in agent("vibe-verify-worker") + assert "When the session recorded reference images (`referenceImages`), open each as an image" in flat(agent("vibe-verify-worker")) def test_a_surface_without_tokens_is_logged_for_engineering_not_invented() -> None: diff --git a/plugins/vibe/tools/python/vibe_contract_text.py b/plugins/vibe/tools/python/vibe_contract_text.py new file mode 100644 index 00000000..1443f9d9 --- /dev/null +++ b/plugins/vibe/tools/python/vibe_contract_text.py @@ -0,0 +1,43 @@ +"""Text helpers shared by the vibe contract tests.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +AGENTS = Path(__file__).resolve().parents[2] / "agents" + + +def flat(text: str) -> str: + """`text` with every run of whitespace collapsed to one space.""" + return re.sub(r"\s+", " ", text) + + +def section(text: str, heading: str) -> str: + """The section under `heading`, up to the next heading of the same or a higher level, flattened. + + `heading` is matched at the start of a line: a line equal to it wins, otherwise the first line it + begins (so "## Return" finds "## Return (under 200 words)"). + """ + level = len(heading.split(" ", 1)[0]) + escaped = re.escape(heading) + match = re.search(r"^%s$" % escaped, text, re.MULTILINE) or re.search(r"^%s" % escaped, text, re.MULTILINE) + if match is None: + raise ValueError(f"heading not found: {heading}") + rest = text[match.end() :] + ends = [found.start() for found in re.finditer(r"\n(#{1,%d}) " % level, rest)] + return flat(rest[: ends[0]] if ends else rest) + + +def paragraph(text: str, marker: str) -> str: + """The blank-line-delimited paragraph or list item block containing `marker`, flattened.""" + for block in re.split(r"\n\s*\n|\n(?=\d+\. )|\n(?=- )", text): + if marker in flat(block): + return flat(block) + raise AssertionError(marker) + + +def agent(name: str) -> str: + """The raw text of the vibe agent definition `name`.""" + return (AGENTS / f"{name}.md").read_text()