Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,43 @@
All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Changed

- The reviewer gate now records its verdict in a first-class, signed
`reviewer_gate` snapshot on the review file instead of the shared
`review.verdict` field. The snapshot carries its own reviewer, attempt,
verdict, reviewed commit range, and an HMAC signature keyed by a
machine-local secret (`~/.repokernel/gate.key`). Because no other command
writes that key, `rk review-sprint`, `rk review-panel`, `rk review-verdict`,
and `rk re-review` can no longer clear or overwrite a recorded gate decision.
- `rk close` (and every path through it — `rk ship`, autonomous runs, epic
close) now requires **both** the built-in/panel review verdict **and** the
gate snapshot to be green, using most-restrictive-wins composition. The
snapshot must be present, signature-valid, bound to the current review
attempt, accepted, and content-fresh against its reviewed commit — always on,
not bypassable with `--skip-checks`.

### Added

- `reviewer_gate` snapshot integrity validation. Live `rk validate` flags a
shipped, gate-required sprint whose **present** snapshot is not accepted, on a
stale attempt, or base-drifted (`REVIEWER_GATE_NOT_ACCEPTED`,
`REVIEWER_GATE_ATTEMPT_MISMATCH`, `REVIEWER_GATE_STALE`). A **missing**
snapshot on already-shipped history is audit-scope only
(`rk validate --audit` → `REVIEWER_GATE_MISSING`), so upgrading a repo with
pre-snapshot shipped sprints does not flood live validation. Signature
authenticity is enforced at close, the only path that ships.
- `rk status` surfaces the gate verdict and attempt alongside the review.

### Migration

- Existing review files parse unchanged (the field is optional). A project that
configures a reviewer gate and has a sprint already in review from before this
change has no snapshot yet; run `rk review-gate <sprint-id>` once to record
one before closing. Projects with no configured reviewer gate are unaffected.

## [1.32.0] - 2026-06-02

Workflow ergonomics, validation, and ship-safety fixes from a dogfooding
Expand Down
24 changes: 24 additions & 0 deletions docs/trust.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ A grant on the host repo applies to its worktrees. rk reads the worktree's
and looks up the trust grant under either path. You grant once, every
worktree under that repo inherits.

## Reviewer gate signing key

When a reviewer gate runs, it records its verdict in a signed `reviewer_gate`
snapshot on the review file. The signature is an HMAC keyed by a machine-local
secret at `~/.repokernel/gate.key` (mode `600`), minted automatically on the
first gate run. It is co-located with the trust file and on the same side of the
boundary: it never enters the repo, so a snapshot committed into a review file
cannot be forged, lifted into another review, or replayed across commit ranges.

`rk close` verifies the signature against this key. Consequences:

- Close happens on the machine that ran the gate (which holds the key), so a
legitimate close always verifies. Closing on a machine without the key fails
closed with `REVIEWER_GATE_SIGNATURE_INVALID` — re-run the gate there.
- The path is overridable via `REPOKERNEL_GATE_SECRET_FILE`. When unset it
follows the trust file's directory, so isolating `REPOKERNEL_TRUST_FILE`
(e.g. in tests) isolates the gate key too.
- `rk validate` checks snapshot structure without the key, so CI catches
structural tampering even though it cannot verify the signature. A present
snapshot that is not accepted / on a stale attempt / base-drifted is flagged
at **live** scope; a **missing** snapshot on already-shipped history is
flagged only under `rk validate --audit` (a past close cannot be backfilled,
and forward enforcement happens at close).

## Error kinds

| Kind | When | What to do |
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/commands/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { runConfiguredChecksFromConfig } from '../lifecycle/checks.js';
import { isWorktreeCheckout } from '../lifecycle/controlPaths.js';
import { classifySprintDiff, inScopeFiles } from '../lifecycle/diffClassifier.js';
import { isExternalAgentEnvironment } from '../lifecycle/executionOwnership.js';
import { evaluateReviewerGate } from '../lifecycle/gateEnforce.js';
import {
changedFilesForSprint,
changedFilesSince,
Expand Down Expand Up @@ -670,6 +671,22 @@ export async function runCloseCommand(
'accept the review before closing',
);
}
// Reviewer-gate snapshot gate — independent of review.verdict and always
// on (not bypassable with --skip-checks). When the project configures a
// reviewer gate for a review-required sprint, close additionally requires
// a present, signed, current-attempt, accepted, fresh snapshot. The
// snapshot lives in a field no other command writes, so review-sprint /
// panel / review-verdict / re-review cannot clear or satisfy it.
const gateEval = await evaluateReviewerGate({
checkPath: await resolveCloseCheckPath(id, cwd),
config: outcome.config,
sprint,
review,
configFile: relative(cwd, outcome.configPath),
});
if (!gateEval.ok) {
return err(gateEval.block.code, gateEval.block.message, gateEval.block.hint);
}
// Strong binding for gated reviews: end_sha pins the exact reviewed commit.
// Any in-scope file that changed since then is unreviewed CONTENT (a
// same-file edit keeps the filename set identical, so the file-set guard
Expand Down Expand Up @@ -731,6 +748,25 @@ export async function runCloseCommand(
);
}
}
} else if (sprint.review_id) {
// Not review-required by current policy — but a recorded reviewer_gate
// snapshot is a commitment that a later config change (opt out / raise the
// threshold) must not be able to void. Enforce the gate lane on its own;
// the built-in verdict lane is not required when policy does not require
// review. `evaluateReviewerGate` no-ops when no snapshot is present.
const review = outcome.graph.reviews.get(sprint.review_id);
if (review?.reviewer_gate) {
const gateEval = await evaluateReviewerGate({
checkPath: await resolveCloseCheckPath(id, cwd),
config: outcome.config,
sprint,
review,
configFile: relative(cwd, outcome.configPath),
});
if (!gateEval.ok) {
return err(gateEval.block.code, gateEval.block.message, gateEval.block.hint);
}
}
}

if (opts.dryRun) return dryRunOk('close', { id, from: sprint.status, to: 'shipped' });
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/commands/reviewGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ export async function runReviewerGateForLinkedSprint(
const review = outcome.graph.reviews.get(sprint.review_id);
if (!review) return blocked(`review ${sprint.review_id} not found`);

// Refuse to run the gate against a review that targets a different sprint —
// it would overwrite that review's snapshot (poisoning the legitimate file).
if (review.sprint_id !== sprint.id) {
return blocked(
`review ${review.id} targets sprint ${review.sprint_id}, not ${sprint.id}`,
`link a review for ${sprint.id} (rk review-create --sprint ${sprint.id})`,
);
}

// Resolve the gate from the LINKED review's reviewer, not the project default —
// `review-create --reviewer <name>` may have stamped a specific reviewer, and
// the gate must run the reviewer the review was actually created for.
Expand Down Expand Up @@ -141,10 +150,19 @@ function formatGateResult(
sprint_id: ctx.sprintId,
review_id: ctx.reviewId,
reviewer: ctx.reviewerName,
// `verdict` is the GATE verdict (one of two close lanes), NOT a
// close-ready signal. Surface it unambiguously and spell out the next
// step so automation does not treat `accepted` as "ready to close".
gate_verdict: result.verdict,
verdict: result.verdict,
reviewer_gate_recorded: true,
findings: result.findings,
...(result.summary ? { summary: result.summary } : {}),
...(result.failSoft ? { fail_soft: result.failSoft } : {}),
next_actions:
result.verdict === 'accepted'
? [`rk review-sprint ${ctx.sprintId}`, `rk close ${ctx.sprintId}`]
: [`rk review-gate ${ctx.sprintId}`],
}),
stderr: '',
};
Expand All @@ -166,7 +184,7 @@ function formatGateResult(
lines.push(
'',
result.verdict === 'accepted'
? `Next: rk close ${ctx.sprintId}`
? `Next: rk review-sprint ${ctx.sprintId} (built-in lane), then rk close ${ctx.sprintId}`
: `Fix findings, commit, then re-run the gate: rk review-gate ${ctx.sprintId}`,
);
return { exitCode: result.exitCode, stdout: `${lines.join('\n')}\n`, stderr: '' };
Expand Down
81 changes: 79 additions & 2 deletions packages/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type Config,
type EpicId,
effectiveReviewer,
effectiveReviewRequired,
HALT_REASONS,
loadProject,
meetsThreshold,
Expand All @@ -13,6 +14,7 @@ import {
RepoKernelError,
type Run,
type RunSprintRecord,
resolveReviewerGate,
runValidators,
type SprintId,
} from '@repokernel/core';
Expand Down Expand Up @@ -54,6 +56,7 @@ import {
import { isoNow } from '../templates/time.js';
import { buildChain } from './chain.js';
import { runCloseCommand, runReviewCommand, runStartCommand } from './lifecycle.js';
import { runReviewSprintCommand } from './reviewSprint.js';
import { type EpicPreflightResult, epicPreflight, renderPreflight } from './runPreflight.js';
import type { CommandResult } from './validate.js';

Expand Down Expand Up @@ -403,6 +406,25 @@ export async function runRunCommand(opts: RunCommandOptions): Promise<CommandRes
);

if (effectiveStrategy === 'parallel') {
// The parallel path does not run the reviewer-gate pipeline (no `rk review`
// gate, no review-sprint). If a gate is configured and any epic sprint
// requires review, refuse upfront instead of dead-ending at close — those
// sprints must ship via sequential `rk run` or be gated + closed manually.
if (resolveReviewerGate(outcome.config.automation) !== null) {
const gated = [...outcome.graph.sprints.values()].find(
(s) =>
s.epic_id === opts.epicId &&
!['shipped', 'cancelled'].includes(s.status) &&
effectiveReviewRequired(s, outcome.config),
);
if (gated) {
return err(
'PARALLEL_GATE_UNSUPPORTED',
`epic ${opts.epicId} configures a reviewer gate and ${gated.id} requires review, but parallel runs do not execute the reviewer gate`,
`run sequentially (omit --parallel / set execution_strategy: sequential), or gate + close each sprint manually with rk review-gate`,
);
}
}
return executeParallelRunLoop(
run,
opRoot,
Expand Down Expand Up @@ -771,8 +793,63 @@ async function executeRunLoop(
return reviewResult;
}

// runReviewCommand auto-commits its review-side `.repokernel/` mutations,
// leaving a clean tree for runCloseCommand (which requires one) below.
// Built-in review lane. `rk review` runs the reviewer gate (when one is
// configured), which records ONLY its signed snapshot and leaves
// review.verdict pending. Evaluate the built-in rules so the composed
// close gate (snapshot + review.verdict) can pass, then commit the verdict
// so the tree is clean for runCloseCommand.
const evalResult = await runReviewSprintCommand(sprint.id, {
cwd: executionCwd,
dryRun: false,
json: false,
});
if (evalResult.exitCode !== EXIT_OK) {
run = await updateRun(
run.id,
{
status: 'failed',
halt_reason: `${HALT_REASONS.REVIEW_FAILED}:${sprint.id}`,
ended_at: isoNow(),
},
opRoot,
);
await releaseLane(`epic-${run.epic_id}`, opRoot, run.id);
return evalResult;
}
const evalOutcome = await loadProject({ cwd: executionCwd });
const evalReview = evalOutcome.ok
? evalOutcome.graph.reviews.get(evalOutcome.graph.sprints.get(sprint.id)?.review_id ?? '')
: undefined;
if (evalReview?.file) {
await stagePathsAndCommit(
executionCwd,
[join(executionCwd, evalReview.file), join(executionCwd, config.paths.registry)],
`chore(rk): record review verdict for ${sprint.id}`,
);
}
// `review-sprint` exits 0 even when it records changes_requested/rejected.
// Halt as a REVIEW failure (not a downstream CLOSE failure) so run state
// names the real cause.
if (evalReview && evalReview.verdict !== 'accepted') {
run = await updateRun(
run.id,
{
status: 'failed',
halt_reason: `${HALT_REASONS.REVIEW_FAILED}:${sprint.id}`,
ended_at: isoNow(),
},
opRoot,
);
await releaseLane(`epic-${run.epic_id}`, opRoot, run.id);
return err(
'REVIEW_NOT_ACCEPTED',
`autonomous mode: built-in review verdict for ${sprint.id} was ${evalReview.verdict}`,
`address the findings and re-run, or review ${sprint.id} manually`,
);
}

// runReviewCommand + the verdict commit above leave a clean tree for
// runCloseCommand (which requires one) below.
const closeResult = await runCloseCommand(sprint.id, {
cwd: executionCwd,
dryRun: false,
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
loadProject,
meetsThreshold,
RepoKernelError,
type ReviewerGateVerdict,
type ReviewVerdict,
resolveNextRunnableSprint,
runValidators,
Expand Down Expand Up @@ -58,6 +59,8 @@ interface CurrentReview {
readonly reviewer: string;
readonly verdict: ReviewVerdict;
readonly review_attempt: number | null;
readonly gate_verdict: ReviewerGateVerdict | null;
readonly gate_attempt: number | null;
}

interface StatusReport {
Expand Down Expand Up @@ -418,6 +421,11 @@ function formatStatus(
lines.push(
` ${cr.review_id} (${cr.reviewer}) ${cr.verdict}${cr.review_attempt !== null ? ` — attempt ${cr.review_attempt}` : ''}`,
);
if (cr.gate_verdict !== null) {
lines.push(
` gate: ${cr.gate_verdict}${cr.gate_attempt !== null ? ` (attempt ${cr.gate_attempt})` : ''}`,
);
}
}
if (report.registryPath) {
lines.push('');
Expand Down Expand Up @@ -499,6 +507,8 @@ function currentReviewsOf(graph: Graph): CurrentReview[] {
reviewer: review.reviewer,
verdict: review.verdict,
review_attempt: review.review_attempt ?? null,
gate_verdict: review.reviewer_gate?.verdict ?? null,
gate_attempt: review.reviewer_gate?.review_attempt ?? null,
});
}
return out.sort((a, b) => a.sprint_id.localeCompare(b.sprint_id));
Expand Down
Loading