chapterhouse 0.9.2 → 0.11.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 (121) hide show
  1. package/README.md +1 -1
  2. package/dist/api/auth.js +11 -1
  3. package/dist/api/auth.test.js +29 -0
  4. package/dist/api/errors.js +23 -0
  5. package/dist/api/route-coverage.test.js +61 -21
  6. package/dist/api/routes/agents.js +472 -0
  7. package/dist/api/routes/memory.js +299 -0
  8. package/dist/api/routes/projects.js +170 -0
  9. package/dist/api/routes/sessions.js +347 -0
  10. package/dist/api/routes/system.js +82 -0
  11. package/dist/api/routes/wiki.js +455 -0
  12. package/dist/api/routes/wiki.test.js +49 -0
  13. package/dist/api/send-json.js +16 -0
  14. package/dist/api/send-json.test.js +18 -0
  15. package/dist/api/server-runtime.js +45 -3
  16. package/dist/api/server.js +34 -1764
  17. package/dist/api/server.test.js +239 -8
  18. package/dist/api/sse-hub.js +37 -0
  19. package/dist/cli.js +1 -1
  20. package/dist/config.js +151 -58
  21. package/dist/config.test.js +29 -0
  22. package/dist/copilot/okr-mapper.js +2 -11
  23. package/dist/copilot/orchestrator.js +358 -352
  24. package/dist/copilot/orchestrator.test.js +139 -4
  25. package/dist/copilot/prompt-date.js +2 -1
  26. package/dist/copilot/session-manager.js +25 -23
  27. package/dist/copilot/session-manager.test.js +35 -1
  28. package/dist/copilot/standup.js +2 -2
  29. package/dist/copilot/task-event-log.js +7 -1
  30. package/dist/copilot/task-event-log.test.js +13 -0
  31. package/dist/copilot/tools/agent.js +608 -0
  32. package/dist/copilot/tools/index.js +19 -0
  33. package/dist/copilot/tools/memory.js +678 -0
  34. package/dist/copilot/tools/models.js +2 -0
  35. package/dist/copilot/tools/okr.js +171 -0
  36. package/dist/copilot/tools/wiki.js +333 -0
  37. package/dist/copilot/tools-deps.js +4 -0
  38. package/dist/copilot/tools.agent.test.js +10 -8
  39. package/dist/copilot/tools.inventory.test.js +76 -0
  40. package/dist/copilot/tools.js +1 -1780
  41. package/dist/copilot/tools.okr.test.js +31 -0
  42. package/dist/copilot/tools.wiki.test.js +6 -3
  43. package/dist/copilot/turn-event-log.js +31 -4
  44. package/dist/copilot/turn-event-log.test.js +24 -2
  45. package/dist/copilot/workiq-installer.test.js +2 -2
  46. package/dist/daemon-install.js +3 -2
  47. package/dist/daemon.js +9 -17
  48. package/dist/integrations/ado-client.js +90 -9
  49. package/dist/integrations/ado-client.test.js +56 -0
  50. package/dist/integrations/team-push.js +1 -0
  51. package/dist/integrations/team-push.test.js +6 -0
  52. package/dist/integrations/teams-notify.js +1 -0
  53. package/dist/integrations/teams-notify.test.js +5 -0
  54. package/dist/memory/active-scope.test.js +0 -1
  55. package/dist/memory/checkpoint.js +89 -72
  56. package/dist/memory/checkpoint.test.js +23 -3
  57. package/dist/memory/eot.js +87 -85
  58. package/dist/memory/eot.test.js +71 -3
  59. package/dist/memory/hooks.js +2 -4
  60. package/dist/memory/housekeeping-scheduler.js +1 -1
  61. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  62. package/dist/memory/housekeeping.js +100 -3
  63. package/dist/memory/housekeeping.test.js +33 -2
  64. package/dist/memory/reflect.test.js +2 -0
  65. package/dist/memory/scope-lock.js +26 -0
  66. package/dist/memory/scope-lock.test.js +118 -0
  67. package/dist/memory/scopes.test.js +0 -1
  68. package/dist/mode-context.js +58 -5
  69. package/dist/mode-context.test.js +68 -0
  70. package/dist/paths.js +1 -0
  71. package/dist/setup.js +3 -2
  72. package/dist/shared/api-schemas.js +48 -5
  73. package/dist/store/connection.js +96 -0
  74. package/dist/store/db.js +5 -1498
  75. package/dist/store/db.test.js +182 -1
  76. package/dist/store/migrations.js +460 -0
  77. package/dist/store/repositories/memory.js +281 -0
  78. package/dist/store/repositories/okr.js +3 -0
  79. package/dist/store/repositories/projects.js +5 -0
  80. package/dist/store/repositories/sessions.js +284 -0
  81. package/dist/store/repositories/wiki.js +60 -0
  82. package/dist/store/schema.js +501 -0
  83. package/dist/util/logger.js +3 -2
  84. package/dist/wiki/consolidation.js +50 -9
  85. package/dist/wiki/consolidation.test.js +45 -0
  86. package/dist/wiki/frontmatter.js +43 -13
  87. package/dist/wiki/frontmatter.test.js +24 -0
  88. package/dist/wiki/fs.js +16 -4
  89. package/dist/wiki/fs.test.js +84 -0
  90. package/dist/wiki/index-manager.js +30 -2
  91. package/dist/wiki/index-manager.test.js +43 -12
  92. package/dist/wiki/ingest.js +1 -1
  93. package/dist/wiki/lock.js +11 -1
  94. package/dist/wiki/log-manager.js +2 -7
  95. package/dist/wiki/migrate.js +44 -17
  96. package/dist/wiki/project-registry.js +10 -5
  97. package/dist/wiki/project-registry.test.js +14 -0
  98. package/dist/wiki/scheduler.js +1 -1
  99. package/dist/wiki/seed-team-wiki.js +2 -1
  100. package/dist/wiki/team-sync.js +31 -6
  101. package/dist/wiki/team-sync.test.js +81 -0
  102. package/package.json +1 -1
  103. package/web/dist/assets/WikiEdit-EBVoY1Pk.js +30 -0
  104. package/web/dist/assets/WikiEdit-EBVoY1Pk.js.map +1 -0
  105. package/web/dist/assets/WikiGraph-BUbbABq-.js +2 -0
  106. package/web/dist/assets/WikiGraph-BUbbABq-.js.map +1 -0
  107. package/web/dist/assets/icon-acolyte-cream.svg +10 -0
  108. package/web/dist/assets/icon-acolyte-dark.svg +10 -0
  109. package/web/dist/assets/icon-acolyte-gold.svg +10 -0
  110. package/web/dist/assets/icon-acolyte-ibad.svg +10 -0
  111. package/web/dist/assets/icon-acolyte-lit.svg +10 -0
  112. package/web/dist/assets/icon-acolyte-mono.svg +10 -0
  113. package/web/dist/assets/icon-acolyte.png +0 -0
  114. package/web/dist/assets/icon-acolyte.svg +10 -0
  115. package/web/dist/assets/index-BGLL9pgM.css +10 -0
  116. package/web/dist/assets/index-KFX8UmOb.js +250 -0
  117. package/web/dist/assets/index-KFX8UmOb.js.map +1 -0
  118. package/web/dist/index.html +6 -4
  119. package/web/dist/assets/index-5kz9aRU9.css +0 -10
  120. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  121. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
package/README.md CHANGED
@@ -18,7 +18,7 @@ See [CHANGELOG.md](CHANGELOG.md) for recent changes and feature history.
18
18
 
19
19
  ## Installation
20
20
 
21
- **Requires Node.js 24 or later** (npm ≥ 11.5.1 for Trusted Publishing support).
21
+ **Requires Node.js 22.5 or later**.
22
22
 
23
23
  Install globally via npm:
24
24
 
package/dist/api/auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { timingSafeEqual } from "crypto";
1
2
  import jwt from "jsonwebtoken";
2
3
  import jwksClient from "jwks-rsa";
3
4
  import { childLogger } from "../util/logger.js";
@@ -39,6 +40,15 @@ function buildAuthenticatedUser(claims, teamLeadId) {
39
40
  function unauthorized(res, message) {
40
41
  res.status(401).json({ error: message });
41
42
  }
43
+ export function timingSafeTokenEqual(provided, expected) {
44
+ if (!provided || !expected)
45
+ return false;
46
+ const a = Buffer.from(provided);
47
+ const b = Buffer.from(expected);
48
+ if (a.length !== b.length)
49
+ return false;
50
+ return timingSafeEqual(a, b);
51
+ }
42
52
  // Module-level cache of JWKS clients keyed by tenant ID.
43
53
  // Creating a new client per request discards the in-process key cache; keeping
44
54
  // one client per tenant lets jwks-rsa honour its cacheMaxAge and avoids an
@@ -124,7 +134,7 @@ export function createAuthMiddleware(options) {
124
134
  }
125
135
  if (!options.config.entraAuthEnabled) {
126
136
  const token = getBearerToken(req);
127
- if (!options.apiToken || token !== options.apiToken) {
137
+ if (!timingSafeTokenEqual(token, options.apiToken)) {
128
138
  unauthorized(res, "Unauthorized");
129
139
  return;
130
140
  }
@@ -448,4 +448,33 @@ test("JWKS client is reused across calls for the same tenant (module-level cache
448
448
  assert.doesNotMatch(String(err2.message), /ENTRA_TENANT_ID or ENTRA_CLIENT_ID is missing/, "second error must come from JWT processing — client reused from module cache");
449
449
  auth._resetJwksClientCache();
450
450
  });
451
+ // ---------------------------------------------------------------------------
452
+ // timingSafeTokenEqual unit tests
453
+ // ---------------------------------------------------------------------------
454
+ test("timingSafeTokenEqual returns true for matching tokens", async () => {
455
+ const auth = await loadAuthModule();
456
+ assert.ok(auth, "auth module should exist");
457
+ assert.equal(typeof auth.timingSafeTokenEqual, "function", "timingSafeTokenEqual should be exported");
458
+ assert.equal(auth.timingSafeTokenEqual("secret-token", "secret-token"), true);
459
+ });
460
+ test("timingSafeTokenEqual returns false for wrong token", async () => {
461
+ const auth = await loadAuthModule();
462
+ assert.ok(auth, "auth module should exist");
463
+ assert.equal(auth.timingSafeTokenEqual("wrong-token", "secret-token"), false);
464
+ });
465
+ test("timingSafeTokenEqual returns false when provided token is null", async () => {
466
+ const auth = await loadAuthModule();
467
+ assert.ok(auth, "auth module should exist");
468
+ assert.equal(auth.timingSafeTokenEqual(null, "secret-token"), false);
469
+ });
470
+ test("timingSafeTokenEqual returns false when expected token is null", async () => {
471
+ const auth = await loadAuthModule();
472
+ assert.ok(auth, "auth module should exist");
473
+ assert.equal(auth.timingSafeTokenEqual("secret-token", null), false);
474
+ });
475
+ test("timingSafeTokenEqual returns false for different-length tokens", async () => {
476
+ const auth = await loadAuthModule();
477
+ assert.ok(auth, "auth module should exist");
478
+ assert.equal(auth.timingSafeTokenEqual("short", "much-longer-token"), false);
479
+ });
451
480
  //# sourceMappingURL=auth.test.js.map
@@ -46,6 +46,19 @@ function isBodyParserSyntaxError(error) {
46
46
  function formatZodError(error) {
47
47
  return error.issues[0]?.message ?? "Invalid request";
48
48
  }
49
+ function getStatusCodeError(error) {
50
+ if (!(error instanceof Error))
51
+ return undefined;
52
+ const candidate = error;
53
+ if (typeof candidate.statusCode !== "number" || candidate.statusCode < 400 || candidate.statusCode > 599) {
54
+ return undefined;
55
+ }
56
+ return {
57
+ statusCode: candidate.statusCode,
58
+ message: error.message,
59
+ expose: candidate.expose === true || candidate.statusCode < 500,
60
+ };
61
+ }
49
62
  export function parseRequest(schema, input) {
50
63
  const parsed = schema.safeParse(input);
51
64
  if (!parsed.success) {
@@ -90,6 +103,16 @@ export function createApiErrorHandler() {
90
103
  res.status(error.statusCode).json({ error: error.expose ? error.message : "Internal server error" });
91
104
  return;
92
105
  }
106
+ const statusCodeError = getStatusCodeError(error);
107
+ if (statusCodeError) {
108
+ if (statusCodeError.statusCode >= 500) {
109
+ log.error({ method: req.method, url: req.originalUrl, err: statusCodeError.message }, "API request failed");
110
+ }
111
+ res
112
+ .status(statusCodeError.statusCode)
113
+ .json({ error: statusCodeError.expose ? statusCodeError.message : "Internal server error" });
114
+ return;
115
+ }
93
116
  log.error({ method: req.method, url: req.originalUrl, err: error instanceof Error ? error.message : error }, "API request failed (unhandled)");
94
117
  res.status(500).json({ error: "Internal server error" });
95
118
  };
@@ -17,13 +17,14 @@
17
17
  // ──────────────────────────────────────────────────────────────────────────────
18
18
  import { describe, test } from "node:test";
19
19
  import assert from "node:assert/strict";
20
- import { readFileSync } from "node:fs";
20
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
24
24
  const ROOT = join(__dirname, "..", "..");
25
25
  const FRONTEND_API_PATH = join(ROOT, "web", "src", "api.ts");
26
26
  const SERVER_TS_PATH = join(ROOT, "src", "api", "server.ts");
27
+ const ROUTES_DIR = join(ROOT, "src", "api", "routes");
27
28
  const WEB_SCHEMAS_PATH = join(ROOT, "src", "shared", "api-schemas.ts");
28
29
  /**
29
30
  * Normalise a URL path for comparison:
@@ -59,20 +60,42 @@ function extractFrontendPaths(src) {
59
60
  return paths;
60
61
  }
61
62
  /**
62
- * Extract all unique normalised API paths registered via app.METHOD() in
63
- * server.ts. Only /api/ prefixed paths are considered.
63
+ * Read server.ts plus extracted route modules so static route coverage stays
64
+ * effective after handlers move behind Express Router instances.
64
65
  */
65
- function extractServerPaths(src) {
66
- const re = /app\.(get|post|put|delete|patch)\(["'`](\/api\/[^"'`?]+)/g;
66
+ function readServerRouteSources() {
67
+ const sources = [readFileSync(SERVER_TS_PATH, "utf8")];
68
+ if (!existsSync(ROUTES_DIR)) {
69
+ return sources;
70
+ }
71
+ const routeFiles = readdirSync(ROUTES_DIR)
72
+ .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
73
+ .sort();
74
+ for (const file of routeFiles) {
75
+ sources.push(readFileSync(join(ROUTES_DIR, file), "utf8"));
76
+ }
77
+ return sources;
78
+ }
79
+ /**
80
+ * Extract all unique normalised API paths registered via app.METHOD() or
81
+ * router.METHOD(). Only /api/ prefixed paths are considered.
82
+ */
83
+ function extractServerPaths(sources) {
84
+ const re = /\b(?:app|router)\.(get|post|put|delete|patch)\(["'`](\/api\/[^"'`?]+)/g;
67
85
  const paths = new Set();
68
- for (const m of src.matchAll(re)) {
69
- const normalised = normalizePath(m[2]);
70
- if (normalised) {
71
- paths.add(normalised);
86
+ for (const src of sources) {
87
+ for (const m of src.matchAll(re)) {
88
+ const normalised = normalizePath(m[2]);
89
+ if (normalised) {
90
+ paths.add(normalised);
91
+ }
72
92
  }
73
93
  }
74
94
  return paths;
75
95
  }
96
+ function readCombinedServerRouteSource() {
97
+ return readServerRouteSources().join("\n");
98
+ }
76
99
  /**
77
100
  * Extract SSE type literals emitted inline via formatSseData({ type: "X", ... })
78
101
  * in server.ts. Pass-through calls like formatSseData(event) are not detected.
@@ -128,9 +151,9 @@ function extractStreamEventSchemaTypes(src) {
128
151
  describe("route coverage — static analysis", () => {
129
152
  test("all frontend authedFetch paths have a matching server route registration", () => {
130
153
  const frontendSrc = readFileSync(FRONTEND_API_PATH, "utf8");
131
- const serverSrc = readFileSync(SERVER_TS_PATH, "utf8");
154
+ const serverSources = readServerRouteSources();
132
155
  const frontendPaths = extractFrontendPaths(frontendSrc);
133
- const serverPaths = extractServerPaths(serverSrc);
156
+ const serverPaths = extractServerPaths(serverSources);
134
157
  const missing = [];
135
158
  for (const fp of [...frontendPaths].sort()) {
136
159
  if (!serverPaths.has(fp)) {
@@ -139,20 +162,37 @@ describe("route coverage — static analysis", () => {
139
162
  }
140
163
  assert.ok(missing.length === 0, `Frontend calls ${missing.length} route(s) with no matching server registration:\n` +
141
164
  missing.map((p) => ` MISSING: ${p}`).join("\n") +
142
- "\n\nAdd the missing route(s) to src/api/server.ts");
165
+ "\n\nAdd the missing route(s) to src/api/server.ts or src/api/routes/*.ts");
166
+ });
167
+ test("server route coverage scans extracted route modules", () => {
168
+ assert.ok(existsSync(ROUTES_DIR), "Expected src/api/routes/ to exist so route coverage scans extracted routers.");
169
+ const routeFiles = readdirSync(ROUTES_DIR)
170
+ .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
171
+ .sort();
172
+ assert.deepEqual(routeFiles, ["agents.ts", "memory.ts", "projects.ts", "sessions.ts", "system.ts", "wiki.ts"]);
143
173
  });
144
174
  test("server routes cover every unique normalised path (no obvious duplicates leaked)", () => {
145
- const serverSrc = readFileSync(SERVER_TS_PATH, "utf8");
146
- const serverPaths = extractServerPaths(serverSrc);
175
+ const serverPaths = extractServerPaths(readServerRouteSources());
147
176
  // Sanity: the server must expose at least 30 /api/ routes.
148
177
  assert.ok(serverPaths.size >= 30, `Expected ≥30 server routes, found ${serverPaths.size}. Check extractServerPaths regex.`);
149
178
  });
150
179
  });
151
- describe("wiki write route safeguards — static analysis", () => {
152
- test("wiki update and pin routes declare authMiddleware explicitly", () => {
153
- const serverSrc = readFileSync(SERVER_TS_PATH, "utf8");
154
- assert.match(serverSrc, /app\.post\("\/api\/wiki\/update",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
155
- assert.match(serverSrc, /app\.post\("\/api\/wiki\/page\/pin",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
180
+ describe("shared API schema enforcement — static analysis", () => {
181
+ test("server route success responses go through sendJson schema validation", () => {
182
+ const serverSrc = readCombinedServerRouteSource();
183
+ assert.doesNotMatch(serverSrc, /\bres\.json\(/, "Do not send successful route responses with res.json() directly; use sendJson(res, SharedSchema, payload).");
184
+ });
185
+ });
186
+ describe("explicit auth route safeguards — static analysis", () => {
187
+ test("sensitive wiki and memory routes declare authMiddleware explicitly", () => {
188
+ const serverSrc = readCombinedServerRouteSource();
189
+ assert.match(serverSrc, /\b(?:app|router)\.post\("\/api\/wiki\/update",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
190
+ assert.match(serverSrc, /\b(?:app|router)\.post\("\/api\/wiki\/page\/pin",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
191
+ assert.match(serverSrc, /\b(?:app|router)\.get\("\/api\/wiki\/korg\/sessions",\s*authMiddleware,/);
192
+ assert.match(serverSrc, /\b(?:app|router)\.post\("\/api\/wiki\/ingest",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
193
+ assert.match(serverSrc, /\b(?:app|router)\.get\("\/api\/wiki\/search",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
194
+ assert.match(serverSrc, /\b(?:app|router)\.post\("\/api\/memory\/hooks\/git-commit",\s*authMiddleware,/);
195
+ assert.match(serverSrc, /\b(?:app|router)\.post\("\/api\/memory\/hooks\/pr-merge",\s*authMiddleware,/);
156
196
  });
157
197
  });
158
198
  describe("SSE exhaustiveness — static analysis", () => {
@@ -169,7 +209,7 @@ describe("SSE exhaustiveness — static analysis", () => {
169
209
  // type in StreamEventSchema, this test FAILS, preventing the regression.
170
210
  // ───────────────────────────────────────────────────────────────────────────
171
211
  test("formatSseEvent is not used with names that belong in the StreamEventSchema data channel", () => {
172
- const serverSrc = readFileSync(SERVER_TS_PATH, "utf8");
212
+ const serverSrc = readCombinedServerRouteSource();
173
213
  const schemaSrc = readFileSync(WEB_SCHEMAS_PATH, "utf8");
174
214
  const namedEventNames = extractSseEventNames(serverSrc);
175
215
  const schemaTypes = extractStreamEventSchemaTypes(schemaSrc);
@@ -184,7 +224,7 @@ describe("SSE exhaustiveness — static analysis", () => {
184
224
  "\n\nNamed SSE events are silently dropped by the frontend's default 'message' handler (issues #367/#368).");
185
225
  });
186
226
  test("all inline formatSseData type literals are known StreamEventSchema types or agent-stream-only types", () => {
187
- const serverSrc = readFileSync(SERVER_TS_PATH, "utf8");
227
+ const serverSrc = readCombinedServerRouteSource();
188
228
  const schemaSrc = readFileSync(WEB_SCHEMAS_PATH, "utf8");
189
229
  const emittedTypes = extractSseDataTypes(serverSrc);
190
230
  const schemaTypes = extractStreamEventSchemaTypes(schemaSrc);