tmux-team 2.0.0-alpha.1 → 2.0.0-alpha.4
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/package.json +4 -2
- package/src/cli.ts +25 -3
- package/src/commands/config.ts +186 -0
- package/src/commands/help.ts +44 -12
- package/src/commands/preamble.ts +153 -0
- package/src/commands/talk.test.ts +160 -5
- package/src/commands/talk.ts +359 -22
- package/src/config.test.ts +1 -1
- package/src/config.ts +70 -6
- package/src/pm/commands.test.ts +1061 -91
- package/src/pm/commands.ts +77 -8
- package/src/pm/manager.ts +12 -6
- package/src/pm/permissions.test.ts +332 -0
- package/src/pm/permissions.ts +279 -0
- package/src/pm/storage/fs.ts +3 -2
- package/src/pm/storage/github.ts +47 -35
- package/src/pm/types.ts +6 -0
- package/src/types.ts +11 -0
- package/src/ui.ts +13 -4
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Permission system for PM commands
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import type { ResolvedConfig, PaneEntry } from '../types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Permission expression format:
|
|
10
|
+
* pm:<resource>:<action>(<field1>,<field2>,...)
|
|
11
|
+
*
|
|
12
|
+
* Examples:
|
|
13
|
+
* - pm:task:list
|
|
14
|
+
* - pm:task:update(status)
|
|
15
|
+
* - pm:task:update(assignee,status)
|
|
16
|
+
* - pm:task:update(*) - wildcard, any field
|
|
17
|
+
* - pm:task:update - no fields, entire action
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface PermissionCheck {
|
|
21
|
+
resource: string; // task, milestone, team, doc, log
|
|
22
|
+
action: string; // list, show, create, update, read
|
|
23
|
+
fields: string[]; // sorted alphabetically
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build permission path from command context.
|
|
28
|
+
*/
|
|
29
|
+
export function buildPermissionPath(check: PermissionCheck): string {
|
|
30
|
+
const base = `pm:${check.resource}:${check.action}`;
|
|
31
|
+
if (check.fields.length === 0) {
|
|
32
|
+
return base;
|
|
33
|
+
}
|
|
34
|
+
// Sort fields alphabetically for canonical form
|
|
35
|
+
const sortedFields = [...check.fields].sort();
|
|
36
|
+
return `${base}(${sortedFields.join(',')})`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse a deny pattern into its components.
|
|
41
|
+
*/
|
|
42
|
+
function parsePattern(pattern: string): {
|
|
43
|
+
resource: string;
|
|
44
|
+
action: string;
|
|
45
|
+
fields: string[] | '*' | null;
|
|
46
|
+
} {
|
|
47
|
+
// Pattern format: pm:resource:action or pm:resource:action(fields) or pm:resource:action(*)
|
|
48
|
+
const match = pattern.match(/^pm:(\w+):(\w+)(?:\(([^)]*)\))?$/);
|
|
49
|
+
if (!match) {
|
|
50
|
+
return { resource: '', action: '', fields: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const [, resource, action, fieldsStr] = match;
|
|
54
|
+
|
|
55
|
+
if (fieldsStr === undefined) {
|
|
56
|
+
// No parentheses - blocks entire action
|
|
57
|
+
return { resource, action, fields: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (fieldsStr === '*') {
|
|
61
|
+
// Wildcard - blocks any field
|
|
62
|
+
return { resource, action, fields: '*' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Specific fields
|
|
66
|
+
const fields = fieldsStr
|
|
67
|
+
.split(',')
|
|
68
|
+
.map((f) => f.trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
return { resource, action, fields };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if a permission path matches a deny pattern.
|
|
75
|
+
*/
|
|
76
|
+
function matchesPattern(path: PermissionCheck, pattern: string): boolean {
|
|
77
|
+
const parsed = parsePattern(pattern);
|
|
78
|
+
|
|
79
|
+
// Resource and action must match
|
|
80
|
+
if (parsed.resource !== path.resource || parsed.action !== path.action) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// No fields in pattern = block entire action
|
|
85
|
+
if (parsed.fields === null) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Wildcard = block if path has any fields
|
|
90
|
+
if (parsed.fields === '*') {
|
|
91
|
+
return path.fields.length > 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Specific fields = block if path uses ANY of the denied fields
|
|
95
|
+
return parsed.fields.some((f) => path.fields.includes(f));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get current tmux pane in "window.pane" format.
|
|
100
|
+
* Returns null if not running in tmux.
|
|
101
|
+
*/
|
|
102
|
+
function getCurrentPane(): string | null {
|
|
103
|
+
// Check if we're in tmux
|
|
104
|
+
if (!process.env.TMUX) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const result = execSync("tmux display-message -p '#{window_index}.#{pane_index}'", {
|
|
110
|
+
encoding: 'utf-8',
|
|
111
|
+
timeout: 1000,
|
|
112
|
+
}).trim();
|
|
113
|
+
return result || null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Look up agent name by pane ID in the registry.
|
|
121
|
+
*/
|
|
122
|
+
function findAgentByPane(paneRegistry: Record<string, PaneEntry>, paneId: string): string | null {
|
|
123
|
+
for (const [agentName, entry] of Object.entries(paneRegistry)) {
|
|
124
|
+
if (entry.pane === paneId) {
|
|
125
|
+
return agentName;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ActorResolution {
|
|
132
|
+
actor: string;
|
|
133
|
+
source: 'pane' | 'env' | 'default';
|
|
134
|
+
warning?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve current actor using pane registry as primary source.
|
|
139
|
+
*
|
|
140
|
+
* Priority:
|
|
141
|
+
* 1. Look up current tmux pane in pane registry → agent name
|
|
142
|
+
* 2. If not in registry → 'human' (full access)
|
|
143
|
+
*
|
|
144
|
+
* Warnings:
|
|
145
|
+
* - If TMT_AGENT_NAME is set but conflicts with pane registry → warn about spoofing
|
|
146
|
+
* - If TMT_AGENT_NAME is set but pane not in registry → warn about unregistered pane
|
|
147
|
+
*/
|
|
148
|
+
export function resolveActor(paneRegistry: Record<string, PaneEntry>): ActorResolution {
|
|
149
|
+
const envActor = process.env.TMT_AGENT_NAME || process.env.TMUX_TEAM_ACTOR;
|
|
150
|
+
const currentPane = getCurrentPane();
|
|
151
|
+
|
|
152
|
+
// Not in tmux - use env var or default to human
|
|
153
|
+
if (!currentPane) {
|
|
154
|
+
if (envActor) {
|
|
155
|
+
return { actor: envActor, source: 'env' };
|
|
156
|
+
}
|
|
157
|
+
return { actor: 'human', source: 'default' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// In tmux - look up pane in registry
|
|
161
|
+
const paneAgent = findAgentByPane(paneRegistry, currentPane);
|
|
162
|
+
|
|
163
|
+
if (paneAgent) {
|
|
164
|
+
// Pane is registered to an agent
|
|
165
|
+
if (envActor && envActor !== paneAgent) {
|
|
166
|
+
// Env var conflicts with pane registry - warn about potential spoofing
|
|
167
|
+
return {
|
|
168
|
+
actor: paneAgent,
|
|
169
|
+
source: 'pane',
|
|
170
|
+
warning: `⚠️ Identity mismatch: TMT_AGENT_NAME="${envActor}" but pane ${currentPane} is registered to "${paneAgent}". Using pane identity.`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return { actor: paneAgent, source: 'pane' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Pane not in registry
|
|
177
|
+
if (envActor) {
|
|
178
|
+
// Agent claims identity but pane not registered - use env identity with warning
|
|
179
|
+
// Security: Still apply agent's deny patterns to prevent bypass via unregistered pane
|
|
180
|
+
return {
|
|
181
|
+
actor: envActor,
|
|
182
|
+
source: 'env',
|
|
183
|
+
warning: `⚠️ Unregistered pane: pane ${currentPane} is not in registry. Using TMT_AGENT_NAME="${envActor}".`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Not registered, no env var - human
|
|
188
|
+
return { actor: 'human', source: 'default' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get current actor (agent name or 'human').
|
|
193
|
+
* Legacy function for backward compatibility.
|
|
194
|
+
* Reads from TMT_AGENT_NAME or TMUX_TEAM_ACTOR env vars.
|
|
195
|
+
*/
|
|
196
|
+
export function getCurrentActor(): string {
|
|
197
|
+
return process.env.TMT_AGENT_NAME || process.env.TMUX_TEAM_ACTOR || 'human';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface PermissionResult {
|
|
201
|
+
allowed: boolean;
|
|
202
|
+
actor: string;
|
|
203
|
+
source: 'pane' | 'env' | 'default';
|
|
204
|
+
warning?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Check if an action is allowed for the current actor.
|
|
209
|
+
* Uses pane-based identity resolution with warnings for conflicts.
|
|
210
|
+
*/
|
|
211
|
+
export function checkPermission(config: ResolvedConfig, check: PermissionCheck): PermissionResult {
|
|
212
|
+
const resolution = resolveActor(config.paneRegistry);
|
|
213
|
+
const { actor, source, warning } = resolution;
|
|
214
|
+
|
|
215
|
+
// Human is always allowed (no deny patterns for human)
|
|
216
|
+
if (actor === 'human') {
|
|
217
|
+
return { allowed: true, actor, source, warning };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Get agent config
|
|
221
|
+
const agentConfig = config.agents[actor];
|
|
222
|
+
if (!agentConfig || !agentConfig.deny || agentConfig.deny.length === 0) {
|
|
223
|
+
// No deny patterns = allow all
|
|
224
|
+
return { allowed: true, actor, source, warning };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Check if any deny pattern matches
|
|
228
|
+
for (const pattern of agentConfig.deny) {
|
|
229
|
+
if (matchesPattern(check, pattern)) {
|
|
230
|
+
return { allowed: false, actor, source, warning };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { allowed: true, actor, source, warning };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Simple permission check (legacy, for tests).
|
|
239
|
+
* Returns true if allowed, false if denied.
|
|
240
|
+
*/
|
|
241
|
+
export function checkPermissionSimple(config: ResolvedConfig, check: PermissionCheck): boolean {
|
|
242
|
+
return checkPermission(config, check).allowed;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Build permission check for common PM operations.
|
|
247
|
+
*/
|
|
248
|
+
export const PermissionChecks = {
|
|
249
|
+
// Task operations
|
|
250
|
+
taskList: (): PermissionCheck => ({ resource: 'task', action: 'list', fields: [] }),
|
|
251
|
+
taskShow: (): PermissionCheck => ({ resource: 'task', action: 'show', fields: [] }),
|
|
252
|
+
taskCreate: (): PermissionCheck => ({ resource: 'task', action: 'create', fields: [] }),
|
|
253
|
+
taskUpdate: (fields: string[]): PermissionCheck => ({
|
|
254
|
+
resource: 'task',
|
|
255
|
+
action: 'update',
|
|
256
|
+
fields,
|
|
257
|
+
}),
|
|
258
|
+
taskDelete: (): PermissionCheck => ({ resource: 'task', action: 'delete', fields: [] }),
|
|
259
|
+
|
|
260
|
+
// Milestone operations
|
|
261
|
+
milestoneList: (): PermissionCheck => ({ resource: 'milestone', action: 'list', fields: [] }),
|
|
262
|
+
milestoneCreate: (): PermissionCheck => ({ resource: 'milestone', action: 'create', fields: [] }),
|
|
263
|
+
milestoneUpdate: (fields: string[]): PermissionCheck => ({
|
|
264
|
+
resource: 'milestone',
|
|
265
|
+
action: 'update',
|
|
266
|
+
fields,
|
|
267
|
+
}),
|
|
268
|
+
|
|
269
|
+
// Doc operations
|
|
270
|
+
docRead: (): PermissionCheck => ({ resource: 'doc', action: 'read', fields: [] }),
|
|
271
|
+
docUpdate: (): PermissionCheck => ({ resource: 'doc', action: 'update', fields: [] }),
|
|
272
|
+
|
|
273
|
+
// Team operations
|
|
274
|
+
teamCreate: (): PermissionCheck => ({ resource: 'team', action: 'create', fields: [] }),
|
|
275
|
+
teamList: (): PermissionCheck => ({ resource: 'team', action: 'list', fields: [] }),
|
|
276
|
+
|
|
277
|
+
// Log operations
|
|
278
|
+
logRead: (): PermissionCheck => ({ resource: 'log', action: 'read', fields: [] }),
|
|
279
|
+
};
|
package/src/pm/storage/fs.ts
CHANGED
|
@@ -161,8 +161,9 @@ export class FSAdapter implements StorageAdapter {
|
|
|
161
161
|
updatedAt: this.now(),
|
|
162
162
|
};
|
|
163
163
|
this.writeJson(path.join(this.tasksDir, `${id}.json`), task);
|
|
164
|
-
// Create
|
|
165
|
-
|
|
164
|
+
// Create doc file with title and optional body
|
|
165
|
+
const docContent = input.body ? `# ${input.title}\n\n${input.body}\n` : `# ${input.title}\n\n`;
|
|
166
|
+
fs.writeFileSync(path.join(this.tasksDir, `${id}.md`), docContent);
|
|
166
167
|
return task;
|
|
167
168
|
}
|
|
168
169
|
|
package/src/pm/storage/github.ts
CHANGED
|
@@ -53,7 +53,7 @@ interface GHIssue {
|
|
|
53
53
|
interface GHMilestone {
|
|
54
54
|
number: number;
|
|
55
55
|
title: string;
|
|
56
|
-
state: '
|
|
56
|
+
state: 'open' | 'closed'; // REST API uses lowercase (unlike GraphQL)
|
|
57
57
|
createdAt: string;
|
|
58
58
|
}
|
|
59
59
|
|
|
@@ -61,13 +61,13 @@ interface GHMilestone {
|
|
|
61
61
|
// Local cache for ID mapping (task ID -> issue number)
|
|
62
62
|
// ─────────────────────────────────────────────────────────────
|
|
63
63
|
|
|
64
|
-
const CACHE_VERSION =
|
|
64
|
+
const CACHE_VERSION = 2;
|
|
65
65
|
|
|
66
66
|
interface IdCache {
|
|
67
67
|
version: number; // Cache format version for migrations
|
|
68
68
|
repo: string; // Associated repo to detect cross-repo drift
|
|
69
69
|
tasks: Record<string, number>; // task ID -> issue number
|
|
70
|
-
milestones: Record<string, number>; // milestone ID ->
|
|
70
|
+
milestones: Record<string, { number: number; name: string }>; // milestone ID -> {number, name}
|
|
71
71
|
nextTaskId: number;
|
|
72
72
|
nextMilestoneId: number;
|
|
73
73
|
}
|
|
@@ -89,9 +89,12 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
89
89
|
// Helper: Execute gh CLI command safely (no shell injection)
|
|
90
90
|
// ─────────────────────────────────────────────────────────────
|
|
91
91
|
|
|
92
|
-
private gh(args: string[]): string {
|
|
92
|
+
private gh(args: string[], options?: { skipRepo?: boolean }): string {
|
|
93
93
|
const fullArgs = [...args];
|
|
94
|
-
|
|
94
|
+
// gh api doesn't accept --repo flag (repo is in the endpoint path)
|
|
95
|
+
// Other commands like 'gh issue' do accept --repo
|
|
96
|
+
const isApiCommand = args[0] === 'api';
|
|
97
|
+
if (this.repo && !isApiCommand && !options?.skipRepo) {
|
|
95
98
|
fullArgs.push('--repo', this.repo);
|
|
96
99
|
}
|
|
97
100
|
// Use spawnSync with array args to avoid shell injection
|
|
@@ -195,7 +198,12 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
195
198
|
|
|
196
199
|
private getMilestoneNumber(milestoneId: string): number | null {
|
|
197
200
|
const cache = this.loadCache();
|
|
198
|
-
return cache.milestones[milestoneId] ?? null;
|
|
201
|
+
return cache.milestones[milestoneId]?.number ?? null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private getMilestoneName(milestoneId: string): string | null {
|
|
205
|
+
const cache = this.loadCache();
|
|
206
|
+
return cache.milestones[milestoneId]?.name ?? null;
|
|
199
207
|
}
|
|
200
208
|
|
|
201
209
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -226,7 +234,7 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
226
234
|
if (issue.milestone?.number) {
|
|
227
235
|
const c = cache || this.loadCache();
|
|
228
236
|
const ghMilestoneNum = issue.milestone.number;
|
|
229
|
-
milestoneId = Object.entries(c.milestones).find(([,
|
|
237
|
+
milestoneId = Object.entries(c.milestones).find(([, m]) => m.number === ghMilestoneNum)?.[0];
|
|
230
238
|
}
|
|
231
239
|
|
|
232
240
|
return {
|
|
@@ -245,7 +253,7 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
245
253
|
return {
|
|
246
254
|
id,
|
|
247
255
|
name: ghMilestone.title,
|
|
248
|
-
status: ghMilestone.state === '
|
|
256
|
+
status: ghMilestone.state === 'closed' ? 'done' : 'pending',
|
|
249
257
|
createdAt: ghMilestone.createdAt,
|
|
250
258
|
updatedAt: ghMilestone.createdAt, // GH milestones don't have updatedAt
|
|
251
259
|
};
|
|
@@ -352,10 +360,10 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
352
360
|
]);
|
|
353
361
|
const ghMilestone = JSON.parse(result) as { number: number; title: string; created_at: string };
|
|
354
362
|
|
|
355
|
-
// Cache the ID mapping
|
|
363
|
+
// Cache the ID mapping (store both number and name)
|
|
356
364
|
const cache = this.loadCache();
|
|
357
365
|
const id = String(cache.nextMilestoneId++);
|
|
358
|
-
cache.milestones[id] = ghMilestone.number;
|
|
366
|
+
cache.milestones[id] = { number: ghMilestone.number, name: ghMilestone.title };
|
|
359
367
|
this.saveCache(cache);
|
|
360
368
|
|
|
361
369
|
return {
|
|
@@ -404,11 +412,11 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
404
412
|
|
|
405
413
|
// Match with cached IDs, or create new mappings
|
|
406
414
|
for (const ghm of ghMilestones) {
|
|
407
|
-
let id = Object.entries(cache.milestones).find(([,
|
|
415
|
+
let id = Object.entries(cache.milestones).find(([, m]) => m.number === ghm.number)?.[0];
|
|
408
416
|
if (!id) {
|
|
409
417
|
// New milestone from GitHub, assign ID
|
|
410
418
|
id = String(cache.nextMilestoneId++);
|
|
411
|
-
cache.milestones[id] = ghm.number;
|
|
419
|
+
cache.milestones[id] = { number: ghm.number, name: ghm.title };
|
|
412
420
|
}
|
|
413
421
|
milestones.push(this.milestoneToMilestone(ghm, id));
|
|
414
422
|
}
|
|
@@ -468,24 +476,26 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
468
476
|
'create',
|
|
469
477
|
'--title',
|
|
470
478
|
input.title,
|
|
479
|
+
'--body',
|
|
480
|
+
input.body || '', // Required for non-interactive mode
|
|
471
481
|
'--label',
|
|
472
482
|
LABELS.TASK,
|
|
473
483
|
'--label',
|
|
474
484
|
LABELS.PENDING,
|
|
475
485
|
];
|
|
476
486
|
|
|
477
|
-
// Add milestone if specified
|
|
487
|
+
// Add milestone if specified (gh issue create expects milestone name, not number)
|
|
478
488
|
if (input.milestone) {
|
|
479
|
-
const
|
|
480
|
-
if (
|
|
481
|
-
args.push('--milestone',
|
|
489
|
+
const milestoneName = this.getMilestoneName(input.milestone);
|
|
490
|
+
if (milestoneName) {
|
|
491
|
+
args.push('--milestone', milestoneName);
|
|
482
492
|
}
|
|
483
493
|
}
|
|
484
494
|
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
495
|
+
// NOTE: Assignee is intentionally NOT supported for GitHub backend.
|
|
496
|
+
// Agent names (e.g., "codex") don't map to GitHub usernames, and passing
|
|
497
|
+
// them could accidentally notify unrelated GitHub users or fail silently.
|
|
498
|
+
// Use labels or comments for agent attribution instead.
|
|
489
499
|
|
|
490
500
|
// Create issue and get its number
|
|
491
501
|
const url = this.gh(args);
|
|
@@ -528,7 +538,14 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
528
538
|
}
|
|
529
539
|
|
|
530
540
|
async listTasks(filter?: ListTasksFilter): Promise<Task[]> {
|
|
531
|
-
|
|
541
|
+
let cache = this.loadCache();
|
|
542
|
+
|
|
543
|
+
// Rebuild milestone cache if empty (e.g., after cache reset)
|
|
544
|
+
if (Object.keys(cache.milestones).length === 0) {
|
|
545
|
+
await this.listMilestones(); // This populates the cache
|
|
546
|
+
cache = this.loadCache(); // Reload updated cache
|
|
547
|
+
}
|
|
548
|
+
|
|
532
549
|
const args = [
|
|
533
550
|
'issue',
|
|
534
551
|
'list',
|
|
@@ -548,18 +565,15 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
548
565
|
args.push('--state', 'all');
|
|
549
566
|
}
|
|
550
567
|
|
|
551
|
-
// Filter by milestone
|
|
568
|
+
// Filter by milestone (gh issue list expects milestone name)
|
|
552
569
|
if (filter?.milestone) {
|
|
553
|
-
const
|
|
554
|
-
if (
|
|
555
|
-
args.push('--milestone',
|
|
570
|
+
const milestoneName = this.getMilestoneName(filter.milestone);
|
|
571
|
+
if (milestoneName) {
|
|
572
|
+
args.push('--milestone', milestoneName);
|
|
556
573
|
}
|
|
557
574
|
}
|
|
558
575
|
|
|
559
|
-
//
|
|
560
|
-
if (filter?.assignee) {
|
|
561
|
-
args.push('--assignee', filter.assignee);
|
|
562
|
-
}
|
|
576
|
+
// NOTE: Assignee filter not supported for GitHub backend (security risk)
|
|
563
577
|
|
|
564
578
|
// Only get tmux-team managed issues (all have TASK label)
|
|
565
579
|
args.push('--label', LABELS.TASK);
|
|
@@ -601,14 +615,12 @@ export class GitHubAdapter implements StorageAdapter {
|
|
|
601
615
|
args.push('--title', input.title);
|
|
602
616
|
}
|
|
603
617
|
|
|
604
|
-
|
|
605
|
-
args.push('--add-assignee', input.assignee);
|
|
606
|
-
}
|
|
618
|
+
// NOTE: Assignee updates are not supported for GitHub backend (security risk)
|
|
607
619
|
|
|
608
620
|
if (input.milestone) {
|
|
609
|
-
const
|
|
610
|
-
if (
|
|
611
|
-
args.push('--milestone',
|
|
621
|
+
const milestoneName = this.getMilestoneName(input.milestone);
|
|
622
|
+
if (milestoneName) {
|
|
623
|
+
args.push('--milestone', milestoneName);
|
|
612
624
|
}
|
|
613
625
|
}
|
|
614
626
|
|
package/src/pm/types.ts
CHANGED
|
@@ -41,6 +41,7 @@ export interface AuditEvent {
|
|
|
41
41
|
|
|
42
42
|
export interface CreateTaskInput {
|
|
43
43
|
title: string;
|
|
44
|
+
body?: string;
|
|
44
45
|
milestone?: string;
|
|
45
46
|
assignee?: string;
|
|
46
47
|
}
|
|
@@ -77,3 +78,8 @@ export interface TeamConfig {
|
|
|
77
78
|
backend: StorageBackend;
|
|
78
79
|
repo?: string; // GitHub repo (owner/repo format) for github backend
|
|
79
80
|
}
|
|
81
|
+
|
|
82
|
+
export interface TeamWithConfig extends Team {
|
|
83
|
+
backend: StorageBackend;
|
|
84
|
+
repo?: string;
|
|
85
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
export interface AgentConfig {
|
|
6
6
|
preamble?: string;
|
|
7
|
+
deny?: string[]; // Permission deny patterns, e.g., ["pm:task:update(status)"]
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export interface PaneEntry {
|
|
@@ -24,6 +25,16 @@ export interface GlobalConfig {
|
|
|
24
25
|
agents: Record<string, AgentConfig>;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
export interface LocalSettings {
|
|
29
|
+
mode?: 'polling' | 'wait';
|
|
30
|
+
preambleMode?: 'always' | 'disabled';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LocalConfigFile {
|
|
34
|
+
$config?: LocalSettings;
|
|
35
|
+
[agentName: string]: PaneEntry | LocalSettings | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
export interface LocalConfig {
|
|
28
39
|
[agentName: string]: PaneEntry;
|
|
29
40
|
}
|
package/src/ui.ts
CHANGED
|
@@ -6,6 +6,9 @@ import type { UI } from './types.js';
|
|
|
6
6
|
|
|
7
7
|
const isTTY = process.stdout.isTTY;
|
|
8
8
|
|
|
9
|
+
// Strip ANSI escape codes for accurate length calculation
|
|
10
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
11
|
+
|
|
9
12
|
export const colors = {
|
|
10
13
|
red: (s: string) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s),
|
|
11
14
|
green: (s: string) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s),
|
|
@@ -46,18 +49,24 @@ export function createUI(jsonMode: boolean): UI {
|
|
|
46
49
|
console.error(`${colors.red('✗')} ${msg}`);
|
|
47
50
|
},
|
|
48
51
|
table: (headers: string[], rows: string[][]) => {
|
|
49
|
-
// Calculate column widths
|
|
52
|
+
// Calculate column widths (strip ANSI codes for accurate length)
|
|
50
53
|
const widths = headers.map((h, i) =>
|
|
51
|
-
Math.max(h.length, ...rows.map((r) => (r[i] || '').length))
|
|
54
|
+
Math.max(h.length, ...rows.map((r) => stripAnsi(r[i] || '').length))
|
|
52
55
|
);
|
|
53
56
|
|
|
54
57
|
// Print header
|
|
55
58
|
console.log(' ' + headers.map((h, i) => colors.yellow(h.padEnd(widths[i]))).join(' '));
|
|
56
59
|
console.log(' ' + widths.map((w) => '─'.repeat(w)).join(' '));
|
|
57
60
|
|
|
58
|
-
// Print rows
|
|
61
|
+
// Print rows (pad based on visible length, not byte length)
|
|
59
62
|
for (const row of rows) {
|
|
60
|
-
|
|
63
|
+
const cells = row.map((c, i) => {
|
|
64
|
+
const cell = c || '-';
|
|
65
|
+
const visibleLen = stripAnsi(cell).length;
|
|
66
|
+
const padding = ' '.repeat(Math.max(0, widths[i] - visibleLen));
|
|
67
|
+
return cell + padding;
|
|
68
|
+
});
|
|
69
|
+
console.log(' ' + cells.join(' '));
|
|
61
70
|
}
|
|
62
71
|
},
|
|
63
72
|
json: (data: unknown) => {
|