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.
- package/dist/api/ralph.js +153 -0
- package/dist/api/ralph.test.js +101 -0
- package/dist/api/server.js +76 -1
- package/dist/copilot/agents.js +6 -1
- package/dist/copilot/hooks.js +157 -0
- package/dist/copilot/hooks.test.js +315 -0
- package/dist/copilot/orchestrator.js +61 -26
- package/dist/copilot/session-manager.js +3 -0
- package/dist/copilot/session-manager.test.js +70 -0
- package/dist/copilot/squad-event-bus.js +27 -0
- package/dist/copilot/tools.js +2 -0
- package/dist/daemon.js +9 -0
- package/dist/squad/charter.js +18 -1
- package/dist/squad/discovery.js +31 -43
- package/package.json +1 -1
- package/web/dist/assets/{index-Dp72-ITT.js → index-0dDxvEWK.js} +82 -79
- package/web/dist/assets/index-0dDxvEWK.js.map +1 -0
- package/web/dist/assets/index-26ooi9MH.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-C6ZKr0jC.css +0 -10
- package/web/dist/assets/index-Dp72-ITT.js.map +0 -1
|
@@ -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
|
package/dist/api/server.js
CHANGED
|
@@ -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
|
|
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
|
});
|
package/dist/copilot/agents.js
CHANGED
|
@@ -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
|