diff --git a/README.md b/README.md index ce08e04..ba4d30c 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ The published MkDocs site is a practical discovery and delivery guide: - [Dual-Mode Thesis & Complementary Stack](docs/12-dual-mode-thesis/README.md) - [Prompt Determinism Thesis & Settings Trap](docs/13-prompt-determinism-thesis/README.md) - [Trust Harness Thesis & Commodity Loop](docs/14-trust-harness-thesis/README.md) +- [Herdr Integration](docs/15-herdr-integration/README.md) - [Evaluation Method and Gate-Effectiveness Corpus](docs/EVALS.md) - [Glossary](docs/GLOSSARY.md) @@ -120,6 +121,15 @@ node src/cli.js doctor The current release is available through [npm](https://www.npmjs.com/package/@bashbop/otito), [GitHub Releases](https://github.com/BASHBOP/otito/releases), and the MCP Registry as `io.github.BASHBOP/otito`. +Run Otito as the trust layer inside a Herdr agent workspace: + +```bash +herdr plugin install BASHBOP/otito/integrations/herdr +herdr plugin pane open --plugin bashbop.otito --entrypoint trust-status +``` + +The Herdr plugin uses the independently installed `otito` CLI. Herdr owns persistent agent terminals and worktrees; Otito owns context, impact, review, and deterministic gate evidence. + ```bash otito repo . --json otito discover ~/projects --depth 2 --json diff --git a/docs/15-herdr-integration/README.md b/docs/15-herdr-integration/README.md new file mode 100644 index 0000000..362b8d2 --- /dev/null +++ b/docs/15-herdr-integration/README.md @@ -0,0 +1,52 @@ +# Herdr Integration + +Herdr is a persistent terminal runtime for coding agents. Otito is the +independent context and merge-evidence layer inside that runtime. + +```text +Herdr workspace/worktree -> coding agent -> changed files + | + v + Otito context/impact/review/gate + | + v + evidence -> hosted checks -> human decision +``` + +The integration is a Herdr v1 plugin under `integrations/herdr`. It exposes: + +- task-aware context, using selected text when available +- change-impact mapping against the active repository +- the composite Otito review verdict +- protected validation against the exact staged Git tree +- an interactive trust-status popup + +Install Otito and then the plugin: + +```bash +npm install -g @bashbop/otito +otito doctor +herdr plugin install BASHBOP/otito/integrations/herdr +``` + +Open the trust view: + +```bash +herdr plugin pane open \ + --plugin bashbop.otito \ + --entrypoint trust-status +``` + +The plugin does not embed an agent, replace Herdr's runtime, or let an agent +award itself approval. Otito stays independently installable and keeps its own +deterministic JSON/CLI contracts. Herdr contributes invocation context: the +active workspace, worktree, pane, repository, and selected task text. + +Local verdicts remain local evidence. Hosted CI, GitHub approvals, CODEOWNERS, +unresolved review conversations, and the final merge decision stay with their +respective authorities. + +See the plugin's +[README](https://github.com/BASHBOP/otito/tree/main/integrations/herdr) for +actions, keybindings, local development, configuration overrides, and the +security boundary. diff --git a/docs/index.md b/docs/index.md index 07c8843..95f442d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,6 +50,7 @@ See [CHANGELOG.md](https://github.com/BASHBOP/otito/blob/main/CHANGELOG.md) for | 12 | [Dual-Mode Thesis](./12-dual-mode-thesis/README.md) | Probabilistic generation beside deterministic verification | :material-check-circle: Active | | 13 | [Prompt Determinism Thesis](./13-prompt-determinism-thesis/README.md) | Why prompt settings do not turn a model into a gate | :material-check-circle: Active | | 14 | [Trust Harness Thesis](./14-trust-harness-thesis/README.md) | Why independent merge evidence outlasts generic agent orchestration | :material-check-circle: Active | +| 15 | [Herdr Integration](./15-herdr-integration/README.md) | Run Otito context and merge evidence inside persistent agent workspaces | :material-check-circle: Active | --- diff --git a/eslint.config.js b/eslint.config.js index d14438f..aa6d31b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,7 +14,7 @@ export default [ ignores: [".otito/**", "coverage/**", "dist/**", "node_modules/**", "package-lock.json"], }, { - files: ["src/**/*.js", "tests/**/*.js", "codex/skills/**/*.js"], + files: ["src/**/*.js", "tests/**/*.js", "codex/skills/**/*.js", "integrations/**/*.mjs"], languageOptions: { ecmaVersion: 2022, globals: nodeGlobals, diff --git a/integrations/herdr/README.md b/integrations/herdr/README.md new file mode 100644 index 0000000..d91ebe1 --- /dev/null +++ b/integrations/herdr/README.md @@ -0,0 +1,64 @@ +# Otito for Herdr + +Run Otito's context, impact, review, and exact staged-tree validation inside the Herdr workspace that already hosts your coding agents. + +Herdr owns persistent terminals, panes, worktrees, and agent lifecycle. Otito remains a separate local-first trust authority. The plugin passes the active repository and selected task text to the installed `otito` CLI; it does not fork Otito's engines or make Herdr responsible for merge verdicts. + +## Install + +Requirements: + +- Herdr 0.8.2 or newer +- Node.js 18.18 or newer +- Git +- Otito on `PATH` + +```bash +npm install -g @bashbop/otito +otito doctor +herdr plugin install BASHBOP/otito/integrations/herdr +``` + +For local plugin development: + +```bash +herdr plugin link /path/to/otito/integrations/herdr +herdr plugin action list --plugin bashbop.otito +``` + +Set `OTITO_BIN` to an explicit executable path if `otito` is not on `PATH`. Set `OTITO_REPO` only when you deliberately want to override the repository resolved from Herdr's active pane or worktree. + +## Use + +```bash +herdr plugin action invoke bashbop.otito.doctor +herdr plugin action invoke bashbop.otito.review +herdr plugin action invoke bashbop.otito.gate-staged +herdr plugin pane open \ + --plugin bashbop.otito \ + --entrypoint trust-status +``` + +The context and impact actions use selected terminal text as the task request when Herdr supplies a selection. The trust-status popup also lets you type a request interactively. + +The staged gate runs Otito's protected validation plan against the exact staged Git tree. It does not commit, push, merge, or approve anything. Local evidence also does not prove hosted CI, GitHub approvals, CODEOWNERS decisions, or the absence of unresolved review conversations. + +## Optional keybindings + +Add plugin actions to `~/.config/herdr/config.toml`: + +```toml +[[keys.command]] +key = "prefix+o" +type = "plugin_action" +command = "bashbop.otito.review" +description = "review current change with Otito" + +[[keys.command]] +key = "prefix+g" +type = "plugin_action" +command = "bashbop.otito.gate-staged" +description = "validate staged change with Otito" +``` + +Plugin code runs as your user and is not sandboxed by Herdr. Review the manifest and scripts before installation, just as you would any editor or agent extension. diff --git a/integrations/herdr/action.mjs b/integrations/herdr/action.mjs new file mode 100644 index 0000000..253708b --- /dev/null +++ b/integrations/herdr/action.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +import { pathToFileURL } from "node:url"; +import { buildOtitoArgs, parseInvocationContext, requestFromContext, resolveBase, resolveRepoRoot, runOtito } from "./runtime.mjs"; + +export function runAction(action, options = {}) { + const context = options.context ?? parseInvocationContext(); + if (action === "doctor") { + return runOtito(buildOtitoArgs(action, {})); + } + + const repo = options.repo ?? resolveRepoRoot(context); + const request = requestFromContext(action, context, options.request); + const base = options.base ?? resolveBase(repo); + process.stdout.write(`Otito · ${action}\nRepository: ${repo}\nRequest: ${request}\n\n`); + return runOtito(buildOtitoArgs(action, { repo, request, base }), { cwd: repo }); +} + +export function main(argv = process.argv.slice(2)) { + const action = argv[0]; + if (!action) throw new Error("Expected an Otito Herdr action name."); + const result = runAction(action); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} + +const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false; + +if (isMain) { + try { + main(); + } catch (error) { + process.stderr.write(`Otito Herdr plugin: ${error.message ?? String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/integrations/herdr/herdr-plugin.toml b/integrations/herdr/herdr-plugin.toml new file mode 100644 index 0000000..8972e51 --- /dev/null +++ b/integrations/herdr/herdr-plugin.toml @@ -0,0 +1,50 @@ +id = "bashbop.otito" +name = "Otito Trust" +version = "0.1.0" +min_herdr_version = "0.8.2" +description = "Local-first repository context, impact analysis, and merge evidence inside Herdr." +platforms = ["linux", "macos"] + +[[actions]] +id = "doctor" +title = "Check Otito" +description = "Verify the local Otito installation and optional tools." +contexts = ["global", "workspace", "pane"] +command = ["node", "action.mjs", "doctor"] + +[[actions]] +id = "context" +title = "Build Otito context" +description = "Build task-aware repository context, using selected text as the request when available." +contexts = ["selection", "workspace", "pane"] +command = ["node", "action.mjs", "context"] + +[[actions]] +id = "impact" +title = "Map change impact" +description = "Rank likely owner files and risks, using selected text as the change request when available." +contexts = ["selection", "workspace", "pane"] +command = ["node", "action.mjs", "impact"] + +[[actions]] +id = "review" +title = "Review current change" +description = "Run Otito's composite impact, review-context, and local-gate verdict." +contexts = ["workspace", "pane", "selection"] +command = ["node", "action.mjs", "review"] + +[[actions]] +id = "gate-staged" +title = "Validate staged change" +description = "Run the Otito gate and protected validation plan against the exact staged tree." +contexts = ["workspace", "pane", "selection"] +command = ["node", "action.mjs", "gate-staged"] + +[[panes]] +id = "trust-status" +title = "Otito · Trust status" +description = "Open an interactive local trust review for the active repository." +placement = "popup" +width = "84%" +height = "78%" +command = ["node", "trust-pane.mjs"] diff --git a/integrations/herdr/runtime.mjs b/integrations/herdr/runtime.mjs new file mode 100644 index 0000000..b756b25 --- /dev/null +++ b/integrations/herdr/runtime.mjs @@ -0,0 +1,122 @@ +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pluginRoot = dirname(fileURLToPath(import.meta.url)); + +export const DEFAULT_REQUESTS = Object.freeze({ + context: "Summarize this repository for the current task", + impact: "Review the current working tree changes and identify affected files", + review: "Review current changes before merge", + "gate-staged": "Validate the exact staged change before commit", +}); + +export function parseInvocationContext(raw = process.env.HERDR_PLUGIN_CONTEXT_JSON) { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +export function requestFromContext(action, context, override) { + const explicit = String(override ?? process.env.OTITO_REQUEST ?? "").trim(); + if (explicit) return explicit; + const selection = String(context.selected_text ?? "").trim(); + return selection || DEFAULT_REQUESTS[action] || DEFAULT_REQUESTS.review; +} + +function git(args, cwd) { + return spawnSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +export function resolveRepoRoot(context = parseInvocationContext()) { + const candidates = [ + process.env.OTITO_REPO, + context.focused_pane_cwd, + context.worktree?.checkout_path, + context.workspace_cwd, + process.env.HERDR_ACTIVE_PANE_CWD, + ]; + + for (const candidate of candidates) { + if (!candidate) continue; + const result = git(["-C", String(candidate), "rev-parse", "--show-toplevel"], pluginRoot); + if (result.status === 0) return result.stdout.trim(); + } + + throw new Error("No Git repository was found for the active Herdr pane. Focus a pane inside a repository and try again."); +} + +export function resolveBase(repo) { + const symbolic = git(["-C", repo, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], pluginRoot); + if (symbolic.status === 0) { + return symbolic.stdout.trim().replace(/^refs\/remotes\//, ""); + } + + for (const candidate of ["origin/main", "origin/master", "main", "master"]) { + const found = git(["-C", repo, "rev-parse", "--verify", "--quiet", candidate], pluginRoot); + if (found.status === 0) return candidate; + } + return undefined; +} + +export function resolveOtitoCommand(env = process.env) { + const configured = String(env.OTITO_BIN ?? "").trim(); + if (configured) return { command: configured, prefix: [] }; + + const installed = spawnSync("otito", ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (installed.status === 0) return { command: "otito", prefix: [] }; + + const sourceCli = resolve(pluginRoot, "..", "..", "src", "cli.js"); + const sourceDependency = resolve(pluginRoot, "..", "..", "node_modules", "typescript"); + if (existsSync(sourceCli) && existsSync(sourceDependency)) { + return { command: process.execPath, prefix: [sourceCli] }; + } + + throw new Error("Otito is not installed. Run `npm install -g @bashbop/otito`, then reopen this action. Set OTITO_BIN to use another binary."); +} + +export function buildOtitoArgs(action, { repo, request, base }) { + if (action === "doctor") return ["doctor"]; + if (action === "context") { + return ["context", request, "--path", repo]; + } + if (action === "impact") { + const args = ["impact", repo, request, "--top", "20"]; + if (base) args.push("--diff-base", base); + return args; + } + if (action === "review") { + const args = ["review", repo, "--request", request]; + if (base) args.push("--base", base); + return args; + } + if (action === "gate-staged") { + const args = ["gate", repo, "--staged", "--run-validation", "--request", request]; + if (base) args.push("--base", base); + return args; + } + throw new Error(`Unknown Otito Herdr action: ${action}`); +} + +export function runOtito(args, options = {}) { + const executable = resolveOtitoCommand(); + const capture = options.capture ?? false; + return spawnSync(executable.command, [...executable.prefix, ...args], { + cwd: options.cwd, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); +} diff --git a/integrations/herdr/trust-pane.mjs b/integrations/herdr/trust-pane.mjs new file mode 100644 index 0000000..3bc3254 --- /dev/null +++ b/integrations/herdr/trust-pane.mjs @@ -0,0 +1,311 @@ +#!/usr/bin/env node + +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; +import { buildOtitoArgs, parseInvocationContext, resolveBase, resolveRepoRoot, runOtito } from "./runtime.mjs"; + +const ANSI = { + reset: "\u001b[0m", + bold: "\u001b[1m", + dim: "\u001b[2m", + black: "\u001b[30m", + white: "\u001b[97m", + muted: "\u001b[38;2;160;174;192m", + brand: "\u001b[48;2;255;255;1m", + success: "\u001b[48;2;34;197;94m", + warning: "\u001b[48;2;245;158;11m", + danger: "\u001b[48;2;239;68;68m", + successText: "\u001b[38;2;34;197;94m", + warningText: "\u001b[38;2;245;158;11m", + dangerText: "\u001b[38;2;239;68;68m", +}; + +const MIN_CONTENT_WIDTH = 52; +const MAX_CONTENT_WIDTH = 88; + +function supportsColor() { + return Boolean(stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb"); +} + +function contentWidth(columns = stdout.columns) { + const available = Number.isFinite(columns) ? columns - 8 : MAX_CONTENT_WIDTH; + return Math.max(MIN_CONTENT_WIDTH, Math.min(MAX_CONTENT_WIDTH, available)); +} + +function paint(value, codes, color) { + if (!color) return value; + return `${codes.join("")}${value}${ANSI.reset}`; +} + +function statusStyle(status) { + if (status === "PASS") return [ANSI.success, ANSI.black, ANSI.bold]; + if (status === "FAIL") return [ANSI.danger, ANSI.white, ANSI.bold]; + return [ANSI.warning, ANSI.black, ANSI.bold]; +} + +function riskStyle(risk) { + if (risk === "high" || risk === "critical") return [ANSI.dangerText, ANSI.bold]; + if (risk === "medium") return [ANSI.warningText, ANSI.bold]; + if (risk === "low") return [ANSI.successText, ANSI.bold]; + return [ANSI.muted]; +} + +function wrapText(value, width) { + const words = String(value) + .trim() + .split(/\s+/) + .flatMap((word) => { + if (word.length <= width) return [word]; + const chunks = []; + for (let index = 0; index < word.length; index += width) { + chunks.push(word.slice(index, index + width)); + } + return chunks; + }); + const lines = []; + let line = ""; + + for (const word of words) { + if (!line) { + line = word; + continue; + } + if (`${line} ${word}`.length <= width) { + line += ` ${word}`; + continue; + } + lines.push(line); + line = word; + } + + if (line) lines.push(line); + return lines.length ? lines : [""]; +} + +function section(title, width, color) { + const ruleWidth = Math.max(4, width - title.length - 3); + return `${paint(title, [ANSI.bold], color)} ${paint("─".repeat(ruleWidth), [ANSI.muted], color)}`; +} + +function tableValue(row, line, paddedLine, lineIndex, color) { + if (!color || lineIndex !== 0) return paddedLine; + if (row.status && line.startsWith(row.status)) { + return `${paint(row.status, statusStyle(row.status), color)}${paddedLine.slice(row.status.length)}`; + } + if (row.risk && line.startsWith(row.risk)) { + return `${paint(row.risk, riskStyle(row.risk.toLowerCase()), color)}${paddedLine.slice(row.risk.length)}`; + } + return paddedLine; +} + +function infoTable(rows, width, color) { + const labelWidth = 13; + const valueWidth = Math.max(24, width - labelWidth - 7); + const horizontal = (size) => "─".repeat(size); + const border = { + top: `┌${horizontal(labelWidth + 2)}┬${horizontal(valueWidth + 2)}┐`, + middle: `├${horizontal(labelWidth + 2)}┼${horizontal(valueWidth + 2)}┤`, + bottom: `└${horizontal(labelWidth + 2)}┴${horizontal(valueWidth + 2)}┘`, + }; + const lines = [paint(border.top, [ANSI.muted], color)]; + + rows.forEach((row, rowIndex) => { + const wrapped = wrapText(row.value, valueWidth); + wrapped.forEach((line, lineIndex) => { + const label = lineIndex === 0 ? row.label.toUpperCase() : ""; + const paddedLine = line.padEnd(valueWidth); + lines.push( + `${paint("│", [ANSI.muted], color)} ${paint(label.padEnd(labelWidth), [ANSI.muted], color)} ${paint("│", [ANSI.muted], color)} ${tableValue(row, line, paddedLine, lineIndex, color)} ${paint("│", [ANSI.muted], color)}`, + ); + }); + if (rowIndex < rows.length - 1) lines.push(paint(border.middle, [ANSI.muted], color)); + }); + + lines.push(paint(border.bottom, [ANSI.muted], color)); + return lines; +} + +function statusBadge(status, color) { + return paint(` ${glyph(status)} `, statusStyle(status), color); +} + +function keyBadge(key, color) { + return paint(` ${key} `, [ANSI.brand, ANSI.black, ANSI.bold], color); +} + +function clear() { + if (stdout.isTTY) stdout.write("\u001b[2J\u001b[H"); +} + +function glyph(verdict) { + if (verdict === "PASS") return "PASS"; + if (verdict === "FAIL") return "FAIL"; + return "WARN"; +} + +export function formatTrustSummary(report, repo, base, options = {}) { + const color = options.color ?? false; + const width = options.width ?? MAX_CONTENT_WIDTH; + const checks = report.pass?.checks ?? []; + const attention = checks.filter((check) => check.status !== "PASS"); + const verdict = glyph(report.verdict); + const risk = String(report.prReviewSummary?.riskLevel ?? "unknown").toLowerCase(); + const changedFiles = report.prReviewSummary?.changedFiles ?? 0; + const additions = report.prReviewSummary?.additions ?? 0; + const deletions = report.prReviewSummary?.deletions ?? 0; + const lines = [ + `${paint(" OTITO ", [ANSI.brand, ANSI.black, ANSI.bold], color)} ${paint("TRUST STATUS", [ANSI.bold], color)}`, + paint("Local merge evidence for AI-assisted changes", [ANSI.muted], color), + "", + section("OVERVIEW", width, color), + ...infoTable( + [ + { label: "Repository", value: repo }, + { label: "Compare with", value: base ?? "Not detected" }, + ], + width, + color, + ), + "", + section("MERGE SIGNAL", width, color), + ...infoTable( + [ + { label: "Verdict", value: `${verdict} · confidence ${report.confidence ?? "?"}%`, status: verdict }, + { label: "Change", value: `${changedFiles} changed file${changedFiles === 1 ? "" : "s"} · +${additions} -${deletions}` }, + { label: "Risk", value: `${risk.toUpperCase()} RISK`, risk: risk.toUpperCase() }, + ], + width, + color, + ), + ]; + + if (attention.length) { + lines.push("", section(`NEEDS ATTENTION · ${attention.length}`, width, color)); + for (const check of attention) { + const summaryLines = wrapText(check.summary, Math.max(24, width - 8)); + lines.push(`${statusBadge(check.status, color)} ${paint(check.name, [ANSI.bold], color)}`); + lines.push(...summaryLines.map((line) => ` ${paint(line, [ANSI.muted], color)}`)); + } + } + + lines.push("", section("LOCAL EVIDENCE ONLY", width, color)); + lines.push( + ...wrapText("Local evidence does not replace hosted CI, CODEOWNERS approval, unresolved-comment checks, or the human merge decision.", width).map((line) => + paint(line, [ANSI.dim], color), + ), + ); + return lines.join("\n"); +} + +export function formatActionBar(options = {}) { + const color = options.color ?? false; + return [ + `${keyBadge("r", color)} Refresh ${keyBadge("c", color)} Context ${keyBadge("i", color)} Impact`, + `${keyBadge("g", color)} Validate staged ${keyBadge("d", color)} Doctor ${keyBadge("q", color)} Close`, + ].join("\n"); +} + +export function formatTrustError(error, options = {}) { + const color = options.color ?? false; + const width = options.width ?? MAX_CONTENT_WIDTH; + const message = error?.message ?? String(error); + return [ + `${paint(" OTITO ", [ANSI.brand, ANSI.black, ANSI.bold], color)} ${paint("TRUST STATUS", [ANSI.bold], color)}`, + "", + section("REVIEW UNAVAILABLE", width, color), + ...wrapText(message, width).map((line) => paint(line, [ANSI.dangerText], color)), + "", + paint("Check the repository and Otito installation, then refresh.", [ANSI.muted], color), + ].join("\n"); +} + +function readReport(repo, base, request) { + const args = ["review", repo, "--request", request, "--json"]; + if (base) args.push("--base", base); + const result = runOtito(args, { cwd: repo, capture: true }); + if (result.error) throw result.error; + let report; + try { + report = JSON.parse(result.stdout); + } catch { + throw new Error(result.stderr.trim() || "Otito returned unreadable review data."); + } + if (report.ok === false) { + throw new Error(report.error || "Otito could not generate a review."); + } + return report; +} + +async function runInteractiveCommand(rl, action, repo, base) { + let request; + if (action === "context" || action === "impact" || action === "gate-staged") { + request = (await rl.question("Change request: ")).trim(); + if (!request) return; + } + const args = buildOtitoArgs(action, { repo, request, base }); + stdout.write("\n"); + const result = runOtito(args, { cwd: repo }); + if (result.error) throw result.error; + await rl.question("\nPress Enter to return to trust status..."); +} + +export async function main() { + const context = parseInvocationContext(); + const repo = resolveRepoRoot(context); + const base = resolveBase(repo); + const rl = createInterface({ input: stdin, output: stdout }); + let request = "Review current changes before merge"; + + try { + for (;;) { + clear(); + const presentation = { color: supportsColor(), width: contentWidth() }; + let report; + try { + report = readReport(repo, base, request); + stdout.write(`${formatTrustSummary(report, repo, base, presentation)}\n`); + } catch (error) { + stdout.write(`${formatTrustError(error, presentation)}\n`); + } + + stdout.write(`\n${formatActionBar(presentation)}\n`); + const choice = (await rl.question(`${paint("›", [ANSI.bold], presentation.color)} `)).trim().toLowerCase(); + if (choice === "q") break; + if (choice === "r" || choice === "") continue; + + try { + if (choice === "c") await runInteractiveCommand(rl, "context", repo, base); + else if (choice === "i") { + const nextRequest = (await rl.question("Change request: ")).trim(); + if (nextRequest) { + request = nextRequest; + const result = runOtito(buildOtitoArgs("impact", { repo, request, base }), { cwd: repo }); + if (result.error) throw result.error; + await rl.question("\nPress Enter to return to trust status..."); + } + } else if (choice === "g") { + await runInteractiveCommand(rl, "gate-staged", repo, base); + } else if (choice === "d") { + const result = runOtito(["doctor"], { cwd: repo }); + if (result.error) throw result.error; + await rl.question("\nPress Enter to return to trust status..."); + } + } catch (error) { + stdout.write(`\nError: ${error.message ?? String(error)}\n`); + await rl.question("Press Enter to continue..."); + } + } + } finally { + rl.close(); + } +} + +const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false; + +if (isMain) { + main().catch((error) => { + process.stderr.write(`Otito Herdr plugin: ${error.message ?? String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/mkdocs.yml b/mkdocs.yml index 392ffbe..8e6496d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -108,6 +108,8 @@ nav: - Overview: 13-prompt-determinism-thesis/README.md - Trust Harness Thesis: - Overview: 14-trust-harness-thesis/README.md + - Herdr Integration: + - Overview: 15-herdr-integration/README.md - Evaluation Guide: EVALS.md - Glossary: GLOSSARY.md diff --git a/tests/herdr-plugin.test.js b/tests/herdr-plugin.test.js new file mode 100644 index 0000000..ba7beb8 --- /dev/null +++ b/tests/herdr-plugin.test.js @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { DEFAULT_REQUESTS, buildOtitoArgs, parseInvocationContext, requestFromContext } from "../integrations/herdr/runtime.mjs"; +import { formatActionBar, formatTrustError, formatTrustSummary } from "../integrations/herdr/trust-pane.mjs"; + +const manifestPath = fileURLToPath(new URL("../integrations/herdr/herdr-plugin.toml", import.meta.url)); + +test("Herdr manifest exposes the bounded Otito trust workflow", () => { + const manifest = readFileSync(manifestPath, "utf8"); + assert.match(manifest, /id = "bashbop\.otito"/); + assert.match(manifest, /min_herdr_version = "0\.8\.2"/); + for (const action of ["doctor", "context", "impact", "review", "gate-staged"]) { + assert.match(manifest, new RegExp(`id = "${action}"`)); + } + assert.match(manifest, /id = "trust-status"/); + assert.doesNotMatch(manifest, /command = \[[^\n]*(merge|push|commit)/); +}); + +test("invocation context is defensive and selected text becomes the request", () => { + assert.deepEqual(parseInvocationContext("not-json"), {}); + const context = parseInvocationContext(JSON.stringify({ selected_text: "Add a safe Herdr integration" })); + assert.equal(requestFromContext("impact", context), "Add a safe Herdr integration"); + assert.equal(requestFromContext("review", {}), DEFAULT_REQUESTS.review); +}); + +test("staged gate arguments bind request, base, validation, and staged tree", () => { + const args = buildOtitoArgs("gate-staged", { + repo: "/tmp/repo", + request: "ship the plugin", + base: "origin/main", + }); + assert.deepEqual(args, ["gate", "/tmp/repo", "--staged", "--run-validation", "--request", "ship the plugin", "--base", "origin/main"]); +}); + +test("trust status keeps hosted and human authority explicit", () => { + const output = formatTrustSummary( + { + verdict: "WARN", + confidence: 70, + prReviewSummary: { + changedFiles: 2, + additions: 12, + deletions: 3, + riskLevel: "medium", + }, + pass: { + checks: [ + { + status: "WARN", + name: "Review state", + summary: "Hosted review is not available locally.", + }, + ], + }, + }, + "/tmp/repo", + "origin/main", + ); + assert.match(output, /WARN · confidence 70%/); + assert.match(output, /Hosted review is not available locally/); + assert.match(output, /does not replace hosted CI/); + assert.match(output, /human merge decision/); +}); + +test("trust status presents an accessible terminal hierarchy with optional brand colour", () => { + const report = { + verdict: "PASS", + confidence: 96, + prReviewSummary: { + changedFiles: 1, + additions: 8, + deletions: 2, + riskLevel: "low", + }, + pass: { checks: [] }, + }; + + const plain = formatTrustSummary(report, "/tmp/repo", "origin/main", { color: false, width: 72 }); + assert.match(plain, /OVERVIEW/); + assert.match(plain, /MERGE SIGNAL/); + assert.match(plain, /PASS.*confidence 96%/); + assert.match(plain, /1 changed file/); + assert.match(plain, /LOW RISK/); + assert.match(plain, /┌─+┬─+┐/); + assert.match(plain, /REPOSITORY.*│ \/tmp\/repo/); + assert.equal(plain.includes("\u001b["), false); + + const colored = formatTrustSummary(report, "/tmp/repo", "origin/main", { color: true, width: 72 }); + assert.equal(colored.includes("\u001b[48;2;255;255;1m"), true); + assert.equal(colored.includes("\u001b[48;2;34;197;94m"), true); + assert.match(colored, /PASS/); + + const actions = formatActionBar({ color: false }); + assert.match(actions, /r.*Refresh.*c.*Context.*i.*Impact/); + assert.match(actions, /g.*Validate staged.*q.*Close/); + + const error = formatTrustError(new Error("Repository not found"), { color: false, width: 72 }); + assert.match(error, /REVIEW UNAVAILABLE/); + assert.match(error, /Repository not found/); +});