chapterhouse 0.5.1 → 0.6.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 (41) hide show
  1. package/.pr-types.json +14 -0
  2. package/README.md +6 -0
  3. package/dist/api/server.js +5 -3
  4. package/dist/cli.js +4 -2
  5. package/dist/config.js +75 -13
  6. package/dist/config.test.js +73 -0
  7. package/dist/copilot/memory-coordinator.js +234 -0
  8. package/dist/copilot/memory-coordinator.test.js +257 -0
  9. package/dist/copilot/orchestrator.js +31 -212
  10. package/dist/copilot/orchestrator.test.js +111 -0
  11. package/dist/copilot/pr-title.js +92 -0
  12. package/dist/copilot/pr-title.test.js +54 -0
  13. package/dist/copilot/router.js +43 -8
  14. package/dist/copilot/router.test.js +60 -18
  15. package/dist/copilot/threat-model.js +50 -0
  16. package/dist/copilot/threat-model.test.js +129 -0
  17. package/dist/copilot/tools.js +65 -39
  18. package/dist/copilot/tools.wiki.test.js +15 -6
  19. package/dist/daemon.js +7 -2
  20. package/dist/integrations/team-push.js +8 -1
  21. package/dist/integrations/teams-notify.js +8 -1
  22. package/dist/memory/housekeeping.js +73 -25
  23. package/dist/memory/housekeeping.test.js +95 -3
  24. package/dist/memory/inbox.test.js +178 -0
  25. package/dist/memory/tiering.test.js +323 -0
  26. package/dist/mode-context.js +28 -0
  27. package/dist/mode-context.test.js +42 -0
  28. package/dist/setup.js +162 -95
  29. package/dist/setup.test.js +139 -0
  30. package/dist/sprint-merge.js +168 -0
  31. package/dist/sprint-merge.test.js +131 -0
  32. package/dist/store/db.js +63 -0
  33. package/dist/store/db.test.js +279 -0
  34. package/dist/wiki/team-sync.js +8 -1
  35. package/package.json +6 -1
  36. package/web/dist/assets/{index-BfHqP3-C.js → index-B5oDsQ5y.js} +84 -84
  37. package/web/dist/assets/index-B5oDsQ5y.js.map +1 -0
  38. package/web/dist/assets/index-DknKAtDS.css +10 -0
  39. package/web/dist/index.html +2 -2
  40. package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
  41. package/web/dist/assets/index-_O6AoWOS.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
@@ -9,6 +9,7 @@ import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, getAgentInfo,
9
9
  import { agentEventBus } from "../copilot/agent-event-bus.js";
10
10
  import { ensureDefaultAgents, getAgentRegistry, loadAgents } from "../copilot/agents.js";
11
11
  import { config, persistModel } from "../config.js";
12
+ import { ModeContext } from "../mode-context.js";
12
13
  import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
13
14
  import { searchIndex, parseIndex } from "../wiki/index-manager.js";
14
15
  import { createAuthMiddleware, getBootstrapAuthResponse } from "./auth.js";
@@ -34,6 +35,7 @@ import { childLogger } from "../util/logger.js";
34
35
  import { getActiveScope } from "../memory/active-scope.js";
35
36
  import { createScope, getScope } from "../memory/scopes.js";
36
37
  const log = childLogger("server");
38
+ const modeContext = new ModeContext(config);
37
39
  void searchIndex; // re-exported by index-manager; reference here documents the dep
38
40
  const __dirname = dirname(fileURLToPath(import.meta.url));
39
41
  // Built SPA bundle (web/dist/), shipped alongside dist/
@@ -148,7 +150,7 @@ catch (err) {
148
150
  log.error({ err: err instanceof Error ? err.message : String(err) }, "Auth token error");
149
151
  process.exit(1);
150
152
  }
151
- if (config.standaloneMode) {
153
+ if (modeContext.isPersonal() && config.standaloneMode) {
152
154
  log.warn("Running without authentication — team features disabled");
153
155
  }
154
156
  function isLoopbackHostname(hostname) {
@@ -262,7 +264,7 @@ function assertValidPagePath(path) {
262
264
  }
263
265
  }
264
266
  function getWikiPageScope(path) {
265
- return teamWikiSync.isTeamPath(path) ? "team" : "personal";
267
+ return modeContext.canSyncTeamWiki() && teamWikiSync.isTeamPath(path) ? "team" : "personal";
266
268
  }
267
269
  function getEmptyWikiWelcomeContent(today = new Date()) {
268
270
  return `---
@@ -1027,7 +1029,7 @@ app.put("/api/projects/:slug/rules/soft", async (req, res) => {
1027
1029
  app.get("/api/wiki/pages", async (req, res) => {
1028
1030
  ensureWikiStructure();
1029
1031
  // Sync team wiki pages if connected, using the caller's auth token
1030
- if (teamWikiSync.isEnabled()) {
1032
+ if (modeContext.canSyncTeamWiki() && teamWikiSync.isEnabled()) {
1031
1033
  const authorizationHeader = typeof req.headers.authorization === "string"
1032
1034
  ? req.headers.authorization
1033
1035
  : undefined;
package/dist/cli.js CHANGED
@@ -61,9 +61,11 @@ switch (command) {
61
61
  await import("./daemon.js");
62
62
  break;
63
63
  }
64
- case "setup":
65
- await import("./setup.js");
64
+ case "setup": {
65
+ const { runSetup } = await import("./setup.js");
66
+ await runSetup();
66
67
  break;
68
+ }
67
69
  case "update": {
68
70
  const updateFlags = args.slice(1);
69
71
  const checkOnly = updateFlags.includes("--check-only");
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { config as loadEnv } from "dotenv";
2
2
  import { z } from "zod";
3
3
  import { existsSync, readFileSync, writeFileSync } from "fs";
4
4
  import { API_TOKEN_PATH, ENV_PATH, ensureChapterhouseHome } from "./paths.js";
5
+ export { ModeContext } from "./mode-context.js";
5
6
  export const DISABLE_DOTENV_ENV_VAR = "CHAPTERHOUSE_DISABLE_DOTENV";
6
7
  const DEFAULT_WORKER_TIMEOUT_MS = 900_000;
7
8
  const BOOLEAN_ENV_PATTERN = /^(true|false)$/;
@@ -176,6 +177,43 @@ function parseEntityCategories(rawValue) {
176
177
  .filter(Boolean);
177
178
  return cats.length > 0 ? [...new Set(cats)] : [...DEFAULT_ENTITY_CATEGORIES];
178
179
  }
180
+ function configuredKeys(entries) {
181
+ return entries
182
+ .filter(([, value]) => typeof value === "boolean" ? value : value.trim().length > 0)
183
+ .map(([key]) => key);
184
+ }
185
+ function buildModeCompatibilityWarnings(raw, mode) {
186
+ if (mode !== "personal") {
187
+ return [];
188
+ }
189
+ const warnings = [];
190
+ const adoKeys = configuredKeys([
191
+ ["ADO_ORG", raw.ADO_ORG?.trim() || ""],
192
+ ["ADO_PROJECT", raw.ADO_PROJECT?.trim() || ""],
193
+ ["ADO_PAT", raw.ADO_PAT?.trim() || ""],
194
+ ]);
195
+ if (adoKeys.length > 0 && adoKeys.length < 3) {
196
+ warnings.push(`Personal mode detected incomplete Azure DevOps settings (${adoKeys.join(", ")}); those team-only settings may be ignored.`);
197
+ }
198
+ const entraKeys = configuredKeys([
199
+ ["ENTRA_AUTH_ENABLED", raw.ENTRA_AUTH_ENABLED?.trim() === "true"],
200
+ ["ENTRA_TENANT_ID", raw.ENTRA_TENANT_ID?.trim() || ""],
201
+ ["ENTRA_CLIENT_ID", raw.ENTRA_CLIENT_ID?.trim() || ""],
202
+ ["ENTRA_REQUIRED_ROLE", raw.ENTRA_REQUIRED_ROLE?.trim() || ""],
203
+ ["ENTRA_TEAM_LEAD_ID", raw.ENTRA_TEAM_LEAD_ID?.trim() || ""],
204
+ ]);
205
+ if (entraKeys.length > 0) {
206
+ warnings.push(`Personal mode ignores Entra auth settings (${entraKeys.join(", ")}).`);
207
+ }
208
+ const teamsKeys = configuredKeys([
209
+ ["TEAMS_NOTIFICATIONS_ENABLED", raw.TEAMS_NOTIFICATIONS_ENABLED?.trim() === "true"],
210
+ ["TEAMS_WEBHOOK_URL", raw.TEAMS_WEBHOOK_URL?.trim() || ""],
211
+ ]);
212
+ if (teamsKeys.length > 0) {
213
+ warnings.push(`Personal mode ignores Teams notification settings (${teamsKeys.join(", ")}).`);
214
+ }
215
+ return warnings;
216
+ }
179
217
  function resolveConfiguredApiToken(envToken, { apiTokenPath = API_TOKEN_PATH, exists = existsSync, readFile = readFileSync, } = {}) {
180
218
  const trimmedEnvToken = envToken?.trim();
181
219
  if (trimmedEnvToken) {
@@ -220,11 +258,33 @@ export function parseRuntimeConfig(env, options = {}) {
220
258
  const entraClientId = raw.ENTRA_CLIENT_ID?.trim() || "";
221
259
  const entraRequiredRole = raw.ENTRA_REQUIRED_ROLE?.trim() || "";
222
260
  const entraTeamLeadId = raw.ENTRA_TEAM_LEAD_ID?.trim() || "";
223
- const entraAuthEnabled = parseBooleanEnv("ENTRA_AUTH_ENABLED", raw.ENTRA_AUTH_ENABLED, false);
261
+ const rawEntraAuthEnabled = parseBooleanEnv("ENTRA_AUTH_ENABLED", raw.ENTRA_AUTH_ENABLED, false);
262
+ if (parsedMode === "personal" && rawEntraAuthEnabled) {
263
+ throw new Error("Personal mode cannot be used with ENTRA_AUTH_ENABLED=true. Set CHAPTERHOUSE_MODE=team to use Entra auth, or unset ENTRA_AUTH_ENABLED to run in unauthenticated personal mode.");
264
+ }
265
+ const parsedTeamsWebhookUrl = parseOptionalUrl("TEAMS_WEBHOOK_URL", raw.TEAMS_WEBHOOK_URL);
266
+ const rawTeamsNotificationsEnabled = parseBooleanEnv("TEAMS_NOTIFICATIONS_ENABLED", raw.TEAMS_NOTIFICATIONS_ENABLED, false);
267
+ const modeCompatibilityWarnings = buildModeCompatibilityWarnings(raw, parsedMode);
268
+ const personalModeIgnoresEntra = parsedMode === "personal" && configuredKeys([
269
+ ["ENTRA_AUTH_ENABLED", rawEntraAuthEnabled],
270
+ ["ENTRA_TENANT_ID", entraTenantId],
271
+ ["ENTRA_CLIENT_ID", entraClientId],
272
+ ["ENTRA_REQUIRED_ROLE", entraRequiredRole],
273
+ ["ENTRA_TEAM_LEAD_ID", entraTeamLeadId],
274
+ ]).length > 0;
275
+ const personalModeIgnoresTeams = parsedMode === "personal" && configuredKeys([
276
+ ["TEAMS_NOTIFICATIONS_ENABLED", rawTeamsNotificationsEnabled],
277
+ ["TEAMS_WEBHOOK_URL", parsedTeamsWebhookUrl],
278
+ ]).length > 0;
279
+ const effectiveEntraAuthEnabled = personalModeIgnoresEntra ? false : rawEntraAuthEnabled;
280
+ const effectiveEntraTenantId = personalModeIgnoresEntra ? "" : entraTenantId;
281
+ const effectiveEntraClientId = personalModeIgnoresEntra ? "" : entraClientId;
282
+ const effectiveEntraRequiredRole = personalModeIgnoresEntra ? "" : entraRequiredRole;
283
+ const effectiveEntraTeamLeadId = personalModeIgnoresEntra ? "" : entraTeamLeadId;
284
+ const effectiveTeamsWebhookUrl = personalModeIgnoresTeams ? "" : parsedTeamsWebhookUrl;
285
+ const effectiveTeamsNotificationsEnabled = personalModeIgnoresTeams ? false : rawTeamsNotificationsEnabled;
224
286
  const apiToken = resolveConfiguredApiToken(raw.API_TOKEN, options);
225
- const standaloneMode = !entraAuthEnabled && !apiToken;
226
- const teamsWebhookUrl = parseOptionalUrl("TEAMS_WEBHOOK_URL", raw.TEAMS_WEBHOOK_URL);
227
- const teamsNotificationsEnabled = parseBooleanEnv("TEAMS_NOTIFICATIONS_ENABLED", raw.TEAMS_NOTIFICATIONS_ENABLED, false);
287
+ const standaloneMode = !effectiveEntraAuthEnabled && !apiToken;
228
288
  const apiRateLimitWindowMs = parsePositiveIntegerEnv("API_RATE_LIMIT_WINDOW_MS", raw.API_RATE_LIMIT_WINDOW_MS, DEFAULT_API_RATE_LIMIT_WINDOW_MS);
229
289
  const apiRateLimitGeneralMax = parsePositiveIntegerEnv("API_RATE_LIMIT_GENERAL_MAX", raw.API_RATE_LIMIT_GENERAL_MAX, DEFAULT_API_RATE_LIMIT_GENERAL_MAX);
230
290
  const apiRateLimitAuthMax = parsePositiveIntegerEnv("API_RATE_LIMIT_AUTH_MAX", raw.API_RATE_LIMIT_AUTH_MAX, DEFAULT_API_RATE_LIMIT_AUTH_MAX);
@@ -238,10 +298,10 @@ export function parseRuntimeConfig(env, options = {}) {
238
298
  const memoryInboxRetentionDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS", raw.CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS, 7);
239
299
  const memoryHotRecallBoost = parsePositiveNumberEnv("CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST", raw.CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST, 1.5);
240
300
  const memoryHotAgeDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS", raw.CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS, 30);
241
- if (entraAuthEnabled && (!entraTenantId || !entraClientId)) {
301
+ if (effectiveEntraAuthEnabled && (!effectiveEntraTenantId || !effectiveEntraClientId)) {
242
302
  throw new Error("ENTRA_AUTH_ENABLED=true requires ENTRA_TENANT_ID and ENTRA_CLIENT_ID");
243
303
  }
244
- if (teamsNotificationsEnabled && !teamsWebhookUrl) {
304
+ if (effectiveTeamsNotificationsEnabled && !effectiveTeamsWebhookUrl) {
245
305
  throw new Error("TEAMS_NOTIFICATIONS_ENABLED=true requires TEAMS_WEBHOOK_URL");
246
306
  }
247
307
  return {
@@ -256,11 +316,11 @@ export function parseRuntimeConfig(env, options = {}) {
256
316
  adoProject: raw.ADO_PROJECT?.trim() || DEFAULT_ADO_PROJECT,
257
317
  adoPat: raw.ADO_PAT?.trim() || "",
258
318
  workerTimeoutMs: parsedWorkerTimeout,
259
- entraTenantId,
260
- entraClientId,
261
- entraRequiredRole,
262
- entraTeamLeadId,
263
- entraAuthEnabled,
319
+ entraTenantId: effectiveEntraTenantId,
320
+ entraClientId: effectiveEntraClientId,
321
+ entraRequiredRole: effectiveEntraRequiredRole,
322
+ entraTeamLeadId: effectiveEntraTeamLeadId,
323
+ entraAuthEnabled: effectiveEntraAuthEnabled,
264
324
  standaloneMode,
265
325
  corsAllowedOrigins: parseCorsAllowedOrigins(raw.CORS_ALLOWED_ORIGINS),
266
326
  copilotModel: raw.COPILOT_MODEL || DEFAULT_MODEL,
@@ -271,8 +331,8 @@ export function parseRuntimeConfig(env, options = {}) {
271
331
  teamWikiCacheTtlMinutes: parsedTeamWikiCacheTtlMinutes,
272
332
  teamWikiPaths: parseTeamWikiPaths(raw.TEAM_WIKI_PATHS),
273
333
  wikiEntityCategories: parseEntityCategories(raw.WIKI_ENTITY_CATEGORIES),
274
- teamsWebhookUrl,
275
- teamsNotificationsEnabled,
334
+ teamsWebhookUrl: effectiveTeamsWebhookUrl,
335
+ teamsNotificationsEnabled: effectiveTeamsNotificationsEnabled,
276
336
  apiRateLimitWindowMs,
277
337
  apiRateLimitGeneralMax,
278
338
  apiRateLimitAuthMax,
@@ -295,6 +355,7 @@ export function parseRuntimeConfig(env, options = {}) {
295
355
  memoryTieringEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_TIERING_ENABLED", raw.CHAPTERHOUSE_MEMORY_TIERING_ENABLED, true),
296
356
  memoryHotRecallBoost,
297
357
  memoryHotAgeDays,
358
+ modeCompatibilityWarnings,
298
359
  };
299
360
  }
300
361
  const runtimeConfig = parseRuntimeConfig(process.env);
@@ -352,6 +413,7 @@ export const config = {
352
413
  memoryTieringEnabled: runtimeConfig.memoryTieringEnabled,
353
414
  memoryHotRecallBoost: runtimeConfig.memoryHotRecallBoost,
354
415
  memoryHotAgeDays: runtimeConfig.memoryHotAgeDays,
416
+ modeCompatibilityWarnings: runtimeConfig.modeCompatibilityWarnings,
355
417
  copilotAuthToken: runtimeConfig.copilotAuthToken,
356
418
  get copilotModel() {
357
419
  return _copilotModel;
@@ -14,6 +14,7 @@ test("parses Teams webhook settings", async () => {
14
14
  const configModule = await import("./config.js");
15
15
  assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
16
16
  const parsed = configModule.parseRuntimeConfig({
17
+ CHAPTERHOUSE_MODE: "team",
17
18
  TEAMS_WEBHOOK_URL: "https://teams.example.test/webhook",
18
19
  TEAMS_NOTIFICATIONS_ENABLED: "true",
19
20
  });
@@ -24,6 +25,7 @@ test("rejects Teams notifications without a webhook", async () => {
24
25
  const configModule = await import("./config.js");
25
26
  assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
26
27
  assert.throws(() => configModule.parseRuntimeConfig({
28
+ CHAPTERHOUSE_MODE: "team",
27
29
  TEAMS_NOTIFICATIONS_ENABLED: "true",
28
30
  }), /TEAMS_NOTIFICATIONS_ENABLED=true requires TEAMS_WEBHOOK_URL/);
29
31
  });
@@ -60,6 +62,7 @@ test("requires Entra tenant and client when auth is enabled", async () => {
60
62
  const configModule = await import("./config.js");
61
63
  assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
62
64
  assert.throws(() => configModule.parseRuntimeConfig({
65
+ CHAPTERHOUSE_MODE: "team",
63
66
  ENTRA_AUTH_ENABLED: "true",
64
67
  ENTRA_TENANT_ID: "tenant-id",
65
68
  }), /ENTRA_AUTH_ENABLED=true requires ENTRA_TENANT_ID and ENTRA_CLIENT_ID/);
@@ -268,4 +271,74 @@ test("rejects invalid SSE replay settings", async () => {
268
271
  CHAPTERHOUSE_SSE_BUFFER_CAPACITY: "0",
269
272
  }), /CHAPTERHOUSE_SSE_BUFFER_CAPACITY must be a positive integer/);
270
273
  });
274
+ test("personal mode starts cleanly without Entra env vars", async () => {
275
+ // Security: personal mode should boot in its intended unauthenticated configuration instead of requiring enterprise auth by accident.
276
+ const configModule = await import("./config.js");
277
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
278
+ const parsed = configModule.parseRuntimeConfig({
279
+ CHAPTERHOUSE_MODE: "personal",
280
+ });
281
+ assert.equal(parsed.chapterhouseMode, "personal");
282
+ assert.equal(parsed.entraAuthEnabled, false);
283
+ assert.doesNotMatch(parsed.modeCompatibilityWarnings.join("\n"), /Entra/i);
284
+ });
285
+ test("personal mode rejects explicit Entra auth enablement with a clear fix suggestion", async () => {
286
+ // Security: personal deployments must fail fast instead of starting in a broken auth state that looks protected but is not.
287
+ const configModule = await import("./config.js");
288
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
289
+ assert.throws(() => configModule.parseRuntimeConfig({
290
+ CHAPTERHOUSE_MODE: "personal",
291
+ ENTRA_AUTH_ENABLED: "true",
292
+ ENTRA_TENANT_ID: "tenant-id",
293
+ ENTRA_CLIENT_ID: "client-id",
294
+ }), /Personal mode cannot be used with ENTRA_AUTH_ENABLED=true[\s\S]*CHAPTERHOUSE_MODE=team[\s\S]*unset ENTRA_AUTH_ENABLED/);
295
+ });
296
+ test("team mode accepts Entra auth when required settings are present", async () => {
297
+ // Security: team mode is the only safe place for Entra auth, so valid enterprise settings must remain supported there.
298
+ const configModule = await import("./config.js");
299
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
300
+ const parsed = configModule.parseRuntimeConfig({
301
+ CHAPTERHOUSE_MODE: "team",
302
+ ENTRA_AUTH_ENABLED: "true",
303
+ ENTRA_TENANT_ID: "tenant-id",
304
+ ENTRA_CLIENT_ID: "client-id",
305
+ });
306
+ assert.equal(parsed.chapterhouseMode, "team");
307
+ assert.equal(parsed.entraAuthEnabled, true);
308
+ assert.equal(parsed.entraTenantId, "tenant-id");
309
+ assert.equal(parsed.entraClientId, "client-id");
310
+ });
311
+ test("personal mode still warns about leftover Entra settings when auth is not explicitly enabled", async () => {
312
+ // Security: leftover enterprise env vars should be called out so personal-mode operators know those settings are being ignored.
313
+ const configModule = await import("./config.js");
314
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
315
+ const parsed = configModule.parseRuntimeConfig({
316
+ CHAPTERHOUSE_MODE: "personal",
317
+ ENTRA_TENANT_ID: "tenant-only",
318
+ });
319
+ assert.equal(parsed.chapterhouseMode, "personal");
320
+ assert.equal(parsed.entraAuthEnabled, false);
321
+ assert.match(parsed.modeCompatibilityWarnings.join("\n"), /Entra/i);
322
+ });
323
+ test("personal mode warns and disables incomplete Teams notification settings instead of throwing", async () => {
324
+ // Security: personal mode should ignore team-only integrations without partially enabling cross-tenant notification flows.
325
+ const configModule = await import("./config.js");
326
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
327
+ const parsed = configModule.parseRuntimeConfig({
328
+ CHAPTERHOUSE_MODE: "personal",
329
+ TEAMS_NOTIFICATIONS_ENABLED: "true",
330
+ });
331
+ assert.equal(parsed.teamsNotificationsEnabled, false);
332
+ assert.match(parsed.modeCompatibilityWarnings.join("\n"), /Teams/i);
333
+ });
334
+ test("personal mode warns when Azure DevOps settings are only partially configured", async () => {
335
+ const configModule = await import("./config.js");
336
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
337
+ const parsed = configModule.parseRuntimeConfig({
338
+ CHAPTERHOUSE_MODE: "personal",
339
+ ADO_PAT: "test-pat",
340
+ });
341
+ assert.equal(parsed.adoPat, "test-pat");
342
+ assert.match(parsed.modeCompatibilityWarnings.join("\n"), /Azure DevOps/i);
343
+ });
271
344
  //# sourceMappingURL=config.test.js.map
@@ -0,0 +1,234 @@
1
+ import { getActiveScope } from "../memory/active-scope.js";
2
+ import { CheckpointTracker, isCheckpointInFlight, runCheckpointExtraction } from "../memory/checkpoint.js";
3
+ import { runEndOfTaskMemoryHook } from "../memory/eot.js";
4
+ import { getHotTierEntries, renderHotTierForActiveScope, renderHotTierXML } from "../memory/hot-tier.js";
5
+ import { isHousekeepingInFlight, runHousekeeping } from "../memory/housekeeping.js";
6
+ import { getScope } from "../memory/scopes.js";
7
+ import { config as defaultConfig } from "../config.js";
8
+ import { childLogger } from "../util/logger.js";
9
+ const log = childLogger("memory-coordinator");
10
+ const MAX_CHECKPOINT_CHARS_PER_SIDE = 4_000;
11
+ export class MemoryCoordinator {
12
+ checkpointTrackers = new Map();
13
+ checkpointTurnsBySession = new Map();
14
+ housekeepingTurnsBySession = new Map();
15
+ completedTaskIds = new Set();
16
+ getCopilotClient;
17
+ resolveScopeForSession;
18
+ config;
19
+ constructor(options) {
20
+ this.getCopilotClient = options.getCopilotClient;
21
+ this.resolveScopeForSession = options.resolveScopeForSession ?? (() => getActiveScope());
22
+ this.config = options.config ?? defaultConfig;
23
+ }
24
+ async onTurnComplete(sessionKey, prompt, response, source) {
25
+ const sourceType = this.normalizeSource(source);
26
+ if (sourceType === "background") {
27
+ return;
28
+ }
29
+ this.scheduleCheckpointExtraction(sessionKey, prompt, response);
30
+ this.scheduleHousekeeping(sessionKey);
31
+ }
32
+ async onScopeChange(sessionKey, prev, next) {
33
+ if (!prev) {
34
+ return;
35
+ }
36
+ const previousScope = getScope(prev) ?? null;
37
+ if (!previousScope) {
38
+ return;
39
+ }
40
+ if (!this.config.memoryCheckpointOnScopeChange) {
41
+ log.info({ sessionKey, scope: previousScope.slug }, "memory.checkpoint.scope_change_disabled");
42
+ return;
43
+ }
44
+ const tracker = this.getCheckpointTracker(sessionKey);
45
+ const turnsSinceLast = tracker.turnsSinceLastFire();
46
+ if (turnsSinceLast < this.config.memoryCheckpointMinTurnsForScopeFire) {
47
+ log.info({
48
+ sessionKey,
49
+ scope: previousScope.slug,
50
+ turns_since_last: turnsSinceLast,
51
+ min_required: this.config.memoryCheckpointMinTurnsForScopeFire,
52
+ }, "memory.checkpoint.scope_change_skip");
53
+ return;
54
+ }
55
+ if (isCheckpointInFlight(sessionKey)) {
56
+ log.info({ sessionKey, trigger: "scope_change" }, "memory.checkpoint.in_flight_skip");
57
+ return;
58
+ }
59
+ const copilotClient = this.getCopilotClient();
60
+ if (!copilotClient) {
61
+ log.error({ sessionKey }, "memory.checkpoint.error");
62
+ return;
63
+ }
64
+ const turns = this.checkpointTurnsBySession.get(sessionKey) ?? [];
65
+ if (turns.length === 0) {
66
+ log.info({
67
+ sessionKey,
68
+ scope: previousScope.slug,
69
+ turns_since_last: turnsSinceLast,
70
+ min_required: this.config.memoryCheckpointMinTurnsForScopeFire,
71
+ }, "memory.checkpoint.scope_change_skip");
72
+ return;
73
+ }
74
+ tracker.markScopeChangeFire();
75
+ const nextScope = next ? (getScope(next) ?? null) : null;
76
+ void runCheckpointExtraction({
77
+ sessionKey,
78
+ turns: turns.slice(-this.config.memoryCheckpointTurns),
79
+ activeScope: previousScope,
80
+ copilotClient,
81
+ trigger: "scope_change",
82
+ scopeChangeContext: {
83
+ from: previousScope.slug,
84
+ to: nextScope?.slug ?? "no active scope",
85
+ },
86
+ }).catch((error) => {
87
+ log.error({ err: error, sessionKey }, "memory.checkpoint.error");
88
+ });
89
+ }
90
+ async buildHotTierContext(sessionKey) {
91
+ if (!this.config.memoryInjectEnabled) {
92
+ return "";
93
+ }
94
+ const scope = this.resolveScopeForSession(sessionKey);
95
+ if (!scope) {
96
+ return "";
97
+ }
98
+ const activeScope = getActiveScope();
99
+ const hotTierXml = activeScope?.id === scope.id
100
+ ? renderHotTierForActiveScope()
101
+ : renderHotTierXML(getHotTierEntries(scope.id));
102
+ return hotTierXml ? hotTierXml.trimEnd() : "";
103
+ }
104
+ buildPerTurnHooks(sessionKey) {
105
+ if (!this.config.memoryInjectEnabled) {
106
+ return undefined;
107
+ }
108
+ const hooks = {
109
+ onUserPromptSubmitted: async () => {
110
+ const hotTierXml = await this.buildHotTierContext(sessionKey);
111
+ return hotTierXml ? { additionalContext: hotTierXml } : undefined;
112
+ },
113
+ };
114
+ return hooks;
115
+ }
116
+ async onAgentTaskComplete(taskId, result) {
117
+ if (this.completedTaskIds.has(taskId)) {
118
+ log.info({ taskId }, "memory.eot.duplicate_skip");
119
+ return;
120
+ }
121
+ this.completedTaskIds.add(taskId);
122
+ const copilotClient = this.getCopilotClient();
123
+ if (!copilotClient) {
124
+ return;
125
+ }
126
+ const finalResult = typeof result === "string" ? result : result == null ? "" : String(result);
127
+ await runEndOfTaskMemoryHook({
128
+ taskId,
129
+ finalResult,
130
+ copilotClient,
131
+ });
132
+ }
133
+ reset(sessionKey) {
134
+ this.getCheckpointTracker(sessionKey).reset();
135
+ this.checkpointTurnsBySession.delete(sessionKey);
136
+ this.housekeepingTurnsBySession.delete(sessionKey);
137
+ this.completedTaskIds.clear();
138
+ }
139
+ shutdown() {
140
+ this.checkpointTrackers.clear();
141
+ this.checkpointTurnsBySession.clear();
142
+ this.housekeepingTurnsBySession.clear();
143
+ this.completedTaskIds.clear();
144
+ }
145
+ normalizeSource(source) {
146
+ return source === "background" ? "background" : source === "sse-web" ? "sse-web" : "web";
147
+ }
148
+ truncateCheckpointText(value) {
149
+ const trimmed = value.trim();
150
+ if (trimmed.length <= MAX_CHECKPOINT_CHARS_PER_SIDE) {
151
+ return trimmed;
152
+ }
153
+ return `${trimmed.slice(0, MAX_CHECKPOINT_CHARS_PER_SIDE)}…`;
154
+ }
155
+ getCheckpointTracker(sessionKey) {
156
+ let tracker = this.checkpointTrackers.get(sessionKey);
157
+ if (!tracker) {
158
+ tracker = new CheckpointTracker();
159
+ this.checkpointTrackers.set(sessionKey, tracker);
160
+ }
161
+ return tracker;
162
+ }
163
+ appendCheckpointTurn(sessionKey, turn) {
164
+ const turns = this.checkpointTurnsBySession.get(sessionKey) ?? [];
165
+ turns.push(turn);
166
+ const overflow = turns.length - this.config.memoryCheckpointTurns;
167
+ if (overflow > 0) {
168
+ turns.splice(0, overflow);
169
+ }
170
+ this.checkpointTurnsBySession.set(sessionKey, turns);
171
+ return turns;
172
+ }
173
+ scheduleCheckpointExtraction(sessionKey, prompt, response) {
174
+ const tracker = this.getCheckpointTracker(sessionKey);
175
+ const turns = this.appendCheckpointTurn(sessionKey, {
176
+ user: this.truncateCheckpointText(prompt),
177
+ assistant: this.truncateCheckpointText(response),
178
+ });
179
+ if (!this.config.memoryCheckpointEnabled) {
180
+ log.info({ sessionKey }, "memory.checkpoint.disabled");
181
+ return;
182
+ }
183
+ tracker.tickOrchestratorTurn();
184
+ if (!tracker.shouldFire()) {
185
+ return;
186
+ }
187
+ tracker.markFired();
188
+ if (isCheckpointInFlight(sessionKey)) {
189
+ log.info({ sessionKey }, "memory.checkpoint.in_flight_skip");
190
+ return;
191
+ }
192
+ const copilotClient = this.getCopilotClient();
193
+ if (!copilotClient) {
194
+ log.error({ sessionKey }, "memory.checkpoint.error");
195
+ return;
196
+ }
197
+ const activeScope = this.resolveScopeForSession(sessionKey);
198
+ void runCheckpointExtraction({
199
+ sessionKey,
200
+ turns: turns.slice(-this.config.memoryCheckpointTurns),
201
+ activeScope,
202
+ copilotClient,
203
+ trigger: "cadence",
204
+ }).catch((error) => {
205
+ log.error({ err: error, sessionKey }, "memory.checkpoint.error");
206
+ });
207
+ }
208
+ scheduleHousekeeping(sessionKey) {
209
+ if (!this.config.memoryHousekeepingEnabled) {
210
+ log.info({ sessionKey }, "memory.housekeeping.disabled");
211
+ return;
212
+ }
213
+ const turns = (this.housekeepingTurnsBySession.get(sessionKey) ?? 0) + 1;
214
+ if (turns < this.config.memoryHousekeepingTurns) {
215
+ this.housekeepingTurnsBySession.set(sessionKey, turns);
216
+ return;
217
+ }
218
+ this.housekeepingTurnsBySession.set(sessionKey, 0);
219
+ const activeScope = this.resolveScopeForSession(sessionKey);
220
+ if (!activeScope) {
221
+ log.info({ sessionKey }, "memory.housekeeping.no_active_scope");
222
+ return;
223
+ }
224
+ const scopeIds = [activeScope.id];
225
+ if (isHousekeepingInFlight(scopeIds)) {
226
+ log.info({ sessionKey, scope_ids: scopeIds }, "memory.housekeeping.in_flight_skip");
227
+ return;
228
+ }
229
+ void runHousekeeping({ scopeIds }).catch((error) => {
230
+ log.error({ err: error, sessionKey, scope_ids: scopeIds }, "memory.housekeeping.error");
231
+ });
232
+ }
233
+ }
234
+ //# sourceMappingURL=memory-coordinator.js.map