From 758fcb7f43c76d37586b01fb3dd980568c1a7c06 Mon Sep 17 00:00:00 2001 From: Sebastian Pietrzak Date: Fri, 9 Oct 2026 13:53:55 +0200 Subject: [PATCH 1/4] refactor(cli): one parseRunOptions for run and watch, run-only flags typed and rejected on watch Both subcommands mapped CLI values to RunOptions in their own spread and had already drifted. parseRunOptions is now the single mapping; its exhaustive shape makes a RunOptions field without a parser line a type error. forkScorer and specFile stay run-only (RunOnlyKey), and watch and doctor now reject --fork, --fork-scorer and --spec-file instead of ignoring --fork/--spec-file. The usage text shares one block for the flags both commands print, and --fork-scorer is documented under run only. The RUN_OPTIONS fixture gains the two fallback fields it was missing and is typed so it cannot drift either. The doctor command type drops its never-set forkScorer field. --- src/cli/args.test.ts | 73 ++++++++++++- src/cli/args.ts | 193 ++++++++++++++++++--------------- src/cli/run-options.fixture.ts | 9 +- 3 files changed, 187 insertions(+), 88 deletions(-) diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 89c1119c..635edcec 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, assert } from 'vitest'; -import { parseCli } from './args.js'; +import { parseCli, USAGE } from './args.js'; +import type { SharedRunOptions } from './args.js'; describe('parseCli', () => { it('defaults gc to cwd, 6h, no remote, not dry-run', () => { @@ -222,6 +223,76 @@ describe('parseCli', () => { expect(noFork.kind === 'error' && noFork.message).toMatch(/only applies with --fork/); }); + it('parses the shared RunOptions flags identically on run and watch (one parseRunOptions)', () => { + const shared = [ + '--provider', 'codex', '--review-provider', 'claude', '--fallback-provider', 'cursor', '--fallback-model', 'cursor-fast', + '--provider-model', 'gpt-5', '--review-model', 'claude-opus', '--escalate-model', 'claude-fable-5', + '--no-simplify', '--verify', 'pnpm test', '--visual-proof', 'pnpm shot', '--conformance', '--conformance-model', 'opus', + '--commit-author', 'Seba ', '--flow', 'flow-b', '--base', 'dev', '--max-turns', '80', '--max-repair-iterations', '5', + ]; + // Required<>: a RunOptions field added without an entry here fails to compile, as it does in parseRunOptions. + // --plan is asserted separately because it excludes --flow. + const expected: Required> = { + provider: 'codex', + reviewProvider: 'claude', + fallbackProvider: 'cursor', + fallbackModel: 'cursor-fast', + providerModel: 'gpt-5', + reviewModel: 'claude-opus', + escalateModel: 'claude-fable-5', + noSimplify: true, + verifyCmd: 'pnpm test', + visualProofCmd: 'pnpm shot', + conformance: true, + conformanceModel: 'opus', + commitAuthor: { name: 'Seba', email: 'seba@example.com' }, + flow: 'flow-b', + baseBranch: 'dev', + maxTurns: 80, + maxRepairIterations: 5, + }; + const run = parseCli(['run', '--github', 'o/r#1', ...shared], '/work'); + const watch = parseCli(['watch', '--source', 'github', '--label', 'vanguard', ...shared], '/work'); + expect(run).toMatchObject({ kind: 'run', ...expected }); + expect(watch).toMatchObject({ kind: 'watch', ...expected }); + for (const key of Object.keys(expected)) { + expect(watch[key as keyof typeof watch]).toEqual(run[key as keyof typeof run]); + } + expect(parseCli(['run', '--github', 'o/r#1', '--plan'], '/work')).toMatchObject({ kind: 'run', plan: true }); + expect(parseCli(['watch', '--source', 'github', '--label', 'vanguard', '--plan'], '/work')).toMatchObject({ kind: 'watch', plan: true }); + // Absent flags leave no key on either command. + const bareRun = parseCli(['run', '--github', 'o/r#1'], '/work'); + const bareWatch = parseCli(['watch', '--source', 'github', '--label', 'vanguard'], '/work'); + for (const key of [...Object.keys(expected), 'plan']) { + expect(key in bareRun).toBe(false); + expect(key in bareWatch).toBe(false); + } + }); + + it('rejects the run-only flags on watch and doctor instead of ignoring them', () => { + for (const command of ['watch', 'doctor']) { + const base = [command, '--label', 'vanguard']; + expect(parseCli([...base, '--spec-file', 's.md'], '/work')).toMatchObject({ kind: 'error', message: expect.stringContaining(`every ticket ${command} picks up`) }); + expect(parseCli([...base, '--fork', '3'], '/work')).toMatchObject({ kind: 'error', message: expect.stringContaining(`--fork applies to run only; ${command} does not fork`) }); + expect(parseCli([...base, '--fork-scorer', 'llm'], '/work')).toMatchObject({ kind: 'error', message: expect.stringContaining(`${command} does not fork`) }); + // The same flags are fine on run. + expect(parseCli(['run', '--github', 'o/r#1', '--spec-file', 's.md', '--fork', '3', '--fork-scorer', 'llm'], '/work')).toMatchObject({ kind: 'run', specFile: 's.md', forkN: 3, forkScorer: 'llm' }); + } + }); + + it('documents the shared flags under both run and watch, and the run-only ones under run only', () => { + const watchBlock = USAGE.slice(USAGE.indexOf('watch options (trigger'), USAGE.indexOf('run options (exactly one source)')); + const runBlock = USAGE.slice(USAGE.indexOf('run options (exactly one source)'), USAGE.indexOf('review-pr options:')); + for (const flag of ['--fallback-provider ', '--conformance-model ', '--max-repair-iterations ', '--base ', '--max-turns ']) { + expect(runBlock).toContain(flag); + expect(watchBlock).toContain(flag); + } + for (const flag of ['--fork ', '--fork-scorer ', '--spec-file ']) { + expect(runBlock).toContain(flag); + expect(watchBlock).not.toContain(flag); + } + }); + it('parses --provider-model and --review-model on run', () => { const cmd = parseCli(['run', '--linear', 'TES-1', '--provider-model', 'opus', '--review-model', 'haiku'], '/work'); expect(cmd.kind === 'run' && cmd.providerModel).toBe('opus'); diff --git a/src/cli/args.ts b/src/cli/args.ts index 5ee836f5..162f9a4a 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -128,8 +128,6 @@ export type Command = reviewModel?: string; /** Model for gate repairs after the first one failed (reactive escalation); default: stay on the implementer model. */ escalateModel?: string; - /** How --fork variants are scored: an LLM verdict (default) or a decision model (clef via Cloudflare or VANGUARD_DECISION_URL). */ - forkScorer?: 'llm' | 'decision'; verifyCmd?: string; specModel?: string; specLabel?: string; @@ -294,6 +292,61 @@ function parseForkScorer(raw: unknown): 'llm' | 'decision' | undefined | null { return raw === 'llm' || raw === 'decision' ? raw : null; } +/** + * RunOptions keys only `run` sets: one spec file describes one task, and fork variants need one + * implementer stage. watch processes every ready ticket per poll and never forks, so it rejects them. + */ +type RunOnlyKey = 'forkScorer' | 'specFile'; + +/** + * The RunOptions run and watch read off the same flags. `customProviders` is loaded from the target + * repo at dispatch (provider-choice.ts), never parsed. + */ +export type SharedRunOptions = Omit; + +/** + * Every key of T, present or not — so a RunOptions field added without a line in parseRunOptions + * is a type error, not a flag one subcommand silently drops. + */ +export type Exhaustive = { readonly [K in keyof T]-?: T[K] | undefined }; + +/** Drop the undefined entries: an absent flag leaves no key on the command (callers test with `in`). */ +function present(exhaustive: Exhaustive): T { + return Object.fromEntries(Object.entries(exhaustive).filter(([, value]) => value !== undefined)) as T; +} + +/** + * Shared flags other commands validate too (provider gates, --commit-author, --flow/--plan, the + * turn caps): parseCli checks them once, before any subcommand branch, and hands the results in. + */ +type CheckedRunFlags = Exhaustive< + Pick +>; + +/** The one CLI → RunOptions mapping, used by run and watch so the two cannot drift. */ +function parseRunOptions(values: Record, checked: CheckedRunFlags): SharedRunOptions { + return present({ + provider: checked.provider, + reviewProvider: checked.reviewProvider, + fallbackProvider: checked.fallbackProvider, + fallbackModel: checked.fallbackModel, + providerModel: typeof values['provider-model'] === 'string' ? values['provider-model'] : undefined, + reviewModel: typeof values['review-model'] === 'string' ? values['review-model'] : undefined, + escalateModel: typeof values['escalate-model'] === 'string' ? values['escalate-model'] : undefined, + noSimplify: values['no-simplify'] === true ? true : undefined, + verifyCmd: typeof values.verify === 'string' ? values.verify : undefined, + visualProofCmd: typeof values['visual-proof'] === 'string' ? values['visual-proof'] : undefined, + conformance: values.conformance === true ? true : undefined, + conformanceModel: typeof values['conformance-model'] === 'string' ? values['conformance-model'] : undefined, + commitAuthor: checked.commitAuthor, + plan: values.plan === true ? true : undefined, + flow: checked.flow, + baseBranch: typeof values.base === 'string' ? values.base : undefined, + maxTurns: checked.maxTurns, + maxRepairIterations: checked.maxRepairIterations, + }); +} + const HOUR_MS = 60 * 60 * 1000; const DEFAULT_MAX_AGE_HOURS = 6; const DEFAULT_CONCURRENCY = 2; @@ -548,6 +601,17 @@ export function parseCli(argv: string[], cwd: string): Command { const maxTurns = parseLimit(values['max-turns']); const maxRepairIterations = parseLimit(values['max-repair-iterations']); + const runOptions = parseRunOptions(values, { + provider, + reviewProvider, + fallbackProvider, + fallbackModel, + commitAuthor, + flow: flowRaw, + maxTurns, + maxRepairIterations, + }); + if (positionals[0] === 'stats') { return { kind: 'stats', @@ -830,6 +894,11 @@ export function parseCli(argv: string[], cwd: string): Command { // forkAndSelect runs every variant on the primary agent and returns before the stage fallback is // consulted, so the flag would be accepted and silently never fire. if (fallbackProvider !== undefined && Number.isFinite(forkN) && forkN >= 2) return fail('--fallback-provider does not apply to --fork variants; drop one of them.'); + // The single-task RunOptions (see RunOnlyKey): typed exhaustively so a new run-only field lands here. + const runOnly = present>({ + forkScorer, + specFile: typeof values['spec-file'] === 'string' ? values['spec-file'] : undefined, + }); return { kind: 'run', source: picked[0], @@ -846,26 +915,8 @@ export function parseCli(argv: string[], cwd: string): Command { ...(typeof values['github-repo'] === 'string' ? { repoSlug: values['github-repo'] } : {}), ...(typeof values.label === 'string' ? { label: values.label } : {}), ...(typeof values['gitlab-project'] === 'string' && picked[0] === 'gitlab' ? { project: values['gitlab-project'] } : {}), - ...(provider !== undefined ? { provider } : {}), - ...(reviewProvider !== undefined ? { reviewProvider } : {}), - ...(fallbackProvider !== undefined ? { fallbackProvider } : {}), - ...(fallbackModel !== undefined ? { fallbackModel } : {}), - ...(typeof values['provider-model'] === 'string' ? { providerModel: values['provider-model'] } : {}), - ...(typeof values['review-model'] === 'string' ? { reviewModel: values['review-model'] } : {}), - ...(typeof values['escalate-model'] === 'string' ? { escalateModel: values['escalate-model'] } : {}), - ...(forkScorer !== undefined ? { forkScorer } : {}), - ...(values['no-simplify'] === true ? { noSimplify: true } : {}), - ...(typeof values.verify === 'string' ? { verifyCmd: values.verify } : {}), - ...(typeof values['visual-proof'] === 'string' ? { visualProofCmd: values['visual-proof'] } : {}), - ...(values.conformance === true ? { conformance: true } : {}), - ...(typeof values['conformance-model'] === 'string' ? { conformanceModel: values['conformance-model'] } : {}), - ...(commitAuthor !== undefined ? { commitAuthor } : {}), - ...(values.plan === true ? { plan: true } : {}), - ...(flowRaw !== undefined ? { flow: flowRaw } : {}), - ...(typeof values.base === 'string' ? { baseBranch: values.base } : {}), - ...(maxTurns !== undefined ? { maxTurns } : {}), - ...(maxRepairIterations !== undefined ? { maxRepairIterations } : {}), - ...(typeof values['spec-file'] === 'string' ? { specFile: values['spec-file'] } : {}), + ...runOptions, + ...runOnly, }; } @@ -981,8 +1032,10 @@ export function parseCli(argv: string[], cwd: string): Command { if (values['max-tasks'] !== undefined && maxTasks === undefined) { return fail(`--max-tasks needs a positive integer, got "${String(values['max-tasks'])}".`); } - // watch has no --fork, so a fork scorer there could only be a silent no-op or a spurious failure. - if (values['fork-scorer'] !== undefined) return fail('--fork-scorer applies to `run --fork ` only; watch does not fork.'); + // The single-task flags (RunOnlyKey + --fork): on watch each would be a silent no-op or a spurious failure. + if (values['fork-scorer'] !== undefined) return fail(`--fork-scorer applies to \`run --fork \` only; ${commandKind} does not fork.`); + if (values.fork !== undefined) return fail(`--fork applies to run only; ${commandKind} does not fork.`); + if (values['spec-file'] !== undefined) return fail(`--spec-file applies to a single-issue run only; one spec file cannot describe every ticket ${commandKind} picks up.`); type WatchCommon = Omit, 'kind' | 'concurrency' | 'intervalMs' | 'once' | 'egress'>; const common: WatchCommon = { source, @@ -998,21 +1051,7 @@ export function parseCli(argv: string[], cwd: string): Command { ...(typeof values['review-state'] === 'string' ? { reviewState: values['review-state'] } : {}), ...(typeof values.skills === 'string' ? { skillsDir: values.skills } : {}), ...(typeof values['github-repo'] === 'string' ? { repoSlug: values['github-repo'] } : {}), - ...(provider !== undefined ? { provider } : {}), - ...(reviewProvider !== undefined ? { reviewProvider } : {}), - ...(fallbackProvider !== undefined ? { fallbackProvider } : {}), - ...(fallbackModel !== undefined ? { fallbackModel } : {}), - ...(typeof values['provider-model'] === 'string' ? { providerModel: values['provider-model'] } : {}), - ...(typeof values['review-model'] === 'string' ? { reviewModel: values['review-model'] } : {}), - ...(typeof values['escalate-model'] === 'string' ? { escalateModel: values['escalate-model'] } : {}), - ...(values['no-simplify'] === true ? { noSimplify: true } : {}), - ...(typeof values.verify === 'string' ? { verifyCmd: values.verify } : {}), - ...(commitAuthor !== undefined ? { commitAuthor } : {}), - ...(values.plan === true ? { plan: true } : {}), - ...(flowRaw !== undefined ? { flow: flowRaw } : {}), - ...(typeof values.base === 'string' ? { baseBranch: values.base } : {}), - ...(maxTurns !== undefined ? { maxTurns } : {}), - ...(maxRepairIterations !== undefined ? { maxRepairIterations } : {}), + ...runOptions, ...(proxyMode ? { llmProxy: true } : {}), // Loop v1 fields (omitted when not supplied, preserving existing behaviour when absent). ...(typeof values['spec-model'] === 'string' ? { specModel: values['spec-model'] } : {}), @@ -1029,15 +1068,14 @@ export function parseCli(argv: string[], cwd: string): Command { }; if (commandKind === 'doctor') { - return { kind: 'doctor', ...common, ...(values.fix === true ? { fix: true } : {}) }; + // Preflight never runs a stage: the proof/conformance knobs stay off the doctor command. + const { visualProofCmd: _visualProofCmd, conformance: _conformance, conformanceModel: _conformanceModel, ...checks } = common; + return { kind: 'doctor', ...checks, ...(values.fix === true ? { fix: true } : {}) }; } return { kind: 'watch', ...common, - ...(typeof values['visual-proof'] === 'string' ? { visualProofCmd: values['visual-proof'] } : {}), - ...(values.conformance === true ? { conformance: true } : {}), - ...(typeof values['conformance-model'] === 'string' ? { conformanceModel: values['conformance-model'] } : {}), concurrency: Number.isFinite(concurrency) && concurrency >= 1 ? Math.floor(concurrency) : DEFAULT_CONCURRENCY, intervalMs: (Number.isFinite(interval) && interval > 0 ? interval : 60) * 1000, once: values.once === true, @@ -1048,6 +1086,27 @@ export function parseCli(argv: string[], cwd: string): Command { return { kind: 'help' }; } +/** Option lines run and watch print verbatim — one source, so the two usage blocks cannot drift either. */ +const SHARED_RUN_OPTIONS_USAGE = ` --provider Provider that runs every stage (default: claude) + run/watch/doctor also accept a custom provider name from the repo's + .vanguard/app.json customProviders (S6) — direct mode only. + --review-provider Run only the review stage on this provider (cross-provider review) + --fallback-provider Retry on this provider when the primary one fails (outage, limit, revoked credential) — the implementer and every other stage on its provider; must sit on a different transport (e.g. codex for a claude implementer) + --fallback-model Model for every stage that fell back (default: that provider's default) + --provider-model Model for the implementer/simplifier stages (default: provider's default; zai -> glm-5.2) + --review-model Model for the review stage (default: provider's default) + --escalate-model Model for the 2nd and later gate repairs, once a repair on the implementer + model has failed (default: stay on the implementer model) + --no-simplify Skip the simplifier stage (lean: implement -> review only) + --verify Verification command for Proof of Work (overrides VANGUARD_VERIFY_CMD and auto-detect) + --visual-proof Visual proof command for UI artifacts (overrides VANGUARD_VISUAL_PROOF_CMD) + --conformance Run the conformance pass (planner-tier model checks diff against spec; opt-in) + --conformance-model Model for the conformance stage (default: same as implementer; 'opus' for planner-tier) + --commit-author Git author for the commit, "Name " (also enables white-label mode: feat/ branch, no Vanguard branding/review comment) + --plan Add a dedicated planning stage first (opus, high effort) before implement/review + --flow Run a named workflow (e.g. flow-b: plan -> implement -> adversary -> repair). --plan == --flow plan + --max-repair-iterations Override the conformance/verify repair loop-back cap (default: 2)`; + export const USAGE = `vanguard Commands: @@ -1094,29 +1153,9 @@ Commands: state name "Spec", needs-info state "Needs Info"). For GitHub, a repo-only watch without --label also uses the routing-label defaults. --skills --repo --concurrency --egress (as for run) - --provider Provider that runs every stage (default: claude) - run/watch/doctor also accept a custom provider name from the repo's - .vanguard/app.json customProviders (S6) — direct mode only. - --review-provider Run only the review stage on this provider (cross-provider review) - --fallback-provider Retry on this provider when the primary one fails (outage, limit, revoked credential) — the implementer and every other stage on its provider; must sit on a different transport (e.g. codex for a claude implementer) - --fallback-model Model for every stage that fell back (default: that provider's default) - --provider-model Model for the implementer/simplifier stages (default: provider's default) - --review-model Model for the review stage (default: provider's default) - --escalate-model Model for the 2nd and later gate repairs, once a repair on the implementer - model has failed (default: stay on the implementer model) - --fork-scorer How --fork variants are scored: a one-shot LLM verdict (default) or a - decision model (clef; needs CLOUDFLARE_ACCOUNT_ID+CLOUDFLARE_AUTH_TOKEN or VANGUARD_DECISION_URL) - --no-simplify Skip the simplifier stage (lean: implement -> review only) - --verify Verification command for Proof of Work (overrides VANGUARD_VERIFY_CMD and auto-detect) - --visual-proof Visual proof command for UI artifacts (overrides VANGUARD_VISUAL_PROOF_CMD) - --conformance Run the conformance pass (planner-tier model checks diff against spec; opt-in) - --conformance-model Model for the conformance stage (default: same as implementer; 'opus' for planner-tier) - --commit-author Git author for the commit, "Name " (also enables white-label mode: feat/ branch, no Vanguard branding/review comment) +${SHARED_RUN_OPTIONS_USAGE} --base Base branch to branch off and target the PR at; also the loop-v1 spec pass's research baseline (default: main) - --plan Add a dedicated planning stage first (opus, high effort) before implement/review - --flow Run a named workflow (e.g. flow-b: plan -> implement -> adversary -> repair). --plan == --flow plan --max-turns Override the implementer (or loop-v1 spec pass's tech-spec) stage turn cap (default: 30; opt-in, higher cost) - --max-repair-iterations Override the conformance/verify repair loop-back cap (default: 2) Note (project): Status option names must match the project's Status field exactly. Resolve field and option IDs with: gh project field-list --owner --format json @@ -1190,29 +1229,13 @@ Commands: --skills Skills directory to inject (Linear: the linear-cli skill) --github-repo GitHub repo slug (default: detected from origin) --concurrency (parent/project) max tasks at once (default: 2) - --provider Provider that runs every stage (default: claude) - run/watch/doctor also accept a custom provider name from the repo's - .vanguard/app.json customProviders (S6) — direct mode only. - --review-provider Run only the review stage on this provider (cross-provider review) - --fallback-provider Retry on this provider when the primary one fails (outage, limit, revoked credential) — the implementer and every other stage on its provider; must sit on a different transport (e.g. codex for a claude implementer) - --fallback-model Model for every stage that fell back (default: that provider's default) - --provider-model Model for the implementer/simplifier stages (default: provider's default; zai -> glm-5.2) - --review-model Model for the review stage (default: provider's default) - --escalate-model Model for the 2nd and later gate repairs, once a repair on the implementer - model has failed (default: stay on the implementer model) - --no-simplify Skip the simplifier stage (lean: implement -> review only) - --fork Run the implementer as n variants (n>=2) and keep the best-scored diff - --verify Verification command for Proof of Work (overrides VANGUARD_VERIFY_CMD and auto-detect) - --visual-proof Visual proof command for UI artifacts (overrides VANGUARD_VISUAL_PROOF_CMD) - --conformance Run the conformance pass (planner-tier model checks diff against spec; opt-in) - --conformance-model Model for the conformance stage (default: same as implementer; 'opus' for planner-tier) - --commit-author Git author for the commit, "Name " (also enables white-label mode: feat/ branch, no Vanguard branding/review comment) +${SHARED_RUN_OPTIONS_USAGE} --base Base branch to branch off and target the PR at (default: main) - --plan Add a dedicated planning stage first (opus, high effort) before implement/review - --flow Run a named workflow (e.g. flow-b: plan -> implement -> adversary -> repair). --plan == --flow plan --max-turns Override the implementer stage turn cap (default: 30; opt-in, higher cost) - --max-repair-iterations Override the conformance/verify repair loop-back cap (default: 2) - --spec-file Inject a local spec file as a virtual issue comment (implementer + conformance read it; nothing is posted to the tracker) + --fork Run the implementer as n variants (n>=2) and keep the best-scored diff + --fork-scorer How --fork variants are scored: a one-shot LLM verdict (default) or a + decision model (clef; needs CLOUDFLARE_ACCOUNT_ID+CLOUDFLARE_AUTH_TOKEN or VANGUARD_DECISION_URL) + --spec-file Inject a local spec file as a virtual issue comment (implementer + conformance read it; nothing is posted to the tracker) review-pr options: GitHub PR URL, owner/repo#number, or bare number with --github-repo diff --git a/src/cli/run-options.fixture.ts b/src/cli/run-options.fixture.ts index 473e3f39..eb9ad750 100644 --- a/src/cli/run-options.fixture.ts +++ b/src/cli/run-options.fixture.ts @@ -1,11 +1,16 @@ +import type { Exhaustive, SharedRunOptions } from './args.js'; + /** * Every shared RunOptions flag set to a non-default value. The run/watch deps-threading tests assert * each per-source builder carries this whole object through `pickRunOptions`, so a dropped spread is - * caught — a guard the type system cannot give, since RunOptions' fields are all optional. + * caught — a guard the type system cannot give, since RunOptions' fields are all optional. The + * `satisfies` keeps this list itself complete: a new shared field without a value here fails to compile. */ export const RUN_OPTIONS = { provider: 'codex', reviewProvider: 'cursor', + fallbackProvider: 'claude', + fallbackModel: 'claude-sonnet-5-5', providerModel: 'gpt-5', reviewModel: 'claude-opus', noSimplify: true, @@ -22,4 +27,4 @@ export const RUN_OPTIONS = { maxTurns: 80, maxRepairIterations: 5, escalateModel: 'claude-fable-5', -} as const; +} as const satisfies Exhaustive; From ec33d722e240bfae89b395f59ea6e586328a85bf Mon Sep 17 00:00:00 2001 From: Sebastian Pietrzak Date: Fri, 9 Oct 2026 14:05:28 +0200 Subject: [PATCH 2/4] refactor(cli): --spec-file rejection names what watch and doctor do, doctor usage lists the run-only flags Review round 1: the interpolated message claimed doctor picks up tickets; a fixed sentence is true for both commands. The doctor usage section now says it rejects --fork, --fork-scorer and --spec-file like watch, and the rejection test states --source and asserts the run side once. --- src/cli/args.test.ts | 8 ++++---- src/cli/args.ts | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 635edcec..5d767359 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -271,13 +271,13 @@ describe('parseCli', () => { it('rejects the run-only flags on watch and doctor instead of ignoring them', () => { for (const command of ['watch', 'doctor']) { - const base = [command, '--label', 'vanguard']; - expect(parseCli([...base, '--spec-file', 's.md'], '/work')).toMatchObject({ kind: 'error', message: expect.stringContaining(`every ticket ${command} picks up`) }); + const base = [command, '--source', 'linear', '--label', 'vanguard']; + expect(parseCli([...base, '--spec-file', 's.md'], '/work')).toMatchObject({ kind: 'error', message: expect.stringContaining('--spec-file applies to a single-issue run only') }); expect(parseCli([...base, '--fork', '3'], '/work')).toMatchObject({ kind: 'error', message: expect.stringContaining(`--fork applies to run only; ${command} does not fork`) }); expect(parseCli([...base, '--fork-scorer', 'llm'], '/work')).toMatchObject({ kind: 'error', message: expect.stringContaining(`${command} does not fork`) }); - // The same flags are fine on run. - expect(parseCli(['run', '--github', 'o/r#1', '--spec-file', 's.md', '--fork', '3', '--fork-scorer', 'llm'], '/work')).toMatchObject({ kind: 'run', specFile: 's.md', forkN: 3, forkScorer: 'llm' }); } + // The same flags are fine on run. + expect(parseCli(['run', '--github', 'o/r#1', '--spec-file', 's.md', '--fork', '3', '--fork-scorer', 'llm'], '/work')).toMatchObject({ kind: 'run', specFile: 's.md', forkN: 3, forkScorer: 'llm' }); }); it('documents the shared flags under both run and watch, and the run-only ones under run only', () => { diff --git a/src/cli/args.ts b/src/cli/args.ts index 162f9a4a..09851afd 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1035,7 +1035,7 @@ export function parseCli(argv: string[], cwd: string): Command { // The single-task flags (RunOnlyKey + --fork): on watch each would be a silent no-op or a spurious failure. if (values['fork-scorer'] !== undefined) return fail(`--fork-scorer applies to \`run --fork \` only; ${commandKind} does not fork.`); if (values.fork !== undefined) return fail(`--fork applies to run only; ${commandKind} does not fork.`); - if (values['spec-file'] !== undefined) return fail(`--spec-file applies to a single-issue run only; one spec file cannot describe every ticket ${commandKind} picks up.`); + if (values['spec-file'] !== undefined) return fail('--spec-file applies to a single-issue run only (watch reads every ticket from the tracker; doctor claims none).'); type WatchCommon = Omit, 'kind' | 'concurrency' | 'intervalMs' | 'once' | 'egress'>; const common: WatchCommon = { source, @@ -1389,6 +1389,7 @@ ${SHARED_RUN_OPTIONS_USAGE} doctor options: Uses the same source/routing flags as watch, but only runs AFK preflight checks and exits. + Like watch, it rejects the run-only flags --fork, --fork-scorer and --spec-file. Add --spec-only to check a spec-only loop-v1 watch: it skips the checks that only guard publishing. Example (GitHub): vanguard doctor --source github --github-repo owner/repo Example (Linear): vanguard doctor --loop-v1 --label vanguard --skills ./skills From b399d922a5599a8b7b1e25059055eee828a1ab30 Mon Sep 17 00:00:00 2001 From: Sebastian Pietrzak Date: Fri, 9 Oct 2026 14:29:00 +0200 Subject: [PATCH 3/4] refactor(cli): doctor command type derives its RunOptions share, usage keeps the old flag order Review round 2: the doctor variant is Omit instead of a hand-written field list that had drifted from what the parser sets; a test pins that doctor carries every shared flag except the three stage-only ones. parseRunOptions is called next to its two uses, after the early-returning subcommands, and --max-repair-iterations prints after --base/--max-turns again in both usage sections. --- src/cli/args.test.ts | 29 +++++++++++++++++++++++++ src/cli/args.ts | 51 ++++++++++++++++++++++---------------------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 5d767359..a9e4bf51 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -269,6 +269,35 @@ describe('parseCli', () => { } }); + it('carries every shared flag onto doctor except the stage-only three', () => { + const argv = [ + 'doctor', '--source', 'github', '--label', 'vanguard', + '--provider', 'codex', '--review-provider', 'claude', '--fallback-provider', 'cursor', '--fallback-model', 'cursor-fast', + '--provider-model', 'gpt-5', '--review-model', 'claude-opus', '--escalate-model', 'claude-fable-5', + '--no-simplify', '--verify', 'pnpm test', '--visual-proof', 'pnpm shot', '--conformance', '--conformance-model', 'opus', + '--commit-author', 'Seba ', '--flow', 'flow-b', '--base', 'dev', '--max-turns', '80', '--max-repair-iterations', '5', + ]; + const doctor = parseCli(argv, '/work'); + expect(doctor).toMatchObject({ + kind: 'doctor', + provider: 'codex', + reviewProvider: 'claude', + fallbackProvider: 'cursor', + fallbackModel: 'cursor-fast', + providerModel: 'gpt-5', + reviewModel: 'claude-opus', + escalateModel: 'claude-fable-5', + noSimplify: true, + verifyCmd: 'pnpm test', + commitAuthor: { name: 'Seba', email: 'seba@example.com' }, + flow: 'flow-b', + baseBranch: 'dev', + maxTurns: 80, + maxRepairIterations: 5, + }); + for (const key of ['visualProofCmd', 'conformance', 'conformanceModel']) expect(key in doctor).toBe(false); + }); + it('rejects the run-only flags on watch and doctor instead of ignoring them', () => { for (const command of ['watch', 'doctor']) { const base = [command, '--source', 'linear', '--label', 'vanguard']; diff --git a/src/cli/args.ts b/src/cli/args.ts index 09851afd..4ddd0c3e 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -103,7 +103,7 @@ export type Command = provider?: ProviderName; llmProxy?: boolean; } - | { + | ({ kind: 'doctor'; /** Repair what the checks can repair (today: refresh the sandbox image's claude CLI). */ fix?: boolean; @@ -120,15 +120,8 @@ export type Command = repoSlug?: string; repoPath: string; skillsDir?: string; - provider?: string; - reviewProvider?: string; - fallbackProvider?: string; - fallbackModel?: string; - providerModel?: string; - reviewModel?: string; - /** Model for gate repairs after the first one failed (reactive escalation); default: stay on the implementer model. */ - escalateModel?: string; - verifyCmd?: string; + /** Cap on ready tasks per poll; parsed with watch's flags, read by nothing doctor runs. */ + maxTasks?: number; specModel?: string; specLabel?: string; agentLabel?: string; @@ -141,7 +134,7 @@ export type Command = specClaimedState?: string; specOnly?: boolean; llmProxy?: boolean; - } + } & Omit) | ({ kind: 'run'; source: 'linear' | 'github' | 'project' | 'gitlab'; @@ -304,6 +297,9 @@ type RunOnlyKey = 'forkScorer' | 'specFile'; */ export type SharedRunOptions = Omit; +/** Shared keys only a running stage reads: doctor's preflight never runs one, so they stay off its command. */ +type StageOnlyKey = 'visualProofCmd' | 'conformance' | 'conformanceModel'; + /** * Every key of T, present or not — so a RunOptions field added without a line in parseRunOptions * is a type error, not a flag one subcommand silently drops. @@ -601,17 +597,6 @@ export function parseCli(argv: string[], cwd: string): Command { const maxTurns = parseLimit(values['max-turns']); const maxRepairIterations = parseLimit(values['max-repair-iterations']); - const runOptions = parseRunOptions(values, { - provider, - reviewProvider, - fallbackProvider, - fallbackModel, - commitAuthor, - flow: flowRaw, - maxTurns, - maxRepairIterations, - }); - if (positionals[0] === 'stats') { return { kind: 'stats', @@ -862,6 +847,18 @@ export function parseCli(argv: string[], cwd: string): Command { }; } + // Shared by the two branches below; every command above returned already. + const runOptions = parseRunOptions(values, { + provider, + reviewProvider, + fallbackProvider, + fallbackModel, + commitAuthor, + flow: flowRaw, + maxTurns, + maxRepairIterations, + }); + if (positionals[0] === 'run') { const sources: Array<['linear' | 'github' | 'project' | 'gitlab', string]> = []; if (typeof values.linear === 'string') sources.push(['linear', values.linear]); @@ -1068,7 +1065,7 @@ export function parseCli(argv: string[], cwd: string): Command { }; if (commandKind === 'doctor') { - // Preflight never runs a stage: the proof/conformance knobs stay off the doctor command. + // StageOnlyKey: the proof/conformance knobs stay off the doctor command. const { visualProofCmd: _visualProofCmd, conformance: _conformance, conformanceModel: _conformanceModel, ...checks } = common; return { kind: 'doctor', ...checks, ...(values.fix === true ? { fix: true } : {}) }; } @@ -1104,8 +1101,10 @@ const SHARED_RUN_OPTIONS_USAGE = ` --provider Model for the conformance stage (default: same as implementer; 'opus' for planner-tier) --commit-author Git author for the commit, "Name " (also enables white-label mode: feat/ branch, no Vanguard branding/review comment) --plan Add a dedicated planning stage first (opus, high effort) before implement/review - --flow Run a named workflow (e.g. flow-b: plan -> implement -> adversary -> repair). --plan == --flow plan - --max-repair-iterations Override the conformance/verify repair loop-back cap (default: 2)`; + --flow Run a named workflow (e.g. flow-b: plan -> implement -> adversary -> repair). --plan == --flow plan`; + +/** Printed after each section's own --base/--max-turns lines. */ +const MAX_REPAIR_ITERATIONS_USAGE = ' --max-repair-iterations Override the conformance/verify repair loop-back cap (default: 2)'; export const USAGE = `vanguard @@ -1156,6 +1155,7 @@ Commands: ${SHARED_RUN_OPTIONS_USAGE} --base Base branch to branch off and target the PR at; also the loop-v1 spec pass's research baseline (default: main) --max-turns Override the implementer (or loop-v1 spec pass's tech-spec) stage turn cap (default: 30; opt-in, higher cost) +${MAX_REPAIR_ITERATIONS_USAGE} Note (project): Status option names must match the project's Status field exactly. Resolve field and option IDs with: gh project field-list --owner --format json @@ -1232,6 +1232,7 @@ ${SHARED_RUN_OPTIONS_USAGE} ${SHARED_RUN_OPTIONS_USAGE} --base Base branch to branch off and target the PR at (default: main) --max-turns Override the implementer stage turn cap (default: 30; opt-in, higher cost) +${MAX_REPAIR_ITERATIONS_USAGE} --fork Run the implementer as n variants (n>=2) and keep the best-scored diff --fork-scorer How --fork variants are scored: a one-shot LLM verdict (default) or a decision model (clef; needs CLOUDFLARE_ACCOUNT_ID+CLOUDFLARE_AUTH_TOKEN or VANGUARD_DECISION_URL) From 6c9ef04ea947901eb5c95399c406fad970829a58 Mon Sep 17 00:00:00 2001 From: Sebastian Pietrzak Date: Fri, 9 Oct 2026 14:50:26 +0200 Subject: [PATCH 4/4] fix(cli): watch command type excludes the run-only RunOptions, usage and bare-command tests cannot pass vacuously Review round 3: the watch variant is Omit (it keeps customProviders, which dispatch loads onto the command), so setting forkScorer or specFile on a watch command is a type error, not only a parse-time rejection. The --spec-file rejection names the invoking command with a clause true for watch and doctor alike. The usage test asserts the section headings exist in order, and the absent-flag test asserts the bare commands parsed as run and watch. --- src/cli/args.test.ts | 12 ++++++++++-- src/cli/args.ts | 5 +++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index a9e4bf51..ddf49122 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -263,6 +263,8 @@ describe('parseCli', () => { // Absent flags leave no key on either command. const bareRun = parseCli(['run', '--github', 'o/r#1'], '/work'); const bareWatch = parseCli(['watch', '--source', 'github', '--label', 'vanguard'], '/work'); + expect(bareRun.kind).toBe('run'); + expect(bareWatch.kind).toBe('watch'); for (const key of [...Object.keys(expected), 'plan']) { expect(key in bareRun).toBe(false); expect(key in bareWatch).toBe(false); @@ -310,8 +312,14 @@ describe('parseCli', () => { }); it('documents the shared flags under both run and watch, and the run-only ones under run only', () => { - const watchBlock = USAGE.slice(USAGE.indexOf('watch options (trigger'), USAGE.indexOf('run options (exactly one source)')); - const runBlock = USAGE.slice(USAGE.indexOf('run options (exactly one source)'), USAGE.indexOf('review-pr options:')); + const watchStart = USAGE.indexOf('watch options (trigger'); + const runStart = USAGE.indexOf('run options (exactly one source)'); + const runEnd = USAGE.indexOf('review-pr options:'); + expect(watchStart).toBeGreaterThan(-1); + expect(runStart).toBeGreaterThan(watchStart); + expect(runEnd).toBeGreaterThan(runStart); + const watchBlock = USAGE.slice(watchStart, runStart); + const runBlock = USAGE.slice(runStart, runEnd); for (const flag of ['--fallback-provider ', '--conformance-model ', '--max-repair-iterations ', '--base ', '--max-turns ']) { expect(runBlock).toContain(flag); expect(watchBlock).toContain(flag); diff --git a/src/cli/args.ts b/src/cli/args.ts index 4ddd0c3e..c7cd80d3 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -214,7 +214,8 @@ export type Command = specClaimedState?: string; /** (loop-v1) Run only the spec pass each tick; the agent pass is skipped (no list, claim or run). */ specOnly?: boolean; - } & RunOptions) + // customProviders stays: dispatch loads the repo customs onto the command (watch.ts); only RunOnlyKey is excluded. + } & Omit) | { kind: 'review-mr'; iid: number; @@ -1032,7 +1033,7 @@ export function parseCli(argv: string[], cwd: string): Command { // The single-task flags (RunOnlyKey + --fork): on watch each would be a silent no-op or a spurious failure. if (values['fork-scorer'] !== undefined) return fail(`--fork-scorer applies to \`run --fork \` only; ${commandKind} does not fork.`); if (values.fork !== undefined) return fail(`--fork applies to run only; ${commandKind} does not fork.`); - if (values['spec-file'] !== undefined) return fail('--spec-file applies to a single-issue run only (watch reads every ticket from the tracker; doctor claims none).'); + if (values['spec-file'] !== undefined) return fail(`--spec-file applies to a single-issue run only; ${commandKind} has no single issue to attach it to.`); type WatchCommon = Omit, 'kind' | 'concurrency' | 'intervalMs' | 'once' | 'egress'>; const common: WatchCommon = { source,