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.
Files changed (40) hide show
  1. package/.pr-types.json +14 -0
  2. package/README.md +6 -0
  3. package/dist/api/agent-edit-access.js +11 -0
  4. package/dist/api/agents.api.test.js +48 -0
  5. package/dist/api/server.js +182 -11
  6. package/dist/api/server.test.js +334 -3
  7. package/dist/config.test.js +29 -0
  8. package/dist/copilot/agent-event-bus.js +1 -0
  9. package/dist/copilot/agents.js +114 -46
  10. package/dist/copilot/agents.mcp-servers.test.js +87 -0
  11. package/dist/copilot/agents.parse.test.js +69 -0
  12. package/dist/copilot/agents.test.js +125 -1
  13. package/dist/copilot/memory-coordinator.js +234 -0
  14. package/dist/copilot/memory-coordinator.test.js +257 -0
  15. package/dist/copilot/orchestrator.js +81 -221
  16. package/dist/copilot/orchestrator.test.js +238 -1
  17. package/dist/copilot/pr-title.js +92 -0
  18. package/dist/copilot/pr-title.test.js +54 -0
  19. package/dist/copilot/router.test.js +30 -0
  20. package/dist/copilot/session-manager.js +34 -0
  21. package/dist/copilot/threat-model.js +50 -0
  22. package/dist/copilot/threat-model.test.js +129 -0
  23. package/dist/copilot/tools.js +61 -37
  24. package/dist/copilot/tools.wiki.test.js +15 -6
  25. package/dist/setup.js +15 -5
  26. package/dist/setup.test.js +20 -3
  27. package/dist/sprint-merge.js +168 -0
  28. package/dist/sprint-merge.test.js +131 -0
  29. package/dist/store/db.js +63 -0
  30. package/dist/store/db.test.js +279 -0
  31. package/dist/test/setup-env.js +2 -1
  32. package/dist/test/setup-env.test.js +8 -1
  33. package/package.json +8 -1
  34. package/web/dist/assets/index-DuKYxMIR.css +10 -0
  35. package/web/dist/assets/index-DytB69KC.js +223 -0
  36. package/web/dist/assets/index-DytB69KC.js.map +1 -0
  37. package/web/dist/index.html +2 -2
  38. package/web/dist/assets/index-CPaILy2j.js +0 -223
  39. package/web/dist/assets/index-CPaILy2j.js.map +0 -1
  40. package/web/dist/assets/index-Cs7AGeaL.css +0 -10
package/.pr-types.json ADDED
@@ -0,0 +1,14 @@
1
+ [
2
+ "feat",
3
+ "fix",
4
+ "docs",
5
+ "style",
6
+ "refactor",
7
+ "perf",
8
+ "test",
9
+ "chore",
10
+ "build",
11
+ "ci",
12
+ "revert",
13
+ "release"
14
+ ]
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
@@ -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, getAgentInfo, cancelCurrentMessage, interruptSessionTurn, getLastRouteResult, getCurrentSessionKey } from "../copilot/orchestrator.js";
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
- res.json(getAgentInfo());
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/channels", (_req, res) => {
326
- let agents = getAgentRegistry();
327
- if (agents.length === 0) {
328
- ensureDefaultAgents();
329
- agents = loadAgents();
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)