diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 7bd68b6b..3874a870 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -16,18 +16,6 @@ }, "category": "Productivity" }, - { - "name": "effect-kit", - "source": { - "source": "local", - "path": "./plugins/effect-kit" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Productivity" - }, { "name": "plan-lifecycle", "source": { diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6c400810..7b7015ec 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -35,34 +35,6 @@ "codex" ] }, - { - "name": "effect-kit", - "source": "./plugins/effect-kit", - "description": "Cross-tool Effect skill kit: Effect 3.x setup (effect-ts-setup), patterns (effect-ts-specialist), and porting (effect-ts-port), plus version-gated Effect v4 beta guidance (effect-v4).", - "version": "0.5.0", - "author": { - "name": "Eduardo Marquez" - }, - "license": "MIT", - "homepage": "https://github.com/DocksDocks/docks", - "repository": "https://github.com/DocksDocks/docks", - "keywords": [ - "effect", - "effect-ts", - "typescript", - "skills", - "cross-tool", - "codex" - ], - "category": "engineering-workflows", - "tags": [ - "effect", - "typescript", - "cross-tool", - "skills", - "codex" - ] - }, { "name": "plan-lifecycle", "source": "./plugins/plan-lifecycle", diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 0103a1ca..372a9896 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -6,8 +6,8 @@ resolve to plus the independent `targeting-contracts` job; `validate` joins both prerequisites without rerunning the gate. Core owns `scripts/tests/plan-cli.mjs` plus the `bounded-workflows` case and the `plan-workspace-template` comparison between `docs/PLAN.md` and the plugin plan template in -`scripts/tests/plan-skill-phases.mjs`, the joint Docks/Effect Kit -trigger-collision audit, the plugin gates, and JavaScript quality. Manual +`scripts/tests/plan-skill-phases.mjs`, the Docks trigger-collision audit, the +plugin gates, and JavaScript quality. Manual dispatches run one full gate alongside the targeting contract before the same join. Tag pushes run one registry-resolved plugin gate; the join requires the targeting contract to be skipped there. @@ -53,7 +53,7 @@ lane list is duplicated in YAML. (context tree, no-bespoke-gates, durable anchors, author tooling, observability, test-contract registry, unit tests), and the CI targeting contract. Cross-plugin by nature and cheap (~10 s), so it is never skipped. -- `core`: Docks, effect-kit and plan-lifecycle — their plugin gates, the plan CLI +- `core`: Docks and plan-lifecycle — their plugin gates, the plan CLI contract, the bounded-workflows case, the plan-workspace-template comparison between `docs/PLAN.md` and the plugin plan template for three skills and two reviewer wrappers, collision audits, and JavaScript quality. @@ -76,9 +76,9 @@ ownership requires corresponding workflow and targeting-contract changes. The separate targeting-contract job runs this test without `--unit` for pull requests and manual (`workflow_dispatch`) validation, then feeds its result -into the single authoritative `validate` join. Its nested effect-kit timing -contract stays out of `ci.mjs` and release-tag targeted gates; a tag push skips -the job, and the join accepts that expected skip. +into the single authoritative `validate` join. Its timing contract stays out of +`ci.mjs` and release-tag targeted gates; a tag push skips the job, and the join +accepts that expected skip. ## Cache behavior diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8d10e7a..16f47374 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ on: branches: [main] push: tags: - - '*--v*' # any --v release tag (docks--v*, effect-kit--v*, ...) + - '*--v*' # any --v release tag (docks--v*, plan-lifecycle--v*, ...) workflow_dispatch: # Least privilege: every validation job is read-only. diff --git a/AGENTS.md b/AGENTS.md index 3bb88dcb..3017aa8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,6 @@ Node 24 remains the validator runtime and matches CI's `node-version`; Bun 1.4.0 │ ├── skills/ (cross-tool) surfaced in every runtime — incl. security/refactor/skill-agent-pipeline pipelines │ └── hooks/ (cross-tool) context-tree-nudge PostToolUse hook (Claude + Codex) ├── plugins/plan-lifecycle/ GitHub-issue plan lifecycle plugin (cross-tool): three skills, shipped plan.mjs, v3 contract reference, and two read-only reviewer wrappers under agents/; self-versioned with a closed compatibility.json checked by its self-test -├── plugins/effect-kit/ Effect-TS skill kit plugin (cross-tool): effect-ts-setup / effect-ts-specialist / effect-ts-port (skills-only; depends on docks for plan-lifecycle + authoring skills); self-versioned ├── .claude-plugin/marketplace.json Claude marketplace catalog ├── .agents/plugins/marketplace.json Codex marketplace catalog ├── .agents/skills/ project-local skills (canonical, multi-tool) @@ -43,7 +42,6 @@ Per-area conventions load lazily from nested `AGENTS.md` nodes. Each is paired w |---|---| | `docs/AGENTS.md` | plan-record routing, GitHub issue backend, frozen pre-GitHub archive | | `plugins/docks/skills/AGENTS.md` | skill authoring — description CSO, frontmatter, body rules, scoring | -| `plugins/effect-kit/skills/AGENTS.md` | effect-kit skill authoring — Effect 3.x plus version-gated Effect v4 conventions | | `plugins/plan-lifecycle/skills/AGENTS.md` | plan-lifecycle skill authoring — the three lifecycle skills, contract sync, fail-loud routing | | `scripts/AGENTS.md` | validators, edit→release workflow, double-layer gating, versioning | | `.github/AGENTS.md` | CI trigger model, keep-in-sync with `ci.mjs` | diff --git a/README.md b/README.md index 6249ab02..fc29343e 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,12 @@ here neither depend on it nor reference it. ## Platform support -The three plugins that ship from this repository support Linux and macOS only: +The two plugins that ship from this repository support Linux and macOS only: | Plugin | Supported hosts | |---|---| | `docks` | Linux and macOS only | | `plan-lifecycle` | Linux and macOS only | -| `effect-kit` | Linux and macOS only | After install, the pipeline skills are user-invocable — ask "run a security audit", "refactor `src/`", or "audit my skills", or invoke `security` / `refactor` / `skill-agent-pipeline` directly. Every other skill auto-triggers by description match; namespacing is invisible at runtime. @@ -116,8 +115,7 @@ The complete contract lives in `docs/PLAN.md`. │ │ ├── .claude-plugin/plugin.json │ │ ├── skills/ ← cross-tool skills │ │ └── README.md ← plugin-facing docs -│ ├── plan-lifecycle/ ← GitHub-issue plan lifecycle plugin (three skills + plan.mjs + marker contract + two read-only reviewer wrappers) -│ └── effect-kit/ ← Effect-TS skill kit plugin +│ └── plan-lifecycle/ ← GitHub-issue plan lifecycle plugin (three skills + plan.mjs + marker contract + two read-only reviewer wrappers) ├── scripts/ ← plugin-author tooling (NOT shipped to users) │ ├── ci.mjs / release.mjs ← orchestrators (the gate ci.yml runs) │ ├── skills/guard.mjs, agents/guard.mjs + score.mjs @@ -153,7 +151,7 @@ Node 24 remains the validator runtime and matches CI's `node-version`; Bun 1.4.0 `--per-file` on a scorer prints one ` ` line per item — useful for spotting drift after an edit. `node scripts/ci.mjs` runs the full local gate (guards + scorers + manifest + idempotency); `ci.yml` runs that same file on CI. -On a PR to `main`, CI runs only the shards the changed paths resolve to — the repo-wide checks always, plus the lane owning any plugin you touched. On a `--v` release tag (docks, plan-lifecycle and effect-kit each tag independently), it runs the repo-wide shard plus that plugin's own gate. See `.github/workflows/ci.yml`; full trigger model below. +On a PR to `main`, CI runs only the shards the changed paths resolve to — the repo-wide checks always, plus the lane owning any plugin you touched. On a `--v` release tag (docks and plan-lifecycle tag independently), it runs the repo-wide shard plus that plugin's own gate. See `.github/workflows/ci.yml`; full trigger model below. ## Versioning + releases diff --git a/package.json b/package.json index 6655b1fe..87906523 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "ci": "node scripts/ci.mjs", "guard:skills": "node scripts/skills/guard.mjs plugins/docks/skills", "score:skills": "node plugins/docks/skills/productivity/write-skill/scripts/skill-guard.mjs score --per-file plugins/docks/skills", - "format:js": "biome format --write scripts tests plugins/effect-kit/test plugins/plan-lifecycle/test plugins/docks/hooks package.json biome.json", - "lint:js": "biome lint scripts tests plugins/effect-kit/test plugins/plan-lifecycle/test plugins/docks/hooks plugins/plan-lifecycle/skills/productivity/plan-manager/scripts plugins/docks/skills/productivity/write-skill/scripts", - "check:js": "biome ci scripts tests plugins/effect-kit/test plugins/plan-lifecycle/test plugins/docks/hooks package.json biome.json && biome lint plugins/plan-lifecycle/skills/productivity/plan-manager/scripts plugins/docks/skills/productivity/write-skill/scripts", + "format:js": "biome format --write scripts tests plugins/plan-lifecycle/test plugins/docks/hooks package.json biome.json", + "lint:js": "biome lint scripts tests plugins/plan-lifecycle/test plugins/docks/hooks plugins/plan-lifecycle/skills/productivity/plan-manager/scripts plugins/docks/skills/productivity/write-skill/scripts", + "check:js": "biome ci scripts tests plugins/plan-lifecycle/test plugins/docks/hooks package.json biome.json && biome lint plugins/plan-lifecycle/skills/productivity/plan-manager/scripts plugins/docks/skills/productivity/write-skill/scripts", "test:unit": "node --test --test-concurrency=4 scripts/tests/unit/*.test.mjs", "test:observability": "node scripts/tests/ci-observability.mjs", "test:contracts": "node scripts/tests/test-contracts.mjs", diff --git a/plugins/docks/skills/AGENTS.md b/plugins/docks/skills/AGENTS.md index 68ab4abd..abf28c78 100644 --- a/plugins/docks/skills/AGENTS.md +++ b/plugins/docks/skills/AGENTS.md @@ -77,8 +77,7 @@ sync). Under this tree, `refactor`, `security`, `context-tree`, and `skill-agent-pipeline` are lifecycle ROUTES: each carries one byte-identical absent-lifecycle prerequisite paragraph, asserted verbatim by `scripts/tests/plan-skill-phases.mjs` and the plan-lifecycle self-test. Change -that paragraph only in lockstep across all six routes (these four plus -effect-kit's `effect-ts-port` and `effect-ts-setup`) and both validators. +that paragraph only in lockstep across all four routes and both validators. ## Cross-tool wording (Claude Code + Codex) diff --git a/plugins/docks/skills/productivity/context-tree/SKILL.md b/plugins/docks/skills/productivity/context-tree/SKILL.md index ae5dfa58..33eb5f2a 100644 --- a/plugins/docks/skills/productivity/context-tree/SKILL.md +++ b/plugins/docks/skills/productivity/context-tree/SKILL.md @@ -4,8 +4,8 @@ description: "Use when a repo's root CLAUDE.md/AGENTS.md grew too large and per- user-invocable: true metadata: pattern: meta-skill - updated: "2026-08-20" - content_hash: "bb974165ae5cf79189297cd40b61580607e34956d1e9a5f12092ae28a8a5bbc7" + updated: "2026-08-24" + content_hash: "58e996cd6b48c0ed4ae199adaf0fc18fe8b12f096dd4551221158f280658da2c" --- # Context Tree — lazy per-folder AGENTS.md + CLAUDE.md diff --git a/plugins/docks/skills/productivity/context-tree/references/major-folder-heuristics.md b/plugins/docks/skills/productivity/context-tree/references/major-folder-heuristics.md index 34a2d72a..fac452c9 100644 --- a/plugins/docks/skills/productivity/context-tree/references/major-folder-heuristics.md +++ b/plugins/docks/skills/productivity/context-tree/references/major-folder-heuristics.md @@ -38,6 +38,6 @@ Prefer ONE node per major folder, not one per subfolder. Roll child conventions ## This repo's nodes (dogfood reference) -`docs/` · `plugins/docks/skills/` · `plugins/effect-kit/skills/` · `plugins/plan-lifecycle/skills/` · `scripts/` · `.github/`. The set drifts as plugins land — re-derive it with `find . -name CLAUDE.md -not -path "*/node_modules/*"` (every hit except the root file is a node). +`docs/` · `plugins/docks/skills/` · `plugins/plan-lifecycle/skills/` · `scripts/` · `.github/`. The set drifts as plugins land — re-derive it with `find . -name CLAUDE.md -not -path "*/node_modules/*"` (every hit except the root file is a node). Not a node: `plugins/docks/agents/` — `claude plugin validate --strict` lints every `*.md` under a plugin's `agents/` as a subagent, so an AGENTS.md pair there fails validation; its authoring rules live in the repo root file instead. A tool that lints every markdown file in a folder makes that folder node-ineligible — check before scaffolding. diff --git a/plugins/docks/skills/productivity/context-tree/references/node-template.md b/plugins/docks/skills/productivity/context-tree/references/node-template.md index fbd4e511..889b921e 100644 --- a/plugins/docks/skills/productivity/context-tree/references/node-template.md +++ b/plugins/docks/skills/productivity/context-tree/references/node-template.md @@ -65,7 +65,6 @@ Edit the node, not this list, when a folder's rules change. |---|---| | `docs/` | plan-record routing + issue-body contract | | `plugins/docks/skills/` | skill authoring + scoring | -| `plugins/effect-kit/skills/` | effect-kit skill authoring | | `plugins/plan-lifecycle/skills/` | plan-lifecycle skill authoring | | `scripts/` | validator / CI tooling contract | | `.github/` | CI workflow triggers | diff --git a/plugins/effect-kit/.claude-plugin/plugin.json b/plugins/effect-kit/.claude-plugin/plugin.json deleted file mode 100644 index ceb5d84f..00000000 --- a/plugins/effect-kit/.claude-plugin/plugin.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "effect-kit", - "description": "Cross-tool Effect skill kit: Effect 3.x setup, patterns, and porting plus version-gated Effect v4 beta guidance", - "version": "0.5.0", - "author": { - "name": "DocksDocks" - }, - "license": "MIT", - "skills": [ - "./skills/engineering" - ], - "dependencies": [ - { - "name": "docks", - "marketplace": "docks" - }, - { - "name": "plan-lifecycle", - "marketplace": "docks" - } - ] -} diff --git a/plugins/effect-kit/.codex-plugin/plugin.json b/plugins/effect-kit/.codex-plugin/plugin.json deleted file mode 100644 index bc75ab1c..00000000 --- a/plugins/effect-kit/.codex-plugin/plugin.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "effect-kit", - "version": "0.5.0", - "description": "Cross-tool Effect skill kit: Effect 3.x setup, patterns, and porting plus version-gated Effect v4 beta guidance", - "author": { - "name": "DocksDocks" - }, - "license": "MIT", - "skills": "./skills/", - "interface": { - "displayName": "effect-kit", - "shortDescription": "Effect 3.x skills plus version-gated Effect v4 beta guidance", - "category": "Productivity" - } -} diff --git a/plugins/effect-kit/skills/AGENTS.md b/plugins/effect-kit/skills/AGENTS.md deleted file mode 100644 index ea0802d5..00000000 --- a/plugins/effect-kit/skills/AGENTS.md +++ /dev/null @@ -1,15 +0,0 @@ -# Authoring skills (plugins/effect-kit/skills/) - -Skills are the cross-tool payload — each surfaces in Claude Code, Codex, and any agentskills.io runtime. Each skill is a directory `//SKILL.md` (+ optional `references/`). Sole category: `engineering/` (the Effect payload). Plan-lifecycle and authoring skills come from the sibling docks plugin — don't re-bundle them here. **Effect-only scope:** every skill in this plugin targets the Effect ecosystem (the `effect` package and official `@effect/*` / `@effect-atom/*` packages); non-Effect skills belong in docks or their own plugin. - -- Description starts "Use when …" (CSO); ≤500 chars for full scorer credit. -- `name` matches the parent directory; kebab-case. -- Body ≤500 lines (sweet spot 80–310) — every line loads on activation. -- During skill iteration, run the narrow validators and checks relevant to the change; after a meaningful batch, optionally run `node scripts/ci.mjs --plugin effect-kit`. Reserve full `node scripts/ci.mjs` for the final relevant implementation tree before commit, push, or release. -- After changing a skill's meaning, run `node scripts/skills/content-hash.mjs --backfill plugins/effect-kit/skills`. It re-syncs the hash and stamps `metadata.updated` together, and only for skills whose meaning actually changed. - -The existing skills (`engineering/effect-ts-setup`, `engineering/effect-ts-port`, `engineering/effect-ts-specialist`) target **Effect 3.x stable**. The separate `engineering/effect-v4` skill provides version-gated **Effect v4 beta/prerelease** guidance: it must inspect `package.json` and the lockfile before version-specific code and must never emit v4 APIs into an Effect 3.x project. Keep every version-specific API claim grounded in the installed package and current official sources. - -Adapted upstream skills use `metadata.pattern: upstream-adapted` and a `metadata.upstream` block recording repository, immutable commit, source path/URL, license, vendoring date, and intentional patches. Preserve useful progressive-disclosure references; never require mutable guidance downloads at skill runtime. - -Use the `write-skill` skill (from the sibling docks plugin) to author new skills from scratch. Full authoring contract (frontmatter, CSO, scoring, content-hash idempotency, durable-anchors grammar): see `plugins/docks/skills/AGENTS.md`. diff --git a/plugins/effect-kit/skills/CLAUDE.md b/plugins/effect-kit/skills/CLAUDE.md deleted file mode 100644 index 43c994c2..00000000 --- a/plugins/effect-kit/skills/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/SKILL.md b/plugins/effect-kit/skills/engineering/effect-ts-port/SKILL.md deleted file mode 100644 index 73a792a4..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-port/SKILL.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -name: effect-ts-port -description: "Use when porting existing Fastify, Next.js App Router, or React code to Effect 3.x — resolve package.json plus the lockfile, map scope, then migrate one boundary at a time with `Effect.tryPromise` and `ManagedRuntime`. Not for Effect v4 porting/migration (unsupported); use effect-v4 for Effect v4 code. Not for first-time Effect 3.x setup (use effect-ts-setup) or fresh Effect code (use effect-ts-specialist)." -user-invocable: true -metadata: - pattern: pipeline - updated: "2026-08-20" - content_hash: "7a713a5b1d3c48c5e1bf28716a9ccb1f67dd4d9d1896308da88db5cb4388bdb6" ---- - -# Effect-TS Port (cross-tool pipeline) - -Migrate an existing Fastify / Next.js / React codebase to Effect 3.x as one sequential pass: detect the framework, map the surface, resolve scope, write a tiered plan, then either report it or continue through manager review and port one boundary at a time with tests as the ratchet. Single-agent and cross-tool — no slash command, no subagent dispatch, no Plan Mode. Framework specifics live in `references/`; this body is the orchestration. Pattern mirrors the `security` / `refactor` pipelines. - - -Single-agent sequential, gated on the plan lifecycle — NOT Plan Mode. Run the phases IN ORDER, in THIS context. Phases 0–3 are read-only analysis. Route label and workspace setup to `plan-workspace`; the unified `plan-manager` owns canonical-plan creation, fresh review, lifecycle, implementation/delegation, verification, and finish/archive. A plan-only or assessment-only request stops after the reviewed migration plan. An implementation request continues into Phase 4 after the manager records the reviewed start checkpoint; no additional user lifecycle command is required. Hand each phase to `plan-manager` under its exact heading so a mid-run compaction resumes from the issue body. - - -Prerequisite: `plan-lifecycle` must be installed. If `plan-workspace` or `plan-manager` is unavailable, STOP, name the missing `plan-lifecycle` plugin, and do not create or mutate a plan. - - -Boundary-first, incremental — never a big-bang rewrite. Wrap existing Promise/throwing code with `Effect.tryPromise({ try, catch })`, run it through a single `ManagedRuntime` at the framework edge, and migrate the highest-value slice first, expanding outward (strangler-fig). Port ONE slice at a time: change it, run the type-checker + tests, and on failure REVERT immediately (`git restore`) and log `REVERTED: ` — do not try to "fix forward". The app stays green and shippable after every slice. - - - -Don't guess Effect APIs. First inspect `package.json` plus the lockfile or installed package and choose exactly one primary Effect skill for the current boundary: setup, port, v3 specialist, or v4 specialist. Do not load them as an umbrella. This port targets **Effect 3.x stable**; use its matching v3 references and verify unfamiliar APIs against installed source or current docs. If the task is fresh Effect code rather than a port, stop this skill and route to `effect-ts-specialist` instead. An explicit or resolved Effect 4.x port/migration is unsupported here; do not apply v3 patterns. `Schema` is `effect/Schema`; for HTTP prefer **`@effect/platform` HttpApi**; for React use **`@effect-atom/atom-react`**. - - -## When to use - -- A service or app on Fastify / Next.js App Router / React that you want on Effect, incrementally. -- You want a reviewable, tiered migration plan before any code changes — and tests guarding every slice. - -## When NOT to use - -| Situation | Use instead | -|---|---| -| Effect not installed / no tsconfig yet | `effect-ts-setup` (this pipeline runs its detection as Phase 0) | -| Writing new Effect code (no migration) | `effect-ts-specialist` | -| Generic dead-code / SOLID cleanup | `refactor` | -| Security review | `security` | - -## Pipeline - -Run in order. Each phase reads its reference (where listed), then hands its output to `plan-manager` for the plan issue under the exact heading (the resume anchor — keep it verbatim). - -| # | Phase | Reference | Output heading | -|---|---|---|---| -| 0 | Detection (framework, package manager, Effect present?) | — (inline) | `## Phase 0: Detection` | -| 1 | Surface map (entry points, async edges, shared deps) | `references/boundary-strategy.md` | `## Phase 1: Surface Map` | -| 2 | Scope resolution (derive defaults; ask once only if material ambiguity remains) | — (inline) | `## Phase 2: Scope` | -| 3 | Migration plan (tiered slices + test strategy) | framework reference(s) | `## Phase 3: Migration Plan` | -| — | **HANDOFF** — plan-only ends; implementation enters manager review | — | — | -| 4 | Implementation (one slice at a time, boundary-first) | framework reference(s) | `## Phase 4: Implementation Log` | -| 5 | Verification (type-check, tests, no scope bleed) | — (inline) | `## Phase 5: Verification` | - -## How to run each phase - -1. Anchor the date once (`date "+%Y-%m-%d"`); record scope (a path arg, or the whole project). -2. Ask `plan-manager` to create the canonical issue with `plan.mjs new --title --goal ` and own every lifecycle write. In a repository without a GitHub remote, use the untracked fallback below. Run Phases 0→3, handing each to the manager under its heading; confirm the prior heading landed before the next. A phase with nothing to report writes "none" — never silently skip. -3. At the HANDOFF, follow the request intent. An implementation request resumes at Phase 4 after the manager's reviewed start checkpoint; no user lifecycle command is required. - -## The plan record (IPC + deliverable) - -```text -GitHub issue # labeled plan, plan:drafting (created and managed by unified plan-manager) -docs/effect-port-.md (untracked fallback only when the repository has no GitHub remote) -``` - -Hand phase output to `plan-manager` as you go — never hold all of it in context and dump it at the end. - -The plan's `## Steps` table is the slice list. A `Mistakes & Dead Ends` subsection in `## Verification Results` records every `REVERTED:` slice so a resumed run skips known dead ends. - -## Phase 0 — Detection (inline) - -```bash -ls package.json tsconfig.json pnpm-lock.yaml bun.lock package-lock.json 2>/dev/null -``` - -Identify the framework(s) and whether Effect is already present (`grep '"effect"' package.json`). If Effect is absent, select **`effect-ts-setup`** as the sole primary Effect skill for that setup boundary, complete it, then re-resolve package and lock evidence before selecting this port skill; never load both as one umbrella pass. Record framework, package manager, and Effect presence under `## Phase 0: Detection`. - -| Signal | Framework | Primary reference | -|---|---|---| -| `fastify` in deps, `*.route.ts`, `fastify()` | Fastify | `references/fastify.md` | -| `next` in deps, `app/**/route.ts`, `"use server"` | Next.js App Router | `references/nextjs.md` | -| `react`/`react-dom`, `.tsx` components, hooks | React | `references/react.md` | - -## Phase 1 — Surface map - -Read `references/boundary-strategy.md`. Enumerate the edges where async/impure work happens — route handlers, server actions, data loaders, React event handlers/effects, external API/DB calls. For each, note the current error handling and what it depends on. This is the candidate slice list. Pick the **run boundary** (one `ManagedRuntime`) and the first pilot slice (highest value, lowest blast radius). - -## Phase 2 — Scope resolution - -Derive scope from the user's request and repository evidence before asking: - -1. **Surfaces** — use the explicitly named framework, route group, or component tree; a whole-app port request includes all detected surfaces. -2. **Depth** — default to *wrap* (keep the framework, run Effect inside handlers); use *replace* only when the request or existing architecture calls for `@effect/platform` HttpApi. -3. **Pilot** — default to one end-to-end slice before expanding. -4. **Constraints** — keep tests green by default; derive serverless/edge/runtime lifecycle from deployment config. - -Ask one bounded question only when unresolved surface or wrap-vs-replace choices would materially change the plan. Record the answer under `## Phase 2: Scope`, then continue; do not create a separate approval turn. - -## Phase 3 — Migration plan → HANDOFF - -Read the relevant framework reference(s). Write `## Phase 3: Migration Plan` in the report and populate the plan issue's `## Steps` table with ordered slices (each: file:line, wrap-or-replace, the Effect shape it becomes, test command, risk). Tier them: **(1)** shared boundary (the `ManagedRuntime` + base layers), **(2)** leaf slices (one handler/component), **(3)** structural (replace a router, lift state to atoms). For plan-only intent, report the reviewed plan issue and stop. For implementation intent, give the report to unified `plan-manager`; it files it in the issue, then continue automatically after its reviewed start checkpoint. - -## Phase 4 — Implementation (after the reviewed start checkpoint) - -1. Establish a baseline: run the type-checker + test suite. Note any pre-existing failures. -2. Build the **shared boundary first** (Tier 1): the `ManagedRuntime` from your `MainLive` layer, and the base services. Verify it compiles before touching any handler. -3. For each slice in tier order: read the framework reference, apply the boundary pattern, then run the type-checker + tests. On green, log `APPLIED: `; on failure, `git restore` the slice and log `REVERTED: ` in the `Mistakes & Dead Ends` subsection of `## Verification Results`, then continue. ONE slice per test cycle — never batch. -4. Keep ephemeral UI-local state in `useState`; lift shared/async/server state into Effect/atoms (React). Wrap, don't rewrite, until a slice is fully green. - -## Phase 5 — Verification (inline) - -Write `## Phase 5: Verification`: type-check clean, tests green (vs the Phase 4 baseline), and a scope check — every changed file must trace to a planned slice (`git diff --name-only` ⊆ the union of the plan's Steps `Files` cells). An out-of-scope change ⇒ `git restore` it. Report slices applied vs reverted, and any follow-up slices deferred to a new plan. - -## Framework references - -| Read for | File | -|---|---| -| Incremental strategy, the run boundary, what to port first, `Effect.tryPromise` | `references/boundary-strategy.md` | -| Fastify handlers (wrap) and `@effect/platform` HttpApi (replace) | `references/fastify.md` | -| HttpApi current DSL — declare / implement / serve / client / error-status mapping | `references/http-api.md` | -| `@effect/rpc` typed RPC (tRPC replacement): contract, HTTP serve, client | `references/effect-rpc.md` | -| Next.js App Router route handlers, server actions, module-scope runtime | `references/nextjs.md` | -| React via `@effect-atom/atom-react` (atoms, `Result`, `Atom.runtime`, SSR/lifecycle) | `references/react.md` | - -## Boundary pattern — the mistake that breaks ports - -```ts -// BAD — a fresh runtime per request: every layer (pools, clients) is rebuilt and leaked -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params // Next 15+: params is a Promise - return Response.json(await Effect.runPromise(getUser(id).pipe(Effect.provide(MainLive)))) -} -// GOOD — one module-scope ManagedRuntime; handlers run through it and stay R-free -import { runtime } from "@/lib/runtime" // ManagedRuntime.make(MainLive), built once -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - return Response.json(await runtime.runPromise(getUser(id))) -} -``` - -## Gotchas - -| Gotcha | Consequence | Right move | -|---|---|---| -| Big-bang rewrite of all handlers at once | Can't tell which slice broke; app un-shippable | One slice → type-check + test → keep/revert | -| `Effect.runPromise` inside every handler (new runtime each call) | Layers rebuilt per request; pools leak | One module-scope `ManagedRuntime`; `runtime.runPromise` per call | -| Targeting `effect-http` for HTTP | Deprecated since 2024 | `@effect/platform` HttpApi | -| Using `@effect-rx/rx-react` for React | Renamed/superseded | `@effect-atom/atom-react` | -| Editing code during Phases 0–3 | Invalidates the input before review | Keep analysis read-only; implementation begins after the manager's reviewed start checkpoint | -| Module-scope runtime on edge/serverless without a caveat | Cold-start surprises | Note the deploy target in Phase 2; see the references | -| Assuming a GitHub plan issue is available in a repository with no GitHub remote | The report cannot be filed | Use the untracked fallback only for that repository | - -## When this skill does NOT apply - -- The requested target or resolved dependency is Effect v4 — v4 porting/migration is unsupported in this release. -- Effect isn't set up yet — run **`effect-ts-setup`** (Phase 0 will send you there). -- You're authoring new Effect code, not migrating — use **`effect-ts-specialist`**. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/references/boundary-strategy.md b/plugins/effect-kit/skills/engineering/effect-ts-port/references/boundary-strategy.md deleted file mode 100644 index 9153213c..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-port/references/boundary-strategy.md +++ /dev/null @@ -1,64 +0,0 @@ -# Boundary Strategy — incremental adoption - -You don't rewrite an app into Effect; you grow Effect outward from the edges. The whole strategy is three ideas: **wrap impure code at the boundary**, **run through one runtime**, and **migrate one slice at a time** so the app is green after every step (strangler-fig). - -## 1. The run boundary — one `ManagedRuntime` - -Build a single runtime from your composed layer at module scope, and run every Effect through it at each framework entry point. This is the seam every framework reference plugs into. - -```ts -// lib/runtime.ts — built once, reused -import { ManagedRuntime } from "effect" -import { MainLive } from "./layers" // Layer composing your services (Db, Config, etc.) - -export const runtime = ManagedRuntime.make(MainLive) -// on shutdown / HMR teardown: await runtime.dispose() -``` - -`runtime.runPromise(effect)` / `runtime.runPromiseExit(effect)` run an effect with every service in `MainLive` already provided — so handlers and components stay `R`-free. NEVER call `Effect.runPromise` (a fresh runtime) per request: it rebuilds layers and leaks pooled resources. - -## 2. Wrap impure code — `Effect.tryPromise` - -Existing Promise/throwing code becomes an Effect at the boundary, with the failure typed: - -```ts -import { Effect, Data } from "effect" -class DbError extends Data.TaggedError("DbError")<{ cause: unknown }> {} - -// existing: async function getUser(id) { return db.user(id) } -const getUser = (id: string) => - Effect.tryPromise({ try: () => db.user(id), catch: (cause) => new DbError({ cause }) }) -``` - -Start by wrapping the leaf calls (DB, HTTP, fs), then compose them with `Effect.gen`. You don't have to convert the whole call tree at once — an Effect that calls a still-Promise function via `tryPromise` is perfectly valid. - -## 3. What to port first - -| Order | Target | Why | -|---|---|---| -| 1 | The shared boundary: `ManagedRuntime` + base layers (Config, Db, Logger) | Everything else depends on it; get it compiling alone | -| 2 | One leaf slice end-to-end (a single handler/loader + its services) | Proves the pattern with minimal blast radius — the pilot | -| 3 | Remaining leaf slices, one at a time | Each is independent; tests guard each | -| 4 | Structural moves (replace a router with HttpApi, lift state to atoms) | Higher risk; do after the leaves are stable | - -Prefer the highest-complexity/highest-value leaf as the pilot — that's where Effect's error/dependency typing pays off first. - -## 4. Stay green - -- One slice per test cycle: change → type-check → test → keep or `git restore`. -- Map typed errors to the framework's response at the boundary (status code, error shape) — see each framework reference. -- Keep ephemeral UI/local state as-is; only lift shared/async/server state into Effect (or atoms in React). -- A slice that fights you for more than one revert is a sign the boundary is wrong — re-map it in the plan rather than forcing it. - -## 5. Map errors at the edge - -```ts -import { Cause, Exit } from "effect" -const exit = await runtime.runPromiseExit(getUser(id)) -if (Exit.isSuccess(exit)) return ok(exit.value) -// inspect the typed failure to choose a status: -const failure = Cause.failureOption(exit.cause) // Option -// match on failure.value._tag → 404 / 400 / 503 ; defects (Cause.isDie) → 500 + log -``` - -This `runPromiseExit` + `Cause`/`Exit` inspection is the portable error-mapping idiom reused by the Fastify and Next.js references. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/references/effect-rpc.md b/plugins/effect-kit/skills/engineering/effect-ts-port/references/effect-rpc.md deleted file mode 100644 index 0b8910ad..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-port/references/effect-rpc.md +++ /dev/null @@ -1,92 +0,0 @@ -# @effect/rpc — typed effectful RPC (the tRPC-replacement slice) - -When the app carries a tRPC-style internal API layer, `@effect/rpc` gives the same end-to-end typing with Effect semantics: Schema-typed payload/success/error per procedure, streaming built in, and a derived client. Plan it as a structural (Tier-3) slice. - -> Verified against `@effect/rpc` 0.75.1 (2026-07-05; peers `@effect/platform ^0.96.1` + `effect ^3.21.2` — the trio moves in lockstep, upgrade together). The canonical doc is the package README (`Effect-TS/effect` → `packages/rpc`) — effect.website has no rpc page; re-verify shapes there before writing code. - -## Contract (shared between server & client) - -```ts -import { Rpc, RpcGroup } from "@effect/rpc" -import { Schema } from "effect" - -export class UserRpcs extends RpcGroup.make( - Rpc.make("UserList", { success: User, stream: true }), // stream: true → client gets a Stream - Rpc.make("UserById", { - success: User, - error: Schema.String, // typed error channel - payload: { id: Schema.String }, // payload is a plain record of Schema fields - }), - Rpc.make("UserCreate", { success: User, payload: { name: Schema.String } }), -) {} -``` - -## Server — implement the group as a Layer - -```ts -export const UsersLive = UserRpcs.toLayer( - Effect.gen(function* () { - const db = yield* UserRepository - return { - UserList: () => Stream.fromIterableEffect(db.findMany), // stream rpc returns a Stream - UserById: ({ id }) => db.findById(id), // unary rpc returns an Effect - UserCreate: ({ name }) => db.create(name), - } - }), -).pipe(Layer.provide(UserRepository.Default)) -``` - -## Serve over HTTP (Node) - -```ts -import { HttpRouter } from "@effect/platform" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" -import { RpcSerialization, RpcServer } from "@effect/rpc" -import { createServer } from "node:http" - -const RpcLayer = RpcServer.layer(UserRpcs).pipe(Layer.provide(UsersLive)) -const HttpProtocol = RpcServer.layerProtocolHttp({ path: "/rpc" }).pipe( - Layer.provide(RpcSerialization.layerNdjson), // no-framing protocols; layerJson when framing exists -) -const Main = HttpRouter.Default.serve().pipe( - Layer.provide(RpcLayer), - Layer.provide(HttpProtocol), - Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), -) -NodeRuntime.runMain(Layer.launch(Main)) -``` - -(The README's own example serves on Bun — `BunHttpServer.layer({ port })` from `@effect/platform-bun`; the Node swap above reuses the platform README's `NodeHttpServer.layer(createServer, { port })` serve form.) - -Next.js route handler: there is no official recipe yet. The source-verified building block is `RpcServer.toWebHandler(group, { layer }) → { handler: (Request) => Promise, dispose }` — mount `handler` as the route's `POST`. Treat as an adaptation and re-verify against the current README before shipping. - -## Client - -```ts -import { FetchHttpClient } from "@effect/platform" -import { RpcClient, RpcSerialization } from "@effect/rpc" - -const ProtocolLive = RpcClient.layerProtocolHttp({ url: "http://localhost:3000/rpc" }).pipe( - Layer.provide([FetchHttpClient.layer, RpcSerialization.layerNdjson]), // must match the server -) -const program = Effect.gen(function* () { - const client = yield* RpcClient.make(UserRpcs) // typed client derived from the group - yield* client.UserCreate({ name: "Charlie" }) - return yield* Stream.runCollect(client.UserList({})) -}).pipe(Effect.scoped) // RpcClient.make is scoped -program.pipe(Effect.provide(ProtocolLive), Effect.runPromise) -``` - -## Middleware (auth) - -`RpcMiddleware.Tag` classes attach per-RPC (`Rpc.make(…).middleware(AuthMiddleware)`) or group-wide; the server implementation provides e.g. `CurrentUser` from headers, and the client side (`RpcMiddleware.layerClient`) injects the auth header into outgoing requests. Full shapes: README §middleware. - -## Gotchas - -| Gotcha | Right move | -|---|---| -| Client/server serialization mismatch | Same `RpcSerialization.layer*` on both sides | -| Unscoped client usage | `RpcClient.make` is scoped — `Effect.scoped` the program, or wrap in an `Effect.Service` with `scoped:` | -| Writing `payload: Schema.Struct({…})` | The current form is a plain record of Schema fields | -| Bumping rpc alone | Peers pin `@effect/platform` — upgrade the trio together | -| Calling RPCs from React components | `AtomRpc.Tag` wraps the client as atoms — see `react.md` | diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/references/fastify.md b/plugins/effect-kit/skills/engineering/effect-ts-port/references/fastify.md deleted file mode 100644 index 631c3496..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-port/references/fastify.md +++ /dev/null @@ -1,82 +0,0 @@ -# Fastify → Effect - -Two tracks. **Wrap** keeps Fastify and runs Effects inside handlers — the low-risk incremental default. **Replace** swaps Fastify for `@effect/platform` HttpApi — more work, but you gain a typed client + OpenAPI + Schema validation for free. Most migrations do Wrap first and Replace later (per route group). - -## Track A — Wrap (keep Fastify, run Effect inside) - -```ts -import Fastify from "fastify" -import { Cause, Exit } from "effect" -import { runtime } from "./lib/runtime" // one ManagedRuntime (see boundary-strategy.md) - -const app = Fastify() - -app.get("/users/:id", async (req, reply) => { - const exit = await runtime.runPromiseExit(getUser((req.params as { id: string }).id)) - if (Exit.isSuccess(exit)) return reply.send(exit.value) - - const failure = Cause.failureOption(exit.cause) // Option - if (failure._tag === "Some") { - switch (failure.value._tag) { - case "UserNotFound": return reply.code(404).send({ error: "not found" }) - case "DbError": return reply.code(503).send({ error: "unavailable" }) - } - } - reply.code(500).send({ error: "internal" }) // defect - return reply -}) - -app.addHook("onClose", async () => { await runtime.dispose() }) -``` - -One slice = one route. Validate the request body with Schema before handing it to the effect: - -```ts -import { Schema } from "effect" -const CreateUser = Schema.Struct({ name: Schema.String, email: Schema.String }) -app.post("/users", async (req, reply) => { - const exit = await runtime.runPromiseExit( - Schema.decodeUnknown(CreateUser)(req.body).pipe(Effect.flatMap(createUser)) - ) - // ParseError → 400; map as above -}) -``` - -A small `respond(reply, exit)` helper centralizes the `Exit`/`Cause` → status mapping so each route stays a one-liner. - -## Track B — Replace (Fastify → @effect/platform HttpApi) - -`@effect/platform` HttpApi declares the API once (endpoints + Schema), then gives you a server implementation, a type-safe client, and an OpenAPI doc. Shape: - -```ts -import { HttpApi, HttpApiGroup, HttpApiEndpoint, HttpApiBuilder } from "@effect/platform" -import { Schema } from "effect" - -// 1. declare the spec (endpoints carry Schema for path/body/success/error) -const UsersApi = HttpApi.make("api").add( - HttpApiGroup.make("users") - .add(HttpApiEndpoint.get("getUser", "/users/:id") - .setPath(Schema.Struct({ id: Schema.String })) // gives the handler a typed `path.id` - .addSuccess(User)) - .add(HttpApiEndpoint.post("createUser", "/users").setPayload(CreateUser).addSuccess(User)), -) - -// 2. implement each group with HttpApiBuilder — handlers are Effects that use your services -const UsersLive = HttpApiBuilder.group(UsersApi, "users", (handlers) => - handlers - .handle("getUser", ({ path }) => getUser(path.id)) - .handle("createUser", ({ payload }) => createUser(payload)), -) - -// 3. serve (Node): HttpApiBuilder.api(UsersApi) + a platform HttpServer layer -``` - -> The exact HttpApi DSL (method chains, `HttpApiSchema.param`, error mapping) moves between `@effect/platform` minors — current verified shapes (declare / implement / serve / client / errors) live in `references/http-api.md`; re-verify via context7 (`@effect/platform`) before writing it. Tagged errors added with `.addError(...)` (or a status annotation on the error class) map to HTTP status codes automatically; unannotated errors default to 500. - -`effect-http` (sukovanej) was the precursor and is **deprecated** (2024) in favor of HttpApi — don't target it. Migration of names: `Api`→`HttpApi`, `ApiGroup`→`HttpApiGroup`, `ApiEndpoint`→`HttpApiEndpoint`, `RouterBuilder`→`HttpApiBuilder`. - -## Choosing per route group - -- Hot path you can't risk → **Wrap**, ship, move on. -- A route group you're already reworking, or one that needs a typed client/OpenAPI → **Replace** with HttpApi. -- You can mix during a transition, but there is no official Fastify mount for platform web handlers (`(Request) => Promise` vs Fastify's Node req/reply) — run replaced groups on their own server/prefix, or write a small manual bridge. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/references/http-api.md b/plugins/effect-kit/skills/engineering/effect-ts-port/references/http-api.md deleted file mode 100644 index 5a203ad9..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-port/references/http-api.md +++ /dev/null @@ -1,113 +0,0 @@ -# @effect/platform HttpApi (Track B target) - -Declarative HTTP: define endpoints once with Schema and the same definition yields the server implementation, a typed client, and OpenAPI docs. This is the current-DSL companion to `fastify.md`'s Track B sketch — and works standalone for any Node HTTP surface. - -> Verified against `@effect/platform` 0.96.2 (2026-07-05). The platform docs classify HttpApi as an **Unstable** module within Effect 3.x — the DSL moves between platform minors, so re-verify shapes against the package README (`Effect-TS/effect` → `packages/platform`) before writing code; effect.website deliberately defers HttpApi docs to that README. - -## Contents - -- [Declare — the spec is endpoints + Schema](#declare--the-spec-is-endpoints--schema) -- [Errors → status codes](#errors--status-codes) -- [Implement — handlers are Effects](#implement--handlers-are-effects) -- [Serve (Node)](#serve-node) -- [Typed client (free)](#typed-client-free) -- [Auth / middleware (sketch)](#auth--middleware-sketch) -- [Gotchas](#gotchas) - -## Declare — the spec is endpoints + Schema - -```ts -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform" -import { Schema } from "effect" - -// path params, two equivalent styles: -const idParam = HttpApiSchema.param("id", Schema.NumberFromString) -const getUser = HttpApiEndpoint.get("getUser")`/users/${idParam}`.addSuccess(User) -// or a ":id" string path + an explicit path schema: -const getUserAlt = HttpApiEndpoint.get("getUser", "/users/:id") - .setPath(Schema.Struct({ id: Schema.NumberFromString })) - .addSuccess(User) - -const createUser = HttpApiEndpoint.post("createUser", "/users") - .setPayload(Schema.Struct({ name: Schema.String })) - .addSuccess(User, { status: 201 }) - .addError(UserNotFound, { status: 404 }) - -const api = HttpApi.make("api").add( - HttpApiGroup.make("users").add(getUser).add(createUser).prefix("/v1"), -) -``` - -Methods in the current README: `get` / `post` / `patch` / `del` (not `delete`); catch-all path `"*"`. Also `.setUrlParams(Schema.Struct({…}))` for query strings (repeated params via `Schema.Array`) and `.setHeaders(…)`. `.prefix()` exists at endpoint, group, and api level. - -## Errors → status codes - -```ts -class Unauthorized extends Schema.TaggedError()( - "Unauthorized", {}, HttpApiSchema.annotations({ status: 401 }) -) {} -// or bind at the endpoint: .addError(UserNotFound, { status: 404 }) -``` - -Defaults: success **200** (**204** for void), unannotated errors **500**, request-validation failures → `HttpApiDecodeError` (**400**). The `HttpApiError` module ships prebuilt classes (`NotFound`, `Unauthorized`, `Conflict`, …) usable as `.addError(HttpApiError.NotFound)`. - -## Implement — handlers are Effects - -```ts -import { HttpApiBuilder } from "@effect/platform" - -const UsersLive = HttpApiBuilder.group(api, "users", (handlers) => - handlers - .handle("getUser", ({ path: { id } }) => getUserById(id)) - .handle("createUser", ({ payload }) => createUserFn(payload)), -) -const ApiLive = HttpApiBuilder.api(api).pipe(Layer.provide(UsersLive)) -``` - -The handler input carries only the keys whose schema you set (`path`, `payload`, `urlParams`, `headers`) plus always `request` (the raw `HttpServerRequest`). The group callback may be an `Effect.gen` that yields services before returning the handlers. - -## Serve (Node) - -```ts -import { HttpApiBuilder, HttpApiSwagger, HttpMiddleware, HttpServer } from "@effect/platform" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" -import { createServer } from "node:http" - -const HttpLive = HttpApiBuilder.serve(HttpMiddleware.logger).pipe( - Layer.provide(HttpApiSwagger.layer({ path: "/docs" })), // OpenAPI UI — "/docs" is the default path - Layer.provide(HttpApiBuilder.middlewareCors()), - Layer.provide(ApiLive), - HttpServer.withLogAddress, - Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), -) -Layer.launch(HttpLive).pipe(NodeRuntime.runMain) -``` - -For serverless / web-standard runtimes, `HttpApiBuilder.toWebHandler` yields a `(Request) => Promise` — the Next.js seam used in `nextjs.md`. - -## Typed client (free) - -```ts -import { FetchHttpClient, HttpApiClient } from "@effect/platform" - -const users = Effect.gen(function* () { - const client = yield* HttpApiClient.make(api, { baseUrl: "http://localhost:3000" }) - return yield* client.users.getUser({ path: { id: 1 } }) -}).pipe(Effect.provide(FetchHttpClient.layer)) -``` - -Call shape is `client..({ path, payload, urlParams, … })`; it needs an `HttpClient` layer (e.g. `FetchHttpClient.layer`). - -## Auth / middleware (sketch) - -`HttpApiMiddleware.Tag` classes attach with `.middleware(…)` at endpoint, group, or api level; `HttpApiSecurity.bearer` / `.apiKey` / `.basic` declare auth schemes — the bearer token arrives as a `Redacted` value, and `provides:` hands e.g. `CurrentUser` to downstream handlers. Full shapes: the platform README §Middleware. - -## Gotchas - -| Gotcha | Right move | -|---|---| -| `:id` path without `.setPath` | Handler gets no typed `path.id` — set the path schema, or use the template-literal + `HttpApiSchema.param` style | -| `HttpApiEndpoint.delete` | It's `del` | -| `PUT` endpoints | Not demonstrated in the current README — verify against the typings before using | -| Mixed-case keys in a headers schema | Headers are normalized to lowercase; use lowercase keys only | -| Calling the API from React components | `AtomHttpApi.Tag` wraps `HttpApiClient` as atoms — see `react.md` | diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/references/nextjs.md b/plugins/effect-kit/skills/engineering/effect-ts-port/references/nextjs.md deleted file mode 100644 index b08cd4f0..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-port/references/nextjs.md +++ /dev/null @@ -1,86 +0,0 @@ -# Next.js App Router → Effect - -The App Router calls your code (route handlers, server actions) — you don't own the entry point, so the `ManagedRuntime`-at-module-scope pattern is exactly right. Two integration depths: run effects *inside* a handler (simple), or hand a whole `HttpApp` to a web handler (full platform). - -## The shared runtime (once) - -```ts -// lib/runtime.ts -import { ManagedRuntime } from "effect" -import { MainLive } from "./layers" -export const runtime = ManagedRuntime.make(MainLive) -``` - -Import this from every route/action. It's reused across invocations within a warm server instance. - -## Route handler — run inside (simple) - -```ts -// app/api/users/[id]/route.ts -import { Cause, Exit } from "effect" -import { runtime } from "@/lib/runtime" - -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params // Next 15+: params is a Promise - const exit = await runtime.runPromiseExit(getUser(id)) - if (Exit.isSuccess(exit)) return Response.json(exit.value) - const f = Cause.failureOption(exit.cause) - const status = f._tag === "Some" && f.value._tag === "UserNotFound" ? 404 : 500 - return Response.json({ error: f._tag === "Some" ? f.value._tag : "internal" }, { status }) -} -``` - -Validate the body with Schema before the effect runs: - -```ts -import { Schema } from "effect" -export async function POST(req: Request) { - const body = await req.json() - const exit = await runtime.runPromiseExit( - Schema.decodeUnknown(CreateUser)(body).pipe(Effect.flatMap(createUser)), - ) - // ParseError → 400 ; success → 201 -} -``` - -## Route handler — web handler (full platform) - -When the route is a whole `HttpApp` (built from `@effect/platform` `HttpApiBuilder` or `HttpRouter`), convert it to a runtime-bound web handler: - -```ts -import { HttpApp } from "@effect/platform" -import { runtime } from "@/lib/runtime" - -// toWebHandlerRuntime takes a plain Runtime — unwrap the ManagedRuntime first (async) -const handlerPromise = runtime - .runtime() - .then((rt) => HttpApp.toWebHandlerRuntime(rt)(httpApp)) // (req: Request) => Promise -export const POST = async (req: Request) => (await handlerPromise)(req) -// inside httpApp: HttpServerRequest.schemaBodyJson(CreateUser) parses+validates the body, -// HttpServerResponse.json(...) responds. Automatic tagged-error → status mapping is an HttpApi -// feature (declare `.addError(E, { status })`); a plain HttpRouter needs explicit mapping. -``` - -## Server actions - -```ts -"use server" -import { runtime } from "@/lib/runtime" - -export async function createUserAction(form: FormData) { - return runtime.runPromise(handleCreateUser(form)) // throws → Next surfaces it; or runPromiseExit to handle -} -``` - -Same runtime, same pattern — the action body is an Effect run through `runtime`. - -## Caveats (state in Phase 2 scope) - -- **Serverless / cold starts**: the module-scope runtime is reused within a warm lambda but rebuilt on cold start. Keep `MainLive` construction cheap; lazy-init expensive resources inside services. -- **Edge runtime**: not all Node platform layers run on the edge — use `@effect/platform` (web) layers, not `@effect/platform-node`, for edge routes. -- **Dev HMR**: a module-scope runtime can survive HMR and leak; guard with a `globalThis` singleton in dev, and `runtime.dispose()` on teardown. -- **RPC alternative**: `@effect/rpc` can replace a tRPC layer in the App Router (a typed effectful RPC client) — a Track-B-style move, plan it as a structural slice; contract + serve + client shapes: `references/effect-rpc.md`. - -## Slicing - -One route file or one server action = one slice. Build `lib/runtime.ts` + `MainLive` first (Tier 1), then convert leaf routes one at a time, each guarded by its test (or a request smoke test). diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/references/react.md b/plugins/effect-kit/skills/engineering/effect-ts-port/references/react.md deleted file mode 100644 index f7fe685e..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-port/references/react.md +++ /dev/null @@ -1,143 +0,0 @@ -# React → Effect (effect-atom) - -The React binding is **`@effect-atom/atom-react`** (`tim-smart/effect-atom`, MIT — the successor to `@effect-rx/rx-react`). Atoms are fine-grained reactive cells with first-class Effect integration: an Effect-backed atom surfaces a `Result` (Initial / Success / Failure), so loading and error states are modeled, not improvised. - -## Contents - -- [Install + (optional) provider](#install--optional-provider) -- [Basic state + hooks](#basic-state--hooks) -- [Server/async state — Effect-backed atoms + `Result`](#serverasync-state--effect-backed-atoms--result) -- [Services & layers in components — `Atom.runtime`](#services--layers-in-components--atomruntime) -- [Actions / mutations — `Atom.fn`](#actions--mutations--atomfn) -- [Lifecycle, registry & SSR](#lifecycle-registry--ssr) -- [Migration map](#migration-map) -- [Slicing](#slicing) - -## Install + (optional) provider - -```bash -pnpm add @effect-atom/atom-react # peers: effect ^3.19, react >=18 <20 — moves; - # verify: npm info @effect-atom/atom-react peerDependencies -``` - -No provider is required — hooks fall back to a default registry. Wrap with `RegistryProvider` only for SSR initial values or custom GC config: - -```tsx -import { RegistryProvider } from "@effect-atom/atom-react" -{children} -``` - -`atom-react` re-exports `Atom`, `Result`, `Registry` — import everything from it. - -## Basic state + hooks - -```tsx -import { Atom, useAtom, useAtomValue, useAtomSet } from "@effect-atom/atom-react" - -const countAtom = Atom.make(0) - -function Counter() { - const [count, setCount] = useAtom(countAtom) // [value, setter]; setCount(c => c + 1) - // or split: const count = useAtomValue(countAtom); const setCount = useAtomSet(countAtom) -} -``` - -Derived/computed atoms take a getter; they recompute when any `get(...)` dependency changes: - -```tsx -const doubledAtom = Atom.make((get) => get(countAtom) * 2) -const label = Atom.map(countAtom, (n) => `Count: ${n}`) -``` - -Atoms are ephemeral by default — when the last subscriber unmounts, the state resets; wrap with `Atom.keepAlive` when it must survive (e.g. across route changes). - -## Server/async state — Effect-backed atoms + `Result` - -```tsx -import { Atom, Result, useAtomValue } from "@effect-atom/atom-react" -import { Effect, Schema } from "effect" - -const userAtom = Atom.make((get) => - Effect.gen(function* () { - const id = get(userIdAtom) - const res = yield* Effect.tryPromise(() => fetch(`/api/users/${id}`)) - return yield* Schema.decodeUnknown(User)(yield* Effect.tryPromise(() => res.json())) - }), -) // value type is Result; re-runs automatically when userIdAtom changes - -function UserCard() { - const result = useAtomValue(userAtom) - return Result.match(result, { - onInitial: () => , - onFailure: (f) => , - onSuccess: (s) =>
{s.value.name}
, - }) -} -``` - -`success.waiting` is the stale-while-revalidate flag (a refresh is in flight but the old value still shows). There's also a fluent `Result.builder(result).onInitial(...).onErrorTag("NotFound", ...).onSuccess(...).render()`. - -## Services & layers in components — `Atom.runtime` - -```tsx -import { Atom } from "@effect-atom/atom-react" -import { Effect } from "effect" - -const runtimeAtom = Atom.runtime(Users.Default) // bridge an Effect Layer into atoms -const usersAtom = runtimeAtom.atom( - Effect.gen(function* () { return yield* (yield* Users).getAll }), -) // Atom> -// global layers once: Atom.runtime.addGlobalLayer(LoggerLive) -``` - -## Actions / mutations — `Atom.fn` - -```tsx -import { Atom, useAtomSet } from "@effect-atom/atom-react" -const createUserAtom = runtimeAtom.fn( - Effect.fnUntraced(function* (name: string) { return yield* (yield* Users).create(name) }), -) -function NewUser() { - const create = useAtomSet(createUserAtom, { mode: "promiseExit" }) - const onSubmit = async (name: string) => { - const exit = await create(name) // Promise> - if (exit._tag === "Success") { /* ... */ } - } -} -``` - -Keyed atoms: `const todoAtom = Atom.family((id: string) => runtimeAtom.atom(getTodo(id)))` → `useAtomValue(todoAtom(id))`. - -## Lifecycle, registry & SSR - -- **Idle disposal:** with no provider, the default registry keeps unused (zero-subscriber) atom state for 400 ms; under a `RegistryProvider` there is NO default TTL — unused atoms dispose immediately unless you pass `defaultIdleTTL` (ms). Per-atom: `Atom.setIdleTTL(duration)`; `Atom.keepAlive` opts out of disposal entirely (`Atom.autoDispose` reverts it). -- **SSR/hydration:** mark atoms `Atom.serializable({ key, schema })`, `Hydration.dehydrate(registry)` on the server, `` on the client — imported from the subpath `@effect-atom/atom-react/ReactHydration` (NOT re-exported from the index). No official end-to-end Next.js recipe exists yet — verify the flow against the effect-atom source before relying on it. -- **Client bridges:** `AtomHttpApi.Tag` wraps an `@effect/platform` `HttpApiClient`, `AtomRpc.Tag` wraps an `@effect/rpc` client — both expose `.query(…)`/`.mutation(…)` atoms with `reactivityKeys` invalidation (the component-side counterpart of `http-api.md` / `effect-rpc.md`): - -```tsx -class CountClient extends AtomHttpApi.Tag()("CountClient", { - api: Api, httpClient: FetchHttpClient.layer, baseUrl: "http://localhost:3000", -}) {} -const count = useAtomValue(CountClient.query("counter", "count", { reactivityKeys: ["count"] })) -const increment = useAtomSet(CountClient.mutation("counter", "increment")) -``` - -- **Outside React:** `Registry.make(…)` + `registry.get/set/subscribe` drive the same atoms imperatively (tests, scripts). - -## Migration map - -| Today | With effect-atom | -|---|---| -| `useState` for **local/ephemeral UI** (form input, toggles) | **Keep it** — atoms are for shared/async/server state | -| `useState`/`useReducer` for **shared** state | `Atom.make(value)` + `useAtom`/`useAtomValue`/`useAtomSet` | -| Selectors / `useMemo` over store | derived `Atom.make((get) => ...)` / `Atom.map` | -| React Query / SWR query | `runtimeAtom.atom(effect).pipe(Atom.withReactivity(keys))`; `Result` replaces `{isLoading,isError,data}` | -| React Query mutation + `invalidateQueries` | `runtimeAtom.fn(effect, { reactivityKeys })` — completing it refreshes matching query atoms | -| Optimistic update boilerplate | `Atom.optimistic` / `Atom.optimisticFn` (auto-rollback on failure) | -| Zustand / Jotai store | atoms (same mental model; `useAtom` trio mirrors Jotai) | - -## Slicing - -Lift one piece of shared/server state into an atom at a time, leaving `useState` for local concerns. A component reading an Effect-backed atom doesn't need a `ManagedRuntime` in the tree — `Atom.runtime(Layer)` carries the services. One atom (+ its consumers) per slice; the component renders the three `Result` states explicitly. - -> effect-atom is pre-1.0 (0.5.x) — the API is settling. Verify hook/combinator names against the current docs (atom.kitlangton.com examples, or `tim-smart/effect-atom`) before relying on a less-common one. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-setup/SKILL.md b/plugins/effect-kit/skills/engineering/effect-ts-setup/SKILL.md deleted file mode 100644 index ecd0d05f..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-setup/SKILL.md +++ /dev/null @@ -1,162 +0,0 @@ ---- -name: effect-ts-setup -description: "Use when bootstrapping Effect 3.x in a repo — resolve package.json plus the lockfile, install `effect` and relevant official packages, wire `@effect/language-service`, merge tsconfig, add typecheck, and write Effect conventions. Complete an explicit setup request as one bounded change. Not for Effect v4 setup (unsupported), Effect v4 code (use effect-v4), Fastify/Next/React porting (use effect-ts-port), or existing Effect code (use effect-ts-specialist)." -user-invocable: true -metadata: - pattern: tool-wrapper - updated: "2026-08-03" - content_hash: "7dec8850070f20b1058fdb4d2d96b5d6d9574a1302649dab9a3bfe0659df7559" ---- - -# Effect-TS Setup (one-time repo bootstrap) - -Configure a repository to work well with Effect: the right dependencies, the language-service diagnostics, a strict-enough tsconfig, a type-check script, and an agent-instruction block so future AI sessions know the conventions. Modeled on Kit Langton's effect.solutions agent-guided setup, but self-contained — it needs no external CLI (and opportunistically uses `effect-solutions` when present). - - -An explicit setup request authorizes this bounded local bootstrap. Detect and merge first, then complete the applicable install, configuration, agent-context, and verification steps in one workflow; do not stop for confirmation before each command or file write. Ask once only when multiple/no lockfiles leave the package manager unresolved or repository evidence cannot determine a materially different project type. After that answer, continue without another confirmation loop. A plan-only request reports the reviewed setup plan and stops; an implementation request executes it. - - - -Resolve `package.json` plus the lockfile (or installed package) before selecting guidance, then use exactly one primary Effect skill for the current boundary: this skill for an explicit Effect 3.x bootstrap, `effect-ts-port` for an existing Fastify/Next/React migration, `effect-ts-specialist` for resolved 3.x code, or `effect-v4` for resolved/explicit v4 code. Do not load them as an umbrella. This setup targets **Effect 3.x stable**; v4 setup remains unsupported. Install with no version pin only while the package manager's current default resolves 3.x. `Schema` lives in **`effect/Schema`** — NEVER install `@effect/schema`. The optional source reference is **`Effect-TS/effect`**, not the v4 prerelease repository. - - - -Detect before you write — never clobber. Read existing `tsconfig.json`, `package.json` scripts, and agent files first; MERGE recommended settings into what's there rather than overwriting, and keep a `typecheck` script the repo already defines. Write the agent block only between the `` / `` markers (replace in place if they exist) so re-running is idempotent. - - -## Checklist (show once at the start) - -```text -- [ ] Detect repo state + package manager -- [ ] Install Effect dependencies -- [ ] Wire @effect/language-service -- [ ] Apply tsconfig settings -- [ ] Add typecheck script -- [ ] Write agent-instruction block -- [ ] (explicitly requested only) Clone Effect source reference -- [ ] Summary -``` - -## Step 1 — Detect (read-only) - -```bash -ls -la package.json tsconfig.json bun.lock pnpm-lock.yaml package-lock.json yarn.lock .vscode AGENTS.md CLAUDE.md .claude .cursorrules 2>/dev/null -file AGENTS.md CLAUDE.md 2>/dev/null | grep -i link # detect symlinks -``` - -Resolve the package manager from the lock file, then confirm: - -| Lock file | Package manager | -|---|---| -| `pnpm-lock.yaml` | pnpm | -| `bun.lock` | bun | -| `package-lock.json` | npm | -| `yarn.lock` | yarn | -| multiple | ASK which to use | -| none | ASK preference (default pnpm); `package.json` absent → offer ` init` first | - -Infer project type from deps/files (drives Step 2): a CLI (bin entry), an HTTP server/client (fastify/express/next/fetch usage), a React app, or a plain library. - -## Step 2 — Install dependencies - -| Project type | Packages (no version pin) | -|---|---| -| Always | `effect` | -| CLI app | `+ @effect/cli @effect/platform-node` | -| HTTP server/client | `+ @effect/platform` (+ `@effect/platform-node` on Node) | -| React app | `+ @effect-atom/atom-react` | -| Tests | `-D @effect/vitest vitest` | - -```bash -# example (pnpm): -pnpm add effect @effect/platform -``` - -Never add `@effect/schema`. Run the resolved package-manager command as part of the bounded setup; do not insert a per-command confirmation stop. - -## Step 3 — Language service - -`@effect/language-service` adds edit-time + build-time Effect diagnostics (floating effects, missing context, anti-patterns). Install it, register the tsconfig plugin, add the `prepare` patch, and set the editor to the workspace TypeScript. Full steps + the diagnostics catalog: `references/language-service.md`. - -## Step 4 — tsconfig - -Compare the repo's `tsconfig.json` to the recommended strict baseline and MERGE (don't overwrite). The exact `compilerOptions`, the "bundler vs `tsc`" rule of thumb, and the VS Code/Cursor settings: `references/tsconfig.md`. - -## Step 5 — Package scripts - -If no type-check script exists, add one (keep an existing one): - -```jsonc -// simple project: -"typecheck": "tsc --noEmit" -// monorepo with project references: -"typecheck": "tsc --build --noEmit" -``` - -## Step 6 — Agent-instruction block - -Write this managed block so future agents follow the conventions. Insert between the markers (replace in place if present — idempotent): - -```markdown - -## Effect Best Practices - -Target **Effect 3.x stable**. Before writing Effect code, consult the `effect-ts-specialist` skill — services & layers, tagged errors, `effect/Schema`, `Config`, `ManagedRuntime`, `@effect/vitest`. `Schema` is `effect/Schema`, never `@effect/schema`. - -Deeper references when available: `bunx effect-solutions@latest show ` (Bun CLI), context7 (`effect`), or a cloned `Effect-TS/effect` tree. Never guess an Effect API — verify first. - -``` - -Placement by file state: - -| State | Action | -|---|---| -| Both `AGENTS.md` + `CLAUDE.md` exist, not symlinked | Write the block into both | -| One exists | Write into it; optionally create the other as a symlink/`@AGENTS.md` shim | -| One is a symlink of the other | Write the real file only | -| Neither | Create `AGENTS.md` with the block; add `CLAUDE.md` = `@AGENTS.md` | - -## Step 7 — Effect source reference (optional, explicit only) - -Clone a grep-able Effect 3.x source reference only when the current user request explicitly includes that network read; otherwise skip it without opening a confirmation turn: - -```bash -git clone --depth 1 https://github.com/Effect-TS/effect.git ~/.local/share/effect-kit/effect -# update later: git -C ~/.local/share/effect-kit/effect pull --depth 1 -``` - -Then add a one-line `## Local Effect Source` note pointing at that path. Never infer this optional external read from a normal local setup request. - -## Step 8 — Summary - -Report: package manager, steps completed vs skipped (with reasons), files created/modified, and any errors plus their resolution. Name a later matching Effect skill only if the user separately requests that work after dependency resolution; do not stack another Effect skill onto setup by default. - -For a large monorepo or multi-package setup, route the complete implementation request through the unified **`plan-manager`** when it warrants a canonical plan; after review, continue without asking for a lifecycle command. - -Prerequisite: `plan-lifecycle` must be installed. If `plan-workspace` or `plan-manager` is unavailable, STOP, name the missing `plan-lifecycle` plugin, and do not create or mutate a plan. - -## Gotchas - -| Gotcha | Consequence | Right move | -|---|---|---| -| Pausing before every install/edit | Turns one mechanical setup into a user-scheduled workflow | Ask once only for material package-manager/project ambiguity, then complete the bounded setup | -| Installing `@effect/schema` | Deprecated package, wrong types | `effect/Schema` ships in core | -| Pinning an Effect version | Drifts from latest 3.x, peer-dep friction | Install unpinned; let the PM resolve | -| Overwriting an existing `tsconfig.json` | Wipes the user's settings | Read first; merge recommended keys | -| `tsc` ignores the LSP plugin at build time | No build-time Effect diagnostics | Run `effect-language-service patch` in a `prepare` script | -| Editor uses the bundled TS, not the workspace | Plugin diagnostics never show | Set `typescript.tsdk` + select workspace version | -| Appending the agent block twice on re-run | Duplicated section | Replace between the `` markers | -| Cloning `effect-smol` for a v3 project | v4 APIs mislead the agent | Clone `Effect-TS/effect` for v3 ground truth | - -## References - -| Read for | File | -|---|---| -| `@effect/language-service` install, plugin config, build patch, diagnostics | `references/language-service.md` | -| Recommended `compilerOptions`, bundler-vs-tsc rule, editor settings | `references/tsconfig.md` | - -## When this skill does NOT apply - -- Effect v4 setup or a repository already resolved to Effect 4.x — setup is unsupported; use **`effect-v4`** only for v4 code/review, never install v3 over it. -- The repo already uses Effect 3.x and you're writing code — use **`effect-ts-specialist`**. -- Migrating an existing Fastify/Next/React app — use **`effect-ts-port`** after resolving package and lock evidence. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-setup/references/language-service.md b/plugins/effect-kit/skills/engineering/effect-ts-setup/references/language-service.md deleted file mode 100644 index b08cf2ea..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-setup/references/language-service.md +++ /dev/null @@ -1,72 +0,0 @@ -# @effect/language-service - -A TypeScript language-service plugin that adds Effect-aware diagnostics: it catches floating effects, missing requirements/errors in the type, and a long list of anti-patterns — at edit time in the editor, and (via a patch) at build time under `tsc`. - -## 1. Install (dev dependency) - -```bash -pnpm add -D @effect/language-service # or npm i -D / bun add -d -``` - -## 2. Register the tsconfig plugin - -```jsonc -{ - "$schema": "https://raw.githubusercontent.com/Effect-TS/language-service/refs/heads/main/schema.json", - "compilerOptions": { - "plugins": [{ "name": "@effect/language-service" }] - } -} -``` - -The plugin accepts options (all optional): `diagnostics`, `diagnosticSeverity`, `refactors`, `quickinfo`, `completions`, `goto`, `inlays`, `barrelImportPackages`, `namespaceImportPackages`, and more — the `$schema` above is the authoritative, current list. Start with the bare `{ "name": "@effect/language-service" }` and tune later. - -## 3. Point the editor at the workspace TypeScript - -A TS language-service plugin only runs under the workspace TypeScript, not VS Code's bundled copy. - -```json -// .vscode/settings.json -{ - "typescript.tsdk": "./node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true -} -``` - -Then: Command Palette → "TypeScript: Select TypeScript Version" → "Use Workspace Version". (Cursor is identical; JetBrains/NVim-vtsls/Emacs have their own workspace-TS settings.) - -## 4. Build-time diagnostics (patch `tsc`) - -`tsc` does not load language-service plugins, so the package patches the local `typescript`/`tsc` to emit Effect diagnostics even with `noEmit`/composite builds. Make it persistent with a `prepare` script: - -```bash -pnpm exec effect-language-service patch # one-off -``` - -```json -// package.json -{ "scripts": { "prepare": "effect-language-service patch" } } -``` - -Verify the patch took: `tsc --noEmit` on a file with a floating effect now reports the Effect diagnostic (`unpatch` reverts it). - -CLI verbs: `setup`, `config`, `patch`, `unpatch`, `check`, `diagnostics`, `quickfixes`, `codegen`, `overview`, `layerinfo` (re-derive with `pnpm exec effect-language-service --help`). - -## 5. Per-line control - -```ts -// @effect-diagnostics effect/floatingEffect:off -// @effect-diagnostics effect/floatingEffect:error -// @effect-diagnostics *:off -``` - -## Diagnostics catalog (what it catches) - -| Category | Examples | -|---|---| -| **Correctness** | `floatingEffect` (Effect not yielded/run), `missingEffectContext`, `missingEffectError`, `missingLayerContext`, `missingStarInYieldEffectGen` (`yield` vs `yield*`) | -| **Anti-pattern** | `tryCatchInEffectGen`, `runEffectInsideEffect`, `multipleEffectProvide`, `strictEffectProvide` (provide at entry only), `leakingRequirements`, `scopeInLayerEffect` (use `Layer.scoped`) | -| **Effect-native** (migration) | `processEnvInEffect` → `Config`, `globalFetchInEffect` → Effect HTTP, `globalConsoleInEffect` → `Effect.log`, `globalDateInEffect` → `Clock`/`DateTime`, `globalRandom` → `Random`, `globalTimersInEffect` → `Effect.sleep`/`Schedule`, `instanceOfSchema` → `Schema.is` | -| **Style** | `effectDoNotation` (prefer `Effect.gen`/`Effect.fn`), `schemaStructWithTag` → `Schema.TaggedStruct`, `unnecessaryEffectGen`, `unnecessaryPipe` | - -Plus refactors/codegens: async-function → `Effect.gen`/`Effect.fn`, `Effect.Service` ↔ `Context.Tag` conversion, "Layer Magic" auto-composition, structural-type → `Schema`, and a mermaid Layer-graph in quickinfo. These are the same anti-patterns the `effect-ts-specialist` skill warns about — the LSP enforces them mechanically. Rule names move with the plugin: re-derive the current catalog with `pnpm exec effect-language-service diagnostics`, and exercise any single rule via a scratch-file repro + `pnpm exec effect-language-service check`. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-setup/references/tsconfig.md b/plugins/effect-kit/skills/engineering/effect-ts-setup/references/tsconfig.md deleted file mode 100644 index 572fe8c4..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-setup/references/tsconfig.md +++ /dev/null @@ -1,72 +0,0 @@ -# Recommended tsconfig - -Effect leans on precise types — a strict tsconfig is what makes the requirement/error channels catch mistakes. MERGE these into the repo's existing `tsconfig.json`; don't overwrite settings the user already chose. - -## Baseline `compilerOptions` - -```jsonc -{ - "compilerOptions": { - // build performance - "incremental": true, - "composite": true, - - // module / target - "target": "ES2022", - "module": "NodeNext", - "moduleDetection": "force", - - // imports - "verbatimModuleSyntax": true, - "rewriteRelativeImportExtensions": true, - - // type-safety (the part that matters most for Effect) - "strict": true, - "exactOptionalPropertyTypes": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitOverride": true, - "noFallthroughCasesInSwitch": true, - - // dev ergonomics - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "skipLibCheck": true, - - // the language-service plugin (see language-service.md) - "plugins": [{ "name": "@effect/language-service" }] - } -} -``` - -`exactOptionalPropertyTypes` + `strict` are the load-bearing flags — they make `Schema` optional fields and the error channel behave as written (with EOPT off, optional fields accept `undefined` statically yet can fail at decode — the Schema introduction docs demo this). - -## Rule of thumb: who compiles your code? - -| Situation | Settings | -|---|---| -| A bundler (Vite, esbuild, Next, tsx) compiles your code; `tsc` only type-checks | `"module": "preserve"`, `"moduleResolution": "bundler"`, `"noEmit": true` | -| `tsc` compiles (a library, an npm package, a Node app/CLI) | `"module": "NodeNext"`, `"declaration": true` (+ `"composite"`/`"declarationMap"` for a monorepo) | - -So a Next.js or Vite app uses the bundler row; a published library or a `tsc`-built CLI uses the `tsc` row. - -## Monorepo (project references) - -Each package extends a shared `tsconfig.base.json` and the root composes them with `references`. Use `"composite": true` per package and a root `typecheck` of `tsc --build --noEmit`. Keep one `tsconfig.base.json` holding the language-service plugin so every package inherits the diagnostics. - -## Editor settings (recap) - -```json -// .vscode/settings.json — required for the LSP plugin to run -{ - "typescript.tsdk": "./node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true -} -``` - -## Notes - -- If the repo has no `tsconfig.json`, create one with the baseline above (pick the bundler vs `tsc` row by project type). -- Don't silently flip a setting the user set differently (e.g. they intentionally disabled `noUnusedLocals`) — surface the diff and let them choose. -- `skipLibCheck: true` is recommended for speed; it skips checking `.d.ts` files only — your own code's type-checking is unaffected (per the TS docs). diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/SKILL.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/SKILL.md deleted file mode 100644 index a31ad7e6..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-specialist/SKILL.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -name: effect-ts-specialist -description: "Use when writing or reviewing Effect 3.x code — services and layers (`Context.Tag`/`Effect.Service`), tagged errors, `effect/Schema` data modeling, `Config`, `ManagedRuntime`, scoped resources, `@effect/vitest`, and `@effect/language-service` diagnostics. Not for Effect v4 (use effect-v4), Fastify/Next/React porting (use effect-ts-port), or first-time setup (use effect-ts-setup)." -user-invocable: false -metadata: - pattern: patterns-reference - updated: "2026-07-15" - content_hash: "776d4387c4bfd12d1a4407f9e187d80747df76938abb003119fcfa3d2f853b48" ---- - -# Effect-TS Specialist (idiomatic Effect 3.x) - -Effect rewards a small set of idioms and punishes guessing — the type-level error and requirement channels mean a wrong pattern shows up as a confusing `R`/`E` mismatch, not a runtime bug. This skill is the decision layer: which idiom to reach for, and the two or three mistakes that cost the most. Depth per topic lives in `references/`. - - -Never guess an Effect API from memory — the surface is large and moves between minor versions. Target **Effect 3.x stable** (the `effect` package). Before version-specific code, inspect `package.json` and the lockfile; if they resolve Effect 4.x, stop and use **`effect-v4`**. `Schema` is imported from **`effect/Schema`**, never the deprecated `@effect/schema` (folded into core in 3.10). Verify unfamiliar combinators against installed source or current documentation. A wrong API shape is worse than asking. - - - -Errors live in the **typed error channel**, not in `throw`. Model expected domain failures (validation, not-found, permission-denied) as tagged errors so they appear in `E` and `catchTag` can recover them. Reserve `throw`/defects for genuine bugs and invariant violations — promote those with `Effect.orDie`. NEVER use `try/catch` inside `Effect.gen`; wrap promise-returning code with `Effect.tryPromise({ try, catch })` so the failure is typed. - - - -Provide layers **once, at the application boundary** (`Effect.provide(program, MainLive)` or a single `ManagedRuntime`). Service method signatures carry `R = never` — push every dependency into the service's Layer, not the call site. Scattering `provide` calls or leaking requirements into method types is the most common structural smell (the language service flags it as `multipleEffectProvide` / `leakingRequirements`). - - -## Decision table — reach for X when you want Y - -| You want | Reach for | Reference | -|---|---|---| -| A service with one obvious implementation | `Effect.Service` (bundles Tag + `Default` layer) | `services-and-layers.md` | -| A service defined interface-first / multiple impls | `Context.Tag` + a separate `Layer` | `services-and-layers.md` | -| A recoverable domain failure | `Data.TaggedError` (in-process) / `Schema.TaggedError` (crosses a boundary) | `error-handling.md` | -| To wrap an existing Promise | `Effect.tryPromise({ try, catch })` | `error-handling.md` | -| A product/record type with methods | `Schema.Class` | `data-modeling.md` | -| A sum/variant type | `Schema.TaggedClass` + `Schema.Union` | `data-modeling.md` | -| A primitive that must not be mixed up | `Schema.brand` (`UserId` ≠ `PostId`) | `data-modeling.md` | -| Typed env/secret access | `Config.*` (+ `Config.redacted` for secrets) | `config.md` | -| To run an Effect at a framework edge | `ManagedRuntime.make(layer)` then `runtime.runPromise` | `running-effects.md` | -| A resource with guaranteed cleanup | `Effect.acquireRelease` + `Layer.scoped` | `running-effects.md` | -| Deterministic tests (time, services) | `@effect/vitest` `it.effect` + `TestClock` | `testing.md` | - -## Services & layers (the spine) - -```ts -// GOOD — Effect.Service bundles the Tag and a Default layer; deps go in the layer, not the methods -class Users extends Effect.Service()("app/Users", { - effect: Effect.gen(function* () { - const sql = yield* Sql // dependency resolved by the layer - const getAll = sql.query("select * from users") // R = never on the method - return { getAll } as const - }), - dependencies: [SqlLive], -}) {} -// access: const users = yield* Users -// provide: Effect.provide(program, Users.Default) -``` - -```ts -// BAD — dependency leaks into the method's requirement type, and provide is scattered per-call -const getAll = (sql: Sql) => Effect.provide(sql.query("..."), SqlLive) // R leaks; provided too deep -``` - -Layer naming is camelCase + `Layer`/`Default` suffix; compose with `Layer.merge` / `Layer.provide` / `Layer.provideMerge`. **Memoize parameterized layers** (store `const pgLayer = Postgres.layer({...})` once) so reference identity dedupes shared resources like connection pools. Full patterns: `references/services-and-layers.md`. - -## Errors: typed channel vs defects - -```ts -// GOOD — domain failure is tagged → shows up in E, recoverable with catchTag -class UserNotFound extends Data.TaggedError("UserNotFound")<{ id: string }> {} -const find = (id: string) => - Effect.tryPromise({ try: () => db.user(id), catch: (e) => new DbError({ cause: e }) }).pipe( - Effect.flatMap((u) => (u ? Effect.succeed(u) : new UserNotFound({ id }))) - ) -find("7").pipe(Effect.catchTag("UserNotFound", () => Effect.succeed(guestUser))) -``` - -```ts -// BAD — throw inside gen becomes an untyped defect; try/catch defeats the error channel -const find = (id: string) => Effect.gen(function* () { - try { const u = yield* Effect.promise(() => db.user(id)); if (!u) throw new Error("nope"); return u } - catch (e) { throw e } // ❌ language service: tryCatchInEffectGen -}) -``` - -Use `Schema.TaggedError` (serializable) when the error crosses a network/DB boundary (e.g. `HttpApi`); `Data.TaggedError` when it stays in-process. Recover with `catchTag`/`catchTags`; promote unrecoverable failures with `Effect.orDie`. Detail: `references/error-handling.md`. - -## Running at the boundary - -You almost never call `Effect.runPromise` in app code — you build a runtime once and run effects through it at each framework entry point (route handler, server action, React event). `ManagedRuntime.make(MainLive)` is the bridge; see `references/running-effects.md`. This is the seam the **`effect-ts-port`** skill plugs every framework into. - -## Hybrid knowledge source (opportunistic, never required) - -The `references/` here are self-contained. When more depth is needed and the tools exist, prefer ground truth over memory: - -```bash -bunx effect-solutions@latest list # Kit Langton's idiomatic-Effect docs CLI (needs Bun; optional) -bunx effect-solutions@latest show # run `list` first for the exact topic slugs -# else: context7 resolve-library-id `effect` → query-docs; or grep a cloned effect source tree -``` - -Topics roughly correspond to the reference files below. Absence of these tools is never a blocker — the references stand alone. - -## Gotchas - -| Gotcha | Consequence | Right move | -|---|---|---| -| `try/catch` inside `Effect.gen` | Failure escapes the typed channel | `Effect.tryPromise({ try, catch })` | -| `throw` for an expected failure | Becomes an unrecoverable defect | Tagged error in `E`; recover with `catchTag` | -| `provide` scattered through the call tree | Layers re-built, requirements leak | Provide once at the boundary | -| `Layer.effect` for a service needing cleanup | Finalizer never runs | `Layer.scoped` + `acquireRelease` (LSP: `scopeInLayerEffect`) | -| Re-creating a parameterized layer per use | Duplicate pools/connections | Memoize in a `const`; reference identity dedupes | -| `process.env` / `new Date()` / `Math.random()` inside an Effect | Untestable, impure | `Config.*` / `Clock` / `Random` (LSP flags each) | -| `installing @effect/schema` | Deprecated package, wrong types | Import from `effect/Schema` (core since 3.10) | -| Plain `JSON.parse` of external input | Unvalidated `any` | `Schema.decodeUnknown` / `Schema.parseJson` | - -Install the **`@effect/language-service`** tsconfig plugin (the `effect-ts-setup` skill wires it) — it catches most of the above at edit time. Rule names here and in the references move with the plugin: exercise a claim by reproducing the anti-pattern in a scratch file and confirming the diagnostic appears (or run `pnpm exec effect-language-service check`). - -## References - -| Read for | File | -|---|---| -| `Context.Tag` vs `Effect.Service`, layer composition, memoization, provide-once | `references/services-and-layers.md` | -| Tagged errors, defects vs typed errors, `tryPromise`, `catchTag`/`catchTags` | `references/error-handling.md` | -| `Schema.Class`/`TaggedClass`/`Union`, brands, decode/encode, JSON | `references/data-modeling.md` | -| `Config.*`, `redacted`, defaults, `ConfigProvider`, config-as-service | `references/config.md` | -| `runPromise`/`runFork`, `ManagedRuntime`, `Scope`/`acquireRelease` | `references/running-effects.md` | -| `@effect/vitest` `it.effect`/`it.scoped`, `TestClock`, mocking layers | `references/testing.md` | - -## When this skill does NOT apply - -- Writing or reviewing Effect v4 code, or a project whose lockfile resolves `effect` 4.x — use **`effect-v4`**. -- Migrating an existing Fastify / Next.js / React codebase to Effect — use **`effect-ts-port`** (detect → plan → migrate). -- Bootstrapping `effect` deps, tsconfig, and the language service in a fresh repo — use **`effect-ts-setup`**. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/config.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/config.md deleted file mode 100644 index 06f4212a..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/config.md +++ /dev/null @@ -1,80 +0,0 @@ -# Config - -`Config` reads environment/configuration as a typed, composable value with validation and secret-hiding built in. Replaces ad-hoc `process.env.FOO!` access (which the language service flags as `processEnvInEffect`). - -## Primitives - -```ts -import { Config, Effect } from "effect" - -const port = Config.integer("PORT") -const host = Config.string("HOST") -const debug = Config.boolean("DEBUG") -const timeout = Config.duration("REQUEST_TIMEOUT") // "5 seconds" → Duration -const apiUrl = Config.url("API_URL") -const apiKey = Config.redacted("API_KEY") // Redacted — never printed in logs/errors -``` - -`Config.redacted` wraps the value so it renders as `` everywhere; unwrap only at the point of use with `Redacted.value(key)`. - -## Defaults, fallbacks, nesting - -```ts -const port = Config.integer("PORT").pipe(Config.withDefault(3000)) -const key = Config.redacted("API_KEY").pipe(Config.orElse(() => Config.redacted("LEGACY_KEY"))) - -// group related keys under a prefix: DB_HOST, DB_PORT -const db = Config.all({ - host: Config.string("HOST"), - port: Config.integer("PORT").pipe(Config.withDefault(5432)), -}).pipe(Config.nested("DB")) -``` - -## Reading config - -```ts -const program = Effect.gen(function* () { - const cfg = yield* db // Config is itself an Effect that fails with ConfigError - return connect(cfg.host, cfg.port) -}) -``` - -A missing/invalid key fails with `ConfigError` in the `E` channel — so config problems surface at layer construction, not as a `undefined` three calls deep. - -## Config as a service (the idiom) - -Wrap config in a service so the rest of the app depends on a typed value, and tests inject a fixed one: - -```ts -class AppConfig extends Effect.Service()("app/Config", { - effect: Config.all({ - port: Config.integer("PORT").pipe(Config.withDefault(3000)), - apiKey: Config.redacted("API_KEY"), - }), -}) {} - -// prod: AppConfig.Default reads from the environment -// test: a fixed layer -const TestConfig = Layer.succeed(AppConfig, { port: 0, apiKey: Redacted.make("test") } as any) -``` - -## Providers (where values come from) - -```ts -import { ConfigProvider, Effect, Layer } from "effect" - -// default provider reads process.env — nothing to wire for the common case. -// override for tests with an in-memory map: -const testProvider = ConfigProvider.fromMap( - new Map([["PORT", "0"], ["API_KEY", "test-key"]]) -) -program.pipe(Effect.withConfigProvider(testProvider)) -// or as a layer: Layer.setConfigProvider(testProvider) -``` - -## Checklist - -- Never read `process.env` inside an Effect — use `Config.*`. -- Secrets → `Config.redacted`; unwrap with `Redacted.value` only at use. -- Wrap config in a service with a `Default` (env) layer and a fixed test layer. -- Tests inject values via `ConfigProvider.fromMap`, not by mutating `process.env`. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/data-modeling.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/data-modeling.md deleted file mode 100644 index 21c01e22..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/data-modeling.md +++ /dev/null @@ -1,96 +0,0 @@ -# Data Modeling with Schema - -`Schema` (from **`effect/Schema`** — not `@effect/schema`) is one declaration that gives you a runtime decoder/encoder *and* a static type. Model your domain in Schema and validation, serialization, and types stay in sync. - -## Records / product types → `Schema.Class` - -```ts -import { Schema } from "effect" - -class User extends Schema.Class("User")({ - id: UserId, // a branded primitive (below) - name: Schema.String, - email: Schema.String, - createdAt: Schema.DateFromString, // decodes ISO string → Date, encodes back -}) { - get displayName() { return `${this.name} <${this.email}>` } -} - -User.make({ id, name, email, createdAt: new Date() }) // construct (validates) -``` - -`Schema.Class` gives an opaque class type, a constructor, and getters/methods — preferred over a bare `Schema.Struct` when the type is a domain entity. - -## Variants / sum types → `Schema.TaggedClass` + `Schema.Union` - -```ts -class Pending extends Schema.TaggedClass()("Pending", {}) {} -class Shipped extends Schema.TaggedClass()("Shipped", { trackingNo: Schema.String }) {} -class Delivered extends Schema.TaggedClass()("Delivered", { at: Schema.DateFromString }) {} - -const OrderStatus = Schema.Union(Pending, Shipped, Delivered) -type OrderStatus = Schema.Schema.Type - -// exhaustive match on the _tag: -import { Match } from "effect" -const label = Match.type().pipe( - Match.tag("Pending", () => "waiting"), - Match.tag("Shipped", (s) => `tracking ${s.trackingNo}`), - Match.tag("Delivered", () => "done"), - Match.exhaustive, -) -``` - -## Brand primitives — stop ID mix-ups - -```ts -const UserId = Schema.String.pipe(Schema.brand("UserId")) -const PostId = Schema.String.pipe(Schema.brand("PostId")) -type UserId = Schema.Schema.Type // string & Brand<"UserId"> - -declare function getUser(id: UserId): void -getUser(somePostId) // ❌ compile error — PostId is not assignable to UserId -``` - -Brand nearly every domain primitive (ids, emails, slugs, money). It's the cheapest bug-prevention Schema gives you. - -## Decode external input — never trust `JSON.parse` - -```ts -// from unknown (HTTP body, queue message): -const user = yield* Schema.decodeUnknown(User)(payload) // Effect -// sync variant — plain value, throws ParseError; NOT an Effect (no yield*), trusted input only: -const user2 = Schema.decodeUnknownSync(User)(payload) -// from a JSON string in one step: -const cfg = yield* Schema.decode(Schema.parseJson(Settings))(rawString) -// encode back to the wire shape: -const wire = yield* Schema.encode(User)(user) -``` - -`decodeUnknown` returns an `Effect` whose error is `ParseError` — pipe it straight into your tagged-error handling. - -## Refinements, transforms, optional fields - -```ts -const Age = Schema.Number.pipe(Schema.int(), Schema.between(0, 150)) -const Email = Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+$/), Schema.brand("Email")) - -const Settings = Schema.Struct({ - theme: Schema.Literal("light", "dark"), - retries: Schema.optionalWith(Schema.Number, { default: () => 3 }), // default on decode - nickname: Schema.optional(Schema.String), // may be absent -}) - -// custom bidirectional transform: -const Trimmed = Schema.transform(Schema.String, Schema.String, { - decode: (s) => s.trim(), - encode: (s) => s, -}) -``` - -## Gotchas - -- Import from `effect/Schema`. `@effect/schema` is deprecated (folded into core in Effect 3.10). -- `Schema.Struct` is the plain record; reach for `Schema.Class` when you want a nominal domain type with methods. -- Decoding is effectful (`ParseError` in `E`) — handle it, don't `decodeUnknownSync` on untrusted input in a request path. -- For a discriminated union, give each member a distinct tag via `Schema.TaggedClass`/`Schema.TaggedStruct` so `Match.tag` + `Match.exhaustive` can prove totality. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/error-handling.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/error-handling.md deleted file mode 100644 index 7574a8e4..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/error-handling.md +++ /dev/null @@ -1,91 +0,0 @@ -# Error Handling - -Effect splits failures into two kinds: - -- **Typed errors** (the `E` channel) — *expected* domain failures: validation, not-found, permission-denied, a 4xx from an upstream API. Recoverable, enumerated in the type. -- **Defects** — *unexpected* bugs and broken invariants. Not in `E`; they bubble as `Cause.Die`. You usually let them crash (and log), not `catch` them. - -The skill is choosing the right channel and never letting a `throw` silently become a defect. - -## Defining tagged errors - -```ts -import { Data, Schema } from "effect" - -// in-process only (cheapest): -class UserNotFound extends Data.TaggedError("UserNotFound")<{ id: string }> {} -class DbError extends Data.TaggedError("DbError")<{ cause: unknown }> {} - -// crosses a boundary (HTTP/RPC/queue) → serializable, schema-validated: -class ValidationError extends Schema.TaggedError()("ValidationError", { - field: Schema.String, - message: Schema.String, -}) {} -``` - -Rule of thumb: **`Data.TaggedError` until the error has to leave the process**, then `Schema.TaggedError` (it encodes/decodes and integrates with `HttpApi` status mapping). The `_tag` field is what `catchTag` matches on. - -## Wrapping promise / throwing code - -```ts -import { Effect } from "effect" - -// GOOD — failure is typed as DbError, never a defect -const query = (id: string) => - Effect.tryPromise({ - try: () => db.user(id), - catch: (cause) => new DbError({ cause }), - }) - -// no `catch` → fails with the untyped UnknownException (fine for throwaway scripts, not domain code) -const loose = Effect.tryPromise(() => fetch(url)) -// synchronous throwing code → Effect.try({ try, catch }) -``` - -Never `try/catch` inside `Effect.gen` — the language service flags `tryCatchInEffectGen`, and the caught error escapes the `E` channel. - -## Recovering - -```ts -program.pipe( - Effect.catchTag("UserNotFound", (e) => Effect.succeed(guestFor(e.id))), - Effect.catchTags({ - DbError: (e) => Effect.logError(e.cause).pipe(Effect.zipRight(Effect.fail(new ServiceUnavailable()))), - ValidationError: (e) => Effect.fail(new BadRequest({ field: e.field })), - }), -) - -// map/translate without recovering: -program.pipe(Effect.mapError((e) => new PublicError({ cause: e }))) -// recover by value: Effect.catchAll / Effect.orElse / Effect.catchAllCause (sees defects too) -``` - -## Typed error → defect (and back) - -```ts -// at the composition root, an unrecoverable config failure should crash, not be a typed error: -const config = loadConfig.pipe(Effect.orDie) // E becomes a defect -const configMsg = loadConfig.pipe(Effect.orDieWith((e) => new Error(`bad config: ${e}`))) - -// inspect the full failure (typed errors + defects + interruptions): -program.pipe(Effect.catchAllCause((cause) => Effect.logError(cause))) -``` - -## Retry & timeouts (typed errors are ret-riable) - -```ts -import { Effect, Schedule } from "effect" -query(id).pipe( - // intersect keeps the backoff AND caps the tries (compose would pick the shorter — zero — delay) - Effect.retry(Schedule.exponential("100 millis").pipe(Schedule.intersect(Schedule.recurs(3)))), - Effect.timeout("5 seconds"), // adds TimeoutException to E -) -// simple bounded retry without backoff: Effect.retry(query(id), { times: 3 }) -``` - -## Checklist - -- Expected failure → tagged error in `E`. Bug/invariant → defect (`orDie`, or just let it throw outside Effect). -- `Data.TaggedError` in-process; `Schema.TaggedError` across a boundary. -- Wrap every Promise with `Effect.tryPromise({ try, catch })`; wrap sync throwers with `Effect.try`. -- Recover with `catchTag`/`catchTags`; translate with `mapError`; see everything with `catchAllCause`. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/running-effects.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/running-effects.md deleted file mode 100644 index 191ae399..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/running-effects.md +++ /dev/null @@ -1,79 +0,0 @@ -# Running Effects & Resource Scopes - -An `Effect` is a *description*; nothing happens until it's run. Where and how you run it is the boundary between Effect-land and the host (Node, a framework, the browser). This is the seam every `effect-ts-port` integration plugs into. - -## The run functions (boundaries only) - -```ts -import { Effect } from "effect" - -Effect.runPromise(program) // Promise, rejects on failure -Effect.runPromiseExit(program) // Promise> — inspect success/failure without throwing -Effect.runFork(program) // RuntimeFiber — fire-and-forget, cancellable -Effect.runSync(program) // A — only for fully-synchronous effects (throws otherwise) -``` - -Call these at the **edge** (top of `main`, a request handler, an event callback) — never inside business logic. A program you run usually has `R = never`; if `R` isn't `never`, you forgot to provide a layer. - -## `ManagedRuntime` — the framework bridge - -Most apps don't own their entry point (Next.js calls your route, React calls your handler). Build a runtime **once** from your layer, then run effects through it: - -```ts -import { Effect, ManagedRuntime } from "effect" - -// module scope — built once, reused across invocations -const runtime = ManagedRuntime.make(MainLive) - -// at each entry point: -export async function handler(req: Request) { - return runtime.runPromise(handleRequest(req)) // effect can use every service in MainLive -} - -// on shutdown / HMR teardown: -await runtime.dispose() -``` - -`runtime.runPromise` / `runPromiseExit` / `runFork` mirror the `Effect.run*` family but carry the provided context, so handlers stay `R`-free. This is exactly what `effect-ts-port` wires into Fastify handlers, Next.js route handlers / server actions, and React. - -> Caveat (serverless/edge): module-scope runtime is reused within a warm instance but rebuilt on cold start; awaiting `runtime.runtime()` at module top-level is fine for Node servers, but verify it against your deploy target (edge runtimes, HMR in dev). - -## Resource management — `Scope` + `acquireRelease` - -A `Scope` guarantees finalizers run (success, failure, or interruption). Acquire/release pairs are the building block: - -```ts -import { Effect } from "effect" - -const withFile = Effect.acquireRelease( - Effect.sync(() => openSync(path)), // acquire - (fd) => Effect.sync(() => closeSync(fd)), // release — always runs -) - -// use within a scope; the file is closed when the scope closes: -const program = Effect.scoped( - Effect.gen(function* () { - const fd = yield* withFile - return yield* read(fd) - }) -) -``` - -### Scoped layers - -When a *service* owns a resource (pool, client, subscription), build it with `Layer.scoped` so its finalizer runs when the app's scope closes: - -```ts -const DbLive = Layer.scoped( - Db, - Effect.acquireRelease(connect(url), (c) => c.close()), -) -// BAD: Layer.effect(Db, connect(url)) — connection never closed (LSP: scopeInLayerEffect) -``` - -## Checklist - -- Run at the edge with `Effect.run*`; everywhere else, return Effects. -- Don't control the entry point? Build a `ManagedRuntime` once and `runtime.runPromise` per call; `dispose()` on teardown. -- A program you run should have `R = never` — a lingering requirement means a missing `provide`. -- Resource with cleanup → `acquireRelease`; resource owned by a service → `Layer.scoped`, never `Layer.effect`. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/services-and-layers.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/services-and-layers.md deleted file mode 100644 index 48e73783..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/services-and-layers.md +++ /dev/null @@ -1,112 +0,0 @@ -# Services & Layers - -A *service* is an interface in the requirement (`R`) channel. A *layer* is a recipe that builds the service (and may require other services to do so). Wiring = composing layers and providing the composite once. - -## Contents - -- [Two ways to define a service](#two-ways-to-define-a-service) -- [Rules that keep `R` clean](#rules-that-keep-r-clean) -- [Building layers](#building-layers) -- [Composition](#composition) -- [Memoize parameterized layers](#memoize-parameterized-layers) -- [Provide once](#provide-once) - -## Two ways to define a service - -### `Effect.Service` — default when there's one obvious implementation - -```ts -import { Effect } from "effect" - -class Cache extends Effect.Service()("app/Cache", { - // `effect` builds the implementation; deps it needs go in `dependencies` - effect: Effect.gen(function* () { - const store = new Map() - const get = (k: string) => Effect.sync(() => store.get(k)) - const set = (k: string, v: string) => Effect.sync(() => void store.set(k, v)) - return { get, set } as const - }), - dependencies: [], // other layers this service needs -}) {} - -// access inside any Effect: -const program = Effect.gen(function* () { - const cache = yield* Cache - yield* cache.set("k", "v") -}) -// provide the bundled default layer at the boundary: -Effect.runPromise(Effect.provide(program, Cache.Default)) -``` - -`Effect.Service` auto-generates `Cache.Default` (a `Layer`) and the Tag. Use `{ sync: () => ... }` for a pure impl, `{ effect: ... }` for an effectful one, `{ scoped: ... }` when construction needs a finalizer. - -### `Context.Tag` — interface-first, or many implementations - -```ts -import { Context, Effect, Layer } from "effect" - -class Clock extends Context.Tag("app/Clock") -}>() {} - -const SystemClockLive = Layer.succeed(Clock, { now: Effect.sync(() => Date.now()) }) -const TestClockLive = Layer.succeed(Clock, { now: Effect.succeed(0) }) -``` - -Pick `Context.Tag` when you want to sketch the interface before any impl, or swap implementations (prod vs test, multiple backends). Pick `Effect.Service` for the common "one impl, give me a default layer" case. The `@effect/language-service` ships a refactor that converts between the two (list the current refactors with `pnpm exec effect-language-service overview`). - -## Rules that keep `R` clean - -- **Service ID is unique and namespaced**: `"app/Users"`, `"@org/Billing"`. Duplicate IDs silently collide. -- **Methods have `R = never`.** A method's type is `Effect` — never `Effect`. Dependencies are resolved when the *layer* is built, not when the method is called. Leaking a requirement into a method type is the `leakingRequirements` smell. -- **`readonly` props only.** No exposed mutable fields; encapsulate state behind methods. - -## Building layers - -```ts -import { Effect, Layer } from "effect" - -// from an effect that yields the service shape, pulling its own deps: -const UsersLive = Layer.effect( - Users, - Effect.gen(function* () { - const sql = yield* Sql // Users layer now requires Sql - return { getAll: sql.query("select * from users") } - }) -) - -// from a constant: Layer.succeed(Tag, impl) -// needing a finalizer: Layer.scoped(Tag, Effect.acquireRelease(open, close)) -``` - -## Composition - -| Combinator | Meaning | -|---|---| -| `Layer.merge(a, b)` | Both services available; deps of each still required | -| `Layer.provide(a, b)` | `b` satisfies `a`'s requirements (wires `b` *into* `a`) | -| `Layer.provideMerge(a, b)` | Like `provide`, but keeps `b` in the output too | - -```ts -const MainLive = UsersLive.pipe( - Layer.provide(SqlLive), // SqlLive satisfies Users' need for Sql - Layer.provideMerge(ConfigLive) // Config available to everything AND exported -) -``` - -## Memoize parameterized layers - -```ts -// GOOD — one instance, reference identity dedupes the pool across repos -const pgLayer = Postgres.layer({ url: dbUrl }) -const UserRepoLive = UserRepo.Default.pipe(Layer.provide(pgLayer)) -const OrderRepoLive = OrderRepo.Default.pipe(Layer.provide(pgLayer)) // same pgLayer - -// BAD — two distinct layers → two connection pools -const UserRepoLive = UserRepo.Default.pipe(Layer.provide(Postgres.layer({ url: dbUrl }))) -const OrderRepoLive = OrderRepo.Default.pipe(Layer.provide(Postgres.layer({ url: dbUrl }))) -``` - -## Provide once - -Build `MainLive` at the composition root and provide it at the single entry point. Do not `Effect.provide` inside business logic — it rebuilds layers and hides what a function truly requires. The language service flags repeated provides as `multipleEffectProvide` / `strictEffectProvide`. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/testing.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/testing.md deleted file mode 100644 index 59333189..00000000 --- a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/testing.md +++ /dev/null @@ -1,67 +0,0 @@ -# Testing with `@effect/vitest` - -`@effect/vitest` runs Effects as tests with the test context wired in — including a **`TestClock` frozen at zero**, so time-dependent code is deterministic. Install `@effect/vitest` as a dev dependency and import `it` from it. - -## The test entry points - -```ts -import { it, expect } from "@effect/vitest" -import { Effect } from "effect" - -// it.effect — auto-provides TestContext + TestClock (time starts at 0, advances only when you say) -it.effect("adds users", () => - Effect.gen(function* () { - const users = yield* Users - const all = yield* users.getAll - expect(all).toHaveLength(0) - }).pipe(Effect.provide(Users.Default))) - -// it.live — real clock / real services (use when you actually need wall-clock or real delays) -it.live("hits the network", () => Effect.gen(function* () { /* ... */ })) - -// it.scoped — for effects that acquire scoped resources; the scope closes at test end -it.scoped("opens and closes a connection", () => Effect.gen(function* () { /* ... */ })) -``` - -Modifiers compose as in vitest: `it.effect.skip`, `it.effect.only`, `it.effect.fails` (asserts the effect fails). - -## Controlling time with `TestClock` - -```ts -import { it, expect } from "@effect/vitest" -import { Effect, TestClock, Fiber } from "effect" - -it.effect("times out after 5s without blocking the test", () => - Effect.gen(function* () { - const fiber = yield* Effect.fork(slowOp.pipe(Effect.timeout("5 seconds"))) - yield* TestClock.adjust("5 seconds") // virtual time jump — test runs instantly - const exit = yield* Fiber.await(fiber) // Exit — Fiber.join would re-raise the failure - expect(exit._tag).toBe("Failure") - })) -``` - -`TestClock.adjust` advances virtual time, firing any scheduled effects (timeouts, `Effect.sleep`, `Schedule`) without real waiting. - -## Mocking services with layers - -Swap a real layer for a fake by providing a different `Layer` — no mocking framework: - -```ts -const FakeUsers = Layer.succeed(Users, { - getAll: Effect.succeed([{ id: "1", name: "Test" }]), -}) - -it.effect("uses the fake", () => - Effect.gen(function* () { - const users = yield* Users - expect(yield* users.getAll).toHaveLength(1) - }).pipe(Effect.provide(FakeUsers))) -``` - -Define a static `testLayer` next to a service's real `layer` for reuse. **Provide per-test** (inside each `it.effect`) for isolation, unless a resource is expensive enough to share via a suite-level layer. - -## Notes - -- Logging is suppressed by default under `it.effect` (verify: `Effect.log` prints nothing there, but does under `it.live`); re-enable with a `Logger` layer or switch to `it.live`. -- Assert on `Exit` (`Effect.runPromiseExit` / `Fiber.await` / `Effect.exit`) when testing the failure channel — `Fiber.join` re-raises the failure instead of yielding an Exit. -- The test script should run `vitest` (not `bun test`); pin `@effect/vitest` to match your `effect` version. diff --git a/plugins/effect-kit/skills/engineering/effect-v4/SKILL.md b/plugins/effect-kit/skills/engineering/effect-v4/SKILL.md deleted file mode 100644 index 778e1131..00000000 --- a/plugins/effect-kit/skills/engineering/effect-v4/SKILL.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -name: effect-v4 -description: "Use when writing or reviewing Effect v4 code, when package.json plus the lockfile resolve `effect` 4.x (including beta/prerelease), or when the user explicitly requests Effect v4 — services, layers, Schema, Config, Schedule, Cache, Stream, unstable HTTP, and @effect/vitest. Inspect both dependency files before version-specific code. Not for Effect 3.x; use effect-ts-specialist." -user-invocable: false -license: MIT -compatibility: "Requires an installed Effect 4 dependency or an explicit Effect v4 request. Effect v4 is prerelease at this vendored snapshot." -metadata: - pattern: upstream-adapted - updated: "2026-07-15" - upstream: - repository: https://github.com/kitlangton/skills - commit: "30dee8607214c893dd89f6eee65c669ef3dce8c9" - path: skills/effect - source: https://github.com/kitlangton/skills/tree/30dee8607214c893dd89f6eee65c669ef3dce8c9/skills/effect - license: MIT - vendored_at: "2026-07-15" - patches: - - "Renamed effect to effect-v4 and added reciprocal Effect Kit routing." - - "Added package.json plus lockfile version gating and prerelease drift checks." - - "Replaced mutable-upstream runtime lookup with installed-package inspection; added Docks reference TOCs." - content_hash: "be01d73f90ff4a9325daa0d189edccf135d37ddf46ef45e9a8e9d18bff1e6944" ---- - -# Effect v4 Specialist - -This is version-gated guidance for Effect v4. It adapts Kit Langton's pinned production guide while keeping Effect Kit's established Effect 3.x skills intact. - - -Before supplying version-specific code, inspect both `package.json` and the repository lockfile (`pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `bun.lock`, or the project's equivalent). Resolve the actual `effect` major from the lockfile or installed `node_modules/effect/package.json`; do not infer it from a range alone. A resolved 3.x dependency routes to **`effect-ts-specialist`**. Never write v4 APIs into a 3.x project. - - - -Effect v4 is beta/prerelease at this vendored snapshot (`effect@4.0.0-beta.98`; npm `latest` remains 3.x). For an unfamiliar or moving API, inspect the installed package's exports, declarations, and source before using it. The pinned official snapshot is supporting evidence, not permission to download mutable guidance at runtime or to assume a later beta has the same surface. - - - -Do not install, upgrade, migrate, or rewrite dependencies merely to activate this skill. An explicit v4 request permits v4 explanation or isolated examples, but if the current project resolves Effect 3.x, report the conflict and keep v4 code out of that project. V4 setup and v3-to-v4 migration are outside this skill. - - -## Version routing - -| Evidence | Route | -|---|---| -| Lockfile or installed package resolves `effect` 4.x, including a prerelease | Continue with `effect-v4` | -| User explicitly asks for Effect v4 and the project has no Effect dependency | Give clearly labelled v4 guidance; do not install dependencies | -| Project resolves `effect` 3.x | Stop v4 generation; use `effect-ts-specialist` | -| `package.json` and lockfile disagree, or the resolved major is unclear | Stop and resolve the version evidence before code | -| Explicit Effect 3.x setup request | Use `effect-ts-setup` | -| Explicit Effect 3.x Fastify, Next.js, or React port request | Use `effect-ts-port` | -| Explicit or resolved Effect v4 setup/port/migration request | Report unsupported; do not activate a v3 skill | -| Generic TypeScript request with no Effect signal | Do not use an Effect Kit skill | - -```text -BAD: package.json says "effect": "^4.0.0-beta.1", so emit the newest remembered v4 API. -GOOD: read package.json and the lockfile, confirm the resolved v4 build, then verify the API in that installed package. -``` - -## Source rule - -Check these before guessing: - -- the nearest `AGENTS.md` and project-local Effect practices -- `package.json`, the lockfile, and the installed `effect` package version/source -- this skill's matching reference branch -- the official source tag matching the installed version when local declarations do not answer the question - -Do not fetch mutable `main`, `latest`, or remote skill content while applying this guidance. Project conventions still take precedence when they are valid for the resolved v4 version. - -## Branch chooser - -Read only the references matching the task. If a task spans branches, read all matching files before editing. - -| Task | Reference | -|---|---| -| Data models, schemas, brands, variants, optional keys, decoders | [`SCHEMA.md`](references/SCHEMA.md) | -| Services, layers, runtime wiring, errors, `Effect.fn`, test services | [`SERVICES_LAYERS.md`](references/SERVICES_LAYERS.md) | -| Runtime config, env, `ConfigProvider`, `layerConfig` | [`CONFIG.md`](references/CONFIG.md) | -| Retry, repeat, polling, backoff, jitter, rate-limit policies | [`SCHEDULING.md`](references/SCHEDULING.md) | -| Memoization, TTL caches, concurrent lookup dedupe, batching | [`CACHING.md`](references/CACHING.md) | -| Streams, async sources, queues/pubsubs, pagination, backpressure | [`STREAMS.md`](references/STREAMS.md) | -| Outgoing HTTP, status handling, HTTP rate limiting | [`HTTP_CLIENTS.md`](references/HTTP_CLIENTS.md) | -| Effect tests, time, concurrency synchronization, fakes | [`TESTING.md`](references/TESTING.md) | - -## Core defaults - -- Compose workflows with `Effect.gen(function* () { ... })`. -- Define public and non-trivial internal service methods with `Effect.fn("Domain.operation")`. -- Prefer `Context.Service` for application services unless the project has a verified current convention. -- Build implementations with `Layer.effect(Service, Effect.gen(...))` and return `Service.of({ ... })`. -- Model records with `Schema.Struct(...)` plus a same-name `interface`. -- Model typed Effect errors with `Schema.TaggedErrorClass`. -- Read runtime config through `Config`, not direct `process.env` access in application logic. -- Use `Schedule` for retry, repeat, polling, pacing, and backoff. -- Use `Stream` for effectful multi-value sources needing pull, backpressure, interruption, or transformation. -- Treat `effect/unstable/*`, especially HTTP modules, as explicitly moving and verify the installed export first. -- Prefer Effect-aware tests, explicit layers, and deterministic synchronization over sleeps. -- Decode untrusted input with `Schema.decodeUnknownEffect(...)` or `schema.makeEffect(...)`; reserve throwing `schema.make(...)` for trusted construction. - -## Quick selection guide - -| Need | Default | -|---|---| -| Object record | `Schema.Struct(...)` plus a same-name `interface` | -| Scalar ID/value object | Constrained branded schema | -| Internal state/decision union | `Data.TaggedEnum` plus `Data.taggedEnum()` | -| Boundary-crossing variant/union | `Schema.TaggedStruct` / `Schema.TaggedUnion` | -| Expected typed failure | `Schema.TaggedErrorClass` | -| Unknown boundary payload | `Schema.decodeUnknownEffect(...)` | -| Service boundary | `Context.Service()(...)`, `Layer.effect(...)`, `Service.of(...)` | -| Runtime configuration | `Config` in layers; `ConfigProvider` overrides in tests | -| Event source | `Stream`, consumed with `Stream.runForEach(...)` | -| Keyed TTL lookup cache | `Cache.make(...)` or exit-aware `Cache.makeWith(...)` | -| Outgoing HTTP | Verified `effect/unstable/http` `HttpClient` surface | -| Time-sensitive test | `TestClock`, never real sleeping | - -## Boundary rules - -- Keep HTTP handlers thin: decode input, read context, call services, map typed errors. -- Keep business rules in domain functions or services, not transport handlers. -- Wrap HTTP clients, SDKs, CLIs, and external integrations in named effects at adapter boundaries. -- Decode persisted or external values that are not already trusted. -- Keep network/provider calls outside authoritative database transactions. -- Retry only when idempotency is proven; let exhausted failures remain visible without a truthful fallback. - -## Do nots - -- Do not use `as any`, non-null assertions, or unchecked casts to silence Effect typing problems. -- Do not introduce `Schema.Class` or `Schema.TaggedClass` as default application models. -- Do not hand-roll `_tag` errors when `Schema.TaggedErrorClass` fits. -- Do not use cause-level recovery when typed-error recovery is enough. -- Do not use `Layer.mergeAll(...)` or `provideMerge(...)` as blind make-it-compile tools. -- Do not hide required authority, credentials, persistence, or transports behind `Context.Reference` defaults. -- Do not add arbitrary `Effect.sleep(...)` to tests when deterministic synchronization exists. -- Do not hand-roll Map/TTL/prune caches when `effect/Cache` fits. - -## Provenance and integration changes - -The guidance and eight progressive-disclosure references were vendored from Kit Langton's MIT-licensed `skills/effect` at commit `30dee8607214c893dd89f6eee65c669ef3dce8c9` on 2026-07-15. API claims were checked against official `effect@4.0.0-beta.98` source at tag commit `3e4abbcb0d0e9a5e82b6b88c7ef7ab69900105ec`. - -Integration changes are limited to the renamed skill, strict Effect Kit routing, package/lockfile gating, explicit prerelease and unstable-module cautions, removal of mutable runtime lookups, compacting duplicated selection prose, and Docks-required reference tables of contents. The existing Effect 3.x setup, port, and specialist skills remain separate. - -## When this skill does not apply - -- Effect 3.x implementation or review — use **`effect-ts-specialist`**. -- First-time Effect 3.x setup — use **`effect-ts-setup`**. -- Fastify, Next.js, or React porting specifically to Effect 3.x — use **`effect-ts-port`**. -- Effect v4 installation, v3-to-v4 migration, or dependency upgrades — no Effect Kit skill covers these in this release. diff --git a/plugins/effect-kit/skills/engineering/effect-v4/references/CACHING.md b/plugins/effect-kit/skills/engineering/effect-v4/references/CACHING.md deleted file mode 100644 index f9ea1064..00000000 --- a/plugins/effect-kit/skills/engineering/effect-v4/references/CACHING.md +++ /dev/null @@ -1,76 +0,0 @@ -# Caching, Memoization, And Request Dedupe - -Use this when memoizing expensive lookups, caching per-key results with TTL, deduplicating concurrent identical calls, or considering request batching. - -Prefer `effect/Cache` over a `Map` + timestamp + prune-loop cache when its keyed memoization, TTL, capacity, lifecycle, and eviction semantics fit. - -## Core Rules - -- `Cache.make({ capacity, lookup, timeToLive })` caches per-key lookups with one fixed TTL for all entries. -- `Cache.makeWith(lookup, { capacity, timeToLive(exit, key) })` computes TTL per entry from the lookup's `Exit` — the tool for "cache successes, not failures". -- Concurrent `Cache.get` calls for the same missing key share one pending lookup — dedupe is built in; do not add your own in-flight tracking. -- `capacity` is required and bounds the cache; stop writing manual prune/evict loops. -- Return a zero TTL (`0` or `"0 millis"`) from `timeToLive` to avoid caching transient failures or degraded fallbacks without failing the caller. A short negative-cache TTL can be appropriate for stable failures such as not-found results. -- `Cache.invalidate(cache, key)` / `Cache.refresh(cache, key)` handle explicit staleness; `Cache.has` checks without triggering a lookup. -- Cache construction is effectful. Build the cache once in the owning layer/scope and share the handle; a cache built per call caches nothing. -- For a single value (no key), use `Effect.cached(effect)` or `Effect.cachedWithTTL(effect, ttl)` instead of a one-key Cache. -- For cached resources that need cleanup (connections, clients), use `ScopedCache`. - -## Exit-Aware TTL (cache successes, skip degraded results) - -```ts -import { Cache, Duration, Effect, Exit } from "effect" - -const makeResolver = Effect.gen(function* () { - const cache = yield* Cache.makeWith( - (channelRef: string) => resolveUncached(channelRef), // never-failing, returns { where, cacheable } - { - capacity: 300, - timeToLive: (exit) => - Exit.isSuccess(exit) && exit.value.cacheable ? "10 minutes" : Duration.zero, - }, - ) - return (channelRef: string) => - Cache.get(cache, channelRef).pipe(Effect.map((resolved) => resolved.where)) -}) -``` - -This replaces a hand-rolled `Map` plus prune logic, and upgrades it: repeated rows pointing at the same key during one burst share a single provider call. - -## Expensive Client Acquisition Belongs In The Layer, Not The Lookup - -A cache cannot fix a lookup that pays a scoped acquisition per call, such as SDK client construction or authentication. Acquire clients once via the owning layer (`Layer.build` inside a `Layer.unwrap(Effect.gen(...))` composition, or a service dependency) so the cached lookup is a plain call: - -```ts -// Bad: every cache miss acquires a fresh client -const lookup = (id: string) => - getRecord(id).pipe(Effect.provide(apiClientLayer(options))) - -// Good: client built once for the layer's lifetime; misses are one API call -// Layer.build requires Scope.Scope; acquire this inside the owning layer's scope. -const context = yield* Layer.build(apiClientLayer(options)) -const lookup = (id: string) => Context.get(context, ApiClient).getRecord(id) -``` - -## Request Batching (`Effect.request` + `RequestResolver`) - -Batching exists for backends with a real batch endpoint: the resolver receives an array of pending requests and can collapse them into one wire call. - -- Use it when the API can answer N keys in one call (SQL `IN (...)`, DataLoader-style endpoints, batch GET). -- Do not reach for it when the backend only has per-item endpoints (most REST provider APIs): a batched resolver still loops one call per entry, so it buys nothing over `Effect.forEach(items, f, { concurrency })` plus `Cache` for dedupe/memoization. -- `RequestResolver.batchN(resolver, n)` bounds batch size; `RequestResolver.makeGrouped` groups requests that must resolve through different targets. - -Selection guide: - -- Same key requested repeatedly over time → `Cache`. -- Same key requested concurrently in one burst → `Cache` (shared pending lookup). -- Many distinct keys, backend has a batch endpoint → `Effect.request` + `RequestResolver`. -- Many distinct keys, per-item endpoint only → `Effect.forEach(..., { concurrency: n })`, optionally through a `Cache`. - -## Do Nots - -- Do not hand-roll Map/TTL/prune caches, in-flight dedupe maps, or LRU logic when `Cache` fits. -- Choose failure TTLs by semantics. Skip transient failures and degraded fallbacks by default; bounded negative caching can protect an upstream from repeated stable failures. -- Do not build a cache inside the request handler or per call — hoist it to the owning layer. -- Do not adopt `RequestResolver` batching for per-item REST endpoints just because "batching" sounds faster. -- Do not put scoped client acquisition inside the cache lookup; acquire once in the layer. diff --git a/plugins/effect-kit/skills/engineering/effect-v4/references/CONFIG.md b/plugins/effect-kit/skills/engineering/effect-v4/references/CONFIG.md deleted file mode 100644 index d081e698..00000000 --- a/plugins/effect-kit/skills/engineering/effect-v4/references/CONFIG.md +++ /dev/null @@ -1,67 +0,0 @@ -# Config - -Use this when reading runtime configuration, env vars, `.env` files, provider-specific settings, or writing `layerConfig(...)` helpers. - -Read runtime configuration through Effect `Config` recipes and provider layers, not direct `process.env` access inside application logic. - -```ts -export const dataDirectoryConfig = Config.schema( - AbsolutePath, - "APP_DATA_DIR", -) - -export const layerFromEnvironment = Layer.effect( - Configuration.Service, - Effect.gen(function* () { - const apiKey = yield* Config.redacted("API_KEY") - const optionalModel = yield* Config.option(Config.string("MODEL")) - const enabled = yield* Config.boolean("FEATURE_ENABLED").pipe( - Config.withDefault(false), - ) - - return Configuration.Service.of({ apiKey, optionalModel, enabled }) - }), -) -``` - -## Config Recipes - -- `Config` is yieldable and reads the current `ConfigProvider` reference. -- The default provider is `ConfigProvider.fromEnv()`. -- Use `Config.redacted(...)` for credentials. -- Use `Config.schema(...)` or `Config.mapOrFail(...)` for refined values. -- Use `Config.option(...)` for semantic absence. -- Use `Config.withDefault(...)` for missing-data defaults only; malformed values still fail. -- Use `Config.orElse(...)` only when intentionally catching any config parse failure. -- Use `Config.unwrap(...)` / `Config.Wrap` for `layerConfig(...)` helpers. - -## Providers - -- Use `ConfigProvider.layer(provider)` to replace the active provider for an app or suite. -- Use `ConfigProvider.layerAdd(provider)` for fallbacks; pass `{ asPrimary: true }` when the added provider must override the current provider. -- Use `ConfigProvider.fromUnknown(...)` for deterministic test config. -- Use `ConfigProvider.fromEnv(...)` for environment variables. -- Use `ConfigProvider.constantCase` when camelCase schema keys should read `SCREAMING_SNAKE_CASE` env vars. -- Use `ConfigProvider.nested(...)` to scope a provider under a prefix. -- Treat `.env`, directory, and environment providers as startup/boundary sources, not business-workflow reads. - -## Layer Config Helpers - -Library-style layers often expose both concrete `layer(options)` and config-backed `layerConfig(options: Config.Wrap)`. - -```ts -export const layerConfig = ( - config: Config.Wrap, -) => - Layer.effect( - Client.Service, - Config.unwrap(config).pipe( - Effect.flatMap(makeClient), - Effect.map((client) => Client.Service.of(client)), - ), - ) -``` - -Use this pattern when a service naturally supports runtime config while still allowing tests to pass concrete values. - -Use `Layer.succeed(AppConfiguration.Service, testConfig)` when the app already wraps environment config in an application service and the test does not need to exercise Config decoding itself. diff --git a/plugins/effect-kit/skills/engineering/effect-v4/references/HTTP_CLIENTS.md b/plugins/effect-kit/skills/engineering/effect-v4/references/HTTP_CLIENTS.md deleted file mode 100644 index 5935aae3..00000000 --- a/plugins/effect-kit/skills/engineering/effect-v4/references/HTTP_CLIENTS.md +++ /dev/null @@ -1,108 +0,0 @@ -# HTTP Clients - -Use this when writing outgoing HTTP calls, Effect HttpClient adapters, status classification, HTTP retries, or rate limiting. - -Use Effect HTTP client modules for outgoing HTTP in app/provider code: - -- `effect/unstable/http/HttpClient` -- `effect/unstable/http/HttpClientRequest` -- `effect/unstable/http/HttpClientResponse` -- `effect/unstable/http/HttpClientError` - -Prefer Effect HttpClient in Effect application and provider code when its typed errors, layers, and transforms are useful. Raw `fetch` remains reasonable for browser or edge constraints, small adapters, platform transports, and libraries that intentionally avoid unstable Effect HTTP APIs. - -## Contents - -- [Boundary Shape](#boundary-shape) -- [Effect HttpClient](#effect-httpclient) -- [Retry And Rate Limits](#retry-and-rate-limits) -- [Raw Fetch Exception](#raw-fetch-exception) - -## Boundary Shape - -HTTP adapter methods should be named effects that own the full boundary: - -- construct request -- attach auth and headers -- execute request -- classify status -- decode response body -- map transport/status/decode failures to typed domain errors -- apply retry/rate-limit policy where idempotent - -Keep raw provider/network effects outside business services and database transactions. - -## Effect HttpClient - -Useful APIs: - -- `HttpClient.get(...)`, `post(...)`, `put(...)`, `patch(...)`, `del(...)`, `execute(...)` for service accessors. -- `HttpClient.mapRequest(...)` / `mapRequestEffect(...)` for configured client transforms. -- `HttpClientRequest.prependUrl(...)` for base URLs. -- `HttpClientRequest.bearerToken(...)` for bearer auth. -- `HttpClientRequest.acceptJson` for JSON accept headers. -- `HttpClientRequest.bodyJson(...)` for effectful JSON body encoding. -- `HttpClientRequest.schemaBodyJson(...)` for schema-backed JSON body encoding. -- `HttpClient.filterStatusOk` / `HttpClientResponse.filterStatusOk` before decoding when non-2xx responses are failures. -- `HttpClientResponse.schemaBodyJson(...)` for body-only decoding, `schemaJson(...)` for status/headers/body decoding, and `schemaNoBody(...)` for status/headers decoding. -- `HttpClient.retryTransient(...)` for common transient HTTP failures. -- `HttpClient.withRateLimiter(...)` for proactive pacing and learning from rate-limit headers. It requires a `RateLimiter` plus initial window, limit, and key options; it adds `RateLimiterError` to the error channel and retries `429` responses by default. - -## Retry And Rate Limits - -Use `HttpClient.retryTransient(...)` for common transient HTTP failures: - -- transport errors -- timeouts -- `408` -- `429` -- `500` -- `502` -- `503` -- `504` - -Use `HttpClient.withRateLimiter(...)` when the client should proactively pace requests and learn from rate-limit / `Retry-After` headers. - -Use operation-level `Effect.retry(...)` when retry depends on domain-specific typed errors, provider payloads, or idempotency rules. Read `SCHEDULING.md` for custom schedules and `retryAfterMs` typed-provider patterns. - -## Raw Fetch Exception - -Use raw `fetch` deliberately when implementing a platform transport, adapting an API that cannot use Effect HttpClient, or targeting a runtime/library boundary where the unstable Effect HTTP modules are not an appropriate dependency. - -If a temporary raw `fetch` boundary is unavoidable, keep it inside an adapter service and still use Effect boundary discipline. - -```ts -const request = Effect.fn("Provider.request")(function* (input: RequestInput) { - const response = yield* Effect.tryPromise({ - try: (signal) => fetch(input.url, { signal, headers: input.headers }), - catch: (cause) => new ProviderError({ operation: "Provider.request", cause }), - }) - - if (!response.ok) { - return yield* Effect.fail(new ProviderRejected({ - operation: "Provider.request", - status: response.status, - })) - } - - const json = yield* Effect.tryPromise({ - try: () => response.json(), - catch: (cause) => new ProviderError({ operation: "Provider.decodeJson", cause }), - }) - - return yield* Schema.decodeUnknownEffect(ResponseSchema)(json).pipe( - Effect.mapError((cause) => - new ProviderError({ operation: "Provider.decodeResponse", cause }), - ), - ) -}) -``` - -Guidance: - -- Prefer replacing this with Effect HttpClient before adding more behavior. -- Wire `AbortSignal` from `Effect.tryPromise` into `fetch` when raw fetch is unavoidable. -- Classify HTTP status before decoding successful payloads. -- Decode unknown response bodies with Schema at the boundary. -- Preserve provider evidence needed for diagnosis, but redact secrets and private payloads. -- Apply retry only for idempotent operations. diff --git a/plugins/effect-kit/skills/engineering/effect-v4/references/SCHEDULING.md b/plugins/effect-kit/skills/engineering/effect-v4/references/SCHEDULING.md deleted file mode 100644 index 739d836d..00000000 --- a/plugins/effect-kit/skills/engineering/effect-v4/references/SCHEDULING.md +++ /dev/null @@ -1,144 +0,0 @@ -# Scheduling And Retry - -Use this when writing retries, repeats, polling workers, backoff, jitter, rate-limit-aware policies, timeouts, or pass loops. - -Use `Schedule` for retry, polling, pacing, and repeated background work instead of hand-rolled `while (true)` loops with sleeps. - -## Contents - -- [Core Rules](#core-rules) -- [Polling Workers](#polling-workers) -- [Per-Item Failure Isolation](#per-item-failure-isolation) -- [Reusable Retry Policy](#reusable-retry-policy) -- [Rate-Limit-Aware Typed Retry](#rate-limit-aware-typed-retry) -- [Timeouts And Delays](#timeouts-and-delays) - -## Core Rules - -- `Effect.retry(...)` retries typed failures; defects and interruptions are not retried. -- `Effect.repeat(...)` repeats successful effects; failures stop repetition unless the pass handles them first. -- The source effect runs once before the schedule is stepped. -- `Schedule.recurs(3)` means three retries/repetitions after the initial run. -- `Schedule.spaced(...)` waits after work completes. -- `Schedule.fixed(...)` aligns executions to a cadence. -- Use `Schedule.exponential(...)` or `Schedule.fibonacci(...)` for backoff. -- Add `Schedule.jittered` to avoid synchronized retry storms. -- Use `Schedule.recurs(...)` for a counter schedule or `Schedule.upTo({ times })` to bound a delay schedule. -- Use `Schedule.tapInput(...)` to log retry inputs. -- Use `Schedule.tap(...)` when full schedule metadata matters. -- Use `Effect.retryOrElse(...)` when exhausted retries need a fallback/reporting effect. -- Retry only at the narrowest boundary with proven idempotency. -- Exhausted failures should remain visible unless the boundary has a truthful fallback. - -## Polling Workers - -Prefer typed pass failures over cause recovery. - -```ts -const pass = runPass().pipe( - Effect.tapError((error) => - Effect.logError("Worker.pass_failed", error), - ), - Effect.ignore, -) - -const run = pass.pipe( - Effect.repeat(Schedule.spaced("1 second")), -) -``` - -This shape says expected operational pass failures are logged and the worker continues. Defects still defect and can reach supervision. - -Use cause-level recovery only at supervision boundaries where the policy is truly "report non-interrupt failure and continue". - -```ts -const logNonInterruptCauseAndContinue = (message: string) => - Effect.catchCauseIf( - (cause) => !Cause.hasInterrupts(cause), - (cause) => Effect.logError(message, cause), - ) -``` - -Do not catch causes just to make failures disappear. If only expected typed failures should be recoverable, use `Effect.catchIf(...)`, `Effect.catchFilter(...)`, `Effect.catchTag(...)`, or `Effect.retry(...)` on those typed errors instead. - -## Per-Item Failure Isolation - -For batch workers, catch expected item-level typed failures around each item so one bad item does not stall the batch. - -```ts -yield* Effect.forEach( - items, - (item) => - processItem(item).pipe( - Effect.tapError((error) => - Effect.logError("Worker.item_failed", error).pipe( - Effect.annotateLogs({ itemId: item.id }), - ), - ), - Effect.ignore, - ), - { discard: true, concurrency: 5 }, -) -``` - -Only do this when retrying the item later is truthful or skipping the item is the product policy. - -## Reusable Retry Policy - -```ts -const projectionRetrySchedule: Schedule.Schedule = - Schedule.exponential("100 millis").pipe( - Schedule.jittered, - Schedule.upTo({ times: 5 }), - ) - -const reconcileWithRetry = (target: Target) => - reconcile(target).pipe( - Effect.retryOrElse( - projectionRetrySchedule.pipe( - Schedule.tapInput((error) => - Effect.logWarning("Agent.Projection.reconcile.retrying").pipe( - Effect.annotateLogs({ operation: error.operation }), - ), - ), - ), - (error) => Effect.logError("Agent.Projection.reconcile.stopped", error), - ), - ) -``` - -Use this when the operation is idempotent and retry state is useful for logs or metrics. - -## Rate-Limit-Aware Typed Retry - -For provider errors that carry `retryAfterMs`, let the schedule use the larger of the backoff delay and the provider delay. - -```ts -type RateLimited = { - readonly retryAfterMs?: number | undefined -} - -const providerRetrySchedule: Schedule.Schedule = - Schedule.exponential("200 millis").pipe( - Schedule.jittered, - Schedule.upTo({ times: 5 }), - Schedule.passthrough, - Schedule.modifyDelay(({ input, duration }) => - Effect.succeed( - input.retryAfterMs === undefined - ? duration - : Duration.max(duration, Duration.millis(input.retryAfterMs)), - ), - ), - ) -``` - -Use this for operation-level retries over typed provider errors. For Effect HttpClient-level 429 handling and proactive pacing, read `HTTP_CLIENTS.md`. - -## Timeouts And Delays - -- Use `Effect.timeout(...)` when the operation has a real deadline. -- Use `Effect.delay(...)` when one operation should start later. -- Use `Effect.sleep(...)` inside production workflows only when sleeping itself is the domain behavior. -- Avoid manual sleep loops; use `Effect.repeat(...)` with `Schedule` for recurring work. -- In tests, use `TestClock` rather than real time. Read `TESTING.md`. diff --git a/plugins/effect-kit/skills/engineering/effect-v4/references/SCHEMA.md b/plugins/effect-kit/skills/engineering/effect-v4/references/SCHEMA.md deleted file mode 100644 index d3eaf87a..00000000 --- a/plugins/effect-kit/skills/engineering/effect-v4/references/SCHEMA.md +++ /dev/null @@ -1,139 +0,0 @@ -# Schema And Data Modeling - -Use this when touching data models, DTOs, row schemas, wire contracts, brands, variants, optional fields, or decoders. - -## Contents - -- [Records](#records) -- [Field And Contract Reuse](#field-and-contract-reuse) -- [Optionality And Defaults](#optionality-and-defaults) -- [Nominal Values](#nominal-values) -- [Variants](#variants) -- [Errors](#errors) - -## Records - -Default to `Schema.Struct(...)` plus a same-name `interface`. - -```ts -export const User = Schema.Struct({ - id: UserId, - name: Schema.NonEmptyString, - email: Schema.optionalKey(Schema.String), -}) - -export interface User extends Schema.Schema.Type {} -``` - -Guidance: - -- Add `.annotate({ identifier: "User" })` only when tooling consumes it: HTTP API, RPC, OpenAPI/JSON Schema, docs, diagnostics, or codegen. -- Use `schema.make(...)` when construction is trusted. -- Use `schema.makeEffect(...)` when construction failure should stay in the Effect error channel. -- Decode unknown input at boundaries with `Schema.decodeUnknownEffect(...)` by default. -- Use `Schema.decodeUnknownSync(...)` only in scripts, tests, or startup paths where throwing is acceptable. -- Use `Schema.decodeUnknownOption(...)` only when mismatch details are intentionally discarded. -- Use `Schema.decodeUnknownResult(...)` for pure code that wants explicit success/failure without Effect. - -## Field And Contract Reuse - -Reuse fields directly when contracts are semantically related. - -```ts -export const CreateUserInput = Schema.Struct({ - name: User.fields.name, - email: User.fields.email, -}) - -export const StoredUser = User.pipe( - Schema.fieldsAssign({ - createdAt: Schema.DateTimeUtcFromString, - }), -) -``` - -Guidance: - -- Use `.fields`, `Schema.fieldsAssign(...)`, and `.mapFields(...)` when contracts are genuinely related. -- Use `Schema.encodeKeys(...)` when decoded TypeScript names differ from encoded wire/storage keys and naming is the only difference. -- Keep explicit mapping when behavior, joins, validation, or domain translation is involved. -- Use `Schema.extendTo(...)` sparingly for decoded-only derived fields. -- Use field reuse to build small related contracts, not one oversized inheritance-by-schema object. - -## Optionality And Defaults - -- Use `Schema.optionalKey(...)` for absent JSON/storage keys. -- Use `Schema.optional(...)` only when explicit `undefined` is part of the contract. -- Use `Schema.NullOr`, `Schema.UndefinedOr`, or `Schema.NullishOr` only when nullish values are truly part of the encoded contract. -- Keep normalized defaulted values as required fields and apply defaults in constructors/decoding. -- Do not make domain values optional merely for construction convenience. - -## Nominal Values - -- Use constrained branded schemas for scalar IDs and value objects. -- Use normal schema constraints before `Schema.brand(...)` for most code. -- Reach for `Schema.fromBrand(...)` only when the project already models brands with `Brand` constructors or wants the check packaged with the brand constructor. - -## Variants - -```ts -type Step = Data.TaggedEnum<{ - Continue: { readonly cursor: number } - Finished: { readonly count: number } -}> - -export const Step = Data.taggedEnum() - -const next = Step.Continue({ cursor: 10 }) -const label = Step.$match(next, { - Continue: ({ cursor }) => `continue at ${cursor}`, - Finished: ({ count }) => `finished ${count}`, -}) -``` - -```ts -export const Event = Schema.TaggedUnion({ - Started: { runId: RunId }, - Finished: { runId: RunId, result: Schema.Json }, -}) - -export type Event = typeof Event.Type - -const event = Event.cases.Started.make({ runId }) -const label = Event.match(event, { - Started: ({ runId }) => `started ${runId}`, - Finished: ({ runId }) => `finished ${runId}`, -}) -``` - -Guidance: - -- Use `Data.TaggedEnum` for internal control-flow algebras; it provides constructors, `$is`, and exhaustive `$match`. Do not add a Schema solely to obtain these utilities. -- Use `Schema.TaggedStruct` for the ordinary Effect-owned `_tag` variant. -- Use `Schema.TaggedUnion` when the union needs decoding, encoding, persistence, wire validation, JSON Schema derivation, or schema composition. -- Prefer a principled split over forcing one representation everywhere: Data internally, Schema at boundaries. -- Use `Schema.tag(...)` when an external contract has a custom discriminator field such as `type` or `kind`; combine those structs with `Schema.toTaggedUnion("type")` when union helpers are needed. -- If the encoded contract omits the discriminant, use `Schema.tagDefaultOmit(...)` deliberately. -- Avoid `Schema.Class` and `Schema.TaggedClass` for new data models. - -## Errors - -`Schema.TaggedErrorClass` is the explicit class exception for typed Effect errors. - -```ts -export class PersistenceError extends Schema.TaggedErrorClass()( - "UserRepo.PersistenceError", - { - operation: Schema.String, - cause: Schema.Defect(), - }, -) {} -``` - -Guidance: - -- Map infrastructure failures into domain-specific tagged errors at service boundaries. -- Include operation labels when they help diagnose adapter, persistence, provider, or transport failures. -- Use schema unions for public API or transport error surfaces. -- Use `Schema.Defect()` for defect-like payloads. -- Preserve interruption when catching broad causes at ingress, worker, or stream boundaries. diff --git a/plugins/effect-kit/skills/engineering/effect-v4/references/SERVICES_LAYERS.md b/plugins/effect-kit/skills/engineering/effect-v4/references/SERVICES_LAYERS.md deleted file mode 100644 index f863e14f..00000000 --- a/plugins/effect-kit/skills/engineering/effect-v4/references/SERVICES_LAYERS.md +++ /dev/null @@ -1,177 +0,0 @@ -# Services, Layers, And Modules - -Use this when defining service tags, module surfaces, layer implementations, runtime wiring, typed errors, or `Effect.fn` operation boundaries. - -## Contents - -- [Module Surface](#module-surface) -- [Layer Constructors](#layer-constructors) -- [Long-Lived Work](#long-lived-work) -- [Runtime Wiring](#runtime-wiring) -- [Effect.fn](#effectfn) -- [Operation Error Helpers](#operation-error-helpers) - -## Module Surface - -One opinionated application-module style uses file-local role names and one canonical ES module namespace projection. Follow the existing codebase's module style when it has one; this convention is not required by Effect. - -```ts -export interface Interface { - readonly get: (id: UserId) => Effect.Effect -} - -export class Service extends Context.Service()( - "@app/UserRepo", -) {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient - - const get = Effect.fn("UserRepo.get")(function* (id: UserId) { - // ... - }) - - return Service.of({ get }) - }), -) - -export class NotFound extends Schema.TaggedErrorClass()( - "UserRepo.NotFound", - { id: UserId }, -) {} - -export * as UserRepo from "./user-repo.js" -``` - -Consumers use the module namespace. - -```ts -import { UserRepo } from "./user-repo.js" - -const program = Effect.gen(function* () { - const repo = yield* UserRepo.Service - return yield* repo.get(id) -}) -``` - -The self-export is deliberate. It lets the file remain the module while giving every consumer the same domain-first name, without a TypeScript `namespace`, wrapper object, or repeated consumer-side aliases. - -```ts -// Sibling module: import the owning leaf directly. -import { UserRepo } from "./user-repo.js" - -// Folder or package barrel: relay the identity established by the leaf. -export { UserRepo } from "./user-repo.js" -``` - -Guidance: - -- Do not name the tag class `UserRepo` inside `user-repo.ts`; the module namespace is the domain name. -- In this module style, single-file modules self-export their canonical namespace at the bottom: `export * as UserRepo from "./user-repo.js"`. -- Sibling modules import that namespace from the owning leaf; they do not import through their own aggregate barrel. -- Folder and package barrels relay established leaf identities with `export { UserRepo } from "./user-repo.js"`. -- The resulting `UserRepo.UserRepo === UserRepo` self-reference is unusual. Use this pattern only where the runtime and toolchain support it; otherwise use named exports or a separate barrel. -- Export only intentional surface; keep local schemas, row codecs, helpers, and implementation details unexported. -- Do not introduce TypeScript `namespace` declarations for organization. -- Use a named service class such as `class UserRepo extends Context.Service...` when an external library or existing codebase does not use module namespace style. - -## Layer Constructors - -Choose the layer constructor that matches the thing produced. - -```ts -Layer.succeed(Service, impl) // already-built service -Layer.sync(Service, () => impl) // lazy synchronous service -Layer.effect(Service, makeEffect) // effectful service acquisition -``` - -Guidance: - -- Default real implementations to `Layer.effect(Service, Effect.gen(...))`. -- Use `Layer.effectContext(...)` when one acquisition intentionally supplies multiple services, especially first-class test stubs or one client backing several service tags. -- Use `Layer.unwrap(...)` when config or runtime discovery chooses/builds the layer. -- Use `Layer.fresh(...)` or `Effect.provide(layer, { local: true })` only when a test or operation needs isolated acquisition. -- Use `Context.Reference` rarely, only for ambient/defaultable runtime references where a safe default is real. - -## Long-Lived Work - -A layer that starts a stream, listener, worker, subscription, or forever loop must fork that work into the layer scope. Layer acquisition must complete. - -```ts -export const layer = Layer.effectDiscard( - Effect.gen(function* () { - const events = yield* Events.Service - - yield* events.stream.pipe( - Stream.runForEach(handleEvent), - Effect.forkScoped, - ) - }), -) -``` - -Guidance: - -- Use `Effect.forkScoped`, `FiberSet`, or `FiberMap` for scoped background work. -- Do not run forever work inline during layer acquisition. -- Do not expose public `start` methods unless the domain explicitly needs manual lifecycle control. - -## Runtime Wiring - -- Use `Layer.provide(...)` to hide an implementation dependency. -- Use `Layer.provideMerge(...)` only when the dependency should remain exposed for downstream consumers. -- Use `Layer.mergeAll(...)` for independent exposed layers. -- Prefer flat, topologically sorted runtime layer values with named subgraphs. -- Avoid using `provideMerge` as a blind make-it-compile tool. -- Avoid hiding important authority or lifecycle dependencies behind broad invisible provisioning. - -## Effect.fn - -Use extra `Effect.fn(...)` arguments for wrappers that apply to the whole function call. Each transform receives `(effect, ...originalArgs)`. - -```ts -const readAttachment = Effect.fn("Attachment.read")( - function* (ref: AttachmentRef) { - return yield* api.read(ref) - }, - (effect, ref) => - effect.pipe( - attachmentError("Attachment.read", { attachmentId: ref.id }), - ), -) -``` - -Good whole-function transforms: - -- error classification -- localized recovery -- logging annotations -- spans -- retry -- timeout -- ensuring cleanup -- small local provisioning -- result mapping - -Guidance: - -- Keep the generator body focused on the core workflow. -- Use transforms when the wrapper needs original arguments. -- Do not build long clever pipelines; one or two transforms is usually enough. -- Do not use this for local branch-level handling inside the workflow. - -## Operation Error Helpers - -For boundary errors with operation labels, prefer a shared curried `mapError` helper over hand-writing wrappers in every module. - -```ts -const persistenceError = operationError(PersistenceError.make) - -const row = yield* query.pipe( - persistenceError("UserRepository.findById"), -) -``` - -Name the local helper after the error it produces, such as `persistenceError`, `projectionError`, or `processingError`. Use `Effect.fn(...)` and spans for observability in addition to payload labels, not instead of them. diff --git a/plugins/effect-kit/skills/engineering/effect-v4/references/STREAMS.md b/plugins/effect-kit/skills/engineering/effect-v4/references/STREAMS.md deleted file mode 100644 index 166ccf29..00000000 --- a/plugins/effect-kit/skills/engineering/effect-v4/references/STREAMS.md +++ /dev/null @@ -1,148 +0,0 @@ -# Streams - -Use this when working with `Stream`, event sources, async iterables, queue/pubsub-backed streams, pagination, backpressure, throttling, debouncing, or long-lived stream consumers. - -## Contents - -- [Mental Model](#mental-model) -- [Source Chooser](#source-chooser) -- [Transformation Chooser](#transformation-chooser) -- [Consumption Chooser](#consumption-chooser) -- [Long-Lived Consumers](#long-lived-consumers) -- [Queues, PubSub, And SubscriptionRef](#queues-pubsub-and-subscriptionref) -- [Backpressure And Buffers](#backpressure-and-buffers) -- [Error Handling](#error-handling) -- [Keyed Concurrency](#keyed-concurrency) -- [Tests](#tests) - -## Mental Model - -`Stream` is an effectful source that can emit many `A` values over time, fail with `E`, and require services `R`. Streams are pull-based and backpressured; consumption controls demand. - -Use streams for sources that are naturally many-valued and time-ordered: - -- gateway events -- provider callbacks adapted through queues -- subscription/event logs -- paginated APIs -- file/stdin/platform streams -- scheduled ticks when values matter -- pipelines with filtering, mapping, buffering, throttling, or bounded concurrent processing - -Do not use streams just to loop forever. For one repeated effect with no emitted values, use `Effect.repeat(...)` with `Schedule`; read `SCHEDULING.md`. - -## Source Chooser - -- In-memory values: `Stream.make(...)` or `Stream.fromIterable(...)`. -- Test fixtures: `Stream.fromIterable(...)`, often with `Stream.concat(Stream.never)` for an open subscription. -- Queue-backed callback boundary: `Queue` plus `Stream.fromQueue(...)`. -- Broadcast events: `PubSub` plus `Stream.fromPubSub(...)`. -- Latest-value state plus updates: `SubscriptionRef`. -- Schedule-generated ticks/values: `Stream.fromSchedule(...)`. -- Paginated pull APIs: `Stream.paginate(...)`; its step function is already effectful, returning `Effect<[chunk, Option]>`. -- Async iterable/platform source: `Stream.fromAsyncIterable(...)` when no native Effect source exists. -- Effect that produces a stream after reading services/config: `Stream.unwrap(...)`. - -## Transformation Chooser - -- Pure transformation: `Stream.map(...)`. -- Effectful transformation: `Stream.mapEffect(...)`. -- Bounded concurrent effectful transformation: `Stream.mapEffect(fn, { concurrency })`. -- Drop ordering when order is irrelevant and latency matters: `Stream.mapEffect(fn, { concurrency, unordered: true })`. -- One input to zero/many outputs: `Stream.flatMap(...)`. -- Multiple inner streams concurrently: `Stream.flatMap(fn, { concurrency })`. -- Keep only matching values: `Stream.filter(...)` / `Stream.filterEffect(...)`. -- Stateful transformation: `Stream.mapAccum(...)` / `Stream.mapAccumEffect(...)`. -- Paginated pull-to-pages: prefer `Stream.paginate(...)` over hand-rolled loops. There is no separate `Stream.paginateEffect`. - -## Consumption Chooser - -- Side-effecting consumer: `Stream.runForEach(...)`. -- Ignore elements but run the stream: `Stream.runDrain`. -- Tests/small finite streams: `Stream.runCollect`. -- First N values in tests: `Stream.take(n)` plus `Stream.runCollect`. -- Fold into a value: `Stream.runFold(...)`. -- Long-lived consumer in a layer: `stream.pipe(Stream.runForEach(...), Effect.forkScoped)`. - -Avoid `Stream.runCollect` on unbounded or production event streams. - -## Long-Lived Consumers - -Own long-lived stream consumers in layers and fork them into the layer scope. - -```ts -export const layer = Layer.effectDiscard( - Effect.gen(function* () { - const gateway = yield* Gateway.Service - - yield* gateway.events.pipe( - Stream.filter(isMessageEvent), - Stream.runForEach(handleEvent), - Effect.forkScoped, - ) - }), -) -``` - -Guidance: - -- Let the layer own the stream lifetime. -- Use `Effect.forkScoped` for the ordinary case. -- If methods need to fork work into the layer lifetime, capture `Scope.Scope` during layer acquisition and use `Effect.forkIn(scope)` internally. Do not expose the scope as public service API. -- Preserve stream failures unless the owning boundary has a truthful recovery policy. - -## Queues, PubSub, And SubscriptionRef - -- Use `Queue` when each event/item should be consumed by one consumer or worker. -- Use `PubSub` when every subscriber should see every event. -- Use `SubscriptionRef` when consumers need the current value and a stream of changes. -- Expose a `Stream` from service interfaces when callers should consume events, not push into the queue. -- Keep producer queues/private refs inside the implementation or test service. - -Good service shape: - -```ts -export interface Interface { - readonly events: Stream.Stream - readonly status: Stream.Stream -} -``` - -Implementation can use private `Queue` / `SubscriptionRef`; consumers see streams. - -## Backpressure And Buffers - -Prefer natural stream backpressure first. - -Use `Stream.buffer(...)` only when producer and consumer should decouple. - -- `strategy: "suspend"`: apply backpressure when full. -- `strategy: "dropping"`: drop new values when full. -- `strategy: "sliding"`: keep the latest values by dropping old ones. -- `capacity: "unbounded"`: rare; use only when growth is bounded elsewhere. - -Use `Stream.debounce(...)` for quiet-period behavior and `Stream.throttle(...)` / `Stream.throttleEffect(...)` for rate-shaped streams. - -## Error Handling - -- Prefer typed stream errors over defects. -- Use `Stream.mapError(...)` to translate errors at boundaries. -- Use `Stream.catchIf(...)`, `Stream.catchTag(...)`, or `Stream.catchFilter(...)` for typed recovery. -- Use `Stream.catchCause(...)` only at explicit supervision boundaries. -- Do not hide stream defects by default; let them reach the owning layer/runtime unless the stream is explicitly best-effort. - -## Keyed Concurrency - -For streams of work keyed by session/channel/id, prefer a named helper over ad hoc maps of fibers. - -If the codebase already has a keyed-run helper (for example a `runForEachKeyed` that runs different keys concurrently while serializing each key and coalescing pending values into one latest-value rerun), use it. Otherwise build one named helper with `FiberMap` rather than scattering fiber bookkeeping through consumers. - -Use this for projection/reconciliation streams where each key needs ordered processing but different keys can run in parallel. - -## Tests - -- Use `Stream.fromIterable(...)` for finite fixtures. -- Use `Stream.empty` for no events. -- Use `Stream.fromQueue(...)` with a test-owned `Queue` when the test needs to drive events interactively. -- Use `Stream.take(n)` plus `Stream.runCollect` for finite assertions. -- Avoid real sleeps; coordinate with `Deferred`, `Queue`, `Latch`, and `TestClock`. diff --git a/plugins/effect-kit/skills/engineering/effect-v4/references/TESTING.md b/plugins/effect-kit/skills/engineering/effect-v4/references/TESTING.md deleted file mode 100644 index fc8af6aa..00000000 --- a/plugins/effect-kit/skills/engineering/effect-v4/references/TESTING.md +++ /dev/null @@ -1,125 +0,0 @@ -# Testing - -Use this when writing Effect tests, tests involving time, retry, schedules, concurrency, workers, services, fakes, or config. - -## Contents - -- [Defaults](#defaults) -- [Synchronization Instead Of Sleeps](#synchronization-instead-of-sleeps) -- [First-Class App Test Stubs](#first-class-app-test-stubs) -- [Config In Tests](#config-in-tests) - -## Defaults - -- Use `it.effect` by default. -- Use `it.live` only when real time or live runtime services are the behavior under test. -- Use test layers and `ConfigProvider` rather than global mutation. -- Use `TestClock.setTime` / `TestClock.adjust` for sleeps, schedules, retries, leases, and timeouts. -- Fork sleeping effects before advancing `TestClock`. -- Avoid arbitrary `Effect.sleep(...)` in tests; it usually makes tests slow and flaky. -- Assert typed failures, rollback, interruption, finalization, retry bounds, idempotency, concurrency laws, and malformed persistence where relevant. - -```ts -it.effect("finds a user", () => - Effect.gen(function* () { - const users = yield* UserRepo.Service - const result = yield* users.find(UserId.make("u1")) - expect(Option.isSome(result)).toBe(true) - }).pipe(Effect.provide(UserRepo.testLayer)), -) -``` - -## Synchronization Instead Of Sleeps - -- Use `Deferred` for one-shot readiness/completion signals. -- Use `Queue` for handing test-controlled work or observed events across fibers. -- Use `Latch` for reusable open/close coordination gates. -- Use `Ref` for shared test observation state. -- Use explicit test hooks when the production boundary can expose a deterministic synchronization point. - -```ts -it.effect("publishes exactly once", () => - Effect.gen(function* () { - const published = yield* Queue.unbounded() - const ready = yield* Deferred.make() - - const runWorker = makeWorker({ - onReady: () => Deferred.succeed(ready, undefined), - onPublish: (message) => Queue.offer(published, message), - }) - - yield* runWorker.pipe( - Effect.forkScoped, - ) - - yield* Deferred.await(ready) - const message = yield* Queue.take(published) - - expect(message).toEqual(expectedMessage) - }), -) -``` - -## First-Class App Test Stubs - -Use `TestInterface extends Interface`, `TestService`, and `testLayer` for reusable/stateful fakes. - -```ts -export interface Interface { - readonly send: (message: Message) => Effect.Effect -} - -export class Service extends Context.Service()( - "@app/Notifier", -) {} - -export interface TestInterface extends Interface { - readonly sentMessages: () => Effect.Effect> - readonly failNextSend: (error: SendError) => Effect.Effect -} - -export class TestService extends Context.Service()( - "@app/Notifier/Test", -) {} - -export const testLayer = Layer.effectContext( - Effect.gen(function* () { - const sent = yield* Ref.make>([]) - const nextFailure = yield* Ref.make>(Option.none()) - - const service = TestService.of({ - send: Effect.fn("Notifier.Test.send")(function* (message) { - const failure = yield* Ref.getAndSet(nextFailure, Option.none()) - if (Option.isSome(failure)) return yield* Effect.fail(failure.value) - yield* Ref.update(sent, (messages) => [...messages, message]) - }), - sentMessages: Effect.fn("Notifier.Test.sentMessages")(function* () { - return yield* Ref.get(sent) - }), - failNextSend: Effect.fn("Notifier.Test.failNextSend")(function* (error) { - yield* Ref.set(nextFailure, Option.some(error)) - }), - }) - - return Context.empty().pipe( - Context.add(Service, service), - Context.add(TestService, service), - ) - }), -) -``` - -Guidance: - -- The same object should back both the real `Service` tag and `TestService` tag. -- Production code depends only on the real service tag. -- Tests use `TestService` for control and inspection. -- Use function-valued service members, including zero-argument operations, so `Effect.fn` fits naturally. -- Use `Layer.succeed` for complete dead-simple static test implementations. -- Use `Layer.mock` only for tiny local partial mocks where omitted members should fail loudly if used. - -## Config In Tests - -Use `ConfigProvider.layer(ConfigProvider.fromUnknown(...))` when the test should exercise Config decoding. - -Use `Layer.succeed(AppConfiguration.Service, config)` when the app wraps decoded config in its own service and the test does not need to exercise env decoding. diff --git a/plugins/effect-kit/test/selftest.mjs b/plugins/effect-kit/test/selftest.mjs deleted file mode 100644 index 374e0864..00000000 --- a/plugins/effect-kit/test/selftest.mjs +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const repoRoot = path.resolve(pluginRoot, '../..'); -const skillRoot = path.join(pluginRoot, 'skills/engineering'); - -function skill(name) { - const file = path.join(skillRoot, name, 'SKILL.md'); - const source = fs.readFileSync(file, 'utf8'); - const match = source.match(/^description:\s*["'](.+)["']$/m); - assert.ok(match, `${name}: single-line quoted description is required by this regression`); - return { description: match[1], source }; -} - -const v3 = skill('effect-ts-specialist'); -const v4 = skill('effect-v4'); -const setup = skill('effect-ts-setup'); -const port = skill('effect-ts-port'); - -const contracts = [ - { - name: 'Effect 3.x implementation selects the existing specialist', - checks: [ - [v3.description, /Effect 3\.x/], - [v4.description, /Not for Effect 3\.x; use effect-ts-specialist/], - ], - }, - { - name: 'explicit Effect v4 selects the v4 specialist', - checks: [ - [v4.description, /explicitly requests Effect v4/], - [v3.description, /Not for Effect v4 \(use effect-v4\)/], - ], - }, - { - name: 'an Effect 4 dependency selects the v4 specialist', - checks: [[v4.description, /package\.json plus the lockfile resolve `effect` 4\.x/]], - }, - { - name: 'Effect setup remains on the v3 setup skill', - checks: [ - [setup.description, /bootstrapping Effect 3\.x/], - [v4.source, /Explicit Effect 3\.x setup request \| Use `effect-ts-setup`/], - ], - }, - { - name: 'Effect porting remains on the v3 port skill', - checks: [ - [port.description, /porting existing Fastify.*to Effect 3\.x/], - [v4.source, /Explicit Effect 3\.x Fastify.*port request \| Use `effect-ts-port`/], - ], - }, - { - name: 'generic TypeScript selects no Effect skill', - checks: [[v4.source, /Generic TypeScript request with no Effect signal \| Do not use an Effect Kit skill/]], - }, - { - name: 'package and lockfile evidence are both mandatory', - checks: [ - [v4.source, /inspect both `package\.json` and the repository lockfile/], - [v4.source, /`package\.json` and lockfile disagree, or the resolved major is unclear \| Stop/], - ], - }, - { - name: 'Effect v4 setup and porting never reach v3 skills', - checks: [ - [setup.description, /Not for Effect v4 setup \(unsupported\)/], - [port.description, /Not for Effect v4 porting\/migration \(unsupported\)/], - [v4.source, /Effect v4 setup\/port\/migration request \| Report unsupported/], - ], - }, -]; - -for (const contract of contracts) { - for (const [artifact, pattern] of contract.checks) assert.match(artifact, pattern, contract.name); -} - -for (const relative of ['.claude-plugin/plugin.json', '.codex-plugin/plugin.json']) { - const manifest = JSON.parse(fs.readFileSync(path.join(pluginRoot, relative), 'utf8')); - assert.match(manifest.description, /Effect 3\.x/); - assert.match(manifest.description, /Effect v4 beta/); -} - -const marketplace = JSON.parse(fs.readFileSync(path.join(repoRoot, '.claude-plugin/marketplace.json'), 'utf8')); -const entry = marketplace.plugins.find((plugin) => plugin.name === 'effect-kit'); -assert.ok(entry, 'effect-kit marketplace entry must exist'); -assert.match(entry.description, /Effect v4 beta/); - -console.log(`effect-kit self-test PASSED: ${contracts.length} shipped routing contracts and manifest discovery checks`); diff --git a/plugins/plan-lifecycle/skills/AGENTS.md b/plugins/plan-lifecycle/skills/AGENTS.md index 07bec88c..b816cdae 100644 --- a/plugins/plan-lifecycle/skills/AGENTS.md +++ b/plugins/plan-lifecycle/skills/AGENTS.md @@ -81,14 +81,14 @@ a coordinated multi-file cutover. The skill bodies are asserted verbatim by `scripts/tests/plan-cli.mjs`. Update positive assertions in the same change as their normative sentences; never relax an assertion to make a copy drift pass. -## Fail-loud routing (six external routes) +## Fail-loud routing (four external routes) -`refactor`, `security`, `context-tree`, and `skill-agent-pipeline` (docks) plus -`effect-ts-port` and `effect-ts-setup` (effect-kit) each carry one -byte-identical prerequisite paragraph naming this plugin, so a runtime without -`plan-lifecycle` stops instead of silently proceeding without a plan. -`test/selftest.mjs` and `scripts/tests/plan-skill-phases.mjs` assert the exact -text; change it only in lockstep across all six routes and both validators. +`refactor`, `security`, `context-tree`, and `skill-agent-pipeline` (docks) each +carry one byte-identical prerequisite paragraph naming this plugin, so a +runtime without `plan-lifecycle` stops instead of silently proceeding without +a plan. `test/selftest.mjs` and `scripts/tests/plan-skill-phases.mjs` assert the +exact text; change it only in lockstep across all four routes and both +validators. ## Scoring and namespace diff --git a/plugins/plan-lifecycle/test/selftest.mjs b/plugins/plan-lifecycle/test/selftest.mjs index 1572b713..7bd840f8 100755 --- a/plugins/plan-lifecycle/test/selftest.mjs +++ b/plugins/plan-lifecycle/test/selftest.mjs @@ -43,7 +43,7 @@ for (const name of RETIRED_PLAN_MACHINERY) { } // ---- routing prerequisite: byte-identical, exactly once per route ---------- -// Keep in lockstep with scripts/tests/plan-skill-phases.mjs and the six files. +// Keep in lockstep with scripts/tests/plan-skill-phases.mjs and the four files. const LIFECYCLE_ROUTE_PREREQUISITE = 'Prerequisite: `plan-lifecycle` must be installed. If `plan-workspace` or `plan-manager` is unavailable, STOP, name the missing `plan-lifecycle` plugin, and do not create or mutate a plan.'; const LIFECYCLE_ROUTE_FILES = [ @@ -51,8 +51,6 @@ const LIFECYCLE_ROUTE_FILES = [ 'plugins/docks/skills/engineering/security/SKILL.md', 'plugins/docks/skills/productivity/context-tree/SKILL.md', 'plugins/docks/skills/productivity/skill-agent-pipeline/SKILL.md', - 'plugins/effect-kit/skills/engineering/effect-ts-port/SKILL.md', - 'plugins/effect-kit/skills/engineering/effect-ts-setup/SKILL.md', ]; for (const relative of LIFECYCLE_ROUTE_FILES) { const occurrences = read(relative).split(LIFECYCLE_ROUTE_PREREQUISITE).length - 1; @@ -99,21 +97,6 @@ assert.equal( ); assert.equal(claudeManifest.version, marketVersion('plan-lifecycle'), 'plan-lifecycle catalog entry must agree'); -const effectClaude = readJson('plugins/effect-kit/.claude-plugin/plugin.json'); -const effectCodex = readJson('plugins/effect-kit/.codex-plugin/plugin.json'); -assert.equal( - effectClaude.version, - effectCodex.version, - `effect-kit manifests must agree (claude=${effectClaude.version} codex=${effectCodex.version})`, -); -assert.equal(effectClaude.version, marketVersion('effect-kit'), 'effect-kit catalog entry must agree'); -assert.ok( - (effectClaude.dependencies ?? []).some( - (dependency) => dependency.name === 'plan-lifecycle' && dependency.marketplace === 'docks', - ), - 'effect-kit Claude manifest must declare the plan-lifecycle dependency', -); - // ---- single ownership: both catalogs and the author registry --------------- const codexEntries = codexMarket.plugins.filter((plugin) => plugin.name === 'plan-lifecycle'); assert.equal(codexEntries.length, 1, 'plan-lifecycle must have exactly one Codex marketplace entry'); @@ -127,8 +110,8 @@ assert.equal(registered.length, 1, 'the author registry must contain exactly one assert.equal(registered[0].root, 'plugins/plan-lifecycle'); console.log( - 'plan-lifecycle self-test PASSED: routing-prerequisite (6 routes), ' + - `manifest/catalog agreement (plan-lifecycle ${claudeManifest.version}, effect-kit ${effectClaude.version}), ` + + 'plan-lifecycle self-test PASSED: routing-prerequisite (4 routes), ' + + `manifest/catalog agreement (plan-lifecycle ${claudeManifest.version}), ` + 'registry and both catalogs hold exactly one entry, plan CLI exposes its closed API, ' + `minimum_docks_major ${compatibility.minimum_docks_major} met by docks major ${docksMajor}`, ); diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 3901d272..339b0cb1 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -8,7 +8,7 @@ Run focused checks while implementing. For a final change owned by exactly one p ## Multi-plugin model (`scripts/lib/plugins.mjs`) -The repo hosts **multiple plugins** (`docks`, `plan-lifecycle`, `effect-kit`) under `plugins/`. `scripts/lib/plugins.mjs` is the **single source of truth**: a `PLUGINS` array of descriptors, each declaring paths + capabilities. **Adding a plugin = adding one descriptor** — no edits to `ci.mjs`/`release.mjs`. +The repo hosts **multiple plugins** (`docks`, `plan-lifecycle`) under `plugins/`. `scripts/lib/plugins.mjs` is the **single source of truth**: a `PLUGINS` array of descriptors, each declaring paths + capabilities. **Adding a plugin = adding one descriptor** — no edits to `ci.mjs`/`release.mjs`. | Descriptor field | Meaning | |---|---| @@ -28,14 +28,14 @@ The repo hosts **multiple plugins** (`docks`, `plan-lifecycle`, `effect-kit`) un `lib/plugin-release.mjs` owns ordinary release ordering behind `runGenericPluginRelease({ argv, repo, plugins, io })`. Its IO value is an exact closed adapter that carries sixteen filesystem, Git, Claude, GitHub, selected-CI, and logging operations. `release.mjs` composes the production operations. Descriptors remain inert policy data. The fifteenth operation, `wouldStageChange`, answers whether `git add` of release bytes would stage anything different from HEAD. Production hashes the proposed bytes with `git hash-object --path --stdin`. It compares that hash with `git rev-parse --quiet --verify HEAD:`. `--path` applies the same clean filters that `git add` applies. The probe never passes `-w`, so it never writes an object. The sixteenth operation, `tagPublished`, answers whether a release tag is already published: it checks the local ref, then asks origin, and refuses to guess when origin is unreachable. It is an adapter operation because reaching origin is IO. A caller that cannot stub it puts the network inside every test of the surrounding decision. The engine validates every policy before touching IO. It enforces dry-run no-mutation itself rather than trusting an adapter. -`ci.mjs` is **registry-driven**. A full invocation runs repo-wide checks once (workflow YAML, both marketplace catalogs, tree/guard, durable anchors, author tooling, unit tests, and CI targeting), then selects every present plugin's shell hooks, repository author suites, and capability-driven `gatePlugin` work. `--plugin ` skips repo-wide sections and runs only the named plugin's owned author checks, target-derived shell lint, and plugin validation. When Docks plan author checks apply, CI runs `scripts/tests/plan-cli.mjs` plus `scripts/tests/plan-skill-phases.mjs` with the `bounded-workflows` and `plan-workspace-template` cases. Trigger-collision checks audit Docks and Effect Kit together once. +`ci.mjs` is **registry-driven**. A full invocation runs repo-wide checks once (workflow YAML, both marketplace catalogs, tree/guard, durable anchors, author tooling, unit tests, and CI targeting), then selects every present plugin's shell hooks, repository author suites, and capability-driven `gatePlugin` work. `--plugin ` skips repo-wide sections and runs only the named plugin's owned author checks, target-derived shell lint, and plugin validation. When Docks plan author checks apply, CI runs `scripts/tests/plan-cli.mjs` plus `scripts/tests/plan-skill-phases.mjs` with the `bounded-workflows` and `plan-workspace-template` cases. Trigger-collision checks audit Docks once. ## Pull-request topology The closed `core` pull-request lane selects plugins, not regression partitions. -It owns the focused Docks plan CLI and plan skill phase contracts, the Docks, -effect-kit, and plan-lifecycle plugin gates, their joint trigger-collision -audit, and JavaScript quality. The always-on `repo` shard owns the repo-wide checks. +It owns the focused Docks plan CLI and plan skill phase contracts, the Docks and +plan-lifecycle plugin gates, the Docks trigger-collision audit, and JavaScript +quality. The always-on `repo` shard owns the repo-wide checks. The lane performs the frozen Bun install and materializes the pinned `claude-code` binary. Its result feeds the single authoritative @@ -174,7 +174,7 @@ For example, an 8 GB, 6-core swapless host with tmpfs `/tmp` prints ```text final implementation tree → node scripts/ci.mjs --plugin (LAYER 1 — local, selected plugin) - → node scripts/release.mjs [--plugin ] (one plugin) + → node scripts/release.mjs [--plugin ] (one plugin) ├── runs ci.mjs -q --plugin as the selected-plugin preflight ├── bumps THIS plugin's plugin.json (+ codex mirror) + its marketplace entry ├── commits + pushes (chore(release): v) @@ -186,7 +186,7 @@ final implementation tree → node scripts/ci.mjs --plugin (LAYER 1 — The release tag, not the manifest number, is the fact that a version was released. When manifests are already at this version, a re-cut stages nothing. The release tags existing HEAD instead of creating a commit. A dry run consults the clean-tree gate. On a dirty tree, it reports the refusal instead of forecasting a landing it cannot predict. -The positional flow above is preserved for docks/effect-kit/plan-lifecycle, including its existing bump resolution, local and tag CI gates, commit/push/tag behavior, release notes, and read-only dry run. +The positional flow above is preserved for docks and plan-lifecycle, including its existing bump resolution, local and tag CI gates, commit/push/tag behavior, release notes, and read-only dry run. GitHub pull requests resolve their diff into a shard set and run `node scripts/ci.mjs --lane ` for each, then require the unchanged `validate` join status. `resolve-shards` maps changed paths onto plugin roots from `lib/plugins.mjs` and emits the matrix; the `repo` shard always runs, `core` runs when the diff implicates a plugin it owns, and every resolution failure — unresolvable base, empty diff, non-pull-request event, or a path outside every plugin root — falls open to both shards. `workflow_dispatch` runs one untargeted `node scripts/ci.mjs` full invocation. A release-tag push strictly resolves the tag's plugin identity, rejects malformed or unknown targets, and runs `node scripts/ci.mjs --plugin ` as the authoritative selected-plugin gate; PR sharding never touches that path. The `repo` shard owns the repo-wide workflow, standalone catalog, tree/durable-anchor, and CI-targeting sections, so a plugin shard runs only the selected plugins' owned author checks, shell-hook lint, and plugin gates, including marketplace/version coherence. Targeted `--plugin` CI skips the repo-wide sections entirely. The Bun dependency cache only reduces repeated download work. Its contents are never validation evidence: the frozen lockfile, release preflight, and `ci.mjs` result remain authoritative. @@ -196,4 +196,4 @@ Before `node scripts/release.mjs`, run the smallest authoritative gate for the f ## Versioning -Versions are **per-plugin and independent** — `docks` and `effect-kit` bump separately, and the Claude marketplace catalog holds one entry per plugin (matched by `name`). Within a single plugin, both its `plugin.json`s (`.claude-plugin/`, `.codex-plugin/`) and its marketplace entry carry a `version` that must agree — `release.mjs` keeps that plugin's triple in lockstep, and `ci.mjs`'s per-plugin gate fails on disagreement; `claude plugin tag` validates it too. The Codex marketplace catalog has no plugin version field but is still validated for JSON shape. Without an explicit plugin `version`, every commit counts as a new "update" to consumers (noisy prompts), so always tag explicit semver bumps. Tag format: `--v`, with the double-dash separator from `claude plugin tag`. +Versions are **per-plugin and independent** — `docks` and `plan-lifecycle` bump separately, and the Claude marketplace catalog holds one entry per plugin (matched by `name`). Within a single plugin, both its `plugin.json`s (`.claude-plugin/`, `.codex-plugin/`) and its marketplace entry carry a `version` that must agree — `release.mjs` keeps that plugin's triple in lockstep, and `ci.mjs`'s per-plugin gate fails on disagreement; `claude plugin tag` validates it too. The Codex marketplace catalog has no plugin version field but is still validated for JSON shape. Without an explicit plugin `version`, every commit counts as a new "update" to consumers (noisy prompts), so always tag explicit semver bumps. Tag format: `--v`, with the double-dash separator from `claude plugin tag`. diff --git a/scripts/ci.mjs b/scripts/ci.mjs index b855e253..3e265f55 100644 --- a/scripts/ci.mjs +++ b/scripts/ci.mjs @@ -344,12 +344,12 @@ if (targets.length > 0) { } } -const routingPluginNames = new Set(['docks', 'effect-kit']); +const routingPluginNames = new Set(['docks']); const selectedRoutingPlugin = targets.some(({ name }) => routingPluginNames.has(name)); const collisionGroups = []; if (selectedRoutingPlugin) { collisionGroups.push({ - label: 'docks/effect-kit', + label: 'docks', roots: PLUGINS.filter(({ name }) => routingPluginNames.has(name)).map(({ skills }) => skills), }); } diff --git a/scripts/config/test-contracts.json b/scripts/config/test-contracts.json index 68a87175..09957294 100644 --- a/scripts/config/test-contracts.json +++ b/scripts/config/test-contracts.json @@ -1,25 +1,6 @@ { "schema": 1, "contracts": [ - { - "id": "plugin-effect-kit-selftest", - "version": 1, - "title": "Effect Kit self-test", - "owner": { - "suite": "plugins/effect-kit/test/selftest.mjs", - "layer": "smoke" - }, - "selection": { - "kind": "node-script", - "selector": "plugins/effect-kit/test/selftest.mjs", - "expected_min": 1 - }, - "platforms": ["linux", "macos"], - "toolchains": ["node-24"], - "release_role": "gate", - "replaces": [], - "skips": [] - }, { "id": "plugin-plan-lifecycle-selftest", "version": 1, diff --git a/scripts/lib/plugins.mjs b/scripts/lib/plugins.mjs index 3843dd61..52565ea8 100644 --- a/scripts/lib/plugins.mjs +++ b/scripts/lib/plugins.mjs @@ -71,25 +71,6 @@ export const PLUGINS = [ install: '/plugin marketplace update docks\n/plugin install docks@docks', }, }, - { - name: 'effect-kit', - root: 'plugins/effect-kit', - ciLane: 'core', - javascriptQuality: { ci: ['plugins/effect-kit/test'], lint: [] }, - skills: 'plugins/effect-kit/skills', - agents: null, - codex: true, - selftest: 'plugins/effect-kit/test/selftest.mjs', - rust: null, - extraJson: [], - authorChecks: [], - releaseContracts: [], - transformGuard: false, - release: { - kind: 'generic', - install: '/plugin marketplace update docks\n/plugin install effect-kit@docks', - }, - }, { name: 'plan-lifecycle', root: 'plugins/plan-lifecycle', @@ -105,8 +86,8 @@ export const PLUGINS = [ rust: null, extraJson: [], // 'plan-reviewer' also stays on docks: the routing prerequisite the suite - // asserts lives in docks/effect-kit skill bodies, while the lifecycle - // machinery it drives ships here. Both owners select the same suites and + // asserts lives in docks skill bodies, while the lifecycle machinery it + // drives ships here. Both owners select the same suites and // selectedAuthorChecks() dedupes on a full run. authorChecks: ['plan-reviewer'], releaseContracts: [], diff --git a/scripts/release.mjs b/scripts/release.mjs index 024856ec..58d4a429 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // release.mjs — dispatch the generic plugin release lane. // -// Generic positional lane (Docks, Effect Kit, and Plan Lifecycle): +// Generic positional lane (Docks and Plan Lifecycle): // node scripts/release.mjs [--dry-run] [--plugin ] patch|minor|major| // (--plugin defaults to "docks") // diff --git a/scripts/tests/ci-plugin-targeting.mjs b/scripts/tests/ci-plugin-targeting.mjs index 6d489092..fed76c47 100755 --- a/scripts/tests/ci-plugin-targeting.mjs +++ b/scripts/tests/ci-plugin-targeting.mjs @@ -491,16 +491,8 @@ async function testFocusedCiCommandSelection() { 'scripts/tests/ci-observability.mjs', 'scripts/tests/test-contracts.mjs', ]; - const effectKitBiomeCiArgv = ['run', 'biome', 'ci', 'plugins/effect-kit/test']; - const coreBiomeCiArgv = [ - 'run', - 'biome', - 'ci', - 'scripts', - 'plugins/docks/hooks', - 'plugins/effect-kit/test', - 'plugins/plan-lifecycle/test', - ]; + const planLifecycleBiomeCiArgv = ['run', 'biome', 'ci', 'plugins/plan-lifecycle/test']; + const coreBiomeCiArgv = ['run', 'biome', 'ci', 'scripts', 'plugins/docks/hooks', 'plugins/plan-lifecycle/test']; const docksBiomeLintArgv = ['run', 'biome', 'lint', 'plugins/docks/skills/productivity/write-skill/scripts']; const coreBiomeLintArgv = [ 'run', @@ -515,7 +507,7 @@ async function testFocusedCiCommandSelection() { try { const [targeted, untargeted, docksTargeted, core, timedCore, repoWide, full] = await Promise.all([ - run('targeted-', ['--plugin', 'effect-kit']), + run('targeted-', ['--plugin', 'plan-lifecycle']), run('untargeted-', []), run('docks-targeted-', ['--plugin', 'docks']), run('core-', ['--lane', 'core']), @@ -531,14 +523,14 @@ async function testFocusedCiCommandSelection() { `targeted CI must not invoke repo-wide command ${script}`, ); } - assert.equal(countToolInvocation(targeted.calls, 'bun', effectKitBiomeCiArgv), 1); + assert.equal(countToolInvocation(targeted.calls, 'bun', planLifecycleBiomeCiArgv), 1); assert.match(targeted.result.stdout, /javascript quality/); assert.doesNotMatch( targeted.result.stdout, /workflow YAML|marketplace catalogs|repo-wide guards|CI targeting contract/, ); assert.equal( - invokesNode(targeted.calls, 'scripts/skills/guard.mjs', 'plugins/effect-kit/skills'), + invokesNode(targeted.calls, 'scripts/skills/guard.mjs', 'plugins/plan-lifecycle/skills'), true, 'targeted CI must retain the selected plugin gate', ); @@ -547,19 +539,16 @@ async function testFocusedCiCommandSelection() { const planCliArgv = ['scripts/tests/plan-cli.mjs']; const boundedWorkflowArgv = ['scripts/tests/plan-skill-phases.mjs', '--case', 'bounded-workflows']; const templateCaseArgv = ['scripts/tests/plan-skill-phases.mjs', '--case', 'plan-workspace-template']; - const crossPluginCollisionArgv = [ - 'tests/skill-trigger-collision.mjs', - 'plugins/docks/skills', - 'plugins/effect-kit/skills', - ]; + const docksCollisionArgv = ['tests/skill-trigger-collision.mjs', 'plugins/docks/skills']; const planLifecycleCollisionArgv = ['tests/skill-trigger-collision.mjs', 'plugins/plan-lifecycle/skills']; + assert.equal(countToolInvocation(targeted.calls, 'node', planLifecycleCollisionArgv), 1); assert.equal( - countToolInvocation(targeted.calls, 'node', crossPluginCollisionArgv), - 1, - 'an Effect Kit target must retain the joint Docks/Effect trigger-collision contract', + countToolInvocation(targeted.calls, 'node', docksCollisionArgv), + 0, + 'a Plan Lifecycle target must not run the Docks trigger-collision contract', ); - assert.equal(countToolInvocation(targeted.calls, 'node', planCliArgv), 0); + assert.equal(countToolInvocation(targeted.calls, 'node', planCliArgv), 1); for (const [ciArgs, selected] of [ [[], untargeted], @@ -577,9 +566,9 @@ async function testFocusedCiCommandSelection() { `${ciArgs.length === 0 ? 'full' : 'Docks-targeted'} CI must run the bounded workflow contract once`, ); assert.equal( - countToolInvocation(selected.calls, 'node', crossPluginCollisionArgv), + countToolInvocation(selected.calls, 'node', docksCollisionArgv), 1, - `${ciArgs.length === 0 ? 'full' : 'Docks-targeted'} CI must audit Docks and Effect Kit together once`, + `${ciArgs.length === 0 ? 'full' : 'Docks-targeted'} CI must audit Docks once`, ); if (ciArgs.length === 0) { assert.equal(countToolInvocation(selected.calls, 'bun', ['run', 'check:js']), 1); @@ -610,13 +599,12 @@ async function testFocusedCiCommandSelection() { } assert.match(core.result.stdout, /plan orchestration/); assert.match(core.result.stdout, /plugin: docks/); - assert.match(core.result.stdout, /plugin: effect-kit/); assert.match(core.result.stdout, /plugin: plan-lifecycle/); assert.doesNotMatch(core.result.stdout, /partition passed/); assert.equal(countToolInvocation(core.calls, 'node', planCliArgv), 1); assert.equal(countToolInvocation(core.calls, 'node', boundedWorkflowArgv), 1); assert.equal(countToolInvocation(core.calls, 'node', templateCaseArgv), 1); - assert.equal(countToolInvocation(core.calls, 'node', crossPluginCollisionArgv), 1); + assert.equal(countToolInvocation(core.calls, 'node', docksCollisionArgv), 1); assert.equal(countToolInvocation(core.calls, 'node', planLifecycleCollisionArgv), 1); assert.equal(countToolInvocation(core.calls, 'node', ['plugins/plan-lifecycle/test/selftest.mjs']), 1); @@ -652,11 +640,10 @@ async function testFocusedCiCommandSelection() { 'skill trigger collisions', 'plan orchestration', 'plugin: docks', - 'plugin: effect-kit', 'plugin: plan-lifecycle', 'javascript quality', ], - 'core CI timing phases must own its three plugins and nothing repo-wide', + 'core CI timing phases must own its two plugins and nothing repo-wide', ); const observedFloorCalls = core.calls.filter( ({ args: callArgs }) => callArgs[0] === 'scripts/config/read-floor.mjs', @@ -701,7 +688,7 @@ async function testFocusedCiCommandSelection() { } assert.equal(countToolInvocation(repoWide.calls, 'node', ['scripts/plans/no-bespoke-gates.mjs']), 1); assert.equal(countToolInvocation(repoWide.calls, 'bun', ['run', 'test:unit']), 1); - assert.doesNotMatch(repoWide.result.stdout, /plugin: docks|plugin: effect-kit|plugin: plan-lifecycle/); + assert.doesNotMatch(repoWide.result.stdout, /plugin: docks|plugin: plan-lifecycle/); assert.equal(countToolInvocation(repoWide.calls, 'bun', ['run', 'check:js']), 0); assert.equal(countToolInvocation(repoWide.calls, 'bun', coreBiomeCiArgv), 0); // The repo shard used to schedule no biome at all: it selects zero plugins, so the @@ -896,7 +883,7 @@ async function testGenericReleaseModuleContract( runGenericPluginRelease, resolveGenericReleaseIo, ) { - const ordinaryNames = ['docks', 'effect-kit', 'plan-lifecycle']; + const ordinaryNames = ['docks', 'plan-lifecycle']; const ordinaryPlugins = ordinaryNames.map((name) => PLUGINS.find((plugin) => plugin.name === name)); for (const plugin of ordinaryPlugins) { assert.ok(plugin, `missing ordinary plugin descriptor: ${plugin?.name}`); @@ -958,7 +945,7 @@ async function testGenericReleaseModuleContract( } const generic = ordinaryPlugins[0]; - const recutPlugin = ordinaryPlugins[2]; + const recutPlugin = ordinaryPlugins.at(-1); const recutVersion = JSON.parse( fs.readFileSync(path.join(ROOT, recutPlugin.root, '.claude-plugin/plugin.json'), 'utf8'), ).version; @@ -1476,10 +1463,10 @@ async function testDryRunReleaseSafety() { 'Docks dry-run must not invoke write, commit, push, tag, workflow, or GitHub Release mutation', ); assert.deepEqual(gitSnapshot(), before); - const ordinaryReleaseBytes = ['effect-kit', 'plan-lifecycle'] + const ordinaryReleaseBytes = ['plan-lifecycle'] .flatMap((name) => [`plugins/${name}/.claude-plugin/plugin.json`, `plugins/${name}/.codex-plugin/plugin.json`]) .map((file) => fs.readFileSync(path.join(ROOT, file), 'base64')); - for (const pluginName of ['effect-kit', 'plan-lifecycle']) { + for (const pluginName of ['plan-lifecycle']) { fs.writeFileSync(callLog, '', { mode: 0o600 }); const pluginResult = spawnSync( process.execPath, @@ -1526,7 +1513,7 @@ async function testDryRunReleaseSafety() { ); } assert.deepEqual( - ['effect-kit', 'plan-lifecycle'] + ['plan-lifecycle'] .flatMap((name) => [`plugins/${name}/.claude-plugin/plugin.json`, `plugins/${name}/.codex-plugin/plugin.json`]) .map((file) => fs.readFileSync(path.join(ROOT, file), 'base64')), ordinaryReleaseBytes, @@ -1542,7 +1529,7 @@ function testTimingWriteFailure() { const worktree = path.join(fixtureRoot, 'worktree'); let worktreeAdded = false; const runCi = (cwd, timingPath) => - spawnSync(process.execPath, ['scripts/ci.mjs', '--plugin', 'effect-kit', '--timings-json', timingPath], { + spawnSync(process.execPath, ['scripts/ci.mjs', '--plugin', 'plan-lifecycle', '--timings-json', timingPath], { cwd, encoding: 'utf8', timeout: 600_000, @@ -1563,7 +1550,7 @@ function testTimingWriteFailure() { path.join(ROOT, 'scripts/lib/ci-background-task.mjs'), path.join(worktree, 'scripts/lib/ci-background-task.mjs'), ); - const manifestPath = path.join(worktree, 'plugins/effect-kit/.claude-plugin/plugin.json'); + const manifestPath = path.join(worktree, 'plugins/plan-lifecycle/.claude-plugin/plugin.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); manifest.version = '0.0.0'; fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); @@ -1606,9 +1593,9 @@ if (mode === '--timing-write-failure') { const names = (rows) => rows.map((row) => row.name); const byName = (name) => PLUGINS.find((plugin) => plugin.name === name); -assert.deepEqual(names(resolveCiTargets(PLUGINS, null)), ['docks', 'effect-kit', 'plan-lifecycle']); +assert.deepEqual(names(resolveCiTargets(PLUGINS, null)), ['docks', 'plan-lifecycle']); assert.deepEqual(names(resolveCiTargets(PLUGINS, 'docks')), ['docks']); -assert.throws(() => resolveCiTargets(PLUGINS, 'unknown-plugin'), /unknown plugin.*docks, effect-kit, plan-lifecycle/); +assert.throws(() => resolveCiTargets(PLUGINS, 'unknown-plugin'), /unknown plugin.*docks, plan-lifecycle/); const laneShape = ({ name, targets, repoWide }) => ({ name, targets: names(targets), @@ -1620,7 +1607,6 @@ assert.deepEqual( PLUGINS.map(({ name, ciLane }) => ({ name, ciLane })), [ { name: 'docks', ciLane: 'core' }, - { name: 'effect-kit', ciLane: 'core' }, { name: 'plan-lifecycle', ciLane: 'core' }, ], ); @@ -1631,7 +1617,7 @@ assert.deepEqual(laneShape(resolveCiLane(PLUGINS, 'repo')), { }); assert.deepEqual(laneShape(resolveCiLane(PLUGINS, 'core')), { name: 'core', - targets: ['docks', 'effect-kit', 'plan-lifecycle'], + targets: ['docks', 'plan-lifecycle'], repoWide: false, }); assert.equal( @@ -1640,19 +1626,14 @@ assert.equal( 'exactly one shard may own the repo-wide checks, and it must be the always-on one', ); const syntheticCorePlugin = { - ...byName('effect-kit'), + ...byName('plan-lifecycle'), name: 'synthetic-core-plugin', root: 'plugins/synthetic-core-plugin', ciLane: 'core', }; PLUGINS.push(syntheticCorePlugin); try { - assert.deepEqual(names(resolveCiLane(PLUGINS, 'core').targets), [ - 'docks', - 'effect-kit', - 'plan-lifecycle', - 'synthetic-core-plugin', - ]); + assert.deepEqual(names(resolveCiLane(PLUGINS, 'core').targets), ['docks', 'plan-lifecycle', 'synthetic-core-plugin']); } finally { assert.equal(PLUGINS.pop(), syntheticCorePlugin); } @@ -1677,10 +1658,10 @@ assert.throws(() => resolveCiLane(PLUGINS, 'constructor'), /unknown CI lane.*rep assert.throws( () => resolveCiLane( - PLUGINS.filter(({ name }) => name !== 'effect-kit'), + PLUGINS.filter(({ name }) => name !== 'plan-lifecycle'), 'core', ), - /unknown plugin: effect-kit/, + /unknown plugin: plan-lifecycle/, ); await Promise.all( [ @@ -1735,7 +1716,6 @@ assert.deepEqual(shardsFor(['plugins/plan-lifecycle/test/selftest.mjs', 'plugins 'repo', 'core', ]); -assert.deepEqual(shardsFor(['plugins/docks/skills/a.md', 'plugins/effect-kit/test/b.mjs']).lanes, ['repo', 'core']); // Every fail-open path selects everything. for (const [label, selection] of [ @@ -1778,14 +1758,17 @@ for (const [label, input] of [ // usable shard must fail here rather than ride in ungated. assert.deepEqual(assertShardTopologyCoversRegistry(), ['repo', 'core']); for (const [label, broken] of [ - ['a plugin with no ciLane', { ...byName('effect-kit'), name: 'no-lane', root: 'plugins/no-lane', ciLane: undefined }], + [ + 'a plugin with no ciLane', + { ...byName('plan-lifecycle'), name: 'no-lane', root: 'plugins/no-lane', ciLane: undefined }, + ], [ 'a plugin claiming the repo-wide shard', - { ...byName('effect-kit'), name: 'greedy', root: 'plugins/greedy', ciLane: 'repo' }, + { ...byName('plan-lifecycle'), name: 'greedy', root: 'plugins/greedy', ciLane: 'repo' }, ], [ 'a plugin on an unknown shard', - { ...byName('effect-kit'), name: 'stray', root: 'plugins/stray', ciLane: 'mutations' }, + { ...byName('plan-lifecycle'), name: 'stray', root: 'plugins/stray', ciLane: 'mutations' }, ], ]) { if (broken.ciLane === undefined) delete broken.ciLane; @@ -1816,7 +1799,7 @@ const withPluginTreeEntry = (name, make, run) => { assert.equal(fs.existsSync(target), false, `${name} must leave no residue`); }; -assert.deepEqual(assertPluginTreesAreRegistered(), ['plugins/docks', 'plugins/effect-kit', 'plugins/plan-lifecycle']); +assert.deepEqual(assertPluginTreesAreRegistered(), ['plugins/docks', 'plugins/plan-lifecycle']); // The reported defect, and it must reach callers through the wired entry point too. withPluginTreeEntry( @@ -1864,7 +1847,7 @@ withPluginTreeEntry( // The mirror defect: a descriptor outliving its tree. The shard resolver keeps // routing diffs to a root nothing can validate. -const deletedTreePlugin = { ...byName('effect-kit'), name: 'deleted-tree', root: 'plugins/deleted-tree' }; +const deletedTreePlugin = { ...byName('plan-lifecycle'), name: 'deleted-tree', root: 'plugins/deleted-tree' }; PLUGINS.push(deletedTreePlugin); try { assert.throws( @@ -1921,7 +1904,6 @@ try { } console.log('shard resolver CLI fail-open behaviour passed'); assert.deepEqual([...selectedAuthorChecks([byName('docks')])], ['idempotency', 'plan-reviewer']); -assert.deepEqual([...selectedAuthorChecks([byName('effect-kit')])], []); assert.deepEqual([...selectedAuthorChecks([byName('plan-lifecycle')])], ['plan-reviewer']); assert.deepEqual( [...selectedAuthorChecks([byName('docks'), byName('plan-lifecycle')])], @@ -1934,7 +1916,7 @@ await testFocusedCiCommandSelection(); console.log('focused CI command selection passed'); assert.deepEqual(parseReleaseTag('docks--v0.12.8'), { plugin: 'docks', version: '0.12.8' }); -assert.deepEqual(parseReleaseTag('effect-kit--v11.2.0'), { plugin: 'effect-kit', version: '11.2.0' }); +assert.deepEqual(parseReleaseTag('plan-lifecycle--v11.2.0'), { plugin: 'plan-lifecycle', version: '11.2.0' }); for (const invalid of [ 'docks--v01.2.3', 'docks--v1.02.3', @@ -1947,7 +1929,10 @@ for (const invalid of [ assert.throws(() => parseReleaseTag(invalid), /invalid release tag|unknown plugin/); assert.deepEqual(workflowCiSelection('pull_request', ''), { mode: 'full', plugin: null }); assert.deepEqual(workflowCiSelection('workflow_dispatch', ''), { mode: 'full', plugin: null }); -assert.deepEqual(workflowCiSelection('push', 'effect-kit--v0.3.1'), { mode: 'targeted', plugin: 'effect-kit' }); +assert.deepEqual(workflowCiSelection('push', 'plan-lifecycle--v0.3.1'), { + mode: 'targeted', + plugin: 'plan-lifecycle', +}); assert.throws(() => workflowCiSelection('push', 'bad-tag'), /invalid release tag/); assert.throws(() => workflowCiSelection('schedule', ''), /unsupported workflow event/); console.log('release tag and workflow selection passed'); @@ -1957,12 +1942,12 @@ try { const githubOutput = path.join(tmp, 'github-output'); const cli = spawnSync( 'node', - ['scripts/ci-target.mjs', 'release-tag', 'effect-kit--v0.11.2', '--github-output', githubOutput], + ['scripts/ci-target.mjs', 'release-tag', 'plan-lifecycle--v0.11.2', '--github-output', githubOutput], { cwd: ROOT, encoding: 'utf8' }, ); assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, ''); - assert.equal(fs.readFileSync(githubOutput, 'utf8'), 'mode=targeted\nplugin=effect-kit\n'); + assert.equal(fs.readFileSync(githubOutput, 'utf8'), 'mode=targeted\nplugin=plan-lifecycle\n'); const malformed = spawnSync('node', ['scripts/ci-target.mjs', 'release-tag', 'docks--v1.2.3;echo-owned'], { cwd: ROOT, @@ -1996,13 +1981,13 @@ try { RUNNER_ARCH: 'X64', }; const [targeted, hosted] = await Promise.all([ - execFileResult('node', ['scripts/ci.mjs', '--plugin', 'effect-kit', '--timings-json', timingPath], { + execFileResult('node', ['scripts/ci.mjs', '--plugin', 'plan-lifecycle', '--timings-json', timingPath], { cwd: ROOT, encoding: 'utf8', env: hostFreeEnv, timeout: 120_000, }), - execFileResult('node', ['scripts/ci.mjs', '--plugin', 'effect-kit', '--timings-json', hostedTimingPath], { + execFileResult('node', ['scripts/ci.mjs', '--plugin', 'plan-lifecycle', '--timings-json', hostedTimingPath], { cwd: ROOT, encoding: 'utf8', env: hostedEnv, @@ -2010,9 +1995,9 @@ try { }), ]); assert.equal(targeted.status, 0, `${targeted.stdout}\n${targeted.stderr}`); - assert.doesNotMatch(targeted.stdout, /skill-maintainer idempotency|plan review policy|plugin: docks/); - assert.match(targeted.stdout, /plugin: effect-kit/); - validateTimingReport(timingPath, 'effect-kit', ['javascript quality']); + assert.doesNotMatch(targeted.stdout, /skill-maintainer idempotency|plugin: docks/); + assert.match(targeted.stdout, /plugin: plan-lifecycle/); + validateTimingReport(timingPath, 'plan-lifecycle', ['javascript quality', 'javascript quality lint']); console.log('targeted CI timing report passed'); assert.equal(hosted.status, 0, `${hosted.stdout}\n${hosted.stderr}`); diff --git a/scripts/tests/plan-skill-phases.mjs b/scripts/tests/plan-skill-phases.mjs index 68e489b4..11a0913b 100755 --- a/scripts/tests/plan-skill-phases.mjs +++ b/scripts/tests/plan-skill-phases.mjs @@ -330,8 +330,6 @@ const LIFECYCLE_ROUTE_FILES = [ 'plugins/docks/skills/engineering/security/SKILL.md', 'plugins/docks/skills/productivity/context-tree/SKILL.md', 'plugins/docks/skills/productivity/skill-agent-pipeline/SKILL.md', - 'plugins/effect-kit/skills/engineering/effect-ts-port/SKILL.md', - 'plugins/effect-kit/skills/engineering/effect-ts-setup/SKILL.md', ]; function normalizeContract(text) { diff --git a/scripts/tests/unit/gate-memo.test.mjs b/scripts/tests/unit/gate-memo.test.mjs index aa9421a0..73bf928c 100644 --- a/scripts/tests/unit/gate-memo.test.mjs +++ b/scripts/tests/unit/gate-memo.test.mjs @@ -361,9 +361,9 @@ const FULL_RUN = { phases: [ { name: 'workflow YAML', duration_ms: 900 }, { name: 'plan orchestration', duration_ms: 23_800 }, - { name: 'plugin: effect-kit', duration_ms: 320_500 }, + { name: 'plugin: docks', duration_ms: 320_500 }, { name: 'plugin: plan-lifecycle', duration_ms: 4_200 }, - { name: 'plugin: docks', duration_ms: 1_100 }, + { name: 'javascript quality', duration_ms: 1_100 }, ], commands: Array.from({ length: 70 }, (_, index) => ({ id: `c${index}` })), }; @@ -375,7 +375,7 @@ test('the cost summary ranks the full run and names the scope the tree actually renderGateCost(FULL_RUN, { memoRequested: true, scopes: CHANGED_ONE }), [ '▣ gate cost — 364.6s across 5 phase(s) and 70 command(s)', - ' 87.9% 320.5s plugin: effect-kit', + ' 87.9% 320.5s plugin: docks', ' 6.5% 23.8s plan orchestration', ' 1.2% 4.2s plugin: plan-lifecycle', ' Cheaper next time: node scripts/ci.mjs --plugin plan-lifecycle — the only plugin this working tree touches.', @@ -386,7 +386,7 @@ test('the cost summary ranks the full run and names the scope the tree actually assert.match(renderGateCost({ ...FULL_RUN, status: 'failed' }, { scopes: CHANGED_ONE }), /^▣ gate cost — 364.6s/); }); -// The dearest phase is effect-kit in every one of these runs. Naming it would be +// The dearest phase is docks in every one of these runs. Naming it would be // exactly the wrong advice: it is the plugin these trees did not touch. test('the advice follows the changed paths, never the dearest phase', () => { const advise = (scopes) => renderGateCost(FULL_RUN, { memoRequested: true, scopes }).split('\n').at(-1); @@ -394,7 +394,7 @@ test('the advice follows the changed paths, never the dearest phase', () => { advise(CHANGED_ONE), ' Cheaper next time: node scripts/ci.mjs --plugin plan-lifecycle — the only plugin this working tree touches.', ); - assert.doesNotMatch(advise(CHANGED_ONE), /effect-kit/, 'never hand over a command for the untouched plugin'); + assert.doesNotMatch(advise(CHANGED_ONE), /--plugin docks\b/, 'never hand over a command for the untouched plugin'); // Several plugins: name them, but offer no command, because no single --plugin run // would be both cheaper and complete. diff --git a/scripts/tests/unit/skill-score-vacuity.test.mjs b/scripts/tests/unit/skill-score-vacuity.test.mjs index a38944a9..cb3a1823 100644 --- a/scripts/tests/unit/skill-score-vacuity.test.mjs +++ b/scripts/tests/unit/skill-score-vacuity.test.mjs @@ -13,7 +13,7 @@ import test from 'node:test'; // gate rather than a reimplementation of it. const HERE = path.dirname(new URL(import.meta.url).pathname); const REPO = path.resolve(HERE, '../../..'); -const TARGET = 'effect-kit'; +const TARGET = 'docks'; const SKILLS_ROOT = `plugins/${TARGET}/skills`; // Counted the way the gate counts: every / directory, SKILL.md or not. diff --git a/scripts/tests/unit/tag-ci-run-identity.test.mjs b/scripts/tests/unit/tag-ci-run-identity.test.mjs index a16c175d..cee38542 100644 --- a/scripts/tests/unit/tag-ci-run-identity.test.mjs +++ b/scripts/tests/unit/tag-ci-run-identity.test.mjs @@ -98,7 +98,7 @@ test('ambiguity fails loudly instead of picking', () => { }); test('another tag at the same commit is not this tag', () => { - listRuns([run({ headBranch: 'effect-kit--v0.4.0' })]); + listRuns([run({ headBranch: 'plan-lifecycle--v0.4.0' })]); assert.throws(() => io.waitForTagCi(TAG, COMMIT, PUSHED_AT), /no CI run appeared/); });