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.
- 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-Ch4AYrQP.js} +72 -69
- package/web/dist/assets/index-Ch4AYrQP.js.map +1 -0
- package/web/dist/assets/index-D__tBB0X.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
|
@@ -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
|
-
|
|
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
|
-
|
|
666
|
-
|
|
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: {
|
|
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
|