chapterhouse 0.5.1 → 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.
- package/.pr-types.json +14 -0
- package/README.md +6 -0
- package/dist/api/server.js +5 -3
- package/dist/cli.js +4 -2
- package/dist/config.js +75 -13
- package/dist/config.test.js +73 -0
- package/dist/copilot/memory-coordinator.js +234 -0
- package/dist/copilot/memory-coordinator.test.js +257 -0
- package/dist/copilot/orchestrator.js +31 -212
- package/dist/copilot/orchestrator.test.js +111 -0
- package/dist/copilot/pr-title.js +92 -0
- package/dist/copilot/pr-title.test.js +54 -0
- package/dist/copilot/router.js +43 -8
- package/dist/copilot/router.test.js +60 -18
- package/dist/copilot/threat-model.js +50 -0
- package/dist/copilot/threat-model.test.js +129 -0
- package/dist/copilot/tools.js +65 -39
- package/dist/copilot/tools.wiki.test.js +15 -6
- package/dist/daemon.js +7 -2
- package/dist/integrations/team-push.js +8 -1
- package/dist/integrations/teams-notify.js +8 -1
- package/dist/memory/housekeeping.js +73 -25
- package/dist/memory/housekeeping.test.js +95 -3
- package/dist/memory/inbox.test.js +178 -0
- package/dist/memory/tiering.test.js +323 -0
- package/dist/mode-context.js +28 -0
- package/dist/mode-context.test.js +42 -0
- package/dist/setup.js +162 -95
- package/dist/setup.test.js +139 -0
- package/dist/sprint-merge.js +168 -0
- package/dist/sprint-merge.test.js +131 -0
- package/dist/store/db.js +63 -0
- package/dist/store/db.test.js +279 -0
- package/dist/wiki/team-sync.js +8 -1
- package/package.json +6 -1
- package/web/dist/assets/{index-BfHqP3-C.js → index-B5oDsQ5y.js} +84 -84
- package/web/dist/assets/index-B5oDsQ5y.js.map +1 -0
- package/web/dist/assets/index-DknKAtDS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
- package/web/dist/assets/index-_O6AoWOS.css +0 -10
|
@@ -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
|
package/dist/copilot/router.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { getState, setState } from "../store/db.js";
|
|
2
|
+
import { config } from "../config.js";
|
|
2
3
|
import { classifyWithLLM } from "./classifier.js";
|
|
3
4
|
import { childLogger } from "../util/logger.js";
|
|
4
5
|
const log = childLogger("router");
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Default configuration
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
9
|
+
const STANDARD_MODEL = "claude-sonnet-4.6";
|
|
10
|
+
const PREMIUM_MODEL = "claude-opus-4.6";
|
|
8
11
|
const DEFAULT_CONFIG = {
|
|
9
|
-
enabled:
|
|
12
|
+
enabled: config.chapterhouseMode === "personal",
|
|
10
13
|
tierModels: {
|
|
11
14
|
fast: "gpt-4.1",
|
|
12
|
-
standard:
|
|
13
|
-
premium:
|
|
15
|
+
standard: STANDARD_MODEL,
|
|
16
|
+
premium: PREMIUM_MODEL,
|
|
14
17
|
},
|
|
15
18
|
overrides: [
|
|
16
19
|
{
|
|
@@ -19,11 +22,32 @@ const DEFAULT_CONFIG = {
|
|
|
19
22
|
"design", "ui", "ux", "css", "layout", "styling", "visual",
|
|
20
23
|
"mockup", "wireframe", "frontend design", "tailwind", "responsive",
|
|
21
24
|
],
|
|
22
|
-
model:
|
|
25
|
+
model: PREMIUM_MODEL,
|
|
23
26
|
},
|
|
24
27
|
],
|
|
25
28
|
cooldownMessages: 2,
|
|
26
29
|
};
|
|
30
|
+
function cloneRouterConfig(routerConfig) {
|
|
31
|
+
return {
|
|
32
|
+
...routerConfig,
|
|
33
|
+
tierModels: { ...routerConfig.tierModels },
|
|
34
|
+
overrides: routerConfig.overrides.map((rule) => ({
|
|
35
|
+
...rule,
|
|
36
|
+
keywords: [...rule.keywords],
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function getDefaultRouterConfig(hasStoredConfig) {
|
|
41
|
+
const defaults = cloneRouterConfig(DEFAULT_CONFIG);
|
|
42
|
+
if (config.chapterhouseMode === "personal" && !hasStoredConfig) {
|
|
43
|
+
defaults.tierModels.premium = defaults.tierModels.standard;
|
|
44
|
+
defaults.overrides = defaults.overrides.map((rule) => ({
|
|
45
|
+
...rule,
|
|
46
|
+
model: rule.model === PREMIUM_MODEL ? defaults.tierModels.standard : rule.model,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
return defaults;
|
|
50
|
+
}
|
|
27
51
|
// ---------------------------------------------------------------------------
|
|
28
52
|
// Module-level state
|
|
29
53
|
// ---------------------------------------------------------------------------
|
|
@@ -51,18 +75,29 @@ function wordMatch(text, keyword) {
|
|
|
51
75
|
// ---------------------------------------------------------------------------
|
|
52
76
|
export function getRouterConfig() {
|
|
53
77
|
const stored = getState("router_config");
|
|
78
|
+
const defaults = getDefaultRouterConfig(stored !== undefined);
|
|
54
79
|
if (stored) {
|
|
55
80
|
try {
|
|
56
|
-
|
|
81
|
+
const parsed = JSON.parse(stored);
|
|
82
|
+
return {
|
|
83
|
+
...defaults,
|
|
84
|
+
...parsed,
|
|
85
|
+
tierModels: {
|
|
86
|
+
...defaults.tierModels,
|
|
87
|
+
...(parsed.tierModels ?? {}),
|
|
88
|
+
},
|
|
89
|
+
overrides: parsed.overrides ?? defaults.overrides,
|
|
90
|
+
};
|
|
57
91
|
}
|
|
58
92
|
catch {
|
|
59
|
-
return
|
|
93
|
+
return defaults;
|
|
60
94
|
}
|
|
61
95
|
}
|
|
62
|
-
return
|
|
96
|
+
return defaults;
|
|
63
97
|
}
|
|
64
98
|
export function updateRouterConfig(partial) {
|
|
65
|
-
const
|
|
99
|
+
const hasStoredConfig = getState("router_config") !== undefined;
|
|
100
|
+
const current = hasStoredConfig ? getRouterConfig() : cloneRouterConfig(DEFAULT_CONFIG);
|
|
66
101
|
const merged = {
|
|
67
102
|
...current,
|
|
68
103
|
...partial,
|
|
@@ -13,6 +13,13 @@ async function loadRouterModule(t, options = {}) {
|
|
|
13
13
|
},
|
|
14
14
|
},
|
|
15
15
|
});
|
|
16
|
+
t.mock.module("../config.js", {
|
|
17
|
+
namedExports: {
|
|
18
|
+
config: {
|
|
19
|
+
chapterhouseMode: options.mode ?? "personal",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
16
23
|
t.mock.module("./classifier.js", {
|
|
17
24
|
namedExports: {
|
|
18
25
|
classifyWithLLM: async (_client, prompt) => await (options.classify?.(prompt) ?? null),
|
|
@@ -21,16 +28,15 @@ async function loadRouterModule(t, options = {}) {
|
|
|
21
28
|
const router = await import(new URL(`./router.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
22
29
|
return { router, state };
|
|
23
30
|
}
|
|
24
|
-
test("router config
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
});
|
|
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.
|
|
33
|
+
const { router } = await loadRouterModule(t, { mode: "personal" });
|
|
28
34
|
assert.deepEqual(router.getRouterConfig(), {
|
|
29
|
-
enabled:
|
|
35
|
+
enabled: true,
|
|
30
36
|
tierModels: {
|
|
31
37
|
fast: "gpt-4.1",
|
|
32
38
|
standard: "claude-sonnet-4.6",
|
|
33
|
-
premium: "claude-
|
|
39
|
+
premium: "claude-sonnet-4.6",
|
|
34
40
|
},
|
|
35
41
|
overrides: [
|
|
36
42
|
{
|
|
@@ -39,19 +45,29 @@ test("router config falls back safely and deep-merges tier model updates", async
|
|
|
39
45
|
"design", "ui", "ux", "css", "layout", "styling", "visual",
|
|
40
46
|
"mockup", "wireframe", "frontend design", "tailwind", "responsive",
|
|
41
47
|
],
|
|
42
|
-
model: "claude-
|
|
48
|
+
model: "claude-sonnet-4.6",
|
|
43
49
|
},
|
|
44
50
|
],
|
|
45
51
|
cooldownMessages: 2,
|
|
46
52
|
});
|
|
53
|
+
});
|
|
54
|
+
test("router config keeps auto-routing off by default in team mode", async (t) => {
|
|
55
|
+
const { router } = await loadRouterModule(t, { mode: "team" });
|
|
56
|
+
assert.equal(router.getRouterConfig().enabled, false);
|
|
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.
|
|
60
|
+
const { router, state } = await loadRouterModule(t, { mode: "personal" });
|
|
61
|
+
const saved = router.updateRouterConfig({ enabled: true });
|
|
62
|
+
assert.equal(saved.enabled, true);
|
|
63
|
+
assert.equal(saved.tierModels.premium, "claude-opus-4.6");
|
|
64
|
+
assert.equal(saved.overrides[0]?.model, "claude-opus-4.6");
|
|
47
65
|
const updated = router.updateRouterConfig({
|
|
48
|
-
enabled: true,
|
|
49
66
|
tierModels: {
|
|
50
|
-
...
|
|
67
|
+
...saved.tierModels,
|
|
51
68
|
premium: "gpt-5.5",
|
|
52
69
|
},
|
|
53
70
|
});
|
|
54
|
-
assert.equal(updated.enabled, true);
|
|
55
71
|
assert.deepEqual(updated.tierModels, {
|
|
56
72
|
fast: "gpt-4.1",
|
|
57
73
|
standard: "claude-sonnet-4.6",
|
|
@@ -60,7 +76,7 @@ test("router config falls back safely and deep-merges tier model updates", async
|
|
|
60
76
|
assert.equal(JSON.parse(state.get("router_config") || "{}").tierModels.premium, "gpt-5.5");
|
|
61
77
|
});
|
|
62
78
|
test("resolveModel stays in manual mode when the router is disabled", async (t) => {
|
|
63
|
-
const { router } = await loadRouterModule(t);
|
|
79
|
+
const { router } = await loadRouterModule(t, { mode: "team" });
|
|
64
80
|
const result = await router.resolveModel("Ship it", "claude-sonnet-4.6", []);
|
|
65
81
|
assert.deepEqual(result, {
|
|
66
82
|
model: "claude-sonnet-4.6",
|
|
@@ -69,28 +85,54 @@ test("resolveModel stays in manual mode when the router is disabled", async (t)
|
|
|
69
85
|
routerMode: "manual",
|
|
70
86
|
});
|
|
71
87
|
});
|
|
72
|
-
test("resolveModel applies overrides
|
|
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.
|
|
73
90
|
const { router } = await loadRouterModule(t, {
|
|
74
91
|
classify: async () => "fast",
|
|
75
92
|
});
|
|
76
|
-
router.updateRouterConfig({ enabled: true });
|
|
77
93
|
const override = await router.resolveModel("Need a UI refresh", "gpt-4.1", [], {});
|
|
78
94
|
const noOverride = await router.resolveModel("Fruit salad", "gpt-4.1", [], {});
|
|
79
95
|
assert.equal(override.overrideName, "design");
|
|
80
|
-
assert.equal(override.model, "claude-
|
|
96
|
+
assert.equal(override.model, "claude-sonnet-4.6");
|
|
81
97
|
assert.equal(override.switched, true);
|
|
82
98
|
assert.equal(noOverride.overrideName, undefined);
|
|
83
99
|
assert.equal(noOverride.model, "gpt-4.1");
|
|
84
100
|
assert.equal(noOverride.tier, "fast");
|
|
85
101
|
});
|
|
86
|
-
test("
|
|
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
|
+
});
|
|
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.
|
|
87
130
|
const { router } = await loadRouterModule(t);
|
|
88
|
-
router.updateRouterConfig({ enabled: true });
|
|
89
131
|
const result = await router.resolveModel("yes", "claude-sonnet-4.6", ["premium"]);
|
|
90
132
|
assert.deepEqual(result, {
|
|
91
|
-
model: "claude-
|
|
133
|
+
model: "claude-sonnet-4.6",
|
|
92
134
|
tier: "premium",
|
|
93
|
-
switched:
|
|
135
|
+
switched: false,
|
|
94
136
|
routerMode: "auto",
|
|
95
137
|
});
|
|
96
138
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const THREAT_MODEL_PATTERNS = [
|
|
2
|
+
/(^|[\/._-])auth([\/._-]|$)/i,
|
|
3
|
+
/credential/i,
|
|
4
|
+
/(auth|access|bearer|refresh|session|payment|api)[-_.]?token|token[-_.]?(auth|key|secret|refresh|access|session|payment)/i,
|
|
5
|
+
/billing/i,
|
|
6
|
+
/subscription/i,
|
|
7
|
+
/api[-_]?key/i,
|
|
8
|
+
/(^|[\/._-])tiers?([\/._-]|$)/i,
|
|
9
|
+
];
|
|
10
|
+
function normalizeChangedFiles(changedFiles) {
|
|
11
|
+
return changedFiles.map((file) => file.trim()).filter(Boolean);
|
|
12
|
+
}
|
|
13
|
+
export function findThreatModelMatches(changedFiles) {
|
|
14
|
+
return normalizeChangedFiles(changedFiles).filter((file) => THREAT_MODEL_PATTERNS.some((pattern) => pattern.test(file)));
|
|
15
|
+
}
|
|
16
|
+
export function hasThreatModelSection(prBody) {
|
|
17
|
+
const stripped = (prBody ?? "").replace(/<!--[\s\S]*?-->/g, "");
|
|
18
|
+
return /^##+\s+Threat Model\b/im.test(stripped);
|
|
19
|
+
}
|
|
20
|
+
export function evaluateThreatModelCheck(input) {
|
|
21
|
+
const matchedFiles = findThreatModelMatches(input.changedFiles);
|
|
22
|
+
if (matchedFiles.length === 0) {
|
|
23
|
+
return {
|
|
24
|
+
required: false,
|
|
25
|
+
valid: true,
|
|
26
|
+
matchedFiles: [],
|
|
27
|
+
message: "✅ No auth/credentials/billing file changes detected — a threat model section is not required.",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (hasThreatModelSection(input.prBody)) {
|
|
31
|
+
return {
|
|
32
|
+
required: true,
|
|
33
|
+
valid: true,
|
|
34
|
+
matchedFiles,
|
|
35
|
+
message: "✅ Threat model section present for auth/credentials/billing changes.",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
required: true,
|
|
40
|
+
valid: false,
|
|
41
|
+
matchedFiles,
|
|
42
|
+
message: [
|
|
43
|
+
"⚠️ This PR modifies auth/credentials/billing files. Add a '## Threat Model' section to the PR description explaining the security implications.",
|
|
44
|
+
"",
|
|
45
|
+
"Matched files:",
|
|
46
|
+
...matchedFiles.map((file) => `- ${file}`),
|
|
47
|
+
].join("\n"),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=threat-model.js.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
async function loadThreatModelModule() {
|
|
6
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
7
|
+
const moduleUrl = new URL(`./threat-model.js?case=${nonce}`, import.meta.url);
|
|
8
|
+
const module = await import(moduleUrl.href).catch(() => null);
|
|
9
|
+
assert.ok(module, "expected threat-model module to exist");
|
|
10
|
+
return module;
|
|
11
|
+
}
|
|
12
|
+
test("requires a threat model section when auth, credential, token, or billing files change", async () => {
|
|
13
|
+
const { evaluateThreatModelCheck } = await loadThreatModelModule();
|
|
14
|
+
const result = evaluateThreatModelCheck({
|
|
15
|
+
changedFiles: [
|
|
16
|
+
"src/api/auth.ts",
|
|
17
|
+
"src/auth/session-token.ts",
|
|
18
|
+
"docs/architecture.md",
|
|
19
|
+
"src/billing/subscription-plan.ts",
|
|
20
|
+
],
|
|
21
|
+
prBody: "## Summary\nAdds validation for token refresh.\n",
|
|
22
|
+
});
|
|
23
|
+
assert.equal(result.required, true);
|
|
24
|
+
assert.equal(result.valid, false);
|
|
25
|
+
assert.deepEqual(result.matchedFiles, [
|
|
26
|
+
"src/api/auth.ts",
|
|
27
|
+
"src/auth/session-token.ts",
|
|
28
|
+
"src/billing/subscription-plan.ts",
|
|
29
|
+
]);
|
|
30
|
+
assert.match(result.message, /This PR modifies auth\/credentials\/billing files/i);
|
|
31
|
+
assert.match(result.message, /## Threat Model/);
|
|
32
|
+
});
|
|
33
|
+
test("accepts PRs with matching files once the threat model section is present", async () => {
|
|
34
|
+
const { evaluateThreatModelCheck } = await loadThreatModelModule();
|
|
35
|
+
const result = evaluateThreatModelCheck({
|
|
36
|
+
changedFiles: ["src/copilot/auth.ts", "src/config.ts"],
|
|
37
|
+
prBody: [
|
|
38
|
+
"## Summary",
|
|
39
|
+
"Hardens auth bootstrap behavior.",
|
|
40
|
+
"",
|
|
41
|
+
"## Threat Model",
|
|
42
|
+
"Tokens stay in-memory only; no new credentials are persisted.",
|
|
43
|
+
].join("\n"),
|
|
44
|
+
});
|
|
45
|
+
assert.equal(result.required, true);
|
|
46
|
+
assert.equal(result.valid, true);
|
|
47
|
+
assert.deepEqual(result.matchedFiles, ["src/copilot/auth.ts"]);
|
|
48
|
+
});
|
|
49
|
+
test("skips the requirement when changed files are outside auth, credentials, and billing", async () => {
|
|
50
|
+
const { evaluateThreatModelCheck } = await loadThreatModelModule();
|
|
51
|
+
const result = evaluateThreatModelCheck({
|
|
52
|
+
changedFiles: ["src/wiki/index.ts", "web/src/routes/Chat.tsx"],
|
|
53
|
+
prBody: "## Summary\nUI cleanup only.\n",
|
|
54
|
+
});
|
|
55
|
+
assert.equal(result.required, false);
|
|
56
|
+
assert.equal(result.valid, true);
|
|
57
|
+
assert.deepEqual(result.matchedFiles, []);
|
|
58
|
+
assert.match(result.message, /not required/i);
|
|
59
|
+
});
|
|
60
|
+
test("does NOT flag lexer or NLP files with 'token' in the name (false-positive guard)", async () => {
|
|
61
|
+
const { evaluateThreatModelCheck } = await loadThreatModelModule();
|
|
62
|
+
const result = evaluateThreatModelCheck({
|
|
63
|
+
changedFiles: [
|
|
64
|
+
"src/parser/tokenizer.ts",
|
|
65
|
+
"src/nlp/token-embeddings.ts",
|
|
66
|
+
"src/utils/tokenize.ts",
|
|
67
|
+
"src/copilot/token-cache.ts",
|
|
68
|
+
"web/src/components/TokenDisplay.tsx",
|
|
69
|
+
],
|
|
70
|
+
prBody: "## Summary\nRefactors NLP pipeline.\n",
|
|
71
|
+
});
|
|
72
|
+
assert.equal(result.required, false);
|
|
73
|
+
assert.equal(result.valid, true);
|
|
74
|
+
assert.deepEqual(result.matchedFiles, []);
|
|
75
|
+
assert.match(result.message, /not required/i);
|
|
76
|
+
});
|
|
77
|
+
test("correctly flags security-relevant token files (auth-token, session-token, payment-token)", async () => {
|
|
78
|
+
const { evaluateThreatModelCheck } = await loadThreatModelModule();
|
|
79
|
+
const securityFiles = [
|
|
80
|
+
"src/auth/session-token.ts",
|
|
81
|
+
"src/billing/payment-token.ts",
|
|
82
|
+
"src/credentials/access-token-store.ts",
|
|
83
|
+
"src/api/bearer-token-middleware.ts",
|
|
84
|
+
"src/copilot/refresh-token.ts",
|
|
85
|
+
"src/auth/token-secret.ts",
|
|
86
|
+
];
|
|
87
|
+
for (const file of securityFiles) {
|
|
88
|
+
const result = evaluateThreatModelCheck({
|
|
89
|
+
changedFiles: [file],
|
|
90
|
+
prBody: "## Summary\nNo threat model section.\n",
|
|
91
|
+
});
|
|
92
|
+
assert.equal(result.required, true, `Expected ${file} to trigger threat-model gate`);
|
|
93
|
+
assert.deepEqual(result.matchedFiles, [file]);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
test("hasThreatModelSection returns false when the section exists only inside an HTML comment (template placeholder)", async () => {
|
|
97
|
+
const { evaluateThreatModelCheck } = await loadThreatModelModule();
|
|
98
|
+
// Simulates a PR body where the author left the default template untouched —
|
|
99
|
+
// the Threat Model heading is present only inside an HTML comment block.
|
|
100
|
+
const templatePlaceholderBody = [
|
|
101
|
+
"## Summary",
|
|
102
|
+
"Adds a new auth flow.",
|
|
103
|
+
"",
|
|
104
|
+
"<!--",
|
|
105
|
+
"## Threat Model",
|
|
106
|
+
"",
|
|
107
|
+
"If this PR touches auth, credentials, API keys, billing tiers, or subscription logic — fill in the Threat Model section.",
|
|
108
|
+
"Otherwise delete it.",
|
|
109
|
+
"",
|
|
110
|
+
"- Risk:",
|
|
111
|
+
"- Mitigations:",
|
|
112
|
+
"- Reviewer focus:",
|
|
113
|
+
"-->",
|
|
114
|
+
].join("\n");
|
|
115
|
+
const result = evaluateThreatModelCheck({
|
|
116
|
+
changedFiles: ["src/auth/session-token.ts"],
|
|
117
|
+
prBody: templatePlaceholderBody,
|
|
118
|
+
});
|
|
119
|
+
assert.equal(result.required, true);
|
|
120
|
+
assert.equal(result.valid, false, "A Threat Model section inside an HTML comment must NOT satisfy the gate");
|
|
121
|
+
assert.match(result.message, /## Threat Model/);
|
|
122
|
+
});
|
|
123
|
+
test("PR template reminds authors about the threat model section", () => {
|
|
124
|
+
const template = readFileSync(join(process.cwd(), ".github", "PULL_REQUEST_TEMPLATE.md"), "utf8");
|
|
125
|
+
assert.match(template, /Threat Model/);
|
|
126
|
+
assert.match(template, /If this PR touches auth, credentials, API keys, billing tiers, or subscription logic/i);
|
|
127
|
+
assert.match(template, /Otherwise delete it/i);
|
|
128
|
+
});
|
|
129
|
+
//# sourceMappingURL=threat-model.test.js.map
|