Skip to content

Commit 381a487

Browse files
authored
claude/clarify-review-gates-d9yMM (#221)
1 parent 0c6cee3 commit 381a487

6 files changed

Lines changed: 217 additions & 59 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ When modifying any component, check if other components need corresponding updat
5959
| Stage | Profiles section | `plugin/studios/{name}/stages/{stage}/STAGE.md` | stage.sh, orchestrator.ts |
6060
| Hat | Profiles section | `plugin/studios/{name}/stages/{stage}/hats/{hat}.md` | prompts/core.ts |
6161
| Review Agent | Quality Enforcement | `plugin/studios/{name}/stages/{stage}/review-agents/{agent}.md` | orchestrator.ts, prompts/core.ts |
62-
| Review Gate | Quality Enforcement | `review:` field in STAGE.md (auto/ask/external/await/[external,ask]) | orchestrator.ts |
62+
| Review Gate | Quality Enforcement | `review:` field in STAGE.md `auto` (harness advances), `ask` (local human approval), `external` (blocks for external review system), `await` (blocks for external event), or compound list like `[external, ask]` (user chooses path) | orchestrator.ts |
6363
| Operation Template | Operation phase | `plugin/studios/{name}/operations/{op}.md` | prompts/complex.ts |
6464
| Reflection Dimension | Reflection phase | `plugin/studios/{name}/reflections/{dim}.md` | prompts/core.ts |
6565
| Completion Criteria | Throughout | `quality_gates:` in unit/intent frontmatter, harness-enforced | orchestrator.ts, quality-gate.sh |
@@ -80,7 +80,7 @@ When modifying any component, check if other components need corresponding updat
8080
| Studio | (no equivalent) | A named lifecycle template (profile implementation) containing stages |
8181
| Stage | (no equivalent) | A lifecycle phase within a studio, containing hats and review gates |
8282
| Hat | Role | A behavioral role scoped to a stage, defined in `hats/{hat}.md` files within the stage directory |
83-
| Review Gate | Quality Gate | A checkpoint between stages (auto, ask, or external) |
83+
| Review Gate | Quality Gate | A checkpoint between stages controlling advancement. `auto` = harness-only (no human). `ask` = local review UI, human approves/rejects via MCP response. `external` = blocks until external system (GitHub/GitLab) approves; signal detected primarily by branch merge detection, with URL-based CLI probing as fallback. `await` = blocks until an external event occurs (not a review — e.g., customer response, pipeline). Compound: `[external, ask]` = user chooses between external submission or local approval. |
8484

8585
### Hierarchy
8686

packages/haiku/src/orchestrator.ts

Lines changed: 107 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
findHaikuRoot,
4343
gitCommitState,
4444
intentDir,
45+
isGitRepo,
4546
parseFrontmatter,
4647
readJson,
4748
setFrontmatterField,
@@ -198,22 +199,61 @@ function resolveStageMetadata(
198199
}
199200

200201
// ── External review detection ─────────────────────────────────────────────
202+
//
203+
// Two-tier signal detection for external/await gates:
204+
//
205+
// Tier 1: Branch merge detection (structural). In git workflows, external
206+
// review gates use a stage branch (`haiku/{slug}/{stage}`) that gets merged
207+
// into the intent hub (`haiku/{slug}/main`) when the review is approved.
208+
// `isBranchMerged()` detects this — including squash merges. This is the
209+
// primary, tamper-resistant signal: the merge is a structural fact, not
210+
// something the agent can self-assert.
211+
//
212+
// Tier 2: URL-based CLI probing (fallback). The orchestrator shells out to
213+
// `gh` or `glab` to check PR/MR status when a review URL was recorded.
214+
// Used when branch detection is unavailable (non-git workflows) or as a
215+
// secondary check. Supports GitHub `reviewDecision` and GitLab `approved`.
216+
//
217+
// The agent never self-approves gates. If neither tier detects approval,
218+
// the orchestrator returns `awaiting_external_review` and the user must
219+
// run `/haiku:pickup` after the external review is actually approved.
201220

202221
/**
203-
* Best-effort check if an external review URL has been approved.
204-
* Supports GitHub PRs (gh), GitLab MRs (glab), and generic URLs.
205-
* Returns true if approved/merged, false otherwise. Never throws.
222+
* Tier 2 (fallback): URL-based synchronous check if an external review URL
223+
* has been approved. Supports GitHub PRs (gh) and GitLab MRs (glab). Returns
224+
* true if approved or merged, false otherwise. Never throws.
225+
*
226+
* This is the fallback detection path — Tier 1 is branch merge detection via
227+
* `isBranchMerged()`, which is structural and tamper-resistant.
228+
*
229+
* For GitHub: checks both `reviewDecision` (APPROVED = reviews passed) and
230+
* `state` (MERGED = already accepted). A PR with approved reviews that hasn't
231+
* been merged yet still passes the gate — the review was approved, which is
232+
* what the gate cares about.
233+
*
234+
* For GitLab: checks both approval status and merge state.
206235
*/
207236
function checkExternalApproval(url: string): boolean {
208237
try {
209238
if (url.includes("github.com") && url.includes("/pull/")) {
210-
// GitHub PR — check via gh CLI (argument array avoids shell injection)
211-
const state = execFileSync(
239+
// GitHub PR — check review decision AND merge state (argument array avoids shell injection)
240+
const output = execFileSync(
212241
"gh",
213-
["pr", "view", url, "--json", "state", "-q", ".state"],
242+
[
243+
"pr",
244+
"view",
245+
url,
246+
"--json",
247+
"state,reviewDecision",
248+
"-q",
249+
"[.state, .reviewDecision]",
250+
],
214251
{ encoding: "utf8", stdio: "pipe" },
215252
).trim()
216-
return state === "MERGED" // CLOSED means rejected/abandoned, not approved
253+
const parsed = JSON.parse(output) as [string, string]
254+
const [state, reviewDecision] = parsed
255+
// Gate passes if PR is merged OR if reviews are approved (even if not yet merged)
256+
return state === "MERGED" || reviewDecision === "APPROVED"
217257
}
218258
if (url.includes("gitlab") && url.includes("/merge_requests/")) {
219259
// GitLab MR — check via glab CLI (argument array avoids shell injection)
@@ -222,11 +262,14 @@ function checkExternalApproval(url: string): boolean {
222262
["mr", "view", url, "--output", "json"],
223263
{ encoding: "utf8", stdio: "pipe" },
224264
).trim()
225-
return (JSON.parse(output) as { state?: string }).state === "merged"
265+
const mr = JSON.parse(output) as { state?: string; approved?: boolean }
266+
// Gate passes if MR is merged OR if it has been approved
267+
return mr.state === "merged" || mr.approved === true
226268
}
227-
// Unknown URL type — can't check automatically
269+
// Unknown URL type — can't check via CLI
228270
return false
229271
} catch {
272+
// CLI not available or network error
230273
return false
231274
}
232275
}
@@ -1357,6 +1400,11 @@ export function runNext(slug: string): OrchestratorAction {
13571400
// - await → "external" (awaits external event after submission)
13581401
// Note: continuous intents may have discrete branch isolation for external-review
13591402
// stages (PR isolation), but the gate options still reflect the stage's review field.
1403+
//
1404+
// Non-git environments: `external` gates fall back to `ask` because there is no
1405+
// structural signal (branch merge) to enforce external review. Without git, the
1406+
// only safe option is local human approval. Compound gates containing `external`
1407+
// strip it and keep remaining types (e.g., [external, ask] → ask).
13601408
if (phase === "gate") {
13611409
const reviewType = resolveStageReview(studio, currentStage)
13621410
const stageIdx = studioStages.indexOf(currentStage)
@@ -1367,10 +1415,20 @@ export function runNext(slug: string): OrchestratorAction {
13671415
// A continuous intent with PR-isolated external-review stages should still show
13681416
// the stage's full gate options, not be forced to external-only.
13691417
const intentMode = (intent.mode as string) || "continuous"
1418+
const gitAvailable = isGitRepo()
13701419

13711420
// Determine gate type for the review UI
13721421
let effectiveGateType: string
1373-
if (intentMode === "discrete") {
1422+
if (!gitAvailable && reviewType.includes("external")) {
1423+
// Non-git environment: external gates have no structural signal (no branch
1424+
// merge to detect). Fall back to ask — local human approval is the only
1425+
// safe option. For compound gates like "external,ask", strip external.
1426+
const remaining = reviewType
1427+
.split(",")
1428+
.filter((t) => t !== "external")
1429+
.join(",")
1430+
effectiveGateType = remaining || "ask"
1431+
} else if (intentMode === "discrete") {
13741432
// Pure discrete intent: always submit for external review (PR per stage)
13751433
effectiveGateType = "external"
13761434
} else if (reviewType === "auto" || reviewType === "ask") {
@@ -1400,39 +1458,48 @@ export function runNext(slug: string): OrchestratorAction {
14001458

14011459
// Blocked on external review — check if it's been approved
14021460
if (gateOutcome === "blocked") {
1403-
const externalUrl = (stageState.external_review_url as string) || ""
1404-
if (externalUrl) {
1405-
// Best-effort: check if the external review was approved
1406-
const approved = checkExternalApproval(externalUrl)
1407-
if (approved) {
1408-
// External approval detected — advance
1409-
const path = stageStatePath(slug, currentStage)
1410-
const data = readJson(path)
1411-
data.gate_outcome = "advanced"
1412-
writeJson(path, data)
1413-
emitTelemetry("haiku.gate.resolved", {
1414-
intent: slug,
1415-
stage: currentStage,
1416-
gate_type: "external",
1417-
outcome: "approved",
1418-
})
1419-
// Fall through to advance logic below
1420-
} else {
1421-
return {
1422-
action: "awaiting_external_review",
1423-
intent: slug,
1424-
stage: currentStage,
1425-
external_review_url: externalUrl,
1426-
message: `Stage '${currentStage}' is awaiting external review at: ${externalUrl}. Run /haiku:pickup again after approval.`,
1427-
}
1461+
let approved = false
1462+
1463+
// Tier 1: Branch merge detection (structural, tamper-resistant)
1464+
if (isGitRepo()) {
1465+
const stageBranch = `haiku/${slug}/${currentStage}`
1466+
const mainline = `haiku/${slug}/main`
1467+
if (isBranchMerged(stageBranch, mainline)) {
1468+
approved = true
1469+
}
1470+
}
1471+
1472+
// Tier 2: URL-based CLI probing (fallback)
1473+
if (!approved) {
1474+
const externalUrl = (stageState.external_review_url as string) || ""
1475+
if (externalUrl) {
1476+
approved = checkExternalApproval(externalUrl)
14281477
}
1478+
}
1479+
1480+
if (approved) {
1481+
// External approval detected — advance
1482+
const path = stageStatePath(slug, currentStage)
1483+
const data = readJson(path)
1484+
data.gate_outcome = "advanced"
1485+
writeJson(path, data)
1486+
emitTelemetry("haiku.gate.resolved", {
1487+
intent: slug,
1488+
stage: currentStage,
1489+
gate_type: "external",
1490+
outcome: "approved",
1491+
})
1492+
// Fall through to advance logic below
14291493
} else {
1430-
// No URL recorded — ask the agent to provide it or manually advance
1494+
const externalUrl = (stageState.external_review_url as string) || ""
14311495
return {
14321496
action: "awaiting_external_review",
14331497
intent: slug,
14341498
stage: currentStage,
1435-
message: `Stage '${currentStage}' is awaiting external review. Provide the review URL via haiku_stage_set or run /haiku:revisit to re-enter the gate.`,
1499+
...(externalUrl ? { external_review_url: externalUrl } : {}),
1500+
message: externalUrl
1501+
? `Stage '${currentStage}' is awaiting external review at: ${externalUrl}. Neither branch merge detection nor CLI-based check detected approval yet. Run /haiku:pickup after the review is approved.`
1502+
: `Stage '${currentStage}' is awaiting external review but no review URL was recorded. Run /haiku:pickup after the review is approved.`,
14361503
}
14371504
}
14381505
}
@@ -2655,9 +2722,9 @@ function buildRunInstructions(
26552722
sections.push(
26562723
`## Awaiting External Review\n\n${
26572724
externalUrl
2658-
? `The stage is awaiting external review at: ${externalUrl}\n\n`
2659-
: "The stage is awaiting external review but no review URL has been recorded.\n\n"
2660-
}Ask the user for the status of the external review. If approved, call \`haiku_run_next { intent: "${slug}" }\` — the FSM will detect the approval and advance.\n\n${externalUrl ? "" : `If the user provides a review URL, pass it: \`haiku_run_next { intent: "${slug}", external_review_url: "<url>" }\`\n`}`,
2725+
? `The stage is awaiting external review at: ${externalUrl}`
2726+
: "The stage is awaiting external review but no review URL has been recorded."
2727+
}\n\nThe orchestrator checks for approval automatically (branch merge detection + URL-based CLI probing). Neither detected approval yet.\n\nInform the user that the stage is waiting on external review. After the review is approved, run \`/haiku:pickup\` to continue.`,
26612728
)
26622729
break
26632730
}

plugin/providers/comms.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,27 @@ description: Bidirectional comms provider — notifications out, signals in
4747

4848
## Sync: Gate Resolution via Comms
4949

50-
For `await` gates, the comms provider can serve as the event signal:
50+
The comms provider can post gate notifications and receive replies, but the agent cannot use comms MCP tools to self-approve a gate. Gate advancement is controlled by the orchestrator through structural signals (branch merge detection, URL-based CLI probing), not by agent self-confirmation.
51+
52+
For `await` gates where a comms signal is the expected resolution (e.g., a customer reply in a Slack thread), the flow is:
5153

5254
```
5355
1. Stage completes → post "Waiting for {event}" to channel
5456
2. User replies "customer responded" or reacts with ✅
55-
3. Next session: Claude checks the thread, sees confirmation
56-
4. Advances the gate
57+
3. User runs /haiku:pickup after the event occurs
58+
4. Orchestrator checks gate status and advances
5759
```
5860

59-
This turns the comms channel into a lightweight event bus for human-mediated events.
61+
For `await` gates where the user wants to approve locally after confirming the event occurred, the stage must have a compound gate `[await, ask]` — the `ask` component allows local approval through the review UI.
62+
63+
### Comms as Notification, Not Signal Source
64+
65+
Comms MCP tools (Slack, Teams, Discord) are used for:
66+
67+
- **Outbound notifications** — posting gate status, intent completion, blockers
68+
- **Inbound context** — surfacing stakeholder feedback, thread replies relevant to active intents
69+
70+
They are NOT used for gate advancement. The agent cannot confirm its own gate — that would be a process bypass.
6071

6172
## Provider Config
6273

plugin/providers/git.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ On session start, check git state:
3737
| Provider Concept | H·AI·K·U Concept | Translation |
3838
|---|---|---|
3939
| Intent branch | Intent | `haiku/{slug}/main` maps to the active intent |
40-
| Pull Request / Merge Request | Delivery gate | PR state maps to external review outcome |
40+
| Pull Request / Merge Request | Delivery gate / external review signal | PR review decision (APPROVED) or merge state (MERGED) maps to external gate resolution |
4141
| PR review comments | Review feedback | Surface as context for review agents |
4242
| Merge conflict | Blocker | Flag intent as needing conflict resolution |
4343
| Remote ahead/behind | Sync state | Behind = pull needed; ahead = push needed |
@@ -72,13 +72,31 @@ When `auto_pr` is enabled:
7272
- Temporary state files — only committed state goes to remote
7373
- In-progress unit work — only merged results via the intent branch
7474

75+
## External Gate Signal Detection
76+
77+
For stages with `external` review gates, the orchestrator checks approval using two tiers:
78+
79+
### Tier 1: Branch Merge Detection (Primary)
80+
81+
The primary signal is whether the stage branch (`haiku/{slug}/{stage}`) was merged back into the intent main branch (`haiku/{slug}/main`). The orchestrator checks this locally using `git merge-base --is-ancestor` and falls back to checking for merged PRs via `gh`/`glab` (which handles squash merges where the branch commit is not a direct ancestor). This is structural verification — the agent cannot fake a branch merge.
82+
83+
### Tier 2: URL-Based CLI Probing (Fallback)
84+
85+
If a `external_review_url` was recorded in the stage state, the orchestrator also checks PR/MR approval status via CLI tools:
86+
87+
- **GitHub**`gh pr view <url> --json state,reviewDecision` → advances on `MERGED` or `reviewDecision === "APPROVED"`
88+
- **GitLab**`glab mr view <url> --output json` → advances on `merged` state or `approved === true`
89+
90+
This complements Tier 1 by detecting approval before the branch is actually merged. Runs automatically on every `/haiku:pickup`.
91+
7592
## Non-Git Environments
7693

7794
When not running in a git repository, the MCP operates in filesystem mode:
7895
- State is stored as files on disk in `.haiku/`
7996
- No commits, no pushes, no branches, no worktrees
8097
- Units work in-place rather than in worktree isolation
8198
- All lifecycle operations still function — just without version control
99+
- **External gates fall back to `ask`** — there is no structural signal (branch merge) to enforce external review, so the framework opens the local review UI for human approval instead of blocking indefinitely
82100

83101
## Provider Config
84102

0 commit comments

Comments
 (0)