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.
@@ -19,6 +19,11 @@ import { agentEventBus } from "./agent-event-bus.js";
19
19
  import { initTaskEventLog } from "./task-event-log.js";
20
20
  import { emitTurnEvent, persistTurnEvents, scheduleClearTurnLog, } from "./turn-event-log.js";
21
21
  import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
22
+ import { ActiveProjectRulesLoadError, renderActiveProjectRulesBlock, } from "./project-rules-injection.js";
23
+ import { detectProjectRuleWarnings } from "./project-rule-warnings.js";
24
+ import { loadRegistry } from "../wiki/project-registry.js";
25
+ import { loadProjectRules } from "../wiki/project-rules.js";
26
+ import { resolveProject } from "./project-resolution.js";
22
27
  const log = childLogger("orchestrator");
23
28
  const MAX_RETRIES = 3;
24
29
  const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];
@@ -118,6 +123,9 @@ export function getCurrentChannelKey() {
118
123
  export function getCurrentActivityCallback() {
119
124
  return turnContextStorage.getStore()?.activityCallback;
120
125
  }
126
+ export function getCurrentActiveProjectRules() {
127
+ return turnContextStorage.getStore()?.activeProjectRules ?? null;
128
+ }
121
129
  export function getCurrentAuthenticatedUser() {
122
130
  return turnContextStorage.getStore()?.authUser ?? currentAuthenticatedUser;
123
131
  }
@@ -360,6 +368,14 @@ export const ORCHESTRATOR_TIMEOUT_MS = (() => {
360
368
  async function executeOnSession(manager, item) {
361
369
  const { sessionKey } = manager;
362
370
  const session = await manager.ensureSession();
371
+ const activeProjectRules = getActiveProjectRules(item.prompt, item.projectPath);
372
+ const warningLines = activeProjectRules
373
+ ? detectProjectRuleWarnings(item.prompt, activeProjectRules.rules.hard)
374
+ : [];
375
+ const warningBlock = warningLines.length > 0 ? `${warningLines.join("\n")}\n\n` : "";
376
+ const sessionPrompt = activeProjectRules
377
+ ? `${warningBlock}${renderActiveProjectRulesBlock(activeProjectRules.project.slug, activeProjectRules.project.path, activeProjectRules.rules.path, activeProjectRules.rules.hard, activeProjectRules.rules.soft)}\n\n${item.prompt}`
378
+ : item.prompt;
363
379
  // Update last-seen globals (backwards compat — for callers that inspect after a turn ends)
364
380
  currentAuthenticatedUser = item.authUser;
365
381
  currentAuthorizationHeader = item.authHeader;
@@ -370,6 +386,7 @@ async function executeOnSession(manager, item) {
370
386
  authUser: item.authUser,
371
387
  authHeader: item.authHeader,
372
388
  activityCallback: item.onActivity,
389
+ activeProjectRules,
373
390
  }, async () => {
374
391
  let accumulated = "";
375
392
  let toolCallExecuted = false;
@@ -386,6 +403,11 @@ async function executeOnSession(manager, item) {
386
403
  spawnArgsMap.set(data.toolCallId, {
387
404
  name: typeof args.name === "string" ? args.name : undefined,
388
405
  description: typeof args.description === "string" ? args.description : undefined,
406
+ prompt: typeof args.prompt === "string"
407
+ ? args.prompt
408
+ : typeof args.task === "string"
409
+ ? args.task
410
+ : undefined,
389
411
  });
390
412
  }
391
413
  });
@@ -522,7 +544,12 @@ async function executeOnSession(manager, item) {
522
544
  const description = (typeof spawnArgs?.description === "string"
523
545
  ? spawnArgs.description
524
546
  : data.agentDescription || data.agentDisplayName || `Agent dispatch: ${agentSlug}`).slice(0, 500);
525
- db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source) VALUES (?, ?, ?, 'running', ?, ?, 'adhoc')`).run(data.toolCallId, agentSlug, description, item.sourceChannel || null, sessionKey);
547
+ const prompt = typeof spawnArgs?.prompt === "string" ? spawnArgs.prompt : null;
548
+ db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, prompt, status, origin_channel, session_key, source)
549
+ VALUES (?, ?, ?, ?, 'running', ?, ?, 'adhoc')`).run(data.toolCallId, agentSlug, description, prompt, item.sourceChannel || null, sessionKey);
550
+ if (prompt) {
551
+ db.prepare(`UPDATE agent_tasks SET prompt = COALESCE(prompt, ?) WHERE task_id = ?`).run(prompt, data.toolCallId);
552
+ }
526
553
  activeSubagentTaskIds.add(data.toolCallId);
527
554
  void agentEventBus.emit({
528
555
  type: "session:created",
@@ -662,8 +689,22 @@ async function executeOnSession(manager, item) {
662
689
  });
663
690
  });
664
691
  try {
665
- const result = await session.sendAndWait({ prompt: item.prompt, ...(item.attachments && item.attachments.length > 0 ? { attachments: item.attachments } : {}) }, ORCHESTRATOR_TIMEOUT_MS);
666
- const finalContent = result?.data?.content || accumulated || "(No response)";
692
+ if (warningBlock) {
693
+ accumulated = warningBlock;
694
+ item.callback(accumulated, false, item.turnId);
695
+ emitTurnEvent(sessionKey, {
696
+ type: "turn:delta",
697
+ turnId: item.turnId,
698
+ sessionKey,
699
+ part: { type: "text", text: warningBlock },
700
+ });
701
+ }
702
+ const result = await session.sendAndWait({ prompt: sessionPrompt, ...(item.attachments && item.attachments.length > 0 ? { attachments: item.attachments } : {}) }, ORCHESTRATOR_TIMEOUT_MS);
703
+ const streamedContent = warningBlock && accumulated.startsWith(warningBlock)
704
+ ? accumulated.slice(warningBlock.length)
705
+ : accumulated;
706
+ const responseContent = result?.data?.content || streamedContent || "(No response)";
707
+ const finalContent = warningBlock ? `${warningBlock}${responseContent}` : responseContent;
667
708
  return finalContent;
668
709
  }
669
710
  catch (err) {
@@ -743,6 +784,30 @@ async function processItem(item, manager) {
743
784
  lastRouteResult = routeResult;
744
785
  return executeOnSession(manager, item);
745
786
  }
787
+ function getActiveProjectRules(prompt, projectPath) {
788
+ const registry = loadRegistry();
789
+ const project = resolveProject(prompt, { projectPath }, registry);
790
+ if (!project) {
791
+ return null;
792
+ }
793
+ try {
794
+ const rules = loadProjectRules(project.slug);
795
+ if (!rules.found) {
796
+ return null;
797
+ }
798
+ return { project, rules };
799
+ }
800
+ catch (err) {
801
+ if (err instanceof ActiveProjectRulesLoadError || err instanceof Error) {
802
+ log.warn({
803
+ slug: err instanceof ActiveProjectRulesLoadError ? err.slug : project.slug,
804
+ err: err.message,
805
+ }, "Project rules could not be loaded; continuing without injection");
806
+ return null;
807
+ }
808
+ throw err;
809
+ }
810
+ }
746
811
  function isRecoverableError(err) {
747
812
  const msg = err instanceof Error ? err.message : String(err);
748
813
  if (/timeout/i.test(msg))
@@ -789,6 +854,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
789
854
  const finalContent = await new Promise((resolve, reject) => {
790
855
  manager.enqueue({
791
856
  prompt: taggedPrompt,
857
+ projectPath: source.type === "web" ? source.projectPath : undefined,
792
858
  attachments,
793
859
  callback,
794
860
  // Cast: QueuedMessage.onActivity uses a wide event type to avoid circular
@@ -896,6 +962,7 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
896
962
  const finalContent = await new Promise((resolve, reject) => {
897
963
  manager.enqueue({
898
964
  prompt: taggedPrompt,
965
+ projectPath: source.type === "web" ? source.projectPath : undefined,
899
966
  attachments,
900
967
  callback,
901
968
  onActivity: onActivity,
@@ -100,6 +100,9 @@ async function loadOrchestratorModule(t, overrides = {}) {
100
100
  ],
101
101
  sendResult: "Finished successfully",
102
102
  taskEvents: new Map(),
103
+ projectRegistry: {},
104
+ resolveProjectArgs: [],
105
+ loadProjectRulesArgs: [],
103
106
  ...overrides,
104
107
  };
105
108
  const client = createFakeClient(state);
@@ -191,6 +194,25 @@ async function loadOrchestratorModule(t, overrides = {}) {
191
194
  getWikiSummary: () => "wiki summary",
192
195
  },
193
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
+ });
194
216
  t.mock.module("../paths.js", {
195
217
  namedExports: {
196
218
  SESSIONS_DIR: "/sessions",
@@ -246,9 +268,73 @@ async function loadOrchestratorModule(t, overrides = {}) {
246
268
  state.healthCheckIntervalMs = delayMs;
247
269
  return { ref() { }, unref() { } };
248
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
+ });
249
283
  const orchestrator = await import(new URL(`./orchestrator.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
250
284
  return { orchestrator, state, client };
251
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
+ }
252
338
  function captureSessionEvents(t, sessionKey) {
253
339
  const events = [];
254
340
  const seenTurnIds = new Set();
@@ -330,6 +416,222 @@ test("sendToOrchestrator logs both sides, remembers web auth context, and record
330
416
  ]);
331
417
  assert.equal(state.episodeWrites, 1);
332
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
+ });
333
635
  test("@mentions route through the orchestrator session without invoking the model router", async (t) => {
334
636
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
335
637
  config: {
@@ -678,10 +980,18 @@ test("#81: tool.execution_start stash + subagent.started → agent_tasks uses sp
678
980
  await new Promise((resolve) => setTimeout(resolve, 10));
679
981
  assert.ok(state.lastSession, "FakeSession must have been created");
680
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");
681
987
  state.lastSession.emit("tool.execution_start", {
682
988
  toolName: "task",
683
989
  toolCallId: "tc-spawn-1",
684
- arguments: { name: "kaylee", description: "🔧 Kaylee: test spawn" },
990
+ arguments: {
991
+ name: "kaylee",
992
+ description: "🔧 Kaylee: test spawn",
993
+ prompt: promptText,
994
+ },
685
995
  });
686
996
  // Step 2: emit subagent.started with the same toolCallId — SDK only knows agent_type details
687
997
  state.lastSession.emit("subagent.started", {
@@ -695,6 +1005,7 @@ test("#81: tool.execution_start stash + subagent.started → agent_tasks uses sp
695
1005
  const argsJson = JSON.stringify(insertWrite.args);
696
1006
  assert.ok(argsJson.includes("kaylee"), `agent_slug must be "kaylee" but got: ${argsJson}`);
697
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}`);
698
1009
  assert.ok(!argsJson.includes("general-purpose"), `agent_slug must NOT fall back to "general-purpose" when spawn name is available`);
699
1010
  state.pendingReject?.(new Error("test teardown"));
700
1011
  });
@@ -882,6 +1193,31 @@ test("#98: interruptCurrentTurn aborts active turn and starts replacement turn",
882
1193
  state.pendingReject?.(new Error("aborted"));
883
1194
  await new Promise((resolve) => setTimeout(resolve, 50));
884
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
+ });
885
1221
  test("#98: interruptCurrentTurn falls back to normal send when no session is active", async (t) => {
886
1222
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
887
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