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.
package/README.md CHANGED
@@ -14,14 +14,16 @@ Chapterhouse is built on [Max](https://github.com/burkeholland/max) by [Burke Ho
14
14
  - **Learns any skill** — pulls from [skills.sh](https://skills.sh) or builds new skills on demand.
15
15
  - **Your Copilot subscription** — works with any model your subscription includes (Claude, GPT, Gemini, …). Auto-routing picks a tier per message.
16
16
 
17
+ See [CHANGELOG.md](CHANGELOG.md) for recent changes and feature history.
18
+
17
19
  ## Installation
18
20
 
19
- **Requires Node.js 22.5 or later.**
21
+ **Requires Node.js 24 or later** (npm ≥ 11.5.1 for Trusted Publishing support).
20
22
 
21
23
  Install globally via npm:
22
24
 
23
25
  ```bash
24
- npm install -g chapterhouse
26
+ npm install -g chapterhouse@latest
25
27
  ```
26
28
 
27
29
  After installing, run first-time setup:
@@ -57,6 +59,12 @@ ENABLE_SQUAD=1 # set to 1 to enable squad agent routi
57
59
 
58
60
  # Optional — periodic decisions→wiki sync (requires ENABLE_SQUAD=1)
59
61
  CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS=300000 # default 5 minutes (300 000 ms)
62
+
63
+ # Optional — logging
64
+ LOG_LEVEL=info # trace | debug | info | warn | error | fatal | silent (default: info)
65
+ # Set to "debug" to see chat message content and routing decisions.
66
+ # Logs are structured JSON (Pino). For human-readable output:
67
+ # chapterhouse start 2>&1 | npx pino-pretty
60
68
  ```
61
69
 
62
70
  Then start the daemon:
@@ -91,13 +99,33 @@ npm install && npm run build && npm link
91
99
 
92
100
  ## Upgrading
93
101
 
94
- If you already have Chapterhouse installed:
95
-
96
102
  ```bash
97
103
  chapterhouse update
98
104
  ```
99
105
 
100
- Or manually: `cd ~/.chapterhouse/src && git pull && npm install && npm run build`. Your `~/.chapterhouse/` config carries forward automatically.
106
+ `chapterhouse update` is npm-registry-aware. It detects how Chapterhouse was installed and routes accordingly:
107
+
108
+ | Install source | Update action |
109
+ | -------------- | ------------- |
110
+ | **npm global** (`npm install -g chapterhouse`) | Runs `npm install -g chapterhouse@latest` |
111
+ | **git/legacy** (`~/.chapterhouse/src`) | Git pull + rebuild (with deprecation notice) |
112
+ | **dev** (source working tree) | No-op — use `git pull` manually |
113
+
114
+ ### Update flags
115
+
116
+ | Flag | Description |
117
+ | ---- | ----------- |
118
+ | `--check-only` | Print current/latest version and exit without updating |
119
+ | `--ref <version>` | Install a specific version, e.g. `--ref 0.1.5` |
120
+ | `--force` | Bypass the Node 24 / npm 11.5.1 precondition check |
121
+
122
+ **Legacy users:** if you previously installed via git clone, switch to the registry path once:
123
+
124
+ ```bash
125
+ npm install -g chapterhouse@latest
126
+ ```
127
+
128
+ Your `~/.chapterhouse/` config carries forward automatically.
101
129
 
102
130
  ## Quick Start
103
131
 
@@ -202,13 +230,16 @@ The deployment assets for the shared instance set Entra auth and `CHAPTERHOUSE_M
202
230
 
203
231
  ## CLI Commands
204
232
 
205
- | Command | Description |
206
- | --------------------------- | ------------------------------------------------- |
207
- | `chapterhouse start` | Start the Chapterhouse daemon (web UI + HTTP API) |
208
- | `chapterhouse start --open` | Same, plus open the browser |
209
- | `chapterhouse setup` | Interactive first-run configuration |
210
- | `chapterhouse update` | Check for and install updates |
211
- | `chapterhouse help` | Show available commands |
233
+ | Command | Description |
234
+ | ------------------------------ | ------------------------------------------------- |
235
+ | `chapterhouse start` | Start the Chapterhouse daemon (web UI + HTTP API) |
236
+ | `chapterhouse start --open` | Same, plus open the browser |
237
+ | `chapterhouse setup` | Interactive first-run configuration |
238
+ | `chapterhouse update` | Check for and install updates (npm registry for global installs) |
239
+ | `chapterhouse update --check-only` | Print current/latest version without updating |
240
+ | `chapterhouse update --ref <ver>` | Install a specific version |
241
+ | `chapterhouse daemon <sub>` | Manage the persistent background service |
242
+ | `chapterhouse help` | Show available commands |
212
243
 
213
244
  ### Flags
214
245
 
@@ -217,6 +248,42 @@ The deployment assets for the shared instance set Entra auth and `CHAPTERHOUSE_M
217
248
  | `--self-edit` | Allow Chapterhouse to modify its own source code (use with `chapterhouse start`) |
218
249
  | `--open` | Open the web UI in your default browser when the daemon is ready |
219
250
 
251
+ ### Daemon Management (macOS / Linux)
252
+
253
+ Chapterhouse can run as a persistent user-level background service that starts on login and restarts automatically on crash — no root required.
254
+
255
+ #### Install
256
+
257
+ ```sh
258
+ chapterhouse daemon install
259
+ ```
260
+
261
+ This writes and loads the appropriate unit file for your OS:
262
+
263
+ | Platform | Unit file |
264
+ | -------- | ----------------------------------------------------------------------- |
265
+ | macOS | `~/Library/LaunchAgents/com.bketelsen.chapterhouse.plist` (launchd) |
266
+ | Linux | `~/.config/systemd/user/chapterhouse.service` (systemd `--user`) |
267
+ | Windows | Not supported — run `chapterhouse start` manually or use Task Scheduler |
268
+
269
+ #### Manage
270
+
271
+ ```sh
272
+ chapterhouse daemon status # is it running? what PID? where are the logs?
273
+ chapterhouse daemon stop # stop without uninstalling
274
+ chapterhouse daemon start # start without re-installing
275
+ chapterhouse daemon restart # restart in place
276
+ chapterhouse daemon logs # tail live logs (Ctrl+C to exit)
277
+ chapterhouse daemon uninstall # stop, disable, and remove the unit file
278
+ ```
279
+
280
+ #### Log locations
281
+
282
+ | Platform | Location |
283
+ | -------- | ----------------------------------------------------------- |
284
+ | macOS | `~/Library/Logs/chapterhouse.log` |
285
+ | Linux | `journalctl --user -u chapterhouse` (no extra config needed) |
286
+
220
287
  ## Web UI
221
288
 
222
289
  The browser app at `http://localhost:7788` is split into a few views:
@@ -1,4 +1,6 @@
1
1
  import { ZodError } from "zod";
2
+ import { childLogger } from "../util/logger.js";
3
+ const log = childLogger("api:errors");
2
4
  export class HttpError extends Error {
3
5
  statusCode;
4
6
  expose;
@@ -80,15 +82,15 @@ export function createApiErrorHandler() {
80
82
  }
81
83
  if (error instanceof HttpError) {
82
84
  if (error.statusCode >= 500) {
83
- console.error(`[api] ${req.method} ${req.originalUrl} failed:`, error);
85
+ log.error({ method: req.method, url: req.originalUrl, err: error instanceof Error ? error.message : error }, "API request failed");
84
86
  }
85
87
  else if (error.statusCode === 401 || error.statusCode === 403) {
86
- console.warn(`[api] ${req.method} ${req.originalUrl} denied: ${error.message}`);
88
+ log.warn({ method: req.method, url: req.originalUrl, err: error.message }, "API request denied");
87
89
  }
88
90
  res.status(error.statusCode).json({ error: error.expose ? error.message : "Internal server error" });
89
91
  return;
90
92
  }
91
- console.error(`[api] ${req.method} ${req.originalUrl} failed:`, error);
93
+ log.error({ method: req.method, url: req.originalUrl, err: error instanceof Error ? error.message : error }, "API request failed (unhandled)");
92
94
  res.status(500).json({ error: "Internal server error" });
93
95
  };
94
96
  }
@@ -64,26 +64,17 @@ test("api not-found handler returns JSON for unknown API routes", async () => {
64
64
  });
65
65
  test("centralized error handler hides internal error details", async () => {
66
66
  const app = express();
67
- const originalConsoleError = console.error;
68
- const logged = [];
69
- console.error = (...args) => {
70
- logged.push(args);
71
- };
72
- try {
73
- app.get("/api/failure", () => {
74
- throw new InternalServerError("do not leak", false);
75
- });
76
- app.use(createApiErrorHandler());
77
- await withServer(app, async (baseUrl) => {
78
- const response = await fetch(`${baseUrl}/api/failure`);
79
- assert.equal(response.status, 500);
80
- assert.deepEqual(await response.json(), { error: "Internal server error" });
81
- });
82
- assert.equal(logged.length, 1);
83
- assert.equal(logged[0]?.[0], "[api] GET /api/failure failed:");
84
- }
85
- finally {
86
- console.error = originalConsoleError;
87
- }
67
+ app.get("/api/failure", () => {
68
+ throw new InternalServerError("do not leak", false);
69
+ });
70
+ app.use(createApiErrorHandler());
71
+ await withServer(app, async (baseUrl) => {
72
+ const response = await fetch(`${baseUrl}/api/failure`);
73
+ // Internal details must NOT reach the client
74
+ assert.equal(response.status, 500);
75
+ assert.deepEqual(await response.json(), { error: "Internal server error" });
76
+ });
77
+ // Logging now goes through pino (structured JSON), not console.error.
78
+ // The HTTP response contract above is the authoritative check.
88
79
  });
89
80
  //# sourceMappingURL=errors.test.js.map
@@ -1,7 +1,7 @@
1
1
  import cors from "cors";
2
2
  import express from "express";
3
3
  import helmet from "helmet";
4
- import { existsSync, statSync } from "fs";
4
+ import { existsSync, statSync, readdirSync } from "fs";
5
5
  import { join, dirname } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { z } from "zod";
@@ -19,12 +19,15 @@ import { withWikiWrite } from "../wiki/lock.js";
19
19
  import { listSkills, removeSkill } from "../copilot/skills.js";
20
20
  import { restartDaemon } from "../daemon.js";
21
21
  import { API_TOKEN_PATH, resolveWikiRelativePath } from "../paths.js";
22
- import { getDb } from "../store/db.js";
22
+ import { getDb, getSessionMessages } from "../store/db.js";
23
23
  import { getStatus, onStatusChange } from "../status.js";
24
24
  import { formatSseData, formatSseEvent } from "./sse.js";
25
25
  import { syncDecisionsFileToWiki } from "../squad/mirror.js";
26
+ import { resolveProjectSquad } from "../squad/discovery.js";
26
27
  import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, buildHistoryEntries, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
27
28
  import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
29
+ import { childLogger } from "../util/logger.js";
30
+ const log = childLogger("server");
28
31
  void searchIndex; // re-exported by index-manager; reference here documents the dep
29
32
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
33
  // Built SPA bundle (web/dist/), shipped alongside dist/
@@ -68,11 +71,11 @@ try {
68
71
  });
69
72
  }
70
73
  catch (err) {
71
- console.error(err instanceof Error ? err.message : String(err));
74
+ log.error({ err: err instanceof Error ? err.message : String(err) }, "Auth token error");
72
75
  process.exit(1);
73
76
  }
74
77
  if (config.standaloneMode) {
75
- console.log("[standalone] Running without authentication — team features disabled");
78
+ log.warn("Running without authentication — team features disabled");
76
79
  }
77
80
  function isLoopbackHostname(hostname) {
78
81
  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
@@ -401,7 +404,7 @@ app.get("/api/models", async (_req, res) => {
401
404
  res.json({ models: models.map((m) => m.id), current: config.copilotModel });
402
405
  }
403
406
  catch (error) {
404
- console.error("[api] Failed to list models:", error);
407
+ log.error({ err: error instanceof Error ? error.message : error }, "Failed to list models");
405
408
  throw new InternalServerError();
406
409
  }
407
410
  });
@@ -417,7 +420,7 @@ app.get("/api/auto", (_req, res) => {
417
420
  app.post("/api/auto", (req, res) => {
418
421
  const body = parseRequest(autoRequestSchema, req.body ?? {});
419
422
  const updated = updateRouterConfig(body);
420
- console.log(`[chapterhouse] Auto-routing ${updated.enabled ? "enabled" : "disabled"}`);
423
+ log.info({ enabled: updated.enabled }, "Auto-routing updated");
421
424
  res.json(updated);
422
425
  });
423
426
  // ---------------------------------------------------------------------------
@@ -518,7 +521,7 @@ app.post("/api/restart", (_req, res) => {
518
521
  res.json({ status: "restarting" });
519
522
  setTimeout(() => {
520
523
  restartDaemon().catch((err) => {
521
- console.error("[chapterhouse] Restart failed:", err);
524
+ log.error({ err: err instanceof Error ? err.message : err }, "Restart failed");
522
525
  });
523
526
  }, 500);
524
527
  });
@@ -528,6 +531,31 @@ app.post("/api/restart", (_req, res) => {
528
531
  const projectRegisterSchema = z.object({
529
532
  projectRoot: requiredString("projectRoot must be a non-empty string"),
530
533
  }).strict();
534
+ /**
535
+ * Count squad agents on disk for a project.
536
+ * Authoritative source: each subdirectory of <projectRoot>/.squad/agents/ that
537
+ * contains a charter.md is one agent. Never relies on the SQLite cache so the
538
+ * badge is always accurate even before the cache is warm.
539
+ */
540
+ function countAgentsOnDisk(projectRoot) {
541
+ const agentsDir = join(projectRoot, ".squad", "agents");
542
+ if (!existsSync(agentsDir))
543
+ return 0;
544
+ try {
545
+ return readdirSync(agentsDir).filter((entry) => {
546
+ try {
547
+ return statSync(join(agentsDir, entry)).isDirectory() &&
548
+ existsSync(join(agentsDir, entry, "charter.md"));
549
+ }
550
+ catch {
551
+ return false;
552
+ }
553
+ }).length;
554
+ }
555
+ catch {
556
+ return 0;
557
+ }
558
+ }
531
559
  app.get("/api/projects", (_req, res) => {
532
560
  if (!config.squadEnabled) {
533
561
  res.status(503).json({ error: "Squad integration is disabled. Set ENABLE_SQUAD=1 to enable." });
@@ -535,19 +563,18 @@ app.get("/api/projects", (_req, res) => {
535
563
  }
536
564
  const db = getDb();
537
565
  const rows = db.prepare(`
538
- SELECT project_squads.project_root, project_squads.squad_dir,
539
- COUNT(squad_agents.slug) as agent_count, project_squads.loaded_at
566
+ SELECT project_root, squad_dir, loaded_at, last_used_at
540
567
  FROM project_squads
541
- LEFT JOIN squad_agents ON project_squads.project_root = squad_agents.project_root
542
- WHERE project_squads.registered = 1
543
- GROUP BY project_squads.project_root
544
- ORDER BY project_squads.loaded_at DESC
568
+ WHERE registered = 1
569
+ ORDER BY COALESCE(last_used_at, 0) DESC
545
570
  `).all();
546
571
  res.json(rows.map((r) => ({
547
572
  projectRoot: r.project_root,
548
573
  squadDir: r.squad_dir,
549
- agentCount: r.agent_count,
574
+ // Count from live filesystem — authoritative per Squad SDK rule: repo files win over cache.
575
+ agentCount: countAgentsOnDisk(r.project_root),
550
576
  loadedAt: r.loaded_at,
577
+ lastUsedAt: r.last_used_at != null ? new Date(r.last_used_at).toISOString() : undefined,
551
578
  })));
552
579
  });
553
580
  app.post("/api/projects", async (req, res) => {
@@ -570,10 +597,15 @@ app.post("/api/projects", async (req, res) => {
570
597
  // Fire-and-forget: sync decisions.md to the wiki. Non-fatal if it fails.
571
598
  syncDecisionsFileToWiki(projectRoot).then(result => {
572
599
  if (result) {
573
- console.log(`[squad] Synced ${result.entriesSynced} decision entries to wiki for ${projectRoot}`);
600
+ log.info({ entriesSynced: result.entriesSynced, projectRoot }, "Synced squad decisions to wiki");
574
601
  }
575
602
  }).catch(err => {
576
- console.warn('[squad] syncDecisionsFileToWiki failed during registration (non-fatal):', err instanceof Error ? err.message : err);
603
+ log.warn({ err: err instanceof Error ? err.message : err }, "syncDecisionsFileToWiki failed during registration (non-fatal)");
604
+ });
605
+ // Fire-and-forget: populate squad_agents cache from disk so future queries have
606
+ // something to work with (non-fatal — GET /api/projects counts live from disk anyway).
607
+ resolveProjectSquad(projectRoot).catch(err => {
608
+ log.warn({ err: err instanceof Error ? err.message : err }, "resolveProjectSquad failed during registration (non-fatal)");
577
609
  });
578
610
  res.status(201).json({ projectRoot, message: "Project registered successfully" });
579
611
  });
@@ -593,6 +625,24 @@ app.delete("/api/projects/:projectRoot", (req, res) => {
593
625
  db.prepare(`DELETE FROM squad_agents WHERE project_root = ?`).run(projectRoot);
594
626
  res.json({ message: "Project removed" });
595
627
  });
628
+ // ---------------------------------------------------------------------------
629
+ // Session messages — frontend rehydration on reload
630
+ // ---------------------------------------------------------------------------
631
+ app.get("/api/session/:sessionKey/messages", (req, res) => {
632
+ const sessionKey = Array.isArray(req.params.sessionKey)
633
+ ? req.params.sessionKey[0]
634
+ : req.params.sessionKey;
635
+ if (!sessionKey) {
636
+ throw new BadRequestError("Missing sessionKey");
637
+ }
638
+ const rawLimit = req.query.limit;
639
+ const limit = rawLimit !== undefined ? parseInt(String(rawLimit), 10) : undefined;
640
+ if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) {
641
+ throw new BadRequestError("'limit' must be a positive integer");
642
+ }
643
+ const messages = getSessionMessages(sessionKey, limit);
644
+ res.json({ sessionKey, messages });
645
+ });
596
646
  app.use(apiNotFoundHandler);
597
647
  // ---------------------------------------------------------------------------
598
648
  // Static SPA + fallback. Mounted last so API routes win.
@@ -625,7 +675,7 @@ app.use(createApiErrorHandler());
625
675
  export function startApiServer() {
626
676
  return new Promise((resolve, reject) => {
627
677
  const server = app.listen(config.apiPort, config.apiHost, () => {
628
- console.log(`[chapterhouse] HTTP API + web UI listening on http://${getDisplayHost(config.apiHost)}:${config.apiPort}`);
678
+ log.info({ host: getDisplayHost(config.apiHost), port: config.apiPort }, "HTTP API + web UI listening");
629
679
  resolve();
630
680
  });
631
681
  server.on("error", (err) => {
package/dist/cli.js CHANGED
@@ -21,19 +21,30 @@ Usage:
21
21
  chapterhouse <command>
22
22
 
23
23
  Commands:
24
- start Start the Chapterhouse daemon (web UI at http://localhost:7788)
25
- setup Pick a default model and write ~/.chapterhouse/.env
26
- update Check for updates and install the latest version
27
- help Show this help message
24
+ start Start the Chapterhouse daemon (web UI at http://localhost:7788)
25
+ setup Pick a default model and write ~/.chapterhouse/.env
26
+ update Check for updates and install the latest version
27
+ daemon <sub> Manage the persistent background service (install/uninstall/start/stop/restart/status/logs)
28
+ help Show this help message
28
29
 
29
30
  Flags (start):
30
31
  --self-edit Allow Chapterhouse to modify his own source code (off by default)
31
32
  --open Open the web UI in your default browser once the daemon is ready
32
33
 
34
+ Flags (update):
35
+ --check-only Print version info and install-source, then exit without updating
36
+ --ref <version> Install a specific version (e.g. --ref 0.1.5)
37
+ --force Bypass the Node/npm version precondition check
38
+
33
39
  Examples:
34
40
  chapterhouse start Start the daemon, then open http://localhost:7788
35
41
  chapterhouse start --open Same, but open the browser for you
36
42
  chapterhouse start --self-edit Start with self-edit enabled
43
+ chapterhouse update Update to latest (npm registry for global installs)
44
+ chapterhouse update --check-only Show current/latest version without updating
45
+ chapterhouse update --ref 0.1.5 Install a specific version
46
+ chapterhouse daemon install Install and enable the persistent background service
47
+ chapterhouse daemon status Show whether the service is running
37
48
  `.trim());
38
49
  }
39
50
  const args = process.argv.slice(2);
@@ -54,30 +65,77 @@ switch (command) {
54
65
  await import("./setup.js");
55
66
  break;
56
67
  case "update": {
57
- const { checkForUpdate, performUpdate } = await import("./update.js");
68
+ const updateFlags = args.slice(1);
69
+ const checkOnly = updateFlags.includes("--check-only");
70
+ const force = updateFlags.includes("--force");
71
+ const refIdx = updateFlags.indexOf("--ref");
72
+ const ref = refIdx !== -1 ? (updateFlags[refIdx + 1] ?? null) : null;
73
+ const { checkForUpdate, performUpdate, detectInstallSource, checkPreconditions, buildLegacyGitInstallCommand, } = await import("./update.js");
74
+ const source = detectInstallSource();
75
+ // Precondition check (bypass with --force)
76
+ if (!force) {
77
+ const pre = checkPreconditions();
78
+ if (!pre.ok) {
79
+ console.warn(`⚠ ${pre.message}`);
80
+ console.warn(" Run with --force to proceed anyway.");
81
+ process.exit(1);
82
+ }
83
+ }
58
84
  const check = await checkForUpdate();
59
85
  if (!check.checkSucceeded) {
60
- console.error("⚠ Could not reach GitHub to check for updates. Check your network and try again.");
86
+ console.error("⚠ Could not reach npm registry or GitHub to check for updates. Check your network and try again.");
61
87
  process.exit(1);
62
88
  }
63
- if (!check.updateAvailable) {
64
- console.log(`chapterhouse v${check.current} is already the latest version.`);
65
- console.log("Pulling latest from main...");
66
- const result = await performUpdate();
67
- if (result.ok) {
68
- console.log("✅ Updated to latest main.");
89
+ console.log(`chapterhouse current: v${check.current}`);
90
+ console.log(`chapterhouse latest: v${check.latest ?? "unknown"}`);
91
+ console.log(`Install source: ${source}`);
92
+ if (checkOnly) {
93
+ if (check.updateAvailable) {
94
+ console.log(`\nUpdate available: v${check.current} v${check.latest}`);
95
+ console.log(`Run \`chapterhouse update\` to install.`);
69
96
  }
70
97
  else {
71
- console.error(`❌ Update failed: ${result.output}`);
72
- process.exit(1);
98
+ console.log("\nYou are on the latest version.");
73
99
  }
74
100
  break;
75
101
  }
76
- console.log(`Update available: v${check.current} → v${check.latest}`);
77
- console.log("Installing...");
78
- const result = await performUpdate(check.latest ? `v${check.latest}` : null);
102
+ if (source === "dev") {
103
+ console.log("ℹ Dev mode — use git pull manually.");
104
+ break;
105
+ }
106
+ if (source === "git") {
107
+ console.warn("⚠ Deprecation notice: your install uses the legacy git clone path.");
108
+ console.warn(` Switch to the npm registry path for automatic updates:`);
109
+ console.warn(` npm install -g chapterhouse@latest`);
110
+ console.warn(" Continuing with legacy git update...\n");
111
+ }
112
+ const targetRef = ref ?? (check.latest ? check.latest : null);
113
+ if (!check.updateAvailable && !ref) {
114
+ console.log(`chapterhouse v${check.current} is already the latest version.`);
115
+ if (source !== "git")
116
+ break;
117
+ // For git installs, still offer to pull latest main
118
+ console.log("Pulling latest from main...");
119
+ }
120
+ else if (check.updateAvailable) {
121
+ console.log(`\nUpdate available: v${check.current} → v${check.latest}`);
122
+ console.log("Installing...");
123
+ }
124
+ else {
125
+ console.log(`Installing specified ref: ${targetRef}`);
126
+ }
127
+ const result = await performUpdate(targetRef);
79
128
  if (result.ok) {
80
- console.log(`✅ Updated to v${check.latest}`);
129
+ if (result.source === "dev") {
130
+ console.log("ℹ Dev mode — use git pull manually.");
131
+ }
132
+ else {
133
+ const label = ref ? `${ref}` : `v${check.latest ?? "latest"}`;
134
+ console.log(`✅ Updated to ${label}`);
135
+ if (result.source === "registry" || result.source === "unknown") {
136
+ console.log(" To verify provenance: npm audit signatures (in any project that has chapterhouse as a dep)");
137
+ }
138
+ }
81
139
  }
82
140
  else {
83
141
  console.error(`❌ Update failed: ${result.output}`);
@@ -85,6 +143,41 @@ switch (command) {
85
143
  }
86
144
  break;
87
145
  }
146
+ case "daemon": {
147
+ const subcommand = args[1];
148
+ const { install, uninstall, start: daemonStart, stop: daemonStop, restart: daemonRestart, status: daemonStatus, logs: daemonLogs, printDaemonHelp, } = await import("./daemon-install.js");
149
+ switch (subcommand) {
150
+ case "install":
151
+ await install();
152
+ break;
153
+ case "uninstall":
154
+ await uninstall();
155
+ break;
156
+ case "start":
157
+ await daemonStart();
158
+ break;
159
+ case "stop":
160
+ await daemonStop();
161
+ break;
162
+ case "restart":
163
+ await daemonRestart();
164
+ break;
165
+ case "status":
166
+ await daemonStatus();
167
+ break;
168
+ case "logs":
169
+ await daemonLogs();
170
+ break;
171
+ default:
172
+ if (subcommand) {
173
+ console.error(`Unknown daemon subcommand: ${subcommand}\n`);
174
+ }
175
+ printDaemonHelp();
176
+ if (subcommand)
177
+ process.exit(1);
178
+ }
179
+ break;
180
+ }
88
181
  case "help":
89
182
  case "--help":
90
183
  case "-h":
@@ -9,6 +9,8 @@ import { getState, setState } from "../store/db.js";
9
9
  import { loadMcpConfig } from "./mcp-config.js";
10
10
  import { getSkillDirectories } from "./skills.js";
11
11
  import { findSquadAgent, renderProjectAgentRoster } from "../squad/registry.js";
12
+ import { childLogger } from "../util/logger.js";
13
+ const log = childLogger("agents");
12
14
  // Frontmatter schema
13
15
  const agentFrontmatterSchema = z.object({
14
16
  name: z.string().min(1),
@@ -62,7 +64,7 @@ export function parseAgentMd(content, slug) {
62
64
  }
63
65
  const result = agentFrontmatterSchema.safeParse(parsed);
64
66
  if (!result.success) {
65
- console.warn(`[agents] Invalid frontmatter in ${slug}.agent.md:`, result.error.format());
67
+ log.warn({ slug, errors: result.error.format() }, "Invalid frontmatter in agent file");
66
68
  return null;
67
69
  }
68
70
  const fm = result.data;
@@ -102,7 +104,7 @@ export function loadAgents() {
102
104
  configs.push(config);
103
105
  }
104
106
  catch (err) {
105
- console.warn(`[agents] Failed to read ${entry}:`, err instanceof Error ? err.message : err);
107
+ log.warn({ entry, err: err instanceof Error ? err.message : err }, "Failed to read agent entry");
106
108
  }
107
109
  }
108
110
  agentRegistry = configs;
@@ -138,7 +140,7 @@ export function ensureDefaultAgents() {
138
140
  if (!existsSync(dest)) {
139
141
  copyFileSync(src, dest);
140
142
  setState(stateKey, srcHash);
141
- console.log(`[agents] Installed bundled agent: ${file}`);
143
+ log.info({ file }, "Installed bundled agent");
142
144
  continue;
143
145
  }
144
146
  // Check if the bundled version actually changed since our last sync
@@ -150,13 +152,13 @@ export function ensureDefaultAgents() {
150
152
  const destHash = createHash("sha256").update(readFileSync(dest)).digest("hex");
151
153
  if (lastSyncedHash && destHash !== lastSyncedHash) {
152
154
  // User modified the file after our last sync — don't clobber their changes
153
- console.log(`[agents] Skipping ${file} — user has local customizations`);
155
+ log.debug({ file }, "Skipping bundled agent — user has local customizations");
154
156
  continue;
155
157
  }
156
158
  // Safe to update: either first sync (no record) or file is unmodified from our last deploy
157
159
  copyFileSync(src, dest);
158
160
  setState(stateKey, srcHash);
159
- console.log(`[agents] Updated bundled agent: ${file}`);
161
+ log.info({ file }, "Updated bundled agent");
160
162
  }
161
163
  }
162
164
  /** Create a new agent .md file. Returns error string or null on success. */
@@ -326,7 +328,7 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
326
328
  bufferExhaustionThreshold: 0.95,
327
329
  },
328
330
  });
329
- console.log(`[agents] Created ephemeral session for @${agent.slug} (${model})`);
331
+ log.info({ agentSlug: agent.slug, model }, "Created ephemeral agent session");
330
332
  return session;
331
333
  }
332
334
  /** Create an ephemeral session for a squad virtual agent (not in CH registry). */
@@ -353,7 +355,7 @@ export async function createSquadAgentSession(slug, client, allTools, systemMess
353
355
  bufferExhaustionThreshold: 0.95,
354
356
  },
355
357
  });
356
- console.log(`[agents] Created squad virtual agent session for @${slug} (${model})`);
358
+ log.info({ agentSlug: slug, model }, "Created squad virtual agent session");
357
359
  return session;
358
360
  }
359
361
  /** Clean up active task tracking (for shutdown/restart). */
@@ -1,4 +1,6 @@
1
1
  import { approveAll } from "@github/copilot-sdk";
2
+ import { childLogger } from "../util/logger.js";
3
+ const log = childLogger("classifier");
2
4
  // ---------------------------------------------------------------------------
3
5
  // Persistent GPT-4.1 classifier session
4
6
  // ---------------------------------------------------------------------------
@@ -52,7 +54,7 @@ export async function classifyWithLLM(client, message) {
52
54
  return TIER_MAP[raw] ?? "standard";
53
55
  }
54
56
  catch (err) {
55
- console.log(`[chapterhouse] Classifier error (falling back to heuristics): ${err instanceof Error ? err.message : err}`);
57
+ log.warn({ err: err instanceof Error ? err.message : err }, "Classifier error, falling back to heuristics");
56
58
  // Destroy broken session so it's recreated next time
57
59
  if (classifierSession) {
58
60
  classifierSession.destroy().catch(() => { });