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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions docs/15-herdr-integration/README.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
64 changes: 64 additions & 0 deletions integrations/herdr/README.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions integrations/herdr/action.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
50 changes: 50 additions & 0 deletions integrations/herdr/herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -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"]
122 changes: 122 additions & 0 deletions integrations/herdr/runtime.mjs
Original file line number Diff line number Diff line change
@@ -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",
});
}
Loading