chapterhouse 0.5.2 → 0.6.0

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.
@@ -3,13 +3,9 @@ import { randomUUID } from "node:crypto";
3
3
  import { approveAll } from "@github/copilot-sdk";
4
4
  import { createTools } from "./tools.js";
5
5
  import { getOrchestratorSystemMessage } from "./system-message.js";
6
- import { renderHotTierForActiveScope } from "../memory/hot-tier.js";
7
- import { getHotTierEntries, renderHotTierXML } from "../memory/hot-tier.js";
8
6
  import { getActiveScope, withActiveScope } from "../memory/active-scope.js";
9
7
  import { getScope } from "../memory/scopes.js";
10
- import { CheckpointTracker, isCheckpointInFlight, runCheckpointExtraction } from "../memory/checkpoint.js";
11
- import { isHousekeepingInFlight, runHousekeeping } from "../memory/housekeeping.js";
12
- import { runEndOfTaskMemoryHook } from "../memory/eot.js";
8
+ import { MemoryCoordinator } from "./memory-coordinator.js";
13
9
  import { CHAPTERHOUSE_VERSION } from "../version.js";
14
10
  import { config, DEFAULT_MODEL } from "../config.js";
15
11
  import { loadMcpConfig } from "./mcp-config.js";
@@ -77,157 +73,15 @@ let currentUserContext;
77
73
  let currentAuthenticatedUser;
78
74
  let currentAuthorizationHeader;
79
75
  let lastRouteResult;
80
- const checkpointTrackers = new Map();
81
- const checkpointTurnsBySession = new Map();
82
- const housekeepingTurnsBySession = new Map();
83
- const MAX_CHECKPOINT_CHARS_PER_SIDE = 4_000;
76
+ let memoryCoordinator;
84
77
  export function getLastRouteResult() {
85
78
  return lastRouteResult;
86
79
  }
87
- function truncateCheckpointText(value) {
88
- const trimmed = value.trim();
89
- if (trimmed.length <= MAX_CHECKPOINT_CHARS_PER_SIDE) {
90
- return trimmed;
91
- }
92
- return `${trimmed.slice(0, MAX_CHECKPOINT_CHARS_PER_SIDE)}…`;
93
- }
94
- function getCheckpointTracker(sessionKey) {
95
- let tracker = checkpointTrackers.get(sessionKey);
96
- if (!tracker) {
97
- tracker = new CheckpointTracker();
98
- checkpointTrackers.set(sessionKey, tracker);
99
- }
100
- return tracker;
101
- }
102
80
  export function resetCheckpointSessionState(sessionKey) {
103
- getCheckpointTracker(sessionKey).reset();
104
- checkpointTurnsBySession.delete(sessionKey);
105
- housekeepingTurnsBySession.delete(sessionKey);
106
- }
107
- function appendCheckpointTurn(sessionKey, turn) {
108
- const turns = checkpointTurnsBySession.get(sessionKey) ?? [];
109
- turns.push(turn);
110
- const overflow = turns.length - config.memoryCheckpointTurns;
111
- if (overflow > 0) {
112
- turns.splice(0, overflow);
113
- }
114
- checkpointTurnsBySession.set(sessionKey, turns);
115
- return turns;
116
- }
117
- function scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source) {
118
- if (source.type === "background") {
119
- return;
120
- }
121
- const tracker = getCheckpointTracker(sessionKey);
122
- const turns = appendCheckpointTurn(sessionKey, {
123
- user: truncateCheckpointText(prompt),
124
- assistant: truncateCheckpointText(finalContent),
125
- });
126
- if (!config.memoryCheckpointEnabled) {
127
- log.info({ sessionKey }, "memory.checkpoint.disabled");
128
- return;
129
- }
130
- tracker.tickOrchestratorTurn();
131
- if (!tracker.shouldFire()) {
132
- return;
133
- }
134
- tracker.markFired();
135
- if (isCheckpointInFlight(sessionKey)) {
136
- log.info({ sessionKey }, "memory.checkpoint.in_flight_skip");
137
- return;
138
- }
139
- if (!copilotClient) {
140
- log.error({ sessionKey }, "memory.checkpoint.error");
141
- return;
142
- }
143
- const activeScope = getMemoryScopeForSession(sessionKey);
144
- void runCheckpointExtraction({
145
- sessionKey,
146
- turns: turns.slice(-config.memoryCheckpointTurns),
147
- activeScope,
148
- copilotClient,
149
- trigger: "cadence",
150
- }).catch((error) => {
151
- log.error({ err: error, sessionKey }, "memory.checkpoint.error");
152
- });
153
- }
154
- function scheduleHousekeeping(sessionKey, source) {
155
- if (source.type === "background") {
156
- return;
157
- }
158
- if (!config.memoryHousekeepingEnabled) {
159
- log.info({ sessionKey }, "memory.housekeeping.disabled");
160
- return;
161
- }
162
- const turns = (housekeepingTurnsBySession.get(sessionKey) ?? 0) + 1;
163
- if (turns < config.memoryHousekeepingTurns) {
164
- housekeepingTurnsBySession.set(sessionKey, turns);
165
- return;
166
- }
167
- housekeepingTurnsBySession.set(sessionKey, 0);
168
- const activeScope = getMemoryScopeForSession(sessionKey);
169
- if (!activeScope) {
170
- log.info({ sessionKey }, "memory.housekeeping.no_active_scope");
171
- return;
172
- }
173
- const scopeIds = [activeScope.id];
174
- if (isHousekeepingInFlight(scopeIds)) {
175
- log.info({ sessionKey, scope_ids: scopeIds }, "memory.housekeeping.in_flight_skip");
176
- return;
177
- }
178
- void runHousekeeping({ scopeIds }).catch((error) => {
179
- log.error({ err: error, sessionKey, scope_ids: scopeIds }, "memory.housekeeping.error");
180
- });
81
+ memoryCoordinator?.reset(sessionKey);
181
82
  }
182
83
  export function maybeScheduleScopeChangeCheckpoint(sessionKey, previousScope, nextScope) {
183
- if (!previousScope) {
184
- return;
185
- }
186
- if (!config.memoryCheckpointOnScopeChange) {
187
- log.info({ sessionKey, scope: previousScope.slug }, "memory.checkpoint.scope_change_disabled");
188
- return;
189
- }
190
- const tracker = getCheckpointTracker(sessionKey);
191
- const turnsSinceLast = tracker.turnsSinceLastFire();
192
- if (turnsSinceLast < config.memoryCheckpointMinTurnsForScopeFire) {
193
- log.info({
194
- sessionKey,
195
- scope: previousScope.slug,
196
- turns_since_last: turnsSinceLast,
197
- min_required: config.memoryCheckpointMinTurnsForScopeFire,
198
- }, "memory.checkpoint.scope_change_skip");
199
- return;
200
- }
201
- if (isCheckpointInFlight(sessionKey)) {
202
- log.info({ sessionKey, trigger: "scope_change" }, "memory.checkpoint.in_flight_skip");
203
- return;
204
- }
205
- if (!copilotClient) {
206
- log.error({ sessionKey }, "memory.checkpoint.error");
207
- return;
208
- }
209
- const turns = checkpointTurnsBySession.get(sessionKey) ?? [];
210
- if (turns.length === 0) {
211
- log.info({
212
- sessionKey,
213
- scope: previousScope.slug,
214
- turns_since_last: turnsSinceLast,
215
- min_required: config.memoryCheckpointMinTurnsForScopeFire,
216
- }, "memory.checkpoint.scope_change_skip");
217
- return;
218
- }
219
- tracker.markScopeChangeFire();
220
- void runCheckpointExtraction({
221
- sessionKey,
222
- turns: turns.slice(-config.memoryCheckpointTurns),
223
- activeScope: previousScope,
224
- copilotClient,
225
- trigger: "scope_change",
226
- scopeChangeContext: {
227
- from: previousScope.slug,
228
- to: nextScope?.slug ?? "no active scope",
229
- },
230
- }).catch((error) => {
84
+ void memoryCoordinator?.onScopeChange(sessionKey, previousScope?.slug ?? "", nextScope?.slug ?? "").catch((error) => {
231
85
  log.error({ err: error, sessionKey }, "memory.checkpoint.error");
232
86
  });
233
87
  }
@@ -339,39 +193,11 @@ function getMemoryScopeForSession(sessionKey) {
339
193
  }
340
194
  return getActiveScope();
341
195
  }
342
- function buildScopedHotTierContext(scope) {
343
- if (!config.memoryInjectEnabled || !scope) {
344
- return undefined;
345
- }
346
- const hotTierXml = renderHotTierXML(getHotTierEntries(scope.id));
347
- return hotTierXml ? hotTierXml.trimEnd() : undefined;
348
- }
349
- function buildHotTierContext() {
350
- if (!config.memoryInjectEnabled) {
351
- return undefined;
352
- }
353
- const hotTierXml = renderHotTierForActiveScope();
354
- if (!hotTierXml) {
355
- return undefined;
356
- }
357
- return hotTierXml.trimEnd();
358
- }
359
- function buildPerTurnMemoryHooks(sessionKey) {
360
- if (!config.memoryInjectEnabled) {
361
- return undefined;
362
- }
363
- return {
364
- onUserPromptSubmitted: () => {
365
- const hotTierXml = buildScopedHotTierContext(getMemoryScopeForSession(sessionKey));
366
- return hotTierXml ? { additionalContext: hotTierXml } : undefined;
367
- },
368
- };
369
- }
370
- function getSystemMessageOptions(memorySummary) {
196
+ function getSystemMessageOptions(memorySummary, hotTierXml) {
371
197
  return {
372
198
  selfEditEnabled: config.selfEditEnabled,
373
199
  memorySummary: memorySummary || undefined,
374
- hotTierXml: buildHotTierContext(),
200
+ hotTierXml,
375
201
  agentRoster: buildAgentRoster(),
376
202
  userContext: currentUserContext,
377
203
  };
@@ -407,15 +233,9 @@ function updateRequestContext(source) {
407
233
  }
408
234
  }
409
235
  export function feedAgentResult(taskId, agentSlug, result) {
410
- if (copilotClient) {
411
- void runEndOfTaskMemoryHook({
412
- taskId,
413
- finalResult: result,
414
- copilotClient,
415
- }).catch((error) => {
416
- log.error({ err: error, taskId }, "memory.eot.error");
417
- });
418
- }
236
+ void memoryCoordinator?.onAgentTaskComplete(taskId, result).catch((error) => {
237
+ log.error({ err: error, taskId }, "memory.eot.error");
238
+ });
419
239
  const sessionKey = getTaskSessionKey(taskId) || "default";
420
240
  const agentTurnId = randomUUID();
421
241
  const agentDisplayName = getAgentRegistry().find((agent) => agent.slug === agentSlug)?.name ?? agentSlug;
@@ -509,13 +329,12 @@ async function createOrResumeSession(sessionKey, projectRoot) {
509
329
  let { tools, mcpServers, skillDirectories } = baseConfig;
510
330
  const isProjectSession = sessionKey.startsWith("project:");
511
331
  const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
512
- const agentScope = persistentAgent?.scope ? getScope(persistentAgent.scope) ?? null : null;
513
332
  const infiniteSessions = {
514
333
  enabled: true,
515
334
  backgroundCompactionThreshold: 0.80,
516
335
  bufferExhaustionThreshold: 0.95,
517
336
  };
518
- const memoryHooks = buildPerTurnMemoryHooks(sessionKey);
337
+ const memoryHooks = memoryCoordinator?.buildPerTurnHooks(sessionKey);
519
338
  let model = config.copilotModel;
520
339
  let systemMessageContent;
521
340
  let sessionMode = isProjectSession ? "project" : "default";
@@ -525,7 +344,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
525
344
  client: copilotClient,
526
345
  onAgentTaskComplete: feedAgentResult,
527
346
  })));
528
- const scopedHotTier = buildScopedHotTierContext(agentScope);
347
+ const scopedHotTier = (await memoryCoordinator?.buildHotTierContext(sessionKey)) || undefined;
529
348
  const channelNote = `You are in your persistent Chapterhouse channel (${sessionKey}). Your memory scope is ${persistentAgent.scope}.`;
530
349
  systemMessageContent = [
531
350
  composeAgentSystemMessage(persistentAgent),
@@ -536,8 +355,9 @@ async function createOrResumeSession(sessionKey, projectRoot) {
536
355
  }
537
356
  else {
538
357
  const memorySummary = getWikiSummary();
358
+ const hotTierXml = (await memoryCoordinator?.buildHotTierContext(sessionKey)) || undefined;
539
359
  systemMessageContent = getOrchestratorSystemMessage({
540
- ...getSystemMessageOptions(memorySummary),
360
+ ...getSystemMessageOptions(memorySummary, hotTierXml),
541
361
  version: CHAPTERHOUSE_VERSION,
542
362
  });
543
363
  }
@@ -559,7 +379,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
559
379
  infiniteSessions,
560
380
  });
561
381
  log.info({ sessionKey }, "Session resumed successfully");
562
- resetCheckpointSessionState(sessionKey);
382
+ memoryCoordinator?.reset(sessionKey);
563
383
  upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
564
384
  const mgr = registry?.get(sessionKey);
565
385
  if (mgr)
@@ -586,7 +406,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
586
406
  infiniteSessions,
587
407
  });
588
408
  log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
589
- resetCheckpointSessionState(sessionKey);
409
+ memoryCoordinator?.reset(sessionKey);
590
410
  upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
591
411
  if (sessionKey === "default")
592
412
  setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
@@ -597,6 +417,12 @@ async function createOrResumeSession(sessionKey, projectRoot) {
597
417
  }
598
418
  export async function initOrchestrator(client) {
599
419
  copilotClient = client;
420
+ memoryCoordinator?.shutdown();
421
+ memoryCoordinator = new MemoryCoordinator({
422
+ getCopilotClient: () => copilotClient,
423
+ config,
424
+ resolveScopeForSession: getMemoryScopeForSession,
425
+ });
600
426
  // Initialize per-task ring buffer — subscribes to agentEventBus for session:tool_call events.
601
427
  initTaskEventLog();
602
428
  // (Re-)create the registry — supports multiple initOrchestrator calls in tests
@@ -893,12 +719,8 @@ async function executeOnSession(manager, item) {
893
719
  spawnArgsMap.delete(taskId);
894
720
  activeSubagentTaskIds.delete(taskId);
895
721
  db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(finalResult?.slice(0, 10000) ?? null, taskId);
896
- if (copilotClient && finalResult) {
897
- void runEndOfTaskMemoryHook({
898
- taskId,
899
- finalResult,
900
- copilotClient,
901
- }).catch((error) => {
722
+ if (finalResult) {
723
+ void memoryCoordinator?.onAgentTaskComplete(taskId, finalResult).catch((error) => {
902
724
  log.error({ err: error, taskId }, "memory.eot.error");
903
725
  });
904
726
  }
@@ -1249,8 +1071,9 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1249
1071
  logConversation("assistant", finalContent, logSource, sessionKey, { turnId });
1250
1072
  }
1251
1073
  catch { /* best-effort */ }
1252
- scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source);
1253
- scheduleHousekeeping(sessionKey, source);
1074
+ void memoryCoordinator?.onTurnComplete(sessionKey, prompt, finalContent, source.type).catch((error) => {
1075
+ log.error({ err: error, sessionKey }, "memory.turn_complete.error");
1076
+ });
1254
1077
  if (copilotClient) {
1255
1078
  maybeWriteEpisode(copilotClient).catch((err) => {
1256
1079
  log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
@@ -1350,8 +1173,9 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
1350
1173
  logConversation("assistant", finalContent, sourceLabel, sessionKey, { turnId });
1351
1174
  }
1352
1175
  catch { /* best-effort */ }
1353
- scheduleCheckpointExtraction(sessionKey, newPrompt, finalContent, source);
1354
- scheduleHousekeeping(sessionKey, source);
1176
+ void memoryCoordinator?.onTurnComplete(sessionKey, newPrompt, finalContent, source.type).catch((error) => {
1177
+ log.error({ err: error, sessionKey }, "memory.turn_complete.error");
1178
+ });
1355
1179
  if (copilotClient) {
1356
1180
  maybeWriteEpisode(copilotClient).catch((err) => {
1357
1181
  log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
@@ -1491,15 +1315,13 @@ export function getAgentInfo() {
1491
1315
  }
1492
1316
  /** Clean up on shutdown/restart. */
1493
1317
  export async function shutdownAgents() {
1318
+ memoryCoordinator?.shutdown();
1319
+ memoryCoordinator = undefined;
1494
1320
  if (!registry) {
1495
- checkpointTrackers.clear();
1496
- checkpointTurnsBySession.clear();
1497
1321
  await clearActiveTasks();
1498
1322
  return;
1499
1323
  }
1500
1324
  await registry.shutdown();
1501
- checkpointTrackers.clear();
1502
- checkpointTurnsBySession.clear();
1503
1325
  await clearActiveTasks();
1504
1326
  }
1505
1327
  //# sourceMappingURL=orchestrator.js.map
@@ -158,6 +158,117 @@ async function loadOrchestratorModule(t, overrides = {}) {
158
158
  DEFAULT_MODEL: "fallback-model",
159
159
  },
160
160
  });
161
+ t.mock.module("./memory-coordinator.js", {
162
+ namedExports: {
163
+ MemoryCoordinator: class {
164
+ checkpointTrackers = new Map();
165
+ checkpointTurnsBySession = new Map();
166
+ housekeepingTurnsBySession = new Map();
167
+ constructor(_options) { }
168
+ getCheckpointTracker(sessionKey) {
169
+ let tracker = this.checkpointTrackers.get(sessionKey);
170
+ if (!tracker) {
171
+ tracker = { turns: 0 };
172
+ this.checkpointTrackers.set(sessionKey, tracker);
173
+ }
174
+ return tracker;
175
+ }
176
+ async onTurnComplete(sessionKey, prompt, response, source) {
177
+ if (source === "background") {
178
+ return;
179
+ }
180
+ const tracker = this.getCheckpointTracker(sessionKey);
181
+ state.checkpointTickCalls++;
182
+ tracker.turns++;
183
+ const turns = this.checkpointTurnsBySession.get(sessionKey) ?? [];
184
+ turns.push({ user: prompt.trim(), assistant: response.trim() });
185
+ this.checkpointTurnsBySession.set(sessionKey, turns);
186
+ if (state.config.memoryCheckpointEnabled !== false && tracker.turns >= state.checkpointShouldFireAfter && !state.checkpointInFlight) {
187
+ state.checkpointMarkFiredCalls++;
188
+ tracker.turns = 0;
189
+ state.checkpointRuns.push({
190
+ sessionKey,
191
+ turns: turns.slice(-5),
192
+ activeScope: state.activeScope ?? null,
193
+ trigger: "cadence",
194
+ });
195
+ }
196
+ if (state.config.memoryHousekeepingEnabled === false) {
197
+ return;
198
+ }
199
+ const count = (this.housekeepingTurnsBySession.get(sessionKey) ?? 0) + 1;
200
+ const cadence = state.config.memoryHousekeepingTurns ?? 50;
201
+ if (count < cadence) {
202
+ this.housekeepingTurnsBySession.set(sessionKey, count);
203
+ return;
204
+ }
205
+ this.housekeepingTurnsBySession.set(sessionKey, 0);
206
+ if (!state.activeScope || state.housekeepingInFlight) {
207
+ return;
208
+ }
209
+ state.housekeepingRuns.push({ scopeIds: [state.activeScope.id] });
210
+ }
211
+ async onScopeChange(sessionKey, prev, next) {
212
+ if (!prev || state.config.memoryCheckpointOnScopeChange === false || state.checkpointInFlight) {
213
+ return;
214
+ }
215
+ const tracker = this.getCheckpointTracker(sessionKey);
216
+ if (tracker.turns < (state.config.memoryCheckpointMinTurnsForScopeFire ?? 2)) {
217
+ return;
218
+ }
219
+ const turns = this.checkpointTurnsBySession.get(sessionKey) ?? [];
220
+ if (turns.length === 0) {
221
+ return;
222
+ }
223
+ state.checkpointMarkScopeChangeFireCalls++;
224
+ tracker.turns = 0;
225
+ state.checkpointRuns.push({
226
+ sessionKey,
227
+ turns: turns.slice(-5),
228
+ activeScope: { slug: prev },
229
+ trigger: "scope_change",
230
+ scopeChangeContext: { from: prev, to: next || "no active scope" },
231
+ });
232
+ }
233
+ async buildHotTierContext(sessionKey) {
234
+ if (state.config.memoryInjectEnabled === false) {
235
+ return "";
236
+ }
237
+ if (sessionKey.startsWith("agent:")) {
238
+ const agent = state.registry.find((entry) => `agent:${entry.slug}` === sessionKey);
239
+ if (agent?.scope) {
240
+ return (state.hotTierByScope?.get(agent.scope) ?? "").trimEnd();
241
+ }
242
+ }
243
+ const xml = state.hotTierXml ?? state.hotTierByScope?.get(state.activeScope?.slug ?? "") ?? "";
244
+ return xml.trimEnd();
245
+ }
246
+ buildPerTurnHooks(sessionKey) {
247
+ if (state.config.memoryInjectEnabled === false) {
248
+ return undefined;
249
+ }
250
+ return {
251
+ onUserPromptSubmitted: async () => {
252
+ const additionalContext = await this.buildHotTierContext(sessionKey);
253
+ return additionalContext ? { additionalContext } : undefined;
254
+ },
255
+ };
256
+ }
257
+ async onAgentTaskComplete(_taskId, _result) { }
258
+ reset(sessionKey) {
259
+ state.checkpointResetCalls++;
260
+ this.getCheckpointTracker(sessionKey).turns = 0;
261
+ this.checkpointTurnsBySession.delete(sessionKey);
262
+ this.housekeepingTurnsBySession.delete(sessionKey);
263
+ }
264
+ shutdown() {
265
+ this.checkpointTrackers.clear();
266
+ this.checkpointTurnsBySession.clear();
267
+ this.housekeepingTurnsBySession.clear();
268
+ }
269
+ },
270
+ },
271
+ });
161
272
  t.mock.module("../memory/hot-tier.js", {
162
273
  namedExports: {
163
274
  renderHotTierForActiveScope: () => state.hotTierXml ?? "",
@@ -0,0 +1,92 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ const PR_TYPES_CONFIG_PATH = join(__dirname, "..", "..", ".pr-types.json");
6
+ function loadPrTitleTypes() {
7
+ const parsed = JSON.parse(readFileSync(PR_TYPES_CONFIG_PATH, "utf-8"));
8
+ if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== "string" || value.trim().length === 0)) {
9
+ throw new Error(`Invalid PR title types config at ${PR_TYPES_CONFIG_PATH}`);
10
+ }
11
+ return parsed;
12
+ }
13
+ const VALID_PR_TITLE_TYPES = loadPrTitleTypes();
14
+ const PR_TITLE_PATTERN = new RegExp(`^(?<type>${VALID_PR_TITLE_TYPES.join("|")})(?:\\((?<scope>[^()\\r\\n]+)\\))?: (?<description>.+)$`);
15
+ function examplesBlock() {
16
+ return [
17
+ "Examples:",
18
+ " Valid: feat: add user search",
19
+ " Valid: fix(auth): handle token expiry",
20
+ " Valid: test: memory tiering edge cases",
21
+ " Invalid: adding new thing",
22
+ " Invalid: WIP",
23
+ " Invalid: HOTFIX",
24
+ ].join("\n");
25
+ }
26
+ function allowedTypesLine() {
27
+ return `Allowed types: ${VALID_PR_TITLE_TYPES.join(", ")}.`;
28
+ }
29
+ function isAllCapsDescription(description) {
30
+ const lettersOnly = description.replace(/[^A-Za-z]/g, "");
31
+ return lettersOnly.length > 0 && lettersOnly === lettersOnly.toUpperCase();
32
+ }
33
+ export function explainPrTitleValidation(title) {
34
+ const normalizedTitle = title.trim();
35
+ if (!normalizedTitle) {
36
+ return {
37
+ valid: false,
38
+ message: [
39
+ "PR title is required.",
40
+ "Use conventional commit format: type(optional-scope): description",
41
+ allowedTypesLine(),
42
+ examplesBlock(),
43
+ ].join("\n"),
44
+ };
45
+ }
46
+ const match = PR_TITLE_PATTERN.exec(normalizedTitle);
47
+ if (!match?.groups) {
48
+ return {
49
+ valid: false,
50
+ message: [
51
+ `PR title \"${normalizedTitle}\" must match conventional commit format: type(optional-scope): description`,
52
+ allowedTypesLine(),
53
+ examplesBlock(),
54
+ ].join("\n"),
55
+ };
56
+ }
57
+ const description = match.groups.description.trim();
58
+ if (!description) {
59
+ return {
60
+ valid: false,
61
+ message: [
62
+ "PR title description must be non-empty.",
63
+ examplesBlock(),
64
+ ].join("\n"),
65
+ };
66
+ }
67
+ if (isAllCapsDescription(description)) {
68
+ return {
69
+ valid: false,
70
+ message: [
71
+ `PR title description must not be ALL CAPS: \"${description}\".`,
72
+ "Use a short, sentence-style description after the conventional type prefix.",
73
+ examplesBlock(),
74
+ ].join("\n"),
75
+ };
76
+ }
77
+ return {
78
+ valid: true,
79
+ message: `PR title is valid: ${normalizedTitle}`,
80
+ };
81
+ }
82
+ export function isValidPrTitle(title) {
83
+ return explainPrTitleValidation(title).valid;
84
+ }
85
+ export function assertValidPrTitle(title) {
86
+ const result = explainPrTitleValidation(title);
87
+ if (!result.valid) {
88
+ throw new Error(result.message);
89
+ }
90
+ }
91
+ export { VALID_PR_TITLE_TYPES };
92
+ //# sourceMappingURL=pr-title.js.map
@@ -0,0 +1,54 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import { explainPrTitleValidation, isValidPrTitle } from "./pr-title.js";
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const PR_TYPES_CONFIG_PATH = join(__dirname, "..", "..", ".pr-types.json");
9
+ test("accepts conventional PR titles with and without scopes", () => {
10
+ assert.equal(isValidPrTitle("feat: add user search"), true);
11
+ assert.equal(isValidPrTitle("fix(auth): handle token expiry"), true);
12
+ assert.equal(isValidPrTitle("test: memory tiering edge cases"), true);
13
+ assert.equal(isValidPrTitle("release: v1.2.3"), true);
14
+ });
15
+ test("rejects blank or malformed PR titles with clear guidance", () => {
16
+ const blank = explainPrTitleValidation(" ");
17
+ assert.equal(blank.valid, false);
18
+ assert.match(blank.message, /PR title is required/i);
19
+ assert.match(blank.message, /feat: add user search/);
20
+ const malformed = explainPrTitleValidation("adding new thing");
21
+ assert.equal(malformed.valid, false);
22
+ assert.match(malformed.message, /must match conventional commit format/i);
23
+ assert.match(malformed.message, /type\(optional-scope\): description/i);
24
+ });
25
+ test("rejects all-caps descriptions even when the prefix is valid", () => {
26
+ const result = explainPrTitleValidation("fix: HOTFIX");
27
+ assert.equal(result.valid, false);
28
+ assert.match(result.message, /description must not be all caps/i);
29
+ });
30
+ test("rejects unsupported types", () => {
31
+ const result = explainPrTitleValidation("hotfix: patch prod issue");
32
+ assert.equal(result.valid, false);
33
+ assert.match(result.message, /allowed types/i);
34
+ assert.match(result.message, /feat, fix, docs, style, refactor, perf, test, chore, build, ci, revert, release/);
35
+ });
36
+ test("loads PR title types from the shared config file", () => {
37
+ const raw = readFileSync(PR_TYPES_CONFIG_PATH, "utf-8");
38
+ const types = JSON.parse(raw);
39
+ assert.deepEqual(types, [
40
+ "feat",
41
+ "fix",
42
+ "docs",
43
+ "style",
44
+ "refactor",
45
+ "perf",
46
+ "test",
47
+ "chore",
48
+ "build",
49
+ "ci",
50
+ "revert",
51
+ "release",
52
+ ]);
53
+ });
54
+ //# sourceMappingURL=pr-title.test.js.map
@@ -29,6 +29,7 @@ async function loadRouterModule(t, options = {}) {
29
29
  return { router, state };
30
30
  }
31
31
  test("router config defaults personal-mode auto-routing to standard-cost routes before opt-in", async (t) => {
32
+ // Security/Billing: personal mode must not silently spend premium quota until the user explicitly opts in.
32
33
  const { router } = await loadRouterModule(t, { mode: "personal" });
33
34
  assert.deepEqual(router.getRouterConfig(), {
34
35
  enabled: true,
@@ -55,6 +56,7 @@ test("router config keeps auto-routing off by default in team mode", async (t) =
55
56
  assert.equal(router.getRouterConfig().enabled, false);
56
57
  });
57
58
  test("saving router config in personal mode opts into premium defaults and deep-merges tier model updates", async (t) => {
59
+ // Security/Billing: persisted router_config is the explicit consent boundary for premium model usage.
58
60
  const { router, state } = await loadRouterModule(t, { mode: "personal" });
59
61
  const saved = router.updateRouterConfig({ enabled: true });
60
62
  assert.equal(saved.enabled, true);
@@ -84,6 +86,7 @@ test("resolveModel stays in manual mode when the router is disabled", async (t)
84
86
  });
85
87
  });
86
88
  test("resolveModel applies safe design overrides before personal-mode opt-in and ignores partial-word matches", async (t) => {
89
+ // Security/Billing: premium-looking prompts like design work must still stay on standard-cost models until opt-in is stored.
87
90
  const { router } = await loadRouterModule(t, {
88
91
  classify: async () => "fast",
89
92
  });
@@ -96,7 +99,34 @@ test("resolveModel applies safe design overrides before personal-mode opt-in and
96
99
  assert.equal(noOverride.model, "gpt-4.1");
97
100
  assert.equal(noOverride.tier, "fast");
98
101
  });
102
+ test("stored router opt-in re-enables premium design overrides in personal mode", async (t) => {
103
+ // Security/Billing: once the user explicitly opts in, premium routing should activate so future regressions do not strand consented users on downgraded routing.
104
+ const { router } = await loadRouterModule(t, {
105
+ mode: "personal",
106
+ storedConfig: JSON.stringify({
107
+ enabled: true,
108
+ tierModels: {
109
+ fast: "gpt-4.1",
110
+ standard: "claude-sonnet-4.6",
111
+ premium: "claude-opus-4.6",
112
+ },
113
+ overrides: [
114
+ {
115
+ name: "design",
116
+ keywords: ["design", "ui"],
117
+ model: "claude-opus-4.6",
118
+ },
119
+ ],
120
+ cooldownMessages: 2,
121
+ }),
122
+ });
123
+ const result = await router.resolveModel("Need a UI refresh", "claude-sonnet-4.6", [], {});
124
+ assert.equal(result.overrideName, "design");
125
+ assert.equal(result.model, "claude-opus-4.6");
126
+ assert.equal(result.switched, true);
127
+ });
99
128
  test("short follow-ups inherit the previous tier without forcing premium before opt-in", async (t) => {
129
+ // Security/Billing: follow-up replies must not become a backdoor that silently upgrades personal-mode users to premium routing.
100
130
  const { router } = await loadRouterModule(t);
101
131
  const result = await router.resolveModel("yes", "claude-sonnet-4.6", ["premium"]);
102
132
  assert.deepEqual(result, {