chapterhouse 0.5.2 → 0.7.0
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/.pr-types.json +14 -0
- package/README.md +6 -0
- package/dist/api/agent-edit-access.js +11 -0
- package/dist/api/agents.api.test.js +48 -0
- package/dist/api/server.js +182 -11
- package/dist/api/server.test.js +334 -3
- package/dist/config.test.js +29 -0
- package/dist/copilot/agent-event-bus.js +1 -0
- package/dist/copilot/agents.js +114 -46
- package/dist/copilot/agents.mcp-servers.test.js +87 -0
- package/dist/copilot/agents.parse.test.js +69 -0
- package/dist/copilot/agents.test.js +125 -1
- package/dist/copilot/memory-coordinator.js +234 -0
- package/dist/copilot/memory-coordinator.test.js +257 -0
- package/dist/copilot/orchestrator.js +81 -221
- package/dist/copilot/orchestrator.test.js +238 -1
- package/dist/copilot/pr-title.js +92 -0
- package/dist/copilot/pr-title.test.js +54 -0
- package/dist/copilot/router.test.js +30 -0
- package/dist/copilot/session-manager.js +34 -0
- package/dist/copilot/threat-model.js +50 -0
- package/dist/copilot/threat-model.test.js +129 -0
- package/dist/copilot/tools.js +61 -37
- package/dist/copilot/tools.wiki.test.js +15 -6
- package/dist/setup.js +15 -5
- package/dist/setup.test.js +20 -3
- package/dist/sprint-merge.js +168 -0
- package/dist/sprint-merge.test.js +131 -0
- package/dist/store/db.js +63 -0
- package/dist/store/db.test.js +279 -0
- package/dist/test/setup-env.js +2 -1
- package/dist/test/setup-env.test.js +8 -1
- package/package.json +8 -1
- package/web/dist/assets/index-DuKYxMIR.css +10 -0
- package/web/dist/assets/index-DytB69KC.js +223 -0
- package/web/dist/assets/index-DytB69KC.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CPaILy2j.js +0 -223
- package/web/dist/assets/index-CPaILy2j.js.map +0 -1
- package/web/dist/assets/index-Cs7AGeaL.css +0 -10
package/.pr-types.json
ADDED
package/README.md
CHANGED
|
@@ -91,6 +91,12 @@ cd ~/.chapterhouse/src
|
|
|
91
91
|
npm install && npm run build && npm link
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
If you're opening a sprint PR from the repo, validate the title first:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npm run pr:title:check -- "chore: add PR title preflight validation"
|
|
98
|
+
```
|
|
99
|
+
|
|
94
100
|
## Upgrading
|
|
95
101
|
|
|
96
102
|
```bash
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ForbiddenError } from "./errors.js";
|
|
2
|
+
export function assertAgentEditAccess(options, user) {
|
|
3
|
+
if (!options.entraAuthEnabled) {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
if (user?.role === "team-lead") {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
throw new ForbiddenError("Admin access required");
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=agent-edit-access.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createAgentFile, isBuiltinAgent, parseAgentMd } from "../copilot/agents.js";
|
|
4
|
+
test("isBuiltinAgent returns true for shipped agent slugs", () => {
|
|
5
|
+
assert.equal(isBuiltinAgent("chapterhouse"), true);
|
|
6
|
+
assert.equal(isBuiltinAgent("coder"), true);
|
|
7
|
+
assert.equal(isBuiltinAgent("designer"), true);
|
|
8
|
+
assert.equal(isBuiltinAgent("general-purpose"), true);
|
|
9
|
+
assert.equal(isBuiltinAgent("custom-helper"), false);
|
|
10
|
+
});
|
|
11
|
+
test("parseAgentMd parses block-style YAML arrays", () => {
|
|
12
|
+
const agent = parseAgentMd([
|
|
13
|
+
"---",
|
|
14
|
+
"name: Designer",
|
|
15
|
+
"description: Handles UI flows",
|
|
16
|
+
"model: claude-sonnet-4.6",
|
|
17
|
+
"skills:",
|
|
18
|
+
" - frontend-design",
|
|
19
|
+
" - ux-copy",
|
|
20
|
+
"tools:",
|
|
21
|
+
" - read",
|
|
22
|
+
" - write",
|
|
23
|
+
"---",
|
|
24
|
+
"",
|
|
25
|
+
"You are Designer.",
|
|
26
|
+
].join("\n"), "designer");
|
|
27
|
+
assert.ok(agent, "agent charter should parse");
|
|
28
|
+
assert.deepEqual(agent.skills, ["frontend-design", "ux-copy"]);
|
|
29
|
+
assert.deepEqual(agent.tools, ["read", "write"]);
|
|
30
|
+
});
|
|
31
|
+
test("slug validation regex rejects traversal-like agent slugs", () => {
|
|
32
|
+
const error = createAgentFile("..%2F..%2F", "Traversal", "Should fail validation", "claude-sonnet-4.6", "You are not valid.");
|
|
33
|
+
assert.equal(error, "Invalid slug '..%2F..%2F': must be kebab-case (a-z0-9 with hyphens).");
|
|
34
|
+
});
|
|
35
|
+
test("parseAgentMd returns null for invalid frontmatter", () => {
|
|
36
|
+
const agent = parseAgentMd([
|
|
37
|
+
"---",
|
|
38
|
+
"name: Designer",
|
|
39
|
+
"description: Handles UI flows",
|
|
40
|
+
"model: claude-sonnet-4.6",
|
|
41
|
+
"skills: [frontend-design",
|
|
42
|
+
"---",
|
|
43
|
+
"",
|
|
44
|
+
"You are Designer.",
|
|
45
|
+
].join("\n"), "designer");
|
|
46
|
+
assert.equal(agent, null);
|
|
47
|
+
});
|
|
48
|
+
//# sourceMappingURL=agents.api.test.js.map
|
package/dist/api/server.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import cors from "cors";
|
|
2
2
|
import express from "express";
|
|
3
3
|
import helmet from "helmet";
|
|
4
|
-
import { existsSync } from "fs";
|
|
5
|
-
import { join, dirname } from "path";
|
|
4
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
5
|
+
import { join, dirname, resolve, sep } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse,
|
|
8
|
+
import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, cancelCurrentMessage, interruptSessionTurn, getLastRouteResult, getCurrentSessionKey, getPersistentAgentSessionState, reloadPersistentAgent } from "../copilot/orchestrator.js";
|
|
9
9
|
import { agentEventBus } from "../copilot/agent-event-bus.js";
|
|
10
|
-
import { ensureDefaultAgents, getAgentRegistry, loadAgents } from "../copilot/agents.js";
|
|
10
|
+
import { ensureDefaultAgents, getAgent, getAgentRegistry, isBuiltinAgent, loadAgents, notifyAgentSaved, parseAgentMd, parseAgentMdOrThrow, serializeAgentMd, setAgentSaveRuntimeHooks, SLUG_REGEX, } from "../copilot/agents.js";
|
|
11
11
|
import { config, persistModel } from "../config.js";
|
|
12
12
|
import { ModeContext } from "../mode-context.js";
|
|
13
13
|
import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
|
|
14
14
|
import { searchIndex, parseIndex } from "../wiki/index-manager.js";
|
|
15
15
|
import { createAuthMiddleware, getBootstrapAuthResponse } from "./auth.js";
|
|
16
|
+
import { assertAgentEditAccess } from "./agent-edit-access.js";
|
|
16
17
|
import { createConcurrentConnectionLimiter, createFixedWindowRateLimiter } from "./rate-limit.js";
|
|
17
18
|
import { createTeamRouter } from "./team.js";
|
|
18
19
|
import { writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, } from "../wiki/fs.js";
|
|
@@ -23,7 +24,7 @@ import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
|
|
|
23
24
|
import { withWikiWrite } from "../wiki/lock.js";
|
|
24
25
|
import { listSkills, removeSkill } from "../copilot/skills.js";
|
|
25
26
|
import { restartDaemon } from "../daemon.js";
|
|
26
|
-
import { API_TOKEN_PATH } from "../paths.js";
|
|
27
|
+
import { AGENTS_DIR, API_TOKEN_PATH } from "../paths.js";
|
|
27
28
|
import { getCurrentRunId, getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
|
|
28
29
|
import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
|
|
29
30
|
import { subscribeSession, getSessionEventsFromDb, getSessionMaxSeqFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
|
|
@@ -78,6 +79,22 @@ const autoRequestSchema = z.object({
|
|
|
78
79
|
const wikiWriteSchema = z.object({
|
|
79
80
|
content: z.string({ error: "Missing 'content' string in request body" }),
|
|
80
81
|
}).strict();
|
|
82
|
+
const agentPatchSchema = z.object({
|
|
83
|
+
name: requiredString("name must be a non-empty string").optional(),
|
|
84
|
+
description: requiredString("description must be a non-empty string").optional(),
|
|
85
|
+
model: requiredString("model must be a non-empty string").optional(),
|
|
86
|
+
systemPrompt: z.string().optional(),
|
|
87
|
+
}).passthrough().superRefine((value, ctx) => {
|
|
88
|
+
for (const key of ["slug", "scope", "tools", "skills"]) {
|
|
89
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
90
|
+
ctx.addIssue({
|
|
91
|
+
code: "custom",
|
|
92
|
+
path: [key],
|
|
93
|
+
message: `'${key}' is read-only`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
81
98
|
const projectCreateSchema = z.object({
|
|
82
99
|
slug: requiredString("Missing 'slug' in request body")
|
|
83
100
|
.regex(/^[a-z0-9][a-z0-9-]*$/, "Project slug must be a lowercase slug"),
|
|
@@ -284,6 +301,18 @@ Create your first page via the wiki UI or by editing files under \`pages/\`.
|
|
|
284
301
|
const sseClients = new Map();
|
|
285
302
|
const pendingSseMessages = [];
|
|
286
303
|
let connectionCounter = 0;
|
|
304
|
+
function broadcastSsePayload(payload) {
|
|
305
|
+
for (const [, res] of sseClients) {
|
|
306
|
+
res.write(formatSseData(payload));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
setAgentSaveRuntimeHooks({
|
|
310
|
+
getPersistentSessionState: getPersistentAgentSessionState,
|
|
311
|
+
reloadPersistentSession: reloadPersistentAgent,
|
|
312
|
+
emitAgentReloadEvent: (event) => {
|
|
313
|
+
broadcastSsePayload(event);
|
|
314
|
+
},
|
|
315
|
+
});
|
|
287
316
|
// ---------------------------------------------------------------------------
|
|
288
317
|
// Bootstrap — hands the API token to the same-origin SPA on first load.
|
|
289
318
|
// Loopback-only by IP / Origin check.
|
|
@@ -316,18 +345,145 @@ const handleHealth = (_req, res) => {
|
|
|
316
345
|
};
|
|
317
346
|
app.get("/status", handleHealth);
|
|
318
347
|
app.get("/health", handleHealth);
|
|
348
|
+
function getLoadedAgents() {
|
|
349
|
+
let agents = getAgentRegistry();
|
|
350
|
+
if (agents.length === 0) {
|
|
351
|
+
ensureDefaultAgents();
|
|
352
|
+
agents = loadAgents();
|
|
353
|
+
}
|
|
354
|
+
return agents;
|
|
355
|
+
}
|
|
356
|
+
function getAgentLastEdited(slug) {
|
|
357
|
+
try {
|
|
358
|
+
return statSync(join(AGENTS_DIR, `${slug}.agent.md`)).mtime.toISOString();
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
319
364
|
// ---------------------------------------------------------------------------
|
|
320
365
|
// Workers / agents
|
|
321
366
|
// ---------------------------------------------------------------------------
|
|
322
367
|
app.get("/api/agents", (_req, res) => {
|
|
323
|
-
|
|
368
|
+
const agents = getLoadedAgents()
|
|
369
|
+
.map((agent) => ({
|
|
370
|
+
name: agent.name,
|
|
371
|
+
slug: agent.slug,
|
|
372
|
+
description: agent.description,
|
|
373
|
+
model: agent.model,
|
|
374
|
+
scope: agent.scope ?? null,
|
|
375
|
+
type: isBuiltinAgent(agent.slug) ? "builtin" : "custom",
|
|
376
|
+
lastEdited: getAgentLastEdited(agent.slug),
|
|
377
|
+
}))
|
|
378
|
+
.sort((left, right) => {
|
|
379
|
+
if (left.type !== right.type) {
|
|
380
|
+
return left.type === "builtin" ? -1 : 1;
|
|
381
|
+
}
|
|
382
|
+
return left.name.localeCompare(right.name);
|
|
383
|
+
});
|
|
384
|
+
res.json(agents);
|
|
324
385
|
});
|
|
325
|
-
app.get("/api/
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
386
|
+
app.get("/api/agents/:slug", (req, res, next) => {
|
|
387
|
+
const slugParam = req.params.slug;
|
|
388
|
+
const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam;
|
|
389
|
+
if (slug === "stream") {
|
|
390
|
+
next();
|
|
391
|
+
return;
|
|
330
392
|
}
|
|
393
|
+
if (!SLUG_REGEX.test(slug)) {
|
|
394
|
+
res.status(400).json({ error: "Invalid slug" });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
getLoadedAgents();
|
|
398
|
+
const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
|
|
399
|
+
const resolvedAgentsDir = resolve(AGENTS_DIR);
|
|
400
|
+
const resolvedFilePath = resolve(filePath);
|
|
401
|
+
if (!(resolvedFilePath === resolvedAgentsDir || resolvedFilePath.startsWith(`${resolvedAgentsDir}${sep}`))) {
|
|
402
|
+
res.status(403).json({ error: "Access denied" });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
let content;
|
|
406
|
+
try {
|
|
407
|
+
content = readFileSync(filePath, "utf-8");
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
res.status(404).json({ error: `Agent '${slug}' not found` });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const agent = parseAgentMd(content, slug);
|
|
414
|
+
if (!agent) {
|
|
415
|
+
res.status(500).json({ error: `Agent '${slug}' could not be parsed` });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const builtin = isBuiltinAgent(slug);
|
|
419
|
+
res.json({
|
|
420
|
+
name: agent.name,
|
|
421
|
+
slug: agent.slug,
|
|
422
|
+
description: agent.description,
|
|
423
|
+
model: agent.model,
|
|
424
|
+
scope: agent.scope ?? null,
|
|
425
|
+
persistent: agent.persistent ?? false,
|
|
426
|
+
skills: agent.skills ?? [],
|
|
427
|
+
type: builtin ? "builtin" : "custom",
|
|
428
|
+
editable: !builtin,
|
|
429
|
+
systemPrompt: agent.systemMessage,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
app.patch("/api/agents/:slug", async (req, res) => {
|
|
433
|
+
const slugParam = req.params.slug;
|
|
434
|
+
const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam;
|
|
435
|
+
if (!SLUG_REGEX.test(slug)) {
|
|
436
|
+
throw new BadRequestError("Invalid slug");
|
|
437
|
+
}
|
|
438
|
+
if (modeContext.isTeam() && req.user?.role !== "team-lead") {
|
|
439
|
+
throw new ForbiddenError("Forbidden");
|
|
440
|
+
}
|
|
441
|
+
const patch = parseRequest(agentPatchSchema, req.body ?? {});
|
|
442
|
+
const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
|
|
443
|
+
const resolvedAgentsDir = resolve(AGENTS_DIR);
|
|
444
|
+
const resolvedFilePath = resolve(filePath);
|
|
445
|
+
if (!(resolvedFilePath === resolvedAgentsDir || resolvedFilePath.startsWith(`${resolvedAgentsDir}${sep}`))) {
|
|
446
|
+
throw new ForbiddenError("Access denied");
|
|
447
|
+
}
|
|
448
|
+
assertAgentEditAccess({ entraAuthEnabled: config.entraAuthEnabled }, req.user);
|
|
449
|
+
if (isBuiltinAgent(slug)) {
|
|
450
|
+
throw new ForbiddenError("Built-in agents are read-only");
|
|
451
|
+
}
|
|
452
|
+
if (!existsSync(filePath)) {
|
|
453
|
+
throw new NotFoundError("Agent not found");
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
const current = parseAgentMdOrThrow(readFileSync(filePath, "utf-8"), slug);
|
|
457
|
+
const updated = {
|
|
458
|
+
...current,
|
|
459
|
+
name: patch.name ?? current.name,
|
|
460
|
+
description: patch.description ?? current.description,
|
|
461
|
+
model: patch.model ?? current.model,
|
|
462
|
+
systemMessage: patch.systemPrompt ?? current.systemMessage,
|
|
463
|
+
};
|
|
464
|
+
const nextContent = serializeAgentMd(updated);
|
|
465
|
+
writeFileSync(filePath, nextContent, "utf-8");
|
|
466
|
+
await notifyAgentSaved(slug, updated);
|
|
467
|
+
res.json({
|
|
468
|
+
name: updated.name,
|
|
469
|
+
slug: updated.slug,
|
|
470
|
+
description: updated.description,
|
|
471
|
+
model: updated.model,
|
|
472
|
+
scope: updated.scope ?? null,
|
|
473
|
+
persistent: updated.persistent ?? false,
|
|
474
|
+
skills: updated.skills ?? [],
|
|
475
|
+
type: "custom",
|
|
476
|
+
editable: true,
|
|
477
|
+
systemPrompt: updated.systemMessage,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
482
|
+
throw new BadRequestError(`Invalid content: ${message}`);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
app.get("/api/channels", (_req, res) => {
|
|
486
|
+
const agents = getLoadedAgents();
|
|
331
487
|
const persistentAgentChannels = agents
|
|
332
488
|
.filter((agent) => agent.persistent)
|
|
333
489
|
.map((agent) => ({
|
|
@@ -650,6 +806,21 @@ app.post("/api/cancel", async (_req, res) => {
|
|
|
650
806
|
}
|
|
651
807
|
res.json({ status: "ok", cancelled });
|
|
652
808
|
});
|
|
809
|
+
app.post("/api/agents/:slug/reload-confirm", async (req, res) => {
|
|
810
|
+
const slugParam = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
|
|
811
|
+
const slug = slugParam?.trim() || "";
|
|
812
|
+
const agent = getAgent(slug);
|
|
813
|
+
if (!agent?.persistent) {
|
|
814
|
+
throw new NotFoundError("Agent not found");
|
|
815
|
+
}
|
|
816
|
+
const sessionKey = `agent:${agent.slug}`;
|
|
817
|
+
await interruptSessionTurn(sessionKey);
|
|
818
|
+
const reloadResult = await reloadPersistentAgent(agent.slug);
|
|
819
|
+
if (reloadResult === "reloaded") {
|
|
820
|
+
broadcastSsePayload({ type: "agent_reloaded", slug: agent.slug, reason: "confirmed_restart" });
|
|
821
|
+
}
|
|
822
|
+
res.json({ status: "ok" });
|
|
823
|
+
});
|
|
653
824
|
// Cancel the active turn for one session key without touching other channels.
|
|
654
825
|
app.post("/api/session/:sessionKey/interrupt", async (req, res) => {
|
|
655
826
|
const sessionKey = Array.isArray(req.params.sessionKey)
|