tmux-team 2.0.0-alpha.1 → 2.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,157 +2,1126 @@
2
2
  // PM Commands Tests
3
3
  // ─────────────────────────────────────────────────────────────
4
4
 
5
- import { describe, it } from 'vitest';
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import os from 'os';
9
+ import type { Context } from '../types.js';
10
+ import type { UI } from '../types.js';
11
+ // ExitCodes imported for reference but tested via ctx.exit mock
12
+ import {
13
+ cmdPm,
14
+ cmdPmInit,
15
+ cmdPmMilestone,
16
+ cmdPmTask,
17
+ cmdPmDoc,
18
+ cmdPmLog,
19
+ cmdPmList,
20
+ } from './commands.js';
21
+ import { findCurrentTeamId, linkTeam, getTeamsDir } from './manager.js';
22
+
23
+ // ─────────────────────────────────────────────────────────────
24
+ // Test helpers
25
+ // ─────────────────────────────────────────────────────────────
26
+
27
+ function createMockUI(): UI & { logs: string[]; errors: string[]; jsonData: unknown[] } {
28
+ const logs: string[] = [];
29
+ const errors: string[] = [];
30
+ const jsonData: unknown[] = [];
31
+
32
+ return {
33
+ logs,
34
+ errors,
35
+ jsonData,
36
+ info: vi.fn((msg: string) => logs.push(`[info] ${msg}`)),
37
+ success: vi.fn((msg: string) => logs.push(`[success] ${msg}`)),
38
+ warn: vi.fn((msg: string) => logs.push(`[warn] ${msg}`)),
39
+ error: vi.fn((msg: string) => errors.push(msg)),
40
+ table: vi.fn((_headers: string[], _rows: string[][]) => logs.push('[table]')),
41
+ json: vi.fn((data: unknown) => jsonData.push(data)),
42
+ };
43
+ }
44
+
45
+ function createMockContext(
46
+ globalDir: string,
47
+ options: { json?: boolean; cwd?: string; agents?: Record<string, { deny?: string[] }> } = {}
48
+ ): Context & { ui: ReturnType<typeof createMockUI>; exitCode: number | null } {
49
+ const ui = createMockUI();
50
+ let exitCode: number | null = null;
51
+
52
+ // Override cwd for tests
53
+ const originalCwd = process.cwd;
54
+ if (options.cwd) {
55
+ vi.spyOn(process, 'cwd').mockReturnValue(options.cwd);
56
+ }
57
+
58
+ return {
59
+ ui,
60
+ exitCode,
61
+ flags: { json: options.json ?? false },
62
+ paths: { globalDir, configFile: path.join(globalDir, 'config.json') },
63
+ config: {
64
+ mode: 'polling',
65
+ preambleMode: 'always',
66
+ defaults: { timeout: 60, pollInterval: 1, captureLines: 100 },
67
+ agents: options.agents ?? {},
68
+ paneRegistry: {},
69
+ },
70
+ exit: vi.fn((code: number) => {
71
+ exitCode = code;
72
+ throw new Error(`Exit: ${code}`);
73
+ }),
74
+ restoreCwd: () => {
75
+ if (options.cwd) {
76
+ vi.spyOn(process, 'cwd').mockImplementation(originalCwd);
77
+ }
78
+ },
79
+ } as unknown as Context & { ui: ReturnType<typeof createMockUI>; exitCode: number | null };
80
+ }
81
+
82
+ // ─────────────────────────────────────────────────────────────
83
+ // requireTeam tests
84
+ // ─────────────────────────────────────────────────────────────
6
85
 
7
86
  describe('requireTeam', () => {
8
- // Test finds team from .tmux-team-id file
9
- it.todo('finds team ID from .tmux-team-id file in cwd');
87
+ let testDir: string;
88
+ let globalDir: string;
89
+
90
+ beforeEach(() => {
91
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
92
+ globalDir = path.join(testDir, 'global');
93
+ fs.mkdirSync(globalDir, { recursive: true });
94
+ });
95
+
96
+ afterEach(() => {
97
+ vi.restoreAllMocks();
98
+ if (fs.existsSync(testDir)) {
99
+ fs.rmSync(testDir, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ it('finds team ID from .tmux-team-id file in cwd', () => {
104
+ const projectDir = path.join(testDir, 'project');
105
+ fs.mkdirSync(projectDir, { recursive: true });
106
+ fs.writeFileSync(path.join(projectDir, '.tmux-team-id'), 'test-team-123\n');
10
107
 
11
- // Test finds team from TMUX_TEAM_ID env
12
- it.todo('finds team ID from TMUX_TEAM_ID environment variable');
108
+ const teamId = findCurrentTeamId(projectDir, globalDir);
109
+ expect(teamId).toBe('test-team-123');
110
+ });
13
111
 
14
- // Test validates team.json exists
15
- it.todo('validates team.json exists for team ID');
112
+ it('finds team ID from TMUX_TEAM_ID environment variable', () => {
113
+ const originalEnv = process.env.TMUX_TEAM_ID;
114
+ process.env.TMUX_TEAM_ID = 'env-team-456';
16
115
 
17
- // Test error for missing team link
18
- it.todo('exits with error when no .tmux-team-id found');
116
+ try {
117
+ const teamId = findCurrentTeamId(testDir, globalDir);
118
+ expect(teamId).toBe('env-team-456');
119
+ } finally {
120
+ if (originalEnv) {
121
+ process.env.TMUX_TEAM_ID = originalEnv;
122
+ } else {
123
+ delete process.env.TMUX_TEAM_ID;
124
+ }
125
+ }
126
+ });
19
127
 
20
- // Test error for stale team ID
21
- it.todo('exits with error when team.json does not exist (stale ID)');
128
+ it('validates team.json exists for team ID', async () => {
129
+ const projectDir = path.join(testDir, 'project');
130
+ fs.mkdirSync(projectDir, { recursive: true });
131
+
132
+ // Create team directory with team.json
133
+ const teamId = 'valid-team-id';
134
+ const teamDir = path.join(globalDir, 'teams', teamId);
135
+ fs.mkdirSync(teamDir, { recursive: true });
136
+ fs.writeFileSync(
137
+ path.join(teamDir, 'team.json'),
138
+ JSON.stringify({ id: teamId, name: 'Test', createdAt: new Date().toISOString() })
139
+ );
140
+
141
+ // Link project to team
142
+ linkTeam(projectDir, teamId);
143
+
144
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
145
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
146
+
147
+ // Should not throw - team is valid
148
+ await cmdPmTask(ctx, ['list']);
149
+ expect(ctx.ui.logs.some((l) => l.includes('[info]') || l.includes('[table]'))).toBe(true);
150
+ });
151
+
152
+ it('exits with error when no .tmux-team-id found', async () => {
153
+ const projectDir = path.join(testDir, 'empty-project');
154
+ fs.mkdirSync(projectDir, { recursive: true });
155
+
156
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
157
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
158
+
159
+ await expect(cmdPmTask(ctx, ['list'])).rejects.toThrow('Exit');
160
+ expect(ctx.ui.errors[0]).toContain('No team found');
161
+ });
162
+
163
+ it('exits with error when team.json does not exist (stale ID)', async () => {
164
+ const projectDir = path.join(testDir, 'stale-project');
165
+ fs.mkdirSync(projectDir, { recursive: true });
166
+
167
+ // Create .tmux-team-id pointing to non-existent team
168
+ fs.writeFileSync(path.join(projectDir, '.tmux-team-id'), 'stale-team-id\n');
169
+
170
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
171
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
172
+
173
+ await expect(cmdPmTask(ctx, ['list'])).rejects.toThrow('Exit');
174
+ expect(ctx.ui.errors[0]).toContain('not found');
175
+ });
22
176
  });
23
177
 
178
+ // ─────────────────────────────────────────────────────────────
179
+ // cmdPmInit tests
180
+ // ─────────────────────────────────────────────────────────────
181
+
24
182
  describe('cmdPmInit', () => {
25
- // Test creates team with UUID
26
- it.todo('creates team with generated UUID');
183
+ let testDir: string;
184
+ let globalDir: string;
185
+ let projectDir: string;
27
186
 
28
- // Test creates team with custom name
29
- it.todo('uses --name flag for team name');
187
+ beforeEach(() => {
188
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
189
+ globalDir = path.join(testDir, 'global');
190
+ projectDir = path.join(testDir, 'project');
191
+ fs.mkdirSync(globalDir, { recursive: true });
192
+ fs.mkdirSync(projectDir, { recursive: true });
193
+ });
30
194
 
31
- // Test creates .tmux-team-id link file
32
- it.todo('creates .tmux-team-id file in current directory');
195
+ afterEach(() => {
196
+ vi.restoreAllMocks();
197
+ if (fs.existsSync(testDir)) {
198
+ fs.rmSync(testDir, { recursive: true, force: true });
199
+ }
200
+ });
33
201
 
34
- // Test logs team_created event
35
- it.todo('logs team_created event to audit log');
202
+ it('creates team with generated UUID', async () => {
203
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
204
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
36
205
 
37
- // Test JSON output
38
- it.todo('outputs team info in JSON when --json flag set');
206
+ await cmdPmInit(ctx, []);
207
+
208
+ // Check that a team directory was created
209
+ const teamsDir = getTeamsDir(globalDir);
210
+ const teamDirs = fs.readdirSync(teamsDir);
211
+ expect(teamDirs.length).toBe(1);
212
+
213
+ // UUID format validation
214
+ expect(teamDirs[0]).toMatch(/^[0-9a-f-]{36}$/);
215
+ });
216
+
217
+ it('uses --name flag for team name', async () => {
218
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
219
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
220
+
221
+ await cmdPmInit(ctx, ['--name', 'My Custom Project']);
222
+
223
+ expect(ctx.ui.logs.some((l) => l.includes('My Custom Project'))).toBe(true);
224
+ });
225
+
226
+ it('creates .tmux-team-id file in current directory', async () => {
227
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
228
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
229
+
230
+ await cmdPmInit(ctx, ['--name', 'Test']);
231
+
232
+ const idFile = path.join(projectDir, '.tmux-team-id');
233
+ expect(fs.existsSync(idFile)).toBe(true);
234
+
235
+ const teamId = fs.readFileSync(idFile, 'utf-8').trim();
236
+ expect(teamId).toMatch(/^[0-9a-f-]{36}$/);
237
+ });
238
+
239
+ it('logs team_created event to audit log', async () => {
240
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
241
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
242
+
243
+ await cmdPmInit(ctx, ['--name', 'Test']);
244
+
245
+ // Read the events file
246
+ const teamsDir = getTeamsDir(globalDir);
247
+ const teamDirs = fs.readdirSync(teamsDir);
248
+ const eventsFile = path.join(teamsDir, teamDirs[0], 'events.jsonl');
249
+
250
+ expect(fs.existsSync(eventsFile)).toBe(true);
251
+ const events = fs
252
+ .readFileSync(eventsFile, 'utf-8')
253
+ .trim()
254
+ .split('\n')
255
+ .map((l) => JSON.parse(l));
256
+ expect(events[0].event).toBe('team_created');
257
+ });
258
+
259
+ it('outputs team info in JSON when --json flag set', async () => {
260
+ const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
261
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
262
+
263
+ await cmdPmInit(ctx, ['--name', 'JSON Test']);
264
+
265
+ expect(ctx.ui.jsonData.length).toBe(1);
266
+ const data = ctx.ui.jsonData[0] as { team: { name: string } };
267
+ expect(data.team.name).toBe('JSON Test');
268
+ });
39
269
  });
40
270
 
271
+ // ─────────────────────────────────────────────────────────────
272
+ // cmdPmMilestone tests
273
+ // ─────────────────────────────────────────────────────────────
274
+
41
275
  describe('cmdPmMilestone', () => {
42
- // Test milestone add
43
- it.todo('creates milestone with given name');
276
+ let testDir: string;
277
+ let globalDir: string;
278
+ let projectDir: string;
279
+ let teamId: string;
280
+
281
+ beforeEach(async () => {
282
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
283
+ globalDir = path.join(testDir, 'global');
284
+ projectDir = path.join(testDir, 'project');
285
+ fs.mkdirSync(globalDir, { recursive: true });
286
+ fs.mkdirSync(projectDir, { recursive: true });
287
+
288
+ // Initialize a team
289
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
290
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
291
+ await cmdPmInit(ctx, ['--name', 'Test Project']);
292
+ vi.restoreAllMocks();
293
+
294
+ teamId = fs.readFileSync(path.join(projectDir, '.tmux-team-id'), 'utf-8').trim();
295
+ });
296
+
297
+ afterEach(() => {
298
+ vi.restoreAllMocks();
299
+ if (fs.existsSync(testDir)) {
300
+ fs.rmSync(testDir, { recursive: true, force: true });
301
+ }
302
+ });
303
+
304
+ it('creates milestone with given name', async () => {
305
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
306
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
307
+
308
+ await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
309
+
310
+ expect(ctx.ui.logs.some((l) => l.includes('Sprint 1'))).toBe(true);
311
+
312
+ // Verify file was created
313
+ const milestonePath = path.join(globalDir, 'teams', teamId, 'milestones', '1.json');
314
+ expect(fs.existsSync(milestonePath)).toBe(true);
315
+ });
44
316
 
45
- // Test milestone list
46
- it.todo('lists all milestones in table format');
317
+ it('lists all milestones in table format', async () => {
318
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
319
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
47
320
 
48
- // Test milestone done
49
- it.todo('marks milestone as done');
321
+ await cmdPmMilestone(ctx, ['add', 'Phase 1']);
322
+ await cmdPmMilestone(ctx, ['add', 'Phase 2']);
323
+ await cmdPmMilestone(ctx, ['list']);
50
324
 
51
- // Test milestone not found error
52
- it.todo('exits with error for non-existent milestone');
325
+ expect(ctx.ui.table).toHaveBeenCalled();
326
+ });
53
327
 
54
- // Test shorthand "m" routing
55
- it.todo('routes "pm m add" to milestone add');
328
+ it('marks milestone as done', async () => {
329
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
330
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
331
+
332
+ await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
333
+ await cmdPmMilestone(ctx, ['done', '1']);
334
+
335
+ expect(ctx.ui.logs.some((l) => l.includes('done'))).toBe(true);
336
+
337
+ // Verify status was updated
338
+ const milestonePath = path.join(globalDir, 'teams', teamId, 'milestones', '1.json');
339
+ const milestone = JSON.parse(fs.readFileSync(milestonePath, 'utf-8'));
340
+ expect(milestone.status).toBe('done');
341
+ });
342
+
343
+ it('exits with error for non-existent milestone', async () => {
344
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
345
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
346
+
347
+ await expect(cmdPmMilestone(ctx, ['done', '999'])).rejects.toThrow('Exit');
348
+ expect(ctx.ui.errors[0]).toContain('not found');
349
+ });
350
+
351
+ it('routes "pm m add" to milestone add', async () => {
352
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
353
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
354
+
355
+ await cmdPm(ctx, ['m', 'add', 'Shorthand Test']);
356
+
357
+ expect(ctx.ui.logs.some((l) => l.includes('Shorthand Test'))).toBe(true);
358
+ });
56
359
  });
57
360
 
361
+ // ─────────────────────────────────────────────────────────────
362
+ // cmdPmTask tests
363
+ // ─────────────────────────────────────────────────────────────
364
+
58
365
  describe('cmdPmTask', () => {
59
- // Test task add
60
- it.todo('creates task with given title');
366
+ let testDir: string;
367
+ let globalDir: string;
368
+ let projectDir: string;
369
+ let teamId: string;
370
+
371
+ beforeEach(async () => {
372
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
373
+ globalDir = path.join(testDir, 'global');
374
+ projectDir = path.join(testDir, 'project');
375
+ fs.mkdirSync(globalDir, { recursive: true });
376
+ fs.mkdirSync(projectDir, { recursive: true });
377
+
378
+ // Initialize a team
379
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
380
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
381
+ await cmdPmInit(ctx, ['--name', 'Test Project']);
382
+ vi.restoreAllMocks();
383
+
384
+ teamId = fs.readFileSync(path.join(projectDir, '.tmux-team-id'), 'utf-8').trim();
385
+ });
386
+
387
+ afterEach(() => {
388
+ vi.restoreAllMocks();
389
+ if (fs.existsSync(testDir)) {
390
+ fs.rmSync(testDir, { recursive: true, force: true });
391
+ }
392
+ });
393
+
394
+ it('creates task with given title', async () => {
395
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
396
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
397
+
398
+ await cmdPmTask(ctx, ['add', 'Implement login']);
399
+
400
+ expect(ctx.ui.logs.some((l) => l.includes('Implement login'))).toBe(true);
401
+ });
402
+
403
+ it('creates task with milestone reference', async () => {
404
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
405
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
406
+
407
+ // Create milestone first
408
+ await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
409
+ await cmdPmTask(ctx, ['add', 'Task with milestone', '--milestone', '1']);
410
+
411
+ const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
412
+ const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
413
+ expect(task.milestone).toBe('1');
414
+ });
415
+
416
+ it('creates task with assignee', async () => {
417
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
418
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
419
+
420
+ await cmdPmTask(ctx, ['add', 'Assigned task', '--assignee', 'claude']);
421
+
422
+ const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
423
+ const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
424
+ expect(task.assignee).toBe('claude');
425
+ });
426
+
427
+ it('lists all tasks in table format', async () => {
428
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
429
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
430
+
431
+ await cmdPmTask(ctx, ['add', 'Task 1']);
432
+ await cmdPmTask(ctx, ['add', 'Task 2']);
433
+ await cmdPmTask(ctx, ['list']);
434
+
435
+ expect(ctx.ui.table).toHaveBeenCalled();
436
+ });
61
437
 
62
- // Test task add with --milestone
63
- it.todo('creates task with milestone reference');
438
+ it('filters task list by status', async () => {
439
+ const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
440
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
64
441
 
65
- // Test task add with --assignee
66
- it.todo('creates task with assignee');
442
+ await cmdPmTask(ctx, ['add', 'Pending task']);
443
+ await cmdPmTask(ctx, ['add', 'Another task']);
444
+ await cmdPmTask(ctx, ['done', '1']);
445
+ await cmdPmTask(ctx, ['list', '--status', 'pending']);
67
446
 
68
- // Test task list
69
- it.todo('lists all tasks in table format');
447
+ const lastJson = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as { id: string }[];
448
+ expect(lastJson).toHaveLength(1);
449
+ expect(lastJson[0].id).toBe('2');
450
+ });
70
451
 
71
- // Test task list with --status filter
72
- it.todo('filters task list by status');
452
+ it('filters task list by milestone', async () => {
453
+ const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
454
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
73
455
 
74
- // Test task list with --milestone filter
75
- it.todo('filters task list by milestone');
456
+ await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
457
+ await cmdPmMilestone(ctx, ['add', 'Sprint 2']);
458
+ await cmdPmTask(ctx, ['add', 'Task in Sprint 1', '--milestone', '1']);
459
+ await cmdPmTask(ctx, ['add', 'Task in Sprint 2', '--milestone', '2']);
460
+ await cmdPmTask(ctx, ['list', '--milestone', '1']);
76
461
 
77
- // Test task show
78
- it.todo('displays task details');
462
+ const lastJson = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as { milestone: string }[];
463
+ expect(lastJson).toHaveLength(1);
464
+ expect(lastJson[0].milestone).toBe('1');
465
+ });
79
466
 
80
- // Test task update --status
81
- it.todo('updates task status');
467
+ it('displays task details', async () => {
468
+ const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
469
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
82
470
 
83
- // Test task update --assignee
84
- it.todo('updates task assignee');
471
+ await cmdPmTask(ctx, ['add', 'Show me', '--assignee', 'claude']);
472
+ await cmdPmTask(ctx, ['show', '1']);
85
473
 
86
- // Test task done
87
- it.todo('marks task as done');
474
+ const lastJson = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as { title: string };
475
+ expect(lastJson.title).toBe('Show me');
476
+ });
88
477
 
89
- // Test task not found error
90
- it.todo('exits with error for non-existent task');
478
+ it('updates task status', async () => {
479
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
480
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
91
481
 
92
- // Test shorthand "t" routing
93
- it.todo('routes "pm t add" to task add');
482
+ await cmdPmTask(ctx, ['add', 'Update me']);
483
+ await cmdPmTask(ctx, ['update', '1', '--status', 'in_progress']);
484
+
485
+ const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
486
+ const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
487
+ expect(task.status).toBe('in_progress');
488
+ });
489
+
490
+ it('updates task assignee', async () => {
491
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
492
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
493
+
494
+ await cmdPmTask(ctx, ['add', 'Reassign me']);
495
+ await cmdPmTask(ctx, ['update', '1', '--assignee', 'codex']);
496
+
497
+ const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
498
+ const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
499
+ expect(task.assignee).toBe('codex');
500
+ });
501
+
502
+ it('marks task as done', async () => {
503
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
504
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
505
+
506
+ await cmdPmTask(ctx, ['add', 'Complete me']);
507
+ await cmdPmTask(ctx, ['done', '1']);
508
+
509
+ const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
510
+ const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
511
+ expect(task.status).toBe('done');
512
+ });
513
+
514
+ it('exits with error for non-existent task', async () => {
515
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
516
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
517
+
518
+ await expect(cmdPmTask(ctx, ['show', '999'])).rejects.toThrow('Exit');
519
+ expect(ctx.ui.errors[0]).toContain('not found');
520
+ });
521
+
522
+ it('routes "pm t add" to task add', async () => {
523
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
524
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
525
+
526
+ await cmdPm(ctx, ['t', 'add', 'Shorthand task']);
527
+
528
+ expect(ctx.ui.logs.some((l) => l.includes('Shorthand task'))).toBe(true);
529
+ });
94
530
  });
95
531
 
532
+ // ─────────────────────────────────────────────────────────────
533
+ // cmdPmDoc tests
534
+ // ─────────────────────────────────────────────────────────────
535
+
96
536
  describe('cmdPmDoc', () => {
97
- // Test doc print mode
98
- it.todo('prints task documentation with --print flag');
537
+ let testDir: string;
538
+ let globalDir: string;
539
+ let projectDir: string;
540
+
541
+ beforeEach(async () => {
542
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
543
+ globalDir = path.join(testDir, 'global');
544
+ projectDir = path.join(testDir, 'project');
545
+ fs.mkdirSync(globalDir, { recursive: true });
546
+ fs.mkdirSync(projectDir, { recursive: true });
547
+
548
+ // Initialize a team and create a task
549
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
550
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
551
+ await cmdPmInit(ctx, ['--name', 'Test Project']);
552
+ await cmdPmTask(ctx, ['add', 'Test Task']);
553
+ vi.restoreAllMocks();
554
+ });
555
+
556
+ afterEach(() => {
557
+ vi.restoreAllMocks();
558
+ if (fs.existsSync(testDir)) {
559
+ fs.rmSync(testDir, { recursive: true, force: true });
560
+ }
561
+ });
562
+
563
+ it('prints task documentation with --print flag', async () => {
564
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
565
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
566
+
567
+ // Capture console.log output
568
+ const logs: string[] = [];
569
+ const originalLog = console.log;
570
+ console.log = (msg: string) => logs.push(msg);
571
+
572
+ await cmdPmDoc(ctx, ['1', '--print']);
99
573
 
100
- // Test doc edit mode (spawns editor)
101
- it.todo('opens documentation in $EDITOR');
574
+ console.log = originalLog;
102
575
 
103
- // Test doc for non-existent task
104
- it.todo('exits with error for non-existent task');
576
+ expect(logs.some((l) => l.includes('Test Task'))).toBe(true);
577
+ });
578
+
579
+ it('opens documentation in $EDITOR', async () => {
580
+ // This test is tricky because it spawns an editor
581
+ // We'll just verify the command doesn't throw
582
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
583
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
584
+
585
+ // Set a no-op editor
586
+ const originalEditor = process.env.EDITOR;
587
+ process.env.EDITOR = 'true'; // 'true' command exists and does nothing
588
+
589
+ try {
590
+ await cmdPmDoc(ctx, ['1']);
591
+ expect(ctx.ui.logs.some((l) => l.includes('Saved'))).toBe(true);
592
+ } finally {
593
+ if (originalEditor) {
594
+ process.env.EDITOR = originalEditor;
595
+ } else {
596
+ delete process.env.EDITOR;
597
+ }
598
+ }
599
+ });
600
+
601
+ it('exits with error for non-existent task', async () => {
602
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
603
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
604
+
605
+ await expect(cmdPmDoc(ctx, ['999'])).rejects.toThrow('Exit');
606
+ expect(ctx.ui.errors[0]).toContain('not found');
607
+ });
105
608
  });
106
609
 
610
+ // ─────────────────────────────────────────────────────────────
611
+ // cmdPmLog tests
612
+ // ─────────────────────────────────────────────────────────────
613
+
107
614
  describe('cmdPmLog', () => {
108
- // Test log display
109
- it.todo('displays audit events');
615
+ let testDir: string;
616
+ let globalDir: string;
617
+ let projectDir: string;
618
+
619
+ beforeEach(async () => {
620
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
621
+ globalDir = path.join(testDir, 'global');
622
+ projectDir = path.join(testDir, 'project');
623
+ fs.mkdirSync(globalDir, { recursive: true });
624
+ fs.mkdirSync(projectDir, { recursive: true });
625
+
626
+ // Initialize a team
627
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
628
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
629
+ await cmdPmInit(ctx, ['--name', 'Test Project']);
630
+ vi.restoreAllMocks();
631
+ });
632
+
633
+ afterEach(() => {
634
+ vi.restoreAllMocks();
635
+ if (fs.existsSync(testDir)) {
636
+ fs.rmSync(testDir, { recursive: true, force: true });
637
+ }
638
+ });
639
+
640
+ it('displays audit events', async () => {
641
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
642
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
643
+
644
+ // Create some events
645
+ await cmdPmTask(ctx, ['add', 'Task 1']);
646
+ await cmdPmTask(ctx, ['done', '1']);
647
+
648
+ const logs: string[] = [];
649
+ const originalLog = console.log;
650
+ console.log = (msg: string) => logs.push(String(msg));
651
+
652
+ await cmdPmLog(ctx, []);
653
+
654
+ console.log = originalLog;
655
+
656
+ // Should show team_created and task events
657
+ expect(logs.some((l) => l.includes('team_created') || l.includes('task'))).toBe(true);
658
+ });
659
+
660
+ it('limits number of events displayed', async () => {
661
+ const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
662
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
663
+
664
+ // Create multiple events
665
+ await cmdPmTask(ctx, ['add', 'Task 1']);
666
+ await cmdPmTask(ctx, ['add', 'Task 2']);
667
+ await cmdPmTask(ctx, ['add', 'Task 3']);
668
+ await cmdPmLog(ctx, ['--limit', '2']);
669
+
670
+ const lastJson = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as unknown[];
671
+ expect(lastJson.length).toBe(2);
672
+ });
110
673
 
111
- // Test log with --limit
112
- it.todo('limits number of events displayed');
674
+ it('outputs events in JSON when --json flag set', async () => {
675
+ const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
676
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
113
677
 
114
- // Test log JSON output
115
- it.todo('outputs events in JSON when --json flag set');
678
+ await cmdPmLog(ctx, []);
116
679
 
117
- // Test empty log message
118
- it.todo('shows info message when no events');
680
+ expect(ctx.ui.jsonData.length).toBeGreaterThan(0);
681
+ expect(Array.isArray(ctx.ui.jsonData[ctx.ui.jsonData.length - 1])).toBe(true);
682
+ });
683
+
684
+ it('shows info message when no events', async () => {
685
+ // Create a new project without events
686
+ const newProjectDir = path.join(testDir, 'empty-project');
687
+ fs.mkdirSync(newProjectDir, { recursive: true });
688
+
689
+ const initCtx = createMockContext(globalDir, { cwd: newProjectDir });
690
+ vi.spyOn(process, 'cwd').mockReturnValue(newProjectDir);
691
+ await cmdPmInit(initCtx, ['--name', 'Empty']);
692
+ vi.restoreAllMocks();
693
+
694
+ // Clear events file
695
+ const teamId = fs.readFileSync(path.join(newProjectDir, '.tmux-team-id'), 'utf-8').trim();
696
+ const eventsFile = path.join(globalDir, 'teams', teamId, 'events.jsonl');
697
+ fs.writeFileSync(eventsFile, '');
698
+
699
+ const ctx = createMockContext(globalDir, { cwd: newProjectDir });
700
+ vi.spyOn(process, 'cwd').mockReturnValue(newProjectDir);
701
+
702
+ await cmdPmLog(ctx, []);
703
+
704
+ expect(ctx.ui.logs.some((l) => l.includes('No events'))).toBe(true);
705
+ });
119
706
  });
120
707
 
708
+ // ─────────────────────────────────────────────────────────────
709
+ // cmdPmList tests
710
+ // ─────────────────────────────────────────────────────────────
711
+
121
712
  describe('cmdPmList', () => {
122
- // Test lists all teams
123
- it.todo('lists all teams in table format');
713
+ let testDir: string;
714
+ let globalDir: string;
715
+
716
+ beforeEach(() => {
717
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
718
+ globalDir = path.join(testDir, 'global');
719
+ fs.mkdirSync(globalDir, { recursive: true });
720
+ });
721
+
722
+ afterEach(() => {
723
+ vi.restoreAllMocks();
724
+ if (fs.existsSync(testDir)) {
725
+ fs.rmSync(testDir, { recursive: true, force: true });
726
+ }
727
+ });
728
+
729
+ it('lists all teams in table format', async () => {
730
+ // Create multiple teams
731
+ const project1 = path.join(testDir, 'project1');
732
+ const project2 = path.join(testDir, 'project2');
733
+ fs.mkdirSync(project1, { recursive: true });
734
+ fs.mkdirSync(project2, { recursive: true });
735
+
736
+ const ctx1 = createMockContext(globalDir, { cwd: project1 });
737
+ vi.spyOn(process, 'cwd').mockReturnValue(project1);
738
+ await cmdPmInit(ctx1, ['--name', 'Project 1']);
739
+ vi.restoreAllMocks();
740
+
741
+ const ctx2 = createMockContext(globalDir, { cwd: project2 });
742
+ vi.spyOn(process, 'cwd').mockReturnValue(project2);
743
+ await cmdPmInit(ctx2, ['--name', 'Project 2']);
744
+ vi.restoreAllMocks();
124
745
 
125
- // Test no teams message
126
- it.todo('shows info message when no teams');
746
+ const ctx = createMockContext(globalDir);
747
+ await cmdPmList(ctx, []);
127
748
 
128
- // Test JSON output
129
- it.todo('outputs teams in JSON when --json flag set');
749
+ expect(ctx.ui.table).toHaveBeenCalled();
750
+ });
751
+
752
+ it('shows info message when no teams', async () => {
753
+ const ctx = createMockContext(globalDir);
754
+ await cmdPmList(ctx, []);
755
+
756
+ expect(ctx.ui.logs.some((l) => l.includes('No teams'))).toBe(true);
757
+ });
758
+
759
+ it('outputs teams in JSON when --json flag set', async () => {
760
+ // Create a team first
761
+ const project = path.join(testDir, 'project');
762
+ fs.mkdirSync(project, { recursive: true });
763
+
764
+ const initCtx = createMockContext(globalDir, { cwd: project });
765
+ vi.spyOn(process, 'cwd').mockReturnValue(project);
766
+ await cmdPmInit(initCtx, ['--name', 'JSON Team']);
767
+ vi.restoreAllMocks();
768
+
769
+ const ctx = createMockContext(globalDir, { json: true });
770
+ await cmdPmList(ctx, []);
771
+
772
+ expect(ctx.ui.jsonData.length).toBe(1);
773
+ expect(ctx.ui.jsonData[0]).toHaveProperty('teams');
774
+ expect(ctx.ui.jsonData[0]).toHaveProperty('currentTeamId');
775
+ expect(Array.isArray(ctx.ui.jsonData[0].teams)).toBe(true);
776
+ });
130
777
  });
131
778
 
779
+ // ─────────────────────────────────────────────────────────────
780
+ // cmdPm router tests
781
+ // ─────────────────────────────────────────────────────────────
782
+
132
783
  describe('cmdPm router', () => {
133
- // Test command routing
134
- it.todo('routes to correct subcommand');
784
+ let testDir: string;
785
+ let globalDir: string;
786
+ let projectDir: string;
787
+
788
+ beforeEach(async () => {
789
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
790
+ globalDir = path.join(testDir, 'global');
791
+ projectDir = path.join(testDir, 'project');
792
+ fs.mkdirSync(globalDir, { recursive: true });
793
+ fs.mkdirSync(projectDir, { recursive: true });
794
+
795
+ // Initialize a team
796
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
797
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
798
+ await cmdPmInit(ctx, ['--name', 'Test Project']);
799
+ vi.restoreAllMocks();
800
+ });
801
+
802
+ afterEach(() => {
803
+ vi.restoreAllMocks();
804
+ if (fs.existsSync(testDir)) {
805
+ fs.rmSync(testDir, { recursive: true, force: true });
806
+ }
807
+ });
808
+
809
+ it('routes to correct subcommand', async () => {
810
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
811
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
812
+
813
+ await cmdPm(ctx, ['task', 'add', 'Routed task']);
135
814
 
136
- // Test shorthand expansion
137
- it.todo('expands m to milestone, t to task');
815
+ expect(ctx.ui.logs.some((l) => l.includes('Routed task'))).toBe(true);
816
+ });
138
817
 
139
- // Test unknown command error
140
- it.todo('exits with error for unknown subcommand');
818
+ it('expands m to milestone, t to task', async () => {
819
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
820
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
141
821
 
142
- // Test help command
143
- it.todo('displays help for pm help');
822
+ await cmdPm(ctx, ['m', 'add', 'Milestone via m']);
823
+ await cmdPm(ctx, ['t', 'add', 'Task via t']);
824
+
825
+ expect(ctx.ui.logs.some((l) => l.includes('Milestone via m'))).toBe(true);
826
+ expect(ctx.ui.logs.some((l) => l.includes('Task via t'))).toBe(true);
827
+ });
828
+
829
+ it('exits with error for unknown subcommand', async () => {
830
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
831
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
832
+
833
+ await expect(cmdPm(ctx, ['unknown'])).rejects.toThrow('Exit');
834
+ expect(ctx.ui.errors[0]).toContain('Unknown pm command');
835
+ });
836
+
837
+ it('displays help for pm help', async () => {
838
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
839
+
840
+ const logs: string[] = [];
841
+ const originalLog = console.log;
842
+ console.log = (msg: string) => logs.push(String(msg));
843
+
844
+ await cmdPm(ctx, ['help']);
845
+
846
+ console.log = originalLog;
847
+
848
+ expect(logs.some((l) => l.includes('tmux-team pm'))).toBe(true);
849
+ });
144
850
  });
145
851
 
852
+ // ─────────────────────────────────────────────────────────────
853
+ // parseStatus tests
854
+ // ─────────────────────────────────────────────────────────────
855
+
146
856
  describe('parseStatus', () => {
147
- // Test valid statuses
148
- it.todo('parses pending, in_progress, done');
857
+ let testDir: string;
858
+ let globalDir: string;
859
+ let projectDir: string;
860
+
861
+ beforeEach(async () => {
862
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
863
+ globalDir = path.join(testDir, 'global');
864
+ projectDir = path.join(testDir, 'project');
865
+ fs.mkdirSync(globalDir, { recursive: true });
866
+ fs.mkdirSync(projectDir, { recursive: true });
867
+
868
+ // Initialize a team
869
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
870
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
871
+ await cmdPmInit(ctx, ['--name', 'Test Project']);
872
+ await cmdPmTask(ctx, ['add', 'Test task']);
873
+ vi.restoreAllMocks();
874
+ });
875
+
876
+ afterEach(() => {
877
+ vi.restoreAllMocks();
878
+ if (fs.existsSync(testDir)) {
879
+ fs.rmSync(testDir, { recursive: true, force: true });
880
+ }
881
+ });
882
+
883
+ it('parses pending, in_progress, done', async () => {
884
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
885
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
886
+
887
+ await cmdPmTask(ctx, ['update', '1', '--status', 'pending']);
888
+ await cmdPmTask(ctx, ['update', '1', '--status', 'in_progress']);
889
+ await cmdPmTask(ctx, ['update', '1', '--status', 'done']);
890
+
891
+ // If we got here without errors, parsing worked
892
+ expect(true).toBe(true);
893
+ });
894
+
895
+ it('normalizes in-progress to in_progress', async () => {
896
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
897
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
898
+
899
+ await cmdPmTask(ctx, ['update', '1', '--status', 'in-progress']);
900
+
901
+ const teamId = fs.readFileSync(path.join(projectDir, '.tmux-team-id'), 'utf-8').trim();
902
+ const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
903
+ const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
904
+ expect(task.status).toBe('in_progress');
905
+ });
906
+
907
+ it('handles case insensitive input', async () => {
908
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
909
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
910
+
911
+ await cmdPmTask(ctx, ['update', '1', '--status', 'DONE']);
912
+
913
+ const teamId = fs.readFileSync(path.join(projectDir, '.tmux-team-id'), 'utf-8').trim();
914
+ const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
915
+ const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
916
+ expect(task.status).toBe('done');
917
+ });
918
+
919
+ it('throws error for invalid status', async () => {
920
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
921
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
922
+
923
+ await expect(cmdPmTask(ctx, ['update', '1', '--status', 'invalid'])).rejects.toThrow(
924
+ 'Invalid status'
925
+ );
926
+ });
927
+ });
928
+
929
+ // ─────────────────────────────────────────────────────────────
930
+ // Permission Integration Tests
931
+ // ─────────────────────────────────────────────────────────────
932
+
933
+ describe('Permission integration', () => {
934
+ let testDir: string;
935
+ let globalDir: string;
936
+ let projectDir: string;
937
+ const originalEnv = { ...process.env };
938
+
939
+ beforeEach(async () => {
940
+ // Disable pane detection in tests
941
+ delete process.env.TMUX;
942
+
943
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
944
+ globalDir = path.join(testDir, 'global');
945
+ projectDir = path.join(testDir, 'project');
946
+ fs.mkdirSync(globalDir, { recursive: true });
947
+ fs.mkdirSync(projectDir, { recursive: true });
948
+
949
+ // Initialize a team and create a task
950
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
951
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
952
+ await cmdPmInit(ctx, ['--name', 'Test Project']);
953
+ await cmdPmTask(ctx, ['add', 'Test task']);
954
+ await cmdPmMilestone(ctx, ['add', 'Test milestone']);
955
+ vi.restoreAllMocks();
956
+ });
957
+
958
+ afterEach(() => {
959
+ process.env = { ...originalEnv };
960
+ vi.restoreAllMocks();
961
+ if (fs.existsSync(testDir)) {
962
+ fs.rmSync(testDir, { recursive: true, force: true });
963
+ }
964
+ });
965
+
966
+ it('allows human to perform any action', async () => {
967
+ delete process.env.TMT_AGENT_NAME;
968
+ delete process.env.TMUX_TEAM_ACTOR;
969
+
970
+ const ctx = createMockContext(globalDir, {
971
+ cwd: projectDir,
972
+ agents: { codex: { deny: ['pm:task:update(status)'] } },
973
+ });
974
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
975
+
976
+ // Human should be able to update status even with deny pattern for codex
977
+ await cmdPmTask(ctx, ['update', '1', '--status', 'in_progress']);
978
+ expect(ctx.ui.logs.some((l) => l.includes('Updated'))).toBe(true);
979
+ });
980
+
981
+ it('blocks agent when deny pattern matches status update', async () => {
982
+ process.env.TMT_AGENT_NAME = 'codex';
983
+
984
+ const ctx = createMockContext(globalDir, {
985
+ cwd: projectDir,
986
+ agents: { codex: { deny: ['pm:task:update(status)'] } },
987
+ });
988
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
989
+
990
+ await expect(cmdPmTask(ctx, ['update', '1', '--status', 'done'])).rejects.toThrow('Exit');
991
+ expect(ctx.ui.errors[0]).toContain('Permission denied');
992
+ expect(ctx.ui.errors[0]).toContain('pm:task:update(status)');
993
+ });
994
+
995
+ it('blocks agent when deny pattern matches task done command', async () => {
996
+ process.env.TMT_AGENT_NAME = 'codex';
997
+
998
+ const ctx = createMockContext(globalDir, {
999
+ cwd: projectDir,
1000
+ agents: { codex: { deny: ['pm:task:update(status)'] } },
1001
+ });
1002
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1003
+
1004
+ // 'task done' is equivalent to 'task update --status done'
1005
+ await expect(cmdPmTask(ctx, ['done', '1'])).rejects.toThrow('Exit');
1006
+ expect(ctx.ui.errors[0]).toContain('Permission denied');
1007
+ });
1008
+
1009
+ it('allows agent to update assignee when only status is denied', async () => {
1010
+ process.env.TMT_AGENT_NAME = 'codex';
1011
+
1012
+ const ctx = createMockContext(globalDir, {
1013
+ cwd: projectDir,
1014
+ agents: { codex: { deny: ['pm:task:update(status)'] } },
1015
+ });
1016
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1017
+
1018
+ // Assignee update should be allowed
1019
+ await cmdPmTask(ctx, ['update', '1', '--assignee', 'gemini']);
1020
+ expect(ctx.ui.logs.some((l) => l.includes('Updated'))).toBe(true);
1021
+ });
1022
+
1023
+ it('blocks agent when wildcard deny pattern matches any field update', async () => {
1024
+ process.env.TMT_AGENT_NAME = 'codex';
1025
+
1026
+ const ctx = createMockContext(globalDir, {
1027
+ cwd: projectDir,
1028
+ agents: { codex: { deny: ['pm:task:update(*)'] } },
1029
+ });
1030
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1031
+
1032
+ await expect(cmdPmTask(ctx, ['update', '1', '--assignee', 'gemini'])).rejects.toThrow('Exit');
1033
+ expect(ctx.ui.errors[0]).toContain('Permission denied');
1034
+ });
1035
+
1036
+ it('blocks agent when entire action is denied (no fields)', async () => {
1037
+ process.env.TMT_AGENT_NAME = 'codex';
1038
+
1039
+ const ctx = createMockContext(globalDir, {
1040
+ cwd: projectDir,
1041
+ agents: { codex: { deny: ['pm:task:create'] } },
1042
+ });
1043
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1044
+
1045
+ await expect(cmdPmTask(ctx, ['add', 'New task'])).rejects.toThrow('Exit');
1046
+ expect(ctx.ui.errors[0]).toContain('Permission denied');
1047
+ expect(ctx.ui.errors[0]).toContain('pm:task:create');
1048
+ });
1049
+
1050
+ it('allows agent without deny patterns', async () => {
1051
+ process.env.TMT_AGENT_NAME = 'gemini';
1052
+
1053
+ const ctx = createMockContext(globalDir, {
1054
+ cwd: projectDir,
1055
+ agents: { codex: { deny: ['pm:task:update(status)'] } }, // Only codex is restricted
1056
+ });
1057
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1058
+
1059
+ // gemini should be allowed
1060
+ await cmdPmTask(ctx, ['update', '1', '--status', 'done']);
1061
+ expect(ctx.ui.logs.some((l) => l.includes('Updated'))).toBe(true);
1062
+ });
1063
+
1064
+ it('blocks milestone status update when denied', async () => {
1065
+ process.env.TMT_AGENT_NAME = 'codex';
1066
+
1067
+ const ctx = createMockContext(globalDir, {
1068
+ cwd: projectDir,
1069
+ agents: { codex: { deny: ['pm:milestone:update(status)'] } },
1070
+ });
1071
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1072
+
1073
+ await expect(cmdPmMilestone(ctx, ['done', '1'])).rejects.toThrow('Exit');
1074
+ expect(ctx.ui.errors[0]).toContain('Permission denied');
1075
+ });
1076
+
1077
+ it('blocks team creation when denied', async () => {
1078
+ process.env.TMT_AGENT_NAME = 'codex';
1079
+
1080
+ const newProjectDir = path.join(testDir, 'new-project');
1081
+ fs.mkdirSync(newProjectDir, { recursive: true });
1082
+
1083
+ const ctx = createMockContext(globalDir, {
1084
+ cwd: newProjectDir,
1085
+ agents: { codex: { deny: ['pm:team:create'] } },
1086
+ });
1087
+ vi.spyOn(process, 'cwd').mockReturnValue(newProjectDir);
1088
+
1089
+ await expect(cmdPmInit(ctx, ['--name', 'New Project'])).rejects.toThrow('Exit');
1090
+ expect(ctx.ui.errors[0]).toContain('Permission denied');
1091
+ });
1092
+
1093
+ it('blocks doc update but allows doc read', async () => {
1094
+ process.env.TMT_AGENT_NAME = 'codex';
1095
+
1096
+ const ctx = createMockContext(globalDir, {
1097
+ cwd: projectDir,
1098
+ agents: { codex: { deny: ['pm:doc:update'] } },
1099
+ });
1100
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1101
+
1102
+ // Read should work
1103
+ const logs: string[] = [];
1104
+ const originalLog = console.log;
1105
+ console.log = (msg: string) => logs.push(msg);
1106
+
1107
+ await cmdPmDoc(ctx, ['1', '--print']);
1108
+
1109
+ console.log = originalLog;
1110
+ expect(logs.some((l) => l.includes('Test task'))).toBe(true);
1111
+ });
149
1112
 
150
- // Test hyphen to underscore normalization
151
- it.todo('normalizes in-progress to in_progress');
1113
+ it('uses TMUX_TEAM_ACTOR when TMT_AGENT_NAME is not set', async () => {
1114
+ delete process.env.TMT_AGENT_NAME;
1115
+ process.env.TMUX_TEAM_ACTOR = 'codex';
152
1116
 
153
- // Test case insensitivity
154
- it.todo('handles case insensitive input');
1117
+ const ctx = createMockContext(globalDir, {
1118
+ cwd: projectDir,
1119
+ agents: { codex: { deny: ['pm:task:update(status)'] } },
1120
+ });
1121
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
155
1122
 
156
- // Test invalid status error
157
- it.todo('throws error for invalid status');
1123
+ await expect(cmdPmTask(ctx, ['update', '1', '--status', 'done'])).rejects.toThrow('Exit');
1124
+ expect(ctx.ui.errors[0]).toContain('Permission denied');
1125
+ expect(ctx.ui.errors[0]).toContain('codex');
1126
+ });
158
1127
  });