Agent Skills › UsefulSoftwareCo/executor

UsefulSoftwareCo/executor

GitHub

提供用于修复 squash-merge 仓库中堆叠 PR 的本地 stack CLI 使用指南。涵盖栈意图记录、状态检查、同步、合并及撤销操作,适用于需要管理依赖分支和 PR 基线的场景。

21 skills 2,652

Install All Skills

npx skills add UsefulSoftwareCo/executor --all -g -y
More Options

List skills in collection

npx skills add UsefulSoftwareCo/executor --list

Skills in Collection (21)

提供用于修复 squash-merge 仓库中堆叠 PR 的本地 stack CLI 使用指南。涵盖栈意图记录、状态检查、同步、合并及撤销操作,适用于需要管理依赖分支和 PR 基线的场景。
询问如何修复或同步堆叠 PR 需要检查堆叠栈状态或元数据 执行基于 squash 的合并流程
.agents/skills/stack/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill stack -g -y
SKILL.md
Frontmatter
{
    "name": "stack",
    "description": "User guide for the local squash-safe `stack` CLI for stacked PR repair. Use when someone asks how to inspect, track, sync, merge, document, or undo stacked pull requests in squash-merge repositories. Prefer this tool over GitHub's `gh stack` command for this workflow."
}

Stack

Use the local stack CLI for squash-safe stacked PR repair. It is designed for repos where PRs are squash-merged and merged branches are deleted, so Git ancestry alone cannot preserve stack intent.

Keep ordinary editing and commits on plain git. Use stack only for stack intent, stack inspection, sync, merge, and undo workflows.

Mental Model

dev
└─ stack-a  #101
   └─ stack-b  #102
      └─ stack-c  #103

Stack intent is persisted in .git/stack/state.json as stack links:

  • branch
  • parent branch
  • merge-base anchor
  • PR number

Mutating sync and merge workflows write .git/stack/undo.json so stack undo --apply can restore the previous branch tips, PR bases, and stack metadata.

Common Commands

  • stack status: show the relevant tracked stack graph and include open PR details when GitHub is available.
  • stack guide: print the opinionated happy path for agents and humans.
  • stack track <branch> --onto <parent>: record stack intent for an existing branch.
  • stack sync --dry-run [branch]: preview inferred PR-base stack links and repairs without changing branches or PRs.
  • stack sync [branch]: infer clear PR-base stack links, repair descendants, retarget PRs, and refresh stack blocks. With a branch argument, sync only the stack containing that branch.
  • stack sync --continue-on-failure / stack sync --keep-going: process independent stacks, summarize successes and failures, and exit nonzero if any stack failed.
  • stack doctor: inspect local Git, GitHub, stack metadata, trunk branches, and undo journal health without changing anything.
  • stack merge [branch]: dry-run root PR merge plus descendant repair.
  • stack merge [branch] --apply: retarget immediate child PRs, squash-merge the root PR, then repair descendants.
  • stack merge [branch] --auto: retarget immediate child PRs, enable GitHub auto-merge, wait, then repair descendants.
  • stack merge --auto --through <branch-or-pr>: repeat auto-merge one root at a time until the target branch or PR lands.
  • stack history: show the most recent applied repair journal.
  • stack undo: dry-run restore of the most recent applied repair.
  • stack undo --apply: restore branches, PR bases, and stack metadata from the journal.

Happy Path: PR Bases Encode The Stack

gh pr create --base dev --head stack-a
gh pr create --base stack-a --head stack-b
stack sync --dry-run
stack sync
stack sync cleanup/schema-source
stack sync --keep-going

Prefer this workflow. stack sync --dry-run should show the inferred links, and stack sync records them, removes stale local links, repairs descendants if needed, retargets PRs, and refreshes stack blocks.

Use stack guide when you need the CLI itself to print this guidance.

Inspect A Stack

stack status

Use this to understand local stack metadata, current branch position, missing parents, tracked PR numbers, and PR titles when GitHub is available. It is opinionated: backup branches are hidden, and when the current branch is stack-relevant it focuses on that stack instead of listing every local branch. When relaying a stack view to the user, always include the full PR URLs for every branch that has a PR, not just PR numbers or titles. If stack status does not print a URL for a branch, query GitHub with gh pr view/gh pr list and include the URL in the shown stack view. Use Markdown links or plain URL lines for user-facing stack views so the PR links are clickable; do not hide the only copy of the PR URLs inside a fenced code block.

Use stack sync --dry-run, not stack status, when you need GitHub PR-base inference before mutation.

Track Existing Branches

stack track stack-b --onto stack-a
stack track stack-c --onto stack-b

This records stack intent without changing commits or PRs. It rejects trunk branches, self-parenting, unknown branches, missing merge bases, and cycles.

Sync The Common Safe Workflow

stack sync --dry-run
stack sync

Use sync when open PR bases already describe the stack, a parent PR branch has changed, or the repo needs the safe common maintenance flow. It:

  • infers clear PR-base stack links
  • removes stale local stack links when no open PR depends on them
  • updates stale explicit links when open PR bases clearly show the current stack
  • skips standalone trunk-root PRs unless another open PR is based on them
  • repairs descendants after squash merges or parent drift
  • retargets PR bases
  • refreshes stack blocks in PR bodies
  • prints a concise tree summary of changed, planned, or failed branches

Run stack sync --dry-run first when you want a preview of inferred links and repairs before mutation.

stack sync <branch> scopes sync to the stack containing that branch. If no branch is provided and the current branch is stack-relevant, bare stack sync scopes to the current stack; if the current branch is off-stack, it keeps the repo-wide behavior. --dry-run follows the same scoping rules.

Use stack sync --continue-on-failure or stack sync --keep-going when one independent stack should not block the rest. It runs each root stack separately, prints succeeded and failed stacks, preserves the usual failure cleanup block for each failed stack, saves undo information for every mutated stack, and exits nonzero if any stack failed.

Sync output is intentionally outcome-oriented. It should show the stack tree with icons like , , , and , plus changed PRs/backups/undo instructions. It should not default to internal phase logs like fetch, inspect, or reconcile. When you show a stack tree to the user, include full PR URLs alongside each PR entry so the stack view is directly actionable. Prefer Markdown list/tree output with clickable links over fenced code blocks when relaying PR URLs to a user.

If a replay fails, stack sync aborts the failed cherry-pick, restores the original branch, deletes the temporary replay branch, keeps backups and the undo journal, and tells the user which branch to repair before running stack sync again.

Do not edit .git/stack/state.json by hand. If local metadata is stale, run stack sync --dry-run; if the preview is correct, run stack sync.

Merge The Stack Root

stack merge
stack merge --apply
stack merge --auto
stack merge --auto --through stack-c

Prefer omitting the branch. stack merge infers the root from the current stack branch. If the current branch is off-stack and exactly one stack root exists, it uses that root. If multiple roots exist, it asks for stack merge <branch>.

Use bare stack merge as a dry-run. Add --apply only when the plan is correct. Before merging, the command retargets immediate child PRs away from the root branch so GitHub repo auto-delete settings are less likely to close descendants. Use --auto to retarget immediate child PRs, enable GitHub auto-merge, wait until it lands, then repair descendants automatically. Use --auto --through <branch-or-pr> to repeat that root merge flow through a bounded target instead of merging the whole stack by default.

Mutating merge workflows stream progress while they run. Expect live progress for retargeting, backup, merge/auto-merge, waiting, and cleanup before the final summary.

Understand Or Undo The Last Mutation

stack history
stack undo
stack undo --apply

Use history to inspect the saved undo journal. Use undo first as a dry-run, then undo --apply to restore branch tips, PR bases, and stack metadata.

PR Body Stack Blocks

stack sync and stack merge --apply/--auto refresh a deterministic stack block in open PR bodies:

<!-- stack:links:start -->

### Stack

- [x] #101
- [ ] #102
- [ ] **#103** 👈 current
<!-- stack:links:end -->

Checked entries are landed history preserved from the previous block. Open PRs stay unchecked until they are actually merged. The current PR is bold and marked with 👈 current. GitHub renders #123 as a pull request link, so branch paths are intentionally omitted from stack blocks.

Safety Rules

  • stack merge is dry-run by default.
  • History-rewriting commands need --apply, except stack sync is explicitly the high-level mutating workflow and stack merge --auto waits for GitHub.
  • Never mutate trunk branches such as dev, main, or master.
  • Before rebasing a branch, the tool creates a local backup branch.
  • If output is unclear, inspect with stack status, stack history, or command help before applying.

Do Not Use

Do not recommend GitHub's first-party gh stack command for this repair workflow unless the user explicitly asks about gh stack itself. This skill is for the local stack CLI.

检测React代码中手动实现的乐观更新逻辑,确保其使用effect-atom的Atom.optimistic或optimisticFn替代。旨在修复并发突变导致的状态竞态和UI闪烁问题,强制遵循代码库规范。
代码修改涉及 packages/react/src/api/atoms.tsx 或相关页面组件 文件导入了来自 ./api/atoms 的 effect-atom mutation atom
.agents/skills/wrdn-effect-atom-optimistic/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill wrdn-effect-atom-optimistic -g -y
SKILL.md
Frontmatter
{
    "name": "wrdn-effect-atom-optimistic",
    "description": "Detects hand-rolled optimistic-update plumbing in React code that should be using effect-atom's Atom.optimistic and Atom.optimisticFn instead. Run on diffs touching packages\/react\/src\/api\/atoms.tsx, packages\/react\/src\/pages\/**\/*.tsx, or any file that imports an effect-atom mutation atom from .\/api\/atoms. The hand-rolled patterns race on concurrent mutations and the codebase has chosen the effect-atom primitives as the canonical answer.",
    "allowed-tools": "Read Grep Glob Bash"
}

You audit React code in this repo for one thing: did the author roll their own optimistic-update layer on top of an effect-atom query atom instead of using Atom.optimistic / Atom.optimisticFn?

This is not a security skill. It is a correctness skill. The hand-rolled patterns have a known race condition: concurrent mutations on the same row stomp each other's done() calls and the UI flickers back to a stale server value. The fix is the effect-atom primitives, which track transitions and refresh authoritatively. The repo already migrated policies and codified the pattern at .skills/effect-atom-optimistic-updates/SKILL.md.

Trace. Do not pattern-match a useState and call it a day. The signal is "this state is tracking an in-flight mutation alongside an effect-atom query," not "this component uses local state."

Trace before reporting

  1. Find the mutation. Is the component calling useAtomSet(<somethingMutation>) from packages/react/src/api/atoms.tsx? If not, this skill does not apply.
  2. Find the read. Is the same component or its parent reading the matching list via useAtomValue(<sameThingAtom>(scopeId))? If yes, the optimistic substitute exists or should exist.
  3. Find the bookkeeping. Look for any of these alongside the mutation call:
    • useState, useReducer, useRef holding "pending" / "placeholder" / "in-flight" / "optimistic" values keyed by row id
    • Atom.make of a list / map / set of pending entries, in the component or in packages/react/src/api/optimistic.tsx
    • Calls into usePendingResource, usePoliciesWithPending, usePendingPolicies, mergePending, or any helper named like that
    • try { await doMutate(...) } finally { placeholder.done() } shapes
    • Manual id minting (pending-${...}, crypto.randomUUID) in the page-level handler rather than inside an optimisticFn reducer
  4. Confirm the optimistic atom exists or is missing. Open packages/react/src/api/atoms.tsx and check for <thing>OptimisticAtom = Atom.family(scopeId => Atom.optimistic(<thing>Atom(scopeId))). If a sibling resource (sources, secrets, policies) has the optimistic wrapper and this one doesn't, the bug is the missing wrapper plus the hand-rolled substitute.
  5. Check for grandfathered code. usePendingSources, useSourcesWithPending, useConnectionsWithPendingRemovals, and usePendingConnectionRemovals in packages/react/src/api/optimistic.tsx are legacy. New code should not extend them. Their continued existence is not a finding by itself; new consumers or new entries in PendingResource are.

When the trace cannot resolve with the files at hand, drop the finding.

What to Report

  • Hand-rolled pending state next to an effect-atom mutation. Component imports a mutation from ./api/atoms (e.g. updatePolicy, createSecret, removeConnection) and tracks the in-flight value in useState / useRef / a custom atom for the purpose of immediate UI feedback. Severity: medium.
  • New entries in PendingResource or new helpers in packages/react/src/api/optimistic.tsx. The file is closed for new patterns. New rows, new usePending<X> hooks, new use<X>WithPending hooks should instead be Atom.optimistic + Atom.optimisticFn families in atoms.tsx. Severity: medium.
  • try/finally cleanup of a placeholder around await doMutate(...). This shape is the tell. optimisticFn clears its own transition; manual cleanup means the author is reimplementing it. Severity: medium.
  • Reading <thing>Atom(scopeId) in a component that also writes through <thing>OptimisticAtom's mutations. The reads and writes must both go through the optimistic family or both bypass it; mixing them produces visual jumps. Severity: medium.
  • Atom.optimisticFn reducer that derives next state from a captured snapshot of the parent atom instead of from the current argument. The reducer signature is (current, update) => W — the runtime reads the optimistic state itself and passes it as current, which already reflects in-flight transitions. Code that closes over a useAtomValue(...) snapshot or a captured policies variable instead of using current will see stale state under racing edits. A placeholder row id derived from Date.now() or random is fine; the bug is reducer state that ignores current. Severity: low.
  • Atom.optimistic wrapped via Atom.optimistic(policiesAtom(scopeId)) outside an Atom.family. Without Atom.family, every render builds a new optimistic atom and transitions don't share state. Severity: medium.

What NOT to Report

  • useState / useReducer for UI-local state that has nothing to do with mutation lifecycle: form input values, modal open flags, hover state, derived display values. Read the surrounding code; if there is no useAtomSet or await doMutate near the state, it is not in scope.
  • The legacy hooks themselves in packages/react/src/api/optimistic.tsx (useSourcesWithPending, useConnectionsWithPendingRemovals, usePendingSources, usePendingConnectionRemovals). Existing call sites are grandfathered. Only flag new call sites or new helpers added to that file.
  • useState for a "busy" / "submitting" boolean used to disable a button while the mutation runs. That is not optimistic state.
  • setTimeout / setInterval based debouncing or rate-limiting around a mutation. Different concern.
  • Toast / error-message state. UI feedback, not optimistic data.
  • Server-only code (apps/cloud, apps/local, packages/core/**). This skill is React-specific; do not flag backend handlers, plugin storage, or test helpers.
  • Storybook files, test files, and example-only code. The pattern matters in shipped UI; not in fixtures.

Severity ladder

Level Criteria
medium New optimistic-mutation code that bypasses Atom.optimistic / Atom.optimisticFn and rolls its own pending state. Or a mixed read/write where the read goes through the plain query atom and the write goes through the optimistic mutation.
low Subtle defects in an Atom.optimisticFn reducer that work today but degrade under racing (clock-based identity, missing Atom.family wrapper, computed-once captures of scopeId).

Do not invent high. Pick low when in doubt and explain why.

Reference patterns (TypeScript)

The repo's reference implementation lives in packages/react/src/api/atoms.tsx (search for policiesOptimisticAtom, updatePolicyOptimistic).

Bad: hand-rolled pending state

// packages/react/src/pages/secrets.tsx (hypothetical)
import { useState } from "react";
import { useAtomSet, useAtomValue } from "@effect-atom/atom-react";

import { secretsAtom, updateSecret } from "../api/atoms";

export function SecretsPage() {
  const scopeId = useScope();
  const secrets = useAtomValue(secretsAtom(scopeId));
  const doUpdate = useAtomSet(updateSecret, { mode: "promise" });
  const [pendingValue, setPendingValue] = useState<Map<string, string>>(new Map());

  const handleEdit = async (id: string, value: string) => {
    setPendingValue((m) => new Map(m).set(id, value));
    try {
      await doUpdate({ path: { scopeId, secretId: id }, payload: { value } });
    } finally {
      setPendingValue((m) => {
        const next = new Map(m);
        next.delete(id);
        return next;
      });
    }
  };
  // ...
}

The Map keyed by id, the try/finally, the manual cleanup. All three signal a hand-rolled optimistic layer. Two fast edits to the same secret race: A's finally deletes B's pending entry, UI flickers to A's server value before B settles.

Safe: effect-atom primitives

// packages/react/src/api/atoms.tsx
export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) =>
  Atom.optimistic(secretsAtom(scopeId)),
);

export const updateSecretOptimistic = Atom.family((scopeId: ScopeId) =>
  secretsOptimisticAtom(scopeId).pipe(
    Atom.optimisticFn({
      reducer: (current, arg: { path: { secretId: SecretId }; payload: { value: string } }) =>
        Result.map(current, (rows) =>
          rows.map((r) => (r.id === arg.path.secretId ? { ...r, value: arg.payload.value } : r)),
        ),
      fn: updateSecret,
    }),
  ),
);

// packages/react/src/pages/secrets.tsx
const secrets = useAtomValue(secretsOptimisticAtom(scopeId));
const doUpdate = useAtomSet(updateSecretOptimistic(scopeId), { mode: "promise" });

const handleEdit = (id: SecretId, value: string) =>
  doUpdate({ path: { scopeId, secretId: id }, payload: { value } });

No local state, no try/finally, no manual cleanup. Multiple edits stack: the runtime feeds each reducer call current reflecting prior in-flight transitions, so the second edit sees the optimistic value of the first.

Bad: extending the legacy layer

// packages/react/src/api/optimistic.tsx (hypothetical addition)
export const PendingResource = {
  sources: "sources",
  connectionRemovals: "connection-removals",
  secrets: "secrets", // <-- new
} as const;

export interface PendingSecret {
  readonly value: string;
}

export const useSecretsWithPending = (scopeId: ScopeId) => {
  /* ... */
};
export const usePendingSecrets = () => {
  /* ... */
};

Flag any new entry in PendingResource and any new use<X>WithPending / usePending<X> hook. The file is closed for new patterns.

Subtle: missing Atom.family wrapper

// Bad: builds a fresh optimistic atom every render. No transition state survives.
const optimistic = Atom.optimistic(secretsAtom(scopeId));
const value = useAtomValue(optimistic);
// Safe: Atom.family memoizes per scopeId so transitions persist.
export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) =>
  Atom.optimistic(secretsAtom(scopeId)),
);

const value = useAtomValue(secretsOptimisticAtom(scopeId));

Output Requirements

For each finding:

  • File and line of the offending code.
  • Severity from the ladder above.
  • What is wrong, in one sentence.
  • Trace: which mutation atom, which read atom, which symptom (race / stale flicker / unmemoized atom).
  • Fix: name the optimistic family that should exist, or the change that lifts the page's hand-rolled state into atoms.tsx. Point to packages/react/src/api/atoms.tsx (policiesOptimisticAtom) as the reference shape.

Group findings by severity. Lead with medium.

用于修复 lint 报错的 useAtomSet 写入突变调用,通过添加 reactivityKeys 明确指定失效读取范围,确保数据更新时 UI 正确刷新。
lint 提示 useAtomSet 突变缺少无效化键 需要为写入操作指定精确的数据失效范围
.agents/skills/wrdn-effect-atom-reactivity-keys/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill wrdn-effect-atom-reactivity-keys -g -y
SKILL.md
Frontmatter
{
    "name": "wrdn-effect-atom-reactivity-keys",
    "description": "Add reactivityKeys to effect-atom write mutation calls. Use when lint flags a useAtomSet mutation call that mutates data without invalidation keys.",
    "allowed-tools": "Read Grep Glob Bash"
}

Effect-atom write mutations must say which reads they invalidate.

Fix Shape

  • Find the useAtomSet(...) write mutation call.
  • Add reactivityKeys to the mutation payload at the call site.
  • Use the narrowest keys that cover the rows/lists affected by the write.
  • Keep read-only probe/preview OAuth flows out of this pattern.
  • If the mutation should update UI immediately, check whether wrdn-effect-atom-optimistic also applies.

Good

await updateSource({
  params: { scopeId, sourceId },
  payload,
  reactivityKeys: [["sources", scopeId]],
});
将React中effect-atom突变处理从promise模式加try/catch重构为promiseExit模式。通过显式处理Exit值替代异常捕获,符合Effect错误模型,防止乐观更新清理依赖抛出异常。
代码审查或Linter提示使用try/catch包裹useAtomSet突变调用 需要处理突变失败后的UI状态(如错误提示、忙碌状态重置)
.agents/skills/wrdn-effect-promise-exit/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill wrdn-effect-promise-exit -g -y
SKILL.md
Frontmatter
{
    "name": "wrdn-effect-promise-exit",
    "description": "Replace React\/effect-atom mutation handlers that use promise-mode plus try\/catch with promiseExit and explicit Exit handling. Use when lint or review flags try\/catch around useAtomSet mutation calls, especially UI handlers that set error\/busy state after a failed mutation.",
    "allowed-tools": "Read Grep Glob Bash"
}

You fix one pattern: a React handler awaits an effect-atom mutation in mode: "promise" and catches failures with try/catch.

The preferred UI boundary is mode: "promiseExit" plus Exit.isFailure. This keeps mutation failures as values, matches Effect's error model, and prevents optimistic mutation cleanup from depending on thrown exceptions.

Trace before changing

  1. Find the mutation setter. Look for const doX = useAtomSet(<mutationAtom>, { mode: "promise" }).
  2. Confirm it is an effect-atom mutation boundary. The setter should come from @effect/atom-react and a mutation atom from ./atoms, ../api/atoms, or plugin React atoms.
  3. Find thrown-control handling. The same handler has try { await doX(...) } catch (e) { ... }, usually setting error text, resetting adding/saving, or showing a toast.
  4. Check for non-mutation async work in the same block. If the block also awaits follow-up mutations, convert those to promiseExit too or keep a narrow boundary only around truly non-effect APIs.
  5. Do not rewrite unrelated local async code. Probe requests, OAuth popup helpers, fetch, and browser APIs may need a different skill unless the lint finding specifically points at the mutation call.

Fix shape

  • Change the setter to { mode: "promiseExit" }.
  • Import * as Exit from "effect/Exit" if missing.
  • Import * as Option from "effect/Option" only when extracting an optional error.
  • Replace try/catch around the mutation with:
    • const exit = await doX(args);
    • if (Exit.isFailure(exit)) { ...; return; }
    • success work after the failure branch.
  • Use Exit.findErrorOption(exit) when preserving an existing error message or typed error branch.
  • Keep existing typed error handling when present, e.g. SecretInUseError, ConnectionInUseError.

Bad

const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" });

const handleAdd = async () => {
  setAdding(true);
  setAddError(null);
  try {
    await doAdd({
      params: { scopeId },
      payload,
      reactivityKeys: sourceWriteKeys,
    });
    props.onComplete();
  } catch (e) {
    setAddError(e instanceof Error ? e.message : "Failed to add source");
    setAdding(false);
  }
};

Good

import * as Exit from "effect/Exit";
import * as Option from "effect/Option";

const doAdd = useAtomSet(addGraphqlSource, { mode: "promiseExit" });

const handleAdd = async () => {
  setAdding(true);
  setAddError(null);
  const exit = await doAdd({
    params: { scopeId },
    payload,
    reactivityKeys: sourceWriteKeys,
  });
  if (Exit.isFailure(exit)) {
    const error = Exit.findErrorOption(exit);
    setAddError(
      Option.isSome(error) && error.value instanceof Error
        ? error.value.message
        : "Failed to add source",
    );
    setAdding(false);
    return;
  }
  props.onComplete();
};

Follow-up mutation chains

If success work depends on the mutation result, read it after the failure branch:

const exit = await doAdd(args);
if (Exit.isFailure(exit)) {
  setAdding(false);
  return;
}

const sourceId = exit.value.namespace;

If a follow-up effect-atom mutation can fail and the UI treats that as add failure, make that setter promiseExit too and branch the same way. Do not put the follow-up mutation in try/catch just because the first mutation now returns Exit.

What not to report

  • try/catch around non-effect APIs such as new URL, JSON.parse, raw fetch, or browser popup code. Those may be real lint findings, but they need a different remediation skill.
  • useAtomSet(..., { mode: "promise" }) with no local failure handling and no lint finding. Some call sites intentionally let callers decide the boundary.
  • Tests or SDK/server Effect code. This skill is for React/effect-atom UI mutation handlers.
  • Manual optimistic placeholder cleanup. Use wrdn-effect-atom-optimistic for that; if both patterns appear together, fix optimistic plumbing first, then use promiseExit for the remaining mutation boundary.

Output requirements

When reviewing, report:

  • File and line of the useAtomSet(..., { mode: "promise" }) or try/catch.
  • Mutation being called.
  • Why it should return Exit at this UI boundary.
  • Fix: the exact setter mode and the failure branch to add.

When editing, keep changes local to the handler and imports unless a follow-up mutation in the same success path must also become promiseExit.

规范 HTTP 请求通过 Effect HttpClient 服务而非原生 fetch,以支持测试替换。明确禁止在核心 SDK 和插件接口直接使用 fetch,规定仅允许特定边界(如 Worker 适配)使用,确保网络层可模拟。
代码中出现原生 fetch 调用 涉及网络协议或 Provider 开发 需要为测试注入 Mock HttpClient
.agents/skills/wrdn-effect-raw-fetch-boundary/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill wrdn-effect-raw-fetch-boundary -g -y
SKILL.md
Frontmatter
{
    "name": "wrdn-effect-raw-fetch-boundary",
    "description": "Route HTTP through Effect boundaries instead of raw fetch. Use when lint flags executor\/no-raw-fetch or when adding networked protocol\/provider code.",
    "allowed-tools": "Read Grep Glob Bash"
}

HTTP in core SDK and protocol plugins should go through Effect services so tests can replace real networks with local protocol fixtures or mock HttpClient layers.

Fix Shape

  • Prefer effect/unstable/http HttpClient and HttpClientRequest for ordinary HTTP calls.
  • Accept a Layer.Layer<HttpClient.HttpClient> option on plugin/provider code when callers need to inject a test client.
  • In tests, use real local servers plus FetchHttpClient.layer or a captured HttpClient service from the test layer.
  • Do not add fetch-shaped abstractions in SDK or plugin seams. If a third-party library truly only accepts fetch, keep the adapter in the owning package, name the forced boundary explicitly, and delegate internally to Effect HttpClient.
  • Do not type new protocol/plugin seams as typeof globalThis.fetch; keep the ambient runtime boundary out of domain and test APIs.
  • Do not patch globalThis.fetch. Replace those tests with a local server, HttpClient layer, or the approved Effect-backed adapter.
  • Do not add a broad allowlist entry unless the file is a platform entrypoint or a temporary migration target.

Approved Boundaries

  • Worker/handler objects whose public API must expose a fetch method.
  • Test calls to a worker or Miniflare binding's .fetch(...) method.
  • Small adapters for libraries that only accept fetch, if the implementation delegates to Effect HttpClient.
  • Browser UI event handlers may remain raw only until app-side boundaries are classified; prefer SDK/client APIs where available.

Bad

const response = await fetch(url);
const fetchImpl = options.fetch ?? globalThis.fetch;

Good

const client = yield * HttpClient.HttpClient;
const response = yield * client.execute(HttpClientRequest.get(url));
const plugin = graphqlPlugin({ httpClientLayer: testHttpClientLayer });
在数据边界处使用 Effect Schema、命名守卫或类型适配器规范化未知或松散类型的数据。替代不安全的断言和重复探测,确保领域代码保持类型安全。
代码中存在对未知数据的类型断言或属性探测 需要解析不可信输入或 JSON 字符串并验证结构 存在冗余的类型转换或不安全的运行时检查
.agents/skills/wrdn-effect-schema-boundaries/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill wrdn-effect-schema-boundaries -g -y
SKILL.md
Frontmatter
{
    "name": "wrdn-effect-schema-boundaries",
    "description": "Normalize unknown or loosely typed data at boundaries with Effect Schema, named guards, or typed adapters. Use when lint flags double casts, inline object assertions, unknown shape probing, or ad hoc property checks on unknown values.",
    "allowed-tools": "Read Grep Glob Bash"
}

You fix one pattern: domain code is asserting or probing an unknown shape instead of parsing it once at the boundary.

Fix Shape

  • Prefer Schema.decodeUnknownEffect(MySchema)(value) for untrusted input.
  • Prefer Schema.decodeUnknownEffect(Schema.fromJsonString(MySchema))(text) or Schema.decodeUnknownOption(Schema.parseJson())(text) for JSON strings.
  • Keep domain code typed after the decode; do not keep unknown and probe it repeatedly.
  • Replace JSON.parse, value as string, as unknown as X, as Record<string, unknown>, inline object assertions, "field" in value, and Reflect.get with a schema, typed adapter, or named guard.
  • A named guard is acceptable only when parsing is not the right abstraction and the guard has a precise return type.

Good

const ParsedConfig = Schema.Struct({
  endpoint: Schema.String,
});

const config = yield * Schema.decodeUnknownEffect(ParsedConfig)(raw);
const config = yield * Schema.decodeUnknownEffect(Schema.fromJsonString(ParsedConfig))(rawText);

Bad

const config = raw as unknown as { endpoint: string };
const config = JSON.parse(rawText) as { endpoint: string };
const pattern = updated.pattern as string;
将手动定义的 TypeScript 类型替换为从 Effect Schema 推导出的类型,消除重复声明。通过提取 schema 作为单一事实来源,确保静态类型与运行时解析一致,防止数据漂移。
代码中存在与 Effect Schema 定义重复的 interface 或 type 别名 Lint 或代码审查指出类型与 Schema 字段不一致 需要重构以遵循 schema-first 最佳实践
.agents/skills/wrdn-effect-schema-inferred-types/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill wrdn-effect-schema-inferred-types -g -y
SKILL.md
Frontmatter
{
    "name": "wrdn-effect-schema-inferred-types",
    "description": "Replace duplicated TypeScript shape declarations next to Effect Schema definitions with schema-derived types. Use when lint or review flags an interface\/type alias that repeats fields already described by a nearby Schema.Struct, Schema.Union, Schema.TaggedStruct, or other Effect Schema model.",
    "allowed-tools": "Read Grep Glob Bash"
}

You fix one pattern: a runtime Schema and a manual TypeScript type describe the same shape.

The preferred boundary is schema-first. Define the schema once, export type X = typeof XSchema.Type or type X = Schema.Schema.Type<typeof XSchema>, and make domain code consume the inferred type. This prevents drift between parsing and static types.

Trace before changing

  1. Find the runtime schema. Look for Schema.Struct, Schema.Union, Schema.TaggedStruct, Schema.Record, Schema.Array, or Schema.decodeTo.
  2. Find the duplicate static shape. A nearby interface X or type X = { ... } repeats the same fields, nullability, optionality, or literals.
  3. Check export consumers. If callers import the type, keep the exported type name stable and change only its definition.
  4. Confirm the schema is the source of truth. If the manual type is wider/narrower than runtime parsing, decide whether the schema or consumers are wrong before replacing it.
  5. Handle recursion narrowly. Recursive schemas may need one private recursive helper type to annotate Schema.suspend; keep exported domain types inferred from the schema.

Fix shape

  • Move the schema before the exported type alias when needed.
  • Replace duplicated exported interfaces with aliases derived from the schema:
export const SourceSchema = Schema.Struct({
  id: SourceId,
  name: Schema.String,
  enabled: Schema.Boolean,
});

export type Source = typeof SourceSchema.Type;
  • Use Schema.Schema.Type<typeof XSchema> when it reads better for non-exported or generic schemas:
type IntrospectionResult = Schema.Schema.Type<typeof IntrospectionResultModel>;
  • If using Schema.decodeTo, infer the domain type from the decoded/domain schema, not from the raw transport schema.
  • Do not keep a manual interface solely for documentation. Add schema annotations or comments only when they clarify behavior the schema cannot express.

Bad

export interface StoredSource {
  readonly id: string;
  readonly url: string;
  readonly headers: readonly Header[];
}

export const StoredSourceSchema = Schema.Struct({
  id: Schema.String,
  url: Schema.String,
  headers: Schema.Array(HeaderSchema),
});

Good

export const StoredSourceSchema = Schema.Struct({
  id: Schema.String,
  url: Schema.String,
  headers: Schema.Array(HeaderSchema),
});

export type StoredSource = typeof StoredSourceSchema.Type;

Recursive schemas

Use a private helper only where TypeScript needs an annotation for self-reference:

interface TypeRefRecursive {
  readonly kind: string;
  readonly ofType: TypeRefRecursive | null;
}

const TypeRefSchema: Schema.Codec<TypeRefRecursive> = Schema.Struct({
  kind: Schema.String,
  ofType: Schema.NullOr(Schema.suspend(() => TypeRefSchema)),
});

export type TypeRef = typeof TypeRefSchema.Type;

The exported domain type is still schema-derived. The private helper exists only to satisfy the recursive schema definition.

What not to report

  • Domain types that intentionally do not have a runtime schema.
  • Input builder types where the schema parses a different transport representation.
  • Branded IDs or opaque aliases that are used by schemas but are not themselves duplicate object shapes.
  • Private recursive helper types used only to type Schema.suspend, as long as exported consumer-facing types are inferred.

Output requirements

When reviewing, report:

  • File and line of the duplicated manual type.
  • Schema that already owns the shape.
  • Why the manual type can drift.
  • Fix: the exact inferred alias to use.

When editing, keep exported type names stable unless every caller is updated in the same change.

修复 Effect 代码中未使用类型化错误处理的问题。将原生 JS 异常转换为 Schema.TaggedError,确保错误进入 Effect 通道,同时保留真实边界(如回调、工具)的原有行为,避免静默失败或破坏语义。
代码中存在 new Error, throw, try/catch 发现 Promise.catch, Promise.reject 检测到 instanceof Error 或错误字符串化 存在仅构造标签化错误的冗余辅助函数
.agents/skills/wrdn-effect-typed-errors/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill wrdn-effect-typed-errors -g -y
SKILL.md
Frontmatter
{
    "name": "wrdn-effect-typed-errors",
    "description": "Fix lint findings that use untyped JavaScript error handling instead of Effect typed failures. Use when lint flags new Error, throw, try\/catch, Promise.catch, Promise.reject, instanceof Error, unknown error message\/stringification, or redundant helpers that only construct tagged errors.",
    "allowed-tools": "Read Grep Glob Bash"
}

You fix one family of patterns: untyped JavaScript error handling in Effect code.

The preferred boundary is typed Schema.TaggedError / Data.TaggedError values in the Effect error channel. Construct the tagged error directly at the failure site unless a helper performs real classification or normalization.

Trace before changing

  1. Identify the boundary. Is this Effect domain code, React UI code, a third-party callback, or plain test/tooling code?
  2. Find the existing domain errors. Check nearby errors.ts, Schema.TaggedError, Data.TaggedError, and API .addError(...) declarations before adding a new class.
  3. Decide whether a new error is needed. Add a new tagged error only if callers have a distinct recovery path, HTTP status, UI affordance, retry policy, or telemetry classification.
  4. Preserve failure semantics. If the old code failed, the new code should fail in the Effect error channel. Do not replace thrown failures with fallback values like false, null, undefined, [], or "unknown" unless the existing contract already treats that condition as non-fatal.
  5. Preserve the typed channel. Do not convert typed failures into Error, thrown exceptions, String(error), or .message reads from unknown values.
  6. Recognize real boundaries. Runtime workers, Vite/CLI tooling, callback APIs, and third-party interfaces may have to throw, catch, or reject at the boundary. Do not contort those files into fake Effect shapes. Keep the boundary idiom when it is contained and immediately wrapped into an Effect error channel, stable IPC envelope, or test/tooling result.
  7. Do not hide construction behind trivial helpers. Inline new DomainError(...) unless the helper branches on input or maps an external error format into a domain error.

Preserve behavior first

The lint rule is about where the failure lives, not whether the operation should still fail.

Bad fix: this removes the lint finding by silently changing invalid input into a non-match.

case "in":
  if (!Array.isArray(value)) return false;
  return value.some((v) => cmp(lhs, v));

Good fix: keep the invalid input as a failure, but make it typed.

case "in":
  if (!Array.isArray(value)) {
    return Effect.fail(
      new StorageError({ message: "Value must be an array", cause: clause }),
    );
  }
  return Effect.succeed(value.some((v) => cmp(lhs, v)));

When the containing helper was synchronous, make the helper return Effect.Effect<Success, DomainError> and thread that through callers. Do not collapse the error into a success value to avoid changing call sites.

Boundary exceptions

The lint rule is not a mandate to make every file Effect-shaped. It is acceptable to keep try/catch, throw, new Error, .catch, or String(error) at a true adapter boundary when all of these are true:

  • the surrounding API is inherently throwing, callback-based, Promise-based, process/IPC-based, or plain JS tooling
  • the untyped behavior is contained to the boundary function or module
  • control is immediately translated into a typed Effect failure, stable IPC payload, stable test assertion, or deliberately best-effort cleanup
  • the suppression is narrow and explains the boundary

Repo Effect API compatibility

Use the APIs that exist in this repo's pinned Effect runtime:

  • Use Effect.callback for callback adapters. Do not use Effect.async.
  • Use Effect.andThen or Effect.gen sequencing. Do not use Effect.zipRight.
  • Use Effect.timeoutOrElse or Effect.timeoutOption. Do not use Effect.timeoutFail.

These are not style preferences; the unavailable APIs fail at typecheck or runtime.

Good boundary suppression:

// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.parse feeds stable IPC failure envelope
try {
  const message = JSON.parse(line);
  handleHostMessage(message);
} catch (error) {
  writeIpcMessage({ type: "failed", error: formatBoundaryError(error) });
}

Bad boundary fix: do not replace natural boundary code with fake thenables, fake error objects, promise chains that emulate try/catch, or broad helper machinery solely to make lint pass.

return makeRejectedThenable(makeErrorLike("Tool path missing"));

For Effect domain code, fix the code. For boundary code, either wrap once with Effect.try / Effect.tryPromise at the entry point or use a narrow suppression with a reason.

Fix shapes

Throw / new Error

Bad:

throw new Error("Missing source");

Good in Effect.gen:

return yield * new SourceNotFoundError({ sourceId });

Good in combinators:

Effect.fail(new SourceNotFoundError({ sourceId }));

If a third-party interface requires throwing, keep the throw at the adapter edge only and convert back into a typed failure as soon as control returns to Effect. Prefer a narrow oxlint-disable-next-line with a boundary: reason over code contortions.

Effect.fail inside generators

Prefer yielding the error directly in generator code:

return yield * new SourceNotFoundError({ sourceId });

Do not write:

return yield * Effect.fail(new SourceNotFoundError({ sourceId }));

Use Effect.fail(...) in non-generator combinator code:

Effect.flatMap(
  source,
  Option.match({
    onNone: () => Effect.fail(new SourceNotFoundError({ sourceId })),
    onSome: Effect.succeed,
  }),
);

Promise.catch / Promise.reject

Bad:

await client.close().catch(() => {});
return Promise.reject(new Error("failed"));

Good:

Effect.tryPromise({
  try: () => client.close(),
  catch: (cause) => new ClientCloseError({ cause }),
});

If the failure is intentionally ignored:

Effect.ignore(
  Effect.tryPromise({
    try: () => client.close(),
    catch: (cause) => new ClientCloseError({ cause }),
  }),
);

Effect die / orDie escape hatches

Bad in domain code:

program.pipe(Effect.orDie);
Effect.die(error);

Good:

program.pipe(Effect.mapError((cause) => new DomainError({ message: "Operation failed", cause })));

Effect.die, Effect.dieMessage, Effect.orDie, and Effect.orDieWith turn typed failures into defects. Use them only at a true runtime boundary where the host cannot represent typed failures, and keep that usage behind a narrow lint suppression with a boundary: reason. Do not use orDie to avoid threading an error type through normal Effect code.

try/catch

Bad:

try {
  return JSON.parse(text);
} catch (cause) {
  return new ParseError({ message: String(cause) });
}

Good for schema-backed input:

Schema.decodeUnknownEffect(Schema.fromJsonString(InputSchema))(text).pipe(
  Effect.mapError(() => new ParseError({ message: "Failed to parse input" })),
);

Good for non-schema throwing APIs:

Effect.try({
  try: () => new URL(value),
  catch: (cause) => new UrlParseError({ value, cause }),
});

Unknown error message / instanceof Error

Bad:

err instanceof Error ? err.message : String(err);

Also bad: destructuring message only hides the same unknown-state problem from a shallow property-access lint.

const { message } = err;
return message;

Prefer one of:

Effect.mapError((err) => new DomainError({ cause: err }));
Effect.catchTag("KnownError", (err) => Effect.fail(new DomainError({ message: err.message })));

Only read .message from a typed error union when that field is explicitly part of the user-facing contract. Most boundary errors should instead use a stable product message and keep the original value in a separate cause, trace, log, or telemetry channel. Do not inspect unknown thrown values for domain behavior or customer copy.

If the lint rule overfires inside a branch that has already narrowed to a specific typed error, keep the direct typed read and use a narrow suppression with a reason. Do not rewrite to destructuring just to avoid the lint selector.

Bad: leaks internal provider/native details to users.

Effect.tryPromise({
  try: () => client.call(),
  catch: (cause) =>
    new SourceError({
      message: cause instanceof Error ? cause.message : String(cause),
    }),
});

Good: user-facing message is stable; internal detail goes into cause only if the error type has an internal channel.

Effect.tryPromise({
  try: () => client.call(),
  catch: (cause) =>
    new SourceError({
      message: "Failed to connect to source",
      cause,
    }),
});

If the error schema is serialized to customers and only has message, do not put internal details there. Prefer adding a non-serialized/internal cause field or logging/telemetry over suppressing the lint rule.

Manual tags and broad error laundering

Bad: manually probing _tag to recover from typed Effect failures.

Effect.mapError((err) =>
  "_tag" in err && err._tag === "SecretOwnedByConnectionError"
    ? new SourceError({ message: "Failed to resolve secret" })
    : err,
);

Good: catch the one typed case you intentionally translate.

effect.pipe(
  Effect.catchTag("SecretOwnedByConnectionError", () =>
    Effect.fail(new SourceError({ message: "Failed to resolve secret" })),
  ),
);

Do not wrap a typed error union into one local error only to satisfy a narrower helper signature. Widen the helper/cache/invocation error channel when callers can still use the original typed failure. Wrap only when the new error adds product meaning, such as turning a connection-owned secret into a source configuration problem.

For Effect data types, use public helpers instead of _tag checks:

if (Option.isNone(parsed)) return null;
if (Exit.isFailure(exit)) return ...

Redundant error helpers

Bad:

const connectionError = (message: string) =>
  new McpConnectionError({ transport: "remote", message });

return yield * connectionError("Endpoint URL is required");

Good:

return (
  yield *
  new McpConnectionError({
    transport: "remote",
    message: "Endpoint URL is required",
  })
);

Helpers are allowed only when they do real work, such as:

  • choosing between different tagged errors
  • decoding/parsing an external error shape
  • preserving protocol-specific fields
  • normalizing third-party SDK failures into one domain error

New error or existing error?

Reuse an existing tagged error when only the message changes.

Create a new tagged error when a caller can reasonably branch differently:

  • different HTTP status
  • retry vs no retry
  • auth/sign-in affordance
  • not-found vs conflict vs validation
  • user-actionable vs internal failure
  • different telemetry grouping that should not depend on message text

Do not create one tagged error per sentence of prose.

What not to report

  • Test assertions that intentionally construct errors as fixture values.
  • Runtime adapter edges that must satisfy a third-party throwing API, IPC contract, process worker contract, or tooling contract, as long as the untyped behavior is contained and converted to typed Effect failure or a stable boundary envelope.
  • Real normalization helpers like toOAuth2Error(cause) that inspect protocol fields and preserve structured semantics.
  • React/effect-atom mutation handlers using try/catch; use wrdn-effect-promise-exit for that UI-specific boundary.

Output requirements

When reviewing, report:

  • File and line of the untyped error pattern.
  • Rule being violated.
  • Existing domain error to use, or the new tagged error that should exist.
  • Fix in the relevant shape: direct yield* new ErrorType(...), Effect.tryPromise, schema decode, or direct constructor inline.

When editing, keep the error type precise and avoid broad message parsing.

自动将手动定义的TypeScript对象类型替换为由运行时工厂函数推断的类型。通过查找重复的类型定义,将其重构为ReturnType<typeof factory>,防止类型与实现脱节,同时保留稳定的导出名称供消费者使用。
代码审查发现接口或类型别名镜像了返回对象的形状 存在手动维护且易漂移的对象类型定义
.agents/skills/wrdn-effect-value-inferred-types/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill wrdn-effect-value-inferred-types -g -y
SKILL.md
Frontmatter
{
    "name": "wrdn-effect-value-inferred-types",
    "description": "Replace duplicated object API types with types inferred from the runtime value or factory that owns the shape. Use when lint or review flags an interface\/type alias that mirrors a returned object such as a plugin extension, client surface, route map, or handler table.",
    "allowed-tools": "Read Grep Glob Bash"
}

You fix one pattern: a TypeScript object type manually mirrors a runtime object that already owns the shape.

Prefer value-first APIs. Build the object in a named factory, then export type X = ReturnType<typeof makeX>. Consumers keep importing the stable type name, but the type cannot drift from the implementation.

Trace before changing

  1. Find the source value. Look for an object returned from a named factory, extension: (...) => ({ ... }), a client object, route map, or handler table.
  2. Find the duplicate type. A nearby interface X or type X = { ... } repeats the object methods/properties.
  3. Check whether the value is the source of truth. If the interface is a contract with multiple implementations, keep the interface.
  4. Preserve the exported type name. Replace its definition with ReturnType<typeof makeX> and update callers only if needed.
  5. Use satisfies only at boundaries. Do not make the implementation satisfy a duplicate shape that could drift.

Fix shape

const makePluginExtension = (ctx: PluginCtx<Store>) => {
  const addSource = ...
  const removeSource = ...

  return {
    addSource,
    removeSource,
  };
};

export type PluginExtension = ReturnType<typeof makePluginExtension>;

For factories that need options:

const makePluginExtension =
  (options: PluginOptions) =>
  (ctx: PluginCtx<Store>) => ({
    addSource: ...,
  });

export type PluginExtension = ReturnType<ReturnType<typeof makePluginExtension>>;

Bad

export interface McpPluginExtension {
  readonly addSource: (config: McpSourceConfig) => Effect.Effect<AddResult, Failure>;
  readonly removeSource: (namespace: string, scope: string) => Effect.Effect<void, Failure>;
}

extension: (ctx) => {
  return {
    addSource,
    removeSource,
  } satisfies McpPluginExtension;
};

Good

const makeMcpPluginExtension = (ctx: PluginCtx<McpStore>) => {
  return {
    addSource,
    removeSource,
  };
};

export type McpPluginExtension = ReturnType<typeof makeMcpPluginExtension>;

extension: makeMcpPluginExtension;

What not to report

  • Service/dependency interfaces with multiple implementations.
  • Public config input types that are intentionally a stable authored API.
  • Branded IDs, discriminated unions, or small aliases that do not mirror one object value.
  • Test fakes typed against an existing exported contract.
  • Schema-owned data shapes; use wrdn-effect-schema-inferred-types for those.

Output requirements

When reviewing, report:

  • File and line of the duplicate object type or satisfies usage.
  • Value/factory that owns the shape.
  • Why the manual type can drift.
  • Fix: the exact ReturnType alias to introduce.

When editing, name the factory after the exported type, e.g. makeMcpPluginExtension for McpPluginExtension.

规范仓库内测试代码,强制使用@effect/vitest替代原生vitest导入。禁止在条件分支中使用expect,需拆分测试或显式断言,确保测试确定性与Effect兼容性。
lint提示vitest导入问题 测试中存在条件断言
.agents/skills/wrdn-effect-vitest-tests/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill wrdn-effect-vitest-tests -g -y
SKILL.md
Frontmatter
{
    "name": "wrdn-effect-vitest-tests",
    "description": "Keep tests deterministic and Effect-aware. Use when lint flags direct vitest imports or conditional assertions inside tests.",
    "allowed-tools": "Read Grep Glob Bash"
}

Use @effect/vitest for tests in this repo.

Fix Shape

  • Import describe, it, expect, and helpers from @effect/vitest.
  • Import utility helpers from @effect/vitest/utils when needed.
  • Do not import from raw vitest except in config or tooling files.
  • Do not put expect(...) behind if, ternary, logical, or switch branches.
  • Split conditional behavior into separate tests, or assert the branch condition and expected value explicitly.

Bad

if (result.ok) {
  expect(result.value).toBe("x");
}

Good

expect(result).toEqual({ ok: true, value: "x" });
用于修复工作区中跨包相对导入问题。将交叉包的相对路径替换为包名导出,若缺失则补充最小公开导出,确保仅在同包内使用相对路径,防止访问其他包的私有源码。
lint 报告跨包根目录的相对导入错误 代码中存在指向其他包私有源码树的相对路径引用
.agents/skills/wrdn-package-boundaries/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill wrdn-package-boundaries -g -y
SKILL.md
Frontmatter
{
    "name": "wrdn-package-boundaries",
    "description": "Preserve workspace package boundaries. Use when lint flags relative imports that cross package roots.",
    "allowed-tools": "Read Grep Glob Bash"
}

Workspace packages should import each other through package exports, not relative paths.

Fix Shape

  • Replace cross-package relative imports with the target package name.
  • If the needed module is not exported, add the smallest package export that matches the package's public surface.
  • Keep relative imports only within the same package root.
  • Do not reach into another package's private source tree from an app or package.

Good

import { createExecutor } from "@executor-js/sdk";

Bad

import { createExecutor } from "../../../core/sdk/src";
修复TypeScript类型边界,移除@ts-nocheck等全局类型绕过手段。通过收窄表达式、添加Schema守卫或改进局部类型来解决错误,避免为局部不匹配静默整个文件。
代码中存在 @ts-nocheck 指令 存在其他宽泛的 TypeScript 类型绕过机制 Lint 工具报告类型检查被禁用
.agents/skills/wrdn-typescript-type-safety/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill wrdn-typescript-type-safety -g -y
SKILL.md
Frontmatter
{
    "name": "wrdn-typescript-type-safety",
    "description": "Remove TypeScript escape hatches. Use when lint flags @ts-nocheck or similar broad type bypasses.",
    "allowed-tools": "Read Grep Glob Bash"
}

Fix the type boundary instead of disabling TypeScript.

Fix Shape

  • Remove @ts-nocheck.
  • Narrow the failing expression, add a schema/guard at an unknown boundary, or improve the local type.
  • If a cast is unavoidable, keep it narrow and document the invariant at the cast site.
  • Do not silence an entire file for a localized mismatch.
提供16种服务的生产级仿真器,支持真实SDK测试、OAuth流程及OpenAPI规范对接。
需要模拟GitHub、Stripe等第三方API进行测试 验证OAuth或OIDC身份认证流程 生成OpenAPI规范供集成使用 断言请求是否成功发送
.claude/skills/emulate/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill emulate -g -y
SKILL.md
Frontmatter
{
    "name": "emulate",
    "description": "Use the @executor-js\/emulate service emulators (GitHub, Google, Stripe, Resend, WorkOS, …) to test integrations for real — full OpenAPI specs, working OAuth flows, mintable credentials, and a request ledger for assertions. Use when a test or demo needs a real-shaped upstream API, an OAuth\/OIDC provider, a spec to feed addSpec, or proof that a request actually landed."
}

Emulate: production-fidelity service emulators

@executor-js/emulate (our fork of Vercel Labs' emulate) provides stateful, wire-level emulators for 16 services: github vercel google okta microsoft spotify slack apple aws resend stripe mongoatlas clerk x workos autumn. These are not mocks: real SDKs and real product code run against them unmodified — the cloud e2e target points the actual WorkOS SDK (sealed sessions, JWKS, hosted AuthKit login) and Autumn billing at emulators and exercises the product's real auth code.

This repo only consumes the published npm package. There is no vendor/emulate submodule — everything imports @executor-js/emulate from npm. The emulator source is its own standalone project; to change or deploy an emulator, see "Changing or deploying an emulator" at the bottom.

Two ways to get one

Local, programmatic (what e2e/setup/cloud.boot.ts does):

import { createEmulator } from "@executor-js/emulate";
const github = await createEmulator({ service: "github", port: 4501 });
// github.url, await github.close() — plus the full typed client below

baseUrl sets the advertised origin (redirects, form actions, spec servers) when a proxy fronts the emulator — the bind stays on port.

Attach to a running one (another process, or hosted on Cloudflare with Durable Object state at https://<service>.<instance>.emulators.dev — catalog at GET https://emulators.dev/_emulate/services). Service hosts (https://<service>.emulators.dev) are control plane only, with no shared default instance behind them: create a private instance first and connect to the returned providerBaseUrl.

import { connectEmulator } from "@executor-js/emulate";
const created = await fetch("https://resend.emulators.dev/_emulate/instances", { method: "POST" });
const { providerBaseUrl } = await created.json();
const resend = await connectEmulator({ baseUrl: providerBaseUrl });
// optional: { service: "resend" } verifies the manifest on connect

In e2e scenarios use createEmulatorInstance from e2e/src/emulator-instance.ts, which wraps this.

The typed control-plane client

Both createEmulator and connectEmulator return the same EmulatorClient surface (since 0.7.0) — use it instead of hand-rolling /_emulate fetches:

Call Use
client.openapiUrl The spec URL — feed it straight to Executor's addSpec to register the emulator as an integration
client.credentials.mint({type:"api-key"}) IssuedCredential in the service's real shape: API keys, bearer tokens, OAuth/OIDC clients, client-credentials apps
client.ledger.list() / .clear() LedgerEntry[]: matched operationId, sanitized headers/body, auth identity, response status, webhook deliveries
client.seed({...}) Add state via the service's seed schema (e.g. WorkOS {oauth:{default_access_token_ttl_seconds:60}} to compress token expiry)
client.reset() Reset state + logs, replay seed — works remotely, unlike the old local-only reset
client.manifest() / .quickstart() / .specs() / .coverage() What the service is, which operations are real vs partial
client.state() / .logs() / .connections() Store snapshot, webhook deliveries, copyable SDK/env/curl snippets

The same routes exist as raw HTTP under /_emulate/* (start at GET /_emulate/quickstart, written for agents) for curl/browser use — but in TypeScript, reach for the client; the types are the point.

Recipes

Test an integration end-to-end for real (the connect-handoff pattern): client.credentials.mint(...) → register client.openapiUrl with the product → invoke a tool through the product → find the call in client.ledger.list() (match on entry.request.body / operationId). The ledger is the proof — "the product made this exact upstream call with this auth" — which beats asserting on the product's own response.

Real OAuth/OIDC flows: google/okta/microsoft/apple/clerk/workos mint OAuth clients and run real authorize/token endpoints. The WorkOS emulator additionally serves hosted AuthKit login pages (any email signs in — users are minted on the fly, no password), an OAuth authorization server for MCP clients, and Vault KV. Real SDK + WORKOS_API_URL override = the product's untouched auth code against it. Set EMULATE_WORKOS_AUDIENCE=<client_id> before createEmulator so minted MCP access tokens carry the right audience.

A live, human-pokeable cloud instance with zero .env: cd e2e && bun run cli up cloud --share — WorkOS + Autumn emulators + the app's real dev stack (recipe in e2e/setup/cloud.boot.ts), fronted with tailscale HTTPS.

Changing or deploying an emulator

The emulators live in their own repo — not in executor and not a submodule: github.com/UsefulSoftwareCo/emulate (clone it as a sibling of executor). It's a pnpm + turbo monorepo on node ≥ 24 — a different toolchain from executor's bun, which is exactly why it's standalone rather than vendored here.

You have full autonomy over it: work directly on main, and commit, push, publish, and deploy without asking. The loop:

  1. Edit in the emulate repo, then rebuild the package you changed. It resolves through built dist, so a source-only edit does nothing until you build it — this is the single most common mistake. @emulators/* packages are a workspace:* graph, so adding or renaming one needs an install + rebuild too.
  2. Publish a version bump of @executor-js/emulate to npm.
  3. Deploy the hosted Cloudflare emulators (the emulate-hosts worker behind *.emulators.dev) when behavior the hosted instances serve has changed.
  4. Back in executor, bump the @executor-js/emulate dependency to the version you just published. Never point a consumer at a local checkout to ship — publish, then bump.

The emulate repo's own AGENTS.md / README.md carry the current build, publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read them there rather than memorizing flags here — they move.

A hot deploy can redden other people's e2e. The emulate-hosts worker behind *.emulators.dev is shared infrastructure; a control-plane regression there has failed unrelated PRs' suites before. Scenarios always run on private per-run instances (POST /_emulate/instances); when a scenario needs behavior that isn't deployed yet, pin to a published package version.

Gotchas

  • Secure cookies need HTTPS off-localhost. Browser-driven flows work on 127.0.0.1, but from another device (tailnet) the app's secure: true auth cookies are dropped over http → "Invalid login state". Front BOTH the app and the emulator with HTTPS (tailscale serve), and give the emulator its public origin via baseUrl AND the app's WORKOS_API_URL — the authorize URL the browser follows is derived from the latter.
  • State is per-process and id counters restart. The WorkOS emulator mints org ids from a per-boot counter — a persisted app DB from a previous boot collides with new ids. Wipe the app's data dir when you restart the emulator (the e2e globalsetup and cloud-demo both do).
  • Don't hand-write fake upstreams. If a scenario needs an upstream API, OAuth provider, or webhook source, reach for an emulator before writing a bespoke stub server — you get specs, auth, and the ledger for free, and the e2e AGENTS.md "never modify product code or stubs" rule stays intact.
通过Executor MCP查询生产遥测数据(Axiom追踪、Postgres、PostHog),用于排查错误、延迟及验证部署。包含字段布局说明、APL查询示例及错误归因逻辑。
调查生产环境错误 分析系统延迟或性能问题 检查用户行为与流失信号 验证新部署的遥测数据
.claude/skills/prod-telemetry/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill prod-telemetry -g -y
SKILL.md
Frontmatter
{
    "name": "prod-telemetry",
    "description": "Query Executor's production telemetry — Axiom traces (executor-cloud dataset), prod Postgres via PlanetScale, PostHog product analytics — through the Executor MCP. Use when investigating prod errors, latency, usage, churn signals, or verifying a deploy's telemetry; includes the dataset field layout, working APL recipes, and the error-attribution join."
}

Production telemetry access

All three stores are queryable through the Executor MCP's connected integrations — no dashboards or credentials needed. Verify the connection exists with connections.list if a call fails.

Axiom traces (axiom_mcp)

Tool: axiom_mcp.user.axiomMcpOAuth.querydataset — the argument is apl (NOT query). Dataset: ['executor-cloud'] (worker spans; browser spans join the same traces via traceparent).

Field layout (the part you'd otherwise rediscover by failed queries):

  • Custom span attributes live under the JSON map ['attributes.custom'], NOT as top-level attributes.* columns. Read with ['attributes.custom']['mcp.tool.name']. A nonexistent top-level field is a hard query error ("invalid field"), not an empty result.
  • Span status: ['status.code'] ("OK"/"ERROR"), ['status.message'].
  • Exceptions: the events column carries exception.type / exception.stacktrace JSON.
  • OTel basics are top-level: name, trace_id, span_id, parent_span_id, duration, _time.

Span names worth querying (and their custom attrs):

  • executor.tool.executemcp.tool.name (full address), and since PR #992: executor.tool.outcome (ok/fail), executor.tool.error_code, executor.tool.error_status, executor.tenant, executor.subject.
  • mcp.tool.dispatchmcp.tool.name (sandbox path), mcp.tool.integration, same outcome attrs.
  • plugin.openapi.invokeplugin.openapi.method / path_template / base_url, and since PR #992 http.status_code.
  • mcp.request (outer) — mcp.auth.organization_id, mcp.auth.account_id, mcp.tool.name, CF edge fields (cf.country…), MCP client fingerprint (mcp.client.name…).

Recipe — error signatures by class (the daily-digest query):

['executor-cloud']
| where _time > ago(1d)
| where ['status.code'] == "ERROR" and name == "executor.tool.execute"
| extend msg = substring(tostring(['status.message']), 0, 120)
| extend tool = tostring(['attributes.custom']['mcp.tool.name'])
| summarize n = count() by msg, tool
| sort by n desc

Recipe — attribute errors to orgs. Tool spans now carry executor.tenant directly (post-#992). For spans from BEFORE that deploy, join through the outer request span:

['executor-cloud']
| where name == "mcp.request" and isnotnull(['attributes.custom']['mcp.auth.organization_id'])
| project trace_id, org = tostring(['attributes.custom']['mcp.auth.organization_id'])
| join kind=inner (
    ['executor-cloud']
    | where ['status.code'] == "ERROR" and name == "executor.tool.execute"
    | project trace_id, msg = substring(tostring(['status.message']), 0, 60)
  ) on trace_id
| summarize n = count() by org, msg | sort by n desc

Recipe — upstream failure rate per integration (post-#992 attrs):

['executor-cloud']
| where _time > ago(1d) and name == "mcp.tool.dispatch"
| extend outcome = tostring(['attributes.custom']['executor.tool.outcome'])
| extend integration = tostring(['attributes.custom']['mcp.tool.integration'])
| where isnotnull(outcome)
| summarize calls = count(), fails = countif(outcome == "fail") by integration
| extend failRate = todouble(fails) / todouble(calls)
| sort by fails desc

Known signal caveats (audited 2026-06-12):

  • Pre-#992 spans: ToolResult.fail outcomes (upstream 4xx/5xx, auth rejections) are INVISIBLE — they rode the Effect success channel with no span marker. Don't conclude "no errors" from old data.
  • Many pre-#992 ERROR spans have an EMPTY status.message (tagged errors without a message field) — group those by events exception.type instead.
  • [object Object] status messages are the pre-#992 formatting bug.

Prod database (planetscale_mcp)

Read tool needs {organization: "answer-overflow", database: "executor", branch: "main"}. It returns ok: true even when the SQL failed — check the result text for Error:. Use for tenant/integration/connection facts that spans don't carry (row sizes, config shapes, counts).

Product analytics (posthog_api / mcp_posthog_com)

Browser-side events only (the ~60-event typed catalog, PR #987; server-side events not built). The org-key posthog_api connection covers the REST API; the OAuth MCP connection covers the higher-level tools.

Verifying a deploy's telemetry (Layer-0 canary)

After deploying telemetry changes: run a known-failing tool call against prod, then assert the expected attributes arrive in Axiom within ~1 min. Absence of data looks identical to health — query for the NEW attribute explicitly rather than eyeballing dashboards. The e2e equivalent runs on every suite: e2e/cloud/telemetry-contract.test.ts via the Telemetry service (motel /api/spans/search?attr.<key>=<value>).

指导构建自包含模态框,确保表单状态和异步工作(如OAuth、定时器)位于组件内部。关闭时通过卸载而非手动重置来清理状态,防止生命周期泄漏导致的卡顿Bug。
编写或审查模态框/对话框代码 处理包含异步工作的模态框(如OAuth弹窗、计时器、订阅) 排查模态框关闭后状态未清理的问题
.claude/skills/self-contained-modals/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill self-contained-modals -g -y
SKILL.md
Frontmatter
{
    "name": "self-contained-modals",
    "description": "Build modals\/dialogs self-contained: form and in-flight state lives inside, closing unmounts it. Use when writing or reviewing a modal\/dialog, especially one that owns async work (OAuth popups, timers, subscriptions, AbortControllers). Catches the stuck-on-Connecting class of lifecycle-leak bugs."
}

Self-contained modals

A modal's form state and in-flight work live INSIDE the modal, and closing the modal UNMOUNTS that state so it is destroyed, not hand-reset. Only the open/route intent belongs to the parent (deep links, programmatic open, reconnect handoffs need it).

Why

A hand-written reset() has to enumerate every field, and it silently drifts out of sync with state owned by child hooks the parent can't see. Unmounting resets everything for free, including child-hook cleanup effects (cancelling a dangling server session, clearing a timer, aborting a fetch).

Concrete bug this prevents (executor, add-account-modal): the modal was mounted unconditionally, so useOAuthPopupFlow's busy survived close. reset() zeroed its own booleans but never called oauthPopup.cancel(). Abandon the OAuth popup, close, reopen, and oauthBusy = false || busy(true) = true, so the footer is wedged on "Connecting…" with Close disabled. Unmounting would have cleared busy AND run the hook's cleanup that cancels the server OAuth session.

How to apply

Default to genuine conditional unmount, state inside. When closed the component returns null, so React destroys all of it and runs every child hook's cleanup. This is the cleanest fix and the one to reach for first:

function Parent() {
  const [open, setOpen] = useState(false);
  return open ? <Modal onClose={() => setOpen(false)} /> : null;
}

A key bump (<Body key={openCount} />) is still a manual reset, just spelled as a remount. Prefer real unmount; only reach for keyed remount in the one case below.

That case: Radix Dialog (this repo's components/dialog.tsx) Content/Overlay use data-[state=closed]:animate-out exit animations, so unmounting the whole Dialog drops the close animation. If you must keep that animation, keep the Dialog + DialogContent shell mounted and remount only the state-bearing body per open:

function Parent() {
  const [open, setOpen] = useState(false);
  const [openCount, setOpenCount] = useState(0);
  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogContent>{open ? <ModalBody key={openCount} /> : null}</DialogContent>
    </Dialog>
  );
}

This only works when DialogContent is independent of body state. If the shell depends on the body (e.g. a width className driven by the body's current sub-view), the body must own DialogContent, so there is no stable shell to keep, and genuine unmount (losing the exit animation) is the right call. That is exactly the executor add-account-modal: it genuinely unmounts and accepts the lost animation rather than plumbing body state up to a shell.

Reviewing: flag these smells

  1. A hand-written reset() exists. Its presence means state outlives the modal; ask why the modal isn't just unmounted.
  2. An always-mounted dialog (rendered unconditionally with an open prop) that owns async/in-flight state: popups, timers, subscriptions, AbortControllers, server sessions.
  3. A busy/loading flag composed from a child hook (e.g. ccBusy || someHook.busy) where reset() clears only part of it.
提供 Executor CLI 发布操作指南,涵盖发布范围界定、基于 Changesets 的版本管理与 PR 流程、变更日志生成规范及 Beta 版本管理策略。适用于执行 CLI 发版、撰写更新说明或配置版本变更。
执行 CLI 发布 准备发布说明 进入/退出 Beta 测试周期 为 CLI 编写 Changeset
.skills/cli-release/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill cli-release -g -y
SKILL.md
Frontmatter
{
    "name": "cli-release",
    "description": "Runbook for releasing the `executor` CLI package (stable and beta). Covers scope of what ships with the CLI, user-facing changelog conventions, Changesets + Version Packages PR flow, beta train entry\/exit, and owner preferences. Use when the user asks to cut a release, prepare release notes, enter\/exit a beta train, or write changesets for the CLI."
}

Executor CLI release runbook

Authoritative doc

RELEASING.md at repo root is the source of truth. This skill encodes the owner's preferences on top of it.

What the executor CLI actually ships

The CLI binary bundles:

  • apps/cli/** — CLI source + daemon
  • apps/local/** — the web UI (embedded as a virtual module via apps/cli/src/build.ts:178) + drizzle migrations (build.ts:205)
  • packages/**core, kernel, hosts/mcp, runtime-quickjs, and every plugin under packages/plugins/**

Does not ship in the CLI:

  • apps/cloud/** (Cloudflare Workers deployment)
  • apps/marketing/**, apps/desktop/**
  • examples/**, tests/**

Implication for changelogs: when asked "what changed since the last release", scope is git log v<last>..HEAD -- apps/cli apps/local packages, not just apps/cli. Skipping apps/local and packages misses the bulk of product changes (Connections UI, OAuth plugins, SDK scope, OTEL, etc.).

Versioning preferences

  • Prior convention in this repo uses patch bumps for feature-heavy releases (see .changeset/executor-1.4.6-beta.md for precedent). Don't push back on patch unless there are genuine SemVer-breaking API changes to a library consumer surface.
  • Breaking CLI UX changes (removed flags, changed argv shape) have historically still been patch bumps. Follow the owner's call — ask, don't assume minor.
  • Normal release/patch PRs must add a .changeset/*.md file with frontmatter like "executor": patch. Do not directly bump apps/cli/package.json or bun.lock in a feature/fix PR.
  • Only the Changesets-generated Version Packages PR should move apps/cli/package.json. If a normal PR directly changes that version, merging it to main can make .github/workflows/release.yml tag the commit and dispatch publish-executor-package.yml, causing an immediate CLI publish.
  • @executor-js/* library packages have their own publish path.

Release notes: standard Changesets flow — the changeset body IS the changelog

As of v1.5.0 this repo uses the canonical Changesets pipeline. The old apps/cli/release-notes/next.md rolling file is gone — do not recreate it.

How it's wired

  • Every user-visible PR adds a .changeset/*.md; its body is the user-facing changelog entry.
  • changeset version (run by changesets/action@v1 when building the Version Packages PR) compiles changeset bodies into each bumped package's CHANGELOG.md using @changesets/changelog-github (configured in .changeset/config.json), which prefixes each entry with the PR link and credits the author automatically.
  • apps/cli/src/release.ts (changelogSectionForVersion) extracts the released version's ## <version> section from apps/cli/CHANGELOG.md and uses it as the GitHub Release body. Missing section → falls back to --generate-notes.
  • Per-package CHANGELOG.md seed files are still required for every workspace package (bun run lint:changelog-stubs --fix creates them); changesets/action@v1 crashes with ENOENT on missing files.
  • @changesets/changelog-github needs GITHUB_TOKEN during changeset version. CI provides it; locally: GITHUB_TOKEN=$(gh auth token) bun run changeset:version.

Writing changeset bodies

  • Lead with user-visible behavior, not implementation. One sentence for a typical fix; a short paragraph for a feature.
  • Big releases: a changeset body can be a full markdown section — use bold sub-headings + bullets, never #/## headings (they end up nested inside a changelog list item).
  • Breaking changes: include the before/after surface in the body.
  • Don't duplicate content across changesets — every changeset in the release lands in the same version section.
  • Attribution is automatic via changelog-github; don't hand-write Thanks @... lines.

When drafting a release-spanning changeset from git log

  • Look at git diff v<last>..HEAD -- README.md first — best single view of user-facing changes.
  • Read commits in bulk (git log --oneline v<last>..HEAD -- apps/cli apps/local packages), bucket by theme, then write prose.
  • Merged PRs without changesets still ship in the release — their content ships regardless; only the changelog text is driven by changesets. If something important landed without a changeset, fold its story into a release-summary changeset.

Beta release flow

git checkout -b rs/beta-v<next>-start
bun run release:beta:start                 # creates .changeset/pre.json
# write .changeset/executor-<next>-beta.md (frontmatter + user-facing body)
git add ... && git commit                  # ONLY when owner says commit
git push -u origin rs/beta-v<next>-start
# Open PR -> merge -> release.yml opens "Version Packages (beta)" PR -> merge to publish
  • Published under npm dist-tag beta.
  • Users install: npm i -g executor@beta.
  • Exit the train with bun run release:beta:stop when going back to stable.

Stable release flow

Identical to beta except skip release:beta:start/stop. Changesets produce a normal Version Packages PR; merging publishes under latest.

Owner preferences (hard rules)

  • Never commit until the owner explicitly says so. Set everything up in the working tree, run git status, and stop.
  • No AI / Claude / Anthropic / Co-Authored-By trailers in commits, commit messages, PRs, or any generated file. This is in CLAUDE.md — do not violate.
  • Branch naming: rs/<short-topic> for Rhys's branches. Beta-start branch: rs/beta-v<version>-start.
  • Remote: origin = https://github.com/UsefulSoftwareCo/executor.git. If another remote appears (e.g. a fork remote), ask whether to remove it.
  • Dirty working tree: if there are uncommitted changes when starting a release, ask whether to include them, stash them, or commit separately first. Don't sweep them into the release commit silently.
  • Don't estimate time — code is cheap to write. Focus on what to do, not how long it takes.
  • Fact-check scope claims before publishing. If release notes say "does not affect X", verify by reading the diff.

Common commands

bun run changeset                          # interactive; or write .changeset/*.md directly
bun run lint:changelog-stubs --fix         # seed missing per-package CHANGELOG.md files
bun run release:beta:start                 # enter prerelease
bun run release:beta:stop                  # exit prerelease
bun run release:publish:dry-run            # build full CLI payload without publishing
bun run release:publish:packages:dry-run   # pack @executor-js/* without publishing
bun run release:check                      # invoked by publish workflow

What the workflow does after merge to main

  1. .github/workflows/release.yml opens/updates a Version Packages PR.
  2. Merging that PR:
    • Publishes every @executor-js/* library that's not yet on npm (via scripts/publish-packages.ts).
    • If apps/cli/package.json bumped, tags the commit and dispatches publish-executor-package.yml, which runs release:check, does a full dry-run build, publishes the CLI to npm, and creates/updates the GitHub Release with binary assets.

Fallback behavior

If something is unclear (bump level, whether to include in-flight work, whether to push), ask the owner. A release is a high-blast-radius action; one clarifying question is cheaper than a rogue publish.

指导在 effect-atom 中实现乐观 UI 更新。使用 Atom.optimistic 和 Atom.optimisticFn 包装查询与突变,避免自定义 pending 状态导致的竞态问题。适用于需即时反馈的列表增删改场景,无需等待服务器响应。
需要实现乐观 UI 更新 处理列表查询与突变的并发操作 避免手动管理 pending 状态
.skills/effect-atom-optimistic-updates/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill effect-atom-optimistic-updates -g -y
SKILL.md
Frontmatter
{
    "name": "effect-atom-optimistic-updates",
    "description": "Pattern for implementing optimistic UI updates with effect-atom in this codebase. Use when adding optimistic behavior to a query atom + its mutations (action toggles, list adds\/removes, inline edits). DO NOT roll your own pending-state with React state, Maps, or custom merge helpers — `Atom.optimistic` + `Atom.optimisticFn` already handle racing, refresh, and waiting correctly."
}

effect-atom Optimistic Updates

The @effect-atom/atom-react library ships first-class optimistic support via Atom.optimistic and Atom.optimisticFn. Use them directly. Do not write a custom "pending entries" layer (Maps, useState, useRef) on top of useAtomValue — it will not handle racing updates correctly, and the existing helpers in packages/react/src/api/optimistic.tsx are legacy patterns kept only for sources/connections.

When to use

  • A list query atom (Atom.optimistic wraps the query)
  • Plus one or more mutation atoms that change rows in that list (create / update / delete)
  • And you want the UI to reflect the change immediately on click, before the server roundtrip

If you only need the UI to be "eventually consistent" (i.e. you're fine waiting ~200ms for the server response and the existing reactivity refetch), skip optimistic — just use the mutation directly with reactivityKeys.

Why not roll your own

The naive approach — track pending: Array<{id, value}> in a separate atom and merge it with the server result — has a subtle race that bites on rapid edits:

  1. User clicks "set action = block" → mutation A fires, pending entry written
  2. User clicks "set action = approve" → mutation B fires, pending entry overwritten with B
  3. A's response returns first → finally-block clears the pending entry → UI flickers back to the server's "block" value
  4. B's response returns → UI shows "approve"

Step 3 is the bug. Fixing it correctly requires per-call entry ids and "last entry per row id wins" merging — at which point you've reimplemented a worse version of Atom.optimistic's transition tracking.

Atom.optimistic solves this because the runtime reads the current optimistic state (including any in-flight transitions) and passes it to your reducer as current, so B stacks on top of A correctly. When all transitions settle, it calls refresh(self) to pull the server's authoritative state.

Pattern

Define optimistic atoms next to the underlying query and mutations in the atoms.tsx (or equivalent) module. Each scope-keyed list gets a family that wraps the query with Atom.optimistic, and each mutation gets a family that pipes the optimistic atom through Atom.optimisticFn with a reducer.

import { Atom, Result } from "@effect-atom/atom-react";
import type { ScopeId, PolicyId, ToolPolicyAction } from "@executor/sdk";

import { ExecutorApiClient } from "./client";

// 1. The plain query atom — same as before.
export const policiesAtom = (scopeId: ScopeId) =>
  ExecutorApiClient.query("policies", "list", {
    path: { scopeId },
    timeToLive: "30 seconds",
    reactivityKeys: [ReactivityKey.policies],
  });

// 2. Plain mutations — same as before. These are the underlying `fn` for
//    the optimistic wrappers below.
export const createPolicy = ExecutorApiClient.mutation("policies", "create");
export const updatePolicy = ExecutorApiClient.mutation("policies", "update");
export const removePolicy = ExecutorApiClient.mutation("policies", "remove");

// 3. Optimistic read atom. `Atom.family` memoizes per-scope so every consumer
//    references the same optimistic atom instance and shares transition state.
export const policiesOptimisticAtom = Atom.family((scopeId: ScopeId) =>
  Atom.optimistic(policiesAtom(scopeId)),
);

// 4. Optimistic mutation. The reducer takes the same arg as the underlying
//    mutation and returns the next list state. `Result.map` keeps the
//    Result wrapper intact.
export const updatePolicyOptimistic = Atom.family((scopeId: ScopeId) =>
  policiesOptimisticAtom(scopeId).pipe(
    Atom.optimisticFn({
      reducer: (
        current,
        arg: {
          path: { scopeId: ScopeId; policyId: PolicyId };
          payload: { action?: ToolPolicyAction };
          reactivityKeys?: ReadonlyArray<unknown>;
        },
      ) =>
        Result.map(current, (rows) =>
          rows.map((r) =>
            r.id === arg.path.policyId && arg.payload.action !== undefined
              ? { ...r, action: arg.payload.action }
              : r,
          ),
        ),
      fn: updatePolicy,
    }),
  ),
);

Consuming in components

Read from the optimistic atom; write through the optimistic mutation. The existing reactivityKeys plumbing still applies — pass them in the call.

import { useAtomValue, useAtomSet } from "@effect-atom/atom-react";

import { policiesOptimisticAtom, updatePolicyOptimistic } from "../api/atoms";
import { policyWriteKeys } from "../api/reactivity-keys";

export function PoliciesPage() {
  const scopeId = useScope();

  // Read: this Result reflects in-flight optimistic state on top of server data.
  const policies = useAtomValue(policiesOptimisticAtom(scopeId));

  // Write: same call signature as the underlying mutation.
  const doUpdate = useAtomSet(updatePolicyOptimistic(scopeId), { mode: "promise" });

  const handleUpdate = async (id: string, action: ToolPolicyAction) => {
    await doUpdate({
      path: { scopeId, policyId: PolicyId.make(id) },
      payload: { action },
      reactivityKeys: policyWriteKeys,
    });
  };

  // ...
}

Reducer rules

  1. current is the FULL Result, not the unwrapped value. Use Result.map to update inside the success case — the wrapper preserves Initial/Failure states correctly.
  2. The reducer is called for every transition, including ones that stack on top of in-flight ones. Read current and produce next — don't track "the previous optimistic value" yourself.
  3. The reducer signature must match the mutation's arg shape. Effect-atom passes the raw mutation arg (e.g. { path, payload, reactivityKeys }) to both the reducer and the underlying fn. Don't try to build a "nicer" arg shape unless you also wrap the underlying mutation.
  4. Be pure. No side effects, no calls to Date.now() for stable values, no random ids unless you need a placeholder row id (see "Adds" below).

Adds (server mints the id)

For create flows the server assigns the canonical id. The reducer inserts a placeholder with a temp id — the post-commit refresh replaces it with the canonical row.

export const createPolicyOptimistic = Atom.family((scopeId: ScopeId) =>
  policiesOptimisticAtom(scopeId).pipe(
    Atom.optimisticFn({
      reducer: (
        current,
        arg: {
          path: { scopeId: ScopeId };
          payload: { pattern: string; action: ToolPolicyAction };
          reactivityKeys?: ReadonlyArray<unknown>;
        },
      ) =>
        Result.map(current, (rows) => [
          {
            id: PolicyId.make(`pending-${Math.random().toString(36).slice(2)}`),
            scopeId,
            pattern: arg.payload.pattern,
            action: arg.payload.action,
            position: -Number.MAX_SAFE_INTEGER, // sort to top
            createdAt: Date.now(),
            updatedAt: Date.now(),
          },
          ...rows,
        ]),
      fn: createPolicy,
    }),
  ),
);

The placeholder doesn't need to roundtrip through the id field unless your list rendering keys on it (it usually does — <Row key={p.id}>). A unique prefix like pending- is fine.

Removes

export const removePolicyOptimistic = Atom.family((scopeId: ScopeId) =>
  policiesOptimisticAtom(scopeId).pipe(
    Atom.optimisticFn({
      reducer: (
        current,
        arg: {
          path: { scopeId: ScopeId; policyId: PolicyId };
          reactivityKeys?: ReadonlyArray<unknown>;
        },
      ) => Result.map(current, (rows) => rows.filter((r) => r.id !== arg.path.policyId)),
      fn: removePolicy,
    }),
  ),
);

How racing is handled (mental model)

You don't have to think about this — it works — but understanding helps:

  • Atom.optimistic(self) wraps the underlying atom and tracks a transitions set
  • Each Atom.optimisticFn call creates one shared transition state per (scope, mutation)
  • A call: runtime reads the current optimistic state (including in-flight transitions), invokes reducer(current, arg) → value, sets transition to Success(value, waiting=true), calls the underlying mutation fn with arg
  • The next call to the same optimisticFn sees the prior call's optimistic value as current — so it stacks on top
  • When fn settles, both calls' subscribers fire, the transition flips to non-waiting, and refresh(self) pulls the server state
  • The server's authoritative response replaces the optimistic state via the underlying atom's normal subscribe path

Net result: rapid edits look smooth, the last edit wins both visually and on the server, no flickers, no manual cleanup.

Things to avoid

  • useState/useRef to hold pending values
  • Map / Set of in-flight ids
  • ❌ Custom mergePending helpers
  • try/finally blocks that "clear the placeholder" — optimistic clears for you
  • ❌ Reading policiesAtom directly in the component while writing through updatePolicyOptimistic — they have different transition state, you'll see jumps

Reference implementation

packages/react/src/api/atoms.tsx (search for policiesOptimisticAtom) and packages/react/src/pages/policies.tsx show the full pattern: optimistic read, optimistic create/update/remove, no custom state.

When you must NOT use this

  • The mutation has cross-cutting effects on data that isn't in the same list (e.g. a single mutation invalidates tools AND policies). Reactivity keys still handle that — optimistic only paints the list-local change.
  • You need to show transient UI state that isn't a row property (toasts, dirty indicators per field, pending counts). Those belong in component state, not in the atom layer.
提供基于 Effect 平台的 HTTP API 端到端测试指南。涵盖通过 HttpApiBuilder 定义 API、实现处理器,并利用 NodeHttpServer.layerTest 构建内存测试服务器与 HttpClient 注入的核心模式,确保层组合正确及路径参数处理无误。
编写涉及 @effect/platform 的 HTTP API 测试 配置 HttpApiBuilder 或 HttpClient 测试环境 解决 Effect HTTP 服务集成测试中的层依赖问题
.skills/effect-http-testing/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill effect-http-testing -g -y
SKILL.md
Frontmatter
{
    "name": "effect-http-testing",
    "description": "Testing Effect HttpApi services end-to-end. Use when writing tests that involve Effect's HttpApi, HttpApiBuilder, HttpClient, HttpServer, or when testing any HTTP service\/plugin built with @effect\/platform. Covers proper layer composition, test server setup, HttpClient injection, and common pitfalls."
}

Effect HTTP Testing

Core Pattern

Define an API with HttpApi, implement handlers with HttpApiBuilder.group, serve it with HttpApiBuilder.serve(), and use NodeHttpServer.layerTest to get an in-process test server + HttpClient pointed at it.

import { expect, layer } from "@effect/vitest";
import { Effect, Layer, Schema } from "effect";
import {
  HttpApi,
  HttpApiBuilder,
  HttpApiEndpoint,
  HttpApiGroup,
  HttpClient,
  OpenApi,
} from "@effect/platform";
import { NodeHttpServer } from "@effect/platform-node";

// 1. Define the API
class Item extends Schema.Class<Item>("Item")({
  id: Schema.Number,
  name: Schema.String,
}) {}

const ItemsGroup = HttpApiGroup.make("items")
  .add(HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)))
  .add(
    HttpApiEndpoint.get("getItem", "/items/:itemId")
      .setPath(Schema.Struct({ itemId: Schema.NumberFromString }))
      .addSuccess(Item),
  );

const MyApi = HttpApi.make("myApi").add(ItemsGroup);

// 2. Implement handlers
const ItemsLive = HttpApiBuilder.group(MyApi, "items", (handlers) =>
  handlers
    .handle("listItems", () => Effect.succeed([{ id: 1, name: "Widget" }]))
    .handle("getItem", (req) => Effect.succeed({ id: req.path.itemId, name: "Widget" })),
);

// 3. Build test layer
const ApiLive = HttpApiBuilder.api(MyApi).pipe(Layer.provide(ItemsLive));

const TestLayer = HttpApiBuilder.serve().pipe(
  Layer.provide(ApiLive),
  Layer.provideMerge(NodeHttpServer.layerTest),
);

// 4. Use layer() to share across tests
layer(TestLayer)("My API", (it) => {
  it.effect("works", () =>
    Effect.gen(function* () {
      const client = yield* HttpClient.HttpClient;
      // client is already pointed at the test server
      const response = yield* client.get("/items");
      // ...
    }),
  );
});

Critical Rules

Layer composition order matters

HttpApiBuilder.serve() consumes HttpApi.Api. The API layer must be provided to it, not the other way around:

// CORRECT
HttpApiBuilder.serve().pipe(Layer.provide(ApiLive), Layer.provideMerge(NodeHttpServer.layerTest));

// WRONG — "Service not found: HttpApi.Api"
ApiLive.pipe(Layer.provide(HttpApiBuilder.serve()), Layer.provideMerge(NodeHttpServer.layerTest));

layerTestClient prepends the server URL

NodeHttpServer.layerTest (and HttpServer.layerTestClient) produce an HttpClient that automatically prepends the test server's http://127.0.0.1:<port> to every request URL.

  • Use paths only (/items, /items/2) in requests — not full URLs
  • If your code builds full URLs (e.g. http://localhost/items), the client will produce http://127.0.0.1:PORThttp://localhost/items — an invalid URL
  • When injecting the test client into code that normally uses a baseUrl, pass baseUrl: "" or skip the base URL entirely

Path parameters need setPath()

Effect's HttpApiEndpoint with :param syntax does NOT automatically populate req.path. You must call .setPath() with a schema:

// WRONG — req.path is undefined
HttpApiEndpoint.get("getItem", "/items/:itemId").addSuccess(Item);

// CORRECT — req.path.itemId is typed and populated
HttpApiEndpoint.get("getItem", "/items/:itemId")
  .setPath(Schema.Struct({ itemId: Schema.NumberFromString }))
  .addSuccess(Item);

OpenApi.fromApi generates the spec

Use OpenApi.fromApi(api) to generate an OpenAPI spec from an HttpApi definition. The generated spec:

  • Uses "Api" as the default title (not the api id)
  • Converts :param to {param} in paths
  • Does NOT list path parameters in the parameters array — they're implicit in the path template
  • Uses group.endpoint format for operationIds (e.g. items.listItems)

Grab the test HttpClient from context

Inside layer() tests, the HttpClient is available in the Effect context:

layer(TestLayer)("tests", (it) => {
  it.effect("test", () =>
    Effect.gen(function* () {
      const httpClient = yield* HttpClient.HttpClient;
      // Use it directly or wrap in a Layer for injection
      const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
    }),
  );
});

Use HttpClient for HTTP calls, not fetch

Production code should use HttpClient from @effect/platform, not raw fetch:

import { HttpClient, HttpClientRequest } from "@effect/platform";

// Build request
let request = HttpClientRequest.get("/items");
request = HttpClientRequest.setHeader(request, "accept", "application/json");
request = HttpClientRequest.setUrlParam(request, "limit", "10");

// Execute — requires HttpClient in context
const response = yield * client.execute(request);

// Read body
const data = yield * response.json; // Effect<unknown>
const text = yield * response.text; // Effect<string>

This makes testing clean — swap in a test client layer, no monkey-patching needed.

Response headers are Record<string, string>

Effect's HttpClientResponse.headers is a plain Record<string, string>, not a Web Headers object. Don't call .forEach() or .get() on it:

// WRONG
response.headers.forEach((v, k) => ...)
response.headers.get("content-type")

// CORRECT
const ct = response.headers["content-type"]
const copy = { ...response.headers }

HttpClientRequest.make takes uppercase methods

// The method parameter to make() must be uppercase
HttpClientRequest.make("GET")("/items");

// Or use the convenience methods
HttpClientRequest.get("/items");
HttpClientRequest.post("/items");

Prepending base URLs to a client

Use HttpClient.mapRequest with HttpClientRequest.prependUrl:

const clientWithBase = Layer.effect(
  HttpClient.HttpClient,
  Effect.map(
    HttpClient.HttpClient,
    HttpClient.mapRequest(HttpClientRequest.prependUrl("https://api.example.com")),
  ),
).pipe(Layer.provide(baseClientLayer));

Error assertions

Use Effect.flip to turn errors into values for assertion:

const error = yield * Effect.flip(someFailingEffect);
expect(error._tag).toBe("MyError");

Dependencies

  • @effect/platform — HttpApi, HttpClient, HttpServer, OpenApi
  • @effect/platform-node — NodeHttpServer.layerTest (for Node/vitest)
  • @effect/vitestlayer(), it.effect(), expect
提供将第三方 SDK(如 Stripe、AWS)封装为 Effect 服务的模式。通过 'use' 方法实现类型安全的错误处理、自动链路追踪及依赖注入,支持集中化异常管理和配置密钥加载。
需要封装第三方 Promise API 到 Effect 希望统一外部服务调用的错误处理和追踪
.skills/effect-use-pattern/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill effect-client-wrapper -g -y
SKILL.md
Frontmatter
{
    "name": "effect-client-wrapper",
    "description": "Pattern for wrapping third-party SDK clients (Stripe, Resend, AWS, etc.) with Effect. Use when creating Effect services that wrap external libraries with Promise-based APIs. Provides type-safe error handling, automatic tracing, and clean dependency injection via the \"use\" pattern."
}

Effect Client Wrapper Pattern

Wrap third-party SDK clients with Effect using the "use" pattern for consistent error handling, tracing, and dependency injection.

Pattern Structure

import { Context, Data, Effect, Layer, Config, Redacted } from "effect";

// 1. Define tagged errors
export class MyClientError extends Data.TaggedError("MyClientError")<{
  cause: unknown;
}> {}

export class MyClientInstantiationError extends Data.TaggedError("MyClientInstantiationError")<{
  cause: unknown;
}> {}

// 2. Define service interface with `use` method
export type IMyClient = Readonly<{
  client: ThirdPartyClient;
  use: <A>(fn: (client: ThirdPartyClient) => Promise<A>) => Effect.Effect<A, MyClientError, never>;
}>;

// 3. Create the service implementation
const make = Effect.gen(function* () {
  const apiKey = yield* Config.redacted("MY_CLIENT_API_KEY");

  const client = yield* Effect.try({
    try: () => new ThirdPartyClient(Redacted.value(apiKey)),
    catch: (cause) => new MyClientInstantiationError({ cause }),
  });

  const use = <A>(fn: (client: ThirdPartyClient) => Promise<A>) =>
    Effect.tryPromise({
      try: () => fn(client),
      catch: (cause) => new MyClientError({ cause }),
    }).pipe(Effect.withSpan(`my_client.${fn.name ?? "use"}`));

  return { client, use };
});

// 4. Export as Context.Tag with Default layer
export class MyClient extends Context.Tag("MyClient")<MyClient, IMyClient>() {
  static Default = Layer.effect(this, make).pipe(Layer.annotateSpans({ module: "MyClient" }));
}

Usage

const program = Effect.gen(function* () {
  const myClient = yield* MyClient;

  const result = yield* myClient.use((client) => client.someMethod({ param: "value" }));

  return result;
});

// Run with layer
program.pipe(Effect.provide(MyClient.Default));

Key Benefits

  1. Centralized error handling - All client errors wrapped in typed MyClientError
  2. Automatic tracing - Every use call creates a span with function name
  3. Config-based secrets - API keys loaded via Config.redacted
  4. Clean DI - Consumers inject via yield* MyClient
  5. Encapsulation - Raw client hidden behind use interface

Variations

Multiple Error Types

export class MyClientNetworkError extends Data.TaggedError("MyClientNetworkError")<{
  cause: unknown;
}> {}

export class MyClientValidationError extends Data.TaggedError("MyClientValidationError")<{
  message: string;
}> {}

const use = <A>(fn: (client: ThirdPartyClient) => Promise<A>) =>
  Effect.tryPromise({
    try: () => fn(client),
    catch: (cause) => {
      if (cause instanceof NetworkError) {
        return new MyClientNetworkError({ cause });
      }
      return new MyClientError({ cause });
    },
  }).pipe(Effect.withSpan(`my_client.${fn.name ?? "use"}`));

Named Operations

Expose specific methods instead of generic use:

export type IEmailClient = Readonly<{
  sendEmail: (params: SendEmailParams) => Effect.Effect<EmailResult, EmailError>;
  getEmail: (id: string) => Effect.Effect<Email, EmailError>;
}>;

const make = Effect.gen(function* () {
  const resend = yield* ResendClient;

  return {
    sendEmail: (params) =>
      resend.use((client) => client.emails.send(params)).pipe(Effect.withSpan("email_client.send")),

    getEmail: (id) =>
      resend.use((client) => client.emails.get(id)).pipe(Effect.withSpan("email_client.get")),
  };
});

With Retry Policy

import { Schedule } from "effect";

const retryPolicy = Schedule.exponential(100).pipe(
  Schedule.intersect(Schedule.recurs(3)),
  Schedule.jittered,
);

const use = <A>(fn: (client: ThirdPartyClient) => Promise<A>) =>
  Effect.tryPromise({
    try: () => fn(client),
    catch: (cause) => new MyClientError({ cause }),
  }).pipe(Effect.retry(retryPolicy), Effect.withSpan(`my_client.${fn.name ?? "use"}`));

Real-World Example: Stripe

import Stripe from "stripe";
import { Context, Data, Effect, Layer, Config, Redacted } from "effect";

export class StripeError extends Data.TaggedError("StripeError")<{
  cause: unknown;
}> {}

export type IStripeClient = Readonly<{
  use: <A>(fn: (stripe: Stripe) => Promise<A>) => Effect.Effect<A, StripeError>;
}>;

const make = Effect.gen(function* () {
  const secretKey = yield* Config.redacted("STRIPE_SECRET_KEY");

  const client = new Stripe(Redacted.value(secretKey));

  const use = <A>(fn: (stripe: Stripe) => Promise<A>) =>
    Effect.tryPromise({
      try: () => fn(client),
      catch: (cause) => new StripeError({ cause }),
    }).pipe(Effect.withSpan(`stripe.${fn.name ?? "use"}`));

  return { use };
});

export class StripeClient extends Context.Tag("StripeClient")<StripeClient, IStripeClient>() {
  static Default = Layer.effect(this, make).pipe(Layer.annotateSpans({ module: "StripeClient" }));
}

// Usage
const createCustomer = Effect.gen(function* () {
  const stripe = yield* StripeClient;

  const customer = yield* stripe.use((client) =>
    client.customers.create({ email: "user@example.com" }),
  );

  return customer;
});
指导使用 Graphite CLI (gt) 管理 Git 分支和 PR,替代原生 git 命令以维持堆栈元数据同步。涵盖分支创建、提交修改、自动重堆栈及交互式标志处理,并提供解决 rebase 冲突的标准流程,确保版本控制工作流的一致性。
执行 git commit 或 push 创建或提交 Pull Requests 修改(amend)提交内容 管理堆叠分支(stacking branches)
.skills/graphite/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill graphite -g -y
SKILL.md
Frontmatter
{
    "name": "graphite",
    "description": "Manage Git branches and PRs using Graphite CLI (gt) instead of raw git commands. Use this skill when committing code changes, amending commits, creating branches, or submitting pull requests. Triggers on git commit, git push, creating PRs, amending changes, stacking branches, or any version control workflow."
}

Graphite CLI Command Reference

Prefer gt over git

Use gt commands instead of their git counterparts for commit and branch management. Raw git bypasses Graphite's stack metadata, leaving descendants un-restacked and the CLI's view of the stack out of sync.

Instead of Use
git checkout -b <name> gt create <name>
git commit -m "msg" gt modify -c -m "msg"
git commit --amend gt modify --amend
git commit -am "msg" gt modify -cam "msg"

Why: gt keeps stack metadata consistent and auto-restacks descendant branches when a parent changes.

Interactivity (for agents)

Several commands are interactive by default and will hang without a TTY: gt modify, gt move, gt split, gt restack, gt checkout (no arg), gt submit.

  • Always pass explicit args/flags: gt move --onto <branch>, gt modify -m "msg", gt checkout <branch>, gt submit --no-interactive.
  • Add --no-interactive (or --quiet) to skip prompts.
  • If a command genuinely needs input that can't be supplied via flags (e.g. complex rebase conflict resolution), stop and notify the user rather than guessing.

Rebase Conflicts

When gt restack, gt move, gt sync, etc. pause on a conflict:

  1. git status to find conflicted files.
  2. Resolve the <<<<<<< / ======= / >>>>>>> markers.
  3. Stage with gt add -A (or git add <file>).
  4. Run gt continuenever git rebase --continue. Graphite tracks its own rebase state; using raw git desyncs it.
  5. Verify the stack once continue completes (gt log, run tests).

To bail out: gt abort.

Global Flags

  • --help - Show help for a command
  • --cwd - Working directory for operations
  • --debug - Write debug output
  • --interactive / --no-interactive - Enable/disable prompts (default: enabled)
  • --verify / --no-verify - Enable/disable git hooks (default: enabled)
  • --quiet - Minimize output, implies --no-interactive

Branch Creation & Modification

gt create [name]

Create a new branch stacked on current branch with staged changes.

Flags:

  • -a, --all - Stage all unstaged changes including untracked files
  • -m, --message - Commit message
  • -i, --insert - Insert between current branch and its child
  • -p, --patch - Pick hunks to stage
  • -u, --update - Stage updates to tracked files only
  • -v, --verbose - Show diff in commit template
  • --ai / --no-ai - AI-generate branch name and message

gt modify

Amend current branch's commit or create new commit. Auto-restacks descendants.

Flags:

  • -a, --all - Stage all changes
  • -c, --commit - Create new commit instead of amending
  • -e, --edit - Open editor for commit message
  • -m, --message - New commit message
  • -p, --patch - Pick hunks to stage
  • -u, --update - Stage tracked file updates
  • --into - Amend into specified downstack branch
  • --interactive-rebase - Start git interactive rebase
  • --reset-author - Set commit author to current user

gt absorb

Amend staged changes to relevant commits in current stack.

Flags:

  • -a, --all - Stage all unstaged changes (not untracked)
  • -d, --dry-run - Print what would happen
  • -f, --force - Skip confirmation
  • -p, --patch - Pick hunks to stage

gt squash

Squash all commits in current branch into single commit.

Flags:

  • -m, --message - Commit message
  • --edit - Modify existing message
  • -n, --no-edit - Keep existing message

gt fold

Fold branch's changes into parent, restack descendants.

Flags:

  • -k, --keep - Keep current branch name instead of parent's

gt split

Split current branch into multiple branches.

Flags:

  • -c, --commit, --by-commit - Split by commit history
  • -h, --hunk, --by-hunk - Split by hunk interactively
  • -f, --file, --by-file - Split files matching pathspecs

Submitting PRs

gt submit

Push branches and create/update PRs on GitHub.

Flags:

  • -s, --stack - Include descendant branches
  • -d, --draft - Create PRs as drafts
  • -p, --publish - Publish draft PRs
  • -e, --edit / -n, --no-edit - Edit/skip PR metadata
  • --edit-title / --no-edit-title - Edit/skip title
  • --edit-description / --no-edit-description - Edit/skip description
  • -r, --reviewers - Set reviewers (comma-separated or prompt)
  • -t, --team-reviewers - Team slugs for review
  • -m, --merge-when-ready - Auto-merge when requirements met
  • -c, --confirm - Confirm before submitting
  • --dry-run - Report what would happen
  • -f, --force - Force push (vs --force-with-lease)
  • -u, --update-only - Only update existing PRs
  • -v, --view - Open PR in browser after
  • -w, --web - Edit metadata in browser
  • --cli - Edit metadata via CLI
  • --ai / --no-ai - AI-generate title/description
  • --comment - Add comment to PR
  • --restack - Restack before submitting
  • --rerequest-review - Rerequest from current reviewers
  • --always - Push even if unchanged
  • --branch - Run from specified branch
  • --target-trunk - PR target trunk

gt merge

Merge PRs from trunk to current branch via Graphite.

Flags:

  • -c, --confirm - Confirm before merging
  • --dry-run - Report what would merge

Navigation

gt checkout [branch]

Switch to branch. Interactive selector if no branch provided.

Flags:

  • -a, --all - Show all trunks in selection
  • -u, --show-untracked - Include untracked branches
  • -s, --stack - Only show current stack
  • -t, --trunk - Checkout trunk

gt up [steps]

Switch to child of current branch.

Flags:

  • -n, --steps - Levels to traverse
  • --to - Target branch to navigate toward

gt down [steps]

Switch to parent of current branch.

Flags:

  • -n, --steps - Levels to traverse

gt top

Switch to tip of current stack. Prompts if ambiguous.

gt bottom

Switch to branch closest to trunk in current stack.

Stack Management

gt restack

Ensure each branch has parent in Git history, rebasing if needed.

Flags:

  • --branch - Run from specified branch
  • --downstack - Only restack branch and ancestors
  • --upstack - Only restack branch and descendants
  • --only - Only restack this branch

gt move

Rebase current branch onto target, restack descendants.

Flags:

  • -o, --onto - Target branch
  • --source - Branch to move (default: current)
  • -a, --all - Show all trunks in selection

gt reorder

Reorder branches between trunk and current via editor.

Syncing

gt sync

Sync all branches with remote, clean merged/closed PRs, restack.

Flags:

  • -a, --all - Sync across all trunks
  • -f, --force - Skip confirmations
  • --restack / --no-restack - Restack after sync (default: true)

gt get [branch]

Sync branches from trunk to specified branch/PR from remote.

Flags:

  • -d, --downstack - Don't sync upstack branches
  • -f, --force - Overwrite with remote
  • --restack / --no-restack - Restack after (default: true)
  • -U, --unfrozen - Checkout as unfrozen

Branch Operations

gt delete [name]

Delete branch and Graphite metadata. Children restack to parent.

Flags:

  • -f, --force - Delete even if not merged/closed
  • -c, --close - Close associated PR on GitHub
  • --downstack - Also delete ancestors
  • --upstack - Also delete children

gt rename [name]

Rename branch. Removes PR association.

Flags:

  • -f, --force - Allow rename with open PR

gt track [branch]

Start tracking branch with Graphite by selecting parent.

Flags:

  • -f, --force - Use most recent tracked ancestor as parent
  • -p, --parent - Specify parent branch

gt untrack [branch]

Stop tracking branch. Children also untracked.

Flags:

  • -f, --force - Skip confirmation

gt pop

Delete current branch but keep working tree state.

gt freeze [branch]

Freeze branch and downstack - prevents local modifications.

gt unfreeze [branch]

Unfreeze branch and upstack.

Information

gt log [command]

Show stacks. Forms: gt log, gt log short, gt log long.

Flags:

  • -a, --all - Show all trunks
  • -r, --reverse - Print upside down
  • -u, --show-untracked - Include untracked
  • -s, --stack - Only current stack
  • -n, --steps - Levels to show (implies --stack)
  • --classic - Old style

gt info [branch]

Display branch information.

Flags:

  • -b, --body - Show PR body
  • -d, --diff - Show diff vs parent
  • -p, --patch - Show commit changes
  • -s, --stat - Show diffstat

gt parent / gt children / gt trunk

Show parent, children, or trunk of current branch.

gt trunk flags:

  • --add - Add trunk
  • -a, --all - Show all trunks

gt pr [branch]

Open PR page for branch or PR number.

Flags:

  • --stack - Open stack page

Conflict Resolution

gt continue

Continue command halted by rebase conflict.

Flags:

  • -a, --all - Stage all changes first

gt abort

Abort current command halted by conflict.

Flags:

  • -f, --force - Skip confirmation

gt undo

Undo most recent Graphite mutations.

Flags:

  • -f, --force - Skip confirmation

Setup

gt init

Initialize Graphite by selecting trunk branch.

Flags:

  • --trunk - Trunk branch name
  • --reset - Untrack all branches

gt auth

Add auth token for GitHub.

Flags:

gt config

Configure Graphite CLI (interactive).

gt upgrade

Update CLI to latest stable version.

Git Passthroughs

These pass arguments directly to git:

  • gt add [args..]
  • gt cherry-pick [args..]
  • gt rebase [args..]
  • gt reset [args..]
  • gt restore [args..]

gt revert [sha]

Create branch reverting a trunk commit.

Flags:

  • -e, --edit - Edit commit message
用于执行Warden安全扫描的指南。涵盖本地配置、JSONL输出设置,以及针对授权、代码执行和数据泄露等风险的推荐扫描命令与目标路径。
审计仓库安全性 使用Warden进行扫描 调查认证/数据外泄/代码执行风险 处理Warden扫描结果
.skills/warden-security-review/SKILL.md
npx skills add UsefulSoftwareCo/executor --skill warden-security-review -g -y
SKILL.md
Frontmatter
{
    "name": "warden-security-review",
    "description": "Run Warden security scans in this repo using Sentry's warden-skills. Use when asked to audit security, scan with Warden, investigate authz\/data-exfil\/code-execution\/GitHub Actions risks, or triage Warden findings."
}

Warden security review runbook

Use Warden as a first-pass scanner, then manually verify every finding against the code. A clean Warden run means "no findings from that skill/pass", not "the codebase is secure."

Setup

Warden uses Claude Code auth locally. For Claude Max usage:

claude login

Run Warden through npm so the package version does not need to be committed:

npm exec --yes --package=@sentry/warden -- warden --help

The repo has a warden.toml that uses remote skills from getsentry/warden-skills.

Reference skills are mirrored under .reference/warden-skills when needed. .reference/ is gitignored.

Local Outputs

Write run artifacts under .warden-runs/. Do not commit .warden/ or .warden-runs/.

Use JSONL output for later triage:

mkdir -p .warden-runs
npm exec --yes --package=@sentry/warden -- \
  warden <targets...> --skill <skill> --fail-on off --report-on low --min-confidence low \
  --parallel 2 --log -o .warden-runs/<name>.jsonl

Warden may not treat bare directories as recursive targets. Prefer explicit quoted globs or a target file list.

Recommended Scans

Authz on cloud/API surfaces:

npm exec --yes --package=@sentry/warden -- \
  warden "apps/cloud/src/auth/**/*.ts" "apps/cloud/src/api/**/*.ts" \
  "apps/cloud/src/routes/**/*.tsx" "packages/core/api/src/**/*.ts" \
  --skill wrdn-authz --fail-on off --report-on low --min-confidence low \
  --parallel 2 --log -o .warden-runs/authz.jsonl

Code execution on sink-bearing runtime/plugin files:

rg -l "\b(exec|spawn|execFile|fork|subprocess|Deno\.Command|new Function|eval\(|vm\.|QuickJS|quickjs|Worker\(|import\(|compile|instantiate|runIn|shell|command|child_process)\b" \
  apps/local/src/server apps/cli/src packages/core/execution/src packages/core/sdk/src packages/kernel packages/plugins \
  -g "*.ts" -g "*.tsx" -g "!*.test.ts" -g "!*.spec.ts" -g "!*.e2e.ts" -g "!**/dist/**" -g "!**/node_modules/**" \
  > .warden-runs/code-execution-targets.txt

npm exec --yes --package=@sentry/warden -- \
  warden $(tr '\n' ' ' < .warden-runs/code-execution-targets.txt) \
  --skill wrdn-code-execution --fail-on off --report-on low --min-confidence low \
  --parallel 2 --log -o .warden-runs/code-execution.jsonl

Data exfiltration on backend/API/storage/plugin SDK surfaces:

find apps/cloud/src/api apps/cloud/src/auth apps/local/src/server \
  packages/core/api/src packages/core/storage-core/src packages/core/storage-file/src \
  packages/core/storage-postgres/src packages/core/storage-drizzle/src \
  packages/plugins/mcp/src packages/plugins/openapi/src packages/plugins/graphql/src \
  packages/plugins/google-discovery/src packages/plugins/oauth2/src \
  packages/plugins/onepassword/src packages/plugins/workos-vault/src \
  packages/plugins/file-secrets/src packages/plugins/keychain/src \
  -type f \( -name "*.ts" -o -name "*.tsx" \) |
  rg -v '(\.test\.|\.spec\.|\.e2e\.|dist/|node_modules/|embedded-migrations\.gen\.ts|/react/)' \
  > .warden-runs/exfil-targets-focused.txt

npm exec --yes --package=@sentry/warden -- \
  warden $(tr '\n' ' ' < .warden-runs/exfil-targets-focused.txt) \
  --skill wrdn-data-exfil --fail-on off --report-on low --min-confidence low \
  --parallel 2 --log -o .warden-runs/data-exfil.jsonl

GitHub Actions workflow risks:

find .github -type f \( -name "*.yml" -o -name "*.yaml" \) > .warden-runs/gha-targets.txt

npm exec --yes --package=@sentry/warden -- \
  warden $(tr '\n' ' ' < .warden-runs/gha-targets.txt) \
  --skill wrdn-gha-workflows --fail-on off --report-on low --min-confidence low \
  --parallel 2 --log -o .warden-runs/gha-workflows.jsonl

How to Triage

Deduplicate findings by root cause. Warden often reports the same bug at the low-level sink, wrapper, API handler, and plugin-tool entrypoint.

For each candidate:

  • Trace whether input is user-controlled.
  • Identify the exact sink.
  • Check whether auth, scope, host allowlists, private-IP blocks, redirects, and DNS rebinding defenses exist.
  • Determine what data returns to the caller: raw body, parsed fields, typed error message, timing/status oracle, or no observable data.
  • State confidence and deployment caveats.

Current Known Findings

As of the Warden pass on 2026-04-29:

  • Real: authenticated SSRF in plugin/source setup URL fetching for OpenAPI, Google Discovery, GraphQL, and MCP remote endpoints.
  • Real: mutable third-party GitHub Actions refs in publish/release workflows, especially oven-sh/setup-bun@v2 and changesets/action@v1.
  • Clean in that pass: authz scan on cloud auth/API/core API surfaces; code-execution scan on narrowed CLI/runtime/kernel/plugin sink files.

Do not claim the whole codebase is secure from those clean runs. They are scoped scanner results.

Accueil - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-07-17 15:38
浙ICP备14020137号-1 $Carte des visiteurs$