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.
@@ -24,6 +24,8 @@ import {
24
24
  import type { StorageAdapter } from './storage/adapter.js';
25
25
  import type { TaskStatus, MilestoneStatus, StorageBackend } from './types.js';
26
26
  import path from 'path';
27
+ import fs from 'fs';
28
+ import os from 'os';
27
29
 
28
30
  // ─────────────────────────────────────────────────────────────
29
31
  // Helpers
@@ -170,11 +172,17 @@ export async function cmdPmMilestone(ctx: Context, args: string[]): Promise<void
170
172
  return cmdMilestoneAdd(ctx, rest);
171
173
  case 'list':
172
174
  case 'ls':
175
+ case undefined:
173
176
  return cmdMilestoneList(ctx, rest);
174
177
  case 'done':
175
178
  return cmdMilestoneDone(ctx, rest);
179
+ case 'delete':
180
+ case 'rm':
181
+ return cmdMilestoneDelete(ctx, rest);
182
+ case 'doc':
183
+ return cmdMilestoneDoc(ctx, rest);
176
184
  default:
177
- ctx.ui.error(`Unknown milestone command: ${subcommand}. Use: add, list, done`);
185
+ ctx.ui.error(`Unknown milestone command: ${subcommand}. Use: add, list, done, delete, doc`);
178
186
  ctx.exit(ExitCodes.ERROR);
179
187
  }
180
188
  }
@@ -185,13 +193,26 @@ async function cmdMilestoneAdd(ctx: Context, args: string[]): Promise<void> {
185
193
  const { ui, flags } = ctx;
186
194
  const { storage } = await requireTeam(ctx);
187
195
 
188
- const name = args[0];
196
+ // Parse args: <name> [--description <text>]
197
+ let name = '';
198
+ let description: string | undefined;
199
+
200
+ for (let i = 0; i < args.length; i++) {
201
+ if (args[i] === '--description' || args[i] === '-d') {
202
+ description = args[++i];
203
+ } else if (args[i].startsWith('--description=')) {
204
+ description = args[i].slice(14);
205
+ } else if (!name) {
206
+ name = args[i];
207
+ }
208
+ }
209
+
189
210
  if (!name) {
190
- ui.error('Usage: tmux-team pm milestone add <name>');
211
+ ui.error('Usage: tmux-team pm milestone add <name> [--description <text>]');
191
212
  ctx.exit(ExitCodes.ERROR);
192
213
  }
193
214
 
194
- const milestone = await storage.createMilestone({ name });
215
+ const milestone = await storage.createMilestone({ name, description });
195
216
 
196
217
  await storage.appendEvent({
197
218
  event: 'milestone_created',
@@ -271,6 +292,167 @@ async function cmdMilestoneDone(ctx: Context, args: string[]): Promise<void> {
271
292
  }
272
293
  }
273
294
 
295
+ async function cmdMilestoneDelete(ctx: Context, args: string[]): Promise<void> {
296
+ requirePermission(ctx, PermissionChecks.milestoneDelete());
297
+
298
+ const { ui, flags } = ctx;
299
+ const { storage } = await requireTeam(ctx);
300
+
301
+ const id = args[0];
302
+ if (!id) {
303
+ ui.error('Usage: tmux-team pm milestone delete <id>');
304
+ ctx.exit(ExitCodes.ERROR);
305
+ }
306
+
307
+ const milestone = await storage.getMilestone(id);
308
+ if (!milestone) {
309
+ ui.error(`Milestone ${id} not found`);
310
+ ctx.exit(ExitCodes.PANE_NOT_FOUND);
311
+ }
312
+
313
+ await storage.deleteMilestone(id);
314
+
315
+ await storage.appendEvent({
316
+ event: 'milestone_deleted',
317
+ id,
318
+ name: milestone.name,
319
+ actor: 'human',
320
+ ts: new Date().toISOString(),
321
+ });
322
+
323
+ if (flags.json) {
324
+ ui.json({ deleted: true, id, name: milestone.name });
325
+ } else {
326
+ ui.success(`Milestone #${id} "${milestone.name}" deleted`);
327
+ }
328
+ }
329
+
330
+ async function cmdMilestoneDoc(ctx: Context, args: string[]): Promise<void> {
331
+ const { ui, flags } = ctx;
332
+
333
+ // Parse arguments
334
+ let id: string | undefined;
335
+ let body: string | undefined;
336
+ let bodyFile: string | undefined;
337
+ let showRef = false;
338
+ let editMode = false;
339
+
340
+ for (let i = 0; i < args.length; i++) {
341
+ const arg = args[i];
342
+ if (arg === 'ref') {
343
+ showRef = true;
344
+ } else if (arg === '--edit' || arg === '-e') {
345
+ editMode = true;
346
+ } else if (arg === '--body' || arg === '-b') {
347
+ body = args[++i];
348
+ if (body === undefined) {
349
+ ui.error('--body requires a value');
350
+ ctx.exit(ExitCodes.ERROR);
351
+ }
352
+ } else if (arg.startsWith('--body=')) {
353
+ body = arg.slice(7);
354
+ } else if (arg === '--body-file' || arg === '-f') {
355
+ bodyFile = args[++i];
356
+ if (bodyFile === undefined) {
357
+ ui.error('--body-file requires a value');
358
+ ctx.exit(ExitCodes.ERROR);
359
+ }
360
+ } else if (arg.startsWith('--body-file=')) {
361
+ bodyFile = arg.slice(12);
362
+ } else if (!id) {
363
+ id = arg;
364
+ }
365
+ }
366
+
367
+ if (!id) {
368
+ ui.error(
369
+ 'Usage: tmux-team pm milestone doc <id> [ref | --edit | --body <text> | --body-file <path>]'
370
+ );
371
+ ctx.exit(ExitCodes.ERROR);
372
+ }
373
+
374
+ const isWriteMode = editMode || body !== undefined || bodyFile !== undefined;
375
+
376
+ // Check permission based on mode
377
+ if (isWriteMode) {
378
+ requirePermission(ctx, PermissionChecks.docUpdate());
379
+ } else {
380
+ requirePermission(ctx, PermissionChecks.docRead());
381
+ }
382
+
383
+ const { storage } = await requireTeam(ctx);
384
+ const milestone = await storage.getMilestone(id);
385
+ if (!milestone) {
386
+ ui.error(`Milestone ${id} not found`);
387
+ ctx.exit(ExitCodes.PANE_NOT_FOUND);
388
+ }
389
+
390
+ // Show reference (docPath)
391
+ if (showRef) {
392
+ if (flags.json) {
393
+ ui.json({ id, docPath: milestone.docPath });
394
+ } else {
395
+ console.log(milestone.docPath || '(no docPath)');
396
+ }
397
+ return;
398
+ }
399
+
400
+ // --body: set content directly
401
+ if (body !== undefined) {
402
+ await storage.setMilestoneDoc(id, body);
403
+ ui.success(`Saved documentation for milestone #${id}`);
404
+ return;
405
+ }
406
+
407
+ // --body-file: read content from file
408
+ if (bodyFile !== undefined) {
409
+ if (!fs.existsSync(bodyFile)) {
410
+ ui.error(`File not found: ${bodyFile}`);
411
+ ctx.exit(ExitCodes.ERROR);
412
+ }
413
+ const content = fs.readFileSync(bodyFile, 'utf-8');
414
+ await storage.setMilestoneDoc(id, content);
415
+ ui.success(`Saved documentation for milestone #${id} (from ${bodyFile})`);
416
+ return;
417
+ }
418
+
419
+ const doc = await storage.getMilestoneDoc(id);
420
+
421
+ // Default: print doc content
422
+ if (!editMode) {
423
+ if (flags.json) {
424
+ ui.json({ id, doc });
425
+ } else {
426
+ console.log(doc || '(empty)');
427
+ }
428
+ return;
429
+ }
430
+
431
+ // Edit mode: open in editor using temp file
432
+ const editor = process.env.EDITOR || 'vim';
433
+ const tempDir = os.tmpdir();
434
+ const tempFile = path.join(tempDir, `tmux-team-milestone-${id}.md`);
435
+
436
+ // Write current content to temp file
437
+ fs.writeFileSync(tempFile, doc || `# ${milestone.name}\n\n`);
438
+
439
+ const { spawnSync } = await import('child_process');
440
+ spawnSync(editor, [tempFile], { stdio: 'inherit' });
441
+
442
+ // Read edited content and sync back to storage
443
+ const newContent = fs.readFileSync(tempFile, 'utf-8');
444
+ await storage.setMilestoneDoc(id, newContent);
445
+
446
+ // Clean up temp file
447
+ try {
448
+ fs.unlinkSync(tempFile);
449
+ } catch {
450
+ // Ignore cleanup errors
451
+ }
452
+
453
+ ui.success(`Saved documentation for milestone #${id}`);
454
+ }
455
+
274
456
  export async function cmdPmTask(ctx: Context, args: string[]): Promise<void> {
275
457
  const [subcommand, ...rest] = args;
276
458
 
@@ -279,6 +461,7 @@ export async function cmdPmTask(ctx: Context, args: string[]): Promise<void> {
279
461
  return cmdTaskAdd(ctx, rest);
280
462
  case 'list':
281
463
  case 'ls':
464
+ case undefined:
282
465
  return cmdTaskList(ctx, rest);
283
466
  case 'show':
284
467
  return cmdTaskShow(ctx, rest);
@@ -286,8 +469,10 @@ export async function cmdPmTask(ctx: Context, args: string[]): Promise<void> {
286
469
  return cmdTaskUpdate(ctx, rest);
287
470
  case 'done':
288
471
  return cmdTaskDone(ctx, rest);
472
+ case 'doc':
473
+ return cmdTaskDoc(ctx, rest);
289
474
  default:
290
- ctx.ui.error(`Unknown task command: ${subcommand}. Use: add, list, show, update, done`);
475
+ ctx.ui.error(`Unknown task command: ${subcommand}. Use: add, list, show, update, done, doc`);
291
476
  ctx.exit(ExitCodes.ERROR);
292
477
  }
293
478
  }
@@ -348,12 +533,13 @@ async function cmdTaskAdd(ctx: Context, args: string[]): Promise<void> {
348
533
  async function cmdTaskList(ctx: Context, args: string[]): Promise<void> {
349
534
  requirePermission(ctx, PermissionChecks.taskList());
350
535
 
351
- const { ui, flags } = ctx;
536
+ const { ui, flags, config } = ctx;
352
537
  const { storage } = await requireTeam(ctx);
353
538
 
354
539
  // Parse filters
355
540
  let milestone: string | undefined;
356
541
  let status: TaskStatus | undefined;
542
+ let showAll = false;
357
543
 
358
544
  for (let i = 0; i < args.length; i++) {
359
545
  if (args[i] === '--milestone' || args[i] === '-m') {
@@ -364,10 +550,20 @@ async function cmdTaskList(ctx: Context, args: string[]): Promise<void> {
364
550
  status = parseStatus(args[++i]);
365
551
  } else if (args[i].startsWith('--status=')) {
366
552
  status = parseStatus(args[i].slice(9));
553
+ } else if (args[i] === '--all' || args[i] === '-a') {
554
+ showAll = true;
367
555
  }
368
556
  }
369
557
 
370
- const tasks = await storage.listTasks({ milestone, status });
558
+ const hideOrphanTasks = config.defaults.hideOrphanTasks;
559
+
560
+ // By default, exclude tasks in completed milestones (unless --all)
561
+ const tasks = await storage.listTasks({
562
+ milestone,
563
+ status,
564
+ excludeCompletedMilestones: !showAll,
565
+ hideOrphanTasks,
566
+ });
371
567
 
372
568
  if (flags.json) {
373
569
  ui.json(tasks);
@@ -385,6 +581,16 @@ async function cmdTaskList(ctx: Context, args: string[]): Promise<void> {
385
581
  tasks.map((t) => [t.id, t.title.slice(0, 40), formatStatus(t.status), t.milestone || '-'])
386
582
  );
387
583
  console.log();
584
+
585
+ if (!flags.json) {
586
+ const modeHint = hideOrphanTasks
587
+ ? 'hiding tasks without milestones'
588
+ : 'showing tasks without milestones';
589
+ const toggleHint = hideOrphanTasks ? 'false' : 'true';
590
+ ui.info(
591
+ `List mode: ${modeHint}. Use: ${colors.cyan(`tmt config set hideOrphanTasks ${toggleHint}`)}`
592
+ );
593
+ }
388
594
  }
389
595
 
390
596
  async function cmdTaskShow(ctx: Context, args: string[]): Promise<void> {
@@ -527,34 +733,99 @@ async function cmdTaskDone(ctx: Context, args: string[]): Promise<void> {
527
733
  }
528
734
  }
529
735
 
530
- export async function cmdPmDoc(ctx: Context, args: string[]): Promise<void> {
736
+ async function cmdTaskDoc(ctx: Context, args: string[]): Promise<void> {
531
737
  const { ui, flags } = ctx;
532
738
 
533
- const id = args[0];
739
+ // Parse arguments
740
+ let id: string | undefined;
741
+ let body: string | undefined;
742
+ let bodyFile: string | undefined;
743
+ let showRef = false;
744
+ let editMode = false;
745
+
746
+ for (let i = 0; i < args.length; i++) {
747
+ const arg = args[i];
748
+ if (arg === 'ref') {
749
+ showRef = true;
750
+ } else if (arg === '--edit' || arg === '-e') {
751
+ editMode = true;
752
+ } else if (arg === '--body' || arg === '-b') {
753
+ body = args[++i];
754
+ if (body === undefined) {
755
+ ui.error('--body requires a value');
756
+ ctx.exit(ExitCodes.ERROR);
757
+ }
758
+ } else if (arg.startsWith('--body=')) {
759
+ body = arg.slice(7);
760
+ } else if (arg === '--body-file' || arg === '-f') {
761
+ bodyFile = args[++i];
762
+ if (bodyFile === undefined) {
763
+ ui.error('--body-file requires a value');
764
+ ctx.exit(ExitCodes.ERROR);
765
+ }
766
+ } else if (arg.startsWith('--body-file=')) {
767
+ bodyFile = arg.slice(12);
768
+ } else if (!id) {
769
+ id = arg;
770
+ }
771
+ }
772
+
534
773
  if (!id) {
535
- ui.error('Usage: tmux-team pm doc <id> [--print]');
774
+ ui.error(
775
+ 'Usage: tmux-team pm task doc <id> [ref | --edit | --body <text> | --body-file <path>]'
776
+ );
536
777
  ctx.exit(ExitCodes.ERROR);
537
778
  }
538
779
 
539
- const printOnly = args.includes('--print') || args.includes('-p');
780
+ const isWriteMode = editMode || body !== undefined || bodyFile !== undefined;
540
781
 
541
782
  // Check permission based on mode
542
- if (printOnly || flags.json) {
543
- requirePermission(ctx, PermissionChecks.docRead());
544
- } else {
783
+ if (isWriteMode) {
545
784
  requirePermission(ctx, PermissionChecks.docUpdate());
785
+ } else {
786
+ requirePermission(ctx, PermissionChecks.docRead());
546
787
  }
547
788
 
548
- const { teamId, storage } = await requireTeam(ctx);
789
+ const { storage } = await requireTeam(ctx);
549
790
  const task = await storage.getTask(id);
550
791
  if (!task) {
551
792
  ui.error(`Task ${id} not found`);
552
793
  ctx.exit(ExitCodes.PANE_NOT_FOUND);
553
794
  }
554
795
 
796
+ // Show reference (docPath)
797
+ if (showRef) {
798
+ if (flags.json) {
799
+ ui.json({ id, docPath: task.docPath });
800
+ } else {
801
+ console.log(task.docPath || '(no docPath)');
802
+ }
803
+ return;
804
+ }
805
+
806
+ // --body: set content directly
807
+ if (body !== undefined) {
808
+ await storage.setTaskDoc(id, body);
809
+ ui.success(`Saved documentation for task #${id}`);
810
+ return;
811
+ }
812
+
813
+ // --body-file: read content from file
814
+ if (bodyFile !== undefined) {
815
+ if (!fs.existsSync(bodyFile)) {
816
+ ui.error(`File not found: ${bodyFile}`);
817
+ ctx.exit(ExitCodes.ERROR);
818
+ }
819
+ const content = fs.readFileSync(bodyFile, 'utf-8');
820
+ await storage.setTaskDoc(id, content);
821
+ ui.success(`Saved documentation for task #${id} (from ${bodyFile})`);
822
+ return;
823
+ }
824
+
555
825
  const doc = await storage.getTaskDoc(id);
556
826
 
557
- if (printOnly || flags.json) {
827
+ // Default: print doc content
828
+ if (!editMode) {
558
829
  if (flags.json) {
559
830
  ui.json({ id, doc });
560
831
  } else {
@@ -563,12 +834,27 @@ export async function cmdPmDoc(ctx: Context, args: string[]): Promise<void> {
563
834
  return;
564
835
  }
565
836
 
566
- // Open in editor
837
+ // Edit mode: open in editor using temp file
567
838
  const editor = process.env.EDITOR || 'vim';
568
- const docPath = path.join(getTeamsDir(ctx.paths.globalDir), teamId, 'tasks', `${id}.md`);
839
+ const tempDir = os.tmpdir();
840
+ const tempFile = path.join(tempDir, `tmux-team-task-${id}.md`);
841
+
842
+ // Write current content to temp file
843
+ fs.writeFileSync(tempFile, doc || `# ${task.title}\n\n`);
569
844
 
570
845
  const { spawnSync } = await import('child_process');
571
- spawnSync(editor, [docPath], { stdio: 'inherit' });
846
+ spawnSync(editor, [tempFile], { stdio: 'inherit' });
847
+
848
+ // Read edited content and sync back to storage
849
+ const newContent = fs.readFileSync(tempFile, 'utf-8');
850
+ await storage.setTaskDoc(id, newContent);
851
+
852
+ // Clean up temp file
853
+ try {
854
+ fs.unlinkSync(tempFile);
855
+ } catch {
856
+ // Ignore cleanup errors
857
+ }
572
858
 
573
859
  ui.success(`Saved documentation for task #${id}`);
574
860
  }
@@ -661,8 +947,6 @@ export async function cmdPm(ctx: Context, args: string[]): Promise<void> {
661
947
  return cmdPmMilestone(ctx, rest);
662
948
  case 'task':
663
949
  return cmdPmTask(ctx, rest);
664
- case 'doc':
665
- return cmdPmDoc(ctx, rest);
666
950
  case 'log':
667
951
  return cmdPmLog(ctx, rest);
668
952
  case 'list':
@@ -687,15 +971,19 @@ ${colors.yellow('COMMANDS')}
687
971
  --backend <fs|github> Storage backend (default: fs)
688
972
  --repo <owner/repo> GitHub repo (required for github backend)
689
973
  ${colors.green('list')} List all teams
690
- ${colors.green('milestone')} add <name> Add milestone (shorthand: m)
974
+ ${colors.green('milestone')} add <name> [-d <desc>] Add milestone (shorthand: m)
691
975
  ${colors.green('milestone')} list List milestones
692
976
  ${colors.green('milestone')} done <id> Mark milestone complete
977
+ ${colors.green('milestone')} delete <id> Delete milestone (rm)
978
+ ${colors.green('milestone')} doc <id> [options] Print/update doc
979
+ ref: show path, --edit: edit, --body: set text, --body-file: set from file
693
980
  ${colors.green('task')} add <title> [--milestone] Add task (shorthand: t)
694
- ${colors.green('task')} list [--status] [--milestone] List tasks
981
+ ${colors.green('task')} list [options] List tasks (hides done milestones by default)
982
+ --all: include tasks in completed milestones
695
983
  ${colors.green('task')} show <id> Show task details
696
984
  ${colors.green('task')} update <id> --status <s> Update task status
697
985
  ${colors.green('task')} done <id> Mark task complete
698
- ${colors.green('doc')} <id> [--print] View/edit task documentation
986
+ ${colors.green('task')} doc <id> [options] Print/update doc (same options as milestone doc)
699
987
  ${colors.green('log')} [--limit <n>] Show audit event log
700
988
 
701
989
  ${colors.yellow('BACKENDS')}
@@ -16,7 +16,13 @@ function createMockConfig(agents: Record<string, { deny?: string[] }>): Resolved
16
16
  return {
17
17
  mode: 'polling',
18
18
  preambleMode: 'always',
19
- defaults: { timeout: 60, pollInterval: 1, captureLines: 100 },
19
+ defaults: {
20
+ timeout: 60,
21
+ pollInterval: 1,
22
+ captureLines: 100,
23
+ preambleEvery: 3,
24
+ hideOrphanTasks: false,
25
+ },
20
26
  agents,
21
27
  paneRegistry: {},
22
28
  };
@@ -330,3 +336,109 @@ describe('resolveActor', () => {
330
336
  expect(result.source).toBe('env');
331
337
  });
332
338
  });
339
+
340
+ describe('checkPermission with local config (integration)', () => {
341
+ const originalEnv = { ...process.env };
342
+
343
+ beforeEach(() => {
344
+ process.env = { ...originalEnv };
345
+ delete process.env.TMUX;
346
+ });
347
+
348
+ afterEach(() => {
349
+ process.env = { ...originalEnv };
350
+ });
351
+
352
+ // Helper to create config as if loaded from local tmux-team.json
353
+ function createConfigWithLocalPermissions(
354
+ localAgents: Record<string, { preamble?: string; deny?: string[] }>
355
+ ): ResolvedConfig {
356
+ return {
357
+ mode: 'polling',
358
+ preambleMode: 'always',
359
+ defaults: {
360
+ timeout: 60,
361
+ pollInterval: 1,
362
+ captureLines: 100,
363
+ preambleEvery: 3,
364
+ hideOrphanTasks: false,
365
+ },
366
+ agents: localAgents, // This simulates merged local config
367
+ paneRegistry: {},
368
+ };
369
+ }
370
+
371
+ it('enforces local deny rules for specific agent', () => {
372
+ process.env.TMT_AGENT_NAME = 'claude';
373
+
374
+ // Simulates local config with: claude has deny rules, codex does not
375
+ const config = createConfigWithLocalPermissions({
376
+ claude: { deny: ['pm:task:update(status)', 'pm:milestone:update(status)'] },
377
+ codex: { preamble: 'Code quality guard' }, // No deny
378
+ });
379
+
380
+ // Claude is blocked from status updates
381
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(false);
382
+ expect(checkPermission(config, PermissionChecks.milestoneUpdate(['status'])).allowed).toBe(
383
+ false
384
+ );
385
+
386
+ // Claude can still do other things
387
+ expect(checkPermission(config, PermissionChecks.taskCreate()).allowed).toBe(true);
388
+ expect(checkPermission(config, PermissionChecks.taskList()).allowed).toBe(true);
389
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['assignee'])).allowed).toBe(true);
390
+ });
391
+
392
+ it('allows agent without deny rules to do everything', () => {
393
+ process.env.TMT_AGENT_NAME = 'codex';
394
+
395
+ const config = createConfigWithLocalPermissions({
396
+ claude: { deny: ['pm:task:update(status)'] },
397
+ codex: { preamble: 'Code quality guard' }, // No deny
398
+ });
399
+
400
+ // Codex can do everything including status updates
401
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(true);
402
+ expect(checkPermission(config, PermissionChecks.milestoneUpdate(['status'])).allowed).toBe(
403
+ true
404
+ );
405
+ expect(checkPermission(config, PermissionChecks.taskCreate()).allowed).toBe(true);
406
+ expect(checkPermission(config, PermissionChecks.taskDelete()).allowed).toBe(true);
407
+ });
408
+
409
+ it('project-specific permissions: implementer vs reviewer roles', () => {
410
+ // Real-world scenario: claude implements, codex reviews
411
+ const config = createConfigWithLocalPermissions({
412
+ claude: {
413
+ preamble: 'You implement features. Ask Codex for review before marking done.',
414
+ deny: ['pm:task:update(status)', 'pm:milestone:update(status)'],
415
+ },
416
+ codex: {
417
+ preamble: 'You are the code quality guard. Mark tasks done after reviewing.',
418
+ // No deny - codex can update status
419
+ },
420
+ });
421
+
422
+ // Claude cannot mark tasks done
423
+ process.env.TMT_AGENT_NAME = 'claude';
424
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(false);
425
+
426
+ // Codex can mark tasks done
427
+ process.env.TMT_AGENT_NAME = 'codex';
428
+ expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(true);
429
+ });
430
+
431
+ it('returns correct result when permission denied', () => {
432
+ process.env.TMT_AGENT_NAME = 'claude';
433
+
434
+ const config = createConfigWithLocalPermissions({
435
+ claude: { deny: ['pm:task:update(status)'] },
436
+ });
437
+
438
+ const result = checkPermission(config, PermissionChecks.taskUpdate(['status']));
439
+
440
+ expect(result.allowed).toBe(false);
441
+ expect(result.actor).toBe('claude');
442
+ expect(result.source).toBe('env');
443
+ });
444
+ });
@@ -105,11 +105,24 @@ function getCurrentPane(): string | null {
105
105
  return null;
106
106
  }
107
107
 
108
+ // TMUX_PANE contains the pane ID (e.g., %130) for the shell that's running.
109
+ // We must use -t "$TMUX_PANE" to get the correct pane, otherwise tmux returns
110
+ // the currently focused pane which may be different when commands are sent
111
+ // via send-keys from another pane.
112
+ // See: https://github.com/tmux/tmux/issues/4638
113
+ const tmuxPane = process.env.TMUX_PANE;
114
+ if (!tmuxPane) {
115
+ return null;
116
+ }
117
+
108
118
  try {
109
- const result = execSync("tmux display-message -p '#{window_index}.#{pane_index}'", {
110
- encoding: 'utf-8',
111
- timeout: 1000,
112
- }).trim();
119
+ const result = execSync(
120
+ `tmux display-message -p -t "${tmuxPane}" '#{window_index}.#{pane_index}'`,
121
+ {
122
+ encoding: 'utf-8',
123
+ timeout: 1000,
124
+ }
125
+ ).trim();
113
126
  return result || null;
114
127
  } catch {
115
128
  return null;
@@ -265,6 +278,7 @@ export const PermissionChecks = {
265
278
  action: 'update',
266
279
  fields,
267
280
  }),
281
+ milestoneDelete: (): PermissionCheck => ({ resource: 'milestone', action: 'delete', fields: [] }),
268
282
 
269
283
  // Doc operations
270
284
  docRead: (): PermissionCheck => ({ resource: 'doc', action: 'read', fields: [] }),
@@ -43,6 +43,8 @@ export interface StorageAdapter {
43
43
  // Documentation
44
44
  getTaskDoc(id: string): Promise<string | null>;
45
45
  setTaskDoc(id: string, content: string): Promise<void>;
46
+ getMilestoneDoc(id: string): Promise<string | null>;
47
+ setMilestoneDoc(id: string, content: string): Promise<void>;
46
48
 
47
49
  // Audit log
48
50
  appendEvent(event: AuditEvent): Promise<void>;