Skip to content

feat(uncheck): implement the CLI with agent stop hooks and dogfood it - #1

Merged
dinwwwh merged 10 commits into
mainfrom
claude/packages-uncheck-cli-v4-cfb7c9
Sep 21, 2026
Merged

dinwwwh merged 10 commits into
mainfrom
claude/packages-uncheck-cli-v4-cfb7c9

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

uncheck is now a working CLI: one command that runs oxlint, oxfmt --check and tsc, each only when the project uses it, with --fix to apply lint fixes and formatting. Paths and globs are resolved by uncheck itself into one file list that every tool receives, so tools never disagree on what a directory or pattern means, and tsc runs only the projects whose files/include/exclude (with extends) take those files. Agent hooks run once per turn and send the agent back to fix what remains. The repo itself now runs uncheck instead of eslint and is on TypeScript 7 and Node 22.

Checks

  • Every tsconfig is discovered through git ls-files, references are followed to build the graph, roots are built with tsc -b so references come first, standalone projects run tsc -p afterwards, and cycles are reported. Config parsing is independent of the TypeScript version, since TypeScript 7 ships no compiler API.
  • A path that matches nothing fails the run; --no-error-on-unmatched-pattern runs with whatever matched. Long file lists are batched under the platform argument limit.
  • --require=<check> and --skip=<check> (repeatable) override auto-detection: a required check fails instead of being skipped when its tool is missing, and a check that applies but is broken (circular references, a tsconfig without typescript) always fails. --cwd runs in another directory.

Agent hooks

  • uncheck hooks install [claude|codebuddy|cursor|windsurf|copilot] writes or merges each agent's stop-event config (interactive multi-select on a TTY).
  • uncheck hooks run --fix runs every check on the files changed since the last commit, reports on stderr, and sends the agent back at most once per turn: exit code 2 for Claude Code and CodeBuddy, a follow-up message for Cursor, a block decision for Copilot; Windsurf sees the report. Running per turn rather than per edit keeps typechecks to one per turn.

Structure

  • Each check is a plain object under checks/ on a Check contract: its plan yields the commands to run, or fails with NothingToCheck or CannotCheck, and the uncheck command decides between skipped and failed. Adding a tool is one file plus a registry entry.
  • The uncheck command owns its flags, the check registry and the run loop; hooks run reuses them, and the entry attaches hooks as a subcommand, so no root module knows about flags. Root modules are types, errors, style, tool (bin lookup, argv batching, process execution) and files.
  • Every effectful function is an Effect.fn, there are no casts, and the package is CLI-only: no library entry or exports, just the bin.
  • Output goes through Effect's Console with child processes always piped, following the Effect CLI guide; hook mode and tests capture it by swapping the Console service.

Repo

  • eslint removed; root pnpm check / check:fix, lint-staged and CI use uncheck, and prepare stubs the bin so it always runs the current source.
  • TypeScript 7 with @typescript/typescript6 for unbuild's d.ts step; engines.node is >=22.20 <23 || >=24.8, the releases where path.matchesGlob is stable, and the CI matrix drops Node 20.

Testing

  • 29 tests: the tsc check is driven through its plan on fixture projects (reference graphs and cycles, include/exclude/files matching, extends through packages and ${configDir}, allowJs, defaults), path resolution (files, directories, globs, negations, unmatched, ignored), plus integration tests that run the real tools in temp fixtures (fix mode, references, path scoping, --require/--skip, git discovery, hook install and merge, stop-hook feedback per agent family).
  • pnpm check passes on the repo; the built bin and the stop hook were exercised end to end.

One `uncheck` command runs oxlint, oxfmt --check and tsc, each only when
the project uses it, with --fix, per-tool --oxlint/--oxfmt/--tsc switches
and paths forwarded to oxlint and oxfmt while tsc runs only the projects
whose files/include/exclude take them. tsconfig references are built with
tsc -b first, standalone projects are checked with tsc -p afterwards.

`uncheck hooks` writes Claude Code, CodeBuddy, Cursor, Windsurf and
Copilot hook configs, and `--hook` checks the files an agent just edited
and hands remaining problems back to it.

The repo now runs uncheck instead of eslint and is on TypeScript 7.
@pkg-pr-new

pkg-pr-new Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/uncheck@1

commit: 3d9af7f

@codecov

codecov Bot commented Sep 20, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

ℹ️ You can also turn on project coverage checks and project coverage reporting on Pull Request comment

Thanks for integrating Codecov - We've got you covered ☂️

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Two issues block the advertised behavior: the GitHub Copilot hook never finds the edited file (its payload carries toolArgs as a JSON string), and path-scoped typecheck skips projects referenced under a name other than tsconfig.json — which includes every --hook run in a solution-style repo. Details inline.

Reviewed changes

  • CLI spine — command.ts/bin.ts plan and run oxlint, oxfmt --check and tsc, with per-step --<tool> auto/require/skip flags, forwarded paths, merged outcomes and exit code.
  • Typecheck planning — typecheck.ts/tsconfig.ts discover configs through git ls-files, follow references, compile tsc-style files/include/exclude matching and split tsc -b roots from tsc -p standalone projects.
  • Agent hooks — hooks.ts writes/merges five agents' hook configs; uncheck --hook checks only edited files and returns additionalContext while exiting 0.
  • Repo dogfooding — eslint removed, pnpm check/check:fix and lint-staged/CI now use uncheck, prepare stubs the bin, TypeScript 7.
  • Tests — 31 unit and integration tests, including real-tool fixtures in command.test.ts.

ℹ️ Removed ESLint still referenced by Dependabot

.github/dependabot.yml still defines an eslint group and excludes eslint, @antfu/eslint-config and eslint-plugin-* from the minor/patch group, but this PR removes every ESLint dependency. The group now matches nothing and the exclusion is dead config. Low impact, but it is part of the cleanup the rest of this PR does.

Technical details
# Dependabot config references removed ESLint packages

## Affected sites
- `.github/dependabot.yml:13-17` — `eslint` group patterns `eslint`, `@antfu/eslint-config`, `eslint-plugin-*`.
- `.github/dependabot.yml:20-23` — `exclude-patterns` listing the same packages.
- `package.json` — no ESLint dependency remains.

## Required outcome
Dependabot's grouping reflects the dependencies the repo actually has.

## Suggested approach (optional)
Drop the `eslint` group and remove the ESLint patterns from `exclude-patterns`; group `oxlint`/`oxfmt` if grouping is still wanted.

ℹ️ Nitpicks

  • scripts/sync-sponsors.ts:206 — the comment still says the blank line is removed by "eslint's markdown fixer", but this PR switched the follow-up command to pnpm check:fix.
  • typecheck.ts:104 and :128 — namesUncheckableExtension treats a directory whose last segment contains a dot (src/foo.bar) as a file and drops it; even unfiltered, path.extname(target) routes it to includesFile, so such a directory can never select a project.
  • hooks.ts:191 — installHook treats any occurrence of the substring uncheck as "already installed", so an unrelated mention (a permission, another hook, the package name) suppresses the merge and a stale command is never refreshed.
  • command.ts:163 / packages/uncheck/README.md:71 — the comment and README say only Claude Code and CodeBuddy surface the returned context; GitHub Copilot's postToolUse also honors additionalContext per the hooks reference.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏

Comment thread packages/uncheck/src/hooks.ts Outdated
Comment thread packages/uncheck/src/typecheck.ts Outdated
return []
}

const selected = yield* Effect.filter(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selectTsconfigs only considers discovered tsconfig.json files, so a path never selects a project whose config is referenced under another name — the very common tsconfig.json → tsconfig.app.json/tsconfig.node.json solution layout. With a path, tsc is silently skipped for those projects, and --hook always passes paths (command.ts:213), so the hook never typechecks them.

Technical details
# Path scoping ignores referenced configs not named `tsconfig.json`

## Affected sites
- `packages/uncheck/src/typecheck.ts:110-119` — `selectTsconfigs` filters only `entries`.
- `packages/uncheck/src/typecheck.ts:226` — discovery keeps only `path.basename(relative) === 'tsconfig.json'`, so `tsconfig.app.json` is never an entry.
- `packages/uncheck/src/command.ts:290-294` — no selected project ⇒ `tsc` reported skipped, not failed; the run can end with all-checks-passed while the edited file was never typechecked.
- `packages/uncheck/README.md:56` — a file is documented to select the projects whose inputs take it; for a solution root (`files: []`) that is only true of `tsconfig.json` itself.

## Required outcome
A path inside a referenced project's inputs selects and checks that project in both normal and hook mode.

## Suggested approach (optional)
Resolve selection over the full project graph: load the reference closure of the discovered entries and match each path against every config in it, not just the entries. (Discovering `tsconfig*.json` alone would also surface base configs that are not real projects.)

## Open questions for the human (optional)
Should a selected project that is referenced by a non-selected project also surface the referencing project's reference-integrity errors (missing/composite)? `planTypecheck` loads forward references only, which is a defensible product choice but differs from the README wording that referenced projects are built with `tsc -b`.

Comment thread packages/uncheck/src/tsconfig.ts Outdated
Paths and globs are resolved by uncheck into one file list that every
tool receives, so tools never disagree on what a pattern means; a
pattern matching nothing fails unless --no-error-on-unmatched-pattern
is given. Matching uses Node's path.matchesGlob, which sets the floor
at Node 22.

Each tool is its own module under steps/ (oxlint, oxfmt, tsc) built on
one Step contract, registered in checks.ts. Output goes through Effect's
Console with children always piped, per the Effect CLI guide.

Agent hooks now run once per turn instead of after every edit:
`uncheck hooks install` writes stop-event configs and `uncheck hooks run`
checks the working-tree changes with every step, then sends the agent
back at most once (exit 2 for Claude Code and CodeBuddy, follow-up
message for Cursor, block decision for Copilot).
@dinwwwh dinwwwh changed the title feat(uncheck): implement the CLI with agent hooks and dogfood it feat(uncheck): implement the CLI with agent stop hooks and dogfood it Sep 21, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Two correctness issues in the new path resolver: path.matchesGlob is unavailable on the Node versions engines.node allows, and literal bracket path segments (Next.js dynamic routes) are classified as globs and dropped. Details inline. The prior Copilot toolArgs and coversDirectory concerns are addressed; the referenced-configs selection concern from the first review still stands and its thread is left open.

Reviewed changes

  • Replaced the per-edit --hook with a per-turn stop-hook command tree (hooks install / hooks run), with per-family continue/block feedback and a once-per-turn loop guard.
  • Added resolvePaths: paths, directories, globs and !pattern exclusions now resolve to one concrete file list every step receives, with --no-error-on-unmatched-pattern.
  • Split the CLI into checks.ts, step.ts, resolve.ts and steps/, removing tools.ts, typecheck.ts, tsconfig.ts, ui.ts and extractHookPaths.
  • Reworked tsc to consume resolved files and select projects through includesFile only, dropping coversDirectory.
  • Routed output through Effect's Console and centralized color detection, so hook mode and tests capture output by swapping the service.
  • Repo/CI dogfood: ESLint config and dependencies removed, CI matrix narrowed to Node 26/24/22.

ℹ️ Hook tests encode assumed payload shapes

hooks.test.ts and the hook-mode tests in command.test.ts feed payloads authored to match stopAgent/stopFeedback, so they cannot catch a wrong contract — exactly how the previous Copilot toolArgs bug shipped with green CI. Capturing one real stop payload per agent as a fixture would turn those tests into contract checks.

Technical details
# Agent stop-hook tests are self-referential

## Affected sites
- `packages/uncheck/src/hooks.test.ts:5-9` — asserts `hook_event_name: 'Stop'` / `'stop'` and `stopReason` from hand-written objects.
- `packages/uncheck/src/command.test.ts:409-455` — same, plus `loop_count`/`stop_hook_active` shapes.
- `packages/uncheck/src/hooks.ts:156-189` — `stopAgent`/`stopFeedback` are only as correct as those assumptions.

## Required outcome
Tests fail when a vendor changes the stop event name, the loop-count field, or the continue output, rather than only when the implementation changes.

## Suggested approach (optional)
Record a real `stop` / `Stop` / `agentStop` payload from each agent into a fixture and feed it verbatim. Cursor is the one to capture first: its docs list `hook_event_name` in the common input but never show the `stop` value.

## Open questions for the human (optional)
The GitHub Copilot `agentStop` hook file must live on the default branch to reach the cloud agent — is committing it part of the intended setup flow?

ℹ️ Nitpicks

  • hooks.ts:38 — show_output: true is documented not to apply to post_cascade_response; either drop it or soften the README:73 claim that Windsurf "shows the report".
  • files.ts:81 — glob matching runs with dotfiles off, so uncheck '**/*.ts' skips dotfiles while directory expansion via git ls-files (uncheck .) includes them; worth documenting the difference.
  • checks.ts:78 — when every match is removed by !pattern, files and unmatched are both empty and the run exits 0 with "nothing to check", which reads against README:46 ("A path that matches nothing fails the run").

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏

Comment thread packages/uncheck/package.json Outdated
Comment thread packages/uncheck/src/files.ts Outdated
}

/** Characters that make a pattern a glob rather than a plain path. */
const GLOB_CHARACTERS = /[*?[\]{}()]/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GLOB_CHARACTERS includes [ and ], so a literal path containing them is treated as a glob and never reaches the fs.stat branch. posix.matchesGlob('app/[id]/page.tsx', 'app/[id]/page.tsx') is false — [id] parses as a character class — so uncheck 'app/[id]/page.tsx' fails with "No files match", and hooks run (where allowUnmatched: true) silently drops the changed file.

Technical details
# Bracket path segments are classified as globs and match nothing

## Affected sites
- `packages/uncheck/src/files.ts:137` — `GLOB_CHARACTERS = /[*?[\]{}()]/` includes `[`/`]`.
- `packages/uncheck/src/files.ts:81-83` — the glob branch runs before `fs.stat`, so an existing `app/[id]/page.tsx` is never recognized as a file.
- `packages/uncheck/src/hooks.ts:119` — hook mode passes `allowUnmatched: true`, so the drop is silent.

## Required outcome
A path that names an existing file or directory is checked even when its name contains `[`, `]` or another glob metacharacter.

## Suggested approach (optional)
Probe `fs.stat` first and fall through to glob matching only when the path does not exist; or narrow `GLOB_CHARACTERS`, noting `[`/`]` are the only characters in the set that break a literal self-match (`*`, `?`, `{`, `}`, `(`, `)` were tested and are safe).

Comment thread packages/uncheck/src/hooks.ts Outdated
return 'claude'
}

if (payload.hook_event_name === 'stop') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor is classified by hook_event_name === 'stop'. Cursor's docs list hook_event_name in the common hook input but never show its value for stop (only "workspaceOpen"), and at least one field report says newer Cursor builds send { status, loop_count } without it. If the field is absent, stopAgent returns undefined and Cursor never receives a followup_message — a silently dead hook; if the value is the capitalized "Stop", it is misclassified as Claude and exits 2 instead. Worth confirming against a captured real payload.

…-skip

Checks are plain objects that yield their commands or fail with
NothingToCheck (skipped, or failed under --require) or CannotCheck
(always failed); the runner decides the outcome. Per-check flags are
replaced by repeatable --require and --skip, and --cwd sets the
directory to run in.

Every function that wrapped Effect.gen is an Effect.fn, tiny helpers
are inlined, casts are gone, and JSON is narrowed by hand. The package
is CLI-only: no index entry or exports, only the bin.

Layout: types.ts, errors.ts, style.ts, tool.ts, files.ts, run.ts,
checks/{oxlint,oxfmt,tsc}.ts, commands/{flags,uncheck}.ts and
commands/hooks/. Tests live in tests/ and drive the tsc check through
its plan instead of internals.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one minor output regression inline.

Reviewed changes

  • CLI reorganized — command.ts/checks.ts/step.ts/steps/ became commands/{uncheck,hooks,flags}.ts and checks/{oxlint,oxfmt,tsc}.ts over a plain-object Check contract, and src/index.ts plus the package exports were removed, leaving a bin-only package.
  • Check flags replaced — the per-tool boolean flags became repeatable --require=<check> / --skip=<check> (with a contradiction error), and a new --cwd flag runs in another directory.
  • Stop hooks inlined — stopAgent/stopFeedback moved into commands/hooks/run.ts; hooks.test.ts was deleted and the suite moved to packages/uncheck/tests/.
  • Shared helpers moved — ancestors/readJson now live in files.ts and argument batching in tool.ts; the run log no longer hides the internal --no-error-on-unmatched-pattern flag (see inline).

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏

Comment thread packages/uncheck/src/run.ts Outdated
const { bin, args, files } = invocation
const tail = files === undefined ? [] : files.length <= 3 ? files : [`[${files.length} files]`]

return Console.log(`${dim('▶')} ${bold(bin.name)} ${dim([...args, ...tail].join(' '))}`.trimEnd()).pipe(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refactor dropped the describeArgs helper that filtered the internal --no-error-on-unmatched-pattern flag out of the run log, so every path-scoped invocation now prints it (the integration tests were updated to expect it). This is user-facing output — consider filtering the flag here so the log shows only flags the user actually passed.

The uncheck command owns its flags, check registry and run loop; the
hooks commands reuse them from there and the entry attaches hooks as a
subcommand, so no root module knows about flags. Process execution
moves to tool.ts next to bin resolution.
Like middleapi/orpc: the package's `prepare` stubs dist with jiti and
`prepack` runs the real unbuild, so there is no `build` script and the
root `prepare` only installs git hooks. Runtime dependencies become
devDependencies and are inlined, so the published bin is self-contained.
@dinwwwh
dinwwwh force-pushed the claude/packages-uncheck-cli-v4-cfb7c9 branch from 6658777 to fdd390d Compare September 21, 2026 08:53
@dinwwwh
dinwwwh merged commit d5f7466 into main Sep 21, 2026
6 checks passed
@dinwwwh
dinwwwh deleted the claude/packages-uncheck-cli-v4-cfb7c9 branch September 21, 2026 09:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant