chapterhouse 0.1.1 → 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.
@@ -90,4 +90,65 @@ test("invalidateProjectSquad marks agents stale — next loadProjectSquad re-res
90
90
  // Both should have agents (content integrity survives invalidation + re-resolve)
91
91
  assert.ok(after && typeof after === "object" && "agents" in after, "re-resolved context should include agents");
92
92
  });
93
+ // ---------------------------------------------------------------------------
94
+ // S5-02: agent count from filesystem is always authoritative
95
+ // These tests guard the fix: GET /api/projects must count agents from disk,
96
+ // not from the SQLite squad_agents cache (which is never populated on a fresh
97
+ // registration, causing the badge to show 0).
98
+ // ---------------------------------------------------------------------------
99
+ test("S5-02: resolveProjectSquad counts 0 agents for an empty agents dir", async () => {
100
+ const m = await loadDiscovery();
101
+ const dir = join(repoRoot, ".test-work", `s502-empty-${process.pid}`);
102
+ mkdirSync(join(dir, ".squad", "agents"), { recursive: true });
103
+ try {
104
+ const result = await m.resolveProjectSquad(dir);
105
+ assert.ok(result !== null, "should return context even with no agents");
106
+ assert.equal(result.agents.length, 0, "empty agents dir → 0 agents");
107
+ }
108
+ finally {
109
+ rmSync(dir, { recursive: true, force: true });
110
+ }
111
+ });
112
+ test("S5-02: resolveProjectSquad counts N agents matching charter-bearing subdirs", async () => {
113
+ const m = await loadDiscovery();
114
+ const dir = join(repoRoot, ".test-work", `s502-n-agents-${process.pid}`);
115
+ // Create 3 agents (with charter.md) and 1 directory without charter.md
116
+ for (const slug of ["alpha", "beta", "gamma"]) {
117
+ mkdirSync(join(dir, ".squad", "agents", slug), { recursive: true });
118
+ const { writeFileSync } = await import("node:fs");
119
+ writeFileSync(join(dir, ".squad", "agents", slug, "charter.md"), `# ${slug}\n\n**Role:** Specialist\n`);
120
+ }
121
+ mkdirSync(join(dir, ".squad", "agents", "no-charter"), { recursive: true }); // no charter.md
122
+ try {
123
+ const result = await m.resolveProjectSquad(dir);
124
+ assert.ok(result !== null, "should return context");
125
+ assert.equal(result.agents.length, 3, "only dirs with charter.md count as agents");
126
+ const slugs = result.agents.map((a) => a.slug).sort();
127
+ assert.deepEqual(slugs, ["alpha", "beta", "gamma"]);
128
+ }
129
+ finally {
130
+ rmSync(dir, { recursive: true, force: true });
131
+ }
132
+ });
133
+ test("S5-02: resolveProjectSquad re-reads filesystem after agent added (no stale count)", async () => {
134
+ const m = await loadDiscovery();
135
+ const dir = join(repoRoot, ".test-work", `s502-refresh-${process.pid}`);
136
+ const { writeFileSync } = await import("node:fs");
137
+ // Start with 1 agent
138
+ mkdirSync(join(dir, ".squad", "agents", "first"), { recursive: true });
139
+ writeFileSync(join(dir, ".squad", "agents", "first", "charter.md"), "# first\n\n**Role:** Scout\n");
140
+ try {
141
+ const before = await m.resolveProjectSquad(dir);
142
+ assert.equal(before.agents.length, 1, "before: 1 agent on disk");
143
+ // Add a second agent to disk
144
+ mkdirSync(join(dir, ".squad", "agents", "second"), { recursive: true });
145
+ writeFileSync(join(dir, ".squad", "agents", "second", "charter.md"), "# second\n\n**Role:** Recon\n");
146
+ // resolveProjectSquad always reads from disk (no TTL — direct filesystem read)
147
+ const after = await m.resolveProjectSquad(dir);
148
+ assert.equal(after.agents.length, 2, "after adding agent to disk: 2 agents");
149
+ }
150
+ finally {
151
+ rmSync(dir, { recursive: true, force: true });
152
+ }
153
+ });
93
154
  //# sourceMappingURL=discovery.test.js.map
package/dist/store/db.js CHANGED
@@ -146,6 +146,17 @@ export function getDb() {
146
146
  if (!taskCols.some((c) => c.name === 'session_key')) {
147
147
  db.exec(`ALTER TABLE agent_tasks ADD COLUMN session_key TEXT NOT NULL DEFAULT 'default'`);
148
148
  }
149
+ // Migrate: add source column to agent_tasks ('adhoc' | 'squad') if not present
150
+ if (!taskCols.some((c) => c.name === 'source')) {
151
+ db.exec(`ALTER TABLE agent_tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'adhoc'`);
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
+ }
149
160
  // Prune conversation log at startup — keep more history for better recovery
150
161
  db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
151
162
  // Set up FTS5 for memory search (graceful fallback if not available)
@@ -276,11 +287,42 @@ export function getRecentConversation(limit, sessionKey) {
276
287
  return `${tag}: ${content}`;
277
288
  }).join("\n\n");
278
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
+ }
279
316
  // ---------------------------------------------------------------------------
280
317
  // SQLite memory functions removed — wiki is the single source of truth.
281
318
  // The memories table and FTS5 index are preserved in the schema for safety
282
319
  // (existing data is not deleted), but no code reads or writes to them.
283
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
+ }
284
326
  export function closeDb() {
285
327
  if (db) {
286
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