chapterhouse 0.9.1 → 0.10.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 (112) hide show
  1. package/README.md +1 -1
  2. package/agents/korg.agent.md +20 -0
  3. package/dist/api/auth.js +11 -1
  4. package/dist/api/auth.test.js +29 -0
  5. package/dist/api/errors.js +23 -0
  6. package/dist/api/route-coverage.test.js +61 -21
  7. package/dist/api/routes/agents.js +472 -0
  8. package/dist/api/routes/memory.js +299 -0
  9. package/dist/api/routes/projects.js +170 -0
  10. package/dist/api/routes/sessions.js +347 -0
  11. package/dist/api/routes/system.js +82 -0
  12. package/dist/api/routes/wiki.js +455 -0
  13. package/dist/api/routes/wiki.test.js +49 -0
  14. package/dist/api/send-json.js +16 -0
  15. package/dist/api/send-json.test.js +18 -0
  16. package/dist/api/server-runtime.js +45 -3
  17. package/dist/api/server.js +34 -1764
  18. package/dist/api/server.test.js +239 -8
  19. package/dist/api/sse-hub.js +37 -0
  20. package/dist/cli.js +1 -1
  21. package/dist/config.js +151 -58
  22. package/dist/config.test.js +29 -0
  23. package/dist/copilot/okr-mapper.js +2 -11
  24. package/dist/copilot/orchestrator.js +358 -352
  25. package/dist/copilot/orchestrator.test.js +139 -4
  26. package/dist/copilot/prompt-date.js +2 -1
  27. package/dist/copilot/session-manager.js +25 -23
  28. package/dist/copilot/session-manager.test.js +35 -1
  29. package/dist/copilot/standup.js +2 -2
  30. package/dist/copilot/task-event-log.js +7 -1
  31. package/dist/copilot/task-event-log.test.js +13 -0
  32. package/dist/copilot/tools/agent.js +608 -0
  33. package/dist/copilot/tools/index.js +19 -0
  34. package/dist/copilot/tools/memory.js +678 -0
  35. package/dist/copilot/tools/models.js +2 -0
  36. package/dist/copilot/tools/okr.js +171 -0
  37. package/dist/copilot/tools/wiki.js +333 -0
  38. package/dist/copilot/tools-deps.js +4 -0
  39. package/dist/copilot/tools.agent.test.js +10 -8
  40. package/dist/copilot/tools.inventory.test.js +76 -0
  41. package/dist/copilot/tools.js +1 -1725
  42. package/dist/copilot/tools.okr.test.js +31 -0
  43. package/dist/copilot/tools.wiki.test.js +358 -6
  44. package/dist/copilot/turn-event-log.js +31 -4
  45. package/dist/copilot/turn-event-log.test.js +24 -2
  46. package/dist/copilot/workiq-installer.test.js +2 -2
  47. package/dist/daemon-install.js +3 -2
  48. package/dist/daemon.js +9 -17
  49. package/dist/integrations/ado-client.js +90 -9
  50. package/dist/integrations/ado-client.test.js +56 -0
  51. package/dist/integrations/team-push.js +1 -0
  52. package/dist/integrations/team-push.test.js +6 -0
  53. package/dist/integrations/teams-notify.js +1 -0
  54. package/dist/integrations/teams-notify.test.js +5 -0
  55. package/dist/memory/active-scope.test.js +0 -1
  56. package/dist/memory/checkpoint.js +89 -72
  57. package/dist/memory/checkpoint.test.js +23 -3
  58. package/dist/memory/eot.js +194 -89
  59. package/dist/memory/eot.test.js +186 -3
  60. package/dist/memory/hooks.js +2 -4
  61. package/dist/memory/housekeeping-scheduler.js +1 -1
  62. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  63. package/dist/memory/housekeeping.js +100 -3
  64. package/dist/memory/housekeeping.test.js +33 -2
  65. package/dist/memory/reflect.test.js +2 -0
  66. package/dist/memory/scope-lock.js +26 -0
  67. package/dist/memory/scope-lock.test.js +118 -0
  68. package/dist/memory/scopes.test.js +0 -1
  69. package/dist/mode-context.js +58 -5
  70. package/dist/mode-context.test.js +68 -0
  71. package/dist/paths.js +1 -0
  72. package/dist/setup.js +3 -2
  73. package/dist/shared/api-schemas.js +48 -5
  74. package/dist/store/connection.js +96 -0
  75. package/dist/store/db.js +5 -1498
  76. package/dist/store/db.test.js +182 -1
  77. package/dist/store/migrations.js +460 -0
  78. package/dist/store/repositories/memory.js +281 -0
  79. package/dist/store/repositories/okr.js +3 -0
  80. package/dist/store/repositories/projects.js +5 -0
  81. package/dist/store/repositories/sessions.js +284 -0
  82. package/dist/store/repositories/wiki.js +60 -0
  83. package/dist/store/schema.js +501 -0
  84. package/dist/util/logger.js +3 -2
  85. package/dist/wiki/consolidation.js +50 -9
  86. package/dist/wiki/consolidation.test.js +45 -0
  87. package/dist/wiki/frontmatter.js +45 -14
  88. package/dist/wiki/frontmatter.test.js +26 -1
  89. package/dist/wiki/fs.js +16 -4
  90. package/dist/wiki/fs.test.js +84 -0
  91. package/dist/wiki/index-manager.js +30 -2
  92. package/dist/wiki/index-manager.test.js +43 -12
  93. package/dist/wiki/ingest.js +17 -1
  94. package/dist/wiki/lock.js +11 -1
  95. package/dist/wiki/log-manager.js +2 -7
  96. package/dist/wiki/migrate.js +44 -17
  97. package/dist/wiki/project-registry.js +10 -5
  98. package/dist/wiki/project-registry.test.js +14 -0
  99. package/dist/wiki/scheduler.js +1 -1
  100. package/dist/wiki/seed-team-wiki.js +2 -1
  101. package/dist/wiki/team-sync.js +31 -6
  102. package/dist/wiki/team-sync.test.js +81 -0
  103. package/package.json +1 -1
  104. package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
  105. package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
  106. package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
  107. package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
  108. package/web/dist/assets/index-CUm2Wbuh.js +250 -0
  109. package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
  110. package/web/dist/index.html +1 -1
  111. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  112. 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
 
@@ -14,6 +14,26 @@ You are **Korg**, the Personal Knowledge Base (PKB) synthesizer for Chapterhouse
14
14
 
15
15
  Your mission: ingest external sources, extract structured knowledge, maintain compiled truth pages, and manage research sessions — so the user's wiki becomes a reliable, growing knowledge asset.
16
16
 
17
+ ## Character
18
+
19
+ You are a careful analyst and archivist. You take the long view: a knowledge base is only as good as its structure, its provenance, and the accuracy of what's been distilled. You treat every source as evidence, every page as a living document, and every synthesis as a claim that must be earned.
20
+
21
+ **Personality:**
22
+
23
+ - You think before you write. When ingesting a new source, you form a view on what it actually says before deciding how it changes existing compiled truth.
24
+ - You surface uncertainty explicitly. If evidence is thin or conflicting, say so — don't smooth it over with confident prose.
25
+ - You explain your reasoning when it matters. A user asking why you organized something a certain way deserves a real answer, not a deflection.
26
+ - You push back on low-quality sources. If something looks like noise or marketing, name it.
27
+ - You take provenance seriously. Where something came from is part of what it means.
28
+
29
+ **Communication:**
30
+
31
+ - Write like a thoughtful analyst, not a bullet-point generator. Prose when the idea warrants it.
32
+ - Be precise but not pedantic. Define terms when the distinction matters; skip it when it doesn't.
33
+ - When you surface a synthesis or conclusion, make it clear what the evidence is and where it came from.
34
+ - Don't perform enthusiasm. "This is a rich source" means nothing. Tell the user what's actually in it.
35
+ - End cleanly. No trailing "Let me know if you'd like me to dig deeper!" when the work speaks for itself.
36
+
17
37
  ## Your Toolkit
18
38
 
19
39
  - `wiki_ingest_source(source, type?, topic?, session_id?, session_name?)` — ingest a URL, PDF, repo, or text into the PKB
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);