chapterhouse 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Ralph watch process manager.
3
+ *
4
+ * Wraps `squad loop` (the CLI's "Ralph mode" — continuous work loop) so the
5
+ * web UI can start, stop, and inspect the local watch process.
6
+ *
7
+ * Single-process assumption: only one Ralph loop runs per daemon instance.
8
+ * Cross-project Ralph (WS bridge) is out of scope — see #101 follow-ups.
9
+ *
10
+ * @module api/ralph
11
+ */
12
+ import { spawn, execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ import { childLogger } from "../util/logger.js";
15
+ const log = childLogger("ralph");
16
+ const execFileAsync = promisify(execFile);
17
+ const state = {
18
+ process: null,
19
+ pid: null,
20
+ startedAt: null,
21
+ projectRoot: null,
22
+ interval: null,
23
+ lastPollAt: null,
24
+ };
25
+ // ---------------------------------------------------------------------------
26
+ // Status
27
+ // ---------------------------------------------------------------------------
28
+ export function getRalphStatus() {
29
+ return {
30
+ running: state.process !== null && state.pid !== null,
31
+ pid: state.pid,
32
+ startedAt: state.startedAt?.toISOString() ?? null,
33
+ projectRoot: state.projectRoot,
34
+ interval: state.interval,
35
+ lastPollAt: state.lastPollAt?.toISOString() ?? null,
36
+ };
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // Start
40
+ // ---------------------------------------------------------------------------
41
+ export function startRalph(projectRoot, interval) {
42
+ if (state.process !== null) {
43
+ return { started: false, error: "Ralph watch is already running" };
44
+ }
45
+ const proc = spawn("squad", ["loop", "--interval", String(interval)], {
46
+ cwd: projectRoot,
47
+ detached: false,
48
+ stdio: ["ignore", "pipe", "pipe"],
49
+ });
50
+ if (!proc.pid) {
51
+ return { started: false, error: "Failed to spawn squad process" };
52
+ }
53
+ state.process = proc;
54
+ state.pid = proc.pid;
55
+ state.startedAt = new Date();
56
+ state.projectRoot = projectRoot;
57
+ state.interval = interval;
58
+ proc.stdout?.on("data", (_data) => {
59
+ state.lastPollAt = new Date();
60
+ });
61
+ proc.stderr?.on("data", (data) => {
62
+ log.warn({ data: data.toString().trim() }, "ralph stderr");
63
+ });
64
+ proc.on("exit", (code, signal) => {
65
+ log.info({ code, signal }, "Ralph process exited");
66
+ state.process = null;
67
+ state.pid = null;
68
+ state.startedAt = null;
69
+ state.projectRoot = null;
70
+ state.interval = null;
71
+ });
72
+ log.info({ pid: proc.pid, projectRoot, interval }, "Ralph watch started");
73
+ return { started: true, pid: proc.pid };
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // Stop
77
+ // ---------------------------------------------------------------------------
78
+ export function stopRalph() {
79
+ if (!state.process || !state.pid) {
80
+ return { stopped: false, error: "Ralph watch is not running" };
81
+ }
82
+ try {
83
+ state.process.kill("SIGTERM");
84
+ // State cleanup happens in the exit handler; clear eagerly for immediate
85
+ // status visibility.
86
+ state.process = null;
87
+ state.pid = null;
88
+ state.startedAt = null;
89
+ state.projectRoot = null;
90
+ state.interval = null;
91
+ return { stopped: true };
92
+ }
93
+ catch (err) {
94
+ return { stopped: false, error: err instanceof Error ? err.message : String(err) };
95
+ }
96
+ }
97
+ /**
98
+ * Fetch open squad-related issues from the GitHub repo rooted at projectRoot.
99
+ * Combines squad-labeled issues and issues labeled squad:* (any agent assignment).
100
+ */
101
+ export async function getRalphQueue(projectRoot) {
102
+ const run = async (label) => {
103
+ const { stdout } = await execFileAsync("gh", ["issue", "list", "--label", label, "--json", "number,title,url,state,createdAt,labels", "--limit", "50"], { cwd: projectRoot });
104
+ return JSON.parse(stdout);
105
+ };
106
+ // Fetch squad (triage inbox) and squad:* (agent-assigned) in parallel.
107
+ // Merge and deduplicate by issue number.
108
+ const [squadIssues, squadStarIssues] = await Promise.allSettled([
109
+ run("squad"),
110
+ // gh doesn't support wildcard label filters; use a common prefix label.
111
+ // Most squad-labeled issues carry both "squad" and "squad:<agent>" labels,
112
+ // so the first query already captures them. Keep the second as belt-and-suspenders
113
+ // using any known squad-prefixed label.
114
+ run("squad:*").catch(() => []),
115
+ ]);
116
+ const all = [
117
+ ...(squadIssues.status === "fulfilled" ? squadIssues.value : []),
118
+ ...(squadStarIssues.status === "fulfilled" ? squadStarIssues.value : []),
119
+ ];
120
+ const seen = new Set();
121
+ const deduped = all.filter((issue) => {
122
+ if (seen.has(issue.number))
123
+ return false;
124
+ seen.add(issue.number);
125
+ return true;
126
+ });
127
+ return deduped.map((issue) => ({
128
+ number: issue.number,
129
+ title: issue.title,
130
+ url: issue.url,
131
+ state: issue.state,
132
+ createdAt: issue.createdAt,
133
+ labels: issue.labels.map((l) => l.name),
134
+ }));
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Daemon shutdown hook
138
+ // ---------------------------------------------------------------------------
139
+ /** Kill Ralph watch process on daemon shutdown — call from shutdown() in daemon.ts. */
140
+ export function killRalphOnShutdown() {
141
+ if (state.process) {
142
+ log.info({ pid: state.pid }, "Stopping Ralph watch on daemon shutdown");
143
+ try {
144
+ state.process.kill("SIGTERM");
145
+ }
146
+ catch {
147
+ /* best effort */
148
+ }
149
+ state.process = null;
150
+ state.pid = null;
151
+ }
152
+ }
153
+ //# sourceMappingURL=ralph.js.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Unit tests for Ralph watch process manager.
3
+ *
4
+ * We test the module's exported functions directly. For process-spawn paths
5
+ * we verify state-machine behavior (status transitions, error returns) rather
6
+ * than trying to mock child_process across the ESM module cache boundary —
7
+ * the existing test harness doesn't support that cleanly for singleton modules.
8
+ *
9
+ * The getRalphQueue integration is exercised at the HTTP endpoint level in
10
+ * server.test.ts via supertest.
11
+ */
12
+ import assert from "node:assert/strict";
13
+ import test from "node:test";
14
+ import { getRalphStatus, startRalph, stopRalph } from "./ralph.js";
15
+ // ---------------------------------------------------------------------------
16
+ // Ensure a clean slate before each test group.
17
+ // ---------------------------------------------------------------------------
18
+ function ensureStopped() {
19
+ stopRalph(); // idempotent — ignores "not running" error
20
+ }
21
+ test("getRalphStatus — returns not-running when idle", () => {
22
+ ensureStopped();
23
+ const status = getRalphStatus();
24
+ assert.equal(status.running, false);
25
+ assert.equal(status.pid, null);
26
+ assert.equal(status.startedAt, null);
27
+ assert.equal(status.projectRoot, null);
28
+ });
29
+ test("stopRalph — returns error when not running", () => {
30
+ ensureStopped();
31
+ const result = stopRalph();
32
+ assert.equal(result.stopped, false);
33
+ if (!result.stopped) {
34
+ assert.match(result.error, /not running/i);
35
+ }
36
+ });
37
+ test("startRalph — spawns process and updates status", () => {
38
+ ensureStopped();
39
+ // We spawn 'squad loop' against the project root. In test environment squad
40
+ // will likely exit immediately (no .squad dir), but the process is created
41
+ // and state is updated synchronously before the exit handler fires.
42
+ // Use the worktree path itself — it has a .squad dir via the main repo's
43
+ // git worktree setup, so squad can at least start.
44
+ const projectRoot = process.cwd();
45
+ const result = startRalph(projectRoot, 10);
46
+ // Either it started (pid assigned) or it failed to spawn — either way must be well-typed.
47
+ if (result.started) {
48
+ assert.ok(typeof result.pid === "number", "pid should be a number");
49
+ const status = getRalphStatus();
50
+ // Status may have already transitioned back to stopped if process exited instantly.
51
+ // The important thing is that the call succeeded and we got a pid.
52
+ assert.ok(result.pid > 0, "pid should be positive");
53
+ }
54
+ else {
55
+ // Spawn failed (e.g., squad not found in PATH during test) — that's acceptable.
56
+ assert.ok(typeof result.error === "string");
57
+ }
58
+ // Cleanup regardless.
59
+ ensureStopped();
60
+ });
61
+ test("startRalph — returns error if already running", () => {
62
+ ensureStopped();
63
+ const projectRoot = process.cwd();
64
+ const first = startRalph(projectRoot, 10);
65
+ if (!first.started) {
66
+ // Can't test double-start if first spawn fails — skip gracefully.
67
+ return;
68
+ }
69
+ const second = startRalph(projectRoot, 10);
70
+ assert.equal(second.started, false);
71
+ if (!second.started) {
72
+ assert.match(second.error, /already running/i);
73
+ }
74
+ ensureStopped();
75
+ });
76
+ test("stopRalph — transitions status back to stopped", () => {
77
+ ensureStopped();
78
+ const projectRoot = process.cwd();
79
+ const result = startRalph(projectRoot, 10);
80
+ if (!result.started) {
81
+ // spawn unavailable in test env — skip.
82
+ return;
83
+ }
84
+ const stopResult = stopRalph();
85
+ assert.ok(stopResult.stopped, "stop should succeed when running");
86
+ const status = getRalphStatus();
87
+ assert.equal(status.running, false);
88
+ assert.equal(status.pid, null);
89
+ });
90
+ test("getRalphStatus shape — all fields present", () => {
91
+ ensureStopped();
92
+ const status = getRalphStatus();
93
+ assert.ok(Object.prototype.hasOwnProperty.call(status, "running"));
94
+ assert.ok(Object.prototype.hasOwnProperty.call(status, "pid"));
95
+ assert.ok(Object.prototype.hasOwnProperty.call(status, "startedAt"));
96
+ assert.ok(Object.prototype.hasOwnProperty.call(status, "projectRoot"));
97
+ assert.ok(Object.prototype.hasOwnProperty.call(status, "interval"));
98
+ assert.ok(Object.prototype.hasOwnProperty.call(status, "lastPollAt"));
99
+ assert.equal(typeof status.running, "boolean");
100
+ });
101
+ //# sourceMappingURL=ralph.test.js.map
@@ -6,6 +6,7 @@ import { join, dirname } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { z } from "zod";
8
8
  import { sendToOrchestrator, getAgentInfo, cancelCurrentMessage, getLastRouteResult, getCurrentSessionKey, subscribeTaskEvents } from "../copilot/orchestrator.js";
9
+ import { squadEventBus } from "../copilot/squad-event-bus.js";
9
10
  import { getAgentRegistry } from "../copilot/agents.js";
10
11
  import { config, persistModel } from "../config.js";
11
12
  import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
@@ -27,6 +28,7 @@ import { resolveProjectSquad } from "../squad/discovery.js";
27
28
  import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, buildHistoryEntries, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
28
29
  import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
29
30
  import { childLogger } from "../util/logger.js";
31
+ import { getRalphStatus, startRalph, stopRalph, getRalphQueue } from "./ralph.js";
30
32
  const log = childLogger("server");
31
33
  void searchIndex; // re-exported by index-manager; reference here documents the dep
32
34
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -321,8 +323,72 @@ app.get("/api/workers/:taskId/events/stream", (req, res) => {
321
323
  });
322
324
  });
323
325
  // ---------------------------------------------------------------------------
324
- // SSE stream for real-time chat
326
+ // Global squad EventBus SSE stream thin pass-through of SDK SquadEvents.
327
+ // Replaces the 4-second poll in the Workers frontend: clients subscribe once
328
+ // and receive push notifications on session:created / session:destroyed /
329
+ // session:error so the worker list updates in real time.
330
+ // Chat-specific events (delta, message, queued) are NOT emitted here.
325
331
  // ---------------------------------------------------------------------------
332
+ app.get("/api/squad/stream", (req, res) => {
333
+ res.writeHead(200, {
334
+ "Content-Type": "text/event-stream",
335
+ "Cache-Control": "no-cache",
336
+ Connection: "keep-alive",
337
+ });
338
+ res.write(formatSseData({ type: "connected" }));
339
+ const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
340
+ const unsub = squadEventBus.subscribeAll((event) => {
341
+ res.write(formatSseData({ type: "squad_event", squadEvent: event }));
342
+ });
343
+ req.on("close", () => {
344
+ clearInterval(heartbeat);
345
+ unsub();
346
+ });
347
+ });
348
+ // ---------------------------------------------------------------------------
349
+ // Ralph watch panel endpoints — /api/squad/ralph/*
350
+ // ---------------------------------------------------------------------------
351
+ const ralphStartSchema = z.object({
352
+ projectRoot: z.string().min(1, "projectRoot is required"),
353
+ interval: z.number().int().min(1).max(120).default(10),
354
+ }).strict();
355
+ app.get("/api/squad/ralph/status", (_req, res) => {
356
+ res.json(getRalphStatus());
357
+ });
358
+ app.post("/api/squad/ralph/start", (req, res) => {
359
+ const { projectRoot, interval } = parseRequest(ralphStartSchema, req.body);
360
+ const result = startRalph(projectRoot, interval);
361
+ if (!result.started) {
362
+ res.status(409).json({ error: result.error });
363
+ return;
364
+ }
365
+ res.status(201).json({ pid: result.pid, projectRoot, interval });
366
+ });
367
+ app.post("/api/squad/ralph/stop", (_req, res) => {
368
+ const result = stopRalph();
369
+ if (!result.stopped) {
370
+ res.status(409).json({ error: result.error });
371
+ return;
372
+ }
373
+ res.json({ stopped: true });
374
+ });
375
+ app.get("/api/squad/ralph/queue", async (req, res) => {
376
+ const projectRoot = Array.isArray(req.query.projectRoot)
377
+ ? req.query.projectRoot[0]
378
+ : req.query.projectRoot;
379
+ if (!projectRoot || typeof projectRoot !== "string") {
380
+ res.status(400).json({ error: "Missing required query param: projectRoot" });
381
+ return;
382
+ }
383
+ try {
384
+ const issues = await getRalphQueue(projectRoot);
385
+ res.json({ projectRoot, issues });
386
+ }
387
+ catch (err) {
388
+ log.warn({ err: err instanceof Error ? err.message : err }, "getRalphQueue failed");
389
+ res.status(502).json({ error: err instanceof Error ? err.message : "gh issue list failed" });
390
+ }
391
+ });
326
392
  app.get("/stream", (req, res) => {
327
393
  const connectionId = `web-${++connectionCounter}`;
328
394
  res.writeHead(200, {
@@ -408,6 +474,15 @@ app.post("/api/message", (req, res) => {
408
474
  ...(msgId ? { msgId } : {}),
409
475
  }));
410
476
  }
477
+ }, (remainingLength) => {
478
+ const sseRes = sseClients.get(connectionId);
479
+ if (sseRes) {
480
+ sseRes.write(formatSseData({
481
+ type: "queue-advance",
482
+ length: remainingLength,
483
+ sessionKey: effectiveSessionKey,
484
+ }));
485
+ }
411
486
  });
412
487
  res.json({ status: "queued" });
413
488
  });
@@ -8,6 +8,7 @@ import { AGENTS_DIR, SESSIONS_DIR } from "../paths.js";
8
8
  import { getState, setState } from "../store/db.js";
9
9
  import { loadMcpConfig } from "./mcp-config.js";
10
10
  import { getSkillDirectories } from "./skills.js";
11
+ import { createSessionHooks } from "./hooks.js";
11
12
  import { findSquadAgent, renderProjectAgentRoster } from "../squad/registry.js";
12
13
  import { childLogger } from "../util/logger.js";
13
14
  const log = childLogger("agents");
@@ -19,6 +20,7 @@ const agentFrontmatterSchema = z.object({
19
20
  skills: z.array(z.string()).optional(),
20
21
  tools: z.array(z.string()).optional(),
21
22
  mcpServers: z.array(z.string()).optional(),
23
+ allowed_paths: z.array(z.string()).optional(),
22
24
  });
23
25
  // ---------------------------------------------------------------------------
24
26
  // Agent Registry
@@ -50,7 +52,7 @@ export function parseAgentMd(content, slug) {
50
52
  parsed[key] = value;
51
53
  }
52
54
  // Parse arrays from YAML inline syntax: [a, b, c]
53
- for (const key of ["skills", "tools", "mcpServers"]) {
55
+ for (const key of ["skills", "tools", "mcpServers", "allowed_paths"]) {
54
56
  const raw = parsed[key];
55
57
  if (typeof raw === "string") {
56
58
  const arrMatch = raw.match(/^\[(.*)\]$/);
@@ -76,6 +78,7 @@ export function parseAgentMd(content, slug) {
76
78
  skills: fm.skills,
77
79
  tools: fm.tools,
78
80
  mcpServers: fm.mcpServers,
81
+ allowedPaths: fm.allowed_paths,
79
82
  systemMessage: body,
80
83
  };
81
84
  }
@@ -322,6 +325,7 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
322
325
  mcpServers,
323
326
  skillDirectories,
324
327
  onPermissionRequest: approveAll,
328
+ hooks: createSessionHooks(slug, agent.allowedPaths),
325
329
  infiniteSessions: {
326
330
  enabled: true,
327
331
  backgroundCompactionThreshold: 0.80,
@@ -349,6 +353,7 @@ export async function createSquadAgentSession(slug, client, allTools, systemMess
349
353
  mcpServers,
350
354
  skillDirectories,
351
355
  onPermissionRequest: approveAll,
356
+ hooks: createSessionHooks(slug),
352
357
  infiniteSessions: {
353
358
  enabled: true,
354
359
  backgroundCompactionThreshold: 0.80,
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Hook Pipeline — Governance enforcement for Chapterhouse sessions.
3
+ *
4
+ * Bridges the squad-sdk's HookPipeline to the Copilot SDK's SessionHooks interface.
5
+ * All session creation sites call createSessionHooks() to get a SessionHooks object
6
+ * that runs the four Phase 1 policy hooks:
7
+ *
8
+ * 1. File-write guard — block writes outside allowed paths
9
+ * 2. Shell restriction — block dangerous commands (rm -rf, force-push, etc.)
10
+ * 3. PII scrubber — redact emails from tool output before agent sees it
11
+ * 4. Reviewer lockout — block locked-out agents from re-authoring rejected artifacts
12
+ *
13
+ * SPIKE FINDING (2026-05-08): onPermissionRequest only receives a coarse `kind`
14
+ * ("shell"|"write"|...) with no tool name or arguments. The correct interception
15
+ * point is `hooks.onPreToolUse` / `hooks.onPostToolUse` in SessionConfig.
16
+ */
17
+ import { HookPipeline } from "@bradygaster/squad-sdk/hooks";
18
+ import { childLogger } from "../util/logger.js";
19
+ const log = childLogger("hooks");
20
+ /**
21
+ * Paths agents are allowed to write into by default.
22
+ * This covers the full project tree. Per-agent restrictions come from
23
+ * charter frontmatter `allowed_paths` (Phase 2).
24
+ */
25
+ export const DEFAULT_ALLOWED_PATHS = [
26
+ "src/**",
27
+ ".squad/**",
28
+ "docs/**",
29
+ "web/**",
30
+ ".github/**",
31
+ ".worktrees/**",
32
+ "agents/**",
33
+ "scripts/**",
34
+ "skills/**",
35
+ ];
36
+ let pipeline;
37
+ export function getHookPipeline() {
38
+ return pipeline;
39
+ }
40
+ /**
41
+ * Initialize the singleton HookPipeline. Called once from initOrchestrator()
42
+ * before any sessions are created. Safe to call multiple times (reinitializes).
43
+ */
44
+ export function initHookPipeline(overrides) {
45
+ const config = {
46
+ allowedWritePaths: DEFAULT_ALLOWED_PATHS,
47
+ scrubPii: true,
48
+ reviewerLockout: true,
49
+ // blockedCommands: undefined → HookPipeline uses DEFAULT_BLOCKED_COMMANDS
50
+ ...overrides,
51
+ };
52
+ pipeline = new HookPipeline(config);
53
+ log.info({ scrubPii: config.scrubPii, reviewerLockout: config.reviewerLockout, paths: config.allowedWritePaths?.length }, "HookPipeline initialized");
54
+ return pipeline;
55
+ }
56
+ /**
57
+ * Normalize a file path: strip leading CWD so that absolute paths match
58
+ * relative glob patterns. HookPipeline.matchGlob anchors with ^...$, so
59
+ * "/home/user/repo/src/foo.ts" won't match "src/**" without this step.
60
+ */
61
+ function normalizePath(filePath) {
62
+ const cwd = process.cwd().replace(/\\/g, "/");
63
+ const normalized = filePath.replace(/\\/g, "/");
64
+ if (normalized.startsWith(cwd + "/")) {
65
+ return normalized.slice(cwd.length + 1);
66
+ }
67
+ return normalized;
68
+ }
69
+ /**
70
+ * Normalize all path arguments in a tool args object before running hooks.
71
+ * The file-write guard reads `path` or `file_path` from arguments.
72
+ */
73
+ function normalizeToolArgs(args) {
74
+ const out = { ...args };
75
+ if (typeof out.path === "string")
76
+ out.path = normalizePath(out.path);
77
+ if (typeof out.file_path === "string")
78
+ out.file_path = normalizePath(out.file_path);
79
+ return out;
80
+ }
81
+ /**
82
+ * Create a SessionHooks object for use in createSession / resumeSession calls.
83
+ *
84
+ * @param agentName - Used for reviewer-lockout and log context. Pass "orchestrator"
85
+ * for coordinator sessions.
86
+ * @param agentAllowedPaths - If provided, overrides the default allowed paths for the
87
+ * file-write guard. Derived from charter frontmatter in Phase 2.
88
+ * Currently unused (no charter has allowed_paths yet).
89
+ */
90
+ export function createSessionHooks(agentName, agentAllowedPaths) {
91
+ // If agent has specific paths, create a temporary per-session pipeline for file-write
92
+ // checking. All other hooks (shell, PII, lockout) use the singleton.
93
+ const fileWritePipeline = agentAllowedPaths && agentAllowedPaths.length > 0
94
+ ? new HookPipeline({ allowedWritePaths: agentAllowedPaths })
95
+ : undefined;
96
+ return {
97
+ onPreToolUse: async (input, invocation) => {
98
+ const p = getHookPipeline();
99
+ if (!p)
100
+ return;
101
+ const args = normalizeToolArgs((input.toolArgs ?? {}));
102
+ const ctx = {
103
+ toolName: input.toolName,
104
+ arguments: args,
105
+ agentName,
106
+ sessionId: invocation.sessionId,
107
+ };
108
+ // If agent has specific allowed paths, check file-write guard with those first.
109
+ if (fileWritePipeline) {
110
+ const pathResult = await fileWritePipeline.runPreToolHooks(ctx);
111
+ if (pathResult.action === "block") {
112
+ log.warn({ tool: input.toolName, agent: agentName, reason: pathResult.reason }, "Hook blocked (agent paths)");
113
+ return {
114
+ permissionDecision: "deny",
115
+ permissionDecisionReason: `[BLOCKED] ${pathResult.reason}`,
116
+ };
117
+ }
118
+ }
119
+ const result = await p.runPreToolHooks(ctx);
120
+ if (result.action === "block") {
121
+ log.warn({ tool: input.toolName, agent: agentName, reason: result.reason }, "Hook blocked tool call");
122
+ return {
123
+ permissionDecision: "deny",
124
+ permissionDecisionReason: `[BLOCKED] ${result.reason}`,
125
+ };
126
+ }
127
+ if (result.action === "modify" && result.modifiedArguments) {
128
+ return { modifiedArgs: result.modifiedArguments };
129
+ }
130
+ },
131
+ onPostToolUse: async (input, invocation) => {
132
+ const p = getHookPipeline();
133
+ if (!p)
134
+ return;
135
+ const args = normalizeToolArgs((input.toolArgs ?? {}));
136
+ const originalText = input.toolResult.textResultForLlm;
137
+ const postResult = await p.runPostToolHooks({
138
+ toolName: input.toolName,
139
+ arguments: args,
140
+ result: originalText,
141
+ agentName,
142
+ sessionId: invocation.sessionId,
143
+ });
144
+ const scrubbed = postResult.result;
145
+ if (typeof scrubbed === "string" && scrubbed !== originalText) {
146
+ log.info({ tool: input.toolName, agent: agentName }, "PII scrubbed from tool output");
147
+ return {
148
+ modifiedResult: {
149
+ ...input.toolResult,
150
+ textResultForLlm: scrubbed,
151
+ },
152
+ };
153
+ }
154
+ },
155
+ };
156
+ }
157
+ //# sourceMappingURL=hooks.js.map