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
@@ -33,6 +33,37 @@ test("createTools registers OKR progress tools", async () => {
33
33
  assert.ok(tools.some((tool) => tool.name === "get_my_okrs"));
34
34
  assert.ok(tools.some((tool) => tool.name === "write_team_wiki"));
35
35
  });
36
+ test("get_my_okrs does not fall back to the last authenticated user outside a turn", async () => {
37
+ const toolsModule = await loadToolsModule();
38
+ assert.ok(toolsModule, "tools module should exist");
39
+ const dbModule = await import("../store/db.js");
40
+ dbModule.setState("last_authenticated_user", JSON.stringify({
41
+ id: "u-ada",
42
+ name: "Ada Lovelace",
43
+ email: "ada@example.com",
44
+ role: "engineer",
45
+ }));
46
+ let fetchAttempts = 0;
47
+ const tools = toolsModule.createTools({
48
+ client: { async listModels() { return []; } },
49
+ onAgentTaskComplete: () => { },
50
+ createTeamPushClient: () => ({
51
+ async pushUpdate() {
52
+ throw new Error("not used in this test");
53
+ },
54
+ async fetchOKRs() {
55
+ fetchAttempts += 1;
56
+ return "";
57
+ },
58
+ async writePage(path) {
59
+ return { ok: true, path };
60
+ },
61
+ }),
62
+ });
63
+ const result = await tools.find((tool) => tool.name === "get_my_okrs")?.handler({});
64
+ assert.match(result, /current user identity/i);
65
+ assert.equal(fetchAttempts, 0, "tool must not use persisted last-user identity");
66
+ });
36
67
  test("log_okr_progress suggests a KR when the user did not specify one", async () => {
37
68
  const toolsModule = await loadToolsModule();
38
69
  assert.ok(toolsModule, "tools module should exist");
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import test from "node:test";
5
+ import { withWikiWrite } from "../wiki/lock.js";
5
6
  async function loadToolsModule() {
6
7
  return await import(new URL(`./tools.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
7
8
  }
@@ -513,9 +514,11 @@ updated: 2026-05-12
513
514
 
514
515
  # Rust
515
516
  `);
516
- for (const entry of indexManager.parseIndex()) {
517
- indexManager.removeFromIndex(entry.path);
518
- }
517
+ await withWikiWrite(() => {
518
+ for (const entry of indexManager.parseIndex()) {
519
+ indexManager.removeFromIndex(entry.path);
520
+ }
521
+ });
519
522
  assert.equal(indexManager.parseIndex().length, 0, "Precondition: wiki_pages should start empty");
520
523
  const result = await wikiReindex.handler({});
521
524
  assert.match(result, /^Reindexed \d+ wiki page\(s\) from disk\.$/);
@@ -44,12 +44,37 @@ const turnBuffers = new Map();
44
44
  const turnListeners = new Map();
45
45
  /** Per-session ring buffer for SSE reconnect replay (sliding window across turns). */
46
46
  const sessionBuffers = new Map();
47
- /** Per-session live listeners (SSE connections). */
47
+ /** Warn when one session accumulates an unusual number of SSE listeners. */
48
+ export const SESSION_LISTENER_WARN_THRESHOLD = 50;
49
+ /** Hard cap to evict oldest leaked SSE listeners for one session. */
50
+ export const SESSION_LISTENER_MAX = 100;
51
+ /** Per-session live listeners (SSE connections), insertion-ordered for oldest-first eviction. */
48
52
  const sessionListeners = new Map();
49
53
  /** Pending clear-buffer timers keyed by turnId. */
50
54
  const clearTimers = new Map();
51
55
  /** Monotonic global sequence counter — used as SSE `id:` for Last-Event-ID replay. */
52
56
  let globalSeq = 0;
57
+ function warnHighSessionListenerCount(sessionKey, listenerCount) {
58
+ if (listenerCount !== SESSION_LISTENER_WARN_THRESHOLD + 1)
59
+ return;
60
+ log.warn({ sessionKey, listenerCount, threshold: SESSION_LISTENER_WARN_THRESHOLD }, "turn-event-log: high session listener count may indicate leaked SSE subscribers");
61
+ }
62
+ function evictOverflowSessionListeners(sessionKey, listeners) {
63
+ const overflow = listeners.size - SESSION_LISTENER_MAX;
64
+ if (overflow <= 0)
65
+ return;
66
+ let evicted = 0;
67
+ for (const [listener] of listeners) {
68
+ listeners.delete(listener);
69
+ evicted += 1;
70
+ if (evicted >= overflow)
71
+ break;
72
+ }
73
+ if (listeners.size === 0) {
74
+ sessionListeners.delete(sessionKey);
75
+ }
76
+ log.warn({ sessionKey, evicted, listenerCount: listeners.size, maxListeners: SESSION_LISTENER_MAX }, "turn-event-log: evicted oldest session listeners after exceeding the max listener cap");
77
+ }
53
78
  // ---------------------------------------------------------------------------
54
79
  // Emit
55
80
  // ---------------------------------------------------------------------------
@@ -90,7 +115,7 @@ export function emitTurnEvent(sessionKey, event) {
90
115
  // Notify per-session listeners (SSE connections) -------------------------
91
116
  const sListeners = sessionListeners.get(sessionKey);
92
117
  if (sListeners) {
93
- for (const fn of sListeners) {
118
+ for (const fn of sListeners.keys()) {
94
119
  try {
95
120
  fn(indexed);
96
121
  }
@@ -169,10 +194,12 @@ export function subscribeSession(sessionKey, listener, afterSeq) {
169
194
  // Register live listener
170
195
  let ls = sessionListeners.get(sessionKey);
171
196
  if (!ls) {
172
- ls = new Set();
197
+ ls = new Map();
173
198
  sessionListeners.set(sessionKey, ls);
174
199
  }
175
- ls.add(listener);
200
+ ls.set(listener, true);
201
+ warnHighSessionListenerCount(sessionKey, ls.size);
202
+ evictOverflowSessionListeners(sessionKey, ls);
176
203
  return () => {
177
204
  const set = sessionListeners.get(sessionKey);
178
205
  if (set) {
@@ -22,17 +22,24 @@
22
22
  * SQLite-dependent functions (persistTurnEvents, getSessionEventsFromDb) are
23
23
  * tested in the integration suite to avoid needing a real DB here.
24
24
  */
25
- import { describe, it, afterEach } from "node:test";
25
+ import { describe, it, afterEach, after } from "node:test";
26
26
  import assert from "node:assert/strict";
27
+ import { mkdirSync, rmSync } from "node:fs";
28
+ import { join } from "node:path";
27
29
  import { setTimeout as setTimeoutPromise } from "node:timers/promises";
28
- import { emitTurnEvent, subscribeTurn, subscribeSession, clearTurnLog, scheduleClearTurnLog, turnLogSize, oldestSessionSeq, persistTurnEvents, getSessionEventsFromDb, TURN_BUFFER_CAPACITY, SESSION_BUFFER_CAPACITY, } from "./turn-event-log.js";
30
+ import { emitTurnEvent, subscribeTurn, subscribeSession, clearTurnLog, scheduleClearTurnLog, turnLogSize, oldestSessionSeq, persistTurnEvents, getSessionEventsFromDb, TURN_BUFFER_CAPACITY, SESSION_BUFFER_CAPACITY, SESSION_LISTENER_MAX, } from "./turn-event-log.js";
29
31
  import { getDb } from "../store/db.js";
32
+ import { resetSingletons } from "../test/helpers/reset-singletons.js";
30
33
  // ---------------------------------------------------------------------------
31
34
  // Helpers
32
35
  // ---------------------------------------------------------------------------
33
36
  let turnCounter = 0;
34
37
  let sessionCounter = 0;
35
38
  const usedSessionKeys = [];
39
+ const sandboxRoot = join(process.cwd(), ".test-work", `turn-event-log-${process.pid}`);
40
+ mkdirSync(sandboxRoot, { recursive: true });
41
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
42
+ resetSingletons();
36
43
  function freshTurnId() {
37
44
  return `turn-test-${++turnCounter}-${Date.now()}`;
38
45
  }
@@ -76,6 +83,10 @@ afterEach(() => {
76
83
  }
77
84
  }
78
85
  });
86
+ after(() => {
87
+ resetSingletons();
88
+ rmSync(sandboxRoot, { recursive: true, force: true });
89
+ });
79
90
  function trackTurn(turnId) {
80
91
  usedTurnIds.push(turnId);
81
92
  return turnId;
@@ -260,6 +271,17 @@ describe("turn-event-log", () => {
260
271
  emitTurnEvent(session, makeComplete(turnId, session));
261
272
  assert.equal(received.length, 1);
262
273
  });
274
+ it("evicts the oldest session listeners when one session exceeds the max listener cap", () => {
275
+ const session = freshSessionKey();
276
+ const turnId = trackTurn(freshTurnId());
277
+ const deliveries = Array.from({ length: SESSION_LISTENER_MAX + 1 }, () => []);
278
+ for (const received of deliveries) {
279
+ trackUnsub(subscribeSession(session, (event) => received.push(event)));
280
+ }
281
+ emitTurnEvent(session, makeStarted(turnId, session));
282
+ assert.equal(deliveries[0]?.length, 0, "oldest listener should be evicted once the cap is exceeded");
283
+ assert.equal(deliveries.at(-1)?.length, 1, "most recent listener should still receive events");
284
+ });
263
285
  it("two sessions are isolated from each other", () => {
264
286
  const session1 = freshSessionKey();
265
287
  const session2 = freshSessionKey();
@@ -68,12 +68,12 @@ function makeFs(initialJson) {
68
68
  let stored = initialJson ?? null;
69
69
  const written = [];
70
70
  return {
71
- readFile: (path, _enc) => {
71
+ readFile: (_path, _enc) => {
72
72
  if (stored === null)
73
73
  throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
74
74
  return stored;
75
75
  },
76
- writeFile: (path, data, _enc) => {
76
+ writeFile: (_path, data, _enc) => {
77
77
  stored = data;
78
78
  written.push(data);
79
79
  },
@@ -7,6 +7,7 @@ import { execSync, execFileSync } from "child_process";
7
7
  import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs";
8
8
  import { join, dirname } from "path";
9
9
  import { homedir, platform } from "os";
10
+ import { config } from "./config.js";
10
11
  /** The launchd label / systemd unit name. */
11
12
  export const DAEMON_LABEL = "com.bketelsen.chapterhouse";
12
13
  export const DAEMON_UNIT_NAME = "chapterhouse";
@@ -80,7 +81,7 @@ function composeMacOSPath(binDir, shellPath) {
80
81
  /** Generate the launchd plist XML string. */
81
82
  export function generatePlist(options) {
82
83
  const label = options.label ?? DAEMON_LABEL;
83
- const shellPath = options.shellPath ?? process.env.PATH ?? "";
84
+ const shellPath = options.shellPath ?? config.shellPath;
84
85
  const richPath = composeMacOSPath(dirname(options.binPath), shellPath);
85
86
  return `<?xml version="1.0" encoding="UTF-8"?>
86
87
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -135,7 +136,7 @@ function composeSystemdPath(binDir, shellPath) {
135
136
  /** Generate the systemd user unit file string. */
136
137
  export function generateSystemdUnit(options) {
137
138
  const description = options.description ?? "Chapterhouse AI assistant daemon";
138
- const shellPath = options.shellPath ?? process.env.PATH ?? "";
139
+ const shellPath = options.shellPath ?? config.shellPath;
139
140
  const richPath = composeSystemdPath(dirname(options.binPath), shellPath);
140
141
  return `[Unit]
141
142
  Description=${description}
package/dist/daemon.js CHANGED
@@ -12,6 +12,7 @@ import { checkForUpdate } from "./update.js";
12
12
  import { ensureWikiStructure } from "./wiki/fs.js";
13
13
  import { seedTeamWiki } from "./wiki/seed-team-wiki.js";
14
14
  import { shouldMigrate, migrateMemoriesToWiki, shouldReorganize, reorganizeWiki } from "./wiki/migrate.js";
15
+ import { withWikiWrite } from "./wiki/lock.js";
15
16
  import { shouldEnforceTopics, enforceTopicStructure } from "./wiki/migrate-topics.js";
16
17
  import { SESSIONS_DIR } from "./paths.js";
17
18
  import { getDisplayHost } from "./api/server-runtime.js";
@@ -37,15 +38,7 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
37
38
  * Layer 3 — systemd kill: TimeoutStopSec=90 s (must exceed layer 2)
38
39
  * Allows in-flight LLM streams to complete before the process is torn down.
39
40
  */
40
- const SHUTDOWN_TIMEOUT_MS = (() => {
41
- const env = process.env.CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS;
42
- if (env) {
43
- const parsed = parseInt(env, 10);
44
- if (!isNaN(parsed) && parsed > 0)
45
- return parsed;
46
- }
47
- return 60_000;
48
- })();
41
+ const SHUTDOWN_TIMEOUT_MS = config.shutdownTimeoutMs;
49
42
  /** Remove orphaned session folders older than 7 days, preserving the current session. */
50
43
  function pruneOldSessions() {
51
44
  try {
@@ -95,10 +88,9 @@ async function main() {
95
88
  for (const warning of config.modeCompatibilityWarnings) {
96
89
  log.warn({ mode: config.chapterhouseMode }, warning);
97
90
  }
91
+ log.info({ capabilities: modeContext.getCapabilities() }, modeContext.getStartupCapabilitySummary());
98
92
  // Set up message logging to daemon console
99
93
  setMessageLogger((direction, source, text) => {
100
- const arrow = direction === "in" ? "⟶" : "⟵";
101
- const tag = source.padEnd(8);
102
94
  log.debug({ direction, source, text: truncate(text) }, "chat");
103
95
  });
104
96
  // Initialize SQLite
@@ -110,19 +102,19 @@ async function main() {
110
102
  log.info("Created wiki");
111
103
  }
112
104
  if (modeContext.isTeam()) {
113
- const seed = seedTeamWiki();
105
+ const seed = await withWikiWrite(() => seedTeamWiki());
114
106
  if (seed.created.length > 0) {
115
107
  log.info({ pages: seed.created }, "Seeded team wiki pages");
116
108
  }
117
109
  }
118
110
  if (shouldMigrate()) {
119
111
  log.info("Migrating SQLite memories to wiki");
120
- const count = migrateMemoriesToWiki();
112
+ const count = await withWikiWrite(() => migrateMemoriesToWiki());
121
113
  log.info({ count }, "Migrated memories to wiki");
122
114
  }
123
115
  if (shouldReorganize()) {
124
116
  log.info("Reorganizing wiki pages into entity structure");
125
- const count = reorganizeWiki();
117
+ const count = await withWikiWrite(() => reorganizeWiki());
126
118
  log.info({ count }, "Created entity pages during reorganization");
127
119
  }
128
120
  if (shouldEnforceTopics()) {
@@ -144,7 +136,7 @@ async function main() {
144
136
  // Prune orphaned session folders older than 7 days
145
137
  pruneOldSessions();
146
138
  // One-time deprecation note for legacy Telegram users (v1 → v2)
147
- if (process.env.TELEGRAM_BOT_TOKEN) {
139
+ if (config.telegramBotTokenConfigured) {
148
140
  log.warn("TELEGRAM_BOT_TOKEN found in env — Telegram support was removed in v2. The web UI is now the only client.");
149
141
  }
150
142
  // Auto-install workiq MCP server when Entra is configured
@@ -180,7 +172,7 @@ async function main() {
180
172
  }
181
173
  const url = `http://${getDisplayHost(config.apiHost)}:${config.apiPort}`;
182
174
  log.info({ url }, "Chapterhouse is fully operational");
183
- if (process.env.CHAPTERHOUSE_OPEN_BROWSER === "1") {
175
+ if (config.openBrowserOnStart) {
184
176
  const opener = process.platform === "darwin" ? "open" :
185
177
  process.platform === "win32" ? "explorer.exe" : "xdg-open";
186
178
  try {
@@ -198,7 +190,7 @@ async function main() {
198
190
  }
199
191
  })
200
192
  .catch(() => { }); // silent — network may be unavailable
201
- if (process.env.CHAPTERHOUSE_RESTARTED === "1") {
193
+ if (config.restarted) {
202
194
  delete process.env.CHAPTERHOUSE_RESTARTED;
203
195
  }
204
196
  }
@@ -4,6 +4,85 @@ import { ADO_ORG, ADO_PROJECT, FIELDS, STD_FIELDS, UNIT_FIELD, WIT } from "./ado
4
4
  function escapeWiqlLiteral(value) {
5
5
  return value.replace(/'/g, "''");
6
6
  }
7
+ const ALLOWED_WIQL_FIELDS = new Set([
8
+ "System.Id",
9
+ "System.TeamProject",
10
+ "System.WorkItemType",
11
+ STD_FIELDS.TITLE,
12
+ STD_FIELDS.STATE,
13
+ STD_FIELDS.PARENT,
14
+ STD_FIELDS.ASSIGNED_TO,
15
+ STD_FIELDS.TAGS,
16
+ FIELDS.CURRENT_VALUE,
17
+ FIELDS.TARGET_VALUE,
18
+ FIELDS.OKR_PERIOD,
19
+ FIELDS.OKR_OWNER,
20
+ UNIT_FIELD,
21
+ ]);
22
+ const ALLOWED_WIQL_OPERATORS = new Set(["=", "<>", "IN", "CONTAINS"]);
23
+ const ALLOWED_WIQL_MACROS = new Set(["@project"]);
24
+ function quoteWiqlField(field) {
25
+ if (!ALLOWED_WIQL_FIELDS.has(field)) {
26
+ throw new Error(`Unsupported WIQL field: ${field}`);
27
+ }
28
+ return `[${field}]`;
29
+ }
30
+ function normalizeWiqlOperator(operator) {
31
+ if (ALLOWED_WIQL_OPERATORS.has(operator)) {
32
+ return operator;
33
+ }
34
+ throw new Error(`Unsupported WIQL operator: ${operator}`);
35
+ }
36
+ function formatWiqlScalar(value) {
37
+ if (typeof value === "number") {
38
+ if (!Number.isFinite(value)) {
39
+ throw new Error(`Unsupported WIQL number: ${value}`);
40
+ }
41
+ return String(value);
42
+ }
43
+ return `'${escapeWiqlLiteral(value)}'`;
44
+ }
45
+ function formatWiqlValue(value, operator) {
46
+ if (Array.isArray(value)) {
47
+ if (operator !== "IN") {
48
+ throw new Error(`WIQL operator ${operator} does not accept a list value.`);
49
+ }
50
+ if (value.length === 0) {
51
+ throw new Error("WIQL IN clauses require at least one value.");
52
+ }
53
+ return `(${value.map((item) => formatWiqlScalar(item)).join(", ")})`;
54
+ }
55
+ if (typeof value === "object" && value !== null && "macro" in value) {
56
+ if (!ALLOWED_WIQL_MACROS.has(value.macro)) {
57
+ throw new Error(`Unsupported WIQL macro: ${value.macro}`);
58
+ }
59
+ return value.macro;
60
+ }
61
+ if (typeof value === "string" || typeof value === "number") {
62
+ return formatWiqlScalar(value);
63
+ }
64
+ throw new Error("Unsupported WIQL value.");
65
+ }
66
+ function formatWiqlCondition(condition) {
67
+ const field = quoteWiqlField(condition.field);
68
+ const operator = normalizeWiqlOperator(condition.operator);
69
+ return `${field} ${operator} ${formatWiqlValue(condition.value, operator)}`;
70
+ }
71
+ export function buildWiqlQuery(query) {
72
+ if (query.select.length === 0) {
73
+ throw new Error("WIQL queries must select at least one field.");
74
+ }
75
+ if (query.where.length === 0) {
76
+ throw new Error("WIQL queries must include at least one filter.");
77
+ }
78
+ const selectClause = query.select.map((field) => quoteWiqlField(field)).join(", ");
79
+ const whereClause = query.where.map((condition) => formatWiqlCondition(condition)).join(" AND ");
80
+ return [
81
+ `SELECT ${selectClause}`,
82
+ "FROM WorkItems",
83
+ `WHERE ${whereClause}`,
84
+ ].join(" ");
85
+ }
7
86
  function getStringField(fields, fieldName) {
8
87
  const value = fields?.[fieldName];
9
88
  if (typeof value === "string") {
@@ -47,15 +126,17 @@ function toPercentComplete(currentValue, targetValue) {
47
126
  return Math.max(0, Math.min(100, Math.round((currentValue / targetValue) * 100)));
48
127
  }
49
128
  function buildWiql(workItemType, period) {
50
- const periodClause = period
51
- ? ` AND [${FIELDS.OKR_PERIOD}] = '${escapeWiqlLiteral(period)}'`
52
- : "";
53
- return [
54
- "SELECT [System.Id]",
55
- "FROM WorkItems",
56
- "WHERE [System.TeamProject] = @project",
57
- `AND [System.WorkItemType] = '${workItemType}'${periodClause}`,
58
- ].join(" ");
129
+ const where = [
130
+ { field: "System.TeamProject", operator: "=", value: { macro: "@project" } },
131
+ { field: "System.WorkItemType", operator: "=", value: workItemType },
132
+ ];
133
+ if (period) {
134
+ where.push({ field: FIELDS.OKR_PERIOD, operator: "=", value: period });
135
+ }
136
+ return buildWiqlQuery({
137
+ select: ["System.Id"],
138
+ where,
139
+ });
59
140
  }
60
141
  function getWorkItemIds(result) {
61
142
  return (result.workItems ?? [])
@@ -173,4 +173,60 @@ test("getOKRSummary computes percent complete from KR progress", async () => {
173
173
  assert.equal(summary.objectives[0]?.keyResults[0]?.percentComplete, 75);
174
174
  assert.equal(summary.objectives[0]?.keyResults[1]?.percentComplete, 50);
175
175
  });
176
+ test("buildWiqlQuery rejects non-whitelisted field names", async () => {
177
+ const ado = await loadAdoClientModule();
178
+ assert.ok(ado, "ado client module should exist");
179
+ assert.throws(() => ado.buildWiqlQuery({
180
+ select: ["System.Id"],
181
+ where: [
182
+ {
183
+ field: "System.Title] OR [System.Id",
184
+ operator: "=",
185
+ value: "Ship SSO to all tenants",
186
+ },
187
+ ],
188
+ }), /Unsupported WIQL field/);
189
+ });
190
+ test("buildWiqlQuery rejects non-whitelisted operators", async () => {
191
+ const ado = await loadAdoClientModule();
192
+ assert.ok(ado, "ado client module should exist");
193
+ assert.throws(() => ado.buildWiqlQuery({
194
+ select: ["System.Id"],
195
+ where: [
196
+ {
197
+ field: "System.Title",
198
+ operator: "= '' OR [System.Id] > 0",
199
+ value: "Ship SSO to all tenants",
200
+ },
201
+ ],
202
+ }), /Unsupported WIQL operator/);
203
+ });
204
+ test("getKeyResults escapes WIQL literal values before querying", async () => {
205
+ const ado = await loadAdoClientModule();
206
+ assert.ok(ado, "ado client module should exist");
207
+ const queries = [];
208
+ const client = new ado.AdoClient({
209
+ org: "https://dev.azure.com/example-org",
210
+ project: "example-project",
211
+ pat: "test-pat",
212
+ workItemTrackingApi: {
213
+ async queryByWiql(wiql) {
214
+ queries.push(wiql.query ?? "");
215
+ return { workItems: [] };
216
+ },
217
+ async getWorkItems() {
218
+ throw new Error("not used in this test");
219
+ },
220
+ async updateWorkItem() {
221
+ throw new Error("not used in this test");
222
+ },
223
+ async addComment() {
224
+ throw new Error("not used in this test");
225
+ },
226
+ },
227
+ });
228
+ await client.getKeyResults("2026-Q2' OR [System.Id] > 0 OR 'x'='x");
229
+ assert.equal(queries.length, 1);
230
+ assert.match(queries[0] ?? "", /\[Custom\.OKRPeriod\] = '2026-Q2'' OR \[System\.Id\] > 0 OR ''x''=''x'/);
231
+ });
176
232
  //# sourceMappingURL=ado-client.test.js.map
@@ -17,6 +17,7 @@ export class TeamPushClient {
17
17
  this.getCurrentUser = options.getCurrentUser;
18
18
  this.modeContext = new ModeContext({
19
19
  ...config,
20
+ chapterhouseMode: options.chapterhouseMode ?? config.chapterhouseMode,
20
21
  teamChapterhouseUrl: this.teamChapterhouseUrl,
21
22
  standaloneMode: this.standaloneMode,
22
23
  });
@@ -13,6 +13,7 @@ test("pushUpdate sends the expected payload to the team update endpoint", async
13
13
  assert.ok(teamPushModule, "team push module should exist");
14
14
  const requests = [];
15
15
  const client = new teamPushModule.TeamPushClient({
16
+ chapterhouseMode: "team",
16
17
  teamChapterhouseUrl: "https://team.example.com/",
17
18
  standaloneMode: false,
18
19
  getAuthorizationHeader: () => "Bearer entra-token",
@@ -61,6 +62,7 @@ test("pushUpdate throws descriptive errors for auth and network failures", async
61
62
  const teamPushModule = await loadTeamPushModule();
62
63
  assert.ok(teamPushModule, "team push module should exist");
63
64
  const unauthorizedClient = new teamPushModule.TeamPushClient({
65
+ chapterhouseMode: "team",
64
66
  teamChapterhouseUrl: "https://team.example.com",
65
67
  teamChapterhouseToken: "fallback-token",
66
68
  standaloneMode: false,
@@ -73,6 +75,7 @@ test("pushUpdate throws descriptive errors for auth and network failures", async
73
75
  });
74
76
  await assert.rejects(unauthorizedClient.pushUpdate({ activity: "shipped auth refactor", krId: "O1-KR2", delta: 5 }), /Failed to push OKR update: unauthorized \(HTTP 401\)/i);
75
77
  const networkClient = new teamPushModule.TeamPushClient({
78
+ chapterhouseMode: "team",
76
79
  teamChapterhouseUrl: "https://team.example.com",
77
80
  standaloneMode: false,
78
81
  getAuthorizationHeader: () => "Bearer entra-token",
@@ -92,6 +95,7 @@ test("fetchOKRs returns raw OKR page content for the requested period", async ()
92
95
  assert.ok(teamPushModule, "team push module should exist");
93
96
  const requests = [];
94
97
  const client = new teamPushModule.TeamPushClient({
98
+ chapterhouseMode: "team",
95
99
  teamChapterhouseUrl: "https://team.example.com",
96
100
  standaloneMode: false,
97
101
  getAuthorizationHeader: () => "Bearer entra-token",
@@ -120,6 +124,7 @@ test("writePage PUTs shared wiki content to the team wiki endpoint", async () =>
120
124
  assert.ok(teamPushModule, "team push module should exist");
121
125
  const requests = [];
122
126
  const client = new teamPushModule.TeamPushClient({
127
+ chapterhouseMode: "team",
123
128
  teamChapterhouseUrl: "https://team.example.com/",
124
129
  standaloneMode: false,
125
130
  getAuthorizationHeader: () => "Bearer entra-token",
@@ -152,6 +157,7 @@ test("team push silently no-ops when team integration is disabled", async () =>
152
157
  assert.ok(teamPushModule, "team push module should exist");
153
158
  let called = false;
154
159
  const client = new teamPushModule.TeamPushClient({
160
+ chapterhouseMode: "team",
155
161
  teamChapterhouseUrl: "",
156
162
  standaloneMode: false,
157
163
  fetchImpl: async () => {
@@ -17,6 +17,7 @@ export class TeamsNotifier {
17
17
  this.warn = options.warn ?? ((message) => log.warn(message));
18
18
  this.modeContext = new ModeContext({
19
19
  ...config,
20
+ chapterhouseMode: options.chapterhouseMode ?? config.chapterhouseMode,
20
21
  teamsWebhookUrl: this.webhookUrl,
21
22
  teamsNotificationsEnabled: this.enabled,
22
23
  });
@@ -13,6 +13,7 @@ test("sendMessage formats a Teams MessageCard payload", async () => {
13
13
  assert.ok(teamsNotify, "teams notifier module should exist");
14
14
  const calls = [];
15
15
  const notifier = new teamsNotify.TeamsNotifier({
16
+ chapterhouseMode: "team",
16
17
  webhookUrl: "https://teams.example.test/webhook",
17
18
  enabled: true,
18
19
  fetchImpl: async (input, init) => {
@@ -39,6 +40,7 @@ for (const milestone of [25, 50, 75, 100]) {
39
40
  assert.ok(teamsNotify, "teams notifier module should exist");
40
41
  const calls = [];
41
42
  const notifier = new teamsNotify.TeamsNotifier({
43
+ chapterhouseMode: "team",
42
44
  webhookUrl: "https://teams.example.test/webhook",
43
45
  enabled: true,
44
46
  fetchImpl: async (input, init) => {
@@ -70,6 +72,7 @@ test("sendMessage does not call Teams when notifications are disabled", async ()
70
72
  let fetchCalls = 0;
71
73
  const warnings = [];
72
74
  const notifier = new teamsNotify.TeamsNotifier({
75
+ chapterhouseMode: "team",
73
76
  webhookUrl: "https://teams.example.test/webhook",
74
77
  enabled: false,
75
78
  fetchImpl: async () => {
@@ -89,6 +92,7 @@ test("notifyWeeklyHealthCheck sends an OKR summary card", async () => {
89
92
  assert.ok(teamsNotify, "teams notifier module should exist");
90
93
  const calls = [];
91
94
  const notifier = new teamsNotify.TeamsNotifier({
95
+ chapterhouseMode: "team",
92
96
  webhookUrl: "https://teams.example.test/webhook",
93
97
  enabled: true,
94
98
  fetchImpl: async (input, init) => {
@@ -114,6 +118,7 @@ test("notifyStandup sends the member update summary", async () => {
114
118
  assert.ok(teamsNotify, "teams notifier module should exist");
115
119
  const calls = [];
116
120
  const notifier = new teamsNotify.TeamsNotifier({
121
+ chapterhouseMode: "team",
117
122
  webhookUrl: "https://teams.example.test/webhook",
118
123
  enabled: true,
119
124
  fetchImpl: async (input, init) => {
@@ -34,7 +34,6 @@ test.after(async () => {
34
34
  test("active scope can be set, read, and cleared without changing scope activation status", async () => {
35
35
  const { dbModule, memoryModule } = await loadModules();
36
36
  dbModule.getDb();
37
- const getScope = getFunction(memoryModule, "getScope");
38
37
  const getActiveScope = getFunction(memoryModule, "getActiveScope");
39
38
  const setActiveScope = getFunction(memoryModule, "setActiveScope");
40
39
  const deactivateScope = getFunction(memoryModule, "deactivateScope");