chapterhouse 0.9.2 → 0.11.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/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 -1780
- package/dist/copilot/tools.okr.test.js +31 -0
- package/dist/copilot/tools.wiki.test.js +6 -3
- 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 +87 -85
- package/dist/memory/eot.test.js +71 -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 +43 -13
- package/dist/wiki/frontmatter.test.js +24 -0
- 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 +1 -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-EBVoY1Pk.js +30 -0
- package/web/dist/assets/WikiEdit-EBVoY1Pk.js.map +1 -0
- package/web/dist/assets/WikiGraph-BUbbABq-.js +2 -0
- package/web/dist/assets/WikiGraph-BUbbABq-.js.map +1 -0
- package/web/dist/assets/icon-acolyte-cream.svg +10 -0
- package/web/dist/assets/icon-acolyte-dark.svg +10 -0
- package/web/dist/assets/icon-acolyte-gold.svg +10 -0
- package/web/dist/assets/icon-acolyte-ibad.svg +10 -0
- package/web/dist/assets/icon-acolyte-lit.svg +10 -0
- package/web/dist/assets/icon-acolyte-mono.svg +10 -0
- package/web/dist/assets/icon-acolyte.png +0 -0
- package/web/dist/assets/icon-acolyte.svg +10 -0
- package/web/dist/assets/index-BGLL9pgM.css +10 -0
- package/web/dist/assets/index-KFX8UmOb.js +250 -0
- package/web/dist/assets/index-KFX8UmOb.js.map +1 -0
- package/web/dist/index.html +6 -4
- package/web/dist/assets/index-5kz9aRU9.css +0 -10
- package/web/dist/assets/index-iQrv3lQN.js +0 -286
- package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
|
@@ -33,6 +33,37 @@ test("createTools registers OKR progress tools", async () => {
|
|
|
33
33
|
assert.ok(tools.some((tool) => tool.name === "get_my_okrs"));
|
|
34
34
|
assert.ok(tools.some((tool) => tool.name === "write_team_wiki"));
|
|
35
35
|
});
|
|
36
|
+
test("get_my_okrs does not fall back to the last authenticated user outside a turn", async () => {
|
|
37
|
+
const toolsModule = await loadToolsModule();
|
|
38
|
+
assert.ok(toolsModule, "tools module should exist");
|
|
39
|
+
const dbModule = await import("../store/db.js");
|
|
40
|
+
dbModule.setState("last_authenticated_user", JSON.stringify({
|
|
41
|
+
id: "u-ada",
|
|
42
|
+
name: "Ada Lovelace",
|
|
43
|
+
email: "ada@example.com",
|
|
44
|
+
role: "engineer",
|
|
45
|
+
}));
|
|
46
|
+
let fetchAttempts = 0;
|
|
47
|
+
const tools = toolsModule.createTools({
|
|
48
|
+
client: { async listModels() { return []; } },
|
|
49
|
+
onAgentTaskComplete: () => { },
|
|
50
|
+
createTeamPushClient: () => ({
|
|
51
|
+
async pushUpdate() {
|
|
52
|
+
throw new Error("not used in this test");
|
|
53
|
+
},
|
|
54
|
+
async fetchOKRs() {
|
|
55
|
+
fetchAttempts += 1;
|
|
56
|
+
return "";
|
|
57
|
+
},
|
|
58
|
+
async writePage(path) {
|
|
59
|
+
return { ok: true, path };
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
const result = await tools.find((tool) => tool.name === "get_my_okrs")?.handler({});
|
|
64
|
+
assert.match(result, /current user identity/i);
|
|
65
|
+
assert.equal(fetchAttempts, 0, "tool must not use persisted last-user identity");
|
|
66
|
+
});
|
|
36
67
|
test("log_okr_progress suggests a KR when the user did not specify one", async () => {
|
|
37
68
|
const toolsModule = await loadToolsModule();
|
|
38
69
|
assert.ok(toolsModule, "tools module should exist");
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
|
+
import { withWikiWrite } from "../wiki/lock.js";
|
|
5
6
|
async function loadToolsModule() {
|
|
6
7
|
return await import(new URL(`./tools.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
7
8
|
}
|
|
@@ -513,9 +514,11 @@ updated: 2026-05-12
|
|
|
513
514
|
|
|
514
515
|
# Rust
|
|
515
516
|
`);
|
|
516
|
-
|
|
517
|
-
indexManager.
|
|
518
|
-
|
|
517
|
+
await withWikiWrite(() => {
|
|
518
|
+
for (const entry of indexManager.parseIndex()) {
|
|
519
|
+
indexManager.removeFromIndex(entry.path);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
519
522
|
assert.equal(indexManager.parseIndex().length, 0, "Precondition: wiki_pages should start empty");
|
|
520
523
|
const result = await wikiReindex.handler({});
|
|
521
524
|
assert.match(result, /^Reindexed \d+ wiki page\(s\) from disk\.$/);
|
|
@@ -44,12 +44,37 @@ const turnBuffers = new Map();
|
|
|
44
44
|
const turnListeners = new Map();
|
|
45
45
|
/** Per-session ring buffer for SSE reconnect replay (sliding window across turns). */
|
|
46
46
|
const sessionBuffers = new Map();
|
|
47
|
-
/**
|
|
47
|
+
/** Warn when one session accumulates an unusual number of SSE listeners. */
|
|
48
|
+
export const SESSION_LISTENER_WARN_THRESHOLD = 50;
|
|
49
|
+
/** Hard cap to evict oldest leaked SSE listeners for one session. */
|
|
50
|
+
export const SESSION_LISTENER_MAX = 100;
|
|
51
|
+
/** Per-session live listeners (SSE connections), insertion-ordered for oldest-first eviction. */
|
|
48
52
|
const sessionListeners = new Map();
|
|
49
53
|
/** Pending clear-buffer timers keyed by turnId. */
|
|
50
54
|
const clearTimers = new Map();
|
|
51
55
|
/** Monotonic global sequence counter — used as SSE `id:` for Last-Event-ID replay. */
|
|
52
56
|
let globalSeq = 0;
|
|
57
|
+
function warnHighSessionListenerCount(sessionKey, listenerCount) {
|
|
58
|
+
if (listenerCount !== SESSION_LISTENER_WARN_THRESHOLD + 1)
|
|
59
|
+
return;
|
|
60
|
+
log.warn({ sessionKey, listenerCount, threshold: SESSION_LISTENER_WARN_THRESHOLD }, "turn-event-log: high session listener count may indicate leaked SSE subscribers");
|
|
61
|
+
}
|
|
62
|
+
function evictOverflowSessionListeners(sessionKey, listeners) {
|
|
63
|
+
const overflow = listeners.size - SESSION_LISTENER_MAX;
|
|
64
|
+
if (overflow <= 0)
|
|
65
|
+
return;
|
|
66
|
+
let evicted = 0;
|
|
67
|
+
for (const [listener] of listeners) {
|
|
68
|
+
listeners.delete(listener);
|
|
69
|
+
evicted += 1;
|
|
70
|
+
if (evicted >= overflow)
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
if (listeners.size === 0) {
|
|
74
|
+
sessionListeners.delete(sessionKey);
|
|
75
|
+
}
|
|
76
|
+
log.warn({ sessionKey, evicted, listenerCount: listeners.size, maxListeners: SESSION_LISTENER_MAX }, "turn-event-log: evicted oldest session listeners after exceeding the max listener cap");
|
|
77
|
+
}
|
|
53
78
|
// ---------------------------------------------------------------------------
|
|
54
79
|
// Emit
|
|
55
80
|
// ---------------------------------------------------------------------------
|
|
@@ -90,7 +115,7 @@ export function emitTurnEvent(sessionKey, event) {
|
|
|
90
115
|
// Notify per-session listeners (SSE connections) -------------------------
|
|
91
116
|
const sListeners = sessionListeners.get(sessionKey);
|
|
92
117
|
if (sListeners) {
|
|
93
|
-
for (const fn of sListeners) {
|
|
118
|
+
for (const fn of sListeners.keys()) {
|
|
94
119
|
try {
|
|
95
120
|
fn(indexed);
|
|
96
121
|
}
|
|
@@ -169,10 +194,12 @@ export function subscribeSession(sessionKey, listener, afterSeq) {
|
|
|
169
194
|
// Register live listener
|
|
170
195
|
let ls = sessionListeners.get(sessionKey);
|
|
171
196
|
if (!ls) {
|
|
172
|
-
ls = new
|
|
197
|
+
ls = new Map();
|
|
173
198
|
sessionListeners.set(sessionKey, ls);
|
|
174
199
|
}
|
|
175
|
-
ls.
|
|
200
|
+
ls.set(listener, true);
|
|
201
|
+
warnHighSessionListenerCount(sessionKey, ls.size);
|
|
202
|
+
evictOverflowSessionListeners(sessionKey, ls);
|
|
176
203
|
return () => {
|
|
177
204
|
const set = sessionListeners.get(sessionKey);
|
|
178
205
|
if (set) {
|
|
@@ -22,17 +22,24 @@
|
|
|
22
22
|
* SQLite-dependent functions (persistTurnEvents, getSessionEventsFromDb) are
|
|
23
23
|
* tested in the integration suite to avoid needing a real DB here.
|
|
24
24
|
*/
|
|
25
|
-
import { describe, it, afterEach } from "node:test";
|
|
25
|
+
import { describe, it, afterEach, after } from "node:test";
|
|
26
26
|
import assert from "node:assert/strict";
|
|
27
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
27
29
|
import { setTimeout as setTimeoutPromise } from "node:timers/promises";
|
|
28
|
-
import { emitTurnEvent, subscribeTurn, subscribeSession, clearTurnLog, scheduleClearTurnLog, turnLogSize, oldestSessionSeq, persistTurnEvents, getSessionEventsFromDb, TURN_BUFFER_CAPACITY, SESSION_BUFFER_CAPACITY, } from "./turn-event-log.js";
|
|
30
|
+
import { emitTurnEvent, subscribeTurn, subscribeSession, clearTurnLog, scheduleClearTurnLog, turnLogSize, oldestSessionSeq, persistTurnEvents, getSessionEventsFromDb, TURN_BUFFER_CAPACITY, SESSION_BUFFER_CAPACITY, SESSION_LISTENER_MAX, } from "./turn-event-log.js";
|
|
29
31
|
import { getDb } from "../store/db.js";
|
|
32
|
+
import { resetSingletons } from "../test/helpers/reset-singletons.js";
|
|
30
33
|
// ---------------------------------------------------------------------------
|
|
31
34
|
// Helpers
|
|
32
35
|
// ---------------------------------------------------------------------------
|
|
33
36
|
let turnCounter = 0;
|
|
34
37
|
let sessionCounter = 0;
|
|
35
38
|
const usedSessionKeys = [];
|
|
39
|
+
const sandboxRoot = join(process.cwd(), ".test-work", `turn-event-log-${process.pid}`);
|
|
40
|
+
mkdirSync(sandboxRoot, { recursive: true });
|
|
41
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
42
|
+
resetSingletons();
|
|
36
43
|
function freshTurnId() {
|
|
37
44
|
return `turn-test-${++turnCounter}-${Date.now()}`;
|
|
38
45
|
}
|
|
@@ -76,6 +83,10 @@ afterEach(() => {
|
|
|
76
83
|
}
|
|
77
84
|
}
|
|
78
85
|
});
|
|
86
|
+
after(() => {
|
|
87
|
+
resetSingletons();
|
|
88
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
89
|
+
});
|
|
79
90
|
function trackTurn(turnId) {
|
|
80
91
|
usedTurnIds.push(turnId);
|
|
81
92
|
return turnId;
|
|
@@ -260,6 +271,17 @@ describe("turn-event-log", () => {
|
|
|
260
271
|
emitTurnEvent(session, makeComplete(turnId, session));
|
|
261
272
|
assert.equal(received.length, 1);
|
|
262
273
|
});
|
|
274
|
+
it("evicts the oldest session listeners when one session exceeds the max listener cap", () => {
|
|
275
|
+
const session = freshSessionKey();
|
|
276
|
+
const turnId = trackTurn(freshTurnId());
|
|
277
|
+
const deliveries = Array.from({ length: SESSION_LISTENER_MAX + 1 }, () => []);
|
|
278
|
+
for (const received of deliveries) {
|
|
279
|
+
trackUnsub(subscribeSession(session, (event) => received.push(event)));
|
|
280
|
+
}
|
|
281
|
+
emitTurnEvent(session, makeStarted(turnId, session));
|
|
282
|
+
assert.equal(deliveries[0]?.length, 0, "oldest listener should be evicted once the cap is exceeded");
|
|
283
|
+
assert.equal(deliveries.at(-1)?.length, 1, "most recent listener should still receive events");
|
|
284
|
+
});
|
|
263
285
|
it("two sessions are isolated from each other", () => {
|
|
264
286
|
const session1 = freshSessionKey();
|
|
265
287
|
const session2 = freshSessionKey();
|
|
@@ -68,12 +68,12 @@ function makeFs(initialJson) {
|
|
|
68
68
|
let stored = initialJson ?? null;
|
|
69
69
|
const written = [];
|
|
70
70
|
return {
|
|
71
|
-
readFile: (
|
|
71
|
+
readFile: (_path, _enc) => {
|
|
72
72
|
if (stored === null)
|
|
73
73
|
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
74
74
|
return stored;
|
|
75
75
|
},
|
|
76
|
-
writeFile: (
|
|
76
|
+
writeFile: (_path, data, _enc) => {
|
|
77
77
|
stored = data;
|
|
78
78
|
written.push(data);
|
|
79
79
|
},
|
package/dist/daemon-install.js
CHANGED
|
@@ -7,6 +7,7 @@ import { execSync, execFileSync } from "child_process";
|
|
|
7
7
|
import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
8
8
|
import { join, dirname } from "path";
|
|
9
9
|
import { homedir, platform } from "os";
|
|
10
|
+
import { config } from "./config.js";
|
|
10
11
|
/** The launchd label / systemd unit name. */
|
|
11
12
|
export const DAEMON_LABEL = "com.bketelsen.chapterhouse";
|
|
12
13
|
export const DAEMON_UNIT_NAME = "chapterhouse";
|
|
@@ -80,7 +81,7 @@ function composeMacOSPath(binDir, shellPath) {
|
|
|
80
81
|
/** Generate the launchd plist XML string. */
|
|
81
82
|
export function generatePlist(options) {
|
|
82
83
|
const label = options.label ?? DAEMON_LABEL;
|
|
83
|
-
const shellPath = options.shellPath ??
|
|
84
|
+
const shellPath = options.shellPath ?? config.shellPath;
|
|
84
85
|
const richPath = composeMacOSPath(dirname(options.binPath), shellPath);
|
|
85
86
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
86
87
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -135,7 +136,7 @@ function composeSystemdPath(binDir, shellPath) {
|
|
|
135
136
|
/** Generate the systemd user unit file string. */
|
|
136
137
|
export function generateSystemdUnit(options) {
|
|
137
138
|
const description = options.description ?? "Chapterhouse AI assistant daemon";
|
|
138
|
-
const shellPath = options.shellPath ??
|
|
139
|
+
const shellPath = options.shellPath ?? config.shellPath;
|
|
139
140
|
const richPath = composeSystemdPath(dirname(options.binPath), shellPath);
|
|
140
141
|
return `[Unit]
|
|
141
142
|
Description=${description}
|
package/dist/daemon.js
CHANGED
|
@@ -12,6 +12,7 @@ import { checkForUpdate } from "./update.js";
|
|
|
12
12
|
import { ensureWikiStructure } from "./wiki/fs.js";
|
|
13
13
|
import { seedTeamWiki } from "./wiki/seed-team-wiki.js";
|
|
14
14
|
import { shouldMigrate, migrateMemoriesToWiki, shouldReorganize, reorganizeWiki } from "./wiki/migrate.js";
|
|
15
|
+
import { withWikiWrite } from "./wiki/lock.js";
|
|
15
16
|
import { shouldEnforceTopics, enforceTopicStructure } from "./wiki/migrate-topics.js";
|
|
16
17
|
import { SESSIONS_DIR } from "./paths.js";
|
|
17
18
|
import { getDisplayHost } from "./api/server-runtime.js";
|
|
@@ -37,15 +38,7 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
37
38
|
* Layer 3 — systemd kill: TimeoutStopSec=90 s (must exceed layer 2)
|
|
38
39
|
* Allows in-flight LLM streams to complete before the process is torn down.
|
|
39
40
|
*/
|
|
40
|
-
const SHUTDOWN_TIMEOUT_MS =
|
|
41
|
-
const env = process.env.CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS;
|
|
42
|
-
if (env) {
|
|
43
|
-
const parsed = parseInt(env, 10);
|
|
44
|
-
if (!isNaN(parsed) && parsed > 0)
|
|
45
|
-
return parsed;
|
|
46
|
-
}
|
|
47
|
-
return 60_000;
|
|
48
|
-
})();
|
|
41
|
+
const SHUTDOWN_TIMEOUT_MS = config.shutdownTimeoutMs;
|
|
49
42
|
/** Remove orphaned session folders older than 7 days, preserving the current session. */
|
|
50
43
|
function pruneOldSessions() {
|
|
51
44
|
try {
|
|
@@ -95,10 +88,9 @@ async function main() {
|
|
|
95
88
|
for (const warning of config.modeCompatibilityWarnings) {
|
|
96
89
|
log.warn({ mode: config.chapterhouseMode }, warning);
|
|
97
90
|
}
|
|
91
|
+
log.info({ capabilities: modeContext.getCapabilities() }, modeContext.getStartupCapabilitySummary());
|
|
98
92
|
// Set up message logging to daemon console
|
|
99
93
|
setMessageLogger((direction, source, text) => {
|
|
100
|
-
const arrow = direction === "in" ? "⟶" : "⟵";
|
|
101
|
-
const tag = source.padEnd(8);
|
|
102
94
|
log.debug({ direction, source, text: truncate(text) }, "chat");
|
|
103
95
|
});
|
|
104
96
|
// Initialize SQLite
|
|
@@ -110,19 +102,19 @@ async function main() {
|
|
|
110
102
|
log.info("Created wiki");
|
|
111
103
|
}
|
|
112
104
|
if (modeContext.isTeam()) {
|
|
113
|
-
const seed = seedTeamWiki();
|
|
105
|
+
const seed = await withWikiWrite(() => seedTeamWiki());
|
|
114
106
|
if (seed.created.length > 0) {
|
|
115
107
|
log.info({ pages: seed.created }, "Seeded team wiki pages");
|
|
116
108
|
}
|
|
117
109
|
}
|
|
118
110
|
if (shouldMigrate()) {
|
|
119
111
|
log.info("Migrating SQLite memories to wiki");
|
|
120
|
-
const count = migrateMemoriesToWiki();
|
|
112
|
+
const count = await withWikiWrite(() => migrateMemoriesToWiki());
|
|
121
113
|
log.info({ count }, "Migrated memories to wiki");
|
|
122
114
|
}
|
|
123
115
|
if (shouldReorganize()) {
|
|
124
116
|
log.info("Reorganizing wiki pages into entity structure");
|
|
125
|
-
const count = reorganizeWiki();
|
|
117
|
+
const count = await withWikiWrite(() => reorganizeWiki());
|
|
126
118
|
log.info({ count }, "Created entity pages during reorganization");
|
|
127
119
|
}
|
|
128
120
|
if (shouldEnforceTopics()) {
|
|
@@ -144,7 +136,7 @@ async function main() {
|
|
|
144
136
|
// Prune orphaned session folders older than 7 days
|
|
145
137
|
pruneOldSessions();
|
|
146
138
|
// One-time deprecation note for legacy Telegram users (v1 → v2)
|
|
147
|
-
if (
|
|
139
|
+
if (config.telegramBotTokenConfigured) {
|
|
148
140
|
log.warn("TELEGRAM_BOT_TOKEN found in env — Telegram support was removed in v2. The web UI is now the only client.");
|
|
149
141
|
}
|
|
150
142
|
// Auto-install workiq MCP server when Entra is configured
|
|
@@ -180,7 +172,7 @@ async function main() {
|
|
|
180
172
|
}
|
|
181
173
|
const url = `http://${getDisplayHost(config.apiHost)}:${config.apiPort}`;
|
|
182
174
|
log.info({ url }, "Chapterhouse is fully operational");
|
|
183
|
-
if (
|
|
175
|
+
if (config.openBrowserOnStart) {
|
|
184
176
|
const opener = process.platform === "darwin" ? "open" :
|
|
185
177
|
process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
|
186
178
|
try {
|
|
@@ -198,7 +190,7 @@ async function main() {
|
|
|
198
190
|
}
|
|
199
191
|
})
|
|
200
192
|
.catch(() => { }); // silent — network may be unavailable
|
|
201
|
-
if (
|
|
193
|
+
if (config.restarted) {
|
|
202
194
|
delete process.env.CHAPTERHOUSE_RESTARTED;
|
|
203
195
|
}
|
|
204
196
|
}
|
|
@@ -4,6 +4,85 @@ import { ADO_ORG, ADO_PROJECT, FIELDS, STD_FIELDS, UNIT_FIELD, WIT } from "./ado
|
|
|
4
4
|
function escapeWiqlLiteral(value) {
|
|
5
5
|
return value.replace(/'/g, "''");
|
|
6
6
|
}
|
|
7
|
+
const ALLOWED_WIQL_FIELDS = new Set([
|
|
8
|
+
"System.Id",
|
|
9
|
+
"System.TeamProject",
|
|
10
|
+
"System.WorkItemType",
|
|
11
|
+
STD_FIELDS.TITLE,
|
|
12
|
+
STD_FIELDS.STATE,
|
|
13
|
+
STD_FIELDS.PARENT,
|
|
14
|
+
STD_FIELDS.ASSIGNED_TO,
|
|
15
|
+
STD_FIELDS.TAGS,
|
|
16
|
+
FIELDS.CURRENT_VALUE,
|
|
17
|
+
FIELDS.TARGET_VALUE,
|
|
18
|
+
FIELDS.OKR_PERIOD,
|
|
19
|
+
FIELDS.OKR_OWNER,
|
|
20
|
+
UNIT_FIELD,
|
|
21
|
+
]);
|
|
22
|
+
const ALLOWED_WIQL_OPERATORS = new Set(["=", "<>", "IN", "CONTAINS"]);
|
|
23
|
+
const ALLOWED_WIQL_MACROS = new Set(["@project"]);
|
|
24
|
+
function quoteWiqlField(field) {
|
|
25
|
+
if (!ALLOWED_WIQL_FIELDS.has(field)) {
|
|
26
|
+
throw new Error(`Unsupported WIQL field: ${field}`);
|
|
27
|
+
}
|
|
28
|
+
return `[${field}]`;
|
|
29
|
+
}
|
|
30
|
+
function normalizeWiqlOperator(operator) {
|
|
31
|
+
if (ALLOWED_WIQL_OPERATORS.has(operator)) {
|
|
32
|
+
return operator;
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Unsupported WIQL operator: ${operator}`);
|
|
35
|
+
}
|
|
36
|
+
function formatWiqlScalar(value) {
|
|
37
|
+
if (typeof value === "number") {
|
|
38
|
+
if (!Number.isFinite(value)) {
|
|
39
|
+
throw new Error(`Unsupported WIQL number: ${value}`);
|
|
40
|
+
}
|
|
41
|
+
return String(value);
|
|
42
|
+
}
|
|
43
|
+
return `'${escapeWiqlLiteral(value)}'`;
|
|
44
|
+
}
|
|
45
|
+
function formatWiqlValue(value, operator) {
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
if (operator !== "IN") {
|
|
48
|
+
throw new Error(`WIQL operator ${operator} does not accept a list value.`);
|
|
49
|
+
}
|
|
50
|
+
if (value.length === 0) {
|
|
51
|
+
throw new Error("WIQL IN clauses require at least one value.");
|
|
52
|
+
}
|
|
53
|
+
return `(${value.map((item) => formatWiqlScalar(item)).join(", ")})`;
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === "object" && value !== null && "macro" in value) {
|
|
56
|
+
if (!ALLOWED_WIQL_MACROS.has(value.macro)) {
|
|
57
|
+
throw new Error(`Unsupported WIQL macro: ${value.macro}`);
|
|
58
|
+
}
|
|
59
|
+
return value.macro;
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
62
|
+
return formatWiqlScalar(value);
|
|
63
|
+
}
|
|
64
|
+
throw new Error("Unsupported WIQL value.");
|
|
65
|
+
}
|
|
66
|
+
function formatWiqlCondition(condition) {
|
|
67
|
+
const field = quoteWiqlField(condition.field);
|
|
68
|
+
const operator = normalizeWiqlOperator(condition.operator);
|
|
69
|
+
return `${field} ${operator} ${formatWiqlValue(condition.value, operator)}`;
|
|
70
|
+
}
|
|
71
|
+
export function buildWiqlQuery(query) {
|
|
72
|
+
if (query.select.length === 0) {
|
|
73
|
+
throw new Error("WIQL queries must select at least one field.");
|
|
74
|
+
}
|
|
75
|
+
if (query.where.length === 0) {
|
|
76
|
+
throw new Error("WIQL queries must include at least one filter.");
|
|
77
|
+
}
|
|
78
|
+
const selectClause = query.select.map((field) => quoteWiqlField(field)).join(", ");
|
|
79
|
+
const whereClause = query.where.map((condition) => formatWiqlCondition(condition)).join(" AND ");
|
|
80
|
+
return [
|
|
81
|
+
`SELECT ${selectClause}`,
|
|
82
|
+
"FROM WorkItems",
|
|
83
|
+
`WHERE ${whereClause}`,
|
|
84
|
+
].join(" ");
|
|
85
|
+
}
|
|
7
86
|
function getStringField(fields, fieldName) {
|
|
8
87
|
const value = fields?.[fieldName];
|
|
9
88
|
if (typeof value === "string") {
|
|
@@ -47,15 +126,17 @@ function toPercentComplete(currentValue, targetValue) {
|
|
|
47
126
|
return Math.max(0, Math.min(100, Math.round((currentValue / targetValue) * 100)));
|
|
48
127
|
}
|
|
49
128
|
function buildWiql(workItemType, period) {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
: ""
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
129
|
+
const where = [
|
|
130
|
+
{ field: "System.TeamProject", operator: "=", value: { macro: "@project" } },
|
|
131
|
+
{ field: "System.WorkItemType", operator: "=", value: workItemType },
|
|
132
|
+
];
|
|
133
|
+
if (period) {
|
|
134
|
+
where.push({ field: FIELDS.OKR_PERIOD, operator: "=", value: period });
|
|
135
|
+
}
|
|
136
|
+
return buildWiqlQuery({
|
|
137
|
+
select: ["System.Id"],
|
|
138
|
+
where,
|
|
139
|
+
});
|
|
59
140
|
}
|
|
60
141
|
function getWorkItemIds(result) {
|
|
61
142
|
return (result.workItems ?? [])
|
|
@@ -173,4 +173,60 @@ test("getOKRSummary computes percent complete from KR progress", async () => {
|
|
|
173
173
|
assert.equal(summary.objectives[0]?.keyResults[0]?.percentComplete, 75);
|
|
174
174
|
assert.equal(summary.objectives[0]?.keyResults[1]?.percentComplete, 50);
|
|
175
175
|
});
|
|
176
|
+
test("buildWiqlQuery rejects non-whitelisted field names", async () => {
|
|
177
|
+
const ado = await loadAdoClientModule();
|
|
178
|
+
assert.ok(ado, "ado client module should exist");
|
|
179
|
+
assert.throws(() => ado.buildWiqlQuery({
|
|
180
|
+
select: ["System.Id"],
|
|
181
|
+
where: [
|
|
182
|
+
{
|
|
183
|
+
field: "System.Title] OR [System.Id",
|
|
184
|
+
operator: "=",
|
|
185
|
+
value: "Ship SSO to all tenants",
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
}), /Unsupported WIQL field/);
|
|
189
|
+
});
|
|
190
|
+
test("buildWiqlQuery rejects non-whitelisted operators", async () => {
|
|
191
|
+
const ado = await loadAdoClientModule();
|
|
192
|
+
assert.ok(ado, "ado client module should exist");
|
|
193
|
+
assert.throws(() => ado.buildWiqlQuery({
|
|
194
|
+
select: ["System.Id"],
|
|
195
|
+
where: [
|
|
196
|
+
{
|
|
197
|
+
field: "System.Title",
|
|
198
|
+
operator: "= '' OR [System.Id] > 0",
|
|
199
|
+
value: "Ship SSO to all tenants",
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
}), /Unsupported WIQL operator/);
|
|
203
|
+
});
|
|
204
|
+
test("getKeyResults escapes WIQL literal values before querying", async () => {
|
|
205
|
+
const ado = await loadAdoClientModule();
|
|
206
|
+
assert.ok(ado, "ado client module should exist");
|
|
207
|
+
const queries = [];
|
|
208
|
+
const client = new ado.AdoClient({
|
|
209
|
+
org: "https://dev.azure.com/example-org",
|
|
210
|
+
project: "example-project",
|
|
211
|
+
pat: "test-pat",
|
|
212
|
+
workItemTrackingApi: {
|
|
213
|
+
async queryByWiql(wiql) {
|
|
214
|
+
queries.push(wiql.query ?? "");
|
|
215
|
+
return { workItems: [] };
|
|
216
|
+
},
|
|
217
|
+
async getWorkItems() {
|
|
218
|
+
throw new Error("not used in this test");
|
|
219
|
+
},
|
|
220
|
+
async updateWorkItem() {
|
|
221
|
+
throw new Error("not used in this test");
|
|
222
|
+
},
|
|
223
|
+
async addComment() {
|
|
224
|
+
throw new Error("not used in this test");
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
await client.getKeyResults("2026-Q2' OR [System.Id] > 0 OR 'x'='x");
|
|
229
|
+
assert.equal(queries.length, 1);
|
|
230
|
+
assert.match(queries[0] ?? "", /\[Custom\.OKRPeriod\] = '2026-Q2'' OR \[System\.Id\] > 0 OR ''x''=''x'/);
|
|
231
|
+
});
|
|
176
232
|
//# sourceMappingURL=ado-client.test.js.map
|
|
@@ -17,6 +17,7 @@ export class TeamPushClient {
|
|
|
17
17
|
this.getCurrentUser = options.getCurrentUser;
|
|
18
18
|
this.modeContext = new ModeContext({
|
|
19
19
|
...config,
|
|
20
|
+
chapterhouseMode: options.chapterhouseMode ?? config.chapterhouseMode,
|
|
20
21
|
teamChapterhouseUrl: this.teamChapterhouseUrl,
|
|
21
22
|
standaloneMode: this.standaloneMode,
|
|
22
23
|
});
|
|
@@ -13,6 +13,7 @@ test("pushUpdate sends the expected payload to the team update endpoint", async
|
|
|
13
13
|
assert.ok(teamPushModule, "team push module should exist");
|
|
14
14
|
const requests = [];
|
|
15
15
|
const client = new teamPushModule.TeamPushClient({
|
|
16
|
+
chapterhouseMode: "team",
|
|
16
17
|
teamChapterhouseUrl: "https://team.example.com/",
|
|
17
18
|
standaloneMode: false,
|
|
18
19
|
getAuthorizationHeader: () => "Bearer entra-token",
|
|
@@ -61,6 +62,7 @@ test("pushUpdate throws descriptive errors for auth and network failures", async
|
|
|
61
62
|
const teamPushModule = await loadTeamPushModule();
|
|
62
63
|
assert.ok(teamPushModule, "team push module should exist");
|
|
63
64
|
const unauthorizedClient = new teamPushModule.TeamPushClient({
|
|
65
|
+
chapterhouseMode: "team",
|
|
64
66
|
teamChapterhouseUrl: "https://team.example.com",
|
|
65
67
|
teamChapterhouseToken: "fallback-token",
|
|
66
68
|
standaloneMode: false,
|
|
@@ -73,6 +75,7 @@ test("pushUpdate throws descriptive errors for auth and network failures", async
|
|
|
73
75
|
});
|
|
74
76
|
await assert.rejects(unauthorizedClient.pushUpdate({ activity: "shipped auth refactor", krId: "O1-KR2", delta: 5 }), /Failed to push OKR update: unauthorized \(HTTP 401\)/i);
|
|
75
77
|
const networkClient = new teamPushModule.TeamPushClient({
|
|
78
|
+
chapterhouseMode: "team",
|
|
76
79
|
teamChapterhouseUrl: "https://team.example.com",
|
|
77
80
|
standaloneMode: false,
|
|
78
81
|
getAuthorizationHeader: () => "Bearer entra-token",
|
|
@@ -92,6 +95,7 @@ test("fetchOKRs returns raw OKR page content for the requested period", async ()
|
|
|
92
95
|
assert.ok(teamPushModule, "team push module should exist");
|
|
93
96
|
const requests = [];
|
|
94
97
|
const client = new teamPushModule.TeamPushClient({
|
|
98
|
+
chapterhouseMode: "team",
|
|
95
99
|
teamChapterhouseUrl: "https://team.example.com",
|
|
96
100
|
standaloneMode: false,
|
|
97
101
|
getAuthorizationHeader: () => "Bearer entra-token",
|
|
@@ -120,6 +124,7 @@ test("writePage PUTs shared wiki content to the team wiki endpoint", async () =>
|
|
|
120
124
|
assert.ok(teamPushModule, "team push module should exist");
|
|
121
125
|
const requests = [];
|
|
122
126
|
const client = new teamPushModule.TeamPushClient({
|
|
127
|
+
chapterhouseMode: "team",
|
|
123
128
|
teamChapterhouseUrl: "https://team.example.com/",
|
|
124
129
|
standaloneMode: false,
|
|
125
130
|
getAuthorizationHeader: () => "Bearer entra-token",
|
|
@@ -152,6 +157,7 @@ test("team push silently no-ops when team integration is disabled", async () =>
|
|
|
152
157
|
assert.ok(teamPushModule, "team push module should exist");
|
|
153
158
|
let called = false;
|
|
154
159
|
const client = new teamPushModule.TeamPushClient({
|
|
160
|
+
chapterhouseMode: "team",
|
|
155
161
|
teamChapterhouseUrl: "",
|
|
156
162
|
standaloneMode: false,
|
|
157
163
|
fetchImpl: async () => {
|
|
@@ -17,6 +17,7 @@ export class TeamsNotifier {
|
|
|
17
17
|
this.warn = options.warn ?? ((message) => log.warn(message));
|
|
18
18
|
this.modeContext = new ModeContext({
|
|
19
19
|
...config,
|
|
20
|
+
chapterhouseMode: options.chapterhouseMode ?? config.chapterhouseMode,
|
|
20
21
|
teamsWebhookUrl: this.webhookUrl,
|
|
21
22
|
teamsNotificationsEnabled: this.enabled,
|
|
22
23
|
});
|
|
@@ -13,6 +13,7 @@ test("sendMessage formats a Teams MessageCard payload", async () => {
|
|
|
13
13
|
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
14
14
|
const calls = [];
|
|
15
15
|
const notifier = new teamsNotify.TeamsNotifier({
|
|
16
|
+
chapterhouseMode: "team",
|
|
16
17
|
webhookUrl: "https://teams.example.test/webhook",
|
|
17
18
|
enabled: true,
|
|
18
19
|
fetchImpl: async (input, init) => {
|
|
@@ -39,6 +40,7 @@ for (const milestone of [25, 50, 75, 100]) {
|
|
|
39
40
|
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
40
41
|
const calls = [];
|
|
41
42
|
const notifier = new teamsNotify.TeamsNotifier({
|
|
43
|
+
chapterhouseMode: "team",
|
|
42
44
|
webhookUrl: "https://teams.example.test/webhook",
|
|
43
45
|
enabled: true,
|
|
44
46
|
fetchImpl: async (input, init) => {
|
|
@@ -70,6 +72,7 @@ test("sendMessage does not call Teams when notifications are disabled", async ()
|
|
|
70
72
|
let fetchCalls = 0;
|
|
71
73
|
const warnings = [];
|
|
72
74
|
const notifier = new teamsNotify.TeamsNotifier({
|
|
75
|
+
chapterhouseMode: "team",
|
|
73
76
|
webhookUrl: "https://teams.example.test/webhook",
|
|
74
77
|
enabled: false,
|
|
75
78
|
fetchImpl: async () => {
|
|
@@ -89,6 +92,7 @@ test("notifyWeeklyHealthCheck sends an OKR summary card", async () => {
|
|
|
89
92
|
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
90
93
|
const calls = [];
|
|
91
94
|
const notifier = new teamsNotify.TeamsNotifier({
|
|
95
|
+
chapterhouseMode: "team",
|
|
92
96
|
webhookUrl: "https://teams.example.test/webhook",
|
|
93
97
|
enabled: true,
|
|
94
98
|
fetchImpl: async (input, init) => {
|
|
@@ -114,6 +118,7 @@ test("notifyStandup sends the member update summary", async () => {
|
|
|
114
118
|
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
115
119
|
const calls = [];
|
|
116
120
|
const notifier = new teamsNotify.TeamsNotifier({
|
|
121
|
+
chapterhouseMode: "team",
|
|
117
122
|
webhookUrl: "https://teams.example.test/webhook",
|
|
118
123
|
enabled: true,
|
|
119
124
|
fetchImpl: async (input, init) => {
|
|
@@ -34,7 +34,6 @@ test.after(async () => {
|
|
|
34
34
|
test("active scope can be set, read, and cleared without changing scope activation status", async () => {
|
|
35
35
|
const { dbModule, memoryModule } = await loadModules();
|
|
36
36
|
dbModule.getDb();
|
|
37
|
-
const getScope = getFunction(memoryModule, "getScope");
|
|
38
37
|
const getActiveScope = getFunction(memoryModule, "getActiveScope");
|
|
39
38
|
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
40
39
|
const deactivateScope = getFunction(memoryModule, "deactivateScope");
|