feat(cli): S9a deterministic CLI router, shared envelope, and core git/rev - #163
Merged
Merged
Conversation
…t codes
main.ts becomes the lean `workit` entry: it parses the global flags
(--json, --cwd), looks the verb up in verbs/registry.ts and lazily
imports its module (`run(argv, io)`). --version and help load nothing
but the router; only init/uninstall import index.tsx, which now holds
just the ink wizards. The S9b-S13 verbs (check, pr, ci, git,
verify-delivery, stack, ledger, handoff) are pre-registered against stub
modules that answer a structured not_implemented (exit 2), so later
slices replace only their own file. Existing commands keep working:
init, upgrade, launch, doctor, uninstall, cutover, action, handoff
--task and the eight task families.
output.ts carries the design §2.0 envelope
({ok, code, data, error?, unblock?}) and exit-code table (0 ok, 1
failed, 2 usage, 3 blocked, 4 busy/pending, 5 unavailable).
The dist stays one nonsplitting file (PT-10); cold start of
`workit --version`/`help` under Node 24 drops from ~210 ms to ~105 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ivation core/src/git/rev.ts is a dependency-free seam for the delivery verbs: headSha, worktreeTree (temp GIT_INDEX_FILE + add -A + write-tree, so the real index, HEAD and refs stay untouched), mergeBase, patchId (pinned diff options; equal across a base-only rebase) and remoteTip. It also derives the forge from the push remote host (D16): git's push remote precedence, the single push URL, SSH Host aliases resolved through ~/.ssh/config (Include expanded, first value wins), known public hosts and their SSH-over-443 endpoints, configured enterprise hosts, then a github./gitlab. host name. forgeConflict turns a disagreeing workspace provider into blocked with an unblock hint. The reachability guard gets an AWAITING_CONSUMER entry for rev.ts until its first consumers (S9b check, S10 pr, S13 ledger) land. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…from help - `workit --json <verb>` failed as "unknown command --json" for every command. Global flags before the command are now consumed. A --json given before the verb is passed on to verbs that parse it themselves, ahead of any `--`. - An explicit --cwd now beats an inherited WORKFLOW_WORKSPACE_ROOT. - Stub verbs carry `planned: <slice>` in the registry, so `workit help` does not advertise not_implemented commands. `workit help <verb>` marks them "(coming in <slice>)". - Tests: --json before and after the verb, the envelope-to-exit-code table (including blocked=3, unavailable=5) and --cwd vs env. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cs in git/rev - Every git call has a timeout (GIT_TIMEOUTS, overridable per call). Network calls also set GIT_TERMINAL_PROMPT=0 and GCM_INTERACTIVE=never, and append ssh BatchMode/ConnectTimeout options (to an existing GIT_SSH_COMMAND or core.sshCommand if there is one). remoteTip returns a typed result: an unreachable host is `unavailable`, never a hang. - Remote URLs are never echoed raw. redactRemote drops userinfo, query and fragment, and anything unparseable prints as `<unparseable remote>`. pushForge reports the redacted URL. - patchId pins -U3, myers and the indent heuristic. The doc comment notes that a base change within the context lines makes the id stale. - ssh_config Include is applied only inside a matching block (or at top level), the block's state is restored after the Include, and aliases keep their case. Patterns match case-insensitively and %h keeps the spelling. - worktreeTree records untracked files over 5 MB (configurable) by path, size and mtime in `key` instead of hashing them. - Tests: a trunk move without a rebase keeps the patch-id (this rules out a two-dot diff), a timeout against an unroutable host, redaction of token-bearing URLs, Include semantics, case handling and the size cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
packWorkspacePackages left one wk-pack-tarballs-* dir in the OS temp dir per pack. Tarball dirs are now tracked and removed at the end of the run: a global afterAll preload for `bun test`, which does not emit process "exit", and an exit/signal hook for scripts. A pack that fails removes its dir at once. test/artifacts/pack-cleanup.test.ts checks that a script and a `bun test` run, each including a forced repack, leave no wk-pack-* dirs behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ports #150's `workit doctor --fix-lock [--force [--yes]]` from the old index.tsx dispatcher into verbs/doctor.ts. The CLI's 2 s default lock timeout moves into the router's process setup (installDiagnostics). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndows Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows the ssh child that git leaves behind on timeout keeps the temp repo open, so its removal must not fail the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… prompt
- Network git calls run under a small watchdog in the current runtime.
It starts git in its own process group and, on timeout, kills the
group (SIGKILL to -pgid; `taskkill /T /F` on Windows). That includes
git-remote-https, which carries the credential URL in its argv, and
ssh. Leftover helpers are reaped after a normal exit too.
- networkGitInvocation (exported, unit-tested) builds the invocation:
GIT_TERMINAL_PROMPT=0, empty GIT_ASKPASS/SSH_ASKPASS,
SSH_ASKPASS_REQUIRE=never, GCM_INTERACTIVE=never, ssh
BatchMode/ConnectTimeout, and `-c http.lowSpeedLimit=1 -c
http.lowSpeedTime=<budget>`.
- Behaviour tests (POSIX):
- a fake ssh that blocks unless BatchMode and ConnectTimeout are set;
- a fake askpass that would block against an HTTP 401 server;
- a hanging ssh whose pid must be dead after the timeout;
- a stalled HTTPS server where no process with the token in its argv
may survive.
Mutants that drop the prompt, askpass, BatchMode, ConnectTimeout,
lowSpeed, timer or group-kill settings now fail.
- redactRemote/parseRemoteUrl reject git's `<transport>::<address>`
form (ext::, fd::).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Under --json the router buffers stdout. A JSON document passes through unchanged. Plain text that a verb predating the envelope still prints (usage lines, confirmations) moves to stderr and is replaced by an envelope that keeps the verb's exit code. - `doctor --fix-lock --force` without --yes now refuses with a blocked envelope (`unblock: workit doctor --fix-lock --force --yes`, lock details in data), and the holder line and prompt go to stderr. The refusal exits 3 (blocked) in both output modes; it was 1 before. - init/uninstall under --json return invalid_input (they are interactive), and `action --help --json` returns the usage and payload reference as an ok envelope. - Test: every registered verb (the table must cover the registry) is run on its error paths with --json both before and after the verb, and stdout must parse as one JSON document. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BrainerVirus
added a commit
that referenced
this pull request
Oct 3, 2026
Take main's index.tsx; `workit gc` moves into the router as verbs/gc.ts with the shared envelope, registered next to doctor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
🎉 This PR is included in version 2.2.0 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Slice S9a (
feature/cli-router) fromdocs/workit-next/design.md§2.0 / §5.packages/workit-cli/src/main.tsis the new lean entry. It parses the global flags (--json,--cwd <dir>,--version,help [verb]), looks the verb up inverbs/registry.tsand lazily importsverbs/<verb>.ts(run(argv, io): Promise<number>).src/output.ts):{"ok","code","data","error"?,"unblock"?}with0 ok · 1 failed · 2 usage · 3 blocked · 4 busy/pending · 5 unavailable. Unknown commands returninvalid_input(exit 2) withunblock: "workit help".check,pr,ci,git,verify-delivery,stack,ledgerand barehandoffare registered against stub modules. They return{"code":"not_implemented","data":{verb,subcommand,slice}}with exit 2. Later slices replace only their ownverbs/<verb>.ts, and the registry stays as it is.index.tsxnow holds only theinit/uninstallwizards. Onlyverbs/init.tsandverbs/uninstall.tsload it, throughimport(). The logger moved todiagnostics.ts, anddoctormoved toverbs/doctor.ts.init,upgrade,launch,doctor,uninstall,cutover,action(the payload reference moved toworkit action --help, so help no longer loads the core barrel),handoff --task <id>, and the eight task families, which still route throughtask.ts.core/src/git/rev.ts(no dependencies; no store, config or zod):headShaworktreeTree: a temporaryGIT_INDEX_FILEseeded from the real index, thenadd -Aandwrite-tree. The real index, HEAD and refs are never touched.mergeBasepatchId: diff options are pinned so user config cannot change the id.remoteTippushRemoteName: git's precedence (branch.<b>.pushRemote→remote.pushDefault→branch.<b>.remote→origin→ the only remote).pushUrlparseRemoteUrlreadSshConfig/resolveSshHost:Hostpatterns with*,?and!,%h, first value wins,Includeexpanded,Matchskipped.deriveForge/pushForge: D16, the forge comes from the push remote host, with SSH aliases resolved. Order: known hosts and their SSH-over-443 endpoints, then configured enterprise hosts, then agithub./gitlab.host name.forgeConflict: a disagreeing workspace provider returnsblockedwith an unblock hint.src/main.ts. Knip entry →src/main.ts. tsconfig now includesworkit-cli/src/**/*.ts. The reachability guard's CLI entry →main.ts.Why
github-work.comaliases), so the forge has to come from the push remote.Acceptance (design §5, S9a)
test/workit-cli/router.test.ts: "given any verb, then ink/react are not statically imported (module graph)". It bundlesmain.tswith a metafile and walks only static edges from every registered verb module. It also asserts the router's own static graph has nonode_modulesand noworkit-core. I checked it fails whenverbs/doctor.tsstatically imports../index.workit --helplists the verbsworktreeTreechanges when a file changes, and HEAD and the refs stay untouchedtest/workit-core/git-rev.test.ts. It also checks that the real index bytes are unchanged, that untracked files are included, that ignored files are excluded, and the unborn-branch case.patchIdis equalgit-rev.test.ts. The id changes after a content change.git-rev.test.ts: alias →github/github.com; an unmapped alias → null (no guessing); GitLab public with subgroups, altssh:443, a self-hostedgitlab.host and a configured host; a GHE host with a port;pushForgewith a fetch URL on GitLab and a push URL on thegithub-work.comalias → GitHub;forgeConflict→ blocked.Envelope, exit codes, stubs, unknown verb, global-flag parsing, the family list vs
OPERATION_FAMILIES, andtask list --cwdthrough the router are covered inrouter.test.ts.Measured cold start (Node 24.20.0, built
dist/index.js, median of 25 runs after 3 warm-ups)workit --versionworkit helpworkit task list --jsonOn main,
--versiondid not exist and printed the help.The dist stays one non-splitting file, as PT-10 and the
package-contentstest require. I also measuredbun build --splitting:--versiontakes 31 ms andtask list154 ms. That would need PT-10 amended, so I left it out of this PR.Verification (Node 24.20.0)
bun run lint✓ ·format:check✓ ·typecheck✓ ·bun run knip✓ (52 core files reachable) ·bun run test✓ (1209 pass) ·bun run test:packaging✓ (241 pass)Review fixes (follow-up commits)
Measured issues
workit --json <verb>used to fail with "unknown command --json". Global flags before the command are now consumed. A--jsongiven before the verb is passed on to verbs that parse it themselves, ahead of any--. Tests cover both positions, including--json --versionand--json help. Measured:--json --version→ exit 0 with the envelope.redactRemotedrops userinfo, query and fragment, and anything unparseable prints as<unparseable remote>.pushForgereports the redacted URL. Tests run token-bearinghttps,git+https,ftp, query-token,ssh://user:pwanduser:tok@host:pathURLs throughpushForgeandremoteTip.GIT_TIMEOUTS, overridable per call).GIT_TERMINAL_PROMPT=0andGCM_INTERACTIVE=never, and append-o BatchMode=yes -o ConnectTimeout=Nto an existingGIT_SSH_COMMANDorcore.sshCommand.remoteTipreturns{ok:false, code:"unavailable"}instead of hanging.timeoutMs: 3000. The reviewer measured more than 25 s before this fix.Smaller fixes
patchIdpins-U3 --diff-algorithm=myers --indent-heuristic. The doc comment notes that a base change within the context lines changes the id, which reads as stale (the safe direction).Includeis applied only inside a matching block (or at top level). Inside an inactive block it never matches, and the block's state is restored after theInclude. Aliases keep their case: patterns match case-insensitively and%hkeeps the spelling.worktreeTreerecords untracked files over 5 MB (configurable) by path, size and mtime inkeyinstead of hashing them, and returns them asskipped...diff).plannedin the registry and are left out ofhelp.help <verb>marks them "(coming in )". A slice now deletes its entry'splannedline when it lands.--cwdbeats an inheritedWORKFLOW_WORKSPACE_ROOT, with a test.Test hygiene:
packWorkspacePackagesleaked onewk-pack-tarballs-*dir per pack. Tarball dirs are now removed at the end of the run: a globalafterAllpreload (test/shared/temp-cleanup.ts) underbun test, which does not emit processexit, and an exit/signal hook for scripts.test/artifacts/pack-cleanup.test.tschecks that both a script and abun testrun leave nowk-pack-*dirs. I confirmed it fails without the cleanup. Measured: 0 newwk-pack-*dirs after a fulltest+test:packagingrun.Cold start after these fixes:
--version99 ms,help101 ms.Merged origin/main (#150 lock reclaim, #161 S8a hooks): #150's
workit doctor --fix-lock [--force [--yes]]is ported from the oldindex.tsxdispatcher intoverbs/doctor.ts. The CLI's 2 ssetDefaultLockTimeoutmoves into the router's process setup. #150's doctor tests pass throughmain.ts.Verification after the merge: lint, format:check, typecheck and knip ✓ (65 core files reachable) ·
bun run test1264 pass ·bun run test:packaging249 pass · CI green on all four jobs.Windows note: when
remoteTiptimes out, git is killed but its ssh child is orphaned. That child keeps the repo dir open until ssh gives up. The test cleans that dir up best-effort. In production the orphan is bounded by ssh's own ConnectTimeout.Re-verification fixes
No orphaned network helpers. Network git calls run under a small watchdog in the current runtime. It starts git in its own process group and, on timeout, kills the whole group (
taskkill /T /Fon Windows). Helpers left over after a normal exit are also killed. HTTPS gets-c http.lowSpeedLimit=1 -c http.lowSpeedTime=<budget>. Tests (POSIX):The network safety settings are tested.
networkGitInvocationis exported and its env and args are asserted directly. On top ofGIT_TERMINAL_PROMPT=0it also sets emptyGIT_ASKPASS/SSH_ASKPASSandSSH_ASKPASS_REQUIRE=never. Behaviour tests:I ran 8 mutants (removing the prompt setting, askpass, BatchMode, ConnectTimeout, lowSpeed, the timer, the kill, or narrowing the group kill to a single pid). The tests kill all 8.
Pure JSON on stdout under
--json.doctor --fix-lock --forcewithout--yesnow returns ablockedenvelope withunblockand the lock indata. It exits 3 in both output modes; it used to exit 1.initanduninstallwith--jsonreturninvalid_input.action --help --jsonreturns an ok envelope.--jsonbefore and after the verb and requires stdout to be one JSON document. The test's table must cover the whole registry.ext::/fd::remotes →<unparseable remote>.Notes / deviations
remoteTipnow returns a typed result ({ok, sha} | {ok:false, code}) instead ofstring|null.worktreeTreereturns{tree, key, dirty, skipped}, and freshness should comparekey.not_implementedis added to the envelope codes (exit 2). The task asked for a structurednot_implemented, and design §2.0 says stubs exit 2.not_foundmaps to exit 1, which the design does not specify.rev.tshas no runtime consumer until S9b/S10/S13.scripts/check-reachability.tsgets anAWAITING_CONSUMERentry for it, to be deleted when the first consumer lands.invalid_input. Before, it printed the help and exited 0. Bareworkit handoffwithout--tasknow returnsnot_implemented(S13) instead of the old usage error. Both exit 2.workit gcis not onmain(it lives onbugfix/bounded-recovery), so nothing is routed for it. When it lands it needs a one-line registry entry.core/hooksor the adapters (feat(core): shared host-hook protocol (S8a) #161 is open in parallel).🤖 Generated with Claude Code