chapterhouse 0.5.0 → 0.5.2

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 (40) hide show
  1. package/dist/api/server.js +31 -3
  2. package/dist/api/server.test.js +48 -5
  3. package/dist/cli.js +4 -2
  4. package/dist/config.js +75 -13
  5. package/dist/config.test.js +44 -0
  6. package/dist/copilot/orchestrator.js +17 -6
  7. package/dist/copilot/orchestrator.test.js +50 -3
  8. package/dist/copilot/router.js +43 -8
  9. package/dist/copilot/router.test.js +30 -18
  10. package/dist/copilot/system-message.js +3 -3
  11. package/dist/copilot/system-message.test.js +10 -0
  12. package/dist/copilot/tools.js +79 -12
  13. package/dist/copilot/tools.memory.test.js +94 -6
  14. package/dist/daemon.js +7 -2
  15. package/dist/integrations/team-push.js +8 -1
  16. package/dist/integrations/teams-notify.js +8 -1
  17. package/dist/memory/active-scope.test.js +7 -2
  18. package/dist/memory/eot.js +96 -8
  19. package/dist/memory/eot.test.js +186 -5
  20. package/dist/memory/hot-tier.test.js +14 -4
  21. package/dist/memory/housekeeping.js +73 -25
  22. package/dist/memory/housekeeping.test.js +115 -16
  23. package/dist/memory/inbox.test.js +178 -0
  24. package/dist/memory/scopes.test.js +0 -24
  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 +152 -95
  29. package/dist/setup.test.js +122 -0
  30. package/dist/store/db.js +0 -18
  31. package/dist/wiki/team-sync.js +8 -1
  32. package/package.json +1 -1
  33. package/web/dist/assets/{index-BfHqP3-C.js → index-CPaILy2j.js} +69 -69
  34. package/web/dist/assets/index-CPaILy2j.js.map +1 -0
  35. package/web/dist/assets/index-Cs7AGeaL.css +10 -0
  36. package/web/dist/index.html +2 -2
  37. package/agents/bellonda.agent.md +0 -11
  38. package/agents/hwi-noree.agent.md +0 -12
  39. package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
  40. package/web/dist/assets/index-_O6AoWOS.css +0 -10
@@ -1,4 +1,5 @@
1
1
  import { config } from "../config.js";
2
+ import { getAgent, loadAgents } from "../copilot/agents.js";
2
3
  import { runOneShotPrompt } from "../copilot/oneshot.js";
3
4
  import { childLogger } from "../util/logger.js";
4
5
  import { recordDecision } from "./decisions.js";
@@ -6,6 +7,7 @@ import { upsertEntity } from "./entities.js";
6
7
  import { listPendingMemoryProposalsForTask, resolveInboxItem } from "./inbox.js";
7
8
  import { recordObservation } from "./observations.js";
8
9
  import { recordActionItem } from "./action-items.js";
10
+ import { getActiveScope } from "./active-scope.js";
9
11
  import { getScope } from "./scopes.js";
10
12
  const log = childLogger("memory.eot");
11
13
  function isEndOfTaskHookEnabled() {
@@ -119,7 +121,75 @@ function parseReviewerResponse(raw) {
119
121
  : [],
120
122
  };
121
123
  }
122
- function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence) {
124
+ function isNonEmptyString(value) {
125
+ return typeof value === "string" && value.trim().length > 0;
126
+ }
127
+ function isIsoTimestamp(value) {
128
+ return !Number.isNaN(Date.parse(value));
129
+ }
130
+ function validateActionItemPayload(payload) {
131
+ const actionItem = payload;
132
+ if (!isNonEmptyString(actionItem.title)) {
133
+ throw new Error("Action item proposal payload requires a non-empty title.");
134
+ }
135
+ if (actionItem.detail !== undefined && typeof actionItem.detail !== "string") {
136
+ throw new Error("Action item proposal payload detail must be a string.");
137
+ }
138
+ if (actionItem.due_at !== undefined && (typeof actionItem.due_at !== "string" || !isIsoTimestamp(actionItem.due_at))) {
139
+ throw new Error("Action item proposal payload due_at must be an ISO timestamp.");
140
+ }
141
+ if (actionItem.entity_id !== undefined && (!Number.isInteger(actionItem.entity_id) || actionItem.entity_id <= 0)) {
142
+ throw new Error("Action item proposal payload entity_id must be a positive integer.");
143
+ }
144
+ if (actionItem.entity_id !== undefined && actionItem.entity_name !== undefined) {
145
+ throw new Error("Action item proposal payload must provide either entity_id or entity_name/entity_kind, not both.");
146
+ }
147
+ const hasEntityName = actionItem.entity_name !== undefined;
148
+ const hasEntityKind = actionItem.entity_kind !== undefined;
149
+ if (hasEntityName !== hasEntityKind) {
150
+ throw new Error("Action item proposal payload entity_name and entity_kind must be provided together.");
151
+ }
152
+ if (hasEntityName && !isNonEmptyString(actionItem.entity_name)) {
153
+ throw new Error("Action item proposal payload entity_name must be non-empty when provided.");
154
+ }
155
+ if (hasEntityKind && !isNonEmptyString(actionItem.entity_kind)) {
156
+ throw new Error("Action item proposal payload entity_kind must be non-empty when provided.");
157
+ }
158
+ return {
159
+ title: actionItem.title.trim(),
160
+ detail: actionItem.detail,
161
+ due_at: actionItem.due_at,
162
+ source: actionItem.source,
163
+ entity_id: actionItem.entity_id,
164
+ entity_name: actionItem.entity_name?.trim(),
165
+ entity_kind: actionItem.entity_kind?.trim(),
166
+ };
167
+ }
168
+ function getBoundScopeSlug(sourceAgent) {
169
+ const agent = getAgent(sourceAgent);
170
+ return agent?.scope ?? loadAgents().find((entry) => entry.slug === sourceAgent)?.scope;
171
+ }
172
+ function resolveAcceptedProposalScopeSlug(envelope, proposal) {
173
+ if (isNonEmptyString(envelope.scope_slug)) {
174
+ return envelope.scope_slug.trim();
175
+ }
176
+ const boundScope = getBoundScopeSlug(proposal.sourceAgent);
177
+ if (boundScope) {
178
+ return boundScope;
179
+ }
180
+ const activeScope = getActiveScope();
181
+ if (activeScope) {
182
+ return activeScope.slug;
183
+ }
184
+ if (proposal.scopeId) {
185
+ const queuedScope = getScope(proposal.scopeId);
186
+ if (queuedScope) {
187
+ return queuedScope.slug;
188
+ }
189
+ }
190
+ throw new Error("No memory scope could be resolved for this proposal.");
191
+ }
192
+ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, sourceAgent) {
123
193
  const scope = getScope(scopeSlug);
124
194
  if (!scope) {
125
195
  throw new Error(`Unknown memory scope '${scopeSlug}'.`);
@@ -146,14 +216,22 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence) {
146
216
  return;
147
217
  }
148
218
  if (kind === "action_item") {
149
- const actionItem = payload;
219
+ const actionItem = validateActionItemPayload(payload);
220
+ const entity = actionItem.entity_name && actionItem.entity_kind
221
+ ? upsertEntity({
222
+ scope_id: scope.id,
223
+ kind: actionItem.entity_kind,
224
+ name: actionItem.entity_name,
225
+ confidence,
226
+ })
227
+ : undefined;
150
228
  recordActionItem({
151
229
  scope_id: scope.id,
152
- entity_id: actionItem.entity_id,
230
+ entity_id: entity?.id ?? actionItem.entity_id,
153
231
  title: actionItem.title,
154
232
  detail: actionItem.detail,
155
233
  due_at: actionItem.due_at,
156
- source: actionItem.source ?? source,
234
+ source: sourceAgent ? `subagent_proposal:${sourceAgent}` : actionItem.source ?? source,
157
235
  });
158
236
  return;
159
237
  }
@@ -209,11 +287,21 @@ export async function runEndOfTaskMemoryHook(input) {
209
287
  }
210
288
  reviewedProposalIds.add(proposal.id);
211
289
  if (decision.decision === "accept") {
212
- summary.accepted++;
213
- if (autoAcceptEnabled) {
290
+ if (!autoAcceptEnabled) {
291
+ summary.accepted++;
292
+ }
293
+ else {
214
294
  const envelope = parseEnvelope(proposal.payload);
215
- rememberAcceptedMemory(envelope.kind, envelope.scope_slug ?? getScope(proposal.scopeId).slug, envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence);
216
- resolveInboxItem(proposal.id, "accepted", decision.reason);
295
+ try {
296
+ rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
297
+ resolveInboxItem(proposal.id, "accepted", decision.reason);
298
+ summary.accepted++;
299
+ }
300
+ catch (err) {
301
+ const reason = err instanceof Error ? err.message : String(err);
302
+ resolveInboxItem(proposal.id, "rejected", reason);
303
+ summary.rejected++;
304
+ }
217
305
  }
218
306
  continue;
219
307
  }
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdirSync, rmSync } from "node:fs";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import test from "node:test";
5
5
  const repoRoot = process.cwd();
@@ -15,7 +15,8 @@ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
15
15
  const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
16
16
  const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
17
17
  const eotModule = await import(new URL(`./eot.js?case=${cacheBust}`, import.meta.url).href);
18
- return { dbModule, memoryModule, eotModule };
18
+ const agentsModule = await import(new URL(`../copilot/agents.js?case=${cacheBust}`, import.meta.url).href);
19
+ return { dbModule, memoryModule, eotModule, agentsModule };
19
20
  }
20
21
  function getFunction(module, name) {
21
22
  const value = module[name];
@@ -171,7 +172,6 @@ test("runEndOfTaskMemoryHook accepts action_item proposals into mem_action_items
171
172
  payload: {
172
173
  title: "Migrate feature ideas",
173
174
  detail: "Move feature-ideas.md into mem_action_items.",
174
- source: "subagent_proposal",
175
175
  },
176
176
  confidence: 0.9,
177
177
  }));
@@ -189,11 +189,192 @@ test("runEndOfTaskMemoryHook accepts action_item proposals into mem_action_items
189
189
  }),
190
190
  });
191
191
  const actionItems = listActionItems({ scope_id: chapterhouse.id });
192
- assert.equal(actionItems.some((item) => item.title === "Migrate feature ideas"
193
- && item.detail === "Move feature-ideas.md into mem_action_items."), true);
192
+ const actionItem = actionItems.find((item) => item.title === "Migrate feature ideas");
193
+ assert.ok(actionItem);
194
+ assert.equal(actionItem.detail, "Move feature-ideas.md into mem_action_items.");
195
+ assert.equal(actionItem.status, "open");
196
+ assert.equal(actionItem.source, "subagent_proposal:coder");
194
197
  const inbox = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
195
198
  assert.equal(inbox.status, "accepted");
196
199
  });
200
+ test("runEndOfTaskMemoryHook rejects invalid action_item proposals with a clear reason", async () => {
201
+ const { dbModule, memoryModule, eotModule } = await loadModules("action-item-invalid");
202
+ const db = dbModule.getDb();
203
+ const getScope = getFunction(memoryModule, "getScope");
204
+ const createScope = getFunction(memoryModule, "createScope");
205
+ const listActionItems = getFunction(memoryModule, "listActionItems");
206
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
207
+ const chapterhouse = getScope("chapterhouse");
208
+ assert.ok(chapterhouse);
209
+ const inserted = db.prepare(`
210
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
211
+ VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-action-item-invalid', 'pending')
212
+ `).run(chapterhouse.id, JSON.stringify({
213
+ kind: "action_item",
214
+ payload: {
215
+ detail: "A title is required before this can become an action item.",
216
+ },
217
+ confidence: 0.9,
218
+ }));
219
+ await runEndOfTaskMemoryHook({
220
+ taskId: "task-eot-action-item-invalid",
221
+ finalResult: "Completed and proposed a malformed follow-up.",
222
+ copilotClient: {},
223
+ callLLM: async () => JSON.stringify({
224
+ decisions: [{
225
+ proposal_id: Number(inserted.lastInsertRowid),
226
+ decision: "accept",
227
+ reason: "Concrete follow-up.",
228
+ }],
229
+ implicit_memories: [],
230
+ }),
231
+ });
232
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
233
+ const inbox = db.prepare(`
234
+ SELECT status, resolution_reason, resolved_at
235
+ FROM mem_inbox
236
+ WHERE id = ?
237
+ `).get(Number(inserted.lastInsertRowid));
238
+ assert.equal(inbox.status, "rejected");
239
+ assert.match(inbox.resolution_reason ?? "", /title/i);
240
+ assert.ok(inbox.resolved_at);
241
+ });
242
+ test("runEndOfTaskMemoryHook rejects action_item proposals with ambiguous entity references", async () => {
243
+ const { dbModule, memoryModule, eotModule } = await loadModules("action-item-ambiguous-entity");
244
+ const db = dbModule.getDb();
245
+ const getScope = getFunction(memoryModule, "getScope");
246
+ const createScope = getFunction(memoryModule, "createScope");
247
+ const listActionItems = getFunction(memoryModule, "listActionItems");
248
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
249
+ const chapterhouse = getScope("chapterhouse");
250
+ assert.ok(chapterhouse);
251
+ const inserted = db.prepare(`
252
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
253
+ VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-action-item-ambiguous-entity', 'pending')
254
+ `).run(chapterhouse.id, JSON.stringify({
255
+ kind: "action_item",
256
+ payload: {
257
+ title: "Resolve ambiguous entity link",
258
+ entity_id: 1,
259
+ entity_name: "Bellonda",
260
+ entity_kind: "agent",
261
+ },
262
+ confidence: 0.9,
263
+ }));
264
+ await runEndOfTaskMemoryHook({
265
+ taskId: "task-eot-action-item-ambiguous-entity",
266
+ finalResult: "Completed and proposed an ambiguous follow-up.",
267
+ copilotClient: {},
268
+ callLLM: async () => JSON.stringify({
269
+ decisions: [{
270
+ proposal_id: Number(inserted.lastInsertRowid),
271
+ decision: "accept",
272
+ reason: "Concrete follow-up.",
273
+ }],
274
+ implicit_memories: [],
275
+ }),
276
+ });
277
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
278
+ const inbox = db.prepare(`
279
+ SELECT status, resolution_reason
280
+ FROM mem_inbox
281
+ WHERE id = ?
282
+ `).get(Number(inserted.lastInsertRowid));
283
+ assert.equal(inbox.status, "rejected");
284
+ assert.match(inbox.resolution_reason ?? "", /entity_id.*entity_name|entity_name.*entity_id/i);
285
+ });
286
+ test("runEndOfTaskMemoryHook falls back to the source agent bound scope for action_item proposals without scope_slug", async () => {
287
+ const home = process.env.CHAPTERHOUSE_HOME;
288
+ assert.ok(home, "test home should be set");
289
+ const { AGENTS_DIR: agentsDir } = await import("../paths.js");
290
+ mkdirSync(agentsDir, { recursive: true });
291
+ writeFileSync(join(agentsDir, "bellonda.agent.md"), [
292
+ "---",
293
+ "name: Bellonda",
294
+ "description: Mentat of the infrastructure domain",
295
+ "model: claude-sonnet-4.6",
296
+ "persistent: true",
297
+ "scope: infra",
298
+ "---",
299
+ "",
300
+ "You are Bellonda.",
301
+ ].join("\n"));
302
+ const { dbModule, memoryModule, eotModule, agentsModule } = await loadModules("action-item-bound-scope");
303
+ agentsModule.loadAgents();
304
+ const db = dbModule.getDb();
305
+ const getScope = getFunction(memoryModule, "getScope");
306
+ const createScope = getFunction(memoryModule, "createScope");
307
+ const listActionItems = getFunction(memoryModule, "listActionItems");
308
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
309
+ const chapterhouse = getScope("chapterhouse");
310
+ const infra = createScope({
311
+ slug: "infra",
312
+ title: "Infra",
313
+ description: "Infra test scope",
314
+ keywords: ["infra"],
315
+ });
316
+ assert.ok(chapterhouse);
317
+ const inserted = db.prepare(`
318
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
319
+ VALUES (?, 'memory_proposal', ?, 'bellonda', 'task-eot-action-item-bound-scope', 'pending')
320
+ `).run(chapterhouse.id, JSON.stringify({
321
+ kind: "action_item",
322
+ payload: {
323
+ title: "Review NAS disk alerts",
324
+ },
325
+ confidence: 0.9,
326
+ }));
327
+ await runEndOfTaskMemoryHook({
328
+ taskId: "task-eot-action-item-bound-scope",
329
+ finalResult: "Bellonda proposed an infra follow-up.",
330
+ copilotClient: {},
331
+ callLLM: async () => JSON.stringify({
332
+ decisions: [{
333
+ proposal_id: Number(inserted.lastInsertRowid),
334
+ decision: "accept",
335
+ reason: "Concrete infra follow-up.",
336
+ }],
337
+ implicit_memories: [],
338
+ }),
339
+ });
340
+ assert.equal(listActionItems({ scope_id: infra.id }).some((item) => item.title === "Review NAS disk alerts"), true);
341
+ assert.equal(listActionItems({ scope_id: chapterhouse.id }).some((item) => item.title === "Review NAS disk alerts"), false);
342
+ });
343
+ test("runEndOfTaskMemoryHook still accepts observation, decision, and entity proposals", async () => {
344
+ const { dbModule, memoryModule, eotModule } = await loadModules("existing-proposal-kinds");
345
+ const db = dbModule.getDb();
346
+ const getScope = getFunction(memoryModule, "getScope");
347
+ const listObservations = getFunction(memoryModule, "listObservations");
348
+ const listDecisions = getFunction(memoryModule, "listDecisions");
349
+ const listEntities = getFunction(memoryModule, "listEntities");
350
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
351
+ const chapterhouse = getScope("chapterhouse");
352
+ assert.ok(chapterhouse);
353
+ const proposals = [
354
+ { kind: "observation", payload: { content: "Existing observation proposals still persist." } },
355
+ { kind: "decision", payload: { title: "Keep auto-accept", rationale: "It is required for EOT memory promotion." } },
356
+ { kind: "entity", payload: { name: "Bellonda", entity_kind: "agent", summary: "Infrastructure mentat." } },
357
+ ].map((envelope) => db.prepare(`
358
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
359
+ VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-existing-kinds', 'pending')
360
+ `).run(chapterhouse.id, JSON.stringify(envelope)));
361
+ await runEndOfTaskMemoryHook({
362
+ taskId: "task-eot-existing-kinds",
363
+ finalResult: "Completed with several proposal kinds.",
364
+ copilotClient: {},
365
+ callLLM: async () => JSON.stringify({
366
+ decisions: proposals.map((proposal) => ({
367
+ proposal_id: Number(proposal.lastInsertRowid),
368
+ decision: "accept",
369
+ reason: "Valid durable memory.",
370
+ })),
371
+ implicit_memories: [],
372
+ }),
373
+ });
374
+ assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "Existing observation proposals still persist."), true);
375
+ assert.equal(listDecisions({ scope_id: chapterhouse.id }).some((row) => row.title === "Keep auto-accept" && row.rationale === "It is required for EOT memory promotion."), true);
376
+ assert.equal(listEntities({ scope_id: chapterhouse.id, kind: "agent" }).some((row) => row.name === "Bellonda"), true);
377
+ });
197
378
  test("runEndOfTaskMemoryHook accepts entity proposals with entity_kind into mem_entities", async () => {
198
379
  const { dbModule, memoryModule, eotModule } = await loadModules("entity-accept");
199
380
  const db = dbModule.getDb();
@@ -108,12 +108,17 @@ test("active-scope hot-tier queries do not leak rows from other scopes", async (
108
108
  const { dbModule, memoryModule, hotTierModule } = await loadModules();
109
109
  dbModule.getDb();
110
110
  const getScope = getFunction(memoryModule, "getScope");
111
+ const createScope = getFunction(memoryModule, "createScope");
111
112
  const setActiveScope = getFunction(memoryModule, "setActiveScope");
112
113
  const recordObservation = getFunction(memoryModule, "recordObservation");
113
114
  const chapterhouse = getScope("chapterhouse");
114
- const team = getScope("team");
115
+ const team = createScope({
116
+ slug: "team",
117
+ title: "Team",
118
+ description: "Team test scope",
119
+ keywords: ["team"],
120
+ });
115
121
  assert.ok(chapterhouse);
116
- assert.ok(team);
117
122
  recordObservation({
118
123
  scope_id: chapterhouse.id,
119
124
  content: "Chapterhouse hot entry",
@@ -135,11 +140,16 @@ test("renderHotTierXML includes open active-scope action items in a bounded acti
135
140
  const { dbModule, memoryModule, hotTierModule } = await loadModules();
136
141
  dbModule.getDb();
137
142
  const getScope = getFunction(memoryModule, "getScope");
143
+ const createScope = getFunction(memoryModule, "createScope");
138
144
  const recordActionItem = getFunction(memoryModule, "recordActionItem");
139
145
  const chapterhouse = getScope("chapterhouse");
140
- const team = getScope("team");
146
+ const team = createScope({
147
+ slug: "team",
148
+ title: "Team",
149
+ description: "Team test scope",
150
+ keywords: ["team"],
151
+ });
141
152
  assert.ok(chapterhouse);
142
- assert.ok(team);
143
153
  const urgent = recordActionItem({
144
154
  scope_id: chapterhouse.id,
145
155
  title: "Migrate <feature ideas>",
@@ -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