From 5a75e7919263fa7b64f9702848644882cf0bbf92 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 20 Oct 2026 13:25:23 +0100 Subject: [PATCH] docs(agents): sync agent skills and feature maps with 0.15.0 Fix the 32 gaps from the agent-docs audit: the approval.md drive that left the clone memory at two-person, the pull-request.md doctor jq path, the approval-gate review seam, fixture approval notes in verify-venfork, and stale flag, error-code, help and CLAUDE.md text. --- .agents/skills/review-venfork/SKILL.md | 5 ++-- .agents/skills/verify-venfork/SKILL.md | 5 +++- .../references/features/README.md | 6 ++--- .../references/features/approval.md | 23 +++++++++++++++---- .../references/features/clone.md | 2 +- .../references/features/doctor.md | 2 +- .../references/features/help.md | 6 ++--- .../references/features/issue.md | 6 ++--- .../features/multi-command-journeys.md | 2 +- .../references/features/pull-request.md | 4 ++-- .../references/features/schedule.md | 2 +- .../references/features/setup.md | 10 ++++---- .../references/features/stage.md | 14 +++++++---- .github/pull_request_template.md | 2 +- CLAUDE.md | 8 +++---- docs/agents/gotchas.md | 3 +++ 16 files changed, 64 insertions(+), 36 deletions(-) diff --git a/.agents/skills/review-venfork/SKILL.md b/.agents/skills/review-venfork/SKILL.md index 41cde82..27681d5 100644 --- a/.agents/skills/review-venfork/SKILL.md +++ b/.agents/skills/review-venfork/SKILL.md @@ -9,13 +9,14 @@ Split the diff by seam. Start one reviewer per touched seam, each in its own git ## Seams -1. Leak to public/upstream. Files: `src/shared/stage-gate.ts`, `src/shared/deny-list.ts`, `src/shared/redaction.ts`, `src/commands/stage.ts`, `src/shared/preserve-entries.ts`. Invariant: nothing mirror-only reaches public or upstream. Try: stage a branch with a commit that adds a preserved path; put `venfork` or the mirror owner in an author, committer, message or PR title; use mixed-case or unmatched `venfork:internal` markers in a body. +1. Leak to public/upstream. Files: `src/shared/stage-gate.ts`, `src/shared/deny-list.ts`, `src/shared/redaction.ts`, `src/commands/stage.ts`, `src/shared/preserve-entries.ts`, `src/shared/ai-attribution.ts`, `src/shared/approval.ts`. Invariant: nothing mirror-only reaches public or upstream. Try: stage a branch with a commit that adds a preserved path; put `venfork` or the mirror owner in an author, committer, message or PR title; use mixed-case or unmatched `venfork:internal` markers in a body. 2. Data loss on sync. Files: `src/commands/sync.ts`, `src/shared/divergence.ts`, `src/shared/mirror-commit.ts`, `src/shared/managed-commit.ts`. Invariant: sync never drops a non-managed commit, and the managed commit SHA depends only on the upstream tip and tree. Try: add a user commit under a legacy managed subject; move origin between lease read and push; run sync twice and compare SHAs. 3. Config-branch concurrency. Files: `src/config.ts` (`updateVenforkConfig`, lease on `venfork-config`), `src/shared/config-change.ts`, `tests/integration/preserve-concurrency.test.ts`. Invariant: concurrent config writes never lose a patch. Try: copy the test-local `mock.module` wrappers in that test (`beforeConfigWrite`, `beforeConfigChange` around `updateVenforkConfig` and `applyConfigChange`; the product has no such hook) to land a second write mid-update; patch with `null` against a stale read; exhaust the retry loop. -4. Token and secret scope. Files: `src/workflow.ts`, `src/shared/push-token.ts`, `src/commands/doctor.ts` (`token` check), `src/commands/schedule.ts`. Invariant: the generated workflow holds the least privilege and never prints a token. Try: read the YAML for `permissions`, `secrets.*` in logs and fork-triggered events; run doctor with no token and with a read-only token; check that advice text does not suggest a wider scope than needed. +4. Token and secret scope. Files: `src/workflow.ts`, `src/shared/push-token.ts`, `src/commands/doctor.ts` (`token` check), `src/commands/schedule.ts`. Invariant: the generated workflow holds the least privilege and never prints a token. Try: read the YAML for `permissions`, `secrets.*` in logs and fork-triggered events; run doctor with no token and with a read-only token; check that advice text does not suggest a wider scope than needed. The approval gate also writes to the mirror: the `--force` bypass comment (`gh api --method POST repos//issues//comments`, `recordBypassOnMirrorPr` in `src/shared/approval.ts`) runs with the user's gh auth; check that no advice asks for more than comment access to the mirror. 5. Version pin and migration. Files: `src/shared/semver.ts`, `src/shared/mirror-commit.ts`, `src/errors.ts` (`PinDowngradeError`, `UnmigratedMirrorError`), `isUnpinnedWorkflow`. Invariant: a mirror is never silently downgraded or left on an unpinned workflow. Try: run an older CLI against a newer pin; sync a mirror with a pre-pin workflow; set `VENFORK_INSTALL_SPEC` to an odd value. 6. Network safety. Files: `src/shared/net.ts` (`netExec`, `runNetOp`, `netFetch`), `src/shared/constants.ts`. Invariant: every network git/gh call has no stdin, a timeout and `BatchMode=yes`. Try: `grep` for raw `$` or `execa` calls on `clone`, `fetch`, `push`, `gh api`; set `VENFORK_GIT_TIMEOUT=1`; point a remote at a host that asks for credentials. 7. CLI contract. Files: `src/index.ts`, `src/dispatch.ts`, `src/*-args.ts`, `src/commands/help.ts`, `tests/unit/docs-drift.test.ts`. Invariant: parsers reject bad input before any side effect, help and the user docs (README.md, docs/reference) match the flags, and cancel exits 130. Try: unknown flags, missing values, `--` and repeated flags; compare `--help` with docs/reference/commands.md; cancel a prompt and check the exit code. +8. Approval gate. Files: `src/shared/approval.ts` (mirror PR lookup, reviews, permissions, checks, the `--force` bypass comment), `src/shared/approval-guard.ts` (clone memory in `/venfork/approval.json`), `src/shared/ai-attribution.ts`, `src/commands/approval.ts`, the gate wiring in `src/commands/stage.ts`, `tests/harness/gh-mirror.ts`. Invariant: a gated stage ships exactly the approved mirror PR head; `--yes`, `--json` or a missing TTY never lowers the level or bypasses the gate; the only bypass record is the comment on the mirror PR; nothing about approval or a bypass reaches public or upstream, and venfork-config stores only the level. Try: move the branch between the gate read and the rebuild; approve from the stage runner under `two-person`; pass `--internal-pr` for another branch's PR; corrupt `.git/venfork/approval.json`; `stage approval off` then stage with `--json`; grep the published commits and PR body for the approver, the level or the bypass text. ## Report format diff --git a/.agents/skills/verify-venfork/SKILL.md b/.agents/skills/verify-venfork/SKILL.md index ecacb0b..b898a7e 100644 --- a/.agents/skills/verify-venfork/SKILL.md +++ b/.agents/skills/verify-venfork/SKILL.md @@ -24,7 +24,10 @@ T=$(mktemp -d); bun scripts/verify-fixture.ts --shell > "$T/vf.env" - Helpers: `venfork`, `vf_tip [ref]`, `vf_pushes [ref]` (reflog count = accepted pushes), `vf_yes ` (answers one confirm through a pseudo-TTY), `vf_upstream_commit [msg]`, `vf_origin_commit [msg]` (a teammate push to origin). + `vf_yes` answers one confirm only. A lowered approval level or a `--force` bypass adds a gate confirm before the push confirm, and `--yes` never answers a gate confirm. Then use `vf_yes ... --yes`: `vf_yes` answers the gate confirm and `--yes` the push confirm. Without `--yes`, the push confirm hits end of input and exits 130. +- The fixture's venfork-config has no `approval` (off), unlike a new `setup` mirror (`two-person`). Stage and `stage approval` remember the level in the clone memory `$VF_WORK/.git/venfork/approval.json`. After an unconfirmed lowering (`stage approval off` without a TTY), every later non-TTY stage in this fixture exits 1 with `approval` reason `gate-weakened`. Reset with `rm "$VF_WORK/.git/venfork/approval.json"`, or use a fresh fixture. - Each tool call is a new shell. Source `"$T/vf.env"` again in each one. +- When a sandbox refuses to source `vf.env` or to set `GIT_*` variables inline, write the drive to a script file that exports the JSON `env` (read it with `jq`) and calls `node "$CLI"`, then run it with `sh `. ## Doctor @@ -68,7 +71,7 @@ Evidence is git state, read from the bare repos: - "Sync complete!" prints after a no-op too. Read the spinner lines (`already up to date` vs `Updated`) and the push count. - `stage` rebuilds every commit, so the public SHA changes on every stage. Compare trees and subjects, not SHAs. - The fixture remotes are local paths. The deny-list then holds the origin path and `venfork`, not `owner/name`. -- gh-backed paths (`setup`, `clone`, `pull`, `stage` without `--no-pr`, issues) stop at the stub. Their success paths are proven only by e2e. +- gh-backed paths (`setup`, `clone`, `pull`, `stage` without `--no-pr`, any `stage` with approval `reviewed` or `two-person`, issues) stop at the stub. A gated stage stops even earlier on the fixture, at `precondition`, because origin is not a GitHub repository. Their success paths are proven only by e2e. ## Clean up diff --git a/.agents/skills/verify-venfork/references/features/README.md b/.agents/skills/verify-venfork/references/features/README.md index 3b0e36d..15c5a7c 100644 --- a/.agents/skills/verify-venfork/references/features/README.md +++ b/.agents/skills/verify-venfork/references/features/README.md @@ -26,12 +26,12 @@ Cross-cutting: `leak-invariants.md` (what must never reach public or upstream), - Exit codes: 0 success (and "no" at a confirm at a terminal), 1 a runtime failure (the message prints once, as `error: ...` or, on a TTY, after a red `■`, with no spinner `done:` line and no failed outro; `reportFailure` in `src/shared/ui.ts`), a failing `doctor` check (doctor prints no `error:` line; its summary outro counts the failed checks, e.g. `❌ 2 failed, 5 passed, 4 skipped`) or a partial success (`partialFailureExitCode` in `src/dispatch.ts`: stage `prAction: failed`, a failed `pull pr` push to origin; the success envelope still holds the result), 2 a usage error (`UsageError`: unknown command or option, missing or unexpected argument, malformed flag value, flags that cannot be combined, bare `venfork`) or missing input or consent (`MissingInputError`), 130 a cancel or SIGINT, 143 a SIGTERM. Input valid in form that fails against the repos (a missing branch, divergence, a flag that contradicts `venfork-config`) is exit 1. - Exit 130 has three causes: a prompt that is cancelled (Ctrl-C, Esc) or whose input ends (`Cancelled: input ended at a prompt`); a terminal that closes during the run (EIO on stdin, stdout or stderr, `src/shared/closed-output.ts`); a SIGINT (without `--json`, Node's default handler ends the run and the shell reports 130). Under `--json`, `src/index.ts` prints the `cancelled` envelope on a SIGINT (exit 130) or a SIGTERM (exit 143). Without `--json`, a SIGTERM is Node's default handler too and the shell reports 143. `setup` handles both signals itself: it removes its temp dir, then exits 130 for SIGINT or 143 for SIGTERM (`tests/integration/cli.test.ts` pins 143 for `sync --json` and `setup`). A reader that closes the pipe early (`venfork help | head -1`, EPIPE) keeps the exit code so far and prints no stack. - Prompts: venfork prompts only when stdin and stderr are both a TTY (prompts render on stderr), `CI` is unset (or empty, `false`, `0`), `VENFORK_NONINTERACTIVE` is not `1` and there is no `--no-input` or `--json` (`canPrompt` in `src/shared/prompt.ts`). At a terminal, `venfork stage feat --no-pr 2>/dev/null` exits 2 naming `--yes`; it does not wait on a hidden prompt (`tests/integration/cli.test.ts`, "stage with stderr redirected at a TTY"). Every command accepts `--no-input`, `--yes` and `-y`; where nothing prompts or confirms they are no-ops (`isPromptFlag` in `src/shared/args.ts`). -- Outside a git repository, `sync`, `stage`, `pull`, `schedule`, `workflows` and `keep` stop before the command runs with `NotInRepositoryError` (code `not-in-repository`, exit 1; `assertInRepository` in `src/dispatch.ts`, called by `src/index.ts` after the arguments parse and gh auth). stderr holds only `error: Not in a git repository. ...`: no intro, spinner or command error. A usage error still exits 2, because every usage check runs in the parse step (the cron check in `parseScheduleCliArgs`, the `preserve add` path check in `src/index.ts`). `doctor` reports it as its `repo` check, and `setup` and `clone` run outside a repo. +- Outside a git repository, `sync`, `stage`, `pull`, `schedule`, `workflows` and `keep` stop before the command runs with `NotInRepositoryError` (code `not-in-repository`, exit 1; `assertInRepository` in `src/dispatch.ts`, called by `src/index.ts` after the arguments parse and gh auth). stderr holds only `error: Not in a git repository. ...`: no intro, spinner or command error. A usage error still exits 2, because every usage check runs in the parse step (the cron check in `parseScheduleCliArgs`, the `keep add` path check, `normalizeKeepArgs`, called from `src/index.ts`). `doctor` reports it as its `repo` check, and `setup` and `clone` run outside a repo. - Typed errors live in `src/errors.ts`. Without `--json` the CLI prints only `.message`. With `--json`, match on `error.code`; it is stable, the message is not. The codes (`ErrorCode` in `src/errors.ts`; `describeError` in `src/shared/json-output.ts` maps a failed `git` or `gh` subprocess to `git` or `github`, else `internal`): - exit 2: `usage`, `missing-input` (with `error.flag`). - exit 130 or 143: `cancelled`. A declined confirm is `CommandExitError(0)`, also coded `cancelled`, but no envelope carries it: `--json` implies `--no-input`, so a JSON run never shows a confirm. - - exit 1: `auth`, `git`, `github`, `precondition`, `remote-not-found`, `not-in-repository`, `branch-not-found`, `sync-divergence`, `config`, `redaction`, `mirror-reference`, `stage-leak`, `pin-downgrade`, `unmigrated-mirror`, `runtime`, `internal`. No envelope reports `command-failed`: a `CommandExitError` without a cause is `runtime` (exit 1), `usage` (exit 2) or `cancelled`. - - `error.hint` (the next step) and `error.details` (files, paths, branch, flag) are present for the codes README "JSON output" lists; without `--json` the hint follows the message on the one `error:` line. + - exit 1: `auth`, `git`, `github`, `precondition`, `remote-not-found`, `not-in-repository`, `branch-not-found`, `sync-divergence`, `config`, `redaction`, `mirror-reference`, `stage-leak`, `approval` (with `details.reason`, see `stage.md`), `pin-downgrade`, `unmigrated-mirror`, `runtime`, `internal`. No envelope reports `command-failed`: a `CommandExitError` without a cause is `runtime` (exit 1), `usage` (exit 2) or `cancelled`. + - `error.hint` (the next step) and `error.details` (files, paths, branch, flag) are present for the codes `docs/reference/json.md` lists; without `--json` the hint follows the message on the one `error:` line. - Network ops: every git fetch, push and ls-remote runs through `netExec` (`src/shared/net.ts`): no stdin, a hard timeout, ssh `BatchMode`, and gh as the only credential helper for `https://github.com` (see `sync.md`). - "Concurrent lease" drives use a stale copy of origin: fetch from the copy, push to the real origin. diff --git a/.agents/skills/verify-venfork/references/features/approval.md b/.agents/skills/verify-venfork/references/features/approval.md index f2c271c..5f88f45 100644 --- a/.agents/skills/verify-venfork/references/features/approval.md +++ b/.agents/skills/verify-venfork/references/features/approval.md @@ -1,13 +1,13 @@ --- command: approval entry: src/commands/approval.ts -shared: [json-output] +shared: [approval-guard, prompt, json-output] config: read: [approval] written: [approval] tests: - unit: [tests/unit/stage-args.test.ts, tests/unit/config.test.ts] - integration: [tests/integration/approval.test.ts] + unit: [tests/unit/stage-args.test.ts, tests/unit/config.test.ts, tests/unit/approval.test.ts] + integration: [tests/integration/approval.test.ts, tests/integration/approval-guard.test.ts] e2e: [] --- @@ -33,12 +33,27 @@ venfork stage approval on &1 | grep -o "Approval set to two-person" git -C "$VF_ORIGIN" show venfork-config:.venfork/config.json | jq -r .approval # two-person venfork stage approval off &1 | grep -o "The approval gate is off" venfork stage approval maybe src/feat.txt && git add . && git commit -qm "feat: add feat" +venfork stage approval on /dev/null 2>&1 +venfork stage feat --no-pr --yes --json /dev/null | jq -r .error.code # precondition: the fixture origin is not GitHub +venfork stage approval off /dev/null 2>&1 # the hint's next step, without a TTY +venfork stage feat --no-pr --yes --json /dev/null | jq -c '[.error.code, .error.details.reason]' # ["approval","gate-weakened"] +rm "$VF_WORK/.git/venfork/approval.json" +venfork stage feat --no-pr --yes /dev/null 2>&1; echo $? # 0, and the memory now holds off ``` ## What proves it - `git -C "$VF_ORIGIN" show venfork-config:.venfork/config.json` has `"approval"` after `on`/`reviewed`, and none after `off`. +- `cat "$VF_WORK/.git/venfork/approval.json"` holds `{ "level" }`: the new level after a raise or a confirmed lowering, the earlier level after a lowering that no one confirmed. ## What usually lies -- The setting alone proves nothing about stage. Check the stage feature file for what stage does with the level. +- The setting alone proves nothing about stage. Check the stage feature file for what stage does with the level. With `reviewed` or `two-person`, every fixture stage (also with `--no-pr`) stops at `precondition` "origin is not a GitHub repository". Its hint `venfork stage approval off` leads to the next point. +- `stage approval on` then `stage approval off` without a TTY leaves this clone's memory (`/venfork/approval.json`, in the fixture `$VF_WORK/.git/venfork/approval.json`) at `two-person` while venfork-config says `off`. After that, every non-TTY `venfork stage` in this fixture exits 1 with `approval` reason `gate-weakened`, so the `stage.md` and `leak-invariants.md` drives do not give their documented results (`tests/integration/approval.test.ts` pins this). Reset with `rm "$VF_WORK/.git/venfork/approval.json"` (the next stage records `off`), or run this file last on a fresh fixture. At a TTY, `vf_yes stage feat --no-pr --yes` answers the lowering confirm once and `--yes` answers the push confirm. diff --git a/.agents/skills/verify-venfork/references/features/clone.md b/.agents/skills/verify-venfork/references/features/clone.md index ee11365..6378999 100644 --- a/.agents/skills/verify-venfork/references/features/clone.md +++ b/.agents/skills/verify-venfork/references/features/clone.md @@ -1,7 +1,7 @@ --- command: clone entry: src/commands/clone.ts -shared: [json-output, prompt] +shared: [json-output, net, prompt] config: read: [mode, upstreamUrl, publicForkUrl] written: [] diff --git a/.agents/skills/verify-venfork/references/features/doctor.md b/.agents/skills/verify-venfork/references/features/doctor.md index 5ef75ab..c0e8be1 100644 --- a/.agents/skills/verify-venfork/references/features/doctor.md +++ b/.agents/skills/verify-venfork/references/features/doctor.md @@ -1,7 +1,7 @@ --- command: doctor entry: src/commands/doctor.ts -shared: [constants, cron, divergence, json-output, managed-commit, mirror-commit, net, push-token, semver] +shared: [constants, cron, divergence, json-output, managed-commit, mirror-commit, mirror-origin, net, push-token, semver] config: read: [mode, upstreamUrl, publicForkUrl, schedule, scheduleAuth, preserve, invalidPreserve, shippedBranches, pulledPrs, shippedIssues, pulledIssues] written: [] diff --git a/.agents/skills/verify-venfork/references/features/help.md b/.agents/skills/verify-venfork/references/features/help.md index 9a4d485..7896450 100644 --- a/.agents/skills/verify-venfork/references/features/help.md +++ b/.agents/skills/verify-venfork/references/features/help.md @@ -15,13 +15,13 @@ tests: - `venfork help`, `venfork --help` and `venfork -h` print the one-screen command list on stdout and exit 0: the intro, a "Commands" note with the groups Get started, Every day, Review upstream work, Automation and Advanced (one line per command), a two-line example, and an outro that names `venfork --help`, `venfork help env` and `venfork help exit-codes`. Advanced says `--yes` and `--no-input` work on every command and that `stage --dry-run` pushes nothing. `tests/integration/cli.test.ts` runs the built CLI on a forced 80-column TTY (clack's box) and on a pipe, and checks that both fit 36 lines of 80 columns with no wrapped row. `tests/unit/help.test.ts` checks the plain form and refuses implementation terms in it. - `venfork help env` prints the environment variables (`VENFORK_ALLOW_SELF_REFERENCE` under an Advanced heading) and `venfork help exit-codes` the exit-code table, on stdout, exit 0 (`helpTopic`). Both fit 80 columns. The test derives the env list from every `process.env.VENFORK_*` that `src/` reads. They are help topics, not commands: `venfork env --help` is the unknown-command usage error. -- `venfork help ` prints only that command's usage on stdout. `help pull pr`, `help pull issue` and `help stage issue` print the subcommand entry. `help version`, `help -v` and `help --version` print the `--version` entry. An unknown `` (`help bogus`) is the unknown-command usage error (exit 2, nothing on stdout), and a renamed one (`help status`) names the new form, as `venfork bogus` and `venfork status` do. +- `venfork help ` prints only that command's usage on stdout. `help pull pr`, `help pull issue`, `help stage issue` and `help stage approval` print the subcommand entry. `help version`, `help -v` and `help --version` print the `--version` entry. An unknown `` (`help bogus`) is the unknown-command usage error (exit 2, nothing on stdout), and a renamed one (`help status`) names the new form, as `venfork bogus` and `venfork status` do. - `venfork --help` (or `-h`) prints that command's usage on stdout and exits 0. It is checked before the parser, so other bad arguments on the same line do not matter. `venfork bogus --help` has no entry and is the unknown-command usage error. - `--yes`, `-y` and `--no-input` anywhere after `help` (`venfork help --yes`, `venfork help -y doctor`) are ignored, as on every command. -- Every usage line lists `[--yes] [--no-input] [--json]`, "Available Commands" ends with "Every command accepts --yes, -y and --no-input", and the "Exit Codes" row for 1 includes a partial success. +- Every usage line lists `[--yes] [--no-input] [--json]`, and the `help exit-codes` row for 1 includes a partial success. - `venfork --version`, `-v` and `version` print the package version on stdout and exit 0. - Bare `venfork` prints the same one-screen list on stderr (stdout stays empty) and exits 2. -- Unknown command: exit 2, "Unknown command: . Run `venfork help`.". Renamed commands exit 2 and name the new form: `pull-request` (`venfork pull pr`), `issue` (`venfork pull issue`, `venfork stage issue`), `status` (`venfork doctor`). +- Unknown command: exit 2, "Unknown command: . Run `venfork help`.". Renamed commands exit 2 and name the new form: `pull-request` (`venfork pull pr`), `issue` (`venfork pull issue`, `venfork stage issue`), `status` (`venfork doctor`), `preserve` (`venfork keep`). - `--json` with `help`, `-h`, `--help`, `--version`, `-v` or `version`, or with `-h`/`--help` after a command, is a usage error: the `usage` envelope on stdout, "--json cannot be combined with help or --version, which print text", exit 2. `--json` as the first word (`venfork --json sync`) is the `usage` envelope "Put --json after the command". - A reader that closes the pipe early (`venfork help | head -1`) keeps exit 0 and prints no EPIPE stack. diff --git a/.agents/skills/verify-venfork/references/features/issue.md b/.agents/skills/verify-venfork/references/features/issue.md index 3885d05..4c6c756 100644 --- a/.agents/skills/verify-venfork/references/features/issue.md +++ b/.agents/skills/verify-venfork/references/features/issue.md @@ -1,7 +1,7 @@ --- command: stage issue, pull issue entry: src/commands/issue.ts -shared: [args, prompt, deny-list, json-output, redaction, repo] +shared: [args, prompt, deny-list, json-output, redaction, repo, upstream-publish] config: read: [] written: [shippedIssues, pulledIssues] @@ -20,11 +20,11 @@ tests: - `--yes` answers both confirms. Unless stdin and stderr are both a TTY, and with `CI` set, with `VENFORK_NONINTERACTIVE=1` or with `--no-input`, a confirm without `--yes` exits 2 naming `--yes` (`MissingInputError`) before it reads the issue, and opens nothing. At a TTY a "no" exits 0; a cancel or EOF exits 130. - `--json` never prompts (it implies `--no-input`); without `--yes` it is the `missing-input` envelope, exit 2. On success `result` is `{ number, url, source: { number, url } }`: the issue venfork opened and the issue it came from. - One-shot: no later comment or state sync. A failed config write only warns. -- `stage issue` with a branch-only flag (`--pr`, `--draft`, `--base`, `--internal-pr`, `--no-update-existing`) is a usage error (exit 2). Bare `stage issue` points at `venfork stage branch issue` (exit 2). A malformed `` exits 2; a URL to another repo exits 1. The old `venfork issue ...` exits 2 and names both new forms. +- `stage issue` with a branch-only flag (`--pr`, `--no-pr`, `--draft`, `--dry-run`, `--base`, `--internal-pr`, `--no-update-existing`, `--force`) is a usage error (exit 2). Bare `stage issue` points at `venfork stage branch issue` (exit 2). A malformed `` exits 2; a URL to another repo exits 1. The old `venfork issue ...` exits 2 and names both new forms. ## How a user reaches it -`venfork stage issue [--title ] [--yes] [--no-input] [--json]`, `venfork pull issue [--title ] [--yes] [--no-input] [--json]`. `pull pr` never prompts: it accepts `--no-input` (a no-op) and refuses `--yes`. +`venfork stage issue [--title ] [--yes] [--no-input] [--json]`, `venfork pull issue [--title ] [--yes] [--no-input] [--json]`. `pull pr` never prompts: it accepts `--no-input`, `--yes` and `-y`, and they change nothing. ## How to drive it diff --git a/.agents/skills/verify-venfork/references/features/multi-command-journeys.md b/.agents/skills/verify-venfork/references/features/multi-command-journeys.md index 47f8a81..1d0ff5c 100644 --- a/.agents/skills/verify-venfork/references/features/multi-command-journeys.md +++ b/.agents/skills/verify-venfork/references/features/multi-command-journeys.md @@ -5,7 +5,7 @@ Record the evidence after each step; a journey passes only when every line match ## 1. Setup, schedule, sync twice -The fixture stands in for `setup`: it is the layout setup builds (remotes, `DISABLE` push URL, `venfork-config`). Real setup is e2e tier 1. +The fixture stands in for `setup`: it is the layout setup builds (remotes, `DISABLE` push URL, `venfork-config`), except approval: setup writes `two-person`, the fixture writes no level (`off`), so stage drives need no mirror PR. Do not run `venfork stage approval` in a journey (see `approval.md`). Real setup is e2e tier 1. 1. `venfork doctor --json; echo $?` - exit 0, `invariant` "upstream (+0)". 2. `venfork schedule set "0 */6 * * *" &1 | grep "Scheduled sync enabled"` - one line. `vf_tip "$VF_ORIGIN" main^` equals `vf_tip "$VF_UPSTREAM"`. `git -C "$VF_ORIGIN" log -1 --format=%an main` is `venfork-bot`. diff --git a/.agents/skills/verify-venfork/references/features/pull-request.md b/.agents/skills/verify-venfork/references/features/pull-request.md index a4cd968..2ce78dd 100644 --- a/.agents/skills/verify-venfork/references/features/pull-request.md +++ b/.agents/skills/verify-venfork/references/features/pull-request.md @@ -28,7 +28,7 @@ tests: ## How a user reaches it -`venfork pull pr [--branch-name ] [--no-push] [--yes] [--no-input] [--json]`, then the same command again to refresh. No prompt: `--no-input` is accepted and changes nothing, and `--yes` is an unknown option (exit 2). +`venfork pull pr [--branch-name ] [--no-push] [--yes] [--no-input] [--json]`, then the same command again to refresh. No prompt: `--no-input`, `--yes` and `-y` are accepted and change nothing. ## How to drive it @@ -63,7 +63,7 @@ venfork sync upstream-pr/7 &1; echo $? - `vf_tip "$VF_ORIGIN" upstream-pr/7` equals the PR head. - `git -C "$VF_ORIGIN" show venfork-config:.venfork/config.json` has `pulledPrs["upstream-pr/7"].head` at that SHA. -- `venfork doctor --json | jq .links.pulledPrs`. +- `venfork doctor --json | jq .result.links.pulledPrs`. ## What usually lies diff --git a/.agents/skills/verify-venfork/references/features/schedule.md b/.agents/skills/verify-venfork/references/features/schedule.md index 18e5bc4..672a79b 100644 --- a/.agents/skills/verify-venfork/references/features/schedule.md +++ b/.agents/skills/verify-venfork/references/features/schedule.md @@ -1,7 +1,7 @@ --- command: schedule entry: src/commands/schedule.ts -shared: [config-change, constants, cron, json-output, net, push-token, divergence, mirror-commit] +shared: [config-change, constants, cron, json-output, mirror-origin, net, push-token, divergence, mirror-commit] config: read: [schedule, scheduleAuth, mode, publicForkUrl, preserve, enabledWorkflows, disabledWorkflows] written: [schedule, scheduleAuth] diff --git a/.agents/skills/verify-venfork/references/features/setup.md b/.agents/skills/verify-venfork/references/features/setup.md index f2db12f..bf8ceed 100644 --- a/.agents/skills/verify-venfork/references/features/setup.md +++ b/.agents/skills/verify-venfork/references/features/setup.md @@ -1,10 +1,10 @@ --- command: setup entry: src/commands/setup.ts -shared: [net, fs, json-output, prompt] +shared: [net, fs, json-output, prompt, approval-guard, interrupt] config: read: [mode, upstreamUrl, publicForkUrl] - written: [version, mode, upstreamUrl, publicForkUrl] + written: [version, mode, upstreamUrl, publicForkUrl, approval] tests: unit: [tests/unit/commands.test.ts, tests/unit/setup-args.test.ts, tests/unit/net.test.ts, tests/unit/json-output.test.ts, tests/unit/prompt.test.ts] integration: [tests/integration/cli.test.ts, tests/integration/integrity.test.ts] @@ -21,7 +21,7 @@ tests: - Writes the orphan `venfork-config` branch. Never overwrites one: an existing config that agrees is kept, one that disagrees is a `ConfigError` ("The mirror's existing venfork settings disagree with this setup"). - Sets remotes: `origin`, `public` (not with `--own-upstream`; a stale `public` is removed), `upstream` with push URL `DISABLE`. URLs follow `gh config get git_protocol`. - Recovery: when the fork or the mirror already exists on GitHub, setup skips the seed, reuses a matching local clone and runs `sync` quietly inside it. -- Usage errors (exit 2, before gh auth): `--own-upstream` with `--fork-name`, invalid upstream, invalid `--fork-name`. Runtime errors (exit 1): `PreconditionError` for upstream already under the owner without `--fork-name`, an existing repo that is not a fork of upstream, a local directory that is not the expected mirror clone; `GitHubError` (gh's own message) when `gh repo fork` or `gh repo create` fails and no usable repo exists. +- Usage errors (exit 2, before gh auth): `--own-upstream` with `--fork-name`, invalid upstream, invalid `--fork-name`, an unknown `--approval` level. Runtime errors (exit 1): `PreconditionError` for upstream already under the owner without `--fork-name`, an existing repo that is not a fork of upstream, a local directory that is not the expected mirror clone; `GitHubError` (gh's own message) when `gh repo fork` or `gh repo create` fails and no usable repo exists. - SIGINT and SIGTERM during the run: setup stops its spinner ("Setup interrupted"), removes its temp dir and exits 130 for SIGINT or 143 for SIGTERM (`CommandExitError`, code `cancelled`), with or without `--json`. - `--json` implies `--no-input` and prints one envelope line on stdout. `result` is `{ mode, mirror: { repo, url }, publicFork?: { repo, url }, upstream: { repo, url }, directory }`: `repo` is `owner/name`, `url` the `https://github.com/` page, `publicFork` absent with `--own-upstream`, `directory` the absolute checkout path. Failures carry `error.code` (`missing-input` with `flag`, `usage`, `auth`, `precondition`, `github`, `config`, `cancelled`). @@ -41,6 +41,7 @@ venfork setup a/b b-private --json /dev/null | jq -c '[.error.code, venfork setup a/b --org c &1 | grep -o "Private mirror repo name? b-private.*" # default used, then exit 1 at gh venfork setup a/b --own-upstream --fork-name x &1; echo $? # 2, "--own-upstream cannot be combined with --fork-name" venfork setup a/b m --fork-name "bad name" --org c &1 | grep -o "Invalid --fork-name" +venfork setup a/b --approval maybe &1; echo $? # 2, "--approval must be on, two-person, reviewed or off, not 'maybe'" venfork setup a/b m --org c &1 | grep "gh stub" # exit 1 at the first gh call cp "$VF_ROOT/bin/gh" "$VF_ROOT/gh.ok"; printf '#!/bin/sh\nexit 1\n' > "$VF_ROOT/bin/gh" venfork setup a/b &1 # AuthenticationError, exit 1 @@ -53,12 +54,13 @@ mv "$VF_ROOT/gh.ok" "$VF_ROOT/bin/gh" ## What proves it - e2e only: `gh repo view /-private --json visibility`, `git -C -private remote -v` (upstream push `DISABLE`), `git -C -private show origin/venfork-config:.venfork/config.json`. +- e2e only: `git -C -private show origin/venfork-config:.venfork/config.json | jq -r .approval` is `two-person` (or the `--approval` level; absent for `off`), and `-private/.git/venfork/approval.json` holds the same level. - `venfork doctor --json` inside the new clone: `remotes`, `mode` and `invariant` ok. - On the fixture: exit code and message only. No repo is created. ## What usually lies - `tests/unit/commands.test.ts` answers every `gh` call with a string. A green setup test does not show that GitHub accepted a fork, a create or a push. -- The fixture already contains what setup would build. It does not exercise setup. +- The fixture already contains what setup would build, except approval: setup writes `two-person`, the fixture writes no level (`off`). It does not exercise setup. - Exit 0 after "Continue with personal account?" answered no is a cancel, not a success. - `VENFORK_NONINTERACTIVE=1` does not confirm. Only `--yes` does; without it the confirm exits 2. diff --git a/.agents/skills/verify-venfork/references/features/stage.md b/.agents/skills/verify-venfork/references/features/stage.md index ac20089..c4a2478 100644 --- a/.agents/skills/verify-venfork/references/features/stage.md +++ b/.agents/skills/verify-venfork/references/features/stage.md @@ -1,7 +1,7 @@ --- command: stage entry: src/commands/stage.ts -shared: [approval, approval-guard, ai-attribution, stage-gate, deny-list, redaction, prompt, divergence, json-output, managed-commit, mirror-commit, net, repo, worktree, constants] +shared: [approval, approval-guard, ai-attribution, stage-gate, deny-list, redaction, prompt, divergence, json-output, managed-commit, mirror-commit, net, repo, upstream-publish, worktree, constants] config: read: [mode, upstreamUrl, publicForkUrl, preserve, invalidPreserve, approval] written: [shippedBranches] @@ -22,11 +22,11 @@ tests: - Shows commits and files, then asks "Push to public fork and open the upstream PR?" (no-public: "Push to upstream and open the PR?"; with `--no-pr`: "Push to public fork?" / "Push to upstream?"). `--yes` answers it. Unless stdin and stderr are both a TTY, and with `CI` set, with `VENFORK_NONINTERACTIVE=1` or with `--no-input`, the confirm without `--yes` exits 2 before it fetches with one line (here with `--no-pr`), `error: Not staged. "Push to public fork?" needs an answer and venfork cannot prompt here. Add --yes to confirm.` (`MissingInputError`), and nothing is pushed; stdout stays empty. - Without `--no-pr`, an upstream or push remote that is not on github.com (GitHub Enterprise, a local path) is `precondition` "Cannot open an upstream PR: ... is not a GitHub repository." with the hint `Pass --no-pr to push the branch only.`, after the confirm check and before any fetch. - Pushes `:refs/heads/` with `--force-with-lease=:` and `--no-follow-tags`. No-public pushes to the upstream URL directly (the remote's push URL stays `DISABLE`). -- `--json` never prompts, even at a TTY (it implies `--no-input`). Outside a git repository, stage fails with `not-in-repository` (exit 1) before it reaches the confirm. Without `--yes` it is `ok: false`, `error: { code: 'missing-input', flag: '--yes' }`, exit 2. On success `result` is `{ branch, head, target: 'public' | 'upstream', compareUrl, prAction?, prUrl?, internalPrUrl? }`; `prAction` (`created`, `updated`, `unchanged`, `failed`) is present unless `--no-pr`; `failed` (the PR could not be opened, or an existing PR's body could not be updated) exits 1 with the success envelope, an `error:` line and the outro `Stage incomplete: ...`. The outro names the re-run in the explicit form with the same PR flags, each value shell-quoted (`stageRerunCommand`: `venfork stage branch '' [--no-pr] [--draft] [--base ''] [--title ''] [--internal-pr ] [--no-update-existing]`). -- `--dry-run` runs the plan, the rebuild, the gate and (with the PR) the internal PR lookup and redaction, prints the preview notes and the outro `Dry run: nothing was pushed. ...`, and exits 0. It skips the confirm, so it needs no `--yes`. `result` is `{ dryRun: true, branch, head, target, compareUrl, base, commits, files, removedLater, prTitle?, prBody?, existingPrUrl?, internalPrUrl? }`. With the PR it also runs a read-only `gh pr list` for an open upstream PR on the same head and base; when one is open it sets `existingPrUrl`, drops `prTitle` (a re-stage keeps the title) and, with `--no-update-existing`, drops `prBody`. No push, no `gh pr create|edit`, no `shippedBranches` write. `head` is the preview rebuild's local commit; a real stage rebuilds with a new SHA and the same tree. Gate refusals (`stage-leak`, `mirror-reference`, "Nothing to stage") still fail the dry run. `--dry-run` on `stage issue` is a usage error. +- `--json` never prompts, even at a TTY (it implies `--no-input`). Outside a git repository, stage fails with `not-in-repository` (exit 1) before it reaches the confirm. Without `--yes` it is `ok: false`, `error: { code: 'missing-input', flag: '--yes' }`, exit 2. On success `result` is `{ branch, head, target: 'public' | 'upstream', compareUrl, prAction?, prUrl?, internalPrUrl?, approval?, aiAttribution? }`; `prAction` (`created`, `updated`, `unchanged`, `failed`) is present unless `--no-pr`; `failed` (the PR could not be opened, or an existing PR's body could not be updated) exits 1 with the success envelope, an `error:` line and the outro `Stage incomplete: ...`. The outro names the re-run in the explicit form with the same PR flags, each value shell-quoted (`stageRerunCommand`: `venfork stage branch '' [--no-pr] [--draft] [--base ''] [--title ''] [--internal-pr ] [--no-update-existing]`). +- `--dry-run` runs the plan, the rebuild, the gate and (with the PR) the internal PR lookup and redaction, prints the preview notes and the outro `Dry run: nothing was pushed. ...`, and exits 0. It skips the confirm, so it needs no `--yes`. `result` is `{ dryRun: true, branch, head, target, compareUrl, base, commits, files, removedLater, prTitle?, prBody?, existingPrUrl?, internalPrUrl?, approval?, weakened?, aiAttribution? }`. With the PR it also runs a read-only `gh pr list` for an open upstream PR on the same head and base; when one is open it sets `existingPrUrl`, drops `prTitle` (a re-stage keeps the title) and, with `--no-update-existing`, drops `prBody`. No push, no `gh pr create|edit`, no `shippedBranches` write. `head` is the preview rebuild's local commit; a real stage rebuilds with a new SHA and the same tree. Gate refusals (`stage-leak`, `mirror-reference`, "Nothing to stage") still fail the dry run. `--dry-run` on `stage issue` is a usage error. - The PR step is the default (`--no-pr` skips it; `--pr` is an accepted no-op alias for one release). `--draft`, `--title`, `--internal-pr`, `--no-update-existing` and `--pr` with `--no-pr` are usage errors (exit 2); `--base` is allowed (compare link). A branch with no commits to publish beyond `upstream/` (merge commits and venfork-managed commits are dropped, so a branch carrying only those counts) is `precondition` "Nothing to stage" before the confirm, with the hint `Commit your work on the branch first.`. - The PR step: finds the internal PR on the mirror, redacts it (`stripInternalBlocks`, `RedactionError`), runs `gh pr create`, updates an existing PR body unless `--no-update-existing`, writes `shippedBranches[branch]`. The entry's `sourceHead` is the mirror commit that was staged; its `head` is the rebuilt commit that was pushed. When `gh pr create` fails, the Next Steps note says the upstream PR was not opened and gives the compare URL, and the outro `Stage incomplete: finish the PR with \`venfork stage ...\` again or by hand.` repeats the PR flags the run used (`--draft`, `--base`, `--title`, `--internal-pr`, `--no-update-existing`); only a `--no-pr` run gets the tip to re-run without `--no-pr`. -- With `approval` set (`reviewed`, `two-person`), stage reads the branch's open mirror PR, its reviews, the candidate approvers' permissions, its check runs and statuses through `gh api` (netExec), after the fetch and before the rebuild. Refusals are `ApprovalError` code `approval` (exit 1) with `details.reason` `no-mirror-pr`, `branch-moved`, `changes-requested`, `not-approved`, `self-approved`, `checks-pending` or `checks-failed`; a non-GitHub origin is `precondition`. A pass pins the rebuild to the approved head (`source.sha`); `result.approval` is `{ level, ok: true, prUrl, headSha, approvers }`. `--dry-run` reports a refusal as `result.approval.ok: false` and a warning, and still previews. Nothing about the approval reaches the public fork or upstream (`tests/integration/approval-gate.test.ts` pins the published messages). With the level `off` stage makes no gh call for it. +- With `approval` set (`reviewed`, `two-person`), stage reads the branch's open mirror PR, its reviews, the candidate approvers' permissions, its check runs and statuses through `gh api` (netExec), after the fetch and before the rebuild. Refusals are `ApprovalError` code `approval` (exit 1) with `details.reason` `no-mirror-pr`, `wrong-head` (the `--internal-pr` PR is for another branch, or its head is not on the mirror), `wrong-base` (the PR does not target the mirror default branch), `ambiguous-pr` (several open PRs for the branch target the default branch), `branch-moved`, `changes-requested`, `not-approved`, `self-approved`, `checks-pending` or `checks-failed`; a non-GitHub origin is `precondition`. `--internal-pr ` also picks the mirror PR the gate reads. A pass pins the rebuild to the approved head (`source.sha`); `result.approval` is `{ level, ok: true, prUrl, headSha, approvers }`. `--dry-run` reports a refusal as `result.approval.ok: false` and a warning, and still previews. Nothing about the approval reaches the public fork or upstream (`tests/integration/approval-gate.test.ts` pins the published messages). With the level `off` stage makes no gh call for it. - AI attribution (`src/shared/ai-attribution.ts`): stage lists published lines that credit an AI tool in the Staging Details note with a warning, and sets `result.aiAttribution` (`{ where, subject?, line }`) for `--dry-run` and a real stage. It reads each commit author and committer (`commit-author`, `commit-committer`), the trailers of the last paragraph of each message when every line there is `Key: value` (`commit-message`), and "Generated/Written/Created with/by/using " lines anywhere in the body. A trailer counts when its key names a tool (`Claude-Session`, `Amp-Thread-ID`, `Generated-by`) or its name or email is a known tool identity (exact name such as `Claude` or `copilot-swe-agent[bot]`, a GitHub noreply login, or noreply at a tool domain); `Claude Monet` and `Fix: the db cursor ...` do not. With the PR step it scans the published PR title (phrases) and redacted body (`pr-title`, `pr-body`). Nothing is rewritten. - `--force` (not with `--dry-run`, usage error): when the gate refuses with an open mirror PR, a TTY confirm that `--yes` never answers (no TTY: `approval` reason `bypass-unconfirmed`), then just before the push `gh api --method POST repos//issues//comments --input -` (netExec, body on stdin) naming the gh login, level, staged commit and refusal. A failed comment pushes nothing. No open mirror PR is still refused, and so is a local head that differs from the mirror PR head (`branch-moved`, also when another refusal comes first). A non-GitHub origin at record time is `precondition` and pushes nothing. The staged commit is the one resolved before the confirm. With level `off`, a warning only. No venfork-config write for the bypass; `result.approval` is `{ ok: false, bypassed: true, ... }`. - Lowered level guard (`src/shared/approval-guard.ts`), with any level: the clone memory `venfork/approval.json` under the git common dir (`{ "level" }`, written by `rememberApproval`). A mirror level that ranks below the memory with `--no-input`, `--json` or no TTY is `approval` reason `gate-weakened` (exit 1) before the rebuild; at a TTY a confirm that `--yes` never answers (no: exit 0, memory kept; cancel: 130). An equal or higher level is recorded silently, so lower-then-raise is not flagged. A missing file (fresh clone) records the level without a confirm; a file that exists but cannot be read or parsed counts as `two-person` and the message says so. `--dry-run` sets `result.weakened` and writes no memory. No venfork-config history is read. Not enforcement: a fresh clone does not know an earlier level; branch protection on `venfork-config` is the real control (`tests/integration/approval-guard.test.ts`). @@ -34,7 +34,7 @@ tests: ## How a user reaches it -`venfork stage `, `venfork stage branch ` (for a branch named `issue`, `approval` or `branch`), plus `--no-pr`, `--draft`, `--dry-run`, `--title `, `--base `, `--internal-pr `, `--no-update-existing`, `--yes`/`-y`, `--no-input`, `--json`. `stage issue` is in `issue.md`; `stage approval` is in `approval.md`. +`venfork stage `, `venfork stage branch ` (for a branch named `issue`, `approval` or `branch`), plus `--no-pr`, `--draft`, `--dry-run`, `--title `, `--base `, `--internal-pr `, `--no-update-existing`, `--force`, `--yes`/`-y`, `--no-input`, `--json`. `stage issue` is in `issue.md`; `stage approval` is in `approval.md`. ## How to drive it @@ -56,8 +56,12 @@ venfork stage word --no-pr &1 | grep -o "Refusing to publish.*" # M venfork stage word --no-pr --yes --json /dev/null | jq -r .error.code # mirror-reference venfork stage main --no-pr --yes &1 | grep -o "Cannot open an upstream PR.*" # the default PR step: local upstream is not GitHub +venfork stage feat --no-pr --force --dry-run &1 | grep -o "nothing to bypass" # level off: a warning only, then the push ``` +- Approval gate: the fixture config has no `approval` (off). A raised level stops every fixture stage at `precondition` (see `approval.md`, which also resets the clone memory). The pass and refusal paths run against the gh stub in `tests/harness/gh-mirror.ts` (`tests/integration/approval-gate.test.ts`, `approval-force.test.ts`). + - No-public: launch with `--own-upstream`; `vf_yes stage feat --no-pr` puts `feat` on `$VF_UPSTREAM`. - Hidden prompt: `script -q /dev/null sh -c 'node "$VF_CLI" stage feat --no-pr 2>/dev/null; echo EXIT=$?' .md`. +Verification run. `bun run verify`. Say which harness feature file you drove, from `.agents/skills/verify-venfork/references/features/.md`. Not verified. Name what you did not run: e2e tiers, live GitHub, the Node build. diff --git a/CLAUDE.md b/CLAUDE.md index 23a576b..af63640 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ bun test ./tests/unit/utils.test.ts # single file bun test -t "parses owner" ./tests/unit # single test by name bun run test:watch # watch bun run test:coverage # coverage -bun run verify # the only command that means "done": format:check, lint, lint:invariants, typecheck, tests, node smoke (non-mutating) +bun run verify # the only command that means "done": format:check, lint, lint:invariants, typecheck, tests, build, node smoke (non-mutating) bun run check # biome check + autofix (lint + format); mutates files, so it is not a gate bun run lint # biome lint only bun run lint:invariants # scripts/lint-invariants.ts repo invariant checks @@ -52,7 +52,7 @@ Never run the real-GitHub e2e unless the user asks. It needs the test org (`memc ## Architecture -CLI entry `src/index.ts` parses `argv[0]` as the command and dispatches via a switch. Each command has a dedicated arg parser (`src/-args.ts`) returning a typed options object, then calls the matching `*Command` function in `src/commands/.ts` (re-exported through the `src/commands.ts` barrel). To add/modify a command: touch the arg parser, the command impl, the `index.ts` switch, and `showHelp()`. +CLI entry `src/index.ts` parses `argv[0]` as the command and dispatches via a switch. Each command has a dedicated arg parser (`src/-args.ts`) returning a typed options object, then calls the matching `*Command` function in `src/commands/.ts` (re-exported through the `src/commands.ts` barrel). To add/modify a command: touch the arg parser, the command impl, the `src/index.ts` switch, `src/dispatch.ts` (gh auth, repo check), the help entry in `src/commands/help.ts` (and `showHelp` if it belongs on the one screen), its `docs/reference/commands.md` section and its `verify-venfork` feature file. `src/commands/` holds one file per command. Helpers shared by several commands live in `src/shared/`: `net.ts` (network-op safety), `managed-commit.ts` (managed-commit detection), `mirror-commit.ts` (building the managed commit), `divergence.ts`, `redaction.ts`, `worktree.ts` (temporary detached worktrees), `prompt.ts` (every interactive prompt), `ui.ts` (all human output, on stderr), `json-output.ts` (the one `--json` envelope: a command returns its result, `src/index.ts` writes it, `error.code` comes from `VenforkError.code`, and a failed `git`/`gh` subprocess maps to `git`/`github`), `interrupt.ts` (a command that handles SIGINT/SIGTERM itself claims them, so the `--json` signal envelope waits for its rejection), `constants.ts`. The rest are focused modules: @@ -64,7 +64,7 @@ CLI entry `src/index.ts` parses `argv[0]` as the command and dispatches via a sw ### The "+0/+1 managed commit" model (the core invariant) -When scheduled sync or a `preserve` allowlist is active, the mirror's default branch is kept at **`upstream/` plus at most one venfork-managed commit** (subject `chore: venfork-managed mirror commit`, trailer `Venfork-Managed: 1`). That commit carries the sync workflow YAML (filtered by `enabledWorkflows`/`disabledWorkflows`) and any preserved mirror-only files. When that content adds nothing to the upstream tree (for example every preserved file now exists upstream), there is no managed commit and the mirror tip equals the upstream tip (+0). +When scheduled sync, a `preserve` allowlist or a workflow allow/block list is active, the mirror's default branch is kept at **`upstream/` plus at most one venfork-managed commit** (subject `chore: venfork-managed mirror commit`, trailer `Venfork-Managed: 1`). That commit carries the sync workflow YAML (filtered by `enabledWorkflows`/`disabledWorkflows`) and any preserved mirror-only files. When that content adds nothing to the upstream tree (for example every preserved file now exists upstream), there is no managed commit and the mirror tip equals the upstream tip (+0). Determinism rule: the managed commit's author and committer are the venfork bot and both dates are the committer date of the upstream tip, and it is built with `git commit-tree` (no hooks, no signing). Its SHA is therefore a function of the upstream tip and the tree. A sync with no upstream or config change rebuilds the same SHA, sees origin already points at it, and pushes nothing. When a push is needed it is a single `--force-with-lease=refs/heads/:` push; public is skipped when it already equals upstream. @@ -76,7 +76,7 @@ Determinism rule: the managed commit's author and committer are the venfork bot ### Approval gate -With `approval` set (`venfork stage approval`), stage reads the branch's open mirror PR through `gh api` (`src/shared/approval.ts`): the latest approving or changes-requested review per reviewer, candidate approvers' permission, and every check run and status on the head. It pins the rebuild to the approved head with `source.sha`. A lowered level (compared with the clone memory in the git common dir, `src/shared/approval-guard.ts`) and a `--force` bypass each need a person at a TTY; `--yes` never answers those confirms. The bypass is recorded only as a comment on the mirror PR. Nothing about approval goes upstream, to the public fork or into venfork-config. It is a guardrail, not enforcement: never describe it as protection. +With `approval` set (`venfork stage approval`), stage reads the branch's open mirror PR through `gh api` (`src/shared/approval.ts`): the latest approving or changes-requested review per reviewer, candidate approvers' permission, and every check run and status on the head. It pins the rebuild to the approved head with `source.sha`. A lowered level (compared with the clone memory in the git common dir, `src/shared/approval-guard.ts`) and a `--force` bypass each need a person at a TTY; `--yes` never answers those confirms. The bypass is recorded only as a comment on the mirror PR. venfork-config stores only the level. No approval result or bypass record goes upstream, to the public fork or into venfork-config. It is a guardrail, not enforcement: never describe it as protection. ### Health checks diff --git a/docs/agents/gotchas.md b/docs/agents/gotchas.md index 03a0828..bf63661 100644 --- a/docs/agents/gotchas.md +++ b/docs/agents/gotchas.md @@ -43,3 +43,6 @@ In a `$` template, literal text is split on spaces and quotes are kept: `--jq '. ## pull pr can run on the fixture Set the upstream remote to `acme/widget`, a symlink to `upstream.git` inside the clone (excluded in `.git/info/exclude`): `parseRepoPath` reads owner/repo and git reads a relative path. Push the PR head to `refs/pull//head` and put a gh stub that answers `gh pr view` first on PATH. `tests/integration/pull-pr-refresh.test.ts` does this. + +## The approval memory outlives a fixture drive +`venfork stage approval on` then `off` without a TTY leaves `/venfork/approval.json` at `two-person`, so every later non-TTY stage in that clone exits 1 with `gate-weakened`. Delete the file, or use a fresh fixture, before other stage drives.