Skip to content
Closed
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
28 changes: 28 additions & 0 deletions .github/workflows/kapi-agent-formal-approval-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: kapi-agent formal approval gate

on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
pull_request_review:
types: [submitted, edited, dismissed]

permissions:
contents: read
pull-requests: read
issues: read
checks: read
statuses: read

jobs:
require-formal-kapi-agent-approval:
name: require formal kapi-agent approval
runs-on: ubuntu-latest
steps:
- name: Check out gate script
uses: actions/checkout@v4
- name: Require formal current-head kapi-agent approval
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KAPI_AGENT_REVIEWER: kapi-agent
KAPI_AGENT_REQUIRED_CHECK: kapi-agent/review
run: node scripts/kapi-agent-formal-approval-gate.mjs
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,13 @@ Supervisor contract:
- `status`, `list`, `probe`, `doctor`, `attach`, and future reporting commands are read-only supervisor inspection surfaces unless a command explicitly says otherwise.
- Kapi worker output is incomplete until hermes, openclaw reviews the diff, checks evidence, runs verification, and decides how to integrate it.
- Kapi does not create final PR decisions, merge, deploy, or grant review bots implementation authority.
- kapi-agent approval means a formal current-head Pull Request Review by `kapi-agent` plus a successful `kapi-agent/review` check. An approval-shaped issue comment is never merge approval. GitHub enforces this through `.github/workflows/kapi-agent-formal-approval-gate.yml`; branch protection should require the `require formal kapi-agent approval` status check.

Issue #37 is intentionally split into reviewable child slices: executable CLI setup (#38), repo-generic planning (#39), probe/readiness reliability (#40), doctor diagnostics (#41), worker reporting (#42), and multi-worker orchestration (#43).

### Review CLI Harness

`kapi-review github-pr` emits non-posting JSON for kapi-agent review/check automation and enforces local size, verification, stale-review revision explanation, structured approval-summary, and no-blocking-issues gates.
`kapi-review github-pr` emits non-posting JSON for kapi-agent review/check automation and enforces local size, verification, stale-review revision explanation, structured approval-summary, and no-blocking-issues gates. Merge readiness requires a formal current-head Pull Request Review from `kapi-agent` plus a successful `kapi-agent/review` check; approval-shaped issue comments are ignored as approval evidence. See `docs/kapi-agent-approval-gate.md`.

### Agent Tools

Expand Down
68 changes: 68 additions & 0 deletions docs/kapi-agent-approval-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# kapi-agent approval gate

Kapi/Ragna merge readiness must treat kapi-agent approval as a **formal Pull Request Review**, not as prose in an issue comment.

## Required merge-ready signal

A PR is kapi-agent approved only when all of these are true for the current PR head:

1. `latestReviews` contains a review authored by `kapi-agent`.
2. That review has `state: "APPROVED"`.
3. That review's `commit.oid` equals the PR `headRefOid`.
4. The `kapi-agent/review` check conclusion is `SUCCESS`.
5. The PR is not a draft.

The safe inspection shape is:

```bash
gh pr view <PR> --repo <OWNER/REPO> \
--json headRefOid,latestReviews,statusCheckRollup,isDraft,reviewDecision
```

Issue comments, PR comments, and copied text such as:

```md
## kapi-agent review

**Verdict:** APPROVE
```

are **not** approval evidence. They may be useful context, but they do not satisfy the merge gate because GitHub does not count them as formal review state and they are not tied to the reviewed head commit.

## Current-head rule

Any push after approval makes the old approval stale unless kapi-agent submits a new formal Pull Request Review on the new `headRefOid`. A stale approval should produce `request-kapi-agent-rereview`, not `merge-ready`.

## Request-changes rule

If the latest formal kapi-agent review is `CHANGES_REQUESTED`, the PR is blocked even if there is an approval-shaped issue comment or a green local verification run.

## GitHub-required gate

GitHub must enforce this with a required status check, not just supervisor discipline.

The repository workflow `.github/workflows/kapi-agent-formal-approval-gate.yml` runs on PR and PR-review events. Its check, `require formal kapi-agent approval`, fails unless:

- the latest formal Pull Request Review by `kapi-agent` is `APPROVED`;
- that review's `commit_id` equals the current PR head SHA;
- the `kapi-agent/review` check or commit status is `success`.

Branch protection/rulesets for `dev` should require both:

- `kapi-agent/review`
- `require formal kapi-agent approval`

This makes approval-shaped comments visible as failures in GitHub itself, before any human or CLI merge command can treat the PR as ready.

## Kapi CLI behavior

Kapi supervisor surfaces should:

- fetch formal reviews, comments, and the `kapi-agent/review` check;
- report approval-shaped comments as ignored comment approvals;
- use `request-formal-kapi-agent-review` when only an approval-shaped comment exists;
- emit `merge-ready` only when the formal current-head review and check gate are both satisfied.

## Incident reference

PR #110 was merged after an approval-shaped issue comment but before a formal kapi-agent review. The subsequent formal review requested changes. Issue #122 tracks the corrective gate hardening. This document records the corrected rule: **comment verdicts are never merge approval**.
65 changes: 65 additions & 0 deletions scripts/kapi-agent-formal-approval-gate.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export type KapiAgentGateReview = {
id?: number;
user?: { login?: string };
state?: string;
commit_id?: string;
submitted_at?: string;
html_url?: string;
};

export type KapiAgentGateCheckRun = {
id?: number;
name?: string;
status?: string;
conclusion?: string;
started_at?: string;
completed_at?: string;
html_url?: string;
};

export type KapiAgentGateStatus = {
id?: number;
context?: string;
state?: string;
created_at?: string;
updated_at?: string;
target_url?: string;
};

export type KapiAgentGateComment = { body?: string };

export type KapiAgentGateInput = {
headSha: string;
reviews?: KapiAgentGateReview[];
checkRuns?: KapiAgentGateCheckRun[];
statuses?: KapiAgentGateStatus[];
comments?: KapiAgentGateComment[];
reviewer?: string;
requiredCheck?: string;
};

export type KapiAgentGateResult = {
ok: boolean;
reviewer: string;
requiredCheck: string;
headSha: string;
latestReview?: {
author?: string;
state?: string;
commit_id?: string;
submitted_at?: string;
html_url?: string;
};
currentHeadReview: boolean;
requiredCheckState?: {
source: string;
name?: string;
state: string;
url?: string;
};
ignoredApprovalCommentCount: number;
diagnostics: string[];
};

export function evaluateKapiAgentFormalApproval(input: KapiAgentGateInput): KapiAgentGateResult;
export function isApprovalShapedKapiAgentComment(body: unknown): boolean;
148 changes: 148 additions & 0 deletions scripts/kapi-agent-formal-approval-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";

const DEFAULT_REVIEWER = "kapi-agent";
const DEFAULT_CHECK = "kapi-agent/review";

export function evaluateKapiAgentFormalApproval(input) {
const reviewer = input.reviewer ?? DEFAULT_REVIEWER;
const requiredCheck = input.requiredCheck ?? DEFAULT_CHECK;
const reviews = [...(input.reviews ?? [])]
.filter((review) => review?.user?.login === reviewer)
.sort((a, b) => String(a.submitted_at ?? "").localeCompare(String(b.submitted_at ?? "")) || Number(a.id ?? 0) - Number(b.id ?? 0));
const latestReview = reviews.at(-1);
const currentHeadReview = Boolean(latestReview?.commit_id && latestReview.commit_id === input.headSha);
const formalApproved = latestReview?.state === "APPROVED" && currentHeadReview;
const check = findCheck(input.checkRuns ?? [], input.statuses ?? [], requiredCheck);
const checkSuccess = check?.state === "success";
const commentApprovalCount = (input.comments ?? []).filter((comment) => isApprovalShapedKapiAgentComment(comment?.body)).length;
const diagnostics = [];

if (!latestReview) diagnostics.push(`missing formal Pull Request Review by ${reviewer}`);
if (latestReview && latestReview.state !== "APPROVED") diagnostics.push(`latest formal ${reviewer} review is ${latestReview.state}`);
if (latestReview && !currentHeadReview) diagnostics.push(`latest formal ${reviewer} review is not for current head ${input.headSha}`);
if (!check) diagnostics.push(`missing ${requiredCheck} check/status on current head`);
if (check && !checkSuccess) diagnostics.push(`${requiredCheck} is ${check.state}`);
if (commentApprovalCount > 0 && !formalApproved) diagnostics.push(`ignored ${commentApprovalCount} approval-shaped issue comment(s); comments are not formal PR reviews`);

return {
ok: formalApproved && checkSuccess,
reviewer,
requiredCheck,
headSha: input.headSha,
latestReview: latestReview ? {
author: latestReview.user?.login,
state: latestReview.state,
commit_id: latestReview.commit_id,
submitted_at: latestReview.submitted_at,
html_url: latestReview.html_url,
} : undefined,
currentHeadReview,
requiredCheckState: check,
ignoredApprovalCommentCount: commentApprovalCount,
diagnostics,
};
}

export function isApprovalShapedKapiAgentComment(body) {
return /^## kapi-agent review\s*\n\s*\n\*\*Verdict:\*\*\s*APPROVE(?:\s*\n|\s*$)/i.test(String(body ?? "").trimStart());
}

function findCheck(checkRuns, statuses, requiredCheck) {
const checkRun = [...checkRuns]
.filter((check) => check?.name === requiredCheck)
.sort((a, b) => String(a.completed_at ?? a.started_at ?? "").localeCompare(String(b.completed_at ?? b.started_at ?? "")) || Number(a.id ?? 0) - Number(b.id ?? 0))
.at(-1);
if (checkRun) return { source: "check_run", name: checkRun.name, state: checkRun.conclusion ?? checkRun.status ?? "unknown", url: checkRun.html_url };
const status = [...statuses]
.filter((item) => item?.context === requiredCheck)
.sort((a, b) => String(a.updated_at ?? a.created_at ?? "").localeCompare(String(b.updated_at ?? b.created_at ?? "")) || Number(a.id ?? 0) - Number(b.id ?? 0))
.at(-1);
if (status) return { source: "commit_status", name: status.context, state: status.state ?? "unknown", url: status.target_url };
return undefined;
}

async function main() {
const token = process.env.GITHUB_TOKEN;
const repository = process.env.GITHUB_REPOSITORY;
const eventPath = process.env.GITHUB_EVENT_PATH;
if (!token) throw new Error("GITHUB_TOKEN is required");
if (!repository || !repository.includes("/")) throw new Error("GITHUB_REPOSITORY must be owner/repo");
if (!eventPath) throw new Error("GITHUB_EVENT_PATH is required");
const event = JSON.parse(await readFile(eventPath, "utf8"));
const prNumber = event.pull_request?.number;
if (!prNumber) throw new Error("This gate must run on pull_request or pull_request_review events with a pull_request payload");
const [owner, repo] = repository.split("/");
const pull = await githubJson(token, `/repos/${owner}/${repo}/pulls/${prNumber}`);
const reviews = await githubPaged(token, `/repos/${owner}/${repo}/pulls/${prNumber}/reviews?per_page=100`);
const comments = await githubPaged(token, `/repos/${owner}/${repo}/issues/${prNumber}/comments?per_page=100`);
const checkRuns = (await githubJson(token, `/repos/${owner}/${repo}/commits/${pull.head.sha}/check-runs?per_page=100`)).check_runs ?? [];
const statuses = (await githubJson(token, `/repos/${owner}/${repo}/commits/${pull.head.sha}/status`)).statuses ?? [];
const result = evaluateKapiAgentFormalApproval({
headSha: pull.head.sha,
reviews,
comments,
checkRuns,
statuses,
reviewer: process.env.KAPI_AGENT_REVIEWER || DEFAULT_REVIEWER,
requiredCheck: process.env.KAPI_AGENT_REQUIRED_CHECK || DEFAULT_CHECK,
});
await writeStepSummary(result, prNumber, pull.html_url);
console.log(JSON.stringify(result, null, 2));
if (!result.ok) {
console.error(`::error::kapi-agent formal approval gate failed: ${result.diagnostics.join("; ")}`);
process.exitCode = 1;
}
}

async function githubJson(token, path) {
const response = await fetch(`https://api.github.com${path}`, { headers: githubHeaders(token) });
if (!response.ok) throw new Error(`GitHub API ${path} failed: ${response.status} ${await response.text()}`);
return await response.json();
}

async function githubPaged(token, path) {
const out = [];
for (let page = 1; page <= 10; page += 1) {
const separator = path.includes("?") ? "&" : "?";
const items = await githubJson(token, `${path}${separator}page=${page}`);
out.push(...items);
if (items.length < 100) break;
}
return out;
}

function githubHeaders(token) {
return {
authorization: `Bearer ${token}`,
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
};
}

async function writeStepSummary(result, prNumber, prUrl) {
if (!process.env.GITHUB_STEP_SUMMARY) return;
const lines = [
"# kapi-agent formal approval gate",
"",
`PR: [#${prNumber}](${prUrl})`,
`Result: ${result.ok ? "PASS" : "FAIL"}`,
`Head: \`${result.headSha}\``,
`Latest formal review: ${result.latestReview ? `${result.latestReview.author} / ${result.latestReview.state} / ${result.latestReview.commit_id || "no-commit"}` : "missing"}`,
`Current-head review: ${result.currentHeadReview}`,
`Required check: ${result.requiredCheckState ? `${result.requiredCheckState.name} / ${result.requiredCheckState.state}` : "missing"}`,
`Ignored approval-shaped comments: ${result.ignoredApprovalCommentCount}`,
"",
"## Diagnostics",
...(result.diagnostics.length ? result.diagnostics.map((item) => `- ${item}`) : ["- none"]),
"",
];
await import("node:fs/promises").then(({ appendFile }) => appendFile(process.env.GITHUB_STEP_SUMMARY, lines.join("\n"), "utf8"));
}

if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
console.error(`::error::${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
}
4 changes: 4 additions & 0 deletions skills/kapi-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ When a governed workflow uses `handoff.json`, keep architect and verifier approv
- verifier evidence should use `kind: review`, `role: verifier`, and `verdict: pass` or `approve` when completion evidence is accepted;
- evidence refs should point to a stable command, artifact anchor, or review note, such as `cmd:npm run verify` or `verify.md#verifier-pass`.

## kapi-agent PR Approval Gate

When a Kapi worker PR uses kapi-agent review, treat merge readiness as satisfied only by a formal Pull Request Review authored by `kapi-agent` on the current `headRefOid` plus a successful `kapi-agent/review` check. Ignore approval-shaped issue comments such as `## kapi-agent review` / `**Verdict:** APPROVE`; comments are context, not approval state.

## Completion Output

When closing or reporting progress in an active workflow, include:
Expand Down
6 changes: 4 additions & 2 deletions src/application/github-run-contract-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface GitHubWorkflowPrState {
diagnostics: string[];
pr?: { number: number; url: string; baseRefName: string; headRefName: string; headRefOid: string; mergeable?: string; mergeStateStatus?: string; isDraft?: boolean; reviewDecision?: string };
latestKapiReview?: { state: string; submittedAt?: string; commitOid?: string; bodySummary?: string };
latestCommentApproval?: { author?: string; createdAt?: string; url?: string };
currentHeadReview: boolean;
kapiAgentReviewCheckConclusion?: string;
staleReviewDiagnostic?: string;
Expand Down Expand Up @@ -80,8 +81,9 @@ function mapReview(review: GitHubWorkflowPrState | undefined): GitHubWorkflowRun
if (!review || review.status !== "found") return { status: "missing", label: "kapi-agent review not available", recommendedAction: review?.recommendedAction ?? "request kapi-agent review after opening PR" };
if (review.staleReviewDiagnostic) return { status: "blocked", label: review.staleReviewDiagnostic, recommendedAction: review.recommendedAction };
if (review.latestKapiReview?.state === "CHANGES_REQUESTED") return { status: "blocked", label: review.latestKapiReview.bodySummary ?? "kapi-agent requested changes", recommendedAction: review.recommendedAction };
if (review.latestKapiReview?.state === "APPROVED" && review.currentHeadReview && review.kapiAgentReviewCheckConclusion === "SUCCESS") return { status: "ready", label: "current-head approval and kapi-agent/review success", recommendedAction: review.recommendedAction };
return { status: "pending", label: `latest=${review.latestKapiReview?.state ?? "none"}; current-head=${review.currentHeadReview}; check=${review.kapiAgentReviewCheckConclusion ?? "missing"}`, recommendedAction: review.recommendedAction };
if (review.latestKapiReview?.state === "APPROVED" && review.currentHeadReview && review.kapiAgentReviewCheckConclusion === "SUCCESS") return { status: "ready", label: "formal current-head kapi-agent review approval and kapi-agent/review success", recommendedAction: review.recommendedAction };
if (review.latestCommentApproval && !review.latestKapiReview) return { status: "pending", label: `ignored issue comment approval from ${review.latestCommentApproval.author ?? "unknown"}; formal Pull Request Review is required`, recommendedAction: review.recommendedAction };
return { status: "pending", label: `latest-formal-review=${review.latestKapiReview?.state ?? "none"}; current-head=${review.currentHeadReview}; check=${review.kapiAgentReviewCheckConclusion ?? "missing"}`, recommendedAction: review.recommendedAction };
}

function mapDevIntegration(review: GitHubWorkflowPrState | undefined): GitHubWorkflowRunContractAdapterView["devIntegration"] {
Expand Down
Loading
Loading