Skip to content

feat(cli): S9a deterministic CLI router, shared envelope, and core git/rev - #163

Merged
BrainerVirus merged 11 commits into
mainfrom
feature/cli-router
Oct 3, 2026
Merged

BrainerVirus merged 11 commits into
mainfrom
feature/cli-router

Conversation

@BrainerVirus

@BrainerVirus BrainerVirus commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

What

Slice S9a (feature/cli-router) from docs/workit-next/design.md §2.0 / §5.

  • Router: packages/workit-cli/src/main.ts is the new lean entry. It parses the global flags (--json, --cwd <dir>, --version, help [verb]), looks the verb up in verbs/registry.ts and lazily imports verbs/<verb>.ts (run(argv, io): Promise<number>).
  • Envelope and exit codes (src/output.ts): {"ok","code","data","error"?,"unblock"?} with 0 ok · 1 failed · 2 usage · 3 blocked · 4 busy/pending · 5 unavailable. Unknown commands return invalid_input (exit 2) with unblock: "workit help".
  • Stubs for S9b–S13: check, pr, ci, git, verify-delivery, stack, ledger and bare handoff are registered against stub modules. They return {"code":"not_implemented","data":{verb,subcommand,slice}} with exit 2. Later slices replace only their own verbs/<verb>.ts, and the registry stays as it is.
  • Lazy ink: index.tsx now holds only the init/uninstall wizards. Only verbs/init.ts and verbs/uninstall.ts load it, through import(). The logger moved to diagnostics.ts, and doctor moved to verbs/doctor.ts.
  • Existing commands keep working: init, upgrade, launch, doctor, uninstall, cutover, action (the payload reference moved to workit action --help, so help no longer loads the core barrel), handoff --task <id>, and the eight task families, which still route through task.ts.
  • core/src/git/rev.ts (no dependencies; no store, config or zod):
    • headSha
    • worktreeTree: a temporary GIT_INDEX_FILE seeded from the real index, then add -A and write-tree. The real index, HEAD and refs are never touched.
    • mergeBase
    • patchId: diff options are pinned so user config cannot change the id.
    • remoteTip
    • pushRemoteName: git's precedence (branch.<b>.pushRemote → remote.pushDefault → branch.<b>.remote → origin → the only remote).
    • pushUrl
    • parseRemoteUrl
    • readSshConfig / resolveSshHost: Host patterns with *, ? and !, %h, first value wins, Include expanded, Match skipped.
    • 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 a github./gitlab. host name.
    • forgeConflict: a disagreeing workspace provider returns blocked with an unblock hint.
  • Build entry → src/main.ts. Knip entry → src/main.ts. tsconfig now includes workit-cli/src/**/*.ts. The reachability guard's CLI entry → main.ts.

Why

Acceptance (design §5, S9a)

Given/When/Then Test
G any verb, T ink/react are not imported (module-graph test) test/workit-cli/router.test.ts: "given any verb, then ink/react are not statically imported (module graph)". It bundles main.ts with a metafile and walks only static edges from every registered verb module. It also asserts the router's own static graph has no node_modules and no workit-core. I checked it fails when verbs/doctor.ts statically imports ../index.
… and workit --help lists the verbs "given workit --help, then it lists every registered verb"
G a dirty worktree, T worktreeTree changes when a file changes, and HEAD and the refs stay untouched test/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.
G a rebase that changes only the base, T patchId is equal git-rev.test.ts. The id changes after a content change.
Forge derivation, including the github-work.com SSH alias and GitLab git-rev.test.ts: alias → github/github.com; an unmapped alias → null (no guessing); GitLab public with subgroups, altssh:443, a self-hosted gitlab. host and a configured host; a GHE host with a port; pushForge with a fetch URL on GitLab and a push URL on the github-work.com alias → GitHub; forgeConflict → blocked.

Envelope, exit codes, stubs, unknown verb, global-flag parsing, the family list vs OPERATION_FAMILIES, and task list --cwd through the router are covered in router.test.ts.

Measured cold start (Node 24.20.0, built dist/index.js, median of 25 runs after 3 warm-ups)

command before (main) after
workit --version 211 ms 106 ms
workit help 216 ms 105 ms
workit task list --json 232 ms 189 ms

On main, --version did not exist and printed the help.

The dist stays one non-splitting file, as PT-10 and the package-contents test require. I also measured bun build --splitting: --version takes 31 ms and task list 154 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

  • M1: workit --json <verb> used to fail with "unknown command --json". 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 --. Tests cover both positions, including --json --version and --json help. Measured: --json --version → exit 0 with the envelope.
  • M2: remote URLs are never echoed raw. redactRemote drops userinfo, query and fragment, and anything unparseable prints as <unparseable remote>. pushForge reports the redacted URL. Tests run token-bearing https, git+https, ftp, query-token, ssh://user:pw and user:tok@host:path URLs through pushForge and remoteTip.
  • M3: every git call now has a timeout (GIT_TIMEOUTS, overridable per call).
    • Network calls also set GIT_TERMINAL_PROMPT=0 and GCM_INTERACTIVE=never, and append -o BatchMode=yes -o ConnectTimeout=N to an existing GIT_SSH_COMMAND or core.sshCommand.
    • remoteTip returns {ok:false, code:"unavailable"} instead of hanging.
    • Measured against unroutable 10.255.255.1: 10.0 s with the defaults (ssh ConnectTimeout), 3.0 s with timeoutMs: 3000. The reviewer measured more than 25 s before this fix.

Smaller fixes

  • L1: patchId pins -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).
  • L2/L3: an Include is 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 the Include. Aliases keep their case: patterns match case-insensitively and %h keeps the spelling.
  • L4: worktreeTree records untracked files over 5 MB (configurable) by path, size and mtime in key instead of hashing them, and returns them as skipped.
  • L5: new tests for exit codes 1, 2, 3, 4 and 5, and for "trunk moved, branch not rebased" (this rules out a .. diff).
  • L6: stub verbs carry planned in the registry and are left out of help. help <verb> marks them "(coming in )". A slice now deletes its entry's planned line when it lands.
  • L7: an explicit --cwd beats an inherited WORKFLOW_WORKSPACE_ROOT, with a test.

Test hygiene: packWorkspacePackages leaked one wk-pack-tarballs-* dir per pack. Tarball dirs are now removed at the end of the run: a global afterAll preload (test/shared/temp-cleanup.ts) under bun test, which does not emit process exit, and an exit/signal hook for scripts. test/artifacts/pack-cleanup.test.ts checks that both a script and a bun test run leave no wk-pack-* dirs. I confirmed it fails without the cleanup. Measured: 0 new wk-pack-* dirs after a full test + test:packaging run.

Cold start after these fixes: --version 99 ms, help 101 ms.

Merged origin/main (#150 lock reclaim, #161 S8a hooks): #150's workit doctor --fix-lock [--force [--yes]] is ported from the old index.tsx dispatcher into verbs/doctor.ts. The CLI's 2 s setDefaultLockTimeout moves into the router's process setup. #150's doctor tests pass through main.ts.

Verification after the merge: lint, format:check, typecheck and knip ✓ (65 core files reachable) · bun run test 1264 pass · bun run test:packaging 249 pass · CI green on all four jobs.

Windows note: when remoteTip times 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

  1. 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 /F on Windows). Helpers left over after a normal exit are also killed. HTTPS gets -c http.lowSpeedLimit=1 -c http.lowSpeedTime=<budget>. Tests (POSIX):

    • a hanging fake ssh: its pid is dead after the timeout;
    • a stalled HTTPS server: no process keeps the token in its argv.
  2. The network safety settings are tested. networkGitInvocation is exported and its env and args are asserted directly. On top of GIT_TERMINAL_PROMPT=0 it also sets empty GIT_ASKPASS/SSH_ASKPASS and SSH_ASKPASS_REQUIRE=never. Behaviour tests:

    • a fake ssh that blocks unless both BatchMode and ConnectTimeout are set;
    • a fake askpass that would block against an HTTP 401 server.

    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.

  3. Pure JSON on stdout under --json.

    • The router buffers stdout. Non-JSON output from older verbs moves to stderr and is replaced by an envelope that keeps the verb's exit code.
    • doctor --fix-lock --force without --yes now returns a blocked envelope with unblock and the lock in data. It exits 3 in both output modes; it used to exit 1.
    • init and uninstall with --json return invalid_input.
    • action --help --json returns an ok envelope.
    • A test runs every registered verb's error paths with --json before and after the verb and requires stdout to be one JSON document. The test's table must cover the whole registry.
  4. ext::/fd:: remotes → <unparseable remote>.

Notes / deviations

  • remoteTip now returns a typed result ({ok, sha} | {ok:false, code}) instead of string|null. worktreeTree returns {tree, key, dirty, skipped}, and freshness should compare key.
  • not_implemented is added to the envelope codes (exit 2). The task asked for a structured not_implemented, and design §2.0 says stubs exit 2. not_found maps to exit 1, which the design does not specify.
  • Reachability: rev.ts has no runtime consumer until S9b/S10/S13. scripts/check-reachability.ts gets an AWAITING_CONSUMER entry for it, to be deleted when the first consumer lands.
  • Behaviour change: an unknown subcommand now exits 2 with invalid_input. Before, it printed the help and exited 0. Bare workit handoff without --task now returns not_implemented (S13) instead of the old usage error. Both exit 2.
  • workit gc is not on main (it lives on bugfix/bounded-recovery), so nothing is routed for it. When it lands it needs a one-line registry entry.
  • I did not touch core/hooks or the adapters (feat(core): shared host-hook protocol (S8a) #161 is open in parallel).

🤖 Generated with Claude Code

BrainerVirus and others added 9 commits October 3, 2026 17:38
…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>
BrainerVirus and others added 2 commits October 3, 2026 19:10
… 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
BrainerVirus merged commit f09b15b into main Oct 3, 2026
5 checks passed
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>
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant