chapterhouse 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -12
- package/dist/api/errors.js +5 -3
- package/dist/api/errors.test.js +12 -21
- package/dist/api/server.js +33 -12
- package/dist/cli.js +135 -18
- package/dist/copilot/agents.js +9 -7
- package/dist/copilot/classifier.js +3 -1
- package/dist/copilot/orchestrator.js +35 -31
- package/dist/copilot/orchestrator.test.js +1 -0
- package/dist/copilot/router.js +4 -2
- package/dist/copilot/tools.js +6 -4
- package/dist/daemon-install.js +368 -0
- package/dist/daemon-install.test.js +98 -0
- package/dist/daemon.js +35 -33
- package/dist/squad/index.js +1 -0
- package/dist/squad/worktree.js +295 -0
- package/dist/squad/worktree.test.js +189 -0
- package/dist/store/db.js +38 -0
- package/dist/store/db.test.js +88 -0
- package/dist/update.js +162 -28
- package/dist/update.test.js +84 -5
- package/dist/util/logger.js +41 -0
- package/dist/util/logger.test.js +53 -0
- package/dist/wiki/migrate.js +4 -2
- package/dist/wiki/seed-team-wiki.js +4 -2
- package/package.json +10 -2
- package/web/dist/assets/index-CxT9905O.css +10 -0
- package/web/dist/assets/{index-DAg9IrpO.js → index-DI3rnGm-.js} +59 -59
- package/web/dist/assets/index-DI3rnGm-.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D-e7K-fT.css +0 -10
- package/web/dist/assets/index-DAg9IrpO.js.map +0 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Path convention
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
/**
|
|
8
|
+
* Location: `.worktrees/{agent}-{issueNum}/` inside the repo root.
|
|
9
|
+
* This keeps worktrees discoverable (they're adjacent to the code) and
|
|
10
|
+
* `git worktree list` always surfaces them. The directory is gitignored.
|
|
11
|
+
*
|
|
12
|
+
* Tradeoff vs `~/.cache/chapterhouse-worktrees/<repo>/`:
|
|
13
|
+
* + Visible without knowing a cache path
|
|
14
|
+
* + `chapterhouse squad worktree list` doesn't need to guess the home
|
|
15
|
+
* - Must be added to .gitignore (done once, documented here)
|
|
16
|
+
*/
|
|
17
|
+
export function getWorktreePath(repoRoot, agent, issueNum) {
|
|
18
|
+
return join(repoRoot, '.worktrees', `${agent}-${issueNum}`);
|
|
19
|
+
}
|
|
20
|
+
export function getBranchName(issueNum, slug) {
|
|
21
|
+
const safeslug = slug ? `-${slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}` : '';
|
|
22
|
+
return `squad/${issueNum}${safeslug}`;
|
|
23
|
+
}
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Git helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function git(args, cwd) {
|
|
28
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf-8' });
|
|
29
|
+
return {
|
|
30
|
+
ok: result.status === 0,
|
|
31
|
+
stdout: (result.stdout ?? '').trim(),
|
|
32
|
+
stderr: (result.stderr ?? '').trim(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function hasUncommittedChanges(worktreePath) {
|
|
36
|
+
const r = git(['status', '--porcelain'], worktreePath);
|
|
37
|
+
return r.ok && r.stdout.length > 0;
|
|
38
|
+
}
|
|
39
|
+
function isBranchMerged(repoRoot, branch, base = 'main') {
|
|
40
|
+
// merged if branch tip is reachable from base
|
|
41
|
+
const r = git(['branch', '--merged', base, '--list', branch], repoRoot);
|
|
42
|
+
return r.ok && r.stdout.length > 0;
|
|
43
|
+
}
|
|
44
|
+
function getRepoRoot(cwd) {
|
|
45
|
+
const r = git(['rev-parse', '--show-toplevel'], cwd);
|
|
46
|
+
if (!r.ok)
|
|
47
|
+
throw new Error(`Not inside a git repo: ${r.stderr}`);
|
|
48
|
+
return r.stdout;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Create a dedicated worktree for an agent + issue pair.
|
|
52
|
+
* Branch: `squad/{issueNum}-{agent}` (off `main` by default).
|
|
53
|
+
* Returns the worktree path.
|
|
54
|
+
*/
|
|
55
|
+
export function createWorktree(repoRoot, agent, issueNum, opts = {}) {
|
|
56
|
+
const baseBranch = opts.baseBranch ?? 'main';
|
|
57
|
+
const branch = getBranchName(issueNum, opts.slug ?? agent);
|
|
58
|
+
const worktreePath = getWorktreePath(repoRoot, agent, issueNum);
|
|
59
|
+
if (existsSync(worktreePath)) {
|
|
60
|
+
// Reuse existing worktree
|
|
61
|
+
console.log(`↩ Reusing existing worktree: ${relative(repoRoot, worktreePath)}`);
|
|
62
|
+
return worktreePath;
|
|
63
|
+
}
|
|
64
|
+
// Check if branch already exists (another agent on same issue)
|
|
65
|
+
const branchExists = git(['show-ref', '--verify', `refs/heads/${branch}`], repoRoot);
|
|
66
|
+
let result;
|
|
67
|
+
if (branchExists.ok) {
|
|
68
|
+
result = git(['worktree', 'add', worktreePath, branch], repoRoot);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
result = git(['worktree', 'add', '-b', branch, worktreePath, baseBranch], repoRoot);
|
|
72
|
+
}
|
|
73
|
+
if (!result.ok) {
|
|
74
|
+
throw new Error(`Failed to create worktree: ${result.stderr}`);
|
|
75
|
+
}
|
|
76
|
+
console.log(`✅ Worktree created: ${relative(repoRoot, worktreePath)} (branch: ${branch})`);
|
|
77
|
+
return worktreePath;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* List all squad worktrees (worktrees under `.worktrees/` with `squad/` branches).
|
|
81
|
+
*/
|
|
82
|
+
export function listWorktrees(repoRoot) {
|
|
83
|
+
const r = git(['worktree', 'list', '--porcelain'], repoRoot);
|
|
84
|
+
if (!r.ok)
|
|
85
|
+
throw new Error(`git worktree list failed: ${r.stderr}`);
|
|
86
|
+
const entries = parseWorktreePorcelain(r.stdout);
|
|
87
|
+
const squadEntries = entries.filter(e => e.path.includes('/.worktrees/'));
|
|
88
|
+
return squadEntries.map(e => {
|
|
89
|
+
const dirName = e.path.split('/.worktrees/')[1] ?? '';
|
|
90
|
+
const match = dirName.match(/^([^-]+)-(\d+)/);
|
|
91
|
+
const agent = match ? match[1] : dirName;
|
|
92
|
+
const issueNum = match ? match[2] : '?';
|
|
93
|
+
const dirty = existsSync(e.path) && hasUncommittedChanges(e.path);
|
|
94
|
+
return {
|
|
95
|
+
agent,
|
|
96
|
+
issueNum,
|
|
97
|
+
path: e.path,
|
|
98
|
+
branch: e.branch ?? '(detached)',
|
|
99
|
+
exists: existsSync(e.path),
|
|
100
|
+
dirty,
|
|
101
|
+
head: e.head ?? '',
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Remove a specific worktree. Warns but refuses if the worktree has
|
|
107
|
+
* uncommitted changes (pass `force: true` to override).
|
|
108
|
+
*/
|
|
109
|
+
export function removeWorktree(repoRoot, agent, issueNum, opts = {}) {
|
|
110
|
+
const worktreePath = getWorktreePath(repoRoot, agent, issueNum);
|
|
111
|
+
if (!existsSync(worktreePath)) {
|
|
112
|
+
console.log(`ℹ Worktree not found: ${worktreePath} (nothing to remove)`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!opts.force && hasUncommittedChanges(worktreePath)) {
|
|
116
|
+
console.error(`⚠ Worktree ${relative(repoRoot, worktreePath)} has uncommitted changes.\n` +
|
|
117
|
+
` Commit or stash your work before removing, or pass --force to discard.`);
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const removeArgs = ['worktree', 'remove', worktreePath];
|
|
122
|
+
if (opts.force)
|
|
123
|
+
removeArgs.push('--force');
|
|
124
|
+
const r = git(removeArgs, repoRoot);
|
|
125
|
+
if (!r.ok)
|
|
126
|
+
throw new Error(`Failed to remove worktree: ${r.stderr}`);
|
|
127
|
+
if (opts.deleteBranch) {
|
|
128
|
+
const branch = getBranchName(issueNum, agent);
|
|
129
|
+
const del = git(['branch', '-d', branch], repoRoot);
|
|
130
|
+
if (!del.ok) {
|
|
131
|
+
console.warn(`⚠ Could not delete branch ${branch}: ${del.stderr}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
console.log(`🗑 Removed worktree: ${relative(repoRoot, worktreePath)}`);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Remove all squad worktrees whose branch has been merged into `main`.
|
|
138
|
+
* Warns and skips worktrees with uncommitted changes.
|
|
139
|
+
*/
|
|
140
|
+
export function pruneWorktrees(repoRoot, opts = {}) {
|
|
141
|
+
const base = opts.base ?? 'main';
|
|
142
|
+
const worktrees = listWorktrees(repoRoot);
|
|
143
|
+
if (worktrees.length === 0) {
|
|
144
|
+
console.log('ℹ No squad worktrees found.');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for (const wt of worktrees) {
|
|
148
|
+
const merged = isBranchMerged(repoRoot, wt.branch, base);
|
|
149
|
+
if (!merged) {
|
|
150
|
+
console.log(`↷ Skipping ${wt.agent}-${wt.issueNum} (branch not merged into ${base})`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (wt.dirty) {
|
|
154
|
+
console.warn(`⚠ Skipping ${wt.agent}-${wt.issueNum} (has uncommitted changes — clean up manually)`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (opts.dryRun) {
|
|
158
|
+
console.log(`[dry-run] Would remove: ${wt.agent}-${wt.issueNum} (branch: ${wt.branch})`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
removeWorktree(repoRoot, wt.agent, Number(wt.issueNum), { deleteBranch: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Print helpers
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
export function printWorktreeList(repoRoot) {
|
|
168
|
+
const worktrees = listWorktrees(repoRoot);
|
|
169
|
+
if (worktrees.length === 0) {
|
|
170
|
+
console.log('No squad worktrees found.');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const header = ['AGENT', 'ISSUE', 'BRANCH', 'STATUS', 'PATH'];
|
|
174
|
+
const rows = worktrees.map(wt => [
|
|
175
|
+
wt.agent,
|
|
176
|
+
wt.issueNum,
|
|
177
|
+
wt.branch,
|
|
178
|
+
wt.dirty ? '⚠ dirty' : '✓ clean',
|
|
179
|
+
wt.path,
|
|
180
|
+
]);
|
|
181
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
|
182
|
+
const fmt = (cols) => cols.map((c, i) => c.padEnd(widths[i])).join(' ');
|
|
183
|
+
console.log(fmt(header));
|
|
184
|
+
console.log(widths.map(w => '-'.repeat(w)).join(' '));
|
|
185
|
+
for (const row of rows)
|
|
186
|
+
console.log(fmt(row));
|
|
187
|
+
}
|
|
188
|
+
export function printWorktreeHelp() {
|
|
189
|
+
console.log(`
|
|
190
|
+
chapterhouse squad worktree — manage per-agent git worktrees
|
|
191
|
+
|
|
192
|
+
Usage:
|
|
193
|
+
chapterhouse squad worktree <subcommand>
|
|
194
|
+
|
|
195
|
+
Subcommands:
|
|
196
|
+
create <agent> <issue> [--base <branch>] [--slug <slug>]
|
|
197
|
+
Create a dedicated worktree for an agent + issue.
|
|
198
|
+
Path: .worktrees/{agent}-{issue}/ Branch: squad/{issue}-{agent}
|
|
199
|
+
|
|
200
|
+
list
|
|
201
|
+
List all squad worktrees with agent, branch, and status.
|
|
202
|
+
|
|
203
|
+
remove <agent> <issue> [--force] [--delete-branch]
|
|
204
|
+
Remove a worktree. Refuses if there are uncommitted changes
|
|
205
|
+
unless --force is passed (which discards the changes).
|
|
206
|
+
|
|
207
|
+
prune [--base <branch>] [--dry-run]
|
|
208
|
+
Remove all worktrees whose branch has been merged into main
|
|
209
|
+
(or the specified --base branch). Skips dirty worktrees.
|
|
210
|
+
`.trim());
|
|
211
|
+
}
|
|
212
|
+
function parseWorktreePorcelain(output) {
|
|
213
|
+
const entries = [];
|
|
214
|
+
let current = null;
|
|
215
|
+
for (const line of output.split('\n')) {
|
|
216
|
+
if (line.startsWith('worktree ')) {
|
|
217
|
+
if (current?.path)
|
|
218
|
+
entries.push(current);
|
|
219
|
+
current = { path: line.slice('worktree '.length) };
|
|
220
|
+
}
|
|
221
|
+
else if (line.startsWith('HEAD ') && current) {
|
|
222
|
+
current.head = line.slice('HEAD '.length);
|
|
223
|
+
}
|
|
224
|
+
else if (line.startsWith('branch ') && current) {
|
|
225
|
+
current.branch = line.slice('branch refs/heads/'.length);
|
|
226
|
+
}
|
|
227
|
+
else if (line === '' && current?.path) {
|
|
228
|
+
entries.push(current);
|
|
229
|
+
current = null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (current?.path)
|
|
233
|
+
entries.push(current);
|
|
234
|
+
return entries;
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// CLI entry point
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
export async function runWorktreeCli(args) {
|
|
240
|
+
let repoRoot;
|
|
241
|
+
try {
|
|
242
|
+
repoRoot = getRepoRoot(process.cwd());
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
console.error('❌ Not inside a git repository.');
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
const subcommand = args[0];
|
|
249
|
+
switch (subcommand) {
|
|
250
|
+
case 'create': {
|
|
251
|
+
const agent = args[1];
|
|
252
|
+
const issueNum = args[2];
|
|
253
|
+
if (!agent || !issueNum) {
|
|
254
|
+
console.error('Usage: chapterhouse squad worktree create <agent> <issue> [--base <branch>] [--slug <slug>]');
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
const baseIdx = args.indexOf('--base');
|
|
258
|
+
const baseBranch = baseIdx !== -1 ? args[baseIdx + 1] : undefined;
|
|
259
|
+
const slugIdx = args.indexOf('--slug');
|
|
260
|
+
const slug = slugIdx !== -1 ? args[slugIdx + 1] : undefined;
|
|
261
|
+
const path = createWorktree(repoRoot, agent, issueNum, { baseBranch, slug });
|
|
262
|
+
console.log(path);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
case 'list':
|
|
266
|
+
printWorktreeList(repoRoot);
|
|
267
|
+
break;
|
|
268
|
+
case 'remove': {
|
|
269
|
+
const agent = args[1];
|
|
270
|
+
const issueNum = args[2];
|
|
271
|
+
if (!agent || !issueNum) {
|
|
272
|
+
console.error('Usage: chapterhouse squad worktree remove <agent> <issue> [--force] [--delete-branch]');
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
removeWorktree(repoRoot, agent, issueNum, {
|
|
276
|
+
force: args.includes('--force'),
|
|
277
|
+
deleteBranch: args.includes('--delete-branch'),
|
|
278
|
+
});
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case 'prune': {
|
|
282
|
+
const baseIdx = args.indexOf('--base');
|
|
283
|
+
const base = baseIdx !== -1 ? args[baseIdx + 1] : undefined;
|
|
284
|
+
pruneWorktrees(repoRoot, { base, dryRun: args.includes('--dry-run') });
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
default:
|
|
288
|
+
if (subcommand)
|
|
289
|
+
console.error(`Unknown worktree subcommand: ${subcommand}\n`);
|
|
290
|
+
printWorktreeHelp();
|
|
291
|
+
if (subcommand)
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
//# sourceMappingURL=worktree.js.map
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test fixture: minimal git repo
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const repoRoot = process.cwd();
|
|
10
|
+
const sandboxRoot = join(repoRoot, '.test-work', `worktree-${process.pid}`);
|
|
11
|
+
function initRepo(dir) {
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
// --initial-branch=main ensures the branch is named 'main' regardless of
|
|
14
|
+
// the runner's global init.defaultBranch setting (older git may default to 'master').
|
|
15
|
+
execSync('git init --initial-branch=main', { cwd: dir, stdio: 'pipe' });
|
|
16
|
+
execSync('git config user.email "test@example.com"', { cwd: dir, stdio: 'pipe' });
|
|
17
|
+
execSync('git config user.name "Test CI"', { cwd: dir, stdio: 'pipe' });
|
|
18
|
+
writeFileSync(join(dir, 'README.md'), '# test\n');
|
|
19
|
+
execSync('git add .', { cwd: dir, stdio: 'pipe' });
|
|
20
|
+
// An initial commit is required so that 'main' resolves as a ref.
|
|
21
|
+
// Without it, `git worktree add <path> -b <branch> main` fails with
|
|
22
|
+
// "fatal: invalid reference: main" because the branch has no commits.
|
|
23
|
+
execSync('git commit -m "chore: initial commit"', { cwd: dir, stdio: 'pipe' });
|
|
24
|
+
}
|
|
25
|
+
// We need a separate repo because the main repo's worktrees would interfere.
|
|
26
|
+
// Each test suite gets its own isolated sandbox git repo.
|
|
27
|
+
const testRepo = join(sandboxRoot, 'repo');
|
|
28
|
+
test.before(() => {
|
|
29
|
+
mkdirSync(sandboxRoot, { recursive: true });
|
|
30
|
+
initRepo(testRepo);
|
|
31
|
+
});
|
|
32
|
+
test.after(() => {
|
|
33
|
+
// Clean up any worktrees first (git worktree remove) then the sandbox
|
|
34
|
+
try {
|
|
35
|
+
execSync('git worktree prune', { cwd: testRepo });
|
|
36
|
+
}
|
|
37
|
+
catch { /* ignore */ }
|
|
38
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
async function loadWorktree() {
|
|
41
|
+
return await import(new URL(`./worktree.js?v=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// getWorktreePath
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
test('getWorktreePath returns correct path inside .worktrees/', async () => {
|
|
47
|
+
const m = await loadWorktree();
|
|
48
|
+
const result = m.getWorktreePath('/repo', 'kaylee', 42);
|
|
49
|
+
assert.equal(result, '/repo/.worktrees/kaylee-42');
|
|
50
|
+
});
|
|
51
|
+
test('getWorktreePath works with string issue number', async () => {
|
|
52
|
+
const m = await loadWorktree();
|
|
53
|
+
const result = m.getWorktreePath('/repo', 'wash', '17');
|
|
54
|
+
assert.equal(result, '/repo/.worktrees/wash-17');
|
|
55
|
+
});
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// getBranchName
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
test('getBranchName without slug uses squad/{issue} format', async () => {
|
|
60
|
+
const m = await loadWorktree();
|
|
61
|
+
assert.equal(m.getBranchName(49), 'squad/49');
|
|
62
|
+
});
|
|
63
|
+
test('getBranchName with slug appends kebab-case slug', async () => {
|
|
64
|
+
const m = await loadWorktree();
|
|
65
|
+
assert.equal(m.getBranchName(49, 'git worktrees'), 'squad/49-git-worktrees');
|
|
66
|
+
});
|
|
67
|
+
test('getBranchName normalises special characters in slug', async () => {
|
|
68
|
+
const m = await loadWorktree();
|
|
69
|
+
assert.equal(m.getBranchName(10, 'Fix Login!!'), 'squad/10-fix-login');
|
|
70
|
+
});
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// createWorktree
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
test('createWorktree creates a worktree at .worktrees/{agent}-{issue}/', async () => {
|
|
75
|
+
const m = await loadWorktree();
|
|
76
|
+
const { existsSync } = await import('node:fs');
|
|
77
|
+
const wt = m.createWorktree(testRepo, 'kaylee', 1);
|
|
78
|
+
assert.ok(existsSync(wt), `Worktree path should exist: ${wt}`);
|
|
79
|
+
assert.ok(wt.endsWith('.worktrees/kaylee-1'), `Path should end with .worktrees/kaylee-1, got: ${wt}`);
|
|
80
|
+
// Verify branch was created
|
|
81
|
+
const { spawnSync } = await import('node:child_process');
|
|
82
|
+
const r = spawnSync('git', ['branch', '--list', 'squad/1-kaylee'], { cwd: testRepo, encoding: 'utf-8' });
|
|
83
|
+
assert.ok(r.stdout.trim().length > 0, 'Branch squad/1-kaylee should exist');
|
|
84
|
+
});
|
|
85
|
+
test('createWorktree reuses existing worktree without error', async () => {
|
|
86
|
+
const m = await loadWorktree();
|
|
87
|
+
// create again (already exists from above)
|
|
88
|
+
assert.doesNotThrow(() => {
|
|
89
|
+
m.createWorktree(testRepo, 'kaylee', 1);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
test('createWorktree respects --base option', async () => {
|
|
93
|
+
const m = await loadWorktree();
|
|
94
|
+
const { existsSync } = await import('node:fs');
|
|
95
|
+
// We guarantee 'main' exists via --initial-branch=main in initRepo
|
|
96
|
+
const wt = m.createWorktree(testRepo, 'wash', 2, { baseBranch: 'main' });
|
|
97
|
+
assert.ok(existsSync(wt), `Worktree should be created at ${wt}`);
|
|
98
|
+
});
|
|
99
|
+
test('createWorktree with slug uses slug in branch name', async () => {
|
|
100
|
+
const m = await loadWorktree();
|
|
101
|
+
const wt = m.createWorktree(testRepo, 'zoe', 3, { slug: 'test-isolation' });
|
|
102
|
+
const { spawnSync } = await import('node:child_process');
|
|
103
|
+
const r = spawnSync('git', ['branch', '--list', 'squad/3-test-isolation'], { cwd: testRepo, encoding: 'utf-8' });
|
|
104
|
+
assert.ok(r.stdout.trim().length > 0, 'Branch squad/3-test-isolation should exist');
|
|
105
|
+
void wt;
|
|
106
|
+
});
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// listWorktrees
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
test('listWorktrees returns entries for squad worktrees', async () => {
|
|
111
|
+
const m = await loadWorktree();
|
|
112
|
+
const list = m.listWorktrees(testRepo);
|
|
113
|
+
// We created kaylee-1 above; at minimum it should appear
|
|
114
|
+
assert.ok(list.length >= 1, 'Should have at least 1 squad worktree');
|
|
115
|
+
const kaylee = list.find(w => w.agent === 'kaylee' && w.issueNum === '1');
|
|
116
|
+
assert.ok(kaylee, 'Should include kaylee-1 worktree');
|
|
117
|
+
assert.equal(kaylee?.branch, 'squad/1-kaylee');
|
|
118
|
+
assert.equal(typeof kaylee?.dirty, 'boolean');
|
|
119
|
+
});
|
|
120
|
+
test('listWorktrees does not include the main checkout', async () => {
|
|
121
|
+
const m = await loadWorktree();
|
|
122
|
+
const list = m.listWorktrees(testRepo);
|
|
123
|
+
const hasMain = list.some(w => !w.path.includes('/.worktrees/'));
|
|
124
|
+
assert.ok(!hasMain, 'Main checkout should not appear in squad worktree list');
|
|
125
|
+
});
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// removeWorktree
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
test('removeWorktree removes a clean worktree', async () => {
|
|
130
|
+
const m = await loadWorktree();
|
|
131
|
+
// Create a fresh worktree to remove
|
|
132
|
+
m.createWorktree(testRepo, 'scribe', 5);
|
|
133
|
+
const wtPath = m.getWorktreePath(testRepo, 'scribe', 5);
|
|
134
|
+
const { existsSync } = await import('node:fs');
|
|
135
|
+
assert.ok(existsSync(wtPath));
|
|
136
|
+
m.removeWorktree(testRepo, 'scribe', 5);
|
|
137
|
+
assert.ok(!existsSync(wtPath), 'Worktree should be removed');
|
|
138
|
+
});
|
|
139
|
+
test('removeWorktree is a no-op for non-existent worktree', async () => {
|
|
140
|
+
const m = await loadWorktree();
|
|
141
|
+
assert.doesNotThrow(() => {
|
|
142
|
+
m.removeWorktree(testRepo, 'ghost', 999);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
test('removeWorktree refuses to remove dirty worktree without --force', async () => {
|
|
146
|
+
const m = await loadWorktree();
|
|
147
|
+
const { writeFileSync } = await import('node:fs');
|
|
148
|
+
// Create worktree, then add an uncommitted file
|
|
149
|
+
m.createWorktree(testRepo, 'mal', 6);
|
|
150
|
+
const wtPath = m.getWorktreePath(testRepo, 'mal', 6);
|
|
151
|
+
writeFileSync(join(wtPath, 'uncommitted.txt'), 'dirty\n');
|
|
152
|
+
// Should set exitCode=1 but not throw
|
|
153
|
+
const prev = process.exitCode;
|
|
154
|
+
process.exitCode = 0;
|
|
155
|
+
m.removeWorktree(testRepo, 'mal', 6, { force: false });
|
|
156
|
+
const rejected = process.exitCode === 1;
|
|
157
|
+
process.exitCode = prev; // restore
|
|
158
|
+
assert.ok(rejected, 'Should set exitCode=1 for dirty worktree');
|
|
159
|
+
// cleanup with force
|
|
160
|
+
m.removeWorktree(testRepo, 'mal', 6, { force: true });
|
|
161
|
+
});
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// pruneWorktrees
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
test('pruneWorktrees dry-run lists worktrees that would be removed', async () => {
|
|
166
|
+
const m = await loadWorktree();
|
|
167
|
+
// Capture stdout (basic test — just ensure it doesn't throw)
|
|
168
|
+
assert.doesNotThrow(() => {
|
|
169
|
+
m.pruneWorktrees(testRepo, { dryRun: true });
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
test('pruneWorktrees skips worktrees on unmerged branches', async () => {
|
|
173
|
+
const m = await loadWorktree();
|
|
174
|
+
const { existsSync, writeFileSync } = await import('node:fs');
|
|
175
|
+
const { spawnSync } = await import('node:child_process');
|
|
176
|
+
const { join } = await import('node:path');
|
|
177
|
+
// zoe-3 worktree should exist (created in earlier test)
|
|
178
|
+
const wt = m.getWorktreePath(testRepo, 'zoe', 3);
|
|
179
|
+
assert.ok(existsSync(wt), 'zoe-3 worktree should exist');
|
|
180
|
+
// Add a commit so the branch diverges from base — otherwise git considers
|
|
181
|
+
// a branch with no extra commits as already merged into its parent.
|
|
182
|
+
writeFileSync(join(wt, 'feature.txt'), 'in progress\n');
|
|
183
|
+
spawnSync('git', ['add', 'feature.txt'], { cwd: wt, stdio: 'pipe' });
|
|
184
|
+
spawnSync('git', ['commit', '-m', 'feat: in-progress work'], { cwd: wt, stdio: 'pipe' });
|
|
185
|
+
// We guarantee 'main' via --initial-branch=main in initRepo
|
|
186
|
+
m.pruneWorktrees(testRepo, { base: 'main' });
|
|
187
|
+
assert.ok(existsSync(wt), 'Unmerged worktree should NOT be pruned');
|
|
188
|
+
});
|
|
189
|
+
//# sourceMappingURL=worktree.test.js.map
|
package/dist/store/db.js
CHANGED
|
@@ -150,6 +150,13 @@ 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
|
+
// Migrate: add last_used_at column to project_squads (epoch ms, nullable)
|
|
154
|
+
const projectCols = db.prepare(`PRAGMA table_info(project_squads)`).all();
|
|
155
|
+
if (!projectCols.some((c) => c.name === 'last_used_at')) {
|
|
156
|
+
db.exec(`ALTER TABLE project_squads ADD COLUMN last_used_at INTEGER`);
|
|
157
|
+
// Backfill from loaded_at so sidebar is not empty for existing projects
|
|
158
|
+
db.exec(`UPDATE project_squads SET last_used_at = CAST((julianday(loaded_at) - 2440587.5) * 86400000 AS INTEGER) WHERE last_used_at IS NULL`);
|
|
159
|
+
}
|
|
153
160
|
// Prune conversation log at startup — keep more history for better recovery
|
|
154
161
|
db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
|
|
155
162
|
// Set up FTS5 for memory search (graceful fallback if not available)
|
|
@@ -280,11 +287,42 @@ export function getRecentConversation(limit, sessionKey) {
|
|
|
280
287
|
return `${tag}: ${content}`;
|
|
281
288
|
}).join("\n\n");
|
|
282
289
|
}
|
|
290
|
+
const MAX_SESSION_MESSAGES_LIMIT = 500;
|
|
291
|
+
const DEFAULT_SESSION_MESSAGES_LIMIT = 100;
|
|
292
|
+
/**
|
|
293
|
+
* Return conversation_log rows for a specific session as structured JSON,
|
|
294
|
+
* suitable for seeding the frontend Zustand store on mount.
|
|
295
|
+
*
|
|
296
|
+
* Unlike `getRecentConversation()`, this returns structured objects (not a
|
|
297
|
+
* formatted string) and omits system messages (role = 'system') because the
|
|
298
|
+
* UI only renders user/assistant turns.
|
|
299
|
+
*/
|
|
300
|
+
export function getSessionMessages(sessionKey, limit) {
|
|
301
|
+
const db = getDb();
|
|
302
|
+
const effectiveLimit = Math.min(limit ?? DEFAULT_SESSION_MESSAGES_LIMIT, MAX_SESSION_MESSAGES_LIMIT);
|
|
303
|
+
const rows = db
|
|
304
|
+
.prepare(`SELECT role, content, ts FROM conversation_log
|
|
305
|
+
WHERE session_key = ? AND role IN ('user', 'assistant')
|
|
306
|
+
ORDER BY id DESC LIMIT ?`)
|
|
307
|
+
.all(sessionKey, effectiveLimit);
|
|
308
|
+
// Reverse so oldest is first (chronological order for the UI)
|
|
309
|
+
rows.reverse();
|
|
310
|
+
return rows.map((r) => ({
|
|
311
|
+
role: r.role,
|
|
312
|
+
content: r.content,
|
|
313
|
+
ts: r.ts,
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
283
316
|
// ---------------------------------------------------------------------------
|
|
284
317
|
// SQLite memory functions removed — wiki is the single source of truth.
|
|
285
318
|
// The memories table and FTS5 index are preserved in the schema for safety
|
|
286
319
|
// (existing data is not deleted), but no code reads or writes to them.
|
|
287
320
|
// ---------------------------------------------------------------------------
|
|
321
|
+
export function bumpProjectLastUsed(projectRoot) {
|
|
322
|
+
getDb()
|
|
323
|
+
.prepare(`UPDATE project_squads SET last_used_at = ? WHERE project_root = ?`)
|
|
324
|
+
.run(Date.now(), projectRoot);
|
|
325
|
+
}
|
|
288
326
|
export function closeDb() {
|
|
289
327
|
if (db) {
|
|
290
328
|
db.close();
|
package/dist/store/db.test.js
CHANGED
|
@@ -123,4 +123,92 @@ test("getDb prunes oversized conversation logs on startup and during inserts", a
|
|
|
123
123
|
dbModule.closeDb();
|
|
124
124
|
}
|
|
125
125
|
});
|
|
126
|
+
test("getSessionMessages returns empty array for unknown session", async () => {
|
|
127
|
+
const dbModule = await loadDbModule();
|
|
128
|
+
try {
|
|
129
|
+
dbModule.getDb();
|
|
130
|
+
const result = dbModule.getSessionMessages("nonexistent-session");
|
|
131
|
+
assert.deepEqual(result, []);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
dbModule.closeDb();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
test("getSessionMessages returns structured messages in chronological order, excludes system rows, respects limit", async () => {
|
|
138
|
+
const dbModule = await loadDbModule();
|
|
139
|
+
try {
|
|
140
|
+
dbModule.getDb();
|
|
141
|
+
dbModule.logConversation("user", "hello", "web", "test-session");
|
|
142
|
+
dbModule.logConversation("assistant", "hi there", "web", "test-session");
|
|
143
|
+
dbModule.logConversation("system", "system noise", "worker", "test-session");
|
|
144
|
+
dbModule.logConversation("user", "second message", "web", "test-session");
|
|
145
|
+
dbModule.logConversation("user", "from other session", "web", "other-session");
|
|
146
|
+
const all = dbModule.getSessionMessages("test-session");
|
|
147
|
+
assert.equal(all.length, 3, "3 user/assistant rows, system excluded");
|
|
148
|
+
assert.equal(all[0].role, "user");
|
|
149
|
+
assert.equal(all[0].content, "hello");
|
|
150
|
+
assert.equal(all[1].role, "assistant");
|
|
151
|
+
assert.equal(all[1].content, "hi there");
|
|
152
|
+
assert.equal(all[2].role, "user");
|
|
153
|
+
assert.equal(all[2].content, "second message");
|
|
154
|
+
// Limit clamping
|
|
155
|
+
const limited = dbModule.getSessionMessages("test-session", 2);
|
|
156
|
+
assert.equal(limited.length, 2, "limit=2 returns 2 most recent rows");
|
|
157
|
+
// After reversal, these should be the 2 most-recent (assistant + second user)
|
|
158
|
+
assert.equal(limited[0].content, "hi there");
|
|
159
|
+
assert.equal(limited[1].content, "second message");
|
|
160
|
+
// Other session not leaked
|
|
161
|
+
const other = dbModule.getSessionMessages("other-session");
|
|
162
|
+
assert.equal(other.length, 1);
|
|
163
|
+
assert.equal(other[0].content, "from other session");
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
dbModule.closeDb();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// #26 — bumpProjectLastUsed
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
test("migration adds last_used_at column to project_squads when absent", async () => {
|
|
173
|
+
const dbModule = await loadDbModule();
|
|
174
|
+
try {
|
|
175
|
+
const db = dbModule.getDb();
|
|
176
|
+
const cols = db.prepare(`PRAGMA table_info(project_squads)`).all();
|
|
177
|
+
assert.equal(cols.some((c) => c.name === "last_used_at"), true, "last_used_at column should exist after migration");
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
dbModule.closeDb();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
test("bumpProjectLastUsed updates last_used_at for the given project_root", async () => {
|
|
184
|
+
const dbModule = await loadDbModule();
|
|
185
|
+
try {
|
|
186
|
+
const db = dbModule.getDb();
|
|
187
|
+
db.prepare(`INSERT INTO project_squads (project_root, squad_dir, team_dir, mode, registered) VALUES (?, ?, ?, 'local', 1)`).run("/home/user/test-proj", "/home/user/test-proj/.squad", "/home/user/test-proj/.squad");
|
|
188
|
+
const before = db
|
|
189
|
+
.prepare(`SELECT last_used_at FROM project_squads WHERE project_root = ?`)
|
|
190
|
+
.get("/home/user/test-proj");
|
|
191
|
+
const beforeTs = before.last_used_at ?? 0;
|
|
192
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
193
|
+
dbModule.bumpProjectLastUsed("/home/user/test-proj");
|
|
194
|
+
const after = db
|
|
195
|
+
.prepare(`SELECT last_used_at FROM project_squads WHERE project_root = ?`)
|
|
196
|
+
.get("/home/user/test-proj");
|
|
197
|
+
assert.ok(after.last_used_at !== null, "last_used_at should not be null after bump");
|
|
198
|
+
assert.ok(after.last_used_at > beforeTs, "last_used_at should advance after bump");
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
dbModule.closeDb();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
test("bumpProjectLastUsed is a no-op for unknown project_root (no throw)", async () => {
|
|
205
|
+
const dbModule = await loadDbModule();
|
|
206
|
+
try {
|
|
207
|
+
dbModule.getDb();
|
|
208
|
+
dbModule.bumpProjectLastUsed("/does/not/exist");
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
dbModule.closeDb();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
126
214
|
//# sourceMappingURL=db.test.js.map
|