@proletariat/cli 0.3.46 → 0.3.48
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/bin/validate-better-sqlite3.cjs +55 -0
- package/dist/commands/caffeinate/index.d.ts +10 -0
- package/dist/commands/caffeinate/index.js +64 -0
- package/dist/commands/caffeinate/start.d.ts +14 -0
- package/dist/commands/caffeinate/start.js +86 -0
- package/dist/commands/caffeinate/status.d.ts +10 -0
- package/dist/commands/caffeinate/status.js +55 -0
- package/dist/commands/caffeinate/stop.d.ts +10 -0
- package/dist/commands/caffeinate/stop.js +47 -0
- package/dist/commands/commit.js +10 -8
- package/dist/commands/config/index.js +2 -3
- package/dist/commands/init.js +9 -1
- package/dist/commands/orchestrator/attach.d.ts +1 -0
- package/dist/commands/orchestrator/attach.js +104 -24
- package/dist/commands/orchestrator/index.js +2 -2
- package/dist/commands/orchestrator/start.d.ts +13 -1
- package/dist/commands/orchestrator/start.js +115 -34
- package/dist/commands/orchestrator/status.d.ts +1 -0
- package/dist/commands/orchestrator/status.js +68 -22
- package/dist/commands/orchestrator/stop.d.ts +1 -0
- package/dist/commands/orchestrator/stop.js +50 -13
- package/dist/commands/session/attach.js +55 -9
- package/dist/commands/session/poke.js +1 -1
- package/dist/commands/work/index.js +8 -0
- package/dist/commands/work/linear.d.ts +24 -0
- package/dist/commands/work/linear.js +195 -0
- package/dist/commands/work/review.d.ts +45 -0
- package/dist/commands/work/review.js +401 -0
- package/dist/commands/work/spawn.js +28 -19
- package/dist/commands/work/start.js +12 -2
- package/dist/hooks/init.js +26 -5
- package/dist/lib/caffeinate.d.ts +64 -0
- package/dist/lib/caffeinate.js +146 -0
- package/dist/lib/database/native-validation.d.ts +21 -0
- package/dist/lib/database/native-validation.js +49 -0
- package/dist/lib/execution/codex-adapter.d.ts +96 -0
- package/dist/lib/execution/codex-adapter.js +148 -0
- package/dist/lib/execution/index.d.ts +1 -0
- package/dist/lib/execution/index.js +1 -0
- package/dist/lib/execution/runners.js +56 -6
- package/dist/lib/external-issues/index.d.ts +1 -1
- package/dist/lib/external-issues/index.js +1 -1
- package/dist/lib/external-issues/linear.d.ts +37 -0
- package/dist/lib/external-issues/linear.js +198 -0
- package/dist/lib/external-issues/types.d.ts +67 -0
- package/dist/lib/external-issues/types.js +41 -0
- package/dist/lib/init/index.d.ts +4 -0
- package/dist/lib/init/index.js +11 -1
- package/dist/lib/machine-config.d.ts +1 -0
- package/dist/lib/machine-config.js +6 -3
- package/dist/lib/mcp/tools/work.js +36 -0
- package/dist/lib/pmo/storage/actions.js +3 -3
- package/dist/lib/pmo/storage/base.js +85 -6
- package/dist/lib/pmo/storage/epics.js +1 -1
- package/dist/lib/pmo/storage/tickets.js +2 -2
- package/dist/lib/pmo/storage/types.d.ts +2 -1
- package/oclif.manifest.json +4158 -3651
- package/package.json +2 -2
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
import { PMOCommand, pmoBaseFlags } from '../../lib/pmo/index.js';
|
|
6
|
+
import { styles } from '../../lib/styles.js';
|
|
7
|
+
import { getWorkspaceInfo, } from '../../lib/agents/commands.js';
|
|
8
|
+
import { ExecutionStorage } from '../../lib/execution/storage.js';
|
|
9
|
+
import { shouldOutputJson, outputErrorAsJson, outputSuccessAsJson, createMetadata, } from '../../lib/prompt-json.js';
|
|
10
|
+
import { isGHInstalled, isGHAuthenticated, getPRFeedback, getPRForBranch, } from '../../lib/pr/index.js';
|
|
11
|
+
/**
|
|
12
|
+
* Maximum poll duration before giving up (30 minutes).
|
|
13
|
+
*/
|
|
14
|
+
const MAX_POLL_DURATION_MS = 30 * 60 * 1000;
|
|
15
|
+
export default class WorkReview extends PMOCommand {
|
|
16
|
+
static description = 'Automated review-fix pipeline: review → fix → re-review until clean';
|
|
17
|
+
static examples = [
|
|
18
|
+
'<%= config.bin %> <%= command.id %> TKT-001',
|
|
19
|
+
'<%= config.bin %> <%= command.id %> TKT-001 --max-cycles 5',
|
|
20
|
+
'<%= config.bin %> <%= command.id %> TKT-001 --auto # Skip confirmations between cycles',
|
|
21
|
+
'<%= config.bin %> <%= command.id %> # Interactive mode',
|
|
22
|
+
];
|
|
23
|
+
static args = {
|
|
24
|
+
ticketId: Args.string({
|
|
25
|
+
description: 'Ticket ID to review',
|
|
26
|
+
required: false,
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
static flags = {
|
|
30
|
+
...pmoBaseFlags,
|
|
31
|
+
'max-cycles': Flags.integer({
|
|
32
|
+
description: 'Maximum review-fix cycles before stopping (default: 3)',
|
|
33
|
+
default: 3,
|
|
34
|
+
}),
|
|
35
|
+
auto: Flags.boolean({
|
|
36
|
+
description: 'Skip confirmations between cycles (fully automated)',
|
|
37
|
+
default: false,
|
|
38
|
+
}),
|
|
39
|
+
executor: Flags.string({
|
|
40
|
+
char: 'e',
|
|
41
|
+
description: 'Override executor',
|
|
42
|
+
options: ['claude-code', 'codex', 'aider', 'custom'],
|
|
43
|
+
}),
|
|
44
|
+
'run-on-host': Flags.boolean({
|
|
45
|
+
description: 'Run on host even if devcontainer exists (bypasses sandbox)',
|
|
46
|
+
default: false,
|
|
47
|
+
}),
|
|
48
|
+
'skip-permissions': Flags.boolean({
|
|
49
|
+
description: 'Skip permission checks for agents',
|
|
50
|
+
default: false,
|
|
51
|
+
}),
|
|
52
|
+
display: Flags.string({
|
|
53
|
+
char: 'd',
|
|
54
|
+
description: 'Display mode for agents',
|
|
55
|
+
options: ['foreground', 'terminal', 'background'],
|
|
56
|
+
default: 'background',
|
|
57
|
+
}),
|
|
58
|
+
session: Flags.string({
|
|
59
|
+
char: 's',
|
|
60
|
+
description: 'Session manager inside container',
|
|
61
|
+
options: ['tmux', 'direct'],
|
|
62
|
+
default: 'tmux',
|
|
63
|
+
}),
|
|
64
|
+
'poll-interval': Flags.integer({
|
|
65
|
+
description: 'Polling interval in seconds to check agent completion (default: 10)',
|
|
66
|
+
default: 10,
|
|
67
|
+
}),
|
|
68
|
+
force: Flags.boolean({
|
|
69
|
+
char: 'f',
|
|
70
|
+
description: 'Force spawn even if ticket has running executions',
|
|
71
|
+
default: false,
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
async execute() {
|
|
75
|
+
const { args, flags } = await this.parse(WorkReview);
|
|
76
|
+
const projectId = flags.project;
|
|
77
|
+
const jsonMode = shouldOutputJson(flags);
|
|
78
|
+
const handleError = (code, message) => {
|
|
79
|
+
if (jsonMode) {
|
|
80
|
+
outputErrorAsJson(code, message, createMetadata('work review', flags));
|
|
81
|
+
this.exit(1);
|
|
82
|
+
}
|
|
83
|
+
this.error(message);
|
|
84
|
+
};
|
|
85
|
+
// Check gh CLI
|
|
86
|
+
if (!isGHInstalled() || !isGHAuthenticated()) {
|
|
87
|
+
return handleError('GH_NOT_AVAILABLE', 'GitHub CLI (gh) is required for the review pipeline.\nRun: prlt gh login');
|
|
88
|
+
}
|
|
89
|
+
// Get workspace info
|
|
90
|
+
let workspaceInfo;
|
|
91
|
+
try {
|
|
92
|
+
workspaceInfo = getWorkspaceInfo();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return handleError('NOT_IN_WORKSPACE', 'Not in a workspace. Run "prlt init" first.');
|
|
96
|
+
}
|
|
97
|
+
const dbPath = path.join(workspaceInfo.path, '.proletariat', 'workspace.db');
|
|
98
|
+
const db = new Database(dbPath);
|
|
99
|
+
const executionStorage = new ExecutionStorage(db);
|
|
100
|
+
try {
|
|
101
|
+
// Get ticketId
|
|
102
|
+
let ticketId = args.ticketId;
|
|
103
|
+
if (!ticketId) {
|
|
104
|
+
// Show tickets that are in progress or review (have PRs)
|
|
105
|
+
const allTickets = await this.storage.listTickets(projectId);
|
|
106
|
+
const reviewableTickets = allTickets.filter(t => {
|
|
107
|
+
const hasPR = t.metadata?.pr_url;
|
|
108
|
+
const isInProgress = t.status === 'in_progress' || t.status === 'done';
|
|
109
|
+
return hasPR || isInProgress;
|
|
110
|
+
});
|
|
111
|
+
if (reviewableTickets.length === 0) {
|
|
112
|
+
db.close();
|
|
113
|
+
return handleError('NO_TICKETS', 'No reviewable tickets found. Tickets need a PR to be reviewed.');
|
|
114
|
+
}
|
|
115
|
+
const selected = await this.selectFromList({
|
|
116
|
+
message: 'Select ticket to review:',
|
|
117
|
+
items: reviewableTickets,
|
|
118
|
+
getName: (t) => `${t.id} - ${t.title} ${t.metadata?.pr_url ? '(has PR)' : ''}`,
|
|
119
|
+
getValue: (t) => t.id,
|
|
120
|
+
getCommand: (t) => `prlt work review ${t.id} --json`,
|
|
121
|
+
jsonMode: jsonMode ? { flags, commandName: 'work review' } : null,
|
|
122
|
+
});
|
|
123
|
+
if (!selected) {
|
|
124
|
+
db.close();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
ticketId = selected;
|
|
128
|
+
}
|
|
129
|
+
// Get ticket
|
|
130
|
+
const ticket = await this.storage.getTicket(ticketId);
|
|
131
|
+
if (!ticket) {
|
|
132
|
+
db.close();
|
|
133
|
+
return handleError('TICKET_NOT_FOUND', `Ticket "${ticketId}" not found.`);
|
|
134
|
+
}
|
|
135
|
+
// Find the PR - either from ticket metadata or by branch
|
|
136
|
+
let prUrl = ticket.metadata?.pr_url;
|
|
137
|
+
if (!prUrl && ticket.branch) {
|
|
138
|
+
// Try to find PR by branch
|
|
139
|
+
const prInfo = getPRForBranch(ticket.branch);
|
|
140
|
+
if (prInfo) {
|
|
141
|
+
prUrl = prInfo.url;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!prUrl) {
|
|
145
|
+
db.close();
|
|
146
|
+
return handleError('NO_PR', `Ticket "${ticketId}" has no PR. Create one first with "prlt work ready ${ticketId} --pr".`);
|
|
147
|
+
}
|
|
148
|
+
const maxCycles = flags['max-cycles'];
|
|
149
|
+
const autoMode = flags.auto;
|
|
150
|
+
const pollInterval = (flags['poll-interval'] || 10) * 1000;
|
|
151
|
+
this.log('');
|
|
152
|
+
this.log(styles.header('Review Pipeline'));
|
|
153
|
+
this.log(styles.muted(` Ticket: ${ticket.id} - ${ticket.title}`));
|
|
154
|
+
this.log(styles.muted(` PR: ${prUrl}`));
|
|
155
|
+
this.log(styles.muted(` Max cycles: ${maxCycles}`));
|
|
156
|
+
this.log(styles.muted(` Mode: ${autoMode ? 'fully automated' : 'interactive'}`));
|
|
157
|
+
this.log('');
|
|
158
|
+
// Pipeline loop
|
|
159
|
+
for (let cycle = 1; cycle <= maxCycles; cycle++) {
|
|
160
|
+
this.log(styles.header(`Cycle ${cycle}/${maxCycles}: Review Phase`));
|
|
161
|
+
this.log('');
|
|
162
|
+
// === REVIEW PHASE ===
|
|
163
|
+
// Spawn a review agent
|
|
164
|
+
const reviewArgs = this.buildStartArgs(ticketId, flags, 'review');
|
|
165
|
+
this.log(styles.muted('Spawning review agent...'));
|
|
166
|
+
try {
|
|
167
|
+
await this.config.runCommand('work:start', reviewArgs);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
this.log(styles.error(`Failed to spawn review agent: ${error instanceof Error ? error.message : error}`));
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
// Wait for the review agent to complete
|
|
174
|
+
this.log(styles.muted('Waiting for review agent to complete...'));
|
|
175
|
+
const reviewCompleted = await this.waitForAgentCompletion(ticketId, executionStorage, pollInterval);
|
|
176
|
+
if (!reviewCompleted) {
|
|
177
|
+
this.log(styles.warning('Review agent did not complete within timeout. Stopping pipeline.'));
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
this.log(styles.success('Review agent completed.'));
|
|
181
|
+
this.log('');
|
|
182
|
+
// Check PR feedback
|
|
183
|
+
this.log(styles.muted('Checking review results...'));
|
|
184
|
+
const feedback = getPRFeedback(prUrl);
|
|
185
|
+
if (!feedback) {
|
|
186
|
+
this.log(styles.warning('Could not fetch PR feedback. Stopping pipeline.'));
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
const verdict = this.getReviewVerdict(feedback);
|
|
190
|
+
if (verdict === 'APPROVED') {
|
|
191
|
+
this.log(styles.success('PR APPROVED! No fixes needed.'));
|
|
192
|
+
this.log('');
|
|
193
|
+
if (jsonMode) {
|
|
194
|
+
outputSuccessAsJson({
|
|
195
|
+
ticketId: ticketId,
|
|
196
|
+
prUrl,
|
|
197
|
+
verdict: 'APPROVED',
|
|
198
|
+
cycles: cycle,
|
|
199
|
+
}, createMetadata('work review', flags));
|
|
200
|
+
}
|
|
201
|
+
db.close();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
this.log(styles.warning(`Review verdict: ${verdict}`));
|
|
205
|
+
this.log(styles.muted(` Reviews: ${feedback.reviews.length}`));
|
|
206
|
+
// Show review summary
|
|
207
|
+
for (const review of feedback.reviews) {
|
|
208
|
+
if (review.state === 'CHANGES_REQUESTED' || review.state === 'COMMENTED') {
|
|
209
|
+
this.log(styles.muted(` ${review.author}: ${review.state}`));
|
|
210
|
+
if (review.comments.length > 0) {
|
|
211
|
+
this.log(styles.muted(` ${review.comments.length} comment(s)`));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
this.log('');
|
|
216
|
+
// Check if we've reached max cycles
|
|
217
|
+
if (cycle === maxCycles) {
|
|
218
|
+
this.log(styles.warning(`Reached maximum cycles (${maxCycles}). Stopping pipeline.`));
|
|
219
|
+
this.log(styles.muted('Review feedback remains on the PR for manual follow-up.'));
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
// === FIX PHASE ===
|
|
223
|
+
if (!autoMode) {
|
|
224
|
+
const shouldFix = await this.selectFromList({
|
|
225
|
+
message: `Issues found in cycle ${cycle}. Auto-fix and re-review?`,
|
|
226
|
+
items: [
|
|
227
|
+
{ id: 'yes', name: 'Yes, spawn fix agent and re-review' },
|
|
228
|
+
{ id: 'fix-only', name: 'Fix only (no re-review)' },
|
|
229
|
+
{ id: 'no', name: 'No, stop pipeline' },
|
|
230
|
+
],
|
|
231
|
+
getName: (item) => item.name,
|
|
232
|
+
getValue: (item) => item.id,
|
|
233
|
+
getCommand: () => '',
|
|
234
|
+
jsonMode: jsonMode ? { flags, commandName: 'work review' } : null,
|
|
235
|
+
});
|
|
236
|
+
if (shouldFix === 'no' || !shouldFix) {
|
|
237
|
+
this.log(styles.muted('Pipeline stopped by user.'));
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
if (shouldFix === 'fix-only') {
|
|
241
|
+
// Spawn fix agent but don't re-review
|
|
242
|
+
this.log(styles.header(`Cycle ${cycle}/${maxCycles}: Fix Phase (final)`));
|
|
243
|
+
await this.runFixPhase(ticketId, flags, executionStorage, pollInterval);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
this.log(styles.header(`Cycle ${cycle}/${maxCycles}: Fix Phase`));
|
|
248
|
+
this.log('');
|
|
249
|
+
const fixCompleted = await this.runFixPhase(ticketId, flags, executionStorage, pollInterval);
|
|
250
|
+
if (!fixCompleted) {
|
|
251
|
+
this.log(styles.warning('Fix agent did not complete. Stopping pipeline.'));
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
this.log(styles.success('Fix agent completed. Proceeding to re-review...'));
|
|
255
|
+
this.log('');
|
|
256
|
+
// Small delay before re-review to let GitHub update
|
|
257
|
+
await this.sleep(3000);
|
|
258
|
+
}
|
|
259
|
+
// Final status
|
|
260
|
+
this.log('');
|
|
261
|
+
const finalFeedback = getPRFeedback(prUrl);
|
|
262
|
+
const finalVerdict = finalFeedback ? this.getReviewVerdict(finalFeedback) : 'UNKNOWN';
|
|
263
|
+
this.log(styles.header('Pipeline Complete'));
|
|
264
|
+
this.log(styles.muted(` Final status: ${finalVerdict}`));
|
|
265
|
+
this.log(styles.muted(` PR: ${prUrl}`));
|
|
266
|
+
this.log('');
|
|
267
|
+
if (jsonMode) {
|
|
268
|
+
outputSuccessAsJson({
|
|
269
|
+
ticketId: ticketId,
|
|
270
|
+
prUrl,
|
|
271
|
+
verdict: finalVerdict,
|
|
272
|
+
}, createMetadata('work review', flags));
|
|
273
|
+
}
|
|
274
|
+
db.close();
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
db.close();
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Build args for `work:start` command.
|
|
283
|
+
*/
|
|
284
|
+
buildStartArgs(ticketId, flags, action) {
|
|
285
|
+
const startArgs = [
|
|
286
|
+
ticketId,
|
|
287
|
+
'--action', action,
|
|
288
|
+
'--ephemeral',
|
|
289
|
+
'--display', flags.display || 'background',
|
|
290
|
+
'--yes',
|
|
291
|
+
];
|
|
292
|
+
if (flags.executor)
|
|
293
|
+
startArgs.push('--executor', flags.executor);
|
|
294
|
+
if (flags['run-on-host'])
|
|
295
|
+
startArgs.push('--run-on-host');
|
|
296
|
+
if (flags['skip-permissions'])
|
|
297
|
+
startArgs.push('--skip-permissions');
|
|
298
|
+
else
|
|
299
|
+
startArgs.push('--permission-mode', 'danger');
|
|
300
|
+
if (flags.session)
|
|
301
|
+
startArgs.push('--session', flags.session);
|
|
302
|
+
// Always pass --force for pipeline (multiple agents may work on same ticket across cycles)
|
|
303
|
+
startArgs.push('--force');
|
|
304
|
+
// Pass project if available
|
|
305
|
+
const projectId = flags.project;
|
|
306
|
+
if (projectId)
|
|
307
|
+
startArgs.push('--project', projectId);
|
|
308
|
+
return startArgs;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Run the fix phase: spawn review-fix agent and wait for completion.
|
|
312
|
+
*/
|
|
313
|
+
async runFixPhase(ticketId, flags, executionStorage, pollInterval) {
|
|
314
|
+
const fixArgs = this.buildStartArgs(ticketId, flags, 'review-fix');
|
|
315
|
+
this.log(styles.muted('Spawning fix agent...'));
|
|
316
|
+
try {
|
|
317
|
+
await this.config.runCommand('work:start', fixArgs);
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
this.log(styles.error(`Failed to spawn fix agent: ${error instanceof Error ? error.message : error}`));
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
this.log(styles.muted('Waiting for fix agent to complete...'));
|
|
324
|
+
return this.waitForAgentCompletion(ticketId, executionStorage, pollInterval);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Wait for the most recent execution on a ticket to complete.
|
|
328
|
+
* Polls the execution storage and checks tmux session existence.
|
|
329
|
+
*/
|
|
330
|
+
async waitForAgentCompletion(ticketId, executionStorage, pollInterval) {
|
|
331
|
+
const startTime = Date.now();
|
|
332
|
+
while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
|
|
333
|
+
// Clean up stale executions first
|
|
334
|
+
executionStorage.cleanupStaleExecutions();
|
|
335
|
+
// Check if there's still a running execution for this ticket
|
|
336
|
+
const runningExec = executionStorage.getRunningExecution(ticketId);
|
|
337
|
+
if (!runningExec) {
|
|
338
|
+
// No running execution - agent has completed (or was never started)
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
// Check if the tmux session still exists
|
|
342
|
+
if (runningExec.sessionId) {
|
|
343
|
+
const sessionExists = this.checkTmuxSession(runningExec.sessionId, runningExec.environment, runningExec.containerId);
|
|
344
|
+
if (!sessionExists) {
|
|
345
|
+
// Session is gone - mark as completed
|
|
346
|
+
executionStorage.updateStatus(runningExec.id, 'completed');
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Log progress periodically
|
|
351
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
352
|
+
if (elapsed > 0 && elapsed % 60 === 0) {
|
|
353
|
+
this.log(styles.muted(` Still waiting... (${elapsed}s elapsed)`));
|
|
354
|
+
}
|
|
355
|
+
await this.sleep(pollInterval);
|
|
356
|
+
}
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Check if a tmux session exists.
|
|
361
|
+
*/
|
|
362
|
+
checkTmuxSession(sessionId, environment, containerId) {
|
|
363
|
+
try {
|
|
364
|
+
if (environment === 'devcontainer' && containerId) {
|
|
365
|
+
execSync(`docker exec ${containerId} tmux has-session -t "${sessionId}"`, { stdio: 'pipe' });
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
execSync(`tmux has-session -t "${sessionId}"`, { stdio: 'pipe' });
|
|
369
|
+
}
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Get the review verdict from PR feedback.
|
|
378
|
+
*/
|
|
379
|
+
getReviewVerdict(feedback) {
|
|
380
|
+
// Check review decision first (aggregated by GitHub)
|
|
381
|
+
if (feedback.reviewDecision) {
|
|
382
|
+
return feedback.reviewDecision;
|
|
383
|
+
}
|
|
384
|
+
// Check individual reviews - most recent takes precedence
|
|
385
|
+
const sortedReviews = [...feedback.reviews].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
386
|
+
for (const review of sortedReviews) {
|
|
387
|
+
if (review.state === 'APPROVED')
|
|
388
|
+
return 'APPROVED';
|
|
389
|
+
if (review.state === 'CHANGES_REQUESTED')
|
|
390
|
+
return 'CHANGES_REQUESTED';
|
|
391
|
+
}
|
|
392
|
+
// If there are any comments, treat as needing attention
|
|
393
|
+
if (feedback.reviews.some(r => r.comments.length > 0) || feedback.comments.length > 0) {
|
|
394
|
+
return 'COMMENTED';
|
|
395
|
+
}
|
|
396
|
+
return 'UNKNOWN';
|
|
397
|
+
}
|
|
398
|
+
sleep(ms) {
|
|
399
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -1357,26 +1357,35 @@ export default class WorkSpawn extends PMOCommand {
|
|
|
1357
1357
|
batchOutput = 'interactive';
|
|
1358
1358
|
}
|
|
1359
1359
|
// Prompt for permissions mode if not explicitly set via --skip-permissions flag
|
|
1360
|
+
// Non-code-modifying actions (review, review-comment, groom) default to safe mode
|
|
1361
|
+
// to prevent agents from performing destructive operations like merging PRs
|
|
1362
|
+
const spawnActionModifiesCode = selectedActionDetails?.modifiesCode ?? true;
|
|
1360
1363
|
if (!flags['skip-permissions']) {
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1364
|
+
if (!spawnActionModifiesCode) {
|
|
1365
|
+
// Non-code-modifying actions automatically use safe mode
|
|
1366
|
+
batchPermissionMode = 'safe';
|
|
1367
|
+
}
|
|
1368
|
+
else {
|
|
1369
|
+
// Use FlagResolver for permission mode
|
|
1370
|
+
const permissionResolver = new FlagResolver({
|
|
1371
|
+
commandName: 'work spawn',
|
|
1372
|
+
baseCommand: 'prlt work spawn',
|
|
1373
|
+
jsonMode,
|
|
1374
|
+
flags: {},
|
|
1375
|
+
});
|
|
1376
|
+
permissionResolver.addPrompt({
|
|
1377
|
+
flagName: 'permissionMode',
|
|
1378
|
+
type: 'list',
|
|
1379
|
+
message: `Permission mode for ${executorName}:`,
|
|
1380
|
+
default: 'danger',
|
|
1381
|
+
choices: () => [
|
|
1382
|
+
{ name: '⚠️ danger - Skip permission checks (faster, container provides isolation)', value: 'danger' },
|
|
1383
|
+
{ name: '🔒 safe - Requires approval for dangerous operations', value: 'safe' },
|
|
1384
|
+
],
|
|
1385
|
+
});
|
|
1386
|
+
const permissionResult = await permissionResolver.resolve();
|
|
1387
|
+
batchPermissionMode = permissionResult.permissionMode;
|
|
1388
|
+
}
|
|
1380
1389
|
}
|
|
1381
1390
|
// Prompt for PR creation if not provided AND action modifies code
|
|
1382
1391
|
// Resolution order: explicit flags > workspace config default > interactive prompt
|
|
@@ -1206,9 +1206,17 @@ export default class WorkStart extends PMOCommand {
|
|
|
1206
1206
|
}
|
|
1207
1207
|
// Prompt for permissions mode (all environments)
|
|
1208
1208
|
// Use FlagResolver to handle both JSON mode and interactive prompts consistently
|
|
1209
|
+
// Non-code-modifying actions (review, review-comment, groom) default to safe mode
|
|
1210
|
+
// to prevent agents from performing destructive operations like merging PRs
|
|
1211
|
+
const actionModifiesCode = context.modifiesCode !== false;
|
|
1212
|
+
const defaultPermissionMode = actionModifiesCode ? 'danger' : 'safe';
|
|
1209
1213
|
if (flags['permission-mode']) {
|
|
1210
1214
|
sandboxed = flags['permission-mode'] === 'safe';
|
|
1211
1215
|
}
|
|
1216
|
+
else if (!actionModifiesCode) {
|
|
1217
|
+
// Non-code-modifying actions automatically use safe mode
|
|
1218
|
+
sandboxed = true;
|
|
1219
|
+
}
|
|
1212
1220
|
else {
|
|
1213
1221
|
const containerNote = environment === 'devcontainer'
|
|
1214
1222
|
? ' (container provides additional isolation)'
|
|
@@ -1225,7 +1233,7 @@ export default class WorkStart extends PMOCommand {
|
|
|
1225
1233
|
flagName: 'permission-mode',
|
|
1226
1234
|
type: 'list',
|
|
1227
1235
|
message: `Permission mode for ${executorName}${containerNote}:`,
|
|
1228
|
-
default:
|
|
1236
|
+
default: defaultPermissionMode,
|
|
1229
1237
|
choices: () => [
|
|
1230
1238
|
{ name: '⚠️ danger - Skip permission checks (faster, container provides isolation)', value: 'danger' },
|
|
1231
1239
|
{ name: '🔒 safe - Requires approval for dangerous operations', value: 'safe' },
|
|
@@ -1980,9 +1988,11 @@ export default class WorkStart extends PMOCommand {
|
|
|
1980
1988
|
const hasDevcontainer = hasDevcontainerConfig(agentDir);
|
|
1981
1989
|
const useDevcontainer = hasDevcontainer && !flags['run-on-host'];
|
|
1982
1990
|
// Non-interactive defaults
|
|
1991
|
+
// Non-code-modifying actions default to safe mode to prevent destructive operations
|
|
1983
1992
|
const environment = useDevcontainer ? 'devcontainer' : 'host';
|
|
1984
1993
|
const displayMode = 'terminal';
|
|
1985
|
-
const
|
|
1994
|
+
const actionModifiesCode = context.modifiesCode !== false;
|
|
1995
|
+
const sandboxed = flags['permission-mode'] === 'safe' || (!flags['permission-mode'] && !actionModifiesCode);
|
|
1986
1996
|
const executor = flags.executor || DEFAULT_EXECUTION_CONFIG.defaultExecutor;
|
|
1987
1997
|
const outputMode = 'interactive';
|
|
1988
1998
|
// Handle git branch - only if action modifies code
|
package/dist/hooks/init.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateBetterSqlite3NativeBinding } from '../lib/database/native-validation.js';
|
|
1
2
|
import { readMachineConfig } from '../lib/machine-config.js';
|
|
2
3
|
import { findHQRoot } from '../lib/workspace.js';
|
|
3
4
|
/**
|
|
@@ -9,16 +10,22 @@ import { findHQRoot } from '../lib/workspace.js';
|
|
|
9
10
|
* - AND they're not currently inside a valid HQ directory
|
|
10
11
|
*/
|
|
11
12
|
const hook = async function ({ id, argv, config }) {
|
|
12
|
-
//
|
|
13
|
+
// Commands that work without an HQ still run native module checks.
|
|
13
14
|
const hqOptionalCommands = ['init', 'commit', 'claude', 'pmo:init'];
|
|
14
|
-
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
15
|
+
const isHqOptionalCommand = !!id && hqOptionalCommands.some(cmd => id === cmd || id.startsWith(cmd + ':'));
|
|
17
16
|
// Skip when running under oclif tooling (manifest, readme generation)
|
|
18
17
|
// These run commands to scan metadata and should not trigger the init flow
|
|
19
18
|
if (process.env.OCLIF_COMPILATION || process.argv[1]?.includes('oclif')) {
|
|
20
19
|
return;
|
|
21
20
|
}
|
|
21
|
+
// Skip when in test environments that provide their own HQ
|
|
22
|
+
if (process.env.PRLT_HQ_PATH && process.env.PRLT_TEST_ENV) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Skip init redirect when explicitly disabled (e.g., e2e test isolation)
|
|
26
|
+
if (process.env.PRLT_SKIP_INIT_REDIRECT === '1') {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
22
29
|
// Skip when --help or --version flags are present - these should always be available
|
|
23
30
|
// Check both process.argv (production CLI) and the oclif-provided argv
|
|
24
31
|
// (programmatic invocation via @oclif/test runCommand)
|
|
@@ -28,6 +35,9 @@ const hook = async function ({ id, argv, config }) {
|
|
|
28
35
|
argv?.includes('--version') || argv?.includes('-v')) {
|
|
29
36
|
return;
|
|
30
37
|
}
|
|
38
|
+
if (shouldValidateNativeModules(id)) {
|
|
39
|
+
await validateBetterSqlite3NativeBinding({ context: `command "${id}"` });
|
|
40
|
+
}
|
|
31
41
|
// Skip for help-related commands/flags
|
|
32
42
|
// When user runs just `prlt` with no args, id is undefined
|
|
33
43
|
if (!id || id === 'help') {
|
|
@@ -42,7 +52,7 @@ const hook = async function ({ id, argv, config }) {
|
|
|
42
52
|
return;
|
|
43
53
|
}
|
|
44
54
|
// For all other commands, check if first-time user
|
|
45
|
-
if (isFirstTimeUser()) {
|
|
55
|
+
if (!isHqOptionalCommand && isFirstTimeUser()) {
|
|
46
56
|
const chalk = await import('chalk');
|
|
47
57
|
console.log(chalk.default.yellow('\n⚠️ No headquarters found. Let\'s set one up first.\n'));
|
|
48
58
|
// Run init command - in TTY it prompts interactively,
|
|
@@ -53,6 +63,17 @@ const hook = async function ({ id, argv, config }) {
|
|
|
53
63
|
process.exit(0);
|
|
54
64
|
}
|
|
55
65
|
};
|
|
66
|
+
function shouldValidateNativeModules(id) {
|
|
67
|
+
if (!id) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return !(id === 'help' ||
|
|
71
|
+
id.startsWith('help:') ||
|
|
72
|
+
id === 'plugins' ||
|
|
73
|
+
id.startsWith('plugins:') ||
|
|
74
|
+
id === 'autocomplete' ||
|
|
75
|
+
id.startsWith('autocomplete:'));
|
|
76
|
+
}
|
|
56
77
|
/**
|
|
57
78
|
* Check if this is a first-time user (no headquarters configured)
|
|
58
79
|
*/
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caffeinate management for macOS
|
|
3
|
+
*
|
|
4
|
+
* Manages a background `caffeinate` process to prevent macOS from sleeping.
|
|
5
|
+
* PID state is stored in a runtime file for reliable stop/status operations.
|
|
6
|
+
*/
|
|
7
|
+
export interface CaffeinateState {
|
|
8
|
+
pid: number;
|
|
9
|
+
startedAt: string;
|
|
10
|
+
flags: string[];
|
|
11
|
+
duration?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Ensure the platform is macOS. Throws a descriptive error on other platforms.
|
|
15
|
+
*/
|
|
16
|
+
export declare function assertMacOS(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Check whether the platform is macOS.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isMacOS(): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Read the stored caffeinate state from the PID file.
|
|
23
|
+
* Returns null if no state exists or the file is invalid.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readState(): CaffeinateState | null;
|
|
26
|
+
/**
|
|
27
|
+
* Write caffeinate state to the PID file.
|
|
28
|
+
*/
|
|
29
|
+
export declare function writeState(state: CaffeinateState): void;
|
|
30
|
+
/**
|
|
31
|
+
* Remove the PID file.
|
|
32
|
+
*/
|
|
33
|
+
export declare function clearState(): void;
|
|
34
|
+
/**
|
|
35
|
+
* Check whether a process with the given PID is still running.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isProcessRunning(pid: number): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Get the status of the managed caffeinate process.
|
|
40
|
+
* Cleans up stale state if the process is no longer running.
|
|
41
|
+
*/
|
|
42
|
+
export declare function getStatus(): {
|
|
43
|
+
running: boolean;
|
|
44
|
+
state: CaffeinateState | null;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Start a background caffeinate process.
|
|
48
|
+
*
|
|
49
|
+
* @param flags - Additional flags to pass to caffeinate (e.g. ['-d', '-i'])
|
|
50
|
+
* @param duration - Optional duration in seconds (passed as -t flag)
|
|
51
|
+
* @returns The state of the started process
|
|
52
|
+
* @throws If already running (idempotent guard) or on non-macOS
|
|
53
|
+
*/
|
|
54
|
+
export declare function startCaffeinate(flags?: string[], duration?: number): CaffeinateState;
|
|
55
|
+
/**
|
|
56
|
+
* Stop the managed caffeinate process.
|
|
57
|
+
*
|
|
58
|
+
* @returns true if a process was stopped, false if nothing was running
|
|
59
|
+
*/
|
|
60
|
+
export declare function stopCaffeinate(): boolean;
|
|
61
|
+
export declare const _internals: {
|
|
62
|
+
RUNTIME_DIR: string;
|
|
63
|
+
PID_FILE: string;
|
|
64
|
+
};
|