chapterhouse 0.3.1 → 0.3.3
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/README.md +73 -1
- package/dist/api/auth.js +4 -3
- package/dist/api/auth.test.js +27 -39
- package/dist/api/server.js +43 -2
- package/dist/api/team.js +4 -2
- package/dist/cli.js +8 -2
- package/dist/config.js +3 -0
- package/dist/copilot/episode-writer.js +4 -2
- package/dist/copilot/orchestrator.js +410 -356
- package/dist/copilot/orchestrator.test.js +244 -0
- package/dist/copilot/session-manager.js +337 -0
- package/dist/copilot/session-manager.test.js +358 -0
- package/dist/copilot/system-message.js +2 -1
- package/dist/copilot/system-message.test.js +8 -0
- package/dist/copilot/workiq-installer.js +91 -0
- package/dist/copilot/workiq-installer.test.js +148 -0
- package/dist/daemon.js +12 -1
- package/dist/integrations/teams-notify.js +3 -1
- package/dist/squad/index.js +1 -0
- package/dist/squad/init-cli.js +109 -0
- package/dist/squad/init.js +395 -0
- package/dist/squad/init.test.js +351 -0
- package/dist/squad/mirror.js +4 -2
- package/dist/squad/mirror.scheduler.js +9 -7
- package/dist/store/db.js +58 -5
- package/dist/store/db.test.js +69 -0
- package/dist/version.js +7 -0
- package/dist/wiki/team-sync.js +3 -1
- package/package.json +4 -3
- package/web/dist/assets/index-BkB7gY18.css +10 -0
- package/web/dist/assets/index-DSqc46G_.js +208 -0
- package/web/dist/assets/index-DSqc46G_.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CxT9905O.css +0 -10
- package/web/dist/assets/index-DI3rnGm-.js +0 -142
- package/web/dist/assets/index-DI3rnGm-.js.map +0 -1
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/squad/init.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for the Squad init scaffolding module.
|
|
5
|
+
*
|
|
6
|
+
* Coverage:
|
|
7
|
+
* - scaffoldSquad produces the expected .squad/ layout
|
|
8
|
+
* - isSquadInitialized correctly detects initialized/uninitialized dirs
|
|
9
|
+
* - Idempotence: scaffoldSquad returns null (does not clobber) on re-run
|
|
10
|
+
* - Dune universe character allocation
|
|
11
|
+
* - allocateCast uses the selected universe
|
|
12
|
+
* - resolveRoles clamps to valid presets
|
|
13
|
+
* - force flag overrides idempotence guard
|
|
14
|
+
*/
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { tmpdir as osTmpdir } from 'node:os';
|
|
19
|
+
import test from 'node:test';
|
|
20
|
+
import { scaffoldSquad, isSquadInitialized, allocateCast, resolveRoles, DUNE_CHARACTERS, UNIVERSE_CHARACTERS, DEFAULT_UNIVERSE, } from './init.js';
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
function makeTempDir() {
|
|
25
|
+
return mkdtempSync(join(osTmpdir(), 'ch-init-test-'));
|
|
26
|
+
}
|
|
27
|
+
function cleanup(dir) {
|
|
28
|
+
rmSync(dir, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Unit tests — pure functions
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
test('DUNE_CHARACTERS has at least 5 entries', () => {
|
|
34
|
+
assert.ok(DUNE_CHARACTERS.length >= 5, `Expected ≥ 5 Dune characters, got ${DUNE_CHARACTERS.length}`);
|
|
35
|
+
});
|
|
36
|
+
test('UNIVERSE_CHARACTERS includes Dune', () => {
|
|
37
|
+
assert.ok('Dune' in UNIVERSE_CHARACTERS, 'UNIVERSE_CHARACTERS must include "Dune"');
|
|
38
|
+
assert.equal(UNIVERSE_CHARACTERS['Dune'], DUNE_CHARACTERS);
|
|
39
|
+
});
|
|
40
|
+
test('DEFAULT_UNIVERSE is Dune', () => {
|
|
41
|
+
assert.equal(DEFAULT_UNIVERSE, 'Dune');
|
|
42
|
+
});
|
|
43
|
+
test('resolveRoles(4) returns 4 roles', () => {
|
|
44
|
+
const roles = resolveRoles(4);
|
|
45
|
+
assert.equal(roles.length, 4);
|
|
46
|
+
});
|
|
47
|
+
test('resolveRoles(3) returns 3 roles', () => {
|
|
48
|
+
assert.equal(resolveRoles(3).length, 3);
|
|
49
|
+
});
|
|
50
|
+
test('resolveRoles(5) returns 5 roles', () => {
|
|
51
|
+
assert.equal(resolveRoles(5).length, 5);
|
|
52
|
+
});
|
|
53
|
+
test('resolveRoles clamps values below 3 to 3', () => {
|
|
54
|
+
assert.equal(resolveRoles(1).length, 3);
|
|
55
|
+
});
|
|
56
|
+
test('resolveRoles clamps values above 5 to 5', () => {
|
|
57
|
+
assert.equal(resolveRoles(10).length, 5);
|
|
58
|
+
});
|
|
59
|
+
test('allocateCast assigns Dune names in order', () => {
|
|
60
|
+
const roles = resolveRoles(4);
|
|
61
|
+
const cast = allocateCast(roles, 'Dune');
|
|
62
|
+
assert.equal(Object.keys(cast).length, 4);
|
|
63
|
+
// First role maps to first Dune character
|
|
64
|
+
const firstRoleSlug = roles[0].slug;
|
|
65
|
+
assert.equal(cast[firstRoleSlug], DUNE_CHARACTERS[0]);
|
|
66
|
+
});
|
|
67
|
+
test('allocateCast uses provided universe', () => {
|
|
68
|
+
const roles = resolveRoles(3);
|
|
69
|
+
const cast = allocateCast(roles, 'Firefly');
|
|
70
|
+
const firfly = UNIVERSE_CHARACTERS['Firefly'];
|
|
71
|
+
const firstRoleSlug = roles[0].slug;
|
|
72
|
+
assert.equal(cast[firstRoleSlug], firfly[0]);
|
|
73
|
+
});
|
|
74
|
+
test('allocateCast falls back to Dune for unknown universe', () => {
|
|
75
|
+
const roles = resolveRoles(3);
|
|
76
|
+
const cast = allocateCast(roles, 'UnknownUniverse');
|
|
77
|
+
const firstRoleSlug = roles[0].slug;
|
|
78
|
+
assert.equal(cast[firstRoleSlug], DUNE_CHARACTERS[0]);
|
|
79
|
+
});
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// isSquadInitialized
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
test('isSquadInitialized returns false for empty dir', () => {
|
|
84
|
+
const dir = makeTempDir();
|
|
85
|
+
try {
|
|
86
|
+
assert.equal(isSquadInitialized(dir), false);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
cleanup(dir);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
test('isSquadInitialized returns false when team.md missing', () => {
|
|
93
|
+
const dir = makeTempDir();
|
|
94
|
+
try {
|
|
95
|
+
assert.equal(isSquadInitialized(dir), false);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
cleanup(dir);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// scaffoldSquad integration
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
const BASE_CONFIG = {
|
|
105
|
+
projectName: 'TestApp',
|
|
106
|
+
stack: 'TypeScript, Node.js',
|
|
107
|
+
goal: 'A test application',
|
|
108
|
+
teamSize: 4,
|
|
109
|
+
universe: 'Dune',
|
|
110
|
+
humanName: 'Alice',
|
|
111
|
+
};
|
|
112
|
+
test('scaffoldSquad returns ScaffoldResult with 4 cast agents', () => {
|
|
113
|
+
const dir = makeTempDir();
|
|
114
|
+
try {
|
|
115
|
+
const result = scaffoldSquad(dir, BASE_CONFIG);
|
|
116
|
+
assert.ok(result !== null, 'scaffoldSquad should return a result');
|
|
117
|
+
assert.equal(result.agents.length, 4, 'Should cast 4 agents');
|
|
118
|
+
assert.equal(result.universe, 'Dune');
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
cleanup(dir);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
test('scaffoldSquad creates .squad directory', () => {
|
|
125
|
+
const dir = makeTempDir();
|
|
126
|
+
try {
|
|
127
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
128
|
+
assert.ok(existsSync(join(dir, '.squad')), '.squad/ must exist');
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
cleanup(dir);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
test('scaffoldSquad creates team.md with ## Members section', () => {
|
|
135
|
+
const dir = makeTempDir();
|
|
136
|
+
try {
|
|
137
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
138
|
+
const teamMd = readFileSync(join(dir, '.squad', 'team.md'), 'utf-8');
|
|
139
|
+
assert.ok(teamMd.includes('## Members'), 'team.md must contain ## Members');
|
|
140
|
+
assert.ok(teamMd.includes('Scribe'), 'team.md must list Scribe');
|
|
141
|
+
assert.ok(teamMd.includes('Ralph'), 'team.md must list Ralph');
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
cleanup(dir);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
test('scaffoldSquad creates team.md with project info', () => {
|
|
148
|
+
const dir = makeTempDir();
|
|
149
|
+
try {
|
|
150
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
151
|
+
const teamMd = readFileSync(join(dir, '.squad', 'team.md'), 'utf-8');
|
|
152
|
+
assert.ok(teamMd.includes('TestApp'), 'team.md must contain project name');
|
|
153
|
+
assert.ok(teamMd.includes('Dune'), 'team.md must contain universe');
|
|
154
|
+
assert.ok(teamMd.includes('Alice'), 'team.md must contain human name');
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
cleanup(dir);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
test('scaffoldSquad creates routing.md', () => {
|
|
161
|
+
const dir = makeTempDir();
|
|
162
|
+
try {
|
|
163
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
164
|
+
assert.ok(existsSync(join(dir, '.squad', 'routing.md')));
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
cleanup(dir);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
test('scaffoldSquad creates ceremonies.md', () => {
|
|
171
|
+
const dir = makeTempDir();
|
|
172
|
+
try {
|
|
173
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
174
|
+
assert.ok(existsSync(join(dir, '.squad', 'ceremonies.md')));
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
cleanup(dir);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
test('scaffoldSquad creates decisions.md', () => {
|
|
181
|
+
const dir = makeTempDir();
|
|
182
|
+
try {
|
|
183
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
184
|
+
assert.ok(existsSync(join(dir, '.squad', 'decisions.md')));
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
cleanup(dir);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
test('scaffoldSquad creates agent charter.md for each cast agent', () => {
|
|
191
|
+
const dir = makeTempDir();
|
|
192
|
+
try {
|
|
193
|
+
const result = scaffoldSquad(dir, BASE_CONFIG);
|
|
194
|
+
for (const agent of result.agents) {
|
|
195
|
+
const charterPath = join(dir, '.squad', 'agents', agent.slug, 'charter.md');
|
|
196
|
+
assert.ok(existsSync(charterPath), `charter.md missing for ${agent.slug}`);
|
|
197
|
+
const content = readFileSync(charterPath, 'utf-8');
|
|
198
|
+
assert.ok(content.includes(agent.castName), `charter should mention ${agent.castName}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
cleanup(dir);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
test('scaffoldSquad creates agent history.md for each cast agent', () => {
|
|
206
|
+
const dir = makeTempDir();
|
|
207
|
+
try {
|
|
208
|
+
const result = scaffoldSquad(dir, BASE_CONFIG);
|
|
209
|
+
for (const agent of result.agents) {
|
|
210
|
+
const histPath = join(dir, '.squad', 'agents', agent.slug, 'history.md');
|
|
211
|
+
assert.ok(existsSync(histPath), `history.md missing for ${agent.slug}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
cleanup(dir);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
test('scaffoldSquad creates scribe and ralph agent dirs', () => {
|
|
219
|
+
const dir = makeTempDir();
|
|
220
|
+
try {
|
|
221
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
222
|
+
assert.ok(existsSync(join(dir, '.squad', 'agents', 'scribe', 'charter.md')));
|
|
223
|
+
assert.ok(existsSync(join(dir, '.squad', 'agents', 'ralph', 'charter.md')));
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
cleanup(dir);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
test('scaffoldSquad creates casting/policy.json', () => {
|
|
230
|
+
const dir = makeTempDir();
|
|
231
|
+
try {
|
|
232
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
233
|
+
const policyPath = join(dir, '.squad', 'casting', 'policy.json');
|
|
234
|
+
assert.ok(existsSync(policyPath));
|
|
235
|
+
const policy = JSON.parse(readFileSync(policyPath, 'utf-8'));
|
|
236
|
+
assert.ok(Array.isArray(policy.allowlist_universes));
|
|
237
|
+
assert.ok(policy.allowlist_universes.includes('Dune'));
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
cleanup(dir);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
test('scaffoldSquad creates casting/registry.json with all agents', () => {
|
|
244
|
+
const dir = makeTempDir();
|
|
245
|
+
try {
|
|
246
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
247
|
+
const reg = JSON.parse(readFileSync(join(dir, '.squad', 'casting', 'registry.json'), 'utf-8'));
|
|
248
|
+
assert.ok(reg.agents, 'registry must have agents key');
|
|
249
|
+
assert.ok('scribe' in reg.agents, 'scribe must be in registry');
|
|
250
|
+
assert.ok('ralph' in reg.agents, 'ralph must be in registry');
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
cleanup(dir);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
test('scaffoldSquad creates casting/history.json with assignment snapshot', () => {
|
|
257
|
+
const dir = makeTempDir();
|
|
258
|
+
try {
|
|
259
|
+
const result = scaffoldSquad(dir, BASE_CONFIG);
|
|
260
|
+
const hist = JSON.parse(readFileSync(join(dir, '.squad', 'casting', 'history.json'), 'utf-8'));
|
|
261
|
+
assert.ok(Array.isArray(hist.universe_usage_history));
|
|
262
|
+
assert.equal(hist.universe_usage_history[0].universe, 'Dune');
|
|
263
|
+
assert.ok(result.assignmentId in hist.assignment_cast_snapshots);
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
cleanup(dir);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
test('scaffoldSquad creates or updates .gitattributes', () => {
|
|
270
|
+
const dir = makeTempDir();
|
|
271
|
+
try {
|
|
272
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
273
|
+
const ga = readFileSync(join(dir, '.gitattributes'), 'utf-8');
|
|
274
|
+
assert.ok(ga.includes('.squad/decisions.md merge=union'), '.gitattributes must include union merge for decisions.md');
|
|
275
|
+
assert.ok(ga.includes('.squad/agents/*/history.md merge=union'));
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
cleanup(dir);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
test('scaffoldSquad is idempotent — returns null on second call', () => {
|
|
282
|
+
const dir = makeTempDir();
|
|
283
|
+
try {
|
|
284
|
+
const first = scaffoldSquad(dir, BASE_CONFIG);
|
|
285
|
+
assert.ok(first !== null, 'first call should succeed');
|
|
286
|
+
const second = scaffoldSquad(dir, BASE_CONFIG);
|
|
287
|
+
assert.equal(second, null, 'second call should return null (idempotent)');
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
cleanup(dir);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
test('scaffoldSquad with force:true overwrites existing scaffold', () => {
|
|
294
|
+
const dir = makeTempDir();
|
|
295
|
+
try {
|
|
296
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
297
|
+
const result = scaffoldSquad(dir, { ...BASE_CONFIG, projectName: 'Updated' }, { force: true });
|
|
298
|
+
assert.ok(result !== null, 'forced call should succeed');
|
|
299
|
+
const teamMd = readFileSync(join(dir, '.squad', 'team.md'), 'utf-8');
|
|
300
|
+
assert.ok(teamMd.includes('Updated'), 'team.md should reflect updated project name');
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
cleanup(dir);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
test('isSquadInitialized returns true after scaffoldSquad', () => {
|
|
307
|
+
const dir = makeTempDir();
|
|
308
|
+
try {
|
|
309
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
310
|
+
assert.equal(isSquadInitialized(dir), true);
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
cleanup(dir);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
test('scaffoldSquad uses Dune names from the canonical pool', () => {
|
|
317
|
+
const dir = makeTempDir();
|
|
318
|
+
try {
|
|
319
|
+
const result = scaffoldSquad(dir, BASE_CONFIG);
|
|
320
|
+
for (const agent of result.agents) {
|
|
321
|
+
assert.ok(DUNE_CHARACTERS.includes(agent.castName), `${agent.castName} is not in the Dune character pool`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
cleanup(dir);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
test('scaffoldSquad uses Firefly names when universe is Firefly', () => {
|
|
329
|
+
const dir = makeTempDir();
|
|
330
|
+
try {
|
|
331
|
+
const result = scaffoldSquad(dir, { ...BASE_CONFIG, universe: 'Firefly' });
|
|
332
|
+
const fireflyPool = UNIVERSE_CHARACTERS['Firefly'];
|
|
333
|
+
for (const agent of result.agents) {
|
|
334
|
+
assert.ok(fireflyPool.includes(agent.castName), `${agent.castName} is not in the Firefly pool`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
cleanup(dir);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
test('scaffoldSquad creates decisions/inbox directory', () => {
|
|
342
|
+
const dir = makeTempDir();
|
|
343
|
+
try {
|
|
344
|
+
scaffoldSquad(dir, BASE_CONFIG);
|
|
345
|
+
assert.ok(existsSync(join(dir, '.squad', 'decisions', 'inbox')));
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
cleanup(dir);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
//# sourceMappingURL=init.test.js.map
|
package/dist/squad/mirror.js
CHANGED
|
@@ -2,6 +2,8 @@ import { basename, join } from 'path';
|
|
|
2
2
|
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
3
3
|
import { readPage, writePage } from '../wiki/fs.js';
|
|
4
4
|
import { ensureWikiStructure } from '../wiki/fs.js';
|
|
5
|
+
import { childLogger } from '../util/logger.js';
|
|
6
|
+
const log = childLogger('squad.mirror');
|
|
5
7
|
/**
|
|
6
8
|
* Returns the wiki-relative path for a project's squad decisions page.
|
|
7
9
|
* e.g. "pages/projects/chapterhouse/decisions.md"
|
|
@@ -69,12 +71,12 @@ export async function syncDecisionsFileToWiki(projectRoot) {
|
|
|
69
71
|
// Visibility check: warn if inbox has unmerged drops
|
|
70
72
|
const inbox = await checkDecisionsInbox(projectRoot);
|
|
71
73
|
if (inbox.count > 0) {
|
|
72
|
-
|
|
74
|
+
log.warn({ projectRoot, count: inbox.count, files: inbox.files }, 'unmerged decision drops in inbox — run Scribe to merge');
|
|
73
75
|
}
|
|
74
76
|
return { entriesSynced, wikiPath };
|
|
75
77
|
}
|
|
76
78
|
catch (err) {
|
|
77
|
-
|
|
79
|
+
log.error({ err: err instanceof Error ? err.message : err }, 'syncDecisionsFileToWiki failed (non-fatal)');
|
|
78
80
|
return null;
|
|
79
81
|
}
|
|
80
82
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getDb } from '../store/db.js';
|
|
2
2
|
import { syncDecisionsFileToWiki } from './mirror.js';
|
|
3
|
+
import { childLogger } from '../util/logger.js';
|
|
4
|
+
const log = childLogger('squad.mirror-scheduler');
|
|
3
5
|
export const DEFAULT_DECISIONS_SYNC_INTERVAL_MS = 300_000; // 5 minutes
|
|
4
6
|
function readIntervalFromEnv() {
|
|
5
7
|
const raw = process.env.CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS;
|
|
@@ -17,7 +19,7 @@ function defaultGetRegisteredProjectRoots() {
|
|
|
17
19
|
return rows.map(r => r.project_root);
|
|
18
20
|
}
|
|
19
21
|
catch (err) {
|
|
20
|
-
|
|
22
|
+
log.error({ err: err instanceof Error ? err.message : err }, 'failed to query registered projects (non-fatal)');
|
|
21
23
|
return [];
|
|
22
24
|
}
|
|
23
25
|
}
|
|
@@ -38,12 +40,12 @@ export class DecisionsSyncScheduler {
|
|
|
38
40
|
/** Start the periodic sync. No-op if intervalMs <= 0. */
|
|
39
41
|
start() {
|
|
40
42
|
if (this.intervalMs <= 0) {
|
|
41
|
-
|
|
43
|
+
log.debug({ intervalMs: this.intervalMs }, 'disabled (intervalMs <= 0)');
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
46
|
if (this.handle !== undefined)
|
|
45
47
|
return; // already running
|
|
46
|
-
|
|
48
|
+
log.info({ intervalMs: this.intervalMs }, 'starting');
|
|
47
49
|
this.handle = this.setIntervalImpl(() => { void this.tick(); }, this.intervalMs);
|
|
48
50
|
// Allow Node.js to exit even if the interval is still active
|
|
49
51
|
this.handle?.unref?.();
|
|
@@ -53,24 +55,24 @@ export class DecisionsSyncScheduler {
|
|
|
53
55
|
if (this.handle !== undefined) {
|
|
54
56
|
this.clearIntervalImpl(this.handle);
|
|
55
57
|
this.handle = undefined;
|
|
56
|
-
|
|
58
|
+
log.info('stopped');
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
async tick() {
|
|
60
62
|
const projects = this.getRegisteredProjectRoots();
|
|
61
63
|
if (projects.length === 0)
|
|
62
64
|
return;
|
|
63
|
-
|
|
65
|
+
log.debug({ projects: projects.length }, 'syncing');
|
|
64
66
|
for (const root of projects) {
|
|
65
67
|
try {
|
|
66
68
|
const result = await this.syncFn(root);
|
|
67
69
|
if (result) {
|
|
68
|
-
|
|
70
|
+
log.debug({ entriesSynced: result.entriesSynced, root }, 'synced');
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
catch (err) {
|
|
72
74
|
// syncDecisionsFileToWiki already swallows errors; belt-and-suspenders here
|
|
73
|
-
|
|
75
|
+
log.error({ root, err: err instanceof Error ? err.message : err }, 'unexpected sync error (non-fatal)');
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
}
|
package/dist/store/db.js
CHANGED
|
@@ -150,6 +150,24 @@ export function getDb() {
|
|
|
150
150
|
if (!taskCols.some((c) => c.name === 'source')) {
|
|
151
151
|
db.exec(`ALTER TABLE agent_tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'adhoc'`);
|
|
152
152
|
}
|
|
153
|
+
// agent_task_events: append-only per-task tool-call activity log for /workers streaming
|
|
154
|
+
db.exec(`
|
|
155
|
+
CREATE TABLE IF NOT EXISTS agent_task_events (
|
|
156
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
157
|
+
task_id TEXT NOT NULL REFERENCES agent_tasks(task_id) ON DELETE CASCADE,
|
|
158
|
+
seq INTEGER NOT NULL,
|
|
159
|
+
ts INTEGER NOT NULL,
|
|
160
|
+
kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete')),
|
|
161
|
+
tool_name TEXT,
|
|
162
|
+
summary TEXT
|
|
163
|
+
)
|
|
164
|
+
`);
|
|
165
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_task_events_task_id ON agent_task_events(task_id, seq)`);
|
|
166
|
+
// Migrate: add event_seq column to agent_tasks for monotonic event numbering
|
|
167
|
+
const taskColsNow = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
|
|
168
|
+
if (!taskColsNow.some((c) => c.name === 'event_seq')) {
|
|
169
|
+
db.exec(`ALTER TABLE agent_tasks ADD COLUMN event_seq INTEGER NOT NULL DEFAULT 0`);
|
|
170
|
+
}
|
|
153
171
|
// Migrate: add last_used_at column to project_squads (epoch ms, nullable)
|
|
154
172
|
const projectCols = db.prepare(`PRAGMA table_info(project_squads)`).all();
|
|
155
173
|
if (!projectCols.some((c) => c.name === 'last_used_at')) {
|
|
@@ -313,11 +331,46 @@ export function getSessionMessages(sessionKey, limit) {
|
|
|
313
331
|
ts: r.ts,
|
|
314
332
|
}));
|
|
315
333
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
334
|
+
/**
|
|
335
|
+
* Append one event to agent_task_events and return the new event.
|
|
336
|
+
* Uses a transaction so seq is monotonically incremented.
|
|
337
|
+
* Non-fatal: silently ignores DB errors (task may not exist yet due to race).
|
|
338
|
+
*/
|
|
339
|
+
export function appendTaskEvent(taskId, kind, toolName, summary) {
|
|
340
|
+
const db = getDb();
|
|
341
|
+
try {
|
|
342
|
+
return db.transaction(() => {
|
|
343
|
+
db.prepare(`UPDATE agent_tasks SET event_seq = event_seq + 1 WHERE task_id = ?`).run(taskId);
|
|
344
|
+
const row = db.prepare(`SELECT event_seq FROM agent_tasks WHERE task_id = ?`).get(taskId);
|
|
345
|
+
if (!row)
|
|
346
|
+
return undefined;
|
|
347
|
+
const seq = row.event_seq;
|
|
348
|
+
const ts = Date.now();
|
|
349
|
+
const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary) VALUES (?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary);
|
|
350
|
+
return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary };
|
|
351
|
+
})();
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Return all events for a task ordered by seq ascending.
|
|
359
|
+
*/
|
|
360
|
+
export function getTaskEvents(taskId, afterSeq = 0) {
|
|
361
|
+
const db = getDb();
|
|
362
|
+
const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary
|
|
363
|
+
FROM agent_task_events WHERE task_id = ? AND seq > ? ORDER BY seq ASC`).all(taskId, afterSeq);
|
|
364
|
+
return rows.map((r) => ({
|
|
365
|
+
id: r.id,
|
|
366
|
+
taskId: r.task_id,
|
|
367
|
+
seq: r.seq,
|
|
368
|
+
ts: r.ts,
|
|
369
|
+
kind: r.kind,
|
|
370
|
+
toolName: r.tool_name,
|
|
371
|
+
summary: r.summary,
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
321
374
|
export function bumpProjectLastUsed(projectRoot) {
|
|
322
375
|
getDb()
|
|
323
376
|
.prepare(`UPDATE project_squads SET last_used_at = ? WHERE project_root = ?`)
|
package/dist/store/db.test.js
CHANGED
|
@@ -211,4 +211,73 @@ test("bumpProjectLastUsed is a no-op for unknown project_root (no throw)", async
|
|
|
211
211
|
dbModule.closeDb();
|
|
212
212
|
}
|
|
213
213
|
});
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// #86: agent_task_events — appendTaskEvent and getTaskEvents
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
test("#86: appendTaskEvent inserts a row and getTaskEvents returns it ordered by seq", async () => {
|
|
218
|
+
const dbModule = await loadDbModule();
|
|
219
|
+
try {
|
|
220
|
+
const db = dbModule.getDb();
|
|
221
|
+
// Insert a parent task row
|
|
222
|
+
db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES ('task-sse-001', 'kaylee', 'Fix streaming', 'running')`).run();
|
|
223
|
+
const ev1 = dbModule.appendTaskEvent("task-sse-001", "tool_start", "bash", "npm run build");
|
|
224
|
+
assert.ok(ev1, "appendTaskEvent must return the inserted event");
|
|
225
|
+
assert.equal(ev1.kind, "tool_start");
|
|
226
|
+
assert.equal(ev1.toolName, "bash");
|
|
227
|
+
assert.equal(ev1.summary, "npm run build");
|
|
228
|
+
assert.equal(ev1.seq, 1);
|
|
229
|
+
const ev2 = dbModule.appendTaskEvent("task-sse-001", "tool_complete", null, "ok");
|
|
230
|
+
assert.ok(ev2, "second appendTaskEvent must return a second event");
|
|
231
|
+
assert.equal(ev2.seq, 2, "seq must be monotonically incremented");
|
|
232
|
+
assert.equal(ev2.kind, "tool_complete");
|
|
233
|
+
const events = dbModule.getTaskEvents("task-sse-001");
|
|
234
|
+
assert.equal(events.length, 2, "getTaskEvents must return 2 events");
|
|
235
|
+
assert.equal(events[0].seq, 1);
|
|
236
|
+
assert.equal(events[1].seq, 2);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
dbModule.closeDb();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
test("#86: getTaskEvents with afterSeq filters earlier events", async () => {
|
|
243
|
+
const dbModule = await loadDbModule();
|
|
244
|
+
try {
|
|
245
|
+
const db = dbModule.getDb();
|
|
246
|
+
db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES ('task-sse-002', 'wash', 'UI fix', 'running')`).run();
|
|
247
|
+
dbModule.appendTaskEvent("task-sse-002", "tool_start", "view", "/some/file");
|
|
248
|
+
dbModule.appendTaskEvent("task-sse-002", "tool_complete", null, "ok");
|
|
249
|
+
dbModule.appendTaskEvent("task-sse-002", "tool_start", "bash", "git push");
|
|
250
|
+
const all = dbModule.getTaskEvents("task-sse-002");
|
|
251
|
+
assert.equal(all.length, 3, "all 3 events expected");
|
|
252
|
+
const afterFirst = dbModule.getTaskEvents("task-sse-002", 1);
|
|
253
|
+
assert.equal(afterFirst.length, 2, "afterSeq=1 must return only events with seq > 1");
|
|
254
|
+
assert.equal(afterFirst[0].seq, 2);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
dbModule.closeDb();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
test("#86: appendTaskEvent returns undefined (non-fatal) for unknown task_id", async () => {
|
|
261
|
+
const dbModule = await loadDbModule();
|
|
262
|
+
try {
|
|
263
|
+
dbModule.getDb();
|
|
264
|
+
const result = dbModule.appendTaskEvent("no-such-task", "tool_start", "bash", "echo hi");
|
|
265
|
+
assert.equal(result, undefined, "appendTaskEvent must return undefined for unknown task_id");
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
dbModule.closeDb();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
test("#86: agent_task_events table exists in schema after getDb()", async () => {
|
|
272
|
+
const dbModule = await loadDbModule();
|
|
273
|
+
try {
|
|
274
|
+
const db = dbModule.getDb();
|
|
275
|
+
const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all();
|
|
276
|
+
const tableNames = new Set(tables.map((r) => r.name));
|
|
277
|
+
assert.ok(tableNames.has("agent_task_events"), "agent_task_events table must exist");
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
dbModule.closeDb();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
214
283
|
//# sourceMappingURL=db.test.js.map
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
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 pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
6
|
+
export const CHAPTERHOUSE_VERSION = pkg.version;
|
|
7
|
+
//# sourceMappingURL=version.js.map
|
package/dist/wiki/team-sync.js
CHANGED
|
@@ -4,6 +4,8 @@ import { config } from "../config.js";
|
|
|
4
4
|
import { WIKI_DIR } from "../paths.js";
|
|
5
5
|
import { assertPagePath, readPage, writePage, writeFileAtomic } from "./fs.js";
|
|
6
6
|
import { addToIndex, buildIndexEntryForPage } from "./index-manager.js";
|
|
7
|
+
import { childLogger } from "../util/logger.js";
|
|
8
|
+
const log = childLogger("team-sync");
|
|
7
9
|
export class TeamWikiSync {
|
|
8
10
|
teamChapterhouseUrl;
|
|
9
11
|
teamChapterhouseToken;
|
|
@@ -28,7 +30,7 @@ export class TeamWikiSync {
|
|
|
28
30
|
this.cacheRoot = join(this.wikiDir, ".team-cache");
|
|
29
31
|
this.manifestPath = join(this.cacheRoot, "manifest.json");
|
|
30
32
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
31
|
-
this.warn = options.warn ?? ((message) =>
|
|
33
|
+
this.warn = options.warn ?? ((message) => log.warn(message));
|
|
32
34
|
this.now = options.now ?? (() => new Date());
|
|
33
35
|
}
|
|
34
36
|
isEnabled() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chapterhouse",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"chapterhouse": "dist/cli.js"
|
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
"dev:server": "tsx --watch src/daemon.ts",
|
|
23
23
|
"dev:web": "npm --prefix web run dev",
|
|
24
24
|
"dev": "tsx --watch src/daemon.ts",
|
|
25
|
+
"lint:md": "markdownlint-cli2 'README.md' 'CHANGELOG.md' 'docs/**/*.md' '.github/**/*.md'",
|
|
25
26
|
"release:check": "if [ -n \"$(git status --porcelain)\" ]; then echo '❌ Working tree is not clean. Stage or stash changes before running npm version.'; git status --short; exit 1; fi",
|
|
26
27
|
"preversion": "npm run release:check",
|
|
27
28
|
"prepare": "husky",
|
|
28
29
|
"test": "npm run clean && npm run build:server && node --experimental-test-module-mocks --import ./dist/test/setup-env.js --test 'dist/**/*.test.js'",
|
|
29
|
-
"prepublishOnly": "npm run build"
|
|
30
|
-
"prepare": "husky"
|
|
30
|
+
"prepublishOnly": "npm run build"
|
|
31
31
|
},
|
|
32
32
|
"engines": {
|
|
33
33
|
"node": ">=22.5.0"
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"@types/jsonwebtoken": "^9.0.10",
|
|
75
75
|
"@types/node": "^25.6.0",
|
|
76
76
|
"husky": "^9.1.7",
|
|
77
|
+
"markdownlint-cli2": "^0.22.1",
|
|
77
78
|
"tsx": "^4.21.0",
|
|
78
79
|
"typescript": "^5.9.3"
|
|
79
80
|
}
|