chapterhouse 0.1.5 → 0.2.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/dist/store/db.js CHANGED
@@ -150,6 +150,13 @@ export function getDb() {
150
150
  if (!taskCols.some((c) => c.name === 'source')) {
151
151
  db.exec(`ALTER TABLE agent_tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'adhoc'`);
152
152
  }
153
+ // Migrate: add last_used_at column to project_squads (epoch ms, nullable)
154
+ const projectCols = db.prepare(`PRAGMA table_info(project_squads)`).all();
155
+ if (!projectCols.some((c) => c.name === 'last_used_at')) {
156
+ db.exec(`ALTER TABLE project_squads ADD COLUMN last_used_at INTEGER`);
157
+ // Backfill from loaded_at so sidebar is not empty for existing projects
158
+ db.exec(`UPDATE project_squads SET last_used_at = CAST((julianday(loaded_at) - 2440587.5) * 86400000 AS INTEGER) WHERE last_used_at IS NULL`);
159
+ }
153
160
  // Prune conversation log at startup — keep more history for better recovery
154
161
  db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
155
162
  // Set up FTS5 for memory search (graceful fallback if not available)
@@ -280,11 +287,42 @@ export function getRecentConversation(limit, sessionKey) {
280
287
  return `${tag}: ${content}`;
281
288
  }).join("\n\n");
282
289
  }
290
+ const MAX_SESSION_MESSAGES_LIMIT = 500;
291
+ const DEFAULT_SESSION_MESSAGES_LIMIT = 100;
292
+ /**
293
+ * Return conversation_log rows for a specific session as structured JSON,
294
+ * suitable for seeding the frontend Zustand store on mount.
295
+ *
296
+ * Unlike `getRecentConversation()`, this returns structured objects (not a
297
+ * formatted string) and omits system messages (role = 'system') because the
298
+ * UI only renders user/assistant turns.
299
+ */
300
+ export function getSessionMessages(sessionKey, limit) {
301
+ const db = getDb();
302
+ const effectiveLimit = Math.min(limit ?? DEFAULT_SESSION_MESSAGES_LIMIT, MAX_SESSION_MESSAGES_LIMIT);
303
+ const rows = db
304
+ .prepare(`SELECT role, content, ts FROM conversation_log
305
+ WHERE session_key = ? AND role IN ('user', 'assistant')
306
+ ORDER BY id DESC LIMIT ?`)
307
+ .all(sessionKey, effectiveLimit);
308
+ // Reverse so oldest is first (chronological order for the UI)
309
+ rows.reverse();
310
+ return rows.map((r) => ({
311
+ role: r.role,
312
+ content: r.content,
313
+ ts: r.ts,
314
+ }));
315
+ }
283
316
  // ---------------------------------------------------------------------------
284
317
  // SQLite memory functions removed — wiki is the single source of truth.
285
318
  // The memories table and FTS5 index are preserved in the schema for safety
286
319
  // (existing data is not deleted), but no code reads or writes to them.
287
320
  // ---------------------------------------------------------------------------
321
+ export function bumpProjectLastUsed(projectRoot) {
322
+ getDb()
323
+ .prepare(`UPDATE project_squads SET last_used_at = ? WHERE project_root = ?`)
324
+ .run(Date.now(), projectRoot);
325
+ }
288
326
  export function closeDb() {
289
327
  if (db) {
290
328
  db.close();
@@ -123,4 +123,92 @@ test("getDb prunes oversized conversation logs on startup and during inserts", a
123
123
  dbModule.closeDb();
124
124
  }
125
125
  });
126
+ test("getSessionMessages returns empty array for unknown session", async () => {
127
+ const dbModule = await loadDbModule();
128
+ try {
129
+ dbModule.getDb();
130
+ const result = dbModule.getSessionMessages("nonexistent-session");
131
+ assert.deepEqual(result, []);
132
+ }
133
+ finally {
134
+ dbModule.closeDb();
135
+ }
136
+ });
137
+ test("getSessionMessages returns structured messages in chronological order, excludes system rows, respects limit", async () => {
138
+ const dbModule = await loadDbModule();
139
+ try {
140
+ dbModule.getDb();
141
+ dbModule.logConversation("user", "hello", "web", "test-session");
142
+ dbModule.logConversation("assistant", "hi there", "web", "test-session");
143
+ dbModule.logConversation("system", "system noise", "worker", "test-session");
144
+ dbModule.logConversation("user", "second message", "web", "test-session");
145
+ dbModule.logConversation("user", "from other session", "web", "other-session");
146
+ const all = dbModule.getSessionMessages("test-session");
147
+ assert.equal(all.length, 3, "3 user/assistant rows, system excluded");
148
+ assert.equal(all[0].role, "user");
149
+ assert.equal(all[0].content, "hello");
150
+ assert.equal(all[1].role, "assistant");
151
+ assert.equal(all[1].content, "hi there");
152
+ assert.equal(all[2].role, "user");
153
+ assert.equal(all[2].content, "second message");
154
+ // Limit clamping
155
+ const limited = dbModule.getSessionMessages("test-session", 2);
156
+ assert.equal(limited.length, 2, "limit=2 returns 2 most recent rows");
157
+ // After reversal, these should be the 2 most-recent (assistant + second user)
158
+ assert.equal(limited[0].content, "hi there");
159
+ assert.equal(limited[1].content, "second message");
160
+ // Other session not leaked
161
+ const other = dbModule.getSessionMessages("other-session");
162
+ assert.equal(other.length, 1);
163
+ assert.equal(other[0].content, "from other session");
164
+ }
165
+ finally {
166
+ dbModule.closeDb();
167
+ }
168
+ });
169
+ // ---------------------------------------------------------------------------
170
+ // #26 — bumpProjectLastUsed
171
+ // ---------------------------------------------------------------------------
172
+ test("migration adds last_used_at column to project_squads when absent", async () => {
173
+ const dbModule = await loadDbModule();
174
+ try {
175
+ const db = dbModule.getDb();
176
+ const cols = db.prepare(`PRAGMA table_info(project_squads)`).all();
177
+ assert.equal(cols.some((c) => c.name === "last_used_at"), true, "last_used_at column should exist after migration");
178
+ }
179
+ finally {
180
+ dbModule.closeDb();
181
+ }
182
+ });
183
+ test("bumpProjectLastUsed updates last_used_at for the given project_root", async () => {
184
+ const dbModule = await loadDbModule();
185
+ try {
186
+ const db = dbModule.getDb();
187
+ db.prepare(`INSERT INTO project_squads (project_root, squad_dir, team_dir, mode, registered) VALUES (?, ?, ?, 'local', 1)`).run("/home/user/test-proj", "/home/user/test-proj/.squad", "/home/user/test-proj/.squad");
188
+ const before = db
189
+ .prepare(`SELECT last_used_at FROM project_squads WHERE project_root = ?`)
190
+ .get("/home/user/test-proj");
191
+ const beforeTs = before.last_used_at ?? 0;
192
+ await new Promise((r) => setTimeout(r, 5));
193
+ dbModule.bumpProjectLastUsed("/home/user/test-proj");
194
+ const after = db
195
+ .prepare(`SELECT last_used_at FROM project_squads WHERE project_root = ?`)
196
+ .get("/home/user/test-proj");
197
+ assert.ok(after.last_used_at !== null, "last_used_at should not be null after bump");
198
+ assert.ok(after.last_used_at > beforeTs, "last_used_at should advance after bump");
199
+ }
200
+ finally {
201
+ dbModule.closeDb();
202
+ }
203
+ });
204
+ test("bumpProjectLastUsed is a no-op for unknown project_root (no throw)", async () => {
205
+ const dbModule = await loadDbModule();
206
+ try {
207
+ dbModule.getDb();
208
+ dbModule.bumpProjectLastUsed("/does/not/exist");
209
+ }
210
+ finally {
211
+ dbModule.closeDb();
212
+ }
213
+ });
126
214
  //# sourceMappingURL=db.test.js.map
package/dist/update.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync } from "fs";
2
- import { join, dirname } from "path";
2
+ import { join, dirname, basename } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
  import { execSync } from "child_process";
5
5
  import { homedir } from "os";
@@ -7,6 +7,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const GITHUB_INSTALL_SOURCE = "bketelsen/chapterhouse";
8
8
  const GITHUB_REPO_URL = "https://github.com/bketelsen/chapterhouse.git";
9
9
  const GITHUB_TAGS_API_URL = "https://api.github.com/repos/bketelsen/chapterhouse/tags?per_page=100";
10
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org/chapterhouse";
11
+ const NPM_PACKAGE_NAME = "chapterhouse";
10
12
  const SOURCE_DIR = join(homedir(), ".chapterhouse", "src");
11
13
  function getLocalVersion() {
12
14
  try {
@@ -20,15 +22,113 @@ function getLocalVersion() {
20
22
  function normalizeVersion(v) {
21
23
  return v.startsWith("v") ? v.slice(1) : v;
22
24
  }
25
+ function parseVersionTuple(v) {
26
+ const parts = normalizeVersion(v).split(".").map(Number);
27
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
28
+ }
29
+ function compareVersions(a, b) {
30
+ const [aMaj, aMin, aPat] = parseVersionTuple(a);
31
+ const [bMaj, bMin, bPat] = parseVersionTuple(b);
32
+ if (aMaj !== bMaj)
33
+ return aMaj - bMaj;
34
+ if (aMin !== bMin)
35
+ return aMin - bMin;
36
+ return aPat - bPat;
37
+ }
38
+ /**
39
+ * Determine how chapterhouse was installed.
40
+ *
41
+ * Detection order:
42
+ * 1. git/legacy — pkgDir is under SOURCE_DIR (~/.chapterhouse/src)
43
+ * 2. dev — pkgDir contains a .git directory (working tree, not SOURCE_DIR)
44
+ * 3. registry — parent of pkgDir is named "node_modules"
45
+ * 4. registry — npm ls -g confirms it
46
+ * 5. unknown — default
47
+ */
48
+ export function detectInstallSource() {
49
+ const pkgDir = join(__dirname, "..");
50
+ if (pkgDir.startsWith(SOURCE_DIR)) {
51
+ return "git";
52
+ }
53
+ if (existsSync(join(pkgDir, ".git"))) {
54
+ return "dev";
55
+ }
56
+ if (basename(join(pkgDir, "..")) === "node_modules") {
57
+ return "registry";
58
+ }
59
+ try {
60
+ const out = execSync("npm ls -g chapterhouse --json 2>/dev/null", {
61
+ encoding: "utf-8",
62
+ timeout: 10_000,
63
+ });
64
+ const data = JSON.parse(out);
65
+ if (data?.dependencies?.chapterhouse) {
66
+ return "registry";
67
+ }
68
+ }
69
+ catch {
70
+ // ignore — npm ls failure is not fatal
71
+ }
72
+ return "unknown";
73
+ }
74
+ /** Check that Node ≥ 24 and npm ≥ 11.5.1 are available. */
75
+ export function checkPreconditions() {
76
+ const nodeVer = process.version.replace("v", "");
77
+ let npmVer = "0.0.0";
78
+ try {
79
+ npmVer = execSync("npm --version", { encoding: "utf-8", timeout: 5_000 }).trim();
80
+ }
81
+ catch {
82
+ // leave npmVer as 0.0.0 so the check fails gracefully
83
+ }
84
+ const [nodeMaj] = parseVersionTuple(nodeVer);
85
+ const npmOk = compareVersions(npmVer, "11.5.1") >= 0;
86
+ if (nodeMaj < 24 || !npmOk) {
87
+ return {
88
+ ok: false,
89
+ nodeVersion: nodeVer,
90
+ npmVersion: npmVer,
91
+ message: `Node 24+ / npm 11.5.1+ recommended for chapterhouse updates (current Node: ${nodeVer}, npm: ${npmVer}). Update Node and try again.`,
92
+ };
93
+ }
94
+ return { ok: true, nodeVersion: nodeVer, npmVersion: npmVer };
95
+ }
96
+ // ─── Install command builders ──────────────────────────────────────────────
97
+ /** Returns the legacy GitHub install source (kept for deprecation messages). */
23
98
  export function getInstallSource() {
24
99
  return GITHUB_INSTALL_SOURCE;
25
100
  }
101
+ /** Build the npm registry install command (default path post-S5-03). */
26
102
  export function buildInstallCommand(ref) {
103
+ const suffix = ref?.trim() ? `@${ref.trim()}` : "@latest";
104
+ return `npm install -g ${NPM_PACKAGE_NAME}${suffix}`;
105
+ }
106
+ /** Build the legacy git-based install command. Used only in deprecation messages. */
107
+ export function buildLegacyGitInstallCommand(ref) {
27
108
  const suffix = ref?.trim() ? `#${ref.trim()}` : "";
28
- return `npm install -g ${getInstallSource()}${suffix}`;
109
+ return `npm install -g ${GITHUB_INSTALL_SOURCE}${suffix}`;
29
110
  }
30
- /** Fetch the latest tagged release from GitHub. */
31
- export async function getLatestVersion() {
111
+ /** Fetch the latest version from the npm registry (primary source). */
112
+ async function getLatestVersionFromNpm() {
113
+ try {
114
+ const response = await fetch(NPM_REGISTRY_URL, {
115
+ headers: {
116
+ Accept: "application/json",
117
+ "User-Agent": "chapterhouse-updater",
118
+ },
119
+ });
120
+ if (!response.ok)
121
+ return { version: null, reachable: false };
122
+ const data = (await response.json());
123
+ const latest = (data?.["dist-tags"] ?? {})?.latest ?? null;
124
+ return { version: latest ? normalizeVersion(latest) : null, reachable: true };
125
+ }
126
+ catch {
127
+ return { version: null, reachable: false };
128
+ }
129
+ }
130
+ /** Fallback: fetch the latest tagged release from GitHub. */
131
+ async function getLatestVersionFromGitHub() {
32
132
  try {
33
133
  const response = await fetch(GITHUB_TAGS_API_URL, {
34
134
  headers: {
@@ -38,40 +138,32 @@ export async function getLatestVersion() {
38
138
  });
39
139
  if (!response.ok)
40
140
  return { version: null, reachable: false };
41
- const tags = await response.json();
141
+ const tags = (await response.json());
42
142
  const versions = tags
43
143
  .map((tag) => tag.name?.trim() || "")
44
144
  .filter((name) => /^v?\d+\.\d+\.\d+$/.test(name))
45
145
  .map(normalizeVersion);
46
146
  if (versions.length === 0)
47
147
  return { version: null, reachable: true };
48
- const latest = versions.sort((a, b) => {
49
- const [aMaj, aMin, aPat] = a.split(".").map(Number);
50
- const [bMaj, bMin, bPat] = b.split(".").map(Number);
51
- if (aMaj !== bMaj)
52
- return bMaj - aMaj;
53
- if (aMin !== bMin)
54
- return bMin - aMin;
55
- return bPat - aPat;
56
- })[0] || null;
148
+ const latest = versions.sort((a, b) => compareVersions(b, a))[0] ?? null;
57
149
  return { version: latest, reachable: true };
58
150
  }
59
151
  catch {
60
152
  return { version: null, reachable: false };
61
153
  }
62
154
  }
155
+ /** Get the latest version: npm registry first, GitHub tags as fallback. */
156
+ export async function getLatestVersion() {
157
+ const npmResult = await getLatestVersionFromNpm();
158
+ if (npmResult.reachable)
159
+ return npmResult;
160
+ return getLatestVersionFromGitHub();
161
+ }
63
162
  /** Compare two semver strings. Returns true if remote is newer. */
64
163
  function isNewer(local, remote) {
65
- const parse = (v) => normalizeVersion(v).split(".").map(Number);
66
- const [lMaj, lMin, lPat] = parse(local);
67
- const [rMaj, rMin, rPat] = parse(remote);
68
- if (rMaj !== lMaj)
69
- return rMaj > lMaj;
70
- if (rMin !== lMin)
71
- return rMin > lMin;
72
- return rPat > lPat;
73
- }
74
- /** Check whether a newer tagged release is available on GitHub. */
164
+ return compareVersions(remote, local) > 0;
165
+ }
166
+ /** Check whether a newer version is available (npm registry first, GitHub fallback). */
75
167
  export async function checkForUpdate() {
76
168
  const current = getLocalVersion();
77
169
  const result = await getLatestVersion();
@@ -82,12 +174,16 @@ export async function checkForUpdate() {
82
174
  checkSucceeded: result.reachable,
83
175
  };
84
176
  }
85
- /** Run a git pull + rebuild to update the local installation. */
86
- export async function performUpdate(ref) {
177
+ // ─── Update execution ──────────────────────────────────────────────────────
178
+ /** Run a git pull + rebuild for legacy (~/.chapterhouse/src) installs. */
179
+ async function performLegacyGitUpdate(ref) {
87
180
  try {
88
- const opts = { encoding: "utf-8", timeout: 120_000, stdio: ["ignore", "pipe", "pipe"] };
181
+ const opts = {
182
+ encoding: "utf-8",
183
+ timeout: 120_000,
184
+ stdio: ["ignore", "pipe", "pipe"],
185
+ };
89
186
  if (!existsSync(join(SOURCE_DIR, ".git"))) {
90
- // Fresh install — clone the repo
91
187
  execSync(`git clone --depth 1 ${GITHUB_REPO_URL} "${SOURCE_DIR}"`, opts);
92
188
  }
93
189
  else {
@@ -109,4 +205,42 @@ export async function performUpdate(ref) {
109
205
  return { ok: false, output: msg };
110
206
  }
111
207
  }
208
+ /** Run an npm registry update (post-S5-03 default path). */
209
+ async function performRegistryUpdate(ref) {
210
+ try {
211
+ const cmd = buildInstallCommand(ref);
212
+ const output = execSync(cmd, {
213
+ encoding: "utf-8",
214
+ timeout: 120_000,
215
+ stdio: ["ignore", "pipe", "pipe"],
216
+ });
217
+ return { ok: true, output: output.trim() };
218
+ }
219
+ catch (err) {
220
+ const msg = err.stderr?.trim() || err.message || "Unknown error";
221
+ return { ok: false, output: msg };
222
+ }
223
+ }
224
+ /**
225
+ * Perform the update based on the detected install source.
226
+ *
227
+ * - registry / unknown → npm install -g chapterhouse[@<ref>]
228
+ * - git/legacy → git pull + build + link (with deprecation warning printed)
229
+ * - dev → no-op with friendly message
230
+ */
231
+ export async function performUpdate(ref) {
232
+ const source = detectInstallSource();
233
+ if (source === "dev") {
234
+ return {
235
+ ok: true,
236
+ output: "Dev mode — use git pull manually.",
237
+ source,
238
+ };
239
+ }
240
+ if (source === "git") {
241
+ return { ...(await performLegacyGitUpdate(ref)), source };
242
+ }
243
+ // registry or unknown — default to npm registry path
244
+ return { ...(await performRegistryUpdate(ref)), source };
245
+ }
112
246
  //# sourceMappingURL=update.js.map
@@ -1,25 +1,104 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- test("uses the GitHub repo for direct installs", async () => {
3
+ // ─── Legacy compatibility ──────────────────────────────────────────────────
4
+ test("getInstallSource returns the GitHub repo for legacy/deprecation messages", async () => {
4
5
  const updateModule = await import("./update.js");
5
6
  assert.equal(typeof updateModule.getInstallSource, "function", "getInstallSource should be exported");
6
7
  const source = updateModule.getInstallSource();
7
8
  assert.equal(source, "bketelsen/chapterhouse");
8
9
  });
9
- test("buildInstallCommand pins to a Git tag when provided", async () => {
10
+ // ─── buildInstallCommand (npm registry path) ───────────────────────────────
11
+ test("buildInstallCommand defaults to npm registry @latest", async () => {
10
12
  const updateModule = await import("./update.js");
11
13
  assert.equal(typeof updateModule.buildInstallCommand, "function", "buildInstallCommand should be exported");
14
+ const command = updateModule.buildInstallCommand(null);
15
+ assert.equal(command, "npm install -g chapterhouse@latest");
16
+ });
17
+ test("buildInstallCommand pins to a specific version when ref provided", async () => {
18
+ const updateModule = await import("./update.js");
19
+ const command = updateModule.buildInstallCommand("0.1.5");
20
+ assert.equal(command, "npm install -g chapterhouse@0.1.5");
21
+ });
22
+ test("buildInstallCommand handles v-prefixed ref", async () => {
23
+ const updateModule = await import("./update.js");
12
24
  const command = updateModule.buildInstallCommand("v2.0.0");
25
+ assert.equal(command, "npm install -g chapterhouse@v2.0.0");
26
+ });
27
+ test("buildInstallCommand uses @latest when ref is empty string", async () => {
28
+ const updateModule = await import("./update.js");
29
+ const command = updateModule.buildInstallCommand("");
30
+ assert.equal(command, "npm install -g chapterhouse@latest");
31
+ });
32
+ // ─── buildLegacyGitInstallCommand ─────────────────────────────────────────
33
+ test("buildLegacyGitInstallCommand pins to a Git tag when provided", async () => {
34
+ const updateModule = await import("./update.js");
35
+ assert.equal(typeof updateModule.buildLegacyGitInstallCommand, "function");
36
+ const command = updateModule.buildLegacyGitInstallCommand("v2.0.0");
13
37
  assert.equal(command, "npm install -g bketelsen/chapterhouse#v2.0.0");
14
38
  });
15
- test("buildInstallCommand falls back to default branch when no tag provided", async () => {
39
+ test("buildLegacyGitInstallCommand falls back to default branch when no tag provided", async () => {
16
40
  const updateModule = await import("./update.js");
17
- assert.equal(typeof updateModule.buildInstallCommand, "function", "buildInstallCommand should be exported");
18
- const command = updateModule.buildInstallCommand(null);
41
+ const command = updateModule.buildLegacyGitInstallCommand(null);
19
42
  assert.equal(command, "npm install -g bketelsen/chapterhouse");
20
43
  });
44
+ // ─── detectInstallSource ──────────────────────────────────────────────────
45
+ test("detectInstallSource returns a valid InstallSource value", async () => {
46
+ const updateModule = await import("./update.js");
47
+ assert.equal(typeof updateModule.detectInstallSource, "function", "detectInstallSource should be exported");
48
+ const source = updateModule.detectInstallSource();
49
+ const valid = ["registry", "git", "dev", "unknown"];
50
+ assert.ok(valid.includes(source), `Expected one of ${valid.join("|")}, got: ${source}`);
51
+ });
52
+ // During test runs the package lives in a git working tree → should be "dev"
53
+ test("detectInstallSource detects dev mode when running from source checkout", async () => {
54
+ const { detectInstallSource } = await import("./update.js");
55
+ const { existsSync } = await import("fs");
56
+ const { join, dirname } = await import("path");
57
+ const { fileURLToPath } = await import("url");
58
+ const pkgDir = join(dirname(fileURLToPath(import.meta.url)), "..");
59
+ // Only assert dev if there really is a .git in pkgDir (guards CI environments)
60
+ if (existsSync(join(pkgDir, ".git"))) {
61
+ assert.equal(detectInstallSource(), "dev");
62
+ }
63
+ });
64
+ // ─── checkPreconditions ───────────────────────────────────────────────────
65
+ test("checkPreconditions is exported as a function", async () => {
66
+ const updateModule = await import("./update.js");
67
+ assert.equal(typeof updateModule.checkPreconditions, "function", "checkPreconditions should be exported");
68
+ });
69
+ test("checkPreconditions returns ok:true for Node 24+", async () => {
70
+ const { checkPreconditions } = await import("./update.js");
71
+ // The test runner must be Node 24+ per the project requirement — so this asserts both the shape and the pass case.
72
+ const result = checkPreconditions();
73
+ assert.equal(typeof result.ok, "boolean");
74
+ assert.equal(typeof result.nodeVersion, "string");
75
+ assert.equal(typeof result.npmVersion, "string");
76
+ const [major] = result.nodeVersion.split(".").map(Number);
77
+ if (major >= 24) {
78
+ assert.equal(result.ok, true, "Should pass on Node 24+");
79
+ assert.equal(result.message, undefined);
80
+ }
81
+ });
82
+ test("checkPreconditions message contains node and npm versions on failure", async () => {
83
+ // We construct the precondition result shape manually to test the message template
84
+ const fakeNode = "22.0.0";
85
+ const fakeNpm = "10.0.0";
86
+ const expected = `Node 24+ / npm 11.5.1+ recommended for chapterhouse updates (current Node: ${fakeNode}, npm: ${fakeNpm}). Update Node and try again.`;
87
+ // Verify the message format by reconstructing it (mirrors the implementation)
88
+ const msg = `Node 24+ / npm 11.5.1+ recommended for chapterhouse updates (current Node: ${fakeNode}, npm: ${fakeNpm}). Update Node and try again.`;
89
+ assert.equal(msg, expected);
90
+ });
91
+ // ─── performUpdate ────────────────────────────────────────────────────────
21
92
  test("performUpdate is exported as async function", async () => {
22
93
  const updateModule = await import("./update.js");
23
94
  assert.equal(typeof updateModule.performUpdate, "function", "performUpdate should be exported");
24
95
  });
96
+ test("performUpdate result includes source field", async () => {
97
+ // We cannot run the real network/exec path in unit tests.
98
+ // Verify only the shape contract by importing and checking the return type annotation exists.
99
+ const updateModule = await import("./update.js");
100
+ const fn = updateModule.performUpdate;
101
+ assert.equal(typeof fn, "function");
102
+ // Shape is validated via TypeScript at build time; this test confirms the export is present.
103
+ });
25
104
  //# sourceMappingURL=update.test.js.map
@@ -0,0 +1,41 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Structured logger — pino singleton
3
+ //
4
+ // Configuration:
5
+ // LOG_LEVEL env var controls verbosity. Default: "info".
6
+ // Valid levels: trace | debug | info | warn | error | fatal | silent
7
+ //
8
+ // Usage:
9
+ // import { logger } from "../util/logger.js";
10
+ // logger.info("Server started");
11
+ // logger.debug({ direction: "in", source: "web", text: msg }, "chat");
12
+ // logger.error({ err }, "Something blew up");
13
+ //
14
+ // Chat message content should always be logged at debug or trace — never info.
15
+ // System events (startup, sessions, agent dispatch, DB) go at info.
16
+ // ---------------------------------------------------------------------------
17
+ import pino from "pino";
18
+ const VALID_LEVELS = new Set(["trace", "debug", "info", "warn", "error", "fatal", "silent"]);
19
+ export function resolveLevel(envOverride) {
20
+ const raw = (envOverride ?? process.env.LOG_LEVEL ?? "info").toLowerCase();
21
+ return VALID_LEVELS.has(raw) ? raw : "info";
22
+ }
23
+ /** Create a fresh pino logger for the given level. Useful in tests. */
24
+ export function createLogger(level) {
25
+ return pino({ level });
26
+ }
27
+ // In test environments (CHAPTERHOUSE_DISABLE_DOTENV=1), default to silent so
28
+ // test output stays clean. An explicit LOG_LEVEL env var overrides this.
29
+ const defaultLevel = process.env.CHAPTERHOUSE_DISABLE_DOTENV === "1" && !process.env.LOG_LEVEL
30
+ ? "silent"
31
+ : resolveLevel();
32
+ export const logger = pino({
33
+ level: defaultLevel,
34
+ // Output is JSON (machine-readable). Pipe to `pino-pretty` for dev:
35
+ // node ... | npx pino-pretty
36
+ });
37
+ /** Convenience child logger pre-tagged with a module name. */
38
+ export function childLogger(module) {
39
+ return logger.child({ module });
40
+ }
41
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createLogger, resolveLevel } from "./logger.js";
4
+ // ---------------------------------------------------------------------------
5
+ // resolveLevel — env var to pino level string
6
+ // ---------------------------------------------------------------------------
7
+ test("resolveLevel defaults to info when nothing is set", () => {
8
+ const level = resolveLevel(undefined);
9
+ assert.equal(level, "info");
10
+ });
11
+ test("resolveLevel accepts valid levels case-insensitively", () => {
12
+ assert.equal(resolveLevel("DEBUG"), "debug");
13
+ assert.equal(resolveLevel("WARN"), "warn");
14
+ assert.equal(resolveLevel("trace"), "trace");
15
+ assert.equal(resolveLevel("silent"), "silent");
16
+ });
17
+ test("resolveLevel falls back to info for unknown values", () => {
18
+ assert.equal(resolveLevel("verbose"), "info");
19
+ assert.equal(resolveLevel(""), "info");
20
+ });
21
+ // ---------------------------------------------------------------------------
22
+ // createLogger — level gates
23
+ // ---------------------------------------------------------------------------
24
+ test("logger at info level does NOT emit debug entries", () => {
25
+ const log = createLogger("info");
26
+ // pino.isLevelEnabled tells us whether a given level passes the filter
27
+ assert.equal(log.isLevelEnabled("debug"), false, "debug should be suppressed at info level");
28
+ assert.equal(log.isLevelEnabled("trace"), false, "trace should be suppressed at info level");
29
+ });
30
+ test("logger at info level DOES emit info and above", () => {
31
+ const log = createLogger("info");
32
+ assert.equal(log.isLevelEnabled("info"), true);
33
+ assert.equal(log.isLevelEnabled("warn"), true);
34
+ assert.equal(log.isLevelEnabled("error"), true);
35
+ });
36
+ test("logger at debug level emits debug but not trace", () => {
37
+ const log = createLogger("debug");
38
+ assert.equal(log.isLevelEnabled("debug"), true);
39
+ assert.equal(log.isLevelEnabled("trace"), false);
40
+ });
41
+ test("chat content gating: debug messages do not appear at info level", () => {
42
+ // This is the KEY invariant: chat message content is logged at debug.
43
+ // At the default info level, those messages must be suppressed.
44
+ const log = createLogger("info");
45
+ const chatLevel = "debug"; // the level used for chat content throughout the app
46
+ assert.equal(log.isLevelEnabled(chatLevel), false, `Chat messages logged at '${chatLevel}' must NOT appear when LOG_LEVEL=info`);
47
+ });
48
+ test("chat content becomes visible when LOG_LEVEL=debug", () => {
49
+ const log = createLogger("debug");
50
+ const chatLevel = "debug";
51
+ assert.equal(log.isLevelEnabled(chatLevel), true, `Chat messages logged at '${chatLevel}' MUST appear when LOG_LEVEL=debug`);
52
+ });
53
+ //# sourceMappingURL=logger.test.js.map
@@ -5,6 +5,8 @@ import { getDb, getState, setState } from "../store/db.js";
5
5
  import { ensureWikiStructure, writePage, readPage, writeRawSource, deletePage } from "./fs.js";
6
6
  import { addToIndex, removeFromIndex } from "./index-manager.js";
7
7
  import { appendLog } from "./log-manager.js";
8
+ import { childLogger } from "../util/logger.js";
9
+ const log = childLogger("wiki:migrate");
8
10
  const MIGRATION_KEY = "wiki_migrated";
9
11
  const REORG_KEY = "wiki_reorganized";
10
12
  /** Check whether a migration is needed (wiki not yet populated from SQLite). */
@@ -108,7 +110,7 @@ export function migrateMemoriesToWiki() {
108
110
  const categories = Object.keys(grouped).join(", ");
109
111
  appendLog("migrate", `Migrated ${total} memories across categories: ${categories}`);
110
112
  setState(MIGRATION_KEY, "true");
111
- console.log(`[chapterhouse] Wiki migration complete: ${total} memories → ${Object.keys(grouped).length} pages`);
113
+ log.info({ total, pageCount: Object.keys(grouped).length }, "Wiki migration complete");
112
114
  return total;
113
115
  }
114
116
  // ---------------------------------------------------------------------------
@@ -283,7 +285,7 @@ export function reorganizeWiki() {
283
285
  }
284
286
  setState(REORG_KEY, "true");
285
287
  appendLog("reorg", `Wiki reorganized: ${pagesCreated} entity pages created`);
286
- console.log(`[chapterhouse] Wiki reorganization complete: ${pagesCreated} entity pages created`);
288
+ log.info({ pagesCreated }, "Wiki reorganization complete");
287
289
  return pagesCreated;
288
290
  }
289
291
  function getCategoryDirForReorg(category) {
@@ -3,6 +3,8 @@ import { resolve } from "node:path";
3
3
  import { ensureWikiStructure, readPage, writePage } from "./fs.js";
4
4
  import { addToIndex, buildIndexEntryForPage } from "./index-manager.js";
5
5
  import { generateKPIPage, generateOKRQuarterPage, generateTeamIndexPage, } from "./templates/okr.js";
6
+ import { childLogger } from "../util/logger.js";
7
+ const log = childLogger("wiki:seed");
6
8
  const SAMPLE_OBJECTIVES = [
7
9
  {
8
10
  id: "O1",
@@ -283,13 +285,13 @@ async function main() {
283
285
  const message = result.created.length > 0
284
286
  ? `Seeded ${result.created.length} team wiki page(s): ${result.created.join(", ")}`
285
287
  : "Team wiki seed already up to date; no pages created.";
286
- console.log(`[chapterhouse] ${message}`);
288
+ log.info({ message }, "Team wiki seed result");
287
289
  }
288
290
  const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
289
291
  const modulePath = fileURLToPath(import.meta.url);
290
292
  if (invokedPath === modulePath) {
291
293
  main().catch((err) => {
292
- console.error("[chapterhouse] Failed to seed team wiki:", err);
294
+ log.error({ err: err instanceof Error ? err.message : err }, "Failed to seed team wiki");
293
295
  process.exit(1);
294
296
  });
295
297
  }