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/src/config.ts CHANGED
@@ -19,15 +19,16 @@ const LOCAL_CONFIG_FILENAME = 'tmux-team.json';
19
19
  const STATE_FILENAME = 'state.json';
20
20
 
21
21
  // Default configuration values
22
- const DEFAULT_CONFIG: Omit<GlobalConfig, 'agents'> & { agents: Record<string, never> } = {
22
+ const DEFAULT_CONFIG: GlobalConfig = {
23
23
  mode: 'polling',
24
24
  preambleMode: 'always',
25
25
  defaults: {
26
26
  timeout: 180,
27
27
  pollInterval: 1,
28
28
  captureLines: 100,
29
+ preambleEvery: 3, // inject preamble every 3 messages
30
+ hideOrphanTasks: false, // hide tasks without milestone in list
29
31
  },
30
- agents: {},
31
32
  };
32
33
 
33
34
  /**
@@ -130,7 +131,7 @@ export function loadConfig(paths: Paths): ResolvedConfig {
130
131
  paneRegistry: {},
131
132
  };
132
133
 
133
- // Merge global config
134
+ // Merge global config (mode, preambleMode, defaults only)
134
135
  const globalConfig = loadJsonFile<Partial<GlobalConfig>>(paths.globalConfig);
135
136
  if (globalConfig) {
136
137
  if (globalConfig.mode) config.mode = globalConfig.mode;
@@ -138,12 +139,10 @@ export function loadConfig(paths: Paths): ResolvedConfig {
138
139
  if (globalConfig.defaults) {
139
140
  config.defaults = { ...config.defaults, ...globalConfig.defaults };
140
141
  }
141
- if (globalConfig.agents) {
142
- config.agents = { ...config.agents, ...globalConfig.agents };
143
- }
144
142
  }
145
143
 
146
- // Load local config (pane registry + optional settings)
144
+ // Load local config (pane registry + optional settings + agent config)
145
+ // Local config is the SSOT for agent configuration (preamble, deny)
147
146
  const localConfigFile = loadJsonFile<LocalConfigFile>(paths.localConfig);
148
147
  if (localConfigFile) {
149
148
  // Extract local settings if present
@@ -153,22 +152,36 @@ export function loadConfig(paths: Paths): ResolvedConfig {
153
152
  if (localSettings) {
154
153
  if (localSettings.mode) config.mode = localSettings.mode;
155
154
  if (localSettings.preambleMode) config.preambleMode = localSettings.preambleMode;
155
+ if (localSettings.preambleEvery !== undefined) {
156
+ config.defaults.preambleEvery = localSettings.preambleEvery;
157
+ }
156
158
  }
157
159
 
158
- // Set pane registry (filter out $config)
159
- config.paneRegistry = paneEntries as LocalConfig;
160
+ // Build pane registry and agents config from local entries
161
+ for (const [agentName, entry] of Object.entries(paneEntries)) {
162
+ const paneEntry = entry as LocalConfig[string];
163
+
164
+ // Add to pane registry if has valid pane field
165
+ if (paneEntry.pane) {
166
+ config.paneRegistry[agentName] = paneEntry;
167
+ }
168
+
169
+ // Build agents config from preamble/deny fields
170
+ const hasPreamble = Object.prototype.hasOwnProperty.call(paneEntry, 'preamble');
171
+ const hasDeny = Object.prototype.hasOwnProperty.call(paneEntry, 'deny');
172
+
173
+ if (hasPreamble || hasDeny) {
174
+ config.agents[agentName] = {
175
+ ...(hasPreamble && { preamble: paneEntry.preamble }),
176
+ ...(hasDeny && { deny: paneEntry.deny }),
177
+ };
178
+ }
179
+ }
160
180
  }
161
181
 
162
182
  return config;
163
183
  }
164
184
 
165
- export function saveLocalConfig(
166
- paths: Paths,
167
- paneRegistry: Record<string, { pane: string; remark?: string }>
168
- ): void {
169
- fs.writeFileSync(paths.localConfig, JSON.stringify(paneRegistry, null, 2) + '\n');
170
- }
171
-
172
185
  export function ensureGlobalDir(paths: Paths): void {
173
186
  if (!fs.existsSync(paths.globalDir)) {
174
187
  fs.mkdirSync(paths.globalDir, { recursive: true });
@@ -9,15 +9,7 @@ import os from 'os';
9
9
  import type { Context } from '../types.js';
10
10
  import type { UI } from '../types.js';
11
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';
12
+ import { cmdPm, cmdPmInit, cmdPmMilestone, cmdPmTask, cmdPmLog, cmdPmList } from './commands.js';
21
13
  import { findCurrentTeamId, linkTeam, getTeamsDir } from './manager.js';
22
14
 
23
15
  // ─────────────────────────────────────────────────────────────
@@ -44,7 +36,18 @@ function createMockUI(): UI & { logs: string[]; errors: string[]; jsonData: unkn
44
36
 
45
37
  function createMockContext(
46
38
  globalDir: string,
47
- options: { json?: boolean; cwd?: string; agents?: Record<string, { deny?: string[] }> } = {}
39
+ options: {
40
+ json?: boolean;
41
+ cwd?: string;
42
+ agents?: Record<string, { deny?: string[] }>;
43
+ defaults?: Partial<{
44
+ timeout: number;
45
+ pollInterval: number;
46
+ captureLines: number;
47
+ preambleEvery: number;
48
+ hideOrphanTasks: boolean;
49
+ }>;
50
+ } = {}
48
51
  ): Context & { ui: ReturnType<typeof createMockUI>; exitCode: number | null } {
49
52
  const ui = createMockUI();
50
53
  let exitCode: number | null = null;
@@ -63,7 +66,14 @@ function createMockContext(
63
66
  config: {
64
67
  mode: 'polling',
65
68
  preambleMode: 'always',
66
- defaults: { timeout: 60, pollInterval: 1, captureLines: 100 },
69
+ defaults: {
70
+ timeout: 60,
71
+ pollInterval: 1,
72
+ captureLines: 100,
73
+ preambleEvery: 3,
74
+ hideOrphanTasks: false,
75
+ ...options.defaults,
76
+ },
67
77
  agents: options.agents ?? {},
68
78
  paneRegistry: {},
69
79
  },
@@ -320,9 +330,35 @@ describe('cmdPmMilestone', () => {
320
330
 
321
331
  await cmdPmMilestone(ctx, ['add', 'Phase 1']);
322
332
  await cmdPmMilestone(ctx, ['add', 'Phase 2']);
333
+
334
+ (ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
323
335
  await cmdPmMilestone(ctx, ['list']);
324
336
 
325
- expect(ctx.ui.table).toHaveBeenCalled();
337
+ expect(ctx.ui.table).toHaveBeenCalledTimes(1);
338
+ expect(ctx.ui.table).toHaveBeenCalledWith(
339
+ ['ID', 'NAME', 'STATUS'],
340
+ expect.arrayContaining([
341
+ expect.arrayContaining(['1', 'Phase 1']),
342
+ expect.arrayContaining(['2', 'Phase 2']),
343
+ ])
344
+ );
345
+ });
346
+
347
+ it('lists milestones when called without subcommand', async () => {
348
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
349
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
350
+
351
+ await cmdPmMilestone(ctx, ['add', 'Phase 1']);
352
+
353
+ // Clear mock call history after add, then verify empty args triggers list
354
+ (ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
355
+ await cmdPmMilestone(ctx, []);
356
+
357
+ expect(ctx.ui.table).toHaveBeenCalledTimes(1);
358
+ expect(ctx.ui.table).toHaveBeenCalledWith(
359
+ ['ID', 'NAME', 'STATUS'],
360
+ expect.arrayContaining([expect.arrayContaining(['1', 'Phase 1'])])
361
+ );
326
362
  });
327
363
 
328
364
  it('marks milestone as done', async () => {
@@ -348,6 +384,55 @@ describe('cmdPmMilestone', () => {
348
384
  expect(ctx.ui.errors[0]).toContain('not found');
349
385
  });
350
386
 
387
+ it('deletes milestone', async () => {
388
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
389
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
390
+
391
+ await cmdPmMilestone(ctx, ['add', 'To Delete']);
392
+ await cmdPmMilestone(ctx, ['delete', '1']);
393
+
394
+ expect(ctx.ui.logs.some((l) => l.includes('deleted'))).toBe(true);
395
+
396
+ // Verify milestone was deleted
397
+ const milestonePath = path.join(globalDir, 'teams', teamId, 'milestones', '1.json');
398
+ expect(fs.existsSync(milestonePath)).toBe(false);
399
+ });
400
+
401
+ it('deletes milestone with rm shorthand', async () => {
402
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
403
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
404
+
405
+ await cmdPmMilestone(ctx, ['add', 'To Remove']);
406
+ await cmdPmMilestone(ctx, ['rm', '1']);
407
+
408
+ expect(ctx.ui.logs.some((l) => l.includes('deleted'))).toBe(true);
409
+ });
410
+
411
+ it('returns JSON on milestone delete with --json flag', async () => {
412
+ const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
413
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
414
+
415
+ await cmdPmMilestone(ctx, ['add', 'JSON Delete']);
416
+ await cmdPmMilestone(ctx, ['delete', '1']);
417
+
418
+ const output = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as {
419
+ deleted: boolean;
420
+ id: string;
421
+ name: string;
422
+ };
423
+ expect(output.deleted).toBe(true);
424
+ expect(output.id).toBe('1');
425
+ expect(output.name).toBe('JSON Delete');
426
+ });
427
+
428
+ it('exits with error when deleting non-existent milestone', async () => {
429
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
430
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
431
+
432
+ await expect(cmdPmMilestone(ctx, ['delete', '999'])).rejects.toThrow('Exit');
433
+ expect(ctx.ui.errors[0]).toContain('not found');
434
+ });
435
+
351
436
  it('routes "pm m add" to milestone add', async () => {
352
437
  const ctx = createMockContext(globalDir, { cwd: projectDir });
353
438
  vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
@@ -356,6 +441,153 @@ describe('cmdPmMilestone', () => {
356
441
 
357
442
  expect(ctx.ui.logs.some((l) => l.includes('Shorthand Test'))).toBe(true);
358
443
  });
444
+
445
+ it('creates milestone with --description flag', async () => {
446
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
447
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
448
+
449
+ await cmdPmMilestone(ctx, ['add', 'Phase 1', '--description', 'Initial development']);
450
+
451
+ // Verify doc file contains description
452
+ const docPath = path.join(globalDir, 'teams', teamId, 'milestones', '1.md');
453
+ const content = fs.readFileSync(docPath, 'utf-8');
454
+ expect(content).toContain('Phase 1');
455
+ expect(content).toContain('Initial development');
456
+ });
457
+
458
+ it('prints milestone doc by default', async () => {
459
+ const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
460
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
461
+
462
+ await cmdPmMilestone(ctx, ['add', 'Phase 1', '-d', 'Test description']);
463
+
464
+ (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
465
+ await cmdPmMilestone(ctx, ['doc', '1']);
466
+
467
+ expect(ctx.ui.json).toHaveBeenCalledWith(
468
+ expect.objectContaining({
469
+ id: '1',
470
+ doc: expect.stringContaining('Phase 1'),
471
+ })
472
+ );
473
+ });
474
+
475
+ it('returns milestone doc in JSON format', async () => {
476
+ const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
477
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
478
+
479
+ await cmdPmMilestone(ctx, ['add', 'Phase 1']);
480
+ await cmdPmMilestone(ctx, ['doc', '1']);
481
+
482
+ const jsonOutput = ctx.ui.jsonData.find(
483
+ (d) => typeof d === 'object' && d !== null && 'doc' in d
484
+ ) as { id: string; doc: string };
485
+ expect(jsonOutput).toMatchObject({
486
+ id: '1',
487
+ doc: expect.stringContaining('Phase 1'),
488
+ });
489
+ });
490
+
491
+ it('exits with error for non-existent milestone doc', async () => {
492
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
493
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
494
+
495
+ await expect(cmdPmMilestone(ctx, ['doc', '999'])).rejects.toThrow('Exit');
496
+ expect(ctx.ui.errors[0]).toContain('not found');
497
+ });
498
+
499
+ it('shows docPath with doc ref subcommand', async () => {
500
+ const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
501
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
502
+
503
+ await cmdPmMilestone(ctx, ['add', 'Phase 1']);
504
+
505
+ (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
506
+ await cmdPmMilestone(ctx, ['doc', '1', 'ref']);
507
+
508
+ expect(ctx.ui.json).toHaveBeenCalledTimes(1);
509
+ expect(ctx.ui.json).toHaveBeenCalledWith(
510
+ expect.objectContaining({
511
+ id: '1',
512
+ docPath: expect.stringContaining('1.md'),
513
+ })
514
+ );
515
+ });
516
+
517
+ it('creates milestone with -d shorthand for description', async () => {
518
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
519
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
520
+
521
+ await cmdPmMilestone(ctx, ['add', 'Sprint 1', '-d', 'Two week sprint']);
522
+
523
+ const docPath = path.join(globalDir, 'teams', teamId, 'milestones', '1.md');
524
+ const content = fs.readFileSync(docPath, 'utf-8');
525
+ expect(content).toContain('Sprint 1');
526
+ expect(content).toContain('Two week sprint');
527
+ });
528
+
529
+ it('prints full doc content including description', async () => {
530
+ const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
531
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
532
+
533
+ await cmdPmMilestone(ctx, ['add', 'Phase 1', '--description', 'Detailed description here']);
534
+
535
+ (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
536
+ await cmdPmMilestone(ctx, ['doc', '1']);
537
+
538
+ expect(ctx.ui.json).toHaveBeenCalledTimes(1);
539
+ expect(ctx.ui.json).toHaveBeenCalledWith(
540
+ expect.objectContaining({
541
+ id: '1',
542
+ doc: expect.stringContaining('Detailed description here'),
543
+ })
544
+ );
545
+ });
546
+
547
+ it('sets milestone documentation with --body flag', async () => {
548
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
549
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
550
+
551
+ await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
552
+ await cmdPmMilestone(ctx, ['doc', '1', '--body', 'New milestone content']);
553
+ expect(ctx.ui.logs.some((l) => l.includes('Saved'))).toBe(true);
554
+
555
+ // Verify the content was saved
556
+ const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
557
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
558
+ await cmdPmMilestone(jsonCtx, ['doc', '1']);
559
+
560
+ expect(jsonCtx.ui.json).toHaveBeenCalledWith(
561
+ expect.objectContaining({
562
+ doc: 'New milestone content',
563
+ })
564
+ );
565
+ });
566
+
567
+ it('sets milestone documentation with --body-file flag', async () => {
568
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
569
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
570
+
571
+ await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
572
+
573
+ // Create a temp file with content
574
+ const tempFile = path.join(projectDir, 'milestone-doc.md');
575
+ fs.writeFileSync(tempFile, '# Milestone content from file');
576
+
577
+ await cmdPmMilestone(ctx, ['doc', '1', '--body-file', tempFile]);
578
+ expect(ctx.ui.logs.some((l) => l.includes('Saved') && l.includes('from'))).toBe(true);
579
+
580
+ // Verify the content was saved
581
+ const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
582
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
583
+ await cmdPmMilestone(jsonCtx, ['doc', '1']);
584
+
585
+ expect(jsonCtx.ui.json).toHaveBeenCalledWith(
586
+ expect.objectContaining({
587
+ doc: expect.stringContaining('Milestone content from file'),
588
+ })
589
+ );
590
+ });
359
591
  });
360
592
 
361
593
  // ─────────────────────────────────────────────────────────────
@@ -430,9 +662,35 @@ describe('cmdPmTask', () => {
430
662
 
431
663
  await cmdPmTask(ctx, ['add', 'Task 1']);
432
664
  await cmdPmTask(ctx, ['add', 'Task 2']);
665
+
666
+ (ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
433
667
  await cmdPmTask(ctx, ['list']);
434
668
 
435
- expect(ctx.ui.table).toHaveBeenCalled();
669
+ expect(ctx.ui.table).toHaveBeenCalledTimes(1);
670
+ expect(ctx.ui.table).toHaveBeenCalledWith(
671
+ ['ID', 'TITLE', 'STATUS', 'MILESTONE'],
672
+ expect.arrayContaining([
673
+ expect.arrayContaining(['1', 'Task 1']),
674
+ expect.arrayContaining(['2', 'Task 2']),
675
+ ])
676
+ );
677
+ });
678
+
679
+ it('lists tasks when called without subcommand', async () => {
680
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
681
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
682
+
683
+ await cmdPmTask(ctx, ['add', 'Task 1']);
684
+
685
+ // Clear mock call history after add, then verify empty args triggers list
686
+ (ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
687
+ await cmdPmTask(ctx, []);
688
+
689
+ expect(ctx.ui.table).toHaveBeenCalledTimes(1);
690
+ expect(ctx.ui.table).toHaveBeenCalledWith(
691
+ ['ID', 'TITLE', 'STATUS', 'MILESTONE'],
692
+ expect.arrayContaining([expect.arrayContaining(['1', 'Task 1'])])
693
+ );
436
694
  });
437
695
 
438
696
  it('filters task list by status', async () => {
@@ -464,6 +722,24 @@ describe('cmdPmTask', () => {
464
722
  expect(lastJson[0].milestone).toBe('1');
465
723
  });
466
724
 
725
+ it('hides tasks without milestone when hideOrphanTasks is enabled', async () => {
726
+ const ctx = createMockContext(globalDir, {
727
+ json: true,
728
+ cwd: projectDir,
729
+ defaults: { hideOrphanTasks: true },
730
+ });
731
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
732
+
733
+ await cmdPmMilestone(ctx, ['add', 'Phase 1']);
734
+ await cmdPmTask(ctx, ['add', 'With milestone', '--milestone', '1']);
735
+ await cmdPmTask(ctx, ['add', 'Without milestone']);
736
+ await cmdPmTask(ctx, ['list']);
737
+
738
+ const tasks = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as { title: string }[];
739
+ expect(tasks).toHaveLength(1);
740
+ expect(tasks[0].title).toBe('With milestone');
741
+ });
742
+
467
743
  it('displays task details', async () => {
468
744
  const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
469
745
  vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
@@ -530,10 +806,10 @@ describe('cmdPmTask', () => {
530
806
  });
531
807
 
532
808
  // ─────────────────────────────────────────────────────────────
533
- // cmdPmDoc tests
809
+ // cmdPmTask doc tests
534
810
  // ─────────────────────────────────────────────────────────────
535
811
 
536
- describe('cmdPmDoc', () => {
812
+ describe('cmdPmTask doc', () => {
537
813
  let testDir: string;
538
814
  let globalDir: string;
539
815
  let projectDir: string;
@@ -560,23 +836,22 @@ describe('cmdPmDoc', () => {
560
836
  }
561
837
  });
562
838
 
563
- it('prints task documentation with --print flag', async () => {
564
- const ctx = createMockContext(globalDir, { cwd: projectDir });
839
+ it('prints task documentation by default', async () => {
840
+ const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
565
841
  vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
566
842
 
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']);
573
-
574
- console.log = originalLog;
843
+ (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
844
+ await cmdPmTask(ctx, ['doc', '1']);
575
845
 
576
- expect(logs.some((l) => l.includes('Test Task'))).toBe(true);
846
+ expect(ctx.ui.json).toHaveBeenCalledWith(
847
+ expect.objectContaining({
848
+ id: '1',
849
+ doc: expect.stringContaining('Test Task'),
850
+ })
851
+ );
577
852
  });
578
853
 
579
- it('opens documentation in $EDITOR', async () => {
854
+ it('opens documentation in $EDITOR with --edit flag', async () => {
580
855
  // This test is tricky because it spawns an editor
581
856
  // We'll just verify the command doesn't throw
582
857
  const ctx = createMockContext(globalDir, { cwd: projectDir });
@@ -587,7 +862,7 @@ describe('cmdPmDoc', () => {
587
862
  process.env.EDITOR = 'true'; // 'true' command exists and does nothing
588
863
 
589
864
  try {
590
- await cmdPmDoc(ctx, ['1']);
865
+ await cmdPmTask(ctx, ['doc', '1', '--edit']);
591
866
  expect(ctx.ui.logs.some((l) => l.includes('Saved'))).toBe(true);
592
867
  } finally {
593
868
  if (originalEditor) {
@@ -602,9 +877,61 @@ describe('cmdPmDoc', () => {
602
877
  const ctx = createMockContext(globalDir, { cwd: projectDir });
603
878
  vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
604
879
 
605
- await expect(cmdPmDoc(ctx, ['999'])).rejects.toThrow('Exit');
880
+ await expect(cmdPmTask(ctx, ['doc', '999'])).rejects.toThrow('Exit');
606
881
  expect(ctx.ui.errors[0]).toContain('not found');
607
882
  });
883
+
884
+ it('sets documentation with --body flag', async () => {
885
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
886
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
887
+
888
+ await cmdPmTask(ctx, ['doc', '1', '--body', 'New content via --body']);
889
+ expect(ctx.ui.logs.some((l) => l.includes('Saved'))).toBe(true);
890
+
891
+ // Verify the content was saved
892
+ const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
893
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
894
+ await cmdPmTask(jsonCtx, ['doc', '1']);
895
+
896
+ expect(jsonCtx.ui.json).toHaveBeenCalledWith(
897
+ expect.objectContaining({
898
+ doc: 'New content via --body',
899
+ })
900
+ );
901
+ });
902
+
903
+ it('sets documentation with --body-file flag', async () => {
904
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
905
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
906
+
907
+ // Create a temp file with content
908
+ const tempFile = path.join(projectDir, 'test-doc.md');
909
+ fs.writeFileSync(tempFile, '# Content from file\n\nThis came from a file.');
910
+
911
+ await cmdPmTask(ctx, ['doc', '1', '--body-file', tempFile]);
912
+ expect(ctx.ui.logs.some((l) => l.includes('Saved') && l.includes('from'))).toBe(true);
913
+
914
+ // Verify the content was saved
915
+ const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
916
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
917
+ await cmdPmTask(jsonCtx, ['doc', '1']);
918
+
919
+ expect(jsonCtx.ui.json).toHaveBeenCalledWith(
920
+ expect.objectContaining({
921
+ doc: expect.stringContaining('Content from file'),
922
+ })
923
+ );
924
+ });
925
+
926
+ it('exits with error for non-existent --body-file', async () => {
927
+ const ctx = createMockContext(globalDir, { cwd: projectDir });
928
+ vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
929
+
930
+ await expect(
931
+ cmdPmTask(ctx, ['doc', '1', '--body-file', '/nonexistent/file.md'])
932
+ ).rejects.toThrow('Exit');
933
+ expect(ctx.ui.errors[0]).toContain('File not found');
934
+ });
608
935
  });
609
936
 
610
937
  // ─────────────────────────────────────────────────────────────
@@ -638,23 +965,22 @@ describe('cmdPmLog', () => {
638
965
  });
639
966
 
640
967
  it('displays audit events', async () => {
641
- const ctx = createMockContext(globalDir, { cwd: projectDir });
968
+ const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
642
969
  vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
643
970
 
644
971
  // Create some events
645
972
  await cmdPmTask(ctx, ['add', 'Task 1']);
646
973
  await cmdPmTask(ctx, ['done', '1']);
647
974
 
648
- const logs: string[] = [];
649
- const originalLog = console.log;
650
- console.log = (msg: string) => logs.push(String(msg));
651
-
975
+ (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
652
976
  await cmdPmLog(ctx, []);
653
977
 
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);
978
+ // cmdPmLog outputs array of events directly
979
+ expect(ctx.ui.json).toHaveBeenCalledWith(
980
+ expect.arrayContaining([
981
+ expect.objectContaining({ event: expect.stringMatching(/team_created|task/) }),
982
+ ])
983
+ );
658
984
  });
659
985
 
660
986
  it('limits number of events displayed', async () => {
@@ -746,7 +1072,14 @@ describe('cmdPmList', () => {
746
1072
  const ctx = createMockContext(globalDir);
747
1073
  await cmdPmList(ctx, []);
748
1074
 
749
- expect(ctx.ui.table).toHaveBeenCalled();
1075
+ expect(ctx.ui.table).toHaveBeenCalledTimes(1);
1076
+ expect(ctx.ui.table).toHaveBeenCalledWith(
1077
+ ['', 'ID', 'NAME', 'BACKEND', 'CREATED'],
1078
+ expect.arrayContaining([
1079
+ expect.arrayContaining(['Project 1']),
1080
+ expect.arrayContaining(['Project 2']),
1081
+ ])
1082
+ );
750
1083
  });
751
1084
 
752
1085
  it('shows info message when no teams', async () => {
@@ -838,15 +1171,14 @@ describe('cmdPm router', () => {
838
1171
  it('displays help for pm help', async () => {
839
1172
  const ctx = createMockContext(globalDir, { cwd: projectDir });
840
1173
 
841
- const logs: string[] = [];
842
- const originalLog = console.log;
843
- console.log = (msg: string) => logs.push(String(msg));
844
-
845
- await cmdPm(ctx, ['help']);
1174
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
846
1175
 
847
- console.log = originalLog;
848
-
849
- expect(logs.some((l) => l.includes('tmux-team pm'))).toBe(true);
1176
+ try {
1177
+ await cmdPm(ctx, ['help']);
1178
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('tmux-team pm'));
1179
+ } finally {
1180
+ logSpy.mockRestore();
1181
+ }
850
1182
  });
851
1183
  });
852
1184
 
@@ -1096,19 +1428,21 @@ describe('Permission integration', () => {
1096
1428
 
1097
1429
  const ctx = createMockContext(globalDir, {
1098
1430
  cwd: projectDir,
1431
+ json: true,
1099
1432
  agents: { codex: { deny: ['pm:doc:update'] } },
1100
1433
  });
1101
1434
  vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1102
1435
 
1103
1436
  // Read should work
1104
- const logs: string[] = [];
1105
- const originalLog = console.log;
1106
- console.log = (msg: string) => logs.push(msg);
1107
-
1108
- await cmdPmDoc(ctx, ['1', '--print']);
1109
-
1110
- console.log = originalLog;
1111
- expect(logs.some((l) => l.includes('Test task'))).toBe(true);
1437
+ (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
1438
+ await cmdPmTask(ctx, ['doc', '1']);
1439
+
1440
+ expect(ctx.ui.json).toHaveBeenCalledWith(
1441
+ expect.objectContaining({
1442
+ id: '1',
1443
+ doc: expect.stringContaining('Test task'),
1444
+ })
1445
+ );
1112
1446
  });
1113
1447
 
1114
1448
  it('uses TMUX_TEAM_ACTOR when TMT_AGENT_NAME is not set', async () => {