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.
- package/README.md +1 -1
- package/agents/korg.agent.md +20 -0
- package/dist/api/auth.js +11 -1
- package/dist/api/auth.test.js +29 -0
- package/dist/api/errors.js +23 -0
- package/dist/api/route-coverage.test.js +61 -21
- package/dist/api/routes/agents.js +472 -0
- package/dist/api/routes/memory.js +299 -0
- package/dist/api/routes/projects.js +170 -0
- package/dist/api/routes/sessions.js +347 -0
- package/dist/api/routes/system.js +82 -0
- package/dist/api/routes/wiki.js +455 -0
- package/dist/api/routes/wiki.test.js +49 -0
- package/dist/api/send-json.js +16 -0
- package/dist/api/send-json.test.js +18 -0
- package/dist/api/server-runtime.js +45 -3
- package/dist/api/server.js +34 -1764
- package/dist/api/server.test.js +239 -8
- package/dist/api/sse-hub.js +37 -0
- package/dist/cli.js +1 -1
- package/dist/config.js +151 -58
- package/dist/config.test.js +29 -0
- package/dist/copilot/okr-mapper.js +2 -11
- package/dist/copilot/orchestrator.js +358 -352
- package/dist/copilot/orchestrator.test.js +139 -4
- package/dist/copilot/prompt-date.js +2 -1
- package/dist/copilot/session-manager.js +25 -23
- package/dist/copilot/session-manager.test.js +35 -1
- package/dist/copilot/standup.js +2 -2
- package/dist/copilot/task-event-log.js +7 -1
- package/dist/copilot/task-event-log.test.js +13 -0
- package/dist/copilot/tools/agent.js +608 -0
- package/dist/copilot/tools/index.js +19 -0
- package/dist/copilot/tools/memory.js +678 -0
- package/dist/copilot/tools/models.js +2 -0
- package/dist/copilot/tools/okr.js +171 -0
- package/dist/copilot/tools/wiki.js +333 -0
- package/dist/copilot/tools-deps.js +4 -0
- package/dist/copilot/tools.agent.test.js +10 -8
- package/dist/copilot/tools.inventory.test.js +76 -0
- package/dist/copilot/tools.js +1 -1725
- package/dist/copilot/tools.okr.test.js +31 -0
- package/dist/copilot/tools.wiki.test.js +358 -6
- package/dist/copilot/turn-event-log.js +31 -4
- package/dist/copilot/turn-event-log.test.js +24 -2
- package/dist/copilot/workiq-installer.test.js +2 -2
- package/dist/daemon-install.js +3 -2
- package/dist/daemon.js +9 -17
- package/dist/integrations/ado-client.js +90 -9
- package/dist/integrations/ado-client.test.js +56 -0
- package/dist/integrations/team-push.js +1 -0
- package/dist/integrations/team-push.test.js +6 -0
- package/dist/integrations/teams-notify.js +1 -0
- package/dist/integrations/teams-notify.test.js +5 -0
- package/dist/memory/active-scope.test.js +0 -1
- package/dist/memory/checkpoint.js +89 -72
- package/dist/memory/checkpoint.test.js +23 -3
- package/dist/memory/eot.js +194 -89
- package/dist/memory/eot.test.js +186 -3
- package/dist/memory/hooks.js +2 -4
- package/dist/memory/housekeeping-scheduler.js +1 -1
- package/dist/memory/housekeeping-scheduler.test.js +1 -2
- package/dist/memory/housekeeping.js +100 -3
- package/dist/memory/housekeeping.test.js +33 -2
- package/dist/memory/reflect.test.js +2 -0
- package/dist/memory/scope-lock.js +26 -0
- package/dist/memory/scope-lock.test.js +118 -0
- package/dist/memory/scopes.test.js +0 -1
- package/dist/mode-context.js +58 -5
- package/dist/mode-context.test.js +68 -0
- package/dist/paths.js +1 -0
- package/dist/setup.js +3 -2
- package/dist/shared/api-schemas.js +48 -5
- package/dist/store/connection.js +96 -0
- package/dist/store/db.js +5 -1498
- package/dist/store/db.test.js +182 -1
- package/dist/store/migrations.js +460 -0
- package/dist/store/repositories/memory.js +281 -0
- package/dist/store/repositories/okr.js +3 -0
- package/dist/store/repositories/projects.js +5 -0
- package/dist/store/repositories/sessions.js +284 -0
- package/dist/store/repositories/wiki.js +60 -0
- package/dist/store/schema.js +501 -0
- package/dist/util/logger.js +3 -2
- package/dist/wiki/consolidation.js +50 -9
- package/dist/wiki/consolidation.test.js +45 -0
- package/dist/wiki/frontmatter.js +45 -14
- package/dist/wiki/frontmatter.test.js +26 -1
- package/dist/wiki/fs.js +16 -4
- package/dist/wiki/fs.test.js +84 -0
- package/dist/wiki/index-manager.js +30 -2
- package/dist/wiki/index-manager.test.js +43 -12
- package/dist/wiki/ingest.js +17 -1
- package/dist/wiki/lock.js +11 -1
- package/dist/wiki/log-manager.js +2 -7
- package/dist/wiki/migrate.js +44 -17
- package/dist/wiki/project-registry.js +10 -5
- package/dist/wiki/project-registry.test.js +14 -0
- package/dist/wiki/scheduler.js +1 -1
- package/dist/wiki/seed-team-wiki.js +2 -1
- package/dist/wiki/team-sync.js +31 -6
- package/dist/wiki/team-sync.test.js +81 -0
- package/package.json +1 -1
- package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
- package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
- package/web/dist/assets/index-CUm2Wbuh.js +250 -0
- package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-iQrv3lQN.js +0 -286
- 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) >=
|
|
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);
|
package/dist/mode-context.js
CHANGED
|
@@ -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
|
|
25
|
+
return this.getCapabilities().wikiSyncMode === "local+team";
|
|
14
26
|
}
|
|
15
27
|
canLogToAdo() {
|
|
16
|
-
return this.
|
|
28
|
+
return this.getCapabilities().integrationPolicy === "personal";
|
|
17
29
|
}
|
|
18
30
|
canSyncToTeams() {
|
|
19
|
-
return this.
|
|
31
|
+
return (this.getCapabilities().integrationPolicy === "team" &&
|
|
32
|
+
this.config.teamsNotificationsEnabled &&
|
|
33
|
+
this.config.teamsWebhookUrl.length > 0);
|
|
20
34
|
}
|
|
21
35
|
getMemorySyncCapability() {
|
|
22
|
-
return this.
|
|
36
|
+
return this.getCapabilities().wikiSyncMode;
|
|
23
37
|
}
|
|
24
38
|
requiresEntraAuth() {
|
|
25
|
-
return this.
|
|
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 =
|
|
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(
|
|
105
|
+
return Boolean(config.copilotAuthToken);
|
|
105
106
|
}
|
|
106
107
|
function getGhAuthStatus() {
|
|
107
108
|
try {
|