tmux-team 2.0.0-alpha.4 → 2.2.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 +223 -17
- package/package.json +1 -1
- package/src/commands/add.ts +9 -5
- package/src/commands/config.ts +104 -9
- package/src/commands/help.ts +3 -1
- package/src/commands/preamble.ts +22 -25
- package/src/commands/remove.ts +5 -3
- package/src/commands/talk.test.ts +199 -14
- package/src/commands/talk.ts +26 -2
- package/src/commands/update.ts +16 -5
- package/src/config.test.ts +183 -9
- package/src/config.ts +29 -16
- package/src/pm/commands.test.ts +389 -55
- package/src/pm/commands.ts +312 -24
- package/src/pm/permissions.test.ts +113 -1
- package/src/pm/permissions.ts +18 -4
- package/src/pm/storage/adapter.ts +2 -0
- package/src/pm/storage/fs.test.ts +129 -1
- package/src/pm/storage/fs.ts +38 -4
- package/src/pm/storage/github.ts +96 -17
- package/src/pm/types.ts +6 -0
- package/src/state.test.ts +20 -10
- package/src/state.ts +28 -1
- package/src/types.ts +5 -1
- package/src/ui.ts +2 -1
|
@@ -40,7 +40,7 @@ describe('FSAdapter', () => {
|
|
|
40
40
|
expect(team.name).toBe('My Project');
|
|
41
41
|
expect(team.windowId).toBe('window-1');
|
|
42
42
|
expect(team.id).toBe(path.basename(testDir));
|
|
43
|
-
expect(team.createdAt).
|
|
43
|
+
expect(team.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
44
44
|
|
|
45
45
|
// Verify file contents
|
|
46
46
|
const teamFile = path.join(testDir, 'team.json');
|
|
@@ -162,6 +162,80 @@ describe('FSAdapter', () => {
|
|
|
162
162
|
const milestone = await adapter.getMilestone('1');
|
|
163
163
|
expect(milestone).toBeNull();
|
|
164
164
|
});
|
|
165
|
+
|
|
166
|
+
it('creates markdown doc file for milestone', async () => {
|
|
167
|
+
const milestone = await adapter.createMilestone({ name: 'Phase 1' });
|
|
168
|
+
const docPath = path.join(testDir, 'milestones', `${milestone.id}.md`);
|
|
169
|
+
|
|
170
|
+
expect(fs.existsSync(docPath)).toBe(true);
|
|
171
|
+
const content = fs.readFileSync(docPath, 'utf-8');
|
|
172
|
+
expect(content).toContain('# Phase 1');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('creates milestone with description in doc file', async () => {
|
|
176
|
+
const milestone = await adapter.createMilestone({
|
|
177
|
+
name: 'Phase 1',
|
|
178
|
+
description: 'Initial development phase',
|
|
179
|
+
});
|
|
180
|
+
const docPath = path.join(testDir, 'milestones', `${milestone.id}.md`);
|
|
181
|
+
|
|
182
|
+
const content = fs.readFileSync(docPath, 'utf-8');
|
|
183
|
+
expect(content).toContain('# Phase 1');
|
|
184
|
+
expect(content).toContain('Initial development phase');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('deletes both json and md files on milestone delete', async () => {
|
|
188
|
+
const milestone = await adapter.createMilestone({ name: 'Cleanup' });
|
|
189
|
+
const jsonPath = path.join(testDir, 'milestones', `${milestone.id}.json`);
|
|
190
|
+
const mdPath = path.join(testDir, 'milestones', `${milestone.id}.md`);
|
|
191
|
+
|
|
192
|
+
expect(fs.existsSync(jsonPath)).toBe(true);
|
|
193
|
+
expect(fs.existsSync(mdPath)).toBe(true);
|
|
194
|
+
|
|
195
|
+
await adapter.deleteMilestone(milestone.id);
|
|
196
|
+
|
|
197
|
+
expect(fs.existsSync(jsonPath)).toBe(false);
|
|
198
|
+
expect(fs.existsSync(mdPath)).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('Milestone Documentation', () => {
|
|
203
|
+
beforeEach(async () => {
|
|
204
|
+
await adapter.initTeam('Test Project');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('gets milestone documentation', async () => {
|
|
208
|
+
await adapter.createMilestone({ name: 'Phase 1', description: 'Intro' });
|
|
209
|
+
const doc = await adapter.getMilestoneDoc('1');
|
|
210
|
+
|
|
211
|
+
expect(doc).toContain('# Phase 1');
|
|
212
|
+
expect(doc).toContain('Intro');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('sets milestone documentation', async () => {
|
|
216
|
+
await adapter.createMilestone({ name: 'Test' });
|
|
217
|
+
await adapter.setMilestoneDoc('1', '# Updated\n\nNew content');
|
|
218
|
+
|
|
219
|
+
const doc = await adapter.getMilestoneDoc('1');
|
|
220
|
+
expect(doc).toBe('# Updated\n\nNew content');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('creates milestones dir when setting doc if missing', async () => {
|
|
224
|
+
await adapter.createMilestone({ name: 'Test' });
|
|
225
|
+
// Remove the milestones dir
|
|
226
|
+
fs.rmSync(path.join(testDir, 'milestones'), { recursive: true, force: true });
|
|
227
|
+
|
|
228
|
+
await adapter.setMilestoneDoc('1', '# Updated\n\nBody');
|
|
229
|
+
|
|
230
|
+
expect(fs.existsSync(path.join(testDir, 'milestones', '1.md'))).toBe(true);
|
|
231
|
+
const doc = await adapter.getMilestoneDoc('1');
|
|
232
|
+
expect(doc).toBe('# Updated\n\nBody');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('returns null for non-existent doc', async () => {
|
|
236
|
+
const doc = await adapter.getMilestoneDoc('999');
|
|
237
|
+
expect(doc).toBeNull();
|
|
238
|
+
});
|
|
165
239
|
});
|
|
166
240
|
|
|
167
241
|
describe('Task Operations', () => {
|
|
@@ -254,6 +328,60 @@ describe('FSAdapter', () => {
|
|
|
254
328
|
expect(tasks[0].assignee).toBe('claude');
|
|
255
329
|
});
|
|
256
330
|
|
|
331
|
+
it('excludes tasks in completed milestones by default', async () => {
|
|
332
|
+
// Create milestones
|
|
333
|
+
const m1 = await adapter.createMilestone({ name: 'Done Milestone' });
|
|
334
|
+
const m2 = await adapter.createMilestone({ name: 'Open Milestone' });
|
|
335
|
+
await adapter.updateMilestone(m1.id, { status: 'done' });
|
|
336
|
+
|
|
337
|
+
// Create tasks in both milestones
|
|
338
|
+
await adapter.createTask({ title: 'Task in done milestone', milestone: m1.id });
|
|
339
|
+
await adapter.createTask({ title: 'Task in open milestone', milestone: m2.id });
|
|
340
|
+
await adapter.createTask({ title: 'Task without milestone' });
|
|
341
|
+
|
|
342
|
+
// Default: excludeCompletedMilestones = true
|
|
343
|
+
const tasks = await adapter.listTasks();
|
|
344
|
+
expect(tasks).toHaveLength(2);
|
|
345
|
+
expect(tasks.map((t) => t.title)).not.toContain('Task in done milestone');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('includes tasks in completed milestones when excludeCompletedMilestones is false', async () => {
|
|
349
|
+
// Create milestones
|
|
350
|
+
const m1 = await adapter.createMilestone({ name: 'Done Milestone' });
|
|
351
|
+
await adapter.updateMilestone(m1.id, { status: 'done' });
|
|
352
|
+
|
|
353
|
+
// Create task in done milestone
|
|
354
|
+
await adapter.createTask({ title: 'Task in done milestone', milestone: m1.id });
|
|
355
|
+
await adapter.createTask({ title: 'Task without milestone' });
|
|
356
|
+
|
|
357
|
+
// Include all tasks
|
|
358
|
+
const tasks = await adapter.listTasks({ excludeCompletedMilestones: false });
|
|
359
|
+
expect(tasks).toHaveLength(2);
|
|
360
|
+
expect(tasks.map((t) => t.title)).toContain('Task in done milestone');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('hides tasks without milestones when hideOrphanTasks is true', async () => {
|
|
364
|
+
const milestone = await adapter.createMilestone({ name: 'Has Tasks' });
|
|
365
|
+
|
|
366
|
+
await adapter.createTask({ title: 'With milestone', milestone: milestone.id });
|
|
367
|
+
await adapter.createTask({ title: 'Without milestone' });
|
|
368
|
+
|
|
369
|
+
const tasks = await adapter.listTasks({ hideOrphanTasks: true });
|
|
370
|
+
expect(tasks).toHaveLength(1);
|
|
371
|
+
expect(tasks[0].title).toBe('With milestone');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('includes tasks without milestones when hideOrphanTasks is false', async () => {
|
|
375
|
+
const milestone = await adapter.createMilestone({ name: 'Has Tasks' });
|
|
376
|
+
|
|
377
|
+
await adapter.createTask({ title: 'With milestone', milestone: milestone.id });
|
|
378
|
+
await adapter.createTask({ title: 'Without milestone' });
|
|
379
|
+
|
|
380
|
+
const tasks = await adapter.listTasks({ hideOrphanTasks: false });
|
|
381
|
+
expect(tasks).toHaveLength(2);
|
|
382
|
+
expect(tasks.map((t) => t.title)).toContain('Without milestone');
|
|
383
|
+
});
|
|
384
|
+
|
|
257
385
|
it('returns task by ID', async () => {
|
|
258
386
|
await adapter.createTask({ title: 'Test Task' });
|
|
259
387
|
const task = await adapter.getTask('1');
|
package/src/pm/storage/fs.ts
CHANGED
|
@@ -106,10 +106,17 @@ export class FSAdapter implements StorageAdapter {
|
|
|
106
106
|
id,
|
|
107
107
|
name: input.name,
|
|
108
108
|
status: 'pending',
|
|
109
|
+
description: input.description,
|
|
110
|
+
docPath: `milestones/${id}.md`,
|
|
109
111
|
createdAt: this.now(),
|
|
110
112
|
updatedAt: this.now(),
|
|
111
113
|
};
|
|
112
114
|
this.writeJson(path.join(this.milestonesDir, `${id}.json`), milestone);
|
|
115
|
+
// Create doc file with name and optional description
|
|
116
|
+
const docContent = input.description
|
|
117
|
+
? `# ${input.name}\n\n${input.description}\n`
|
|
118
|
+
: `# ${input.name}\n\n`;
|
|
119
|
+
fs.writeFileSync(path.join(this.milestonesDir, `${id}.md`), docContent);
|
|
113
120
|
return milestone;
|
|
114
121
|
}
|
|
115
122
|
|
|
@@ -141,10 +148,10 @@ export class FSAdapter implements StorageAdapter {
|
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
async deleteMilestone(id: string): Promise<void> {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
151
|
+
const jsonPath = path.join(this.milestonesDir, `${id}.json`);
|
|
152
|
+
const mdPath = path.join(this.milestonesDir, `${id}.md`);
|
|
153
|
+
if (fs.existsSync(jsonPath)) fs.unlinkSync(jsonPath);
|
|
154
|
+
if (fs.existsSync(mdPath)) fs.unlinkSync(mdPath);
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
// Task operations
|
|
@@ -191,6 +198,21 @@ export class FSAdapter implements StorageAdapter {
|
|
|
191
198
|
tasks = tasks.filter((t) => t.assignee === filter.assignee);
|
|
192
199
|
}
|
|
193
200
|
|
|
201
|
+
// Exclude tasks in completed milestones (default: true)
|
|
202
|
+
const excludeCompleted = filter?.excludeCompletedMilestones ?? true;
|
|
203
|
+
if (excludeCompleted) {
|
|
204
|
+
const milestones = await this.listMilestones();
|
|
205
|
+
const completedMilestoneIds = new Set(
|
|
206
|
+
milestones.filter((m) => m.status === 'done').map((m) => m.id)
|
|
207
|
+
);
|
|
208
|
+
tasks = tasks.filter((t) => !t.milestone || !completedMilestoneIds.has(t.milestone));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Hide tasks without milestone if configured
|
|
212
|
+
if (filter?.hideOrphanTasks) {
|
|
213
|
+
tasks = tasks.filter((t) => t.milestone);
|
|
214
|
+
}
|
|
215
|
+
|
|
194
216
|
return tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
195
217
|
}
|
|
196
218
|
|
|
@@ -225,6 +247,18 @@ export class FSAdapter implements StorageAdapter {
|
|
|
225
247
|
fs.writeFileSync(docPath, content);
|
|
226
248
|
}
|
|
227
249
|
|
|
250
|
+
async getMilestoneDoc(id: string): Promise<string | null> {
|
|
251
|
+
const docPath = path.join(this.milestonesDir, `${id}.md`);
|
|
252
|
+
if (!fs.existsSync(docPath)) return null;
|
|
253
|
+
return fs.readFileSync(docPath, 'utf-8');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async setMilestoneDoc(id: string, content: string): Promise<void> {
|
|
257
|
+
this.ensureDir(this.milestonesDir);
|
|
258
|
+
const docPath = path.join(this.milestonesDir, `${id}.md`);
|
|
259
|
+
fs.writeFileSync(docPath, content);
|
|
260
|
+
}
|
|
261
|
+
|
|
228
262
|
// Audit log
|
|
229
263
|
async appendEvent(event: AuditEvent): Promise<void> {
|
|
230
264
|
this.ensureDir(this.teamDir);
|
package/src/pm/storage/github.ts
CHANGED
|
@@ -53,6 +53,7 @@ interface GHIssue {
|
|
|
53
53
|
interface GHMilestone {
|
|
54
54
|
number: number;
|
|
55
55
|
title: string;
|
|
56
|
+
description: string | null;
|
|
56
57
|
state: 'open' | 'closed'; // REST API uses lowercase (unlike GraphQL)
|
|
57
58
|
createdAt: string;
|
|
58
59
|
}
|
|
@@ -61,13 +62,13 @@ interface GHMilestone {
|
|
|
61
62
|
// Local cache for ID mapping (task ID -> issue number)
|
|
62
63
|
// ─────────────────────────────────────────────────────────────
|
|
63
64
|
|
|
64
|
-
const CACHE_VERSION =
|
|
65
|
+
const CACHE_VERSION = 3; // Bumped: milestone state now cached
|
|
65
66
|
|
|
66
67
|
interface IdCache {
|
|
67
68
|
version: number; // Cache format version for migrations
|
|
68
69
|
repo: string; // Associated repo to detect cross-repo drift
|
|
69
70
|
tasks: Record<string, number>; // task ID -> issue number
|
|
70
|
-
milestones: Record<string, { number: number; name: string }>; // milestone ID -> {number, name}
|
|
71
|
+
milestones: Record<string, { number: number; name: string; state?: 'open' | 'closed' }>; // milestone ID -> {number, name, state}
|
|
71
72
|
nextTaskId: number;
|
|
72
73
|
nextMilestoneId: number;
|
|
73
74
|
}
|
|
@@ -89,7 +90,7 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
89
90
|
// Helper: Execute gh CLI command safely (no shell injection)
|
|
90
91
|
// ─────────────────────────────────────────────────────────────
|
|
91
92
|
|
|
92
|
-
private gh(args: string[], options?: { skipRepo?: boolean }): string {
|
|
93
|
+
private gh(args: string[], options?: { skipRepo?: boolean; input?: string }): string {
|
|
93
94
|
const fullArgs = [...args];
|
|
94
95
|
// gh api doesn't accept --repo flag (repo is in the endpoint path)
|
|
95
96
|
// Other commands like 'gh issue' do accept --repo
|
|
@@ -101,6 +102,7 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
101
102
|
const result = spawnSync('gh', fullArgs, {
|
|
102
103
|
encoding: 'utf-8',
|
|
103
104
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
105
|
+
input: options?.input,
|
|
104
106
|
});
|
|
105
107
|
|
|
106
108
|
if (result.error) {
|
|
@@ -137,9 +139,17 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
137
139
|
try {
|
|
138
140
|
const data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf-8')) as IdCache;
|
|
139
141
|
|
|
142
|
+
// Migrate from version 2 to 3: add milestone state field
|
|
143
|
+
if (data.version === 2) {
|
|
144
|
+
// Preserve all existing data, just update version
|
|
145
|
+
// Milestone states will be populated on next listMilestones() call
|
|
146
|
+
data.version = 3;
|
|
147
|
+
this.saveCache(data);
|
|
148
|
+
}
|
|
149
|
+
|
|
140
150
|
// Validate cache integrity
|
|
141
151
|
if (data.version !== CACHE_VERSION) {
|
|
142
|
-
//
|
|
152
|
+
// Unknown version - reset cache
|
|
143
153
|
console.error(`[tmux-team] Cache version mismatch, resetting cache`);
|
|
144
154
|
return this.createEmptyCache();
|
|
145
155
|
}
|
|
@@ -254,6 +264,8 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
254
264
|
id,
|
|
255
265
|
name: ghMilestone.title,
|
|
256
266
|
status: ghMilestone.state === 'closed' ? 'done' : 'pending',
|
|
267
|
+
description: ghMilestone.description || undefined,
|
|
268
|
+
docPath: `github:milestone/${ghMilestone.number}`,
|
|
257
269
|
createdAt: ghMilestone.createdAt,
|
|
258
270
|
updatedAt: ghMilestone.createdAt, // GH milestones don't have updatedAt
|
|
259
271
|
};
|
|
@@ -348,7 +360,7 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
348
360
|
|
|
349
361
|
async createMilestone(input: CreateMilestoneInput): Promise<Milestone> {
|
|
350
362
|
// Create milestone in GitHub
|
|
351
|
-
const
|
|
363
|
+
const args = [
|
|
352
364
|
'api',
|
|
353
365
|
`repos/${this.repo}/milestones`,
|
|
354
366
|
'-X',
|
|
@@ -357,19 +369,30 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
357
369
|
`title=${input.name}`,
|
|
358
370
|
'-f',
|
|
359
371
|
'state=open',
|
|
360
|
-
]
|
|
361
|
-
|
|
372
|
+
];
|
|
373
|
+
if (input.description) {
|
|
374
|
+
args.push('-f', `description=${input.description}`);
|
|
375
|
+
}
|
|
376
|
+
const result = this.gh(args);
|
|
377
|
+
const ghMilestone = JSON.parse(result) as {
|
|
378
|
+
number: number;
|
|
379
|
+
title: string;
|
|
380
|
+
description: string | null;
|
|
381
|
+
created_at: string;
|
|
382
|
+
};
|
|
362
383
|
|
|
363
|
-
// Cache the ID mapping (store
|
|
384
|
+
// Cache the ID mapping (store number, name, and state)
|
|
364
385
|
const cache = this.loadCache();
|
|
365
386
|
const id = String(cache.nextMilestoneId++);
|
|
366
|
-
cache.milestones[id] = { number: ghMilestone.number, name: ghMilestone.title };
|
|
387
|
+
cache.milestones[id] = { number: ghMilestone.number, name: ghMilestone.title, state: 'open' };
|
|
367
388
|
this.saveCache(cache);
|
|
368
389
|
|
|
369
390
|
return {
|
|
370
391
|
id,
|
|
371
392
|
name: ghMilestone.title,
|
|
372
393
|
status: 'pending',
|
|
394
|
+
description: ghMilestone.description || undefined,
|
|
395
|
+
docPath: `github:milestone/${ghMilestone.number}`,
|
|
373
396
|
createdAt: ghMilestone.created_at,
|
|
374
397
|
updatedAt: ghMilestone.created_at,
|
|
375
398
|
};
|
|
@@ -416,8 +439,9 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
416
439
|
if (!id) {
|
|
417
440
|
// New milestone from GitHub, assign ID
|
|
418
441
|
id = String(cache.nextMilestoneId++);
|
|
419
|
-
cache.milestones[id] = { number: ghm.number, name: ghm.title };
|
|
420
442
|
}
|
|
443
|
+
// Always update cache with current state
|
|
444
|
+
cache.milestones[id] = { number: ghm.number, name: ghm.title, state: ghm.state };
|
|
421
445
|
milestones.push(this.milestoneToMilestone(ghm, id));
|
|
422
446
|
}
|
|
423
447
|
|
|
@@ -443,9 +467,24 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
443
467
|
if (input.status) {
|
|
444
468
|
args.push('-f', `state=${input.status === 'done' ? 'closed' : 'open'}`);
|
|
445
469
|
}
|
|
470
|
+
if (input.description !== undefined) {
|
|
471
|
+
args.push('-f', `description=${input.description}`);
|
|
472
|
+
}
|
|
446
473
|
|
|
447
474
|
const result = this.gh(args);
|
|
448
475
|
const ghMilestone = JSON.parse(result) as GHMilestone;
|
|
476
|
+
|
|
477
|
+
// Update cache with new name/state
|
|
478
|
+
const cache = this.loadCache();
|
|
479
|
+
if (cache.milestones[id]) {
|
|
480
|
+
cache.milestones[id] = {
|
|
481
|
+
...cache.milestones[id],
|
|
482
|
+
name: ghMilestone.title,
|
|
483
|
+
state: ghMilestone.state,
|
|
484
|
+
};
|
|
485
|
+
this.saveCache(cache);
|
|
486
|
+
}
|
|
487
|
+
|
|
449
488
|
return this.milestoneToMilestone(ghMilestone, id);
|
|
450
489
|
}
|
|
451
490
|
|
|
@@ -453,13 +492,12 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
453
492
|
const number = this.getMilestoneNumber(id);
|
|
454
493
|
if (!number) return;
|
|
455
494
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
495
|
+
// Note: We intentionally don't delete the milestone from GitHub.
|
|
496
|
+
// GitHub milestones may be used by other tools, and deletion is destructive.
|
|
497
|
+
// Instead, we only remove it from the local cache (soft delete).
|
|
498
|
+
// To permanently delete, use the GitHub web UI or `gh api` directly.
|
|
461
499
|
|
|
462
|
-
// Remove from cache
|
|
500
|
+
// Remove from cache only
|
|
463
501
|
const cache = this.loadCache();
|
|
464
502
|
delete cache.milestones[id];
|
|
465
503
|
this.saveCache(cache);
|
|
@@ -580,7 +618,7 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
580
618
|
|
|
581
619
|
try {
|
|
582
620
|
const issues = this.ghJson<GHIssue[]>(args);
|
|
583
|
-
|
|
621
|
+
let tasks: Task[] = [];
|
|
584
622
|
|
|
585
623
|
for (const issue of issues) {
|
|
586
624
|
// Find or create ID mapping
|
|
@@ -593,6 +631,23 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
593
631
|
tasks.push(this.issueToTask(issue, id, cache));
|
|
594
632
|
}
|
|
595
633
|
|
|
634
|
+
// Exclude tasks in completed milestones (default: true)
|
|
635
|
+
const excludeCompleted = filter?.excludeCompletedMilestones ?? true;
|
|
636
|
+
if (excludeCompleted) {
|
|
637
|
+
// Build set of closed milestone IDs from cache
|
|
638
|
+
const closedMilestoneIds = new Set(
|
|
639
|
+
Object.entries(cache.milestones)
|
|
640
|
+
.filter(([, m]) => m.state === 'closed')
|
|
641
|
+
.map(([id]) => id)
|
|
642
|
+
);
|
|
643
|
+
tasks = tasks.filter((t) => !t.milestone || !closedMilestoneIds.has(t.milestone));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Hide tasks without milestone if configured
|
|
647
|
+
if (filter?.hideOrphanTasks) {
|
|
648
|
+
tasks = tasks.filter((t) => t.milestone);
|
|
649
|
+
}
|
|
650
|
+
|
|
596
651
|
this.saveCache(cache);
|
|
597
652
|
return tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
598
653
|
} catch (error) {
|
|
@@ -705,6 +760,30 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
705
760
|
this.gh(['issue', 'edit', String(number), '--body', content]);
|
|
706
761
|
}
|
|
707
762
|
|
|
763
|
+
async getMilestoneDoc(id: string): Promise<string | null> {
|
|
764
|
+
const number = this.getMilestoneNumber(id);
|
|
765
|
+
if (!number) return null;
|
|
766
|
+
|
|
767
|
+
try {
|
|
768
|
+
const result = this.gh(['api', `repos/${this.repo}/milestones/${number}`]);
|
|
769
|
+
const ghMilestone = JSON.parse(result) as GHMilestone;
|
|
770
|
+
return ghMilestone.description || null;
|
|
771
|
+
} catch {
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async setMilestoneDoc(id: string, content: string): Promise<void> {
|
|
777
|
+
const number = this.getMilestoneNumber(id);
|
|
778
|
+
if (!number) throw new Error(`Milestone ${id} not found`);
|
|
779
|
+
|
|
780
|
+
// Use -F with @- to read from stdin (handles multiline content properly)
|
|
781
|
+
this.gh(
|
|
782
|
+
['api', `repos/${this.repo}/milestones/${number}`, '-X', 'PATCH', '-F', 'description=@-'],
|
|
783
|
+
{ input: content }
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
708
787
|
// ─────────────────────────────────────────────────────────────
|
|
709
788
|
// Audit log (Issue Comments)
|
|
710
789
|
// ─────────────────────────────────────────────────────────────
|
package/src/pm/types.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface Milestone {
|
|
|
16
16
|
id: string;
|
|
17
17
|
name: string;
|
|
18
18
|
status: MilestoneStatus;
|
|
19
|
+
description?: string;
|
|
20
|
+
docPath?: string;
|
|
19
21
|
createdAt: string;
|
|
20
22
|
updatedAt: string;
|
|
21
23
|
}
|
|
@@ -55,17 +57,21 @@ export interface UpdateTaskInput {
|
|
|
55
57
|
|
|
56
58
|
export interface CreateMilestoneInput {
|
|
57
59
|
name: string;
|
|
60
|
+
description?: string;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
export interface UpdateMilestoneInput {
|
|
61
64
|
name?: string;
|
|
62
65
|
status?: MilestoneStatus;
|
|
66
|
+
description?: string;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
export interface ListTasksFilter {
|
|
66
70
|
milestone?: string;
|
|
67
71
|
status?: TaskStatus;
|
|
68
72
|
assignee?: string;
|
|
73
|
+
excludeCompletedMilestones?: boolean; // Hide tasks in completed milestones (default: true)
|
|
74
|
+
hideOrphanTasks?: boolean; // Hide tasks without milestone (default: false)
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
// ─────────────────────────────────────────────────────────────
|
package/src/state.test.ts
CHANGED
|
@@ -51,8 +51,11 @@ describe('State Management', () => {
|
|
|
51
51
|
fs.writeFileSync(paths.stateFile, JSON.stringify(existingState));
|
|
52
52
|
|
|
53
53
|
const state = loadState(paths);
|
|
54
|
-
expect(state.requests.claude).
|
|
55
|
-
|
|
54
|
+
expect(state.requests.claude).toMatchObject({
|
|
55
|
+
id: 'req-1',
|
|
56
|
+
nonce: 'abc123',
|
|
57
|
+
pane: '1.0',
|
|
58
|
+
});
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
it('returns empty state when state.json is corrupted', () => {
|
|
@@ -123,7 +126,11 @@ describe('State Management', () => {
|
|
|
123
126
|
|
|
124
127
|
const cleaned = cleanupState(paths, 60); // 60 second TTL
|
|
125
128
|
|
|
126
|
-
expect(cleaned.requests.recentAgent).
|
|
129
|
+
expect(cleaned.requests.recentAgent).toMatchObject({
|
|
130
|
+
id: 'new',
|
|
131
|
+
nonce: 'new',
|
|
132
|
+
pane: '1.0',
|
|
133
|
+
});
|
|
127
134
|
});
|
|
128
135
|
|
|
129
136
|
it('requires ttlSeconds parameter', () => {
|
|
@@ -142,7 +149,7 @@ describe('State Management', () => {
|
|
|
142
149
|
// With 10s TTL, agent1 should be kept, agent2 removed
|
|
143
150
|
const cleaned = cleanupState(paths, 10);
|
|
144
151
|
|
|
145
|
-
expect(cleaned.requests.agent1).
|
|
152
|
+
expect(cleaned.requests.agent1).toMatchObject({ id: '1', nonce: 'a' });
|
|
146
153
|
expect(cleaned.requests.agent2).toBeUndefined();
|
|
147
154
|
});
|
|
148
155
|
|
|
@@ -193,8 +200,11 @@ describe('State Management', () => {
|
|
|
193
200
|
setActiveRequest(paths, 'claude', req);
|
|
194
201
|
|
|
195
202
|
const state = loadState(paths);
|
|
196
|
-
expect(state.requests.claude).
|
|
197
|
-
|
|
203
|
+
expect(state.requests.claude).toMatchObject({
|
|
204
|
+
id: 'req-1',
|
|
205
|
+
nonce: 'nonce123',
|
|
206
|
+
pane: '1.0',
|
|
207
|
+
});
|
|
198
208
|
});
|
|
199
209
|
|
|
200
210
|
it('stores request with id, nonce, pane, and startedAtMs', () => {
|
|
@@ -244,8 +254,8 @@ describe('State Management', () => {
|
|
|
244
254
|
setActiveRequest(paths, 'codex', req2);
|
|
245
255
|
|
|
246
256
|
const state = loadState(paths);
|
|
247
|
-
expect(state.requests.claude).
|
|
248
|
-
expect(state.requests.codex).
|
|
257
|
+
expect(state.requests.claude).toMatchObject({ id: '1', nonce: 'a' });
|
|
258
|
+
expect(state.requests.codex).toMatchObject({ id: '2', nonce: 'b' });
|
|
249
259
|
});
|
|
250
260
|
});
|
|
251
261
|
|
|
@@ -282,7 +292,7 @@ describe('State Management', () => {
|
|
|
282
292
|
clearActiveRequest(paths, 'claude', 'wrong-id');
|
|
283
293
|
|
|
284
294
|
const state = loadState(paths);
|
|
285
|
-
expect(state.requests.claude).
|
|
295
|
+
expect(state.requests.claude).toMatchObject({ id: 'req-1', nonce: 'a' }); // Should still exist
|
|
286
296
|
});
|
|
287
297
|
|
|
288
298
|
it('clears when requestId matches', () => {
|
|
@@ -305,7 +315,7 @@ describe('State Management', () => {
|
|
|
305
315
|
|
|
306
316
|
const state = loadState(paths);
|
|
307
317
|
expect(state.requests.claude).toBeUndefined();
|
|
308
|
-
expect(state.requests.codex).
|
|
318
|
+
expect(state.requests.codex).toMatchObject({ id: '2', nonce: 'b' });
|
|
309
319
|
});
|
|
310
320
|
});
|
|
311
321
|
});
|
package/src/state.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface AgentRequestState {
|
|
|
15
15
|
|
|
16
16
|
export interface StateFile {
|
|
17
17
|
requests: Record<string, AgentRequestState>;
|
|
18
|
+
preambleCounters?: Record<string, number>; // agentName -> message count
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
const DEFAULT_STATE: StateFile = { requests: {} };
|
|
@@ -52,7 +53,10 @@ export function cleanupState(paths: Paths, ttlSeconds: number): StateFile {
|
|
|
52
53
|
const now = Date.now();
|
|
53
54
|
|
|
54
55
|
const ttlMs = Math.max(1, ttlSeconds) * 1000;
|
|
55
|
-
const next: StateFile = {
|
|
56
|
+
const next: StateFile = {
|
|
57
|
+
requests: {},
|
|
58
|
+
preambleCounters: state.preambleCounters, // Preserve preamble counters
|
|
59
|
+
};
|
|
56
60
|
|
|
57
61
|
for (const [agent, req] of Object.entries(state.requests)) {
|
|
58
62
|
if (!req || typeof req.startedAtMs !== 'number') continue;
|
|
@@ -81,3 +85,26 @@ export function clearActiveRequest(paths: Paths, agent: string, requestId?: stri
|
|
|
81
85
|
delete state.requests[agent];
|
|
82
86
|
saveState(paths, state);
|
|
83
87
|
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get the current preamble counter for an agent.
|
|
91
|
+
* Returns 0 if not set.
|
|
92
|
+
*/
|
|
93
|
+
export function getPreambleCounter(paths: Paths, agent: string): number {
|
|
94
|
+
const state = loadState(paths);
|
|
95
|
+
return state.preambleCounters?.[agent] ?? 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Increment the preamble counter for an agent and return the new value.
|
|
100
|
+
*/
|
|
101
|
+
export function incrementPreambleCounter(paths: Paths, agent: string): number {
|
|
102
|
+
const state = loadState(paths);
|
|
103
|
+
if (!state.preambleCounters) {
|
|
104
|
+
state.preambleCounters = {};
|
|
105
|
+
}
|
|
106
|
+
const newCount = (state.preambleCounters[agent] ?? 0) + 1;
|
|
107
|
+
state.preambleCounters[agent] = newCount;
|
|
108
|
+
saveState(paths, state);
|
|
109
|
+
return newCount;
|
|
110
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -10,24 +10,28 @@ export interface AgentConfig {
|
|
|
10
10
|
export interface PaneEntry {
|
|
11
11
|
pane: string;
|
|
12
12
|
remark?: string;
|
|
13
|
+
preamble?: string; // Agent preamble (prepended to messages)
|
|
14
|
+
deny?: string[]; // Permission deny patterns
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface ConfigDefaults {
|
|
16
18
|
timeout: number; // seconds
|
|
17
19
|
pollInterval: number; // seconds
|
|
18
20
|
captureLines: number;
|
|
21
|
+
preambleEvery: number; // inject preamble every N messages (default: 3)
|
|
22
|
+
hideOrphanTasks: boolean; // hide tasks without milestone in list (default: false)
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export interface GlobalConfig {
|
|
22
26
|
mode: 'polling' | 'wait';
|
|
23
27
|
preambleMode: 'always' | 'disabled';
|
|
24
28
|
defaults: ConfigDefaults;
|
|
25
|
-
agents: Record<string, AgentConfig>;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export interface LocalSettings {
|
|
29
32
|
mode?: 'polling' | 'wait';
|
|
30
33
|
preambleMode?: 'always' | 'disabled';
|
|
34
|
+
preambleEvery?: number; // local override for preamble frequency
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export interface LocalConfigFile {
|
package/src/ui.ts
CHANGED
|
@@ -7,7 +7,8 @@ import type { UI } from './types.js';
|
|
|
7
7
|
const isTTY = process.stdout.isTTY;
|
|
8
8
|
|
|
9
9
|
// Strip ANSI escape codes for accurate length calculation
|
|
10
|
-
const
|
|
10
|
+
const ansiEscape = String.fromCharCode(27);
|
|
11
|
+
const stripAnsi = (s: string) => s.replace(new RegExp(`${ansiEscape}\\[[0-9;]*m`, 'g'), '');
|
|
11
12
|
|
|
12
13
|
export const colors = {
|
|
13
14
|
red: (s: string) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s),
|