chapterhouse 0.1.1

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.
Files changed (119) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +363 -0
  3. package/agents/chapterhouse.agent.md +40 -0
  4. package/agents/coder.agent.md +38 -0
  5. package/agents/designer.agent.md +43 -0
  6. package/agents/general-purpose.agent.md +30 -0
  7. package/dist/api/auth.js +159 -0
  8. package/dist/api/auth.test.js +463 -0
  9. package/dist/api/errors.js +95 -0
  10. package/dist/api/errors.test.js +89 -0
  11. package/dist/api/rate-limit.js +85 -0
  12. package/dist/api/server-runtime.js +62 -0
  13. package/dist/api/server.js +651 -0
  14. package/dist/api/server.test.js +385 -0
  15. package/dist/api/sse.integration.test.js +270 -0
  16. package/dist/api/sse.js +7 -0
  17. package/dist/api/team.js +196 -0
  18. package/dist/api/team.test.js +466 -0
  19. package/dist/cli.js +102 -0
  20. package/dist/config.js +299 -0
  21. package/dist/config.phase3.test.js +20 -0
  22. package/dist/config.test.js +148 -0
  23. package/dist/copilot/agents.js +447 -0
  24. package/dist/copilot/agents.squad.test.js +72 -0
  25. package/dist/copilot/classifier.js +72 -0
  26. package/dist/copilot/client.js +32 -0
  27. package/dist/copilot/client.test.js +100 -0
  28. package/dist/copilot/episode-writer.js +219 -0
  29. package/dist/copilot/episode-writer.test.js +41 -0
  30. package/dist/copilot/mcp-config.js +22 -0
  31. package/dist/copilot/okr-mapper.js +196 -0
  32. package/dist/copilot/okr-mapper.test.js +114 -0
  33. package/dist/copilot/orchestrator.js +685 -0
  34. package/dist/copilot/orchestrator.test.js +523 -0
  35. package/dist/copilot/router.js +142 -0
  36. package/dist/copilot/router.test.js +119 -0
  37. package/dist/copilot/skills.js +125 -0
  38. package/dist/copilot/standup.js +138 -0
  39. package/dist/copilot/standup.test.js +132 -0
  40. package/dist/copilot/system-message.js +143 -0
  41. package/dist/copilot/system-message.test.js +17 -0
  42. package/dist/copilot/tools.js +1212 -0
  43. package/dist/copilot/tools.okr.test.js +260 -0
  44. package/dist/copilot/tools.squad.test.js +168 -0
  45. package/dist/daemon.js +235 -0
  46. package/dist/home-path.js +12 -0
  47. package/dist/home-path.test.js +11 -0
  48. package/dist/integrations/ado-analytics.js +178 -0
  49. package/dist/integrations/ado-analytics.test.js +284 -0
  50. package/dist/integrations/ado-client.js +227 -0
  51. package/dist/integrations/ado-client.test.js +176 -0
  52. package/dist/integrations/ado-schema.js +25 -0
  53. package/dist/integrations/ado-schema.test.js +39 -0
  54. package/dist/integrations/ado-skill.js +55 -0
  55. package/dist/integrations/report-generator.js +114 -0
  56. package/dist/integrations/report-generator.test.js +62 -0
  57. package/dist/integrations/team-push.js +144 -0
  58. package/dist/integrations/team-push.test.js +178 -0
  59. package/dist/integrations/teams-notify.js +108 -0
  60. package/dist/integrations/teams-notify.test.js +135 -0
  61. package/dist/paths.js +41 -0
  62. package/dist/setup.js +149 -0
  63. package/dist/shutdown-signals.js +13 -0
  64. package/dist/shutdown-signals.test.js +33 -0
  65. package/dist/squad/charter.js +108 -0
  66. package/dist/squad/charter.test.js +89 -0
  67. package/dist/squad/context.js +48 -0
  68. package/dist/squad/context.test.js +59 -0
  69. package/dist/squad/discovery.js +280 -0
  70. package/dist/squad/discovery.test.js +93 -0
  71. package/dist/squad/index.js +7 -0
  72. package/dist/squad/mirror.js +81 -0
  73. package/dist/squad/mirror.scheduler.js +78 -0
  74. package/dist/squad/mirror.scheduler.test.js +197 -0
  75. package/dist/squad/mirror.test.js +172 -0
  76. package/dist/squad/registry.js +162 -0
  77. package/dist/squad/registry.test.js +31 -0
  78. package/dist/squad/squad-coordinator-system-message.test.js +190 -0
  79. package/dist/squad/squad-session-routing.test.js +260 -0
  80. package/dist/squad/types.js +4 -0
  81. package/dist/status.js +25 -0
  82. package/dist/status.test.js +22 -0
  83. package/dist/store/db.js +290 -0
  84. package/dist/store/db.test.js +126 -0
  85. package/dist/store/squad-sessions.test.js +341 -0
  86. package/dist/test/setup-env.js +3 -0
  87. package/dist/update.js +112 -0
  88. package/dist/update.test.js +25 -0
  89. package/dist/wiki/context.js +138 -0
  90. package/dist/wiki/fs.js +195 -0
  91. package/dist/wiki/fs.test.js +39 -0
  92. package/dist/wiki/index-manager.js +359 -0
  93. package/dist/wiki/index-manager.test.js +129 -0
  94. package/dist/wiki/lock.js +26 -0
  95. package/dist/wiki/lock.test.js +30 -0
  96. package/dist/wiki/log-manager.js +20 -0
  97. package/dist/wiki/migrate.js +306 -0
  98. package/dist/wiki/okr.test.js +101 -0
  99. package/dist/wiki/path-utils.js +4 -0
  100. package/dist/wiki/path-utils.test.js +8 -0
  101. package/dist/wiki/seed-team-wiki.js +296 -0
  102. package/dist/wiki/seed-team-wiki.test.js +69 -0
  103. package/dist/wiki/team-sync.js +212 -0
  104. package/dist/wiki/team-sync.test.js +185 -0
  105. package/dist/wiki/templates/okr.js +98 -0
  106. package/package.json +72 -0
  107. package/skills/.gitkeep +0 -0
  108. package/skills/find-skills/SKILL.md +161 -0
  109. package/skills/find-skills/_meta.json +4 -0
  110. package/skills/frontend-design/LICENSE.txt +177 -0
  111. package/skills/frontend-design/SKILL.md +42 -0
  112. package/skills/squad/SKILL.md +76 -0
  113. package/web/dist/assets/index-D-e7K-fT.css +10 -0
  114. package/web/dist/assets/index-DAg9IrpO.js +142 -0
  115. package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
  116. package/web/dist/chapterhouse-icon.png +0 -0
  117. package/web/dist/chapterhouse-icon.svg +42 -0
  118. package/web/dist/chapterhouse-logo.svg +46 -0
  119. package/web/dist/index.html +15 -0
package/dist/cli.js ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ function getVersion() {
7
+ try {
8
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
9
+ return pkg.version || "0.0.0";
10
+ }
11
+ catch {
12
+ return "0.0.0";
13
+ }
14
+ }
15
+ function printHelp() {
16
+ const version = getVersion();
17
+ console.log(`
18
+ chapterhouse v${version} — AI orchestrator powered by Copilot SDK
19
+
20
+ Usage:
21
+ chapterhouse <command>
22
+
23
+ Commands:
24
+ start Start the Chapterhouse daemon (web UI at http://localhost:7788)
25
+ setup Pick a default model and write ~/.chapterhouse/.env
26
+ update Check for updates and install the latest version
27
+ help Show this help message
28
+
29
+ Flags (start):
30
+ --self-edit Allow Chapterhouse to modify his own source code (off by default)
31
+ --open Open the web UI in your default browser once the daemon is ready
32
+
33
+ Examples:
34
+ chapterhouse start Start the daemon, then open http://localhost:7788
35
+ chapterhouse start --open Same, but open the browser for you
36
+ chapterhouse start --self-edit Start with self-edit enabled
37
+ `.trim());
38
+ }
39
+ const args = process.argv.slice(2);
40
+ const command = args[0] || "help";
41
+ switch (command) {
42
+ case "start": {
43
+ const startFlags = args.slice(1);
44
+ if (startFlags.includes("--self-edit")) {
45
+ process.env.CHAPTERHOUSE_SELF_EDIT = "1";
46
+ }
47
+ if (startFlags.includes("--open")) {
48
+ process.env.CHAPTERHOUSE_OPEN_BROWSER = "1";
49
+ }
50
+ await import("./daemon.js");
51
+ break;
52
+ }
53
+ case "setup":
54
+ await import("./setup.js");
55
+ break;
56
+ case "update": {
57
+ const { checkForUpdate, performUpdate } = await import("./update.js");
58
+ const check = await checkForUpdate();
59
+ if (!check.checkSucceeded) {
60
+ console.error("⚠ Could not reach GitHub to check for updates. Check your network and try again.");
61
+ process.exit(1);
62
+ }
63
+ if (!check.updateAvailable) {
64
+ console.log(`chapterhouse v${check.current} is already the latest version.`);
65
+ console.log("Pulling latest from main...");
66
+ const result = await performUpdate();
67
+ if (result.ok) {
68
+ console.log("✅ Updated to latest main.");
69
+ }
70
+ else {
71
+ console.error(`❌ Update failed: ${result.output}`);
72
+ process.exit(1);
73
+ }
74
+ break;
75
+ }
76
+ console.log(`Update available: v${check.current} → v${check.latest}`);
77
+ console.log("Installing...");
78
+ const result = await performUpdate(check.latest ? `v${check.latest}` : null);
79
+ if (result.ok) {
80
+ console.log(`✅ Updated to v${check.latest}`);
81
+ }
82
+ else {
83
+ console.error(`❌ Update failed: ${result.output}`);
84
+ process.exit(1);
85
+ }
86
+ break;
87
+ }
88
+ case "help":
89
+ case "--help":
90
+ case "-h":
91
+ printHelp();
92
+ break;
93
+ case "--version":
94
+ case "-v":
95
+ console.log(getVersion());
96
+ break;
97
+ default:
98
+ console.error(`Unknown command: ${command}\n`);
99
+ printHelp();
100
+ process.exit(1);
101
+ }
102
+ //# sourceMappingURL=cli.js.map
package/dist/config.js ADDED
@@ -0,0 +1,299 @@
1
+ import { config as loadEnv } from "dotenv";
2
+ import { z } from "zod";
3
+ import { existsSync, readFileSync, writeFileSync } from "fs";
4
+ import { API_TOKEN_PATH, ENV_PATH, ensureChapterhouseHome } from "./paths.js";
5
+ export const DISABLE_DOTENV_ENV_VAR = "CHAPTERHOUSE_DISABLE_DOTENV";
6
+ const DEFAULT_WORKER_TIMEOUT_MS = 600_000;
7
+ const BOOLEAN_ENV_PATTERN = /^(true|false)$/;
8
+ function loadRuntimeEnv() {
9
+ if (process.env[DISABLE_DOTENV_ENV_VAR] === "1") {
10
+ return;
11
+ }
12
+ loadEnv({ path: ENV_PATH });
13
+ if ((process.env.NODE_ENV?.trim() || "development") !== "production") {
14
+ loadEnv();
15
+ }
16
+ }
17
+ loadRuntimeEnv();
18
+ const configSchema = z.object({
19
+ NODE_ENV: z.string().optional(),
20
+ CHAPTERHOUSE_MODE: z.string().optional(),
21
+ API_HOST: z.string().optional(),
22
+ API_PORT: z.string().optional(),
23
+ STANDUP_TIME: z.string().optional(),
24
+ ADO_ORG: z.string().optional(),
25
+ ADO_PROJECT: z.string().optional(),
26
+ ADO_PAT: z.string().optional(),
27
+ COPILOT_MODEL: z.string().optional(),
28
+ WORKER_TIMEOUT: z.string().optional(),
29
+ ENTRA_TENANT_ID: z.string().optional(),
30
+ ENTRA_CLIENT_ID: z.string().optional(),
31
+ ENTRA_REQUIRED_ROLE: z.string().optional(),
32
+ ENTRA_TEAM_LEAD_ID: z.string().optional(),
33
+ ENTRA_AUTH_ENABLED: z.string().optional(),
34
+ CORS_ALLOWED_ORIGINS: z.string().optional(),
35
+ API_TOKEN: z.string().optional(),
36
+ TEAM_CHAPTERHOUSE_URL: z.string().optional(),
37
+ TEAM_CHAPTERHOUSE_TOKEN: z.string().optional(),
38
+ TEAM_WIKI_CACHE_TTL_MINUTES: z.string().optional(),
39
+ TEAM_WIKI_PATHS: z.string().optional(),
40
+ TEAMS_WEBHOOK_URL: z.string().optional(),
41
+ TEAMS_NOTIFICATIONS_ENABLED: z.string().optional(),
42
+ COPILOT_TOKEN: z.string().optional(),
43
+ GITHUB_TOKEN: z.string().optional(),
44
+ API_RATE_LIMIT_WINDOW_MS: z.string().optional(),
45
+ API_RATE_LIMIT_GENERAL_MAX: z.string().optional(),
46
+ API_RATE_LIMIT_AUTH_MAX: z.string().optional(),
47
+ API_RATE_LIMIT_SSE_MAX_CONNECTIONS: z.string().optional(),
48
+ ENABLE_SQUAD: z.string().optional(),
49
+ });
50
+ export const DEFAULT_MODEL = "claude-sonnet-4.6";
51
+ export const DEFAULT_TEAM_WIKI_CACHE_TTL_MINUTES = 60;
52
+ export const DEFAULT_TEAM_WIKI_PATHS = ["pages/team", "pages/okrs", "pages/kpis", "pages/shared"];
53
+ export const DEFAULT_STANDUP_TIME = "09:00";
54
+ export const DEFAULT_ADO_ORG = "";
55
+ export const DEFAULT_ADO_PROJECT = "";
56
+ export const DEFAULT_API_RATE_LIMIT_WINDOW_MS = 60_000;
57
+ export const DEFAULT_API_RATE_LIMIT_GENERAL_MAX = 100;
58
+ export const DEFAULT_API_RATE_LIMIT_AUTH_MAX = 10;
59
+ export const DEFAULT_API_RATE_LIMIT_SSE_MAX_CONNECTIONS = 5;
60
+ function parseBooleanEnv(name, rawValue, defaultValue) {
61
+ const normalized = rawValue?.trim();
62
+ if (!normalized) {
63
+ return defaultValue;
64
+ }
65
+ if (!BOOLEAN_ENV_PATTERN.test(normalized)) {
66
+ throw new Error(`${name} must be 'true' or 'false', got: "${rawValue}"`);
67
+ }
68
+ return normalized === "true";
69
+ }
70
+ function parseOptionalUrl(name, rawValue) {
71
+ const normalized = rawValue?.trim() || "";
72
+ if (!normalized) {
73
+ return "";
74
+ }
75
+ try {
76
+ const parsed = new URL(normalized);
77
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
78
+ throw new Error(`${name} must use http or https, got: "${rawValue}"`);
79
+ }
80
+ return parsed.toString().replace(/\/+$/, "");
81
+ }
82
+ catch (error) {
83
+ if (error instanceof Error && error.message.startsWith(name)) {
84
+ throw error;
85
+ }
86
+ throw new Error(`${name} must be a valid URL, got: "${rawValue}"`);
87
+ }
88
+ }
89
+ function parsePositiveIntegerEnv(name, rawValue, defaultValue) {
90
+ const normalized = rawValue?.trim();
91
+ if (!normalized) {
92
+ return defaultValue;
93
+ }
94
+ const parsed = Number(normalized);
95
+ if (!Number.isInteger(parsed) || parsed <= 0) {
96
+ throw new Error(`${name} must be a positive integer, got: "${rawValue}"`);
97
+ }
98
+ return parsed;
99
+ }
100
+ function parseCorsAllowedOrigins(rawValue) {
101
+ const normalized = rawValue?.trim();
102
+ if (!normalized) {
103
+ return [];
104
+ }
105
+ const origins = normalized
106
+ .split(",")
107
+ .map((value) => value.trim())
108
+ .filter(Boolean)
109
+ .map((value) => {
110
+ try {
111
+ const parsed = new URL(value);
112
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
113
+ throw new Error();
114
+ }
115
+ return parsed.origin;
116
+ }
117
+ catch {
118
+ throw new Error(`CORS_ALLOWED_ORIGINS must contain valid http(s) origins, got: "${value}"`);
119
+ }
120
+ });
121
+ return [...new Set(origins)];
122
+ }
123
+ function parseTeamWikiPaths(rawValue) {
124
+ const paths = (rawValue || DEFAULT_TEAM_WIKI_PATHS.join(","))
125
+ .split(",")
126
+ .map((value) => value.trim().replace(/\/+$/, ""))
127
+ .filter(Boolean);
128
+ return paths.length > 0 ? paths : [...DEFAULT_TEAM_WIKI_PATHS];
129
+ }
130
+ function resolveConfiguredApiToken(envToken, { apiTokenPath = API_TOKEN_PATH, exists = existsSync, readFile = readFileSync, } = {}) {
131
+ const trimmedEnvToken = envToken?.trim();
132
+ if (trimmedEnvToken) {
133
+ return trimmedEnvToken;
134
+ }
135
+ if (!exists(apiTokenPath)) {
136
+ return null;
137
+ }
138
+ const persistedToken = readFile(apiTokenPath, "utf-8").trim();
139
+ return persistedToken.length > 0 ? persistedToken : null;
140
+ }
141
+ export function parseRuntimeConfig(env, options = {}) {
142
+ const raw = configSchema.parse(env);
143
+ const nodeEnv = raw.NODE_ENV?.trim() || "development";
144
+ const isProduction = nodeEnv === "production";
145
+ const parsedMode = raw.CHAPTERHOUSE_MODE?.trim() || "personal";
146
+ if (parsedMode !== "personal" && parsedMode !== "team") {
147
+ throw new Error(`CHAPTERHOUSE_MODE must be 'personal' or 'team', got: "${raw.CHAPTERHOUSE_MODE}"`);
148
+ }
149
+ const parsedHost = raw.API_HOST?.trim() || "localhost";
150
+ const parsedPort = parseInt(raw.API_PORT || "7788", 10);
151
+ if (Number.isNaN(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
152
+ throw new Error(`API_PORT must be 1-65535, got: "${raw.API_PORT}"`);
153
+ }
154
+ const parsedStandupTime = raw.STANDUP_TIME?.trim() || DEFAULT_STANDUP_TIME;
155
+ if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(parsedStandupTime)) {
156
+ throw new Error(`STANDUP_TIME must use HH:MM 24-hour local time, got: "${raw.STANDUP_TIME}"`);
157
+ }
158
+ const parsedWorkerTimeout = raw.WORKER_TIMEOUT
159
+ ? Number(raw.WORKER_TIMEOUT)
160
+ : DEFAULT_WORKER_TIMEOUT_MS;
161
+ if (!Number.isInteger(parsedWorkerTimeout) || parsedWorkerTimeout <= 0) {
162
+ throw new Error(`WORKER_TIMEOUT must be a positive integer (ms), got: "${raw.WORKER_TIMEOUT}"`);
163
+ }
164
+ const parsedTeamWikiCacheTtlMinutes = raw.TEAM_WIKI_CACHE_TTL_MINUTES
165
+ ? Number(raw.TEAM_WIKI_CACHE_TTL_MINUTES)
166
+ : DEFAULT_TEAM_WIKI_CACHE_TTL_MINUTES;
167
+ if (!Number.isInteger(parsedTeamWikiCacheTtlMinutes) || parsedTeamWikiCacheTtlMinutes <= 0) {
168
+ throw new Error(`TEAM_WIKI_CACHE_TTL_MINUTES must be a positive integer (minutes), got: "${raw.TEAM_WIKI_CACHE_TTL_MINUTES}"`);
169
+ }
170
+ const entraTenantId = raw.ENTRA_TENANT_ID?.trim() || "";
171
+ const entraClientId = raw.ENTRA_CLIENT_ID?.trim() || "";
172
+ const entraRequiredRole = raw.ENTRA_REQUIRED_ROLE?.trim() || "";
173
+ const entraTeamLeadId = raw.ENTRA_TEAM_LEAD_ID?.trim() || "";
174
+ const entraAuthEnabled = parseBooleanEnv("ENTRA_AUTH_ENABLED", raw.ENTRA_AUTH_ENABLED, false);
175
+ const apiToken = resolveConfiguredApiToken(raw.API_TOKEN, options);
176
+ const standaloneMode = !entraAuthEnabled && !apiToken;
177
+ const teamsWebhookUrl = parseOptionalUrl("TEAMS_WEBHOOK_URL", raw.TEAMS_WEBHOOK_URL);
178
+ const teamsNotificationsEnabled = parseBooleanEnv("TEAMS_NOTIFICATIONS_ENABLED", raw.TEAMS_NOTIFICATIONS_ENABLED, false);
179
+ const apiRateLimitWindowMs = parsePositiveIntegerEnv("API_RATE_LIMIT_WINDOW_MS", raw.API_RATE_LIMIT_WINDOW_MS, DEFAULT_API_RATE_LIMIT_WINDOW_MS);
180
+ const apiRateLimitGeneralMax = parsePositiveIntegerEnv("API_RATE_LIMIT_GENERAL_MAX", raw.API_RATE_LIMIT_GENERAL_MAX, DEFAULT_API_RATE_LIMIT_GENERAL_MAX);
181
+ const apiRateLimitAuthMax = parsePositiveIntegerEnv("API_RATE_LIMIT_AUTH_MAX", raw.API_RATE_LIMIT_AUTH_MAX, DEFAULT_API_RATE_LIMIT_AUTH_MAX);
182
+ const apiRateLimitSseMaxConnections = parsePositiveIntegerEnv("API_RATE_LIMIT_SSE_MAX_CONNECTIONS", raw.API_RATE_LIMIT_SSE_MAX_CONNECTIONS, DEFAULT_API_RATE_LIMIT_SSE_MAX_CONNECTIONS);
183
+ if (entraAuthEnabled && (!entraTenantId || !entraClientId)) {
184
+ throw new Error("ENTRA_AUTH_ENABLED=true requires ENTRA_TENANT_ID and ENTRA_CLIENT_ID");
185
+ }
186
+ if (teamsNotificationsEnabled && !teamsWebhookUrl) {
187
+ throw new Error("TEAMS_NOTIFICATIONS_ENABLED=true requires TEAMS_WEBHOOK_URL");
188
+ }
189
+ return {
190
+ nodeEnv,
191
+ isProduction,
192
+ chapterhouseMode: parsedMode,
193
+ apiHost: parsedHost,
194
+ apiPort: parsedPort,
195
+ standupTime: parsedStandupTime,
196
+ // Azure DevOps org/project must be provided explicitly for OKR integrations.
197
+ adoOrg: raw.ADO_ORG?.trim() || DEFAULT_ADO_ORG,
198
+ adoProject: raw.ADO_PROJECT?.trim() || DEFAULT_ADO_PROJECT,
199
+ adoPat: raw.ADO_PAT?.trim() || "",
200
+ workerTimeoutMs: parsedWorkerTimeout,
201
+ entraTenantId,
202
+ entraClientId,
203
+ entraRequiredRole,
204
+ entraTeamLeadId,
205
+ entraAuthEnabled,
206
+ standaloneMode,
207
+ corsAllowedOrigins: parseCorsAllowedOrigins(raw.CORS_ALLOWED_ORIGINS),
208
+ copilotModel: raw.COPILOT_MODEL || DEFAULT_MODEL,
209
+ copilotAuthToken: raw.COPILOT_TOKEN?.trim() || raw.GITHUB_TOKEN?.trim() || "",
210
+ selfEditEnabled: env.CHAPTERHOUSE_SELF_EDIT === "1",
211
+ teamChapterhouseUrl: parseOptionalUrl("TEAM_CHAPTERHOUSE_URL", raw.TEAM_CHAPTERHOUSE_URL),
212
+ teamChapterhouseToken: raw.TEAM_CHAPTERHOUSE_TOKEN?.trim() || "",
213
+ teamWikiCacheTtlMinutes: parsedTeamWikiCacheTtlMinutes,
214
+ teamWikiPaths: parseTeamWikiPaths(raw.TEAM_WIKI_PATHS),
215
+ teamsWebhookUrl,
216
+ teamsNotificationsEnabled,
217
+ apiRateLimitWindowMs,
218
+ apiRateLimitGeneralMax,
219
+ apiRateLimitAuthMax,
220
+ apiRateLimitSseMaxConnections,
221
+ squadEnabled: raw.ENABLE_SQUAD === "1",
222
+ };
223
+ }
224
+ const runtimeConfig = parseRuntimeConfig(process.env);
225
+ let _copilotModel = runtimeConfig.copilotModel;
226
+ export const config = {
227
+ nodeEnv: runtimeConfig.nodeEnv,
228
+ isProduction: runtimeConfig.isProduction,
229
+ chapterhouseMode: runtimeConfig.chapterhouseMode,
230
+ apiHost: runtimeConfig.apiHost,
231
+ apiPort: runtimeConfig.apiPort,
232
+ standupTime: runtimeConfig.standupTime,
233
+ adoOrg: runtimeConfig.adoOrg,
234
+ adoProject: runtimeConfig.adoProject,
235
+ adoPat: runtimeConfig.adoPat,
236
+ get ADO_ORG() {
237
+ return runtimeConfig.adoOrg;
238
+ },
239
+ get ADO_PROJECT() {
240
+ return runtimeConfig.adoProject;
241
+ },
242
+ workerTimeoutMs: runtimeConfig.workerTimeoutMs,
243
+ entraTenantId: runtimeConfig.entraTenantId,
244
+ entraClientId: runtimeConfig.entraClientId,
245
+ entraRequiredRole: runtimeConfig.entraRequiredRole,
246
+ entraTeamLeadId: runtimeConfig.entraTeamLeadId,
247
+ entraAuthEnabled: runtimeConfig.entraAuthEnabled,
248
+ standaloneMode: runtimeConfig.standaloneMode,
249
+ corsAllowedOrigins: runtimeConfig.corsAllowedOrigins,
250
+ teamChapterhouseUrl: runtimeConfig.teamChapterhouseUrl,
251
+ teamChapterhouseToken: runtimeConfig.teamChapterhouseToken,
252
+ teamWikiCacheTtlMinutes: runtimeConfig.teamWikiCacheTtlMinutes,
253
+ teamWikiPaths: runtimeConfig.teamWikiPaths,
254
+ teamsWebhookUrl: runtimeConfig.teamsWebhookUrl,
255
+ teamsNotificationsEnabled: runtimeConfig.teamsNotificationsEnabled,
256
+ apiRateLimitWindowMs: runtimeConfig.apiRateLimitWindowMs,
257
+ apiRateLimitGeneralMax: runtimeConfig.apiRateLimitGeneralMax,
258
+ apiRateLimitAuthMax: runtimeConfig.apiRateLimitAuthMax,
259
+ apiRateLimitSseMaxConnections: runtimeConfig.apiRateLimitSseMaxConnections,
260
+ squadEnabled: runtimeConfig.squadEnabled,
261
+ copilotAuthToken: runtimeConfig.copilotAuthToken,
262
+ get copilotModel() {
263
+ return _copilotModel;
264
+ },
265
+ set copilotModel(model) {
266
+ _copilotModel = model;
267
+ },
268
+ get selfEditEnabled() {
269
+ return runtimeConfig.selfEditEnabled;
270
+ },
271
+ };
272
+ /** Update or append an env var in ~/.chapterhouse/.env */
273
+ function persistEnvVar(key, value) {
274
+ ensureChapterhouseHome();
275
+ try {
276
+ const content = readFileSync(ENV_PATH, "utf-8");
277
+ const lines = content.split("\n");
278
+ let found = false;
279
+ const updated = lines.map((line) => {
280
+ if (line.startsWith(`${key}=`)) {
281
+ found = true;
282
+ return `${key}=${value}`;
283
+ }
284
+ return line;
285
+ });
286
+ if (!found)
287
+ updated.push(`${key}=${value}`);
288
+ writeFileSync(ENV_PATH, updated.join("\n"));
289
+ }
290
+ catch {
291
+ // File doesn't exist — create it
292
+ writeFileSync(ENV_PATH, `${key}=${value}\n`);
293
+ }
294
+ }
295
+ /** Persist the current model choice to ~/.chapterhouse/.env */
296
+ export function persistModel(model) {
297
+ persistEnvVar("COPILOT_MODEL", model);
298
+ }
299
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,20 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ test("defaults CHAPTERHOUSE_MODE to personal when unset", async () => {
4
+ const configModule = await import("./config.js");
5
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
6
+ const parsed = configModule.parseRuntimeConfig({
7
+ TEAM_CHAPTERHOUSE_URL: "https://team.example.com",
8
+ });
9
+ assert.equal(parsed.chapterhouseMode, "personal");
10
+ assert.equal(parsed.teamChapterhouseUrl, "https://team.example.com");
11
+ });
12
+ test("parses explicit CHAPTERHOUSE_MODE values", async () => {
13
+ const configModule = await import("./config.js");
14
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
15
+ const parsed = configModule.parseRuntimeConfig({
16
+ CHAPTERHOUSE_MODE: "team",
17
+ });
18
+ assert.equal(parsed.chapterhouseMode, "team");
19
+ });
20
+ //# sourceMappingURL=config.phase3.test.js.map
@@ -0,0 +1,148 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ test("parses API_HOST alongside API_PORT", async () => {
4
+ const configModule = await import("./config.js");
5
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
6
+ const parsed = configModule.parseRuntimeConfig({
7
+ API_HOST: "0.0.0.0",
8
+ API_PORT: "7788",
9
+ });
10
+ assert.equal(parsed.apiHost, "0.0.0.0");
11
+ assert.equal(parsed.apiPort, 7788);
12
+ });
13
+ test("parses Teams webhook settings", async () => {
14
+ const configModule = await import("./config.js");
15
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
16
+ const parsed = configModule.parseRuntimeConfig({
17
+ TEAMS_WEBHOOK_URL: "https://teams.example.test/webhook",
18
+ TEAMS_NOTIFICATIONS_ENABLED: "true",
19
+ });
20
+ assert.equal(parsed.teamsWebhookUrl, "https://teams.example.test/webhook");
21
+ assert.equal(parsed.teamsNotificationsEnabled, true);
22
+ });
23
+ test("rejects Teams notifications without a webhook", async () => {
24
+ const configModule = await import("./config.js");
25
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
26
+ assert.throws(() => configModule.parseRuntimeConfig({
27
+ TEAMS_NOTIFICATIONS_ENABLED: "true",
28
+ }), /TEAMS_NOTIFICATIONS_ENABLED=true requires TEAMS_WEBHOOK_URL/);
29
+ });
30
+ test("parses Azure DevOps OKR settings", async () => {
31
+ const configModule = await import("./config.js");
32
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
33
+ const parsed = configModule.parseRuntimeConfig({
34
+ ADO_ORG: "https://dev.azure.com/example-org",
35
+ ADO_PROJECT: "example-project",
36
+ ADO_PAT: "test-pat",
37
+ });
38
+ assert.equal(parsed.adoOrg, "https://dev.azure.com/example-org");
39
+ assert.equal(parsed.adoProject, "example-project");
40
+ assert.equal(parsed.adoPat, "test-pat");
41
+ });
42
+ test("leaves Azure DevOps OKR settings empty when unset", async () => {
43
+ const configModule = await import("./config.js");
44
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
45
+ const parsed = configModule.parseRuntimeConfig({});
46
+ assert.equal(parsed.adoOrg, "");
47
+ assert.equal(parsed.adoProject, "");
48
+ });
49
+ test("parses STANDUP_TIME and defaults it to 09:00", async () => {
50
+ const configModule = await import("./config.js");
51
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
52
+ const parsedDefault = configModule.parseRuntimeConfig({});
53
+ const parsedExplicit = configModule.parseRuntimeConfig({
54
+ STANDUP_TIME: "08:15",
55
+ });
56
+ assert.equal(parsedDefault.standupTime, "09:00");
57
+ assert.equal(parsedExplicit.standupTime, "08:15");
58
+ });
59
+ test("requires Entra tenant and client when auth is enabled", async () => {
60
+ const configModule = await import("./config.js");
61
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
62
+ assert.throws(() => configModule.parseRuntimeConfig({
63
+ ENTRA_AUTH_ENABLED: "true",
64
+ ENTRA_TENANT_ID: "tenant-id",
65
+ }), /ENTRA_AUTH_ENABLED=true requires ENTRA_TENANT_ID and ENTRA_CLIENT_ID/);
66
+ });
67
+ test("enables standalone mode when Entra auth and API token are both absent", async () => {
68
+ const configModule = await import("./config.js");
69
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
70
+ const parsed = configModule.parseRuntimeConfig({}, {
71
+ apiTokenPath: ".test-work/missing-token",
72
+ exists: () => false,
73
+ });
74
+ assert.equal(parsed.standaloneMode, true);
75
+ });
76
+ test("disables standalone mode when an API token is configured", async () => {
77
+ const configModule = await import("./config.js");
78
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
79
+ const parsed = configModule.parseRuntimeConfig({
80
+ API_TOKEN: "personal-token",
81
+ });
82
+ assert.equal(parsed.standaloneMode, false);
83
+ });
84
+ test("normalizes configured CORS origins", async () => {
85
+ const configModule = await import("./config.js");
86
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
87
+ const parsed = configModule.parseRuntimeConfig({
88
+ CORS_ALLOWED_ORIGINS: "https://app.example.com/path, https://app.example.com, http://localhost:3000",
89
+ });
90
+ assert.deepEqual(parsed.corsAllowedOrigins, ["https://app.example.com", "http://localhost:3000"]);
91
+ });
92
+ test("defaults TEAM_WIKI_PATHS to include the shared namespace", async () => {
93
+ const configModule = await import("./config.js");
94
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
95
+ const parsed = configModule.parseRuntimeConfig({});
96
+ assert.deepEqual(parsed.teamWikiPaths, ["pages/team", "pages/okrs", "pages/kpis", "pages/shared"]);
97
+ });
98
+ test("prefers COPILOT_TOKEN over GITHUB_TOKEN for Copilot SDK auth", async () => {
99
+ const configModule = await import("./config.js");
100
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
101
+ const parsed = configModule.parseRuntimeConfig({
102
+ GITHUB_TOKEN: "github-token",
103
+ COPILOT_TOKEN: "copilot-token",
104
+ });
105
+ assert.equal(parsed.copilotAuthToken, "copilot-token");
106
+ });
107
+ test("uses GITHUB_TOKEN when COPILOT_TOKEN is unset", async () => {
108
+ const configModule = await import("./config.js");
109
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
110
+ const parsed = configModule.parseRuntimeConfig({
111
+ GITHUB_TOKEN: "github-token",
112
+ });
113
+ assert.equal(parsed.copilotAuthToken, "github-token");
114
+ });
115
+ test("rejects invalid boolean env values", async () => {
116
+ const configModule = await import("./config.js");
117
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
118
+ assert.throws(() => configModule.parseRuntimeConfig({
119
+ ENTRA_AUTH_ENABLED: "yes",
120
+ }), /ENTRA_AUTH_ENABLED must be 'true' or 'false'/);
121
+ });
122
+ test("parses rate limiting settings and defaults", async () => {
123
+ const configModule = await import("./config.js");
124
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
125
+ const parsedDefault = configModule.parseRuntimeConfig({});
126
+ const parsedExplicit = configModule.parseRuntimeConfig({
127
+ API_RATE_LIMIT_WINDOW_MS: "120000",
128
+ API_RATE_LIMIT_GENERAL_MAX: "150",
129
+ API_RATE_LIMIT_AUTH_MAX: "12",
130
+ API_RATE_LIMIT_SSE_MAX_CONNECTIONS: "7",
131
+ });
132
+ assert.equal(parsedDefault.apiRateLimitWindowMs, 60000);
133
+ assert.equal(parsedDefault.apiRateLimitGeneralMax, 100);
134
+ assert.equal(parsedDefault.apiRateLimitAuthMax, 10);
135
+ assert.equal(parsedDefault.apiRateLimitSseMaxConnections, 5);
136
+ assert.equal(parsedExplicit.apiRateLimitWindowMs, 120000);
137
+ assert.equal(parsedExplicit.apiRateLimitGeneralMax, 150);
138
+ assert.equal(parsedExplicit.apiRateLimitAuthMax, 12);
139
+ assert.equal(parsedExplicit.apiRateLimitSseMaxConnections, 7);
140
+ });
141
+ test("rejects invalid rate limiting settings", async () => {
142
+ const configModule = await import("./config.js");
143
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
144
+ assert.throws(() => configModule.parseRuntimeConfig({
145
+ API_RATE_LIMIT_GENERAL_MAX: "0",
146
+ }), /API_RATE_LIMIT_GENERAL_MAX must be a positive integer/);
147
+ });
148
+ //# sourceMappingURL=config.test.js.map