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
@@ -3,16 +3,17 @@ import { config } from "../config.js";
3
3
  import { getDb } from "../store/db.js";
4
4
  import { childLogger } from "../util/logger.js";
5
5
  import { getActiveScope } from "./active-scope.js";
6
+ import { releaseScopeWriteLocks, tryAcquireScopeWriteLocks } from "./scope-lock.js";
6
7
  import { listScopes } from "./scopes.js";
7
8
  import { tieringPass } from "./tiering.js";
8
9
  export { tieringPass };
9
10
  const log = childLogger("memory.housekeeping");
10
- const SIMILARITY_THRESHOLD = 0.8;
11
11
  const GLOBAL_PASS_SCOPE = Symbol("global-housekeeping-pass-scope");
12
12
  const inFlightScopesByPass = new Map();
13
13
  const PASS_ORDER = [
14
14
  "dedup_observations",
15
15
  "dedup_decisions",
16
+ "compact_supersede_chains",
16
17
  "orphan_cleanup",
17
18
  "decay",
18
19
  "compact_inbox",
@@ -48,7 +49,7 @@ function isSimilar(left, right) {
48
49
  if (left.trim().toLowerCase() === right.trim().toLowerCase()) {
49
50
  return true;
50
51
  }
51
- return jaccard(left, right) >= SIMILARITY_THRESHOLD;
52
+ return jaccard(left, right) >= config.memoryHousekeepingSimilarityThreshold;
52
53
  }
53
54
  function compareObservationKeeper(left, right) {
54
55
  if (right.confidence !== left.confidence) {
@@ -79,6 +80,11 @@ function normalizePassName(pass) {
79
80
  dedupdecisions: "dedup_decisions",
80
81
  dedupdecisionspass: "dedup_decisions",
81
82
  decisions: "dedup_decisions",
83
+ compact_supersede_chains: "compact_supersede_chains",
84
+ compactsupersedechains: "compact_supersede_chains",
85
+ compactsupersedechainspass: "compact_supersede_chains",
86
+ supersede: "compact_supersede_chains",
87
+ supersedechains: "compact_supersede_chains",
82
88
  orphan_cleanup: "orphan_cleanup",
83
89
  orphancleanup: "orphan_cleanup",
84
90
  orphancleanuppass: "orphan_cleanup",
@@ -189,6 +195,83 @@ export function dedupDecisionsPass(scopeId) {
189
195
  return passSummary("dedupDecisionsPass", 0, 0, [error instanceof Error ? error.message : String(error)]);
190
196
  }
191
197
  }
198
+ function resolveSupersedeTerminal(startId, firstTargetId, supersededBy) {
199
+ const seen = new Set([startId]);
200
+ let current = firstTargetId;
201
+ while (true) {
202
+ if (seen.has(current)) {
203
+ return null;
204
+ }
205
+ seen.add(current);
206
+ const next = supersededBy.get(current);
207
+ if (next === undefined) {
208
+ return firstTargetId;
209
+ }
210
+ if (next === null) {
211
+ return current;
212
+ }
213
+ current = next;
214
+ }
215
+ }
216
+ export function compactSupersedeChainsPass(scopeId) {
217
+ try {
218
+ const db = getDb();
219
+ const observationRows = db.prepare(`
220
+ SELECT id, superseded_by
221
+ FROM mem_observations
222
+ WHERE scope_id = ?
223
+ AND superseded_by IS NOT NULL
224
+ ORDER BY id ASC
225
+ `).all(scopeId);
226
+ const decisionRows = db.prepare(`
227
+ SELECT id, superseded_by
228
+ FROM mem_decisions
229
+ WHERE scope_id = ?
230
+ AND superseded_by IS NOT NULL
231
+ ORDER BY id ASC
232
+ `).all(scopeId);
233
+ let modified = 0;
234
+ const tx = db.transaction(() => {
235
+ for (const { table, rows } of [
236
+ { table: "mem_observations", rows: observationRows },
237
+ { table: "mem_decisions", rows: decisionRows },
238
+ ]) {
239
+ const targetIds = [...new Set(rows.map((row) => row.superseded_by))];
240
+ const activeTargets = targetIds.length === 0
241
+ ? undefined
242
+ : db.prepare(`
243
+ SELECT id, superseded_by
244
+ FROM ${table}
245
+ WHERE scope_id = ?
246
+ AND id IN (${targetIds.map(() => "?").join(",")})
247
+ `);
248
+ const targets = targetIds.length === 0
249
+ ? []
250
+ : activeTargets.all(scopeId, ...targetIds);
251
+ const supersededBy = new Map();
252
+ for (const row of rows) {
253
+ supersededBy.set(row.id, row.superseded_by);
254
+ }
255
+ for (const target of targets) {
256
+ supersededBy.set(target.id, target.superseded_by);
257
+ }
258
+ const update = db.prepare(`UPDATE ${table} SET superseded_by = ? WHERE id = ? AND scope_id = ?`);
259
+ for (const row of rows) {
260
+ const terminal = resolveSupersedeTerminal(row.id, row.superseded_by, supersededBy);
261
+ if (terminal !== null && terminal !== row.superseded_by) {
262
+ modified += update.run(terminal, row.id, scopeId).changes;
263
+ supersededBy.set(row.id, terminal);
264
+ }
265
+ }
266
+ }
267
+ });
268
+ tx();
269
+ return passSummary("compactSupersedeChainsPass", observationRows.length + decisionRows.length, modified);
270
+ }
271
+ catch (error) {
272
+ return passSummary("compactSupersedeChainsPass", 0, 0, [error instanceof Error ? error.message : String(error)]);
273
+ }
274
+ }
192
275
  export function orphanCleanupPass(scopeId) {
193
276
  try {
194
277
  const db = getDb();
@@ -326,6 +409,8 @@ async function runPass(pass, scopeId) {
326
409
  return await Promise.resolve(dedupObservationsPass(scopeId));
327
410
  case "dedup_decisions":
328
411
  return await Promise.resolve(dedupDecisionsPass(scopeId));
412
+ case "compact_supersede_chains":
413
+ return await Promise.resolve(compactSupersedeChainsPass(scopeId));
329
414
  case "orphan_cleanup":
330
415
  return await Promise.resolve(orphanCleanupPass(scopeId));
331
416
  case "decay":
@@ -374,8 +459,19 @@ export async function runHousekeeping(opts = {}) {
374
459
  durationMs: 0,
375
460
  };
376
461
  }
462
+ const scopedPasses = passes.filter((pass) => pass !== "compact_inbox");
463
+ const lockedScopeIds = scopedPasses.length > 0 ? scopeIds : [];
464
+ if (lockedScopeIds.length > 0 && !tryAcquireScopeWriteLocks(lockedScopeIds)) {
465
+ releasePassScopes(reservedPassScopes);
466
+ return {
467
+ scopeIds,
468
+ summaries: [passSummary("runHousekeeping", 0, 0, ["Memory writes are already in flight for this scope."])],
469
+ totalExamined: 0,
470
+ totalModified: 0,
471
+ durationMs: 0,
472
+ };
473
+ }
377
474
  try {
378
- const scopedPasses = passes.filter((pass) => pass !== "compact_inbox");
379
475
  const hasCompactInbox = passes.includes("compact_inbox");
380
476
  const summaries = (await Promise.all(scopeIds.map((scopeId) => runScopePasses(scopeId, scopedPasses)))).flat();
381
477
  if (hasCompactInbox) {
@@ -394,6 +490,7 @@ export async function runHousekeeping(opts = {}) {
394
490
  return { scopeIds, summaries, totalExamined, totalModified, durationMs };
395
491
  }
396
492
  finally {
493
+ releaseScopeWriteLocks(lockedScopeIds);
397
494
  releasePassScopes(reservedPassScopes);
398
495
  }
399
496
  }
@@ -25,6 +25,7 @@ async function loadMockedHousekeepingModule(t, options = {}) {
25
25
  config: {
26
26
  memoryDecayDays: 30,
27
27
  memoryInboxRetentionDays: 7,
28
+ memoryHousekeepingSimilarityThreshold: 0.8,
28
29
  },
29
30
  },
30
31
  });
@@ -136,7 +137,6 @@ test("dedupObservationsPass supersedes similar observations in scope determinist
136
137
  test("dedupDecisionsPass supersedes similar active decisions within scope and keeps the latest decision", async () => {
137
138
  const { dbModule, memoryModule, housekeepingModule } = await loadModules();
138
139
  const db = dbModule.getDb();
139
- const getScope = getFunction(memoryModule, "getScope");
140
140
  const upsertEntity = getFunction(memoryModule, "upsertEntity");
141
141
  const recordDecision = getFunction(memoryModule, "recordDecision");
142
142
  const team = createTestScope(memoryModule, "team");
@@ -182,6 +182,38 @@ test("dedupDecisionsPass supersedes similar active decisions within scope and ke
182
182
  const second = housekeepingModule.dedupDecisionsPass(team.id);
183
183
  assert.equal(second.modified, 0);
184
184
  });
185
+ test("compactSupersedeChainsPass collapses long supersede chains without looping on cycles", async () => {
186
+ const { dbModule, memoryModule, housekeepingModule } = await loadModules();
187
+ const db = dbModule.getDb();
188
+ const recordObservation = getFunction(memoryModule, "recordObservation");
189
+ const recordDecision = getFunction(memoryModule, "recordDecision");
190
+ const team = createTestScope(memoryModule, "team");
191
+ const firstObservation = recordObservation({ scope_id: team.id, content: "First observation", source: "test" });
192
+ const middleObservation = recordObservation({ scope_id: team.id, content: "Middle observation", source: "test" });
193
+ const keeperObservation = recordObservation({ scope_id: team.id, content: "Keeper observation", source: "test" });
194
+ const cycleLeft = recordObservation({ scope_id: team.id, content: "Cycle left", source: "test" });
195
+ const cycleRight = recordObservation({ scope_id: team.id, content: "Cycle right", source: "test" });
196
+ db.prepare(`UPDATE mem_observations SET superseded_by = ? WHERE id = ?`).run(middleObservation.id, firstObservation.id);
197
+ db.prepare(`UPDATE mem_observations SET superseded_by = ? WHERE id = ?`).run(keeperObservation.id, middleObservation.id);
198
+ db.prepare(`UPDATE mem_observations SET superseded_by = ? WHERE id = ?`).run(cycleRight.id, cycleLeft.id);
199
+ db.prepare(`UPDATE mem_observations SET superseded_by = ? WHERE id = ?`).run(cycleLeft.id, cycleRight.id);
200
+ const firstDecision = recordDecision({ scope_id: team.id, title: "Use local memory", rationale: "Initial.", decided_at: "2026-05-10" });
201
+ const middleDecision = recordDecision({ scope_id: team.id, title: "Use scoped memory", rationale: "Refined.", decided_at: "2026-05-11" });
202
+ const keeperDecision = recordDecision({ scope_id: team.id, title: "Use tiered scoped memory", rationale: "Final.", decided_at: "2026-05-12" });
203
+ db.prepare(`UPDATE mem_decisions SET superseded_by = ? WHERE id = ?`).run(middleDecision.id, firstDecision.id);
204
+ db.prepare(`UPDATE mem_decisions SET superseded_by = ? WHERE id = ?`).run(keeperDecision.id, middleDecision.id);
205
+ const summary = housekeepingModule.compactSupersedeChainsPass(team.id);
206
+ assert.equal(summary.pass, "compactSupersedeChainsPass");
207
+ assert.equal(summary.modified, 2);
208
+ assert.deepEqual(summary.errors, []);
209
+ assert.equal(db.prepare(`SELECT superseded_by FROM mem_observations WHERE id = ?`).get(firstObservation.id).superseded_by, keeperObservation.id);
210
+ assert.equal(db.prepare(`SELECT superseded_by FROM mem_decisions WHERE id = ?`).get(firstDecision.id).superseded_by, keeperDecision.id);
211
+ assert.deepEqual(db.prepare(`SELECT id, superseded_by FROM mem_observations WHERE id IN (?, ?) ORDER BY id`).all(cycleLeft.id, cycleRight.id), [
212
+ { id: cycleLeft.id, superseded_by: cycleRight.id },
213
+ { id: cycleRight.id, superseded_by: cycleLeft.id },
214
+ ]);
215
+ assert.equal(housekeepingModule.compactSupersedeChainsPass(team.id).modified, 0);
216
+ });
185
217
  test("orphanCleanupPass clears missing observation entity references without touching valid or out-of-scope rows", async () => {
186
218
  const { dbModule, memoryModule, housekeepingModule } = await loadModules();
187
219
  const db = dbModule.getDb();
@@ -321,7 +353,6 @@ test("runHousekeeping rejects overlapping runs that share an in-flight scope", a
321
353
  test("tieringPass promotes and demotes rows from lifecycle signals and is idempotent", async () => {
322
354
  const { dbModule, memoryModule, housekeepingModule } = await loadModules();
323
355
  const db = dbModule.getDb();
324
- const getScope = getFunction(memoryModule, "getScope");
325
356
  const upsertEntity = getFunction(memoryModule, "upsertEntity");
326
357
  const recordObservation = getFunction(memoryModule, "recordObservation");
327
358
  const recordDecision = getFunction(memoryModule, "recordDecision");
@@ -29,6 +29,7 @@ async function loadReflectModule(t, llmResponse) {
29
29
  });
30
30
  t.mock.module("../util/logger.js", {
31
31
  namedExports: {
32
+ logger: { info: () => { }, warn: () => { }, error: () => { } },
32
33
  childLogger: () => ({ info: () => { }, warn: () => { }, error: () => { } }),
33
34
  },
34
35
  });
@@ -61,6 +62,7 @@ async function loadToolsModule(t) {
61
62
  });
62
63
  t.mock.module("../util/logger.js", {
63
64
  namedExports: {
65
+ logger: { info: () => { }, warn: () => { }, error: () => { } },
64
66
  childLogger: () => ({ info: () => { }, warn: () => { }, error: () => { } }),
65
67
  },
66
68
  });
@@ -0,0 +1,26 @@
1
+ const inFlightScopeWrites = new Set();
2
+ function uniqueScopeIds(scopeIds) {
3
+ return [...new Set(scopeIds)].sort((left, right) => left - right);
4
+ }
5
+ export function tryAcquireScopeWriteLocks(scopeIds) {
6
+ const uniqueScopeIdsToAcquire = uniqueScopeIds(scopeIds);
7
+ if (uniqueScopeIdsToAcquire.some((scopeId) => inFlightScopeWrites.has(scopeId))) {
8
+ return false;
9
+ }
10
+ for (const scopeId of uniqueScopeIdsToAcquire) {
11
+ inFlightScopeWrites.add(scopeId);
12
+ }
13
+ return true;
14
+ }
15
+ export function releaseScopeWriteLocks(scopeIds) {
16
+ for (const scopeId of uniqueScopeIds(scopeIds)) {
17
+ inFlightScopeWrites.delete(scopeId);
18
+ }
19
+ }
20
+ export function isScopeWriteLocked(scopeIds) {
21
+ if (!scopeIds || scopeIds.length === 0) {
22
+ return inFlightScopeWrites.size > 0;
23
+ }
24
+ return uniqueScopeIds(scopeIds).some((scopeId) => inFlightScopeWrites.has(scopeId));
25
+ }
26
+ //# sourceMappingURL=scope-lock.js.map
@@ -0,0 +1,118 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ const sharedScope = {
4
+ id: 11,
5
+ slug: "shared",
6
+ title: "Shared",
7
+ description: "Shared scope for lock tests.",
8
+ keywords: ["shared"],
9
+ active: true,
10
+ createdAt: new Date().toISOString(),
11
+ updatedAt: new Date().toISOString(),
12
+ };
13
+ async function loadModules(t, tieringPass) {
14
+ t.mock.module("../config.js", {
15
+ namedExports: {
16
+ config: {
17
+ copilotModel: "test-model",
18
+ memoryDecayDays: 30,
19
+ memoryInboxRetentionDays: 7,
20
+ memoryCheckpointTurns: 5,
21
+ memoryCheckpointEnabled: true,
22
+ },
23
+ },
24
+ });
25
+ t.mock.module("../copilot/oneshot.js", {
26
+ namedExports: {
27
+ runOneShotPrompt: async () => ({ content: JSON.stringify({ proposals: [] }) }),
28
+ },
29
+ });
30
+ t.mock.module("../store/db.js", {
31
+ namedExports: {
32
+ getDb: () => {
33
+ throw new Error("getDb should not be called in this test");
34
+ },
35
+ },
36
+ });
37
+ t.mock.module("../util/logger.js", {
38
+ namedExports: {
39
+ childLogger: () => ({
40
+ info: () => { },
41
+ warn: () => { },
42
+ error: () => { },
43
+ }),
44
+ },
45
+ });
46
+ t.mock.module("./active-scope.js", {
47
+ namedExports: {
48
+ getActiveScope: () => sharedScope,
49
+ },
50
+ });
51
+ t.mock.module("./scopes.js", {
52
+ namedExports: {
53
+ getScope: (slug) => (slug === sharedScope.slug ? sharedScope : null),
54
+ listScopes: () => [{ id: sharedScope.id, active: true }],
55
+ },
56
+ });
57
+ t.mock.module("./entities.js", {
58
+ namedExports: {
59
+ listEntities: () => [],
60
+ },
61
+ });
62
+ t.mock.module("./decisions.js", {
63
+ namedExports: {
64
+ listDecisions: () => [],
65
+ recordDecision: () => ({ id: 1 }),
66
+ },
67
+ });
68
+ t.mock.module("./observations.js", {
69
+ namedExports: {
70
+ listObservations: () => [],
71
+ recordObservation: () => ({ id: 1 }),
72
+ },
73
+ });
74
+ t.mock.module("./checkpoint-prompt.js", {
75
+ namedExports: {
76
+ buildCheckpointSystemPrompt: () => "system",
77
+ buildCheckpointUserPrompt: () => "user",
78
+ },
79
+ });
80
+ t.mock.module("./tiering.js", {
81
+ namedExports: {
82
+ tieringPass,
83
+ },
84
+ });
85
+ const checkpointModule = await import(new URL(`./checkpoint.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
86
+ const housekeepingModule = await import(new URL(`./housekeeping.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
87
+ return { checkpointModule, housekeepingModule };
88
+ }
89
+ test("runCheckpointExtraction skips writes while housekeeping holds the same scope lock", async (t) => {
90
+ let releaseHousekeeping;
91
+ const { checkpointModule, housekeepingModule } = await loadModules(t, async (scopeId) => {
92
+ assert.equal(scopeId, sharedScope.id);
93
+ await new Promise((resolve) => {
94
+ releaseHousekeeping = resolve;
95
+ });
96
+ return { pass: `tieringPass:${scopeId}`, examined: 1, modified: 1, errors: [] };
97
+ });
98
+ const housekeepingRun = housekeepingModule.runHousekeeping({ scopeIds: [sharedScope.id], passes: ["tiering"] });
99
+ await Promise.resolve();
100
+ const checkpointResult = await checkpointModule.runCheckpointExtraction({
101
+ turns: [{ user: "Remember this.", assistant: "Okay." }],
102
+ activeScope: sharedScope,
103
+ copilotClient: {},
104
+ callLLM: async () => JSON.stringify({
105
+ proposals: [
106
+ {
107
+ kind: "observation",
108
+ content: "Shared scope writes should not overlap housekeeping.",
109
+ confidence: 0.95,
110
+ },
111
+ ],
112
+ }),
113
+ });
114
+ assert.deepEqual(checkpointResult, { written: 0, skipped: 0, errors: [] });
115
+ releaseHousekeeping?.();
116
+ await housekeepingRun;
117
+ });
118
+ //# sourceMappingURL=scope-lock.test.js.map
@@ -5,7 +5,6 @@ import test from "node:test";
5
5
  const repoRoot = process.cwd();
6
6
  const sandboxRoot = join(repoRoot, ".test-work", `memory-scopes-${process.pid}`);
7
7
  const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
8
- const dbPath = join(chapterhouseHome, "chapterhouse.db");
9
8
  process.env.CHAPTERHOUSE_HOME = sandboxRoot;
10
9
  async function loadModules() {
11
10
  const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
@@ -1,3 +1,15 @@
1
+ const CAPABILITY_MATRIX = {
2
+ personal: {
3
+ deploymentMode: "personal",
4
+ writePolicy: "personal",
5
+ integrationPolicy: "personal",
6
+ },
7
+ team: {
8
+ deploymentMode: "team",
9
+ writePolicy: "team-lead",
10
+ integrationPolicy: "team",
11
+ },
12
+ };
1
13
  export class ModeContext {
2
14
  config;
3
15
  constructor(config) {
@@ -10,19 +22,60 @@ export class ModeContext {
10
22
  return this.config.chapterhouseMode === "team";
11
23
  }
12
24
  canSyncTeamWiki() {
13
- return !this.config.standaloneMode && this.config.teamChapterhouseUrl.length > 0;
25
+ return this.getCapabilities().wikiSyncMode === "local+team";
14
26
  }
15
27
  canLogToAdo() {
16
- return this.isPersonal();
28
+ return this.getCapabilities().integrationPolicy === "personal";
17
29
  }
18
30
  canSyncToTeams() {
19
- return this.config.teamsNotificationsEnabled && this.config.teamsWebhookUrl.length > 0;
31
+ return (this.getCapabilities().integrationPolicy === "team" &&
32
+ this.config.teamsNotificationsEnabled &&
33
+ this.config.teamsWebhookUrl.length > 0);
20
34
  }
21
35
  getMemorySyncCapability() {
22
- return this.canSyncTeamWiki() ? "local+team" : "local";
36
+ return this.getCapabilities().wikiSyncMode;
23
37
  }
24
38
  requiresEntraAuth() {
25
- return this.isTeam() || this.config.entraAuthEnabled;
39
+ return this.getCapabilities().authMode === "entra";
40
+ }
41
+ getCapabilities() {
42
+ const modeCapabilities = CAPABILITY_MATRIX[this.config.chapterhouseMode];
43
+ if (!modeCapabilities) {
44
+ throw new Error(`Unknown CHAPTERHOUSE_MODE: "${this.config.chapterhouseMode}"`);
45
+ }
46
+ const authMode = this.getAuthMode();
47
+ return {
48
+ ...modeCapabilities,
49
+ authMode,
50
+ wikiSyncMode: this.getWikiSyncMode(authMode),
51
+ };
52
+ }
53
+ getStartupCapabilitySummary() {
54
+ const capabilities = this.getCapabilities();
55
+ return [
56
+ `deployment=${capabilities.deploymentMode}`,
57
+ `auth=${capabilities.authMode}`,
58
+ `wiki=${capabilities.wikiSyncMode}`,
59
+ `writes=${capabilities.writePolicy}`,
60
+ `integrations=${capabilities.integrationPolicy}`,
61
+ ].join(" ");
62
+ }
63
+ getAuthMode() {
64
+ if (this.config.standaloneMode) {
65
+ return "standalone";
66
+ }
67
+ if (this.config.entraAuthEnabled) {
68
+ return "entra";
69
+ }
70
+ return "api-token";
71
+ }
72
+ getWikiSyncMode(authMode) {
73
+ if (this.config.chapterhouseMode === "team" &&
74
+ authMode !== "standalone" &&
75
+ this.config.teamChapterhouseUrl.length > 0) {
76
+ return "local+team";
77
+ }
78
+ return "local";
26
79
  }
27
80
  }
28
81
  //# sourceMappingURL=mode-context.js.map
@@ -39,4 +39,72 @@ test("ModeContext exposes the expected team-mode capabilities", async () => {
39
39
  assert.equal(context.getMemorySyncCapability(), "local+team");
40
40
  assert.equal(context.requiresEntraAuth(), true);
41
41
  });
42
+ test("ModeContext uses an explicit personal/team capability matrix", async () => {
43
+ const configModule = await import("./config.js");
44
+ assert.equal(typeof configModule.ModeContext, "function", "ModeContext should be exported");
45
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
46
+ const parseRuntimeConfig = configModule.parseRuntimeConfig;
47
+ const createContext = (runtimeConfig) => new configModule.ModeContext(runtimeConfig);
48
+ const parsedPersonalWithTeamSettings = parseRuntimeConfig({
49
+ CHAPTERHOUSE_MODE: "personal",
50
+ API_TOKEN: "personal-token",
51
+ TEAM_CHAPTERHOUSE_URL: "https://team.example.com",
52
+ TEAMS_WEBHOOK_URL: "https://teams.example.com/webhook",
53
+ TEAMS_NOTIFICATIONS_ENABLED: "true",
54
+ ADO_ORG: "https://dev.azure.com/example",
55
+ ADO_PROJECT: "Project",
56
+ ADO_PAT: "personal-pat",
57
+ });
58
+ const personalWithTeamSettings = createContext({
59
+ ...parsedPersonalWithTeamSettings,
60
+ teamsWebhookUrl: "https://teams.example.com/webhook",
61
+ teamsNotificationsEnabled: true,
62
+ });
63
+ assert.deepEqual(personalWithTeamSettings.getCapabilities(), {
64
+ authMode: "api-token",
65
+ deploymentMode: "personal",
66
+ wikiSyncMode: "local",
67
+ writePolicy: "personal",
68
+ integrationPolicy: "personal",
69
+ });
70
+ assert.equal(personalWithTeamSettings.canSyncTeamWiki(), false);
71
+ assert.equal(personalWithTeamSettings.canSyncToTeams(), false);
72
+ assert.equal(personalWithTeamSettings.canLogToAdo(), true);
73
+ assert.equal(personalWithTeamSettings.requiresEntraAuth(), false);
74
+ const teamWithApiToken = createContext(parseRuntimeConfig({
75
+ CHAPTERHOUSE_MODE: "team",
76
+ API_TOKEN: "team-token",
77
+ TEAM_CHAPTERHOUSE_URL: "https://team.example.com",
78
+ ADO_ORG: "https://dev.azure.com/example",
79
+ ADO_PROJECT: "Project",
80
+ ADO_PAT: "team-pat",
81
+ }));
82
+ assert.deepEqual(teamWithApiToken.getCapabilities(), {
83
+ authMode: "api-token",
84
+ deploymentMode: "team",
85
+ wikiSyncMode: "local+team",
86
+ writePolicy: "team-lead",
87
+ integrationPolicy: "team",
88
+ });
89
+ assert.equal(teamWithApiToken.canSyncTeamWiki(), true);
90
+ assert.equal(teamWithApiToken.canLogToAdo(), false);
91
+ assert.equal(teamWithApiToken.requiresEntraAuth(), false);
92
+ assert.match(teamWithApiToken.getStartupCapabilitySummary(), /auth=api-token/);
93
+ assert.match(teamWithApiToken.getStartupCapabilitySummary(), /wiki=local\+team/);
94
+ const teamStandalone = createContext(parseRuntimeConfig({
95
+ CHAPTERHOUSE_MODE: "team",
96
+ TEAM_CHAPTERHOUSE_URL: "https://team.example.com",
97
+ }, {
98
+ apiTokenPath: ".test-work/missing-token",
99
+ exists: () => false,
100
+ }));
101
+ assert.deepEqual(teamStandalone.getCapabilities(), {
102
+ authMode: "standalone",
103
+ deploymentMode: "team",
104
+ wikiSyncMode: "local",
105
+ writePolicy: "team-lead",
106
+ integrationPolicy: "team",
107
+ });
108
+ assert.equal(teamStandalone.canSyncTeamWiki(), false);
109
+ });
42
110
  //# sourceMappingURL=mode-context.test.js.map
package/dist/paths.js CHANGED
@@ -4,6 +4,7 @@ import { mkdirSync } from "fs";
4
4
  import { normalizeWikiPath } from "./wiki/path-utils.js";
5
5
  /** Base directory for all Chapterhouse user data: ~/.chapterhouse */
6
6
  function resolveChapterhouseHome() {
7
+ // Bootstrap exception: config.ts depends on paths.ts to find ~/.chapterhouse/.env.
7
8
  const configuredHome = process.env.CHAPTERHOUSE_HOME?.trim();
8
9
  if (!configuredHome) {
9
10
  return join(homedir(), ".chapterhouse");
package/dist/setup.js CHANGED
@@ -4,6 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { CopilotClient } from "@github/copilot-sdk";
5
5
  import { CHAPTERHOUSE_HOME, ensureChapterhouseHome, ENV_PATH, WIKI_DIR } from "./paths.js";
6
6
  import { getExampleProjectPath } from "./home-path.js";
7
+ import { config } from "./config.js";
7
8
  const BOLD = "\x1b[1m";
8
9
  const DIM = "\x1b[2m";
9
10
  const GREEN = "\x1b[32m";
@@ -76,7 +77,7 @@ function readExistingEnv() {
76
77
  return { lines, values };
77
78
  }
78
79
  function resolveSetupMode(existing) {
79
- const configured = process.env.CHAPTERHOUSE_MODE?.trim() || existing.CHAPTERHOUSE_MODE?.trim() || "personal";
80
+ const configured = config.explicitChapterhouseMode || existing.CHAPTERHOUSE_MODE?.trim() || "personal";
80
81
  return configured === "team" ? "team" : "personal";
81
82
  }
82
83
  function upsertEnvLines(lines, updates) {
@@ -101,7 +102,7 @@ function upsertEnvLines(lines, updates) {
101
102
  return nextLines;
102
103
  }
103
104
  function hasTokenEnv() {
104
- return Boolean(process.env.GITHUB_TOKEN?.trim() || process.env.COPILOT_TOKEN?.trim());
105
+ return Boolean(config.copilotAuthToken);
105
106
  }
106
107
  function getGhAuthStatus() {
107
108
  try {