Tap v2 is a discontinuous schema break. The v1 W3C Annotation envelope is gone; the @taprun/spec package ships v1.0 with a bare Plan type sourced from core/types.ts. This guide walks the four moving parts: your local taps, the npm packages you depend on, the doctor verdict you read in CI, and the runtime-shape change every consumer must absorb.
If you keep v0.x lockfiles pinned, nothing breaks today. If you upgrade, this is the path.
# 1. See what migrates automatically
tap migrate scan
# 2. Apply
tap migrate migrate
# 3. Bump npm packages
npm install @taprun/spec@^1 @taprun/from-playwright@^1 @taprun/from-puppeteer@^1
The CLI prints a report of three buckets: auto-migratable (W3C envelope wrapping a body that already uses only v2 ops), needs-rewrite (uses deleted ops or fields), and corrupt. Auto-migratable plans land in ~/.tap/plans/<site>/<name>.plan.json. Originals stay read-only in ~/.tap/taps/ for the deprecation window so nothing is lost.
Be realistic about coverage. Most v0.x plans were compiled with a universal
op:execbody (the v1 escape hatch). Auto-migration coverage on the legacy corpus is therefore close to 0% — not because the migration tool is incomplete but because v1 leaned heavily on free-form JS. Most upgrades land in the needs-rewrite bucket. The cheapest path for those istap capture <url> <site>/<name> --intent "..."against the original target, not a hand port.
| Concept | v1 (deprecated) | v2 (current) |
|---|---|---|
| Envelope | W3C Annotation {type:"Annotation", body:{type:"tap:ExecutionPlan", ...}} |
Bare Plan (no wrapper) |
| Op count | 24 ops including op:exec, op:pipe, op:parseXML, op:screenshot, op:scroll |
11 ops — 7 substrate + 3 control flow + 1 typed-eval escape |
| Op:exec | Free-form Deno-host JS body | Replaced by op:eval (page-context) with required returns type, plus composition via if/foreach/parallel |
| Read/write split | intent: "read" \| "write" field |
Discriminated union on the Plan type itself: read variant has no act/key; write variant requires both |
| Doctor verdict | 6 arms (healthy · broken · stale · layer-mismatch · unreachable · unverified) |
4 arms — equivalent · drifted · first_snapshot · unreachable |
| Equivalence rule | Hard-coded structural diff in doctor | Per-tap CEL snapshot_equivalent predicate that you author |
legacy: true, allowUnverifiable |
Pre-drainage escape hatches | Deleted; v2 lint rejects on save |
| Heal classes | “3 token classes” (cache / minimal / rewrite) as separate codepaths | Same three paths, single escalation pipeline; reads as cache (0 tokens) → minimal-patch (~1.1K) → full-rewrite (~14K) |
generator field |
Required | Optional compiled_by metadata under forge-ai-lifecycle ADR |
The unifying move: every concept that used to be a string discriminator (intent, legacy, allowUnverifiable) became a TypeScript discriminated union. Invalid combinations are now unrepresentable.
The verb surface itself collapsed too: v1’s tap-v2 doctor is now tap verify; tap-v2 fix and the AI-write heal pipeline merged into the capture re-call path (re-running capture against an existing site+name is the heal path); tap-v2 mcp-tool is gone — every saved plan auto-projects as the MCP tool <site>.<name>.
tap migrate scanRead-only inventory. Walks ~/.tap/taps/ and classifies each .tap.json into one of three buckets. No file is moved. Output is a printable report with the count per bucket.
tap migrate scan --root ~/.tap # all sites
tap migrate scan --site github # one site
tap migrate migrateApplies the conversion. For each auto-migratable plan: strips the W3C wrapper, drops the deleted top-level fields (intent, legacy, allowUnverifiable), synthesises id: { site, name } from the body, treats ops as observe for read taps, runs lintPlan on the result, and writes to ~/.tap/plans/<site>/<name>.plan.json if lint passes. Originals stay untouched in ~/.tap/taps/ until you decide to delete them.
tap migrate migrate --dry-run # preview
tap migrate migrate # apply
--dry-run performs the full conversion + lint in memory and prints the same summary you’d get from a live run, so you can read the report before any file lands.
Behaviour reference: the tap migrate CLI verb provided by the Tap binary. The migration adapter is part of the proprietary engine.
tap lint (planned)If you author plans by hand, lint is the static gate. It rejects deleted fields (legacy: true on save, intent discriminator, allowUnverifiable), enforces the 11-op closure, and verifies the read/write discriminated union is well-formed.
tap lint # whole fleet
tap lint github/trending # one tap
A plan needs hand-rewriting if its body contains:
op:exec step (Deno-host arbitrary JS) — replace with composition over if / foreach / parallel plus a typed op:eval for the value-only escape, or model the side-effect as a sequence of op:input + op:wait steps.op:pipe step — pipes were a v1-only template macro. Inline the underlying ops or re-forge from a fresh source.op:parseXML / op:screenshot / op:scroll step — parseXML is now op:fetch + CEL extraction; screenshot is removed from the closure (use the chrome runtime directly if needed); scroll merges into op:input with kind: "scroll".legacy: true or allowUnverifiable: true.For these, the cheapest path is: tap capture <url> <site>/<name> --intent "..." against the original target. The forge AI cost is usually 2–4K tokens per re-capture — cheaper than a hand-rewrite.
If the plan is bespoke (no public source, no easy re-forge), open a tap-skills issue tagged migration-help. The core team sweeps the queue weekly during the deprecation window.
| Package | v0.x | v1.0 (this release) | Action |
|---|---|---|---|
@taprun/spec |
Vendored v1 schema | Re-vendored v2 core/types.ts PUBLIC subset + JSON Schema validator |
npm install @taprun/spec@^1 |
@taprun/from-playwright |
Compiles to v1 plan | Compiles to v2 Plan | npm install @taprun/from-playwright@^1 |
@taprun/from-puppeteer |
Compiles to v1 plan | Compiles to v2 Plan | npm install @taprun/from-puppeteer@^1 |
@taprun/from-stagehand |
Compiles Stagehand → v1 | Deprecated, no v1.0 | Pin to last v0.x; see note below |
create-tap-script |
Scaffolds v1 starter | Scaffolds v2 starter | npx create-tap-script@latest |
from-stagehand is deprecatedStagehand is cloud-coupled — it requires Browserbase to run. Tap v2 is local-first by architecture (cookies never cross a trust boundary). Rewriting from-stagehand to emit v2 Plans would force the substrate interface to grow a cloud implementation alongside the local one, dragging the cloud-coupling problem into the engine. We drew the line at the adapter boundary instead. Existing v0.x consumers keep their lockfiles; the integration is not maintained going forward.
If you have a Stagehand script you want on Tap v2, the practical path is: capture the same flow with @taprun/from-playwright against your own Chromium, since the underlying Playwright control-flow is what Stagehand wraps anyway.
We never npm unpublish. v0.x stays installable forever; lockfiles continue to resolve. The only difference is the npm deprecate notice that points back here. Upgrade on your schedule.
v1:
{
"@context": ["http://www.w3.org/ns/anno.jsonld", "https://taprun.dev/ns/tap-v1"],
"type": "Annotation",
"motivation": "tap:executing",
"body": {
"type": "tap:ExecutionPlan",
"site": "github", "name": "trending",
"intent": "read",
"ops": [
{ "op": "fetch", "url": "https://api.github.com/search/repositories?q=stars:>1000" }
]
}
}
v2:
{
"id": { "site": "github", "name": "trending" },
"observe": [
{
"op": "fetch",
"url": "https://api.github.com/search/repositories?q=stars:>1000",
"format": "json",
"save": "raw"
}
],
"return": "$.raw.items"
}
v1 used intent: "write" plus a free-form op:exec body. v2 splits the lifecycle into observe (read state), act (perform side effect), confirm (verify it took), with a key CEL expression that dedups runs.
{
"id": { "site": "twitter", "name": "post" },
"args": { "text": { "type": "string", "required": true } },
"key": "$.args.text",
"observe": [
{ "op": "fetch", "url": "https://twitter.com/api/me/drafts", "save": "drafts" }
],
"act": [
{ "op": "input", "kind": "fill", "target": "[data-testid='tweetTextarea_0']", "value": "" },
{ "op": "input", "kind": "click", "target": "[data-testid='tweetButton']" }
],
"confirm": [
{ "op": "wait", "selector": "[data-testid='toast']", "timeout_ms": 5000 }
],
"return": "$.confirm[0]"
}
The act array carries the side effect; confirm verifies it landed; key is the dedup contract over runs sharing the same args. Runs sharing a key go through the intent state machine (preflight → in_flight → committed / aborted / uncertain) so a retry can’t double-post.
op:exec → op:evalv1’s op:exec was the all-purpose JS escape hatch. v2 replaces it with op:eval, which:
chrome.scripting;requires a declared returns.type (string |
number |
boolean |
object |
array) — output is schema-checked before binding into scope; |
If your old op:exec was doing DOM mutation, model it as a sequence of op:input ops. If it was reading a value, port it to op:eval with the appropriate returns.type.
If your CI parses tap verify JSON output (formerly tap doctor in v1), the verdict enum collapsed from 6 arms to 4:
| v1 verdict | v2 verdict | Notes |
|---|---|---|
healthy |
equivalent |
Renamed for symmetry with drifted |
broken |
drifted |
Drift now includes the per-tap CEL predicate result |
stale |
drifted |
No separate state — the predicate decides |
layer-mismatch |
drifted |
Folded in |
unverified |
first_snapshot |
First-run state on a fresh tap |
unreachable |
unreachable |
Unchanged |
The 40% false-positive reduction (PoC-measured) comes from the per-tap snapshot_equivalent CEL predicate — you decide which fields count as “the same answer.”
Plugin authoring moved off the in-process *.plugin.ts interface to MCP sub-server pattern: ~/.tap/config/plugins/<name>/manifest.json plus an executable speaking JSON-RPC. Same secrets file (~/.tap/secrets), same SMTP / shell-out logic, structured manifest. See plugin-runtime-model for the interface.
If you maintain qqmail, demand, jimeng, dreamina, or mail-send plugins — the core team is opening rewrite PRs against your repos during the migration window. Merge or fork as you prefer.
| When | What |
|---|---|
| Day 0 | v1.0.0 packages publish; taprun.dev rebuild deploys |
| Day +30 | npm deprecate warning starts firing on every install of v0.x |
| Day +90 | v0.x receives security patches only |
| Day +180 | v0.x retired (still downloadable; effectively frozen) |
Three weeks of prep before Day 0 — internal dogfood, community-tap migration, blog post — so by the time you read this, the path is well-trodden.
2026-05-04-* is publicmigration-help