chapterhouse 0.3.1 → 0.3.3

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
@@ -182,6 +182,21 @@ Optional Entra settings:
182
182
  - `ENTRA_REQUIRED_ROLE` — if set, the signed-in user must have this app role in the token's `roles` claim. This replaced the older group-based check.
183
183
  - `ENTRA_TEAM_LEAD_ID` — optional for regular engineers, who can omit it entirely. Set it only for the one person who should be treated as `team-lead` for managerial functions such as `/api/team/report` and protected OKR/KPI/team wiki writes. Without it, the signed-in user is treated as `engineer`, which is the correct role for normal team members.
184
184
 
185
+ ### WorkIQ MCP server (Entra only)
186
+
187
+ When `ENTRA_AUTH_ENABLED=true`, Chapterhouse automatically adds a `workiq` entry to `~/.copilot/mcp-config.json` at daemon startup. This gives the orchestrator access to Microsoft 365 tools (Teams, Outlook, Calendar, etc.) via the `@microsoft/workiq` MCP server without any manual configuration.
188
+
189
+ The entry uses `npx -y @microsoft/workiq` so no global npm install is required — npx fetches the server on first use.
190
+
191
+ | Behaviour | Detail |
192
+ |-----------|--------|
193
+ | **Trigger** | `ENTRA_AUTH_ENABLED=true` + `ENTRA_TENANT_ID` set |
194
+ | **Idempotent** | Safe to restart; entry is only written if `workiq` key is absent |
195
+ | **Opt-out** | Set `CHAPTERHOUSE_WORKIQ_AUTO_INSTALL=false` to disable |
196
+ | **Failure-safe** | If the write fails (permissions, read-only FS), a structured warning is logged and the daemon continues |
197
+
198
+ **`CHAPTERHOUSE_WORKIQ_AUTO_INSTALL`** — `true` (default) or `false`. Set to `false` to manage the workiq MCP entry manually.
199
+
185
200
  ## Docker (Personal)
186
201
 
187
202
  For a single-user local deployment, use the personal compose file. It binds port `7788`, runs the daemon as the non-root `node` user, and persists state in `CHAPTERHOUSE_HOME` (default: `$HOME/.chapterhouse` on macOS/Linux).
@@ -239,9 +254,47 @@ The deployment assets for the shared instance set Entra auth and `CHAPTERHOUSE_M
239
254
  | `chapterhouse update --check-only` | Print current/latest version without updating |
240
255
  | `chapterhouse update --ref <ver>` | Install a specific version |
241
256
  | `chapterhouse daemon <sub>` | Manage the persistent background service |
257
+ | `chapterhouse squad init` | Initialize a new Squad team for this project (guided dialog) |
242
258
  | `chapterhouse squad worktree <sub>` | Manage per-agent git worktrees for concurrent squad work |
243
259
  | `chapterhouse help` | Show available commands |
244
260
 
261
+ ### Squad Init Command
262
+
263
+ Bootstrap a Squad team for a project that has no `.squad/` directory yet. The command runs an interactive dialog and writes the full `.squad/` scaffold:
264
+
265
+ ```sh
266
+ chapterhouse squad init
267
+ # Runs the guided dialog: project name, stack, goal, team size, universe.
268
+ # Proposes a named roster, then writes .squad/ on confirmation.
269
+
270
+ chapterhouse squad init --force
271
+ # Re-scaffolds even if .squad/ already exists (overwrites existing files).
272
+
273
+ chapterhouse squad init --yes
274
+ # Skip the confirmation prompt (non-interactive / CI mode).
275
+
276
+ chapterhouse squad init --universe Firefly
277
+ # Use a specific naming universe instead of the default (Dune).
278
+ # Available: Dune (default), Firefly, Star Wars, The Matrix, Breaking Bad
279
+ ```
280
+
281
+ **What gets written:**
282
+
283
+ | Path | Purpose |
284
+ |------|---------|
285
+ | `.squad/team.md` | Roster with `## Members` table (required by GitHub workflows) |
286
+ | `.squad/routing.md` | Task routing rules by role |
287
+ | `.squad/ceremonies.md` | Sprint rhythm and review gates |
288
+ | `.squad/decisions.md` | Append-only decision log |
289
+ | `.squad/agents/{name}/charter.md` | Per-agent charter with project context |
290
+ | `.squad/agents/{name}/history.md` | Per-agent day-1 seed history |
291
+ | `.squad/casting/policy.json` | Universe allowlist and capacity |
292
+ | `.squad/casting/registry.json` | Persistent name registry (cast names survive re-runs) |
293
+ | `.squad/casting/history.json` | Universe assignment audit trail |
294
+ | `.gitattributes` | Union merge rules for append-only Squad files |
295
+
296
+ **Naming universes:** By default, Squad uses the **Dune** universe (Leto, Jessica, Stilgar, Chani, Gurney, Duncan, …). This is configurable with `--universe` or via the dialog. Scribe and Ralph are always exempt from casting — they keep their names regardless of universe.
297
+
245
298
  ### Squad Worktree Commands
246
299
 
247
300
  Squad agents that work on GitHub issues each get a dedicated git worktree so they never step on each other. The `chapterhouse squad worktree` subcommands manage these worktrees:
@@ -319,9 +372,21 @@ Chapterhouse enforces a **3-layer timing contract** so in-flight LLM streams can
319
372
 
320
373
  **Rule:** each layer must exceed the one above it. Do not tighten `CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS` below `CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS`, and do not reduce `TimeoutStopSec` below `CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS`.
321
374
 
375
+ #### Session lifecycle
376
+
377
+ Each conversation window (browser tab, terminal session, squad worktree) maps to a separate `SessionManager` with its own queue and SDK session. Two env vars control how long sessions live:
378
+
379
+ | Config | What it controls | Default |
380
+ |--------|-----------------|---------|
381
+ | `CHAPTERHOUSE_SESSION_IDLE_TTL_MS` | How long an idle session (no in-flight turn, empty queue) is kept before being disconnected | `1800000` (30 min) |
382
+ | `CHAPTERHOUSE_SESSION_MAX_ACTIVE` | Maximum number of simultaneously active sessions; when reached, the least-recently-used *idle* session is evicted to make room | `20` |
383
+
384
+ Busy sessions (processing a turn or with items queued) are never evicted by either mechanism.
385
+
322
386
  #### Daemon PATH
323
387
 
324
388
  The generated systemd unit and launchd plist compose a rich `PATH` that includes:
389
+
325
390
  - The installing shell's `$PATH` (captured at install time)
326
391
  - The binary's own directory
327
392
  - Linuxbrew (`/home/linuxbrew/.linuxbrew/bin`), Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`)
@@ -343,7 +408,7 @@ The browser app at `http://localhost:7788` is split into a few views:
343
408
 
344
409
  ## How it Works
345
410
 
346
- ```
411
+ ```text
347
412
  Browser ──HTTP / SSE──► Chapterhouse Daemon
348
413
 
349
414
  Orchestrator Session (Copilot SDK)
@@ -457,6 +522,12 @@ npm run dev:web
457
522
 
458
523
  # Build everything
459
524
  npm run build
525
+
526
+ # Run tests
527
+ npm test
528
+
529
+ # Lint user-facing markdown (README, CHANGELOG, docs/, .github/)
530
+ npm run lint:md
460
531
  ```
461
532
 
462
533
  The web UI lives in `web/`. Production builds emit to `web/dist/`, which the Express server serves out of in `src/api/server.ts`.
@@ -482,5 +553,6 @@ git push origin main --follow-tags
482
553
  All commits on this repository follow **[Conventional Commits v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/)**. The format is `<type>(<scope>): <subject>` (e.g. `feat(api): add session export endpoint`). Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `build`, `ci`, `revert`, `release`.
483
554
 
484
555
  This is automatically enforced:
556
+
485
557
  - **Locally:** `husky` installs a `commit-msg` git hook on `npm install` that runs `commitlint` against every commit message. Bad messages are rejected before the commit lands.
486
558
  - **On PRs:** A GitHub Action (`lint-pr-title.yml`) validates the PR title on every open/edit. This matters because squash-merges use the PR title as the commit message on `main`.
package/dist/api/auth.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import jwt from "jsonwebtoken";
2
2
  import jwksClient from "jwks-rsa";
3
+ import { childLogger } from "../util/logger.js";
4
+ const log = childLogger("auth");
3
5
  function getIssuer(tenantId) {
4
6
  return `https://login.microsoftonline.com/${tenantId}/v2.0`;
5
7
  }
@@ -137,8 +139,7 @@ export function createAuthMiddleware(options) {
137
139
  try {
138
140
  const claims = await verifyBearerToken(token, options.config);
139
141
  if (options.config.entraRequiredRole && !hasRequiredRole(claims, options.config.entraRequiredRole)) {
140
- console.warn(`[auth] Token for ${claims.preferred_username || claims.oid} rejected: ` +
141
- `required role "${options.config.entraRequiredRole}" not found in roles=${JSON.stringify(claims.roles ?? [])}`);
142
+ log.warn({ username: claims.preferred_username || claims.oid, requiredRole: options.config.entraRequiredRole, roles: claims.roles ?? [] }, "token rejected: required app role not found");
142
143
  unauthorized(res, "Unauthorized: user does not have the required app role");
143
144
  return;
144
145
  }
@@ -151,7 +152,7 @@ export function createAuthMiddleware(options) {
151
152
  next();
152
153
  }
153
154
  catch (err) {
154
- console.error("[auth] JWT verification failed:", err);
155
+ log.error({ err }, "JWT verification failed");
155
156
  unauthorized(res, "Unauthorized: invalid or expired token");
156
157
  }
157
158
  };
@@ -323,46 +323,34 @@ test("returns a generic message when JWT verification fails", async () => {
323
323
  const auth = await loadAuthModule();
324
324
  assert.ok(auth, "auth module should exist");
325
325
  assert.equal(typeof auth.createAuthMiddleware, "function", "createAuthMiddleware should be exported");
326
- const originalConsoleError = console.error;
327
- const logged = [];
328
- console.error = (...args) => {
329
- logged.push(args);
326
+ const middleware = auth.createAuthMiddleware({
327
+ apiToken: "legacy-token",
328
+ config: {
329
+ entraAuthEnabled: true,
330
+ standaloneMode: false,
331
+ entraClientId: "client-id",
332
+ entraTenantId: "tenant-id",
333
+ entraRequiredRole: "",
334
+ entraTeamLeadId: "",
335
+ },
336
+ verifyBearerToken: async () => {
337
+ throw new Error("jwt malformed");
338
+ },
339
+ });
340
+ const req = {
341
+ path: "/api/message",
342
+ headers: { authorization: "Bearer bad-token" },
343
+ query: {},
344
+ socket: { remoteAddress: "127.0.0.1" },
330
345
  };
331
- try {
332
- const middleware = auth.createAuthMiddleware({
333
- apiToken: "legacy-token",
334
- config: {
335
- entraAuthEnabled: true,
336
- standaloneMode: false,
337
- entraClientId: "client-id",
338
- entraTenantId: "tenant-id",
339
- entraRequiredRole: "",
340
- entraTeamLeadId: "",
341
- },
342
- verifyBearerToken: async () => {
343
- throw new Error("jwt malformed");
344
- },
345
- });
346
- const req = {
347
- path: "/api/message",
348
- headers: { authorization: "Bearer bad-token" },
349
- query: {},
350
- socket: { remoteAddress: "127.0.0.1" },
351
- };
352
- const res = createMockResponse();
353
- let nextCalled = false;
354
- await middleware(req, res, () => {
355
- nextCalled = true;
356
- });
357
- assert.equal(nextCalled, false);
358
- assert.equal(res.statusCode, 401);
359
- assert.deepEqual(res.body, { error: "Unauthorized: invalid or expired token" });
360
- assert.equal(logged.length, 1);
361
- assert.equal(logged[0]?.[0], "[auth] JWT verification failed:");
362
- }
363
- finally {
364
- console.error = originalConsoleError;
365
- }
346
+ const res = createMockResponse();
347
+ let nextCalled = false;
348
+ await middleware(req, res, () => {
349
+ nextCalled = true;
350
+ });
351
+ assert.equal(nextCalled, false);
352
+ assert.equal(res.statusCode, 401);
353
+ assert.deepEqual(res.body, { error: "Unauthorized: invalid or expired token" });
366
354
  });
367
355
  // ---------------------------------------------------------------------------
368
356
  // Auth edge cases: JWKS cache + Entra error paths
@@ -5,7 +5,7 @@ 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";
8
- import { sendToOrchestrator, getAgentInfo, cancelCurrentMessage, getLastRouteResult, getCurrentSessionKey } from "../copilot/orchestrator.js";
8
+ import { sendToOrchestrator, getAgentInfo, cancelCurrentMessage, getLastRouteResult, getCurrentSessionKey, subscribeTaskEvents } from "../copilot/orchestrator.js";
9
9
  import { getAgentRegistry } from "../copilot/agents.js";
10
10
  import { config, persistModel } from "../config.js";
11
11
  import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
@@ -19,7 +19,7 @@ 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, getSessionMessages } from "../store/db.js";
22
+ import { getDb, getSessionMessages, getTaskEvents } 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";
@@ -266,9 +266,12 @@ app.get("/api/workers/:taskId", (req, res) => {
266
266
  if (!row) {
267
267
  throw new NotFoundError("Task not found");
268
268
  }
269
+ const registry = getAgentRegistry();
270
+ const agent = registry.find((a) => a.slug === row.agent_slug);
269
271
  res.json({
270
272
  taskId: row.task_id,
271
273
  agentSlug: row.agent_slug,
274
+ name: agent?.name || row.agent_slug,
272
275
  description: row.description,
273
276
  status: row.status,
274
277
  result: row.result,
@@ -276,6 +279,44 @@ app.get("/api/workers/:taskId", (req, res) => {
276
279
  completedAt: row.completed_at,
277
280
  });
278
281
  });
282
+ // Historical event log for a task (catch-up on page load)
283
+ app.get("/api/workers/:taskId/events", (req, res) => {
284
+ const taskId = req.params.taskId;
285
+ const afterSeqRaw = req.query.afterSeq;
286
+ const afterSeq = typeof afterSeqRaw === "string" && !isNaN(Number(afterSeqRaw)) ? Number(afterSeqRaw) : 0;
287
+ const taskRow = getDb()
288
+ .prepare(`SELECT task_id FROM agent_tasks WHERE task_id = ?`)
289
+ .get(taskId);
290
+ if (!taskRow) {
291
+ throw new NotFoundError("Task not found");
292
+ }
293
+ const events = getTaskEvents(taskId, afterSeq);
294
+ res.json({ taskId, events });
295
+ });
296
+ // SSE stream for per-task live tool-call activity
297
+ app.get("/api/workers/:taskId/events/stream", (req, res) => {
298
+ const taskId = req.params.taskId;
299
+ const taskRow = getDb()
300
+ .prepare(`SELECT task_id FROM agent_tasks WHERE task_id = ?`)
301
+ .get(taskId);
302
+ if (!taskRow) {
303
+ throw new NotFoundError("Task not found");
304
+ }
305
+ res.writeHead(200, {
306
+ "Content-Type": "text/event-stream",
307
+ "Cache-Control": "no-cache",
308
+ Connection: "keep-alive",
309
+ });
310
+ res.write(formatSseData({ type: "connected", taskId }));
311
+ const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
312
+ const unsub = subscribeTaskEvents(taskId, (event) => {
313
+ res.write(formatSseData({ type: "task_event", taskId, ...event }));
314
+ });
315
+ req.on("close", () => {
316
+ clearInterval(heartbeat);
317
+ unsub();
318
+ });
319
+ });
279
320
  // ---------------------------------------------------------------------------
280
321
  // SSE stream for real-time chat
281
322
  // ---------------------------------------------------------------------------
package/dist/api/team.js CHANGED
@@ -9,6 +9,7 @@ import { BadRequestError, ForbiddenError, InternalServerError, asBadRequest, par
9
9
  import { assertPagePath, ensureWikiStructure, getWikiDir, listPages, readPage, writePage, } from "../wiki/fs.js";
10
10
  import { withWikiWrite } from "../wiki/lock.js";
11
11
  import { TeamsNotifier, TEAMS_MILESTONE_THRESHOLDS } from "../integrations/teams-notify.js";
12
+ import { childLogger } from "../util/logger.js";
12
13
  const wikiPageRoute = /^\/wiki\/(.+)$/;
13
14
  const updateSchema = z.object({
14
15
  engineerId: z.string().min(1),
@@ -111,10 +112,11 @@ function getCrossedMilestone(kr, delta) {
111
112
  }
112
113
  export function createTeamRouter(options) {
113
114
  const router = express.Router();
115
+ const log = childLogger("team");
114
116
  const now = options.now ?? (() => new Date());
115
117
  const teamsNotifier = options.teamsNotifier ?? new TeamsNotifier();
116
118
  const adoClient = options.adoClient ?? (config.adoPat ? new AdoClient() : undefined);
117
- const warn = options.warn ?? ((message) => console.warn(message));
119
+ const warn = options.warn ?? ((message) => log.warn(message));
118
120
  router.use(options.authMiddleware);
119
121
  router.post("/update", async (req, res) => {
120
122
  const input = parseRequest(updateSchema, req.body);
@@ -187,7 +189,7 @@ export function createTeamRouter(options) {
187
189
  res.json({ ok: true, report });
188
190
  }
189
191
  catch (error) {
190
- console.error("[team] Failed to generate report:", error);
192
+ log.error({ err: error instanceof Error ? error.message : error }, "failed to generate report");
191
193
  throw new InternalServerError();
192
194
  }
193
195
  });
package/dist/cli.js CHANGED
@@ -25,7 +25,7 @@ Commands:
25
25
  setup Pick a default model and write ~/.chapterhouse/.env
26
26
  update Check for updates and install the latest version
27
27
  daemon <sub> Manage the persistent background service (install/uninstall/start/stop/restart/status/logs)
28
- squad <sub> Squad agent tools (worktree management)
28
+ squad <sub> Squad agent tools (init, worktree management)
29
29
  help Show this help message
30
30
 
31
31
  Flags (start):
@@ -182,9 +182,13 @@ switch (command) {
182
182
  case "squad": {
183
183
  const squadSub = args[1];
184
184
  if (squadSub === 'worktree') {
185
- const { runWorktreeCli, printWorktreeHelp } = await import("./squad/worktree.js");
185
+ const { runWorktreeCli } = await import("./squad/worktree.js");
186
186
  await runWorktreeCli(args.slice(2));
187
187
  }
188
+ else if (squadSub === 'init') {
189
+ const { runInitCli } = await import("./squad/init-cli.js");
190
+ await runInitCli(args.slice(2));
191
+ }
188
192
  else {
189
193
  if (squadSub) {
190
194
  console.error(`Unknown squad subcommand: ${squadSub}\n`);
@@ -193,8 +197,10 @@ switch (command) {
193
197
  chapterhouse squad — Squad agent tools
194
198
 
195
199
  Subcommands:
200
+ init Initialize a new Squad team for this project (guided dialog)
196
201
  worktree Manage per-agent git worktrees (create / list / remove / prune)
197
202
 
203
+ Run \`chapterhouse squad init\` to scaffold a .squad/ directory with a named team.
198
204
  Run \`chapterhouse squad worktree\` for worktree subcommand help.
199
205
  `.trim());
200
206
  if (squadSub)
package/dist/config.js CHANGED
@@ -46,6 +46,7 @@ const configSchema = z.object({
46
46
  API_RATE_LIMIT_AUTH_MAX: z.string().optional(),
47
47
  API_RATE_LIMIT_SSE_MAX_CONNECTIONS: z.string().optional(),
48
48
  ENABLE_SQUAD: z.string().optional(),
49
+ CHAPTERHOUSE_WORKIQ_AUTO_INSTALL: z.string().optional(),
49
50
  });
50
51
  export const DEFAULT_MODEL = "claude-sonnet-4.6";
51
52
  export const DEFAULT_TEAM_WIKI_CACHE_TTL_MINUTES = 60;
@@ -219,6 +220,7 @@ export function parseRuntimeConfig(env, options = {}) {
219
220
  apiRateLimitAuthMax,
220
221
  apiRateLimitSseMaxConnections,
221
222
  squadEnabled: raw.ENABLE_SQUAD === "1",
223
+ workiqAutoInstall: parseBooleanEnv("CHAPTERHOUSE_WORKIQ_AUTO_INSTALL", raw.CHAPTERHOUSE_WORKIQ_AUTO_INSTALL, true),
222
224
  };
223
225
  }
224
226
  const runtimeConfig = parseRuntimeConfig(process.env);
@@ -258,6 +260,7 @@ export const config = {
258
260
  apiRateLimitAuthMax: runtimeConfig.apiRateLimitAuthMax,
259
261
  apiRateLimitSseMaxConnections: runtimeConfig.apiRateLimitSseMaxConnections,
260
262
  squadEnabled: runtimeConfig.squadEnabled,
263
+ workiqAutoInstall: runtimeConfig.workiqAutoInstall,
261
264
  copilotAuthToken: runtimeConfig.copilotAuthToken,
262
265
  get copilotModel() {
263
266
  return _copilotModel;
@@ -12,6 +12,8 @@ import { addToIndex } from "../wiki/index-manager.js";
12
12
  import { appendLog } from "../wiki/log-manager.js";
13
13
  import { withWikiWrite } from "../wiki/lock.js";
14
14
  import { setBusy, setIdle } from "../status.js";
15
+ import { childLogger } from "../util/logger.js";
16
+ const log = childLogger("episode-writer");
15
17
  const EPISODE_MODEL = "gpt-4.1";
16
18
  const EPISODE_TIMEOUT_MS = 30_000;
17
19
  const MIN_TURNS_FOR_SUMMARY = 10;
@@ -193,10 +195,10 @@ async function runEpisode(client) {
193
195
  setState(LAST_SUMMARY_TIME_KEY, String(now));
194
196
  setState(IN_PROGRESS_KEY, "");
195
197
  });
196
- console.log(`[chapterhouse] Episode writer: summarized ${rows.length} turns (ids ${startId}-${endId}) pages/conversations/${new Date().toISOString().slice(0, 10)}.md`);
198
+ log.info({ rows: rows.length, startId, endId }, "summarized turns");
197
199
  }
198
200
  catch (err) {
199
- console.log(`[chapterhouse] Episode writer error (non-fatal): ${err instanceof Error ? err.message : err}`);
201
+ log.warn({ err: err instanceof Error ? err.message : err }, "episode writer error (non-fatal)");
200
202
  if (episodeSession) {
201
203
  episodeSession.destroy().catch(() => { });
202
204
  episodeSession = undefined;