chapterhouse 0.3.19 → 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.
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { clearTurnLog, subscribeSession } from "./turn-event-log.js";
3
4
  function createFakeClient(state) {
4
5
  class FakeSession {
5
6
  sessionId = "session-123";
@@ -25,6 +26,9 @@ function createFakeClient(state) {
25
26
  state.pendingReject = reject;
26
27
  });
27
28
  }
29
+ if (state.sendErrorMessage) {
30
+ throw new Error(state.sendErrorMessage);
31
+ }
28
32
  return { data: { content: state.sendResult } };
29
33
  }
30
34
  async setModel(model) {
@@ -96,6 +100,9 @@ async function loadOrchestratorModule(t, overrides = {}) {
96
100
  ],
97
101
  sendResult: "Finished successfully",
98
102
  taskEvents: new Map(),
103
+ projectRegistry: {},
104
+ resolveProjectArgs: [],
105
+ loadProjectRulesArgs: [],
99
106
  ...overrides,
100
107
  };
101
108
  const client = createFakeClient(state);
@@ -187,6 +194,25 @@ async function loadOrchestratorModule(t, overrides = {}) {
187
194
  getWikiSummary: () => "wiki summary",
188
195
  },
189
196
  });
197
+ t.mock.module("../wiki/project-registry.js", {
198
+ namedExports: {
199
+ loadRegistry: () => state.projectRegistry,
200
+ },
201
+ });
202
+ t.mock.module("../wiki/project-rules.js", {
203
+ namedExports: {
204
+ loadProjectRules: (slug) => {
205
+ state.loadProjectRulesArgs.push(slug);
206
+ if (state.projectRulesError) {
207
+ throw new Error(state.projectRulesError);
208
+ }
209
+ return state.projectRulesResult ?? {
210
+ found: false,
211
+ path: `pages/projects/${slug}/rules.md`,
212
+ };
213
+ },
214
+ },
215
+ });
190
216
  t.mock.module("../paths.js", {
191
217
  namedExports: {
192
218
  SESSIONS_DIR: "/sessions",
@@ -242,9 +268,88 @@ async function loadOrchestratorModule(t, overrides = {}) {
242
268
  state.healthCheckIntervalMs = delayMs;
243
269
  return { ref() { }, unref() { } };
244
270
  });
271
+ t.mock.module("./project-resolution.js", {
272
+ namedExports: {
273
+ resolveProject: (message, context, registry) => {
274
+ state.resolveProjectArgs.push({
275
+ message,
276
+ context: { ...context },
277
+ registry: { ...registry },
278
+ });
279
+ return state.resolvedProject ?? null;
280
+ },
281
+ },
282
+ });
245
283
  const orchestrator = await import(new URL(`./orchestrator.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
246
284
  return { orchestrator, state, client };
247
285
  }
286
+ function createLoadedProjectRules() {
287
+ return {
288
+ found: true,
289
+ path: "pages/projects/chapterhouse/rules.md",
290
+ hard: {
291
+ auto_pr: false,
292
+ require_worktree: true,
293
+ pr_draft_default: true,
294
+ default_branch: "main",
295
+ commit_co_author: "Copilot <223556219+Copilot@users.noreply.github.com>",
296
+ test_command: "npm test",
297
+ build_command: "npm run build",
298
+ lint_command: "npm run lint:md",
299
+ require_clean_worktree: true,
300
+ },
301
+ soft: "- Keep tests green.\n- Prefer explicit names.\n",
302
+ warnings: [],
303
+ metadata: {},
304
+ };
305
+ }
306
+ function expectedWarningLines() {
307
+ return [
308
+ "⚠️ Project rule warning: this task may violate `auto_pr: false` — proceeding anyway.",
309
+ "⚠️ Project rule warning: this task may violate `require_worktree: true` — proceeding anyway.",
310
+ "⚠️ Project rule warning: this task may violate `pr_draft_default: true` — proceeding anyway.",
311
+ "⚠️ Project rule warning: this task may violate `default_branch: main` — proceeding anyway.",
312
+ "⚠️ Project rule warning: this task may violate `require_clean_worktree: true` — proceeding anyway.",
313
+ ];
314
+ }
315
+ function expectedProjectRulesPrompt(userPrompt, warningLines = []) {
316
+ const warningBlock = warningLines.length > 0 ? `${warningLines.join("\n")}\n\n` : "";
317
+ return warningBlock
318
+ + "## Active Project Rules\n\n"
319
+ + "Project: chapterhouse\n"
320
+ + "Registry path: /home/bjk/projects/chapterhouse\n"
321
+ + "Rules page: pages/projects/chapterhouse/rules.md\n\n"
322
+ + "Hard rules:\n"
323
+ + "- auto_pr: false\n"
324
+ + "- require_worktree: true\n"
325
+ + "- pr_draft_default: true\n"
326
+ + "- default_branch: main\n"
327
+ + "- commit_co_author: Copilot <223556219+Copilot@users.noreply.github.com>\n"
328
+ + "- test_command: npm test\n"
329
+ + "- build_command: npm run build\n"
330
+ + "- lint_command: npm run lint:md\n"
331
+ + "- require_clean_worktree: true\n\n"
332
+ + "Soft rules:\n"
333
+ + "- Keep tests green.\n"
334
+ + "- Prefer explicit names.\n\n"
335
+ + "These rules are advisory. If a planned action may violate a hard rule, emit a visible warning and proceed unless the user says otherwise.\n\n"
336
+ + userPrompt;
337
+ }
338
+ function captureSessionEvents(t, sessionKey) {
339
+ const events = [];
340
+ const seenTurnIds = new Set();
341
+ const unsubscribe = subscribeSession(sessionKey, (event) => {
342
+ events.push(event);
343
+ seenTurnIds.add(event.turnId);
344
+ });
345
+ t.after(() => {
346
+ unsubscribe();
347
+ for (const turnId of seenTurnIds) {
348
+ clearTurnLog(turnId);
349
+ }
350
+ });
351
+ return events;
352
+ }
248
353
  test("initOrchestrator falls back to an available model and eagerly creates a session", async (t) => {
249
354
  const { orchestrator, state, client } = await loadOrchestratorModule(t);
250
355
  await orchestrator.initOrchestrator(client);
@@ -311,6 +416,222 @@ test("sendToOrchestrator logs both sides, remembers web auth context, and record
311
416
  ]);
312
417
  assert.equal(state.episodeWrites, 1);
313
418
  });
419
+ test("sendToOrchestrator prepends active project rules when @project resolution succeeds", async (t) => {
420
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
421
+ config: {
422
+ copilotModel: "claude-sonnet-4.6",
423
+ selfEditEnabled: true,
424
+ },
425
+ routeResults: [
426
+ {
427
+ model: "claude-sonnet-4.6",
428
+ tier: "standard",
429
+ switched: false,
430
+ routerMode: "auto",
431
+ },
432
+ ],
433
+ sendResult: "Scoped summary",
434
+ projectRegistry: {
435
+ chapterhouse: "/home/bjk/projects/chapterhouse",
436
+ },
437
+ resolvedProject: {
438
+ slug: "chapterhouse",
439
+ path: "/home/bjk/projects/chapterhouse",
440
+ source: "mention",
441
+ },
442
+ projectRulesResult: createLoadedProjectRules(),
443
+ });
444
+ await orchestrator.initOrchestrator(client);
445
+ const final = await new Promise((resolve) => {
446
+ orchestrator.sendToOrchestrator("@project:chapterhouse summarize the deployment", { type: "web", connectionId: "conn-project" }, (text, done) => {
447
+ if (done) {
448
+ resolve(text);
449
+ }
450
+ });
451
+ });
452
+ assert.equal(final, "Scoped summary");
453
+ assert.deepEqual(state.routerArgs, [["[via web] @project:chapterhouse summarize the deployment", "claude-sonnet-4.6", []]]);
454
+ assert.deepEqual(state.resolveProjectArgs, [{
455
+ message: "[via web] @project:chapterhouse summarize the deployment",
456
+ context: {
457
+ projectPath: undefined,
458
+ },
459
+ registry: {
460
+ chapterhouse: "/home/bjk/projects/chapterhouse",
461
+ },
462
+ }]);
463
+ assert.deepEqual(state.loadProjectRulesArgs, ["chapterhouse"]);
464
+ assert.deepEqual(state.sessionPrompts, [{
465
+ prompt: expectedProjectRulesPrompt("[via web] @project:chapterhouse summarize the deployment"),
466
+ }]);
467
+ });
468
+ test("sendToOrchestrator prepends active project rules when the web project path resolves a project", async (t) => {
469
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
470
+ config: {
471
+ copilotModel: "claude-sonnet-4.6",
472
+ selfEditEnabled: true,
473
+ },
474
+ routeResults: [
475
+ {
476
+ model: "claude-sonnet-4.6",
477
+ tier: "standard",
478
+ switched: false,
479
+ routerMode: "auto",
480
+ },
481
+ ],
482
+ sendResult: "Path summary",
483
+ projectRegistry: {
484
+ chapterhouse: "/home/bjk/projects/chapterhouse",
485
+ },
486
+ resolvedProject: {
487
+ slug: "chapterhouse",
488
+ path: "/home/bjk/projects/chapterhouse",
489
+ source: "selectedProjectPath",
490
+ },
491
+ projectRulesResult: createLoadedProjectRules(),
492
+ });
493
+ await orchestrator.initOrchestrator(client);
494
+ const final = await new Promise((resolve) => {
495
+ orchestrator.sendToOrchestrator("summarize the deployment", { type: "web", connectionId: "conn-path", projectPath: "/home/bjk/projects/chapterhouse/src/copilot" }, (text, done) => {
496
+ if (done) {
497
+ resolve(text);
498
+ }
499
+ });
500
+ });
501
+ assert.equal(final, "Path summary");
502
+ assert.deepEqual(state.resolveProjectArgs, [{
503
+ message: "[via web] summarize the deployment",
504
+ context: {
505
+ projectPath: "/home/bjk/projects/chapterhouse/src/copilot",
506
+ },
507
+ registry: {
508
+ chapterhouse: "/home/bjk/projects/chapterhouse",
509
+ },
510
+ }]);
511
+ assert.deepEqual(state.loadProjectRulesArgs, ["chapterhouse"]);
512
+ assert.deepEqual(state.sessionPrompts, [{
513
+ prompt: expectedProjectRulesPrompt("[via web] summarize the deployment"),
514
+ }]);
515
+ });
516
+ test("sendToOrchestrator surfaces project rule warnings before the orchestrator response", async (t) => {
517
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
518
+ config: {
519
+ copilotModel: "claude-sonnet-4.6",
520
+ selfEditEnabled: true,
521
+ },
522
+ routeResults: [
523
+ {
524
+ model: "claude-sonnet-4.6",
525
+ tier: "standard",
526
+ switched: false,
527
+ routerMode: "auto",
528
+ },
529
+ ],
530
+ sendResult: "Working on it",
531
+ projectRegistry: {
532
+ chapterhouse: "/home/bjk/projects/chapterhouse",
533
+ },
534
+ resolvedProject: {
535
+ slug: "chapterhouse",
536
+ path: "/home/bjk/projects/chapterhouse",
537
+ source: "mention",
538
+ },
539
+ projectRulesResult: createLoadedProjectRules(),
540
+ });
541
+ await orchestrator.initOrchestrator(client);
542
+ const callbackStates = [];
543
+ const prompt = "@project:chapterhouse start the refactor directly in the main checkout, open a non-draft PR against develop, and release from the dirty worktree";
544
+ const warningBlock = `${expectedWarningLines().join("\n")}\n\n`;
545
+ const final = await new Promise((resolve) => {
546
+ orchestrator.sendToOrchestrator(prompt, { type: "web", connectionId: "conn-warning" }, (text, done) => {
547
+ callbackStates.push({ text, done });
548
+ if (done) {
549
+ resolve(text);
550
+ }
551
+ });
552
+ });
553
+ assert.equal(final, `${warningBlock}Working on it`);
554
+ assert.deepEqual(callbackStates, [
555
+ { text: warningBlock, done: false },
556
+ { text: `${warningBlock}Working on it`, done: true },
557
+ ]);
558
+ assert.deepEqual(state.sessionPrompts, [{
559
+ prompt: expectedProjectRulesPrompt(`[via web] ${prompt}`, expectedWarningLines()),
560
+ }]);
561
+ });
562
+ test("sendToOrchestrator leaves the prompt unchanged when no project resolves", async (t) => {
563
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
564
+ config: {
565
+ copilotModel: "claude-sonnet-4.6",
566
+ selfEditEnabled: true,
567
+ },
568
+ routeResults: [
569
+ {
570
+ model: "claude-sonnet-4.6",
571
+ tier: "standard",
572
+ switched: false,
573
+ routerMode: "auto",
574
+ },
575
+ ],
576
+ sendResult: "No rules",
577
+ projectRegistry: {
578
+ chapterhouse: "/home/bjk/projects/chapterhouse",
579
+ },
580
+ resolvedProject: null,
581
+ });
582
+ await orchestrator.initOrchestrator(client);
583
+ const final = await new Promise((resolve) => {
584
+ orchestrator.sendToOrchestrator("summarize the deployment", { type: "web", connectionId: "conn-no-project" }, (text, done) => {
585
+ if (done) {
586
+ resolve(text);
587
+ }
588
+ });
589
+ });
590
+ assert.equal(final, "No rules");
591
+ assert.deepEqual(state.loadProjectRulesArgs, []);
592
+ assert.deepEqual(state.sessionPrompts, [{
593
+ prompt: "[via web] summarize the deployment",
594
+ }]);
595
+ });
596
+ test("sendToOrchestrator leaves the prompt unchanged when loading project rules fails", async (t) => {
597
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
598
+ config: {
599
+ copilotModel: "claude-sonnet-4.6",
600
+ selfEditEnabled: true,
601
+ },
602
+ routeResults: [
603
+ {
604
+ model: "claude-sonnet-4.6",
605
+ tier: "standard",
606
+ switched: false,
607
+ routerMode: "auto",
608
+ },
609
+ ],
610
+ sendResult: "No rules",
611
+ projectRegistry: {
612
+ chapterhouse: "/home/bjk/projects/chapterhouse",
613
+ },
614
+ resolvedProject: {
615
+ slug: "chapterhouse",
616
+ path: "/home/bjk/projects/chapterhouse",
617
+ source: "mention",
618
+ },
619
+ projectRulesError: "invalid frontmatter",
620
+ });
621
+ await orchestrator.initOrchestrator(client);
622
+ const final = await new Promise((resolve) => {
623
+ orchestrator.sendToOrchestrator("@project:chapterhouse summarize the deployment", { type: "web", connectionId: "conn-project" }, (text, done) => {
624
+ if (done) {
625
+ resolve(text);
626
+ }
627
+ });
628
+ });
629
+ assert.equal(final, "No rules");
630
+ assert.deepEqual(state.loadProjectRulesArgs, ["chapterhouse"]);
631
+ assert.deepEqual(state.sessionPrompts, [{
632
+ prompt: "[via web] @project:chapterhouse summarize the deployment",
633
+ }]);
634
+ });
314
635
  test("@mentions route through the orchestrator session without invoking the model router", async (t) => {
315
636
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
316
637
  config: {
@@ -340,8 +661,10 @@ test("feedAgentResult injects a background completion turn and proactively notif
340
661
  selfEditEnabled: true,
341
662
  },
342
663
  sendResult: "Agent complete",
664
+ taskSessionKeys: new Map([["task-9", "chat:bg-lifecycle"]]),
343
665
  });
344
666
  await orchestrator.initOrchestrator(client);
667
+ const events = captureSessionEvents(t, "chat:bg-lifecycle");
345
668
  const notified = new Promise((resolve) => {
346
669
  orchestrator.setProactiveNotify(resolve);
347
670
  });
@@ -362,6 +685,57 @@ test("feedAgentResult injects a background completion turn and proactively notif
362
685
  source: "background",
363
686
  },
364
687
  ]);
688
+ const started = events.filter((event) => event.type === "turn:started");
689
+ const completed = events.filter((event) => event.type === "turn:complete");
690
+ assert.equal(started.length, 1, "background turn should emit one turn:started event");
691
+ assert.equal(completed.length, 1, "background turn should emit one turn:complete event");
692
+ assert.equal(started[0]?.turnId, completed[0]?.turnId, "background lifecycle events must share the same turnId");
693
+ });
694
+ test("enqueueForSse emits exactly one turn lifecycle pair for sse-web turns", async (t) => {
695
+ const { orchestrator, client } = await loadOrchestratorModule(t, {
696
+ config: {
697
+ copilotModel: "claude-sonnet-4.6",
698
+ selfEditEnabled: true,
699
+ },
700
+ sendResult: "SSE complete",
701
+ });
702
+ await orchestrator.initOrchestrator(client);
703
+ const sessionKey = `chat:sse-lifecycle-${Date.now()}`;
704
+ const events = captureSessionEvents(t, sessionKey);
705
+ const turnId = orchestrator.enqueueForSse({ sessionKey, prompt: "hello from sse" });
706
+ await new Promise((resolve) => setTimeout(resolve, 20));
707
+ const turnEvents = events.filter((event) => event.turnId === turnId);
708
+ const started = turnEvents.filter((event) => event.type === "turn:started");
709
+ const completed = turnEvents.filter((event) => event.type === "turn:complete");
710
+ assert.equal(started.length, 1, "sse-web turn should emit turn:started exactly once");
711
+ assert.equal(completed.length, 1, "sse-web turn should emit turn:complete exactly once");
712
+ });
713
+ test("sendToOrchestrator emits turn:error instead of turn:complete on failures", async (t) => {
714
+ const { orchestrator, client } = await loadOrchestratorModule(t, {
715
+ config: {
716
+ copilotModel: "claude-sonnet-4.6",
717
+ selfEditEnabled: true,
718
+ },
719
+ sendErrorMessage: "session exploded",
720
+ sendResult: "unreachable",
721
+ });
722
+ await orchestrator.initOrchestrator(client);
723
+ const sessionKey = `chat:error-lifecycle-${Date.now()}`;
724
+ const events = captureSessionEvents(t, sessionKey);
725
+ const received = new Promise((resolve) => {
726
+ orchestrator.sendToOrchestrator("trigger an error", { type: "background", sessionKey }, (text, done, turnId) => {
727
+ if (done) {
728
+ resolve({ text, done, turnId });
729
+ }
730
+ });
731
+ });
732
+ const result = await received;
733
+ assert.match(result.text, /^Error:/);
734
+ const turnEvents = events.filter((event) => event.turnId === result.turnId);
735
+ const errors = turnEvents.filter((event) => event.type === "turn:error");
736
+ const completed = turnEvents.filter((event) => event.type === "turn:complete");
737
+ assert.equal(errors.length, 1, "failed turn should emit one turn:error event");
738
+ assert.equal(completed.length, 0, "failed turn must not emit turn:complete");
365
739
  });
366
740
  test("cancelCurrentMessage aborts the active request and agent helpers expose running work", async (t) => {
367
741
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
@@ -606,10 +980,18 @@ test("#81: tool.execution_start stash + subagent.started → agent_tasks uses sp
606
980
  await new Promise((resolve) => setTimeout(resolve, 10));
607
981
  assert.ok(state.lastSession, "FakeSession must have been created");
608
982
  // Step 1: emit tool.execution_start for a "task" call with spawn parameters
983
+ const promptText = [
984
+ "Investigate why /workers omits the dispatched prompt.",
985
+ "Return the exact DB column and API response change needed.",
986
+ ].join("\n");
609
987
  state.lastSession.emit("tool.execution_start", {
610
988
  toolName: "task",
611
989
  toolCallId: "tc-spawn-1",
612
- arguments: { name: "kaylee", description: "🔧 Kaylee: test spawn" },
990
+ arguments: {
991
+ name: "kaylee",
992
+ description: "🔧 Kaylee: test spawn",
993
+ prompt: promptText,
994
+ },
613
995
  });
614
996
  // Step 2: emit subagent.started with the same toolCallId — SDK only knows agent_type details
615
997
  state.lastSession.emit("subagent.started", {
@@ -623,6 +1005,7 @@ test("#81: tool.execution_start stash + subagent.started → agent_tasks uses sp
623
1005
  const argsJson = JSON.stringify(insertWrite.args);
624
1006
  assert.ok(argsJson.includes("kaylee"), `agent_slug must be "kaylee" but got: ${argsJson}`);
625
1007
  assert.ok(argsJson.includes("🔧 Kaylee: test spawn"), `description must be spawn description but got: ${argsJson}`);
1008
+ assert.ok(insertWrite.args.some((arg) => arg === promptText), `prompt must be persisted from task spawn args but got: ${argsJson}`);
626
1009
  assert.ok(!argsJson.includes("general-purpose"), `agent_slug must NOT fall back to "general-purpose" when spawn name is available`);
627
1010
  state.pendingReject?.(new Error("test teardown"));
628
1011
  });
@@ -810,6 +1193,31 @@ test("#98: interruptCurrentTurn aborts active turn and starts replacement turn",
810
1193
  state.pendingReject?.(new Error("aborted"));
811
1194
  await new Promise((resolve) => setTimeout(resolve, 50));
812
1195
  });
1196
+ test("interruptCurrentTurn prepends active project rules to the replacement turn", async (t) => {
1197
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1198
+ sendResult: "__PENDING__",
1199
+ projectRegistry: {
1200
+ chapterhouse: "/home/bjk/projects/chapterhouse",
1201
+ },
1202
+ projectRulesResult: createLoadedProjectRules(),
1203
+ });
1204
+ await orchestrator.initOrchestrator(client);
1205
+ orchestrator.sendToOrchestrator("first long request", { type: "background" }, () => { });
1206
+ await new Promise((resolve) => setTimeout(resolve, 10));
1207
+ state.resolvedProject = {
1208
+ slug: "chapterhouse",
1209
+ path: "/home/bjk/projects/chapterhouse",
1210
+ source: "mention",
1211
+ };
1212
+ await orchestrator.interruptCurrentTurn("default", "@project:chapterhouse replacement request", { type: "background" }, () => { });
1213
+ state.sendResult = "replacement complete";
1214
+ state.pendingReject?.(new Error("aborted"));
1215
+ await new Promise((resolve) => setTimeout(resolve, 50));
1216
+ assert.deepEqual(state.sessionPrompts, [
1217
+ { prompt: "first long request" },
1218
+ { prompt: expectedProjectRulesPrompt("@project:chapterhouse replacement request") },
1219
+ ]);
1220
+ });
813
1221
  test("#98: interruptCurrentTurn falls back to normal send when no session is active", async (t) => {
814
1222
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
815
1223
  sendResult: "fallback response",
@@ -0,0 +1,73 @@
1
+ import { isAbsolute, normalize, relative, sep } from "node:path";
2
+ const PROJECT_MENTION_RE = /(^|[^@\w-])@project:([a-z0-9][a-z0-9-]*)(?=$|[^A-Za-z0-9_-])/;
3
+ export function extractProjectMention(message) {
4
+ const match = message.match(PROJECT_MENTION_RE);
5
+ return match?.[2] ?? null;
6
+ }
7
+ export function resolveProject(message, context, registry) {
8
+ const explicitSlug = extractProjectMention(message);
9
+ if (explicitSlug) {
10
+ const explicitPath = registry[explicitSlug];
11
+ if (explicitPath) {
12
+ return {
13
+ slug: explicitSlug,
14
+ path: normalizeProjectPath(explicitPath),
15
+ source: "mention",
16
+ };
17
+ }
18
+ }
19
+ const selectedProjectPath = context.selectedProjectPath ?? context.projectPath;
20
+ if (selectedProjectPath) {
21
+ const selectedMatch = matchProjectPath(selectedProjectPath, registry);
22
+ if (selectedMatch) {
23
+ return {
24
+ ...selectedMatch,
25
+ source: "selectedProjectPath",
26
+ };
27
+ }
28
+ }
29
+ if (context.cwd) {
30
+ const cwdMatch = matchProjectPath(context.cwd, registry);
31
+ if (cwdMatch) {
32
+ return {
33
+ ...cwdMatch,
34
+ source: "cwd",
35
+ };
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ function matchProjectPath(candidatePath, registry) {
41
+ const normalizedCandidate = normalizeCandidatePath(candidatePath);
42
+ if (!normalizedCandidate)
43
+ return null;
44
+ const entries = Object.entries(registry)
45
+ .map(([slug, registeredPath]) => ({
46
+ slug,
47
+ path: normalizeProjectPath(registeredPath),
48
+ }))
49
+ .sort((left, right) => right.path.length - left.path.length || left.slug.localeCompare(right.slug));
50
+ for (const entry of entries) {
51
+ if (pathMatches(entry.path, normalizedCandidate)) {
52
+ return entry;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ function pathMatches(projectPath, candidatePath) {
58
+ const rel = relative(projectPath, candidatePath);
59
+ return rel === "" || (!rel.startsWith("..") && rel !== ".." && !rel.startsWith(`..${sep}`));
60
+ }
61
+ function normalizeCandidatePath(path) {
62
+ if (!isAbsolute(path))
63
+ return null;
64
+ return normalizeProjectPath(path);
65
+ }
66
+ function normalizeProjectPath(path) {
67
+ let normalized = normalize(path);
68
+ while (normalized.length > 1 && normalized.endsWith(sep)) {
69
+ normalized = normalized.slice(0, -1);
70
+ }
71
+ return normalized;
72
+ }
73
+ //# sourceMappingURL=project-resolution.js.map
@@ -0,0 +1,124 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ async function loadModule() {
4
+ const nonce = `${Date.now()}-${Math.random()}`;
5
+ return import(new URL(`./project-resolution.js?case=${nonce}`, import.meta.url).href);
6
+ }
7
+ test("extractProjectMention returns the first exact slug mention", async () => {
8
+ const { extractProjectMention } = await loadModule();
9
+ assert.equal(extractProjectMention("Please fix this for (@project:docs-site), then circle back to @project:chapterhouse."), "docs-site");
10
+ });
11
+ test("extractProjectMention ignores partial, cased, and embedded mentions", async () => {
12
+ const { extractProjectMention } = await loadModule();
13
+ assert.equal(extractProjectMention("email foo@project:docs-site later"), null);
14
+ assert.equal(extractProjectMention("@project:Docs-Site fix this"), null);
15
+ assert.equal(extractProjectMention("@project:docs_site fix this"), null);
16
+ });
17
+ test("resolveProject prefers an explicit project mention over path-based matches", async () => {
18
+ const { resolveProject } = await loadModule();
19
+ const registry = {
20
+ chapterhouse: "/work/chapterhouse",
21
+ "docs-site": "/work/docs-site",
22
+ };
23
+ assert.deepEqual(resolveProject("@project:chapterhouse fix the spinner", {
24
+ selectedProjectPath: "/work/docs-site",
25
+ cwd: "/work/docs-site",
26
+ }, registry), {
27
+ slug: "chapterhouse",
28
+ path: "/work/chapterhouse",
29
+ source: "mention",
30
+ });
31
+ });
32
+ test("resolveProject falls back to the selected project path when an explicit mention is not registered", async () => {
33
+ const { resolveProject } = await loadModule();
34
+ const registry = {
35
+ chapterhouse: "/work/chapterhouse",
36
+ "docs-site": "/work/docs-site",
37
+ };
38
+ assert.deepEqual(resolveProject("@project:missing update the landing page", {
39
+ selectedProjectPath: "/work/docs-site",
40
+ cwd: "/work/chapterhouse",
41
+ }, registry), {
42
+ slug: "docs-site",
43
+ path: "/work/docs-site",
44
+ source: "selectedProjectPath",
45
+ });
46
+ });
47
+ test("resolveProject treats projectPath as the selected project path signal", async () => {
48
+ const { resolveProject } = await loadModule();
49
+ const registry = {
50
+ chapterhouse: "/work/chapterhouse",
51
+ "docs-site": "/work/docs-site",
52
+ };
53
+ assert.deepEqual(resolveProject("Update the landing page", {
54
+ projectPath: "/work/docs-site",
55
+ cwd: "/work/chapterhouse",
56
+ }, registry), {
57
+ slug: "docs-site",
58
+ path: "/work/docs-site",
59
+ source: "selectedProjectPath",
60
+ });
61
+ });
62
+ test("resolveProject matches an exact selected project path before cwd", async () => {
63
+ const { resolveProject } = await loadModule();
64
+ const registry = {
65
+ chapterhouse: "/work/chapterhouse",
66
+ "docs-site": "/work/docs-site",
67
+ };
68
+ assert.deepEqual(resolveProject("Update the landing page", {
69
+ selectedProjectPath: "/work/docs-site",
70
+ cwd: "/work/chapterhouse",
71
+ }, registry), {
72
+ slug: "docs-site",
73
+ path: "/work/docs-site",
74
+ source: "selectedProjectPath",
75
+ });
76
+ });
77
+ test("resolveProject matches descendant paths and picks the longest registered prefix", async () => {
78
+ const { resolveProject } = await loadModule();
79
+ const registry = {
80
+ monorepo: "/work/chapterhouse",
81
+ web: "/work/chapterhouse/web",
82
+ docs: "/work/docs",
83
+ };
84
+ assert.deepEqual(resolveProject("Fix the UI build", {
85
+ cwd: "/work/chapterhouse/web/src/routes",
86
+ }, registry), {
87
+ slug: "web",
88
+ path: "/work/chapterhouse/web",
89
+ source: "cwd",
90
+ });
91
+ });
92
+ test("resolveProject does not treat sibling prefixes as descendant matches", async () => {
93
+ const { resolveProject } = await loadModule();
94
+ const registry = {
95
+ app: "/work/app",
96
+ };
97
+ assert.equal(resolveProject("Fix it", {
98
+ cwd: "/work/app2/src",
99
+ }, registry), null);
100
+ });
101
+ test("resolveProject trims trailing slashes before path matching", async () => {
102
+ const { resolveProject } = await loadModule();
103
+ const registry = {
104
+ chapterhouse: "/work/chapterhouse/",
105
+ };
106
+ assert.deepEqual(resolveProject("Fix it", {
107
+ cwd: "/work/chapterhouse/web/",
108
+ }, registry), {
109
+ slug: "chapterhouse",
110
+ path: "/work/chapterhouse",
111
+ source: "cwd",
112
+ });
113
+ });
114
+ test("resolveProject returns null when neither message text nor paths resolve to a registered project", async () => {
115
+ const { resolveProject } = await loadModule();
116
+ const registry = {
117
+ chapterhouse: "/work/chapterhouse",
118
+ };
119
+ assert.equal(resolveProject("Fix it", {
120
+ selectedProjectPath: "/work/docs-site",
121
+ cwd: "/tmp/other",
122
+ }, registry), null);
123
+ });
124
+ //# sourceMappingURL=project-resolution.test.js.map