// flag-review.ts — the framework's human-handover signal: "this work is done, // flag it for the user's review." The FINAL step of the review lifecycle // (dispatch → agent review → flag_for_review). Tool-agnostic; each host // registers the tool + supplies its notify sink. // // Log format (JSONL, one entry per flag): // { ts, event: "flag_for_review", origin, summary, risk, blast_radius, // self_reviewed, review_method, notified } // // ORIGIN + DEDUPLICATION (AUTOPILOT-27): two paths write this log — the // harness's automatic handover on a review PASS, and the orchestrator's manual // flag_for_review refinement. Their entries used to be indistinguishable, and // a manual flag following the automatic one notified the human a SECOND time // for the same queue item. Every entry now carries `origin`, and a repeat // notification for the same queue_key inside NOTIFY_DEDUPE_MS is merged into // the first (the entry is still logged in full — the refinement's content is // never dropped, only its duplicate ping). import { appendFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; export type ReviewRisk = "low" | "medium" | "high"; export type ReviewMethod = "commit-hook" | "reviewer-subagent" | "none"; /** WHO raised the flag: the harness's deterministic handover on a review PASS, * or the orchestrator calling flag_for_review itself. */ export type FlagOrigin = "automatic" | "orchestrator-authored"; /** DEDUPE WINDOW: long enough to cover the orchestrator reading the PASS tick * and adding its refinement (seconds to a couple of minutes), short enough * that a genuinely NEW flag for the same item — a re-review after a * re-dispatch — still pings. */ export const NOTIFY_DEDUPE_MS = 120_000; /** Last notification per queue_key (process-local: both writers — the harness * runner and the host's flag_for_review tool — live in the SAME host * process). A flag with no queue_key is never deduplicated: it has no * identity to compare. */ const lastNotifiedAt = new Map(); /** Test seam: forget the dedupe window (hermetic tests share a process). */ export function resetNotifyDedupe(): void { lastNotifiedAt.clear(); } export interface FlagReviewParams { summary: string; risk: ReviewRisk; blast_radius: string; /** WHERE to review — file paths, `path:line` ranges, diff refs, commit * SHAs, MR/PR links. The human reviews these. */ review_targets: string[]; self_reviewed: boolean; review_method: ReviewMethod; /** What the user should DO after reviewing (merge X, approve Y, decide A/B). */ action_needed?: string; /** What was NOT verified / could change. */ residual_risks?: string; /** The queue item this flag came from (when orchestrated). */ queue_key?: string; /** WHO raised it (default: orchestrator-authored — the manual tool). */ origin?: FlagOrigin; } export interface FlagReviewSinks { /** Reviews log path (default: $AGENT_FEEDBACK_REVIEWS_DIR || * ~/.local/state/orchestrator/reviews.jsonl). */ logPath?: string; /** Desktop-notification sink (pi: notifyDesktop; opencode: console). */ notify?: (title: string, body: string) => void; } /** Desktop notification (OSC 777/99 — Ghostty, iTerm2, WezTerm, Kitty, rxvt). * Tool-agnostic; control chars stripped so OSC sequences stay well-formed. */ export function notifyDesktop(title: string, body: string): void { try { const clean = (s: string) => s.replace(/[\x00-\x1f\x7f]/g, " "); process.stdout.write(`\x1b]777;notify;${clean(title)};${clean(body)}\x07`); process.stdout.write(`\x1b]99;i=1:d=0;${clean(title)}\x1b\\`); } catch { // silent } } export function defaultReviewsLog(): string { return process.env.AGENT_FEEDBACK_REVIEWS_DIR ?? join(homedir(), ".local/state/orchestrator/reviews.jsonl"); } export function flagForReview(params: FlagReviewParams, sinks: FlagReviewSinks = {}): { deliveryNote: string; notified: boolean } { const logPath = sinks.logPath ?? defaultReviewsLog(); const origin: FlagOrigin = params.origin ?? "orchestrator-authored"; const now = Date.now(); const previous = params.queue_key ? lastNotifiedAt.get(params.queue_key) : undefined; const duplicate = previous !== undefined && now - previous < NOTIFY_DEDUPE_MS; const entry = { ts: new Date(now).toISOString(), event: "flag_for_review", origin, notified: !duplicate, summary: params.summary, risk: params.risk, blast_radius: params.blast_radius, review_targets: params.review_targets, self_reviewed: params.self_reviewed, review_method: params.review_method, ...(params.action_needed ? { action_needed: params.action_needed } : {}), ...(params.residual_risks ? { residual_risks: params.residual_risks } : {}), ...(params.queue_key ? { queue_key: params.queue_key } : {}), }; try { mkdirSync(join(logPath, ".."), { recursive: true }); appendFileSync(logPath, JSON.stringify(entry) + "\n", { encoding: "utf-8" }); } catch { // silent } if (!duplicate) { if (params.queue_key) lastNotifiedAt.set(params.queue_key, now); (sinks.notify ?? notifyDesktop)( `review ready (${params.risk})${params.review_targets.length ? ` · ${params.review_targets.length} target${params.review_targets.length > 1 ? "s" : ""}` : ""}`, `${params.summary.slice(0, 120)}${params.review_targets[0] ? ` ${params.review_targets[0].slice(0, 80)}` : ""}`, ); } // The tool RESULT shows the CONTENTS, not a summary of the flag: the user // sees what was done + where to look right in the result. (The desktop // notification stays short — title + one-liner.) const targetLines = params.review_targets.length ? params.review_targets.map((t) => ` - ${t}`).join("\n") : " (none — name the files!)"; const lines = [ `Flagged for review. Risk: ${params.risk}.${params.self_reviewed ? " Self-reviewed (" + params.review_method + ")." : " NOT self-reviewed — verify before relying."}`, "", `Summary: ${params.summary}`, `Blast radius: ${params.blast_radius}`, `Review targets (${params.review_targets.length}):`, targetLines, ]; if (params.action_needed) lines.push("", `Action needed: ${params.action_needed}`); if (params.residual_risks) lines.push("", `Residual risks: ${params.residual_risks}`); if (params.queue_key) lines.push("", `Queue item: ${params.queue_key}`); if (duplicate) { lines.push( "", `Notification MERGED: ${params.queue_key} was already flagged ${Math.round((now - (previous ?? now)) / 1000)}s ago (the harness's automatic handover or an earlier flag) — this entry is logged in full, the duplicate desktop ping is suppressed.`, ); } const deliveryNote = lines.join("\n"); return { deliveryNote, notified: !duplicate }; }