chapterhouse 0.3.20 → 0.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/server.js +154 -3
- package/dist/api/server.test.js +461 -14
- package/dist/copilot/orchestrator.js +70 -3
- package/dist/copilot/orchestrator.test.js +337 -1
- package/dist/copilot/project-resolution.js +73 -0
- package/dist/copilot/project-resolution.test.js +124 -0
- package/dist/copilot/project-rule-warnings.js +73 -0
- package/dist/copilot/project-rule-warnings.test.js +46 -0
- package/dist/copilot/project-rules-injection.js +71 -0
- package/dist/copilot/project-rules-injection.test.js +84 -0
- package/dist/copilot/tools.agent.test.js +214 -0
- package/dist/copilot/tools.js +14 -3
- package/dist/store/db.js +4 -0
- package/dist/store/db.test.js +30 -0
- package/dist/wiki/frontmatter.js +1 -1
- package/dist/wiki/lint.js +37 -10
- package/dist/wiki/lint.test.js +72 -0
- package/dist/wiki/project-registry.js +160 -0
- package/dist/wiki/project-registry.test.js +72 -0
- package/dist/wiki/project-rules.js +155 -0
- package/dist/wiki/project-rules.test.js +217 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-9We9vWBC.js → index-B8dZzlmE.js} +66 -66
- package/web/dist/assets/index-B8dZzlmE.js.map +1 -0
- package/web/dist/assets/index-D9flFppK.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-9We9vWBC.js.map +0 -1
- package/web/dist/assets/index-DYx2idiH.css +0 -10
package/dist/api/server.test.js
CHANGED
|
@@ -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(
|
|
109
|
-
mkdirSync(
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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`, {
|