tmux-team 2.0.0-alpha.4 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -17
- package/package.json +1 -1
- package/src/commands/add.ts +9 -5
- package/src/commands/config.ts +104 -9
- package/src/commands/help.ts +3 -1
- package/src/commands/preamble.ts +22 -25
- package/src/commands/remove.ts +5 -3
- package/src/commands/talk.test.ts +199 -14
- package/src/commands/talk.ts +26 -2
- package/src/commands/update.ts +16 -5
- package/src/config.test.ts +183 -9
- package/src/config.ts +29 -16
- package/src/pm/commands.test.ts +389 -55
- package/src/pm/commands.ts +312 -24
- package/src/pm/permissions.test.ts +113 -1
- package/src/pm/permissions.ts +18 -4
- package/src/pm/storage/adapter.ts +2 -0
- package/src/pm/storage/fs.test.ts +129 -1
- package/src/pm/storage/fs.ts +38 -4
- package/src/pm/storage/github.ts +96 -17
- package/src/pm/types.ts +6 -0
- package/src/state.test.ts +20 -10
- package/src/state.ts +28 -1
- package/src/types.ts +5 -1
- package/src/ui.ts +2 -1
package/src/pm/commands.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
736
|
+
async function cmdTaskDoc(ctx: Context, args: string[]): Promise<void> {
|
|
531
737
|
const { ui, flags } = ctx;
|
|
532
738
|
|
|
533
|
-
|
|
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(
|
|
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
|
|
780
|
+
const isWriteMode = editMode || body !== undefined || bodyFile !== undefined;
|
|
540
781
|
|
|
541
782
|
// Check permission based on mode
|
|
542
|
-
if (
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
837
|
+
// Edit mode: open in editor using temp file
|
|
567
838
|
const editor = process.env.EDITOR || 'vim';
|
|
568
|
-
const
|
|
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, [
|
|
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>
|
|
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 [
|
|
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('
|
|
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: {
|
|
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
|
+
});
|
package/src/pm/permissions.ts
CHANGED
|
@@ -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(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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>;
|