@a2f0/agent-tool 0.1.52 → 0.1.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/explicit-state-machines/SKILL.md +15 -1
- package/skills/explicit-state-machines/references/lessons.md +14 -0
- package/src/bun/execute.ts +3 -3
- package/src/permissions/catalog.ts +10 -12
- package/src/skills/bundled.ts +2 -2
- package/src/stateMachines/scan.ts +183 -9
package/package.json
CHANGED
|
@@ -24,7 +24,9 @@ impossible states or scattered transitions, and proves behavior did not change.
|
|
|
24
24
|
lifecycle booleans toggled together (`isLoading`, `hasError`,
|
|
25
25
|
`_running`, `busy.current`, `this.#rendered`), and status-like fields assigned
|
|
26
26
|
three or more string values from three or more places (`step.status = ...`).
|
|
27
|
-
It reads extensionless scripts by their shebang and is read-only.
|
|
27
|
+
It reads extensionless scripts by their shebang and is read-only. It skips
|
|
28
|
+
SQL comparisons, status fields whose every write is an `UPDATE` naming its
|
|
29
|
+
from-state, and enum case payload labels (`.ended(shouldResume: true)`).
|
|
28
30
|
|
|
29
31
|
- `agent-tool state-machines [path...] [--limit N] [--min-score N] [--json]`
|
|
30
32
|
scans the current repository; each path names the repository holding it.
|
|
@@ -53,6 +55,10 @@ wizards, CLI subcommands that resume):
|
|
|
53
55
|
may set any value), in the database or in API handlers.
|
|
54
56
|
- Shell, Python, or Expect scripts that track progress with several
|
|
55
57
|
variables.
|
|
58
|
+
- One nullable `error` shared by a load phase and user actions: starting an
|
|
59
|
+
action clears the load's failure, so the view falls back to "Loading..."
|
|
60
|
+
after a failed load. Split a `loading | ready | failed` load machine from
|
|
61
|
+
the actions' own errors.
|
|
56
62
|
|
|
57
63
|
## Decide what to convert
|
|
58
64
|
|
|
@@ -92,6 +98,14 @@ module is about to be deleted. Also leave alone:
|
|
|
92
98
|
two clicks start the work twice.
|
|
93
99
|
- Flags in different components (a parent's `editing`, and `busy` in a child
|
|
94
100
|
that only exists while editing): the mount already excludes the bad pair.
|
|
101
|
+
- Latest-wins request counters with one writer (`++request` paired with
|
|
102
|
+
`request === current`): already the recommended guard, not a machine.
|
|
103
|
+
- A one-time focus-return ref written together with the close event.
|
|
104
|
+
- A single set-once setup flag (`isConfigured`).
|
|
105
|
+
- A memoized single teardown promise (`closing ??= teardown()`) with a
|
|
106
|
+
one-way `closed` flag only that teardown sets.
|
|
107
|
+
- A boolean that is only an event's payload field
|
|
108
|
+
(`interruptionEnded(shouldResume:)`): it is part of an event, not state.
|
|
95
109
|
|
|
96
110
|
Record each one you leave and why, so the next pass does not reread it.
|
|
97
111
|
|
|
@@ -34,3 +34,17 @@ findings in that repository's guidance.
|
|
|
34
34
|
`fix/` PRs: a swallowed settings-write error, a partial download cached
|
|
35
35
|
forever, an audio session never released, a verify step failing during
|
|
36
36
|
registry propagation, and a CI runner image that broke browser tests.
|
|
37
|
+
- A nullable `error` shared by a load and user actions hid a real bug in
|
|
38
|
+
devopsrockstars' Stripe payment step: after a failed load, Cancel cleared
|
|
39
|
+
the error and the step showed "Loading..." again. A `loading | ready |
|
|
40
|
+
failed` load machine with separate action errors fixed it
|
|
41
|
+
(devopsrockstars#898). The scanner sees booleans only; read for it by hand.
|
|
42
|
+
- The second sweep's perennial false positives are now named: guarded SQL
|
|
43
|
+
status updates (devopsrockstars checkout and webhooks, balebase contracts
|
|
44
|
+
and jobs) and enum payload labels are skipped by the scanner, along with
|
|
45
|
+
`*.testUtils.*` files; latest-wins request counters, set-once setup flags,
|
|
46
|
+
one-time focus-return refs, and memoized teardown promises are in the
|
|
47
|
+
skill's leave-alone list.
|
|
48
|
+
An update whose `WHERE` has an `OR`, a `NOT` or a subquery still counts,
|
|
49
|
+
even when every branch names the from-state (balebase finance): read it by
|
|
50
|
+
hand.
|
package/src/bun/execute.ts
CHANGED
|
@@ -16,9 +16,9 @@ const USAGE = `Usage:
|
|
|
16
16
|
const EXECUTABLE = /^[\w.+-][\w.+-]*$/;
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* An executable whose class asks
|
|
20
|
-
* its classification, an older allowance could
|
|
21
|
-
* are regenerated.
|
|
19
|
+
* An executable whose class asks must have no generated allow rule. If the recorded rules
|
|
20
|
+
* predate its classification, an older allowance could preauthorize it, so refuse until
|
|
21
|
+
* they are regenerated.
|
|
22
22
|
*/
|
|
23
23
|
function assertCurrentRules(root: string, command: string): void {
|
|
24
24
|
if (!existsSync(path.join(root, ".agent-tool-permissions.json"))) return;
|
|
@@ -42,12 +42,6 @@ export const CATALOG: CatalogEntry[] = [
|
|
|
42
42
|
// `bun x` runs only executables that `permissions.executables` classifies; project.ts adds those.
|
|
43
43
|
// Package scripts are not classified, so `bun run` keeps the harness default.
|
|
44
44
|
|
|
45
|
-
/** Direct spellings of classified actions. Codex prompts for them, so a raw command cannot bypass its agent-tool form. */
|
|
46
|
-
export const GUARDS: { command: string; class: PermissionClass }[] = [
|
|
47
|
-
...["bun add", "bun a", "npm install", "npm i", "npm add", "pnpm add", "yarn add", "bunx", "bun x", "npx"].map(command => ({ command, class: "intake" as const })),
|
|
48
|
-
...["gh pr merge", "npm publish", "bun publish"].map(command => ({ command, class: "release" as const })),
|
|
49
|
-
];
|
|
50
|
-
|
|
51
45
|
/** How agents invoke an installed agent-tool: the package script, the bin link, or PATH. */
|
|
52
46
|
export const INVOCATIONS = ["bun run agent-tool", "node_modules/.bin/agent-tool", "agent-tool"];
|
|
53
47
|
|
|
@@ -83,30 +77,34 @@ export function claudePermissions(project: CatalogEntry[] = []): ClaudePermissio
|
|
|
83
77
|
const quote = (value: string) => JSON.stringify(value);
|
|
84
78
|
const element = (values: string[]) => values.length === 1 ? quote(values[0]!) : `[${values.map(quote).join(", ")}]`;
|
|
85
79
|
|
|
86
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Only allow rules are generated, as for Claude. Codex's explicit prompt rules
|
|
82
|
+
* reject commands when approval_policy is never, even with danger-full-access.
|
|
83
|
+
* Asking classes stay unlisted so the user's approval and sandbox policies apply.
|
|
84
|
+
*/
|
|
87
85
|
export function codexRules(project: CatalogEntry[] = []): string {
|
|
88
86
|
const lines = [
|
|
89
87
|
"# Managed by agent-tool; regenerate with `agent-tool permissions install --apply`.",
|
|
90
|
-
"#
|
|
91
|
-
"#
|
|
88
|
+
"# Only routine commands receive allow rules. Other commands keep the user's",
|
|
89
|
+
"# approval and sandbox policies; explicit prompts would reject them in never mode.",
|
|
90
|
+
"# A command whose --apply form asks gets no rule, since prefixes also match --apply.",
|
|
92
91
|
"# Equal decisions group under the applied effect, not the read-only preview.",
|
|
93
92
|
];
|
|
94
93
|
const groups = new Map<string, { prefix: string[]; last: string[]; class: PermissionClass }>();
|
|
95
94
|
const add = (tokens: string[], value: PermissionClass) => {
|
|
95
|
+
if (CLASSES[value].decision !== "allow") return;
|
|
96
96
|
const prefix = tokens.slice(0, -1), key = `${value}\0${prefix.join("\0")}`;
|
|
97
97
|
const group = groups.get(key) ?? { prefix, last: [], class: value };
|
|
98
98
|
group.last.push(tokens.at(-1)!); groups.set(key, group);
|
|
99
99
|
};
|
|
100
100
|
for (const invocation of INVOCATIONS) for (const entry of [...CATALOG, ...project]) add([...invocation.split(" "), ...entry.command.split(" ")], strictest(entry));
|
|
101
|
-
for (const guard of GUARDS) add(guard.command.split(" "), guard.class);
|
|
102
101
|
for (const value of Object.keys(CLASSES) as PermissionClass[]) {
|
|
103
102
|
const rules = [...groups.values()].filter(group => group.class === value);
|
|
104
103
|
if (!rules.length) continue;
|
|
105
104
|
lines.push("", `# ${value}: ${CLASSES[value].description}`);
|
|
106
105
|
for (const rule of rules) {
|
|
107
106
|
const pattern = [...rule.prefix.map(quote), element(rule.last)].join(", ");
|
|
108
|
-
|
|
109
|
-
lines.push(`prefix_rule(pattern=[${pattern}], ${reason}, match=[${quote([...rule.prefix, rule.last[0]!].join(" "))}])`);
|
|
107
|
+
lines.push(`prefix_rule(pattern=[${pattern}], decision="allow", match=[${quote([...rule.prefix, rule.last[0]!].join(" "))}])`);
|
|
110
108
|
}
|
|
111
109
|
}
|
|
112
110
|
return `${lines.join("\n")}\n`;
|
package/src/skills/bundled.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated by scripts/build/bundle-skills.ts; edit skills/*/SKILL.md and skills/*/references/*.md.
|
|
2
2
|
export const BUNDLED_SKILLS: Readonly<Record<string, string>> = {
|
|
3
3
|
"cross-agent-review": "---\nname: cross-agent-review\ndescription: Review committed branch changes with an independent Claude Code, Codex, or OpenCode reviewer, and repair actionable findings when requested.\n---\n\n# Cross-Agent Review\n\nUse the installed `agent-tool` executable. Read the repository's development and\nreview policy and `agent-tool.json` when present. Keep project validation commands\nand release rules in the repository; do not assume Bun, a workspace layout, or\nparticular CI job names.\n\nSelect an independent reviewer using a different model/provider from the author\nwhen possible, not merely a different CLI: Hermes and OpenCode can use the same\nmodel as another harness. Hermes can coordinate this skill but is not a `review`\nadapter. Honor a requested reviewer or effort; otherwise use an available\nindependent reviewer.\nRun `agent-tool doctor` if CLI compatibility is uncertain.\n\nRun noisy validation or repair checks through\n`agent-tool run --label <check> -- <command> [args...]`. Read its bounded failure\ntail and use `agent-tool logs <log>` or a targeted search for more evidence.\nKeep `agent-tool review` direct: it retains reviewer stderr in a private local\nlog and displays the complete final review. Never hide, truncate, or substitute\na command success receipt for findings and the final verdict.\n\nReview committed changes with `agent-tool review claude`, `agent-tool review codex`,\nor `agent-tool review opencode`. Optional effort is `low`, `medium`, `high`,\n`xhigh`, or `max`. For a local or non-GitHub repository, add `--base <commit-or-ref>`.\nFor a coordinated GitHub review, pin the fetched base using\n`AGENT_TOOL_REVIEW_BASE_REF`, the base branch name such as `main` (not a\nremote-tracking ref like `origin/main`), and `AGENT_TOOL_REVIEW_BASE_OID`, its\nfetched commit. Fetch from the repository that owns the PR, which can differ\nfrom the checkout's origin.\n\nUse `agent-tool repo context --fetch` to resolve and fetch the PR's own base,\nthen record the exact base and HEAD before reviewing. The tool reviews raw committed\nfiles and excludes worktree edits. A zero exit means a complete review was\nreturned; it does not mean the findings are non-blocking. Read the final\n`VERDICT: BLOCKER|MAJOR|MINOR|SUGGESTION|CLEAN` and the findings. A reviewer\nthat labels findings `[P0]` through `[P3]` uses the same scale: `[P0]` and\n`[P1]` are BLOCKER and MAJOR, `[P2]` and `[P3]` are MINOR and SUGGESTION.\n\nIf a CLI is unavailable, out of credits, or returns an unusable review, try another\navailable reviewer and disclose the fallback. Do not silently convert a failed\nreview into CLEAN or call an in-session review independent.\n\nFor report-only requests, report findings without changing files or history.\nWhen repairs are authorized, address actionable BLOCKER and MAJOR findings,\nvalidate with the repository's relevant checks, commit the repairs, and review\nthe new HEAD. Continue while meaningful progress is possible; report an impasse\nwhen progress requires a material user decision. A repaired commit is unreviewed\nuntil the next review completes. Preserve unrelated work.\n\nBefore reporting success, confirm HEAD still matches the reviewed commit and\nthat an existing PR points to that same commit. If the base moved, refresh it and\nreview again before shipping. Report reviewer, exact base and head IDs, verdict,\nremaining findings, validation, and any repairs performed. This skill does not\nauthorize pushing, opening a PR, or merging unless the user requested that work.\n",
|
|
4
|
-
"explicit-state-machines": "---\nname: explicit-state-machines\ndescription: Find implicit state machines in a repository, or across many repositories, and refactor them into explicit ones (a closed set of states, typed events, one pure transition function) without changing behavior. Use when the user asks to \"find all of the implicit state machines\", to replace tangled boolean flags or scattered status writes, or to dogfood this pass across repositories; not for adding a state-machine library or redesigning features.\n---\n\n# Explicit State Machines\n\nAn implicit state machine is code that moves through modes (idle, loading,\nfailed, done) without naming them: several booleans that are only valid in\nsome combinations, a status string written from many places, nullable fields\nwhose presence means \"we are in phase X\", or effects that chain off each other\nto advance a process. Bugs live in the combinations nobody named:\n`isLoading && error`, a retry that fires after `done`, a dialog both open and\nsubmitting twice.\n\nAn explicit machine names every state once, carries each state's data only in\nthat state, and changes state only through one function of `(state, event)`.\nThe pass finds the implicit ones, converts those where the conversion removes\nimpossible states or scattered transitions, and proves behavior did not change.\n\n## Find candidates\n\n`agent-tool state-machines` ranks tracked source files by two signals:\nlifecycle booleans toggled together (`isLoading`, `hasError`,\n`_running`, `busy.current`, `this.#rendered`), and status-like fields assigned\nthree or more string values from three or more places (`step.status = ...`).\nIt reads extensionless scripts by their shebang and is read-only.\n\n- `agent-tool state-machines [path...] [--limit N] [--min-score N] [--json]`\n scans the current repository; each path names the repository holding it.\n If the repository's pinned agent-tool lacks the command, run\n `bunx --bun @a2f0/agent-tool@latest state-machines`.\n- `--limit N` (default 25 per repository, 0 for all), `--min-score N`\n (default 3), `--json` for agents.\n\nThe script finds leads, not verdicts, and it misses whole families. Also read\nfor these by hand, starting from the places state lives (stores, providers,\nhooks, workers, sync loops, connection handlers, upload and checkout flows,\nwizards, CLI subcommands that resume):\n\n- `loading`/`error`/`data` held as separate fields, or a nullable `error`\n beside a non-null `data`; any nullable value paired with a boolean or a\n second nullable (`nowPlaying` with `interruptedNoise`, `reservation` with\n `editing`). The scanner sees booleans only.\n- State split between a context and per-component state that an effect\n re-syncs, and \"already handled\" dedupe fields that quietly mean \"confirmed\n started\".\n- Fields that are set together and cleared together (`startedAt`, `jobId`,\n `abortController`) and so encode \"running\".\n- `useEffect` chains where one effect sets state that triggers the next.\n- Retry, polling, and reconnect loops guarded by counters and flags.\n- Status columns or enums whose transitions are enforced nowhere (any writer\n may set any value), in the database or in API handlers.\n- Shell, Python, or Expect scripts that track progress with several\n variables.\n\n## Decide what to convert\n\nConvert a candidate only when at least one holds:\n\n1. Two or more of its variables can hold a combination that is meaningless\n (`isLoading && isError`, `data` set while `status === \"failed\"`).\n2. Its transitions are written in several places, so no single place says\n which moves are legal.\n3. A bug or missing guard follows from either (an event handled in a state\n where it must be ignored). Fix the bug in the same change, with a test that\n fails before it, and say so in the PR.\n\nLeave it alone when the flags are independent (a theme toggle and a sidebar\ntoggle are a product of two machines, not one), when there is a single\nboolean, when a status is only read (an API response's field mirrored into\nUI), when it already is a discriminated union with one writer, or when the\nmodule is about to be deleted. Also leave alone:\n\n- Status columns whose writes all live in one module, each a conditional\n update naming the states it may leave (`UPDATE ... WHERE status = $from`),\n so that module reads as the transition table. Where writers are spread out\n or name no from-state, add one `ALLOWED` table and check it in the update\n instead; a value `CHECK` alone does not enforce transitions. A database\n transition trigger only restates a guarded module and breaks fixtures that\n simulate races by setting states.\n- Monotone flags with one writer each that move one way and are read only\n by cleanup (lock held, applying, committed), and append-only accumulators.\n- Static records checked by one validator, and before/after comparisons that\n validate rather than track progress.\n- Effect and promise guards (`cancelled`, `mounted`) on their own.\n- One request in flight plus one \"run again\" flag, both written from one\n function (`pending` with `started`): three reachable phases, one writer.\n- A ref mirroring a state to block a double click before re-render\n (`isSubmitting` with `isSubmittingRef`), written together. The missing\n mirror is the bug: a `busy` guard read from a click handler's render lets\n two clicks start the work twice.\n- Flags in different components (a parent's `editing`, and `busy` in a child\n that only exists while editing): the mount already excludes the bad pair.\n\nRecord each one you leave and why, so the next pass does not reread it.\n\n## Convert\n\nFollow `references/patterns.md` for the language at hand. In every language:\n\n1. List the states and the data each one carries; list the events. Write the\n transition table before the code, and keep it in the PR body.\n2. Model states as a closed type: a TypeScript discriminated union on a\n `status` (or the repository's existing tag name), a Swift enum with\n associated values, a Kotlin sealed interface, a Python `Enum` or tagged\n dataclasses, a Go typed constant with a transition function.\n3. Write one pure `transition(state, event)` (a reducer). Make the switch\n exhaustive (`never` check, `assertNever`, `@unique` enums, sealed `when`).\n Say explicitly what an event does in a state where it does not apply:\n ignore it (return the same state) or throw in development; never fall\n through silently.\n4. Keep every guard the old code had. A `cancelled` flag or an unmount check\n that drops a stale response must become a request tag carried by the\n state and its events (only the current request may finish), never a bare\n \"is it loading?\" check, which accepts `start(A) -> reset -> start(B) ->\n succeed(A)`. Test that sequence. A cleaned-up effect's callbacks (its own\n abort's rejection included) must not dispatch either; see `patterns.md`.\n5. Move side effects out of the transition: effects run on entering a state\n (a worker loop that switches on it, or an effect keyed on the state's whole\n identity: `status` plus its request tag), not inside the reducer. An\n effect keyed on `status` alone misses `loading(A) -> loading(B)` and never\n starts B; test a replacement request.\n6. Keep call sites small: derive old booleans from the state\n (`const isLoading = state.status === \"loading\"`) where many readers use\n them, rather than rewriting every consumer in the same change.\n7. Derive old booleans so they stay stable across intermediate states: an\n effect keyed on `editing` must not refire when a replacement passes\n through `reserving`. Keep effects tied to an event, not a state (resuming\n audio on a press), in the handler.\n8. Use no new dependency. Use a state-machine library (XState, Robot) only if\n the repository already depends on it.\n9. Test the transition table directly: every legal transition, a sample of\n ignored events per state, and the impossible combinations that the old\n code allowed. Keep the existing behavior tests passing unchanged; a test\n that must change shows a behavior change, which belongs in its own PR.\n Run the new behavior tests against the old file (`git show <base>:<file>`\n into a copy kept aside) to prove the refactor changed nothing and that a\n bug's regression test fails before its fix. For output that is printed or\n saved as JSON, assert key order too.\n10. Check every new guard against existing listeners: a guard that only acts\n in `ready` breaks a listener reacting to an event fired mid-render.\n11. Treat \"initial\" props (`initialEditing`) as one-time requests, on mount or\n when they turn true, not as levels that re-apply whenever another input\n changes. Fold a mirrored prop the machine depends on (`canWrite`) into the\n state, synced by one effect declared before the request effect.\n\n## Ship\n\n- Work on a `refactor/<topic>` branch (`fix/<topic>` when the change fixes a\n bug) and ship with the repository's `ship-pr` skill: its validation, its\n independent review, its merge policy.\n- Check file-count or naming budgets (ls-lint) before adding a machine module\n and its test; a full directory may need the tests in an existing file.\n- One PR per repository per pass, unless the diff passes about 600 lines or\n spans unrelated areas; then one PR per area, each independently valid. In\n large repositories plan one PR per machine: formatted transition tables\n and their tests grow fast.\n- The PR body lists each converted machine (file, states, events, the\n impossible combinations removed, any bug fixed) and each candidate left\n alone with its reason.\n- Repository-specific findings (where machines cluster, conventions such as\n the tag name or reducer location) go in that repository's agent guidance.\n Cross-repository lessons go in `references/lessons.md` here, by PR to\n agent-tool.\n\n## Across many repositories\n\nA pass over many repositories follows the coordinating workspace's sweep\nrules (status, ledger, one delegate per repository, `ship-pr` each). Run\n`agent-tool state-machines --json` in each repository first, attach its slice\nto that repository's delegate brief, and remind delegates to read beyond the\nscript's list.\nRepositories whose push hook or ship policy runs a full browser, simulator,\nor package gate need a heavy slot to ship: a delegate without one prepares a\nreviewed commit or open PR and hands off to a finisher that holds one.\n\n## Ownership\n\nagent-tool (`a2f0/agent-tool`) owns this skill in\n`skills/explicit-state-machines/`, and the `state-machines` scanner with its\ntests in `src/stateMachines/`. Repositories get it from\n`agent-tool skills install --apply`; do not edit the installed copy. Change it\nin agent-tool by PR, and send cross-repository lessons to\n`references/lessons.md` there. Repository-specific conventions still go in\nthat repository's `AGENTS.md`.\n",
|
|
4
|
+
"explicit-state-machines": "---\nname: explicit-state-machines\ndescription: Find implicit state machines in a repository, or across many repositories, and refactor them into explicit ones (a closed set of states, typed events, one pure transition function) without changing behavior. Use when the user asks to \"find all of the implicit state machines\", to replace tangled boolean flags or scattered status writes, or to dogfood this pass across repositories; not for adding a state-machine library or redesigning features.\n---\n\n# Explicit State Machines\n\nAn implicit state machine is code that moves through modes (idle, loading,\nfailed, done) without naming them: several booleans that are only valid in\nsome combinations, a status string written from many places, nullable fields\nwhose presence means \"we are in phase X\", or effects that chain off each other\nto advance a process. Bugs live in the combinations nobody named:\n`isLoading && error`, a retry that fires after `done`, a dialog both open and\nsubmitting twice.\n\nAn explicit machine names every state once, carries each state's data only in\nthat state, and changes state only through one function of `(state, event)`.\nThe pass finds the implicit ones, converts those where the conversion removes\nimpossible states or scattered transitions, and proves behavior did not change.\n\n## Find candidates\n\n`agent-tool state-machines` ranks tracked source files by two signals:\nlifecycle booleans toggled together (`isLoading`, `hasError`,\n`_running`, `busy.current`, `this.#rendered`), and status-like fields assigned\nthree or more string values from three or more places (`step.status = ...`).\nIt reads extensionless scripts by their shebang and is read-only. It skips\nSQL comparisons, status fields whose every write is an `UPDATE` naming its\nfrom-state, and enum case payload labels (`.ended(shouldResume: true)`).\n\n- `agent-tool state-machines [path...] [--limit N] [--min-score N] [--json]`\n scans the current repository; each path names the repository holding it.\n If the repository's pinned agent-tool lacks the command, run\n `bunx --bun @a2f0/agent-tool@latest state-machines`.\n- `--limit N` (default 25 per repository, 0 for all), `--min-score N`\n (default 3), `--json` for agents.\n\nThe script finds leads, not verdicts, and it misses whole families. Also read\nfor these by hand, starting from the places state lives (stores, providers,\nhooks, workers, sync loops, connection handlers, upload and checkout flows,\nwizards, CLI subcommands that resume):\n\n- `loading`/`error`/`data` held as separate fields, or a nullable `error`\n beside a non-null `data`; any nullable value paired with a boolean or a\n second nullable (`nowPlaying` with `interruptedNoise`, `reservation` with\n `editing`). The scanner sees booleans only.\n- State split between a context and per-component state that an effect\n re-syncs, and \"already handled\" dedupe fields that quietly mean \"confirmed\n started\".\n- Fields that are set together and cleared together (`startedAt`, `jobId`,\n `abortController`) and so encode \"running\".\n- `useEffect` chains where one effect sets state that triggers the next.\n- Retry, polling, and reconnect loops guarded by counters and flags.\n- Status columns or enums whose transitions are enforced nowhere (any writer\n may set any value), in the database or in API handlers.\n- Shell, Python, or Expect scripts that track progress with several\n variables.\n- One nullable `error` shared by a load phase and user actions: starting an\n action clears the load's failure, so the view falls back to \"Loading...\"\n after a failed load. Split a `loading | ready | failed` load machine from\n the actions' own errors.\n\n## Decide what to convert\n\nConvert a candidate only when at least one holds:\n\n1. Two or more of its variables can hold a combination that is meaningless\n (`isLoading && isError`, `data` set while `status === \"failed\"`).\n2. Its transitions are written in several places, so no single place says\n which moves are legal.\n3. A bug or missing guard follows from either (an event handled in a state\n where it must be ignored). Fix the bug in the same change, with a test that\n fails before it, and say so in the PR.\n\nLeave it alone when the flags are independent (a theme toggle and a sidebar\ntoggle are a product of two machines, not one), when there is a single\nboolean, when a status is only read (an API response's field mirrored into\nUI), when it already is a discriminated union with one writer, or when the\nmodule is about to be deleted. Also leave alone:\n\n- Status columns whose writes all live in one module, each a conditional\n update naming the states it may leave (`UPDATE ... WHERE status = $from`),\n so that module reads as the transition table. Where writers are spread out\n or name no from-state, add one `ALLOWED` table and check it in the update\n instead; a value `CHECK` alone does not enforce transitions. A database\n transition trigger only restates a guarded module and breaks fixtures that\n simulate races by setting states.\n- Monotone flags with one writer each that move one way and are read only\n by cleanup (lock held, applying, committed), and append-only accumulators.\n- Static records checked by one validator, and before/after comparisons that\n validate rather than track progress.\n- Effect and promise guards (`cancelled`, `mounted`) on their own.\n- One request in flight plus one \"run again\" flag, both written from one\n function (`pending` with `started`): three reachable phases, one writer.\n- A ref mirroring a state to block a double click before re-render\n (`isSubmitting` with `isSubmittingRef`), written together. The missing\n mirror is the bug: a `busy` guard read from a click handler's render lets\n two clicks start the work twice.\n- Flags in different components (a parent's `editing`, and `busy` in a child\n that only exists while editing): the mount already excludes the bad pair.\n- Latest-wins request counters with one writer (`++request` paired with\n `request === current`): already the recommended guard, not a machine.\n- A one-time focus-return ref written together with the close event.\n- A single set-once setup flag (`isConfigured`).\n- A memoized single teardown promise (`closing ??= teardown()`) with a\n one-way `closed` flag only that teardown sets.\n- A boolean that is only an event's payload field\n (`interruptionEnded(shouldResume:)`): it is part of an event, not state.\n\nRecord each one you leave and why, so the next pass does not reread it.\n\n## Convert\n\nFollow `references/patterns.md` for the language at hand. In every language:\n\n1. List the states and the data each one carries; list the events. Write the\n transition table before the code, and keep it in the PR body.\n2. Model states as a closed type: a TypeScript discriminated union on a\n `status` (or the repository's existing tag name), a Swift enum with\n associated values, a Kotlin sealed interface, a Python `Enum` or tagged\n dataclasses, a Go typed constant with a transition function.\n3. Write one pure `transition(state, event)` (a reducer). Make the switch\n exhaustive (`never` check, `assertNever`, `@unique` enums, sealed `when`).\n Say explicitly what an event does in a state where it does not apply:\n ignore it (return the same state) or throw in development; never fall\n through silently.\n4. Keep every guard the old code had. A `cancelled` flag or an unmount check\n that drops a stale response must become a request tag carried by the\n state and its events (only the current request may finish), never a bare\n \"is it loading?\" check, which accepts `start(A) -> reset -> start(B) ->\n succeed(A)`. Test that sequence. A cleaned-up effect's callbacks (its own\n abort's rejection included) must not dispatch either; see `patterns.md`.\n5. Move side effects out of the transition: effects run on entering a state\n (a worker loop that switches on it, or an effect keyed on the state's whole\n identity: `status` plus its request tag), not inside the reducer. An\n effect keyed on `status` alone misses `loading(A) -> loading(B)` and never\n starts B; test a replacement request.\n6. Keep call sites small: derive old booleans from the state\n (`const isLoading = state.status === \"loading\"`) where many readers use\n them, rather than rewriting every consumer in the same change.\n7. Derive old booleans so they stay stable across intermediate states: an\n effect keyed on `editing` must not refire when a replacement passes\n through `reserving`. Keep effects tied to an event, not a state (resuming\n audio on a press), in the handler.\n8. Use no new dependency. Use a state-machine library (XState, Robot) only if\n the repository already depends on it.\n9. Test the transition table directly: every legal transition, a sample of\n ignored events per state, and the impossible combinations that the old\n code allowed. Keep the existing behavior tests passing unchanged; a test\n that must change shows a behavior change, which belongs in its own PR.\n Run the new behavior tests against the old file (`git show <base>:<file>`\n into a copy kept aside) to prove the refactor changed nothing and that a\n bug's regression test fails before its fix. For output that is printed or\n saved as JSON, assert key order too.\n10. Check every new guard against existing listeners: a guard that only acts\n in `ready` breaks a listener reacting to an event fired mid-render.\n11. Treat \"initial\" props (`initialEditing`) as one-time requests, on mount or\n when they turn true, not as levels that re-apply whenever another input\n changes. Fold a mirrored prop the machine depends on (`canWrite`) into the\n state, synced by one effect declared before the request effect.\n\n## Ship\n\n- Work on a `refactor/<topic>` branch (`fix/<topic>` when the change fixes a\n bug) and ship with the repository's `ship-pr` skill: its validation, its\n independent review, its merge policy.\n- Check file-count or naming budgets (ls-lint) before adding a machine module\n and its test; a full directory may need the tests in an existing file.\n- One PR per repository per pass, unless the diff passes about 600 lines or\n spans unrelated areas; then one PR per area, each independently valid. In\n large repositories plan one PR per machine: formatted transition tables\n and their tests grow fast.\n- The PR body lists each converted machine (file, states, events, the\n impossible combinations removed, any bug fixed) and each candidate left\n alone with its reason.\n- Repository-specific findings (where machines cluster, conventions such as\n the tag name or reducer location) go in that repository's agent guidance.\n Cross-repository lessons go in `references/lessons.md` here, by PR to\n agent-tool.\n\n## Across many repositories\n\nA pass over many repositories follows the coordinating workspace's sweep\nrules (status, ledger, one delegate per repository, `ship-pr` each). Run\n`agent-tool state-machines --json` in each repository first, attach its slice\nto that repository's delegate brief, and remind delegates to read beyond the\nscript's list.\nRepositories whose push hook or ship policy runs a full browser, simulator,\nor package gate need a heavy slot to ship: a delegate without one prepares a\nreviewed commit or open PR and hands off to a finisher that holds one.\n\n## Ownership\n\nagent-tool (`a2f0/agent-tool`) owns this skill in\n`skills/explicit-state-machines/`, and the `state-machines` scanner with its\ntests in `src/stateMachines/`. Repositories get it from\n`agent-tool skills install --apply`; do not edit the installed copy. Change it\nin agent-tool by PR, and send cross-repository lessons to\n`references/lessons.md` there. Repository-specific conventions still go in\nthat repository's `AGENTS.md`.\n",
|
|
5
5
|
"open-issue": "---\nname: open-issue\ndescription: Open a GitHub issue the user explicitly requested, assigned to the authenticated GitHub user, with a reviewable title and body.\n---\n\n# Open Issue\n\nOpen an issue only when the user explicitly asks for one; finding a bug,\nrecording evidence, or following another skill does not authorize it. Follow\nrepository guidance for where issues belong, labels, and templates.\n\nResolve the target repository with `agent-tool repo context` (its `repo`\nfield) from the checkout, or the `owner/repo` the user named; never infer it\nfrom a folder name. Before creating, search open issues for a duplicate:\n\n```sh\ngh issue list --repo <owner/repo> --state open --search '<key words>' --json number,title,url\n```\n\nIf one matches, report it instead of opening another, and assign it with\n`gh issue edit <number> --repo <owner/repo> --add-assignee @me` when the user\nwants it tracked.\n\nWrite a specific title and a body that states the observed behavior, the\nexpected behavior, and reproduction steps or evidence (commands, versions,\nlinks). Never include secret values. Use only labels that already exist\n(`gh label list --repo <owner/repo>`). Put the body in a temporary file, then\ncreate the issue assigned to the authenticated GitHub user:\n\n```sh\ngh issue create --repo <owner/repo> --title '<title>' --body-file /path/to/issue-body.md --assignee @me\n```\n\nAlways pass `--assignee @me`; assign other people only when the user names\nthem, in addition to `@me` unless the user says otherwise. Confirm the result\nwith `gh issue view <url> --json url,assignees` and, if the assignee is missing,\nadd it with `gh issue edit <url> --add-assignee @me`. Report the issue URL and\nits assignees, or the exact error if assignment failed.\n",
|
|
6
6
|
"open-pr": "---\nname: open-pr\ndescription: Open a GitHub pull request for the current feature branch with a validated title and a reviewable description.\n---\n\n# Open PR\n\nUse the installed `agent-tool`. Follow repository policy for validation, commits,\nand branch naming. Resolve the repository and default branch with `agent-tool repo context`;\ndo not assume the branch is named main. Preserve unrelated work and finish\nrequested changes and appropriate checks before publishing them.\n\nEnsure the intended commit is on a feature branch and use\n`agent-tool branch push <reviewed-head> --remote <remote>` through normal hooks.\nIt names the destination explicitly and verifies local/remote HEAD afterward;\na changed result needs another review. Respect existing push authorization.\nIf the user only requested a draft description, prepare it without publishing.\n\nWrite a title that satisfies the data-only `agent-tool.json` subject policy.\nDescribe the concrete resulting behavior and relevant validation. When the\nbranch changes package manifests, run\n`agent-tool dependencies diff --base <base-oid>` and name every `newPackages`\nentry in the body with its manifest and version, so a reviewer sees new\nthird-party code however it was added. Put multiline body text in a temporary\nfile, then run:\n\n```sh\nagent-tool pr open 'feat: describe the change' --body-file /path/to/pr-body.txt\n```\n\nOmitting the title uses the latest commit subject. The tool refuses duplicate\nopen PRs and requires the branch to be pushed at its local HEAD. It does not\npush for you. Resolve a fork's head/base identities explicitly if the repository\nuses fork PRs; the initial open helper supports same-repository branches.\n\nReport the created PR link and any remaining validation or review work. Run\n`agent-tool teardown` first, stop what the work started, and report what remains.\n",
|
|
7
7
|
"reset": "---\nname: reset\ndescription: Return a clean checkout to its updated default branch after confirmed shipping or an explicit checkout cleanup request.\n---\n\n# Reset\n\nThis is checkout cleanup, not a destructive Git reset. Read repository guidance\nfor setup and hooks. Determine the default branch through the repository's\nremote metadata rather than assuming main.\n\nFor post-merge cleanup, preview with\n`agent-tool repo cleanup <PR-number> <shipped-head>`, then run it with `--apply`\nwithin the authorized cleanup scope. It requires a confirmed MERGED PR, refuses\nthe default and `branch.exempt` branches, preserves local edits, checks exact\nlocal/remote feature commits, verifies the merge is on the default branch, and\nfast-forwards that branch. By default it prunes the\nshipped local feature branch and deletes the matching remote branch if present.\nLocal pruning also applies after a squash merge or when GitHub already deleted\nthe remote branch; a missing remote is not evidence that local cleanup is done.\nPass `--keep-branch` when the user asked to preserve both feature refs.\nRead the receipt's completed steps if a later operation fails; do not claim full\ncleanup from a partial result. Verify the final branch and clean worktree, and\ncheck that `git branch --list <feature-branch>` is empty unless keeping the branch.\nIf the feature branch is checked out in another worktree, shared cleanup reports\na partial failure after updating the default branch and leaves both feature refs\nin place; preserve that worktree and report the remaining cleanup.\n\nRemote branch deletion uses GitHub REST after checking the live ref to avoid\npre-push hooks. The API has no atomic expected-OID delete guard; the check and\ndeletion retain a small concurrent-update window. Fork PRs require manual remote\nhandling and are refused by the shared cleanup command.\nUse a repository's stronger cleanup procedure when required, including atomic\nremote deletion or additional local verification; do not weaken it to use the\nshared command. The same local pruning and keep-branch outcomes still apply.\n\nFor an explicit checkout cleanup without a merged PR, preserve all feature refs:\ncheck worktree/index, resolve the default branch with `repo context`, fetch it,\nswitch, and update by fast-forward only. Never discard or automatically stash\nedits, force a diverged default branch, or infer shipping from Git ancestry after\na squash merge.\n\nRun `agent-tool teardown` and stop what the finished work left running\n(`teardown --apply` for recorded processes); report anything that remains.\nRun the repository's documented dependency/hook setup if required. When copied\nhooks are configured, use `agent-tool hooks check` and `hooks install --apply`;\ninspect unmanaged hooks before an explicit `--adopt`, which backs them up.\nDo not assume a package manager. Report final branch, commit, whether the shipped\nlocal branch was pruned or preserved, and unfinished cleanup or setup.\n",
|
|
@@ -12,7 +12,7 @@ export const BUNDLED_SKILLS: Readonly<Record<string, string>> = {
|
|
|
12
12
|
/** Markdown files a skill ships beside its SKILL.md, by skill and then by path within the skill directory. */
|
|
13
13
|
export const BUNDLED_SKILL_REFERENCES: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
|
14
14
|
"explicit-state-machines": {
|
|
15
|
-
"references/lessons.md": "# Lessons\n\nCross-repository lessons from explicit-state-machine passes. Add one when a\npass learns something the next repository needs; keep repository-specific\nfindings in that repository's guidance.\n\n- The script's `flags` signal skips `cancelled` and `settled`: effect cleanups\n and promise races set them once as guards, which is not a machine.\n- Type members and returned object literals (`status: \"idle\"`) are how an\n explicit machine names its states, so the `status` signal counts only\n assignments and setter calls.\n- The first sweep (16 repositories) found real machines the scanner missed:\n React refs (`busy.current`), `#private` fields, in-place writes to nested\n objects (`record.steps[i].status`), lifecycle booleans not named `is*`\n (`booting`, `paging`), extensionless and Expect scripts, and nullable values\n paired with flags. The first five are now signals; read for the last by hand.\n- A ref mirroring a state (`isSubmitting` with `isSubmittingRef`) now shows as\n two flags; it is worth reading, since the pair can disagree.\n- Server status columns in balebase and devopsrockstars were already guarded:\n every write, in one module, is a conditional update naming the states it\n may leave. The scanner still lists them; the skill says when that suffices.\n- Repositories with no runtime state (seed data, one-shot CLIs, dotfiles)\n closed as `unchanged` within minutes; let them, and record what was read.\n- Converting found real bugs: a step that stayed `running` forever after its\n runner failed to start, a gallery acting on a detached render, a bootstrap\n gate blocked forever when prerequisites vanished mid-run, two menus closing\n on one press, duplicate stop logs on a second focus loss.\n- Second and third rounds in balebase and tearleads, driven by the widened\n scanner, found one more real bug (a checkout link minting two Stripe\n sessions on a double click) and several false positives now named in the\n skill's leave-alone list: in-flight-plus-rerun flags, ref mirrors, and flag\n pairs split across a parent and a conditionally mounted child.\n- Bug fixes the sweep surfaced outside state machines shipped as their own\n `fix/` PRs: a swallowed settings-write error, a partial download cached\n forever, an audio session never released, a verify step failing during\n registry propagation, and a CI runner image that broke browser tests.\n",
|
|
15
|
+
"references/lessons.md": "# Lessons\n\nCross-repository lessons from explicit-state-machine passes. Add one when a\npass learns something the next repository needs; keep repository-specific\nfindings in that repository's guidance.\n\n- The script's `flags` signal skips `cancelled` and `settled`: effect cleanups\n and promise races set them once as guards, which is not a machine.\n- Type members and returned object literals (`status: \"idle\"`) are how an\n explicit machine names its states, so the `status` signal counts only\n assignments and setter calls.\n- The first sweep (16 repositories) found real machines the scanner missed:\n React refs (`busy.current`), `#private` fields, in-place writes to nested\n objects (`record.steps[i].status`), lifecycle booleans not named `is*`\n (`booting`, `paging`), extensionless and Expect scripts, and nullable values\n paired with flags. The first five are now signals; read for the last by hand.\n- A ref mirroring a state (`isSubmitting` with `isSubmittingRef`) now shows as\n two flags; it is worth reading, since the pair can disagree.\n- Server status columns in balebase and devopsrockstars were already guarded:\n every write, in one module, is a conditional update naming the states it\n may leave. The scanner still lists them; the skill says when that suffices.\n- Repositories with no runtime state (seed data, one-shot CLIs, dotfiles)\n closed as `unchanged` within minutes; let them, and record what was read.\n- Converting found real bugs: a step that stayed `running` forever after its\n runner failed to start, a gallery acting on a detached render, a bootstrap\n gate blocked forever when prerequisites vanished mid-run, two menus closing\n on one press, duplicate stop logs on a second focus loss.\n- Second and third rounds in balebase and tearleads, driven by the widened\n scanner, found one more real bug (a checkout link minting two Stripe\n sessions on a double click) and several false positives now named in the\n skill's leave-alone list: in-flight-plus-rerun flags, ref mirrors, and flag\n pairs split across a parent and a conditionally mounted child.\n- Bug fixes the sweep surfaced outside state machines shipped as their own\n `fix/` PRs: a swallowed settings-write error, a partial download cached\n forever, an audio session never released, a verify step failing during\n registry propagation, and a CI runner image that broke browser tests.\n- A nullable `error` shared by a load and user actions hid a real bug in\n devopsrockstars' Stripe payment step: after a failed load, Cancel cleared\n the error and the step showed \"Loading...\" again. A `loading | ready |\n failed` load machine with separate action errors fixed it\n (devopsrockstars#898). The scanner sees booleans only; read for it by hand.\n- The second sweep's perennial false positives are now named: guarded SQL\n status updates (devopsrockstars checkout and webhooks, balebase contracts\n and jobs) and enum payload labels are skipped by the scanner, along with\n `*.testUtils.*` files; latest-wins request counters, set-once setup flags,\n one-time focus-return refs, and memoized teardown promises are in the\n skill's leave-alone list.\n An update whose `WHERE` has an `OR`, a `NOT` or a subquery still counts,\n even when every branch names the from-state (balebase finance): read it by\n hand.\n",
|
|
16
16
|
"references/patterns.md": "# Patterns\n\nBefore and after shapes per language. Match the repository's existing names\n(tag field, reducer file layout, error helpers) over these.\n\n## TypeScript and React\n\nBefore: three booleans and two nullable fields, eight combinations, four valid.\n\n```ts\nconst [isLoading, setIsLoading] = useState(false);\nconst [error, setError] = useState<Error | null>(null);\nconst [data, setData] = useState<Item[] | null>(null);\n```\n\nAfter: one union, one reducer, booleans derived for readers.\n\n```ts\nexport type LoadState =\n | { status: \"idle\" }\n | { status: \"loading\"; request: number }\n | { status: \"failed\"; error: Error }\n | { status: \"loaded\"; items: Item[] };\n\nexport type LoadEvent =\n | { type: \"start\"; request: number }\n | { type: \"succeed\"; request: number; items: Item[] }\n | { type: \"fail\"; request: number; error: Error }\n | { type: \"reset\" };\n\n/** Only the request that is still current may finish the load. */\nconst current = (state: LoadState, request: number) =>\n state.status === \"loading\" && state.request === request;\n\nexport function transition(state: LoadState, event: LoadEvent): LoadState {\n switch (event.type) {\n case \"start\":\n return { status: \"loading\", request: event.request };\n case \"succeed\":\n return current(state, event.request) ? { status: \"loaded\", items: event.items } : state;\n case \"fail\":\n return current(state, event.request) ? { status: \"failed\", error: event.error } : state;\n case \"reset\":\n return { status: \"idle\" };\n default:\n return assertNever(event);\n }\n}\n\nfunction assertNever(value: never): never {\n throw new Error(`Unhandled event: ${JSON.stringify(value)}`);\n}\n\n// In the component:\nconst [state, dispatch] = useReducer(transition, { status: \"idle\" });\nconst isLoading = state.status === \"loading\";\n// Keyed on the request, not just the status, so a replacement request starts.\nconst request = state.status === \"loading\" ? state.request : undefined;\nuseEffect(() => {\n if (request === undefined) return;\n const abort = new AbortController();\n // The cleanup's own abort rejects the fetch; that rejection must not\n // dispatch, or a re-run of this effect for the same request (Strict Mode,\n // a dependency change) records a failure the user never had.\n let active = true;\n fetchItems(abort.signal).then(\n items => { if (active) dispatch({ type: \"succeed\", request, items }); },\n error => { if (active) dispatch({ type: \"fail\", request, error }); },\n );\n return () => { active = false; abort.abort(); };\n}, [request]);\n```\n\nThe request tag rejects results from a superseded request; the `active` flag\nrejects callbacks from a cleaned-up effect for the same request. Keep both, and\ntest the replay: mount, unmount, mount again (Strict Mode), then resolve, and\nexpect `loaded`, not `failed`.\n\n- Never drop a cancellation guard without replacing it. A bare\n `status === \"loading\"` check accepts a stale result in\n `start(A) -> reset -> start(B) -> succeed(A)`; tag each request (a counter\n or the `AbortController` itself) and let only the current one finish, as\n above, with that sequence as a test. Keep aborting the network work too.\n- Stores (Zustand, Redux, signals): keep the union as the store's state and\n the reducer as its only writer; actions dispatch events.\n- Classes and long-lived loops (sync workers, socket connections): a private\n `state` field of the union type, a `send(event)` method that applies\n `transition`, and the loop `switch`es on `state.status`.\n- Server status columns: a `ALLOWED: Record<Status, readonly Status[]>` table\n and one `moveTo(row, next)` that checks it inside the update's transaction\n (`UPDATE ... WHERE status = $from`), so concurrent writers cannot skip a\n state.\n\nTest with a table:\n\n```ts\ntest.each([\n [{ status: \"idle\" }, { type: \"succeed\", request: 1, items: [] }, { status: \"idle\" }],\n [{ status: \"loading\", request: 1 }, { type: \"succeed\", request: 1, items: [] }, { status: \"loaded\", items: [] }],\n [{ status: \"loading\", request: 2 }, { type: \"succeed\", request: 1, items: [] }, { status: \"loading\", request: 2 }],\n [{ status: \"loaded\", items: [] }, { type: \"fail\", request: 1, error }, { status: \"loaded\", items: [] }],\n] as const)(\"%o + %o\", (state, event, next) => {\n expect(transition(state, event)).toEqual(next);\n});\n\ntest(\"a stale request cannot finish a newer one\", () => {\n const events: LoadEvent[] = [\n { type: \"start\", request: 1 }, { type: \"reset\" }, { type: \"start\", request: 2 },\n { type: \"succeed\", request: 1, items: stale },\n ];\n expect(events.reduce(transition, { status: \"idle\" })).toEqual({ status: \"loading\", request: 2 });\n});\n```\n\n### More React and class shapes\n\n- **A handler, not an effect, starts the work** (a sign-in button): take the\n request number from a `useRef` counter, dispatch `start` with it, and have\n the async handler dispatch `succeed`/`fail` with the same number.\n- **Synchronous re-entrance** (two clicks before a render): keep the current\n state in a ref that one `send(event)` updates and that returns the next\n state; the caller starts work only if its own request entered `loading`.\n- **A queue that spans effect runs** (read one page at a time, queue one\n refresh): keep the machine in a ref with a `useState` copy for rendering,\n written by one `send`. A superseded request that must still finish (to start\n the queued refresh) carries `current: false` instead of being dropped.\n- **A handler that both changes state and decides an output** (open a menu now,\n defer it, or ignore the press): make the step a Mealy function returning\n `{ state, output }`, so every decision lives in one place.\n- **Long-lived controllers that log or notify on change**: have `send` return\n the previous state too, so the caller acts only on the change that happened\n (log on `starting -> playing`, not on every event).\n- **Classes holding resources** (sockets, timers, watchdogs): give each\n resource to exactly one state and release it in one `leave(previous, next)`\n step; guard `if (next === this.state) return` because leave callbacks can\n re-enter (`onDisconnect` calling `stop()`).\n- **Testing async controllers**: move the React controller into its own hook\n with the async work injected, so `renderHook` can drive stale, replacement\n and Strict Mode sequences. In React Native Testing Library, start a press\n without awaiting it to observe the pending state, then resolve inside\n `await act(...)`.\n- **Dismissals name what they dismiss** (outside click, escape): otherwise the\n listener of the menu that just closed can close the menu that replaced it.\n\n### Placement and lint\n\n- Put the reducer beside the code that runs it when a new module costs a\n network request or a build allowlist entry (bundler-free static sites), and\n in Swift in a file already compiled into the app target, to avoid an Xcode\n project edit.\n- A reducer can exceed Biome's cognitive-complexity limit; one type guard such\n as `inAttempt(state, attempt, ...statuses)` usually brings it back.\n\n## Swift\n\n```swift\nenum Upload: Equatable {\n case idle\n case uploading(progress: Double)\n case failed(message: String)\n case done(url: URL)\n\n func handling(_ event: UploadEvent) -> Upload {\n switch (self, event) {\n case (.idle, .start), (.failed, .start): return .uploading(progress: 0)\n case (.uploading, .progress(let p)): return .uploading(progress: p)\n case (.uploading, .finish(let url)): return .done(url: url)\n case (.uploading, .fail(let message)): return .failed(message: message)\n default: return self\n }\n }\n}\n```\n\nAvoid a `default` that hides a new case where the compiler could check it:\nswitch on `self` first and handle every case.\n\n## Kotlin\n\nA `sealed interface State` with `data object Idle : State` and\n`data class Failed(val error: Throwable) : State`, and a\n`fun State.on(event: Event): State = when (this) { ... }` with no `else`\nbranch, so a new state fails to compile until it is handled.\n\n## Python\n\n```python\nfrom dataclasses import dataclass\nfrom typing import assert_never\n\n@dataclass(frozen=True)\nclass Idle: ...\n@dataclass(frozen=True)\nclass Running:\n job_id: str\n@dataclass(frozen=True)\nclass Failed:\n error: str\n\nState = Idle | Running | Failed\n\ndef transition(state: State, event: Event) -> State:\n match state, event:\n case Idle(), Start(job_id=job_id):\n return Running(job_id)\n case Running(), Fail(error=error):\n return Failed(error)\n case _:\n return state\n```\n\nWhere a plain enum suffices (no per-state data), use `enum.Enum` and an\n`ALLOWED: dict[State, frozenset[State]]` table.\n\n## Go\n\nA `type State int` with `const` values and a `String()` method, a\n`func (s State) On(e Event) (State, error)` that returns an error for an\nillegal transition, and an `exhaustive` lint if the repository runs one.\n\n## Shell\n\nOne `state` variable and a `case \"$state\" in` loop instead of several\n`done=0`/`failed=1` variables; print the state when the script exits early.\n"
|
|
17
17
|
}
|
|
18
18
|
};
|
|
@@ -76,6 +76,7 @@ const SKIPPED = [
|
|
|
76
76
|
/(^|\/)\.(agents|claude|codex|opencode)\//,
|
|
77
77
|
/(^|\/)(__tests__|__mocks__|e2e|fixtures?|test|tests)\//,
|
|
78
78
|
/\.(test|spec|stories|e2e)\.[a-z]+$/,
|
|
79
|
+
/\.(test|spec)(Utils|Helpers|-utils|-helpers)\.[a-z]+$/,
|
|
79
80
|
/_test\.(go|py)$/,
|
|
80
81
|
/(^|\/)(test_[^/]*|conftest)\.py$/,
|
|
81
82
|
/(Tests?|Spec)\.(swift|kt|java)$/,
|
|
@@ -220,23 +221,29 @@ const STATUS_SETTER = /\bset_?([A-Za-z_$][\w$]*)\(\s*(["'`])([\w-]+)\2\s*\)/g;
|
|
|
220
221
|
export function scanText(text: string, syntax: "c" | "hash"): Signal[] {
|
|
221
222
|
const flags: Writes = new Map();
|
|
222
223
|
const statuses: Writes = new Map();
|
|
223
|
-
codeLines(text, syntax)
|
|
224
|
+
const lines = codeLines(text, syntax);
|
|
225
|
+
const sql = sqlStatusFacts(lines);
|
|
226
|
+
lines.forEach((line, index) => {
|
|
224
227
|
const at = index + 1;
|
|
225
228
|
noteFlagWrites(line, at, flags);
|
|
226
|
-
noteStatusAssigns(line, at, statuses);
|
|
229
|
+
noteStatusAssigns(line, at, statuses, sql);
|
|
227
230
|
if (syntax === "hash") noteTclWrites(line, at, flags, statuses);
|
|
228
231
|
noteStatusSetters(line, at, statuses);
|
|
229
232
|
});
|
|
230
233
|
return signalsFrom(flags, statuses);
|
|
231
234
|
}
|
|
232
235
|
|
|
233
|
-
/**
|
|
234
|
-
|
|
236
|
+
/**
|
|
237
|
+
* The values each flag or status field is written with, the lines that write
|
|
238
|
+
* it, and whether any write is not a guarded SQL update.
|
|
239
|
+
*/
|
|
240
|
+
type Writes = Map<string, { values: Set<string>; lines: Set<number>; unguarded: boolean }>;
|
|
235
241
|
|
|
236
|
-
function note(map: Writes, name: string, value: string, line: number): void {
|
|
237
|
-
const entry = map.get(name) ?? { values: new Set<string>(), lines: new Set<number>() };
|
|
242
|
+
function note(map: Writes, name: string, value: string, line: number, guarded = false): void {
|
|
243
|
+
const entry = map.get(name) ?? { values: new Set<string>(), lines: new Set<number>(), unguarded: false };
|
|
238
244
|
entry.values.add(value);
|
|
239
245
|
entry.lines.add(line);
|
|
246
|
+
entry.unguarded ||= !guarded;
|
|
240
247
|
map.set(name, entry);
|
|
241
248
|
}
|
|
242
249
|
|
|
@@ -245,6 +252,7 @@ function noteFlagWrites(line: string, at: number, flags: Writes): void {
|
|
|
245
252
|
for (const match of line.matchAll(USE_STATE)) if (isFlag(match[1])) note(flags, match[1], match[2], at);
|
|
246
253
|
for (const match of line.matchAll(ASSIGN)) {
|
|
247
254
|
const name = targetName(match[1], match[2]);
|
|
255
|
+
if (match[0].includes(":") && isCasePayloadLabel(line, match.index)) continue;
|
|
248
256
|
if (isFlag(name)) note(flags, name, match[3].toLowerCase(), at);
|
|
249
257
|
}
|
|
250
258
|
for (const match of line.matchAll(SETTER)) {
|
|
@@ -253,13 +261,178 @@ function noteFlagWrites(line: string, at: number, flags: Writes): void {
|
|
|
253
261
|
}
|
|
254
262
|
}
|
|
255
263
|
|
|
256
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Whether the `label:` starting at `index` is the first argument label of an
|
|
266
|
+
* implicit member such as Swift's `.interruptionEnded(shouldResume: true)`: an
|
|
267
|
+
* enum case's payload, so part of an event, not a write to state. Looks back
|
|
268
|
+
* over one identifier only, so it is linear and bounded.
|
|
269
|
+
*/
|
|
270
|
+
function isCasePayloadLabel(line: string, index: number): boolean {
|
|
271
|
+
let at = index - 1;
|
|
272
|
+
while (at >= 0 && (line[at] === " " || line[at] === "\t")) at--;
|
|
273
|
+
if (line[at] !== "(") return false;
|
|
274
|
+
const end = --at;
|
|
275
|
+
while (at >= 0 && /[\w$]/.test(line[at]!)) at--;
|
|
276
|
+
if (at === end || line[at] !== ".") return false;
|
|
277
|
+
return at === 0 || !/[\w$)\]?!]/.test(line[at - 1]!);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function noteStatusAssigns(line: string, at: number, statuses: Writes, sql: SqlFacts): void {
|
|
257
281
|
for (const match of line.matchAll(STATUS_ASSIGN)) {
|
|
258
282
|
const name = targetName(match[1], match[2]);
|
|
259
|
-
if (STATUS.test(name))
|
|
283
|
+
if (!STATUS.test(name)) continue;
|
|
284
|
+
const key = `${at}:${match.index + match[0].search(/\s*=/)}`;
|
|
285
|
+
if (sql.comparisons.has(key)) continue;
|
|
286
|
+
note(statuses, name, match[4], at, sql.guarded.has(key));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Where SQL compares a status (`WHERE status = 'x'`), and which SQL writes name their from-state, keyed `line:end`. */
|
|
291
|
+
interface SqlFacts {
|
|
292
|
+
readonly comparisons: Set<string>;
|
|
293
|
+
readonly guarded: Set<string>;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface Token {
|
|
297
|
+
readonly text: string;
|
|
298
|
+
readonly kind: "word" | "literal" | "punct";
|
|
299
|
+
readonly line: number;
|
|
300
|
+
/** Column just past the token, matching where an assignment target ends. */
|
|
301
|
+
readonly end: number;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Words, single-quoted literals and punctuation, one pass per line; a literal ends at the line's end. */
|
|
305
|
+
function sqlTokens(lines: string[]): Token[] {
|
|
306
|
+
const tokens: Token[] = [];
|
|
307
|
+
lines.forEach((text, index) => {
|
|
308
|
+
const line = index + 1;
|
|
309
|
+
let at = 0;
|
|
310
|
+
while (at < text.length) {
|
|
311
|
+
const char = text[at]!;
|
|
312
|
+
if (char === " " || char === "\t" || char === "\r") {
|
|
313
|
+
at++;
|
|
314
|
+
} else if (/[\w$#.]/.test(char)) {
|
|
315
|
+
const start = at;
|
|
316
|
+
while (at < text.length && /[\w$#.]/.test(text[at]!)) at++;
|
|
317
|
+
tokens.push({ text: text.slice(start, at), kind: "word", line, end: at });
|
|
318
|
+
} else if (char === "'") {
|
|
319
|
+
const close = text.indexOf("'", at + 1);
|
|
320
|
+
const end = close < 0 ? text.length : close + 1;
|
|
321
|
+
tokens.push({ text: text.slice(at, end), kind: "literal", line, end });
|
|
322
|
+
at = end;
|
|
323
|
+
} else {
|
|
324
|
+
tokens.push({ text: char, kind: "punct", line, end: ++at });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
return tokens;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Uppercase SQL keywords: lowercase `and`/`or`/`in` are code in Python and Ruby. */
|
|
332
|
+
const COMPARES_AFTER = new Set(["WHERE", "AND", "OR", "ON", "WHEN", "NOT"]);
|
|
333
|
+
/** A statement ends at `;` or at the delimiter of the string holding it. */
|
|
334
|
+
const STATEMENT_ENDS = new Set([";", "`", '"']);
|
|
335
|
+
/** Lines an `UPDATE` may span before the scan stops treating what follows as its clauses. */
|
|
336
|
+
const STATEMENT_LINES = 40;
|
|
337
|
+
|
|
338
|
+
/** Whether the token at `index` follows `WHERE`, `AND` and the like, past any opening parentheses. */
|
|
339
|
+
function followsComparisonKeyword(tokens: Token[], index: number): boolean {
|
|
340
|
+
let at = index - 1;
|
|
341
|
+
while (at >= 0 && tokens[at]!.text === "(") at--;
|
|
342
|
+
return at >= 0 && COMPARES_AFTER.has(tokens[at]!.text);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** An `UPDATE` being read: its clause, the status writes in its `SET`, and the fields its `WHERE` names a from-state for. */
|
|
346
|
+
interface UpdateMode {
|
|
347
|
+
readonly kind: "update";
|
|
348
|
+
readonly line: number;
|
|
349
|
+
readonly phase: "update" | "set" | "where";
|
|
350
|
+
readonly writes: { field: string; key: string }[];
|
|
351
|
+
readonly guards: Set<string>;
|
|
352
|
+
/**
|
|
353
|
+
* An `OR` or `NOT` in the `WHERE`, or a subquery anywhere in the update:
|
|
354
|
+
* the from-state may not hold for every row, so nothing is guarded.
|
|
355
|
+
*/
|
|
356
|
+
readonly loose: boolean;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Where the SQL pass is: in code, inside a `SELECT` or `DELETE` (every `field = 'x'` is a comparison), or in an `UPDATE`. */
|
|
360
|
+
type SqlMode = { readonly kind: "code" } | { readonly kind: "reading"; readonly line: number } | UpdateMode;
|
|
361
|
+
|
|
362
|
+
const CODE: SqlMode = { kind: "code" };
|
|
363
|
+
|
|
364
|
+
/** The mode after one token, and the update it ended, if any. Pure: it never changes `mode`. */
|
|
365
|
+
function sqlStep(mode: SqlMode, token: Token): { mode: SqlMode; ended?: UpdateMode } {
|
|
366
|
+
const ended = mode.kind === "update" ? mode : undefined;
|
|
367
|
+
if (mode.kind !== "code" && token.line - mode.line > STATEMENT_LINES) return { mode: sqlStep(CODE, token).mode, ended };
|
|
368
|
+
if (token.kind === "punct") return STATEMENT_ENDS.has(token.text) ? { mode: CODE, ended } : { mode };
|
|
369
|
+
if (token.kind !== "word") return { mode };
|
|
370
|
+
switch (token.text) {
|
|
371
|
+
case "UPDATE":
|
|
372
|
+
return { mode: { kind: "update", line: token.line, phase: "update", writes: [], guards: new Set(), loose: false }, ended };
|
|
373
|
+
case "SELECT":
|
|
374
|
+
case "DELETE":
|
|
375
|
+
// A subquery ends the UPDATE holding it unguarded, and writes after it stay counted.
|
|
376
|
+
return mode.kind === "update" ? { mode: CODE, ended: { ...mode, loose: true } } : { mode: { kind: "reading", line: token.line }, ended };
|
|
377
|
+
case "INSERT":
|
|
378
|
+
return { mode: CODE, ended };
|
|
379
|
+
case "SET":
|
|
380
|
+
return { mode: mode.kind === "update" ? { ...mode, phase: "set" } : mode };
|
|
381
|
+
case "WHERE":
|
|
382
|
+
return { mode: mode.kind === "update" ? { ...mode, phase: "where" } : mode };
|
|
383
|
+
case "OR":
|
|
384
|
+
case "NOT":
|
|
385
|
+
return { mode: mode.kind === "update" && mode.phase === "where" ? { ...mode, loose: true } : mode };
|
|
386
|
+
default:
|
|
387
|
+
return { mode };
|
|
260
388
|
}
|
|
261
389
|
}
|
|
262
390
|
|
|
391
|
+
/** Whether `field = <value>` or `field IN (` names a from-state. */
|
|
392
|
+
function namesFromState(next: Token | undefined, value: Token | undefined): boolean {
|
|
393
|
+
if (next?.text === "IN" || next?.text === "in") return value?.text === "(";
|
|
394
|
+
if (next?.text !== "=" || value === undefined) return false;
|
|
395
|
+
return value.kind === "literal" || value.text === "?" || value.text === ":" || value.text === "@" || /^\$\w+$/.test(value.text);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* SQL facts about status fields, found token by token with no backtracking:
|
|
400
|
+
* comparisons after `WHERE`/`AND`/`OR`/`ON`/`WHEN` or inside a `SELECT` or
|
|
401
|
+
* `DELETE` are reads, not writes, and an `UPDATE ... SET status = 'x'` is
|
|
402
|
+
* guarded when its own `WHERE`, with no `OR`, `NOT` or subquery, names the same field's
|
|
403
|
+
* from-state (`status = 'y'`, `= ?`, `= $1`, `= :from`, `IN (...)`). Such
|
|
404
|
+
* updates make their module the transition table, which the skill leaves alone.
|
|
405
|
+
*/
|
|
406
|
+
function sqlStatusFacts(lines: string[]): SqlFacts {
|
|
407
|
+
const facts: SqlFacts = { comparisons: new Set(), guarded: new Set() };
|
|
408
|
+
const finish = (update: UpdateMode) => {
|
|
409
|
+
if (update.loose) return;
|
|
410
|
+
for (const write of update.writes) if (update.guards.has(write.field)) facts.guarded.add(write.key);
|
|
411
|
+
};
|
|
412
|
+
const tokens = sqlTokens(lines);
|
|
413
|
+
let mode: SqlMode = CODE;
|
|
414
|
+
tokens.forEach((token, index) => {
|
|
415
|
+
const step = sqlStep(mode, token);
|
|
416
|
+
if (step.ended) finish(step.ended);
|
|
417
|
+
mode = step.mode;
|
|
418
|
+
if (token.kind !== "word") return;
|
|
419
|
+
const field = token.text.slice(token.text.lastIndexOf(".") + 1);
|
|
420
|
+
if (!STATUS.test(field)) return;
|
|
421
|
+
const [next, value] = [tokens[index + 1], tokens[index + 2]];
|
|
422
|
+
const key = `${token.line}:${token.end}`;
|
|
423
|
+
const assignsLiteral = next?.text === "=" && value?.kind === "literal";
|
|
424
|
+
const inWhere = mode.kind === "update" && mode.phase === "where";
|
|
425
|
+
if (mode.kind === "reading" || inWhere || followsComparisonKeyword(tokens, index)) {
|
|
426
|
+
if (assignsLiteral) facts.comparisons.add(key);
|
|
427
|
+
if (mode.kind === "update" && inWhere && namesFromState(next, value)) mode.guards.add(field);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (mode.kind === "update" && mode.phase === "set" && assignsLiteral) mode.writes.push({ field, key });
|
|
431
|
+
});
|
|
432
|
+
if (mode.kind === "update") finish(mode);
|
|
433
|
+
return facts;
|
|
434
|
+
}
|
|
435
|
+
|
|
263
436
|
/** Tcl's `set name value`: a flag when the value is boolean, otherwise a status. */
|
|
264
437
|
function noteTclWrites(line: string, at: number, flags: Writes, statuses: Writes): void {
|
|
265
438
|
const flag = TCL_FLAG.exec(line);
|
|
@@ -286,7 +459,8 @@ function signalsFrom(flags: Writes, statuses: Writes): Signal[] {
|
|
|
286
459
|
});
|
|
287
460
|
}
|
|
288
461
|
for (const [name, entry] of statuses) {
|
|
289
|
-
|
|
462
|
+
// A field written only by guarded SQL updates already has its transition table.
|
|
463
|
+
if (entry.unguarded && entry.values.size >= 3 && entry.lines.size >= 3) {
|
|
290
464
|
signals.push({ kind: "status", names: [name], values: [...entry.values].sort(), lines: [...entry.lines].sort((a, b) => a - b) });
|
|
291
465
|
}
|
|
292
466
|
}
|