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
@@ -6,6 +6,7 @@ import { join } from "path";
6
6
  import { homedir } from "os";
7
7
  import { listSkills, createSkill, removeSkill } from "./skills.js";
8
8
  import { config, persistModel } from "../config.js";
9
+ import { ModeContext } from "../mode-context.js";
9
10
  import { agentEventBus } from "./agent-event-bus.js";
10
11
  import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, sendToAgentSession, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
11
12
  import { getRouterConfig, updateRouterConfig } from "./router.js";
@@ -31,6 +32,7 @@ import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
31
32
  import { childLogger } from "../util/logger.js";
32
33
  import { getActiveScope as getMemoryActiveScope, createScope as createMemoryScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, } from "../memory/index.js";
33
34
  const log = childLogger("tools");
35
+ const modeContext = new ModeContext(config);
34
36
  /** Escape a string for safe inclusion as a single-line YAML scalar value. */
35
37
  function yamlEscape(value) {
36
38
  // Always quote and escape backslashes, double quotes, and newlines.
@@ -51,6 +53,21 @@ function yamlListItem(value) {
51
53
  function indexSafe(text) {
52
54
  return text.replace(/[\r\n|]/g, " ").trim();
53
55
  }
56
+ function sanitizeWikiUpdateError(err) {
57
+ if (err instanceof z.ZodError) {
58
+ return err.issues.map((issue) => issue.message).join("; ") || "Invalid wiki_update arguments.";
59
+ }
60
+ const message = err instanceof Error ? err.message : String(err);
61
+ if (message.startsWith("Wiki page frontmatter violates the required shape:")
62
+ || message.startsWith("Wiki path")
63
+ || message.startsWith("Wiki page paths must end in .md:")
64
+ || message.startsWith("Refused unsafe wiki path:")
65
+ || message.startsWith("Refused: only pages under pages/")
66
+ || message === "Wiki path is required") {
67
+ return message;
68
+ }
69
+ return "Wiki update failed. Check the page path and frontmatter, then try again.";
70
+ }
54
71
  function isTimeoutError(err) {
55
72
  const msg = err instanceof Error ? err.message : String(err);
56
73
  return /timeout|timed?\s*out/i.test(msg);
@@ -187,6 +204,13 @@ const memoryProposeArgsSchema = z.object({
187
204
  }
188
205
  });
189
206
  const memoryTierTableSchema = z.enum(["observation", "decision", "entity", "action_item"]);
207
+ const wikiUpdateArgsSchema = z.object({
208
+ path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/chapterhouse/index.md', 'pages/projects/chapterhouse/decisions.md', 'pages/people/brian/index.md')"),
209
+ title: z.string().describe("Page title for the index"),
210
+ summary: z.string().describe("One-line summary for the index"),
211
+ section: z.string().optional().describe("Index section (default: 'Knowledge')"),
212
+ content: z.string().describe("Full page content (markdown)"),
213
+ });
190
214
  function getCurrentQuarter(now = new Date()) {
191
215
  return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
192
216
  }
@@ -536,7 +560,7 @@ export function createTools(deps) {
536
560
  notes: z.string().optional().describe("Optional notes about the work"),
537
561
  }),
538
562
  handler: async (args) => {
539
- if (config.chapterhouseMode !== "personal") {
563
+ if (!modeContext.canLogToAdo()) {
540
564
  return "OKR progress logging is only available from personal Chapterhouse instances.";
541
565
  }
542
566
  const mapper = createOKRMapper();
@@ -1246,7 +1270,7 @@ export function createTools(deps) {
1246
1270
  if (args.scope_slug && !requestedScope) {
1247
1271
  return `Unknown memory scope '${args.scope_slug}'.`;
1248
1272
  }
1249
- const result = runHousekeeping({
1273
+ const result = await runHousekeeping({
1250
1274
  scopeIds: requestedScope ? [requestedScope.id] : undefined,
1251
1275
  allScopes: args.all_scopes,
1252
1276
  passes: args.passes,
@@ -1659,46 +1683,48 @@ export function createTools(deps) {
1659
1683
  "topic overview or a facet name (e.g. 'decisions', 'feature-ideas') — exactly one topic level, " +
1660
1684
  "lowercase slugs only. Flat-category pages MUST be 'pages/<category>.md' (preferences, facts, " +
1661
1685
  "routines, decisions). Bad paths are rejected with a suggested correction.",
1662
- parameters: z.object({
1663
- path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/chapterhouse/index.md', 'pages/projects/chapterhouse/decisions.md', 'pages/people/brian/index.md')"),
1664
- title: z.string().describe("Page title for the index"),
1665
- summary: z.string().describe("One-line summary for the index"),
1666
- section: z.string().optional().describe("Index section (default: 'Knowledge')"),
1667
- content: z.string().describe("Full page content (markdown)"),
1668
- }),
1686
+ parameters: wikiUpdateArgsSchema,
1669
1687
  handler: async (args) => {
1670
- return withWikiWrite(async () => {
1671
- ensureWikiStructure();
1672
- assertPagePath(args.path);
1673
- const validation = validateWikiFrontmatter(args.content, {
1674
- allowedTags: loadTaxonomy(),
1675
- });
1676
- if (!validation.valid) {
1677
- throw new Error(validation.errors.map((error) => error.message).join("\n\n"));
1678
- }
1679
- writePage(args.path, args.content);
1680
- // Rebuild from disk so the index summary/tags/updated reflect the actual page.
1681
- const today = new Date().toISOString().slice(0, 10);
1682
- const rebuilt = buildIndexEntryForPage(args.path, {
1683
- section: args.section || "Knowledge",
1684
- updated: today,
1685
- });
1686
- if (rebuilt) {
1687
- rebuilt.section = args.section || "Knowledge";
1688
- addToIndex(rebuilt);
1689
- }
1690
- else {
1691
- addToIndex({
1692
- path: args.path,
1693
- title: args.title,
1694
- summary: indexSafe(args.summary).slice(0, 160),
1695
- section: args.section || "Knowledge",
1688
+ try {
1689
+ const parsedArgs = wikiUpdateArgsSchema.parse(args);
1690
+ return await withWikiWrite(async () => {
1691
+ ensureWikiStructure();
1692
+ assertPagePath(parsedArgs.path);
1693
+ const validation = validateWikiFrontmatter(parsedArgs.content, {
1694
+ allowedTags: loadTaxonomy(),
1695
+ });
1696
+ if (!validation.valid) {
1697
+ throw new Error(validation.errors.map((error) => error.message).join("\n\n"));
1698
+ }
1699
+ writePage(parsedArgs.path, parsedArgs.content);
1700
+ // Rebuild from disk so the index summary/tags/updated reflect the actual page.
1701
+ const today = new Date().toISOString().slice(0, 10);
1702
+ const rebuilt = buildIndexEntryForPage(parsedArgs.path, {
1703
+ section: parsedArgs.section || "Knowledge",
1696
1704
  updated: today,
1697
1705
  });
1698
- }
1699
- appendLog("update", `wiki_update: ${indexSafe(args.title)} (${args.path})`);
1700
- return `Wiki page updated: ${args.title} (${args.path})`;
1701
- });
1706
+ if (rebuilt) {
1707
+ rebuilt.section = parsedArgs.section || "Knowledge";
1708
+ addToIndex(rebuilt);
1709
+ }
1710
+ else {
1711
+ addToIndex({
1712
+ path: parsedArgs.path,
1713
+ title: parsedArgs.title,
1714
+ summary: indexSafe(parsedArgs.summary).slice(0, 160),
1715
+ section: parsedArgs.section || "Knowledge",
1716
+ updated: today,
1717
+ });
1718
+ }
1719
+ appendLog("update", `wiki_update: ${indexSafe(parsedArgs.title)} (${parsedArgs.path})`);
1720
+ return `Wiki page updated: ${parsedArgs.title} (${parsedArgs.path})`;
1721
+ });
1722
+ }
1723
+ catch (err) {
1724
+ const error = sanitizeWikiUpdateError(err);
1725
+ log.error({ err: err instanceof Error ? err.message : err, path: typeof args?.path === "string" ? args.path : undefined }, "wiki_update failed");
1726
+ return { error };
1727
+ }
1702
1728
  },
1703
1729
  }),
1704
1730
  defineTool("wiki_ingest", {
@@ -23,7 +23,7 @@ test.afterEach(async () => {
23
23
  rmSync(home, { recursive: true, force: true });
24
24
  }
25
25
  });
26
- test("wiki_update rejects content without required frontmatter", async () => {
26
+ test("wiki_update returns validation errors instead of throwing for invalid frontmatter", async () => {
27
27
  const toolsModule = await loadToolsModule();
28
28
  const tools = toolsModule.createTools({
29
29
  client: { async listModels() { return []; } },
@@ -31,14 +31,19 @@ test("wiki_update rejects content without required frontmatter", async () => {
31
31
  });
32
32
  const tool = tools.find((entry) => entry.name === "wiki_update");
33
33
  assert.ok(tool);
34
- await assert.rejects(tool.handler({
34
+ const result = await tool.handler({
35
35
  path: "pages/shared/chapterhouse.md",
36
36
  title: "Chapterhouse",
37
37
  summary: "Runtime notes",
38
38
  content: "# Chapterhouse\n\nRuntime notes.\n",
39
- }), /Wiki page frontmatter violates the required shape/i);
39
+ });
40
+ assert.deepEqual(result, {
41
+ error: "Wiki page frontmatter violates the required shape: missing YAML frontmatter. Use:\n---\ntitle: <title>\nsummary: <plain-text one-line summary, max 200 chars>\nupdated: YYYY-MM-DD\ntags: []\nrelated: []\n---",
42
+ });
43
+ const wikiFs = await readWikiArtifacts();
44
+ assert.equal(wikiFs.readPage("pages/shared/chapterhouse.md"), undefined);
40
45
  });
41
- test("wiki_update rejects malformed summaries and unknown tags", async () => {
46
+ test("wiki_update returns descriptive validation errors for malformed summaries and unknown tags", async () => {
42
47
  const toolsModule = await loadToolsModule();
43
48
  const tools = toolsModule.createTools({
44
49
  client: { async listModels() { return []; } },
@@ -46,7 +51,7 @@ test("wiki_update rejects malformed summaries and unknown tags", async () => {
46
51
  });
47
52
  const tool = tools.find((entry) => entry.name === "wiki_update");
48
53
  assert.ok(tool);
49
- await assert.rejects(tool.handler({
54
+ const result = await tool.handler({
50
55
  path: "pages/shared/chapterhouse.md",
51
56
  title: "Chapterhouse",
52
57
  summary: "Runtime notes",
@@ -60,7 +65,11 @@ tags: [engineering, made-up-tag]
60
65
 
61
66
  Runtime notes.
62
67
  `,
63
- }), /Add it to `pages\/_meta\/taxonomy\.md` first\./);
68
+ });
69
+ assert.equal(typeof result, "object");
70
+ assert.match(result.error, /invalid 'summary'/i);
71
+ assert.match(result.error, /unknown tag 'made-up-tag'/i);
72
+ assert.match(result.error, /pages\/_meta\/taxonomy\.md/);
64
73
  });
65
74
  test("wiki_update accepts valid frontmatter and refreshes the index entry", async () => {
66
75
  const toolsModule = await loadToolsModule();
package/dist/daemon.js CHANGED
@@ -4,6 +4,7 @@ import { stopEpisodeWriter } from "./copilot/episode-writer.js";
4
4
  import { startApiServer, broadcastToSSE } from "./api/server.js";
5
5
  import { getDb, closeDb, getState } from "./store/db.js";
6
6
  import { config } from "./config.js";
7
+ import { ModeContext } from "./mode-context.js";
7
8
  import { spawn } from "child_process";
8
9
  import { readdirSync, statSync, rmSync } from "fs";
9
10
  import { join } from "path";
@@ -21,6 +22,7 @@ import { CHAPTERHOUSE_VERSION } from "./version.js";
21
22
  import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/workiq-installer.js";
22
23
  import { MemoryHousekeepingScheduler } from "./memory/housekeeping-scheduler.js";
23
24
  const log = logger.child({ module: "daemon" });
25
+ const modeContext = new ModeContext(config);
24
26
  let memoryHousekeepingScheduler;
25
27
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
26
28
  /**
@@ -86,6 +88,9 @@ async function main() {
86
88
  if (config.selfEditEnabled) {
87
89
  log.warn("Self-edit mode enabled — Chapterhouse can modify his own source code");
88
90
  }
91
+ for (const warning of config.modeCompatibilityWarnings) {
92
+ log.warn({ mode: config.chapterhouseMode }, warning);
93
+ }
89
94
  // Set up message logging to daemon console
90
95
  setMessageLogger((direction, source, text) => {
91
96
  const arrow = direction === "in" ? "⟶" : "⟵";
@@ -100,7 +105,7 @@ async function main() {
100
105
  if (wikiIsNew) {
101
106
  log.info("Created wiki");
102
107
  }
103
- if (config.chapterhouseMode === "team") {
108
+ if (modeContext.isTeam()) {
104
109
  const seed = seedTeamWiki();
105
110
  if (seed.created.length > 0) {
106
111
  log.info({ pages: seed.created }, "Seeded team wiki pages");
@@ -153,7 +158,7 @@ async function main() {
153
158
  await startApiServer();
154
159
  memoryHousekeepingScheduler = new MemoryHousekeepingScheduler();
155
160
  memoryHousekeepingScheduler.start();
156
- if (config.chapterhouseMode === "personal" && (config.adoPat || config.teamChapterhouseUrl)) {
161
+ if (modeContext.canLogToAdo() && (config.adoPat || config.teamChapterhouseUrl)) {
157
162
  new StandupScheduler().schedule();
158
163
  }
159
164
  const url = `http://${getDisplayHost(config.apiHost)}:${config.apiPort}`;
@@ -1,4 +1,5 @@
1
1
  import { config } from "../config.js";
2
+ import { ModeContext } from "../mode-context.js";
2
3
  export class TeamPushClient {
3
4
  teamChapterhouseUrl;
4
5
  teamChapterhouseToken;
@@ -6,6 +7,7 @@ export class TeamPushClient {
6
7
  fetchImpl;
7
8
  getAuthorizationHeader;
8
9
  getCurrentUser;
10
+ modeContext;
9
11
  constructor(options = {}) {
10
12
  this.teamChapterhouseUrl = (options.teamChapterhouseUrl ?? config.teamChapterhouseUrl).trim().replace(/\/+$/, "");
11
13
  this.teamChapterhouseToken = (options.teamChapterhouseToken ?? config.teamChapterhouseToken).trim();
@@ -13,6 +15,11 @@ export class TeamPushClient {
13
15
  this.fetchImpl = options.fetchImpl ?? fetch;
14
16
  this.getAuthorizationHeader = options.getAuthorizationHeader;
15
17
  this.getCurrentUser = options.getCurrentUser;
18
+ this.modeContext = new ModeContext({
19
+ ...config,
20
+ teamChapterhouseUrl: this.teamChapterhouseUrl,
21
+ standaloneMode: this.standaloneMode,
22
+ });
16
23
  }
17
24
  async pushUpdate(payload) {
18
25
  if (!this.isEnabled()) {
@@ -129,7 +136,7 @@ export class TeamPushClient {
129
136
  throw new Error("Failed to push OKR update: no authenticated engineer identity is available");
130
137
  }
131
138
  isEnabled() {
132
- return !this.standaloneMode && this.teamChapterhouseUrl.length > 0;
139
+ return this.modeContext.canSyncTeamWiki();
133
140
  }
134
141
  }
135
142
  function describeHttpFailure(status) {
@@ -1,4 +1,5 @@
1
1
  import { config } from "../config.js";
2
+ import { ModeContext } from "../mode-context.js";
2
3
  import { childLogger } from "../util/logger.js";
3
4
  const log = childLogger("teams-notify");
4
5
  export const TEAMS_MILESTONE_THRESHOLDS = [25, 50, 75, 100];
@@ -8,11 +9,17 @@ export class TeamsNotifier {
8
9
  enabled;
9
10
  fetchImpl;
10
11
  warn;
12
+ modeContext;
11
13
  constructor(options = {}) {
12
14
  this.webhookUrl = (options.webhookUrl ?? config.teamsWebhookUrl).trim();
13
15
  this.enabled = options.enabled ?? config.teamsNotificationsEnabled;
14
16
  this.fetchImpl = options.fetchImpl ?? fetch;
15
17
  this.warn = options.warn ?? ((message) => log.warn(message));
18
+ this.modeContext = new ModeContext({
19
+ ...config,
20
+ teamsWebhookUrl: this.webhookUrl,
21
+ teamsNotificationsEnabled: this.enabled,
22
+ });
16
23
  }
17
24
  async sendMessage(title, body, color = DEFAULT_COLOR) {
18
25
  return await this.postCard({
@@ -61,7 +68,7 @@ export class TeamsNotifier {
61
68
  });
62
69
  }
63
70
  async postCard(card) {
64
- if (!this.enabled || this.webhookUrl.length === 0) {
71
+ if (!this.modeContext.canSyncToTeams()) {
65
72
  this.warn("[teams] Teams notifications are disabled or TEAMS_WEBHOOK_URL is empty.");
66
73
  return false;
67
74
  }
@@ -8,7 +8,8 @@ import { tieringPass } from "./tiering.js";
8
8
  export { tieringPass };
9
9
  const log = childLogger("memory.housekeeping");
10
10
  const SIMILARITY_THRESHOLD = 0.8;
11
- const inFlightKeys = new Set();
11
+ const GLOBAL_PASS_SCOPE = Symbol("global-housekeeping-pass-scope");
12
+ const inFlightScopesByPass = new Map();
12
13
  const PASS_ORDER = [
13
14
  "dedup_observations",
14
15
  "dedup_decisions",
@@ -280,38 +281,91 @@ function resolveScopeIds(input) {
280
281
  const activeScope = getActiveScope();
281
282
  return activeScope ? [activeScope.id] : [];
282
283
  }
283
- function runPass(pass, scopeId) {
284
+ function getInFlightScopes(pass) {
285
+ let scopes = inFlightScopesByPass.get(pass);
286
+ if (!scopes) {
287
+ scopes = new Set();
288
+ inFlightScopesByPass.set(pass, scopes);
289
+ }
290
+ return scopes;
291
+ }
292
+ function getReservedPassScopes(scopeIds, passes) {
293
+ const reserved = [];
294
+ for (const pass of passes) {
295
+ if (pass === "compact_inbox") {
296
+ reserved.push({ pass, scope: GLOBAL_PASS_SCOPE });
297
+ continue;
298
+ }
299
+ for (const scopeId of scopeIds) {
300
+ reserved.push({ pass, scope: scopeId });
301
+ }
302
+ }
303
+ return reserved;
304
+ }
305
+ function reservePassScopes(reserved) {
306
+ if (reserved.some(({ pass, scope }) => getInFlightScopes(pass).has(scope))) {
307
+ return false;
308
+ }
309
+ for (const { pass, scope } of reserved) {
310
+ getInFlightScopes(pass).add(scope);
311
+ }
312
+ return true;
313
+ }
314
+ function releasePassScopes(reserved) {
315
+ for (const { pass, scope } of reserved) {
316
+ const scopes = inFlightScopesByPass.get(pass);
317
+ scopes?.delete(scope);
318
+ if (scopes && scopes.size === 0) {
319
+ inFlightScopesByPass.delete(pass);
320
+ }
321
+ }
322
+ }
323
+ async function runPass(pass, scopeId) {
284
324
  switch (pass) {
285
325
  case "dedup_observations":
286
- return dedupObservationsPass(scopeId);
326
+ return await Promise.resolve(dedupObservationsPass(scopeId));
287
327
  case "dedup_decisions":
288
- return dedupDecisionsPass(scopeId);
328
+ return await Promise.resolve(dedupDecisionsPass(scopeId));
289
329
  case "orphan_cleanup":
290
- return orphanCleanupPass(scopeId);
330
+ return await Promise.resolve(orphanCleanupPass(scopeId));
291
331
  case "decay":
292
- return decayPass(scopeId);
332
+ return await Promise.resolve(decayPass(scopeId));
293
333
  case "compact_inbox":
294
- return compactInboxPass();
334
+ return await Promise.resolve(compactInboxPass());
295
335
  case "tiering":
296
- return tieringPass(scopeId);
336
+ return await Promise.resolve(tieringPass(scopeId));
297
337
  }
298
338
  }
299
- function inFlightKey(scopeIds, passes) {
300
- return `${scopeIds.join(",") || "none"}:${passes.join(",")}`;
339
+ async function runScopePasses(scopeId, passes) {
340
+ const summaries = [];
341
+ for (const pass of passes) {
342
+ summaries.push(await runPass(pass, scopeId));
343
+ }
344
+ return summaries;
301
345
  }
302
346
  export function isHousekeepingInFlight(scopeIds, passes) {
347
+ const normalizedPasses = passes?.map(normalizePassName) ?? PASS_ORDER;
303
348
  if (!scopeIds || scopeIds.length === 0) {
304
- return inFlightKeys.size > 0;
349
+ return normalizedPasses.some((pass) => (inFlightScopesByPass.get(pass)?.size ?? 0) > 0);
305
350
  }
306
- const normalizedPasses = passes?.map(normalizePassName) ?? PASS_ORDER;
307
- return inFlightKeys.has(inFlightKey([...new Set(scopeIds)].sort((a, b) => a - b), normalizedPasses));
351
+ const uniqueScopeIds = [...new Set(scopeIds)].sort((a, b) => a - b);
352
+ return normalizedPasses.some((pass) => {
353
+ const scopes = inFlightScopesByPass.get(pass);
354
+ if (!scopes) {
355
+ return false;
356
+ }
357
+ if (pass === "compact_inbox") {
358
+ return scopes.has(GLOBAL_PASS_SCOPE);
359
+ }
360
+ return uniqueScopeIds.some((scopeId) => scopes.has(scopeId));
361
+ });
308
362
  }
309
- export function runHousekeeping(opts = {}) {
363
+ export async function runHousekeeping(opts = {}) {
310
364
  const started = performance.now();
311
365
  const scopeIds = resolveScopeIds(opts).sort((a, b) => a - b);
312
366
  const passes = opts.passes?.length ? opts.passes.map(normalizePassName) : PASS_ORDER;
313
- const key = inFlightKey(scopeIds, passes);
314
- if (inFlightKeys.has(key)) {
367
+ const reservedPassScopes = getReservedPassScopes(scopeIds, passes);
368
+ if (!reservePassScopes(reservedPassScopes)) {
315
369
  return {
316
370
  scopeIds,
317
371
  summaries: [passSummary("runHousekeeping", 0, 0, ["Housekeeping is already in flight for this scope/pass set."])],
@@ -320,18 +374,12 @@ export function runHousekeeping(opts = {}) {
320
374
  durationMs: 0,
321
375
  };
322
376
  }
323
- inFlightKeys.add(key);
324
- const summaries = [];
325
377
  try {
326
378
  const scopedPasses = passes.filter((pass) => pass !== "compact_inbox");
327
379
  const hasCompactInbox = passes.includes("compact_inbox");
328
- for (const scopeId of scopeIds) {
329
- for (const pass of scopedPasses) {
330
- summaries.push(runPass(pass, scopeId));
331
- }
332
- }
380
+ const summaries = (await Promise.all(scopeIds.map((scopeId) => runScopePasses(scopeId, scopedPasses)))).flat();
333
381
  if (hasCompactInbox) {
334
- summaries.push(compactInboxPass());
382
+ summaries.push(await runPass("compact_inbox", undefined));
335
383
  }
336
384
  const totalExamined = summaries.reduce((sum, summary) => sum + summary.examined, 0);
337
385
  const totalModified = summaries.reduce((sum, summary) => sum + summary.modified, 0);
@@ -346,7 +394,7 @@ export function runHousekeeping(opts = {}) {
346
394
  return { scopeIds, summaries, totalExamined, totalModified, durationMs };
347
395
  }
348
396
  finally {
349
- inFlightKeys.delete(key);
397
+ releasePassScopes(reservedPassScopes);
350
398
  }
351
399
  }
352
400
  //# sourceMappingURL=housekeeping.js.map
@@ -16,9 +16,51 @@ function resetSandbox() {
16
16
  async function loadModules() {
17
17
  const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
18
18
  const memoryModule = await import(new URL("./index.js", import.meta.url).href);
19
- const housekeepingModule = await import(new URL("./housekeeping.js", import.meta.url).href);
19
+ const housekeepingModule = await import(new URL(`./housekeeping.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
20
20
  return { dbModule, memoryModule, housekeepingModule };
21
21
  }
22
+ async function loadMockedHousekeepingModule(t, options = {}) {
23
+ t.mock.module("../config.js", {
24
+ namedExports: {
25
+ config: {
26
+ memoryDecayDays: 30,
27
+ memoryInboxRetentionDays: 7,
28
+ },
29
+ },
30
+ });
31
+ t.mock.module("../store/db.js", {
32
+ namedExports: {
33
+ getDb: () => {
34
+ throw new Error("getDb should not be called in this test");
35
+ },
36
+ },
37
+ });
38
+ t.mock.module("../util/logger.js", {
39
+ namedExports: {
40
+ childLogger: () => ({
41
+ info: () => { },
42
+ warn: () => { },
43
+ error: () => { },
44
+ }),
45
+ },
46
+ });
47
+ t.mock.module("./active-scope.js", {
48
+ namedExports: {
49
+ getActiveScope: () => (options.activeScopeId === undefined ? null : { id: options.activeScopeId }),
50
+ },
51
+ });
52
+ t.mock.module("./scopes.js", {
53
+ namedExports: {
54
+ listScopes: () => options.scopes ?? [],
55
+ },
56
+ });
57
+ t.mock.module("./tiering.js", {
58
+ namedExports: {
59
+ tieringPass: (scopeId) => options.tieringPass?.(scopeId) ?? { pass: "tieringPass", examined: scopeId, modified: 1, errors: [] },
60
+ },
61
+ });
62
+ return await import(new URL(`./housekeeping.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
63
+ }
22
64
  function getFunction(module, name) {
23
65
  const value = module[name];
24
66
  assert.equal(typeof value, "function", `expected ${name} to be exported`);
@@ -215,17 +257,67 @@ test("runHousekeeping defaults to the active scope and can target all active sco
215
257
  const teamOld = recordObservation({ scope_id: team.id, content: "Team old low", source: "test", confidence: 0.1 });
216
258
  db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-31 days') WHERE id IN (?, ?)`).run(chapterhouseOld.id, teamOld.id);
217
259
  setActiveScope("chapterhouse");
218
- const activeOnly = housekeepingModule.runHousekeeping({ passes: ["decay"] });
260
+ const activeOnly = await housekeepingModule.runHousekeeping({ passes: ["decay"] });
219
261
  assert.deepEqual(activeOnly.scopeIds, [chapterhouse.id]);
220
262
  assert.equal(activeOnly.summaries.length, 1);
221
263
  assert.equal(activeOnly.summaries[0]?.modified, 1);
222
264
  assert.ok(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(chapterhouseOld.id).archived_at);
223
265
  assert.equal(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(teamOld.id).archived_at, null);
224
- const allScopes = housekeepingModule.runHousekeeping({ allScopes: true, passes: ["decay"] });
266
+ const allScopes = await housekeepingModule.runHousekeeping({ allScopes: true, passes: ["decay"] });
225
267
  assert.ok(allScopes.scopeIds.includes(team.id));
226
268
  assert.equal(allScopes.summaries.some((summary) => summary.modified === 1), true);
227
269
  assert.ok(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(teamOld.id).archived_at);
228
270
  });
271
+ test("runHousekeeping starts all scoped passes before awaiting completion", async (t) => {
272
+ const releases = new Map();
273
+ const startedScopes = [];
274
+ const housekeepingModule = await loadMockedHousekeepingModule(t, {
275
+ scopes: [
276
+ { id: 11, active: true },
277
+ { id: 22, active: true },
278
+ ],
279
+ tieringPass: async (scopeId) => {
280
+ startedScopes.push(scopeId);
281
+ await new Promise((resolve) => {
282
+ releases.set(scopeId, resolve);
283
+ });
284
+ return { pass: `tieringPass:${scopeId}`, examined: 1, modified: 1, errors: [] };
285
+ },
286
+ });
287
+ const pending = housekeepingModule.runHousekeeping({ allScopes: true, passes: ["tiering"] });
288
+ assert.equal(typeof pending.then, "function");
289
+ await Promise.resolve();
290
+ assert.deepEqual(startedScopes.sort((left, right) => left - right), [11, 22]);
291
+ releases.get(11)?.();
292
+ releases.get(22)?.();
293
+ const result = await pending;
294
+ assert.deepEqual(result.scopeIds, [11, 22]);
295
+ assert.deepEqual(result.summaries.map((summary) => summary.pass), ["tieringPass:11", "tieringPass:22"]);
296
+ });
297
+ test("runHousekeeping rejects overlapping runs that share an in-flight scope", async (t) => {
298
+ const releases = new Map();
299
+ const housekeepingModule = await loadMockedHousekeepingModule(t, {
300
+ scopes: [
301
+ { id: 11, active: true },
302
+ { id: 22, active: true },
303
+ ],
304
+ tieringPass: async (scopeId) => {
305
+ await new Promise((resolve) => {
306
+ releases.set(scopeId, resolve);
307
+ });
308
+ return { pass: `tieringPass:${scopeId}`, examined: 1, modified: 1, errors: [] };
309
+ },
310
+ });
311
+ const firstRun = housekeepingModule.runHousekeeping({ scopeIds: [11, 22], passes: ["tiering"] });
312
+ await Promise.resolve();
313
+ assert.equal(housekeepingModule.isHousekeepingInFlight([22], ["tiering"]), true);
314
+ const secondRun = await housekeepingModule.runHousekeeping({ scopeIds: [22], passes: ["tiering"] });
315
+ assert.deepEqual(secondRun.scopeIds, [22]);
316
+ assert.match(secondRun.summaries[0]?.errors[0] ?? "", /already in flight/i);
317
+ releases.get(11)?.();
318
+ releases.get(22)?.();
319
+ await firstRun;
320
+ });
229
321
  test("tieringPass promotes and demotes rows from lifecycle signals and is idempotent", async () => {
230
322
  const { dbModule, memoryModule, housekeepingModule } = await loadModules();
231
323
  const db = dbModule.getDb();