chapterhouse 0.3.20 → 0.3.22

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.
@@ -1,9 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { createServer } from "node:http";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
+ import Database from "better-sqlite3";
7
8
  test("supports API_TOKEN env var and personal health route helpers", async () => {
8
9
  const runtime = await import("./server-runtime.js");
9
10
  assert.equal(typeof runtime.resolveApiToken, "function", "resolveApiToken should be exported");
@@ -67,7 +68,6 @@ test("formats named SSE status events", async () => {
67
68
  assert.equal(sse.formatSseEvent("status", { status: "dreaming", message: "Consolidating memories..." }), 'event: status\ndata: {"status":"dreaming","message":"Consolidating memories..."}\n\n');
68
69
  });
69
70
  const repoRoot = process.cwd();
70
- const serverTestRoot = join(repoRoot, ".test-work", `server-routes-${process.pid}`);
71
71
  async function getFreePort() {
72
72
  const server = createServer();
73
73
  await new Promise((resolve) => {
@@ -104,9 +104,10 @@ async function stopChild(child) {
104
104
  // Additionally, strip COPILOT_* env vars from the child so it doesn't accidentally
105
105
  // piggy-back on the running agent session, which worsens the blocking behaviour.
106
106
  async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
107
+ const testRoot = join(repoRoot, ".test-work", `server-routes-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
107
108
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
108
- rmSync(serverTestRoot, { recursive: true, force: true });
109
- mkdirSync(serverTestRoot, { recursive: true });
109
+ rmSync(testRoot, { recursive: true, force: true });
110
+ mkdirSync(testRoot, { recursive: true });
110
111
  const port = await getFreePort();
111
112
  const logs = [];
112
113
  const child = spawn(process.execPath, [
@@ -120,7 +121,7 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
120
121
  // agent's session credentials, which causes the SDK to hang in standalone mode.
121
122
  ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("COPILOT_"))),
122
123
  CHAPTERHOUSE_DISABLE_DOTENV: "1",
123
- CHAPTERHOUSE_HOME: serverTestRoot,
124
+ CHAPTERHOUSE_HOME: testRoot,
124
125
  API_HOST: "127.0.0.1",
125
126
  API_PORT: String(port),
126
127
  API_TOKEN: "route-token",
@@ -140,7 +141,7 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
140
141
  try {
141
142
  const response = await fetch(`${baseUrl}/status`);
142
143
  if (response.ok) {
143
- await run({ baseUrl, authHeader: "Bearer route-token" });
144
+ await run({ baseUrl, authHeader: "Bearer route-token", testRoot });
144
145
  return;
145
146
  }
146
147
  }
@@ -153,7 +154,7 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
153
154
  }
154
155
  finally {
155
156
  await stopChild(child);
156
- rmSync(serverTestRoot, { recursive: true, force: true });
157
+ rmSync(testRoot, { recursive: true, force: true });
157
158
  }
158
159
  }
159
160
  test("server routes expose bootstrap and public config without auth", async () => {
@@ -230,13 +231,22 @@ test("server only enables production CORS for explicit origins", async () => {
230
231
  });
231
232
  test("server wiki routes support authenticated CRUD", async () => {
232
233
  await withStartedServer(async ({ baseUrl, authHeader }) => {
234
+ const content = `---
235
+ title: Deploy
236
+ summary: Deploy runbook
237
+ updated: 2026-05-12
238
+ tags: [ops]
239
+ ---
240
+
241
+ # Deploy
242
+ `;
233
243
  const createResponse = await fetch(`${baseUrl}/api/wiki/page?path=pages/shared/runbooks/deploy.md`, {
234
244
  method: "PUT",
235
245
  headers: {
236
246
  authorization: authHeader,
237
247
  "content-type": "application/json",
238
248
  },
239
- body: JSON.stringify({ content: "# Deploy\n" }),
249
+ body: JSON.stringify({ content }),
240
250
  });
241
251
  assert.equal(createResponse.status, 200);
242
252
  assert.deepEqual(await createResponse.json(), {
@@ -250,7 +260,15 @@ test("server wiki routes support authenticated CRUD", async () => {
250
260
  assert.equal(getResponse.status, 200);
251
261
  assert.deepEqual(await getResponse.json(), {
252
262
  path: "pages/shared/runbooks/deploy.md",
253
- content: "# Deploy\n",
263
+ content,
264
+ renderedContent: "# Deploy\n",
265
+ frontmatter: {
266
+ title: "Deploy",
267
+ summary: "Deploy runbook",
268
+ updated: "2026-05-12",
269
+ tags: ["ops"],
270
+ metadata: {},
271
+ },
254
272
  });
255
273
  const deleteResponse = await fetch(`${baseUrl}/api/wiki/page?path=pages/shared/runbooks/deploy.md`, {
256
274
  method: "DELETE",
@@ -268,9 +286,39 @@ test("server wiki routes support authenticated CRUD", async () => {
268
286
  assert.deepEqual(await missingResponse.json(), { error: "Page not found" });
269
287
  });
270
288
  });
289
+ test("server worker detail returns the stored dispatched prompt", async () => {
290
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
291
+ const db = new Database(join(testRoot, ".chapterhouse", "chapterhouse.db"));
292
+ try {
293
+ const cols = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
294
+ if (!cols.some((col) => col.name === "prompt")) {
295
+ db.exec(`ALTER TABLE agent_tasks ADD COLUMN prompt TEXT`);
296
+ }
297
+ db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, prompt, status)
298
+ VALUES (?, ?, ?, ?, ?)`).run("worker-prompt-001", "coder", "Investigate workers detail", "Inspect the /workers detail API and surface the full dispatched prompt.", "completed");
299
+ }
300
+ finally {
301
+ db.close();
302
+ }
303
+ const response = await fetch(`${baseUrl}/api/workers/worker-prompt-001`, {
304
+ headers: { authorization: authHeader },
305
+ });
306
+ assert.equal(response.status, 200);
307
+ const body = await response.json();
308
+ assert.equal(body.taskId, "worker-prompt-001");
309
+ assert.equal(body.agentSlug, "coder");
310
+ assert.equal(typeof body.name, "string");
311
+ assert.equal(body.description, "Investigate workers detail");
312
+ assert.equal(body.status, "completed");
313
+ assert.equal(body.prompt, "Inspect the /workers detail API and surface the full dispatched prompt.");
314
+ assert.equal(body.result, null);
315
+ assert.equal(typeof body.startedAt, "string");
316
+ assert.equal(body.completedAt, null);
317
+ });
318
+ });
271
319
  test("server wiki route synthesizes a welcome page when pages/index.md is missing", async () => {
272
- await withStartedServer(async ({ baseUrl, authHeader }) => {
273
- rmSync(join(serverTestRoot, ".chapterhouse", "wiki", "pages", "index.md"), { force: true });
320
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
321
+ rmSync(join(testRoot, ".chapterhouse", "wiki", "pages", "index.md"), { force: true });
274
322
  const response = await fetch(`${baseUrl}/api/wiki/page?path=pages/index.md`, {
275
323
  headers: { authorization: authHeader },
276
324
  });
@@ -289,12 +337,24 @@ Your wiki is empty. Pages are organized by category — projects, people, tools,
289
337
 
290
338
  Create your first page via the wiki UI or by editing files under \`pages/\`.
291
339
  `,
340
+ renderedContent: `# Wiki
341
+
342
+ Your wiki is empty. Pages are organized by category — projects, people, tools, topics, areas, orgs, facts, preferences, routines.
343
+
344
+ Create your first page via the wiki UI or by editing files under \`pages/\`.
345
+ `,
346
+ frontmatter: {
347
+ title: "Wiki",
348
+ summary: "Empty wiki — get started.",
349
+ updated: new Date().toISOString().slice(0, 10),
350
+ metadata: {},
351
+ },
292
352
  });
293
353
  });
294
354
  });
295
355
  test("server wiki route returns stored content for pages/index.md when it exists", async () => {
296
- await withStartedServer(async ({ baseUrl, authHeader }) => {
297
- const indexPath = join(serverTestRoot, ".chapterhouse", "wiki", "pages", "index.md");
356
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
357
+ const indexPath = join(testRoot, ".chapterhouse", "wiki", "pages", "index.md");
298
358
  const content = `---
299
359
  title: Wiki
300
360
  summary: Existing home page.
@@ -303,7 +363,7 @@ updated: 2026-05-12
303
363
 
304
364
  # Custom Wiki Home
305
365
  `;
306
- mkdirSync(join(serverTestRoot, ".chapterhouse", "wiki", "pages"), { recursive: true });
366
+ mkdirSync(join(testRoot, ".chapterhouse", "wiki", "pages"), { recursive: true });
307
367
  writeFileSync(indexPath, content, "utf-8");
308
368
  const response = await fetch(`${baseUrl}/api/wiki/page?path=pages/index.md`, {
309
369
  headers: { authorization: authHeader },
@@ -312,6 +372,13 @@ updated: 2026-05-12
312
372
  assert.deepEqual(await response.json(), {
313
373
  path: "pages/index.md",
314
374
  content,
375
+ renderedContent: "# Custom Wiki Home\n",
376
+ frontmatter: {
377
+ title: "Wiki",
378
+ summary: "Existing home page.",
379
+ updated: "2026-05-12",
380
+ metadata: {},
381
+ },
315
382
  });
316
383
  });
317
384
  });
@@ -324,6 +391,386 @@ test("server wiki route still returns 404 for other missing wiki pages", async (
324
391
  assert.deepEqual(await response.json(), { error: "Page not found" });
325
392
  });
326
393
  });
394
+ test("server projects route returns an empty list when the registry is missing", async () => {
395
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
396
+ const response = await fetch(`${baseUrl}/api/projects`, {
397
+ headers: { authorization: authHeader },
398
+ });
399
+ assert.equal(response.status, 200);
400
+ assert.deepEqual(await response.json(), []);
401
+ });
402
+ });
403
+ test("server projects route sorts by slug and summarizes rule counts", async () => {
404
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
405
+ const projectsIndexPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "index.md");
406
+ mkdirSync(join(projectsIndexPath, ".."), { recursive: true });
407
+ writeFileSync(projectsIndexPath, "---\n"
408
+ + "title: Projects\n"
409
+ + "summary: Canonical project registry.\n"
410
+ + "updated: 2026-05-12\n"
411
+ + "---\n\n"
412
+ + "# Projects\n\n"
413
+ + "## Project Registry\n\n"
414
+ + "```yaml\n"
415
+ + "zeta: /srv/zeta\n"
416
+ + "alpha: /srv/alpha\n"
417
+ + "```\n", "utf-8");
418
+ const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
419
+ mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
420
+ writeFileSync(alphaRulesPath, "---\n"
421
+ + "title: Project rules for alpha\n"
422
+ + "summary: Alpha rules.\n"
423
+ + "auto_pr: false\n"
424
+ + "test_command: npm test\n"
425
+ + "---\n\n"
426
+ + "## Soft Rules\n\n"
427
+ + "- Keep tests green.\n"
428
+ + "* Keep docs updated.\n", "utf-8");
429
+ const response = await fetch(`${baseUrl}/api/projects`, {
430
+ headers: { authorization: authHeader },
431
+ });
432
+ assert.equal(response.status, 200);
433
+ assert.deepEqual(await response.json(), [
434
+ {
435
+ slug: "alpha",
436
+ cwd: "/srv/alpha",
437
+ hardRuleCount: 2,
438
+ softRuleCount: 2,
439
+ },
440
+ {
441
+ slug: "zeta",
442
+ cwd: "/srv/zeta",
443
+ hardRuleCount: null,
444
+ softRuleCount: null,
445
+ },
446
+ ]);
447
+ });
448
+ });
449
+ test("server project detail route returns cwd plus hard and soft rules", async () => {
450
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
451
+ const projectsIndexPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "index.md");
452
+ mkdirSync(join(projectsIndexPath, ".."), { recursive: true });
453
+ writeFileSync(projectsIndexPath, "---\n"
454
+ + "title: Projects\n"
455
+ + "summary: Canonical project registry.\n"
456
+ + "updated: 2026-05-12\n"
457
+ + "---\n\n"
458
+ + "# Projects\n\n"
459
+ + "## Project Registry\n\n"
460
+ + "```yaml\n"
461
+ + "alpha: /srv/alpha\n"
462
+ + "```\n", "utf-8");
463
+ const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
464
+ mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
465
+ writeFileSync(alphaRulesPath, "---\n"
466
+ + "title: Project rules for alpha\n"
467
+ + "summary: Alpha rules.\n"
468
+ + "auto_pr: false\n"
469
+ + "test_command: npm test\n"
470
+ + "---\n\n"
471
+ + "## Soft Rules\n\n"
472
+ + "- Keep tests green.\n"
473
+ + "* Prefer worktrees for PR work.\n"
474
+ + " - Nested bullets do not count yet.\n", "utf-8");
475
+ const response = await fetch(`${baseUrl}/api/projects/alpha`, {
476
+ headers: { authorization: authHeader },
477
+ });
478
+ assert.equal(response.status, 200);
479
+ assert.deepEqual(await response.json(), {
480
+ slug: "alpha",
481
+ cwd: "/srv/alpha",
482
+ rulesFound: true,
483
+ hardRules: {
484
+ auto_pr: false,
485
+ require_worktree: false,
486
+ pr_draft_default: false,
487
+ default_branch: "main",
488
+ commit_co_author: "Copilot <223556219+Copilot@users.noreply.github.com>",
489
+ test_command: "npm test",
490
+ build_command: "",
491
+ lint_command: "",
492
+ require_clean_worktree: false,
493
+ },
494
+ softRules: [
495
+ "Keep tests green.",
496
+ "Prefer worktrees for PR work.",
497
+ ],
498
+ });
499
+ }, {}, 60_000);
500
+ });
501
+ test("server project detail route returns 404 for an unknown slug", async () => {
502
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
503
+ const response = await fetch(`${baseUrl}/api/projects/missing`, {
504
+ headers: { authorization: authHeader },
505
+ });
506
+ assert.equal(response.status, 404);
507
+ assert.deepEqual(await response.json(), { error: "Project not found" });
508
+ }, {}, 60_000);
509
+ });
510
+ test("server projects create route rejects a duplicate slug", async () => {
511
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
512
+ const projectsIndexPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "index.md");
513
+ mkdirSync(join(projectsIndexPath, ".."), { recursive: true });
514
+ writeFileSync(projectsIndexPath, "---\n"
515
+ + "title: Projects\n"
516
+ + "summary: Canonical project registry.\n"
517
+ + "updated: 2026-05-12\n"
518
+ + "---\n\n"
519
+ + "# Projects\n\n"
520
+ + "## Project Registry\n\n"
521
+ + "```yaml\n"
522
+ + "alpha: /srv/original\n"
523
+ + "```\n", "utf-8");
524
+ const response = await fetch(`${baseUrl}/api/projects`, {
525
+ method: "POST",
526
+ headers: {
527
+ authorization: authHeader,
528
+ "content-type": "application/json",
529
+ },
530
+ body: JSON.stringify({
531
+ slug: "alpha",
532
+ cwd: "/srv/alpha",
533
+ }),
534
+ });
535
+ assert.equal(response.status, 400);
536
+ assert.deepEqual(await response.json(), { error: "Project 'alpha' already exists" });
537
+ assert.equal(readFileSync(projectsIndexPath, "utf-8"), "---\n"
538
+ + "title: Projects\n"
539
+ + "summary: Canonical project registry.\n"
540
+ + "updated: 2026-05-12\n"
541
+ + "---\n\n"
542
+ + "# Projects\n\n"
543
+ + "## Project Registry\n\n"
544
+ + "```yaml\n"
545
+ + "alpha: /srv/original\n"
546
+ + "```\n");
547
+ }, {}, 60_000);
548
+ });
549
+ test("server projects delete route removes the registry entry and rules page", async () => {
550
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
551
+ const projectsIndexPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "index.md");
552
+ mkdirSync(join(projectsIndexPath, ".."), { recursive: true });
553
+ writeFileSync(projectsIndexPath, "---\n"
554
+ + "title: Projects\n"
555
+ + "summary: Canonical project registry.\n"
556
+ + "updated: 2026-05-12\n"
557
+ + "---\n\n"
558
+ + "# Projects\n\n"
559
+ + "## Project Registry\n\n"
560
+ + "```yaml\n"
561
+ + "alpha: /srv/alpha\n"
562
+ + "beta: /srv/beta\n"
563
+ + "```\n", "utf-8");
564
+ const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
565
+ mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
566
+ writeFileSync(alphaRulesPath, "---\n"
567
+ + "title: Project rules for alpha\n"
568
+ + "summary: Alpha rules.\n"
569
+ + "---\n\n"
570
+ + "## Soft Rules\n", "utf-8");
571
+ const response = await fetch(`${baseUrl}/api/projects/alpha`, {
572
+ method: "DELETE",
573
+ headers: { authorization: authHeader },
574
+ });
575
+ assert.equal(response.status, 200);
576
+ assert.deepEqual(await response.json(), { ok: true, slug: "alpha" });
577
+ assert.equal(readFileSync(projectsIndexPath, "utf-8"), "---\n"
578
+ + "title: Projects\n"
579
+ + "summary: Canonical project registry.\n"
580
+ + "updated: 2026-05-12\n"
581
+ + "---\n\n"
582
+ + "# Projects\n\n"
583
+ + "## Project Registry\n\n"
584
+ + "```yaml\n"
585
+ + "beta: /srv/beta\n"
586
+ + "```\n");
587
+ assert.equal(existsSync(alphaRulesPath), false);
588
+ }, {}, 60_000);
589
+ });
590
+ test("server projects delete route returns 404 for an unknown slug", async () => {
591
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
592
+ const response = await fetch(`${baseUrl}/api/projects/missing`, {
593
+ method: "DELETE",
594
+ headers: { authorization: authHeader },
595
+ });
596
+ assert.equal(response.status, 404);
597
+ assert.deepEqual(await response.json(), { error: "Project not found" });
598
+ }, {}, 60_000);
599
+ });
600
+ test("server project hard-rules update route rewrites only hard-rule frontmatter fields", async () => {
601
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
602
+ const projectsIndexPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "index.md");
603
+ mkdirSync(join(projectsIndexPath, ".."), { recursive: true });
604
+ writeFileSync(projectsIndexPath, "---\n"
605
+ + "title: Projects\n"
606
+ + "summary: Canonical project registry.\n"
607
+ + "updated: 2026-05-12\n"
608
+ + "---\n\n"
609
+ + "# Projects\n\n"
610
+ + "## Project Registry\n\n"
611
+ + "```yaml\n"
612
+ + "alpha: /srv/alpha\n"
613
+ + "```\n", "utf-8");
614
+ const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
615
+ mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
616
+ writeFileSync(alphaRulesPath, "---\n"
617
+ + "title: Project rules for alpha\n"
618
+ + "summary: Alpha rules.\n"
619
+ + "updated: 2026-05-13\n"
620
+ + "tags: [engineering, workflow]\n"
621
+ + "related: []\n"
622
+ + "auto_pr: false\n"
623
+ + "test_command: npm test\n"
624
+ + "custom_rule: preserve-me\n"
625
+ + "---\n\n"
626
+ + "## Soft Rules\n\n"
627
+ + "- Keep tests green.\n", "utf-8");
628
+ const response = await fetch(`${baseUrl}/api/projects/alpha/rules/hard`, {
629
+ method: "PUT",
630
+ headers: {
631
+ authorization: authHeader,
632
+ "content-type": "application/json",
633
+ },
634
+ body: JSON.stringify({
635
+ hardRules: {
636
+ auto_pr: true,
637
+ require_worktree: true,
638
+ pr_draft_default: true,
639
+ default_branch: "release",
640
+ commit_co_author: "Jane Doe <jane@example.com>",
641
+ test_command: "pnpm test",
642
+ build_command: "pnpm build",
643
+ lint_command: "pnpm lint",
644
+ require_clean_worktree: true,
645
+ },
646
+ }),
647
+ });
648
+ assert.equal(response.status, 200);
649
+ assert.deepEqual(await response.json(), {
650
+ slug: "alpha",
651
+ cwd: "/srv/alpha",
652
+ rulesFound: true,
653
+ hardRules: {
654
+ auto_pr: true,
655
+ require_worktree: true,
656
+ pr_draft_default: true,
657
+ default_branch: "release",
658
+ commit_co_author: "Jane Doe <jane@example.com>",
659
+ test_command: "pnpm test",
660
+ build_command: "pnpm build",
661
+ lint_command: "pnpm lint",
662
+ require_clean_worktree: true,
663
+ },
664
+ softRules: [
665
+ "Keep tests green.",
666
+ ],
667
+ });
668
+ assert.equal(readFileSync(alphaRulesPath, "utf-8"), "---\n"
669
+ + "title: Project rules for alpha\n"
670
+ + "summary: Alpha rules.\n"
671
+ + "updated: 2026-05-13\n"
672
+ + "tags: [engineering, workflow]\n"
673
+ + "related: []\n"
674
+ + "auto_pr: true\n"
675
+ + "require_worktree: true\n"
676
+ + "pr_draft_default: true\n"
677
+ + "default_branch: release\n"
678
+ + "commit_co_author: Jane Doe <jane@example.com>\n"
679
+ + "test_command: pnpm test\n"
680
+ + "build_command: pnpm build\n"
681
+ + "lint_command: pnpm lint\n"
682
+ + "require_clean_worktree: true\n"
683
+ + "custom_rule: preserve-me\n"
684
+ + "---\n\n"
685
+ + "## Soft Rules\n\n"
686
+ + "- Keep tests green.\n");
687
+ }, {}, 60_000);
688
+ });
689
+ test("server project soft-rules update route rewrites the body while preserving frontmatter", async () => {
690
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
691
+ const projectsIndexPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "index.md");
692
+ mkdirSync(join(projectsIndexPath, ".."), { recursive: true });
693
+ writeFileSync(projectsIndexPath, "---\n"
694
+ + "title: Projects\n"
695
+ + "summary: Canonical project registry.\n"
696
+ + "updated: 2026-05-12\n"
697
+ + "---\n\n"
698
+ + "# Projects\n\n"
699
+ + "## Project Registry\n\n"
700
+ + "```yaml\n"
701
+ + "alpha: /srv/alpha\n"
702
+ + "```\n", "utf-8");
703
+ const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
704
+ mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
705
+ writeFileSync(alphaRulesPath, "---\n"
706
+ + "title: Project rules for alpha\n"
707
+ + "summary: Alpha rules.\n"
708
+ + "updated: 2026-05-13\n"
709
+ + "tags: [engineering, workflow]\n"
710
+ + "related: []\n"
711
+ + "auto_pr: false\n"
712
+ + "test_command: npm test\n"
713
+ + "custom_rule: preserve-me\n"
714
+ + "---\n\n"
715
+ + "## Soft Rules\n\n"
716
+ + "Intro text that should be replaced.\n\n"
717
+ + "- Keep tests green.\n", "utf-8");
718
+ const response = await fetch(`${baseUrl}/api/projects/alpha/rules/soft`, {
719
+ method: "PUT",
720
+ headers: {
721
+ authorization: authHeader,
722
+ "content-type": "application/json",
723
+ },
724
+ body: JSON.stringify({
725
+ softRules: [
726
+ "Ship the smallest safe slice.",
727
+ "Document deferred work in the PR body.",
728
+ ],
729
+ }),
730
+ });
731
+ assert.equal(response.status, 200);
732
+ assert.deepEqual(await response.json(), {
733
+ slug: "alpha",
734
+ cwd: "/srv/alpha",
735
+ rulesFound: true,
736
+ hardRules: {
737
+ auto_pr: false,
738
+ require_worktree: false,
739
+ pr_draft_default: false,
740
+ default_branch: "main",
741
+ commit_co_author: "Copilot <223556219+Copilot@users.noreply.github.com>",
742
+ test_command: "npm test",
743
+ build_command: "",
744
+ lint_command: "",
745
+ require_clean_worktree: false,
746
+ },
747
+ softRules: [
748
+ "Ship the smallest safe slice.",
749
+ "Document deferred work in the PR body.",
750
+ ],
751
+ });
752
+ assert.equal(readFileSync(alphaRulesPath, "utf-8"), "---\n"
753
+ + "title: Project rules for alpha\n"
754
+ + "summary: Alpha rules.\n"
755
+ + "updated: 2026-05-13\n"
756
+ + "tags: [engineering, workflow]\n"
757
+ + "related: []\n"
758
+ + "auto_pr: false\n"
759
+ + "require_worktree: false\n"
760
+ + "pr_draft_default: false\n"
761
+ + "default_branch: main\n"
762
+ + "commit_co_author: Copilot <223556219+Copilot@users.noreply.github.com>\n"
763
+ + "test_command: npm test\n"
764
+ + "build_command: \n"
765
+ + "lint_command: \n"
766
+ + "require_clean_worktree: false\n"
767
+ + "custom_rule: preserve-me\n"
768
+ + "---\n\n"
769
+ + "## Soft Rules\n\n"
770
+ + "- Ship the smallest safe slice.\n"
771
+ + "- Document deferred work in the PR body.\n");
772
+ }, {}, 60_000);
773
+ });
327
774
  test("server message route validates the SSE connection id", async () => {
328
775
  await withStartedServer(async ({ baseUrl, authHeader }) => {
329
776
  const response = await fetch(`${baseUrl}/api/message`, {