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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ terraform.tfvars
.haiku/**/write-audit.jsonl
# Engine diagnostics (loop-guard log, etc — runtime-only)
.haiku/diagnostics/
# Runtime-verification proof (screenshots/video) + ephemeral Playwright
# driver — regenerated every run, uploaded to the PR, never committed.
.haiku/intents/*/stages/*/proof/
.haiku/intents/*/proof/
bun.lock
plugin/bin/haiku.mjs.map
.claude/scheduled_tasks.lock
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

75 changes: 71 additions & 4 deletions packages/haiku/src/git-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { migrateIntent } from "./orchestrator/migrate-registry.js"
// `migrateIntent("0", "4.0.0")` would throw "no migration path."
import { hasV3CruftInIntent } from "./orchestrator/migrations/v0-to-v4.js"
import {
ensureWorktreesGitignored,
ensureHaikuGitignored,
isGitRepo,
primaryRepoRoot,
} from "./state-tools.js"
Expand Down Expand Up @@ -1335,6 +1335,73 @@ export function openIntentDraftPullRequest(opts: {
return result
}

/** Open a DRAFT PR/MR from `haiku/<slug>/<stage>` into the intent's main
* branch (`haiku/<slug>/main`, NOT the repo mainline). Called once at
* stage start in discrete / discrete-hybrid mode so each stage has a
* place where its proof artifacts land while the work happens. The
* engine flips the draft to ready at the stage gate via
* markPullRequestReady().
*
* Best-effort: any failure (no git repo, no provider CLI, push or PR
* create error) returns a populated message and lets the caller surface
* it. Stage start never blocks on this. Shape mirrors
* openIntentDraftPullRequest — the only differences are the branch/base
* pair (stage → intent-main) and the default copy. */
export function openStageDraftPullRequest(opts: {
slug: string
stage: string
title?: string
body?: string
}): OpenIntentMrResult {
const branch = `haiku/${opts.slug}/${opts.stage}`
const base = `haiku/${opts.slug}/main`
const title = opts.title ?? `H·AI·K·U: ${opts.slug} — stage ${opts.stage}`
const body =
opts.body ??
`Stage \`${opts.stage}\` of intent \`${opts.slug}\` is in flight. The H·AI·K·U engine opened this PR as a draft so the stage's work — and its runtime-verification proof — can be watched as units land. The engine marks it ready at the stage gate; merging it signals approval.`

if (!isGitRepo()) {
return {
branch,
base,
message: "Not a git repo — no draft stage PR opened.",
}
}

const push = pushBranchToOrigin(branch)
const result: OpenIntentMrResult = {
branch,
base,
pushed: push.ok,
pushError: push.ok ? undefined : push.error,
message: "",
}

if (push.ok) {
const pr = openPullRequest(branch, base, title, body, { draft: true })
if (pr.ok && pr.url) {
result.createdUrl = pr.url
result.message = `Draft stage PR opened: ${pr.url}`
return result
}
result.prError = pr.error
}

const compare = buildCompareUrl(branch, base)
if (compare) {
result.compareUrl = compare
result.message = push.ok
? `Stage branch \`${branch}\` is pushed. The provider CLI didn't create the draft PR (${result.prError ?? "no tool found"}). Click here to open one manually: ${compare}`
: `Failed to push \`${branch}\` (${push.error}). After resolving, open the draft stage PR at: ${compare}`
return result
}

result.message = push.ok
? `Pushed \`${branch}\` but no compare URL could be built (origin host not recognised). Open the draft stage PR manually from \`${branch}\` into \`${base}\`.`
: `Failed to push \`${branch}\` (${push.error}) and no compare URL could be built.`
return result
}

/** Flip a draft PR/MR to "ready for review" / remove draft status.
* Detects provider from URL hostname: `gh pr ready <url>` or
* `glab mr update <iid> --ready`. Best-effort; the caller logs failures
Expand Down Expand Up @@ -3747,7 +3814,7 @@ export function createUnitWorktree(
// `createFixChainWorktree`.
ensureIntentGitAttributes(slug)
// Guarantee the worktree pool is ignored before any worktree lands in it.
ensureWorktreesGitignored()
ensureHaikuGitignored()
const unitBranch = `haiku/${slug}/${unit}`
const worktreeBase = join(primaryRepoRoot(), ".haiku", "worktrees", slug)
const worktreePath = join(worktreeBase, unit)
Expand Down Expand Up @@ -4140,7 +4207,7 @@ export function createDiscoveryWorktree(
// `createFixChainWorktree`.
ensureIntentGitAttributes(slug)
// Guarantee the worktree pool is ignored before any worktree lands in it.
ensureWorktreesGitignored()
ensureHaikuGitignored()
const discBranch = discoveryBranchName(slug, stage, template)
const worktreePath = discoveryWorktreePath(slug, stage, template)
const worktreeBase = join(primaryRepoRoot(), ".haiku", "worktrees", slug)
Expand Down Expand Up @@ -4435,7 +4502,7 @@ export function createFixChainWorktree(
// JSONL appends still trip the integrator cap.
ensureIntentGitAttributes(slug)
// Guarantee the worktree pool is ignored before any worktree lands in it.
ensureWorktreesGitignored()
ensureHaikuGitignored()
const fixBranch = fixChainBranchName(slug, scope, feedbackId)
const worktreePath = fixChainWorktreePath(slug, scope, feedbackId)
const worktreeBase = join(primaryRepoRoot(), ".haiku", "worktrees", slug)
Expand Down
2 changes: 1 addition & 1 deletion packages/haiku/src/orchestrator/prompts/_shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ const REGISTRY: Record<SharedBlockId, SharedBlockEntry> = {
content: () => runtimeVerificationBlock(),
title: "Runtime-verification doctrine",
summary:
"that verification is runtime observation (NOT running tests/typecheck); how to find the change's surface and route to a handle (web/GUI → `haiku_view` boot + `haiku-playwright`; CLI → run the command; server → the socket; library → the public export); the `.haiku/boot.md` project boot recipe; how to drive the smallest path, probe around the change, capture evidence under `proof/`; and the PASS/FAIL/BLOCKED/SKIP verdict that drives sign-off vs. feedback.",
"that verification is runtime observation (NOT running tests/typecheck); how to find the change's surface and route to a handle (web/GUI → `haiku_view` boot + a self-installed Playwright script that records video, with the `haiku-playwright` MCP as fallback; CLI → run the command; server → the socket; library → the public export); the `.haiku/boot.md` project boot recipe; how to drive the smallest path, probe around the change, capture video+screenshot evidence under the gitignored `proof/` and upload it to the change request (GitLab uploads API / GitHub release-asset); and the PASS/FAIL/BLOCKED/SKIP verdict that drives sign-off vs. feedback.",
},
"service-dependencies": {
content: () => serviceDependenciesBlock(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
// self-stamp (`haiku_review_stamp`'s note); the pre-tick drain signs each
// pending role (or re-dispatches the ones that filed findings).

import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { Eta } from "eta"
import { intentDir, parseFrontmatter } from "../../../../../state-tools.js"
import {
readReviewAgentBody,
readStudioReviewAgentPaths,
Expand Down Expand Up @@ -56,6 +59,19 @@ const eta = new Eta({ autoEscape: false, useWith: true })
const TEMPLATE = loadTemplate(import.meta.url)
const SUBAGENT_TEMPLATE = loadTemplate(import.meta.url, "subagent.eta.md")

/** The intent-main draft PR URL off intent.md FM, or empty string when
* absent (no provider CLI / not a git repo). Proof upload is skipped on
* empty — the captures still land on disk and the SPA serves them live. */
function intentDraftPrUrl(slug: string): string {
const intentFile = join(intentDir(slug), "intent.md")
if (!existsSync(intentFile)) return ""
const fm = parseFrontmatter(readFileSync(intentFile, "utf8")).data as Record<
string,
unknown
>
return (fm.draft_pr_url as string) || ""
}

/** Fallback mandate for a role that's neither an engine built-in nor a
* configured studio agent — reached only on registry drift (a test locks
* the cursor's `intentRoles` ↔ engine registry sync). Spawned as a normal
Expand Down Expand Up @@ -115,6 +131,11 @@ function buildRoleBlock(
}
}

const prInteraction = PR_INTERACTION_ROLES.has(role)
// At intent completion the whole intent is merged onto intent main, so
// proof uploads always target the single intent-main draft PR (there is
// no per-stage PR at this scope).
const proofTargetPrUrl = prInteraction ? intentDraftPrUrl(slug) : ""
const promptBody = mandateRef
? eta.renderString(SUBAGENT_TEMPLATE, {
slug,
Expand All @@ -123,7 +144,8 @@ function buildRoleBlock(
doctrineRef: RUNTIME_OBSERVATION_ROLES.has(role)
? sharedBlockRef("runtime-verification")
: "",
prInteraction: PR_INTERACTION_ROLES.has(role),
prInteraction,
proofTargetPrUrl,
existingFeedback: buildExistingFeedbackBlock(slug, ""),
decisions: buildDecisionsBlock(slug),
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,18 @@ import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { Eta } from "eta"
import matter from "gray-matter"
import { stageDir } from "../../../../../state-tools.js"
import {
intentDir,
parseFrontmatter,
readStagePr,
stageDir,
} from "../../../../../state-tools.js"
import {
readReviewAgentBody,
resolveReviewAgentPath,
} from "../../../../../studio-reader.js"
import { materializeReferenceFile } from "../../../../../subagent-prompt-file.js"
import { PER_STAGE_PR_MODES } from "../../../../workflow/delivery-modes.js"
import {
buildDecisionsBlock,
buildExistingFeedbackBlock,
Expand All @@ -33,11 +39,32 @@ import {
} from "../../../_helpers.js"
import { loadTemplate } from "../../../_load-template.js"
import {
PR_INTERACTION_ROLES,
RUNTIME_OBSERVATION_ROLES,
sharedBlockRef,
} from "../../../_shared/index.js"
import { definePromptBuilder } from "../../../define.js"

/** The PR/MR a runtime-verifier uploads this stage's proof to. In
* per-stage delivery modes that's the stage's own draft PR (base =
* intent main); otherwise the single intent-main draft PR. Empty string
* when no PR exists (no provider CLI / not a git repo) — the proof still
Comment thread
jwaldrip marked this conversation as resolved.
* lands on disk and the SPA serves it live; upload is just skipped. */
function resolveProofTargetPrUrl(slug: string, stage: string): string {
const intentFile = join(intentDir(slug), "intent.md")
if (!existsSync(intentFile)) return ""
const fm = parseFrontmatter(readFileSync(intentFile, "utf8")).data as Record<
string,
unknown
>
const mode = (fm.mode as string) || ""
if (stage && PER_STAGE_PR_MODES.has(mode)) {
Comment thread
jwaldrip marked this conversation as resolved.
const stagePr = readStagePr(slug, stage)?.url
if (stagePr) return stagePr
}
return (fm.draft_pr_url as string) || ""
}

const eta = new Eta({ autoEscape: false, useWith: true })
const TEMPLATE = loadTemplate(import.meta.url)
const SUBAGENT_TEMPLATE = loadTemplate(import.meta.url, "subagent.eta.md")
Expand Down Expand Up @@ -128,6 +155,10 @@ function buildRoleBlock(opts: {
const doctrineRef = RUNTIME_OBSERVATION_ROLES.has(role)
? sharedBlockRef("runtime-verification")
: ""
const prInteraction = PR_INTERACTION_ROLES.has(role)
const proofTargetPrUrl = prInteraction
? resolveProofTargetPrUrl(slug, stage)
: ""

const subagentPrompt = eta.renderString(SUBAGENT_TEMPLATE, {
slug,
Expand All @@ -138,6 +169,8 @@ function buildRoleBlock(opts: {
units,
outputPaths,
doctrineRef,
prInteraction,
proofTargetPrUrl,
existingFeedback: buildExistingFeedbackBlock(slug, stage),
decisions: buildDecisionsBlock(slug),
})
Expand Down
19 changes: 15 additions & 4 deletions packages/haiku/src/orchestrator/review-role-classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,20 @@
* `runtime-verification` doctrine for these roles and let them write
* `proof/` evidence captures (but never source).
*
* - PR_INTERACTION_ROLES audit the delivery PR on the remote (CI status +
* review conversation) rather than local artifacts or the running app.
* They may interact with the PR via the VCS CLI (`gh`/`glab`) — read
* checks, read/post/resolve review threads — but still MUST NOT edit
* - PR_INTERACTION_ROLES interact with the change request on the remote via
* the VCS CLI (`gh`/`glab`) — read checks, read/post/resolve review
* threads, and upload assets to the PR/MR body. They still MUST NOT edit
* source/specs/units; code fixes flow through `haiku_feedback` and the
* studio fix-hat loop.
*
* A role may be in BOTH sets — `runtime-verifier` is. It drives the live work
* AND captures proof (runtime-observation), and that proof is regenerated
* binary churn that doesn't belong in git, so it uploads the captures to the
* relevant PR/MR (PR-interaction). The dispatch builders + subagent templates
* treat the two scope grants as ADDITIVE — a both-sets role gets the
* proof-write carve-out AND the PR-interaction carve-out, not one or the
* other.
*
* To make a NEW agent post-execute-only (no pre-execute spec audit), add its
* role name to `RUNTIME_OBSERVATION_ROLES` — that one edit drops it from the
* review walk AND grants it the runtime doctrine + proof-write carve-out.
Expand All @@ -35,4 +42,8 @@ export const RUNTIME_OBSERVATION_ROLES: ReadonlySet<string> = new Set([

export const PR_INTERACTION_ROLES: ReadonlySet<string> = new Set([
"delivery-verifier",
// Uploads its runtime-verification proof (gitignored binary churn) to the
// stage/intent PR so it's durable + reviewable off-git. Also in
// RUNTIME_OBSERVATION_ROLES — the scopes are additive (see header).
"runtime-verifier",
])
16 changes: 16 additions & 0 deletions packages/haiku/src/orchestrator/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,22 @@ export function resolveStageOptional(studio: string, stage: string): boolean {
return readStageFrontmatter(studio, stage).optional === true
}

/** Whether a stage's review gate routes to EXTERNAL review (STAGE.md
* `review: external`, or a compound list like `[external, ask]` that
* includes it). In `discrete-hybrid` mode this is what marks a stage as
* "gets its own PR" — only external-review stages open a per-stage draft
* PR; the continuous stages keep their work on the intent-main PR. In
* plain `discrete` mode every stage gets one regardless. */
export function stageRequiresExternalReview(
studio: string,
stage: string,
): boolean {
const review = readStageFrontmatter(studio, stage).review
Comment thread
jwaldrip marked this conversation as resolved.
if (typeof review === "string") return review === "external"
if (Array.isArray(review)) return review.includes("external")
return false
}

/** Downstream in-plan stages that reference `stage` via their `inputs:` or
* `review-agents-include:`. Drives the "what you're severing" summary shown
* when an optional stage is offered for drop — so the decision isn't blind to
Expand Down
19 changes: 19 additions & 0 deletions packages/haiku/src/orchestrator/workflow/delivery-modes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// orchestrator/workflow/delivery-modes.ts — the single source for which
// intent modes use per-stage delivery PRs. A neutral leaf module (no heavy
// deps) so both the workflow side-effects (which OPEN the stage PR) and the
// prompt dispatch builder (which picks the proof-upload TARGET) read the
// same set and can never drift when a new mode is added.
//
// `discrete` / `discrete-hybrid` open a per-stage draft PR (base = intent
// main) where that stage's work + runtime-verification proof lands.
// `continuous` / `autopilot` / `quick` keep everything on the single
// intent-main draft PR opened at intent_create.
//
// Note: membership here means "this mode CAN open per-stage PRs."
// `discrete-hybrid` further narrows WHICH stages get one to the external-
// review stages (`stageRequiresExternalReview`); `discrete` opens one for
// every stage.
export const PER_STAGE_PR_MODES: ReadonlySet<string> = new Set([
"discrete",
"discrete-hybrid",
])
Loading
Loading