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.
- package/LICENSE +23 -0
- package/README.md +363 -0
- package/agents/chapterhouse.agent.md +40 -0
- package/agents/coder.agent.md +38 -0
- package/agents/designer.agent.md +43 -0
- package/agents/general-purpose.agent.md +30 -0
- package/dist/api/auth.js +159 -0
- package/dist/api/auth.test.js +463 -0
- package/dist/api/errors.js +95 -0
- package/dist/api/errors.test.js +89 -0
- package/dist/api/rate-limit.js +85 -0
- package/dist/api/server-runtime.js +62 -0
- package/dist/api/server.js +651 -0
- package/dist/api/server.test.js +385 -0
- package/dist/api/sse.integration.test.js +270 -0
- package/dist/api/sse.js +7 -0
- package/dist/api/team.js +196 -0
- package/dist/api/team.test.js +466 -0
- package/dist/cli.js +102 -0
- package/dist/config.js +299 -0
- package/dist/config.phase3.test.js +20 -0
- package/dist/config.test.js +148 -0
- package/dist/copilot/agents.js +447 -0
- package/dist/copilot/agents.squad.test.js +72 -0
- package/dist/copilot/classifier.js +72 -0
- package/dist/copilot/client.js +32 -0
- package/dist/copilot/client.test.js +100 -0
- package/dist/copilot/episode-writer.js +219 -0
- package/dist/copilot/episode-writer.test.js +41 -0
- package/dist/copilot/mcp-config.js +22 -0
- package/dist/copilot/okr-mapper.js +196 -0
- package/dist/copilot/okr-mapper.test.js +114 -0
- package/dist/copilot/orchestrator.js +685 -0
- package/dist/copilot/orchestrator.test.js +523 -0
- package/dist/copilot/router.js +142 -0
- package/dist/copilot/router.test.js +119 -0
- package/dist/copilot/skills.js +125 -0
- package/dist/copilot/standup.js +138 -0
- package/dist/copilot/standup.test.js +132 -0
- package/dist/copilot/system-message.js +143 -0
- package/dist/copilot/system-message.test.js +17 -0
- package/dist/copilot/tools.js +1212 -0
- package/dist/copilot/tools.okr.test.js +260 -0
- package/dist/copilot/tools.squad.test.js +168 -0
- package/dist/daemon.js +235 -0
- package/dist/home-path.js +12 -0
- package/dist/home-path.test.js +11 -0
- package/dist/integrations/ado-analytics.js +178 -0
- package/dist/integrations/ado-analytics.test.js +284 -0
- package/dist/integrations/ado-client.js +227 -0
- package/dist/integrations/ado-client.test.js +176 -0
- package/dist/integrations/ado-schema.js +25 -0
- package/dist/integrations/ado-schema.test.js +39 -0
- package/dist/integrations/ado-skill.js +55 -0
- package/dist/integrations/report-generator.js +114 -0
- package/dist/integrations/report-generator.test.js +62 -0
- package/dist/integrations/team-push.js +144 -0
- package/dist/integrations/team-push.test.js +178 -0
- package/dist/integrations/teams-notify.js +108 -0
- package/dist/integrations/teams-notify.test.js +135 -0
- package/dist/paths.js +41 -0
- package/dist/setup.js +149 -0
- package/dist/shutdown-signals.js +13 -0
- package/dist/shutdown-signals.test.js +33 -0
- package/dist/squad/charter.js +108 -0
- package/dist/squad/charter.test.js +89 -0
- package/dist/squad/context.js +48 -0
- package/dist/squad/context.test.js +59 -0
- package/dist/squad/discovery.js +280 -0
- package/dist/squad/discovery.test.js +93 -0
- package/dist/squad/index.js +7 -0
- package/dist/squad/mirror.js +81 -0
- package/dist/squad/mirror.scheduler.js +78 -0
- package/dist/squad/mirror.scheduler.test.js +197 -0
- package/dist/squad/mirror.test.js +172 -0
- package/dist/squad/registry.js +162 -0
- package/dist/squad/registry.test.js +31 -0
- package/dist/squad/squad-coordinator-system-message.test.js +190 -0
- package/dist/squad/squad-session-routing.test.js +260 -0
- package/dist/squad/types.js +4 -0
- package/dist/status.js +25 -0
- package/dist/status.test.js +22 -0
- package/dist/store/db.js +290 -0
- package/dist/store/db.test.js +126 -0
- package/dist/store/squad-sessions.test.js +341 -0
- package/dist/test/setup-env.js +3 -0
- package/dist/update.js +112 -0
- package/dist/update.test.js +25 -0
- package/dist/wiki/context.js +138 -0
- package/dist/wiki/fs.js +195 -0
- package/dist/wiki/fs.test.js +39 -0
- package/dist/wiki/index-manager.js +359 -0
- package/dist/wiki/index-manager.test.js +129 -0
- package/dist/wiki/lock.js +26 -0
- package/dist/wiki/lock.test.js +30 -0
- package/dist/wiki/log-manager.js +20 -0
- package/dist/wiki/migrate.js +306 -0
- package/dist/wiki/okr.test.js +101 -0
- package/dist/wiki/path-utils.js +4 -0
- package/dist/wiki/path-utils.test.js +8 -0
- package/dist/wiki/seed-team-wiki.js +296 -0
- package/dist/wiki/seed-team-wiki.test.js +69 -0
- package/dist/wiki/team-sync.js +212 -0
- package/dist/wiki/team-sync.test.js +185 -0
- package/dist/wiki/templates/okr.js +98 -0
- package/package.json +72 -0
- package/skills/.gitkeep +0 -0
- package/skills/find-skills/SKILL.md +161 -0
- package/skills/find-skills/_meta.json +4 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/squad/SKILL.md +76 -0
- package/web/dist/assets/index-D-e7K-fT.css +10 -0
- package/web/dist/assets/index-DAg9IrpO.js +142 -0
- package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
- package/web/dist/chapterhouse-icon.png +0 -0
- package/web/dist/chapterhouse-icon.svg +42 -0
- package/web/dist/chapterhouse-logo.svg +46 -0
- package/web/dist/index.html +15 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
async function loadTeamPushModule() {
|
|
4
|
+
try {
|
|
5
|
+
return await import(new URL(`./team-push.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
test("pushUpdate sends the expected payload to the team update endpoint", async () => {
|
|
12
|
+
const teamPushModule = await loadTeamPushModule();
|
|
13
|
+
assert.ok(teamPushModule, "team push module should exist");
|
|
14
|
+
const requests = [];
|
|
15
|
+
const client = new teamPushModule.TeamPushClient({
|
|
16
|
+
teamChapterhouseUrl: "https://team.example.com/",
|
|
17
|
+
standaloneMode: false,
|
|
18
|
+
getAuthorizationHeader: () => "Bearer entra-token",
|
|
19
|
+
getCurrentUser: () => ({
|
|
20
|
+
id: "11111111-2222-3333-4444-555555555555",
|
|
21
|
+
name: "Ada Lovelace",
|
|
22
|
+
email: "ada@example.com",
|
|
23
|
+
}),
|
|
24
|
+
fetchImpl: async (input, init) => {
|
|
25
|
+
requests.push({ input: String(input), init });
|
|
26
|
+
return new Response(JSON.stringify({
|
|
27
|
+
ok: true,
|
|
28
|
+
entry: {
|
|
29
|
+
engineerId: "11111111-2222-3333-4444-555555555555",
|
|
30
|
+
activity: "merged auth refactor PR",
|
|
31
|
+
krId: "O1-KR2",
|
|
32
|
+
delta: 10,
|
|
33
|
+
notes: "Latency benchmark improved",
|
|
34
|
+
},
|
|
35
|
+
}), {
|
|
36
|
+
status: 200,
|
|
37
|
+
headers: { "content-type": "application/json" },
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const result = await client.pushUpdate({
|
|
42
|
+
activity: "merged auth refactor PR",
|
|
43
|
+
krId: "O1-KR2",
|
|
44
|
+
delta: 10,
|
|
45
|
+
notes: "Latency benchmark improved",
|
|
46
|
+
});
|
|
47
|
+
assert.equal(result.ok, true);
|
|
48
|
+
assert.equal(requests.length, 1);
|
|
49
|
+
assert.equal(requests[0]?.input, "https://team.example.com/api/team/update");
|
|
50
|
+
assert.equal(requests[0]?.init?.method, "POST");
|
|
51
|
+
assert.equal((requests[0]?.init?.headers).authorization, "Bearer entra-token");
|
|
52
|
+
assert.deepEqual(JSON.parse(String(requests[0]?.init?.body)), {
|
|
53
|
+
engineerId: "11111111-2222-3333-4444-555555555555",
|
|
54
|
+
activity: "merged auth refactor PR",
|
|
55
|
+
krId: "O1-KR2",
|
|
56
|
+
delta: 10,
|
|
57
|
+
notes: "Latency benchmark improved",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
test("pushUpdate throws descriptive errors for auth and network failures", async () => {
|
|
61
|
+
const teamPushModule = await loadTeamPushModule();
|
|
62
|
+
assert.ok(teamPushModule, "team push module should exist");
|
|
63
|
+
const unauthorizedClient = new teamPushModule.TeamPushClient({
|
|
64
|
+
teamChapterhouseUrl: "https://team.example.com",
|
|
65
|
+
teamChapterhouseToken: "fallback-token",
|
|
66
|
+
standaloneMode: false,
|
|
67
|
+
getCurrentUser: () => ({
|
|
68
|
+
id: "eng-1",
|
|
69
|
+
name: "Ada Lovelace",
|
|
70
|
+
email: "ada@example.com",
|
|
71
|
+
}),
|
|
72
|
+
fetchImpl: async () => new Response("forbidden", { status: 401 }),
|
|
73
|
+
});
|
|
74
|
+
await assert.rejects(unauthorizedClient.pushUpdate({ activity: "shipped auth refactor", krId: "O1-KR2", delta: 5 }), /Failed to push OKR update: unauthorized \(HTTP 401\)/i);
|
|
75
|
+
const networkClient = new teamPushModule.TeamPushClient({
|
|
76
|
+
teamChapterhouseUrl: "https://team.example.com",
|
|
77
|
+
standaloneMode: false,
|
|
78
|
+
getAuthorizationHeader: () => "Bearer entra-token",
|
|
79
|
+
getCurrentUser: () => ({
|
|
80
|
+
id: "eng-1",
|
|
81
|
+
name: "Ada Lovelace",
|
|
82
|
+
email: "ada@example.com",
|
|
83
|
+
}),
|
|
84
|
+
fetchImpl: async () => {
|
|
85
|
+
throw new Error("socket hang up");
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
await assert.rejects(networkClient.pushUpdate({ activity: "shipped auth refactor", krId: "O1-KR2", delta: 5 }), /Failed to push OKR update: network request failed: socket hang up/i);
|
|
89
|
+
});
|
|
90
|
+
test("fetchOKRs returns raw OKR page content for the requested period", async () => {
|
|
91
|
+
const teamPushModule = await loadTeamPushModule();
|
|
92
|
+
assert.ok(teamPushModule, "team push module should exist");
|
|
93
|
+
const requests = [];
|
|
94
|
+
const client = new teamPushModule.TeamPushClient({
|
|
95
|
+
teamChapterhouseUrl: "https://team.example.com",
|
|
96
|
+
standaloneMode: false,
|
|
97
|
+
getAuthorizationHeader: () => "Bearer entra-token",
|
|
98
|
+
fetchImpl: async (input) => {
|
|
99
|
+
requests.push(String(input));
|
|
100
|
+
return new Response(JSON.stringify({
|
|
101
|
+
pages: [
|
|
102
|
+
{
|
|
103
|
+
path: "pages/okrs/2026-Q2.md",
|
|
104
|
+
content: "# OKRs — 2026 Q2\n\n### O1-KR2: Reduce auth service latency\n",
|
|
105
|
+
updatedAt: "2026-05-01T12:00:00.000Z",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
}), {
|
|
109
|
+
status: 200,
|
|
110
|
+
headers: { "content-type": "application/json" },
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const content = await client.fetchOKRs("2026-Q2");
|
|
115
|
+
assert.equal(requests[0], "https://team.example.com/api/team/okrs?period=2026-Q2");
|
|
116
|
+
assert.match(content, /O1-KR2: Reduce auth service latency/);
|
|
117
|
+
});
|
|
118
|
+
test("writePage PUTs shared wiki content to the team wiki endpoint", async () => {
|
|
119
|
+
const teamPushModule = await loadTeamPushModule();
|
|
120
|
+
assert.ok(teamPushModule, "team push module should exist");
|
|
121
|
+
const requests = [];
|
|
122
|
+
const client = new teamPushModule.TeamPushClient({
|
|
123
|
+
teamChapterhouseUrl: "https://team.example.com/",
|
|
124
|
+
standaloneMode: false,
|
|
125
|
+
getAuthorizationHeader: () => "Bearer entra-token",
|
|
126
|
+
fetchImpl: async (input, init) => {
|
|
127
|
+
requests.push({ input: String(input), init });
|
|
128
|
+
return new Response(JSON.stringify({
|
|
129
|
+
ok: true,
|
|
130
|
+
path: "pages/shared/runbooks/deploy.md",
|
|
131
|
+
}), {
|
|
132
|
+
status: 200,
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
const result = await client.writePage("pages/shared/runbooks/deploy.md", "# Deploy Runbook\n");
|
|
138
|
+
assert.deepEqual(result, {
|
|
139
|
+
ok: true,
|
|
140
|
+
path: "pages/shared/runbooks/deploy.md",
|
|
141
|
+
});
|
|
142
|
+
assert.equal(requests.length, 1);
|
|
143
|
+
assert.equal(requests[0]?.input, "https://team.example.com/api/team/wiki/pages%2Fshared%2Frunbooks%2Fdeploy.md");
|
|
144
|
+
assert.equal(requests[0]?.init?.method, "PUT");
|
|
145
|
+
assert.equal((requests[0]?.init?.headers).authorization, "Bearer entra-token");
|
|
146
|
+
assert.deepEqual(JSON.parse(String(requests[0]?.init?.body)), {
|
|
147
|
+
content: "# Deploy Runbook\n",
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
test("team push silently no-ops when team integration is disabled", async () => {
|
|
151
|
+
const teamPushModule = await loadTeamPushModule();
|
|
152
|
+
assert.ok(teamPushModule, "team push module should exist");
|
|
153
|
+
let called = false;
|
|
154
|
+
const client = new teamPushModule.TeamPushClient({
|
|
155
|
+
teamChapterhouseUrl: "",
|
|
156
|
+
standaloneMode: false,
|
|
157
|
+
fetchImpl: async () => {
|
|
158
|
+
called = true;
|
|
159
|
+
throw new Error("fetch should not run");
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
assert.deepEqual(await client.pushUpdate({ activity: "shipped standalone mode", krId: "O1-KR2", delta: 5 }), {
|
|
163
|
+
ok: false,
|
|
164
|
+
entry: {
|
|
165
|
+
disabled: true,
|
|
166
|
+
activity: "shipped standalone mode",
|
|
167
|
+
krId: "O1-KR2",
|
|
168
|
+
delta: 5,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
assert.equal(await client.fetchOKRs("2026-Q2"), "");
|
|
172
|
+
assert.deepEqual(await client.writePage("pages/shared/runbooks/deploy.md", "# Deploy\n"), {
|
|
173
|
+
ok: false,
|
|
174
|
+
path: "pages/shared/runbooks/deploy.md",
|
|
175
|
+
});
|
|
176
|
+
assert.equal(called, false);
|
|
177
|
+
});
|
|
178
|
+
//# sourceMappingURL=team-push.test.js.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
export const TEAMS_MILESTONE_THRESHOLDS = [25, 50, 75, 100];
|
|
3
|
+
const DEFAULT_COLOR = "0076D7";
|
|
4
|
+
export class TeamsNotifier {
|
|
5
|
+
webhookUrl;
|
|
6
|
+
enabled;
|
|
7
|
+
fetchImpl;
|
|
8
|
+
warn;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.webhookUrl = (options.webhookUrl ?? config.teamsWebhookUrl).trim();
|
|
11
|
+
this.enabled = options.enabled ?? config.teamsNotificationsEnabled;
|
|
12
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
13
|
+
this.warn = options.warn ?? ((message) => console.warn(message));
|
|
14
|
+
}
|
|
15
|
+
async sendMessage(title, body, color = DEFAULT_COLOR) {
|
|
16
|
+
return await this.postCard({
|
|
17
|
+
summary: title,
|
|
18
|
+
activityTitle: title,
|
|
19
|
+
text: body,
|
|
20
|
+
themeColor: color,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async notifyKRMilestone(kr) {
|
|
24
|
+
const milestone = getReachedMilestone(kr.currentValue, kr.targetValue);
|
|
25
|
+
if (milestone === null) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return await this.postCard({
|
|
29
|
+
summary: "KR Milestone",
|
|
30
|
+
activityTitle: `🎯 KR Milestone: ${kr.title}`,
|
|
31
|
+
text: `${kr.owner} moved **${kr.title}** to **${formatValue(kr.currentValue, kr.unit)}** out of **${formatValue(kr.targetValue, kr.unit)}**.`,
|
|
32
|
+
facts: [
|
|
33
|
+
{ name: "KR", value: kr.id },
|
|
34
|
+
{ name: "Owner", value: kr.owner },
|
|
35
|
+
{ name: "Milestone", value: `${milestone}%` },
|
|
36
|
+
{ name: "Progress", value: `${formatValue(kr.currentValue, kr.unit)} / ${formatValue(kr.targetValue, kr.unit)}` },
|
|
37
|
+
],
|
|
38
|
+
themeColor: DEFAULT_COLOR,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async notifyWeeklyHealthCheck(summary) {
|
|
42
|
+
const body = summary.objectives
|
|
43
|
+
.map((objective) => `- ${objective.title}: ${objective.percentComplete}%`)
|
|
44
|
+
.join("\n");
|
|
45
|
+
return await this.postCard({
|
|
46
|
+
summary: "Weekly OKR Health Check",
|
|
47
|
+
activityTitle: `📈 Weekly OKR Health Check: ${summary.quarter}`,
|
|
48
|
+
text: body,
|
|
49
|
+
themeColor: DEFAULT_COLOR,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async notifyStandup(member, updates) {
|
|
53
|
+
const body = updates.map((update) => `- ${update}`).join("\n");
|
|
54
|
+
return await this.postCard({
|
|
55
|
+
summary: "Daily Standup",
|
|
56
|
+
activityTitle: `🗣️ Standup: ${member}`,
|
|
57
|
+
text: body,
|
|
58
|
+
themeColor: DEFAULT_COLOR,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async postCard(card) {
|
|
62
|
+
if (!this.enabled || this.webhookUrl.length === 0) {
|
|
63
|
+
this.warn("[teams] Teams notifications are disabled or TEAMS_WEBHOOK_URL is empty.");
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const payload = {
|
|
67
|
+
"@type": "MessageCard",
|
|
68
|
+
"@context": "http://schema.org/extensions",
|
|
69
|
+
themeColor: card.themeColor,
|
|
70
|
+
summary: card.summary,
|
|
71
|
+
sections: [
|
|
72
|
+
{
|
|
73
|
+
activityTitle: card.activityTitle,
|
|
74
|
+
text: card.text,
|
|
75
|
+
facts: card.facts,
|
|
76
|
+
markdown: true,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
const response = await this.fetchImpl(this.webhookUrl, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: {
|
|
83
|
+
"content-type": "application/json",
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify(payload),
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error(`Teams webhook request failed: HTTP ${response.status}`);
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function getReachedMilestone(currentValue, targetValue) {
|
|
94
|
+
if (!Number.isFinite(currentValue) || !Number.isFinite(targetValue) || targetValue <= 0) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
let reached = null;
|
|
98
|
+
for (const threshold of TEAMS_MILESTONE_THRESHOLDS) {
|
|
99
|
+
if (currentValue >= (targetValue * threshold) / 100) {
|
|
100
|
+
reached = threshold;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return reached;
|
|
104
|
+
}
|
|
105
|
+
function formatValue(value, unit) {
|
|
106
|
+
return unit === "%" ? `${value}%` : `${value} ${unit}`.trim();
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=teams-notify.js.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
async function loadTeamsNotifyModule() {
|
|
4
|
+
try {
|
|
5
|
+
return await import(new URL(`./teams-notify.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
test("sendMessage formats a Teams MessageCard payload", async () => {
|
|
12
|
+
const teamsNotify = await loadTeamsNotifyModule();
|
|
13
|
+
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
14
|
+
const calls = [];
|
|
15
|
+
const notifier = new teamsNotify.TeamsNotifier({
|
|
16
|
+
webhookUrl: "https://teams.example.test/webhook",
|
|
17
|
+
enabled: true,
|
|
18
|
+
fetchImpl: async (input, init) => {
|
|
19
|
+
calls.push({ input, init });
|
|
20
|
+
return new Response(null, { status: 200 });
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
const sent = await notifier.sendMessage("Release ready", "Everything is green.", "00FF00");
|
|
24
|
+
assert.equal(sent, true);
|
|
25
|
+
assert.equal(calls.length, 1);
|
|
26
|
+
assert.equal(String(calls[0]?.input), "https://teams.example.test/webhook");
|
|
27
|
+
assert.equal(calls[0]?.init?.method, "POST");
|
|
28
|
+
const payload = JSON.parse(String(calls[0]?.init?.body));
|
|
29
|
+
assert.equal(payload["@type"], "MessageCard");
|
|
30
|
+
assert.equal(payload["@context"], "http://schema.org/extensions");
|
|
31
|
+
assert.equal(payload.themeColor, "00FF00");
|
|
32
|
+
assert.equal(payload.summary, "Release ready");
|
|
33
|
+
assert.equal(payload.sections[0]?.activityTitle, "Release ready");
|
|
34
|
+
assert.equal(payload.sections[0]?.text, "Everything is green.");
|
|
35
|
+
});
|
|
36
|
+
for (const milestone of [25, 50, 75, 100]) {
|
|
37
|
+
test(`notifyKRMilestone sends the ${milestone}% milestone payload`, async () => {
|
|
38
|
+
const teamsNotify = await loadTeamsNotifyModule();
|
|
39
|
+
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
40
|
+
const calls = [];
|
|
41
|
+
const notifier = new teamsNotify.TeamsNotifier({
|
|
42
|
+
webhookUrl: "https://teams.example.test/webhook",
|
|
43
|
+
enabled: true,
|
|
44
|
+
fetchImpl: async (input, init) => {
|
|
45
|
+
calls.push({ input, init });
|
|
46
|
+
return new Response(null, { status: 200 });
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const sent = await notifier.notifyKRMilestone({
|
|
50
|
+
id: "KR-7",
|
|
51
|
+
title: "Reduce incident volume",
|
|
52
|
+
owner: "Ada",
|
|
53
|
+
currentValue: milestone,
|
|
54
|
+
targetValue: 100,
|
|
55
|
+
unit: "%",
|
|
56
|
+
});
|
|
57
|
+
assert.equal(sent, true);
|
|
58
|
+
assert.equal(calls.length, 1);
|
|
59
|
+
const payload = JSON.parse(String(calls[0]?.init?.body));
|
|
60
|
+
assert.equal(payload.summary, "KR Milestone");
|
|
61
|
+
assert.equal(payload.themeColor, "0076D7");
|
|
62
|
+
assert.equal(payload.sections[0]?.activityTitle, "🎯 KR Milestone: Reduce incident volume");
|
|
63
|
+
assert.ok(payload.sections[0]?.facts?.some((fact) => fact.name === "Milestone" && fact.value === `${milestone}%`));
|
|
64
|
+
assert.ok(payload.sections[0]?.facts?.some((fact) => fact.name === "Owner" && fact.value === "Ada"));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
test("sendMessage does not call Teams when notifications are disabled", async () => {
|
|
68
|
+
const teamsNotify = await loadTeamsNotifyModule();
|
|
69
|
+
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
70
|
+
let fetchCalls = 0;
|
|
71
|
+
const warnings = [];
|
|
72
|
+
const notifier = new teamsNotify.TeamsNotifier({
|
|
73
|
+
webhookUrl: "https://teams.example.test/webhook",
|
|
74
|
+
enabled: false,
|
|
75
|
+
fetchImpl: async () => {
|
|
76
|
+
fetchCalls += 1;
|
|
77
|
+
return new Response(null, { status: 200 });
|
|
78
|
+
},
|
|
79
|
+
warn: (message) => warnings.push(message),
|
|
80
|
+
});
|
|
81
|
+
const sent = await notifier.sendMessage("Ignored", "This should not send.");
|
|
82
|
+
assert.equal(sent, false);
|
|
83
|
+
assert.equal(fetchCalls, 0);
|
|
84
|
+
assert.equal(warnings.length, 1);
|
|
85
|
+
assert.match(warnings[0] ?? "", /teams notifications/i);
|
|
86
|
+
});
|
|
87
|
+
test("notifyWeeklyHealthCheck sends an OKR summary card", async () => {
|
|
88
|
+
const teamsNotify = await loadTeamsNotifyModule();
|
|
89
|
+
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
90
|
+
const calls = [];
|
|
91
|
+
const notifier = new teamsNotify.TeamsNotifier({
|
|
92
|
+
webhookUrl: "https://teams.example.test/webhook",
|
|
93
|
+
enabled: true,
|
|
94
|
+
fetchImpl: async (input, init) => {
|
|
95
|
+
calls.push({ input, init });
|
|
96
|
+
return new Response(null, { status: 200 });
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const sent = await notifier.notifyWeeklyHealthCheck({
|
|
100
|
+
quarter: "2026-Q2",
|
|
101
|
+
objectives: [
|
|
102
|
+
{ title: "Ship SSO", percentComplete: 80 },
|
|
103
|
+
{ title: "Reduce incidents", percentComplete: 55 },
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
assert.equal(sent, true);
|
|
107
|
+
const payload = JSON.parse(String(calls[0]?.init?.body));
|
|
108
|
+
assert.equal(payload.summary, "Weekly OKR Health Check");
|
|
109
|
+
assert.match(payload.sections[0]?.text ?? "", /Ship SSO: 80%/);
|
|
110
|
+
assert.match(payload.sections[0]?.text ?? "", /Reduce incidents: 55%/);
|
|
111
|
+
});
|
|
112
|
+
test("notifyStandup sends the member update summary", async () => {
|
|
113
|
+
const teamsNotify = await loadTeamsNotifyModule();
|
|
114
|
+
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
115
|
+
const calls = [];
|
|
116
|
+
const notifier = new teamsNotify.TeamsNotifier({
|
|
117
|
+
webhookUrl: "https://teams.example.test/webhook",
|
|
118
|
+
enabled: true,
|
|
119
|
+
fetchImpl: async (input, init) => {
|
|
120
|
+
calls.push({ input, init });
|
|
121
|
+
return new Response(null, { status: 200 });
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const sent = await notifier.notifyStandup("Grace", [
|
|
125
|
+
"Closed the migration backlog",
|
|
126
|
+
"Unblocked the SSO rollout",
|
|
127
|
+
]);
|
|
128
|
+
assert.equal(sent, true);
|
|
129
|
+
const payload = JSON.parse(String(calls[0]?.init?.body));
|
|
130
|
+
assert.equal(payload.summary, "Daily Standup");
|
|
131
|
+
assert.equal(payload.sections[0]?.activityTitle, "🗣️ Standup: Grace");
|
|
132
|
+
assert.match(payload.sections[0]?.text ?? "", /Closed the migration backlog/);
|
|
133
|
+
assert.match(payload.sections[0]?.text ?? "", /Unblocked the SSO rollout/);
|
|
134
|
+
});
|
|
135
|
+
//# sourceMappingURL=teams-notify.test.js.map
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { mkdirSync } from "fs";
|
|
4
|
+
import { normalizeWikiPath } from "./wiki/path-utils.js";
|
|
5
|
+
/** Base directory for all Chapterhouse user data: ~/.chapterhouse */
|
|
6
|
+
function resolveChapterhouseHome() {
|
|
7
|
+
const configuredHome = process.env.CHAPTERHOUSE_HOME?.trim();
|
|
8
|
+
if (!configuredHome) {
|
|
9
|
+
return join(homedir(), ".chapterhouse");
|
|
10
|
+
}
|
|
11
|
+
return configuredHome.endsWith(".chapterhouse")
|
|
12
|
+
? configuredHome
|
|
13
|
+
: join(configuredHome, ".chapterhouse");
|
|
14
|
+
}
|
|
15
|
+
export const CHAPTERHOUSE_HOME = resolveChapterhouseHome();
|
|
16
|
+
/** Path to the SQLite database */
|
|
17
|
+
export const DB_PATH = join(CHAPTERHOUSE_HOME, "chapterhouse.db");
|
|
18
|
+
/** Path to the user .env file */
|
|
19
|
+
export const ENV_PATH = join(CHAPTERHOUSE_HOME, ".env");
|
|
20
|
+
/** Path to user-local skills */
|
|
21
|
+
export const SKILLS_DIR = join(CHAPTERHOUSE_HOME, "skills");
|
|
22
|
+
/** Path to Chapterhouse's isolated session state (keeps CLI history clean) */
|
|
23
|
+
export const SESSIONS_DIR = join(CHAPTERHOUSE_HOME, "sessions");
|
|
24
|
+
/** Path to the API bearer token file */
|
|
25
|
+
export const API_TOKEN_PATH = join(CHAPTERHOUSE_HOME, "api-token");
|
|
26
|
+
/** Agent definition files (~/.chapterhouse/agents/) */
|
|
27
|
+
export const AGENTS_DIR = join(CHAPTERHOUSE_HOME, "agents");
|
|
28
|
+
/** Root of the LLM-maintained wiki knowledge base */
|
|
29
|
+
export const WIKI_DIR = join(CHAPTERHOUSE_HOME, "wiki");
|
|
30
|
+
/** Wiki pages (entity, concept, summary files) */
|
|
31
|
+
export const WIKI_PAGES_DIR = join(WIKI_DIR, "pages");
|
|
32
|
+
/** Raw ingested source documents (immutable) */
|
|
33
|
+
export const WIKI_SOURCES_DIR = join(WIKI_DIR, "sources");
|
|
34
|
+
export function resolveWikiRelativePath(relativePath) {
|
|
35
|
+
return join(WIKI_DIR, ...normalizeWikiPath(relativePath).split("/"));
|
|
36
|
+
}
|
|
37
|
+
/** Ensure ~/.chapterhouse/ exists */
|
|
38
|
+
export function ensureChapterhouseHome() {
|
|
39
|
+
mkdirSync(CHAPTERHOUSE_HOME, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=paths.js.map
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import * as readline from "readline";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { CopilotClient } from "@github/copilot-sdk";
|
|
4
|
+
import { ensureChapterhouseHome, ENV_PATH, CHAPTERHOUSE_HOME } from "./paths.js";
|
|
5
|
+
import { getExampleProjectPath } from "./home-path.js";
|
|
6
|
+
const BOLD = "\x1b[1m";
|
|
7
|
+
const DIM = "\x1b[2m";
|
|
8
|
+
const GREEN = "\x1b[32m";
|
|
9
|
+
const YELLOW = "\x1b[33m";
|
|
10
|
+
const CYAN = "\x1b[36m";
|
|
11
|
+
const RESET = "\x1b[0m";
|
|
12
|
+
const FALLBACK_MODELS = [
|
|
13
|
+
{ id: "claude-sonnet-4.6", label: "Claude Sonnet 4.6", desc: "Fast, great for most tasks" },
|
|
14
|
+
{ id: "gpt-5.1", label: "GPT-5.1", desc: "OpenAI's fast model" },
|
|
15
|
+
{ id: "gpt-4.1", label: "GPT-4.1", desc: "Free included model" },
|
|
16
|
+
];
|
|
17
|
+
async function fetchModels() {
|
|
18
|
+
let client;
|
|
19
|
+
try {
|
|
20
|
+
client = new CopilotClient({ autoStart: true });
|
|
21
|
+
await client.start();
|
|
22
|
+
const models = await client.listModels();
|
|
23
|
+
return models
|
|
24
|
+
.filter((m) => m.policy?.state === "enabled" && !m.name.includes("(Internal only)"))
|
|
25
|
+
.map((m) => {
|
|
26
|
+
const mult = m.billing?.multiplier;
|
|
27
|
+
const desc = mult === 0 || mult === undefined ? "Included with Copilot" : `Premium (${mult}x)`;
|
|
28
|
+
return { id: m.id, label: m.name, desc };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
try {
|
|
36
|
+
await client?.stop();
|
|
37
|
+
}
|
|
38
|
+
catch { /* best-effort */ }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function ask(rl, question) {
|
|
42
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
43
|
+
}
|
|
44
|
+
async function askPicker(rl, label, options, defaultId) {
|
|
45
|
+
console.log(`${BOLD}${label}${RESET}\n`);
|
|
46
|
+
const defaultIdx = Math.max(0, options.findIndex((o) => o.id === defaultId));
|
|
47
|
+
for (let i = 0; i < options.length; i++) {
|
|
48
|
+
const marker = i === defaultIdx ? `${GREEN}▸${RESET}` : " ";
|
|
49
|
+
const tag = i === defaultIdx ? ` ${DIM}(default)${RESET}` : "";
|
|
50
|
+
console.log(` ${marker} ${CYAN}${i + 1}${RESET} ${options[i].label}${tag}`);
|
|
51
|
+
console.log(` ${DIM}${options[i].desc}${RESET}`);
|
|
52
|
+
}
|
|
53
|
+
console.log();
|
|
54
|
+
const input = await ask(rl, ` Pick a number ${DIM}(1-${options.length}, Enter for default)${RESET}: `);
|
|
55
|
+
const num = parseInt(input.trim(), 10);
|
|
56
|
+
if (num >= 1 && num <= options.length)
|
|
57
|
+
return options[num - 1].id;
|
|
58
|
+
return options[defaultIdx].id;
|
|
59
|
+
}
|
|
60
|
+
async function main() {
|
|
61
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
62
|
+
console.log(`
|
|
63
|
+
${BOLD}╔══════════════════════════════════════════╗
|
|
64
|
+
║ 🤖 Chapterhouse Setup ║
|
|
65
|
+
╚══════════════════════════════════════════╝${RESET}
|
|
66
|
+
`);
|
|
67
|
+
console.log(`${DIM}Config directory: ${CHAPTERHOUSE_HOME}${RESET}\n`);
|
|
68
|
+
ensureChapterhouseHome();
|
|
69
|
+
// Load existing values if any
|
|
70
|
+
const existing = {};
|
|
71
|
+
if (existsSync(ENV_PATH)) {
|
|
72
|
+
for (const line of readFileSync(ENV_PATH, "utf-8").split("\n")) {
|
|
73
|
+
const match = line.match(/^([A-Z_]+)=(.*)$/);
|
|
74
|
+
if (match)
|
|
75
|
+
existing[match[1]] = match[2];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ── What is Chapterhouse ──────────────────────────────────────────
|
|
79
|
+
console.log(`${BOLD}Meet Chapterhouse${RESET}`);
|
|
80
|
+
console.log(`Chapterhouse is your team-level AI assistant — an always-on daemon that runs on`);
|
|
81
|
+
console.log(`your machine. Open the web UI in your browser and talk in plain English;`);
|
|
82
|
+
console.log(`Chapterhouse handles the rest.`);
|
|
83
|
+
console.log();
|
|
84
|
+
console.log(`${CYAN}What Chapterhouse can do out of the box:${RESET}`);
|
|
85
|
+
console.log(` • Have conversations and answer questions`);
|
|
86
|
+
console.log(` • Spin up Copilot CLI sessions to code, debug, and run commands`);
|
|
87
|
+
console.log(` • Manage multiple background tasks simultaneously`);
|
|
88
|
+
console.log(` • Maintain a shared engineering wiki of everything it learns about your team`);
|
|
89
|
+
console.log();
|
|
90
|
+
console.log(`${CYAN}Skills — teach Chapterhouse anything:${RESET}`);
|
|
91
|
+
console.log(` Chapterhouse has a skill system that lets it learn new capabilities. There's`);
|
|
92
|
+
console.log(` an open source library of community skills it can install, or it can`);
|
|
93
|
+
console.log(` write its own from scratch.`);
|
|
94
|
+
console.log();
|
|
95
|
+
await ask(rl, `${DIM}Press Enter to continue...${RESET}`);
|
|
96
|
+
console.log();
|
|
97
|
+
// ── Model picker ─────────────────────────────────────────
|
|
98
|
+
console.log(`\n${BOLD}━━━ Default Model ━━━${RESET}\n`);
|
|
99
|
+
console.log(`${DIM}Fetching available models from Copilot...${RESET}`);
|
|
100
|
+
let models = await fetchModels();
|
|
101
|
+
if (models.length === 0) {
|
|
102
|
+
console.log(`${YELLOW} Could not fetch models (Copilot CLI may not be authenticated yet).${RESET}`);
|
|
103
|
+
console.log(`${DIM} Showing a curated list — you can switch anytime after setup.${RESET}\n`);
|
|
104
|
+
models = FALLBACK_MODELS;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
console.log(`${GREEN} ✓ Found ${models.length} models${RESET}\n`);
|
|
108
|
+
}
|
|
109
|
+
console.log(`${DIM}You can switch models anytime in the web UI's Settings page.${RESET}\n`);
|
|
110
|
+
const currentModel = existing.COPILOT_MODEL || "claude-sonnet-4.6";
|
|
111
|
+
const model = await askPicker(rl, "Choose a default model:", models, currentModel);
|
|
112
|
+
const modelLabel = models.find((m) => m.id === model)?.label || model;
|
|
113
|
+
console.log(`\n${GREEN} ✓ Using ${modelLabel}${RESET}\n`);
|
|
114
|
+
// ── Write config ─────────────────────────────────────────
|
|
115
|
+
const apiPort = existing.API_PORT || "7788";
|
|
116
|
+
const lines = [];
|
|
117
|
+
lines.push(`API_PORT=${apiPort}`);
|
|
118
|
+
lines.push(`COPILOT_MODEL=${model}`);
|
|
119
|
+
writeFileSync(ENV_PATH, lines.join("\n") + "\n");
|
|
120
|
+
// ── Done ─────────────────────────────────────────────────
|
|
121
|
+
console.log(`
|
|
122
|
+
${GREEN}${BOLD}✅ Chapterhouse is ready!${RESET}
|
|
123
|
+
${DIM}Config saved to ${ENV_PATH}${RESET}
|
|
124
|
+
|
|
125
|
+
${BOLD}Get started:${RESET}
|
|
126
|
+
|
|
127
|
+
${CYAN}1.${RESET} Make sure Copilot CLI is authenticated:
|
|
128
|
+
${BOLD}copilot login${RESET}
|
|
129
|
+
|
|
130
|
+
${CYAN}2.${RESET} Start Chapterhouse:
|
|
131
|
+
${BOLD}chapterhouse start${RESET}
|
|
132
|
+
|
|
133
|
+
${CYAN}3.${RESET} Open the web UI:
|
|
134
|
+
${BOLD}http://localhost:${apiPort}${RESET}
|
|
135
|
+
|
|
136
|
+
${BOLD}Things to try:${RESET}
|
|
137
|
+
|
|
138
|
+
${DIM}"Start working on the auth bug in ${getExampleProjectPath()}"${RESET}
|
|
139
|
+
${DIM}"What sessions are running?"${RESET}
|
|
140
|
+
${DIM}"Find me a skill for checking Gmail"${RESET}
|
|
141
|
+
${DIM}"Switch to gpt-4.1"${RESET}
|
|
142
|
+
`);
|
|
143
|
+
rl.close();
|
|
144
|
+
}
|
|
145
|
+
main().catch((err) => {
|
|
146
|
+
console.error("Setup failed:", err);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
});
|
|
149
|
+
//# sourceMappingURL=setup.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function registerShutdownSignals(target, handler) {
|
|
2
|
+
target.on("SIGINT", handler);
|
|
3
|
+
target.on("SIGTERM", handler);
|
|
4
|
+
if (target.platform === "win32") {
|
|
5
|
+
try {
|
|
6
|
+
target.on("SIGBREAK", handler);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
// Some runtimes do not support SIGBREAK registration even on Windows.
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=shutdown-signals.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { registerShutdownSignals } from "./shutdown-signals.js";
|
|
4
|
+
test("registerShutdownSignals adds SIGBREAK on Windows", () => {
|
|
5
|
+
const seen = [];
|
|
6
|
+
registerShutdownSignals({
|
|
7
|
+
platform: "win32",
|
|
8
|
+
on(signal, listener) {
|
|
9
|
+
void listener;
|
|
10
|
+
seen.push(signal);
|
|
11
|
+
return this;
|
|
12
|
+
},
|
|
13
|
+
}, async () => { });
|
|
14
|
+
assert.deepEqual(seen, ["SIGINT", "SIGTERM", "SIGBREAK"]);
|
|
15
|
+
});
|
|
16
|
+
test("registerShutdownSignals ignores unsupported SIGBREAK registration errors", () => {
|
|
17
|
+
const seen = [];
|
|
18
|
+
assert.doesNotThrow(() => {
|
|
19
|
+
registerShutdownSignals({
|
|
20
|
+
platform: "win32",
|
|
21
|
+
on(signal, listener) {
|
|
22
|
+
void listener;
|
|
23
|
+
seen.push(signal);
|
|
24
|
+
if (signal === "SIGBREAK") {
|
|
25
|
+
throw new Error("unsupported");
|
|
26
|
+
}
|
|
27
|
+
return this;
|
|
28
|
+
},
|
|
29
|
+
}, async () => { });
|
|
30
|
+
});
|
|
31
|
+
assert.deepEqual(seen, ["SIGINT", "SIGTERM", "SIGBREAK"]);
|
|
32
|
+
});
|
|
33
|
+
//# sourceMappingURL=shutdown-signals.test.js.map
|