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,81 @@
|
|
|
1
|
+
import { basename, join } from 'path';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
3
|
+
import { readPage, writePage } from '../wiki/fs.js';
|
|
4
|
+
import { ensureWikiStructure } from '../wiki/fs.js';
|
|
5
|
+
/**
|
|
6
|
+
* Returns the wiki-relative path for a project's squad decisions page.
|
|
7
|
+
* e.g. "pages/projects/chapterhouse/decisions.md"
|
|
8
|
+
*/
|
|
9
|
+
export function projectDecisionWikiPath(projectRoot) {
|
|
10
|
+
const slug = basename(projectRoot).toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
11
|
+
return `pages/projects/${slug}/decisions.md`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Append a decision entry to the project's wiki decisions page.
|
|
15
|
+
* Returns the wiki page path that was written.
|
|
16
|
+
*
|
|
17
|
+
* Only call this on successful task completion — the caller is responsible for
|
|
18
|
+
* not invoking on failure.
|
|
19
|
+
*/
|
|
20
|
+
export async function mirrorDecisionToWiki(link, taskSummary, resultSummary) {
|
|
21
|
+
ensureWikiStructure();
|
|
22
|
+
const wikiPath = projectDecisionWikiPath(link.projectRoot);
|
|
23
|
+
const dateStr = new Date().toISOString().slice(0, 10);
|
|
24
|
+
const existing = readPage(wikiPath) ?? `# Squad Decisions — ${basename(link.projectRoot)}\n\n`;
|
|
25
|
+
const entry = `## ${dateStr}\n- **Agent:** @${link.squadAgentSlug} | **Task:** ${taskSummary} | **Result:** ${resultSummary}\n`;
|
|
26
|
+
// Append entry, ensuring a blank line separator
|
|
27
|
+
const separator = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
|
|
28
|
+
const updated = existing + separator + entry;
|
|
29
|
+
writePage(wikiPath, updated);
|
|
30
|
+
return wikiPath;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Check the decisions inbox for unmerged decision drops.
|
|
34
|
+
* Returns count and filenames — never throws.
|
|
35
|
+
*/
|
|
36
|
+
export async function checkDecisionsInbox(projectRoot) {
|
|
37
|
+
const inboxPath = join(projectRoot, '.squad', 'decisions', 'inbox');
|
|
38
|
+
try {
|
|
39
|
+
if (!existsSync(inboxPath))
|
|
40
|
+
return { count: 0, files: [] };
|
|
41
|
+
const files = readdirSync(inboxPath).filter(f => f.endsWith('.md'));
|
|
42
|
+
return { count: files.length, files };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { count: 0, files: [] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Sync the content of <projectRoot>/.squad/decisions.md verbatim to the
|
|
50
|
+
* project's wiki decisions page. decisions.md is the source of truth — the
|
|
51
|
+
* wiki page is overwritten on every call.
|
|
52
|
+
*
|
|
53
|
+
* Returns null when decisions.md does not exist (project may not be squad-
|
|
54
|
+
* initialized); never throws — errors are logged and the caller is unaffected.
|
|
55
|
+
*/
|
|
56
|
+
export async function syncDecisionsFileToWiki(projectRoot) {
|
|
57
|
+
const decisionsPath = join(projectRoot, '.squad', 'decisions.md');
|
|
58
|
+
try {
|
|
59
|
+
if (!existsSync(decisionsPath))
|
|
60
|
+
return null;
|
|
61
|
+
const source = readFileSync(decisionsPath, 'utf-8');
|
|
62
|
+
ensureWikiStructure();
|
|
63
|
+
const wikiPath = projectDecisionWikiPath(projectRoot);
|
|
64
|
+
const timestamp = new Date().toISOString();
|
|
65
|
+
const header = `<!-- Last synced: ${timestamp} -->\n\n`;
|
|
66
|
+
writePage(wikiPath, header + source);
|
|
67
|
+
// Count entries as second-level headings (## …) in the decisions file
|
|
68
|
+
const entriesSynced = (source.match(/^##\s/gm) ?? []).length;
|
|
69
|
+
// Visibility check: warn if inbox has unmerged drops
|
|
70
|
+
const inbox = await checkDecisionsInbox(projectRoot);
|
|
71
|
+
if (inbox.count > 0) {
|
|
72
|
+
console.warn(`[squad] Project at ${projectRoot} has ${inbox.count} unmerged decision drops in inbox: ${inbox.files.join(', ')}. Run Scribe to merge.`);
|
|
73
|
+
}
|
|
74
|
+
return { entriesSynced, wikiPath };
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
console.error('[squad] syncDecisionsFileToWiki failed (non-fatal):', err instanceof Error ? err.message : err);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=mirror.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { getDb } from '../store/db.js';
|
|
2
|
+
import { syncDecisionsFileToWiki } from './mirror.js';
|
|
3
|
+
export const DEFAULT_DECISIONS_SYNC_INTERVAL_MS = 300_000; // 5 minutes
|
|
4
|
+
function readIntervalFromEnv() {
|
|
5
|
+
const raw = process.env.CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS;
|
|
6
|
+
if (raw === undefined)
|
|
7
|
+
return DEFAULT_DECISIONS_SYNC_INTERVAL_MS;
|
|
8
|
+
const parsed = Number(raw);
|
|
9
|
+
return isNaN(parsed) ? DEFAULT_DECISIONS_SYNC_INTERVAL_MS : parsed;
|
|
10
|
+
}
|
|
11
|
+
function defaultGetRegisteredProjectRoots() {
|
|
12
|
+
try {
|
|
13
|
+
const db = getDb();
|
|
14
|
+
const rows = db
|
|
15
|
+
.prepare(`SELECT project_root FROM project_squads WHERE registered = 1`)
|
|
16
|
+
.all();
|
|
17
|
+
return rows.map(r => r.project_root);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
console.error('[squad] DecisionsSyncScheduler: failed to query registered projects (non-fatal):', err instanceof Error ? err.message : err);
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class DecisionsSyncScheduler {
|
|
25
|
+
intervalMs;
|
|
26
|
+
getRegisteredProjectRoots;
|
|
27
|
+
syncFn;
|
|
28
|
+
setIntervalImpl;
|
|
29
|
+
clearIntervalImpl;
|
|
30
|
+
handle;
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.intervalMs = options.intervalMs ?? readIntervalFromEnv();
|
|
33
|
+
this.getRegisteredProjectRoots = options.getRegisteredProjectRoots ?? defaultGetRegisteredProjectRoots;
|
|
34
|
+
this.syncFn = options.syncFn ?? syncDecisionsFileToWiki;
|
|
35
|
+
this.setIntervalImpl = options.setIntervalImpl ?? setInterval;
|
|
36
|
+
this.clearIntervalImpl = options.clearIntervalImpl ?? clearInterval;
|
|
37
|
+
}
|
|
38
|
+
/** Start the periodic sync. No-op if intervalMs <= 0. */
|
|
39
|
+
start() {
|
|
40
|
+
if (this.intervalMs <= 0) {
|
|
41
|
+
console.log('[squad] DecisionsSyncScheduler: disabled (intervalMs <= 0)');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (this.handle !== undefined)
|
|
45
|
+
return; // already running
|
|
46
|
+
console.log(`[squad] DecisionsSyncScheduler: starting, interval=${this.intervalMs}ms`);
|
|
47
|
+
this.handle = this.setIntervalImpl(() => { void this.tick(); }, this.intervalMs);
|
|
48
|
+
// Allow Node.js to exit even if the interval is still active
|
|
49
|
+
this.handle?.unref?.();
|
|
50
|
+
}
|
|
51
|
+
/** Stop the periodic sync and clear the timer. */
|
|
52
|
+
stop() {
|
|
53
|
+
if (this.handle !== undefined) {
|
|
54
|
+
this.clearIntervalImpl(this.handle);
|
|
55
|
+
this.handle = undefined;
|
|
56
|
+
console.log('[squad] DecisionsSyncScheduler: stopped');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async tick() {
|
|
60
|
+
const projects = this.getRegisteredProjectRoots();
|
|
61
|
+
if (projects.length === 0)
|
|
62
|
+
return;
|
|
63
|
+
console.log(`[squad] DecisionsSyncScheduler: syncing ${projects.length} project(s)`);
|
|
64
|
+
for (const root of projects) {
|
|
65
|
+
try {
|
|
66
|
+
const result = await this.syncFn(root);
|
|
67
|
+
if (result) {
|
|
68
|
+
console.log(`[squad] DecisionsSyncScheduler: synced ${result.entriesSynced} entries for ${root}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
// syncDecisionsFileToWiki already swallows errors; belt-and-suspenders here
|
|
73
|
+
console.error(`[squad] DecisionsSyncScheduler: unexpected error for ${root}:`, err instanceof Error ? err.message : err);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=mirror.scheduler.js.map
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { DecisionsSyncScheduler, DEFAULT_DECISIONS_SYNC_INTERVAL_MS } from './mirror.scheduler.js';
|
|
4
|
+
function makeFakeInterval() {
|
|
5
|
+
let nextId = 1;
|
|
6
|
+
const timers = new Map();
|
|
7
|
+
const setIntervalImpl = (cb, ms) => {
|
|
8
|
+
const id = nextId++;
|
|
9
|
+
timers.set(id, { cb, ms });
|
|
10
|
+
return { id, unref: () => { } };
|
|
11
|
+
};
|
|
12
|
+
const clearIntervalImpl = (handle) => {
|
|
13
|
+
timers.delete(handle.id);
|
|
14
|
+
};
|
|
15
|
+
const tick = () => {
|
|
16
|
+
for (const { cb } of timers.values())
|
|
17
|
+
cb();
|
|
18
|
+
};
|
|
19
|
+
const activeCount = () => timers.size;
|
|
20
|
+
return { setIntervalImpl, clearIntervalImpl, tick, activeCount };
|
|
21
|
+
}
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Tests
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
test('DecisionsSyncScheduler: default interval matches 5 minutes', () => {
|
|
26
|
+
assert.equal(DEFAULT_DECISIONS_SYNC_INTERVAL_MS, 300_000);
|
|
27
|
+
});
|
|
28
|
+
test('DecisionsSyncScheduler: start registers a timer with the configured interval', () => {
|
|
29
|
+
const intervals = [];
|
|
30
|
+
const handles = [];
|
|
31
|
+
const setIntervalImpl = (cb, ms) => {
|
|
32
|
+
intervals.push(ms);
|
|
33
|
+
const handle = { id: 1, unref: () => { } };
|
|
34
|
+
handles.push(handle);
|
|
35
|
+
return handle;
|
|
36
|
+
};
|
|
37
|
+
const clearIntervalImpl = (_h) => { };
|
|
38
|
+
const scheduler = new DecisionsSyncScheduler({
|
|
39
|
+
intervalMs: 60_000,
|
|
40
|
+
getRegisteredProjectRoots: () => [],
|
|
41
|
+
syncFn: async () => null,
|
|
42
|
+
setIntervalImpl: setIntervalImpl,
|
|
43
|
+
clearIntervalImpl: clearIntervalImpl,
|
|
44
|
+
});
|
|
45
|
+
scheduler.start();
|
|
46
|
+
assert.equal(intervals.length, 1, 'should register exactly one interval');
|
|
47
|
+
assert.equal(intervals[0], 60_000, 'interval should match configured value');
|
|
48
|
+
});
|
|
49
|
+
test('DecisionsSyncScheduler: does not register a timer when intervalMs <= 0', () => {
|
|
50
|
+
let registered = 0;
|
|
51
|
+
const setIntervalImpl = (_cb, _ms) => {
|
|
52
|
+
registered++;
|
|
53
|
+
return { id: 1 };
|
|
54
|
+
};
|
|
55
|
+
const clearIntervalImpl = (_h) => { };
|
|
56
|
+
const scheduler = new DecisionsSyncScheduler({
|
|
57
|
+
intervalMs: 0,
|
|
58
|
+
getRegisteredProjectRoots: () => ['/some/project'],
|
|
59
|
+
syncFn: async () => null,
|
|
60
|
+
setIntervalImpl: setIntervalImpl,
|
|
61
|
+
clearIntervalImpl: clearIntervalImpl,
|
|
62
|
+
});
|
|
63
|
+
scheduler.start();
|
|
64
|
+
assert.equal(registered, 0, 'should not register any interval when disabled');
|
|
65
|
+
});
|
|
66
|
+
test('DecisionsSyncScheduler: tick calls syncFn once per registered project', async () => {
|
|
67
|
+
const synced = [];
|
|
68
|
+
const scheduler = new DecisionsSyncScheduler({
|
|
69
|
+
intervalMs: 60_000,
|
|
70
|
+
getRegisteredProjectRoots: () => ['/project/alpha', '/project/beta', '/project/gamma'],
|
|
71
|
+
syncFn: async (root) => { synced.push(root); return { entriesSynced: 1, wikiPath: 'pages/x.md' }; },
|
|
72
|
+
setIntervalImpl: setInterval,
|
|
73
|
+
clearIntervalImpl: clearInterval,
|
|
74
|
+
});
|
|
75
|
+
await scheduler.tick();
|
|
76
|
+
assert.deepEqual(synced, ['/project/alpha', '/project/beta', '/project/gamma'], 'should call syncFn once per project in order');
|
|
77
|
+
});
|
|
78
|
+
test('DecisionsSyncScheduler: tick calls syncFn on each interval tick', async () => {
|
|
79
|
+
const { setIntervalImpl, clearIntervalImpl, tick } = makeFakeInterval();
|
|
80
|
+
const syncCalls = [];
|
|
81
|
+
const scheduler = new DecisionsSyncScheduler({
|
|
82
|
+
intervalMs: 60_000,
|
|
83
|
+
getRegisteredProjectRoots: () => ['/project/alpha', '/project/beta'],
|
|
84
|
+
syncFn: async (root) => { syncCalls.push(root); return null; },
|
|
85
|
+
setIntervalImpl: setIntervalImpl,
|
|
86
|
+
clearIntervalImpl: clearIntervalImpl,
|
|
87
|
+
});
|
|
88
|
+
scheduler.start();
|
|
89
|
+
// Simulate two interval ticks — we need to await async propagation
|
|
90
|
+
tick();
|
|
91
|
+
await new Promise(r => setImmediate(r));
|
|
92
|
+
tick();
|
|
93
|
+
await new Promise(r => setImmediate(r));
|
|
94
|
+
// Two ticks × two projects = 4 sync calls
|
|
95
|
+
assert.equal(syncCalls.length, 4, 'should call syncFn 2 projects × 2 ticks = 4 times');
|
|
96
|
+
assert.ok(syncCalls.filter(r => r === '/project/alpha').length === 2, 'alpha synced twice');
|
|
97
|
+
assert.ok(syncCalls.filter(r => r === '/project/beta').length === 2, 'beta synced twice');
|
|
98
|
+
});
|
|
99
|
+
test('DecisionsSyncScheduler: stop clears the timer', () => {
|
|
100
|
+
const { setIntervalImpl, clearIntervalImpl, activeCount } = makeFakeInterval();
|
|
101
|
+
const scheduler = new DecisionsSyncScheduler({
|
|
102
|
+
intervalMs: 60_000,
|
|
103
|
+
getRegisteredProjectRoots: () => ['/project/x'],
|
|
104
|
+
syncFn: async () => null,
|
|
105
|
+
setIntervalImpl: setIntervalImpl,
|
|
106
|
+
clearIntervalImpl: clearIntervalImpl,
|
|
107
|
+
});
|
|
108
|
+
scheduler.start();
|
|
109
|
+
assert.equal(activeCount(), 1, 'timer should be registered after start');
|
|
110
|
+
scheduler.stop();
|
|
111
|
+
assert.equal(activeCount(), 0, 'timer should be cleared after stop');
|
|
112
|
+
});
|
|
113
|
+
test('DecisionsSyncScheduler: stop is idempotent', () => {
|
|
114
|
+
const { setIntervalImpl, clearIntervalImpl, activeCount } = makeFakeInterval();
|
|
115
|
+
const scheduler = new DecisionsSyncScheduler({
|
|
116
|
+
intervalMs: 60_000,
|
|
117
|
+
getRegisteredProjectRoots: () => [],
|
|
118
|
+
syncFn: async () => null,
|
|
119
|
+
setIntervalImpl: setIntervalImpl,
|
|
120
|
+
clearIntervalImpl: clearIntervalImpl,
|
|
121
|
+
});
|
|
122
|
+
scheduler.start();
|
|
123
|
+
scheduler.stop();
|
|
124
|
+
scheduler.stop(); // second stop should not throw
|
|
125
|
+
assert.equal(activeCount(), 0, 'timer count still 0 after double stop');
|
|
126
|
+
});
|
|
127
|
+
test('DecisionsSyncScheduler: tick skips sync when no projects registered', async () => {
|
|
128
|
+
let syncCount = 0;
|
|
129
|
+
const scheduler = new DecisionsSyncScheduler({
|
|
130
|
+
intervalMs: 60_000,
|
|
131
|
+
getRegisteredProjectRoots: () => [],
|
|
132
|
+
syncFn: async () => { syncCount++; return null; },
|
|
133
|
+
setIntervalImpl: setInterval,
|
|
134
|
+
clearIntervalImpl: clearInterval,
|
|
135
|
+
});
|
|
136
|
+
await scheduler.tick();
|
|
137
|
+
assert.equal(syncCount, 0, 'should not call syncFn when there are no registered projects');
|
|
138
|
+
});
|
|
139
|
+
test('DecisionsSyncScheduler: tick is resilient — continues after syncFn throws', async () => {
|
|
140
|
+
const synced = [];
|
|
141
|
+
const scheduler = new DecisionsSyncScheduler({
|
|
142
|
+
intervalMs: 60_000,
|
|
143
|
+
getRegisteredProjectRoots: () => ['/project/throws', '/project/ok'],
|
|
144
|
+
syncFn: async (root) => {
|
|
145
|
+
if (root === '/project/throws')
|
|
146
|
+
throw new Error('boom');
|
|
147
|
+
synced.push(root);
|
|
148
|
+
return { entriesSynced: 1, wikiPath: 'pages/ok.md' };
|
|
149
|
+
},
|
|
150
|
+
setIntervalImpl: setInterval,
|
|
151
|
+
clearIntervalImpl: clearInterval,
|
|
152
|
+
});
|
|
153
|
+
await scheduler.tick();
|
|
154
|
+
assert.deepEqual(synced, ['/project/ok'], 'should continue syncing after an error in one project');
|
|
155
|
+
});
|
|
156
|
+
test('DecisionsSyncScheduler: start is idempotent — only one timer registered', () => {
|
|
157
|
+
const { setIntervalImpl, clearIntervalImpl, activeCount } = makeFakeInterval();
|
|
158
|
+
const scheduler = new DecisionsSyncScheduler({
|
|
159
|
+
intervalMs: 60_000,
|
|
160
|
+
getRegisteredProjectRoots: () => [],
|
|
161
|
+
syncFn: async () => null,
|
|
162
|
+
setIntervalImpl: setIntervalImpl,
|
|
163
|
+
clearIntervalImpl: clearIntervalImpl,
|
|
164
|
+
});
|
|
165
|
+
scheduler.start();
|
|
166
|
+
scheduler.start(); // second start should be a no-op
|
|
167
|
+
assert.equal(activeCount(), 1, 'only one timer should be registered after double start');
|
|
168
|
+
scheduler.stop();
|
|
169
|
+
});
|
|
170
|
+
test('DecisionsSyncScheduler: reads interval from CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS env var', () => {
|
|
171
|
+
const original = process.env.CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS;
|
|
172
|
+
process.env.CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS = '12345';
|
|
173
|
+
try {
|
|
174
|
+
const intervals = [];
|
|
175
|
+
const scheduler = new DecisionsSyncScheduler({
|
|
176
|
+
getRegisteredProjectRoots: () => [],
|
|
177
|
+
syncFn: async () => null,
|
|
178
|
+
setIntervalImpl: ((_cb, ms) => {
|
|
179
|
+
intervals.push(ms);
|
|
180
|
+
return { id: 99, unref: () => { } };
|
|
181
|
+
}),
|
|
182
|
+
clearIntervalImpl: (_h) => { },
|
|
183
|
+
});
|
|
184
|
+
scheduler.start();
|
|
185
|
+
assert.equal(intervals[0], 12345, 'should use env var value for interval');
|
|
186
|
+
scheduler.stop();
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
if (original === undefined) {
|
|
190
|
+
delete process.env.CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
process.env.CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS = original;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
//# sourceMappingURL=mirror.scheduler.test.js.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
const repoRoot = process.cwd();
|
|
6
|
+
const sandboxRoot = join(repoRoot, ".test-work", `squad-mirror-${process.pid}`);
|
|
7
|
+
// Point CHAPTERHOUSE_HOME at sandbox so wiki writes land in an isolated location
|
|
8
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
9
|
+
const mockProjectPath = join(repoRoot, "src/test/fixtures/mock-squad-repo");
|
|
10
|
+
const mockLink = {
|
|
11
|
+
taskId: "task-001",
|
|
12
|
+
projectRoot: mockProjectPath,
|
|
13
|
+
squadAgentSlug: "ripley",
|
|
14
|
+
wikiDecisionPath: `pages/projects/${basename(mockProjectPath).toLowerCase().replace(/[^a-z0-9-]/g, "-")}/decisions.md`,
|
|
15
|
+
};
|
|
16
|
+
async function loadMirror() {
|
|
17
|
+
return await import(new URL(`./mirror.js?v=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
18
|
+
}
|
|
19
|
+
test.before(() => {
|
|
20
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
21
|
+
mkdirSync(join(sandboxRoot, ".chapterhouse"), { recursive: true });
|
|
22
|
+
});
|
|
23
|
+
test.after(() => {
|
|
24
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
test("projectDecisionWikiPath returns path matching pages/projects/{basename}/decisions.md", async () => {
|
|
27
|
+
const m = await loadMirror();
|
|
28
|
+
assert.equal(typeof m.projectDecisionWikiPath, "function", "projectDecisionWikiPath should be exported");
|
|
29
|
+
const projectRoot = "/home/user/my-cool-project";
|
|
30
|
+
const result = m.projectDecisionWikiPath(projectRoot);
|
|
31
|
+
const expectedBasename = basename(projectRoot).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
32
|
+
assert.ok(typeof result === "string" && result.startsWith("pages/projects/"), `expected path to start with 'pages/projects/', got: ${result}`);
|
|
33
|
+
assert.ok(result.includes(expectedBasename), `expected path to include project basename '${expectedBasename}', got: ${result}`);
|
|
34
|
+
assert.ok(result.endsWith("decisions.md"), `expected path to end with 'decisions.md', got: ${result}`);
|
|
35
|
+
});
|
|
36
|
+
test("mirrorDecisionToWiki creates a wiki page if it doesn't exist", async () => {
|
|
37
|
+
const m = await loadMirror();
|
|
38
|
+
assert.equal(typeof m.mirrorDecisionToWiki, "function", "mirrorDecisionToWiki should be exported");
|
|
39
|
+
// Clean state for this test
|
|
40
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
41
|
+
mkdirSync(join(sandboxRoot, ".chapterhouse"), { recursive: true });
|
|
42
|
+
const wikiPath = await m.mirrorDecisionToWiki(mockLink, "Designed module layout", "Adopted layered architecture");
|
|
43
|
+
assert.ok(typeof wikiPath === "string" && wikiPath.length > 0, "should return a wiki path");
|
|
44
|
+
const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
|
|
45
|
+
const fullPath = join(wikiDir, ...wikiPath.split("/"));
|
|
46
|
+
assert.ok(existsSync(fullPath), `wiki page should exist at ${fullPath}`);
|
|
47
|
+
});
|
|
48
|
+
test("mirrorDecisionToWiki appends to an existing wiki page", async () => {
|
|
49
|
+
const m = await loadMirror();
|
|
50
|
+
await m.mirrorDecisionToWiki(mockLink, "First decision", "First result");
|
|
51
|
+
const dallasLink = { ...mockLink, taskId: "task-002", squadAgentSlug: "dallas" };
|
|
52
|
+
await m.mirrorDecisionToWiki(dallasLink, "Second decision", "Second result");
|
|
53
|
+
const wikiPath = m.projectDecisionWikiPath(mockProjectPath);
|
|
54
|
+
const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
|
|
55
|
+
const fullPath = join(wikiDir, ...wikiPath.split("/"));
|
|
56
|
+
assert.ok(existsSync(fullPath), `wiki page should exist at ${fullPath}`);
|
|
57
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
58
|
+
assert.ok(content.includes("First decision"), "first entry should be present after append");
|
|
59
|
+
assert.ok(content.includes("Second decision"), "second entry should be present after append");
|
|
60
|
+
});
|
|
61
|
+
test("appended entry includes agent slug, task summary, and result summary", async () => {
|
|
62
|
+
const m = await loadMirror();
|
|
63
|
+
const dallasLink = { ...mockLink, taskId: "task-003", squadAgentSlug: "dallas" };
|
|
64
|
+
await m.mirrorDecisionToWiki(dallasLink, "API design task", "Chose REST over GraphQL");
|
|
65
|
+
const wikiPath = m.projectDecisionWikiPath(mockProjectPath);
|
|
66
|
+
const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
|
|
67
|
+
const fullPath = join(wikiDir, ...wikiPath.split("/"));
|
|
68
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
69
|
+
assert.ok(content.includes("dallas"), "entry should include agent slug 'dallas'");
|
|
70
|
+
assert.ok(content.includes("API design task"), "entry should include task summary");
|
|
71
|
+
assert.ok(content.includes("Chose REST over GraphQL"), "entry should include result summary");
|
|
72
|
+
});
|
|
73
|
+
test("mirrorDecisionToWiki returns the wiki page path", async () => {
|
|
74
|
+
const m = await loadMirror();
|
|
75
|
+
const returned = await m.mirrorDecisionToWiki({ ...mockLink, taskId: "task-final" }, "Final check", "All good");
|
|
76
|
+
const expected = m.projectDecisionWikiPath(mockProjectPath);
|
|
77
|
+
assert.equal(returned, expected, "mirrorDecisionToWiki should return the wiki page path");
|
|
78
|
+
});
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// syncDecisionsFileToWiki tests
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
async function loadMirrorFresh() {
|
|
83
|
+
return await import(new URL(`./mirror.js?fresh=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
84
|
+
}
|
|
85
|
+
test("syncDecisionsFileToWiki returns null when decisions.md does not exist", async () => {
|
|
86
|
+
const m = await loadMirrorFresh();
|
|
87
|
+
const sync = m.syncDecisionsFileToWiki;
|
|
88
|
+
const result = await sync("/nonexistent/path/that/does/not/exist");
|
|
89
|
+
assert.equal(result, null, "should return null for missing decisions.md");
|
|
90
|
+
});
|
|
91
|
+
test("syncDecisionsFileToWiki writes decisions.md content to the wiki page", async () => {
|
|
92
|
+
const m = await loadMirrorFresh();
|
|
93
|
+
const sync = m.syncDecisionsFileToWiki;
|
|
94
|
+
const projectDecisionWikiPath = m.projectDecisionWikiPath;
|
|
95
|
+
// Set up a temp project with a .squad/decisions.md
|
|
96
|
+
const projectDir = join(repoRoot, ".test-work", `sync-test-${process.pid}-1`);
|
|
97
|
+
const squadDir = join(projectDir, ".squad");
|
|
98
|
+
mkdirSync(squadDir, { recursive: true });
|
|
99
|
+
const decisionsContent = `# Squad Decisions\n\n## Decision 1\nUse TypeScript.\n\n## Decision 2\nUse SQLite.\n`;
|
|
100
|
+
const { writeFileSync } = await import("node:fs");
|
|
101
|
+
writeFileSync(join(squadDir, "decisions.md"), decisionsContent, "utf-8");
|
|
102
|
+
try {
|
|
103
|
+
const result = await sync(projectDir);
|
|
104
|
+
assert.ok(result !== null, "should return a result object, not null");
|
|
105
|
+
assert.equal(result.entriesSynced, 2, "should count 2 second-level headings");
|
|
106
|
+
const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
|
|
107
|
+
const wikiPath = projectDecisionWikiPath(projectDir);
|
|
108
|
+
const fullPath = join(wikiDir, ...wikiPath.split("/"));
|
|
109
|
+
assert.ok(existsSync(fullPath), `wiki page should exist at ${fullPath}`);
|
|
110
|
+
const written = readFileSync(fullPath, "utf-8");
|
|
111
|
+
assert.ok(written.includes("Use TypeScript"), "wiki page should contain decisions content");
|
|
112
|
+
assert.ok(written.includes("Use SQLite"), "wiki page should contain second decision");
|
|
113
|
+
assert.ok(written.includes("Last synced:"), "wiki page should include sync timestamp header");
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
test("syncDecisionsFileToWiki overwrites (not appends) on re-run with updated content", async () => {
|
|
120
|
+
const m = await loadMirrorFresh();
|
|
121
|
+
const sync = m.syncDecisionsFileToWiki;
|
|
122
|
+
const projectDecisionWikiPath = m.projectDecisionWikiPath;
|
|
123
|
+
const projectDir = join(repoRoot, ".test-work", `sync-test-${process.pid}-2`);
|
|
124
|
+
const squadDir = join(projectDir, ".squad");
|
|
125
|
+
mkdirSync(squadDir, { recursive: true });
|
|
126
|
+
const { writeFileSync } = await import("node:fs");
|
|
127
|
+
const decisionsFile = join(squadDir, "decisions.md");
|
|
128
|
+
// First sync
|
|
129
|
+
writeFileSync(decisionsFile, "## Original decision\nContent A.\n", "utf-8");
|
|
130
|
+
await sync(projectDir);
|
|
131
|
+
// Update and re-sync
|
|
132
|
+
writeFileSync(decisionsFile, "## Updated decision\nContent B.\n", "utf-8");
|
|
133
|
+
await sync(projectDir);
|
|
134
|
+
try {
|
|
135
|
+
const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
|
|
136
|
+
const wikiPath = projectDecisionWikiPath(projectDir);
|
|
137
|
+
const fullPath = join(wikiDir, ...wikiPath.split("/"));
|
|
138
|
+
const written = readFileSync(fullPath, "utf-8");
|
|
139
|
+
assert.ok(written.includes("Content B"), "wiki should contain updated content after re-sync");
|
|
140
|
+
assert.ok(!written.includes("Content A"), "wiki should NOT contain old content after overwrite");
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
test("checkDecisionsInbox returns 0 and empty array when inbox is absent", async () => {
|
|
147
|
+
const m = await loadMirrorFresh();
|
|
148
|
+
const checkInbox = m.checkDecisionsInbox;
|
|
149
|
+
const result = await checkInbox("/nonexistent/project/path");
|
|
150
|
+
assert.equal(result.count, 0, "count should be 0 for missing inbox");
|
|
151
|
+
assert.deepEqual(result.files, [], "files should be empty array for missing inbox");
|
|
152
|
+
});
|
|
153
|
+
test("checkDecisionsInbox returns files in inbox", async () => {
|
|
154
|
+
const m = await loadMirrorFresh();
|
|
155
|
+
const checkInbox = m.checkDecisionsInbox;
|
|
156
|
+
const projectDir = join(repoRoot, ".test-work", `inbox-test-${process.pid}`);
|
|
157
|
+
const inboxDir = join(projectDir, ".squad", "decisions", "inbox");
|
|
158
|
+
mkdirSync(inboxDir, { recursive: true });
|
|
159
|
+
const { writeFileSync } = await import("node:fs");
|
|
160
|
+
writeFileSync(join(inboxDir, "drop-one.md"), "# Drop", "utf-8");
|
|
161
|
+
writeFileSync(join(inboxDir, "drop-two.md"), "# Drop", "utf-8");
|
|
162
|
+
try {
|
|
163
|
+
const result = await checkInbox(projectDir);
|
|
164
|
+
assert.equal(result.count, 2, "should count 2 inbox files");
|
|
165
|
+
assert.ok(result.files.includes("drop-one.md"), "should list drop-one.md");
|
|
166
|
+
assert.ok(result.files.includes("drop-two.md"), "should list drop-two.md");
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
//# sourceMappingURL=mirror.test.js.map
|