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 CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ _Avoid_: task-executor (task collides with the harness's own task tools, and **s
The per-slice record a **Slice executor** writes at each completed stage, and the anchor an interrupted slice resumes from. It preserves the resume granularity that per-transition `subState` writes give today, now that one executor spawn spans several stages. The executor owns the record and reads it back to resume itself; the orchestrator never opens it, obtaining its contents only through a dedicated MCP tool when an envelope is missing or invalid — the same structured-recovery path as the **Worktree fallback**. It also carries the once-only **Model fallback** guard, which is why that guard survives a handoff without living in the orchestrator's checkpoint.
_Avoid_: slice state, slice checkpoint (the run-state checkpoint is the orchestrator's; this record is the executor's)

**Read guard**:
The plugin-level `PreToolUse` **Hook** that mechanically denies the orchestrator a read of a slice-internal artifact — the **Slice progress record** and the slice report — and returns a reason naming the correct behaviour instead. It tells the orchestrator from a subagent by the agent identity the hook event carries, is scoped to `Read` and `Bash`, and is a silent no-op with no run in progress. It is **defence in depth for the prose rule, never a replacement**: policy can disable plugin hooks, and `@`-referenced files reach the model without any tool call at all, so the boundary stated in the orchestrator's own instructions stays load-bearing. See ADR-0017.
_Avoid_: read block, permission rule (a permission rule was the rejected alternative — it would restrict the executor too and carries no corrective message)

**Failure class**:
The closed enum a **Slice executor** returns naming *why* a slice failed, alongside the prose failure reason. It exists because the layer holding the evidence and the layer holding tracker authority are no longer the same one: the executor observes the failure and classifies it, and the orchestrator maps the class to a triage label and writes it. Classification follows the evidence; labelling policy stays with the single writer.
_Avoid_: failure reason (that is the prose companion, not the enum), error code
Expand Down
13 changes: 10 additions & 3 deletions plugins/orchestrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ A long backlog can exhaust the orchestrator session before every wave is done

When several runs proceed concurrently in one repository, the watchdog binds to the correct run by **driver-session identity**: a companion `SessionStart` hook captures the session's `session_id` into `$ORCHESTRATE_SESSION_ID`, the orchestrator records it as `driverSessionId` in `run-state.json` (refreshed on resume), and the watchdog matches the event's `session_id` against each in-progress run — writing the flag only under the matching run's directory. If it cannot disambiguate, or the identity is unavailable, the watchdog safely writes nothing: the run stays correct and merely loses automatic handoff, remaining manually resumable with `/orchestrate`.

### Read guard

The slice-executor delegation layer saves the orchestrator's context by keeping slice-internal artifacts — each slice's progress record and its report — out of the orchestrator's window entirely: it learns a slice's outcome from the executor's result envelope and passes those paths forward without opening them. The bundled `read-guard` hook (a `PreToolUse` hook scoped to `Read` and `Bash`) enforces that boundary mechanically, denying such a read and returning a reason that names what to do instead — use the envelope's own fields, and recover the record through the `recover_slice_progress` tool if the envelope is missing or invalid. It tells the orchestrator from a subagent by the agent identity the hook event carries, so an executor reading its own record is untouched, and with no run in progress it is a silent no-op for every path.

It is **defence in depth, not a dependency.** The same boundary is stated as a rule in the orchestrator's own instructions and stays load-bearing: enterprise policy (`allowManagedHooksOnly`) or a user setting (`disableAllHooks`, which is all-or-nothing and would also give up the context watchdog) can switch plugin hooks off, and even when enabled the hook cannot see a file referenced with `@` in a prompt, which Claude Code inserts without any tool call.

## Installation

```bash
Expand Down Expand Up @@ -421,7 +427,8 @@ plugins/orchestrate/
├── README.md # This file
├── hooks/
│ └── hooks.json # context-watchdog (PostToolUse) +
│ # session-start (SessionStart) hooks
│ # session-start (SessionStart) +
│ # read-guard (PreToolUse) hooks
├── skills/
│ ├── orchestrate/
│ │ ├── SKILL.md # The orchestrator judgment spine
Expand All @@ -440,7 +447,7 @@ plugins/orchestrate/
├── src/ # Tool implementations
├── test/ # Unit suite
└── dist/ # Bundled server + context-watchdog +
# session-start hooks
# session-start + read-guard hooks
```

## Contributing to orchestrate-mcp
Expand All @@ -450,7 +457,7 @@ The `orchestrate-mcp/` directory contains a TypeScript MCP server whose compiled
```bash
cd plugins/orchestrate/orchestrate-mcp
npm ci # if node_modules is stale
npm run build # regenerates dist/index.js, dist/context-watchdog.js, dist/session-start.js
npm run build # regenerates dist/index.js, dist/context-watchdog.js, dist/session-start.js, dist/read-guard.js
git add dist/
```

Expand Down
12 changes: 12 additions & 0 deletions plugins/orchestrate/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@
]
}
],
"PreToolUse": [
{
"matcher": "Read|Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/orchestrate-mcp/dist/read-guard.js\"",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": ".*",
Expand Down
215 changes: 215 additions & 0 deletions plugins/orchestrate/orchestrate-mcp/dist/read-guard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
#!/usr/bin/env node
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));

// src/hooks/read-guard.ts
var path = __toESM(require("path"));
var GUARDED_BASENAME = /^slice-\d+-(progress\.json|report\.md)$/;
var READ_VERBS = /* @__PURE__ */ new Set([
"cat",
"head",
"tail",
"less",
"more",
"bat",
"sed",
"awk",
"grep",
"rg",
"jq",
"od",
"xxd",
"strings",
"nl",
"wc",
"source",
"."
]);
var READ_GUARD_DENY_REASON = "orchestrate: the orchestrator does not open slice-internal artifacts. Use the slice-executor envelope's own fields for the slice's outcome, and pass reportPath forward without opening it. If the envelope is missing or invalid, recover the progress record's contents through the recover_slice_progress MCP tool, which derives the path from (runId, issue) and returns validated structured data.";
function denyPayload(reason) {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: reason
}
};
}
function unquote(token) {
const match = token.match(/^(["'])(.*)\1$/);
return match ? match[2] : token;
}
function isGuardedPath(candidate, cwd, runDir) {
if (candidate === "") return false;
const resolved = path.resolve(cwd, candidate);
return path.dirname(resolved) === runDir && GUARDED_BASENAME.test(path.basename(resolved));
}
function bashReadsGuardedPath(command, cwd, runDir) {
for (const segment of command.split(/&&|\|\||;|\||\n/)) {
const tokens = segment.trim().split(/\s+/).filter((t) => t !== "");
if (tokens.length === 0) continue;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
let candidate;
if (token === "<") candidate = tokens[i + 1];
else if (token.startsWith("<") && !token.startsWith("<<")) {
candidate = token.slice(1);
}
if (candidate !== void 0 && isGuardedPath(unquote(candidate), cwd, runDir)) {
return true;
}
}
let start = 0;
while (start < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[start])) {
start++;
}
if (start >= tokens.length) continue;
if (!READ_VERBS.has(path.basename(unquote(tokens[start])))) continue;
for (const token of tokens.slice(start + 1)) {
if (isGuardedPath(unquote(token), cwd, runDir)) return true;
}
}
return false;
}
function decideReadGuard(input) {
const none = { decision: "none" };
try {
if (typeof input.agentId === "string" && input.agentId.length > 0) {
return none;
}
const runId = input.activeRunId;
if (typeof runId !== "string" || runId.length === 0) return none;
if (typeof input.cwd !== "string" || input.cwd.length === 0) return none;
const toolInput = input.toolInput;
if (typeof toolInput !== "object" || toolInput === null) return none;
const cwd = input.cwd;
const runDir = path.resolve(cwd, ".orchestrate", "runs", runId);
if (input.toolName === "Read") {
const filePath = toolInput.file_path;
if (typeof filePath === "string" && isGuardedPath(filePath, cwd, runDir)) {
return { decision: "deny", reason: READ_GUARD_DENY_REASON };
}
return none;
}
if (input.toolName === "Bash") {
const command = toolInput.command;
if (typeof command === "string" && bashReadsGuardedPath(command, cwd, runDir)) {
return { decision: "deny", reason: READ_GUARD_DENY_REASON };
}
return none;
}
return none;
} catch {
return none;
}
}

// src/hooks/run-discovery.ts
var path2 = __toESM(require("path"));
var fs = __toESM(require("fs"));
function scanInProgressRuns(cwd) {
const runsDir = path2.join(cwd, ".orchestrate", "runs");
let entries;
try {
entries = fs.readdirSync(runsDir, { withFileTypes: true });
} catch {
return [];
}
const runs = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const statePath = path2.join(runsDir, entry.name, "run-state.json");
let runState;
try {
runState = JSON.parse(fs.readFileSync(statePath, "utf8"));
} catch {
continue;
}
if (typeof runState !== "object" || runState === null || runState.status !== "in-progress") {
continue;
}
const rawId = runState.driverSessionId;
runs.push({
runId: entry.name,
driverSessionId: typeof rawId === "string" ? rawId : null
});
}
return runs;
}
function findActiveRunForSession(cwd, sessionId) {
const runs = scanInProgressRuns(cwd);
if (runs.length === 0) return null;
if (typeof sessionId === "string" && sessionId.length > 0) {
const matches = runs.filter((r) => r.driverSessionId === sessionId);
if (matches.length === 1) return matches[0].runId;
}
if (runs.length === 1) return runs[0].runId;
return null;
}

// src/hooks/read-guard-cli.ts
function readStdin() {
return new Promise((resolve2) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => resolve2(data));
process.stdin.on("error", () => resolve2(data));
});
}
async function main() {
let raw = "";
try {
raw = await readStdin();
} catch {
process.exit(0);
}
try {
const event = raw ? JSON.parse(raw) : {};
const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd();
const sessionId = typeof event.session_id === "string" && event.session_id.length > 0 ? event.session_id : void 0;
const runId = findActiveRunForSession(cwd, sessionId);
if (runId === null) {
process.exit(0);
}
const decision = decideReadGuard({
toolName: typeof event.tool_name === "string" ? event.tool_name : void 0,
toolInput: typeof event.tool_input === "object" && event.tool_input !== null ? event.tool_input : void 0,
// Present ONLY inside a subagent call, which is what makes it — and not
// `agent_type`, which a `--agent` session also carries — the main-thread
// discriminator.
agentId: typeof event.agent_id === "string" ? event.agent_id : void 0,
cwd,
activeRunId: runId
});
if (decision.decision === "deny") {
process.stdout.write(JSON.stringify(denyPayload(decision.reason)));
}
} catch {
}
process.exit(0);
}
void main();
2 changes: 1 addition & 1 deletion plugins/orchestrate/orchestrate-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "MCP server providing worktree lifecycle and orchestration tools for parallel Claude Code agents.",
"main": "dist/index.js",
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js && esbuild src/hooks/context-watchdog-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/context-watchdog.js && esbuild src/hooks/session-start-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/session-start.js",
"build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js && esbuild src/hooks/context-watchdog-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/context-watchdog.js && esbuild src/hooks/session-start-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/session-start.js && esbuild src/hooks/read-guard-cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/read-guard.js",
"typecheck": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
84 changes: 84 additions & 0 deletions plugins/orchestrate/orchestrate-mcp/src/hooks/read-guard-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env node
import { decideReadGuard, denyPayload } from "./read-guard.js";
import { findActiveRunForSession } from "./run-discovery.js";

// Entry point for the `read-guard` PreToolUse hook. Claude Code pipes the hook
// event JSON on stdin; this script asks `read-guard.ts` whether the call is the
// orchestrator opening a slice-internal artifact, and on a deny writes the
// `hookSpecificOutput` payload to stdout. Every decision — deny and no-decision
// alike — exits 0: JSON output is only processed on exit 0, and silence plus
// exit 0 is the documented "no decision, normal permission flow applies" path.
// A blocking hook that crashed would be worse than one that missed, so the
// whole body is wrapped in a swallowing try/catch.
//
// All judgement lives in the pure module. This file does two things the module
// cannot: it reads the event, and it resolves the active run from the
// filesystem — the same split `context-watchdog-cli.ts` uses, where
// `findActiveRunForSession` is called here rather than inside the hook module.
//
// The run resolution is also the guard's OFF SWITCH: with no in-progress run,
// or with concurrent runs this session cannot be disambiguated against,
// discovery returns null and every path is allowed. That short-circuit is
// duplicated inside the module (which no-ops on an absent `activeRunId`), so
// the behaviour is unit-testable rather than reachable only through a process.

/** Reads all of stdin as a string. Resolves with whatever arrived on error. */
function readStdin(): Promise<string> {
return new Promise((resolve) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => resolve(data));
process.stdin.on("error", () => resolve(data));
});
}

async function main(): Promise<void> {
let raw = "";
try {
raw = await readStdin();
} catch {
process.exit(0);
}

try {
const event = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd();
// An EMPTY `session_id` is treated as absent, the same guard
// `findActiveRunForSession` applies — an empty string is not an identity.
const sessionId =
typeof event.session_id === "string" && event.session_id.length > 0
? event.session_id
: undefined;

const runId = findActiveRunForSession(cwd, sessionId);
if (runId === null) {
process.exit(0);
}

const decision = decideReadGuard({
toolName: typeof event.tool_name === "string" ? event.tool_name : undefined,
toolInput:
typeof event.tool_input === "object" && event.tool_input !== null
? (event.tool_input as Record<string, unknown>)
: undefined,
// Present ONLY inside a subagent call, which is what makes it — and not
// `agent_type`, which a `--agent` session also carries — the main-thread
// discriminator.
agentId: typeof event.agent_id === "string" ? event.agent_id : undefined,
cwd,
activeRunId: runId,
});

if (decision.decision === "deny") {
process.stdout.write(JSON.stringify(denyPayload(decision.reason)));
}
} catch {
// Any failure is swallowed — the guard must not disrupt the session.
}
process.exit(0);
}

void main();
Loading
Loading