Migration guide — v1 → v2

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.


TL;DR

# 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:exec body (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 is tap capture <url> <site>/<name> --intent "..." against the original target, not a hand port.


What changed at the schema level

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 armsequivalent · 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>.


The three CLI verbs you need

tap migrate scan

Read-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 migrate

Applies 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

Plans that don’t auto-migrate

A plan needs hand-rewriting if its body contains:

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.


npm packages

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

Why from-stagehand is deprecated

Stagehand 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.

v0.x is not unpublished

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.


Code-level mapping cheatsheet

Read tap

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"
}

Write tap

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 (preflightin_flightcommitted / aborted / uncertain) so a retry can’t double-post.

op:execop:eval

v1’s op:exec was the all-purpose JS escape hatch. v2 replaces it with op:eval, which:

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.


Doctor verdict — what changed in CI

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.”


Plugins, if you ship them

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.


Timeline

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.


Stuck?