@proletariat/cli 0.3.46 → 0.3.47

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.
@@ -27,6 +27,7 @@ export default class Work extends PMOCommand {
27
27
  { id: 'resolve', name: 'Resolve questions (agent-assisted)', command: `prlt work resolve -P ${projectId} --json` },
28
28
  { id: 'spawn', name: 'Spawn work (batch by column)', command: `prlt work spawn -P ${projectId} --json` },
29
29
  { id: 'watch', name: 'Watch column (auto-spawn)', command: `prlt work watch -P ${projectId} --json` },
30
+ { id: 'review', name: 'Review pipeline (review → fix → re-review)', command: `prlt work review -P ${projectId} --json` },
30
31
  { id: 'ready', name: 'Mark work ready for review', command: `prlt work ready -P ${projectId} --json` },
31
32
  { id: 'complete', name: 'Mark work complete', command: `prlt work complete -P ${projectId} --json` },
32
33
  { id: 'cancel', name: 'Cancel', command: '' },
@@ -62,6 +63,9 @@ export default class Work extends PMOCommand {
62
63
  case 'watch':
63
64
  await this.config.runCommand('work:watch', projectArgs);
64
65
  break;
66
+ case 'review':
67
+ await this.config.runCommand('work:review', projectArgs);
68
+ break;
65
69
  case 'ready':
66
70
  await this.config.runCommand('work:ready', projectArgs);
67
71
  break;
@@ -0,0 +1,45 @@
1
+ import { PMOCommand } from '../../lib/pmo/index.js';
2
+ export default class WorkReview extends PMOCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ ticketId: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ 'max-cycles': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
+ auto: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ executor: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'run-on-host': import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ 'skip-permissions': import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ display: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
+ session: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
16
+ 'poll-interval': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
17
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ machine: import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
+ project: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
+ };
22
+ execute(): Promise<void>;
23
+ /**
24
+ * Build args for `work:start` command.
25
+ */
26
+ private buildStartArgs;
27
+ /**
28
+ * Run the fix phase: spawn review-fix agent and wait for completion.
29
+ */
30
+ private runFixPhase;
31
+ /**
32
+ * Wait for the most recent execution on a ticket to complete.
33
+ * Polls the execution storage and checks tmux session existence.
34
+ */
35
+ private waitForAgentCompletion;
36
+ /**
37
+ * Check if a tmux session exists.
38
+ */
39
+ private checkTmuxSession;
40
+ /**
41
+ * Get the review verdict from PR feedback.
42
+ */
43
+ private getReviewVerdict;
44
+ private sleep;
45
+ }
@@ -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
+ }
@@ -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,11 +10,9 @@ 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
- // Skip for commands that work without an HQ
13
+ // Commands that work without an HQ still run native module checks.
13
14
  const hqOptionalCommands = ['init', 'commit', 'claude', 'pmo:init'];
14
- if (id && hqOptionalCommands.some(cmd => id === cmd || id.startsWith(cmd + ':'))) {
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')) {
@@ -28,6 +27,9 @@ const hook = async function ({ id, argv, config }) {
28
27
  argv?.includes('--version') || argv?.includes('-v')) {
29
28
  return;
30
29
  }
30
+ if (shouldValidateNativeModules(id)) {
31
+ await validateBetterSqlite3NativeBinding({ context: `command "${id}"` });
32
+ }
31
33
  // Skip for help-related commands/flags
32
34
  // When user runs just `prlt` with no args, id is undefined
33
35
  if (!id || id === 'help') {
@@ -42,7 +44,7 @@ const hook = async function ({ id, argv, config }) {
42
44
  return;
43
45
  }
44
46
  // For all other commands, check if first-time user
45
- if (isFirstTimeUser()) {
47
+ if (!isHqOptionalCommand && isFirstTimeUser()) {
46
48
  const chalk = await import('chalk');
47
49
  console.log(chalk.default.yellow('\n⚠️ No headquarters found. Let\'s set one up first.\n'));
48
50
  // Run init command - in TTY it prompts interactively,
@@ -53,6 +55,17 @@ const hook = async function ({ id, argv, config }) {
53
55
  process.exit(0);
54
56
  }
55
57
  };
58
+ function shouldValidateNativeModules(id) {
59
+ if (!id) {
60
+ return false;
61
+ }
62
+ return !(id === 'help' ||
63
+ id.startsWith('help:') ||
64
+ id === 'plugins' ||
65
+ id.startsWith('plugins:') ||
66
+ id === 'autocomplete' ||
67
+ id.startsWith('autocomplete:'));
68
+ }
56
69
  /**
57
70
  * Check if this is a first-time user (no headquarters configured)
58
71
  */
@@ -0,0 +1,21 @@
1
+ export interface BetterSqlite3RuntimeInfo {
2
+ nodeVersion: string;
3
+ nodeMajor: number | null;
4
+ abi: string;
5
+ platform: NodeJS.Platform;
6
+ arch: string;
7
+ }
8
+ export interface BetterSqlite3ValidationOptions {
9
+ context: string;
10
+ loadModule?: () => Promise<{
11
+ default: new (path: string) => BetterSqlite3DatabaseLike;
12
+ }>;
13
+ }
14
+ interface BetterSqlite3DatabaseLike {
15
+ pragma(sql: string): unknown;
16
+ close(): void;
17
+ }
18
+ export declare function getBetterSqlite3RuntimeInfo(): BetterSqlite3RuntimeInfo;
19
+ export declare function buildBetterSqlite3ValidationMessage(cause: unknown, info: BetterSqlite3RuntimeInfo, context: string): string;
20
+ export declare function validateBetterSqlite3NativeBinding(options: BetterSqlite3ValidationOptions): Promise<void>;
21
+ export {};
@@ -0,0 +1,49 @@
1
+ const SUPPORTED_NODE_MAJORS = [20, 22, 23, 24, 25];
2
+ function parseNodeMajor(version) {
3
+ const match = /^v?(\d+)/.exec(version);
4
+ return match ? Number.parseInt(match[1], 10) : null;
5
+ }
6
+ export function getBetterSqlite3RuntimeInfo() {
7
+ return {
8
+ nodeVersion: process.version,
9
+ nodeMajor: parseNodeMajor(process.version),
10
+ abi: process.versions.modules,
11
+ platform: process.platform,
12
+ arch: process.arch,
13
+ };
14
+ }
15
+ export function buildBetterSqlite3ValidationMessage(cause, info, context) {
16
+ const reason = cause instanceof Error ? cause.message : String(cause);
17
+ const nodeMajorHint = info.nodeMajor === null || SUPPORTED_NODE_MAJORS.includes(info.nodeMajor)
18
+ ? null
19
+ : `- Unsupported Node major for this CLI: ${info.nodeMajor} (supported: ${SUPPORTED_NODE_MAJORS.join(', ')})`;
20
+ const lines = [
21
+ `better-sqlite3 native module validation failed (${context}).`,
22
+ `Runtime: node ${info.nodeVersion} (ABI ${info.abi}) on ${info.platform}-${info.arch}.`,
23
+ `Load error: ${reason}`,
24
+ '',
25
+ 'Fix steps:',
26
+ '1. Rebuild native bindings for the current runtime: `npm rebuild better-sqlite3`',
27
+ '2. Verify runtime architecture: `node -p "process.platform + \'-\' + process.arch + \' abi=\' + process.versions.modules"`',
28
+ '3. If globally installed, reinstall CLI with current Node: `npm uninstall -g @proletariat/cli && npm install -g @proletariat/cli`',
29
+ '4. If running tests from source, reinstall workspace deps: `pnpm install`',
30
+ ];
31
+ if (nodeMajorHint) {
32
+ lines.splice(2, 0, nodeMajorHint);
33
+ }
34
+ return lines.join('\n');
35
+ }
36
+ export async function validateBetterSqlite3NativeBinding(options) {
37
+ const loadModule = options.loadModule ?? (async () => import('better-sqlite3'));
38
+ const runtime = getBetterSqlite3RuntimeInfo();
39
+ try {
40
+ const mod = await loadModule();
41
+ const Database = mod.default;
42
+ const db = new Database(':memory:');
43
+ db.pragma('foreign_keys = ON');
44
+ db.close();
45
+ }
46
+ catch (error) {
47
+ throw new Error(buildBetterSqlite3ValidationMessage(error, runtime, options.context));
48
+ }
49
+ }
@@ -20,8 +20,11 @@ import { readDevcontainerJson } from './devcontainer.js';
20
20
  * Example: "TKT-347-implement-altman"
21
21
  */
22
22
  export function buildSessionName(context) {
23
- // Sanitize action name: replace spaces and special chars with hyphens for shell safety
24
- const action = (context.actionName || 'work').replace(/\s+/g, '-');
23
+ // Sanitize action name: strip non-alphanumeric chars for shell/tmux safety (& breaks paths)
24
+ const action = (context.actionName || 'work')
25
+ .replace(/[^a-zA-Z0-9._-]/g, '-')
26
+ .replace(/-+/g, '-')
27
+ .replace(/^-|-$/g, '');
25
28
  const agent = context.agentName || 'agent';
26
29
  return `${context.ticketId}-${action}-${agent}`;
27
30
  }
@@ -165,7 +168,10 @@ export function getExecutorCommand(executor, prompt, skipPermissions = true) {
165
168
  // Manual mode - will prompt for each action (still interactive, no -p)
166
169
  return { cmd: 'claude', args: [prompt] };
167
170
  case 'codex':
168
- return { cmd: 'codex', args: ['--prompt', prompt] };
171
+ // Map danger mode to Codex-native autonomy mode.
172
+ return skipPermissions
173
+ ? { cmd: 'codex', args: ['--yolo', '--prompt', prompt] }
174
+ : { cmd: 'codex', args: ['--prompt', prompt] };
169
175
  case 'aider':
170
176
  return { cmd: 'aider', args: ['--message', prompt] };
171
177
  case 'custom':
@@ -1242,7 +1248,7 @@ export function buildDevcontainerCommand(context, executor, promptFile, containe
1242
1248
  }
1243
1249
  else {
1244
1250
  // Non-Claude executors: use getExecutorCommand() to get correct command and args
1245
- const { cmd, args } = getExecutorCommand(executor, `PLACEHOLDER`, false);
1251
+ const { cmd, args } = getExecutorCommand(executor, `PLACEHOLDER`, !sandboxed);
1246
1252
  // Replace the placeholder prompt with a file read for shell safety
1247
1253
  const argsStr = args.map(a => a === 'PLACEHOLDER' ? `"$(cat ${promptFile})"` : a).join(' ');
1248
1254
  executorCmd = `${cmd} ${argsStr}`;
@@ -219,6 +219,42 @@ export function registerWorkTools(server, ctx) {
219
219
  return errorResponse(error);
220
220
  }
221
221
  });
222
+ strictTool(server, 'work_review', 'Run automated review-fix pipeline on a ticket: spawns review agent, checks results, auto-spawns fix agent if issues found, re-reviews until clean or max cycles reached. Requires ticket to have a PR.', {
223
+ ticket_id: z.string().describe('Ticket ID to review'),
224
+ max_cycles: z.number().optional().describe('Maximum review-fix cycles (default: 3)'),
225
+ skip_permissions: z.boolean().optional().describe('Skip permission prompts (default: false)'),
226
+ environment: z.enum(['devcontainer', 'host']).optional().describe('Execution environment (default: devcontainer)'),
227
+ }, async (params) => {
228
+ try {
229
+ const args = [params.ticket_id, '--auto'];
230
+ if (params.max_cycles) {
231
+ args.push('--max-cycles', String(params.max_cycles));
232
+ }
233
+ if (params.skip_permissions) {
234
+ args.push('--skip-permissions');
235
+ }
236
+ if (params.environment === 'host') {
237
+ args.push('--run-on-host');
238
+ }
239
+ const cmd = `prlt work review ${args.join(' ')}`;
240
+ const output = ctx.runCommand(cmd);
241
+ return {
242
+ content: [{
243
+ type: 'text',
244
+ text: JSON.stringify({
245
+ success: true,
246
+ ticketId: params.ticket_id,
247
+ command: cmd,
248
+ output,
249
+ message: `Review pipeline started for ${params.ticket_id}`,
250
+ }, null, 2),
251
+ }],
252
+ };
253
+ }
254
+ catch (error) {
255
+ return errorResponse(error);
256
+ }
257
+ });
222
258
  strictTool(server, 'work_spawn', 'Spawn work on a ticket using the full CLI pipeline (agent selection, Docker build, container creation, branch setup, tmux session). Shells out to "prlt work spawn" — works whenever prlt is installed, no workspace context needed in-process.', {
223
259
  ticket_id: z.string().describe('Ticket ID to spawn work for'),
224
260
  action: z.string().optional().describe('Action to perform (e.g., implement, groom, review, custom). Defaults to implement.'),