@proletariat/cli 0.3.47 → 0.3.49

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.
Files changed (74) hide show
  1. package/dist/commands/caffeinate/index.d.ts +10 -0
  2. package/dist/commands/caffeinate/index.js +64 -0
  3. package/dist/commands/caffeinate/start.d.ts +14 -0
  4. package/dist/commands/caffeinate/start.js +86 -0
  5. package/dist/commands/caffeinate/status.d.ts +10 -0
  6. package/dist/commands/caffeinate/status.js +55 -0
  7. package/dist/commands/caffeinate/stop.d.ts +10 -0
  8. package/dist/commands/caffeinate/stop.js +47 -0
  9. package/dist/commands/claude/index.js +21 -21
  10. package/dist/commands/claude/open.js +1 -1
  11. package/dist/commands/commit.js +10 -8
  12. package/dist/commands/config/index.js +4 -5
  13. package/dist/commands/execution/config.d.ts +2 -2
  14. package/dist/commands/execution/config.js +18 -18
  15. package/dist/commands/execution/list.js +2 -2
  16. package/dist/commands/execution/view.js +2 -2
  17. package/dist/commands/init.js +9 -1
  18. package/dist/commands/orchestrator/attach.js +64 -14
  19. package/dist/commands/orchestrator/start.d.ts +5 -5
  20. package/dist/commands/orchestrator/start.js +45 -35
  21. package/dist/commands/orchestrator/status.js +64 -23
  22. package/dist/commands/orchestrator/stop.js +44 -12
  23. package/dist/commands/qa/index.js +12 -12
  24. package/dist/commands/session/attach.js +23 -0
  25. package/dist/commands/session/poke.js +1 -1
  26. package/dist/commands/staff/add.js +1 -1
  27. package/dist/commands/work/index.js +4 -0
  28. package/dist/commands/work/linear.d.ts +24 -0
  29. package/dist/commands/work/linear.js +218 -0
  30. package/dist/commands/work/revise.js +8 -8
  31. package/dist/commands/work/spawn.js +29 -20
  32. package/dist/commands/work/start.js +22 -12
  33. package/dist/commands/work/watch.js +3 -3
  34. package/dist/hooks/init.js +8 -0
  35. package/dist/lib/agents/index.js +2 -2
  36. package/dist/lib/caffeinate.d.ts +64 -0
  37. package/dist/lib/caffeinate.js +146 -0
  38. package/dist/lib/database/drizzle-schema.d.ts +7 -7
  39. package/dist/lib/database/drizzle-schema.js +1 -1
  40. package/dist/lib/execution/codex-adapter.d.ts +96 -0
  41. package/dist/lib/execution/codex-adapter.js +148 -0
  42. package/dist/lib/execution/config.d.ts +6 -6
  43. package/dist/lib/execution/config.js +17 -10
  44. package/dist/lib/execution/devcontainer.d.ts +3 -3
  45. package/dist/lib/execution/devcontainer.js +3 -3
  46. package/dist/lib/execution/index.d.ts +1 -0
  47. package/dist/lib/execution/index.js +1 -0
  48. package/dist/lib/execution/runners.d.ts +2 -2
  49. package/dist/lib/execution/runners.js +69 -26
  50. package/dist/lib/execution/spawner.js +3 -3
  51. package/dist/lib/execution/storage.d.ts +2 -2
  52. package/dist/lib/execution/storage.js +3 -3
  53. package/dist/lib/execution/types.d.ts +2 -2
  54. package/dist/lib/execution/types.js +1 -1
  55. package/dist/lib/external-issues/index.d.ts +1 -1
  56. package/dist/lib/external-issues/index.js +1 -1
  57. package/dist/lib/external-issues/linear.d.ts +43 -0
  58. package/dist/lib/external-issues/linear.js +261 -0
  59. package/dist/lib/external-issues/types.d.ts +67 -0
  60. package/dist/lib/external-issues/types.js +41 -0
  61. package/dist/lib/init/index.d.ts +4 -0
  62. package/dist/lib/init/index.js +11 -1
  63. package/dist/lib/machine-config.d.ts +1 -0
  64. package/dist/lib/machine-config.js +6 -3
  65. package/dist/lib/pmo/schema.d.ts +1 -1
  66. package/dist/lib/pmo/schema.js +1 -1
  67. package/dist/lib/pmo/storage/actions.js +3 -3
  68. package/dist/lib/pmo/storage/base.js +116 -6
  69. package/dist/lib/pmo/storage/epics.js +1 -1
  70. package/dist/lib/pmo/storage/tickets.js +2 -2
  71. package/dist/lib/pmo/storage/types.d.ts +2 -1
  72. package/dist/lib/repos/index.js +1 -1
  73. package/oclif.manifest.json +3052 -2721
  74. package/package.json +1 -1
@@ -0,0 +1,261 @@
1
+ import { ExternalIssueAdapterError, toNormalizedEnvelope, } from './types.js';
2
+ const DEFAULT_LINEAR_API_URL = 'https://api.linear.app/graphql';
3
+ const LINEAR_ISSUES_QUERY = `
4
+ query IssuesForSpawn($teamKey: String!, $first: Int!) {
5
+ issues(
6
+ first: $first
7
+ filter: {
8
+ team: { key: { eq: $teamKey } }
9
+ state: { type: { nin: ["completed", "canceled"] } }
10
+ }
11
+ orderBy: updatedAt
12
+ ) {
13
+ nodes {
14
+ id
15
+ identifier
16
+ title
17
+ description
18
+ url
19
+ priority
20
+ labels {
21
+ nodes {
22
+ name
23
+ }
24
+ }
25
+ state {
26
+ name
27
+ type
28
+ }
29
+ }
30
+ }
31
+ }
32
+ `;
33
+ const LINEAR_ISSUE_BY_IDENTIFIER_QUERY = `
34
+ query IssueForSpawn($id: String!) {
35
+ issue(id: $id) {
36
+ id
37
+ identifier
38
+ title
39
+ description
40
+ url
41
+ priority
42
+ labels {
43
+ nodes {
44
+ name
45
+ }
46
+ }
47
+ state {
48
+ name
49
+ type
50
+ }
51
+ }
52
+ }
53
+ `;
54
+ function priorityFromLinear(value) {
55
+ switch (value) {
56
+ case 1:
57
+ return 'P0';
58
+ case 2:
59
+ return 'P1';
60
+ case 3:
61
+ return 'P2';
62
+ case 4:
63
+ return 'P3';
64
+ default:
65
+ return null;
66
+ }
67
+ }
68
+ function ensureLinearConfig(config) {
69
+ const apiKey = config.apiKey || process.env.LINEAR_API_KEY || process.env.PRLT_LINEAR_API_KEY;
70
+ const team = config.team || process.env.PRLT_LINEAR_TEAM || process.env.LINEAR_TEAM_KEY;
71
+ const apiUrl = config.apiUrl || process.env.PRLT_LINEAR_API_URL || DEFAULT_LINEAR_API_URL;
72
+ if (!apiKey) {
73
+ throw new ExternalIssueAdapterError('MISSING_CONFIG', 'Missing Linear API key. Set LINEAR_API_KEY or PRLT_LINEAR_API_KEY.');
74
+ }
75
+ if (!team) {
76
+ throw new ExternalIssueAdapterError('MISSING_CONFIG', 'Missing Linear team key. Pass --team or set PRLT_LINEAR_TEAM.');
77
+ }
78
+ return { apiKey, team, apiUrl };
79
+ }
80
+ function ensureLinearIssueShape(issue) {
81
+ if (!issue.id || !issue.identifier || !issue.title || !issue.url) {
82
+ throw new ExternalIssueAdapterError('BAD_PAYLOAD', 'Linear issue payload is missing required fields (id, identifier, title, url).', issue);
83
+ }
84
+ }
85
+ /**
86
+ * Normalize a raw Linear API issue node into a canonical IssueEnvelope.
87
+ */
88
+ export function normalizeLinearIssue(rawIssue) {
89
+ if (!rawIssue || typeof rawIssue !== 'object') {
90
+ throw new ExternalIssueAdapterError('BAD_PAYLOAD', 'Linear issue payload is invalid.', rawIssue);
91
+ }
92
+ const issue = rawIssue;
93
+ ensureLinearIssueShape(issue);
94
+ const labels = (issue.labels?.nodes || [])
95
+ .map(label => label.name?.trim())
96
+ .filter((name) => Boolean(name));
97
+ const [projectKey] = issue.identifier.split('-');
98
+ return {
99
+ source: 'linear',
100
+ external_id: issue.id,
101
+ external_key: issue.identifier,
102
+ title: issue.title,
103
+ description: issue.description || '',
104
+ labels,
105
+ priority: priorityFromLinear(issue.priority),
106
+ status: issue.state?.name || 'Unknown',
107
+ url: issue.url,
108
+ project_key: projectKey || 'UNKNOWN',
109
+ assignee: null,
110
+ item_type: 'issue',
111
+ raw: rawIssue,
112
+ };
113
+ }
114
+ /**
115
+ * Normalize a raw Linear issue into a PMO-ready NormalizedIssueEnvelope.
116
+ */
117
+ export function normalizeLinearIssueToEnvelope(rawIssue) {
118
+ const envelope = normalizeLinearIssue(rawIssue);
119
+ return toNormalizedEnvelope(envelope, 'feature');
120
+ }
121
+ /**
122
+ * Build a PMO ticket description from a NormalizedIssueEnvelope.
123
+ */
124
+ export function buildLinearTicketDescription(envelope) {
125
+ const body = envelope.description.trim();
126
+ const metadataLines = [
127
+ `- Source: ${envelope.source.name}`,
128
+ `- External key: ${envelope.source.externalKey}`,
129
+ `- External id: ${envelope.source.externalId}`,
130
+ `- URL: ${envelope.source.url}`,
131
+ `- Status: ${envelope.status}`,
132
+ `- Priority: ${envelope.priority || 'Unset'}`,
133
+ `- Labels: ${envelope.labels.length > 0 ? envelope.labels.join(', ') : 'None'}`,
134
+ ];
135
+ const parts = [
136
+ body,
137
+ '## External Issue Context',
138
+ metadataLines.join('\n'),
139
+ ].filter(Boolean);
140
+ return parts.join('\n\n');
141
+ }
142
+ /**
143
+ * Build ticket metadata from a NormalizedIssueEnvelope for traceability.
144
+ */
145
+ export function buildLinearMetadata(envelope) {
146
+ return {
147
+ external_source: envelope.source.name,
148
+ external_key: envelope.source.externalKey,
149
+ external_id: envelope.source.externalId,
150
+ external_url: envelope.source.url,
151
+ external_raw: JSON.stringify(envelope.source.raw),
152
+ };
153
+ }
154
+ /**
155
+ * Build a spawn context message from a NormalizedIssueEnvelope.
156
+ */
157
+ export function buildLinearSpawnContextMessage(envelope, additionalMessage) {
158
+ const lines = [
159
+ `External issue source: ${envelope.source.name}`,
160
+ `External issue key: ${envelope.source.externalKey}`,
161
+ `External issue id: ${envelope.source.externalId}`,
162
+ `External issue URL: ${envelope.source.url}`,
163
+ ];
164
+ if (additionalMessage?.trim()) {
165
+ lines.push('', additionalMessage.trim());
166
+ }
167
+ return lines.join('\n');
168
+ }
169
+ /**
170
+ * Build a CLI command string for selecting a specific Linear issue.
171
+ */
172
+ export function buildLinearIssueChoiceCommand(issueIdentifier, projectId) {
173
+ let command = `prlt work linear --issue ${issueIdentifier} --json`;
174
+ if (projectId) {
175
+ command += ` -P ${projectId}`;
176
+ }
177
+ return command;
178
+ }
179
+ /**
180
+ * Fetch a single Linear issue by identifier (for example, ENG-123) and normalize it.
181
+ */
182
+ export async function getLinearIssueByIdentifier(configInput, identifier, options) {
183
+ const config = ensureLinearConfig(configInput);
184
+ const fetchImpl = options?.fetchImpl || fetch;
185
+ const response = await fetchImpl(config.apiUrl, {
186
+ method: 'POST',
187
+ headers: {
188
+ 'Content-Type': 'application/json',
189
+ Authorization: config.apiKey,
190
+ },
191
+ body: JSON.stringify({
192
+ query: LINEAR_ISSUE_BY_IDENTIFIER_QUERY,
193
+ variables: {
194
+ id: identifier,
195
+ },
196
+ }),
197
+ });
198
+ if (response.status === 401 || response.status === 403) {
199
+ throw new ExternalIssueAdapterError('AUTH_FAILED', 'Linear authentication failed. Verify your LINEAR_API_KEY token.');
200
+ }
201
+ if (!response.ok) {
202
+ throw new ExternalIssueAdapterError('REQUEST_FAILED', `Linear request failed with status ${response.status}.`);
203
+ }
204
+ const payload = await response.json();
205
+ if (payload.errors && payload.errors.length > 0) {
206
+ const message = payload.errors[0]?.message || 'Unknown Linear API error.';
207
+ if (/auth|token|forbidden|unauthorized/i.test(message)) {
208
+ throw new ExternalIssueAdapterError('AUTH_FAILED', `Linear authentication failed: ${message}`);
209
+ }
210
+ // "not found" should be treated as null, not hard failure.
211
+ if (/not found/i.test(message)) {
212
+ return null;
213
+ }
214
+ throw new ExternalIssueAdapterError('REQUEST_FAILED', `Linear API error: ${message}`);
215
+ }
216
+ const node = payload.data?.issue;
217
+ if (!node)
218
+ return null;
219
+ return normalizeLinearIssueToEnvelope(node);
220
+ }
221
+ /**
222
+ * Fetch and normalize Linear issues into NormalizedIssueEnvelopes.
223
+ */
224
+ export async function listLinearIssues(configInput, options) {
225
+ const config = ensureLinearConfig(configInput);
226
+ const fetchImpl = options?.fetchImpl || fetch;
227
+ const limit = Math.max(1, Math.min(options?.limit ?? 20, 100));
228
+ const response = await fetchImpl(config.apiUrl, {
229
+ method: 'POST',
230
+ headers: {
231
+ 'Content-Type': 'application/json',
232
+ Authorization: config.apiKey,
233
+ },
234
+ body: JSON.stringify({
235
+ query: LINEAR_ISSUES_QUERY,
236
+ variables: {
237
+ teamKey: config.team,
238
+ first: limit,
239
+ },
240
+ }),
241
+ });
242
+ if (response.status === 401 || response.status === 403) {
243
+ throw new ExternalIssueAdapterError('AUTH_FAILED', 'Linear authentication failed. Verify your LINEAR_API_KEY token.');
244
+ }
245
+ if (!response.ok) {
246
+ throw new ExternalIssueAdapterError('REQUEST_FAILED', `Linear request failed with status ${response.status}.`);
247
+ }
248
+ const payload = await response.json();
249
+ if (payload.errors && payload.errors.length > 0) {
250
+ const message = payload.errors[0]?.message || 'Unknown Linear API error.';
251
+ if (/auth|token|forbidden|unauthorized/i.test(message)) {
252
+ throw new ExternalIssueAdapterError('AUTH_FAILED', `Linear authentication failed: ${message}`);
253
+ }
254
+ throw new ExternalIssueAdapterError('REQUEST_FAILED', `Linear API error: ${message}`);
255
+ }
256
+ const nodes = payload.data?.issues?.nodes;
257
+ if (!Array.isArray(nodes)) {
258
+ throw new ExternalIssueAdapterError('BAD_PAYLOAD', 'Linear response payload was missing issues.nodes.', payload);
259
+ }
260
+ return nodes.map(normalizeLinearIssueToEnvelope);
261
+ }
@@ -142,3 +142,70 @@ export declare class ExternalIssueError extends Error {
142
142
  validationErrors?: IssueValidationError[] | undefined;
143
143
  constructor(code: ExternalIssueErrorCode, message: string, source?: IssueSource | undefined, validationErrors?: IssueValidationError[] | undefined);
144
144
  }
145
+ /**
146
+ * Error codes for adapter-level operations (config, auth, payload, request).
147
+ */
148
+ export type ExternalIssueAdapterErrorCode = 'MISSING_CONFIG' | 'AUTH_FAILED' | 'BAD_PAYLOAD' | 'REQUEST_FAILED';
149
+ /**
150
+ * Typed error for external issue adapter operations.
151
+ *
152
+ * Covers config, auth, payload, and request failures that occur
153
+ * when interacting with a specific external issue source.
154
+ */
155
+ export declare class ExternalIssueAdapterError extends Error {
156
+ readonly code: ExternalIssueAdapterErrorCode;
157
+ readonly causeDetail?: unknown | undefined;
158
+ constructor(code: ExternalIssueAdapterErrorCode, message: string, causeDetail?: unknown | undefined);
159
+ }
160
+ /**
161
+ * Source metadata nested object for traceability.
162
+ */
163
+ export interface IssueSourceMetadata {
164
+ /** Which external system the issue came from */
165
+ name: IssueSource;
166
+ /** Unique identifier in the external system (e.g., Linear UUID) */
167
+ externalId: string;
168
+ /** Human-readable key in the external system (e.g., "ENG-123") */
169
+ externalKey: string;
170
+ /** URL to view the issue in the external system */
171
+ url: string;
172
+ /** Original raw payload from the external system */
173
+ raw: Record<string, unknown>;
174
+ }
175
+ /**
176
+ * PMO-ready normalized issue envelope.
177
+ *
178
+ * Wraps a canonical IssueEnvelope with PMO-oriented fields (e.g., category)
179
+ * and a nested source metadata object for ergonomic access in commands.
180
+ *
181
+ * Produced by normalizing an IssueEnvelope into the shape expected by
182
+ * PMO ticket creation and the spawn context pipeline.
183
+ */
184
+ export interface NormalizedIssueEnvelope {
185
+ /** Nested source metadata for traceability */
186
+ source: IssueSourceMetadata;
187
+ /** Issue title / summary */
188
+ title: string;
189
+ /** Issue description (markdown or plain text) */
190
+ description: string;
191
+ /** Labels / tags applied to the issue */
192
+ labels: string[];
193
+ /** Priority level (normalized to P0-P3 scale, or null) */
194
+ priority: string | null;
195
+ /** Current status name in the external system */
196
+ status: string;
197
+ /** Project key in the external system */
198
+ projectKey: string;
199
+ /** Assignee display name or identifier */
200
+ assignee: string | null;
201
+ /** PMO ticket category derived from source metadata (e.g., "feature") */
202
+ category: string | null;
203
+ /** Source-native work item kind when available */
204
+ itemType?: string | null;
205
+ }
206
+ /**
207
+ * Convert a canonical IssueEnvelope to a NormalizedIssueEnvelope.
208
+ *
209
+ * Deterministic: same input always produces same output.
210
+ */
211
+ export declare function toNormalizedEnvelope(envelope: IssueEnvelope, category?: string | null): NormalizedIssueEnvelope;
@@ -24,3 +24,44 @@ export class ExternalIssueError extends Error {
24
24
  this.name = 'ExternalIssueError';
25
25
  }
26
26
  }
27
+ /**
28
+ * Typed error for external issue adapter operations.
29
+ *
30
+ * Covers config, auth, payload, and request failures that occur
31
+ * when interacting with a specific external issue source.
32
+ */
33
+ export class ExternalIssueAdapterError extends Error {
34
+ code;
35
+ causeDetail;
36
+ constructor(code, message, causeDetail) {
37
+ super(message);
38
+ this.code = code;
39
+ this.causeDetail = causeDetail;
40
+ this.name = 'ExternalIssueAdapterError';
41
+ }
42
+ }
43
+ /**
44
+ * Convert a canonical IssueEnvelope to a NormalizedIssueEnvelope.
45
+ *
46
+ * Deterministic: same input always produces same output.
47
+ */
48
+ export function toNormalizedEnvelope(envelope, category) {
49
+ return {
50
+ source: {
51
+ name: envelope.source,
52
+ externalId: envelope.external_id,
53
+ externalKey: envelope.external_key,
54
+ url: envelope.url,
55
+ raw: envelope.raw,
56
+ },
57
+ title: envelope.title,
58
+ description: envelope.description,
59
+ labels: envelope.labels,
60
+ priority: envelope.priority,
61
+ status: envelope.status,
62
+ projectKey: envelope.project_key,
63
+ assignee: envelope.assignee,
64
+ category: category ?? null,
65
+ itemType: envelope.item_type ?? null,
66
+ };
67
+ }
@@ -35,6 +35,10 @@ export declare function validateHQLocation(location: string): {
35
35
  valid: boolean;
36
36
  reason?: string;
37
37
  };
38
+ /**
39
+ * Check if an HQ name is already in use by another headquarters on this machine.
40
+ */
41
+ export declare function isHQNameTaken(name: string): boolean;
38
42
  /**
39
43
  * Prompt user for HQ name
40
44
  */
@@ -8,7 +8,7 @@ import { createAgentWorktrees } from '../agents/index.js';
8
8
  import { addRepositoriesToHQ, isInGitRepo } from '../repos/index.js';
9
9
  import { createPMO, } from '../pmo/index.js';
10
10
  import { createWorkspaceDatabase, addRepositoriesToDatabase, addAgentsToDatabase, createTheme, addThemeNames, setActiveTheme } from '../database/index.js';
11
- import { ensureMachineConfigDir, registerHeadquarters, getOrganizations, createOrganization, } from '../machine-config.js';
11
+ import { ensureMachineConfigDir, registerHeadquarters, getOrganizations, createOrganization, findHeadquartersByName, } from '../machine-config.js';
12
12
  import { hasGitHubRemote } from '../repos/git.js';
13
13
  import { isGHInstalled, isGHAuthenticated } from '../pr/index.js';
14
14
  /**
@@ -55,6 +55,13 @@ export function validateHQLocation(location) {
55
55
  }
56
56
  return { valid: true };
57
57
  }
58
+ /**
59
+ * Check if an HQ name is already in use by another headquarters on this machine.
60
+ */
61
+ export function isHQNameTaken(name) {
62
+ const existing = findHeadquartersByName(name);
63
+ return existing.length > 0;
64
+ }
58
65
  /**
59
66
  * Prompt user for HQ name
60
67
  */
@@ -74,6 +81,9 @@ export async function promptForHQName() {
74
81
  if (!/^[a-zA-Z0-9-_]+$/.test(input)) {
75
82
  return 'Name can only contain letters, numbers, hyphens, and underscores';
76
83
  }
84
+ if (isHQNameTaken(input.trim())) {
85
+ return `HQ name "${input.trim()}" is already in use on this machine. Pick another name.`;
86
+ }
77
87
  return true;
78
88
  },
79
89
  }]);
@@ -40,6 +40,7 @@ export interface MachineConfig {
40
40
  }
41
41
  /**
42
42
  * Get the path to the machine-level config directory (~/.proletariat/).
43
+ * Uses process.env.HOME when set (for testability), falling back to os.homedir().
43
44
  */
44
45
  export declare function getMachineConfigDir(): string;
45
46
  /**
@@ -5,9 +5,11 @@ import { isValidHQ } from './workspace.js';
5
5
  const CONFIG_VERSION = '1.0.0';
6
6
  /**
7
7
  * Get the path to the machine-level config directory (~/.proletariat/).
8
+ * Uses process.env.HOME when set (for testability), falling back to os.homedir().
8
9
  */
9
10
  export function getMachineConfigDir() {
10
- return path.join(os.homedir(), '.proletariat');
11
+ const home = process.env.HOME || os.homedir();
12
+ return path.join(home, '.proletariat');
11
13
  }
12
14
  /**
13
15
  * Get the path to the machine-level config file (~/.proletariat/config.json).
@@ -32,9 +34,10 @@ export function ensureMachineConfigDir() {
32
34
  * - Removes trailing slashes
33
35
  */
34
36
  export function normalizePath(inputPath) {
35
- // Expand ~ to home directory
37
+ // Expand ~ to home directory (respect process.env.HOME for testability)
38
+ const home = process.env.HOME || os.homedir();
36
39
  let resolved = inputPath.startsWith('~')
37
- ? path.join(os.homedir(), inputPath.slice(1))
40
+ ? path.join(home, inputPath.slice(1))
38
41
  : inputPath;
39
42
  // Make absolute
40
43
  resolved = path.resolve(resolved);
@@ -67,7 +67,7 @@ export declare const PMO_TABLE_SCHEMAS: {
67
67
  readonly project_specs: "\n CREATE TABLE IF NOT EXISTS pmo_project_specs (\n project_id TEXT NOT NULL REFERENCES pmo_projects(id) ON DELETE CASCADE,\n spec_id TEXT NOT NULL REFERENCES pmo_specs(id) ON DELETE CASCADE,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (project_id, spec_id)\n )";
68
68
  readonly cache_metadata: "\n CREATE TABLE IF NOT EXISTS pmo_cache_metadata (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n )";
69
69
  readonly settings: "\n CREATE TABLE IF NOT EXISTS pmo_settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n )";
70
- readonly agent_work: "\n CREATE TABLE IF NOT EXISTS agent_work (\n id TEXT PRIMARY KEY,\n ticket_id TEXT NOT NULL,\n agent_name TEXT NOT NULL,\n executor TEXT NOT NULL,\n environment TEXT NOT NULL DEFAULT 'host',\n display_mode TEXT NOT NULL DEFAULT 'terminal',\n sandboxed INTEGER NOT NULL DEFAULT 0,\n status TEXT NOT NULL DEFAULT 'starting',\n branch TEXT,\n pid TEXT,\n container_id TEXT,\n session_id TEXT,\n host TEXT,\n log_path TEXT,\n started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n completed_at TIMESTAMP,\n exit_code INTEGER,\n error_message TEXT,\n FOREIGN KEY (ticket_id) REFERENCES pmo_tickets(id) ON DELETE CASCADE\n )";
70
+ readonly agent_work: "\n CREATE TABLE IF NOT EXISTS agent_work (\n id TEXT PRIMARY KEY,\n ticket_id TEXT NOT NULL,\n agent_name TEXT NOT NULL,\n executor TEXT NOT NULL,\n environment TEXT NOT NULL DEFAULT 'host',\n display_mode TEXT NOT NULL DEFAULT 'terminal',\n permission_mode TEXT NOT NULL DEFAULT 'safe',\n status TEXT NOT NULL DEFAULT 'starting',\n branch TEXT,\n pid TEXT,\n container_id TEXT,\n session_id TEXT,\n host TEXT,\n log_path TEXT,\n started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n completed_at TIMESTAMP,\n exit_code INTEGER,\n error_message TEXT,\n FOREIGN KEY (ticket_id) REFERENCES pmo_tickets(id) ON DELETE CASCADE\n )";
71
71
  readonly containers: "\n CREATE TABLE IF NOT EXISTS containers (\n id TEXT PRIMARY KEY,\n agent_name TEXT NOT NULL,\n docker_id TEXT NOT NULL,\n docker_name TEXT,\n image TEXT,\n status TEXT NOT NULL DEFAULT 'unknown',\n current_execution_id TEXT,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (agent_name) REFERENCES agents(name) ON DELETE CASCADE,\n FOREIGN KEY (current_execution_id) REFERENCES agent_work(id) ON DELETE SET NULL\n )";
72
72
  readonly id_sequences: "\n CREATE TABLE IF NOT EXISTS id_sequences (\n table_name TEXT PRIMARY KEY,\n next_id INTEGER NOT NULL DEFAULT 1\n )";
73
73
  readonly statuses: "\n CREATE TABLE IF NOT EXISTS pmo_statuses (\n id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL,\n name TEXT NOT NULL,\n category TEXT NOT NULL,\n position INTEGER NOT NULL DEFAULT 0,\n color TEXT,\n description TEXT,\n is_default INTEGER NOT NULL DEFAULT 0,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (project_id) REFERENCES pmo_projects(id) ON DELETE CASCADE,\n UNIQUE(project_id, name)\n )";
@@ -328,7 +328,7 @@ export const PMO_TABLE_SCHEMAS = {
328
328
  executor TEXT NOT NULL,
329
329
  environment TEXT NOT NULL DEFAULT 'host',
330
330
  display_mode TEXT NOT NULL DEFAULT 'terminal',
331
- sandboxed INTEGER NOT NULL DEFAULT 0,
331
+ permission_mode TEXT NOT NULL DEFAULT 'safe',
332
332
  status TEXT NOT NULL DEFAULT 'starting',
333
333
  branch TEXT,
334
334
  pid TEXT,
@@ -165,10 +165,10 @@ export class ActionStorage {
165
165
  description: row.description || undefined,
166
166
  prompt: row.prompt,
167
167
  endPrompt: row.end_prompt || undefined,
168
- suggestedForCategories: row.default_category
169
- ? JSON.parse(row.default_category)
168
+ suggestedForCategories: row.suggested_for_categories
169
+ ? JSON.parse(row.suggested_for_categories)
170
170
  : undefined,
171
- defaultMoveToCategory: row.default_category,
171
+ defaultMoveToCategory: row.default_move_to_category,
172
172
  modifiesCode: row.modifies_code === 1,
173
173
  isBuiltin: row.is_builtin === 1,
174
174
  createdAt: new Date(row.created_at),
@@ -345,6 +345,37 @@ export function runMigrations(db) {
345
345
  // Non-critical migration - don't fail initialization
346
346
  }
347
347
  }
348
+ // Migration: Rename sandboxed column to permission_mode in agent_work table
349
+ if (tableExists(T.agent_work)) {
350
+ const awColumns = db.pragma(`table_info(${T.agent_work})`);
351
+ const awColumnNames = new Set(awColumns.map(c => c.name));
352
+ if (awColumnNames.has('sandboxed') && !awColumnNames.has('permission_mode')) {
353
+ try {
354
+ db.exec(`ALTER TABLE ${T.agent_work} ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'safe'`);
355
+ // Migrate existing data: sandboxed=1 → 'safe', sandboxed=0 → 'danger'
356
+ db.exec(`UPDATE ${T.agent_work} SET permission_mode = CASE WHEN sandboxed = 1 THEN 'safe' ELSE 'danger' END`);
357
+ }
358
+ catch {
359
+ // Column may already exist
360
+ }
361
+ }
362
+ }
363
+ // Migration: Rename execution.sandboxed setting to execution.permission_mode
364
+ if (tableExists('workspace_settings')) {
365
+ try {
366
+ const oldSetting = db.prepare(`SELECT value FROM workspace_settings WHERE key = 'execution.sandboxed'`).get();
367
+ if (oldSetting) {
368
+ const permMode = oldSetting.value === 'true' ? 'safe' : 'danger';
369
+ db.prepare(`
370
+ INSERT INTO workspace_settings (key, value) VALUES ('execution.permission_mode', ?)
371
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
372
+ `).run(permMode);
373
+ }
374
+ }
375
+ catch {
376
+ // Non-critical migration
377
+ }
378
+ }
348
379
  }
349
380
  /**
350
381
  * Seed built-in workflows from BUILTIN_TEMPLATES (single source of truth).
@@ -817,7 +848,16 @@ After reviewing, determine your verdict:
817
848
  - **REQUEST_CHANGES**: There are issues that must be fixed before merging
818
849
  - **COMMENT**: General feedback, no blocking issues but some suggestions
819
850
 
820
- Do NOT modify any code. Do NOT attempt to fix any issues. This is a read-only review — report your findings only.
851
+ ## STRICT RULES - READ CAREFULLY
852
+
853
+ - **DO NOT** merge the PR (\`gh pr merge\` is FORBIDDEN)
854
+ - **DO NOT** push any code (\`git push\` is FORBIDDEN)
855
+ - **DO NOT** run tests or test suites
856
+ - **DO NOT** modify, edit, or write any code files
857
+ - **DO NOT** create commits
858
+ - Your ONLY output should be a \`gh pr review\` comment
859
+ - This is a **read-only** review — read the diff, analyze it, post your review, and stop
860
+
821
861
  If you identify issues that need fixing, describe them in your review. A separate action (Review & Fix) will handle fixes.
822
862
 
823
863
  ${PRLT_COMMANDS_COMMON}
@@ -872,7 +912,7 @@ COMMENT - Some suggestions but no blocking issues."
872
912
 
873
913
  Format the body with: what looks good, concerns (if any), suggested improvements (if any), and your verdict.
874
914
 
875
- No commits are needed for code review.
915
+ **REMINDER:** Do NOT merge the PR. Do NOT run tests. Do NOT modify code. Only post the review comment above.
876
916
 
877
917
  **After posting your review**, if you found issues that need fixing, log them on the ticket so another action can address them:
878
918
  \`\`\`bash
@@ -884,6 +924,76 @@ prlt ticket edit <TICKET_ID> --add-subtask "Fix: <description of issue>"
884
924
  modifiesCode: false,
885
925
  position: 4,
886
926
  },
927
+ {
928
+ id: 'review-comment',
929
+ name: 'Review Comment',
930
+ description: 'Post review comments on a PR without merging, testing, or modifying code',
931
+ prompt: `${PRLT_USAGE_RULE}
932
+
933
+ ---
934
+
935
+ # Action: Review Comment
936
+
937
+ Read the PR diff, analyze the changes, and post a GitHub review comment. That is your ONLY job.
938
+
939
+ ## STRICT RULES - READ CAREFULLY
940
+
941
+ You are a **read-only** reviewer. You MUST follow these rules:
942
+
943
+ - **DO NOT** run \`gh pr merge\` — merging is FORBIDDEN
944
+ - **DO NOT** run \`git push\` — pushing is FORBIDDEN
945
+ - **DO NOT** run tests or test suites of any kind
946
+ - **DO NOT** modify, edit, or write any code files
947
+ - **DO NOT** create commits or branches
948
+ - **DO NOT** run any commands that change repository state
949
+ - Your **ONLY** permitted write operation is \`gh pr review\` to post your comment
950
+
951
+ ## What To Do
952
+
953
+ 1. Read the PR diff to understand the changes
954
+ 2. Analyze the code for bugs, issues, style, and correctness
955
+ 3. Post your review using \`gh pr review\` with the appropriate verdict
956
+ 4. **STOP** — do nothing else after posting the review
957
+
958
+ ${PRLT_COMMANDS_COMMON}
959
+ ${PRLT_COMMANDS_REVIEW}`,
960
+ endPrompt: `Post your review on the PR using \`gh pr review\`. This is the ONLY action you should take.
961
+
962
+ **If approving:**
963
+ \`\`\`bash
964
+ gh pr review --approve --body "## Review
965
+
966
+ ### Summary
967
+ - ...
968
+
969
+ APPROVED - Looks good to merge."
970
+ \`\`\`
971
+
972
+ **If requesting changes:**
973
+ \`\`\`bash
974
+ gh pr review --request-changes --body "## Review
975
+
976
+ ### Issues
977
+ - ...
978
+
979
+ REQUEST CHANGES - Please address the above."
980
+ \`\`\`
981
+
982
+ **If commenting:**
983
+ \`\`\`bash
984
+ gh pr review --comment --body "## Review
985
+
986
+ ### Feedback
987
+ - ...
988
+
989
+ COMMENT - Some suggestions, no blockers."
990
+ \`\`\`
991
+
992
+ **CRITICAL REMINDER:** After posting your review, STOP. Do NOT merge the PR. Do NOT run tests. Do NOT modify code. Do NOT push anything. Your job is done after the \`gh pr review\` command.`,
993
+ suggestedForCategories: ['completed'],
994
+ modifiesCode: false,
995
+ position: 5,
996
+ },
887
997
  {
888
998
  id: 'review-fix',
889
999
  name: 'Review & Fix',
@@ -943,7 +1053,7 @@ ${PRLT_COMMANDS_REVIEW}`,
943
1053
  \`\`\``,
944
1054
  suggestedForCategories: ['started', 'completed'],
945
1055
  modifiesCode: true,
946
- position: 5,
1056
+ position: 6,
947
1057
  },
948
1058
  {
949
1059
  id: 'revise',
@@ -1007,7 +1117,7 @@ The PR will be updated automatically with your pushed changes.`,
1007
1117
  suggestedForCategories: ['completed'],
1008
1118
  defaultMoveToCategory: 'started',
1009
1119
  modifiesCode: true,
1010
- position: 6,
1120
+ position: 7,
1011
1121
  },
1012
1122
  {
1013
1123
  id: 'explore-cli',
@@ -1168,7 +1278,7 @@ tmux_kill_session({ session: "qa-test" })
1168
1278
  \`\`\``,
1169
1279
  suggestedForCategories: [],
1170
1280
  modifiesCode: false,
1171
- position: 8,
1281
+ position: 9,
1172
1282
  },
1173
1283
  {
1174
1284
  id: 'test',
@@ -1213,7 +1323,7 @@ ${PRLT_COMMANDS_CODE}`,
1213
1323
  **IMPORTANT:** Use the global \`prlt\` command.`,
1214
1324
  suggestedForCategories: ['started', 'completed'],
1215
1325
  modifiesCode: true,
1216
- position: 7,
1326
+ position: 8,
1217
1327
  },
1218
1328
  ];
1219
1329
  // Use INSERT OR REPLACE to always update builtin actions with latest prompts
@@ -139,7 +139,7 @@ export class EpicStorage {
139
139
  updates.push('file_path = ?');
140
140
  params.push(changes.filePath);
141
141
  }
142
- if (changes.specId !== undefined) {
142
+ if ('specId' in changes) {
143
143
  updates.push('spec_id = ?');
144
144
  params.push(changes.specId || null);
145
145
  }