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.
@@ -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();
@@ -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